@f5-sales-demo/xcsh 19.98.6 → 19.98.7

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@f5-sales-demo/xcsh",
4
- "version": "19.98.6",
4
+ "version": "19.98.7",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://github.com/f5-sales-demo/xcsh",
7
7
  "author": "Can Boluk",
@@ -57,13 +57,13 @@
57
57
  "dependencies": {
58
58
  "@agentclientprotocol/sdk": "1.3.0",
59
59
  "@mozilla/readability": "^0.6",
60
- "@f5-sales-demo/xcsh-stats": "19.98.6",
61
- "@f5-sales-demo/pi-agent-core": "19.98.6",
62
- "@f5-sales-demo/pi-ai": "19.98.6",
63
- "@f5-sales-demo/pi-natives": "19.98.6",
64
- "@f5-sales-demo/pi-resource-management": "19.98.6",
65
- "@f5-sales-demo/pi-tui": "19.98.6",
66
- "@f5-sales-demo/pi-utils": "19.98.6",
60
+ "@f5-sales-demo/xcsh-stats": "19.98.7",
61
+ "@f5-sales-demo/pi-agent-core": "19.98.7",
62
+ "@f5-sales-demo/pi-ai": "19.98.7",
63
+ "@f5-sales-demo/pi-natives": "19.98.7",
64
+ "@f5-sales-demo/pi-resource-management": "19.98.7",
65
+ "@f5-sales-demo/pi-tui": "19.98.7",
66
+ "@f5-sales-demo/pi-utils": "19.98.7",
67
67
  "@sinclair/typebox": "^0.34",
68
68
  "@xterm/headless": "^6.0",
69
69
  "ajv": "^8.20",
@@ -17,17 +17,17 @@ export interface BuildInfo {
17
17
  }
18
18
 
19
19
  export const BUILD_INFO: BuildInfo = {
20
- "version": "19.98.6",
21
- "commit": "9a4b5f250189b2d423b62f15f02c2403c070fb3d",
22
- "shortCommit": "9a4b5f2",
20
+ "version": "19.98.7",
21
+ "commit": "0a7dd046f51c31f5c15b62cf4fb7a2b77f179d4e",
22
+ "shortCommit": "0a7dd04",
23
23
  "branch": "main",
24
- "tag": "v19.98.6",
25
- "commitDate": "2026-07-28T11:16:35Z",
26
- "buildDate": "2026-07-28T11:40:19.737Z",
24
+ "tag": "v19.98.7",
25
+ "commitDate": "2026-07-28T11:57:17Z",
26
+ "buildDate": "2026-07-28T12:27:02.779Z",
27
27
  "dirty": true,
28
28
  "prNumber": "",
29
29
  "repoUrl": "https://github.com/f5-sales-demo/xcsh",
30
30
  "repoSlug": "f5-sales-demo/xcsh",
31
- "commitUrl": "https://github.com/f5-sales-demo/xcsh/commit/9a4b5f250189b2d423b62f15f02c2403c070fb3d",
32
- "releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v19.98.6"
31
+ "commitUrl": "https://github.com/f5-sales-demo/xcsh/commit/0a7dd046f51c31f5c15b62cf4fb7a2b77f179d4e",
32
+ "releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v19.98.7"
33
33
  };
@@ -18,8 +18,8 @@
18
18
  * for both bash and python.
19
19
  */
20
20
  import * as path from "node:path";
21
- import { parseFindPattern, parseSearchPath, resolveToCwd, splitTopLevel } from "../tools/path-utils";
22
- import { lexShellCommand } from "../tools/shell-lex";
21
+ import { expandPath, parseFindPattern, parseSearchPath, resolveToCwd, splitTopLevel } from "../tools/path-utils";
22
+ import { lexShellCommand, type ShellSimpleCommand } from "../tools/shell-lex";
23
23
  import { provenExemptWords } from "./command-operands";
24
24
  import type { SandboxAccess, SandboxPolicy } from "./policy";
25
25
 
@@ -194,6 +194,12 @@ function operandAccess(operator: string): SandboxAccess | "skip" {
194
194
  interface PathCandidate {
195
195
  token: string;
196
196
  access: SandboxAccess;
197
+ /**
198
+ * A directory the shell is about to move into. It must clear both boundaries on its own merits,
199
+ * and the system-root read exemption does not apply: `/usr` being readable is no reason to let
200
+ * the working directory — and with it every unchecked relative path — move there.
201
+ */
202
+ mustBeInTree?: boolean;
197
203
  }
198
204
 
199
205
  /** Write first, so a `<>` denial names the stricter boundary the caller is most likely missing. */
@@ -205,6 +211,9 @@ const REDIRECT_OPERATOR = /[0-9]*(?:&>>|&>|<<<|<<-|<<|>>|>\||<>|>&|<&|>|<)/g;
205
211
  /** A whitespace token that is only a redirection operator, so the next token is its operand. */
206
212
  const BARE_REDIRECT = /^[0-9]*(?:&>>|&>|<<<|<<-|<<|>>|>\||<>|>&|<&|>|<)$/;
207
213
 
214
+ /** Where a shell word ends inside a whitespace token: an operator, separator, or grouping. */
215
+ const METACHARACTER = /[;&|<>()]/;
216
+
208
217
  /**
209
218
  * A path glued to its option, as one shell word (#2524).
210
219
  *
@@ -267,7 +276,17 @@ function codePathOccurrences(command: string): PathOccurrence[] {
267
276
  const access = operandAccess(operator[0]);
268
277
  if (access === "skip") continue;
269
278
  const from = operator.index + operator[0].length;
270
- add(stripped.slice(from).replace(/^["']|["']$/g, ""), match.index + openingQuote + from, access);
279
+ // The operand ends at the first shell metacharacter, not at the end of the whitespace
280
+ // token. `>/dev/null; echo x` is one token, and taking all of it produced the "path"
281
+ // `/dev/null;`, which matched no write sink and refused a completely ordinary command
282
+ // (#2540). Truncating only ever shortens a candidate, so nothing blocked becomes allowed.
283
+ const rest = stripped.slice(from);
284
+ const stop = rest.search(METACHARACTER);
285
+ add(
286
+ (stop === -1 ? rest : rest.slice(0, stop)).replace(/^["']|["']$/g, ""),
287
+ match.index + openingQuote + from,
288
+ access,
289
+ );
271
290
  }
272
291
 
273
292
  // An option glued to its value is one word too, and the lexer cannot help: it
@@ -323,13 +342,22 @@ function codePathCandidates(command: string): PathCandidate[] {
323
342
  * confidently, the floor stands alone.
324
343
  */
325
344
  /**
326
- * A redirect target the shell will certainly open, whose path cannot be resolved here
327
- * `printf x >"$TARGET"`. Unlike an operand, there is no reading under which this is data
328
- * rather than a file, so it cannot be waved through (#2534).
345
+ * Something the shell will act on whose path this layer cannot resolve `cd "$DEST"`.
346
+ *
347
+ * Refusal is justified here and *not* for a redirect target, which is the asymmetry worth stating: a
348
+ * redirect target that escapes damages one file, while a directory change silently relocates every
349
+ * later relative path in the session, and relative paths are never candidates at all. So an
350
+ * unresolvable `cd` fails closed, and an unresolvable `> "$LOG"` does not.
351
+ *
352
+ * The reverse was tried (#2552) and refused `make > "$LOG"`, `> "$TMPDIR/f"` and `> out-$$.txt` —
353
+ * ordinary shell, writing in-tree. Narrowing it is not possible either: a variable's *value* can
354
+ * contain `../`, so `> "out-$X"` escapes while looking relative. Resolving that needs the expansion,
355
+ * which only the shell has — Phase 2 (#2554).
329
356
  */
330
357
  interface UnresolvableTarget {
331
358
  text: string;
332
- access: SandboxAccess;
359
+ /** What the shell was about to do, for a message that names the actual problem. */
360
+ what: "directory change";
333
361
  }
334
362
 
335
363
  interface ShellScan {
@@ -337,6 +365,116 @@ interface ShellScan {
337
365
  unresolvable: UnresolvableTarget[];
338
366
  }
339
367
 
368
+ /**
369
+ * Why a directory change was refused. Distinct from `describe` because the remedy differs: the path
370
+ * may well be readable, and the problem is that standing there redefines every relative path the
371
+ * boundary trusts to stay in the tree.
372
+ */
373
+ function describeDirectoryChange(policy: SandboxPolicy, target?: string, unresolved?: string): string {
374
+ const what =
375
+ target === undefined
376
+ ? `a directory this check cannot resolve from the command text (${unresolved})`
377
+ : `${target}, which is outside it`;
378
+ return `Refusing to change the working directory to ${what} (session directory: ${policy.cwd}). Relative paths are trusted to stay inside the session tree, so moving out of it would silently take every later path with it. Use an absolute path, or --allow-path to widen the boundary first.`;
379
+ }
380
+
381
+ /** Builtins that move the shell, and therefore move what every later relative path means. */
382
+ const DIRECTORY_CHANGE = new Set(["cd", "pushd"]);
383
+
384
+ /** `cd`'s own options. All boolean, so the target is the first non-option operand. */
385
+ const DIRECTORY_CHANGE_OPTIONS = new Set(["-L", "-P", "-e", "-@"]);
386
+
387
+ /**
388
+ * Commands whose operand is a script the shell will run. The lexer hands the script over as one
389
+ * word, so a directory change inside it is invisible unless that word is lexed in turn.
390
+ */
391
+ const SCRIPT_RUNNERS = new Set(["sh", "bash", "zsh", "dash", "ksh", "eval"]);
392
+
393
+ /**
394
+ * Prefixes that run the *following words* as a command rather than a script string. `command` is
395
+ * already unwrapped by the lexer; `builtin` is not, and `builtin cd /` would otherwise sail past a
396
+ * gate that only looks at `name`.
397
+ */
398
+ const COMMAND_PREFIXES = new Set(["builtin"]);
399
+
400
+ /** Cheap pre-filter: only lex a script operand that could contain a directory change at all. */
401
+ const DIRECTORY_CHANGE_TOKEN = /(^|[\s;&|(])(cd|pushd)([\s;&|)]|$)/;
402
+
403
+ /**
404
+ * Targets of a directory change: a candidate that must resolve in-tree, or the raw text when it
405
+ * cannot be resolved at all.
406
+ *
407
+ * This gate exists because a relative operand is never a candidate — the floor assumes it resolves
408
+ * under the session directory. `cd` is what breaks that assumption: the bash tool runs one
409
+ * persistent brush-core shell in the agent's own process, so a directory change outlives the call
410
+ * that made it and afterwards `cat tmp/x` reads somewhere else entirely (#2542).
411
+ *
412
+ * It is defence-in-depth, not a boundary. Verified still open: `c=cd; $c /`, `alias g=cd; g /`, and
413
+ * a symlink created in the same command (#2553). Those need the shell's own resolution, which is
414
+ * Phase 2 (#2554). Do not add a seventh spelling here — two adversarial rounds produced six.
415
+ */
416
+ function directoryChangeTargets(commands: readonly ShellSimpleCommand[], depth = 0): (PathCandidate | string)[] {
417
+ const targets: (PathCandidate | string)[] = [];
418
+
419
+ for (const command of commands) {
420
+ if (command.name === undefined) continue;
421
+ let name = command.name;
422
+ let operands = command.words.slice(command.operandStart).filter(word => word.redirect === undefined);
423
+ // `builtin cd sub` is a `cd`; unwrap the prefix before deciding anything.
424
+ while (COMMAND_PREFIXES.has(name) && operands.length > 0 && operands[0].literal) {
425
+ name = operands[0].text;
426
+ operands = operands.slice(1);
427
+ }
428
+
429
+ // A script operand is text the shell will run, so lex it and gate what it contains. `eval
430
+ // 'cd /'` and `sh -c '…'` both arrive here. One level is enough for every real spelling;
431
+ // deeper nesting is unprovable.
432
+ if (SCRIPT_RUNNERS.has(name)) {
433
+ for (const operand of operands) {
434
+ if (!operand.literal) {
435
+ targets.push(operand.text); // `eval "$cmd"` — nothing to read
436
+ continue;
437
+ }
438
+ if (!DIRECTORY_CHANGE_TOKEN.test(operand.text)) continue;
439
+ if (depth > 0) {
440
+ targets.push(operand.text);
441
+ continue;
442
+ }
443
+ const inner = lexShellCommand(operand.text);
444
+ if (inner.unterminated) targets.push(operand.text);
445
+ else targets.push(...directoryChangeTargets(inner.commands, depth + 1));
446
+ }
447
+ continue;
448
+ }
449
+
450
+ if (!DIRECTORY_CHANGE.has(name)) continue;
451
+
452
+ // Options precede the target and are all boolean. One the model does not recognise means the
453
+ // target cannot be located, so the proof fails rather than guessing which operand it is.
454
+ let index = 0;
455
+ let unparseable = false;
456
+ while (index < operands.length && operands[index].text.startsWith("-") && operands[index].text !== "-") {
457
+ if (!DIRECTORY_CHANGE_OPTIONS.has(operands[index].text)) {
458
+ unparseable = true;
459
+ break;
460
+ }
461
+ index++;
462
+ }
463
+ if (unparseable) {
464
+ targets.push(operands.map(word => word.text).join(" "));
465
+ continue;
466
+ }
467
+
468
+ const target = operands[index];
469
+ // `cd` with no operand goes to $HOME; `cd -` returns somewhere only the shell remembers; a
470
+ // non-literal target cannot be resolved from text.
471
+ if (target === undefined) targets.push(`${name} (no target: goes to $HOME)`);
472
+ else if (!target.literal || target.text === "-") targets.push(target.text);
473
+ else targets.push({ token: target.text, access: "read", mustBeInTree: true });
474
+ }
475
+ return targets;
476
+ }
477
+
340
478
  function shellPathCandidates(command: string): ShellScan {
341
479
  const lexed = lexShellCommand(command);
342
480
  // Unbalanced quotes mean every word boundary is a guess: neither the blanking nor the write
@@ -366,18 +504,21 @@ function shellPathCandidates(command: string): ShellScan {
366
504
  // Not gated on `looksLikePath`: that test is for the floor's *guesses* about which fragments of a
367
505
  // command might be filenames. A redirect target is one the shell will certainly open, so a bare
368
506
  // `out.txt` is checked too — it resolves under the cwd, which a read-only cwd does not license.
369
- const unresolvable: UnresolvableTarget[] = [];
370
507
  for (const word of lexed.words) {
371
508
  if (word.redirect === undefined || word.redirect === "here-string") continue;
372
509
  for (const access of word.redirect === "read-write" ? WRITE_AND_READ : [word.redirect]) {
373
- // `literal` is false when the word carries `$VAR`, a substitution, a glob or a brace
374
- // expansion, so `text` is not a stand-in for one filesystem reference. Resolving it
375
- // anyway is what let `>"$TARGET"` through: it became the literal string `$TARGET`
376
- // under the cwd, which the boundary happily allowed.
510
+ // A non-literal target `$VAR`, a substitution, a glob is not checked. `text` is not a
511
+ // stand-in for one filesystem reference, and refusing on that basis rejected ordinary
512
+ // in-tree shell (see UnresolvableTarget). Resolving it is Phase 2's job.
377
513
  if (word.literal) candidates.push({ token: word.text, access });
378
- else unresolvable.push({ text: word.text, access });
379
514
  }
380
515
  }
516
+
517
+ const unresolvable: UnresolvableTarget[] = [];
518
+ for (const target of directoryChangeTargets(lexed.commands)) {
519
+ if (typeof target === "string") unresolvable.push({ text: target, what: "directory change" });
520
+ else candidates.push(target);
521
+ }
381
522
  return { candidates, unresolvable };
382
523
  }
383
524
 
@@ -402,7 +543,12 @@ function evaluateCodeTool(check: ToolCallCheck, fields: string[], shell: boolean
402
543
 
403
544
  const rawCwd = typeof input.cwd === "string" ? input.cwd : undefined;
404
545
  const base = rawCwd ? resolveToCwd(rawCwd, cwd) : cwd;
405
- if (rawCwd && !policy.isAllowed(base, "read")) return deny(policy, base, "read");
546
+ // Both boundaries, for the same reason a `cd` target needs both: relative paths are never
547
+ // scanned, so wherever the command runs is somewhere it can write freely. Read alone let
548
+ // `{ cwd: "/shared/ctx", command: "touch notes.md" }` write into a read-only root.
549
+ if (rawCwd && !(policy.isAllowed(base, "read") && policy.isAllowed(base, "write"))) {
550
+ return { block: true, reason: describeDirectoryChange(policy, base) };
551
+ }
406
552
 
407
553
  const commands: string[] = [];
408
554
  for (const field of fields) {
@@ -429,17 +575,19 @@ function evaluateCodeTool(check: ToolCallCheck, fields: string[], shell: boolean
429
575
  // at this layer at all. That is the Phase 2 OS-sandbox's job; do not read the text
430
576
  // boundary as complete (#2534).
431
577
  for (const target of scan.unresolvable) {
432
- return {
433
- block: true,
434
- reason:
435
- `sandbox: refusing to ${target.access} a redirect target this layer cannot resolve: ` +
436
- `${target.text}. The shell will open it, but its path comes from an expansion, so the ` +
437
- "boundary cannot be checked. Write to an explicit path, or use --allow-path.",
438
- };
578
+ return { block: true, reason: describeDirectoryChange(policy, undefined, target.text) };
439
579
  }
440
580
  const seen = new Set<string>();
441
- for (const { token, access } of scan.candidates) {
442
- if (!seen.add(`${access}\0${token}`)) continue;
581
+ for (const { token, access, mustBeInTree } of scan.candidates) {
582
+ if (!seen.add(`${access}\0${mustBeInTree ? "cd\0" : ""}${token}`)) continue;
583
+ if (mustBeInTree) {
584
+ // `path.resolve`, not `resolveToCwd`: the latter maps an all-slashes path to the cwd,
585
+ // which would read `cd /` as `cd .` and let the shell walk out. Both boundaries,
586
+ // because relative paths go unchecked wherever the shell is standing.
587
+ const moved = path.resolve(base, expandPath(token));
588
+ if (policy.isAllowed(moved, "read") && policy.isAllowed(moved, "write")) continue;
589
+ return { block: true, reason: describeDirectoryChange(policy, moved) };
590
+ }
443
591
  const resolved = resolveToCwd(token, base);
444
592
  if (policy.isAllowed(resolved, access)) continue;
445
593
  // SYSTEM_READ_ROOTS is a read allowance — "directories a subprocess may legitimately