Skip to content

fix(agent): grant the linked worktree's git dirs to the OS sandbox (P7-SANDBOX-01)#142

Open
Reederey87 wants to merge 1 commit into
mainfrom
fix/p7-sandbox-01
Open

fix(agent): grant the linked worktree's git dirs to the OS sandbox (P7-SANDBOX-01)#142
Reederey87 wants to merge 1 commit into
mainfrom
fix/p7-sandbox-01

Conversation

@Reederey87

@Reederey87 Reederey87 commented Jul 7, 2026

Copy link
Copy Markdown
Owner

Fixes P7-SANDBOX-01 (P1) from the Pass-7 audit (#141).

The bug

A DevStrap agent worktree is a git linked worktree whose index/objects/refs live in the parent clone's .git, outside the sandbox write-allow (worktree + tmp only). Under the default guarded policy with --sandbox auto (the common Mac dev host), the kernel EPERM'd every git add/git commit, so agent pr had nothing to push — the core "agent commits → agent pr pushes" loop silently broke. It was untested: the only sandbox+git canary committed into a fresh nested repo, never exercising the linked-worktree path.

The fix

New git.Runner.WorktreeSandboxWriteDirs resolves the linked worktree's git storage a commit writes — <git-common-dir>/{objects,refs,logs} + the per-worktree admin dir (--git-dir) — and all three sandbox backends (Seatbelt, bubblewrap, Landlock) plus the --read-confine allow-list now grant them.

Security-critical design: the grant is per-subpath, never the common dir roothooks/ and config are withheld, because granting write there would let a confined agent plant a hook/config that executes unsandboxed on a later git op (a sandbox escape). This per-subpath approach is also required because Landlock cannot carve a read-only hole out of an RW grant. The resolver additionally refuses a --git-dir that resolves outside <common>/worktrees/ (hardening against a malformed gitfile/commondir).

Proof

Load-bearing e2e on the real Mac Seatbelt kernel sandbox (TestSeatbeltAllowsLinkedWorktreeCommit, env-gated): git add && git commit in a real linked worktree fails without the grant and succeeds with it, landing on the branch. Plus unit coverage for the resolver's security invariants (never the common root/hooks/config), and the Seatbelt/bwrap/read-confine wiring.

Review

Dual review applied (opus — clean/ship-it; Codex gpt-5.5 — two minors fixed): the <common>/worktrees/ validation, symlink-resolving the object/ref subpaths, gating resolution on sandbox-enabled, and a warning on an unexpectedly-empty grant. Deferred by agreement (latent — readonly blocks commits): the --read-confine common-config read path (tracked in the work-log follow-ups, alongside the pre-existing, not-widened commondir-redirect note).

Gates

gofmt · go vet · golangci-lint 0 issues · go test -race ./... green · spec-drift passes (specs 05/10/15 + work-log).

Stacked after the Pass-7 audit (#141): the ledger row-move (P7-SANDBOX-01Recently shipped, Pass-7 open 17→16) happens on rebase once #141 lands, per the AGENTS.md multi-PR-wave convention.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Sandboxed agents can now commit changes from linked Git worktrees more reliably.
    • The sandbox now grants only the Git storage paths needed for git add and git commit, while still blocking sensitive Git hooks and config files.
    • Read access for Git operations was expanded so common worktree actions like status and commit continue to work under confinement.
  • Bug Fixes

    • Reduced cases where sandboxed Git commits failed due to missing permissions in linked worktrees.

…7-SANDBOX-01)

A DevStrap agent worktree is a git *linked* worktree whose index/objects/refs
live in the parent clone's .git, outside the worktree write-allow. Under the
default guarded policy with --sandbox auto (the common Mac dev host), the write
confinement kernel-EPERM'd every `git add`/`git commit`, so `agent pr` had
nothing to push — the core agent loop silently broke, untested (the only
sandbox+git canary used a fresh nested repo).

New git.Runner.WorktreeSandboxWriteDirs resolves the linked worktree's
<git-common-dir>/{objects,refs,logs} + the per-worktree admin dir; all three
sandbox backends (Seatbelt, bubblewrap, Landlock) and the --read-confine
allow-list now grant them. The common dir's hooks/ and config are deliberately
excluded (per-subpath, never the whole .git) — granting them would let a
confined agent plant a hook/config that runs UNSANDBOXED on a later git op.

Proven by a live Seatbelt e2e (TestSeatbeltAllowsLinkedWorktreeCommit): the
commit FAILS without the grant and SUCCEEDS with it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 7, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds Runner.WorktreeSandboxWriteDirs to resolve a linked git worktree's write-required storage paths (objects, refs, logs, admin dir), exposes them via a new SandboxSpec.GitDirs field, and wires them into Seatbelt SBPL, bubblewrap, Landlock, and read-confine backends plus CLI agent run flow, with tests and spec docs updated.

Changes

Linked worktree git sandbox write grants

Layer / File(s) Summary
Git worktree write-dir resolution
internal/git/git.go, internal/git/sandbox_writedirs_test.go
New Runner.WorktreeSandboxWriteDirs resolves git common/git dir via rev-parse, symlink-normalizes paths, and returns objects/refs/logs plus per-worktree admin dir; tests cover linked-worktree and non-repo cases.
SandboxSpec.GitDirs field
internal/platform/sandbox.go
Adds GitDirs []string field to SandboxSpec documenting required writeable git storage paths and hooks/config exclusions.
Backend write/read grants for GitDirs
internal/platform/sandbox_profile.go, internal/platform/sandbox_bwrap_args.go, internal/platform/sandbox_landlock.go, internal/platform/sandbox_read_confine.go, internal/platform/sandbox_gitdirs_test.go, internal/platform/sandbox_worktree_commit_darwin_test.go
SBPL profile, bubblewrap --bind-try, Landlock RWDirs, and readConfineRoots all incorporate spec.GitDirs; unit and gated Darwin e2e tests verify grants and exclusion of the common .git root/hooks/config.
CLI agent run wiring
internal/cli/agent.go, internal/cli/agent_sandbox_test.go
agentSandboxSpec and runAgentProcess accept gitDirs; newAgentRunCommand resolves git write dirs via WorktreeSandboxWriteDirs, warns if empty, and passes them through.
Spec and work log documentation
spec/05_MAC_FIRST_IMPLEMENTATION.md, spec/10_AGENT_WORKSPACES_AND_POLICIES.md, spec/15_SECURITY_THREAT_MODEL.md, spec/18_WORK_LOG.md
Documents P7-SANDBOX-01 audit item, expanded enforcement/threat-model narratives, and a new work log entry describing the change and residual follow-ups.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related PRs

  • Reederey87/DevStrap#107: Extends the same Seatbelt sandbox spec/SBPL profile architecture with the new GitDirs write grants.
  • Reederey87/DevStrap#121: Extends the bubblewrap argument builder introduced there to bind-mount per-worktree git storage dirs.
  • Reederey87/DevStrap#123: Extends the Landlock sandbox policy logic with per-worktree GitDirs write grants.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description is detailed, but it does not follow the required Summary/Tests/Safety Checklist template. Add the required Summary, Tests, and Safety Checklist sections, including the specified test checkboxes and completed safety items.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: granting linked worktree git dirs to the OS sandbox.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/p7-sandbox-01

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (4)
internal/git/sandbox_writedirs_test.go (1)

17-58: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

No test for the main (non-linked) checkout branch.

Only the linked-worktree and non-repo-at-all cases are exercised. The gitDirAbs == commonAbs branch in WorktreeSandboxWriteDirs (main checkout returning just objects/refs/logs, no admin dir) has no direct test coverage here.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/git/sandbox_writedirs_test.go` around lines 17 - 58, Add a test case
in TestWorktreeSandboxWriteDirs that exercises the main checkout path where
gitDirAbs equals commonAbs in WorktreeSandboxWriteDirs; verify it returns only
the expected common grants for objects, refs, and logs, and does not include a
per-worktree admin dir. Use the existing WorktreeSandboxWriteDirs helper and
compare against the current linked-worktree assertions so the non-linked branch
is explicitly covered.
internal/git/git.go (2)

601-613: 🔒 Security & Privacy | 🔵 Trivial | 💤 Low value

Boundary check accepts rel == ".", i.e. gitDirAbs == worktreesRoot.

filepath.Rel(worktreesRoot, gitDirAbs) returns "." (not an error) when the two paths are equal, and rel != ".." && !strings.HasPrefix(rel, "../") doesn't exclude that case — so a --git-dir resolving to exactly <common>/worktrees (rather than a subdirectory under it) would be granted wholesale instead of being refused. Given the explicit hardening intent of this function ("keeping the hooks/config exclusion robust against hostile setups"), tightening the check to also reject rel == "." costs nothing and removes the ambiguity, even though ordinary git worktree layouts (confirmed via docs) never produce --git-dir == <common>/worktrees itself.

🔒️ Suggested tightening
-		if rel, rerr := filepath.Rel(worktreesRoot, gitDirAbs); rerr == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
+		if rel, rerr := filepath.Rel(worktreesRoot, gitDirAbs); rerr == nil && rel != "." && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
 			dirs = append(dirs, gitDirAbs)
 		}

Since git validates the target of a worktree gitdir file before returning it, I'm not fully certain this is reachable in practice — flagging for confirmation.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/git/git.go` around lines 601 - 613, The boundary check in the git
worktree admin-dir handling is too permissive because
`filepath.Rel(worktreesRoot, gitDirAbs)` can return `"."` when `gitDirAbs`
equals the `worktreesRoot` itself, causing `gitDirAbs` to be added even though
it is not a child worktree. Update the `gitDirAbs != commonAbs` /
`worktreesRoot` check in `internal/git/git.go` to explicitly reject `rel == "."`
while still allowing only true descendants, preserving the hardening intent
around `dirs` and `worktreesRoot`.

557-565: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Any git rev-parse failure is silently treated as "not a worktree."

Both rev-parse calls collapse every error (missing binary, transient I/O, timeout, permission issue) into return nil, nil. Since callers (e.g. agent.go) discard the error too, a genuine linked worktree could silently get zero write grants on a transient git failure, producing a confusing sandbox denial with no diagnosable cause. r.Run already returns a CommandError with a Kind field (see classifyGitError/ErrTimeout usage above in this file) that could be used to distinguish a real "not a git repository" condition from other failures worth surfacing/logging.

♻️ Sketch
 	common, err := r.Run(ctx, dir, "rev-parse", "--git-common-dir")
 	if err != nil {
-		return nil, nil // not a git worktree — nothing to grant
+		var cmdErr CommandError
+		if errors.As(err, &cmdErr) && cmdErr.Kind == ErrNotARepo {
+			return nil, nil // not a git worktree — nothing to grant
+		}
+		return nil, err // surface unexpected failures instead of masking them
 	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/git/git.go` around lines 557 - 565, WorktreeSandboxWriteDirs
currently treats every git rev-parse failure as “not a worktree,” which hides
real failures. Update Runner.WorktreeSandboxWriteDirs to inspect the error
returned by r.Run and only return nil, nil for the genuine non-repository case;
for other CommandError kinds (for example timeout, I/O, permission, missing
binary) surface or log the error instead. Use the existing CommandError.Kind
handling and classifyGitError/ErrTimeout patterns in internal/git/git.go to keep
the no-worktree path distinct from actionable failures.
internal/cli/agent_sandbox_test.go (1)

101-109: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Missing coverage for GitDirs threading through agentSandboxSpec.

Both updated tests pass nil for the new gitDirs param and neither asserts spec.GitDirs. Given this is the CLI-level wiring for the P7-SANDBOX-01 fix, a regression here (e.g. gitDirs param dropped or ignored inside agentSandboxSpec) wouldn't be caught by any test in this cohort — sandbox_gitdirs_test.go only covers the platform-backend consumption of spec.GitDirs, not that the CLI actually populates it.

✅ Suggested addition to `TestAgentSandboxSpecAnchorsRealUserHome`
 func TestAgentSandboxSpecAnchorsRealUserHome(t *testing.T) {
 	home := t.TempDir()
 	t.Setenv("HOME", home)
-	spec, err := agentSandboxSpec("/wt", "/tmp/run", "/log", nil, agentSandboxLaunch{devstrapHome: "/dsh", denyNetwork: true}, "arun_test")
+	gitDirs := []string{"/common/objects", "/common/refs"}
+	spec, err := agentSandboxSpec("/wt", "/tmp/run", "/log", gitDirs, agentSandboxLaunch{devstrapHome: "/dsh", denyNetwork: true}, "arun_test")
 	if err != nil {
 		t.Fatalf("agentSandboxSpec: %v", err)
 	}
+	if !slices.Equal(spec.GitDirs, gitDirs) {
+		t.Fatalf("GitDirs = %v, want %v", spec.GitDirs, gitDirs)
+	}
 	if spec.UserHome != home {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/cli/agent_sandbox_test.go` around lines 101 - 109, The CLI wiring in
agentSandboxSpec is not covered for the new GitDirs path because the current
tests pass nil and never assert spec.GitDirs. Update
TestAgentSandboxSpecAnchorsRealUserHome (and/or add a sibling test) to pass a
non-nil gitDirs value into agentSandboxSpec and verify the returned spec
populates GitDirs correctly, so regressions where the parameter is dropped or
ignored are caught at the agentSandboxSpec boundary.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/git/sandbox_writedirs_test.go`:
- Around line 73-98: Add test coverage for the plain-checkout branch where
gitDirAbs equals commonAbs in a normal repository checkout; the current
`mustEval`, `contains`, and `gitBinOrSkip` helpers are fine, but the sandbox
write-dir tests need a new case that exercises the regular checkout path in
addition to the existing linked worktree and non-repo scenarios. Update
`internal/git/sandbox_writedirs_test.go` to set up or use a standard checkout
and assert the `gitDirAbs == commonAbs` branch is reached.

---

Nitpick comments:
In `@internal/cli/agent_sandbox_test.go`:
- Around line 101-109: The CLI wiring in agentSandboxSpec is not covered for the
new GitDirs path because the current tests pass nil and never assert
spec.GitDirs. Update TestAgentSandboxSpecAnchorsRealUserHome (and/or add a
sibling test) to pass a non-nil gitDirs value into agentSandboxSpec and verify
the returned spec populates GitDirs correctly, so regressions where the
parameter is dropped or ignored are caught at the agentSandboxSpec boundary.

In `@internal/git/git.go`:
- Around line 601-613: The boundary check in the git worktree admin-dir handling
is too permissive because `filepath.Rel(worktreesRoot, gitDirAbs)` can return
`"."` when `gitDirAbs` equals the `worktreesRoot` itself, causing `gitDirAbs` to
be added even though it is not a child worktree. Update the `gitDirAbs !=
commonAbs` / `worktreesRoot` check in `internal/git/git.go` to explicitly reject
`rel == "."` while still allowing only true descendants, preserving the
hardening intent around `dirs` and `worktreesRoot`.
- Around line 557-565: WorktreeSandboxWriteDirs currently treats every git
rev-parse failure as “not a worktree,” which hides real failures. Update
Runner.WorktreeSandboxWriteDirs to inspect the error returned by r.Run and only
return nil, nil for the genuine non-repository case; for other CommandError
kinds (for example timeout, I/O, permission, missing binary) surface or log the
error instead. Use the existing CommandError.Kind handling and
classifyGitError/ErrTimeout patterns in internal/git/git.go to keep the
no-worktree path distinct from actionable failures.

In `@internal/git/sandbox_writedirs_test.go`:
- Around line 17-58: Add a test case in TestWorktreeSandboxWriteDirs that
exercises the main checkout path where gitDirAbs equals commonAbs in
WorktreeSandboxWriteDirs; verify it returns only the expected common grants for
objects, refs, and logs, and does not include a per-worktree admin dir. Use the
existing WorktreeSandboxWriteDirs helper and compare against the current
linked-worktree assertions so the non-linked branch is explicitly covered.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 5c984efc-cc68-4f9a-925f-720bb861064e

📥 Commits

Reviewing files that changed from the base of the PR and between d667530 and 7fa06e2.

📒 Files selected for processing (15)
  • internal/cli/agent.go
  • internal/cli/agent_sandbox_test.go
  • internal/git/git.go
  • internal/git/sandbox_writedirs_test.go
  • internal/platform/sandbox.go
  • internal/platform/sandbox_bwrap_args.go
  • internal/platform/sandbox_gitdirs_test.go
  • internal/platform/sandbox_landlock.go
  • internal/platform/sandbox_profile.go
  • internal/platform/sandbox_read_confine.go
  • internal/platform/sandbox_worktree_commit_darwin_test.go
  • spec/05_MAC_FIRST_IMPLEMENTATION.md
  • spec/10_AGENT_WORKSPACES_AND_POLICIES.md
  • spec/15_SECURITY_THREAT_MODEL.md
  • spec/18_WORK_LOG.md

Comment on lines +73 to +98
func mustEval(t *testing.T, p string) string {
t.Helper()
r, err := filepath.EvalSymlinks(p)
if err != nil {
return filepath.Clean(p)
}
return r
}

func contains(s []string, v string) bool {
for _, x := range s {
if x == v {
return true
}
}
return false
}

func gitBinOrSkip(t *testing.T) string {
t.Helper()
bin, err := exec.LookPath("git")
if err != nil {
t.Skip("git not installed")
}
return bin
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -n --type=go '^func (contains|mustEval|gitBinOrSkip)\(' internal/git

Repository: Reederey87/DevStrap

Length of output: 414


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== package files =="
git ls-files internal/git/*.go

echo
echo "== helper name search across internal/git =="
rg -n --type=go '^func (contains|mustEval|gitBinOrSkip)\(' internal/git

echo
echo "== tests around sandbox_writedirs =="
ast-grep outline internal/git/sandbox_writedirs_test.go --view expanded || true

echo
echo "== implementation branch references =="
rg -n 'gitDirAbs == commonAbs|commonAbs|linked-worktree|sandbox_writedirs' internal/git

Repository: Reederey87/DevStrap

Length of output: 1353


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== internal/git/sandbox_writedirs_test.go (relevant sections) =="
sed -n '1,170p' internal/git/sandbox_writedirs_test.go

echo
echo "== internal/git/git.go (relevant section) =="
sed -n '560,630p' internal/git/git.go

Repository: Reederey87/DevStrap

Length of output: 5566


Cover the plain-checkout branch too Add a case for the gitDirAbs == commonAbs path in a regular checkout; the current tests only cover linked worktrees and non-repos.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/git/sandbox_writedirs_test.go` around lines 73 - 98, Add test
coverage for the plain-checkout branch where gitDirAbs equals commonAbs in a
normal repository checkout; the current `mustEval`, `contains`, and
`gitBinOrSkip` helpers are fine, but the sandbox write-dir tests need a new case
that exercises the regular checkout path in addition to the existing linked
worktree and non-repo scenarios. Update `internal/git/sandbox_writedirs_test.go`
to set up or use a standard checkout and assert the `gitDirAbs == commonAbs`
branch is reached.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant