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]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
930c7e0b67
commit
b89606fcf1
@@ -0,0 +1,169 @@
|
|||||||
|
//! 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";
|
||||||
|
|
||||||
|
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<String> {
|
||||||
|
let script = build_install_script();
|
||||||
|
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() -> 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 > {hooks}/tap.sh <<'CM_TAP_EOF'\n{tap_script}\nCM_TAP_EOF\n\
|
||||||
|
chmod +x {hooks}/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(HOOK_DIR),
|
||||||
|
tap_script = crate::vm_tool_tap::hook_script(TAP_DIR),
|
||||||
|
settings_path = SETTINGS_PATH,
|
||||||
|
settings = settings,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_script_writes_both_hooks_and_the_settings_document() {
|
||||||
|
let s = build_install_script();
|
||||||
|
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().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 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()).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)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -52,6 +52,7 @@ pub mod runtime_preflight;
|
|||||||
pub mod runtime_provision;
|
pub mod runtime_provision;
|
||||||
pub mod security_scan;
|
pub mod security_scan;
|
||||||
pub mod session_executor;
|
pub mod session_executor;
|
||||||
|
pub mod container_tool_hooks;
|
||||||
pub mod gateway_preflight;
|
pub mod gateway_preflight;
|
||||||
pub mod skill_self_authoring;
|
pub mod skill_self_authoring;
|
||||||
pub mod skill_use;
|
pub mod skill_use;
|
||||||
|
|||||||
@@ -331,6 +331,22 @@ pub async fn on_launch(
|
|||||||
Some(url) => RuntimeProvisioner::for_gateway(url),
|
Some(url) => RuntimeProvisioner::for_gateway(url),
|
||||||
None => RuntimeProvisioner::from_env(),
|
None => RuntimeProvisioner::from_env(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Tell the daemon where the hooks are. `container_tool_hooks::install`
|
||||||
|
// wrote them; this is what makes claude read them. Doing one without the
|
||||||
|
// other leaves a gate that is installed and inert, which looks exactly
|
||||||
|
// like a gate that found nothing.
|
||||||
|
if let Some(p) = provisioner.as_ref() {
|
||||||
|
if let Err(e) = p
|
||||||
|
.set_claude_cli_settings(crate::container_tool_hooks::SETTINGS_PATH)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
eprintln!(
|
||||||
|
"mission_orchestrator: could not point claude_cli at the hook \
|
||||||
|
settings ({e}) — this mission's tool calls run unchecked"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
let mut first_team_id: Option<Uuid> = None;
|
let mut first_team_id: Option<Uuid> = None;
|
||||||
let mut provisioned_claws: Vec<cm_domain::AgentId> = Vec::new();
|
let mut provisioned_claws: Vec<cm_domain::AgentId> = Vec::new();
|
||||||
for (purpose, template_id) in &picks {
|
for (purpose, template_id) in &picks {
|
||||||
|
|||||||
@@ -611,6 +611,10 @@ impl MissionRuntimeProvisioner {
|
|||||||
// Reuse; try to re-scrape the pairing code from logs,
|
// Reuse; try to re-scrape the pairing code from logs,
|
||||||
// but it may have rotated out — caller falls back to
|
// but it may have rotated out — caller falls back to
|
||||||
// the previously-persisted value in that case.
|
// the previously-persisted value in that case.
|
||||||
|
// Re-install on reuse: the container outlives the server
|
||||||
|
// process, and a hook that exists only on first creation is a
|
||||||
|
// hook that quietly disappears after a redeploy.
|
||||||
|
let _ = crate::container_tool_hooks::install(&self.docker, &name).await;
|
||||||
let pairing_code = self.mint_pairing_code(&name).await;
|
let pairing_code = self.mint_pairing_code(&name).await;
|
||||||
return Ok(EnsuredContainer {
|
return Ok(EnsuredContainer {
|
||||||
endpoint: endpoint_url(&name),
|
endpoint: endpoint_url(&name),
|
||||||
@@ -792,6 +796,12 @@ impl MissionRuntimeProvisioner {
|
|||||||
// slow boot doesn't hang the launch — the topology_worker
|
// slow boot doesn't hang the launch — the topology_worker
|
||||||
// will retry pairing later if we came up empty.
|
// will retry pairing later if we came up empty.
|
||||||
let pairing_code = self.wait_for_pairing_code(&name).await;
|
let pairing_code = self.wait_for_pairing_code(&name).await;
|
||||||
|
|
||||||
|
// Gate and observe the tools claude runs inside its own subprocess.
|
||||||
|
// Best-effort by design: a phase that runs unhooked still delivers, and
|
||||||
|
// failing the launch to protect telemetry would be the wrong trade.
|
||||||
|
let _ = crate::container_tool_hooks::install(&self.docker, &name).await;
|
||||||
|
|
||||||
Ok(EnsuredContainer {
|
Ok(EnsuredContainer {
|
||||||
endpoint: endpoint_url(&name),
|
endpoint: endpoint_url(&name),
|
||||||
pairing_code,
|
pairing_code,
|
||||||
|
|||||||
@@ -186,6 +186,21 @@ impl RuntimeProvisioner {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Point `claude_cli.default` at a settings document, so the hooks written
|
||||||
|
/// into the container are actually read.
|
||||||
|
///
|
||||||
|
/// Without this the gate and the tap exist on disk and claude never loads
|
||||||
|
/// them — installed, inert, and indistinguishable from working. The alias
|
||||||
|
/// is `claude_cli.default` because that is what `provider_alias_for` binds
|
||||||
|
/// every claude model to.
|
||||||
|
pub async fn set_claude_cli_settings(&self, path: &str) -> Result<(), String> {
|
||||||
|
self.set_prop(
|
||||||
|
"providers.models.claude_cli.default.settings",
|
||||||
|
serde_json::json!(path),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
/// Rebind an existing claw's model without touching its risk_profile
|
/// Rebind an existing claw's model without touching its risk_profile
|
||||||
/// or mcp_bundles. Used by the "change model" UI on the Agents page
|
/// or mcp_bundles. Used by the "change model" UI on the Agents page
|
||||||
/// so we don't accidentally demote a coding_readwrite claw back to
|
/// so we don't accidentally demote a coding_readwrite claw back to
|
||||||
|
|||||||
Reference in New Issue
Block a user