Skip to content

Commit c8b7017

Browse files
fix(review): enforce reviewer read-only in default mode and pass review body via stdin
Address review 4577706991. - Launch the claude-code reviewer in PermissionModeDefault instead of PermissionModeAuto. Auto mode auto-approves unlisted, classifier-"safe" tools, so a filesystem-mutating Bash command (> file, sed -i, rm) fell through the allow/deny lists. Default mode honors the allowlist and stalls every other tool (no human on the headless pane), so read-only is enforced rather than classifier-dependent. Reconcile the comment, which described default-mode semantics while the code set auto. - Replace the shell-fragile `--body-text "<markdown>"` prompt step with a quoted heredoc piped to `ao review submit --body -`. Review Markdown routinely contains ", `, $, and \, which break a double-quoted arg or trigger expansion; stdin passes them through literally and writes no file. - Add `--body -` stdin support to the review submit command, with a test that round-trips Markdown containing those shell-breaking characters. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent f2276fa commit c8b7017

5 files changed

Lines changed: 63 additions & 18 deletions

File tree

backend/internal/adapters/reviewer/claudecode/claudecode.go

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -33,12 +33,16 @@ var _ ports.Reviewer = (*Reviewer)(nil)
3333

3434
// reviewerAllowedTools is the read-only tool allowlist the reviewer launches
3535
// with. The reviewer runs headless (no human to approve prompts) but must stay
36-
// read-only, so instead of bypassPermissions — which skips the permission
37-
// system entirely and ignores allow/deny rules — it launches in the default
38-
// mode where these rules are honored: allow rules auto-approve without
39-
// prompting, so the reviewer can read the checkout and run the few commands it
40-
// needs (git diff/log/show to inspect the PR, gh to post the review, and
41-
// `ao review submit` to record the verdict) without stalling.
36+
// read-only. It launches in default mode (not auto, and not bypassPermissions —
37+
// which skips the permission system entirely and ignores allow/deny rules). In
38+
// default mode the allow rules auto-approve without prompting, so the reviewer
39+
// can read the checkout and run the few commands it needs (git diff/log/show to
40+
// inspect the PR, gh to post the review, and `ao review submit` to record the
41+
// verdict) without stalling, while any tool that is neither allowed nor denied —
42+
// including a filesystem-mutating Bash command — prompts and, with no human on
43+
// the pane, stalls rather than executing. That is what makes read-only an
44+
// enforced guarantee rather than a classifier-dependent one: auto mode would let
45+
// a "safe"-classified shell write through, default mode cannot.
4246
var reviewerAllowedTools = []string{
4347
"Read",
4448
"Grep",
@@ -70,10 +74,13 @@ func (r *Reviewer) ReviewCommand(ctx context.Context, inv ports.ReviewInvocation
7074
WorkspacePath: inv.WorkspacePath,
7175
Prompt: inv.Prompt,
7276
SystemPrompt: inv.SystemPrompt,
73-
// Launch off bypassPermissions so the allow/deny lists are enforced.
74-
// Set an explicit non-bypass mode instead of deferring to the user's
75-
// Claude defaultMode, which may itself be bypassPermissions.
76-
Permissions: ports.PermissionModeAuto,
77+
// Launch in default mode, not bypassPermissions (which ignores the
78+
// allow/deny lists) and not auto (which would auto-approve an unlisted,
79+
// classifier-"safe" shell write). Default mode honors the allowlist and
80+
// stalls every other tool, so read-only is enforced rather than left to
81+
// the auto classifier. Set it explicitly instead of deferring to the
82+
// user's Claude defaultMode, which may itself be bypassPermissions.
83+
Permissions: ports.PermissionModeDefault,
7784
AllowedTools: reviewerAllowedTools,
7885
DisallowedTools: reviewerDisallowedTools,
7986
})

backend/internal/adapters/reviewer/claudecode/claudecode_test.go

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -45,11 +45,13 @@ func TestReviewCommandLaunchesReadOnlyOffBypass(t *testing.T) {
4545
t.Fatalf("ReviewCommand: %v", err)
4646
}
4747

48-
// The allowlist is what enforces read-only, so it must launch in an
49-
// explicit non-bypass mode: bypassPermissions ignores allow/deny rules
50-
// entirely, and an empty mode would defer to a user's defaultMode.
51-
if agent.got.Permissions != ports.PermissionModeAuto {
52-
t.Fatalf("reviewer must launch in auto permission mode; got %q", agent.got.Permissions)
48+
// The allowlist is what enforces read-only, so it must launch in default
49+
// mode: bypassPermissions ignores allow/deny rules entirely, auto would
50+
// auto-approve unlisted classifier-"safe" shell writes, and an empty mode
51+
// would defer to a user's defaultMode. Default mode honors the allowlist and
52+
// stalls every other tool.
53+
if agent.got.Permissions != ports.PermissionModeDefault {
54+
t.Fatalf("reviewer must launch in default permission mode; got %q", agent.got.Permissions)
5355
}
5456
if !contains(agent.got.AllowedTools, "Read") || !contains(agent.got.AllowedTools, "Bash(ao review submit:*)") {
5557
t.Fatalf("allowlist missing read-only review tools: %#v", agent.got.AllowedTools)

backend/internal/cli/review.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package cli
33
import (
44
"errors"
55
"fmt"
6+
"io"
67
"net/url"
78
"os"
89
"strings"
@@ -67,7 +68,7 @@ func newReviewSubmitCommand(ctx *commandContext) *cobra.Command {
6768
cmd.Flags().StringVar(&opts.session, "session", "", "Worker session id (or pass it as the positional argument)")
6869
cmd.Flags().StringVar(&opts.runID, "run", "", "Review run id (required)")
6970
cmd.Flags().StringVar(&opts.verdict, "verdict", "", "Review verdict: approved or changes_requested (required)")
70-
cmd.Flags().StringVar(&opts.body, "body", "", "Path to a Markdown file with the review body")
71+
cmd.Flags().StringVar(&opts.body, "body", "", "Path to a Markdown file with the review body, or - to read it from stdin")
7172
cmd.Flags().StringVar(&opts.bodyText, "body-text", "", "Review body as inline Markdown (use instead of --body when no file should be written)")
7273
return cmd
7374
}
@@ -96,6 +97,12 @@ func (c *commandContext) submitReview(cmd *cobra.Command, args []string, opts re
9697
switch {
9798
case opts.bodyText != "":
9899
body = opts.bodyText
100+
case path == "-":
101+
raw, err := io.ReadAll(cmd.InOrStdin())
102+
if err != nil {
103+
return usageError{fmt.Errorf("read body from stdin: %w", err)}
104+
}
105+
body = string(raw)
99106
case path != "":
100107
raw, err := os.ReadFile(path)
101108
if err != nil {

backend/internal/cli/review_test.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"net/http/httptest"
88
"os"
99
"path/filepath"
10+
"strings"
1011
"testing"
1112
)
1213

@@ -81,6 +82,32 @@ func TestReviewSubmitReadsInlineBody(t *testing.T) {
8182
}
8283
}
8384

85+
func TestReviewSubmitReadsBodyFromStdin(t *testing.T) {
86+
cfg := setConfigEnv(t)
87+
srv, capture := reviewServer(t, http.StatusOK, `{"review":{"verdict":"changes_requested"}}`)
88+
writeRunFileFor(t, cfg, srv)
89+
90+
// Markdown a real review carries: quotes, backticks, $ and a backslash — the
91+
// characters that break a double-quoted --body-text arg. Stdin passes them
92+
// through untouched.
93+
md := "Found a bug in `cmd \"run\"`: $HOME isn't escaped (see C:\\path)."
94+
deps := aliveDeps()
95+
deps.In = strings.NewReader(md)
96+
97+
_, errOut, err := executeCLI(t, deps,
98+
"review", "submit", "mer-1", "--run", "run-1", "--verdict", "changes_requested", "--body", "-")
99+
if err != nil {
100+
t.Fatalf("unexpected error: %v\nstderr=%s", err, errOut)
101+
}
102+
var req submitReviewRequest
103+
if err := json.Unmarshal([]byte(capture.body), &req); err != nil {
104+
t.Fatalf("decode body: %v", err)
105+
}
106+
if req.Body != md {
107+
t.Fatalf("stdin body not forwarded verbatim; got %q want %q", req.Body, md)
108+
}
109+
}
110+
84111
func TestReviewSubmitBodyAndBodyTextIsUsageError(t *testing.T) {
85112
setConfigEnv(t)
86113
_, _, err := executeCLI(t, aliveDeps(),

backend/internal/review/prompt.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,11 @@ Do these steps in order:
2525
2. Post your review on the pull request with `+"`gh`"+`, with inline comments for specific findings:
2626
- If changes are needed, request changes.
2727
- If it is ready, approve it. GitHub does not let you approve a PR you opened; if the approval is rejected because you are the PR author, post the same review as a regular comment instead (a COMMENT-event review whose body states it is an approval).
28-
3. Record the result with AO by passing your full review inline:
28+
3. Record the result with AO. Pass your full review on stdin via a quoted heredoc so quotes, backticks, $, and backslashes in the Markdown are passed through literally and never break the shell or trigger expansion. Keep the closing REVIEW on its own line with no indentation:
2929
30-
ao review submit --session %s --run %s --verdict <approved|changes_requested> --body-text "<your full review as Markdown>"
30+
ao review submit --session %s --run %s --verdict <approved|changes_requested> --body - <<'REVIEW'
31+
<your full review as Markdown>
32+
REVIEW
3133
3234
Only if step 2 genuinely fails on the provider, still run step 3 so the result is recorded.`,
3335
spec.PRURL, spec.TargetSHA, spec.WorkerID, spec.RunID)

0 commit comments

Comments
 (0)