diff --git a/packages/core/src/utils/shell-utils.test.ts b/packages/core/src/utils/shell-utils.test.ts index 180ddca1a50..706b730c898 100644 --- a/packages/core/src/utils/shell-utils.test.ts +++ b/packages/core/src/utils/shell-utils.test.ts @@ -323,6 +323,20 @@ describe('checkCommandPermissions', () => { }); }); + it('should not let a backslash inside single quotes hide a blocked command', async () => { + // `echo 'a\'; rm ...` is two commands to the shell. If the splitter + // mistakes `\'` for an escaped quote it sees a single `echo` command + // and the deny rule never gets to look at `rm`. + config.getPermissionsDeny = () => ['ShellTool(rm)']; + const result = await checkCommandPermissions( + "echo 'a\\'; rm -rf /tmp/x", + config, + ); + expect(result.allAllowed).toBe(false); + expect(result.isHardDenial).toBe(true); + expect(result.disallowedCommands).toEqual(['rm -rf /tmp/x']); + }); + it('should return a detailed failure object for a command not on a strict allowlist', async () => { config.getCoreTools = () => ['ShellTool(ls)']; const result = await checkCommandPermissions('git status && ls', config); @@ -564,6 +578,26 @@ describe('getCommandRoots', () => { 'bar.exe', ]); }); + + it('should treat a backslash inside single quotes as literal, not an escape', async () => { + // The shell performs no escaping inside single quotes, so `'a\'` closes + // the quote and `;` separates two commands: + // $ echo 'a\'; rm -rf /tmp/x -> prints "a\", then runs rm + // Treating `\'` as an escaped quote would leave the parser inside the + // quote and swallow `rm` entirely. + expect(getCommandRoots("echo 'a\\'; rm -rf /tmp/x")).toEqual([ + 'echo', + 'rm', + ]); + }); + + it('should still honour backslash escapes outside single quotes', async () => { + // Inside double quotes a backslash *does* escape, so the quote stays open + // and the whole string is one command. + expect(getCommandRoots('echo "a\\"; rm -rf /tmp/x"')).toEqual(['echo']); + // An escaped separator outside quotes is likewise not a separator. + expect(getCommandRoots('echo a\\; rm -rf /tmp/x')).toEqual(['echo']); + }); }); describe('stripShellWrapper', () => { diff --git a/packages/core/src/utils/shell-utils.ts b/packages/core/src/utils/shell-utils.ts index 76073f83345..146c0cede37 100644 --- a/packages/core/src/utils/shell-utils.ts +++ b/packages/core/src/utils/shell-utils.ts @@ -231,7 +231,12 @@ export function splitCommands(command: string): string[] { continue; } - if (char === '\\' && i < command.length - 1) { + // Inside single quotes the shell treats a backslash as a literal + // character — it escapes nothing, so `'a\'` closes the quote. Consuming + // the following character here would keep the parser "inside" the quote + // and swallow every separator to the end of the line, hiding whole + // commands from the permission checks that consume these segments. + if (!inSingleQuotes && char === '\\' && i < command.length - 1) { currentCommand += char + command[i + 1]; i += 2; continue;