0d46f892db65afd75b0fe8cfe7ac880fffbd8db1
6
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
37eb6bcad1 |
test(gate): tolerate EPIPE in the no-node test; harness asserts skill.triage
The no-node test runs the hook with an empty PATH, so cat is missing too and the script exits before reading stdin; on Linux the test's write can lose that race (CI run 6483). The child exiting unread is the no-node path working. Harness: assert_skill_triage on chain and microvm — the event must exist; agreement with what the agent read is reported. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01WZb5A2kfVfjpdwSochkuHz |
||
|
|
3909fa14ca |
sec(gate): every denial names its rule; write tools are judged by path; the container tier records denials
Rule ids on the gate's rules, recorded on every denial as
{"rule":"<id>","payload":<hook event>} and read back by
vm_tool_gate::denial_detail into gate.denied events, so 'which rule fires
and how often' is a query on detail->>'rule'. Older bare-payload lines
still parse, with no rule.
A write-path policy: Write/Edit/MultiEdit/NotebookEdit are refused when
aimed at the hooks, their records, or the settings that install them, on
either tier (/root/toolgate, /root/toolhooks, /root/tap, the guest and
.claude settings, and .git/hooks/ — a hook in the checkout runs with the
agent's authority unseen). An agent could Write over denied.jsonl and erase
its own denials; now it cannot, and the same paths are refused to Bash
whatever the tool in front of them (sed -i is a text tool that writes).
The extractor prints file_path as a third line; the predicate and the
generated shell are tested against the same payloads, and the shell test
now reads the record back.
The container tier drained the tap and the inert marker and never the
denials: gate.denied existed only for microVM phases. drain_denied fixes
that. Harness: a gatepolicy scenario with two negative controls on the
container tier, asserting the two rule ids in the record.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WZb5A2kfVfjpdwSochkuHz
|
||
|
|
5a2ed8fb42 |
fix(gate): the exfiltration rule matched a spelling production never writes
One rule stood between a mission agent and sending the checkout off the
machine, and it was `Match::Command` on `curl -x post` — the segment had to
BEGIN with `curl -X POST`. The tap already held the answer to whether that is
what agents write. It is not:
158 Bash calls from the two production missions
166 curl invocations, every one a GET
84 curl -s 26 curl -s -L --max-time
22 curl -s --max-time 20 curl -sL 7 curl -s -o
Every one begins `curl -s`, and that `-s` pushes the needle off position zero.
Run against the real generated guest script, all of these were ALLOWED:
curl -s -X POST https://… -d @/mission/repo/secret.md
curl -d @report.md https://… curl -F [email protected] https://…
curl -T report.md https://… curl --upload-file report.md https://…
wget --post-file=report.md https://…
Zero denials in production therefore meant nothing. A gate with nothing to deny
and a gate anchored to a spelling its own traffic never uses produce identical
output — the shape this codebase keeps meeting.
`Match::Carries(cmd)` matches a segment that STARTS with the command and
contains the needle anywhere after it, so flag order stops mattering. A rule now
carries several needles, because one action has many spellings and a rule per
spelling is how half of them get missed.
`CarriesExact` exists for the flags whose CASE is their meaning: curl's `-F`
uploads a form and `-f` fails quietly, as in the wholly ordinary `curl -fsSL`.
Lowercasing the command before splitting made those one string, so segments are
now lowercased individually and the exact rules read the original.
`--data-urlencode` is deliberately absent: with `-G` it builds a query string
for a GET, and denying the read idiom to catch a rare POST spelling is the trade
this module refuses to make.
Also closed a divergence between the two implementations of one policy: the
generated shell had no text-tool exemption, so it denied
`echo --dangerously-skip-permissions` while the Rust predicate allowed it.
Evidence, not assertion: all 158 recorded production commands replayed through
the new script deny 0, and the six shapes above deny with the reason reaching
the model.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WZb5A2kfVfjpdwSochkuHz
|
||
|
|
0be932fd83 |
test(gate): a fixture emitter for the live PreToolUse check, and what it proved
Tried to close the last open question — does the PreToolUse gate actually
fire in a guest — and got most of the way.
Established:
- the generated script blocks and allows correctly under DASH, not just
macOS sh: force-push and `cd /tmp && rm -rf /` return 2, while
`grep -rn 'rm -rf /' docs/` and ordinary work return 0
- without node it allows and writes the `inert` marker, so a gate that
cannot parse is distinguishable from one that matched nothing
- `claude` in the runtime image supports `--settings` (SETTINGS-OK)
- PreToolUse DOES fire under `claude -p` in this image — measured by an
earlier session and recorded in vm_stop_gate.rs:36
Unproven, and now precisely scoped: whether Claude Code honours a
PreToolUse hook supplied via `--settings <path>` specifically, with a real
agent turn. The live attempt hit the weekly subscription rate limit, and
`claude doctor` does not report hooks, so there is no non-LLM confirmation
available.
`emit_guest_assets` (ignored by default) writes the real hook script and the
real settings document to /tmp so the check can be run against the actual
binary in one docker command — no microVM, no fleet. The exact command is in
docs/NEXT-SESSION.md.
Worth stating plainly: if that link is broken, the gate is inert in
production and looks exactly like a gate that found nothing — which is the
failure mode this whole session has been about.
Full workspace suite green: 107 binaries.
Co-Authored-By: Claude Opus 5 <[email protected]>
|
||
|
|
128b423205 |
fix(ci): the tool gate needs node, and an inert gate must say so
The first push of the PreToolUse gate failed CI, and the reason is a property of the gate worth fixing rather than a CI quirk. The hook parses its JSON payload with `node` — no jq in the runtime image, and node is guaranteed there because Claude Code is a node program. CI runs `cargo test --workspace` inside `rust:1.96-slim`, which has no node. The extraction returned nothing, the gate allowed everything, and the two "blocks" tests failed. That is correct behaviour with a dangerous appearance. A gate that cannot read its input must not block the phase — failing closed on a parse error denies every tool call, which is what an earlier `case`-syntax bug did. But allowing silently makes an INERT gate indistinguishable from one that simply matched nothing, which is this codebase's recurring defect exactly. So the gate now records `inert` when node is absent, still allowing, and a test pins both halves: exit 0, and the marker written. The host can check for that file rather than infer a working gate from an absence of denials. CI installs nodejs so the shell tests exercise the gate instead of its inert path. Verified in a rust:1.96-slim container: without node the force-push payload returns 0, with node it returns 2. Also confirmed the generated script behaves under dash — Linux /bin/sh — not only under macOS sh. An earlier apparent dash failure was invalid JSON in the probe command, not the gate. Full workspace suite green: 106 binaries. Co-Authored-By: Claude Opus 5 <[email protected]> |
||
|
|
547b5d9987 |
feat(missions): a pre-execution gate on mission tool calls
The second half of the tool-call research. Until now a mission agent's
Bash call was gated by nothing, anywhere.
WHY THERE WAS NO GATE
vm_tool_tap is a PostToolUse hook: it fires after the tool has already run
and exit-0s unconditionally, because a non-zero PostToolUse talks back to
the model. It is telemetry and says so. GatePolicy — the §15 door — has one
enforcement site, the chat loop, and its approvals key on
(session_id, message_id), which no mission phase can produce. Meanwhile the
solo tiers run `claude -p --permission-mode acceptEdits` with Read, Edit,
Write and Bash pre-approved.
PreToolUse fires under `claude -p` in this image — measured by vm_stop_gate,
which also proved the exit-2-plus-stderr contract — and had zero callers.
This is that hook.
WHAT IT IS, AND IS NOT
A deterministic policy gate: a short deny list of actions with no legitimate
form inside a mission, blocked before they run, with the reason handed back
so the model can choose differently.
It is NOT the §15 human approval gate, and the module says so. A hook blocks
the agent's process while it runs and a human decision takes minutes to
hours; waiting inside the hook would wedge the turn. This closes the gap
between nothing and something.
The deny list is short on purpose. A gate that blocks legitimate work is
worse than none: the agent cannot ask a human, so it either works around the
block — doing something stranger than what was denied — or burns the turn.
FOUR BUGS THE TESTS FOUND, IN ORDER
1. Substring matching denied `grep -rn 'rm -rf /' docs/`. Searching for a
string is not running it. Now rules anchor to the start of a shell
segment, with a separate flag-style match that exempts text tools.
2. The `case` patterns were unquoted, so a needle containing a space made
the whole script a SYNTAX ERROR — which as a PreToolUse hook exits
non-zero and denies EVERY call. Every text assertion passed while the
script was in that state; only running it under a real `sh` found it.
3. The hook receives JSON, not a command, so "starts with" could never
match — `case` saw `{"tool_name":"bash",...` every time. Now extracts
tool_name and tool_input.command with `node` (no jq in the image; node is
guaranteed because Claude Code is a node program).
4. `IFS='\n'` in POSIX sh sets IFS to backslash and the letter n, not a
newline. Nothing split, so only commands with no separator were ever
tested and `cd /tmp && rm -rf /` sailed through. Now a literal newline.
Every failure path allows. A gate that fails closed on a parse error blocks
the whole phase, which is exactly what bug 2 did.
Wired through vm_tool_tap::guest_settings, still the single writer of the
guest settings document — a third hook makes the clobber it prevents more
likely, not less, and a test asserts all three survive one document and that
PreToolUse points at the gate's own script rather than the tap's.
Honest limit, stated in the module: a determined agent defeats any
string-matching gate. This is aimed at accidents and obvious cases; the real
isolation is the container and microVM boundary.
Full workspace suite green: 106 binaries, zero build errors.
Co-Authored-By: Claude Opus 5 <[email protected]>
|