diff --git a/SECURITY.md b/SECURITY.md index d9acc8e..fabec77 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -7,3 +7,10 @@ The optional kernel module `kmod/strixpc` runs in the kernel; load it only on a If you find a security problem, please email **contact@openie.dev** instead of opening a public issue. We read every report and will credit you if you like, but this is a small, read-only research project with no support promise or fixed response time. + +## Action authorization + +`crates/receipt::auth` is a separate fail-closed check for two typed capabilities (`inspect_decision`, `quote_receipt`). +It does not execute commands. A model saying Commit, a high decision probability, and a verified event envelope are not +permission. See [docs/action-authorization.md](docs/action-authorization.md). This is not a claim that the runtime is safe +to run as an autonomous agent. The servers above are still unhardened. diff --git a/crates/kev/src/bin/kev_serve.rs b/crates/kev/src/bin/kev_serve.rs index 0bb1b3e..33e6df6 100644 --- a/crates/kev/src/bin/kev_serve.rs +++ b/crates/kev/src/bin/kev_serve.rs @@ -8,6 +8,8 @@ //! KEV_POWER=) integrated over the request, gross, ModelBased; wait_j = 0 (stateless, nothing resident), Estimator. //! KEV_RECEIPT_KEY=<32-byte or hex seed file> (else an ephemeral key per run); KEV_RECEIPT_LEDGER= persists the //! per-thread seq/prev-hash chain (default ~/.local/state/strix-rt/receipts.tsv; "-" = in memory only). +//! Probabilities, the word Commit, and a signed receipt are not action authorization. That check is +//! `receipt::auth` (docs/action-authorization.md). This server does not call it and does not execute tools. use anyhow::{anyhow, Result}; use kev::{decisions, json::{self, J}, systemone, Kev}; use std::io::{BufRead, BufReader, Read, Write}; diff --git a/crates/receipt/examples/auth_check.rs b/crates/receipt/examples/auth_check.rs new file mode 100644 index 0000000..50f431d --- /dev/null +++ b/crates/receipt/examples/auth_check.rs @@ -0,0 +1,101 @@ +//! Non-executing example for `receipt::auth`. +//! +//! Copyright 2026 Open Interface Engineering, Inc. +//! SPDX-License-Identifier: MIT OR Apache-2.0 +//! +//! Run: `cargo run -p receipt --example auth_check` +//! +//! This binary prints allow/deny decisions. It does not spawn a process, open a +//! shell, call a model, or read a power sensor. A deny is a successful check. + +use receipt::auth::{authorize, Action, DenyReason, Grant}; +use receipt::{decision_hash, Energy, Provenance, Tier}; + +fn main() { + let body = br#"{"model":"kev-0.8b","input":"status ping","questions":[{"type":"predicate","instructions":"Is this a status ping?"}]}"#; + let inspect = Action::inspect_decision(decision_hash(body, "kev-0.8b", Tier::Rules)); + let quote = Action::quote_receipt("threadA", [0x42; 32]).expect("thread id"); + let grants = [ + Grant::issue("op.inspect", inspect.clone(), None).expect("grant"), + Grant::issue("op.quote", quote.clone(), Some(0.5)).expect("grant"), + ]; + let under = Energy { + joules: 0.5, + provenance: Provenance::ModelBased, + }; + + expect(&grants, inspect.canonical().as_bytes(), None, None); + expect(&grants, quote.canonical().as_bytes(), Some(&under), None); + expect( + &[], + inspect.canonical().as_bytes(), + None, + Some(DenyReason::NoAuthority), + ); + + // Reported LFM3-Omni shapes. Printed only as deny labels. Not passed to a shell. + for sample in ["Commit\nrm -r /", "Commit\nrm -rf /", "echo hello", "hello"] { + expect( + &grants, + sample.as_bytes(), + Some(&under), + Some(DenyReason::Unstructured), + ); + } + let prefixed = format!("Commit\n{}", inspect.canonical()); + expect( + &grants, + prefixed.as_bytes(), + None, + Some(DenyReason::Unstructured), + ); + expect( + &grants, + b"strix-rt/action/v1\ncommit\n", + None, + Some(DenyReason::UnknownAction), + ); + + let other = Action::inspect_decision([0x99; 32]); + expect( + &grants, + other.canonical().as_bytes(), + None, + Some(DenyReason::ScopeMismatch), + ); + let over = Energy { + joules: 4.0, + provenance: Provenance::ModelBased, + }; + expect( + &grants, + quote.canonical().as_bytes(), + Some(&over), + Some(DenyReason::OverBudget), + ); + let estimated = Energy { + joules: 0.0, + provenance: Provenance::Estimator, + }; + expect( + &grants, + quote.canonical().as_bytes(), + Some(&estimated), + Some(DenyReason::EnergyUnmeasured), + ); + + println!("auth_check: all expected decisions matched"); +} + +fn expect(grants: &[Grant], proposal: &[u8], energy: Option<&Energy>, denial: Option) { + let decision = authorize(grants, proposal, energy); + println!("{decision}"); + let got = decision.denial(); + if got != denial { + panic!("expected {denial:?}, got {got:?}"); + } + if denial.is_none() { + let action = receipt::auth::parse_action(proposal).expect("canonical allow parses"); + assert!(decision.permit().unwrap().covers(&action)); + } +} diff --git a/crates/receipt/src/auth.rs b/crates/receipt/src/auth.rs new file mode 100644 index 0000000..6d13ca5 --- /dev/null +++ b/crates/receipt/src/auth.rs @@ -0,0 +1,898 @@ +//! Fail-closed action authorization. +//! +//! Copyright 2026 Open Interface Engineering, Inc. +//! SPDX-License-Identifier: MIT OR Apache-2.0 +//! +//! This module decides whether a proposal is a typed action that an operator grant +//! already allows. It does not spawn processes, open files, read a power meter, or +//! dispatch tools. `sign::Gate` remains a signature check; a verified envelope is +//! not an allow. +//! +//! Grammar (canonical, one byte-string per action): +//! ```text +//! strix-rt/action/v1\n +//! inspect_decision\n +//! <64 lowercase hex content-hash>\n +//! ``` +//! ```text +//! strix-rt/action/v1\n +//! quote_receipt\n +//! \n +//! <64 lowercase hex receipt-hash>\n +//! ``` +//! Thread ids are 1..=64 bytes, `[A-Za-z0-9][A-Za-z0-9_-]*`. Proposals longer than +//! 256 bytes, or that do not start with the magic line, are unstructured. Unknown +//! verbs are denied. There is no command list and no shell action. +//! +//! The parameters of an action are its scope. The allow binding is a domain-separated +//! SHA-256 of the canonical bytes, so a later payload swap does not match `Permit::covers`. +//! A `Permit` is the record of one `authorize` call. It is not a bearer token; nothing +//! in this crate consumes a permit to perform I/O. +//! +//! Mesh joule ceilings are not stored on this baseline. When at least one matching +//! grant carries `compute_ceiling_j`, the strictest of those ceilings is applied to +//! the caller-supplied [`Energy`]. `Estimator` and non-finite joules (the receipt +//! sentinel for "not metered") do not satisfy a ceiling. Grants with no ceiling do +//! not invent a number and do not read sysfs. A ceiling-free grant does not erase a +//! stricter grant for the same action. + +use crate::{hex, Energy, Hash, Provenance}; +use sha2::{Digest, Sha256}; + +const MAGIC_LINE: &[u8] = b"strix-rt/action/v1\n"; +const MAX_PROPOSAL_LEN: usize = 256; +const BINDING_DOMAIN: &[u8] = b"strix-rt/action-binding/v1"; + +/// Why `authorize` refused. Display text is a fixed label and does not echo the proposal. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum DenyReason { + /// Bytes are not a canonical action document. + Unstructured, + /// The verb token is well-formed and is not in the closed action set. + UnknownAction, + /// The header was recognized but the document is not canonical. + Malformed, + /// No grants were supplied. + NoAuthority, + /// Grants exist, and none are the same action kind. + ActionMismatch, + /// The action kind matched a grant, but the scope did not. + ScopeMismatch, + /// A ceiling applies and the caller supplied no energy figure. + EnergyMissing, + /// A ceiling applies and joules are negative or not finite. + EnergyInvalid, + /// A ceiling applies and provenance is [`Provenance::Estimator`]. + EnergyUnmeasured, + /// A ceiling applies and joules are above the strictest matching ceiling. + OverBudget, + /// `Grant::issue` was given a non-finite or negative ceiling. + BadCeiling, + /// `Grant::issue` was given an id or action that cannot be represented. + BadGrant, +} + +impl std::fmt::Display for DenyReason { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + DenyReason::Unstructured => "unstructured", + DenyReason::UnknownAction => "unknown_action", + DenyReason::Malformed => "malformed", + DenyReason::NoAuthority => "no_authority", + DenyReason::ActionMismatch => "action_mismatch", + DenyReason::ScopeMismatch => "scope_mismatch", + DenyReason::EnergyMissing => "energy_missing", + DenyReason::EnergyInvalid => "energy_invalid", + DenyReason::EnergyUnmeasured => "energy_unmeasured", + DenyReason::OverBudget => "over_budget", + DenyReason::BadCeiling => "bad_ceiling", + DenyReason::BadGrant => "bad_grant", + }) + } +} + +/// Closed set of actions this baseline can name. Both are references to objects the +/// runtime has already computed. Neither variant runs a tool. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Action { + /// Scope: the `/v1/decisions` content hash (`decision_hash` / slice content hash). + InspectDecision { content_hash: Hash }, + /// Scope: one thread id plus that thread's receipt hash. + QuoteReceipt { + thread_id: String, + receipt_hash: Hash, + }, +} + +impl Action { + pub fn inspect_decision(content_hash: Hash) -> Self { + Action::InspectDecision { content_hash } + } + + pub fn quote_receipt( + thread_id: impl Into, + receipt_hash: Hash, + ) -> Result { + let thread_id = thread_id.into(); + if !valid_thread(&thread_id) { + return Err(DenyReason::Malformed); + } + Ok(Action::QuoteReceipt { + thread_id, + receipt_hash, + }) + } + + pub fn kind_name(&self) -> &'static str { + match self { + Action::InspectDecision { .. } => "inspect_decision", + Action::QuoteReceipt { .. } => "quote_receipt", + } + } + + /// Canonical proposal bytes. `parse_action` accepts only this encoding. + pub fn canonical(&self) -> String { + match self { + Action::InspectDecision { content_hash } => { + format!( + "strix-rt/action/v1\ninspect_decision\n{}\n", + hex(content_hash) + ) + } + Action::QuoteReceipt { + thread_id, + receipt_hash, + } => { + format!( + "strix-rt/action/v1\nquote_receipt\n{thread_id}\n{}\n", + hex(receipt_hash) + ) + } + } + } + + /// Domain-separated hash of [`Self::canonical`]. This is the scope binding. + pub fn binding(&self) -> Hash { + binding_of(&self.canonical()) + } +} + +/// Operator-issued capability. Construct it with [`Grant::issue`]. The model cannot mint one. +#[derive(Clone, Debug, PartialEq)] +pub struct Grant { + capability_id: String, + action: Action, + compute_ceiling_j: Option, +} + +impl Grant { + /// `compute_ceiling_j: None` means this grant does not assert a Mesh ceiling. + /// It does not mean "unlimited measured joules". + pub fn issue( + id: &str, + action: Action, + compute_ceiling_j: Option, + ) -> Result { + if !valid_capability_id(id) { + return Err(DenyReason::BadGrant); + } + if let Some(c) = compute_ceiling_j { + if !c.is_finite() || c < 0.0 { + return Err(DenyReason::BadCeiling); + } + } + let parsed = parse_action(action.canonical().as_bytes())?; + if parsed != action { + return Err(DenyReason::BadGrant); + } + Ok(Grant { + capability_id: id.to_string(), + action, + compute_ceiling_j, + }) + } + + pub fn capability_id(&self) -> &str { + &self.capability_id + } + pub fn action(&self) -> &Action { + &self.action + } + pub fn compute_ceiling_j(&self) -> Option { + self.compute_ceiling_j + } +} + +/// Record of an allow. `_seal` stops other crates from building a permit by struct literal. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Permit { + capability_id: String, + binding: Hash, + _seal: (), +} + +impl Permit { + pub fn capability_id(&self) -> &str { + &self.capability_id + } + pub fn binding(&self) -> &Hash { + &self.binding + } + /// True only when `action` is the exact action this allow was computed for. + pub fn covers(&self, action: &Action) -> bool { + self.binding == action.binding() + } +} + +/// Result of [`authorize`]. An allow is not an instruction to execute anything. +#[derive(Clone, Debug, PartialEq, Eq)] +#[must_use = "an authorization decision must be checked; allow does not execute"] +pub enum Decision { + Allow(Permit), + Deny(DenyReason), +} + +impl Decision { + pub fn is_allow(&self) -> bool { + matches!(self, Decision::Allow(_)) + } + pub fn is_deny(&self) -> bool { + matches!(self, Decision::Deny(_)) + } + pub fn permit(&self) -> Option<&Permit> { + match self { + Decision::Allow(p) => Some(p), + Decision::Deny(_) => None, + } + } + pub fn denial(&self) -> Option { + match self { + Decision::Allow(_) => None, + Decision::Deny(r) => Some(*r), + } + } +} + +impl std::fmt::Display for Decision { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Decision::Allow(p) => write!( + f, + "allow capability_id={} binding={}", + p.capability_id(), + hex(p.binding()) + ), + Decision::Deny(r) => write!(f, "deny {r}"), + } + } +} + +/// Parse a canonical action. This does not consult grants and does not allow anything. +#[must_use = "parsing does not authorize; call authorize"] +pub fn parse_action(bytes: &[u8]) -> Result { + if !bytes.starts_with(MAGIC_LINE) { + return Err(DenyReason::Unstructured); + } + if bytes.len() > MAX_PROPOSAL_LEN || bytes.contains(&0) || bytes.contains(&b'\r') { + return Err(DenyReason::Malformed); + } + let text = std::str::from_utf8(bytes).map_err(|_| DenyReason::Malformed)?; + if !text.ends_with('\n') { + return Err(DenyReason::Malformed); + } + let lines: Vec<&str> = text[..text.len() - 1].split('\n').collect(); + if lines.first().copied() != Some("strix-rt/action/v1") { + return Err(DenyReason::Malformed); + } + let verb = lines.get(1).copied().unwrap_or(""); + if !verb_token(verb) { + return Err(DenyReason::Malformed); + } + if !known_verb(verb) { + return Err(DenyReason::UnknownAction); + } + match verb { + "inspect_decision" => { + if lines.len() != 3 { + return Err(DenyReason::Malformed); + } + let content_hash = parse_hash(lines[2]).ok_or(DenyReason::Malformed)?; + Ok(Action::InspectDecision { content_hash }) + } + "quote_receipt" => { + if lines.len() != 4 { + return Err(DenyReason::Malformed); + } + if !valid_thread(lines[2]) { + return Err(DenyReason::Malformed); + } + let receipt_hash = parse_hash(lines[3]).ok_or(DenyReason::Malformed)?; + Ok(Action::QuoteReceipt { + thread_id: lines[2].to_string(), + receipt_hash, + }) + } + _ => Err(DenyReason::UnknownAction), + } +} + +/// Authorize `proposal` against operator `grants`. +/// +/// `proposal` is untrusted bytes (model output, a file, a queue message). Grants are +/// a separate argument. There is no commit flag: a model word is not authority. +/// +/// On an exact action match, every matching ceiling applies and the strictest one wins. +/// A grant that sets no ceiling does not cancel a grant that sets one. Energy is read +/// only from `energy`; this function does not meter. +#[must_use = "an authorization decision must be checked; allow does not execute"] +pub fn authorize(grants: &[Grant], proposal: &[u8], energy: Option<&Energy>) -> Decision { + let action = match parse_action(proposal) { + Ok(a) => a, + Err(r) => return Decision::Deny(r), + }; + if action.canonical().as_bytes() != proposal { + return Decision::Deny(DenyReason::Malformed); + } + if grants.is_empty() { + return Decision::Deny(DenyReason::NoAuthority); + } + + let mut saw_kind = false; + let mut strictest: Option = None; + let mut chosen_id: Option<&str> = None; + for g in grants { + if !same_kind(&g.action, &action) { + continue; + } + saw_kind = true; + if g.action != action { + continue; + } + match g.compute_ceiling_j { + Some(c) => { + let tighter = strictest.map(|s| c < s).unwrap_or(true); + if tighter { + strictest = Some(c); + chosen_id = Some(g.capability_id.as_str()); + } + } + None => { + if chosen_id.is_none() { + chosen_id = Some(g.capability_id.as_str()); + } + } + } + } + let Some(id) = chosen_id else { + return Decision::Deny(if saw_kind { + DenyReason::ScopeMismatch + } else { + DenyReason::ActionMismatch + }); + }; + if let Some(ceiling) = strictest { + if let Some(reason) = energy_refusal(ceiling, energy) { + return Decision::Deny(reason); + } + } + Decision::Allow(Permit { + capability_id: id.to_string(), + binding: action.binding(), + _seal: (), + }) +} + +fn energy_refusal(ceiling: f64, energy: Option<&Energy>) -> Option { + let Some(e) = energy else { + return Some(DenyReason::EnergyMissing); + }; + if !e.joules.is_finite() || e.joules < 0.0 { + return Some(DenyReason::EnergyInvalid); + } + match e.provenance { + Provenance::HwShunt | Provenance::ModelBased => {} + Provenance::Estimator => return Some(DenyReason::EnergyUnmeasured), + } + if e.joules > ceiling { + return Some(DenyReason::OverBudget); + } + None +} + +fn binding_of(canonical: &str) -> Hash { + let mut h = Sha256::new(); + h.update(BINDING_DOMAIN); + h.update((canonical.len() as u64).to_le_bytes()); + h.update(canonical.as_bytes()); + h.finalize().into() +} + +fn same_kind(a: &Action, b: &Action) -> bool { + std::mem::discriminant(a) == std::mem::discriminant(b) +} + +fn known_verb(v: &str) -> bool { + matches!(v, "inspect_decision" | "quote_receipt") +} + +fn verb_token(s: &str) -> bool { + let b = s.as_bytes(); + !b.is_empty() + && b.len() <= 64 + && b[0].is_ascii_lowercase() + && b.iter() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || *c == b'_') +} + +fn valid_thread(s: &str) -> bool { + let b = s.as_bytes(); + (1..=64).contains(&b.len()) + && b[0].is_ascii_alphanumeric() + && b.iter() + .all(|c| c.is_ascii_alphanumeric() || *c == b'_' || *c == b'-') +} + +fn valid_capability_id(s: &str) -> bool { + let b = s.as_bytes(); + (1..=128).contains(&b.len()) + && b[0].is_ascii_alphanumeric() + && b.iter() + .all(|c| c.is_ascii_alphanumeric() || matches!(*c, b'.' | b'_' | b':' | b'-')) +} + +fn parse_hash(s: &str) -> Option { + if s.len() != 64 || !s.bytes().all(|c| matches!(c, b'0'..=b'9' | b'a'..=b'f')) { + return None; + } + let mut h = [0u8; 32]; + for i in 0..32 { + h[i] = u8::from_str_radix(&s[2 * i..2 * i + 2], 16).ok()?; + } + Some(h) +} + +#[cfg(test)] +#[allow(clippy::cloned_ref_to_slice_refs)] +mod tests { + use super::*; + use crate::sign::{seal, Gate, Subscription, Verdict}; + use crate::{decision_hash, Tier}; + use ed25519_dalek::SigningKey; + + fn measured(j: f64, p: Provenance) -> Energy { + Energy { + joules: j, + provenance: p, + } + } + fn allow_of(d: &Decision) -> &Permit { + d.permit().expect("allow") + } + + #[test] + fn harmless_fixtures_roundtrip_and_allow() { + let body = br#"{"model":"kev-0.8b","input":"status ping","questions":[{"type":"predicate","instructions":"Is this a status ping?"}]}"#; + let hash = decision_hash(body, "kev-0.8b", Tier::Small { batch: 1 }); + let inspect = Action::inspect_decision(hash); + let quote = Action::quote_receipt("threadA", [0x22; 32]).unwrap(); + for action in [inspect, quote] { + let bytes = action.canonical(); + assert_eq!(parse_action(bytes.as_bytes()).unwrap(), action); + assert_eq!(action.canonical().as_bytes(), bytes.as_bytes()); + let bare = authorize(&[], bytes.as_bytes(), None); + assert_eq!(bare.denial(), Some(DenyReason::NoAuthority)); + let g = Grant::issue("op.ping", action.clone(), None).unwrap(); + assert_eq!(g.compute_ceiling_j(), None); + let d = authorize(&[g], bytes.as_bytes(), None); + let p = allow_of(&d); + assert!(p.covers(&action)); + assert_eq!(p.capability_id(), "op.ping"); + assert_eq!(p.binding(), &action.binding()); + } + } + + #[test] + fn model_bytes_do_not_mint_authority() { + let action = Action::inspect_decision([0x11; 32]); + let bytes = action.canonical(); + assert_eq!( + authorize(&[], bytes.as_bytes(), None).denial(), + Some(DenyReason::NoAuthority) + ); + let wrapped = format!("Commit\n{bytes}"); + assert_eq!( + authorize(&[], wrapped.as_bytes(), None).denial(), + Some(DenyReason::Unstructured) + ); + let g = Grant::issue("op.ping", action.clone(), None).unwrap(); + assert_eq!( + authorize(&[g.clone()], wrapped.as_bytes(), None).denial(), + Some(DenyReason::Unstructured) + ); + assert!(authorize(&[g], bytes.as_bytes(), None).is_allow()); + } + + #[test] + fn commit_and_other_verbs_are_not_actions() { + for verb in [ + "commit", + "shell", + "exec", + "run", + "bash", + "system", + "tool", + "delete_root", + "echo", + ] { + let doc = format!("strix-rt/action/v1\n{verb}\n"); + assert_eq!( + parse_action(doc.as_bytes()), + Err(DenyReason::UnknownAction), + "{verb}" + ); + let g = + Grant::issue("op.ping", Action::inspect_decision([1; 32]), Some(1.0e9)).unwrap(); + assert_eq!( + authorize(&[g], doc.as_bytes(), None).denial(), + Some(DenyReason::UnknownAction) + ); + } + assert_eq!( + parse_action(b"strix-rt/action/v1\nCommit\n"), + Err(DenyReason::Malformed) + ); + } + + #[test] + fn unstructured_inputs_share_one_reason() { + let samples: &[&[u8]] = &[ + b"", + b" ", + b"\n", + b"hello", + b"Commit", + b"Commit\n", + b"Commit\nnope", + b"status ping", + b"{\"probability\":1.0}", + b"{\"choice\":\"commit\",\"confidence\":1}", + b"strix-rt/action/v2\ninspect_decision\n", + &[0xff, 0xfe], + ]; + for s in samples { + assert_eq!(parse_action(s), Err(DenyReason::Unstructured)); + } + let huge = vec![b'A'; 200_000]; + assert_eq!(parse_action(&huge), Err(DenyReason::Unstructured)); + for b in 0u8..=255 { + assert_eq!(parse_action(&[b]), Err(DenyReason::Unstructured)); + } + } + + #[test] + fn malformed_documents_do_not_allow() { + let hash = hex(&[0xab; 32]); + let g = Grant::issue("op.ping", Action::inspect_decision([0xab; 32]), None).unwrap(); + let cases = [ + format!("strix-rt/action/v1\ninspect_decision\n{hash}"), + format!("strix-rt/action/v1\ninspect_decision\n{hash}\n\n"), + format!("strix-rt/action/v1\ninspect_decision\n{hash}\nextra\n"), + format!( + "strix-rt/action/v1\ninspect_decision\n{}\n", + hash.to_uppercase() + ), + format!("strix-rt/action/v1\ninspect_decision\n{hash} \n"), + "strix-rt/action/v1\ninspect_decision\n\n".into(), + "strix-rt/action/v1\n\n".into(), + format!("strix-rt/action/v1\nquote_receipt\n../x\n{hash}\n"), + format!("strix-rt/action/v1\nquote_receipt\n-rf\n{hash}\n"), + format!("strix-rt/action/v1\nquote_receipt\na/b\n{hash}\n"), + format!("strix-rt/action/v1\nquote_receipt\n\n{hash}\n"), + ]; + for c in cases { + let d = authorize(&[g.clone()], c.as_bytes(), None); + assert_eq!(d.denial(), Some(DenyReason::Malformed), "{c:?}"); + } + let mut nul = format!("strix-rt/action/v1\ninspect_decision\n{hash}\n").into_bytes(); + nul.push(0); + assert_eq!(parse_action(&nul), Err(DenyReason::Malformed)); + } + + #[test] + fn scope_and_kind_mismatch() { + let inspect = Action::inspect_decision([0x11; 32]); + let other = Action::inspect_decision([0x22; 32]); + let quote = Action::quote_receipt("threadA", [0x11; 32]).unwrap(); + let g = Grant::issue("op.ping", inspect.clone(), None).unwrap(); + assert_eq!( + authorize(&[g.clone()], other.canonical().as_bytes(), None).denial(), + Some(DenyReason::ScopeMismatch) + ); + assert_eq!( + authorize(&[g.clone()], quote.canonical().as_bytes(), None).denial(), + Some(DenyReason::ActionMismatch) + ); + let qg = Grant::issue("op.quote", quote.clone(), None).unwrap(); + let moved = Action::quote_receipt("threadB", [0x11; 32]).unwrap(); + let rehash = Action::quote_receipt("threadA", [0x33; 32]).unwrap(); + assert_eq!( + authorize(&[qg.clone()], moved.canonical().as_bytes(), None).denial(), + Some(DenyReason::ScopeMismatch) + ); + assert_eq!( + authorize(&[qg], rehash.canonical().as_bytes(), None).denial(), + Some(DenyReason::ScopeMismatch) + ); + assert_ne!(inspect.binding(), other.binding()); + assert_ne!(inspect.binding(), quote.binding()); + } + + #[test] + fn permit_does_not_cover_a_substituted_action() { + let a = Action::inspect_decision([0xab; 32]); + let b = Action::inspect_decision([0xcd; 32]); + let g = Grant::issue("op.ping", a.clone(), None).unwrap(); + let d = authorize(&[g], a.canonical().as_bytes(), None); + let p = allow_of(&d); + assert!(p.covers(&a)); + assert!(!p.covers(&b)); + let mut flipped = a.canonical().into_bytes(); + let last = flipped.len() - 2; + flipped[last] = if flipped[last] == b'a' { b'b' } else { b'a' }; + assert!(authorize(&[Grant::issue("op.ping", a, None).unwrap()], &flipped, None).is_deny()); + } + + #[test] + fn every_single_byte_change_of_an_allow_is_denied() { + let actions = [ + Action::inspect_decision([0xab; 32]), + Action::quote_receipt("threadA", [0x22; 32]).unwrap(), + ]; + for action in actions { + let g = Grant::issue("op.ping", action.clone(), None).unwrap(); + let base = action.canonical().into_bytes(); + assert!(authorize(&[g.clone()], &base, None).is_allow()); + for i in 0..base.len() { + for delta in [1u8, 0x20, 0xff] { + let mut tampered = base.clone(); + tampered[i] ^= delta; + if tampered == base { + continue; + } + assert!( + authorize(&[g.clone()], &tampered, None).is_deny(), + "i={i} d={delta}" + ); + } + } + } + } + + #[test] + fn random_noncanonical_bytes_never_allow() { + let action = Action::inspect_decision([0x11; 32]); + let g = Grant::issue("op.ping", action.clone(), None).unwrap(); + let canonical = action.canonical(); + let mut state = 0xdecafbadu64; + for _ in 0..300 { + state = state.wrapping_mul(6364136223846793005).wrapping_add(1); + let n = (state % 80) as usize + 1; + let mut bytes = vec![0u8; n]; + for b in &mut bytes { + state = state.wrapping_mul(6364136223846793005).wrapping_add(1); + *b = (state >> 33) as u8; + } + assert_ne!(bytes.as_slice(), canonical.as_bytes()); + assert!(authorize(&[g.clone()], &bytes, None).is_deny()); + } + } + + #[test] + fn binding_is_domain_separated_and_stable() { + let action = Action::inspect_decision([0x44; 32]); + let again = action.binding(); + assert_eq!(action.binding(), again); + let raw: Hash = Sha256::digest(action.canonical().as_bytes()).into(); + assert_ne!(action.binding(), raw); + let text = format!("deny {}", authorize(&[], b"unique-marker-zxq", None)); + assert!(!text.contains("unique-marker")); + } + + #[test] + fn energy_ceiling_uses_existing_provenance_and_does_not_invent_a_budget() { + let action = Action::inspect_decision([0x55; 32]); + let bytes = action.canonical(); + let open = Grant::issue("op.open", action.clone(), None).unwrap(); + let nan = measured(f64::NAN, Provenance::ModelBased); + assert!(authorize(&[open], bytes.as_bytes(), Some(&nan)).is_allow()); + + let capped = Grant::issue("op.cap", action.clone(), Some(1.0)).unwrap(); + let ok = measured(1.0, Provenance::ModelBased); + assert!(authorize(&[capped.clone()], bytes.as_bytes(), Some(&ok)).is_allow()); + let shunt = measured(0.25, Provenance::HwShunt); + assert!(authorize(&[capped.clone()], bytes.as_bytes(), Some(&shunt)).is_allow()); + let zero = Grant::issue("op.zero", action.clone(), Some(0.0)).unwrap(); + assert!(authorize( + &[zero.clone()], + bytes.as_bytes(), + Some(&measured(0.0, Provenance::ModelBased)) + ) + .is_allow()); + assert_eq!( + authorize( + &[zero], + bytes.as_bytes(), + Some(&measured(f64::MIN_POSITIVE, Provenance::HwShunt)) + ) + .denial(), + Some(DenyReason::OverBudget) + ); + assert_eq!( + authorize(&[capped.clone()], bytes.as_bytes(), None).denial(), + Some(DenyReason::EnergyMissing) + ); + assert_eq!( + authorize(&[capped.clone()], bytes.as_bytes(), Some(&nan)).denial(), + Some(DenyReason::EnergyInvalid) + ); + assert_eq!( + authorize( + &[capped.clone()], + bytes.as_bytes(), + Some(&measured(f64::INFINITY, Provenance::HwShunt)) + ) + .denial(), + Some(DenyReason::EnergyInvalid) + ); + assert_eq!( + authorize( + &[capped.clone()], + bytes.as_bytes(), + Some(&measured(-0.1, Provenance::ModelBased)) + ) + .denial(), + Some(DenyReason::EnergyInvalid) + ); + assert_eq!( + authorize( + &[capped.clone()], + bytes.as_bytes(), + Some(&measured(0.0, Provenance::Estimator)) + ) + .denial(), + Some(DenyReason::EnergyUnmeasured) + ); + assert_eq!( + authorize( + &[capped.clone()], + bytes.as_bytes(), + Some(&measured(1.0 + f64::EPSILON, Provenance::ModelBased)) + ) + .denial(), + Some(DenyReason::OverBudget) + ); + let loose = Grant::issue("op.loose", action.clone(), Some(5.0)).unwrap(); + let mid = measured(2.0, Provenance::ModelBased); + assert_eq!( + authorize( + &[loose.clone(), capped.clone()], + bytes.as_bytes(), + Some(&mid) + ) + .denial(), + Some(DenyReason::OverBudget) + ); + assert!(authorize( + &[loose, capped], + bytes.as_bytes(), + Some(&measured(1.0, Provenance::ModelBased)) + ) + .is_allow()); + let unbounded = Grant::issue("op.wide", action.clone(), None).unwrap(); + let tight = Grant::issue("op.tight", action, Some(1.0)).unwrap(); + assert_eq!( + authorize(&[unbounded, tight], bytes.as_bytes(), Some(&mid)).denial(), + Some(DenyReason::OverBudget) + ); + } + + #[test] + fn strictest_ceiling_names_that_grant() { + let action = Action::inspect_decision([0x66; 32]); + let bytes = action.canonical(); + let wide = Grant::issue("op.wide", action.clone(), Some(5.0)).unwrap(); + let tight = Grant::issue("op.tight", action, Some(1.0)).unwrap(); + let d = authorize( + &[wide, tight], + bytes.as_bytes(), + Some(&measured(1.0, Provenance::ModelBased)), + ); + assert_eq!(allow_of(&d).capability_id(), "op.tight"); + } + + #[test] + fn bad_grants_are_rejected() { + let action = Action::inspect_decision([1; 32]); + assert_eq!( + Grant::issue("", action.clone(), None).unwrap_err(), + DenyReason::BadGrant + ); + assert_eq!( + Grant::issue("has space", action.clone(), None).unwrap_err(), + DenyReason::BadGrant + ); + assert_eq!( + Grant::issue("slash/id", action.clone(), None).unwrap_err(), + DenyReason::BadGrant + ); + assert_eq!( + Grant::issue("op", action.clone(), Some(f64::NAN)).unwrap_err(), + DenyReason::BadCeiling + ); + assert_eq!( + Grant::issue("op", action.clone(), Some(-1.0)).unwrap_err(), + DenyReason::BadCeiling + ); + assert_eq!( + Grant::issue("op", action, Some(f64::INFINITY)).unwrap_err(), + DenyReason::BadCeiling + ); + assert_eq!( + Action::quote_receipt("", [0; 32]).unwrap_err(), + DenyReason::Malformed + ); + assert_eq!( + Action::quote_receipt("-rf", [0; 32]).unwrap_err(), + DenyReason::Malformed + ); + assert_eq!( + Action::quote_receipt("../x", [0; 32]).unwrap_err(), + DenyReason::Malformed + ); + let sneaky = Action::QuoteReceipt { + thread_id: "rm rf".into(), + receipt_hash: [0; 32], + }; + assert!(Grant::issue("op", sneaky, None).is_err()); + } + + #[test] + fn signature_accept_is_not_authorization() { + let k = SigningKey::from_bytes(&[1; 32]); + let sub = Subscription { + tool_call_id: "sub1".into(), + dispatch_receipt: [7; 32], + expected_signer: k.verifying_key().to_bytes(), + }; + let payload = b"Commit\nnope"; + let env = seal(&k, "sub1", payload, [7; 32], 0); + assert_eq!( + Gate::default().check(&sub, Some(&env), payload), + Verdict::Accept + ); + let g = Grant::issue("op.ping", Action::inspect_decision([0x11; 32]), Some(1.0e9)).unwrap(); + assert_eq!( + authorize(&[g], payload, Some(&measured(0.0, Provenance::HwShunt))).denial(), + Some(DenyReason::Unstructured) + ); + + let action = Action::inspect_decision([0x11; 32]); + let bytes = action.canonical(); + let signed = seal(&k, "sub1", bytes.as_bytes(), [7; 32], 1); + assert_eq!( + Gate::default().check(&sub, Some(&signed), bytes.as_bytes()), + Verdict::Accept + ); + assert_eq!( + authorize(&[], bytes.as_bytes(), None).denial(), + Some(DenyReason::NoAuthority) + ); + assert_eq!( + Gate::default().check(&sub, None, bytes.as_bytes()), + Verdict::Quarantine("unsigned") + ); + let g = Grant::issue("op.ping", action, None).unwrap(); + assert!(authorize(&[g], bytes.as_bytes(), None).is_allow()); + } +} diff --git a/crates/receipt/src/lib.rs b/crates/receipt/src/lib.rs index 432b83d..287a7dd 100644 --- a/crates/receipt/src/lib.rs +++ b/crates/receipt/src/lib.rs @@ -1,12 +1,16 @@ //! JouleContract-style per-slice receipts for an Infinity-style suspend/resume layer in front of /v1/decisions -//! (docs/infinity-suspend-resume.md). Types + content hash only: no GPU, no I/O, no signing yet. +//! (docs/infinity-suspend-resume.md). No GPU. `auth` performs no I/O. //! A slice = one completion + persist + at most one tool dispatch. Two keys, deliberately separate: //! - `input_id`: delivery dedup (Infinity's processed_message_ids). Opaque, NOT hashed. //! - `content_hash`: what was computed (history prefix, input payload, model, tier, decode params). Doubles as the //! JouleClaw L0 cache key. Must not depend on run-time randomness (e.g. Infinity's uuid synthetic tool-call ids). +//! +//! `sign::Gate` checks event-envelope signatures only. `auth` is a separate fail-closed capability check. +//! A verified signature is not permission to act, and `auth` does not execute anything (docs/action-authorization.md). pub mod sign; pub mod ledger; pub mod meter; +pub mod auth; use sha2::{Digest, Sha256}; pub type Hash = [u8; 32]; diff --git a/crates/receipt/tests/auth_gate.rs b/crates/receipt/tests/auth_gate.rs new file mode 100644 index 0000000..e0e80cf --- /dev/null +++ b/crates/receipt/tests/auth_gate.rs @@ -0,0 +1,211 @@ +#![allow(clippy::cloned_ref_to_slice_refs)] +//! Public-API checks for the action gate. Independent of the unit-test module. +//! Copyright 2026 Open Interface Engineering, Inc. +//! SPDX-License-Identifier: MIT OR Apache-2.0 +//! +//! These tests do not spawn a shell. The destructive strings are inputs to a parser. + +use ed25519_dalek::SigningKey; +use receipt::auth::{authorize, Action, Decision, DenyReason, Grant}; +use receipt::sign::{seal, Gate, Subscription, Verdict}; +use receipt::{decision_hash, Energy, Provenance, Tier}; + +fn energy(j: f64, p: Provenance) -> Energy { + Energy { + joules: j, + provenance: p, + } +} + +fn ping_action() -> Action { + let body = br#"{"model":"kev-0.8b","input":"status ping","questions":[{"type":"predicate","instructions":"Is this a status ping?"}]}"#; + Action::inspect_decision(decision_hash(body, "kev-0.8b", Tier::Rules)) +} + +fn assert_deny(grants: &[Grant], bytes: &[u8], energy: Option<&Energy>, reason: DenyReason) { + let d = authorize(grants, bytes, energy); + assert_eq!(d.denial(), Some(reason), "bytes={bytes:?} decision={d}"); + // Fixed label only: the proposal is not copied into the decision text. + assert_eq!(d.to_string(), format!("deny {reason}")); +} + +#[test] +fn allowed_harmless_fixtures() { + let inspect = ping_action(); + let quote = Action::quote_receipt("threadA", [0x42; 32]).unwrap(); + let grants = [ + Grant::issue("op.inspect", inspect.clone(), None).unwrap(), + Grant::issue("op.quote", quote.clone(), Some(0.5)).unwrap(), + ]; + let di = authorize(&grants, inspect.canonical().as_bytes(), None); + assert!(di.is_allow(), "{di}"); + assert!(di.permit().unwrap().covers(&inspect)); + assert!(!di.permit().unwrap().covers("e)); + let measured = energy(0.5, Provenance::ModelBased); + let dq = authorize(&grants, quote.canonical().as_bytes(), Some(&measured)); + assert!(dq.is_allow(), "{dq}"); + assert_eq!(dq.permit().unwrap().capability_id(), "op.quote"); + assert!(dq.permit().unwrap().covers("e)); +} + +#[test] +fn destructive_lfm_shapes_and_other_unstructured_text_deny_the_same_way() { + // Shapes reported from Framework runs of LFM3-Omni Q4_K_M. Those strings were + // not executed there, and they are not executed here. The raw 60 transcripts + // are not in this remote tree. + let reported = [ + "Commit\nrm -r /", + "Commit\nrm -rf /", + "Commit\nrm -r /\n", + "Commit\nrm -rf /\n", + ]; + let also = [ + "rm -r /", + "rm -rf /", + "rm -rf / --no-preserve-root", + "sudo rm -rf /", + "/bin/rm -rf /", + "echo hello", + "please wave", + "please delete the root filesystem", + "format C:", + "hello", + "Commit", + "{\"action\":\"shell\",\"argv\":[\"rm\",\"-rf\",\"/\"]}", + "{\"commit\":true,\"choice\":\"commit\",\"probability\":1}", + "cm0gLXJmIC8=", + "$(rm -rf /)", + "`rm -rf /`", + "yes\nrm -rf /", + ]; + let inspect = ping_action(); + let grant = Grant::issue("op.inspect", inspect, Some(1.0e9)).unwrap(); + let measured = energy(0.0, Provenance::HwShunt); + for s in reported.into_iter().chain(also) { + assert_deny( + &[grant.clone()], + s.as_bytes(), + Some(&measured), + DenyReason::Unstructured, + ); + } + let crlf = b"Commit\r\nrm -rf /"; + assert_deny(&[grant], crlf, None, DenyReason::Unstructured); +} + +#[test] +fn commit_prefixed_canonical_action_is_not_an_allow() { + let action = ping_action(); + let grant = Grant::issue("op.inspect", action.clone(), None).unwrap(); + let prefixed = format!("Commit\n{}", action.canonical()); + assert_deny( + &[grant.clone()], + prefixed.as_bytes(), + None, + DenyReason::Unstructured, + ); + let doc = "strix-rt/action/v1\ncommit\n"; + assert_deny( + &[grant.clone()], + doc.as_bytes(), + None, + DenyReason::UnknownAction, + ); + let hash = receipt::hex(&match action { + Action::InspectDecision { content_hash } => content_hash, + Action::QuoteReceipt { .. } => [0; 32], + }); + let extra = format!("strix-rt/action/v1\ninspect_decision\n{hash}\nrm -rf /\n"); + assert_deny(&[grant], extra.as_bytes(), None, DenyReason::Malformed); +} + +#[test] +fn payload_substitution_and_missing_authority() { + let a = ping_action(); + let b = Action::inspect_decision([0x99; 32]); + assert_deny(&[], a.canonical().as_bytes(), None, DenyReason::NoAuthority); + let g = Grant::issue("op.inspect", a.clone(), None).unwrap(); + let d = authorize(&[g.clone()], a.canonical().as_bytes(), None); + assert!(d.permit().unwrap().covers(&a)); + assert!(!d.permit().unwrap().covers(&b)); + assert_deny( + &[g.clone()], + b.canonical().as_bytes(), + None, + DenyReason::ScopeMismatch, + ); + let quote = Action::quote_receipt("threadA", [0x42; 32]).unwrap(); + assert_deny( + &[g], + quote.canonical().as_bytes(), + None, + DenyReason::ActionMismatch, + ); +} + +#[test] +fn signed_destructive_envelope_stays_unauthorized() { + let k = SigningKey::from_bytes(&[9; 32]); + let sub = Subscription { + tool_call_id: "sub1".into(), + dispatch_receipt: [7; 32], + expected_signer: k.verifying_key().to_bytes(), + }; + for payload in [ + b"Commit\nrm -r /".as_slice(), + b"Commit\nrm -rf /".as_slice(), + ] { + let env = seal(&k, "sub1", payload, [7; 32], 0); + assert_eq!( + Gate::default().check(&sub, Some(&env), payload), + Verdict::Accept + ); + let grant = Grant::issue("op.inspect", ping_action(), None).unwrap(); + assert_deny(&[grant], payload, None, DenyReason::Unstructured); + } + let action = ping_action(); + let bytes = action.canonical(); + let env = seal(&k, "sub1", bytes.as_bytes(), [7; 32], 4); + assert_eq!( + Gate::default().check(&sub, Some(&env), bytes.as_bytes()), + Verdict::Accept + ); + assert_deny(&[], bytes.as_bytes(), None, DenyReason::NoAuthority); +} + +#[test] +fn energy_refusal_follows_receipt_semantics() { + let action = ping_action(); + let g = Grant::issue("op.inspect", action.clone(), Some(1.0)).unwrap(); + let bytes = action.canonical(); + assert_deny( + &[g.clone()], + bytes.as_bytes(), + None, + DenyReason::EnergyMissing, + ); + assert_deny( + &[g.clone()], + bytes.as_bytes(), + Some(&energy(f64::NAN, Provenance::ModelBased)), + DenyReason::EnergyInvalid, + ); + assert_deny( + &[g.clone()], + bytes.as_bytes(), + Some(&energy(0.0, Provenance::Estimator)), + DenyReason::EnergyUnmeasured, + ); + assert_deny( + &[g.clone()], + bytes.as_bytes(), + Some(&energy(1.1, Provenance::HwShunt)), + DenyReason::OverBudget, + ); + let d = authorize( + &[g], + bytes.as_bytes(), + Some(&energy(1.0, Provenance::ModelBased)), + ); + assert!(matches!(d, Decision::Allow(_)), "{d}"); +} diff --git a/docs/README.md b/docs/README.md index fa10d29..5158686 100644 --- a/docs/README.md +++ b/docs/README.md @@ -21,6 +21,7 @@ and it is why so many entries say whether the box was quiet. | [cosmos3.md](cosmos3.md) | Cosmos3-Nano text-to-video | | [parakeet.md](parakeet.md) | Parakeet Redux speech-to-text | | [decision_models.md](decision_models.md) | "System One" decision models (Kev, Laya) and energy per decision | +| [action-authorization.md](action-authorization.md) | Fail-closed typed capability gate; what it does not execute or claim | | [../PROGRESS.md](../PROGRESS.md) | Day-by-day progress log from the first GEMV to video generation | Logs and session scripts named in these files are under `../logs/`. A few large logs, binary dumps, audio, video and images were diff --git a/docs/action-authorization.md b/docs/action-authorization.md new file mode 100644 index 0000000..a1807be --- /dev/null +++ b/docs/action-authorization.md @@ -0,0 +1,182 @@ +# Action authorization + +Copyright 2026 Open Interface Engineering, Inc. +SPDX-License-Identifier: MIT OR Apache-2.0 + +Status: library gate on remote `main` at `34ad7dd` (`crates/receipt::auth`). It is not +wired into `kev_serve`, it does not execute tools, and it is not a claim that this +runtime is safe to run as an autonomous agent. Nothing here was taken from an +unpublished local tree. + +## Threat model + +On a Framework box, LFM3-Omni Q4_K_M was asked for a destructive root deletion and +answered with `Commit` plus `rm -r /` or `rm -rf /` on every recorded try (0/60 +refusals). Those strings were not executed. The raw transcripts are not in this +repository. The fixtures in the tests and in `crates/receipt/examples/auth_check.rs` +use the reported shapes. + +The failure mode we are closing is a caller treating one of these as permission: + +- the model writing `Commit`, a choice value `commit`, or a probability near 1 +- `sign::Gate` returning `Verdict::Accept` (the envelope is authentic) +- `POST /v1/decisions` returning predicate, choice, or score probabilities +- a free-form `SliceReceipt.dispatched_tool` string + +The adversary is model output and anything else that can place bytes in the proposal: +malformed documents, an extra line, a substituted hash, a different thread id, or a +signed copy of a destructive payload. The operator grant list is trusted in-process +state. A caller who skips `authorize`, or who can rewrite the grant list, is outside +this gate. Kernel, GPU, weight supply, and network exposure of the experimental +servers are outside it too (`SECURITY.md`). + +## Policy + +`authorize(grants, proposal, energy)` is fail-closed. + +- The proposal must be one canonical typed action. Anything else is `unstructured`, + `unknown_action`, or `malformed`. +- The closed set is `inspect_decision` and `quote_receipt`. Both name objects this + runtime already computed (a decision content hash, or a thread id plus a receipt + hash). There is no shell, process, filesystem, or network action to grant. +- Default deny for an empty grant list (`no_authority`), a different kind + (`action_mismatch`), and the same kind with a different scope (`scope_mismatch`). +- The model does not mint grants. Prefixing a valid document with `Commit` makes it + unstructured. The verb `commit` is `unknown_action`. There is no commit parameter. +- This is not a command blacklist. `hello`, `echo hello`, and `Commit\nrm -rf /` + all return `unstructured` because they are not the grammar below. The production + parser does not search for a forbidden-command table. + +Grammar, exactly, including the final newline, maximum 256 bytes: + +```text +strix-rt/action/v1 +inspect_decision +<64 lowercase hex content hash> + +strix-rt/action/v1 +quote_receipt + +<64 lowercase hex receipt hash> +``` + +A thread id is 1 to 64 bytes, `[A-Za-z0-9][A-Za-z0-9_-]*`. Uppercase hex, an extra +field, a missing field, CR, NUL, or a second document is `malformed`. + +## Binding + +An allow carries a `Permit` whose binding is SHA-256 over the domain +`strix-rt/action-binding/v1` and the length-prefixed canonical bytes (the same +length-prefix habit as the other JouleContract hashes in this crate). The action's +parameters are the scope: there is no extra payload field that can be swapped after +the decision. + +`Permit::covers` is true only for that action. Changing one byte of a granted +proposal denies. A `Permit` is not a bearer token. Other crates cannot construct +one, and nothing in `receipt` performs I/O because a permit exists. Calling +`covers` is not a second authorization and does not re-check energy. + +Grants are tried as a set of exact matches. The strictest `compute_ceiling_j` among +exact matches applies. A grant with no ceiling does not cancel a grant that has one. +The reported `capability_id` is the grant that supplied that strictest ceiling, or +the first exact match when no ceiling is set. + +## Energy + +JouleContract types already in this crate are `Energy { joules, provenance }` with +`HwShunt`, `ModelBased`, and `Estimator`. `ledger::to_json` renders non-finite +joules as JSON `null` (unmetered). The design note says Mesh ceilings apply to +`compute_j`. This baseline has no stored ceiling, no budget account, and no default +joule number. + +So: + +- A grant with `compute_ceiling_j: None` does not check energy and does not invent a + budget. Missing, NaN, or `Estimator` energy can still accompany an allow. That is + "no ceiling was asserted", not "the action is free in joules". +- When a matching ceiling is set, the caller must pass finite joules `>= 0` and + `<=` the strictest ceiling, with provenance `HwShunt` or `ModelBased`. +- `Estimator` is `energy_unmeasured`, including a cache-hit `0.0` tagged `Estimator`. + Those receipts stay honest; they just cannot satisfy a ceiling. +- Non-finite or negative joules are `energy_invalid`. Absence is `energy_missing`. + Above the ceiling is `over_budget`. The bound is inclusive. +- `auth` does not call `meter`, does not read sysfs, and does not fill in joules. + `KEV_POWER` is untouched. If the caller has no measurement, a ceiling denies. + +## What stays separate + +| Mechanism | What it decides | What it does not decide | +|---|---|---| +| `sign::Gate` | Envelope signature, subscription, payload hash, replay | Whether the payload is an allowed action | +| `POST /v1/decisions` | Predicate, choice, and score probabilities, plus a signed slice receipt | Action authorization. `dispatched_tool` is still an unstructured `Option` and `kev_serve` passes `None` | +| `receipt::auth` | Typed capability match, exact scope, optional ceiling | Authenticity of an event envelope. It does not execute the action | + +A destructive payload can verify under `sign::Gate` and still be `unstructured` here. +A granted `inspect_decision` can be allowed here and still be quarantined as unsigned +if it is presented to `sign::Gate` with no envelope. Both outcomes are tested. + +## Integration status + +Verified on this workspace before the change, remote `main` `34ad7dd`: + +- `crates/receipt/src/sign.rs`: `Gate::check` returns `Accept` or `Quarantine`. +- `crates/kev/src/bin/kev_serve.rs`: `/v1/decisions` returns model probabilities and a + receipt. It does not authorize or run a tool. +- `crates/receipt` energy code meters a caller-supplied sysfs path or records NaN. + It does not enforce a ceiling. +- No shell dispatch of model output exists. This change does not add one. + +Not found here: David's unpublished local working tree. This gate does not pretend to +line up with modules that are not on the remote. If that tree already has a +different capability type, this patch is against `34ad7dd` only (`git diff 34ad7dd`). + +`kev_serve` behavior is unchanged. The crate comment now points here so a probability +is not mistaken for an allow. Infinity's `handle_content` path is still a design +note (`docs/infinity-suspend-resume.md`); this function is not hooked into it. + +## Limitations + +- Not general autonomous safety, not a sandbox, not an OS policy, not a deployment. +- The only typable allows are references to an existing decision hash or receipt. + Adding an execution verb would be a code change to the closed set, which this + gate is here to make obvious. +- In-process grants are not signed. The signature gate does not vouch for them. +- `dispatched_tool` on a receipt is not a capability and is not read by `authorize`. +- Cache-hit `Estimator` energy cannot pass a ceiling. There is no measured substitute + in this crate, and we do not invent one. +- No ceiling on the grant means no energy refusal. +- Strict canonical bytes only. A semantically identical JSON object is unstructured. +- The LFM result cited above is a reported shape, not a replay of the 60 logs. +- Experimental HTTP servers remain as `SECURITY.md` describes them. + +## Non-executing example + +`crates/receipt/examples/auth_check.rs` builds one inspect grant and one quote grant, +then prints decisions. The quote grant's ceiling is `0.5` J, supplied by the example, +not by a meter. Expected shape: + +```text +allow capability_id=op.inspect binding=<64 hex> +allow capability_id=op.quote binding=<64 hex> +deny no_authority +deny unstructured +deny unstructured +deny unstructured +deny unstructured +deny unstructured +deny unknown_action +deny scope_mismatch +deny over_budget +deny energy_unmeasured +auth_check: all expected decisions matched +``` + +The two `Commit` / `rm` lines are the third and fourth `deny unstructured` rows. +`echo hello` and `hello` are the same reason. Run: + +```sh +cargo run -p receipt --example auth_check +cargo test -p receipt +``` + +Neither command starts `kev_serve` or a shell. diff --git a/docs/infinity-suspend-resume.md b/docs/infinity-suspend-resume.md index 6004356..3dac5c9 100644 --- a/docs/infinity-suspend-resume.md +++ b/docs/infinity-suspend-resume.md @@ -84,3 +84,5 @@ Read: `crates/infinity-agent-core/src/{traits.rs,stores.rs,message.rs,event_proc ## Prototype `crates/receipt` (no GPU deps; sha2 only): `SliceReceipt`, `SliceKey`, `Tier`, `Provenance`, `InputKind`, `content_hash` (domain-separated, length-prefixed SHA-256; batch size excluded so batching does not change meaning), rolling `extend_prefix`, `receipt_hash` (chain), `cache_hit`, `apportion` (batch joules by token share, ModelBased). 9 unit tests. Signing (ed25519) and wiring into kev_serve are next. + +Signing and receipt emission on `/v1/decisions` landed after that sentence. Action authorization is a later, separate function, `crates/receipt::auth`, documented in [action-authorization.md](action-authorization.md). It is not hooked into `handle_content` or `kev_serve`, and it does not execute a dispatched tool. `sign::Gate` still only verifies envelopes. Mesh joule ceilings are still not a stored account; `auth` applies a ceiling only when a grant carries one, using the `Energy` value the caller already has. It does not meter.