Skip to content

Lockdown: remote-only, read-only mode (10/13) - #213

Draft
alex-clickhouse wants to merge 1 commit into
config-refactor/09-git-syncfrom
config-refactor/10-lockdown
Draft

Lockdown: remote-only, read-only mode (10/13)#213
alex-clickhouse wants to merge 1 commit into
config-refactor/09-git-syncfrom
config-refactor/10-lockdown

Conversation

@alex-clickhouse

@alex-clickhouse alex-clickhouse commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

What

With lockdown set, configuration comes only from the tracked workspace layer — both machine-local layers are dropped — and the runtime write paths that would mutate tracked config refuse with a message saying what to do instead.

The flag is honoured only in settings.yaml, so a local edit cannot unlock an instance, and NERVE_LOCKDOWN in the service definition anchors it outside every file on the box. Load fails closed on a missing required secret. Machine-local keys the locked bundle leaves to the defaults are reported by name, at load and at validate time.

Why

An instance whose agent can edit its own configuration can edit the limits on what it may do. Lockdown makes the reviewed repo the only source of configuration, so changing behaviour requires a merged pull request rather than a local write.

That only holds if the flag itself is out of reach, which is why it is read from the tracked file alone and why the environment anchor exists: NERVE_LOCKDOWN lives in the systemd unit or the compose file, the one place neither the agent nor a config edit reaches. The anchor also selects the workspace, so the file that decides everything else cannot decide which file that is.

Dropping the machine layers has a consequence worth stating plainly: any key whose natural home is config.yaml now has nowhere to be said, and the declared default answers for it on every locked box — invisibly, in the diff of the change that turned lockdown on. So those keys are named while the config loads and while it validates, separated into values that are shareable and should be moved to the tracked layer, and values that are machine-local where the default may still be the wrong answer. telegram.enabled additionally flips its fallback under lockdown: the declared default is on, and a box where Telegram was deliberately switched off must not start serving DMs with full agent access because the shared settings happened to carry a resolvable token.

Scope, stated plainly: this covers the tools that name a path. Bash is auto-approved on the same code path, so this is an audit trail and a guard against the ordinary edit, not a sandbox. Filtering command strings would look like a boundary without being one; real sandboxing is separate work.

🤖 Generated with Claude Code

@alex-clickhouse alex-clickhouse changed the title Lockdown: remote-only, read-only mode Lockdown: remote-only, read-only mode (10/13) Jul 28, 2026
@alex-clickhouse
alex-clickhouse requested a review from Copilot July 28, 2026 10:00

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Implements “lockdown” mode to make instances remote-only and read-only with respect to git-tracked workspace config, tightening both config loading/validation and runtime write paths so a locked box only runs reviewed, synced configuration.

Changes:

  • Add lockdown config semantics: drop machine-local layers when locked, require/validate secrets, and enforce workspace/config subtree containment (incl. cron path containment and symlink escape protections).
  • Add runtime write guards (REST routes, agent tool backends, skills/tasks/memory/cron write paths) to refuse edits that would mutate tracked config under lockdown, with clearer HTTP/error reporting.
  • Tighten workspace sync + validation/reporting so locked instances refuse .gitignored config-subtree files, surface unresolved env refs even when tolerated, and report “merged but not applied” when reload fails.

Reviewed changes

Copilot reviewed 27 out of 27 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
tests/test_sync_service.py Adds coverage for locked sync behavior (ignored gate plugin refusal) and apply/reporting paths.
tests/test_lockdown.py Comprehensive tests for lockdown loading, anchoring, containment, and write guards across subsystems.
tests/test_backup.py Verifies config subtree is captured in backups but not restored/written back; tests restore allowlist.
nerve/templates/config/settings.yaml Documents lockdown behavior and operational guidance in the settings template.
nerve/tasks/manager.py Guards task “done” move from deleting tracked config under lockdown.
nerve/sync_service.py Adds locked sync mode, propagates validator info, and reloads config on apply with error reporting.
nerve/skills/manager.py Adds lockdown write guards and validates skill IDs to prevent path traversal deletes.
nerve/memory/manager.py Refuses writes into tracked config subtree under lockdown for caller-supplied paths.
nerve/gateway/server.py Adds exception handlers for LockdownError (403) and SkillIdError (400).
nerve/gateway/routes/tasks.py Guards task file writes (DB-provided path) from landing in tracked config under lockdown.
nerve/gateway/routes/memory.py Guards arbitrary workspace writes from landing in tracked config under lockdown.
nerve/gateway/routes/external_agents.py Blocks external-agent mutations under lockdown.
nerve/gateway/routes/config.py Passes locked into sync and avoids reporting “ok” when apply/reload failed.
nerve/gateway/auth.py Fail-closed authentication behavior when locked and jwt secret missing.
nerve/cron/service.py Warns when locked instance has no user cron entries due to legacy fallback being disabled.
nerve/cron/jobs.py Guards save_jobs so it can’t write tracked cron config under lockdown.
nerve/cron/gates.py Clarifies behavior rationale around gate build failures (documentation/comment change).
nerve/config.py Core lockdown implementation: anchor env vars, strict lockdown parsing, containment checks, secret guards, runtime write refusal helpers.
nerve/config_validate.py Adds locked-view validation controls (assume_locked), anchor awareness, unresolved-env reporting, and lockdown-specific checks.
nerve/cli.py Wires --assume-lockdown into nerve config validate; passes locked mode into sync.
nerve/channels/telegram.py Refuses pairing flow under lockdown and ensures no in-memory bypass.
nerve/backup.py Separates backup-collect vs restore-allowlist; blocks restoring workspace/config from bundles and warns.
nerve/agent/tools/handlers/tasks.py Blocks task write/update/done paths from mutating tracked config under lockdown.
nerve/agent/backends/codex/backend.py Adds best-effort lockdown refusal for codex file-change approvals when approvals are enabled.
nerve/agent/backends/claude.py Adds lockdown denial for path-naming write tools and repeats it in pre-tool hooks.
docs/cron.md Documents cron path containment rules under lockdown.
docs/config.md Adds end-to-end lockdown documentation, anchor behavior, CI validation guidance, and new CLI flag docs.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread tests/test_lockdown.py
"spelling", ["yess", "disabled", "${TG_ON:-nope}", "[]"],
)
def test_locked_reads_an_unparseable_value_as_off(self, tmp_path, spelling):
""""Unstated" was not the only way to reach the wrong answer.
Comment on lines +447 to +455
(gates / "local_unreviewed.py").write_text("MARKER = 1\n")

unlocked = sync_workspace(ws, tmp_path / "cfg", branch="main", validate=True)
assert unlocked.ok, unlocked.message
assert any("local_unreviewed.py" in w for w in unlocked.validation_warnings)

# Same tree, same commit — only lockdown differs.
self._git("reset", "-q", "--hard", "HEAD@{1}", cwd=ws)
locked = sync_workspace(
Comment thread nerve/sync_service.py
Comment on lines +568 to +570
except Exception as e: # noqa: BLE001 — a bad reload must not kill the loop
config_error = f"{type(e).__name__}: {e}"
logger.warning("config reload after sync failed: %s", e)
@alex-clickhouse
alex-clickhouse force-pushed the config-refactor/10-lockdown branch from c99ece8 to c4e79d1 Compare July 28, 2026 12:11
@alex-clickhouse
alex-clickhouse force-pushed the config-refactor/10-lockdown branch from c4e79d1 to 5b1cd93 Compare July 28, 2026 12:36
@alex-clickhouse
alex-clickhouse force-pushed the config-refactor/10-lockdown branch from 5b1cd93 to be3b207 Compare July 28, 2026 13:14
@alex-clickhouse
alex-clickhouse force-pushed the config-refactor/10-lockdown branch from be3b207 to f3f12df Compare July 28, 2026 14:23
@alex-clickhouse
alex-clickhouse force-pushed the config-refactor/10-lockdown branch from f3f12df to e7d7eca Compare July 28, 2026 14:54
@alex-clickhouse
alex-clickhouse force-pushed the config-refactor/10-lockdown branch 2 times, most recently from 76460ea to e93c6c2 Compare July 28, 2026 16:03
@alex-clickhouse
alex-clickhouse force-pushed the config-refactor/10-lockdown branch from e93c6c2 to 3be1034 Compare July 28, 2026 16:20
@alex-clickhouse
alex-clickhouse force-pushed the config-refactor/10-lockdown branch from 3be1034 to 5079340 Compare July 28, 2026 18:40
@alex-clickhouse
alex-clickhouse force-pushed the config-refactor/10-lockdown branch 2 times, most recently from 0d88970 to 710da81 Compare July 29, 2026 09:18
@alex-clickhouse
alex-clickhouse force-pushed the config-refactor/10-lockdown branch from 710da81 to 87c32b3 Compare July 29, 2026 09:31
@alex-clickhouse
alex-clickhouse force-pushed the config-refactor/10-lockdown branch from 87c32b3 to bb83145 Compare July 29, 2026 10:31
@alex-clickhouse
alex-clickhouse force-pushed the config-refactor/10-lockdown branch from bb83145 to e66dcd9 Compare July 29, 2026 13:28
@alex-clickhouse
alex-clickhouse force-pushed the config-refactor/10-lockdown branch from e66dcd9 to d6d56a4 Compare July 30, 2026 08:01
Under lockdown the two machine-local layers are dropped and only the synced,
tracked config applies, and runtime writes that would mutate tracked config are
refused with a clear error. The flag is anchored in the environment as well as in
the file, so an instance cannot be unlocked by editing the very config it is meant
to be constrained by.

Enabling lockdown no longer disables authentication: dropping the machine-local
layers dropped the auth secret along with them. Paths are contained — cron files
and the gate plugin directory must resolve inside <workspace>/config/, an escaping
override is ignored in favour of the in-workspace default, and if the default
itself escapes through a symlink the instance refuses to start rather than running
config nobody reviewed.

Agent write paths that reach config/ are refused instead of reporting a success
they did not earn, and the task read, write and move paths are guarded
consistently — including the one in the task manager that nothing imports yet, so
wiring it up later cannot reintroduce a deletion of tracked config. Backup
separates what restore accepts from what backup collects, so a restore cannot
smuggle files past the guard.

Co-Authored-By: Claude <noreply@anthropic.com>
@alex-clickhouse
alex-clickhouse force-pushed the config-refactor/10-lockdown branch from d6d56a4 to 7357284 Compare July 30, 2026 08:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants