@f5-sales-demo/xcsh 20.1.0 → 20.1.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/CHANGELOG.md CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [20.1.2] - 2026-08-01
6
+
7
+ ### Fixed
8
+
9
+ - Made `xcsh sandbox check` work for sessions rooted directly under the operator home, distinguish harness errors from sandbox failures, and verify this topology against installed release artifacts ([#2807](https://github.com/f5-sales-demo/xcsh/issues/2807))
10
+
11
+ ## [20.1.1] - 2026-08-01
12
+
13
+ ### Fixed
14
+
15
+ - 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))
16
+
5
17
  ## [20.1.0] - 2026-08-01
6
18
 
7
19
  ### 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.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",
@@ -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.2",
61
+ "@f5-sales-demo/pi-agent-core": "20.1.2",
62
+ "@f5-sales-demo/pi-ai": "20.1.2",
63
+ "@f5-sales-demo/pi-natives": "20.1.2",
64
+ "@f5-sales-demo/pi-resource-management": "20.1.2",
65
+ "@f5-sales-demo/pi-tui": "20.1.2",
66
+ "@f5-sales-demo/pi-utils": "20.1.2",
67
67
  "@sinclair/typebox": "^0.34",
68
68
  "@xterm/headless": "^6.0",
69
69
  "ajv": "^8.20",
@@ -8,13 +8,20 @@ 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 {
12
+ SANDBOX_CHECK_NAMED_SIBLING_ENV,
13
+ SANDBOX_OPERATOR_HOME_ENV,
14
+ SANDBOX_SESSION_ROOT_ENV,
15
+ } from "../sandbox/session-fence";
11
16
  import { BashTool, type ToolSession } from "../tools";
12
17
 
13
- export type SandboxCheckResultStatus = "PASS" | "FAIL" | "SKIP";
18
+ export type SandboxCheckResultStatus = "PASS" | "FAIL" | "SKIP" | "ERROR";
14
19
 
15
20
  export interface SandboxCheckResult {
16
21
  name: string;
17
22
  status: SandboxCheckResultStatus;
23
+ /** Present on failures, errors, and skips; paths are generalized before they leave the process. */
24
+ detail?: string;
18
25
  }
19
26
 
20
27
  export interface SandboxCheckReport {
@@ -24,38 +31,115 @@ export interface SandboxCheckReport {
24
31
  summary: {
25
32
  passed: number;
26
33
  failed: number;
34
+ errors: number;
27
35
  skipped: number;
28
36
  };
29
37
  }
30
38
 
31
39
  export interface SandboxCheckOptions {
32
40
  json?: boolean;
41
+ verbose?: boolean;
33
42
  }
34
43
 
44
+ interface ProbeOutcome {
45
+ passed: boolean;
46
+ detail?: string;
47
+ }
48
+
49
+ interface ShellProbeResult {
50
+ exitCode: number;
51
+ output: string;
52
+ }
53
+
54
+ type Redaction = readonly [path: string, label: string];
55
+
35
56
  function quote(value: string): string {
36
57
  return JSON.stringify(value);
37
58
  }
38
59
 
39
- async function shellExitCode(
60
+ function errorCode(error: unknown): string | undefined {
61
+ if (typeof error !== "object" || error === null || !("code" in error)) return undefined;
62
+ const code = (error as { code?: unknown }).code;
63
+ return typeof code === "string" ? code : undefined;
64
+ }
65
+
66
+ function sanitizeDetail(value: string, redactions: readonly Redaction[]): string {
67
+ let sanitized = value;
68
+ for (const [actual, label] of [...redactions].sort(([a], [b]) => b.length - a.length)) {
69
+ if (actual.length > 0) sanitized = sanitized.replaceAll(actual, label);
70
+ }
71
+ sanitized = sanitized.replace(/\s+/gu, " ").trim();
72
+ return sanitized.length > 500 ? `${sanitized.slice(0, 497)}...` : sanitized;
73
+ }
74
+
75
+ function errnoFromOutput(output: string): string {
76
+ if (/operation not permitted/iu.test(output)) return "EPERM";
77
+ if (/permission denied/iu.test(output)) return "EACCES";
78
+ if (/no such file or directory/iu.test(output)) return "ENOENT";
79
+ if (/not a directory/iu.test(output)) return "ENOTDIR";
80
+ return "unknown";
81
+ }
82
+
83
+ function exceptionOutcome(
84
+ assertion: string,
85
+ displayPath: string,
86
+ error: unknown,
87
+ redactions: readonly Redaction[],
88
+ ): ProbeOutcome {
89
+ const message = error instanceof Error ? error.message : String(error);
90
+ return {
91
+ passed: false,
92
+ detail: sanitizeDetail(
93
+ `${assertion}; path=${displayPath}; errno=${errorCode(error) ?? "unknown"}; error=${message}`,
94
+ redactions,
95
+ ),
96
+ };
97
+ }
98
+
99
+ function shellOutcome(
100
+ result: ShellProbeResult,
101
+ expectSuccess: boolean,
102
+ assertion: string,
103
+ displayPath: string,
104
+ redactions: readonly Redaction[],
105
+ ): ProbeOutcome {
106
+ const passed = expectSuccess ? result.exitCode === 0 : result.exitCode !== 0;
107
+ if (passed) return { passed: true };
108
+ const output = sanitizeDetail(result.output, redactions);
109
+ const errno = result.exitCode === 0 ? "none" : errnoFromOutput(output);
110
+ return {
111
+ passed: false,
112
+ detail: sanitizeDetail(
113
+ `${assertion}; path=${displayPath}; exit=${result.exitCode}; errno=${errno}${output ? `; output=${output}` : ""}`,
114
+ redactions,
115
+ ),
116
+ };
117
+ }
118
+
119
+ async function shellProbe(
40
120
  command: string,
41
121
  cwd: string,
42
- fence: ContainmentFence,
122
+ fence: ContainmentFence | undefined,
43
123
  signal: AbortSignal,
44
- ): Promise<number> {
124
+ ): Promise<ShellProbeResult> {
125
+ let output = "";
45
126
  const result = await executeShell(
46
127
  {
47
128
  command,
48
129
  cwd,
49
- fence: fenceForNative(fence),
130
+ fence: fence === undefined ? undefined : fenceForNative(fence),
50
131
  signal,
51
132
  timeoutMs: 15_000,
52
133
  },
53
- () => {},
134
+ (error, chunk) => {
135
+ if (error) output += `${error.message}\n`;
136
+ else output += chunk;
137
+ },
54
138
  );
55
- return result.exitCode ?? -1;
139
+ return { exitCode: result.exitCode ?? -1, output };
56
140
  }
57
141
 
58
- function renderReport(report: SandboxCheckReport, json: boolean): void {
142
+ function renderReport(report: SandboxCheckReport, json: boolean, verbose: boolean): void {
59
143
  if (json) {
60
144
  process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
61
145
  return;
@@ -63,43 +147,83 @@ function renderReport(report: SandboxCheckReport, json: boolean): void {
63
147
 
64
148
  const enforcement = report.osEnforced ? "OS enforced" : "scanner only";
65
149
  process.stdout.write(`Sandbox backend: ${report.backend} (${enforcement})\n\n`);
66
- const width = Math.max(...report.checks.map(check => check.name.length));
150
+ const nameWidth = Math.max(0, ...report.checks.map(check => check.name.length));
151
+ const statusWidth = Math.max(0, ...report.checks.map(check => check.status.length));
67
152
  for (const check of report.checks) {
68
- process.stdout.write(`${check.status.padEnd(4)} ${check.name.padEnd(width)}\n`);
153
+ process.stdout.write(`${check.status.padEnd(statusWidth)} ${check.name.padEnd(nameWidth)}\n`);
154
+ if (verbose && check.detail) process.stdout.write(` ${check.detail}\n`);
69
155
  }
70
156
  process.stdout.write(
71
- `\n${report.summary.passed} passed, ${report.summary.failed} failed, ${report.summary.skipped} skipped\n`,
157
+ `\n${report.summary.passed} passed, ${report.summary.failed} failed, ${report.summary.errors} errors, ${report.summary.skipped} skipped\n`,
72
158
  );
159
+ if (report.checks.some(check => check.name === "conformance matrix setup" && check.status === "ERROR")) {
160
+ process.stdout.write("Conformance matrix did not run.\n");
161
+ }
162
+ if (!verbose && (report.summary.failed > 0 || report.summary.errors > 0)) {
163
+ process.stdout.write("Run `xcsh sandbox check --verbose` for failure details.\n");
164
+ }
73
165
  }
74
166
 
75
167
  /** Run the conformance matrix and report only after every synthetic fixture has been removed. */
76
168
  export async function runSandboxCheck(options: SandboxCheckOptions = {}): Promise<SandboxCheckReport> {
77
169
  const backend = containmentStatus(true);
78
170
  const checks: SandboxCheckResult[] = [];
171
+ const fixturePaths: string[] = [];
172
+ const knownCleanupLeaves: string[] = [];
173
+ const nonEnumerableCleanupDirs = new Set<string>();
174
+ const redactions: Redaction[] = [];
79
175
  const abortController = new AbortController();
80
176
  const interrupt = () => abortController.abort();
81
177
  process.once("SIGINT", interrupt);
82
178
  process.once("SIGTERM", interrupt);
83
179
 
84
180
  let fixtureRoot: string | undefined;
85
- const add = (name: string, status: SandboxCheckResultStatus): void => {
86
- checks.push({ name, status });
181
+ const add = (name: string, status: SandboxCheckResultStatus, detail?: string): void => {
182
+ checks.push({ name, status, ...(detail ? { detail } : {}) });
87
183
  };
88
- const check = async (name: string, probe: () => boolean | Promise<boolean>): Promise<void> => {
184
+ const check = async (
185
+ name: string,
186
+ probe: () => boolean | ProbeOutcome | Promise<boolean | ProbeOutcome>,
187
+ ): Promise<void> => {
89
188
  if (abortController.signal.aborted) {
90
- add(name, "FAIL");
189
+ add(name, "ERROR", "probe aborted before execution; path=<probe>; errno=ABORTED");
91
190
  return;
92
191
  }
93
192
  try {
94
- add(name, (await probe()) ? "PASS" : "FAIL");
95
- } catch {
96
- add(name, "FAIL");
193
+ const result = await probe();
194
+ const outcome = typeof result === "boolean" ? { passed: result } : result;
195
+ add(
196
+ name,
197
+ outcome.passed ? "PASS" : "FAIL",
198
+ outcome.passed ? undefined : (outcome.detail ?? "assertion failed; path=<probe>; errno=unknown"),
199
+ );
200
+ } catch (error) {
201
+ const outcome = exceptionOutcome("probe threw", "<probe>", error, redactions);
202
+ add(name, "ERROR", outcome.detail);
97
203
  }
98
204
  };
99
205
 
100
206
  try {
101
- const tmpRoot = await fs.realpath(os.tmpdir());
102
- fixtureRoot = await fs.mkdtemp(path.join(tmpRoot, "xcsh-sandbox-check-"));
207
+ const inheritedWorkspace = process.env[SANDBOX_SESSION_ROOT_ENV];
208
+ const inheritedHome = process.env[SANDBOX_OPERATOR_HOME_ENV];
209
+ const inheritedSibling = process.env[SANDBOX_CHECK_NAMED_SIBLING_ENV];
210
+ const inheritedProfile = inheritedWorkspace !== undefined;
211
+ const workspaceInput = inheritedWorkspace ?? process.cwd();
212
+ const homeInput = inheritedHome ?? os.homedir();
213
+ redactions.push([workspaceInput, "<workspace>"], [homeInput, "<operator-home>"]);
214
+ // BashTool owns and canonicalises inherited values before applying Seatbelt/Landlock. Re-running
215
+ // realpath here can require metadata access that the live profile deliberately withholds from the
216
+ // session parent — which is the operator home for a `~/<workspace>` layout (#2807).
217
+ const liveWorkspace = inheritedWorkspace ?? (await fs.realpath(workspaceInput));
218
+ const liveHome = inheritedHome ?? (await fs.realpath(homeInput));
219
+ redactions.push([liveWorkspace, "<workspace>"], [liveHome, "<operator-home>"]);
220
+ if (inheritedSibling !== undefined) redactions.push([inheritedSibling, "<session-parent>/<synthetic-sibling>"]);
221
+
222
+ const fixtureBase = inheritedProfile ? liveWorkspace : await fs.realpath(os.tmpdir());
223
+ fixtureRoot = await fs.mkdtemp(path.join(fixtureBase, ".xcsh-sandbox-check-policy-"));
224
+ fixturePaths.push(fixtureRoot);
225
+ redactions.push([fixtureRoot, "<synthetic-root>"]);
226
+
103
227
  const accountRoot = path.join(fixtureRoot, "Users");
104
228
  const operatorHome = path.join(accountRoot, "operator");
105
229
  const otherHome = path.join(accountRoot, "other-account");
@@ -165,39 +289,134 @@ export async function runSandboxCheck(options: SandboxCheckOptions = {}): Promis
165
289
  cwd: workspace,
166
290
  fence,
167
291
  });
168
- return blocked.every(result => result.block) && !ownConfig.block;
292
+ const passed = blocked.every(result => result.block) && !ownConfig.block;
293
+ return passed
294
+ ? { passed: true }
295
+ : {
296
+ passed: false,
297
+ detail: "structured-tool policy disagreed with the shell boundary; path=<synthetic-root>; errno=none",
298
+ };
169
299
  });
170
300
 
171
301
  await check("workspace read, write, glob, and recursion", async () => {
302
+ const displayPath = "<workspace>/<synthetic-fixture>";
303
+ let liveFixture: string;
304
+ try {
305
+ liveFixture = await fs.mkdtemp(path.join(liveWorkspace, ".xcsh-sandbox-check-workspace-"));
306
+ fixturePaths.push(liveFixture);
307
+ redactions.push([liveFixture, displayPath]);
308
+ await fs.mkdir(path.join(liveFixture, "nested"));
309
+ await Bun.write(path.join(liveFixture, "own.txt"), "own\n");
310
+ } catch (error) {
311
+ return exceptionOutcome("create live workspace fixture", displayPath, error, redactions);
312
+ }
172
313
  const command =
173
314
  "cat own.txt > /dev/null && printf created > created.txt && " +
174
315
  "printf '%s\\n' ./* > /dev/null && find . -type f > /dev/null";
175
- return (await shellExitCode(command, workspace, fence, abortController.signal)) === 0;
316
+ const result = await shellProbe(command, liveFixture, undefined, abortController.signal);
317
+ return shellOutcome(
318
+ result,
319
+ true,
320
+ "live profile must allow workspace read, write, glob, and recursion",
321
+ displayPath,
322
+ redactions,
323
+ );
176
324
  });
325
+
177
326
  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;
327
+ const displayPath = "<session-parent>/<synthetic-sibling>";
328
+ let liveSibling = inheritedSibling;
329
+ if (liveSibling === undefined) {
330
+ try {
331
+ liveSibling = await fs.mkdtemp(path.join(path.dirname(liveWorkspace), ".xcsh-sandbox-check-sibling-"));
332
+ fixturePaths.push(liveSibling);
333
+ nonEnumerableCleanupDirs.add(liveSibling);
334
+ redactions.push([liveSibling, displayPath]);
335
+ const namedFile = path.join(liveSibling, "named.txt");
336
+ await Bun.write(namedFile, "sibling\n");
337
+ knownCleanupLeaves.push(namedFile);
338
+ } catch (error) {
339
+ return exceptionOutcome("create named sibling fixture", displayPath, error, redactions);
340
+ }
341
+ }
342
+ const result = await shellProbe(
343
+ 'test "$(cat named.txt)" = sibling',
344
+ liveSibling,
345
+ undefined,
346
+ abortController.signal,
347
+ );
348
+ return shellOutcome(result, true, "live profile must allow a named sibling read", displayPath, redactions);
180
349
  });
181
350
 
182
351
  if (backend.osEnforced) {
183
352
  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;
353
+ const result = await shellProbe(
354
+ `ls ${quote(workspaces)} > /dev/null`,
355
+ workspace,
356
+ fence,
357
+ abortController.signal,
358
+ );
359
+ return shellOutcome(
360
+ result,
361
+ false,
362
+ "synthetic session parent enumeration must be refused",
363
+ "<synthetic-session-parent>",
364
+ redactions,
365
+ );
186
366
  });
187
367
  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;
368
+ const result = await shellProbe(
369
+ `ls ${quote(accountRoot)} > /dev/null`,
370
+ workspace,
371
+ fence,
372
+ abortController.signal,
373
+ );
374
+ return shellOutcome(
375
+ result,
376
+ false,
377
+ "synthetic account container enumeration must be refused",
378
+ "<synthetic-account-container>",
379
+ redactions,
380
+ );
190
381
  });
191
382
  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;
383
+ const result = await shellProbe(`cd ${quote(otherHome)}`, workspace, fence, abortController.signal);
384
+ return shellOutcome(
385
+ result,
386
+ false,
387
+ "synthetic other account traversal must be refused",
388
+ "<synthetic-other-account>",
389
+ redactions,
390
+ );
194
391
  });
195
392
  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
393
+ const sessionRead = await shellProbe(
394
+ `cat ${quote(path.join(otherSession, "state.jsonl"))} > /dev/null`,
395
+ workspace,
396
+ fence,
397
+ abortController.signal,
398
+ );
399
+ const sessionOutcome = shellOutcome(
400
+ sessionRead,
401
+ false,
402
+ "synthetic other session read must be refused",
403
+ "<synthetic-session-store>/<synthetic-session>",
404
+ redactions,
405
+ );
406
+ if (!sessionOutcome.passed) return sessionOutcome;
407
+
408
+ const memoryRead = await shellProbe(
409
+ `cat ${quote(path.join(otherMemory, "MEMORY.md"))} > /dev/null`,
410
+ workspace,
411
+ fence,
412
+ abortController.signal,
413
+ );
414
+ return shellOutcome(
415
+ memoryRead,
416
+ false,
417
+ "synthetic other memory read must be refused",
418
+ "<synthetic-memory-store>/<synthetic-memory>",
419
+ redactions,
201
420
  );
202
421
  });
203
422
  } else {
@@ -207,14 +426,39 @@ export async function runSandboxCheck(options: SandboxCheckOptions = {}): Promis
207
426
  "synthetic other account cannot be entered",
208
427
  "cross-session stores cannot be read",
209
428
  ]) {
210
- add(name, "SKIP");
429
+ add(name, "SKIP", "OS enforcement backend unavailable; path=<probe>; errno=unsupported");
211
430
  }
212
431
  }
213
432
 
214
433
  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;
434
+ const displayPath = "<operator-home>/<synthetic-fixture>";
435
+ let liveConfig: string;
436
+ try {
437
+ // Landlock cannot grant creation on a split directory without also granting every
438
+ // denied descendant. The live fence therefore grants operator-owned CLI config roots
439
+ // explicitly; use one of those when another Landlock profile is already inherited.
440
+ // Standalone and Seatbelt checks retain the direct-home probe.
441
+ const configBase =
442
+ inheritedProfile && backend.backend === "landlock" ? path.join(liveHome, ".config", "gh") : liveHome;
443
+ liveConfig = await fs.mkdtemp(path.join(configBase, ".xcsh-sandbox-check-home-"));
444
+ fixturePaths.push(liveConfig);
445
+ redactions.push([liveConfig, displayPath]);
446
+ } catch (error) {
447
+ return exceptionOutcome("create operator-home fixture", displayPath, error, redactions);
448
+ }
449
+ const result = await shellProbe(
450
+ 'printf operator > config && test "$(cat config)" = operator',
451
+ liveConfig,
452
+ undefined,
453
+ abortController.signal,
454
+ );
455
+ return shellOutcome(
456
+ result,
457
+ true,
458
+ "live profile must allow operator-home configuration writes",
459
+ displayPath,
460
+ redactions,
461
+ );
218
462
  });
219
463
 
220
464
  await check("cwd resets across tool calls", async () => {
@@ -248,24 +492,57 @@ export async function runSandboxCheck(options: SandboxCheckOptions = {}): Promis
248
492
  .map(part => part.text)
249
493
  .join("")
250
494
  .trim();
251
- return text(moved) === nested && text(reset) === workspace && text(explicit) === nested;
495
+ const passed = text(moved) === nested && text(reset) === workspace && text(explicit) === nested;
496
+ return passed
497
+ ? { passed: true }
498
+ : {
499
+ passed: false,
500
+ detail:
501
+ "tool-call cwd did not reset to <synthetic-workspace> or honor explicit cwd; path=<synthetic-root>; errno=none",
502
+ };
252
503
  });
253
- } catch {
254
- add("conformance matrix completed", "FAIL");
504
+ } catch (error) {
505
+ const outcome = exceptionOutcome("conformance matrix setup failed", "<probe>", error, redactions);
506
+ add("conformance matrix setup", "ERROR", outcome.detail);
255
507
  } finally {
256
508
  process.off("SIGINT", interrupt);
257
509
  process.off("SIGTERM", interrupt);
258
- if (fixtureRoot === undefined) {
259
- add("synthetic fixtures removed", "FAIL");
260
- } else {
510
+ const cleanupFailures: string[] = [];
511
+ for (const leafPath of [...knownCleanupLeaves].reverse()) {
261
512
  try {
262
- await fs.rm(fixtureRoot, { recursive: true, force: true });
263
- await fs.stat(fixtureRoot);
264
- add("synthetic fixtures removed", "FAIL");
513
+ await fs.rm(leafPath, { force: true });
265
514
  } catch (error) {
266
- add("synthetic fixtures removed", isEnoent(error) ? "PASS" : "FAIL");
515
+ if (!isEnoent(error)) cleanupFailures.push(error instanceof Error ? error.message : String(error));
267
516
  }
268
517
  }
518
+ for (const fixturePath of [...fixturePaths].reverse()) {
519
+ try {
520
+ // Landlock denies enumeration of the session parent. A sibling created after the
521
+ // profile snapshot consequently cannot be walked during recursive cleanup, even
522
+ // though its known leaf and the directory itself can be removed by name.
523
+ if (nonEnumerableCleanupDirs.has(fixturePath)) await fs.rmdir(fixturePath);
524
+ else await fs.rm(fixturePath, { recursive: true, force: true });
525
+ await fs.stat(fixturePath);
526
+ cleanupFailures.push(`fixture remains at ${fixturePath}`);
527
+ } catch (error) {
528
+ if (!isEnoent(error)) cleanupFailures.push(error instanceof Error ? error.message : String(error));
529
+ }
530
+ }
531
+
532
+ if (cleanupFailures.length > 0) {
533
+ add(
534
+ "synthetic fixtures removed",
535
+ "ERROR",
536
+ sanitizeDetail(
537
+ `fixture cleanup incomplete; path=<synthetic-fixtures>; errno=unknown; error=${cleanupFailures.join("; ")}`,
538
+ redactions,
539
+ ),
540
+ );
541
+ } else if (fixturePaths.length === 0) {
542
+ add("synthetic fixtures removed", "SKIP", "setup created no fixtures; path=<synthetic-fixtures>; errno=none");
543
+ } else {
544
+ add("synthetic fixtures removed", "PASS");
545
+ }
269
546
  }
270
547
 
271
548
  const report: SandboxCheckReport = {
@@ -275,9 +552,10 @@ export async function runSandboxCheck(options: SandboxCheckOptions = {}): Promis
275
552
  summary: {
276
553
  passed: checks.filter(result => result.status === "PASS").length,
277
554
  failed: checks.filter(result => result.status === "FAIL").length,
555
+ errors: checks.filter(result => result.status === "ERROR").length,
278
556
  skipped: checks.filter(result => result.status === "SKIP").length,
279
557
  },
280
558
  };
281
- renderReport(report, options.json ?? false);
559
+ renderReport(report, options.json ?? false, options.verbose ?? false);
282
560
  return report;
283
561
  }
@@ -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
- if (report.summary.failed > 0) process.exitCode = 1;
23
+ const report = await runSandboxCheck({ json: flags.json, verbose: flags.verbose });
24
+ if (report.summary.failed > 0 || report.summary.errors > 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.2",
21
+ "commit": "b5534d98c8ad483cc6e8709b44242cf3ea98041d",
22
+ "shortCommit": "b5534d9",
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.2",
25
+ "commitDate": "2026-08-01T21:09:01Z",
26
+ "buildDate": "2026-08-01T21:30:18.970Z",
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/b5534d98c8ad483cc6e8709b44242cf3ea98041d",
32
+ "releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v20.1.2"
33
33
  };
@@ -2,10 +2,10 @@
2
2
 
3
3
  import type { ConsoleCatalogData } from "./console-catalog-types";
4
4
 
5
- export const CONSOLE_CATALOG_VERSION = "8e7e1863840cc8ed4db0a9781c695f251aafb214";
5
+ export const CONSOLE_CATALOG_VERSION = "ea6ed666f67f016f68f4e538d1f22e44e18d11a2";
6
6
 
7
7
  export const CONSOLE_CATALOG_DATA: ConsoleCatalogData = {
8
- version: "8e7e1863840cc8ed4db0a9781c695f251aafb214",
8
+ version: "ea6ed666f67f016f68f4e538d1f22e44e18d11a2",
9
9
  workflows: {
10
10
  "address-allocator/create":
11
11
  '---\nschema: urn:xcsh:console:workflow:v1\nid: address-allocator-create\nlabel: Create IP Address Allocators\nresource: address-allocator\noperation: create\npreconditions:\n - user_logged_in\n - "role_minimum: admin"\nparams:\n name:\n required: true\n description: IP Address Allocators name (lowercase alphanumeric and hyphens)\n example: example-address-allocator\n address_allocator_mode:\n required: true\n description: Address Allocator Mode\n allocation_unit:\n required: false\n description: Allocation Unit\n default: 0\n address_pool:\n required: false\n description: Address Pool\n default: value\n address_allocation_scheme:\n required: false\n description: "Server-required: Field should be not nil"\n default: value\nsteps:\n - id: navigate-to-list\n action: navigate\n url: /web/workspaces/multi-cloud-network-connect/manage/networking/legacy_network_configuration/address_allocators\n wait_for: text(\'IP Address Allocators\')\n description: Navigate to IP Address Allocators list page\n - id: click-add-tab\n action: click\n selector: text(\'Add IP Address Allocator\')\n wait_for: textbox[name=\'Name\']\n description: Click Add IP Address Allocator to open the create form\n - id: fill-name\n action: fill\n selector: textbox[name=\'Name\']\n value: "{name}"\n description: Enter Name\n - id: select-address_allocator_mode\n action: select\n selector: listbox\n context: Address Allocator Mode section\n value: "{address_allocator_mode}"\n description: Select Address Allocator Mode\n - id: fill-allocation_unit\n action: fill\n selector: spinbutton[name=\'Allocation Unit\']\n value: "{allocation_unit}"\n description: Set Allocation Unit\n - id: fill-address_pool\n action: fill\n selector: ngx-datatable input.form-control\n context: Address Pool table\n value: "{address_pool}"\n description: Enter Address Pool in the existing table row (no Add Item needed — the table ships one empty row)\n - id: select-address_allocation_scheme\n action: select\n selector: listbox\n context: Address Allocation Scheme section\n value: "{address_allocation_scheme}"\n description: Select Address Allocation Scheme\n - id: save\n action: click\n selector: "[class*=\'save-bt\'],[class*=\'submit-button\']"\n context: footer\n wait_for: text(\'{name}\')\n wait_timeout_ms: 30000\n description: Save/submit the form (union selector matches save-bt OR submit-button)\npostconditions:\n - resource_list_page_visible\n - "resource_name_in_list: {name}"\nmetadata:\n confidence: inferred\n discovered_at: 2026-06-24\n console_version: "2025.06"\n notes: Auto-generated by scripts/generate-workflows.ts from api-specs-enriched field metadata.\n',
@@ -978,8 +978,6 @@ export const CONSOLE_CATALOG_DATA: ConsoleCatalogData = {
978
978
  "---\nschema: urn:xcsh:console:resource:v1\nid: securemesh-site\nlabel: Secure Mesh Sites\n_source: Generated from api-specs-enriched/config/console_ui.yaml\napi:\n kind: securemesh_site\nconsole:\n workspace: multi-cloud-network-connect\n workspace_label: Multi-Cloud Network Connect\n route_prefix: /web/workspaces/multi-cloud-network-connect\n route_pattern: /manage/site_management/legacy_configs/securemesh_site\n menu_path:\n - Manage\n - Site Management\n - Legacy Configurations\n - Secure Mesh Sites\n breadcrumbs:\n - Home\n - Multi-Cloud Network Connect\n - Manage\n - Site Management\n - Legacy Configurations\n - Secure Mesh Sites\n namespace_scoped: false\nadd_action:\n type: tab\n label: Add Secure Mesh Site\nsave_action:\n label: Add Secure Mesh Site\ncancel_action:\n label: Cancel All\nform_tabs:\n - Form\n - Documentation\n - JSON\nform:\n sections:\n - id: metadata\n label: Metadata\n api_fields:\n - metadata.name\n - metadata.labels\n - metadata.description\nenriched_fields:\n count: 25\n source: api-specs-enriched/config/console_field_metadata.yaml\nmetadata:\n confidence: validated\n discovered_at: '2026-06-16'\n console_version: '2025.06'\n",
979
979
  "securemesh-site-v2":
980
980
  "---\nschema: urn:xcsh:console:resource:v1\nid: securemesh-site-v2\nlabel: Secure Mesh Sites v2\n_source: Generated from api-specs-enriched/config/console_ui.yaml\napi:\n kind: securemesh_site_v2\nconsole:\n workspace: multi-cloud-network-connect\n workspace_label: Multi-Cloud Network Connect\n route_prefix: /web/workspaces/multi-cloud-network-connect\n route_pattern: /manage/site_management/cloud_sites/securemesh_site_v2\n menu_path:\n - Manage\n - Site Management\n - Customer Edges\n - Secure Mesh Sites v2\n breadcrumbs:\n - Home\n - Multi-Cloud Network Connect\n - Manage\n - Site Management\n - Customer Edges\n - Secure Mesh Sites v2\n namespace_scoped: false\nadd_action:\n type: tab\n label: Add Secure Mesh Site v2\nsave_action:\n label: Add Secure Mesh Site v2\ncancel_action:\n label: Cancel All\nform_tabs:\n - Form\n - Documentation\n - JSON\nform:\n sections:\n - id: metadata\n label: Metadata\n api_fields:\n - metadata.name\n - metadata.labels\n - metadata.description\nenriched_fields:\n count: 8\n source: api-specs-enriched/config/console_field_metadata.yaml\nmetadata:\n confidence: validated\n discovered_at: '2026-06-16'\n console_version: '2025.06'\n",
981
- "securemesh-site-v2-cloud":
982
- "---\nschema: urn:xcsh:console:resource:v1\nid: securemesh-site-v2-cloud\nlabel: Secure Mesh Sites v2\n_source: Generated from api-specs-enriched/config/console_ui.yaml\napi:\n kind: securemesh_site_v2_cloud\nconsole:\n workspace: multi-cloud-network-connect\n workspace_label: Multi-Cloud Network Connect\n route_prefix: /web/workspaces/multi-cloud-network-connect\n route_pattern: /manage/site_management/cloud_sites/securemesh_site_v2\n menu_path:\n - Manage\n - Site Management\n - Customer Edges\n - Secure Mesh Sites v2\n breadcrumbs:\n - Home\n - Multi-Cloud Network Connect\n - Manage\n - Site Management\n - Customer Edges\n - Secure Mesh Sites v2\n namespace_scoped: false\nadd_action:\n type: tab\n label: Add Secure Mesh Site\nsave_action:\n label: Add Secure Mesh Site\ncancel_action:\n label: Cancel All\nform_tabs:\n - Form\n - Documentation\n - JSON\nform:\n sections:\n - id: metadata\n label: Metadata\n api_fields:\n - metadata.name\n - metadata.labels\n - metadata.description\nmetadata:\n confidence: validated\n discovered_at: '2026-06-17'\n console_version: '2025.06'\n notes: This is the v2 cloud sites path — differs from securemesh_site_v2 which may have a different route\n",
983
981
  segment:
984
982
  "---\nschema: urn:xcsh:console:resource:v1\nid: segment\nlabel: Segments\n_source: Generated from api-specs-enriched/config/console_ui.yaml\napi:\n kind: segment\nconsole:\n workspace: multi-cloud-network-connect\n workspace_label: Multi-Cloud Network Connect\n route_prefix: /web/workspaces/multi-cloud-network-connect\n route_pattern: /manage/networking/network_configuration/segment\n menu_path:\n - Manage\n - Networking\n - Network Configuration\n - Segments\n breadcrumbs:\n - Home\n - Multi-Cloud Network Connect\n - Manage\n - Networking\n - Network Configuration\n - Segments\n namespace_scoped: false\nadd_action:\n type: tab\n label: Add Segment\nsave_action:\n label: Add Segment\ncancel_action:\n label: Cancel All\nform_tabs:\n - Form\n - Documentation\n - JSON\nform:\n sections:\n - id: metadata\n label: Metadata\n api_fields:\n - metadata.name\n - metadata.labels\n - metadata.description\nenriched_fields:\n count: 4\n source: api-specs-enriched/config/console_field_metadata.yaml\nmetadata:\n confidence: validated\n discovered_at: '2026-06-16'\n console_version: '2025.06'\n",
985
983
  "segment-connection":
@@ -16,6 +16,18 @@
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
+ export const SANDBOX_CHECK_NAMED_SIBLING_ENV = "XCSH_SANDBOX_CHECK_NAMED_SIBLING";
30
+
19
31
  /** The slice of `Settings` this needs — supplied explicitly so the caller names its own source. */
20
32
  export interface SettingsReader {
21
33
  get(key: string): unknown;
package/src/tools/bash.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  import * as fs from "node:fs";
2
+ import * as os from "node:os";
3
+ import * as path from "node:path";
2
4
  import type {
3
5
  AgentTool,
4
6
  AgentToolContext,
@@ -17,7 +19,12 @@ import { truncateToVisualLines } from "../modes/components/visual-truncate";
17
19
  import type { Theme } from "../modes/theme/theme";
18
20
  import bashDescription from "../prompts/tools/bash.md" with { type: "text" };
19
21
  import { type ContainmentFence, containmentStatus, fenceVerdict } from "../sandbox/containment";
20
- import { resolveSessionFence } from "../sandbox/session-fence";
22
+ import {
23
+ resolveSessionFence,
24
+ SANDBOX_CHECK_NAMED_SIBLING_ENV,
25
+ SANDBOX_OPERATOR_HOME_ENV,
26
+ SANDBOX_SESSION_ROOT_ENV,
27
+ } from "../sandbox/session-fence";
21
28
  import { SECRET_ENV_PATTERNS, type SecretObfuscator } from "../secrets";
22
29
  import { DEFAULT_MAX_BYTES, TailBuffer } from "../session/streaming-output";
23
30
  import { renderStatusLine } from "../tui";
@@ -177,6 +184,21 @@ function normalizeBashEnv(env: Record<string, string> | undefined): Record<strin
177
184
  return normalized;
178
185
  }
179
186
 
187
+ /** Canonical context resolved by the unfenced host before a child inherits the live profile. */
188
+ async function canonicalizeSandboxContextPath(input: string): Promise<string> {
189
+ try {
190
+ return await fs.promises.realpath(input);
191
+ } catch {
192
+ // The fence builder already handles an unavailable home conservatively. Keep ordinary bash calls
193
+ // working in that case; the diagnostic will report the path-specific failure if it is invoked.
194
+ return input;
195
+ }
196
+ }
197
+
198
+ function invokesSandboxCheck(command: string): boolean {
199
+ return /(?:xcsh(?:-[a-z0-9-]+)?|cli\.ts)["']?\s+["']?sandbox["']?\s+["']?check["']?(?:\s|$)/iu.test(command);
200
+ }
201
+
180
202
  function escapeBashEnvValueForDisplay(value: string): string {
181
203
  return value
182
204
  .replaceAll("\\", "\\\\")
@@ -557,11 +579,11 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
557
579
  * RPC `bash` reach it too, and the same brush-core runs credential helpers and the interactive
558
580
  * `xcsh shell`. Only the model's tool call is fenced (#2554).
559
581
  */
560
- #containmentFence() {
582
+ #containmentFence(root = this.#containmentRoot()) {
561
583
  const artifactsDir = this.session.getArtifactsDir?.();
562
584
  // One resolver, shared with `sandbox-guard` and the internal-URL check, so the pre-check and the
563
585
  // kernel cannot be looking at different boundaries (#2624).
564
- return resolveSessionFence(this.#containmentRoot(), this.session.settings, {
586
+ return resolveSessionFence(root, this.session.settings, {
565
587
  extraRoots: artifactsDir ? [artifactsDir] : [],
566
588
  });
567
589
  }
@@ -583,7 +605,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
583
605
  ctx?: AgentToolContext,
584
606
  ): Promise<AgentToolResult<BashToolDetails>> {
585
607
  let command = rawCommand;
586
- const env = normalizeBashEnv(rawEnv);
608
+ const requestedEnv = normalizeBashEnv(rawEnv);
587
609
 
588
610
  // Extract leading `cd <path> && ...` into cwd when the model ignores the cwd parameter.
589
611
  if (!cwd) {
@@ -627,7 +649,27 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
627
649
  // different configurations — which doubled a cost that lands as user-visible latency. Sharing is
628
650
  // also the more correct answer: the internal-URL check and the shell are then reasoning about the
629
651
  // same boundary rather than two that merely agree today.
630
- const fence = this.#containmentFence();
652
+ const containmentRoot = this.#containmentRoot();
653
+ const fence = this.#containmentFence(containmentRoot);
654
+ const sandboxCheckInvocation = fence !== undefined && invokesSandboxCheck(command);
655
+ // A nested `xcsh sandbox check` must exercise the grants of this exact live profile. Seatbelt and
656
+ // Landlock restrictions compose and cannot be relaxed by its subprocess, so the diagnostic needs
657
+ // the immutable session anchor even when this individual call uses `cwd` or starts with `cd`.
658
+ // Keep these values host-owned: tool-supplied env cannot replace them.
659
+ let env = requestedEnv;
660
+ let trustedSessionRoot = containmentRoot;
661
+ if (fence !== undefined) {
662
+ const [canonicalSessionRoot, trustedOperatorHome] = await Promise.all([
663
+ canonicalizeSandboxContextPath(containmentRoot),
664
+ canonicalizeSandboxContextPath(os.homedir()),
665
+ ]);
666
+ trustedSessionRoot = canonicalSessionRoot;
667
+ env = {
668
+ ...requestedEnv,
669
+ [SANDBOX_SESSION_ROOT_ENV]: trustedSessionRoot,
670
+ [SANDBOX_OPERATOR_HOME_ENV]: trustedOperatorHome,
671
+ };
672
+ }
631
673
 
632
674
  const localOptions = {
633
675
  getArtifactsDir: this.session.getArtifactsDir,
@@ -670,6 +712,9 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
670
712
  const obfuscator = this.session.obfuscator;
671
713
  _sessionObfuscator = obfuscator; // Keep module-level ref fresh for renderer
672
714
  const maskSecrets = obfuscator?.hasSecrets() ? (t: string) => obfuscator.obfuscate(t) : undefined;
715
+ if (asyncRequested && sandboxCheckInvocation) {
716
+ throw new ToolError("Sandbox check must run synchronously so its synthetic fixtures can be removed.");
717
+ }
673
718
 
674
719
  if (asyncRequested) {
675
720
  if (!this.session.asyncJobManager) {
@@ -690,7 +735,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
690
735
  return this.#buildBackgroundStartResult(job.jobId, job.label, "", timeoutSec);
691
736
  }
692
737
 
693
- if (this.#autoBackgroundEnabled && !pty && this.session.asyncJobManager) {
738
+ if (this.#autoBackgroundEnabled && !pty && this.session.asyncJobManager && !sandboxCheckInvocation) {
694
739
  const autoBackgroundWaitMs = this.#resolveAutoBackgroundWaitMs(timeoutMs);
695
740
  const startBackgrounded = autoBackgroundWaitMs === 0;
696
741
  const job = this.#startManagedBashJob({
@@ -748,41 +793,60 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
748
793
  const osBackend = containmentStatus(fence !== undefined);
749
794
  const ptyConfinable = !osBackend.osEnforced || osBackend.backend === "seatbelt";
750
795
  const usePty = pty && ptyConfinable && $env.PI_NO_PTY !== "1" && ctx?.hasUI === true && ctx.ui !== undefined;
751
- const result: BashResult | BashInteractiveResult = usePty
752
- ? await runInteractiveBashPty(ctx.ui!, {
753
- command,
754
- cwd: commandCwd,
755
- timeoutMs,
756
- signal,
757
- env,
758
- artifactPath,
759
- artifactId,
760
- maskSecrets,
761
- // The same fence that decided `ptyConfinable`, so the gate and the enforcement can
762
- // never be looking at different answers.
763
- fence,
764
- })
765
- : await executeBash(command, {
766
- cwd: commandCwd,
767
- sessionKey: this.session.getSessionId?.() ?? undefined,
768
- timeout: timeoutMs,
769
- signal,
770
- env,
771
- artifactPath,
772
- artifactId,
773
- maskSecrets,
774
- fence,
775
- onChunk: chunk => {
776
- tailBuffer.append(chunk);
777
- if (onUpdate) {
778
- const preview = maskSecrets ? maskSecrets(tailBuffer.text()) : tailBuffer.text();
779
- onUpdate({
780
- content: [{ type: "text", text: preview }],
781
- details: {},
782
- });
783
- }
784
- },
785
- });
796
+ let sandboxCheckSibling: string | undefined;
797
+ if (sandboxCheckInvocation) {
798
+ // Landlock cannot add a newly-created child to an already-applied profile. Prepare the named
799
+ // sibling before the child is confined so the diagnostic measures reachability, not whether a
800
+ // nested sandbox can widen itself. The host owns both this path and its cleanup.
801
+ sandboxCheckSibling = await fs.promises.mkdtemp(
802
+ path.join(path.dirname(trustedSessionRoot), ".xcsh-sandbox-check-live-sibling-"),
803
+ );
804
+ await Bun.write(path.join(sandboxCheckSibling, "named.txt"), "sibling\n");
805
+ env = { ...env, [SANDBOX_CHECK_NAMED_SIBLING_ENV]: sandboxCheckSibling };
806
+ }
807
+
808
+ let result: BashResult | BashInteractiveResult;
809
+ try {
810
+ result = usePty
811
+ ? await runInteractiveBashPty(ctx.ui!, {
812
+ command,
813
+ cwd: commandCwd,
814
+ timeoutMs,
815
+ signal,
816
+ env,
817
+ artifactPath,
818
+ artifactId,
819
+ maskSecrets,
820
+ // The same fence that decided `ptyConfinable`, so the gate and the enforcement can
821
+ // never be looking at different answers.
822
+ fence,
823
+ })
824
+ : await executeBash(command, {
825
+ cwd: commandCwd,
826
+ sessionKey: this.session.getSessionId?.() ?? undefined,
827
+ timeout: timeoutMs,
828
+ signal,
829
+ env,
830
+ artifactPath,
831
+ artifactId,
832
+ maskSecrets,
833
+ fence,
834
+ onChunk: chunk => {
835
+ tailBuffer.append(chunk);
836
+ if (onUpdate) {
837
+ const preview = maskSecrets ? maskSecrets(tailBuffer.text()) : tailBuffer.text();
838
+ onUpdate({
839
+ content: [{ type: "text", text: preview }],
840
+ details: {},
841
+ });
842
+ }
843
+ },
844
+ });
845
+ } finally {
846
+ if (sandboxCheckSibling !== undefined) {
847
+ await fs.promises.rm(sandboxCheckSibling, { recursive: true, force: true });
848
+ }
849
+ }
786
850
  if (result.cancelled) {
787
851
  if (signal?.aborted) {
788
852
  throw new ToolAbortError(normalizeResultOutput(result) || "Command aborted");