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
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
//! `tm hook --pm-guard` — PreToolUse enforcement for MPM-managed PM sessions
//! (issue #1977; native sub-agent exemption issue #2014).
//!
//! Why: the deployed PM instructions carry prohibitions P1–P11 ("delegate all
//! file edits to rust-engineer", "never run builds/tests yourself", …) but
//! they are prompt-only — nothing stops the model from calling `Edit`/`Write`
//! or shelling out to `sed`/`make`/`pytest` directly. Claude Code's
//! `PreToolUse` hook is the single mechanical seam that can *block* a tool call
//! before it runs, so wiring a guard here turns those prohibitions from advice
//! into enforcement while still steering the PM toward the Task/Agent tool.
//! Those prohibitions apply only to the PM's OWN direct tool calls — a
//! sub-agent dispatched via the native `Task`/`Agent` tool is doing exactly the
//! delegated work the PM is steered toward, so its Edit/Write calls must be
//! exempt, not denied (issue #2014). The legacy `CLAUDE_MPM_SUB_AGENT` env-var
//! exemption only covers trusty-mpm's own nested-process spawns; it is never
//! set for a native subagent, so those calls were denied, forcing operators to
//! fall back to the all-or-nothing `TRUSTY_MPM_PM_UNRESTRICTED=1` bypass just
//! to get delegated work done.
//! What: [`pm_guard`] is the `tm hook --pm-guard` entry point. It short-circuits
//! (ALLOW) inside sub-agents / disabled-hook shells / the explicit
//! `TRUSTY_MPM_PM_UNRESTRICTED=1` bypass, reads the `PreToolUse` stdin payload,
//! exempts native sub-agent dispatches via [`payload_is_subagent_dispatch`]
//! (the payload's documented `agent_id` field), classifies the tool **locally**
//! (no daemon round-trip — a PreToolUse hook must be fast and must never
//! hard-block when the daemon is down), and either stays silent (ALLOW) or
//! prints a `permissionDecision: "deny"` response (DENY). The classification is
//! a static default-ALLOW + explicit deny-list: [`evaluate_tool`] denies a PM
//! direct edit tool ONLY when it targets a *source-code* file (path-based —
//! issue #2604), denies the forbidden Bash verbs, and allows everything else —
//! including single-file writes to PM-owned orchestration state
//! (`.trusty-mpm/**`, the memory palace, `TASK.md`), docs, and config. Every
//! error path (malformed stdin, missing fields) fails **open** — ALLOW — so a
//! broken hook never wedges the PM. **Opt-in deny-by-default (issue #2231):**
//! the sub-agent exemption is narrowed, ONLY when
//! [`pm_guard_deny_by_default::deny_by_default_enabled`] is truthy, so a
//! delegated dispatch of an [`evaluate_tool`]-guarded tool is denied when
//! [`pm_guard_deny_by_default::persona_status`] resolves to
//! `PersonaStatus::ExplicitlyFailed` (a positively-recorded managed-session
//! persona-deployment failure). This is the ONE place default behavior can
//! change, and only when the operator explicitly opts in — see that module's
//! doc comment for the full #2172-lesson rationale. Unset (the default),
//! Guard 4 is byte-for-byte identical to the pre-#2231 unconditional exemption.
//! **Per-turn file-change budget (issue #2918):** a "file change" deny
//! (source-code Edit/Write/MultiEdit/NotebookEdit, or a shell-based file edit
//! — [`SOURCE_EDIT_REASON`] / [`crate::commands::pm_guard_bash::SHELL_EDIT_REASON`])
//! is no longer an absolute prohibition. [`pm_guard`] now allows up to
//! [`crate::commands::pm_guard_budget::DEFAULT_FILE_CHANGE_BUDGET`] such
//! changes per turn-window (see that module for the turn-window heuristic)
//! before hard-blocking with a budget-exhausted message that names the count
//! and, via [`crate::commands::pm_guard_routing::delegation_hint_for_path`],
//! the specialist matching the target file's content type instead of a
//! hardcoded `rust-engineer`.
//! Build/test and network denials are NOT budget-eligible and stay absolute.
//! Test: `evaluate_tool_*`, `payload_is_subagent_dispatch_*`, and
//! `build_pretooluse_deny_response_*` below cover the tool policy, sub-agent
//! exemption, and JSON shape; the Bash-command classifier (composition and
//! substitution handling) lives in the sibling [`super::pm_guard_bash`] module
//! with its own tests; the opt-in persona gate's policy lives in
//! [`super::pm_guard_deny_by_default`] with its own tests; `tests/tm_hook_pm_guard.rs`
//! exercises the stdin→decision→stdout path (and the env bypasses / sub-agent
//! exemption / fail-open) end to end through the real binary.
use crate;
use crate;
use crate;
use crate;
use crate;
/// Escape-hatch env var: `TRUSTY_MPM_PM_UNRESTRICTED=1` disables all PM
/// enforcement for the invocation.
///
/// Why: the prohibitions exist for the *autonomous* PM loop; when the operator
/// explicitly tells the PM "you do it this time", there must be a way to lift
/// the block without editing settings.json and restarting. Keying it on the
/// exact value `"1"` (rather than mere presence) keeps an accidental empty
/// export from silently disabling enforcement. This is a manual, opt-in,
/// single-named-var override — never set by trusty-mpm's own session-launch or
/// spawn code (verified: no occurrence outside this module and its tests) — so
/// there is nothing for issue #2014 to "retire" in the launcher; the fix there
/// is that [`payload_is_subagent_dispatch`] now makes this bypass unnecessary
/// for ordinary delegated work. It remains available for the rare case an
/// operator wants to lift enforcement on the PM's *own* direct edits too.
/// What: the literal env var name. See [`pm_unrestricted`].
pub const PM_UNRESTRICTED_ENV: &str = "TRUSTY_MPM_PM_UNRESTRICTED";
/// Single-file mutation tools the PM invokes directly. Whether a given call is
/// denied is decided by its *target path*, not the tool name — see
/// [`evaluate_edit_tool`].
///
/// Why: these are the direct filesystem-mutation tools. Prohibition P1's real
/// target is *source-code implementation* — the PM must hand `.rs`/`.py`/… work
/// to rust-engineer. But a single-file write to PM-owned orchestration state (a
/// session snapshot under `.trusty-mpm/`, the memory palace, a `TASK.md`), a
/// doc, or a config file is legitimate PM work and must NOT be blocked
/// (issue #2604 — Bob's directive: "the PM can write single files").
/// What: matched by exact tool name in [`evaluate_tool`], then classified by
/// target path in [`evaluate_edit_tool`].
const EDIT_TOOLS: & = &;
/// Deny reason for a PM direct write to a *source-code* file (prohibition P1).
///
/// Why (issue #2918): this text is the pure classifier's internal deny
/// signal — `pm_guard` compares a returned reason against this constant to
/// decide budget-eligibility, then (once the per-turn budget is exhausted)
/// builds a fresh, content-routed message rather than printing this one
/// verbatim. It is retained (rather than inlined) so `evaluate_tool`/
/// `evaluate_edit_tool` stay pure and their existing unit tests need no
/// budget/routing awareness.
const SOURCE_EDIT_REASON: &str = "PM must not implement source code directly (prohibition P1). \
Delegate the change to rust-engineer via the Task/Agent tool. Single-file writes to \
non-source files (docs, config, and `.trusty-mpm/` orchestration state) are permitted.";
/// Source-code file extensions a PM direct edit-tool call must not target.
///
/// Why: prohibition P1 blocks the PM from *implementing code* directly; the file
/// extension is the cheap, reliable signal for "this is source the PM should
/// hand to rust-engineer" (issue #2604). Non-code single-file writes (docs,
/// config, data, orchestration state) are legitimate and are NOT listed here.
/// What: lowercase extensions (no dot) matched in [`is_source_code_path`]. The
/// list covers the mainstream compiled/scripting languages; anything not listed
/// is treated as a non-source file and allowed.
/// Test: `is_source_code_path_classifies_by_extension`.
const SOURCE_CODE_EXTENSIONS: & = &;
/// `tm hook --pm-guard` entry point — classify a `PreToolUse` call and emit an
/// ALLOW (silent) or DENY (`permissionDecision`) response.
///
/// Why: this is the load-bearing integration point wired into every managed
/// session's `PreToolUse` hook (see `session_launch::settings::write_project_hooks`).
/// It must be fast and fail-open: guard env vars short-circuit to ALLOW so
/// nested sub-agents (`CLAUDE_MPM_SUB_AGENT`) and CI / build shells
/// (`TRUSTY_MPM_DISABLE_HOOKS`) are never blocked, and the explicit
/// `TRUSTY_MPM_PM_UNRESTRICTED=1` bypass lets the operator lift enforcement on
/// demand. Any missing/malformed input degrades to ALLOW rather than wedging
/// the PM. The decision is computed purely from the static tool classification
/// — never a daemon call — so a down daemon can never hard-block a tool call.
/// What: returns `Ok(())` (ALLOW, no stdout) for every short-circuit, a payload
/// with no `tool_name`, or an [`evaluate_tool`] verdict of ALLOW. On DENY it
/// fires a best-effort audit POST to `<url>/hooks` (tight timeout, result
/// ignored — audit only, never gating) and prints the
/// `hookSpecificOutput.permissionDecision = "deny"` JSON to stdout, then
/// returns. `url` is used only for the audit POST.
/// Test: env short-circuits + fail-open + deny/allow are covered end to end in
/// `tests/tm_hook_pm_guard.rs`; the pure policy is covered by this module's
/// unit tests.
pub async
/// Whether the `TRUSTY_MPM_PM_UNRESTRICTED=1` bypass is active.
///
/// Why: split out so the exact-`"1"` matching rule has one definition.
/// What: `true` only when [`PM_UNRESTRICTED_ENV`] is set to exactly `"1"`.
/// Test: exercised via `tests/tm_hook_pm_guard.rs::pm_guard_bypass_env_allows_all`.
/// Whether a `PreToolUse` payload originates from a native Claude Code
/// sub-agent (a `Task`/`Agent`-tool-dispatched delegation) rather than the
/// top-level PM's own tool call.
///
/// Why (issue #2014): the PM legitimately dispatches Edit/Write work to
/// rust-engineer (and other) sub-agents via the native `Task`/`Agent` tool.
/// That dispatched sub-agent's tool calls fire their own `PreToolUse` events,
/// but the pre-existing [`SUB_AGENT_ENV`] exemption only covers trusty-mpm's
/// own nested-process spawns (it stamps `CLAUDE_MPM_SUB_AGENT` on processes
/// *it* forks) — a native Task-tool sub-agent never has that var set, so its
/// Edit/Write calls were denied, forcing the all-or-nothing
/// `TRUSTY_MPM_PM_UNRESTRICTED=1` bypass just to unblock ordinary delegation.
/// Claude Code's hooks contract documents `agent_id` as present in the
/// `PreToolUse` JSON payload "only when the hook fires inside a subagent
/// call", specifically so hooks can "distinguish subagent hook calls from
/// main-thread calls" (confirmed against the live hooks reference,
/// <https://code.claude.com/docs/en/hooks>, 2026-07-06) — it is the documented,
/// version-stable signal for exactly this distinction, unlike undocumented
/// process env vars.
/// What: returns `true` when `payload.agent_id` is present and non-empty.
/// `agent_type` is deliberately NOT used as the signal — it is also stamped on
/// a top-level session launched with `--agent`, so alone it would not reliably
/// distinguish a dispatched sub-agent from the PM's own session.
/// Test: `payload_is_subagent_dispatch_true_when_agent_id_present`,
/// `payload_is_subagent_dispatch_false_when_absent_or_empty`; exercised end to
/// end via `pm_guard_native_subagent_dispatch_allows_edit` in
/// `tests/tm_hook_pm_guard.rs`.
/// Classify a `PreToolUse` tool call: `Some(reason)` denies, `None` allows.
///
/// Why: the default-ALLOW + explicit deny-list policy. Default-deny would
/// break the PM (it legitimately calls Read/Grep/Task/git-status/mcp tools all
/// the time); only the small, high-confidence set of prohibited actions is
/// blocked.
/// What: routes an [`EDIT_TOOLS`] call through the path-based
/// [`evaluate_edit_tool`] (deny only a source-code write — issue #2604),
/// delegates a `Bash` call to [`evaluate_bash_command`] against its `command`
/// argument, and allows every other tool (Read, Grep, Glob, Task, TodoWrite,
/// WebFetch, `mcp__*`, …).
/// Test: `evaluate_tool_denies_source_code_edits`,
/// `evaluate_tool_allows_pm_orchestration_state`,
/// `evaluate_tool_allows_read_and_task`, `evaluate_tool_delegates_bash`.
pub
/// Classify a PM direct edit-tool call by its target path (issue #2604).
///
/// Why: prohibition P1 must stop the PM implementing *source code* directly
/// while still letting it write single non-code files — its own orchestration
/// state (`.trusty-mpm/` snapshots, the memory palace), docs, and config. Bob's
/// directive (live dogfooding): "the PM can write single files." The pre-#2604
/// guard blocked *all* Edit/Write, which denied the PM its own session-pause
/// snapshot write to `.trusty-mpm/sessions/session-*.md`.
/// What: reads the target path; ALLOW (`None`) when it is PM-owned orchestration
/// state ([`is_pm_orchestration_path`], the HARD always-allow set — this wins
/// over the source-code rule); DENY ([`SOURCE_EDIT_REASON`]) when it is a
/// source-code file ([`is_source_code_path`]); otherwise ALLOW (a single-file
/// write to a non-source file). A call with no resolvable target path fails
/// open to ALLOW, consistent with the module's fail-open posture.
/// Test: `evaluate_tool_denies_source_code_edits`,
/// `evaluate_tool_allows_pm_orchestration_state`,
/// `evaluate_tool_allows_non_source_single_file_writes`,
/// `evaluate_edit_tool_fails_open_without_path`.
/// The filesystem path an edit-tool call targets, if any.
///
/// Why: the edit-tool deny decision (issue #2604) is path-based, so it must read
/// the file the call would write. `Write`/`Edit`/`MultiEdit` name it in
/// `file_path`; `NotebookEdit` uses `notebook_path`.
/// What: returns the first present string of `tool_input.file_path` /
/// `tool_input.notebook_path`, or `None` when neither is present (a malformed
/// call — the caller then fails open).
/// Test: covered via `evaluate_edit_tool_fails_open_without_path` and the
/// `evaluate_tool_*` edit cases.
/// Whether `path` is PM-owned orchestration state that must NEVER be blocked.
///
/// Why (issue #2604, HARD requirement): the PM has to persist its own
/// orchestration artifacts — session snapshots and config/override files under
/// `.trusty-mpm/`, its memory palace under the tm-owned `.trusty-tools/` config
/// home, and `TASK.md`-style session files. A session-pause writing
/// `.trusty-mpm/sessions/session-*.md` must always pass, independent of the
/// source-code rule.
/// What: `true` when the basename is `TASK.md` or `MEMORY.md`, or when any path
/// *component* is exactly `.trusty-mpm` or `.trusty-tools`. Component matching
/// (not substring) is deliberate so the crate source dir `crates/trusty-mpm/`
/// — note no leading dot — is NOT matched.
/// Test: `is_pm_orchestration_path_matches_owned_state`.
/// Whether `path` names a source-code file (by extension).
///
/// Why: prohibition P1 blocks the PM from *implementing code* directly; the
/// extension is the reliable, cheap signal for "this is source the PM should
/// hand to rust-engineer" (issue #2604).
/// What: `true` when the lowercased file extension is in
/// [`SOURCE_CODE_EXTENSIONS`]. Extension-less files (e.g. `Makefile`, `README`)
/// and non-code extensions (`.md`, `.toml`, `.json`, `.txt`) return `false`.
/// Test: `is_source_code_path_classifies_by_extension`.
/// 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
/// [`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.
/// Test: `build_pretooluse_deny_response_has_expected_shape`.
pub
/// Best-effort audit POST recording a PM-guard denial to the daemon.
///
/// Why: enforcement actions should be observable (dashboard / audit log), but
/// the audit must never gate or slow the decision — a PreToolUse hook has to
/// be fast and fail-open. So this is fire-and-forget with a tight connect +
/// total timeout and a fully-ignored result; a down/slow daemon costs at most
/// the short timeout and never changes the (already-computed) deny outcome.
/// What: POSTs `{session_id, event:"PreToolUse", payload:{cwd, tool,
/// pm_guard_decision:"deny", pm_guard_reason}}` to `<url>/hooks` and drops any
/// error. Builds its own short-lived client so the connect guard is scoped to
/// this call; a client-build failure silently skips the POST.
/// Test: side-effect-only best-effort I/O — covered indirectly by the deny
/// paths in `tests/tm_hook_pm_guard.rs` (which point at an unreachable daemon,
/// proving the deny still emits despite the failed POST).
async