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
//! The MCP servers a tm-launched Claude Code session adds, and how (#7892).
//!
//! Why: tm points `CLAUDE_CONFIG_DIR` at one protected directory (ADR-0042)
//! whose `.claude.json` holds the operator's own USER-SCOPE `mcpServers` map.
//! Owner ruling 2026-09-14 (#7892, superseding the 2026-09-11 default-deny
//! ruling of #7422 and PR #7468): that map has STANDARD Claude Code user-scope
//! semantics — every server in it loads in every tm session, with no per-project
//! grant. The per-project model it replaces cost an operator eight working
//! servers in one session and four commands to get them back.
//!
//! What: tm's own trusty-* builtins are added ON TOP, through the one file this
//! module composes and hands to `--mcp-config`. That flag is ADDITIVE in Claude
//! Code: it merges its servers with the user-scope map rather than replacing it.
//! `--strict-mcp-config`, which would have suppressed the user scope, is gone
//! (#7892) — [`mcp_config_flag_string`] and [`mcp_config_argv`] render the
//! `--mcp-config <file>` pair alone.
//!
//! WHAT THIS MODULE NO LONGER DOES. It does not read the operator's user-scope
//! map to filter it, does not copy it into the composed file, and does not
//! consult `tm project trust`, `tm mcp share`, or `[session] mcp_servers` for
//! the server half. A project's own `<cwd>/.mcp.json` is Claude Code's to
//! approve, through its native `enableAllProjectMcpServers` /
//! `enabledMcpjsonServers` settings or its own prompt — tm neither pre-approves
//! nor suppresses it. [`crate::core::project_mcp_approval`] only REPORTS that
//! native state for `tm doctor` and `tm mcp list`.
//!
//! THE PLUGIN HALF OF `[session]` IS UNCHANGED and still gated on
//! [`crate::core::project_trust::is_project_trusted`], through
//! [`granted_plugins`]. Claude Code has no per-project plugin approval —
//! `enabledPlugins` is settings-only — so there is no native standard for
//! #7892 to defer to, and a clone that names an operator-installed plugin would
//! otherwise turn its skills, commands and hooks on inside a
//! `--dangerously-skip-permissions` pane (#7422, issue #3033).
//!
//! THE COMPOSED FILE STILL NEVER LIVES IN THE REPOSITORY. It is written to
//! `~/.trusty-tools/trusty-mpm/session-mcp/<hash of cwd>.json` at mode `0600`,
//! keyed on the cwd so two worktrees of one repository never share a file, and
//! rewritten on every launch.
//!
//! FAIL-OPEN (#7892), where #7422 failed closed: a `.claude.json` tm cannot
//! read or parse no longer stops anything, because nothing in the composition
//! depends on it. The launch proceeds with the builtins, and
//! [`McpScope::degraded`] carries a one-line warning naming the file, which
//! [`provision_at`] prints. An unwritable state directory is still the one
//! hard failure ([`ScopeError`]) — without the composed file the session would
//! silently lose tm's own builtins.
//! Test: `session_mcp_scope_tests.rs`.
use ;
use ;
use crate;
/// Directory under the tm-managed state root holding every composed file.
///
/// Why: the file is tm-owned launch state, so it lives beside the rest of tm's
/// user-scope state and never inside a working tree.
/// What: `session-mcp`, under `~/.trusty-tools/trusty-mpm/`.
/// Test: `session_mcp_path_is_outside_the_repository`.
pub const SESSION_MCP_DIR: &str = "session-mcp";
/// Owner-only permissions for the composed file and its directory.
///
/// Why: the file is a launch-state record under the operator's own `$HOME`, so
/// it is never group- or world-readable, the same bar `ProjectTrustStore::save`
/// holds its own store to.
/// What: `0o600` for the file, `0o700` for the directory.
const OWNER_ONLY_FILE: u32 = 0o600;
/// Owner-only directory permissions — see [`OWNER_ONLY_FILE`].
const OWNER_ONLY_DIR: u32 = 0o700;
/// Basename of the protected, tm-managed Claude Code config.
const CLAUDE_JSON: &str = ".claude.json";
/// Why a session-scoped MCP config could not be produced.
///
/// Why: without the composed file a session loses tm's own builtins, so this
/// stays a typed error the spawn paths propagate rather than a logged warning.
/// What: `Write` for a state directory or file tm cannot write; `Encode` for a
/// composed map that will not serialise.
/// Test: `provision_errors_when_the_state_dir_is_unwritable`.
/// What a session loads: tm's builtins, plus the user scope Claude Code adds.
///
/// Why: `tm doctor`, `tm mcp list` and `tm session instructions` all have to
/// tell an operator which servers a session in this project actually gets.
/// Since #7892 that is an additive answer with no exclusions in it, so the type
/// carries no `excluded` list — there is nothing tm scopes out.
/// What: `servers` is the `mcpServers` map tm writes and passes to
/// `--mcp-config`; `included` names it, sorted; `user_scope` names the
/// operator's own entries in `$CLAUDE_CONFIG_DIR/.claude.json`, which Claude
/// Code loads itself and tm only reports; `degraded` is set only when that file
/// exists and could not be read or parsed.
/// Test: `resolve_scope_is_the_builtins_plus_the_reported_user_scope`,
/// `resolve_scope_never_filters_the_user_scope`.
/// The opt-in plugin names this project's committed config declares.
///
/// Why: a plugin's skills cost every session in the project its context whether
/// or not that project uses them, and Claude Code has no per-project approval
/// for one.
/// What: the `session.plugins` list, or an empty vector.
///
/// THIS IS THE RAW, UNGATED READ of a file that ships with the clone. Nothing
/// outside this module should call it: the list only takes effect in a trusted
/// project, so every consumer wants [`granted_plugins`] instead.
/// Test: `opt_in_plugins_reads_the_project_config`.
/// The plugin opt-ins this project has actually been GRANTED.
///
/// Why (#7422, retained by #7892): `[session] plugins` is an in-repo
/// declaration, so it cannot be the permission for itself. The server half of
/// that table stopped needing a grant under #7892 because Claude Code has its
/// own user-scope and `.mcp.json` approval semantics to defer to; plugins have
/// none, so this gate stays.
/// What: [`opt_in_plugins`] in a trusted project, an empty vector otherwise —
/// which maps every known plugin to `false`, the default-deny state.
/// Test: `granted_plugins_reads_the_real_trust_store_and_denies_by_default`,
/// `plugin_scope_denies_an_opt_in_from_an_untrusted_project`.
/// [`granted_plugins`] against an explicit trust decision.
///
/// Why: the hermetic seam — the trust bit lives under the operator's `$HOME`,
/// so a test of the gate itself would otherwise have to redirect it.
/// What: the declared list when `trusted`, an empty vector when not.
/// Test: `granted_plugins_are_empty_for_an_untrusted_project`,
/// `granted_plugins_pass_through_for_a_trusted_project`.
/// Stable per-workspace filename component for `cwd`.
///
/// Why: one composed file per workspace, named without leaking the path into a
/// directory listing and without any character a filesystem could reject. Two
/// worktrees of one repository canonicalize differently, so they never collide.
/// What: the first 32 hex characters of the sha256 of the canonicalized `cwd`
/// (the path as given when it cannot be canonicalized, matching
/// `project_trust::normalize`'s fallback so the two agree on the same
/// directory).
/// Test: `session_mcp_path_is_per_workspace`.
/// Where a session's composed MCP config is written, under an explicit root.
///
/// Why: the hermetic core of [`session_mcp_path`]; tests point `root` at a
/// temp dir so they never touch the real `~/.trusty-tools`.
/// What: `<root>/session-mcp/<workspace key>.json`.
/// Test: `session_mcp_path_is_per_workspace`,
/// `session_mcp_path_is_outside_the_repository`.
/// Where a session's composed MCP config is written.
///
/// Why: one derivation, so the writer and the `--mcp-config` flag can never
/// point at different files.
/// What: `~/.trusty-tools/trusty-mpm/session-mcp/<workspace key>.json`.
/// `None` only when the home directory cannot be resolved — the spawn paths
/// call [`provision_for_spawn`] first, which turns that into a typed error
/// before any argv naming this path is built.
/// Test: `session_mcp_path_is_outside_the_repository`.
/// The composed file's path, but only for a spawn that relocates its config dir.
///
/// Why: a spawn that leaves `CLAUDE_CONFIG_DIR` alone reads the operator's own
/// `~/.claude.json`, which already declares whatever they want everywhere; tm
/// adds nothing to it.
/// What: [`session_mcp_path`] when `config_dir` is `Some`; `None` otherwise.
/// Test: `scoped_for_declines_a_non_relocated_spawn`.
/// Render the `--mcp-config` flag pair for a shell command line.
///
/// Why: the flag must appear wherever
/// [`crate::core::model_inject::setting_sources_flag`] appears, so both are
/// rendered by a pure function of the same spawn posture.
/// What: `" --mcp-config '<path>'"` (leading space, single-quoted path) when
/// `mcp_config` is `Some`; the empty string otherwise. // #7892: no
/// `--strict-mcp-config` — that flag suppressed the operator's user scope.
/// Test: `mcp_config_flag_string_quotes_the_path`,
/// `mcp_config_flag_string_is_empty_without_a_file`,
/// `mcp_config_flag_string_never_renders_strict`.
/// Render the `--mcp-config` flag pair as argv tokens.
///
/// Why: the in-place relaunch path `exec`s directly with no shell in between,
/// so its path token must NOT be quoted — quoting would embed literal quote
/// characters in the filename Claude Code then fails to open.
/// What: `["--mcp-config", "<path>"]` when `mcp_config` is `Some`; an empty
/// vector otherwise. // #7892: no `--strict-mcp-config`.
/// Test: `mcp_config_argv_is_two_unquoted_tokens`,
/// `mcp_config_argv_never_carries_strict_mcp_config`.
/// The operator's user-scope server names, read fail-open (#7892).
///
/// Why: these load in every session whatever tm does, so this read exists ONLY
/// to report them. [`crate::core::mcp_config::list_servers`] renames a malformed
/// file to `.claude.json.corrupt`, which is right for an operator-invoked
/// `tm mcp` command and wrong on every launch against a file that also holds
/// OAuth state.
/// What: `Ok(names)` — sorted — for an absent, server-less or parsed file;
/// `Err(one-line reason naming the file)` when it exists and cannot be read or
/// parsed. An absent file is the empty, non-degraded case: an operator who has
/// registered nothing has lost nothing.
/// Test: `user_scope_servers_names_every_entry`,
/// `user_scope_servers_reports_a_malformed_config`,
/// `user_scope_servers_is_empty_without_a_config`.
/// Compose what a session in this config dir will load.
///
/// Why: see the module doc — one decision, read by the launch, `tm doctor`,
/// `tm mcp list` and `tm session instructions` alike. Since #7892 it takes no
/// project directory, because nothing about it varies by project: the composed
/// set is tm's builtins, and the user scope loads regardless.
/// What: `servers`/`included` are [`BUILTIN_MANAGED_MCP_SERVERS`], built by
/// [`builtin_server_entry`] rather than copied from the shared map, so a
/// session keeps its trusty-* servers even when `.claude.json` is unreadable.
/// `user_scope` reports [`user_scope_servers`]; its failure becomes `degraded`
/// and nothing else.
/// Test: `resolve_scope_is_the_builtins_plus_the_reported_user_scope`,
/// `resolve_scope_never_filters_the_user_scope`,
/// `resolve_scope_degrades_but_still_composes_the_builtins`.
/// The exact bytes a launch writes into the composed session-MCP file.
///
/// Why: `tm doctor --fix` has to answer "is the file on disk already what a
/// launch would write?" before deciding to rewrite it, and the only honest way
/// to answer that is to compose the same bytes the writer composes (#7678).
/// What: pretty-printed `{"mcpServers": …}` for [`resolve_scope`]'s decision.
/// Silent: the degraded warning belongs to the launch, not to a comparison.
///
/// # Errors
///
/// [`ScopeError::Encode`] when the composed map will not serialise.
/// Test: `composed_body_matches_the_provisioned_file`.
/// Serialise a composed `mcpServers` map.
///
/// Why: [`composed_body`] and [`provision_at`] must produce identical bytes, and
/// `provision_at` needs the [`McpScope`] itself to print its warning.
///
/// # Errors
///
/// [`ScopeError::Encode`] when the map will not serialise.
/// Test: `composed_body_matches_the_provisioned_file`.
/// Compose and write a session's MCP config, returning the file to point at.
///
/// Why: the write is the step that can fail, so it is separated from the pure
/// [`resolve_scope`] decision and from the pure flag rendering. Callers run it
/// BEFORE building a launch command and propagate its error.
/// What: resolves the managed state root, creates `<root>/session-mcp/` at
/// `0700`, and overwrites the file at `0600`. Rewritten on every launch, and
/// never inside the repository — see the module doc.
///
/// # Errors
///
/// [`ScopeError::Write`] when the state root cannot be resolved, or the
/// directory or file cannot be written; [`ScopeError::Encode`] when the
/// composed map will not serialise.
/// Test: `provision_writes_the_composed_map`,
/// `provision_errors_when_the_state_dir_is_unwritable`,
/// `provision_writes_an_owner_only_file`, `provision_rewrites_on_every_call`.
/// [`provision`] against an already-resolved output path.
///
/// Why: the hermetic core, so tests write into a temp dir without redirecting
/// `$HOME`, and so the one home-resolution failure has exactly one call site.
/// What: see [`provision`]; `path` is the file to write. // #7892: a protected
/// `.claude.json` tm cannot read prints one line on stderr and the launch
/// continues with the builtins — it used to be composed FROM that file, and now
/// is not, so there is nothing left for the failure to take away.
///
/// # Errors
///
/// See [`provision`].
/// Test: `provision_writes_the_composed_map`,
/// `provision_writes_an_owner_only_file`,
/// `provision_warns_and_still_writes_when_the_shared_config_is_malformed`.
/// Provision a session's MCP config when the spawn relocates its config dir.
///
/// Why: every spawn path repeats the same two lines — skip when `config_dir` is
/// `None`, otherwise provision and propagate. One helper keeps the skip
/// condition identical across them.
/// What: `Ok(None)` when `config_dir` is `None`; otherwise [`provision`]'s
/// result wrapped in `Some`.
///
/// # Errors
///
/// Propagates [`provision`]'s error unchanged.
/// Test: `provision_for_spawn_declines_a_non_relocated_spawn`.
/// [`provision_for_spawn`] against an explicit `~/.trusty-tools/trusty-mpm`
/// root (#8233).
///
/// Why: [`provision_for_spawn`] resolves that root from the process home, so a
/// test driving the real managed-launch path wrote a session-mcp file into the
/// operator's own `~/.trusty-tools/`. The daemon already holds the layout this
/// launch belongs to; naming the root here is what lets it reach the write.
/// What: `Ok(None)` when `config_dir` is `None`; otherwise [`provision_at`]
/// against [`session_mcp_path_at`]`(root, cwd)`, wrapped in `Some`.
///
/// # Errors
///
/// Propagates [`provision_at`]'s error unchanged.
/// Test: `provision_for_spawn_at_writes_under_the_named_root`,
/// `provision_for_spawn_at_declines_a_non_relocated_spawn`.