- Rust 100%
os.system and subprocess passed the 17-marker filter with exit 0 while the README promised to refuse snippets that write files. Five markers close that path; the regex-free heuristic stays a heuristic and the README now names what still gets through. |
||
|---|---|---|
| src | ||
| .gitignore | ||
| Cargo.lock | ||
| Cargo.toml | ||
| README.md | ||
python-guard
Hermes pre_tool_call hook for terminal: refuse Python snippets that write files —
the common shapes, not a sandbox (see Deviations).
Files get edited with patch / write_file, or by a Rust binary — not through
python3 -c one-liners and not through python - <<EOF heredocs.
Why
The rule in the coder profile is "Rust instead of Python" (~/.hermes/profiles/coder/SOUL.md).
A rule in a prompt is a suggestion; a shell hook is not. When an agent wants to do a
five-line file edit, the cheapest path used to be python3 - <<EOF ... EOF — that
bypasses the edit tools (no diff, no checkpoint, wrong language for the repo). This
binary is the guard that makes that path fail.
Allowed on purpose: python3 existing_script.py and python3 -m module. Those are
existing tools that are being ported, not new code; blocking them would break
watchdogs and test runners.
Build
cargo build --release
install -m 755 target/release/python-guard ~/bin/python-guard
Two dependencies (serde_json, regex). Release profile is LTO + one codegen unit.
Usage
The hook is wired per profile in ~/.hermes/profiles/<profile>/config.yaml:
hooks:
pre_tool_call:
- command: /home/<user>/bin/python-guard
matcher: ^terminal$
timeout: 5
hooks_auto_accept: true
Same entry in shell-hooks-allowlist.json, otherwise the hook command itself needs
approval. Protocol:
- stdin: the hook payload,
{"tool_name": "terminal", "args": {"command": "..."}, ...} - exit
0→ allow - exit
2+ one line of JSON on stdout → block:{"action":"block","message":"Python-Heredoc/Inline-Edit ist gesperrt … (erkannt: <reason>)"}
Try it by hand:
echo '{"tool_name":"terminal","args":{"command":"python3 - <<EOF"}}' \
| ~/bin/python-guard; echo "exit=$?" # exit=2 + block message
Two rules, in this order:
-
python[0-9.]*followed by<<(optionally- <<) → blocked, whatever the body is. -
python[0-9.]* -cand any of 22 markers in the command → blocked. Markers:write_text,write_bytes,.write(,"w",'w',"a",'a',shutil.,os.rename,os.replace,os.remove,os.makedirs,os.unlink,unlink(,rmtree,mkdir(,truncate(,os.system,os.popen,os.exec,subprocess,pty.spawn.The last five exist because a write can happen in a child process:
python3 -c "import os; os.system('echo x > f')"contains no file-write marker at all.os.execalso coversos.execl/os.execv, which is how the marker list stays five entries instead of ten.
Other tools and other commands are untouched: only tool_name == "terminal" is inspected.
Deviations
- Substring matching, not parsing.
python3 -c "print('a')"is blocked because'a'is a write marker (python3 -c 'print("w")'likewise). Fail-closed was chosen over a Python parser: a false positive costs one retry withpatch, a false negative costs a silent unsandboxed file edit. - Not a sandbox.
python3 script.py,python3 -m …,perl -e,node -e, and writing the snippet to a file first and running that file all pass. It blocks the shapes that were actually being used, nothing more. - The spawn markers are still substring matching, so they are trivially avoidable. All
of these pass today (checked against the installed binary):
getattr(os, 'sys'+'tem')(…),eval/execon a decoded string,ctypes.CDLL(None).system(…), base64-decoding the payload, and whitespace variants likeos . system(…). Blocking those would need a Python parser or a real sandbox, and neither is what this hook is: it is an education rule that makes the cheap path fail, not a gate against an agent that is trying to get around it. - The spawn markers also block harmless code.
python3 -c "import subprocess; print(subprocess.run(['ls']))"is refused even though it only reads. Same trade-off asprint('a')above: fail-closed, retry with a script file or plain shell. - Heredocs are blocked even when harmless (e.g.
python3 - <<EOFthat only prints). The body is never read; the shape alone triggers the block. - Fail-open. Unreadable stdin or invalid JSON exits
0— a hook that cannot read its payload must not stop work. A guard bug then looks like no guard, not like a frozen agent. - German block message, on purpose: it is read by the agent in a German-language setup and names the two alternatives (edit tools, Rust binary).
Tests
cargo test # 4 tests, 18 assertions
blocks_stdin_heredocs(3) —python3 - <<'EOF',cd /tmp && python <<EOF,python3 - <<EOFblocks_inline_writes(3) —Path(...).write_text,shutil.copy,open(...,'w').writeblocks_process_spawn(5) —os.system,subprocess.run(['tee',…]),os.popen,os.execl,pty.spawnallows_plain_execution(7) — a script path,-m pytest,python3 -c "print(2+2)",cargo test,git status,python3 -c "import os; print(os.getcwd())"(module imported, nothing started),grep -rn subprocess src/(markers only count insidepython -c)
The tests call blocked_reason() directly, so they cover the rules but not the
JSON/stdin path or the exit code.
Measurements
- Release binary: 2.2 MB, installed copy is byte-identical to
target/release/python-guard. - 200 invocations of the binary through a shell loop: 0.84 s wall → ~4 ms per call, which is what the 5 s hook timeout is sized for.
- Live checks against the installed binary: heredoc → exit 2 with
stdin script (python - <<EOF);python3 -c "…write_text…"→ exit 2;python3 -c "…os.system('touch /tmp/x')"→ exit 2 (/tmp/xnot created);python3 -c "…subprocess.run(['touch','/tmp/x'])"→ exit 2;python3 -c "import os; print(os.getcwd())"→ exit 0;python3 ~/.hermes/scripts/ollama_cloud_watchdog.py --check→ exit 0;python3 -m pytest -q→ exit 0.