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
@@ -667,6 +667,21 @@ async fn drain_finished_container_phases(pool: &PgPool) -> Result<(), String> {
|
||||
)
|
||||
.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;
|
||||
if tools.is_empty() {
|
||||
continue;
|
||||
@@ -1442,6 +1457,9 @@ async fn launch_phase(
|
||||
p.team_engine,
|
||||
crate::vm_stop_gate::StopGate::for_phase(kind, p.config),
|
||||
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;
|
||||
}
|
||||
@@ -1690,6 +1708,9 @@ async fn launch_microvm_phase(
|
||||
// workspace at the same guest path instead of a checkout — see
|
||||
// `VmPhase::has_repo`.
|
||||
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> {
|
||||
record_phase_prompt(pool, mission_id, phase_id, "microvm", task).await;
|
||||
sqlx::query(
|
||||
@@ -1762,6 +1783,7 @@ async fn launch_microvm_phase(
|
||||
crate::microvm_executor::run_phase_in_vm(
|
||||
&hub,
|
||||
crate::microvm_executor::VmPhase {
|
||||
task_policy: Some(&task_policy),
|
||||
// Attribution for live output: this is the run a browser
|
||||
// subscribes to for this phase.
|
||||
run_id: Some(run_id),
|
||||
@@ -1846,6 +1868,19 @@ async fn launch_microvm_phase(
|
||||
)
|
||||
.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 {
|
||||
let detail = crate::vm_tool_gate::denial_detail(line);
|
||||
crate::mission_events::record(
|
||||
|
||||
Reference in New Issue
Block a user