Agent shell guard
Harn gives Codex and Claude the same repository command rules. The guard keeps Rust builds and tests on the Make targets that configure a private build directory for each worktree. It also stops long commands from losing most of their output in a filter.
The guard applies only to agent shell calls. It does not change commands run by people, continuous integration, Make recipes, or repository scripts.
Use the supported commands#
Run the Make target when one owns the operation:
| Instead of | Run |
|---|---|
cargo build | make build |
cargo check | make check |
cargo test or cargo nextest | make test |
cargo clippy | make lint |
cargo fmt | make fmt |
cargo bench | make bench |
Inspection and package-management commands such as cargo tree and
cargo add remain available because no Make target owns them.
For a real one-off, add HARN_ALLOW_RAW_CARGO=1 to that command. The escape is
visible in the shell call and does not weaken later calls.
Build tools that only some repositories own#
Every repository wiring this guard is a Cargo workspace, so the table above
applies everywhere. Other build tools do not, and a repository cannot be told
to run a Make target it has never declared. Those rules therefore fire only
where the adapter has measured the target in the root Makefile:
| Instead of | Run | Fires when the Makefile declares |
|---|---|---|
swift build | make swift-build | swift-build |
swift test | make swift-test | swift-test |
The one-off escape is HARN_ALLOW_RAW_SWIFT=1. A repository with no such
target, or one whose Makefile the adapter could not read, is left alone: the
rule is inert there rather than pointing at a command that does not exist.
Commit work in progress instead of stashing it#
Agent shell calls cannot run git stash forms that write the stash list:
a bare git stash, push, save, pop, apply, drop, clear and branch.
The list belongs to the repository, so every linked worktree shares it. An agent
that pops after another agent's push applies that agent's changes and loses its
own. A work-in-progress commit stays on its branch:
git commit -am wip
git reset --soft HEAD~1 # later, to continue editing
git stash list and git stash show stay available, since they only read.
The guard checks Git aliases too. Builtin and configuration queries use the selected executable only when it belongs to the hook's trusted PATH. Payload paths outside it are not executed during preflight. Source command facts retain assignment-only stages and outer shell prefixes, so a PATH or working-directory change cannot reuse facts from a different Git installation. Missing context remains unknown. When a payload changes Git configuration, run a custom alias in a separate shell call so the guard can read its effective configuration. Within one decision, repeated scans reuse measured builtin facts for the same executable. A new decision queries again; different executables keep separate facts.
The verdict does not depend on the caller's working directory. The adapter grants
the policy's Git queries read access to the repository of the caller's directory
and of each absolute path in the command, including a linked worktree's Git
directories. When Git still cannot read the configuration, the refusal says the
guard faulted and names why; it never reads as a git stash refusal.
Keep complete command output#
Do not send a build or test directly into grep, head, or another filter.
Save the complete output first:
make test > .harn-runs/test.log 2>&1; result=$?
grep FAILED .harn-runs/test.log
exit "$result"
tee is also accepted when it names a log file:
make test 2>&1 | tee .harn-runs/test.log
This preserves the evidence needed to investigate a failure without repeating the expensive command.
Keep generated output out of Trash#
Trash is reserved for user-authored or uncertain data that may need recovery.
Do not move temporary files, benchmark output, compiler caches, or other
reproducible build artifacts there: that turns cleanup into visible Trash
clutter. The guard rejects trash and trash-put when an argument resolves to
an OS temporary root or a repository-generated root such as .build/,
target/, .harn-runs/, or the Burin eval roots. It still allows Trash for
ordinary user files.
Prefer leaving a rebuildable cache in place. Put one-off output beneath an OS temporary directory, and permanently remove it only through an exact, validated path and a mechanism accepted by the host. If the host refuses that cleanup, leave the data in its temporary/build root and report it instead of using Trash as a fallback.
Enable the hooks#
The repository already carries both host adapters:
- Codex reads
.codex/hooks.jsonafter the project is trusted. Open/hooksto review the active hook. Codex asks for review again when the hook file changes. - Claude Code reads
.claude/settings.json. Open/hooksto inspect or disable the active hook.
Both adapters call scripts/agent-shell-guard.sh. That small adapter selects an
existing Harn executable and passes the host payload to
scripts/agent_shell_guard.harn, which owns every decision. The adapter never
starts a build. Its empty builtin allow list denies registered non-core
builtins and the typed Harness methods backed by them.
HARN_LLM_CALLS_DISABLED=1 independently prevents a real model request.
Platform-specific temporary roots and generated-root configuration live in the
typed, data-only scripts/agent_shell_guard_policy.harn module; the evaluator
contains no second path list.
The adapter prefers a hook-owned release binary at
$AGENT_SHELL_GUARD_HARN_BIN, or the default Harn cache location, only when the
adjacent .standalone-v1 file contains harn-run-standalone-v1. That runtime
uses harn run --standalone, so ambient handlers, packages, skills, and cache
authority cannot delay a command-policy decision. An explicit executable
HARN_BIN retains the project-aware invocation for compatibility with older
Harn releases. Without either path, the adapter falls back to an existing
repository or installed executable without building one.
Once that exact marker enrolls the cache, a checksum-verified harn upgrade
also refreshes the hook-owned binary from the downloaded release archive. The
updater serializes concurrent refreshes, publishes digest-keyed release and
source provenance, and atomically replaces the executable. An absent or
malformed marker never enrolls or mutates the cache. --no-verify and legacy
releases without a checksum manifest leave the existing hook runtime untouched.
If the upgrade command itself is running from that enrolled path, an unverified
self-upgrade is refused before either executable changes.
The hook itself never checks for updates, accesses the network, or hashes the
installed binary during command evaluation.
The project-aware fallback loads project trigger and hook handler code only if
a handler runs. A top-level handler initialization failure therefore cannot
disable command checks. Harn still parses harn.toml and validates handler
declarations.
The adapter bounds policy execution below the host hook deadline. It first
sends TERM, then KILL after a short grace so an interpreter descendant cannot
keep the hook pipe open. Exit statuses 124, 137, and 143 deny the command
because the policy timed out or was interrupted before proving it safe. Any
other non-zero exit except 127 denies as well, naming the failure: the
interpreter ran and the evaluation produced no verdict, so no rule was applied.
A missing or non-executable interpreter is the one remaining fail-open, so a
broken local installation or a fresh clone cannot lock the agent out of
recovery. The adapter decides it before the policy runs, or from status 127
when a project wrapper such as harnw resolves no runtime. That allow prints
one stderr line saying the guard is off and which interpreter was missing, so
it is never silent. Policy output is published
only after a successful interpreter exit;
partial output from a crash or timeout is discarded instead of becoming a
malformed host decision.
See the current Codex hooks reference and Claude Code hooks reference for the host-level trust and configuration rules.
Diagnose a decision#
Send a host-shaped payload to the same adapter used by the agents:
printf '%s' '{"tool_name":"Bash","tool_input":{"command":"cargo test"}}' \
| scripts/agent-shell-guard.sh
A blocked command prints one JSON decision with the reason and replacement. An allowed command prints nothing. To check the policy and its in-process tests:
harn check scripts/agent_shell_guard.harn
harn test scripts/tests/agent_shell_guard_test.harn
If the adapter stays silent unexpectedly, confirm that harn is installed or
set HARN_BIN to an existing executable. For the standalone path, also confirm
that the adjacent marker contains the exact attestation shown above. Then repeat
the probe with diagnostics enabled:
printf '%s' '{"tool_name":"Bash","tool_input":{"command":"cargo test"}}' \
| AGENT_SHELL_GUARD_DEBUG=1 scripts/agent-shell-guard.sh
The adapter deliberately ignores an invalid executable path and remains fail-open, because no policy ran, announcing on stderr that the guard is off. Once a policy does run, a failure denies. Debug mode preserves Harn startup and policy errors on stderr.
Reuse the policy in another repository#
First give that repository Make targets with the same public names. Each target must own its build directory and environment before the matching Cargo command is blocked. Then project these files from Harn without editing their copies:
scripts/agent_shell_guard.harnscripts/agent_shell_guard_policy.harnscripts/agent-shell-guard.sh
Add host settings that call the projected adapter. Keep repository-specific startup hooks in the host settings rather than putting them in the shared policy. The deferred handler startup matters most in these downstream projects, where the command policy sits beside application triggers and hooks. The Harn fleet manifest checks projected files byte for byte, so a policy change has one owner and downstream drift becomes a failed check.