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
//! Project-level settings hygiene: detect and remove tm hook contamination
//! from project `.claude/settings*.json` files (issue #2940).
//!
//! Why: `tm install` used to discover and mutate EVERY `.claude/settings.json`
//! / `settings.local.json` reachable under `$HOME`, wiring tm hooks into every
//! project it found — including projects the operator never launched via tm.
//! That was a one-way street (the project could no longer cleanly go back to
//! claude-mpm, since the tm entries now lived in a file the project owns) and
//! could conflict with a project's own pre-existing claude-mpm hooks. Hooks
//! now live solely in the tm-owned `CLAUDE_CONFIG_DIR` (see
//! [`super::ensure_managed_hooks`]); this module is the cleanup path for
//! settings files a PRE-FIX `tm install` already contaminated, exposed as
//! `tm hooks clean` and as the `tm doctor` hook-hygiene probe.
//! What: [`contains_tm_hooks`] / [`tm_hook_event_names`] detect tm-owned
//! entries; [`foreign_hook_event_names`] detects a DIFFERENT harness's hooks
//! (informational only — never removed); [`clean_settings_file`] performs the
//! actual scan-or-apply pass over one file, always backing up before writing.
//!
//! Known limitations:
//! - **Mixed hook groups (issue #2948, RESOLVED).** [`event_names_matching`]
//! now classifies a group by `.any()` over its inner `hooks[*].command`
//! entries, and [`super::strip_hook_entries_matching_for_events`] filters at
//! PER-ENTRY granularity within a group rather than dropping/keeping the
//! whole group. A hand-edited group whose inner array mixes ONE tm command
//! with ONE foreign (e.g. claude-mpm) command is now flagged by both
//! [`tm_hook_event_names`] and [`foreign_hook_event_names`] (it genuinely
//! carries both kinds of entry), and `tm hooks clean --force` removes only
//! the tm-owned entry, leaving the foreign entry and the group itself intact.
//! Previously (issue #2940 review round 1) the `.all()` classification made
//! such a group invisible to both `tm doctor` and `tm hooks clean` even
//! though it carried a live tm entry.
//! - **The hash-suffixed binary matcher trusts any binary under a `deps/`
//! directory.** [`super::is_mpm_hook_command`]'s hash-suffix branch (via
//! [`super::is_mpm_hash_suffixed_artifact`]) requires the resolved path to
//! contain a `deps` path component — narrowing, but not eliminating, the
//! risk that a coincidentally-named foreign binary (`<stem>-<hexhash>` under
//! *some* `deps/` directory that isn't actually a tm Cargo build artifact)
//! is misclassified as tm-owned by `tm hooks clean --force`. The bare-name
//! branch (`tm`/`trusty-mpm`/`session_manager_mvp`, unscoped by path) carries
//! the same pre-existing residual risk. Dry-run mode always prints the
//! exact matched command strings before any deletion (see `commands::hooks::clean`)
//! so an operator can review before applying `--force`.
//!
//! Test: `cargo test -p trusty-mpm standalone::hooks::cleanup` — see
//! `cleanup_tests.rs`.
use ;
use Value;
use ;
/// Per-file outcome of a [`clean_settings_file`] pass.
///
/// Why: `tm hooks clean` needs to report exactly what it found (dry run) or
/// did (`--force`) per file, without re-deriving the event list from the
/// mutated value after the fact.
/// What: the scanned file's path, the `hooks` event keys that carried at
/// least one tm-owned group, and — only when the caller passed `force: true`
/// and a write actually happened — the backup file's path.
/// Test: `clean_settings_file_dry_run_reports_without_writing`,
/// `clean_settings_file_force_writes_backup_and_strips`.
/// Does this settings value contain at least one tm-owned hook group?
///
/// Why: the fast, read-only predicate both `tm hooks clean`'s dry run and the
/// `tm doctor` contamination check need — scanning without ever risking a
/// write.
/// What: walks every array under the top-level `hooks` object and returns
/// `true` as soon as one group's `hooks[*].command` entries include AT LEAST
/// ONE recognised by [`is_mpm_hook_command`] (issue #2948: entry-level, not
/// whole-group, so a hand-mixed group carrying one tm command alongside a
/// foreign one is still detected — mirroring the matching semantics
/// [`strip_mpm_hook_entries`] uses to decide what to remove).
/// Test: `contains_tm_hooks_true_for_tm_entry`,
/// `contains_tm_hooks_false_for_foreign_or_absent`.
/// The `hooks` event keys that carry at least one tm-owned group.
///
/// Why: [`CleanOutcome::removed_events`] must be computed BEFORE
/// [`strip_mpm_hook_entries`] mutates the value away, and the doctor probe
/// wants the same list for its message.
/// What: returns every event key (e.g. `PreToolUse`, `Stop`) under `hooks`
/// where at least one group carries at least one tm-owned entry (issue #2948:
/// entry-level match, so a hand-mixed group counts). Order follows the JSON
/// object's iteration order; empty when `hooks` is absent or not an object.
/// Test: `tm_hook_event_names_lists_only_contaminated_events`,
/// `tm_hook_event_names_flags_mixed_group`.
/// The `hooks` event keys that carry at least one FOREIGN (claude-mpm) group.
///
/// Why: `tm doctor` must warn about foreign hooks that would fire inside a tm
/// session WITHOUT ever touching them — that call belongs to the operator.
/// What: returns every event key under `hooks` where at least one group
/// carries at least one entry recognised by [`is_claude_mpm_hook_command`]
/// (issue #2948: entry-level match). [`is_mpm_hook_command`] and
/// [`is_claude_mpm_hook_command`] are mutually exclusive PER COMMAND STRING —
/// a single command is never classified as both — but a single EVENT can now
/// appear in both [`tm_hook_event_names`] and this function's result when a
/// hand-mixed group (or two separate groups under the same event) carries one
/// entry of each kind; that is a real, simultaneous contamination-and-conflict
/// state, not a classification bug.
/// Test: `foreign_hook_event_names_lists_claude_mpm_entries`,
/// `foreign_hook_event_names_flags_mixed_group`.
/// Shared walker behind [`tm_hook_event_names`] / [`foreign_hook_event_names`].
///
/// Why (issue #2948): a hook GROUP is classified by whether ANY of its inner
/// `hooks[*].command` entries match — not ALL of them. The previous `.all()`
/// classification made a hand-mixed group (one tm command + one foreign
/// command in the same matcher group) invisible to both the tm-owned and
/// foreign-owned predicates, since neither ALL-tm nor ALL-foreign held. `.any()`
/// makes the walker symmetric with [`super::strip_hook_entries_matching_for_events`],
/// which also now operates at entry (not group) granularity.
/// Scan (and, when `force`, clean) one settings file for tm hook contamination.
///
/// Why: the single write path for `tm hooks clean` — every call backs up
/// before mutating so a bad scan can always be undone by hand, and a missing
/// or non-object file is treated as "nothing to clean" rather than an error
/// (a project with no settings file, or one that failed to parse, has no tm
/// contamination to report). The final write MUST be atomic (issue #2940
/// review round 1, HIGH): a plain truncate-then-write left `settings.json`
/// corrupt — unparseable by Claude Code until a manual `.bak` restore — if
/// the process died mid-write; [`trusty_common::claude_config::write_json_atomic`]
/// writes to a temp file and renames over `path`, matching the atomicity
/// [`super::remove_global_trusty_mpm_hooks`] (mod.rs:353) already uses for
/// the same class of mutation.
/// What: reads `path` (missing file → `Ok(None)`, malformed/non-object JSON →
/// `Ok(None)` — never touched), returns `Ok(None)` when
/// [`contains_tm_hooks`] is `false`. Otherwise computes the contaminated
/// event list, and when `force` is `true`: writes a byte-identical backup of
/// the pre-clean text to `<path>.bak-<unix-epoch-seconds>` (this crate's own
/// backup, reported via [`CleanOutcome::backup_path`]; `write_json_atomic`
/// additionally maintains its own internal `<path>.bak`, a harmless second
/// safety net), strips the tm-owned groups via [`strip_mpm_hook_entries`],
/// and atomically writes the result back (preserving every other key). In
/// dry-run mode (`force: false`) the file is never touched and `backup_path`
/// is `None`.
/// Test: `clean_settings_file_dry_run_reports_without_writing`,
/// `clean_settings_file_force_writes_backup_and_strips`,
/// `clean_settings_file_preserves_non_tm_keys`,
/// `clean_settings_file_missing_file_is_noop`,
/// `clean_settings_file_malformed_json_is_noop`,
/// `clean_settings_file_non_object_json_is_noop`.