@f5-sales-demo/xcsh 20.0.4 → 20.1.0

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,16 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [20.1.0] - 2026-08-01
6
+
7
+ ### Added
8
+
9
+ - Added `xcsh sandbox check` to verify the installed sandbox with synthetic fixtures and automatic cleanup ([#2790](https://github.com/f5-sales-demo/xcsh/issues/2790))
10
+
11
+ ### Fixed
12
+
13
+ - Denied local account containers and other operators' homes while preserving full access to the current operator's home ([#2788](https://github.com/f5-sales-demo/xcsh/issues/2788))
14
+
5
15
  ## [20.0.4] - 2026-08-01
6
16
 
7
17
  ### 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.0.4",
4
+ "version": "20.1.0",
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.0.4",
61
- "@f5-sales-demo/pi-agent-core": "20.0.4",
62
- "@f5-sales-demo/pi-ai": "20.0.4",
63
- "@f5-sales-demo/pi-natives": "20.0.4",
64
- "@f5-sales-demo/pi-resource-management": "20.0.4",
65
- "@f5-sales-demo/pi-tui": "20.0.4",
66
- "@f5-sales-demo/pi-utils": "20.0.4",
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",
67
67
  "@sinclair/typebox": "^0.34",
68
68
  "@xterm/headless": "^6.0",
69
69
  "ajv": "^8.20",
@@ -0,0 +1,283 @@
1
+ /** Installed-binary conformance check for the live filesystem sandbox. */
2
+ import * as fs from "node:fs/promises";
3
+ import * as os from "node:os";
4
+ import * as path from "node:path";
5
+ import { executeShell } from "@f5-sales-demo/pi-natives";
6
+ import { isEnoent } from "@f5-sales-demo/pi-utils";
7
+ import { Settings } from "../config/settings";
8
+ import { fenceForNative } from "../exec/bash-executor";
9
+ import { buildContainmentFence, type ContainmentFence, containmentStatus } from "../sandbox/containment";
10
+ import { evaluateToolCall } from "../sandbox/enforce";
11
+ import { BashTool, type ToolSession } from "../tools";
12
+
13
+ export type SandboxCheckResultStatus = "PASS" | "FAIL" | "SKIP";
14
+
15
+ export interface SandboxCheckResult {
16
+ name: string;
17
+ status: SandboxCheckResultStatus;
18
+ }
19
+
20
+ export interface SandboxCheckReport {
21
+ backend: string;
22
+ osEnforced: boolean;
23
+ checks: SandboxCheckResult[];
24
+ summary: {
25
+ passed: number;
26
+ failed: number;
27
+ skipped: number;
28
+ };
29
+ }
30
+
31
+ export interface SandboxCheckOptions {
32
+ json?: boolean;
33
+ }
34
+
35
+ function quote(value: string): string {
36
+ return JSON.stringify(value);
37
+ }
38
+
39
+ async function shellExitCode(
40
+ command: string,
41
+ cwd: string,
42
+ fence: ContainmentFence,
43
+ signal: AbortSignal,
44
+ ): Promise<number> {
45
+ const result = await executeShell(
46
+ {
47
+ command,
48
+ cwd,
49
+ fence: fenceForNative(fence),
50
+ signal,
51
+ timeoutMs: 15_000,
52
+ },
53
+ () => {},
54
+ );
55
+ return result.exitCode ?? -1;
56
+ }
57
+
58
+ function renderReport(report: SandboxCheckReport, json: boolean): void {
59
+ if (json) {
60
+ process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
61
+ return;
62
+ }
63
+
64
+ const enforcement = report.osEnforced ? "OS enforced" : "scanner only";
65
+ process.stdout.write(`Sandbox backend: ${report.backend} (${enforcement})\n\n`);
66
+ const width = Math.max(...report.checks.map(check => check.name.length));
67
+ for (const check of report.checks) {
68
+ process.stdout.write(`${check.status.padEnd(4)} ${check.name.padEnd(width)}\n`);
69
+ }
70
+ process.stdout.write(
71
+ `\n${report.summary.passed} passed, ${report.summary.failed} failed, ${report.summary.skipped} skipped\n`,
72
+ );
73
+ }
74
+
75
+ /** Run the conformance matrix and report only after every synthetic fixture has been removed. */
76
+ export async function runSandboxCheck(options: SandboxCheckOptions = {}): Promise<SandboxCheckReport> {
77
+ const backend = containmentStatus(true);
78
+ const checks: SandboxCheckResult[] = [];
79
+ const abortController = new AbortController();
80
+ const interrupt = () => abortController.abort();
81
+ process.once("SIGINT", interrupt);
82
+ process.once("SIGTERM", interrupt);
83
+
84
+ let fixtureRoot: string | undefined;
85
+ const add = (name: string, status: SandboxCheckResultStatus): void => {
86
+ checks.push({ name, status });
87
+ };
88
+ const check = async (name: string, probe: () => boolean | Promise<boolean>): Promise<void> => {
89
+ if (abortController.signal.aborted) {
90
+ add(name, "FAIL");
91
+ return;
92
+ }
93
+ try {
94
+ add(name, (await probe()) ? "PASS" : "FAIL");
95
+ } catch {
96
+ add(name, "FAIL");
97
+ }
98
+ };
99
+
100
+ try {
101
+ const tmpRoot = await fs.realpath(os.tmpdir());
102
+ fixtureRoot = await fs.mkdtemp(path.join(tmpRoot, "xcsh-sandbox-check-"));
103
+ const accountRoot = path.join(fixtureRoot, "Users");
104
+ const operatorHome = path.join(accountRoot, "operator");
105
+ const otherHome = path.join(accountRoot, "other-account");
106
+ const workspaces = path.join(operatorHome, "workspaces");
107
+ const workspace = path.join(workspaces, "example-a");
108
+ const sibling = path.join(workspaces, "example-b");
109
+ const nested = path.join(workspace, "nested");
110
+ const sessionStore = path.join(operatorHome, ".xcsh", "agent", "sessions");
111
+ const otherSession = path.join(sessionStore, "synthetic-session");
112
+ const memoryStore = path.join(operatorHome, ".xcsh", "agent", "memories");
113
+ const otherMemory = path.join(memoryStore, "synthetic-memory");
114
+ const configDir = path.join(operatorHome, ".config", "xcsh-check");
115
+
116
+ for (const dir of [workspace, sibling, nested, otherHome, otherSession, otherMemory, configDir]) {
117
+ await fs.mkdir(dir, { recursive: true });
118
+ }
119
+ await Bun.write(path.join(workspace, "own.txt"), "own\n");
120
+ await Bun.write(path.join(sibling, "named.txt"), "sibling\n");
121
+ await Bun.write(path.join(otherHome, "synthetic.txt"), "synthetic\n");
122
+ await Bun.write(path.join(otherSession, "state.jsonl"), "synthetic\n");
123
+ await Bun.write(path.join(otherMemory, "MEMORY.md"), "synthetic\n");
124
+
125
+ const fence = buildContainmentFence({
126
+ workspace,
127
+ home: operatorHome,
128
+ fsRoot: fixtureRoot,
129
+ leakRoots: [sessionStore, memoryStore],
130
+ });
131
+
132
+ await check("structured tools share the boundary", () => {
133
+ const blocked = [
134
+ evaluateToolCall({ toolName: "read", input: { file_path: workspaces }, cwd: workspace, fence }),
135
+ evaluateToolCall({
136
+ toolName: "write",
137
+ input: { file_path: path.join(otherHome, "new.txt") },
138
+ cwd: workspace,
139
+ fence,
140
+ }),
141
+ evaluateToolCall({ toolName: "find", input: { pattern: `${accountRoot}/**/*` }, cwd: workspace, fence }),
142
+ evaluateToolCall({ toolName: "grep", input: { path: otherHome }, cwd: workspace, fence }),
143
+ evaluateToolCall({
144
+ toolName: "read",
145
+ input: { file_path: path.join(otherSession, "state.jsonl") },
146
+ cwd: workspace,
147
+ fence,
148
+ }),
149
+ evaluateToolCall({
150
+ toolName: "read",
151
+ input: { file_path: path.join(otherMemory, "MEMORY.md") },
152
+ cwd: workspace,
153
+ fence,
154
+ }),
155
+ evaluateToolCall({
156
+ toolName: "python",
157
+ input: { code: `import os; os.listdir(${JSON.stringify(accountRoot)})` },
158
+ cwd: workspace,
159
+ fence,
160
+ }),
161
+ ];
162
+ const ownConfig = evaluateToolCall({
163
+ toolName: "write",
164
+ input: { file_path: path.join(configDir, "config") },
165
+ cwd: workspace,
166
+ fence,
167
+ });
168
+ return blocked.every(result => result.block) && !ownConfig.block;
169
+ });
170
+
171
+ await check("workspace read, write, glob, and recursion", async () => {
172
+ const command =
173
+ "cat own.txt > /dev/null && printf created > created.txt && " +
174
+ "printf '%s\\n' ./* > /dev/null && find . -type f > /dev/null";
175
+ return (await shellExitCode(command, workspace, fence, abortController.signal)) === 0;
176
+ });
177
+ 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;
180
+ });
181
+
182
+ if (backend.osEnforced) {
183
+ 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;
186
+ });
187
+ 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;
190
+ });
191
+ 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;
194
+ });
195
+ 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
201
+ );
202
+ });
203
+ } else {
204
+ for (const name of [
205
+ "session parent cannot be enumerated",
206
+ "account container cannot be enumerated",
207
+ "synthetic other account cannot be entered",
208
+ "cross-session stores cannot be read",
209
+ ]) {
210
+ add(name, "SKIP");
211
+ }
212
+ }
213
+
214
+ 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;
218
+ });
219
+
220
+ await check("cwd resets across tool calls", async () => {
221
+ const settings = await Settings.init({
222
+ cwd: workspace,
223
+ agentDir: path.join(fixtureRoot!, "agent-state"),
224
+ inMemory: true,
225
+ overrides: {
226
+ "async.enabled": false,
227
+ "bash.autoBackground.enabled": false,
228
+ "bashInterceptor.enabled": false,
229
+ "sandbox.enabled": true,
230
+ },
231
+ });
232
+ const session: ToolSession = {
233
+ cwd: workspace,
234
+ hasUI: false,
235
+ hasEditTool: true,
236
+ settings,
237
+ getSessionFile: () => null,
238
+ getSessionSpawns: () => null,
239
+ getSessionId: () => "sandbox-check-session",
240
+ };
241
+ const tool = new BashTool(session);
242
+ const moved = await tool.execute("sandbox-check-move", { command: `cd ${quote(nested)}; pwd` });
243
+ const reset = await tool.execute("sandbox-check-reset", { command: "pwd" });
244
+ const explicit = await tool.execute("sandbox-check-explicit", { command: "pwd", cwd: nested });
245
+ const text = (result: typeof moved): string =>
246
+ result.content
247
+ .filter((part): part is { type: "text"; text: string } => part.type === "text")
248
+ .map(part => part.text)
249
+ .join("")
250
+ .trim();
251
+ return text(moved) === nested && text(reset) === workspace && text(explicit) === nested;
252
+ });
253
+ } catch {
254
+ add("conformance matrix completed", "FAIL");
255
+ } finally {
256
+ process.off("SIGINT", interrupt);
257
+ process.off("SIGTERM", interrupt);
258
+ if (fixtureRoot === undefined) {
259
+ add("synthetic fixtures removed", "FAIL");
260
+ } else {
261
+ try {
262
+ await fs.rm(fixtureRoot, { recursive: true, force: true });
263
+ await fs.stat(fixtureRoot);
264
+ add("synthetic fixtures removed", "FAIL");
265
+ } catch (error) {
266
+ add("synthetic fixtures removed", isEnoent(error) ? "PASS" : "FAIL");
267
+ }
268
+ }
269
+ }
270
+
271
+ const report: SandboxCheckReport = {
272
+ backend: backend.backend,
273
+ osEnforced: backend.osEnforced,
274
+ checks,
275
+ summary: {
276
+ passed: checks.filter(result => result.status === "PASS").length,
277
+ failed: checks.filter(result => result.status === "FAIL").length,
278
+ skipped: checks.filter(result => result.status === "SKIP").length,
279
+ },
280
+ };
281
+ renderReport(report, options.json ?? false);
282
+ return report;
283
+ }
package/src/cli.ts CHANGED
@@ -56,6 +56,7 @@ const commands: CommandEntry[] = [
56
56
  { name: "grep", load: () => import("./commands/grep").then(m => m.default) },
57
57
  { name: "grievances", load: () => import("./commands/grievances").then(m => m.default) },
58
58
  { name: "read", load: () => import("./commands/read").then(m => m.default) },
59
+ { name: "sandbox", load: () => import("./commands/sandbox").then(m => m.default) },
59
60
  { name: "jupyter", load: () => import("./commands/jupyter").then(m => m.default) },
60
61
  { name: "manager", load: () => import("./commands/manager").then(m => m.default) },
61
62
  { name: "office", load: () => import("./commands/office").then(m => m.default) },
@@ -0,0 +1,25 @@
1
+ /** Run installed-binary sandbox diagnostics. */
2
+ import { Args, Command, Flags } from "@f5-sales-demo/pi-utils/cli";
3
+ import { runSandboxCheck } from "../cli/sandbox-check";
4
+
5
+ export default class Sandbox extends Command {
6
+ static description = "Verify the installed filesystem sandbox";
7
+
8
+ static args = {
9
+ action: Args.string({
10
+ description: "Sandbox action",
11
+ required: true,
12
+ options: ["check"],
13
+ }),
14
+ };
15
+
16
+ static flags = {
17
+ json: Flags.boolean({ description: "Output JSON" }),
18
+ };
19
+
20
+ async run(): Promise<void> {
21
+ const { flags } = await this.parse(Sandbox);
22
+ const report = await runSandboxCheck({ json: flags.json });
23
+ if (report.summary.failed > 0) process.exitCode = 1;
24
+ }
25
+ }
@@ -17,17 +17,17 @@ export interface BuildInfo {
17
17
  }
18
18
 
19
19
  export const BUILD_INFO: BuildInfo = {
20
- "version": "20.0.4",
21
- "commit": "c1606427884c61f77f5f1fbf1c4ab7511920061b",
22
- "shortCommit": "c160642",
20
+ "version": "20.1.0",
21
+ "commit": "a4002d84fa2f409413a313cb8a04670a1b107084",
22
+ "shortCommit": "a4002d8",
23
23
  "branch": "main",
24
- "tag": "v20.0.4",
25
- "commitDate": "2026-08-01T06:54:53Z",
26
- "buildDate": "2026-08-01T07:20:58.905Z",
24
+ "tag": "v20.1.0",
25
+ "commitDate": "2026-08-01T13:46:31Z",
26
+ "buildDate": "2026-08-01T14:24:45.281Z",
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/c1606427884c61f77f5f1fbf1c4ab7511920061b",
32
- "releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v20.0.4"
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"
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 = "7410235724c7a9fa215feddaded61f37179248fb";
5
+ export const CONSOLE_CATALOG_VERSION = "8e7e1863840cc8ed4db0a9781c695f251aafb214";
6
6
 
7
7
  export const CONSOLE_CATALOG_DATA: ConsoleCatalogData = {
8
- version: "7410235724c7a9fa215feddaded61f37179248fb",
8
+ version: "8e7e1863840cc8ed4db0a9781c695f251aafb214",
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',
@@ -15,9 +15,9 @@ followed symlinks — so how a path is spelled does not change what is reachable
15
15
  - Cross-tenant isolation removes the discovery step. The directory containing the session root cannot
16
16
  be enumerated, but a sibling path the operator names directly can still be read, written, or entered.
17
17
  An explicit read grant, including `--allow-path <dir>`, restores enumeration for that directory.
18
- - Cross-session stores and data roots remain denied. Another session's transcripts, memories, internal
19
- contexts, and temporary working state are not reachable through tools, nor are unrelated data roots
20
- and mounted data volumes.
18
+ - Cross-session stores, other operators' accounts, and data roots remain denied. Another session's
19
+ transcripts, memories, internal contexts, and temporary working state are not reachable through
20
+ tools, nor are sibling local accounts, unrelated data roots, and mounted data volumes.
21
21
 
22
22
  The same boundary answers for every tool. `read`, `write`, `grep`, `find`, `python`, and `bash` consult
23
23
  the same rules, so changing tools or spelling a path differently does not widen the session.
@@ -51,7 +51,8 @@ spelling may not be caught.
51
51
 
52
52
  The intended rules are the same ones a confined session has: parent enumeration is refused while named
53
53
  operator access remains available, operator-owned home and configuration stay readable and writable,
54
- and cross-session stores and unrelated data roots stay denied. Ordinary work remains unrestricted.
54
+ and cross-session stores, other operators' accounts, and unrelated data roots stay denied. Ordinary
55
+ work remains unrestricted.
55
56
 
56
57
  Treat the boundary as a statement of intent rather than a guarantee, and do not go looking for paths
57
58
  outside the session directory on the assumption that something would stop you.
@@ -8,8 +8,8 @@
8
8
  *
9
9
  * So the fence is gentle. It leaves `/usr`, `/tmp`, package caches, the network and process execution
10
10
  * alone. Its cross-tenant courtesy removes the discovery step — enumerating the session root's parent —
11
- * while keeping named operator access. Explicit data-root and cross-session state denies remain where
12
- * the model has no legitimate reason to wander (#2554).
11
+ * while keeping named operator access. Other operators' accounts, explicit data roots and cross-session
12
+ * state remain denied where the model has no legitimate reason to wander (#2554).
13
13
  *
14
14
  * Produced declaratively rather than as an ordered rule list, because the two backends disagree about
15
15
  * order: seatbelt evaluates rules in sequence with the last match winning, while Landlock only grants
@@ -197,9 +197,8 @@ const OPERATIONAL_ROOT_NAMES = new Set([
197
197
  * unforeseen — `/data`, `/scratch`, a bespoke mount.
198
198
  */
199
199
  const DATA_ROOTS = [
200
- // `/Users` and `/home` are deliberately absent: denying the home container denies this operator's own
201
- // home with it, which is the whole of #2637. Other accounts are 0700, so the filesystem already refuses
202
- // them; the fence is not here to re-implement file permissions.
200
+ "/Users", // Account containers are denied, then this operator's canonical home is allowed back at
201
+ "/home", // greater depth. That preserves #2637 without exposing another local account (#2788).
203
202
  "/root", // Linux superuser home: not this operator's account
204
203
  "/Volumes", // macOS mounts. Per-container, not per-child: /Volumes/Macintosh HD resolves to /,
205
204
  "/mnt", // which `tooBroadToDeny` then rejects, and the kernel resolves such a path before any
@@ -481,6 +480,11 @@ export function buildContainmentFence(options: ContainmentOptions): ContainmentF
481
480
  }
482
481
 
483
482
  if (home !== undefined) {
483
+ // The account container is a data root, but this operator's whole home belongs to them (#2637).
484
+ // A deeper full allow preserves their normal filesystem rights while leaving sibling accounts under
485
+ // the broader deny. Cross-session stores are denied again at still greater depth below.
486
+ allow.add(home);
487
+
484
488
  // Granted whether or not they exist yet. `~/.bun` has to be writable *before* the first
485
489
  // `bun install` creates it, so dropping absent caches would break exactly the first run.
486
490
  // Canonicalised when present, so a symlinked cache resolves to its real location.
@@ -505,6 +509,7 @@ export function buildContainmentFence(options: ContainmentOptions): ContainmentF
505
509
  // prefix whether or not the path exists; Landlock cannot attach a rule to an absent inode, but its
506
510
  // plan never grants a path `readdir` did not see either, so nothing is lost there.
507
511
  const known = DATA_ROOTS.map(name => canonicalThroughExisting(path.join(fsRoot, path.basename(name))));
512
+ const accountRoots = new Set(["Users", "home"].map(name => canonicalThroughExisting(path.join(fsRoot, name))));
508
513
  const found = dataRootEntries(fsRoot).map(entry => canonical(entry) ?? entry);
509
514
  // Whole filesystem roots other than the workspace's own — the other Windows drives.
510
515
  //
@@ -520,11 +525,11 @@ export function buildContainmentFence(options: ContainmentOptions): ContainmentF
520
525
  // e.g. /Volumes/Macintosh HD -> /. Skipped for the whole-root list, per the note above.
521
526
  if (!rootScoped.has(resolved) && tooBroadToDeny(resolved, fsRoot)) continue;
522
527
  if (resolved === fsRoot) continue; // never the root the workspace lives on
523
- // Never a directory that CONTAINS home, because denying it denies home (#2637). Removing `/Users`
524
- // and `/home` from the static list was not enough: the root enumeration re-adds them, since
525
- // `Users` is not an operational name. That is what still refused `~/git/STYLE_GUIDE.md` at the
526
- // kernel while `fenceVerdict` said allow — the unit tests use synthetic roots and could not see it.
527
- if (home !== undefined && pathIsWithin(resolved, home)) continue;
528
+ // A directory containing home is normally too broad to deny because it would revoke the operator's
529
+ // own files (#2637). Account containers are the deliberate exception: they are denied here and the
530
+ // canonical home allow above wins at greater depth, isolating sibling accounts without hobbling the
531
+ // operator (#2788).
532
+ if (home !== undefined && pathIsWithin(resolved, home) && !accountRoots.has(resolved)) continue;
528
533
  // A deny beats an allow at EQUAL depth, so denying a root that IS the workspace or IS something
529
534
  // the operator granted would not be redundant — it would silently revoke the grant. Deeper is
530
535
  // fine and intended: an ancestor deny with the workspace allowed inside it is the normal shape.