//! Gate and observe the tools a **container-tier** mission agent runs. //! //! The container tier is the one that actually runs missions in production, //! and until now it had neither. Both gaps have the same cause: `claude_cli` //! runs claude as a subprocess, claude runs its tools inside that subprocess, //! and so those calls never pass through ZeroClaw's executor — which is 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 own `stream-json` output does not help either: the transport was //! never the problem, and a mission proved it by producing zero `tool.call` //! events with the parser working perfectly. //! //! Hooks are the way in, and they are already proven. Claude Code reads //! `hooks.PreToolUse` / `PostToolUse` from the document passed to `--settings` //! and honours them under `-p` — measured against the real binary, where a //! `PreToolUse` hook blocked a `Bash` call, recorded the payload, and got its //! refusal reason back to the model. //! //! So this module writes the same hook scripts the microVM tier already uses //! into the mission's container, and the provider is pointed at the settings //! document. One mechanism, two tiers. //! //! # Everything here degrades to "no hooks", never to a failed mission //! //! A phase that runs unobserved still delivers. A phase that fails to start //! because telemetry could not be installed delivers nothing, which is a worse //! trade — the same stance `microvm_executor` takes for the same reason. use bollard::Docker; use std::time::Duration; /// Where the hooks live inside the mission container. /// /// Under `/root`, never under `/mission/repo`: anything written into the /// checkout would show up in the diff the mission delivers. pub const HOOK_DIR: &str = "/root/toolhooks"; /// The settings document `claude -p --settings` is pointed at. pub const SETTINGS_PATH: &str = "/root/toolhooks/settings.json"; /// Where the `PostToolUse` tap appends, inside the container. pub const TAP_DIR: &str = "/root/toolhooks/tap"; pub const INSTALL_TIMEOUT: Duration = Duration::from_secs(30); /// Install the pre-execution gate and the tool tap into a mission container. /// /// Returns the settings path on success. `None` means the container runs /// without hooks — logged, never fatal. pub async fn install(docker: &Docker, container: &str) -> Option { install_with(docker, container, None).await } /// As [`install`], carrying a phase's task policy into the gate. pub async fn install_with( docker: &Docker, container: &str, task: Option<&crate::vm_tool_gate::TaskPolicy>, ) -> Option { let script = build_install_script(task); let argv = vec!["sh".to_string(), "-lc".to_string(), script]; match crate::container_exec::exec_as_root(docker, container, None, &argv, INSTALL_TIMEOUT).await { Ok(out) if out.exit_code == Some(0) => Some(SETTINGS_PATH.to_string()), other => { eprintln!( "container_tool_hooks: could not install hooks in {container} ({other:?}) — \ this mission's tool calls will run unchecked and unrecorded" ); None } } } /// One shell script that lays down both hooks and the settings document. /// /// Composed here rather than by each hook module writing its own file: two /// writers of one `settings.json` is a silent clobber, and the microVM tier /// already learned that the expensive way. fn build_install_script(task: Option<&crate::vm_tool_gate::TaskPolicy>) -> String { let settings = crate::vm_tool_tap::guest_settings( None, Some(TAP_DIR), Some(HOOK_DIR), ); format!( "set -e\n\ mkdir -p {hooks} {tap}\n\ cat > {hooks}/tool-gate.sh <<'CM_GATE_EOF'\n{gate}\nCM_GATE_EOF\n\ chmod +x {hooks}/tool-gate.sh\n\ cat > {tap}/tap.sh <<'CM_TAP_EOF'\n{tap_script}\nCM_TAP_EOF\n\ chmod +x {tap}/tap.sh\n\ cat > {settings_path} <<'CM_SETTINGS_EOF'\n{settings}\nCM_SETTINGS_EOF\n", hooks = HOOK_DIR, tap = TAP_DIR, gate = crate::vm_tool_gate::hook_script_with(HOOK_DIR, task), tap_script = crate::vm_tool_tap::hook_script(TAP_DIR), settings_path = SETTINGS_PATH, settings = settings, ) } /// The MCP configuration `claude -p --mcp-config` is pointed at. /// /// Under `/root` with the hooks, never under `/mission/repo`: it carries a /// bearer token, and anything written into the checkout arrives in the diff the /// mission delivers. pub const MCP_CONFIG_PATH: &str = "/root/toolhooks/clawmates-mcp.json"; /// Where the mission container reaches this server. /// /// Mission containers join `clawmates_core`, the same network the API is on, so /// the API is reachable by container name. The name differs between /// deployments (`clawmates-server-1` locally, `clawmates_server_1` on gw-04), /// so the default is derived from **our own** hostname — docker's embedded DNS /// resolves a container id on a user-defined network, which makes this /// self-configuring rather than a constant that is right in one place. /// Measured from a sibling container: both the id and the name return 200. pub fn api_origin() -> Option { if let Ok(v) = std::env::var("CLAWMATES_API_ORIGIN") { if !v.trim().is_empty() { return Some(v.trim().trim_end_matches('/').to_string()); } } let host = std::env::var("HOSTNAME").ok()?; let host = host.trim(); if host.is_empty() { return None; } Some(format!("http://{host}:8080")) } /// The `--mcp-config` document: one HTTP server, carrying its own credential. /// /// The token is a `skills:read` session and nothing else. It is written into a /// file the agent can read — it runs `Bash` — so the only thing keeping this /// safe is that the credential authenticates to exactly one route. See /// `cm_auth::authenticate_scoped`. pub fn mcp_document(origin: &str, token: &str) -> serde_json::Value { serde_json::json!({ "mcpServers": { "clawmates_skills": { "type": "http", "url": format!("{origin}/mcp/skills"), "headers": { "Authorization": format!("Bearer {token}") } } } }) } // NOTE on `--allowedTools`. The provider passes it only when the config sets // `tools`, and the seed already does — without it `claude -p` stops mid-turn to // ask for write permission. Whether the MCP tools ALSO need naming there is not // documented anywhere we control, and the daemon exposes no config read to // merge into that list safely: overwriting it would take `Write` and `Bash` // away from every mission agent, and that failure would look like agents that // stopped working rather than a config that was replaced. // // So it is left alone and the question is answered by running a mission with // the door installed. Guessing here is how the last three defects in this file // were introduced. /// Write the MCP configuration into a mission container. /// /// Returns the path on success. `None` means the mission runs without a door — /// logged, never fatal, exactly like the hooks above. A phase that cannot /// retrieve a skill still delivers; a phase that fails to start because a /// config write failed delivers nothing. pub async fn install_door(docker: &Docker, container: &str, doc: &serde_json::Value) -> Option { // `printf %s` with the JSON single-quoted, not a heredoc: the document is // one line and contains no newline to terminate on. let script = format!( "mkdir -p {HOOK_DIR} && printf '%s' {} > {MCP_CONFIG_PATH} && chmod 600 {MCP_CONFIG_PATH}", crate::vm_tool_tap::shell_quote(&doc.to_string()), ); let argv = vec!["sh".to_string(), "-lc".to_string(), script]; match crate::container_exec::exec_as_root(docker, container, None, &argv, INSTALL_TIMEOUT).await { Ok(out) if out.exit_code == Some(0) => Some(MCP_CONFIG_PATH.to_string()), other => { eprintln!( "container_tool_hooks: could not write the MCP config in {container} ({other:?}) — this mission runs without the skills door" ); None } } } /// Event kinds under which the gate's own state lands in the mission record. /// /// Recorded, not only logged, so "was this mission gated?" is answerable from /// the mission afterwards. Stderr is where the answer used to go, which is the /// same place as nowhere once the container that printed it is gone. pub const GATE_INSTALLED: &str = "gate.installed"; pub const GATE_ABSENT: &str = "gate.absent"; /// The gate ran but could not parse its input and allowed everything. See /// [`crate::vm_tool_gate::INERT_FILE`] — this is the reader that marker was /// missing in production; until now only a unit test looked for it. pub const GATE_INERT: &str = "gate.inert"; /// One call the gate refused. `detail` is the hook event with `rule` set /// beside it — see [`crate::vm_tool_gate::denial_detail`]. Both tiers. pub const GATE_DENIED: &str = "gate.denied"; /// One call a task policy would have refused while it was in shadow. Same /// detail shape as [`GATE_DENIED`]; the difference is that it RAN. pub const GATE_WOULD_DENY: &str = "gate.would_deny"; /// Write the install outcome into the mission record. pub async fn record_install( pool: &sqlx::PgPool, mission_id: uuid::Uuid, phase_id: Option, hooks: Option<&str>, ) { let mut e = match hooks { Some(path) => crate::mission_events::MissionEvent::new(mission_id, GATE_INSTALLED) .target(path) .detail(serde_json::json!({ "settings": path, "tap": tap_file() })), None => crate::mission_events::MissionEvent::new(mission_id, GATE_ABSENT).detail( serde_json::json!({ "why": "container_tool_hooks::install failed — this mission's tool \ calls run unchecked and unrecorded" }), ), }; if let Some(p) = phase_id { e = e.phase(p); } crate::mission_events::record(pool, e).await; } /// The inert marker's path inside the container. pub fn inert_file() -> String { format!("{HOOK_DIR}/{}", crate::vm_tool_gate::INERT_FILE) } /// Did the gate go inert since the last drain? Reads the marker and clears /// it, so each occurrence is reported once. /// /// `Some(text)` is the marker's contents — every line the gate appended while /// it could not parse. `None` is "the marker is not there", which is the /// normal case and also, by construction, the only case that means the gate /// was actually checking. pub async fn drain_inert(docker: &Docker, container: &str) -> Option { let file = inert_file(); let script = format!("cat {file} 2>/dev/null && rm -f {file} 2>/dev/null; true"); let argv = vec!["sh".to_string(), "-lc".to_string(), script]; match crate::container_exec::exec_as_root(docker, container, None, &argv, INSTALL_TIMEOUT).await { Ok(out) if !out.stdout.trim().is_empty() => Some(out.stdout.trim().to_string()), _ => None, } } /// The gate's denial record inside the mission container. pub fn denied_file() -> String { format!("{HOOK_DIR}/{}", crate::vm_tool_gate::DENIED_FILE) } /// Every call the gate refused since the last drain, one JSON line each /// (`vm_tool_gate::denial_detail` reads them). Read-then-truncate, like /// [`drain`], for the same reason: no cursor to keep, and the phase has /// finished so nothing is appending. pub async fn drain_denied(docker: &Docker, container: &str) -> Vec { let file = denied_file(); let script = format!("cat {file} 2>/dev/null || true; : > {file} 2>/dev/null || true"); let argv = vec!["sh".to_string(), "-lc".to_string(), script]; match crate::container_exec::exec_as_root(docker, container, None, &argv, INSTALL_TIMEOUT).await { Ok(out) => out .stdout .lines() .map(str::trim) .filter(|l| !l.is_empty()) .map(str::to_string) .collect(), Err(_) => Vec::new(), } } /// The gate's shadow record: calls a policy WOULD have refused, had it been /// enforcing. Drained exactly like [`drain_denied`] and recorded as /// [`GATE_WOULD_DENY`], because a shadow mode whose output nobody reads is /// an off switch with extra steps. pub async fn drain_would_deny(docker: &Docker, container: &str) -> Vec { let file = format!("{HOOK_DIR}/{}", crate::vm_tool_gate::WOULD_DENY_FILE); let script = format!("cat {file} 2>/dev/null || true; : > {file} 2>/dev/null || true"); let argv = vec!["sh".to_string(), "-lc".to_string(), script]; match crate::container_exec::exec_as_root(docker, container, None, &argv, INSTALL_TIMEOUT).await { Ok(out) => out .stdout .lines() .map(str::trim) .filter(|l| !l.is_empty()) .map(str::to_string) .collect(), Err(_) => Vec::new(), } } /// Hosts named in fetched content, as the tap recorded them. One event per /// finished phase, carrying the whole list: stage 1 of argument provenance /// is observation only, and this is what gets inspected before any rule is /// built on it. pub const TAINT_HOSTS: &str = "taint.hosts"; /// Read the tap's taint file. NOT cleared, unlike every drain above: it is /// the state a future `untrusted-target` rule consults for the rest of the /// mission, so each phase's event is the set known when that phase ended. pub async fn drain_taint(docker: &Docker, container: &str) -> Vec { let argv = vec![ "sh".to_string(), "-lc".to_string(), crate::vm_tool_tap::taint_probe(TAP_DIR), ]; match crate::container_exec::exec_as_root(docker, container, None, &argv, INSTALL_TIMEOUT).await { Ok(out) => crate::vm_tool_tap::parse_taint(&out.stdout), Err(_) => Vec::new(), } } /// The detail of a [`TAINT_HOSTS`] event. pub fn taint_detail(hosts: &[String], tier: &str) -> serde_json::Value { serde_json::json!({ "hosts": hosts, "count": hosts.len(), "capped": hosts.len() >= crate::vm_tool_tap::MAX_TAINT_HOSTS, "tier": tier, }) } /// The tap file inside the mission container. pub fn tap_file() -> String { format!("{TAP_DIR}/tools.jsonl") } /// Read everything the tap recorded, then clear it. /// /// Read-then-truncate rather than a cursor, because this tier has no /// long-lived loop to hold one: the microVM path drains inside the turn it is /// watching, while a container turn is driven asynchronously by /// `topology_worker`. Truncation makes the drain idempotent — a second pass /// reads an empty file and records nothing — without a column to store a /// cursor in. /// /// Called only for phases that have FINISHED, so the agent is no longer /// appending and the read/truncate gap cannot lose an event. pub async fn drain(docker: &Docker, container: &str) -> Vec { let file = tap_file(); // `cat` then truncate in one exec: two round-trips would widen the window // between them for no benefit. let script = format!("cat {file} 2>/dev/null || true; : > {file} 2>/dev/null || true"); let argv = vec!["sh".to_string(), "-lc".to_string(), script]; match crate::container_exec::exec_as_root(docker, container, None, &argv, INSTALL_TIMEOUT).await { Ok(out) => crate::vm_tool_tap::parse(&out.stdout), Err(e) => { // A reaped container is the normal end state, not a fault. eprintln!("container_tool_hooks: no tap drained from {container}: {e}"); Vec::new() } } } #[cfg(test)] mod tests { use super::*; /// Every command the settings document names must be a file the installer /// actually writes. /// /// This caught a real one: the document pointed PostToolUse at /// `{TAP_DIR}/tap.sh` while the installer wrote `{HOOK_DIR}/tap.sh`, so /// the hook referenced a file that did not exist. Claude Code does not /// complain about a missing hook command — it simply records nothing, and /// a mission ran with the tap installed, pointed at nothing, and silent. /// /// Asserting that the script "mentions tap.sh" did not catch it. The paths /// have to be compared. #[test] fn every_hook_command_is_a_file_the_installer_writes() { let settings = crate::vm_tool_tap::guest_settings(None, Some(TAP_DIR), Some(HOOK_DIR)); let script = build_install_script(None); let hooks = settings["hooks"].as_object().expect("hooks"); assert!(!hooks.is_empty(), "no hooks at all"); for (event, entries) in hooks { let cmd = entries[0]["hooks"][0]["command"] .as_str() .unwrap_or_else(|| panic!("{event} has no command")); assert!( script.contains(&format!("cat > {cmd} <<")), "{event} points at {cmd}, which the installer never writes — \ the hook is registered and inert" ); assert!( script.contains(&format!("chmod +x {cmd}")), "{event} points at {cmd}, which is never made executable" ); } } #[test] fn the_script_writes_both_hooks_and_the_settings_document() { let s = build_install_script(None); assert!(s.contains("tool-gate.sh"), "the pre-execution gate is missing"); assert!(s.contains("tap.sh"), "the tool tap is missing"); assert!(s.contains(SETTINGS_PATH), "the settings document is missing"); // Both hooks in ONE document — the whole reason this is composed here. assert!(s.contains("PreToolUse")); assert!(s.contains("PostToolUse")); } /// Nothing may be written into the mission checkout. /// /// A file left under `/mission/repo` shows up in the diff the mission /// delivers, so hook plumbing would arrive as part of the agent's work. #[test] fn nothing_is_written_into_the_checkout() { assert!(HOOK_DIR.starts_with("/root/")); assert!(SETTINGS_PATH.starts_with("/root/")); assert!(TAP_DIR.starts_with("/root/")); assert!(!build_install_script(None).contains("/mission/repo")); } /// The two halves must stay together. /// /// Writing the hooks without pointing the provider at them leaves a gate /// that is installed and inert — indistinguishable from a gate that found /// nothing, which is this codebase's signature failure. Pointing the /// provider at a document nobody wrote makes claude fail to start. #[test] fn the_installer_and_the_provider_prop_agree() { let orchestrator = include_str!("mission_orchestrator.rs"); assert!( orchestrator.contains("set_claude_cli_settings") && orchestrator.contains("container_tool_hooks::SETTINGS_PATH"), "the hooks are installed but nothing points claude at them" ); let runtime = include_str!("mission_runtime.rs"); assert!( runtime.contains("container_tool_hooks::install"), "the provider is pointed at a settings document nobody writes" ); // Both container paths — created AND reused. A hook that exists only // on first creation disappears after a server redeploy. assert_eq!( runtime.matches("container_tool_hooks::install").count(), 2, "install must run on the reuse path too" ); } /// The drain must clear what it read. /// /// Truncation IS the idempotency here — there is no cursor column and no /// marker row. A drain that reads without clearing would re-record every /// tool call on every tick, and a phase's early files would end up weighted /// by how long the sweep ran. #[test] fn the_drain_reads_then_clears() { let file = tap_file(); assert!(file.starts_with(TAP_DIR), "the tap must live under {TAP_DIR}"); // The script is built inline in `drain`; assert on the shape it must // have, since getting this wrong duplicates every event silently. let script = format!("cat {file} 2>/dev/null || true; : > {file} 2>/dev/null || true"); assert!(script.contains(&format!("cat {file}")), "must read"); assert!(script.contains(&format!(": > {file}")), "must clear"); } /// The sweep has to exist, or the hooks write a file nobody reads. #[test] fn something_actually_collects_the_tap() { let runner = include_str!("phase_runner.rs"); assert!( runner.contains("container_tool_hooks::drain"), "the tap is written and never collected — the same shape as a gate \ that is installed and inert" ); assert!( runner.contains("drain_finished_container_phases(pool).await?"), "the drain exists but the tick does not call it" ); } /// The taint file is never cleared, so its record needs its own /// once-per-phase guard. Measured without one: the first live mission /// recorded the same `taint.hosts` event four times, and the sweep that /// revisits a finished phase for 30 minutes would have kept going. #[test] fn the_taint_record_is_written_once_per_phase() { let runner = include_str!("phase_runner.rs"); let body = runner .split("drain_taint(&docker, &container).await") .nth(1) .expect("the sweep drains the taint file"); let guard = body.find("SELECT EXISTS").expect("no once-per-phase guard"); let record = body.find("TAINT_HOSTS,\n").unwrap_or(usize::MAX).min( body.find("MissionEvent::new").expect("the record"), ); assert!(guard < record, "the guard must run before the record is written"); } /// The drain must use the connector that honours DOCKER_HOST. /// /// The server reaches Docker through a socket proxy, so /// `connect_with_local_defaults` fails there — and it failed SILENTLY, /// which meant the sweep did nothing while the tap filled up and every /// other link in the chain looked correct. Cost a full diagnostic cycle. #[test] fn the_sweep_connects_the_way_the_rest_of_the_server_does() { let runner = include_str!("phase_runner.rs"); let body = runner .split("async fn drain_finished_container_phases(") .nth(1) .and_then(|s| s.split("\nasync fn ").next()) .expect("sweep body"); assert!( body.contains("container_exec::connect()"), "the sweep must use the DOCKER_HOST-aware connector" ); assert!( // The CALL, not the word: the comment above it names the // connector it is warning against. !body.contains("connect_with_local_defaults()"), "the local-socket connector fails behind the socket proxy" ); } /// The generated installer must be valid shell — a here-doc or quoting slip /// makes it fail in the container, where the only symptom is a mission that /// silently runs unhooked. #[test] fn the_install_script_is_valid_shell() { if std::process::Command::new("bash").arg("-c").arg("true").status().is_err() { return; } let tmp = std::env::temp_dir().join(format!("cm-install-{}.sh", std::process::id())); std::fs::write(&tmp, build_install_script(None)).unwrap(); let out = std::process::Command::new("bash") .arg("-n") .arg(&tmp) .output() .expect("bash -n"); let _ = std::fs::remove_file(&tmp); assert!( out.status.success(), "installer will not parse: {}", String::from_utf8_lossy(&out.stderr) ); } }