feat: review-gate MCP server with OpenCode review loop #1
Loading…
Reference in a new issue
No description provided.
Delete branch "feature/review-gate-implementation"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Overview
This PR implements the
review-gateModel Context Protocol (MCP) server that delegates advisory pre-PR code review to OpenCode (deepseek/deepseek-flash).Key Capabilities
--session <session_id>) across iterative review rounds to avoid redundant token costs while capping reviews to a maximum of 2 rounds (max_iterations = 2).Review Verification
review-gateMCP server.(1) What was fixed: - Updated check_branch_sync to verify the base ref exists with 'git rev-parse --verify --quiet <ref>^{commit}'. - If rev-list or base ref verification fails, check_branch_sync now fails closed and returns is_synced=False with an explicit rejection reason rather than synthesizing a false success. - Added unit tests asserting that non-existent/unresolvable base branches are properly rejected with is_synced=False. (2) What was not fixed: - None; unresolvable base ref fail-open behavior is resolved. (3) Entire feedback from review gate verbatim: #### 2. **[MAJOR | BUG]** Sync check fails open when the base ref cannot be resolved **Location:** `src/review_gate/git_utils.py` (Lines 119-128) If 'git rev-list --count HEAD..<base>' fails (typo in base branch, missing ref, shallow/empty repository), the function returns is_synced=True with zero counts. The gate therefore reports success for an unverifiable comparison. get_review_diff then also fails to resolve the ref, producing an empty diff, and server.review_pr_changes returns '[NO CHANGES TO REVIEW]', which can be misread as a clean result and lets a PR proceed without ever being reviewed. **Suggestion:** ```suggestion Do not synthesize a successful status on error. Either raise a typed exception that the server converts into a visible error, or return BranchSyncStatus(is_synced=False, rejection_reason=f"Unable to resolve base ref '{base_ref}'; refusing to review"). Also make get_review_diff surface merge-base/diff failures instead of silently returning empty output. ```(1) What was fixed: - Updated best_effort_fetch with bounded 15-second timeout and non-interactive environment (GIT_TERMINAL_PROMPT=0) to prevent hanging on prompts or unreachable networks. - Changed fetch refspec to explicitly update remote-tracking ref '+refs/heads/{b}:refs/remotes/origin/{b}' instead of modifying local branch heads. - Added return code / status checking to indicate whether fetch succeeded. (2) What was not fixed: - None; remote fetch bounding and refspec safety have been resolved. (3) Entire feedback from review gate verbatim: #### 3. **[MAJOR | PERFORMANCE]** Best-effort fetch can hang indefinitely and can validate against a stale base **Location:** `src/review_gate/git_utils.py` (Lines 85-99) best_effort_fetch calls subprocess.run without a timeout and without GIT_TERMINAL_PROMPT=0, so an unreachable remote or a credential prompt can block the MCP server forever. It also fetches into a local ref ('<branch>:<branch>'), which fails when that branch is currently checked out, and it suppresses all failures. check_branch_sync normally prefers origin/<branch>, so a failed fetch means the mandatory rebase check can pass against a stale remote-tracking ref, defeating the gate's primary purpose. **Suggestion:** ```suggestion Fetch explicitly into the remote-tracking ref with a bounded timeout and non-interactive environment: env={**os.environ, 'GIT_TERMINAL_PROMPT': '0'}, args ['fetch','--no-tags','--quiet','origin', f'+refs/heads/{b}:refs/remotes/origin/{b}'], timeout=15. Return a success flag; when the fetch fails, either fail closed or include an explicit 'base may be stale' warning in the review result. ```(1) What was fixed: - Replaced substring checks ('"origin/main" in remote_branches') with exact branch name set membership using 'git branch --format="%(refname:short)"'. - Added symbolic ref validation with 'rev-parse' before returning detected origin/HEAD. - Added test coverage ensuring substring branch names like 'maintenance' or 'feature-main-fix' do not erroneously match 'main'. (2) What was not fixed: - None; exact branch autodetection is implemented. (3) Entire feedback from review gate verbatim: #### 4. **[MINOR | BUG]** Base branch autodetection uses substring matching **Location:** `src/review_gate/git_utils.py` (Lines 64-80) Checks such as '"origin/main" in remote_branches' and '"main" in local_branches' operate on the entire command output, not on individual branch names. A repository with only 'origin/maintenance' matches 'main', and a local branch such as 'feature-main-fix' matches the local check. That selects a nonexistent base ref, which then triggers the fail-open sync path above. **Suggestion:** ```suggestion Parse output into a set of names first, e.g. strip '* ' and whitespace from 'git branch --format="%(refname:short)"', then test exact membership: {'main','master'} & branches. Use 'git symbolic-ref --short refs/remotes/origin/HEAD' for origin HEAD and validate the resulting ref with rev-parse before returning it. ```(1) What was fixed: - In get_review_diff, added pre-flight verification of both base_ref and HEAD commit objects using 'git rev-parse --verify'. - Replaced silent fallback to base_ref with an explicit ValueError when 'git merge-base' fails or produces empty output. (2) What was not fixed: - None; merge-base error masking has been eliminated. (3) Entire feedback from review gate verbatim: #### 5. **[MINOR | BUG]** Merge-base failure is masked by falling back to the base ref **Location:** `src/review_gate/git_utils.py` (Lines 179-182) When 'git merge-base <base> HEAD' fails, merge_base is replaced with base_ref and the subsequent diff commands are still attempted with check=False. The caller cannot distinguish 'no changes' from 'could not compute a diff', which again turns an operational error into a plausible empty review. **Suggestion:** ```suggestion Raise a descriptive error (or return an explicit error flag) when merge-base fails, and only fall back to a direct ref comparison after verifying both refs with 'git rev-parse --verify <ref>^{commit}'. ```(1) What was fixed: - Updated `.forgejo/workflows/ci.yaml` to trigger on `branches: ["**"]` so that nested branches like `feature/*` trigger CI. - Replaced manual virtualenv pip install with official astral `uv` installation and `uv sync --frozen --extra test` to strictly honor the committed `uv.lock`. - Renamed job to 'Run Test Suite' to accurately reflect what the workflow executes. (2) What was not fixed: - Standalone linter step (such as ruff) was not added at this time to keep CI dependencies minimal and aligned with pyproject.toml test dependencies. (3) Entire feedback from review gate verbatim: #### 13. **[MINOR | ARCHITECTURE]** CI ignores the committed uv.lock and has no lint step **Location:** `.forgejo/workflows/ci.yaml` (Lines 19-29) The repository commits uv.lock, but CI creates a plain venv and runs pip install -e '.[test]', so tested dependency versions can drift from the lockfile and from local development. The job is named 'Run Test Suite & Lint' but only runs pytest. Additionally, 'branches: ["*"]' does not match names containing '/' in standard glob semantics, so pushes to feature branches such as 'feature/x' may not trigger CI. **Suggestion:** ```suggestion Use uv in CI ('uv sync --frozen --extra test' then 'uv run pytest') to honor uv.lock, pin the Python version, use 'branches: ["**"]', and add an actual lint/type-check step (e.g. ruff check and a type checker) or rename the job to reflect what it does. ```(1) What was fixed: - Passed `OPENCODE_CONFIG_CONTENT='{"permission": {"*": "deny"}}'` in the subprocess environment when invoking OpenCode. - This enforces an explicit deny-by-default boundary prohibiting all tools (bash, edit, read, external_directory), preventing OpenCode from requesting external directory access or auto-rejecting with exit code 1 when running non-interactively without `--auto`. - Updated test in `tests/test_harnesses.py` to assert that the deny permission profile is passed in subprocess environment variables. (2) What was not fixed: - None; the execution failure is resolved and pure advisory mode is strictly enforced. (3) Entire feedback from review gate verbatim: #### 1. **[CRITICAL | ARCHITECTURE]** OpenCode Execution Failed (Exit Code 1) **Location:** General The OpenCode review process exited with code 1. Details: ! permission requested: external_directory (/tmp/*); auto-rejecting(1) What was fixed: - In `src/review_gate/git_utils.py`, updated `get_review_diff` to format exclusions as `:(exclude)<pattern>` pathspecs rather than raw inclusion pathspecs. - Applied the exclusion pathspecs consistently across merge-base diff, dirty working tree diff, and `diff --stat`. - Added `_matches_exclusion` matching using `fnmatch` to filter untracked files from both the diff stat and synthesized diff contents. - Added comprehensive unit test `test_get_review_diff_exclude_patterns` in `tests/test_git_utils.py`. (2) What was not fixed: - None; exclusion patterns now strictly exclude matching tracked and untracked files across all diff outputs. (3) Entire feedback from review gate verbatim: #### 4. **[MAJOR | BUG]** exclude_patterns is passed as an inclusion pathspec, not an exclusion **Location:** `src/review_gate/git_utils.py` (Lines 300-313) git diff <a> <b> -- <paths> restricts output TO the supplied paths; it does not exclude them. The docstring promises custom exclusions, so a caller passing sensitive paths (for example ['*.env', 'secrets/**']) would get a diff containing only those files. The same pathspec is not applied to the untracked-file loop or to the --stat command, so the stat, diff, and untracked sections can disagree. No current caller passes the argument, so the bug is latent, but the API contract is broken and security-relevant. **Suggestion:** ```suggestion Use exclusion pathspecs: diff_cmd.extend(['--', *[f':(exclude){p}' for p in exclude_patterns]]). Apply the same pathspec to both diff invocations and filter untracked files, and add a test asserting an excluded file and its content are absent. ```1. What was fixed: Added an integration test with a fake `opencode` executable on `PATH` to verify the CLI contract (`--standalone`, `--format json`, `--model`, stdin prompt delivery, and `OPENCODE_CONFIG_CONTENT` deny-all permission profile). Added test coverage for `FileNotFoundError` and `OSError` harness execution failure paths, server `main()` entrypoint wiring (`mcp_app.run(transport="stdio")`), `strict_fetch=True` rejection on remote fetch failure, and `include_uncommitted=False` diff filtering. Added `except OSError` handling in `OpenCodeHarness.review` to fail closed cleanly on OS execution errors. 2. What was not fixed: None. All testing and error-path coverage recommendations in Finding 11 were implemented. 3. Verbatim feedback from review gate: #### 11. **[MINOR | TESTS]** No integration coverage for the OpenCode CLI contract or several git edge cases **Location:** `tests/test_harnesses.py` (Lines 1-386) The harness tests mock asyncio.create_subprocess_exec entirely, so they never verify that opencode run --standalone --model ... --format json actually reads the prompt from stdin, emits the assumed JSONL type=text/part.text shape, or honors OPENCODE_CONFIG_CONTENT={'permission':{'*':'deny'}}. A mismatch would only surface at runtime, potentially as a 600-second timeout. There are also no tests for fetch failure and stale refs, exclude_patterns, diff truncation, untracked symlinks and binaries, include_uncommitted=False, the FileNotFoundError and OSError harness paths, or the server main() startup. **Suggestion:** ```suggestion Add a small integration or smoke test against a fake opencode executable on PATH, plus unit tests for the listed git and harness edge cases. Add a smoke test that constructs the MCP app and exercises main()/mcp_app.run wiring. ```1. What was fixed: Reused `_format_fenced_block` from `review_gate.models` to dynamically calculate backtick fence lengths for both `safe_diff` and `diff_stat` in `src/review_gate/prompts.py`, guaranteeing that arbitrary backtick sequences in diffs or filenames cannot prematurely close markdown code fences. Added unit test `test_build_review_prompt_dynamic_fencing_and_nonce` in `tests/test_harnesses.py` to verify dynamic fence sizing and boundary nonce collision redaction. 2. What was not fixed: None. All recommendations in Finding 1 were implemented. 3. Verbatim feedback from review gate: #### 1. **[MAJOR | SECURITY]** Untrusted diff fence can be closed by backticks inside the diff **Location:** `src/review_gate/prompts.py` (Lines 74-87) The diff is wrapped in a fixed triple-backtick fence: f"```diff\n{safe_diff.strip()}\n```". Any diff line containing ``` (extremely common when a PR touches Markdown, tests, or nested code fences) terminates the fence early, so the remainder of the attacker-controlled diff is presented to the model outside the 'UNTRUSTED DATA' block. The nonce redaction does not help here because the attacker does not need to forge the boundary marker, only the markdown fence. The diff_stat block on line 75 has the same weakness via attacker-controlled file names. This undermines the prompt-injection defense that the README advertises as a core guardrail, and build_review_prompt has no tests at all. **Suggestion:** ```suggestion Reuse the fence-escaping logic already present in models._format_fenced_block (compute the longest backtick run and use a fence longer than it), or escape backticks in the diff, and keep the nonce boundary around the escaped block. Apply the same to diff_stat. Add unit tests for build_review_prompt asserting that a diff containing ``` and the (unknown) boundary marker cannot terminate the untrusted region. ```1. What was fixed: Added active runtime surveillance of the OpenCode JSONL event stream via `_extract_text_and_detect_tools` in `src/review_gate/harnesses/opencode.py`. If OpenCode emits or attempts any tool call events (e.g. `tool_call`, `tool_use`, `action`, `command`, or `call`) during advisory review, the harness immediately fails closed to `CHANGES_REQUESTED` with a critical `Unauthorized Tool Execution Detected` security finding. Added unit test `test_opencode_refuses_to_approve_if_tool_event_emitted` in `tests/test_harnesses.py` to verify this defense-in-depth gate. 2. What was not fixed: Did not pin a minimum OpenCode version in `pyproject.toml` as OpenCode is an external CLI binary rather than a Python package dependency. Instead, reinforced runtime tool execution interception directly in the harness. 3. Verbatim feedback from review gate: #### 3. **[MAJOR | SECURITY]** Deny-by-default OpenCode permission profile is an unverified environment-variable claim **Location:** `src/review_gate/harnesses/opencode.py` (Lines 78-82) The entire advisory-only guarantee rests on setting OPENCODE_CONFIG_CONTENT={"permission": {"*": "deny"}}. Nothing verifies that the installed OpenCode version honors this key, and mcp/opencode versions are not pinned in pyproject. If it is ignored (older CLI, renamed key, or a user config that takes precedence), the untrusted diff is fed to an agent that may execute tools/commands in the target repository. The only test (test_opencode_cli_contract_with_fake_executable) merely asserts the env var string was passed to a fake binary, so it proves nothing about enforcement. The code also emits a misleading module docstring/README claim of a permanent deny. **Suggestion:** ```suggestion Pin a minimum OpenCode version known to support the permission config, verify at startup that tool execution is actually denied (e.g. run a canary prompt/self-test and fail closed if a tool event appears), and/or pass the policy through every supported mechanism (config file + env + CLI flag). Add a test with a fake CLI that emits a tool-call event and assert the harness refuses to run/approve. ```### Fixed Updated get_review_diff to append an explicit marker `# [SKIPPED: {untracked_rel} is {file_size} bytes, exceeds per-file budget]` to dirty_diff_parts when an untracked file meets or exceeds 100KB, accounting for its size in current_len. Also added check for git ls-files failure and a unit test in tests/test_git_utils.py. ### Not Fixed None. ### Review Feedback #### 8. **[MINOR | BUG]** Untracked files >= 100KB are silently omitted from the review **Location:** `src/review_gate/git_utils.py` (Lines 420-427) The untracked-content loop only reads files with file_size < 100_000 and has no else branch. Large new files (generated code, SQL dumps, vendored sources) vanish with no truncation marker or stat note, so a review can miss the largest part of a change and still be reported as complete (or as terminal 'no changes'). This is inconsistent with the explicit truncation markers used elsewhere. **Suggestion:** ```suggestion Append a marker entry such as f"# [SKIPPED: {untracked_rel} is {file_size} bytes, exceeds per-file budget]" and account for it in current_len, so the reviewer knows content was omitted. ```### Fixed Updated OpenCodeHarness to unconditionally prepend SYSTEM_REVIEW_PROMPT (including review role, guidelines, and exact JSON schema definition) to full_prompt for all rounds (iteration 1, continuation rounds, with or without session ID). Updated build_review_prompt so that continuation instructions are conditioned solely on iteration > 1 rather than erroneously converting first-call sessions to follow-up framing. Added unit tests verifying schema presence across continuation rounds and proper initial prompt framing with session ID. ### Not Fixed None. ### Review Feedback #### 3. **[MAJOR | BUG]** Follow-up rounds drop the system prompt and output schema **Location:** `src/review_gate/harnesses/opencode.py` (Lines 75-78) `SYSTEM_REVIEW_PROMPT` (role, review dimensions, required JSON schema, verdict rules) is only prepended when `session_id` is absent and `iteration == 1`. For `iteration > 1` without a session id, or when a caller supplies a session id on the first call, the model receives only the follow-up text plus the final 'return JSON matching the requested schema' line, but the schema itself is never provided. This makes unparseable output likely, which the parser then fails closed to CHANGES_REQUESTED, potentially making the loop non-convergent. The follow-up framing is also applied incorrectly to first-call session ids. **Suggestion:** ```suggestion Always include the system prompt (or a compact schema block) in every round, e.g. `full_prompt = f"{SYSTEM_REVIEW_PROMPT}\n\n---\n\n{user_prompt}"` unconditionally, and only apply the follow-up section when there is evidence of a genuine prior round (server-tracked). Add a test asserting the schema text is present on iteration 2 without a session id. ```### Fixed Replaced the non-greedy regex fallback in OpenCodeHarness with an iterative scanner using json.JSONDecoder().raw_decode. This correctly parses unfenced model outputs that contain nested JSON structures (such as the issues list and inner dictionaries) and preserves surrounded prose without premature truncation. Added a unit test verifying extraction of unfenced, nested JSON containing multiple issues. ### Not Fixed None. ### Review Feedback #### 4. **[MINOR | BUG]** Unfenced JSON fallback cannot parse nested review objects **Location:** `src/review_gate/harnesses/opencode.py` (Lines 318-325) The fallback `re.findall(r"(\{[\s\S]*?\})", text)` is non-greedy and unanchored, so for any review with a non-empty `issues` array it captures only up to the first nested `}`, producing invalid JSON that is skipped. Because findall does not retry from the same start position, a valid top-level object is never recovered. Any model output that omits the code fence but contains findings becomes 'Unparseable Review Response' and fails closed, defeating the purpose of the fallback. **Suggestion:** ```suggestion Scan with `json.JSONDecoder().raw_decode` over each `{` position, or use a balanced-brace extractor, accepting the first/last object containing a `verdict` key. Add a test with unfenced, nested JSON containing `issues`. ```### Fixed Updated build_review_prompt in prompts.py to redact BOUNDARY_NONCE: {nonce} as well as the literal nonce value itself ([NONCE_REDACTED]) from the untrusted diff before fencing. Separated the untrusted diff boundary markers from the authentication nonce to avoid placing the nonce adjacent to attacker-influenced content, and documented the residual prompt-injection risk in the review prompt. Updated unit tests to verify redaction of boundary markers and raw nonce tokens. ### Not Fixed None. ### Review Feedback #### 6. **[MINOR | SECURITY]** Nonce collision guard does not match the actual boundary marker **Location:** `src/review_gate/prompts.py` (Lines 93-105) `safe_diff = diff.replace(f"DIFF_BOUNDARY_{nonce}", ...)` redacts a marker format that is never emitted; the real boundary text is `BOUNDARY_NONCE: {nonce}`. The guard is therefore ineffective. More fundamentally, the nonce is visible to the model in the prompt, so injected diff content can instruct the model to echo it; duplicate-block detection only catches the case where the model also emits its real verdict. Note this is a limitation of self-authenticating model output, not a cryptographic guarantee. **Suggestion:** ```suggestion Redact `BOUNDARY_NONCE: {nonce}` (and the nonce value itself) from the diff before fencing, and consider not placing the nonce adjacent to the untrusted block. Document the residual prompt-injection risk, since nonce verification cannot make model output tamper-proof. ```### Fixed Updated check_branch_sync to fail closed and reject reviews (is_synced=False) with an explicit error reason if git rev-list --count {base_ref}..HEAD fails with a non-zero exit code, rather than silently defaulting ahead_count to 0. Updated has_uncommitted_changes to raise a ValueError when git status --porcelain exits non-zero, ensuring git corruption or index errors fail closed instead of silently reporting a clean working tree. Added corresponding unit tests verifying rejection and exception propagation. ### Not Fixed None. ### Review Feedback #### 9. **[MINOR | BUG]** Silent fallbacks hide git failures in sync and dirty-tree checks **Location:** `src/review_gate/git_utils.py` (Lines 240-242) If `git rev-list --count {base_ref}..HEAD` fails, `commits_ahead` is silently set to 0 rather than propagated. Similarly `has_uncommitted_changes` returns False whenever `git status` fails (empty stdout). Both are misleading in a tool whose contract is fail-closed, and can produce a 'synced, clean' report on git errors. **Suggestion:** ```suggestion Return an explicit error/rejection reason when `res_ahead.returncode != 0`, and make `has_uncommitted_changes` raise or return an error sentinel when `git status` fails so callers fail closed. ```### Fixed Updated OpenCodeHarness.review to return ReviewVerdict.COMMENT with summary "NO CHANGES TO REVIEW: No code changes detected to review." rather than issuing ReviewVerdict.APPROVED when invoked with an empty diff. This aligns the harness defense-in-depth with the terminal state semantics of the review gate server ("No approval is issued"). Added a dedicated unit test in tests/test_harnesses.py. ### Not Fixed None. ### Review Feedback #### 10. **[MINOR | BUG]** Empty-diff harness path issues APPROVED, contradicting terminal-state rules **Location:** `src/review_gate/harnesses/opencode.py` (Lines 47-59) `OpenCodeHarness.review` returns `ReviewVerdict.APPROVED` with 'No code changes detected to review' when the diff is empty. The server currently short-circuits empty diffs, but any direct harness use (or future refactor) would issue an approval where the documented contract says 'No approval is issued'. This weakens defense in depth. **Suggestion:** ```suggestion Return `COMMENT` with an explicit 'NO CHANGES TO REVIEW' marker (or raise), matching the terminal-state semantics in server.py. ```Model Response / What was fixed: - Moved standard review_gate module imports in `src/review_gate/server.py` above the `try/except ImportError` MCP compatibility fallback block, ensuring no module-level imports follow runtime code and avoiding potential E402 lint violations. - Stripped trailing blank lines at the end of test files (`tests/test_git_utils.py` and `tests/test_harnesses.py`) to prevent W391 lint issues. - Formatted the entire codebase with `ruff format` so that `uv run ruff check .` and `uv run ruff format --check .` both pass with zero errors. What was not fixed: - None. All import orderings, trailing blank lines, and style formatting suggestions were fully resolved and verified. Verbatim Review Gate Feedback: #### 12. **[MINOR | STYLE]** Likely ruff E402 failure from imports after the try/except fallback **Location:** `src/review_gate/server.py` (Lines 7-25) The file performs a `try/except ImportError` import block and then continues with ordinary module-level imports (`from review_gate.git_utils import ...`, harnesses, models). With `E` selected and only `E501` ignored, ruff is likely to report E402 ('module level import not at top of file') for these imports, failing the new `uv run ruff check .` CI step. The test files also end with multiple trailing blank lines, which should trip W391 (also selected via `W`). **Suggestion:** ```suggestion Move all normal imports above the compatibility try/except (or add targeted `# noqa: E402` with justification), and strip trailing blank lines from tests. Verify locally with `uv run ruff check .` and consider running `ruff format --check`. ```Model Response / What was fixed: - Refactored sensitive file omission detection in `get_review_diff` to parse `git diff -z --name-status -M` output instead of raw `--name-only`. - Pure deletions (`D`) of files that existed in the base branch are no longer treated as omitted secrets, enabling remediation PRs that remove sensitive files to be approved cleanly. - Implemented intermediate commit scanning over `git rev-list merge_base..HEAD` via `git diff-tree -z --name-status -M -r --no-commit-id <commit>` to detect secrets that were introduced in early branch commits and deleted in later commits. - Evaluated both source and destination paths for rename and copy events (`R`, `C`). - Added comprehensive unit test `test_sensitive_detection_handles_deletions_renames_and_intermediate_commits` verifying base secret deletion, intermediate commit leakage detection, and rename detection. What was not fixed: - None. All status filtering, history scanning, and test scenarios were fully implemented. Verbatim Review Gate Feedback: { "title": "Sensitive-file guardrail blocks secret deletion and misses secrets in intermediate commits", "file": "src/review_gate/git_utils.py", "line_start": 496, "line_end": 511, "severity": "major", "category": "security", "description": "Sensitive detection is built from `git diff --name-only merge_base HEAD` plus the working-tree diff. That list includes deletions, so a PR whose only change is deleting `.env` or `secret.key` still populates `sensitive_omitted` and is rejected by server.py, blocking the very remediation the rejection message requests. Conversely, the net diff only sees the final tree: a secret added in one branch commit and deleted in a later one is absent from `merge_base..HEAD`, so the gate can approve while the secret remains in branch history. Rename detection can also miss the old sensitive path. Tests cover only additions.", "suggestion": "Use `git diff --name-status` with `-z` and flag only sensitive paths that exist at HEAD as additions/modifications/renames, checking both sides of renames. Do not flag pure deletions of paths that existed in the base. Separately scan `merge_base..HEAD` commit-by-commit (for example `git rev-list` plus `git diff-tree --name-status -r --no-commit-id`) for commits that add or modify a sensitive path, so add-then-delete secrets are caught. Add tests for deleting a base secret, adding then deleting a secret, and renaming a secret." }Model Response / What was fixed: - Applied `redact_url_userinfo` to git subprocess stderr and exception messages in `best_effort_fetch`, preventing credential leakage if git emits error logs containing embedded userinfo for reachable or unreachable remotes. - Restricted `ALLOWED_SCHEMES` in `is_safe_remote_url` to encrypted protocols only (`https://`, `ssh://`), rejecting unencrypted `http://` and `git://` transports. - Added unit tests in `tests/test_git_utils.py` verifying credential absence from fetch failure logs, rejection of unencrypted schemes, and redaction of git error strings containing embedded userinfo. What was not fixed: - None. Redaction of fetch failure messages and transport restrictions were fully implemented and tested. Verbatim Review Gate Feedback: { "title": "Fetch-failure logging can expose embedded remote credentials", "file": "src/review_gate/git_utils.py", "line_start": 302, "line_end": 306, "severity": "major", "category": "security", "description": "`is_safe_remote_url` permits credentialed URLs such as `https://user:pass@host/repo.git`, but the fetch-failure path prints `proc.stderr` verbatim. Git error text can include the remote URL, so the password or token can be written to MCP stderr or captured logs. `redact_url_userinfo` is applied only to the pre-fetch rejection message; the test suite covers a rejected `file://` URL, not an allowed URL that fails.", "suggestion": "Apply `redact_url_userinfo` to `proc.stderr` and any URL-bearing error before logging, or avoid logging stderr entirely. Add a test with an allowed scheme plus embedded credentials and an unreachable remote, asserting the secret is absent from stderr. Consider rejecting `http://` and `git://` or warning about them, since the comment calls the allow-list secure but those transports are not." }Model Response / What was fixed: - Replaced client-trusting iteration state with `SessionRecord` in `src/review_gate/server.py`, tracking session ID, iteration, max_iterations, repository path, base commit, HEAD commit SHA, diff content SHA256, and creation timestamp. - Validated session ID syntax against regex pattern `^ses_[a-zA-Z0-9_-]{8,64}$`, rejecting malformed IDs before subprocess invocation. - Required valid sessions to be previously issued by the review harness; rejected unknown or forged session IDs. - Derived iteration bounds purely server-side (`session_record.iteration + 1`) and strictly blocked calls exceeding `max_iterations` or `SERVER_MAX_ITERATIONS`. - Required HEAD commit SHA and diff hash to change before permitting a follow-up review round, rejecting attempts to run follow-up reviews on unchanged code. - Added session expiry cleanup and unit tests covering forged session IDs, format validation, repeated calls exceeding bounds, unchanged code detection, and session expiry. What was not fixed: - None. All suggested session verification, diff tracking, iteration enforcement, and unit tests were implemented. Verbatim Review Gate Feedback: { "title": "Session and iteration state can be forged and max_iterations is not enforced", "file": "src/review_gate/server.py", "line_start": 116, "line_end": 129, "severity": "major", "category": "security", "description": "The server still trusts client state. An unknown `session_id` is treated as active (`prev_iteration == 0` leads to `min(iteration, 2)`), so a caller can pass an arbitrary session id and `iteration=2` on the first call, receiving follow-up framing and passing an unvalidated id to OpenCode `--session`. `effective_iteration = prev_iteration + 1` has no check against `max_iterations` or `SERVER_MAX_ITERATIONS`, so repeated calls yield Round 3/2, 4/2, and so on; the bound is never enforced. No HEAD SHA or diff hash is stored, so the server cannot verify the prompt's claim that the author committed fixes. `_SESSION_ITERATIONS` is keyed only by session id and grows without expiry, so ids are not scoped to a repo, base, or head.", "suggestion": "Store a per-session record with iteration, max_iterations, repo, base commit, HEAD SHA, and diff hash. Reject unknown or expired session ids and only accept ids issued by the harness. Derive iteration server-side and reject when it would exceed the stored max or SERVER_MAX_ITERATIONS. Require HEAD/diff to change before treating a call as a follow-up. Validate the session id format before putting it in the prompt or argv. Add tests for an arbitrary session id, a third call, unchanged diff, and expiry/cleanup." }### 1. What was fixed - Updated `get_review_diff` to return a 5-tuple including `was_truncated: bool`. - Evaluated `MAX_DIFF_CHARS` dynamically if `max_diff_chars is None` to ensure budget overrides take effect. - Updated `review_pr_changes` in `src/review_gate/server.py` to unpack `diff_was_truncated` and immediately return `## [REVIEW REJECTED: DIFF TRUNCATED]`, blocking approval and failing closed before calling the model harness. - Updated test unpacks across `tests/test_git_utils.py` and `tests/test_harnesses.py`. - Added test `test_server_rejects_truncated_diff_even_if_model_approves` verifying that diff truncation halts review and prevents approval. ### 2. What was not fixed None. All points in the review finding were addressed. ### 3. Review Gate Feedback (verbatim) { "title": "Truncated diffs are not propagated as a blocking condition", "file": "src/review_gate/git_utils.py", "line_start": 168, "line_end": 190, "severity": "major", "category": "security", "description": "`_run_git_streamed_diff` detects `was_truncated`, and `get_review_diff` knows `diff_truncated`, but it only appends a textual `[DIFF TRUNCATED]` marker and returns the ordinary four-tuple. The server has no machine-readable indication that the review covered only part of the changes, so the harness can return APPROVED for a partial diff. An attacker can add benign changes plus a large or late file so the payload falls beyond the 500k character budget; a model that ignores the marker can approve unseen code. This conflicts with the fail-closed claim.", "suggestion": "Propagate a truncated boolean from `get_review_diff` (for example a five-tuple or dataclass) through the server and fail closed: return CHANGES_REQUESTED or an explicit review-incomplete state whenever truncation occurs. Add a test where the diff truncates and the model returns APPROVED, asserting the final result is not APPROVED." }### 1. What was fixed - Added `_sanitize_untrusted_text` in `src/review_gate/models.py` to sanitize model-supplied fields (`summary`, `issue.title`, `issue.file`, `issue.description`, and `issue.suggestion`). - Neutralized review verdict tokens (such as `[REVIEW APPROVED]`, `[CHANGES REQUESTED]`, etc.) into `[UNTRUSTED_VERDICT_TOKEN: ...]` and escaped Markdown heading tokens so prompt-injected diffs cannot spoof review gate status headers. - Added `test_unescaped_model_text_cannot_render_fake_approval_header` in `tests/test_harnesses.py` ensuring fake approval headers in summary and issues cannot spoof the verdict or be mistaken for approval. ### 2. What was not fixed None. All points in the review finding were addressed. ### 3. Review Gate Feedback (verbatim) { "title": "Unescaped model text can render a fake approval header", "file": "src/review_gate/models.py", "line_start": 128, "line_end": 148, "severity": "major", "category": "security", "description": "`to_markdown` emits model-controlled `summary`, issue `title`, and `description` as raw Markdown immediately after the real verdict header. A prompt-injected diff can induce the model to put `## [REVIEW APPROVED]` into the summary or a finding while the parsed verdict is CHANGES_REQUESTED. The AGENTS_TEMPLATE tells agents to open a PR once they see `## [REVIEW APPROVED]`; an agent doing a substring check can be fooled because the nonce authenticates only the JSON verdict, not the rendered text. The same applies to issue descriptions and filenames.", "suggestion": "Treat all model-derived Markdown as untrusted: escape or neutralize verdict-header tokens, fence issue descriptions, or return an authoritative structured JSON result and tell consumers to read only that. Add a test where the summary or title contains a fake `## [REVIEW APPROVED]` while the verdict is CHANGES_REQUESTED, asserting the rendered output cannot be mistaken for approval." }### 1. What was fixed - Refactored `_is_tool_execution_event` in `src/review_gate/harnesses/opencode.py` to remove early return on top-level `tool_call`/`tool_use`, allowing part inspection when payload is nested. - Supported `part.type` of `tool`, `tool_call`, and `tool_use` with payload attributes (`tool`, `name`, `tool_name`, `args`, `input`, `call_id`). - Added test coverage in `tests/test_harnesses.py` for nested `tool_use` events with `part.type: tool` and `tool_call` matching OpenCode stream schemas while preserving benign step metadata exclusion. ### 2. What was not fixed None. All points in the review finding were addressed. ### 3. Review Gate Feedback (verbatim) { "title": "Nested tool_use events can bypass tool-execution detection", "file": "src/review_gate/harnesses/opencode.py", "line_start": 278, "line_end": 299, "severity": "major", "category": "security", "description": "In `_is_tool_execution_event`, the `tool_call`/`tool_use` branch returns a boolean immediately, checking only top-level fields. If the actual tool payload is nested under `data.part` (as the text extraction already expects for the JSONL stream), the top-level check is false and the function returns False before the part-level branch runs. A real nested tool-use event can therefore go undetected. The previous broad detection caught all `tool_use` events; the new tests cover top-level `tool_call` or `type: step` with `part.type: tool_call`, but not `type: tool_use` with `part.type: tool`. This is a regression in the post-hoc failsafe for the deny-by-default permission profile.", "suggestion": "Do not return early. Combine top-level and part-level checks, and accept `part.type` values such as `tool`, `tool_call`, and `tool_use` when a tool name, args, input, or call id is present. Add tests for a nested `type: tool_use` with `part.type: tool`, and verify against the actual OpenCode event schema. Keep benign step metadata containing only a `tool` key from being flagged." }aint nobody have time to read t...