Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

- **Compliance cost model verified — SOC 2 readiness is ~$15–17K first-year, not $20–50K.** The compliance-readiness tracker's cost table is filled from real quotes (2026-08-07 compliance-automation platform conversation): ~$12K/yr platform covering both SOC 2 and ISO 42001, ~$3K Type I, ~$4–5K Type II, pen test and ISO 42001 bundled at no increment. Cost is no longer the gating variable — the trigger remains the first enterprise contract requiring certification. The three-horizon map's P5 profile carries the same economics. Numbers rounded and vendor-neutral in the public tree; attributed quote record internal. Docs only — no product impact. (#454)

### Added
- **Decision-sink demo — the cpex#166 seam integration is now visibly runnable.** `integrations/cpex-ocsf-audit/examples/decision_sink_demo.rs` feeds four finalized `DecisionLog`s through the sink-mode emitter and pretty-prints the OCSF records: clean allow, allow-after-modification (`action: Modified`), a denial with the violation at `status_code`/`status_detail` (the record a post-hook observer can never produce), and the suppressed-deny case with the flat `deny_ignored: true` flag plus an `aborted` step. Deterministic timestamps and stream stamps; complements `emit_sample` (dispatch half, chain + DSSE verify). Verified live: 32/32 tests and both examples green against cpex `feat/audit-seam` @ `386710a` on rustc 1.96. Docs/demo only — no product impact. (#479)

### Fixed
- **Lock regeneration command rewritten to Renovate's dialect — round two of the pip-compile unblock.** #477 restored the uv autogenerated header, but Renovate's parser then rejected the command itself, verified against the manager's source: options with arguments must use `=` separators (`--python-version=3.11`, not `--python-version 3.11`), only `--output-file` is recognized (not `-o`), and `--python-platform` is not in the uv allowlist at all. All three lock headers now carry `uv pip compile <src> --python-version=3.11 --generate-hashes --output-file=<lock>` — a header-only change (uv keeps existing pins as its resolution baseline, so no transitive drift). The dropped platform flag means locks must be regenerated on linux/x86_64 (CI, Renovate, or a container); the requirements.txt headers say so. CI/deps only — no product impact. (#478)

Expand Down
182 changes: 182 additions & 0 deletions integrations/cpex-ocsf-audit/examples/decision_sink_demo.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
// Location: ./integrations/cpex-ocsf-audit/examples/decision_sink_demo.rs
// Copyright 2026 AI Identity
// SPDX-License-Identifier: Apache-2.0
//
// Demo: the decision-audit sink — the half of this plugin that consumes
// the cpex#166 audit seam. A sink-mode emitter (no `hooks:` listed, so
// it attaches as an `AuditHandler`) receives the executor's finalized
// `DecisionLog` at every pipeline verdict and turns it into an OCSF
// event. This example feeds it the four rulings that matter and
// pretty-prints what lands in the audit stream:
//
// 1. Allow — every plugin let the request through
// 2. Allow-after-modify — a redactor rewrote the payload (Modified)
// 3. Deny — the PDP blocked it (violation -> status)
// 4. Suppressed deny — a Transform-phase plugin signalled deny and
// was ignored by role (`deny_ignored`), plus a
// concurrent branch cancelled (`aborted`).
// Terminal verdict Allow — the record a
// post-hook observer could never produce.
//
// cargo run --example decision_sink_demo
//
// Timestamps and stream stamps are fixed so the output is deterministic.

use std::collections::HashMap;
use std::sync::Arc;

use serde_json::json;

use cpex_plugin_ocsf_audit::OcsfAuditEmitter;

use cpex_core::cmf::{ContentPart, Message, MessagePayload, Role, ToolCall};
use cpex_core::decision::{DecisionLog, PluginAction, Span, Verdict};
use cpex_core::error::PluginViolation;
use cpex_core::extensions::{Extensions, SecurityExtension, SubjectExtension};
use cpex_core::plugin::{OnError, PluginConfig, PluginMode};

/// Sink-mode emitter: `hooks` is EMPTY, which is what makes the factory
/// attach this instance as a decision-audit sink (`as_audit_handler()`)
/// instead of a post-hook observer.
fn sink() -> OcsfAuditEmitter {
let config = PluginConfig {
name: "ocsf-decision-sink-demo".into(),
kind: "audit/ocsf".into(),
hooks: vec![],
mode: PluginMode::Audit,
priority: 50,
on_error: OnError::Fail,
config: Some(json!({
"chain": false,
"product_name": "AI Identity OCSF Audit",
"vendor_name": "AI Identity",
})),
..Default::default()
};
OcsfAuditEmitter::new(config).expect("valid demo config")
}

/// The request under judgement: an agent invoking the `get_compensation`
/// HR tool on behalf of alice@corp.com.
fn tool_request() -> (MessagePayload, Extensions) {
let payload = MessagePayload {
message: Message::with_content(
Role::Tool,
vec![ContentPart::ToolCall {
content: ToolCall {
tool_call_id: "call-042".into(),
name: "get_compensation".into(),
arguments: HashMap::from([("employee_id".to_string(), json!("EMP-001234"))]),
namespace: Some("hr".into()),
},
}],
),
};

let mut sec = SecurityExtension::default();
let mut subj = SubjectExtension::default();
subj.id = Some("alice@corp.com".into());
subj.roles.insert("hr".into());
sec.subject = Some(subj);
sec.labels.insert("PII".into());

let ext = Extensions {
security: Some(Arc::new(sec)),
..Default::default()
};
(payload, ext)
}

/// Build a finalized DecisionLog the way the executor would: ordered
/// per-plugin steps, a terminal verdict, the invocation span, and the
/// seam's completeness/ordering stamps.
fn finalized(
steps: Vec<(&str, PluginMode, PluginAction)>,
verdict: Verdict,
stream_seq: u64,
emission_seq: u64,
) -> DecisionLog {
let mut log = DecisionLog::new();
for (name, mode, action) in steps {
log.record(name, mode, action);
}
log.set_span(Span {
trace_id: "4bf92f3577b34da6a3ce929d0e0e4736".into(),
span_id: format!("00f067aa0ba9{:04}", emission_seq),
parent_span_id: Some("00f067aa0ba90200".into()),
});
log.set_stream(1_755_648_000_000_000_000, "gw-1/boot-7".into(), stream_seq, emission_seq);
log.finalize(verdict);
log
}

fn main() {
let e = sink();
let (payload, ext) = tool_request();

// 1. Clean allow: PDP and PII scan both passed.
let allow = finalized(
vec![
("cedar-pdp", PluginMode::Sequential, PluginAction::Allowed),
("pii-scan", PluginMode::Sequential, PluginAction::Allowed),
],
Verdict::Allow,
41,
41,
);

// 2. Allow after modification: the redactor rewrote the payload.
let modified = finalized(
vec![
("cedar-pdp", PluginMode::Sequential, PluginAction::Allowed),
("pii-redactor", PluginMode::Transform, PluginAction::ModifiedPayload),
],
Verdict::Allow,
42,
42,
);

// 3. Deny: the PDP blocked the call. The violation the executor
// stamped rides into status_code / status_detail.
let mut violation = PluginViolation::new(
"policy_denied",
"cedar-pdp: subject lacks permission read_compensation on hr/get_compensation",
);
violation.plugin_name = Some("cedar-pdp".into());
let denied = finalized(
vec![("cedar-pdp", PluginMode::Sequential, PluginAction::Denied)],
Verdict::Deny(violation),
43,
43,
);

// 4. The subtle record: a Transform-phase plugin signalled deny and
// was suppressed by role (deny_ignored, never re-coded as allow),
// and a concurrent branch was cancelled (aborted, distinct from
// error). Terminal verdict: Allow. "Every suppressed transform
// deny" is one SIEM query on these step actions.
let suppressed = finalized(
vec![
("cedar-pdp", PluginMode::Sequential, PluginAction::Allowed),
("injection-guard", PluginMode::Transform, PluginAction::DenyIgnored),
("secondary-scan", PluginMode::Transform, PluginAction::Aborted),
],
Verdict::Allow,
44,
44,
);

let cases = [
("1 — Allow (clean)", &allow, "2026-08-21T03:20:00.000Z"),
("2 — Allow after modification", &modified, "2026-08-21T03:20:01.000Z"),
("3 — Deny (policy violation)", &denied, "2026-08-21T03:20:02.000Z"),
("4 — Suppressed deny + aborted branch", &suppressed, "2026-08-21T03:20:03.000Z"),
];

for (title, log, ts) in cases {
let ev = e.build_decision(Some(&payload), &ext, log, ts);
println!("// ===== Decision {title} =====");
println!("{}", serde_json::to_string_pretty(&ev).unwrap());
println!();
}
}
Loading