66//! "the model received it" are exactly the two things that must not be conflated.
77
88use std:: fs;
9+ use std:: io:: Write as _;
10+ use std:: os:: unix:: fs:: PermissionsExt ;
911use std:: os:: unix:: fs:: symlink;
1012use std:: path:: { Path , PathBuf } ;
11- use std:: process:: { Command , Output } ;
13+ use std:: process:: { Command , Output , Stdio } ;
1214
1315use 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