mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
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:
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) &&
|
||||
|
||||
Reference in new issue
Block a user