use std::process::Command;
use autumn_web::agent_authority::manifest::{
ActionRow, AgentAuthorityManifest, UngovernedTool, UnregisteredAuthority, parse_manifest_dump,
};
use crate::routes;
pub const DUMP_ENV: &str = autumn_web::agent_authority::manifest::DUMP_ENV;
#[allow(clippy::struct_excessive_bools)] pub struct AgentsManifestOptions<'a> {
pub package: Option<&'a str>,
pub bin: Option<&'a str>,
pub manifest: Option<&'a str>,
pub json: bool,
pub check: Option<&'a str>,
pub allow_ungoverned: bool,
pub allow_unaudited: bool,
pub features: routes::CargoFeatures,
pub release: bool,
}
#[must_use]
pub fn format_report(manifest: &AgentAuthorityManifest) -> String {
manifest.summary()
}
#[must_use]
pub fn format_drift(
committed: &AgentAuthorityManifest,
current: &AgentAuthorityManifest,
) -> Option<String> {
if committed == current {
return None;
}
let mut lines: Vec<String> = Vec::new();
if committed.schema_version != current.schema_version {
lines.push(format!(
" manifest schema version {} -> {}",
committed.schema_version, current.schema_version
));
}
for row in ¤t.actions {
match committed
.actions
.iter()
.find(|c| action_key(c) == action_key(row))
{
None => {
lines.push(format!(
" + agent-operable action {} under grant {}",
action_key(row),
row.grant.name
));
for effect in &row.effects {
lines.push(format!(" + {} {}", effect.kind, effect.subject));
}
}
Some(before) if before != row => {
lines.push(format!(" ~ {}", action_key(row)));
lines.extend(action_changes(before, row));
}
Some(_) => {}
}
}
for row in &committed.actions {
if !current
.actions
.iter()
.any(|c| action_key(c) == action_key(row))
{
lines.push(format!(" - agent-operable action {}", action_key(row)));
}
}
lines.extend(tool_changes(committed, current));
lines.extend(unregistered_changes(committed, current));
lines.extend(grant_changes(committed, current));
if committed.audit != current.audit {
lines.push(format!(
" agent audit sink configured {} -> {}",
committed.audit.sink_configured, current.audit.sink_configured
));
}
if lines.is_empty() {
lines.push(" (the documents differ in a field this report does not name)".to_string());
}
Some(format!(
"\u{2717} The agent-authority manifest has drifted from the committed copy:\n{}",
lines.join("\n")
))
}
fn tool_changes(
committed: &AgentAuthorityManifest,
current: &AgentAuthorityManifest,
) -> Vec<String> {
let mut lines = Vec::new();
for tool in ¤t.ungoverned_tools {
if !committed.ungoverned_tools.contains(tool) {
lines.push(format!(
" + ungoverned MCP tool {} [{}]{}",
describe_tool(tool),
tool.exposed_by,
if tool.mutating { " -- MUTATING" } else { "" }
));
}
}
for tool in &committed.ungoverned_tools {
if !current.ungoverned_tools.contains(tool) {
lines.push(format!(" - ungoverned MCP tool {}", describe_tool(tool)));
}
}
for tool in ¤t.ungoverned_webmcp_tools {
if !committed.ungoverned_webmcp_tools.contains(tool) {
lines.push(format!(
" + ungoverned WebMCP tool {} [{}]{}",
describe_tool(tool),
tool.exposed_by,
if tool.mutating { " -- MUTATING" } else { "" }
));
}
}
for tool in &committed.ungoverned_webmcp_tools {
if !current.ungoverned_webmcp_tools.contains(tool) {
lines.push(format!(
" - ungoverned WebMCP tool {}",
describe_tool(tool)
));
}
}
lines
}
fn unregistered_changes(
committed: &AgentAuthorityManifest,
current: &AgentAuthorityManifest,
) -> Vec<String> {
let mut lines = Vec::new();
for row in ¤t.unregistered_authorities {
if !committed.unregistered_authorities.contains(row) {
lines.push(format!(
" + route naming an unregistered authority {}",
describe_unregistered(row)
));
}
}
for row in &committed.unregistered_authorities {
if !current.unregistered_authorities.contains(row) {
lines.push(format!(
" - route naming an unregistered authority {}",
describe_unregistered(row)
));
}
}
lines
}
fn grant_changes(
committed: &AgentAuthorityManifest,
current: &AgentAuthorityManifest,
) -> Vec<String> {
let key_missing = |haystack: &AgentAuthorityManifest, name: &str, location: &str| {
!haystack
.grants
.iter()
.any(|g| g.name == name && g.location == location)
};
let mut lines = Vec::new();
for grant in ¤t.grants {
if key_missing(committed, &grant.name, &grant.location) {
lines.push(format!(" + grant {} ({})", grant.name, grant.location));
}
}
for grant in &committed.grants {
if key_missing(current, &grant.name, &grant.location) {
lines.push(format!(" - grant {} ({})", grant.name, grant.location));
}
}
lines
}
#[must_use]
pub fn format_ungoverned_failure(
manifest: &AgentAuthorityManifest,
allow_ungoverned: bool,
) -> Option<String> {
if allow_ungoverned {
return None;
}
let mcp = manifest.ungoverned_mutating_tools();
let webmcp = manifest.ungoverned_mutating_webmcp_tools();
let same = |a: &UngovernedTool, b: &UngovernedTool| {
(&a.method, &a.path, &a.tool) == (&b.method, &b.path, &b.tool)
};
let mutating: Vec<String> = mcp
.iter()
.map(|tool| {
let both = webmcp.iter().any(|w| same(tool, w));
format!(
" {}{}",
describe_tool(tool),
if both { " [MCP+WebMCP]" } else { "" }
)
})
.chain(
webmcp
.iter()
.filter(|tool| !mcp.iter().any(|m| same(tool, m)))
.map(|tool| format!(" {} [WebMCP]", describe_tool(tool))),
)
.collect();
if mutating.is_empty() {
return None;
}
Some(format!(
"\u{2717} {} MCP or WebMCP tool{} can change state with no authority envelope:\n{}\n\n\
An agent can call these, and nothing declares what they are allowed to do. Annotate each \
handler with `#[agent_operable(grant = YourGrant)]` and declare the envelope with \
`authority_grant!`, or re-run with `--allow-ungoverned` to record them as they are. \
See docs/guide/agent-authority.md",
mutating.len(),
if mutating.len() == 1 { "" } else { "s" },
mutating.join("\n"),
))
}
#[must_use]
pub fn format_ungoverned_warning(manifest: &AgentAuthorityManifest) -> Option<String> {
let mut blocks = Vec::new();
for (surface, tools) in [
("MCP", &manifest.ungoverned_tools),
("WebMCP", &manifest.ungoverned_webmcp_tools),
] {
if tools.is_empty() {
continue;
}
let listed = tools
.iter()
.map(|tool| {
format!(
" {} [{}]{}",
describe_tool(tool),
tool.exposed_by,
if tool.mutating {
" -- mutating"
} else {
" -- read-only"
}
)
})
.collect::<Vec<_>>()
.join("\n");
blocks.push(format!(
"! {} {surface} tool{} exposed with no authority envelope:\n{listed}",
tools.len(),
if tools.len() == 1 { "" } else { "s" },
));
}
(!blocks.is_empty()).then(|| blocks.join("\n"))
}
#[must_use]
pub fn format_unaudited_failure(
manifest: &AgentAuthorityManifest,
allow_unaudited: bool,
) -> Option<String> {
if allow_unaudited || !manifest.unaudited_and_unrecoverable() {
return None;
}
let mut listed: Vec<String> = manifest
.non_reversible_actions()
.iter()
.map(|row| {
format!(
" {}::{} under grant {} ({}){}",
row.module_path,
row.action,
row.grant.name,
row.grant.reversibility,
row.route.as_ref().map_or_else(String::new, |route| format!(
" -- agent-callable at {} {}",
route.method, route.path
))
)
})
.collect();
listed.extend(
manifest
.ungoverned_mutating_tools()
.iter()
.map(|tool| format!(" {} -- mutating, ungoverned", describe_tool(tool))),
);
Some(format!(
"\u{2717} No agent audit sink is configured, and this binary can still take {} action{} \
that nothing can undo:\n{}\n\n\
Every one of these is reachable as an MCP tool, so an agent can call it. Nothing records \
them: with no sink installed the audit write trivially succeeds, so the runtime's \
fail-closed refusal never fires -- the refusal protects a configured sink that is \
failing, not a missing one. Install a sink with `AppBuilder::with_audit_sink(..)`, make \
the actions `reversible`, stop exposing them as tools, or re-run with \
`--allow-unaudited` to accept it. See docs/guide/agent-authority.md",
listed.len(),
if listed.len() == 1 { "" } else { "s" },
listed.join("\n"),
))
}
#[must_use]
pub fn format_unregistered_failure(manifest: &AgentAuthorityManifest) -> Option<String> {
if manifest.unregistered_authorities.is_empty() {
return None;
}
let listed = manifest
.unregistered_authorities
.iter()
.map(|row| format!(" {}", describe_unregistered(row)))
.collect::<Vec<_>>()
.join("\n");
Some(format!(
"\u{2717} {} route{} an authority nothing registered:\n{listed}\n\n\
The handler carries an `#[agent_operable]` marker but no descriptor reached the manifest, \
so the action appears in no list and no gate sees it. This cannot happen through the \
macros -- check for a hand-written `__AUTUMN_AGENT_AUTHORITY_*` static, or for an \
`#[agent_operable]` behind a `#[cfg]` the route is not behind. \
See docs/guide/agent-authority.md",
manifest.unregistered_authorities.len(),
if manifest.unregistered_authorities.len() == 1 {
" names"
} else {
"s name"
},
))
}
#[must_use]
pub fn describe_unregistered(row: &UnregisteredAuthority) -> String {
format!(
"{} {} ({}) -> {}::{}{}{}",
row.method,
row.path,
row.handler,
row.module_path,
row.action,
if row.mcp_tool { " [mcp tool]" } else { "" },
if row.webmcp_tool {
" [webmcp tool]"
} else {
""
}
)
}
#[must_use]
pub fn describe_tool(tool: &UngovernedTool) -> String {
if tool.handler == tool.tool {
format!("{} {} ({})", tool.method, tool.path, tool.tool)
} else {
format!(
"{} {} ({}, handler {})",
tool.method, tool.path, tool.tool, tool.handler
)
}
}
fn action_changes(before: &ActionRow, after: &ActionRow) -> Vec<String> {
let mut lines: Vec<String> = Vec::new();
if before.grant.name != after.grant.name {
lines.push(format!(
" grant {} -> {}",
before.grant.name, after.grant.name
));
}
if before.grant.reversibility != after.grant.reversibility {
lines.push(format!(
" reversibility {} -> {}",
before.grant.reversibility, after.grant.reversibility
));
}
if before.grant.tenant_scope != after.grant.tenant_scope {
lines.push(format!(
" tenant scope {} -> {}",
before.grant.tenant_scope, after.grant.tenant_scope
));
}
if before.grant.rate != after.grant.rate {
lines.push(format!(
" declared rate {} -> {}",
show(before.grant.rate.as_deref()),
show(after.grant.rate.as_deref())
));
}
if before.grant.spend != after.grant.spend {
lines.push(format!(
" declared spend {} -> {}",
show(before.grant.spend.as_deref()),
show(after.grant.spend.as_deref())
));
}
for effect in &after.effects {
if !before.effects.contains(effect) {
lines.push(format!(
" + {} {} ({}, {})",
effect.kind, effect.subject, effect.provenance, effect.location
));
}
}
for effect in &before.effects {
if !after.effects.contains(effect) {
lines.push(format!(
" - {} {} ({})",
effect.kind, effect.subject, effect.provenance
));
}
}
if before.exposure != after.exposure {
lines.push(format!(
" exposure {} -> {}",
before.exposure, after.exposure
));
}
let webmcp = |row: &ActionRow| row.route.as_ref().is_some_and(|route| route.webmcp_tool);
if webmcp(before) != webmcp(after) {
lines.push(format!(
" webmcp tool {} -> {}",
webmcp(before),
webmcp(after)
));
}
if before.provenance != after.provenance {
lines.push(format!(
" provenance {} -> {}",
before.provenance, after.provenance
));
}
for site in &after.asserted_effect_free {
if !before.asserted_effect_free.contains(site) {
lines.push(format!(
" + asserted effect-free at {}: {}",
site.location, site.reason
));
}
}
for site in &before.asserted_effect_free {
if !after.asserted_effect_free.contains(site) {
lines.push(format!(
" - asserted effect-free at {}: {}",
site.location, site.reason
));
}
}
if before.unused_grant_entries != after.unused_grant_entries {
lines.push(format!(
" granted-but-unused: [{}] -> [{}]",
before.unused_grant_entries.join(", "),
after.unused_grant_entries.join(", ")
));
}
lines
}
fn action_key(row: &ActionRow) -> String {
format!("{}::{}", row.module_path, row.action)
}
fn show(value: Option<&str>) -> String {
value.map_or_else(|| "none".to_string(), ToString::to_string)
}
pub fn write_manifest(
manifest: &AgentAuthorityManifest,
path: &std::path::Path,
) -> std::io::Result<()> {
std::fs::write(path, format!("{}\n", manifest.to_json()))
}
pub fn run(opts: &AgentsManifestOptions<'_>) {
eprintln!("\u{1F342} autumn agents manifest\n");
if !opts.features.is_default() {
eprintln!("Building with {}\n", opts.features.to_args().join(" "));
}
if opts.release {
eprintln!("Building the release profile\n");
}
let profile = routes::CargoProfile::from_release(opts.release);
routes::compile_binary_with(opts.package, opts.bin, &opts.features, &profile);
let binary = routes::find_binary_in_profile(opts.package, opts.bin, &profile);
let mut command = Command::new(&binary);
command.env(DUMP_ENV, "1");
routes::clear_other_dump_modes(&mut command, DUMP_ENV);
let output = command
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.output()
.unwrap_or_else(|e| {
eprintln!("\u{2717} Failed to run {}: {e}", binary.display());
std::process::exit(1);
});
eprint!("{}", String::from_utf8_lossy(&output.stderr));
if !output.status.success() {
eprintln!(
"\u{2717} Binary exited with status {} while dumping the agent-authority manifest",
output.status
);
std::process::exit(output.status.code().unwrap_or(1));
}
let stdout = String::from_utf8_lossy(&output.stdout);
let Some(manifest) = parse_manifest_dump(&stdout) else {
eprintln!(
"\u{2717} The app produced no agent-authority manifest. Either it was built against \
an autumn-web without `autumn agents manifest` support, or it took a different \
startup path first \u{2014} `AUTUMN_BUILD_STATIC`, `AUTUMN_DUMP_ROUTES`, \
`AUTUMN_DUMP_CACHE_COHERENCE`, `AUTUMN_DUMP_DATA_FLOW` and `AUTUMN_DUMP_JOBS` are \
all handled before the manifest dump and are cleared for this run, so an app that \
exits earlier for its own reasons will land here too."
);
eprintln!("Raw output: {stdout}");
std::process::exit(1);
};
let committed = opts.check.map(|path| match std::fs::read_to_string(path) {
Ok(text) => match serde_json::from_str::<AgentAuthorityManifest>(&text) {
Ok(m) => m,
Err(e) => {
eprintln!("\u{2717} {path} is not an agent-authority manifest: {e}");
std::process::exit(1);
}
},
Err(e) => {
eprintln!("\u{2717} Failed to read {path}: {e}");
std::process::exit(1);
}
});
if let Some(path) = opts.manifest {
if let Err(e) = write_manifest(&manifest, std::path::Path::new(path)) {
eprintln!("\u{2717} Failed to write manifest to {path}: {e}");
std::process::exit(1);
}
eprintln!("\u{2713} Wrote agent-authority manifest \u{2192} {path}");
}
if opts.json {
println!("{}", manifest.to_json());
} else {
println!("{}", format_report(&manifest));
}
if let Some(warning) = format_ungoverned_warning(&manifest) {
eprintln!("{warning}");
}
let mut failed = false;
if let (Some(path), Some(committed)) = (opts.check, committed) {
if let Some(drift) = format_drift(&committed, &manifest) {
eprintln!("{drift}");
eprintln!(
"\nIf the change is intended, re-run with `--manifest {path}` and commit the result."
);
failed = true;
} else {
eprintln!("\u{2713} The agent-authority manifest matches {path}.");
}
if let Some(ungoverned) = format_ungoverned_failure(&manifest, opts.allow_ungoverned) {
eprintln!("{ungoverned}");
failed = true;
}
if let Some(unaudited) = format_unaudited_failure(&manifest, opts.allow_unaudited) {
eprintln!("{unaudited}");
failed = true;
}
if let Some(unregistered) = format_unregistered_failure(&manifest) {
eprintln!("{unregistered}");
failed = true;
}
}
if failed {
std::process::exit(1);
}
}
#[cfg(test)]
mod tests {
use autumn_web::agent_authority::manifest::{
AssertedEffectFreeRow, McpExposedBy, RouteSummary,
};
use autumn_web::agent_authority::{
AgentAuthority, AssertedEffectFree, Effect, EffectKind, EffectProvenance, Grant,
Reversibility, TenantScope,
};
use super::*;
static REFUND_GRANT: Grant = Grant {
name: "RefundDrafter",
writes: &["Refund"],
unbounded_writes: &[],
tenant_scope: TenantScope::Scoped,
outbound: &["https://api.stripe.com/v1/refunds"],
webhooks: &[],
jobs: &[],
rate: Some("10/min"),
spend: Some("500.00 USD"),
reversibility: Reversibility::Compensable,
location: "billing/refunds.rs:10",
};
static WRITE_ONLY: &[Effect] = &[Effect {
kind: EffectKind::Write,
subject: "Refund",
location: "billing/refunds.rs:30",
provenance: EffectProvenance::TypeResolved,
}];
static WRITE_AND_OUTBOUND: &[Effect] = &[
Effect {
kind: EffectKind::Write,
subject: "Refund",
location: "billing/refunds.rs:30",
provenance: EffectProvenance::TypeResolved,
},
Effect {
kind: EffectKind::Outbound,
subject: "https://api.stripe.com/v1/refunds",
location: "billing/refunds.rs:42",
provenance: EffectProvenance::Syntactic,
},
];
static DRAFT_REFUND: AgentAuthority = AgentAuthority {
action: "draft_refund",
module_path: "billing::refunds",
location: "billing/refunds.rs:28",
grant: &REFUND_GRANT,
effects: WRITE_ONLY,
asserted_effect_free_sites: 0,
asserted_effect_free: &[],
};
static DRAFT_REFUND_WIDENED: AgentAuthority = AgentAuthority {
action: "draft_refund",
module_path: "billing::refunds",
location: "billing/refunds.rs:28",
grant: &REFUND_GRANT,
effects: WRITE_AND_OUTBOUND,
asserted_effect_free_sites: 0,
asserted_effect_free: &[],
};
static ARCHIVE_REFUND: AgentAuthority = AgentAuthority {
action: "archive_refund",
module_path: "billing::refunds",
location: "billing/refunds.rs:70",
grant: &REFUND_GRANT,
effects: WRITE_ONLY,
asserted_effect_free_sites: 0,
asserted_effect_free: &[],
};
fn route(
method: &str,
path: &str,
handler: &'static str,
module_path: &'static str,
mcp_tool: bool,
authority: Option<&'static AgentAuthority>,
) -> RouteSummary {
RouteSummary {
method: method.to_string(),
path: path.to_string(),
handler,
operation_id: handler,
module_path,
mcp_tool,
exposed_by: mcp_tool.then_some(McpExposedBy::Attribute),
duplicate_operation_id: false,
webmcp_tool: false,
webmcp_exposed_by: None,
agent_authority: authority,
}
}
fn webmcp_route(method: &str, path: &str, handler: &'static str) -> RouteSummary {
let mut r = route(method, path, handler, "shop::widgets", false, None);
r.webmcp_tool = true;
r.webmcp_exposed_by = Some(McpExposedBy::Attribute);
r
}
#[test]
fn a_mutating_webmcp_tool_with_no_envelope_fails_the_check() {
let manifest = manifest_of(
&[],
&[webmcp_route("DELETE", "/widgets/{id}", "destroy_widget")],
);
let failure = format_ungoverned_failure(&manifest, false)
.expect("an ungoverned WebMCP write must fail the gate");
assert!(failure.contains("WebMCP"), "{failure}");
assert!(failure.contains("destroy_widget"), "{failure}");
assert!(failure.contains("--allow-ungoverned"), "{failure}");
assert!(format_ungoverned_failure(&manifest, true).is_none());
let warning = format_ungoverned_warning(&manifest).expect("warning");
assert!(warning.contains("WebMCP"), "{warning}");
assert!(warning.contains("destroy_widget"), "{warning}");
}
#[test]
fn a_route_on_both_surfaces_fails_once() {
let mut both = webmcp_route("DELETE", "/widgets/{id}", "destroy_widget");
both.mcp_tool = true;
both.exposed_by = Some(McpExposedBy::Attribute);
let manifest = manifest_of(&[], &[both]);
let failure = format_ungoverned_failure(&manifest, false).expect("failure");
assert!(failure.contains("1 MCP or WebMCP tool can"), "{failure}");
assert!(failure.contains("[MCP+WebMCP]"), "{failure}");
}
#[test]
fn a_webmcp_flag_change_on_a_row_is_named_in_the_drift() {
let before = manifest_of(&[&DRAFT_REFUND], &[governed_route(&DRAFT_REFUND)]);
let mut route = governed_route(&DRAFT_REFUND);
route.webmcp_tool = true;
route.webmcp_exposed_by = Some(McpExposedBy::Attribute);
let after = manifest_of(&[&DRAFT_REFUND], &[route]);
let drift = format_drift(&before, &after).expect("drift");
assert!(drift.contains("webmcp tool false -> true"), "{drift}");
}
#[test]
fn a_read_only_webmcp_tool_warns_but_does_not_fail() {
let manifest = manifest_of(&[], &[webmcp_route("GET", "/widgets", "list_widgets")]);
assert!(format_ungoverned_failure(&manifest, false).is_none());
let warning = format_ungoverned_warning(&manifest).expect("warning");
assert!(warning.contains("list_widgets"), "{warning}");
}
#[test]
fn a_new_ungoverned_webmcp_tool_is_named_in_the_drift_report() {
let before = manifest_of(&[], &[]);
let after = manifest_of(&[], &[webmcp_route("POST", "/widgets", "make_widget")]);
let drift = format_drift(&before, &after).expect("drift");
assert!(drift.contains("+ ungoverned WebMCP tool"), "{drift}");
let back = format_drift(&after, &before).expect("drift");
assert!(back.contains("- ungoverned WebMCP tool"), "{back}");
}
fn governed_route(authority: &'static AgentAuthority) -> RouteSummary {
route(
"POST",
"/refunds",
authority.action,
authority.module_path,
true,
Some(authority),
)
}
fn manifest_of(
authorities: &[&'static AgentAuthority],
routes: &[RouteSummary],
) -> AgentAuthorityManifest {
AgentAuthorityManifest::from_parts(authorities, &[&REFUND_GRANT], routes, true)
}
#[test]
fn the_report_names_each_action_its_envelope_and_its_effects() {
let manifest = manifest_of(&[&DRAFT_REFUND], &[governed_route(&DRAFT_REFUND)]);
let report = format_report(&manifest);
assert!(report.contains("draft_refund"), "{report}");
assert!(report.contains("RefundDrafter"), "{report}");
assert!(report.contains("compensable"), "{report}");
assert!(report.contains("Refund"), "{report}");
}
#[test]
fn the_report_states_that_the_caps_are_declared_only() {
let manifest = manifest_of(&[&DRAFT_REFUND], &[governed_route(&DRAFT_REFUND)]);
let report = format_report(&manifest);
assert!(report.contains("declared"), "{report}");
assert!(report.contains("not enforced"), "{report}");
}
#[test]
fn an_identical_manifest_reports_no_drift() {
let manifest = manifest_of(&[&DRAFT_REFUND], &[governed_route(&DRAFT_REFUND)]);
assert!(format_drift(&manifest, &manifest).is_none());
}
#[test]
fn a_new_action_is_named_in_the_drift_report() {
let before = manifest_of(&[&DRAFT_REFUND], &[governed_route(&DRAFT_REFUND)]);
let after = manifest_of(
&[&DRAFT_REFUND, &ARCHIVE_REFUND],
&[
governed_route(&DRAFT_REFUND),
governed_route(&ARCHIVE_REFUND),
],
);
let drift = format_drift(&before, &after).expect("drift");
assert!(
drift.contains("+ agent-operable action billing::refunds::archive_refund"),
"{drift}"
);
}
#[test]
fn a_removed_action_is_named_in_the_drift_report() {
let before = manifest_of(
&[&DRAFT_REFUND, &ARCHIVE_REFUND],
&[
governed_route(&DRAFT_REFUND),
governed_route(&ARCHIVE_REFUND),
],
);
let after = manifest_of(&[&DRAFT_REFUND], &[governed_route(&DRAFT_REFUND)]);
let drift = format_drift(&before, &after).expect("drift");
assert!(
drift.contains("- agent-operable action billing::refunds::archive_refund"),
"{drift}"
);
}
#[test]
fn a_widened_effect_set_is_named_effect_by_effect() {
let before = manifest_of(&[&DRAFT_REFUND], &[governed_route(&DRAFT_REFUND)]);
let after = manifest_of(
&[&DRAFT_REFUND_WIDENED],
&[governed_route(&DRAFT_REFUND_WIDENED)],
);
let drift = format_drift(&before, &after).expect("drift");
assert!(drift.contains("billing::refunds::draft_refund"), "{drift}");
assert!(drift.contains("+ outbound"), "{drift}");
assert!(
drift.contains("https://api.stripe.com/v1/refunds"),
"{drift}"
);
let narrowed = format_drift(&after, &before).expect("drift");
assert!(narrowed.contains("- outbound"), "{narrowed}");
}
#[test]
fn a_schema_version_change_is_reported() {
let before = manifest_of(&[&DRAFT_REFUND], &[governed_route(&DRAFT_REFUND)]);
let mut after = before.clone();
after.schema_version += 1;
let drift = format_drift(&before, &after).expect("drift");
assert!(drift.contains("schema version"), "{drift}");
}
#[test]
fn a_newly_ungoverned_tool_is_named_in_the_drift_report() {
let before = manifest_of(&[&DRAFT_REFUND], &[governed_route(&DRAFT_REFUND)]);
let after = manifest_of(
&[&DRAFT_REFUND],
&[
governed_route(&DRAFT_REFUND),
route(
"DELETE",
"/widgets/{id}",
"destroy_widget",
"shop::widgets",
true,
None,
),
],
);
let drift = format_drift(&before, &after).expect("drift");
assert!(drift.contains("destroy_widget"), "{drift}");
assert!(drift.contains("ungoverned"), "{drift}");
}
#[test]
fn a_mutating_tool_with_no_envelope_fails_the_check() {
let manifest = manifest_of(
&[&DRAFT_REFUND],
&[
governed_route(&DRAFT_REFUND),
route(
"DELETE",
"/widgets/{id}",
"destroy_widget",
"shop::widgets",
true,
None,
),
],
);
let failure = format_ungoverned_failure(&manifest, false)
.expect("a mutating tool nothing governs must fail the gate");
assert!(failure.contains("destroy_widget"), "{failure}");
assert!(failure.contains("DELETE /widgets/{id}"), "{failure}");
assert!(failure.contains("--allow-ungoverned"), "{failure}");
assert!(failure.contains("agent_operable"), "{failure}");
}
#[test]
fn the_allow_flag_turns_the_failure_into_a_pass() {
let manifest = manifest_of(
&[&DRAFT_REFUND],
&[
governed_route(&DRAFT_REFUND),
route(
"DELETE",
"/widgets/{id}",
"destroy_widget",
"shop::widgets",
true,
None,
),
],
);
assert!(format_ungoverned_failure(&manifest, true).is_none());
let warning = format_ungoverned_warning(&manifest).expect("warning");
assert!(warning.contains("destroy_widget"), "{warning}");
}
#[test]
fn a_read_only_tool_with_no_envelope_warns_but_does_not_fail() {
let manifest = manifest_of(
&[&DRAFT_REFUND],
&[
governed_route(&DRAFT_REFUND),
route(
"GET",
"/widgets",
"list_widgets",
"shop::widgets",
true,
None,
),
],
);
assert!(format_ungoverned_failure(&manifest, false).is_none());
let warning = format_ungoverned_warning(&manifest).expect("warning");
assert!(warning.contains("list_widgets"), "{warning}");
}
#[test]
fn a_fully_governed_app_neither_fails_nor_warns() {
let manifest = manifest_of(&[&DRAFT_REFUND], &[governed_route(&DRAFT_REFUND)]);
assert!(format_ungoverned_failure(&manifest, false).is_none());
assert!(format_ungoverned_warning(&manifest).is_none());
}
#[test]
fn a_tool_is_described_by_method_path_and_name() {
let manifest = manifest_of(
&[],
&[route(
"DELETE",
"/widgets/{id}",
"destroy_widget",
"shop::widgets",
true,
None,
)],
);
let tool = manifest
.ungoverned_tools
.first()
.expect("the route is an ungoverned tool");
assert_eq!(describe_tool(tool), "DELETE /widgets/{id} (destroy_widget)");
}
fn manifest_with_sink(
authorities: &[&'static AgentAuthority],
routes: &[RouteSummary],
sink: bool,
) -> AgentAuthorityManifest {
AgentAuthorityManifest::from_parts(authorities, &[&REFUND_GRANT], routes, sink)
}
#[test]
fn no_sink_plus_an_action_nothing_can_undo_fails_the_check() {
let manifest =
manifest_with_sink(&[&DRAFT_REFUND], &[governed_route(&DRAFT_REFUND)], false);
let failure = format_unaudited_failure(&manifest, false).expect("must fail");
assert!(failure.contains("draft_refund"), "{failure}");
assert!(failure.contains("RefundDrafter"), "{failure}");
assert!(failure.contains("compensable"), "{failure}");
assert!(failure.contains("with_audit_sink"), "{failure}");
assert!(failure.contains("trivially succeeds"), "{failure}");
}
#[test]
fn a_configured_sink_clears_the_gate() {
let manifest = manifest_with_sink(&[&DRAFT_REFUND], &[governed_route(&DRAFT_REFUND)], true);
assert!(format_unaudited_failure(&manifest, false).is_none());
}
#[test]
fn an_action_no_agent_can_reach_does_not_fail_the_check() {
let http_only = manifest_with_sink(
&[&DRAFT_REFUND],
&[route(
"POST",
"/refunds",
"draft_refund",
"billing::refunds",
false,
Some(&DRAFT_REFUND),
)],
false,
);
assert!(format_unaudited_failure(&http_only, false).is_none());
let unmounted = manifest_with_sink(&[&DRAFT_REFUND], &[], false);
assert!(format_unaudited_failure(&unmounted, false).is_none());
let as_tool = manifest_with_sink(&[&DRAFT_REFUND], &[governed_route(&DRAFT_REFUND)], false);
let failure = format_unaudited_failure(&as_tool, false).expect("must fail");
assert!(
failure.contains("agent-callable at POST /refunds"),
"{failure}"
);
}
#[test]
fn an_ungoverned_mutating_tool_trips_the_gate_even_with_no_governed_action() {
let manifest = manifest_with_sink(
&[],
&[route(
"DELETE",
"/widgets/{id}",
"destroy_widget",
"shop::widgets",
true,
None,
)],
false,
);
let failure = format_unaudited_failure(&manifest, false).expect("must fail");
assert!(failure.contains("destroy_widget"), "{failure}");
}
#[test]
fn allow_unaudited_silences_the_gate_and_nothing_else() {
let manifest =
manifest_with_sink(&[&DRAFT_REFUND], &[governed_route(&DRAFT_REFUND)], false);
assert!(format_unaudited_failure(&manifest, true).is_none());
assert!(
format_report(&manifest).contains("NOT configured"),
"{}",
format_report(&manifest)
);
}
#[test]
fn a_route_naming_an_authority_nothing_registered_fails_the_check() {
let manifest = manifest_of(&[], &[governed_route(&DRAFT_REFUND)]);
assert!(manifest.actions.is_empty());
assert!(manifest.ungoverned_tools.is_empty());
let failure = format_unregistered_failure(&manifest).expect("must fail");
assert!(failure.contains("draft_refund"), "{failure}");
assert!(failure.contains("billing::refunds"), "{failure}");
}
#[test]
fn a_registered_authority_clears_the_unregistered_gate() {
let manifest = manifest_of(&[&DRAFT_REFUND], &[governed_route(&DRAFT_REFUND)]);
assert!(format_unregistered_failure(&manifest).is_none());
}
#[test]
fn an_unregistered_authority_appearing_is_reported_as_drift() {
let before = manifest_of(&[&DRAFT_REFUND], &[governed_route(&DRAFT_REFUND)]);
let after = manifest_of(&[], &[governed_route(&DRAFT_REFUND)]);
let drift = format_drift(&before, &after).expect("drifted");
assert!(drift.contains("unregistered authority"), "{drift}");
assert!(drift.contains("draft_refund"), "{drift}");
}
#[test]
fn an_added_asserted_effect_free_site_is_a_drift_line() {
static HATCHED: AgentAuthority = AgentAuthority {
action: "draft_refund",
module_path: "billing::refunds",
location: "billing/refunds.rs:28",
grant: &REFUND_GRANT,
effects: WRITE_ONLY,
asserted_effect_free_sites: 1,
asserted_effect_free: &[AssertedEffectFree {
location: "billing/refunds.rs:41",
reason: "the helper only formats the receipt",
}],
};
let before = manifest_of(&[&DRAFT_REFUND], &[governed_route(&DRAFT_REFUND)]);
let after = manifest_of(&[&HATCHED], &[governed_route(&HATCHED)]);
let drift = format_drift(&before, &after).expect("drifted");
assert!(drift.contains("+ asserted effect-free"), "{drift}");
assert!(drift.contains("billing/refunds.rs:41"), "{drift}");
assert!(
drift.contains("the helper only formats the receipt"),
"{drift}"
);
let back = format_drift(&after, &before).expect("drifted");
assert!(back.contains("- asserted effect-free"), "{back}");
let row = after
.actions
.iter()
.find(|r| r.action == "draft_refund")
.expect("row");
assert_eq!(
row.asserted_effect_free,
vec![AssertedEffectFreeRow {
location: "billing/refunds.rs:41".to_string(),
reason: "the helper only formats the receipt".to_string(),
}]
);
}
#[test]
fn the_reports_say_whether_an_attribute_or_the_hatch_exposed_a_tool() {
let mut hatched = route(
"GET",
"/reports",
"list_reports",
"shop::reports",
true,
None,
);
hatched.exposed_by = Some(McpExposedBy::Hatch);
let manifest = manifest_of(&[], &[hatched]);
let warning = format_ungoverned_warning(&manifest).expect("warned");
assert!(warning.contains("[hatch]"), "{warning}");
assert!(warning.contains("read-only"), "{warning}");
let empty = manifest_of(&[], &[]);
let drift = format_drift(&empty, &manifest).expect("drifted");
assert!(drift.contains("[hatch]"), "{drift}");
}
#[test]
fn a_renamed_tool_is_reported_by_both_names() {
let mut renamed = route(
"POST",
"/widgets",
"create_widget_handler",
"shop::widgets",
true,
None,
);
renamed.operation_id = "createWidget";
let manifest = manifest_of(&[], &[renamed]);
let tool = manifest
.ungoverned_tools
.first()
.expect("the route is an ungoverned tool");
assert_eq!(
describe_tool(tool),
"POST /widgets (createWidget, handler create_widget_handler)"
);
let plain = manifest_of(
&[],
&[route(
"DELETE",
"/widgets/{id}",
"destroy_widget",
"shop::widgets",
true,
None,
)],
);
assert_eq!(
describe_tool(&plain.ungoverned_tools[0]),
"DELETE /widgets/{id} (destroy_widget)"
);
}
#[test]
fn the_manifest_written_to_disk_reads_back_as_the_same_document() {
let manifest = manifest_of(&[&DRAFT_REFUND], &[governed_route(&DRAFT_REFUND)]);
let dir = std::env::temp_dir().join(format!(
"autumn-agent-authority-{}-{}",
std::process::id(),
line!()
));
std::fs::create_dir_all(&dir).expect("temp dir");
let path = dir.join("agent-authority-manifest.json");
write_manifest(&manifest, &path).expect("write");
let text = std::fs::read_to_string(&path).expect("read");
let parsed: AgentAuthorityManifest = serde_json::from_str(&text).expect("parse");
assert_eq!(parsed, manifest);
let _ = std::fs::remove_dir_all(&dir);
}
}