diff --git a/.gitignore b/.gitignore index 5bc574b..0eee271 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,6 @@ llm_cache/ # Local Claude Code agent state (worktrees, transcripts) /.claude/ /.claire/ + +# local evidence/run artifacts +.boruna/ diff --git a/AGENTS.md b/AGENTS.md index ba37b24..4eb497a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,6 +47,7 @@ The server communicates over JSON-RPC stdio. All tools return structured JSON wi | `boruna_capability_list` | List the frozen 1.0 capability set with `capability_set_hash` | | `boruna_policy_validate` | Validate a `Policy` JSON document; returns typed `error_kind` on rejection | | `boruna_symbols` | Extract top-level symbols (fns/records/enums) from `.ax` source → exact typed signatures, capabilities, requires/ensures arity | +| `boruna_run_sealed` | Compile + run `.ax`, replay-verify the execution, return a verifiable record (result, `replay_verified`, capability calls, event log, SHA-256 seal digest) | ## Agent-native CLI surfaces diff --git a/CHANGELOG.md b/CHANGELOG.md index c2fd561..c722d18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,54 @@ Versioning follows [Semantic Versioning](https://semver.org/). ## [Unreleased] +## [3.1.0] — 2026-07-18 + +Additive feature release — no breaking changes. Deepens Boruna's two moats: +**verifiable/auditable evidence** (standards interop, compliance reporting, +observability export, sealed contract/guard verdicts) and **agent authoring** +(exact-signature lookup, a run-and-seal execution cell, an agent corpus). Ideas +were mined from adjacent tooling (Temporal/LangGraph/Langfuse/Credo AI/SLSA/ +in-toto/Sigstore) and from the `agentlanguages.dev` peer catalogue, then mapped +onto Boruna's determinism + evidence model. + +### Added + +- **In-toto + DSSE attestation** — `boruna evidence attest ` emits the + bundle as an in-toto Statement (`predicateType https://boruna.dev/runtime-provenance/v1`) + wrapped in a DSSE envelope signed with the bundle's ed25519 key; `--verify` + checks it. Makes runtime-execution provenance consumable by the supply-chain + ecosystem (`cosign`, `in-toto-verify`). Additive — the native bundle is unchanged. + Predicate schema: `docs/spec/runtime-provenance-predicate-1.0.md`. +- **Compliance-mapping report** — `boruna evidence report --framework eu-ai-act|nist|iso42001` + verifies a bundle, then maps its contents to the specific obligation each helps + satisfy (EU AI Act Art. 12/19/26, NIST AI RMF, ISO/IEC 42001), honestly flagging + gaps. A technical mapping, not a certificate of compliance. +- **OpenTelemetry export** — `boruna evidence otel ` emits the run as OTLP/JSON + spans (no SDK dep, no network) with tamper-evidence attributes + (`boruna.bundle_hash`, `audit_log_hash`, `signature.keyid`) and `gen_ai.*` spans + for `llm.*` calls, so a run surfaces in any OTel backend while linking back to a + verifiable record. +- **Sealed contract + guard verdicts** — `requires`/`ensures` contract checks now + record a `ContractCheck` event (pass and fail) into the hash-chained evidence log. + New `__builtin_guard(value, passed, label)` runs a deterministic output check, + traps fail-closed on violation, and seals the verdict — so "the guardrail ran on + this model output and returned this verdict" becomes a replayable, tamper-evident fact. +- **`std-guard` standard library** (14th lib) — pure, deterministic output validators + (length/range/allow-list/ban-list/refusal-heuristic/json-shape). +- **MCP tools** (now 14) — `boruna_symbols` (exact typed signatures for `.ax` source) + and `boruna_run_sealed` (compile + run + replay-verify → a verifiable execution record). +- **Quickfix-coverage CI gate** — every auto-fixable diagnostic must ship a repair + strategy or be explicitly allow-listed. +- **Agent corpus & docs** — `llms.txt`, an `.ax` teaching primer, a static agent + portal manifest, an evidence threat model, and a runtime-execution-provenance + positioning doc. + +### Fixed + +- **`docs/reference/ax-language.md` syntax drift** — corrected to the real grammar + (records use `type`, enum variants are unit or single-payload, match arms use bare + variant names), verified with `boruna lang check`. + ## [3.0.0] — 2026-07-18 Removes the entire HTTP / serving / distributed-execution layer. Boruna is now a diff --git a/CLAUDE.md b/CLAUDE.md index 503d379..4785fa9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -107,7 +107,7 @@ Note: directory paths still use original names (crates/llmbc, crates/llmc, etc.) - **boruna-framework** (dir: crates/llmfw) — Framework layer enforcing the App protocol (Elm architecture: init/update/view). `AppValidator`, `AppRuntime`, `TestHarness`, `PolicySet`, state machine diffing. - **boruna-effect** (dir: crates/llm-effect) — Token-optimized LLM integration: prompt building, context management, caching, normalization, capability gating for LLM calls. - **boruna-cli** (dir: crates/llmvm-cli) — CLI binary (`boruna`). Subcommands: compile, run, trace, replay, inspect, ast, framework, lang, trace2tests, template, workflow, evidence. -- **boruna-mcp** (dir: crates/boruna-mcp) — MCP server binary (`boruna-mcp`). Exposes 13 tools over JSON-RPC stdio for AI coding agents. Built on rmcp v0.16. +- **boruna-mcp** (dir: crates/boruna-mcp) — MCP server binary (`boruna-mcp`). Exposes 14 tools over JSON-RPC stdio for AI coding agents. Built on rmcp v0.16. ### Supporting Crates @@ -122,8 +122,8 @@ Note: directory paths still use original names (crates/llmbc, crates/llmc, etc.) ### Standard Libraries (libs/) -13 deterministic libraries, each with `package.ax.json` and `src/core.ax`: -std-ui, std-forms, std-authz, std-http, std-db, std-sync, std-validation, std-routing, std-storage, std-notifications, std-testing (all 1.0-stable as of v1.2.0), plus std-llm and std-json (1.0-stable as of v1.3.0). All 13 are 1.0-stable. +14 deterministic libraries, each with `package.ax.json` and `src/core.ax`: +std-ui, std-forms, std-authz, std-http, std-db, std-sync, std-validation, std-routing, std-storage, std-notifications, std-testing (all 1.0-stable as of v1.2.0), plus std-llm and std-json (1.0-stable as of v1.3.0), plus std-guard (deterministic output validators). The original 13 are 1.0-stable. All are pure-functional (no hidden side effects). Libraries needing capabilities declare them in their manifest (e.g., std-http requires `net.fetch`, std-db requires `db.query`). @@ -152,6 +152,7 @@ MCP (Model Context Protocol) server that exposes Boruna's toolchain to AI coding | `boruna_capability_list` | Report the capability-set identity hash for `.ax` source | | `boruna_policy_validate` | Validate a policy definition (strict validator) | | `boruna_symbols` | Extract top-level symbols (fns/records/enums) from `.ax` source → exact typed signatures, capabilities, requires/ensures arity | +| `boruna_run_sealed` | Compile + run `.ax`, replay-verify the execution, return a verifiable record (result, `replay_verified`, capability calls, event log, SHA-256 seal digest). Not a signed bundle — that's the workflow path | ### IDE Configuration diff --git a/Cargo.lock b/Cargo.lock index 83c1957..edfadff 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -289,7 +289,7 @@ checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c" [[package]] name = "boruna-benches" -version = "3.0.0" +version = "3.1.0" dependencies = [ "boruna-bytecode", "boruna-compiler", @@ -302,7 +302,7 @@ dependencies = [ [[package]] name = "boruna-bytecode" -version = "3.0.0" +version = "3.1.0" dependencies = [ "serde", "serde_json", @@ -312,7 +312,7 @@ dependencies = [ [[package]] name = "boruna-cli" -version = "3.0.0" +version = "3.1.0" dependencies = [ "boruna-bytecode", "boruna-compiler", @@ -331,7 +331,7 @@ dependencies = [ [[package]] name = "boruna-compiler" -version = "3.0.0" +version = "3.1.0" dependencies = [ "boruna-bytecode", "boruna-vm", @@ -345,7 +345,7 @@ dependencies = [ [[package]] name = "boruna-effect" -version = "3.0.0" +version = "3.1.0" dependencies = [ "boruna-bytecode", "serde", @@ -356,7 +356,7 @@ dependencies = [ [[package]] name = "boruna-framework" -version = "3.0.0" +version = "3.1.0" dependencies = [ "boruna-bytecode", "boruna-compiler", @@ -369,7 +369,7 @@ dependencies = [ [[package]] name = "boruna-lsp" -version = "3.0.0" +version = "3.1.0" dependencies = [ "boruna-compiler", "boruna-tooling", @@ -381,7 +381,7 @@ dependencies = [ [[package]] name = "boruna-mcp" -version = "3.0.0" +version = "3.1.0" dependencies = [ "boruna-bytecode", "boruna-compiler", @@ -395,13 +395,14 @@ dependencies = [ "schemars 1.2.1", "serde", "serde_json", + "sha2", "tempfile", "tokio", ] [[package]] name = "boruna-orchestrator" -version = "3.0.0" +version = "3.1.0" dependencies = [ "aes-gcm", "base64 0.22.1", @@ -432,7 +433,7 @@ dependencies = [ [[package]] name = "boruna-pkg" -version = "3.0.0" +version = "3.1.0" dependencies = [ "boruna-bytecode", "boruna-compiler", @@ -445,7 +446,7 @@ dependencies = [ [[package]] name = "boruna-tooling" -version = "3.0.0" +version = "3.1.0" dependencies = [ "boruna-bytecode", "boruna-compiler", @@ -462,7 +463,7 @@ dependencies = [ [[package]] name = "boruna-vm" -version = "3.0.0" +version = "3.1.0" dependencies = [ "boruna-bytecode", "opentelemetry", diff --git a/Cargo.toml b/Cargo.toml index ddd0584..9da926e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,7 +16,7 @@ members = [ ] [workspace.package] -version = "3.0.0" +version = "3.1.0" edition = "2021" [workspace.dependencies] diff --git a/README.md b/README.md index e32e0c9..f28d237 100644 --- a/README.md +++ b/README.md @@ -31,9 +31,9 @@ This makes Boruna suited for teams building AI workflows that touch regulated da - **Diagnostics, auto-repair, and migration** — `boruna lang check`, `boruna lang repair`, `boruna migrate` for `.ax` files and bundle/workflow upgrades - **`boruna new`** — interactive scaffold for new workflows from templates - **33 built-in functions** — string (12), list (7), and map (7) operations plus type conversions and debug builtins (`__builtin_string_*`, `__builtin_list_*`, `__builtin_map_*`, …) available in every `.ax` file without imports -- **Import resolution** — `import "std-name"` inlines `libs//src/core.ax` at compile time; all 13 stdlib packages are 1.0-stable +- **Import resolution** — `import "std-name"` inlines `libs//src/core.ax` at compile time; 14 stdlib packages (the original 13 are 1.0-stable) - **Four formal versioned specifications** — `.ax` language 1.0, bytecode 1.0, evidence bundle format 1.0, workflow DAG schema 1.0 (all under [`docs/spec/`](./docs/spec/)) -- **MCP server** — exposes 13 tools for AI coding agent integration (Claude Code, Cursor, Codex) +- **MCP server** — exposes 14 tools for AI coding agent integration (Claude Code, Cursor, Codex) ## What Boruna is not diff --git a/crates/boruna-mcp/Cargo.toml b/crates/boruna-mcp/Cargo.toml index 6c89cca..053da0c 100644 --- a/crates/boruna-mcp/Cargo.toml +++ b/crates/boruna-mcp/Cargo.toml @@ -23,3 +23,4 @@ schemars = "1.0" clap = { workspace = true } tempfile = "3" jsonschema = { version = "0.30", default-features = false } +sha2 = "0.10" diff --git a/crates/boruna-mcp/src/server.rs b/crates/boruna-mcp/src/server.rs index bab3b81..4db1c53 100644 --- a/crates/boruna-mcp/src/server.rs +++ b/crates/boruna-mcp/src/server.rs @@ -87,6 +87,21 @@ struct RunLimitsParams { max_memory_mb: Option, } +#[derive(Serialize, Deserialize, JsonSchema)] +struct RunSealedParams { + /// The .ax source code to run and seal + source: String, + /// Capability policy — SAME shape as `boruna_run`. Either the string + /// shorthand "allow-all" / "deny-all" (default: "allow-all") or a Policy + /// object (see docs/reference/policy-schema.md). Invalid values return + /// success=false with error_kind="invalid_policy" or a policy.* kind. + #[serde(default)] + policy: Option, + /// Maximum execution steps (default: 10000000). Deterministic ceiling, + /// applied to both the original and the replay run. + max_steps: Option, +} + #[derive(Serialize, Deserialize, JsonSchema)] struct SymbolsParams { /// The .ax source code to extract top-level symbols from @@ -334,6 +349,27 @@ impl BorunaMcpServer { Ok(CallToolResult::success(vec![Content::text(result)])) } + // ── Run-and-Seal Tool ── + + #[tool( + description = "Compile and execute .ax source, then return a VERIFIABLE execution record — not just the result. The run is executed once to capture the VM EventLog (capability calls/results, actor events, UI emits, and requires/ensures contract checks), then RE-EXECUTED a second time with the recorded capability results fed back through a replay handler; the two logs are compared with ReplayEngine::verify_full. The response carries `replay_verified` (true only when every event recurs identically), `result`, `steps`, an ordered `capability_calls` list, the full `event_log`, and `event_log_sha256` — a SHA-256 digest of the canonical log that acts as a stable seal handle. `policy` uses the SAME shape as boruna_run. IMPORTANT: the 'seal' here is a replay-verified event log, NOT a signed evidence bundle — a signed, hash-chained bundle is a workflow-directory artifact produced by the orchestrator (`boruna workflow run --record` / `boruna evidence verify`); the response documents this in seal.note. Use this to call Boruna as a deterministic, auditable execution cell from an external agent framework. Domain errors (compile/parse failures, runtime_error, capability_denied, invalid_policy) are returned as success=false JSON." + )] + async fn boruna_run_sealed( + &self, + Parameters(params): Parameters, + ) -> Result { + validate_source(¶ms.source)?; + let source = params.source; + let policy = params.policy; + let max_steps = params.max_steps.unwrap_or(10_000_000); + let result = tokio::task::spawn_blocking(move || { + tools::sealed::run_sealed(&source, policy.as_ref(), max_steps) + }) + .await + .map_err(|e| McpError::internal_error(format!("task join error: {e}"), None))?; + Ok(CallToolResult::success(vec![Content::text(result)])) + } + // ── Diagnostics Tools ── #[tool( diff --git a/crates/boruna-mcp/src/tools/mod.rs b/crates/boruna-mcp/src/tools/mod.rs index 5e9aef8..af488ba 100644 --- a/crates/boruna-mcp/src/tools/mod.rs +++ b/crates/boruna-mcp/src/tools/mod.rs @@ -4,6 +4,7 @@ pub mod compile; pub mod framework; pub mod policy; pub mod run; +pub mod sealed; pub mod symbols; pub mod template; pub mod workflow; @@ -121,6 +122,27 @@ mod protocol_version_tests { assert_protocol_version(&out, "run compile failure"); } + // ── run_sealed ── + + #[test] + fn run_sealed_success_carries_protocol_version() { + let out = sealed::run_sealed("fn main() -> Int { 1 + 2 }\n", None, 1_000_000); + assert_protocol_version(&out, "run_sealed success"); + } + + #[test] + fn run_sealed_compile_failure_carries_protocol_version() { + let out = sealed::run_sealed("@@@ not valid", None, 1_000_000); + assert_protocol_version(&out, "run_sealed compile failure"); + } + + #[test] + fn run_sealed_invalid_policy_carries_protocol_version() { + let bad = serde_json::json!(42); + let out = sealed::run_sealed("fn main() -> Int { 1 }\n", Some(&bad), 1_000_000); + assert_protocol_version(&out, "run_sealed invalid_policy"); + } + // ── check / repair ── #[test] diff --git a/crates/boruna-mcp/src/tools/run.rs b/crates/boruna-mcp/src/tools/run.rs index 0ad71d4..11d338f 100644 --- a/crates/boruna-mcp/src/tools/run.rs +++ b/crates/boruna-mcp/src/tools/run.rs @@ -600,7 +600,7 @@ pub(crate) fn parse_policy(value: Option<&JsonValue>) -> Result serde_json::Value { +pub(crate) fn format_value(value: &Value) -> serde_json::Value { match value { Value::Int(n) => serde_json::json!(n), Value::Float(f) => serde_json::json!(f), diff --git a/crates/boruna-mcp/src/tools/sealed.rs b/crates/boruna-mcp/src/tools/sealed.rs new file mode 100644 index 0000000..038e050 --- /dev/null +++ b/crates/boruna-mcp/src/tools/sealed.rs @@ -0,0 +1,449 @@ +//! `boruna_run_sealed` — run an `.ax` program and return a **verifiable +//! execution record**, not just the result value. +//! +//! ## What "sealed" means here (honest scope) +//! +//! A full, signed, hash-chained **evidence bundle** is a workflow-directory +//! artifact produced by the orchestrator (`boruna workflow run --record` → +//! `boruna evidence verify`). It requires a workflow definition, a +//! DataStore, and a filesystem output directory — none of which exist at the +//! scope of a single stateless MCP `source`-string call. +//! +//! What a single MCP run *can* honestly produce is the strongest artifact the +//! run actually generates: the VM's [`boruna_vm::replay::EventLog`] plus a **deterministic +//! replay proof**. This tool: +//! +//! 1. compiles + runs the source under the requested policy, capturing the +//! original [`boruna_vm::replay::EventLog`] (every capability call/result, actor event, UI +//! emit, and `requires`/`ensures` contract check); +//! 2. re-executes the same module a second time, feeding the recorded +//! capability results back through a [`ReplayHandler`] so the replay is +//! hermetic (no re-invocation of side effects); +//! 3. compares the two logs with [`ReplayEngine::verify_full`] — every event +//! must recur in the same order with identical payloads; +//! 4. returns `replay_verified`, the full event log, an ordered list of +//! capability calls, and a SHA-256 digest of the canonical event log that +//! acts as a stable **seal handle** a caller can pin. +//! +//! So the seal here is a **replay-verified event log**, deliberately *not* a +//! signed bundle. The response says so in its `seal.kind` /`seal.note` fields +//! and points at the workflow path for callers who need the full bundle. We +//! never fabricate a bundle. + +use boruna_bytecode::Value; +use boruna_vm::capability_gateway::{CapabilityGateway, ReplayHandler}; +use boruna_vm::error::VmError; +use boruna_vm::replay::{Event, ReplayEngine, ReplayResult}; +use boruna_vm::vm::Vm; +use serde_json::Value as JsonValue; +use sha2::{Digest, Sha256}; + +use super::TOOL_RESPONSE_PROTOCOL_VERSION; +use crate::tools::run::{format_value, parse_policy}; + +/// Cap on the number of events embedded in the `event_log` array and the +/// `capability_calls` array, mirroring `run.rs`'s `TRACE_LIMIT`. The SHA-256 +/// digest is always computed over the FULL log, so truncating the embedded +/// view never weakens the seal — a caller can still detect tampering via the +/// digest even when the array is clipped for transport. +const EVENT_LOG_LIMIT: usize = 1000; + +/// Human-facing description of the seal semantics. Kept in one place so the +/// response and the docs stay in lockstep. +const SEAL_NOTE: &str = "This seal is a deterministic replay proof over the VM EventLog \ + (ReplayEngine::verify_full), not a signed evidence bundle. A signed, hash-chained \ + evidence bundle is a workflow-directory artifact produced by the orchestrator — see \ + `boruna workflow run --record` and `boruna evidence verify`."; + +/// Compile, run, replay-verify, and seal `.ax` source. +/// +/// `policy` uses the exact same shape [`boruna_run`](crate::tools::run) accepts +/// (`None` → allow-all, `"allow-all"`/`"deny-all"` shorthand, or a strict +/// Policy object). `max_steps` is the deterministic execution ceiling applied +/// to BOTH the original and the replay run. +/// +/// Domain failures follow the crate convention — returned as a successful tool +/// response with `success: false` and a stable `error_kind`: +/// - compile failure → the compiler's own error JSON (`error_kind: "parse_error"` etc.) +/// - `invalid_policy` / `policy.*` → policy parse/validation failure +/// - `capability_denied` → the run hit a `CapabilityDenied`/budget error +/// (the denied capability name is reflected in `capability`) +/// - `runtime_error` → any other VM error +pub fn run_sealed(source: &str, policy: Option<&JsonValue>, max_steps: u64) -> String { + // Compile once; clone the module for the replay run so both executions + // start from a bit-identical program. + let module = match boruna_compiler::compile("module", source) { + Ok(m) => m, + Err(e) => return compile_error_json(&e), + }; + + // Resolve policy through the SAME parser boruna_run uses. + let gw_policy = match parse_policy(policy) { + Ok(p) => p, + Err(err) => { + return serde_json::json!({ + "success": false, + "protocol_version": TOOL_RESPONSE_PROTOCOL_VERSION, + "error_kind": err.error_kind, + "message": err.message, + }) + .to_string(); + } + }; + + // ── Original run ── + let gateway = CapabilityGateway::new(gw_policy.clone()); + let mut vm = Vm::new(module.clone(), gateway); + vm.set_max_steps(max_steps); + let value = match vm.run() { + Ok(v) => v, + Err(e) => return vm_error_json(&e, vm.step_count()), + }; + let original_log = vm.event_log().clone(); + + // ── Replay run ── + // Feed the recorded capability RESULTS back through a ReplayHandler so the + // second execution never touches a real side effect — it must reproduce + // the identical event sequence purely from the recorded outcomes. + let recorded: Vec = original_log.capability_results(); + let replay_gateway = + CapabilityGateway::with_handler(gw_policy, Box::new(ReplayHandler::new(recorded))); + let mut replay_vm = Vm::new(module, replay_gateway); + replay_vm.set_max_steps(max_steps); + + let (replay_verified, replay_divergence): (bool, Option) = match replay_vm.run() { + Ok(_) => match ReplayEngine::verify_full(&original_log, replay_vm.event_log()) { + ReplayResult::Identical => (true, None), + ReplayResult::Diverged { reason } => (false, Some(reason)), + }, + // A replay that itself errors (e.g. the recorded log was exhausted + // because the replay diverged onto a path with more capability calls) + // is a genuine divergence — surface it, don't claim verified. + Err(e) => (false, Some(format!("replay execution failed: {e}"))), + }; + + // ── Build the sealed artifact ── + let events = original_log.events(); + let event_count = events.len(); + + // Digest over the FULL canonical log (never the truncated view). + let canonical = original_log.to_json().unwrap_or_default(); + let digest = sha256_hex(canonical.as_bytes()); + + let truncated = event_count > EVENT_LOG_LIMIT; + let embedded: Vec = events + .iter() + .take(EVENT_LOG_LIMIT) + .map(event_json) + .collect(); + + let capability_calls: Vec = events + .iter() + .filter_map(cap_call_json) + .take(EVENT_LOG_LIMIT) + .collect(); + + let json = serde_json::json!({ + "success": true, + "protocol_version": TOOL_RESPONSE_PROTOCOL_VERSION, + "result": format_value(&value), + "steps": vm.step_count(), + "replay_verified": replay_verified, + "replay_divergence_reason": replay_divergence, + "event_count": event_count, + "capability_calls": capability_calls, + "event_log": { + "version": original_log.version(), + "events": embedded, + "truncated": truncated, + }, + "event_log_sha256": digest, + "seal": { + "kind": "replay-verified-event-log", + "verified": replay_verified, + "digest_alg": "sha256", + "digest": digest, + "note": SEAL_NOTE, + }, + }); + + serde_json::to_string_pretty(&json).unwrap_or_else(|_| "{}".into()) +} + +/// Map a compile-time [`CompileError`] to this tool's typed domain-error +/// envelope. Unlike `boruna_run` (which reuses `compile::compile_error_json`'s +/// `errors[]`/`code` shape), `boruna_run_sealed` speaks a flat `error_kind` +/// taxonomy consistently across every failure path — `parse_error` for +/// lex/parse failures (mirroring `boruna_symbols`), `compile_error` for +/// type/codegen failures that occur before any execution. +fn compile_error_json(err: &boruna_compiler::CompileError) -> String { + use boruna_compiler::CompileError; + let (kind, message, line, col) = match err { + CompileError::Lexer { line, col, msg } => { + ("parse_error", msg.clone(), Some(*line), Some(*col)) + } + CompileError::Parse { line, msg } => ("parse_error", msg.clone(), Some(*line), None), + CompileError::Type(msg) | CompileError::Codegen(msg) => { + ("compile_error", msg.clone(), None, None) + } + }; + serde_json::json!({ + "success": false, + "protocol_version": TOOL_RESPONSE_PROTOCOL_VERSION, + "error_kind": kind, + "error": message, + "line": line, + "col": col, + }) + .to_string() +} + +/// Map a terminal [`VmError`] to the crate's domain-error envelope. +/// +/// `CapabilityDenied` / `CapabilityBudgetExceeded` get their own +/// `capability_denied` kind (with the offending capability reflected) so a +/// caller running under a restrictive policy can distinguish a policy block +/// from an ordinary runtime fault. Everything else stays `runtime_error`, +/// matching `boruna_run`. +fn vm_error_json(err: &VmError, steps: u64) -> String { + match err { + VmError::CapabilityDenied(cap) | VmError::CapabilityBudgetExceeded(cap) => { + serde_json::json!({ + "success": false, + "protocol_version": TOOL_RESPONSE_PROTOCOL_VERSION, + "error_kind": "capability_denied", + "capability": cap.name(), + "message": format!("{err}"), + "steps": steps, + }) + .to_string() + } + _ => serde_json::json!({ + "success": false, + "protocol_version": TOOL_RESPONSE_PROTOCOL_VERSION, + "error_kind": "runtime_error", + "message": format!("{err}"), + "steps": steps, + }) + .to_string(), + } +} + +/// Render one [`Event`] as JSON. Capability args/results and payloads reuse +/// `format_value` so the shapes match `boruna_run`'s `result`. +fn event_json(event: &Event) -> serde_json::Value { + match event { + Event::CapCall { capability, args } => serde_json::json!({ + "event": "cap_call", + "capability": capability, + "args": args.iter().map(format_value).collect::>(), + }), + Event::CapResult { capability, result } => serde_json::json!({ + "event": "cap_result", + "capability": capability, + "result": format_value(result), + }), + Event::ActorSpawn { actor_id, function } => serde_json::json!({ + "event": "actor_spawn", + "actor_id": actor_id, + "function": function, + }), + Event::MessageSend { from, to, payload } => serde_json::json!({ + "event": "message_send", + "from": from, + "to": to, + "payload": format_value(payload), + }), + Event::MessageReceive { actor_id, payload } => serde_json::json!({ + "event": "message_receive", + "actor_id": actor_id, + "payload": format_value(payload), + }), + Event::UiEmit { tree } => serde_json::json!({ + "event": "ui_emit", + "tree": format_value(tree), + }), + Event::SchedulerTick { + round, + active_actor, + } => serde_json::json!({ + "event": "scheduler_tick", + "round": round, + "active_actor": active_actor, + }), + Event::ContractCheck { + function, + kind, + index, + passed, + } => serde_json::json!({ + "event": "contract_check", + "function": function, + "kind": kind, + "index": index, + "passed": passed, + }), + } +} + +/// Project only the `CapCall` events into the `capability_calls` summary. +fn cap_call_json(event: &Event) -> Option { + match event { + Event::CapCall { capability, args } => Some(serde_json::json!({ + "capability": capability, + "args": args.iter().map(format_value).collect::>(), + })), + _ => None, + } +} + +fn sha256_hex(bytes: &[u8]) -> String { + let mut hasher = Sha256::new(); + hasher.update(bytes); + let digest = hasher.finalize(); + let mut out = String::with_capacity(digest.len() * 2); + for b in digest { + use std::fmt::Write; + let _ = write!(out, "{b:02x}"); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::{json, Value}; + + fn parse(s: &str) -> Value { + serde_json::from_str(s).expect("valid JSON") + } + + const PURE_SOURCE: &str = "fn main() -> Int {\n 1 + 2\n}\n"; + + // A program that makes a capability call at the surface level. `step_input` + // is the one builtin that compiles to `Op::CapCall`; under the MockHandler + // it deterministically returns an empty string, so the run records a + // CapCall + CapResult and the replay reproduces them exactly. + const CAP_SOURCE: &str = r#" +fn main() -> Int !{step.input} { + let _x: String = step_input("upstream") + 7 +} +"#; + + #[test] + fn pure_run_is_success_and_replay_verified() { + let out = run_sealed(PURE_SOURCE, None, 1_000_000); + let v = parse(&out); + assert_eq!(v["success"], true, "output: {out}"); + assert_eq!(v["result"], json!(3)); + assert_eq!(v["replay_verified"], true); + assert!(v["replay_divergence_reason"].is_null()); + // A pure program logs no events; the seal is still well-formed. + assert_eq!(v["event_count"], 0); + assert_eq!(v["capability_calls"].as_array().unwrap().len(), 0); + assert_eq!(v["seal"]["kind"], "replay-verified-event-log"); + assert_eq!(v["seal"]["verified"], true); + // Digest is a 64-char hex string and is echoed in both places. + let digest = v["event_log_sha256"].as_str().unwrap(); + assert_eq!(digest.len(), 64); + assert!(digest.chars().all(|c| c.is_ascii_hexdigit())); + assert_eq!(v["seal"]["digest"], v["event_log_sha256"]); + } + + #[test] + fn capability_run_records_and_verifies() { + let out = run_sealed(CAP_SOURCE, None, 1_000_000); + let v = parse(&out); + assert_eq!(v["success"], true, "output: {out}"); + assert_eq!(v["result"], json!(7)); + assert_eq!(v["replay_verified"], true, "output: {out}"); + + // The run recorded a step.input CapCall + its CapResult (2 events). + assert_eq!(v["event_count"], 2); + let caps = v["capability_calls"].as_array().unwrap(); + assert_eq!(caps.len(), 1); + assert_eq!(caps[0]["capability"], "step.input"); + + // The embedded event log carries the call and the result. + let events = v["event_log"]["events"].as_array().unwrap(); + assert_eq!(events.len(), 2); + assert_eq!(events[0]["event"], "cap_call"); + assert_eq!(events[0]["capability"], "step.input"); + assert_eq!(events[1]["event"], "cap_result"); + assert_eq!(v["event_log"]["truncated"], false); + } + + #[test] + fn runtime_error_is_success_false() { + // list_get out of bounds → a runtime trap (not a compile error). + let src = r#" +fn main() -> Int { + let xs: List = [1, 2, 3] + list_get(xs, 99) +} +"#; + let out = run_sealed(src, None, 1_000_000); + let v = parse(&out); + assert_eq!(v["success"], false, "output: {out}"); + assert_eq!(v["error_kind"], "runtime_error"); + assert!(v["message"].is_string()); + } + + #[test] + fn parse_error_is_success_false_parse_error() { + let out = run_sealed("@@@ not valid .ax", None, 1_000_000); + let v = parse(&out); + assert_eq!(v["success"], false, "output: {out}"); + assert_eq!(v["error_kind"], "parse_error"); + } + + #[test] + fn capability_denied_is_reflected() { + // deny-all blocks the step.input capability → the denial surfaces + // with a typed error_kind and the offending capability name. + let out = run_sealed(CAP_SOURCE, Some(&json!("deny-all")), 1_000_000); + let v = parse(&out); + assert_eq!(v["success"], false, "output: {out}"); + assert_eq!(v["error_kind"], "capability_denied"); + assert_eq!(v["capability"], "step.input"); + assert!(v["message"].as_str().unwrap().contains("capability denied")); + } + + #[test] + fn invalid_policy_is_reflected() { + let bad = json!(42); + let out = run_sealed(PURE_SOURCE, Some(&bad), 1_000_000); + let v = parse(&out); + assert_eq!(v["success"], false, "output: {out}"); + assert_eq!(v["error_kind"], "invalid_policy"); + } + + #[test] + fn seal_note_documents_bundle_boundary() { + // The honesty contract: the response must state that this is a replay + // proof, not a signed bundle, and point at the workflow path. + let out = run_sealed(PURE_SOURCE, None, 1_000_000); + let v = parse(&out); + let note = v["seal"]["note"].as_str().unwrap(); + assert!(note.contains("not a signed evidence bundle")); + assert!(note.contains("workflow")); + } + + #[test] + fn every_response_carries_protocol_version() { + for out in [ + run_sealed(PURE_SOURCE, None, 1_000_000), + run_sealed("@@@", None, 1_000_000), + run_sealed(CAP_SOURCE, Some(&json!("deny-all")), 1_000_000), + run_sealed(PURE_SOURCE, Some(&json!(42)), 1_000_000), + ] { + let v = parse(&out); + assert_eq!( + v["protocol_version"], + json!(TOOL_RESPONSE_PROTOCOL_VERSION), + "missing protocol_version in: {out}" + ); + } + } +} diff --git a/crates/llmbc/src/opcode.rs b/crates/llmbc/src/opcode.rs index 05fce8d..5112e6b 100644 --- a/crates/llmbc/src/opcode.rs +++ b/crates/llmbc/src/opcode.rs @@ -95,6 +95,20 @@ pub enum Op { index: u32, }, + /// Guard-and-seal an output value against a deterministic check. + /// + /// Stack layout (top → bottom): `[label, passed, value]`. Pops the + /// `label` (String) and the `passed` (Bool) verdict, leaving `value` + /// on the stack unchanged so the guard is transparent on the happy + /// path. The VM seals the verdict into the evidence trail as a + /// `ContractCheck` event with `kind: "output"` (for BOTH outcomes), + /// then, if `passed` is falsy, traps fail-closed with + /// `VmError::ContractViolation` carrying the label. This proves "the + /// guardrail ran on this value and returned this verdict" for a + /// sealed run. Emitted only by codegen for the `__builtin_guard` + /// builtin. + GuardSeal, + /// Capability call: cap_id, arg_count. Args on stack. CapCall(u32, u8), @@ -294,6 +308,7 @@ impl Op { Op::ReceiveMsg => 0x11, Op::Assert { .. } => 0x12, Op::CapCall(_, _) => 0x13, + Op::GuardSeal => 0x15, Op::Add => 0x20, Op::Sub => 0x21, Op::Mul => 0x22, diff --git a/crates/llmc/src/codegen.rs b/crates/llmc/src/codegen.rs index 2852a1e..8070801 100644 --- a/crates/llmc/src/codegen.rs +++ b/crates/llmc/src/codegen.rs @@ -586,6 +586,19 @@ impl Emitter { fe.code.push(Op::DebugMsg); return Ok(()); } + // guard-and-seal: run a deterministic boolean + // check on a value, fail closed on false, and seal + // the verdict into the evidence trail as an + // `output` ContractCheck. Stack the VM expects + // (top → bottom): [label, passed, value] — so emit + // value, then passed, then label. + "__builtin_guard" if args.len() == 3 => { + self.emit_expr(&args[0], fe)?; // value + self.emit_expr(&args[1], fe)?; // passed + self.emit_expr(&args[2], fe)?; // label + fe.code.push(Op::GuardSeal); + return Ok(()); + } // 0.3-S14: builtin `step_input(name)` reads a // workflow step's resolved upstream output. // Emits `Op::CapCall(StepInput, 1)` which diff --git a/crates/llmc/src/tests.rs b/crates/llmc/src/tests.rs index f6fd735..c18cbe8 100644 --- a/crates/llmc/src/tests.rs +++ b/crates/llmc/src/tests.rs @@ -270,6 +270,53 @@ mod tests { assert_eq!(run_source(src), Value::Int(7)); } + #[test] + fn test_guard_builtin_emits_guard_seal_opcode() { + use boruna_bytecode::Op; + let module = compile( + "m", + "fn main() -> Int { __builtin_guard(42, true, \"ok\") }", + ) + .unwrap(); + let main = module.functions.iter().find(|f| f.name == "main").unwrap(); + assert!( + main.code.iter().any(|op| matches!(op, Op::GuardSeal)), + "expected a GuardSeal opcode from __builtin_guard" + ); + } + + #[test] + fn test_guard_builtin_pass_returns_value_and_seals() { + use boruna_vm::replay::Event; + let src = "fn main() -> Int { __builtin_guard(42, true, \"json-shape\") }"; + let module = compile("test", src).expect("compile"); + let gateway = CapabilityGateway::new(Policy::allow_all()); + let mut vm = Vm::new(module, gateway); + assert_eq!(vm.run().unwrap(), Value::Int(42)); + assert!(vm.event_log().events().iter().any(|e| matches!( + e, + Event::ContractCheck { function, kind, passed, .. } + if function == "json-shape" && kind == "output" && *passed + ))); + } + + #[test] + fn test_guard_builtin_fail_traps_closed() { + use boruna_vm::replay::Event; + use boruna_vm::VmError; + let src = "fn main() -> Int { __builtin_guard(42, false, \"json-shape\") }"; + let module = compile("test", src).expect("compile"); + let gateway = CapabilityGateway::new(Policy::allow_all()); + let mut vm = Vm::new(module, gateway); + let err = vm.run().expect_err("false guard must trap"); + assert!(matches!(err, VmError::ContractViolation { .. })); + // The failed verdict is sealed even though the run trapped. + assert!(vm.event_log().events().iter().any(|e| matches!( + e, + Event::ContractCheck { kind, passed, .. } if kind == "output" && !*passed + ))); + } + #[test] fn test_e2e_for_loop_sums_list() { let src = "fn main() -> Int {\n let mut total = 0\n for x in [1, 2, 3, 4, 5] {\n total = total + x\n }\n total\n}"; diff --git a/crates/llmc/src/typeck.rs b/crates/llmc/src/typeck.rs index b4b5752..b3971af 100644 --- a/crates/llmc/src/typeck.rs +++ b/crates/llmc/src/typeck.rs @@ -88,6 +88,11 @@ impl TypeChecker { // See docs/spec/bytecode-1.0.md §4 (Debug, DebugMsg). functions.insert("__builtin_debug".to_string(), 1); functions.insert("__builtin_debug_msg".to_string(), 2); + // guard-and-seal: `__builtin_guard(value, passed, label)` runs a + // deterministic boolean check on a value, fails closed on false, + // and seals the verdict into the evidence trail. Returns `value` + // unchanged on pass. Compiles to `Op::GuardSeal`. + functions.insert("__builtin_guard".to_string(), 3); // 0.3-S14: read a workflow step's resolved input value at // runtime. Compiles to `Op::CapCall(StepInput, 1)` which // dispatches through the gateway's StepInputHandler. Returns diff --git a/crates/llmvm-cli/src/main.rs b/crates/llmvm-cli/src/main.rs index 291ed8a..30d0df2 100644 --- a/crates/llmvm-cli/src/main.rs +++ b/crates/llmvm-cli/src/main.rs @@ -952,6 +952,70 @@ enum EvidenceCommand { #[arg(long)] json: bool, }, + /// Emit (or verify) an in-toto Statement + DSSE envelope for the + /// bundle's runtime provenance, for interop with the supply-chain + /// ecosystem (`cosign verify-blob`, `in-toto-verify`). Additive — + /// does NOT touch the native bundle format. Writes + /// `attestation.intoto.dsse.json` into the bundle directory. + Attest { + /// Evidence bundle directory (must contain `manifest.json`). + dir: PathBuf, + /// Verify the existing `attestation.intoto.dsse.json` instead of + /// producing one. Checks the DSSE ed25519 signature over the PAE. + #[arg(long)] + verify: bool, + /// ed25519 signing seed (32 bytes as 64 hex chars) used to sign + /// the DSSE PAE. This is the SAME key machinery as manifest + /// signing — supply the same seed you signed the bundle with. + /// Falls back to `BORUNA_BUNDLE_SIGNING_KEY`. Required (only) + /// when producing an attestation. + #[arg(long, value_name = "HEX")] + signing_key: Option, + /// With `--verify`: pin the trusted ed25519 public key (64 hex + /// chars). A valid signature MUST be made by this key, else + /// verification fails. Without a pin, a self-consistent + /// signature is accepted. + #[arg(long, value_name = "HEX")] + verify_key: Option, + /// Output path for the DSSE envelope. Defaults to + /// `/attestation.intoto.dsse.json`. + #[arg(long)] + output: Option, + }, + /// Generate a human-readable COMPLIANCE evidence-mapping report that + /// maps a bundle's actual contents to the specific regulatory + /// obligation each one helps satisfy. Verifies the bundle first and + /// stamps the verdict at the top; a tampered/unverifiable bundle + /// produces a report that says so loudly. This is a technical mapping, + /// NOT a certificate of compliance. + Report { + /// Evidence bundle directory. + dir: PathBuf, + /// Regulatory framework to map against. + #[arg(long, value_name = "FRAMEWORK")] + framework: String, + /// Output rendering: `md` (default) or `html`. + #[arg(long, value_name = "FORMAT", default_value = "md")] + format: String, + }, + /// Export the bundle's execution as OpenTelemetry spans in OTLP/JSON — + /// the file format any OTel collector ingests. No SDK dependency, no + /// network: emit the document and POST it to a collector (or pipe it + /// through the `otlpjson` file receiver) to surface the run in Jaeger, + /// Tempo, Honeycomb, Datadog, etc. + /// + /// The root span carries tamper-evidence attributes (`boruna.bundle_hash`, + /// `boruna.audit_log_hash`, `boruna.signature.keyid`) so a span in a + /// tracing backend links back to a record verifiable with + /// `boruna evidence verify`. `llm.*` capability calls are emitted as + /// `gen_ai.*` spans (OTel GenAI semantic conventions). + Otel { + /// Evidence bundle directory. + dir: PathBuf, + /// Write the OTLP/JSON document to this file instead of stdout. + #[arg(long, value_name = "FILE")] + out: Option, + }, } #[derive(Subcommand)] @@ -4108,6 +4172,109 @@ fn run_evidence( } => { evidence_diff::evidence_diff(&bundle_a, &bundle_b, json)?; } + EvidenceCommand::Attest { + dir, + verify, + signing_key, + verify_key, + output, + } => { + run_evidence_attest(dir, verify, signing_key, verify_key, output)?; + } + EvidenceCommand::Report { + dir, + framework, + format, + } => { + use boruna_orchestrator::audit::report::{ + generate_report, ComplianceFramework, ReportFormat, + }; + let framework = ComplianceFramework::parse(&framework)?; + let format = ReportFormat::parse(&format)?; + let report = generate_report(&dir, framework, format)?; + println!("{report}"); + } + EvidenceCommand::Otel { dir, out } => { + let doc = boruna_orchestrator::audit::otel::bundle_to_otlp_json(&dir)?; + match out { + Some(path) => { + fs::write(&path, &doc) + .map_err(|e| format!("cannot write {}: {e}", path.display()))?; + println!("OTLP/JSON spans written to {}", path.display()); + } + None => println!("{doc}"), + } + } + } + Ok(()) +} + +/// `boruna evidence attest ` — emit or verify an in-toto Statement +/// wrapped in a DSSE envelope for a bundle's runtime provenance. +/// Additive interop: reads the (plaintext) manifest, reuses the existing +/// ed25519 signing key, and writes `attestation.intoto.dsse.json`. With +/// `--verify`, it checks the DSSE signature over the PAE instead. +fn run_evidence_attest( + dir: PathBuf, + verify: bool, + signing_key: Option, + verify_key: Option, + output: Option, +) -> Result<(), Box> { + use boruna_orchestrator::audit::attestation::{ + attest, parse_seed_hex, verify_envelope, DsseEnvelope, + }; + use boruna_orchestrator::audit::evidence::BundleManifest; + + let out_path = output.unwrap_or_else(|| dir.join("attestation.intoto.dsse.json")); + + if verify { + let raw = fs::read_to_string(&out_path) + .map_err(|e| format!("cannot read {}: {e}", out_path.display()))?; + let envelope: DsseEnvelope = + serde_json::from_str(&raw).map_err(|e| format!("invalid DSSE envelope: {e}"))?; + match verify_envelope(&envelope, verify_key.as_deref()) { + Ok(stmt) => { + println!("attestation is VALID"); + println!(" predicateType: {}", stmt.predicate_type); + println!(" subjects: {}", stmt.subject.len()); + println!( + " invocationId: {}", + stmt.predicate.run_details.metadata.invocation_id + ); + } + Err(e) => { + eprintln!("attestation INVALID: {e}"); + process::exit(1); + } + } + return Ok(()); + } + + // Produce mode: read the manifest and sign a fresh envelope. + let manifest_json = fs::read_to_string(dir.join("manifest.json")) + .map_err(|e| format!("cannot read manifest.json: {e}"))?; + let manifest: BundleManifest = + serde_json::from_str(&manifest_json).map_err(|e| format!("invalid manifest.json: {e}"))?; + + let seed_hex = signing_key + .or_else(|| std::env::var("BORUNA_BUNDLE_SIGNING_KEY").ok()) + .ok_or_else(|| { + "no signing key: pass --signing-key <64-hex> or set BORUNA_BUNDLE_SIGNING_KEY" + .to_string() + })?; + let seed = parse_seed_hex(&seed_hex).map_err(|e| e.to_string())?; + + let envelope = attest(&manifest, env!("CARGO_PKG_VERSION"), &seed) + .map_err(|e| format!("attestation failed: {e}"))?; + let json = serde_json::to_string_pretty(&envelope) + .map_err(|e| format!("cannot serialize envelope: {e}"))?; + fs::write(&out_path, json).map_err(|e| format!("cannot write {}: {e}", out_path.display()))?; + + println!("attestation written to {}", out_path.display()); + println!(" payloadType: {}", envelope.payload_type); + if let Some(sig) = envelope.signatures.first() { + println!(" keyid: {}", sig.keyid); } Ok(()) } diff --git a/crates/llmvm/src/replay.rs b/crates/llmvm/src/replay.rs index f32f31e..0184471 100644 --- a/crates/llmvm/src/replay.rs +++ b/crates/llmvm/src/replay.rs @@ -48,6 +48,12 @@ pub enum Event { /// exactly which one failed (`passed: false`, immediately before the /// run traps with `VmError::ContractViolation`). `kind` is /// `"requires"`/`"ensures"`; `index` is the 0-based clause position. + /// + /// Also reused by the guard-and-seal builtin (`Op::GuardSeal`) with + /// `kind: "output"`, `function` = the guard label, and `index: 0` — + /// so a fail-closed output check on an (e.g. model-produced) value + /// seals its verdict through the same evidence path without a new + /// event variant or format bump. ContractCheck { function: String, kind: String, @@ -153,6 +159,22 @@ impl EventLog { }); } + /// Record that a guard-and-seal output check was evaluated. Reuses + /// the `ContractCheck` event with `kind: "output"` (so no new event + /// variant, no format version bump, and the orchestrator's evidence + /// bundle seals it transparently). `label` is the guard's name, + /// carried in the `function` field; `index` is always 0 (output + /// checks are standalone, not part of a numbered clause list). Called + /// by the VM at every `Op::GuardSeal` site for both pass and fail. + pub fn log_output_check(&mut self, label: &str, passed: bool) { + self.events.push(Event::ContractCheck { + function: label.to_string(), + kind: "output".to_string(), + index: 0, + passed, + }); + } + pub fn events(&self) -> &[Event] { &self.events } diff --git a/crates/llmvm/src/tests.rs b/crates/llmvm/src/tests.rs index 20d13d4..b0389f3 100644 --- a/crates/llmvm/src/tests.rs +++ b/crates/llmvm/src/tests.rs @@ -778,6 +778,138 @@ mod tests { ); } + #[test] + fn test_guard_seal_pass_returns_value_and_seals() { + // __builtin_guard(value, true, "json-shape") returns `value` + // unchanged AND seals an output ContractCheck{passed:true}. + // Stack the VM expects (top → bottom): [label, passed, value]. + let module = simple_module( + vec![ + Op::PushConst(0), // value + Op::PushConst(1), // passed = true + Op::PushConst(2), // label + Op::GuardSeal, + Op::Ret, + ], + vec![ + Value::Int(42), + Value::Bool(true), + Value::String("json-shape".into()), + ], + ); + let gateway = CapabilityGateway::new(Policy::allow_all()); + let mut vm = Vm::new(module, gateway); + // Transparent on the happy path: the guarded value flows through. + assert_eq!(vm.run().unwrap(), Value::Int(42)); + + let checks: Vec<_> = vm + .event_log() + .events() + .iter() + .filter(|e| matches!(e, Event::ContractCheck { .. })) + .collect(); + assert_eq!(checks.len(), 1); + match checks[0] { + Event::ContractCheck { + function, + kind, + index, + passed, + } => { + assert_eq!(function, "json-shape"); + assert_eq!(kind, "output"); + assert_eq!(*index, 0); + assert!(*passed); + } + other => panic!("expected ContractCheck, got {other:?}"), + } + } + + #[test] + fn test_guard_seal_fail_traps_and_seals() { + // __builtin_guard(value, false, "json-shape") traps fail-closed + // with VmError::ContractViolation AND still seals the verdict as + // an output ContractCheck{passed:false}. + let module = simple_module( + vec![ + Op::PushConst(0), // value + Op::PushConst(1), // passed = false + Op::PushConst(2), // label + Op::GuardSeal, + Op::Ret, + ], + vec![ + Value::Int(42), + Value::Bool(false), + Value::String("json-shape".into()), + ], + ); + let gateway = CapabilityGateway::new(Policy::allow_all()); + let mut vm = Vm::new(module, gateway); + let err = vm.run().expect_err("failing output guard must trap"); + assert!(matches!(err, VmError::ContractViolation { .. })); + + // Even though the run trapped, the failed check is sealed. + let checks: Vec<_> = vm + .event_log() + .events() + .iter() + .filter(|e| matches!(e, Event::ContractCheck { .. })) + .collect(); + assert_eq!(checks.len(), 1); + match checks[0] { + Event::ContractCheck { + function, + kind, + passed, + .. + } => { + assert_eq!(function, "json-shape"); + assert_eq!(kind, "output"); + assert!(!*passed); + } + other => panic!("expected ContractCheck, got {other:?}"), + } + } + + #[test] + fn test_guard_seal_replay_determinism() { + // A run containing guard-and-seal output events must + // verify_full-match a re-run of the identical module. + let make_module = || { + simple_module( + vec![ + Op::PushConst(0), // value + Op::PushConst(1), // passed = true + Op::PushConst(2), // label + Op::GuardSeal, + Op::Ret, + ], + vec![ + Value::Int(7), + Value::Bool(true), + Value::String("output-shape".into()), + ], + ) + }; + + let mut vm1 = Vm::new(make_module(), CapabilityGateway::new(Policy::allow_all())); + vm1.run().unwrap(); + let mut vm2 = Vm::new(make_module(), CapabilityGateway::new(Policy::allow_all())); + vm2.run().unwrap(); + + assert!(vm1.event_log().events().iter().any(|e| matches!( + e, + Event::ContractCheck { kind, .. } if kind == "output" + ))); + + let result = ReplayEngine::verify_full(vm1.event_log(), vm2.event_log()); + assert!( + matches!(result, ReplayResult::Identical), + "output guard events must replay identically: {result:?}" + ); + } + #[test] fn test_event_log_explicit_v1_still_reads() { // Backward compat: an explicit version-1 log (no ContractCheck) diff --git a/crates/llmvm/src/vm.rs b/crates/llmvm/src/vm.rs index 7175062..3d43cae 100644 --- a/crates/llmvm/src/vm.rs +++ b/crates/llmvm/src/vm.rs @@ -628,6 +628,29 @@ impl Vm { }); } } + Op::GuardSeal => { + // Stack (top → bottom): [label, passed, value]. Pop the + // label and the boolean verdict; leave `value` on the + // stack so the guard is transparent on pass. + let label_val = self.pop()?; + let label = match label_val { + Value::String(s) => s, + other => format!("{other}"), + }; + let passed = self.pop()?.is_truthy(); + // Seal the verdict for BOTH outcomes: a passing run + // proves the guardrail ran and approved this value; a + // failing run pins the exact guard that rejected it, + // right before the fail-closed trap below. + self.event_log.log_output_check(&label, passed); + if !passed { + return Err(VmError::ContractViolation { + message: format!("output guard `{label}` failed"), + counterexample: Vec::new(), + }); + } + // `value` remains on the stack as the guard's result. + } Op::CapCall(cap_id, arg_count) => { let cap = Capability::from_id(cap_id).ok_or(VmError::UnknownCapability(cap_id))?; diff --git a/docs/README.md b/docs/README.md index 80b0f5f..ce77ea5 100644 --- a/docs/README.md +++ b/docs/README.md @@ -12,6 +12,8 @@ Core ideas that make Boruna work: - [Determinism](./concepts/determinism.md) — why same inputs → same outputs, and how it's enforced - [Capabilities](./concepts/capabilities.md) — declaring and gating side effects - [Evidence Bundles](./concepts/evidence-bundles.md) — tamper-evident audit logs and replay +- [Evidence Bundle Threat Model](./concepts/threat-model.md) — what a bundle proves and does not prove (tamper-evidence vs. tamper-proofing vs. non-repudiation) +- [Runtime Execution Provenance](./concepts/runtime-execution-provenance.md) — the provenance category Boruna occupies vs. SLSA, in-toto, Sigstore, C2PA, TEE attestation - [Bundle Storage](./concepts/bundle-storage.md) — local + remote (S3/GCS/Azure) destinations for evidence bundles ## Guides diff --git a/docs/concepts/runtime-execution-provenance.md b/docs/concepts/runtime-execution-provenance.md new file mode 100644 index 0000000..184716f --- /dev/null +++ b/docs/concepts/runtime-execution-provenance.md @@ -0,0 +1,132 @@ +# Runtime Execution Provenance + +Boruna occupies a provenance category that the established supply-chain +and content-authenticity standards leave largely unoccupied: +**runtime execution provenance** — an attested, tamper-evident record of +what a *specific execution* actually did. + +The claim this category makes is narrow and concrete: + +> *This specific run executed these steps, made these policy-gated +> capability calls, under this policy, transforming these recorded inputs +> into these recorded outputs.* + +That is a statement about an **execution trace**, not about a build, an +artifact, a signing event, a media file, or a booted code image. The +distinction matters because the standards people reach for by reflex all +answer a *different* question. + +--- + +## 1. The provenance landscape + +Each of these standards is good at what it targets. None of them targets +the execution trace of a particular run. + +| Standard / mechanism | What it attests | What it leaves unoccupied | +|----------------------|-----------------|---------------------------| +| **SLSA** | **Build** provenance: that an artifact was produced by a particular build system from particular sources, following a particular process. | Says nothing about what happens when the built thing later *runs*. A SLSA-attested binary can still do anything at runtime. | +| **in-toto** | **Supply-chain step metadata**: that each link in a defined software supply chain was performed by an authorized party on declared materials/products. | Models the *pipeline that assembles software*, not the *runtime behavior of a workflow execution*. The "steps" are build/release steps, not gated capability calls made during one run. | +| **Sigstore / Rekor** | **Signing events over artifacts**: that a given artifact digest was signed by a given identity, witnessed in an append-only transparency log. | Attests the *existence of a signature* over a blob at a time — not *what an execution did*. Rekor witnesses that something was signed, not that a run made these calls under this policy. | +| **C2PA** | **Content provenance**: the capture/edit history and origin of a media asset (image, audio, video). | Concerned with the lineage of *content*, not with the *execution* of a program or workflow. | +| **TEE remote attestation** | **Code identity**: which enclave/image was measured and booted on attested hardware. | Attests *what code was loaded and the platform it ran on* — not the *trace of what that code then did* (which steps, which capability calls, which inputs→outputs). | + +Read down the right-hand column: build provenance, supply-chain step +metadata, signing events, content lineage, and code identity are all +covered — and the *runtime execution trace* falls through the gap between +them. Boruna's evidence bundle is aimed squarely at that gap. + +These standards are complementary, not competitors. A mature deployment +might use SLSA for the binary, TEE attestation for the platform, and +Boruna for the execution trace — each answering the question it is built +to answer. + +--- + +## 2. What Boruna attests + +The evidence bundle records the run itself. The load-bearing components +(see [Evidence Bundles](./evidence-bundles.md) and +`orchestrator/src/audit/evidence.rs`) map directly onto the category +claim: + +- **These steps executed** — the hash-chained `audit_log`, whose entries + record step start/completion and capability calls in order, with each + entry chained to the previous (`entry_hash = SHA-256(prev_hash || + event_json)`). +- **Under this policy** — `policy.json` and its `policy_hash`; the policy + snapshot that gated the run is sealed alongside the trace, so a verifier + sees the exact rules in force. +- **Made these gated capability calls** — capability calls appear in the + audit log / event stream; the optional `model_invoking_steps.json` + additionally records which steps transitively reached an LLM + capability, so an auditor can see which steps touched a model without + re-analyzing sources. +- **Transforming these inputs into these outputs** — per-step outputs + under `outputs//.json`, each SHA-256-checksummed in + `file_checksums` and thereby committed to by `bundle_hash`. +- **With declared purpose** — the optional `intents.json` records the + per-step declared intent (what each step was *authorized* to do), + captured as replay-verified evidence alongside what it actually did. + +All of these are covered by the same integrity contract: on-disk +checksums, an unbroken audit-log chain, and a `bundle_hash` over the +manifest. The record is therefore **tamper-evident** in exactly the sense +defined in the [Evidence Bundle Threat Model](./threat-model.md) — and, +as that document is careful to state, tamper-evidence is not +tamper-proofing, and a sealed trace attests *what was recorded*, not that +the producer recorded honestly. + +--- + +## 3. Interoperability with the standards + +Occupying a distinct category does not mean living apart from the +ecosystem. The intent is for a Boruna execution record to slot into +existing supply-chain and attestation tooling rather than replace it. + +To that end, an **in-toto / DSSE emission** is available via +`boruna evidence attest `: it exports the bundle's core +attestation (run identity, `workflow_hash`, `policy_hash`, +`audit_log_hash`, output digests) as an in-toto Statement +(`predicateType: https://boruna.dev/runtime-provenance/v1`) wrapped in a +DSSE envelope signed with the bundle's ed25519 key. That makes the +execution-provenance claim consumable by the same tooling that already +ingests SLSA and in-toto attestations — for example, a transparency log +or a policy engine that gates on attestations. The predicate schema is +specified in `docs/spec/runtime-provenance-predicate-1.0.md`. + +> **Status note.** The in-toto/DSSE emitter is implemented +> (`boruna evidence attest`, `--verify` to check the envelope). It is +> **additive** — the native, authoritative format remains the evidence +> bundle described in `docs/spec/evidence-bundle-1.0.md`. Live +> interoperability with a specific `cosign`/`in-toto-verify` binary is +> spec-conformant but not yet end-to-end verified (the DSSE `keyid` is a +> raw-hex ed25519 key that a consumer must bridge to PEM SPKI); see the +> predicate spec's compatibility notes. + +The relationship is layered, not overlapping: + +- **SLSA / in-toto** attest how the software (and, via DSSE, other + attestations) was produced and assembled. +- **Sigstore / Rekor** can witness signatures — including, once emitted, + a DSSE-wrapped Boruna attestation — in an append-only log. +- **TEE attestation** can vouch for the platform and code image that ran + the Boruna engine. +- **Boruna** attests the execution trace that happened on top of all of + the above. + +Each layer roots a different claim; together they compose into a story +that no single standard tells alone. + +--- + +## 4. See also + +- [Evidence Bundles](./evidence-bundles.md) — the concrete artifact that + carries the execution-provenance record. +- [Evidence Bundle Threat Model](./threat-model.md) — precisely what the + record proves and does not prove (tamper-evidence vs. tamper-proofing + vs. non-repudiation). +- `docs/spec/evidence-bundle-1.0.md` — the normative on-disk format and + integrity contract. diff --git a/docs/concepts/threat-model.md b/docs/concepts/threat-model.md new file mode 100644 index 0000000..1cd55f5 --- /dev/null +++ b/docs/concepts/threat-model.md @@ -0,0 +1,148 @@ +# Evidence Bundle Threat Model + +This document states, honestly and without marketing, what an evidence +bundle proves and — just as important — what it does **not** prove. It +is written in the SLSA spirit: enumerate the threats, name the concrete +mitigation, and name the residual gap that remains after the mitigation. + +If you take one sentence away, take this: + +> An evidence bundle lets you prove that **the record was not altered +> after it was sealed**, and that the record is **internally consistent +> under replay**. It does not, and cannot, prove that the record was +> **true when it was written**. + +Everything below is an elaboration of that single distinction. + +--- + +## 1. Three properties people conflate + +Compliance conversations routinely blur three different guarantees. +Boruna delivers the first, delivers the second only under stated +conditions, and deliberately does not claim the third. + +| Property | Plain-English meaning | Does Boruna provide it? | +|----------|-----------------------|-------------------------| +| **Tamper-evidence** | If someone changes the sealed record, a verifier can *detect* the change. | Yes — this is the core guarantee. | +| **Tamper-proofing** | Changing the sealed record is *impossible*. | No. Nothing on a general-purpose filesystem is tamper-proof; a holder of the bytes can always rewrite them. Boruna makes tampering *detectable*, not *impossible*. | +| **Non-repudiation** | The party who produced the record cannot later deny producing it. | Partial, and only with a signed bundle under a pinned key (see §4). Even then it attests *who sealed the bytes*, not *whether the bytes are true*. | + +Keep these separate. Most overclaiming comes from quietly upgrading +"tamper-evident" to "tamper-proof", or from treating a signature as +proof of *truth* rather than proof of *origin*. + +--- + +## 2. What a bundle actually contains + +A sealed bundle (`orchestrator/src/audit/evidence.rs`, `BundleManifest`) +carries, at minimum: + +- `run_id`, `workflow_name`, `workflow_hash`, `policy_hash` +- `audit_log_hash` — the head of a hash-chained event log +- `file_checksums` — SHA-256 of every component file (workflow, policy, + audit log, env fingerprint, per-step outputs, and optional + `intents.json` / `model_invoking_steps.json`) +- `env_fingerprint` — OS / arch / Boruna version, **self-reported** +- `bundle_hash` — SHA-256 over the manifest itself (excluding + `bundle_hash` and `signature`), which therefore commits to every + `file_checksums` entry and the `audit_log_hash` +- optional `encryption` — AES-256-GCM envelope metadata +- optional `signature` — an ed25519 signature over `bundle_hash` + +The integrity contract enforced by `verify_bundle` +(`orchestrator/src/audit/verify.rs`): every file's on-disk SHA-256 must +match `file_checksums`; the audit-log chain must be unbroken +(`entry_hash = SHA-256(prev_hash || event_json)`); the chain head must +equal `audit_log_hash`; and all required components must be present. For +the full on-disk contract see +[Evidence Bundles](./evidence-bundles.md) and the format spec at +`docs/spec/evidence-bundle-1.0.md`. + +--- + +## 3. Threats and mitigations + +| Threat | What Boruna does | Residual gap | +|--------|------------------|--------------| +| **A third party edits a bundle file after it was sealed.** | The hash chain plus `file_checksums` plus `bundle_hash` make a naive edit detectable: any changed byte fails its SHA-256 check, and any spliced/removed audit entry breaks the chain. `verify_bundle` reports the failing check. | A *naive* edit is caught by plain `verify`. A *motivated* attacker who holds the whole bundle can rewrite the file **and** recompute every checksum **and** recompute `bundle_hash` so the bundle is internally self-consistent — this defeats plain `verify` (documented in-code as the "F1 weakness"). Closing it requires an **external anchor** or a **signature under a pinned key** — see §4. Tamper-*evidence*, not tamper-*proofing*. | +| **The recorder/producer is malicious and seals a FALSE record at write time.** | Nothing. The bundle faithfully seals whatever the producer fed it. A signature (if present) attests *which key sealed these bytes* — the producer's identity — not that the sealed facts are true. | **Not prevented, by construction.** Garbage-in is sealed as faithfully as truth-in. Evidence bundles are a *tamper-evidence* mechanism, not a *truth oracle*. Detecting a lying producer requires controls outside the bundle (independent corroboration, dual control over the recorder, a trusted execution environment — see §5). | +| **The signing key is compromised.** | With a valid key an attacker can forge or backdate the entire bundle, sign it, and it will verify under that key — hash-chaining is single-writer and provides no defense once the writer's key is held. Pinning a `trusted_pubkey` at verify time limits acceptance to a specific key, so a *different* attacker key is rejected. | If the *legitimate* key itself is stolen, pinning does not help — the forged bundle carries the pinned key. There is no revocation, no key rotation history, and no witnessed record of *when* a signature was made. Mitigation direction (not yet implemented): anchoring signatures in an append-only **transparency log** and/or **keyless, identity-bound signing**, so a signature is bound to a witnessed moment and a verifiable identity rather than to a long-lived secret. | +| **Backdating — sealing a record now but claiming it was produced earlier.** | The manifest carries `started_at` / `completed_at` / `created_at` timestamps, but these are **self-reported wall-clock values written by the producer**. Nothing external witnesses them. | **No trusted timestamp today.** A producer (or a key holder) can set these fields to any value. Mitigation direction (not yet implemented): anchoring the `bundle_hash` in an external append-only log (e.g. a Rekor-style transparency log) at seal time, so the *earliest-existence* time of the bundle is witnessed by a third party rather than asserted by the producer. | +| **Non-determinism, especially LLM calls, undermines "reproducibility".** | Replay re-executes the workflow against the **recorded** capability results: LLM calls, HTTP fetches, and other effects return their captured responses instead of hitting live services, and `--verify` checks that the replay reproduces the same output hashes. This proves the recorded run is *internally consistent* — the recorded inputs deterministically produce the recorded outputs. | Replay proves reproducibility **given the recorded capability results** — it does **not** prove that the model (or any external service) would return the same thing if called again live. A non-deterministic model is captured, not tamed: the bundle pins *what the model said this time*, not *what the model will say next time*. Do not read a passing replay as "the model is deterministic." | +| **The environment fingerprint is forged.** | `env_fingerprint.json` records OS, architecture, and Boruna version, and it is checksummed and covered by `bundle_hash` like every other file — so it cannot be changed *after* sealing without detection. | The fingerprint is **self-reported by the recording process, not hardware-attested.** A malicious or misconfigured producer can write any values it likes *at seal time*; the integrity check only proves those values were not altered afterward, not that they were true. Mitigation direction (not yet implemented): **TEE remote attestation**, binding the fingerprint to a hardware root of trust that attests the actual code image and platform that ran. | + +--- + +## 4. Why "detects tampering" needs a footnote + +Plain `boruna evidence verify` gives you **internal** consistency: it +recomputes every checksum and the chain and confirms they agree with the +manifest. That catches accidental corruption and unsophisticated edits. + +It does **not**, by itself, catch an attacker who controls the whole +bundle, because that attacker can make the manifest agree with their +forgery. Two independent, composable checks close this gap; neither is on +by default, and each roots trust in something the attacker does not +control: + +1. **External anchor** (`--expected-bundle-hash` / + `expected_bundle_hash`). You record the `bundle_hash` out-of-band at + seal time — in a separate system the attacker cannot rewrite — and + supply it at verify time. Verification then requires the recomputed + hash to equal *your* anchor, not the manifest's self-reported one. A + forged-but-self-consistent bundle fails because its recomputed hash no + longer matches the anchor you kept. This is what makes a plaintext + bundle genuinely tamper-evident against a motivated attacker. + +2. **ed25519 signature under a pinned key** (`--verify-key` / + `trusted_pubkey`, optionally `require_signature`). The producer signs + `bundle_hash` with an ed25519 key; the verifier pins the *expected* + public key. Trust is rooted in the pinned key: a bundle re-signed with + any other key is rejected as `signature_untrusted_key`. Without + pinning, a signature proves only that *some* key signed — an attacker + can substitute their own. + +The signature's meaning is precise: it attests **which key sealed these +bytes**. That is an origin/authenticity claim about the producer, not a +truth claim about the content (contrast the malicious-producer row in +§3). Non-repudiation follows only to the extent that the key is bound to +an accountable identity and is not shared — conditions the bundle format +cannot enforce on its own. + +--- + +## 5. Mitigation directions (not yet implemented) + +The residual gaps in §3 are real. The honest position is that they are +*known* and have *known* remedies on the roadmap, none of which ship +today: + +- **Transparency-log anchoring** (Rekor-style): witness the `bundle_hash` + in an external append-only log at seal time, giving a third-party- + attested earliest-existence timestamp and defeating silent backdating. +- **Keyless / identity-bound signing**: bind a signature to a verifiable + workload identity for a short-lived credential, reducing the blast + radius of a stolen long-lived key. +- **TEE remote attestation**: replace the self-reported environment + fingerprint with a hardware-attested measurement of the code image and + platform that actually executed. + +Until these land, treat the corresponding claims conservatively: a bundle +proves post-seal integrity and internal replay-consistency, anchored or +signed bundles additionally prove origin against a chosen root of trust, +and *nothing in the bundle* proves the producer was honest or that the +timestamps are true. + +--- + +## 6. See also + +- [Evidence Bundles](./evidence-bundles.md) — on-disk layout, hash chain, + and the `verify` / `inspect` / replay workflow. +- [Runtime Execution Provenance](./runtime-execution-provenance.md) — the + provenance category Boruna occupies, and how it relates to SLSA, + in-toto, Sigstore, C2PA, and TEE attestation. +- `docs/spec/evidence-bundle-1.0.md` — the normative format and integrity + contract. diff --git a/docs/spec/runtime-provenance-predicate-1.0.md b/docs/spec/runtime-provenance-predicate-1.0.md new file mode 100644 index 0000000..9db4c63 --- /dev/null +++ b/docs/spec/runtime-provenance-predicate-1.0.md @@ -0,0 +1,214 @@ +# Boruna Runtime-Provenance Predicate 1.0 + +`predicateType: https://boruna.dev/runtime-provenance/v1` + +This spec defines an **interop** view of a Boruna evidence bundle: the same +provenance the native bundle already records (`manifest.json`), re-emitted as a +standard [in-toto Statement](https://github.com/in-toto/attestation) wrapped in a +[DSSE](https://github.com/secure-systems-lab/dsse) envelope so that off-the-shelf +supply-chain tooling (`cosign verify-blob`, `in-toto-verify`) can consume Boruna +evidence. + +It is **additive and non-breaking**. Producing an attestation does not modify the +bundle, its `manifest.json`, its `bundle_hash`, or the existing +`evidence verify` path. See `docs/spec/evidence-bundle-1.0.md` for the native +format that remains the source of truth. + +The producer/verifier lives in `orchestrator/src/audit/attestation.rs`; the CLI +surface is `boruna evidence attest`. + +--- + +## 1. Artifacts + +Two nested artifacts are produced from a finalized `BundleManifest`: + +1. an **in-toto Statement v1**, and +2. a **DSSE envelope** wrapping that Statement, signed with the SAME ed25519 key + used for manifest signing (`EvidenceBundleBuilder::with_signing_key`). No new + keypair is introduced; the DSSE `keyid` is the lowercase-hex ed25519 public + key, identical to `manifest.signature.public_key`. + +The envelope is written to `/attestation.intoto.dsse.json`. + +--- + +## 2. Statement shape + +```jsonc +{ + "_type": "https://in-toto.io/Statement/v1", + "subject": [ + { "name": "", "digest": { "sha256": "" } }, + // ... one per manifest.file_checksums entry ... + { "name": "boruna-bundle:", "digest": { "sha256": "" } } + ], + "predicateType": "https://boruna.dev/runtime-provenance/v1", + "predicate": { /* see §3 */ } +} +``` + +### Subjects + +`subject[]` is the set of artifacts this attestation makes claims about: + +- **Every component file** the manifest checksums — reusing + `manifest.file_checksums` verbatim (name → `sha256`). These include + `workflow.json`, `policy.json`, `audit_log.json`, `env_fingerprint.json`, + per-step `outputs//.json`, and any optional components + (`intents.json`, `model_invoking_steps.json`, `event_log.json`). +- **The bundle itself**, as a synthetic subject `boruna-bundle:` whose + `sha256` is the manifest's `bundle_hash`. (`bundle_hash` is the SHA-256 of the + canonicalized manifest, so it is a digest, not a file — but it lets a verifier + bind the whole bundle by a single value.) + +`file_checksums` is a `BTreeMap`, so subjects are emitted in sorted-name order — +the Statement bytes are deterministic for a given manifest. + +--- + +## 3. Predicate schema + +The predicate borrows the shape of +[SLSA Provenance v1](https://slsa.dev/provenance/v1)'s `buildDefinition` / +`runDetails`, mapping the existing manifest fields: + +```jsonc +{ + "buildDefinition": { + "buildType": "https://boruna.dev/workflow-run/v1", + "externalParameters": { + "workflowName": "", + "workflowHash": "", + "policyHash": "" + }, + "internalParameters": { + "borunaVersion": "", + "envFingerprint": { /* manifest.env_fingerprint, verbatim */ } + } + }, + "runDetails": { + "builder": { "id": "https://boruna.dev/boruna@" }, + "metadata": { + "invocationId": "", + "startedOn": "", + "finishedOn": "" + }, + "byproducts": { + "auditLogHash": "", + "bundleHash": "" + } + } +} +``` + +### Field mapping (manifest → predicate) + +| Manifest field | Predicate location | +|-----------------------|------------------------------------------------------| +| `workflow_name` | `buildDefinition.externalParameters.workflowName` | +| `workflow_hash` | `buildDefinition.externalParameters.workflowHash` | +| `policy_hash` | `buildDefinition.externalParameters.policyHash` | +| `env_fingerprint` | `buildDefinition.internalParameters.envFingerprint` | +| (build version) | `buildDefinition.internalParameters.borunaVersion` | +| `run_id` | `runDetails.metadata.invocationId` | +| `started_at` | `runDetails.metadata.startedOn` | +| `completed_at` | `runDetails.metadata.finishedOn` | +| `audit_log_hash` | `runDetails.byproducts.auditLogHash` | +| `bundle_hash` | `runDetails.byproducts.bundleHash` | + +**Capability set and contract-check results** are not manifest struct fields — +they live in bundle *components* (`event_log.json` carries `ContractCheck` +events; `model_invoking_steps.json` lists steps that reached an `llm.*` +capability). Those components appear as **subjects** (by SHA-256) rather than +being inlined into the predicate, so the attestation still binds them without +duplicating or re-parsing their contents. A consumer that wants contract-check +detail dereferences the `event_log.json` subject and reads it from the bundle. + +All maps are `BTreeMap` and struct fields serialize in declaration order, so the +Statement is byte-stable: same run → same manifest → same Statement bytes → same +signature. + +--- + +## 4. DSSE envelope + +```jsonc +{ + "payload": "", + "payloadType": "application/vnd.in-toto+json", + "signatures": [ + { "sig": "", "keyid": "" } + ] +} +``` + +The signature is an ed25519 signature over the DSSE **Pre-Authentication +Encoding (PAE)** of `(payloadType, payload)`: + +``` +PAE(type, body) = "DSSEv1" SP LEN(type) SP type SP LEN(body) SP body +``` + +where `SP` is a single ASCII space (`0x20`) and `LEN` is the ASCII-decimal byte +length. `type` is `application/vnd.in-toto+json` and `body` is the **raw** +Statement JSON bytes (the pre-base64 bytes), not the base64 text. + +**Known vector** (from the DSSE spec's worked example, asserted in +`attestation.rs` tests): + +``` +PAE("http://example.com/HelloWorld", "hello world") + = "DSSEv1 29 http://example.com/HelloWorld 11 hello world" +``` + +The `payloadType` is bound into the signed bytes, so a signature over one payload +type cannot be replayed under another. + +--- + +## 5. CLI + +```bash +# Produce: sign the manifest's provenance into a DSSE envelope. +# Reuses the SAME ed25519 seed you signed the bundle with. +boruna evidence attest --signing-key <64-hex-seed> +# (or set BORUNA_BUNDLE_SIGNING_KEY instead of --signing-key) +# → writes /attestation.intoto.dsse.json + +# Verify: check the DSSE signature over the PAE. +boruna evidence attest --verify +# Optionally pin the trusted signer key: +boruna evidence attest --verify --verify-key <64-hex-pubkey> +``` + +`--verify` exits non-zero on any failure (bad signature, mutated payload, wrong +`payloadType`, or a pinned key that made no valid signature). + +--- + +## 6. Ecosystem compatibility + +The envelope is a standard DSSE envelope with `payloadType` +`application/vnd.in-toto+json` and a standard in-toto Statement payload, so it is +structurally consumable by the wider ecosystem: + +- **`cosign verify-blob-attestation`** consumes a DSSE envelope + a public key and + verifies the ed25519 signature over the PAE. Export the signer's public key in + PEM form (the DSSE `keyid` here is the raw 32-byte ed25519 public key as hex; + cosign expects a PEM `PUBLIC KEY`, so wrap the key in SubjectPublicKeyInfo DER → + PEM before handing it to cosign). The signature algorithm (ed25519 over PAE) + and envelope layout match what cosign verifies. +- **`in-toto-verify` / the in-toto attestation validators** parse the Statement + (`_type`, `subject`, `predicateType`, `predicate`) directly; the predicate is a + custom type, so policy is expressed against `predicateType == + https://boruna.dev/runtime-provenance/v1` and the fields in §3. + +**Honest caveat.** The bytes and algorithms follow the DSSE and in-toto specs, and +Boruna verifies its own envelopes end-to-end (round-trip, PAE known-vector, and +tamper tests). Full black-box interop with a specific `cosign` / `in-toto` +*release* — including the exact public-key PEM/DER encoding each tool wants and +any tool-specific envelope expectations — has **not** been exercised against those +binaries in this change; treat cross-tool verification as "spec-conformant, +pending a live `cosign`/`in-toto-verify` integration check." The key-encoding +bridge (raw hex ed25519 → SPKI PEM) is the most likely point of friction. diff --git a/examples/adapters/README.md b/examples/adapters/README.md new file mode 100644 index 0000000..fc82b49 --- /dev/null +++ b/examples/adapters/README.md @@ -0,0 +1,128 @@ +# Boruna as an auditable execution cell + +These adapters show how to call Boruna from an external agent framework +(LangGraph, Temporal, OpenAI Agents SDK, …) as a **deterministic, auditable +execution cell**: you hand Boruna an `.ax` program, it runs it under a +capability policy, and it hands back not just the result but a **verifiable +execution record**. + +The tool that makes this work is **`boruna_run_sealed`** on Boruna's MCP +server. It runs the program, captures the VM's `EventLog`, re-executes it a +second time feeding the recorded capability results back, and compares the two +logs (`ReplayEngine::verify_full`). You get: + +| field | meaning | +|-------|---------| +| `result` | the program's return value | +| `replay_verified` | `true` only if the second run reproduced every event identically | +| `capability_calls` | ordered list of capability calls the run made | +| `event_log` | the full event log (capability calls/results, actor events, UI emits, contract checks) | +| `event_log_sha256` | SHA-256 of the canonical event log — a stable **seal handle** to pin | +| `steps` | deterministic step count | + +## What "sealed" means (and does not) + +The seal here is a **replay-verified event log**, *not* a signed evidence +bundle. That is deliberate and honest: + +- A single MCP call is a stateless `source`-string invocation. It has no + workflow definition, no data store, and no output directory. +- A full **signed, hash-chained evidence bundle** is a workflow-directory + artifact produced by the **orchestrator**, not by a single run. Produce one + with: + + ```bash + boruna workflow run --policy allow-all --record + boruna evidence verify + ``` + +`boruna_run_sealed` returns the strongest artifact a single run genuinely +produces — a deterministic replay proof plus a digest — and says so in its +`seal.note`. It never fabricates a bundle. If you need the signed bundle, use +the workflow path above. + +## Registering the MCP server + +Boruna ships an MCP server binary, `boruna-mcp`, speaking JSON-RPC over stdio. + +`.mcp.json` (Claude Code) or equivalent: + +```json +{ + "mcpServers": { + "boruna": { + "command": "cargo", + "args": ["run", "--bin", "boruna-mcp", "--manifest-path", "/path/to/ai-lang/Cargo.toml"], + "env": {} + } + } +} +``` + +Or, with the binary on `PATH`, just `command: "boruna-mcp"`. + +## Calling the tool + +Arguments: + +```jsonc +{ + "source": "fn main() -> Int { 2 + 40 }\n", // required, .ax source (≤ 1 MB) + "policy": "allow-all", // optional: "allow-all" | "deny-all" | policy object + "max_steps": 10000000 // optional deterministic ceiling +} +``` + +Success response (abridged): + +```jsonc +{ + "success": true, + "protocol_version": 1, + "result": 42, + "steps": 6, + "replay_verified": true, + "replay_divergence_reason": null, + "event_count": 0, + "capability_calls": [], + "event_log": { "version": 2, "events": [], "truncated": false }, + "event_log_sha256": "5df6e0e2761359d30a8275058e299fcc0381534545f55cf43e41983f5d4c9456", + "seal": { + "kind": "replay-verified-event-log", + "verified": true, + "digest_alg": "sha256", + "digest": "5df6…9456", + "note": "This seal is a deterministic replay proof over the VM EventLog … not a signed evidence bundle …" + } +} +``` + +Domain errors come back as `success: false` with a stable `error_kind` +(`parse_error`, `runtime_error`, `capability_denied`, `invalid_policy`, …) — +they are returned as successful tool responses, not MCP transport errors, so +callers branch on the JSON. + +## The adapters + +- **`langgraph_node.py`** — a LangGraph node that calls `boruna_run_sealed` and + writes `{result, replay_verified}` into graph state so downstream nodes can + branch on reproducibility. +- **`temporal_activity.py`** — a Temporal **Activity** wrapping the same call + (external I/O belongs in an Activity, not in deterministic workflow code), + returning a small result the workflow can persist or assert on. + +Both mark their MCP-client plumbing (`call_mcp_tool`) as **pseudo-code** — wire +it to your MCP client of choice (e.g. the `mcp` Python SDK's stdio client, or +LangChain's MCP adapters). Everything else is real. + +### CLI fallback + +If you would rather not run the MCP server, the CLI covers the run itself +(without the replay-verified envelope): + +```bash +boruna run program.ax --policy allow-all +``` + +For the full audited path with a signed bundle, use `boruna workflow run +--record` + `boruna evidence verify` as shown above. diff --git a/examples/adapters/langgraph_node.py b/examples/adapters/langgraph_node.py new file mode 100644 index 0000000..486a4cc --- /dev/null +++ b/examples/adapters/langgraph_node.py @@ -0,0 +1,106 @@ +"""LangGraph node that runs an .ax program through Boruna as a deterministic, +auditable execution cell and writes the verified result back into graph state. + +The node calls the `boruna_run_sealed` MCP tool. Boruna compiles + runs the +`.ax` source, replays it, and returns a verifiable execution record: +`{ result, replay_verified, event_log_sha256, event_log, ... }`. We surface +`result` and `replay_verified` into the graph so downstream nodes (or a human +gate) can branch on whether the run is reproducible. + +Status: illustrative. The MCP plumbing (`call_mcp_tool`) is PSEUDO-CODE — wire +it to your MCP client of choice (e.g. the `mcp` Python SDK's stdio client, or +LangChain's MCP adapters). Everything else is real, runnable LangGraph. + +Register the server (see README.md in this dir): + boruna-mcp # JSON-RPC over stdio +or, from a checkout: + cargo run --bin boruna-mcp +""" + +from __future__ import annotations + +import json +from typing import Any, Optional, TypedDict + +from langgraph.graph import END, START, StateGraph + + +class GraphState(TypedDict, total=False): + # Input: the .ax program to execute. + ax_source: str + # Capability policy — same shape boruna_run/boruna_run_sealed accept: + # "allow-all", "deny-all", or a policy object. Optional; defaults allow-all. + policy: Any + # Outputs written by the boruna node: + result: Any + replay_verified: bool + event_log_sha256: Optional[str] + error: Optional[str] + + +# --------------------------------------------------------------------------- +# PSEUDO-CODE: replace with your MCP client call. The contract is: +# tool "boruna_run_sealed", args {source, policy?, max_steps?} +# -> returns a JSON *string* (Boruna tools return text content). +# --------------------------------------------------------------------------- +def call_mcp_tool(tool: str, arguments: dict) -> str: # pragma: no cover + raise NotImplementedError( + "Wire this to your MCP client. Example with the `mcp` SDK stdio client:\n" + " async with stdio_client(StdioServerParameters(command='boruna-mcp')) as (r, w):\n" + " async with ClientSession(r, w) as session:\n" + " await session.initialize()\n" + " res = await session.call_tool(tool, arguments)\n" + " return res.content[0].text\n" + ) + + +def boruna_sealed_node(state: GraphState) -> GraphState: + """Run state['ax_source'] through Boruna and record the verified outcome.""" + raw = call_mcp_tool( + "boruna_run_sealed", + { + "source": state["ax_source"], + "policy": state.get("policy", "allow-all"), + }, + ) + payload = json.loads(raw) + + if not payload.get("success", False): + # Domain errors (parse/runtime/capability_denied/invalid_policy) come + # back as success=false — surface, don't raise, so the graph can route. + return { + "result": None, + "replay_verified": False, + "event_log_sha256": None, + "error": f"{payload.get('error_kind')}: {payload.get('message')}", + } + + return { + "result": payload["result"], + "replay_verified": payload["replay_verified"], + "event_log_sha256": payload.get("event_log_sha256"), + "error": None, + } + + +def build_graph(): + g = StateGraph(GraphState) + g.add_node("boruna", boruna_sealed_node) + g.add_edge(START, "boruna") + g.add_edge("boruna", END) + return g.compile() + + +if __name__ == "__main__": # pragma: no cover + graph = build_graph() + out = graph.invoke( + { + "ax_source": "fn main() -> Int {\n 2 + 40\n}\n", + "policy": "allow-all", + } + ) + # Only trust the result if the run reproduced deterministically. + if out["replay_verified"]: + print("verified result:", out["result"], "seal:", out["event_log_sha256"]) + else: + print("UNVERIFIED / error:", out.get("error")) diff --git a/examples/adapters/temporal_activity.py b/examples/adapters/temporal_activity.py new file mode 100644 index 0000000..8d1cc41 --- /dev/null +++ b/examples/adapters/temporal_activity.py @@ -0,0 +1,93 @@ +"""Temporal Activity wrapping Boruna as a deterministic, auditable execution cell. + +Why an Activity and not Workflow code: Temporal *workflow* code must itself be +deterministic and may not do I/O, so calling an external process/MCP server +belongs in an **Activity**. The nice property here is that Boruna's own +execution is deterministic and replay-verified, so the Activity returns a +`replay_verified` flag plus a `event_log_sha256` seal the workflow can persist +or assert on. + +The Activity calls the `boruna_run_sealed` MCP tool (or, equivalently, the +`boruna run` CLI). It returns a small dataclass the workflow can store. + +Status: illustrative. `call_mcp_tool` is PSEUDO-CODE — wire it to your MCP +client. The Temporal activity/worker scaffolding is real (temporalio SDK). +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from typing import Any, Optional + +from temporalio import activity + + +@dataclass +class SealedRunRequest: + ax_source: str + policy: Any = "allow-all" # "allow-all" | "deny-all" | policy object + max_steps: int = 10_000_000 + + +@dataclass +class SealedRunResult: + success: bool + result: Any + replay_verified: bool + event_log_sha256: Optional[str] + error: Optional[str] + + +# --------------------------------------------------------------------------- +# PSEUDO-CODE: replace with your MCP client call, OR shell out to the CLI: +# subprocess.run(["boruna", "run", path, "--policy", "allow-all"], ...) +# The MCP tool returns richer data (replay_verified + seal), so it is preferred. +# --------------------------------------------------------------------------- +def call_mcp_tool(tool: str, arguments: dict) -> str: # pragma: no cover + raise NotImplementedError("Wire this to your MCP client — see README.md") + + +@activity.defn +async def run_boruna_sealed(req: SealedRunRequest) -> SealedRunResult: + raw = call_mcp_tool( + "boruna_run_sealed", + {"source": req.ax_source, "policy": req.policy, "max_steps": req.max_steps}, + ) + payload = json.loads(raw) + + if not payload.get("success", False): + return SealedRunResult( + success=False, + result=None, + replay_verified=False, + event_log_sha256=None, + error=f"{payload.get('error_kind')}: {payload.get('message')}", + ) + + return SealedRunResult( + success=True, + result=payload["result"], + replay_verified=payload["replay_verified"], + event_log_sha256=payload.get("event_log_sha256"), + error=None, + ) + + +# --- Example workflow using the activity (illustrative) -------------------- +# from datetime import timedelta +# from temporalio import workflow +# +# @workflow.defn +# class DeterministicCellWorkflow: +# @workflow.run +# async def run(self, ax_source: str) -> SealedRunResult: +# res = await workflow.execute_activity( +# run_boruna_sealed, +# SealedRunRequest(ax_source=ax_source), +# start_to_close_timeout=timedelta(seconds=30), +# ) +# # Fail the workflow if the cell did not reproduce deterministically. +# if res.success and not res.replay_verified: +# raise workflow.ApplicationError("boruna run was not replay-verified") +# return res diff --git a/libs/std-guard/package.ax.json b/libs/std-guard/package.ax.json new file mode 100644 index 0000000..08a2e48 --- /dev/null +++ b/libs/std-guard/package.ax.json @@ -0,0 +1,8 @@ +{ + "name": "std.guard", + "version": "1.0.0", + "description": "Deterministic output-content validators for guarding effects on model/LLM output", + "dependencies": {}, + "required_capabilities": [], + "exposed_modules": ["core"] +} diff --git a/libs/std-guard/src/core.ax b/libs/std-guard/src/core.ax new file mode 100644 index 0000000..f2e3fbf --- /dev/null +++ b/libs/std-guard/src/core.ax @@ -0,0 +1,200 @@ +// std.guard — Deterministic output-content validators. +// +// Boruna gates ACTIONS via capabilities, but by default trusts the CONTENT +// a model/LLM produces. std.guard provides pure, deterministic checks a +// workflow can run on a string BEFORE letting it drive an effect — mirroring +// the deterministic (non-ML) validators from Guardrails AI. +// +// Every function here is pure and deterministic: same input -> same output, +// no capabilities, no side effects. All string matching is exact/structural; +// there is no regex and no ML classification. + +// A richer result carrier for callers that want a reason string alongside the +// boolean verdict (mirrors std.validation's ValidationResult shape). +type GuardResult { passed: Bool, reason: String } + +fn guard_pass() -> GuardResult { + GuardResult { passed: true, reason: "" } +} + +fn guard_fail(reason: String) -> GuardResult { + GuardResult { passed: false, reason: reason } +} + +// Wrap a raw boolean verdict into a GuardResult with an explanatory reason +// used only when the check fails. +fn to_guard(passed: Bool, reason: String) -> GuardResult { + if passed { + guard_pass() + } else { + guard_fail(reason) + } +} + +// ── Length / emptiness ── + +// Non-empty after trimming surrounding whitespace. Whitespace-only strings +// are treated as empty, since a blank model answer should not drive an effect. +fn is_non_empty(s: String) -> Bool { + __builtin_string_len(__builtin_string_trim(s)) > 0 +} + +fn min_length(s: String, n: Int) -> Bool { + __builtin_string_len(s) >= n +} + +fn max_length(s: String, n: Int) -> Bool { + __builtin_string_len(s) <= n +} + +fn length_between(s: String, lo: Int, hi: Int) -> Bool { + let len: Int = __builtin_string_len(s) + len >= lo && len <= hi +} + +// ── Numeric range (parse-then-compare; false on parse failure) ── + +// Parse s as an integer and confirm lo <= value <= hi. Any non-integer input +// (empty, floats, words) fails the guard rather than throwing. +fn is_in_range(s: String, lo: Int, hi: Int) -> Bool { + match __builtin_int_parse(s) { + Some(n) => n >= lo && n <= hi, + None => false, + } +} + +// Float variant of is_in_range. Non-numeric input fails the guard. +fn is_in_range_float(s: String, lo: Float, hi: Float) -> Bool { + match __builtin_float_parse(s) { + Some(f) => f >= lo && f <= hi, + None => false, + } +} + +// ── Structural string matchers (exact, case-sensitive) ── + +fn matches_prefix(s: String, prefix: String) -> Bool { + __builtin_string_starts_with(s, prefix) +} + +fn matches_suffix(s: String, suffix: String) -> Bool { + __builtin_string_ends_with(s, suffix) +} + +fn matches_contains(s: String, needle: String) -> Bool { + __builtin_string_contains(s, needle) +} + +// ── Allow-list / ban-list ── + +// True when target appears exactly in items. Linear scan (no loops-free +// requirement: uses a bounded while like std.json). +fn list_contains_str(items: List, target: String) -> Bool { + let n: Int = list_len(items) + let i: Int = 0 + let found: Bool = false + while i < n { + let item: String = list_get(items, i) + found = found || (item == target) + i = i + 1 + } + found +} + +// Allow-list: s must be one of the permitted options (exact match). +fn is_one_of(s: String, options: List) -> Bool { + list_contains_str(options, s) +} + +// Ban-list: s must NOT be one of the banned values (exact match). +fn not_in_banlist(s: String, banned: List) -> Bool { + if list_contains_str(banned, s) { + false + } else { + true + } +} + +// ── Content heuristics ── + +// Case-insensitive substring test: does haystack contain needle ignoring case? +fn contains_ci(haystack: String, needle: String) -> Bool { + let h: String = __builtin_string_to_lower(haystack) + let n: String = __builtin_string_to_lower(needle) + __builtin_string_contains(h, n) +} + +// Heuristic: does the output read like a model refusal / boilerplate apology +// rather than a real answer? Case-insensitive substring match against a fixed +// phrase set. This is a heuristic, not a classifier: it can miss novel +// phrasings (false negatives) and can fire on legitimate text that quotes a +// refusal (false positives). Use it as a cheap pre-filter, not a proof. +fn looks_like_refusal(s: String) -> Bool { + let lo: String = __builtin_string_to_lower(s) + __builtin_string_contains(lo, "i cannot") + || __builtin_string_contains(lo, "i can't") + || __builtin_string_contains(lo, "i'm sorry") + || __builtin_string_contains(lo, "i am sorry") + || __builtin_string_contains(lo, "as an ai") + || __builtin_string_contains(lo, "i am unable") + || __builtin_string_contains(lo, "i'm unable") + || __builtin_string_contains(lo, "cannot assist") + || __builtin_string_contains(lo, "cannot help with") + || __builtin_string_contains(lo, "unable to help") +} + +// Cheap STRUCTURAL check that s could be JSON: after trimming, it starts with +// `{` and ends with `}`, or starts with `[` and ends with `]`. This does NOT +// parse or validate JSON — it will accept malformed input like "{ bad" -> no, +// (that ends with 'd'), but WILL accept "{not valid json}" and reject a valid +// bare JSON number/string/`true`. Treat a true result as "worth trying to +// parse", never as "this is valid JSON". For real validation, parse it. +fn is_probably_json(s: String) -> Bool { + let t: String = __builtin_string_trim(s) + let n: Int = __builtin_string_len(t) + if n < 2 { + false + } else { + let is_obj: Bool = __builtin_string_starts_with(t, "{") && __builtin_string_ends_with(t, "}") + let is_arr: Bool = __builtin_string_starts_with(t, "[") && __builtin_string_ends_with(t, "]") + is_obj || is_arr + } +} + +fn main() -> Int { + // Exercise every validator with at least one true and one false case. + let allow: List = ["approve", "reject"] + let ban: List = ["DROP TABLE"] + + let checks_true: Bool = is_non_empty("hello") + && min_length("hello", 3) + && max_length("hi", 5) + && length_between("abc", 1, 5) + && is_in_range("42", 0, 100) + && is_in_range_float("2.5", 0.0, 10.0) + && matches_prefix("prefix-body", "prefix-") + && matches_suffix("body.json", ".json") + && matches_contains("needle in haystack", "needle") + && is_one_of("approve", allow) + && not_in_banlist("safe value", ban) + && contains_ci("HELLO World", "hello") + && looks_like_refusal("I'm sorry, I cannot do that") + && is_probably_json("{\"ok\": true}") + + let checks_false: Bool = is_non_empty(" ") + || min_length("hi", 5) + || max_length("toolong", 3) + || is_in_range("not-a-number", 0, 100) + || is_in_range_float("abc", 0.0, 10.0) + || is_one_of("nope", allow) + || (not_in_banlist("DROP TABLE", ban) == false && false) + || looks_like_refusal("Here is your answer: 42") + || is_probably_json("just some prose") + + // checks_true must hold and checks_false must not; collapse to 0. + if checks_true && (checks_false == false) { + 0 + } else { + 1 + } +} diff --git a/orchestrator/src/audit/attestation.rs b/orchestrator/src/audit/attestation.rs new file mode 100644 index 0000000..9239695 --- /dev/null +++ b/orchestrator/src/audit/attestation.rs @@ -0,0 +1,653 @@ +//! Interop layer: emit an evidence bundle's provenance ALSO as a +//! standard in-toto Statement wrapped in a DSSE envelope, so Boruna +//! evidence is consumable by the supply-chain ecosystem (`cosign +//! verify-blob`, `in-toto-verify`) WITHOUT changing the native bundle +//! format. This is purely ADDITIVE — nothing here touches the manifest, +//! its `bundle_hash`, or the existing verify path. +//! +//! Two artifacts are produced from a finalized [`BundleManifest`]: +//! +//! 1. an **in-toto Statement v1** — `_type`, +//! `subject[]` (the run's component files by SHA-256, plus the bundle +//! itself keyed by `bundle_hash`), `predicateType` +//! (`https://boruna.dev/runtime-provenance/v1`), and a `predicate` +//! that maps the manifest fields into a SLSA-provenance-shaped +//! `buildDefinition` / `runDetails` structure; and +//! 2. a **DSSE envelope** wrapping that Statement, whose signature is an +//! ed25519 signature over the DSSE **PAE** pre-authentication +//! encoding of `(payloadType, payload)`. +//! +//! The signing key is the SAME ed25519 key used for manifest signing +//! (`EvidenceBundleBuilder::with_signing_key`); the `keyid` is the hex +//! public key, matching `ManifestSignature.public_key`. No new keypair +//! is introduced. +//! +//! Determinism: the Statement is serialized with canonical (sorted-key / +//! fixed field-order) JSON via `serde_json`, and those exact bytes are +//! what get base64-encoded into the payload and signed. Same run → +//! same manifest → same Statement bytes → same PAE → same signature. +//! +//! See `docs/spec/runtime-provenance-predicate-1.0.md` for the predicate +//! schema and a worked `cosign` / `in-toto-verify` example. + +use base64::Engine as _; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; + +use crate::audit::evidence::BundleManifest; +use crate::audit::fingerprint::EnvFingerprint; + +/// in-toto Statement `_type` for the v1 spec. +pub const STATEMENT_TYPE: &str = "https://in-toto.io/Statement/v1"; + +/// Boruna's runtime-provenance predicate type (versioned). +pub const PREDICATE_TYPE: &str = "https://boruna.dev/runtime-provenance/v1"; + +/// DSSE payload type for an in-toto Statement, per the in-toto spec. +pub const DSSE_PAYLOAD_TYPE: &str = "application/vnd.in-toto+json"; + +/// Build type recorded in the predicate's `buildDefinition.buildType`. +pub const BUILD_TYPE: &str = "https://boruna.dev/workflow-run/v1"; + +/// Errors from building or verifying an attestation. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AttestError { + /// The supplied signing seed was not 64 hex chars / 32 bytes. + BadSigningKey(String), + /// A DSSE artifact could not be (de)serialized. + Serialization(String), + /// The DSSE payload was not valid base64. + BadPayloadBase64(String), + /// The envelope's `payloadType` was not the expected in-toto type. + UnexpectedPayloadType { found: String }, + /// A signature's `sig` or `keyid` was malformed hex/base64. + BadSignatureEncoding(String), + /// The envelope had no signatures to verify. + NoSignatures, + /// ed25519 verification failed over the PAE for every signature. + SignatureInvalid, + /// A trusted key was pinned but no signature was made by it. + UntrustedKey { pinned: String }, +} + +impl std::fmt::Display for AttestError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + AttestError::BadSigningKey(m) => write!(f, "invalid signing key: {m}"), + AttestError::Serialization(m) => write!(f, "attestation serialization failed: {m}"), + AttestError::BadPayloadBase64(m) => write!(f, "DSSE payload is not valid base64: {m}"), + AttestError::UnexpectedPayloadType { found } => write!( + f, + "unexpected DSSE payloadType {found:?} (expected {DSSE_PAYLOAD_TYPE:?})" + ), + AttestError::BadSignatureEncoding(m) => write!(f, "bad signature encoding: {m}"), + AttestError::NoSignatures => write!(f, "DSSE envelope has no signatures"), + AttestError::SignatureInvalid => { + write!(f, "ed25519 signature does not verify over the DSSE PAE") + } + AttestError::UntrustedKey { pinned } => { + write!(f, "no signature was made by the pinned key {pinned}") + } + } + } +} + +impl std::error::Error for AttestError {} + +/// A single in-toto `subject`: a named artifact by digest. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct Subject { + pub name: String, + /// Digest algorithm → lowercase-hex digest. Always contains + /// `sha256`. `BTreeMap` for deterministic key ordering. + pub digest: BTreeMap, +} + +/// SLSA-shaped `buildDefinition` half of the predicate. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct BuildDefinition { + #[serde(rename = "buildType")] + pub build_type: String, + /// Operator-facing inputs that identify the run: the workflow and + /// policy the run was bound to (by content hash) plus the workflow + /// name. + #[serde(rename = "externalParameters")] + pub external_parameters: BTreeMap, + /// Environment fingerprint captured at finalize time. + #[serde(rename = "internalParameters")] + pub internal_parameters: InternalParameters, +} + +/// Internal (platform-captured) parameters for the run. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct InternalParameters { + #[serde(rename = "borunaVersion")] + pub boruna_version: String, + #[serde(rename = "envFingerprint")] + pub env_fingerprint: EnvFingerprint, +} + +/// SLSA-shaped `runDetails` half of the predicate. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct RunDetails { + pub builder: Builder, + pub metadata: RunMetadata, + /// Non-artifact outputs of the run that are still evidence: the + /// hash-chained audit-log hash and the native bundle hash. + /// `BTreeMap` for deterministic ordering. + pub byproducts: BTreeMap, +} + +/// Identifies the builder (the Boruna engine) that produced the run. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct Builder { + pub id: String, +} + +/// Run invocation metadata (SLSA `runDetails.metadata` shape). +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct RunMetadata { + #[serde(rename = "invocationId")] + pub invocation_id: String, + #[serde(rename = "startedOn")] + pub started_on: String, + #[serde(rename = "finishedOn")] + pub finished_on: String, +} + +/// The `predicate` body: a documented mapping of the manifest fields. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct RuntimeProvenancePredicate { + #[serde(rename = "buildDefinition")] + pub build_definition: BuildDefinition, + #[serde(rename = "runDetails")] + pub run_details: RunDetails, +} + +/// A full in-toto Statement (v1). +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct InTotoStatement { + #[serde(rename = "_type")] + pub type_: String, + pub subject: Vec, + #[serde(rename = "predicateType")] + pub predicate_type: String, + pub predicate: RuntimeProvenancePredicate, +} + +/// One DSSE signature. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct DsseSignature { + /// base64(ed25519 signature over `PAE(payloadType, payload)`). + pub sig: String, + /// Lowercase-hex ed25519 public key (matches + /// `ManifestSignature.public_key`). + pub keyid: String, +} + +/// A DSSE envelope wrapping a base64 in-toto Statement payload. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct DsseEnvelope { + /// base64(canonical Statement JSON bytes). + pub payload: String, + #[serde(rename = "payloadType")] + pub payload_type: String, + pub signatures: Vec, +} + +/// DSSE Pre-Authentication Encoding of `(payload_type, payload)`. +/// +/// `PAE(type, body) = "DSSEv1" SP LEN(type) SP type SP LEN(body) SP body` +/// where `SP` is a single ASCII space and `LEN` is the ASCII-decimal +/// byte length. This is the exact string that gets signed — it binds +/// the payload type into the signature so a signature over one type +/// cannot be replayed as another. See the DSSE spec (`protocol.md`). +pub fn pae(payload_type: &str, payload: &[u8]) -> Vec { + let mut out = Vec::with_capacity(payload.len() + payload_type.len() + 32); + out.extend_from_slice(b"DSSEv1 "); + out.extend_from_slice(payload_type.len().to_string().as_bytes()); + out.push(b' '); + out.extend_from_slice(payload_type.as_bytes()); + out.push(b' '); + out.extend_from_slice(payload.len().to_string().as_bytes()); + out.push(b' '); + out.extend_from_slice(payload); + out +} + +/// Build the in-toto Statement for a finalized bundle manifest. +/// +/// `subject[]` is every component file the manifest checksums (by its +/// SHA-256), plus a synthetic subject for the bundle itself keyed by +/// `bundle_hash`. The predicate maps the manifest's provenance fields +/// into a SLSA-shaped structure. Fully deterministic: `file_checksums` +/// is a `BTreeMap` so subjects come out in sorted-name order. +pub fn statement_from_manifest(manifest: &BundleManifest, boruna_version: &str) -> InTotoStatement { + let mut subject: Vec = manifest + .file_checksums + .iter() + .map(|(name, sha)| { + let mut digest = BTreeMap::new(); + digest.insert("sha256".to_string(), sha.clone()); + Subject { + name: name.clone(), + digest, + } + }) + .collect(); + // The bundle itself as a subject, keyed by its manifest bundle_hash. + { + let mut digest = BTreeMap::new(); + digest.insert("sha256".to_string(), manifest.bundle_hash.clone()); + subject.push(Subject { + name: format!("boruna-bundle:{}", manifest.run_id), + digest, + }); + } + + let mut external_parameters = BTreeMap::new(); + external_parameters.insert("workflowName".to_string(), manifest.workflow_name.clone()); + external_parameters.insert("workflowHash".to_string(), manifest.workflow_hash.clone()); + external_parameters.insert("policyHash".to_string(), manifest.policy_hash.clone()); + + let mut byproducts = BTreeMap::new(); + byproducts.insert("auditLogHash".to_string(), manifest.audit_log_hash.clone()); + byproducts.insert("bundleHash".to_string(), manifest.bundle_hash.clone()); + + InTotoStatement { + type_: STATEMENT_TYPE.to_string(), + subject, + predicate_type: PREDICATE_TYPE.to_string(), + predicate: RuntimeProvenancePredicate { + build_definition: BuildDefinition { + build_type: BUILD_TYPE.to_string(), + external_parameters, + internal_parameters: InternalParameters { + boruna_version: boruna_version.to_string(), + env_fingerprint: manifest.env_fingerprint.clone(), + }, + }, + run_details: RunDetails { + builder: Builder { + id: format!("https://boruna.dev/boruna@{boruna_version}"), + }, + metadata: RunMetadata { + invocation_id: manifest.run_id.clone(), + started_on: manifest.started_at.clone(), + finished_on: manifest.completed_at.clone(), + }, + byproducts, + }, + }, + } +} + +/// Serialize a Statement to its canonical (deterministic) JSON bytes. +/// +/// `serde_json` emits struct fields in declaration order and `BTreeMap` +/// keys in sorted order, so the output is byte-stable for a given +/// Statement — exactly what the DSSE payload/signature require. These +/// bytes (not a re-serialization) are what get base64-encoded into the +/// payload and fed through the PAE. +pub fn statement_to_canonical_bytes(statement: &InTotoStatement) -> Result, AttestError> { + serde_json::to_vec(statement).map_err(|e| AttestError::Serialization(e.to_string())) +} + +/// Parse a 32-byte ed25519 signing seed from 64 hex chars. +pub fn parse_seed_hex(hex: &str) -> Result<[u8; 32], AttestError> { + let hex = hex.trim(); + if hex.len() != 64 { + return Err(AttestError::BadSigningKey(format!( + "expected 64 hex chars (32 bytes), got {}", + hex.len() + ))); + } + let mut out = [0u8; 32]; + for (i, chunk) in hex.as_bytes().chunks(2).enumerate() { + let s = std::str::from_utf8(chunk) + .map_err(|_| AttestError::BadSigningKey("non-utf8".to_string()))?; + out[i] = u8::from_str_radix(s, 16) + .map_err(|_| AttestError::BadSigningKey("non-hex digit".to_string()))?; + } + Ok(out) +} + +/// Produce a signed DSSE envelope for a manifest, using the ed25519 key +/// derived from `signing_seed` (the SAME key machinery as manifest +/// signing). The signature is over `PAE(payloadType, payload)`; the +/// `keyid` is the hex public key. +pub fn attest( + manifest: &BundleManifest, + boruna_version: &str, + signing_seed: &[u8; 32], +) -> Result { + let statement = statement_from_manifest(manifest, boruna_version); + let payload_bytes = statement_to_canonical_bytes(&statement)?; + sign_statement_bytes(&payload_bytes, signing_seed) +} + +/// Sign already-serialized Statement bytes into a DSSE envelope. Split +/// out from [`attest`] so tests can exercise the PAE/signature path +/// against known payload bytes. +pub fn sign_statement_bytes( + payload_bytes: &[u8], + signing_seed: &[u8; 32], +) -> Result { + use ed25519_dalek::Signer; + let sk = ed25519_dalek::SigningKey::from_bytes(signing_seed); + let to_sign = pae(DSSE_PAYLOAD_TYPE, payload_bytes); + let sig = sk.sign(&to_sign); + + let b64 = base64::engine::general_purpose::STANDARD; + Ok(DsseEnvelope { + payload: b64.encode(payload_bytes), + payload_type: DSSE_PAYLOAD_TYPE.to_string(), + signatures: vec![DsseSignature { + sig: b64.encode(sig.to_bytes()), + keyid: to_hex(sk.verifying_key().as_bytes()), + }], + }) +} + +/// Verify a DSSE envelope: check the `payloadType`, then verify each +/// signature's ed25519 sig over `PAE(payloadType, payload)` using the +/// signature's own `keyid` as the public key. Returns the decoded +/// Statement on success. +/// +/// When `trusted_pubkey` is `Some`, verification additionally requires +/// that at least one VALID signature was made by that pinned key — +/// otherwise an attacker who re-signs a mutated payload with their own +/// key would pass. Without a pin, a valid self-consistent signature is +/// accepted (the caller vouches for the key out of band, e.g. via the +/// manifest's `signature.public_key`). +pub fn verify_envelope( + envelope: &DsseEnvelope, + trusted_pubkey: Option<&str>, +) -> Result { + if envelope.payload_type != DSSE_PAYLOAD_TYPE { + return Err(AttestError::UnexpectedPayloadType { + found: envelope.payload_type.clone(), + }); + } + if envelope.signatures.is_empty() { + return Err(AttestError::NoSignatures); + } + + let b64 = base64::engine::general_purpose::STANDARD; + let payload_bytes = b64 + .decode(envelope.payload.as_bytes()) + .map_err(|e| AttestError::BadPayloadBase64(e.to_string()))?; + let to_verify = pae(&envelope.payload_type, &payload_bytes); + + let mut any_valid = false; + let mut pinned_valid = false; + for s in &envelope.signatures { + let pk_bytes = match decode_hex_array::<32>(&s.keyid) { + Ok(b) => b, + Err(_) => continue, + }; + let sig_bytes = match b64.decode(s.sig.as_bytes()) { + Ok(b) if b.len() == 64 => { + let mut arr = [0u8; 64]; + arr.copy_from_slice(&b); + arr + } + _ => continue, + }; + let vk = match ed25519_dalek::VerifyingKey::from_bytes(&pk_bytes) { + Ok(vk) => vk, + Err(_) => continue, + }; + let signature = ed25519_dalek::Signature::from_bytes(&sig_bytes); + use ed25519_dalek::Verifier; + if vk.verify(&to_verify, &signature).is_ok() { + any_valid = true; + if let Some(pin) = trusted_pubkey { + if pin.eq_ignore_ascii_case(&s.keyid) { + pinned_valid = true; + } + } + } + } + + if !any_valid { + return Err(AttestError::SignatureInvalid); + } + if let Some(pin) = trusted_pubkey { + if !pinned_valid { + return Err(AttestError::UntrustedKey { + pinned: pin.to_string(), + }); + } + } + + let statement: InTotoStatement = serde_json::from_slice(&payload_bytes) + .map_err(|e| AttestError::Serialization(e.to_string()))?; + Ok(statement) +} + +/// Lowercase-hex encode bytes. +fn to_hex(bytes: &[u8]) -> String { + use std::fmt::Write; + let mut s = String::with_capacity(bytes.len() * 2); + for b in bytes { + let _ = write!(s, "{b:02x}"); + } + s +} + +/// Decode a fixed-length hex string into `[u8; N]`. +fn decode_hex_array(hex: &str) -> Result<[u8; N], String> { + if hex.len() != N * 2 { + return Err(format!("expected {} hex chars, got {}", N * 2, hex.len())); + } + let mut out = [0u8; N]; + for (i, chunk) in hex.as_bytes().chunks(2).enumerate() { + let s = std::str::from_utf8(chunk).map_err(|_| "non-utf8".to_string())?; + out[i] = u8::from_str_radix(s, 16).map_err(|_| "non-hex digit".to_string())?; + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::audit::evidence::EvidenceBundleBuilder; + use crate::audit::log::{AuditEvent, AuditLog}; + use std::path::Path; + + fn signing_seed(base: u8) -> [u8; 32] { + let mut s = [0u8; 32]; + for (i, b) in s.iter_mut().enumerate() { + *b = base.wrapping_add((i as u8).wrapping_mul(7)); + } + s + } + + fn pubkey_hex(seed: &[u8; 32]) -> String { + let sk = ed25519_dalek::SigningKey::from_bytes(seed); + to_hex(sk.verifying_key().as_bytes()) + } + + fn build_manifest(dir: &Path) -> BundleManifest { + let mut builder = EvidenceBundleBuilder::new(dir, "run-attest-001", "attest-test").unwrap(); + builder.add_workflow_def(r#"{"name":"test"}"#).unwrap(); + builder.add_policy(r#"{"default_allow":true}"#).unwrap(); + builder + .add_step_output("s1", "result", r#"{"value":1}"#) + .unwrap(); + let mut audit = AuditLog::new(); + audit.append(AuditEvent::WorkflowStarted { + workflow_hash: "abc".into(), + policy_hash: "def".into(), + }); + audit.append(AuditEvent::WorkflowCompleted { + result_hash: "res".into(), + total_duration_ms: 7, + }); + builder.finalize(&audit).unwrap() + } + + #[test] + fn pae_matches_dsse_spec_known_vector() { + // From the DSSE spec (protocol.md) worked example: + // payloadType = "http://example.com/HelloWorld" + // payload = "hello world" + // PAE = "DSSEv1 29 http://example.com/HelloWorld 11 hello world" + let got = pae("http://example.com/HelloWorld", b"hello world"); + assert_eq!( + String::from_utf8(got).unwrap(), + "DSSEv1 29 http://example.com/HelloWorld 11 hello world" + ); + } + + #[test] + fn pae_binds_payload_type_and_length() { + // Empty payload still encodes the "0" length token. + assert_eq!( + String::from_utf8(pae("application/vnd.in-toto+json", b"")).unwrap(), + "DSSEv1 28 application/vnd.in-toto+json 0 " + ); + } + + #[test] + fn statement_has_correct_types_and_subject_digests() { + let dir = tempfile::tempdir().unwrap(); + let manifest = build_manifest(dir.path()); + let stmt = statement_from_manifest(&manifest, "9.9.9"); + + assert_eq!(stmt.type_, STATEMENT_TYPE); + assert_eq!(stmt.predicate_type, PREDICATE_TYPE); + + // Every manifest file checksum appears as a subject sha256. + for (name, sha) in &manifest.file_checksums { + let subj = stmt + .subject + .iter() + .find(|s| &s.name == name) + .unwrap_or_else(|| panic!("missing subject {name}")); + assert_eq!(subj.digest.get("sha256"), Some(sha)); + } + // Plus the synthetic bundle subject keyed by bundle_hash. + let bundle_subj = stmt + .subject + .iter() + .find(|s| s.name == "boruna-bundle:run-attest-001") + .expect("bundle subject present"); + assert_eq!( + bundle_subj.digest.get("sha256"), + Some(&manifest.bundle_hash) + ); + + // Predicate maps the manifest fields. + let ext = &stmt.predicate.build_definition.external_parameters; + assert_eq!(ext.get("workflowHash"), Some(&manifest.workflow_hash)); + assert_eq!(ext.get("policyHash"), Some(&manifest.policy_hash)); + assert_eq!( + stmt.predicate.run_details.byproducts.get("auditLogHash"), + Some(&manifest.audit_log_hash) + ); + assert_eq!( + stmt.predicate.run_details.metadata.invocation_id, + manifest.run_id + ); + } + + #[test] + fn statement_round_trips_through_json() { + let dir = tempfile::tempdir().unwrap(); + let manifest = build_manifest(dir.path()); + let stmt = statement_from_manifest(&manifest, "1.2.3"); + let bytes = statement_to_canonical_bytes(&stmt).unwrap(); + let back: InTotoStatement = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(stmt, back); + } + + #[test] + fn statement_bytes_are_deterministic() { + // Same manifest → identical canonical bytes (→ identical sig). + let dir = tempfile::tempdir().unwrap(); + let manifest = build_manifest(dir.path()); + let a = statement_to_canonical_bytes(&statement_from_manifest(&manifest, "1.0.0")).unwrap(); + let b = statement_to_canonical_bytes(&statement_from_manifest(&manifest, "1.0.0")).unwrap(); + assert_eq!(a, b); + } + + #[test] + fn attest_then_verify_passes() { + let dir = tempfile::tempdir().unwrap(); + let manifest = build_manifest(dir.path()); + let seed = signing_seed(3); + let env = attest(&manifest, "1.0.0", &seed).unwrap(); + + assert_eq!(env.payload_type, DSSE_PAYLOAD_TYPE); + assert_eq!(env.signatures.len(), 1); + assert_eq!(env.signatures[0].keyid, pubkey_hex(&seed)); + + // Unpinned verify passes and returns the Statement. + let stmt = verify_envelope(&env, None).unwrap(); + assert_eq!(stmt.type_, STATEMENT_TYPE); + + // Pinned to the correct key passes. + let pk = pubkey_hex(&seed); + let stmt2 = verify_envelope(&env, Some(&pk)).unwrap(); + assert_eq!(stmt2, stmt); + } + + #[test] + fn verify_fails_on_mutated_payload() { + let dir = tempfile::tempdir().unwrap(); + let manifest = build_manifest(dir.path()); + let seed = signing_seed(3); + let mut env = attest(&manifest, "1.0.0", &seed).unwrap(); + + // Mutate the payload: decode, tamper a byte, re-encode. The + // signature is over the ORIGINAL payload's PAE, so it must fail. + let b64 = base64::engine::general_purpose::STANDARD; + let mut raw = b64.decode(env.payload.as_bytes()).unwrap(); + // Flip a byte well inside the JSON body. + raw[10] ^= 0xFF; + env.payload = b64.encode(&raw); + + let err = verify_envelope(&env, None).unwrap_err(); + assert_eq!(err, AttestError::SignatureInvalid); + } + + #[test] + fn verify_fails_on_wrong_pinned_key() { + let dir = tempfile::tempdir().unwrap(); + let manifest = build_manifest(dir.path()); + let seed = signing_seed(3); + let env = attest(&manifest, "1.0.0", &seed).unwrap(); + + let wrong = pubkey_hex(&signing_seed(42)); + let err = verify_envelope(&env, Some(&wrong)).unwrap_err(); + assert_eq!(err, AttestError::UntrustedKey { pinned: wrong }); + } + + #[test] + fn verify_rejects_wrong_payload_type() { + let dir = tempfile::tempdir().unwrap(); + let manifest = build_manifest(dir.path()); + let seed = signing_seed(3); + let mut env = attest(&manifest, "1.0.0", &seed).unwrap(); + env.payload_type = "application/json".to_string(); + let err = verify_envelope(&env, None).unwrap_err(); + assert!(matches!(err, AttestError::UnexpectedPayloadType { .. })); + } + + #[test] + fn parse_seed_hex_roundtrip_and_errors() { + let seed = signing_seed(5); + let hex = to_hex(&seed); + assert_eq!(parse_seed_hex(&hex).unwrap(), seed); + assert!(matches!( + parse_seed_hex("abc"), + Err(AttestError::BadSigningKey(_)) + )); + assert!(matches!( + parse_seed_hex(&"zz".repeat(32)), + Err(AttestError::BadSigningKey(_)) + )); + } +} diff --git a/orchestrator/src/audit/fingerprint.rs b/orchestrator/src/audit/fingerprint.rs index 1f987a3..d5e9f5d 100644 --- a/orchestrator/src/audit/fingerprint.rs +++ b/orchestrator/src/audit/fingerprint.rs @@ -1,7 +1,7 @@ use serde::{Deserialize, Serialize}; /// Environment fingerprint captured at runtime (no secrets). -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct EnvFingerprint { pub boruna_version: String, pub rust_version: String, diff --git a/orchestrator/src/audit/mod.rs b/orchestrator/src/audit/mod.rs index 291865e..61fe3d3 100644 --- a/orchestrator/src/audit/mod.rs +++ b/orchestrator/src/audit/mod.rs @@ -1,7 +1,10 @@ +pub mod attestation; pub mod encryption; pub mod evidence; pub mod fingerprint; pub mod log; +pub mod otel; +pub mod report; pub mod rotate; pub mod storage; #[cfg(feature = "azure")] @@ -19,6 +22,7 @@ pub use encryption::{ pub use evidence::*; pub use fingerprint::*; pub use log::*; +pub use report::{generate_report, ComplianceFramework, ReportFormat}; pub use verify::*; /// Evidence bundle format version emitted by the current build. diff --git a/orchestrator/src/audit/otel.rs b/orchestrator/src/audit/otel.rs new file mode 100644 index 0000000..48ca02c --- /dev/null +++ b/orchestrator/src/audit/otel.rs @@ -0,0 +1,794 @@ +//! OpenTelemetry (OTLP/JSON) export of an evidence bundle's execution. +//! +//! Turns a sealed run into a set of OpenTelemetry spans encoded in the +//! OTLP/JSON wire format — the exact shape any OTel collector ingests via +//! its OTLP/HTTP receiver. This is deliberately a **file emitter**, not an +//! SDK exporter: no `opentelemetry` crate, no async runtime, no network. +//! It keeps Boruna v3.0 local-only while still letting a run show up in the +//! observability stack a buyer already runs (Jaeger, Tempo, Honeycomb, +//! Datadog, …) simply by POSTing the emitted document, or piping it through +//! the collector's `otlpjson` file receiver. +//! +//! ## Why this matters: Boruna as the NOTARIZED upstream +//! +//! CRITICAL — the whole point of this exporter is the tamper-evidence +//! carried on the **root span's attributes**: +//! * `boruna.bundle_hash` — SHA-256 over the manifest (file checksums + +//! audit_log_hash). Recomputable by `boruna evidence verify`. +//! * `boruna.audit_log_hash` — head of the hash-chained audit log. +//! * `boruna.signature.keyid` — ed25519 public key (hex) that signed +//! `bundle_hash`, when the bundle is signed. +//! +//! A span in a buyer's tracing backend therefore links back to a +//! independently verifiable record: anyone can take these attributes, +//! re-run `evidence verify` against the bundle, and prove the trace was +//! produced by an untampered run. Boruna is the notarized source feeding +//! the observability graph — not just another emitter of unattested spans. +//! +//! ## Determinism +//! +//! Wall clocks and RNGs are unavailable in the deterministic core, and the +//! export must be byte-stable (same bundle → same bytes). So: +//! * trace/span IDs are derived from `sha256(run_id [":" index])` — never +//! random. The 16-byte trace id and 8-byte span ids are stable slices +//! of those digests. +//! * span start/end times are ordinal: a base nanosecond anchor (parsed +//! from the manifest's `started_at`, or 0 if unparseable) plus the +//! event index. They encode ordering, not measured latency. + +use serde::Serialize; +use sha2::{Digest, Sha256}; +use std::path::Path; + +use crate::audit::evidence::BundleManifest; +use boruna_vm::replay::{Event, EventLog}; + +/// Errors emitting an OTLP/JSON document from a bundle. +#[derive(Debug, thiserror::Error)] +pub enum OtelExportError { + #[error("cannot read {file}: {source}")] + Io { + file: String, + source: std::io::Error, + }, + #[error("invalid manifest.json: {0}")] + BadManifest(serde_json::Error), + #[error("invalid event_log.json: {0}")] + BadEventLog(String), + #[error("serialization failed: {0}")] + Serialize(serde_json::Error), +} + +/// OTLP/JSON `TracesData` document. +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct TracesData { + resource_spans: Vec, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct ResourceSpans { + resource: Resource, + scope_spans: Vec, +} + +#[derive(Serialize)] +struct Resource { + attributes: Vec, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct ScopeSpans { + scope: Scope, + spans: Vec, +} + +#[derive(Serialize)] +struct Scope { + name: String, + version: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct Span { + trace_id: String, + span_id: String, + /// Empty string for the root span (OTLP convention). + parent_span_id: String, + name: String, + /// SPAN_KIND_INTERNAL = 1, SPAN_KIND_CLIENT = 3. + kind: u32, + start_time_unix_nano: String, + end_time_unix_nano: String, + attributes: Vec, + events: Vec, + status: Status, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct SpanEvent { + time_unix_nano: String, + name: String, + attributes: Vec, +} + +#[derive(Serialize)] +struct Status { + /// STATUS_CODE_UNSET = 0, OK = 1, ERROR = 2. + code: u32, +} + +#[derive(Serialize)] +struct KeyValue { + key: String, + value: AnyValue, +} + +/// OTLP `AnyValue` oneof, JSON-encoded. int64 is a string per proto3 JSON. +#[derive(Serialize, Default)] +#[serde(rename_all = "camelCase")] +struct AnyValue { + #[serde(skip_serializing_if = "Option::is_none")] + string_value: Option, + #[serde(skip_serializing_if = "Option::is_none")] + bool_value: Option, + #[serde(skip_serializing_if = "Option::is_none")] + int_value: Option, +} + +fn str_val(s: impl Into) -> AnyValue { + AnyValue { + string_value: Some(s.into()), + ..Default::default() + } +} +fn bool_val(b: bool) -> AnyValue { + AnyValue { + bool_value: Some(b), + ..Default::default() + } +} +fn int_val(i: u64) -> AnyValue { + AnyValue { + int_value: Some(i.to_string()), + ..Default::default() + } +} +fn kv(key: &str, value: AnyValue) -> KeyValue { + KeyValue { + key: key.to_string(), + value, + } +} + +/// Read `bundle_dir` and emit its execution as an OTLP/JSON traces +/// document (pretty-printed, deterministic bytes). +/// +/// Root span: `boruna.run` carrying the run identity + tamper-evidence +/// attributes drawn from `manifest.json`. Child spans: one per VM event +/// in `event_log.json` (when present) — `llm.*` capability calls become +/// `gen_ai.*` spans (GenAI semantic conventions), other effects become +/// `boruna.capability` / `boruna.*` spans. `ContractCheck` events are +/// recorded as span events on the root span. +pub fn bundle_to_otlp_json(bundle_dir: &Path) -> Result { + let manifest_path = bundle_dir.join("manifest.json"); + let manifest_json = + std::fs::read_to_string(&manifest_path).map_err(|e| OtelExportError::Io { + file: "manifest.json".to_string(), + source: e, + })?; + let manifest: BundleManifest = + serde_json::from_str(&manifest_json).map_err(OtelExportError::BadManifest)?; + + // event_log.json is optional: a bundle sealed without a VM event log + // (e.g. `evidence create` from a persisted run) still exports a root + // span carrying the tamper-evidence anchor. + let event_log = match std::fs::read_to_string(bundle_dir.join("event_log.json")) { + Ok(s) => Some(EventLog::from_json(&s).map_err(OtelExportError::BadEventLog)?), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => None, + Err(e) => { + return Err(OtelExportError::Io { + file: "event_log.json".to_string(), + source: e, + }) + } + }; + + let doc = build_traces(&manifest, event_log.as_ref()); + serde_json::to_string_pretty(&doc).map_err(OtelExportError::Serialize) +} + +/// Base nanosecond anchor for ordinal span times: the manifest's +/// `started_at` parsed as RFC3339, or 0 when it can't be parsed. +fn base_nanos(manifest: &BundleManifest) -> u64 { + chrono::DateTime::parse_from_rfc3339(&manifest.started_at) + .ok() + .and_then(|dt| dt.timestamp_nanos_opt()) + .map(|n| n.max(0) as u64) + .unwrap_or(0) +} + +fn build_traces(manifest: &BundleManifest, event_log: Option<&EventLog>) -> TracesData { + let trace_id = trace_id_from(&manifest.run_id); + let root_span_id = span_id_from(&manifest.run_id, "root"); + let base = base_nanos(manifest); + + let events = event_log.map(|l| l.events()).unwrap_or(&[]); + // A CapResult consumed into its matching CapCall span does not get a + // span of its own — track which indices were folded in. + let mut consumed = vec![false; events.len()]; + + // ContractCheck events fold onto the root span as span events; child + // spans come from every other VM event. + let mut root_events: Vec = Vec::new(); + let mut child_spans: Vec = Vec::new(); + + for (i, ev) in events.iter().enumerate() { + if consumed[i] { + continue; + } + match ev { + Event::ContractCheck { + function, + kind, + index, + passed, + } => { + root_events.push(SpanEvent { + time_unix_nano: (base + i as u64).to_string(), + name: "boruna.contract_check".to_string(), + attributes: vec![ + kv("boruna.contract.function", str_val(function.clone())), + kv("boruna.contract.kind", str_val(kind.clone())), + kv("boruna.contract.index", int_val(*index as u64)), + kv("boruna.contract.passed", bool_val(*passed)), + ], + }); + } + Event::CapCall { capability, args } => { + // Fold the next matching, not-yet-consumed CapResult into + // this span so one operation = one span. + let result_idx = events + .iter() + .enumerate() + .skip(i + 1) + .find_map(|(j, e)| match e { + Event::CapResult { capability: c, .. } + if c == capability && !consumed[j] => + { + Some(j) + } + _ => None, + }); + let mut passed = true; + if let Some(j) = result_idx { + consumed[j] = true; + if let Event::CapResult { result, .. } = &events[j] { + // A Value::Err result marks the operation failed. + passed = !matches!(result, boruna_bytecode::Value::Err(_)); + } + } + child_spans.push(capability_span( + &trace_id, + &root_span_id, + &manifest.run_id, + i, + base, + capability, + args.len(), + passed, + )); + } + Event::CapResult { capability, result } => { + // An unpaired CapResult (no preceding CapCall) still gets a + // span so the trace loses nothing. + let passed = !matches!(result, boruna_bytecode::Value::Err(_)); + child_spans.push(generic_span( + &trace_id, + &root_span_id, + &manifest.run_id, + i, + base, + "boruna.capability_result", + vec![kv("boruna.capability", str_val(capability.clone()))], + passed, + )); + } + Event::ActorSpawn { actor_id, function } => { + child_spans.push(generic_span( + &trace_id, + &root_span_id, + &manifest.run_id, + i, + base, + "boruna.actor_spawn", + vec![ + kv("boruna.actor.id", int_val(*actor_id)), + kv("boruna.actor.function", str_val(function.clone())), + ], + true, + )); + } + Event::MessageSend { from, to, .. } => { + child_spans.push(generic_span( + &trace_id, + &root_span_id, + &manifest.run_id, + i, + base, + "boruna.message_send", + vec![ + kv("boruna.message.from", int_val(*from)), + kv("boruna.message.to", int_val(*to)), + ], + true, + )); + } + Event::MessageReceive { actor_id, .. } => { + child_spans.push(generic_span( + &trace_id, + &root_span_id, + &manifest.run_id, + i, + base, + "boruna.message_receive", + vec![kv("boruna.actor.id", int_val(*actor_id))], + true, + )); + } + Event::UiEmit { .. } => { + child_spans.push(generic_span( + &trace_id, + &root_span_id, + &manifest.run_id, + i, + base, + "boruna.ui_emit", + vec![], + true, + )); + } + Event::SchedulerTick { + round, + active_actor, + } => { + child_spans.push(generic_span( + &trace_id, + &root_span_id, + &manifest.run_id, + i, + base, + "boruna.scheduler_tick", + vec![ + kv("boruna.scheduler.round", int_val(*round)), + kv("boruna.scheduler.active_actor", int_val(*active_actor)), + ], + true, + )); + } + } + } + + let root = Span { + trace_id, + span_id: root_span_id, + parent_span_id: String::new(), + name: "boruna.run".to_string(), + kind: 1, // INTERNAL + start_time_unix_nano: base.to_string(), + // Root ends after the last event so children nest inside it. + end_time_unix_nano: (base + events.len() as u64 + 1).to_string(), + attributes: root_attributes(manifest), + events: root_events, + status: Status { code: 1 }, + }; + + // Root first, then children (their parentSpanId points back at root). + let mut spans = Vec::with_capacity(1 + child_spans.len()); + spans.push(root); + spans.extend(child_spans); + + let version = env!("CARGO_PKG_VERSION").to_string(); + TracesData { + resource_spans: vec![ResourceSpans { + resource: Resource { + attributes: vec![ + kv("service.name", str_val("boruna")), + kv("service.version", str_val(version.clone())), + kv("boruna.run_id", str_val(manifest.run_id.clone())), + ], + }, + scope_spans: vec![ScopeSpans { + scope: Scope { + name: "boruna.evidence".to_string(), + version, + }, + spans, + }], + }], + } +} + +/// Tamper-evidence + identity attributes on the root span. This is the +/// span that links the trace back to a verifiable record — see the module +/// docs. `boruna.signature.keyid` is emitted only for signed bundles. +fn root_attributes(manifest: &BundleManifest) -> Vec { + let mut attrs = vec![ + kv("boruna.run_id", str_val(manifest.run_id.clone())), + kv( + "boruna.workflow_name", + str_val(manifest.workflow_name.clone()), + ), + kv( + "boruna.workflow_hash", + str_val(manifest.workflow_hash.clone()), + ), + kv("boruna.policy_hash", str_val(manifest.policy_hash.clone())), + // --- tamper-evidence: recomputable/verifiable by `evidence verify` --- + kv("boruna.bundle_hash", str_val(manifest.bundle_hash.clone())), + kv( + "boruna.audit_log_hash", + str_val(manifest.audit_log_hash.clone()), + ), + ]; + if let Some(sig) = &manifest.signature { + attrs.push(kv( + "boruna.signature.algorithm", + str_val(sig.algorithm.clone()), + )); + // The public key is the key id a verifier pins with + // `evidence verify --verify-key`. + attrs.push(kv( + "boruna.signature.keyid", + str_val(sig.public_key.clone()), + )); + } + attrs +} + +/// A capability-call span. `llm.*` capabilities map to the OTel GenAI +/// semantic conventions (`gen_ai.*`); everything else is a generic +/// `boruna.capability` span. +#[allow(clippy::too_many_arguments)] +fn capability_span( + trace_id: &str, + parent: &str, + run_id: &str, + index: usize, + base: u64, + capability: &str, + args_count: usize, + passed: bool, +) -> Span { + let mut attributes = vec![ + kv("boruna.capability", str_val(capability.to_string())), + kv("boruna.capability.args_count", int_val(args_count as u64)), + ]; + let (name, kind) = if let Some(op) = gen_ai_operation(capability) { + // GenAI semantic conventions: gen_ai.system + gen_ai.operation.name. + attributes.push(kv("gen_ai.system", str_val("boruna"))); + attributes.push(kv("gen_ai.operation.name", str_val(op.clone()))); + (format!("gen_ai.{op}"), 3u32) // CLIENT + } else { + ("boruna.capability".to_string(), 1u32) // INTERNAL + }; + Span { + trace_id: trace_id.to_string(), + span_id: span_id_from(run_id, &index.to_string()), + parent_span_id: parent.to_string(), + name, + kind, + start_time_unix_nano: (base + index as u64).to_string(), + end_time_unix_nano: (base + index as u64 + 1).to_string(), + attributes, + events: Vec::new(), + status: Status { + code: if passed { 1 } else { 2 }, + }, + } +} + +#[allow(clippy::too_many_arguments)] +fn generic_span( + trace_id: &str, + parent: &str, + run_id: &str, + index: usize, + base: u64, + name: &str, + attributes: Vec, + passed: bool, +) -> Span { + Span { + trace_id: trace_id.to_string(), + span_id: span_id_from(run_id, &index.to_string()), + parent_span_id: parent.to_string(), + name: name.to_string(), + kind: 1, // INTERNAL + start_time_unix_nano: (base + index as u64).to_string(), + end_time_unix_nano: (base + index as u64 + 1).to_string(), + attributes, + events: Vec::new(), + status: Status { + code: if passed { 1 } else { 2 }, + }, + } +} + +/// Map an `llm.*` capability to a GenAI `gen_ai.operation.name`. Returns +/// `None` for non-LLM capabilities. The operation is normalized toward the +/// GenAI convention's vocabulary where the suffix is recognizable, else the +/// raw suffix is passed through. +fn gen_ai_operation(capability: &str) -> Option { + let suffix = capability.strip_prefix("llm.")?; + let op = match suffix { + "complete" | "completion" | "completions" => "text_completion", + // `llm.call` is Boruna's single generic LLM capability; map it to + // the convention's most common operation. + "chat" | "chat_completion" | "call" => "chat", + "embed" | "embedding" | "embeddings" => "embeddings", + "" => "chat", + other => other, + }; + Some(op.to_string()) +} + +/// 16-byte (32 hex) trace id derived from the run id. Deterministic, never +/// random — see module docs. +fn trace_id_from(run_id: &str) -> String { + let digest = Sha256::digest(run_id.as_bytes()); + to_hex(&digest[..16]) +} + +/// 8-byte (16 hex) span id derived from `run_id` + a per-span tag (the +/// event index, or "root"). Deterministic. +fn span_id_from(run_id: &str, tag: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(run_id.as_bytes()); + hasher.update(b":"); + hasher.update(tag.as_bytes()); + let digest = hasher.finalize(); + to_hex(&digest[..8]) +} + +fn to_hex(bytes: &[u8]) -> String { + use std::fmt::Write; + let mut s = String::with_capacity(bytes.len() * 2); + for b in bytes { + let _ = write!(s, "{b:02x}"); + } + s +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::audit::evidence::EvidenceBundleBuilder; + use crate::audit::log::AuditLog; + use boruna_bytecode::{Capability, ContractKind, Value}; + use boruna_vm::replay::EventLog; + + /// Build a bundle on disk carrying a VM event log with a mix of + /// events, including an `llm.*` capability call. Returns the bundle + /// directory path (inside `dir`). + fn make_bundle(dir: &Path, run_id: &str) -> std::path::PathBuf { + let mut log = EventLog::new(); + // an LLM capability call + result → should become a gen_ai span + log.log_cap_call(&Capability::LlmCall, &[Value::String("prompt".into())]); + log.log_cap_result(&Capability::LlmCall, &Value::String("answer".into())); + // a non-LLM capability → generic boruna.capability span + log.log_cap_call( + &Capability::NetFetch, + &[Value::String("https://example.com".into())], + ); + log.log_cap_result(&Capability::NetFetch, &Value::String("".into())); + let event_log_json = log.to_json().unwrap(); + + let mut builder = EvidenceBundleBuilder::new(dir, run_id, "otel-wf").unwrap(); + builder.add_workflow_def(r#"{"name":"otel"}"#).unwrap(); + builder.add_policy(r#"{"default_allow":true}"#).unwrap(); + builder.add_file("event_log.json", &event_log_json).unwrap(); + + let mut audit = AuditLog::new(); + audit.append(crate::audit::log::AuditEvent::WorkflowStarted { + workflow_hash: "wfh".into(), + policy_hash: "plh".into(), + }); + audit.append(crate::audit::log::AuditEvent::WorkflowCompleted { + result_hash: "res".into(), + total_duration_ms: 5, + }); + builder.finalize(&audit).unwrap(); + dir.join(run_id) + } + + fn parse(json: &str) -> serde_json::Value { + serde_json::from_str(json).unwrap() + } + + /// Collect (name, attributes-map) for every span in the document. + fn spans(doc: &serde_json::Value) -> Vec { + doc["resourceSpans"][0]["scopeSpans"][0]["spans"] + .as_array() + .unwrap() + .clone() + } + + fn attr<'a>(span: &'a serde_json::Value, key: &str) -> Option<&'a serde_json::Value> { + span["attributes"] + .as_array()? + .iter() + .find(|kv| kv["key"] == key) + .map(|kv| &kv["value"]) + } + + #[test] + fn root_span_carries_identity_and_tamper_evidence() { + let dir = tempfile::tempdir().unwrap(); + let bundle = make_bundle(dir.path(), "run-otel-001"); + let out = bundle_to_otlp_json(&bundle).unwrap(); + let doc = parse(&out); + let spans = spans(&doc); + + let root = &spans[0]; + assert_eq!(root["name"], "boruna.run"); + assert_eq!(root["parentSpanId"], ""); + // trace id is 32 hex chars, span id 16 hex chars + assert_eq!(root["traceId"].as_str().unwrap().len(), 32); + assert_eq!(root["spanId"].as_str().unwrap().len(), 16); + + // run_id present + assert_eq!( + attr(root, "boruna.run_id").unwrap()["stringValue"], + "run-otel-001" + ); + // the tamper-evidence anchors are present and non-empty + let bundle_hash = attr(root, "boruna.bundle_hash").unwrap()["stringValue"] + .as_str() + .unwrap() + .to_string(); + let audit_hash = attr(root, "boruna.audit_log_hash").unwrap()["stringValue"] + .as_str() + .unwrap() + .to_string(); + assert_eq!(bundle_hash.len(), 64); + assert_eq!(audit_hash.len(), 64); + + // and they match the manifest exactly (the span links back to a + // verifiable record). + let manifest: BundleManifest = + serde_json::from_str(&std::fs::read_to_string(bundle.join("manifest.json")).unwrap()) + .unwrap(); + assert_eq!(bundle_hash, manifest.bundle_hash); + assert_eq!(audit_hash, manifest.audit_log_hash); + } + + #[test] + fn capability_events_produce_child_spans() { + let dir = tempfile::tempdir().unwrap(); + let bundle = make_bundle(dir.path(), "run-otel-002"); + let doc = parse(&bundle_to_otlp_json(&bundle).unwrap()); + let spans = spans(&doc); + + // root + 2 capability spans (each CapCall folds its CapResult). + assert_eq!(spans.len(), 3, "expected root + 2 capability spans"); + + // every child points at the root + let root_id = spans[0]["spanId"].as_str().unwrap(); + for child in &spans[1..] { + assert_eq!(child["parentSpanId"], root_id); + } + + // the non-LLM call is a generic boruna.capability span + let net = spans + .iter() + .find(|s| { + attr(s, "boruna.capability").map(|v| v["stringValue"] == "net.fetch") == Some(true) + }) + .unwrap(); + assert_eq!(net["name"], "boruna.capability"); + } + + #[test] + fn llm_capability_produces_gen_ai_span() { + let dir = tempfile::tempdir().unwrap(); + let bundle = make_bundle(dir.path(), "run-otel-003"); + let doc = parse(&bundle_to_otlp_json(&bundle).unwrap()); + let spans = spans(&doc); + + let genai = spans + .iter() + .find(|s| s["name"].as_str().map(|n| n.starts_with("gen_ai.")) == Some(true)) + .expect("an llm.* call must produce a gen_ai.* span"); + + assert_eq!(genai["name"], "gen_ai.chat"); + assert_eq!( + attr(genai, "gen_ai.operation.name").unwrap()["stringValue"], + "chat" + ); + assert_eq!( + attr(genai, "gen_ai.system").unwrap()["stringValue"], + "boruna" + ); + // GenAI spans are CLIENT kind + assert_eq!(genai["kind"], 3); + } + + #[test] + fn contract_checks_become_root_span_events() { + // Build a bundle whose event log includes a ContractCheck. + let dir = tempfile::tempdir().unwrap(); + let mut log = EventLog::new(); + log.log_contract_check("main", ContractKind::Requires, 0, true); + let mut builder = EvidenceBundleBuilder::new(dir.path(), "run-otel-004", "wf").unwrap(); + builder + .add_file("event_log.json", &log.to_json().unwrap()) + .unwrap(); + builder.finalize(&AuditLog::new()).unwrap(); + + let doc = parse(&bundle_to_otlp_json(&dir.path().join("run-otel-004")).unwrap()); + let root = &spans(&doc)[0]; + let events = root["events"].as_array().unwrap(); + assert_eq!(events.len(), 1); + assert_eq!(events[0]["name"], "boruna.contract_check"); + let a = &events[0]["attributes"]; + let function = a + .as_array() + .unwrap() + .iter() + .find(|kv| kv["key"] == "boruna.contract.function") + .unwrap(); + assert_eq!(function["value"]["stringValue"], "main"); + } + + #[test] + fn signed_bundle_emits_signature_keyid() { + let dir = tempfile::tempdir().unwrap(); + let mut log = EventLog::new(); + log.log_cap_call(&Capability::LlmCall, &[]); + let mut builder = EvidenceBundleBuilder::new(dir.path(), "run-otel-005", "wf") + .unwrap() + .with_signing_key(&[7u8; 32]); + builder + .add_file("event_log.json", &log.to_json().unwrap()) + .unwrap(); + builder.finalize(&AuditLog::new()).unwrap(); + + let doc = parse(&bundle_to_otlp_json(&dir.path().join("run-otel-005")).unwrap()); + let root = &spans(&doc)[0]; + let keyid = attr(root, "boruna.signature.keyid").expect("signed bundle emits keyid"); + assert_eq!(keyid["stringValue"].as_str().unwrap().len(), 64); + assert_eq!( + attr(root, "boruna.signature.algorithm").unwrap()["stringValue"], + "ed25519" + ); + } + + #[test] + fn output_is_deterministic() { + let dir = tempfile::tempdir().unwrap(); + let bundle = make_bundle(dir.path(), "run-otel-006"); + let a = bundle_to_otlp_json(&bundle).unwrap(); + let b = bundle_to_otlp_json(&bundle).unwrap(); + assert_eq!(a, b, "same bundle must export byte-identical OTLP/JSON"); + } + + #[test] + fn bundle_without_event_log_still_exports_root() { + // No event_log.json → root span only, still carrying the anchors. + let dir = tempfile::tempdir().unwrap(); + let mut builder = EvidenceBundleBuilder::new(dir.path(), "run-otel-007", "wf").unwrap(); + builder.add_workflow_def(r#"{"name":"x"}"#).unwrap(); + builder.finalize(&AuditLog::new()).unwrap(); + + let doc = parse(&bundle_to_otlp_json(&dir.path().join("run-otel-007")).unwrap()); + let spans = spans(&doc); + assert_eq!(spans.len(), 1); + assert_eq!(spans[0]["name"], "boruna.run"); + assert!(attr(&spans[0], "boruna.bundle_hash").is_some()); + } +} diff --git a/orchestrator/src/audit/report.rs b/orchestrator/src/audit/report.rs new file mode 100644 index 0000000..a321580 --- /dev/null +++ b/orchestrator/src/audit/report.rs @@ -0,0 +1,885 @@ +//! Compliance evidence-mapping reports (`boruna evidence report`). +//! +//! Turns the machine-facts already inside an evidence bundle (run id, +//! hash-chained audit log, policy/workflow hashes, env fingerprint, +//! signature) into a HUMAN-READABLE mapping from each present artifact +//! to the specific regulatory obligation it helps satisfy. +//! +//! This is deliberately NOT a certificate of compliance. It is a +//! *technical mapping*: "here is the record, and here is the obligation +//! text it speaks to." Obligations the bundle does NOT cover are flagged +//! loudly (e.g. retention metadata, which the bundle format does not +//! carry). The report ALWAYS runs `verify_bundle` first — a mapping over +//! a tampered or unverifiable bundle is worse than useless, so the +//! verification verdict is stamped at the top and any failure is shown +//! prominently. + +use std::path::Path; + +use crate::audit::evidence::BundleManifest; +use crate::audit::log::{AuditEvent, AuditLog}; +use crate::audit::verify::verify_bundle; + +/// Regulatory framework the report maps evidence against. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ComplianceFramework { + /// EU AI Act (Regulation (EU) 2024/1689) record-keeping obligations. + EuAiAct, + /// NIST AI Risk Management Framework 1.0. + Nist, + /// ISO/IEC 42001:2023 AI management system. + Iso42001, +} + +impl ComplianceFramework { + /// Parse the CLI `--framework` value. + pub fn parse(s: &str) -> Result { + match s.trim().to_ascii_lowercase().as_str() { + "eu-ai-act" | "eu_ai_act" | "euaiact" => Ok(ComplianceFramework::EuAiAct), + "nist" | "nist-ai-rmf" => Ok(ComplianceFramework::Nist), + "iso42001" | "iso-42001" | "iso" => Ok(ComplianceFramework::Iso42001), + other => Err(format!( + "unknown framework {other:?} (expected: eu-ai-act | nist | iso42001)" + )), + } + } + + fn title(self) -> &'static str { + match self { + ComplianceFramework::EuAiAct => "EU AI Act (Regulation (EU) 2024/1689)", + ComplianceFramework::Nist => "NIST AI Risk Management Framework 1.0", + ComplianceFramework::Iso42001 => "ISO/IEC 42001:2023", + } + } +} + +/// Output rendering for a report. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ReportFormat { + Markdown, + Html, +} + +impl ReportFormat { + /// Parse the CLI `--format` value. + pub fn parse(s: &str) -> Result { + match s.trim().to_ascii_lowercase().as_str() { + "md" | "markdown" => Ok(ReportFormat::Markdown), + "html" => Ok(ReportFormat::Html), + other => Err(format!("unknown format {other:?} (expected: md | html)")), + } + } +} + +/// How well the recorded evidence speaks to an obligation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Coverage { + /// The bundle provides a record that directly satisfies this obligation. + Provided, + /// The bundle covers part of the obligation; the rest is out of scope + /// for what a sealed run-artifact can attest. + Partial, + /// The bundle carries no evidence for this obligation. + NotCovered, + /// The obligation does not apply to this particular run. + NotApplicable, +} + +impl Coverage { + fn label(self) -> &'static str { + match self { + Coverage::Provided => "EVIDENCE PROVIDED", + Coverage::Partial => "PARTIAL", + Coverage::NotCovered => "NOT COVERED", + Coverage::NotApplicable => "NOT APPLICABLE", + } + } +} + +/// One obligation row: the regulatory reference, what the bundle provides +/// for it (with the actual run's hashes), and — critically — any gap. +struct Obligation { + reference: String, + title: String, + coverage: Coverage, + provides: String, + gap: Option, +} + +/// Facts distilled from a bundle's manifest + audit log, used to build the +/// obligation mapping. Absent components are recorded as `false`/`None` so +/// the mapping can flag them honestly. +struct BundleFacts { + run_id: String, + workflow_name: String, + workflow_hash: String, + policy_hash: String, + audit_log_hash: String, + bundle_hash: String, + started_at: String, + completed_at: String, + env_summary: String, + signed: bool, + signer_pubkey: Option, + encrypted: bool, + has_workflow: bool, + has_policy: bool, + has_outputs: bool, + has_intents: bool, + has_model_invocations: bool, + /// `None` when the audit log could not be parsed (e.g. encrypted and + /// undecryptable in report context). + audit_event_count: Option, + /// Human-readable approval-gate (human-oversight) records, if any. + approvals: Vec, +} + +impl BundleFacts { + fn from_bundle(bundle_dir: &Path, manifest: &BundleManifest) -> Self { + let (audit_event_count, approvals) = read_audit_facts(bundle_dir); + let env = &manifest.env_fingerprint; + BundleFacts { + run_id: manifest.run_id.clone(), + workflow_name: manifest.workflow_name.clone(), + workflow_hash: manifest.workflow_hash.clone(), + policy_hash: manifest.policy_hash.clone(), + audit_log_hash: manifest.audit_log_hash.clone(), + bundle_hash: manifest.bundle_hash.clone(), + started_at: manifest.started_at.clone(), + completed_at: manifest.completed_at.clone(), + env_summary: format!( + "{} on {}/{}, boruna {}", + env.rust_version, env.os, env.arch, env.boruna_version + ), + signed: manifest.signature.is_some(), + signer_pubkey: manifest.signature.as_ref().map(|s| s.public_key.clone()), + encrypted: manifest.encryption.is_some(), + has_workflow: bundle_dir.join("workflow.json").exists(), + has_policy: bundle_dir.join("policy.json").exists(), + has_outputs: bundle_dir.join("outputs").is_dir(), + has_intents: bundle_dir.join("intents.json").exists(), + has_model_invocations: bundle_dir.join("model_invoking_steps.json").exists(), + audit_event_count, + approvals, + } + } +} + +/// Read the audit log for the event count and any approval-gate records. +/// Degrades gracefully: on any read/parse failure returns `(None, [])`. +fn read_audit_facts(bundle_dir: &Path) -> (Option, Vec) { + let raw = match std::fs::read_to_string(bundle_dir.join("audit_log.json")) { + Ok(s) => s, + Err(_) => return (None, Vec::new()), + }; + let log = match AuditLog::from_json(&raw) { + Ok(l) => l, + Err(_) => return (None, Vec::new()), + }; + let mut approvals = Vec::new(); + for entry in log.entries() { + match &entry.event { + AuditEvent::ApprovalRequested { step_id, role } => { + approvals.push(format!( + "step `{step_id}`: approval requested from role `{role}`" + )); + } + AuditEvent::ApprovalGranted { step_id, approver } => { + approvals.push(format!( + "step `{step_id}`: approval GRANTED by `{approver}`" + )); + } + AuditEvent::ApprovalDenied { step_id, reason } => { + approvals.push(format!("step `{step_id}`: approval DENIED ({reason})")); + } + _ => {} + } + } + (Some(log.entries().len()), approvals) +} + +/// Generate a compliance evidence-mapping report for a bundle. +/// +/// The bundle is VERIFIED first; the verdict (and any errors) is stamped +/// at the top of the report. A tampered/unverifiable bundle still produces +/// a report — one that says so loudly — so an auditor is never handed a +/// clean-looking mapping over broken evidence. +/// +/// Returns `Err` only when the manifest itself cannot be read/parsed (there +/// is then nothing to map); a bundle that merely fails verification returns +/// `Ok` with the failure surfaced in the report body. +pub fn generate_report( + bundle_dir: &Path, + framework: ComplianceFramework, + format: ReportFormat, +) -> Result { + let manifest_path = bundle_dir.join("manifest.json"); + let manifest_json = std::fs::read_to_string(&manifest_path) + .map_err(|e| format!("cannot read manifest.json: {e}"))?; + let manifest: BundleManifest = + serde_json::from_str(&manifest_json).map_err(|e| format!("invalid manifest.json: {e}"))?; + + let verdict = verify_bundle(bundle_dir); + let facts = BundleFacts::from_bundle(bundle_dir, &manifest); + let obligations = match framework { + ComplianceFramework::EuAiAct => eu_ai_act_obligations(&facts), + ComplianceFramework::Nist => nist_obligations(&facts), + ComplianceFramework::Iso42001 => iso42001_obligations(&facts), + }; + + Ok(match format { + ReportFormat::Markdown => render_markdown(framework, &facts, &verdict, &obligations), + ReportFormat::Html => render_html(framework, &facts, &verdict, &obligations), + }) +} + +// ---- Obligation catalogues ------------------------------------------------ + +fn eu_ai_act_obligations(f: &BundleFacts) -> Vec { + let mut out = Vec::new(); + + // Art. 12(2)(a-c) — automatic recording of events (logging). + let events = f + .audit_event_count + .map(|n| n.to_string()) + .unwrap_or_else(|| "unavailable".to_string()); + out.push(Obligation { + reference: "Art. 12(2)(a–c)".to_string(), + title: "Automatic recording of events (logging) over the system's lifetime".to_string(), + coverage: if f.audit_event_count.is_some() { + Coverage::Provided + } else { + Coverage::NotCovered + }, + provides: format!( + "`audit_log.json` — a hash-chained (SHA-256) event log with {events} entries, \ + head hash `audit_log_hash = {}`. Each entry chains the previous entry's hash, so \ + any insertion, deletion, or edit is detectable. This is the automatically generated \ + record of events over the run's lifetime.", + f.audit_log_hash + ), + gap: if f.audit_event_count.is_none() { + Some( + "The audit log could not be read (bundle may be encrypted); the event record \ + cannot be summarised without the decryption key." + .to_string(), + ) + } else { + None + }, + }); + + // Art. 12(3) — Annex III logging content. + let period_ok = !f.started_at.is_empty() && !f.completed_at.is_empty(); + let persons_ok = !f.approvals.is_empty(); + let all_ok = period_ok && f.has_outputs && persons_ok; + let mut sub = Vec::new(); + sub.push(format!( + "- period of each use: recorded as `started_at = {}` .. `completed_at = {}` [{}]", + f.started_at, + f.completed_at, + if period_ok { "PROVIDED" } else { "MISSING" } + )); + sub.push(format!( + "- input data / records checked against: per-step outputs under `outputs/` [{}]", + if f.has_outputs { + "PROVIDED" + } else { + "NOT PRESENT" + } + )); + sub.push(format!( + "- natural persons verifying results: approval-gate records [{}]", + if persons_ok { + "PROVIDED" + } else { + "NONE RECORDED" + } + )); + out.push(Obligation { + reference: "Art. 12(3)".to_string(), + title: "Logging content for Annex III high-risk systems".to_string(), + coverage: if all_ok { + Coverage::Provided + } else { + Coverage::Partial + }, + provides: sub.join("\n"), + gap: if all_ok { + None + } else { + Some( + "Not every Annex III logging item is present in this run. Items marked MISSING / \ + NONE RECORDED are either not applicable to a fully-automated run (no human \ + verifier) or must be supplied by the deploying system — the bundle attests only \ + what the run actually recorded." + .to_string(), + ) + }, + }); + + // Art. 19 + Art. 26(6) — retention >= 6 months. The bundle format + // carries NO retention metadata, so this is always flagged. + out.push(Obligation { + reference: "Art. 19 & Art. 26(6)".to_string(), + title: "Automatic logs kept / retained for at least 6 months".to_string(), + coverage: Coverage::NotCovered, + provides: "The bundle is a sealed, tamper-evident snapshot but declares no retention \ + period or lifecycle policy." + .to_string(), + gap: Some( + "retention policy: NOT DECLARED — Art. 19 (provider) and Art. 26(6) (deployer) \ + require the automatically generated logs to be retained for a period appropriate to \ + the intended purpose, and at least 6 months unless other law provides otherwise. \ + Retention must be enforced by the operator's storage/lifecycle controls; it is NOT \ + attested by this evidence bundle." + .to_string(), + ), + }); + + // Art. 14 — human oversight (approval-gate records). + if f.approvals.is_empty() { + out.push(Obligation { + reference: "Art. 14".to_string(), + title: "Human oversight".to_string(), + coverage: Coverage::NotApplicable, + provides: "No approval-gate (human-oversight) events were recorded in this run's \ + audit log." + .to_string(), + gap: Some( + "If this system is subject to Art. 14 human-oversight requirements, the workflow \ + did not record a human approval gate. A fully-automated run cannot evidence \ + human oversight — add an approval step to capture it." + .to_string(), + ), + }); + } else { + out.push(Obligation { + reference: "Art. 14".to_string(), + title: "Human oversight".to_string(), + coverage: Coverage::Provided, + provides: format!( + "Approval-gate records in the audit log evidence human oversight:\n{}", + f.approvals + .iter() + .map(|a| format!("- {a}")) + .collect::>() + .join("\n") + ), + gap: None, + }); + } + + out +} + +fn nist_obligations(f: &BundleFacts) -> Vec { + let mut out = Vec::new(); + + let signer = f + .signer_pubkey + .as_deref() + .map(|k| format!(", ed25519-signed by `{k}`")) + .unwrap_or_default(); + out.push(Obligation { + reference: "MEASURE 2.x (2.8 / 2.9 / 2.11)".to_string(), + title: "Traceability, provenance, and the ability to inspect/audit AI system behaviour" + .to_string(), + coverage: Coverage::Provided, + provides: format!( + "The bundle is a replayable provenance record: `bundle_hash = {}`{}, workflow hash \ + `{}`, audit head `{}`, and an environment fingerprint ({}). Per-file SHA-256 \ + checksums plus the hash-chained log let a third party re-inspect exactly what ran \ + and confirm nothing changed.", + f.bundle_hash, signer, f.workflow_hash, f.audit_log_hash, f.env_summary + ), + gap: None, + }); + + out.push(Obligation { + reference: "MANAGE 2.x".to_string(), + title: "Documented policy / capability controls governing the system".to_string(), + coverage: if f.has_policy { + Coverage::Provided + } else { + Coverage::NotCovered + }, + provides: if f.has_policy { + format!( + "`policy.json` captures the capability policy in force during the run \ + (`policy_hash = {}`); capability decisions are recorded in the audit log.", + f.policy_hash + ) + } else { + "No `policy.json` component is present in this bundle.".to_string() + }, + gap: if f.has_policy { + None + } else { + Some( + "policy record: NOT PRESENT — MANAGE expects the governing controls to be \ + documented; this bundle carries no policy snapshot." + .to_string(), + ) + }, + }); + + out +} + +fn iso42001_obligations(f: &BundleFacts) -> Vec { + let mut out = Vec::new(); + + let integrity = if f.signed { + "sealed with a bundle hash AND an ed25519 signature" + } else { + "sealed with a bundle hash (unsigned)" + }; + out.push(Obligation { + reference: "Clause 7.5 / 8.1".to_string(), + title: "Control of documented information & operational records".to_string(), + coverage: if f.signed { + Coverage::Provided + } else { + Coverage::Partial + }, + provides: format!( + "The evidence bundle for run `{}` (workflow `{}`) is a controlled record: {}, \ + `bundle_hash = {}`. Its contents are protected against unintended alteration by \ + per-file checksums and the hash-chained audit log.{}", + f.run_id, + f.workflow_name, + integrity, + f.bundle_hash, + if f.encrypted { + " Contents are additionally encrypted at rest." + } else { + "" + } + ), + gap: if f.signed { + None + } else { + Some( + "The record is tamper-EVIDENT (hash-chained) but UNSIGNED — integrity rests on \ + an out-of-band anchor of `bundle_hash`. Sign the bundle (ed25519) to root record \ + integrity in an operator key rather than external anchoring." + .to_string(), + ) + }, + }); + + out +} + +// ---- Rendering ------------------------------------------------------------ + +const DISCLAIMER: &str = "This is a TECHNICAL EVIDENCE-MAPPING report, NOT a certificate of \ +compliance and NOT legal attestation. It maps artifacts present in a Boruna evidence bundle to the \ +regulatory obligations they help evidence; it does not assess whether the deploying organisation \ +meets those obligations. Determinism guarantees reproducibility GIVEN the recorded effects \ +(captured capability results) — it does NOT prove reproducibility of any underlying AI model's \ +outputs. Obligations flagged NOT COVERED / NOT DECLARED require controls outside this bundle. \ +Consult qualified counsel for a compliance determination."; + +fn render_markdown( + framework: ComplianceFramework, + f: &BundleFacts, + verdict: &crate::audit::verify::VerifyResult, + obligations: &[Obligation], +) -> String { + let mut s = String::new(); + s.push_str(&format!( + "# Compliance Evidence Mapping — {}\n\n", + framework.title() + )); + s.push_str(&format!("- **Run ID:** `{}`\n", f.run_id)); + s.push_str(&format!("- **Workflow:** `{}`\n", f.workflow_name)); + s.push_str(&format!("- **Bundle hash:** `{}`\n", f.bundle_hash)); + s.push_str(&format!("- **Audit log hash:** `{}`\n", f.audit_log_hash)); + s.push_str(&format!( + "- **Run window:** `{}` .. `{}`\n", + f.started_at, f.completed_at + )); + s.push_str(&format!("- **Environment:** {}\n", f.env_summary)); + s.push_str(&format!( + "- **Signature:** {}\n", + match &f.signer_pubkey { + Some(k) => format!("ed25519 `{k}`"), + None => "unsigned".to_string(), + } + )); + s.push('\n'); + + // Verification banner — loud on failure. + if verdict.valid { + s.push_str( + "## Verification: PASSED\n\nThe bundle passed integrity verification \ + (`verify_bundle`): checksums, hash chain, and required files are intact.\n\n", + ); + } else { + s.push_str("## Verification: FAILED\n\n"); + s.push_str( + "> WARNING: This bundle did NOT pass integrity verification. The mapping below is \ + over EVIDENCE THAT CANNOT BE TRUSTED. Do not rely on it until the errors are \ + resolved.\n\n", + ); + for e in &verdict.errors { + s.push_str(&format!("- `{e}`\n")); + } + s.push('\n'); + } + + s.push_str("## Disclaimer\n\n"); + s.push_str(DISCLAIMER); + s.push_str("\n\n"); + + s.push_str("## Obligation mapping\n\n"); + for o in obligations { + s.push_str(&format!( + "### {} — {}\n\n**Status:** {}\n\n**Evidence provided:**\n\n{}\n\n", + o.reference, + o.title, + o.coverage.label(), + o.provides + )); + if let Some(gap) = &o.gap { + s.push_str(&format!("**Gap / not covered:** {gap}\n\n")); + } + } + + s.push_str("---\n\n"); + s.push_str(&format!( + "_Generated by `boruna evidence report` over bundle `{}`. Components observed: \ + workflow={}, policy={}, outputs={}, intents={}, model_invoking_steps={}._\n", + f.run_id, + f.has_workflow, + f.has_policy, + f.has_outputs, + f.has_intents, + f.has_model_invocations + )); + s +} + +fn render_html( + framework: ComplianceFramework, + f: &BundleFacts, + verdict: &crate::audit::verify::VerifyResult, + obligations: &[Obligation], +) -> String { + let mut body = String::new(); + body.push_str(&format!( + "

Compliance Evidence Mapping — {}

\n", + esc(framework.title()) + )); + body.push_str("
    \n"); + body.push_str(&format!( + "
  • Run ID: {}
  • \n", + esc(&f.run_id) + )); + body.push_str(&format!( + "
  • Workflow: {}
  • \n", + esc(&f.workflow_name) + )); + body.push_str(&format!( + "
  • Bundle hash: {}
  • \n", + esc(&f.bundle_hash) + )); + body.push_str(&format!( + "
  • Audit log hash: {}
  • \n", + esc(&f.audit_log_hash) + )); + body.push_str(&format!( + "
  • Run window: {} .. {}
  • \n", + esc(&f.started_at), + esc(&f.completed_at) + )); + body.push_str(&format!( + "
  • Environment: {}
  • \n", + esc(&f.env_summary) + )); + let sig = match &f.signer_pubkey { + Some(k) => format!("ed25519 {}", esc(k)), + None => "unsigned".to_string(), + }; + body.push_str(&format!("
  • Signature: {sig}
  • \n")); + body.push_str("
\n"); + + if verdict.valid { + body.push_str( + "

Verification: PASSED

The bundle passed \ + integrity verification (checksums, hash chain, required files intact).

\n", + ); + } else { + body.push_str( + "

Verification: FAILED

WARNING: \ + This bundle did NOT pass integrity verification. The mapping below is over \ + evidence that cannot be trusted.

    \n", + ); + for e in &verdict.errors { + body.push_str(&format!("
  • {}
  • \n", esc(e))); + } + body.push_str("
\n"); + } + + body.push_str(&format!( + "

Disclaimer

{}

\n", + esc(DISCLAIMER) + )); + + body.push_str("

Obligation mapping

\n"); + for o in obligations { + let cls = match o.coverage { + Coverage::Provided => "provided", + Coverage::Partial => "partial", + Coverage::NotCovered => "notcovered", + Coverage::NotApplicable => "na", + }; + body.push_str(&format!( + "
\n

{} — {}

\n\ +

Status: {}

\n\ +

{}

\n", + cls, + esc(&o.reference), + esc(&o.title), + o.coverage.label(), + esc_multiline(&o.provides) + )); + if let Some(gap) = &o.gap { + body.push_str(&format!( + "

Gap / not covered: {}

\n", + esc(gap) + )); + } + body.push_str("
\n"); + } + + format!( + "\n\n\n\n\ + Compliance Evidence Mapping — {}\n\n\ + \n\n{}\n\n", + esc(&f.run_id), + HTML_STYLE, + body + ) +} + +const HTML_STYLE: &str = "body{font-family:system-ui,-apple-system,sans-serif;max-width:52rem;\ +margin:2rem auto;padding:0 1rem;line-height:1.5;color:#1a1a1a}\ +code{background:#f2f2f2;padding:.1em .3em;border-radius:3px;font-size:.9em;word-break:break-all}\ +.verify.pass{border-left:4px solid #2e7d32;background:#edf7ed;padding:.5rem 1rem}\ +.verify.fail{border-left:4px solid #c62828;background:#fdecea;padding:.5rem 1rem}\ +.disclaimer{border:1px solid #999;background:#fafafa;padding:.5rem 1rem;font-size:.9em}\ +section.ob{border:1px solid #ddd;border-radius:6px;padding:.5rem 1rem;margin:1rem 0}\ +section.ob.notcovered{border-color:#c62828}section.ob.partial{border-color:#f9a825}\ +section.ob.provided{border-color:#2e7d32}.status strong{text-transform:uppercase}\ +.gap{color:#a11}"; + +/// HTML-escape a string for safe interpolation into element content. +fn esc(s: &str) -> String { + s.replace('&', "&") + .replace('<', "<") + .replace('>', ">") +} + +/// Like `esc`, but turns newlines into `
` so multi-line `provides` +/// text keeps its line breaks in HTML. +fn esc_multiline(s: &str) -> String { + esc(s).replace('\n', "
\n") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::audit::evidence::EvidenceBundleBuilder; + use crate::audit::log::{AuditEvent, AuditLog}; + use std::path::Path; + + /// Build a plaintext bundle (no retention metadata, no approvals) that + /// verifies cleanly. Mirrors the constructions in verify.rs tests. + fn build_bundle(dir: &Path) -> BundleManifest { + let mut builder = EvidenceBundleBuilder::new(dir, "run-report-001", "report-wf").unwrap(); + builder.add_workflow_def(r#"{"name":"report"}"#).unwrap(); + builder.add_policy(r#"{"default_allow":true}"#).unwrap(); + builder + .add_step_output("s1", "result", r#"{"value":1}"#) + .unwrap(); + + let mut audit = AuditLog::new(); + audit.append(AuditEvent::WorkflowStarted { + workflow_hash: "abc".into(), + policy_hash: "def".into(), + }); + audit.append(AuditEvent::StepCompleted { + step_id: "s1".into(), + output_hash: "out".into(), + duration_ms: 5, + }); + audit.append(AuditEvent::WorkflowCompleted { + result_hash: "res".into(), + total_duration_ms: 6, + }); + builder.finalize(&audit).unwrap() + } + + #[test] + fn framework_and_format_parse() { + assert_eq!( + ComplianceFramework::parse("eu-ai-act").unwrap(), + ComplianceFramework::EuAiAct + ); + assert_eq!( + ComplianceFramework::parse("NIST").unwrap(), + ComplianceFramework::Nist + ); + assert_eq!( + ComplianceFramework::parse("iso42001").unwrap(), + ComplianceFramework::Iso42001 + ); + assert!(ComplianceFramework::parse("gdpr").is_err()); + assert_eq!(ReportFormat::parse("md").unwrap(), ReportFormat::Markdown); + assert_eq!(ReportFormat::parse("HTML").unwrap(), ReportFormat::Html); + assert!(ReportFormat::parse("pdf").is_err()); + } + + #[test] + fn eu_ai_act_report_maps_real_facts_and_flags_retention() { + let dir = tempfile::tempdir().unwrap(); + let manifest = build_bundle(dir.path()); + let bundle_dir = dir.path().join("run-report-001"); + + let report = generate_report( + &bundle_dir, + ComplianceFramework::EuAiAct, + ReportFormat::Markdown, + ) + .unwrap(); + + // Verified clean bundle. + assert!( + report.contains("Verification: PASSED"), + "expected PASSED, got:\n{report}" + ); + // The report cites the bundle's REAL identifiers. + assert!(report.contains("run-report-001"), "missing run_id"); + assert!( + report.contains(&manifest.audit_log_hash), + "missing real audit_log_hash" + ); + // Names the record-keeping article. + assert!(report.contains("Art. 12"), "missing Art. 12"); + // Flags the missing retention policy against Art. 19. + assert!( + report.contains("NOT DECLARED") && report.contains("Art. 19"), + "retention gap not flagged:\n{report}" + ); + // Honest about being a mapping, not a certificate. + assert!( + report.contains("NOT a certificate of compliance"), + "disclaimer missing" + ); + } + + #[test] + fn tampered_bundle_report_says_verification_failed() { + let dir = tempfile::tempdir().unwrap(); + build_bundle(dir.path()); + let bundle_dir = dir.path().join("run-report-001"); + + // Tamper a covered file: manifest still parses, but verify fails on + // the checksum mismatch. + std::fs::write(bundle_dir.join("workflow.json"), r#"{"name":"EVIL"}"#).unwrap(); + + let report = generate_report( + &bundle_dir, + ComplianceFramework::EuAiAct, + ReportFormat::Markdown, + ) + .unwrap(); + + assert!( + report.contains("Verification: FAILED"), + "tampered bundle must report FAILED:\n{report}" + ); + assert!( + report.contains("checksum mismatch"), + "expected the checksum error surfaced in the report" + ); + assert!( + report.contains("cannot be trusted") || report.contains("CANNOT BE TRUSTED"), + "expected a loud untrusted-evidence warning" + ); + } + + #[test] + fn nist_and_iso_reports_render() { + let dir = tempfile::tempdir().unwrap(); + build_bundle(dir.path()); + let bundle_dir = dir.path().join("run-report-001"); + + let nist = generate_report( + &bundle_dir, + ComplianceFramework::Nist, + ReportFormat::Markdown, + ) + .unwrap(); + assert!(nist.contains("MEASURE 2"), "NIST MEASURE mapping missing"); + assert!(nist.contains("MANAGE"), "NIST MANAGE mapping missing"); + + let iso = generate_report( + &bundle_dir, + ComplianceFramework::Iso42001, + ReportFormat::Html, + ) + .unwrap(); + assert!(iso.starts_with(""), "HTML doctype missing"); + assert!(iso.contains("Clause 7.5"), "ISO clause mapping missing"); + assert!(iso.contains("run-report-001"), "run_id missing in HTML"); + } + + #[test] + fn report_maps_human_oversight_when_approvals_present() { + let dir = tempfile::tempdir().unwrap(); + let mut builder = + EvidenceBundleBuilder::new(dir.path(), "run-report-appr", "appr-wf").unwrap(); + builder.add_workflow_def(r#"{"name":"appr"}"#).unwrap(); + builder.add_policy(r#"{"default_allow":true}"#).unwrap(); + let mut audit = AuditLog::new(); + audit.append(AuditEvent::ApprovalRequested { + step_id: "review".into(), + role: "compliance-officer".into(), + }); + audit.append(AuditEvent::ApprovalGranted { + step_id: "review".into(), + approver: "alice".into(), + }); + builder.finalize(&audit).unwrap(); + let bundle_dir = dir.path().join("run-report-appr"); + + let report = generate_report( + &bundle_dir, + ComplianceFramework::EuAiAct, + ReportFormat::Markdown, + ) + .unwrap(); + // Art. 14 human oversight is now evidenced, not N/A. + assert!(report.contains("Art. 14")); + assert!( + report.contains("GRANTED by `alice`"), + "approval record not surfaced:\n{report}" + ); + } + + #[test] + fn missing_manifest_is_an_error() { + let dir = tempfile::tempdir().unwrap(); + let err = generate_report( + dir.path(), + ComplianceFramework::EuAiAct, + ReportFormat::Markdown, + ) + .unwrap_err(); + assert!(err.contains("manifest.json"), "got: {err}"); + } +} diff --git a/tooling/src/stdlib/mod.rs b/tooling/src/stdlib/mod.rs index a79967a..ebe5cd8 100644 --- a/tooling/src/stdlib/mod.rs +++ b/tooling/src/stdlib/mod.rs @@ -238,6 +238,19 @@ mod tests { assert_eq!(result, 0); } + #[test] + fn test_std_guard_compiles() { + let src = load_library_source(&libs_dir(), "std-guard").unwrap(); + assert!(verify_compiles(&src).is_ok()); + } + + #[test] + fn test_std_guard_runs() { + let src = load_library_source(&libs_dir(), "std-guard").unwrap(); + let result = run_library(&src).unwrap(); + assert_eq!(result, 0); // all true-cases hold, all false-cases fail + } + // ── Determinism Tests ── #[test] @@ -299,4 +312,10 @@ mod tests { let src = load_library_source(&libs_dir(), "std-json").unwrap(); assert!(verify_determinism(&src).is_ok()); } + + #[test] + fn test_std_guard_determinism() { + let src = load_library_source(&libs_dir(), "std-guard").unwrap(); + assert!(verify_determinism(&src).is_ok()); + } } diff --git a/tooling/src/tests.rs b/tooling/src/tests.rs index 5cfc268..0fc7a8b 100644 --- a/tooling/src/tests.rs +++ b/tooling/src/tests.rs @@ -429,6 +429,7 @@ fn test_stdlib_all_compile_and_run() { "std-storage", "std-notifications", "std-testing", + "std-guard", ]; for name in &lib_names { let src = stdlib::load_library_source(&libs_dir, name) @@ -452,6 +453,7 @@ fn test_stdlib_determinism() { "std-db", "std-notifications", "std-testing", + "std-guard", ]; for name in &lib_names { let src = stdlib::load_library_source(&libs_dir, name)