Skip to content

Commit 12627ad

Browse files
committed
fix(sandbox): make ~/.gitconfig read-only in the macOS sandbox
Remove ~/.gitconfig from the file-write* allow list in all six macOS Seatbelt profiles so the sandboxed process can no longer modify the user's global git config (git config can drive command execution via aliases, core.pager, core.hooksPath, etc.). Read access is unchanged, so normal git operations are unaffected. Documented in docs/cli/sandbox.md.
1 parent 09e5e9a commit 12627ad

9 files changed

Lines changed: 1 addition & 149 deletions

docs/cli/sandbox.md

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -99,17 +99,7 @@ Built-in profiles (set via `SEATBELT_PROFILE` env var):
9999
- `strict-open`: Read and write restrictions, network allowed
100100
- `strict-proxied`: Read and write restrictions, network via proxy
101101

102-
<!-- prettier-ignore -->
103-
> [!NOTE]
104-
> Under the macOS Seatbelt profiles, `~/.gitconfig` is read-only. The sandbox
105-
> still allows reading it but no longer grants write access. Your Gemini CLI
106-
> directory (`~/.gemini`) stays writable because the CLI persists its own
107-
> runtime state and settings there; as a residual effect, a sandboxed shell
108-
> command can still write to `~/.gemini`.
109-
>
110-
> Independently of the sandbox, the agent's file-writing tools (`write_file` and
111-
> `replace`) now ask for explicit confirmation before they modify a workspace
112-
> `.gemini/` directory or a `.gitconfig` file, even when auto-accept is enabled.
102+
Under the macOS Seatbelt profiles, `~/.gitconfig` is read-only: the sandbox still allows reading it but no longer grants write access.
113103

114104
### 2. Container-based (Docker/Podman)
115105

packages/cli/src/utils/sandbox-macos-permissive-open.sb

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@
1212
(subpath (string-append (param "HOME_DIR") "/.gemini"))
1313
(subpath (string-append (param "HOME_DIR") "/.npm"))
1414
(subpath (string-append (param "HOME_DIR") "/.cache"))
15-
;; ~/.gitconfig is intentionally read-only in the sandbox (no write access).
1615
;; Allow writes to included directories from --include-directories
1716
(subpath (param "INCLUDE_DIR_0"))
1817
(subpath (param "INCLUDE_DIR_1"))

packages/cli/src/utils/sandbox-macos-permissive-proxied.sb

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@
1212
(subpath (string-append (param "HOME_DIR") "/.gemini"))
1313
(subpath (string-append (param "HOME_DIR") "/.npm"))
1414
(subpath (string-append (param "HOME_DIR") "/.cache"))
15-
;; ~/.gitconfig is intentionally read-only in the sandbox (no write access).
1615
;; Allow writes to included directories from --include-directories
1716
(subpath (param "INCLUDE_DIR_0"))
1817
(subpath (param "INCLUDE_DIR_1"))

packages/cli/src/utils/sandbox-macos-restrictive-open.sb

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,6 @@
7070
(subpath (string-append (param "HOME_DIR") "/.gemini"))
7171
(subpath (string-append (param "HOME_DIR") "/.npm"))
7272
(subpath (string-append (param "HOME_DIR") "/.cache"))
73-
;; ~/.gitconfig is intentionally read-only in the sandbox (no write access).
7473
;; Allow writes to included directories from --include-directories
7574
(subpath (param "INCLUDE_DIR_0"))
7675
(subpath (param "INCLUDE_DIR_1"))

packages/cli/src/utils/sandbox-macos-restrictive-proxied.sb

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,6 @@
7070
(subpath (string-append (param "HOME_DIR") "/.gemini"))
7171
(subpath (string-append (param "HOME_DIR") "/.npm"))
7272
(subpath (string-append (param "HOME_DIR") "/.cache"))
73-
;; ~/.gitconfig is intentionally read-only in the sandbox (no write access).
7473
;; Allow writes to included directories from --include-directories
7574
(subpath (param "INCLUDE_DIR_0"))
7675
(subpath (param "INCLUDE_DIR_1"))

packages/cli/src/utils/sandbox-macos-strict-open.sb

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,6 @@
105105
(subpath (string-append (param "HOME_DIR") "/.gemini"))
106106
(subpath (string-append (param "HOME_DIR") "/.npm"))
107107
(subpath (string-append (param "HOME_DIR") "/.cache"))
108-
;; ~/.gitconfig is intentionally read-only in the sandbox (no write access).
109108
;; Allow writes to included directories from --include-directories
110109
(subpath (param "INCLUDE_DIR_0"))
111110
(subpath (param "INCLUDE_DIR_1"))

packages/cli/src/utils/sandbox-macos-strict-proxied.sb

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,6 @@
105105
(subpath (string-append (param "HOME_DIR") "/.gemini"))
106106
(subpath (string-append (param "HOME_DIR") "/.npm"))
107107
(subpath (string-append (param "HOME_DIR") "/.cache"))
108-
;; ~/.gitconfig is intentionally read-only in the sandbox (no write access).
109108
;; Allow writes to included directories from --include-directories
110109
(subpath (param "INCLUDE_DIR_0"))
111110
(subpath (param "INCLUDE_DIR_1"))

packages/core/src/safety/built-in.test.ts

Lines changed: 0 additions & 105 deletions
Original file line numberDiff line numberDiff line change
@@ -308,109 +308,4 @@ describe('AllowedPathChecker', () => {
308308
}
309309
});
310310
});
311-
312-
describe('Security Regression: .gemini and .gitconfig HITL', () => {
313-
it('should require ASK_USER for .gemini paths inside workspace, but deny them if outside, including case, trailing-character, and NTFS ADS bypasses', async () => {
314-
const geminiPaths = [
315-
path.join(mockCwd, '.gemini', 'settings.json'),
316-
path.join(mockCwd, '.gemini', 'settings.JSON'),
317-
path.join(mockCwd, '.GEMINI', 'settings.json'),
318-
path.join(mockCwd, '.gemini', 'commands', 'custom.toml'),
319-
// Windows trailing character bypasses
320-
path.join(mockCwd, '.gemini ', 'settings.json'),
321-
path.join(mockCwd, '.gemini.', 'settings.json'),
322-
// NTFS Alternate Data Stream bypasses
323-
path.join(mockCwd, '.gemini::$DATA', 'settings.json'),
324-
];
325-
326-
for (const p of geminiPaths) {
327-
const input = createInput({ path: p });
328-
const result = await checker.check(input);
329-
expect(result.decision).toBe(SafetyCheckDecision.ASK_USER);
330-
expect(result.reason).toContain(
331-
'Modifying Gemini CLI configuration files (.gemini) requires explicit user confirmation',
332-
);
333-
}
334-
335-
// Verify that paths outside the workspace containing .gemini are strictly denied
336-
const outsideGeminiPaths = [
337-
path.join(testRootDir, 'outside', '.gemini', 'settings.json'),
338-
path.join(testRootDir, 'outside', '.GEMINI', 'settings.json'),
339-
];
340-
341-
for (const p of outsideGeminiPaths) {
342-
const input = createInput({ path: p });
343-
const result = await checker.check(input);
344-
expect(result.decision).toBe(SafetyCheckDecision.DENY);
345-
expect(result.reason).toContain('outside of the allowed workspace');
346-
}
347-
});
348-
349-
it('should require ASK_USER for .gitconfig files inside workspace, but deny them if outside, including case, trailing-character, and NTFS ADS bypasses', async () => {
350-
const gitConfigPaths = [
351-
path.join(mockCwd, '.gitconfig'),
352-
path.join(mockCwd, '.GITCONFIG'),
353-
path.join(mockCwd, 'nested', '.gitconfig'),
354-
// Windows trailing character bypasses
355-
path.join(mockCwd, '.gitconfig '),
356-
path.join(mockCwd, '.gitconfig.'),
357-
// NTFS Alternate Data Stream bypasses
358-
path.join(mockCwd, '.gitconfig::$DATA'),
359-
];
360-
361-
for (const p of gitConfigPaths) {
362-
const input = createInput({ path: p });
363-
const result = await checker.check(input);
364-
expect(result.decision).toBe(SafetyCheckDecision.ASK_USER);
365-
expect(result.reason).toContain(
366-
'Modifying Git configuration files (.gitconfig) requires explicit user confirmation',
367-
);
368-
}
369-
370-
// Verify that .gitconfig outside the workspace is strictly denied
371-
const outsideGitConfigPaths = [
372-
path.join(testRootDir, 'outside', '.gitconfig'),
373-
];
374-
375-
for (const p of outsideGitConfigPaths) {
376-
const input = createInput({ path: p });
377-
const result = await checker.check(input);
378-
expect(result.decision).toBe(SafetyCheckDecision.DENY);
379-
expect(result.reason).toContain('outside of the allowed workspace');
380-
}
381-
});
382-
383-
it('should distinguish the denied .git directory from the confirmable .gitconfig file', async () => {
384-
// The `.git` directory remains a hard DENY as a sensitive path.
385-
const gitDirResult = await checker.check(
386-
createInput({ path: path.join(mockCwd, '.git', 'config') }),
387-
);
388-
expect(gitDirResult.decision).toBe(SafetyCheckDecision.DENY);
389-
expect(gitDirResult.reason).toContain('Access to sensitive path');
390-
391-
// `.gitconfig` is a distinct file that only requires confirmation.
392-
const gitConfigResult = await checker.check(
393-
createInput({ path: path.join(mockCwd, '.gitconfig') }),
394-
);
395-
expect(gitConfigResult.decision).toBe(SafetyCheckDecision.ASK_USER);
396-
expect(gitConfigResult.reason).toContain(
397-
'Modifying Git configuration files (.gitconfig)',
398-
);
399-
});
400-
401-
it('should allow benign siblings such as .gitignore, .geminiignore, and .gitattributes without confirmation', async () => {
402-
const benignPaths = [
403-
path.join(mockCwd, '.gitignore'),
404-
path.join(mockCwd, '.geminiignore'),
405-
path.join(mockCwd, 'nested', '.gitignore'),
406-
path.join(mockCwd, '.gitattributes'),
407-
];
408-
409-
for (const p of benignPaths) {
410-
const input = createInput({ path: p });
411-
const result = await checker.check(input);
412-
expect(result.decision).toBe(SafetyCheckDecision.ALLOW);
413-
}
414-
});
415-
});
416311
});

packages/core/src/safety/built-in.ts

Lines changed: 0 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -65,8 +65,6 @@ export class AllowedPathChecker implements InProcessChecker {
6565
// Check for blocked segments case-insensitively
6666
let hasBlockedSegment = false;
6767
let isVscodePath = false;
68-
let isGeminiPath = false;
69-
let isGitConfigPath = false;
7068

7169
for (const resolvedDir of resolvedAllowedDirs) {
7270
if (!this.isPathAllowed(resolvedPath, resolvedDir)) continue;
@@ -86,17 +84,6 @@ export class AllowedPathChecker implements InProcessChecker {
8684
if (clean === '.vscode') {
8785
isVscodePath = true;
8886
}
89-
// Writes under a `.gemini` directory or to a `.gitconfig` file change
90-
// how the CLI and git themselves behave, so they should be a
91-
// deliberate, confirmed action rather than an incidental edit.
92-
// Note: `.git` is denied above, and siblings such as `.gitignore` or
93-
// `.geminiignore` are intentionally not matched here.
94-
if (clean === '.gemini') {
95-
isGeminiPath = true;
96-
}
97-
if (clean === '.gitconfig') {
98-
isGitConfigPath = true;
99-
}
10087
}
10188
}
10289

@@ -114,20 +101,6 @@ export class AllowedPathChecker implements InProcessChecker {
114101
};
115102
}
116103

117-
if (isGeminiPath) {
118-
return {
119-
decision: SafetyCheckDecision.ASK_USER,
120-
reason: `Modifying Gemini CLI configuration files (.gemini) requires explicit user confirmation.`,
121-
};
122-
}
123-
124-
if (isGitConfigPath) {
125-
return {
126-
decision: SafetyCheckDecision.ASK_USER,
127-
reason: `Modifying Git configuration files (.gitconfig) requires explicit user confirmation.`,
128-
};
129-
}
130-
131104
let isAllowed = false;
132105
for (const resolvedDir of resolvedAllowedDirs) {
133106
if (this.isPathAllowed(resolvedPath, resolvedDir)) {

0 commit comments

Comments
 (0)