Repository navigation
chore: run the agent git guard in Pi and Hermes - #1175
Conversation
A Pi project extension and a Hermes project plugin feed each shell tool call to tools/agent-guard/git_guard.py and block what it refuses. git_guard.py accepts Pi's bash and Hermes's terminal payloads, including Hermes's per-command workdir. AGENTS.md names each harness's one-time enable step.
|
Review round 1 (Codex The reviewer checked against the installed Pi 1.0.4 and Hermes
Tests pass: the table (161 commands × 4 shapes) and the Pi extension test. |
…wershell in the git guard Hermes execute_code code and process_manage write/submit input, and Pi powershell commands, are screened as text: refused when they mention or could spell a git write or PR merge, never parsed as Bash. The Hermes plugin takes the terminal's directory from Hermes's own precedence (task override, session record, terminal.cwd/TERMINAL_CWD) and refuses git writes on a non-local backend or when that lookup fails.
hermes plugins enable cannot see project plugins in Hermes 0.21.5; add git-guard to plugins.enabled and start Hermes from the repo root with HERMES_ENABLE_PROJECT_PLUGINS=true.
Fixed in
Mutation check: 12 mutations across
Live runs re-done at |
|
Review round 2 (Codex Round 1's repros are closed. Refusal on docker, ssh and modal backends works.
Whether to keep text screening of |
…rminal cwd in the git guard Hermes execute_code and Pi powershell are refused outright while the adapters are loaded; agents use terminal or bash. Hermes process_manage write/submit input gets the normal shell check with no known directory, and absent data is Enter alone. The plugin resolves the terminal's directory as terminal_tool does on the local backend: an absolute workdir, else the execution session's record, else the planned default. A relative workdir, disagreeing session records, a non-local backend or unreadable settings refuse git writes.
Fixed per the maintainer's decision in
Mutation check: 17 mutations across the guard, the Hermes plugin and the Pi extension (refusals, Live runs re-done at |
|
Round 2 fixes verified at
The review loop stops here: rounds 1 and 2 confirmed defects, and every one is fixed, refused by the maintainer's decision, or documented as a limit (input split across |
…hermes # Conflicts: # AGENTS.md
Closes #1171
tools/agent-guard/git_guard.py(#1158) now also runs in Pi and Hermes, the two harnesses it did not reach. Each one gets a small adapter in a file this repo carries. The adapter hands every shell tool call to the guard and blocks what the guard refuses.git_guard.py.pi/extensions/git-guard.ts, atool_callhandler forbashandpowershell{"tool_name": "bash" or "powershell", "tool_input": <Pi's tool input>, "cwd": ctx.cwd}{ block: true, reason: <guard stderr> }.hermes/plugins/git-guard/, apre_tool_callplugin hook forterminal,process_manageandexecute_code{"hook_event_name": "pre_tool_call", "tool_name": …, "tool_input": <args>, "cwd": <terminal cwd>, "unchecked"?: <reason>}{"action": "block", "message": <guard stderr>}git_guard.pynow treatsBash(Claude Code, Codex),bash(Pi) andterminal(Hermes) as shell tools.terminal. The plugin resolves the directory the wayterminal_tooldoes on the local backend. An absoluteworkdiris used as is. Otherwise the execution session's record wins (keyed byget_current_session_key(default="") or task_id), then the planned default: task override, task record, thenterminal.cwd/TERMINAL_CWD. The Hermes process directory is never used. Git writes refuse, and other commands run, in four cases where the repository can't be named: a non-local backend, a relativeworkdir(it resolves against the cached environment's directory, which the hook can't see), a session contextvar and task id whose recorded directories disagree, and terminal settings the plugin can't read.process_managewrite/submit. The input goes to a running shell, so it gets the normal shell check with no known directory. A merge refuses by its own rule, a branch-dependent write refuses unless it starts withcd /absolute/path &&, and absentdatais Enter alone, as in Hermes. Other actions carry nodataand pass.execute_codeand Pipowershell. Both are refused outright, by the maintainer's decision, because the guard cannot check Python or PowerShell. The message points the agent toterminalorbash.Sources
docs/extensions.mdin@earendil-works/pi-coding-agent1.0.4: "tool_callcan mutate input or block execution"; "Atool_callhandler failure blocks the tool as a fail-safe."examples/extensions/permission-gate.tsreadsevent.toolName === "bash"andevent.input.command.dist/core/tools/bash.d.tsdefines the bash input{command, timeout?}, andExtensionContext.cwdis the working directory.docs/configuration.md: project extensions live in.pi/extensions/and load "after project trust is granted".website/docs/user-guide/features/hooks.md(pre_tool_call): the callback receivestool_name("terminal"for shell),argsandtask_id, andreturn {"action": "block", "message": ...}"short-circuits the tool with that text as the error returned to the model". A raising or timed-out callback fails closed.tools/terminal_tool.py: the command runs inargs["workdir"], else the session cwd (get_session_cwd(task_id)), else config.hermes_cli/plugins_discovery.py:.hermes/plugins/is scanned only withHERMES_ENABLE_PROJECT_PLUGINS, and a non-bundled plugin loads only when listed inplugins.enabled.One-time enable steps (also in AGENTS.md, next to the guard line)
pi --approvefor one run. Until then Pi does not load.pi/extensions/.git-guardto theplugins.enabledlist in your Hermesconfig.yaml(keep the other entries, and drop anygit-guardentry underplugins.disabled). Then start Hermes from the repository root withHERMES_ENABLE_PROJECT_PLUGINS=true.hermes plugins enable git-guarddoes not work: in Hermes 0.21.5 its name lookup (hermes_cli/plugins_cmd.py) never scans project plugins.Notes for review
process_manageinput is checked one write at a time. A command split across several writes (gh pr me, thenrge 1175) passes, and so doesecho "git commit", which is harmless. The running shell also keeps state between writes: an earlierexport GIT_DIR=…, alias or function changes what a latercd /abs && git commitdoes, and the guard does not see it. Hermes's other command routes,terminal(background=True)at launch andhermes_tools.terminal()calls, go throughterminal(review round 1 traced them in the installed source).execute_codeor Pipowershellin this repository while the adapters are loaded.main.git_guard_test.py(run inclassifier-self-test) now feeds every row in all four payload shapes and tests the Hermes plugin callback.pi_extension_test.mjsruns in thewebjob, which already sets up Node 26; Node 26 strips the extension's types natively.Tests run locally
python3 tools/agent-guard/git_guard_test.py: 161 commands × 4 payload shapes (Claude, Codex, Pi, Hermes): 164 allow, 480 block. Thirteen tests in all. Besides the table they cover:execute_codeandpowershellrefused;process_manageinput: a merge, a push toHEAD:mainand a bare commit refuse; an absolutecdwith a commit,ls, an absent and an emptydata, andkillpass; a relativecdrefuses;terminal.cwdon main;workdirafter the environment moved;node --test tools/agent-guard/pi_extension_test.mjs: a merge and a commit on main block,git statuspasses,powershellis refused, and other tools pass.dotnet testfiltered toTenancyDocsFreshnessTests,RenameAccountDocsTestsandFileSizeLimitTests(20 passed), plus theImagePinandCommittedSchemaDocs_CarryNoEnvironmentSpecificContenttracked-file guards (3 passed).Proof at
f52de5f5Mutation check on review round 2's checks
Each check was disabled on its own and both tests were run (red expected), then the file was restored and the tests re-run (green expected).
git_guard.pygit_guard.pygit_guard.pygit_guard.pygit_guard.pygit_guard.pygit_guard.pyhermes pluginhermes pluginhermes pluginhermes pluginhermes pluginhermes pluginhermes pluginhermes pluginhermes plugingit-guard.tsLive runs
Both harnesses ran live in this worktree against the vLLM model
glm53-flash-exl3-4bpw-tp2-c5-ctx262144(https://iori-spark.mforcelabs.com/v1, probed first). The blocked command is a--dry-runpush to main, so even a guard failure would have changed nothing. No global configuration changed.Pi:
pi --mode json -p --approve --no-session --provider vllm --model <id> "<prompt>".--approvetrusts the project for that run only. These are the tool events from the JSON stream (~/.cache/gh-hooks/pi_live.py):Hermes:
HERMES_HOME=<temporary dir> HERMES_ENABLE_PROJECT_PLUGINS=true hermes chat -q "<prompt>". The temporary home held only a three-keyconfig.yaml: the vLLM endpoint as a custom provider, local terminal, andplugins.enabled: [git-guard]. Afterwards~/.hermes/config.yamlhad the same mtime, and the temporary home was deleted (~/.cache/gh-hooks/hermes_live.py). Each box is the model's reply quoting the tool result; Hermes renders Markdown, so the backticks are gone: