feat(gate): task permission — the work surface is allowed, the platform is not
deploy / test (push) Successful in 5m40s
deploy / build (push) Successful in 5m47s

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:
Omar Sobh
2026-09-22 10:07:58 -05:00
co-authored by Claude Opus 5
parent 2d1f3954e8
commit 79a6119f4e
7 changed files with 411 additions and 18 deletions
+40 -7
View File
@@ -46,7 +46,16 @@ pub const INSTALL_TIMEOUT: Duration = Duration::from_secs(30);
/// 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();
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];
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
/// writers of one `settings.json` is a silent clobber, and the microVM tier
/// 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(
None,
Some(TAP_DIR),
@@ -82,7 +91,7 @@ fn build_install_script() -> String {
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),
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,
@@ -189,6 +198,9 @@ 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(
@@ -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.
pub fn tap_file() -> String {
format!("{TAP_DIR}/tools.jsonl")
@@ -314,7 +347,7 @@ mod tests {
#[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();
let script = build_install_script(None);
let hooks = settings["hooks"].as_object().expect("hooks");
assert!(!hooks.is_empty(), "no hooks at all");
@@ -336,7 +369,7 @@ mod tests {
#[test]
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("tap.sh"), "the tool tap 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!(SETTINGS_PATH.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.
@@ -452,7 +485,7 @@ mod tests {
return;
}
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")
.arg("-n")
.arg(&tmp)