Skip to content

Commit 411dbe4

Browse files
committed
test(hooks): expose silent StopFailure events
1 parent d4ba08c commit 411dbe4

1 file changed

Lines changed: 146 additions & 2 deletions

File tree

tests/claude_hooks.rs

Lines changed: 146 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,11 @@
66
//! "the model received it" are exactly the two things that must not be conflated.
77
88
use std::fs;
9+
use std::io::Write as _;
10+
use std::os::unix::fs::PermissionsExt;
911
use std::os::unix::fs::symlink;
1012
use std::path::{Path, PathBuf};
11-
use std::process::{Command, Output};
13+
use std::process::{Command, Output, Stdio};
1214

1315
use st2::context;
1416

@@ -72,6 +74,10 @@ impl Fixture {
7274

7375
/// `overrides` are applied last, so a test can drop `PATH` entries or retune staleness.
7476
fn run_with(&self, script: &str, overrides: &[(&str, &str)]) -> Output {
77+
self.run_with_input(script, overrides, "")
78+
}
79+
80+
fn run_with_input(&self, script: &str, overrides: &[(&str, &str)], input: &str) -> Output {
7581
let script = Path::new(env!("CARGO_MANIFEST_DIR"))
7682
.join("hooks")
7783
.join(script);
@@ -87,7 +93,24 @@ impl Fixture {
8793
for (key, value) in overrides {
8894
command.env(key, value);
8995
}
90-
command.output().unwrap()
96+
let mut child = command
97+
.stdin(Stdio::piped())
98+
.stdout(Stdio::piped())
99+
.stderr(Stdio::piped())
100+
.spawn()
101+
.unwrap();
102+
child
103+
.stdin
104+
.take()
105+
.unwrap()
106+
.write_all(input.as_bytes())
107+
.unwrap();
108+
child.wait_with_output().unwrap()
109+
}
110+
111+
fn stop_failure_record(&self) -> PathBuf {
112+
self.state
113+
.join("st2/hook-events/stop-failure/Silber.cos.jsonl")
91114
}
92115
}
93116

@@ -217,3 +240,124 @@ fn session_start_fails_open_without_required_commands() {
217240
);
218241
assert!(output.stdout.is_empty());
219242
}
243+
244+
#[test]
245+
fn stop_failure_appends_a_private_redacted_record_without_a_supervisor() {
246+
if !jq_available() {
247+
eprintln!("SKIP: jq is required by the shipped Claude hook");
248+
return;
249+
}
250+
let fixture = Fixture::new();
251+
let payload = r#"{
252+
"session_id": "session-safe",
253+
"hook_event_name": "StopFailure",
254+
"error": "authentication_failed",
255+
"error_details": "Login expired: Bearer bearer-secret-1234567890 and sk-ant-api03-abcdefghijklmnop",
256+
"last_assistant_message": "API Error: Login expired",
257+
"api_key": "secret-key-value",
258+
"nested": {
259+
"authorization": "Bearer nested-secret-1234567890",
260+
"safe": "retry in 60 seconds"
261+
}
262+
}"#;
263+
264+
let output = fixture.run_with_input("claude-stop-failure.sh", &[], payload);
265+
266+
assert!(
267+
output.status.success(),
268+
"stderr:\n{}",
269+
String::from_utf8_lossy(&output.stderr)
270+
);
271+
assert!(output.stdout.is_empty());
272+
assert!(output.stderr.is_empty());
273+
274+
let record_path = fixture.stop_failure_record();
275+
let contents = fs::read_to_string(&record_path).unwrap();
276+
let lines = contents.lines().collect::<Vec<_>>();
277+
assert_eq!(lines.len(), 1, "one hook call must append one JSONL line");
278+
let record: serde_json::Value = serde_json::from_str(lines[0]).unwrap();
279+
assert_eq!(record["schema"], 1);
280+
assert_eq!(record["event"], "StopFailure");
281+
assert_eq!(record["identity"], "Silber.cos");
282+
assert_eq!(record["error_type"], "authentication_failed");
283+
assert!(
284+
record["timestamp"]
285+
.as_str()
286+
.is_some_and(|value| value.ends_with('Z'))
287+
);
288+
assert_eq!(record["payload"]["session_id"], "session-safe");
289+
assert_eq!(record["payload"]["error"], "authentication_failed");
290+
assert_eq!(record["payload"]["api_key"], "[REDACTED]");
291+
assert_eq!(record["payload"]["nested"]["authorization"], "[REDACTED]");
292+
assert_eq!(record["payload"]["nested"]["safe"], "retry in 60 seconds");
293+
assert!(
294+
record["payload"]["error_details"]
295+
.as_str()
296+
.unwrap()
297+
.contains("[REDACTED]")
298+
);
299+
assert!(!contents.contains("bearer-secret"));
300+
assert!(!contents.contains("sk-ant-api03"));
301+
assert!(!contents.contains("secret-key-value"));
302+
assert!(!contents.contains("nested-secret"));
303+
assert_eq!(
304+
fs::metadata(record_path).unwrap().permissions().mode() & 0o777,
305+
0o600
306+
);
307+
}
308+
309+
#[test]
310+
fn stop_failure_records_old_and_new_error_fields_before_reaction_filtering() {
311+
if !jq_available() {
312+
eprintln!("SKIP: jq is required by the shipped Claude hook");
313+
return;
314+
}
315+
let fixture = Fixture::new();
316+
317+
let first = fixture.run_with_input(
318+
"claude-stop-failure.sh",
319+
&[],
320+
r#"{"hook_event_name":"StopFailure","error_type":"max_output_tokens"}"#,
321+
);
322+
let second = fixture.run_with_input(
323+
"claude-stop-failure.sh",
324+
&[],
325+
r#"{"hook_event_name":"StopFailure","error":"overloaded"}"#,
326+
);
327+
328+
assert!(first.status.success());
329+
assert!(second.status.success());
330+
let contents = fs::read_to_string(fixture.stop_failure_record()).unwrap();
331+
let records = contents
332+
.lines()
333+
.map(|line| serde_json::from_str::<serde_json::Value>(line).unwrap())
334+
.collect::<Vec<_>>();
335+
assert_eq!(records.len(), 2);
336+
assert_eq!(records[0]["error_type"], "max_output_tokens");
337+
assert_eq!(records[1]["error_type"], "overloaded");
338+
}
339+
340+
#[test]
341+
fn stop_failure_remains_fail_open_when_the_record_path_is_unwritable() {
342+
if !jq_available() {
343+
eprintln!("SKIP: jq is required by the shipped Claude hook");
344+
return;
345+
}
346+
let fixture = Fixture::new();
347+
let blocked = fixture._tmp.path().join("blocked-state");
348+
fs::write(&blocked, "not a directory").unwrap();
349+
350+
let output = fixture.run_with_input(
351+
"claude-stop-failure.sh",
352+
&[("XDG_STATE_HOME", blocked.to_str().unwrap())],
353+
r#"{"hook_event_name":"StopFailure","error":"server_error"}"#,
354+
);
355+
356+
assert!(
357+
output.status.success(),
358+
"record failure must not block Claude. stderr:\n{}",
359+
String::from_utf8_lossy(&output.stderr)
360+
);
361+
assert!(output.stdout.is_empty());
362+
assert!(output.stderr.is_empty());
363+
}

0 commit comments

Comments
 (0)