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
//! Detection and quarantine of stray `.mcp.json` files above a workspace.
//!
//! Why: Claude Code discovers `.mcp.json` by walking UP from a session's cwd,
//! so a file at `/tmp/.mcp.json` configures the MCP servers of every session
//! whose cwd is anywhere beneath `/tmp` — including agent scratchpad
//! directories, which is how the one that motivated this module was found. tm
//! writes `<workspace>/.mcp.json` on every managed launch, so a run whose
//! workspace resolved above real projects leaves such a file behind. Stopping
//! future writes is a separate decision (ADR-0042) and would not help: a file
//! already on disk keeps being discovered no matter what the write path later
//! does.
//!
//! The whole design is bounded by two constraints, and both push toward
//! refusing:
//!
//! 1. **A `.mcp.json` may be the operator's.** The observed file declares four
//! `trusty-*` servers AND four operator HTTP servers in one map. "It contains
//! trusty-* servers" is not provenance — an operator can register exactly
//! those by hand. Proof comes from [`crate::core::mcp_provenance`]'s ledger
//! and from nowhere else.
//! 2. **Nothing here deletes.** A quarantine RENAMES `.mcp.json` to
//! `.mcp.json.quarantined-<epoch>` beside itself. Claude Code looks for the
//! exact basename, so the rename is what actually stops the discovery, and
//! every byte survives for the operator to inspect or restore. This keeps
//! [`crate::core::doctor_repair`]'s "nothing is deleted" rule intact rather
//! than carving an exception into it.
//!
//! **Search bound** ([`scan_dirs`]): the strict ancestors of the workspace,
//! walking up and stopping at the operator's home directory (inclusive), the
//! filesystem root (exclusive), or [`MAX_ANCESTOR_DEPTH`] levels — whichever
//! comes first — plus the two well-known temp roots. Two things this
//! deliberately is NOT: it is not a walk to `/` (above home is system config
//! and none of tm's business), and it is not a recursive descent (a scan that
//! finds `.mcp.json` files INSIDE unrelated projects would report other tools'
//! legitimate config as strays). The temp roots are listed explicitly because
//! they are not ancestors of any workspace — they are ancestors of the agent
//! scratchpad cwds, which is exactly why a file there is so far-reaching. The
//! whole set is a fixed handful of `stat` calls, cheap enough to run on every
//! `tm doctor`.
//!
//! Test: `stray_mcp_tests.rs`.
use ;
use crate;
use crateMCP_JSON;
use crate;
/// The `tm doctor` check name, and the `check` field of every repair step.
pub const CHECK_NAME: &str = "stray_mcp_json";
/// Hard ceiling on how far up from a workspace the scan walks.
///
/// Why: the home directory is the normal ceiling, but a workspace outside home
/// (`/opt/…`, an external volume) has no such boundary and would otherwise walk
/// to the filesystem root. A fixed cap keeps the cost constant and keeps the
/// scan away from system directories in that case too.
/// Test: `scan_dirs_stops_at_the_depth_cap_outside_home`.
pub const MAX_ANCESTOR_DEPTH: usize = 8;
/// One `.mcp.json` found above a workspace.
///
/// Why: the doctor check reports these and the repair acts on them, from one
/// scan — so the provenance verdict and the server list are computed once and
/// cannot disagree between the two surfaces.
/// What: the file's path, what tm can prove about who wrote it, and the
/// `mcpServers` keys it declares (for the report; never for attribution).
/// Test: `stray_mcp_tests.rs`.
/// The directories a scan probes for a stray `.mcp.json`.
///
/// Why: see the module doc — the bound is the design decision here, and it is a
/// single function so the check and the repair can never sweep different sets.
/// What: the strict ancestors of `workspace` (nearest first), stopping after
/// `home` (inclusive), before the filesystem root, or after
/// [`MAX_ANCESTOR_DEPTH`] entries; then `std::env::temp_dir()` and `/tmp`.
/// Deduplicated, order-stable. Never includes `workspace` itself — that file is
/// the project's own managed config, not a stray. Pure path arithmetic plus the
/// temp-dir lookup; touches the filesystem only through the caller's later
/// `stat`.
/// Test: `scan_dirs_walks_ancestors_up_to_home`,
/// `scan_dirs_excludes_the_workspace_itself`,
/// `scan_dirs_never_reaches_the_filesystem_root`,
/// `scan_dirs_stops_at_the_depth_cap_outside_home`,
/// `scan_dirs_includes_the_temp_roots`.
/// A directory's canonical form, falling back to its lexical path.
///
/// Why: every path comparison in this module decides whether a file is left
/// alone, and two spellings of one directory must never read as two
/// directories. Symlinked spellings are the normal case here, not an edge:
/// macOS makes `/tmp` a symlink to `/private/tmp`, and a caller can hold either
/// spelling of a workspace or a home directory. Centralised so the comparison
/// cannot be done lexically at one site and canonically at another — which is
/// exactly the drift that let a lexical workspace guard rename a project's own
/// live `.mcp.json` away.
/// What: [`std::fs::canonicalize`], or the path unchanged when it cannot be
/// resolved (absent, unreadable). A comparison against an unresolvable path
/// falls back to the lexical one, which can only ever FAIL to match — and a
/// failed match here means "not the workspace, not home", so the caller
/// re-examines rather than acts. The mirror of
/// [`mcp_provenance::absolute_key`](crate::core::mcp_provenance), which
/// canonicalizes a file's PARENT for the same reason.
/// Test: `explicit_refuses_the_workspaces_own_file_via_a_symlinked_workspace_spelling`,
/// `scan_dirs_stops_at_home_through_a_symlinked_spelling`,
/// `scan_dirs_has_no_duplicates`.
/// Are these two paths the same directory, whatever their spelling?
///
/// Why: see [`canonical_dir`]. This is the only comparison the module makes.
/// Test: as [`canonical_dir`].
/// Find every stray `.mcp.json` in the bounded scan set.
///
/// Why: one read-only pass feeds both the doctor check and the repair preview.
/// What: for each directory from [`scan_dirs`] holding a regular-file
/// `.mcp.json`, classifies it via [`mcp_provenance::classify`] and collects the
/// `mcpServers` keys. A symlink is reported (so the operator sees it) but
/// carries a [`Provenance::Unknown`] verdict, because what it points at is not
/// the file that was found and following it is how a repair damages something
/// elsewhere. Writes nothing.
/// Test: `scan_finds_a_stray_above_the_workspace`,
/// `scan_ignores_the_workspaces_own_file`,
/// `scan_reports_a_symlink_as_unknown`,
/// `scan_lists_the_declared_servers`.
/// The `mcpServers` keys a `.mcp.json` declares.
///
/// Why: the report names them so an operator can recognise the file without
/// opening it — the point being that a mixed file (framework servers beside
/// hand-added ones) is visibly the case where deletion would lose something.
/// This is for the OPERATOR's judgement, never for tm's: nothing downstream
/// derives provenance from these names.
/// What: sorted top-level keys of `mcpServers`; empty on any read or parse
/// failure, which is a display gap, not an error worth failing a check over.
/// Test: `scan_lists_the_declared_servers`, `declared_servers_tolerates_garbage`.
/// Quarantine every stray `.mcp.json` tm can PROVE it wrote.
///
/// Why: this is the sweep `tm doctor --fix` runs, and its safety rests
/// entirely on the ledger. Only [`Provenance::TmWritten`] — tm wrote it and the
/// bytes are unchanged — is acted on. An unattributed file (every file written
/// before the ledger shipped, including the one that motivated this work), a
/// file edited after tm wrote it, and anything whose provenance could not be
/// determined are each REFUSED with the reason shown, exactly as
/// `refuse_legacy_sources` reports rather than acts. The operator clears those
/// with [`quarantine_explicit`], where naming the path IS the attribution.
/// What: one [`RepairStep`] per finding; [`RepairMode::DryRun`] classifies and
/// prints without touching the filesystem. A file cleared for quarantine is
/// RE-CLASSIFIED immediately before the rename — see [`apply_verified`].
/// Test: `sweep_quarantines_a_tm_written_stray`,
/// `sweep_refuses_an_unattributed_stray`,
/// `sweep_refuses_a_stray_edited_after_tm_wrote_it`,
/// `sweep_refuses_when_the_ledger_is_unreadable`,
/// `sweep_dry_run_writes_nothing`,
/// `sweep_never_touches_the_workspaces_own_file`,
/// `sweep_refuses_a_file_edited_between_scan_and_rename`.
/// Quarantine one `.mcp.json` the operator named on the command line.
///
/// Why: the sweep above can never clear a file written before the ledger
/// existed, which is every stray on disk today. Rather than weaken the sweep's
/// evidence rule — the one thing standing between a repair and somebody's
/// hand-written config — this takes the attribution from the operator: naming
/// an exact path is a deliberate act tm cannot perform on its own, and it is
/// still a dry run until `--yes`.
///
/// It refuses three things regardless of what was named: a path that is not a
/// `.mcp.json` (the operator meant something else, and this command has no
/// business renaming an arbitrary file), the WORKSPACE's own `.mcp.json` (live
/// managed config, whose removal breaks the current project rather than fixing
/// anything), and a symlink (the bytes are elsewhere).
/// What: one [`RepairStep`]. On [`RepairMode::Apply`] the rename happens and
/// the ledger claim, if any, is released.
/// Test: `explicit_quarantines_an_unattributed_file`,
/// `explicit_refuses_a_non_mcp_path`,
/// `explicit_refuses_the_workspaces_own_file`,
/// `explicit_refuses_a_symlink`, `explicit_refuses_a_missing_file`,
/// `explicit_dry_run_writes_nothing`.
/// Re-verify provenance immediately before renaming, then rename.
///
/// Why (#5371 critic HIGH): the sweep classifies during [`scan`] and renames
/// later. Anything can happen in that window — a concurrent session launch
/// rewriting the file, or an operator opening it in an editor and saving — and
/// a rename driven by the earlier verdict would discard an edit made since,
/// which is the one outcome this whole design exists to prevent. The window
/// cannot be closed (there is no atomic compare-and-rename), so it is made
/// small and the stale verdict is never trusted: the checksum is re-read at the
/// point of action.
///
/// A file that changed in the window is REFUSED, not retried. Retrying would
/// race the same way, and the operator's edit is precisely what must survive.
/// What: re-runs [`mcp_provenance::classify`]; only a still-[`Provenance::TmWritten`]
/// verdict proceeds to [`apply_or_plan`]. Skipped in [`RepairMode::DryRun`],
/// which writes nothing and whose preview is allowed to be a moment stale.
/// Test: `sweep_refuses_a_file_edited_between_scan_and_rename`,
/// `sweep_quarantines_a_tm_written_stray`.
/// Rename the file aside, or describe the rename.
///
/// Why: preview and apply must be one code path — a separate planner drifts
/// from the actor exactly when it matters, which is the rule
/// [`RepairMode`] exists to enforce.
/// What: in [`RepairMode::DryRun`] returns a [`StepStatus::Planned`] naming the
/// destination. In [`RepairMode::Apply`] renames `.mcp.json` to
/// `.mcp.json.quarantined-<epoch>` in the SAME directory (so the rename is
/// atomic — same filesystem — and no bytes are copied or lost), then releases
/// the ledger claim. The destination is reported as the backup, because that is
/// exactly what it is.
/// Test: `sweep_quarantines_a_tm_written_stray`,
/// `explicit_quarantines_an_unattributed_file`,
/// `quarantine_destination_does_not_collide`.
/// Where a quarantined `.mcp.json` is moved to.
///
/// Why: the basename must stop matching `.mcp.json` exactly — that is what ends
/// the discovery — while staying beside the original so the rename is atomic
/// and the operator finds it without being told where to look.
/// What: `<dir>/.mcp.json.quarantined-<unix-seconds>`, with a numeric suffix if
/// that already exists, so a second run in the same second never overwrites the
/// first quarantine.
/// Test: `quarantine_destination_does_not_collide`.
/// A refusal step naming the path and the reason.