@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.
|
|
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.
|
|
61
|
-
"@f5-sales-demo/pi-agent-core": "19.98.
|
|
62
|
-
"@f5-sales-demo/pi-ai": "19.98.
|
|
63
|
-
"@f5-sales-demo/pi-natives": "19.98.
|
|
64
|
-
"@f5-sales-demo/pi-resource-management": "19.98.
|
|
65
|
-
"@f5-sales-demo/pi-tui": "19.98.
|
|
66
|
-
"@f5-sales-demo/pi-utils": "19.98.
|
|
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.
|
|
21
|
-
"commit": "
|
|
22
|
-
"shortCommit": "
|
|
20
|
+
"version": "19.98.7",
|
|
21
|
+
"commit": "0a7dd046f51c31f5c15b62cf4fb7a2b77f179d4e",
|
|
22
|
+
"shortCommit": "0a7dd04",
|
|
23
23
|
"branch": "main",
|
|
24
|
-
"tag": "v19.98.
|
|
25
|
-
"commitDate": "2026-07-28T11:
|
|
26
|
-
"buildDate": "2026-07-
|
|
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/
|
|
32
|
-
"releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v19.98.
|
|
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
|
};
|
package/src/sandbox/enforce.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
*
|
|
327
|
-
*
|
|
328
|
-
*
|
|
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
|
-
|
|
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
|
-
//
|
|
374
|
-
//
|
|
375
|
-
//
|
|
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
|
-
|
|
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
|