@f5-sales-demo/xcsh 19.99.0 → 19.100.1

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.99.0",
4
+ "version": "19.100.1",
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.99.0",
61
- "@f5-sales-demo/pi-agent-core": "19.99.0",
62
- "@f5-sales-demo/pi-ai": "19.99.0",
63
- "@f5-sales-demo/pi-natives": "19.99.0",
64
- "@f5-sales-demo/pi-resource-management": "19.99.0",
65
- "@f5-sales-demo/pi-tui": "19.99.0",
66
- "@f5-sales-demo/pi-utils": "19.99.0",
60
+ "@f5-sales-demo/xcsh-stats": "19.100.1",
61
+ "@f5-sales-demo/pi-agent-core": "19.100.1",
62
+ "@f5-sales-demo/pi-ai": "19.100.1",
63
+ "@f5-sales-demo/pi-natives": "19.100.1",
64
+ "@f5-sales-demo/pi-resource-management": "19.100.1",
65
+ "@f5-sales-demo/pi-tui": "19.100.1",
66
+ "@f5-sales-demo/pi-utils": "19.100.1",
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
- return prompt.render(containmentTemplate, { containment });
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(
@@ -217,6 +222,16 @@ export function renderAboutDoc(
217
222
  "at /_llms-txt/{topic}.txt, per-page content at /{slug}.md, plus",
218
223
  "llms-small.txt (compact) and llms-full.txt (complete).",
219
224
  "",
225
+ "Every one of those exists per language as well: {locale}/llms.txt,",
226
+ "{locale}/llms-small.txt, {locale}/llms-full.txt, the tiered sets at",
227
+ "/_llms-txt/{locale}/{topic}.txt, and per-page content at /{locale}/{slug}.md.",
228
+ "The root llms.txt lists the languages under `## Translations`. When the user",
229
+ "is working in a language, read that language's set rather than translating the",
230
+ "default locale's — it is already written in their language. Locale segments",
231
+ "are slugs, not BCP-47 tags (pt-br, zh-cn, zh-tw). The default locale's",
232
+ "complete and abridged documents stay at the repo root, so its",
233
+ "{locale}/llms-full.txt is intentionally absent.",
234
+ "",
220
235
  "## Lineage",
221
236
  "",
222
237
  "xcsh is a fork of [badlogic/pi-mono](https://github.com/badlogic/pi-mono).",
@@ -17,17 +17,17 @@ export interface BuildInfo {
17
17
  }
18
18
 
19
19
  export const BUILD_INFO: BuildInfo = {
20
- "version": "19.99.0",
21
- "commit": "8020e6b8aa7ae0c4fd4ac6da9ee9fa81b77182f0",
22
- "shortCommit": "8020e6b",
20
+ "version": "19.100.1",
21
+ "commit": "ab1af6daf7fdebc0fd0a193bdae578f0e9395eab",
22
+ "shortCommit": "ab1af6d",
23
23
  "branch": "main",
24
- "tag": "v19.99.0",
25
- "commitDate": "2026-07-28T17:45:44Z",
26
- "buildDate": "2026-07-28T18:11:25.457Z",
24
+ "tag": "v19.100.1",
25
+ "commitDate": "2026-07-29T00:48:34Z",
26
+ "buildDate": "2026-07-29T01:14:16.102Z",
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/8020e6b8aa7ae0c4fd4ac6da9ee9fa81b77182f0",
32
- "releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v19.99.0"
31
+ "commitUrl": "https://github.com/f5-sales-demo/xcsh/commit/ab1af6daf7fdebc0fd0a193bdae578f0e9395eab",
32
+ "releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v19.100.1"
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(enabled: boolean, platform: string = process.platform): 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
- // Only the macOS seatbelt backend exists today. Linux Landlock is a follow-up; until it lands,
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 policy = resolveSessionPolicy(this.session.cwd, this.session.settings);
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: this.session.cwd,
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
- const usePty = pty && $env.PI_NO_PTY !== "1" && ctx?.hasUI === true && ctx.ui !== undefined;
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: this.#containmentFence(),
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: this.#containmentFence(),
747
+ fence,
686
748
  onChunk: chunk => {
687
749
  tailBuffer.append(chunk);
688
750
  if (onUpdate) {
@@ -15,6 +15,7 @@ import { renderStatusLine } from "../tui";
15
15
  import { CachedOutputBlock } from "../tui/output-block";
16
16
  import { formatDimensionNote, resizeImage } from "../utils/image-resize";
17
17
  import { ensureTool } from "../utils/tools-manager";
18
+ import { buildLlmEndpointCandidates } from "../web/llms-endpoints";
18
19
  import { extractWithParallel, findParallelApiKey, getParallelExtractContent } from "../web/parallel";
19
20
  import { specialHandlers } from "../web/scrapers";
20
21
  import type { RenderResult } from "../web/scrapers/types";
@@ -97,32 +98,6 @@ function hasCommand(cmd: string): boolean {
97
98
  return Boolean($which(cmd));
98
99
  }
99
100
 
100
- /**
101
- * Build llms.txt candidates scoped to the requested URL
102
- */
103
- function buildLlmEndpointCandidates(url: string): string[] {
104
- try {
105
- const parsed = new URL(url);
106
- if (parsed.pathname === "/") {
107
- return [`${parsed.origin}/.well-known/llms.txt`, `${parsed.origin}/llms.txt`, `${parsed.origin}/llms.md`];
108
- }
109
-
110
- const trimmedPath = parsed.pathname.replace(/\/+$/, "");
111
- const segments = trimmedPath.split("/").filter(Boolean);
112
- const scopeDepth = parsed.pathname.endsWith("/") ? segments.length : Math.max(segments.length - 1, 1);
113
- const endpoints: string[] = [];
114
-
115
- for (let depth = scopeDepth; depth >= 1; depth--) {
116
- const scope = `/${segments.slice(0, depth).join("/")}/`;
117
- endpoints.push(`${parsed.origin}${scope}llms.txt`, `${parsed.origin}${scope}llms.md`);
118
- }
119
-
120
- return endpoints;
121
- } catch {
122
- return [];
123
- }
124
- }
125
-
126
101
  /**
127
102
  * Normalize URL (add scheme if missing)
128
103
  */
@@ -0,0 +1,41 @@
1
+ /**
2
+ * llms.txt endpoint discovery.
3
+ *
4
+ * Kept free of native and network imports so the scope walk can be unit tested
5
+ * on its own: it is the only thing that decides whether a request for a
6
+ * translated page reaches that language's llms.txt index or silently falls back
7
+ * to the default locale's.
8
+ */
9
+
10
+ /**
11
+ * Build llms.txt candidates scoped to the requested URL, deepest scope first.
12
+ *
13
+ * The federated product docs publish an index per language at
14
+ * `<locale>/llms.txt` alongside the one at the repo root, so walking outwards
15
+ * from the deepest path scope is what lets a request for `/mcn/ja/demo/` find
16
+ * the Japanese index before falling back to `/mcn/llms.txt`. Reversing the walk,
17
+ * or capping it at the repository segment, would send every non-default-locale
18
+ * request back to default-locale context.
19
+ */
20
+ export function buildLlmEndpointCandidates(url: string): string[] {
21
+ try {
22
+ const parsed = new URL(url);
23
+ if (parsed.pathname === "/") {
24
+ return [`${parsed.origin}/.well-known/llms.txt`, `${parsed.origin}/llms.txt`, `${parsed.origin}/llms.md`];
25
+ }
26
+
27
+ const trimmedPath = parsed.pathname.replace(/\/+$/, "");
28
+ const segments = trimmedPath.split("/").filter(Boolean);
29
+ const scopeDepth = parsed.pathname.endsWith("/") ? segments.length : Math.max(segments.length - 1, 1);
30
+ const endpoints: string[] = [];
31
+
32
+ for (let depth = scopeDepth; depth >= 1; depth--) {
33
+ const scope = `/${segments.slice(0, depth).join("/")}/`;
34
+ endpoints.push(`${parsed.origin}${scope}llms.txt`, `${parsed.origin}${scope}llms.md`);
35
+ }
36
+
37
+ return endpoints;
38
+ } catch {
39
+ return [];
40
+ }
41
+ }