@f5-sales-demo/xcsh 19.98.9 → 19.99.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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@f5-sales-demo/xcsh",
4
- "version": "19.98.9",
4
+ "version": "19.99.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": "19.98.9",
61
- "@f5-sales-demo/pi-agent-core": "19.98.9",
62
- "@f5-sales-demo/pi-ai": "19.98.9",
63
- "@f5-sales-demo/pi-natives": "19.98.9",
64
- "@f5-sales-demo/pi-resource-management": "19.98.9",
65
- "@f5-sales-demo/pi-tui": "19.98.9",
66
- "@f5-sales-demo/pi-utils": "19.98.9",
60
+ "@f5-sales-demo/xcsh-stats": "19.99.0",
61
+ "@f5-sales-demo/pi-agent-core": "19.99.0",
62
+ "@f5-sales-demo/pi-ai": "19.99.0",
63
+ "@f5-sales-demo/pi-natives": "19.99.0",
64
+ "@f5-sales-demo/pi-resource-management": "19.99.0",
65
+ "@f5-sales-demo/pi-tui": "19.99.0",
66
+ "@f5-sales-demo/pi-utils": "19.99.0",
67
67
  "@sinclair/typebox": "^0.34",
68
68
  "@xterm/headless": "^6.0",
69
69
  "ajv": "^8.20",
@@ -6,10 +6,27 @@
6
6
  import * as fs from "node:fs/promises";
7
7
  import { executeShell, Shell } from "@f5-sales-demo/pi-natives";
8
8
  import { Settings } from "../config/settings";
9
+ import type { ContainmentFence } from "../sandbox/containment";
9
10
  import { OutputSink } from "../session/streaming-output";
10
11
  import { getOrCreateSnapshot } from "../utils/shell-snapshot";
11
12
  import { NON_INTERACTIVE_ENV } from "./non-interactive-env";
12
13
 
14
+ /**
15
+ * The fence as the napi boundary wants it: plain mutable arrays, or absent for unrestricted.
16
+ *
17
+ * Shared with the PTY path, which needs the identical conversion. One converter, so the two
18
+ * execution paths cannot end up disagreeing about what they hand the native layer.
19
+ */
20
+ export function fenceForNative(fence: ContainmentFence | undefined) {
21
+ if (!fence) return undefined;
22
+ return {
23
+ allow: [...fence.allow],
24
+ allowReadOnly: [...fence.allowReadOnly],
25
+ allowWriteOnly: [...fence.allowWriteOnly],
26
+ deny: [...fence.deny],
27
+ };
28
+ }
29
+
13
30
  export interface BashExecutorOptions {
14
31
  cwd?: string;
15
32
  timeout?: number;
@@ -24,6 +41,16 @@ export interface BashExecutorOptions {
24
41
  artifactId?: string;
25
42
  /** Mask sensitive values (e.g. env var secrets) in output. */
26
43
  maskSecrets?: (text: string) => string;
44
+ /**
45
+ * Which paths this command may reach, enforced inside the shell rather than guessed from the
46
+ * command text (#2554).
47
+ *
48
+ * Absent means unrestricted, and absent is the default on purpose. Only the model's `bash` tool
49
+ * supplies one — `executeBash` is also reached by user-typed `!cmd`, by RPC `bash`, and the same
50
+ * brush-core runs credential helpers and the interactive `xcsh shell`. Fencing those would break
51
+ * API-key resolution and confine the operator at their own prompt.
52
+ */
53
+ fence?: ContainmentFence;
27
54
  }
28
55
 
29
56
  export interface BashResult {
@@ -186,6 +213,7 @@ export async function executeBash(command: string, options?: BashExecutorOptions
186
213
  env: commandEnv,
187
214
  timeoutMs: options?.timeout,
188
215
  signal: runAbortController.signal,
216
+ fence: fenceForNative(options?.fence),
189
217
  },
190
218
  (err, chunk) => {
191
219
  if (!err) {
@@ -202,6 +230,7 @@ export async function executeBash(command: string, options?: BashExecutorOptions
202
230
  snapshotPath: snapshotPath ?? undefined,
203
231
  timeoutMs: options?.timeout,
204
232
  signal: runAbortController.signal,
233
+ fence: fenceForNative(options?.fence),
205
234
  },
206
235
  (err, chunk) => {
207
236
  if (!err) {
@@ -3,6 +3,8 @@ import * as path from "node:path";
3
3
  import { prompt } from "@f5-sales-demo/pi-utils";
4
4
  import { $ } from "bun";
5
5
  import activeModelTemplate from "../prompts/internal-urls/active-model.md" with { type: "text" };
6
+ import containmentTemplate from "../prompts/internal-urls/containment.md" with { type: "text" };
7
+ import type { ContainmentStatus } from "../sandbox/containment";
6
8
  import type { ContextStatus } from "../services/xcsh-context";
7
9
  import type { ActiveModelSnapshot } from "../session/active-model";
8
10
  import { BUILD_INFO, type BuildInfo } from "./build-info.generated";
@@ -157,10 +159,23 @@ function renderActiveModel(model: ActiveModelSnapshot | null): string {
157
159
  return prompt.render(activeModelTemplate, { model });
158
160
  }
159
161
 
162
+ /**
163
+ * What is enforcing the filesystem boundary, and what that does and does not guarantee.
164
+ *
165
+ * Stated here because two sessions can look identical and offer very different guarantees: with an OS
166
+ * backend a path is checked where it is opened and the spelling cannot matter, while without one the
167
+ * only check reads the command text. An operator has no other way to tell which they have.
168
+ */
169
+ function renderContainment(containment: ContainmentStatus | null): string {
170
+ if (!containment) return "";
171
+ return prompt.render(containmentTemplate, { containment });
172
+ }
173
+
160
174
  export function renderAboutDoc(
161
175
  info: RuntimeBuildInfo,
162
176
  context: ContextStatus | null,
163
177
  model: ActiveModelSnapshot | null,
178
+ containment: ContainmentStatus | null,
164
179
  ): string {
165
180
  return [
166
181
  "# xcsh — identity and build fingerprint",
@@ -183,6 +198,7 @@ export function renderAboutDoc(
183
198
  "",
184
199
  renderPlatformContext(context, Date.now()),
185
200
  renderActiveModel(model),
201
+ renderContainment(containment),
186
202
  "## Source of truth",
187
203
  "",
188
204
  `- Repository: ${info.repoUrl}`,
@@ -17,17 +17,17 @@ export interface BuildInfo {
17
17
  }
18
18
 
19
19
  export const BUILD_INFO: BuildInfo = {
20
- "version": "19.98.9",
21
- "commit": "f920b739147c8af141a54972d353e0522a276fcc",
22
- "shortCommit": "f920b73",
20
+ "version": "19.99.0",
21
+ "commit": "8020e6b8aa7ae0c4fd4ac6da9ee9fa81b77182f0",
22
+ "shortCommit": "8020e6b",
23
23
  "branch": "main",
24
- "tag": "v19.98.9",
25
- "commitDate": "2026-07-28T14:28:13Z",
26
- "buildDate": "2026-07-28T15:05:19.433Z",
24
+ "tag": "v19.99.0",
25
+ "commitDate": "2026-07-28T17:45:44Z",
26
+ "buildDate": "2026-07-28T18:11:25.457Z",
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/f920b739147c8af141a54972d353e0522a276fcc",
32
- "releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v19.98.9"
31
+ "commitUrl": "https://github.com/f5-sales-demo/xcsh/commit/8020e6b8aa7ae0c4fd4ac6da9ee9fa81b77182f0",
32
+ "releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v19.99.0"
33
33
  };
@@ -55,12 +55,12 @@ function contentTypeFor(filePath: string): InternalResource["contentType"] {
55
55
  /**
56
56
  * Extensions a plugin may legitimately declare that are NOT text.
57
57
  *
58
- * Reading one as UTF-8 corrupts it, and nobody wants 91 KB of mangled bytes in a prompt
59
- * either — the MEDDPICC plugin's `meddpicc-template.xlsx` is exactly that size. What a
60
- * caller needs is where the file is, so it can be copied, opened, or handed to a tool
61
- * that understands the format; so a binary resource resolves to its location, not its
62
- * contents. Deliberately a small allow-list of formats plugins actually ship rather than
63
- * content sniffing, so the decision is inspectable.
58
+ * Reading one as UTF-8 corrupts it, and nobody wants tens of kilobytes of mangled bytes in a
59
+ * prompt either — a spreadsheet or a slide deck runs to that easily. What a caller needs is
60
+ * where the file is, so it can be copied, opened, or handed to a tool that understands the
61
+ * format; so a binary resource resolves to its location, not its contents. Deliberately a
62
+ * small allow-list of formats plugins actually ship rather than content sniffing, so the
63
+ * decision is inspectable.
64
64
  */
65
65
  const BINARY_EXTENSIONS = new Set([
66
66
  ".xlsx",
@@ -27,6 +27,7 @@
27
27
  */
28
28
  import * as path from "node:path";
29
29
  import { logger } from "@f5-sales-demo/pi-utils";
30
+ import type { ContainmentStatus } from "../sandbox/containment";
30
31
  import type { ContextStatus } from "../services/xcsh-context";
31
32
  import type { ActiveModelSnapshot } from "../session/active-model";
32
33
  import { type ApiCatalogResolver, createApiCatalogResolver } from "./api-catalog-resolve";
@@ -311,6 +312,12 @@ export interface InternalDocsProtocolOptions {
311
312
  * reflects a mid-session switch instead of the model the session launched with (#2459).
312
313
  */
313
314
  readonly getActiveModel?: () => ActiveModelSnapshot | null;
315
+ /**
316
+ * What is enforcing the filesystem boundary. A live getter, like `getActiveModel`, because
317
+ * `--no-sandbox` and `sandbox.enabled` can differ per session and the answer must not be captured
318
+ * at construction.
319
+ */
320
+ readonly getContainment?: () => ContainmentStatus | null;
314
321
  readonly apiSpecResolver?: ApiSpecResolver;
315
322
  readonly apiCatalogResolver?: ApiCatalogResolver;
316
323
  readonly getPluginRoots?: GetPluginRoots;
@@ -323,6 +330,7 @@ export class InternalDocsProtocolHandler implements ProtocolHandler {
323
330
  readonly #resolveBuildInfo: () => Promise<RuntimeBuildInfo>;
324
331
  readonly #getContextStatus: (() => ContextStatus | null) | undefined;
325
332
  readonly #getActiveModel: (() => ActiveModelSnapshot | null) | undefined;
333
+ readonly #getContainment: (() => ContainmentStatus | null) | undefined;
326
334
  #apiSpecResolver: ApiSpecResolver | null;
327
335
  #apiCatalogResolver: ApiCatalogResolver | null;
328
336
  #terraformResolver: TerraformResolver | null;
@@ -338,6 +346,7 @@ export class InternalDocsProtocolHandler implements ProtocolHandler {
338
346
  this.#resolveBuildInfo = options.resolveBuildInfo ?? getRuntimeBuildInfo;
339
347
  this.#getContextStatus = options.getContextStatus;
340
348
  this.#getActiveModel = options.getActiveModel;
349
+ this.#getContainment = options.getContainment;
341
350
  this.#apiSpecResolver = options.apiSpecResolver ?? null;
342
351
  this.#apiCatalogResolver = options.apiCatalogResolver ?? null;
343
352
  this.#terraformResolver = null;
@@ -814,7 +823,8 @@ export class InternalDocsProtocolHandler implements ProtocolHandler {
814
823
  const info = await this.#resolveBuildInfo();
815
824
  const context = this.#getContextStatus?.() ?? null;
816
825
  const activeModel = this.#getActiveModel?.() ?? null;
817
- const content = renderAboutDoc(info, context, activeModel);
826
+ const containment = this.#getContainment?.() ?? null;
827
+ const content = renderAboutDoc(info, context, activeModel, containment);
818
828
  return {
819
829
  url: url.href,
820
830
  content,
@@ -0,0 +1,35 @@
1
+ ## Sandbox containment
2
+
3
+ {{#if containment.enabled}}
4
+ Filesystem isolation is **on**. Enforcement for the `bash` tool: **{{containment.backend}}**.
5
+
6
+ {{#if containment.osEnforced}}
7
+ Your shell is confined by the operating system, not by inspecting the command you wrote. A path is
8
+ checked where it is actually opened, after the shell has expanded variables, resolved aliases and
9
+ followed symlinks — so how a path is spelled does not change what is reachable.
10
+ - Ordinary work is unrestricted: system paths, `/tmp`, package caches (`~/.bun`, `~/.cargo`, …), the
11
+ network, and running programs are all untouched.
12
+ - What is refused: reading or writing outside the session directory — another checkout, `~/.ssh`,
13
+ `~/.aws`, `~/Documents`, and other sessions' transcripts. `~/.gitconfig` is readable, not writable.
14
+ - `cd` out of the session tree is refused, because every later relative path would resolve there.
15
+
16
+ If a command is refused, do not try to reach the same path a different way: the boundary is enforced
17
+ below the command text, so no rewriting will succeed. Say what you needed and why. The operator can
18
+ widen it with `--allow-path <dir>`, which grants read and write.
19
+ {{else}}
20
+ On this platform there is **no OS-level backend**, so the boundary is enforced only by scanning the
21
+ command text before it runs. That check is best-effort by construction: it reads what you wrote
22
+ rather than what the shell will do, so a path assembled at runtime or reached through an unusual
23
+ spelling may not be caught.
24
+
25
+ Treat the boundary as a statement of intent rather than a guarantee here, and do not go looking for
26
+ paths outside the session directory on the assumption that something would stop you.
27
+ {{/if}}
28
+ {{else}}
29
+ Filesystem isolation is **off** for this session — started with `--no-sandbox`, or
30
+ `sandbox.enabled` is false. Every path on this machine is reachable.
31
+
32
+ Nothing is confining you, so the judgement is yours: stay within the directory the operator is
33
+ working in unless they asked for something else, and do not read private files elsewhere on the
34
+ machine because you happen to be able to.
35
+ {{/if}}
@@ -0,0 +1,269 @@
1
+ /**
2
+ * The containment fence: what the shell may reach, enforced below the command text.
3
+ *
4
+ * This is deliberately NOT `SandboxPolicy`. That object is deny-by-default — "a path matched by no
5
+ * rule is denied" — which is the right posture for the structured file tools and the wrong one here.
6
+ * Confining a shell that way refuses ordinary work: measured on macOS 26.3, a deny-default seatbelt
7
+ * profile could not even `execvp /bin/cat`.
8
+ *
9
+ * So the fence is gentle. It restricts no operation — `/usr`, `/tmp`, package caches, the network and
10
+ * process execution are never mentioned, and nothing that works today stops working. The single thing
11
+ * it prevents is the assistant wandering the filesystem: reading or writing another customer's
12
+ * checkout, `~/.ssh`, `~/Documents`. That is where the cross-customer risk actually lives (#2554).
13
+ *
14
+ * Produced declaratively rather than as an ordered rule list, because the two backends disagree about
15
+ * order: seatbelt evaluates rules in sequence with the last match winning, while Landlock only grants
16
+ * and cannot deny a subpath of something granted. Both can compile `{allow, allowReadOnly, deny}` with
17
+ * deny-wins, so neither has to reason about ordering.
18
+ */
19
+ import * as fs from "node:fs";
20
+ import * as os from "node:os";
21
+ import * as path from "node:path";
22
+ import { getMemoriesDir, getSessionsDir, getXCSHContextsDir, pathIsWithin } from "@f5-sales-demo/pi-utils";
23
+
24
+ export type FenceAccess = "read" | "write";
25
+ export type FenceVerdict = "allow" | "deny";
26
+
27
+ export interface ContainmentFence {
28
+ /** Canonical roots the shell may read and write. */
29
+ readonly allow: readonly string[];
30
+ /** Canonical roots the shell may read but not write. */
31
+ readonly allowReadOnly: readonly string[];
32
+ /** Canonical roots the shell may write but not read. */
33
+ readonly allowWriteOnly: readonly string[];
34
+ /** Canonical roots denied in both directions, winning over any allow they sit inside. */
35
+ readonly deny: readonly string[];
36
+ }
37
+
38
+ export interface ContainmentOptions {
39
+ /** The session's working directory. Must exist — it is the one root that cannot be dropped. */
40
+ workspace: string;
41
+ /** Overridable for tests; defaults to the real home directory. */
42
+ home?: string;
43
+ /** A session-specific temp dir, if the session has one. */
44
+ sessionTmp?: string;
45
+ /** Roots granted read+write, as `--allow-path` does. */
46
+ extraRoots?: readonly string[];
47
+ /** Roots granted read only — `sandbox.allowRead`. Must NOT become writable. */
48
+ readOnlyRoots?: readonly string[];
49
+ /** Roots granted write only — `sandbox.allowWrite`. */
50
+ writeOnlyRoots?: readonly string[];
51
+ /** Cross-session leak roots to deny. Defaults to the real memories/sessions/contexts dirs. */
52
+ leakRoots?: readonly string[];
53
+ }
54
+
55
+ /**
56
+ * Directories inside home that hold tool state rather than the operator's data. Denying home without
57
+ * carving these back out is what would break `bun install`, `cargo build` and `npm ci` — the exact
58
+ * class of breakage this fence must not cause.
59
+ *
60
+ * `~/Library/Caches` is macOS-wide tool cache; it is not customer data and several toolchains use it.
61
+ */
62
+ const CACHE_DIRS = [
63
+ // Artifact subdirectories only. Granting the parents put credentials inside the fence —
64
+ // `.cargo/credentials.toml`, `.m2/settings.xml`, `.npm/_authToken` — and `.cargo/config.toml`
65
+ // and `.gradle/init.gradle` are worse than credentials: both can redirect a later build, so a
66
+ // write there is persistence rather than theft. Found by adversarial review, verified writable.
67
+ path.join(".bun", "install", "cache"),
68
+ path.join(".cargo", "registry"),
69
+ path.join(".cargo", "git"),
70
+ path.join(".npm", "_cacache"),
71
+ path.join(".m2", "repository"),
72
+ path.join(".gradle", "caches"),
73
+ path.join(".gradle", "wrapper"),
74
+ path.join(".yarn", "berry", "cache"),
75
+ path.join(".rustup", "toolchains"),
76
+ path.join(".rustup", "downloads"),
77
+ // No credential convention of their own, so granted whole.
78
+ ".pnpm-store",
79
+ ".deno",
80
+ path.join("Library", "Caches"),
81
+ path.join("Library", "pnpm"),
82
+ ];
83
+
84
+ /** Read-only inside home: configuration a tool needs to behave correctly, but must not rewrite. */
85
+ const READ_ONLY_HOME = [".gitconfig", path.join(".config", "git")];
86
+
87
+ /**
88
+ * Canonicalise a root, or return undefined when it is absent.
89
+ *
90
+ * Canonicalisation is load-bearing rather than tidiness: a seatbelt `(subpath "/tmp/x")` rule grants
91
+ * nothing, because the real path is `/private/tmp/x`. A rule that appears to enforce and does not is
92
+ * the worst outcome available, so a root that cannot be resolved is dropped rather than emitted.
93
+ */
94
+ function canonical(root: string): string | undefined {
95
+ try {
96
+ return fs.realpathSync(root);
97
+ } catch {
98
+ return undefined;
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Parents that must never be denied, however the workspace is placed.
104
+ *
105
+ * Denying the workspace's parent closes sibling access, but the parent is not always a sibling
106
+ * container. A workspace directly under `/`, under a system directory, or under the OS temp dir would
107
+ * otherwise deny `/`, `/usr` or `/tmp` — refusing exactly the work this fence is supposed to leave
108
+ * alone. The home tree is excluded too because the home deny already covers it, at the right depth.
109
+ */
110
+ function tooBroadToDeny(candidate: string): boolean {
111
+ if (candidate === path.parse(candidate).root) return true;
112
+ const never = [
113
+ safeReal(os.tmpdir()),
114
+ "/usr",
115
+ "/bin",
116
+ "/sbin",
117
+ "/lib",
118
+ "/opt",
119
+ "/etc",
120
+ "/dev",
121
+ "/proc",
122
+ "/sys",
123
+ "/var",
124
+ "/private",
125
+ "/System",
126
+ "/Library",
127
+ "/Users",
128
+ "/home",
129
+ ];
130
+ return never.includes(candidate);
131
+ }
132
+
133
+ /** realpath without throwing, for building the never-deny list. */
134
+ function safeReal(input: string): string {
135
+ try {
136
+ return fs.realpathSync(input);
137
+ } catch {
138
+ return input;
139
+ }
140
+ }
141
+
142
+ /** The deepest root containing `candidate`, so a nested rule beats the broader one it sits inside. */
143
+ function deepestMatch(roots: readonly string[], candidate: string): string | undefined {
144
+ let best: string | undefined;
145
+ for (const root of roots) {
146
+ if (!pathIsWithin(root, candidate)) continue;
147
+ if (best === undefined || root.length > best.length) best = root;
148
+ }
149
+ return best;
150
+ }
151
+
152
+ /** Build the fence for a session. Throws only when the workspace itself cannot be resolved. */
153
+ export function buildContainmentFence(options: ContainmentOptions): ContainmentFence {
154
+ const workspace = canonical(options.workspace);
155
+ if (workspace === undefined) {
156
+ throw new Error(
157
+ `sandbox containment: cannot canonicalise the session workspace ${options.workspace}. ` +
158
+ "A fence built on an unresolved path would silently grant nothing, so refusing to build one.",
159
+ );
160
+ }
161
+
162
+ const home = canonical(options.home ?? os.homedir());
163
+ const allow = new Set<string>([workspace]);
164
+ const allowReadOnly = new Set<string>();
165
+ const allowWriteOnly = new Set<string>();
166
+ const deny = new Set<string>();
167
+
168
+ // Home is one fence. The workspace's own parent is the other, and it is the one that matters when
169
+ // checkouts live outside home: with /work/customer-a as the workspace, /work/customer-b matched no
170
+ // rule at all and was readable and writable. Denying the parent closes sibling access wherever the
171
+ // checkouts sit, which is the threat this exists for.
172
+ if (home !== undefined && home !== workspace) deny.add(home);
173
+ const parent = path.dirname(workspace);
174
+ if (parent !== workspace && !tooBroadToDeny(parent)) deny.add(parent);
175
+
176
+ for (const root of [options.sessionTmp, ...(options.extraRoots ?? [])]) {
177
+ if (root === undefined) continue;
178
+ const resolved = canonical(root);
179
+ if (resolved !== undefined) allow.add(resolved);
180
+ }
181
+ // Kept distinct. Merging them into one read+write list made a folder shared for reading writable,
182
+ // undoing the read/write split built for #2516 — found by adversarial review.
183
+ for (const root of options.readOnlyRoots ?? []) {
184
+ const resolved = canonical(root);
185
+ if (resolved !== undefined) allowReadOnly.add(resolved);
186
+ }
187
+ for (const root of options.writeOnlyRoots ?? []) {
188
+ const resolved = canonical(root);
189
+ if (resolved !== undefined) allowWriteOnly.add(resolved);
190
+ }
191
+
192
+ if (home !== undefined) {
193
+ // Granted whether or not they exist yet. `~/.bun` has to be writable *before* the first
194
+ // `bun install` creates it, so dropping absent caches would break exactly the first run.
195
+ // Canonicalised when present, so a symlinked cache resolves to its real location.
196
+ for (const cache of CACHE_DIRS) allow.add(canonical(path.join(home, cache)) ?? path.join(home, cache));
197
+ for (const config of READ_ONLY_HOME) {
198
+ allowReadOnly.add(canonical(path.join(home, config)) ?? path.join(home, config));
199
+ }
200
+ }
201
+
202
+ // Cross-session leak roots. These may sit *under* an allowed root — the agent dir is inside home,
203
+ // and a session whose workspace is the agent dir would otherwise re-expose every other session's
204
+ // transcript. `fenceVerdict` resolves that by depth, so nesting is safe rather than accidental.
205
+ const leaks = options.leakRoots ?? [getMemoriesDir(), getSessionsDir(), getXCSHContextsDir()];
206
+ for (const leak of leaks) {
207
+ const resolved = canonical(leak);
208
+ if (resolved !== undefined) deny.add(resolved);
209
+ }
210
+
211
+ return {
212
+ allow: [...allow],
213
+ allowReadOnly: [...allowReadOnly],
214
+ allowWriteOnly: [...allowWriteOnly],
215
+ deny: [...deny],
216
+ };
217
+ }
218
+
219
+ /**
220
+ * Whether the fence permits `access` on `candidate`.
221
+ *
222
+ * Deepest match wins, and a deny beats an allow at equal depth — the same precedence `SandboxPolicy`
223
+ * uses, so the two layers cannot disagree about a path they both see. Unlike that policy, the default
224
+ * here is **allow**: a path matched by no rule is outside the fence and none of its business.
225
+ */
226
+ export function fenceVerdict(fence: ContainmentFence, candidate: string, access: FenceAccess): FenceVerdict {
227
+ const denied = deepestMatch(fence.deny, candidate);
228
+ const readOnly = deepestMatch(fence.allowReadOnly, candidate);
229
+ const writeOnly = deepestMatch(fence.allowWriteOnly, candidate);
230
+ const allowed = deepestMatch(fence.allow, candidate);
231
+
232
+ const depth = (root: string | undefined): number => (root === undefined ? -1 : root.length);
233
+ const deepest = Math.max(depth(denied), depth(readOnly), depth(writeOnly), depth(allowed));
234
+
235
+ // Deny first at equal depth: the leak roots depend on it.
236
+ if (denied !== undefined && depth(denied) === deepest) return "deny";
237
+ if (readOnly !== undefined && depth(readOnly) === deepest) return access === "read" ? "allow" : "deny";
238
+ if (writeOnly !== undefined && depth(writeOnly) === deepest) return access === "write" ? "allow" : "deny";
239
+ if (allowed !== undefined && depth(allowed) === deepest) return "allow";
240
+ return "allow";
241
+ }
242
+
243
+ /** Which mechanism is actually enforcing the boundary for the `bash` tool. */
244
+ export type ContainmentBackend = "seatbelt" | "landlock" | "scanner-only" | "disabled";
245
+
246
+ export interface ContainmentStatus {
247
+ readonly enabled: boolean;
248
+ readonly backend: ContainmentBackend;
249
+ /** True when the kernel enforces it, false when only the command-text scan does. */
250
+ readonly osEnforced: boolean;
251
+ }
252
+
253
+ /**
254
+ * What is actually enforcing the boundary right now.
255
+ *
256
+ * Reported so an operator can tell a confined session from an unconfined one. The distinction is not
257
+ * cosmetic: with a backend, a path is checked where it is opened and the spelling cannot matter;
258
+ * without one, the only check reads the command text and is best-effort by construction. Two sessions
259
+ * that look identical can offer very different guarantees, and `xcsh://about` is where that is stated.
260
+ *
261
+ * Deliberately not surfaced at startup or anywhere in the TUI — the operator asked for no UI change.
262
+ */
263
+ export function containmentStatus(enabled: boolean, platform: string = process.platform): ContainmentStatus {
264
+ if (!enabled) return { enabled: false, backend: "disabled", osEnforced: false };
265
+ // Only the macOS seatbelt backend exists today. Linux Landlock is a follow-up; until it lands,
266
+ // Linux and Windows fall back to the scanner and say so rather than implying enforcement.
267
+ if (platform === "darwin") return { enabled: true, backend: "seatbelt", osEnforced: true };
268
+ return { enabled: true, backend: "scanner-only", osEnforced: false };
269
+ }
@@ -14,8 +14,22 @@
14
14
  * Arbitrary-code tools (`bash`, `python`) cannot be fully contained in-process: this
15
15
  * checks the `cwd` argument precisely and scans the command/code for path tokens
16
16
  * (bare, quoted, `~`, `..`, absolute) that escape the tree. OS system paths are exempt.
17
- * This is best-effort; the opt-in OS-level sandbox (Phase 2) is the airtight enforcement
18
- * for both bash and python.
17
+ *
18
+ * **This scan is no longer the boundary for `bash` on macOS.** Containment now runs below the command
19
+ * text (`sandbox/containment.ts`, #2554): the shell's own `cd` and redirections are checked where they
20
+ * act, and spawned children are confined by a seatbelt profile. A path is therefore decided after
21
+ * expansion, alias resolution and symlink following, which is what closed the escapes this scan kept
22
+ * leaking — #2470, #2516, #2520, #2524, #2540, #2542, #2553, and GHSA-q4hg.
23
+ *
24
+ * What remains this file's job:
25
+ * - every structured file tool (`read`/`write`/`edit`/`grep`/…), which has no subprocess to confine
26
+ * - `python`, which is not covered by the shell fence at all
27
+ * - `bash` on platforms with no backend — Linux Landlock is a follow-up, Windows has no equivalent —
28
+ * where this is again the only layer, and `xcsh://about` says so
29
+ * - a fast pre-check that produces a readable refusal before a command runs
30
+ *
31
+ * So: keep it, and do not extend it. Another spelling caught here buys little now, and the pattern of
32
+ * adding one has a poor record — two adversarial rounds on the #2542 fix alone produced six bypasses.
19
33
  */
20
34
  import * as path from "node:path";
21
35
  import { expandPath, parseFindPattern, parseSearchPath, resolveToCwd, splitTopLevel } from "../tools/path-utils";
package/src/sdk.ts CHANGED
@@ -96,6 +96,8 @@ import {
96
96
  } from "./mcp/discoverable-tool-metadata";
97
97
  import { buildMemoryToolDeveloperInstructions, getMemoryRoot, startMemoryStartupTask } from "./memories";
98
98
  import asyncResultTemplate from "./prompts/tools/async-result.md" with { type: "text" };
99
+ import { containmentStatus } from "./sandbox/containment";
100
+ import { resolveSessionPolicy } from "./sandbox/session-policy";
99
101
  import {
100
102
  collectEnvSecrets,
101
103
  deobfuscateSessionContext,
@@ -1154,6 +1156,9 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1154
1156
  return null;
1155
1157
  }
1156
1158
  },
1159
+ // Read live rather than captured, for the same reason as the model: `--no-sandbox` and
1160
+ // `sandbox.enabled` are per-session, so the answer must reflect this session (#2554).
1161
+ getContainment: () => containmentStatus(resolveSessionPolicy(process.cwd(), settings) !== undefined),
1157
1162
  // Read live rather than captured: `session.model` is a read-through to agent state, so a
1158
1163
  // mid-session Ctrl+P switch shows up on the next xcsh://about read (#2459).
1159
1164
  getActiveModel: () =>
@@ -12,8 +12,10 @@ import {
12
12
  } from "@f5-sales-demo/pi-tui";
13
13
  import type { Terminal as XtermTerminalType } from "@xterm/headless";
14
14
  import xterm from "@xterm/headless";
15
+ import { fenceForNative } from "../exec/bash-executor";
15
16
  import { NON_INTERACTIVE_ENV } from "../exec/non-interactive-env";
16
17
  import type { Theme } from "../modes/theme/theme";
18
+ import type { ContainmentFence } from "../sandbox/containment";
17
19
  import { OutputSink, type OutputSummary } from "../session/streaming-output";
18
20
  import { sanitizeWithImagePassthrough } from "../utils/image-passthrough";
19
21
  import { formatStatusIcon, replaceTabs } from "./render-utils";
@@ -293,6 +295,17 @@ export async function runInteractiveBashPty(
293
295
  artifactPath?: string;
294
296
  artifactId?: string;
295
297
  maskSecrets?: (text: string) => string;
298
+ /**
299
+ * Filesystem boundary for this command. `undefined` means unrestricted, and must be said
300
+ * rather than omitted.
301
+ *
302
+ * Deliberately a required key with an optional value. This path is reached only from the
303
+ * model's `bash` tool, and it was unfenced precisely because the field was easy to leave out
304
+ * of one of two call sites — the boundary ended up opt-out via a parameter the model itself
305
+ * supplies. Requiring the key makes that omission a type error instead of a silent hole; a
306
+ * future caller that genuinely wants no fence has to write `fence: undefined` and mean it.
307
+ */
308
+ fence: ContainmentFence | undefined;
296
309
  },
297
310
  ): Promise<BashInteractiveResult> {
298
311
  const sink = new OutputSink({
@@ -360,6 +373,7 @@ export async function runInteractiveBashPty(
360
373
  signal: options.signal,
361
374
  cols,
362
375
  rows,
376
+ fence: fenceForNative(options.fence),
363
377
  },
364
378
  (err, chunk) => {
365
379
  if (finished || err || !chunk) return;
package/src/tools/bash.ts CHANGED
@@ -16,6 +16,7 @@ import { resolveLocalRoot } from "../internal-urls/local-protocol";
16
16
  import { truncateToVisualLines } from "../modes/components/visual-truncate";
17
17
  import type { Theme } from "../modes/theme/theme";
18
18
  import bashDescription from "../prompts/tools/bash.md" with { type: "text" };
19
+ import { buildContainmentFence } from "../sandbox/containment";
19
20
  import { resolveSessionPolicy } from "../sandbox/session-policy";
20
21
  import { SECRET_ENV_PATTERNS, type SecretObfuscator } from "../secrets";
21
22
  import { DEFAULT_MAX_BYTES, TailBuffer } from "../session/streaming-output";
@@ -392,6 +393,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
392
393
  artifactPath,
393
394
  artifactId,
394
395
  maskSecrets: options.maskSecrets,
396
+ fence: this.#containmentFence(),
395
397
  onChunk: chunk => {
396
398
  tailBuffer.append(chunk);
397
399
  const preview = options.maskSecrets ? options.maskSecrets(tailBuffer.text()) : tailBuffer.text();
@@ -475,6 +477,31 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
475
477
  return Math.max(0, Math.min(this.#autoBackgroundThresholdMs, timeoutMs - timeoutBufferMs));
476
478
  }
477
479
 
480
+ /**
481
+ * The fence for this invocation, or undefined when isolation is off.
482
+ *
483
+ * Built here rather than in the executor because `executeBash` is shared: user-typed `!cmd` and
484
+ * RPC `bash` reach it too, and the same brush-core runs credential helpers and the interactive
485
+ * `xcsh shell`. Only the model's tool call is fenced (#2554).
486
+ *
487
+ * Uses the session's *live* cwd so an in-tree `cd` keeps working, while the fence's own roots
488
+ * decide what is reachable — which is what stops a `cd` out of the tree from taking the boundary
489
+ * with it, without the text layer having to recognise every spelling of `cd`.
490
+ */
491
+ #containmentFence() {
492
+ const policy = resolveSessionPolicy(this.session.cwd, this.session.settings);
493
+ if (!policy) return undefined; // --no-sandbox / sandbox.enabled = false
494
+ const artifactsDir = this.session.getArtifactsDir?.();
495
+ // The three grants stay distinct. Merging allowRead and allowWrite into one read+write list
496
+ // made a folder shared for reading writable, undoing the split built for #2516.
497
+ return buildContainmentFence({
498
+ workspace: this.session.cwd,
499
+ extraRoots: artifactsDir ? [artifactsDir] : [],
500
+ readOnlyRoots: (this.session.settings.get("sandbox.allowRead") as string[] | undefined) ?? [],
501
+ writeOnlyRoots: (this.session.settings.get("sandbox.allowWrite") as string[] | undefined) ?? [],
502
+ });
503
+ }
504
+
478
505
  async execute(
479
506
  _toolCallId: string,
480
507
  {
@@ -644,6 +671,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
644
671
  artifactPath,
645
672
  artifactId,
646
673
  maskSecrets,
674
+ fence: this.#containmentFence(),
647
675
  })
648
676
  : await executeBash(command, {
649
677
  cwd: commandCwd,
@@ -654,6 +682,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
654
682
  artifactPath,
655
683
  artifactId,
656
684
  maskSecrets,
685
+ fence: this.#containmentFence(),
657
686
  onChunk: chunk => {
658
687
  tailBuffer.append(chunk);
659
688
  if (onUpdate) {