@f5-sales-demo/xcsh 19.98.1 → 19.98.2

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.2",
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.2",
61
+ "@f5-sales-demo/pi-agent-core": "19.98.2",
62
+ "@f5-sales-demo/pi-ai": "19.98.2",
63
+ "@f5-sales-demo/pi-natives": "19.98.2",
64
+ "@f5-sales-demo/pi-resource-management": "19.98.2",
65
+ "@f5-sales-demo/pi-tui": "19.98.2",
66
+ "@f5-sales-demo/pi-utils": "19.98.2",
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.2",
21
+ "commit": "0721dc78b3894f7f79f8121b7ccde4de06e2c711",
22
+ "shortCommit": "0721dc7",
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.2",
25
+ "commitDate": "2026-07-28T03:43:05Z",
26
+ "buildDate": "2026-07-28T04:18:37.474Z",
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/0721dc78b3894f7f79f8121b7ccde4de06e2c711",
32
+ "releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v19.98.2"
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,153 @@ 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
+
135
193
  /**
136
194
  * Path-like tokens in a command/code string: bare (whitespace-split) and quoted.
137
195
  *
138
196
  * Indiscriminate by design, and that is the point: because it never asks what a command *means*, it
139
197
  * also catches paths hidden inside quoted scripts, heredoc bodies, `-exec` runs and substitutions.
140
198
  * This is the coverage floor for both bash and python. Do not narrow it.
199
+ *
200
+ * Offsets come along so a caller can tell one occurrence of a token from another. Nothing else about
201
+ * what this finds has changed: the two passes and `looksLikePath` are the floor.
141
202
  */
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));
203
+ function codePathOccurrences(command: string): PathOccurrence[] {
204
+ const found: PathOccurrence[] = [];
205
+ const add = (token: string, at: number, access?: SandboxAccess): void => {
206
+ if (token.length > 0 && looksLikePath(token)) found.push({ token, at, access });
207
+ };
208
+ // Set when the previous token was nothing but a redirection operator, so this one is its operand.
209
+ let carried: SandboxAccess | "skip" | undefined;
210
+ for (const match of command.matchAll(/\S+/g)) {
211
+ const raw = match[0];
212
+ const operand = carried;
213
+ carried = undefined;
214
+
215
+ if (BARE_REDIRECT.test(raw)) {
216
+ carried = operandAccess(raw);
217
+ continue;
218
+ }
219
+
220
+ const stripped = raw.replace(/^["']|["']$/g, "");
221
+ const openingQuote = raw.length !== stripped.length && (raw[0] === '"' || raw[0] === "'") ? 1 : 0;
222
+ if (operand !== "skip") add(stripped, match.index + openingQuote, operand);
223
+ // An operator glued to its operand is one whitespace token, and `>/work/x` is not absolute.
224
+ // The lexer resolves this for words it can see, but not for text inside a quoted script or a
225
+ // heredoc body — where the floor is the only thing looking — so scan past the operator here
226
+ // too, anywhere it appears in the token: `echo a>/work/x` is one token as well. The operator
227
+ // is also the only thing that says which boundary a nested redirect crosses.
228
+ for (const operator of stripped.matchAll(REDIRECT_OPERATOR)) {
229
+ const access = operandAccess(operator[0]);
230
+ if (access === "skip") continue;
231
+ const from = operator.index + operator[0].length;
232
+ add(stripped.slice(from).replace(/^["']|["']$/g, ""), match.index + openingQuote + from, access);
233
+ }
234
+ }
235
+ for (const match of command.matchAll(/["']([^"']+)["']/g)) add(match[1], match.index + 1);
236
+ return found;
237
+ }
238
+
239
+ /** The floor as plain read candidates — what a non-shell language gets. */
240
+ function codePathCandidates(command: string): PathCandidate[] {
241
+ return codePathOccurrences(command).map(({ token }) => ({ token, access: "read" as const }));
147
242
  }
148
243
 
149
244
  /**
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).
245
+ * Path candidates of a *bash* command. Three things happen here, and only the first narrows:
153
246
  *
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.
247
+ * 1. **Exempt words are blanked.** Words the invoked command provably treats as a script or pattern
248
+ * rather than a filename stop being scanned (issue #2470: `sed -n '/a/p'` was refused because
249
+ * `/a/p` looks absolute, though sed never opens it). Implemented by blanking the exempt spans and
250
+ * re-running the floor over what remains, rather than by subtracting a set of token strings. Set
251
+ * subtraction loses which *occurrence* a token came from, so `echo '/elsewhere/x' && cat
252
+ * /elsewhere/x` would exempt the echo operand and thereby clear the identical token belonging to
253
+ * `cat`. Blanking is positional and cannot leak that way.
158
254
  *
159
- * It also keeps the floor's reach: text the lexer never turns into a word a heredoc body, an
255
+ * 2. **A word the shell opens for writing is checked against the write boundary** (issue #2516).
256
+ * Every candidate used to be checked as a read, so a path granted read access but not write was
257
+ * writable through `>`. Marking is by span, for the same occurrence-identity reason as blanking:
258
+ * in `cat /shared/x && printf y > /shared/x` only the second occurrence is the write.
259
+ *
260
+ * 3. **Redirect targets are added as candidates** (issue #2520). The floor splits on whitespace, so
261
+ * an operator glued to its path — `cat </work/custB/secret` — was one token that did not look
262
+ * like a path and was never checked at all. The lexer parses those correctly, so its redirect
263
+ * targets go in on top of the floor. This only ever adds candidates.
264
+ *
265
+ * The floor's reach is unchanged: text the lexer never turns into a word — a heredoc body, an
160
266
  * `-exec` run — is not blanked, so it is still scanned. When the command cannot be lexed
161
- * confidently, nothing is blanked at all.
267
+ * confidently, the floor stands alone.
162
268
  */
163
- function shellPathTokens(command: string): string[] {
164
- const floor = codePathTokens(command);
165
- if (floor.length === 0) return floor;
166
-
269
+ function shellPathCandidates(command: string): PathCandidate[] {
167
270
  const lexed = lexShellCommand(command);
168
- // Unbalanced quotes mean the word boundaries are guesses; keep the whole floor.
169
- if (lexed.unterminated) return floor;
271
+ // Unbalanced quotes mean every word boundary is a guess: neither the blanking nor the write
272
+ // marking below can be trusted, so fall back to the floor, checked as reads.
273
+ if (lexed.unterminated) return codePathCandidates(command);
170
274
 
171
275
  const exemptSpans = lexed.commands.flatMap(simpleCommand => provenExemptWords(simpleCommand));
172
- if (exemptSpans.length === 0) return floor;
173
-
276
+ let scanned = command;
174
277
  // Replace each exempt word with equivalent-length whitespace, so surrounding offsets and word
175
278
  // boundaries are preserved and only that word's own text stops being scanned.
176
- let masked = command;
177
279
  for (const word of exemptSpans) {
178
- masked = masked.slice(0, word.start) + " ".repeat(word.end - word.start) + masked.slice(word.end);
280
+ scanned = scanned.slice(0, word.start) + " ".repeat(word.end - word.start) + scanned.slice(word.end);
179
281
  }
180
- return codePathTokens(masked);
282
+
283
+ // `<>` opens for both, so it stays a read here and picks up its write below: a floor occurrence
284
+ // may carry only one access, and read is the one the floor would have used anyway.
285
+ const writeTargets = lexed.words.filter(word => word.redirect === "write");
286
+ const inWriteTarget = (at: number): boolean => writeTargets.some(word => at >= word.start && at < word.end);
287
+
288
+ // A floor occurrence takes the access its own operator gave it; failing that, the span of a
289
+ // lexed write target it sits inside; failing that, read.
290
+ const candidates: PathCandidate[] = codePathOccurrences(scanned).map(({ token, at, access }) => ({
291
+ token,
292
+ access: access ?? (inWriteTarget(at) ? "write" : "read"),
293
+ }));
294
+
295
+ // Not gated on `looksLikePath`: that test is for the floor's *guesses* about which fragments of a
296
+ // command might be filenames. A redirect target is one the shell will certainly open, so a bare
297
+ // `out.txt` is checked too — it resolves under the cwd, which a read-only cwd does not license.
298
+ for (const word of lexed.words) {
299
+ if (word.redirect === undefined || word.redirect === "here-string") continue;
300
+ for (const access of word.redirect === "read-write" ? WRITE_AND_READ : [word.redirect]) {
301
+ candidates.push({ token: word.text, access });
302
+ }
303
+ }
304
+ return candidates;
181
305
  }
182
306
 
183
307
  /** Base directories a search input would actually search, split like the tools do. */
@@ -216,13 +340,19 @@ function evaluateCodeTool(check: ToolCallCheck, fields: string[], shell: boolean
216
340
  }
217
341
 
218
342
  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)) {
343
+ // Only bash gets the shell-aware treatment. Python is not shell: lexing `open('/x')` as
344
+ // shell yields one non-absolute word and would lose the check entirely, and it has no
345
+ // redirects, so every candidate it produces is a read.
346
+ const seen = new Set<string>();
347
+ for (const { token, access } of shell ? shellPathCandidates(command) : codePathCandidates(command)) {
348
+ if (!seen.add(`${access}\0${token}`)) continue;
222
349
  const resolved = resolveToCwd(token, base);
223
- if (!policy.isAllowed(resolved, "read") && !isSystemPath(resolved)) {
224
- return deny(policy, resolved, "read");
225
- }
350
+ if (policy.isAllowed(resolved, access)) continue;
351
+ // SYSTEM_READ_ROOTS is a read allowance — "directories a subprocess may legitimately
352
+ // read or traverse". It never licenses writing into /etc, /usr or /opt; only the
353
+ // discard-and-echo devices are writable.
354
+ if (access === "read" ? isSystemPath(resolved) : isSystemWriteSink(resolved)) continue;
355
+ return deny(policy, resolved, access);
226
356
  }
227
357
  }
228
358
 
@@ -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
  }