@f5-sales-demo/xcsh 19.101.0 → 19.101.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/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@f5-sales-demo/xcsh",
|
|
4
|
-
"version": "19.101.
|
|
4
|
+
"version": "19.101.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": "19.101.
|
|
61
|
-
"@f5-sales-demo/pi-agent-core": "19.101.
|
|
62
|
-
"@f5-sales-demo/pi-ai": "19.101.
|
|
63
|
-
"@f5-sales-demo/pi-natives": "19.101.
|
|
64
|
-
"@f5-sales-demo/pi-resource-management": "19.101.
|
|
65
|
-
"@f5-sales-demo/pi-tui": "19.101.
|
|
66
|
-
"@f5-sales-demo/pi-utils": "19.101.
|
|
60
|
+
"@f5-sales-demo/xcsh-stats": "19.101.2",
|
|
61
|
+
"@f5-sales-demo/pi-agent-core": "19.101.2",
|
|
62
|
+
"@f5-sales-demo/pi-ai": "19.101.2",
|
|
63
|
+
"@f5-sales-demo/pi-natives": "19.101.2",
|
|
64
|
+
"@f5-sales-demo/pi-resource-management": "19.101.2",
|
|
65
|
+
"@f5-sales-demo/pi-tui": "19.101.2",
|
|
66
|
+
"@f5-sales-demo/pi-utils": "19.101.2",
|
|
67
67
|
"@sinclair/typebox": "^0.34",
|
|
68
68
|
"@xterm/headless": "^6.0",
|
|
69
69
|
"ajv": "^8.20",
|
|
@@ -20,55 +20,182 @@ const here = import.meta.dir; // packages/coding-agent/scripts
|
|
|
20
20
|
const vendoredPath = path.resolve(here, "../src/browser/capabilities.json");
|
|
21
21
|
const generatedPath = path.resolve(here, "../src/browser/capabilities.generated.ts");
|
|
22
22
|
|
|
23
|
-
//
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
23
|
+
// Guarded so the regression checks below can be imported by tests without regenerating files.
|
|
24
|
+
// Importing this module used to run the whole generator as a side effect (#2578).
|
|
25
|
+
if (import.meta.main) {
|
|
26
|
+
// Co-build refresh: adopt the sibling extension's freshly-built contract — but only when it really is
|
|
27
|
+
// fresher. "The directory exists" is not evidence of that, and treating it as evidence silently rewrote a
|
|
28
|
+
// committed contract from whatever happened to be on the developer's disk (#2578).
|
|
29
|
+
//
|
|
30
|
+
// Measured on a real machine: a sibling checkout left at contractVersion 1.8.0 overwrote the committed
|
|
31
|
+
// 1.12.0 and deleted the whole `handshake` feature block, on every `bun run check`, `bun test` and
|
|
32
|
+
// `bun run build`. `release.ts` runs `git commit -a`, so the downgrade was one release away from shipping.
|
|
33
|
+
// The guard applies to the path found by *proximity*, which is the accidental one. An explicitly set
|
|
34
|
+
// XCSH_EXTENSION_CAPABILITIES is a deliberate statement about which manifest to compile in — including a
|
|
35
|
+
// major-version co-build that removes tools on purpose — so it is honoured and merely announced.
|
|
36
|
+
const explicitPath = process.env.XCSH_EXTENSION_CAPABILITIES;
|
|
37
|
+
const siblingPath = explicitPath ?? path.resolve(here, "../../../../xcsh-chrome-extension/capabilities.json");
|
|
38
|
+
if (explicitPath !== undefined && fs.existsSync(explicitPath) && path.resolve(explicitPath) !== vendoredPath) {
|
|
39
|
+
console.log(`Adopting ${explicitPath} (XCSH_EXTENSION_CAPABILITIES set explicitly).`);
|
|
40
|
+
fs.copyFileSync(explicitPath, vendoredPath);
|
|
41
|
+
} else if (explicitPath === undefined && fs.existsSync(siblingPath) && path.resolve(siblingPath) !== vendoredPath) {
|
|
42
|
+
adoptSiblingIfNotARegression(siblingPath, vendoredPath);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const manifest = JSON.parse(fs.readFileSync(vendoredPath, "utf8")) as {
|
|
46
|
+
contractVersion: string;
|
|
47
|
+
tools: Array<{ name: string }>;
|
|
48
|
+
};
|
|
49
|
+
const toolNames = manifest.tools.map(t => t.name);
|
|
50
|
+
|
|
51
|
+
const output = [
|
|
52
|
+
"// Auto-generated by scripts/generate-extension-capabilities.ts - DO NOT EDIT",
|
|
53
|
+
"// Source: src/browser/capabilities.json (the Chrome extension's published contract).",
|
|
54
|
+
"",
|
|
55
|
+
"export interface ExtensionToolFlags {",
|
|
56
|
+
"\treadonly readOnly?: boolean;",
|
|
57
|
+
"\treadonly mutates?: boolean;",
|
|
58
|
+
"\treadonly requiresExplainMode?: boolean;",
|
|
59
|
+
"}",
|
|
60
|
+
"",
|
|
61
|
+
"export interface ExtensionToolDef {",
|
|
62
|
+
"\treadonly name: string;",
|
|
63
|
+
"\treadonly summary: string;",
|
|
64
|
+
"\treadonly category: string;",
|
|
65
|
+
"\treadonly params: Record<string, unknown>;",
|
|
66
|
+
"\treadonly flags?: ExtensionToolFlags;",
|
|
67
|
+
"}",
|
|
68
|
+
"",
|
|
69
|
+
"export interface ExtensionCapabilities {",
|
|
70
|
+
"\treadonly version: string;",
|
|
71
|
+
"\treadonly contractVersion: string;",
|
|
72
|
+
"\treadonly multiPortDiscovery?: boolean;",
|
|
73
|
+
"\treadonly protocol: string;",
|
|
74
|
+
"\treadonly tools: readonly ExtensionToolDef[];",
|
|
75
|
+
"\treadonly features: Record<string, unknown>;",
|
|
76
|
+
"}",
|
|
77
|
+
"",
|
|
78
|
+
`export const EXTENSION_CAPABILITIES: ExtensionCapabilities = ${JSON.stringify(manifest, null, "\t")};`,
|
|
79
|
+
"",
|
|
80
|
+
`export const EXTENSION_CONTRACT_VERSION = ${JSON.stringify(manifest.contractVersion)};`,
|
|
81
|
+
"",
|
|
82
|
+
`export const EXTENSION_TOOL_NAMES: readonly string[] = ${JSON.stringify(toolNames)};`,
|
|
83
|
+
"",
|
|
84
|
+
].join("\n");
|
|
85
|
+
|
|
86
|
+
await Bun.write(generatedPath, output);
|
|
87
|
+
console.log(
|
|
88
|
+
`Generated ${path.relative(process.cwd(), generatedPath)} (${toolNames.length} tools, contract ${manifest.contractVersion})`,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** A capability manifest, as far as the regression check needs to understand one. */
|
|
93
|
+
interface CapabilityManifest {
|
|
94
|
+
contractVersion?: string;
|
|
95
|
+
tools?: Array<{ name?: string }>;
|
|
96
|
+
features?: Record<string, unknown>;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Decide whether a sibling manifest may replace the committed one.
|
|
101
|
+
*
|
|
102
|
+
* Exported for tests. Returns the reason to refuse, or undefined when adoption is safe.
|
|
103
|
+
*
|
|
104
|
+
* Two independent guards, because they catch different mistakes:
|
|
105
|
+
* - a lower `contractVersion` is a stale checkout, the case that actually happened;
|
|
106
|
+
* - a missing tool or feature key at the same-or-higher version is a broken build, which a version
|
|
107
|
+
* comparison alone would wave through.
|
|
108
|
+
*/
|
|
109
|
+
export function regressionReason(sibling: CapabilityManifest, vendored: CapabilityManifest): string | undefined {
|
|
110
|
+
const siblingVersion = sibling.contractVersion ?? "";
|
|
111
|
+
const vendoredVersion = vendored.contractVersion ?? "";
|
|
112
|
+
const versionOrder = compareContractVersions(siblingVersion, vendoredVersion);
|
|
113
|
+
if (versionOrder < 0) {
|
|
114
|
+
return `contractVersion would go backwards, ${vendoredVersion} -> ${siblingVersion}`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const lostTools = missingKeys(
|
|
118
|
+
(vendored.tools ?? []).map(tool => tool.name ?? ""),
|
|
119
|
+
(sibling.tools ?? []).map(tool => tool.name ?? ""),
|
|
120
|
+
);
|
|
121
|
+
if (lostTools.length > 0) return `tools would be lost: ${lostTools.join(", ")}`;
|
|
122
|
+
|
|
123
|
+
const lostFeatures = missingKeys(Object.keys(vendored.features ?? {}), Object.keys(sibling.features ?? {}));
|
|
124
|
+
if (lostFeatures.length > 0) return `features would be lost: ${lostFeatures.join(", ")}`;
|
|
125
|
+
|
|
126
|
+
// Equal versions with different content mean one side changed without bumping, so the version says
|
|
127
|
+
// nothing about which is fresher. That is not hypothetical: a tool was once added while contractVersion
|
|
128
|
+
// stayed put, which is exactly how a stale same-version sibling could regress nested tool params or
|
|
129
|
+
// feature internals past a check that only compares names. Refuse and let the operator decide, via
|
|
130
|
+
// XCSH_EXTENSION_CAPABILITIES if they mean it.
|
|
131
|
+
if (versionOrder === 0 && !sameContent(sibling, vendored)) {
|
|
132
|
+
return `contractVersion is unchanged at ${vendoredVersion} but the content differs, so neither copy is provably fresher`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return undefined;
|
|
28
136
|
}
|
|
29
137
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
"
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
"
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
138
|
+
/**
|
|
139
|
+
* Whole-manifest equality, by canonical JSON.
|
|
140
|
+
*
|
|
141
|
+
* Deliberately not a deep field-by-field comparison: the question is only "are these the same document",
|
|
142
|
+
* and any difference at an equal version is enough to refuse.
|
|
143
|
+
*/
|
|
144
|
+
function sameContent(left: CapabilityManifest, right: CapabilityManifest): boolean {
|
|
145
|
+
return canonicalJson(left) === canonicalJson(right);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** JSON with object keys sorted, so key order alone does not read as a difference. */
|
|
149
|
+
function canonicalJson(value: unknown): string {
|
|
150
|
+
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
|
|
151
|
+
if (value !== null && typeof value === "object") {
|
|
152
|
+
const entries = Object.entries(value as Record<string, unknown>).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
153
|
+
return `{${entries.map(([key, entry]) => `${JSON.stringify(key)}:${canonicalJson(entry)}`).join(",")}}`;
|
|
154
|
+
}
|
|
155
|
+
return JSON.stringify(value) ?? "null";
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function missingKeys(expected: readonly string[], actual: readonly string[]): string[] {
|
|
159
|
+
const present = new Set(actual.filter(Boolean));
|
|
160
|
+
return expected.filter(key => key !== "" && !present.has(key));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Numeric-segment comparison. Enough for a dotted contract version, and no dependency. */
|
|
164
|
+
export function compareContractVersions(left: string, right: string): number {
|
|
165
|
+
const parse = (value: string): number[] => value.split(".").map(part => Number.parseInt(part, 10) || 0);
|
|
166
|
+
const a = parse(left);
|
|
167
|
+
const b = parse(right);
|
|
168
|
+
for (let index = 0; index < Math.max(a.length, b.length); index += 1) {
|
|
169
|
+
const diff = (a[index] ?? 0) - (b[index] ?? 0);
|
|
170
|
+
if (diff !== 0) return diff < 0 ? -1 : 1;
|
|
171
|
+
}
|
|
172
|
+
return 0;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function adoptSiblingIfNotARegression(from: string, to: string): void {
|
|
176
|
+
let sibling: CapabilityManifest;
|
|
177
|
+
let vendored: CapabilityManifest;
|
|
178
|
+
try {
|
|
179
|
+
sibling = JSON.parse(fs.readFileSync(from, "utf8")) as CapabilityManifest;
|
|
180
|
+
vendored = JSON.parse(fs.readFileSync(to, "utf8")) as CapabilityManifest;
|
|
181
|
+
} catch (error) {
|
|
182
|
+
// Unreadable input must not silently leave the committed copy in place either — say so.
|
|
183
|
+
console.warn(`Keeping the committed capabilities: could not compare with ${from} (${String(error)}).`);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const reason = regressionReason(sibling, vendored);
|
|
188
|
+
if (reason === undefined) {
|
|
189
|
+
fs.copyFileSync(from, to);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Refused rather than fatal: this generator runs from `check:types`, `test` and `build`, so failing
|
|
194
|
+
// here would block every command on any machine with a stale sibling checkout. Skipping keeps the
|
|
195
|
+
// committed contract — the correct one — and names what was ignored so it is fixable.
|
|
196
|
+
console.warn(
|
|
197
|
+
`Ignoring ${from}: ${reason}.\n` +
|
|
198
|
+
" The committed capabilities.json is being kept. Update or remove that checkout, or set\n" +
|
|
199
|
+
" XCSH_EXTENSION_CAPABILITIES to the manifest you actually want compiled in.",
|
|
200
|
+
);
|
|
201
|
+
}
|
|
@@ -17,17 +17,17 @@ export interface BuildInfo {
|
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
export const BUILD_INFO: BuildInfo = {
|
|
20
|
-
"version": "19.101.
|
|
21
|
-
"commit": "
|
|
22
|
-
"shortCommit": "
|
|
20
|
+
"version": "19.101.2",
|
|
21
|
+
"commit": "aa04beb39731933015b85ba67e1107c2c301ac01",
|
|
22
|
+
"shortCommit": "aa04beb",
|
|
23
23
|
"branch": "main",
|
|
24
|
-
"tag": "v19.101.
|
|
25
|
-
"commitDate": "2026-07-
|
|
26
|
-
"buildDate": "2026-07-
|
|
24
|
+
"tag": "v19.101.2",
|
|
25
|
+
"commitDate": "2026-07-30T03:09:56Z",
|
|
26
|
+
"buildDate": "2026-07-30T03:38:03.284Z",
|
|
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.101.
|
|
31
|
+
"commitUrl": "https://github.com/f5-sales-demo/xcsh/commit/aa04beb39731933015b85ba67e1107c2c301ac01",
|
|
32
|
+
"releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v19.101.2"
|
|
33
33
|
};
|
|
@@ -278,3 +278,380 @@ export function provenExemptWords(cmd: ShellSimpleCommand): ShellWord[] {
|
|
|
278
278
|
|
|
279
279
|
return exempt;
|
|
280
280
|
}
|
|
281
|
+
|
|
282
|
+
/** Flag-only options shared by the compressors. */
|
|
283
|
+
const GZIP_BOOLEAN_OPTIONS = [
|
|
284
|
+
"-1",
|
|
285
|
+
"-2",
|
|
286
|
+
"-3",
|
|
287
|
+
"-4",
|
|
288
|
+
"-5",
|
|
289
|
+
"-6",
|
|
290
|
+
"-7",
|
|
291
|
+
"-8",
|
|
292
|
+
"-9",
|
|
293
|
+
"-c",
|
|
294
|
+
"--stdout",
|
|
295
|
+
"-d",
|
|
296
|
+
"--decompress",
|
|
297
|
+
"-f",
|
|
298
|
+
"--force",
|
|
299
|
+
"-k",
|
|
300
|
+
"--keep",
|
|
301
|
+
"-q",
|
|
302
|
+
"--quiet",
|
|
303
|
+
"-r",
|
|
304
|
+
"--recursive",
|
|
305
|
+
"-t",
|
|
306
|
+
"--test",
|
|
307
|
+
"-v",
|
|
308
|
+
"--verbose",
|
|
309
|
+
] as const satisfies readonly string[];
|
|
310
|
+
|
|
311
|
+
/** curl options that consume a separate value, so it is not mistaken for a path. */
|
|
312
|
+
const CURL_VALUE_OPTIONS = [
|
|
313
|
+
"-H",
|
|
314
|
+
"--header",
|
|
315
|
+
"-X",
|
|
316
|
+
"--request",
|
|
317
|
+
"-d",
|
|
318
|
+
"--data",
|
|
319
|
+
"-u",
|
|
320
|
+
"--user",
|
|
321
|
+
"-A",
|
|
322
|
+
"--user-agent",
|
|
323
|
+
"--connect-timeout",
|
|
324
|
+
"--max-time",
|
|
325
|
+
"-m",
|
|
326
|
+
"--retry",
|
|
327
|
+
"-b",
|
|
328
|
+
"--cookie",
|
|
329
|
+
"-c",
|
|
330
|
+
"--cookie-jar",
|
|
331
|
+
] as const satisfies readonly string[];
|
|
332
|
+
|
|
333
|
+
/** cp's flag-only options; shared so the mv/install entries stay readable. */
|
|
334
|
+
const COPY_BOOLEAN_OPTIONS = [
|
|
335
|
+
"-a",
|
|
336
|
+
"--archive",
|
|
337
|
+
"-b",
|
|
338
|
+
"--backup",
|
|
339
|
+
"-d",
|
|
340
|
+
"-f",
|
|
341
|
+
"--force",
|
|
342
|
+
"-i",
|
|
343
|
+
"--interactive",
|
|
344
|
+
"-H",
|
|
345
|
+
"-l",
|
|
346
|
+
"--link",
|
|
347
|
+
"-L",
|
|
348
|
+
"--dereference",
|
|
349
|
+
"-n",
|
|
350
|
+
"--no-clobber",
|
|
351
|
+
"-P",
|
|
352
|
+
"--no-dereference",
|
|
353
|
+
"-p",
|
|
354
|
+
"-R",
|
|
355
|
+
"-r",
|
|
356
|
+
"--recursive",
|
|
357
|
+
"-s",
|
|
358
|
+
"--symbolic-link",
|
|
359
|
+
"-u",
|
|
360
|
+
"--update",
|
|
361
|
+
"-v",
|
|
362
|
+
"--verbose",
|
|
363
|
+
"-x",
|
|
364
|
+
"--one-file-system",
|
|
365
|
+
] as const satisfies readonly string[];
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Which operands a command *writes*, so the boundary check picks the right direction.
|
|
369
|
+
*
|
|
370
|
+
* The read/write split in `enforce.ts` is derived from shell redirections. A write the invoked program
|
|
371
|
+
* performs on one of its own operands — `tee FILE`, `dd of=FILE`, `cp SRC DST`, `sort -o FILE` — had no
|
|
372
|
+
* such signal and defaulted to a read check. Under an `allowRead`-only grant that check passes, so the
|
|
373
|
+
* write lands on a path shared for reading only; confirmed at the decision layer, where all four returned
|
|
374
|
+
* `block: false` (GHSA-q4hg). The same misclassification refused those commands against a *write-only*
|
|
375
|
+
* grant, which is the mirror-image false refusal.
|
|
376
|
+
*
|
|
377
|
+
* Adding to this table is monotonic. Marking an operand as a write that is really a read costs a false
|
|
378
|
+
* refusal, which is the direction the surrounding code already errs in; missing one leaves today's
|
|
379
|
+
* behaviour untouched. So it is safe to extend, and deliberately incomplete rather than guessed at:
|
|
380
|
+
* `tar -f` is absent because whether it reads or writes depends on the mode letters, and getting that
|
|
381
|
+
* wrong in the permissive direction is the bug being fixed.
|
|
382
|
+
*
|
|
383
|
+
* Same suppression rule as `provenExemptWords`: an option this model cannot parse exactly shifts operand
|
|
384
|
+
* positions, so anything unrecognized abandons the command rather than guessing at a slot.
|
|
385
|
+
*/
|
|
386
|
+
interface WriteOperandSpec {
|
|
387
|
+
/** Options taking a separate value, so it is not mistaken for a positional operand. */
|
|
388
|
+
valueOptions: readonly string[];
|
|
389
|
+
/** Options that are flags only. */
|
|
390
|
+
booleanOptions: readonly string[];
|
|
391
|
+
/** `--output=F` style options whose value is the written path. */
|
|
392
|
+
outputOptions?: readonly string[];
|
|
393
|
+
/** `of=F` style prefixes whose value is the written path. */
|
|
394
|
+
outputPrefixes?: readonly string[];
|
|
395
|
+
/** Every positional operand is written (`tee a b c`). */
|
|
396
|
+
writesAllPositional?: boolean;
|
|
397
|
+
/** The final positional operand is written (`cp src dst`). */
|
|
398
|
+
writesLastPositional?: boolean;
|
|
399
|
+
/** With this option present the destination moves into it, so positional slots stop being writes. */
|
|
400
|
+
destinationOption?: string;
|
|
401
|
+
/** Positional operands that are not paths at all — `chmod 644 f`, `chown me f`. */
|
|
402
|
+
skipLeadingPositional?: number;
|
|
403
|
+
/** Every positional is written once this option appears (`install -d a b c`). */
|
|
404
|
+
allPositionalOption?: string;
|
|
405
|
+
/** Sources are written too, because the command removes them (`mv`). */
|
|
406
|
+
writesSourcesToo?: boolean;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const WRITE_OPERAND_SPECS: Record<string, WriteOperandSpec> = {
|
|
410
|
+
// tee writes every file operand; it reads stdin, never the files.
|
|
411
|
+
tee: {
|
|
412
|
+
valueOptions: [],
|
|
413
|
+
booleanOptions: ["-a", "--append", "-i", "--ignore-interrupts", "-p"],
|
|
414
|
+
writesAllPositional: true,
|
|
415
|
+
},
|
|
416
|
+
// `of=` is the output file. `if=` stays a read, which is what the floor already gives it.
|
|
417
|
+
dd: { valueOptions: [], booleanOptions: [], outputPrefixes: ["of="] },
|
|
418
|
+
sort: {
|
|
419
|
+
valueOptions: ["-k", "-t", "-S", "-T", "--key", "--field-separator", "--buffer-size"],
|
|
420
|
+
booleanOptions: ["-b", "-d", "-f", "-g", "-i", "-M", "-h", "-n", "-R", "-r", "-u", "-V", "-z", "-c", "-s"],
|
|
421
|
+
outputOptions: ["-o", "--output"],
|
|
422
|
+
},
|
|
423
|
+
cp: {
|
|
424
|
+
valueOptions: ["-S", "--suffix", "-t", "--target-directory"],
|
|
425
|
+
booleanOptions: COPY_BOOLEAN_OPTIONS,
|
|
426
|
+
writesLastPositional: true,
|
|
427
|
+
destinationOption: "-t",
|
|
428
|
+
},
|
|
429
|
+
// `mv` also REMOVES its sources, so a source in a read-only root is a mutation of that root.
|
|
430
|
+
// Marking only the destination let `mv /shared/ctx/file .` delete from a read-allowed grant.
|
|
431
|
+
mv: {
|
|
432
|
+
valueOptions: ["-S", "--suffix", "-t", "--target-directory"],
|
|
433
|
+
booleanOptions: ["-f", "--force", "-i", "--interactive", "-n", "--no-clobber", "-v", "--verbose", "-u"],
|
|
434
|
+
writesLastPositional: true,
|
|
435
|
+
writesSourcesToo: true,
|
|
436
|
+
destinationOption: "-t",
|
|
437
|
+
},
|
|
438
|
+
install: {
|
|
439
|
+
valueOptions: ["-m", "--mode", "-o", "--owner", "-g", "--group", "-t", "--target-directory", "-S", "--suffix"],
|
|
440
|
+
booleanOptions: ["-b", "-c", "-C", "-d", "-D", "-p", "-s", "-v", "--verbose", "--backup"],
|
|
441
|
+
writesLastPositional: true,
|
|
442
|
+
destinationOption: "-t",
|
|
443
|
+
// `install -d a b c` creates every operand as a directory rather than copying into the last.
|
|
444
|
+
allPositionalOption: "-d",
|
|
445
|
+
},
|
|
446
|
+
// Plain mutators: every path operand is written. Their absence was the largest hole in the table —
|
|
447
|
+
// `rm` and `touch` against a read-only root are the obvious cases, and both were classified as reads.
|
|
448
|
+
rm: {
|
|
449
|
+
valueOptions: [],
|
|
450
|
+
booleanOptions: ["-f", "--force", "-i", "-I", "-r", "-R", "--recursive", "-d", "--dir", "-v", "--verbose"],
|
|
451
|
+
writesAllPositional: true,
|
|
452
|
+
},
|
|
453
|
+
rmdir: { valueOptions: [], booleanOptions: ["-p", "--parents", "-v", "--verbose"], writesAllPositional: true },
|
|
454
|
+
touch: {
|
|
455
|
+
valueOptions: ["-d", "--date", "-r", "--reference", "-t"],
|
|
456
|
+
booleanOptions: ["-a", "-c", "--no-create", "-f", "-h", "--no-dereference", "-m"],
|
|
457
|
+
writesAllPositional: true,
|
|
458
|
+
},
|
|
459
|
+
mkdir: {
|
|
460
|
+
valueOptions: ["-m", "--mode"],
|
|
461
|
+
booleanOptions: ["-p", "--parents", "-v", "--verbose"],
|
|
462
|
+
writesAllPositional: true,
|
|
463
|
+
},
|
|
464
|
+
ln: {
|
|
465
|
+
valueOptions: ["-S", "--suffix", "-t", "--target-directory"],
|
|
466
|
+
booleanOptions: ["-b", "-f", "--force", "-i", "-L", "-n", "-P", "-r", "-s", "--symbolic", "-v"],
|
|
467
|
+
writesLastPositional: true,
|
|
468
|
+
destinationOption: "-t",
|
|
469
|
+
},
|
|
470
|
+
shred: {
|
|
471
|
+
valueOptions: ["-n", "--iterations", "-s", "--size"],
|
|
472
|
+
booleanOptions: ["-f", "--force", "-u", "--remove", "-v", "--verbose", "-x", "-z", "--zero"],
|
|
473
|
+
writesAllPositional: true,
|
|
474
|
+
},
|
|
475
|
+
unlink: { valueOptions: [], booleanOptions: [], writesAllPositional: true },
|
|
476
|
+
// The first positional is a mode or an owner, not a path.
|
|
477
|
+
chmod: {
|
|
478
|
+
valueOptions: ["--reference"],
|
|
479
|
+
booleanOptions: ["-c", "-f", "-v", "-R", "--recursive", "--silent", "--changes", "--verbose"],
|
|
480
|
+
writesAllPositional: true,
|
|
481
|
+
skipLeadingPositional: 1,
|
|
482
|
+
},
|
|
483
|
+
chown: {
|
|
484
|
+
valueOptions: ["--reference", "--from"],
|
|
485
|
+
booleanOptions: ["-c", "-f", "-v", "-R", "--recursive", "-h", "--no-dereference", "--silent", "--changes"],
|
|
486
|
+
writesAllPositional: true,
|
|
487
|
+
skipLeadingPositional: 1,
|
|
488
|
+
},
|
|
489
|
+
// In-place editors and compressors REPLACE the file they are given.
|
|
490
|
+
//
|
|
491
|
+
// `sed -i` has famously ambiguous arity — GNU takes an attached suffix, BSD a separate one — so
|
|
492
|
+
// which positional is the script cannot be settled. Every literal positional is therefore marked
|
|
493
|
+
// written, which over-marks the script operand. That is the safe direction: a false refusal, not a
|
|
494
|
+
// permitted write. The marking only applies when `-i` is present at all.
|
|
495
|
+
sed: {
|
|
496
|
+
valueOptions: ["-e", "--expression", "-f", "--file", "-l", "--line-length"],
|
|
497
|
+
booleanOptions: ["-n", "--quiet", "--silent", "-E", "-r", "--regexp-extended", "-s", "-u", "-z", "--debug"],
|
|
498
|
+
writesAllPositional: false,
|
|
499
|
+
allPositionalOption: "-i",
|
|
500
|
+
},
|
|
501
|
+
gzip: { valueOptions: ["-S", "--suffix"], booleanOptions: GZIP_BOOLEAN_OPTIONS, writesAllPositional: true },
|
|
502
|
+
gunzip: { valueOptions: ["-S", "--suffix"], booleanOptions: GZIP_BOOLEAN_OPTIONS, writesAllPositional: true },
|
|
503
|
+
bzip2: { valueOptions: [], booleanOptions: GZIP_BOOLEAN_OPTIONS, writesAllPositional: true },
|
|
504
|
+
xz: { valueOptions: ["-T", "--threads"], booleanOptions: GZIP_BOOLEAN_OPTIONS, writesAllPositional: true },
|
|
505
|
+
// Downloaders name their output explicitly; the URL operand is not a path.
|
|
506
|
+
curl: {
|
|
507
|
+
valueOptions: CURL_VALUE_OPTIONS,
|
|
508
|
+
booleanOptions: ["-L", "--location", "-s", "--silent", "-S", "--show-error", "-f", "--fail", "-k", "-I", "-v"],
|
|
509
|
+
outputOptions: ["-o", "--output"],
|
|
510
|
+
},
|
|
511
|
+
wget: {
|
|
512
|
+
valueOptions: ["--user-agent", "--header", "-P", "--directory-prefix", "-T", "--timeout"],
|
|
513
|
+
booleanOptions: ["-q", "--quiet", "-c", "--continue", "-N", "--no-verbose", "-nv"],
|
|
514
|
+
outputOptions: ["-O", "--output-document"],
|
|
515
|
+
},
|
|
516
|
+
truncate: {
|
|
517
|
+
valueOptions: ["-s", "--size", "-r", "--reference"],
|
|
518
|
+
booleanOptions: ["-c", "--no-create", "-o", "--io-blocks"],
|
|
519
|
+
writesAllPositional: true,
|
|
520
|
+
},
|
|
521
|
+
};
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* Words of `cmd` the invoked command writes to.
|
|
525
|
+
*
|
|
526
|
+
* Empty whenever anything is uncertain — an unknown command, an unrecognized option, a non-literal word.
|
|
527
|
+
* A word returned here is checked against the write boundary instead of the read one.
|
|
528
|
+
*/
|
|
529
|
+
export function writtenOperandWords(cmd: ShellSimpleCommand): ShellWord[] {
|
|
530
|
+
if (cmd.name === undefined) return [];
|
|
531
|
+
const spec = WRITE_OPERAND_SPECS[cmd.name];
|
|
532
|
+
if (spec === undefined) return [];
|
|
533
|
+
|
|
534
|
+
const operandWords = cmd.words.slice(cmd.operandStart).filter(word => word.redirect === undefined);
|
|
535
|
+
|
|
536
|
+
// An unparsable option shifts every positional slot, so abandon rather than mark the wrong word.
|
|
537
|
+
// Everything after `--` is a positional, however much it looks like an option — otherwise a file
|
|
538
|
+
// literally named `-t` makes `cp -- -t /drop/secret out/` read as a target-directory option and
|
|
539
|
+
// relabels a source as a write target.
|
|
540
|
+
let sawEndOfOptions = false;
|
|
541
|
+
for (const word of operandWords) {
|
|
542
|
+
if (word.text === "--") {
|
|
543
|
+
sawEndOfOptions = true;
|
|
544
|
+
continue;
|
|
545
|
+
}
|
|
546
|
+
if (sawEndOfOptions) continue;
|
|
547
|
+
const option = optionName(word.text);
|
|
548
|
+
if (option === undefined) continue;
|
|
549
|
+
if (isOutputOption(spec, option) || spec.valueOptions.includes(option)) continue;
|
|
550
|
+
if (spec.booleanOptions.includes(option)) continue;
|
|
551
|
+
if (matchesAllPositionalOption(spec, option)) continue;
|
|
552
|
+
if (bundledBooleans(option, spec) !== undefined) continue;
|
|
553
|
+
return [];
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
const written: ShellWord[] = [];
|
|
557
|
+
const positional: ShellWord[] = [];
|
|
558
|
+
let destinationMoved = false;
|
|
559
|
+
let allPositionalWritten = spec.writesAllPositional ?? false;
|
|
560
|
+
let pastEndOfOptions = false;
|
|
561
|
+
|
|
562
|
+
for (let index = 0; index < operandWords.length; index += 1) {
|
|
563
|
+
const word = operandWords[index];
|
|
564
|
+
if (!pastEndOfOptions && word.text === "--") {
|
|
565
|
+
pastEndOfOptions = true;
|
|
566
|
+
continue;
|
|
567
|
+
}
|
|
568
|
+
const option = pastEndOfOptions ? undefined : optionName(word.text);
|
|
569
|
+
|
|
570
|
+
if (option !== undefined && matchesAllPositionalOption(spec, option)) {
|
|
571
|
+
// `install -d` stops copying and starts creating, so every operand becomes a written path.
|
|
572
|
+
allPositionalWritten = true;
|
|
573
|
+
continue;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
if (option !== undefined) {
|
|
577
|
+
const attached = word.text.includes("=");
|
|
578
|
+
if (isOutputOption(spec, option)) {
|
|
579
|
+
// `--output=F` carries its value; `-o F` takes the next word.
|
|
580
|
+
const target = attached ? valueAfterEquals(word) : operandWords[index + 1];
|
|
581
|
+
if (!attached) index += 1;
|
|
582
|
+
if (target?.literal) written.push(target);
|
|
583
|
+
continue;
|
|
584
|
+
}
|
|
585
|
+
if (spec.valueOptions.includes(option)) {
|
|
586
|
+
if (option === spec.destinationOption) destinationMoved = true;
|
|
587
|
+
if (!attached) {
|
|
588
|
+
const target = operandWords[index + 1];
|
|
589
|
+
index += 1;
|
|
590
|
+
if (target?.literal) written.push(target);
|
|
591
|
+
} else if (word.literal) {
|
|
592
|
+
written.push(word);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
continue;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// `of=PATH` and friends are positional in shape but name their own direction.
|
|
599
|
+
const prefix = spec.outputPrefixes?.find(candidate => word.text.startsWith(candidate));
|
|
600
|
+
if (prefix !== undefined) {
|
|
601
|
+
if (word.literal) written.push(word);
|
|
602
|
+
continue;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
positional.push(word);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
const paths = positional.slice(spec.skipLeadingPositional ?? 0);
|
|
609
|
+
if (allPositionalWritten) {
|
|
610
|
+
written.push(...paths.filter(word => word.literal));
|
|
611
|
+
} else if (spec.writesSourcesToo && spec.writesLastPositional && !destinationMoved && paths.length >= 2) {
|
|
612
|
+
// Destination AND sources: `mv` removes what it moves.
|
|
613
|
+
written.push(...paths.filter(word => word.literal));
|
|
614
|
+
} else if (spec.writesLastPositional && !destinationMoved && paths.length >= 2) {
|
|
615
|
+
// Only with a source present. A lone operand is `cp x` — an error, not a write to model.
|
|
616
|
+
const destination = paths[paths.length - 1];
|
|
617
|
+
if (destination.literal) written.push(destination);
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
return written;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* Split a bundled short option (`-ai`) into its letters when every one is a known boolean flag.
|
|
625
|
+
*
|
|
626
|
+
* Without this, `tee -ai /shared/ctx/x` hit the unrecognized-option path and abandoned the command —
|
|
627
|
+
* failing open to a read check on a write, which is the defect this module exists to fix.
|
|
628
|
+
*/
|
|
629
|
+
function bundledBooleans(option: string, spec: WriteOperandSpec): string[] | undefined {
|
|
630
|
+
if (!option.startsWith("-") || option.startsWith("--") || option.length <= 2) return undefined;
|
|
631
|
+
const letters = [...option.slice(1)].map(letter => `-${letter}`);
|
|
632
|
+
return letters.every(letter => spec.booleanOptions.includes(letter)) ? letters : undefined;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/**
|
|
636
|
+
* Whether an option turns every operand into a written path.
|
|
637
|
+
*
|
|
638
|
+
* Prefix-matched for the single-letter form, because GNU `sed` attaches the backup suffix to the flag:
|
|
639
|
+
* `-i.bak` is the same in-place edit as `-i`, and exact matching let that spelling through.
|
|
640
|
+
*/
|
|
641
|
+
function matchesAllPositionalOption(spec: WriteOperandSpec, option: string): boolean {
|
|
642
|
+
const target = spec.allPositionalOption;
|
|
643
|
+
if (target === undefined) return false;
|
|
644
|
+
if (option === target) return true;
|
|
645
|
+
return target.length === 2 && !target.startsWith("--") && option.startsWith(target);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
function isOutputOption(spec: WriteOperandSpec, option: string): boolean {
|
|
649
|
+
return spec.outputOptions?.includes(option) ?? false;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/** The value half of an `--option=value` word, as a span the caller can mark. */
|
|
653
|
+
function valueAfterEquals(word: ShellWord): ShellWord | undefined {
|
|
654
|
+
const equals = word.text.indexOf("=");
|
|
655
|
+
if (equals === -1) return undefined;
|
|
656
|
+
return { ...word, text: word.text.slice(equals + 1) };
|
|
657
|
+
}
|
package/src/sandbox/enforce.ts
CHANGED
|
@@ -44,7 +44,7 @@ import * as path from "node:path";
|
|
|
44
44
|
import { pathIsWithin } from "@f5-sales-demo/pi-utils";
|
|
45
45
|
import { expandPath, parseFindPattern, parseSearchPath, resolveToCwd, splitTopLevel } from "../tools/path-utils";
|
|
46
46
|
import { lexShellCommand, type ShellSimpleCommand } from "../tools/shell-lex";
|
|
47
|
-
import { provenExemptWords } from "./command-operands";
|
|
47
|
+
import { provenExemptWords, writtenOperandWords } from "./command-operands";
|
|
48
48
|
import type { SandboxAccess, SandboxPolicy } from "./policy";
|
|
49
49
|
|
|
50
50
|
export interface ToolCallCheck {
|
|
@@ -585,7 +585,12 @@ function shellPathCandidates(command: string): ShellScan {
|
|
|
585
585
|
// `<>` opens for both, so it stays a read here and picks up its write below: a floor occurrence
|
|
586
586
|
// may carry only one access, and read is the one the floor would have used anyway.
|
|
587
587
|
const writeTargets = lexed.words.filter(word => word.redirect === "write");
|
|
588
|
-
|
|
588
|
+
// Plus the operands the invoked program writes itself — `tee FILE`, `dd of=FILE`, `cp SRC DST`.
|
|
589
|
+
// Those had no direction signal and defaulted to a read check, so a write into an allowRead-only
|
|
590
|
+
// root passed and a write into an allowWrite-only root was refused (GHSA-q4hg).
|
|
591
|
+
const writtenOperands = lexed.commands.flatMap(simpleCommand => writtenOperandWords(simpleCommand));
|
|
592
|
+
const inWriteTarget = (at: number): boolean =>
|
|
593
|
+
[...writeTargets, ...writtenOperands].some(word => at >= word.start && at < word.end);
|
|
589
594
|
|
|
590
595
|
// A floor occurrence takes the access its own operator gave it; failing that, the span of a
|
|
591
596
|
// lexed write target it sits inside; failing that, read.
|