@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.
Files changed (40) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/package.json +8 -8
  3. package/src/cli/resource-cli.ts +134 -0
  4. package/src/cli/sandbox-check.ts +84 -21
  5. package/src/cli/update-cli.ts +1 -1
  6. package/src/cli.ts +8 -0
  7. package/src/commands/apply.ts +21 -0
  8. package/src/commands/create.ts +21 -0
  9. package/src/commands/delete.ts +32 -0
  10. package/src/commands/diff.ts +20 -0
  11. package/src/commands/export.ts +36 -0
  12. package/src/commands/get.ts +30 -0
  13. package/src/commands/resource-flags.ts +30 -0
  14. package/src/commands/self-update.ts +18 -0
  15. package/src/commands/update.ts +14 -14
  16. package/src/commands/validate.ts +20 -0
  17. package/src/internal-urls/build-info.generated.ts +8 -8
  18. package/src/locales/ar.json +1 -1
  19. package/src/locales/de.json +1 -1
  20. package/src/locales/en.json +1 -1
  21. package/src/locales/es.json +1 -1
  22. package/src/locales/fr.json +1 -1
  23. package/src/locales/hi.json +1 -1
  24. package/src/locales/it.json +1 -1
  25. package/src/locales/ja.json +1 -1
  26. package/src/locales/ko.json +1 -1
  27. package/src/locales/pt-br.json +1 -1
  28. package/src/locales/th.json +1 -1
  29. package/src/locales/zh-cn.json +1 -1
  30. package/src/locales/zh-tw.json +1 -1
  31. package/src/main.ts +1 -1
  32. package/src/prompts/internal-urls/containment.md +16 -17
  33. package/src/resource-management/index.ts +8 -0
  34. package/src/sandbox/command-operands.ts +20 -290
  35. package/src/sandbox/containment.ts +41 -42
  36. package/src/sandbox/enforce.ts +45 -280
  37. package/src/sandbox/session-fence.ts +7 -0
  38. package/src/slash-commands/resource-commands.ts +63 -93
  39. package/src/tools/bash.ts +9 -3
  40. package/src/tools/shell-lex.ts +8 -10
@@ -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 (`bash`, `python`) cannot be fully contained in-process: this
15
- * checks the `cwd` argument precisely and scans the command/code for path tokens
16
- * (bare, quoted, `~`, `..`, absolute) that escape the tree. OS system paths are exempt.
17
- *
18
- * **This scan is no longer the boundary for `bash` on macOS.** Containment now runs below the command
19
- * text (`sandbox/containment.ts`, #2554): the shell's own `cd` and redirections are checked where they
20
- * act, and spawned children are confined by a seatbelt profile. A path is therefore decided after
21
- * expansion, alias resolution and symlink following, which is what closed the escapes this scan kept
22
- * leaking — #2470, #2516, #2520, #2524, #2540, #2542, #2553, and GHSA-q4hg.
23
- *
24
- * What remains this file's job:
25
- * - every structured file tool (`read`/`write`/`edit`/`grep`/…), which has no subprocess to confine
26
- * - `python`, 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 { provenExemptWords, writtenOperandWords } from "./command-operands";
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 a relative operand is never a candidate — the floor assumes it resolves
399
- * under the session directory. `cd` is what breaks that assumption: the bash tool runs one
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
- // Unbalanced quotes mean every word boundary is a guess: neither the blanking nor the write
463
- // marking below can be trusted, so fall back to the floor, checked as reads.
464
- if (lexed.unterminated) return { candidates: codePathCandidates(command) };
465
-
466
- const exemptSpans = lexed.commands.flatMap(simpleCommand => provenExemptWords(simpleCommand));
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
- // `<>` opens for both, so it stays a read here and picks up its write below: a floor occurrence
475
- // may carry only one access, and read is the one the floor would have used anyway.
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, fields: string[], shell: boolean): ToolCallDecision {
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
- const commands: string[] = [];
537
- for (const field of fields) {
538
- if (typeof input[field] === "string") commands.push(input[field] as string);
539
- }
540
- // python also accepts a `cells` array of { code }.
541
- if (Array.isArray(input.cells)) {
542
- for (const cell of input.cells) {
543
- const code = (cell as { code?: unknown })?.code;
544
- if (typeof code === "string") commands.push(code);
545
- }
546
- }
547
-
548
- for (const command of commands) {
549
- // Only bash gets the shell-aware treatment. Python is not shell: lexing `open('/x')` as
550
- // shell yields one non-absolute word and would lose the check entirely, and it has no
551
- // redirects, so every candidate it produces is a read.
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
- const codeFields = CODE_FIELDS[toolName];
699
- if (codeFields) return evaluateCodeTool(check, codeFields, toolName === "bash");
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
- readManifestFiles,
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: parsed.namespace ?? defaultNamespace,
85
+ namespace: defaultNamespace,
89
86
  resolvePayloadVars: (json: string) => contextEnv.resolvePayloadVars(json),
90
87
  });
91
88
 
92
- const ns = parsed.namespace ?? defaultNamespace;
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 fileResults = await readManifestFiles(parsed.filenames, parsed.recursive);
105
- const allObjects = fileResults.flatMap(r => r.objects);
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
- const manifests = parseManifests(allObjects, fileResults[0]?.sourcePath ?? "input");
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 deleteTargets: { kind: string; name: string }[] = [];
141
-
123
+ let inputs: ManifestOperationInput[];
142
124
  if (parsed.filenames.length > 0) {
143
- const fileResults = await readManifestFiles(parsed.filenames, parsed.recursive);
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
- deleteTargets = [{ kind: parsed.kind, name: parsed.name }];
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] [--force]");
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 resolved = kindResolver.resolveKind(kind);
180
- const result = await client.get(resolved, name, ns);
181
- if (result.error) {
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.resource) {
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
- const fileResults = await readManifestFiles(parsed.filenames, parsed.recursive);
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 resolved = kindResolver.resolveKind(parsed.kind);
228
- const result = await client.get(resolved, parsed.name, ns);
229
- if (result.error) {
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.items) {
203
+ if (result?.items) {
234
204
  ctx.showStatus(formatResourceList(result.items, parsed.kind, parsed.outputFormat));
235
- } else if (result.resource) {
205
+ } else if (result?.resource) {
236
206
  ctx.showStatus(formatResourceDetail(result.resource, parsed.kind, parsed.outputFormat));
237
207
  }
238
208
  break;