fix(core): read an unquoted - after >& as close, and a quoted one as a file

After an unquoted `>&`, `N>&` or `<&`, bash reads an unquoted `-` as
"close the descriptor" and starts a new word after it, so
`rm >&-notes.txt` closes stdout and removes notes.txt, and
`cat <&-notes.txt` reads it. The parser took `-notes.txt` as the
redirect word, and since it starts with `-` the flag check in
looksLikePath dropped it: no operation at all, so with `Bash(*)` allowed
and `Edit` (or `Read`) denied, the command was allowed. `main` reported
a write to `&-notes.txt` instead, which a blanket deny still caught but
a deny rule for notes.txt did not.

A quoted or escaped `-` (`>&"-x"`, `>&'-x'`, `>&\-x`) is part of a file
name: bash writes `-x`. The same flag check dropped that write too.

The tokenizer now splits an unquoted `-` right after an unquoted `>&`,
`N>&` or `<&` into its own close token, and the rest of the word goes
back into the argument list. Descriptor redirects are handled in one
place; a `-` that is still at the start of the word there was quoted, so
the write is reported without the flag check.

Spaced forms (`>& -notes.txt`, `>& "-x"`) are unchanged: the tokenizer
trims the unquoted trailing `&` of `>&`, as on `main`.

Reported in review of #15681 (R1-1, R1-2).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
suaroandClaude Opus 5.5 committed 2026-10-27 20:56:15 -04:00
1 parent 513b9a32df
commit 4ef50aae47
3 files changed
+113 -22

No files matched your search

@@ -3366,6 +3366,16 @@ describe('PermissionManager — compound shell write attribution', () => {
['printf TXT >"&protected.txt"', 'WriteFileTool(&protected.txt)', 'deny'],
['printf TXT >\\&protected.txt', 'WriteFileTool(&protected.txt)', 'deny'],
['printf TXT >&protected.txt', 'WriteFileTool(&protected.txt)', 'allow'],
// A quoted `-` after `>&` is a file name; an unquoted one closes the
// descriptor and the rest of the word is an argument (`rm` removes it).
['printf TXT >&"-x"', 'Edit', 'deny'],
['printf TXT >&\\-x', 'Edit', 'deny'],
['printf TXT >&-x', 'Edit', 'allow'],
['rm >&-notes.txt', 'Edit', 'deny'],
['rm >&-notes.txt', 'Edit(notes.txt)', 'deny'],
['rm 2>&-notes.txt', 'Edit(notes.txt)', 'deny'],
['cat <&-notes.txt', 'Read', 'deny'],
['cat <&-notes.txt', 'Read(notes.txt)', 'deny'],
])(
'Bash(*) allow, %s with %s denied → %s',
async (command, deny, expected) => {
@@ -474,6 +474,46 @@ describe('extractShellOperations', () => {
expect(ops('printf TXT >&$OUT')).toEqual([write(`${CWD}/&$OUT`)]);
});
// Only a whole number or `-` is a descriptor; anything else names a file.
// (`2>&file` and `<&file` are errors in bash; reporting them keeps the
// check strict.)
it.each<[command: string, expected: ShellOperation[]]>([
['printf TXT >&1.log', [write(`${CWD}/1.log`)]],
['printf TXT >&2fa-notes.md', [write(`${CWD}/2fa-notes.md`)]],
['printf TXT 2>&protected.txt', [write(`${CWD}/protected.txt`)]],
['cat <&protected.txt', [read(`${CWD}/protected.txt`)]],
])('fd redirect word: %s', (command, expected) => {
expectSorted(command, expected);
});
// After an unquoted `>&`, `N>&` or `<&`, bash reads an unquoted `-` as
// "close" and starts a new word: `rm >&-notes.txt` closes stdout and
// removes notes.txt.
it.each<[command: string, expected: ShellOperation[]]>([
['rm >&-notes.txt', [edit(`${CWD}/notes.txt`)]],
['rm 1>&-notes.txt', [edit(`${CWD}/notes.txt`)]],
['rm 2>&-notes.txt', [edit(`${CWD}/notes.txt`)]],
['rm <&-notes.txt', [edit(`${CWD}/notes.txt`)]],
['rm >&-"notes.txt"', [edit(`${CWD}/notes.txt`)]],
['cat <&-notes.txt', [read(`${CWD}/notes.txt`)]],
['printf TXT >&-x', []],
['df -hT / >&"-"', []],
])('unquoted - closes the descriptor: %s', (command, expected) => {
expectSorted(command, expected);
});
// A quoted or escaped `-` is part of the file name: `>&"-x"` writes `-x`.
it.each<[command: string, file: string]>([
['printf TXT >&"-x"', '-x'],
["printf TXT >&'-x'", '-x'],
['printf TXT >&\\-x', '-x'],
['printf TXT 1>&"-x"', '-x'],
['printf TXT >&"-1"', '-1'],
['rm >&"-"notes.txt', '-notes.txt'],
])('quoted or escaped - after >& names a file: %s', (command, file) => {
expect(ops(command)).toEqual([write(`${CWD}/${file}`)]);
});
it('regression: ordinary file redirects still tracked', () => {
expect(ops('echo hi > out.txt')).toContainEqual(write(`${CWD}/out.txt`));
expect(ops('sort < in.txt')).toContainEqual(read(`${CWD}/in.txt`));
@@ -91,7 +91,9 @@ export interface ShellOperation {
* Quote removal hides whether a `&` was typed bare: `>&1` duplicates a file
* descriptor, but `>"&1"` and `>\&1` write a file named `&1`. When
* `fdRedirects` is given, it receives the index of each token that starts
* with `>&`, `1>&`, `2>&` or `<&` whose `>`/`<` and `&` were both unquoted.
* with `>&`, `1>&`, `2>&` or `<&` whose `>`/`<` and `&` were both unquoted,
* and an unquoted `-` right after such an operator becomes its own token,
* as bash reads it: `>&-notes.txt` is `>&-` plus the argument `notes.txt`.
*/
function tokenize(command: string, fdRedirects?: Set<number>): string[] {
const tokens: string[] = [];
@@ -147,11 +149,34 @@ function pushToken(
if (token === '{' || token === '}') return;
const normalized = trimShellSyntax(token, bare);
if (!normalized) return;
if (!fdRedirects) {
tokens.push(normalized);
return;
}
// trimShellSyntax only drops leading `(` and trailing `)`/`&`.
const start = /^\(*/.exec(token)![0].length;
const normBare = bare.slice(start, start + normalized.length);
const op = /^[12]?>&|^<&/.exec(normalized)?.[0];
if (op && fdRedirects) {
const start = /^\(*/.exec(token)![0].length;
const opEnd = start + op.length;
if (bare.slice(opEnd - 2, opEnd) === '11') fdRedirects.add(tokens.length);
if (op && normBare.slice(op.length - 2, op.length) === '11') {
fdRedirects.add(tokens.length);
// Right after an unquoted `>&`, `N>&` or `<&`, bash reads an unquoted
// `-` as "close the descriptor" and starts a new word after it:
// `rm >&-notes.txt` closes stdout and removes `notes.txt`.
const close = op.length;
if (
normalized[close] === '-' &&
normBare[close] === '1' &&
normalized.length > close + 1
) {
tokens.push(normalized.slice(0, close + 1));
pushToken(
tokens,
normalized.slice(close + 1),
normBare.slice(close + 1),
fdRedirects,
);
return;
}
}
tokens.push(normalized);
}
@@ -248,6 +273,25 @@ function isNetworkPseudoDevice(target: string): boolean {
return /^\/dev\/(tcp|udp)\//.test(target);
}
/**
* The file that `>&word`, `N>&word` or `<&word` opens, or undefined when it
* only duplicates or closes a descriptor (`2>&1`, `>&2`, `<&0`, `2>&-`).
*
* `>&file` and `1>&file` write `file` (stdout and stderr). `2>&file` and
* `<&file` are errors in bash; treating them as file operations keeps the
* check strict. A `$`/backtick word may expand to a descriptor or a file, so
* it keeps the literal `&word` write.
*/
function fdRedirectFile(word: string): string | undefined {
if (/^(\d+|-)$/.test(word)) return undefined;
if (isDynamicShellPath(word)) return `&${word}`;
if (word === '/dev/null' || isNetworkPseudoDevice(word)) return undefined;
// `tokenize` splits off an unquoted leading `-`, so a `-` still here was
// quoted or escaped: `>&"-x"` writes the file `-x`, it isn't a flag.
if (word.startsWith('-') || looksLikePath(word)) return word;
return undefined;
}
/**
* Extract I/O redirections from a token array.
*
@@ -274,6 +318,19 @@ function extractRedirects(
for (let i = 0; i < tokens.length; i++) {
const tok = tokens[i]!;
// ── `>&word`, `N>&word`, `<&word` with an unquoted `>&`/`<&` ─────────
const fdOp = fdRedirects.has(i)
? /^(?:[12]?>&|<&)/.exec(tok)?.[0]
: undefined;
if (fdOp) {
toRemove.add(i);
const file = fdRedirectFile(tok.slice(fdOp.length));
if (file !== undefined) {
(fdOp === '<&' ? readFiles : writeFiles).push(resolvePath(file, cwd));
}
continue;
}
// ── Separate-token redirect operators ─────────────────────────────────
if (tok === '>' || tok === '1>') {
const target = tokens[i + 1];
@@ -332,27 +389,11 @@ function extractRedirects(
const m = tok.match(/^(<<-?|1>>|1>|>>|>|2>>|2>|&>>|&>|<)([\s\S]+)$/);
if (m) {
const op = m[1]!;
let target = m[2]!;
const target = m[2]!;
if (op.startsWith('<<')) {
toRemove.add(i);
continue;
}
// `>&`, `N>&` and `<&` take a file descriptor, not a file name.
if (fdRedirects.has(i)) {
const word = target.slice(1);
// `2>&1`, `>&2`, `<&0`, `2>&-`: duplicate or close a descriptor.
// Bash opens no file, so there is no file operation to check.
if (/^(\d+|-)$/.test(word)) {
toRemove.add(i);
continue;
}
// `>&file` and `1>&file` write `file` (stdout and stderr). Check
// that path, not `&file`. (`2>&file` and `<&file` are errors in
// bash; treating them as file operations keeps the check strict.)
// A `$`/backtick word may expand to a descriptor or a file, so it
// keeps the literal `&word` write.
if (!isDynamicShellPath(word)) target = word;
}
if (
target !== '/dev/null' &&
looksLikePath(target) &&