Skip to content
Open
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
15 changes: 15 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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" }
Expand Down
42 changes: 42 additions & 0 deletions builtins/plugins/ocsf-audit/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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
102 changes: 102 additions & 0 deletions builtins/plugins/ocsf-audit/README.md
Original file line number Diff line number Diff line change
@@ -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": <configured chain id>,
"event": <the emitted event, minus its `attestation` member>,
"prev_entry_hash": <previous entry_hash, or null for the first record>
} ) )
```

`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.
194 changes: 194 additions & 0 deletions builtins/plugins/ocsf-audit/SAMPLE-OUTPUT.md
Original file line number Diff line number Diff line change
@@ -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
```
Loading
Loading