@f5-sales-demo/xcsh 19.98.5 → 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.5",
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.5",
61
- "@f5-sales-demo/pi-agent-core": "19.98.5",
62
- "@f5-sales-demo/pi-ai": "19.98.5",
63
- "@f5-sales-demo/pi-natives": "19.98.5",
64
- "@f5-sales-demo/pi-resource-management": "19.98.5",
65
- "@f5-sales-demo/pi-tui": "19.98.5",
66
- "@f5-sales-demo/pi-utils": "19.98.5",
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.5",
21
- "commit": "8f74310238adee476f2e551a7860f2b58a948df9",
22
- "shortCommit": "8f74310",
20
+ "version": "19.98.7",
21
+ "commit": "0a7dd046f51c31f5c15b62cf4fb7a2b77f179d4e",
22
+ "shortCommit": "0a7dd04",
23
23
  "branch": "main",
24
- "tag": "v19.98.5",
25
- "commitDate": "2026-07-28T10:29:00Z",
26
- "buildDate": "2026-07-28T10:55:18.129Z",
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/8f74310238adee476f2e551a7860f2b58a948df9",
32
- "releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v19.98.5"
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
 
@@ -151,6 +151,21 @@ function deny(policy: SandboxPolicy, resolved: string, access: SandboxAccess): T
151
151
  return { block: true, reason: policy.describe(resolved, access) };
152
152
  }
153
153
 
154
+ /**
155
+ * `$HOME` and `${HOME}` are spellings of `~`, which `looksLikePath` already treats as a
156
+ * path (#2534). Three ways to name one file, only one of them checked, is an oversight
157
+ * rather than a policy: `cat ~/.ssh/id_rsa` was blocked while `cat $HOME/.ssh/id_rsa`
158
+ * was not. Rewriting to `~` here means detection AND resolution both see the real path,
159
+ * so the denial names the file rather than a literal dollar sign.
160
+ *
161
+ * `\b` keeps `$HOMEBREW_PREFIX` and friends out of it.
162
+ */
163
+ const HOME_EXPANSION = /^\$(?:HOME\b|\{HOME\})/;
164
+
165
+ function normalizeHomeExpansion(token: string): string {
166
+ return HOME_EXPANSION.test(token) ? token.replace(HOME_EXPANSION, "~") : token;
167
+ }
168
+
154
169
  function looksLikePath(token: string): boolean {
155
170
  return path.isAbsolute(token) || token.startsWith("~") || /(^|[/\\])\.\.([/\\]|$)/.test(token);
156
171
  }
@@ -179,6 +194,12 @@ function operandAccess(operator: string): SandboxAccess | "skip" {
179
194
  interface PathCandidate {
180
195
  token: string;
181
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;
182
203
  }
183
204
 
184
205
  /** Write first, so a `<>` denial names the stricter boundary the caller is most likely missing. */
@@ -190,6 +211,9 @@ const REDIRECT_OPERATOR = /[0-9]*(?:&>>|&>|<<<|<<-|<<|>>|>\||<>|>&|<&|>|<)/g;
190
211
  /** A whitespace token that is only a redirection operator, so the next token is its operand. */
191
212
  const BARE_REDIRECT = /^[0-9]*(?:&>>|&>|<<<|<<-|<<|>>|>\||<>|>&|<&|>|<)$/;
192
213
 
214
+ /** Where a shell word ends inside a whitespace token: an operator, separator, or grouping. */
215
+ const METACHARACTER = /[;&|<>()]/;
216
+
193
217
  /**
194
218
  * A path glued to its option, as one shell word (#2524).
195
219
  *
@@ -224,7 +248,8 @@ const OPTION_VALUE_FORMS: readonly RegExp[] = [
224
248
  */
225
249
  function codePathOccurrences(command: string): PathOccurrence[] {
226
250
  const found: PathOccurrence[] = [];
227
- const add = (token: string, at: number, access?: SandboxAccess): void => {
251
+ const add = (raw: string, at: number, access?: SandboxAccess): void => {
252
+ const token = normalizeHomeExpansion(raw);
228
253
  if (token.length > 0 && looksLikePath(token)) found.push({ token, at, access });
229
254
  };
230
255
  // Set when the previous token was nothing but a redirection operator, so this one is its operand.
@@ -251,7 +276,17 @@ function codePathOccurrences(command: string): PathOccurrence[] {
251
276
  const access = operandAccess(operator[0]);
252
277
  if (access === "skip") continue;
253
278
  const from = operator.index + operator[0].length;
254
- 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
+ );
255
290
  }
256
291
 
257
292
  // An option glued to its value is one word too, and the lexer cannot help: it
@@ -306,11 +341,145 @@ function codePathCandidates(command: string): PathCandidate[] {
306
341
  * `-exec` run — is not blanked, so it is still scanned. When the command cannot be lexed
307
342
  * confidently, the floor stands alone.
308
343
  */
309
- function shellPathCandidates(command: string): PathCandidate[] {
344
+ /**
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).
356
+ */
357
+ interface UnresolvableTarget {
358
+ text: string;
359
+ /** What the shell was about to do, for a message that names the actual problem. */
360
+ what: "directory change";
361
+ }
362
+
363
+ interface ShellScan {
364
+ candidates: PathCandidate[];
365
+ unresolvable: UnresolvableTarget[];
366
+ }
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
+
478
+ function shellPathCandidates(command: string): ShellScan {
310
479
  const lexed = lexShellCommand(command);
311
480
  // Unbalanced quotes mean every word boundary is a guess: neither the blanking nor the write
312
481
  // marking below can be trusted, so fall back to the floor, checked as reads.
313
- if (lexed.unterminated) return codePathCandidates(command);
482
+ if (lexed.unterminated) return { candidates: codePathCandidates(command), unresolvable: [] };
314
483
 
315
484
  const exemptSpans = lexed.commands.flatMap(simpleCommand => provenExemptWords(simpleCommand));
316
485
  let scanned = command;
@@ -338,10 +507,19 @@ function shellPathCandidates(command: string): PathCandidate[] {
338
507
  for (const word of lexed.words) {
339
508
  if (word.redirect === undefined || word.redirect === "here-string") continue;
340
509
  for (const access of word.redirect === "read-write" ? WRITE_AND_READ : [word.redirect]) {
341
- candidates.push({ token: word.text, access });
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.
513
+ if (word.literal) candidates.push({ token: word.text, access });
342
514
  }
343
515
  }
344
- return candidates;
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
+ }
522
+ return { candidates, unresolvable };
345
523
  }
346
524
 
347
525
  /** Base directories a search input would actually search, split like the tools do. */
@@ -365,7 +543,12 @@ function evaluateCodeTool(check: ToolCallCheck, fields: string[], shell: boolean
365
543
 
366
544
  const rawCwd = typeof input.cwd === "string" ? input.cwd : undefined;
367
545
  const base = rawCwd ? resolveToCwd(rawCwd, cwd) : cwd;
368
- 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
+ }
369
552
 
370
553
  const commands: string[] = [];
371
554
  for (const field of fields) {
@@ -383,9 +566,28 @@ function evaluateCodeTool(check: ToolCallCheck, fields: string[], shell: boolean
383
566
  // Only bash gets the shell-aware treatment. Python is not shell: lexing `open('/x')` as
384
567
  // shell yields one non-absolute word and would lose the check entirely, and it has no
385
568
  // redirects, so every candidate it produces is a read.
569
+ const scan: ShellScan = shell
570
+ ? shellPathCandidates(command)
571
+ : { candidates: codePathCandidates(command), unresolvable: [] };
572
+ // Refused before any candidate is resolved: there is no path to check, and that is
573
+ // precisely the problem. The residual this does NOT cover is an expansion in an
574
+ // operand rather than a redirect target — `cat "$SECRET"` — which cannot be resolved
575
+ // at this layer at all. That is the Phase 2 OS-sandbox's job; do not read the text
576
+ // boundary as complete (#2534).
577
+ for (const target of scan.unresolvable) {
578
+ return { block: true, reason: describeDirectoryChange(policy, undefined, target.text) };
579
+ }
386
580
  const seen = new Set<string>();
387
- for (const { token, access } of shell ? shellPathCandidates(command) : codePathCandidates(command)) {
388
- 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
+ }
389
591
  const resolved = resolveToCwd(token, base);
390
592
  if (policy.isAllowed(resolved, access)) continue;
391
593
  // SYSTEM_READ_ROOTS is a read allowance — "directories a subprocess may legitimately