fix(core): prevent SIGHUP kills in PTY environments (WSL2/Kitty/Alacritty) (#27267)

This commit is contained in:
PROTHAM
2026-05-21 02:13:14 +05:30
committed by GitHub
parent d7384c446f
commit f79d5e059c
5 changed files with 145 additions and 11 deletions
@@ -21,6 +21,8 @@ import {
parseCommandDetails,
splitCommands,
stripShellWrapper,
stripHupGuard,
BASH_HUP_GUARD,
normalizeCommand,
hasRedirection,
resolveExecutable,
@@ -646,3 +648,58 @@ describe('resolveExecutable', () => {
expect(resolveExecutable('anything')).toBeUndefined();
});
});
describe('stripHupGuard', () => {
it('should remove our own HUP guard prefix from the start of a command', () => {
expect(stripHupGuard(`${BASH_HUP_GUARD} git status`)).toBe('git status');
});
it('should be idempotent: if no guard present, return command unchanged', () => {
expect(stripHupGuard('git status')).toBe('git status');
});
it('should return empty string when command is exactly the guard itself', () => {
expect(stripHupGuard(BASH_HUP_GUARD)).toBe('');
});
it('should handle leading whitespace before the guard', () => {
expect(stripHupGuard(` ${BASH_HUP_GUARD} git status`)).toBe('git status');
});
// Security regression: must NOT strip user-supplied trap commands
it('should NOT strip a user-supplied trap command (only strips our exact preamble)', () => {
// A user attempting to use trap as a sandbox bypass
const maliciousCmd = `trap 'rm -rf /' EXIT; git status`;
// stripHupGuard looks for the exact BASH_HUP_GUARD prefix (trap '' HUP;), not 'trap'
expect(stripHupGuard(maliciousCmd)).toBe(maliciousCmd);
});
it('should NOT strip a trap command with different arguments', () => {
const cmd = `trap '' TERM; git status`;
expect(stripHupGuard(cmd)).toBe(cmd);
});
});
describe('stripShellWrapper (with HUP guard integration)', () => {
it('should strip bash -c wrapper AND then the HUP guard preamble', () => {
const wrapped = `bash -c "${BASH_HUP_GUARD} git status"`;
expect(stripShellWrapper(wrapped)).toBe('git status');
});
it('should strip the HUP guard alone when no shell wrapper', () => {
expect(stripShellWrapper(`${BASH_HUP_GUARD} git status`)).toBe(
'git status',
);
});
it('should leave user-supplied trap commands intact (security check)', () => {
// This ensures that a user command starting with a different trap
// is not incorrectly stripped by the HUP guard removal logic
const maliciousCmd = `trap 'rm -rf /' EXIT; git status`;
expect(stripShellWrapper(maliciousCmd)).toBe(maliciousCmd);
});
it('should still strip a plain shell wrapper without HUP guard', () => {
expect(stripShellWrapper('sh -c "ls -l"')).toEqual('ls -l');
});
});
+43 -4
View File
@@ -14,9 +14,40 @@ import {
type SpawnOptionsWithoutStdio,
} from 'node:child_process';
/**
* The exact HUP-signal guard preamble injected by ensureHupIgnored().
* Exported so shellExecutionService and stripShellWrapper stay in sync.
*/
export const BASH_HUP_GUARD = `trap '' HUP;`;
/**
* Strips the SIGHUP guard prepended by ensureHupIgnored() from a command.
*
* This is intentionally narrow: it only removes the exact literal string
* `trap '' HUP; ` from the very start of a command. It does NOT
* skip or ignore arbitrary user-supplied `trap` commands, which would be a
* sandbox-bypass vector (e.g., `trap 'rm -rf /' EXIT; git status`).
*/
export function stripHupGuard(command: string): string {
const trimmed = command.trimStart();
const prefix = `${BASH_HUP_GUARD} `;
if (trimmed.startsWith(prefix)) {
return trimmed.slice(prefix.length);
}
// Handle case where there's no trailing space (e.g., guard is the whole command)
if (trimmed === BASH_HUP_GUARD) {
return '';
}
return command;
}
/**
* Extracts the primary command name from a potentially wrapped shell command.
* Strips shell wrappers and handles shopt/set/etc.
* Strips shell wrappers (including our own HUP guard) and handles shopt/set/etc.
*
* Returns the command name only when there is exactly ONE non-builtin root so
* that chained commands (e.g. `git; malicious_cmd`) never silently inherit the
* first command's sandbox permissions.
*
* @param command - The full command string.
* @param args - The arguments for the command.
@@ -32,7 +63,10 @@ export async function getCommandName(
const roots = getCommandRoots(stripped).filter(
(r) => r !== 'shopt' && r !== 'set',
);
if (roots.length > 0) {
// Single-root enforcement: only grant named-command permissions when the
// command is unambiguous. Multi-root chains fall back to basename so that
// `git; malicious_cmd` never inherits `git`'s sandbox policy.
if (roots.length === 1) {
return roots[0];
}
return path.basename(command);
@@ -838,6 +872,7 @@ export function stripShellWrapper(command: string): string {
const pattern =
/^\s*(?:(?:(?:\S+\/)?(?:sh|bash|zsh))\s+-c|cmd\.exe\s+\/c|powershell(?:\.exe)?\s+(?:-NoProfile\s+)?-Command|pwsh(?:\.exe)?\s+(?:-NoProfile\s+)?-Command)\s+/i;
const match = command.match(pattern);
let result: string;
if (match) {
let newCommand = command.substring(match[0].length).trim();
if (
@@ -846,9 +881,13 @@ export function stripShellWrapper(command: string): string {
) {
newCommand = newCommand.substring(1, newCommand.length - 1);
}
return newCommand;
result = newCommand;
} else {
result = command.trim();
}
return command.trim();
// Peel off the SIGHUP guard that ensureHupIgnored() prepends so that
// sandbox managers see the actual user command, not our preamble.
return stripHupGuard(result);
}
/**