Every interceptor step was metered as a span of its own. Where the meter reads this process's energy count, each read first takes the reading thread off its core (#229): tens of microseconds and its own wake-up. An interceptor step is often a few microseconds, so a span of its own read as mostly the meter, and a chain of k interceptors paid 2k reads per seam. A meter that reads a counter now meters the whole seam pass as one span (two reads per seam), and the steps are sealed after it closes, each with its share of the reading by its own thread CPU time, which is exact and read at no cost. The shares are integer micro-joules that sum to the reading exactly (largest remainder); a share of a reading is ModelBased, a pass of one step keeps the reading's own grade, and coverage is the pass's. A meter that reads nothing (a model, a constant) still meters each step on its own, so the receipts sealed with it are byte-identical. jcp-energy exposes the thread CPU clock it already had (`thread_cpu_time`). On the M3 Ultra, three steps burning 2, 4 and 8 ms were sealed 10.8, 21.5 and 42.8 mJ, summing to the chain's figure and to 96-97% of an independent read around the pass, in 10 of 10 rounds. Co-Authored-By: Claude Opus 5.5 <[email protected]>
1086 lines
43 KiB
Rust
1086 lines
43 KiB
Rust
//! Attested interceptor chains for JCP — a composable, ordered pipeline of
|
|
//! transforms around each model and tool call.
|
|
//!
|
|
//! An agent loop calls a model, the model asks for tools, the loop runs them. A
|
|
//! great deal of the work of making that loop safe and cheap lives *around* those
|
|
//! calls: redact a secret before it reaches the model, trim stale context, cap the
|
|
//! number of calls, hold a dangerous tool for a human. The incumbent shape for
|
|
//! this is a stack of in-process middleware — `before_model`, `after_model`,
|
|
//! `wrap_tool_call` — composed onion-order around each call.
|
|
//!
|
|
//! This ingests that shape, with the two things arbitrary in-process hooks lack:
|
|
//!
|
|
//! - **A per-step signed audit.** Every interceptor step seals an `intercept/1`
|
|
//! JCR-1 receipt carrying the request digest *before* and *after* the step, the
|
|
//! seam it ran at, its outcome, and the metered energy — hash-chained. The exact
|
|
//! transform pipeline that produced a model request is then reconstructable and
|
|
//! verifiable by anyone: you can prove a secret was redacted before the wire, or
|
|
//! that a call was refused, without ever sealing the content itself (only its
|
|
//! hash). A step cannot run un-sealed.
|
|
//! - **Honest joule accounting.** Each step is metered ([`jcp_energy`]); the
|
|
//! receipt records the figure at its true provenance, never overclaiming —
|
|
//! and a figure that covers only part of the step says so (`"coverage":
|
|
//! "Partial"`), rather than being sealed as the whole step's energy.
|
|
//!
|
|
//! Where the meter reads a counter, a seam is metered as one span around its
|
|
//! whole pass, not one per step ([`Chain::before_model`]). A counter read for
|
|
//! one process first takes the reading thread off its core, so each read
|
|
//! costs tens of microseconds and its own wake-up; an interceptor step is
|
|
//! often a few microseconds, which a span of its own would read as mostly the
|
|
//! meter. The pass's reading is divided among its steps by each step's own
|
|
//! thread CPU time ([`jcp_energy::thread_cpu_time`], exact and free to
|
|
//! read), so the steps' figures sum to the reading exactly; a step's share of
|
|
//! a reading is `ModelBased`, and a pass with one step keeps the reading's
|
|
//! own grade. A meter that reads nothing (a model or a constant) costs
|
|
//! nothing to run and still meters each step on its own.
|
|
//!
|
|
//! A [`Chain`] runs its interceptors forward through the `before_*` seams and in
|
|
//! reverse through `after_model` (the onion unwinds), stopping at the first
|
|
//! [`Step::Halt`] (a guardrail refused) or [`Step::Hold`] (a human must approve).
|
|
//! Three reference interceptors — [`Redact`], [`CallLimit`], [`Approve`] — cover a
|
|
//! content transform, a guardrail, and a human-in-the-loop hold; each reuses a
|
|
//! primitive the family already has rather than reinventing it.
|
|
|
|
use ed25519_dalek::SigningKey;
|
|
use jcp_energy::Meter;
|
|
use jcp_receipt::jcr1;
|
|
use jcp_schema::{ContentId, Coverage, Energy, Provenance};
|
|
use std::time::Duration;
|
|
use serde_json::{json, Value};
|
|
|
|
// ───────────────── the request/response/tool an interceptor transforms ─────────────────
|
|
|
|
/// The model request flowing through the chain — what an interceptor may inspect
|
|
/// or rewrite before the model runs (prompt, conversation, offered tools, model).
|
|
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
|
pub struct ModelRequest {
|
|
/// The system / instruction prompt.
|
|
pub prompt: String,
|
|
/// The conversation turns.
|
|
pub messages: Vec<String>,
|
|
/// The tool names offered to the model.
|
|
pub tools: Vec<String>,
|
|
/// The model / recipe id (what a fallback interceptor would swap).
|
|
pub model: String,
|
|
}
|
|
|
|
impl ModelRequest {
|
|
/// A content id over the request — what a receipt commits to before/after a
|
|
/// transform, so a redaction or a trim is provable without sealing the content.
|
|
pub fn digest(&self) -> ContentId {
|
|
let mut buf = Vec::new();
|
|
push_field(&mut buf, b"prompt", self.prompt.as_bytes());
|
|
for m in &self.messages {
|
|
push_field(&mut buf, b"msg", m.as_bytes());
|
|
}
|
|
for t in &self.tools {
|
|
push_field(&mut buf, b"tool", t.as_bytes());
|
|
}
|
|
push_field(&mut buf, b"model", self.model.as_bytes());
|
|
ContentId::of_bytes(&buf)
|
|
}
|
|
}
|
|
|
|
/// The model response, after the call — what an `after_model` interceptor inspects.
|
|
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
|
pub struct ModelResponse {
|
|
/// The generated text.
|
|
pub text: String,
|
|
/// The tool calls the model asked for.
|
|
pub tool_calls: Vec<ToolCall>,
|
|
}
|
|
|
|
impl ModelResponse {
|
|
/// A content id over the response.
|
|
pub fn digest(&self) -> ContentId {
|
|
let mut buf = Vec::new();
|
|
push_field(&mut buf, b"text", self.text.as_bytes());
|
|
for c in &self.tool_calls {
|
|
push_field(&mut buf, b"tool", c.tool.as_bytes());
|
|
push_field(&mut buf, b"args", c.args.as_bytes());
|
|
}
|
|
ContentId::of_bytes(&buf)
|
|
}
|
|
}
|
|
|
|
/// One tool call — what a `before_tool` interceptor may rewrite, refuse, or hold.
|
|
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
|
pub struct ToolCall {
|
|
/// The tool name.
|
|
pub tool: String,
|
|
/// The call arguments.
|
|
pub args: String,
|
|
}
|
|
|
|
impl ToolCall {
|
|
/// A content id over the call.
|
|
pub fn digest(&self) -> ContentId {
|
|
let mut buf = Vec::new();
|
|
push_field(&mut buf, b"tool", self.tool.as_bytes());
|
|
push_field(&mut buf, b"args", self.args.as_bytes());
|
|
ContentId::of_bytes(&buf)
|
|
}
|
|
}
|
|
|
|
/// Length-prefixed, tagged field concatenation — a deterministic preimage.
|
|
fn push_field(buf: &mut Vec<u8>, tag: &[u8], bytes: &[u8]) {
|
|
buf.extend_from_slice(&(tag.len() as u64).to_le_bytes());
|
|
buf.extend_from_slice(tag);
|
|
buf.extend_from_slice(&(bytes.len() as u64).to_le_bytes());
|
|
buf.extend_from_slice(bytes);
|
|
}
|
|
|
|
// ───────────────────────────── seam + step ─────────────────────────────
|
|
|
|
/// Where in the loop an interceptor step ran — recorded in its receipt.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub enum Seam {
|
|
/// Before the model call (forward order): inspect/rewrite the request.
|
|
BeforeModel,
|
|
/// After the model call (reverse order): inspect/rewrite the response.
|
|
AfterModel,
|
|
/// Before a tool executes: inspect/rewrite/refuse/hold the call.
|
|
BeforeTool,
|
|
}
|
|
|
|
impl Seam {
|
|
/// The wire label recorded in the receipt.
|
|
pub fn label(self) -> &'static str {
|
|
match self {
|
|
Seam::BeforeModel => "before_model",
|
|
Seam::AfterModel => "after_model",
|
|
Seam::BeforeTool => "before_tool",
|
|
}
|
|
}
|
|
}
|
|
|
|
/// What an interceptor decides at a seam.
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub enum Step {
|
|
/// Proceed with the (possibly rewritten) request/response/call.
|
|
Continue,
|
|
/// Refuse — the call must not run; the chain stops (a guardrail objected).
|
|
Halt(String),
|
|
/// Hold for a human to approve before proceeding (human-in-the-loop).
|
|
Hold(String),
|
|
}
|
|
|
|
impl Step {
|
|
fn label(&self) -> &'static str {
|
|
match self {
|
|
Step::Continue => "continue",
|
|
Step::Halt(_) => "halt",
|
|
Step::Hold(_) => "hold",
|
|
}
|
|
}
|
|
fn reason(&self) -> Option<&str> {
|
|
match self {
|
|
Step::Continue => None,
|
|
Step::Halt(r) | Step::Hold(r) => Some(r),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// One transform in the chain. A no-op default at each seam means an interceptor
|
|
/// implements only the seams it cares about.
|
|
pub trait Interceptor {
|
|
/// The interceptor's stable name — recorded in every receipt it seals.
|
|
fn name(&self) -> &str;
|
|
/// Inspect or rewrite the request before the model runs.
|
|
fn before_model(&mut self, _req: &mut ModelRequest) -> Step {
|
|
Step::Continue
|
|
}
|
|
/// Inspect or rewrite the response after the model runs.
|
|
fn after_model(&mut self, _req: &ModelRequest, _resp: &mut ModelResponse) -> Step {
|
|
Step::Continue
|
|
}
|
|
/// Inspect, rewrite, refuse, or hold a tool call before it executes.
|
|
fn before_tool(&mut self, _call: &mut ToolCall) -> Step {
|
|
Step::Continue
|
|
}
|
|
}
|
|
|
|
/// The result of running one seam across every interceptor.
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub enum ChainOutcome {
|
|
/// Every interceptor let the call continue.
|
|
Proceed,
|
|
/// An interceptor refused; the named one and its reason.
|
|
Halted {
|
|
/// The interceptor that refused.
|
|
interceptor: String,
|
|
/// Why.
|
|
reason: String,
|
|
},
|
|
/// An interceptor held for approval; the named one and its reason.
|
|
Held {
|
|
/// The interceptor that held.
|
|
interceptor: String,
|
|
/// Why.
|
|
reason: String,
|
|
},
|
|
}
|
|
|
|
impl ChainOutcome {
|
|
/// May the call proceed to the model/tool?
|
|
pub fn proceeds(&self) -> bool {
|
|
matches!(self, ChainOutcome::Proceed)
|
|
}
|
|
}
|
|
|
|
// ───────────────────────────── the chain ─────────────────────────────
|
|
|
|
/// A composable, attested pipeline of interceptors. Runs each seam onion-order —
|
|
/// `before_*` forward, `after_model` in reverse — metering and sealing every step
|
|
/// as an `intercept/1` receipt. Stops at the first `Halt`/`Hold`.
|
|
pub struct Chain {
|
|
interceptors: Vec<Box<dyn Interceptor>>,
|
|
meter: Meter,
|
|
steward_sk: SigningKey,
|
|
steward: String,
|
|
steward_pk: [u8; 32],
|
|
seq: u64,
|
|
prev: ContentId,
|
|
receipts: Vec<Vec<u8>>,
|
|
energy_uj: u64,
|
|
}
|
|
|
|
impl Chain {
|
|
/// A new chain sealing under `steward_sk` / `steward`, metering with the
|
|
/// platform's best energy interface.
|
|
pub fn new(steward_sk: SigningKey, steward: impl Into<String>) -> Self {
|
|
let steward_pk = steward_sk.verifying_key().to_bytes();
|
|
Chain {
|
|
interceptors: Vec::new(),
|
|
meter: Meter::detect(),
|
|
steward_sk,
|
|
steward: steward.into(),
|
|
steward_pk,
|
|
seq: 0,
|
|
prev: ContentId([0u8; 32]),
|
|
receipts: Vec::new(),
|
|
energy_uj: 0,
|
|
}
|
|
}
|
|
|
|
/// Override the meter (e.g. a constant meter for deterministic tests).
|
|
pub fn with_meter(mut self, meter: Meter) -> Self {
|
|
self.meter = meter;
|
|
self
|
|
}
|
|
|
|
/// Append an interceptor (builder form).
|
|
pub fn with(mut self, interceptor: Box<dyn Interceptor>) -> Self {
|
|
self.interceptors.push(interceptor);
|
|
self
|
|
}
|
|
|
|
/// Append an interceptor.
|
|
pub fn push(&mut self, interceptor: Box<dyn Interceptor>) {
|
|
self.interceptors.push(interceptor);
|
|
}
|
|
|
|
/// Run the request through every interceptor's `before_model` seam, forward
|
|
/// order, sealing each step. Stops at the first refusal/hold.
|
|
///
|
|
/// Where the meter reads a counter, the whole pass is one span and each
|
|
/// step is sealed with its share of it by thread CPU time (see the crate
|
|
/// docs); otherwise each step is metered on its own.
|
|
pub fn before_model(&mut self, req: &mut ModelRequest) -> ChainOutcome {
|
|
let order: Vec<usize> = (0..self.interceptors.len()).collect();
|
|
self.run_seam(Seam::BeforeModel, &order, |interceptor| {
|
|
let before = req.digest();
|
|
let step = interceptor.before_model(req);
|
|
(step, before, req.digest())
|
|
})
|
|
}
|
|
|
|
/// Run the response through every interceptor's `after_model` seam, **reverse**
|
|
/// order (the onion unwinding), sealing each step.
|
|
pub fn after_model(&mut self, req: &ModelRequest, resp: &mut ModelResponse) -> ChainOutcome {
|
|
let order: Vec<usize> = (0..self.interceptors.len()).rev().collect();
|
|
self.run_seam(Seam::AfterModel, &order, |interceptor| {
|
|
let before = resp.digest();
|
|
let step = interceptor.after_model(req, resp);
|
|
(step, before, resp.digest())
|
|
})
|
|
}
|
|
|
|
/// Run a tool call through every interceptor's `before_tool` seam, forward
|
|
/// order, sealing each step. Stops at the first refusal/hold.
|
|
pub fn before_tool(&mut self, call: &mut ToolCall) -> ChainOutcome {
|
|
let order: Vec<usize> = (0..self.interceptors.len()).collect();
|
|
self.run_seam(Seam::BeforeTool, &order, |interceptor| {
|
|
let before = call.digest();
|
|
let step = interceptor.before_tool(call);
|
|
(step, before, call.digest())
|
|
})
|
|
}
|
|
|
|
/// Run one seam over the interceptors in `order`, stopping after the
|
|
/// first step that halts or holds, and seal every step that ran.
|
|
///
|
|
/// A meter that reads a counter meters the pass as one span; the steps are
|
|
/// sealed after it closes, each with its [`apportion`]ed share by its own
|
|
/// thread CPU time, in the order they ran. Any other meter meters each step
|
|
/// as it runs, as one span of its own.
|
|
fn run_seam(
|
|
&mut self,
|
|
seam: Seam,
|
|
order: &[usize],
|
|
mut step_at: impl FnMut(&mut dyn Interceptor) -> (Step, ContentId, ContentId),
|
|
) -> ChainOutcome {
|
|
if order.is_empty() {
|
|
return ChainOutcome::Proceed;
|
|
}
|
|
if self.meter.provenance() == Provenance::HwShunt {
|
|
let (ran, pass) = {
|
|
let meter = &self.meter;
|
|
let interceptors = &mut self.interceptors;
|
|
meter.measure(|| {
|
|
let mut ran = Vec::with_capacity(order.len());
|
|
for &idx in order {
|
|
let cpu_before = jcp_energy::thread_cpu_time();
|
|
let (step, before, after) = step_at(interceptors[idx].as_mut());
|
|
let cpu = match (cpu_before, jcp_energy::thread_cpu_time()) {
|
|
(Some(a), Some(b)) => Some(b.saturating_sub(a)),
|
|
_ => None,
|
|
};
|
|
let stops = !matches!(step, Step::Continue);
|
|
ran.push((idx, step, before, after, cpu));
|
|
if stops {
|
|
break;
|
|
}
|
|
}
|
|
ran
|
|
})
|
|
};
|
|
let cpu: Vec<Option<Duration>> = ran.iter().map(|r| r.4).collect();
|
|
let mut outcome = ChainOutcome::Proceed;
|
|
for ((idx, step, before, after, _), energy) in ran.into_iter().zip(apportion(&pass, &cpu)) {
|
|
let name = self.interceptors[idx].name().to_string();
|
|
self.seal(&name, seam, &step, before, after, energy);
|
|
if let Some(stopped) = terminal(&name, &step) {
|
|
outcome = stopped;
|
|
}
|
|
}
|
|
return outcome;
|
|
}
|
|
for &idx in order {
|
|
let ((step, before, after), energy) = {
|
|
let meter = &self.meter;
|
|
let interceptor = &mut self.interceptors[idx];
|
|
meter.measure(|| step_at(interceptor.as_mut()))
|
|
};
|
|
let name = self.interceptors[idx].name().to_string();
|
|
self.seal(&name, seam, &step, before, after, energy);
|
|
if let Some(outcome) = terminal(&name, &step) {
|
|
return outcome;
|
|
}
|
|
}
|
|
ChainOutcome::Proceed
|
|
}
|
|
|
|
fn seal(
|
|
&mut self,
|
|
interceptor: &str,
|
|
seam: Seam,
|
|
step: &Step,
|
|
before: ContentId,
|
|
after: ContentId,
|
|
energy: Energy,
|
|
) {
|
|
let wire = intercept_wire(
|
|
interceptor, seam, step, before, after, &energy, self.seq, &self.prev, &self.steward,
|
|
);
|
|
let core = jcr1::signed_core_bytes(&wire).expect("intercept wire is a JCR-1 object");
|
|
let envelope = jcr1::cose_sign1_envelope(&core, &self.steward_sk, &self.steward);
|
|
self.prev = ContentId::of_bytes(&envelope);
|
|
self.seq += 1;
|
|
self.energy_uj += energy.uj();
|
|
self.receipts.push(envelope);
|
|
}
|
|
|
|
/// The sealed `intercept/1` receipts, in order.
|
|
pub fn receipts(&self) -> &[Vec<u8>] {
|
|
&self.receipts
|
|
}
|
|
|
|
/// Total metered energy across every sealed step, microjoules. Where a
|
|
/// step's figure was `Partial` (its receipt says so), so is this total.
|
|
pub fn energy_uj(&self) -> u64 {
|
|
self.energy_uj
|
|
}
|
|
|
|
/// The sealing steward id.
|
|
pub fn steward(&self) -> &str {
|
|
&self.steward
|
|
}
|
|
|
|
/// The sealing steward's public key — a third party verifies with this.
|
|
pub fn steward_pubkey(&self) -> [u8; 32] {
|
|
self.steward_pk
|
|
}
|
|
}
|
|
|
|
/// Divide one pass's figure among the steps that ran in it, by each step's
|
|
/// thread CPU time (`cpu`, in run order): integer micro-joules that sum to the
|
|
/// pass's figure exactly (largest remainder; a tie goes to the earlier step).
|
|
///
|
|
/// The shares are weighted by CPU time only where every step's was read and
|
|
/// they are not all zero; otherwise equally. A share of a reading is a model of
|
|
/// how the reading divides, so a pass graded `HwShunt` gives its steps
|
|
/// `ModelBased` shares; any other grade is kept. A pass of one step is not
|
|
/// divided and keeps its figure as it is. Coverage is the pass's: a `Partial`
|
|
/// reading gives `Partial` shares. The pass's span also holds the chain's own
|
|
/// work between steps (digests) and the meter's closing wake-up; all of it is
|
|
/// divided among the steps, so no step is under its own energy.
|
|
fn apportion(pass: &Energy, cpu: &[Option<Duration>]) -> Vec<Energy> {
|
|
let n = cpu.len();
|
|
if n == 0 {
|
|
return Vec::new();
|
|
}
|
|
if n == 1 {
|
|
return vec![*pass];
|
|
}
|
|
let total = pass.uj();
|
|
let weights: Vec<u128> = match cpu.iter().map(|c| c.map(|d| d.as_nanos())).collect::<Option<Vec<u128>>>() {
|
|
Some(w) if w.iter().any(|&x| x > 0) => w,
|
|
_ => vec![1; n],
|
|
};
|
|
let sum: u128 = weights.iter().sum();
|
|
let mut shares: Vec<u64> = weights.iter().map(|&w| (u128::from(total) * w / sum) as u64).collect();
|
|
let mut left = total - shares.iter().sum::<u64>();
|
|
// Largest remainder first; equal remainders go to the earlier step.
|
|
let mut order: Vec<usize> = (0..n).collect();
|
|
order.sort_by_key(|&i| std::cmp::Reverse(u128::from(total) * weights[i] % sum));
|
|
for &i in &order {
|
|
if left == 0 {
|
|
break;
|
|
}
|
|
shares[i] += 1;
|
|
left -= 1;
|
|
}
|
|
let provenance = match pass.provenance {
|
|
Provenance::HwShunt => Provenance::ModelBased,
|
|
other => other,
|
|
};
|
|
shares
|
|
.into_iter()
|
|
.map(|uj| Energy {
|
|
measured_uj: None,
|
|
estimated_uj: uj,
|
|
provenance,
|
|
coverage: pass.coverage,
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn terminal(name: &str, step: &Step) -> Option<ChainOutcome> {
|
|
match step {
|
|
Step::Continue => None,
|
|
Step::Halt(reason) => Some(ChainOutcome::Halted {
|
|
interceptor: name.to_string(),
|
|
reason: reason.clone(),
|
|
}),
|
|
Step::Hold(reason) => Some(ChainOutcome::Held {
|
|
interceptor: name.to_string(),
|
|
reason: reason.clone(),
|
|
}),
|
|
}
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn intercept_wire(
|
|
interceptor: &str,
|
|
seam: Seam,
|
|
step: &Step,
|
|
before: ContentId,
|
|
after: ContentId,
|
|
energy: &Energy,
|
|
seq: u64,
|
|
prev: &ContentId,
|
|
steward: &str,
|
|
) -> Value {
|
|
// JCR-1 forbids bools and floats: `transformed` is 0/1, energy is integer µJ.
|
|
let mut o = json!({
|
|
"intercept_receipt": "intercept/1",
|
|
"interceptor": interceptor,
|
|
"seam": seam.label(),
|
|
"outcome": step.label(),
|
|
"request_before": before.to_hex(),
|
|
"request_after": after.to_hex(),
|
|
"transformed": u64::from(before != after),
|
|
"energy_uj": energy.uj(),
|
|
"provenance": energy.provenance.jcr1_label(),
|
|
"seq": seq,
|
|
"prev": prev.to_hex(),
|
|
"steward": steward,
|
|
});
|
|
// Coverage rides beside provenance only when the figure is `Partial`. The
|
|
// whole-step case is the default and is omitted, so a receipt for a whole
|
|
// figure is byte-identical to one sealed before the field existed. Without
|
|
// it a partial figure would be sealed as the step's whole energy.
|
|
if !energy.is_whole() {
|
|
o["coverage"] = json!(energy.coverage.label());
|
|
}
|
|
if let Some(r) = step.reason() {
|
|
o["reason"] = json!(r);
|
|
}
|
|
o
|
|
}
|
|
|
|
// ───────────────────────────── verification ─────────────────────────────
|
|
|
|
/// Errors verifying a sealed `intercept/1` receipt.
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum InterceptError {
|
|
/// A JCR-1 envelope / signature error.
|
|
#[error("jcr1: {0}")]
|
|
Jcr1(#[from] jcr1::Jcr1Error),
|
|
/// A decode error reading the verified payload back.
|
|
#[error("schema: {0}")]
|
|
Schema(String),
|
|
/// The verified payload is not a well-formed `intercept/1` receipt.
|
|
#[error("malformed intercept receipt: {0}")]
|
|
Malformed(String),
|
|
}
|
|
|
|
/// The fields recovered from a verified `intercept/1` receipt.
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub struct InterceptReceipt {
|
|
/// The interceptor that ran.
|
|
pub interceptor: String,
|
|
/// The seam (`before_model` / `after_model` / `before_tool`).
|
|
pub seam: String,
|
|
/// The outcome (`continue` / `halt` / `hold`).
|
|
pub outcome: String,
|
|
/// The request content id before the step (hex).
|
|
pub request_before: String,
|
|
/// The request content id after the step (hex).
|
|
pub request_after: String,
|
|
/// Whether the step rewrote the request (before ≠ after).
|
|
pub transformed: bool,
|
|
/// The metered energy, microjoules.
|
|
pub energy_uj: u64,
|
|
/// The energy provenance label.
|
|
pub provenance: String,
|
|
/// How much of the step the energy figure covers: `Partial` when the
|
|
/// receipt carries `"coverage": "Partial"`, `Whole` when it carries no
|
|
/// coverage (the only other encoding a receipt may have).
|
|
pub coverage: Coverage,
|
|
/// The reason, for a halt/hold outcome.
|
|
pub reason: Option<String>,
|
|
/// The chain sequence number.
|
|
pub seq: u64,
|
|
/// The previous entry's content id (hex).
|
|
pub prev: String,
|
|
/// The sealing steward.
|
|
pub steward: String,
|
|
}
|
|
|
|
/// Verify a sealed `intercept/1` receipt against the steward key and recover its
|
|
/// fields. A flipped byte anywhere fails the signature.
|
|
pub fn verify(envelope: &[u8], steward: &str, pubkey: &[u8; 32]) -> Result<InterceptReceipt, InterceptError> {
|
|
let payload = jcr1::verify_cose_sign1(envelope, steward, pubkey)?;
|
|
let v: Value =
|
|
jcp_schema::from_canonical_cbor(&payload).map_err(|e| InterceptError::Schema(e.to_string()))?;
|
|
if v.get("intercept_receipt").and_then(Value::as_str) != Some("intercept/1") {
|
|
return Err(InterceptError::Malformed("not an intercept/1 receipt".into()));
|
|
}
|
|
let s = |k: &str| {
|
|
v.get(k)
|
|
.and_then(Value::as_str)
|
|
.map(str::to_string)
|
|
.ok_or_else(|| InterceptError::Malformed(format!("missing string '{k}'")))
|
|
};
|
|
let u = |k: &str| {
|
|
v.get(k)
|
|
.and_then(Value::as_u64)
|
|
.ok_or_else(|| InterceptError::Malformed(format!("missing uint '{k}'")))
|
|
};
|
|
// Present only for a partial figure; any other value, `"Whole"` included,
|
|
// is not an encoding a sealer produces, so it is refused rather than read.
|
|
let coverage = match v.get("coverage") {
|
|
None => Coverage::Whole,
|
|
Some(c) if c.as_str() == Some(Coverage::Partial.label()) => Coverage::Partial,
|
|
Some(c) => return Err(InterceptError::Malformed(format!("coverage {c} is not \"Partial\""))),
|
|
};
|
|
Ok(InterceptReceipt {
|
|
interceptor: s("interceptor")?,
|
|
seam: s("seam")?,
|
|
outcome: s("outcome")?,
|
|
request_before: s("request_before")?,
|
|
request_after: s("request_after")?,
|
|
transformed: u("transformed")? != 0,
|
|
energy_uj: u("energy_uj")?,
|
|
provenance: s("provenance")?,
|
|
coverage,
|
|
reason: v.get("reason").and_then(Value::as_str).map(str::to_string),
|
|
seq: u("seq")?,
|
|
prev: s("prev")?,
|
|
steward: s("steward")?,
|
|
})
|
|
}
|
|
|
|
/// Verify a whole chain of `intercept/1` receipts: each seal valid under the
|
|
/// steward key, and each `prev` linking to the prior envelope's content id.
|
|
/// Returns the number of verified links, or the index of the first break.
|
|
pub fn verify_chain(receipts: &[Vec<u8>], steward: &str, pubkey: &[u8; 32]) -> Result<usize, usize> {
|
|
let mut prev = ContentId([0u8; 32]).to_hex();
|
|
for (i, env) in receipts.iter().enumerate() {
|
|
let Ok(r) = verify(env, steward, pubkey) else {
|
|
return Err(i);
|
|
};
|
|
if r.prev != prev {
|
|
return Err(i);
|
|
}
|
|
prev = ContentId::of_bytes(env).to_hex();
|
|
}
|
|
Ok(receipts.len())
|
|
}
|
|
|
|
// ───────────────────────────── reference interceptors ─────────────────────────────
|
|
|
|
/// Redact secret / PII markers from the request (and tool args) before they reach
|
|
/// the model — a *transform* (before ≠ after digest), sealed, so you can prove the
|
|
/// secret never crossed the wire. The content-keyed guardrail as a cleaning step,
|
|
/// not merely a refusal.
|
|
pub struct Redact {
|
|
markers: Vec<String>,
|
|
}
|
|
|
|
impl Redact {
|
|
/// Redact these marker substrings.
|
|
pub fn new(markers: impl IntoIterator<Item = impl Into<String>>) -> Self {
|
|
Redact { markers: markers.into_iter().map(Into::into).collect() }
|
|
}
|
|
|
|
/// The default secret-egress markers (private keys, cloud secret ids).
|
|
pub fn secrets() -> Self {
|
|
Redact::new([
|
|
"BEGIN PRIVATE KEY",
|
|
"BEGIN RSA PRIVATE KEY",
|
|
"BEGIN OPENSSH PRIVATE KEY",
|
|
"aws_secret_access_key",
|
|
])
|
|
}
|
|
|
|
fn scrub(&self, s: &str) -> String {
|
|
let mut out = s.to_string();
|
|
for m in &self.markers {
|
|
if !m.is_empty() {
|
|
out = out.replace(m.as_str(), "[REDACTED]");
|
|
}
|
|
}
|
|
out
|
|
}
|
|
}
|
|
|
|
impl Interceptor for Redact {
|
|
fn name(&self) -> &str {
|
|
"redact"
|
|
}
|
|
fn before_model(&mut self, req: &mut ModelRequest) -> Step {
|
|
req.prompt = self.scrub(&req.prompt);
|
|
for m in &mut req.messages {
|
|
*m = self.scrub(m);
|
|
}
|
|
Step::Continue
|
|
}
|
|
fn before_tool(&mut self, call: &mut ToolCall) -> Step {
|
|
call.args = self.scrub(&call.args);
|
|
Step::Continue
|
|
}
|
|
}
|
|
|
|
/// Cap the number of model (or tool) calls in a run — a guardrail that halts once
|
|
/// the budget is spent (the model-call-limit / tool-call-limit shape).
|
|
pub struct CallLimit {
|
|
max: u32,
|
|
count: u32,
|
|
on_tools: bool,
|
|
}
|
|
|
|
impl CallLimit {
|
|
/// Cap model calls at `max`.
|
|
pub fn models(max: u32) -> Self {
|
|
CallLimit { max, count: 0, on_tools: false }
|
|
}
|
|
/// Cap tool calls at `max`.
|
|
pub fn tools(max: u32) -> Self {
|
|
CallLimit { max, count: 0, on_tools: true }
|
|
}
|
|
fn tick(&mut self) -> Step {
|
|
self.count += 1;
|
|
if self.count > self.max {
|
|
Step::Halt(format!("call limit of {} reached", self.max))
|
|
} else {
|
|
Step::Continue
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Interceptor for CallLimit {
|
|
fn name(&self) -> &str {
|
|
if self.on_tools {
|
|
"tool-call-limit"
|
|
} else {
|
|
"model-call-limit"
|
|
}
|
|
}
|
|
fn before_model(&mut self, _req: &mut ModelRequest) -> Step {
|
|
if self.on_tools {
|
|
Step::Continue
|
|
} else {
|
|
self.tick()
|
|
}
|
|
}
|
|
fn before_tool(&mut self, _call: &mut ToolCall) -> Step {
|
|
if self.on_tools {
|
|
self.tick()
|
|
} else {
|
|
Step::Continue
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Hold a named tool call for a human before it runs (human-in-the-loop). The hold
|
|
/// is sealed, so the pause point is on the audit chain; consent is granted out of
|
|
/// band (e.g. an editor's approval surface).
|
|
pub struct Approve {
|
|
tools: Vec<String>,
|
|
}
|
|
|
|
impl Approve {
|
|
/// Hold these tool names for approval.
|
|
pub fn tools(names: impl IntoIterator<Item = impl Into<String>>) -> Self {
|
|
Approve { tools: names.into_iter().map(Into::into).collect() }
|
|
}
|
|
}
|
|
|
|
impl Interceptor for Approve {
|
|
fn name(&self) -> &str {
|
|
"approve"
|
|
}
|
|
fn before_tool(&mut self, call: &mut ToolCall) -> Step {
|
|
if self.tools.iter().any(|t| t == &call.tool) {
|
|
Step::Hold(format!("tool '{}' requires approval", call.tool))
|
|
} else {
|
|
Step::Continue
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn chain() -> Chain {
|
|
// A constant meter keeps energy deterministic in tests.
|
|
Chain::new(SigningKey::from_bytes(&[7u8; 32]), "did:jcp:test-intercept")
|
|
.with_meter(Meter::constant(1_000))
|
|
}
|
|
|
|
fn req() -> ModelRequest {
|
|
ModelRequest {
|
|
prompt: "you are helpful".into(),
|
|
messages: vec!["read the file".into()],
|
|
tools: vec!["read_file".into(), "run".into()],
|
|
model: "joule-l3".into(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn redact_transforms_the_request_and_seals_the_change() {
|
|
let mut c = chain().with(Box::new(Redact::secrets()));
|
|
let mut r = req();
|
|
r.messages.push("key: -----BEGIN PRIVATE KEY----- abcd".into());
|
|
let outcome = c.before_model(&mut r);
|
|
assert_eq!(outcome, ChainOutcome::Proceed);
|
|
// The secret is gone from the request the model would see.
|
|
assert!(!r.messages.iter().any(|m| m.contains("BEGIN PRIVATE KEY")));
|
|
assert!(r.messages.iter().any(|m| m.contains("[REDACTED]")));
|
|
// The receipt records that this step transformed the request.
|
|
let rec = verify(&c.receipts()[0], c.steward(), &c.steward_pubkey()).unwrap();
|
|
assert_eq!(rec.interceptor, "redact");
|
|
assert_eq!(rec.seam, "before_model");
|
|
assert_eq!(rec.outcome, "continue");
|
|
assert!(rec.transformed);
|
|
assert_ne!(rec.request_before, rec.request_after);
|
|
}
|
|
|
|
#[test]
|
|
fn a_clean_request_is_not_marked_transformed() {
|
|
let mut c = chain().with(Box::new(Redact::secrets()));
|
|
c.before_model(&mut req());
|
|
let rec = verify(&c.receipts()[0], c.steward(), &c.steward_pubkey()).unwrap();
|
|
assert!(!rec.transformed);
|
|
assert_eq!(rec.request_before, rec.request_after);
|
|
}
|
|
|
|
#[test]
|
|
fn call_limit_halts_after_the_budget() {
|
|
let mut c = chain().with(Box::new(CallLimit::models(2)));
|
|
assert!(c.before_model(&mut req()).proceeds());
|
|
assert!(c.before_model(&mut req()).proceeds());
|
|
match c.before_model(&mut req()) {
|
|
ChainOutcome::Halted { interceptor, .. } => assert_eq!(interceptor, "model-call-limit"),
|
|
other => panic!("expected halt, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn approve_holds_a_named_tool_but_passes_others() {
|
|
let mut c = chain().with(Box::new(Approve::tools(["run"])));
|
|
assert!(c
|
|
.before_tool(&mut ToolCall { tool: "read_file".into(), args: "x".into() })
|
|
.proceeds());
|
|
match c.before_tool(&mut ToolCall { tool: "run".into(), args: "cargo test".into() }) {
|
|
ChainOutcome::Held { interceptor, reason } => {
|
|
assert_eq!(interceptor, "approve");
|
|
assert!(reason.contains("run"));
|
|
}
|
|
other => panic!("expected hold, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn onion_order_is_forward_before_and_reverse_after() {
|
|
// Two order-recording interceptors prove before_model runs forward and
|
|
// after_model runs in reverse.
|
|
struct Recorder {
|
|
id: u8,
|
|
log: std::rc::Rc<std::cell::RefCell<Vec<String>>>,
|
|
}
|
|
impl Interceptor for Recorder {
|
|
fn name(&self) -> &str {
|
|
"recorder"
|
|
}
|
|
fn before_model(&mut self, _r: &mut ModelRequest) -> Step {
|
|
self.log.borrow_mut().push(format!("before{}", self.id));
|
|
Step::Continue
|
|
}
|
|
fn after_model(&mut self, _r: &ModelRequest, _resp: &mut ModelResponse) -> Step {
|
|
self.log.borrow_mut().push(format!("after{}", self.id));
|
|
Step::Continue
|
|
}
|
|
}
|
|
let log = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
|
|
let mut c = chain()
|
|
.with(Box::new(Recorder { id: 1, log: log.clone() }))
|
|
.with(Box::new(Recorder { id: 2, log: log.clone() }));
|
|
let mut r = req();
|
|
c.before_model(&mut r);
|
|
let mut resp = ModelResponse::default();
|
|
c.after_model(&r, &mut resp);
|
|
assert_eq!(*log.borrow(), vec!["before1", "before2", "after2", "after1"]);
|
|
}
|
|
|
|
#[test]
|
|
fn every_step_seals_a_hash_chained_receipt() {
|
|
let mut c = chain()
|
|
.with(Box::new(Redact::secrets()))
|
|
.with(Box::new(CallLimit::models(5)));
|
|
c.before_model(&mut req());
|
|
assert_eq!(c.receipts().len(), 2);
|
|
assert_eq!(verify_chain(c.receipts(), c.steward(), &c.steward_pubkey()), Ok(2));
|
|
}
|
|
|
|
#[test]
|
|
fn a_tampered_receipt_fails_verification() {
|
|
let mut c = chain().with(Box::new(Redact::secrets()));
|
|
c.before_model(&mut req());
|
|
let mut env = c.receipts()[0].clone();
|
|
*env.last_mut().unwrap() ^= 0x01; // flip a signature byte
|
|
assert!(verify(&env, c.steward(), &c.steward_pubkey()).is_err());
|
|
}
|
|
|
|
fn ns(n: u64) -> Option<Duration> {
|
|
Some(Duration::from_nanos(n))
|
|
}
|
|
|
|
/// A pass's reading divides among its steps by thread CPU time, in whole
|
|
/// micro-joules that sum to the reading exactly, as `ModelBased` shares.
|
|
#[test]
|
|
fn a_reading_divides_among_its_steps_by_cpu_time_and_sums_exactly() {
|
|
let pass = Energy::measured(1_000);
|
|
let shares = apportion(&pass, &[ns(1_000), ns(2_000), ns(7_000)]);
|
|
assert_eq!(shares.iter().map(Energy::uj).collect::<Vec<_>>(), vec![100, 200, 700]);
|
|
for e in &shares {
|
|
assert_eq!((e.provenance, e.coverage, e.measured_uj), (Provenance::ModelBased, Coverage::Whole, None));
|
|
e.check_provenance().unwrap();
|
|
}
|
|
// Remainders: 1000 µJ over thirds is 333/333/334, never 999 or 1002.
|
|
for (cpu, want) in [
|
|
(vec![ns(1), ns(1), ns(1)], vec![334, 333, 333]),
|
|
(vec![ns(3), ns(3), ns(1)], vec![429, 428, 143]),
|
|
(vec![ns(5), ns(0), ns(5)], vec![500, 0, 500]),
|
|
] {
|
|
let got: Vec<u64> = apportion(&pass, &cpu).iter().map(Energy::uj).collect();
|
|
assert_eq!(got, want, "{cpu:?}");
|
|
assert_eq!(got.iter().sum::<u64>(), 1_000);
|
|
}
|
|
}
|
|
|
|
/// One step is not divided: it keeps the reading, grade and all.
|
|
#[test]
|
|
fn a_pass_of_one_step_keeps_its_reading() {
|
|
let pass = Energy::measured(1_234);
|
|
assert_eq!(apportion(&pass, &[ns(9)]), vec![pass]);
|
|
assert!(apportion(&pass, &[]).is_empty());
|
|
}
|
|
|
|
/// Without every step's CPU time, or with none of it non-zero, the
|
|
/// reading divides equally; a partial reading gives partial shares; a
|
|
/// grade below a reading is kept.
|
|
#[test]
|
|
fn shares_fall_back_to_equal_and_keep_the_passs_coverage_and_lower_grade() {
|
|
let got: Vec<u64> = apportion(&Energy::measured(10), &[ns(5), None]).iter().map(Energy::uj).collect();
|
|
assert_eq!(got, vec![5, 5]);
|
|
let got: Vec<u64> = apportion(&Energy::measured(10), &[ns(0), ns(0)]).iter().map(Energy::uj).collect();
|
|
assert_eq!(got, vec![5, 5]);
|
|
for e in apportion(&Energy::measured_partial(9), &[ns(1), ns(2)]) {
|
|
assert_eq!((e.provenance, e.coverage), (Provenance::ModelBased, Coverage::Partial));
|
|
}
|
|
for e in apportion(&Energy::estimator(9), &[ns(1), ns(2)]) {
|
|
assert_eq!(e.provenance, Provenance::Estimator);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn energy_is_metered_and_accumulates_honestly() {
|
|
let mut c = chain()
|
|
.with(Box::new(Redact::secrets()))
|
|
.with(Box::new(CallLimit::models(5)));
|
|
c.before_model(&mut req());
|
|
// Two steps at the constant meter's 1_000 µJ each.
|
|
assert_eq!(c.energy_uj(), 2_000);
|
|
let rec = verify(&c.receipts()[0], c.steward(), &c.steward_pubkey()).unwrap();
|
|
assert_eq!(rec.energy_uj, 1_000);
|
|
assert_eq!(rec.provenance, "Estimator"); // Meter::constant → Estimator
|
|
}
|
|
|
|
/// The `intercept/1` envelopes the chain in
|
|
/// [`a_whole_figure_seals_byte_identically_to_before`] sealed at 3fd35db5,
|
|
/// before coverage was carried: a `Redact` then `CallLimit` step at the
|
|
/// constant meter's 1 000 µJ, under the tests' key and steward.
|
|
const WHOLE_BEFORE_COVERAGE: [&str; 2] = [
|
|
concat!(
|
|
"d284581ba2012704566469643a6a63703a746573742d696e74657263657074a0590190ac637365710064707265767840",
|
|
"303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030",
|
|
"30303030303030303030303030303030647365616d6c6265666f72655f6d6f64656c676f7574636f6d6568636f6e7469",
|
|
"6e75656773746577617264766469643a6a63703a746573742d696e7465726365707469656e657267795f756a1903e86a",
|
|
"70726f76656e616e636569457374696d61746f726b696e746572636570746f72667265646163746b7472616e73666f72",
|
|
"6d6564006d726571756573745f6166746572784061393861313863336263643239363363613966363839633334396531",
|
|
"3364636464303237366532343566303239623034353639626232376331316635653963636e726571756573745f626566",
|
|
"6f7265784061393861313863336263643239363363613966363839633334396531336463646430323736653234356630",
|
|
"32396230343536396262323763313166356539636371696e746572636570745f726563656970746b696e746572636570",
|
|
"742f315840b128f5ec05886ebc7207c2e8687c04f5c8b452e2bde4f2ba4f241b0c9b86bdcb9319aba3815f10fd9572fe",
|
|
"d52bc91c848e33276f09cd7cf75426a5f81313c903",
|
|
),
|
|
concat!(
|
|
"d284581ba2012704566469643a6a63703a746573742d696e74657263657074a059019aac637365710164707265767840",
|
|
"653934313538363465383261656238303162363036626233663836366235356661613863313330353761613463626232",
|
|
"66663035653564643730616632306264647365616d6c6265666f72655f6d6f64656c676f7574636f6d6568636f6e7469",
|
|
"6e75656773746577617264766469643a6a63703a746573742d696e7465726365707469656e657267795f756a1903e86a",
|
|
"70726f76656e616e636569457374696d61746f726b696e746572636570746f72706d6f64656c2d63616c6c2d6c696d69",
|
|
"746b7472616e73666f726d6564006d726571756573745f61667465727840613938613138633362636432393633636139",
|
|
"663638396333343965313364636464303237366532343566303239623034353639626232376331316635653963636e72",
|
|
"6571756573745f6265666f72657840613938613138633362636432393633636139663638396333343965313364636464",
|
|
"3032373665323435663032396230343536396262323763313166356539636371696e746572636570745f726563656970",
|
|
"746b696e746572636570742f3158401bb080b73a487db157a7375884d9983a13725e45e79e5532726a17cd5c0ed2dfb0",
|
|
"541a67e23371d456099130ae763c96bc0f8c2f29e3b68340d5b8a2ccd9ba08",
|
|
),
|
|
];
|
|
|
|
fn payload(env: &[u8], c: &Chain) -> Value {
|
|
let core = jcr1::verify_cose_sign1(env, c.steward(), &c.steward_pubkey()).expect("a sealed receipt verifies");
|
|
jcp_schema::from_canonical_cbor(&core).expect("the core decodes")
|
|
}
|
|
|
|
/// A whole figure seals exactly the bytes it sealed before coverage
|
|
/// existed: no `coverage` key, the same envelopes, byte for byte.
|
|
#[test]
|
|
fn a_whole_figure_seals_byte_identically_to_before() {
|
|
let mut c = chain()
|
|
.with(Box::new(Redact::secrets()))
|
|
.with(Box::new(CallLimit::models(5)));
|
|
c.before_model(&mut req());
|
|
assert_eq!(c.receipts().len(), 2);
|
|
for (i, env) in c.receipts().iter().enumerate() {
|
|
assert!(payload(env, &c).get("coverage").is_none(), "receipt {i} carries a coverage for a whole figure");
|
|
assert_eq!(verify(env, c.steward(), &c.steward_pubkey()).unwrap().coverage, Coverage::Whole);
|
|
assert_eq!(hex::encode(env), WHOLE_BEFORE_COVERAGE[i], "receipt {i} is not the envelope sealed before coverage");
|
|
}
|
|
}
|
|
|
|
/// The same for a whole hardware reading, `HwShunt`, which the
|
|
/// constant meter cannot produce: its object is the `intercept/1` object
|
|
/// as it was, key for key, and canonicalizes to the same core bytes.
|
|
#[test]
|
|
fn a_whole_reading_seals_the_object_it_sealed_before() {
|
|
let (before, after) = (ContentId::of_bytes(b"a"), ContentId::of_bytes(b"b"));
|
|
let prev = ContentId([3u8; 32]);
|
|
let wire = intercept_wire(
|
|
"redact", Seam::BeforeTool, &Step::Continue, before, after, &Energy::measured(7), 4, &prev, "did:jcp:s",
|
|
);
|
|
let as_before = json!({
|
|
"intercept_receipt": "intercept/1",
|
|
"interceptor": "redact",
|
|
"seam": "before_tool",
|
|
"outcome": "continue",
|
|
"request_before": before.to_hex(),
|
|
"request_after": after.to_hex(),
|
|
"transformed": 1u64,
|
|
"energy_uj": 7u64,
|
|
"provenance": "HwShunt",
|
|
"seq": 4u64,
|
|
"prev": prev.to_hex(),
|
|
"steward": "did:jcp:s",
|
|
});
|
|
assert_eq!(wire, as_before);
|
|
assert_eq!(jcr1::signed_core_bytes(&wire).unwrap(), jcr1::signed_core_bytes(&as_before).unwrap());
|
|
}
|
|
|
|
/// A partial figure is sealed as one: `"coverage": "Partial"` beside its
|
|
/// provenance, recovered by `verify`, and that key is the only difference
|
|
/// from the same figure sealed whole.
|
|
#[test]
|
|
fn a_partial_figure_seals_coverage_partial() {
|
|
let mut c = chain();
|
|
let d = req().digest();
|
|
c.seal("meter", Seam::BeforeModel, &Step::Continue, d, d, Energy::measured_partial(500));
|
|
c.seal("meter", Seam::BeforeModel, &Step::Continue, d, d, Energy::measured(500));
|
|
let (partial, whole) = (payload(&c.receipts()[0], &c), payload(&c.receipts()[1], &c));
|
|
assert_eq!(partial["coverage"], json!("Partial"), "a partial figure sealed without its coverage: {partial}");
|
|
let rec = verify(&c.receipts()[0], c.steward(), &c.steward_pubkey()).unwrap();
|
|
assert_eq!((rec.coverage, rec.provenance.as_str(), rec.energy_uj), (Coverage::Partial, "HwShunt", 500));
|
|
let strip = |mut v: Value| {
|
|
let o = v.as_object_mut().unwrap();
|
|
o.remove("coverage");
|
|
o.remove("seq");
|
|
o.remove("prev");
|
|
v
|
|
};
|
|
assert_eq!(strip(partial), strip(whole), "a partial figure's receipt differs from a whole one's beyond its coverage");
|
|
}
|
|
|
|
/// `coverage` has one encoding, `"Partial"`, and absence means whole. A
|
|
/// receipt carrying any other value, an explicit `"Whole"` included, is
|
|
/// refused rather than read either way.
|
|
#[test]
|
|
fn verify_refuses_a_coverage_other_than_partial() {
|
|
let sk = SigningKey::from_bytes(&[7u8; 32]);
|
|
let steward = "did:jcp:test-intercept";
|
|
let d = req().digest();
|
|
let whole = intercept_wire("m", Seam::BeforeModel, &Step::Continue, d, d, &Energy::measured(1), 0, &d, steward);
|
|
for bad in [json!("Whole"), json!("partial"), json!(1u64)] {
|
|
let mut wire = whole.clone();
|
|
wire["coverage"] = bad.clone();
|
|
let env = jcr1::cose_sign1_envelope(&jcr1::signed_core_bytes(&wire).unwrap(), &sk, steward);
|
|
let got = verify(&env, steward, &sk.verifying_key().to_bytes());
|
|
assert!(matches!(got, Err(InterceptError::Malformed(_))), "coverage {bad} was read: {got:?}");
|
|
}
|
|
}
|
|
}
|