@f5-sales-demo/xcsh 19.98.10 → 19.100.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.10",
4
+ "version": "19.100.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.10",
61
- "@f5-sales-demo/pi-agent-core": "19.98.10",
62
- "@f5-sales-demo/pi-ai": "19.98.10",
63
- "@f5-sales-demo/pi-natives": "19.98.10",
64
- "@f5-sales-demo/pi-resource-management": "19.98.10",
65
- "@f5-sales-demo/pi-tui": "19.98.10",
66
- "@f5-sales-demo/pi-utils": "19.98.10",
60
+ "@f5-sales-demo/xcsh-stats": "19.100.0",
61
+ "@f5-sales-demo/pi-agent-core": "19.100.0",
62
+ "@f5-sales-demo/pi-ai": "19.100.0",
63
+ "@f5-sales-demo/pi-natives": "19.100.0",
64
+ "@f5-sales-demo/pi-resource-management": "19.100.0",
65
+ "@f5-sales-demo/pi-tui": "19.100.0",
66
+ "@f5-sales-demo/pi-utils": "19.100.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,28 @@ 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
+ // `landlock` is derived rather than another field on the status, because the template needs a
172
+ // boolean and Handlebars cannot compare strings. It gates the Linux-only costs — unlistable split
173
+ // directories, no setuid, no interactive terminal — which are true of that backend and no other.
174
+ return prompt.render(containmentTemplate, {
175
+ containment: { ...containment, landlock: containment.backend === "landlock" },
176
+ });
177
+ }
178
+
160
179
  export function renderAboutDoc(
161
180
  info: RuntimeBuildInfo,
162
181
  context: ContextStatus | null,
163
182
  model: ActiveModelSnapshot | null,
183
+ containment: ContainmentStatus | null,
164
184
  ): string {
165
185
  return [
166
186
  "# xcsh — identity and build fingerprint",
@@ -183,6 +203,7 @@ export function renderAboutDoc(
183
203
  "",
184
204
  renderPlatformContext(context, Date.now()),
185
205
  renderActiveModel(model),
206
+ renderContainment(containment),
186
207
  "## Source of truth",
187
208
  "",
188
209
  `- 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.10",
21
- "commit": "8523f605f109087c188c50d82aa0d20add2e7544",
22
- "shortCommit": "8523f60",
20
+ "version": "19.100.0",
21
+ "commit": "77f25e303dc3b1fa200117333808b1ed95a6927d",
22
+ "shortCommit": "77f25e3",
23
23
  "branch": "main",
24
- "tag": "v19.98.10",
25
- "commitDate": "2026-07-28T14:58:14Z",
26
- "buildDate": "2026-07-28T15:23:24.282Z",
24
+ "tag": "v19.100.0",
25
+ "commitDate": "2026-07-28T22:16:47Z",
26
+ "buildDate": "2026-07-28T22:49:07.531Z",
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/8523f605f109087c188c50d82aa0d20add2e7544",
32
- "releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v19.98.10"
31
+ "commitUrl": "https://github.com/f5-sales-demo/xcsh/commit/77f25e303dc3b1fa200117333808b1ed95a6927d",
32
+ "releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v19.100.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,50 @@
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
+
20
+ {{#if containment.landlock}}
21
+ Three things behave differently under this backend, and none of them is a bug to work around:
22
+ - `ls /` and `ls` of the directory holding the session tree fail. A kernel rule covers a whole subtree,
23
+ so a directory with both reachable and unreachable children cannot be listed at all. Listing a
24
+ specific directory you can reach works normally.
25
+ - `sudo` and other setuid programs do not work, because confining a process requires giving up the
26
+ ability to gain privileges.
27
+ - Interactive terminal programs (`top`, `less`, an interactive `ssh`) run without a real terminal here,
28
+ so prefer their non-interactive forms — `ps`, `cat`, `ssh -T`, or a piped command.
29
+ {{#if containment.truncationUngoverned}}
30
+ - This kernel is too old to govern truncation, so a file outside the boundary can still be emptied even
31
+ though it cannot be read or written. Never truncate a path outside the session directory.
32
+ {{/if}}
33
+ {{/if}}
34
+ {{else}}
35
+ On this platform there is **no OS-level backend**, so the boundary is enforced only by scanning the
36
+ command text before it runs. That check is best-effort by construction: it reads what you wrote
37
+ rather than what the shell will do, so a path assembled at runtime or reached through an unusual
38
+ spelling may not be caught.
39
+
40
+ Treat the boundary as a statement of intent rather than a guarantee here, and do not go looking for
41
+ paths outside the session directory on the assumption that something would stop you.
42
+ {{/if}}
43
+ {{else}}
44
+ Filesystem isolation is **off** for this session — started with `--no-sandbox`, or
45
+ `sandbox.enabled` is false. Every path on this machine is reachable.
46
+
47
+ Nothing is confining you, so the judgement is yours: stay within the directory the operator is
48
+ working in unless they asked for something else, and do not read private files elsewhere on the
49
+ machine because you happen to be able to.
50
+ {{/if}}
@@ -0,0 +1,323 @@
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 * as natives from "@f5-sales-demo/pi-natives";
23
+ import { getMemoriesDir, getSessionsDir, getXCSHContextsDir, pathIsWithin } from "@f5-sales-demo/pi-utils";
24
+
25
+ export type FenceAccess = "read" | "write";
26
+ export type FenceVerdict = "allow" | "deny";
27
+
28
+ export interface ContainmentFence {
29
+ /** Canonical roots the shell may read and write. */
30
+ readonly allow: readonly string[];
31
+ /** Canonical roots the shell may read but not write. */
32
+ readonly allowReadOnly: readonly string[];
33
+ /** Canonical roots the shell may write but not read. */
34
+ readonly allowWriteOnly: readonly string[];
35
+ /** Canonical roots denied in both directions, winning over any allow they sit inside. */
36
+ readonly deny: readonly string[];
37
+ }
38
+
39
+ export interface ContainmentOptions {
40
+ /** The session's working directory. Must exist — it is the one root that cannot be dropped. */
41
+ workspace: string;
42
+ /** Overridable for tests; defaults to the real home directory. */
43
+ home?: string;
44
+ /** A session-specific temp dir, if the session has one. */
45
+ sessionTmp?: string;
46
+ /** Roots granted read+write, as `--allow-path` does. */
47
+ extraRoots?: readonly string[];
48
+ /** Roots granted read only — `sandbox.allowRead`. Must NOT become writable. */
49
+ readOnlyRoots?: readonly string[];
50
+ /** Roots granted write only — `sandbox.allowWrite`. */
51
+ writeOnlyRoots?: readonly string[];
52
+ /** Cross-session leak roots to deny. Defaults to the real memories/sessions/contexts dirs. */
53
+ leakRoots?: readonly string[];
54
+ }
55
+
56
+ /**
57
+ * Directories inside home that hold tool state rather than the operator's data. Denying home without
58
+ * carving these back out is what would break `bun install`, `cargo build` and `npm ci` — the exact
59
+ * class of breakage this fence must not cause.
60
+ *
61
+ * `~/Library/Caches` is macOS-wide tool cache; it is not customer data and several toolchains use it.
62
+ */
63
+ const CACHE_DIRS = [
64
+ // Artifact subdirectories only. Granting the parents put credentials inside the fence —
65
+ // `.cargo/credentials.toml`, `.m2/settings.xml`, `.npm/_authToken` — and `.cargo/config.toml`
66
+ // and `.gradle/init.gradle` are worse than credentials: both can redirect a later build, so a
67
+ // write there is persistence rather than theft. Found by adversarial review, verified writable.
68
+ path.join(".bun", "install", "cache"),
69
+ path.join(".cargo", "registry"),
70
+ path.join(".cargo", "git"),
71
+ path.join(".npm", "_cacache"),
72
+ path.join(".m2", "repository"),
73
+ path.join(".gradle", "caches"),
74
+ path.join(".gradle", "wrapper"),
75
+ path.join(".yarn", "berry", "cache"),
76
+ path.join(".rustup", "toolchains"),
77
+ path.join(".rustup", "downloads"),
78
+ // No credential convention of their own, so granted whole.
79
+ ".pnpm-store",
80
+ ".deno",
81
+ path.join("Library", "Caches"),
82
+ path.join("Library", "pnpm"),
83
+ ];
84
+
85
+ /** Read-only inside home: configuration a tool needs to behave correctly, but must not rewrite. */
86
+ const READ_ONLY_HOME = [".gitconfig", path.join(".config", "git")];
87
+
88
+ /**
89
+ * Canonicalise a root, or return undefined when it is absent.
90
+ *
91
+ * Canonicalisation is load-bearing rather than tidiness: a seatbelt `(subpath "/tmp/x")` rule grants
92
+ * nothing, because the real path is `/private/tmp/x`. A rule that appears to enforce and does not is
93
+ * the worst outcome available, so a root that cannot be resolved is dropped rather than emitted.
94
+ */
95
+ function canonical(root: string): string | undefined {
96
+ try {
97
+ return fs.realpathSync(root);
98
+ } catch {
99
+ return undefined;
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Parents that must never be denied, however the workspace is placed.
105
+ *
106
+ * Denying the workspace's parent closes sibling access, but the parent is not always a sibling
107
+ * container. A workspace directly under `/`, under a system directory, or under the OS temp dir would
108
+ * otherwise deny `/`, `/usr` or `/tmp` — refusing exactly the work this fence is supposed to leave
109
+ * alone. The home tree is excluded too because the home deny already covers it, at the right depth.
110
+ */
111
+ function tooBroadToDeny(candidate: string): boolean {
112
+ if (candidate === path.parse(candidate).root) return true;
113
+ const never = [
114
+ safeReal(os.tmpdir()),
115
+ "/usr",
116
+ "/bin",
117
+ "/sbin",
118
+ "/lib",
119
+ "/opt",
120
+ "/etc",
121
+ "/dev",
122
+ "/proc",
123
+ "/sys",
124
+ "/var",
125
+ "/private",
126
+ "/System",
127
+ "/Library",
128
+ "/Users",
129
+ "/home",
130
+ ];
131
+ return never.includes(candidate);
132
+ }
133
+
134
+ /** realpath without throwing, for building the never-deny list. */
135
+ function safeReal(input: string): string {
136
+ try {
137
+ return fs.realpathSync(input);
138
+ } catch {
139
+ return input;
140
+ }
141
+ }
142
+
143
+ /** The deepest root containing `candidate`, so a nested rule beats the broader one it sits inside. */
144
+ function deepestMatch(roots: readonly string[], candidate: string): string | undefined {
145
+ let best: string | undefined;
146
+ for (const root of roots) {
147
+ if (!pathIsWithin(root, candidate)) continue;
148
+ if (best === undefined || root.length > best.length) best = root;
149
+ }
150
+ return best;
151
+ }
152
+
153
+ /** Build the fence for a session. Throws only when the workspace itself cannot be resolved. */
154
+ export function buildContainmentFence(options: ContainmentOptions): ContainmentFence {
155
+ const workspace = canonical(options.workspace);
156
+ if (workspace === undefined) {
157
+ throw new Error(
158
+ `sandbox containment: cannot canonicalise the session workspace ${options.workspace}. ` +
159
+ "A fence built on an unresolved path would silently grant nothing, so refusing to build one.",
160
+ );
161
+ }
162
+
163
+ const home = canonical(options.home ?? os.homedir());
164
+ const allow = new Set<string>([workspace]);
165
+ const allowReadOnly = new Set<string>();
166
+ const allowWriteOnly = new Set<string>();
167
+ const deny = new Set<string>();
168
+
169
+ // Home is one fence. The workspace's own parent is the other, and it is the one that matters when
170
+ // checkouts live outside home: with /work/customer-a as the workspace, /work/customer-b matched no
171
+ // rule at all and was readable and writable. Denying the parent closes sibling access wherever the
172
+ // checkouts sit, which is the threat this exists for.
173
+ if (home !== undefined && home !== workspace) deny.add(home);
174
+ const parent = path.dirname(workspace);
175
+ if (parent !== workspace && !tooBroadToDeny(parent)) deny.add(parent);
176
+
177
+ for (const root of [options.sessionTmp, ...(options.extraRoots ?? [])]) {
178
+ if (root === undefined) continue;
179
+ const resolved = canonical(root);
180
+ if (resolved !== undefined) allow.add(resolved);
181
+ }
182
+ // Kept distinct. Merging them into one read+write list made a folder shared for reading writable,
183
+ // undoing the read/write split built for #2516 — found by adversarial review.
184
+ for (const root of options.readOnlyRoots ?? []) {
185
+ const resolved = canonical(root);
186
+ if (resolved !== undefined) allowReadOnly.add(resolved);
187
+ }
188
+ for (const root of options.writeOnlyRoots ?? []) {
189
+ const resolved = canonical(root);
190
+ if (resolved !== undefined) allowWriteOnly.add(resolved);
191
+ }
192
+
193
+ if (home !== undefined) {
194
+ // Granted whether or not they exist yet. `~/.bun` has to be writable *before* the first
195
+ // `bun install` creates it, so dropping absent caches would break exactly the first run.
196
+ // Canonicalised when present, so a symlinked cache resolves to its real location.
197
+ for (const cache of CACHE_DIRS) allow.add(canonical(path.join(home, cache)) ?? path.join(home, cache));
198
+ for (const config of READ_ONLY_HOME) {
199
+ allowReadOnly.add(canonical(path.join(home, config)) ?? path.join(home, config));
200
+ }
201
+ }
202
+
203
+ // Cross-session leak roots. These may sit *under* an allowed root — the agent dir is inside home,
204
+ // and a session whose workspace is the agent dir would otherwise re-expose every other session's
205
+ // transcript. `fenceVerdict` resolves that by depth, so nesting is safe rather than accidental.
206
+ const leaks = options.leakRoots ?? [getMemoriesDir(), getSessionsDir(), getXCSHContextsDir()];
207
+ for (const leak of leaks) {
208
+ const resolved = canonical(leak);
209
+ if (resolved !== undefined) deny.add(resolved);
210
+ }
211
+
212
+ return {
213
+ allow: [...allow],
214
+ allowReadOnly: [...allowReadOnly],
215
+ allowWriteOnly: [...allowWriteOnly],
216
+ deny: [...deny],
217
+ };
218
+ }
219
+
220
+ /**
221
+ * Whether the fence permits `access` on `candidate`.
222
+ *
223
+ * Deepest match wins, and a deny beats an allow at equal depth — the same precedence `SandboxPolicy`
224
+ * uses, so the two layers cannot disagree about a path they both see. Unlike that policy, the default
225
+ * here is **allow**: a path matched by no rule is outside the fence and none of its business.
226
+ */
227
+ export function fenceVerdict(fence: ContainmentFence, candidate: string, access: FenceAccess): FenceVerdict {
228
+ const denied = deepestMatch(fence.deny, candidate);
229
+ const readOnly = deepestMatch(fence.allowReadOnly, candidate);
230
+ const writeOnly = deepestMatch(fence.allowWriteOnly, candidate);
231
+ const allowed = deepestMatch(fence.allow, candidate);
232
+
233
+ const depth = (root: string | undefined): number => (root === undefined ? -1 : root.length);
234
+ const deepest = Math.max(depth(denied), depth(readOnly), depth(writeOnly), depth(allowed));
235
+
236
+ // Deny first at equal depth: the leak roots depend on it.
237
+ if (denied !== undefined && depth(denied) === deepest) return "deny";
238
+ if (readOnly !== undefined && depth(readOnly) === deepest) return access === "read" ? "allow" : "deny";
239
+ if (writeOnly !== undefined && depth(writeOnly) === deepest) return access === "write" ? "allow" : "deny";
240
+ if (allowed !== undefined && depth(allowed) === deepest) return "allow";
241
+ return "allow";
242
+ }
243
+
244
+ /** Which mechanism is actually enforcing the boundary for the `bash` tool. */
245
+ export type ContainmentBackend = "seatbelt" | "landlock" | "scanner-only" | "disabled";
246
+
247
+ export interface ContainmentStatus {
248
+ readonly enabled: boolean;
249
+ readonly backend: ContainmentBackend;
250
+ /** True when the kernel enforces it, false when only the command-text scan does. */
251
+ readonly osEnforced: boolean;
252
+ /**
253
+ * Set when the backend enforces reads and writes but cannot govern truncation.
254
+ *
255
+ * True only on Landlock ABI 2 — kernels 5.19 to 6.1, which includes Debian 12 — where
256
+ * `LANDLOCK_ACCESS_FS_TRUNCATE` does not exist. A denied file cannot be read or written there, but
257
+ * `truncate(2)` can still zero it. That is destruction rather than disclosure, and it is not
258
+ * reachable through `>` (which needs write access at open), so the backend is still worth having.
259
+ * Reported rather than folded into `osEnforced`, because "enforced" and "enforced except this" are
260
+ * different claims and an operator is entitled to know which one they have.
261
+ */
262
+ readonly truncationUngoverned?: boolean;
263
+ }
264
+
265
+ /**
266
+ * What is actually enforcing the boundary right now.
267
+ *
268
+ * Reported so an operator can tell a confined session from an unconfined one. The distinction is not
269
+ * cosmetic: with a backend, a path is checked where it is opened and the spelling cannot matter;
270
+ * without one, the only check reads the command text and is best-effort by construction. Two sessions
271
+ * that look identical can offer very different guarantees, and `xcsh://about` is where that is stated.
272
+ *
273
+ * Deliberately not surfaced at startup or anywhere in the TUI — the operator asked for no UI change.
274
+ */
275
+ export function containmentStatus(
276
+ enabled: boolean,
277
+ platform: string = process.platform,
278
+ probe: () => { backend: string; truncateHandled?: boolean } | undefined = probeNativeBackend,
279
+ ): ContainmentStatus {
280
+ if (!enabled) return { enabled: false, backend: "disabled", osEnforced: false };
281
+ // macOS always has seatbelt, so there is nothing to ask.
282
+ if (platform === "darwin") return { enabled: true, backend: "seatbelt", osEnforced: true };
283
+ // Everywhere else the answer cannot be inferred from the platform name. Landlock can be compiled
284
+ // out of the kernel, left out of its boot-time LSM list, or too old to allow cross-directory
285
+ // rename — and none of that is visible from `process.platform`. Asking the native layer is the
286
+ // difference between reporting what is enforcing and reporting what we hope is enforcing.
287
+ // Guarded here rather than inside the probe, so *any* probe is safe to pass — including an injected
288
+ // one. A native module from an older release has no such export, and letting a `TypeError` escape
289
+ // would turn a missing status line into a broken `xcsh://about`. Falling back to `scanner-only`
290
+ // understates the boundary, which is the safe direction to be wrong in.
291
+ let probed: { backend: string; truncateHandled?: boolean } | undefined;
292
+ try {
293
+ probed = probe();
294
+ } catch {
295
+ probed = undefined;
296
+ }
297
+ if (probed?.backend === "landlock") {
298
+ return {
299
+ enabled: true,
300
+ backend: "landlock",
301
+ osEnforced: true,
302
+ // Absent on the ABI that governs truncation; present, and stated, on the one that does not.
303
+ ...(probed.truncateHandled === false ? { truncationUngoverned: true } : {}),
304
+ };
305
+ }
306
+ return { enabled: true, backend: "scanner-only", osEnforced: false };
307
+ }
308
+
309
+ /**
310
+ * Ask the native layer which backend is active, if it can answer.
311
+ *
312
+ * **Reached through a namespace import on purpose.** A native module built before this export existed
313
+ * does not have the symbol, and a static `import { containmentBackend }` against it fails at *link*
314
+ * time with `SyntaxError: Export named 'containmentBackend' not found` — taking the whole module graph
315
+ * down before any `try`/`catch` can run. Found exactly that way: the tarball install smoke test died on
316
+ * it while the runtime guard sat there looking sufficient. A namespace member that is absent is merely
317
+ * `undefined`, which is a case code can actually handle.
318
+ */
319
+ function probeNativeBackend(): { backend: string; truncateHandled?: boolean } | undefined {
320
+ const probe = (natives as { containmentBackend?: () => { backend: string; truncateHandled?: boolean } })
321
+ .containmentBackend;
322
+ return typeof probe === "function" ? probe() : undefined;
323
+ }
@@ -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, containmentStatus } 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";
@@ -37,6 +38,14 @@ import { clampTimeout } from "./tool-timeouts";
37
38
  // Module-level obfuscator reference for the renderer (set by BashTool constructor).
38
39
  let _sessionObfuscator: SecretObfuscator | undefined;
39
40
 
41
+ /**
42
+ * Where each session's containment boundary is anchored, captured once and never moved by the model.
43
+ *
44
+ * Keyed on the session object because the tool is built by a factory, so an instance field would reset
45
+ * whenever a new `BashTool` is made. Weak so a finished session is collectable.
46
+ */
47
+ const FENCE_ANCHORS = new WeakMap<object, { root: string; project: string }>();
48
+
40
49
  export const BASH_DEFAULT_PREVIEW_LINES = 10;
41
50
 
42
51
  const BASH_ENV_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
@@ -392,6 +401,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
392
401
  artifactPath,
393
402
  artifactId,
394
403
  maskSecrets: options.maskSecrets,
404
+ fence: this.#containmentFence(),
395
405
  onChunk: chunk => {
396
406
  tailBuffer.append(chunk);
397
407
  const preview = options.maskSecrets ? options.maskSecrets(tailBuffer.text()) : tailBuffer.text();
@@ -475,6 +485,67 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
475
485
  return Math.max(0, Math.min(this.#autoBackgroundThresholdMs, timeoutMs - timeoutBufferMs));
476
486
  }
477
487
 
488
+ /**
489
+ * Where this session's boundary is anchored.
490
+ *
491
+ * **Not the live cwd.** The fence used to be rebuilt from `session.cwd`, which line ~717 replaces
492
+ * with whatever PWD the command ended in — so the model could move the boundary with a `cd`. Measured
493
+ * on a workspace outside the home tree, which is the layout the sibling-checkout deny exists for:
494
+ * with `/work/custA` as the workspace, `/work/custB/secret.env` was denied; `cd /usr` is permitted
495
+ * (correctly — `/usr` is not sensitive), and the *next* fence was rooted at `/usr`, where the parent
496
+ * deny of `/work` cannot be expressed because `dirname("/usr")` is `/` and denying the root is refused
497
+ * as too broad. `/work/custB` then became readable **and** writable. Two tool calls, no exotic
498
+ * spelling, and the tenant boundary was gone.
499
+ *
500
+ * So the anchor is captured once per session and never follows the shell. It is keyed on the session
501
+ * object rather than held on this instance because the tool is built by a factory
502
+ * (`bash: s => new BashTool(s)`) and must not depend on how long an instance happens to live.
503
+ *
504
+ * An *operator* moving the project — startup, or the slash command that calls `setProjectDir` — does
505
+ * re-anchor it. That asymmetry is the point: the operator may move the boundary, the model may not.
506
+ *
507
+ * Note the read/write tools are unaffected by this class of bug: `SandboxPolicy` is deny-by-default,
508
+ * so relocating its root grants nothing new. Only an allow-by-default fence loses a deny when it
509
+ * moves, which is why this needed fixing here and not there.
510
+ */
511
+ #containmentRoot(): string {
512
+ const project = getProjectDir();
513
+ const anchor = FENCE_ANCHORS.get(this.session);
514
+ if (anchor === undefined) {
515
+ FENCE_ANCHORS.set(this.session, { root: this.session.cwd, project });
516
+ return this.session.cwd;
517
+ }
518
+ if (anchor.project !== project) {
519
+ // The operator moved the project. Re-anchor there rather than at `session.cwd`, which a
520
+ // model `cd` may have moved in the meantime.
521
+ FENCE_ANCHORS.set(this.session, { root: project, project });
522
+ return project;
523
+ }
524
+ return anchor.root;
525
+ }
526
+
527
+ /**
528
+ * The fence for this invocation, or undefined when isolation is off.
529
+ *
530
+ * Built here rather than in the executor because `executeBash` is shared: user-typed `!cmd` and
531
+ * RPC `bash` reach it too, and the same brush-core runs credential helpers and the interactive
532
+ * `xcsh shell`. Only the model's tool call is fenced (#2554).
533
+ */
534
+ #containmentFence() {
535
+ const root = this.#containmentRoot();
536
+ const policy = resolveSessionPolicy(root, this.session.settings);
537
+ if (!policy) return undefined; // --no-sandbox / sandbox.enabled = false
538
+ const artifactsDir = this.session.getArtifactsDir?.();
539
+ // The three grants stay distinct. Merging allowRead and allowWrite into one read+write list
540
+ // made a folder shared for reading writable, undoing the split built for #2516.
541
+ return buildContainmentFence({
542
+ workspace: root,
543
+ extraRoots: artifactsDir ? [artifactsDir] : [],
544
+ readOnlyRoots: (this.session.settings.get("sandbox.allowRead") as string[] | undefined) ?? [],
545
+ writeOnlyRoots: (this.session.settings.get("sandbox.allowWrite") as string[] | undefined) ?? [],
546
+ });
547
+ }
548
+
478
549
  async execute(
479
550
  _toolCallId: string,
480
551
  {
@@ -633,7 +704,23 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
633
704
  // Allocate artifact for truncated output storage
634
705
  const { path: artifactPath, id: artifactId } = (await this.session.allocateOutputArtifact?.("bash")) ?? {};
635
706
 
636
- const usePty = pty && $env.PI_NO_PTY !== "1" && ctx?.hasUI === true && ctx.ui !== undefined;
707
+ // The PTY path can only be confined where the OS backend reaches it, and on Linux it does not:
708
+ // Landlock is applied in a `pre_exec` hook, and `portable-pty`'s `CommandBuilder` exposes none
709
+ // (its `as_command` is private, and its `Clone + Debug + PartialEq` derives preclude a closure
710
+ // field ever being added). Since `pty` is a parameter the *model* supplies, leaving it reachable
711
+ // would make containment opt-out by the very caller it constrains — the hole that was just closed
712
+ // on macOS. So a fenced session falls back to the non-PTY path, which is confined.
713
+ //
714
+ // The cost is real and Linux-only: `top`, `less` and `ssh` run without a terminal in a fenced
715
+ // session. Confining the PTY child properly is the follow-up; reporting a boundary that a flag
716
+ // steps around would be worse than losing interactivity.
717
+ // Only worth giving up when there is an OS backend for the non-PTY path to use and none for this
718
+ // one. Where no backend exists — Linux without Landlock, Windows — both paths are scanner-only,
719
+ // so disabling PTY would remove interactive terminals and improve containment by nothing.
720
+ const fence = this.#containmentFence();
721
+ const osBackend = containmentStatus(fence !== undefined);
722
+ const ptyConfinable = !osBackend.osEnforced || osBackend.backend === "seatbelt";
723
+ const usePty = pty && ptyConfinable && $env.PI_NO_PTY !== "1" && ctx?.hasUI === true && ctx.ui !== undefined;
637
724
  const result: BashResult | BashInteractiveResult = usePty
638
725
  ? await runInteractiveBashPty(ctx.ui!, {
639
726
  command,
@@ -644,6 +731,9 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
644
731
  artifactPath,
645
732
  artifactId,
646
733
  maskSecrets,
734
+ // The same fence that decided `ptyConfinable`, so the gate and the enforcement can
735
+ // never be looking at different answers.
736
+ fence,
647
737
  })
648
738
  : await executeBash(command, {
649
739
  cwd: commandCwd,
@@ -654,6 +744,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
654
744
  artifactPath,
655
745
  artifactId,
656
746
  maskSecrets,
747
+ fence,
657
748
  onChunk: chunk => {
658
749
  tailBuffer.append(chunk);
659
750
  if (onUpdate) {