@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 +8 -8
- package/src/exec/bash-executor.ts +29 -0
- package/src/internal-urls/build-info-runtime.ts +21 -0
- package/src/internal-urls/build-info.generated.ts +8 -8
- package/src/internal-urls/plugin-resolve.ts +6 -6
- package/src/internal-urls/xcsh-protocol.ts +11 -1
- package/src/prompts/internal-urls/containment.md +50 -0
- package/src/sandbox/containment.ts +323 -0
- package/src/sandbox/enforce.ts +16 -2
- package/src/sdk.ts +5 -0
- package/src/tools/bash-interactive.ts +14 -0
- package/src/tools/bash.ts +92 -1
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@f5-sales-demo/xcsh",
|
|
4
|
-
"version": "19.
|
|
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.
|
|
61
|
-
"@f5-sales-demo/pi-agent-core": "19.
|
|
62
|
-
"@f5-sales-demo/pi-ai": "19.
|
|
63
|
-
"@f5-sales-demo/pi-natives": "19.
|
|
64
|
-
"@f5-sales-demo/pi-resource-management": "19.
|
|
65
|
-
"@f5-sales-demo/pi-tui": "19.
|
|
66
|
-
"@f5-sales-demo/pi-utils": "19.
|
|
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.
|
|
21
|
-
"commit": "
|
|
22
|
-
"shortCommit": "
|
|
20
|
+
"version": "19.100.0",
|
|
21
|
+
"commit": "77f25e303dc3b1fa200117333808b1ed95a6927d",
|
|
22
|
+
"shortCommit": "77f25e3",
|
|
23
23
|
"branch": "main",
|
|
24
|
-
"tag": "v19.
|
|
25
|
-
"commitDate": "2026-07-
|
|
26
|
-
"buildDate": "2026-07-
|
|
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/
|
|
32
|
-
"releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v19.
|
|
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
|
|
59
|
-
* either —
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
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
|
|
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
|
+
}
|
package/src/sandbox/enforce.ts
CHANGED
|
@@ -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
|
-
*
|
|
18
|
-
* for
|
|
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
|
-
|
|
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) {
|