@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
|
@@ -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
|
-
*
|
|
384
|
-
*
|
|
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
|
-
/**
|
|
400
|
-
|
|
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=`
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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))
|
|
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
|
-
|
|
587
|
-
if (!attached)
|
|
588
|
-
|
|
589
|
-
|
|
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
|
|
11
|
-
* while keeping named operator access.
|
|
12
|
-
*
|
|
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`
|
|
261
|
-
*
|
|
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:`
|
|
352
|
-
*
|
|
353
|
-
*
|
|
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
|
|
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
|
-
|
|
450
|
-
|
|
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 =>
|
|
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
|
|
485
|
-
//
|
|
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
|
|
497
|
-
//
|
|
498
|
-
//
|
|
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
|
|
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
|
|
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
|
|
529
|
-
//
|
|
530
|
-
//
|
|
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
|
-
//
|
|
534
|
-
//
|
|
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 (
|
|
538
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
625
|
-
*
|
|
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
|
*/
|