@f5-sales-demo/xcsh 19.99.0 → 19.100.0
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.
|
|
4
|
+
"version": "19.100.0",
|
|
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.
|
|
61
|
-
"@f5-sales-demo/pi-agent-core": "19.
|
|
62
|
-
"@f5-sales-demo/pi-ai": "19.
|
|
63
|
-
"@f5-sales-demo/pi-natives": "19.
|
|
64
|
-
"@f5-sales-demo/pi-resource-management": "19.
|
|
65
|
-
"@f5-sales-demo/pi-tui": "19.
|
|
66
|
-
"@f5-sales-demo/pi-utils": "19.
|
|
60
|
+
"@f5-sales-demo/xcsh-stats": "19.100.0",
|
|
61
|
+
"@f5-sales-demo/pi-agent-core": "19.100.0",
|
|
62
|
+
"@f5-sales-demo/pi-ai": "19.100.0",
|
|
63
|
+
"@f5-sales-demo/pi-natives": "19.100.0",
|
|
64
|
+
"@f5-sales-demo/pi-resource-management": "19.100.0",
|
|
65
|
+
"@f5-sales-demo/pi-tui": "19.100.0",
|
|
66
|
+
"@f5-sales-demo/pi-utils": "19.100.0",
|
|
67
67
|
"@sinclair/typebox": "^0.34",
|
|
68
68
|
"@xterm/headless": "^6.0",
|
|
69
69
|
"ajv": "^8.20",
|
|
@@ -168,7 +168,12 @@ function renderActiveModel(model: ActiveModelSnapshot | null): string {
|
|
|
168
168
|
*/
|
|
169
169
|
function renderContainment(containment: ContainmentStatus | null): string {
|
|
170
170
|
if (!containment) return "";
|
|
171
|
-
|
|
171
|
+
// `landlock` is derived rather than another field on the status, because the template needs a
|
|
172
|
+
// boolean and Handlebars cannot compare strings. It gates the Linux-only costs — unlistable split
|
|
173
|
+
// directories, no setuid, no interactive terminal — which are true of that backend and no other.
|
|
174
|
+
return prompt.render(containmentTemplate, {
|
|
175
|
+
containment: { ...containment, landlock: containment.backend === "landlock" },
|
|
176
|
+
});
|
|
172
177
|
}
|
|
173
178
|
|
|
174
179
|
export function renderAboutDoc(
|
|
@@ -17,17 +17,17 @@ export interface BuildInfo {
|
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
export const BUILD_INFO: BuildInfo = {
|
|
20
|
-
"version": "19.
|
|
21
|
-
"commit": "
|
|
22
|
-
"shortCommit": "
|
|
20
|
+
"version": "19.100.0",
|
|
21
|
+
"commit": "77f25e303dc3b1fa200117333808b1ed95a6927d",
|
|
22
|
+
"shortCommit": "77f25e3",
|
|
23
23
|
"branch": "main",
|
|
24
|
-
"tag": "v19.
|
|
25
|
-
"commitDate": "2026-07-
|
|
26
|
-
"buildDate": "2026-07-
|
|
24
|
+
"tag": "v19.100.0",
|
|
25
|
+
"commitDate": "2026-07-28T22:16:47Z",
|
|
26
|
+
"buildDate": "2026-07-28T22:49:07.531Z",
|
|
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.
|
|
31
|
+
"commitUrl": "https://github.com/f5-sales-demo/xcsh/commit/77f25e303dc3b1fa200117333808b1ed95a6927d",
|
|
32
|
+
"releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v19.100.0"
|
|
33
33
|
};
|
|
@@ -16,6 +16,21 @@ followed symlinks — so how a path is spelled does not change what is reachable
|
|
|
16
16
|
If a command is refused, do not try to reach the same path a different way: the boundary is enforced
|
|
17
17
|
below the command text, so no rewriting will succeed. Say what you needed and why. The operator can
|
|
18
18
|
widen it with `--allow-path <dir>`, which grants read and write.
|
|
19
|
+
|
|
20
|
+
{{#if containment.landlock}}
|
|
21
|
+
Three things behave differently under this backend, and none of them is a bug to work around:
|
|
22
|
+
- `ls /` and `ls` of the directory holding the session tree fail. A kernel rule covers a whole subtree,
|
|
23
|
+
so a directory with both reachable and unreachable children cannot be listed at all. Listing a
|
|
24
|
+
specific directory you can reach works normally.
|
|
25
|
+
- `sudo` and other setuid programs do not work, because confining a process requires giving up the
|
|
26
|
+
ability to gain privileges.
|
|
27
|
+
- Interactive terminal programs (`top`, `less`, an interactive `ssh`) run without a real terminal here,
|
|
28
|
+
so prefer their non-interactive forms — `ps`, `cat`, `ssh -T`, or a piped command.
|
|
29
|
+
{{#if containment.truncationUngoverned}}
|
|
30
|
+
- This kernel is too old to govern truncation, so a file outside the boundary can still be emptied even
|
|
31
|
+
though it cannot be read or written. Never truncate a path outside the session directory.
|
|
32
|
+
{{/if}}
|
|
33
|
+
{{/if}}
|
|
19
34
|
{{else}}
|
|
20
35
|
On this platform there is **no OS-level backend**, so the boundary is enforced only by scanning the
|
|
21
36
|
command text before it runs. That check is best-effort by construction: it reads what you wrote
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
import * as fs from "node:fs";
|
|
20
20
|
import * as os from "node:os";
|
|
21
21
|
import * as path from "node:path";
|
|
22
|
+
import * as natives from "@f5-sales-demo/pi-natives";
|
|
22
23
|
import { getMemoriesDir, getSessionsDir, getXCSHContextsDir, pathIsWithin } from "@f5-sales-demo/pi-utils";
|
|
23
24
|
|
|
24
25
|
export type FenceAccess = "read" | "write";
|
|
@@ -248,6 +249,17 @@ export interface ContainmentStatus {
|
|
|
248
249
|
readonly backend: ContainmentBackend;
|
|
249
250
|
/** True when the kernel enforces it, false when only the command-text scan does. */
|
|
250
251
|
readonly osEnforced: boolean;
|
|
252
|
+
/**
|
|
253
|
+
* Set when the backend enforces reads and writes but cannot govern truncation.
|
|
254
|
+
*
|
|
255
|
+
* True only on Landlock ABI 2 — kernels 5.19 to 6.1, which includes Debian 12 — where
|
|
256
|
+
* `LANDLOCK_ACCESS_FS_TRUNCATE` does not exist. A denied file cannot be read or written there, but
|
|
257
|
+
* `truncate(2)` can still zero it. That is destruction rather than disclosure, and it is not
|
|
258
|
+
* reachable through `>` (which needs write access at open), so the backend is still worth having.
|
|
259
|
+
* Reported rather than folded into `osEnforced`, because "enforced" and "enforced except this" are
|
|
260
|
+
* different claims and an operator is entitled to know which one they have.
|
|
261
|
+
*/
|
|
262
|
+
readonly truncationUngoverned?: boolean;
|
|
251
263
|
}
|
|
252
264
|
|
|
253
265
|
/**
|
|
@@ -260,10 +272,52 @@ export interface ContainmentStatus {
|
|
|
260
272
|
*
|
|
261
273
|
* Deliberately not surfaced at startup or anywhere in the TUI — the operator asked for no UI change.
|
|
262
274
|
*/
|
|
263
|
-
export function containmentStatus(
|
|
275
|
+
export function containmentStatus(
|
|
276
|
+
enabled: boolean,
|
|
277
|
+
platform: string = process.platform,
|
|
278
|
+
probe: () => { backend: string; truncateHandled?: boolean } | undefined = probeNativeBackend,
|
|
279
|
+
): ContainmentStatus {
|
|
264
280
|
if (!enabled) return { enabled: false, backend: "disabled", osEnforced: false };
|
|
265
|
-
//
|
|
266
|
-
// Linux and Windows fall back to the scanner and say so rather than implying enforcement.
|
|
281
|
+
// macOS always has seatbelt, so there is nothing to ask.
|
|
267
282
|
if (platform === "darwin") return { enabled: true, backend: "seatbelt", osEnforced: true };
|
|
283
|
+
// Everywhere else the answer cannot be inferred from the platform name. Landlock can be compiled
|
|
284
|
+
// out of the kernel, left out of its boot-time LSM list, or too old to allow cross-directory
|
|
285
|
+
// rename — and none of that is visible from `process.platform`. Asking the native layer is the
|
|
286
|
+
// difference between reporting what is enforcing and reporting what we hope is enforcing.
|
|
287
|
+
// Guarded here rather than inside the probe, so *any* probe is safe to pass — including an injected
|
|
288
|
+
// one. A native module from an older release has no such export, and letting a `TypeError` escape
|
|
289
|
+
// would turn a missing status line into a broken `xcsh://about`. Falling back to `scanner-only`
|
|
290
|
+
// understates the boundary, which is the safe direction to be wrong in.
|
|
291
|
+
let probed: { backend: string; truncateHandled?: boolean } | undefined;
|
|
292
|
+
try {
|
|
293
|
+
probed = probe();
|
|
294
|
+
} catch {
|
|
295
|
+
probed = undefined;
|
|
296
|
+
}
|
|
297
|
+
if (probed?.backend === "landlock") {
|
|
298
|
+
return {
|
|
299
|
+
enabled: true,
|
|
300
|
+
backend: "landlock",
|
|
301
|
+
osEnforced: true,
|
|
302
|
+
// Absent on the ABI that governs truncation; present, and stated, on the one that does not.
|
|
303
|
+
...(probed.truncateHandled === false ? { truncationUngoverned: true } : {}),
|
|
304
|
+
};
|
|
305
|
+
}
|
|
268
306
|
return { enabled: true, backend: "scanner-only", osEnforced: false };
|
|
269
307
|
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Ask the native layer which backend is active, if it can answer.
|
|
311
|
+
*
|
|
312
|
+
* **Reached through a namespace import on purpose.** A native module built before this export existed
|
|
313
|
+
* does not have the symbol, and a static `import { containmentBackend }` against it fails at *link*
|
|
314
|
+
* time with `SyntaxError: Export named 'containmentBackend' not found` — taking the whole module graph
|
|
315
|
+
* down before any `try`/`catch` can run. Found exactly that way: the tarball install smoke test died on
|
|
316
|
+
* it while the runtime guard sat there looking sufficient. A namespace member that is absent is merely
|
|
317
|
+
* `undefined`, which is a case code can actually handle.
|
|
318
|
+
*/
|
|
319
|
+
function probeNativeBackend(): { backend: string; truncateHandled?: boolean } | undefined {
|
|
320
|
+
const probe = (natives as { containmentBackend?: () => { backend: string; truncateHandled?: boolean } })
|
|
321
|
+
.containmentBackend;
|
|
322
|
+
return typeof probe === "function" ? probe() : undefined;
|
|
323
|
+
}
|
package/src/tools/bash.ts
CHANGED
|
@@ -16,7 +16,7 @@ import { resolveLocalRoot } from "../internal-urls/local-protocol";
|
|
|
16
16
|
import { truncateToVisualLines } from "../modes/components/visual-truncate";
|
|
17
17
|
import type { Theme } from "../modes/theme/theme";
|
|
18
18
|
import bashDescription from "../prompts/tools/bash.md" with { type: "text" };
|
|
19
|
-
import { buildContainmentFence } from "../sandbox/containment";
|
|
19
|
+
import { buildContainmentFence, containmentStatus } from "../sandbox/containment";
|
|
20
20
|
import { resolveSessionPolicy } from "../sandbox/session-policy";
|
|
21
21
|
import { SECRET_ENV_PATTERNS, type SecretObfuscator } from "../secrets";
|
|
22
22
|
import { DEFAULT_MAX_BYTES, TailBuffer } from "../session/streaming-output";
|
|
@@ -38,6 +38,14 @@ import { clampTimeout } from "./tool-timeouts";
|
|
|
38
38
|
// Module-level obfuscator reference for the renderer (set by BashTool constructor).
|
|
39
39
|
let _sessionObfuscator: SecretObfuscator | undefined;
|
|
40
40
|
|
|
41
|
+
/**
|
|
42
|
+
* Where each session's containment boundary is anchored, captured once and never moved by the model.
|
|
43
|
+
*
|
|
44
|
+
* Keyed on the session object because the tool is built by a factory, so an instance field would reset
|
|
45
|
+
* whenever a new `BashTool` is made. Weak so a finished session is collectable.
|
|
46
|
+
*/
|
|
47
|
+
const FENCE_ANCHORS = new WeakMap<object, { root: string; project: string }>();
|
|
48
|
+
|
|
41
49
|
export const BASH_DEFAULT_PREVIEW_LINES = 10;
|
|
42
50
|
|
|
43
51
|
const BASH_ENV_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
@@ -477,25 +485,61 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
477
485
|
return Math.max(0, Math.min(this.#autoBackgroundThresholdMs, timeoutMs - timeoutBufferMs));
|
|
478
486
|
}
|
|
479
487
|
|
|
488
|
+
/**
|
|
489
|
+
* Where this session's boundary is anchored.
|
|
490
|
+
*
|
|
491
|
+
* **Not the live cwd.** The fence used to be rebuilt from `session.cwd`, which line ~717 replaces
|
|
492
|
+
* with whatever PWD the command ended in — so the model could move the boundary with a `cd`. Measured
|
|
493
|
+
* on a workspace outside the home tree, which is the layout the sibling-checkout deny exists for:
|
|
494
|
+
* with `/work/custA` as the workspace, `/work/custB/secret.env` was denied; `cd /usr` is permitted
|
|
495
|
+
* (correctly — `/usr` is not sensitive), and the *next* fence was rooted at `/usr`, where the parent
|
|
496
|
+
* deny of `/work` cannot be expressed because `dirname("/usr")` is `/` and denying the root is refused
|
|
497
|
+
* as too broad. `/work/custB` then became readable **and** writable. Two tool calls, no exotic
|
|
498
|
+
* spelling, and the tenant boundary was gone.
|
|
499
|
+
*
|
|
500
|
+
* So the anchor is captured once per session and never follows the shell. It is keyed on the session
|
|
501
|
+
* object rather than held on this instance because the tool is built by a factory
|
|
502
|
+
* (`bash: s => new BashTool(s)`) and must not depend on how long an instance happens to live.
|
|
503
|
+
*
|
|
504
|
+
* An *operator* moving the project — startup, or the slash command that calls `setProjectDir` — does
|
|
505
|
+
* re-anchor it. That asymmetry is the point: the operator may move the boundary, the model may not.
|
|
506
|
+
*
|
|
507
|
+
* Note the read/write tools are unaffected by this class of bug: `SandboxPolicy` is deny-by-default,
|
|
508
|
+
* so relocating its root grants nothing new. Only an allow-by-default fence loses a deny when it
|
|
509
|
+
* moves, which is why this needed fixing here and not there.
|
|
510
|
+
*/
|
|
511
|
+
#containmentRoot(): string {
|
|
512
|
+
const project = getProjectDir();
|
|
513
|
+
const anchor = FENCE_ANCHORS.get(this.session);
|
|
514
|
+
if (anchor === undefined) {
|
|
515
|
+
FENCE_ANCHORS.set(this.session, { root: this.session.cwd, project });
|
|
516
|
+
return this.session.cwd;
|
|
517
|
+
}
|
|
518
|
+
if (anchor.project !== project) {
|
|
519
|
+
// The operator moved the project. Re-anchor there rather than at `session.cwd`, which a
|
|
520
|
+
// model `cd` may have moved in the meantime.
|
|
521
|
+
FENCE_ANCHORS.set(this.session, { root: project, project });
|
|
522
|
+
return project;
|
|
523
|
+
}
|
|
524
|
+
return anchor.root;
|
|
525
|
+
}
|
|
526
|
+
|
|
480
527
|
/**
|
|
481
528
|
* The fence for this invocation, or undefined when isolation is off.
|
|
482
529
|
*
|
|
483
530
|
* Built here rather than in the executor because `executeBash` is shared: user-typed `!cmd` and
|
|
484
531
|
* RPC `bash` reach it too, and the same brush-core runs credential helpers and the interactive
|
|
485
532
|
* `xcsh shell`. Only the model's tool call is fenced (#2554).
|
|
486
|
-
*
|
|
487
|
-
* Uses the session's *live* cwd so an in-tree `cd` keeps working, while the fence's own roots
|
|
488
|
-
* decide what is reachable — which is what stops a `cd` out of the tree from taking the boundary
|
|
489
|
-
* with it, without the text layer having to recognise every spelling of `cd`.
|
|
490
533
|
*/
|
|
491
534
|
#containmentFence() {
|
|
492
|
-
const
|
|
535
|
+
const root = this.#containmentRoot();
|
|
536
|
+
const policy = resolveSessionPolicy(root, this.session.settings);
|
|
493
537
|
if (!policy) return undefined; // --no-sandbox / sandbox.enabled = false
|
|
494
538
|
const artifactsDir = this.session.getArtifactsDir?.();
|
|
495
539
|
// The three grants stay distinct. Merging allowRead and allowWrite into one read+write list
|
|
496
540
|
// made a folder shared for reading writable, undoing the split built for #2516.
|
|
497
541
|
return buildContainmentFence({
|
|
498
|
-
workspace:
|
|
542
|
+
workspace: root,
|
|
499
543
|
extraRoots: artifactsDir ? [artifactsDir] : [],
|
|
500
544
|
readOnlyRoots: (this.session.settings.get("sandbox.allowRead") as string[] | undefined) ?? [],
|
|
501
545
|
writeOnlyRoots: (this.session.settings.get("sandbox.allowWrite") as string[] | undefined) ?? [],
|
|
@@ -660,7 +704,23 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
660
704
|
// Allocate artifact for truncated output storage
|
|
661
705
|
const { path: artifactPath, id: artifactId } = (await this.session.allocateOutputArtifact?.("bash")) ?? {};
|
|
662
706
|
|
|
663
|
-
|
|
707
|
+
// The PTY path can only be confined where the OS backend reaches it, and on Linux it does not:
|
|
708
|
+
// Landlock is applied in a `pre_exec` hook, and `portable-pty`'s `CommandBuilder` exposes none
|
|
709
|
+
// (its `as_command` is private, and its `Clone + Debug + PartialEq` derives preclude a closure
|
|
710
|
+
// field ever being added). Since `pty` is a parameter the *model* supplies, leaving it reachable
|
|
711
|
+
// would make containment opt-out by the very caller it constrains — the hole that was just closed
|
|
712
|
+
// on macOS. So a fenced session falls back to the non-PTY path, which is confined.
|
|
713
|
+
//
|
|
714
|
+
// The cost is real and Linux-only: `top`, `less` and `ssh` run without a terminal in a fenced
|
|
715
|
+
// session. Confining the PTY child properly is the follow-up; reporting a boundary that a flag
|
|
716
|
+
// steps around would be worse than losing interactivity.
|
|
717
|
+
// Only worth giving up when there is an OS backend for the non-PTY path to use and none for this
|
|
718
|
+
// one. Where no backend exists — Linux without Landlock, Windows — both paths are scanner-only,
|
|
719
|
+
// so disabling PTY would remove interactive terminals and improve containment by nothing.
|
|
720
|
+
const fence = this.#containmentFence();
|
|
721
|
+
const osBackend = containmentStatus(fence !== undefined);
|
|
722
|
+
const ptyConfinable = !osBackend.osEnforced || osBackend.backend === "seatbelt";
|
|
723
|
+
const usePty = pty && ptyConfinable && $env.PI_NO_PTY !== "1" && ctx?.hasUI === true && ctx.ui !== undefined;
|
|
664
724
|
const result: BashResult | BashInteractiveResult = usePty
|
|
665
725
|
? await runInteractiveBashPty(ctx.ui!, {
|
|
666
726
|
command,
|
|
@@ -671,7 +731,9 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
671
731
|
artifactPath,
|
|
672
732
|
artifactId,
|
|
673
733
|
maskSecrets,
|
|
674
|
-
fence
|
|
734
|
+
// The same fence that decided `ptyConfinable`, so the gate and the enforcement can
|
|
735
|
+
// never be looking at different answers.
|
|
736
|
+
fence,
|
|
675
737
|
})
|
|
676
738
|
: await executeBash(command, {
|
|
677
739
|
cwd: commandCwd,
|
|
@@ -682,7 +744,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
682
744
|
artifactPath,
|
|
683
745
|
artifactId,
|
|
684
746
|
maskSecrets,
|
|
685
|
-
fence
|
|
747
|
+
fence,
|
|
686
748
|
onChunk: chunk => {
|
|
687
749
|
tailBuffer.append(chunk);
|
|
688
750
|
if (onUpdate) {
|