No description
Find a file
Pandi 77d2e205c1 fix(guard): block process spawn, which wrote files without a write marker
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.
2026-11-05 09:24:13 +01:00
src fix(guard): block process spawn, which wrote files without a write marker 2026-11-05 09:24:13 +01:00
.gitignore python-guard: block Python inline writes, allow file-based scripts 2026-11-03 09:12:59 +01:00
Cargo.lock python-guard: block Python inline writes, allow file-based scripts 2026-11-03 09:12:59 +01:00
Cargo.toml python-guard: block Python inline writes, allow file-based scripts 2026-11-03 09:12:59 +01:00
README.md fix(guard): block process spawn, which wrote files without a write marker 2026-11-05 09:24:13 +01:00

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:

  1. python[0-9.]* followed by << (optionally - <<) → blocked, whatever the body is.

  2. python[0-9.]* -c and 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.exec also covers os.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 with patch, 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/exec on a decoded string, ctypes.CDLL(None).system(…), base64-decoding the payload, and whitespace variants like os . 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 as print('a') above: fail-closed, retry with a script file or plain shell.
  • Heredocs are blocked even when harmless (e.g. python3 - <<EOF that 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 - <<EOF
  • blocks_inline_writes (3) — Path(...).write_text, shutil.copy, open(...,'w').write
  • blocks_process_spawn (5) — os.system, subprocess.run(['tee',…]), os.popen, os.execl, pty.spawn
  • allows_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 inside python -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/x not 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.