@f5-sales-demo/xcsh 19.98.1 → 19.98.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@f5-sales-demo/xcsh",
4
- "version": "19.98.1",
4
+ "version": "19.98.3",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://github.com/f5-sales-demo/xcsh",
7
7
  "author": "Can Boluk",
@@ -57,13 +57,13 @@
57
57
  "dependencies": {
58
58
  "@agentclientprotocol/sdk": "1.3.0",
59
59
  "@mozilla/readability": "^0.6",
60
- "@f5-sales-demo/xcsh-stats": "19.98.1",
61
- "@f5-sales-demo/pi-agent-core": "19.98.1",
62
- "@f5-sales-demo/pi-ai": "19.98.1",
63
- "@f5-sales-demo/pi-natives": "19.98.1",
64
- "@f5-sales-demo/pi-resource-management": "19.98.1",
65
- "@f5-sales-demo/pi-tui": "19.98.1",
66
- "@f5-sales-demo/pi-utils": "19.98.1",
60
+ "@f5-sales-demo/xcsh-stats": "19.98.3",
61
+ "@f5-sales-demo/pi-agent-core": "19.98.3",
62
+ "@f5-sales-demo/pi-ai": "19.98.3",
63
+ "@f5-sales-demo/pi-natives": "19.98.3",
64
+ "@f5-sales-demo/pi-resource-management": "19.98.3",
65
+ "@f5-sales-demo/pi-tui": "19.98.3",
66
+ "@f5-sales-demo/pi-utils": "19.98.3",
67
67
  "@sinclair/typebox": "^0.34",
68
68
  "@xterm/headless": "^6.0",
69
69
  "ajv": "^8.20",
@@ -104,13 +104,6 @@ export function needsProvision(reg: Registry, sessionId: string): boolean {
104
104
  return !reg.has(sessionId);
105
105
  }
106
106
 
107
- /** Lowest free port in the range not already held by a worker. */
108
- export function pickPort(reg: Registry, range: number[]): number | null {
109
- const used = new Set([...reg.values()].map(w => w.port));
110
- for (const p of range) if (!used.has(p)) return p;
111
- return null;
112
- }
113
-
114
107
  /** sessionIds whose worker has been idle longer than idleMs. */
115
108
  export function staleKeys(reg: Registry, now: number, idleMs: number): string[] {
116
109
  const out: string[] = [];
@@ -170,3 +163,35 @@ export function sparesToSpawn(
170
163
  const freeSlots = Math.max(0, totalPorts - activeWorkers - currentSpares);
171
164
  return Math.min(want, freeSlots);
172
165
  }
166
+
167
+ /**
168
+ * Choose a port to spawn on, probing ONLY ports that are not already spoken for.
169
+ *
170
+ * `isFree` is not a read — every implementation of it binds the port to find out,
171
+ * then releases it. Probing a port that has already been handed to a worker which
172
+ * is still starting up can therefore win that bind, and a worker whose forced
173
+ * `XCSH_BRIDGE_PORT` is occupied throws and exits rather than retrying
174
+ * ("XCSH_BRIDGE_PORT N is already in use", extension-bridge.ts). `proc.exited`
175
+ * then drops its registry entry, so the session ends up with no worker at all.
176
+ *
177
+ * That is the two-tab flake (#2463): provisioning a second tab swept the whole
178
+ * range, including the first tab's port, and could kill the first tab's worker
179
+ * mid-bind — leaving exactly one port advertising the tenant.
180
+ *
181
+ * `reserved` covers ports promised but not yet in the registry (pre-warmed spares
182
+ * live there until adopted). Probing is lazy: it stops at the first free port, so
183
+ * this binds no more ports than it has to.
184
+ */
185
+ export function selectSpawnPort(
186
+ reg: Registry,
187
+ range: readonly number[],
188
+ reserved: readonly number[],
189
+ isFree: (port: number) => boolean,
190
+ ): number | null {
191
+ const taken = new Set<number>([...reg.values()].map(w => w.port).concat(reserved));
192
+ for (const port of range) {
193
+ if (taken.has(port)) continue; // ours already — never probe it
194
+ if (isFree(port)) return port;
195
+ }
196
+ return null;
197
+ }
@@ -33,8 +33,8 @@ import {
33
33
  binaryIsStale,
34
34
  needsProvision,
35
35
  parseControlMsg,
36
- pickPort,
37
36
  type Registry,
37
+ selectSpawnPort,
38
38
  sparesToSpawn,
39
39
  staleKeys,
40
40
  touchLastSeen,
@@ -46,12 +46,16 @@ const IDLE_MS = 20 * 60_000;
46
46
  const SWEEP_MS = 60_000;
47
47
 
48
48
  /**
49
- * Whether a loopback TCP port is bindable RIGHT NOW. `pickPort` only dedupes
50
- * against the manager's own registry; a range port can still be held by another
51
- * app, a stale worker, or a second manager. We pre-filter the range with this so
52
- * a spawned worker (which binds its forced `XCSH_BRIDGE_PORT` strictly) never
53
- * lands on an occupied port and dies. Bun.listen binds and throws synchronously,
54
- * so this stays sync keeping provision idempotency race-free.
49
+ * Whether a loopback TCP port is bindable RIGHT NOW. A range port can be held by
50
+ * another app, a stale worker, or a second manager, and a spawned worker binds its
51
+ * forced `XCSH_BRIDGE_PORT` strictly it throws and exits rather than falling back
52
+ * so an occupied port must be ruled out before we hand it over.
53
+ *
54
+ * NOTE this is a BINDING probe, not a read: it takes the port and releases it. Only
55
+ * `selectSpawnPort` may call it, and only for ports we have not already handed out;
56
+ * probing one of our own in-flight workers can win the bind and kill it (#2463).
57
+ * Bun.listen binds and throws synchronously, so this stays sync — keeping provision
58
+ * idempotency race-free.
55
59
  */
56
60
  function isPortFree(port: number): boolean {
57
61
  try {
@@ -241,10 +245,12 @@ export default class Manager extends Command {
241
245
  const pool: SpareRec[] = [];
242
246
 
243
247
  const spawnSpare = (): void => {
244
- const usedPorts = new Set<number>([...reg.values()].map(w => w.port).concat(pool.map(s => s.port)));
245
- const port = pickPort(
248
+ // Same rule as spawnWorker: assigned and reserved ports are never probed.
249
+ const port = selectSpawnPort(
246
250
  reg,
247
- range.filter(p => !usedPorts.has(p) && isPortFree(p)),
251
+ range,
252
+ pool.map(s => s.port),
253
+ isPortFree,
248
254
  );
249
255
  if (port === null) return; // range full — do not pre-warm
250
256
  const proc = Bun.spawn([process.execPath, ...workerArgv()], {
@@ -388,8 +394,15 @@ export default class Manager extends Command {
388
394
  };
389
395
 
390
396
  const spawnWorker = (msg: { sessionId: string; tenant: string }, managerProvisionMs: number): void => {
391
- // Registry-dedupe (pickPort) over only the ports free at the OS level.
392
- const port = pickPort(reg, range.filter(isPortFree));
397
+ // Never probe a port we have already handed out: isPortFree BINDS to find
398
+ // out, so sweeping the whole range could win the bind from a worker that is
399
+ // still starting up and kill it (#2463). Spares are reserved the same way.
400
+ const port = selectSpawnPort(
401
+ reg,
402
+ range,
403
+ pool.map(s => s.port),
404
+ isPortFree,
405
+ );
393
406
  if (port === null) {
394
407
  console.error(`[xcsh manager] port range exhausted; cannot provision ${msg.sessionId}`);
395
408
  return;
@@ -17,17 +17,17 @@ export interface BuildInfo {
17
17
  }
18
18
 
19
19
  export const BUILD_INFO: BuildInfo = {
20
- "version": "19.98.1",
21
- "commit": "2cf64a9979fc34447e7e1f35b466fbcc789b2289",
22
- "shortCommit": "2cf64a9",
20
+ "version": "19.98.3",
21
+ "commit": "4d3b95e9c675ae810cc60b6829fa3541a042d1ac",
22
+ "shortCommit": "4d3b95e",
23
23
  "branch": "main",
24
- "tag": "v19.98.1",
25
- "commitDate": "2026-07-28T02:41:40Z",
26
- "buildDate": "2026-07-28T03:07:44.141Z",
24
+ "tag": "v19.98.3",
25
+ "commitDate": "2026-07-28T05:29:35Z",
26
+ "buildDate": "2026-07-28T05:56:14.196Z",
27
27
  "dirty": true,
28
28
  "prNumber": "",
29
29
  "repoUrl": "https://github.com/f5-sales-demo/xcsh",
30
30
  "repoSlug": "f5-sales-demo/xcsh",
31
- "commitUrl": "https://github.com/f5-sales-demo/xcsh/commit/2cf64a9979fc34447e7e1f35b466fbcc789b2289",
32
- "releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v19.98.1"
31
+ "commitUrl": "https://github.com/f5-sales-demo/xcsh/commit/4d3b95e9c675ae810cc60b6829fa3541a042d1ac",
32
+ "releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v19.98.3"
33
33
  };
@@ -116,6 +116,29 @@ function isSystemPath(resolved: string): boolean {
116
116
  return SYSTEM_READ_ROOTS.some(root => resolved === root || resolved.startsWith(`${root}${path.sep}`));
117
117
  }
118
118
 
119
+ /**
120
+ * Character devices that discard output or route it back to the caller's own descriptors. Writing
121
+ * to one stores nothing and reaches no file, so it cannot carry data across the boundary — and
122
+ * `> /dev/null` is too common to refuse.
123
+ *
124
+ * An exact list, not a `/dev` prefix: `/dev` also holds raw block devices like `/dev/disk0`, where
125
+ * a write is both an escape and a catastrophe.
126
+ */
127
+ const SYSTEM_WRITE_SINKS = new Set([
128
+ "/dev/null",
129
+ "/dev/zero",
130
+ "/dev/full",
131
+ "/dev/tty",
132
+ "/dev/stdin",
133
+ "/dev/stdout",
134
+ "/dev/stderr",
135
+ ]);
136
+
137
+ function isSystemWriteSink(resolved: string): boolean {
138
+ // /dev/fd/N is an already-open descriptor of this process, not a path it can newly reach.
139
+ return SYSTEM_WRITE_SINKS.has(resolved) || resolved.startsWith("/dev/fd/");
140
+ }
141
+
119
142
  function firstString(input: Record<string, unknown>, keys: string[]): string | undefined {
120
143
  for (const key of keys) {
121
144
  const value = input[key];
@@ -132,52 +155,193 @@ function looksLikePath(token: string): boolean {
132
155
  return path.isAbsolute(token) || token.startsWith("~") || /(^|[/\\])\.\.([/\\]|$)/.test(token);
133
156
  }
134
157
 
158
+ /** A path-like token found by the floor, with where it was found. */
159
+ interface PathOccurrence {
160
+ token: string;
161
+ /** Offset of the token's first character — past any opening quote — in the scanned string. */
162
+ at: number;
163
+ /** Set when a redirection operator in the raw text says what the shell will do with it. */
164
+ access?: SandboxAccess;
165
+ }
166
+
167
+ /**
168
+ * What a redirection operator does to the operand that follows it. `skip` is a here-string or a
169
+ * heredoc delimiter: that operand is literal data the shell never opens — verified against bash,
170
+ * where `cat <<</tmp/f` prints the string `/tmp/f` rather than the contents of that file.
171
+ */
172
+ function operandAccess(operator: string): SandboxAccess | "skip" {
173
+ if (operator.includes("<<")) return "skip";
174
+ // `<>` opens for both; the write side is the one a read-only grant must not license.
175
+ return operator.includes(">") ? "write" : "read";
176
+ }
177
+
178
+ /** A path to check, and the boundary to check it against. */
179
+ interface PathCandidate {
180
+ token: string;
181
+ access: SandboxAccess;
182
+ }
183
+
184
+ /** Write first, so a `<>` denial names the stricter boundary the caller is most likely missing. */
185
+ const WRITE_AND_READ = ["write", "read"] as const satisfies readonly SandboxAccess[];
186
+
187
+ /** A redirection operator, with its optional file-descriptor prefix. Longest forms first. */
188
+ const REDIRECT_OPERATOR = /[0-9]*(?:&>>|&>|<<<|<<-|<<|>>|>\||<>|>&|<&|>|<)/g;
189
+
190
+ /** A whitespace token that is only a redirection operator, so the next token is its operand. */
191
+ const BARE_REDIRECT = /^[0-9]*(?:&>>|&>|<<<|<<-|<<|>>|>\||<>|>&|<&|>|<)$/;
192
+
193
+ /**
194
+ * A path glued to its option, as one shell word (#2524).
195
+ *
196
+ * `path.isAbsolute("if=/work/custB/secret")` is false, so the floor's whole-token test
197
+ * never saw these — a single space was the difference between the blocked form and the
198
+ * allowed one.
199
+ *
200
+ * Only the option's VALUE is captured, never an arbitrary `/`-containing substring. That
201
+ * distinction is what keeps #2470 shut: scanning any slash-bearing fragment would read
202
+ * `sed -n '/a/p'` as a path again, which is the false positive #2479 exists to remove.
203
+ * The captured value still has to satisfy `looksLikePath`, so `--output=./out` stays
204
+ * allowed while `--output=/work/custB/x` does not.
205
+ *
206
+ * The short-option form additionally requires its value to begin with `/` or `~`. Without
207
+ * that, `-la` and friends would be read as an option carrying a relative path.
208
+ */
209
+ const OPTION_VALUE_FORMS: readonly RegExp[] = [
210
+ /^-{1,2}[A-Za-z0-9][^=]*=(.+)$/, // --output=/p, -o=/p
211
+ /^[A-Za-z_][A-Za-z0-9_]*=(.+)$/, // if=/p, of=/p (operand style)
212
+ /^-[A-Za-z0-9]+([/~].*)$/, // -o/p, -C/p (no separator)
213
+ ];
214
+
135
215
  /**
136
216
  * Path-like tokens in a command/code string: bare (whitespace-split) and quoted.
137
217
  *
138
218
  * Indiscriminate by design, and that is the point: because it never asks what a command *means*, it
139
219
  * also catches paths hidden inside quoted scripts, heredoc bodies, `-exec` runs and substitutions.
140
220
  * This is the coverage floor for both bash and python. Do not narrow it.
221
+ *
222
+ * Offsets come along so a caller can tell one occurrence of a token from another. Nothing else about
223
+ * what this finds has changed: the two passes and `looksLikePath` are the floor.
141
224
  */
142
- function codePathTokens(command: string): string[] {
143
- const tokens = new Set<string>();
144
- for (const raw of command.split(/\s+/)) tokens.add(raw.replace(/^["']|["']$/g, ""));
145
- for (const match of command.matchAll(/["']([^"']+)["']/g)) tokens.add(match[1]);
146
- return [...tokens].filter(token => token.length > 0 && looksLikePath(token));
225
+ function codePathOccurrences(command: string): PathOccurrence[] {
226
+ const found: PathOccurrence[] = [];
227
+ const add = (token: string, at: number, access?: SandboxAccess): void => {
228
+ if (token.length > 0 && looksLikePath(token)) found.push({ token, at, access });
229
+ };
230
+ // Set when the previous token was nothing but a redirection operator, so this one is its operand.
231
+ let carried: SandboxAccess | "skip" | undefined;
232
+ for (const match of command.matchAll(/\S+/g)) {
233
+ const raw = match[0];
234
+ const operand = carried;
235
+ carried = undefined;
236
+
237
+ if (BARE_REDIRECT.test(raw)) {
238
+ carried = operandAccess(raw);
239
+ continue;
240
+ }
241
+
242
+ const stripped = raw.replace(/^["']|["']$/g, "");
243
+ const openingQuote = raw.length !== stripped.length && (raw[0] === '"' || raw[0] === "'") ? 1 : 0;
244
+ if (operand !== "skip") add(stripped, match.index + openingQuote, operand);
245
+ // An operator glued to its operand is one whitespace token, and `>/work/x` is not absolute.
246
+ // The lexer resolves this for words it can see, but not for text inside a quoted script or a
247
+ // heredoc body — where the floor is the only thing looking — so scan past the operator here
248
+ // too, anywhere it appears in the token: `echo a>/work/x` is one token as well. The operator
249
+ // is also the only thing that says which boundary a nested redirect crosses.
250
+ for (const operator of stripped.matchAll(REDIRECT_OPERATOR)) {
251
+ const access = operandAccess(operator[0]);
252
+ if (access === "skip") continue;
253
+ const from = operator.index + operator[0].length;
254
+ add(stripped.slice(from).replace(/^["']|["']$/g, ""), match.index + openingQuote + from, access);
255
+ }
256
+
257
+ // An option glued to its value is one word too, and the lexer cannot help: it
258
+ // correctly reports `if=/work/custB/secret` as a single word, because that is what
259
+ // it is. Which options introduce a filename is command-specific knowledge the floor
260
+ // deliberately does not have, so scan the value and let `looksLikePath` decide.
261
+ //
262
+ // Skipped after a here-string or heredoc delimiter for the same reason the whole
263
+ // token is: that operand is literal data the shell never opens, so `cat <<<if=/tmp/f`
264
+ // prints the text rather than reading the file.
265
+ if (operand !== "skip") {
266
+ for (const form of OPTION_VALUE_FORMS) {
267
+ const optionValue = stripped.match(form);
268
+ if (!optionValue?.[1]) continue;
269
+ const from = stripped.length - optionValue[1].length;
270
+ add(optionValue[1].replace(/^["']|["']$/g, ""), match.index + openingQuote + from, operand);
271
+ break; // the forms overlap; the first that matches has already captured the value
272
+ }
273
+ }
274
+ }
275
+ for (const match of command.matchAll(/["']([^"']+)["']/g)) add(match[1], match.index + 1);
276
+ return found;
277
+ }
278
+
279
+ /** The floor as plain read candidates — what a non-shell language gets. */
280
+ function codePathCandidates(command: string): PathCandidate[] {
281
+ return codePathOccurrences(command).map(({ token }) => ({ token, access: "read" as const }));
147
282
  }
148
283
 
149
284
  /**
150
- * Path-like tokens of a *bash* command, ignoring words the invoked command provably treats as a
151
- * script or pattern rather than a filename (issue #2470: `sed -n '/a/p'` was refused because `/a/p`
152
- * looks absolute, though sed never opens it).
285
+ * Path candidates of a *bash* command. Three things happen here, and only the first narrows:
153
286
  *
154
- * Implemented by blanking the exempt spans and re-running the floor over what remains, rather than
155
- * by subtracting a set of token strings. Set subtraction loses which *occurrence* a token came from,
156
- * so `echo '/elsewhere/x' && cat /elsewhere/x` would exempt the echo operand and thereby clear the
157
- * identical token belonging to `cat`. Blanking is positional and cannot leak that way.
287
+ * 1. **Exempt words are blanked.** Words the invoked command provably treats as a script or pattern
288
+ * rather than a filename stop being scanned (issue #2470: `sed -n '/a/p'` was refused because
289
+ * `/a/p` looks absolute, though sed never opens it). Implemented by blanking the exempt spans and
290
+ * re-running the floor over what remains, rather than by subtracting a set of token strings. Set
291
+ * subtraction loses which *occurrence* a token came from, so `echo '/elsewhere/x' && cat
292
+ * /elsewhere/x` would exempt the echo operand and thereby clear the identical token belonging to
293
+ * `cat`. Blanking is positional and cannot leak that way.
158
294
  *
159
- * It also keeps the floor's reach: text the lexer never turns into a word a heredoc body, an
295
+ * 2. **A word the shell opens for writing is checked against the write boundary** (issue #2516).
296
+ * Every candidate used to be checked as a read, so a path granted read access but not write was
297
+ * writable through `>`. Marking is by span, for the same occurrence-identity reason as blanking:
298
+ * in `cat /shared/x && printf y > /shared/x` only the second occurrence is the write.
299
+ *
300
+ * 3. **Redirect targets are added as candidates** (issue #2520). The floor splits on whitespace, so
301
+ * an operator glued to its path — `cat </work/custB/secret` — was one token that did not look
302
+ * like a path and was never checked at all. The lexer parses those correctly, so its redirect
303
+ * targets go in on top of the floor. This only ever adds candidates.
304
+ *
305
+ * The floor's reach is unchanged: text the lexer never turns into a word — a heredoc body, an
160
306
  * `-exec` run — is not blanked, so it is still scanned. When the command cannot be lexed
161
- * confidently, nothing is blanked at all.
307
+ * confidently, the floor stands alone.
162
308
  */
163
- function shellPathTokens(command: string): string[] {
164
- const floor = codePathTokens(command);
165
- if (floor.length === 0) return floor;
166
-
309
+ function shellPathCandidates(command: string): PathCandidate[] {
167
310
  const lexed = lexShellCommand(command);
168
- // Unbalanced quotes mean the word boundaries are guesses; keep the whole floor.
169
- if (lexed.unterminated) return floor;
311
+ // Unbalanced quotes mean every word boundary is a guess: neither the blanking nor the write
312
+ // marking below can be trusted, so fall back to the floor, checked as reads.
313
+ if (lexed.unterminated) return codePathCandidates(command);
170
314
 
171
315
  const exemptSpans = lexed.commands.flatMap(simpleCommand => provenExemptWords(simpleCommand));
172
- if (exemptSpans.length === 0) return floor;
173
-
316
+ let scanned = command;
174
317
  // Replace each exempt word with equivalent-length whitespace, so surrounding offsets and word
175
318
  // boundaries are preserved and only that word's own text stops being scanned.
176
- let masked = command;
177
319
  for (const word of exemptSpans) {
178
- masked = masked.slice(0, word.start) + " ".repeat(word.end - word.start) + masked.slice(word.end);
320
+ scanned = scanned.slice(0, word.start) + " ".repeat(word.end - word.start) + scanned.slice(word.end);
179
321
  }
180
- return codePathTokens(masked);
322
+
323
+ // `<>` opens for both, so it stays a read here and picks up its write below: a floor occurrence
324
+ // may carry only one access, and read is the one the floor would have used anyway.
325
+ const writeTargets = lexed.words.filter(word => word.redirect === "write");
326
+ const inWriteTarget = (at: number): boolean => writeTargets.some(word => at >= word.start && at < word.end);
327
+
328
+ // A floor occurrence takes the access its own operator gave it; failing that, the span of a
329
+ // lexed write target it sits inside; failing that, read.
330
+ const candidates: PathCandidate[] = codePathOccurrences(scanned).map(({ token, at, access }) => ({
331
+ token,
332
+ access: access ?? (inWriteTarget(at) ? "write" : "read"),
333
+ }));
334
+
335
+ // Not gated on `looksLikePath`: that test is for the floor's *guesses* about which fragments of a
336
+ // command might be filenames. A redirect target is one the shell will certainly open, so a bare
337
+ // `out.txt` is checked too — it resolves under the cwd, which a read-only cwd does not license.
338
+ for (const word of lexed.words) {
339
+ if (word.redirect === undefined || word.redirect === "here-string") continue;
340
+ for (const access of word.redirect === "read-write" ? WRITE_AND_READ : [word.redirect]) {
341
+ candidates.push({ token: word.text, access });
342
+ }
343
+ }
344
+ return candidates;
181
345
  }
182
346
 
183
347
  /** Base directories a search input would actually search, split like the tools do. */
@@ -216,13 +380,19 @@ function evaluateCodeTool(check: ToolCallCheck, fields: string[], shell: boolean
216
380
  }
217
381
 
218
382
  for (const command of commands) {
219
- // Only bash gets the shell-aware subtraction. Python is not shell: lexing `open('/x')` as
220
- // shell yields one non-absolute word and would lose the check entirely.
221
- for (const token of shell ? shellPathTokens(command) : codePathTokens(command)) {
383
+ // Only bash gets the shell-aware treatment. Python is not shell: lexing `open('/x')` as
384
+ // shell yields one non-absolute word and would lose the check entirely, and it has no
385
+ // redirects, so every candidate it produces is a read.
386
+ const seen = new Set<string>();
387
+ for (const { token, access } of shell ? shellPathCandidates(command) : codePathCandidates(command)) {
388
+ if (!seen.add(`${access}\0${token}`)) continue;
222
389
  const resolved = resolveToCwd(token, base);
223
- if (!policy.isAllowed(resolved, "read") && !isSystemPath(resolved)) {
224
- return deny(policy, resolved, "read");
225
- }
390
+ if (policy.isAllowed(resolved, access)) continue;
391
+ // SYSTEM_READ_ROOTS is a read allowance — "directories a subprocess may legitimately
392
+ // read or traverse". It never licenses writing into /etc, /usr or /opt; only the
393
+ // discard-and-echo devices are writable.
394
+ if (access === "read" ? isSystemPath(resolved) : isSystemWriteSink(resolved)) continue;
395
+ return deny(policy, resolved, access);
226
396
  }
227
397
  }
228
398
 
@@ -23,6 +23,12 @@
23
23
  /** How a word was quoted. `mixed` when built from differently-quoted segments, as in `a"b"'c'`. */
24
24
  export type QuoteKind = "none" | "single" | "double" | "ansi-c" | "mixed";
25
25
 
26
+ /**
27
+ * What the shell will do with a redirect operand. `<>` opens the file for both. `here-string` is
28
+ * `<<<`, whose operand is literal text supplied on stdin — the shell never opens it as a path.
29
+ */
30
+ export type RedirectDirection = "read" | "write" | "read-write" | "here-string";
31
+
26
32
  export interface ShellWord {
27
33
  /** Literal text after quote removal and backslash processing. */
28
34
  text: string;
@@ -37,8 +43,11 @@ export interface ShellWord {
37
43
  * must not treat a non-literal word as a resolvable path or a whole-word URL.
38
44
  */
39
45
  literal: boolean;
40
- /** Set when this word is the target of a redirection, with the direction of that redirect. */
41
- redirect: "read" | "write" | undefined;
46
+ /**
47
+ * Set when this word is the target of a redirection, with the direction of that redirect.
48
+ * `read-write` is `<>`, which opens the one file for both.
49
+ */
50
+ redirect: RedirectDirection | undefined;
42
51
  }
43
52
 
44
53
  export type ShellOperator = "|" | "||" | "&&" | ";" | "&" | "\n";
@@ -245,7 +254,7 @@ function readOperator(lexer: Lexer): ShellOperator | undefined {
245
254
  }
246
255
 
247
256
  type RedirectToken =
248
- | { kind: "file"; direction: "read" | "write" }
257
+ | { kind: "file"; direction: RedirectDirection }
249
258
  | { kind: "fd-dup" }
250
259
  | { kind: "heredoc"; stripTabs: boolean };
251
260
 
@@ -274,10 +283,10 @@ function readRedirect(lexer: Lexer, end: number): RedirectToken | undefined {
274
283
  return { kind: "file", direction: "write" };
275
284
  }
276
285
  if (src.startsWith("<<<", cursor)) {
277
- // A here-string supplies literal text, not a filename, but treating it as a redirect target
278
- // keeps it out of the exemptible-operand set.
286
+ // A here-string supplies literal text, not a filename. It stays a redirect target so it is
287
+ // still kept out of the exemptible-operand set, but its direction says it is never opened.
279
288
  lexer.pos = cursor + 3;
280
- return { kind: "file", direction: "read" };
289
+ return { kind: "file", direction: "here-string" };
281
290
  }
282
291
  if (src.startsWith("<<-", cursor)) {
283
292
  lexer.pos = cursor + 3;
@@ -287,15 +296,33 @@ function readRedirect(lexer: Lexer, end: number): RedirectToken | undefined {
287
296
  lexer.pos = cursor + 2;
288
297
  return { kind: "heredoc", stripTabs: false };
289
298
  }
290
- if (src.startsWith(">&", cursor) || src.startsWith("<&", cursor)) {
299
+ // `<>` opens one file for reading and writing; reported as `<` alone the write would be invisible.
300
+ if (src.startsWith("<>", cursor)) {
291
301
  lexer.pos = cursor + 2;
292
- while (lexer.pos < end && (isDigit(src[lexer.pos]) || src[lexer.pos] === "-")) lexer.pos++;
293
- return { kind: "fd-dup" };
302
+ return { kind: "file", direction: "read-write" };
303
+ }
304
+ // `>&` and `<&` duplicate a descriptor only when a descriptor follows: `2>&1`, `3>&-`. With a
305
+ // filename after it — `printf x >&out.txt` — bash opens the file instead, sending both stdout
306
+ // and stderr to it, so the target has to be checked like any other write.
307
+ if (src.startsWith(">&", cursor) || src.startsWith("<&", cursor)) {
308
+ const write = src[cursor] === ">";
309
+ let scan = cursor + 2;
310
+ while (scan < end && isDigit(src[scan])) scan++;
311
+ if (src[scan] === "-") scan++;
312
+ const duplicated = scan > cursor + 2 && (scan >= end || isWordBreak(src[scan]));
313
+ lexer.pos = duplicated ? scan : cursor + 2;
314
+ return duplicated ? { kind: "fd-dup" } : { kind: "file", direction: write ? "write" : "read" };
294
315
  }
295
316
  if (src.startsWith(">>", cursor)) {
296
317
  lexer.pos = cursor + 2;
297
318
  return { kind: "file", direction: "write" };
298
319
  }
320
+ // `>|` overrides noclobber. Without this the `|` reads as a pipe and the filename after it
321
+ // becomes the next command's name, so the write target is lost.
322
+ if (src.startsWith(">|", cursor)) {
323
+ lexer.pos = cursor + 2;
324
+ return { kind: "file", direction: "write" };
325
+ }
299
326
  if (ch === ">") {
300
327
  lexer.pos = cursor + 1;
301
328
  return { kind: "file", direction: "write" };
@@ -304,6 +331,11 @@ function readRedirect(lexer: Lexer, end: number): RedirectToken | undefined {
304
331
  return { kind: "file", direction: "read" };
305
332
  }
306
333
 
334
+ /** True where a word ends: whitespace, or the start of an operator or grouping. */
335
+ function isWordBreak(ch: string | undefined): boolean {
336
+ return ch === undefined || /[\s;&|<>()]/.test(ch);
337
+ }
338
+
307
339
  function isDigit(ch: string | undefined): boolean {
308
340
  return ch !== undefined && ch >= "0" && ch <= "9";
309
341
  }