Skip to content
Draft
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1227,6 +1227,7 @@ Review selection and execution flags:
| `--reviewer-effort <effort>` | Override reviewer-stage effort with `low`, `medium`, `high`, `xhigh`, or `max`, subject to runtime support. Available for dry-run, no-post, and live reviews. |
| `--review-base-sha <sha>` | Review this base commit SHA instead of the PR's current base SHA. Requires `--review-head-sha` and `--dry-run` or `--no-post`. |
| `--review-head-sha <sha>` | Review this head commit SHA instead of the PR's current head SHA. Requires `--review-base-sha` and `--dry-run` or `--no-post`. |
| `--without-discussion` | Replay the pinned review as a first pass. cr reads no review threads, thread outcomes, issue comments, or prior reviews, and neither resumes nor updates the PR's orchestrator session or reviewer cohort sessions, so the discussion is not in any selection, reviewer, or rollup prompt; the PR title, description, and diff still are. Reviewers also get a narrower tool surface: the review checkouts have no git remotes, `claude_cli` reviewers are denied WebFetch, WebSearch, and Bash commands such as `gh`, `curl`, `wget`, and `git fetch`/`pull`/`ls-remote`, and `codex_cli` reviewers run with web search and sandbox network access off. That blocks the obvious lookups but is not a network sandbox, so a reviewer with a shell could still reach the network another way. The run marker (`review-run.json`), dossier discussion artifacts, and `--json` run output record `without_discussion: true`, and such a run never resumes an incomplete run made with discussion (or the reverse). Requires `--review-base-sha`, `--review-head-sha`, and `--dry-run` or `--no-post`; cannot be combined with `--fresh-session`. |
| `--session <name>` | Override the PR's default orchestrator session with a named live-review session. Reviewer cohorts remain PR-scoped. Not allowed with `--dry-run`, `--no-post`, or `--retry-posts`. |

Review progress on stderr reports the merged reviewer catalog, final selected
Expand Down
14 changes: 14 additions & 0 deletions internal/cmd/reviewcmd/reviewcmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ type commandFlags struct {
reviewerEffort string
reviewBaseSHA string
reviewHeadSHA string
withoutDiscussion bool
maxAgents int
maxConcurrency int
allowSelfReview bool
Expand Down Expand Up @@ -124,6 +125,7 @@ func RegisterWithFactory(rootCmd *cobra.Command, opts *root.Options, factory Run
cmd.Flags().StringVar(&flags.reviewerEffort, "reviewer-effort", "", "Override reviewer effort for this review")
cmd.Flags().StringVar(&flags.reviewBaseSHA, "review-base-sha", "", "Review this base commit SHA instead of the PR's current base SHA; requires --dry-run and --review-head-sha")
cmd.Flags().StringVar(&flags.reviewHeadSHA, "review-head-sha", "", "Review this head commit SHA instead of the PR's current head SHA; requires --dry-run and --review-base-sha")
cmd.Flags().BoolVar(&flags.withoutDiscussion, "without-discussion", false, "Replay the pinned review as a first pass, without the PR's existing discussion or review sessions, and deny reviewers common network tools; requires --dry-run, --review-base-sha, and --review-head-sha")
cmd.Flags().IntVar(&flags.maxAgents, "max-agents", 0, "Maximum selected reviewer agents")
cmd.Flags().IntVar(&flags.maxConcurrency, "max-concurrency", 0, "Maximum concurrent reviewer agents")
cmd.Flags().BoolVar(&flags.allowSelfReview, "allow-self-review", false, "Allow reviewer credentials matching the PR author")
Expand Down Expand Up @@ -202,6 +204,14 @@ func runReview(ctx context.Context, cmd *cobra.Command, opts *root.Options, fact
return exitcode.Usage(fmt.Errorf("--review-base-sha and --review-head-sha require --dry-run or --no-post"))
}
}
if flags.withoutDiscussion {
if !flags.dryRun {
return exitcode.Usage(fmt.Errorf("--without-discussion requires --dry-run or --no-post"))
}
if !reviewBaseChanged {
return exitcode.Usage(fmt.Errorf("--without-discussion requires --review-base-sha and --review-head-sha"))
}
}
if flags.rerun && flags.retryPosts {
return exitcode.Usage(fmt.Errorf("--rerun and --retry-posts are mutually exclusive"))
}
Expand All @@ -212,6 +222,9 @@ func runReview(ctx context.Context, cmd *cobra.Command, opts *root.Options, fact
if sessionName != "" && flags.retryPosts {
return exitcode.Usage(fmt.Errorf("--session cannot be used with --retry-posts"))
}
if flags.freshSession && flags.withoutDiscussion {
return exitcode.Usage(fmt.Errorf("--fresh-session cannot be used with --without-discussion, which never reuses or resets PR sessions"))
}
if flags.freshSession && flags.retryPosts {
return exitcode.Usage(fmt.Errorf("--fresh-session cannot be used with --retry-posts"))
}
Expand Down Expand Up @@ -348,6 +361,7 @@ func runReview(ctx context.Context, cmd *cobra.Command, opts *root.Options, fact
ReviewerFast: reviewerFast,
ReviewBaseSHA: reviewBaseSHA,
ReviewHeadSHA: reviewHeadSHA,
WithoutDiscussion: flags.withoutDiscussion,
Rerun: flags.rerun,
FreshSession: flags.freshSession,
ToolVersion: version.Version,
Expand Down
65 changes: 65 additions & 0 deletions internal/cmd/reviewcmd/reviewcmd_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -572,6 +572,71 @@ func TestReviewRejectsInvalidReviewSHAOverrides(t *testing.T) {
}
}

func TestReviewDryRunPassesWithoutDiscussion(t *testing.T) {
for _, mode := range []string{"--dry-run", "--no-post"} {
t.Run(mode, func(t *testing.T) {
runner := &fakeRunner{result: testPipelineResult(false)}
cmd, _ := newTestCommand(t, testConfig(), fakeFactory(runner))

err := root.Execute(cmd, []string{
"review", "https://github.com/open-cli-collective/codereview-cli/pull/29",
mode,
"--review-base-sha", "1111111",
"--review-head-sha", "2222222",
"--without-discussion",
})
if err != nil {
t.Fatalf("Execute: %v", err)
}
if len(runner.requests) != 1 {
t.Fatalf("runner calls = %d, want 1", len(runner.requests))
}
if !runner.requests[0].WithoutDiscussion {
t.Fatal("WithoutDiscussion = false, want true")
}
})
}
}

func TestReviewRejectsWithoutDiscussionOutsidePinnedDryRun(t *testing.T) {
tests := []struct {
name string
args []string
wantErr string
}{
{name: "live with SHAs", args: []string{"--review-base-sha", "1111111", "--review-head-sha", "2222222", "--without-discussion"}, wantErr: "require --dry-run or --no-post"},
{name: "live without SHAs", args: []string{"--without-discussion"}, wantErr: "--without-discussion requires --dry-run or --no-post"},
{name: "dry run without SHAs", args: []string{"--dry-run", "--without-discussion"}, wantErr: "--without-discussion requires --review-base-sha and --review-head-sha"},
{name: "no post without SHAs", args: []string{"--no-post", "--without-discussion"}, wantErr: "--without-discussion requires --review-base-sha and --review-head-sha"},
{name: "dry run with base only", args: []string{"--dry-run", "--review-base-sha", "1111111", "--without-discussion"}, wantErr: "must be set together"},
{name: "fresh session", args: []string{"--dry-run", "--review-base-sha", "1111111", "--review-head-sha", "2222222", "--without-discussion", "--fresh-session"}, wantErr: "--fresh-session cannot be used with --without-discussion"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var factoryCalled bool
cmd, _ := newTestCommand(t, testConfig(), func(context.Context, app.OpenRequest) (app.Runtime, error) {
factoryCalled = true
return app.Runtime{Runner: &fakeRunner{result: testPipelineResult(false), liveResult: testLiveResult(false)}}, nil
})

args := append([]string{"review", "https://github.com/open-cli-collective/codereview-cli/pull/29"}, tt.args...)
err := root.Execute(cmd, args)
if err == nil {
t.Fatal("Execute error = nil, want usage error")
}
if got := exitcode.FromError(err); got != exitcode.UsageError {
t.Fatalf("exit code = %d, want usage", got)
}
if !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("error = %q, want %q", err.Error(), tt.wantErr)
}
if factoryCalled {
t.Fatal("runtime factory was called for invalid --without-discussion use")
}
})
}
}

func TestReviewLiveRejectsStageOverridesBeforeRuntimeFactory(t *testing.T) {
tests := []struct {
name string
Expand Down
27 changes: 20 additions & 7 deletions internal/dossier/dossier.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,12 @@ type ChangedFile struct {

// Inputs contains the source material written to raw dossier artifacts.
type Inputs struct {
CurrentPR gitprovider.PR
ReviewPR gitprovider.PR
PinnedReview bool
CurrentPR gitprovider.PR
ReviewPR gitprovider.PR
PinnedReview bool
// WithoutDiscussion drops every discussion input, even if a caller
// supplied some, and records that the review ran without discussion.
WithoutDiscussion bool
ChangedFiles []ChangedFile
Threads []gitprovider.InlineThread
ThreadContext []threadcontext.Thread
Expand Down Expand Up @@ -128,6 +131,7 @@ type dossierPRContextArtifact struct {

type dossierDiscussionArtifact struct {
PinnedReview bool `json:"pinned_review"`
WithoutDiscussion bool `json:"without_discussion,omitempty"`
DiscussionOmittedNote string `json:"discussion_omitted_note,omitempty"`
TopLevelComments []dossierTopLevelCommentArtifact `json:"top_level_comments,omitempty"`
InlineThreads []dossierInlineThreadArtifact `json:"inline_threads,omitempty"`
Expand All @@ -138,6 +142,7 @@ type DiscussionSummary struct {
SchemaVersion int `json:"schema_version"`
SourceFingerprint string `json:"source_fingerprint,omitempty"`
PinnedReview bool `json:"pinned_review"`
WithoutDiscussion bool `json:"without_discussion,omitempty"`
DiscussionOmittedNote string `json:"discussion_omitted_note,omitempty"`
TopLevelOmitted int `json:"top_level_comments_omitted,omitempty"`
InlineThreadsOmitted int `json:"inline_threads_omitted,omitempty"`
Expand Down Expand Up @@ -286,6 +291,12 @@ func WriteRaw(paths runartifact.Paths, in Inputs) error {
return fmt.Errorf("pipeline: create dossier dir: %w", err)
}
}
if in.WithoutDiscussion {
in.Threads = nil
in.ThreadContext = nil
in.Reviews = nil
in.IssueComments = nil
}

prContext := dossierPRContextArtifact{
Title: in.CurrentPR.Title,
Expand Down Expand Up @@ -314,9 +325,10 @@ func WriteRaw(paths runartifact.Paths, in Inputs) error {
}
discussion := dossierDiscussionArtifact{
PinnedReview: in.PinnedReview,
WithoutDiscussion: in.WithoutDiscussion,
DiscussionOmittedNote: strings.TrimSpace(in.DiscussionOmittedNote),
}
if !in.PinnedReview {
if !in.PinnedReview && !in.WithoutDiscussion {
discussion.TopLevelComments = topLevelComments
discussion.InlineThreads = inlineThreads
}
Expand Down Expand Up @@ -395,10 +407,11 @@ func ReadDiscussionSummary(paths runartifact.Paths) (DiscussionSummary, error) {
}

func summarizeDiscussionArtifacts(ctx context.Context, env Env, req PreparationRequest, discussion dossierDiscussionArtifact) (DiscussionSummary, error) {
if discussion.PinnedReview {
if discussion.PinnedReview || discussion.WithoutDiscussion {
return DiscussionSummary{
SchemaVersion: dossierSummarySchemaVersion,
PinnedReview: true,
PinnedReview: discussion.PinnedReview,
WithoutDiscussion: discussion.WithoutDiscussion,
DiscussionOmittedNote: strings.TrimSpace(discussion.DiscussionOmittedNote),
}, nil
}
Expand Down Expand Up @@ -797,7 +810,7 @@ func renderDossierDiscussionSummaryMarkdown(summary DiscussionSummary, title str
var out strings.Builder
out.WriteString(title)
out.WriteString("\n\n")
if summary.PinnedReview {
if summary.PinnedReview || summary.WithoutDiscussion {
note := strings.TrimSpace(summary.DiscussionOmittedNote)
if note == "" {
note = "Current PR discussion omitted for pinned review."
Expand Down
4 changes: 4 additions & 0 deletions internal/llm/adapter.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@ type ReviewerWorkspaceRequest struct {
Env []string
AllowedFiles []string
MaxToolOutputBytes int
// NoNetwork asks the adapter to deny reviewer tools that reach the network
// (git hosts, web fetch, web search), so a reviewer cannot look up live PR
// state such as discussion. It narrows the tool surface; it is not a sandbox.
NoNetwork bool
}

// ErrReviewerWorkspaceUnsupported reports that an adapter cannot use a prepared
Expand Down
76 changes: 76 additions & 0 deletions internal/llmadapters/subprocess.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"os/exec"
"path/filepath"
"regexp"
"slices"
"strconv"
"strings"
"sync"
Expand Down Expand Up @@ -345,6 +346,7 @@ func buildClaudeForegroundArgs(req Request, scratch string, resumeSessionID stri
if workspace := req.ReviewerWorkspace; workspace != nil {
args = append(args, "--add-dir", workspace.RepoDir)
}
args = appendClaudeNoNetworkArgs(args, req)
if resumeSessionID != "" {
args = append(args, "--resume", resumeSessionID)
}
Expand All @@ -360,6 +362,33 @@ func buildClaudeForegroundArgs(req Request, scratch string, resumeSessionID stri
return append(args, "--", claudeBGPositionalPrompt(scratch))
}

// claudeNoNetworkDisallowedTools denies the ways a Claude reviewer could look
// up live PR state from its workspace: web tools, the GitHub CLI, HTTP
// clients, and git commands that talk to a remote. Bash can still reach the
// network by other means, so this narrows the tool surface; it is not a sandbox.
var claudeNoNetworkDisallowedTools = strings.Join([]string{
"WebFetch",
"WebSearch",
"Bash(gh *)",
"Bash(curl *)",
"Bash(wget *)",
"Bash(git fetch *)",
"Bash(git pull *)",
"Bash(git ls-remote *)",
"Bash(git clone *)",
"Bash(git remote *)",
"Bash(git -C * fetch *)",
"Bash(git -C * pull *)",
"Bash(git -C * ls-remote *)",
}, ",")

func appendClaudeNoNetworkArgs(args []string, req Request) []string {
if req.ReviewerWorkspace == nil || !req.ReviewerWorkspace.NoNetwork {
return args
}
return append(args, "--disallowedTools", claudeNoNetworkDisallowedTools)
}

// startClaude picks the transport for a Claude task. Background mode is the
// default and foreground is the documented-sturdier fallback, so a background
// job that fails without producing a result is retried once in foreground
Expand Down Expand Up @@ -774,6 +803,7 @@ func (a *SubprocessAdapter) buildArgsForSession(req Request, scratch string, res
if workspace := req.ReviewerWorkspace; workspace != nil {
args = append(args, "--add-dir", workspace.RepoDir)
}
args = appendClaudeNoNetworkArgs(args, req)
if resumeSessionID != "" {
args = append(args, "--resume", resumeSessionID)
}
Expand Down Expand Up @@ -803,6 +833,7 @@ func (a *SubprocessAdapter) buildArgsForSession(req Request, scratch string, res
if req.Fast {
args = append(args, "-c", `service_tier="fast"`)
}
args = appendCodexNoNetworkArgs(args, req)
if resumeSessionID != "" {
args = subprocessCodexResumeArgs()
if req.Model != "" {
Expand All @@ -814,6 +845,7 @@ func (a *SubprocessAdapter) buildArgsForSession(req Request, scratch string, res
if req.Fast {
args = append(args, "-c", `service_tier="fast"`)
}
args = appendCodexNoNetworkArgs(args, req)
args = append(args, resumeSessionID)
}
return append(args, "--", req.Prompt), nil
Expand All @@ -822,6 +854,24 @@ func (a *SubprocessAdapter) buildArgsForSession(req Request, scratch string, res
}
}

// codexNoNetworkConfig turns off Codex web search (on by default, in cached
// mode) and pins the workspace-write sandbox's network access off, so a
// reviewer cannot look up live PR state.
var codexNoNetworkConfig = []string{
`web_search="disabled"`,
"sandbox_workspace_write.network_access=false",
}

func appendCodexNoNetworkArgs(args []string, req Request) []string {
if req.ReviewerWorkspace == nil || !req.ReviewerWorkspace.NoNetwork {
return args
}
for _, value := range codexNoNetworkConfig {
args = append(args, "-c", value)
}
return args
}

func subprocessCodexBaseArgs(prefix []string, sandbox, cwd string, durable bool) []string {
args := append([]string(nil), prefix...)
args = append(args, "--json")
Expand Down Expand Up @@ -874,9 +924,13 @@ func (a *SubprocessAdapter) validateArgs(args []string, scratch string, req Requ
"--model": true,
"--effort": true,
"--settings": true,
"--disallowedTools": true,
}); err != nil {
return err
}
if err := validateClaudeNoNetworkArgs(checkedArgs, req); err != nil {
return err
}
// Exactly one transport: the detached job service (--bg) or headless
// foreground print mode (-p, see claudeForegroundEnabled).
if containsFlag(checkedArgs, "--bg") == containsFlag(checkedArgs, "-p") {
Expand Down Expand Up @@ -950,6 +1004,28 @@ func (a *SubprocessAdapter) validateArgs(args []string, scratch string, req Requ
return fmt.Errorf("%w: missing %s", ErrUnsafeSubprocessConfig, flag)
}
}
if workspace != nil && workspace.NoNetwork {
configs := flagValues(checkedArgs, "-c")
for _, want := range codexNoNetworkConfig {
if !slices.Contains(configs, want) {
return fmt.Errorf("%w: codex_cli offline reviewer must pass -c %s", ErrUnsafeSubprocessConfig, want)
}
}
}
}
return nil
}

func validateClaudeNoNetworkArgs(args []string, req Request) error {
got, ok := flagValueOK(args, "--disallowedTools")
if req.ReviewerWorkspace != nil && req.ReviewerWorkspace.NoNetwork {
if !ok || got != claudeNoNetworkDisallowedTools {
return fmt.Errorf("%w: claude_cli offline reviewer must deny network tools", ErrUnsafeSubprocessConfig)
}
return nil
}
if ok {
return fmt.Errorf("%w: claude_cli must not pass --disallowedTools without an offline reviewer workspace", ErrUnsafeSubprocessConfig)
}
return nil
}
Expand Down
Loading
Loading