@f5-sales-demo/xcsh 20.3.2 → 20.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -0
- package/package.json +8 -8
- package/src/cli/resource-cli.ts +134 -0
- package/src/cli/sandbox-check.ts +84 -21
- package/src/cli/update-cli.ts +1 -1
- package/src/cli.ts +8 -0
- package/src/commands/apply.ts +21 -0
- package/src/commands/create.ts +21 -0
- package/src/commands/delete.ts +32 -0
- package/src/commands/diff.ts +20 -0
- package/src/commands/export.ts +36 -0
- package/src/commands/get.ts +30 -0
- package/src/commands/resource-flags.ts +30 -0
- package/src/commands/self-update.ts +18 -0
- package/src/commands/update.ts +14 -14
- package/src/commands/validate.ts +20 -0
- package/src/internal-urls/build-info.generated.ts +8 -8
- package/src/locales/ar.json +1 -1
- package/src/locales/de.json +1 -1
- package/src/locales/en.json +1 -1
- package/src/locales/es.json +1 -1
- package/src/locales/fr.json +1 -1
- package/src/locales/hi.json +1 -1
- package/src/locales/it.json +1 -1
- package/src/locales/ja.json +1 -1
- package/src/locales/ko.json +1 -1
- package/src/locales/pt-br.json +1 -1
- package/src/locales/th.json +1 -1
- package/src/locales/zh-cn.json +1 -1
- package/src/locales/zh-tw.json +1 -1
- package/src/main.ts +1 -1
- package/src/prompts/internal-urls/containment.md +16 -17
- package/src/resource-management/index.ts +8 -0
- package/src/sandbox/command-operands.ts +20 -290
- package/src/sandbox/containment.ts +41 -42
- package/src/sandbox/enforce.ts +45 -280
- package/src/sandbox/session-fence.ts +7 -0
- package/src/slash-commands/resource-commands.ts +63 -93
- package/src/tools/bash.ts +9 -3
- package/src/tools/shell-lex.ts +8 -10
package/src/sandbox/enforce.ts
CHANGED
|
@@ -11,45 +11,18 @@
|
|
|
11
11
|
* base directory. We split the same way and check EVERY token's base, so a
|
|
12
12
|
* multi-path input cannot smuggle a sibling directory past the gate.
|
|
13
13
|
*
|
|
14
|
-
* Arbitrary-code tools
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* act, and spawned children are confined by a seatbelt profile. A path is therefore decided after
|
|
21
|
-
* expansion, alias resolution and symlink following, which is what closed the escapes this scan kept
|
|
22
|
-
* leaking — #2470, #2516, #2520, #2524, #2540, #2542, #2553, and GHSA-q4hg.
|
|
23
|
-
*
|
|
24
|
-
* What remains this file's job:
|
|
25
|
-
* - every structured file tool (`read`/`write`/`edit`/`grep`/…), which has no subprocess to confine
|
|
26
|
-
* - `python`, whose kernel is a shared, lock-protected gateway reused across sessions, so it can never
|
|
27
|
-
* carry a per-session fence and this is the only thing deciding for it
|
|
28
|
-
* - `bash` on a platform with no backend, where this is again the only layer and `xcsh://about` says so
|
|
29
|
-
* - a fast pre-check that produces a readable refusal before a command runs
|
|
30
|
-
*
|
|
31
|
-
* So: keep it, and do not extend it. Another spelling caught here buys little now, and the pattern of
|
|
32
|
-
* adding one has a poor record — two adversarial rounds on the #2542 fix alone produced six bypasses.
|
|
33
|
-
*
|
|
34
|
-
* **One policy, asked at two places (#2624.)** This used to consult `SandboxPolicy`, which was
|
|
35
|
-
* deny-by-default and confined to the cwd, while the fence below it is allow-by-default with targeted
|
|
36
|
-
* denies. Running both made the effective boundary their intersection, and the intersection refused
|
|
37
|
-
* ordinary work: `grep -oE '<title>[^<]*</title>'` was rejected because the floor reads the fragment
|
|
38
|
-
* `/title` out of the closing tag and a deny-by-default policy has to refuse an unrecognised absolute
|
|
39
|
-
* path. So were `</h1>`, `</td>`, `--pretty=format:'%h </%an>'`, `cd "$DEST"`, `read /etc/hosts` and a
|
|
40
|
-
* `/tmp` write — none of which has anything to do with reaching another customer's files.
|
|
41
|
-
*
|
|
42
|
-
* The floor is unchanged, because the floor was never the problem. It guesses which fragments of a
|
|
43
|
-
* command might be paths, and under allow-by-default a wrong guess matches no rule and costs nothing.
|
|
44
|
-
* Deny-by-default is what turned each wrong guess into a refusal, so the posture went rather than the
|
|
45
|
-
* guessing — which also means this file no longer needs to know about system roots, temp directories or
|
|
46
|
-
* which backend is running. The fence answers all of that, and it is the same answer the kernel gives.
|
|
14
|
+
* Arbitrary-code tools are intentionally different. Their source text is data, not a reliable account
|
|
15
|
+
* of what the program will open, and scanning it caused false refusals such as #2931. `bash` therefore
|
|
16
|
+
* pre-checks only filesystem effects the shell lexer proves: an explicit `cwd`, literal redirections,
|
|
17
|
+
* known write operands, and literal directory changes. The OS backend remains the boundary for spawned
|
|
18
|
+
* processes. The shared `python` kernel cannot carry a per-session OS fence, so only its explicit `cwd`
|
|
19
|
+
* is checked; source and cell text are never scanned.
|
|
47
20
|
*/
|
|
48
21
|
import * as fs from "node:fs";
|
|
49
22
|
import * as path from "node:path";
|
|
50
23
|
import { expandPath, parseFindPattern, parseSearchPath, resolveToCwd, splitTopLevel } from "../tools/path-utils";
|
|
51
24
|
import { lexShellCommand, type ShellSimpleCommand } from "../tools/shell-lex";
|
|
52
|
-
import {
|
|
25
|
+
import { writtenOperandWords } from "./command-operands";
|
|
53
26
|
import { type ContainmentFence, type FenceAccess, fenceVerdict } from "./containment";
|
|
54
27
|
|
|
55
28
|
/** The two access directions, named as the fence names them. */
|
|
@@ -123,12 +96,6 @@ const SEARCH_TOOLS: Record<string, SearchSpec> = {
|
|
|
123
96
|
ast_edit: { key: "path", base: token => parseSearchPath(token).basePath, access: "write" },
|
|
124
97
|
};
|
|
125
98
|
|
|
126
|
-
/** Arbitrary-code tools whose command/code strings are scanned best-effort. */
|
|
127
|
-
const CODE_FIELDS: Record<string, string[]> = {
|
|
128
|
-
bash: ["command"],
|
|
129
|
-
python: ["code"],
|
|
130
|
-
};
|
|
131
|
-
|
|
132
99
|
function firstString(input: Record<string, unknown>, keys: string[]): string | undefined {
|
|
133
100
|
for (const key of keys) {
|
|
134
101
|
const value = input[key];
|
|
@@ -155,43 +122,6 @@ function deny(cwd: string, resolved: string, access: SandboxAccess): ToolCallDec
|
|
|
155
122
|
};
|
|
156
123
|
}
|
|
157
124
|
|
|
158
|
-
/**
|
|
159
|
-
* `$HOME` and `${HOME}` are spellings of `~`, which `looksLikePath` already treats as a path (#2534).
|
|
160
|
-
* Rewriting to `~` means detection and resolution answer consistently whether the resulting home path
|
|
161
|
-
* is allowed or denied by an explicit rule.
|
|
162
|
-
*
|
|
163
|
-
* `\b` keeps `$HOMEBREW_PREFIX` and friends out of it.
|
|
164
|
-
*/
|
|
165
|
-
const HOME_EXPANSION = /^\$(?:HOME\b|\{HOME\})/;
|
|
166
|
-
|
|
167
|
-
function normalizeHomeExpansion(token: string): string {
|
|
168
|
-
return HOME_EXPANSION.test(token) ? token.replace(HOME_EXPANSION, "~") : token;
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
function looksLikePath(token: string): boolean {
|
|
172
|
-
return path.isAbsolute(token) || token.startsWith("~") || /(^|[/\\])\.\.([/\\]|$)/.test(token);
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
/** A path-like token found by the floor, with where it was found. */
|
|
176
|
-
interface PathOccurrence {
|
|
177
|
-
token: string;
|
|
178
|
-
/** Offset of the token's first character — past any opening quote — in the scanned string. */
|
|
179
|
-
at: number;
|
|
180
|
-
/** Set when a redirection operator in the raw text says what the shell will do with it. */
|
|
181
|
-
access?: SandboxAccess;
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
/**
|
|
185
|
-
* What a redirection operator does to the operand that follows it. `skip` is a here-string or a
|
|
186
|
-
* heredoc delimiter: that operand is literal data the shell never opens — verified against bash,
|
|
187
|
-
* where `cat <<</tmp/f` prints the string `/tmp/f` rather than the contents of that file.
|
|
188
|
-
*/
|
|
189
|
-
function operandAccess(operator: string): SandboxAccess | "skip" {
|
|
190
|
-
if (operator.includes("<<")) return "skip";
|
|
191
|
-
// `<>` opens for both; the write side is the one a read-only grant must not license.
|
|
192
|
-
return operator.includes(">") ? "write" : "read";
|
|
193
|
-
}
|
|
194
|
-
|
|
195
125
|
/** A path to check, and the boundary to check it against. */
|
|
196
126
|
interface PathCandidate {
|
|
197
127
|
token: string;
|
|
@@ -207,142 +137,6 @@ interface PathCandidate {
|
|
|
207
137
|
/** Write first, so a `<>` denial names the stricter boundary the caller is most likely missing. */
|
|
208
138
|
const WRITE_AND_READ = ["write", "read"] as const satisfies readonly SandboxAccess[];
|
|
209
139
|
|
|
210
|
-
/** A redirection operator, with its optional file-descriptor prefix. Longest forms first. */
|
|
211
|
-
const REDIRECT_OPERATOR = /[0-9]*(?:&>>|&>|<<<|<<-|<<|>>|>\||<>|>&|<&|>|<)/g;
|
|
212
|
-
|
|
213
|
-
/** A whitespace token that is only a redirection operator, so the next token is its operand. */
|
|
214
|
-
const BARE_REDIRECT = /^[0-9]*(?:&>>|&>|<<<|<<-|<<|>>|>\||<>|>&|<&|>|<)$/;
|
|
215
|
-
|
|
216
|
-
/** Where a shell word ends inside a whitespace token: an operator, separator, or grouping. */
|
|
217
|
-
const METACHARACTER = /[;&|<>()]/;
|
|
218
|
-
|
|
219
|
-
/**
|
|
220
|
-
* A path glued to its option, as one shell word (#2524).
|
|
221
|
-
*
|
|
222
|
-
* `path.isAbsolute("if=/work/custB/secret")` is false, so the floor's whole-token test
|
|
223
|
-
* never saw these — a single space was the difference between the blocked form and the
|
|
224
|
-
* allowed one.
|
|
225
|
-
*
|
|
226
|
-
* Only the option's VALUE is captured, never an arbitrary `/`-containing substring. That
|
|
227
|
-
* distinction is what keeps #2470 shut: scanning any slash-bearing fragment would read
|
|
228
|
-
* `sed -n '/a/p'` as a path again, which is the false positive #2479 exists to remove.
|
|
229
|
-
* The captured value still has to satisfy `looksLikePath`, so `--output=./out` stays
|
|
230
|
-
* allowed while `--output=/work/custB/x` does not.
|
|
231
|
-
*
|
|
232
|
-
* The short-option form additionally requires its value to begin with `/` or `~`. Without
|
|
233
|
-
* that, `-la` and friends would be read as an option carrying a relative path.
|
|
234
|
-
*/
|
|
235
|
-
const OPTION_VALUE_FORMS: readonly RegExp[] = [
|
|
236
|
-
/^-{1,2}[A-Za-z0-9][^=]*=(.+)$/, // --output=/p, -o=/p
|
|
237
|
-
/^[A-Za-z_][A-Za-z0-9_]*=(.+)$/, // if=/p, of=/p (operand style)
|
|
238
|
-
/^-[A-Za-z0-9]+([/~].*)$/, // -o/p, -C/p (no separator)
|
|
239
|
-
];
|
|
240
|
-
|
|
241
|
-
/**
|
|
242
|
-
* Path-like tokens in a command/code string: bare (whitespace-split) and quoted.
|
|
243
|
-
*
|
|
244
|
-
* Indiscriminate by design, and that is the point: because it never asks what a command *means*, it
|
|
245
|
-
* also catches paths hidden inside quoted scripts, heredoc bodies, `-exec` runs and substitutions.
|
|
246
|
-
* This is the coverage floor for both bash and python. Do not narrow it.
|
|
247
|
-
*
|
|
248
|
-
* Offsets come along so a caller can tell one occurrence of a token from another. Nothing else about
|
|
249
|
-
* what this finds has changed: the two passes and `looksLikePath` are the floor.
|
|
250
|
-
*/
|
|
251
|
-
function codePathOccurrences(command: string): PathOccurrence[] {
|
|
252
|
-
const found: PathOccurrence[] = [];
|
|
253
|
-
const add = (raw: string, at: number, access?: SandboxAccess): void => {
|
|
254
|
-
const token = normalizeHomeExpansion(raw);
|
|
255
|
-
if (token.length > 0 && looksLikePath(token)) found.push({ token, at, access });
|
|
256
|
-
};
|
|
257
|
-
// Set when the previous token was nothing but a redirection operator, so this one is its operand.
|
|
258
|
-
let carried: SandboxAccess | "skip" | undefined;
|
|
259
|
-
for (const match of command.matchAll(/\S+/g)) {
|
|
260
|
-
const raw = match[0];
|
|
261
|
-
const operand = carried;
|
|
262
|
-
carried = undefined;
|
|
263
|
-
|
|
264
|
-
if (BARE_REDIRECT.test(raw)) {
|
|
265
|
-
carried = operandAccess(raw);
|
|
266
|
-
continue;
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
const stripped = raw.replace(/^["']|["']$/g, "");
|
|
270
|
-
const openingQuote = raw.length !== stripped.length && (raw[0] === '"' || raw[0] === "'") ? 1 : 0;
|
|
271
|
-
if (operand !== "skip") add(stripped, match.index + openingQuote, operand);
|
|
272
|
-
// An operator glued to its operand is one whitespace token, and `>/work/x` is not absolute.
|
|
273
|
-
// The lexer resolves this for words it can see, but not for text inside a quoted script or a
|
|
274
|
-
// heredoc body — where the floor is the only thing looking — so scan past the operator here
|
|
275
|
-
// too, anywhere it appears in the token: `echo a>/work/x` is one token as well. The operator
|
|
276
|
-
// is also the only thing that says which boundary a nested redirect crosses.
|
|
277
|
-
for (const operator of stripped.matchAll(REDIRECT_OPERATOR)) {
|
|
278
|
-
const access = operandAccess(operator[0]);
|
|
279
|
-
if (access === "skip") continue;
|
|
280
|
-
const from = operator.index + operator[0].length;
|
|
281
|
-
// The operand ends at the first shell metacharacter, not at the end of the whitespace
|
|
282
|
-
// token. `>/dev/null; echo x` is one token, and taking all of it produced the "path"
|
|
283
|
-
// `/dev/null;`, which matched no write sink and refused a completely ordinary command
|
|
284
|
-
// (#2540). Truncating only ever shortens a candidate, so nothing blocked becomes allowed.
|
|
285
|
-
const rest = stripped.slice(from);
|
|
286
|
-
const stop = rest.search(METACHARACTER);
|
|
287
|
-
add(
|
|
288
|
-
(stop === -1 ? rest : rest.slice(0, stop)).replace(/^["']|["']$/g, ""),
|
|
289
|
-
match.index + openingQuote + from,
|
|
290
|
-
access,
|
|
291
|
-
);
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
// An option glued to its value is one word too, and the lexer cannot help: it
|
|
295
|
-
// correctly reports `if=/work/custB/secret` as a single word, because that is what
|
|
296
|
-
// it is. Which options introduce a filename is command-specific knowledge the floor
|
|
297
|
-
// deliberately does not have, so scan the value and let `looksLikePath` decide.
|
|
298
|
-
//
|
|
299
|
-
// Skipped after a here-string or heredoc delimiter for the same reason the whole
|
|
300
|
-
// token is: that operand is literal data the shell never opens, so `cat <<<if=/tmp/f`
|
|
301
|
-
// prints the text rather than reading the file.
|
|
302
|
-
if (operand !== "skip") {
|
|
303
|
-
for (const form of OPTION_VALUE_FORMS) {
|
|
304
|
-
const optionValue = stripped.match(form);
|
|
305
|
-
if (!optionValue?.[1]) continue;
|
|
306
|
-
const from = stripped.length - optionValue[1].length;
|
|
307
|
-
add(optionValue[1].replace(/^["']|["']$/g, ""), match.index + openingQuote + from, operand);
|
|
308
|
-
break; // the forms overlap; the first that matches has already captured the value
|
|
309
|
-
}
|
|
310
|
-
}
|
|
311
|
-
}
|
|
312
|
-
for (const match of command.matchAll(/["']([^"']+)["']/g)) add(match[1], match.index + 1);
|
|
313
|
-
return found;
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
/** The floor as plain read candidates — what a non-shell language gets. */
|
|
317
|
-
function codePathCandidates(command: string): PathCandidate[] {
|
|
318
|
-
return codePathOccurrences(command).map(({ token }) => ({ token, access: "read" as const }));
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
/**
|
|
322
|
-
* Path candidates of a *bash* command. Three things happen here, and only the first narrows:
|
|
323
|
-
*
|
|
324
|
-
* 1. **Exempt words are blanked.** Words the invoked command provably treats as a script or pattern
|
|
325
|
-
* rather than a filename stop being scanned (issue #2470: `sed -n '/a/p'` was refused because
|
|
326
|
-
* `/a/p` looks absolute, though sed never opens it). Implemented by blanking the exempt spans and
|
|
327
|
-
* re-running the floor over what remains, rather than by subtracting a set of token strings. Set
|
|
328
|
-
* subtraction loses which *occurrence* a token came from, so `echo '/elsewhere/x' && cat
|
|
329
|
-
* /elsewhere/x` would exempt the echo operand and thereby clear the identical token belonging to
|
|
330
|
-
* `cat`. Blanking is positional and cannot leak that way.
|
|
331
|
-
*
|
|
332
|
-
* 2. **A word the shell opens for writing is checked against the write boundary** (issue #2516).
|
|
333
|
-
* Every candidate used to be checked as a read, so a path granted read access but not write was
|
|
334
|
-
* writable through `>`. Marking is by span, for the same occurrence-identity reason as blanking:
|
|
335
|
-
* in `cat /shared/x && printf y > /shared/x` only the second occurrence is the write.
|
|
336
|
-
*
|
|
337
|
-
* 3. **Redirect targets are added as candidates** (issue #2520). The floor splits on whitespace, so
|
|
338
|
-
* an operator glued to its path — `cat </work/custB/secret` — was one token that did not look
|
|
339
|
-
* like a path and was never checked at all. The lexer parses those correctly, so its redirect
|
|
340
|
-
* targets go in on top of the floor. This only ever adds candidates.
|
|
341
|
-
*
|
|
342
|
-
* The floor's reach is unchanged: text the lexer never turns into a word — a heredoc body, an
|
|
343
|
-
* `-exec` run — is not blanked, so it is still scanned. When the command cannot be lexed
|
|
344
|
-
* confidently, the floor stands alone.
|
|
345
|
-
*/
|
|
346
140
|
/**
|
|
347
141
|
* A `cd` whose target this layer cannot resolve — `cd "$DEST"`, `cd $(git rev-parse …)`, `cd -` — is
|
|
348
142
|
* **not** refused (#2624).
|
|
@@ -395,8 +189,8 @@ const DIRECTORY_CHANGE_TOKEN = /(^|[\s;&|(])(cd|pushd)([\s;&|)]|$)/;
|
|
|
395
189
|
/**
|
|
396
190
|
* Literal targets of a directory change, as candidates that must resolve in-tree.
|
|
397
191
|
*
|
|
398
|
-
* This gate exists because
|
|
399
|
-
*
|
|
192
|
+
* This gate exists because ordinary relative operands are runtime decisions. `cd` changes where
|
|
193
|
+
* those operands resolve: the bash tool runs one
|
|
400
194
|
* persistent brush-core shell in the agent's own process, so a directory change outlives the call
|
|
401
195
|
* that made it and afterwards `cat tmp/x` reads somewhere else entirely (#2542).
|
|
402
196
|
*
|
|
@@ -459,38 +253,14 @@ function directoryChangeTargets(commands: readonly ShellSimpleCommand[], depth =
|
|
|
459
253
|
|
|
460
254
|
function shellPathCandidates(command: string): ShellScan {
|
|
461
255
|
const lexed = lexShellCommand(command);
|
|
462
|
-
//
|
|
463
|
-
//
|
|
464
|
-
if (lexed.unterminated) return { candidates:
|
|
465
|
-
|
|
466
|
-
const
|
|
467
|
-
let scanned = command;
|
|
468
|
-
// Replace each exempt word with equivalent-length whitespace, so surrounding offsets and word
|
|
469
|
-
// boundaries are preserved and only that word's own text stops being scanned.
|
|
470
|
-
for (const word of exemptSpans) {
|
|
471
|
-
scanned = scanned.slice(0, word.start) + " ".repeat(word.end - word.start) + scanned.slice(word.end);
|
|
472
|
-
}
|
|
256
|
+
// An unfinished command has no filesystem effect, and treating partial words as paths is exactly
|
|
257
|
+
// the source-text guessing this layer avoids.
|
|
258
|
+
if (lexed.unterminated) return { candidates: [] };
|
|
259
|
+
|
|
260
|
+
const candidates: PathCandidate[] = [];
|
|
473
261
|
|
|
474
|
-
//
|
|
475
|
-
//
|
|
476
|
-
const writeTargets = lexed.words.filter(word => word.redirect === "write");
|
|
477
|
-
// Plus the operands the invoked program writes itself — `tee FILE`, `dd of=FILE`, `cp SRC DST`.
|
|
478
|
-
// Those had no direction signal and defaulted to a read check, so a write into an allowRead-only
|
|
479
|
-
// root passed and a write into an allowWrite-only root was refused (GHSA-q4hg).
|
|
480
|
-
const writtenOperands = lexed.commands.flatMap(simpleCommand => writtenOperandWords(simpleCommand));
|
|
481
|
-
const inWriteTarget = (at: number): boolean =>
|
|
482
|
-
[...writeTargets, ...writtenOperands].some(word => at >= word.start && at < word.end);
|
|
483
|
-
|
|
484
|
-
// A floor occurrence takes the access its own operator gave it; failing that, the span of a
|
|
485
|
-
// lexed write target it sits inside; failing that, read.
|
|
486
|
-
const candidates: PathCandidate[] = codePathOccurrences(scanned).map(({ token, at, access }) => ({
|
|
487
|
-
token,
|
|
488
|
-
access: access ?? (inWriteTarget(at) ? "write" : "read"),
|
|
489
|
-
}));
|
|
490
|
-
|
|
491
|
-
// Not gated on `looksLikePath`: that test is for the floor's *guesses* about which fragments of a
|
|
492
|
-
// command might be filenames. A redirect target is one the shell will certainly open, so a bare
|
|
493
|
-
// `out.txt` is checked too — it resolves under the cwd, which a read-only cwd does not license.
|
|
262
|
+
// A redirect target is one the shell will certainly open, so a bare `out.txt` is checked too — it
|
|
263
|
+
// resolves under the cwd, which a read-only cwd does not license.
|
|
494
264
|
for (const word of lexed.words) {
|
|
495
265
|
if (word.redirect === undefined || word.redirect === "here-string") continue;
|
|
496
266
|
for (const access of word.redirect === "read-write" ? WRITE_AND_READ : [word.redirect]) {
|
|
@@ -501,6 +271,13 @@ function shellPathCandidates(command: string): ShellScan {
|
|
|
501
271
|
}
|
|
502
272
|
}
|
|
503
273
|
|
|
274
|
+
// Known program operands that are written — `tee FILE`, `dd of=FILE`, `cp SRC DST` — are as
|
|
275
|
+
// explicit as redirects. Ordinary read operands are deliberately absent: only the process that
|
|
276
|
+
// opens them can decide whether path-looking argument text is a filename, a pattern, or data.
|
|
277
|
+
for (const word of lexed.commands.flatMap(simpleCommand => writtenOperandWords(simpleCommand))) {
|
|
278
|
+
if (word.literal) candidates.push({ token: word.text, access: "write" });
|
|
279
|
+
}
|
|
280
|
+
|
|
504
281
|
candidates.push(...directoryChangeTargets(lexed.commands));
|
|
505
282
|
return { candidates };
|
|
506
283
|
}
|
|
@@ -521,7 +298,7 @@ function searchBases(raw: string, base: (token: string) => string): string[] {
|
|
|
521
298
|
return [...tokens];
|
|
522
299
|
}
|
|
523
300
|
|
|
524
|
-
function evaluateCodeTool(check: ToolCallCheck,
|
|
301
|
+
function evaluateCodeTool(check: ToolCallCheck, shell: boolean): ToolCallDecision {
|
|
525
302
|
const { input, cwd } = check;
|
|
526
303
|
|
|
527
304
|
const rawCwd = typeof input.cwd === "string" ? input.cwd : undefined;
|
|
@@ -533,38 +310,26 @@ function evaluateCodeTool(check: ToolCallCheck, fields: string[], shell: boolean
|
|
|
533
310
|
return { block: true, reason: describeDirectoryChange(cwd, base) };
|
|
534
311
|
}
|
|
535
312
|
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
const scan: ShellScan = shell ? shellPathCandidates(command) : { candidates: codePathCandidates(command) };
|
|
553
|
-
const seen = new Set<string>();
|
|
554
|
-
for (const { token, access, mustBeInTree } of scan.candidates) {
|
|
555
|
-
if (!seen.add(`${access}\0${mustBeInTree ? "cd\0" : ""}${token}`)) continue;
|
|
556
|
-
if (mustBeInTree) {
|
|
557
|
-
// `path.resolve`, not `resolveToCwd`: the latter maps an all-slashes path to the cwd,
|
|
558
|
-
// which would read `cd /` as `cd .` and let the shell walk out. Both directions,
|
|
559
|
-
// because relative paths go unchecked wherever the shell is standing.
|
|
560
|
-
const moved = path.resolve(base, expandPath(token));
|
|
561
|
-
if (permits(check, moved, "read") && permits(check, moved, "write")) continue;
|
|
562
|
-
return { block: true, reason: describeDirectoryChange(cwd, moved) };
|
|
563
|
-
}
|
|
564
|
-
const resolved = resolveToCwd(token, base);
|
|
565
|
-
if (permits(check, resolved, access)) continue;
|
|
566
|
-
return deny(cwd, resolved, access);
|
|
313
|
+
// Python's persistent shared kernel has no per-session OS fence. Scanning source cannot fix that:
|
|
314
|
+
// path-like strings may be inert data while computed paths never appear in source at all. Keep the
|
|
315
|
+
// explicit cwd contract and otherwise let Python run without a heuristic pre-check (#2931).
|
|
316
|
+
if (!shell || typeof input.command !== "string") return ALLOW;
|
|
317
|
+
|
|
318
|
+
const scan = shellPathCandidates(input.command);
|
|
319
|
+
const seen = new Set<string>();
|
|
320
|
+
for (const { token, access, mustBeInTree } of scan.candidates) {
|
|
321
|
+
if (!seen.add(`${access}\0${mustBeInTree ? "cd\0" : ""}${token}`)) continue;
|
|
322
|
+
if (mustBeInTree) {
|
|
323
|
+
// `path.resolve`, not `resolveToCwd`: the latter maps an all-slashes path to the cwd,
|
|
324
|
+
// which would read `cd /` as `cd .` and let the shell walk out. Both directions,
|
|
325
|
+
// because relative paths go unchecked wherever the shell is standing.
|
|
326
|
+
const moved = path.resolve(base, expandPath(token));
|
|
327
|
+
if (permits(check, moved, "read") && permits(check, moved, "write")) continue;
|
|
328
|
+
return { block: true, reason: describeDirectoryChange(cwd, moved) };
|
|
567
329
|
}
|
|
330
|
+
const resolved = resolveToCwd(token, base);
|
|
331
|
+
if (permits(check, resolved, access)) continue;
|
|
332
|
+
return deny(cwd, resolved, access);
|
|
568
333
|
}
|
|
569
334
|
|
|
570
335
|
return ALLOW;
|
|
@@ -695,8 +460,8 @@ function evaluateReadTool(check: ToolCallCheck): ToolCallDecision {
|
|
|
695
460
|
export function evaluateToolCall(check: ToolCallCheck): ToolCallDecision {
|
|
696
461
|
const { toolName, input, cwd } = check;
|
|
697
462
|
|
|
698
|
-
|
|
699
|
-
if (
|
|
463
|
+
if (toolName === "bash") return evaluateCodeTool(check, true);
|
|
464
|
+
if (toolName === "python") return evaluateCodeTool(check, false);
|
|
700
465
|
|
|
701
466
|
if (toolName === "edit") return evaluateEdit(check);
|
|
702
467
|
if (toolName === "read") return evaluateReadTool(check);
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* allow-by-default with targeted denies, so the effective boundary was their intersection and the
|
|
15
15
|
* intersection refused ordinary work. One object means the pre-check and the kernel cannot disagree.
|
|
16
16
|
*/
|
|
17
|
+
import * as path from "node:path";
|
|
17
18
|
import { buildContainmentFence, type ContainmentFence } from "./containment";
|
|
18
19
|
|
|
19
20
|
/**
|
|
@@ -28,6 +29,12 @@ export const SANDBOX_SESSION_ROOT_ENV = "XCSH_SANDBOX_SESSION_ROOT";
|
|
|
28
29
|
export const SANDBOX_OPERATOR_HOME_ENV = "XCSH_SANDBOX_OPERATOR_HOME";
|
|
29
30
|
export const SANDBOX_CHECK_NAMED_SIBLING_ENV = "XCSH_SANDBOX_CHECK_NAMED_SIBLING";
|
|
30
31
|
|
|
32
|
+
/** Choose a writable root for the live named-path probe without escaping an operator-home session. */
|
|
33
|
+
export function sandboxCheckSiblingRoot(workspace: string, operatorHome: string): string {
|
|
34
|
+
const parent = path.dirname(workspace);
|
|
35
|
+
return workspace === operatorHome || parent === workspace ? workspace : parent;
|
|
36
|
+
}
|
|
37
|
+
|
|
31
38
|
/** The slice of `Settings` this needs — supplied explicitly so the caller names its own source. */
|
|
32
39
|
export interface SettingsReader {
|
|
33
40
|
get(key: string): unknown;
|
|
@@ -33,19 +33,16 @@ export async function handleResourceCommand(
|
|
|
33
33
|
const {
|
|
34
34
|
parseResourceArgs,
|
|
35
35
|
ResourceClient,
|
|
36
|
-
|
|
36
|
+
readManifestInputs,
|
|
37
|
+
runResourceOperation,
|
|
38
|
+
formatResourceOperationReport,
|
|
37
39
|
ManifestFileError,
|
|
38
|
-
parseManifests,
|
|
39
40
|
ManifestParseError,
|
|
40
41
|
KindResolutionError,
|
|
41
|
-
validateManifest,
|
|
42
|
-
formatValidationErrors,
|
|
43
|
-
formatOperationResult,
|
|
44
42
|
formatResourceList,
|
|
45
43
|
formatResourceDetail,
|
|
46
|
-
formatMultiOperationSummary,
|
|
47
|
-
formatDiff,
|
|
48
44
|
} = await import("@f5-sales-demo/pi-resource-management");
|
|
45
|
+
type ManifestOperationInput = import("@f5-sales-demo/pi-resource-management").ManifestOperationInput;
|
|
49
46
|
const { kindResolver } = await import("../resource-management/index");
|
|
50
47
|
|
|
51
48
|
const parsed = parseResourceArgs(command.args);
|
|
@@ -85,83 +82,62 @@ export async function handleResourceCommand(
|
|
|
85
82
|
const client = new ResourceClient({
|
|
86
83
|
apiUrl,
|
|
87
84
|
apiToken,
|
|
88
|
-
namespace:
|
|
85
|
+
namespace: defaultNamespace,
|
|
89
86
|
resolvePayloadVars: (json: string) => contextEnv.resolvePayloadVars(json),
|
|
90
87
|
});
|
|
91
88
|
|
|
92
|
-
const
|
|
89
|
+
const runBatch = async (
|
|
90
|
+
operation: "apply" | "create" | "delete" | "diff",
|
|
91
|
+
inputs: ManifestOperationInput[],
|
|
92
|
+
): Promise<void> => {
|
|
93
|
+
const report = await runResourceOperation({
|
|
94
|
+
operation,
|
|
95
|
+
inputs,
|
|
96
|
+
kindResolver,
|
|
97
|
+
client,
|
|
98
|
+
namespaceOverride: parsed.namespace,
|
|
99
|
+
defaultNamespace,
|
|
100
|
+
dryRun: parsed.dryRun,
|
|
101
|
+
});
|
|
102
|
+
ctx.showStatus(formatResourceOperationReport(report, parsed.outputFormat));
|
|
103
|
+
};
|
|
93
104
|
|
|
94
105
|
try {
|
|
95
106
|
switch (commandName) {
|
|
96
107
|
case "apply":
|
|
97
108
|
case "create": {
|
|
98
109
|
if (parsed.filenames.length === 0) {
|
|
99
|
-
ctx.showStatus(
|
|
100
|
-
`Usage: /${commandName} -f <file.json|file.yaml|dir/> [-n namespace] [--dry-run=client|server]`,
|
|
101
|
-
);
|
|
110
|
+
ctx.showStatus(`Usage: /${commandName} -f <file.json|file.yaml|dir/> [-n namespace] [--dry-run=client]`);
|
|
102
111
|
return;
|
|
103
112
|
}
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
if (allObjects.length === 0) {
|
|
113
|
+
const inputs = await readManifestInputs(parsed.filenames, parsed.recursive);
|
|
114
|
+
if (inputs.length === 0) {
|
|
107
115
|
ctx.showStatus("No resources found in the specified file(s).");
|
|
108
116
|
return;
|
|
109
117
|
}
|
|
110
|
-
|
|
111
|
-
const results = [];
|
|
112
|
-
for (const manifest of manifests) {
|
|
113
|
-
const { result: valResult, resolved } = validateManifest(manifest, kindResolver, ns);
|
|
114
|
-
if (!valResult.valid) {
|
|
115
|
-
ctx.showStatus(formatValidationErrors(manifest, valResult));
|
|
116
|
-
results.push({
|
|
117
|
-
status: "error" as const,
|
|
118
|
-
error: { kind: "validation" as const, message: "Validation failed" },
|
|
119
|
-
});
|
|
120
|
-
continue;
|
|
121
|
-
}
|
|
122
|
-
if (!resolved) continue;
|
|
123
|
-
|
|
124
|
-
const opResult =
|
|
125
|
-
commandName === "apply"
|
|
126
|
-
? await client.apply(manifest, resolved, ns, parsed.dryRun)
|
|
127
|
-
: await client.create(manifest, resolved, ns, parsed.dryRun);
|
|
128
|
-
|
|
129
|
-
results.push(opResult);
|
|
130
|
-
ctx.showStatus(formatOperationResult(opResult, manifest, parsed.outputFormat));
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
if (manifests.length > 1) {
|
|
134
|
-
ctx.showStatus(formatMultiOperationSummary(results as any, manifests));
|
|
135
|
-
}
|
|
118
|
+
await runBatch(commandName, inputs);
|
|
136
119
|
break;
|
|
137
120
|
}
|
|
138
121
|
|
|
139
122
|
case "delete": {
|
|
140
|
-
let
|
|
141
|
-
|
|
123
|
+
let inputs: ManifestOperationInput[];
|
|
142
124
|
if (parsed.filenames.length > 0) {
|
|
143
|
-
|
|
144
|
-
const allObjects = fileResults.flatMap(r => r.objects);
|
|
145
|
-
const manifests = parseManifests(allObjects, fileResults[0]?.sourcePath ?? "input");
|
|
146
|
-
deleteTargets = manifests.map(m => ({ kind: m.kind, name: m.metadata.name }));
|
|
125
|
+
inputs = await readManifestInputs(parsed.filenames, parsed.recursive);
|
|
147
126
|
} else if (parsed.kind && parsed.name) {
|
|
148
|
-
|
|
127
|
+
const namespace = parsed.namespace ?? defaultNamespace;
|
|
128
|
+
const rawObject = { kind: parsed.kind, metadata: { name: parsed.name, namespace }, spec: {} };
|
|
129
|
+
inputs = [
|
|
130
|
+
{
|
|
131
|
+
index: 0,
|
|
132
|
+
sourcePath: "command-line",
|
|
133
|
+
manifest: { kind: parsed.kind, metadata: { name: parsed.name, namespace }, spec: {}, rawObject },
|
|
134
|
+
},
|
|
135
|
+
];
|
|
149
136
|
} else {
|
|
150
|
-
ctx.showStatus("Usage: /delete -f <file> or /delete <kind> <name> [-n namespace]
|
|
137
|
+
ctx.showStatus("Usage: /delete -f <file> or /delete <kind> <name> [-n namespace]");
|
|
151
138
|
return;
|
|
152
139
|
}
|
|
153
|
-
|
|
154
|
-
for (const target of deleteTargets) {
|
|
155
|
-
const resolved = kindResolver.resolveKind(target.kind);
|
|
156
|
-
const result = await client.delete(target.kind, target.name, resolved, ns);
|
|
157
|
-
ctx.showStatus(
|
|
158
|
-
formatOperationResult(
|
|
159
|
-
result,
|
|
160
|
-
{ kind: target.kind, metadata: { name: target.name }, spec: {}, rawObject: {} } as any,
|
|
161
|
-
parsed.outputFormat,
|
|
162
|
-
),
|
|
163
|
-
);
|
|
164
|
-
}
|
|
140
|
+
await runBatch("delete", inputs);
|
|
165
141
|
break;
|
|
166
142
|
}
|
|
167
143
|
|
|
@@ -176,13 +152,21 @@ export async function handleResourceCommand(
|
|
|
176
152
|
ctx.showStatus("Usage: /describe <kind> <name> [-n namespace] [-o json|yaml]");
|
|
177
153
|
return;
|
|
178
154
|
}
|
|
179
|
-
const
|
|
180
|
-
|
|
181
|
-
|
|
155
|
+
const report = await runResourceOperation({
|
|
156
|
+
operation: "get",
|
|
157
|
+
kind,
|
|
158
|
+
name,
|
|
159
|
+
kindResolver,
|
|
160
|
+
client,
|
|
161
|
+
namespaceOverride: parsed.namespace,
|
|
162
|
+
defaultNamespace,
|
|
163
|
+
});
|
|
164
|
+
const result = report.results[0];
|
|
165
|
+
if (result?.error) {
|
|
182
166
|
ctx.showStatus(`Error: ${result.error.message}`);
|
|
183
167
|
return;
|
|
184
168
|
}
|
|
185
|
-
if (result
|
|
169
|
+
if (result?.resource) {
|
|
186
170
|
ctx.showStatus(formatResourceDetail(result.resource, kind, parsed.outputFormat));
|
|
187
171
|
}
|
|
188
172
|
break;
|
|
@@ -193,29 +177,7 @@ export async function handleResourceCommand(
|
|
|
193
177
|
ctx.showStatus("Usage: /diff -f <file.json|file.yaml> [-n namespace]");
|
|
194
178
|
return;
|
|
195
179
|
}
|
|
196
|
-
|
|
197
|
-
const allObjects = fileResults.flatMap(r => r.objects);
|
|
198
|
-
const manifests = parseManifests(allObjects, fileResults[0]?.sourcePath ?? "input");
|
|
199
|
-
|
|
200
|
-
for (const manifest of manifests) {
|
|
201
|
-
const { result: valResult, resolved } = validateManifest(manifest, kindResolver, ns);
|
|
202
|
-
if (!valResult.valid || !resolved) {
|
|
203
|
-
ctx.showStatus(formatValidationErrors(manifest, valResult));
|
|
204
|
-
continue;
|
|
205
|
-
}
|
|
206
|
-
const diffResult = await client.diff(manifest, resolved, ns);
|
|
207
|
-
if (diffResult.error) {
|
|
208
|
-
ctx.showStatus(`Error: ${diffResult.error.message}`);
|
|
209
|
-
continue;
|
|
210
|
-
}
|
|
211
|
-
if (diffResult.isNew) {
|
|
212
|
-
ctx.showStatus(`${manifest.kind}/${manifest.metadata.name} does not exist yet — will be created.`);
|
|
213
|
-
continue;
|
|
214
|
-
}
|
|
215
|
-
if (diffResult.diff) {
|
|
216
|
-
ctx.showStatus(formatDiff(diffResult.diff, manifest.kind, manifest.metadata.name));
|
|
217
|
-
}
|
|
218
|
-
}
|
|
180
|
+
await runBatch("diff", await readManifestInputs(parsed.filenames, parsed.recursive));
|
|
219
181
|
break;
|
|
220
182
|
}
|
|
221
183
|
|
|
@@ -224,15 +186,23 @@ export async function handleResourceCommand(
|
|
|
224
186
|
ctx.showStatus("Usage: /get <kind> [name] [-n namespace] [-o json|yaml|table]");
|
|
225
187
|
return;
|
|
226
188
|
}
|
|
227
|
-
const
|
|
228
|
-
|
|
229
|
-
|
|
189
|
+
const report = await runResourceOperation({
|
|
190
|
+
operation: "get",
|
|
191
|
+
kind: parsed.kind,
|
|
192
|
+
name: parsed.name,
|
|
193
|
+
kindResolver,
|
|
194
|
+
client,
|
|
195
|
+
namespaceOverride: parsed.namespace,
|
|
196
|
+
defaultNamespace,
|
|
197
|
+
});
|
|
198
|
+
const result = report.results[0];
|
|
199
|
+
if (result?.error) {
|
|
230
200
|
ctx.showStatus(`Error: ${result.error.message}`);
|
|
231
201
|
return;
|
|
232
202
|
}
|
|
233
|
-
if (result
|
|
203
|
+
if (result?.items) {
|
|
234
204
|
ctx.showStatus(formatResourceList(result.items, parsed.kind, parsed.outputFormat));
|
|
235
|
-
} else if (result
|
|
205
|
+
} else if (result?.resource) {
|
|
236
206
|
ctx.showStatus(formatResourceDetail(result.resource, parsed.kind, parsed.outputFormat));
|
|
237
207
|
}
|
|
238
208
|
break;
|