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
//! Allowlisted untracked/secret file sync into session worktrees (#2196).
//!
//! Why: an operator's live checkout often carries files git never tracks —
//! most commonly `.env`/`.env.local` — that a session worktree still needs
//! (env vars for local tooling, API keys for tests, etc.). Managed sessions
//! intentionally never copy the operator's uncommitted work into a worktree
//! (see `tm launch`'s "uncommitted local changes are not carried into the
//! managed clone" warning), so without an explicit, narrow allowlist those
//! files are simply absent and every session has to be re-configured by hand.
//! This module is that narrow allowlist: it copies ONLY files matching
//! operator-declared patterns (default `.env*`, see
//! [`crate::core::trusty_tools_config::DEFAULT_UNTRACKED_SYNC_PATTERNS`]) —
//! never an unrestricted "copy all untracked files" — and never fails the
//! spawn it runs inside.
//! What: [`sync_untracked_files`] is the single entry point: for each
//! pattern, matches files at the SOURCE checkout root (or one explicit
//! subdirectory when a pattern contains `/`), copies matches into the
//! destination worktree at the same relative path, and appends each copied
//! path to the worktree's real `info/exclude` file (resolved via `git
//! rev-parse --git-path`, which correctly follows a linked worktree's gitlink
//! back to the shared common git dir) so the secret is gitignore-safe
//! regardless of the repo's own tracked `.gitignore`. Every step is
//! best-effort: a missing source root, an unreadable file, or an oversized file
//! is `warn!`-logged and skipped — this function NEVER returns an error and
//! NEVER panics, matching the "sync failure must not fail the spawn" contract
//! from [`try_inproject_spawn`](crate::daemon::managed_routes::inproject::try_inproject_spawn)'s call site.
//!
//! 🔴 Best-effort applies to COPYING, never to the exclusion guarantee (#4733).
//! Until #4733 the exclude-append ran AFTER the copy and a failure was only a
//! `warn!`, so a `git rev-parse` that merely declined to answer — a stale
//! worktree gitlink, `detected dubious ownership`, an unreadable `.git` — left
//! the operator's `.env` sitting unregistered in a live worktree where a later
//! `git add -A && git commit` stages it into history. The order is now inverted:
//! register first, ask `git check-ignore` (git's own authority, mirroring
//! `crate::core::session_launch::native_mcp::is_env_local_actually_ignored`)
//! whether each path is genuinely ignored, and copy only what it confirms. A
//! destination with no repository at all has no history to leak into and is
//! copied to freely; anything in between refuses.
//! Test: `tests` submodule — pattern matching, the size cap, the path-escape
//! guard, the `node_modules/`-is-a-directory-so-never-matches case, the
//! `.git/info/exclude` append for a real linked worktree, and the #4733
//! broken-repo / verification-failure refusals.
use fs;
use ;
use ;
/// Per-file size ceiling for untracked-sync copies.
///
/// Why: the allowlist is meant for small config/secret files; without a cap
/// a mis-declared pattern (or a `.env.*` matching an unexpectedly large file)
/// could copy something enormous into every session worktree.
/// What: 5 MiB. Files larger than this are skipped with a `warn!`.
/// Test: `tests::oversized_file_is_skipped`.
pub const MAX_SYNC_FILE_BYTES: u64 = 5 * 1024 * 1024;
/// Sync allowlisted untracked/secret files from `source` into `dest_worktree`.
///
/// Why: the single seam that turns a resolved pattern allowlist into actual
/// copied files, called once per in-project spawn right after the session
/// worktree is created (`lifecycle::reserve_inproject_worktree`).
///
/// What: resolves candidate files via [`collect_matches`] for every pattern
/// (top-level of `source`, or one explicit subdirectory when the pattern
/// contains `/`), drops anything over [`MAX_SYNC_FILE_BYTES`], and then — before
/// writing a single byte into the worktree — settles the exclusion guarantee:
///
/// 1. [`resolve_git_exclude`] classifies the destination in three states.
/// [`ExcludeTarget::Unknown`] (git failed, or the destination is a repository
/// git could not read) copies NOTHING; [`ExcludeTarget::NoRepo`] has no
/// history to leak into, so it copies with no registration.
/// 2. For a real repository, every candidate path is appended to the worktree's
/// real `info/exclude` via [`append_paths_to_exclude`] FIRST — registration
/// is what MAKES a path ignored.
/// 3. Each path is then re-verified with [`is_path_git_ignored`] — `git
/// check-ignore`, git's own authority, which is what PROVES it, and which
/// also answers "not ignored" for a path the repo already TRACKS. Only
/// confirmed paths are copied, so a failure in step 2 costs at most the
/// paths the repo's own ignore rules did not already cover.
///
/// Duplicate matches across overlapping patterns are copied once. Never panics,
/// never returns an error — every failure is a `warn!` (#4733: refusing to copy
/// is the failure mode, never copying unregistered).
/// Test: `tests::syncs_matching_files_only`, `tests::disabled_copies_nothing`
/// (caller-side gate — this function itself has no `enabled` concept, the
/// caller skips calling it entirely when disabled),
/// `tests::missing_source_file_is_non_fatal`,
/// `tests::broken_repo_copies_nothing`,
/// `tests::tracked_secret_is_not_overwritten_in_worktree`.
/// Whether `path` is a statable file at or under [`MAX_SYNC_FILE_BYTES`].
///
/// Why: the size cap has to run before the exclude registration (#4733) so a
/// file that will never be copied does not earn an exclude entry.
/// What: `false` for an unstatable path or one over the cap, each `warn!`-logged.
/// Test: `tests::oversized_file_is_skipped`.
/// Resolve the candidate `(absolute_path, relative_path)` matches for one
/// allowlist pattern.
///
/// Why: a pattern with no `/` matches top-level files only (the `.env*`
/// case); a pattern containing `/` names an explicit subdirectory, which is
/// scanned non-recursively at that one level. Splitting the two cases here
/// keeps [`sync_untracked_files`] a flat copy loop.
/// What: returns `(source-relative absolute path, relative path string)`
/// pairs for every regular file directly inside the resolved directory whose
/// filename matches the leaf pattern via [`glob_match`]. Returns an empty
/// vec (never an error) when the resolved directory is unreadable/absent, or
/// when the pattern's directory component escapes `source` (see
/// [`is_unsafe_dir_component`]).
/// Test: `tests::top_level_pattern_matches_only_root_files`,
/// `tests::subdirectory_pattern_matches_one_level`,
/// `tests::path_escape_pattern_matches_nothing`.
/// Whether a pattern's directory component would escape `source`.
///
/// Why: [`collect_matches`] must refuse a pattern like `"../secrets/.env"` or
/// `"/etc/.env"` BEFORE ever joining it onto `source` and reading a
/// directory outside the checkout — the allowlist-only / no-escape security
/// guard (#2196).
/// What: `true` when `dir_part` is absolute (starts with `/`) or any `/`-
/// separated segment is `".."` or empty (`"a//b"`, a leading/trailing `/`).
/// Test: `tests::path_escape_pattern_matches_nothing`.
/// Match `name` against a simple glob `pattern` (literal characters plus `*`
/// wildcards only — no `?`, no `**`, no character classes).
///
/// Why: the allowlist's real-world patterns are exact names (`.env`), simple
/// prefixes (`.env.*`), or simple suffixes (`*.env`); a hand-rolled matcher
/// avoids pulling in a glob crate for a handful of trivial shapes while still
/// handling an arbitrary number/position of `*` correctly. `pub` (not
/// `pub(crate)`) since #7122: `pm_guard_bash::secret_file_copy`'s `cp`/`mv`
/// denylist reuses this exact matcher from the `tm` binary target, which
/// depends on this library crate the same way any external consumer would —
/// `pub(crate)` here would not reach across that crate boundary. One matcher
/// serves both an ALLOWLIST (this module's sanctioned `.env*` sync) and a
/// DENYLIST (the guard's raw-`cp`/`mv` refusal); the two never share a
/// pattern LIST — see that module's doc for why reusing this list would be
/// wrong — only the matching logic.
/// What: `true` iff `pattern`, with each `*` treated as "zero or more
/// characters", matches `name` in full. Case-sensitive (filesystem names on
/// the platforms this runs on are case-sensitive or case-preserving).
/// Test: `tests::glob_match_exact`, `tests::glob_match_prefix`,
/// `tests::glob_match_suffix`, `tests::glob_match_no_match`.
/// Recursive worker for [`glob_match`].
///
/// Why: isolated so the wildcard branch's backtracking search reads as a
/// one-liner over the two slices.
/// What: standard `*`-only glob matching: an empty pattern matches only an
/// empty remainder; a literal char must match the next char exactly; a `*`
/// tries consuming `0..=n.len()` characters before matching the rest of the
/// pattern against the rest of the name.
/// Test: covered transitively via [`glob_match`]'s tests.
/// What the destination worktree's git exclude file resolved to.
///
/// Why: #4733 — "there is no repository here" and "git could not be asked" are
/// different facts with opposite safe answers. A destination with no repository
/// has no history for a secret to leak into, so copying is fine; a destination
/// that IS (or may be) a repository whose exclude file could not be resolved
/// must be left alone entirely.
/// What: [`Registered`](Self::Registered) carries the real `info/exclude` path
/// (shared common dir for a linked worktree); [`NoRepo`](Self::NoRepo) is a
/// corroborated absence; [`Unknown`](Self::Unknown) carries the reason no
/// conclusion was available.
/// Test: `tests::broken_repo_copies_nothing`,
/// `tests::plain_directory_destination_still_copies`.
/// The prefix of the only git stderr CONSISTENT with "there is genuinely no
/// repository here".
///
/// Why: the opening parenthesis is load-bearing. Git emits
/// `fatal: not a git repository: (null)` for a STALE WORKTREE POINTER, so the
/// shorter phrase `not a git repository` matches a broken repo and a
/// genuinely-absent one alike — and the broken repo is the one with real history
/// a secret can be committed into.
///
/// Consistent with, NOT proof of: git emits the same text for an unreadable
/// `.git`, where the repository is real. [`classify_rev_parse_failure`]
/// corroborates it with a filesystem witness first.
///
/// What: covers both wordings of git's upward-discovery failure —
/// `(or any of the parent directories)` and `(or any parent up to mount point
/// <dir>)` — and neither colon form (`: (null)`, `: '<GIT_DIR>'`). Any other
/// wording falls through to [`ExcludeTarget::Unknown`] — the fail-closed
/// direction. Mirrors `trusty-agents-common`'s `vcs_claim::NO_REPO_STDERR_PREFIX`
/// (#4448/#4727/#9495); #4735 extracts the shared probe both will call.
/// Test: `tests::broken_repo_copies_nothing`,
/// `tests::classify_rev_parse_failure_accepts_the_mount_boundary_wording`.
// #9495: match the shared prefix; the full clause missed git's mount-boundary wording.
const NO_REPO_STDERR_PREFIX: &str = "not a git repository (or any ";
/// Resolve the destination's real `info/exclude` path, in three states (#4733).
///
/// Why: this is the gate the whole exclusion guarantee turns on, so it must
/// answer WHY a `rev-parse` failed rather than collapsing every failure into
/// "just warn and carry on copying".
///
/// It asks git rather than joining `<dest_worktree>/.git/info/exclude` because a
/// linked worktree's `.git` is a FILE (a gitlink to
/// `<common-git-dir>/worktrees/<name>`) while `info/exclude` is repo-wide and
/// lives in the SHARED common git dir — the naive join would try to create a
/// directory inside that gitlink file and fail. This mirrors how
/// [`super::ensure_worktrees_gitignored`] reaches the analogous file for the
/// base clone.
///
/// 🔴 THE EXIT CODE ALONE IS NOT A CLASSIFIER. Git exits 128 for "not a
/// repository" AND for `detected dubious ownership`, a stale worktree gitlink,
/// a broken `repositoryformatversion`, or any failing `git` shim on `PATH`.
///
/// What: runs `git -C <dest_worktree> rev-parse --git-path info/exclude`. A
/// spawn failure or an empty stdout is [`ExcludeTarget::Unknown`]; a non-zero
/// exit is delegated to [`classify_rev_parse_failure`]; success resolves a
/// relative result (plain repo) against `dest_worktree` and uses an absolute one
/// (linked worktree, pointing at the shared common dir) as-is.
/// Test: `tests::copied_files_are_added_to_shared_worktree_exclude`,
/// `tests::broken_repo_copies_nothing`,
/// `tests::plain_directory_destination_still_copies`,
/// `tests::missing_git_binary_copies_nothing`.
/// [`resolve_git_exclude`] with an injectable git program name.
///
/// Why: the spawn-failure arm (no `git` on `PATH`) is a real, security-relevant
/// branch — it must refuse to copy — and the only alternative for reaching it is
/// mutating `PATH`, which is process-global and racy under a parallel test
/// runner. A program-name parameter makes it reachable hermetically.
/// What: identical to [`resolve_git_exclude`]; `git_bin` names the program.
/// Test: `tests::missing_git_binary_copies_nothing`.
/// Why a failed `rev-parse` failed — the gate copying turns on.
///
/// Why: git's "no repository" message is not proof there is no repository. It
/// emits a [`NO_REPO_STDERR_PREFIX`] wording whenever discovery never got far
/// enough to conclude otherwise — an unreadable `.git`, an unreadable
/// `.git/HEAD`, `GIT_CEILING_DIRECTORIES`, or a filesystem boundary (#9495)
/// stopping the upward walk. In every one of those the
/// repository, and the history a committed secret would land in, are real.
/// `symlink_metadata` on an ancestor `.git` needs only the parent's search bit,
/// so it is a witness git does not use; a disagreement between the two IS the
/// "cannot be asked" state.
/// What: [`ExcludeTarget::NoRepo`] only when the message matches AND no ancestor
/// carries a `.git` entry; [`ExcludeTarget::Unknown`] otherwise.
///
/// 🔴 The `.canonicalize()` is load-bearing, not tidiness. `Path::ancestors`
/// walks the path LEXICALLY, so an uncanonicalised relative path or one reached
/// through a symlink yields a chain that is not the real one — the project's
/// `.git` is never visited and the permissive answer wins, putting a secret in a
/// live worktree. A canonicalisation failure is itself
/// [`ExcludeTarget::Unknown`].
/// Test: `tests::classify_rev_parse_failure_corroborates_the_no_repo_message`,
/// `tests::classify_rev_parse_failure_canonicalises_before_walking_ancestors`,
/// `tests::classify_rev_parse_failure_refuses_the_mount_boundary_wording_under_a_real_repo`,
/// `tests::broken_repo_copies_nothing`.
/// Ask git itself whether `relative_path` is ignored inside `dest_worktree`.
///
/// Why: appending to `info/exclude` succeeds unconditionally and proves nothing
/// — a `.gitignore` negation elsewhere can override it, and a path the repo
/// already TRACKS is never ignored no matter what the exclude file says (git
/// deliberately reports tracked paths as not-ignored here). `git check-ignore`
/// is git's OWN authority on whether `git add -A` would stage the path, which is
/// exactly the question. Mirrors
/// [`crate::core::session_launch::native_mcp::is_env_local_actually_ignored`]
/// rather than inventing a second mechanism (#4733).
/// What: runs `git -C <dest_worktree> check-ignore -q -- <relative_path>` via
/// `.output()` (NOT `.status()`) so nothing the child writes can reach this
/// process's stdout, which must stay clean for MCP JSON-RPC framing. Any
/// non-zero exit — "not ignored", not a repo, no `git` binary — is `false`.
/// Never panics.
/// Test: `tests::copied_files_are_added_to_shared_worktree_exclude`,
/// `tests::tracked_secret_is_not_overwritten_in_worktree`.
/// Append `relative_paths` to an already-resolved `info/exclude` file.
///
/// Why: split from [`append_to_git_exclude`] so the sync flow resolves the
/// exclude path exactly once (#4733 moved that resolution ahead of the copy).
/// What: creates the file's parent dir and appends each path not already
/// present, preserving the read-then-append idempotency
/// [`super::ensure_worktrees_gitignored`] uses.
/// Test: `tests::append_to_git_exclude_is_idempotent`.