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
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
//! `tm hook --divert-check` — `PreToolUse` bulk-read diversion (#6887).
//!
//! Why: a session that reads a 2000-line file pays for every line in its own
//! context on its own expensive model. This hook is the seam that catches such
//! a read before it happens and steers the agent to `tm divert bulk-read`,
//! which answers the question on a cheap worker instead. The #6882 POC measured
//! -45% cost and -54% output tokens for that swap.
//!
//! What: [`divert_check`] reads the `PreToolUse` stdin payload, classifies the
//! call with [`divert_targets`], counts the target's lines, and — only when a
//! worker credential is plausibly present — prints a `permissionDecision:
//! "deny"` whose reason names the command to run instead. It NEVER calls an
//! LLM: a `PreToolUse` hook has a 10-second budget and a network round trip
//! inside it would stall every read the session makes.
//!
//! Everything about this module fails OPEN, in the same house style as
//! `pm_guard` and `agent_cost`. Three distinct paths reach ALLOW and each is
//! deliberate:
//!
//! 1. Nothing to classify — no stdin, no `tool_name`, an unrecognised tool, a
//! bounded read (`offset`/`limit`, or `head -n`), an unreadable file.
//! 2. Under threshold — the read is cheap enough to let through.
//! 3. **No worker binary** — the block would be a dead end: the agent would be
//! told to run a command that cannot succeed, with no way back to the read
//! it actually wanted. That path returns
//! [`DivertDecision::AllowWithWarning`], so the warning is a value a test
//! can assert rather than a log line it has to scrape
//! (`divert_check_allows_with_a_warning_when_no_worker`).
//!
//! Test: the `#[cfg(test)]` suite below.
use ;
use Value;
use DEFAULT_DIVERT_MIN_LINES;
use DIVERT_MIN_LINES_ENV;
use crate;
use crate;
use cratebuild_divert_deny_response;
/// Bash commands that read a whole file to stdout.
///
/// Why: these are the routes to the same bytes `Read` would return, so leaving
/// them uncovered makes the feature trivially bypassable — an agent told "don't
/// Read that file" simply `cat`s it.
/// What: the bare command names, matched against the first word of the command
/// (with any directory prefix stripped).
/// Test: `divert_targets_matches_bulk_bash_readers`.
const BULK_READ_COMMANDS: = ;
/// The bulk readers whose short flags actually cap how much is printed.
///
/// Why (#7120): the classifier used to decline EVERY single-dash flag as
/// already-bounding. That is true of `head -n 40` and false of `cat -n`, where
/// `-n` only numbers the lines it prints. Claude Code's bypass-permissions
/// instruction tells the agent to read files with `cat`, which it spells
/// `cat -n <path>`, so that one rule let every bypass-mode whole-file read past
/// the diverter while the identical `Read` payload was diverted.
/// What: `head` and `tail` take a count through a short flag (`-n 40`,
/// `-c 200`, `-40`); for the rest of [`BULK_READ_COMMANDS`] a short flag is
/// formatting, so the file operands still name an unbounded read.
/// Test: `divert_targets_diverts_a_line_numbered_cat`,
/// `divert_targets_skips_bounded_reads`.
const BOUNDED_BY_SHORT_FLAG: = ;
/// What the hook decided about one tool call.
///
/// Why: separating the decision from the I/O (stdin, the filesystem, stdout)
/// is what lets every branch — especially the fail-open ones — be unit-tested
/// without a live session.
/// What: `Allow` prints nothing (Claude Code's documented "no decision, carry
/// on"); `AllowWithWarning` is the same decision plus the operator-facing
/// reason the diversion did not happen, carried as a VALUE so the fail-open
/// path is assertable instead of only loggable (acceptance criterion 4);
/// `Block` carries the reason string that becomes `permissionDecisionReason`.
/// Test: the whole suite below.
pub
/// Entry point for `tm hook --divert-check`.
///
/// Why: registered by [`trusty_mpm::core::session_launch`] as the `PreToolUse`
/// hook for `Read` and `Bash` when `[divert] enabled` is on.
/// What: reads the stdin payload, resolves the threshold from the session
/// environment, and prints the deny object when [`decide`] blocks. Prints
/// NOTHING on stdout for either allow — per the hooks reference, exit 0 with no
/// output means "no decision to report", which is what we want; an explicit
/// `allow` would bypass the user's own permission flow. A warning goes to
/// stderr, where it cannot corrupt the hook protocol.
/// Test: `divert_check_allows_with_a_warning_when_no_worker`,
/// `divert_check_blocks_when_worker_available_and_over_threshold`.
pub async
/// Decide whether one tool call should be diverted.
///
/// Why: the pure core. Every fail-open branch is reachable from here with no
/// filesystem, no network, and no process environment, which is the only way
/// the worker-absent branch can be proven to exist rather than asserted to.
/// What: returns [`DivertDecision::Block`] only when ALL of these hold — the
/// tool is an unbounded bulk read ([`divert_targets`] yields at least one
/// path), some target's line count is at or above `min_lines`, and
/// `worker_present` is true. An over-threshold read with no worker returns
/// [`DivertDecision::AllowWithWarning`]; everything else returns
/// [`DivertDecision::Allow`]. `line_count` is injected so tests need no fixture
/// files; production passes a closure over [`count_lines`].
///
/// Note the worker check is LAST and is not an optimisation: a build that
/// dropped it would still pass every threshold test, which is why tests 6 and 7
/// assert the two orders of that pair separately.
/// Test: `divert_check_allows_with_a_warning_when_no_worker`,
/// `divert_check_blocks_when_worker_available_and_over_threshold`,
/// `divert_check_allows_a_bounded_read`.
pub
/// The warning shown when an over-threshold read is let through.
///
/// Why (acceptance criterion 4): a silent allow is indistinguishable from the
/// feature being off, so an operator who enabled `[divert]` and sees no
/// diversions has nothing to look at. Naming the missing binary makes the fix
/// obvious.
/// What: one line naming the file and `claude`.
/// Test: `divert_check_allows_with_a_warning_when_no_worker`.
pub
/// The `permissionDecisionReason` shown to the agent on a block.
///
/// Why: the reason IS the recovery path. It has to name the exact replacement
/// command and the escape hatch, because the agent sees nothing else — a
/// reason that only says "too big" leaves it with no next move.
/// What: names the file, its line count, the `tm divert bulk-read` command, and
/// the two ways out (the fall-through signal, and a bounded re-read).
/// Test: `block_reason_names_the_worker_command`.
pub
/// The file paths a tool call would bulk-read, if any.
///
/// Why: the classifier decides what the feature even applies to, so it is where
/// over-reach does damage. It is deliberately narrow: a call it cannot lex
/// confidently yields no targets and is allowed.
/// What: for `Read`, the `file_path` — unless `offset` or `limit` is present,
/// which already bounds the read and is the documented escape hatch. For
/// `Bash`, the file operands of a SINGLE simple [`BULK_READ_COMMANDS`]
/// invocation: a command containing a pipe, redirect, or separator is left
/// alone (it is not a plain read), and a `head`/`tail` carrying an explicit
/// count (`-n`, `-c`, `-100`) is already bounded. A `cat` flag is formatting,
/// not a bound, so `cat -n <path>` diverts exactly like the `Read` payload for
/// the same file (#7120). Every other tool yields nothing.
/// Test: `divert_targets_matches_bulk_bash_readers`,
/// `divert_targets_skips_bounded_reads`, `divert_targets_ignores_other_tools`.
pub
/// File operands of a single simple bulk-read shell command.
///
/// Why: see [`divert_targets`]. Kept intentionally dumb — anything it is not
/// certain about it declines, because a false positive here blocks a call the
/// agent legitimately needs.
/// What: returns empty when the command contains a shell composition character
/// (`| & ; < > $ ( ` \n`), when the first word is not in
/// [`BULK_READ_COMMANDS`], or when a bounding flag is present on one of the
/// [`BOUNDED_BY_SHORT_FLAG`] commands. Otherwise returns every non-flag operand
/// after the command word.
/// Test: `divert_targets_matches_bulk_bash_readers`,
/// `divert_targets_skips_bounded_reads`,
/// `divert_targets_diverts_a_line_numbered_cat`.
/// Resolve a possibly-relative hook path against the session's `cwd`.
///
/// Why: Claude Code's payload carries whatever path the agent typed, which for
/// a `Bash` `cat` is usually relative to the session's working directory.
/// What: joins onto `cwd` unless `path` is already absolute.
/// Test: covered via `divert_check_blocks_when_worker_available_and_over_threshold`.
/// Count the lines of a file, or `None` when it cannot be read.
///
/// Why: an unreadable path must ALLOW, not block — a missing file is the tool
/// call's problem to report, not the hook's to pre-empt.
/// What: reads the file and counts `\n`-separated lines, capped at `u32`. A
/// non-UTF-8 or unreadable file yields `None`.
/// Test: `count_lines_reads_a_real_file`.