Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
fc5cb28
fix(core): read a backslash inside single quotes as literal when spli…
TianYuan1024 Sep 13, 2026
dd80fb9
Merge remote-tracking branch 'origin/main' into fix/permission-single…
TianYuan1024 Sep 13, 2026
a122d7a
fix(core): keep the escape inside bash's ANSI-C quotes when splitting
TianYuan1024 Sep 13, 2026
06b3591
Merge remote-tracking branch 'origin/main' into fix/permission-single…
TianYuan1024 Sep 13, 2026
0974d13
test(core): cover mixed quote forms on one line when splitting commands
TianYuan1024 Sep 13, 2026
29ddd0a
Merge remote-tracking branch 'origin/main' into fix/permission-single…
TianYuan1024 Sep 13, 2026
2306f10
fix(core): elide a line continuation before deciding the quote form
TianYuan1024 Sep 14, 2026
e1d00ca
Merge remote-tracking branch 'origin/main' into fix/permission-single…
TianYuan1024 Sep 14, 2026
d2d63af
docs(core): correct why the continuation branch and ANSI-C quoting work
TianYuan1024 Sep 14, 2026
8af09af
Merge remote-tracking branch 'origin/main' into fix/permission-single…
TianYuan1024 Sep 14, 2026
413ea00
Merge remote-tracking branch 'origin/main' into fix/permission-single…
TianYuan1024 Sep 15, 2026
550a71b
fix(core): keep every boundary either backslash reading finds when sp…
TianYuan1024 Sep 15, 2026
f56192f
refactor(core): condense splitter comments and quote-handling tests
TianYuan1024 Sep 15, 2026
ee3a811
perf(core): skip the second scan when the command has no backslash
TianYuan1024 Sep 16, 2026
3eb322a
Merge remote-tracking branch 'origin/main' into fix/permission-single…
TianYuan1024 Sep 16, 2026
68d4df3
fix(core): trim only the whitespace bash's lexer discards when splitting
TianYuan1024 Sep 17, 2026
32e35ca
Merge remote-tracking branch 'origin/main' into fix/permission-single…
TianYuan1024 Sep 17, 2026
2d85029
Merge remote-tracking branch 'origin/main' into fix/permission-single…
TianYuan1024 Sep 19, 2026
16b0f00
Merge remote-tracking branch 'origin/main' into fix/permission-single…
TianYuan1024 Sep 19, 2026
f0bc4fc
test(core): pin the one-character reach of a pending `$`
TianYuan1024 Sep 19, 2026
e10f3c2
test(core): pin the false deny this split declares as its tradeoff
TianYuan1024 Sep 20, 2026
683e06b
Merge remote-tracking branch 'origin/main' into fix/permission-single…
TianYuan1024 Sep 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions packages/core/src/permissions/permission-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -591,6 +591,60 @@ describe('splitCompoundCommand', () => {
expect(splitCompoundCommand('echo a \\&& b')).toEqual(['echo a \\&', 'b']);
});

// Rows starting with a plain `'c\'` are split only by bash's reading, so
// they pin the ANSI-C tracking.
it.each([
["echo 'a\\' ; touch /tmp/x", ["echo 'a\\'", 'touch /tmp/x']],
Comment thread
This conversation was marked as resolved.
Resolved
["echo 'a\\' && touch /tmp/x", ["echo 'a\\'", 'touch /tmp/x']],
["echo 'a\\' | sh", ["echo 'a\\'", 'sh']],
["echo 'a\\' & touch /tmp/x", ["echo 'a\\'", 'touch /tmp/x']],
["echo 'a\\'\ntouch /tmp/x", ["echo 'a\\'", 'touch /tmp/x']],
["echo $'a\\'' ; touch /tmp/x", ["echo $'a\\''", 'touch /tmp/x']],
[
"echo 'c\\' $'a\\'' ; touch /tmp/x",
["echo 'c\\' $'a\\''", 'touch /tmp/x'],
],
[
"echo 'c\\' \\\\$'a\\'' ; touch /tmp/x",
["echo 'c\\' \\\\$'a\\''", 'touch /tmp/x'],
],
[
"echo 'c\\' $\\\n'a\\'' ; touch /tmp/x",
["echo 'c\\' $\\\n'a\\''", 'touch /tmp/x'],
],
["echo \\$'a\\' ; touch /tmp/x", ["echo \\$'a\\'", 'touch /tmp/x']],
Comment thread
This conversation was marked as resolved.
Resolved
["echo $$'a\\' ; touch /tmp/x", ["echo $$'a\\'", 'touch /tmp/x']],
['echo "$"\'a\\\' ; touch /tmp/x', ['echo "$"\'a\\\'', 'touch /tmp/x']],
])('splits after the quoted word in %s', async (command, parts) => {
expect(splitCompoundCommand(command)).toEqual(parts);
});

it('keeps an escaped quote inside double quotes and a line continuation', async () => {
expect(splitCompoundCommand('echo "a\\" ; touch /tmp/x"')).toEqual([
'echo "a\\" ; touch /tmp/x"',
]);
expect(splitCompoundCommand('echo a\\\nb')).toEqual(['echo a\\\nb']);
});

// The last row is one command to bash but stays split, as on main.
it.each([
[
"echo done # note 'a\\''\nrm -rf /tmp/x",
["echo done # note 'a\\''", 'rm -rf /tmp/x'],
],
[
"echo `echo 'a\\''` ; rm -rf /tmp/x",
["echo `echo 'a\\''`", 'rm -rf /tmp/x'],
],
[
"cat <<EOF\necho safe 'a\\''\nEOF\nrm -rf /tmp/x",
['cat <<EOF', "echo safe 'a\\''", 'EOF', 'rm -rf /tmp/x'],
],
["echo 'a\\'' ; rm x'", ["echo 'a\\''", "rm x'"]],
])('keeps the boundaries main found in %s', async (command, parts) => {
expect(splitCompoundCommand(command)).toEqual(parts);
});

it('trims whitespace around sub-commands', async () => {
expect(splitCompoundCommand(' git status && rm -rf / ')).toEqual([
'git status',
Expand Down Expand Up @@ -2316,6 +2370,43 @@ describe('PermissionManager', () => {
).toBe('deny');
});

it.each<[string, string[], string]>([
["echo 'a\\' ; rm -rf /tmp/x", [], 'ask'],
["echo 'a\\' ; rm -rf /tmp/x", ['Bash(rm *)'], 'deny'],
["echo $'a\\'' ; rm -rf /tmp/x", [], 'ask'],
["echo $'a\\'' ; rm -rf /tmp/x", ['Bash(rm *)'], 'deny'],
["echo $\\\n'a\\'' ; rm -rf /tmp/x", ['Bash(rm *)'], 'deny'],
["echo done # note 'a\\''\nrm -rf /tmp/x", ['Bash(rm *)'], 'deny'],
[
"cat <<EOF\necho safe 'a\\''\nEOF\nrm -rf /tmp/x",
['Bash(rm *)'],
'deny',
],
[
"echo done # note 'a\\''\necho {} > .qwen/settings.json",
['Write(.qwen/settings.json)'],
'deny',
],
["echo 'a\\'' ; rm x'", ['Bash(rm *)'], 'deny'],
])('%j with deny %j is %s', async (command, deny, expected) => {
pm = new PermissionManager(
makeConfig({
permissionsAllow: ['Bash(echo *)', 'Bash(cat *)'],
permissionsDeny: deny,
cwd: '/repo',
projectRoot: '/repo',
}),
);
pm.initialize();
expect(
await pm.evaluate({
toolName: 'run_shell_command',
command,
cwd: '/repo',
}),
).toBe(expected);
});

it('|| compound: all allowed → allow', async () => {
pm = new PermissionManager(
makeConfig({
Expand Down
83 changes: 68 additions & 15 deletions packages/core/src/permissions/rule-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -883,31 +883,89 @@ export interface CompoundCommandSegment {
*
* See {@link splitCompoundCommand} for the string-only form and for examples;
* this is the same split, and that function is a projection of this one.
*
* Scanned twice and split wherever either scan finds an operator: comments,
Comment thread
TianYuan1024 marked this conversation as resolved.
Resolved
* backtick bodies and heredocs are not modelled, and quotes inside them can
* fool bash's backslash reading where the pre-fix reading still splits.
*/
export function splitCompoundCommandSegments(
command: string,
): CompoundCommandSegment[] {
const boundaries = [
...findOperatorBoundaries(command, 'bash'),
...findOperatorBoundaries(command, 'escape-everywhere'),
Comment thread
TianYuan1024 marked this conversation as resolved.
Resolved
].sort((a, b) => a.start - b.start);

const segments: CompoundCommandSegment[] = [];
let lastSplit = 0;
for (const { start, end, operator } of boundaries) {
if (start < lastSplit) {
continue;
}
const segment = command.substring(lastSplit, start).trim();
Comment thread
TianYuan1024 marked this conversation as resolved.
Resolved
if (segment) {
segments.push({ command: segment, terminator: operator });
Comment thread
TianYuan1024 marked this conversation as resolved.
Resolved
}
lastSplit = end;
}

// Add the last segment
const lastSegment = command.substring(lastSplit).trim();
if (lastSegment) {
segments.push({ command: lastSegment, terminator: '' });
}

return segments;
}

interface OperatorBoundary {
start: number;
end: number;
operator: string;
}

type BackslashReading = 'bash' | 'escape-everywhere';

function findOperatorBoundaries(
command: string,
reading: BackslashReading,
): OperatorBoundary[] {
const boundaries: OperatorBoundary[] = [];
let inSingle = false;
let inDouble = false;
let inAnsiC = false;
Comment thread
TianYuan1024 marked this conversation as resolved.
Resolved
let dollarPending = false;
let escaped = false;
let lastSplit = 0;
// Nesting depth of `$(( … ))` / `(( … ))`. Inside arithmetic a bare `&` is
// bitwise AND, not the async operator, so `$(( FLAGS & MASK ))` is one word.
let arithmeticDepth = 0;

for (let i = 0; i < command.length; i++) {
const ch = command[i]!;
const ansiCIntroducer: boolean = dollarPending;
dollarPending = false;
Comment thread
This conversation was marked as resolved.
Resolved

if (escaped) {
escaped = false;
continue;
}
if (ch === '\\') {
// In bash a backslash is literal inside a plain `'…'` (so `'a\'` closes)
// but escapes inside ANSI-C `$'…'` (so `$'a\''` closes at the third quote).
if (
ch === '\\' &&
(reading === 'escape-everywhere' || !(inSingle && !inAnsiC))
) {
// `$\⏎'…'` is still ANSI-C, so the pending `$` survives a continuation.
if (command[i + 1] === '\n') {
dollarPending = ansiCIntroducer;
i++;
continue;
}
escaped = true;
continue;
}
if (ch === "'" && !inDouble) {
inAnsiC = inSingle ? false : ansiCIntroducer;
Comment thread
This conversation was marked as resolved.
Resolved
inSingle = !inSingle;
continue;
}
Expand All @@ -918,6 +976,11 @@ export function splitCompoundCommandSegments(
if (inSingle || inDouble) {
continue;
}
if (ch === '$') {
// The second `$` of `$$` (the PID) cannot open `$'…'`.
dollarPending = !ansiCIntroducer;
continue;
}

if (ch === '(' && command[i + 1] === '(') {
arithmeticDepth++;
Expand All @@ -941,23 +1004,13 @@ export function splitCompoundCommandSegments(
if (op === '&' && (arithmeticDepth > 0 || !isAsyncOperator(command, i))) {
continue;
}
const segment = command.substring(lastSplit, i).trim();
if (segment) {
segments.push({ command: segment, terminator: op });
}
lastSplit = i + op.length;
i = lastSplit - 1; // -1 because the loop will i++
boundaries.push({ start: i, end: i + op.length, operator: op });
i += op.length - 1; // -1 because the loop will i++
break;
}
}

// Add the last segment
const lastSegment = command.substring(lastSplit).trim();
if (lastSegment) {
segments.push({ command: lastSegment, terminator: '' });
}

return segments;
return boundaries;
}

/**
Expand Down
Loading