Trusty: provenance library workspace (initial import)

This commit is contained in:
2026-09-09 18:31:00 -04:00
commit 693f3bef1e
73 changed files with 26295 additions and 0 deletions

View File

@@ -0,0 +1,19 @@
[package]
name = "trusty-policy-audit"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
rust-version.workspace = true
description = "Trusty-backed PolicyAuditor adapter. Routes every PolicyEvent (Allow + Deny) into a trusty append-only chain so the policy decision trail is tamper-evident, replayable, and externally verifiable."
[dependencies]
trusty-policy = { workspace = true }
trusty-sqlite = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
[dev-dependencies]
trusty-core = { workspace = true }
tempfile = "3"

View File

@@ -0,0 +1,262 @@
//! Trusty-backed `PolicyAuditor`.
//!
//! Closes the policy <-> chain loop: every `PolicyEvent` emitted by
//! `trusty_policy::PolicyEngine::evaluate` (Allow OR Deny) is appended
//! to a `Trusty` chain. The `entity_id` of the chain row is the policy
//! scope (e.g. `agent.executor.v1`), so each registered FSM gets its
//! own per-entity chain. Tamper-evidence, ordering, and external
//! verifiability are inherited from `trusty-sqlite`.
//!
//! # Why a separate crate
//!
//! `trusty-policy` deliberately has no dependency on `trusty-core` /
//! `trusty-sqlite` (library-not-platform rule). Wiring the two
//! together lives here so consumers who want only the policy gate
//! without an audit chain pay nothing.
//!
//! # Quickstart
//!
//! ```no_run
//! use trusty_policy::{Fsm, PolicyEngine, ScopeKey, Transition};
//! use trusty_sqlite::Trusty;
//! use trusty_core::SecretKey;
//! use trusty_policy_audit::TrustyAuditor;
//!
//! let trusty = Trusty::open_in_memory(SecretKey::new([7u8; 16])).unwrap();
//! let auditor = TrustyAuditor::new(trusty);
//!
//! let scope = ScopeKey::new("agent.executor.v1");
//! let mut fsm: Fsm<&'static str, &'static str> = Fsm::new("idle");
//! fsm.allow(Transition::new("idle", "claim", "claimed"));
//!
//! let mut engine = PolicyEngine::new(auditor);
//! engine.register(scope.clone(), fsm).unwrap();
//! let _ = engine.evaluate(&scope, &"idle", &"claim", Some("agent_001"));
//! // The chain row for entity_id="agent.executor.v1" now contains
//! // the PolicyEvent JSON.
//! ```
#![deny(clippy::all)]
use std::sync::{Arc, Mutex};
use trusty_policy::{PolicyAuditor, PolicyEvent};
use trusty_sqlite::Trusty;
/// Source-URL marker stamped on every chained policy row. Consumers
/// can filter by this to separate policy events from other domain
/// events that may share a chain.
pub const POLICY_SOURCE_URL: &str = "policy://trusty-policy/event";
/// Extraction-method tag used when calling `Trusty::append_with_method`.
/// Mirrors the existing trusty convention; consumers reading the chain
/// can recognize policy rows by `extraction_method = "policy"`.
pub const POLICY_EXTRACTION_METHOD: &str = "policy";
/// `PolicyAuditor` that appends each `PolicyEvent` to a `Trusty` chain.
/// The `Trusty` handle is held behind `Arc<Mutex<_>>` so the auditor
/// is `Send + 'static` (the trait bound `PolicyEngine::new` requires)
/// while still allowing the caller to share the same chain across
/// other writers if needed.
///
/// On audit failure (rare: disk full, schema corruption) the auditor
/// stores the most recent error in `last_error` and silently drops
/// the row. Callers who need hard-stop semantics on audit failure
/// should poll `last_error()` or wrap with their own checking layer.
/// We deliberately do NOT panic: a misbehaving audit sink must not
/// take down the policy engine during a deny path.
pub struct TrustyAuditor {
trusty: Arc<Mutex<Trusty>>,
last_error: Arc<Mutex<Option<String>>>,
}
impl TrustyAuditor {
/// Wrap a `Trusty` handle. The auditor takes ownership; if you
/// need shared access, build the auditor with [`Self::from_shared`].
pub fn new(trusty: Trusty) -> Self {
Self {
trusty: Arc::new(Mutex::new(trusty)),
last_error: Arc::new(Mutex::new(None)),
}
}
/// Wrap an already-shared `Trusty` so the same chain can be
/// appended-to by other writers.
pub fn from_shared(trusty: Arc<Mutex<Trusty>>) -> Self {
Self {
trusty,
last_error: Arc::new(Mutex::new(None)),
}
}
/// Most recent audit error, if any. Cleared by [`Self::clear_error`].
pub fn last_error(&self) -> Option<String> {
self.last_error.lock().ok().and_then(|g| g.clone())
}
pub fn clear_error(&self) {
if let Ok(mut g) = self.last_error.lock() {
*g = None;
}
}
/// Borrow the inner shared handle. Useful for the consumer to
/// run integrity / verification queries after the engine has
/// emitted events.
pub fn shared(&self) -> Arc<Mutex<Trusty>> {
Arc::clone(&self.trusty)
}
}
impl PolicyAuditor for TrustyAuditor {
fn audit(&mut self, event: &PolicyEvent) {
// Serialize the event as JSON. The chain row's `data` column
// is the canonical record; downstream verifiers re-parse it.
let payload = match serde_json::to_value(event) {
Ok(v) => v,
Err(e) => {
self.record_error(format!("serialize PolicyEvent: {e}"));
return;
}
};
// entity_id = scope. One chain per FSM scope.
let entity_id = event.scope.as_str();
let mut guard = match self.trusty.lock() {
Ok(g) => g,
Err(p) => {
self.record_error(format!("trusty mutex poisoned: {p}"));
return;
}
};
if let Err(e) = guard.append_with_method(
entity_id,
&payload,
POLICY_SOURCE_URL,
POLICY_EXTRACTION_METHOD,
) {
self.record_error(format!("append_with_method: {e}"));
}
}
}
impl TrustyAuditor {
fn record_error(&self, msg: String) {
if let Ok(mut g) = self.last_error.lock() {
*g = Some(msg);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use trusty_core::SecretKey;
use trusty_policy::{Fsm, PolicyEngine, ScopeKey, Transition};
fn key() -> SecretKey {
SecretKey::new([7u8; 16])
}
fn pipeline_fsm() -> Fsm<&'static str, &'static str> {
let mut f = Fsm::new("idle");
f.allow(Transition::new("idle", "claim", "claimed"))
.allow(Transition::new("claimed", "execute", "running"))
.allow(Transition::new("running", "finish", "done"));
f
}
#[test]
fn allow_emits_chain_row() {
let trusty = Trusty::open_in_memory(key()).unwrap();
let auditor = TrustyAuditor::new(trusty);
let shared = auditor.shared();
let scope = ScopeKey::new("agent.executor.v1");
let mut engine: PolicyEngine<&'static str, &'static str> = PolicyEngine::new(auditor);
engine.register(scope.clone(), pipeline_fsm()).unwrap();
let r = engine.evaluate(&scope, &"idle", &"claim", Some("agent_001"));
assert!(r.is_allow());
// Read back the chain via the shared handle. raw_payload is
// the full envelope; the PolicyEvent JSON lives at .data.
let guard = shared.lock().unwrap();
let history = guard.history("agent.executor.v1", 100).unwrap();
assert_eq!(history.len(), 1, "exactly one chained policy row");
let raw = history[0].raw_payload.as_ref().expect("payload present");
let envelope: serde_json::Value = serde_json::from_str(raw).unwrap();
let payload = &envelope["data"];
assert_eq!(payload["scope"], "agent.executor.v1");
assert_eq!(payload["from"], "\"idle\"");
assert_eq!(payload["action"], "\"claim\"");
assert_eq!(payload["actor"], "agent_001");
assert!(payload["result"].get("Allow").is_some());
}
#[test]
fn deny_also_emits_chain_row() {
let trusty = Trusty::open_in_memory(key()).unwrap();
let auditor = TrustyAuditor::new(trusty);
let shared = auditor.shared();
let scope = ScopeKey::new("agent.executor.v1");
let mut engine: PolicyEngine<&'static str, &'static str> = PolicyEngine::new(auditor);
engine.register(scope.clone(), pipeline_fsm()).unwrap();
// illegal: idle -> finish is undeclared
let r = engine.evaluate(&scope, &"idle", &"finish", None);
assert!(!r.is_allow());
let guard = shared.lock().unwrap();
let history = guard.history("agent.executor.v1", 100).unwrap();
assert_eq!(history.len(), 1, "deny is also chained");
let envelope: serde_json::Value =
serde_json::from_str(history[0].raw_payload.as_ref().unwrap()).unwrap();
assert!(envelope["data"]["result"].get("Deny").is_some());
}
#[test]
fn separate_scopes_get_separate_chains() {
let trusty = Trusty::open_in_memory(key()).unwrap();
let auditor = TrustyAuditor::new(trusty);
let shared = auditor.shared();
let s_exec = ScopeKey::new("agent.executor.v1");
let s_audit = ScopeKey::new("agent.auditor.v1");
let mut engine: PolicyEngine<&'static str, &'static str> = PolicyEngine::new(auditor);
engine.register(s_exec.clone(), pipeline_fsm()).unwrap();
engine.register(s_audit.clone(), pipeline_fsm()).unwrap();
let _ = engine.evaluate(&s_exec, &"idle", &"claim", None);
let _ = engine.evaluate(&s_audit, &"idle", &"claim", None);
let guard = shared.lock().unwrap();
assert_eq!(guard.history("agent.executor.v1", 100).unwrap().len(), 1);
assert_eq!(guard.history("agent.auditor.v1", 100).unwrap().len(), 1);
}
#[test]
fn chain_is_tamper_evident() {
let trusty = Trusty::open_in_memory(key()).unwrap();
let auditor = TrustyAuditor::new(trusty);
let shared = auditor.shared();
let scope = ScopeKey::new("agent.executor.v1");
let mut engine: PolicyEngine<&'static str, &'static str> = PolicyEngine::new(auditor);
engine.register(scope.clone(), pipeline_fsm()).unwrap();
let _ = engine.evaluate(&scope, &"idle", &"claim", None);
let _ = engine.evaluate(&scope, &"claimed", &"finish", None); // deny
let _ = engine.evaluate(&scope, &"claimed", &"execute", None);
let _ = engine.evaluate(&scope, &"running", &"finish", None);
let guard = shared.lock().unwrap();
let report = guard.integrity().unwrap();
assert!(report.ok, "chain integrity must hold: {:?}", report);
assert_eq!(guard.history("agent.executor.v1", 100).unwrap().len(), 4);
}
}