Skip to content

Commit 456d1ae

Browse files
authored
fix(cli): resolve permission denied in sandbox on NixOS and other distros (google-gemini#27004)
1 parent e3f2d3e commit 456d1ae

4 files changed

Lines changed: 191 additions & 22 deletions

File tree

packages/cli/src/utils/sandbox.test.ts

Lines changed: 59 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -787,12 +787,67 @@ describe('sandbox', () => {
787787
expect.arrayContaining(['--user', 'root', '--env', 'HOME=/home/user']),
788788
expect.any(Object),
789789
);
790-
// Check that the entrypoint command includes useradd/groupadd
790+
// Check that the entrypoint command includes the defensive useradd check
791791
const args = vi.mocked(spawn).mock.calls[1][1] as string[];
792792
const entrypointCmd = args[args.length - 1];
793-
expect(entrypointCmd).toContain('groupadd');
794-
expect(entrypointCmd).toContain('useradd');
795-
expect(entrypointCmd).toContain('su -p gemini');
793+
expect(entrypointCmd).toContain('if command -v useradd');
794+
expect(entrypointCmd).toContain('groupadd -g 1000 -o gemini');
795+
expect(entrypointCmd).toContain('id 1000');
796+
expect(entrypointCmd).toContain('useradd -o -u 1000');
797+
expect(entrypointCmd).toContain('USER_NAME=$(id -nu 1000 2>/dev/null);');
798+
expect(entrypointCmd).toContain('if [ -n "$USER_NAME" ]; then');
799+
expect(entrypointCmd).toContain('su -p "$USER_NAME"');
800+
expect(entrypointCmd).toContain('else');
801+
expect(entrypointCmd).toContain('Error: Failed to map host UID 1000');
802+
expect(entrypointCmd).toContain('exit 1');
803+
expect(entrypointCmd).toContain("Error: 'useradd' not found");
804+
});
805+
806+
it('should correctly escape home directory with spaces and special characters', async () => {
807+
const config: SandboxConfig = createMockSandboxConfig({
808+
command: 'docker',
809+
image: 'gemini-cli-sandbox',
810+
});
811+
process.env['SANDBOX_SET_UID_GID'] = 'true';
812+
vi.mocked(os.platform).mockReturnValue('linux');
813+
814+
const specialHome = '/home/user name `$(id)`';
815+
mockedHomedir.mockReturnValue(specialHome);
816+
mockedGetContainerPath.mockImplementation((p: string) => p);
817+
818+
// Mock image check to return true
819+
interface MockProcessWithStdout extends EventEmitter {
820+
stdout: EventEmitter;
821+
}
822+
const mockImageCheckProcess = new EventEmitter() as MockProcessWithStdout;
823+
mockImageCheckProcess.stdout = new EventEmitter();
824+
vi.mocked(spawn).mockImplementationOnce(() => {
825+
setTimeout(() => {
826+
mockImageCheckProcess.stdout.emit('data', Buffer.from('image-id'));
827+
mockImageCheckProcess.emit('close', 0);
828+
}, 1);
829+
return mockImageCheckProcess as unknown as ReturnType<typeof spawn>;
830+
});
831+
832+
const mockSpawnProcess = new EventEmitter() as unknown as ReturnType<
833+
typeof spawn
834+
>;
835+
mockSpawnProcess.on = vi.fn().mockImplementation((event, cb) => {
836+
if (event === 'close') {
837+
setTimeout(() => cb(0), 10);
838+
}
839+
return mockSpawnProcess;
840+
});
841+
vi.mocked(spawn).mockImplementationOnce(() => mockSpawnProcess);
842+
843+
await start_sandbox(config);
844+
845+
const args = vi.mocked(spawn).mock.calls[1][1] as string[];
846+
const entrypointCmd = args[args.length - 1];
847+
848+
// Verify that the special home directory is properly quoted/escaped
849+
// The quote tool should handle spaces and backticks
850+
expect(entrypointCmd).toContain("'/home/user name `$(id)`'");
796851
});
797852

798853
it('should register and unregister proxy exit handlers', async () => {

packages/cli/src/utils/sandbox.ts

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -676,22 +676,34 @@ export async function start_sandbox(
676676
// container's /etc/passwd file, which is required by os.userInfo().
677677
const username = 'gemini';
678678
const homeDir = getContainerPath(homedir());
679-
680-
const setupUserCommands = [
681-
// Use -f with groupadd to avoid errors if the group already exists.
682-
`groupadd -f -g ${gid} ${username}`,
683-
// Create user only if it doesn't exist. Use -o for non-unique UID.
684-
`id -u ${username} &>/dev/null || useradd -o -u ${uid} -g ${gid} -d ${homeDir} -s /bin/bash ${username}`,
685-
].join(' && ');
679+
const quotedHomeDir = quote([homeDir]);
686680

687681
const originalCommand = finalEntrypoint[2];
688682
const escapedOriginalCommand = originalCommand.replace(/'/g, "'\\''");
689683

690-
// Use `su -p` to preserve the environment.
691-
const suCommand = `su -p ${username} -c '${escapedOriginalCommand}'`;
684+
// Use defensive entrypoint logic that checks for useradd availability.
685+
// This ensures we can support UID/GID mapping on distros that have these
686+
// tools. If useradd is missing (e.g. on minimal images), we fail explicitly
687+
// to avoid insecurely falling back to root execution with host mounts.
688+
const defensiveEntrypoint = [
689+
`if command -v useradd >/dev/null 2>&1; then`,
690+
` (groupadd -g ${gid} -o ${username} 2>/dev/null || true) &&`,
691+
` (id ${uid} >/dev/null 2>&1 || useradd -o -u ${uid} -g ${gid} -d ${quotedHomeDir} -s /bin/bash ${username} 2>/dev/null || true) &&`,
692+
` USER_NAME=$(id -nu ${uid} 2>/dev/null);`,
693+
` if [ -n "$USER_NAME" ]; then`,
694+
` su -p "$USER_NAME" -c '${escapedOriginalCommand}';`,
695+
` else`,
696+
` echo "Error: Failed to map host UID ${uid} to a user in the container." >&2;`,
697+
` exit 1;`,
698+
` fi`,
699+
`else`,
700+
` echo "Error: 'useradd' not found in container. UID/GID mapping is required for Linux distros like NixOS/Arch to avoid permission issues. Please use a container image that includes standard user management tools (like 'ubuntu' or 'debian')." >&2;`,
701+
` exit 1;`,
702+
`fi`,
703+
].join('\n');
692704

693705
// The entrypoint is always `['bash', '-c', '<command>']`, so we modify the command part.
694-
finalEntrypoint[2] = `${setupUserCommands} && ${suCommand}`;
706+
finalEntrypoint[2] = defensiveEntrypoint;
695707

696708
// We still need userFlag for the simpler proxy container, which does not have this issue.
697709
userFlag = `--user ${uid}:${gid}`;

packages/cli/src/utils/sandboxUtils.test.ts

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,95 @@ describe('sandboxUtils', () => {
143143
expect(await shouldUseCurrentUserInSandbox()).toBe(true);
144144
});
145145

146+
it('should return true on NixOS', async () => {
147+
delete process.env['SANDBOX_SET_UID_GID'];
148+
vi.mocked(os.platform).mockReturnValue('linux');
149+
vi.mocked(readFile).mockResolvedValue('ID=nixos\n');
150+
expect(await shouldUseCurrentUserInSandbox()).toBe(true);
151+
});
152+
153+
it('should return true on NixOS with quotes', async () => {
154+
delete process.env['SANDBOX_SET_UID_GID'];
155+
vi.mocked(os.platform).mockReturnValue('linux');
156+
vi.mocked(readFile).mockResolvedValue('ID="nixos"\n');
157+
expect(await shouldUseCurrentUserInSandbox()).toBe(true);
158+
});
159+
160+
it('should return true on Ubuntu with single quotes', async () => {
161+
delete process.env['SANDBOX_SET_UID_GID'];
162+
vi.mocked(os.platform).mockReturnValue('linux');
163+
vi.mocked(readFile).mockResolvedValue("ID='ubuntu'\n");
164+
expect(await shouldUseCurrentUserInSandbox()).toBe(true);
165+
});
166+
167+
it('should return true on Arch Linux', async () => {
168+
delete process.env['SANDBOX_SET_UID_GID'];
169+
vi.mocked(os.platform).mockReturnValue('linux');
170+
vi.mocked(readFile).mockResolvedValue('ID=arch\n');
171+
expect(await shouldUseCurrentUserInSandbox()).toBe(true);
172+
});
173+
174+
it('should return false on unrecognized Linux and warn on UID mismatch', async () => {
175+
delete process.env['SANDBOX_SET_UID_GID'];
176+
vi.mocked(os.platform).mockReturnValue('linux');
177+
vi.mocked(readFile).mockResolvedValue('ID=unknown\n');
178+
vi.mocked(os.userInfo).mockReturnValue({
179+
uid: 1234,
180+
username: 'test',
181+
gid: 1234,
182+
shell: '/bin/bash',
183+
homedir: '/home/test',
184+
});
185+
186+
const { debugLogger } = await import('@google/gemini-cli-core');
187+
expect(await shouldUseCurrentUserInSandbox()).toBe(false);
188+
expect(debugLogger.warn).toHaveBeenCalledWith(
189+
expect.stringContaining(
190+
'Host UID mismatch detected (current UID: 1234)',
191+
),
192+
);
193+
});
194+
195+
it('should return true on Pop!_OS (via ID_LIKE)', async () => {
196+
delete process.env['SANDBOX_SET_UID_GID'];
197+
vi.mocked(os.platform).mockReturnValue('linux');
198+
vi.mocked(readFile).mockResolvedValue(
199+
'ID=pop\nID_LIKE="ubuntu debian"\n',
200+
);
201+
expect(await shouldUseCurrentUserInSandbox()).toBe(true);
202+
});
203+
204+
it('should return false and NOT warn for host root user (UID 0)', async () => {
205+
delete process.env['SANDBOX_SET_UID_GID'];
206+
vi.mocked(os.platform).mockReturnValue('linux');
207+
vi.mocked(readFile).mockResolvedValue('ID=unknown\n');
208+
vi.mocked(os.userInfo).mockReturnValue({
209+
uid: 0,
210+
username: 'root',
211+
gid: 0,
212+
shell: '/bin/bash',
213+
homedir: '/root',
214+
});
215+
216+
const { debugLogger } = await import('@google/gemini-cli-core');
217+
expect(await shouldUseCurrentUserInSandbox()).toBe(false);
218+
expect(debugLogger.warn).not.toHaveBeenCalledWith(
219+
expect.stringContaining('Host UID mismatch detected'),
220+
);
221+
});
222+
223+
it('should warn and return false if /etc/os-release is unreadable', async () => {
224+
delete process.env['SANDBOX_SET_UID_GID'];
225+
vi.mocked(os.platform).mockReturnValue('linux');
226+
vi.mocked(readFile).mockRejectedValue(new Error('EACCES'));
227+
228+
const { debugLogger } = await import('@google/gemini-cli-core');
229+
expect(await shouldUseCurrentUserInSandbox()).toBe(false);
230+
expect(debugLogger.warn).toHaveBeenCalledWith(
231+
expect.stringContaining('Could not read /etc/os-release'),
232+
);
233+
});
234+
146235
it('should return false on non-Linux', async () => {
147236
delete process.env['SANDBOX_SET_UID_GID'];
148237
vi.mocked(os.platform).mockReturnValue('darwin');

packages/cli/src/utils/sandboxUtils.ts

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -49,22 +49,35 @@ export async function shouldUseCurrentUserInSandbox(): Promise<boolean> {
4949
if (os.platform() === 'linux') {
5050
try {
5151
const osReleaseContent = await readFile('/etc/os-release', 'utf8');
52-
if (
53-
osReleaseContent.includes('ID=debian') ||
54-
osReleaseContent.includes('ID=ubuntu') ||
55-
osReleaseContent.match(/^ID_LIKE=.*debian.*/m) || // Covers derivatives
56-
osReleaseContent.match(/^ID_LIKE=.*ubuntu.*/m) // Covers derivatives
57-
) {
52+
const isSupportedDistro =
53+
osReleaseContent.match(
54+
/^ID=["']?(?:debian|ubuntu|nixos|arch|fedora|suse|opensuse)/m,
55+
) ||
56+
osReleaseContent.match(
57+
/^ID_LIKE=["']?.*(?:debian|ubuntu|arch|fedora|suse).*/m,
58+
);
59+
60+
if (isSupportedDistro) {
5861
debugLogger.log(
59-
'Defaulting to use current user UID/GID for Debian/Ubuntu-based Linux.',
62+
'Defaulting to use current user UID/GID for supported Linux distribution.',
6063
);
6164
return true;
6265
}
66+
67+
// If we're on Linux but the distro is unrecognized, check for a UID mismatch
68+
// that might cause permission issues in the sandbox.
69+
const uid = os.userInfo().uid;
70+
if (uid !== 1000 && uid !== 0) {
71+
debugLogger.warn(
72+
`Warning: Host UID mismatch detected (current UID: ${uid}). ` +
73+
'If you encounter permission errors in the sandbox, try setting SANDBOX_SET_UID_GID=true.',
74+
);
75+
}
6376
} catch {
6477
// Silently ignore if /etc/os-release is not found or unreadable.
6578
// The default (false) will be applied in this case.
6679
debugLogger.warn(
67-
'Warning: Could not read /etc/os-release to auto-detect Debian/Ubuntu for UID/GID default.',
80+
'Warning: Could not read /etc/os-release to auto-detect Linux distribution for UID/GID default.',
6881
);
6982
}
7083
}

0 commit comments

Comments
 (0)