Skip to content

Commit 3cc7e5b

Browse files
authored
Revert "fix(core): prevent SIGHUP kills in PTY environments" (google-gemini#27401)
1 parent a00d03e commit 3cc7e5b

5 files changed

Lines changed: 11 additions & 145 deletions

File tree

packages/core/src/sandbox/utils/commandUtils.ts

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -56,11 +56,7 @@ export async function getCommandName(req: SandboxRequest): Promise<string> {
5656
const roots = getCommandRoots(stripped).filter(
5757
(r) => r !== 'shopt' && r !== 'set',
5858
);
59-
// Single-root enforcement: only grant named-command permissions when the
60-
// command is unambiguous. Multi-root chains fall back to basename so that
61-
// a chained command like `git; malicious_cmd` never inherits `git`'s
62-
// sandbox policy for the entire chain.
63-
if (roots.length === 1) {
59+
if (roots.length > 0) {
6460
return roots[0];
6561
}
6662
return path.basename(req.command);

packages/core/src/services/shellExecutionService.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -289,7 +289,7 @@ describe('ShellExecutionService', () => {
289289
'bash',
290290
[
291291
'-c',
292-
"trap '' HUP; shopt -u promptvars nullglob extglob nocaseglob dotglob; ls -l",
292+
'shopt -u promptvars nullglob extglob nocaseglob dotglob; ls -l',
293293
],
294294
expect.any(Object),
295295
);
@@ -1057,7 +1057,7 @@ describe('ShellExecutionService', () => {
10571057
'bash',
10581058
[
10591059
'-c',
1060-
'trap \'\' HUP; shopt -u promptvars nullglob extglob nocaseglob dotglob; ls "foo bar"',
1060+
'shopt -u promptvars nullglob extglob nocaseglob dotglob; ls "foo bar"',
10611061
],
10621062
expect.objectContaining({
10631063
handleFlowControl: true,
@@ -1315,7 +1315,7 @@ describe('ShellExecutionService child_process fallback', () => {
13151315
'bash',
13161316
[
13171317
'-c',
1318-
"trap '' HUP; shopt -u promptvars nullglob extglob nocaseglob dotglob; ls -l",
1318+
'shopt -u promptvars nullglob extglob nocaseglob dotglob; ls -l',
13191319
],
13201320
expect.objectContaining({ shell: false, detached: true }),
13211321
);
@@ -1674,7 +1674,7 @@ describe('ShellExecutionService child_process fallback', () => {
16741674
'bash',
16751675
[
16761676
'-c',
1677-
'trap \'\' HUP; shopt -u promptvars nullglob extglob nocaseglob dotglob; ls "foo bar"',
1677+
'shopt -u promptvars nullglob extglob nocaseglob dotglob; ls "foo bar"',
16781678
],
16791679
expect.objectContaining({
16801680
shell: false,

packages/core/src/services/shellExecutionService.ts

Lines changed: 2 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ import {
1717
getShellConfiguration,
1818
resolveExecutable,
1919
type ShellType,
20-
BASH_HUP_GUARD,
2120
} from '../utils/shell-utils.js';
2221
import { isBinary, truncateString } from '../utils/textUtils.js';
2322
import pkg from '@xterm/headless';
@@ -115,32 +114,6 @@ function injectUtf8CodepageForPty(
115114
return command;
116115
}
117116

118-
/**
119-
* Prepends a POSIX SIGHUP-ignore guard to bash commands on non-Windows platforms.
120-
*
121-
* PTY environments such as WSL2, Kitty, and Alacritty aggressively send SIGHUP
122-
* to process groups that lose their controlling terminal. By prepending
123-
* `trap '' HUP;` we apply the same mechanism as the POSIX `nohup` utility:
124-
* SIG_IGN is inherited across exec(), so every child spawned by the command
125-
* also ignores SIGHUP — making the guard genuinely effective even in subshells.
126-
*
127-
* The guard is bash-only and idempotent (won't be doubled if already present).
128-
* It is stripped back out by stripShellWrapper() / stripHupGuard() before any
129-
* sandbox or permission-check logic sees the command, so there is no
130-
* privilege-escalation surface from the preamble itself.
131-
*/
132-
function ensureHupIgnored(command: string, shell: ShellType): string {
133-
if (shell !== 'bash') {
134-
return command;
135-
}
136-
const trimmed = command.trimStart();
137-
const prefix = `${BASH_HUP_GUARD} `;
138-
if (trimmed.startsWith(prefix) || trimmed === BASH_HUP_GUARD) {
139-
return command; // Already guarded — idempotent
140-
}
141-
return `${BASH_HUP_GUARD} ${command}`;
142-
}
143-
144117
/** A structured result from a shell command execution. */
145118
export type ShellExecutionResult = ExecutionResult;
146119

@@ -477,16 +450,9 @@ export class ShellExecutionService {
477450

478451
const resolvedExecutable = resolveExecutable(executable) ?? executable;
479452

480-
const promptGuarded = ensurePromptvarsDisabled(commandToExecute, shell);
481-
// Prepend the SIGHUP-ignore guard for bash on non-Windows. This uses the
482-
// same mechanism as POSIX `nohup`: SIG_IGN is inherited across exec(), so
483-
// child processes spawned by the command also ignore SIGHUP. The guard is
484-
// stripped by stripShellWrapper() before any sandbox permission checks.
485-
const hupGuarded = !isWindows
486-
? ensureHupIgnored(promptGuarded, shell)
487-
: promptGuarded;
453+
const guardedCommand = ensurePromptvarsDisabled(commandToExecute, shell);
488454
const finalCommand = injectUtf8CodepageForPty(
489-
hupGuarded,
455+
guardedCommand,
490456
shell,
491457
isWindows,
492458
usingPty,

packages/core/src/utils/shell-utils.test.ts

Lines changed: 0 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,6 @@ import {
2121
parseCommandDetails,
2222
splitCommands,
2323
stripShellWrapper,
24-
stripHupGuard,
25-
BASH_HUP_GUARD,
2624
normalizeCommand,
2725
hasRedirection,
2826
resolveExecutable,
@@ -668,58 +666,3 @@ describe('resolveExecutable', () => {
668666
expect(resolveExecutable('anything')).toBeUndefined();
669667
});
670668
});
671-
672-
describe('stripHupGuard', () => {
673-
it('should remove our own HUP guard prefix from the start of a command', () => {
674-
expect(stripHupGuard(`${BASH_HUP_GUARD} git status`)).toBe('git status');
675-
});
676-
677-
it('should be idempotent: if no guard present, return command unchanged', () => {
678-
expect(stripHupGuard('git status')).toBe('git status');
679-
});
680-
681-
it('should return empty string when command is exactly the guard itself', () => {
682-
expect(stripHupGuard(BASH_HUP_GUARD)).toBe('');
683-
});
684-
685-
it('should handle leading whitespace before the guard', () => {
686-
expect(stripHupGuard(` ${BASH_HUP_GUARD} git status`)).toBe('git status');
687-
});
688-
689-
// Security regression: must NOT strip user-supplied trap commands
690-
it('should NOT strip a user-supplied trap command (only strips our exact preamble)', () => {
691-
// A user attempting to use trap as a sandbox bypass
692-
const maliciousCmd = `trap 'rm -rf /' EXIT; git status`;
693-
// stripHupGuard looks for the exact BASH_HUP_GUARD prefix (trap '' HUP;), not 'trap'
694-
expect(stripHupGuard(maliciousCmd)).toBe(maliciousCmd);
695-
});
696-
697-
it('should NOT strip a trap command with different arguments', () => {
698-
const cmd = `trap '' TERM; git status`;
699-
expect(stripHupGuard(cmd)).toBe(cmd);
700-
});
701-
});
702-
703-
describe('stripShellWrapper (with HUP guard integration)', () => {
704-
it('should strip bash -c wrapper AND then the HUP guard preamble', () => {
705-
const wrapped = `bash -c "${BASH_HUP_GUARD} git status"`;
706-
expect(stripShellWrapper(wrapped)).toBe('git status');
707-
});
708-
709-
it('should strip the HUP guard alone when no shell wrapper', () => {
710-
expect(stripShellWrapper(`${BASH_HUP_GUARD} git status`)).toBe(
711-
'git status',
712-
);
713-
});
714-
715-
it('should leave user-supplied trap commands intact (security check)', () => {
716-
// This ensures that a user command starting with a different trap
717-
// is not incorrectly stripped by the HUP guard removal logic
718-
const maliciousCmd = `trap 'rm -rf /' EXIT; git status`;
719-
expect(stripShellWrapper(maliciousCmd)).toBe(maliciousCmd);
720-
});
721-
722-
it('should still strip a plain shell wrapper without HUP guard', () => {
723-
expect(stripShellWrapper('sh -c "ls -l"')).toEqual('ls -l');
724-
});
725-
});

packages/core/src/utils/shell-utils.ts

Lines changed: 4 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -14,40 +14,9 @@ import {
1414
type SpawnOptionsWithoutStdio,
1515
} from 'node:child_process';
1616

17-
/**
18-
* The exact HUP-signal guard preamble injected by ensureHupIgnored().
19-
* Exported so shellExecutionService and stripShellWrapper stay in sync.
20-
*/
21-
export const BASH_HUP_GUARD = `trap '' HUP;`;
22-
23-
/**
24-
* Strips the SIGHUP guard prepended by ensureHupIgnored() from a command.
25-
*
26-
* This is intentionally narrow: it only removes the exact literal string
27-
* `trap '' HUP; ` from the very start of a command. It does NOT
28-
* skip or ignore arbitrary user-supplied `trap` commands, which would be a
29-
* sandbox-bypass vector (e.g., `trap 'rm -rf /' EXIT; git status`).
30-
*/
31-
export function stripHupGuard(command: string): string {
32-
const trimmed = command.trimStart();
33-
const prefix = `${BASH_HUP_GUARD} `;
34-
if (trimmed.startsWith(prefix)) {
35-
return trimmed.slice(prefix.length);
36-
}
37-
// Handle case where there's no trailing space (e.g., guard is the whole command)
38-
if (trimmed === BASH_HUP_GUARD) {
39-
return '';
40-
}
41-
return command;
42-
}
43-
4417
/**
4518
* Extracts the primary command name from a potentially wrapped shell command.
46-
* Strips shell wrappers (including our own HUP guard) and handles shopt/set/etc.
47-
*
48-
* Returns the command name only when there is exactly ONE non-builtin root so
49-
* that chained commands (e.g. `git; malicious_cmd`) never silently inherit the
50-
* first command's sandbox permissions.
19+
* Strips shell wrappers and handles shopt/set/etc.
5120
*
5221
* @param command - The full command string.
5322
* @param args - The arguments for the command.
@@ -63,10 +32,7 @@ export async function getCommandName(
6332
const roots = getCommandRoots(stripped).filter(
6433
(r) => r !== 'shopt' && r !== 'set',
6534
);
66-
// Single-root enforcement: only grant named-command permissions when the
67-
// command is unambiguous. Multi-root chains fall back to basename so that
68-
// `git; malicious_cmd` never inherits `git`'s sandbox policy.
69-
if (roots.length === 1) {
35+
if (roots.length > 0) {
7036
return roots[0];
7137
}
7238
return path.basename(command);
@@ -877,7 +843,6 @@ export function stripShellWrapper(command: string): string {
877843
const pattern =
878844
/^\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;
879845
const match = command.match(pattern);
880-
let result: string;
881846
if (match) {
882847
let newCommand = command.substring(match[0].length).trim();
883848
if (
@@ -886,13 +851,9 @@ export function stripShellWrapper(command: string): string {
886851
) {
887852
newCommand = newCommand.substring(1, newCommand.length - 1);
888853
}
889-
result = newCommand;
890-
} else {
891-
result = command.trim();
854+
return newCommand;
892855
}
893-
// Peel off the SIGHUP guard that ensureHupIgnored() prepends so that
894-
// sandbox managers see the actual user command, not our preamble.
895-
return stripHupGuard(result);
856+
return command.trim();
896857
}
897858

898859
/**

0 commit comments

Comments
 (0)