decc680bcce2c6f6b9bc3e6826df0c2ebade7514
10
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
decc680bcc |
fix(taint): record taint.hosts once per phase, not once per sweep tick
The container drain revisits finished phases for 30 minutes; the other drains are idempotent because they truncate, and the taint file deliberately is not. The first live mission (01a0cb7e) recorded the same event four times. Record only when no event for the phase already carries at least as many hosts. Live result otherwise as designed: curl https://example.com tainted iana.org (the page's link), not example.com (the agent's own target), and grep -rn curl added nothing. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> |
||
|
|
597e76b261 |
feat(taint): stage 1 of argument provenance — the tap records hosts fetched content named
The "derived from untrusted content" half of ActGov's invariant (no outbound action whose target came from untrusted content). Observed only; no rule reads it yet. - the tap runs a node extractor only when a payload could be a fetch (WebFetch, WebSearch, curl/wget in command position) and appends the response's URL hosts, minus the agent's own target, to untrusted-hosts.txt beside the tap — a path hook-files already protects - capped at 500, deduplicated, and the tap still always exits 0 - both tiers drain it per finished phase into a taint.hosts event - shell-tested against the generated hook with the real node; the test caught `grep -r curl docs` being read as a fetch Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> |
||
|
|
79a6119f4e |
feat(gate): task permission — the work surface is allowed, the platform is not
ActGov's second layer (arXiv 2609.24446), in the honest form our evidence supports. The paper binds each task to its minimum tools; 171 recorded tool calls cannot justify a per-task minimum, but they do justify the line this draws: files, commands, search, web, delegation and skills are the work surface and pass; ListAgents, ScheduleWakeup, CronCreate, SendMessage and the rest reach the platform itself and do not. That line is not theoretical. ListAgents and ScheduleWakeup were both called by microVM missions whose --allowedTools is Read Edit Write Bash Agent. Neither is on that list; both ran, because the flag governs permission prompting and not availability. Our gate is the only place this can be enforced. TaskPolicy is rendered into the same guest script as the floor and the role policies. A phase names its own set with "agent_tools" — NOT "tools", which security_scan already owns for its scanner list; both are now in phase_config::KNOWN_KEYS, adjacent, each saying what the other is. SHADOW BY DEFAULT. The gate records what it would have refused to would-deny.jsonl and allows the call; the host drains it into gate.would_deny on both tiers. CLAWMATES_TASK_PERMISSION=enforce flips it. A policy tightened on a guess and enforced on day one is how an agent learns to work around the gate, and a shadow mode nobody can read is an off switch with extra steps. The VM probe needed a sentinel: a refusal and a call that merely would have been refused are both JSON objects with the same keys, and telling them apart by content would confuse the one distinction shadow mode exists to make. Asymmetry, stated rather than hidden: a VM is per-phase and honours the phase's own agent_tools; a container serves every phase of its mission and gets the mission-wide default. Narrowing per phase there needs a re-install between phases and is not done. 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
|
||
|
|
248948cc84 |
fix: three things that were known and written nowhere
All three have the same shape — the system learns something and only stderr
hears it — and each was flagged in the handoff as a silent-discard defect.
The gate's install outcome. `container_tool_hooks::install` returned Some or
None and both call sites wrote `let _ =`. A mission whose gate never installed
left a record indistinguishable from one whose gate stood there and matched
nothing. `EnsuredContainer` now carries the outcome to the callers that have a
pool, and they record `gate.installed` (with the settings path) or
`gate.absent` on the mission, so "was this mission gated?" is answerable from
the mission.
The inert marker. `vm_tool_gate` writes an `inert` file when it cannot parse
its input and allows everything, precisely so an inert gate does not look like
a permissive one. The only reader was a unit test. `drain_inert` now reads and
clears it at every tap drain, and a `gate.inert` event with the occurrence count
lands beside the calls that ran unchecked.
The judge's spend. `LlmEvent::Usage` arrived on every judge call and was
matched by `Ok(_) => {}`. Two plan exhaustions (2026-08-29, 2026-09-09) with
no row anywhere saying a judge token had been spent; `usage_events` had no
provider or model column. The loop now accumulates requests and tokens onto the
Verdict — counting a request BEFORE the stream opens, so a 429 the provider
refused still counts, because the retry storm was made of those — and
`record` writes a `kind = 'judge'` row with provider, model, mission and
request count. Migration 0085 adds the columns, all nullable, so the two
existing writers are untouched.
Tests: a scripted-provider verdict records one request and nonzero tokens; a
provider that refuses still records the request and zero tokens.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WZb5A2kfVfjpdwSochkuHz
|
||
|
|
00160739de |
feat(skills): a files arm — progressive disclosure through Read, not a deferred tool
The `index` arm retrieves through `ReadMcpResourceTool`, which is DEFERRED:
absent from the agent's default tool list until `ToolSearch` loads it. Across
three matched production runs (same recipe, same task, same three offered
uris) it retrieved 1 skill in 9 chances:
01a07812 delegation forced no instruction 0/3
01a0842e no delegation no instruction 1/3
01a09877 no delegation told to load it 0/3
The third run is the decisive one. The preamble said in plain words to run
ToolSearch first; all three prompts carried it; zero ToolSearch calls, and the
three reasoning narratives never mention skills at all. The section was not
declined, it was never engaged with. Instruction is not the lever.
`Read` is a core tool. Never deferred, and every one of those agents used it.
So this arm keeps progressive disclosure exactly as `index` has it — a name, a
`when_to_use`, and a pointer the agent has to follow — and changes only what
the pointer is: a path under /mission/skills instead of an MCP uri. The bodies
are written into the container at launch (every visible skill, one tar upload;
bindings resolve per agent at turn time so a per-mission subset is not knowable
here) and a `Read` of that path is a tapped tool call, so Trigger is exactly as
observable as before.
A third arm and not a replacement, selected per mission like the others, so
the comparison runs against one binary. `resolve` falls back to `inline` when
the files were not written, for the reason `index` does: a pointer to nothing
reads as an agent ignoring its skills.
The writer and reader of a path are one pair of functions
(`skill_file_path` / `skill_from_file_path`), matched by the scorer through
the same seam `parse_uri` uses, and the end-to-end test fails when the matcher
is broken. `Mode::is_retrieval` exists so the next arm cannot silently inherit
`inline`'s "not observable" for what is a miss.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WZb5A2kfVfjpdwSochkuHz
|
||
|
|
73f5d71c55 |
feat(missions): install the skills door, with a credential it is safe to leave
The capability has been built and undeployed since `88eef99d4`: `claude_cli` accepts `mcp_config` and passes `--mcp-config --strict-mcp-config`, so Claude Code's own MCP client can reach our skills server. What was missing was the config document and, underneath it, a credential that could be left in a container an untrusted agent reads. Now both halves happen together — the document goes in, and the daemon is told to pass it — because doing one without the other leaves a door installed and unreachable, which looks exactly like a door nobody walked through. That is the same shape as the hooks that shipped installed and inert three bugs running. The API origin defaults to our own `HOSTNAME` rather than a container name. Mission containers share `clawmates_core` with the server, and the server's name differs between deployments (`clawmates-server-1` locally, `clawmates_server_1` on gw-04); docker's embedded DNS resolves a container id on a user-defined network, so this is self-configuring. Measured from a sibling container: both the id and the name return 200. `--allowedTools` is deliberately NOT touched. The provider passes it only when `tools` is set and the seed already sets it — without it `claude -p` stops mid-turn asking for write permission. Whether MCP tools also need naming there is undocumented in anything we control, and the daemon exposes no config read to merge into the list safely; overwriting it would take `Write` and `Bash` from every mission agent, and that failure would look like agents that stopped working rather than a config that was replaced. So the question gets answered by running a mission with the door installed. Guessing is how the last three defects in this file got in. Every failure degrades to "no door", never to a failed launch. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_018i9Ten1LU4jUr5d7TAWda9 |
||
|
|
e84413d437 |
fix(missions): the container tier now records its tool calls — verified live
Ran it end to end on a real mission. First time the container tier has ever
been observable:
tool.call 10 Bash 6, Read 3, Write 1
file.touch 4 research/tapproof.md
reasoning 5
prompt.composed 5
Three defects found by running it, each of which left every other link
looking correct:
1. The settings document pointed PostToolUse at {TAP_DIR}/tap.sh while the
installer wrote {HOOK_DIR}/tap.sh. Claude Code does not complain about a
hook command that does not exist — it records nothing. Asserting the
script "mentions tap.sh" had passed; the PATHS have to be compared, and
a test now does that for every hook the document names.
2. The mission container runs CLAWMATES_RUNTIME_IMAGE, not the shared
runtime container I had swapped. It was still on an image whose daemon
schema has no `settings` field, so set_claude_cli_settings returned
404 path_not_found — which the error message said plainly, and which is
the only reason this was quick to spot.
3. The sweep used connect_with_local_defaults(). The server reaches Docker
through a socket proxy (DOCKER_HOST), so that connector fails there — and
my code returned Ok(()) on the error, silently. The tap filled up, the
query matched rows, and nothing ran. Now uses container_exec::connect and
logs the failure; a test pins the choice.
All three are the same shape as the bug they were chasing: installed,
inert, indistinguishable from working. The tests added for each compare the
two ends rather than asserting a string appears somewhere.
Full workspace suite green: 107 binaries, 412 lib tests.
Co-Authored-By: Claude Opus 5 <[email protected]>
|
||
|
|
cd59e4798d |
feat(missions): collect the container tier's tool calls
The hooks from the previous commit write a tap file that nothing reads — which is the same shape as the gate that is installed and inert: everything looks wired and no evidence ever appears. The microVM tier records its tools from inside the loop watching the VM. A container turn is driven asynchronously by topology_worker, so there is no such loop and something has to come and collect the file. `drain_finished_container_phases` does, on the same tick as the benchmark baseline and the security scan, reusing `record_vm_tools` so container tool calls land as the same TOOL_CALL / FILE_TOUCH events the World already renders. One shape, two tiers. Idempotent by TRUNCATION, not a marker or a cursor column: `drain` clears the file it read, so a second pass finds nothing. Read-then-clear happens in one exec, and only for phases that have FINISHED — the agent is no longer appending, so the gap between read and clear cannot lose an event. A cursor would have needed a migration and a column that means nothing to anyone else. Two tests exist because the failure is silent either way: the drain must clear what it read (otherwise every tick re-records the same calls and a phase's early files end up weighted by how long the sweep ran), and the tick must actually call the sweep (otherwise the hooks write a file nobody collects). Full workspace suite green: 107 binaries. Co-Authored-By: Claude Opus 5 <[email protected]> |
||
|
|
b89606fcf1 |
feat(missions): gate and observe tools on the container tier
The container tier is the one that actually runs missions in production, and it had neither a tool gate nor tool telemetry. The microVM tier has had both since yesterday; the tier that matters had neither. Both gaps have one cause. `claude_cli` runs claude as a subprocess, claude runs its tools inside that subprocess, and those calls never pass through ZeroClaw's executor — the only thing that emits TurnEvent::ToolCall and therefore the only thing the gateway turns into a frame ClawMates can see. Recovering the calls from the CLI's stream-json output did not help: a real mission produced zero tool.call events with the parser working perfectly. The transport was never the problem. Hooks are the way in, and they are proven. Claude Code reads hooks.PreToolUse / PostToolUse from the document given to `--settings` and honours them under `-p` — measured yesterday against the real binary, where the gate blocked a Bash call, recorded the payload, and got its refusal reason back to the model. So the same hook scripts the microVM tier uses are now written into the mission's container, and the provider is pointed at the settings document (`--settings` added to claude_cli in the fork, be9c34b1c). Composed in ONE script for one document: two writers of one settings.json is a silent clobber, and the microVM tier already learned that expensively. Installed on BOTH container paths — created and reused. A hook that exists only on first creation quietly disappears after a server redeploy, and the container outlives the server process. Everything degrades to "no hooks", never to a failed mission: a phase that runs unobserved still delivers; one that fails to start because telemetry could not be installed delivers nothing. Four tests, including two that exist because the halves are inert alone: the installer and the provider prop must both be wired (hooks nobody reads, or a document nobody wrote), and nothing may be written under /mission/repo, where it would arrive as part of the agent's delivered diff. Full workspace suite green: 107 binaries. Co-Authored-By: Claude Opus 5 <[email protected]> |