Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions backend/internal/adapters/agent/claudecode/claudecode.go
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (
permissions = cfg.Config.Permissions
}
appendPermissionFlags(&cmd, permissions)
appendToolFlags(&cmd, cfg.AllowedTools, cfg.DisallowedTools)

if model := strings.TrimSpace(cfg.Config.Model); model != "" {
cmd = append(cmd, "--model", model)
Expand Down Expand Up @@ -313,6 +314,20 @@ func appendPermissionFlags(cmd *[]string, permissions ports.PermissionMode) {
}
}

// appendToolFlags emits --allowedTools / --disallowedTools for a tool-scoped
// launch. Each list is joined with commas into one value so rules that contain
// spaces (e.g. "Bash(git diff:*)") are not split into separate tool names.
// Empty lists emit nothing, so an unrestricted launch is unchanged. These rules
// only bite when the launch is off bypassPermissions, which ignores them.
func appendToolFlags(cmd *[]string, allowed, disallowed []string) {
if len(allowed) > 0 {
*cmd = append(*cmd, "--allowedTools", strings.Join(allowed, ","))
}
if len(disallowed) > 0 {
*cmd = append(*cmd, "--disallowedTools", strings.Join(disallowed, ","))
}
}

func normalizePermissionMode(mode ports.PermissionMode) ports.PermissionMode {
switch mode {
case ports.PermissionModeDefault,
Expand Down
32 changes: 32 additions & 0 deletions backend/internal/adapters/agent/claudecode/claudecode_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -580,6 +580,38 @@ func readJSON(t *testing.T, path string) map[string]any {
return m
}

func TestGetLaunchCommandEmitsToolAllowlist(t *testing.T) {
p := &Plugin{resolvedBinary: "claude"}

cmd, err := p.GetLaunchCommand(context.Background(), ports.LaunchConfig{
AllowedTools: []string{"Read", "Grep", "Bash(git diff:*)"},
DisallowedTools: []string{"Edit", "Write", "Bash(git push:*)"},
})
if err != nil {
t.Fatal(err)
}

// Each list is one comma-joined value so a rule with spaces stays intact.
if !containsSubsequence(cmd, []string{"--allowedTools", "Read,Grep,Bash(git diff:*)"}) {
t.Fatalf("missing joined --allowedTools value; got %#v", cmd)
}
if !containsSubsequence(cmd, []string{"--disallowedTools", "Edit,Write,Bash(git push:*)"}) {
t.Fatalf("missing joined --disallowedTools value; got %#v", cmd)
}
}

func TestGetLaunchCommandOmitsToolFlagsWhenUnset(t *testing.T) {
p := &Plugin{resolvedBinary: "claude"}

cmd, err := p.GetLaunchCommand(context.Background(), ports.LaunchConfig{Prompt: "do it"})
if err != nil {
t.Fatal(err)
}
if contains(cmd, "--allowedTools") || contains(cmd, "--disallowedTools") {
t.Fatalf("unrestricted launch should emit no tool flags; got %#v", cmd)
}
}

func contains(values []string, needle string) bool {
for _, v := range values {
if v == needle {
Expand Down
41 changes: 37 additions & 4 deletions backend/internal/adapters/reviewer/claudecode/claudecode.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,37 @@ func (r *Reviewer) Harness() domain.ReviewerHarness {

var _ ports.Reviewer = (*Reviewer)(nil)

// reviewerAllowedTools is the read-only tool allowlist the reviewer launches
// with. The reviewer runs headless (no human to approve prompts) but must stay
// read-only, so instead of bypassPermissions — which skips the permission
// system entirely and ignores allow/deny rules — it launches in the default
// mode where these rules are honored: allow rules auto-approve without
// prompting, so the reviewer can read the checkout and run the few commands it
// needs (git diff/log/show to inspect the PR, gh to post the review, and
// `ao review submit` to record the verdict) without stalling.
var reviewerAllowedTools = []string{
"Read",
"Grep",
"Glob",
"Bash(gh:*)",
"Bash(git diff:*)",
"Bash(git log:*)",
"Bash(git show:*)",
"Bash(git status:*)",
"Bash(ao review submit:*)",
}

// reviewerDisallowedTools hard-denies the write paths as defense in depth, so a
// misbehaving model cannot edit files or move the branch even if a future
// allowlist entry would otherwise admit it.
var reviewerDisallowedTools = []string{
"Edit",
"Write",
"NotebookEdit",
"Bash(git push:*)",
"Bash(git commit:*)",
}

// ReviewCommand builds a claude-code invocation that reviews the worker's
// checkout for the PR, with the review prompt baked in.
func (r *Reviewer) ReviewCommand(ctx context.Context, inv ports.ReviewInvocation) (ports.ReviewCommandSpec, error) {
Expand All @@ -39,10 +70,12 @@ func (r *Reviewer) ReviewCommand(ctx context.Context, inv ports.ReviewInvocation
WorkspacePath: inv.WorkspacePath,
Prompt: inv.Prompt,
SystemPrompt: inv.SystemPrompt,
// The reviewer runs headless with no human to approve tool prompts; it
// is read-only by prompt and must run gh/ao on its own, so bypass the
// permission gate rather than stall on the first prompt.
Permissions: ports.PermissionModeBypassPermissions,
// Launch off bypassPermissions so the allow/deny lists are enforced.
// Set an explicit non-bypass mode instead of deferring to the user's
// Claude defaultMode, which may itself be bypassPermissions.
Permissions: ports.PermissionModeAuto,
AllowedTools: reviewerAllowedTools,

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Permissions is left as the zero value here, which (per GetLaunchCommand) emits no --permission-mode flag and defers to the user's ~/.claude/settings.json defaultMode. If that default is bypassPermissions, this allow/deny list is silently ignored — the exact failure mode #260 wanted closed. Pass an explicit non-bypass mode (e.g. ports.PermissionModeAcceptEdits) instead of relying on ambient config.

DisallowedTools: reviewerDisallowedTools,
})
if err != nil {
return ports.ReviewCommandSpec{}, err
Expand Down
71 changes: 71 additions & 0 deletions backend/internal/adapters/reviewer/claudecode/claudecode_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package claudecode

import (
"context"
"testing"

"github.com/aoagents/agent-orchestrator/backend/internal/ports"
)

// captureAgent is a stub ports.Agent that records the LaunchConfig the reviewer
// builds, so the test asserts the reviewer's tool policy without needing the
// real claude binary on PATH.
type captureAgent struct {
got ports.LaunchConfig
}

func (a *captureAgent) GetConfigSpec(context.Context) (ports.ConfigSpec, error) {
return ports.ConfigSpec{}, nil
}
func (a *captureAgent) GetLaunchCommand(_ context.Context, cfg ports.LaunchConfig) ([]string, error) {
a.got = cfg
return []string{"claude"}, nil
}
func (a *captureAgent) GetPromptDeliveryStrategy(context.Context, ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) {
return ports.PromptDeliveryInCommand, nil
}
func (a *captureAgent) GetAgentHooks(context.Context, ports.WorkspaceHookConfig) error { return nil }
func (a *captureAgent) GetRestoreCommand(context.Context, ports.RestoreConfig) ([]string, bool, error) {
return nil, false, nil
}
func (a *captureAgent) SessionInfo(context.Context, ports.SessionRef) (ports.SessionInfo, bool, error) {
return ports.SessionInfo{}, false, nil
}

func TestReviewCommandLaunchesReadOnlyOffBypass(t *testing.T) {
agent := &captureAgent{}
r := &Reviewer{agent: agent}

if _, err := r.ReviewCommand(context.Background(), ports.ReviewInvocation{
ReviewerID: "review-w1",
WorkspacePath: "/ws/w1",
Prompt: "review it",
SystemPrompt: "you are a reviewer",
}); err != nil {
t.Fatalf("ReviewCommand: %v", err)
}

// The allowlist is what enforces read-only, so it must launch in an
// explicit non-bypass mode: bypassPermissions ignores allow/deny rules
// entirely, and an empty mode would defer to a user's defaultMode.
if agent.got.Permissions != ports.PermissionModeAuto {
t.Fatalf("reviewer must launch in auto permission mode; got %q", agent.got.Permissions)
}
if !contains(agent.got.AllowedTools, "Read") || !contains(agent.got.AllowedTools, "Bash(ao review submit:*)") {
t.Fatalf("allowlist missing read-only review tools: %#v", agent.got.AllowedTools)
}
for _, denied := range []string{"Edit", "Write", "Bash(git push:*)", "Bash(git commit:*)"} {
if !contains(agent.got.DisallowedTools, denied) {
t.Fatalf("disallow list missing %q: %#v", denied, agent.got.DisallowedTools)
}
}
}

func contains(values []string, needle string) bool {
for _, v := range values {
if v == needle {
return true
}
}
return false
}
23 changes: 16 additions & 7 deletions backend/internal/cli/review.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,11 @@ type submitReviewRequest struct {
}

type reviewSubmitOptions struct {
session string
runID string
verdict string
body string
session string
runID string
verdict string
body string
bodyText string
}

func newReviewCommand(ctx *commandContext) *cobra.Command {
Expand All @@ -67,6 +68,7 @@ func newReviewSubmitCommand(ctx *commandContext) *cobra.Command {
cmd.Flags().StringVar(&opts.runID, "run", "", "Review run id (required)")
cmd.Flags().StringVar(&opts.verdict, "verdict", "", "Review verdict: approved or changes_requested (required)")
cmd.Flags().StringVar(&opts.body, "body", "", "Path to a Markdown file with the review body")
cmd.Flags().StringVar(&opts.bodyText, "body-text", "", "Review body as inline Markdown (use instead of --body when no file should be written)")
return cmd
}

Expand All @@ -86,17 +88,24 @@ func (c *commandContext) submitReview(cmd *cobra.Command, args []string, opts re
if verdict == "" {
return usageError{errors.New("usage: --verdict is required (approved or changes_requested)")}
}
path := strings.TrimSpace(opts.body)
if path != "" && opts.bodyText != "" {
return usageError{errors.New("usage: pass either --body or --body-text, not both")}
}
var body string
if path := strings.TrimSpace(opts.body); path != "" {
switch {
case opts.bodyText != "":
body = opts.bodyText
case path != "":
raw, err := os.ReadFile(path)
if err != nil {
return usageError{fmt.Errorf("read body file: %w", err)}
}
body = string(raw)
}
path := "sessions/" + url.PathEscape(session) + "/reviews/submit"
reqPath := "sessions/" + url.PathEscape(session) + "/reviews/submit"
var res reviewRunResponse
if err := c.postJSON(cmd.Context(), path, submitReviewRequest{RunID: runID, Verdict: verdict, Body: body}, &res); err != nil {
if err := c.postJSON(cmd.Context(), reqPath, submitReviewRequest{RunID: runID, Verdict: verdict, Body: body}, &res); err != nil {
return err
}
_, err := fmt.Fprintf(cmd.OutOrStdout(), "recorded %s review for %s\n", res.Review.Verdict, session)
Expand Down
28 changes: 28 additions & 0 deletions backend/internal/cli/review_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,34 @@ func TestReviewSubmitReadsBodyFile(t *testing.T) {
}
}

func TestReviewSubmitReadsInlineBody(t *testing.T) {
cfg := setConfigEnv(t)
srv, capture := reviewServer(t, http.StatusOK, `{"review":{"verdict":"changes_requested"}}`)
writeRunFileFor(t, cfg, srv)

_, errOut, err := executeCLI(t, aliveDeps(),
"review", "submit", "mer-1", "--run", "run-1", "--verdict", "changes_requested", "--body-text", "please fix")
if err != nil {
t.Fatalf("unexpected error: %v\nstderr=%s", err, errOut)
}
var req submitReviewRequest
if err := json.Unmarshal([]byte(capture.body), &req); err != nil {
t.Fatalf("decode body: %v", err)
}
if req.Body != "please fix" {
t.Fatalf("inline body not forwarded; request = %+v", req)
}
}

func TestReviewSubmitBodyAndBodyTextIsUsageError(t *testing.T) {
setConfigEnv(t)
_, _, err := executeCLI(t, aliveDeps(),
"review", "submit", "mer-1", "--run", "run-1", "--verdict", "approved", "--body", "x.md", "--body-text", "y")
if got := ExitCode(err); got != 2 {
t.Fatalf("exit code = %d, want 2 (usage); err=%v", got, err)
}
}

func TestReviewSubmitUsesSessionFlag(t *testing.T) {
cfg := setConfigEnv(t)
srv, capture := reviewServer(t, http.StatusOK, `{"review":{"verdict":"approved"}}`)
Expand Down
18 changes: 13 additions & 5 deletions backend/internal/ports/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,11 +100,19 @@ const (

// LaunchConfig carries inputs needed to build a new agent launch command.
type LaunchConfig struct {
Config AgentConfig
IssueID string
Permissions PermissionMode
Prompt string
SessionID string
Config AgentConfig
IssueID string
Permissions PermissionMode
Prompt string
SessionID string
// AllowedTools and DisallowedTools scope the agent to a tool allowlist when
// it runs in a non-bypass permission mode (allow rules auto-approve, deny
// rules auto-reject). They are the enforced read-only guarantee the reviewer
// relies on: bypassPermissions ignores both lists, so a restricted launch
// must leave Permissions off bypass. Empty means no restriction, so worker
// sessions are unaffected.
AllowedTools []string
DisallowedTools []string
SystemPrompt string
SystemPromptFile string
WorkspacePath string
Expand Down
11 changes: 6 additions & 5 deletions backend/internal/review/prompt.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,15 @@ Post your review on the pull request using the available review tooling (request
prompt = fmt.Sprintf(`Review pull request %s (head commit %s).
Do these steps in order:
1. Post your review on the pull request with `+"`gh`"+`, with inline comments for specific findings:
1. You are read-only: you can read the checkout and run gh, git diff/log/show, and ao. You cannot write or edit files, so do not write a review file.
2. Post your review on the pull request with `+"`gh`"+`, with inline comments for specific findings:
- If changes are needed, request changes.
- 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).
2. Write your full review to review.md and record the result with AO by running exactly:
- 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).
3. Record the result with AO by passing your full review inline:
ao review submit --session %s --run %s --verdict <approved|changes_requested> --body review.md
ao review submit --session %s --run %s --verdict <approved|changes_requested> --body-text "<your full review as Markdown>"
Only if step 1 genuinely fails on the provider, still run step 2 so the result is recorded.`,
Only if step 2 genuinely fails on the provider, still run step 3 so the result is recorded.`,
spec.PRURL, spec.TargetSHA, spec.WorkerID, spec.RunID)
return prompt, systemPrompt
}
28 changes: 26 additions & 2 deletions frontend/src/renderer/components/CenterPane.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,42 @@
import { ChevronLeft, Shield } from "lucide-react";
import type { Theme } from "../stores/ui-store";
import type { TerminalTarget } from "../types/terminal";
import type { WorkspaceSession } from "../types/workspace";
import { TerminalPane } from "./TerminalPane";

type CenterPaneProps = {
session?: WorkspaceSession;
theme: Theme;
daemonReady: boolean;
terminalTarget?: TerminalTarget;
onSelectWorkerTerminal?: () => void;
};

export function CenterPane({ session, theme, daemonReady }: CenterPaneProps) {
export function CenterPane({ session, theme, daemonReady, terminalTarget, onSelectWorkerTerminal }: CenterPaneProps) {
const target = terminalTarget ?? { kind: "worker" };

return (
<div className="flex h-full min-h-0 min-w-0 flex-col bg-background">
{target.kind === "reviewer" ? (
<div className="reviewer-terminal-header">
<button
aria-label="Back to agent terminal"
className="reviewer-terminal-header__back"
onClick={onSelectWorkerTerminal}
type="button"
>
<ChevronLeft aria-hidden="true" />
<span>agent</span>
</button>
<span className="reviewer-terminal-header__role">
<Shield aria-hidden="true" />
Reviewer
</span>
<span className="reviewer-terminal-header__harness">{target.harness}</span>
</div>
) : null}
<div className="min-h-0 flex-1">
<TerminalPane session={session} theme={theme} daemonReady={daemonReady} />
<TerminalPane daemonReady={daemonReady} session={session} terminalTarget={target} theme={theme} />
</div>
</div>
);
Expand Down
Loading
Loading