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
//! The `PreToolUse` stdout envelope every `tm hook` decision is printed in.
//!
//! Why: two shapes share one JSON envelope and one protocol rule — stdout may
//! carry exactly one object — and they are printed from four modules
//! (`pm_guard`, `divert_check`, and the rules they call). Keeping both builders
//! in one place is what stops a second, subtly different envelope appearing at
//! a new print site; splitting them out of `pm_guard.rs` is also what kept that
//! file under the 500-SLOC cap when #7172 added a rule to it.
//!
//! What: [`build_pretooluse_deny_response`] is the private deny envelope. Only
//! two builders reach it, so the compiler decides who may print an unprefixed
//! deny: [`build_pm_guard_deny_response`] carries the
//! [`PM_GUARD_REFUSAL_PREFIX`] every `tm hook --pm-guard` refusal starts with
//! (#8546), and [`build_divert_deny_response`] is `divert_check`'s own,
//! unprefixed. [`build_pretooluse_context_response`] speaks to the agent and
//! decides nothing. None ever emits an explicit `allow` — see each doc for why.
//!
//! Test: the `#[cfg(test)]` suite below.
/// The decision a `PreToolUse` REWRITE may carry (#8261 round 3).
///
/// Why: a rewrite of a command the user's settings `ask` about must still
/// ask, and one they `allow` may still be allowed — but never `deny`, which
/// goes through [`build_pm_guard_deny_response`] so it carries the prefix.
/// Test: `a_rewrite_carries_only_ask_or_allow`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum RewriteDecision {
/// Ask the user, as the original command's `ask` rule would.
Ask,
/// Allow, as the original command's `allow` rules would.
Allow,
}
/// A `PreToolUse` response replacing the tool input with `input`.
///
/// What: `hookSpecificOutput.updatedInput = input`, plus `permissionDecision`
/// when `decision` is set; with none, the normal permission flow applies.
/// Test: `a_rewrite_carries_only_ask_or_allow`.
pub(crate) fn build_rewrite_response(
input: serde_json::Value,
decision: Option<RewriteDecision>,
) -> serde_json::Value {
let mut out = serde_json::json!({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"updatedInput": input,
}
});
if let Some(decision) = decision {
out["hookSpecificOutput"]["permissionDecision"] = serde_json::Value::from(match decision {
RewriteDecision::Ask => "ask",
RewriteDecision::Allow => "allow",
});
}
out
}
/// Build the `hookSpecificOutput.permissionDecision = "deny"` JSON body.
///
/// Why: this is the exact shape Claude Code parses on a `PreToolUse` hook's
/// `exit 0` stdout to block a tool call. Confirmed against the live Claude Code
/// hooks reference (<https://code.claude.com/docs/en/hooks>, confirmed
/// 2026-07-03): a deny is
/// `{"hookSpecificOutput":{"hookEventName":"PreToolUse",
/// "permissionDecision":"deny","permissionDecisionReason":"…"}}`, and the
/// documented ALLOW form is simply exit 0 with no JSON — which is why
/// [`crate::commands::pm_guard::pm_guard`] prints nothing on ALLOW rather than emitting a
/// `permissionDecision:"allow"` object. Mirrors
/// `hook_rewrite::build_pretooluse_rewrite_response` (same `hookSpecificOutput`
/// envelope, different decision payload).
/// What: returns the deny `serde_json::Value`; its `Display` impl is compact
/// single-line JSON, so `println!("{}", …)` satisfies the protocol's "stdout
/// must contain only the JSON object" rule.
/// Private since #8547 review: a caller outside this module must choose
/// [`build_pm_guard_deny_response`] or [`build_divert_deny_response`].
/// Test: `build_pretooluse_deny_response_has_expected_shape`.
fn build_pretooluse_deny_response(reason: &str) -> serde_json::Value {
serde_json::json!({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": reason
}
})
}
/// The text every refusal `tm hook --pm-guard` prints begins with (#8546).
///
/// Why: the Claude Code harness refuses some tool calls itself — for example a
/// Bash command "too complex to verify that it stays inside the worktree" — and
/// both refusals reach the agent and the operator as bare text. The prefix is
/// how a reader tells tm's refusal from the harness's, so it is never added to
/// another layer's refusal: `divert_check` prints its own, unprefixed.
/// What: the literal `tm pm-guard: `.
/// Test: `pm_guard_deny_carries_the_prefix_exactly_once`.
pub(crate) const PM_GUARD_REFUSAL_PREFIX: &str = "tm pm-guard: ";
/// Build the deny for a `tm hook --pm-guard` refusal: `reason` behind
/// [`PM_GUARD_REFUSAL_PREFIX`].
///
/// Why (#8546): the one builder every pm-guard deny site calls, so the prefix
/// is added in one place instead of being written into ~30 reason strings, any
/// of which a new rule could forget.
/// What: the private deny envelope over `reason` with the prefix prepended; a
/// `reason` that already starts with it is not prefixed twice.
/// Test: `pm_guard_deny_carries_the_prefix_exactly_once`,
/// `pm_guard_modules_never_print_an_unprefixed_deny`.
pub(crate) fn build_pm_guard_deny_response(reason: &str) -> serde_json::Value {
let body = reason
.strip_prefix(PM_GUARD_REFUSAL_PREFIX)
.unwrap_or(reason);
build_pretooluse_deny_response(&format!("{PM_GUARD_REFUSAL_PREFIX}{body}"))
}
/// Build the deny for a `tm hook --divert-check` refusal, with no prefix.
///
/// Why (#8546 review): `divert_check` is a separate hook layer whose refusal
/// must not read as tm pm-guard's, so it needs an unprefixed deny. Naming it
/// keeps the generic builder private, and the scan in the test suite below
/// fails if a pm-guard module calls this one.
/// What: [`build_pretooluse_deny_response`] over `reason`, unchanged.
/// Test: `divert_deny_carries_no_prefix`.
pub(crate) fn build_divert_deny_response(reason: &str) -> serde_json::Value {
build_pretooluse_deny_response(reason)
}
/// Build a `hookSpecificOutput.additionalContext` body — a message TO the agent
/// that changes no decision.
///
/// Why (#4837 review, HIGH): the agent-cost warning is addressed to the agent
/// ("wrap up and report back"), but the only channel it had was an audit POST
/// to the daemon, so no agent ever read it — the graduated warn-then-stop
/// response did not exist from the agent's side. `additionalContext` is the
/// channel that fixes it: Claude Code injects the string next to the tool
/// result, and the object carries **no** `permissionDecision`, so the call
/// still goes through the normal permission flow. Per the hooks reference
/// (<https://code.claude.com/docs/en/hooks>, confirmed 2026-08-04): "Exit code
/// 0 with no output means the hook has no decision to report, so the tool call
/// continues through the normal permission flow… staying silent doesn't approve
/// it", and `additionalContext` is listed for `PreToolUse` as appearing "next
/// to the tool result". That is precisely the property the author needed and
/// could not find: context reaches the agent, and an explicit `allow` — which
/// WOULD bypass the permission flow — is never emitted.
/// What: returns the `serde_json::Value` whose compact `Display` is the single
/// stdout object; mirrors [`build_pretooluse_deny_response`]'s envelope.
/// Test: `build_pretooluse_context_response_carries_no_decision`.
pub(crate) fn build_pretooluse_context_response(context: &str) -> serde_json::Value {
serde_json::json!({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"additionalContext": context
}
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn build_pretooluse_deny_response_has_expected_shape() {
let v = build_pretooluse_deny_response("nope");
assert_eq!(v["hookSpecificOutput"]["hookEventName"], "PreToolUse");
assert_eq!(v["hookSpecificOutput"]["permissionDecision"], "deny");
assert_eq!(v["hookSpecificOutput"]["permissionDecisionReason"], "nope");
// Display is compact single-line JSON (protocol: stdout is only the object).
assert!(!v.to_string().contains('\n'));
}
#[test]
fn pm_guard_deny_carries_the_prefix_exactly_once() {
for reason in ["nope", "tm pm-guard: nope"] {
let v = build_pm_guard_deny_response(reason);
assert_eq!(v["hookSpecificOutput"]["permissionDecision"], "deny");
assert_eq!(
v["hookSpecificOutput"]["permissionDecisionReason"], "tm pm-guard: nope",
"input {reason:?}"
);
}
}
#[test]
fn divert_deny_carries_no_prefix() {
// divert_check is its own layer; its refusal must not read as pm-guard's.
let v = build_divert_deny_response("nope");
assert_eq!(v["hookSpecificOutput"]["permissionDecision"], "deny");
assert_eq!(v["hookSpecificOutput"]["permissionDecisionReason"], "nope");
}
/// Every production `.rs` file of the `tm` binary, skipping `tests/`
/// directories, `tests.rs` and `*_tests.rs`.
fn tm_binary_sources() -> Vec<std::path::PathBuf> {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/bin/tm");
let mut out = Vec::new();
let mut stack = vec![root];
while let Some(dir) = stack.pop() {
for entry in std::fs::read_dir(&dir).expect("read source dir") {
let path = entry.expect("dir entry").path();
if path.is_dir() {
if !path.ends_with("tests") {
stack.push(path);
}
continue;
}
let name = path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
let is_test = name == "tests.rs" || name.ends_with("_tests.rs");
if name.ends_with(".rs") && !is_test {
out.push(path);
}
}
}
out
}
/// The lines of `text` before its `#[cfg(test)] mod …` test module.
///
/// A doc comment that merely mentions `#[cfg(test)]` does not end the
/// production section: the marker must stand alone on an unindented line
/// and be followed, past any other attributes, by a `mod` item.
fn production_lines(text: &str) -> Vec<&str> {
let lines: Vec<&str> = text.lines().collect();
let end = (0..lines.len())
.find(|&i| {
lines[i] == "#[cfg(test)]"
&& lines[i + 1..]
.iter()
.find(|l| !l.starts_with("#["))
.is_some_and(|l| l.starts_with("mod "))
})
.unwrap_or(lines.len());
lines[..end].to_vec()
}
#[test]
fn a_rewrite_carries_only_ask_or_allow() {
let input = serde_json::json!({ "command": "x" });
let none = build_rewrite_response(input.clone(), None);
assert!(
none["hookSpecificOutput"]
.get("permissionDecision")
.is_none()
);
assert_eq!(none["hookSpecificOutput"]["updatedInput"]["command"], "x");
let ask = build_rewrite_response(input.clone(), Some(RewriteDecision::Ask));
assert_eq!(ask["hookSpecificOutput"]["permissionDecision"], "ask");
let allow = build_rewrite_response(input, Some(RewriteDecision::Allow));
assert_eq!(allow["hookSpecificOutput"]["permissionDecision"], "allow");
}
#[test]
fn pm_guard_modules_never_print_an_unprefixed_deny() {
// #8546: the private generic builder is enforced by the compiler. What
// it cannot see is a hand-built deny object anywhere in the binary, or
// a pm-guard module borrowing divert_check's unprefixed builder. This
// scan fails on either whether or not any test exercises the new path.
let sources = tm_binary_sources();
assert!(sources.len() > 100, "scan found too few files: {sources:?}");
let mut offenders = Vec::new();
for path in sources {
if path.ends_with("pm_guard_response.rs") {
continue;
}
let divert = path.ends_with("divert_check.rs");
let text = std::fs::read_to_string(&path).expect("read source");
for (i, line) in production_lines(&text).iter().enumerate() {
let code = line.trim_start();
if code.starts_with("//") {
continue;
}
if code.contains("\"permissionDecision\"")
|| (!divert && code.contains("build_divert_deny_response"))
{
offenders.push(format!("{}:{}: {code}", path.display(), i + 1));
}
}
}
assert!(
offenders.is_empty(),
"pm-guard modules must deny through build_pm_guard_deny_response: {offenders:#?}"
);
}
#[test]
fn build_pretooluse_context_response_carries_no_decision() {
// #4837 review HIGH: this is the channel that reaches the AGENT. The
// absence of `permissionDecision` is the whole safety property — with
// one, the object would approve the call and bypass the permission
// flow, which is exactly what the author declined to do.
let v = build_pretooluse_context_response("wrap up");
assert_eq!(v["hookSpecificOutput"]["hookEventName"], "PreToolUse");
assert_eq!(v["hookSpecificOutput"]["additionalContext"], "wrap up");
assert!(
v["hookSpecificOutput"].get("permissionDecision").is_none(),
"an explicit decision here would bypass the permission flow"
);
assert!(!v.to_string().contains('\n'));
}
}