Adversarial review surfaced 9 critiques: 7 patched, 1 resolved by spike, 1 deferred. Most damaging: --comment/--dry-run flag inversion and fabricated dedup SHA-trailer mechanism — both indicated the change was written against the dep change's design rather than the shipped skill artifact. Artifacts: - new design.md with 12 decisions (D2 no-PR-head-checkout, D6 dedup, D7 ephemeral token, D9 dist split, D10 failure matrix, D11 skill invocation prompt, D12 --allowedTools whitelist) and Risks - new battle-test.md with critique inventory, dispositions, patches - proposal.md: corrected cost range, flag, --allowedTools detail - spec.md: 9 patches — split copy-paste vs uses: paths, no PR-head checkout, dedup contract, allowed-tools restriction, max_turns soft cap, PR# pass requirement C4 spike (2026-05-13) validated D2+D7 on Codeberg Forgejo v11.x. Ephemeral GITEA_TOKEN + permissions: pull-requests: write permits create_pull_review on fork PR; pull_request_target executes BASE workflow file (display label pull_request is cosmetic). Spike artifacts retained for reproducibility. C5 spike (cross-repo workflow_call) scaffolded but deferred; tracked in forgejo-mcp-33k. Cleanup of Codeberg test repos tracked in forgejo-mcp-p0y.
147 lines
20 KiB
Markdown
147 lines
20 KiB
Markdown
# Battle Test — forgejo-action-code-review
|
|
|
|
**Date:** 2026-05-12
|
|
**Targets:** proposal.md, design.md, specs/action-code-review/spec.md
|
|
**Team:** adversary (devils-advocate, opus), defender (proponent, opus)
|
|
|
|
## Verdict
|
|
|
|
**Patches applied; C4 spike resolved; C5 spike deferred. Proceed to tasks.md.**
|
|
|
|
Of 9 critiques: 7 patched, 1 resolved by spike (C4), 1 deferred via spec split (C5 — Path A copy-paste is supported; Path B cross-repo `uses:` is recommended-when-supported and tracked in bd for follow-up).
|
|
|
|
The change was written against the dep change's **design**, not its **shipped artifact**. The `/code-review` skill drifted from its archived design before this action layered on top, and this change inherited the drift. The single most damaging finding (C1): the action invoked the skill with `--comment` to enable posting; the shipped skill posts by **default** and accepts `--dry-run` to suppress. Every action artifact referenced the wrong flag. C2 was similarly load-bearing: the D6 dedup design assumed the skill embeds head-SHA in review bodies — it does not. Both fixed by patches and a follow-up tasks.md entry to amend skill Step 12 body templates.
|
|
|
|
C4 spike (2026-05-13) validated D2 (BASE workflow file executes under `pull_request_target` on fork PRs; Forgejo's display label `pull_request` is cosmetic, not semantic) and D7 (ephemeral `GITEA_TOKEN` + `permissions: pull-requests: write` permits `create_pull_review` on fork PRs on Codeberg v11.x). Spike artifacts retained under `openspec/changes/forgejo-action-code-review/spikes/c4-ephemeral-token-fork-pr/` for reproducibility.
|
|
|
|
## Surviving Critiques
|
|
|
|
### C1. `--comment` flag does not exist; posting is the skill's default
|
|
|
|
**Severity:** blocker
|
|
**Failure mode:** workflow passes `--comment` to a skill that does not recognize it; or omits it and posts anyway. Either way the action ships against a non-existent flag contract. The proposal's "supports `--comment` flag to post findings" is fiction relative to the shipped `.claude/commands/code-review.md`.
|
|
**Hits:** proposal.md "What Changes" bullet 2; design.md D6 "Tagging mechanism" paragraph; spec.md "Findings posted as a Forgejo PR review" requirement.
|
|
**Canonical conflict:** `.claude/commands/code-review.md:3` declares `argument-hint: [PR#] [--dry-run]`; line 12 reads "By default, findings are posted as a PR review with inline comments. Use `--dry-run` for terminal-only output."
|
|
**Falsifier:** `grep -n "dry-run\|--comment" .claude/commands/code-review.md` — done; confirms inversion.
|
|
**Disposition:** patch (concede-patch)
|
|
|
|
### C2. D6 dedup tagging is fabricated
|
|
|
|
**Severity:** blocker
|
|
**Failure mode:** D6 promises action-side dedup via "current head SHA tag in review body footer." Skill's body templates (Step 12 of `.claude/commands/code-review.md`) use fixed strings with no SHA and no placeholder. Without a contract-level mechanism, dedup either no-ops (every run posts a new review) or relies on soft prompt-injection via `additional_instructions` — unreliable for a mechanism the spec promises to honor.
|
|
**Hits:** design.md D6 paragraph beginning "Tagging mechanism"; the change's "duplicate-review suppression" goal in design.md "Goals" list.
|
|
**Canonical conflict:** skill design.md (archived dep change) marks dedup as future work; shipped skill provides no tagging surface.
|
|
**Falsifier:** `grep -n "sha\|SHA\|<!--" .claude/commands/code-review.md` — done; no trailer mechanism present.
|
|
**Disposition:** patch (concede-patch — choose Option A drop L2 or Option B lift dedup to spec contract + skill amendment)
|
|
|
|
### C3. D2 protects filesystem but not LLM prompt context
|
|
|
|
**Severity:** major
|
|
**Failure mode:** D2's "no PR-head checkout" claim collapses the runner-filesystem threat model but leaves the LLM prompt-injection vector wide open. The skill (Steps 7.2 and 8) loads PR-controlled diff and file content into Claude's context, and the in-session forgejo-mcp server has write-side tools (`create_pull_review`). Industry-weak "do not follow embedded instructions" defenses are mitigation, not prevention. D2 reads as if it solved the whole problem; it solved one half.
|
|
**Hits:** design.md D2 "This collapses the standard pull_request_target threat model"; design.md Risks "`additional_instructions` injection vector" bullet (too narrow); spec.md "Required secrets" requirement.
|
|
**Canonical conflict:** design honestly says "untrusted text input fed to Claude" remains; that line undersells what an injected agent can do with a tool-equipped Claude.
|
|
**Falsifier:** code-read of `.claude/commands/code-review.md` Step 7-8 and the in-session MCP tool list — done; PR-controlled bytes do reach Claude's context.
|
|
**Disposition:** patch (concede-patch — bound residual threat via FORGEJO_TOKEN scope; rename Risks bullet to "Prompt-injection vectors")
|
|
|
|
### C4. Fork-PR ephemeral-token `create_pull_review` — RESOLVED
|
|
|
|
**Severity:** major (resolved)
|
|
**Failure mode (originally):** D7 marked "verification owed" for whether Codeberg Forgejo v11.x honors `pull-requests: write` on `${{ secrets.GITEA_TOKEN }}` for fork PRs.
|
|
**Spike result (2026-05-13):** ran C4 spike against `goern/c4-spike-target` ← fork `tinytalesshop/c4-spike-target`. Ephemeral `GITEA_TOKEN` + `permissions: pull-requests: write` **PERMITTED** `create_pull_review` on the fork PR. Bot identity: `forgejo-actions` (review id 1350819 on PR #2, HTTP 200). Follow-up test: pushed a modified workflow to the fork's `spike-pr` branch with a distinctive body string; the BASE workflow body was posted, **not** the fork's modified body. Conclusion: Forgejo Actions honors `pull_request_target` semantics (BASE workflow file executes with secrets), even though the displayed event label is `pull_request` (cosmetic UI artifact). Both D2 and D7 validated.
|
|
**Disposition:** **defend / verified by spike.** No design changes needed. Spike artifacts retained under `openspec/changes/forgejo-action-code-review/spikes/c4-ephemeral-token-fork-pr/` for reproducibility.
|
|
|
|
### C5. Cross-repo `workflow_call` semantics on Forgejo — DEFERRED
|
|
|
|
**Severity:** major
|
|
**Failure mode:** D9 mandates `uses: goern/forgejo-mcp/.forgejo/workflows/claude-code-review.yml@<tag>` as distribution. Forgejo Actions' `workflow_call` cross-repo semantics, `secrets: inherit` behavior, and tag resolution are not GitHub-equivalent.
|
|
**Hits:** design.md D9; spec.md "Reusable Forgejo Actions workflow definition" Scenarios.
|
|
**Canonical conflict:** none directly; Forgejo Actions docs not loaded into repo.
|
|
**Falsifier (scaffolded but not executed):** spike scaffolding lives at `openspec/changes/forgejo-action-code-review/spikes/c5-cross-repo-workflow-call/`. Library + consumer repos created on Codeberg (`goern/c5-spike-lib`, `goern/c5-spike-consumer`). Tag, secrets, and trigger commit not yet applied.
|
|
**Disposition:** **deferred to follow-up bd issue.** Spec patch already applied (copy-paste vs. `uses:` paths split into two scenarios). D9 documents Path A (copy-paste) as the certain path and Path B (cross-repo `uses:`) as recommended-when-supported. Implementation can proceed on Path A; C5 spike result will determine whether Path B is documented as supported or marked experimental.
|
|
|
|
### C6. `max_turns` trip kills posting; "submit partial review" unenforceable
|
|
|
|
**Severity:** major
|
|
**Failure mode:** spec promises "the workflow SHALL still attempt to submit the partial review (if any findings exist) before exiting" when `max_turns` is reached. Step 12 (the post step) is the last skill pipeline step. `--max-turns` typically trips mid-pipeline (Steps 8-11). Claude Code has no "post on the way out" hook. Spec contract is unenforceable.
|
|
**Hits:** spec.md "Cost cap via max-turns" Scenario "max_turns reached"; design.md D10 "max_turns exhausted" row.
|
|
**Canonical conflict:** `.claude/commands/code-review.md` step ordering puts posting last; Claude Code CLI has no exit hook.
|
|
**Falsifier:** test run with `--max-turns 5` on a non-trivial PR — done by code-read of skill pipeline; trip occurs before Step 12.
|
|
**Disposition:** patch (concede-patch — rewrite scenario to record `max_turns_exhausted` status in step summary, accept zero-finding outcome, recommend `max_turns: 25` floor)
|
|
|
|
### C7. `--max-turns` is soft cost cap; proposal's "$0.02-0.15" understates
|
|
|
|
**Severity:** major
|
|
**Failure mode:** proposal claims `--max-turns` is "a hard cap" on cost; spec says "hard upper bound on Claude Code execution." `--max-turns` bounds main-loop turn count, not subagent token spend. Three parallel Opus subagents per main-loop turn can each consume large contexts. Worst-case cost on a large PR is closer to $0.50-$5.00 than $0.02-$0.15. Marketing copy must match implementation.
|
|
**Hits:** proposal.md "Cost implications"; spec.md "Cost cap via max-turns"; design.md Risks "Cost runaway despite max_turns" (which already admits soft cap — contradicting spec).
|
|
**Canonical conflict:** Claude Code's documented `--max-turns` semantics + dep change design's tiered subagent model.
|
|
**Falsifier:** single test run on a 500-line PR with `cost_usd` reported via `--output-format json` — confirms or refutes range.
|
|
**Disposition:** patch (concede-patch — rewrite to "soft cap on main-loop turns"; surface `cost_usd` in step summary; recalibrate proposal cost range)
|
|
|
|
### C8. Action must pass PR# explicitly; base-only checkout breaks auto-detect
|
|
|
|
**Severity:** major
|
|
**Failure mode:** skill Step 3 auto-detects PR via `git branch --show-current` when PR# not passed. With D2's base-only checkout, current branch is the base branch — auto-detect either fails or matches the wrong PR (e.g., a PR landing into base). Spec scenario says "against the PR identified by the workflow event context" without specifying mechanism.
|
|
**Hits:** spec.md "Non-interactive Claude Code invocation" Scenario "/code-review skill is the entry point".
|
|
**Canonical conflict:** `.claude/commands/code-review.md` Step 3 (auto-detect logic) + D2's base-only checkout decision.
|
|
**Falsifier:** code-read of skill Step 3 + workflow checkout strategy — done; confirms mismatch.
|
|
**Disposition:** patch (concede-patch — invocation prompt MUST be literal `/code-review ${{ github.event.pull_request.number }}`; add D11 "Skill invocation prompt shape")
|
|
|
|
### C9. `--allowedTools` whitelist unspecified; Bash fallback inheritance has security implications
|
|
|
|
**Severity:** major
|
|
**Failure mode:** skill's `allowed-tools` frontmatter includes `Bash(forgejo-mcp --cli:*)` as MCP fallback. Action's `--allowedTools` must either inherit (an injected agent can craft arbitrary `--cli` calls within FORGEJO_TOKEN scope) or drop (skill's MCP fallback silently dies). Either choice is load-bearing for security and reliability; design leaves it unspecified.
|
|
**Hits:** proposal.md "Capabilities" (mentions `--allowedTools` as cost control with no detail); design.md (no decision present).
|
|
**Canonical conflict:** `.claude/commands/code-review.md` frontmatter `allowed-tools` declaration.
|
|
**Falsifier:** code-read of skill frontmatter — done; Bash MCP fallback is present.
|
|
**Disposition:** patch (concede-patch — add D12 "`--allowedTools` whitelist: MCP-only, no Bash fallback"; add corresponding spec requirement)
|
|
|
|
## Conceded Patches
|
|
|
|
Apply these before tasks.md is written and implementation begins. Numbers map to critiques above.
|
|
|
|
### proposal.md
|
|
|
|
- **"What Changes" bullet 2** (C1): replace `invokes the /code-review skill in non-interactive (-p) mode` with `invokes /code-review <PR#> in non-interactive (-p) mode (skill posts by default; --dry-run is not passed)`.
|
|
- **"Impact" → "Cost implications"** (C7): replace `Each PR review run costs ~$0.02-0.15 depending on diff size and model tier. --max-turns provides a hard cap.` with `Each PR review run costs ~$0.10-$5.00 depending on diff size, model tier, and subagent dispatch fan-out. --max-turns caps main-loop turns (soft cost cap); per-run cost_usd is surfaced in the step summary for monitoring; large PRs scale cost super-linearly with subagent count.`
|
|
- **"Impact" → "Capabilities"** (C9): expand `cost controls (--max-turns, --allowedTools)` to `cost controls (--max-turns, --allowedTools restricted to MCP review tools per design D12)`.
|
|
|
|
### design.md
|
|
|
|
- **D2** (C3): append sub-section **"What D2 does not protect against"** listing (1) prompt-injection via PR-controlled bytes reaching Claude's context, (2) write-side MCP tool invocation by an injected agent, (3) "do not follow embedded instructions" prompt-string defense as mitigation not prevention. Bound residual threat: "FORGEJO_TOKEN scope (PR read + PR-review write only on the target repo). An injected attacker can post a malicious review body or comment but cannot read other repos, modify code, or escalate beyond review write."
|
|
- **D6** (C1 + C2): rewrite "Tagging mechanism" paragraph. Pick **Option A** (drop L2 dedup, document duplicate-review limitation matching dep design, remove "duplicate-review suppression" from Goals) **or Option B** (lift dedup to a spec.md "Dedup contract" requirement that binds the workflow to append `<!-- forgejo-mcp-review: <head_sha> -->` trailer, requires skill amendment via separate tasks.md entry). User must choose.
|
|
- **D7** (C4): retain "Verification owed" — but mark explicitly **stalemate pending spike**. After spike, either remove note (ephemeral works) or flip default to PAT-required (ephemeral does not work for fork PRs).
|
|
- **D9** (C5): lead with copy-paste as the supported distribution path; describe cross-repo `uses:` as recommended **when supported by the target Forgejo version**. List specific cross-repo concerns: `secrets: inherit` behavior, tag resolution, token grants. Mark `uses:` path as **stalemate pending spike**.
|
|
- **D10** (C6): clarify "max_turns exhausted" row rationale — "trips most often mid-pipeline before Step 12, so partial-review-not-posted is the expected outcome. Surface `max_turns_exhausted` in step summary so consumers raise the limit. Recommended floor: 25." Add new failure row: `| inconclusive run (max_turns trip before posting) | review | exit 0 | step summary records max_turns_exhausted with zero findings |`.
|
|
- **Risks → "`additional_instructions` injection vector" bullet** (C3): rename to **"Prompt-injection vectors"**. Expand to note every PR-controlled byte (diff, file contents, PR title/body) reaches Claude's context. Mitigation: scope FORGEJO_TOKEN to PR read + review-write on the single target repo, no org-wide scope.
|
|
- **Risks → "Cost runaway despite max_turns" bullet** (C7): strengthen — recommend `max_turns: 20` ceiling and document worst-case cost range `$0.50-$5.00 per large-PR review`.
|
|
- **New decision D11** (C8): **"Skill invocation prompt shape"** — invocation is the literal string `/code-review ${{ github.event.pull_request.number }}`. PR# from event context. No `--dry-run`. Skill's branch-based auto-detection bypassed because base-only checkout makes it unreliable.
|
|
- **New decision D12** (C9): **"`--allowedTools` whitelist: MCP-only, no Bash fallback"** — enumerate exact allowed tools (`mcp__codeberg__get_pull_request_by_index`, `mcp__codeberg__list_pull_request_files`, `mcp__codeberg__get_pull_request_diff`, `mcp__codeberg__get_file_content`, `mcp__codeberg__list_pull_reviews`, `mcp__codeberg__create_pull_review`, `Task`). No `Bash(...)`, no `Read`, no `AskUserQuestion`. Trade-off: brittleness (no fallback) for tighter blast-radius (no shell escape from prompt injection). Bounded further by FORGEJO_TOKEN scope.
|
|
|
|
### spec.md
|
|
|
|
- **"Findings posted as a Forgejo PR review"** (C1): add sentence `The workflow MUST invoke the skill without the --dry-run flag.`
|
|
- **"Required secrets"** (C3): add scenario `Scenario: FORGEJO_TOKEN broader than PR read + PR-review write` → `WHEN the workflow runs with a FORGEJO_TOKEN whose scope exceeds PR read + PR-review write on the target repository THEN preflight SHALL fail with a clear error.`
|
|
- **"Reusable Forgejo Actions workflow definition"** (C5): split the existing scenario into two — `Scenario: PR opened on a repository that copies the workflow file` (the supported path) and `Scenario: PR opened on a repository that references via uses:` (gated on `workflow_call` support, marked uncertain until spike).
|
|
- **"Cost cap via max-turns"** (C6 + C7): rewrite first sentence to `The workflow SHALL impose a soft upper bound on Claude Code's main-loop turn count via the --max-turns flag. The cap bounds the number of subagent dispatches, not the token spend within each subagent.` Add requirement `The workflow SHALL surface per-run cost_usd from --output-format json in the step summary.` Rewrite Scenario "max_turns reached" to `WHEN Claude Code consumes its configured max_turns budget without finishing organically THEN Claude Code SHALL terminate AND the workflow step SHALL exit zero AND the step summary SHALL record a max_turns_exhausted status with whatever findings were posted (which may be none).` Drop the unenforceable "SHALL still attempt to submit."
|
|
- **"Non-interactive Claude Code invocation" → Scenario "/code-review skill is the entry point"** (C8): replace `against the PR identified by the workflow event context` with `the prompt SHALL be the literal string "/code-review ${{ github.event.pull_request.number }}". The workflow MUST NOT rely on the skill's branch-based auto-detection.`
|
|
- **New requirement** (C2 Option B only): "Dedup contract" — `The workflow SHALL append a machine-parseable trailer of the form <!-- forgejo-mcp-review: <head_sha> --> to every posted review body. The workflow's pre-flight step SHALL parse trailers from list_pull_reviews results and skip if the current head SHA matches an existing bot review's trailer.` Plus a tasks.md entry to amend skill Step 12 templates.
|
|
- **New requirement** (C9): "Allowed tools" — `The workflow SHALL invoke Claude Code with --allowedTools restricted to the MCP tools enumerated in design D12. The list MUST NOT include any Bash(...) entries.`
|
|
|
|
## Future Work
|
|
|
|
- **C5 spike** is deferred to a separate bd issue. Path A (copy-paste) is the certain distribution; Path B (cross-repo `uses:`) is recommended-when-supported and gated on a future spike. Implementation may proceed on Path A without blocking.
|
|
|
|
## Lead Recommendation
|
|
|
|
**Status (2026-05-13):** C4 resolved by spike. C5 deferred but documentation already split into Path A + Path B. C2 decision made (Option B: lift dedup to spec + skill amendment). C1, C3, C6, C7, C8, C9 patches applied.
|
|
|
|
**Resolved before tasks.md:**
|
|
|
|
1. ✅ **C4 spike** complete. Ephemeral `GITEA_TOKEN` works for `create_pull_review` on fork PRs on Codeberg Forgejo v11.x. D2's BASE-workflow-file guarantee verified (`pull_request_target` semantics honored despite displayed `pull_request` label).
|
|
2. **C5 spike** deferred. Repos created (`goern/c5-spike-lib`, `goern/c5-spike-consumer`); tag + secrets + trigger commit not yet applied. Tracked in bd. Implementation proceeds on Path A; Path B documented as conditional.
|
|
3. ✅ **C2 decision**: Option B chosen. Spec gained "Dedup contract" requirement; skill amendment for SHA trailer is a tasks.md entry (forward dependency on `forgejo-code-review-skill` follow-up).
|
|
4. ✅ **Mechanical patches** applied for C1, C3, C6, C7, C8, C9 to proposal.md, design.md, spec.md.
|
|
|
|
**Change is now hardened enough for tasks.md** with the following caveats: the C5 spike result may demote D9 Path B from "recommended" to "experimental" if cross-repo `workflow_call` semantics turn out unreliable on Codeberg Forgejo. That demotion is doc-only and does not block implementation.
|
|
|
|
**Most embarrassing findings (recorded for future battle-tests):** C1 (`--comment` vs `--dry-run` flag inversion) and C2 (fabricated dedup tagging mechanism). Both indicate the change set was written against the dep change's design rather than the shipped skill artifact. Future battle-tests should explicitly include "code-read the shipped artifact" as a pre-flight, not just "read the dep change's design."
|