@mnemonik/shared 7.97.1 → 7.98.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. package/dist/ambientPointer.d.ts.map +1 -1
  2. package/dist/ambientPointer.js +2 -1
  3. package/dist/ambientPointer.js.map +1 -1
  4. package/dist/apiOrigin.d.ts +3 -0
  5. package/dist/apiOrigin.d.ts.map +1 -0
  6. package/dist/apiOrigin.js +5 -0
  7. package/dist/apiOrigin.js.map +1 -0
  8. package/dist/ast/astChunker.d.ts +12 -12
  9. package/dist/ast/astChunker.js +32 -32
  10. package/dist/ast/grammars.d.ts +9 -9
  11. package/dist/ast/grammars.js +15 -15
  12. package/dist/codeScanner.d.ts +56 -43
  13. package/dist/codeScanner.d.ts.map +1 -1
  14. package/dist/codeScanner.js +262 -143
  15. package/dist/codeScanner.js.map +1 -1
  16. package/dist/contextBudget.js +2 -2
  17. package/dist/contextBudget.js.map +1 -1
  18. package/dist/gitEditDetector.d.ts +6 -6
  19. package/dist/gitEditDetector.js +7 -7
  20. package/dist/hookRuntime.d.ts +35 -2
  21. package/dist/hookRuntime.d.ts.map +1 -1
  22. package/dist/hookRuntime.js +127 -33
  23. package/dist/hookRuntime.js.map +1 -1
  24. package/dist/hookTimeouts.d.ts +6 -6
  25. package/dist/hookTimeouts.js +6 -6
  26. package/dist/hostAdapter.d.ts +135 -0
  27. package/dist/hostAdapter.d.ts.map +1 -0
  28. package/dist/hostAdapter.js +323 -0
  29. package/dist/hostAdapter.js.map +1 -0
  30. package/dist/hostBinary.d.ts +29 -0
  31. package/dist/hostBinary.d.ts.map +1 -0
  32. package/dist/hostBinary.js +179 -0
  33. package/dist/hostBinary.js.map +1 -0
  34. package/dist/index.d.ts +11 -1
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +10 -1
  37. package/dist/index.js.map +1 -1
  38. package/dist/instructions.d.ts +26 -26
  39. package/dist/instructions.js +26 -26
  40. package/dist/jitDeliveryAck.d.ts.map +1 -1
  41. package/dist/jitDeliveryAck.js +2 -0
  42. package/dist/jitDeliveryAck.js.map +1 -1
  43. package/dist/projectIdentityFile.d.ts +25 -0
  44. package/dist/projectIdentityFile.d.ts.map +1 -0
  45. package/dist/projectIdentityFile.js +87 -0
  46. package/dist/projectIdentityFile.js.map +1 -0
  47. package/dist/projectSetupHandoff.d.ts +24 -0
  48. package/dist/projectSetupHandoff.d.ts.map +1 -0
  49. package/dist/projectSetupHandoff.js +249 -0
  50. package/dist/projectSetupHandoff.js.map +1 -0
  51. package/dist/protectedPaths.d.ts +9 -0
  52. package/dist/protectedPaths.d.ts.map +1 -0
  53. package/dist/protectedPaths.js +66 -0
  54. package/dist/protectedPaths.js.map +1 -0
  55. package/dist/readiness.d.ts +143 -0
  56. package/dist/readiness.d.ts.map +1 -0
  57. package/dist/readiness.js +304 -0
  58. package/dist/readiness.js.map +1 -0
  59. package/dist/repositoryFingerprint.d.ts +50 -0
  60. package/dist/repositoryFingerprint.d.ts.map +1 -0
  61. package/dist/repositoryFingerprint.js +160 -0
  62. package/dist/repositoryFingerprint.js.map +1 -0
  63. package/dist/repositoryRoot.d.ts +56 -0
  64. package/dist/repositoryRoot.d.ts.map +1 -0
  65. package/dist/repositoryRoot.js +226 -0
  66. package/dist/repositoryRoot.js.map +1 -0
  67. package/dist/runtimeReader.d.ts +102 -0
  68. package/dist/runtimeReader.d.ts.map +1 -0
  69. package/dist/runtimeReader.js +408 -0
  70. package/dist/runtimeReader.js.map +1 -0
  71. package/dist/runtimeSigners.d.ts +37 -0
  72. package/dist/runtimeSigners.d.ts.map +1 -0
  73. package/dist/runtimeSigners.js +507 -0
  74. package/dist/runtimeSigners.js.map +1 -0
  75. package/dist/scannerSupervisor.d.ts +35 -0
  76. package/dist/scannerSupervisor.d.ts.map +1 -0
  77. package/dist/scannerSupervisor.js +2 -0
  78. package/dist/scannerSupervisor.js.map +1 -0
  79. package/dist/secretPatterns.d.ts +9 -9
  80. package/dist/secretPatterns.d.ts.map +1 -1
  81. package/dist/secretPatterns.js +27 -25
  82. package/dist/secretPatterns.js.map +1 -1
  83. package/dist/testing/hostAdapterConformance.d.ts +17 -0
  84. package/dist/testing/hostAdapterConformance.d.ts.map +1 -0
  85. package/dist/testing/hostAdapterConformance.js +477 -0
  86. package/dist/testing/hostAdapterConformance.js.map +1 -0
  87. package/dist/usageGuide.d.ts +1 -1
  88. package/dist/usageGuide.d.ts.map +1 -1
  89. package/dist/usageGuide.js +4 -4
  90. package/package.json +7 -2
  91. package/queries/bash.tags.scm +1 -1
  92. package/queries/groovy.tags.scm +1 -1
  93. package/scripts/vendor-grammars.ts +13 -13
  94. package/src/ambientPointer.ts +2 -1
  95. package/src/apiOrigin.ts +4 -0
  96. package/src/ast/astChunker.ts +39 -39
  97. package/src/ast/grammars.ts +16 -16
  98. package/src/codeScanner.ts +274 -149
  99. package/src/contextBudget.ts +2 -2
  100. package/src/gitEditDetector.ts +7 -7
  101. package/src/hookRuntime.ts +162 -34
  102. package/src/hookTimeouts.ts +6 -6
  103. package/src/hostAdapter.ts +468 -0
  104. package/src/hostBinary.ts +205 -0
  105. package/src/index.ts +20 -0
  106. package/src/instructions.ts +26 -26
  107. package/src/jitDeliveryAck.ts +2 -0
  108. package/src/projectIdentityFile.ts +100 -0
  109. package/src/projectSetupHandoff.ts +275 -0
  110. package/src/protectedPaths.ts +88 -0
  111. package/src/readiness.ts +516 -0
  112. package/src/repositoryFingerprint.ts +199 -0
  113. package/src/repositoryRoot.ts +261 -0
  114. package/src/runtimeReader.ts +495 -0
  115. package/src/runtimeSigners.ts +577 -0
  116. package/src/scannerSupervisor.ts +26 -0
  117. package/src/secretPatterns.ts +27 -25
  118. package/src/testing/hostAdapterConformance.ts +538 -0
  119. package/src/usageGuide.ts +4 -4
  120. package/test-fixtures/identity/runner.d.mts +12 -0
  121. package/test-fixtures/identity/runner.mjs +282 -0
  122. package/tests/fixtures/repository-fingerprint-v1.golden.json +834 -0
  123. package/tests/projectIdentity.test.ts +56 -0
  124. package/tests/readiness.test.ts +158 -0
  125. package/tests/repositoryFingerprint.test.ts +26 -0
  126. package/tsconfig.test.json +9 -0
  127. package/wasm/bash.tags.scm +1 -1
  128. package/wasm/groovy.tags.scm +1 -1
  129. package/wasm/javascript.tags.scm +2 -2
  130. package/wasm/solidity.tags.scm +1 -1
@@ -2,13 +2,15 @@
2
2
  * Code Scanner - Parse and chunk source files for embedding
3
3
  */
4
4
 
5
- import { readdir, readFile, stat, lstat, realpath } from 'fs/promises';
6
- import { join, relative, extname, sep } from 'path';
5
+ import { readdir, open, lstat, realpath } from 'fs/promises';
6
+ import { constants, type Stats } from 'fs';
7
+ import { join, relative, extname, sep, resolve, dirname } from 'path';
7
8
  import { createHash } from 'crypto';
8
9
  import ignore, { type Ignore } from 'ignore';
9
10
  import { debug as logDebug, info as logInfo, warn as logWarn } from './logger.js';
10
11
  import { withTimeout } from './asyncUtils.js';
11
12
  import { scrubSecrets } from './secretPatterns.js';
13
+ import { isProtectedLocalPath, protectedLocalPaths } from './protectedPaths.js';
12
14
  import { MAX_AST_PARSE_BYTES, chunkWithAst, type AstChunk } from './ast/astChunker.js';
13
15
  import { astArtifactReport, resolveAstLanguage } from './ast/grammars.js';
14
16
 
@@ -20,14 +22,14 @@ const FILE_OP_TIMEOUT_MS = 5000;
20
22
  /**
21
23
  * The one file-size ceiling in Mnemonik. A file at or below this is scanned,
22
24
  * chunked, AND pushed verbatim; a file above it is uniformly outside the
23
- * system — not scanned, not chunked, not citable. "Scanned ⟺ verifiable".
25
+ * system - not scanned, not chunked, not citable. "Scanned ⟺ verifiable".
24
26
  *
25
27
  * This is deliberately a single exported constant rather than a number
26
28
  * repeated per layer. Three ceilings previously existed and had drifted:
27
29
  * the scan ceiling here (10 MB), a 5 MB whole-file push cap in the scanner
28
30
  * daemon, and a 5 MB `content` cap in the server's scanPushSchema. Files
29
31
  * between 5 and 10 MB were therefore indexed as chunks whose source content
30
- * the server never received — chunks that could be retrieved but never
32
+ * the server never received - chunks that could be retrieved but never
31
33
  * verified against, a silent fidelity split. Every consumer now imports this:
32
34
  *
33
35
  * - `CodeScanner.MAX_FILE_SIZE` / `collectAuthorityFilesWithStatus` (this file)
@@ -39,6 +41,9 @@ const FILE_OP_TIMEOUT_MS = 5000;
39
41
  */
40
42
  export const MAX_SCANNED_FILE_BYTES = 10 * 1024 * 1024; // 10MB
41
43
 
44
+ /** Transport ceiling; ordinary chunking may exceed maxChunkSize but never this bound. */
45
+ export const MAX_PUSH_CHUNK_CONTENT_LENGTH = 500_000;
46
+
42
47
  export interface CodeChunk {
43
48
  content: string;
44
49
  filePath: string;
@@ -75,12 +80,14 @@ export interface ScanOptions {
75
80
  * mock.
76
81
  */
77
82
  maxAstParseBytes?: number;
83
+ /** Additional protected paths for isolated homes/tests; defaults can only be extended. */
84
+ protectedPaths?: readonly string[];
78
85
  }
79
86
 
80
87
  /**
81
88
  * Mutable per-walk state threaded through the recursive traversals.
82
89
  * `complete` starts true and is latched false by any caught fs error
83
- * (readdir failure, per-entry lstat/realpath/stat throw) — meaning the
90
+ * (readdir failure, per-entry lstat/realpath/stat throw) - meaning the
84
91
  * result may be missing entries that still exist on disk. Callers that
85
92
  * derive *removals* from a walk must treat an incomplete walk as
86
93
  * untrustworthy; adds/changes are unaffected.
@@ -90,12 +97,12 @@ interface WalkState {
90
97
  }
91
98
 
92
99
  /**
93
- * Directory basenames the scanner never indexes, across every ecosystem —
100
+ * Directory basenames the scanner never indexes, across every ecosystem -
94
101
  * generated output, package-manager/build caches, and vendored deps. SINGLE
95
102
  * SOURCE OF TRUTH: the scanner package's discovery + watch walks import this
96
103
  * (`packages/scanner/src/{discovery,watcher}.ts`) and `src/core/utils.ts`
97
104
  * derives from it, so every ignore surface agrees instead of drifting (the
98
- * pre-2026-07 state had four divergent copies). Add only UNAMBIGUOUS names —
105
+ * pre-2026-07 state had four divergent copies). Add only UNAMBIGUOUS names -
99
106
  * a project's own `.gitignore` / `.mnemonikignore` covers the ambiguous,
100
107
  * project-specific cases (`vendor/`, custom dirs) hierarchically.
101
108
  */
@@ -124,7 +131,7 @@ export const BUILT_IN_IGNORE_DIRS: readonly string[] = [
124
131
  '.mypy_cache',
125
132
  '.pytest_cache',
126
133
  '.ruff_cache',
127
- // Native mobile — iOS / Android / React Native / Flutter (2026-07-11 Pods incident)
134
+ // Native mobile - iOS / Android / React Native / Flutter (2026-07-11 Pods incident)
128
135
  'Pods',
129
136
  'DerivedData',
130
137
  '.gradle',
@@ -148,7 +155,7 @@ export const BUILT_IN_IGNORE_DIRS: readonly string[] = [
148
155
  // an agent's own shell state into the user's searchable memory.
149
156
  //
150
157
  // Scoped to this one directory name deliberately. `.claude/` at large holds
151
- // real project content — `.claude/rules.md` is indexed on purpose
158
+ // real project content - `.claude/rules.md` is indexed on purpose
152
159
  // (tests/CodeScannerIgnorePatterns.test.ts), and only `.claude/worktrees/`
153
160
  // is suppressed, by the generic nested-git-boundary rule.
154
161
  'shell-snapshots',
@@ -159,7 +166,7 @@ export const BUILT_IN_IGNORE_DIRS: readonly string[] = [
159
166
  * anchored fixture rule), combined with BUILT_IN_IGNORE_DIRS to form
160
167
  * DEFAULT_OPTIONS.ignorePatterns. See `shouldIgnore` for match semantics.
161
168
  *
162
- * `/tests/fixtures/*`: leading '/' = segment-anchored — matches tests/fixtures/
169
+ * `/tests/fixtures/*`: leading '/' = segment-anchored - matches tests/fixtures/
163
170
  * at the path root or immediately after a '/', so nested packages/x/tests/
164
171
  * fixtures/ are excluded but integration-tests/fixtures/ is NOT. Fixture
165
172
  * snapshot dirs were being indexed as real source and outranking the genuine
@@ -186,19 +193,19 @@ const BUILT_IN_IGNORE_FILE_PATTERNS: readonly string[] = [
186
193
 
187
194
  /**
188
195
  * Files whose CONTENT is a credential rather than code. Today these are
189
- * excluded only INCIDENTALLY — `.pem`, `.key` and friends carry no
196
+ * excluded only INCIDENTALLY - `.pem`, `.key` and friends carry no
190
197
  * allowlisted extension, so nothing chunks them. That protection evaporates
191
198
  * the moment the extension allowlist widens (as it did for `.sh`/`.lua`), so
192
199
  * the exclusion is made explicit here and becomes a defended invariant:
193
200
  * unlike `ignorePatterns`, this predicate is NOT caller-overridable.
194
201
  *
195
202
  * Expressed as a predicate rather than glob strings because `shouldIgnore`'s
196
- * engine has no negation and matches globs as unanchored substrings — neither
203
+ * engine has no negation and matches globs as unanchored substrings - neither
197
204
  * property can state "every `.env.*` EXCEPT `.env.example`".
198
205
  *
199
206
  * Precision notes (over-exclusion is a fidelity failure in its own right):
200
207
  * - `.env` family: `/^\.env(\..+)?$/` matches `.env`, `.env.local`,
201
- * `.env.production` — and deliberately NOT `.envrc` or `.environment.ts`.
208
+ * `.env.production` - and deliberately NOT `.envrc` or `.environment.ts`.
202
209
  * - `.env.example` is carved out: it is authority-collected on purpose
203
210
  * (AUTHORITY_FILE_MATCHERS) and holds placeholder values, not secrets.
204
211
  * It is the doc-truth env_vars authority; excluding it would blind that
@@ -206,7 +213,7 @@ const BUILT_IN_IGNORE_FILE_PATTERNS: readonly string[] = [
206
213
  * - `credentials` is matched ONLY directly inside a dot-directory
207
214
  * (`.aws/credentials`, `.docker/credentials`). A bare `credentials/` is a
208
215
  * real, common SOURCE directory name (gRPC, auth libraries) and
209
- * `shouldIgnore` cannot tell a file from a directory at its call sites —
216
+ * `shouldIgnore` cannot tell a file from a directory at its call sites -
210
217
  * an unqualified rule would silently delete that subtree from the index.
211
218
  * - `.tfvars` is excluded whole rather than per-basename: the credential file
212
219
  * is conventionally `terraform.tfvars` but the name is free-form, and the
@@ -222,7 +229,7 @@ const SECRET_FILE_BASENAME_PATTERNS: readonly RegExp[] = [
222
229
  /\.(?:pem|key|p12|pfx|keystore|jks|kubeconfig)$/i,
223
230
  // Terraform variable files: `terraform.tfvars` is the conventional home for
224
231
  // provider credentials, and unlike the entries above nothing else was
225
- // keeping it out — it is excluded here rather than merely left off
232
+ // keeping it out - it is excluded here rather than merely left off
226
233
  // `DEFAULT_INCLUDE_EXTENSIONS`, so a future widening of the allowlist cannot
227
234
  // quietly opt a project into embedding its own cloud keys.
228
235
  /\.tfvars(\.json)?$/i,
@@ -260,7 +267,7 @@ const IGNORE_FILE_NAMES = ['.gitignore', '.mnemonikignore'] as const;
260
267
  * Exported because this list IS the product's language coverage: a project
261
268
  * written in something absent from it indexes zero code, and `code_search`
262
269
  * reports that as "no indexed matches" rather than "I do not read this
263
- * language" — invisible to the developer and to us. Coverage is a separate
270
+ * language" - invisible to the developer and to us. Coverage is a separate
264
271
  * dial from parse quality: a language with no structured extractor falls
265
272
  * through to `chunkRaw`, which is what Go, Java and C already get in
266
273
  * production, and crude chunks beat no chunks by an enormous margin.
@@ -268,7 +275,7 @@ const IGNORE_FILE_NAMES = ['.gitignore', '.mnemonikignore'] as const;
268
275
  * Data formats (`.json`, `.yaml`, `.toml`, `.lock`) are deliberately absent:
269
276
  * the ones that carry meaning are already collected verbatim by
270
277
  * `AUTHORITY_FILE_MATCHERS`, and blanket-indexing the extension would pull in
271
- * lockfiles and generated output. `.tfvars` is absent for a different reason —
278
+ * lockfiles and generated output. `.tfvars` is absent for a different reason -
272
279
  * `terraform.tfvars` is a conventional home for provider credentials and
273
280
  * `isSecretFile` now excludes it outright.
274
281
  */
@@ -289,7 +296,7 @@ export const DEFAULT_INCLUDE_EXTENSIONS: readonly string[] = [
289
296
  '.php',
290
297
  '.swift',
291
298
  '.kt',
292
- // Shell and Lua are the working languages of whole real projects —
299
+ // Shell and Lua are the working languages of whole real projects -
293
300
  // deployment tooling, container entrypoints, imapfilter/nginx/redis
294
301
  // configuration. Omitting them meant such a project indexed ZERO code and
295
302
  // code_search could not answer anything about it, while reporting that as
@@ -300,7 +307,7 @@ export const DEFAULT_INCLUDE_EXTENSIONS: readonly string[] = [
300
307
  '.lua',
301
308
  '.md',
302
309
 
303
- // ── Variants of languages already on the list. `.mjs`/`.cjs`/`.mts`/`.cts`/
310
+ // -- Variants of languages already on the list. `.mjs`/`.cjs`/`.mts`/`.cts`/
304
311
  // `.pyi` inherit their language string from a listed extension, so their
305
312
  // absence was oversight rather than policy. The C++ spellings are a choice,
306
313
  // not an inheritance: `.h` stays 'c' (C headers dominate, and reading a C++
@@ -318,7 +325,7 @@ export const DEFAULT_INCLUDE_EXTENSIONS: readonly string[] = [
318
325
  '.cxx',
319
326
  '.kts',
320
327
 
321
- // ── Languages the scanner could not read at all.
328
+ // -- Languages the scanner could not read at all.
322
329
  '.dart',
323
330
  '.m',
324
331
  '.mm',
@@ -347,7 +354,7 @@ export const DEFAULT_INCLUDE_EXTENSIONS: readonly string[] = [
347
354
  '.vue',
348
355
  '.svelte',
349
356
 
350
- // ── Infrastructure and schema DSLs, where real logic lives and where "how
357
+ // -- Infrastructure and schema DSLs, where real logic lives and where "how
351
358
  // is this deployed?" and "what does this table hold?" go unanswered today.
352
359
  '.tf',
353
360
  '.sql',
@@ -360,12 +367,12 @@ export const DEFAULT_INCLUDE_EXTENSIONS: readonly string[] = [
360
367
  ];
361
368
 
362
369
  /**
363
- * Extension → language string, the value carried on every chunk and on the
370
+ * Extension -> language string, the value carried on every chunk and on the
364
371
  * wire (`/scan/push` accepts any non-empty string up to 50 chars).
365
372
  *
366
373
  * A string no grammar claims is correct and expected: the AST layer resolves
367
374
  * such a language to `null` and the heuristic chunker takes over. Keep this in
368
- * sync with `DEFAULT_INCLUDE_EXTENSIONS` — an allowlisted extension that lands
375
+ * sync with `DEFAULT_INCLUDE_EXTENSIONS` - an allowlisted extension that lands
369
376
  * on 'unknown' still gets chunked, but nothing downstream can reason about it.
370
377
  *
371
378
  * Two extensions are genuinely ambiguous and are resolved rather than fudged:
@@ -453,7 +460,7 @@ export function languageForExtension(filePathOrExt: string): string {
453
460
  // `extname` FIRST. A dotfile that carries a real extension ('.eslintrc.js',
454
461
  // '.mocharc.cjs', '.prettierrc.ts') starts with '.' and contains no '/', so
455
462
  // a bare-extension-first reading swallowed the whole name and answered
456
- // 'unknown' — while the same file spelled 'src/.eslintrc.js' answered
463
+ // 'unknown' - while the same file spelled 'src/.eslintrc.js' answered
457
464
  // 'javascript'. Those extensions are allowlisted, so the files are indexed
458
465
  // and reach the server with exactly the root-relative spelling that failed.
459
466
  const fromPath = EXTENSION_LANGUAGES[extname(filePathOrExt).toLowerCase()];
@@ -466,16 +473,16 @@ export function languageForExtension(filePathOrExt: string): string {
466
473
 
467
474
  /**
468
475
  * THE definition of "this path is a SQL migration the schema_columns authority
469
- * collects verbatim". One predicate, referenced by both halves of the deal —
476
+ * collects verbatim". One predicate, referenced by both halves of the deal -
470
477
  * `AUTHORITY_FILE_MATCHERS` (collect it) and `isAuthorityOnlyPath` (therefore
471
- * do not chunk it) — because two hand-written regexes drifted once already and
478
+ * do not chunk it) - because two hand-written regexes drifted once already and
472
479
  * the failure is silent in both directions.
473
480
  *
474
481
  * Root-anchored and CASE-SENSITIVE on purpose: it mirrors the server-side
475
482
  * extractor, which does `listFiles('migrations/')` (LIKE 'migrations/%') then
476
483
  * `endsWith('.sql')`, both case-sensitive. `Migrations/001.sql` (the .NET/EF
477
484
  * Core spelling) and `migrations/002.SQL` are NOT collected, so they must not
478
- * be suppressed from chunking either — that would index them nowhere. Same
485
+ * be suppressed from chunking either - that would index them nowhere. Same
479
486
  * reason a nested `packages/x/migrations/y.sql` is left alone.
480
487
  */
481
488
  const isMigrationSqlAuthorityPath = (posixRelPath: string): boolean =>
@@ -489,7 +496,7 @@ const isMigrationSqlAuthorityPath = (posixRelPath: string): boolean =>
489
496
  * through.
490
497
  *
491
498
  * `migrations/**.sql` is the live case: adding `.sql` to the allowlist without
492
- * this exclusion would dual-collect every migration — once verbatim, once
499
+ * this exclusion would dual-collect every migration - once verbatim, once
493
500
  * chunked and embedded. On this repo alone that is 208 files of append-only
494
501
  * DDL (140 forward, the rest rollback/manual), embedded to answer questions
495
502
  * the authority path already answers exactly.
@@ -504,7 +511,7 @@ const AUTHORITY_ONLY_PATH_PREDICATES: readonly ((posixRelPath: string) => boolea
504
511
 
505
512
  /**
506
513
  * True when `relPath` is collected verbatim as authority content and must not
507
- * additionally be chunked. Accepts OS-native or POSIX separators.
514
+ * also be chunked. Accepts OS-native or POSIX separators.
508
515
  */
509
516
  export function isAuthorityOnlyPath(relPath: string): boolean {
510
517
  if (!relPath) return false;
@@ -518,6 +525,7 @@ const DEFAULT_OPTIONS: Required<ScanOptions> = {
518
525
  ignorePatterns: [...BUILT_IN_IGNORE_DIRS, ...BUILT_IN_IGNORE_FILE_PATTERNS],
519
526
  includeExtensions: [...DEFAULT_INCLUDE_EXTENSIONS],
520
527
  maxAstParseBytes: MAX_AST_PARSE_BYTES,
528
+ protectedPaths: [],
521
529
  };
522
530
 
523
531
  /**
@@ -547,7 +555,7 @@ export const AUTHORITY_FILE_MATCHERS: Array<(relPath: string) => boolean> = [
547
555
  ];
548
556
 
549
557
  /**
550
- * Segment-anchored match for the `tests/fixtures/` ignore pattern above —
558
+ * Segment-anchored match for the `tests/fixtures/` ignore pattern above -
551
559
  * matches at the path root or immediately after a `/`, mirroring exactly
552
560
  * what `shouldIgnore` compiles `'/tests/fixtures/*'` to. Exported so
553
561
  * server-side consumers (e.g. the `/scan/push` route) and query-time
@@ -567,7 +575,7 @@ const BUILT_IN_IGNORE_DIR_SET = new Set<string>(BUILT_IN_IGNORE_DIRS);
567
575
  * (segment-exact) with a project's uploaded .gitignore/.mnemonikignore patterns
568
576
  * (gitignore syntax via the `ignore` package). Built once per request and reused
569
577
  * across all pushed paths (root-relative, forward-slash). This does NOT replace
570
- * the daemon's precise hierarchical walk — it backstops it so a buggy, stale, or
578
+ * the daemon's precise hierarchical walk - it backstops it so a buggy, stale, or
571
579
  * malicious client cannot index a path the project ignores, making .mnemonikignore
572
580
  * a hard guarantee rather than best-effort.
573
581
  */
@@ -599,7 +607,7 @@ export function makeIgnoreMatcher(
599
607
  * being scanned: it carries its own `.git` entry. A `.git` FILE marks a
600
608
  * linked git worktree (wherever the user keeps them) or a submodule
601
609
  * checkout; a `.git` DIRECTORY marks a nested clone. Files under such a
602
- * boundary belong to that repository's own project identity — scanning them
610
+ * boundary belong to that repository's own project identity - scanning them
603
611
  * into the parent project is wrong attribution for any codebase, so the
604
612
  * walkers skip these subtrees. Convention-free: this is git's own marker,
605
613
  * not a host/tool path layout. The scan ROOT is exempt by construction
@@ -620,7 +628,7 @@ export async function isGitBoundary(dirPath: string): Promise<boolean> {
620
628
 
621
629
  /**
622
630
  * One directory's compiled ignore-file rules, keyed by its path relative to
623
- * the scan root (`''` = root). A stack of these, root→leaf, is threaded
631
+ * the scan root (`''` = root). A stack of these, root->leaf, is threaded
624
632
  * through the walkers; `ignoredByStack` tests deepest-first so a nested
625
633
  * `.gitignore` overrides a shallower one (git precedence).
626
634
  */
@@ -649,7 +657,7 @@ let astCapabilityLog: Promise<void> | null = null;
649
657
  const reportedGrammarFallbacks = new Set<string>();
650
658
 
651
659
  /**
652
- * Log which grammars this install ships — ONCE per process, at daemon start,
660
+ * Log which grammars this install ships - ONCE per process, at daemon start,
653
661
  * never per file.
654
662
  *
655
663
  * A missing grammar silently degrades every file of that language to the
@@ -660,8 +668,8 @@ const reportedGrammarFallbacks = new Set<string>();
660
668
  *
661
669
  * Deliberately a `statSync` of the artifacts (`astArtifactReport`) and not a
662
670
  * load of them (`astCapabilityReport`). Loading all 18 to print this line costs
663
- * ~690 ms and ~75 MB of RSS that is never returned — web-tree-sitter exposes no
664
- * `Language.delete` — which is a permanent tax on a daemon watching a pure
671
+ * ~690 ms and ~75 MB of RSS that is never returned - web-tree-sitter exposes no
672
+ * `Language.delete` - which is a permanent tax on a daemon watching a pure
665
673
  * TypeScript repo, paid to pre-answer a question about seventeen languages it
666
674
  * will never see. Grammars load lazily instead, on the first file of a language,
667
675
  * and the two failure modes only a load can detect (`wasm_load_failed`,
@@ -691,23 +699,44 @@ export function logAstCapabilityOnce(): Promise<void> {
691
699
 
692
700
  export class CodeScanner {
693
701
  private options: Required<ScanOptions>;
702
+ private readonly protectedPaths: readonly string[];
703
+ /** Cumulative observations since this scanner started, including repeated walks. */
704
+ readonly skipReasons = { symlink_skipped: 0 };
705
+
706
+ private skipSymlink(stats: Stats): boolean {
707
+ if (!stats.isSymbolicLink()) return false; // Includes Windows junctions.
708
+ this.skipReasons.symlink_skipped++;
709
+ return true;
710
+ }
694
711
 
695
712
  /**
696
713
  * `includeExtensions` folded to lowercase for matching. The gate used to
697
714
  * compare `extname()` verbatim while `detectLanguage` lowercased, so a
698
- * project spelling its files the canonical way — `.R` for R, `.SQL`/`.PS1`
699
- * on Windows, `.C`/`.H` in older C trees — indexed zero of them and nothing
715
+ * project spelling its files the canonical way - `.R` for R, `.SQL`/`.PS1`
716
+ * on Windows, `.C`/`.H` in older C trees - indexed zero of them and nothing
700
717
  * said why.
701
718
  */
702
719
  private readonly includeExtensionSet: ReadonlySet<string>;
703
720
 
704
721
  constructor(options: ScanOptions = {}) {
705
722
  this.options = { ...DEFAULT_OPTIONS, ...options };
723
+ this.protectedPaths = [
724
+ ...new Set([...protectedLocalPaths(), ...(options.protectedPaths ?? [])]),
725
+ ];
706
726
  this.includeExtensionSet = new Set(
707
727
  this.options.includeExtensions.map((ext) => ext.toLowerCase())
708
728
  );
709
729
  }
710
730
 
731
+ private isProtected(path: string): boolean {
732
+ return isProtectedLocalPath(path, this.protectedPaths);
733
+ }
734
+
735
+ private async isProtectedRead(path: string): Promise<boolean> {
736
+ if (this.isProtected(path)) return true;
737
+ return isProtectedLocalPath(await realpath(path), this.protectedPaths);
738
+ }
739
+
711
740
  /**
712
741
  * The chunkable-file gate, shared by every walker and by the explicit
713
742
  * file-list path so all three agree on what exists. `relPath` is the path
@@ -724,7 +753,7 @@ export class CodeScanner {
724
753
 
725
754
  /**
726
755
  * Read `.gitignore` + `.mnemonikignore` in `absDir` and compile them into one
727
- * ignore layer (the two files are unioned — `.mnemonikignore` is just an
756
+ * ignore layer (the two files are unioned - `.mnemonikignore` is just an
728
757
  * additional gitignore-syntax exclusion list). Returns null when neither file
729
758
  * exists. FAIL-CLOSED: a non-ENOENT read error (EISDIR, EACCES, timeout)
730
759
  * latches `walk.complete = false` so callers deriving *removals* from the walk
@@ -733,19 +762,22 @@ export class CodeScanner {
733
762
  private async loadIgnoreLayer(
734
763
  absDir: string,
735
764
  baseRel: string,
736
- walk: WalkState
765
+ walk: WalkState,
766
+ rootPath: string
737
767
  ): Promise<IgnoreLayer | null> {
768
+ if (this.isProtected(absDir)) return null;
738
769
  const patterns: string[] = [];
739
770
  for (const name of IGNORE_FILE_NAMES) {
740
771
  const filePath = join(absDir, name);
741
772
  try {
742
773
  const content = await withTimeout(
743
- readFile(filePath, 'utf-8'),
774
+ this.readConfined(filePath, rootPath),
744
775
  FILE_OP_TIMEOUT_MS,
745
776
  `readFile timed out: ${filePath}`
746
777
  );
747
778
  for (const line of content.split(/\r?\n/)) patterns.push(line);
748
779
  } catch (err) {
780
+ if ((err as Error).message === 'symlink_skipped') continue;
749
781
  if ((err as NodeJS.ErrnoException)?.code !== 'ENOENT') {
750
782
  walk.complete = false;
751
783
  logDebug('Ignore-file read failed (fail-closed)', { path: filePath, err });
@@ -787,7 +819,7 @@ export class CodeScanner {
787
819
  }
788
820
 
789
821
  /**
790
- * Reconstruct the root→parent ignore-layer stack for a single file, for the
822
+ * Reconstruct the root->parent ignore-layer stack for a single file, for the
791
823
  * incremental `scanFiles` path (which has a flat file list, not a recursive
792
824
  * walk). Layers are memoized per directory in `cache` across a batch.
793
825
  */
@@ -807,7 +839,12 @@ export class CodeScanner {
807
839
  for (const dir of dirs) {
808
840
  let layer = cache.get(dir);
809
841
  if (layer === undefined) {
810
- layer = await this.loadIgnoreLayer(dir === '' ? rootPath : join(rootPath, dir), dir, walk);
842
+ layer = await this.loadIgnoreLayer(
843
+ dir === '' ? rootPath : join(rootPath, dir),
844
+ dir,
845
+ walk,
846
+ rootPath
847
+ );
811
848
  cache.set(dir, layer);
812
849
  }
813
850
  if (layer) stack.push(layer);
@@ -818,7 +855,7 @@ export class CodeScanner {
818
855
  /**
819
856
  * True when any directory strictly between `rootPath` and `filePath`
820
857
  * carries its own `.git` entry (see isGitBoundary). Paths that don't
821
- * resolve under `rootPath` are not judged here (return false) — the
858
+ * resolve under `rootPath` are not judged here (return false) - the
822
859
  * server's path validation handles escapes.
823
860
  */
824
861
  private async insideNestedGitBoundary(filePath: string, rootPath: string): Promise<boolean> {
@@ -842,17 +879,89 @@ export class CodeScanner {
842
879
  */
843
880
  private static readonly MAX_DEPTH = 10;
844
881
 
845
- /**
846
- * Symlink confinement shared by every walker (traverseDirectory,
847
- * traversePaths, collectAuthorityFiles): true when the symlink at
848
- * `fullPath` resolves inside `rootPath` and may be followed, false when
849
- * it escapes the project root. A dangling link (realpath ENOENT) throws
850
- * to the caller's per-entry handler.
851
- */
852
- private async confineSymlink(fullPath: string, rootPath: string): Promise<boolean> {
853
- const resolved = await realpath(fullPath);
854
- const resolvedRoot = await realpath(rootPath);
855
- return resolved.startsWith(resolvedRoot + '/') || resolved === resolvedRoot;
882
+ private canonicalRoots = new Map<string, string>();
883
+ private async canonicalRoot(root: string): Promise<string> {
884
+ let canonical = this.canonicalRoots.get(root);
885
+ if (!canonical) {
886
+ canonical = await realpath(root);
887
+ this.canonicalRoots.set(root, canonical);
888
+ }
889
+ return canonical;
890
+ }
891
+
892
+ static containsCanonical(
893
+ candidate: string,
894
+ root: string,
895
+ paths = { sep, resolve },
896
+ caseInsensitive = process.platform === 'win32'
897
+ ): boolean {
898
+ // realpath expands filesystem aliases (including Windows 8.3 names). Never guess a short name.
899
+ candidate = paths.resolve(candidate);
900
+ root = paths.resolve(root);
901
+ if (caseInsensitive) {
902
+ candidate = candidate.toLowerCase();
903
+ root = root.toLowerCase();
904
+ }
905
+ return (
906
+ candidate === root || candidate.startsWith(root.endsWith(paths.sep) ? root : root + paths.sep)
907
+ );
908
+ }
909
+
910
+ private async confineSymlink(fullPath: string, canonicalRoot: string): Promise<boolean> {
911
+ // Check each component: explicit-file callers must not bypass the walkers'
912
+ // no-symlink policy by supplying a path through an in-root directory link.
913
+ let path = resolve(fullPath);
914
+ if (!CodeScanner.containsCanonical(path, canonicalRoot)) return false;
915
+ for (;;) {
916
+ if (this.skipSymlink(await lstat(path))) throw new Error('symlink_skipped');
917
+ if (CodeScanner.containsCanonical(canonicalRoot, path)) break;
918
+ path = dirname(path);
919
+ }
920
+ return CodeScanner.containsCanonical(await realpath(fullPath), canonicalRoot);
921
+ }
922
+
923
+ async readConfined(fullPath: string, canonicalRoot: string, enumerated?: Stats): Promise<string> {
924
+ const expected = enumerated ?? (await lstat(fullPath));
925
+ if (this.skipSymlink(expected)) throw new Error('symlink_skipped');
926
+ if (!expected.isFile()) throw new Error('scanner_not_regular');
927
+ if (!(await this.confineSymlink(fullPath, canonicalRoot)))
928
+ throw new Error('scanner_containment');
929
+ const canonical = await realpath(fullPath);
930
+ if (
931
+ !CodeScanner.containsCanonical(canonical, canonicalRoot) ||
932
+ (await this.isProtectedRead(canonical))
933
+ )
934
+ throw new Error('scanner_containment');
935
+ // O_NONBLOCK also prevents a substituted FIFO from hanging before fstat.
936
+ const flags =
937
+ constants.O_RDONLY |
938
+ (constants.O_NONBLOCK ?? 0) |
939
+ (process.platform === 'win32' ? 0 : (constants.O_NOFOLLOW ?? 0));
940
+ const handle = await open(fullPath, flags).catch((error: NodeJS.ErrnoException) => {
941
+ if (error.code === 'ELOOP') {
942
+ this.skipReasons.symlink_skipped++;
943
+ throw new Error('symlink_skipped');
944
+ }
945
+ throw error;
946
+ });
947
+ try {
948
+ const actual = await handle.stat();
949
+ if (!actual.isFile()) throw new Error('scanner_not_regular');
950
+ if (actual.dev !== expected.dev || actual.ino !== expected.ino)
951
+ throw new Error('scanner_identity_changed');
952
+ // Windows has no O_NOFOLLOW; lstat is Node's junction/reparse-link check.
953
+ if (process.platform === 'win32' && this.skipSymlink(await lstat(fullPath)))
954
+ throw new Error('symlink_skipped');
955
+ // Linux also proves the opened object is still at the checked path.
956
+ if (
957
+ process.platform === 'linux' &&
958
+ (await realpath(`/proc/self/fd/${handle.fd}`)) !== canonical
959
+ )
960
+ throw new Error('scanner_containment');
961
+ return await handle.readFile('utf-8');
962
+ } finally {
963
+ await handle.close();
964
+ }
856
965
  }
857
966
 
858
967
  /**
@@ -867,7 +976,7 @@ export class CodeScanner {
867
976
  /**
868
977
  * Completeness-aware variant of `scanDirectory`. `complete === false`
869
978
  * means at least one fs error was swallowed during the walk, so the
870
- * chunk set may be missing files that still exist on disk — callers
979
+ * chunk set may be missing files that still exist on disk - callers
871
980
  * deriving removals from the result must not trust it as an inventory.
872
981
  * Chunks are secret-scrubbed before returning (same daemon-side
873
982
  * redaction guarantee as `scanFiles`).
@@ -875,6 +984,7 @@ export class CodeScanner {
875
984
  async scanDirectoryWithStatus(
876
985
  rootPath: string
877
986
  ): Promise<{ chunks: CodeChunk[]; complete: boolean }> {
987
+ rootPath = await this.canonicalRoot(rootPath);
878
988
  const chunks: CodeChunk[] = [];
879
989
  const walk: WalkState = { complete: true };
880
990
  await this.traverseDirectory(rootPath, rootPath, chunks, 0, walk);
@@ -885,7 +995,7 @@ export class CodeScanner {
885
995
  * Enumerate scan-eligible relative file paths under `rootPath` without
886
996
  * reading or chunking content. Same walk + ignorePatterns + extension
887
997
  * filter as `scanDirectory`, but bounded to O(file count) directory ops
888
- * — cheap enough to call on every periodic reconcile tick.
998
+ * - cheap enough to call on every periodic reconcile tick.
889
999
  *
890
1000
  * Returns relative paths normalized against `rootPath`, matching the
891
1001
  * shape the server stores in `memories.metadata->>'filePath'`. Use this
@@ -904,12 +1014,13 @@ export class CodeScanner {
904
1014
 
905
1015
  /**
906
1016
  * Completeness-aware variant of `listFiles`. `complete === false` means
907
- * an fs error truncated part of the walk — the returned paths may be
1017
+ * an fs error truncated part of the walk - the returned paths may be
908
1018
  * missing files that still exist on disk. The daemon's reconcile channel
909
1019
  * MUST NOT derive removedFiles from an incomplete inventory (a truncated
910
1020
  * walk otherwise reads as mass deletion and deprecates live memories).
911
1021
  */
912
1022
  async listFilesWithStatus(rootPath: string): Promise<{ paths: string[]; complete: boolean }> {
1023
+ rootPath = await this.canonicalRoot(rootPath);
913
1024
  const paths: string[] = [];
914
1025
  const walk: WalkState = { complete: true };
915
1026
  await this.traversePaths(rootPath, rootPath, paths, 0, walk);
@@ -928,8 +1039,11 @@ export class CodeScanner {
928
1039
  stack: IgnoreStack = []
929
1040
  ): Promise<void> {
930
1041
  if (depth >= CodeScanner.MAX_DEPTH) return;
1042
+ if (this.isProtected(currentPath)) return;
931
1043
  let entries: string[];
932
1044
  try {
1045
+ if (!(await this.confineSymlink(currentPath, rootPath)))
1046
+ throw new Error('scanner_containment');
933
1047
  entries = await withTimeout(
934
1048
  readdir(currentPath),
935
1049
  FILE_OP_TIMEOUT_MS,
@@ -942,28 +1056,25 @@ export class CodeScanner {
942
1056
  }
943
1057
  const dirRel =
944
1058
  currentPath === rootPath ? '' : relative(rootPath, currentPath).split(sep).join('/');
945
- const localLayer = await this.loadIgnoreLayer(currentPath, dirRel, walk);
1059
+ const localLayer = await this.loadIgnoreLayer(currentPath, dirRel, walk, rootPath);
946
1060
  const localStack = localLayer ? [...stack, localLayer] : stack;
947
1061
  for (const entry of entries) {
948
1062
  const fullPath = join(currentPath, entry);
949
1063
  const relativePath = relative(rootPath, fullPath);
1064
+ if (this.isProtected(fullPath)) continue;
950
1065
  if (this.shouldIgnore(relativePath)) continue;
951
1066
 
952
1067
  // Per-entry isolation: a single bad entry (dangling symlink, raced
953
1068
  // deletion, permission denial, fs timeout) must not abort the rest of
954
- // the directory — but the inventory is no longer removal-trustworthy.
1069
+ // the directory - but the inventory is no longer removal-trustworthy.
955
1070
  try {
956
1071
  const lstats = await withTimeout(
957
1072
  lstat(fullPath),
958
1073
  FILE_OP_TIMEOUT_MS,
959
1074
  `lstat timed out: ${fullPath}`
960
1075
  );
961
- if (lstats.isSymbolicLink() && !(await this.confineSymlink(fullPath, rootPath))) {
962
- continue;
963
- }
964
- const stats = lstats.isSymbolicLink()
965
- ? await withTimeout(stat(fullPath), FILE_OP_TIMEOUT_MS, `stat timed out: ${fullPath}`)
966
- : lstats;
1076
+ if (this.skipSymlink(lstats)) continue;
1077
+ const stats = lstats;
967
1078
 
968
1079
  if (stats.isDirectory()) {
969
1080
  if (this.ignoredByStack(relativePath, true, localStack)) continue; // .gitignore/.mnemonikignore
@@ -987,12 +1098,15 @@ export class CodeScanner {
987
1098
  * Pass rootPath to compute proper relative file paths in chunk metadata.
988
1099
  */
989
1100
  async scanFiles(filePaths: string[], rootPath: string): Promise<CodeChunk[]> {
1101
+ rootPath = await this.canonicalRoot(rootPath);
990
1102
  const chunks: CodeChunk[] = [];
991
1103
  const ignoreCache = new Map<string, IgnoreLayer | null>();
992
1104
  const walk: WalkState = { complete: true };
993
1105
 
994
1106
  for (const filePath of filePaths) {
995
1107
  try {
1108
+ if (!(await this.confineSymlink(filePath, rootPath))) continue;
1109
+ if (this.isProtected(filePath) || (rootPath && this.isProtected(rootPath))) continue;
996
1110
  const fileRel = rootPath ? relative(rootPath, filePath).split(sep).join('/') : filePath;
997
1111
  if (this.shouldIgnore(fileRel)) {
998
1112
  continue;
@@ -1007,7 +1121,7 @@ export class CodeScanner {
1007
1121
  }
1008
1122
  }
1009
1123
  // Explicit file lists arrive from watcher events, which can race a
1010
- // worktree/nested-clone appearing — apply the same nested-git-boundary
1124
+ // worktree/nested-clone appearing - apply the same nested-git-boundary
1011
1125
  // rule the walks use (an ancestor between root and the file carrying
1012
1126
  // its own .git means the file belongs to another repository).
1013
1127
  if (rootPath && (await this.insideNestedGitBoundary(filePath, rootPath))) {
@@ -1037,14 +1151,26 @@ export class CodeScanner {
1037
1151
  * honor the same redaction guarantee.
1038
1152
  */
1039
1153
  private scrubChunks(chunks: CodeChunk[]): CodeChunk[] {
1040
- return chunks.map((chunk) => {
1154
+ return chunks.flatMap((chunk) => {
1041
1155
  const scrubbed = scrubSecrets(chunk.content);
1042
- if (scrubbed === chunk.content) return chunk;
1043
- return {
1044
- ...chunk,
1045
- content: scrubbed,
1046
- contentHash: this.hash(scrubbed),
1047
- };
1156
+ if (scrubbed.length <= MAX_PUSH_CHUNK_CONTENT_LENGTH) {
1157
+ return scrubbed === chunk.content
1158
+ ? [chunk]
1159
+ : [{ ...chunk, content: scrubbed, contentHash: this.hash(scrubbed) }];
1160
+ }
1161
+ const pieces: CodeChunk[] = [];
1162
+ let startLine = chunk.startLine;
1163
+ for (let offset = 0; offset < scrubbed.length;) {
1164
+ let end = Math.min(offset + MAX_PUSH_CHUNK_CONTENT_LENGTH, scrubbed.length);
1165
+ // Keep a UTF-16 surrogate pair together at a transport boundary.
1166
+ if (end < scrubbed.length && /[\uD800-\uDBFF]/.test(scrubbed.charAt(end - 1))) end--;
1167
+ const content = scrubbed.slice(offset, end);
1168
+ const endLine = startLine + (content.match(/\n/g)?.length ?? 0);
1169
+ pieces.push({ ...chunk, content, startLine, endLine, contentHash: this.hash(content) });
1170
+ startLine = endLine;
1171
+ offset = end;
1172
+ }
1173
+ return pieces;
1048
1174
  });
1049
1175
  }
1050
1176
 
@@ -1065,10 +1191,13 @@ export class CodeScanner {
1065
1191
  logDebug('Max directory depth reached, skipping', { path: currentPath, depth });
1066
1192
  return;
1067
1193
  }
1194
+ if (this.isProtected(currentPath)) return;
1068
1195
 
1069
1196
  // Wrap readdir with timeout to prevent hanging
1070
1197
  let entries: string[];
1071
1198
  try {
1199
+ if (!(await this.confineSymlink(currentPath, rootPath)))
1200
+ throw new Error('scanner_containment');
1072
1201
  entries = await withTimeout(
1073
1202
  readdir(currentPath),
1074
1203
  FILE_OP_TIMEOUT_MS,
@@ -1083,12 +1212,13 @@ export class CodeScanner {
1083
1212
  // Layer this directory's .gitignore/.mnemonikignore onto the inherited stack.
1084
1213
  const dirRel =
1085
1214
  currentPath === rootPath ? '' : relative(rootPath, currentPath).split(sep).join('/');
1086
- const localLayer = await this.loadIgnoreLayer(currentPath, dirRel, walk);
1215
+ const localLayer = await this.loadIgnoreLayer(currentPath, dirRel, walk, rootPath);
1087
1216
  const localStack = localLayer ? [...stack, localLayer] : stack;
1088
1217
 
1089
1218
  for (const entry of entries) {
1090
1219
  const fullPath = join(currentPath, entry);
1091
1220
  const relativePath = relative(rootPath, fullPath);
1221
+ if (this.isProtected(fullPath)) continue;
1092
1222
 
1093
1223
  // Check ignore patterns
1094
1224
  if (this.shouldIgnore(relativePath)) {
@@ -1097,7 +1227,7 @@ export class CodeScanner {
1097
1227
 
1098
1228
  // Per-entry isolation: a single bad entry (dangling symlink, raced
1099
1229
  // deletion, permission denial, fs timeout) must not abort the rest of
1100
- // the directory — but the scan is no longer removal-trustworthy.
1230
+ // the directory - but the scan is no longer removal-trustworthy.
1101
1231
  try {
1102
1232
  const lstats = await withTimeout(
1103
1233
  lstat(fullPath),
@@ -1105,14 +1235,8 @@ export class CodeScanner {
1105
1235
  `lstat timed out: ${fullPath}`
1106
1236
  );
1107
1237
 
1108
- if (lstats.isSymbolicLink() && !(await this.confineSymlink(fullPath, rootPath))) {
1109
- logDebug('Skipping symlink escaping project root', { fullPath, rootPath });
1110
- continue;
1111
- }
1112
-
1113
- const stats = lstats.isSymbolicLink()
1114
- ? await withTimeout(stat(fullPath), FILE_OP_TIMEOUT_MS, `stat timed out: ${fullPath}`)
1115
- : lstats;
1238
+ if (this.skipSymlink(lstats)) continue;
1239
+ const stats = lstats;
1116
1240
 
1117
1241
  if (stats.isDirectory()) {
1118
1242
  if (this.ignoredByStack(relativePath, true, localStack)) continue; // .gitignore/.mnemonikignore
@@ -1121,7 +1245,7 @@ export class CodeScanner {
1121
1245
  } else if (stats.isFile()) {
1122
1246
  if (this.ignoredByStack(relativePath, false, localStack)) continue;
1123
1247
  if (this.isChunkable(fullPath, relativePath)) {
1124
- const fileChunks = await this.parseFile(fullPath, rootPath);
1248
+ const fileChunks = await this.parseFile(fullPath, rootPath, stats);
1125
1249
  chunks.push(...fileChunks);
1126
1250
  }
1127
1251
  }
@@ -1142,7 +1266,7 @@ export class CodeScanner {
1142
1266
  */
1143
1267
  private shouldIgnore(path: string): boolean {
1144
1268
  // Credential-bearing file kinds first, and outside the overridable
1145
- // `ignorePatterns` list — a caller-supplied pattern set must not be able
1269
+ // `ignorePatterns` list - a caller-supplied pattern set must not be able
1146
1270
  // to opt a project back into indexing its own private keys.
1147
1271
  if (isSecretFile(path)) return true;
1148
1272
  const segments = path.split('/');
@@ -1151,10 +1275,10 @@ export class CodeScanner {
1151
1275
  // A leading '/' opts a glob pattern into SEGMENT-ANCHORED matching:
1152
1276
  // the pattern body must start at the beginning of the path or right
1153
1277
  // after a '/'. Without it, glob patterns keep their historical
1154
- // unanchored substring semantics — anchoring is opt-in per pattern so
1278
+ // unanchored substring semantics - anchoring is opt-in per pattern so
1155
1279
  // this change cannot alter any existing pattern's behavior.
1156
1280
  // (Motivating bug: unanchored 'tests/fixtures/*' also matched
1157
- // 'integration-tests/fixtures/…' in every indexed project.)
1281
+ // 'integration-tests/fixtures/...' in every indexed project.)
1158
1282
  const anchored = pattern.startsWith('/');
1159
1283
  const body = anchored ? pattern.slice(1) : pattern;
1160
1284
  // Escape regex special chars, then replace all * with .*
@@ -1171,21 +1295,23 @@ export class CodeScanner {
1171
1295
 
1172
1296
  /**
1173
1297
  * Parse a file and extract code chunks.
1174
- * Size limit is the shared `MAX_SCANNED_FILE_BYTES` ceiling — the same
1298
+ * Size limit is the shared `MAX_SCANNED_FILE_BYTES` ceiling - the same
1175
1299
  * number the daemon and the server's scanPushSchema enforce, so anything
1176
1300
  * chunked here can always be shipped and stored verbatim.
1177
1301
  */
1178
1302
  private static readonly MAX_FILE_SIZE = MAX_SCANNED_FILE_BYTES;
1179
1303
 
1180
- private async parseFile(filePath: string, rootPath: string): Promise<CodeChunk[]> {
1304
+ private async parseFile(
1305
+ filePath: string,
1306
+ rootPath: string,
1307
+ enumerated?: Stats
1308
+ ): Promise<CodeChunk[]> {
1181
1309
  try {
1182
1310
  // Check file size before reading to avoid memory issues
1183
1311
  // Wrap stat with timeout
1184
- const stats = await withTimeout(
1185
- stat(filePath),
1186
- FILE_OP_TIMEOUT_MS,
1187
- `stat timed out: ${filePath}`
1188
- );
1312
+ const stats =
1313
+ enumerated ??
1314
+ (await withTimeout(lstat(filePath), FILE_OP_TIMEOUT_MS, `stat timed out: ${filePath}`));
1189
1315
  if (stats.size > CodeScanner.MAX_FILE_SIZE) {
1190
1316
  logDebug('Skipping file exceeding size limit', {
1191
1317
  filePath,
@@ -1197,15 +1323,15 @@ export class CodeScanner {
1197
1323
 
1198
1324
  // Wrap readFile with timeout
1199
1325
  let content = await withTimeout(
1200
- readFile(filePath, 'utf-8'),
1326
+ this.readConfined(filePath, rootPath, stats),
1201
1327
  FILE_OP_TIMEOUT_MS,
1202
1328
  `readFile timed out: ${filePath}`
1203
1329
  );
1204
1330
  // NUL sanitation (ingestion boundary, daemon side): Postgres `text`
1205
- // columns reject the literal NUL byte (U+0000) — a single one aborts
1331
+ // columns reject the literal NUL byte (U+0000) - a single one aborts
1206
1332
  // the whole INSERT/UPDATE. Some source files legitimately contain
1207
1333
  // them. Strip before any chunking so every chunk's content, its
1208
- // contentHash, and its embedding all agree — mirrors the server-side
1334
+ // contentHash, and its embedding all agree - mirrors the server-side
1209
1335
  // strip in ProjectManager (kept as defense in depth for content that
1210
1336
  // reaches the server via an older scanner) but doing it here means
1211
1337
  // the daemon never even transmits the NUL bytes.
@@ -1227,7 +1353,7 @@ export class CodeScanner {
1227
1353
  };
1228
1354
 
1229
1355
  // AST first for the languages with a vendored grammar. `null` means "no
1230
- // grammar for this" — the honest majority of the allowlist — and the
1356
+ // grammar for this" - the honest majority of the allowlist - and the
1231
1357
  // heuristic path below takes over with no log, because that is not a
1232
1358
  // degradation.
1233
1359
  const astLanguage = resolveAstLanguage(fileMetadata.extension, language);
@@ -1250,8 +1376,8 @@ export class CodeScanner {
1250
1376
  }
1251
1377
  // `errorNodes` is handed back precisely so this degradation is not
1252
1378
  // silent, and discarding it made it silent. One ERROR node can swallow
1253
- // the rest of a file — `typeof import(...)` in a type argument does
1254
- // exactly that to tree-sitter-typescript — after which no definition is
1379
+ // the rest of a file - `typeof import(...)` in a type argument does
1380
+ // exactly that to tree-sitter-typescript - after which no definition is
1255
1381
  // captured and the file arrives as one unnamed whole-file chunk. Naming
1256
1382
  // the file, the error count and how many definitions survived is what
1257
1383
  // makes that visible in a scan log.
@@ -1259,7 +1385,7 @@ export class CodeScanner {
1259
1385
  // Deliberately NOT a fallback to the heuristic path: measured on the
1260
1386
  // real construct (tests/AgentInjectionDeliveryWiring.test.ts), the
1261
1387
  // heuristic path names nothing either (its name pattern needs a
1262
- // leading `function`/`class`/`const`) AND covers less — it emitted 3
1388
+ // leading `function`/`class`/`const`) AND covers less - it emitted 3
1263
1389
  // chunks starting at line 18 and dropped lines 1-17, because the
1264
1390
  // uncovered-region supplement only fires above 50 lines. Trading
1265
1391
  // total coverage for no gain is worse than one honest blob, so the
@@ -1281,8 +1407,8 @@ export class CodeScanner {
1281
1407
  // file cannot be bounded on line boundaries at all.
1282
1408
  const bounded = this.boundAstChunks(ast.chunks);
1283
1409
  if (bounded) {
1284
- // Zero chunks here means the file has no non-whitespace line — the AST
1285
- // chunker covers every other line by construction — so returning
1410
+ // Zero chunks here means the file has no non-whitespace line - the AST
1411
+ // chunker covers every other line by construction - so returning
1286
1412
  // nothing is the correct answer, not a lost file.
1287
1413
  return bounded.map(
1288
1414
  ({ symbolName, symbolKind, signature, symbolContainer, ...chunk }) => ({
@@ -1300,7 +1426,7 @@ export class CodeScanner {
1300
1426
  })
1301
1427
  );
1302
1428
  }
1303
- // One line longer than `maxChunkSize` — a generated `*_pb2.py` carries
1429
+ // One line longer than `maxChunkSize` - a generated `*_pb2.py` carries
1304
1430
  // the whole serialized descriptor on one. Splitting it would have to
1305
1431
  // cut mid-line, and two chunks over one line range collide on the
1306
1432
  // `filePath:startLine-endLine` staleness key, so the whole file goes to
@@ -1318,7 +1444,7 @@ export class CodeScanner {
1318
1444
  } else if (ast.unsupported === 'grammar_unavailable') {
1319
1445
  // Process-global, not per file: `loadGrammar` caches its failures, so
1320
1446
  // every file of this language degrades for the same reason. Warned per
1321
- // file, this printed 1,037 identical lines in one scan of this repo —
1447
+ // file, this printed 1,037 identical lines in one scan of this repo -
1322
1448
  // the log flood `logAstCapabilityOnce` exists to avoid. Once per
1323
1449
  // (reason, grammar); the file-specific reasons below stay per file.
1324
1450
  const key = `${ast.unsupported}:${astLanguage}`;
@@ -1335,7 +1461,7 @@ export class CodeScanner {
1335
1461
  } else {
1336
1462
  // Not a silent no-op: name the file AND the reason, then degrade to the
1337
1463
  // heuristic path. A degraded scan indistinguishable from a healthy one
1338
- // is the defect class this routing exists to avoid — and degrading to
1464
+ // is the defect class this routing exists to avoid - and degrading to
1339
1465
  // zero chunks would delete the file from the index instead.
1340
1466
  logWarn('AST chunking unavailable; falling back to heuristic chunking', {
1341
1467
  filePath: relativePath,
@@ -1405,8 +1531,8 @@ export class CodeScanner {
1405
1531
  * chunk that came from a definition is exactly as big as the definition, and a
1406
1532
  * span that is one enormous line is left whole on purpose. Both used to reach
1407
1533
  * the wire verbatim, where `scanChunkSchema` caps content at 500,000 chars and
1408
- * rejects the WHOLE push if any chunk is over — so one generated file stopped
1409
- * all scanning for that push — and where anything past 8191 tokens is only
1534
+ * rejects the WHOLE push if any chunk is over - so one generated file stopped
1535
+ * all scanning for that push - and where anything past 8191 tokens is only
1410
1536
  * partially embedded.
1411
1537
  *
1412
1538
  * The invariants, all of which the split preserves:
@@ -1414,8 +1540,8 @@ export class CodeScanner {
1414
1540
  * joined by '\n', never a join of disjoint regions and never an elision;
1415
1541
  * - total coverage is unchanged: the pieces tile the original range, in order,
1416
1542
  * with no gap and no overlap;
1417
- * - the definition's identity rides on the piece that carries its signature —
1418
- * the first one — and the continuations are plain `raw` spans, because a
1543
+ * - the definition's identity rides on the piece that carries its signature -
1544
+ * the first one - and the continuations are plain `raw` spans, because a
1419
1545
  * continuation is not the definition and must not claim its name.
1420
1546
  *
1421
1547
  * `null` (the caller degrades the whole file to the heuristic chunker) is
@@ -1624,7 +1750,7 @@ export class CodeScanner {
1624
1750
  continue;
1625
1751
  }
1626
1752
 
1627
- // Regex literal — heuristic: / after operator chars or keywords that precede expressions
1753
+ // Regex literal - heuristic: / after operator chars or keywords that precede expressions
1628
1754
  if (c === '/' && i > 0) {
1629
1755
  // Look back for operator context (skip whitespace)
1630
1756
  let j = i - 1;
@@ -1658,7 +1784,7 @@ export class CodeScanner {
1658
1784
  if (content.charAt(i) === '\\') {
1659
1785
  i++; // skip escaped char
1660
1786
  } else if (content.charAt(i) === '[') {
1661
- // character class — skip to ]
1787
+ // character class - skip to ]
1662
1788
  i++;
1663
1789
  while (i < len && content.charAt(i) !== ']') {
1664
1790
  if (content.charAt(i) === '\\') i++;
@@ -1780,7 +1906,7 @@ export class CodeScanner {
1780
1906
  regex: '(?:export\\s+)?const\\s+\\w+\\s*=\\s*(?:async\\s+)?\\([^)]*\\)\\s*=>\\s*\\{',
1781
1907
  type: 'function',
1782
1908
  },
1783
- // Class methods — matches indented methods with optional modifiers.
1909
+ // Class methods - matches indented methods with optional modifiers.
1784
1910
  // Excludes control flow keywords (if, for, while, switch, catch, return).
1785
1911
  {
1786
1912
  regex:
@@ -1813,13 +1939,9 @@ export class CodeScanner {
1813
1939
  /**
1814
1940
  * Raw chunking with overlap, bounded by character count (not line count).
1815
1941
  *
1816
- * The previous implementation took `floor(maxChunkSize / 80)` lines per
1817
- * chunk on the assumption of ~80 chars/line. Long-line files (minified
1818
- * JS, JSON blobs, generated code) produced chunks many times larger than
1819
- * `maxChunkSize`, which then exceeded OpenAI's 8191-token embedding
1820
- * limit and surfaced as 400s on /scan/push (Sentry MNEMONIK-58).
1821
- *
1822
- * Now: walk lines and accumulate character length; emit when the next
1942
+ * Line-count estimates do not bound long-line files such as minified JS,
1943
+ * JSON blobs and generated code. Walk lines and accumulate character
1944
+ * length; emit when the next
1823
1945
  * line would push the running total past `maxChunkSize`. Single lines
1824
1946
  * longer than `maxChunkSize` are force-split into char-based segments.
1825
1947
  * 10% overlap is carried by character count from the tail of the
@@ -1929,7 +2051,7 @@ export class CodeScanner {
1929
2051
  /**
1930
2052
  * Walk the project for authority manifest/config/CI files and return their
1931
2053
  * verbatim content. Reuses ignorePatterns; matches AUTHORITY_FILE_MATCHERS
1932
- * (not includeExtensions). Content-only — no chunking, no embeddings.
2054
+ * (not includeExtensions). Content-only - no chunking, no embeddings.
1933
2055
  */
1934
2056
  async collectAuthorityFiles(
1935
2057
  projectRoot: string
@@ -1941,67 +2063,65 @@ export class CodeScanner {
1941
2063
  /**
1942
2064
  * Completeness-aware variant of `collectAuthorityFiles`. `complete ===
1943
2065
  * false` means an fs error hid part of the walk, so authority paths may
1944
- * be missing — the daemon folds this into the same removal gate as
2066
+ * be missing - the daemon folds this into the same removal gate as
1945
2067
  * `listFilesWithStatus` (a partial authority walk would likewise drop
1946
2068
  * inventory entries and read as deletion).
1947
2069
  */
1948
- async collectAuthorityFilesWithStatus(projectRoot: string): Promise<{
2070
+ async collectAuthorityFilesWithStatus(
2071
+ projectRoot: string,
2072
+ onFile?: (file: { path: string; content: string; hash: string }) => Promise<void>
2073
+ ): Promise<{
1949
2074
  files: Array<{ path: string; content: string; hash: string }>;
1950
2075
  complete: boolean;
1951
2076
  }> {
2077
+ projectRoot = await this.canonicalRoot(projectRoot);
1952
2078
  const out: Array<{ path: string; content: string; hash: string }> = [];
1953
2079
  const walkState: WalkState = { complete: true };
1954
2080
 
1955
2081
  // M2: cap walk depth to match the scanner's MAX_DEPTH (10)
1956
2082
  const walk = async (dir: string, depth: number, stack: IgnoreStack = []): Promise<void> => {
1957
2083
  if (depth >= CodeScanner.MAX_DEPTH) return;
2084
+ if (this.isProtected(dir)) return;
1958
2085
  let entries;
1959
2086
  try {
2087
+ if (!(await this.confineSymlink(dir, projectRoot))) throw new Error('scanner_containment');
1960
2088
  entries = await readdir(dir, { withFileTypes: true });
1961
2089
  } catch {
1962
2090
  walkState.complete = false;
1963
2091
  return;
1964
2092
  }
1965
2093
  const dirRel = dir === projectRoot ? '' : relative(projectRoot, dir).split(sep).join('/');
1966
- const localLayer = await this.loadIgnoreLayer(dir, dirRel, walkState);
2094
+ const localLayer = await this.loadIgnoreLayer(dir, dirRel, walkState, projectRoot);
1967
2095
  const localStack = localLayer ? [...stack, localLayer] : stack;
1968
2096
  for (const ent of entries) {
1969
2097
  const full = join(dir, ent.name);
1970
2098
  const rel = relative(projectRoot, full).split(sep).join('/');
2099
+ if (this.isProtected(full)) continue;
1971
2100
  if (this.shouldIgnore(rel)) continue;
1972
2101
 
1973
- // Resolve the entry kind through symlinks, confined to the project
1974
- // root: a symlink named like an authority file (`package.json`,
1975
- // `.env.example`, ...) pointing outside the root would otherwise be
1976
- // read verbatim and exfiltrated to the server. Same predicate as
1977
- // traverseDirectory/traversePaths (confineSymlink). Confined
1978
- // symlinked directories are traversed (Dirent.isDirectory() is
1979
- // false for them, so they were previously silently skipped).
1980
- let isDir = ent.isDirectory();
1981
- let isFile = ent.isFile();
1982
- if (ent.isSymbolicLink()) {
1983
- try {
1984
- if (!(await this.confineSymlink(full, projectRoot))) continue;
1985
- const stats = await stat(full);
1986
- isDir = stats.isDirectory();
1987
- isFile = stats.isFile();
1988
- } catch {
1989
- /* dangling/unreadable link — skip just this entry */
1990
- walkState.complete = false;
1991
- continue;
1992
- }
2102
+ let stats: Stats;
2103
+ try {
2104
+ stats = await lstat(full);
2105
+ } catch {
2106
+ walkState.complete = false;
2107
+ continue;
1993
2108
  }
2109
+ if (this.skipSymlink(stats)) continue;
2110
+ const isDir = stats.isDirectory(),
2111
+ isFile = stats.isFile();
1994
2112
 
1995
2113
  if (this.ignoredByStack(rel, isDir, localStack)) continue; // .gitignore/.mnemonikignore
1996
2114
  if (isDir) {
1997
2115
  if (await isGitBoundary(full)) continue; // another repo's subtree
1998
2116
  await walk(full, depth + 1, localStack);
1999
2117
  } else if (isFile && AUTHORITY_FILE_MATCHERS.some((m) => m(rel))) {
2118
+ let file: { path: string; content: string; hash: string } | undefined;
2000
2119
  try {
2001
- let content = await readFile(full, 'utf-8');
2120
+ if (stats.size > MAX_SCANNED_FILE_BYTES) continue;
2121
+ let content = await this.readConfined(full, projectRoot, stats);
2002
2122
  // Authority files honor the same single ceiling as chunked files
2003
2123
  // (MAX_SCANNED_FILE_BYTES). Manifests are tiny; one this large is
2004
- // anomalous, so say so rather than dropping it silently — a
2124
+ // anomalous, so say so rather than dropping it silently - a
2005
2125
  // missing authority file otherwise looks identical to a project
2006
2126
  // that simply has no manifest.
2007
2127
  if (content.length > MAX_SCANNED_FILE_BYTES) {
@@ -2020,11 +2140,16 @@ export class CodeScanner {
2020
2140
  content = content.replace(/\0/g, '');
2021
2141
  }
2022
2142
  const hash = createHash('sha256').update(content).digest('hex');
2023
- out.push({ path: rel, content, hash });
2143
+ file = { path: rel, content, hash };
2024
2144
  } catch {
2025
- /* unreadable — skip */
2145
+ /* unreadable - skip */
2026
2146
  walkState.complete = false;
2027
2147
  }
2148
+ // The consumer owns backpressure; its abort must leave the walk immediately.
2149
+ if (file) {
2150
+ if (onFile) await onFile(file);
2151
+ else out.push(file);
2152
+ }
2028
2153
  }
2029
2154
  }
2030
2155
  };