@f5-sales-demo/xcsh 20.0.5 → 20.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -0
- package/package.json +8 -8
- package/src/cli/sandbox-check.ts +542 -0
- package/src/cli.ts +1 -0
- package/src/commands/sandbox.ts +26 -0
- package/src/internal-urls/build-info.generated.ts +8 -8
- package/src/prompts/internal-urls/containment.md +5 -4
- package/src/sandbox/containment.ts +15 -10
- package/src/sandbox/session-fence.ts +11 -0
- package/src/tools/bash.ts +19 -5
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [20.1.1] - 2026-08-01
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Made `xcsh sandbox check` exercise the live bash profile, report actionable failure details, and run under macOS and Linux confinement in CI ([#2800](https://github.com/f5-sales-demo/xcsh/issues/2800))
|
|
10
|
+
|
|
11
|
+
## [20.1.0] - 2026-08-01
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- Added `xcsh sandbox check` to verify the installed sandbox with synthetic fixtures and automatic cleanup ([#2790](https://github.com/f5-sales-demo/xcsh/issues/2790))
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
|
|
19
|
+
- Denied local account containers and other operators' homes while preserving full access to the current operator's home ([#2788](https://github.com/f5-sales-demo/xcsh/issues/2788))
|
|
20
|
+
|
|
5
21
|
## [20.0.4] - 2026-08-01
|
|
6
22
|
|
|
7
23
|
### Fixed
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@f5-sales-demo/xcsh",
|
|
4
|
-
"version": "20.
|
|
4
|
+
"version": "20.1.1",
|
|
5
5
|
"description": "Coding agent CLI with read, bash, edit, write tools and session management",
|
|
6
6
|
"homepage": "https://github.com/f5-sales-demo/xcsh",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -57,13 +57,13 @@
|
|
|
57
57
|
"dependencies": {
|
|
58
58
|
"@agentclientprotocol/sdk": "1.3.0",
|
|
59
59
|
"@mozilla/readability": "^0.6",
|
|
60
|
-
"@f5-sales-demo/xcsh-stats": "20.
|
|
61
|
-
"@f5-sales-demo/pi-agent-core": "20.
|
|
62
|
-
"@f5-sales-demo/pi-ai": "20.
|
|
63
|
-
"@f5-sales-demo/pi-natives": "20.
|
|
64
|
-
"@f5-sales-demo/pi-resource-management": "20.
|
|
65
|
-
"@f5-sales-demo/pi-tui": "20.
|
|
66
|
-
"@f5-sales-demo/pi-utils": "20.
|
|
60
|
+
"@f5-sales-demo/xcsh-stats": "20.1.1",
|
|
61
|
+
"@f5-sales-demo/pi-agent-core": "20.1.1",
|
|
62
|
+
"@f5-sales-demo/pi-ai": "20.1.1",
|
|
63
|
+
"@f5-sales-demo/pi-natives": "20.1.1",
|
|
64
|
+
"@f5-sales-demo/pi-resource-management": "20.1.1",
|
|
65
|
+
"@f5-sales-demo/pi-tui": "20.1.1",
|
|
66
|
+
"@f5-sales-demo/pi-utils": "20.1.1",
|
|
67
67
|
"@sinclair/typebox": "^0.34",
|
|
68
68
|
"@xterm/headless": "^6.0",
|
|
69
69
|
"ajv": "^8.20",
|
|
@@ -0,0 +1,542 @@
|
|
|
1
|
+
/** Installed-binary conformance check for the live filesystem sandbox. */
|
|
2
|
+
import * as fs from "node:fs/promises";
|
|
3
|
+
import * as os from "node:os";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import { executeShell } from "@f5-sales-demo/pi-natives";
|
|
6
|
+
import { isEnoent } from "@f5-sales-demo/pi-utils";
|
|
7
|
+
import { Settings } from "../config/settings";
|
|
8
|
+
import { fenceForNative } from "../exec/bash-executor";
|
|
9
|
+
import { buildContainmentFence, type ContainmentFence, containmentStatus } from "../sandbox/containment";
|
|
10
|
+
import { evaluateToolCall } from "../sandbox/enforce";
|
|
11
|
+
import { SANDBOX_OPERATOR_HOME_ENV, SANDBOX_SESSION_ROOT_ENV } from "../sandbox/session-fence";
|
|
12
|
+
import { BashTool, type ToolSession } from "../tools";
|
|
13
|
+
|
|
14
|
+
export type SandboxCheckResultStatus = "PASS" | "FAIL" | "SKIP";
|
|
15
|
+
|
|
16
|
+
export interface SandboxCheckResult {
|
|
17
|
+
name: string;
|
|
18
|
+
status: SandboxCheckResultStatus;
|
|
19
|
+
/** Present on failures and skips; paths are generalized before they leave the process. */
|
|
20
|
+
detail?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface SandboxCheckReport {
|
|
24
|
+
backend: string;
|
|
25
|
+
osEnforced: boolean;
|
|
26
|
+
checks: SandboxCheckResult[];
|
|
27
|
+
summary: {
|
|
28
|
+
passed: number;
|
|
29
|
+
failed: number;
|
|
30
|
+
skipped: number;
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface SandboxCheckOptions {
|
|
35
|
+
json?: boolean;
|
|
36
|
+
verbose?: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface ProbeOutcome {
|
|
40
|
+
passed: boolean;
|
|
41
|
+
detail?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface ShellProbeResult {
|
|
45
|
+
exitCode: number;
|
|
46
|
+
output: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
type Redaction = readonly [path: string, label: string];
|
|
50
|
+
|
|
51
|
+
function quote(value: string): string {
|
|
52
|
+
return JSON.stringify(value);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function errorCode(error: unknown): string | undefined {
|
|
56
|
+
if (typeof error !== "object" || error === null || !("code" in error)) return undefined;
|
|
57
|
+
const code = (error as { code?: unknown }).code;
|
|
58
|
+
return typeof code === "string" ? code : undefined;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function sanitizeDetail(value: string, redactions: readonly Redaction[]): string {
|
|
62
|
+
let sanitized = value;
|
|
63
|
+
for (const [actual, label] of [...redactions].sort(([a], [b]) => b.length - a.length)) {
|
|
64
|
+
if (actual.length > 0) sanitized = sanitized.replaceAll(actual, label);
|
|
65
|
+
}
|
|
66
|
+
sanitized = sanitized.replace(/\s+/gu, " ").trim();
|
|
67
|
+
return sanitized.length > 500 ? `${sanitized.slice(0, 497)}...` : sanitized;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function errnoFromOutput(output: string): string {
|
|
71
|
+
if (/operation not permitted/iu.test(output)) return "EPERM";
|
|
72
|
+
if (/permission denied/iu.test(output)) return "EACCES";
|
|
73
|
+
if (/no such file or directory/iu.test(output)) return "ENOENT";
|
|
74
|
+
if (/not a directory/iu.test(output)) return "ENOTDIR";
|
|
75
|
+
return "unknown";
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function exceptionOutcome(
|
|
79
|
+
assertion: string,
|
|
80
|
+
displayPath: string,
|
|
81
|
+
error: unknown,
|
|
82
|
+
redactions: readonly Redaction[],
|
|
83
|
+
): ProbeOutcome {
|
|
84
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
85
|
+
return {
|
|
86
|
+
passed: false,
|
|
87
|
+
detail: sanitizeDetail(
|
|
88
|
+
`${assertion}; path=${displayPath}; errno=${errorCode(error) ?? "unknown"}; error=${message}`,
|
|
89
|
+
redactions,
|
|
90
|
+
),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function shellOutcome(
|
|
95
|
+
result: ShellProbeResult,
|
|
96
|
+
expectSuccess: boolean,
|
|
97
|
+
assertion: string,
|
|
98
|
+
displayPath: string,
|
|
99
|
+
redactions: readonly Redaction[],
|
|
100
|
+
): ProbeOutcome {
|
|
101
|
+
const passed = expectSuccess ? result.exitCode === 0 : result.exitCode !== 0;
|
|
102
|
+
if (passed) return { passed: true };
|
|
103
|
+
const output = sanitizeDetail(result.output, redactions);
|
|
104
|
+
const errno = result.exitCode === 0 ? "none" : errnoFromOutput(output);
|
|
105
|
+
return {
|
|
106
|
+
passed: false,
|
|
107
|
+
detail: sanitizeDetail(
|
|
108
|
+
`${assertion}; path=${displayPath}; exit=${result.exitCode}; errno=${errno}${output ? `; output=${output}` : ""}`,
|
|
109
|
+
redactions,
|
|
110
|
+
),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function shellProbe(
|
|
115
|
+
command: string,
|
|
116
|
+
cwd: string,
|
|
117
|
+
fence: ContainmentFence | undefined,
|
|
118
|
+
signal: AbortSignal,
|
|
119
|
+
): Promise<ShellProbeResult> {
|
|
120
|
+
let output = "";
|
|
121
|
+
const result = await executeShell(
|
|
122
|
+
{
|
|
123
|
+
command,
|
|
124
|
+
cwd,
|
|
125
|
+
fence: fence === undefined ? undefined : fenceForNative(fence),
|
|
126
|
+
signal,
|
|
127
|
+
timeoutMs: 15_000,
|
|
128
|
+
},
|
|
129
|
+
(error, chunk) => {
|
|
130
|
+
if (error) output += `${error.message}\n`;
|
|
131
|
+
else output += chunk;
|
|
132
|
+
},
|
|
133
|
+
);
|
|
134
|
+
return { exitCode: result.exitCode ?? -1, output };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function renderReport(report: SandboxCheckReport, json: boolean, verbose: boolean): void {
|
|
138
|
+
if (json) {
|
|
139
|
+
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const enforcement = report.osEnforced ? "OS enforced" : "scanner only";
|
|
144
|
+
process.stdout.write(`Sandbox backend: ${report.backend} (${enforcement})\n\n`);
|
|
145
|
+
const width = Math.max(0, ...report.checks.map(check => check.name.length));
|
|
146
|
+
for (const check of report.checks) {
|
|
147
|
+
process.stdout.write(`${check.status.padEnd(4)} ${check.name.padEnd(width)}\n`);
|
|
148
|
+
if (verbose && check.detail) process.stdout.write(` ${check.detail}\n`);
|
|
149
|
+
}
|
|
150
|
+
process.stdout.write(
|
|
151
|
+
`\n${report.summary.passed} passed, ${report.summary.failed} failed, ${report.summary.skipped} skipped\n`,
|
|
152
|
+
);
|
|
153
|
+
if (!verbose && report.summary.failed > 0) {
|
|
154
|
+
process.stdout.write("Run `xcsh sandbox check --verbose` for failure details.\n");
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Run the conformance matrix and report only after every synthetic fixture has been removed. */
|
|
159
|
+
export async function runSandboxCheck(options: SandboxCheckOptions = {}): Promise<SandboxCheckReport> {
|
|
160
|
+
const backend = containmentStatus(true);
|
|
161
|
+
const checks: SandboxCheckResult[] = [];
|
|
162
|
+
const fixturePaths: string[] = [];
|
|
163
|
+
const knownCleanupLeaves: string[] = [];
|
|
164
|
+
const nonEnumerableCleanupDirs = new Set<string>();
|
|
165
|
+
const redactions: Redaction[] = [];
|
|
166
|
+
const abortController = new AbortController();
|
|
167
|
+
const interrupt = () => abortController.abort();
|
|
168
|
+
process.once("SIGINT", interrupt);
|
|
169
|
+
process.once("SIGTERM", interrupt);
|
|
170
|
+
|
|
171
|
+
let fixtureRoot: string | undefined;
|
|
172
|
+
const add = (name: string, status: SandboxCheckResultStatus, detail?: string): void => {
|
|
173
|
+
checks.push({ name, status, ...(detail ? { detail } : {}) });
|
|
174
|
+
};
|
|
175
|
+
const check = async (
|
|
176
|
+
name: string,
|
|
177
|
+
probe: () => boolean | ProbeOutcome | Promise<boolean | ProbeOutcome>,
|
|
178
|
+
): Promise<void> => {
|
|
179
|
+
if (abortController.signal.aborted) {
|
|
180
|
+
add(name, "FAIL", "probe aborted before execution; path=<probe>; errno=ABORTED");
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
try {
|
|
184
|
+
const result = await probe();
|
|
185
|
+
const outcome = typeof result === "boolean" ? { passed: result } : result;
|
|
186
|
+
add(
|
|
187
|
+
name,
|
|
188
|
+
outcome.passed ? "PASS" : "FAIL",
|
|
189
|
+
outcome.passed ? undefined : (outcome.detail ?? "assertion failed; path=<probe>; errno=unknown"),
|
|
190
|
+
);
|
|
191
|
+
} catch (error) {
|
|
192
|
+
const outcome = exceptionOutcome("probe threw", "<probe>", error, redactions);
|
|
193
|
+
add(name, "FAIL", outcome.detail);
|
|
194
|
+
}
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
try {
|
|
198
|
+
const inheritedProfile = process.env[SANDBOX_SESSION_ROOT_ENV] !== undefined;
|
|
199
|
+
const workspaceInput = process.env[SANDBOX_SESSION_ROOT_ENV] ?? process.cwd();
|
|
200
|
+
const homeInput = process.env[SANDBOX_OPERATOR_HOME_ENV] ?? os.homedir();
|
|
201
|
+
redactions.push([workspaceInput, "<workspace>"], [homeInput, "<operator-home>"]);
|
|
202
|
+
const liveWorkspace = await fs.realpath(workspaceInput);
|
|
203
|
+
const liveHome = await fs.realpath(homeInput);
|
|
204
|
+
redactions.push([liveWorkspace, "<workspace>"], [liveHome, "<operator-home>"]);
|
|
205
|
+
|
|
206
|
+
const fixtureBase = inheritedProfile ? liveWorkspace : await fs.realpath(os.tmpdir());
|
|
207
|
+
fixtureRoot = await fs.mkdtemp(path.join(fixtureBase, ".xcsh-sandbox-check-policy-"));
|
|
208
|
+
fixturePaths.push(fixtureRoot);
|
|
209
|
+
redactions.push([fixtureRoot, "<synthetic-root>"]);
|
|
210
|
+
|
|
211
|
+
const accountRoot = path.join(fixtureRoot, "Users");
|
|
212
|
+
const operatorHome = path.join(accountRoot, "operator");
|
|
213
|
+
const otherHome = path.join(accountRoot, "other-account");
|
|
214
|
+
const workspaces = path.join(operatorHome, "workspaces");
|
|
215
|
+
const workspace = path.join(workspaces, "example-a");
|
|
216
|
+
const sibling = path.join(workspaces, "example-b");
|
|
217
|
+
const nested = path.join(workspace, "nested");
|
|
218
|
+
const sessionStore = path.join(operatorHome, ".xcsh", "agent", "sessions");
|
|
219
|
+
const otherSession = path.join(sessionStore, "synthetic-session");
|
|
220
|
+
const memoryStore = path.join(operatorHome, ".xcsh", "agent", "memories");
|
|
221
|
+
const otherMemory = path.join(memoryStore, "synthetic-memory");
|
|
222
|
+
const configDir = path.join(operatorHome, ".config", "xcsh-check");
|
|
223
|
+
|
|
224
|
+
for (const dir of [workspace, sibling, nested, otherHome, otherSession, otherMemory, configDir]) {
|
|
225
|
+
await fs.mkdir(dir, { recursive: true });
|
|
226
|
+
}
|
|
227
|
+
await Bun.write(path.join(workspace, "own.txt"), "own\n");
|
|
228
|
+
await Bun.write(path.join(sibling, "named.txt"), "sibling\n");
|
|
229
|
+
await Bun.write(path.join(otherHome, "synthetic.txt"), "synthetic\n");
|
|
230
|
+
await Bun.write(path.join(otherSession, "state.jsonl"), "synthetic\n");
|
|
231
|
+
await Bun.write(path.join(otherMemory, "MEMORY.md"), "synthetic\n");
|
|
232
|
+
|
|
233
|
+
const fence = buildContainmentFence({
|
|
234
|
+
workspace,
|
|
235
|
+
home: operatorHome,
|
|
236
|
+
fsRoot: fixtureRoot,
|
|
237
|
+
leakRoots: [sessionStore, memoryStore],
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
await check("structured tools share the boundary", () => {
|
|
241
|
+
const blocked = [
|
|
242
|
+
evaluateToolCall({ toolName: "read", input: { file_path: workspaces }, cwd: workspace, fence }),
|
|
243
|
+
evaluateToolCall({
|
|
244
|
+
toolName: "write",
|
|
245
|
+
input: { file_path: path.join(otherHome, "new.txt") },
|
|
246
|
+
cwd: workspace,
|
|
247
|
+
fence,
|
|
248
|
+
}),
|
|
249
|
+
evaluateToolCall({ toolName: "find", input: { pattern: `${accountRoot}/**/*` }, cwd: workspace, fence }),
|
|
250
|
+
evaluateToolCall({ toolName: "grep", input: { path: otherHome }, cwd: workspace, fence }),
|
|
251
|
+
evaluateToolCall({
|
|
252
|
+
toolName: "read",
|
|
253
|
+
input: { file_path: path.join(otherSession, "state.jsonl") },
|
|
254
|
+
cwd: workspace,
|
|
255
|
+
fence,
|
|
256
|
+
}),
|
|
257
|
+
evaluateToolCall({
|
|
258
|
+
toolName: "read",
|
|
259
|
+
input: { file_path: path.join(otherMemory, "MEMORY.md") },
|
|
260
|
+
cwd: workspace,
|
|
261
|
+
fence,
|
|
262
|
+
}),
|
|
263
|
+
evaluateToolCall({
|
|
264
|
+
toolName: "python",
|
|
265
|
+
input: { code: `import os; os.listdir(${JSON.stringify(accountRoot)})` },
|
|
266
|
+
cwd: workspace,
|
|
267
|
+
fence,
|
|
268
|
+
}),
|
|
269
|
+
];
|
|
270
|
+
const ownConfig = evaluateToolCall({
|
|
271
|
+
toolName: "write",
|
|
272
|
+
input: { file_path: path.join(configDir, "config") },
|
|
273
|
+
cwd: workspace,
|
|
274
|
+
fence,
|
|
275
|
+
});
|
|
276
|
+
const passed = blocked.every(result => result.block) && !ownConfig.block;
|
|
277
|
+
return passed
|
|
278
|
+
? { passed: true }
|
|
279
|
+
: {
|
|
280
|
+
passed: false,
|
|
281
|
+
detail: "structured-tool policy disagreed with the shell boundary; path=<synthetic-root>; errno=none",
|
|
282
|
+
};
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
await check("workspace read, write, glob, and recursion", async () => {
|
|
286
|
+
const displayPath = "<workspace>/<synthetic-fixture>";
|
|
287
|
+
let liveFixture: string;
|
|
288
|
+
try {
|
|
289
|
+
liveFixture = await fs.mkdtemp(path.join(liveWorkspace, ".xcsh-sandbox-check-workspace-"));
|
|
290
|
+
fixturePaths.push(liveFixture);
|
|
291
|
+
redactions.push([liveFixture, displayPath]);
|
|
292
|
+
await fs.mkdir(path.join(liveFixture, "nested"));
|
|
293
|
+
await Bun.write(path.join(liveFixture, "own.txt"), "own\n");
|
|
294
|
+
} catch (error) {
|
|
295
|
+
return exceptionOutcome("create live workspace fixture", displayPath, error, redactions);
|
|
296
|
+
}
|
|
297
|
+
const command =
|
|
298
|
+
"cat own.txt > /dev/null && printf created > created.txt && " +
|
|
299
|
+
"printf '%s\\n' ./* > /dev/null && find . -type f > /dev/null";
|
|
300
|
+
const result = await shellProbe(command, liveFixture, undefined, abortController.signal);
|
|
301
|
+
return shellOutcome(
|
|
302
|
+
result,
|
|
303
|
+
true,
|
|
304
|
+
"live profile must allow workspace read, write, glob, and recursion",
|
|
305
|
+
displayPath,
|
|
306
|
+
redactions,
|
|
307
|
+
);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
await check("named sibling remains reachable", async () => {
|
|
311
|
+
const displayPath = "<session-parent>/<synthetic-sibling>";
|
|
312
|
+
let liveSibling: string;
|
|
313
|
+
try {
|
|
314
|
+
liveSibling = await fs.mkdtemp(path.join(path.dirname(liveWorkspace), ".xcsh-sandbox-check-sibling-"));
|
|
315
|
+
fixturePaths.push(liveSibling);
|
|
316
|
+
nonEnumerableCleanupDirs.add(liveSibling);
|
|
317
|
+
redactions.push([liveSibling, displayPath]);
|
|
318
|
+
const namedFile = path.join(liveSibling, "named.txt");
|
|
319
|
+
await Bun.write(namedFile, "sibling\n");
|
|
320
|
+
knownCleanupLeaves.push(namedFile);
|
|
321
|
+
} catch (error) {
|
|
322
|
+
return exceptionOutcome("create named sibling fixture", displayPath, error, redactions);
|
|
323
|
+
}
|
|
324
|
+
const result = await shellProbe(
|
|
325
|
+
'test "$(cat named.txt)" = sibling',
|
|
326
|
+
liveSibling,
|
|
327
|
+
undefined,
|
|
328
|
+
abortController.signal,
|
|
329
|
+
);
|
|
330
|
+
return shellOutcome(result, true, "live profile must allow a named sibling read", displayPath, redactions);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
if (backend.osEnforced) {
|
|
334
|
+
await check("session parent cannot be enumerated", async () => {
|
|
335
|
+
const result = await shellProbe(
|
|
336
|
+
`ls ${quote(workspaces)} > /dev/null`,
|
|
337
|
+
workspace,
|
|
338
|
+
fence,
|
|
339
|
+
abortController.signal,
|
|
340
|
+
);
|
|
341
|
+
return shellOutcome(
|
|
342
|
+
result,
|
|
343
|
+
false,
|
|
344
|
+
"synthetic session parent enumeration must be refused",
|
|
345
|
+
"<synthetic-session-parent>",
|
|
346
|
+
redactions,
|
|
347
|
+
);
|
|
348
|
+
});
|
|
349
|
+
await check("account container cannot be enumerated", async () => {
|
|
350
|
+
const result = await shellProbe(
|
|
351
|
+
`ls ${quote(accountRoot)} > /dev/null`,
|
|
352
|
+
workspace,
|
|
353
|
+
fence,
|
|
354
|
+
abortController.signal,
|
|
355
|
+
);
|
|
356
|
+
return shellOutcome(
|
|
357
|
+
result,
|
|
358
|
+
false,
|
|
359
|
+
"synthetic account container enumeration must be refused",
|
|
360
|
+
"<synthetic-account-container>",
|
|
361
|
+
redactions,
|
|
362
|
+
);
|
|
363
|
+
});
|
|
364
|
+
await check("synthetic other account cannot be entered", async () => {
|
|
365
|
+
const result = await shellProbe(`cd ${quote(otherHome)}`, workspace, fence, abortController.signal);
|
|
366
|
+
return shellOutcome(
|
|
367
|
+
result,
|
|
368
|
+
false,
|
|
369
|
+
"synthetic other account traversal must be refused",
|
|
370
|
+
"<synthetic-other-account>",
|
|
371
|
+
redactions,
|
|
372
|
+
);
|
|
373
|
+
});
|
|
374
|
+
await check("cross-session stores cannot be read", async () => {
|
|
375
|
+
const sessionRead = await shellProbe(
|
|
376
|
+
`cat ${quote(path.join(otherSession, "state.jsonl"))} > /dev/null`,
|
|
377
|
+
workspace,
|
|
378
|
+
fence,
|
|
379
|
+
abortController.signal,
|
|
380
|
+
);
|
|
381
|
+
const sessionOutcome = shellOutcome(
|
|
382
|
+
sessionRead,
|
|
383
|
+
false,
|
|
384
|
+
"synthetic other session read must be refused",
|
|
385
|
+
"<synthetic-session-store>/<synthetic-session>",
|
|
386
|
+
redactions,
|
|
387
|
+
);
|
|
388
|
+
if (!sessionOutcome.passed) return sessionOutcome;
|
|
389
|
+
|
|
390
|
+
const memoryRead = await shellProbe(
|
|
391
|
+
`cat ${quote(path.join(otherMemory, "MEMORY.md"))} > /dev/null`,
|
|
392
|
+
workspace,
|
|
393
|
+
fence,
|
|
394
|
+
abortController.signal,
|
|
395
|
+
);
|
|
396
|
+
return shellOutcome(
|
|
397
|
+
memoryRead,
|
|
398
|
+
false,
|
|
399
|
+
"synthetic other memory read must be refused",
|
|
400
|
+
"<synthetic-memory-store>/<synthetic-memory>",
|
|
401
|
+
redactions,
|
|
402
|
+
);
|
|
403
|
+
});
|
|
404
|
+
} else {
|
|
405
|
+
for (const name of [
|
|
406
|
+
"session parent cannot be enumerated",
|
|
407
|
+
"account container cannot be enumerated",
|
|
408
|
+
"synthetic other account cannot be entered",
|
|
409
|
+
"cross-session stores cannot be read",
|
|
410
|
+
]) {
|
|
411
|
+
add(name, "SKIP", "OS enforcement backend unavailable; path=<probe>; errno=unsupported");
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
await check("operator home configuration is writable", async () => {
|
|
416
|
+
const displayPath = "<operator-home>/<synthetic-fixture>";
|
|
417
|
+
let liveConfig: string;
|
|
418
|
+
try {
|
|
419
|
+
// Landlock cannot grant creation on a split directory without also granting every
|
|
420
|
+
// denied descendant. The live fence therefore grants operator-owned CLI config roots
|
|
421
|
+
// explicitly; use one of those when another Landlock profile is already inherited.
|
|
422
|
+
// Standalone and Seatbelt checks retain the direct-home probe.
|
|
423
|
+
const configBase =
|
|
424
|
+
inheritedProfile && backend.backend === "landlock" ? path.join(liveHome, ".config", "gh") : liveHome;
|
|
425
|
+
liveConfig = await fs.mkdtemp(path.join(configBase, ".xcsh-sandbox-check-home-"));
|
|
426
|
+
fixturePaths.push(liveConfig);
|
|
427
|
+
redactions.push([liveConfig, displayPath]);
|
|
428
|
+
} catch (error) {
|
|
429
|
+
return exceptionOutcome("create operator-home fixture", displayPath, error, redactions);
|
|
430
|
+
}
|
|
431
|
+
const result = await shellProbe(
|
|
432
|
+
'printf operator > config && test "$(cat config)" = operator',
|
|
433
|
+
liveConfig,
|
|
434
|
+
undefined,
|
|
435
|
+
abortController.signal,
|
|
436
|
+
);
|
|
437
|
+
return shellOutcome(
|
|
438
|
+
result,
|
|
439
|
+
true,
|
|
440
|
+
"live profile must allow operator-home configuration writes",
|
|
441
|
+
displayPath,
|
|
442
|
+
redactions,
|
|
443
|
+
);
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
await check("cwd resets across tool calls", async () => {
|
|
447
|
+
const settings = await Settings.init({
|
|
448
|
+
cwd: workspace,
|
|
449
|
+
agentDir: path.join(fixtureRoot!, "agent-state"),
|
|
450
|
+
inMemory: true,
|
|
451
|
+
overrides: {
|
|
452
|
+
"async.enabled": false,
|
|
453
|
+
"bash.autoBackground.enabled": false,
|
|
454
|
+
"bashInterceptor.enabled": false,
|
|
455
|
+
"sandbox.enabled": true,
|
|
456
|
+
},
|
|
457
|
+
});
|
|
458
|
+
const session: ToolSession = {
|
|
459
|
+
cwd: workspace,
|
|
460
|
+
hasUI: false,
|
|
461
|
+
hasEditTool: true,
|
|
462
|
+
settings,
|
|
463
|
+
getSessionFile: () => null,
|
|
464
|
+
getSessionSpawns: () => null,
|
|
465
|
+
getSessionId: () => "sandbox-check-session",
|
|
466
|
+
};
|
|
467
|
+
const tool = new BashTool(session);
|
|
468
|
+
const moved = await tool.execute("sandbox-check-move", { command: `cd ${quote(nested)}; pwd` });
|
|
469
|
+
const reset = await tool.execute("sandbox-check-reset", { command: "pwd" });
|
|
470
|
+
const explicit = await tool.execute("sandbox-check-explicit", { command: "pwd", cwd: nested });
|
|
471
|
+
const text = (result: typeof moved): string =>
|
|
472
|
+
result.content
|
|
473
|
+
.filter((part): part is { type: "text"; text: string } => part.type === "text")
|
|
474
|
+
.map(part => part.text)
|
|
475
|
+
.join("")
|
|
476
|
+
.trim();
|
|
477
|
+
const passed = text(moved) === nested && text(reset) === workspace && text(explicit) === nested;
|
|
478
|
+
return passed
|
|
479
|
+
? { passed: true }
|
|
480
|
+
: {
|
|
481
|
+
passed: false,
|
|
482
|
+
detail:
|
|
483
|
+
"tool-call cwd did not reset to <synthetic-workspace> or honor explicit cwd; path=<synthetic-root>; errno=none",
|
|
484
|
+
};
|
|
485
|
+
});
|
|
486
|
+
} catch (error) {
|
|
487
|
+
const outcome = exceptionOutcome("conformance matrix setup failed", "<probe>", error, redactions);
|
|
488
|
+
add("conformance matrix completed", "FAIL", outcome.detail);
|
|
489
|
+
} finally {
|
|
490
|
+
process.off("SIGINT", interrupt);
|
|
491
|
+
process.off("SIGTERM", interrupt);
|
|
492
|
+
const cleanupFailures: string[] = [];
|
|
493
|
+
for (const leafPath of [...knownCleanupLeaves].reverse()) {
|
|
494
|
+
try {
|
|
495
|
+
await fs.rm(leafPath, { force: true });
|
|
496
|
+
} catch (error) {
|
|
497
|
+
if (!isEnoent(error)) cleanupFailures.push(error instanceof Error ? error.message : String(error));
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
for (const fixturePath of [...fixturePaths].reverse()) {
|
|
501
|
+
try {
|
|
502
|
+
// Landlock denies enumeration of the session parent. A sibling created after the
|
|
503
|
+
// profile snapshot consequently cannot be walked during recursive cleanup, even
|
|
504
|
+
// though its known leaf and the directory itself can be removed by name.
|
|
505
|
+
if (nonEnumerableCleanupDirs.has(fixturePath)) await fs.rmdir(fixturePath);
|
|
506
|
+
else await fs.rm(fixturePath, { recursive: true, force: true });
|
|
507
|
+
await fs.stat(fixturePath);
|
|
508
|
+
cleanupFailures.push(`fixture remains at ${fixturePath}`);
|
|
509
|
+
} catch (error) {
|
|
510
|
+
if (!isEnoent(error)) cleanupFailures.push(error instanceof Error ? error.message : String(error));
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
if (fixtureRoot === undefined || cleanupFailures.length > 0) {
|
|
515
|
+
add(
|
|
516
|
+
"synthetic fixtures removed",
|
|
517
|
+
"FAIL",
|
|
518
|
+
sanitizeDetail(
|
|
519
|
+
`fixture cleanup incomplete; path=<synthetic-fixtures>; errno=${
|
|
520
|
+
fixtureRoot === undefined ? "ENOENT" : "unknown"
|
|
521
|
+
}${cleanupFailures.length > 0 ? `; error=${cleanupFailures.join("; ")}` : ""}`,
|
|
522
|
+
redactions,
|
|
523
|
+
),
|
|
524
|
+
);
|
|
525
|
+
} else {
|
|
526
|
+
add("synthetic fixtures removed", "PASS");
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
const report: SandboxCheckReport = {
|
|
531
|
+
backend: backend.backend,
|
|
532
|
+
osEnforced: backend.osEnforced,
|
|
533
|
+
checks,
|
|
534
|
+
summary: {
|
|
535
|
+
passed: checks.filter(result => result.status === "PASS").length,
|
|
536
|
+
failed: checks.filter(result => result.status === "FAIL").length,
|
|
537
|
+
skipped: checks.filter(result => result.status === "SKIP").length,
|
|
538
|
+
},
|
|
539
|
+
};
|
|
540
|
+
renderReport(report, options.json ?? false, options.verbose ?? false);
|
|
541
|
+
return report;
|
|
542
|
+
}
|
package/src/cli.ts
CHANGED
|
@@ -56,6 +56,7 @@ const commands: CommandEntry[] = [
|
|
|
56
56
|
{ name: "grep", load: () => import("./commands/grep").then(m => m.default) },
|
|
57
57
|
{ name: "grievances", load: () => import("./commands/grievances").then(m => m.default) },
|
|
58
58
|
{ name: "read", load: () => import("./commands/read").then(m => m.default) },
|
|
59
|
+
{ name: "sandbox", load: () => import("./commands/sandbox").then(m => m.default) },
|
|
59
60
|
{ name: "jupyter", load: () => import("./commands/jupyter").then(m => m.default) },
|
|
60
61
|
{ name: "manager", load: () => import("./commands/manager").then(m => m.default) },
|
|
61
62
|
{ name: "office", load: () => import("./commands/office").then(m => m.default) },
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** Run installed-binary sandbox diagnostics. */
|
|
2
|
+
import { Args, Command, Flags } from "@f5-sales-demo/pi-utils/cli";
|
|
3
|
+
import { runSandboxCheck } from "../cli/sandbox-check";
|
|
4
|
+
|
|
5
|
+
export default class Sandbox extends Command {
|
|
6
|
+
static description = "Verify the installed filesystem sandbox";
|
|
7
|
+
|
|
8
|
+
static args = {
|
|
9
|
+
action: Args.string({
|
|
10
|
+
description: "Sandbox action",
|
|
11
|
+
required: true,
|
|
12
|
+
options: ["check"],
|
|
13
|
+
}),
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
static flags = {
|
|
17
|
+
json: Flags.boolean({ description: "Output JSON" }),
|
|
18
|
+
verbose: Flags.boolean({ char: "v", description: "Show failure details" }),
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
async run(): Promise<void> {
|
|
22
|
+
const { flags } = await this.parse(Sandbox);
|
|
23
|
+
const report = await runSandboxCheck({ json: flags.json, verbose: flags.verbose });
|
|
24
|
+
if (report.summary.failed > 0) process.exitCode = 1;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -17,17 +17,17 @@ export interface BuildInfo {
|
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
export const BUILD_INFO: BuildInfo = {
|
|
20
|
-
"version": "20.
|
|
21
|
-
"commit": "
|
|
22
|
-
"shortCommit": "
|
|
20
|
+
"version": "20.1.1",
|
|
21
|
+
"commit": "ac536e6d319a00467e8e95de237cc915b1551dec",
|
|
22
|
+
"shortCommit": "ac536e6",
|
|
23
23
|
"branch": "main",
|
|
24
|
-
"tag": "v20.
|
|
25
|
-
"commitDate": "2026-08-
|
|
26
|
-
"buildDate": "2026-08-
|
|
24
|
+
"tag": "v20.1.1",
|
|
25
|
+
"commitDate": "2026-08-01T18:06:22Z",
|
|
26
|
+
"buildDate": "2026-08-01T18:28:19.612Z",
|
|
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/v20.
|
|
31
|
+
"commitUrl": "https://github.com/f5-sales-demo/xcsh/commit/ac536e6d319a00467e8e95de237cc915b1551dec",
|
|
32
|
+
"releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v20.1.1"
|
|
33
33
|
};
|
|
@@ -15,9 +15,9 @@ followed symlinks — so how a path is spelled does not change what is reachable
|
|
|
15
15
|
- Cross-tenant isolation removes the discovery step. The directory containing the session root cannot
|
|
16
16
|
be enumerated, but a sibling path the operator names directly can still be read, written, or entered.
|
|
17
17
|
An explicit read grant, including `--allow-path <dir>`, restores enumeration for that directory.
|
|
18
|
-
- Cross-session stores and data roots remain denied. Another session's
|
|
19
|
-
contexts, and temporary working state are not reachable through
|
|
20
|
-
and mounted data volumes.
|
|
18
|
+
- Cross-session stores, other operators' accounts, and data roots remain denied. Another session's
|
|
19
|
+
transcripts, memories, internal contexts, and temporary working state are not reachable through
|
|
20
|
+
tools, nor are sibling local accounts, unrelated data roots, and mounted data volumes.
|
|
21
21
|
|
|
22
22
|
The same boundary answers for every tool. `read`, `write`, `grep`, `find`, `python`, and `bash` consult
|
|
23
23
|
the same rules, so changing tools or spelling a path differently does not widen the session.
|
|
@@ -51,7 +51,8 @@ spelling may not be caught.
|
|
|
51
51
|
|
|
52
52
|
The intended rules are the same ones a confined session has: parent enumeration is refused while named
|
|
53
53
|
operator access remains available, operator-owned home and configuration stay readable and writable,
|
|
54
|
-
and cross-session stores and unrelated data roots stay denied. Ordinary
|
|
54
|
+
and cross-session stores, other operators' accounts, and unrelated data roots stay denied. Ordinary
|
|
55
|
+
work remains unrestricted.
|
|
55
56
|
|
|
56
57
|
Treat the boundary as a statement of intent rather than a guarantee, and do not go looking for paths
|
|
57
58
|
outside the session directory on the assumption that something would stop you.
|
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
*
|
|
9
9
|
* So the fence is gentle. It leaves `/usr`, `/tmp`, package caches, the network and process execution
|
|
10
10
|
* alone. Its cross-tenant courtesy removes the discovery step — enumerating the session root's parent —
|
|
11
|
-
* while keeping named operator access.
|
|
12
|
-
* the model has no legitimate reason to wander (#2554).
|
|
11
|
+
* while keeping named operator access. Other operators' accounts, explicit data roots and cross-session
|
|
12
|
+
* state remain denied where the model has no legitimate reason to wander (#2554).
|
|
13
13
|
*
|
|
14
14
|
* Produced declaratively rather than as an ordered rule list, because the two backends disagree about
|
|
15
15
|
* order: seatbelt evaluates rules in sequence with the last match winning, while Landlock only grants
|
|
@@ -197,9 +197,8 @@ const OPERATIONAL_ROOT_NAMES = new Set([
|
|
|
197
197
|
* unforeseen — `/data`, `/scratch`, a bespoke mount.
|
|
198
198
|
*/
|
|
199
199
|
const DATA_ROOTS = [
|
|
200
|
-
//
|
|
201
|
-
|
|
202
|
-
// them; the fence is not here to re-implement file permissions.
|
|
200
|
+
"/Users", // Account containers are denied, then this operator's canonical home is allowed back at
|
|
201
|
+
"/home", // greater depth. That preserves #2637 without exposing another local account (#2788).
|
|
203
202
|
"/root", // Linux superuser home: not this operator's account
|
|
204
203
|
"/Volumes", // macOS mounts. Per-container, not per-child: /Volumes/Macintosh HD resolves to /,
|
|
205
204
|
"/mnt", // which `tooBroadToDeny` then rejects, and the kernel resolves such a path before any
|
|
@@ -481,6 +480,11 @@ export function buildContainmentFence(options: ContainmentOptions): ContainmentF
|
|
|
481
480
|
}
|
|
482
481
|
|
|
483
482
|
if (home !== undefined) {
|
|
483
|
+
// The account container is a data root, but this operator's whole home belongs to them (#2637).
|
|
484
|
+
// A deeper full allow preserves their normal filesystem rights while leaving sibling accounts under
|
|
485
|
+
// the broader deny. Cross-session stores are denied again at still greater depth below.
|
|
486
|
+
allow.add(home);
|
|
487
|
+
|
|
484
488
|
// Granted whether or not they exist yet. `~/.bun` has to be writable *before* the first
|
|
485
489
|
// `bun install` creates it, so dropping absent caches would break exactly the first run.
|
|
486
490
|
// Canonicalised when present, so a symlinked cache resolves to its real location.
|
|
@@ -505,6 +509,7 @@ export function buildContainmentFence(options: ContainmentOptions): ContainmentF
|
|
|
505
509
|
// prefix whether or not the path exists; Landlock cannot attach a rule to an absent inode, but its
|
|
506
510
|
// plan never grants a path `readdir` did not see either, so nothing is lost there.
|
|
507
511
|
const known = DATA_ROOTS.map(name => canonicalThroughExisting(path.join(fsRoot, path.basename(name))));
|
|
512
|
+
const accountRoots = new Set(["Users", "home"].map(name => canonicalThroughExisting(path.join(fsRoot, name))));
|
|
508
513
|
const found = dataRootEntries(fsRoot).map(entry => canonical(entry) ?? entry);
|
|
509
514
|
// Whole filesystem roots other than the workspace's own — the other Windows drives.
|
|
510
515
|
//
|
|
@@ -520,11 +525,11 @@ export function buildContainmentFence(options: ContainmentOptions): ContainmentF
|
|
|
520
525
|
// e.g. /Volumes/Macintosh HD -> /. Skipped for the whole-root list, per the note above.
|
|
521
526
|
if (!rootScoped.has(resolved) && tooBroadToDeny(resolved, fsRoot)) continue;
|
|
522
527
|
if (resolved === fsRoot) continue; // never the root the workspace lives on
|
|
523
|
-
//
|
|
524
|
-
//
|
|
525
|
-
//
|
|
526
|
-
//
|
|
527
|
-
if (home !== undefined && pathIsWithin(resolved, home)) continue;
|
|
528
|
+
// A directory containing home is normally too broad to deny because it would revoke the operator's
|
|
529
|
+
// own files (#2637). Account containers are the deliberate exception: they are denied here and the
|
|
530
|
+
// canonical home allow above wins at greater depth, isolating sibling accounts without hobbling the
|
|
531
|
+
// operator (#2788).
|
|
532
|
+
if (home !== undefined && pathIsWithin(resolved, home) && !accountRoots.has(resolved)) continue;
|
|
528
533
|
// A deny beats an allow at EQUAL depth, so denying a root that IS the workspace or IS something
|
|
529
534
|
// the operator granted would not be redundant — it would silently revoke the grant. Deeper is
|
|
530
535
|
// fine and intended: an ancestor deny with the workspace allowed inside it is the normal shape.
|
|
@@ -16,6 +16,17 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import { buildContainmentFence, type ContainmentFence } from "./containment";
|
|
18
18
|
|
|
19
|
+
/**
|
|
20
|
+
* Trusted context added to commands launched by the fenced model bash tool.
|
|
21
|
+
*
|
|
22
|
+
* A subprocess cannot infer the session anchor after a command-local `cd`, and an inherited OS
|
|
23
|
+
* sandbox cannot be loosened. The installed sandbox diagnostic uses these values to place its
|
|
24
|
+
* allow-side fixtures inside paths the live bash profile already grants instead of inventing a
|
|
25
|
+
* second workspace under the system temp directory (#2800).
|
|
26
|
+
*/
|
|
27
|
+
export const SANDBOX_SESSION_ROOT_ENV = "XCSH_SANDBOX_SESSION_ROOT";
|
|
28
|
+
export const SANDBOX_OPERATOR_HOME_ENV = "XCSH_SANDBOX_OPERATOR_HOME";
|
|
29
|
+
|
|
19
30
|
/** The slice of `Settings` this needs — supplied explicitly so the caller names its own source. */
|
|
20
31
|
export interface SettingsReader {
|
|
21
32
|
get(key: string): unknown;
|
package/src/tools/bash.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
|
+
import * as os from "node:os";
|
|
2
3
|
import type {
|
|
3
4
|
AgentTool,
|
|
4
5
|
AgentToolContext,
|
|
@@ -17,7 +18,7 @@ import { truncateToVisualLines } from "../modes/components/visual-truncate";
|
|
|
17
18
|
import type { Theme } from "../modes/theme/theme";
|
|
18
19
|
import bashDescription from "../prompts/tools/bash.md" with { type: "text" };
|
|
19
20
|
import { type ContainmentFence, containmentStatus, fenceVerdict } from "../sandbox/containment";
|
|
20
|
-
import { resolveSessionFence } from "../sandbox/session-fence";
|
|
21
|
+
import { resolveSessionFence, SANDBOX_OPERATOR_HOME_ENV, SANDBOX_SESSION_ROOT_ENV } from "../sandbox/session-fence";
|
|
21
22
|
import { SECRET_ENV_PATTERNS, type SecretObfuscator } from "../secrets";
|
|
22
23
|
import { DEFAULT_MAX_BYTES, TailBuffer } from "../session/streaming-output";
|
|
23
24
|
import { renderStatusLine } from "../tui";
|
|
@@ -557,11 +558,11 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
557
558
|
* RPC `bash` reach it too, and the same brush-core runs credential helpers and the interactive
|
|
558
559
|
* `xcsh shell`. Only the model's tool call is fenced (#2554).
|
|
559
560
|
*/
|
|
560
|
-
#containmentFence() {
|
|
561
|
+
#containmentFence(root = this.#containmentRoot()) {
|
|
561
562
|
const artifactsDir = this.session.getArtifactsDir?.();
|
|
562
563
|
// One resolver, shared with `sandbox-guard` and the internal-URL check, so the pre-check and the
|
|
563
564
|
// kernel cannot be looking at different boundaries (#2624).
|
|
564
|
-
return resolveSessionFence(
|
|
565
|
+
return resolveSessionFence(root, this.session.settings, {
|
|
565
566
|
extraRoots: artifactsDir ? [artifactsDir] : [],
|
|
566
567
|
});
|
|
567
568
|
}
|
|
@@ -583,7 +584,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
583
584
|
ctx?: AgentToolContext,
|
|
584
585
|
): Promise<AgentToolResult<BashToolDetails>> {
|
|
585
586
|
let command = rawCommand;
|
|
586
|
-
const
|
|
587
|
+
const requestedEnv = normalizeBashEnv(rawEnv);
|
|
587
588
|
|
|
588
589
|
// Extract leading `cd <path> && ...` into cwd when the model ignores the cwd parameter.
|
|
589
590
|
if (!cwd) {
|
|
@@ -627,7 +628,20 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
627
628
|
// different configurations — which doubled a cost that lands as user-visible latency. Sharing is
|
|
628
629
|
// also the more correct answer: the internal-URL check and the shell are then reasoning about the
|
|
629
630
|
// same boundary rather than two that merely agree today.
|
|
630
|
-
const
|
|
631
|
+
const containmentRoot = this.#containmentRoot();
|
|
632
|
+
const fence = this.#containmentFence(containmentRoot);
|
|
633
|
+
// A nested `xcsh sandbox check` must exercise the grants of this exact live profile. Seatbelt and
|
|
634
|
+
// Landlock restrictions compose and cannot be relaxed by its subprocess, so the diagnostic needs
|
|
635
|
+
// the immutable session anchor even when this individual call uses `cwd` or starts with `cd`.
|
|
636
|
+
// Keep these values host-owned: tool-supplied env cannot replace them.
|
|
637
|
+
const env =
|
|
638
|
+
fence === undefined
|
|
639
|
+
? requestedEnv
|
|
640
|
+
: {
|
|
641
|
+
...requestedEnv,
|
|
642
|
+
[SANDBOX_SESSION_ROOT_ENV]: containmentRoot,
|
|
643
|
+
[SANDBOX_OPERATOR_HOME_ENV]: os.homedir(),
|
|
644
|
+
};
|
|
631
645
|
|
|
632
646
|
const localOptions = {
|
|
633
647
|
getArtifactsDir: this.session.getArtifactsDir,
|