Skip to content

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

Merged
pufit merged 5 commits into
mainfrom
config-refactor/10-lockdown
Aug 1, 2026
Merged

Lockdown: remote-only, read-only mode (10/13)#213
pufit merged 5 commits into
mainfrom
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.

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.yaml carries
the lockdown flag itself, the auth secret and every cron path, and a symlink
there was invisible — lockdown_workspace_problems returned [] and the
instance came up reporting lockdown = True with its tracked layer arriving from
a 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_file and gate_plugins_dir
each 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 into
one, so git status -- <ws>/config reports nothing whatever is underneath: sync
calls the workspace clean while the daemon imports cron/gates/*.py from a
directory the config repo has never held a file of. Verified, not assumed.

skills/ and the root instruction files were writable under lockdown. The
branch made skills/ tracked config in one place and not the other:
skills/manager.py refuses create/update/delete/toggle, while the write guard
scoped itself to config/. So Write to skills/backdoor/SKILL.md was allowed,
discover() is not lockdown-gated and runs on every reload, and the planted file
became a live model-invocable skill with its own allowed-tools frontmatter
without the 403 ever being involved. No Bash required — which is the point,
since the stated standard is closing the path an agent takes by default and by
accident.

REVIEWED_DIRS and REVIEWED_ROOT_FILES name the surface once; sync's
dirty-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.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, and the editor renders it read-only.

Lockdown now requires somewhere to sync from. lockdown: true on a workspace
with 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

@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 Outdated
Comment thread tests/test_sync_service.py
Comment thread nerve/sync_service.py Outdated
@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 bcc5a83 to 8f31ba3 Compare July 30, 2026 10:06
@alex-clickhouse
alex-clickhouse force-pushed the config-refactor/10-lockdown branch from 8f31ba3 to 54df721 Compare July 31, 2026 08:19
@alex-clickhouse
alex-clickhouse force-pushed the config-refactor/10-lockdown branch from 54df721 to af84fe0 Compare July 31, 2026 08:36
@pufit
pufit force-pushed the config-refactor/10-lockdown branch from af84fe0 to b5474ab Compare August 1, 2026 02:33
@pufit
pufit force-pushed the config-refactor/10-lockdown branch from b5474ab to 8ba9f19 Compare August 1, 2026 02:34
@pufit
pufit force-pushed the config-refactor/10-lockdown branch from 8ba9f19 to 49005f0 Compare August 1, 2026 02:46
@pufit
pufit force-pushed the config-refactor/10-lockdown branch from 49005f0 to dc1e02c Compare August 1, 2026 02:58
@pufit
pufit force-pushed the config-refactor/10-lockdown branch from dc1e02c to 8a9f66c Compare August 1, 2026 02:58
@pufit
pufit force-pushed the config-refactor/10-lockdown branch from 8a9f66c to a428399 Compare August 1, 2026 02:58
@pufit
pufit force-pushed the config-refactor/10-lockdown branch from a428399 to b094965 Compare August 1, 2026 02:58
@pufit
pufit force-pushed the config-refactor/10-lockdown branch from b094965 to c6451b3 Compare August 1, 2026 02:59
Base automatically changed from config-refactor/09-git-sync to main August 1, 2026 02:59
alex-clickhouse and others added 5 commits July 31, 2026 22:59
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
pufit force-pushed the config-refactor/10-lockdown branch from c6451b3 to cba2ba6 Compare August 1, 2026 02:59
@pufit
pufit merged commit 2eae0eb into main Aug 1, 2026
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.

3 participants