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() }
|
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 {
|
pub fn spent(&self, grant_id: &ContentId) -> u64 {
|
||||||
self.runtime.spent(grant_id)
|
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
|
/// 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 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
|
/// 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).
|
//! **observe** mode: allow, and disclose that it is not enforcing (SPEC §7).
|
||||||
//! - `JOULEHOOK_SERVER` — grant-audience label (default `<host>`).
|
//! - `JOULEHOOK_SERVER` — grant-audience label (default `<host>`).
|
||||||
//! - `JOULEHOOK_BUDGET_UJ` — per-tool joule budget, µJ (default 1e9).
|
//! - `JOULEHOOK_BUDGET_UJ` — per-tool joule budget, µJ (default 1e9).
|
||||||
//! - `JOULEHOOK_COST_UJ` — joules attributed to each call, µJ (default 0). Cross-call
|
//! - `JOULEHOOK_COST_UJ` — joules attributed to each call, µJ (default 0).
|
||||||
//! budget accounting needs a persistent gate; this v0 enforces the capability set
|
//! Cross-call budget accounting needs the spend to outlive the process; the
|
||||||
//! per call and seals each decision.
|
//! sealed receipt stream below is that persistence, replayed at each call.
|
||||||
//! - `JOULEHOOK_RECEIPT_LOG` — path to append sealed JCR-1 receipts (hex per line).
|
//! - `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};
|
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
|
/// 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
|
/// set, seal, and return `(allow, reason, sealed_receipts)`. Decided outside the
|
||||||
/// model — a deterministic function of the action and the held capabilities.
|
/// 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>>) {
|
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 tool_refs: Vec<&str> = cfg.tools.iter().map(String::as_str).collect();
|
||||||
let mut g = LocalToolGate::new(&cfg.server, &tool_refs, cfg.budget_uj);
|
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 args = serde_json::to_vec(&event.input).unwrap_or_default();
|
||||||
let allow = g.authorize(&event.tool, &args, cfg.cost_uj);
|
let allow = g.authorize(&event.tool, &args, cfg.cost_uj);
|
||||||
let reason = if allow {
|
let reason = if allow {
|
||||||
format!("allow: '{}' is in the granted capability set and within budget", event.tool)
|
match replayed {
|
||||||
} else {
|
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)
|
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())
|
(allow, reason, g.receipts().to_vec())
|
||||||
}
|
}
|
||||||
|
|
@ -418,6 +497,107 @@ mod tests {
|
||||||
assert!(receipts.is_empty());
|
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]
|
#[test]
|
||||||
fn extracts_windsurf_and_shell_shapes() {
|
fn extracts_windsurf_and_shell_shapes() {
|
||||||
// Windsurf pre_mcp_tool_use.
|
// Windsurf pre_mcp_tool_use.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue