@f5-sales-demo/xcsh 20.1.1 → 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,12 @@
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
+
5
11
  ## [20.1.1] - 2026-08-01
6
12
 
7
13
  ### Fixed
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.1",
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.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",
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,15 +8,19 @@ 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
+ import {
12
+ SANDBOX_CHECK_NAMED_SIBLING_ENV,
13
+ SANDBOX_OPERATOR_HOME_ENV,
14
+ SANDBOX_SESSION_ROOT_ENV,
15
+ } from "../sandbox/session-fence";
12
16
  import { BashTool, type ToolSession } from "../tools";
13
17
 
14
- export type SandboxCheckResultStatus = "PASS" | "FAIL" | "SKIP";
18
+ export type SandboxCheckResultStatus = "PASS" | "FAIL" | "SKIP" | "ERROR";
15
19
 
16
20
  export interface SandboxCheckResult {
17
21
  name: string;
18
22
  status: SandboxCheckResultStatus;
19
- /** Present on failures and skips; paths are generalized before they leave the process. */
23
+ /** Present on failures, errors, and skips; paths are generalized before they leave the process. */
20
24
  detail?: string;
21
25
  }
22
26
 
@@ -27,6 +31,7 @@ export interface SandboxCheckReport {
27
31
  summary: {
28
32
  passed: number;
29
33
  failed: number;
34
+ errors: number;
30
35
  skipped: number;
31
36
  };
32
37
  }
@@ -142,15 +147,19 @@ function renderReport(report: SandboxCheckReport, json: boolean, verbose: boolea
142
147
 
143
148
  const enforcement = report.osEnforced ? "OS enforced" : "scanner only";
144
149
  process.stdout.write(`Sandbox backend: ${report.backend} (${enforcement})\n\n`);
145
- const width = Math.max(0, ...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));
146
152
  for (const check of report.checks) {
147
- 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`);
148
154
  if (verbose && check.detail) process.stdout.write(` ${check.detail}\n`);
149
155
  }
150
156
  process.stdout.write(
151
- `\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`,
152
158
  );
153
- if (!verbose && report.summary.failed > 0) {
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)) {
154
163
  process.stdout.write("Run `xcsh sandbox check --verbose` for failure details.\n");
155
164
  }
156
165
  }
@@ -177,7 +186,7 @@ export async function runSandboxCheck(options: SandboxCheckOptions = {}): Promis
177
186
  probe: () => boolean | ProbeOutcome | Promise<boolean | ProbeOutcome>,
178
187
  ): Promise<void> => {
179
188
  if (abortController.signal.aborted) {
180
- add(name, "FAIL", "probe aborted before execution; path=<probe>; errno=ABORTED");
189
+ add(name, "ERROR", "probe aborted before execution; path=<probe>; errno=ABORTED");
181
190
  return;
182
191
  }
183
192
  try {
@@ -190,18 +199,25 @@ export async function runSandboxCheck(options: SandboxCheckOptions = {}): Promis
190
199
  );
191
200
  } catch (error) {
192
201
  const outcome = exceptionOutcome("probe threw", "<probe>", error, redactions);
193
- add(name, "FAIL", outcome.detail);
202
+ add(name, "ERROR", outcome.detail);
194
203
  }
195
204
  };
196
205
 
197
206
  try {
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();
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();
201
213
  redactions.push([workspaceInput, "<workspace>"], [homeInput, "<operator-home>"]);
202
- const liveWorkspace = await fs.realpath(workspaceInput);
203
- const liveHome = await fs.realpath(homeInput);
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));
204
219
  redactions.push([liveWorkspace, "<workspace>"], [liveHome, "<operator-home>"]);
220
+ if (inheritedSibling !== undefined) redactions.push([inheritedSibling, "<session-parent>/<synthetic-sibling>"]);
205
221
 
206
222
  const fixtureBase = inheritedProfile ? liveWorkspace : await fs.realpath(os.tmpdir());
207
223
  fixtureRoot = await fs.mkdtemp(path.join(fixtureBase, ".xcsh-sandbox-check-policy-"));
@@ -309,17 +325,19 @@ export async function runSandboxCheck(options: SandboxCheckOptions = {}): Promis
309
325
 
310
326
  await check("named sibling remains reachable", async () => {
311
327
  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);
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
+ }
323
341
  }
324
342
  const result = await shellProbe(
325
343
  'test "$(cat named.txt)" = sibling',
@@ -485,7 +503,7 @@ export async function runSandboxCheck(options: SandboxCheckOptions = {}): Promis
485
503
  });
486
504
  } catch (error) {
487
505
  const outcome = exceptionOutcome("conformance matrix setup failed", "<probe>", error, redactions);
488
- add("conformance matrix completed", "FAIL", outcome.detail);
506
+ add("conformance matrix setup", "ERROR", outcome.detail);
489
507
  } finally {
490
508
  process.off("SIGINT", interrupt);
491
509
  process.off("SIGTERM", interrupt);
@@ -511,17 +529,17 @@ export async function runSandboxCheck(options: SandboxCheckOptions = {}): Promis
511
529
  }
512
530
  }
513
531
 
514
- if (fixtureRoot === undefined || cleanupFailures.length > 0) {
532
+ if (cleanupFailures.length > 0) {
515
533
  add(
516
534
  "synthetic fixtures removed",
517
- "FAIL",
535
+ "ERROR",
518
536
  sanitizeDetail(
519
- `fixture cleanup incomplete; path=<synthetic-fixtures>; errno=${
520
- fixtureRoot === undefined ? "ENOENT" : "unknown"
521
- }${cleanupFailures.length > 0 ? `; error=${cleanupFailures.join("; ")}` : ""}`,
537
+ `fixture cleanup incomplete; path=<synthetic-fixtures>; errno=unknown; error=${cleanupFailures.join("; ")}`,
522
538
  redactions,
523
539
  ),
524
540
  );
541
+ } else if (fixturePaths.length === 0) {
542
+ add("synthetic fixtures removed", "SKIP", "setup created no fixtures; path=<synthetic-fixtures>; errno=none");
525
543
  } else {
526
544
  add("synthetic fixtures removed", "PASS");
527
545
  }
@@ -534,6 +552,7 @@ export async function runSandboxCheck(options: SandboxCheckOptions = {}): Promis
534
552
  summary: {
535
553
  passed: checks.filter(result => result.status === "PASS").length,
536
554
  failed: checks.filter(result => result.status === "FAIL").length,
555
+ errors: checks.filter(result => result.status === "ERROR").length,
537
556
  skipped: checks.filter(result => result.status === "SKIP").length,
538
557
  },
539
558
  };
@@ -21,6 +21,6 @@ export default class Sandbox extends Command {
21
21
  async run(): Promise<void> {
22
22
  const { flags } = await this.parse(Sandbox);
23
23
  const report = await runSandboxCheck({ json: flags.json, verbose: flags.verbose });
24
- if (report.summary.failed > 0) process.exitCode = 1;
24
+ if (report.summary.failed > 0 || report.summary.errors > 0) process.exitCode = 1;
25
25
  }
26
26
  }
@@ -17,17 +17,17 @@ export interface BuildInfo {
17
17
  }
18
18
 
19
19
  export const BUILD_INFO: BuildInfo = {
20
- "version": "20.1.1",
21
- "commit": "ac536e6d319a00467e8e95de237cc915b1551dec",
22
- "shortCommit": "ac536e6",
20
+ "version": "20.1.2",
21
+ "commit": "b5534d98c8ad483cc6e8709b44242cf3ea98041d",
22
+ "shortCommit": "b5534d9",
23
23
  "branch": "main",
24
- "tag": "v20.1.1",
25
- "commitDate": "2026-08-01T18:06:22Z",
26
- "buildDate": "2026-08-01T18:28:19.612Z",
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/ac536e6d319a00467e8e95de237cc915b1551dec",
32
- "releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v20.1.1"
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":
@@ -26,6 +26,7 @@ import { buildContainmentFence, type ContainmentFence } from "./containment";
26
26
  */
27
27
  export const SANDBOX_SESSION_ROOT_ENV = "XCSH_SANDBOX_SESSION_ROOT";
28
28
  export const SANDBOX_OPERATOR_HOME_ENV = "XCSH_SANDBOX_OPERATOR_HOME";
29
+ export const SANDBOX_CHECK_NAMED_SIBLING_ENV = "XCSH_SANDBOX_CHECK_NAMED_SIBLING";
29
30
 
30
31
  /** The slice of `Settings` this needs — supplied explicitly so the caller names its own source. */
31
32
  export interface SettingsReader {
package/src/tools/bash.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as os from "node:os";
3
+ import * as path from "node:path";
3
4
  import type {
4
5
  AgentTool,
5
6
  AgentToolContext,
@@ -18,7 +19,12 @@ import { truncateToVisualLines } from "../modes/components/visual-truncate";
18
19
  import type { Theme } from "../modes/theme/theme";
19
20
  import bashDescription from "../prompts/tools/bash.md" with { type: "text" };
20
21
  import { type ContainmentFence, containmentStatus, fenceVerdict } from "../sandbox/containment";
21
- import { resolveSessionFence, SANDBOX_OPERATOR_HOME_ENV, SANDBOX_SESSION_ROOT_ENV } 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";
22
28
  import { SECRET_ENV_PATTERNS, type SecretObfuscator } from "../secrets";
23
29
  import { DEFAULT_MAX_BYTES, TailBuffer } from "../session/streaming-output";
24
30
  import { renderStatusLine } from "../tui";
@@ -178,6 +184,21 @@ function normalizeBashEnv(env: Record<string, string> | undefined): Record<strin
178
184
  return normalized;
179
185
  }
180
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
+
181
202
  function escapeBashEnvValueForDisplay(value: string): string {
182
203
  return value
183
204
  .replaceAll("\\", "\\\\")
@@ -630,18 +651,25 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
630
651
  // same boundary rather than two that merely agree today.
631
652
  const containmentRoot = this.#containmentRoot();
632
653
  const fence = this.#containmentFence(containmentRoot);
654
+ const sandboxCheckInvocation = fence !== undefined && invokesSandboxCheck(command);
633
655
  // A nested `xcsh sandbox check` must exercise the grants of this exact live profile. Seatbelt and
634
656
  // Landlock restrictions compose and cannot be relaxed by its subprocess, so the diagnostic needs
635
657
  // the immutable session anchor even when this individual call uses `cwd` or starts with `cd`.
636
658
  // 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
- };
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
+ }
645
673
 
646
674
  const localOptions = {
647
675
  getArtifactsDir: this.session.getArtifactsDir,
@@ -684,6 +712,9 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
684
712
  const obfuscator = this.session.obfuscator;
685
713
  _sessionObfuscator = obfuscator; // Keep module-level ref fresh for renderer
686
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
+ }
687
718
 
688
719
  if (asyncRequested) {
689
720
  if (!this.session.asyncJobManager) {
@@ -704,7 +735,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
704
735
  return this.#buildBackgroundStartResult(job.jobId, job.label, "", timeoutSec);
705
736
  }
706
737
 
707
- if (this.#autoBackgroundEnabled && !pty && this.session.asyncJobManager) {
738
+ if (this.#autoBackgroundEnabled && !pty && this.session.asyncJobManager && !sandboxCheckInvocation) {
708
739
  const autoBackgroundWaitMs = this.#resolveAutoBackgroundWaitMs(timeoutMs);
709
740
  const startBackgrounded = autoBackgroundWaitMs === 0;
710
741
  const job = this.#startManagedBashJob({
@@ -762,41 +793,60 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
762
793
  const osBackend = containmentStatus(fence !== undefined);
763
794
  const ptyConfinable = !osBackend.osEnforced || osBackend.backend === "seatbelt";
764
795
  const usePty = pty && ptyConfinable && $env.PI_NO_PTY !== "1" && ctx?.hasUI === true && ctx.ui !== undefined;
765
- const result: BashResult | BashInteractiveResult = usePty
766
- ? await runInteractiveBashPty(ctx.ui!, {
767
- command,
768
- cwd: commandCwd,
769
- timeoutMs,
770
- signal,
771
- env,
772
- artifactPath,
773
- artifactId,
774
- maskSecrets,
775
- // The same fence that decided `ptyConfinable`, so the gate and the enforcement can
776
- // never be looking at different answers.
777
- fence,
778
- })
779
- : await executeBash(command, {
780
- cwd: commandCwd,
781
- sessionKey: this.session.getSessionId?.() ?? undefined,
782
- timeout: timeoutMs,
783
- signal,
784
- env,
785
- artifactPath,
786
- artifactId,
787
- maskSecrets,
788
- fence,
789
- onChunk: chunk => {
790
- tailBuffer.append(chunk);
791
- if (onUpdate) {
792
- const preview = maskSecrets ? maskSecrets(tailBuffer.text()) : tailBuffer.text();
793
- onUpdate({
794
- content: [{ type: "text", text: preview }],
795
- details: {},
796
- });
797
- }
798
- },
799
- });
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
+ }
800
850
  if (result.cancelled) {
801
851
  if (signal?.aborted) {
802
852
  throw new ToolAbortError(normalizeResultOutput(result) || "Command aborted");