feat(review): grant the claude reviewer child a read-only tool allowlist · Entire

feat(review): grant the claude reviewer child a read-only tool allowlist

90c8d54·

peyton-alt·1w ago·2 files·+72 added/-2 removed

Headless claude -p auto-denies any tool call that would have prompted interactively, and buildReviewCmd passed no permission config — so finder subagents bounced off denials for git diff/log/show and detoured around them (observed in a live review transcript: 'attempted tool calls, hit approval/tool restrictions, and continued').

Pass --allowedTools with the read-only surface a review needs: Read/ Grep/Glob/Task/TodoWrite, enumerated read-only git subcommands (not Bash(git:*) — aliases/hooks execute arbitrary code and push/commit match the blanket), and entire's read-only lookup commands referenced by the checkpoint context. Never --dangerously-skip-permissions: reviewers process untrusted diffs and must stay unable to write.

Co-Authored-By: Claude Fable 5 noreply@anthropic.com

Sessions

f0c2e8deec13View transcript

[?\

Handoff To Claude: entire review RedoClaude Code·17 steps](/content/gh/entireio/cli/session/93833a17-c2c6-4cb0-85b2-663c867b105f#timeline-f0c2e8deec13/index.html)

Changes

2

7 unmodified lines

8
9
10
11
12
13
14
19 unmodified lines

34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
40
62
63
64
65
66
67
68
69

7 unmodified lines

"io"
    "os"
    "os/exec"
    "strings"

"github.com/entireio/cli/cmd/entire/cli/review"
    reviewtypes "github.com/entireio/cli/cmd/entire/cli/review/types"
19 unmodified lines

}
}

// reviewToolAllowlist is the read-only tool surface a headless claude
// review child gets pre-approved. Print mode (-p) auto-denies any tool call
// that would have prompted interactively, so without an explicit allowlist
// every finder that runs `git diff`/`git log` bounces off a denial and
// detours (slower reviews, findings verified by workaround or not at all).
//
// Deliberately narrow: read-only git subcommands are enumerated instead of
// granting Bash(git:\*) — git can execute arbitrary code via aliases/hooks,
// and push/commit match the blanket pattern. Nothing write-capable (Edit,
// Write) and never --dangerously-skip-permissions: reviewers process
// untrusted input (the diff under review), so the child must stay unable to
// modify the repo. --allowedTools ADDS to the user's own permission config;
// it cannot revoke anything.
var reviewToolAllowlist = []string{
    "Read", "Grep", "Glob", "Task", "TodoWrite",
    "Bash(git diff:*)", "Bash(git log:*)", "Bash(git show:*)",
    "Bash(git status:*)", "Bash(git blame:*)", "Bash(git rev-parse:*)",
    "Bash(git merge-base:*)", "Bash(git ls-files:*)", "Bash(git branch:*)",
    "Bash(entire search:*)", "Bash(entire checkpoint explain:*)",
}

// buildReviewCmd builds the exec.Cmd for a claude review run.
// Exposed at package level for test inspection of argv and env.
func buildReviewCmd(ctx context.Context, cfg reviewtypes.RunConfig) *exec.Cmd {
    prompt := review.ComposeReviewPrompt(cfg)
    args := []string{"-p", prompt, "--output-format", "stream-json", "--verbose"}
    args := []string{
        "-p", prompt,
        "--output-format", "stream-json", "--verbose",
        "--allowedTools", strings.Join(reviewToolAllowlist, ","),
    }
    args = review.AppendModelFlag(args, cfg.Model)
    cmd := exec.CommandContext(ctx, "claude", args...)
    cmd.Env = review.AppendReviewEnv(os.Environ(), "claude-code", cfg, prompt)
}

Mcmd/entire/cli/agent/claudecode/reviewer.go+27/-1

95 unmodified lines

96 97 98 99 99 100 101 102 103 297 unmodified lines

401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446

95 unmodified lines

cmd := buildReviewCmd(context.Background(), cfg)

// Expect: claude -p --output-format stream-json --verbose wantSuffix := []string{"--output-format", "stream-json", "--verbose"} // --allowedTools wantSuffix := []string{"--output-format", "stream-json", "--verbose", "--allowedTools", strings.Join(reviewToolAllowlist, ",")} if len(cmd.Args) != 3+len(wantSuffix) {

t.Fatalf("expected %d args, got %d: %v", 3+len(wantSuffix), len(cmd.Args), cmd.Args) } 297 unmodified lines

_ = ev } }

// TestReviewer_ArgvIncludesReadOnlyAllowlist verifies the spawned claude // child gets an explicit read-only tool allowlist. Headless -p mode // auto-denies any tool call not pre-approved, so without this every finder // that runs git diff/git log bounces off a denial and detours — slower // reviews and findings that were never verified. The allowlist grants the // read-only surface a review needs and nothing write-capable. func TestReviewer_ArgvIncludesReadOnlyAllowlist(t *testing.T) { t.Parallel() cmd := buildReviewCmd(context.Background(), reviewtypes.RunConfig{Skills: []string{"/x"}})

allowed := "" for i, arg := range cmd.Args { if arg == "--allowedTools" && i+1 < len(cmd.Args) { allowed = cmd.Args[i+1] } if arg == "--dangerously-skip-permissions" { t.Fatal("reviewer child must never run with permissions disabled") } } if allowed == "" { t.Fatalf("--allowedTools missing from argv: %v", cmd.Args) }

members := make(map[string]bool) for _, m := range strings.Split(allowed, ",") { members[strings.TrimSpace(m)] = true } for _, want := range []string{ "Read", "Grep", "Glob", "Task", "Bash(git diff:)", "Bash(git log:)", "Bash(git show:)", "Bash(git status:)", "Bash(git blame:)", "Bash(git rev-parse:)", } { if !members[want] { t.Errorf("allowlist missing %q; got %q", want, allowed) } } for _, banned := range []string{"Edit", "Write", "MultiEdit", "NotebookEdit", "Bash", "Bash(git:*)"} { if members[banned] { t.Errorf("allowlist must not grant write-capable or blanket tool %q; got %q", banned, allowed) } } }