Skip to content

Commit 0b3d546

Browse files
committed
feat(audit): project capability-budget-exhaustion into provenance
QueryProvenance now projects CapabilityBudgetExhausted, surfacing the spent grant's signature as the matched rule with outcome budget_exhausted, so which rule ran out is auditable rather than dropped. The variant already carried signature_b58; this is a read-side projection only, so the audit write path and the audit-kinds.v1.json wire schema are unchanged (the dedicated serde-pin and golden freeze tests guard both). CapabilityScopeRejected gains a doc comment recording why candidate-cap attribution is deliberately omitted: on a scope reject the peer held several action-matched grants, all scope-insufficient, so a single signature would mislead, a Vec would balloon audit volume, and either would un-freeze the golden wire schema. Re-anchors stale in-source line citations shifted by the new doc block.
1 parent 40d6f5a commit 0b3d546

4 files changed

Lines changed: 146 additions & 32 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
Covenant sits below agent applications and above the host operating system. It owns the state, authority, and accountability concerns that recur across agent frameworks — scoped capabilities, durable memory, runtime isolation, append-only audit, and commit-scoped provenance — so individual frameworks can stop reinventing them.
1616

1717
<!-- METRICS:START -->
18-
**Status.** Local control plane is real and live-tested (36 Rust crates, ~254k lines, 3467 source-discovered Rust tests including 470 live boundary tests). Production-grade sandboxing for hostile agent code and networked multi-peer operation are roadmap; the Solana settlement program is deployed on mainnet (credits, staking, slashing, on-chain receipt anchoring), but its daemon-driven economic lifecycle is not yet production. See [BUILT.md](./BUILT.md) for the explicit honesty boundary.
18+
**Status.** Local control plane is real and live-tested (36 Rust crates, ~254k lines, 3469 source-discovered Rust tests including 470 live boundary tests). Production-grade sandboxing for hostile agent code and networked multi-peer operation are roadmap; the Solana settlement program is deployed on mainnet (credits, staking, slashing, on-chain receipt anchoring), but its daemon-driven economic lifecycle is not yet production. See [BUILT.md](./BUILT.md) for the explicit honesty boundary.
1919
<!-- METRICS:END -->
2020

2121
- **Web:** [opencovenant.org](https://opencovenant.org)

agent-os/crates/covenant-audit/src/lib.rs

Lines changed: 131 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,17 @@ pub enum AuditKind {
235235
action: String,
236236
reason: String,
237237
},
238+
/// Logged when a peer held at least one action-matched capability but
239+
/// every candidate's scope refused the concrete request, so the dispatch
240+
/// was denied. `reason` is the same short scope diagnostic the caller
241+
/// saw. Deliberately carries **no candidate-cap attribution**
242+
/// (provenance-capability-rule-evaluation-trace decision): on a scope
243+
/// reject there is no single "matched rule" — the peer may have held
244+
/// several action-matched grants, all scope-insufficient — so one
245+
/// `signature_b58` would mislead, a `Vec` would balloon audit volume,
246+
/// and either would unfreeze the golden wire schema
247+
/// (`audit-kinds.v1.json` / `provenance-records.v1.json`). Revisit only
248+
/// via an explicit schema-version bump.
238249
CapabilityScopeRejected {
239250
agent_id: String,
240251
action: String,
@@ -1439,19 +1450,20 @@ pub struct PrivilegedAction {
14391450
/// `Some` on rows that name an authorizing or affected capability.
14401451
pub rule: Option<String>,
14411452
/// How the action resolved: `authorized`, `denied`, `granted`,
1442-
/// `grant_rejected`, `revoked`, `revoke_noop`, `scope_rejected`, or
1443-
/// `revoke_rejected`.
1453+
/// `grant_rejected`, `revoked`, `revoke_noop`, `scope_rejected`,
1454+
/// `revoke_rejected`, or `budget_exhausted`.
14441455
pub outcome: String,
14451456
}
14461457

14471458
/// Project one audit event into its privileged-action rows. A
14481459
/// [`AuditKind::CapabilityCheck`] fans out to one `authorized` row per
14491460
/// granted required action (each naming its approver and rule) plus one
1450-
/// `denied` row per missing action; the single-row grant, revoke, and
1451-
/// rejection kinds produce exactly one row. Every other kind — intent
1452-
/// dispatch, Hermes tool calls, budget enforcement, peer/operator
1453-
/// administration — has no authorizing rule and projects to an empty
1454-
/// vec, so the provenance view stays scoped to capability provenance.
1461+
/// `denied` row per missing action; the single-row grant, revoke,
1462+
/// rejection, and use-budget-exhaustion kinds produce exactly one row.
1463+
/// Every other kind — intent dispatch, Hermes tool calls, token-budget
1464+
/// enforcement, peer/operator administration — has no authorizing rule
1465+
/// and projects to an empty vec, so the provenance view stays scoped to
1466+
/// capability provenance.
14551467
pub fn project_privileged_actions(event: &AuditEvent) -> Vec<PrivilegedAction> {
14561468
let row = |actor: String,
14571469
action: String,
@@ -1554,6 +1566,24 @@ pub fn project_privileged_actions(event: &AuditEvent) -> Vec<PrivilegedAction> {
15541566
"revoke_rejected",
15551567
"capability_revoke_rejected",
15561568
)],
1569+
// A budget-exhausted refusal is a capability deny with a *known*
1570+
// matched rule: the grant verified but its signed max_uses budget was
1571+
// spent. Project it so the provenance view answers "which rule ran
1572+
// out" rather than dropping the deny. The variant carries no subject
1573+
// display, so like `CapabilityRevokeRejected` the row is keyed by the
1574+
// authorizing signature with an empty actor.
1575+
AuditKind::CapabilityBudgetExhausted {
1576+
signature_b58,
1577+
action,
1578+
..
1579+
} => vec![row(
1580+
String::new(),
1581+
action.clone(),
1582+
None,
1583+
Some(signature_b58.clone()),
1584+
"budget_exhausted",
1585+
"capability_budget_exhausted",
1586+
)],
15571587
_ => Vec::new(),
15581588
}
15591589
}
@@ -1921,17 +1951,17 @@ mod tests {
19211951

19221952
#[tokio::test]
19231953
async fn jsonl_integrity_report_pins_root_hash_as_genesis_seeded_chain_fold() {
1924-
// verify_integrity returns root_hash_hex (lib.rs:1308) as the final
1954+
// verify_integrity returns root_hash_hex (lib.rs:1319) as the final
19251955
// accumulator of the audit hash chain: it seeds previous_hash_hex from
1926-
// ZERO_CHAIN_HASH (lib.rs:1263), then folds each event line forward as
1927-
// previous = chain_hash(previous, sha256_hex(line)) (lib.rs:1264-1296).
1956+
// ZERO_CHAIN_HASH (lib.rs:1274), then folds each event line forward as
1957+
// previous = chain_hash(previous, sha256_hex(line)) (lib.rs:1275-1307).
19281958
// That root is the audit-root subject release signing binds
1929-
// (lib.rs:2669), so its exact byte construction is load-bearing for any
1959+
// (lib.rs:2699), so its exact byte construction is load-bearing for any
19301960
// independent verifier of the anchor.
19311961
//
19321962
// jsonl_integrity_report_accepts_untampered_chain pins root_hash_hex
19331963
// only by length (== 64) plus report.valid, and valid is RELATIONAL: it
1934-
// holds whenever the write-path build_chain_entries (lib.rs:1014) agrees
1964+
// holds whenever the write-path build_chain_entries (lib.rs:1025) agrees
19351965
// with the verify-path inline fold, so a mutation applied consistently
19361966
// to BOTH paths survives it. chain_hash_pins_separator_and_sha256_-
19371967
// composition pins the single LINK, but nothing recomputes the
@@ -1953,7 +1983,7 @@ mod tests {
19531983
assert!(report.valid, "{report:?}");
19541984

19551985
// Independent fold of the exact lines verify_integrity reads
1956-
// (read_event_lines filters empty lines identically, lib.rs:1039-1045).
1986+
// (read_event_lines filters empty lines identically, lib.rs:1050-1056).
19571987
let raw = std::fs::read_to_string(&path).unwrap();
19581988
let mut expected = ZERO_CHAIN_HASH.to_string();
19591989
for line in raw.lines().filter(|l| !l.is_empty()) {
@@ -1977,11 +2007,11 @@ mod tests {
19772007
#[tokio::test]
19782008
async fn jsonl_integrity_report_detects_dangling_chain_anchor() {
19792009
// verify_integrity flags a hash-chain sidecar that outruns the
1980-
// event log: `if anchors.len() > event_lines.len()` (lib.rs:1298)
2010+
// event log: `if anchors.len() > event_lines.len()` (lib.rs:1309)
19812011
// reports the surplus as "<n> dangling chain anchor(s)" — the
19822012
// specific diagnostic for an event log that lost trailing lines
19832013
// (truncated or rolled back) while the append-only chain sidecar
1984-
// kept its anchors. The earlier `!=` parity check (lib.rs:1256)
2014+
// kept its anchors. The earlier `!=` parity check (lib.rs:1267)
19852015
// also marks any count mismatch invalid, so line 827 is the
19862016
// dangling-count diagnostic on top of that verdict, not the sole
19872017
// gate. Every other integrity test runs equal event/anchor counts
@@ -2036,7 +2066,7 @@ mod tests {
20362066
async fn jsonl_integrity_report_detects_missing_anchor_for_unanchored_event() {
20372067
// verify_integrity flags a well-formed event line that has no backing
20382068
// chain anchor: the `Ok(event)` branch's `None => "chain entry {index}
2039-
// missing"` arm (lib.rs:1280), reached only when the event log outruns
2069+
// missing"` arm (lib.rs:1291), reached only when the event log outruns
20402070
// the append-only hash-chain sidecar (event_lines.len() >
20412071
// anchors.len()). That skew is the signature of a forged event appended
20422072
// without extending the chain, and the diagnostic must name the
@@ -2047,7 +2077,7 @@ mod tests {
20472077
//
20482078
// Mutation: narrowing line 977 to `None => {}` drops only this
20492079
// per-index diagnostic. The `anchors.len() != event_lines.len()` parity
2050-
// check at lib.rs:1256 already flips `valid`, so asserting only
2080+
// check at lib.rs:1267 already flips `valid`, so asserting only
20512081
// `!report.valid` would NOT catch the regression — the load-bearing
20522082
// assertion is the specific "chain entry 1 missing" string.
20532083
let dir = tempfile::tempdir().unwrap();
@@ -3673,6 +3703,47 @@ mod tests {
36733703
}
36743704
}
36753705

3706+
#[test]
3707+
fn audit_kind_capability_budget_exhausted_serde_pins_four_field_variant() {
3708+
// AuditKind::CapabilityBudgetExhausted records a deny whose matched
3709+
// grant verified but had already spent its signed max_uses budget —
3710+
// signature_b58 is the join key back to the spent grant and the one
3711+
// field QueryProvenance projects as the rule.
3712+
let kind = AuditKind::CapabilityBudgetExhausted {
3713+
signature_b58: "SpentSig".into(),
3714+
action: "tool.call.echo".into(),
3715+
max_uses: 5,
3716+
used: 5,
3717+
};
3718+
3719+
let wire = serde_json::to_value(&kind).unwrap();
3720+
let obj = wire
3721+
.as_object()
3722+
.expect("AuditKind serializes as a JSON object");
3723+
let mut keys: Vec<&str> = obj.keys().map(String::as_str).collect();
3724+
keys.sort();
3725+
assert_eq!(
3726+
keys,
3727+
vec!["action", "max_uses", "signature_b58", "type", "used"]
3728+
);
3729+
assert_eq!(
3730+
obj.get("type"),
3731+
Some(&serde_json::json!("capability_budget_exhausted")),
3732+
);
3733+
3734+
let back: AuditKind = serde_json::from_value(wire.clone()).unwrap();
3735+
assert_eq!(back, kind);
3736+
3737+
for required in ["signature_b58", "action", "max_uses", "used"] {
3738+
let mut missing = obj.clone();
3739+
missing.remove(required);
3740+
assert!(
3741+
serde_json::from_value::<AuditKind>(serde_json::Value::Object(missing)).is_err(),
3742+
"AuditKind::CapabilityBudgetExhausted wire form must reject a payload missing {required:?}",
3743+
);
3744+
}
3745+
}
3746+
36763747
#[test]
36773748
fn audit_kind_authentication_failed_serde_pins_two_field_variant() {
36783749
// AuditKind::AuthenticationFailed records every rejected auth
@@ -5418,13 +5489,56 @@ mod tests {
54185489
);
54195490
}
54205491

5492+
#[test]
5493+
fn capability_budget_exhausted_projects_the_spent_rule() {
5494+
let rows = project_privileged_actions(&dummy(AuditKind::CapabilityBudgetExhausted {
5495+
signature_b58: "SpentSig".into(),
5496+
action: "tool.call.echo".into(),
5497+
max_uses: 5,
5498+
used: 5,
5499+
}));
5500+
assert_eq!(rows.len(), 1);
5501+
assert_eq!(rows[0].kind, "capability_budget_exhausted");
5502+
assert_eq!(rows[0].outcome, "budget_exhausted");
5503+
assert_eq!(
5504+
rows[0].rule.as_deref(),
5505+
Some("SpentSig"),
5506+
"a budget-exhausted deny is the one deny path with exactly one \
5507+
matched rule — the provenance view must name the spent grant",
5508+
);
5509+
assert_eq!(rows[0].action, "tool.call.echo");
5510+
assert!(
5511+
rows[0].approver.is_none(),
5512+
"exhaustion is refused by budget arithmetic, not by an approver",
5513+
);
5514+
assert!(
5515+
rows[0].actor.is_empty(),
5516+
"the variant carries no subject display; like a cross-peer revoke \
5517+
rejection the row is keyed by the authorizing signature",
5518+
);
5519+
}
5520+
54215521
#[test]
54225522
fn non_capability_kinds_project_no_privileged_actions() {
54235523
let rows = project_privileged_actions(&dummy(AuditKind::AuthenticationFailed {
54245524
transport: "ipc".into(),
54255525
reason: "bad token".into(),
54265526
}));
54275527
assert!(rows.is_empty(), "auth failures carry no authorizing rule");
5528+
5529+
let rows = project_privileged_actions(&dummy(AuditKind::BudgetExhausted {
5530+
agent_display: "research@agent".into(),
5531+
intent_id: Uuid::nil(),
5532+
intent_text: "summarize the logs".into(),
5533+
requested: 10,
5534+
tokens_remaining: 0,
5535+
refill_eta_ms: 60_000,
5536+
}));
5537+
assert!(
5538+
rows.is_empty(),
5539+
"token-budget exhaustion names no signed rule — only the \
5540+
capability use-budget variant projects into provenance",
5541+
);
54285542
}
54295543

54305544
#[test]

0 commit comments

Comments
 (0)