Skip to content

Commit 636bb01

Browse files
committed
feat(hooks): record Claude turn failures
1 parent 411dbe4 commit 636bb01

5 files changed

Lines changed: 176 additions & 4 deletions

File tree

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,13 @@ current inbox; pre-compact preserves a recovery breadcrumb when no context was w
9494
failure hooks surface newly arrived work or a harness failure. They fail open so hook trouble does
9595
not prevent the harness from starting or stopping.
9696

97+
The Claude `StopFailure` hook appends one private JSON object per failure to
98+
`${XDG_STATE_HOME:-$HOME/.local/state}/st2/hook-events/stop-failure/<identity>.jsonl`. It records a
99+
UTC timestamp, the st2 identity, a normalized `error_type`, and the sanitized provider payload.
100+
The hook redacts sensitive fields and token-shaped strings before the append. It records invalid
101+
JSON without copying the raw input. A missing supervisor does not disable the record. Record,
102+
presence, and notification failures remain fail-open.
103+
97104
The same immutable set also carries `pi-channel.ts`. pi has no hook mechanism of its own — an
98105
extension is where a pi session exposes that surface — so st2 ships one and `st2 driver pi-session`
99106
splices it into the launch from the set this binary verified. A declaration never names it, and a

docs/vrs/spec.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -916,6 +916,35 @@ and context larger than a platform argument limit without truncation. The open
916916
verification delta is recorded in
917917
[DELTA-001](./.delta/DELTA-001-session-start-hook-evidence.md).
918918

919+
## Provider StopFailure records (R17)
920+
921+
The Claude `StopFailure` hook reads the provider input once. It appends one
922+
compact JSON object to the agent's machine-local file:
923+
924+
```text
925+
${XDG_STATE_HOME:-$HOME/.local/state}/st2/hook-events/stop-failure/<safe-identity>.jsonl
926+
```
927+
928+
The file mode is `0600`, and its directory mode is `0700`. Each record has
929+
schema `1`, a UTC timestamp, event `StopFailure`, the original st2 identity,
930+
a normalized `error_type`, and the sanitized provider payload. The normalized
931+
field accepts the current provider `error` field and the legacy `error_type`
932+
field.
933+
934+
The sanitizer replaces sensitive field values and token-shaped strings before
935+
the append. A sanitized payload larger than 16,384 characters becomes a bounded
936+
preview. Invalid JSON produces `payload: null` and `payload_error: invalid_json`;
937+
the hook never copies the invalid raw input.
938+
939+
The hook records before it applies presence or supervisor-notification filters.
940+
Thus, filtered errors and agents without supervisors still produce records.
941+
Directory, append, permission, presence, and notification failures do not alter
942+
the hook's successful exit.
943+
944+
Executable acceptance in `tests/claude_hooks.rs` covers append behavior, exact
945+
permissions, current and legacy error fields, redaction, invalid input, filtered
946+
errors, missing supervisors, and an unwritable record path.
947+
919948
The owner updates this spec whenever implementation changes.
920949
Changing [vision.md](./vision.md) or [requirements.md](./requirements.md)
921950
requires Nathan's explicit approval.

examples/native/README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,11 @@ Hook installation is explicit and receipt-bearing. `up` and materialization only
4141
selected immutable hook set; they never refresh shared scripts. Managed settings resolve
4242
`$ST_HOOKS/<script>` into that versioned set.
4343

44+
The Claude `StopFailure` hook records every API-error turn in the machine-local st2 state directory.
45+
Each agent appends to `st2/hook-events/stop-failure/<identity>.jsonl`. The hook records before it
46+
applies notification filters, and it records even when the declaration has no supervisor. The JSONL
47+
payload is redacted before it reaches disk. All record failures remain fail-open.
48+
4449
## Status discipline
4550

4651
All three maintained declarations load the shipped bus contract. Agents must declare `busy` before

hooks/claude-stop-failure.sh

Lines changed: 97 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,111 @@
11
#!/usr/bin/env bash
2-
# st2 Claude StopFailure hook: surface infrastructure wedges through presence and, when declared,
3-
# the agent's supervisor inbox. Fail-open; lifecycle reporting must never wedge the harness.
2+
# st2 Claude StopFailure hook: append one private machine-local JSONL record, then surface
3+
# infrastructure wedges through presence and, when declared, the agent's supervisor inbox.
4+
# Fail-open; lifecycle reporting must never wedge the harness.
45

56
set -u
67

78
identity="${ST_AGENT:-}"
89
root="${ST_ROOT:-${CATALOG:-}}"
910
supervisor="${ST_SUPERVISOR:-}"
11+
input="$(cat 2>/dev/null || true)"
12+
error_type="unknown"
13+
14+
# One file per identity avoids cross-agent append contention. The complete provider payload is
15+
# useful for diagnosis, but sensitive keys and token-shaped strings are redacted before the line
16+
# reaches disk. Oversized sanitized payloads keep a bounded preview. Invalid JSON is never copied.
17+
if command -v jq >/dev/null 2>&1; then
18+
timestamp="$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || printf unknown)"
19+
record_identity="${identity:-unknown}"
20+
record="$(
21+
printf '%s' "$input" | jq -cs \
22+
--arg timestamp "$timestamp" \
23+
--arg identity "$record_identity" '
24+
def sensitive_key:
25+
ascii_downcase
26+
| gsub("[^a-z0-9]"; "")
27+
| test("^(authorization|proxyauthorization|cookie|setcookie|password|passwd|secret|clientsecret|credential|apikey|privatekey|token|authtoken|bearertoken|accesstoken|refreshtoken|idtoken)$");
28+
def redact_string:
29+
gsub("(?i)bearer[[:space:]]+[A-Za-z0-9._~+/=-]{8,}"; "Bearer [REDACTED]")
30+
| gsub("(?i)(access[_-]?token|refresh[_-]?token|api[_-]?key|client[_-]?secret|password|passwd|secret)[[:space:]]*[:=][[:space:]]*[^&[:space:],;]+"; "[REDACTED]")
31+
| gsub("(?i)(sk-[A-Za-z0-9_-]{12,}|github_pat_[A-Za-z0-9_]{20,}|gh[pousr]_[A-Za-z0-9_]{20,}|xox[baprs]-[A-Za-z0-9-]{10,}|AKIA[A-Z0-9]{16})"; "[REDACTED]")
32+
| gsub("[A-Za-z0-9_-]{20,}\\.[A-Za-z0-9_-]{20,}\\.[A-Za-z0-9_-]{10,}"; "[REDACTED]");
33+
def scrub:
34+
walk(
35+
if type == "object" then
36+
with_entries(
37+
if (.key | sensitive_key) then .value = "[REDACTED]" else . end
38+
)
39+
elif type == "string" then
40+
redact_string
41+
else
42+
.
43+
end
44+
);
45+
if length == 1 and (.[0] | type) == "object" then
46+
.[0]
47+
else
48+
error("expected one JSON object")
49+
end
50+
| . as $original
51+
| (($original.error_type // $original.error // "unknown") | tostring | redact_string) as $error_type
52+
| (scrub) as $payload
53+
| ($payload | tojson) as $encoded
54+
| {
55+
schema: 1,
56+
timestamp: $timestamp,
57+
event: "StopFailure",
58+
identity: $identity,
59+
error_type: $error_type,
60+
payload: (
61+
if ($encoded | length) <= 16384 then
62+
$payload
63+
else
64+
{truncated: true, preview: $encoded[0:16384]}
65+
end
66+
)
67+
}
68+
' 2>/dev/null
69+
)"
70+
if [[ -z "$record" ]]; then
71+
record="$(jq -cn \
72+
--arg timestamp "$timestamp" \
73+
--arg identity "$record_identity" '
74+
{
75+
schema: 1,
76+
timestamp: $timestamp,
77+
event: "StopFailure",
78+
identity: $identity,
79+
error_type: "unknown",
80+
payload: null,
81+
payload_error: "invalid_json"
82+
}
83+
' 2>/dev/null || true)"
84+
fi
85+
error_type="$(printf '%s' "$record" | jq -r '.error_type // "unknown"' 2>/dev/null || printf unknown)"
86+
87+
state_base="${XDG_STATE_HOME:-}"
88+
if [[ -z "$state_base" && -n "${HOME:-}" ]]; then
89+
state_base="${HOME}/.local/state"
90+
fi
91+
if [[ -n "$state_base" && -n "$record" ]]; then
92+
safe_identity="$(printf '%s' "$record_identity" | tr -c 'A-Za-z0-9._-' '_' 2>/dev/null || printf unknown)"
93+
record_dir="${state_base}/st2/hook-events/stop-failure"
94+
record_file="${record_dir}/${safe_identity}.jsonl"
95+
(
96+
umask 077
97+
mkdir -p "$record_dir" || exit 0
98+
chmod 700 "$record_dir" || true
99+
printf '%s\n' "$record" >> "$record_file" || exit 0
100+
chmod 600 "$record_file" || true
101+
) 2>/dev/null || true
102+
fi
103+
fi
104+
10105
if [[ -z "$identity" || -z "$root" ]] || ! command -v st2 >/dev/null 2>&1 || ! command -v jq >/dev/null 2>&1; then
11106
exit 0
12107
fi
13108

14-
input="$(cat)"
15-
error_type="$(printf '%s' "$input" | jq -r '.error_type // "unknown"' 2>/dev/null || printf unknown)"
16109
status="away"
17110
notify="yes"
18111
case "$error_type" in

tests/claude_hooks.rs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,14 @@ fn stop_failure_appends_a_private_redacted_record_without_a_supervisor() {
304304
fs::metadata(record_path).unwrap().permissions().mode() & 0o777,
305305
0o600
306306
);
307+
308+
let status = Command::new(env!("CARGO_BIN_EXE_st2"))
309+
.args(["status", "Silber.cos", "--root"])
310+
.arg(&fixture.catalog)
311+
.output()
312+
.unwrap();
313+
assert!(status.status.success());
314+
assert_eq!(String::from_utf8(status.stdout).unwrap(), "offline\n");
307315
}
308316

309317
#[test]
@@ -337,6 +345,28 @@ fn stop_failure_records_old_and_new_error_fields_before_reaction_filtering() {
337345
assert_eq!(records[1]["error_type"], "overloaded");
338346
}
339347

348+
#[test]
349+
fn stop_failure_never_copies_an_invalid_raw_payload_to_disk() {
350+
if !jq_available() {
351+
eprintln!("SKIP: jq is required by the shipped Claude hook");
352+
return;
353+
}
354+
let fixture = Fixture::new();
355+
let raw = "not-json Bearer raw-secret-1234567890";
356+
357+
let output = fixture.run_with_input("claude-stop-failure.sh", &[], raw);
358+
359+
assert!(output.status.success());
360+
assert!(output.stdout.is_empty());
361+
assert!(output.stderr.is_empty());
362+
let contents = fs::read_to_string(fixture.stop_failure_record()).unwrap();
363+
let record: serde_json::Value = serde_json::from_str(contents.trim()).unwrap();
364+
assert_eq!(record["error_type"], "unknown");
365+
assert_eq!(record["payload"], serde_json::Value::Null);
366+
assert_eq!(record["payload_error"], "invalid_json");
367+
assert!(!contents.contains("raw-secret"));
368+
}
369+
340370
#[test]
341371
fn stop_failure_remains_fail_open_when_the_record_path_is_unwritable() {
342372
if !jq_available() {
@@ -360,4 +390,12 @@ fn stop_failure_remains_fail_open_when_the_record_path_is_unwritable() {
360390
);
361391
assert!(output.stdout.is_empty());
362392
assert!(output.stderr.is_empty());
393+
394+
let status = Command::new(env!("CARGO_BIN_EXE_st2"))
395+
.args(["status", "Silber.cos", "--root"])
396+
.arg(&fixture.catalog)
397+
.output()
398+
.unwrap();
399+
assert!(status.status.success());
400+
assert_eq!(String::from_utf8(status.stdout).unwrap(), "away\n");
363401
}

0 commit comments

Comments
 (0)