Skip to content

Commit a85cf9e

Browse files
wesmclaude
andauthored
Prevent .git/index.lock contention and enforce read-only sandbox for review agents (#518) (#539)
## Summary Fixes #518. - Set `GIT_OPTIONAL_LOCKS=0` in the environment of all agent subprocesses via `configureSubprocess()`. Git 2.15+ honours this variable and skips the optional index lock that read-only commands (`git status`, `git diff`) normally take to refresh cached stat data. This prevents background agents from contending with the user's own git operations. - Enforce read-only sandbox for codex review mode by replacing `--full-auto` (which implies `--sandbox workspace-write`) with `--sandbox read-only`. Previously, codex had full write access to the working tree during background reviews — it could run `git add`, modify files, and take mandatory index locks. Now codex review jobs are read-only, matching Claude Code's existing `--allowedTools Read,Glob,Grep` restriction. Agentic mode (fix/refine jobs, `--agentic`) is unchanged and still uses `--dangerously-bypass-approvals-and-sandbox`. - Use `cmd.Environ()` instead of `os.Environ()` when building subprocess environments so `PWD` is correctly synthesized from `cmd.Dir` rather than inheriting the daemon's stale working directory. - Move `configureSubprocess()` after Claude's custom env setup so `GIT_OPTIONAL_LOCKS=0` is appended to the final environment rather than being overwritten. - Probe `codex exec --help` (not top-level `--help`) for `--sandbox` support to match the subcommand where the flag is actually used. --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent b1bb2f0 commit a85cf9e

7 files changed

Lines changed: 90 additions & 28 deletions

File tree

internal/agent/agent_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -344,7 +344,7 @@ func TestSmartAgentReviewPassesModelFlag(t *testing.T) {
344344
helpOutput := ""
345345
jsonOutput := `{"type": "result", "result": "review result"}`
346346
if tt.name == "codex" {
347-
helpOutput = "--full-auto"
347+
helpOutput = "--sandbox"
348348
jsonOutput = `{"type": "item.completed", "item": {"type": "agent_message", "text": "review result"}}`
349349
}
350350

internal/agent/agent_test_helpers.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,7 @@ func mockAgentCLI(t *testing.T, opts MockCLIOpts) *MockCLIResult {
195195
if err := os.WriteFile(helpFile, []byte(opts.HelpOutput), 0644); err != nil {
196196
t.Fatalf("write help output file: %v", err)
197197
}
198-
script.WriteString(fmt.Sprintf(`if [ "$1" = "--help" ]; then cat %q; exit 0; fi`, helpFile) + "\n")
198+
script.WriteString(fmt.Sprintf(`case "$*" in *--help*) cat %q; exit 0;; esac`, helpFile) + "\n")
199199
}
200200

201201
// Capture args

internal/agent/claude.go

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import (
77
"encoding/json"
88
"fmt"
99
"io"
10-
"os"
1110
"os/exec"
1211
"slices"
1312
"strings"
@@ -191,19 +190,24 @@ func (a *ClaudeAgent) Review(ctx context.Context, repoPath, commitSHA, prompt st
191190

192191
cmd := exec.CommandContext(ctx, a.Command, args...)
193192
cmd.Dir = repoPath
194-
tracker := configureSubprocess(cmd)
195193

196194
// Strip CLAUDECODE to prevent nested-session detection (#270),
197195
// and handle API key (configured key or subscription auth).
196+
// Use cmd.Environ() (not os.Environ()) so PWD=<cmd.Dir> is
197+
// synthesized correctly. Set env before configureSubprocess so
198+
// GIT_OPTIONAL_LOCKS=0 is appended to the final environment.
198199
stripKeys := []string{"ANTHROPIC_API_KEY", "CLAUDECODE"}
200+
baseEnv := cmd.Environ()
199201
if apiKey := AnthropicAPIKey(); apiKey != "" {
200-
cmd.Env = append(filterEnv(os.Environ(), stripKeys...), "ANTHROPIC_API_KEY="+apiKey)
202+
cmd.Env = append(filterEnv(baseEnv, stripKeys...), "ANTHROPIC_API_KEY="+apiKey)
201203
} else {
202-
cmd.Env = filterEnv(os.Environ(), stripKeys...)
204+
cmd.Env = filterEnv(baseEnv, stripKeys...)
203205
}
204206
// Suppress sounds from Claude Code (notification/completion sounds)
205207
cmd.Env = append(cmd.Env, "CLAUDE_NO_SOUND=1")
206208

209+
tracker := configureSubprocess(cmd)
210+
207211
var stderr bytes.Buffer
208212
stdoutPipe, err := cmd.StdoutPipe()
209213
if err != nil {

internal/agent/codex.go

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ func (a *CodexAgent) CommandLine() string {
116116
if agenticMode {
117117
args = append(args, codexDangerousFlag)
118118
} else {
119-
args = append(args, codexAutoApproveFlag)
119+
args = append(args, "--sandbox", "read-only")
120120
}
121121
if a.Model != "" {
122122
args = append(args, "-m", a.Model)
@@ -140,7 +140,10 @@ func (a *CodexAgent) buildArgs(repoPath string, agenticMode, autoApprove bool) [
140140
args = append(args, codexDangerousFlag)
141141
}
142142
if autoApprove {
143-
args = append(args, codexAutoApproveFlag)
143+
// Use read-only sandbox for review mode instead of --full-auto
144+
// (which implies --sandbox workspace-write). Background review
145+
// jobs run in the user's repo and must not take index.lock.
146+
args = append(args, "--sandbox", "read-only")
144147
}
145148
args = append(args,
146149
"-C", repoPath,
@@ -174,15 +177,17 @@ func codexSupportsDangerousFlag(ctx context.Context, command string) (bool, erro
174177
return supported, nil
175178
}
176179

177-
func codexSupportsAutoApproveFlag(ctx context.Context, command string) (bool, error) {
180+
// codexSupportsNonInteractive checks that codex supports --sandbox,
181+
// needed for non-agentic review mode (--sandbox read-only).
182+
func codexSupportsNonInteractive(ctx context.Context, command string) (bool, error) {
178183
if cached, ok := codexAutoApproveSupport.Load(command); ok {
179184
return cached.(bool), nil
180185
}
181-
cmd := exec.CommandContext(ctx, command, "--help")
186+
cmd := exec.CommandContext(ctx, command, "exec", "--help")
182187
output, err := cmd.CombinedOutput()
183-
supported := strings.Contains(string(output), codexAutoApproveFlag)
188+
supported := strings.Contains(string(output), "--sandbox")
184189
if err != nil && !supported {
185-
return false, fmt.Errorf("check %s --help: %w: %s", command, err, output)
190+
return false, fmt.Errorf("check %s exec --help: %w: %s", command, err, output)
186191
}
187192
codexAutoApproveSupport.Store(command, supported)
188193
return supported, nil
@@ -202,16 +207,16 @@ func (a *CodexAgent) Review(ctx context.Context, repoPath, commitSHA, prompt str
202207
}
203208
}
204209

205-
// When piping stdin, codex needs --full-auto to run non-interactively.
206-
// Agentic mode uses --dangerously-bypass-approvals-and-sandbox which implies auto-approve.
210+
// Non-agentic review mode uses --sandbox read-only for
211+
// non-interactive execution without writing to the working tree.
207212
autoApprove := false
208213
if !agenticMode {
209-
supported, err := codexSupportsAutoApproveFlag(ctx, a.Command)
214+
supported, err := codexSupportsNonInteractive(ctx, a.Command)
210215
if err != nil {
211216
return "", err
212217
}
213218
if !supported {
214-
return "", fmt.Errorf("codex requires %s for stdin input; upgrade codex or use --agentic", codexAutoApproveFlag)
219+
return "", fmt.Errorf("codex version too old for non-interactive execution; upgrade codex or use --agentic")
215220
}
216221
autoApprove = true
217222
}

internal/agent/codex_test.go

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,8 @@ func TestCodex_buildArgs(t *testing.T) {
3636
name: "NonAgenticAutoApprove",
3737
agentic: false,
3838
autoApprove: true,
39-
wantFlags: []string{codexAutoApproveFlag, "--json"},
40-
wantMissingFlags: []string{codexDangerousFlag},
39+
wantFlags: []string{"--sandbox", "read-only", "--json"},
40+
wantMissingFlags: []string{codexDangerousFlag, codexAutoApproveFlag},
4141
},
4242
{
4343
name: "AgenticNoAutoApprove",
@@ -106,9 +106,9 @@ func TestCodexReviewUnsafeMissingFlagErrors(t *testing.T) {
106106
assert.Contains(t, err.Error(), "does not support")
107107
}
108108

109-
func TestCodexReviewAlwaysAddsAutoApprove(t *testing.T) {
109+
func TestCodexReviewUsesReadOnlySandbox(t *testing.T) {
110110
a, mock := setupMockCodex(t, false, MockCLIOpts{
111-
HelpOutput: "usage " + codexAutoApproveFlag,
111+
HelpOutput: "usage --sandbox",
112112
CaptureArgs: true,
113113
StdoutLines: []string{
114114
`{"type":"item.completed","item":{"type":"agent_message","text":"ok"}}`,
@@ -120,12 +120,16 @@ func TestCodexReviewAlwaysAddsAutoApprove(t *testing.T) {
120120

121121
args, err := os.ReadFile(mock.ArgsFile)
122122
require.NoError(t, err)
123-
assert.Contains(t, string(args), codexAutoApproveFlag, "expected %s in args, got %s", codexAutoApproveFlag, strings.TrimSpace(string(args)))
123+
argsStr := string(args)
124+
assert.Contains(t, argsStr, "--sandbox read-only",
125+
"expected --sandbox read-only in args, got %s", strings.TrimSpace(argsStr))
126+
assert.NotContains(t, argsStr, codexAutoApproveFlag,
127+
"expected no %s in review mode, got %s", codexAutoApproveFlag, strings.TrimSpace(argsStr))
124128
}
125129

126130
func TestCodexReviewWithSessionResumePassesResumeArgs(t *testing.T) {
127131
a, mock := setupMockCodex(t, false, MockCLIOpts{
128-
HelpOutput: "usage " + codexAutoApproveFlag,
132+
HelpOutput: "usage --sandbox",
129133
CaptureArgs: true,
130134
StdoutLines: []string{
131135
`{"type":"item.completed","item":{"type":"agent_message","text":"ok"}}`,
@@ -153,10 +157,7 @@ func TestCodexReviewTimeoutClosesStdoutPipe(t *testing.T) {
153157
})
154158

155159
cmdPath := writeTempCommand(t, `#!/bin/sh
156-
if [ "$1" = "--help" ]; then
157-
echo "usage `+codexAutoApproveFlag+`"
158-
exit 0
159-
fi
160+
case "$*" in *--help*) echo "usage --sandbox"; exit 0;; esac
160161
(sleep 0.2) &
161162
printf '%s\n' '{"type":"item.completed","item":{"type":"agent_message","text":"partial"}}'
162163
exit 0
@@ -313,7 +314,7 @@ func TestCodexParseStreamJSON(t *testing.T) {
313314

314315
func TestCodexReviewPipesPromptViaStdin(t *testing.T) {
315316
a, mock := setupMockCodex(t, false, MockCLIOpts{
316-
HelpOutput: "usage " + codexAutoApproveFlag,
317+
HelpOutput: "usage --sandbox",
317318
CaptureStdin: true,
318319
StdoutLines: []string{
319320
`{"type":"item.completed","item":{"type":"agent_message","text":"ok"}}`,
@@ -331,7 +332,7 @@ func TestCodexReviewPipesPromptViaStdin(t *testing.T) {
331332

332333
func TestCodexReviewNoValidJSONReturnsError(t *testing.T) {
333334
a, _ := setupMockCodex(t, false, MockCLIOpts{
334-
HelpOutput: "usage " + codexAutoApproveFlag,
335+
HelpOutput: "usage --sandbox",
335336
StdoutLines: []string{"plain text output"},
336337
})
337338

internal/agent/process.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,18 @@ type subprocessTracker struct {
2020

2121
func configureSubprocess(cmd *exec.Cmd) *subprocessTracker {
2222
cmd.WaitDelay = subprocessWaitDelay
23+
24+
// Prevent agents from taking .git/index.lock in the user's repo.
25+
// Git 2.15+ honours GIT_OPTIONAL_LOCKS=0: read-only commands like
26+
// "git status" and "git diff" skip the optional index lock they
27+
// normally take to refresh cached stat data. Without this, agent
28+
// processes running in the background contend with the user's own
29+
// git operations (staging, committing, etc.).
30+
if cmd.Env == nil {
31+
cmd.Env = cmd.Environ()
32+
}
33+
cmd.Env = append(cmd.Env, "GIT_OPTIONAL_LOCKS=0")
34+
2335
tracker := &subprocessTracker{}
2436
// Ensure Cancel is always set. Go's exec.CommandContext only provides a
2537
// default Kill cancel when both Cancel==nil and WaitDelay==0. Since we

internal/agent/process_test.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,46 @@ func TestContextProcessErrorDoesNotMaskSignalExitAfterContextDone(t *testing.T)
130130
}
131131
}
132132

133+
func TestConfigureSubprocessSetsOptionalLocks(t *testing.T) {
134+
skipIfWindows(t)
135+
136+
cmd := exec.CommandContext(context.Background(), "sh", "-c", "echo $GIT_OPTIONAL_LOCKS")
137+
configureSubprocess(cmd)
138+
139+
out, err := cmd.Output()
140+
require.NoError(t, err)
141+
require.Equal(t, "0\n", string(out),
142+
"configureSubprocess should set GIT_OPTIONAL_LOCKS=0")
143+
}
144+
145+
func TestConfigureSubprocessPreservesExistingEnv(t *testing.T) {
146+
skipIfWindows(t)
147+
148+
cmd := exec.CommandContext(context.Background(),
149+
"sh", "-c", "echo $MY_TEST_VAR:$GIT_OPTIONAL_LOCKS")
150+
cmd.Env = append(os.Environ(), "MY_TEST_VAR=hello")
151+
configureSubprocess(cmd)
152+
153+
out, err := cmd.Output()
154+
require.NoError(t, err)
155+
require.Equal(t, "hello:0\n", string(out),
156+
"configureSubprocess should preserve existing env and add GIT_OPTIONAL_LOCKS=0")
157+
}
158+
159+
func TestConfigureSubprocessPreservesPWD(t *testing.T) {
160+
skipIfWindows(t)
161+
162+
dir := t.TempDir()
163+
cmd := exec.CommandContext(context.Background(), "sh", "-c", "echo $PWD")
164+
cmd.Dir = dir
165+
configureSubprocess(cmd)
166+
167+
out, err := cmd.Output()
168+
require.NoError(t, err)
169+
require.Equal(t, dir+"\n", string(out),
170+
"configureSubprocess should preserve PWD matching cmd.Dir")
171+
}
172+
133173
func TestConfigureSubprocessDoesNotMarkCanceledWhenProcessAlreadyExited(t *testing.T) {
134174
skipIfWindows(t)
135175

0 commit comments

Comments
 (0)