You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@magpie.apache.org by po...@apache.org on 2026/09/24 11:12:39 UTC

(magpie) 03/13: feat(adversarial-review): read-only backend adapters for codex, copilot, gemini, claude

This is an automated email from the ASF dual-hosted git repository.

potiuk pushed a commit to branch feat/adversarial-review-tool
in repository https://gitbox.apache.org/repos/asf/magpie.git

commit e82801e9dcb367597961469093922bc1fcaa8658
Author: Jarek Potiuk <po...@apache.org>
AuthorDate: Thu Sep 24 00:19:39 2026 +0200

    feat(adversarial-review): read-only backend adapters for codex, copilot, gemini, claude
    
    Generated-by: Claude Opus 5
---
 .../src/adversarial_review/backends.py             | 188 +++++++++++++++++++++
 tools/adversarial-review/tests/test_backends.py    | 175 +++++++++++++++++++
 2 files changed, 363 insertions(+)

diff --git a/tools/adversarial-review/src/adversarial_review/backends.py b/tools/adversarial-review/src/adversarial_review/backends.py
new file mode 100644
index 00000000..da9f2067
--- /dev/null
+++ b/tools/adversarial-review/src/adversarial_review/backends.py
@@ -0,0 +1,188 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""
+One adapter per reviewer CLI: its headless, read-only command line, how the
+harness it belongs to is recognised, and where its final answer sits in its output.
+
+The command lines are this package's security surface. Each one keeps the
+reviewer read-only, and none carries the prompt in argv: the diff can exceed
+ARG_MAX, so the prompt goes on stdin, or through a brief file for Copilot, whose
+`-p` takes text only. `tests/test_backends.py` snapshots every argv and rejects
+known write-granting flags, so a regression that drops a read-only flag fails.
+"""
+
+from __future__ import annotations
+
+import json
+from collections.abc import Callable
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any
+
+COPILOT_INSTRUCTION = (
+    "Read the review brief at {brief} and follow it exactly. Change no file and run no command."
+)
+STDIN_INSTRUCTION = "Follow the review brief given on standard input exactly. Change no file."
+CLAUDE_DENIED_TOOLS = "Bash,Edit,Write,NotebookEdit,WebFetch,WebSearch"
+
+
+class BackendOutputError(ValueError):
+    """The CLI exited 0, but its output carries no usable final answer."""
+
+
+@dataclass(frozen=True)
+class RunContext:
+    repo_dir: Path
+    prompt: str
+    brief_path: Path
+    schema_path: Path
+    last_message_path: Path
+    model: str | None = None
+
+
+@dataclass(frozen=True)
+class Invocation:
+    argv: list[str]
+    stdin: str | None
+
+
+@dataclass(frozen=True)
+class Backend:
+    name: str
+    # (environment variable, required value — None means any non-empty value)
+    self_markers: tuple[tuple[str, str | None], ...]
+    build: Callable[[RunContext], Invocation]
+    extract: Callable[[str, RunContext], str]
+
+
+def _model(flag: str, ctx: RunContext) -> list[str]:
+    return [flag, ctx.model] if ctx.model else []
+
+
+def _json_envelope(stdout: str, what: str) -> dict[str, Any]:
+    try:
+        data = json.loads(stdout)
+    except ValueError as exc:
+        raise BackendOutputError(f"{what} output is not JSON: {exc}") from None
+    if not isinstance(data, dict):
+        raise BackendOutputError(f"{what} output is not a JSON object")
+    return data
+
+
+def _codex(ctx: RunContext) -> Invocation:
+    argv = [
+        "codex",
+        "exec",
+        "-s",
+        "read-only",
+        "--ephemeral",
+        "--skip-git-repo-check",
+        "-C",
+        str(ctx.repo_dir),
+        "--output-schema",
+        str(ctx.schema_path),
+        "-o",
+        str(ctx.last_message_path),
+        *_model("-m", ctx),
+        "-",
+    ]
+    return Invocation(argv, stdin=ctx.prompt)
+
+
+def _codex_extract(stdout: str, ctx: RunContext) -> str:
+    try:
+        text = ctx.last_message_path.read_text(encoding="utf-8")
+    except FileNotFoundError:
+        raise BackendOutputError("codex wrote no final message") from None
+    if not text.strip():
+        raise BackendOutputError("codex's final message is empty")
+    return text
+
+
+def _copilot(ctx: RunContext) -> Invocation:
+    argv = [
+        "copilot",
+        "-p",
+        COPILOT_INSTRUCTION.format(brief=ctx.brief_path),
+        "--add-dir",
+        str(ctx.brief_path.parent),
+        "--deny-tool",
+        "shell",
+        "--deny-tool",
+        "write",
+        "--no-color",
+        "--log-level",
+        "none",
+        *_model("--model", ctx),
+    ]
+    return Invocation(argv, stdin=None)
+
+
+def _plain_extract(stdout: str, ctx: RunContext) -> str:
+    if not stdout.strip():
+        raise BackendOutputError("empty reply")
+    return stdout
+
+
+def _gemini(ctx: RunContext) -> Invocation:
+    argv = ["gemini", "--approval-mode", "plan", "-o", "json", *_model("-m", ctx), "-p", STDIN_INSTRUCTION]
+    return Invocation(argv, stdin=ctx.prompt)
+
+
+def _gemini_extract(stdout: str, ctx: RunContext) -> str:
+    data = _json_envelope(stdout, "gemini")
+    if data.get("error"):
+        raise BackendOutputError(f"gemini reported an error: {data['error']}")
+    response = data.get("response")
+    if not isinstance(response, str) or not response.strip():
+        raise BackendOutputError("gemini output has no `response` text")
+    return response
+
+
+def _claude(ctx: RunContext) -> Invocation:
+    argv = [
+        "claude",
+        "-p",
+        "--output-format",
+        "json",
+        "--disallowedTools",
+        CLAUDE_DENIED_TOOLS,
+        *_model("--model", ctx),
+    ]
+    return Invocation(argv, stdin=ctx.prompt)
+
+
+def _claude_extract(stdout: str, ctx: RunContext) -> str:
+    data = _json_envelope(stdout, "claude")
+    result = data.get("result")
+    if data.get("is_error"):
+        raise BackendOutputError(f"claude reported an error: {result}")
+    if not isinstance(result, str) or not result.strip():
+        raise BackendOutputError("claude output has no `result` text")
+    return result
+
+
+# Order matters for self-detection: a harness started from inside another
+# inherits the outer one's variables, so the innermost candidates are checked
+# first and Claude Code's widely inherited CLAUDECODE comes last.
+BACKENDS: dict[str, Backend] = {
+    "codex": Backend("codex", (("CODEX_SANDBOX", None), ("CODEX_THREAD_ID", None)), _codex, _codex_extract),
+    "copilot": Backend("copilot", (("COPILOT_CLI", None),), _copilot, _plain_extract),
+    "gemini": Backend("gemini", (("GEMINI_CLI", "1"),), _gemini, _gemini_extract),
+    "claude": Backend("claude", (("CLAUDECODE", "1"),), _claude, _claude_extract),
+}
diff --git a/tools/adversarial-review/tests/test_backends.py b/tools/adversarial-review/tests/test_backends.py
new file mode 100644
index 00000000..f2af7a6e
--- /dev/null
+++ b/tools/adversarial-review/tests/test_backends.py
@@ -0,0 +1,175 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+import json
+from pathlib import Path
+
+import pytest
+
+from adversarial_review.backends import BACKENDS, BackendOutputError, RunContext
+
+CTX = RunContext(
+    repo_dir=Path("/repo"),
+    prompt="PROMPT",
+    brief_path=Path("/t/brief.md"),
+    schema_path=Path("/t/findings.schema.json"),
+    last_message_path=Path("/t/codex-last-message.json"),
+)
+WRITE_GRANTING = {
+    "--allow-all-tools",
+    "--allow-all-paths",
+    "yolo",
+    "--yolo",
+    "auto_edit",
+    "danger-full-access",
+    "workspace-write",
+    "--full-auto",
+    "--dangerously-bypass-approvals-and-sandbox",
+    "--dangerously-skip-permissions",
+    "bypassPermissions",
+}
+
+
+def test_backend_set_and_order():
+    assert list(BACKENDS) == ["codex", "copilot", "gemini", "claude"]
+
+
+def test_codex_argv():
+    inv = BACKENDS["codex"].build(CTX)
+    assert inv.argv == [
+        "codex",
+        "exec",
+        "-s",
+        "read-only",
+        "--ephemeral",
+        "--skip-git-repo-check",
+        "-C",
+        "/repo",
+        "--output-schema",
+        "/t/findings.schema.json",
+        "-o",
+        "/t/codex-last-message.json",
+        "-",
+    ]
+    assert inv.stdin == "PROMPT"
+
+
+def test_copilot_argv():
+    inv = BACKENDS["copilot"].build(CTX)
+    assert inv.argv == [
+        "copilot",
+        "-p",
+        "Read the review brief at /t/brief.md and follow it exactly. Change no file and run no command.",
+        "--add-dir",
+        "/t",
+        "--deny-tool",
+        "shell",
+        "--deny-tool",
+        "write",
+        "--no-color",
+        "--log-level",
+        "none",
+    ]
+    assert inv.stdin is None
+
+
+def test_gemini_argv():
+    inv = BACKENDS["gemini"].build(CTX)
+    assert inv.argv == [
+        "gemini",
+        "--approval-mode",
+        "plan",
+        "-o",
+        "json",
+        "-p",
+        "Follow the review brief given on standard input exactly. Change no file.",
+    ]
+    assert inv.stdin == "PROMPT"
+
+
+def test_claude_argv():
+    inv = BACKENDS["claude"].build(CTX)
+    assert inv.argv == [
+        "claude",
+        "-p",
+        "--output-format",
+        "json",
+        "--disallowedTools",
+        "Bash,Edit,Write,NotebookEdit,WebFetch,WebSearch",
+    ]
+    assert inv.stdin == "PROMPT"
+
+
+@pytest.mark.parametrize(
+    ("name", "flag"), [("codex", "-m"), ("copilot", "--model"), ("gemini", "-m"), ("claude", "--model")]
+)
+def test_model_override_is_passed(name, flag):
+    ctx = RunContext(**{**CTX.__dict__, "model": "some-model"})
+    argv = BACKENDS[name].build(ctx).argv
+    assert argv[argv.index(flag) + 1] == "some-model"
+
+
+@pytest.mark.parametrize("name", list(BACKENDS))
+def test_no_backend_grants_writes_or_carries_the_prompt_in_argv(name):
+    ctx = RunContext(**{**CTX.__dict__, "prompt": "DIFF-BODY-MUST-NOT-BE-IN-ARGV"})
+    argv = BACKENDS[name].build(ctx).argv
+    assert not WRITE_GRANTING & set(argv)
+    assert not any("DIFF-BODY-MUST-NOT-BE-IN-ARGV" in a for a in argv)
+
+
+def test_codex_extract_reads_the_last_message_file(tmp_path):
+    ctx = RunContext(**{**CTX.__dict__, "last_message_path": tmp_path / "last.json"})
+    (tmp_path / "last.json").write_text('{"findings": []}', encoding="utf-8")
+    assert BACKENDS["codex"].extract("ignored stdout", ctx) == '{"findings": []}'
+
+
+def test_codex_extract_without_file_is_an_output_error(tmp_path):
+    ctx = RunContext(**{**CTX.__dict__, "last_message_path": tmp_path / "missing.json"})
+    with pytest.raises(BackendOutputError, match="no final message"):
+        BACKENDS["codex"].extract("", ctx)
+
+
+def test_gemini_extract_unwraps_response():
+    assert BACKENDS["gemini"].extract(json.dumps({"response": "R"}), CTX) == "R"
+
+
+def test_gemini_extract_error_envelope():
+    with pytest.raises(BackendOutputError, match="gemini reported an error"):
+        BACKENDS["gemini"].extract(json.dumps({"error": {"message": "quota"}}), CTX)
+
+
+def test_claude_extract_unwraps_result():
+    assert BACKENDS["claude"].extract(json.dumps({"result": "R", "is_error": False}), CTX) == "R"
+
+
+def test_claude_extract_is_error():
+    with pytest.raises(BackendOutputError, match="claude reported an error"):
+        BACKENDS["claude"].extract(json.dumps({"result": "Invalid API key", "is_error": True}), CTX)
+
+
+@pytest.mark.parametrize("name", ["gemini", "claude"])
+def test_json_envelope_backends_reject_non_json(name):
+    with pytest.raises(BackendOutputError, match="not JSON"):
+        BACKENDS[name].extract("plain text", CTX)
+
+
+def test_copilot_extract_is_plain_stdout_but_not_empty():
+    assert BACKENDS["copilot"].extract("text", CTX) == "text"
+    with pytest.raises(BackendOutputError, match="empty"):
+        BACKENDS["copilot"].extract("  \n", CTX)