@f5-sales-demo/xcsh 20.4.1 → 20.4.2

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": "20.4.1",
4
+ "version": "20.4.2",
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",
@@ -60,13 +60,13 @@
60
60
  "dependencies": {
61
61
  "@agentclientprotocol/sdk": "1.3.0",
62
62
  "@mozilla/readability": "^0.6",
63
- "@f5-sales-demo/xcsh-stats": "20.4.1",
64
- "@f5-sales-demo/pi-agent-core": "20.4.1",
65
- "@f5-sales-demo/pi-ai": "20.4.1",
66
- "@f5-sales-demo/pi-natives": "20.4.1",
67
- "@f5-sales-demo/pi-resource-management": "20.4.1",
68
- "@f5-sales-demo/pi-tui": "20.4.1",
69
- "@f5-sales-demo/pi-utils": "20.4.1",
63
+ "@f5-sales-demo/xcsh-stats": "20.4.2",
64
+ "@f5-sales-demo/pi-agent-core": "20.4.2",
65
+ "@f5-sales-demo/pi-ai": "20.4.2",
66
+ "@f5-sales-demo/pi-natives": "20.4.2",
67
+ "@f5-sales-demo/pi-resource-management": "20.4.2",
68
+ "@f5-sales-demo/pi-tui": "20.4.2",
69
+ "@f5-sales-demo/pi-utils": "20.4.2",
70
70
  "@sinclair/typebox": "^0.34",
71
71
  "@xterm/headless": "^6.0",
72
72
  "ajv": "^8.20",
@@ -168,7 +168,7 @@ function renderReport(report: SandboxCheckReport, json: boolean, verbose: boolea
168
168
 
169
169
  /** Run the conformance matrix and report only after every synthetic fixture has been removed. */
170
170
  export async function runSandboxCheck(options: SandboxCheckOptions = {}): Promise<SandboxCheckReport> {
171
- const backend = containmentStatus(true);
171
+ let backend = containmentStatus(true);
172
172
  const checks: SandboxCheckResult[] = [];
173
173
  const fixturePaths: string[] = [];
174
174
  const knownCleanupLeaves: string[] = [];
@@ -218,18 +218,20 @@ export async function runSandboxCheck(options: SandboxCheckOptions = {}): Promis
218
218
  // session parent — which is the operator home for a `~/<workspace>` layout (#2807).
219
219
  const liveWorkspace = inheritedWorkspace ?? (await fs.realpath(workspaceInput));
220
220
  const liveHome = inheritedHome ?? (await fs.realpath(homeInput));
221
- redactions.push([liveWorkspace, "<workspace>"], [liveHome, "<operator-home>"]);
221
+ const liveSystemTmp = await fs.realpath(os.tmpdir());
222
+ const liveSessionParent = path.dirname(liveWorkspace);
223
+ const liveAccountRoot = path.dirname(liveHome);
224
+ redactions.push(
225
+ [liveWorkspace, "<workspace>"],
226
+ [liveHome, "<operator-home>"],
227
+ [liveSystemTmp, "<system-temp>"],
228
+ [liveSessionParent, "<session-parent>"],
229
+ [liveAccountRoot, "<account-container>"],
230
+ );
222
231
  if (inheritedSibling !== undefined) redactions.push([inheritedSibling, "<session-parent>/<synthetic-sibling>"]);
223
232
 
224
- // A home-rooted Landlock profile must split the home grant around protected session stores. The
225
- // kernel can grant an existing named child but cannot grant future children of that split root
226
- // without also reopening the protected stores. BashTool therefore prepares one host-owned child
227
- // before confinement; keep all exact-home live fixtures beneath that already-granted directory.
228
- const liveWritableRoot =
229
- inheritedProfile && liveWorkspace === liveHome && inheritedSibling !== undefined
230
- ? inheritedSibling
231
- : liveWorkspace;
232
- const fixtureBase = inheritedProfile ? liveWritableRoot : await fs.realpath(os.tmpdir());
233
+ const liveWritableRoot = liveWorkspace;
234
+ const fixtureBase = inheritedProfile ? liveWritableRoot : liveSystemTmp;
233
235
  fixtureRoot = await fs.mkdtemp(path.join(fixtureBase, ".xcsh-sandbox-check-policy-"));
234
236
  fixturePaths.push(fixtureRoot);
235
237
  redactions.push([fixtureRoot, "<synthetic-root>"]);
@@ -262,23 +264,15 @@ export async function runSandboxCheck(options: SandboxCheckOptions = {}): Promis
262
264
  fsRoot: fixtureRoot,
263
265
  leakRoots: [sessionStore, memoryStore],
264
266
  });
267
+ const liveFence = buildContainmentFence({ workspace: liveWorkspace, home: liveHome });
268
+ backend = containmentStatus(true, process.platform, undefined, liveFence);
265
269
 
266
270
  await check("structured tools share the boundary", () => {
267
271
  const blocked = [
268
272
  evaluateToolCall({ toolName: "read", input: { file_path: workspaces }, cwd: workspace, fence }),
269
273
  evaluateToolCall({ toolName: "find", input: { pattern: `${accountRoot}/**/*` }, cwd: workspace, fence }),
270
- evaluateToolCall({
271
- toolName: "read",
272
- input: { file_path: path.join(otherSession, "state.jsonl") },
273
- cwd: workspace,
274
- fence,
275
- }),
276
- evaluateToolCall({
277
- toolName: "read",
278
- input: { file_path: path.join(otherMemory, "MEMORY.md") },
279
- cwd: workspace,
280
- fence,
281
- }),
274
+ evaluateToolCall({ toolName: "read", input: { file_path: sessionStore }, cwd: workspace, fence }),
275
+ evaluateToolCall({ toolName: "find", input: { pattern: `${memoryStore}/**/*` }, cwd: workspace, fence }),
282
276
  ];
283
277
  const allowed = [
284
278
  evaluateToolCall({
@@ -300,6 +294,18 @@ export async function runSandboxCheck(options: SandboxCheckOptions = {}): Promis
300
294
  cwd: workspace,
301
295
  fence,
302
296
  }),
297
+ evaluateToolCall({
298
+ toolName: "read",
299
+ input: { file_path: path.join(otherSession, "state.jsonl") },
300
+ cwd: workspace,
301
+ fence,
302
+ }),
303
+ evaluateToolCall({
304
+ toolName: "read",
305
+ input: { file_path: path.join(otherMemory, "MEMORY.md") },
306
+ cwd: workspace,
307
+ fence,
308
+ }),
303
309
  ];
304
310
  const passed = blocked.every(result => result.block) && allowed.every(result => !result.block);
305
311
  return passed
@@ -385,6 +391,77 @@ export async function runSandboxCheck(options: SandboxCheckOptions = {}): Promis
385
391
  );
386
392
  });
387
393
 
394
+ await check("system temp supports direct file creation", async () => {
395
+ const displayPath = "<system-temp>/<synthetic-fixture>";
396
+ const template = path.join(liveSystemTmp, ".xcsh-sandbox-check-tmp-XXXXXX");
397
+ const command =
398
+ `probe=$(mktemp ${quote(template)}) || exit $?; ` +
399
+ `trap 'rm -f "$probe"' EXIT; printf temporary > "$probe" && ` +
400
+ `test "$(cat "$probe")" = temporary && rm "$probe"`;
401
+ const result = await shellProbe(
402
+ command,
403
+ liveWorkspace,
404
+ inheritedProfile ? undefined : liveFence,
405
+ abortController.signal,
406
+ );
407
+ return shellOutcome(
408
+ result,
409
+ true,
410
+ "live profile must allow direct system-temp creation and removal",
411
+ displayPath,
412
+ redactions,
413
+ );
414
+ });
415
+
416
+ await check("system temp remains enumerable", async () => {
417
+ const result = await shellProbe(
418
+ `ls ${quote(liveSystemTmp)} > /dev/null`,
419
+ liveWorkspace,
420
+ inheritedProfile ? undefined : liveFence,
421
+ abortController.signal,
422
+ );
423
+ return shellOutcome(
424
+ result,
425
+ true,
426
+ "live profile must preserve normal system-temp enumeration",
427
+ "<system-temp>",
428
+ redactions,
429
+ );
430
+ });
431
+
432
+ await check("operator home remains enumerable", async () => {
433
+ const result = await shellProbe(
434
+ `ls ${quote(liveHome)} > /dev/null`,
435
+ liveWorkspace,
436
+ inheritedProfile ? undefined : liveFence,
437
+ abortController.signal,
438
+ );
439
+ return shellOutcome(
440
+ result,
441
+ true,
442
+ "live profile must preserve normal operator-home enumeration",
443
+ "<operator-home>",
444
+ redactions,
445
+ );
446
+ });
447
+
448
+ await check("filesystem root remains enumerable", async () => {
449
+ const filesystemRoot = path.parse(liveWorkspace).root;
450
+ const result = await shellProbe(
451
+ `ls ${quote(filesystemRoot)} > /dev/null`,
452
+ liveWorkspace,
453
+ inheritedProfile ? undefined : liveFence,
454
+ abortController.signal,
455
+ );
456
+ return shellOutcome(
457
+ result,
458
+ true,
459
+ "live profile must preserve normal filesystem-root enumeration",
460
+ "<filesystem-root>",
461
+ redactions,
462
+ );
463
+ });
464
+
388
465
  await check("named sibling remains reachable", async () => {
389
466
  const displayPath = "<session-parent>/<synthetic-sibling>";
390
467
  let liveSibling = inheritedSibling;
@@ -413,17 +490,21 @@ export async function runSandboxCheck(options: SandboxCheckOptions = {}): Promis
413
490
  });
414
491
 
415
492
  if (backend.osEnforced) {
416
- await check("session parent cannot be enumerated", async () => {
493
+ await check("session parent discovery respects operator home", async () => {
494
+ const probeParent = inheritedProfile ? liveSessionParent : workspaces;
495
+ const parentIsOperatorHome = probeParent === liveHome;
417
496
  const result = await shellProbe(
418
- `ls ${quote(workspaces)} > /dev/null`,
497
+ `ls ${quote(probeParent)} > /dev/null`,
419
498
  workspace,
420
- fence,
499
+ inheritedProfile ? undefined : fence,
421
500
  abortController.signal,
422
501
  );
423
502
  return shellOutcome(
424
503
  result,
425
- false,
426
- "synthetic session parent enumeration must be refused",
504
+ parentIsOperatorHome,
505
+ parentIsOperatorHome
506
+ ? "operator home must remain enumerable when it is the session parent"
507
+ : "synthetic session parent enumeration must be refused",
427
508
  "<synthetic-session-parent>",
428
509
  redactions,
429
510
  );
@@ -469,10 +550,11 @@ export async function runSandboxCheck(options: SandboxCheckOptions = {}): Promis
469
550
  );
470
551
  });
471
552
  await check("account container cannot be enumerated", async () => {
553
+ const probeAccountRoot = inheritedProfile ? liveAccountRoot : accountRoot;
472
554
  const result = await shellProbe(
473
- `ls ${quote(accountRoot)} > /dev/null`,
555
+ `ls ${quote(probeAccountRoot)} > /dev/null`,
474
556
  workspace,
475
- fence,
557
+ inheritedProfile ? undefined : fence,
476
558
  abortController.signal,
477
559
  );
478
560
  return shellOutcome(
@@ -493,74 +575,94 @@ export async function runSandboxCheck(options: SandboxCheckOptions = {}): Promis
493
575
  redactions,
494
576
  );
495
577
  });
496
- await check("cross-session stores cannot be read", async () => {
497
- const sessionRead = await shellProbe(
498
- `cat ${quote(path.join(otherSession, "state.jsonl"))} > /dev/null`,
578
+ await check("cross-session stores hide listings and keep named access", async () => {
579
+ let probeStore = sessionStore;
580
+ let knownPaths = [path.join(otherSession, "state.jsonl"), path.join(otherMemory, "MEMORY.md")];
581
+ let probeFence: ContainmentFence | undefined = fence;
582
+ if (inheritedProfile) {
583
+ const livePrivateContainer = path.join(liveSystemTmp, "xcsh-local");
584
+ try {
585
+ await fs.mkdir(livePrivateContainer, { recursive: true });
586
+ const livePrivateFixture = await fs.mkdtemp(
587
+ path.join(livePrivateContainer, ".xcsh-sandbox-check-private-"),
588
+ );
589
+ fixturePaths.push(livePrivateFixture);
590
+ redactions.push(
591
+ [livePrivateContainer, "<live-private-container>"],
592
+ [livePrivateFixture, "<live-private-container>/<synthetic-session>"],
593
+ );
594
+ const knownState = path.join(livePrivateFixture, "state.jsonl");
595
+ await Bun.write(knownState, "synthetic\n");
596
+ probeStore = livePrivateContainer;
597
+ knownPaths = [knownState];
598
+ probeFence = undefined;
599
+ } catch (error) {
600
+ return exceptionOutcome(
601
+ "create a known path in the live private temp container",
602
+ "<live-private-container>/<synthetic-session>",
603
+ error,
604
+ redactions,
605
+ );
606
+ }
607
+ }
608
+ const sessionListing = await shellProbe(
609
+ `ls ${quote(probeStore)} > /dev/null`,
499
610
  workspace,
500
- fence,
611
+ probeFence,
501
612
  abortController.signal,
502
613
  );
503
614
  const sessionOutcome = shellOutcome(
504
- sessionRead,
615
+ sessionListing,
505
616
  false,
506
- "synthetic other session read must be refused",
507
- "<synthetic-session-store>/<synthetic-session>",
617
+ "synthetic session-store listing must be refused",
618
+ "<synthetic-session-store>",
508
619
  redactions,
509
620
  );
510
621
  if (!sessionOutcome.passed) return sessionOutcome;
511
622
 
512
- const memoryRead = await shellProbe(
513
- `cat ${quote(path.join(otherMemory, "MEMORY.md"))} > /dev/null`,
623
+ const namedReads = await shellProbe(
624
+ `cat ${knownPaths.map(quote).join(" ")} > /dev/null`,
514
625
  workspace,
515
- fence,
626
+ probeFence,
516
627
  abortController.signal,
517
628
  );
518
629
  return shellOutcome(
519
- memoryRead,
520
- false,
521
- "synthetic other memory read must be refused",
522
- "<synthetic-memory-store>/<synthetic-memory>",
630
+ namedReads,
631
+ true,
632
+ "known synthetic cross-session paths must keep operator access",
633
+ "<synthetic-session-store>/<known-path>",
523
634
  redactions,
524
635
  );
525
636
  });
526
637
  } else {
527
638
  for (const name of [
528
- "session parent cannot be enumerated",
639
+ "session parent discovery respects operator home",
529
640
  "explicit grant restores parent enumeration",
530
641
  "account container cannot be enumerated",
531
642
  "named other account remains reachable",
532
- "cross-session stores cannot be read",
643
+ "cross-session stores hide listings and keep named access",
533
644
  ]) {
534
645
  add(name, "SKIP", "OS enforcement backend unavailable; path=<probe>; errno=unsupported");
535
646
  }
536
647
  }
537
648
 
538
- await check("operator home configuration is writable", async () => {
649
+ await check("operator home supports direct file creation", async () => {
539
650
  const displayPath = "<operator-home>/<synthetic-fixture>";
540
- let liveConfig: string;
541
- try {
542
- // Landlock cannot grant creation on a split directory without also granting every
543
- // denied descendant. The live fence therefore grants operator-owned CLI config roots
544
- // explicitly; use one of those when another Landlock profile is already inherited.
545
- // Standalone and Seatbelt checks retain the direct-home probe.
546
- const configBase =
547
- inheritedProfile && backend.backend === "landlock" ? path.join(liveHome, ".config", "gh") : liveHome;
548
- liveConfig = await fs.mkdtemp(path.join(configBase, ".xcsh-sandbox-check-home-"));
549
- fixturePaths.push(liveConfig);
550
- redactions.push([liveConfig, displayPath]);
551
- } catch (error) {
552
- return exceptionOutcome("create operator-home fixture", displayPath, error, redactions);
553
- }
651
+ const template = path.join(liveHome, ".xcsh-sandbox-check-home-XXXXXX");
652
+ const command =
653
+ `probe=$(mktemp ${quote(template)}) || exit $?; ` +
654
+ `trap 'rm -f "$probe"' EXIT; printf operator > "$probe" && ` +
655
+ `test "$(cat "$probe")" = operator && rm "$probe"`;
554
656
  const result = await shellProbe(
555
- 'printf operator > config && test "$(cat config)" = operator',
556
- liveConfig,
557
- undefined,
657
+ command,
658
+ liveWorkspace,
659
+ inheritedProfile ? undefined : liveFence,
558
660
  abortController.signal,
559
661
  );
560
662
  return shellOutcome(
561
663
  result,
562
664
  true,
563
- "live profile must allow operator-home configuration writes",
665
+ "live profile must allow direct operator-home creation and removal",
564
666
  displayPath,
565
667
  redactions,
566
668
  );
@@ -1810,22 +1810,19 @@ export const SETTINGS_SCHEMA = {
1810
1810
  ui: { tab: "providers", label: "Hide Secrets", description: "Obfuscate secrets before sending to AI providers" },
1811
1811
  },
1812
1812
 
1813
- // Session filesystem isolation. Confines the file tools (read/write/edit/find/grep)
1814
- // and the Bash working directory to the session's CWD subtree plus a curated global
1815
- // allowlist, so concurrent sessions in different customer folders cannot read or
1816
- // write each other's files, secrets, or memory. See src/sandbox/.
1813
+ // Session filesystem isolation is a discovery courtesy, not a user-rights policy. It hides selected
1814
+ // cross-session container listings while named paths retain the operator's normal access.
1817
1815
  "sandbox.enabled": {
1818
1816
  type: "boolean",
1819
1817
  default: true,
1820
1818
  ui: {
1821
1819
  tab: "sandbox",
1822
1820
  label: "Filesystem isolation",
1823
- description: "Confine file tools to the working directory subtree (blocks cross-session access)",
1821
+ description: "Hide cross-session container listings without restricting named operator access",
1824
1822
  },
1825
1823
  },
1826
- // Extra roots (beyond the CWD subtree) the session may read/write. Config/CLI only
1827
- // e.g. `--allow-path <dir>` maps into both. The hardcoded cross-session leak-denies
1828
- // (other sessions' memories/sessions and the shared tenant contexts) always win.
1824
+ // Historical names retained for compatibility. Either list now restores discovery and leaves the
1825
+ // operator's ordinary read/write rights unchanged; `--allow-path <dir>` maps into both.
1829
1826
  "sandbox.allowRead": { type: "array", default: [] as string[] },
1830
1827
  "sandbox.allowWrite": { type: "array", default: [] as string[] },
1831
1828
 
@@ -6,15 +6,13 @@ import { resolveSessionFence } from "../../../sandbox/session-fence";
6
6
  /**
7
7
  * Session filesystem sandbox (bundled, default-on).
8
8
  *
9
- * Confines the model-invoked file tools (read/write/edit/find/grep) and the Bash
10
- * working directory to the session's CWD subtree plus a curated global allowlist, so
11
- * concurrent sessions in different customer folders cannot read or write each other's
12
- * files, secrets, or memory. Enforcement is a `tool_call` gate: the extension wrapper
9
+ * Removes casual cross-session discovery from model-invoked filesystem tools while preserving the
10
+ * operator's normal rights on every named path. Enforcement is a `tool_call` gate: the extension wrapper
13
11
  * blocks the tool when this returns `{ block: true }`, and fails safe (a thrown handler
14
12
  * also blocks).
15
13
  *
16
14
  * The boundary is derived from `ctx.cwd` (always the live session's directory) plus the
17
- * `sandbox.*` settings. Controlled by `sandbox.enabled` (default true); widened per run
15
+ * `sandbox.*` settings. Controlled by `sandbox.enabled` (default true); discovery is widened per run
18
16
  * with `--allow-path` / `--no-sandbox` or the `sandbox.allow*` settings.
19
17
  */
20
18
  export default function sandboxGuard(pi: ExtensionAPI): void {
@@ -17,17 +17,17 @@ export interface BuildInfo {
17
17
  }
18
18
 
19
19
  export const BUILD_INFO: BuildInfo = {
20
- "version": "20.4.1",
21
- "commit": "eb7f355ecbeb2a6a44003ad13128ee973824a2e4",
22
- "shortCommit": "eb7f355",
20
+ "version": "20.4.2",
21
+ "commit": "b85f1346a0ed047c05151f127ba9628612b386a3",
22
+ "shortCommit": "b85f134",
23
23
  "branch": "main",
24
- "tag": "v20.4.1",
25
- "commitDate": "2026-08-05T01:08:56Z",
26
- "buildDate": "2026-08-05T01:37:18.582Z",
24
+ "tag": "v20.4.2",
25
+ "commitDate": "2026-08-05T05:46:35Z",
26
+ "buildDate": "2026-08-05T06:17:35.937Z",
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/eb7f355ecbeb2a6a44003ad13128ee973824a2e4",
32
- "releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v20.4.1"
31
+ "commitUrl": "https://github.com/f5-sales-demo/xcsh/commit/b85f1346a0ed047c05151f127ba9628612b386a3",
32
+ "releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v20.4.2"
33
33
  };
@@ -16,9 +16,9 @@ followed symlinks — so how a path is spelled does not change what is reachable
16
16
  - Cross-tenant isolation removes the discovery step. The session container, local-account containers,
17
17
  data roots, and mounted-data containers cannot be enumerated, but a descendant path the operator
18
18
  names directly can still be read, written, or entered. An explicit read grant restores enumeration.
19
- - Xcsh-private cross-session stores remain denied recursively. Another session's transcripts, memories,
20
- internal contexts, credentials, and temporary working state are not reachable unless the operator
21
- explicitly grants the relevant root.
19
+ - Xcsh-private cross-session stores follow the same discovery boundary: their container cannot be
20
+ enumerated, while a descendant path the operator names directly keeps the operator's normal rights.
21
+ This preserves `/tmp`, home, credentials, package managers, and ordinary tooling without workarounds.
22
22
 
23
23
  Structured filesystem tools and the `bash` runtime consult the same fence. Bash command text is not
24
24
  scanned for path-looking strings; the operating system decides when a process actually opens a path.
@@ -33,10 +33,10 @@ operator. The rule of thumb is that if a file belongs to someone other than the
33
33
  working with, it is not yours to read.
34
34
 
35
35
  {{#if containment.landlock}}
36
- Three things behave differently under this backend, and none of them is a bug to work around:
36
+ Three things behave differently under this backend, and none is a bug to work around:
37
37
 
38
- - `ls /` can fail because a kernel rule cannot expose a directory that mixes reachable and denied data
39
- roots. Listing a specific reachable directory works normally.
38
+ - `ls /` can fail because a kernel rule cannot expose a directory whose descendants have different
39
+ enumeration rights. Listing a specific reachable directory works normally.
40
40
  - `sudo` and other setuid programs do not work, because confining a process requires giving up the
41
41
  ability to gain privileges.
42
42
  - Interactive terminal programs (`top`, `less`, an interactive `ssh`) run without a real terminal here,
@@ -47,7 +47,15 @@ Three things behave differently under this backend, and none of them is a bug to
47
47
  {{/if}}
48
48
  {{/if}}
49
49
  {{else}}
50
- On this platform there is **no OS-level backend**, so for `bash` the boundary is enforced only by
50
+ {{#if containment.discoveryOnly}}
51
+ This Linux session deliberately does **not** arm Landlock for its discovery-only profile. Landlock
52
+ cannot hide one nested directory listing without also breaking ordinary ancestor listings such as
53
+ `ls ~`, `ls /tmp`, and `ls /`; arming it also disables PTYs and setuid tools such as `sudo`. Those
54
+ costs would turn a cross-context courtesy into a user-rights control.
55
+
56
+ {{else}}
57
+ This session is **not using an OS-level backend**, so for `bash` the boundary is enforced only by
58
+ {{/if}}
51
59
  precise pre-checks for an explicit `cwd`, literal redirections, known write operands, and literal
52
60
  directory changes. Command and source text are never scanned for path-looking strings.
53
61
 
@@ -7,9 +7,8 @@
7
7
  * profile could not even `execvp /bin/cat`.
8
8
  *
9
9
  * So the fence is gentle. It leaves `/usr`, `/tmp`, package caches, the network and process execution
10
- * alone. Its cross-tenant courtesy removes discovery by enumerating session, account, and data
11
- * containers while keeping named operator access. Xcsh-private cross-session state remains denied
12
- * recursively unless the operator grants it explicitly (#2931).
10
+ * alone. Its cross-tenant courtesy removes discovery by enumerating session, account, data, and
11
+ * xcsh-private containers while keeping named operator access (#2931, #2952).
13
12
  *
14
13
  * Produced declaratively rather than as an ordered rule list, because the two backends disagree about
15
14
  * order: seatbelt evaluates rules in sequence with the last match winning, while Landlock only grants
@@ -375,12 +374,14 @@ function otherFilesystemRoots(fsRoot: string): string[] {
375
374
  *
376
375
  * `local://` content lands at `<tmp>/xcsh-local/<sessionId>` (`internal-urls/local-protocol.ts`) and a
377
376
  * task's artifacts at `<tmp>/xcsh-tasks/<id>` (`task/index.ts`) whenever no session artifacts dir is
378
- * configured. Those are the same class as `~/.xcsh/agent/sessions` — one session reading another's
379
- * working notes so they belong in the leak roots rather than being covered incidentally.
377
+ * configured. Those are the same class as `~/.xcsh/agent/sessions` — another session's working notes —
378
+ * so their parent listings belong in the leak roots rather than being covered incidentally.
380
379
  *
381
380
  * Nothing else in the temp dir is touched: `xcsh://about` promises `/tmp` is reachable, and refusing it
382
- * wholesale is the false refusal #2582 removed. The session's OWN local root is granted back through
383
- * `extraRoots` at greater depth, so `local://` keeps working.
381
+ * wholesale is the false refusal #2582 removed. These roots lose enumeration only. A recursive deny
382
+ * beneath `/tmp` makes Landlock split that writable parent, which prevents ordinary programs from
383
+ * creating a direct child there (#2952). Named access therefore keeps the operator's normal rights,
384
+ * while the session's own local root remains discoverable through its known path.
384
385
  *
385
386
  * **Two fixed parents, deliberately never enumerated.** The first version listed the temp dir looking for
386
387
  * `xcsh-task-*` siblings, which cost 15ms of a 25-42ms fence build on a 17k-entry temp directory — per
@@ -475,7 +476,10 @@ export function buildContainmentFence(options: ContainmentOptions): ContainmentF
475
476
  const parentExplicitlyReadable = [...extraResolved, ...readOnlyResolved].some(root =>
476
477
  pathIsWithin(root, parentToProtect),
477
478
  );
478
- if (!tooBroadToDeny(parentToProtect, fsRoot) && !parentExplicitlyReadable) {
479
+ // Home itself is an operator workspace, not a customer-container boundary. Hiding its listing when a
480
+ // project is a direct child made ordinary shell navigation fail even on Seatbelt. Deeper project
481
+ // containers are still protected, but the operator always retains a normal `ls ~` experience.
482
+ if (parentToProtect !== home && !tooBroadToDeny(parentToProtect, fsRoot) && !parentExplicitlyReadable) {
479
483
  denyEnumerate.add(parentToProtect);
480
484
  }
481
485
 
@@ -532,25 +536,27 @@ export function buildContainmentFence(options: ContainmentOptions): ContainmentF
532
536
  denyEnumerate.add(resolved);
533
537
  }
534
538
 
535
- // Cross-session leak roots. These may sit *under* an allowed root the agent dir is inside home,
536
- // and a session whose workspace is the agent dir would otherwise re-expose every other session's
537
- // transcript. `fenceVerdict` resolves that by depth, so nesting is safe rather than accidental.
539
+ // Cross-session leak roots lose their exact directory listing, just like sibling workspace and
540
+ // account containers. Named descendants keep the operator's normal filesystem rights. This is
541
+ // deliberate rather than a weaker approximation: Landlock is allow-only, so recursively denying a
542
+ // child of `/tmp` or home prevents creating any new direct child in that parent (#2952). A professional
543
+ // tool must not require TMPDIR workarounds or a pre-created home subdirectory merely to run.
538
544
  //
539
- // Emitted even when absent: a rule that appears only once the directory does is one nobody can rely
540
- // on, and creating it would otherwise be the way around it. This also covers relocated agent state.
545
+ // Emitted even when absent: a root created after the session starts must already have its listing
546
+ // protected. This also covers relocated agent state without enumerating home or the OS temp dir.
541
547
  const leaks = options.leakRoots ?? [
542
548
  getMemoriesDir(),
543
549
  getSessionsDir(),
544
550
  getXCSHContextsDir(),
545
551
  ...sharedTempLeakRoots(),
546
552
  ];
547
- const explicitGrants = [...extraResolved, ...readOnlyResolved, ...writeOnlyResolved];
553
+ const explicitlyReadable = [...extraResolved, ...readOnlyResolved];
548
554
  for (const leak of leaks) {
549
555
  const resolved = canonicalThroughExisting(leak);
550
- // A grant at or above a private root is an explicit operator override. Directional grants stay
551
- // directional because their allowReadOnly/allowWriteOnly rule remains the deepest matching rule.
552
- if (explicitGrants.some(root => pathIsWithin(root, resolved))) continue;
553
- deny.add(resolved);
556
+ // A full or read grant at or above a private root explicitly restores its listing. A write-only
557
+ // grant does not imply permission to discover entries, so it leaves this exact protection intact.
558
+ if (explicitlyReadable.some(root => pathIsWithin(root, resolved))) continue;
559
+ denyEnumerate.add(resolved);
554
560
  }
555
561
 
556
562
  return {
@@ -602,6 +608,8 @@ export interface ContainmentStatus {
602
608
  readonly backend: ContainmentBackend;
603
609
  /** True when the kernel enforces it, false when only precise tool-call pre-checks run. */
604
610
  readonly osEnforced: boolean;
611
+ /** Linux discovery-only profiles stay scanner-only so Landlock cannot remove ordinary ancestor listings. */
612
+ readonly discoveryOnly?: true;
605
613
  /**
606
614
  * Set when the backend enforces reads and writes but cannot govern truncation.
607
615
  *
@@ -625,14 +633,34 @@ export interface ContainmentStatus {
625
633
  *
626
634
  * Deliberately not surfaced at startup or anywhere in the TUI — the operator asked for no UI change.
627
635
  */
636
+ /**
637
+ * Whether Linux needs to arm Landlock for this fence.
638
+ *
639
+ * Exact enumeration denies are the production session courtesy, but Landlock cannot express one without
640
+ * also removing READ_DIR from every ancestor. Arming it made `ls ~`, `ls /tmp`, and `ls /` fail and also
641
+ * set `no_new_privs`, disabling sudo. Keep those checks in brush and the structured-tool gate. Recursive
642
+ * or directional low-level policies still require the kernel backend and retain their stricter contract.
643
+ */
644
+ export function requiresLandlock(fence: ContainmentFence): boolean {
645
+ return fence.deny.length > 0 || fence.allowReadOnly.length > 0 || fence.allowWriteOnly.length > 0;
646
+ }
647
+
628
648
  export function containmentStatus(
629
649
  enabled: boolean,
630
650
  platform: string = process.platform,
631
651
  probe: () => { backend: string; truncateHandled?: boolean } | undefined = probeNativeBackend,
652
+ fence?: ContainmentFence,
632
653
  ): ContainmentStatus {
633
654
  if (!enabled) return { enabled: false, backend: "disabled", osEnforced: false };
634
655
  // macOS always has seatbelt, so there is nothing to ask.
635
656
  if (platform === "darwin") return { enabled: true, backend: "seatbelt", osEnforced: true };
657
+ // Landlock is subtree-based. Applying it to an exact-listing-only courtesy removes normal access to
658
+ // ancestor listings and sets no_new_privs even though it cannot faithfully enforce the intended rule.
659
+ // Do not even probe in this common path: avoiding the syscall is part of keeping the sandbox off the
660
+ // command's latency path.
661
+ if (platform === "linux" && fence !== undefined && !requiresLandlock(fence)) {
662
+ return { enabled: true, backend: "scanner-only", osEnforced: false, discoveryOnly: true };
663
+ }
636
664
  // Everywhere else the answer cannot be inferred from the platform name. Landlock can be compiled
637
665
  // out of the kernel, left out of its boot-time LSM list, or too old to allow cross-directory
638
666
  // rename — and none of that is visible from `process.platform`. Asking the native layer is the
@@ -67,7 +67,7 @@ export interface SessionFenceExtras {
67
67
  * The session's fence for `workspace`, or undefined when sandboxing is off.
68
68
  *
69
69
  * Cached on the full effective configuration — the workspace plus the resolved enable flag, both
70
- * allow-lists and any extra roots — not on the workspace alone. Keying on the allow-lists is what lets
70
+ * discovery-grant lists and any extra roots — not on the workspace alone. Keying on the lists is what lets
71
71
  * a mid-session `settings.override("sandbox.allowRead", …)` take effect, as when the Office pane grants
72
72
  * a user-picked folder; a workspace-only key would keep serving the stale fence and block the path that
73
73
  * was just granted. The key only ever triggers more rebuilds, never fewer restrictions.
@@ -94,15 +94,15 @@ export function resolveSessionFence(
94
94
  const cached = cache.get(signature);
95
95
  if (cached) return cached;
96
96
 
97
- // The three grants stay distinct. Merging allowRead and allowWrite into one read+write list made a
98
- // folder shared for reading writable, undoing the split built for #2516. A root in *both* lists is
99
- // the deliberate exception the fence itself handles, because that is what `--allow-path` produces.
97
+ // Session settings restore discovery; they do not reduce the operator's normal rights. The fence is a
98
+ // cross-context courtesy, not a read-only/write-only privilege boundary. Treat either historical
99
+ // allow-list as a full named-path grant so an old `sandbox.allowRead` entry cannot make a professional
100
+ // tool fail when it writes credentials, state, or build output there. The low-level fence keeps
101
+ // directional roots for specialized callers and tests, but ordinary xcsh sessions never emit them.
100
102
  const fence = buildContainmentFence({
101
103
  workspace,
102
104
  sessionTmp: extras.sessionTmp,
103
- extraRoots: extras.extraRoots,
104
- readOnlyRoots: allowRead,
105
- writeOnlyRoots: allowWrite,
105
+ extraRoots: [...(extras.extraRoots ?? []), ...allowRead, ...allowWrite],
106
106
  });
107
107
  if (cache.size >= CACHE_LIMIT) {
108
108
  const oldest = cache.keys().next();
package/src/sdk.ts CHANGED
@@ -1167,7 +1167,10 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1167
1167
  },
1168
1168
  // Read live rather than captured, for the same reason as the model: `--no-sandbox` and
1169
1169
  // `sandbox.enabled` are per-session, so the answer must reflect this session (#2554).
1170
- getContainment: () => containmentStatus(resolveSessionFence(process.cwd(), settings) !== undefined),
1170
+ getContainment: () => {
1171
+ const fence = resolveSessionFence(process.cwd(), settings);
1172
+ return containmentStatus(fence !== undefined, process.platform, undefined, fence);
1173
+ },
1171
1174
  // Read live rather than captured: `session.model` is a read-through to agent state, so a
1172
1175
  // mid-session Ctrl+P switch shows up on the next xcsh://about read (#2459).
1173
1176
  getActiveModel: () =>
@@ -172,10 +172,10 @@ function shellEscape(p: string): string {
172
172
  /**
173
173
  * Refuse a resolved path the session is not allowed to read.
174
174
  *
175
- * Without this the expander and the sandbox disagreed: bash was handed paths under `~/.xcsh` that
176
- * the same session's `read` tool refuses. The carve-out for session-owned roots is what keeps
177
- * `artifact://`, `agent://` and `local://` working, since the default policy deny-lists the whole
178
- * sessions directory and a session's own artifact root lives inside it.
175
+ * Without this the expander and the sandbox can disagree when an operator configures an explicit
176
+ * directional or recursive boundary. Session-owned roots are still carved out so `artifact://`,
177
+ * `agent://` and `local://` keep working under those opt-in policies. The default production fence
178
+ * preserves named operator access and therefore reaches this check only for enumeration attempts.
179
179
  */
180
180
  function enforceReadBoundary(scheme: SupportedInternalScheme, resolved: string, options: InternalUrlExpansionOptions) {
181
181
  const boundary = options.readBoundary;
package/src/tools/bash.ts CHANGED
@@ -793,7 +793,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
793
793
  // Only worth giving up when there is an OS backend for the non-PTY path to use and none for this
794
794
  // one. Where no backend exists — Linux without Landlock, Windows — both paths are scanner-only,
795
795
  // so disabling PTY would remove interactive terminals and improve containment by nothing.
796
- const osBackend = containmentStatus(fence !== undefined);
796
+ const osBackend = containmentStatus(fence !== undefined, process.platform, undefined, fence);
797
797
  const ptyConfinable = !osBackend.osEnforced || osBackend.backend === "seatbelt";
798
798
  const usePty = pty && ptyConfinable && $env.PI_NO_PTY !== "1" && ctx?.hasUI === true && ctx.ui !== undefined;
799
799
  let sandboxCheckSibling: string | undefined;