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
This commit is contained in:
co-authored by
Claude Opus 5
parent
2d1f3954e8
commit
79a6119f4e
@@ -46,7 +46,16 @@ pub const INSTALL_TIMEOUT: Duration = Duration::from_secs(30);
|
|||||||
/// Returns the settings path on success. `None` means the container runs
|
/// Returns the settings path on success. `None` means the container runs
|
||||||
/// without hooks — logged, never fatal.
|
/// without hooks — logged, never fatal.
|
||||||
pub async fn install(docker: &Docker, container: &str) -> Option<String> {
|
pub async fn install(docker: &Docker, container: &str) -> Option<String> {
|
||||||
let script = build_install_script();
|
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<String> {
|
||||||
|
let script = build_install_script(task);
|
||||||
let argv = vec!["sh".to_string(), "-lc".to_string(), 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
|
match crate::container_exec::exec_as_root(docker, container, None, &argv, INSTALL_TIMEOUT).await
|
||||||
{
|
{
|
||||||
@@ -66,7 +75,7 @@ pub async fn install(docker: &Docker, container: &str) -> Option<String> {
|
|||||||
/// Composed here rather than by each hook module writing its own file: two
|
/// 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
|
/// writers of one `settings.json` is a silent clobber, and the microVM tier
|
||||||
/// already learned that the expensive way.
|
/// already learned that the expensive way.
|
||||||
fn build_install_script() -> String {
|
fn build_install_script(task: Option<&crate::vm_tool_gate::TaskPolicy>) -> String {
|
||||||
let settings = crate::vm_tool_tap::guest_settings(
|
let settings = crate::vm_tool_tap::guest_settings(
|
||||||
None,
|
None,
|
||||||
Some(TAP_DIR),
|
Some(TAP_DIR),
|
||||||
@@ -82,7 +91,7 @@ fn build_install_script() -> String {
|
|||||||
cat > {settings_path} <<'CM_SETTINGS_EOF'\n{settings}\nCM_SETTINGS_EOF\n",
|
cat > {settings_path} <<'CM_SETTINGS_EOF'\n{settings}\nCM_SETTINGS_EOF\n",
|
||||||
hooks = HOOK_DIR,
|
hooks = HOOK_DIR,
|
||||||
tap = TAP_DIR,
|
tap = TAP_DIR,
|
||||||
gate = crate::vm_tool_gate::hook_script(HOOK_DIR),
|
gate = crate::vm_tool_gate::hook_script_with(HOOK_DIR, task),
|
||||||
tap_script = crate::vm_tool_tap::hook_script(TAP_DIR),
|
tap_script = crate::vm_tool_tap::hook_script(TAP_DIR),
|
||||||
settings_path = SETTINGS_PATH,
|
settings_path = SETTINGS_PATH,
|
||||||
settings = settings,
|
settings = settings,
|
||||||
@@ -189,6 +198,9 @@ pub const GATE_INERT: &str = "gate.inert";
|
|||||||
/// One call the gate refused. `detail` is the hook event with `rule` set
|
/// One call the gate refused. `detail` is the hook event with `rule` set
|
||||||
/// beside it — see [`crate::vm_tool_gate::denial_detail`]. Both tiers.
|
/// beside it — see [`crate::vm_tool_gate::denial_detail`]. Both tiers.
|
||||||
pub const GATE_DENIED: &str = "gate.denied";
|
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.
|
/// Write the install outcome into the mission record.
|
||||||
pub async fn record_install(
|
pub async fn record_install(
|
||||||
@@ -263,6 +275,27 @@ pub async fn drain_denied(docker: &Docker, container: &str) -> Vec<String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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<String> {
|
||||||
|
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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The tap file inside the mission container.
|
/// The tap file inside the mission container.
|
||||||
pub fn tap_file() -> String {
|
pub fn tap_file() -> String {
|
||||||
format!("{TAP_DIR}/tools.jsonl")
|
format!("{TAP_DIR}/tools.jsonl")
|
||||||
@@ -314,7 +347,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn every_hook_command_is_a_file_the_installer_writes() {
|
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 settings = crate::vm_tool_tap::guest_settings(None, Some(TAP_DIR), Some(HOOK_DIR));
|
||||||
let script = build_install_script();
|
let script = build_install_script(None);
|
||||||
|
|
||||||
let hooks = settings["hooks"].as_object().expect("hooks");
|
let hooks = settings["hooks"].as_object().expect("hooks");
|
||||||
assert!(!hooks.is_empty(), "no hooks at all");
|
assert!(!hooks.is_empty(), "no hooks at all");
|
||||||
@@ -336,7 +369,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_script_writes_both_hooks_and_the_settings_document() {
|
fn the_script_writes_both_hooks_and_the_settings_document() {
|
||||||
let s = build_install_script();
|
let s = build_install_script(None);
|
||||||
assert!(s.contains("tool-gate.sh"), "the pre-execution gate is missing");
|
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("tap.sh"), "the tool tap is missing");
|
||||||
assert!(s.contains(SETTINGS_PATH), "the settings document is missing");
|
assert!(s.contains(SETTINGS_PATH), "the settings document is missing");
|
||||||
@@ -354,7 +387,7 @@ mod tests {
|
|||||||
assert!(HOOK_DIR.starts_with("/root/"));
|
assert!(HOOK_DIR.starts_with("/root/"));
|
||||||
assert!(SETTINGS_PATH.starts_with("/root/"));
|
assert!(SETTINGS_PATH.starts_with("/root/"));
|
||||||
assert!(TAP_DIR.starts_with("/root/"));
|
assert!(TAP_DIR.starts_with("/root/"));
|
||||||
assert!(!build_install_script().contains("/mission/repo"));
|
assert!(!build_install_script(None).contains("/mission/repo"));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The two halves must stay together.
|
/// The two halves must stay together.
|
||||||
@@ -452,7 +485,7 @@ mod tests {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let tmp = std::env::temp_dir().join(format!("cm-install-{}.sh", std::process::id()));
|
let tmp = std::env::temp_dir().join(format!("cm-install-{}.sh", std::process::id()));
|
||||||
std::fs::write(&tmp, build_install_script()).unwrap();
|
std::fs::write(&tmp, build_install_script(None)).unwrap();
|
||||||
let out = std::process::Command::new("bash")
|
let out = std::process::Command::new("bash")
|
||||||
.arg("-n")
|
.arg("-n")
|
||||||
.arg(&tmp)
|
.arg(&tmp)
|
||||||
|
|||||||
@@ -319,25 +319,43 @@ const SETTINGS_PROBE: &str = "claude --help 2>&1 | grep -q -- '--settings' && ec
|
|||||||
/// month while the container tier moved to 2.1.276, and nothing recorded either.
|
/// month while the container tier moved to 2.1.276, and nothing recorded either.
|
||||||
const CLI_VERSION_PROBE: &str = "claude --version 2>/dev/null | head -c 80";
|
const CLI_VERSION_PROBE: &str = "claude --version 2>/dev/null | head -c 80";
|
||||||
|
|
||||||
/// Read the tool gate's denials and inert marker out of the guest, in one
|
/// Read the tool gate's denials, its shadow record and the inert marker out
|
||||||
/// exec. The marker is a line count of "gave up" events; the denials are the
|
/// of the guest, in one exec. The marker is a line count of "gave up"
|
||||||
/// gate's own JSONL. A missing file is an empty section, not an error.
|
/// events; the other two are the gate's own JSONL. A missing file is an
|
||||||
|
/// empty section, not an error.
|
||||||
|
///
|
||||||
|
/// The two JSONL sections are separated by [`WOULD_MARKER`] rather than by
|
||||||
|
/// shape: both are JSON objects with the same keys, and telling them apart
|
||||||
|
/// by content would mean a refusal and a call that RAN could be confused —
|
||||||
|
/// which is the one distinction shadow mode exists to make.
|
||||||
fn tool_gate_probe(dir: &str) -> String {
|
fn tool_gate_probe(dir: &str) -> String {
|
||||||
format!(
|
format!(
|
||||||
"echo INERT=$(wc -l < {dir}/{inert} 2>/dev/null || echo 0); cat {dir}/{denied} 2>/dev/null",
|
"echo INERT=$(wc -l < {dir}/{inert} 2>/dev/null || echo 0); cat {dir}/{denied} 2>/dev/null; echo {marker}; cat {dir}/{would} 2>/dev/null",
|
||||||
inert = crate::vm_tool_gate::INERT_FILE,
|
inert = crate::vm_tool_gate::INERT_FILE,
|
||||||
denied = crate::vm_tool_gate::DENIED_FILE,
|
denied = crate::vm_tool_gate::DENIED_FILE,
|
||||||
|
would = crate::vm_tool_gate::WOULD_DENY_FILE,
|
||||||
|
marker = WOULD_MARKER,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const WOULD_MARKER: &str = "--CM-WOULD-DENY--";
|
||||||
|
|
||||||
/// Parse [`tool_gate_probe`]'s output.
|
/// Parse [`tool_gate_probe`]'s output.
|
||||||
fn parse_tool_gate_probe(out: &str) -> ToolGateOutcome {
|
fn parse_tool_gate_probe(out: &str) -> ToolGateOutcome {
|
||||||
let mut o = ToolGateOutcome::default();
|
let mut o = ToolGateOutcome::default();
|
||||||
|
let mut shadow = false;
|
||||||
for line in out.lines() {
|
for line in out.lines() {
|
||||||
|
let line = line.trim();
|
||||||
if let Some(n) = line.strip_prefix("INERT=") {
|
if let Some(n) = line.strip_prefix("INERT=") {
|
||||||
o.inert = n.trim().parse().unwrap_or(0);
|
o.inert = n.trim().parse().unwrap_or(0);
|
||||||
} else if !line.trim().is_empty() {
|
} else if line == WOULD_MARKER {
|
||||||
o.denied.push(line.trim().to_string());
|
shadow = true;
|
||||||
|
} else if !line.is_empty() {
|
||||||
|
if shadow {
|
||||||
|
o.would_deny.push(line.to_string());
|
||||||
|
} else {
|
||||||
|
o.denied.push(line.to_string());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
o
|
o
|
||||||
@@ -456,6 +474,10 @@ pub struct VmOutcome {
|
|||||||
pub struct ToolGateOutcome {
|
pub struct ToolGateOutcome {
|
||||||
/// Raw `denied.jsonl` lines — each one a call the gate refused.
|
/// Raw `denied.jsonl` lines — each one a call the gate refused.
|
||||||
pub denied: Vec<String>,
|
pub denied: Vec<String>,
|
||||||
|
/// Calls a task policy WOULD have refused, had it been enforcing. These
|
||||||
|
/// ran. Kept apart from `denied` because the difference between "was
|
||||||
|
/// refused" and "would have been refused" is the whole of shadow mode.
|
||||||
|
pub would_deny: Vec<String>,
|
||||||
/// Times the gate could not parse its input and allowed the call anyway.
|
/// Times the gate could not parse its input and allowed the call anyway.
|
||||||
pub inert: u32,
|
pub inert: u32,
|
||||||
}
|
}
|
||||||
@@ -485,6 +507,7 @@ pub async fn run_phase_in_vm(hub: &NodeHub, p: VmPhase<'_>) -> Result<VmOutcome,
|
|||||||
p.gate,
|
p.gate,
|
||||||
p.run_id,
|
p.run_id,
|
||||||
p.tap_sink.as_ref(),
|
p.tap_sink.as_ref(),
|
||||||
|
p.task_policy,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
@@ -535,6 +558,10 @@ pub struct VmPhase<'a> {
|
|||||||
/// Claude Code `Stop` hook inside the guest. `None` leaves the turn exactly
|
/// Claude Code `Stop` hook inside the guest. `None` leaves the turn exactly
|
||||||
/// as it was.
|
/// as it was.
|
||||||
pub gate: Option<&'a crate::vm_stop_gate::StopGate>,
|
pub gate: Option<&'a crate::vm_stop_gate::StopGate>,
|
||||||
|
/// The tools this phase's agents may use at all, installed with the
|
||||||
|
/// gate. `None` keeps the gate exactly as it was — no task policy, the
|
||||||
|
/// floor and the role policies only.
|
||||||
|
pub task_policy: Option<&'a crate::vm_tool_gate::TaskPolicy>,
|
||||||
/// Which node of a composed graph this VM is running, if any. `None` is the
|
/// Which node of a composed graph this VM is running, if any. `None` is the
|
||||||
/// solo path, where the phase is one VM and the id needs no further
|
/// solo path, where the phase is one VM and the id needs no further
|
||||||
/// qualification. Part of the vm id, so the nodes of one phase-iteration
|
/// qualification. Part of the vm id, so the nodes of one phase-iteration
|
||||||
@@ -603,6 +630,8 @@ async fn run_inside(
|
|||||||
// `VmPhase::tap_sink` — `Some` means the sink owns recording and the
|
// `VmPhase::tap_sink` — `Some` means the sink owns recording and the
|
||||||
// returned `tools` is empty.
|
// returned `tools` is empty.
|
||||||
tap_sink: Option<&tokio::sync::mpsc::UnboundedSender<Vec<crate::vm_tool_tap::Observed>>>,
|
tap_sink: Option<&tokio::sync::mpsc::UnboundedSender<Vec<crate::vm_tool_tap::Observed>>>,
|
||||||
|
// The tools this phase may use at all, baked into the gate at install.
|
||||||
|
task_policy: Option<&crate::vm_tool_gate::TaskPolicy>,
|
||||||
) -> Result<VmOutcome, String> {
|
) -> Result<VmOutcome, String> {
|
||||||
// An agent CLI cannot reach its API without the tunnel, and a turn without
|
// An agent CLI cannot reach its API without the tunnel, and a turn without
|
||||||
// egress does not fail — it hangs, or reports a network error the operator
|
// egress does not fail — it hangs, or reports a network error the operator
|
||||||
@@ -767,7 +796,10 @@ async fn run_inside(
|
|||||||
false => None,
|
false => None,
|
||||||
true => match vm
|
true => match vm
|
||||||
.exec(
|
.exec(
|
||||||
&crate::vm_tool_gate::install_command(crate::vm_tool_gate::GUEST_DIR),
|
&crate::vm_tool_gate::install_command_with(
|
||||||
|
crate::vm_tool_gate::GUEST_DIR,
|
||||||
|
task_policy,
|
||||||
|
),
|
||||||
None,
|
None,
|
||||||
60,
|
60,
|
||||||
&[],
|
&[],
|
||||||
@@ -1190,12 +1222,32 @@ mod tests {
|
|||||||
let g = parse_tool_gate_probe(out);
|
let g = parse_tool_gate_probe(out);
|
||||||
assert_eq!(g.inert, 2);
|
assert_eq!(g.inert, 2);
|
||||||
assert_eq!(g.denied.len(), 2);
|
assert_eq!(g.denied.len(), 2);
|
||||||
|
assert!(g.would_deny.is_empty(), "no marker, no shadow section");
|
||||||
assert!(g.denied[1].contains("rm -rf"));
|
assert!(g.denied[1].contains("rm -rf"));
|
||||||
let none = parse_tool_gate_probe("INERT=0\n");
|
let none = parse_tool_gate_probe("INERT=0\n");
|
||||||
assert_eq!(none, ToolGateOutcome::default());
|
assert_eq!(none, ToolGateOutcome::default());
|
||||||
// The probe reads both files from the gate's own directory.
|
// The probe reads both files from the gate's own directory.
|
||||||
let cmd = tool_gate_probe(crate::vm_tool_gate::GUEST_DIR);
|
let cmd = tool_gate_probe(crate::vm_tool_gate::GUEST_DIR);
|
||||||
assert!(cmd.contains("/root/toolgate/inert") && cmd.contains("/root/toolgate/denied.jsonl"), "{cmd}");
|
assert!(cmd.contains("/root/toolgate/inert") && cmd.contains("/root/toolgate/denied.jsonl"), "{cmd}");
|
||||||
|
assert!(cmd.contains("/root/toolgate/would-deny.jsonl"), "{cmd}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A refusal and a call that merely WOULD have been refused must never
|
||||||
|
/// be read as each other: both are JSON objects with the same keys, so
|
||||||
|
/// the marker is what separates them.
|
||||||
|
#[test]
|
||||||
|
fn the_probe_keeps_refusals_apart_from_the_shadow_record() {
|
||||||
|
let out = format!(
|
||||||
|
"INERT=1\n{denied}\n{WOULD_MARKER}\n{would}\n{would}",
|
||||||
|
denied = r#"{"rule":"force-push"}"#,
|
||||||
|
would = r#"{"rule":"task-permission"}"#,
|
||||||
|
);
|
||||||
|
let g = parse_tool_gate_probe(&out);
|
||||||
|
assert_eq!(g.inert, 1);
|
||||||
|
assert_eq!(g.denied.len(), 1, "{g:?}");
|
||||||
|
assert!(g.denied[0].contains("force-push"));
|
||||||
|
assert_eq!(g.would_deny.len(), 2, "{g:?}");
|
||||||
|
assert!(g.would_deny.iter().all(|l| l.contains("task-permission")));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The CLI's `--agents` schema takes `tools` as an array. 2.1.276 refused
|
/// The CLI's `--agents` schema takes `tools` as an array. 2.1.276 refused
|
||||||
|
|||||||
@@ -191,6 +191,7 @@ impl<V: PhaseVm> TurnExecutor for MicroVmTurnExecutor<V> {
|
|||||||
let outcome = self
|
let outcome = self
|
||||||
.vms
|
.vms
|
||||||
.run(VmPhase {
|
.run(VmPhase {
|
||||||
|
task_policy: None,
|
||||||
// Every node of a composed graph streams to the same outer run,
|
// Every node of a composed graph streams to the same outer run,
|
||||||
// which is the one the operator is watching.
|
// which is the one the operator is watching.
|
||||||
run_id: Some(self.run_id),
|
run_id: Some(self.run_id),
|
||||||
|
|||||||
@@ -649,7 +649,18 @@ impl MissionRuntimeProvisioner {
|
|||||||
// Re-install on reuse: the container outlives the server
|
// Re-install on reuse: the container outlives the server
|
||||||
// process, and a hook that exists only on first creation is a
|
// process, and a hook that exists only on first creation is a
|
||||||
// hook that quietly disappears after a redeploy.
|
// hook that quietly disappears after a redeploy.
|
||||||
let hooks = crate::container_tool_hooks::install(&self.docker, &name).await;
|
let hooks = crate::container_tool_hooks::install_with(
|
||||||
|
&self.docker,
|
||||||
|
&name,
|
||||||
|
// A container serves every phase of the mission, so the
|
||||||
|
// policy installed here is the mission-wide default. A
|
||||||
|
// phase's own `agent_tools` is honoured on the microVM
|
||||||
|
// tier, where the VM is per-phase; narrowing per phase
|
||||||
|
// here would need a re-install between phases and is not
|
||||||
|
// done.
|
||||||
|
Some(&crate::vm_tool_gate::TaskPolicy::default_shadow()),
|
||||||
|
)
|
||||||
|
.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),
|
||||||
@@ -865,7 +876,18 @@ impl MissionRuntimeProvisioner {
|
|||||||
// Gate and observe the tools claude runs inside its own subprocess.
|
// Gate and observe the tools claude runs inside its own subprocess.
|
||||||
// Best-effort by design: a phase that runs unhooked still delivers, and
|
// Best-effort by design: a phase that runs unhooked still delivers, and
|
||||||
// failing the launch to protect telemetry would be the wrong trade.
|
// failing the launch to protect telemetry would be the wrong trade.
|
||||||
let hooks = crate::container_tool_hooks::install(&self.docker, &name).await;
|
let hooks = crate::container_tool_hooks::install_with(
|
||||||
|
&self.docker,
|
||||||
|
&name,
|
||||||
|
// A container serves every phase of the mission, so the
|
||||||
|
// policy installed here is the mission-wide default. A
|
||||||
|
// phase's own `agent_tools` is honoured on the microVM
|
||||||
|
// tier, where the VM is per-phase; narrowing per phase
|
||||||
|
// here would need a re-install between phases and is not
|
||||||
|
// done.
|
||||||
|
Some(&crate::vm_tool_gate::TaskPolicy::default_shadow()),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
Ok(EnsuredContainer {
|
Ok(EnsuredContainer {
|
||||||
endpoint: endpoint_url(&name),
|
endpoint: endpoint_url(&name),
|
||||||
|
|||||||
@@ -53,6 +53,17 @@ pub const KNOWN_KEYS: &[KnownKey] = &[
|
|||||||
phase that changes no files still completes; also vm_stop_gate::\
|
phase that changes no files still completes; also vm_stop_gate::\
|
||||||
StopGate::for_phase, where it drops the in-loop delivery check",
|
StopGate::for_phase, where it drops the in-loop delivery check",
|
||||||
},
|
},
|
||||||
|
KnownKey {
|
||||||
|
key: "agent_tools",
|
||||||
|
read_by: "vm_tool_gate::TaskPolicy::for_phase — the tools this phase's \
|
||||||
|
agents may use at all, enforced by the PreToolUse gate. \
|
||||||
|
Absent means the default work surface (files, commands, \
|
||||||
|
search, web, delegation); the platform-control tools are \
|
||||||
|
never in it. DISTINCT from `tools` below, which is \
|
||||||
|
security_scan's scanner list — two keys, two meanings, and \
|
||||||
|
they are next to each other here so nobody conflates them. \
|
||||||
|
Shadow unless CLAWMATES_TASK_PERMISSION=enforce.",
|
||||||
|
},
|
||||||
KnownKey {
|
KnownKey {
|
||||||
key: "tools",
|
key: "tools",
|
||||||
read_by: "security_scan::run — gates which of cargo_audit / gitleaks / \
|
read_by: "security_scan::run — gates which of cargo_audit / gitleaks / \
|
||||||
|
|||||||
@@ -667,6 +667,21 @@ async fn drain_finished_container_phases(pool: &PgPool) -> Result<(), String> {
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
// The shadow record. A policy that is not enforcing still says what
|
||||||
|
// it would have done, and that is the only evidence that decides
|
||||||
|
// whether it is safe to enforce.
|
||||||
|
for line in crate::container_tool_hooks::drain_would_deny(&docker, &container).await {
|
||||||
|
crate::mission_events::record(
|
||||||
|
pool,
|
||||||
|
crate::mission_events::MissionEvent::new(
|
||||||
|
mission_id,
|
||||||
|
crate::container_tool_hooks::GATE_WOULD_DENY,
|
||||||
|
)
|
||||||
|
.phase(phase_id)
|
||||||
|
.detail(crate::vm_tool_gate::denial_detail(&line)),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
let tools = crate::container_tool_hooks::drain(&docker, &container).await;
|
let tools = crate::container_tool_hooks::drain(&docker, &container).await;
|
||||||
if tools.is_empty() {
|
if tools.is_empty() {
|
||||||
continue;
|
continue;
|
||||||
@@ -1442,6 +1457,9 @@ async fn launch_phase(
|
|||||||
p.team_engine,
|
p.team_engine,
|
||||||
crate::vm_stop_gate::StopGate::for_phase(kind, p.config),
|
crate::vm_stop_gate::StopGate::for_phase(kind, p.config),
|
||||||
has_repo,
|
has_repo,
|
||||||
|
// Shadow unless the deployment says otherwise: the first weeks
|
||||||
|
// produce a record of what WOULD have been refused, not refusals.
|
||||||
|
crate::vm_tool_gate::TaskPolicy::for_phase(p.config),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
@@ -1690,6 +1708,9 @@ async fn launch_microvm_phase(
|
|||||||
// workspace at the same guest path instead of a checkout — see
|
// workspace at the same guest path instead of a checkout — see
|
||||||
// `VmPhase::has_repo`.
|
// `VmPhase::has_repo`.
|
||||||
has_repo: bool,
|
has_repo: bool,
|
||||||
|
// The tools this phase's agents may use at all, from its own config.
|
||||||
|
// Built by the caller, which is where the phase config lives.
|
||||||
|
task_policy: crate::vm_tool_gate::TaskPolicy,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
record_phase_prompt(pool, mission_id, phase_id, "microvm", task).await;
|
record_phase_prompt(pool, mission_id, phase_id, "microvm", task).await;
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
@@ -1762,6 +1783,7 @@ async fn launch_microvm_phase(
|
|||||||
crate::microvm_executor::run_phase_in_vm(
|
crate::microvm_executor::run_phase_in_vm(
|
||||||
&hub,
|
&hub,
|
||||||
crate::microvm_executor::VmPhase {
|
crate::microvm_executor::VmPhase {
|
||||||
|
task_policy: Some(&task_policy),
|
||||||
// Attribution for live output: this is the run a browser
|
// Attribution for live output: this is the run a browser
|
||||||
// subscribes to for this phase.
|
// subscribes to for this phase.
|
||||||
run_id: Some(run_id),
|
run_id: Some(run_id),
|
||||||
@@ -1846,6 +1868,19 @@ async fn launch_microvm_phase(
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
for line in &g.would_deny {
|
||||||
|
crate::mission_events::record(
|
||||||
|
&pool2,
|
||||||
|
crate::mission_events::MissionEvent::new(
|
||||||
|
mission_id,
|
||||||
|
crate::container_tool_hooks::GATE_WOULD_DENY,
|
||||||
|
)
|
||||||
|
.phase(phase_id)
|
||||||
|
.run(run_id)
|
||||||
|
.detail(crate::vm_tool_gate::denial_detail(line)),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
for line in &g.denied {
|
for line in &g.denied {
|
||||||
let detail = crate::vm_tool_gate::denial_detail(line);
|
let detail = crate::vm_tool_gate::denial_detail(line);
|
||||||
crate::mission_events::record(
|
crate::mission_events::record(
|
||||||
|
|||||||
@@ -274,6 +274,101 @@ const ROLE_POLICIES: &[RolePolicy] = &[RolePolicy {
|
|||||||
/// deny a file whose CONTENTS mention a denied string.
|
/// deny a file whose CONTENTS mention a denied string.
|
||||||
const WRITE_TOOLS: &[&str] = &["write", "edit", "multiedit", "notebookedit"];
|
const WRITE_TOOLS: &[&str] = &["write", "edit", "multiedit", "notebookedit"];
|
||||||
|
|
||||||
|
/// The tools a phase's agents may use at all.
|
||||||
|
///
|
||||||
|
/// ActGov's task-permission layer (arXiv 2609.24446): bind the work to the
|
||||||
|
/// tools it needs and refuse the rest. The honest day-one version is
|
||||||
|
/// narrower than the paper's, because our corpus cannot yet justify a
|
||||||
|
/// per-task minimum — 171 recorded tool calls total. What it CAN justify is
|
||||||
|
/// the line this draws: the **work** surface is allowed, the **platform
|
||||||
|
/// control** surface is 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 and both ran,
|
||||||
|
/// because the flag governs permission prompting and not availability. A
|
||||||
|
/// mission agent enumerating the platform's agents or scheduling itself a
|
||||||
|
/// wake-up is outside any mission's business, and nothing stopped it.
|
||||||
|
///
|
||||||
|
/// `enforce` is false by default: the gate records what it WOULD have
|
||||||
|
/// refused and allows the call, because a policy tightened on a guess and
|
||||||
|
/// enforced on day one is how an agent learns to work around the gate.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct TaskPolicy {
|
||||||
|
/// Lowercased tool names, as the extractor prints them.
|
||||||
|
pub allowed: Vec<String>,
|
||||||
|
pub enforce: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The work surface. Everything Claude Code offers for reading, writing,
|
||||||
|
/// searching, running commands, fetching, delegating and bookkeeping.
|
||||||
|
pub const DEFAULT_AGENT_TOOLS: &[&str] = &[
|
||||||
|
// Files and search.
|
||||||
|
"read", "write", "edit", "multiedit", "notebookedit", "glob", "grep", "ls",
|
||||||
|
// Commands.
|
||||||
|
"bash", "bashoutput", "killshell",
|
||||||
|
// The web, which the tiers bound separately (a VM reaches only its
|
||||||
|
// provider and the forge; a container's network policy is the boundary).
|
||||||
|
"webfetch", "websearch",
|
||||||
|
// Delegation and its bookkeeping.
|
||||||
|
"agent", "task", "todowrite", "exitplanmode",
|
||||||
|
// Skills, however they are delivered.
|
||||||
|
"skill", "toolsearch", "readmcpresourcetool", "listmcpresourcestool",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Where the gate records a call it would have refused under a policy that
|
||||||
|
/// is not being enforced yet. Drained like [`DENIED_FILE`], into
|
||||||
|
/// `gate.would_deny`, so shadow mode produces evidence rather than opinion.
|
||||||
|
pub const WOULD_DENY_FILE: &str = "would-deny.jsonl";
|
||||||
|
|
||||||
|
/// The rule id a task-permission refusal carries.
|
||||||
|
pub const TASK_PERMISSION_RULE: &str = "task-permission";
|
||||||
|
|
||||||
|
const TASK_PERMISSION_REASON: &str = "Refusing a tool this phase was not given. \
|
||||||
|
The phase's work is done with files, commands, search and delegation; this \
|
||||||
|
tool reaches the platform itself rather than the task. If the task genuinely \
|
||||||
|
needs it, say so in your output instead of working around it.";
|
||||||
|
|
||||||
|
impl TaskPolicy {
|
||||||
|
/// The default policy for a phase: the work surface, in shadow.
|
||||||
|
pub fn default_shadow() -> Self {
|
||||||
|
TaskPolicy {
|
||||||
|
allowed: DEFAULT_AGENT_TOOLS.iter().map(|t| t.to_string()).collect(),
|
||||||
|
enforce: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read `config.agent_tools` when the phase names its own set, and
|
||||||
|
/// `CLAWMATES_TASK_PERMISSION=enforce` to stop shadowing.
|
||||||
|
///
|
||||||
|
/// **Not `config.tools`** — `security_scan::run` already owns that key
|
||||||
|
/// to choose which scanners run, and quietly giving one key two
|
||||||
|
/// meanings is how a phase ends up enforcing a scanner list as a tool
|
||||||
|
/// policy.
|
||||||
|
pub fn for_phase(config: &serde_json::Value) -> Self {
|
||||||
|
let mut p = Self::default_shadow();
|
||||||
|
if let Some(list) = config.get("agent_tools").and_then(|v| v.as_array()) {
|
||||||
|
let named: Vec<String> = list
|
||||||
|
.iter()
|
||||||
|
.filter_map(|v| v.as_str())
|
||||||
|
.map(|t| t.trim().to_ascii_lowercase())
|
||||||
|
.filter(|t| !t.is_empty())
|
||||||
|
.collect();
|
||||||
|
if !named.is_empty() {
|
||||||
|
p.allowed = named;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.enforce = std::env::var("CLAWMATES_TASK_PERMISSION")
|
||||||
|
.map(|v| v.trim().eq_ignore_ascii_case("enforce"))
|
||||||
|
.unwrap_or(false);
|
||||||
|
p
|
||||||
|
}
|
||||||
|
|
||||||
|
fn permits(&self, tool: &str) -> bool {
|
||||||
|
self.allowed.iter().any(|t| t == tool)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// A decision, with the rule that made it.
|
/// A decision, with the rule that made it.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub struct Denial {
|
pub struct Denial {
|
||||||
@@ -319,8 +414,28 @@ pub fn decide(
|
|||||||
argument: &str,
|
argument: &str,
|
||||||
file_path: Option<&str>,
|
file_path: Option<&str>,
|
||||||
role: Option<&str>,
|
role: Option<&str>,
|
||||||
|
) -> Option<Denial> {
|
||||||
|
decide_with(tool, argument, file_path, role, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// As [`decide`], against a phase's task policy as well as the floor.
|
||||||
|
/// Returns the denial an ENFORCING policy would produce; a shadow policy's
|
||||||
|
/// caller records it instead of acting on it.
|
||||||
|
pub fn decide_with(
|
||||||
|
tool: &str,
|
||||||
|
argument: &str,
|
||||||
|
file_path: Option<&str>,
|
||||||
|
role: Option<&str>,
|
||||||
|
task: Option<&TaskPolicy>,
|
||||||
) -> Option<Denial> {
|
) -> Option<Denial> {
|
||||||
let tool = tool.to_ascii_lowercase();
|
let tool = tool.to_ascii_lowercase();
|
||||||
|
// Task permission first: a tool the phase was never given is refused
|
||||||
|
// whatever it was about to do with it.
|
||||||
|
if let Some(t) = task {
|
||||||
|
if !t.permits(&tool) {
|
||||||
|
return Some(Denial { rule: TASK_PERMISSION_RULE, reason: TASK_PERMISSION_REASON });
|
||||||
|
}
|
||||||
|
}
|
||||||
// A role's own limits come first: they are narrower than the floor by
|
// A role's own limits come first: they are narrower than the floor by
|
||||||
// construction, and the reason names the role rather than the action.
|
// construction, and the reason names the role rather than the action.
|
||||||
if let Some(role) = role.map(str::trim).filter(|r| !r.is_empty()) {
|
if let Some(role) = role.map(str::trim).filter(|r| !r.is_empty()) {
|
||||||
@@ -435,6 +550,16 @@ const TEXT_TOOLS: &[&str] = &[
|
|||||||
/// every tool call in the phase, which is precisely what a `case`-syntax bug
|
/// every tool call in the phase, which is precisely what a `case`-syntax bug
|
||||||
/// did here before a test ran the script under a real shell.
|
/// did here before a test ran the script under a real shell.
|
||||||
pub fn hook_script(dir: &str) -> String {
|
pub fn hook_script(dir: &str) -> String {
|
||||||
|
hook_script_with(dir, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// As [`hook_script`], carrying a phase's task policy.
|
||||||
|
///
|
||||||
|
/// The policy is baked into the script at install time rather than read
|
||||||
|
/// from a file at call time: the guest already refuses to let the agent
|
||||||
|
/// touch `/root/toolhooks`, and a policy the guest could re-read is a
|
||||||
|
/// policy the guest could be persuaded to re-read from somewhere else.
|
||||||
|
pub fn hook_script_with(dir: &str, task: Option<&TaskPolicy>) -> String {
|
||||||
// The denial body, shared by every rule so the shell and the reason stay
|
// The denial body, shared by every rule so the shell and the reason stay
|
||||||
// together in one place.
|
// together in one place.
|
||||||
// Each denial line is `{"rule":"<id>","payload":<the hook event>}`, so the
|
// Each denial line is `{"rule":"<id>","payload":<the hook event>}`, so the
|
||||||
@@ -536,6 +661,32 @@ pub fn hook_script(dir: &str) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Task permission. In shadow the call is RECORDED and allowed; the file
|
||||||
|
// is drained into `gate.would_deny` so a week of real traffic says
|
||||||
|
// whether the set is right before anything is refused on it.
|
||||||
|
let task_check = match task {
|
||||||
|
None => String::new(),
|
||||||
|
Some(t) => {
|
||||||
|
let allowed = t.allowed.join("|");
|
||||||
|
let body = if t.enforce {
|
||||||
|
deny(TASK_PERMISSION_RULE, TASK_PERMISSION_REASON, " ")
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
" printf '{{\"rule\":\"{rule}\",\"payload\":%s}}\\n' \"$payload\" >> {dir}/{file} 2>/dev/null\n \x20 ;;\n",
|
||||||
|
rule = TASK_PERMISSION_RULE,
|
||||||
|
dir = dir,
|
||||||
|
file = WOULD_DENY_FILE,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
format!(
|
||||||
|
"case \"$tool\" in\n\
|
||||||
|
\x20 {allowed}) ;;\n\
|
||||||
|
\x20 *)\n{body}\
|
||||||
|
esac\n"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// One `case` per role policy, rendered from the same table the Rust
|
// One `case` per role policy, rendered from the same table the Rust
|
||||||
// predicate reads, so the guest and the host cannot disagree about who
|
// predicate reads, so the guest and the host cannot disagree about who
|
||||||
// may do what.
|
// may do what.
|
||||||
@@ -574,6 +725,8 @@ pub fn hook_script(dir: &str) -> String {
|
|||||||
cmd=$(printf '%s\\n' \"$info\" | sed -n 2p)\n\
|
cmd=$(printf '%s\\n' \"$info\" | sed -n 2p)\n\
|
||||||
path=$(printf '%s\\n' \"$info\" | sed -n 3p)\n\
|
path=$(printf '%s\\n' \"$info\" | sed -n 3p)\n\
|
||||||
role=$(printf '%s\\n' \"$info\" | sed -n 4p)\n\
|
role=$(printf '%s\\n' \"$info\" | sed -n 4p)\n\
|
||||||
|
# Was this phase given this tool at all?\n\
|
||||||
|
{task_check}\
|
||||||
# A role's own limits. Empty role = the lead's own call, which no\n\
|
# A role's own limits. Empty role = the lead's own call, which no\n\
|
||||||
# role policy binds.\n\
|
# role policy binds.\n\
|
||||||
{role_checks}\
|
{role_checks}\
|
||||||
@@ -615,6 +768,7 @@ pub fn hook_script(dir: &str) -> String {
|
|||||||
exit 0\n",
|
exit 0\n",
|
||||||
extract = NODE_EXTRACT,
|
extract = NODE_EXTRACT,
|
||||||
inert = INERT_FILE,
|
inert = INERT_FILE,
|
||||||
|
task_check = task_check,
|
||||||
role_checks = role_checks,
|
role_checks = role_checks,
|
||||||
write_tools = WRITE_TOOLS.join("|"),
|
write_tools = WRITE_TOOLS.join("|"),
|
||||||
protected = PROTECTED_PATHS
|
protected = PROTECTED_PATHS
|
||||||
@@ -658,9 +812,14 @@ pub fn settings_hook(dir: &str) -> Value {
|
|||||||
|
|
||||||
/// One shell command that installs the gate.
|
/// One shell command that installs the gate.
|
||||||
pub fn install_command(dir: &str) -> String {
|
pub fn install_command(dir: &str) -> String {
|
||||||
|
install_command_with(dir, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// As [`install_command`], carrying a phase's task policy.
|
||||||
|
pub fn install_command_with(dir: &str, task: Option<&TaskPolicy>) -> String {
|
||||||
format!(
|
format!(
|
||||||
"mkdir -p {dir} && cat > {dir}/tool-gate.sh <<'CM_GATE_EOF'\n{}\nCM_GATE_EOF\nchmod +x {dir}/tool-gate.sh",
|
"mkdir -p {dir} && cat > {dir}/tool-gate.sh <<'CM_GATE_EOF'\n{}\nCM_GATE_EOF\nchmod +x {dir}/tool-gate.sh",
|
||||||
hook_script(dir)
|
hook_script_with(dir, task)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -786,6 +945,46 @@ mod tests {
|
|||||||
assert_eq!(ids.len(), RULES.len());
|
assert_eq!(ids.len(), RULES.len());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The work surface passes; the platform-control surface does not.
|
||||||
|
/// The two named tools are the ones microVM missions actually called
|
||||||
|
/// with neither on `--allowedTools`.
|
||||||
|
#[test]
|
||||||
|
fn task_permission_allows_work_and_refuses_platform_control() {
|
||||||
|
let p = TaskPolicy::default_shadow();
|
||||||
|
for tool in ["Bash", "Read", "Write", "Edit", "Glob", "Grep", "Agent", "WebFetch", "Skill"] {
|
||||||
|
assert!(
|
||||||
|
decide_with(tool, "x", None, None, Some(&p)).is_none(),
|
||||||
|
"{tool} is ordinary work and must pass"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for tool in ["ListAgents", "ScheduleWakeup", "CronCreate", "SendMessage", "EndConversation"] {
|
||||||
|
let d = decide_with(tool, "", None, None, Some(&p))
|
||||||
|
.unwrap_or_else(|| panic!("{tool} reaches the platform and must be refused"));
|
||||||
|
assert_eq!(d.rule, TASK_PERMISSION_RULE);
|
||||||
|
}
|
||||||
|
// A phase may name its own, narrower set.
|
||||||
|
let narrow = TaskPolicy::for_phase(&serde_json::json!({"agent_tools": ["Read", "Grep"]}));
|
||||||
|
assert!(decide_with("Read", "", None, None, Some(&narrow)).is_none());
|
||||||
|
assert_eq!(
|
||||||
|
decide_with("Bash", "ls", None, None, Some(&narrow)).unwrap().rule,
|
||||||
|
TASK_PERMISSION_RULE
|
||||||
|
);
|
||||||
|
// `tools` is security_scan's key and must not be read as this one.
|
||||||
|
let scanners = TaskPolicy::for_phase(&serde_json::json!({"tools": ["cargo_audit"]}));
|
||||||
|
assert!(decide_with("Bash", "cargo test", None, None, Some(&scanners)).is_none());
|
||||||
|
// No policy at all is the behaviour every existing caller has.
|
||||||
|
assert!(decide_with("ListAgents", "", None, None, None).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Task permission is checked before the floor: a tool the phase never
|
||||||
|
/// had is refused for that reason, not for what it was about to do.
|
||||||
|
#[test]
|
||||||
|
fn a_tool_the_phase_never_had_is_refused_as_such() {
|
||||||
|
let p = TaskPolicy::default_shadow();
|
||||||
|
let d = decide_with("Bash", "git push --force origin main", None, None, Some(&p)).unwrap();
|
||||||
|
assert_eq!(d.rule, "force-push", "bash IS permitted, so the floor decides");
|
||||||
|
}
|
||||||
|
|
||||||
/// A role policy binds that role's subagent and nobody else.
|
/// A role policy binds that role's subagent and nobody else.
|
||||||
#[test]
|
#[test]
|
||||||
fn the_verifier_may_not_write_and_the_lead_is_untouched() {
|
fn the_verifier_may_not_write_and_the_lead_is_untouched() {
|
||||||
@@ -892,6 +1091,10 @@ mod shell_tests {
|
|||||||
|
|
||||||
/// Run the hook with `payload` on stdin. Returns (exit code, stderr).
|
/// Run the hook with `payload` on stdin. Returns (exit code, stderr).
|
||||||
fn run(payload: &str) -> (i32, String) {
|
fn run(payload: &str) -> (i32, String) {
|
||||||
|
run_with(payload, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_with(payload: &str, task: Option<&TaskPolicy>) -> (i32, String) {
|
||||||
// Unique per invocation: these tests run in parallel and each removes
|
// Unique per invocation: these tests run in parallel and each removes
|
||||||
// its directory afterwards, so a shared path has them deleting the
|
// its directory afterwards, so a shared path has them deleting the
|
||||||
// script out from under each other.
|
// script out from under each other.
|
||||||
@@ -900,7 +1103,7 @@ mod shell_tests {
|
|||||||
let dir = std::env::temp_dir().join(format!("cm-gate-{}-{seq}", std::process::id()));
|
let dir = std::env::temp_dir().join(format!("cm-gate-{}-{seq}", std::process::id()));
|
||||||
std::fs::create_dir_all(&dir).unwrap();
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
let script = dir.join("tool-gate.sh");
|
let script = dir.join("tool-gate.sh");
|
||||||
std::fs::write(&script, hook_script(&dir.to_string_lossy())).unwrap();
|
std::fs::write(&script, hook_script_with(&dir.to_string_lossy(), task)).unwrap();
|
||||||
|
|
||||||
let mut child = Command::new("sh")
|
let mut child = Command::new("sh")
|
||||||
.arg(&script)
|
.arg(&script)
|
||||||
@@ -917,8 +1120,10 @@ mod shell_tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
let out = child.wait_with_output().expect("wait");
|
let out = child.wait_with_output().expect("wait");
|
||||||
let denied = std::fs::read_to_string(dir.join(DENIED_FILE)).unwrap_or_default();
|
let denied = std::fs::read_to_string(dir.join(DENIED_FILE)).unwrap_or_default();
|
||||||
|
let would = std::fs::read_to_string(dir.join(WOULD_DENY_FILE)).unwrap_or_default();
|
||||||
let _ = std::fs::remove_dir_all(&dir);
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
LAST_DENIED.with(|d| *d.borrow_mut() = denied);
|
LAST_DENIED.with(|d| *d.borrow_mut() = denied);
|
||||||
|
LAST_WOULD_DENY.with(|d| *d.borrow_mut() = would);
|
||||||
(
|
(
|
||||||
out.status.code().unwrap_or(-1),
|
out.status.code().unwrap_or(-1),
|
||||||
String::from_utf8_lossy(&out.stderr).to_string(),
|
String::from_utf8_lossy(&out.stderr).to_string(),
|
||||||
@@ -934,6 +1139,13 @@ mod shell_tests {
|
|||||||
LAST_DENIED.with(|d| d.borrow().clone())
|
LAST_DENIED.with(|d| d.borrow().clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
thread_local! {
|
||||||
|
static LAST_WOULD_DENY: std::cell::RefCell<String> = const { std::cell::RefCell::new(String::new()) };
|
||||||
|
}
|
||||||
|
fn last_would_deny() -> String {
|
||||||
|
LAST_WOULD_DENY.with(|d| d.borrow().clone())
|
||||||
|
}
|
||||||
|
|
||||||
/// The record of a denial names the rule and carries the whole event,
|
/// The record of a denial names the rule and carries the whole event,
|
||||||
/// as one JSON object per line the host can parse without guessing.
|
/// as one JSON object per line the host can parse without guessing.
|
||||||
#[test]
|
#[test]
|
||||||
@@ -948,6 +1160,33 @@ mod shell_tests {
|
|||||||
assert_eq!(v["payload"]["tool_input"]["command"], "git push --force origin main");
|
assert_eq!(v["payload"]["tool_input"]["command"], "git push --force origin main");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Task permission in the generated shell, both ways round. Shadow
|
||||||
|
/// RECORDS and allows — the whole point of the mode — and enforcing
|
||||||
|
/// refuses. Ordinary work passes in both.
|
||||||
|
#[test]
|
||||||
|
fn the_shell_shadows_then_enforces_task_permission() {
|
||||||
|
let stray = r#"{"tool_name":"ListAgents","tool_input":{}}"#;
|
||||||
|
let work = r#"{"tool_name":"Bash","tool_input":{"command":"cargo test"}}"#;
|
||||||
|
|
||||||
|
let shadow = TaskPolicy::default_shadow();
|
||||||
|
let (code, _) = run_with(stray, Some(&shadow));
|
||||||
|
assert_eq!(code, 0, "shadow must ALLOW, or it is not shadow");
|
||||||
|
let rec: serde_json::Value = serde_json::from_str(last_would_deny().trim())
|
||||||
|
.unwrap_or_else(|e| panic!("would-deny line is not JSON ({e}): {}", last_would_deny()));
|
||||||
|
assert_eq!(rec["rule"], TASK_PERMISSION_RULE);
|
||||||
|
assert_eq!(rec["payload"]["tool_name"], "ListAgents");
|
||||||
|
assert!(last_denied().is_empty(), "shadow must not write a denial");
|
||||||
|
assert_eq!(run_with(work, Some(&shadow)).0, 0);
|
||||||
|
|
||||||
|
let enforcing = TaskPolicy { enforce: true, ..TaskPolicy::default_shadow() };
|
||||||
|
let (code, stderr) = run_with(stray, Some(&enforcing));
|
||||||
|
assert_eq!(code, 2, "enforcing must refuse: {stderr}");
|
||||||
|
assert!(stderr.contains("not given"), "{stderr}");
|
||||||
|
let rec: serde_json::Value = serde_json::from_str(last_denied().trim()).unwrap();
|
||||||
|
assert_eq!(rec["rule"], TASK_PERMISSION_RULE);
|
||||||
|
assert_eq!(run_with(work, Some(&enforcing)).0, 0, "work still works under enforcement");
|
||||||
|
}
|
||||||
|
|
||||||
/// The role policy, in the shell that actually runs in the guest: the
|
/// The role policy, in the shell that actually runs in the guest: the
|
||||||
/// verifier's write is refused with its rule recorded, the lead's
|
/// verifier's write is refused with its rule recorded, the lead's
|
||||||
/// identical write is allowed, and the verifier's reads and tests are
|
/// identical write is allowed, and the verifier's reads and tests are
|
||||||
|
|||||||
Reference in New Issue
Block a user