@f5-sales-demo/xcsh 20.1.0 → 20.1.2
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 +12 -0
- package/package.json +8 -8
- package/src/cli/sandbox-check.ts +328 -50
- package/src/commands/sandbox.ts +3 -2
- package/src/internal-urls/build-info.generated.ts +8 -8
- package/src/internal-urls/console-catalog.generated.ts +2 -4
- package/src/sandbox/session-fence.ts +12 -0
- package/src/tools/bash.ts +105 -41
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [20.1.2] - 2026-08-01
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Made `xcsh sandbox check` work for sessions rooted directly under the operator home, distinguish harness errors from sandbox failures, and verify this topology against installed release artifacts ([#2807](https://github.com/f5-sales-demo/xcsh/issues/2807))
|
|
10
|
+
|
|
11
|
+
## [20.1.1] - 2026-08-01
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- 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))
|
|
16
|
+
|
|
5
17
|
## [20.1.0] - 2026-08-01
|
|
6
18
|
|
|
7
19
|
### Added
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@f5-sales-demo/xcsh",
|
|
4
|
-
"version": "20.1.
|
|
4
|
+
"version": "20.1.2",
|
|
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.1.
|
|
61
|
-
"@f5-sales-demo/pi-agent-core": "20.1.
|
|
62
|
-
"@f5-sales-demo/pi-ai": "20.1.
|
|
63
|
-
"@f5-sales-demo/pi-natives": "20.1.
|
|
64
|
-
"@f5-sales-demo/pi-resource-management": "20.1.
|
|
65
|
-
"@f5-sales-demo/pi-tui": "20.1.
|
|
66
|
-
"@f5-sales-demo/pi-utils": "20.1.
|
|
60
|
+
"@f5-sales-demo/xcsh-stats": "20.1.2",
|
|
61
|
+
"@f5-sales-demo/pi-agent-core": "20.1.2",
|
|
62
|
+
"@f5-sales-demo/pi-ai": "20.1.2",
|
|
63
|
+
"@f5-sales-demo/pi-natives": "20.1.2",
|
|
64
|
+
"@f5-sales-demo/pi-resource-management": "20.1.2",
|
|
65
|
+
"@f5-sales-demo/pi-tui": "20.1.2",
|
|
66
|
+
"@f5-sales-demo/pi-utils": "20.1.2",
|
|
67
67
|
"@sinclair/typebox": "^0.34",
|
|
68
68
|
"@xterm/headless": "^6.0",
|
|
69
69
|
"ajv": "^8.20",
|
package/src/cli/sandbox-check.ts
CHANGED
|
@@ -8,13 +8,20 @@ import { Settings } from "../config/settings";
|
|
|
8
8
|
import { fenceForNative } from "../exec/bash-executor";
|
|
9
9
|
import { buildContainmentFence, type ContainmentFence, containmentStatus } from "../sandbox/containment";
|
|
10
10
|
import { evaluateToolCall } from "../sandbox/enforce";
|
|
11
|
+
import {
|
|
12
|
+
SANDBOX_CHECK_NAMED_SIBLING_ENV,
|
|
13
|
+
SANDBOX_OPERATOR_HOME_ENV,
|
|
14
|
+
SANDBOX_SESSION_ROOT_ENV,
|
|
15
|
+
} from "../sandbox/session-fence";
|
|
11
16
|
import { BashTool, type ToolSession } from "../tools";
|
|
12
17
|
|
|
13
|
-
export type SandboxCheckResultStatus = "PASS" | "FAIL" | "SKIP";
|
|
18
|
+
export type SandboxCheckResultStatus = "PASS" | "FAIL" | "SKIP" | "ERROR";
|
|
14
19
|
|
|
15
20
|
export interface SandboxCheckResult {
|
|
16
21
|
name: string;
|
|
17
22
|
status: SandboxCheckResultStatus;
|
|
23
|
+
/** Present on failures, errors, and skips; paths are generalized before they leave the process. */
|
|
24
|
+
detail?: string;
|
|
18
25
|
}
|
|
19
26
|
|
|
20
27
|
export interface SandboxCheckReport {
|
|
@@ -24,38 +31,115 @@ export interface SandboxCheckReport {
|
|
|
24
31
|
summary: {
|
|
25
32
|
passed: number;
|
|
26
33
|
failed: number;
|
|
34
|
+
errors: number;
|
|
27
35
|
skipped: number;
|
|
28
36
|
};
|
|
29
37
|
}
|
|
30
38
|
|
|
31
39
|
export interface SandboxCheckOptions {
|
|
32
40
|
json?: boolean;
|
|
41
|
+
verbose?: boolean;
|
|
33
42
|
}
|
|
34
43
|
|
|
44
|
+
interface ProbeOutcome {
|
|
45
|
+
passed: boolean;
|
|
46
|
+
detail?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface ShellProbeResult {
|
|
50
|
+
exitCode: number;
|
|
51
|
+
output: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
type Redaction = readonly [path: string, label: string];
|
|
55
|
+
|
|
35
56
|
function quote(value: string): string {
|
|
36
57
|
return JSON.stringify(value);
|
|
37
58
|
}
|
|
38
59
|
|
|
39
|
-
|
|
60
|
+
function errorCode(error: unknown): string | undefined {
|
|
61
|
+
if (typeof error !== "object" || error === null || !("code" in error)) return undefined;
|
|
62
|
+
const code = (error as { code?: unknown }).code;
|
|
63
|
+
return typeof code === "string" ? code : undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function sanitizeDetail(value: string, redactions: readonly Redaction[]): string {
|
|
67
|
+
let sanitized = value;
|
|
68
|
+
for (const [actual, label] of [...redactions].sort(([a], [b]) => b.length - a.length)) {
|
|
69
|
+
if (actual.length > 0) sanitized = sanitized.replaceAll(actual, label);
|
|
70
|
+
}
|
|
71
|
+
sanitized = sanitized.replace(/\s+/gu, " ").trim();
|
|
72
|
+
return sanitized.length > 500 ? `${sanitized.slice(0, 497)}...` : sanitized;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function errnoFromOutput(output: string): string {
|
|
76
|
+
if (/operation not permitted/iu.test(output)) return "EPERM";
|
|
77
|
+
if (/permission denied/iu.test(output)) return "EACCES";
|
|
78
|
+
if (/no such file or directory/iu.test(output)) return "ENOENT";
|
|
79
|
+
if (/not a directory/iu.test(output)) return "ENOTDIR";
|
|
80
|
+
return "unknown";
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function exceptionOutcome(
|
|
84
|
+
assertion: string,
|
|
85
|
+
displayPath: string,
|
|
86
|
+
error: unknown,
|
|
87
|
+
redactions: readonly Redaction[],
|
|
88
|
+
): ProbeOutcome {
|
|
89
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
90
|
+
return {
|
|
91
|
+
passed: false,
|
|
92
|
+
detail: sanitizeDetail(
|
|
93
|
+
`${assertion}; path=${displayPath}; errno=${errorCode(error) ?? "unknown"}; error=${message}`,
|
|
94
|
+
redactions,
|
|
95
|
+
),
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function shellOutcome(
|
|
100
|
+
result: ShellProbeResult,
|
|
101
|
+
expectSuccess: boolean,
|
|
102
|
+
assertion: string,
|
|
103
|
+
displayPath: string,
|
|
104
|
+
redactions: readonly Redaction[],
|
|
105
|
+
): ProbeOutcome {
|
|
106
|
+
const passed = expectSuccess ? result.exitCode === 0 : result.exitCode !== 0;
|
|
107
|
+
if (passed) return { passed: true };
|
|
108
|
+
const output = sanitizeDetail(result.output, redactions);
|
|
109
|
+
const errno = result.exitCode === 0 ? "none" : errnoFromOutput(output);
|
|
110
|
+
return {
|
|
111
|
+
passed: false,
|
|
112
|
+
detail: sanitizeDetail(
|
|
113
|
+
`${assertion}; path=${displayPath}; exit=${result.exitCode}; errno=${errno}${output ? `; output=${output}` : ""}`,
|
|
114
|
+
redactions,
|
|
115
|
+
),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function shellProbe(
|
|
40
120
|
command: string,
|
|
41
121
|
cwd: string,
|
|
42
|
-
fence: ContainmentFence,
|
|
122
|
+
fence: ContainmentFence | undefined,
|
|
43
123
|
signal: AbortSignal,
|
|
44
|
-
): Promise<
|
|
124
|
+
): Promise<ShellProbeResult> {
|
|
125
|
+
let output = "";
|
|
45
126
|
const result = await executeShell(
|
|
46
127
|
{
|
|
47
128
|
command,
|
|
48
129
|
cwd,
|
|
49
|
-
fence: fenceForNative(fence),
|
|
130
|
+
fence: fence === undefined ? undefined : fenceForNative(fence),
|
|
50
131
|
signal,
|
|
51
132
|
timeoutMs: 15_000,
|
|
52
133
|
},
|
|
53
|
-
() => {
|
|
134
|
+
(error, chunk) => {
|
|
135
|
+
if (error) output += `${error.message}\n`;
|
|
136
|
+
else output += chunk;
|
|
137
|
+
},
|
|
54
138
|
);
|
|
55
|
-
return result.exitCode ?? -1;
|
|
139
|
+
return { exitCode: result.exitCode ?? -1, output };
|
|
56
140
|
}
|
|
57
141
|
|
|
58
|
-
function renderReport(report: SandboxCheckReport, json: boolean): void {
|
|
142
|
+
function renderReport(report: SandboxCheckReport, json: boolean, verbose: boolean): void {
|
|
59
143
|
if (json) {
|
|
60
144
|
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
61
145
|
return;
|
|
@@ -63,43 +147,83 @@ function renderReport(report: SandboxCheckReport, json: boolean): void {
|
|
|
63
147
|
|
|
64
148
|
const enforcement = report.osEnforced ? "OS enforced" : "scanner only";
|
|
65
149
|
process.stdout.write(`Sandbox backend: ${report.backend} (${enforcement})\n\n`);
|
|
66
|
-
const
|
|
150
|
+
const nameWidth = Math.max(0, ...report.checks.map(check => check.name.length));
|
|
151
|
+
const statusWidth = Math.max(0, ...report.checks.map(check => check.status.length));
|
|
67
152
|
for (const check of report.checks) {
|
|
68
|
-
process.stdout.write(`${check.status.padEnd(
|
|
153
|
+
process.stdout.write(`${check.status.padEnd(statusWidth)} ${check.name.padEnd(nameWidth)}\n`);
|
|
154
|
+
if (verbose && check.detail) process.stdout.write(` ${check.detail}\n`);
|
|
69
155
|
}
|
|
70
156
|
process.stdout.write(
|
|
71
|
-
`\n${report.summary.passed} passed, ${report.summary.failed} failed, ${report.summary.skipped} skipped\n`,
|
|
157
|
+
`\n${report.summary.passed} passed, ${report.summary.failed} failed, ${report.summary.errors} errors, ${report.summary.skipped} skipped\n`,
|
|
72
158
|
);
|
|
159
|
+
if (report.checks.some(check => check.name === "conformance matrix setup" && check.status === "ERROR")) {
|
|
160
|
+
process.stdout.write("Conformance matrix did not run.\n");
|
|
161
|
+
}
|
|
162
|
+
if (!verbose && (report.summary.failed > 0 || report.summary.errors > 0)) {
|
|
163
|
+
process.stdout.write("Run `xcsh sandbox check --verbose` for failure details.\n");
|
|
164
|
+
}
|
|
73
165
|
}
|
|
74
166
|
|
|
75
167
|
/** Run the conformance matrix and report only after every synthetic fixture has been removed. */
|
|
76
168
|
export async function runSandboxCheck(options: SandboxCheckOptions = {}): Promise<SandboxCheckReport> {
|
|
77
169
|
const backend = containmentStatus(true);
|
|
78
170
|
const checks: SandboxCheckResult[] = [];
|
|
171
|
+
const fixturePaths: string[] = [];
|
|
172
|
+
const knownCleanupLeaves: string[] = [];
|
|
173
|
+
const nonEnumerableCleanupDirs = new Set<string>();
|
|
174
|
+
const redactions: Redaction[] = [];
|
|
79
175
|
const abortController = new AbortController();
|
|
80
176
|
const interrupt = () => abortController.abort();
|
|
81
177
|
process.once("SIGINT", interrupt);
|
|
82
178
|
process.once("SIGTERM", interrupt);
|
|
83
179
|
|
|
84
180
|
let fixtureRoot: string | undefined;
|
|
85
|
-
const add = (name: string, status: SandboxCheckResultStatus): void => {
|
|
86
|
-
checks.push({ name, status });
|
|
181
|
+
const add = (name: string, status: SandboxCheckResultStatus, detail?: string): void => {
|
|
182
|
+
checks.push({ name, status, ...(detail ? { detail } : {}) });
|
|
87
183
|
};
|
|
88
|
-
const check = async (
|
|
184
|
+
const check = async (
|
|
185
|
+
name: string,
|
|
186
|
+
probe: () => boolean | ProbeOutcome | Promise<boolean | ProbeOutcome>,
|
|
187
|
+
): Promise<void> => {
|
|
89
188
|
if (abortController.signal.aborted) {
|
|
90
|
-
add(name, "
|
|
189
|
+
add(name, "ERROR", "probe aborted before execution; path=<probe>; errno=ABORTED");
|
|
91
190
|
return;
|
|
92
191
|
}
|
|
93
192
|
try {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
add(
|
|
193
|
+
const result = await probe();
|
|
194
|
+
const outcome = typeof result === "boolean" ? { passed: result } : result;
|
|
195
|
+
add(
|
|
196
|
+
name,
|
|
197
|
+
outcome.passed ? "PASS" : "FAIL",
|
|
198
|
+
outcome.passed ? undefined : (outcome.detail ?? "assertion failed; path=<probe>; errno=unknown"),
|
|
199
|
+
);
|
|
200
|
+
} catch (error) {
|
|
201
|
+
const outcome = exceptionOutcome("probe threw", "<probe>", error, redactions);
|
|
202
|
+
add(name, "ERROR", outcome.detail);
|
|
97
203
|
}
|
|
98
204
|
};
|
|
99
205
|
|
|
100
206
|
try {
|
|
101
|
-
const
|
|
102
|
-
|
|
207
|
+
const inheritedWorkspace = process.env[SANDBOX_SESSION_ROOT_ENV];
|
|
208
|
+
const inheritedHome = process.env[SANDBOX_OPERATOR_HOME_ENV];
|
|
209
|
+
const inheritedSibling = process.env[SANDBOX_CHECK_NAMED_SIBLING_ENV];
|
|
210
|
+
const inheritedProfile = inheritedWorkspace !== undefined;
|
|
211
|
+
const workspaceInput = inheritedWorkspace ?? process.cwd();
|
|
212
|
+
const homeInput = inheritedHome ?? os.homedir();
|
|
213
|
+
redactions.push([workspaceInput, "<workspace>"], [homeInput, "<operator-home>"]);
|
|
214
|
+
// BashTool owns and canonicalises inherited values before applying Seatbelt/Landlock. Re-running
|
|
215
|
+
// realpath here can require metadata access that the live profile deliberately withholds from the
|
|
216
|
+
// session parent — which is the operator home for a `~/<workspace>` layout (#2807).
|
|
217
|
+
const liveWorkspace = inheritedWorkspace ?? (await fs.realpath(workspaceInput));
|
|
218
|
+
const liveHome = inheritedHome ?? (await fs.realpath(homeInput));
|
|
219
|
+
redactions.push([liveWorkspace, "<workspace>"], [liveHome, "<operator-home>"]);
|
|
220
|
+
if (inheritedSibling !== undefined) redactions.push([inheritedSibling, "<session-parent>/<synthetic-sibling>"]);
|
|
221
|
+
|
|
222
|
+
const fixtureBase = inheritedProfile ? liveWorkspace : await fs.realpath(os.tmpdir());
|
|
223
|
+
fixtureRoot = await fs.mkdtemp(path.join(fixtureBase, ".xcsh-sandbox-check-policy-"));
|
|
224
|
+
fixturePaths.push(fixtureRoot);
|
|
225
|
+
redactions.push([fixtureRoot, "<synthetic-root>"]);
|
|
226
|
+
|
|
103
227
|
const accountRoot = path.join(fixtureRoot, "Users");
|
|
104
228
|
const operatorHome = path.join(accountRoot, "operator");
|
|
105
229
|
const otherHome = path.join(accountRoot, "other-account");
|
|
@@ -165,39 +289,134 @@ export async function runSandboxCheck(options: SandboxCheckOptions = {}): Promis
|
|
|
165
289
|
cwd: workspace,
|
|
166
290
|
fence,
|
|
167
291
|
});
|
|
168
|
-
|
|
292
|
+
const passed = blocked.every(result => result.block) && !ownConfig.block;
|
|
293
|
+
return passed
|
|
294
|
+
? { passed: true }
|
|
295
|
+
: {
|
|
296
|
+
passed: false,
|
|
297
|
+
detail: "structured-tool policy disagreed with the shell boundary; path=<synthetic-root>; errno=none",
|
|
298
|
+
};
|
|
169
299
|
});
|
|
170
300
|
|
|
171
301
|
await check("workspace read, write, glob, and recursion", async () => {
|
|
302
|
+
const displayPath = "<workspace>/<synthetic-fixture>";
|
|
303
|
+
let liveFixture: string;
|
|
304
|
+
try {
|
|
305
|
+
liveFixture = await fs.mkdtemp(path.join(liveWorkspace, ".xcsh-sandbox-check-workspace-"));
|
|
306
|
+
fixturePaths.push(liveFixture);
|
|
307
|
+
redactions.push([liveFixture, displayPath]);
|
|
308
|
+
await fs.mkdir(path.join(liveFixture, "nested"));
|
|
309
|
+
await Bun.write(path.join(liveFixture, "own.txt"), "own\n");
|
|
310
|
+
} catch (error) {
|
|
311
|
+
return exceptionOutcome("create live workspace fixture", displayPath, error, redactions);
|
|
312
|
+
}
|
|
172
313
|
const command =
|
|
173
314
|
"cat own.txt > /dev/null && printf created > created.txt && " +
|
|
174
315
|
"printf '%s\\n' ./* > /dev/null && find . -type f > /dev/null";
|
|
175
|
-
|
|
316
|
+
const result = await shellProbe(command, liveFixture, undefined, abortController.signal);
|
|
317
|
+
return shellOutcome(
|
|
318
|
+
result,
|
|
319
|
+
true,
|
|
320
|
+
"live profile must allow workspace read, write, glob, and recursion",
|
|
321
|
+
displayPath,
|
|
322
|
+
redactions,
|
|
323
|
+
);
|
|
176
324
|
});
|
|
325
|
+
|
|
177
326
|
await check("named sibling remains reachable", async () => {
|
|
178
|
-
const
|
|
179
|
-
|
|
327
|
+
const displayPath = "<session-parent>/<synthetic-sibling>";
|
|
328
|
+
let liveSibling = inheritedSibling;
|
|
329
|
+
if (liveSibling === undefined) {
|
|
330
|
+
try {
|
|
331
|
+
liveSibling = await fs.mkdtemp(path.join(path.dirname(liveWorkspace), ".xcsh-sandbox-check-sibling-"));
|
|
332
|
+
fixturePaths.push(liveSibling);
|
|
333
|
+
nonEnumerableCleanupDirs.add(liveSibling);
|
|
334
|
+
redactions.push([liveSibling, displayPath]);
|
|
335
|
+
const namedFile = path.join(liveSibling, "named.txt");
|
|
336
|
+
await Bun.write(namedFile, "sibling\n");
|
|
337
|
+
knownCleanupLeaves.push(namedFile);
|
|
338
|
+
} catch (error) {
|
|
339
|
+
return exceptionOutcome("create named sibling fixture", displayPath, error, redactions);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
const result = await shellProbe(
|
|
343
|
+
'test "$(cat named.txt)" = sibling',
|
|
344
|
+
liveSibling,
|
|
345
|
+
undefined,
|
|
346
|
+
abortController.signal,
|
|
347
|
+
);
|
|
348
|
+
return shellOutcome(result, true, "live profile must allow a named sibling read", displayPath, redactions);
|
|
180
349
|
});
|
|
181
350
|
|
|
182
351
|
if (backend.osEnforced) {
|
|
183
352
|
await check("session parent cannot be enumerated", async () => {
|
|
184
|
-
const
|
|
185
|
-
|
|
353
|
+
const result = await shellProbe(
|
|
354
|
+
`ls ${quote(workspaces)} > /dev/null`,
|
|
355
|
+
workspace,
|
|
356
|
+
fence,
|
|
357
|
+
abortController.signal,
|
|
358
|
+
);
|
|
359
|
+
return shellOutcome(
|
|
360
|
+
result,
|
|
361
|
+
false,
|
|
362
|
+
"synthetic session parent enumeration must be refused",
|
|
363
|
+
"<synthetic-session-parent>",
|
|
364
|
+
redactions,
|
|
365
|
+
);
|
|
186
366
|
});
|
|
187
367
|
await check("account container cannot be enumerated", async () => {
|
|
188
|
-
const
|
|
189
|
-
|
|
368
|
+
const result = await shellProbe(
|
|
369
|
+
`ls ${quote(accountRoot)} > /dev/null`,
|
|
370
|
+
workspace,
|
|
371
|
+
fence,
|
|
372
|
+
abortController.signal,
|
|
373
|
+
);
|
|
374
|
+
return shellOutcome(
|
|
375
|
+
result,
|
|
376
|
+
false,
|
|
377
|
+
"synthetic account container enumeration must be refused",
|
|
378
|
+
"<synthetic-account-container>",
|
|
379
|
+
redactions,
|
|
380
|
+
);
|
|
190
381
|
});
|
|
191
382
|
await check("synthetic other account cannot be entered", async () => {
|
|
192
|
-
const
|
|
193
|
-
return (
|
|
383
|
+
const result = await shellProbe(`cd ${quote(otherHome)}`, workspace, fence, abortController.signal);
|
|
384
|
+
return shellOutcome(
|
|
385
|
+
result,
|
|
386
|
+
false,
|
|
387
|
+
"synthetic other account traversal must be refused",
|
|
388
|
+
"<synthetic-other-account>",
|
|
389
|
+
redactions,
|
|
390
|
+
);
|
|
194
391
|
});
|
|
195
392
|
await check("cross-session stores cannot be read", async () => {
|
|
196
|
-
const sessionRead =
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
393
|
+
const sessionRead = await shellProbe(
|
|
394
|
+
`cat ${quote(path.join(otherSession, "state.jsonl"))} > /dev/null`,
|
|
395
|
+
workspace,
|
|
396
|
+
fence,
|
|
397
|
+
abortController.signal,
|
|
398
|
+
);
|
|
399
|
+
const sessionOutcome = shellOutcome(
|
|
400
|
+
sessionRead,
|
|
401
|
+
false,
|
|
402
|
+
"synthetic other session read must be refused",
|
|
403
|
+
"<synthetic-session-store>/<synthetic-session>",
|
|
404
|
+
redactions,
|
|
405
|
+
);
|
|
406
|
+
if (!sessionOutcome.passed) return sessionOutcome;
|
|
407
|
+
|
|
408
|
+
const memoryRead = await shellProbe(
|
|
409
|
+
`cat ${quote(path.join(otherMemory, "MEMORY.md"))} > /dev/null`,
|
|
410
|
+
workspace,
|
|
411
|
+
fence,
|
|
412
|
+
abortController.signal,
|
|
413
|
+
);
|
|
414
|
+
return shellOutcome(
|
|
415
|
+
memoryRead,
|
|
416
|
+
false,
|
|
417
|
+
"synthetic other memory read must be refused",
|
|
418
|
+
"<synthetic-memory-store>/<synthetic-memory>",
|
|
419
|
+
redactions,
|
|
201
420
|
);
|
|
202
421
|
});
|
|
203
422
|
} else {
|
|
@@ -207,14 +426,39 @@ export async function runSandboxCheck(options: SandboxCheckOptions = {}): Promis
|
|
|
207
426
|
"synthetic other account cannot be entered",
|
|
208
427
|
"cross-session stores cannot be read",
|
|
209
428
|
]) {
|
|
210
|
-
add(name, "SKIP");
|
|
429
|
+
add(name, "SKIP", "OS enforcement backend unavailable; path=<probe>; errno=unsupported");
|
|
211
430
|
}
|
|
212
431
|
}
|
|
213
432
|
|
|
214
433
|
await check("operator home configuration is writable", async () => {
|
|
215
|
-
const
|
|
216
|
-
|
|
217
|
-
|
|
434
|
+
const displayPath = "<operator-home>/<synthetic-fixture>";
|
|
435
|
+
let liveConfig: string;
|
|
436
|
+
try {
|
|
437
|
+
// Landlock cannot grant creation on a split directory without also granting every
|
|
438
|
+
// denied descendant. The live fence therefore grants operator-owned CLI config roots
|
|
439
|
+
// explicitly; use one of those when another Landlock profile is already inherited.
|
|
440
|
+
// Standalone and Seatbelt checks retain the direct-home probe.
|
|
441
|
+
const configBase =
|
|
442
|
+
inheritedProfile && backend.backend === "landlock" ? path.join(liveHome, ".config", "gh") : liveHome;
|
|
443
|
+
liveConfig = await fs.mkdtemp(path.join(configBase, ".xcsh-sandbox-check-home-"));
|
|
444
|
+
fixturePaths.push(liveConfig);
|
|
445
|
+
redactions.push([liveConfig, displayPath]);
|
|
446
|
+
} catch (error) {
|
|
447
|
+
return exceptionOutcome("create operator-home fixture", displayPath, error, redactions);
|
|
448
|
+
}
|
|
449
|
+
const result = await shellProbe(
|
|
450
|
+
'printf operator > config && test "$(cat config)" = operator',
|
|
451
|
+
liveConfig,
|
|
452
|
+
undefined,
|
|
453
|
+
abortController.signal,
|
|
454
|
+
);
|
|
455
|
+
return shellOutcome(
|
|
456
|
+
result,
|
|
457
|
+
true,
|
|
458
|
+
"live profile must allow operator-home configuration writes",
|
|
459
|
+
displayPath,
|
|
460
|
+
redactions,
|
|
461
|
+
);
|
|
218
462
|
});
|
|
219
463
|
|
|
220
464
|
await check("cwd resets across tool calls", async () => {
|
|
@@ -248,24 +492,57 @@ export async function runSandboxCheck(options: SandboxCheckOptions = {}): Promis
|
|
|
248
492
|
.map(part => part.text)
|
|
249
493
|
.join("")
|
|
250
494
|
.trim();
|
|
251
|
-
|
|
495
|
+
const passed = text(moved) === nested && text(reset) === workspace && text(explicit) === nested;
|
|
496
|
+
return passed
|
|
497
|
+
? { passed: true }
|
|
498
|
+
: {
|
|
499
|
+
passed: false,
|
|
500
|
+
detail:
|
|
501
|
+
"tool-call cwd did not reset to <synthetic-workspace> or honor explicit cwd; path=<synthetic-root>; errno=none",
|
|
502
|
+
};
|
|
252
503
|
});
|
|
253
|
-
} catch {
|
|
254
|
-
|
|
504
|
+
} catch (error) {
|
|
505
|
+
const outcome = exceptionOutcome("conformance matrix setup failed", "<probe>", error, redactions);
|
|
506
|
+
add("conformance matrix setup", "ERROR", outcome.detail);
|
|
255
507
|
} finally {
|
|
256
508
|
process.off("SIGINT", interrupt);
|
|
257
509
|
process.off("SIGTERM", interrupt);
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
} else {
|
|
510
|
+
const cleanupFailures: string[] = [];
|
|
511
|
+
for (const leafPath of [...knownCleanupLeaves].reverse()) {
|
|
261
512
|
try {
|
|
262
|
-
await fs.rm(
|
|
263
|
-
await fs.stat(fixtureRoot);
|
|
264
|
-
add("synthetic fixtures removed", "FAIL");
|
|
513
|
+
await fs.rm(leafPath, { force: true });
|
|
265
514
|
} catch (error) {
|
|
266
|
-
|
|
515
|
+
if (!isEnoent(error)) cleanupFailures.push(error instanceof Error ? error.message : String(error));
|
|
267
516
|
}
|
|
268
517
|
}
|
|
518
|
+
for (const fixturePath of [...fixturePaths].reverse()) {
|
|
519
|
+
try {
|
|
520
|
+
// Landlock denies enumeration of the session parent. A sibling created after the
|
|
521
|
+
// profile snapshot consequently cannot be walked during recursive cleanup, even
|
|
522
|
+
// though its known leaf and the directory itself can be removed by name.
|
|
523
|
+
if (nonEnumerableCleanupDirs.has(fixturePath)) await fs.rmdir(fixturePath);
|
|
524
|
+
else await fs.rm(fixturePath, { recursive: true, force: true });
|
|
525
|
+
await fs.stat(fixturePath);
|
|
526
|
+
cleanupFailures.push(`fixture remains at ${fixturePath}`);
|
|
527
|
+
} catch (error) {
|
|
528
|
+
if (!isEnoent(error)) cleanupFailures.push(error instanceof Error ? error.message : String(error));
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
if (cleanupFailures.length > 0) {
|
|
533
|
+
add(
|
|
534
|
+
"synthetic fixtures removed",
|
|
535
|
+
"ERROR",
|
|
536
|
+
sanitizeDetail(
|
|
537
|
+
`fixture cleanup incomplete; path=<synthetic-fixtures>; errno=unknown; error=${cleanupFailures.join("; ")}`,
|
|
538
|
+
redactions,
|
|
539
|
+
),
|
|
540
|
+
);
|
|
541
|
+
} else if (fixturePaths.length === 0) {
|
|
542
|
+
add("synthetic fixtures removed", "SKIP", "setup created no fixtures; path=<synthetic-fixtures>; errno=none");
|
|
543
|
+
} else {
|
|
544
|
+
add("synthetic fixtures removed", "PASS");
|
|
545
|
+
}
|
|
269
546
|
}
|
|
270
547
|
|
|
271
548
|
const report: SandboxCheckReport = {
|
|
@@ -275,9 +552,10 @@ export async function runSandboxCheck(options: SandboxCheckOptions = {}): Promis
|
|
|
275
552
|
summary: {
|
|
276
553
|
passed: checks.filter(result => result.status === "PASS").length,
|
|
277
554
|
failed: checks.filter(result => result.status === "FAIL").length,
|
|
555
|
+
errors: checks.filter(result => result.status === "ERROR").length,
|
|
278
556
|
skipped: checks.filter(result => result.status === "SKIP").length,
|
|
279
557
|
},
|
|
280
558
|
};
|
|
281
|
-
renderReport(report, options.json ?? false);
|
|
559
|
+
renderReport(report, options.json ?? false, options.verbose ?? false);
|
|
282
560
|
return report;
|
|
283
561
|
}
|
package/src/commands/sandbox.ts
CHANGED
|
@@ -15,11 +15,12 @@ export default class Sandbox extends Command {
|
|
|
15
15
|
|
|
16
16
|
static flags = {
|
|
17
17
|
json: Flags.boolean({ description: "Output JSON" }),
|
|
18
|
+
verbose: Flags.boolean({ char: "v", description: "Show failure details" }),
|
|
18
19
|
};
|
|
19
20
|
|
|
20
21
|
async run(): Promise<void> {
|
|
21
22
|
const { flags } = await this.parse(Sandbox);
|
|
22
|
-
const report = await runSandboxCheck({ json: flags.json });
|
|
23
|
-
if (report.summary.failed > 0) process.exitCode = 1;
|
|
23
|
+
const report = await runSandboxCheck({ json: flags.json, verbose: flags.verbose });
|
|
24
|
+
if (report.summary.failed > 0 || report.summary.errors > 0) process.exitCode = 1;
|
|
24
25
|
}
|
|
25
26
|
}
|
|
@@ -17,17 +17,17 @@ export interface BuildInfo {
|
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
export const BUILD_INFO: BuildInfo = {
|
|
20
|
-
"version": "20.1.
|
|
21
|
-
"commit": "
|
|
22
|
-
"shortCommit": "
|
|
20
|
+
"version": "20.1.2",
|
|
21
|
+
"commit": "b5534d98c8ad483cc6e8709b44242cf3ea98041d",
|
|
22
|
+
"shortCommit": "b5534d9",
|
|
23
23
|
"branch": "main",
|
|
24
|
-
"tag": "v20.1.
|
|
25
|
-
"commitDate": "2026-08-
|
|
26
|
-
"buildDate": "2026-08-
|
|
24
|
+
"tag": "v20.1.2",
|
|
25
|
+
"commitDate": "2026-08-01T21:09:01Z",
|
|
26
|
+
"buildDate": "2026-08-01T21:30:18.970Z",
|
|
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.1.
|
|
31
|
+
"commitUrl": "https://github.com/f5-sales-demo/xcsh/commit/b5534d98c8ad483cc6e8709b44242cf3ea98041d",
|
|
32
|
+
"releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v20.1.2"
|
|
33
33
|
};
|
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
import type { ConsoleCatalogData } from "./console-catalog-types";
|
|
4
4
|
|
|
5
|
-
export const CONSOLE_CATALOG_VERSION = "
|
|
5
|
+
export const CONSOLE_CATALOG_VERSION = "ea6ed666f67f016f68f4e538d1f22e44e18d11a2";
|
|
6
6
|
|
|
7
7
|
export const CONSOLE_CATALOG_DATA: ConsoleCatalogData = {
|
|
8
|
-
version: "
|
|
8
|
+
version: "ea6ed666f67f016f68f4e538d1f22e44e18d11a2",
|
|
9
9
|
workflows: {
|
|
10
10
|
"address-allocator/create":
|
|
11
11
|
'---\nschema: urn:xcsh:console:workflow:v1\nid: address-allocator-create\nlabel: Create IP Address Allocators\nresource: address-allocator\noperation: create\npreconditions:\n - user_logged_in\n - "role_minimum: admin"\nparams:\n name:\n required: true\n description: IP Address Allocators name (lowercase alphanumeric and hyphens)\n example: example-address-allocator\n address_allocator_mode:\n required: true\n description: Address Allocator Mode\n allocation_unit:\n required: false\n description: Allocation Unit\n default: 0\n address_pool:\n required: false\n description: Address Pool\n default: value\n address_allocation_scheme:\n required: false\n description: "Server-required: Field should be not nil"\n default: value\nsteps:\n - id: navigate-to-list\n action: navigate\n url: /web/workspaces/multi-cloud-network-connect/manage/networking/legacy_network_configuration/address_allocators\n wait_for: text(\'IP Address Allocators\')\n description: Navigate to IP Address Allocators list page\n - id: click-add-tab\n action: click\n selector: text(\'Add IP Address Allocator\')\n wait_for: textbox[name=\'Name\']\n description: Click Add IP Address Allocator to open the create form\n - id: fill-name\n action: fill\n selector: textbox[name=\'Name\']\n value: "{name}"\n description: Enter Name\n - id: select-address_allocator_mode\n action: select\n selector: listbox\n context: Address Allocator Mode section\n value: "{address_allocator_mode}"\n description: Select Address Allocator Mode\n - id: fill-allocation_unit\n action: fill\n selector: spinbutton[name=\'Allocation Unit\']\n value: "{allocation_unit}"\n description: Set Allocation Unit\n - id: fill-address_pool\n action: fill\n selector: ngx-datatable input.form-control\n context: Address Pool table\n value: "{address_pool}"\n description: Enter Address Pool in the existing table row (no Add Item needed — the table ships one empty row)\n - id: select-address_allocation_scheme\n action: select\n selector: listbox\n context: Address Allocation Scheme section\n value: "{address_allocation_scheme}"\n description: Select Address Allocation Scheme\n - id: save\n action: click\n selector: "[class*=\'save-bt\'],[class*=\'submit-button\']"\n context: footer\n wait_for: text(\'{name}\')\n wait_timeout_ms: 30000\n description: Save/submit the form (union selector matches save-bt OR submit-button)\npostconditions:\n - resource_list_page_visible\n - "resource_name_in_list: {name}"\nmetadata:\n confidence: inferred\n discovered_at: 2026-06-24\n console_version: "2025.06"\n notes: Auto-generated by scripts/generate-workflows.ts from api-specs-enriched field metadata.\n',
|
|
@@ -978,8 +978,6 @@ export const CONSOLE_CATALOG_DATA: ConsoleCatalogData = {
|
|
|
978
978
|
"---\nschema: urn:xcsh:console:resource:v1\nid: securemesh-site\nlabel: Secure Mesh Sites\n_source: Generated from api-specs-enriched/config/console_ui.yaml\napi:\n kind: securemesh_site\nconsole:\n workspace: multi-cloud-network-connect\n workspace_label: Multi-Cloud Network Connect\n route_prefix: /web/workspaces/multi-cloud-network-connect\n route_pattern: /manage/site_management/legacy_configs/securemesh_site\n menu_path:\n - Manage\n - Site Management\n - Legacy Configurations\n - Secure Mesh Sites\n breadcrumbs:\n - Home\n - Multi-Cloud Network Connect\n - Manage\n - Site Management\n - Legacy Configurations\n - Secure Mesh Sites\n namespace_scoped: false\nadd_action:\n type: tab\n label: Add Secure Mesh Site\nsave_action:\n label: Add Secure Mesh Site\ncancel_action:\n label: Cancel All\nform_tabs:\n - Form\n - Documentation\n - JSON\nform:\n sections:\n - id: metadata\n label: Metadata\n api_fields:\n - metadata.name\n - metadata.labels\n - metadata.description\nenriched_fields:\n count: 25\n source: api-specs-enriched/config/console_field_metadata.yaml\nmetadata:\n confidence: validated\n discovered_at: '2026-06-16'\n console_version: '2025.06'\n",
|
|
979
979
|
"securemesh-site-v2":
|
|
980
980
|
"---\nschema: urn:xcsh:console:resource:v1\nid: securemesh-site-v2\nlabel: Secure Mesh Sites v2\n_source: Generated from api-specs-enriched/config/console_ui.yaml\napi:\n kind: securemesh_site_v2\nconsole:\n workspace: multi-cloud-network-connect\n workspace_label: Multi-Cloud Network Connect\n route_prefix: /web/workspaces/multi-cloud-network-connect\n route_pattern: /manage/site_management/cloud_sites/securemesh_site_v2\n menu_path:\n - Manage\n - Site Management\n - Customer Edges\n - Secure Mesh Sites v2\n breadcrumbs:\n - Home\n - Multi-Cloud Network Connect\n - Manage\n - Site Management\n - Customer Edges\n - Secure Mesh Sites v2\n namespace_scoped: false\nadd_action:\n type: tab\n label: Add Secure Mesh Site v2\nsave_action:\n label: Add Secure Mesh Site v2\ncancel_action:\n label: Cancel All\nform_tabs:\n - Form\n - Documentation\n - JSON\nform:\n sections:\n - id: metadata\n label: Metadata\n api_fields:\n - metadata.name\n - metadata.labels\n - metadata.description\nenriched_fields:\n count: 8\n source: api-specs-enriched/config/console_field_metadata.yaml\nmetadata:\n confidence: validated\n discovered_at: '2026-06-16'\n console_version: '2025.06'\n",
|
|
981
|
-
"securemesh-site-v2-cloud":
|
|
982
|
-
"---\nschema: urn:xcsh:console:resource:v1\nid: securemesh-site-v2-cloud\nlabel: Secure Mesh Sites v2\n_source: Generated from api-specs-enriched/config/console_ui.yaml\napi:\n kind: securemesh_site_v2_cloud\nconsole:\n workspace: multi-cloud-network-connect\n workspace_label: Multi-Cloud Network Connect\n route_prefix: /web/workspaces/multi-cloud-network-connect\n route_pattern: /manage/site_management/cloud_sites/securemesh_site_v2\n menu_path:\n - Manage\n - Site Management\n - Customer Edges\n - Secure Mesh Sites v2\n breadcrumbs:\n - Home\n - Multi-Cloud Network Connect\n - Manage\n - Site Management\n - Customer Edges\n - Secure Mesh Sites v2\n namespace_scoped: false\nadd_action:\n type: tab\n label: Add Secure Mesh Site\nsave_action:\n label: Add Secure Mesh Site\ncancel_action:\n label: Cancel All\nform_tabs:\n - Form\n - Documentation\n - JSON\nform:\n sections:\n - id: metadata\n label: Metadata\n api_fields:\n - metadata.name\n - metadata.labels\n - metadata.description\nmetadata:\n confidence: validated\n discovered_at: '2026-06-17'\n console_version: '2025.06'\n notes: This is the v2 cloud sites path — differs from securemesh_site_v2 which may have a different route\n",
|
|
983
981
|
segment:
|
|
984
982
|
"---\nschema: urn:xcsh:console:resource:v1\nid: segment\nlabel: Segments\n_source: Generated from api-specs-enriched/config/console_ui.yaml\napi:\n kind: segment\nconsole:\n workspace: multi-cloud-network-connect\n workspace_label: Multi-Cloud Network Connect\n route_prefix: /web/workspaces/multi-cloud-network-connect\n route_pattern: /manage/networking/network_configuration/segment\n menu_path:\n - Manage\n - Networking\n - Network Configuration\n - Segments\n breadcrumbs:\n - Home\n - Multi-Cloud Network Connect\n - Manage\n - Networking\n - Network Configuration\n - Segments\n namespace_scoped: false\nadd_action:\n type: tab\n label: Add Segment\nsave_action:\n label: Add Segment\ncancel_action:\n label: Cancel All\nform_tabs:\n - Form\n - Documentation\n - JSON\nform:\n sections:\n - id: metadata\n label: Metadata\n api_fields:\n - metadata.name\n - metadata.labels\n - metadata.description\nenriched_fields:\n count: 4\n source: api-specs-enriched/config/console_field_metadata.yaml\nmetadata:\n confidence: validated\n discovered_at: '2026-06-16'\n console_version: '2025.06'\n",
|
|
985
983
|
"segment-connection":
|
|
@@ -16,6 +16,18 @@
|
|
|
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
|
+
export const SANDBOX_CHECK_NAMED_SIBLING_ENV = "XCSH_SANDBOX_CHECK_NAMED_SIBLING";
|
|
30
|
+
|
|
19
31
|
/** The slice of `Settings` this needs — supplied explicitly so the caller names its own source. */
|
|
20
32
|
export interface SettingsReader {
|
|
21
33
|
get(key: string): unknown;
|
package/src/tools/bash.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
|
+
import * as os from "node:os";
|
|
3
|
+
import * as path from "node:path";
|
|
2
4
|
import type {
|
|
3
5
|
AgentTool,
|
|
4
6
|
AgentToolContext,
|
|
@@ -17,7 +19,12 @@ import { truncateToVisualLines } from "../modes/components/visual-truncate";
|
|
|
17
19
|
import type { Theme } from "../modes/theme/theme";
|
|
18
20
|
import bashDescription from "../prompts/tools/bash.md" with { type: "text" };
|
|
19
21
|
import { type ContainmentFence, containmentStatus, fenceVerdict } from "../sandbox/containment";
|
|
20
|
-
import {
|
|
22
|
+
import {
|
|
23
|
+
resolveSessionFence,
|
|
24
|
+
SANDBOX_CHECK_NAMED_SIBLING_ENV,
|
|
25
|
+
SANDBOX_OPERATOR_HOME_ENV,
|
|
26
|
+
SANDBOX_SESSION_ROOT_ENV,
|
|
27
|
+
} from "../sandbox/session-fence";
|
|
21
28
|
import { SECRET_ENV_PATTERNS, type SecretObfuscator } from "../secrets";
|
|
22
29
|
import { DEFAULT_MAX_BYTES, TailBuffer } from "../session/streaming-output";
|
|
23
30
|
import { renderStatusLine } from "../tui";
|
|
@@ -177,6 +184,21 @@ function normalizeBashEnv(env: Record<string, string> | undefined): Record<strin
|
|
|
177
184
|
return normalized;
|
|
178
185
|
}
|
|
179
186
|
|
|
187
|
+
/** Canonical context resolved by the unfenced host before a child inherits the live profile. */
|
|
188
|
+
async function canonicalizeSandboxContextPath(input: string): Promise<string> {
|
|
189
|
+
try {
|
|
190
|
+
return await fs.promises.realpath(input);
|
|
191
|
+
} catch {
|
|
192
|
+
// The fence builder already handles an unavailable home conservatively. Keep ordinary bash calls
|
|
193
|
+
// working in that case; the diagnostic will report the path-specific failure if it is invoked.
|
|
194
|
+
return input;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function invokesSandboxCheck(command: string): boolean {
|
|
199
|
+
return /(?:xcsh(?:-[a-z0-9-]+)?|cli\.ts)["']?\s+["']?sandbox["']?\s+["']?check["']?(?:\s|$)/iu.test(command);
|
|
200
|
+
}
|
|
201
|
+
|
|
180
202
|
function escapeBashEnvValueForDisplay(value: string): string {
|
|
181
203
|
return value
|
|
182
204
|
.replaceAll("\\", "\\\\")
|
|
@@ -557,11 +579,11 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
557
579
|
* RPC `bash` reach it too, and the same brush-core runs credential helpers and the interactive
|
|
558
580
|
* `xcsh shell`. Only the model's tool call is fenced (#2554).
|
|
559
581
|
*/
|
|
560
|
-
#containmentFence() {
|
|
582
|
+
#containmentFence(root = this.#containmentRoot()) {
|
|
561
583
|
const artifactsDir = this.session.getArtifactsDir?.();
|
|
562
584
|
// One resolver, shared with `sandbox-guard` and the internal-URL check, so the pre-check and the
|
|
563
585
|
// kernel cannot be looking at different boundaries (#2624).
|
|
564
|
-
return resolveSessionFence(
|
|
586
|
+
return resolveSessionFence(root, this.session.settings, {
|
|
565
587
|
extraRoots: artifactsDir ? [artifactsDir] : [],
|
|
566
588
|
});
|
|
567
589
|
}
|
|
@@ -583,7 +605,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
583
605
|
ctx?: AgentToolContext,
|
|
584
606
|
): Promise<AgentToolResult<BashToolDetails>> {
|
|
585
607
|
let command = rawCommand;
|
|
586
|
-
const
|
|
608
|
+
const requestedEnv = normalizeBashEnv(rawEnv);
|
|
587
609
|
|
|
588
610
|
// Extract leading `cd <path> && ...` into cwd when the model ignores the cwd parameter.
|
|
589
611
|
if (!cwd) {
|
|
@@ -627,7 +649,27 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
627
649
|
// different configurations — which doubled a cost that lands as user-visible latency. Sharing is
|
|
628
650
|
// also the more correct answer: the internal-URL check and the shell are then reasoning about the
|
|
629
651
|
// same boundary rather than two that merely agree today.
|
|
630
|
-
const
|
|
652
|
+
const containmentRoot = this.#containmentRoot();
|
|
653
|
+
const fence = this.#containmentFence(containmentRoot);
|
|
654
|
+
const sandboxCheckInvocation = fence !== undefined && invokesSandboxCheck(command);
|
|
655
|
+
// A nested `xcsh sandbox check` must exercise the grants of this exact live profile. Seatbelt and
|
|
656
|
+
// Landlock restrictions compose and cannot be relaxed by its subprocess, so the diagnostic needs
|
|
657
|
+
// the immutable session anchor even when this individual call uses `cwd` or starts with `cd`.
|
|
658
|
+
// Keep these values host-owned: tool-supplied env cannot replace them.
|
|
659
|
+
let env = requestedEnv;
|
|
660
|
+
let trustedSessionRoot = containmentRoot;
|
|
661
|
+
if (fence !== undefined) {
|
|
662
|
+
const [canonicalSessionRoot, trustedOperatorHome] = await Promise.all([
|
|
663
|
+
canonicalizeSandboxContextPath(containmentRoot),
|
|
664
|
+
canonicalizeSandboxContextPath(os.homedir()),
|
|
665
|
+
]);
|
|
666
|
+
trustedSessionRoot = canonicalSessionRoot;
|
|
667
|
+
env = {
|
|
668
|
+
...requestedEnv,
|
|
669
|
+
[SANDBOX_SESSION_ROOT_ENV]: trustedSessionRoot,
|
|
670
|
+
[SANDBOX_OPERATOR_HOME_ENV]: trustedOperatorHome,
|
|
671
|
+
};
|
|
672
|
+
}
|
|
631
673
|
|
|
632
674
|
const localOptions = {
|
|
633
675
|
getArtifactsDir: this.session.getArtifactsDir,
|
|
@@ -670,6 +712,9 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
670
712
|
const obfuscator = this.session.obfuscator;
|
|
671
713
|
_sessionObfuscator = obfuscator; // Keep module-level ref fresh for renderer
|
|
672
714
|
const maskSecrets = obfuscator?.hasSecrets() ? (t: string) => obfuscator.obfuscate(t) : undefined;
|
|
715
|
+
if (asyncRequested && sandboxCheckInvocation) {
|
|
716
|
+
throw new ToolError("Sandbox check must run synchronously so its synthetic fixtures can be removed.");
|
|
717
|
+
}
|
|
673
718
|
|
|
674
719
|
if (asyncRequested) {
|
|
675
720
|
if (!this.session.asyncJobManager) {
|
|
@@ -690,7 +735,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
690
735
|
return this.#buildBackgroundStartResult(job.jobId, job.label, "", timeoutSec);
|
|
691
736
|
}
|
|
692
737
|
|
|
693
|
-
if (this.#autoBackgroundEnabled && !pty && this.session.asyncJobManager) {
|
|
738
|
+
if (this.#autoBackgroundEnabled && !pty && this.session.asyncJobManager && !sandboxCheckInvocation) {
|
|
694
739
|
const autoBackgroundWaitMs = this.#resolveAutoBackgroundWaitMs(timeoutMs);
|
|
695
740
|
const startBackgrounded = autoBackgroundWaitMs === 0;
|
|
696
741
|
const job = this.#startManagedBashJob({
|
|
@@ -748,41 +793,60 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
748
793
|
const osBackend = containmentStatus(fence !== undefined);
|
|
749
794
|
const ptyConfinable = !osBackend.osEnforced || osBackend.backend === "seatbelt";
|
|
750
795
|
const usePty = pty && ptyConfinable && $env.PI_NO_PTY !== "1" && ctx?.hasUI === true && ctx.ui !== undefined;
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
796
|
+
let sandboxCheckSibling: string | undefined;
|
|
797
|
+
if (sandboxCheckInvocation) {
|
|
798
|
+
// Landlock cannot add a newly-created child to an already-applied profile. Prepare the named
|
|
799
|
+
// sibling before the child is confined so the diagnostic measures reachability, not whether a
|
|
800
|
+
// nested sandbox can widen itself. The host owns both this path and its cleanup.
|
|
801
|
+
sandboxCheckSibling = await fs.promises.mkdtemp(
|
|
802
|
+
path.join(path.dirname(trustedSessionRoot), ".xcsh-sandbox-check-live-sibling-"),
|
|
803
|
+
);
|
|
804
|
+
await Bun.write(path.join(sandboxCheckSibling, "named.txt"), "sibling\n");
|
|
805
|
+
env = { ...env, [SANDBOX_CHECK_NAMED_SIBLING_ENV]: sandboxCheckSibling };
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
let result: BashResult | BashInteractiveResult;
|
|
809
|
+
try {
|
|
810
|
+
result = usePty
|
|
811
|
+
? await runInteractiveBashPty(ctx.ui!, {
|
|
812
|
+
command,
|
|
813
|
+
cwd: commandCwd,
|
|
814
|
+
timeoutMs,
|
|
815
|
+
signal,
|
|
816
|
+
env,
|
|
817
|
+
artifactPath,
|
|
818
|
+
artifactId,
|
|
819
|
+
maskSecrets,
|
|
820
|
+
// The same fence that decided `ptyConfinable`, so the gate and the enforcement can
|
|
821
|
+
// never be looking at different answers.
|
|
822
|
+
fence,
|
|
823
|
+
})
|
|
824
|
+
: await executeBash(command, {
|
|
825
|
+
cwd: commandCwd,
|
|
826
|
+
sessionKey: this.session.getSessionId?.() ?? undefined,
|
|
827
|
+
timeout: timeoutMs,
|
|
828
|
+
signal,
|
|
829
|
+
env,
|
|
830
|
+
artifactPath,
|
|
831
|
+
artifactId,
|
|
832
|
+
maskSecrets,
|
|
833
|
+
fence,
|
|
834
|
+
onChunk: chunk => {
|
|
835
|
+
tailBuffer.append(chunk);
|
|
836
|
+
if (onUpdate) {
|
|
837
|
+
const preview = maskSecrets ? maskSecrets(tailBuffer.text()) : tailBuffer.text();
|
|
838
|
+
onUpdate({
|
|
839
|
+
content: [{ type: "text", text: preview }],
|
|
840
|
+
details: {},
|
|
841
|
+
});
|
|
842
|
+
}
|
|
843
|
+
},
|
|
844
|
+
});
|
|
845
|
+
} finally {
|
|
846
|
+
if (sandboxCheckSibling !== undefined) {
|
|
847
|
+
await fs.promises.rm(sandboxCheckSibling, { recursive: true, force: true });
|
|
848
|
+
}
|
|
849
|
+
}
|
|
786
850
|
if (result.cancelled) {
|
|
787
851
|
if (signal?.aborted) {
|
|
788
852
|
throw new ToolAbortError(normalizeResultOutput(result) || "Command aborted");
|