@cspeach/cli 1.1.23 → 1.1.25

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 (51) hide show
  1. package/dist/agent/loop.js +124 -0
  2. package/dist/classifier/client.js +27 -2
  3. package/dist/commands/plan-continue.js +11 -1
  4. package/dist/commands/plan-resume.js +38 -1
  5. package/dist/lane/app-chain.js +297 -0
  6. package/dist/lane/app-flow.js +55 -0
  7. package/dist/lane/card.js +148 -0
  8. package/dist/lane/chain-token.js +59 -0
  9. package/dist/lane/inputs.js +459 -0
  10. package/dist/lane/paths.js +21 -0
  11. package/dist/lane/resolve.js +109 -0
  12. package/dist/lane/roots.js +113 -0
  13. package/dist/lane/signals.js +80 -0
  14. package/dist/lane/store.js +232 -0
  15. package/dist/lane/types.js +1 -0
  16. package/dist/one-shot.js +1 -0
  17. package/dist/projects/detail-path.js +117 -0
  18. package/dist/projects/extract-plan.js +46 -6
  19. package/dist/projects/merge-cca.js +19 -0
  20. package/dist/projects/merge-upgrade.js +19 -1
  21. package/dist/projects/plan-lane.js +214 -0
  22. package/dist/projects/plan-schema.js +10 -0
  23. package/dist/projects/promote-command.js +8 -2
  24. package/dist/projects/promote.js +7 -1
  25. package/dist/projects/save-command.js +71 -25
  26. package/dist/projects/workspace.js +10 -0
  27. package/dist/register/records.js +7 -2
  28. package/dist/renderer/tool-labels.js +2 -1
  29. package/dist/repl/resume-standalone.js +3 -0
  30. package/dist/repl.js +23 -2
  31. package/dist/skill-catalog.js +14 -6
  32. package/dist/skills/bundled-skills.js +61 -54
  33. package/dist/tools/_filesystem-shared.js +363 -33
  34. package/dist/tools/cap/metadata-import.js +10 -4
  35. package/dist/tools/filesystem/file-write.js +12 -39
  36. package/dist/tools/filesystem/glob.js +25 -2
  37. package/dist/tools/filesystem/grep.js +25 -2
  38. package/dist/tools/fiori/apply.js +30 -21
  39. package/dist/tools/fiori/commit-gate.js +58 -0
  40. package/dist/tools/fiori/fe-extend.js +6 -1
  41. package/dist/tools/fiori/fe-scaffold.js +6 -1
  42. package/dist/tools/fiori/path-params.js +78 -0
  43. package/dist/tools/fiori/scaffold.js +2 -1
  44. package/dist/tools/fiori/tools.js +53 -14
  45. package/dist/tools/lane/app-lane-resolve.js +329 -0
  46. package/dist/tools/local-build.js +4 -0
  47. package/dist/tools/project/_merge-shared.js +36 -1
  48. package/dist/tools/project/cca_merge.js +4 -0
  49. package/dist/tools/project/upgrade_merge_progress.js +21 -10
  50. package/dist/tools/tool-loading.js +2 -0
  51. package/package.json +1 -1
@@ -8,7 +8,8 @@
8
8
  * resolveSafePath before doing any IO.
9
9
  */
10
10
  import * as path from 'node:path';
11
- import { promises as fsPromises } from 'node:fs';
11
+ import { promises as fsPromises, realpathSync, existsSync } from 'node:fs';
12
+ import { cspeachRoot } from '../config/paths.js';
12
13
  /**
13
14
  * Phase 3 sensitive in-root directory prefixes — refused by all filesystem
14
15
  * tools. Matched as `rel === prefix` or `rel` starts with `prefix + '/'`.
@@ -110,36 +111,49 @@ export function blockedExecutableExtension(targetPath) {
110
111
  return BLOCKED_EXECUTABLE_EXTS.has(ext) ? ext : null;
111
112
  }
112
113
  /**
113
- * True if `realAbsPath` is denylisted relative to root — either it falls under
114
- * one of BLOCKED_PREFIXES (directory prefixes) or its basename is in the
115
- * `.env` family. Caller should refuse the operation when `blocked` is true.
116
- *
117
- * CARVE-OUT — the four skill working subdirectories under `.cspeach/`
118
- * (CSPEACH_WORK_SUBDIRS = cca/upgrades/modernize/tests) are ALLOWED so skills
119
- * can read+write their own detail files. A path is carved out (NOT blocked)
120
- * iff ALL hold:
121
- * 1. its root-relative path (lowercased, '/'-separated) starts with
122
- * `.cspeach/<sub>/` for some <sub> in CSPEACH_WORK_SUBDIRS — i.e. it is
123
- * STRICTLY INSIDE one of those subdirs (the subdir entry itself is not
124
- * carved out), AND
125
- * 2. its basename does NOT end with `.cspeach.json` (those envelopes are
126
- * CLI-owned — direct writes stay blocked), AND
127
- * 3. its basename is NOT in the `.env` secret family (BLOCKED_BASENAME_RE).
128
- * The carve-out short-circuits ONLY the `.cspeach` prefix. `.git`,
129
- * `.cspeach-design`, the `.cspeach` root, non-work subdirs, and `.env*`
130
- * everywhere else remain blocked.
114
+ * A path segment the way NTFS resolves it for a name compare:
115
+ * - drop any `:stream` / `::$TYPE` / `:$I30:$INDEX_ALLOCATION` suffix
116
+ * (`.git::$INDEX_ALLOCATION` opens the `.git` folder, `.env::$DATA` opens
117
+ * the `.env` file), then
118
+ * - strip trailing dots and spaces (`.git.`, `.git `, `.env ` open the
119
+ * same object as the bare name).
120
+ * `.` and `..` are kept as-is (they are navigation, not names).
131
121
  */
132
- export function isDenylistedPath(realAbsPath, root) {
133
- const relFromRoot = path.relative(path.resolve(root), realAbsPath).replace(/\\/g, '/');
122
+ function ntfsCanonicalSegment(seg) {
123
+ if (seg === '.' || seg === '..')
124
+ return seg;
125
+ const colon = seg.indexOf(':');
126
+ const name = colon === -1 ? seg : seg.slice(0, colon);
127
+ return name.replace(/[. ]+$/, '');
128
+ }
129
+ /**
130
+ * Windows reserved device names — `CON`, `PRN`, `AUX`, `NUL`, `COM0`-`COM9`,
131
+ * `LPT0`-`LPT9` (and the superscript-digit forms) — WITH OR WITHOUT an
132
+ * extension, ignoring trailing dots/spaces: `CON.xml` and `nul.txt` open the
133
+ * device, not a file. The one shared rule for every name check.
134
+ */
135
+ export function isWindowsReservedName(name) {
136
+ const stem = (name.replace(/[. ]+$/, '').split('.')[0] ?? '').replace(/ +$/, '');
137
+ return /^(CON|PRN|AUX|NUL|COM[0-9¹²³]|LPT[0-9¹²³])$/i.test(stem);
138
+ }
139
+ /**
140
+ * The denylist decision on one root-relative, '/'-separated path. Pure string
141
+ * logic, no normalization — isDenylistedPath runs it on the raw AND the
142
+ * canonical spelling and blocks if either is blocked.
143
+ */
144
+ function relIsDenylisted(rel) {
134
145
  // Case-insensitive comparison: macOS APFS (default) and Windows NTFS (default)
135
146
  // are case-insensitive filesystems, so `.Cspeach/auth.json` resolves to the
136
147
  // SAME inode as `.cspeach/auth.json`. A byte-exact denylist match would let
137
- // the model bypass protection with a one-keystroke case change. The returned
138
- // `relFromRoot` keeps its ORIGINAL case so callers can log what the model
139
- // actually requested.
140
- const relLower = relFromRoot.toLowerCase();
141
- const base = path.basename(relFromRoot);
148
+ // the model bypass protection with a one-keystroke case change.
149
+ const relLower = rel.toLowerCase();
150
+ const base = path.posix.basename(rel);
142
151
  const baseLower = base.toLowerCase();
152
+ // `.git` at ANY depth — a nested repo or submodule (`sub/.git/hooks/…`) is
153
+ // the same code-execution target as the root one. Checked before the
154
+ // carve-out so nothing can short-circuit it.
155
+ if (relLower.split('/').includes('.git'))
156
+ return true;
143
157
  // ── CARVE-OUT: .cspeach/<work-subdir>/… — short-circuits ONLY .cspeach. ──
144
158
  // Must be strictly INSIDE a work subdir, not a CLI-owned envelope, and not
145
159
  // an .env secret. Placed before the prefix loop so it can return early, but
@@ -147,7 +161,7 @@ export function isDenylistedPath(realAbsPath, root) {
147
161
  for (const sub of CSPEACH_WORK_SUBDIRS) {
148
162
  if (relLower.startsWith('.cspeach/' + sub + '/')) {
149
163
  if (!baseLower.endsWith('.cspeach.json') && !BLOCKED_BASENAME_RE.test(base)) {
150
- return { blocked: false, relFromRoot };
164
+ return false;
151
165
  }
152
166
  break; // inside a work subdir but failed an exclusion → fall through to block
153
167
  }
@@ -156,14 +170,204 @@ export function isDenylistedPath(realAbsPath, root) {
156
170
  for (const prefix of BLOCKED_PREFIXES) {
157
171
  const prefixLower = prefix.toLowerCase();
158
172
  if (relLower === prefixLower || relLower.startsWith(prefixLower + '/')) {
159
- return { blocked: true, relFromRoot };
173
+ return true;
160
174
  }
161
175
  }
162
176
  // Basename denylist — the .env family at ANY depth (.env, .env.local, …).
163
177
  // Using the basename (not a prefix) is what catches `.env.production` while
164
178
  // still excluding `.environment-notes.md` and `.envrc`.
165
- if (BLOCKED_BASENAME_RE.test(base)) {
166
- return { blocked: true, relFromRoot };
179
+ return BLOCKED_BASENAME_RE.test(base);
180
+ }
181
+ /**
182
+ * Windows 8.3 short names (`GIT~1` for `.git`) cannot be decided by string
183
+ * math: the alias exists only if the volume generated one. Resolve the
184
+ * nearest EXISTING ancestor through the filesystem (realpath expands short
185
+ * names) and re-attach the not-yet-existing tail, so a file that does not
186
+ * exist yet (`GIT~1/hooks/pre-commit`) is judged by its long-name path.
187
+ *
188
+ * Only "does not exist" (ENOENT) moves the walk up a level. Any OTHER error
189
+ * (EACCES, EPERM, EIO, ELOOP, …) means we cannot tell what the name aliases,
190
+ * so the caller must fail closed — `{ failClosed: true }`. Returns null only
191
+ * when nothing up to the drive root exists.
192
+ */
193
+ function longNameAbs(absPath) {
194
+ let cur = path.win32.resolve(absPath);
195
+ const tail = [];
196
+ for (;;) {
197
+ try {
198
+ const real = realpathSync.native(cur);
199
+ return { abs: tail.length ? path.win32.join(real, ...tail) : real };
200
+ }
201
+ catch (err) {
202
+ if (err?.code !== 'ENOENT')
203
+ return { failClosed: true };
204
+ const parent = path.win32.dirname(cur);
205
+ if (parent === cur)
206
+ return null;
207
+ tail.unshift(path.win32.basename(cur));
208
+ cur = parent;
209
+ }
210
+ }
211
+ }
212
+ /** realpath of an existing folder, or null. Successful lookups are cached (per spelling). */
213
+ const realDirCache = new Map();
214
+ function realDirOrNull(dir) {
215
+ const hit = realDirCache.get(dir);
216
+ if (hit !== undefined)
217
+ return hit;
218
+ try {
219
+ const real = realpathSync.native(dir);
220
+ realDirCache.set(dir, real);
221
+ return real;
222
+ }
223
+ catch {
224
+ return null;
225
+ }
226
+ }
227
+ /** `rel` is the root itself or strictly below it (not `..`, not another drive). */
228
+ function relIsInside(p, rel) {
229
+ return rel === '' || (!rel.startsWith('..') && !p.isAbsolute(rel));
230
+ }
231
+ /**
232
+ * True if `absPath` is the CLI home (`cspeachRoot()`, `~/.cspeach`) or below
233
+ * it — auth tokens, config, sessions, snapshots, logs. Checked against the
234
+ * spelled and the real home, and through NTFS spellings of the `.cspeach`
235
+ * segment (`.cspeach.`, `.cspeach `), case-insensitively.
236
+ */
237
+ function isUnderCliHome(absPath, p, native) {
238
+ const home = cspeachRoot();
239
+ const homes = [p.resolve(home)];
240
+ if (native) {
241
+ const real = realDirOrNull(home);
242
+ if (real && real !== homes[0])
243
+ homes.push(real);
244
+ }
245
+ for (const h of homes) {
246
+ const fromParent = p.relative(p.dirname(h), absPath).replace(/\\/g, '/');
247
+ if (!relIsInside(p, fromParent) || fromParent === '')
248
+ continue;
249
+ const first = ntfsCanonicalSegment(fromParent.split('/')[0]).toLowerCase();
250
+ if (first === p.basename(h).toLowerCase())
251
+ return true;
252
+ }
253
+ return false;
254
+ }
255
+ /**
256
+ * True if `realAbsPath` is denylisted relative to root — either it falls under
257
+ * one of BLOCKED_PREFIXES (directory prefixes) or its basename is in the
258
+ * `.env` family. Caller should refuse the operation when `blocked` is true.
259
+ *
260
+ * CARVE-OUT — the four skill working subdirectories under `.cspeach/`
261
+ * (CSPEACH_WORK_SUBDIRS = cca/upgrades/modernize/tests) are ALLOWED so skills
262
+ * can read+write their own detail files. A path is carved out (NOT blocked)
263
+ * iff ALL hold:
264
+ * 1. its root-relative path (lowercased, '/'-separated) starts with
265
+ * `.cspeach/<sub>/` for some <sub> in CSPEACH_WORK_SUBDIRS — i.e. it is
266
+ * STRICTLY INSIDE one of those subdirs (the subdir entry itself is not
267
+ * carved out), AND
268
+ * 2. its basename does NOT end with `.cspeach.json` (those envelopes are
269
+ * CLI-owned — direct writes stay blocked), AND
270
+ * 3. its basename is NOT in the `.env` secret family (BLOCKED_BASENAME_RE).
271
+ * The carve-out short-circuits ONLY the `.cspeach` prefix. `.git`,
272
+ * `.cspeach-design`, the `.cspeach` root, non-work subdirs, and `.env*`
273
+ * everywhere else remain blocked.
274
+ *
275
+ * `.git` is blocked at ANY depth (nested repos, submodules), not only at the
276
+ * root — its hooks are a code-execution target wherever it lives.
277
+ *
278
+ * The CLI HOME (`cspeachRoot()`, `~/.cspeach`) is blocked WHATEVER the root —
279
+ * also when the CLI runs from `C:\Users`, `C:\` or `/home`, where it is not a
280
+ * root-level `.cspeach`. The carve-out does not apply there.
281
+ *
282
+ * WINDOWS NAME ALIASES (SEC fix, `.git::$INDEX_ALLOCATION`). NTFS opens the
283
+ * same object under spellings that do not byte-match the denylist:
284
+ * - stream suffixes: `.git::$INDEX_ALLOCATION`, `.git:$I30:$INDEX_ALLOCATION`,
285
+ * `.env::$DATA`, `name:stream`;
286
+ * - trailing dots/spaces: `.git.`, `.git `, `.env `;
287
+ * - 8.3 short names: `GIT~1`.
288
+ * So, in order:
289
+ * 1. win32: ANY root-relative segment containing ':' is refused outright.
290
+ * No project file needs an NTFS alternate data stream, and refusing the
291
+ * whole class is safer than enumerating stream types.
292
+ * 2. All platforms: the denylist is applied to the raw path AND to the
293
+ * canonical path (every segment through ntfsCanonicalSegment); blocked if
294
+ * either matches. On POSIX ':' and trailing dots are legal, distinct
295
+ * names, so ordinary names (`notes:12.md`, `report.`) are NOT refused —
296
+ * only a denylisted name wearing a suffix (`.git:x`, `.git.`), which is a
297
+ * real alias on an NTFS/SMB mount and never a legitimate project file.
298
+ * 3. win32: when a segment contains '~' (a possible 8.3 short name), the
299
+ * path is also resolved through the filesystem (longNameAbs) and the
300
+ * long-name path is checked too. If that lookup fails for any reason
301
+ * other than "does not exist", the path is BLOCKED (fail closed).
302
+ *
303
+ * If the root is spelled differently from its real path (a link, an 8.3
304
+ * name, macOS `/var` → `/private/var`) and `realAbsPath` is a REAL path, the
305
+ * relative path is also computed against the real root, so a real path is
306
+ * never misread as "outside root, not denylisted".
307
+ *
308
+ * `platform` exists for tests (exercise the POSIX rules on a Windows box and
309
+ * vice versa); production callers omit it. Filesystem lookups only run when
310
+ * `platform` is the platform the process actually runs on.
311
+ */
312
+ export function isDenylistedPath(realAbsPath, root, platform = process.platform) {
313
+ const isWin = platform === 'win32';
314
+ const native = platform === process.platform;
315
+ const p = isWin ? path.win32 : path.posix;
316
+ const absRoot = p.resolve(root);
317
+ // The returned `relFromRoot` keeps its ORIGINAL case/spelling so callers can
318
+ // log what the model actually requested.
319
+ let relFromRoot = p.relative(absRoot, realAbsPath).replace(/\\/g, '/');
320
+ // Root-relative spellings to judge: against the spelled root, and — when the
321
+ // path sits outside it — against the real root. A real path under a
322
+ // junctioned/linked root is SHOWN relative to the real root too, so messages
323
+ // read `.git/hooks/x`, not `../../real/.git/hooks/x`.
324
+ const rels = [relFromRoot];
325
+ if (native && !relIsInside(p, relFromRoot)) {
326
+ const realRoot = realDirOrNull(absRoot);
327
+ if (realRoot && realRoot !== absRoot) {
328
+ const realRel = p.relative(realRoot, realAbsPath).replace(/\\/g, '/');
329
+ rels.push(realRel);
330
+ if (relIsInside(p, realRel))
331
+ relFromRoot = realRel;
332
+ }
333
+ }
334
+ const blocked = { blocked: true, relFromRoot };
335
+ // The CLI home is protected whatever the root.
336
+ if (isUnderCliHome(realAbsPath, p, native))
337
+ return blocked;
338
+ for (const rel of rels) {
339
+ const segments = rel.split('/');
340
+ // (1) win32: an NTFS stream / stream-type suffix anywhere → refuse.
341
+ if (isWin && segments.some((seg) => seg.includes(':')))
342
+ return blocked;
343
+ // (2) raw spelling, then the NTFS-canonical spelling.
344
+ if (relIsDenylisted(rel))
345
+ return blocked;
346
+ const canonical = segments.map(ntfsCanonicalSegment).join('/');
347
+ if (canonical !== rel && relIsDenylisted(canonical))
348
+ return blocked;
349
+ }
350
+ // (3) win32: possible 8.3 short name → judge the long-name path too.
351
+ if (isWin && native && rels[0].split('/').some((seg) => seg.includes('~'))) {
352
+ const long = longNameAbs(realAbsPath);
353
+ if (long && 'failClosed' in long)
354
+ return blocked;
355
+ if (long) {
356
+ if (isUnderCliHome(long.abs, p, native))
357
+ return blocked;
358
+ const roots = [absRoot];
359
+ const realRoot = realDirOrNull(absRoot);
360
+ if (realRoot && realRoot !== absRoot)
361
+ roots.push(realRoot);
362
+ for (const r of roots) {
363
+ const longRel = p.relative(r, long.abs).replace(/\\/g, '/');
364
+ if (!relIsInside(p, longRel))
365
+ continue;
366
+ const longCanonical = longRel.split('/').map(ntfsCanonicalSegment).join('/');
367
+ if (relIsDenylisted(longRel) || relIsDenylisted(longCanonical))
368
+ return blocked;
369
+ }
370
+ }
167
371
  }
168
372
  return { blocked: false, relFromRoot };
169
373
  }
@@ -214,11 +418,137 @@ export function resolveSafePath(root, userPath) {
214
418
  * found" condition.
215
419
  */
216
420
  export async function assertRealPathContained(absInputPath, root) {
217
- const absRoot = path.resolve(root);
218
421
  const real = await fsPromises.realpath(absInputPath);
219
- const rel = path.relative(absRoot, real);
220
- if (rel.startsWith('..') || path.isAbsolute(rel)) {
422
+ if (!isInsideRoot(real, root))
221
423
  throw new PathOutsideRootError(absInputPath, root);
424
+ return real;
425
+ }
426
+ /**
427
+ * `target` (a REAL path) is inside root — compared with the root as spelled
428
+ * and with the root's own real path, so a root that sits behind a link or an
429
+ * 8.3 name (macOS `/var` → `/private/var`) still contains its own files.
430
+ */
431
+ export function isInsideRoot(target, root) {
432
+ const absRoot = path.resolve(root);
433
+ if (relIsInside(path, path.relative(absRoot, target)))
434
+ return true;
435
+ const realRoot = realOfNearestOrNull(absRoot);
436
+ return realRoot !== null && realRoot !== absRoot && relIsInside(path, path.relative(realRoot, target));
437
+ }
438
+ /**
439
+ * Real form of a folder that may not exist yet: nearest existing ancestor
440
+ * realpath'd, missing tail re-attached. null on any non-ENOENT error.
441
+ */
442
+ function realOfNearestOrNull(abs) {
443
+ let cur = abs;
444
+ const tail = [];
445
+ for (;;) {
446
+ const real = realDirOrNull(cur);
447
+ if (real !== null)
448
+ return tail.length ? path.join(real, ...tail) : real;
449
+ if (existsSync(cur))
450
+ return null; // exists but realpath failed — no real form
451
+ const parent = path.dirname(cur);
452
+ if (parent === cur)
453
+ return null;
454
+ tail.unshift(path.basename(cur));
455
+ cur = parent;
456
+ }
457
+ }
458
+ /**
459
+ * The REAL path a write to `absInputPath` will land on, for a target that may
460
+ * not exist yet. Walks up to the nearest EXISTING ancestor, realpaths it
461
+ * (following links and expanding Windows 8.3 short names), re-attaches the
462
+ * not-yet-existing tail, and verifies the result is inside root. Throws
463
+ * PathOutsideRootError on escape; rethrows any non-ENOENT fs error.
464
+ *
465
+ * Write tools MUST hand this result (not the user-spelled path) to
466
+ * isDenylistedPath: `docs/hooks/pre-commit` where `docs` links to `.git`, or
467
+ * `GIT~1/hooks/pre-commit`, only show up as `.git/...` after resolution.
468
+ */
469
+ export async function realPathForWrite(absInputPath, root) {
470
+ let cur = path.resolve(absInputPath);
471
+ const tail = [];
472
+ for (;;) {
473
+ let real;
474
+ try {
475
+ real = await fsPromises.realpath(cur);
476
+ }
477
+ catch (err) {
478
+ if (err.code !== 'ENOENT')
479
+ throw err;
480
+ const parent = path.dirname(cur);
481
+ if (parent === cur)
482
+ throw err;
483
+ tail.unshift(path.basename(cur));
484
+ cur = parent;
485
+ continue;
486
+ }
487
+ const target = tail.length ? path.join(real, ...tail) : real;
488
+ if (!isInsideRoot(target, root))
489
+ throw new PathOutsideRootError(absInputPath, root);
490
+ return target;
491
+ }
492
+ }
493
+ /** Sync twin of realPathForWrite, for engines whose write path is synchronous. */
494
+ export function realPathForWriteSync(absInputPath, root) {
495
+ let cur = path.resolve(absInputPath);
496
+ const tail = [];
497
+ for (;;) {
498
+ let real;
499
+ try {
500
+ real = realpathSync.native(cur);
501
+ }
502
+ catch (err) {
503
+ if (err.code !== 'ENOENT')
504
+ throw err;
505
+ const parent = path.dirname(cur);
506
+ if (parent === cur)
507
+ throw err;
508
+ tail.unshift(path.basename(cur));
509
+ cur = parent;
510
+ continue;
511
+ }
512
+ const target = tail.length ? path.join(real, ...tail) : real;
513
+ if (!isInsideRoot(target, root))
514
+ throw new PathOutsideRootError(absInputPath, root);
515
+ return target;
516
+ }
517
+ }
518
+ /**
519
+ * The one-call write gate: the real landing path of `absInputPath` must be
520
+ * inside `containRoot` and not denylisted relative to `denyRoot` (nor to
521
+ * `containRoot`), judged on BOTH the spelled and the real path. Throws
522
+ * PathOutsideRootError on escape and ProtectedPathError on a denylisted path.
523
+ * Returns the real landing path.
524
+ */
525
+ export class ProtectedPathError extends Error {
526
+ relFromRoot;
527
+ constructor(relFromRoot) {
528
+ super(`refusing to write protected path "${relFromRoot}" (denylist: ${DENYLIST_DESCRIPTION})`);
529
+ this.relFromRoot = relFromRoot;
530
+ this.name = 'ProtectedPathError';
531
+ }
532
+ }
533
+ export function assertWritableSync(absInputPath, containRoot, denyRoot = containRoot) {
534
+ const real = realPathForWriteSync(absInputPath, containRoot);
535
+ for (const cand of [path.resolve(absInputPath), real]) {
536
+ for (const r of new Set([denyRoot, containRoot])) {
537
+ const v = isDenylistedPath(cand, r);
538
+ if (v.blocked)
539
+ throw new ProtectedPathError(v.relFromRoot);
540
+ }
541
+ }
542
+ return real;
543
+ }
544
+ export async function assertWritable(absInputPath, containRoot, denyRoot = containRoot) {
545
+ const real = await realPathForWrite(absInputPath, containRoot);
546
+ for (const cand of [path.resolve(absInputPath), real]) {
547
+ for (const r of new Set([denyRoot, containRoot])) {
548
+ const v = isDenylistedPath(cand, r);
549
+ if (v.blocked)
550
+ throw new ProtectedPathError(v.relFromRoot);
551
+ }
222
552
  }
223
553
  return real;
224
554
  }
@@ -40,12 +40,10 @@ import { promises as fs } from 'node:fs';
40
40
  import * as path from 'node:path';
41
41
  import { randomBytes } from 'node:crypto';
42
42
  import { registerTool } from '../index.js';
43
- import { resolveSafePath, isDenylistedPath, BLOCKED_PREFIXES } from '../_filesystem-shared.js';
43
+ import { resolveSafePath, isDenylistedPath, BLOCKED_PREFIXES, assertWritable, isWindowsReservedName } from '../_filesystem-shared.js';
44
44
  import { loadConfig } from '../../config/loader.js';
45
45
  import { PlainFetchError, plainSapFetchError } from './_fetch-errors.js';
46
46
  const NAME_RE = /^[A-Za-z][A-Za-z0-9_]{0,59}$/;
47
- /** Windows device names: `CON.edmx` opens the console, not a file. */
48
- const RESERVED_NAME_RE = /^(CON|PRN|AUX|NUL|COM[0-9]|LPT[0-9])$/i;
49
47
  /** projectDir characters. No `:` (drive letters, NTFS streams), no wildcards, no control chars. */
50
48
  const PROJECT_DIR_RE = /^[A-Za-z0-9._\-/\\ ]*$/;
51
49
  /** Longest start tag we read attributes from; longer ones are skipped. */
@@ -185,7 +183,7 @@ export async function capMetadataImportHandler(args, ctx, deps = {}) {
185
183
  if (!NAME_RE.test(apiName)) {
186
184
  return fail('apiName must be a plain name like API_BUSINESS_PARTNER: a letter first, then letters, digits or _, at most 60.');
187
185
  }
188
- if (RESERVED_NAME_RE.test(apiName)) {
186
+ if (isWindowsReservedName(apiName)) {
189
187
  return fail(`apiName ${apiName} is a reserved Windows device name. Use another name, such as API_${apiName.toUpperCase()}.`);
190
188
  }
191
189
  const svc = validServicePath(args.servicePath);
@@ -224,6 +222,14 @@ export async function capMetadataImportHandler(args, ctx, deps = {}) {
224
222
  if (await isSymlink(target)) {
225
223
  return fail(`${relFromRoot} is a symlink. Remove it first; the import never writes through a link.`);
226
224
  }
225
+ // The REAL landing path (links followed, Windows short names expanded) must
226
+ // not be protected either — `projectDir` may be a junction to `.git`.
227
+ try {
228
+ await assertWritable(target, rootAbs);
229
+ }
230
+ catch {
231
+ return fail(outsideMsg);
232
+ }
227
233
  // ── Fetch. ──
228
234
  const get = deps.get ?? (async (p) => {
229
235
  const cfg = await loadConfig();
@@ -9,18 +9,16 @@
9
9
  * Write is atomic: content goes to <path>.tmp then fs.rename swaps it in so
10
10
  * a crash mid-write never leaves a partial file at the target path.
11
11
  *
12
- * Path containment via resolveSafePath + assertRealPathContained. For
13
- * non-existent target files (the common create case), assertRealPathContained
14
- * would throw ENOENT because fs.realpath requires the path to exist. In that
15
- * case we fall back to verifying the parent directory is contained instead,
16
- * using the original abs path (from resolveSafePath) as the write target.
12
+ * Path containment via resolveSafePath + realPathForWrite: the real landing
13
+ * path (nearest existing ancestor realpath'd, missing tail re-attached) is
14
+ * what gets contained, denylisted and written — never the user's spelling.
17
15
  *
18
16
  * Flag-gated: invisible to listTools() unless CSPEACH_TOOL_FILE_WRITE=on.
19
17
  */
20
18
  import { promises as fs, readFileSync } from 'node:fs';
21
19
  import * as path from 'node:path';
22
20
  import { registerTool } from '../index.js';
23
- import { resolveSafePath, assertRealPathContained, PathOutsideRootError, DENYLIST_DESCRIPTION, isDenylistedPath, blockedExecutableExtension, } from '../_filesystem-shared.js';
21
+ import { resolveSafePath, realPathForWrite, PathOutsideRootError, DENYLIST_DESCRIPTION, isDenylistedPath, blockedExecutableExtension, } from '../_filesystem-shared.js';
24
22
  /**
25
23
  * 2026-09-23 (Glenn's first run) — a standalone run writes GUIDE.md and said
26
24
  * nothing about it. He opened it in a text editor, then tried to drop it into
@@ -69,45 +67,20 @@ export async function fileWriteHandler(args, ctx) {
69
67
  }
70
68
  return { content: `error: ${err instanceof Error ? err.message : String(err)}`, is_error: true };
71
69
  }
72
- // 2. Path containment — async phase (follows symlinks to catch symlink escapes).
73
- // For non-existent files (the common create case), fs.realpath throws ENOENT.
74
- // In that case, verify the parent directory is contained instead.
70
+ // 2. Path containment — async phase. Resolve the REAL landing path: the
71
+ // nearest existing ancestor is realpath'd (links followed, Windows 8.3
72
+ // short names expanded) and the not-yet-existing tail re-attached. The
73
+ // denylist below MUST see this real path — the user's spelling can hide
74
+ // `.git` behind a link (`docs -> .git`) or a short name (`GIT~1`).
75
75
  let target;
76
76
  try {
77
- target = await assertRealPathContained(abs, ctx.cwd);
77
+ target = await realPathForWrite(abs, ctx.cwd);
78
78
  }
79
79
  catch (err) {
80
- if (err.code === 'ENOENT') {
81
- // Target file doesn't exist yet (create case) — verify parent is contained.
82
- const parent = path.dirname(abs);
83
- try {
84
- await assertRealPathContained(parent, ctx.cwd);
85
- }
86
- catch (parentErr) {
87
- if (parentErr.code === 'ENOENT') {
88
- // Parent doesn't exist either (will be mkdir -p'd) — verify via string math.
89
- const relFromRoot = path.relative(path.resolve(ctx.cwd), parent);
90
- if (relFromRoot.startsWith('..') || path.isAbsolute(relFromRoot)) {
91
- return { content: 'error: parent directory escapes project root', is_error: true };
92
- }
93
- // Parent is fine — use the original abs as write target.
94
- }
95
- else if (parentErr instanceof PathOutsideRootError) {
96
- return { content: `error: ${parentErr.message}`, is_error: true };
97
- }
98
- else {
99
- return { content: `error: ${parentErr.message}`, is_error: true };
100
- }
101
- }
102
- // Use the original abs path (resolveSafePath result) as write target.
103
- target = abs;
104
- }
105
- else if (err instanceof PathOutsideRootError) {
80
+ if (err instanceof PathOutsideRootError) {
106
81
  return { content: `error: ${err.message} (after symlink resolution)`, is_error: true };
107
82
  }
108
- else {
109
- return { content: `error: ${err instanceof Error ? err.message : String(err)}`, is_error: true };
110
- }
83
+ return { content: `error: ${err instanceof Error ? err.message : String(err)}`, is_error: true };
111
84
  }
112
85
  // 3. Denylist check: refuse sensitive in-root paths
113
86
  const denylist = isDenylistedPath(target, ctx.cwd);
@@ -19,7 +19,7 @@
19
19
  import { promises as fs } from 'node:fs';
20
20
  import * as path from 'node:path';
21
21
  import { registerTool } from '../index.js';
22
- import { resolveSafePath, PathOutsideRootError, isDenylistedPath } from '../_filesystem-shared.js';
22
+ import { resolveSafePath, assertRealPathContained, PathOutsideRootError, isDenylistedPath, DENYLIST_DESCRIPTION, } from '../_filesystem-shared.js';
23
23
  const MAX_RESULTS = 500;
24
24
  const MAX_FILES_TRAVERSED = 10_000;
25
25
  const ALWAYS_SKIPPED_DIRS = new Set([
@@ -150,7 +150,30 @@ export async function globHandler(args, ctx) {
150
150
  }
151
151
  }
152
152
  }
153
- await walk(searchRoot, '');
153
+ // The search root is judged by its REAL path: a junction/symlink used as
154
+ // the root (`docs -> .git`, `ext -> /outside`) must neither escape root nor
155
+ // open a protected folder. The walk then runs on the real path, so every
156
+ // per-file denylist check below sees real paths too.
157
+ let realRoot;
158
+ try {
159
+ realRoot = await assertRealPathContained(searchRoot, ctx.cwd);
160
+ }
161
+ catch (err) {
162
+ if (err instanceof PathOutsideRootError) {
163
+ return { content: `error: ${err.message} (after symlink resolution)`, is_error: true };
164
+ }
165
+ return { content: `error: ${err instanceof Error ? err.message : String(err)}`, is_error: true };
166
+ }
167
+ for (const cand of [searchRoot, realRoot]) {
168
+ const verdict = isDenylistedPath(cand, ctx.cwd);
169
+ if (verdict.blocked) {
170
+ return {
171
+ content: `error: refusing to list protected path "${verdict.relFromRoot}" (denylist: ${DENYLIST_DESCRIPTION})`,
172
+ is_error: true,
173
+ };
174
+ }
175
+ }
176
+ await walk(realRoot, '');
154
177
  // Sort by mtime descending, cap at MAX_RESULTS
155
178
  collected.sort((a, b) => b.mtime - a.mtime);
156
179
  const sample = collected.slice(0, MAX_RESULTS).map((f) => f.relPath);
@@ -20,7 +20,7 @@
20
20
  import { promises as fs } from 'node:fs';
21
21
  import * as path from 'node:path';
22
22
  import { registerTool } from '../index.js';
23
- import { resolveSafePath, PathOutsideRootError, isDenylistedPath } from '../_filesystem-shared.js';
23
+ import { resolveSafePath, assertRealPathContained, PathOutsideRootError, isDenylistedPath, DENYLIST_DESCRIPTION, } from '../_filesystem-shared.js';
24
24
  import { globToRegex } from './glob.js';
25
25
  const MAX_MATCHES = 250;
26
26
  const MAX_FILES_TRAVERSED = 10_000;
@@ -129,7 +129,30 @@ export async function grepHandler(args, ctx) {
129
129
  }
130
130
  }
131
131
  }
132
- await walk(searchRoot, '');
132
+ // The search root is judged by its REAL path: a junction/symlink used as
133
+ // the root (`docs -> .git`, `ext -> /outside`) must neither escape root nor
134
+ // open a protected folder. The walk then runs on the real path, so every
135
+ // per-file denylist check below sees real paths too.
136
+ let realRoot;
137
+ try {
138
+ realRoot = await assertRealPathContained(searchRoot, ctx.cwd);
139
+ }
140
+ catch (err) {
141
+ if (err instanceof PathOutsideRootError) {
142
+ return { content: `error: ${err.message} (after symlink resolution)`, is_error: true };
143
+ }
144
+ return { content: `error: ${err instanceof Error ? err.message : String(err)}`, is_error: true };
145
+ }
146
+ for (const cand of [searchRoot, realRoot]) {
147
+ const verdict = isDenylistedPath(cand, ctx.cwd);
148
+ if (verdict.blocked) {
149
+ return {
150
+ content: `error: refusing to search protected path "${verdict.relFromRoot}" (denylist: ${DENYLIST_DESCRIPTION})`,
151
+ is_error: true,
152
+ };
153
+ }
154
+ }
155
+ await walk(realRoot, '');
133
156
  if (matches.length === 0) {
134
157
  return { content: `no matches for /${args.pattern}/${args.case_insensitive ? 'i' : ''}` };
135
158
  }