Guards that say which way they lean
What should a guard do when it cannot tell whether a command is safe, and how far should I trust it? It should refuse when a mistake cannot be undone, allow when a wrong refusal only costs a minute, say which it did, and never be described as more than it is. These guards are best-effort controls for a careless agent. They are not a complete security boundary, and one of them has a known gap that has been reported and that I am deliberately not showing.
This is the fourth essay in a series about a small open-source harness, pinned to one commit of Agent Harness, 504d930; the guide to the whole flow is HARNESS.md. The third essay was about the spawn gate at the door. These three guards sit at the shell: one for secret dumps and log deletion, one for kills, one for production deletes and heavy jobs. Everything below ran on synthetic data with a throwaway home folder. The check commands print a decision and execute nothing.
Read the command the way a shell does
Picture a guard that blocks a delete of the agent's log folder. The agent tries again with a pair of quote marks in the middle of the folder name. Same folder, same delete. A guard that compares raw text sees a new string and waves it through.
"Why not add the quoted spelling to the blocklist?" Because the spellings never end. Before a shell runs anything it strips quotes, expands the tilde and variables, splits on semicolons and pipes, peels off wrappers, and opens the string after -c. A hurried agent does not have to be clever to hit these, because models write them naturally. The shell's rules are finite, so the toy copies the reading steps instead of chasing spellings: pull out command substitutions and judge their inner text first, lex the rest, drop variable prefixes and wrappers, open shell strings by calling itself again, then expand the tilde, normalize .. and compare the path with the protected folders. It is a little over a hundred lines of standard-library Python, with a depth limit.
I ran a naive text match as the control. It caught three of the eight disguises, by luck, because they contained the literal words, and it missed five. The toy caught all eight. Then I planted five bugs, one per reading step, and the suite failed each time:
mutant QUOTES: FAILED (failures=5)
mutant SHELLC: FAILED (failures=3)
mutant SUBSHELL: FAILED (failures=2)
mutant NORMPATH: FAILED (failures=1)
mutant DEPTH: FAILED (failures=1)
The real guard, asked the same kinds of questions. The middle column is the rule that fired; the last is my description of the disguise:
== 1. plain and direct
allow - ls -la
allow - rm -rf /tmp/scratch-build
block secret_dump/env-dump printenv
allow - printenv HOME
block secret_dump/credential-file cat ~/.aws/credentials
== 2. costumes
block log_delete/log-delete split quotes in the path
block log_delete/log-delete shell string
block secret_dump/env-dump command substitution
block log_delete/log-delete dot dot in path
block log_delete/log-delete over ssh
block secret_dump/credential-file inside docker exec
block log_delete/log-delete find -delete
block log_delete/log-delete-text python one-liner (text scan)
Twelve disguises were tried and all twelve were blocked; the block above shows eight of them. Plain commands were allowed, including a printenv HOME, which names one variable and dumps nothing.
What it does when it cannot read the command
This is the part I most want to be exact about. The guard's documentation says unreadable input is blocked. What I observed is narrower:
== 4. nesting past the depth budget
warn parse/unanalysable twelve levels, harmless
block log_delete/unanalysable twelve levels, names a protected path
A harmless command nested twelve levels deep got a warning and was allowed; the same nesting around a protected path was blocked. So "unreadable" is judged by whether the raw text looks sensitive. I think that trade is right, since blocking every odd command would make the tool unusable and people would switch it off, but it is not the same as "fails closed", and I would rather you know that than assume it. Overwrite rules also check what exists: a truncate or redirect of an existing protected file was blocked, and a truncate of a path that does not exist was allowed.
Credential reads, environment dumps and log deletes are always enforced; in this command guard, broad kills only warn by default, because the separate kill guard below is the one that blocks them. The stated threat model is a careless agent, not an adversary who can edit the hooks. An earlier essay here, A fail-closed allow-list can silently disable your failover, covers the cost of failing closed: a rule that is correct in source can still fail closed on everything once it is installed. A shell guard also only sees shell; editor tools and names built at run time are outside what it judges.
A known gap. While testing I found one shell form that the real guard does not handle at this commit. It has been reported, and I am not showing the exact text until it is fixed. That is not a figure of speech: no file in this series contains it. Treat this guard as one layer among several. A single hole in one tool is also why the tests matter more than the tool: the value is a list of disguises, one line each, with a failing test written before the fix.
Kills: ask whose process it is
The kill guard comes from an incident its own source records: a pattern kill and a piped finder stopped other people's runs on a shared host. The danger is not the verb. A kill by pattern is checked against every process on the machine at the moment it runs, and the agent cannot see that list. So the guard changes the question from "is this dangerous?" to "whose process is this?" A kill of a process number is fine if the number is provably yours.
It accepts proofs: the process's working directory inside the caller's worktree, its program or script path inside that worktree, or a spawn ledger, a small signed log written when you start a process through the guard's own spawn command. The ledger entry carries the start time as well as the number, because numbers get reused. That narrows reuse and does not close it, and I did not replay reuse against the real guard. I ran it in a scratch repository, and the only processes signalled were three sleeps I started myself, at cleanup:
== 1. a plain kill, judged by who owns the process
allow - kill, started by spawn
allow - kill, cwd inside the worktree
block not-yours kill, cwd outside the worktree
block not-yours kill pid 1
block not-yours kill, pid that does not exist
== 2. broad kills and finders
block broad-kill pkill -f sleep
block broad-kill killall sleep
block pidfeed pgrep piped to xargs kill
block pidfeed kill with command substitution
block unprovable-target kill in a for loop
block script-kill python os.kill one-liner
== 3. things that are not kills, or are harmless
allow - kill -0 literal pid
allow - kill -l
allow - pgrep alone
allow - grep for the word kill
allow - job spec
== 4. across a remote shell
block remote-kill ssh box kill
block remote-kill docker exec kill
box is a placeholder host name; the remote probes ran nothing. My toy has 9 tests and I planted seven bugs; all seven failed the suite. The documented limits are real: kill text piped into a remote login shell with no command named is not caught, a kill hidden in a substitution inside a parameter default is not read, and being a descendant of the agent's process proves nothing because every sub-agent shares it. I did not run the remote spawn ledger or the netwatch route.
Production deletes and heavy jobs
The third pair of guards starts with an agent that writes in chat that it is about to delete the stale rows, then does, against the live database. Nobody answered. To the agent, announcing felt like asking. The production-delete guard wants a grant: a signed, time-limited approval for one exactly named target, tied to the user's recorded answer. The default lifetime is fifteen minutes and the ceiling is one hour. Destructive means the listed shapes: delete from, drop, truncate, or an update with no where clause. Production means a remote flag, or a host or database whose name says prod. That is a name heuristic, and the source says so. I called the real function in-process with no database:
== 1. prod_delete_guard: what counts
deny no grant D1 remote delete
allow - same delete, local database
deny no grant psql to a prod host, DROP TABLE
allow - psql to localhost, DROP TABLE
allow - prod host, SELECT only
deny no grant prod host, UPDATE with no WHERE
allow - prod host, UPDATE with WHERE
== 2. disguises and gaps
deny no grant split quotes and a comment
deny file unreadable SQL in a file that does not exist
allow - prose that only mentions the words
deny no grant connection string naming prod
deny no grant cloud tool, query field
deny file unreadable SQL decoded at run time, piped in
The fail direction is deliberate and different from the command guard. Before a rule matches, an error allows; after a match, an error denies, even for a broken grant store. Each gate fails the way its mistake costs: a wrongly blocked listing costs a minute, a wrongly allowed production delete cannot be undone. The documentation lists what is out of scope, including multi-table MySQL deletes, Mongo's deleteMany, Redis flushes, execute-SQL style tools and plain file deletes. The run-time-decoded probe was denied, but the source lists run-time-built SQL as out of scope, so do not lean on that row. I could not create a real grant, because it needs a recorded answer from a real session.
The heavy-job guard keeps full test suites, installs, browser sweeps and containers off a laptop. Its source records a hostile-input test that read an endless stream until the machine reset, and a browser sweep that spawned dozens of renderers. It acts only on macOS unless forced, so I forced it on:
== 3. heavy_job_guard: what counts
deny full-suite pytest, whole suite
allow - pytest, one file
deny full-suite npm test
deny install pnpm install
deny browser headless browser sweep
deny container docker run
allow - ls
allow - grep for the word pytest
allow - commit message that says pytest
== 4. the escape hatch
allow full-suite hatch with a reason, full suite
deny browser hatch with a reason, browser
deny full-suite hatch with no reason
The escape hatch is a bypass that leaves a record: a reason is required, it is logged without the command, and by default an owner gets one use per hour. A browser sweep stays denied even with a reason, because it also needs a runtime cap. Commands that are too large or nest wrappers past twelve levels are denied; I ran fourteen wrappers around a plain listing and it was denied as too deep. Hostile-input work is advisory only, never denied, because breaking things on purpose is a red team's job.
What I did not run
- Nothing real was touched. No real logs, no real processes beyond three sleeps I started, no database, and no
approve-deleteorgrantflow. - The known gap is not shown, and the real guards are not proven complete by twelve disguises passing. A passing list is a list.
- Not exercised: pid reuse against the real kill guard, the remote spawn ledger, the netwatch route, warn and off policy modes, the hook entry points with real payloads, the heavy guard's disk-budget and hatch-limit paths, and the capped runner.
- The guards are opt-in code paths in a harness. An agent that can edit the installed hooks can defeat them, and the documentation says so.
Keep a list of disguises, one line each, and write the failing test before you fix anything. Decide what unreadable means before the day it happens, and write down which way each guard leans. The last essay is about what happens when the thing that dies is the delivery itself: Delivery you can recover.