feat(security): let an unattended session declare what it may run when the approval classifier is down - #349
Merged
Conversation
…n the approval classifier is down `tools/approval.py::_smart_approve` fails CLOSED: every exception out of its `call_llm(task="approval")` becomes "escalate", which means ask a human. With a person at the keyboard that is right. In a delegated run there is nobody, so each flagged command spends the full `approvals.timeout` waiting for an approval that is never coming and is then BLOCKED — or returns `status: "pending_approval"` and halts the operation outright. Observed live on 2026-08-02 in a `hermes desktop spawn --delegated` session. `auxiliary.route` (#340) removed two of the triggers; it did not change the shape of the failure, which is that classifier UNAVAILABILITY degrades to "ask a human" rather than to a declared policy. A session may now carry an allowlist declared in its brief — commands plus the worktree they are scoped to. When the classifier cannot be reached, a command that matches is auto-approved by policy with the reason logged; everything else keeps escalating exactly as it does today. Scoped as narrowly as the failure: * Only when the classifier was UNREACHABLE. A working classifier that says ESCALATE is unsure, not absent, and still wants a human; DENY still denies. _smart_approve's return alphabet is unchanged — "was never consulted" travels beside the verdict on a thread-local the caller clears before each call, so every stubbed _smart_approve reports "available". * Not in interactive CLI, where the prompt reaches someone immediately. * Only for env_type == "local"; a declared root names a path on this machine. * Not for execute_code, which is a Python script with no argv to judge. Matching is on parsed argv plus the resolved working directory, never a regex over shell text. A character allowlist runs first, so chaining, command substitution, redirection, expansion, globbing, escapes and newlines are refused before parsing. Then every token must resolve inside the declared root, following symlinks, as must the cwd — and program-bearing commands (sh, python, sudo, env, ssh, docker, xargs, timeout) cannot be declared at all, because they run whatever their arguments name. Declared per delegation via `hermes desktop spawn --allow-command / --allow-command-root` (CLI -> spawn-control -> renderer -> session.create, mirroring the --toolsets/--goal pins, and failing the create rather than handing back a session that silently has no allowlist), or profile-wide via `approvals.delegated_allowlist` in config.yaml. Both empty by default: with no declaration the fail-closed path is byte-identical to today's. terminal_tool now resolves the run directory before the guard instead of after it, so an explicit `workdir=` is what a scoped rule is judged against. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.
The fail-closed site
tools/approval.py::_smart_approveends in a bareexcept Exceptionthat returns"escalate":"escalate"means ask a human. Underapprovals.mode: smartthat verdict reachescheck_all_command_guards(Phase 2.5) andcheck_execute_code_guard, and both then take the human-approval path. With a person at the keyboard that is exactly right, and it should stay that way.In an unattended run there is no person. Observed live on 2026-08-02, in a
hermes desktop spawn --delegatedsession: every flagged command spent the fullapprovals.timeout(60s) waiting for an approval nobody was going to give, and was then BLOCKED — or, with no notify callback registered, came straight back asstatus: "pending_approval"and halted the operation. The run never made a decision; it just stopped.Two triggers produced it. The classifier is itself an
auxiliary.*call, so on a single-slot local server it self-contended with the session's own in-flight turn and timed out; separately, an unkeyed auxiliary lane made it raise on every call. Both are now mitigated byauxiliary.route(#340). What #340 did not change is the shape of the failure: classifier unavailability still degrades to "ask a human" rather than to a declared policy. That is what this closes.Why an allowlist is the right degradation
The three obvious alternatives are all worse:
What is actually missing is a decision the operator already knows how to make. An operator spawning "run the Godot export in this worktree" knows in advance that
godotandgitinside that worktree are fine, and knows it before the run starts. The allowlist is a place to write that down, so the run degrades to the operator's own policy instead of to a stall.It is scoped as narrowly as the failure it answers:
_smart_approve's return alphabet is unchanged (a dozen tests and the plugin observer hooks key off it); "was never consulted" travels beside the verdict on a thread-local that the caller clears immediately before each call, so a stubbed_smart_approvealways reports "available".env_type == "local". A declared root names a directory on this machine; the same string means somewhere else inside a container or across SSH.execute_code. An allowlist names executables and a worktree; anexecute_codepayload is a Python script with no argv to judge. That path stays fail-closed.What the matcher refuses
Matching is on parsed argv plus the resolved working directory, never a regex or substring over the raw shell string — quoting alone defeats those.
tools/declared_allowlist.py, ~100 lines of policy, deliberately paranoid because the command text is written by a model that may itself be prompt-injected.A character allowlist runs first, before anything is parsed, so a command that could chain or substitute is refused on its syntax and never gets judged on a benign-looking first word:
git status ; rm -rf /tmp/gone;is not in the character allowlistgit status && rm -rf /tmp/gone,||git status | tee /etc/passwd,> /etc/passwdgit status $(rm -rf /tmp/gone), backticksgit status ${IFS}foogit clean -fdx *,~git status+ newline +rm ...git clean -fdx \; rm ...Then
shlex.split, and the remaining checks:git -C /etc status/etcresolves outside the declared rootgit clean -fdx /tmp,../outsidegit clean -fdx escapewhereescape→/somewhere/elsePath.resolve()follows the symlink and judges the targetgit --git-dir=/etc/git clean -fdx--opt=is checked tooGIT_DIR=/etc/git git clean -fdxgit -c core.pager=/bin/sh status,-c diff.external=python3,--pager=shgitrunning an undeclared shell/usr/bin/git,./gitwhengitwas declaredsh,python,sudo,env,ssh,docker,xargs,timeout…gitcould act on any repository on the machineEvery token after
argv[0]is treated as a possible path, including bare names —git add linkwherelinkis a symlink out of the worktree is precisely what a "does it look like a path?" heuristic would wave through. Tokens that genuinely are not paths resolve under the working directory, which is already inside the root, so ordinary invocations are unaffected.The
git -c core.pager=/bin/shrow came out of probing the finished matcher rather than reasoning about it, and it is worth calling out because the first version got it wrong:-cis a bare flag so it was skipped, andcore.pager=/bin/shdoes not start with-, so checking the whole token resolved it to an innocent-looking relative name under the worktree — while the half git actually executes is an absolute path to a shell. Both halves of akey=valueargument are now checked, and a program-bearing name is refused wherever it appears rather than only in the executable slot. There is a regression test per variant, and a companion test asserting the strictness costs none of the invocations people actually run (git commit -m 'ship it',git log --format=%H,git config user.name=me,godot --headless --export-release …).The cwd the matcher judges is the one the command will actually run in:
terminal_toolnow resolvesworkdir/session-cwd before the guard rather than after it. Judging the session default while an explicitworkdir=sent the command elsewhere would enforce the root against the wrong directory — there is a test for exactly that.Where a declaration comes from
Both surfaces are empty by default.
Per delegation (wins over the config default, never written to disk):
hermes desktop spawn --delegated \ --allow-command godot --allow-command git \ --allow-command-root /Users/me/Workspaces/game \ "Run the export and report what broke"CLI →
spawn-control.ts→ renderer →session.create(allowed_commands/allowed_command_root) →tools.approval.set_session_command_allowlist, keyed to the same session key the guards read back. This mirrors the--toolsets/--goalpins exactly, including the "a declaration that cannot be honored FAILS the create" rule — handing back a session that silently has no allowlist is the stall the caller was avoiding. Cleared on session teardown.Profile-wide, for a box that runs unattended (
config.yaml, not.env— this is behaviour, not a secret):Every policy approval is logged at WARNING with the command and the reason, and shows up in the transcript as an approval note. Nobody watched it happen, so the record has to stand on its own.
Unchanged
Noneand control falls through to the same fail-closed path.approvals.deny, the yolo/mode: offbypasses and the container fast-paths all run before any of this and are untouched.check_all_command_guards(~:3245) and the cron path are unchanged — neither stalls today, so neither needed a degradation._smart_approve's return contract is unchanged.Testing
scripts/run_tests.shonly.tests/tools/test_declared_command_allowlist.py(new, 74 tests) — the required coverage: no declaration → classifier failure still escalates; declared + unreachable + matching → policy-approved with a logged reason; declared + non-matching → still escalates; the evasion table above; plus a working-classifier-ESCALATE and a working-classifier-DENY case proving the allowlist does not override a reachable classifier, and an end-to-endterminal_toolpair proving the cwd plumbing is live rather than dead code. The classifier is made unavailable by havingagent.auxiliary_client.call_llmraise — the real fail-closed site, not a stubbed_smart_approve.tests/tui_gateway/test_declared_allowlist_session_scope.py(new) —session.createbinding, pairing/shape refusals, no session or lease left behind by a refused create, teardown cleanup.tests/hermes_cli/test_desktop_spawn.py,apps/desktop/electron/spawn-control.test.ts,apps/desktop/src/app/session/session-overrides.test.ts— wire shape and the flag combinations argparse cannot express.tests/tools/test_smart_approval_injection.py(22),tests/tools/test_execute_code_approval_cluster.py(27),tests/tools/test_approval_plugin_hooks.py,tests/tools/test_approval.py, the terminal-tool suites.npx vitest runon both touched files,tsc --noEmiton the renderer and electron projects,eslintclean.Full sweep of
tests/tools tests/tui_gateway tests/hermes_cli— 831 files, 18,301 passed / 19 failed. Every one of the 19 was re-run on a detachedorigin/mainworktree with none of this applied, and reproduces there:test_gateway_service.py,test_service_manager.py,test_signal_handler_kanban_worker.py,test_gateway_wsl.pytest_execution_flag_detection.pyman/sortvs the GNU invocation grammar the fixtures encodetest_file_tools.py,test_mcp_tool_issue_948.pyorigin/maintest_approval.py::test_nonrecursive_verification_artifact_cleanup_is_not_dangeroustempfile.gettempdir()mocked to/tmp; macOS resolves/private/tmptest_base_environment.py::TestAtomicSnapshotConcurrencyBehavioralorigin/maintootest_approved_command_clean_slate.py::test_execute_code_non_approved_still_interrupts_on_stale_bitorigin/mainfailOne trap worth writing down for anyone reproducing this locally:
run_tests.shre-exportsHOME, andtools/approval.pyloadscommand_allowlistfrom~/.hermes/config.yamlat import. A singlecommand_allowlist: ['execute_code']entry on the host — one "Always Approve" click anywhere on the machine, at any point — makesis_approved()short-circuit and turns 9 approval tests red with a bare{'approved': True, 'message': None}that reads exactly like a regression in this change. It bit me mid-session on files that had been green an hour earlier at the same commit. Run the approval files withHOME=$(mktemp -d) HERMES_PYTHON=<absolute path to the venv python> scripts/run_tests.sh …before believing a local failure there.Platform: macOS 15 (Darwin 25.6.0), Python 3.11 venv.
🤖 Generated with Claude Code