@f5-sales-demo/xcsh 20.1.0 → 20.1.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/CHANGELOG.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [20.1.1] - 2026-08-01
6
+
7
+ ### Fixed
8
+
9
+ - Made `xcsh sandbox check` exercise the live bash profile, report actionable failure details, and run under macOS and Linux confinement in CI ([#2800](https://github.com/f5-sales-demo/xcsh/issues/2800))
10
+
5
11
  ## [20.1.0] - 2026-08-01
6
12
 
7
13
  ### Added
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@f5-sales-demo/xcsh",
4
- "version": "20.1.0",
4
+ "version": "20.1.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": "20.1.0",
61
- "@f5-sales-demo/pi-agent-core": "20.1.0",
62
- "@f5-sales-demo/pi-ai": "20.1.0",
63
- "@f5-sales-demo/pi-natives": "20.1.0",
64
- "@f5-sales-demo/pi-resource-management": "20.1.0",
65
- "@f5-sales-demo/pi-tui": "20.1.0",
66
- "@f5-sales-demo/pi-utils": "20.1.0",
60
+ "@f5-sales-demo/xcsh-stats": "20.1.1",
61
+ "@f5-sales-demo/pi-agent-core": "20.1.1",
62
+ "@f5-sales-demo/pi-ai": "20.1.1",
63
+ "@f5-sales-demo/pi-natives": "20.1.1",
64
+ "@f5-sales-demo/pi-resource-management": "20.1.1",
65
+ "@f5-sales-demo/pi-tui": "20.1.1",
66
+ "@f5-sales-demo/pi-utils": "20.1.1",
67
67
  "@sinclair/typebox": "^0.34",
68
68
  "@xterm/headless": "^6.0",
69
69
  "ajv": "^8.20",
@@ -8,6 +8,7 @@ import { Settings } from "../config/settings";
8
8
  import { fenceForNative } from "../exec/bash-executor";
9
9
  import { buildContainmentFence, type ContainmentFence, containmentStatus } from "../sandbox/containment";
10
10
  import { evaluateToolCall } from "../sandbox/enforce";
11
+ import { SANDBOX_OPERATOR_HOME_ENV, SANDBOX_SESSION_ROOT_ENV } from "../sandbox/session-fence";
11
12
  import { BashTool, type ToolSession } from "../tools";
12
13
 
13
14
  export type SandboxCheckResultStatus = "PASS" | "FAIL" | "SKIP";
@@ -15,6 +16,8 @@ export type SandboxCheckResultStatus = "PASS" | "FAIL" | "SKIP";
15
16
  export interface SandboxCheckResult {
16
17
  name: string;
17
18
  status: SandboxCheckResultStatus;
19
+ /** Present on failures and skips; paths are generalized before they leave the process. */
20
+ detail?: string;
18
21
  }
19
22
 
20
23
  export interface SandboxCheckReport {
@@ -30,32 +33,108 @@ export interface SandboxCheckReport {
30
33
 
31
34
  export interface SandboxCheckOptions {
32
35
  json?: boolean;
36
+ verbose?: boolean;
33
37
  }
34
38
 
39
+ interface ProbeOutcome {
40
+ passed: boolean;
41
+ detail?: string;
42
+ }
43
+
44
+ interface ShellProbeResult {
45
+ exitCode: number;
46
+ output: string;
47
+ }
48
+
49
+ type Redaction = readonly [path: string, label: string];
50
+
35
51
  function quote(value: string): string {
36
52
  return JSON.stringify(value);
37
53
  }
38
54
 
39
- async function shellExitCode(
55
+ function errorCode(error: unknown): string | undefined {
56
+ if (typeof error !== "object" || error === null || !("code" in error)) return undefined;
57
+ const code = (error as { code?: unknown }).code;
58
+ return typeof code === "string" ? code : undefined;
59
+ }
60
+
61
+ function sanitizeDetail(value: string, redactions: readonly Redaction[]): string {
62
+ let sanitized = value;
63
+ for (const [actual, label] of [...redactions].sort(([a], [b]) => b.length - a.length)) {
64
+ if (actual.length > 0) sanitized = sanitized.replaceAll(actual, label);
65
+ }
66
+ sanitized = sanitized.replace(/\s+/gu, " ").trim();
67
+ return sanitized.length > 500 ? `${sanitized.slice(0, 497)}...` : sanitized;
68
+ }
69
+
70
+ function errnoFromOutput(output: string): string {
71
+ if (/operation not permitted/iu.test(output)) return "EPERM";
72
+ if (/permission denied/iu.test(output)) return "EACCES";
73
+ if (/no such file or directory/iu.test(output)) return "ENOENT";
74
+ if (/not a directory/iu.test(output)) return "ENOTDIR";
75
+ return "unknown";
76
+ }
77
+
78
+ function exceptionOutcome(
79
+ assertion: string,
80
+ displayPath: string,
81
+ error: unknown,
82
+ redactions: readonly Redaction[],
83
+ ): ProbeOutcome {
84
+ const message = error instanceof Error ? error.message : String(error);
85
+ return {
86
+ passed: false,
87
+ detail: sanitizeDetail(
88
+ `${assertion}; path=${displayPath}; errno=${errorCode(error) ?? "unknown"}; error=${message}`,
89
+ redactions,
90
+ ),
91
+ };
92
+ }
93
+
94
+ function shellOutcome(
95
+ result: ShellProbeResult,
96
+ expectSuccess: boolean,
97
+ assertion: string,
98
+ displayPath: string,
99
+ redactions: readonly Redaction[],
100
+ ): ProbeOutcome {
101
+ const passed = expectSuccess ? result.exitCode === 0 : result.exitCode !== 0;
102
+ if (passed) return { passed: true };
103
+ const output = sanitizeDetail(result.output, redactions);
104
+ const errno = result.exitCode === 0 ? "none" : errnoFromOutput(output);
105
+ return {
106
+ passed: false,
107
+ detail: sanitizeDetail(
108
+ `${assertion}; path=${displayPath}; exit=${result.exitCode}; errno=${errno}${output ? `; output=${output}` : ""}`,
109
+ redactions,
110
+ ),
111
+ };
112
+ }
113
+
114
+ async function shellProbe(
40
115
  command: string,
41
116
  cwd: string,
42
- fence: ContainmentFence,
117
+ fence: ContainmentFence | undefined,
43
118
  signal: AbortSignal,
44
- ): Promise<number> {
119
+ ): Promise<ShellProbeResult> {
120
+ let output = "";
45
121
  const result = await executeShell(
46
122
  {
47
123
  command,
48
124
  cwd,
49
- fence: fenceForNative(fence),
125
+ fence: fence === undefined ? undefined : fenceForNative(fence),
50
126
  signal,
51
127
  timeoutMs: 15_000,
52
128
  },
53
- () => {},
129
+ (error, chunk) => {
130
+ if (error) output += `${error.message}\n`;
131
+ else output += chunk;
132
+ },
54
133
  );
55
- return result.exitCode ?? -1;
134
+ return { exitCode: result.exitCode ?? -1, output };
56
135
  }
57
136
 
58
- function renderReport(report: SandboxCheckReport, json: boolean): void {
137
+ function renderReport(report: SandboxCheckReport, json: boolean, verbose: boolean): void {
59
138
  if (json) {
60
139
  process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
61
140
  return;
@@ -63,43 +142,72 @@ function renderReport(report: SandboxCheckReport, json: boolean): void {
63
142
 
64
143
  const enforcement = report.osEnforced ? "OS enforced" : "scanner only";
65
144
  process.stdout.write(`Sandbox backend: ${report.backend} (${enforcement})\n\n`);
66
- const width = Math.max(...report.checks.map(check => check.name.length));
145
+ const width = Math.max(0, ...report.checks.map(check => check.name.length));
67
146
  for (const check of report.checks) {
68
147
  process.stdout.write(`${check.status.padEnd(4)} ${check.name.padEnd(width)}\n`);
148
+ if (verbose && check.detail) process.stdout.write(` ${check.detail}\n`);
69
149
  }
70
150
  process.stdout.write(
71
151
  `\n${report.summary.passed} passed, ${report.summary.failed} failed, ${report.summary.skipped} skipped\n`,
72
152
  );
153
+ if (!verbose && report.summary.failed > 0) {
154
+ process.stdout.write("Run `xcsh sandbox check --verbose` for failure details.\n");
155
+ }
73
156
  }
74
157
 
75
158
  /** Run the conformance matrix and report only after every synthetic fixture has been removed. */
76
159
  export async function runSandboxCheck(options: SandboxCheckOptions = {}): Promise<SandboxCheckReport> {
77
160
  const backend = containmentStatus(true);
78
161
  const checks: SandboxCheckResult[] = [];
162
+ const fixturePaths: string[] = [];
163
+ const knownCleanupLeaves: string[] = [];
164
+ const nonEnumerableCleanupDirs = new Set<string>();
165
+ const redactions: Redaction[] = [];
79
166
  const abortController = new AbortController();
80
167
  const interrupt = () => abortController.abort();
81
168
  process.once("SIGINT", interrupt);
82
169
  process.once("SIGTERM", interrupt);
83
170
 
84
171
  let fixtureRoot: string | undefined;
85
- const add = (name: string, status: SandboxCheckResultStatus): void => {
86
- checks.push({ name, status });
172
+ const add = (name: string, status: SandboxCheckResultStatus, detail?: string): void => {
173
+ checks.push({ name, status, ...(detail ? { detail } : {}) });
87
174
  };
88
- const check = async (name: string, probe: () => boolean | Promise<boolean>): Promise<void> => {
175
+ const check = async (
176
+ name: string,
177
+ probe: () => boolean | ProbeOutcome | Promise<boolean | ProbeOutcome>,
178
+ ): Promise<void> => {
89
179
  if (abortController.signal.aborted) {
90
- add(name, "FAIL");
180
+ add(name, "FAIL", "probe aborted before execution; path=<probe>; errno=ABORTED");
91
181
  return;
92
182
  }
93
183
  try {
94
- add(name, (await probe()) ? "PASS" : "FAIL");
95
- } catch {
96
- add(name, "FAIL");
184
+ const result = await probe();
185
+ const outcome = typeof result === "boolean" ? { passed: result } : result;
186
+ add(
187
+ name,
188
+ outcome.passed ? "PASS" : "FAIL",
189
+ outcome.passed ? undefined : (outcome.detail ?? "assertion failed; path=<probe>; errno=unknown"),
190
+ );
191
+ } catch (error) {
192
+ const outcome = exceptionOutcome("probe threw", "<probe>", error, redactions);
193
+ add(name, "FAIL", outcome.detail);
97
194
  }
98
195
  };
99
196
 
100
197
  try {
101
- const tmpRoot = await fs.realpath(os.tmpdir());
102
- fixtureRoot = await fs.mkdtemp(path.join(tmpRoot, "xcsh-sandbox-check-"));
198
+ const inheritedProfile = process.env[SANDBOX_SESSION_ROOT_ENV] !== undefined;
199
+ const workspaceInput = process.env[SANDBOX_SESSION_ROOT_ENV] ?? process.cwd();
200
+ const homeInput = process.env[SANDBOX_OPERATOR_HOME_ENV] ?? os.homedir();
201
+ redactions.push([workspaceInput, "<workspace>"], [homeInput, "<operator-home>"]);
202
+ const liveWorkspace = await fs.realpath(workspaceInput);
203
+ const liveHome = await fs.realpath(homeInput);
204
+ redactions.push([liveWorkspace, "<workspace>"], [liveHome, "<operator-home>"]);
205
+
206
+ const fixtureBase = inheritedProfile ? liveWorkspace : await fs.realpath(os.tmpdir());
207
+ fixtureRoot = await fs.mkdtemp(path.join(fixtureBase, ".xcsh-sandbox-check-policy-"));
208
+ fixturePaths.push(fixtureRoot);
209
+ redactions.push([fixtureRoot, "<synthetic-root>"]);
210
+
103
211
  const accountRoot = path.join(fixtureRoot, "Users");
104
212
  const operatorHome = path.join(accountRoot, "operator");
105
213
  const otherHome = path.join(accountRoot, "other-account");
@@ -165,39 +273,132 @@ export async function runSandboxCheck(options: SandboxCheckOptions = {}): Promis
165
273
  cwd: workspace,
166
274
  fence,
167
275
  });
168
- return blocked.every(result => result.block) && !ownConfig.block;
276
+ const passed = blocked.every(result => result.block) && !ownConfig.block;
277
+ return passed
278
+ ? { passed: true }
279
+ : {
280
+ passed: false,
281
+ detail: "structured-tool policy disagreed with the shell boundary; path=<synthetic-root>; errno=none",
282
+ };
169
283
  });
170
284
 
171
285
  await check("workspace read, write, glob, and recursion", async () => {
286
+ const displayPath = "<workspace>/<synthetic-fixture>";
287
+ let liveFixture: string;
288
+ try {
289
+ liveFixture = await fs.mkdtemp(path.join(liveWorkspace, ".xcsh-sandbox-check-workspace-"));
290
+ fixturePaths.push(liveFixture);
291
+ redactions.push([liveFixture, displayPath]);
292
+ await fs.mkdir(path.join(liveFixture, "nested"));
293
+ await Bun.write(path.join(liveFixture, "own.txt"), "own\n");
294
+ } catch (error) {
295
+ return exceptionOutcome("create live workspace fixture", displayPath, error, redactions);
296
+ }
172
297
  const command =
173
298
  "cat own.txt > /dev/null && printf created > created.txt && " +
174
299
  "printf '%s\\n' ./* > /dev/null && find . -type f > /dev/null";
175
- return (await shellExitCode(command, workspace, fence, abortController.signal)) === 0;
300
+ const result = await shellProbe(command, liveFixture, undefined, abortController.signal);
301
+ return shellOutcome(
302
+ result,
303
+ true,
304
+ "live profile must allow workspace read, write, glob, and recursion",
305
+ displayPath,
306
+ redactions,
307
+ );
176
308
  });
309
+
177
310
  await check("named sibling remains reachable", async () => {
178
- const command = `cd ${quote(sibling)} && test "$(cat named.txt)" = sibling`;
179
- return (await shellExitCode(command, workspace, fence, abortController.signal)) === 0;
311
+ const displayPath = "<session-parent>/<synthetic-sibling>";
312
+ let liveSibling: string;
313
+ try {
314
+ liveSibling = await fs.mkdtemp(path.join(path.dirname(liveWorkspace), ".xcsh-sandbox-check-sibling-"));
315
+ fixturePaths.push(liveSibling);
316
+ nonEnumerableCleanupDirs.add(liveSibling);
317
+ redactions.push([liveSibling, displayPath]);
318
+ const namedFile = path.join(liveSibling, "named.txt");
319
+ await Bun.write(namedFile, "sibling\n");
320
+ knownCleanupLeaves.push(namedFile);
321
+ } catch (error) {
322
+ return exceptionOutcome("create named sibling fixture", displayPath, error, redactions);
323
+ }
324
+ const result = await shellProbe(
325
+ 'test "$(cat named.txt)" = sibling',
326
+ liveSibling,
327
+ undefined,
328
+ abortController.signal,
329
+ );
330
+ return shellOutcome(result, true, "live profile must allow a named sibling read", displayPath, redactions);
180
331
  });
181
332
 
182
333
  if (backend.osEnforced) {
183
334
  await check("session parent cannot be enumerated", async () => {
184
- const command = `ls ${quote(workspaces)} > /dev/null`;
185
- return (await shellExitCode(command, workspace, fence, abortController.signal)) !== 0;
335
+ const result = await shellProbe(
336
+ `ls ${quote(workspaces)} > /dev/null`,
337
+ workspace,
338
+ fence,
339
+ abortController.signal,
340
+ );
341
+ return shellOutcome(
342
+ result,
343
+ false,
344
+ "synthetic session parent enumeration must be refused",
345
+ "<synthetic-session-parent>",
346
+ redactions,
347
+ );
186
348
  });
187
349
  await check("account container cannot be enumerated", async () => {
188
- const command = `ls ${quote(accountRoot)} > /dev/null`;
189
- return (await shellExitCode(command, workspace, fence, abortController.signal)) !== 0;
350
+ const result = await shellProbe(
351
+ `ls ${quote(accountRoot)} > /dev/null`,
352
+ workspace,
353
+ fence,
354
+ abortController.signal,
355
+ );
356
+ return shellOutcome(
357
+ result,
358
+ false,
359
+ "synthetic account container enumeration must be refused",
360
+ "<synthetic-account-container>",
361
+ redactions,
362
+ );
190
363
  });
191
364
  await check("synthetic other account cannot be entered", async () => {
192
- const command = `cd ${quote(otherHome)}`;
193
- return (await shellExitCode(command, workspace, fence, abortController.signal)) !== 0;
365
+ const result = await shellProbe(`cd ${quote(otherHome)}`, workspace, fence, abortController.signal);
366
+ return shellOutcome(
367
+ result,
368
+ false,
369
+ "synthetic other account traversal must be refused",
370
+ "<synthetic-other-account>",
371
+ redactions,
372
+ );
194
373
  });
195
374
  await check("cross-session stores cannot be read", async () => {
196
- const sessionRead = `cat ${quote(path.join(otherSession, "state.jsonl"))} > /dev/null`;
197
- const memoryRead = `cat ${quote(path.join(otherMemory, "MEMORY.md"))} > /dev/null`;
198
- return (
199
- (await shellExitCode(sessionRead, workspace, fence, abortController.signal)) !== 0 &&
200
- (await shellExitCode(memoryRead, workspace, fence, abortController.signal)) !== 0
375
+ const sessionRead = await shellProbe(
376
+ `cat ${quote(path.join(otherSession, "state.jsonl"))} > /dev/null`,
377
+ workspace,
378
+ fence,
379
+ abortController.signal,
380
+ );
381
+ const sessionOutcome = shellOutcome(
382
+ sessionRead,
383
+ false,
384
+ "synthetic other session read must be refused",
385
+ "<synthetic-session-store>/<synthetic-session>",
386
+ redactions,
387
+ );
388
+ if (!sessionOutcome.passed) return sessionOutcome;
389
+
390
+ const memoryRead = await shellProbe(
391
+ `cat ${quote(path.join(otherMemory, "MEMORY.md"))} > /dev/null`,
392
+ workspace,
393
+ fence,
394
+ abortController.signal,
395
+ );
396
+ return shellOutcome(
397
+ memoryRead,
398
+ false,
399
+ "synthetic other memory read must be refused",
400
+ "<synthetic-memory-store>/<synthetic-memory>",
401
+ redactions,
201
402
  );
202
403
  });
203
404
  } else {
@@ -207,14 +408,39 @@ export async function runSandboxCheck(options: SandboxCheckOptions = {}): Promis
207
408
  "synthetic other account cannot be entered",
208
409
  "cross-session stores cannot be read",
209
410
  ]) {
210
- add(name, "SKIP");
411
+ add(name, "SKIP", "OS enforcement backend unavailable; path=<probe>; errno=unsupported");
211
412
  }
212
413
  }
213
414
 
214
415
  await check("operator home configuration is writable", async () => {
215
- const target = path.join(configDir, "config");
216
- const command = `printf operator > ${quote(target)} && test "$(cat ${quote(target)})" = operator`;
217
- return (await shellExitCode(command, workspace, fence, abortController.signal)) === 0;
416
+ const displayPath = "<operator-home>/<synthetic-fixture>";
417
+ let liveConfig: string;
418
+ try {
419
+ // Landlock cannot grant creation on a split directory without also granting every
420
+ // denied descendant. The live fence therefore grants operator-owned CLI config roots
421
+ // explicitly; use one of those when another Landlock profile is already inherited.
422
+ // Standalone and Seatbelt checks retain the direct-home probe.
423
+ const configBase =
424
+ inheritedProfile && backend.backend === "landlock" ? path.join(liveHome, ".config", "gh") : liveHome;
425
+ liveConfig = await fs.mkdtemp(path.join(configBase, ".xcsh-sandbox-check-home-"));
426
+ fixturePaths.push(liveConfig);
427
+ redactions.push([liveConfig, displayPath]);
428
+ } catch (error) {
429
+ return exceptionOutcome("create operator-home fixture", displayPath, error, redactions);
430
+ }
431
+ const result = await shellProbe(
432
+ 'printf operator > config && test "$(cat config)" = operator',
433
+ liveConfig,
434
+ undefined,
435
+ abortController.signal,
436
+ );
437
+ return shellOutcome(
438
+ result,
439
+ true,
440
+ "live profile must allow operator-home configuration writes",
441
+ displayPath,
442
+ redactions,
443
+ );
218
444
  });
219
445
 
220
446
  await check("cwd resets across tool calls", async () => {
@@ -248,24 +474,57 @@ export async function runSandboxCheck(options: SandboxCheckOptions = {}): Promis
248
474
  .map(part => part.text)
249
475
  .join("")
250
476
  .trim();
251
- return text(moved) === nested && text(reset) === workspace && text(explicit) === nested;
477
+ const passed = text(moved) === nested && text(reset) === workspace && text(explicit) === nested;
478
+ return passed
479
+ ? { passed: true }
480
+ : {
481
+ passed: false,
482
+ detail:
483
+ "tool-call cwd did not reset to <synthetic-workspace> or honor explicit cwd; path=<synthetic-root>; errno=none",
484
+ };
252
485
  });
253
- } catch {
254
- add("conformance matrix completed", "FAIL");
486
+ } catch (error) {
487
+ const outcome = exceptionOutcome("conformance matrix setup failed", "<probe>", error, redactions);
488
+ add("conformance matrix completed", "FAIL", outcome.detail);
255
489
  } finally {
256
490
  process.off("SIGINT", interrupt);
257
491
  process.off("SIGTERM", interrupt);
258
- if (fixtureRoot === undefined) {
259
- add("synthetic fixtures removed", "FAIL");
260
- } else {
492
+ const cleanupFailures: string[] = [];
493
+ for (const leafPath of [...knownCleanupLeaves].reverse()) {
261
494
  try {
262
- await fs.rm(fixtureRoot, { recursive: true, force: true });
263
- await fs.stat(fixtureRoot);
264
- add("synthetic fixtures removed", "FAIL");
495
+ await fs.rm(leafPath, { force: true });
265
496
  } catch (error) {
266
- add("synthetic fixtures removed", isEnoent(error) ? "PASS" : "FAIL");
497
+ if (!isEnoent(error)) cleanupFailures.push(error instanceof Error ? error.message : String(error));
267
498
  }
268
499
  }
500
+ for (const fixturePath of [...fixturePaths].reverse()) {
501
+ try {
502
+ // Landlock denies enumeration of the session parent. A sibling created after the
503
+ // profile snapshot consequently cannot be walked during recursive cleanup, even
504
+ // though its known leaf and the directory itself can be removed by name.
505
+ if (nonEnumerableCleanupDirs.has(fixturePath)) await fs.rmdir(fixturePath);
506
+ else await fs.rm(fixturePath, { recursive: true, force: true });
507
+ await fs.stat(fixturePath);
508
+ cleanupFailures.push(`fixture remains at ${fixturePath}`);
509
+ } catch (error) {
510
+ if (!isEnoent(error)) cleanupFailures.push(error instanceof Error ? error.message : String(error));
511
+ }
512
+ }
513
+
514
+ if (fixtureRoot === undefined || cleanupFailures.length > 0) {
515
+ add(
516
+ "synthetic fixtures removed",
517
+ "FAIL",
518
+ sanitizeDetail(
519
+ `fixture cleanup incomplete; path=<synthetic-fixtures>; errno=${
520
+ fixtureRoot === undefined ? "ENOENT" : "unknown"
521
+ }${cleanupFailures.length > 0 ? `; error=${cleanupFailures.join("; ")}` : ""}`,
522
+ redactions,
523
+ ),
524
+ );
525
+ } else {
526
+ add("synthetic fixtures removed", "PASS");
527
+ }
269
528
  }
270
529
 
271
530
  const report: SandboxCheckReport = {
@@ -278,6 +537,6 @@ export async function runSandboxCheck(options: SandboxCheckOptions = {}): Promis
278
537
  skipped: checks.filter(result => result.status === "SKIP").length,
279
538
  },
280
539
  };
281
- renderReport(report, options.json ?? false);
540
+ renderReport(report, options.json ?? false, options.verbose ?? false);
282
541
  return report;
283
542
  }
@@ -15,11 +15,12 @@ export default class Sandbox extends Command {
15
15
 
16
16
  static flags = {
17
17
  json: Flags.boolean({ description: "Output JSON" }),
18
+ verbose: Flags.boolean({ char: "v", description: "Show failure details" }),
18
19
  };
19
20
 
20
21
  async run(): Promise<void> {
21
22
  const { flags } = await this.parse(Sandbox);
22
- const report = await runSandboxCheck({ json: flags.json });
23
+ const report = await runSandboxCheck({ json: flags.json, verbose: flags.verbose });
23
24
  if (report.summary.failed > 0) process.exitCode = 1;
24
25
  }
25
26
  }
@@ -17,17 +17,17 @@ export interface BuildInfo {
17
17
  }
18
18
 
19
19
  export const BUILD_INFO: BuildInfo = {
20
- "version": "20.1.0",
21
- "commit": "a4002d84fa2f409413a313cb8a04670a1b107084",
22
- "shortCommit": "a4002d8",
20
+ "version": "20.1.1",
21
+ "commit": "ac536e6d319a00467e8e95de237cc915b1551dec",
22
+ "shortCommit": "ac536e6",
23
23
  "branch": "main",
24
- "tag": "v20.1.0",
25
- "commitDate": "2026-08-01T13:46:31Z",
26
- "buildDate": "2026-08-01T14:24:45.281Z",
24
+ "tag": "v20.1.1",
25
+ "commitDate": "2026-08-01T18:06:22Z",
26
+ "buildDate": "2026-08-01T18:28:19.612Z",
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/a4002d84fa2f409413a313cb8a04670a1b107084",
32
- "releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v20.1.0"
31
+ "commitUrl": "https://github.com/f5-sales-demo/xcsh/commit/ac536e6d319a00467e8e95de237cc915b1551dec",
32
+ "releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v20.1.1"
33
33
  };
@@ -16,6 +16,17 @@
16
16
  */
17
17
  import { buildContainmentFence, type ContainmentFence } from "./containment";
18
18
 
19
+ /**
20
+ * Trusted context added to commands launched by the fenced model bash tool.
21
+ *
22
+ * A subprocess cannot infer the session anchor after a command-local `cd`, and an inherited OS
23
+ * sandbox cannot be loosened. The installed sandbox diagnostic uses these values to place its
24
+ * allow-side fixtures inside paths the live bash profile already grants instead of inventing a
25
+ * second workspace under the system temp directory (#2800).
26
+ */
27
+ export const SANDBOX_SESSION_ROOT_ENV = "XCSH_SANDBOX_SESSION_ROOT";
28
+ export const SANDBOX_OPERATOR_HOME_ENV = "XCSH_SANDBOX_OPERATOR_HOME";
29
+
19
30
  /** The slice of `Settings` this needs — supplied explicitly so the caller names its own source. */
20
31
  export interface SettingsReader {
21
32
  get(key: string): unknown;
package/src/tools/bash.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import * as fs from "node:fs";
2
+ import * as os from "node:os";
2
3
  import type {
3
4
  AgentTool,
4
5
  AgentToolContext,
@@ -17,7 +18,7 @@ import { truncateToVisualLines } from "../modes/components/visual-truncate";
17
18
  import type { Theme } from "../modes/theme/theme";
18
19
  import bashDescription from "../prompts/tools/bash.md" with { type: "text" };
19
20
  import { type ContainmentFence, containmentStatus, fenceVerdict } from "../sandbox/containment";
20
- import { resolveSessionFence } from "../sandbox/session-fence";
21
+ import { resolveSessionFence, SANDBOX_OPERATOR_HOME_ENV, SANDBOX_SESSION_ROOT_ENV } from "../sandbox/session-fence";
21
22
  import { SECRET_ENV_PATTERNS, type SecretObfuscator } from "../secrets";
22
23
  import { DEFAULT_MAX_BYTES, TailBuffer } from "../session/streaming-output";
23
24
  import { renderStatusLine } from "../tui";
@@ -557,11 +558,11 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
557
558
  * RPC `bash` reach it too, and the same brush-core runs credential helpers and the interactive
558
559
  * `xcsh shell`. Only the model's tool call is fenced (#2554).
559
560
  */
560
- #containmentFence() {
561
+ #containmentFence(root = this.#containmentRoot()) {
561
562
  const artifactsDir = this.session.getArtifactsDir?.();
562
563
  // One resolver, shared with `sandbox-guard` and the internal-URL check, so the pre-check and the
563
564
  // kernel cannot be looking at different boundaries (#2624).
564
- return resolveSessionFence(this.#containmentRoot(), this.session.settings, {
565
+ return resolveSessionFence(root, this.session.settings, {
565
566
  extraRoots: artifactsDir ? [artifactsDir] : [],
566
567
  });
567
568
  }
@@ -583,7 +584,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
583
584
  ctx?: AgentToolContext,
584
585
  ): Promise<AgentToolResult<BashToolDetails>> {
585
586
  let command = rawCommand;
586
- const env = normalizeBashEnv(rawEnv);
587
+ const requestedEnv = normalizeBashEnv(rawEnv);
587
588
 
588
589
  // Extract leading `cd <path> && ...` into cwd when the model ignores the cwd parameter.
589
590
  if (!cwd) {
@@ -627,7 +628,20 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
627
628
  // different configurations — which doubled a cost that lands as user-visible latency. Sharing is
628
629
  // also the more correct answer: the internal-URL check and the shell are then reasoning about the
629
630
  // same boundary rather than two that merely agree today.
630
- const fence = this.#containmentFence();
631
+ const containmentRoot = this.#containmentRoot();
632
+ const fence = this.#containmentFence(containmentRoot);
633
+ // A nested `xcsh sandbox check` must exercise the grants of this exact live profile. Seatbelt and
634
+ // Landlock restrictions compose and cannot be relaxed by its subprocess, so the diagnostic needs
635
+ // the immutable session anchor even when this individual call uses `cwd` or starts with `cd`.
636
+ // Keep these values host-owned: tool-supplied env cannot replace them.
637
+ const env =
638
+ fence === undefined
639
+ ? requestedEnv
640
+ : {
641
+ ...requestedEnv,
642
+ [SANDBOX_SESSION_ROOT_ENV]: containmentRoot,
643
+ [SANDBOX_OPERATOR_HOME_ENV]: os.homedir(),
644
+ };
631
645
 
632
646
  const localOptions = {
633
647
  getArtifactsDir: this.session.getArtifactsDir,