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
322 lines
13 KiB
Rust
322 lines
13 KiB
Rust
//! Which phase-config keys the platform actually reads.
|
|
//!
|
|
//! `mission_phases.config` is free-form JSONB written by workflow recipes, the
|
|
//! mission wizard and the API. Nothing connected a key to the code that reads
|
|
//! it, so a key could be accepted, validated, stored, rendered — and consumed
|
|
//! by nobody.
|
|
//!
|
|
//! `task` was exactly that. Every phase of every mission received identical
|
|
//! instructions because the runner selected only the mission description; the
|
|
//! per-phase task sat in Postgres unread. Mission `019fc42b` is what surfaced
|
|
//! it: two coding phases with different `task` values produced the same two
|
|
//! files. There was no error, because there is nothing to fail — an unread key
|
|
//! is indistinguishable from a key whose value happens not to matter.
|
|
//!
|
|
//! This module is the missing link. Every key here names the code that reads
|
|
//! it, `unknown_keys` reports anything else, and a test asserts the shipped
|
|
//! recipes only write keys that exist. It cannot make a reader appear, but it
|
|
//! makes an absent one visible.
|
|
|
|
/// A phase-config key and where it is consumed.
|
|
pub struct KnownKey {
|
|
pub key: &'static str,
|
|
/// The code path that reads it. Kept as prose so this survives refactors
|
|
/// that a symbol reference would not.
|
|
pub read_by: &'static str,
|
|
}
|
|
|
|
/// Keys with a reader in the current build.
|
|
///
|
|
/// Adding a key here without a reader defeats the purpose. The rule is: a key
|
|
/// earns its entry when something consumes it, not when something writes it.
|
|
pub const KNOWN_KEYS: &[KnownKey] = &[
|
|
KnownKey {
|
|
key: "done_when",
|
|
read_by: "cm_db::repo::missions::create — promoted to the done_when column, \
|
|
swept by phase_runner::evaluate_finished_phases",
|
|
},
|
|
KnownKey {
|
|
key: "max_iterations",
|
|
read_by: "cm_db::repo::missions::create — promoted to the max_iterations column",
|
|
},
|
|
KnownKey {
|
|
key: "task",
|
|
read_by: "phase_runner::start_pending_phases — injected by phase_task_text",
|
|
},
|
|
KnownKey {
|
|
key: "commit_policy",
|
|
read_by: "mission_delivery::Gate::parse — selects the delivery gate",
|
|
},
|
|
KnownKey {
|
|
key: "allow_empty",
|
|
read_by: "phase_runner::empty_delivery_is_a_failure — when true, a coding \
|
|
phase that changes no files still completes; also vm_stop_gate::\
|
|
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 {
|
|
key: "tools",
|
|
read_by: "security_scan::run — gates which of cargo_audit / gitleaks / \
|
|
trivy_fs / semgrep run against the phase's checkout; absent \
|
|
means all four. Listed here as NOT IMPLEMENTED while wired, \
|
|
which understated the recipe: the key was real, what was \
|
|
missing was anything that FIRED the scan outside an operator \
|
|
button — now phase_runner::scan_finished_security_phases",
|
|
},
|
|
KnownKey {
|
|
key: "harness",
|
|
read_by: "benchmark_runner::harness_from_config — selects criterion / \
|
|
cargo_bench / vitest_bench / pytest_bench / shell, with \
|
|
`bench_name` (criterion) and `cmd` (shell) as its arguments. \
|
|
phase_runner's benchmark sweep runs the baseline through it. \
|
|
This key was listed as NOT IMPLEMENTED while being fully \
|
|
wired, which is worse than an unread key: the registry exists \
|
|
so an operator can trust what a recipe does, and it was wrong",
|
|
},
|
|
KnownKey {
|
|
key: "bench_name",
|
|
read_by: "benchmark_runner::harness_from_config — the criterion bench target",
|
|
},
|
|
KnownKey {
|
|
key: "cmd",
|
|
read_by: "benchmark_runner::harness_from_config — the shell harness command line",
|
|
},
|
|
KnownKey {
|
|
key: "done_when_check",
|
|
read_by: "vm_stop_gate::StopGate::for_phase — a shell command the agent's \
|
|
`Stop` hook runs, refusing the stop while it exits non-zero",
|
|
},
|
|
];
|
|
|
|
/// Keys a recipe may carry that are deliberately not consumed *yet*.
|
|
///
|
|
/// Distinguished from unknown keys so the report stays useful: these are known
|
|
/// gaps with an owner, not typos. Every one is a feature described in a shipped
|
|
/// workflow recipe whose implementation does not exist — which is worth seeing
|
|
/// listed, because a recipe promising `loop = "until_done"` reads to an
|
|
/// operator like something that loops.
|
|
pub const DECLARED_BUT_UNREAD: &[KnownKey] = &[
|
|
KnownKey {
|
|
key: "loop",
|
|
read_by: "NOT IMPLEMENTED — phase iteration uses max_iterations + done_when",
|
|
},
|
|
KnownKey {
|
|
key: "produces",
|
|
read_by: "NOT IMPLEMENTED — artifact rendering is not driven by this",
|
|
},
|
|
KnownKey {
|
|
key: "input_from_phase",
|
|
read_by: "NOT IMPLEMENTED — phases share a checkout, not declared inputs",
|
|
},
|
|
KnownKey {
|
|
key: "mode",
|
|
read_by: "NOT IMPLEMENTED — benchmark/refactor mode selection",
|
|
},
|
|
KnownKey {
|
|
key: "benchmark",
|
|
read_by: "NOT IMPLEMENTED — nested benchmark settings",
|
|
},
|
|
KnownKey {
|
|
key: "mcp_bundles",
|
|
read_by: "NOT IMPLEMENTED at phase level — bundles come from the TEAM \
|
|
template (mission_orchestrator binds template.mcp_bundles) and \
|
|
runtime_provision writes agents.<alias>.mcp_bundles. A recipe \
|
|
setting this per phase changes nothing: security_hardening.toml \
|
|
asks for gitea_forge + security_scan and its phase gets neither",
|
|
},
|
|
];
|
|
|
|
fn is_listed(key: &str, list: &[KnownKey]) -> bool {
|
|
list.iter().any(|k| k.key == key)
|
|
}
|
|
|
|
/// Keys in this config that no code reads and that are not known gaps.
|
|
///
|
|
/// Almost always a typo or a setting invented for a feature that was never
|
|
/// built. Returned rather than rejected: a mission whose config carries an
|
|
/// unread key is not *wrong*, it is just doing less than its author believes,
|
|
/// and failing the request would break recipes that already ship these.
|
|
pub fn unknown_keys(config: &serde_json::Value) -> Vec<String> {
|
|
let Some(obj) = config.as_object() else {
|
|
return Vec::new();
|
|
};
|
|
obj.keys()
|
|
.filter(|k| !is_listed(k, KNOWN_KEYS) && !is_listed(k, DECLARED_BUT_UNREAD))
|
|
.cloned()
|
|
.collect()
|
|
}
|
|
|
|
/// Keys that are recognised but that nothing consumes.
|
|
pub fn inert_keys(config: &serde_json::Value) -> Vec<String> {
|
|
let Some(obj) = config.as_object() else {
|
|
return Vec::new();
|
|
};
|
|
obj.keys()
|
|
.filter(|k| is_listed(k, DECLARED_BUT_UNREAD))
|
|
.cloned()
|
|
.collect()
|
|
}
|
|
|
|
/// Log what a phase's config asked for that will not happen.
|
|
///
|
|
/// Called once per phase at mission creation. Deliberately not an error: the
|
|
/// point is that the author's intent and the platform's behaviour have
|
|
/// diverged, and the author should be able to see that without being blocked.
|
|
pub fn report(kind: &str, order_idx: i32, config: &serde_json::Value) {
|
|
let unknown = unknown_keys(config);
|
|
if !unknown.is_empty() {
|
|
eprintln!(
|
|
"phase_config: phase {order_idx} ({kind}) sets unrecognised key(s) {} — \
|
|
nothing reads them; check for a typo",
|
|
unknown.join(", ")
|
|
);
|
|
}
|
|
let inert = inert_keys(config);
|
|
if !inert.is_empty() {
|
|
eprintln!(
|
|
"phase_config: phase {order_idx} ({kind}) sets {} — recognised but NOT \
|
|
IMPLEMENTED, so it will have no effect on this run",
|
|
inert.join(", ")
|
|
);
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn a_key_cannot_be_both_read_and_unread() {
|
|
for k in KNOWN_KEYS {
|
|
assert!(
|
|
!is_listed(k.key, DECLARED_BUT_UNREAD),
|
|
"{} is listed as both read and unread",
|
|
k.key
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn every_known_key_names_its_reader() {
|
|
for k in KNOWN_KEYS {
|
|
assert!(
|
|
!k.read_by.is_empty() && !k.read_by.starts_with("NOT IMPLEMENTED"),
|
|
"{} claims to be read but names no reader",
|
|
k.key
|
|
);
|
|
}
|
|
for k in DECLARED_BUT_UNREAD {
|
|
assert!(
|
|
k.read_by.starts_with("NOT IMPLEMENTED"),
|
|
"{} is listed as unread but names a reader — promote it to KNOWN_KEYS",
|
|
k.key
|
|
);
|
|
}
|
|
}
|
|
|
|
/// The regression that motivated the module: `task` must stay claimed.
|
|
#[test]
|
|
fn the_per_phase_task_key_has_a_reader() {
|
|
assert!(
|
|
is_listed("task", KNOWN_KEYS),
|
|
"task lost its reader again — every phase will get identical instructions"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn unknown_and_inert_keys_are_reported_separately() {
|
|
let cfg = serde_json::json!({
|
|
"done_when": "tests pass",
|
|
"loop": "until_done",
|
|
"typpo": true,
|
|
});
|
|
assert_eq!(unknown_keys(&cfg), vec!["typpo".to_string()]);
|
|
assert_eq!(inert_keys(&cfg), vec!["loop".to_string()]);
|
|
}
|
|
|
|
/// Every key the shipped workflow recipes write must be accounted for.
|
|
///
|
|
/// This is the CI-time half: a recipe that invents `comit_policy` should
|
|
/// fail here rather than run a mission whose gate silently defaults.
|
|
#[test]
|
|
fn shipped_recipes_only_write_accounted_keys() {
|
|
let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/../../templates/workflows");
|
|
let Ok(entries) = std::fs::read_dir(dir) else {
|
|
return; // templates not present in this build context
|
|
};
|
|
// Keys that belong to the recipe/phase envelope rather than to the
|
|
// phase config blob itself.
|
|
const ENVELOPE: &[&str] = &[
|
|
"key",
|
|
"name",
|
|
"title",
|
|
"blurb",
|
|
"kind",
|
|
"order_idx",
|
|
"requires_repo",
|
|
"default_team_template",
|
|
"default_phase_teams",
|
|
"default_topology",
|
|
"phases",
|
|
"description",
|
|
];
|
|
// `[default_phase_teams]` maps a phase PURPOSE to a team template key,
|
|
// so its keys are not config keys and must not be checked as such.
|
|
// They are checked against the purposes `phase_runner::purposes_for`
|
|
// can actually emit instead — a typo'd purpose matches no phase and
|
|
// that phase silently falls back to the mission-wide team, which is
|
|
// exactly the kind of quiet wrong staffing this table exists to end.
|
|
const PURPOSES: &[&str] = &["research", "coding", "security", "mission"];
|
|
for entry in entries.flatten() {
|
|
let path = entry.path();
|
|
if path.extension().and_then(|e| e.to_str()) != Some("toml") {
|
|
continue;
|
|
}
|
|
let body = std::fs::read_to_string(&path).unwrap();
|
|
let mut table = String::new();
|
|
for line in body.lines() {
|
|
let line = line.trim();
|
|
if line.starts_with('[') {
|
|
table = line.trim_matches(['[', ']'].as_slice()).to_string();
|
|
continue;
|
|
}
|
|
if line.starts_with('#') || !line.contains('=') {
|
|
continue;
|
|
}
|
|
let key = line.split('=').next().unwrap().trim();
|
|
if key.is_empty() || key.contains(' ') || key.contains('[') {
|
|
continue;
|
|
}
|
|
if table == "default_phase_teams" {
|
|
assert!(
|
|
PURPOSES.contains(&key),
|
|
"{} staffs purpose `{key}`, which `purposes_for` never emits — \
|
|
that phase would fall back to the mission-wide team with \
|
|
nothing reporting it",
|
|
path.display()
|
|
);
|
|
continue;
|
|
}
|
|
let accounted = ENVELOPE.contains(&key)
|
|
|| is_listed(key, KNOWN_KEYS)
|
|
|| is_listed(key, DECLARED_BUT_UNREAD);
|
|
assert!(
|
|
accounted,
|
|
"{} writes `{key}`, which no reader claims and no gap declares",
|
|
path.display()
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|