1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
//! Hook stdin payload read that reports why it failed (#7975).
//!
//! Why: `tm hook --pm-guard` decides whether a tool call runs. It read its
//! payload through a helper that turned every failure into `None`, and it
//! treated `None` as "nothing to guard" and allowed the call. Claude Code
//! registers the guard on `PreToolUse` only, and its hooks reference states
//! that a command hook's input arrives on stdin as JSON carrying the common
//! fields plus `tool_name`, `tool_input` and `tool_use_id` for that event
//! (<https://code.claude.com/docs/en/hooks>, "Common input fields" and
//! "PreToolUse input", read 2026-09-14). An absent payload is therefore never a
//! legitimate case for the guard. A guard must tell a failed read apart from a
//! payload that names nothing it guards, and an `Option` cannot carry that.
//!
//! What: [`read_hook_stdin`] reads a bounded reader and returns the parsed JSON
//! object or a [`HookStdinError`] naming the failure.
//! [`read_stdin_hook_payload_strict`] applies it to the process stdin under a
//! caller-chosen budget — [`HOOK_STDIN_TIMEOUT`] for an advisory hook,
//! [`PM_GUARD_STDIN_TIMEOUT`] for the guard, whose read now gates the call.
//! [`unreadable_payload_deny_reason`] and [`unclassifiable_payload_deny_reason`]
//! are the deny texts the guard prints. Callers whose result gates nothing keep
//! the `Option` view in `misc::read_stdin_hook_payload`.
//!
//! Test: `hook_stdin_tests.rs`; end to end through the binary in
//! `tests/tm_hook_pm_guard_stdin_7975.rs`.
use Duration;
use ;
use crateDenyContext;
/// How long an ADVISORY hook waits for Claude Code to close stdin.
///
/// Why: a hook must never block the user's prompt if a caller leaves stdin
/// open. 500 ms was widened from 200 ms after a PR #1968 review found 200 ms
/// tight on a loaded host. A read that misses this budget costs an advisory
/// caller only a skipped rewrite or a missing observability field, so the tight
/// bound is the right trade there. #7975: the guard, whose read DENIES on
/// failure, uses [`PM_GUARD_STDIN_TIMEOUT`] instead.
/// Test: `hook_stdin_only_the_advisory_read_uses_the_short_budget`.
pub const HOOK_STDIN_TIMEOUT: Duration = from_millis;
/// How long `tm hook --pm-guard` waits for Claude Code to close stdin.
///
/// Why (#7975 round 2): the guard's read fails CLOSED, so a read that misses
/// its budget no longer costs a no-op — it DENIES a legitimate tool call, and
/// the guard is registered with `matcher: ""`, which fires for every tool
/// (`core::session_launch::settings::pm_guard_hook_value`). Reusing the 500 ms
/// advisory budget would put every tool call behind a bound a PR #1968 review
/// already called tight at 200 ms, with a megabyte-scale `tool_input` (a large
/// `Write` `content`) to cross the pipe inside it.
/// What: 5 s. A deny's worst case is three bounds, not two. `main.rs` resolves
/// the daemon URL through
/// [`trusty_mpm::core::discovery::GATEWAY_PROBE_TIMEOUT`] (500 ms) BEFORE
/// dispatching, and the registered guard command carries no `--url`
/// (`build_tree::PM_GUARD_SUFFIX`), so that probe is a fixed prefix on every
/// invocation. 500 ms + 5 s + [`crate::commands::pm_guard_deny_log::AUDIT_POST_TIMEOUT`]
/// (2 s) = 7.5 s of the 10 s `REGISTERED_HOOK_TIMEOUT` Claude Code is told to
/// allow, leaving 2.5 s for exec and classification.
/// Test: `guard_read_budget_leaves_the_audit_post_inside_the_hook_timeout`,
/// which sums all three so lowering any one of them trips the gate;
/// `read_hook_stdin_reads_a_slow_megabyte_inside_the_guard_budget`.
pub const PM_GUARD_STDIN_TIMEOUT: Duration = from_secs;
/// The `timeout` Claude Code is told to allow the guard hook, in seconds.
///
/// Why (#7975 round 2): the read and audit budgets above are only safe relative
/// to this number, so it is named rather than left implicit. Mirrored, not
/// imported: `core::session_launch::settings` is a private module of the
/// library and unreachable from this binary.
/// Test-only: the production side is the literal in `pm_guard_hook_value`, and
/// duplicating it here would be the way the two drift.
/// Test: `guard_read_budget_leaves_the_audit_post_inside_the_hook_timeout`;
/// the registered value itself by `write_project_hooks_registers_pm_guard`.
pub const REGISTERED_HOOK_TIMEOUT: Duration = from_secs;
/// Why a hook's stdin payload could not be used.
///
/// Why (#7975): each variant is a different fault to debug, and the guard's
/// deny reason names which one fired.
/// What: `Empty` is no bytes or only whitespace; `Read` is an I/O error,
/// including non-UTF-8 input; `TimedOut` is stdin still open at the deadline;
/// `Parse` is text that is not JSON, including a truncated object; `NotAnObject`
/// is valid JSON whose top level is not an object.
pub
/// Read and parse one hook payload from `reader`, reporting any failure.
///
/// Why (#7975): the reader is a parameter so every failure arm, including an
/// I/O error and a timeout, is testable without a child process.
/// What: reads `reader` to EOF under `timeout`, then parses the text. Returns
/// `Ok` only for a JSON object; surrounding whitespace is accepted. Every other
/// outcome returns the matching [`HookStdinError`].
/// Test: `read_hook_stdin_reports_empty_input`,
/// `read_hook_stdin_reports_a_read_error`, `read_hook_stdin_reports_a_timeout`,
/// `read_hook_stdin_reports_truncated_and_non_object_json`,
/// `read_hook_stdin_returns_a_parsed_object`.
pub async
/// [`read_hook_stdin`] over the process stdin, under `timeout`.
///
/// Why (#7975): the entry point every hook reads its payload through. The
/// budget is the caller's because it follows what a missed read costs: the
/// guard denies the call ([`PM_GUARD_STDIN_TIMEOUT`]), an advisory hook only
/// skips work ([`HOOK_STDIN_TIMEOUT`]).
/// What: see [`read_hook_stdin`].
/// Test: `tests/tm_hook_pm_guard_stdin_7975.rs`.
pub async
/// The `permissionDecisionReason` for a tool call whose payload failed to read.
///
/// Why (#7975): the agent sees only this text, so it names the failure and what
/// to try next.
/// What: one sentence naming `err`, the deny, and the issue.
/// Test: `unreadable_payload_deny_reason_names_the_failure`.
pub
/// Why a parsed payload does not describe a tool call the guard can classify.
///
/// Why (#7975 round 2): the guard's rules all read `tool_name` and
/// `tool_input`; a payload carrying neither in the documented shape is a
/// delivery fault, and the deny reason says which field is wrong.
/// What: `None` when `payload` carries a string `tool_name` and an object
/// `tool_input`; otherwise the field-naming detail.
/// Test: `unclassifiable_payload_detail_names_the_bad_field`.
/// The `permissionDecisionReason` for a payload the guard cannot classify.
///
/// Why (#7975 round 2): the agent sees only this text, so it names the bad
/// field and why an unclassifiable payload cannot be allowed through.
/// What: one sentence naming `detail`, the deny, and the issue.
/// Test: `unclassifiable_payload_deny_reason_names_the_failure`.
pub
/// Flush stdout and end the process with exit 0, after a decision is printed.
///
/// Why (#7975): tokio reads stdin on a blocking thread it cannot cancel, so
/// after a timed-out read the runtime's shutdown waits for stdin to close.
/// Claude Code cancels a hook at its `timeout` and discards its output, and a
/// cancelled `PreToolUse` command hook does not block the call (hooks
/// reference, "Timeouts"). Returning normally would turn a printed deny into an
/// allow.
/// What: flushes stdout, ignoring a flush error because nothing is left to
/// report it to, then calls `std::process::exit(0)`.
/// Test: `pm_guard_denies_a_stdin_that_stays_open_past_the_read_timeout`.
!
/// The guard's `PreToolUse` payload, or a printed deny and process exit.
///
/// Why (#7975): `tm hook --pm-guard` must fail closed on an unusable payload,
/// and the deny must outlive a stdin that never closes — see
/// [`exit_after_decision`]. Kept here rather than in `pm_guard.rs`, which sits
/// at the 500-SLOC cap.
/// What: returns the parsed object from [`read_stdin_hook_payload_strict`]. On
/// any [`HookStdinError`] it sends the guard's best-effort audit POST with no
/// session or tool name (neither could be read), prints the
/// `permissionDecision: "deny"` object carrying
/// [`unreadable_payload_deny_reason`], and exits 0 without returning.
/// Test: `pm_guard_denies_an_empty_stdin_payload`,
/// `pm_guard_denies_a_stdin_read_error`,
/// `pm_guard_denies_truncated_or_non_object_json`,
/// `pm_guard_denies_a_stdin_that_stays_open_past_the_read_timeout`,
/// `pm_guard_reads_a_megabyte_tool_input_and_decides_on_its_content`,
/// `pm_guard_allows_a_parsed_payload_naming_no_guarded_operation`.
pub async
/// The guarded `tool_name`, or a printed deny and process exit.
///
/// Why (#7975 round 2, code-critic MEDIUM): the guard used to ALLOW, silently
/// and with no audit record, any payload whose `tool_name` was not a string —
/// and to classify a `Bash` payload with no `tool_input` against an empty
/// command, which every rule allows. Both are global bypasses of the ABSOLUTE
/// rules this module exists to enforce, reached through the exact delivery
/// fault the rest of #7975 fails closed on. Claude Code documents `tool_name`
/// and `tool_input` as always present on `PreToolUse`
/// (<https://code.claude.com/docs/en/hooks>, "PreToolUse input"), the same
/// premise the unreadable-payload deny rests on, so the same answer follows:
/// deny. `hook_event_name` is deliberately not consulted — the guard is
/// registered on `PreToolUse` only, and reading it could only add an
/// allow-path back.
/// What: returns the payload's `tool_name` when
/// [`unclassifiable_payload_detail`] passes it. Otherwise audits the deny with
/// every field still readable — the `session_id`, and the `tool_name` itself on
/// the `tool_input` arm, where only the input was malformed — prints the deny
/// carrying [`unclassifiable_payload_deny_reason`], and exits 0. The audit
/// record is the only alarm this path raises, so it names what it can (round 2,
/// code-critic MEDIUM); a non-string `tool_name` leaves that field empty
/// because there is nothing to name.
/// Test: `pm_guard_denies_a_payload_whose_tool_name_is_not_a_string`,
/// `pm_guard_denies_a_bash_payload_with_no_tool_input`,
/// `pm_guard_allows_a_parsed_payload_naming_no_guarded_operation`.
pub async
/// Audit `reason`, print its deny, and end the process without returning.
///
/// Why (#7975): the two fail-closed paths above differ only in what they can
/// name, and both must outlive a stdin that never closes — see
/// [`exit_after_decision`].
/// What: best-effort audit POST, then the `permissionDecision: "deny"` object
/// on stdout, then exit 0.
/// Test: covered through its two callers' tests.
async !