@cspeach/cli 1.1.24 → 1.1.26
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.
- package/dist/agent/loop.js +20 -8
- package/dist/agent/tool-dispatch.js +6 -0
- package/dist/auth/auth-file.js +173 -12
- package/dist/classifier/client.js +55 -30
- package/dist/cli.js +11 -0
- package/dist/commands/config-set.js +13 -0
- package/dist/commands/config-show.js +5 -1
- package/dist/commands/cost.js +24 -22
- package/dist/commands/deploy-card.js +135 -0
- package/dist/commands/login.js +31 -13
- package/dist/commands/plan-chain.js +31 -0
- package/dist/commands/plan-continue.js +1 -0
- package/dist/commands/plan-resume.js +49 -2
- package/dist/cost/cost-log.js +76 -4
- package/dist/cost/credits-wire.js +9 -0
- package/dist/cost/credits.js +24 -6
- package/dist/cost/router-spend.js +24 -0
- package/dist/cost/session-cost.js +16 -2
- package/dist/models/server-config.js +21 -1
- package/dist/one-shot.js +12 -2
- package/dist/projects/detail-path.js +117 -0
- package/dist/projects/merge-cca.js +19 -0
- package/dist/projects/merge-upgrade.js +19 -1
- package/dist/projects/plan-lane.js +47 -0
- package/dist/projects/plan-run.js +8 -0
- package/dist/projects/plan-schema.js +2 -0
- package/dist/projects/promote.js +7 -1
- package/dist/projects/save-command.js +3 -2
- package/dist/register/records.js +7 -2
- package/dist/renderer/footer-line.js +10 -4
- package/dist/renderer/status-footer.js +5 -4
- package/dist/renderer/tool-labels.js +2 -1
- package/dist/repl.js +41 -8
- package/dist/router/byok-classifier.js +159 -0
- package/dist/router/classifier.js +130 -6
- package/dist/router/router-core.js +307 -0
- package/dist/router/router-prompt.js +121 -0
- package/dist/router/routing-failure.js +65 -3
- package/dist/skills/bundled-skills.js +45 -45
- package/dist/tools/_filesystem-shared.js +363 -33
- package/dist/tools/cap/metadata-import.js +10 -4
- package/dist/tools/filesystem/file-write.js +12 -39
- package/dist/tools/filesystem/glob.js +25 -2
- package/dist/tools/filesystem/grep.js +25 -2
- package/dist/tools/fiori/apply.js +30 -21
- package/dist/tools/fiori/commit-gate.js +58 -0
- package/dist/tools/fiori/deploy/existing-bsp.js +67 -0
- package/dist/tools/fiori/deploy/run.js +608 -0
- package/dist/tools/fiori/deploy/scope.js +8 -0
- package/dist/tools/fiori/deploy/yaml-read.js +236 -0
- package/dist/tools/fiori/deploy-config.js +24 -6
- package/dist/tools/fiori/fe-extend.js +6 -1
- package/dist/tools/fiori/fe-scaffold.js +6 -1
- package/dist/tools/fiori/path-params.js +78 -0
- package/dist/tools/fiori/scaffold.js +2 -1
- package/dist/tools/fiori/tools.js +170 -19
- package/dist/tools/local-build.js +3 -0
- package/dist/tools/project/_merge-shared.js +36 -1
- package/dist/tools/project/cca_merge.js +4 -0
- package/dist/tools/project/upgrade_merge_progress.js +21 -10
- package/dist/tools/subagent/read_agent.js +4 -2
- package/dist/tools/tool-loading.js +1 -1
- 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
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
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
|
-
|
|
133
|
-
|
|
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.
|
|
138
|
-
|
|
139
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
166
|
-
|
|
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
|
-
|
|
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 (
|
|
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 +
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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,
|
|
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
|
|
73
|
-
//
|
|
74
|
-
//
|
|
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
|
|
77
|
+
target = await realPathForWrite(abs, ctx.cwd);
|
|
78
78
|
}
|
|
79
79
|
catch (err) {
|
|
80
|
-
if (err
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|