@cspeach/cli 1.1.29 → 1.1.31
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/.cspeach/project.json +4 -0
- package/README.md +11 -1
- package/dist/agent/loop.js +127 -11
- package/dist/auth/auth-file.js +2 -2
- package/dist/cli.js +39 -13
- package/dist/commands/config-set.js +1 -1
- package/dist/commands/login.js +1 -1
- package/dist/commands/logout.js +1 -1
- package/dist/commands/plan-continue.js +1 -1
- package/dist/commands/plan-gate.js +8 -1
- package/dist/commands/plan-resume.js +24 -18
- package/dist/commands/plan-startup.js +49 -6
- package/dist/commands/team-discover.js +195 -0
- package/dist/commands/team-plan.js +585 -0
- package/dist/commands/team.js +652 -46
- package/dist/commands/whoami.js +1 -1
- package/dist/config/paths.js +15 -1
- package/dist/journey/chain.js +728 -0
- package/dist/journey/clean-core.js +213 -0
- package/dist/journey/flow.js +166 -0
- package/dist/journey/run.js +435 -0
- package/dist/journey/session-deps.js +77 -0
- package/dist/journey/trigger.js +153 -0
- package/dist/lane/app-chain.js +9 -7
- package/dist/lane/chain-token.js +9 -5
- package/dist/lane/roots.js +97 -41
- package/dist/one-shot.js +21 -1
- package/dist/projects/clean-core-confirm.js +192 -0
- package/dist/projects/detail-path.js +9 -4
- package/dist/projects/extract-clean-core.js +82 -0
- package/dist/projects/migration.js +4 -105
- package/dist/projects/plan-lane.js +67 -17
- package/dist/projects/plan-sources.js +13 -7
- package/dist/projects/project-folder.js +131 -0
- package/dist/projects/promote-command.js +39 -0
- package/dist/projects/save-command.js +115 -15
- package/dist/projects/saved-lines.js +17 -3
- package/dist/projects/sort-command.js +499 -0
- package/dist/projects/sort-core.js +248 -0
- package/dist/projects/validate.js +2 -1
- package/dist/projects/workspace-retire.js +48 -0
- package/dist/projects/workspace.js +182 -120
- package/dist/register/atc-file.js +9 -1
- package/dist/register/discover.js +901 -0
- package/dist/register/findings.js +82 -0
- package/dist/register/measure.js +60 -1
- package/dist/register/offer.js +125 -0
- package/dist/register/picture.js +25 -0
- package/dist/register/plan-import.js +325 -0
- package/dist/register/records.js +57 -0
- package/dist/register/status-file/estate-parts.js +168 -0
- package/dist/register/status-file/plan-parts.js +68 -0
- package/dist/register/status-file/progress-chart.js +110 -0
- package/dist/register/status-file/register-map.js +221 -0
- package/dist/register/status-file/register-model.js +341 -0
- package/dist/register/status-file/register-parts.js +555 -0
- package/dist/register/status-file/register-status-file.js +189 -0
- package/dist/register/status-file/team-css.js +5 -0
- package/dist/register/status-file-sync.js +22 -0
- package/dist/register/status-html.js +19 -0
- package/dist/register/store.js +40 -2
- package/dist/register/workbooks.js +124 -0
- package/dist/register/write-whole.js +22 -0
- package/dist/register/xlsx.js +127 -0
- package/dist/register/xref.js +117 -0
- package/dist/renderer/startup-lines.js +11 -0
- package/dist/renderer/tool-labels.js +3 -1
- package/dist/renderer/tool-widget.js +15 -1
- package/dist/repl/at-completer.js +6 -2
- package/dist/repl/at-picker.js +1 -1
- package/dist/repl/journey-entry.js +25 -0
- package/dist/repl.js +158 -39
- package/dist/session/backfill.js +342 -0
- package/dist/session/project-match.js +63 -0
- package/dist/session/resume.js +72 -24
- package/dist/session/schema.js +3 -1
- package/dist/session/store.js +96 -51
- package/dist/skill-catalog.js +2 -2
- package/dist/skills/bundled-skills.js +45 -45
- package/dist/tools/_filesystem-shared.js +190 -21
- package/dist/tools/_team-work-path.js +133 -0
- package/dist/tools/approval.js +7 -1
- package/dist/tools/filesystem/extract-xlsx.js +27 -3
- package/dist/tools/filesystem/file-edit.js +12 -4
- package/dist/tools/filesystem/file-read.js +8 -4
- package/dist/tools/filesystem/file-write.js +12 -4
- package/dist/tools/filesystem/glob.js +33 -8
- package/dist/tools/filesystem/grep.js +9 -4
- package/dist/tools/filesystem/read-document.js +6 -3
- package/dist/tools/project/cca_merge.js +8 -2
- package/dist/tools/project/team_estate.js +91 -0
- package/dist/tools/project/team_findings.js +145 -0
- package/dist/tools/project/upgrade_merge_progress.js +8 -2
- package/dist/tools/sap-read.js +21 -4
- package/dist/tools/sap-write.js +203 -5
- package/dist/tools/subagent/read_agent.js +3 -0
- package/dist/tools/tool-loading.js +2 -2
- package/dist/tools/transport-fit-check.js +117 -0
- package/dist/tools/transport-fit.js +15 -7
- package/dist/tools/transport.js +57 -2
- package/dist/ui/app.js +2 -2
- package/dist/ui/approval-modal.js +74 -6
- package/dist/ui/widgets/confirm-card.js +68 -5
- package/dist/util/open-in-browser.js +143 -0
- package/package.json +120 -120
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import * as path from 'node:path';
|
|
11
11
|
import { promises as fsPromises, realpathSync, existsSync } from 'node:fs';
|
|
12
|
-
import { cspeachRoot } from '../config/paths.js';
|
|
12
|
+
import { cspeachRoot, defaultCspeachRoot } from '../config/paths.js';
|
|
13
|
+
import { SAFE_PROJECT_ID } from '../projects/project-folder.js';
|
|
13
14
|
/**
|
|
14
15
|
* Phase 3 sensitive in-root directory prefixes — refused by all filesystem
|
|
15
16
|
* tools. Matched as `rel === prefix` or `rel` starts with `prefix + '/'`.
|
|
@@ -190,21 +191,21 @@ function relIsDenylisted(rel) {
|
|
|
190
191
|
* so the caller must fail closed — `{ failClosed: true }`. Returns null only
|
|
191
192
|
* when nothing up to the drive root exists.
|
|
192
193
|
*/
|
|
193
|
-
function longNameAbs(absPath) {
|
|
194
|
-
let cur =
|
|
194
|
+
function longNameAbs(absPath, p = path.win32) {
|
|
195
|
+
let cur = p.resolve(absPath);
|
|
195
196
|
const tail = [];
|
|
196
197
|
for (;;) {
|
|
197
198
|
try {
|
|
198
199
|
const real = realpathSync.native(cur);
|
|
199
|
-
return { abs: tail.length ?
|
|
200
|
+
return { abs: tail.length ? p.join(real, ...tail) : real };
|
|
200
201
|
}
|
|
201
202
|
catch (err) {
|
|
202
203
|
if (err?.code !== 'ENOENT')
|
|
203
204
|
return { failClosed: true };
|
|
204
|
-
const parent =
|
|
205
|
+
const parent = p.dirname(cur);
|
|
205
206
|
if (parent === cur)
|
|
206
207
|
return null;
|
|
207
|
-
tail.unshift(
|
|
208
|
+
tail.unshift(p.basename(cur));
|
|
208
209
|
cur = parent;
|
|
209
210
|
}
|
|
210
211
|
}
|
|
@@ -229,18 +230,25 @@ function relIsInside(p, rel) {
|
|
|
229
230
|
return rel === '' || (!rel.startsWith('..') && !p.isAbsolute(rel));
|
|
230
231
|
}
|
|
231
232
|
/**
|
|
232
|
-
* True if `absPath` is the CLI home (`cspeachRoot()
|
|
233
|
-
*
|
|
233
|
+
* True if `absPath` is the CLI home (`cspeachRoot()`) or `~/.cspeach`, or below
|
|
234
|
+
* either — auth tokens, config, sessions, snapshots, logs. Checked against the
|
|
234
235
|
* spelled and the real home, and through NTFS spellings of the `.cspeach`
|
|
235
236
|
* segment (`.cspeach.`, `.cspeach `), case-insensitively.
|
|
236
237
|
*/
|
|
237
238
|
function isUnderCliHome(absPath, p, native) {
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
239
|
+
// Both homes: the one CSPEACH_HOME chose AND ~/.cspeach, which still holds
|
|
240
|
+
// history, logs, streams — and maybe an auth.json from before the move.
|
|
241
|
+
const chosen = [cspeachRoot(), defaultCspeachRoot()];
|
|
242
|
+
const homes = [];
|
|
243
|
+
for (const home of chosen) {
|
|
244
|
+
const spelled = p.resolve(home);
|
|
245
|
+
if (!homes.includes(spelled))
|
|
246
|
+
homes.push(spelled);
|
|
247
|
+
if (native) {
|
|
248
|
+
const real = realDirOrNull(home);
|
|
249
|
+
if (real && !homes.includes(real))
|
|
250
|
+
homes.push(real);
|
|
251
|
+
}
|
|
244
252
|
}
|
|
245
253
|
for (const h of homes) {
|
|
246
254
|
const fromParent = p.relative(p.dirname(h), absPath).replace(/\\/g, '/');
|
|
@@ -252,6 +260,140 @@ function isUnderCliHome(absPath, p, native) {
|
|
|
252
260
|
}
|
|
253
261
|
return false;
|
|
254
262
|
}
|
|
263
|
+
/**
|
|
264
|
+
* The segments of `abs` below `<cspeachRoot()>/work/<SAFE id>/.cspeach/`
|
|
265
|
+
* (lowercased on win32), or null when `abs` is not there under the spelled or
|
|
266
|
+
* the real seat. Null also for ANY segment below the seat that is empty, `.`,
|
|
267
|
+
* `..`, carries ':' (stream), '~' (possible 8.3 name), a trailing dot/space,
|
|
268
|
+
* is `.git`, or is a Windows device name — fail closed, never decoded.
|
|
269
|
+
* Pure string logic on the seat's two spellings; callers pass both the spelled
|
|
270
|
+
* and the real form of the path.
|
|
271
|
+
*/
|
|
272
|
+
function seatTail(abs, p, isWin) {
|
|
273
|
+
const cmp = seatSegs(abs, p, isWin);
|
|
274
|
+
if (!cmp || cmp.length < 4 || cmp[0] !== 'work' || !SAFE_PROJECT_ID.test(cmp[1]) || cmp[2] !== '.cspeach')
|
|
275
|
+
return null;
|
|
276
|
+
return cmp.slice(3);
|
|
277
|
+
}
|
|
278
|
+
/** The checked segments of `abs` below the seat (see seatTail), or null. */
|
|
279
|
+
function seatSegs(abs, p, isWin) {
|
|
280
|
+
const seat = cspeachRoot();
|
|
281
|
+
const seats = [p.resolve(seat)];
|
|
282
|
+
const realSeat = realDirOrNull(seat);
|
|
283
|
+
if (realSeat && !seats.includes(realSeat))
|
|
284
|
+
seats.push(realSeat);
|
|
285
|
+
for (const s of seats) {
|
|
286
|
+
const rel = p.relative(s, abs).replace(/\\/g, '/');
|
|
287
|
+
if (rel === '' || !relIsInside(p, rel))
|
|
288
|
+
continue;
|
|
289
|
+
const segs = rel.split('/');
|
|
290
|
+
const bad = (seg) => seg === '' || seg === '.' || seg === '..' || seg.includes(':') || seg.includes('~')
|
|
291
|
+
|| ntfsCanonicalSegment(seg) !== seg || seg.toLowerCase() === '.git' || (isWin && isWindowsReservedName(seg));
|
|
292
|
+
if (segs.some(bad))
|
|
293
|
+
return null;
|
|
294
|
+
return isWin ? segs.map((x) => x.toLowerCase()) : segs;
|
|
295
|
+
}
|
|
296
|
+
return null;
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Fix round 1 (security review) — the anchor. The REAL path of `root`, and
|
|
300
|
+
* the real `absPath` relative to it ('/'-separated), when the real root is
|
|
301
|
+
* exactly a seat work root `<cspeachRoot()>/work/<SAFE id>` AND the real
|
|
302
|
+
* form of `absPath` sits inside it; else null. The seat opening applies only
|
|
303
|
+
* then: a session started in the home folder, its parent, a team folder or
|
|
304
|
+
* the seat itself never reaches a seat file through the file tools.
|
|
305
|
+
*/
|
|
306
|
+
function workRootFor(root, absPath, platform) {
|
|
307
|
+
const r = spelledAndReal(root, platform);
|
|
308
|
+
const a = spelledAndReal(absPath, platform);
|
|
309
|
+
if (!r || !a)
|
|
310
|
+
return null;
|
|
311
|
+
const realRoot = r.forms[1];
|
|
312
|
+
const segs = seatSegs(realRoot, r.p, r.isWin);
|
|
313
|
+
if (!segs || segs.length !== 2 || segs[0] !== 'work' || !SAFE_PROJECT_ID.test(segs[1]))
|
|
314
|
+
return null;
|
|
315
|
+
const rel = r.p.relative(realRoot, a.forms[1]).replace(/\\/g, '/');
|
|
316
|
+
return rel !== '' && relIsInside(r.p, rel) ? { root: realRoot, rel } : null;
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* Both forms of `absPath` — as spelled, and real (nearest existing ancestor
|
|
320
|
+
* realpath'd, which also expands 8.3 names; missing tail re-attached) — or
|
|
321
|
+
* null when the real form cannot be known (any lookup error but "does not
|
|
322
|
+
* exist", or a platform other than the one we run on): fail closed.
|
|
323
|
+
*/
|
|
324
|
+
function spelledAndReal(absPath, platform) {
|
|
325
|
+
if (platform !== process.platform)
|
|
326
|
+
return null;
|
|
327
|
+
const isWin = platform === 'win32';
|
|
328
|
+
const p = isWin ? path.win32 : path.posix;
|
|
329
|
+
const spelled = p.resolve(absPath);
|
|
330
|
+
const real = longNameAbs(spelled, p);
|
|
331
|
+
if (!real || 'failClosed' in real)
|
|
332
|
+
return null;
|
|
333
|
+
return { p, isWin, forms: [spelled, real.abs] };
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* Task 14 (2026-10-08) — the ONE opening in the CLI home for the file tools:
|
|
337
|
+
* true only when `absPath`, spelled AND real, is strictly inside
|
|
338
|
+
* `<cspeachRoot()>/work/<id>/.cspeach/<sub>/` with `<id>` matching
|
|
339
|
+
* SAFE_PROJECT_ID and `<sub>` in CSPEACH_WORK_SUBDIRS; no segment below the
|
|
340
|
+
* seat carries ':' / '~' / a trailing dot or space / `.git` / a device name;
|
|
341
|
+
* the basename is not a `*.cspeach.json` envelope and not `.env*`. A link
|
|
342
|
+
* anywhere on the way (a junction to the seat, to sessions, out of the seat)
|
|
343
|
+
* fails it, because the real form must match too. Only the seat CSPEACH_HOME
|
|
344
|
+
* chose opens — never `~/.cspeach` when CSPEACH_HOME names another seat.
|
|
345
|
+
*/
|
|
346
|
+
export function isSeatWorkPath(absPath, platform = process.platform) {
|
|
347
|
+
const both = spelledAndReal(absPath, platform);
|
|
348
|
+
if (!both)
|
|
349
|
+
return false;
|
|
350
|
+
return both.forms.every((form) => {
|
|
351
|
+
const tail = seatTail(form, both.p, both.isWin);
|
|
352
|
+
if (!tail || tail.length < 2 || !CSPEACH_WORK_SUBDIRS.includes(tail[0]))
|
|
353
|
+
return false;
|
|
354
|
+
const base = tail[tail.length - 1];
|
|
355
|
+
return !base.toLowerCase().endsWith('.cspeach.json') && !BLOCKED_BASENAME_RE.test(base);
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
/**
|
|
359
|
+
* Task 14 — the READ-ONLY seat shapes: a saved result directly in the seat
|
|
360
|
+
* working folder, `<cspeachRoot()>/work/<id>/.cspeach/<name>.cspeach.json`,
|
|
361
|
+
* or a lane decision, `<cspeachRoot()>/work/<id>/.cspeach/lane/<name>.json`.
|
|
362
|
+
* Same rules as isSeatWorkPath (spelled AND real, fail closed). The write
|
|
363
|
+
* tools never consult this.
|
|
364
|
+
*/
|
|
365
|
+
export function isSeatReadOnlyFile(absPath, platform = process.platform) {
|
|
366
|
+
const both = spelledAndReal(absPath, platform);
|
|
367
|
+
if (!both)
|
|
368
|
+
return false;
|
|
369
|
+
return both.forms.every((form) => {
|
|
370
|
+
const tail = seatTail(form, both.p, both.isWin);
|
|
371
|
+
if (!tail)
|
|
372
|
+
return false;
|
|
373
|
+
const base = tail[tail.length - 1];
|
|
374
|
+
if (BLOCKED_BASENAME_RE.test(base))
|
|
375
|
+
return false;
|
|
376
|
+
if (tail.length === 1)
|
|
377
|
+
return /\.cspeach\.json$/i.test(base);
|
|
378
|
+
return tail.length === 2 && tail[0] === 'lane' && /\.json$/i.test(base);
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* True when `absPath` — spelled or real (nearest existing ancestor) — is the
|
|
383
|
+
* CLI home (`cspeachRoot()` or `~/.cspeach`) or below it. Fails closed: a path
|
|
384
|
+
* whose real form cannot be known counts as inside.
|
|
385
|
+
*/
|
|
386
|
+
export function isCliHomePath(absPath, platform = process.platform) {
|
|
387
|
+
const isWin = platform === 'win32';
|
|
388
|
+
const p = isWin ? path.win32 : path.posix;
|
|
389
|
+
const native = platform === process.platform;
|
|
390
|
+
if (isUnderCliHome(p.resolve(absPath), p, native))
|
|
391
|
+
return true;
|
|
392
|
+
const both = spelledAndReal(absPath, platform);
|
|
393
|
+
if (!both)
|
|
394
|
+
return true;
|
|
395
|
+
return isUnderCliHome(both.forms[1], p, native);
|
|
396
|
+
}
|
|
255
397
|
/**
|
|
256
398
|
* True if `realAbsPath` is denylisted relative to root — either it falls under
|
|
257
399
|
* one of BLOCKED_PREFIXES (directory prefixes) or its basename is in the
|
|
@@ -279,6 +421,17 @@ function isUnderCliHome(absPath, p, native) {
|
|
|
279
421
|
* also when the CLI runs from `C:\Users`, `C:\` or `/home`, where it is not a
|
|
280
422
|
* root-level `.cspeach`. The carve-out does not apply there.
|
|
281
423
|
*
|
|
424
|
+
* ONE ANCHORED EXCEPTION (Task 14, 2026-10-08): a team seat's working files,
|
|
425
|
+
* `<cspeachRoot()>/work/<SAFE id>/.cspeach/<cca|upgrades|modernize|tests>/…`
|
|
426
|
+
* (isSeatWorkPath: spelled AND real path, no stream / '~' / trailing dot or
|
|
427
|
+
* space / .git / device-name segment, no *.cspeach.json, no .env*), are
|
|
428
|
+
* readable and writable ONLY when the REAL root is exactly that path's work
|
|
429
|
+
* root `<cspeachRoot()>/work/<id>` (workRootFor). The path is then judged
|
|
430
|
+
* relative to the real work root alone, with every rule below still applied.
|
|
431
|
+
* Any other root — the home folder, its parent, a team folder, the seat, a
|
|
432
|
+
* sibling work root — keeps the whole CLI home blocked, so a project run from
|
|
433
|
+
* the home folder gets nothing.
|
|
434
|
+
*
|
|
282
435
|
* WINDOWS NAME ALIASES (SEC fix, `.git::$INDEX_ALLOCATION`). NTFS opens the
|
|
283
436
|
* same object under spellings that do not byte-match the denylist:
|
|
284
437
|
* - stream suffixes: `.git::$INDEX_ALLOCATION`, `.git:$I30:$INDEX_ALLOCATION`,
|
|
@@ -331,10 +484,19 @@ export function isDenylistedPath(realAbsPath, root, platform = process.platform)
|
|
|
331
484
|
relFromRoot = realRel;
|
|
332
485
|
}
|
|
333
486
|
}
|
|
487
|
+
// The CLI home is protected whatever the root — except exactly the seat
|
|
488
|
+
// working subfolders (isSeatWorkPath), and only when the real root IS that
|
|
489
|
+
// path's work root (workRootFor). Then the path is judged relative to the
|
|
490
|
+
// real work root alone (a root spelled through a link, on another drive or
|
|
491
|
+
// a share, cannot change the verdict).
|
|
492
|
+
if (isUnderCliHome(realAbsPath, p, native)) {
|
|
493
|
+
const work = isSeatWorkPath(realAbsPath, platform) ? workRootFor(root, realAbsPath, platform) : null;
|
|
494
|
+
if (work === null)
|
|
495
|
+
return { blocked: true, relFromRoot };
|
|
496
|
+
relFromRoot = work.rel;
|
|
497
|
+
rels.splice(0, rels.length, work.rel);
|
|
498
|
+
}
|
|
334
499
|
const blocked = { blocked: true, relFromRoot };
|
|
335
|
-
// The CLI home is protected whatever the root.
|
|
336
|
-
if (isUnderCliHome(realAbsPath, p, native))
|
|
337
|
-
return blocked;
|
|
338
500
|
for (const rel of rels) {
|
|
339
501
|
const segments = rel.split('/');
|
|
340
502
|
// (1) win32: an NTFS stream / stream-type suffix anywhere → refuse.
|
|
@@ -382,7 +544,11 @@ export function isDenylistedPath(realAbsPath, root, platform = process.platform)
|
|
|
382
544
|
*
|
|
383
545
|
* Narrow by construction — true only when ALL hold:
|
|
384
546
|
* - the path is NOT under the CLI home (`~/.cspeach`: auth, config, sessions),
|
|
385
|
-
* whatever the root (a project run from the home folder gets nothing)
|
|
547
|
+
* whatever the root (a project run from the home folder gets nothing) —
|
|
548
|
+
* with ONE anchored exception (Task 14): the seat shapes
|
|
549
|
+
* `<cspeachRoot()>/work/<id>/.cspeach/<name>.cspeach.json` and
|
|
550
|
+
* `.cspeach/lane/<name>.json` (isSeatReadOnlyFile), only when the REAL
|
|
551
|
+
* root is exactly that file's work root `<cspeachRoot()>/work/<id>`;
|
|
386
552
|
* - it sits inside the spelled root, at exactly one of the two shapes above
|
|
387
553
|
* (no deeper nesting, no other `.cspeach` file such as conventions.toml);
|
|
388
554
|
* - no NTFS alias spelling: no ':' segment, no trailing dot/space segment,
|
|
@@ -394,8 +560,11 @@ export function isReadOnlyProjectFile(realAbsPath, root, platform = process.plat
|
|
|
394
560
|
const isWin = platform === 'win32';
|
|
395
561
|
const native = platform === process.platform;
|
|
396
562
|
const p = isWin ? path.win32 : path.posix;
|
|
397
|
-
|
|
398
|
-
|
|
563
|
+
// Under the CLI home only the two read-only seat shapes (Task 14), and only
|
|
564
|
+
// when the real root IS that file's work root (fix round 1).
|
|
565
|
+
if (isUnderCliHome(realAbsPath, p, native)) {
|
|
566
|
+
return isSeatReadOnlyFile(realAbsPath, platform) && workRootFor(root, realAbsPath, platform) !== null;
|
|
567
|
+
}
|
|
399
568
|
const rel = p.relative(p.resolve(root), realAbsPath).replace(/\\/g, '/');
|
|
400
569
|
if (!relIsInside(p, rel) || rel === '')
|
|
401
570
|
return false;
|
|
@@ -476,7 +645,7 @@ export function isInsideRoot(target, root) {
|
|
|
476
645
|
* Real form of a folder that may not exist yet: nearest existing ancestor
|
|
477
646
|
* realpath'd, missing tail re-attached. null on any non-ENOENT error.
|
|
478
647
|
*/
|
|
479
|
-
function realOfNearestOrNull(abs) {
|
|
648
|
+
export function realOfNearestOrNull(abs) {
|
|
480
649
|
let cur = abs;
|
|
481
650
|
const tail = [];
|
|
482
651
|
for (;;) {
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// cspeach-cli/src/tools/_team-work-path.ts
|
|
2
|
+
/**
|
|
3
|
+
* Task 15 (2026-10-08 sessions/folders) — in a TEAM folder (a shared folder
|
|
4
|
+
* holding cspeach-team.json) the skills' personal working files,
|
|
5
|
+
* `.cspeach/<cca|upgrades|modernize|tests>/…`, belong to the person's seat
|
|
6
|
+
* work root `<CSPEACH_HOME>/work/<projectId>`, not to the shared folder.
|
|
7
|
+
*
|
|
8
|
+
* teamWorkTarget is a PURE path mapper: it picks the root a file tool should
|
|
9
|
+
* work against and hands back the same relative path. It opens nothing on its
|
|
10
|
+
* own — every tool still runs its full pipeline (resolveSafePath containment,
|
|
11
|
+
* real-path checks, denylist, write checks) against the root it returns, and
|
|
12
|
+
* the seat opening in the denylist applies only when that root is exactly the
|
|
13
|
+
* work root.
|
|
14
|
+
*
|
|
15
|
+
* solo / home folder → { root: cwd, path: userPath } (unchanged)
|
|
16
|
+
* team folder, `.cspeach/<sub>` → { root: workRoot, path: <same relative path> }
|
|
17
|
+
* team folder, anything else → { root: cwd, path: userPath } (unchanged)
|
|
18
|
+
*
|
|
19
|
+
* The `.cspeach/<sub>` test compares each segment the way Windows opens it
|
|
20
|
+
* (case-insensitive, `:stream` and trailing dots/spaces dropped), so an alias
|
|
21
|
+
* spelling never slips a working file into the shared folder; in the seat the
|
|
22
|
+
* denylist then refuses the alias outright.
|
|
23
|
+
*/
|
|
24
|
+
import { existsSync, realpathSync } from 'node:fs';
|
|
25
|
+
import * as path from 'node:path';
|
|
26
|
+
import { CSPEACH_WORK_SUBDIRS } from './_filesystem-shared.js';
|
|
27
|
+
import { resolveProjectFolderSync } from '../projects/project-folder.js';
|
|
28
|
+
/** A segment as NTFS resolves it for a name compare, lowercased. */
|
|
29
|
+
function nameKey(seg) {
|
|
30
|
+
const colon = seg.indexOf(':');
|
|
31
|
+
return (colon === -1 ? seg : seg.slice(0, colon)).replace(/[. ]+$/, '').toLowerCase();
|
|
32
|
+
}
|
|
33
|
+
/** The seat work root when `cwd` is a team folder, else null. */
|
|
34
|
+
export function teamWorkRoot(cwd) {
|
|
35
|
+
try {
|
|
36
|
+
const pf = resolveProjectFolderSync(cwd);
|
|
37
|
+
return pf.kind === 'team' ? pf.workRoot : null;
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
export function teamWorkTarget(cwd, userPath) {
|
|
44
|
+
const same = { root: cwd, path: userPath };
|
|
45
|
+
if (typeof userPath !== 'string' || userPath.includes('\0'))
|
|
46
|
+
return same;
|
|
47
|
+
const workRoot = teamWorkRoot(cwd);
|
|
48
|
+
if (workRoot === null)
|
|
49
|
+
return same;
|
|
50
|
+
const absRoot = path.resolve(cwd);
|
|
51
|
+
const abs = path.isAbsolute(userPath) ? path.resolve(userPath) : path.resolve(absRoot, userPath);
|
|
52
|
+
const rel = path.relative(absRoot, abs);
|
|
53
|
+
if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel))
|
|
54
|
+
return same;
|
|
55
|
+
return isWorkRel(rel) ? { root: workRoot, path: rel } : same;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Ruling 27 — the READ twin of teamWorkTarget, for file_read and glob only.
|
|
59
|
+
* Besides the working files it maps the two read-only shapes a save leaves in
|
|
60
|
+
* the seat — `.cspeach/<name>.cspeach.json` and `.cspeach/lane/<name>.json` —
|
|
61
|
+
* so reading your own saved results looks where they were saved, as in a solo
|
|
62
|
+
* folder. Writes never use this: those shapes stay CLI-owned.
|
|
63
|
+
*/
|
|
64
|
+
export function teamReadTarget(cwd, userPath) {
|
|
65
|
+
const same = { root: cwd, path: userPath };
|
|
66
|
+
const work = teamWorkTarget(cwd, userPath);
|
|
67
|
+
if (work.root !== cwd || typeof userPath !== 'string' || userPath.includes('\0'))
|
|
68
|
+
return work;
|
|
69
|
+
const workRoot = teamWorkRoot(cwd);
|
|
70
|
+
if (workRoot === null)
|
|
71
|
+
return same;
|
|
72
|
+
const absRoot = path.resolve(cwd);
|
|
73
|
+
const abs = path.isAbsolute(userPath) ? path.resolve(userPath) : path.resolve(absRoot, userPath);
|
|
74
|
+
const rel = path.relative(absRoot, abs);
|
|
75
|
+
if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel))
|
|
76
|
+
return same;
|
|
77
|
+
if (!isReadOnlyRel(rel))
|
|
78
|
+
return same;
|
|
79
|
+
// Final fix wave, item 1 — a save made before this release sits in the
|
|
80
|
+
// shared <team>/.cspeach. The seat copy comes first; only when the seat has
|
|
81
|
+
// none and the shared one exists is the shared one read (read-only: the
|
|
82
|
+
// write tools never use this function).
|
|
83
|
+
if (!existsSync(path.join(workRoot, rel)) && existsSync(path.join(absRoot, rel)))
|
|
84
|
+
return same;
|
|
85
|
+
return { root: workRoot, path: rel };
|
|
86
|
+
}
|
|
87
|
+
/** True for `.cspeach/<name>.cspeach.json` or `.cspeach/lane/<name>.json` (either separator). */
|
|
88
|
+
export function isReadOnlyRel(rel) {
|
|
89
|
+
const segs = rel.split(/[\\/]+/);
|
|
90
|
+
if (nameKey(segs[0] ?? '') !== '.cspeach')
|
|
91
|
+
return false;
|
|
92
|
+
if (segs.length === 2)
|
|
93
|
+
return /\.cspeach\.json$/i.test(nameKey(segs[1]));
|
|
94
|
+
return segs.length === 3 && nameKey(segs[1]) === 'lane' && /\.json$/i.test(nameKey(segs[2]));
|
|
95
|
+
}
|
|
96
|
+
/** True when a root-relative path (either separator) is `.cspeach/<work-subdir>` or below. */
|
|
97
|
+
export function isWorkRel(rel) {
|
|
98
|
+
const segs = rel.split(/[\\/]+/);
|
|
99
|
+
return segs.length >= 2 && nameKey(segs[0]) === '.cspeach'
|
|
100
|
+
&& CSPEACH_WORK_SUBDIRS.includes(nameKey(segs[1]));
|
|
101
|
+
}
|
|
102
|
+
/** Final fix wave, item 3 — the refusal for a write into the shared `.cspeach`. */
|
|
103
|
+
export const SHARED_CSPEACH_REFUSAL = 'In a team folder, working files go to your own folder; this path points into the shared .cspeach.';
|
|
104
|
+
/**
|
|
105
|
+
* True when `cwd` is a team folder and the REAL landing path of a write
|
|
106
|
+
* (links followed, 8.3 names expanded) is inside the shared `<team>/.cspeach`
|
|
107
|
+
* — whatever spelling, junction or short name got it there. Only refuses;
|
|
108
|
+
* never maps anything.
|
|
109
|
+
*/
|
|
110
|
+
export function pointsIntoSharedCspeach(cwd, realTarget) {
|
|
111
|
+
if (teamWorkRoot(cwd) === null)
|
|
112
|
+
return false;
|
|
113
|
+
const absRoot = path.resolve(cwd);
|
|
114
|
+
const realOf = (p) => { try {
|
|
115
|
+
return realpathSync.native(p);
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
return null;
|
|
119
|
+
} };
|
|
120
|
+
const relUnder = (base) => {
|
|
121
|
+
const rel = path.relative(base, realTarget);
|
|
122
|
+
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel)) ? rel : null;
|
|
123
|
+
};
|
|
124
|
+
const realShared = realOf(path.join(absRoot, '.cspeach'));
|
|
125
|
+
if (realShared !== null && relUnder(realShared) !== null)
|
|
126
|
+
return true;
|
|
127
|
+
for (const base of new Set([absRoot, realOf(absRoot) ?? absRoot])) {
|
|
128
|
+
const rel = relUnder(base);
|
|
129
|
+
if (rel !== null && rel !== '' && nameKey(rel.split(/[\\/]+/)[0]) === '.cspeach')
|
|
130
|
+
return true;
|
|
131
|
+
}
|
|
132
|
+
return false;
|
|
133
|
+
}
|
package/dist/tools/approval.js
CHANGED
|
@@ -284,8 +284,14 @@ registerTool({
|
|
|
284
284
|
sapCfg = cfg.sap[ctx.sapAlias];
|
|
285
285
|
riskCtx.autoApprovePolicy = sapCfg?.auto_approve ?? 'never';
|
|
286
286
|
}
|
|
287
|
+
// 1.1.31 (owner, 2026-10-07): a package or transport always gets a person's
|
|
288
|
+
// yes. Live, auto_approve="low" let the model's own "low" create package
|
|
289
|
+
// ZCC_FIX_PROOF and request S4HK903757 with no card. The plan-grant path
|
|
290
|
+
// above is separate and unchanged (a plan the person approved may carry
|
|
291
|
+
// its one new transport).
|
|
292
|
+
const touchesInfra = changes.some((c) => INFRA_BASE_OBJECT_TYPES.has((c.type ?? '').trim().toUpperCase()));
|
|
287
293
|
// Auto-approve caps — never auto-approve at high, cap change count per risk level.
|
|
288
|
-
const autoApproveAllowed = !guardedRun && !qasNoAutoApprove && ((sapCfg?.auto_approve === 'low' && eff.level === 'low' && changes.length <= 5) ||
|
|
294
|
+
const autoApproveAllowed = !guardedRun && !qasNoAutoApprove && !touchesInfra && ((sapCfg?.auto_approve === 'low' && eff.level === 'low' && changes.length <= 5) ||
|
|
289
295
|
(sapCfg?.auto_approve === 'medium' && (eff.level === 'low' || eff.level === 'medium') && changes.length <= 2));
|
|
290
296
|
if (autoApproveAllowed) {
|
|
291
297
|
const ids = [];
|
|
@@ -43,10 +43,11 @@ export function colIndex(ref) {
|
|
|
43
43
|
return n - 1;
|
|
44
44
|
}
|
|
45
45
|
/** One worksheet's cells as a rectangular grid of strings. */
|
|
46
|
-
export function sheetRows(xml, strings, budget) {
|
|
46
|
+
export function sheetRows(xml, strings, budget, opts = {}) {
|
|
47
|
+
const maxRows = opts.maxRows ?? MAX_SHEET_ROWS;
|
|
47
48
|
const rows = [];
|
|
48
49
|
for (const rowMatch of xml.matchAll(/<row[^>]*>([\s\S]*?)<\/row>/g)) {
|
|
49
|
-
if (rows.length >=
|
|
50
|
+
if (rows.length >= maxRows || budget.cells <= 0)
|
|
50
51
|
break;
|
|
51
52
|
const cells = [];
|
|
52
53
|
for (const cell of rowMatch[1].matchAll(/<c\b([^>]*)>([\s\S]*?)<\/c>|<c\b([^>]*)\/>/g)) {
|
|
@@ -68,7 +69,7 @@ export function sheetRows(xml, strings, budget) {
|
|
|
68
69
|
}
|
|
69
70
|
while (cells.length < at)
|
|
70
71
|
cells.push('');
|
|
71
|
-
cells[at] = value.replace(/[\t\r\n]+/g, ' ').trim();
|
|
72
|
+
cells[at] = (opts.keepLines ? value.replace(/\r\n?/g, '\n') : value.replace(/[\t\r\n]+/g, ' ')).trim();
|
|
72
73
|
budget.cells -= 1;
|
|
73
74
|
if (budget.cells <= 0)
|
|
74
75
|
break;
|
|
@@ -137,3 +138,26 @@ export function extractXlsx(buffer) {
|
|
|
137
138
|
warnings.push('no cell text found — the workbook may be empty or hold only charts/images');
|
|
138
139
|
return { text, sheets: sheets.length, warnings };
|
|
139
140
|
}
|
|
141
|
+
/**
|
|
142
|
+
* Every sheet as rows of cell text, for a caller that reads a workbook back as
|
|
143
|
+
* data (the Plan sheet, spec 2026-10-06 §8). Numbers come back as their text
|
|
144
|
+
* (`2`, `46000`), shared and inline strings as written, line breaks inside a
|
|
145
|
+
* cell kept. No row cap: a Plan sheet holds one row per object. Throws on a
|
|
146
|
+
* file that is not a readable .xlsx.
|
|
147
|
+
*/
|
|
148
|
+
export function readSheets(buffer) {
|
|
149
|
+
const zip = new AdmZip(buffer);
|
|
150
|
+
const read = (p) => {
|
|
151
|
+
const entry = zip.getEntry(p);
|
|
152
|
+
return entry ? entry.getData().toString('utf-8') : null;
|
|
153
|
+
};
|
|
154
|
+
const strings = sharedStrings(read('xl/sharedStrings.xml'));
|
|
155
|
+
const budget = { cells: Number.POSITIVE_INFINITY };
|
|
156
|
+
const out = [];
|
|
157
|
+
for (const s of sheetOrder(read('xl/workbook.xml'), read('xl/_rels/workbook.xml.rels'))) {
|
|
158
|
+
const xml = read(s.path);
|
|
159
|
+
if (xml !== null)
|
|
160
|
+
out.push({ name: s.name, rows: sheetRows(xml, strings, budget, { maxRows: Number.POSITIVE_INFINITY, keepLines: true }) });
|
|
161
|
+
}
|
|
162
|
+
return out;
|
|
163
|
+
}
|
|
@@ -15,6 +15,7 @@ import { promises as fs } from 'node:fs';
|
|
|
15
15
|
import * as path from 'node:path';
|
|
16
16
|
import { registerTool } from '../index.js';
|
|
17
17
|
import { resolveSafePath, assertRealPathContained, PathOutsideRootError, DENYLIST_DESCRIPTION, isDenylistedPath, blockedExecutableExtension, } from '../_filesystem-shared.js';
|
|
18
|
+
import { pointsIntoSharedCspeach, SHARED_CSPEACH_REFUSAL, teamWorkTarget } from '../_team-work-path.js';
|
|
18
19
|
export async function fileEditHandler(args, ctx) {
|
|
19
20
|
// 1. Validate args before any IO
|
|
20
21
|
if (args.old_string === args.new_string) {
|
|
@@ -24,9 +25,11 @@ export async function fileEditHandler(args, ctx) {
|
|
|
24
25
|
};
|
|
25
26
|
}
|
|
26
27
|
// 2. Path containment — sync phase (pure string math)
|
|
28
|
+
// Task 15 — in a team folder a skill working file is edited in the seat.
|
|
29
|
+
const t = teamWorkTarget(ctx.cwd, args.path);
|
|
27
30
|
let abs;
|
|
28
31
|
try {
|
|
29
|
-
abs = resolveSafePath(
|
|
32
|
+
abs = resolveSafePath(t.root, t.path);
|
|
30
33
|
}
|
|
31
34
|
catch (err) {
|
|
32
35
|
if (err instanceof PathOutsideRootError) {
|
|
@@ -37,7 +40,7 @@ export async function fileEditHandler(args, ctx) {
|
|
|
37
40
|
// 3. Path containment — async phase (follows symlinks)
|
|
38
41
|
let realAbs;
|
|
39
42
|
try {
|
|
40
|
-
realAbs = await assertRealPathContained(abs,
|
|
43
|
+
realAbs = await assertRealPathContained(abs, t.root);
|
|
41
44
|
}
|
|
42
45
|
catch (err) {
|
|
43
46
|
if (err instanceof PathOutsideRootError) {
|
|
@@ -49,7 +52,7 @@ export async function fileEditHandler(args, ctx) {
|
|
|
49
52
|
return { content: `error: ${err instanceof Error ? err.message : String(err)}`, is_error: true };
|
|
50
53
|
}
|
|
51
54
|
// 4. Denylist check: refuse sensitive in-root paths
|
|
52
|
-
const denylist = isDenylistedPath(realAbs,
|
|
55
|
+
const denylist = isDenylistedPath(realAbs, t.root);
|
|
53
56
|
if (denylist.blocked) {
|
|
54
57
|
return {
|
|
55
58
|
content: `error: refusing to edit sensitive path "${denylist.relFromRoot}" (denylist: ${DENYLIST_DESCRIPTION})`,
|
|
@@ -66,6 +69,11 @@ export async function fileEditHandler(args, ctx) {
|
|
|
66
69
|
is_error: true,
|
|
67
70
|
};
|
|
68
71
|
}
|
|
72
|
+
// 4c. Team folder: the real path never lies in the shared .cspeach
|
|
73
|
+
// (an 8.3 name, a junction or an odd spelling would get it there).
|
|
74
|
+
if (pointsIntoSharedCspeach(ctx.cwd, realAbs)) {
|
|
75
|
+
return { content: `error: ${SHARED_CSPEACH_REFUSAL}`, is_error: true };
|
|
76
|
+
}
|
|
69
77
|
// 5. Read the file
|
|
70
78
|
let content;
|
|
71
79
|
try {
|
|
@@ -114,7 +122,7 @@ export async function fileEditHandler(args, ctx) {
|
|
|
114
122
|
return { content: `error: write failed — ${err.message}`, is_error: true };
|
|
115
123
|
}
|
|
116
124
|
const noun = replacementCount === 1 ? 'replacement' : 'replacements';
|
|
117
|
-
const relPath = path.relative(path.resolve(
|
|
125
|
+
const relPath = path.relative(path.resolve(t.root), realAbs).replace(/\\/g, '/');
|
|
118
126
|
return { content: `edited ${relPath} (${replacementCount} ${noun})` };
|
|
119
127
|
}
|
|
120
128
|
registerTool({
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import { promises as fs } from 'node:fs';
|
|
12
12
|
import { registerTool } from '../index.js';
|
|
13
13
|
import { resolveSafePath, assertRealPathContained, PathOutsideRootError, DENYLIST_DESCRIPTION, isDenylistedPath, isReadOnlyProjectFile } from '../_filesystem-shared.js';
|
|
14
|
+
import { teamReadTarget } from '../_team-work-path.js';
|
|
14
15
|
import { spillToolResult, isUnderSpillRoot } from '../result-spill.js';
|
|
15
16
|
import { basename } from 'node:path';
|
|
16
17
|
const MAX_LINES = 2000;
|
|
@@ -22,9 +23,12 @@ export async function fileReadHandler(args, ctx) {
|
|
|
22
23
|
if (args.limit !== undefined && args.limit < 1) {
|
|
23
24
|
return { content: `error: limit must be a positive integer (got ${args.limit})`, is_error: true };
|
|
24
25
|
}
|
|
26
|
+
// Task 15 — in a team folder a skill working file, a saved result
|
|
27
|
+
// (.cspeach/*.cspeach.json) or a lane decision is read from the seat.
|
|
28
|
+
const t = teamReadTarget(ctx.cwd, args.path);
|
|
25
29
|
let abs;
|
|
26
30
|
try {
|
|
27
|
-
abs = resolveSafePath(
|
|
31
|
+
abs = resolveSafePath(t.root, t.path);
|
|
28
32
|
}
|
|
29
33
|
catch (err) {
|
|
30
34
|
if (err instanceof PathOutsideRootError) {
|
|
@@ -34,7 +38,7 @@ export async function fileReadHandler(args, ctx) {
|
|
|
34
38
|
}
|
|
35
39
|
let realAbs;
|
|
36
40
|
try {
|
|
37
|
-
realAbs = await assertRealPathContained(abs,
|
|
41
|
+
realAbs = await assertRealPathContained(abs, t.root);
|
|
38
42
|
}
|
|
39
43
|
catch (err) {
|
|
40
44
|
if (err instanceof PathOutsideRootError) {
|
|
@@ -48,8 +52,8 @@ export async function fileReadHandler(args, ctx) {
|
|
|
48
52
|
// Denylist check: refuse sensitive in-root paths. The project's own saved
|
|
49
53
|
// results (.cspeach/*.cspeach.json, .cspeach/lane/*.json) are readable —
|
|
50
54
|
// isReadOnlyProjectFile; the write tools never get this carve-out.
|
|
51
|
-
const denylist = isDenylistedPath(realAbs,
|
|
52
|
-
if (denylist.blocked && !isReadOnlyProjectFile(realAbs,
|
|
55
|
+
const denylist = isDenylistedPath(realAbs, t.root);
|
|
56
|
+
if (denylist.blocked && !isReadOnlyProjectFile(realAbs, t.root)) {
|
|
53
57
|
return {
|
|
54
58
|
content: `error: refusing to read sensitive path "${denylist.relFromRoot}" (denylist: ${DENYLIST_DESCRIPTION})`,
|
|
55
59
|
is_error: true,
|
|
@@ -19,6 +19,7 @@ import { promises as fs, readFileSync } from 'node:fs';
|
|
|
19
19
|
import * as path from 'node:path';
|
|
20
20
|
import { registerTool } from '../index.js';
|
|
21
21
|
import { resolveSafePath, realPathForWrite, PathOutsideRootError, DENYLIST_DESCRIPTION, isDenylistedPath, blockedExecutableExtension, } from '../_filesystem-shared.js';
|
|
22
|
+
import { pointsIntoSharedCspeach, SHARED_CSPEACH_REFUSAL, teamWorkTarget } from '../_team-work-path.js';
|
|
22
23
|
/**
|
|
23
24
|
* 2026-09-23 (Glenn's first run) — a standalone run writes GUIDE.md and said
|
|
24
25
|
* nothing about it. He opened it in a text editor, then tried to drop it into
|
|
@@ -57,9 +58,11 @@ function noteGuideWritten(relPath, ctx) {
|
|
|
57
58
|
}
|
|
58
59
|
export async function fileWriteHandler(args, ctx) {
|
|
59
60
|
// 1. Path containment — sync phase (pure string math, catches .. traversals)
|
|
61
|
+
// Task 15 — in a team folder a skill working file goes to the seat.
|
|
62
|
+
const t = teamWorkTarget(ctx.cwd, args.path);
|
|
60
63
|
let abs;
|
|
61
64
|
try {
|
|
62
|
-
abs = resolveSafePath(
|
|
65
|
+
abs = resolveSafePath(t.root, t.path);
|
|
63
66
|
}
|
|
64
67
|
catch (err) {
|
|
65
68
|
if (err instanceof PathOutsideRootError) {
|
|
@@ -74,7 +77,7 @@ export async function fileWriteHandler(args, ctx) {
|
|
|
74
77
|
// `.git` behind a link (`docs -> .git`) or a short name (`GIT~1`).
|
|
75
78
|
let target;
|
|
76
79
|
try {
|
|
77
|
-
target = await realPathForWrite(abs,
|
|
80
|
+
target = await realPathForWrite(abs, t.root);
|
|
78
81
|
}
|
|
79
82
|
catch (err) {
|
|
80
83
|
if (err instanceof PathOutsideRootError) {
|
|
@@ -83,7 +86,7 @@ export async function fileWriteHandler(args, ctx) {
|
|
|
83
86
|
return { content: `error: ${err instanceof Error ? err.message : String(err)}`, is_error: true };
|
|
84
87
|
}
|
|
85
88
|
// 3. Denylist check: refuse sensitive in-root paths
|
|
86
|
-
const denylist = isDenylistedPath(target,
|
|
89
|
+
const denylist = isDenylistedPath(target, t.root);
|
|
87
90
|
if (denylist.blocked) {
|
|
88
91
|
return {
|
|
89
92
|
content: `error: refusing to write sensitive path "${denylist.relFromRoot}" (denylist: ${DENYLIST_DESCRIPTION})`,
|
|
@@ -99,6 +102,11 @@ export async function fileWriteHandler(args, ctx) {
|
|
|
99
102
|
is_error: true,
|
|
100
103
|
};
|
|
101
104
|
}
|
|
105
|
+
// 3c. Team folder: the real landing path never lies in the shared .cspeach
|
|
106
|
+
// (an 8.3 name, a junction or an odd spelling would get it there).
|
|
107
|
+
if (pointsIntoSharedCspeach(ctx.cwd, target)) {
|
|
108
|
+
return { content: `error: ${SHARED_CSPEACH_REFUSAL}`, is_error: true };
|
|
109
|
+
}
|
|
102
110
|
// 4. Ensure parent directory exists (no-op if it already does)
|
|
103
111
|
try {
|
|
104
112
|
await fs.mkdir(path.dirname(target), { recursive: true });
|
|
@@ -117,7 +125,7 @@ export async function fileWriteHandler(args, ctx) {
|
|
|
117
125
|
await fs.unlink(tmpPath).catch(() => undefined);
|
|
118
126
|
return { content: `error: write failed — ${err.message}`, is_error: true };
|
|
119
127
|
}
|
|
120
|
-
const relPath = path.relative(path.resolve(
|
|
128
|
+
const relPath = path.relative(path.resolve(t.root), target).replace(/\\/g, '/');
|
|
121
129
|
const bytes = Buffer.byteLength(args.content, 'utf-8');
|
|
122
130
|
noteGuideWritten(relPath, ctx);
|
|
123
131
|
return { content: `wrote ${relPath} (${bytes} bytes)` };
|