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
//! `tm hook --pm-guard` — agent-side `git worktree remove` denial (#5791).
//!
//! Why: removing a merged worktree could not be delegated at all. An
//! unisolated dispatch is denied by the #4480 shared-HEAD guard, and an
//! isolated agent's git operations are confined to its own worktree, so it
//! cannot act on the shared registry either. The owner ruled on 2026-08-19
//! that this is not a hole to open: worktree removal is PM-executed via a `tm`
//! command — the PM confirms the agent's work is done and runs the removal
//! itself. Prose alone does not carry that. `BASE-AGENT.md` told every agent
//! for months to "remove your worktree" after a merge, and an agent that reads
//! a stale copy of that instruction still reaches for `git worktree remove`.
//! What: [`evaluate_worktree_remove_command`] denies a `git worktree remove`
//! segment when, and only when, the caller is a subagent. `list`, `prune`,
//! `lock`, and `move` are untouched — this rule is about destroying a checkout,
//! not about reading or repairing the registry. The PM is never denied: it is
//! the session the ruling puts in charge, and it runs the reclaim through
//! `tm session prune-worktrees --merged-prs --force`.
//!
//! Caller context comes from [`super::super::pm_guard_fanout::caller_is_subagent`],
//! which FAILS OPEN — an indeterminate context reads as "not a subagent" and
//! allows. That asymmetry is deliberate there and inherited here: a false DENY
//! would land on the PM, the one session that must keep working.
//!
//! Test: `denies_worktree_remove_from_a_subagent`,
//! `allows_worktree_remove_from_the_pm`,
//! `allows_non_remove_worktree_subcommands`,
//! `denies_a_remove_hidden_in_a_composed_command` below;
//! `pm_guard_denies_worktree_remove_from_native_subagent` and
//! `pm_guard_allows_worktree_remove_from_pm` run the binary end to end in
//! `tests/tm_hook_pm_guard.rs`.
use shell_lex;
/// Deny reason for an agent-side `git worktree remove` (#5791).
///
/// Why: a bare refusal makes the model retry or hand-roll a `rm -rf`, which is
/// the worse outcome — it destroys unsaved work and leaves a stale registry
/// entry behind, so the text forecloses it explicitly rather than leaving it
/// as the obvious next thing to try. The text also names the ruling, the one
/// session allowed to run the removal, the exact command that does it, and
/// what the agent should do instead — report and stop. It says which worktree
/// verbs still work, so an agent reading a registry does not treat the whole
/// subcommand as blocked.
/// What: the `permissionDecisionReason` string emitted on this deny.
/// Test: `denies_worktree_remove_from_a_subagent`.
pub const WORKTREE_REMOVE_DENY_REASON: &str = "Worktree removal is PM-executed (#5791, owner ruling 2026-08-19): an agent never removes a \
worktree, its own included. Report back instead — name the merged PR and the worktree path, \
then stop. The PM confirms the work is done and reclaims the tree with \
`tm session prune-worktrees --merged-prs --force`, which spares any worktree still holding \
unsaved work or still owned by a live agent. `rm -rf` on the worktree directory is not the \
workaround either — it destroys unsaved work and leaves a stale registry entry git still \
believes in. `git worktree list` and `git worktree prune` are not blocked, and SendMessage \
is never blocked — use it to report the path back.";
/// Classify a Bash command for agent-side worktree removal: `Some(reason)`
/// denies, `None` allows.
///
/// Why: kept pure — it takes the already-resolved caller context as a `bool`
/// rather than reading the environment — so the policy is exhaustively unit
/// testable and the one env read stays in `pm_guard_fanout`.
/// What: allows outright when the caller is not a subagent, and otherwise
/// denies when any composition segment is a `git worktree remove`.
/// Test: the four cases named in the module docs.
pub
/// True when any composition segment of `command` is a `git worktree remove`.
///
/// Why: a forbidden verb hides in any segment, not just the first
/// (`cargo test && git worktree remove …`), which is why this reuses the same
/// [`super::split_shell_segments`] the rest of the classifier does rather than
/// matching the whole string.
/// What: splits, keeps the segments whose resolved program is `git` with
/// subcommand `worktree` (so `git -C <path> worktree remove` and an
/// env-prefixed spelling both resolve), and looks for the adjacent
/// `worktree remove` token pair. Same residual bypasses as the sibling
/// `worktree add` guard: a verb built by variable expansion or hidden in a
/// command substitution is not resolved, and — largest of them, tracked as
/// [issue #3981](https://github.com/bobmatnyc/trusty-tools/issues/3981) —
/// `pm_guard`'s Guard 2/3 escape hatches
/// (`TRUSTY_MPM_DISABLE_HOOKS`/`TRUSTY_MPM_PM_UNRESTRICTED`) bypass this rule
/// entirely when set, and a subagent Guard 4 already exempts can set either
/// through an ordinary, unblocked `.claude/settings.json` write. That gap
/// predates this rule and is not widened by it; the remedy belongs in the
/// edit-tool classifier. See the same note on
/// [`super::evaluate_worktree_add_command`].