From 449172c36e6f1c7cad62ac98eb9742564467fd94 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Sun, 18 Oct 2026 01:38:36 -0400 Subject: [PATCH 1/2] joulehook: the joule budget survives the process, so it can be exceeded MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SPEC §3.3 is normative: "If the action carries an estimable cost, the grant's joule budget MUST be ≥ the estimate; over budget is `deny`. On `allow`, the spent amount is debited." The debit lived in the `LocalToolGate` that `gate()` constructs, and `gate()` runs once per process — this binary is a PreToolUse command the host spawns per action. So the spend total died with the process on every single call and the next action started again at the full `JOULEHOOK_BUDGET_UJ`. **There was no sequence of actions that could exhaust the budget**, so the deny branch could never be taken, and `conformance/v1/receipt.json` records the symptom: `budget_remaining_uj: 1000000000`, the untouched ceiling. Worse, the deny branch that *was* reachable reported the wrong thing: a single message, "is not in the granted capability set", covered every denial — so a budget denial would have been recorded as a capability denial if one could ever have happened. The hook already wrote the evidence and never read it: sealed receipts are appended to `JOULEHOOK_RECEIPT_LOG`, hex per line. - `LocalToolGate::resume` replays a sealed stream into the gate's spend ledger via `jcp_receipt::replay_spend`, so only verified allow-receipts of this gate's own steward contribute debits. `Bridge` gains `steward()`, `steward_pubkey()` and `restore()` to make that possible. The grants are deterministic (fixed issuer key, `nonce: [0; 16]`, `expiry: 0`), which is exactly why a later process rebuilds byte-identical grant ids for the debits to land on — and exactly why the budget refilled without them. - `gate()` replays the stream before deciding, and the allow reason now records the spend and how many receipts it replayed. - A budget denial says it is a budget denial. - **No stream configured** ⇒ the ceiling covers one call only. That is now disclosed in the allow reason rather than asserted: §3.3 states the budget MUST hold, and per-call it cannot. The crate's env doc previously carried the disclosure ("Cross-call budget accounting needs a persistent gate") while the SPEC's MUST did not; the doc is updated to point at the stream that now provides it. - **A stream that does not decode denies.** The prior spend is then unknown, the budget cannot be asserted, and allowing anyway is the refill the replay exists to prevent — fail closed, as SPEC §8 has this binary do for an unparseable payload and an unrecognised shape. A missing file is simply the first call. The headline test runs three separate `run()` calls — each a fresh gate, which is what a per-action process is — persisting between them as `main` does: 2 mJ, 2 mJ, 2 mJ against a 5 mJ ceiling gives allow, allow, **deny**, with the denial naming the budget. Previously that third call allowed, and so would the three-hundredth. Co-Authored-By: Claude Opus 5 --- jcp/jcp-rs/crates/jcp-bridge/src/lib.rs | 39 ++++ .../crates/joulehook-hook/src/main.rs | 190 +++++++++++++++++- 2 files changed, 224 insertions(+), 5 deletions(-) diff --git a/jcp/jcp-rs/crates/jcp-bridge/src/lib.rs b/jcp/jcp-rs/crates/jcp-bridge/src/lib.rs index 48c05e0fe..8fa98623c 100644 --- a/jcp/jcp-rs/crates/jcp-bridge/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-bridge/src/lib.rs @@ -126,6 +126,23 @@ impl Bridge { Self { runtime: Runtime::new(), steward_sk, steward: steward.into() } } + /// The steward these decisions are sealed under. + pub fn steward(&self) -> &str { + &self.steward + } + + /// The steward's public key — what [`jcp_receipt::replay_spend`] checks a + /// stream against before any of its debits are applied. + pub fn steward_pubkey(&self) -> [u8; 32] { + self.steward_sk.verifying_key().to_bytes() + } + + /// Rebuild spend from a replayed ledger (SPEC §8.1: an enforcement point + /// that restarts rebuilds its budget state from its own sealed output). + pub fn restore(&mut self, entries: impl IntoIterator) { + self.runtime.restore(entries); + } + pub fn spent(&self, grant_id: &ContentId) -> u64 { self.runtime.spent(grant_id) } @@ -295,6 +312,28 @@ impl LocalToolGate { } } + /// Rebuild the spend totals from this gate's own sealed receipt stream. + /// + /// A gate that is spawned once per action — the shape every editor + /// PreToolUse hook has — holds its spend only for that one call. Without + /// this, every action starts again at the full ceiling, and SPEC §3.3's + /// "over budget is `deny`" can never fire however many actions run: there + /// is no sequence that exhausts the budget. + /// + /// Only verified allow-receipts contribute debits + /// ([`jcp_receipt::replay_spend`]), so an envelope this gate's steward did + /// not seal is ignored rather than trusted. The grants are deterministic, + /// so a later process rebuilds byte-identical grant ids for the debits to + /// land on. Returns the number of debits replayed. + pub fn resume<'a>(&mut self, prior: impl IntoIterator) -> usize { + let steward = self.bridge.steward().to_string(); + let pubkey = self.bridge.steward_pubkey(); + let spend = jcp_receipt::replay_spend(prior, &steward, &pubkey); + let n = spend.len(); + self.bridge.restore(spend); + n + } + /// Decide whether `tool` may be called with `args`, attributing `cost_uj` to /// the call. On allow the cost is debited from the tool's grant; either way /// the decision seals. Returns `false` — and the caller must not execute the diff --git a/joulehook/joulehook-rs/crates/joulehook-hook/src/main.rs b/joulehook/joulehook-rs/crates/joulehook-hook/src/main.rs index 82abb555b..5d8a12777 100644 --- a/joulehook/joulehook-rs/crates/joulehook-hook/src/main.rs +++ b/joulehook/joulehook-rs/crates/joulehook-hook/src/main.rs @@ -22,10 +22,15 @@ //! **observe** mode: allow, and disclose that it is not enforcing (SPEC §7). //! - `JOULEHOOK_SERVER` — grant-audience label (default ``). //! - `JOULEHOOK_BUDGET_UJ` — per-tool joule budget, µJ (default 1e9). -//! - `JOULEHOOK_COST_UJ` — joules attributed to each call, µJ (default 0). Cross-call -//! budget accounting needs a persistent gate; this v0 enforces the capability set -//! per call and seals each decision. +//! - `JOULEHOOK_COST_UJ` — joules attributed to each call, µJ (default 0). +//! Cross-call budget accounting needs the spend to outlive the process; the +//! sealed receipt stream below is that persistence, replayed at each call. //! - `JOULEHOOK_RECEIPT_LOG` — path to append sealed JCR-1 receipts (hex per line). +//! Also **read back** at each call to replay prior debits, which is what makes +//! `JOULEHOOK_BUDGET_UJ` a session ceiling: this binary is spawned once per +//! action, so without the stream the spend total dies with the process and +//! SPEC §3.3's "over budget is `deny`" can never fire. Unset ⇒ the budget +//! covers one call only, and each allow says so. use std::io::{Read, Write}; @@ -204,15 +209,89 @@ fn env_u64(key: &str, default: u64) -> u64 { /// The gate decision (SPEC §3.2/§3.3): decide `event` against the granted capability /// set, seal, and return `(allow, reason, sealed_receipts)`. Decided outside the /// model — a deterministic function of the action and the held capabilities. +/// Read the sealed receipts this hook has already written and replay their +/// debits, so `JOULEHOOK_BUDGET_UJ` is a session ceiling rather than a +/// per-call one. +/// +/// This binary is spawned once per action, so the gate's spend total dies with +/// the process. Replaying the stream is what makes SPEC §3.3's "over budget is +/// `deny`" reachable at all — without it every action starts again at the full +/// ceiling and no sequence of actions can exhaust the budget. +/// +/// Returns the number of debits replayed, or `Err` with the reason the stream +/// could not be read. A stream that does not decode is an error, not an empty +/// one: a receipt whose line is unreadable carries a debit, and treating it as +/// absent refills exactly that much budget. +fn replay_prior_spend(path: &str, g: &mut LocalToolGate) -> Result { + let text = match std::fs::read_to_string(path) { + Ok(t) => t, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(0), + Err(e) => return Err(format!("{path}: {e}")), + }; + let mut envelopes: Vec> = Vec::new(); + for (i, line) in text.lines().enumerate() { + let line = line.trim(); + if line.is_empty() { + continue; + } + let bytes = hex::decode(line).map_err(|e| format!("{path}: line {}: {e}", i + 1))?; + envelopes.push(bytes); + } + Ok(g.resume(envelopes.iter().map(Vec::as_slice))) +} + fn gate(event: &ActionEvent, cfg: &Config) -> (bool, String, Vec>) { let tool_refs: Vec<&str> = cfg.tools.iter().map(String::as_str).collect(); let mut g = LocalToolGate::new(&cfg.server, &tool_refs, cfg.budget_uj); + + // The budget is only a ceiling if the spend survives the process. + let replayed = match &cfg.receipt_log { + Some(path) => match replay_prior_spend(path, &mut g) { + Ok(n) => Some(n), + // Fail closed, as SPEC §8 has this binary do everywhere else: with + // the prior spend unknown, the budget cannot be asserted, and + // allowing anyway is the refill the replay exists to prevent. + Err(why) => { + return ( + false, + format!("deny: the sealed receipt stream could not be read, so the joule budget cannot be enforced ({why})"), + vec![], + ); + } + }, + None => None, + }; + let args = serde_json::to_vec(&event.input).unwrap_or_default(); let allow = g.authorize(&event.tool, &args, cfg.cost_uj); let reason = if allow { - format!("allow: '{}' is in the granted capability set and within budget", event.tool) - } else { + match replayed { + Some(n) => format!( + "allow: '{}' is in the granted capability set and within budget ({} uJ of {} uJ spent, {n} prior receipts replayed)", + event.tool, + g.spent_uj(), + cfg.budget_uj + ), + // No stream to replay, so the ceiling only ever covers this one + // call. Disclosed rather than asserted: SPEC §3.3 states the + // budget MUST hold, and per-call it cannot. + None => format!( + "allow: '{}' is in the granted capability set; the joule budget is NOT enforced across calls (set JOULEHOOK_RECEIPT_LOG to make it a session ceiling)", + event.tool + ), + } + } else if !g.grants_tool(&event.tool) { format!("deny: '{}' is not in the granted capability set (decided outside the model)", event.tool) + } else { + // Reachable only now that the spend survives: before the replay this + // branch could never be taken, so a budget denial was reported as a + // capability denial. + format!( + "deny: '{}' is granted but over its joule budget ({} uJ of {} uJ already spent)", + event.tool, + g.spent_uj(), + cfg.budget_uj + ) }; (allow, reason, g.receipts().to_vec()) } @@ -418,6 +497,107 @@ mod tests { assert!(receipts.is_empty()); } + fn tempdir() -> std::path::PathBuf { + let mut p = std::env::temp_dir(); + p.push(format!( + "joulehook-budget-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0) + )); + std::fs::create_dir_all(&p).expect("mkdir"); + p + } + + fn metered_cfg(log: Option) -> Config { + Config { + server: "claude-code".into(), + tools: vec!["Read".into()], + budget_uj: 5_000_000, + cost_uj: 2_000_000, + receipt_log: log, + } + } + + fn decide(cfg: &Config) -> (String, String) { + let (out, _, receipts) = run(Host::ClaudeCode, &event_json("Read"), cfg); + if let Some(path) = &cfg.receipt_log { + persist_receipts(path, &receipts); + } + let v: Value = serde_json::from_str(&out).unwrap(); + let o = &v["hookSpecificOutput"]; + ( + o["permissionDecision"].as_str().unwrap().to_string(), + o["permissionDecisionReason"].as_str().unwrap().to_string(), + ) + } + + /// SPEC §3.3: "the grant's joule budget MUST be >= the estimate; over + /// budget is `deny`." + /// + /// This binary is spawned once per action, so the gate's spend died with + /// the process and every call started again at the full ceiling — there + /// was no sequence of actions that could exhaust the budget, and the deny + /// branch could never be taken. Each `run` below is a separate process; + /// persisting between them is what `main` does. + #[test] + fn the_budget_is_a_session_ceiling_when_the_stream_is_kept() { + let dir = tempdir(); + let cfg = metered_cfg(Some(dir.join("receipts.hex").to_string_lossy().into_owned())); + + let first = decide(&cfg); + let second = decide(&cfg); + let third = decide(&cfg); + + assert_eq!(first.0, "allow", "2 mJ of a 5 mJ ceiling: {}", first.1); + assert_eq!(second.0, "allow", "4 mJ of a 5 mJ ceiling: {}", second.1); + assert_eq!(third.0, "deny", "6 mJ would exceed it: {}", third.1); + assert!( + third.1.contains("over its joule budget"), + "a budget denial says so, rather than reporting a capability denial: {}", + third.1 + ); + assert!( + second.1.contains("prior receipts replayed"), + "the allow records what it replayed: {}", + second.1 + ); + } + + /// The discriminating half: with no stream the ceiling covers one call, + /// so it is disclosed rather than asserted. + #[test] + fn without_a_stream_the_budget_is_not_enforced_across_calls() { + let cfg = metered_cfg(None); + for _ in 0..4 { + let (decision, reason) = decide(&cfg); + assert_eq!(decision, "allow", "nothing accumulates: {reason}"); + assert!( + reason.contains("NOT enforced across calls"), + "and the allow says so: {reason}" + ); + } + } + + /// A stream that does not decode leaves the prior spend unknown, so the + /// budget cannot be asserted — fail closed, as SPEC §8 has this binary do + /// everywhere else. + #[test] + fn an_unreadable_receipt_stream_denies() { + let dir = tempdir(); + let path = dir.join("receipts.hex"); + std::fs::write(&path, "not hex\n").expect("write"); + let cfg = metered_cfg(Some(path.to_string_lossy().into_owned())); + + let (decision, reason) = decide(&cfg); + assert_eq!(decision, "deny"); + assert!( + reason.contains("could not be read"), + "with the reason on the record: {reason}" + ); + } + #[test] fn extracts_windsurf_and_shell_shapes() { // Windsurf pre_mcp_tool_use. -- 2.54.0 (Apple Git-157) From a63d7880469d7295eaf876ed6c562131f56e9020 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Sun, 18 Oct 2026 07:49:53 -0400 Subject: [PATCH 2/2] =?UTF-8?q?jcp-bridge:=20cite=20=C2=A78.1,=20the=20rul?= =?UTF-8?q?e=20this=20standard=20actually=20states?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `check-spec-refs` caught a dangling citation in the previous commit: `LocalToolGate::resume` cited "SPEC §3.3's 'over budget is deny'", but §3.3 is JouleHook's section. A citation in a jcp crate resolves against `jcp/SPEC.md`, which defines no §3.3 — that binding rule is exactly what the check exists to enforce, and it fired on its author. The rule this method implements is jcp's own: §8.1, "The receipt stream is the write-ahead log (REQUIRED)" — an enforcement point that crashes rebuilds its budget state by replaying its own verified receipt stream. That is what `resume` does, and it is the same section `Bridge::restore`, `Runtime::restore` and `Gateway::resume` already cite. The JouleHook consequence is still described, without a bare §-citation that would bind to the wrong standard; the rule is cited where it lives, in `joulehook-hook`. Co-Authored-By: Claude Opus 5 --- jcp/jcp-rs/crates/jcp-bridge/src/lib.rs | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/jcp/jcp-rs/crates/jcp-bridge/src/lib.rs b/jcp/jcp-rs/crates/jcp-bridge/src/lib.rs index 8fa98623c..0d2f7d741 100644 --- a/jcp/jcp-rs/crates/jcp-bridge/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-bridge/src/lib.rs @@ -314,11 +314,16 @@ impl LocalToolGate { /// Rebuild the spend totals from this gate's own sealed receipt stream. /// - /// A gate that is spawned once per action — the shape every editor - /// PreToolUse hook has — holds its spend only for that one call. Without - /// this, every action starts again at the full ceiling, and SPEC §3.3's - /// "over budget is `deny`" can never fire however many actions run: there - /// is no sequence that exhausts the budget. + /// This is SPEC §8.1 for a gate that does not stay resident: "an + /// enforcement point that crashes MUST be able to rebuild its budget state + /// by replaying its own verified receipt stream". + /// + /// A gate spawned once per action — the shape every editor PreToolUse hook + /// has — holds its spend only for that one call. Without this, every action + /// starts again at the full ceiling and no sequence of actions can exhaust + /// the budget, so a holder's own over-budget rule can never fire. (The + /// binding that states that rule is JouleHook's, not this standard's; it is + /// cited where it lives, in `joulehook-hook`.) /// /// Only verified allow-receipts contribute debits /// ([`jcp_receipt::replay_spend`]), so an envelope this gate's steward did -- 2.54.0 (Apple Git-157)