joulehook: the joule budget survives the process, so it can be exceeded #310

Merged
dcharlot merged 2 commits from fix/joulehook-budget-survives-the-process into main 2026-10-18 13:45:34 -04:00
2 changed files with 229 additions and 5 deletions

View file

@ -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<Item = (ContentId, u64)>) {
self.runtime.restore(entries);
}
pub fn spent(&self, grant_id: &ContentId) -> u64 {
self.runtime.spent(grant_id)
}
@ -295,6 +312,33 @@ impl LocalToolGate {
}
}
/// Rebuild the spend totals from this gate's own sealed receipt stream.
///
/// 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
/// 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<Item = &'a [u8]>) -> 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

View file

@ -22,10 +22,15 @@
//! **observe** mode: allow, and disclose that it is not enforcing (SPEC §7).
//! - `JOULEHOOK_SERVER` — grant-audience label (default `<host>`).
//! - `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<usize, String> {
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<u8>> = 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<Vec<u8>>) {
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<String>) -> 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.