feat(approval): block agents from killing their own gateway/host process - #43157
feat(approval): block agents from killing their own gateway/host process#43157OmarB97 wants to merge 2 commits into
Conversation
Code review: clean — well-designed guardThis is a solid security hardening addition. A few observations:
No issues found. LGTM. |
egilewski
left a comment
There was a problem hiding this comment.
Request changes. The new guard only recognizes kill when it is the immediate command at the start of a command segment, so shell wrappers that still execute kill can bypass it. On PR head a08fe96a5cfd70200eeca4ec01e9357ba1e52cfd, these both return approved:
import os
from tools.approval import _check_self_host_kill, check_all_command_guards
for cmd in [f"command kill {os.getpid()}", f"builtin kill {os.getpid()}"]:
print(cmd, _check_self_host_kill(cmd), check_all_command_guards(cmd, "local"))Observed result: _check_self_host_kill(...) == (False, None) and check_all_command_guards(...) == {"approved": True, "message": None} for both forms. command kill <pid> and builtin kill <pid> are valid shell ways to invoke the kill builtin/executable, so an agent can still kill its own host PID without approval. This leaves the core self-host kill bypass open despite the new hardline guard in tools/approval.py around _KILL_CMD_RE.
Validation I ran:
git merge-tree --write-tree upstream/main refs/remotes/upstream/pr/43157=>f799ad298c61f4e64870ce67a40a515642a7ad5cgit diff --check upstream/main...refs/remotes/upstream/pr/43157passedpytest -q tests/tools/test_hardline_blocklist.py -p no:cacheprovider=> 110 passed- direct bypass probe above reproduced the remaining approval bypass
Signed: GPT-5.5-xhigh in Codex
|
Addressed in 2f05e2b9cd762a1423f0a38d6ff688b96a7e7672 (re: review 4472077377). Fix. Your probe on the new head: Tests. 5 new cases in Validation:
|
… guard Review follow-up on upstream PR NousResearch#43157: `command kill <pid>` and `builtin kill <pid>` execute kill but bypassed _KILL_CMD_RE, which only matched kill as the first word of a command segment. Anchor the kill guard at command position via _CMDPOS and add `command [-p]` / builtin to the shared wrapper inventory, so wrapper chains (sudo, env, exec, nohup, setsid, time, command, builtin) are consumed before the anchor. `command -v/-V` stays unmatched — it resolves a name without executing. tests/tools/test_hardline_blocklist.py: 5 new cases covering the reported bypass probes end-to-end, wrapper chains, foreign-PID allow, and command -v precision. 115 passed; approval cluster 370 passed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
egilewski
left a comment
There was a problem hiding this comment.
Recommendation: request changes
I reviewed this against current GitHub main fa7f24e8980367c2ca849eb99e1eb2331c7d3699, PR base 72154ad879e2acebbbf46e27f77a3a4bde3f2ea2, and PR head 2f05e2b9cd762a1423f0a38d6ff688b96a7e7672.
Validation:
git merge-tree --write-tree upstream/main 2f05e2b9cd762a1423f0a38d6ff688b96a7e7672: passed, produced tree96d2c613c756f9034f414113c7bf05478a59cfdf.git diff --check upstream/main...2f05e2b9cd762a1423f0a38d6ff688b96a7e7672: passed.- Direct guard probe on PR head:
_check_self_host_kill()andcheck_all_command_guards(..., "local")still approvecommand -- kill <pid>,builtin -- kill <pid>, andenv -- kill <pid>.
Finding:
The self-host kill guard still misses valid wrapper spellings that pass -- before the command name. command -- kill <pid>, builtin -- kill <pid>, and env -- kill <pid> all execute kill in normal shell usage, but the current _CMDPOS wrapper fragment only consumes command, command -p, builtin, or env assignments before looking for kill. As a result, the probe returned (False, None) from _check_self_host_kill() and {"approved": True, "message": None} from check_all_command_guards() for those self-PID forms, leaving the self-host kill bypass open.
Please cover the -- wrapper variants in the detector and add regression tests for them.
Signed: GPT-5.5-xhigh in Codex
|
Addressed the wrapper Changes:
Verification:
|
egilewski
left a comment
There was a problem hiding this comment.
Recommendation: request changes
I reviewed this against current GitHub main d62979a6f34f64f2ed840f159aac66e24d7cad78, PR base 72154ad879e2acebbbf46e27f77a3a4bde3f2ea2, and PR head c19daf3f85b7417947db2bdae1980487f77d7b56.
Validation:
git merge-tree --write-tree upstream/main upstream/pr/43157: passed, producedfc8453ebaa847da553fe9fb1ee0d1da9b6c20387.git diff --check upstream/main...upstream/pr/43157: passed./home/mac/hermes-agent/.venv/bin/python -B -m pytest -o addopts='' -p no:cacheprovider tests/tools/test_hardline_blocklist.py -q: failedtest_self_host_kill_blocks_parent_pidin this review environment becauseos.getppid()is1, while_self_host_pids()intentionally excludes PID 1.- Direct probe:
command -- kill <pid>,builtin -- kill <pid>,env -- kill <pid>,sudo -- kill <pid>, andcommand -- builtin -- kill <pid>now block. - Direct probe:
bash -c "kill <pid>"andsh -c "kill <pid>"still return{'approved': True, 'message': None}fromcheck_all_command_guards(..., 'local').
Finding:
The wrapper fixes close the earlier -- variants, but the self-host kill guard still misses common shell-interpreter forms that execute the same kill <host-pid> command. A terminal command such as bash -c "kill <pid>" or sh -c "kill <pid>" is still approved even when <pid> is the current agent host process, so the self-host kill bypass remains reachable through a standard shell wrapper.
Please cover shell -c kill forms, or otherwise route them into the hardline/self-host guard, and make the parent-PID regression test robust when the pytest process parent is PID 1.
Signed: GPT-5.5-xhigh in Codex
c19daf3 to
dab04c8
Compare
|
suggesting changes I reviewed a run-owned patch replay of this PR onto current GitHub Result: Security evidence:
Signed: GPT-5.5-xhigh in Codex |
dab04c8 to
1a983a4
Compare
|
Addressed both open review points and refreshed the branch onto current 1. PID-1-robust parent test (this comment)
2.
|
teknium1
left a comment
There was a problem hiding this comment.
Thanks for addressing the earlier wrapper and PID-1 feedback. The numeric self-host-PID premise is still valid on current main: the hardline floor only recognizes kill ... -1 (tools/approval.py:395), and normal commands reach the later yolo path.
Problems
tools/approval.py:603marks any$$as a host kill. Hermes executes local commands througheval(tools/environments/base.py:510) in a spawnedbash -c(tools/environments/local.py:1044), so$$identifies the terminal shell rather than the Python host. The tests attests/tools/test_hardline_blocklist.py:699-701assert this false-positive behavior._extract_shell_c_scripts()scans all flat tokens (tools/approval.py:554-575) rather than shell command positions. Thus command data such asecho sh -c 'kill $PPID'is recursively treated as code and hard-blocked.
Suggested changes
- Retain numeric host-PID matching, but make shell-variable handling expansion-layer-aware and add direct/nested quoting regressions.
- Restrict
-cextraction to actual executable command positions and add data/prose allow cases.
Automated hermes-sweeper review.
| for text in _iter_kill_scan_targets(variant): | ||
| for match in _KILL_CMD_RE.finditer(text): | ||
| args = match.group("args") or "" | ||
| if _KILL_SELF_TOKEN_RE.search(args): |
There was a problem hiding this comment.
$$ is not the agent host PID in Hermes's local execution path: the command is evaluated inside the spawned bash -c shell. Blocking every $$ therefore rejects kill $$, which only terminates that terminal shell. Resolve shell variables at their actual execution layer, or do not use $$ as a host-PID signal.
| except ValueError: | ||
| return [] | ||
| scripts: list = [] | ||
| for idx, tok in enumerate(tokens): |
There was a problem hiding this comment.
This scans any token named sh/bash as an interpreter, including command arguments. echo sh -c 'kill $PPID' is parsed as a nested shell payload and becomes an unconditional block even though it only prints data. Restrict extraction to executable command positions and add an allow regression.
An agent session runs inside a host process (desktop dashboard / gateway,
CLI, or a launcher child). A terminal() `kill` aimed at that host can never
complete usefully: the turn dies mid-flight, the session is orphaned (blank
indicator, failed stop, session-not-found on the next prompt), and in-flight
work is lost. Reproduced 2026-06-09 when a session cleared "stale bytecode"
by killing its own dashboard PID.
Add a hardline self-host kill guard to check_all_command_guards, firing
before the yolo / mode=off / cron bypass (like the rm/shutdown floor and the
sudo-stdin guard) so no session-level setting can leak it. It extracts
numeric `kill` targets and the `$$` / `$PPID` self-tokens and blocks when
they hit this process or its (non-1) parent, across:
- command-position wrappers: command/builtin/sudo/env/exec/nohup/setsid/
time prefixes, with optional `--`, and chains of them (_KILL_CMDPOS,
kept separate from the shared _CMDPOS so widening the kill anchor never
changes the rm/shutdown blast radius);
- shell-interpreter forms: `bash -c "kill $$"` / `sh -c ...` hide the kill
inside a quoted -c payload with no command-position separator, so the
payload is tokenized out (shlex) and re-scanned as a standalone command,
recursively for nested `bash -c "sh -c '...'"`;
- deobfuscation variants (subshell `(kill $$)`, split spellings) via the
shared _command_detection_variants().
Foreign PIDs, `command -v kill`, `pkill`, and negative process-group forms
(`kill -1` is already hardline) stay out of scope. Container backends bypass
via the existing short-circuit — their PIDs are in a separate namespace from
the host gateway's.
Resolves the CHANGES_REQUESTED review on NousResearch#43157:
- closes the `bash -c "kill $$"` / `sh -c "kill $$"` self-host bypass, and
- makes test_self_host_kill_blocks_parent_pid robust in PID-1-parent
environments (containers / CI) where os.getppid() is 1 and PID 1 is
intentionally excluded from the self-host set; a companion test pins that
exclusion so the parent-PID coverage can't be "fixed" by dropping it.
Co-Authored-By: Claude Code <noreply@anthropic.com>
…r the 30s pytest-timeout
test_wait_for_process_kills_subprocess_on_keyboardinterrupt bounded its
internal waits at join(15s) + _wait_for_pgid_exit(30s) = up to ~50s, while
the suite runs under `--timeout=30 --timeout-method=thread` (pyproject
addopts). The thread method hard-`os._exit()`s the per-file interpreter on
expiry, so a slow reap under xdist load didn't fail one assertion — it
crashed the whole file ("1 file where no tests ran"). Because this test
lives on main, every PR's CI inherited the flake and could fail on unrelated
changes.
The real reap chain (SIGTERM -> <=1s -> SIGKILL -> <=2s -> wait, all
wall-clock deadlines) empties the group in <=~3.2s regardless of load, so the
ceilings were ~10x over-provisioned. Cap them so the test's internal budget
can never reach the global cap:
- _wait_for_pgid_exit default 30s -> 8s;
- worker join 15s -> 8s;
- setup(<=5s) + settle(0.3s) + join(<=8s) + wait(<=8s) ~= 21s, comfortably
under 30s. A genuinely stuck reap now fails one assertion cleanly, with
the process-table snapshot, instead of guillotining the shard.
Also add a 0.3s settle before injecting the KeyboardInterrupt so the worker
is reliably parked in _wait_for_process's poll loop first — closing a
test-harness race where the async exception could land before the
try-guarded sleep (where _kill_process never runs), which would be a false
failure unrelated to the product code.
No product code changes; the reap path in base.py / local.py is unchanged.
Runs in ~0.9s locally, 3/3 stable.
Co-Authored-By: Claude Code <noreply@anthropic.com>
1a983a4 to
ec1bf35
Compare
|
suggesting changes The PR blocks direct and several wrapped spellings of
Security evidence:
Uncertainty: The changed pytest files were not executable in this lease because neither system Python nor an available project virtual environment provides pytest.; No destructive payload was executed against an actual gateway process; sink reachability is established from the exact approval result, installed executable paths, and the traced Bash execution path.; The complete universe of indirect signal-delivery spellings is not enumerable by this lexical guard; busybox and xargs were sampled as residual bypass classes rather than treated as exhaustive. Signed: GPT-5.6-sol-xhigh in Codex |
A local terminal backend can share the deployment PID namespace with the container supervisor. The approval floor previously allowed literal kill targets of 1, so an agent command could terminate init and drop every active session even in yolo or approvals-off modes. Classify nonzero kill signals aimed at numeric PID 1 as hardline while preserving signal-zero probes, list and help modes, ordinary target PIDs, quoted prose, and isolated container-backend bypass behavior. Constraint: Containerized Hermes deployments can use environment=local; docker and similar environment names still identify isolated tool sandboxes and retain their early bypass. Rejected: Fold in dynamic self or parent PID detection from NousResearch#43157 | that open PR protects a different runtime identity and intentionally excludes PID 1 Rejected: Expand shared command-wrapper parsing | NousResearch#58643 owns _CMDPOS wrapper and path hardening, and this pattern composes with it Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep signal 0 and kill list or help modes outside the hardline floor because they do not deliver a signal. Tested: scripts/run_tests.sh hardline and approval guard slices, 333 tests passed Tested: ruff 0.15.10, py_compile, diff check, and Windows footgun scan Tested: Disposable tini container changed from running to exited with code 143 after kill -TERM 1 Not-tested: Full suite clean; the local shared venv run passed 40269 tests and failed 175 unrelated tests because of missing optional dependencies, live-system guard failures, platform behavior, and stale installed modules Related: NousResearch#43157 Related: NousResearch#58643
Why
An agent session runs inside a host process — the desktop dashboard/gateway, or the CLI itself. Nothing stops a terminal() call from killing that host's PID. Reproduced on 2026-06-09: a session cleared "stale Python bytecode" by killing its own dashboard process; the turn died mid-flight, the session was orphaned (blank indicator, stop failed, session-not-found on the next prompt) and in-progress work was lost. A static blocklist pattern can't express "our own PID", so this adds a function guard alongside the existing sudo-stdin one.
What changed
tools/approval.py:_check_self_host_killextracts numerickilltargets plus the$$/$PPIDshell self-tokens and blocks when they matchos.getpid()/os.getppid(). Runs incheck_all_command_guardsin the unconditional section (with hardline + sudo-stdin), so--yolo,approvals.mode=off, and cron approve mode cannot bypass it. Containerized backends keep their existing early bypass — PIDs in a container namespace are not the host's.hermes gateway restart, the desktop's Gateway menu) instead of in-session kills.killis anchored at command position via the shared_CMDPOSfragment, whose wrapper inventory now includescommand [-p]andbuiltin— so wrapper spellings that still execute kill (command kill,builtin kill,sudo/env/exec/nohup/setsid/timeprefixes, and chains of those) hit the guard.command -v/-Vstays unmatched (resolves a name without executing). The_CMDPOSextension hardens the shutdown/reboot hardline patterns the same way.pkillname matching and negative-pgid forms (kill -1is already on the hardline list).tests/tools/test_hardline_blocklist.py: 15 new cases (own/parent PID,$$/$PPID, chained commands, foreign-PID allow, pkill/pgid allow, end-to-end hardline shape, yolo no-bypass, container bypass).Verification
tests/tools/test_hardline_blocklist.py— 115 passed (100 pre-existing + 15 new); full approval cluster (6 files) 370 passed on this branch.Notes
Mirror of OmarB97#128 (merged on the fork after live reproduction). Cherry-picks cleanly onto upstream main; no other files touched.