Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
34 changes: 34 additions & 0 deletions packages/core/src/utils/shell-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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', () => {
Expand Down
7 changes: 6 additions & 1 deletion packages/core/src/utils/shell-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading