Skip to content

Commit eda9821

Browse files
committed
fix: harden audit seam per review — startup recovery, panic containment, config-gated effects, one audit record per invocation
Signed-off-by: Teryl Taylor <terylt@ibm.com>
1 parent a178cf9 commit eda9821

10 files changed

Lines changed: 536 additions & 52 deletions

File tree

bindings/python/src/manager.rs

Lines changed: 27 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -358,15 +358,16 @@ mod tests {
358358
manager
359359
}
360360

361-
/// The `tokio::spawn` in `invoke_hook` must catch a panicking plugin and
362-
/// surface `JoinError::is_panic()` rather than aborting the process or
363-
/// leaking the panic to the pyo3_async_runtimes dispatch task.
364-
///
365-
/// This is the Rust-level regression test for the panic-isolation
366-
/// guarantee; the Python-level guarantee is that `invoke_hook` raises
367-
/// `RuntimeError` rather than `pyo3_async_runtimes.RustPanic`.
361+
/// A panicking plugin in the serial phase is contained by the executor
362+
/// (`catch_unwind`) and handled by `on_error` — with the default
363+
/// `on_error=Fail` it becomes a fail-closed deny, exactly like the
364+
/// concurrent phase. So the spawned invoke **completes** (no
365+
/// `JoinError::is_panic`), the result is a deny, and the panic is recorded
366+
/// in the violation with code `plugin_panic` and its message preserved. The
367+
/// `tokio::spawn`/`JoinError` net in `invoke_hook` remains for panics
368+
/// outside a contained plugin body.
368369
#[tokio::test]
369-
async fn invoke_on_panicking_plugin_returns_join_error_is_panic() {
370+
async fn invoke_on_panicking_plugin_is_contained_as_deny() {
370371
let manager = build_panicking_manager();
371372
manager.initialize().await.expect("initialize");
372373

@@ -383,27 +384,29 @@ mod tests {
383384
})
384385
.await;
385386

387+
// The panic is contained, so the spawned task completes rather than
388+
// unwinding.
386389
assert!(
387-
join_result.is_err(),
388-
"spawned task should have failed due to panic"
390+
join_result.is_ok(),
391+
"the invoke task should complete, not unwind, on a contained panic"
389392
);
390-
let join_err = join_result.unwrap_err();
393+
let (result, _bg) = join_result.unwrap();
394+
395+
// Default on_error=Fail turns the panic into a fail-closed deny ...
391396
assert!(
392-
join_err.is_panic(),
393-
"JoinError should report is_panic()=true, not a cancellation"
397+
!result.continue_processing,
398+
"a contained plugin panic denies the request"
399+
);
400+
// ... coded plugin_panic, with the panic message preserved.
401+
let violation = result.violation.expect("a deny carries a violation");
402+
assert_eq!(
403+
violation.code, "plugin_panic",
404+
"a contained plugin panic is coded plugin_panic"
394405
);
395-
396-
// Verify the panic message is extractable — this is the same downcast
397-
// logic used in invoke_hook to build the RuntimeError message.
398-
let payload = join_err.into_panic();
399-
let msg = payload
400-
.downcast_ref::<&str>()
401-
.copied()
402-
.or_else(|| payload.downcast_ref::<String>().map(String::as_str))
403-
.unwrap_or("unknown panic");
404406
assert!(
405-
msg.contains("simulated panic"),
406-
"panic message should propagate, got: {msg}"
407+
violation.reason.contains("simulated panic"),
408+
"panic message should propagate, got: {}",
409+
violation.reason
407410
);
408411
}
409412

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

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,31 @@ impl PluginFactory for AuditLoggerFactory {
2525
fn create(&self, config: &PluginConfig) -> Result<PluginInstance, Box<PluginError>> {
2626
let logger = Arc::new(AuditLogger::new(config.clone())?);
2727

28+
// Make the inferred mode explicit in the startup log. Audit-only (no
29+
// `hooks:`) is the recommended sink mode, so this stays at info rather
30+
// than warn — but it names the mode unambiguously and points at the
31+
// typo case, so an operator who *meant* to list hooks and lost them to
32+
// a YAML slip can catch it in the logs rather than silently getting a
33+
// sink. (An explicit config flag would remove the inference entirely —
34+
// tracked for the sink-mode discussion.)
35+
if config.hooks.is_empty() {
36+
tracing::info!(
37+
plugin = %config.name,
38+
"audit-logger '{}' running in audit-only sink mode (no `hooks:` listed) — \
39+
auto-attaches to the executor verdict path; if you meant to observe specific \
40+
hooks, list them under `hooks:`",
41+
config.name,
42+
);
43+
} else {
44+
tracing::info!(
45+
plugin = %config.name,
46+
hooks = ?config.hooks,
47+
"audit-logger '{}' running as a CMF post-hook observer on {:?}",
48+
config.name,
49+
config.hooks,
50+
);
51+
}
52+
2853
// With no `hooks:` listed the logger runs in audit-only mode — it
2954
// registers no CMF post-hook handlers and instead auto-attaches as a
3055
// decision-audit sink (see `Plugin::as_audit_handler`). Listing hooks

builtins/plugins/delegator-oauth/src/delegator.rs

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -333,8 +333,17 @@ impl OAuthDelegator {
333333
},
334334
};
335335
// Best-effort completion: the act already happened, so a completion
336-
// write failure is not fatal (recovery reconciles by the intent's key).
337-
let _ = ext.complete_effect(&intent, state).await;
336+
// write failure is not fatal (recovery reconciles by the intent's
337+
// key). It must not be silent, though — mirror the core primitive and
338+
// log, so a persistently failing WAL is visible rather than hidden.
339+
if let Err(e) = ext.complete_effect(&intent, state).await {
340+
tracing::warn!(
341+
effect_key = %intent.key,
342+
error = %e,
343+
"failed to write token-mint effect completion; \
344+
recovery will reconcile by key"
345+
);
346+
}
338347

339348
outcome
340349
}

crates/cpex-core/src/config.rs

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -738,6 +738,22 @@ pub(crate) fn validate_config(config: &CpexConfig) -> Result<(), Box<PluginError
738738
message: format!("duplicate plugin name: '{}'", plugin.name),
739739
}));
740740
}
741+
742+
// `emit_effect` is only honored in modes whose phase wires a live
743+
// effect emitter (Sequential / Transform). In any other mode the
744+
// capability would silently no-op at runtime — the mint runs with no
745+
// write-ahead record and no fail-closed guarantee — so reject the
746+
// combination here rather than let it fail open.
747+
if plugin.capabilities.contains("emit_effect") && !plugin.mode.grants_effect_emitter() {
748+
return Err(Box::new(PluginError::Config {
749+
message: format!(
750+
"plugin '{}' declares the 'emit_effect' capability with mode '{}', \
751+
which cannot emit effects (only sequential/transform can); \
752+
effect calls would silently no-op",
753+
plugin.name, plugin.mode
754+
),
755+
}));
756+
}
741757
}
742758

743759
if config.routing_enabled() {
@@ -1189,6 +1205,39 @@ plugins:
11891205
.contains("duplicate plugin name"));
11901206
}
11911207

1208+
#[test]
1209+
fn emit_effect_rejected_in_non_emitting_modes() {
1210+
// Concurrent / audit / fire_and_forget don't wire an effect emitter,
1211+
// so `emit_effect` there would silently no-op — reject at config.
1212+
for mode in ["concurrent", "audit", "fire_and_forget"] {
1213+
let yaml = format!(
1214+
"plugins:\n - name: minter\n kind: builtin\n mode: {mode}\n \
1215+
hooks: [tool_pre_invoke]\n capabilities: [emit_effect]\n"
1216+
);
1217+
let err = parse_config(&yaml).unwrap_err().to_string().to_lowercase();
1218+
assert!(
1219+
err.contains("emit_effect") && err.contains(mode),
1220+
"mode {mode} should be rejected; got: {err}"
1221+
);
1222+
}
1223+
}
1224+
1225+
#[test]
1226+
fn emit_effect_allowed_in_emitting_modes() {
1227+
// Sequential / transform run through the serial phase, which grants
1228+
// the emitter — so `emit_effect` is honored and must pass validation.
1229+
for mode in ["sequential", "transform"] {
1230+
let yaml = format!(
1231+
"plugins:\n - name: minter\n kind: builtin\n mode: {mode}\n \
1232+
hooks: [tool_pre_invoke]\n capabilities: [emit_effect]\n"
1233+
);
1234+
assert!(
1235+
parse_config(&yaml).is_ok(),
1236+
"mode {mode} should be allowed to emit effects"
1237+
);
1238+
}
1239+
}
1240+
11921241
#[test]
11931242
fn parses_effect_log_settings() {
11941243
let yaml = r#"

crates/cpex-core/src/decision.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,16 @@ pub enum PluginAction {
3434
ModifiedPayload,
3535
/// Wrote to an extension slot it was capable of writing.
3636
ModifiedExtensions,
37+
/// Signalled a block from a non-blocking phase (Transform), so the deny
38+
/// was suppressed. Recorded as its own action — never as `Allowed` — so
39+
/// the record reflects the plugin's actual decision, not the discarded
40+
/// intent. A downstream mapping (e.g. an OCSF `ai_operation` disposition)
41+
/// must not read this as an allow.
42+
DenyIgnored,
43+
/// Cancelled mid-flight because another concurrent branch short-circuited
44+
/// the phase. An intentional abort, not a failure — distinct from
45+
/// [`PluginAction::Error`] so it doesn't read as a crash.
46+
Aborted,
3747
/// Failed. The string is the error rendered by the executor; whether
3848
/// this halts the pipeline is decided by the plugin's `on_error`.
3949
Error(String),

crates/cpex-core/src/effect.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,10 @@ pub struct EffectRecord {
5656
pub description: String,
5757
/// Idempotency / reconciliation key threaded into the external call, so
5858
/// an `unknown` outcome can be resolved against the participant later.
59+
/// Must be **unique per attempt** — recovery resolves keys across the
60+
/// whole WAL, so a key reused across retries would let an earlier
61+
/// attempt's terminal record mask a later attempt's orphan (see
62+
/// [`FileEffectLog::recover`]).
5963
pub key: String,
6064
/// Where in its lifecycle this record is.
6165
pub state: EffectState,
@@ -410,6 +414,15 @@ impl FileEffectLog {
410414

411415
// A key is resolved iff some record for it reached a terminal
412416
// state. Everything else (prepared-only, unknown) is unresolved.
417+
//
418+
// INVARIANT: a `key` is a unique per-attempt id, not a reused
419+
// idempotency key. Resolution matches across the whole file, so a
420+
// plugin that reused one stable key across retries would let a
421+
// terminal record from an earlier attempt mask a later attempt's
422+
// orphaned `prepared` as resolved. The OAuth delegator satisfies
423+
// this with a fresh UUID per mint; a future plugin that wants
424+
// stable idempotency keys must add per-attempt scoping here (e.g.
425+
// an attempt counter alongside the key) before relying on recovery.
413426
let resolved: std::collections::HashSet<&str> = records
414427
.iter()
415428
.filter(|r| matches!(r.state, EffectState::Confirmed | EffectState::Rejected))

crates/cpex-core/src/executor.rs

Lines changed: 109 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -396,6 +396,45 @@ impl Executor {
396396
);
397397
}
398398

399+
/// Emit a single allow decision record for an invocation that resolved to
400+
/// zero plugins, keeping the audit stream dense at one record per
401+
/// invocation. **Cheap no-op when no audit sink is attached** — an
402+
/// unaudited host pays only a length check, building no record and
403+
/// consuming no sequence number. The manager calls this at its zero-plugin
404+
/// short-circuits (which return before reaching `execute`), and `execute`
405+
/// calls it for a direct empty invocation. Captures the same span /
406+
/// input-label / input-hash provenance a normal run records at entry, then
407+
/// stamps and emits.
408+
pub(crate) async fn emit_empty_allow(
409+
&self,
410+
payload: &dyn PluginPayload,
411+
extensions: &Extensions,
412+
) {
413+
if self.audit_handlers.is_empty() {
414+
return;
415+
}
416+
let mut decisions = DecisionLog::new();
417+
let request = extensions.request.as_ref();
418+
decisions.set_span(crate::decision::Span::for_request(
419+
request.and_then(|r| r.trace_id.as_deref()),
420+
request.and_then(|r| r.span_id.as_deref()),
421+
));
422+
if let Some(sec) = extensions.security.as_ref() {
423+
let mut labels: Vec<String> = sec.labels.iter().cloned().collect();
424+
labels.sort_unstable();
425+
decisions.set_input_labels(labels);
426+
}
427+
if self.config.capture_content_provenance {
428+
let hash = payload
429+
.audit_bytes()
430+
.map(|b| crate::hooks::payload::content_hash(&b));
431+
decisions.set_input_hash(hash);
432+
}
433+
decisions.finalize(Verdict::Allow);
434+
self.stamp_decision_stream(&mut decisions);
435+
self.emit_audit(payload, extensions, &decisions).await;
436+
}
437+
399438
async fn emit_audit(
400439
&self,
401440
payload: &dyn PluginPayload,
@@ -465,7 +504,15 @@ impl Executor {
465504
) -> (PipelineResult, BackgroundTasks) {
466505
let mut ctx_table = context_table.unwrap_or_default();
467506

507+
// A hook that resolves to zero plugins is a normal case (nothing is
508+
// configured for this entity). It still emits exactly one allow record
509+
// so the audit stream stays dense at one record per invocation — but
510+
// `emit_empty_allow` is a no-op when no sink is attached, so an
511+
// unaudited host pays nothing. (The manager short-circuits most
512+
// zero-plugin invocations before reaching here and calls
513+
// `emit_empty_allow` itself; this covers a direct `execute(&[], …)`.)
468514
if entries.is_empty() {
515+
self.emit_empty_allow(&*payload, &extensions).await;
469516
return (
470517
PipelineResult::allowed_with(payload, extensions, ctx_table),
471518
BackgroundTasks::empty(),
@@ -705,13 +752,40 @@ impl Executor {
705752
}));
706753
}
707754

708-
// Execute with timeout — handler borrows payload, gets filtered extensions
755+
// Execute with timeout — handler borrows payload, gets filtered
756+
// extensions. Contain a panic the same way the concurrent phase
757+
// does (`catch_unwind`): a panic between `begin_effect` and
758+
// `complete_effect` would otherwise unwind the whole request
759+
// future. Collapsing it into a `PluginError` lets `on_error`
760+
// decide and keeps the pipeline's bookkeeping intact; the orphaned
761+
// WAL entry is left for recovery to reconcile as `unknown` rather
762+
// than crashing the request.
763+
use futures::FutureExt;
709764
let timeout_dur = Duration::from_secs(self.config.timeout_seconds);
710765
let result = timeout(
711766
timeout_dur,
712-
entry.handler.invoke(&**payload, &filtered, &mut ctx),
767+
std::panic::AssertUnwindSafe(entry.handler.invoke(&**payload, &filtered, &mut ctx))
768+
.catch_unwind(),
713769
)
714-
.await;
770+
.await
771+
.map(|caught| {
772+
caught.unwrap_or_else(|panic| {
773+
let msg = panic
774+
.downcast_ref::<&'static str>()
775+
.map(|s| s.to_string())
776+
.or_else(|| panic.downcast_ref::<String>().cloned())
777+
.unwrap_or_else(|| "unknown panic".to_string());
778+
error!("{} plugin '{}' panicked: {}", phase_label, plugin_name, msg);
779+
Err(Box::new(crate::error::PluginError::Execution {
780+
plugin_name: plugin_name.to_string(),
781+
message: format!("task panicked: {msg}"),
782+
source: None,
783+
code: Some("panic".into()),
784+
details: std::collections::HashMap::new(),
785+
proto_error_code: None,
786+
}))
787+
})
788+
});
715789

716790
match result {
717791
Ok(Ok(result_box)) => {
@@ -724,8 +798,21 @@ impl Executor {
724798
}
725799
}
726800

801+
// A block signalled from a non-blocking phase
802+
// (Transform): suppressed by the phase contract
803+
// (can_modify, not can_block), but recorded as the
804+
// plugin's actual intent — never a plain allow.
805+
// Enforcement is unchanged (the pipeline proceeds);
806+
// this plugin's modifications are skipped, since it
807+
// asked to stop rather than shape.
808+
let deny_ignored =
809+
!erased.continue_processing && !can_block && erased.violation.is_some();
810+
if deny_ignored {
811+
action = PluginAction::DenyIgnored;
812+
}
813+
727814
// Accept modifications
728-
if can_modify {
815+
if can_modify && !deny_ignored {
729816
if let Some(mp) = erased.modified_payload {
730817
*payload = mp;
731818
action = PluginAction::ModifiedPayload;
@@ -832,12 +919,26 @@ impl Executor {
832919
// If extract failed or no modifications — payload unchanged
833920
},
834921
Ok(Err(e)) => {
922+
// A contained panic (from the `catch_unwind` above) carries
923+
// code "panic". Surface it with the same "plugin_panic"
924+
// violation code the concurrent phase uses, so a host or
925+
// sink can distinguish a panic from an ordinary plugin error
926+
// by code, regardless of which phase it happened in.
927+
let is_panic = matches!(
928+
e.as_ref(),
929+
crate::error::PluginError::Execution { code: Some(c), .. }
930+
if c.as_str() == "panic"
931+
);
835932
error!("{} plugin '{}' failed: {}", phase_label, plugin_name, e);
836933
action = PluginAction::Error(e.to_string());
837934
match on_error {
838935
OnError::Fail if can_block => {
839936
let mut v = crate::error::PluginViolation::new(
840-
"plugin_error",
937+
if is_panic {
938+
"plugin_panic"
939+
} else {
940+
"plugin_error"
941+
},
841942
format!("Plugin '{}' failed: {}", plugin_name, e),
842943
);
843944
v.plugin_name = Some(plugin_name.to_string());
@@ -1141,8 +1242,9 @@ impl Executor {
11411242
},
11421243
BranchOutcome::TimedOut => PluginAction::Error("timed out".to_string()),
11431244
BranchOutcome::Panicked(s) => PluginAction::Error(format!("panicked: {s}")),
1144-
// Cancelled because another branch short-circuited the phase.
1145-
BranchOutcome::Aborted => PluginAction::Error("aborted".to_string()),
1245+
// Cancelled because another branch short-circuited the phase —
1246+
// an intentional abort, recorded as such rather than an error.
1247+
BranchOutcome::Aborted => PluginAction::Aborted,
11461248
};
11471249
decisions.record(plugin_name, entry.plugin_ref.trusted_config().mode, action);
11481250

0 commit comments

Comments
 (0)