Skip to content

Commit ff3b6cf

Browse files
committed
feat: audit provenance — content hashes, taint/span rendering, OAuth mint effect-audit.
Signed-off-by: Teryl Taylor <terylt@ibm.com>
1 parent 2214aac commit ff3b6cf

15 files changed

Lines changed: 940 additions & 144 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/).
1818
### Added
1919

2020
- **Out-of-process host for existing Python CPEX plugins.** A new `cpex-hosts-python` crate registers `kind: isolated_venv`, running an unmodified Python CPEX plugin in its own cached virtualenv as a subprocess instead of in-process through the PyO3 bindings. Each plugin gets a venv keyed by a SHA-256 fingerprint of its requirements + manifest (rebuilt when either changes, `rmtree`d rather than upgraded in place so a removed dependency actually disappears), and the host drives the Python framework's `worker.py` over a newline-delimited JSON stdio protocol. Hook payloads, `context`, and the capability-filtered `Extensions` view cross as JSON; returns come back as a serialized `PluginResult`, with `modified_extensions` merged through the executor's existing copy-on-write tier validation — the host implements no tier logic of its own. Failure modes the executor cannot otherwise distinguish (venv build failure, worker death mid-flight, a task over `max_content_size`, per-invocation timeout) map to distinct `PluginError`s carrying a stable `code` and structured `details`, so the executor's configured `on_error` policy applies unchanged. Pure Rust plus a subprocess — no libpython link, so the crate is in `default-members` and a plain `cargo build` does not require a Python dev install. The wire contract is pinned in `docs/specs/extensions-wire-contract.md`; CMF §3 remains normative for the extension slots themselves. (#149)
21+
- **First-class decision and effect auditing.** CPEX can now audit its own enforcement — every allow, deny, and modify — instead of only the allowed post-hook traffic an observation plugin happened to see. A new `AuditHook` family, auto-attached by the `PluginManager`, fires at the executor's verdict return points (not a pipeline phase), so a blocked call, a scope narrowing, and a clean allow all produce a record. Each carries a `DecisionLog` — executor-owned and handed only to audit sinks, never placed on `PluginContext` — with the ordered plugin steps, the terminal verdict, the invocation's W3C trace span (`trace_id` / `span_id` / `parent_span_id`, child-span model: a fresh span whose parent is the request's span, for causal-DAG reconstruction), the taint labels the request arrived with, and, opt-in, a content hash of the payload at entry. Irreversible external effects (a token mint, an approval grant) are audited as their own events through a capability-gated, write-ahead protocol: a plugin holding `emit_effect` calls `ext.begin_effect` to durably record intent *before* the act (fail-closed — no durable record, no act) and `ext.complete_effect` to record the outcome (`confirmed` / `rejected` / `unknown`). A durable `FileEffectLog` write-ahead log (append + `fsync`, serialized against concurrent writers, self-compacting at a configurable threshold) makes the intent crash-safe; startup recovery (`PluginManager::recover_effects`) compacts completed effects and reconciles crash-orphaned ones against the issuing participant through an `EffectReconciler` seam (the default logs and leaves them `unknown`). `Extensions::perform_effect` brackets the two-phase protocol so a caller cannot skip, reorder, or forget it. Opt-in throughout: no effect WAL and no content hashing unless `plugin_settings.effect_log_path` / `plugin_settings.capture_content_provenance` are set. (#XXX)
22+
- **The OAuth delegator emits write-ahead audit for the tokens it mints.** `cpex-plugin-delegator-oauth` now brackets both mint legs — the workload `client_assertion` base-token mint and the RFC 8693 exchange — with `begin_effect` / `complete_effect`, mapping a successful exchange to `confirmed`, a definitive IdP rejection to `rejected`, and a timeout or unreachable IdP to `unknown` (reconciled later, never assumed minted). Effects are emitted only when the operator grants the plugin `emit_effect` and configures an effect WAL; otherwise the mint path is unchanged. There is deliberately no OAuth-specific reconciler — an IdP exposes no lookup by mint key, so the core default (log and leave `unknown`) is the honest behavior. (#XXX)
23+
- **The reference `audit-logger` renders the new provenance.** Decision records now include the invocation `span`, a `taint` object (the labels the request arrived with vs. the labels after the pipeline — their difference is the taint this node added), and, when content provenance is enabled, a `content` object with the input and output payload hashes (`sha256:…`, digests only, never the content itself). (#XXX)
2124

2225
### Fixed
2326

Cargo.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

builtins/plugins/audit-logger/src/logger.rs

Lines changed: 116 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,54 @@ impl AuditLogger {
230230
})
231231
.collect();
232232
map.insert("decision_steps".into(), json!(steps));
233+
234+
// The invocation's node identity in the decision graph: its own
235+
// span, the upstream call that triggered it (causal parent), and
236+
// the trace they share. Downstream joins these into a causal DAG.
237+
if let Some(span) = decisions.span() {
238+
map.insert(
239+
"span".into(),
240+
json!({
241+
"trace_id": span.trace_id,
242+
"span_id": span.span_id,
243+
"parent_span_id": span.parent_span_id,
244+
}),
245+
);
246+
}
247+
248+
// Taint provenance: the labels the request arrived with vs. the
249+
// labels after the pipeline. Their difference is the taint this
250+
// node added — a taint edge in the decision graph.
251+
let input_labels: Vec<&String> = decisions.input_labels().iter().collect();
252+
let final_labels: Vec<String> = ext
253+
.security
254+
.as_ref()
255+
.map(|s| {
256+
let mut l: Vec<String> = s.labels.iter().cloned().collect();
257+
l.sort_unstable();
258+
l
259+
})
260+
.unwrap_or_default();
261+
if !input_labels.is_empty() || !final_labels.is_empty() {
262+
map.insert(
263+
"taint".into(),
264+
json!({ "input": input_labels, "final": final_labels }),
265+
);
266+
}
267+
268+
// Content-addressed provenance: the input hash (captured at entry
269+
// when enabled) plus this node's output hash. Gated on input_hash
270+
// presence — when provenance is off it is `None` and we emit
271+
// neither. Only digests, never content.
272+
if let Some(input_hash) = decisions.input_hash() {
273+
let output_hash = payload
274+
.and_then(|p| p.audit_bytes())
275+
.map(|b| cpex_core::hooks::payload::content_hash(&b));
276+
map.insert(
277+
"content".into(),
278+
json!({ "input_hash": input_hash, "output_hash": output_hash }),
279+
);
280+
}
233281
}
234282
record
235283
}
@@ -348,7 +396,8 @@ mod tests {
348396
assert_eq!(record["tool_call"]["args"]["employee_id"], "EMP-001234");
349397
// Always-allow contract: handler returns continue_processing.
350398
let mut ctx = PluginContext::default();
351-
let r = <AuditLogger as HookHandler<CmfHook>>::handle(&plugin, &payload, &ext, &mut ctx).await;
399+
let r =
400+
<AuditLogger as HookHandler<CmfHook>>::handle(&plugin, &payload, &ext, &mut ctx).await;
352401
assert!(r.continue_processing);
353402
assert!(r.violation.is_none());
354403
}
@@ -371,6 +420,72 @@ mod tests {
371420
assert_eq!(record["verdict"]["deny"]["code"], "missing_permission");
372421
assert_eq!(record["decision_steps"][0]["plugin"], "cedar-pdp");
373422
assert_eq!(record["decision_steps"][0]["action"], "Denied");
423+
// No span was set on this log → no span field emitted.
424+
assert!(record.get("span").is_none());
425+
}
426+
427+
#[test]
428+
fn decision_record_includes_span_when_set() {
429+
use cpex_core::decision::Span;
430+
431+
let plugin = AuditLogger::new(cfg()).unwrap();
432+
let mut log = DecisionLog::new();
433+
log.set_span(Span::for_request(Some("trace-abc"), Some("upstream-span")));
434+
log.finalize(Verdict::Allow);
435+
436+
let record = plugin.build_decision_record(None, &Extensions::default(), &log);
437+
assert_eq!(record["span"]["trace_id"], "trace-abc");
438+
assert_eq!(record["span"]["parent_span_id"], "upstream-span");
439+
assert!(record["span"]["span_id"]
440+
.as_str()
441+
.is_some_and(|s| !s.is_empty()));
442+
}
443+
444+
#[test]
445+
fn decision_record_includes_taint_delta() {
446+
let plugin = AuditLogger::new(cfg()).unwrap();
447+
let mut log = DecisionLog::new();
448+
log.set_input_labels(vec!["PII".into()]); // the request arrived carrying PII
449+
log.finalize(Verdict::Allow);
450+
451+
// Final state: the pipeline added `secret`.
452+
let mut sec = SecurityExtension::default();
453+
sec.labels.insert("PII".into());
454+
sec.labels.insert("secret".into());
455+
let ext = Extensions {
456+
security: Some(Arc::new(sec)),
457+
..Default::default()
458+
};
459+
460+
let record = plugin.build_decision_record(None, &ext, &log);
461+
assert_eq!(record["taint"]["input"], serde_json::json!(["PII"]));
462+
assert_eq!(
463+
record["taint"]["final"],
464+
serde_json::json!(["PII", "secret"])
465+
);
466+
}
467+
468+
#[test]
469+
fn decision_record_includes_content_hashes_when_captured() {
470+
let plugin = AuditLogger::new(cfg()).unwrap();
471+
let mut log = DecisionLog::new();
472+
log.set_input_hash(Some("sha256:deadbeef".into()));
473+
log.finalize(Verdict::Allow);
474+
475+
// No payload on this dispatch → output_hash is null, input present.
476+
let record = plugin.build_decision_record(None, &Extensions::default(), &log);
477+
assert_eq!(record["content"]["input_hash"], "sha256:deadbeef");
478+
assert!(record["content"]["output_hash"].is_null());
479+
}
480+
481+
#[test]
482+
fn no_content_field_without_input_hash() {
483+
// Provenance off (input_hash None) → no content field at all.
484+
let plugin = AuditLogger::new(cfg()).unwrap();
485+
let mut log = DecisionLog::new();
486+
log.finalize(Verdict::Allow);
487+
let record = plugin.build_decision_record(None, &Extensions::default(), &log);
488+
assert!(record.get("content").is_none());
374489
}
375490

376491
#[test]

builtins/plugins/delegator-oauth/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ serde_json = { workspace = true }
5959
tokio = { workspace = true }
6060
chrono = { workspace = true }
6161
tracing = { workspace = true }
62+
# `uuid` for the per-mint effect key (a unique attempt id in the effect WAL).
63+
uuid = { workspace = true }
6264

6365
# `base64` decodes the minted token's JWT payload for a best-effort,
6466
# read-only interop check (did the IdP honor the RFC 8693 `actor_token`

0 commit comments

Comments
 (0)