From f7eb4ac4e6d2bed6a39e623254492a9491a62017 Mon Sep 17 00:00:00 2001 From: Jeff Leva Date: Mon, 20 Jul 2026 16:06:01 -0600 Subject: [PATCH 1/2] =?UTF-8?q?feat(plugins):=20add=20ocsf-audit=20?= =?UTF-8?q?=E2=80=94=20OCSF=20API=20Activity=20emitter=20with=20attestatio?= =?UTF-8?q?n=20chain?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New builtin CMF plugin (audit/ocsf), a near-twin of audit-logger that serializes each dispatched request as an OCSF API Activity event (class 6003, ai_operation + security_control profiles) instead of a free-form JSON line. - Activity ids follow API Activity's real enum: resources/prompts and readOnlyHint tools -> 2 (Read); other tool calls -> 99 (Other) + activity_name; completions -> 99. Passive post-hook stream carries action_id 3 (Observed) / disposition_id 17 (Logged); deny/modify mapping waits on the framework decision event. - Optional tamper-evident attestation chain with predecessor binding: entry_hash = sha256(canonical{chain_uid, event, prev_entry_hash}) over JCS-style canonical bytes — independently recomputable from the emitted JSON, no shared secret. DSSE signing is a seam (stub) that consumes the same binding bytes. - CMF gap fields (stop_reason, mcp.*, framework.*, monotonic labels, workload identity) ride under OCSF unmapped, keeping the open schema gaps self-documenting; upstream OCSF issues are being filed. - Registered as feature-gated builtin (not in default features); 13 unit tests + deterministic emit_sample example. --- Cargo.lock | 15 + Cargo.toml | 3 + builtins/plugins/ocsf-audit/Cargo.toml | 42 ++ builtins/plugins/ocsf-audit/README.md | 102 +++ builtins/plugins/ocsf-audit/SAMPLE-OUTPUT.md | 194 ++++++ .../ocsf-audit/examples/emit_sample.rs | 207 +++++++ builtins/plugins/ocsf-audit/src/config.rs | 87 +++ builtins/plugins/ocsf-audit/src/emitter.rs | 585 ++++++++++++++++++ builtins/plugins/ocsf-audit/src/factory.rs | 62 ++ builtins/plugins/ocsf-audit/src/lib.rs | 52 ++ builtins/plugins/ocsf-audit/src/ocsf.rs | 451 ++++++++++++++ builtins/plugins/ocsf-audit/src/sign.rs | 178 ++++++ crates/cpex-builtins/Cargo.toml | 2 + crates/cpex-builtins/src/lib.rs | 3 + 14 files changed, 1983 insertions(+) create mode 100644 builtins/plugins/ocsf-audit/Cargo.toml create mode 100644 builtins/plugins/ocsf-audit/README.md create mode 100644 builtins/plugins/ocsf-audit/SAMPLE-OUTPUT.md create mode 100644 builtins/plugins/ocsf-audit/examples/emit_sample.rs create mode 100644 builtins/plugins/ocsf-audit/src/config.rs create mode 100644 builtins/plugins/ocsf-audit/src/emitter.rs create mode 100644 builtins/plugins/ocsf-audit/src/factory.rs create mode 100644 builtins/plugins/ocsf-audit/src/lib.rs create mode 100644 builtins/plugins/ocsf-audit/src/ocsf.rs create mode 100644 builtins/plugins/ocsf-audit/src/sign.rs diff --git a/Cargo.lock b/Cargo.lock index 9a49b7d6..c061aec7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -744,6 +744,7 @@ dependencies = [ "cpex-plugin-delegator-oauth", "cpex-plugin-elicitation-ciba", "cpex-plugin-identity-jwt", + "cpex-plugin-ocsf-audit", "cpex-plugin-pii-scanner", "cpex-session-valkey", "tokio", @@ -916,6 +917,20 @@ dependencies = [ "tracing", ] +[[package]] +name = "cpex-plugin-ocsf-audit" +version = "0.2.2" +dependencies = [ + "async-trait", + "chrono", + "cpex-core", + "serde", + "serde_json", + "sha2 0.10.9", + "tokio", + "tracing", +] + [[package]] name = "cpex-plugin-pii-scanner" version = "0.2.2" diff --git a/Cargo.toml b/Cargo.toml index 11f2bd51..d0301c07 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,7 @@ members = [ "crates/apl-cpex", "builtins/plugins/pii-scanner", "builtins/plugins/audit-logger", + "builtins/plugins/ocsf-audit", "builtins/plugins/identity-jwt", "builtins/plugins/delegator-oauth", "builtins/plugins/delegator-biscuit", @@ -55,6 +56,7 @@ default-members = [ "crates/apl-cpex", "builtins/plugins/pii-scanner", "builtins/plugins/audit-logger", + "builtins/plugins/ocsf-audit", "builtins/plugins/identity-jwt", "builtins/plugins/delegator-oauth", "builtins/plugins/delegator-biscuit", @@ -123,6 +125,7 @@ apl-cmf = { path = "crates/apl-cmf", apl-cpex = { path = "crates/apl-cpex", version = "0.2.2" } cpex-plugin-pii-scanner = { path = "builtins/plugins/pii-scanner", version = "0.2.2" } cpex-plugin-audit-logger = { path = "builtins/plugins/audit-logger", version = "0.2.2" } +cpex-plugin-ocsf-audit = { path = "builtins/plugins/ocsf-audit", version = "0.2.2" } cpex-plugin-identity-jwt = { path = "builtins/plugins/identity-jwt", version = "0.2.2" } cpex-plugin-delegator-oauth = { path = "builtins/plugins/delegator-oauth", version = "0.2.2" } cpex-plugin-delegator-biscuit = { path = "builtins/plugins/delegator-biscuit", version = "0.2.2" } diff --git a/builtins/plugins/ocsf-audit/Cargo.toml b/builtins/plugins/ocsf-audit/Cargo.toml new file mode 100644 index 00000000..dd2df9b0 --- /dev/null +++ b/builtins/plugins/ocsf-audit/Cargo.toml @@ -0,0 +1,42 @@ +# Location: ./builtins/plugins/ocsf-audit/Cargo.toml +# Copyright 2026 AI Identity +# SPDX-License-Identifier: Apache-2.0 +# Authors: Jeff Leva +# +# cpex-plugin-ocsf-audit — CMF plugin that serializes each dispatched +# request into an OCSF API Activity event (class 6003, ai_operation + +# security_control profiles), optionally hash-chained into a +# tamper-evident attestation (record_integrity) and DSSE-signable. +# Near-twin of audit-logger (same observation-only, always-allow +# contract); the difference is the record shape — OCSF instead of a +# free-form JSON line, so the enforcement record is interoperable and +# independently verifiable offline. + +[package] +name = "cpex-plugin-ocsf-audit" +version.workspace = true +edition.workspace = true +license.workspace = true +authors = ["Jeff Leva"] +description = "CPEX CMF plugin — emit OCSF API Activity events with a tamper-evident attestation chain." +repository.workspace = true +homepage.workspace = true +keywords.workspace = true +categories.workspace = true +rust-version.workspace = true + +[dependencies] +cpex-core = { workspace = true } + +async-trait = { workspace = true } +chrono = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +sha2 = "0.10" +tracing = { workspace = true } + +[dev-dependencies] +tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread"] } + +[lints] +workspace = true diff --git a/builtins/plugins/ocsf-audit/README.md b/builtins/plugins/ocsf-audit/README.md new file mode 100644 index 00000000..63bb9fde --- /dev/null +++ b/builtins/plugins/ocsf-audit/README.md @@ -0,0 +1,102 @@ +# cpex-plugin-ocsf-audit + +A CMF plugin that emits each dispatched request as an **OCSF API Activity event** +(class 6003, `ai_operation` + `security_control` profiles) — optionally wrapped in a +tamper-evident **attestation chain** (`record_integrity` profile shape) that an +independent party can verify offline. + +It is a near-twin of the [`audit-logger`](../audit-logger) builtin — same +observation-only, always-allow contract, same factory + hook wiring. The difference is +the record shape: + +| | `audit-logger` | `ocsf-audit` (this crate) | +|---|---|---| +| Output | free-form JSON line | OCSF API Activity event | +| Verifiability | none | hash chain with predecessor binding, DSSE-ready | +| Schema | ad hoc | OCSF — interoperable across tools | + +CPEX produces the enforcement record; this plugin makes it portable (OCSF) and +independently verifiable (attestation chain) without CPEX having to own a schema. + +## Wiring (APL) + +```yaml +plugins: + - name: ocsf-audit + kind: audit/ocsf + hooks: # POST hooks: result/taint/delegation resolved + - cmf.tool_post_invoke + - cmf.llm_output + - cmf.resource_post_fetch + - cmf.prompt_post_invoke # NOT cmf.prompt_post_fetch — see note below + config: + destination: stderr # or: tracing + chain: true # tamper-evident entry_hash chain + signing: none # or: dsse (stub until a signer is provisioned) + chain_uid: "org-example" # stable chain id across the deployment +``` + +> **Prompt hook name.** `cpex-core` ships two prompt-hook vocabularies: +> `hooks/types.rs` has `cmf.prompt_pre/post_fetch`, but the Rust CMF/APL runtime +> dispatches the `cmf/constants.rs` names `cmf.prompt_pre/post_invoke`. A Rust CMF +> plugin must register on the `_invoke` names or prompt events silently never fire. +> (The resource hook names agree across both files — only prompt diverges.) + +## Record shape + +- **Host class:** API Activity (`class_uid: 6003`, `category_uid: 6`), carrying the + `ai_operation` profile objects (`ai_agent`, `ai_model`, `message_context`) plus + `delegation`, actor/user, and tool/resource coordinates. +- **Activity ids** follow API Activity's real enum. Resources, prompts, and tools + annotated `readOnlyHint: true` map to `2 (Read)`; other tool invocations are the + honest `99 (Other)` + `activity_name: "Invoke Tool"` (no Create/Update/Delete claim + without knowing the operation); completions are `99` + `"Completion"`. +- **security_control:** this passive post-hook stream is `action_id: 3 (Observed)` / + `disposition_id: 17 (Logged)`. Deny/modify records (`action_id` 2/4) require the + framework to surface its decision to a plugin — planned, not in this crate yet. +- **Gap fields** with no OCSF home yet (`completion.stop_reason`, `mcp.*`, + `framework.*`, monotonic security labels, workload identity) are emitted under + OCSF `unmapped` (config `include_gap_fields`, default on), which preserves the + evidence and makes the open schema gaps self-documenting. Upstream OCSF issues for + these gaps are being filed. + +## The attestation chain + +With `chain: true`, every event is wrapped in an attestation with **predecessor +binding**: the entry hash commits to the record's position in its chain, not just its +content. + +``` +entry_hash = sha256( canonical_bytes( { + "chain_uid": , + "event": , + "prev_entry_hash": +} ) ) +``` + +`canonical_bytes` is a JCS-style (RFC 8785) canonical serialization (sorted keys, +compact output; set-derived arrays are sorted at build time), so an independent +verifier can recompute the chain from the emitted JSON alone — no access to this +process, no shared secret. Tampering with any record, reordering records, or splicing +a record into a different chain breaks recomputation at that entry. + +Signing is a seam (`sign::OcsfSigner`): `signing: none` emits chained-but-unsigned +records; the `dsse` mode is a stub pending signer provisioning (detached DSSE over the +same canonical binding bytes, so a signature commits to chain position). A provisioned +signer that fails must mark the record loudly (`signed: false` + reason), never +silently unsigned. + +Known limitation (tracked for productionization): the chain head lives in process +memory — one chain per plugin instance, reset on restart. Durable / replica-safe +chaining and checkpoint signing are part of the production-readiness plan. + +## Building and testing + +```bash +cargo build -p cpex-plugin-ocsf-audit +cargo test -p cpex-plugin-ocsf-audit +cargo run -p cpex-plugin-ocsf-audit --example emit_sample +``` + +`SAMPLE-OUTPUT.md` holds the deterministic output of the example — two chained events +with reproducible hashes. diff --git a/builtins/plugins/ocsf-audit/SAMPLE-OUTPUT.md b/builtins/plugins/ocsf-audit/SAMPLE-OUTPUT.md new file mode 100644 index 00000000..594712e5 --- /dev/null +++ b/builtins/plugins/ocsf-audit/SAMPLE-OUTPUT.md @@ -0,0 +1,194 @@ +# Sample output — `cargo run --example emit_sample` + +Real OCSF events produced by the mapping in [`src/ocsf.rs`](src/ocsf.rs) from the two +demo turns in [`examples/emit_sample.rs`](examples/emit_sample.rs). Regenerated 2026-07-20 +after the P0 / review-§4-B revision (production-readiness plan, 2026-07-17/18) — the +hashes changed accordingly. Notes on fidelity: + +- **Host class is API Activity (6003)** with its real activity enum: a tool call without + `readOnlyHint` is the honest `activity_id: 99` + source-defined `activity_name` + ("Invoke Tool" / "Completion"); reads (resources, prompts, read-only-hinted tools) are + `2 (Read)` with the normalized caption. `metadata.profiles` declares `ai_operation` + + `security_control` (+ `record_integrity` when chained), and the passive stream carries + `action_id: 3 (Observed)` / `disposition_id: 17 (Logged)` — deny/modify records arrive + with the cpex-core decision event (WS-A / P1). +- **`entry_hash` commits to the record's chain position** (review §4-B): SHA-256 over the + JCS-style canonical bytes of `{"chain_uid", "event", "prev_entry_hash"}` — the + predecessor hash is part of the hashed input, not a back-pointer. An independent + verifier strips `attestation` from an emitted event, rebuilds that binding object from + the attestation's `chain_uid`/`prev_entry_hash`, canonicalizes + (`sign::canonical_bytes` — sorted keys, compact, set-derived arrays sorted at build + time; review C2), and recomputes. Output below — hashes included — is byte-identical + across process runs. +- **`correlation_uid` is the run id** (`AgentExtension.conversation_id`; review C1): both + events of this run carry `"conv-9"`, so a SIEM can join them on the standard base field. + The per-call `tool_call_id` rides at `api.request.uid`. +- **Key ordering is alphabetical** because `serde_json::Map` is backed by a `BTreeMap` by + default in Rust (the canonical hash bytes sort keys explicitly and do not rely on this). + +```jsonc +// ===== OCSF event 1 — Invoke Tool (get_compensation) ===== +{ + "action": "Observed", + "action_id": 3, + "activity_id": 99, + "activity_name": "Invoke Tool", + "actor": { + "roles": [ + "hr" + ], + "user": { + "groups": [ + "people-ops" + ], + "uid": "alice@corp.com" + } + }, + "ai_agent": { + "conversation_uid": "conv-9", + "instance_uid": "sess-42", + "parent_uid": "orchestrator-1", + "turn": 3, + "uid": "agent-7" + }, + "api": { + "request": { + "uid": "call-001" + } + }, + "attestation": { + "chain_uid": "demo-chain-org-f3576cf6", + "digital_signature": { + "serialization_id": "NONE", + "signed": false + }, + "entry_hash": "sha256:3512c3592465569f02b2d69323bddf96ea1c992c0f72254573fdcb352cbabd39", + "prev_entry_hash": null + }, + "category_uid": 6, + "class_uid": 6003, + "correlation_uid": "conv-9", + "delegation": { + "actor_subject_uid": "agent-7", + "chain": [ + { + "audience": "workday-api", + "scopes_granted": [ + "read_compensation" + ], + "subject_uid": "agent-7", + "timestamp": "1970-01-01T00:00:00+00:00", + "ttl_seconds": 300 + } + ], + "depth": 1, + "origin_subject_uid": "alice@corp.com" + }, + "disposition": "Logged", + "disposition_id": 17, + "metadata": { + "product": { + "name": "AI Identity OCSF Audit", + "vendor_name": "AI Identity" + }, + "profiles": [ + "ai_operation", + "security_control", + "record_integrity" + ], + "version": "1.9.0-dev" + }, + "severity_id": 1, + "time": "2026-06-30T12:00:00.000Z", + "tool": { + "name": "get_compensation", + "namespace": "hr", + "uid": "call-001" + }, + "type_uid": 600399, + "unmapped": { + "cmf.framework": { + "framework": "langgraph", + "framework_version": null, + "graph_id": "graph-hr", + "node_id": "node-compensation" + }, + "cmf.mcp": { + "tool": { + "annotations": {}, + "name": "get_compensation", + "namespace": "hr", + "server_id": "hr-mcp" + } + }, + "cmf.security.labels": [ + "PII", + "secret" + ], + "cmf.workload_identity": { + "attested_at": null, + "attestor": "gke-workload-identity", + "spiffe_id": "spiffe://corp/agent/hr-bot", + "trust_domain": "corp" + } + } +} + +// ===== OCSF event 2 — Completion (chained to event 1) ===== +{ + "action": "Observed", + "action_id": 3, + "activity_id": 99, + "activity_name": "Completion", + "ai_agent": { + "conversation_uid": "conv-9", + "instance_uid": "sess-42", + "parent_uid": null, + "turn": 4, + "uid": "agent-7" + }, + "ai_model": { + "name": "claude-opus-4-8" + }, + "attestation": { + "chain_uid": "demo-chain-org-f3576cf6", + "digital_signature": { + "serialization_id": "NONE", + "signed": false + }, + "entry_hash": "sha256:7d99ca0c534a0ed5c9ca3d7fb3d3881c16d04e5345ed323ae43f00ea6b73e11a", + "prev_entry_hash": "sha256:3512c3592465569f02b2d69323bddf96ea1c992c0f72254573fdcb352cbabd39" + }, + "category_uid": 6, + "class_uid": 6003, + "correlation_uid": "conv-9", + "disposition": "Logged", + "disposition_id": 17, + "duration": 842, + "message_context": { + "completion_tokens": 28, + "prompt_tokens": 120, + "total_tokens": 148 + }, + "metadata": { + "product": { + "name": "AI Identity OCSF Audit", + "vendor_name": "AI Identity" + }, + "profiles": [ + "ai_operation", + "security_control", + "record_integrity" + ], + "version": "1.9.0-dev" + }, + "severity_id": 1, + "time": "2026-06-30T12:00:01.000Z", + "type_uid": 600399, + "unmapped": { + "cmf.completion.stop_reason": "End" + } +} + +// chain check: event2.prev_entry_hash == event1.entry_hash -> true +``` diff --git a/builtins/plugins/ocsf-audit/examples/emit_sample.rs b/builtins/plugins/ocsf-audit/examples/emit_sample.rs new file mode 100644 index 00000000..a8c3396f --- /dev/null +++ b/builtins/plugins/ocsf-audit/examples/emit_sample.rs @@ -0,0 +1,207 @@ +// Location: ./builtins/plugins/ocsf-audit/examples/emit_sample.rs +// Copyright 2026 AI Identity +// SPDX-License-Identifier: Apache-2.0 +// +// Demo: build two realistic CMF turns (a tool invocation, then an LLM +// completion), run them through the OCSF audit emitter with attestation +// chaining on, and pretty-print the resulting OCSF events. +// +// Purpose: show what the plugin emits — including every gap field +// (stop_reason, mcp, framework, monotonic labels, workload identity) +// and the tamper-evident hash chain linking the two events — WITHOUT +// standing up a full CPEX gateway. +// +// cargo run --example emit_sample +// +// The timestamps are fixed so the output is deterministic (and so the +// entry_hash chain is reproducible across runs). + +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::extensions::{ + AgentExtension, CompletionExtension, DelegationExtension, DelegationHop, Extensions, + FrameworkExtension, MCPExtension, SecurityExtension, StopReason, SubjectExtension, TokenUsage, + ToolMetadata, WorkloadIdentity, +}; +use cpex_core::plugin::{OnError, PluginConfig, PluginMode}; + +fn emitter() -> OcsfAuditEmitter { + let config = PluginConfig { + name: "ocsf-audit-demo".into(), + kind: "audit/ocsf".into(), + hooks: vec!["cmf.tool_post_invoke".into(), "cmf.llm_output".into()], + mode: PluginMode::Sequential, + priority: 50, + on_error: OnError::Fail, + config: Some(json!({ + "chain": true, + "signing": "none", + "chain_uid": "demo-chain-org-f3576cf6", + "product_name": "AI Identity OCSF Audit", + "vendor_name": "AI Identity", + })), + ..Default::default() + }; + OcsfAuditEmitter::new(config).expect("valid demo config") +} + +/// Turn 1 — an agent invokes the `get_compensation` HR tool. Carries +/// identity, delegation, MCP tool metadata, framework context, taint +/// labels, and an attested workload identity. +fn tool_turn() -> (MessagePayload, Extensions) { + let payload = MessagePayload { + message: Message::with_content( + Role::Tool, + vec![ContentPart::ToolCall { + content: ToolCall { + tool_call_id: "call-001".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()); + subj.teams.insert("people-ops".into()); + sec.subject = Some(subj); + sec.labels.insert("PII".into()); + sec.labels.insert("secret".into()); + sec.caller_workload = Some(WorkloadIdentity { + spiffe_id: Some("spiffe://corp/agent/hr-bot".into()), + trust_domain: Some("corp".into()), + attestor: Some("gke-workload-identity".into()), + ..Default::default() + }); + + let agent = AgentExtension { + agent_id: Some("agent-7".into()), + parent_agent_id: Some("orchestrator-1".into()), + session_id: Some("sess-42".into()), + conversation_id: Some("conv-9".into()), + turn: Some(3), + ..Default::default() + }; + + let delegation = DelegationExtension { + delegated: true, + depth: 1, + origin_subject_id: Some("alice@corp.com".into()), + actor_subject_id: Some("agent-7".into()), + chain: vec![DelegationHop { + subject_id: "agent-7".into(), + audience: Some("workday-api".into()), + scopes_granted: vec!["read_compensation".into()], + ttl_seconds: Some(300), + ..Default::default() + }], + ..Default::default() + }; + + let mcp = MCPExtension { + tool: Some(ToolMetadata { + name: "get_compensation".into(), + server_id: Some("hr-mcp".into()), + namespace: Some("hr".into()), + ..Default::default() + }), + ..Default::default() + }; + + let framework = FrameworkExtension { + framework: Some("langgraph".into()), + node_id: Some("node-compensation".into()), + graph_id: Some("graph-hr".into()), + ..Default::default() + }; + + let ext = Extensions { + security: Some(Arc::new(sec)), + agent: Some(Arc::new(agent)), + delegation: Some(Arc::new(delegation)), + mcp: Some(Arc::new(mcp)), + framework: Some(Arc::new(framework)), + ..Default::default() + }; + + (payload, ext) +} + +/// Turn 2 — the model produces output. Carries completion metadata: +/// stop reason (a gap), token usage, model, latency. +fn completion_turn() -> (MessagePayload, Extensions) { + let payload = MessagePayload { + message: Message::with_content( + Role::Assistant, + vec![ContentPart::Text { + text: "Alice's current base compensation is redacted per policy.".into(), + }], + ), + }; + + let completion = CompletionExtension { + stop_reason: Some(StopReason::End), + tokens: Some(TokenUsage { + input_tokens: 120, + output_tokens: 28, + total_tokens: 148, + }), + model: Some("claude-opus-4-8".into()), + latency_ms: Some(842), + ..Default::default() + }; + + let agent = AgentExtension { + agent_id: Some("agent-7".into()), + session_id: Some("sess-42".into()), + // Same run as turn 1 — so both events carry + // correlation_uid = "conv-9" and are joinable (review C1). + conversation_id: Some("conv-9".into()), + turn: Some(4), + ..Default::default() + }; + + let ext = Extensions { + completion: Some(Arc::new(completion)), + agent: Some(Arc::new(agent)), + ..Default::default() + }; + + (payload, ext) +} + +fn main() { + let e = emitter(); + + let (p1, x1) = tool_turn(); + let ev1 = e.build(&p1, &x1, "2026-06-30T12:00:00.000Z"); + + let (p2, x2) = completion_turn(); + let ev2 = e.build(&p2, &x2, "2026-06-30T12:00:01.000Z"); + + println!("// ===== OCSF event 1 — Invoke Tool (get_compensation) ====="); + println!("{}", serde_json::to_string_pretty(&ev1).unwrap()); + println!(); + println!("// ===== OCSF event 2 — Completion (chained to event 1) ====="); + println!("{}", serde_json::to_string_pretty(&ev2).unwrap()); + println!(); + + // Demonstrate the tamper-evident chain: event 2's prev_entry_hash + // equals event 1's entry_hash. + let h1 = ev1["attestation"]["entry_hash"].as_str().unwrap_or(""); + let prev2 = ev2["attestation"]["prev_entry_hash"].as_str().unwrap_or(""); + println!( + "// chain check: event2.prev_entry_hash == event1.entry_hash -> {}", + h1 == prev2 + ); +} diff --git a/builtins/plugins/ocsf-audit/src/config.rs b/builtins/plugins/ocsf-audit/src/config.rs new file mode 100644 index 00000000..d5301544 --- /dev/null +++ b/builtins/plugins/ocsf-audit/src/config.rs @@ -0,0 +1,87 @@ +// Location: ./builtins/plugins/ocsf-audit/src/config.rs +// Copyright 2026 AI Identity +// SPDX-License-Identifier: Apache-2.0 +// +// Operator-facing config for the OCSF audit plugin. Mirrors the +// upstream audit-logger's config style (serde, snake_case enums, +// stderr default) and adds OCSF/attestation knobs. + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct OcsfAuditConfig { + /// Where OCSF events go. Stderr default keeps the demo flow + /// (`docker compose logs -f | jq`) identical to audit-logger. + #[serde(default)] + pub destination: OcsfDestination, + + /// Populates OCSF `metadata.product` so a single collector can + /// attribute events to a deployment. + #[serde(default = "default_product_name")] + pub product_name: String, + + /// Populates OCSF `metadata.product.vendor_name`. + #[serde(default = "default_vendor_name")] + pub vendor_name: String, + + /// When true, wrap every event in an attestation: compute an + /// `entry_hash` over the canonical event and thread the previous + /// event's hash into `prev_entry_hash`, forming a tamper-evident + /// chain. This is the integrity seam from the field map. + #[serde(default = "default_true")] + pub chain: bool, + + /// Stable identifier for this attestation chain (OCSF + /// `attestation.chain_uid`). If absent, a process-lifetime random + /// uid is generated at startup. + #[serde(default)] + pub chain_uid: Option, + + /// Signing mode for the attestation. `none` produces an unsigned + /// (but still hash-chained) record; `dsse` is the production mode + /// and declares `digital_signature.serialization_id = DSSE`. + #[serde(default)] + pub signing: SigningMode, + + /// When true (default), gap fields that have no native OCSF home + /// yet — `completion.stop_reason`, `mcp.*`, `framework.*`, + /// monotonic security labels — are emitted under OCSF `unmapped` + /// rather than dropped. This is deliberate: it preserves evidence + /// AND surfaces exactly which WS4/OCSF gaps the plugin had to work + /// around. See CMF-OCSF-FIELD-MAP.md §5. + #[serde(default = "default_true")] + pub include_gap_fields: bool, +} + +fn default_product_name() -> String { + "AI Identity OCSF Audit".to_string() +} +fn default_vendor_name() -> String { + "AI Identity".to_string() +} +fn default_true() -> bool { + true +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum OcsfDestination { + /// One OCSF JSON object per line to stderr. + #[default] + Stderr, + /// Emit via `tracing::info!` at target `ocsf.audit`. + Tracing, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SigningMode { + /// Hash-chained but unsigned. Useful for the demo and for + /// environments where the signing key isn't provisioned yet. + #[default] + None, + /// DSSE-signed (merged in OCSF #1662 via + /// `digital_signature.serialization_id`). Requires a key — see + /// sign.rs. Currently a stub. + Dsse, +} diff --git a/builtins/plugins/ocsf-audit/src/emitter.rs b/builtins/plugins/ocsf-audit/src/emitter.rs new file mode 100644 index 00000000..46f1f91d --- /dev/null +++ b/builtins/plugins/ocsf-audit/src/emitter.rs @@ -0,0 +1,585 @@ +// Location: ./builtins/plugins/ocsf-audit/src/emitter.rs +// Copyright 2026 AI Identity +// SPDX-License-Identifier: Apache-2.0 +// +// The plugin proper. Mirrors audit-logger::AuditLogger: holds config, +// implements Plugin + HookHandler, builds a record, emits, +// and returns allow() (observation-only, never blocks). +// +// Added over audit-logger: +// * OCSF mapping (ocsf::build_ai_operation) +// * optional attestation wrapper with a tamper-evident hash chain +// (entry_hash -> prev_entry_hash) threaded across calls +// * a pluggable signer (sign::OcsfSigner) + +use std::sync::Mutex; + +use async_trait::async_trait; +use serde_json::{json, Value}; + +use cpex_core::cmf::{CmfHook, MessagePayload}; +use cpex_core::context::PluginContext; +use cpex_core::error::PluginError; +use cpex_core::hooks::payload::Extensions; +use cpex_core::hooks::trait_def::{HookHandler, PluginResult}; +use cpex_core::plugin::{Plugin, PluginConfig}; + +use crate::config::{OcsfAuditConfig, OcsfDestination, SigningMode}; +use crate::ocsf; +use crate::sign::{canonical_bytes, entry_hash, DsseSigner, NoopSigner, OcsfSigner}; + +pub struct OcsfAuditEmitter { + cfg: PluginConfig, + typed: OcsfAuditConfig, + chain_uid: String, + signer: Box, + /// Last entry_hash, threaded into the next record's prev_entry_hash. + prev_entry_hash: Mutex>, +} + +impl std::fmt::Debug for OcsfAuditEmitter { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("OcsfAuditEmitter") + .field("name", &self.cfg.name) + .field("chain_uid", &self.chain_uid) + .finish() + } +} + +impl OcsfAuditEmitter { + pub fn new(cfg: PluginConfig) -> Result> { + let typed: OcsfAuditConfig = match cfg.config.as_ref() { + Some(raw) => serde_json::from_value(raw.clone()).map_err(|e| { + Box::new(PluginError::Config { + message: format!( + "plugin '{}' (cpex-plugin-ocsf-audit) config parse failed: {e}", + cfg.name + ), + }) + })?, + None => OcsfAuditConfig::default(), + }; + + let chain_uid = typed + .chain_uid + .clone() + // Process-lifetime fallback uid. Not random across restarts — + // operators who need a stable chain set chain_uid explicitly. + .unwrap_or_else(|| format!("ocsf-chain-{}", cfg.name)); + + let signer: Box = match typed.signing { + SigningMode::None => Box::new(NoopSigner), + SigningMode::Dsse => Box::new(DsseSigner::new()), + }; + + Ok(Self { + cfg, + typed, + chain_uid, + signer, + prev_entry_hash: Mutex::new(None), + }) + } + + /// Build the OCSF event and, if chaining is on, wrap it in an + /// attestation. `now_rfc3339` injected for testability and for + /// deterministic example/demo output. Public so `examples/` and + /// downstream tooling can obtain the event without going through + /// the stderr/tracing emit path. + pub fn build(&self, payload: &MessagePayload, ext: &Extensions, now_rfc3339: &str) -> Value { + let event = ocsf::build_ai_operation(payload, ext, &self.typed, now_rfc3339); + + if !self.typed.chain { + return event; + } + + // Predecessor binding (review §4-B, fixed 2026-07-20): the hash + // commits to (chain_uid, event, prev_entry_hash) — prev is part + // of the HASHED INPUT, not a back-pointer riding beside it, so + // the chain order is cryptographically bound, and committing + // chain_uid prevents splicing a record into a different chain. + // An independent verifier recomputes, per record: + // entry_hash = sha256(canonical_bytes( + // {"chain_uid": c, "event": e, "prev_entry_hash": p})) + // where e = the emitted event minus its `attestation` member, + // and c / p come from that attestation. Canonical bytes are + // JCS-style (review C2): key-sorted, compact, set-derived + // arrays already sorted at build time (ocsf.rs). + let binding = |prev: &Option| { + json!({ + "chain_uid": self.chain_uid, + "event": event, + "prev_entry_hash": prev, + }) + }; + + let (this_hash, prev, bytes) = { + let mut guard = self.prev_entry_hash.lock().unwrap(); + let prev = guard.clone(); + let bytes = canonical_bytes(&binding(&prev)); + let this_hash = entry_hash(&bytes); + *guard = Some(this_hash.clone()); + (this_hash, prev, bytes) + }; + + let mut attestation = json!({ + "chain_uid": self.chain_uid, + "entry_hash": this_hash, + "prev_entry_hash": prev, + }); + + if let Some(signed) = self.signer.sign(&bytes) { + attestation["signature"] = json!(signed.signature); + attestation["digital_signature"] = signed.digital_signature; + } else { + attestation["digital_signature"] = + json!({ "serialization_id": self.signer.serialization_id(), "signed": false }); + } + + // Attach the attestation to the event (OCSF `attestation` object). + let mut out = event; + if let Value::Object(m) = &mut out { + m.insert("attestation".into(), attestation); + } + out + } + + fn emit(&self, event: &Value) { + match self.typed.destination { + OcsfDestination::Stderr => eprintln!("{event}"), + OcsfDestination::Tracing => { + tracing::info!(target: "ocsf.audit", event = %event, "ocsf"); + }, + } + } +} + +#[async_trait] +impl Plugin for OcsfAuditEmitter { + fn config(&self) -> &PluginConfig { + &self.cfg + } +} + +impl HookHandler for OcsfAuditEmitter { + async fn handle( + &self, + payload: &MessagePayload, + ext: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + let now = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true); + let event = self.build(payload, ext, &now); + self.emit(&event); + // Observation-only: never block the request. + PluginResult::allow() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use cpex_core::cmf::{ContentPart, Message, Role, ToolCall}; + use cpex_core::extensions::{SecurityExtension, SubjectExtension}; + use cpex_core::plugin::{OnError, PluginConfig, PluginMode}; + use std::collections::HashMap; + use std::sync::Arc; + + fn cfg(extra: serde_json::Value) -> PluginConfig { + PluginConfig { + name: "ocsf-audit".into(), + kind: super::super::factory::KIND.into(), + hooks: vec!["cmf.tool_post_invoke".into()], + mode: PluginMode::Sequential, + priority: 50, + on_error: OnError::Fail, + config: Some(extra), + ..Default::default() + } + } + + fn tool_payload() -> MessagePayload { + MessagePayload { + message: Message::with_content( + Role::Tool, + vec![ContentPart::ToolCall { + content: ToolCall { + tool_call_id: "call-1".into(), + name: "get_compensation".into(), + arguments: HashMap::new(), + namespace: Some("hr".into()), + }, + }], + ), + } + } + + fn subject_ext() -> Extensions { + let mut sec = SecurityExtension::default(); + sec.subject = Some(SubjectExtension { + id: Some("alice@corp.com".into()), + ..Default::default() + }); + Extensions { + security: Some(Arc::new(sec)), + ..Default::default() + } + } + + #[test] + fn maps_tool_call_to_ocsf_ai_operation() { + let e = OcsfAuditEmitter::new(cfg(json!({ "chain": false }))).unwrap(); + let ev = e.build(&tool_payload(), &subject_ext(), "2026-06-30T12:00:00.000Z"); + + // Host class: API Activity (P0, 2026-07-18 thread). + assert_eq!(ev["class_uid"], 6003); + // No readOnlyHint on this tool -> honest 99 (Other) with a + // source-defined name, per the OCSF enum contract. + assert_eq!(ev["activity_id"], 99); + assert_eq!(ev["activity_name"], "Invoke Tool"); + assert_eq!(ev["type_uid"], 600399); + // Passive post-hook stream = security_control Observed/Logged. + assert_eq!(ev["action_id"], 3); + assert_eq!(ev["disposition_id"], 17); + // Review C1: the per-call id lands at api.request.uid, NOT + // correlation_uid (which mirrors the run id and is absent here + // because this payload carries no AgentExtension). + assert_eq!(ev["api"]["request"]["uid"], "call-1"); + assert!(ev["correlation_uid"].is_null()); + assert_eq!(ev["tool"]["name"], "get_compensation"); + assert_eq!(ev["tool"]["namespace"], "hr"); + assert_eq!(ev["actor"]["user"]["uid"], "alice@corp.com"); + assert_eq!(ev["metadata"]["product"]["vendor_name"], "AI Identity"); + } + + #[test] + fn chains_entry_hashes_across_calls() { + let e = OcsfAuditEmitter::new(cfg(json!({ "chain": true }))).unwrap(); + + let ev1 = e.build(&tool_payload(), &subject_ext(), "2026-06-30T12:00:00.000Z"); + let ev2 = e.build(&tool_payload(), &subject_ext(), "2026-06-30T12:00:01.000Z"); + + // First record has no predecessor. + assert!(ev1["attestation"]["prev_entry_hash"].is_null()); + // Second record's prev_entry_hash == first record's entry_hash. + assert_eq!( + ev2["attestation"]["prev_entry_hash"], + ev1["attestation"]["entry_hash"] + ); + // Unsigned-but-chained in the default (None) signing mode. + assert_eq!(ev1["attestation"]["digital_signature"]["signed"], false); + } + + #[test] + fn read_only_hint_maps_tool_call_to_read() { + use cpex_core::extensions::{MCPExtension, ToolMetadata}; + + let mut ext = subject_ext(); + ext.mcp = Some(Arc::new(MCPExtension { + tool: Some(ToolMetadata { + name: "get_compensation".into(), + annotations: HashMap::from([("readOnlyHint".to_string(), json!(true))]), + ..Default::default() + }), + ..Default::default() + })); + + let e = OcsfAuditEmitter::new(cfg(json!({ "chain": false }))).unwrap(); + let ev = e.build(&tool_payload(), &ext, "2026-07-20T12:00:00.000Z"); + + // readOnlyHint: true -> known id 2 with the normalized caption. + assert_eq!(ev["activity_id"], 2); + assert_eq!(ev["activity_name"], "Read"); + assert_eq!(ev["type_uid"], 600302); + } + + #[test] + fn read_only_hint_for_different_tool_is_ignored() { + use cpex_core::extensions::{MCPExtension, ToolMetadata}; + + let mut ext = subject_ext(); + ext.mcp = Some(Arc::new(MCPExtension { + tool: Some(ToolMetadata { + name: "some_other_tool".into(), + annotations: HashMap::from([("readOnlyHint".to_string(), json!(true))]), + ..Default::default() + }), + ..Default::default() + })); + + let e = OcsfAuditEmitter::new(cfg(json!({ "chain": false }))).unwrap(); + let ev = e.build(&tool_payload(), &ext, "2026-07-20T12:00:00.000Z"); + + // The hint describes a different tool than the one invoked. + assert_eq!(ev["activity_id"], 99); + assert_eq!(ev["activity_name"], "Invoke Tool"); + } + + #[test] + fn profiles_reflect_chain_config() { + let chained = OcsfAuditEmitter::new(cfg(json!({ "chain": true }))) + .unwrap() + .build(&tool_payload(), &subject_ext(), "2026-07-20T12:00:00.000Z"); + assert_eq!( + chained["metadata"]["profiles"], + json!(["ai_operation", "security_control", "record_integrity"]) + ); + + let unchained = OcsfAuditEmitter::new(cfg(json!({ "chain": false }))) + .unwrap() + .build(&tool_payload(), &subject_ext(), "2026-07-20T12:00:00.000Z"); + assert_eq!( + unchained["metadata"]["profiles"], + json!(["ai_operation", "security_control"]) + ); + } + + /// Review §4-B (fixed 2026-07-20): prev_entry_hash is folded into + /// the hashed input. Two byte-identical events at different chain + /// positions must produce different entry_hashes — under the old + /// back-pointer design they collided, so reordering or splicing + /// records between positions (or chains) was undetectable from the + /// hashes alone. + #[test] + fn entry_hash_binds_predecessor_into_hashed_input() { + let e = OcsfAuditEmitter::new(cfg(json!({ "chain": true }))).unwrap(); + let t = "2026-07-20T12:00:00.000Z"; + let ev1 = e.build(&tool_payload(), &subject_ext(), t); + let ev2 = e.build(&tool_payload(), &subject_ext(), t); + + // Identical event content (attestation aside)... + let strip = |v: &Value| { + let mut v = v.clone(); + v.as_object_mut().unwrap().remove("attestation"); + v + }; + assert_eq!(strip(&ev1), strip(&ev2)); + + // ...but a different chain position -> a different entry_hash, + // while linkage still holds. + assert_ne!( + ev1["attestation"]["entry_hash"], + ev2["attestation"]["entry_hash"] + ); + assert_eq!( + ev2["attestation"]["prev_entry_hash"], + ev1["attestation"]["entry_hash"] + ); + } + + #[tokio::test] + async fn handler_is_observation_only() { + let e = OcsfAuditEmitter::new(cfg(json!({}))).unwrap(); + let mut ctx = PluginContext::default(); + let r = e.handle(&tool_payload(), &subject_ext(), &mut ctx).await; + assert!(r.continue_processing); + assert!(r.violation.is_none()); + } + + // --- gap-branch coverage -------------------------------------------- + // The happy-path test above only exercises a tool call + subject. These + // build a fully-populated Extensions set and assert every gap field + // lands where CMF-OCSF-FIELD-MAP.md says it should. + + use cpex_core::extensions::{ + AgentExtension, CompletionExtension, DelegationExtension, DelegationHop, + FrameworkExtension, MCPExtension, StopReason, TokenUsage, ToolMetadata, WorkloadIdentity, + }; + + /// Extensions with every audit-relevant branch populated. + fn full_ext() -> Extensions { + let mut sec = SecurityExtension::default(); + let mut subj = SubjectExtension::default(); + subj.id = Some("alice@corp.com".into()); + subj.roles.insert("hr".into()); + subj.teams.insert("people-ops".into()); + sec.subject = Some(subj); + // monotonic taint labels (gap 4) + sec.labels.insert("PII".into()); + sec.labels.insert("secret".into()); + // workload attestation (gap 5) + sec.caller_workload = Some(WorkloadIdentity { + spiffe_id: Some("spiffe://corp/agent/hr-bot".into()), + trust_domain: Some("corp".into()), + attestor: Some("gke-workload-identity".into()), + ..Default::default() + }); + + let agent = AgentExtension { + agent_id: Some("agent-7".into()), + parent_agent_id: Some("orchestrator-1".into()), + session_id: Some("sess-42".into()), + conversation_id: Some("conv-9".into()), + turn: Some(3), + ..Default::default() + }; + + let completion = CompletionExtension { + stop_reason: Some(StopReason::MaxTokens), // gap 3 + tokens: Some(TokenUsage { + input_tokens: 120, + output_tokens: 30, + total_tokens: 150, + }), + model: Some("claude-opus-4-8".into()), + latency_ms: Some(842), + ..Default::default() + }; + + let delegation = DelegationExtension { + delegated: true, + depth: 1, + origin_subject_id: Some("alice@corp.com".into()), + actor_subject_id: Some("agent-7".into()), + chain: vec![DelegationHop { + subject_id: "agent-7".into(), + audience: Some("workday-api".into()), + scopes_granted: vec!["read_compensation".into()], + ttl_seconds: Some(300), + ..Default::default() + }], + ..Default::default() + }; + + let mcp = MCPExtension { + tool: Some(ToolMetadata { + name: "get_compensation".into(), + server_id: Some("hr-mcp".into()), + namespace: Some("hr".into()), + ..Default::default() + }), + ..Default::default() + }; + + let framework = FrameworkExtension { + framework: Some("langgraph".into()), + node_id: Some("node-compensation".into()), + graph_id: Some("graph-hr".into()), + ..Default::default() + }; + + Extensions { + security: Some(Arc::new(sec)), + agent: Some(Arc::new(agent)), + completion: Some(Arc::new(completion)), + delegation: Some(Arc::new(delegation)), + mcp: Some(Arc::new(mcp)), + framework: Some(Arc::new(framework)), + ..Default::default() + } + } + + #[test] + fn gap_fields_land_in_unmapped() { + let e = OcsfAuditEmitter::new(cfg(json!({ "chain": false }))).unwrap(); + let ev = e.build(&tool_payload(), &full_ext(), "2026-06-30T12:00:00.000Z"); + + let un = &ev["unmapped"]; + assert_eq!(un["cmf.completion.stop_reason"], "MaxTokens"); + assert_eq!(un["cmf.framework"]["framework"], "langgraph"); + assert_eq!(un["cmf.framework"]["graph_id"], "graph-hr"); + assert_eq!(un["cmf.mcp"]["tool"]["server_id"], "hr-mcp"); + assert_eq!( + un["cmf.workload_identity"]["spiffe_id"], + "spiffe://corp/agent/hr-bot" + ); + assert_eq!( + un["cmf.workload_identity"]["attestor"], + "gke-workload-identity" + ); + // monotonic labels — order-independent membership check + let labels = un["cmf.security.labels"].as_array().expect("labels array"); + assert!(labels.iter().any(|v| v == "PII")); + assert!(labels.iter().any(|v| v == "secret")); + } + + #[test] + fn mapped_objects_populate_from_extensions() { + let e = OcsfAuditEmitter::new(cfg(json!({ "chain": false }))).unwrap(); + let ev = e.build(&tool_payload(), &full_ext(), "2026-06-30T12:00:00.000Z"); + + // ai_agent + lineage (PR #1641) + assert_eq!(ev["ai_agent"]["uid"], "agent-7"); + assert_eq!(ev["ai_agent"]["parent_uid"], "orchestrator-1"); + // Review C1: correlation_uid mirrors the run id + // (AgentExtension.conversation_id) so every event of one run + // carries the same value — a per-event id correlates nothing. + assert_eq!(ev["correlation_uid"], "conv-9"); + assert_eq!(ev["api"]["request"]["uid"], "call-1"); + // message_context tokens (merged) + assert_eq!(ev["message_context"]["total_tokens"], 150); + assert_eq!(ev["ai_model"]["name"], "claude-opus-4-8"); + assert_eq!(ev["duration"], 842); + // delegation object (upcoming/Ania) + assert_eq!(ev["delegation"]["depth"], 1); + assert_eq!(ev["delegation"]["chain"][0]["audience"], "workday-api"); + assert_eq!( + ev["delegation"]["chain"][0]["scopes_granted"][0], + "read_compensation" + ); + } + + /// Review C2: HashSet/MonotonicSet iteration order is randomized per + /// instance, so the builder must sort set-derived arrays — otherwise + /// the same logical event canonicalizes to different bytes across + /// process runs and an independent verifier can't recompute + /// entry_hash. + #[test] + fn set_derived_arrays_are_sorted_for_canonical_hashing() { + let mut sec = SecurityExtension::default(); + let mut subj = SubjectExtension::default(); + subj.id = Some("alice@corp.com".into()); + for r in ["zeta", "alpha", "mid"] { + subj.roles.insert(r.into()); + } + for t in ["t2", "t1", "t3"] { + subj.teams.insert(t.into()); + } + sec.subject = Some(subj); + for l in ["secret", "PII", "internal", "export-controlled"] { + sec.labels.insert(l.into()); + } + let ext = Extensions { + security: Some(Arc::new(sec)), + ..Default::default() + }; + + let e = OcsfAuditEmitter::new(cfg(json!({ "chain": true }))).unwrap(); + let ev = e.build(&tool_payload(), &ext, "2026-07-06T12:00:00.000Z"); + + assert_eq!( + ev["unmapped"]["cmf.security.labels"], + json!(["PII", "export-controlled", "internal", "secret"]) + ); + assert_eq!(ev["actor"]["roles"], json!(["alpha", "mid", "zeta"])); + assert_eq!(ev["actor"]["user"]["groups"], json!(["t1", "t2", "t3"])); + } + + /// Structural OCSF conformance — NOT full schema validation (that needs + /// the published schema + a validator; see README). Asserts the base + /// event has the required, correctly-typed fields every OCSF consumer + /// relies on to route a record. + #[test] + fn emits_required_ocsf_base_fields() { + let e = OcsfAuditEmitter::new(cfg(json!({ "chain": false }))).unwrap(); + let ev = e.build(&tool_payload(), &full_ext(), "2026-06-30T12:00:00.000Z"); + + for key in [ + "activity_id", + "category_uid", + "class_uid", + "type_uid", + "severity_id", + ] { + assert!(ev[key].is_u64(), "{key} must be an integer"); + } + assert!(ev["time"].is_string(), "time must be present"); + assert!(ev["metadata"]["version"].is_string()); + assert!(ev["metadata"]["product"]["name"].is_string()); + // type_uid convention: class_uid * 100 + activity_id + assert_eq!( + ev["type_uid"].as_u64().unwrap(), + ev["class_uid"].as_u64().unwrap() * 100 + ev["activity_id"].as_u64().unwrap() + ); + } +} diff --git a/builtins/plugins/ocsf-audit/src/factory.rs b/builtins/plugins/ocsf-audit/src/factory.rs new file mode 100644 index 00000000..25e1b805 --- /dev/null +++ b/builtins/plugins/ocsf-audit/src/factory.rs @@ -0,0 +1,62 @@ +// Location: ./builtins/plugins/ocsf-audit/src/factory.rs +// Copyright 2026 AI Identity +// SPDX-License-Identifier: Apache-2.0 +// +// Factory — registers the emitter under every CMF hook the operator +// lists in `hooks:`. Structurally identical to the upstream +// audit-logger factory (TypedHandlerAdapter per hook name). + +use std::sync::Arc; + +use cpex_core::{ + cmf::CmfHook, + error::PluginError, + factory::{PluginFactory, PluginInstance}, + hooks::TypedHandlerAdapter, + plugin::PluginConfig, +}; + +use crate::emitter::OcsfAuditEmitter; + +/// `kind:` string operators write in CPEX YAML to declare an OCSF +/// audit emitter instance. +pub const KIND: &str = "audit/ocsf"; + +pub struct OcsfAuditFactory; + +impl PluginFactory for OcsfAuditFactory { + fn create(&self, config: &PluginConfig) -> Result> { + let emitter = Arc::new(OcsfAuditEmitter::new(config.clone())?); + + if config.hooks.is_empty() { + return Err(Box::new(PluginError::Config { + message: format!( + "plugin '{}' (cpex-plugin-ocsf-audit): `hooks:` must list at least \ + one CMF hook to emit on. For audit, prefer the POST hooks: \ + cmf.tool_post_invoke, cmf.llm_output, cmf.resource_post_fetch, \ + cmf.prompt_post_invoke. (NOT cmf.prompt_post_fetch — that name \ + exists in hooks/types.rs but the Rust CMF/APL runtime dispatches \ + the cmf/constants.rs name, cmf.prompt_post_invoke; a handler on \ + the _fetch name silently never fires.)", + config.name + ), + })); + } + + let handlers: Vec<_> = config + .hooks + .iter() + .map(|h| -> (&'static str, _) { + let leaked: &'static str = Box::leak(h.clone().into_boxed_str()); + let adapter: Arc = + Arc::new(TypedHandlerAdapter::::new(Arc::clone(&emitter))); + (leaked, adapter) + }) + .collect(); + + Ok(PluginInstance { + plugin: emitter, + handlers, + }) + } +} diff --git a/builtins/plugins/ocsf-audit/src/lib.rs b/builtins/plugins/ocsf-audit/src/lib.rs new file mode 100644 index 00000000..dcfb2c25 --- /dev/null +++ b/builtins/plugins/ocsf-audit/src/lib.rs @@ -0,0 +1,52 @@ +// Location: ./builtins/plugins/ocsf-audit/src/lib.rs +// Copyright 2026 AI Identity +// SPDX-License-Identifier: Apache-2.0 +// +// cpex-plugin-ocsf-audit — CMF plugin that emits one OCSF AI Operation +// event per dispatched request, off the CPEX `run(audit-log)` seam. +// +// It is a near-twin of the upstream `audit-logger` builtin (same +// observation-only, always-allow contract, same factory + hook wiring). +// The difference is the record shape: instead of a free-form JSON line, +// it serializes the CMF `Message` + `Extensions` into an OCSF event, +// following the CMF→OCSF field map (shared review doc), then +// (optionally) wraps it in a tamper-evident attestation chain +// (entry_hash → prev_entry_hash) and signs it. +// +// Why this exists: it makes CPEX's enforcement record interoperable +// (OCSF) and independently verifiable (signed attestation chain), +// without CPEX having to own a schema. CPEX produces the event; this +// plugin makes it portable and verifiable offline. +// +// CMF = ContextForge Message Format (per cpex-core/src/cmf/mod.rs). +// +// Status: builds green against cpex@feat/hil_apl `ad666ba` (cargo build +// + cargo test; Teryl's review baseline, 2026-07-06). The Extension +// field reads and ContentPart variant shapes are confirmed against that +// commit. Review corrections applied 2026-07-06 (see +// the CMF↔OCSF mapping review): prompt hooks +// register on cmf.prompt_*_invoke (C6 — the _fetch names silently never +// fire), correlation_uid mirrors the run id (C1), and events are +// JCS-style canonically serialized so the entry_hash chain verifies +// independently (C2 caveat). +// +// Revision 2026-07-20 (P0 + review §4-B, per the production-readiness +// plan agreed 2026-07-17/18): host class is now API Activity (6003) with +// its real activity enum (CRUD via readOnlyHint, else 99 + source name); +// metadata.profiles declares ai_operation + security_control (+ +// record_integrity when chained) and the passive stream carries +// action_id 3 (Observed) / disposition_id 17 (Logged); and entry_hash +// now commits to (chain_uid, event, prev_entry_hash) — predecessor +// binding, not a back-pointer. Remaining by design: the DSSE signer is +// a stub (the hash chain works unsigned), and deny/modify records +// (action_id 2/4) wait on the cpex-core decision event (WS-A / P1). + +pub mod config; +pub mod emitter; +pub mod factory; +pub mod ocsf; +pub mod sign; + +pub use config::{OcsfAuditConfig, OcsfDestination, SigningMode}; +pub use emitter::OcsfAuditEmitter; +pub use factory::{OcsfAuditFactory, KIND}; diff --git a/builtins/plugins/ocsf-audit/src/ocsf.rs b/builtins/plugins/ocsf-audit/src/ocsf.rs new file mode 100644 index 00000000..03eda43c --- /dev/null +++ b/builtins/plugins/ocsf-audit/src/ocsf.rs @@ -0,0 +1,451 @@ +// Location: ./builtins/plugins/ocsf-audit/src/ocsf.rs +// Copyright 2026 AI Identity +// SPDX-License-Identifier: Apache-2.0 +// +// CMF -> OCSF mapping. This is the running-code form of +// the CMF→OCSF field map (shared review doc). Each block below +// cites the field-map row it implements. +// +// Design choices: +// * We build a serde_json::Value rather than hand-rolling fully +// typed OCSF structs — OCSF object shapes still move release to +// release, and a Value keeps the scaffold honest about what's +// proposed vs merged. +// * Fields with no native OCSF home yet (the five gaps) go under +// `unmapped` when cfg.include_gap_fields is set. That is correct +// OCSF practice AND it makes the gaps self-documenting in the +// emitted evidence. +// +// OCSF MODELING NOTE: `ai_operation` is a PROFILE, not a class. PR #1641 +// (merged 2026-06-29, "Add ai_agent object and extend ai_operation profile +// coverage") makes it contribute `ai_agent` / `ai_model` / `message_context` +// to existing base classes — all in the Application category (6). The host +// class is **API Activity (6003)** — agreed with the CPEX team 2026-07-17/18 +// (matches AOS's host-class choice and AI Identity's production gateway). +// Activity ids follow API Activity's real enum (CRUD + 99 Other), NOT a +// bespoke enum: per the OCSF enum contract, a known id carries the +// normalized caption as activity_name; source-defined names ride with 99. + +use serde_json::{json, Map, Value}; + +use cpex_core::cmf::{ContentPart, MessagePayload}; +use cpex_core::hooks::payload::Extensions; + +use crate::config::OcsfAuditConfig; + +// --- OCSF identifiers --- +const SCHEMA_VERSION: &str = "1.9.0-dev"; +const CATEGORY_UID_APPLICATION: u32 = 6; +/// API Activity — the concrete Application-category class hosting the +/// ai_operation profile (P0 decision, 2026-07-18 thread). +const CLASS_UID_API_ACTIVITY: u32 = 6003; +const SEVERITY_INFORMATIONAL: u32 = 1; + +/// OCSF activity on API Activity (6003): 0 Unknown · 1 Create · 2 Read · +/// 3 Update · 4 Delete · 99 Other. +/// +/// Mapping convention (2026-07-18 thread): +/// * Read Resource / Invoke Prompt -> 2 (Read) +/// * Invoke Tool with readOnlyHint: true -> 2 (Read) +/// * Invoke Tool otherwise -> 99 + activity_name "Invoke Tool" +/// (we can't honestly claim Create/Update/Delete without knowing the +/// operation; destructiveHint stays context, not a Delete mapping) +/// * Completion -> 99 + activity_name "Completion" +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Activity { + Unknown, + Read, + /// 99 (Other) with a source-defined activity_name, per the OCSF + /// enum contract. + Other(&'static str), +} + +impl Activity { + fn id(self) -> u32 { + match self { + Activity::Unknown => 0, + Activity::Read => 2, + Activity::Other(_) => 99, + } + } + fn name(self) -> &'static str { + match self { + Activity::Unknown => "Unknown", + // Known id -> normalized enum caption, never a source-defined + // string (the AOS pin-1-vary-name practice violates this). + Activity::Read => "Read", + Activity::Other(n) => n, + } + } +} + +/// True when the MCP tool metadata for this invocation carries +/// `readOnlyHint: true`. When the content part names the tool, the hint +/// only applies if the MCP slot describes that same tool. +fn tool_read_only_hint(ext: &Extensions, call_name: Option<&str>) -> bool { + let Some(mcp) = ext.mcp.as_ref() else { + return false; + }; + let Some(tool) = mcp.tool.as_ref() else { + return false; + }; + if let Some(name) = call_name { + if tool.name != name { + return false; + } + } + matches!( + tool.annotations.get("readOnlyHint"), + Some(Value::Bool(true)) + ) +} + +/// Infer the OCSF activity from the message content parts (the typed +/// handler does not receive the hook name, so we classify from content — +/// which is more robust anyway) plus the MCP tool annotations. +pub fn activity_of(payload: &MessagePayload, ext: &Extensions) -> Activity { + for part in &payload.message.content { + match part { + // ContentPart variant shapes confirmed against cpex@feat/hil_apl ad666ba (2026-07-06). + ContentPart::ToolCall { content } => { + return if tool_read_only_hint(ext, Some(&content.name)) { + Activity::Read + } else { + Activity::Other("Invoke Tool") + } + }, + ContentPart::ToolResult { .. } => { + // Result side of the same invocation; the result part + // carries no tool name, so the MCP slot speaks for it. + return if tool_read_only_hint(ext, None) { + Activity::Read + } else { + Activity::Other("Invoke Tool") + }; + }, + ContentPart::PromptRequest { .. } + | ContentPart::PromptResult { .. } + | ContentPart::Resource { .. } + | ContentPart::ResourceRef { .. } => return Activity::Read, + _ => {}, + } + } + // Plain assistant text / thinking with completion metadata = LLM output. + if payload + .message + .content + .iter() + .any(|p| matches!(p, ContentPart::Text { .. } | ContentPart::Thinking { .. })) + { + return Activity::Other("Completion"); + } + Activity::Unknown +} + +/// Build the OCSF AI Operation event (the inner event, pre-attestation). +/// `now_rfc3339` is injected so the caller controls the clock (testable). +pub fn build_ai_operation( + payload: &MessagePayload, + ext: &Extensions, + cfg: &OcsfAuditConfig, + now_rfc3339: &str, +) -> Value { + let activity = activity_of(payload, ext); + + let mut ev = Map::new(); + + // --- base event --------------------------------------------------- + ev.insert("activity_id".into(), json!(activity.id())); + ev.insert("activity_name".into(), json!(activity.name())); + ev.insert("category_uid".into(), json!(CATEGORY_UID_APPLICATION)); + ev.insert("class_uid".into(), json!(CLASS_UID_API_ACTIVITY)); + ev.insert( + "type_uid".into(), + json!(CLASS_UID_API_ACTIVITY * 100 + activity.id()), + ); + ev.insert("severity_id".into(), json!(SEVERITY_INFORMATIONAL)); + ev.insert("time".into(), json!(now_rfc3339)); + + // security_control profile: this passive post-hook stream is + // action_id 3 (Observed) / disposition_id 17 (Logged). The deny and + // modify mappings (action_id 2 / 4) arrive with the cpex-core + // decision event (WS-A / P1) — the plugin structurally cannot see a + // denial from a post hook. + ev.insert("action_id".into(), json!(3)); + ev.insert("action".into(), json!("Observed")); + ev.insert("disposition_id".into(), json!(17)); + ev.insert("disposition".into(), json!("Logged")); + + // metadata + product (field map: `meta`/`request` -> base metadata) + let mut profiles = vec!["ai_operation", "security_control"]; + if cfg.chain { + // The attestation wrapper (entry_hash chain) is the + // record_integrity shape from PR #1661 / OCSF 1.9. + profiles.push("record_integrity"); + } + ev.insert( + "metadata".into(), + json!({ + "version": SCHEMA_VERSION, + "profiles": profiles, + "product": { "name": cfg.product_name, "vendor_name": cfg.vendor_name }, + }), + ); + + // correlation (field map: AgentExtension.conversation_id -> base + // correlation_uid). Review C1: the correlation key must be stable + // across every event of one run — conversation_id IS the run. + // Per-event ids (request_id, tool_call_id) correlate nothing; + // tool_call_id rides at api.request.uid instead (see + // attach_capability_coords). + if let Some(cid) = correlation_uid(ext) { + ev.insert("correlation_uid".into(), json!(cid)); + } + + // status (field map: ToolResult.is_error -> status) + if let Some(is_err) = first_tool_error(payload) { + ev.insert("status_id".into(), json!(if is_err { 2 } else { 1 })); // 1=Success 2=Failure + } + + // --- actor / user (field map: SecurityExtension.SubjectExtension) - + if let Some(sec) = ext.security.as_ref() { + if let Some(s) = &sec.subject { + // roles/teams are HashSets — sort so the emitted event is + // canonical and entry_hash is reproducible (review C2). + let mut groups: Vec<&String> = s.teams.iter().collect(); + groups.sort_unstable(); + let mut roles: Vec<&String> = s.roles.iter().collect(); + roles.sort_unstable(); + ev.insert( + "actor".into(), + json!({ + "user": { + "uid": s.id, + "groups": groups, + }, + // roles/permissions ride along as enrichment. + "roles": roles, + }), + ); + } + } + + // --- ai_agent (field map: AgentExtension; PR #1641) --------------- + if let Some(ag) = ext.agent.as_ref() { + ev.insert( + "ai_agent".into(), + json!({ + "uid": ag.agent_id, + "instance_uid": ag.session_id, + // multi-agent lineage + "parent_uid": ag.parent_agent_id, + "conversation_uid": ag.conversation_id, + "turn": ag.turn, + }), + ); + } + + // --- ai_model + message_context (field map: LLMExtension / + // CompletionExtension; mostly merged) ------------------------- + if let Some(comp) = ext.completion.as_ref() { + let mut mctx = Map::new(); + if let Some(tok) = &comp.tokens { + mctx.insert("prompt_tokens".into(), json!(tok.input_tokens)); + mctx.insert("completion_tokens".into(), json!(tok.output_tokens)); + mctx.insert("total_tokens".into(), json!(tok.total_tokens)); + } + if !mctx.is_empty() { + ev.insert("message_context".into(), Value::Object(mctx)); + } + if let Some(model) = &comp.model { + ev.insert("ai_model".into(), json!({ "name": model })); + } + if let Some(ms) = comp.latency_ms { + ev.insert("duration".into(), json!(ms)); // base `duration` (ms) + } + } + + // --- delegation (field map: DelegationExtension; upcoming/Ania) --- + if let Some(del) = ext.delegation.as_ref() { + if del.delegated || !del.chain.is_empty() { + let chain: Vec = del + .chain + .iter() + .map(|hop| { + json!({ + "subject_uid": hop.subject_id, + "audience": hop.audience, + "scopes_granted": hop.scopes_granted, + "ttl_seconds": hop.ttl_seconds, + "timestamp": hop.timestamp.to_rfc3339(), + }) + }) + .collect(); + ev.insert( + "delegation".into(), + json!({ + "depth": del.depth, + "origin_subject_uid": del.origin_subject_id, + "actor_subject_uid": del.actor_subject_id, + "chain": chain, + }), + ); + } + } + + // --- tool/prompt/resource coordinates from content ---------------- + attach_capability_coords(&mut ev, payload); + + // --- the five gaps -> unmapped (field map §5) --------------------- + if cfg.include_gap_fields { + let unmapped = build_unmapped_gaps(payload, ext); + if let Value::Object(m) = &unmapped { + if !m.is_empty() { + ev.insert("unmapped".into(), unmapped); + } + } + } + + Value::Object(ev) +} + +/// Gap fields with no native OCSF home yet. Emitting them under +/// `unmapped` keeps the evidence complete and documents the gaps. +fn build_unmapped_gaps(payload: &MessagePayload, ext: &Extensions) -> Value { + let mut g = Map::new(); + + // gap 3: completion.stop_reason + if let Some(comp) = ext.completion.as_ref() { + if let Some(sr) = &comp.stop_reason { + g.insert( + "cmf.completion.stop_reason".into(), + json!(format!("{sr:?}")), + ); + } + } + + // gap 1: mcp tool/resource/prompt metadata + if let Some(mcp) = ext.mcp.as_ref() { + // MCPExtension = { tool, resource, prompt } (confirmed cpex@feat/hil_apl ad666ba (2026-07-06)). + // Serialized whole; each sub-object carries server_id/namespace/schemas. + g.insert("cmf.mcp".into(), json!(mcp)); + } + + // gap 2: framework context + if let Some(fw) = ext.framework.as_ref() { + g.insert( + "cmf.framework".into(), + json!({ + "framework": fw.framework, + "framework_version": fw.framework_version, + "node_id": fw.node_id, + "graph_id": fw.graph_id, + }), + ); + } + + // gap 4: monotonic security labels (taint set) + if let Some(sec) = ext.security.as_ref() { + // SecurityExtension.labels: MonotonicSet (add-only taint), + // iterated via .iter() (confirmed cpex@feat/hil_apl ad666ba (2026-07-06)). + let labels = security_labels(sec); + if !labels.is_empty() { + g.insert("cmf.security.labels".into(), json!(labels)); + } + } + + // gap 5: workload attestation (SPIFFE) — partial OCSF home + if let Some(wl) = caller_workload(ext) { + g.insert("cmf.workload_identity".into(), wl); + } + + // multimodal content kinds present (lightweight provenance of shape) + let _ = payload; + + Value::Object(g) +} + +// --------------------------------------------------------------------- +// Helpers — small, content-shape-dependent extractors. CMF accessor and +// variant shapes confirmed against cpex@feat/hil_apl ad666ba (2026-07-06). +// --------------------------------------------------------------------- + +fn correlation_uid(ext: &Extensions) -> Option { + // Review C1: correlation_uid must be multi-event-stable, so it + // mirrors the run id (AgentExtension.conversation_id) — NOT + // request_id or tool_call_id, which are per-event unique and + // correlate nothing. Session-grain grouping stays a join on + // ai_agent.instance_uid (session_id); the run is the primary + // forensic grain a SIEM keys on. + ext.agent.as_ref()?.conversation_id.clone() +} + +fn first_tool_error(payload: &MessagePayload) -> Option { + for part in &payload.message.content { + if let ContentPart::ToolResult { content } = part { + return Some(content.is_error); + } + } + None +} + +fn attach_capability_coords(ev: &mut Map, payload: &MessagePayload) { + for part in &payload.message.content { + match part { + ContentPart::ToolCall { content } => { + ev.insert( + "tool".into(), + json!({ + "name": content.name, + "uid": content.tool_call_id, + "namespace": content.namespace, + }), + ); + // Review C1: the per-call id's home is api.request.uid + // (one request = one tool call), not correlation_uid. + ev.insert( + "api".into(), + json!({ "request": { "uid": content.tool_call_id } }), + ); + return; + }, + ContentPart::Resource { content } => { + ev.insert( + "resource".into(), + json!({ "uri": content.uri, "type": format!("{:?}", content.resource_type) }), + ); + return; + }, + _ => {}, + } + } +} + +// The following two isolate the less-obvious accessor paths to one place +// each (both confirmed against cpex@feat/hil_apl ad666ba (2026-07-06)). + +fn security_labels(sec: &cpex_core::extensions::SecurityExtension) -> Vec { + // MonotonicSet::iter() -> impl Iterator. + // The backing HashSet iterates in randomized, seed-dependent order; + // sort so the emitted array is canonical and the entry_hash an + // independent verifier recomputes matches ours (review C2). + let mut labels: Vec = sec.labels.iter().cloned().collect(); + labels.sort_unstable(); + labels +} + +fn caller_workload(ext: &Extensions) -> Option { + // Confirmed cpex@feat/hil_apl ad666ba (2026-07-06): the resolved inbound workload identity + // is reachable at Extensions.security.caller_workload (the executor + // applies IdentityPayload.caller_workload onto the security ext). + // `this_workload` (the gateway's OWN attested id) is the signer + // identity and is handled in sign.rs, not here. + let sec = ext.security.as_ref()?; + let wl = sec.caller_workload.as_ref()?; + Some(json!({ + "spiffe_id": wl.spiffe_id, + "trust_domain": wl.trust_domain, + "attestor": wl.attestor, // e.g. gke-workload-identity, spire-agent, mtls + "attested_at": wl.attested_at, // for stale-evidence rejection + })) +} diff --git a/builtins/plugins/ocsf-audit/src/sign.rs b/builtins/plugins/ocsf-audit/src/sign.rs new file mode 100644 index 00000000..35697af3 --- /dev/null +++ b/builtins/plugins/ocsf-audit/src/sign.rs @@ -0,0 +1,178 @@ +// Location: ./builtins/plugins/ocsf-audit/src/sign.rs +// Copyright 2026 AI Identity +// SPDX-License-Identifier: Apache-2.0 +// +// Signing seam for the attestation. The hash chain (entry_hash / +// prev_entry_hash) works with no signer at all; a signer adds the +// `signature` + `digital_signature` that make the record verifiable +// against an identity, offline. +// +// OCSF #1662 merged `digital_signature.serialization_id`, which is how +// a record declares its canonicalization (JCS / JWS / COSE / DSSE). +// We target DSSE. + +use serde_json::Value; +use sha2::{Digest, Sha256}; + +/// JCS-style (RFC 8785) canonical serialization of an event. +/// +/// This is what `entry_hash` and the signer consume, so an independent +/// verifier can recompute the hash from the emitted JSON without +/// depending on our serializer's internals (review C2). Guarantees: +/// * object keys sorted, compact output (no insignificant whitespace) +/// — explicitly, not via serde_json's default BTreeMap-backed Map +/// (a downstream workspace enabling serde_json's `preserve_order` +/// feature would silently switch that to insertion order); +/// * arrays serialized in the order given. Array order is semantic +/// in JSON (delegation chain, profiles), so the canonicalizer must +/// NOT sort them — instead, set-derived arrays (security labels, +/// roles, teams — HashSet/MonotonicSet, randomized iteration) are +/// sorted at build time in ocsf.rs, making the emitted event itself +/// canonical. +/// +/// Caveats vs full RFC 8785: keys are sorted by Rust byte order, which +/// equals the mandated UTF-16 code-unit order for the ASCII key names +/// we emit; all numbers we emit are integers, where serde_json's +/// formatting matches the mandated ES6 form. Revisit both if non-ASCII +/// keys or floats ever enter the event shape. +pub fn canonical_bytes(v: &Value) -> Vec { + let mut out = Vec::new(); + write_canonical(v, &mut out); + out +} + +fn write_canonical(v: &Value, out: &mut Vec) { + match v { + Value::Object(m) => { + out.push(b'{'); + let mut keys: Vec<&String> = m.keys().collect(); + keys.sort_unstable(); + for (i, k) in keys.iter().enumerate() { + if i > 0 { + out.push(b','); + } + // serde_json string form = RFC 8785 escaping (two-char + // escapes for control chars where defined, \u00XX else). + out.extend_from_slice( + serde_json::to_string(k.as_str()) + .expect("string") + .as_bytes(), + ); + out.push(b':'); + write_canonical(&m[k.as_str()], out); + } + out.push(b'}'); + }, + Value::Array(a) => { + out.push(b'['); + for (i, el) in a.iter().enumerate() { + if i > 0 { + out.push(b','); + } + write_canonical(el, out); + } + out.push(b']'); + }, + leaf => out.extend_from_slice(serde_json::to_string(leaf).expect("leaf").as_bytes()), + } +} + +/// Compute the entry hash over canonical bytes (callers obtain them via +/// `canonical_bytes`). Since the §4-B fix (2026-07-20) the emitter +/// passes the canonical bytes of the chain-binding object +/// `{chain_uid, event, prev_entry_hash}` — not the bare event — so the +/// hash commits to the record's position in its chain; the signer +/// consumes the same bytes. +pub fn entry_hash(canonical_bytes: &[u8]) -> String { + let mut h = Sha256::new(); + h.update(canonical_bytes); + format!("sha256:{:x}", h.finalize()) +} + +/// Result of signing: the detached signature and the OCSF +/// `digital_signature` descriptor to embed alongside it. +pub struct Signed { + pub signature: String, + pub digital_signature: serde_json::Value, +} + +pub trait OcsfSigner: Send + Sync { + fn sign(&self, canonical_bytes: &[u8]) -> Option; + /// Value for `attestation.digital_signature.serialization_id`. + fn serialization_id(&self) -> &'static str; +} + +/// No-op: hash-chained but unsigned. Default for demo / unprovisioned. +pub struct NoopSigner; + +impl OcsfSigner for NoopSigner { + fn sign(&self, _canonical_bytes: &[u8]) -> Option { + None + } + fn serialization_id(&self) -> &'static str { + "NONE" + } +} + +/// DSSE signer — STUB. Wire to AI Identity's existing DSSE/key +/// machinery (the same path the gateway uses to sign OCSF +/// attestations today). Until then it behaves like NoopSigner so the +/// plugin still runs. +pub struct DsseSigner { + // TODO: key handle / signer client. +} + +impl DsseSigner { + pub fn new() -> Self { + Self {} + } +} + +impl Default for DsseSigner { + fn default() -> Self { + Self::new() + } +} + +impl OcsfSigner for DsseSigner { + fn sign(&self, _canonical_bytes: &[u8]) -> Option { + // TODO: produce a DSSE envelope over canonical_bytes and return + // the signature + a digital_signature descriptor with + // serialization_id = "DSSE". Returning None keeps the chain + // intact-but-unsigned until the key is wired. + None + } + fn serialization_id(&self) -> &'static str { + "DSSE" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn canonical_form_is_sorted_and_compact() { + // Build with unsorted key insertion; canonical output must be + // key-sorted, compact, and nest-stable. + let v = json!({ + "z": [3, 1, 2], // array order preserved + "a": { "y": "b", "x": true }, // nested keys sorted + "m": null, + }); + assert_eq!( + String::from_utf8(canonical_bytes(&v)).unwrap(), + r#"{"a":{"x":true,"y":"b"},"m":null,"z":[3,1,2]}"# + ); + } + + #[test] + fn entry_hash_is_reproducible_from_canonical_bytes() { + let v = json!({ "b": 1, "a": ["PII", "secret"] }); + let h1 = entry_hash(&canonical_bytes(&v)); + let h2 = entry_hash(&canonical_bytes(&v.clone())); + assert_eq!(h1, h2); + assert!(h1.starts_with("sha256:")); + } +} diff --git a/crates/cpex-builtins/Cargo.toml b/crates/cpex-builtins/Cargo.toml index bd0723c9..09e17694 100644 --- a/crates/cpex-builtins/Cargo.toml +++ b/crates/cpex-builtins/Cargo.toml @@ -34,6 +34,7 @@ default = ["pii-scanner", "audit-logger", "identity-jwt", "delegator-oauth", "el # Each feature pulls exactly one builtin crate. pii-scanner = ["dep:cpex-plugin-pii-scanner"] audit-logger = ["dep:cpex-plugin-audit-logger"] +ocsf-audit = ["dep:cpex-plugin-ocsf-audit"] identity-jwt = ["dep:cpex-plugin-identity-jwt"] delegator-oauth = ["dep:cpex-plugin-delegator-oauth"] elicitation-ciba = ["dep:cpex-plugin-elicitation-ciba"] @@ -54,6 +55,7 @@ apl-cpex = { workspace = true } # Builtin extension crates — each behind its feature. cpex-plugin-pii-scanner = { workspace = true, optional = true } cpex-plugin-audit-logger = { workspace = true, optional = true } +cpex-plugin-ocsf-audit = { workspace = true, optional = true } cpex-plugin-identity-jwt = { workspace = true, optional = true } cpex-plugin-delegator-oauth = { workspace = true, optional = true } cpex-plugin-elicitation-ciba = { workspace = true, optional = true } diff --git a/crates/cpex-builtins/src/lib.rs b/crates/cpex-builtins/src/lib.rs index 46276552..783e2d4f 100644 --- a/crates/cpex-builtins/src/lib.rs +++ b/crates/cpex-builtins/src/lib.rs @@ -44,6 +44,8 @@ pub use cpex_pdp_cedar_direct::CedarDirectPdpFactory; pub use cpex_pdp_cel::CelPdpFactory; #[cfg(feature = "audit-logger")] pub use cpex_plugin_audit_logger::{AuditLoggerFactory, KIND as AUDIT_KIND}; +#[cfg(feature = "ocsf-audit")] +pub use cpex_plugin_ocsf_audit::{OcsfAuditFactory, KIND as OCSF_AUDIT_KIND}; #[cfg(feature = "delegator-oauth")] pub use cpex_plugin_delegator_oauth::{OAuthDelegatorFactory, KIND as OAUTH_KIND}; #[cfg(feature = "elicitation-ciba")] @@ -95,6 +97,7 @@ register_builtins! { feature "elicitation-ciba" => cpex_plugin_elicitation_ciba::CibaApproverFactory, feature "pii-scanner" => cpex_plugin_pii_scanner::PiiScannerFactory, feature "audit-logger" => cpex_plugin_audit_logger::AuditLoggerFactory, + feature "ocsf-audit" => cpex_plugin_ocsf_audit::OcsfAuditFactory, } // ----------------------------------------------------------------------------- From 9fe56e591dd6a8cfbc885d80a8ff35eb5dc66abb Mon Sep 17 00:00:00 2001 From: Jeff Leva Date: Mon, 20 Jul 2026 16:56:41 -0600 Subject: [PATCH 2/2] =?UTF-8?q?style(builtins):=20rustfmt=20=E2=80=94=20ke?= =?UTF-8?q?ep=20plugin=20re-exports=20alphabetized?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/cpex-builtins/src/lib.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/cpex-builtins/src/lib.rs b/crates/cpex-builtins/src/lib.rs index 783e2d4f..3763fb53 100644 --- a/crates/cpex-builtins/src/lib.rs +++ b/crates/cpex-builtins/src/lib.rs @@ -44,14 +44,14 @@ pub use cpex_pdp_cedar_direct::CedarDirectPdpFactory; pub use cpex_pdp_cel::CelPdpFactory; #[cfg(feature = "audit-logger")] pub use cpex_plugin_audit_logger::{AuditLoggerFactory, KIND as AUDIT_KIND}; -#[cfg(feature = "ocsf-audit")] -pub use cpex_plugin_ocsf_audit::{OcsfAuditFactory, KIND as OCSF_AUDIT_KIND}; #[cfg(feature = "delegator-oauth")] pub use cpex_plugin_delegator_oauth::{OAuthDelegatorFactory, KIND as OAUTH_KIND}; #[cfg(feature = "elicitation-ciba")] pub use cpex_plugin_elicitation_ciba::{CibaApproverFactory, KIND as CIBA_KIND}; #[cfg(feature = "identity-jwt")] pub use cpex_plugin_identity_jwt::{JwtIdentityFactory, KIND as JWT_KIND}; +#[cfg(feature = "ocsf-audit")] +pub use cpex_plugin_ocsf_audit::{OcsfAuditFactory, KIND as OCSF_AUDIT_KIND}; #[cfg(feature = "pii-scanner")] pub use cpex_plugin_pii_scanner::{PiiScannerFactory, KIND as PII_KIND}; #[cfg(feature = "valkey")]