@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
@@ -1,216 +1,5 @@
1
- /**
2
- * Which words of a command are provably *not* filesystem references.
3
- *
4
- * The read-boundary scan in `enforce.ts` finds path-like tokens by scanning raw command text. That
5
- * is indiscriminate on purpose, and it is what makes the scan hard to fool — it sees inside quoted
6
- * scripts, heredoc bodies and nested commands by accident. It also produces the false positives in
7
- * issue #2470: `sed -n '/a/p'` is refused because `/a/p` is absolute-looking, even though sed never
8
- * opens it.
9
- *
10
- * So this module does not replace that scan. It identifies the narrow set of words a command
11
- * demonstrably treats as a script or a pattern rather than a filename; `enforce.ts` blanks those
12
- * spans and re-runs the scan over what is left. The scan remains the floor.
13
- *
14
- * Note the exemption is *positional*. An earlier version subtracted token strings, which lost track
15
- * of which occurrence a token came from: exempting the quoted operand of
16
- * `echo '/elsewhere/x' && cat /elsewhere/x` also cleared the identical token belonging to `cat`.
17
- *
18
- * The invariant to preserve: an exemption must never let a command read a file it would not
19
- * otherwise open. Concretely, that means every one of these must stay unexempted, because each one
20
- * really does open the path inside it — `test/sandbox-enforce.test.ts` pins all of them:
21
- *
22
- * sh -c 'cat /work/custB/x' the shell runs the string
23
- * bash <<'EOF' … EOF the heredoc body is a script
24
- * find . -exec sh -c '…' \; -exec consumes a whole command run
25
- * sed -n 'r /work/custB/x' sed's `r` reads a file
26
- * sed 's|x|cat /work/custB/x|e' sed's `e` flag executes the replacement
27
- * awk 'BEGIN { getline x < "…" }' awk's getline reads a file
28
- *
29
- * When a dialect construct cannot be ruled out, do not exempt. A missed construct is a sandbox
30
- * escape; a falsely-detected one is only a false positive the current code already produces.
31
- */
32
1
  import type { ShellSimpleCommand, ShellWord } from "../tools/shell-lex";
33
2
 
34
- interface OperandSpec {
35
- /** Count of leading non-option operands that are scripts or patterns rather than files. */
36
- readonly exemptLeading: number;
37
- /** Options that relocate the script, so no leading operand is exemptible. */
38
- readonly suppressedBy: readonly string[];
39
- /** Options taking no argument. Anything not listed here or in `valueOptions` disables exemption. */
40
- readonly booleanOptions: readonly string[];
41
- /** Options whose following word is a value, not an operand. */
42
- readonly valueOptions: readonly string[];
43
- /** Set when every operand is text the command never interprets as a path. */
44
- readonly exemptAll?: true;
45
- /** Dialect whose file-access constructs must be absent for the exemption to hold. */
46
- readonly dialect?: "sed" | "awk";
47
- }
48
-
49
- const SED: OperandSpec = {
50
- exemptLeading: 1,
51
- suppressedBy: ["-e", "--expression", "-f", "--file"],
52
- booleanOptions: [
53
- "-n",
54
- "--quiet",
55
- "--silent",
56
- "-s",
57
- "--separate",
58
- "-u",
59
- "--unbuffered",
60
- "-z",
61
- "--null-data",
62
- "-E",
63
- "-r",
64
- "--regexp-extended",
65
- "--posix",
66
- "--debug",
67
- "--sandbox",
68
- ],
69
- // -i and -l are deliberately absent: GNU sed attaches -i's suffix and gives -l an argument,
70
- // while BSD sed gives -i an argument and treats -l as a flag. Their arity is unknowable here,
71
- // so they land in the unrecognized bucket and disable exemption.
72
- valueOptions: [],
73
- dialect: "sed",
74
- };
75
-
76
- const AWK: OperandSpec = {
77
- exemptLeading: 1,
78
- suppressedBy: ["-f", "--file", "--source"],
79
- booleanOptions: ["--posix", "--traditional", "--re-interval", "-c"],
80
- valueOptions: ["-v", "--assign", "-F", "--field-separator"],
81
- dialect: "awk",
82
- };
83
-
84
- const GREP: OperandSpec = {
85
- exemptLeading: 1,
86
- suppressedBy: ["-e", "--regexp", "-f", "--file"],
87
- booleanOptions: [
88
- "-r",
89
- "-R",
90
- "--recursive",
91
- "-i",
92
- "--ignore-case",
93
- "-v",
94
- "--invert-match",
95
- "-n",
96
- "--line-number",
97
- "-H",
98
- "--with-filename",
99
- "-h",
100
- "--no-filename",
101
- "-c",
102
- "--count",
103
- "-l",
104
- "--files-with-matches",
105
- "-L",
106
- "--files-without-match",
107
- "-w",
108
- "--word-regexp",
109
- "-x",
110
- "--line-regexp",
111
- "-F",
112
- "--fixed-strings",
113
- "-E",
114
- "--extended-regexp",
115
- "-P",
116
- "--perl-regexp",
117
- "-o",
118
- "--only-matching",
119
- "-q",
120
- "--quiet",
121
- "-a",
122
- "--text",
123
- "-s",
124
- "--no-messages",
125
- "-z",
126
- "--null-data",
127
- "-b",
128
- "--byte-offset",
129
- "--color",
130
- "--colour",
131
- ],
132
- valueOptions: [
133
- "-m",
134
- "--max-count",
135
- "-A",
136
- "--after-context",
137
- "-B",
138
- "--before-context",
139
- "-C",
140
- "--context",
141
- "--include",
142
- "--exclude",
143
- "--exclude-dir",
144
- ],
145
- };
146
-
147
- const EMITTER: OperandSpec = {
148
- exemptLeading: 0,
149
- suppressedBy: [],
150
- booleanOptions: ["-n", "-e", "-E"],
151
- valueOptions: [],
152
- exemptAll: true,
153
- };
154
-
155
- /**
156
- * Per-command operand models.
157
- *
158
- * `find` and `rg` are deliberately absent. `find -exec` consumes an entire command run, and `rg` has
159
- * options that execute a program per input (`--pre`, `--hostname-bin`) — in both cases an operand
160
- * that looks like a pattern can be a path the command runs, so the cost of drawing the boundary
161
- * wrong is a bypass rather than a false positive. #2470 reports false positives for neither, so
162
- * there is nothing to buy. `grep`/`egrep`/`fgrep` stay: their option surface is POSIX-stable and
163
- * contains nothing that executes.
164
- */
165
- const SPECS: Record<string, OperandSpec> = {
166
- sed: SED,
167
- gsed: SED,
168
- awk: AWK,
169
- gawk: AWK,
170
- mawk: AWK,
171
- nawk: AWK,
172
- grep: GREP,
173
- egrep: GREP,
174
- fgrep: GREP,
175
- echo: EMITTER,
176
- printf: EMITTER,
177
- };
178
-
179
- /** A sed address: a line number, `$`, a `/regex/`, or nothing at all. */
180
- const SED_ADDRESS = String.raw`(?:\/(?:\\.|[^\/\\])*\/|[0-9]+|\$)?`;
181
-
182
- /**
183
- * sed commands that read a file (`r`, `R`), write one (`w`, `W`), or execute a shell command (`e`),
184
- * in command position after an optional address range. Anchoring on the address is what keeps
185
- * `s/red/blue/` exempt while catching `/foo/r file`.
186
- */
187
- const SED_FILE_ACCESS = new RegExp(
188
- String.raw`(?:^|[;{}\n])\s*${SED_ADDRESS}(?:,${SED_ADDRESS})?\s*!?\s*[rRwWe](?:\s|$)`,
189
- );
190
-
191
- /**
192
- * A substitution flag that writes a file (`w`) or executes the replacement as a shell command (`e`),
193
- * as in `s/a/b/w file` or `s|x|cat /elsewhere/secret|e`. The `e` flag makes the script arbitrary
194
- * code, so a script carrying it is never inert text.
195
- */
196
- const SED_SUBSTITUTE_EXECUTES = /s(.)(?:\\.|(?!\1)[^\\])*\1(?:\\.|(?!\1)[^\\])*\1[a-zA-Z0-9]*[we]/;
197
-
198
- /** awk constructs that read, write, or execute rather than just matching and printing. */
199
- const AWK_FILE_ACCESS = [
200
- /\bgetline\b/,
201
- /\bsystem\s*\(/,
202
- /\bclose\s*\(/,
203
- /\bfflush\s*\(/,
204
- /\|/, // a pipe to or from a command
205
- /\b(?:print|printf)\b[^;}\n]*>/,
206
- ];
207
-
208
- function hasFileAccessConstruct(dialect: "sed" | "awk" | undefined, script: string): boolean {
209
- if (dialect === "sed") return SED_FILE_ACCESS.test(script) || SED_SUBSTITUTE_EXECUTES.test(script);
210
- if (dialect === "awk") return AWK_FILE_ACCESS.some(pattern => pattern.test(script));
211
- return false;
212
- }
213
-
214
3
  /** The option name of a word, ignoring any `=value` suffix; undefined when it is not an option. */
215
4
  function optionName(text: string): string | undefined {
216
5
  if (!text.startsWith("-") || text === "-" || text === "--") return undefined;
@@ -218,67 +7,6 @@ function optionName(text: string): string | undefined {
218
7
  return equals === -1 ? text : text.slice(0, equals);
219
8
  }
220
9
 
221
- /**
222
- * Words of `cmd` whose contents the invoked command provably never opens as a path.
223
- *
224
- * Returns an empty array whenever anything is uncertain: an unknown command, a suppressing option, a
225
- * word that is unquoted or carries an expansion, a redirect target, or a script containing a
226
- * file-access construct.
227
- */
228
- export function provenExemptWords(cmd: ShellSimpleCommand): ShellWord[] {
229
- if (cmd.name === undefined) return [];
230
- const spec = SPECS[cmd.name];
231
- if (spec === undefined) return [];
232
-
233
- const operandWords = cmd.words.slice(cmd.operandStart);
234
-
235
- // Which operand holds the script depends entirely on how the options parsed, so an option the
236
- // model cannot parse exactly makes the whole command unmodellable. Two ways that bites:
237
- // a suppressing option carrying an attached argument (`-e's/a/b/'`) would go unrecognized and
238
- // let a real file operand slide into the script slot, and an option of unknown arity (`sed -i`)
239
- // would shift every operand by one. Both end in exempting a path the command really opens, so
240
- // anything unrecognized disables exemption for this command.
241
- for (const word of operandWords) {
242
- if (word.redirect !== undefined) continue;
243
- const option = optionName(word.text);
244
- if (option === undefined) continue;
245
- if (spec.suppressedBy.includes(option)) return [];
246
- if (!spec.booleanOptions.includes(option) && !spec.valueOptions.includes(option)) return [];
247
- }
248
-
249
- const exempt: ShellWord[] = [];
250
- let leadingSeen = 0;
251
-
252
- for (let i = 0; i < operandWords.length; i++) {
253
- const word = operandWords[i];
254
-
255
- // A redirect target is a real file the shell opens, whatever the command does with its args.
256
- if (word.redirect !== undefined) continue;
257
-
258
- const option = optionName(word.text);
259
- if (option !== undefined) {
260
- // An option's value is consumed here so it is never mistaken for the first operand.
261
- if (!word.text.includes("=") && spec.valueOptions.includes(option)) i++;
262
- continue;
263
- }
264
-
265
- if (!spec.exemptAll) {
266
- if (leadingSeen >= spec.exemptLeading) continue;
267
- leadingSeen++;
268
- }
269
-
270
- // Quoting is the author's own signal that the word is data. An unquoted or expansion-bearing
271
- // word is not provably anything, so it stays on the floor.
272
- if (word.quote !== "single" && word.quote !== "double") continue;
273
- if (!word.literal) continue;
274
- if (hasFileAccessConstruct(spec.dialect, word.text)) continue;
275
-
276
- exempt.push(word);
277
- }
278
-
279
- return exempt;
280
- }
281
-
282
10
  /** Flag-only options shared by the compressors. */
283
11
  const GZIP_BOOLEAN_OPTIONS = [
284
12
  "-1",
@@ -380,8 +108,8 @@ const COPY_BOOLEAN_OPTIONS = [
380
108
  * `tar -f` is absent because whether it reads or writes depends on the mode letters, and getting that
381
109
  * wrong in the permissive direction is the bug being fixed.
382
110
  *
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.
111
+ * An option this model cannot parse exactly shifts operand positions, so anything unrecognized
112
+ * abandons the command rather than guessing at a slot.
385
113
  */
386
114
  interface WriteOperandSpec {
387
115
  /** Options taking a separate value, so it is not mistaken for a positional operand. */
@@ -396,8 +124,8 @@ interface WriteOperandSpec {
396
124
  writesAllPositional?: boolean;
397
125
  /** The final positional operand is written (`cp src dst`). */
398
126
  writesLastPositional?: boolean;
399
- /** With this option present the destination moves into it, so positional slots stop being writes. */
400
- destinationOption?: string;
127
+ /** Options that move the destination into their value, so positional slots stop being writes. */
128
+ destinationOptions?: readonly string[];
401
129
  /** Positional operands that are not paths at all — `chmod 644 f`, `chown me f`. */
402
130
  skipLeadingPositional?: number;
403
131
  /** Every positional is written once this option appears (`install -d a b c`). */
@@ -413,7 +141,7 @@ const WRITE_OPERAND_SPECS: Record<string, WriteOperandSpec> = {
413
141
  booleanOptions: ["-a", "--append", "-i", "--ignore-interrupts", "-p"],
414
142
  writesAllPositional: true,
415
143
  },
416
- // `of=` is the output file. `if=` stays a read, which is what the floor already gives it.
144
+ // `of=` is the output file. `if=` is deliberately not interpreted by the pre-check.
417
145
  dd: { valueOptions: [], booleanOptions: [], outputPrefixes: ["of="] },
418
146
  sort: {
419
147
  valueOptions: ["-k", "-t", "-S", "-T", "--key", "--field-separator", "--buffer-size"],
@@ -424,7 +152,7 @@ const WRITE_OPERAND_SPECS: Record<string, WriteOperandSpec> = {
424
152
  valueOptions: ["-S", "--suffix", "-t", "--target-directory"],
425
153
  booleanOptions: COPY_BOOLEAN_OPTIONS,
426
154
  writesLastPositional: true,
427
- destinationOption: "-t",
155
+ destinationOptions: ["-t", "--target-directory"],
428
156
  },
429
157
  // `mv` also REMOVES its sources, so a source in a read-only root is a mutation of that root.
430
158
  // Marking only the destination let `mv /shared/ctx/file .` delete from a read-allowed grant.
@@ -433,13 +161,13 @@ const WRITE_OPERAND_SPECS: Record<string, WriteOperandSpec> = {
433
161
  booleanOptions: ["-f", "--force", "-i", "--interactive", "-n", "--no-clobber", "-v", "--verbose", "-u"],
434
162
  writesLastPositional: true,
435
163
  writesSourcesToo: true,
436
- destinationOption: "-t",
164
+ destinationOptions: ["-t", "--target-directory"],
437
165
  },
438
166
  install: {
439
167
  valueOptions: ["-m", "--mode", "-o", "--owner", "-g", "--group", "-t", "--target-directory", "-S", "--suffix"],
440
168
  booleanOptions: ["-b", "-c", "-C", "-d", "-D", "-p", "-s", "-v", "--verbose", "--backup"],
441
169
  writesLastPositional: true,
442
- destinationOption: "-t",
170
+ destinationOptions: ["-t", "--target-directory"],
443
171
  // `install -d a b c` creates every operand as a directory rather than copying into the last.
444
172
  allPositionalOption: "-d",
445
173
  },
@@ -465,7 +193,7 @@ const WRITE_OPERAND_SPECS: Record<string, WriteOperandSpec> = {
465
193
  valueOptions: ["-S", "--suffix", "-t", "--target-directory"],
466
194
  booleanOptions: ["-b", "-f", "--force", "-i", "-L", "-n", "-P", "-r", "-s", "--symbolic", "-v"],
467
195
  writesLastPositional: true,
468
- destinationOption: "-t",
196
+ destinationOptions: ["-t", "--target-directory"],
469
197
  },
470
198
  shred: {
471
199
  valueOptions: ["-n", "--iterations", "-s", "--size"],
@@ -538,7 +266,8 @@ export function writtenOperandWords(cmd: ShellSimpleCommand): ShellWord[] {
538
266
  // literally named `-t` makes `cp -- -t /drop/secret out/` read as a target-directory option and
539
267
  // relabels a source as a write target.
540
268
  let sawEndOfOptions = false;
541
- for (const word of operandWords) {
269
+ for (let index = 0; index < operandWords.length; index += 1) {
270
+ const word = operandWords[index];
542
271
  if (word.text === "--") {
543
272
  sawEndOfOptions = true;
544
273
  continue;
@@ -546,7 +275,10 @@ export function writtenOperandWords(cmd: ShellSimpleCommand): ShellWord[] {
546
275
  if (sawEndOfOptions) continue;
547
276
  const option = optionName(word.text);
548
277
  if (option === undefined) continue;
549
- if (isOutputOption(spec, option) || spec.valueOptions.includes(option)) continue;
278
+ if (isOutputOption(spec, option) || spec.valueOptions.includes(option)) {
279
+ if (!word.text.includes("=")) index += 1;
280
+ continue;
281
+ }
550
282
  if (spec.booleanOptions.includes(option)) continue;
551
283
  if (matchesAllPositionalOption(spec, option)) continue;
552
284
  if (bundledBooleans(option, spec) !== undefined) continue;
@@ -583,13 +315,11 @@ export function writtenOperandWords(cmd: ShellSimpleCommand): ShellWord[] {
583
315
  continue;
584
316
  }
585
317
  if (spec.valueOptions.includes(option)) {
586
- if (option === spec.destinationOption) destinationMoved = true;
587
- if (!attached) {
588
- const target = operandWords[index + 1];
589
- index += 1;
318
+ const target = attached ? valueAfterEquals(word) : operandWords[index + 1];
319
+ if (!attached) index += 1;
320
+ if (spec.destinationOptions?.includes(option)) {
321
+ destinationMoved = true;
590
322
  if (target?.literal) written.push(target);
591
- } else if (word.literal) {
592
- written.push(word);
593
323
  }
594
324
  }
595
325
  continue;
@@ -598,7 +328,7 @@ export function writtenOperandWords(cmd: ShellSimpleCommand): ShellWord[] {
598
328
  // `of=PATH` and friends are positional in shape but name their own direction.
599
329
  const prefix = spec.outputPrefixes?.find(candidate => word.text.startsWith(candidate));
600
330
  if (prefix !== undefined) {
601
- if (word.literal) written.push(word);
331
+ if (word.literal) written.push({ ...word, text: word.text.slice(prefix.length) });
602
332
  continue;
603
333
  }
604
334
 
@@ -7,9 +7,9 @@
7
7
  * profile could not even `execvp /bin/cat`.
8
8
  *
9
9
  * So the fence is gentle. It leaves `/usr`, `/tmp`, package caches, the network and process execution
10
- * alone. Its cross-tenant courtesy removes the discovery step — enumerating the session root's parent —
11
- * while keeping named operator access. Other operators' accounts, explicit data roots and cross-session
12
- * state remain denied where the model has no legitimate reason to wander (#2554).
10
+ * alone. Its cross-tenant courtesy removes discovery by enumerating session, account, and data
11
+ * containers while keeping named operator access. Xcsh-private cross-session state remains denied
12
+ * recursively unless the operator grants it explicitly (#2931).
13
13
  *
14
14
  * Produced declaratively rather than as an ordered rule list, because the two backends disagree about
15
15
  * order: seatbelt evaluates rules in sequence with the last match winning, while Landlock only grants
@@ -257,10 +257,8 @@ function canonicalThroughExisting(target: string): string {
257
257
  * otherwise deny `/`, `/usr` or `/tmp` — refusing exactly the work this fence is supposed to leave
258
258
  * alone.
259
259
  *
260
- * `/Users` and `/home` were here too, and deliberately are no longer: those hold other operators'
261
- * accounts, so denying them is the point rather than the accident (#2624). The workspace, the caches
262
- * and the tool config dirs all match at greater depth, and home is denied anyway, so nothing inside
263
- * the current account changes.
260
+ * `/Users` and `/home` are deliberately absent: their exact listings are protected separately while
261
+ * named account paths remain available (#2931).
264
262
  */
265
263
  function tooBroadToDeny(candidate: string, fsRoot: string): boolean {
266
264
  if (candidate === path.parse(candidate).root) return true;
@@ -348,14 +346,12 @@ function resolveGrants(roots: readonly string[] | undefined, home: string): Set<
348
346
  * Filesystem roots other than the one the workspace is on.
349
347
  *
350
348
  * On POSIX there is one root and this is empty. On Windows every drive letter is its own root, so a
351
- * workspace on `C:` left `D:\customerB` and any mapped share matching no rule at all — and Windows has
352
- * no kernel backend, so the command-text scan is the entire boundary there. While that scan was
353
- * deny-by-default it refused those paths incidentally; making it agree with the fence (#2624) removed
354
- * the cover and exposed the gap. Found by adversarial review.
349
+ * workspace on `C:` otherwise leaves the `D:\` account/data container discoverable. Windows has no
350
+ * kernel backend, so this exact-enumeration rule is enforced for structured tools but cannot confine a
351
+ * Bash child process.
355
352
  *
356
353
  * **Unverified on Windows.** The probe below cannot run on the platforms this fleet uses, so what is
357
- * tested is the denying, not the discovery — see the test, which injects the list. A drive holding a
358
- * toolchain is opened again with `--allow-path`, which grants at greater depth.
354
+ * tested is the exact-enumeration protection, not discovery — see the test, which injects the list.
359
355
  *
360
356
  * UNC paths (`\\server\share`) have no enumerable root and are not covered.
361
357
  */
@@ -446,10 +442,12 @@ export function buildContainmentFence(options: ContainmentOptions): ContainmentF
446
442
  // Through `resolveGrants` like the allow-lists, so a session temp dir or artifacts dir that does not
447
443
  // exist yet is still granted rather than silently dropped.
448
444
  const tildeHome = canonical(options.homeForTilde ?? options.home ?? os.homedir()) ?? os.homedir();
449
- for (const root of resolveGrants(
450
- [options.sessionTmp, ...(options.extraRoots ?? [])].filter((r): r is string => r !== undefined),
445
+ const sessionTmpResolved = resolveGrants(
446
+ options.sessionTmp === undefined ? undefined : [options.sessionTmp],
451
447
  tildeHome,
452
- )) {
448
+ );
449
+ const extraResolved = resolveGrants(options.extraRoots, tildeHome);
450
+ for (const root of [...sessionTmpResolved, ...extraResolved]) {
453
451
  allow.add(root);
454
452
  }
455
453
  // Kept distinct. Merging them into one read+write list made a folder shared for reading writable,
@@ -474,15 +472,17 @@ export function buildContainmentFence(options: ContainmentOptions): ContainmentF
474
472
  // posture is intentionally not enough: it keeps named access working, while this one exact directory
475
473
  // still cannot be scanned. `--allow-path` appears in both settings lists, so it reaches this branch via
476
474
  // `readOnlyResolved`; a write-only grant does not imply permission to learn directory entries.
477
- const parentExplicitlyReadable = [...readOnlyResolved].some(root => pathIsWithin(root, parentToProtect));
475
+ const parentExplicitlyReadable = [...extraResolved, ...readOnlyResolved].some(root =>
476
+ pathIsWithin(root, parentToProtect),
477
+ );
478
478
  if (!tooBroadToDeny(parentToProtect, fsRoot) && !parentExplicitlyReadable) {
479
479
  denyEnumerate.add(parentToProtect);
480
480
  }
481
481
 
482
482
  if (home !== undefined) {
483
483
  // The account container is a data root, but this operator's whole home belongs to them (#2637).
484
- // A deeper full allow preserves their normal filesystem rights while leaving sibling accounts under
485
- // the broader deny. Cross-session stores are denied again at still greater depth below.
484
+ // A deeper full allow preserves their normal filesystem rights. The account container loses only
485
+ // enumeration, and cross-session stores are denied again at still greater depth below.
486
486
  allow.add(home);
487
487
 
488
488
  // Granted whether or not they exist yet. `~/.bun` has to be writable *before* the first
@@ -493,21 +493,17 @@ export function buildContainmentFence(options: ContainmentOptions): ContainmentF
493
493
  }
494
494
  }
495
495
 
496
- // Top-level directories that hold somebody's files. Without these the fence covered only home and
497
- // the workspace's ancestors, so with the workspace at `~/MEDDPICC/CUSTOMER-A` another operator's
498
- // account, an external volume and `/data/globex` were all readable AND writable — measured. The
499
- // command-text scan was refusing them on the way in, which is exactly why that scan could not
500
- // simply be stood down (#2624).
496
+ // Top-level directories that hold somebody's files lose only their own directory listing. Named
497
+ // descendants remain available with the operator's normal filesystem rights: this fence removes
498
+ // casual discovery, not professional access to a path the operator already knows (#2931).
501
499
  //
502
500
  // Two sources, and the static one is not redundant: enumeration is a single `readdir` that can
503
501
  // fail, and coverage that evaporates with it would be worse than no claim at all. So the known data
504
- // roots are denied by name, and enumeration adds whatever this machine has that the list does not
505
- // foresee.
502
+ // roots are protected by name, and enumeration adds whatever this machine has that the list does
503
+ // not foresee.
506
504
  //
507
505
  // The known roots are resolved through their existing ancestors rather than dropped when absent, so
508
- // a `/data` created *after* the session starts is already denied. Seatbelt matches a `(subpath …)`
509
- // prefix whether or not the path exists; Landlock cannot attach a rule to an absent inode, but its
510
- // plan never grants a path `readdir` did not see either, so nothing is lost there.
506
+ // a `/data` created *after* the session starts already has its exact listing protected.
511
507
  const known = DATA_ROOTS.map(name => canonicalThroughExisting(path.join(fsRoot, path.basename(name))));
512
508
  const accountRoots = new Set(["Users", "home"].map(name => canonicalThroughExisting(path.join(fsRoot, name))));
513
509
  const found = dataRootEntries(fsRoot).map(entry => canonical(entry) ?? entry);
@@ -525,17 +521,15 @@ export function buildContainmentFence(options: ContainmentOptions): ContainmentF
525
521
  // e.g. /Volumes/Macintosh HD -> /. Skipped for the whole-root list, per the note above.
526
522
  if (!rootScoped.has(resolved) && tooBroadToDeny(resolved, fsRoot)) continue;
527
523
  if (resolved === fsRoot) continue; // never the root the workspace lives on
528
- // A directory containing home is normally too broad to deny because it would revoke the operator's
529
- // own files (#2637). Account containers are the deliberate exception: they are denied here and the
530
- // canonical home allow above wins at greater depth, isolating sibling accounts without hobbling the
531
- // operator (#2788).
524
+ // A directory containing home is normally too broad to protect because it would hide unrelated
525
+ // operational entries. Account containers are the deliberate exception: their listing is the
526
+ // discovery surface, while every named account remains reachable (#2788, #2931).
532
527
  if (home !== undefined && pathIsWithin(resolved, home) && !accountRoots.has(resolved)) continue;
533
- // A deny beats an allow at EQUAL depth, so denying a root that IS the workspace or IS something
534
- // the operator granted would not be redundant — it would silently revoke the grant. Deeper is
535
- // fine and intended: an ancestor deny with the workspace allowed inside it is the normal shape.
528
+ // The session workspace and explicit read/full grants retain enumeration. A write-only grant does
529
+ // not imply permission to learn directory entries.
536
530
  if (resolved === workspace) continue;
537
- if (allow.has(resolved) || allowReadOnly.has(resolved) || allowWriteOnly.has(resolved)) continue;
538
- deny.add(resolved);
531
+ if ([...extraResolved, ...readOnlyResolved].some(root => pathIsWithin(root, resolved))) continue;
532
+ denyEnumerate.add(resolved);
539
533
  }
540
534
 
541
535
  // Cross-session leak roots. These may sit *under* an allowed root — the agent dir is inside home,
@@ -550,8 +544,13 @@ export function buildContainmentFence(options: ContainmentOptions): ContainmentF
550
544
  getXCSHContextsDir(),
551
545
  ...sharedTempLeakRoots(),
552
546
  ];
547
+ const explicitGrants = [...extraResolved, ...readOnlyResolved, ...writeOnlyResolved];
553
548
  for (const leak of leaks) {
554
- deny.add(canonicalThroughExisting(leak));
549
+ const resolved = canonicalThroughExisting(leak);
550
+ // A grant at or above a private root is an explicit operator override. Directional grants stay
551
+ // directional because their allowReadOnly/allowWriteOnly rule remains the deepest matching rule.
552
+ if (explicitGrants.some(root => pathIsWithin(root, resolved))) continue;
553
+ deny.add(resolved);
555
554
  }
556
555
 
557
556
  return {
@@ -601,7 +600,7 @@ export type ContainmentBackend = "seatbelt" | "landlock" | "scanner-only" | "dis
601
600
  export interface ContainmentStatus {
602
601
  readonly enabled: boolean;
603
602
  readonly backend: ContainmentBackend;
604
- /** True when the kernel enforces it, false when only the command-text scan does. */
603
+ /** True when the kernel enforces it, false when only precise tool-call pre-checks run. */
605
604
  readonly osEnforced: boolean;
606
605
  /**
607
606
  * Set when the backend enforces reads and writes but cannot govern truncation.
@@ -621,8 +620,8 @@ export interface ContainmentStatus {
621
620
  *
622
621
  * Reported so an operator can tell a confined session from an unconfined one. The distinction is not
623
622
  * cosmetic: with a backend, a path is checked where it is opened and the spelling cannot matter;
624
- * without one, the only check reads the command text and is best-effort by construction. Two sessions
625
- * that look identical can offer very different guarantees, and `xcsh://about` is where that is stated.
623
+ * without one, only explicit tool-call effects can be checked before execution. Two sessions that look
624
+ * identical can offer very different guarantees, and `xcsh://about` is where that is stated.
626
625
  *
627
626
  * Deliberately not surfaced at startup or anywhere in the TUI — the operator asked for no UI change.
628
627
  */