Lockdown: remote-only, read-only mode (10/13) - #213
Draft
alex-clickhouse wants to merge 1 commit into
Draft
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.
| "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 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
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 29, 2026 10:31
87c32b3 to
bb83145
Compare
alex-clickhouse
force-pushed
the
config-refactor/10-lockdown
branch
from
July 29, 2026 13:28
bb83145 to
e66dcd9
Compare
alex-clickhouse
force-pushed
the
config-refactor/10-lockdown
branch
from
July 30, 2026 08:01
e66dcd9 to
d6d56a4
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>
alex-clickhouse
force-pushed
the
config-refactor/10-lockdown
branch
from
July 30, 2026 08:25
d6d56a4 to
7357284
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.🤖 Generated with Claude Code