Lockdown: remote-only, read-only mode (10/13) - #213
Merged
Conversation
There was a problem hiding this comment.
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
lockdownconfig 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.
alex-clickhouse
force-pushed
the
config-refactor/10-lockdown
branch
from
July 28, 2026 12:11
c99ece8 to
c4e79d1
Compare
alex-clickhouse
force-pushed
the
config-refactor/10-lockdown
branch
from
July 28, 2026 12:36
c4e79d1 to
5b1cd93
Compare
alex-clickhouse
force-pushed
the
config-refactor/10-lockdown
branch
from
July 28, 2026 13:14
5b1cd93 to
be3b207
Compare
alex-clickhouse
force-pushed
the
config-refactor/10-lockdown
branch
from
July 28, 2026 14:23
be3b207 to
f3f12df
Compare
alex-clickhouse
force-pushed
the
config-refactor/10-lockdown
branch
from
July 28, 2026 14:54
f3f12df to
e7d7eca
Compare
alex-clickhouse
force-pushed
the
config-refactor/10-lockdown
branch
2 times, most recently
from
July 28, 2026 16:03
76460ea to
e93c6c2
Compare
alex-clickhouse
force-pushed
the
config-refactor/10-lockdown
branch
from
July 28, 2026 16:20
e93c6c2 to
3be1034
Compare
alex-clickhouse
force-pushed
the
config-refactor/10-lockdown
branch
from
July 28, 2026 18:40
3be1034 to
5079340
Compare
alex-clickhouse
force-pushed
the
config-refactor/10-lockdown
branch
2 times, most recently
from
July 29, 2026 09:18
0d88970 to
710da81
Compare
alex-clickhouse
force-pushed
the
config-refactor/10-lockdown
branch
from
July 29, 2026 09:31
710da81 to
87c32b3
Compare
alex-clickhouse
force-pushed
the
config-refactor/10-lockdown
branch
from
July 30, 2026 10:06
bcc5a83 to
8f31ba3
Compare
alex-clickhouse
force-pushed
the
config-refactor/10-lockdown
branch
from
July 31, 2026 08:19
8f31ba3 to
54df721
Compare
alex-clickhouse
force-pushed
the
config-refactor/10-lockdown
branch
from
July 31, 2026 08:36
54df721 to
af84fe0
Compare
pufit
force-pushed
the
config-refactor/10-lockdown
branch
from
August 1, 2026 02:33
af84fe0 to
b5474ab
Compare
pufit
force-pushed
the
config-refactor/10-lockdown
branch
from
August 1, 2026 02:34
b5474ab to
8ba9f19
Compare
pufit
force-pushed
the
config-refactor/10-lockdown
branch
from
August 1, 2026 02:46
8ba9f19 to
49005f0
Compare
pufit
force-pushed
the
config-refactor/10-lockdown
branch
from
August 1, 2026 02:58
49005f0 to
dc1e02c
Compare
pufit
force-pushed
the
config-refactor/10-lockdown
branch
from
August 1, 2026 02:58
dc1e02c to
8a9f66c
Compare
pufit
force-pushed
the
config-refactor/10-lockdown
branch
from
August 1, 2026 02:58
8a9f66c to
a428399
Compare
pufit
force-pushed
the
config-refactor/10-lockdown
branch
from
August 1, 2026 02:58
a428399 to
b094965
Compare
pufit
force-pushed
the
config-refactor/10-lockdown
branch
from
August 1, 2026 02:59
b094965 to
c6451b3
Compare
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>
The lockdown checks verified that `config/` resolves inside the workspace, then trusted everything under it. `settings.yaml` carries the lockdown flag itself, the auth secret and every cron path, and a symlink there was invisible: `lockdown_workspace_problems` returned `[]`, the instance came up reporting `lockdown = True`, and the tracked layer arrived from a file the reviewer never saw. With a relative link the target is an ordinary workspace file the agent may write directly, so "still inside the workspace" was not good enough. This is an omission against a rule the branch already set for itself — cron's `jobs_file`, `system_file` and `gate_plugins_dir` each get a per-file containment check, including their default names as symlinks, and a test comment already says the intent covers "settings.yaml and the gate plugins". It is not a sandbox escape: lockdown is a config-integrity control, the agent has an unfiltered shell, and installing the link needs access that already suffices on its own. What it defeats is the audit trail — a `120000` mode change is not a `lockdown` flip, and after merge nothing reported the layout. The write guard resolved its target, so a symlink at a reviewed *name* made it conclude the write was outside the subtree and allow it. Both views are now checked. The resolved one stays, and is what already refuses the opposite direction — a link in the workspace pointing into `config/`, a directory link, a `..` climb — so the two are paired deliberately, and the inverse cases are pinned by tests so a later refactor cannot swap one for the other. Also refuses a symlinked `config/` directory under lockdown. git does not descend into one, so `git status -- <ws>/config` reports nothing whatever is underneath: sync calls the workspace clean while the daemon loads settings.yaml and imports `cron/gates/*.py` from a directory the config repo has never held a file of. Verified rather than assumed — `_local_config_divergence` returns `([], [])` for an untracked gate plugin there. Problems are now collected instead of returning the first, so an operator fixing a layout sees the whole list. `config_validate` already calls this, so `nerve config validate` and sync's pre-merge validation inherit all of it. Co-Authored-By: Claude <noreply@anthropic.com>
This branch made `skills/` tracked config in one place and not in the other. `skills/manager.py` refuses create, update, delete and toggle under lockdown, while `tracked_config_write_refusal` scoped itself to `config/`, so `Write` to `skills/backdoor/SKILL.md` was allowed — as were the five root instruction files. `SkillManager.discover()` is not lockdown-gated and runs at startup and on every reload, so a planted file is indexed as a live model-invocable skill, with its own `allowed-tools` frontmatter, and the 403 on `create_skill` is never involved. No `Bash` required. That is the path the project's own standard says to close: `docs/config.md` argues refusing `Write`/`Edit` "closes the path an agent takes by default and by accident, not the one it takes deliberately". The "Bash exists" argument cannot excuse the asymmetry, because it would delete the `config/` guard too. This is not a sandbox and does not become one; the point is that a change which takes effect is visible in the PR carrying it. `REVIEWED_DIRS` and `REVIEWED_ROOT_FILES` name the surface once. Two constants and a helper, not a policy object. `PROMPT_FILES` is deliberately not reused: it includes `TASK.md`, runtime state the agent has to go on writing. Sync's dirty-state check now covers the same list, so the guard and the check cannot disagree about what a reviewed file is — a path one of them missed was a file sync merged over without reporting that the live copy differed from the one that passed validation. A submodule inside the surface is promoted from warning to blocking when locked, matching the rule already applied to gitignored files. `docs/config.md` said both things, in the same section: that toggling a skill fails with a 403, and that "writes elsewhere (memory, skills content, task files) are unaffected". The second was right about memory and tasks and wrong about skills. Widening the guard would have made `PUT /api/memory/file/SOUL.md` return 403 for a file the web UI offers to edit, so both read endpoints now report `read_only`, computed from the refusal itself rather than from a second copy of the rule, and the editor renders it read-only. One table of representative paths pins the write guard, the sync pathspec and the startup check against the same expectations, each asked behaviourally — the sync leg against a real git repo, so a mis-spelled pathspec fails it. Known and documented: the startup check covers subtrees, so a symlinked root instruction file still starts. The write guard resolves, so both the link and its target are refused meanwhile. Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
Lockdown makes an instance remote-only: every local change to the reviewed surface is refused, and changes arrive only as a merged commit that sync pulls in. Nothing checked that the second half was possible. `lockdown: true` on a workspace with no git remote — or no repository at all — refused every local edit while having no route to receive one, so the box kept whatever config it held at the moment it was locked, for good, with nothing reporting it. Refused at startup rather than by ignoring the flag. Treating a missing remote as "not locked after all" would make removing a remote an unlock, and the design already has two rules whose point is that there is no such path: the `NERVE_LOCKDOWN` anchor can lock but never unlock, and `lockdown` in `config.yaml` is ignored so a machine-local edit cannot turn it off. A remote of any name counts, not `origin` specifically: with `workspace_sync.branch` unset, sync resolves the current branch's own upstream, which may be on any remote. `workspace_sync.enabled` is not consulted — `nerve config sync` is a manual route that works on a locked box with the periodic loop off, so a remote is a route either way. Git exiting non-zero counts as no route, and its own error text is passed through, so a workspace the daemon cannot read for ownership reasons now says so instead of failing to fetch in silence. Existing locked test workspaces were bricked by this, which is the check working: the fixtures now `git init` and add a remote, rather than the check being narrowed to keep them passing. Co-Authored-By: Claude <noreply@anthropic.com>
pufit
force-pushed
the
config-refactor/10-lockdown
branch
from
August 1, 2026 02:59
c6451b3 to
cba2ba6
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
With
lockdownset, 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, andNERVE_LOCKDOWNin 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_LOCKDOWNlives 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.yamlnow 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.enabledadditionally 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.
Bashis 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.Added in review
Four commits. None of this is framed as closing a sandbox escape — lockdown is a
config-integrity control, the agent has an unfiltered shell, and that is
deliberate and tested. The argument throughout is the audit trail: a change that
takes effect should be visible in the PR carrying it.
Containment was checked per subtree, not per file.
settings.yamlcarriesthe lockdown flag itself, the auth secret and every cron path, and a symlink
there was invisible —
lockdown_workspace_problemsreturned[]and theinstance came up reporting
lockdown = Truewith its tracked layer arriving froma file no reviewer saw. With a relative link the target is an ordinary workspace
file the agent may write directly. This was an omission against a rule the branch
already set for itself: cron's
jobs_file,system_fileandgate_plugins_direach get a per-file check, including their default names as symlinks.
The write guard resolved its target, so a symlink at a reviewed name made it
conclude the write was outside the subtree and allow it. Both views are now
checked. The resolved one stays — it is what refuses the opposite direction, and
the inverse cases are pinned so a later refactor cannot swap one for the other.
A symlinked
config/directory is also refused now. git does not descend intoone, so
git status -- <ws>/configreports nothing whatever is underneath: synccalls the workspace clean while the daemon imports
cron/gates/*.pyfrom adirectory the config repo has never held a file of. Verified, not assumed.
skills/and the root instruction files were writable under lockdown. Thebranch made
skills/tracked config in one place and not the other:skills/manager.pyrefuses create/update/delete/toggle, while the write guardscoped itself to
config/. SoWritetoskills/backdoor/SKILL.mdwas allowed,discover()is not lockdown-gated and runs on every reload, and the planted filebecame a live model-invocable skill with its own
allowed-toolsfrontmatterwithout the 403 ever being involved. No
Bashrequired — which is the point,since the stated standard is closing the path an agent takes by default and by
accident.
REVIEWED_DIRSandREVIEWED_ROOT_FILESname the surface once; sync'sdirty-state check uses the same list, so the guard and the check cannot disagree
about what a reviewed file is. A submodule inside the surface is promoted from
warning to blocking when locked, matching the rule already applied to gitignored
files. The docs said both things in the same section.
Widening the guard would have made
PUT /api/memory/file/SOUL.mdreturn 403 fora file the web UI offers to edit, so both read endpoints now report
read_only,computed from the refusal itself, and the editor renders it read-only.
Lockdown now requires somewhere to sync from.
lockdown: trueon a workspacewith no git remote refused every local change while having no route to receive
one. Refused at startup rather than by ignoring the flag: treating a missing
remote as "not locked after all" would make removing a remote an unlock, and the
design already has two rules whose point is that no such path exists.
🤖 Generated with Claude Code