joulehook: the joule budget survives the process, so it can be exceeded #310
2 changed files with 229 additions and 5 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue