From 7c5f852ca55056d0eb88636dccd630f3eedfe657 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 13 Jul 2026 19:00:19 +0900 Subject: [PATCH] Fix OpenCode security boundary findings --- .github/workflows/opencode-review.yml | 128 ++++--- .../ci/codegraph-package/package-lock.json | 108 ++++++ scripts/ci/codegraph-package/package.json | 8 + scripts/ci/collect_failed_check_evidence.sh | 10 +- scripts/ci/opencode_dispatch_status.py | 91 +++++ scripts/ci/redact_sensitive_log.py | 151 ++++++++ scripts/ci/safe_pytest_command.py | 125 +++++++ scripts/ci/test_strix_quick_gate.sh | 6 +- tests/test_opencode_agent_contract.py | 57 ++- tests/test_opencode_security_boundaries.py | 334 ++++++++++++++++++ 10 files changed, 957 insertions(+), 61 deletions(-) create mode 100644 scripts/ci/codegraph-package/package-lock.json create mode 100644 scripts/ci/codegraph-package/package.json create mode 100644 scripts/ci/opencode_dispatch_status.py create mode 100644 scripts/ci/redact_sensitive_log.py create mode 100644 scripts/ci/safe_pytest_command.py create mode 100644 tests/test_opencode_security_boundaries.py diff --git a/.github/workflows/opencode-review.yml b/.github/workflows/opencode-review.yml index 37995a5d0..96c91e020 100644 --- a/.github/workflows/opencode-review.yml +++ b/.github/workflows/opencode-review.yml @@ -539,51 +539,23 @@ jobs: local project_dir="$1" local workflow_dir="${project_dir}/.github/workflows" [ -d "$workflow_dir" ] || return 0 - - python3 - "$workflow_dir" <<'PY' - import pathlib - import re - import shlex - import sys - - workflow_dir = pathlib.Path(sys.argv[1]) - commands = [] - seen = set() - for path in sorted(workflow_dir.glob("ci.y*ml")): - for line in path.read_text(encoding="utf-8", errors="ignore").splitlines(): - match = re.match(r"\s*run:\s*(.+?)\s*$", line) - if not match: - continue - command = match.group(1).strip() - if "pytest" not in command: - continue - lowered = command.lower() - if lowered.startswith(("pip install", "python -m pip install", "python3 -m pip install")): - continue - try: - words = shlex.split(command) - except ValueError: - continue - if "pytest" not in [pathlib.PurePosixPath(word).name for word in words]: - continue - if command not in seen: - seen.add(command) - commands.append(command) - print("\n".join(commands)) - PY + python3 "${GITHUB_WORKSPACE}/scripts/ci/safe_pytest_command.py" discover \ + --workflow-dir "$workflow_dir" } run_python_test_coverage() { local measured_projects=0 while IFS= read -r project_dir; do measured_projects=1 - configured_commands="$(configured_python_ci_test_commands "$project_dir")" - if [ -n "$configured_commands" ]; then - while IFS= read -r configured_command; do - [ -n "$configured_command" ] || continue + configured_commands_json="$(configured_python_ci_test_commands "$project_dir")" + if [ -n "$configured_commands_json" ]; then + while IFS= read -r configured_command_json; do + [ -n "$configured_command_json" ] || continue run_and_capture "Python configured CI test suite (${project_dir})" \ - bash -c 'cd "$1" && PYTHONPATH=. bash -lc "$2"' bash "$project_dir" "$configured_command" - done <<<"$configured_commands" + python3 "${GITHUB_WORKSPACE}/scripts/ci/safe_pytest_command.py" execute \ + --project-dir "$project_dir" \ + --command-json "$configured_command_json" + done <<<"$configured_commands_json" elif [ -f "${project_dir}/pyproject.toml" ]; then run_and_capture "Python coverage with missing-line report (${project_dir})" \ bash -c 'cd "$1" && PYTHONPATH=. uv run --with coverage --with pytest coverage run -m pytest tests && uv run --with coverage coverage report --show-missing' bash "$project_dir" @@ -1377,10 +1349,14 @@ jobs: append "- Failure count: ${failures}" fi + coverage_output_delimiter="$(python3 -c 'import os; print("coverage_" + os.urandom(24).hex())')" + while grep -Fqx "$coverage_output_delimiter" "$summary_file"; do + coverage_output_delimiter="$(python3 -c 'import os; print("coverage_" + os.urandom(24).hex())')" + done { - printf 'coverage_summary<>"$GITHUB_OUTPUT" cat "$summary_file" @@ -1429,6 +1405,7 @@ jobs: || ( github.event_name == 'pull_request_target' && github.event.action != 'closed' + && github.event.pull_request.head.repo.full_name == github.event.pull_request.base.repo.full_name ) ) runs-on: ubuntu-latest @@ -1504,6 +1481,29 @@ jobs: persist-credentials: false ref: ${{ github.workflow_sha }} + - name: Validate pull request head repository trust + env: + GH_TOKEN: ${{ secrets.PR_REVIEW_MERGE_TOKEN || secrets.OPENCODE_APPROVE_TOKEN || github.token }} + GH_REPOSITORY: ${{ github.event.pull_request.base.repo.full_name || github.event.inputs.target_repository || github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number || github.event.inputs.pr_number }} + run: | + set -euo pipefail + if ! [[ "$GH_REPOSITORY" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]] || + ! [[ "$PR_NUMBER" =~ ^[1-9][0-9]*$ ]]; then + echo "::error::OpenCode privileged review rejected invalid target repository or pull request metadata." + exit 1 + fi + pull_request_json="$(gh api "repos/${GH_REPOSITORY}/pulls/${PR_NUMBER}")" + head_repository="$(jq -r '.head.repo.full_name // empty' <<<"$pull_request_json")" + base_repository="$(jq -r '.base.repo.full_name // empty' <<<"$pull_request_json")" + if [ -z "$head_repository" ] || [ "$head_repository" != "$base_repository" ]; then + printf '::error::OpenCode privileged review refuses external pull request heads before OIDC, review-token, CodeGraph, or model execution. target=%s#%s head_repo=%s base_repo=%s\n' \ + "$GH_REPOSITORY" "$PR_NUMBER" "${head_repository:-}" "${base_repository:-}" + exit 1 + fi + printf 'Validated same-repository OpenCode review source for %s#%s (%s).\n' \ + "$GH_REPOSITORY" "$PR_NUMBER" "$head_repository" + - name: Exchange OpenCode app token for target repository review reads id: review_read_app_token env: @@ -1771,14 +1771,22 @@ jobs: - name: Initialize CodeGraph index for OpenCode env: - CODEGRAPH_PACKAGE: "@colbymchenry/codegraph@0.9.9" + CODEGRAPH_TRUSTED_ROOT: ${{ runner.temp }}/trusted-codegraph NPM_CONFIG_IGNORE_SCRIPTS: "true" OPENCODE_SOURCE_WORKDIR: ${{ runner.temp }}/opencode-pr-head run: | set -euo pipefail + rm -rf "$CODEGRAPH_TRUSTED_ROOT" + mkdir -p "$CODEGRAPH_TRUSTED_ROOT" + cp scripts/ci/codegraph-package/package.json \ + scripts/ci/codegraph-package/package-lock.json \ + "$CODEGRAPH_TRUSTED_ROOT"/ + npm ci --ignore-scripts --omit=dev --prefix "$CODEGRAPH_TRUSTED_ROOT" + CODEGRAPH_BIN="${CODEGRAPH_TRUSTED_ROOT}/node_modules/.bin/codegraph" + test -x "$CODEGRAPH_BIN" cd "$OPENCODE_SOURCE_WORKDIR" - npx -y "$CODEGRAPH_PACKAGE" init -i - npx -y "$CODEGRAPH_PACKAGE" status + "$CODEGRAPH_BIN" init -i + "$CODEGRAPH_BIN" status - name: Prepare bounded OpenCode review evidence timeout-minutes: 12 @@ -6967,14 +6975,16 @@ jobs: always() && github.event_name == 'workflow_dispatch' && github.event.inputs.target_repository != '' - && steps.opencode_review_model_pool.outputs.review_status != '' + && github.event.inputs.pr_head_sha != '' continue-on-error: true env: GH_TOKEN: ${{ secrets.PR_REVIEW_MERGE_TOKEN || secrets.OPENCODE_APPROVE_TOKEN || github.token }} GH_REPOSITORY: ${{ github.event.inputs.target_repository }} + PR_NUMBER: ${{ github.event.inputs.pr_number }} PR_HEAD_SHA: ${{ github.event.inputs.pr_head_sha }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} OPENCODE_MODEL_POOL_OUTCOME: ${{ steps.opencode_review_model_pool.outputs.review_status }} + COVERAGE_EVIDENCE_RESULT: ${{ needs.coverage-evidence.result }} OPENCODE_STATUS_TOKEN_SOURCE: ${{ secrets.PR_REVIEW_MERGE_TOKEN != '' && 'PR_REVIEW_MERGE_TOKEN' || secrets.OPENCODE_APPROVE_TOKEN != '' && 'OPENCODE_APPROVE_TOKEN' || 'github-token' }} run: | set -euo pipefail @@ -6988,12 +6998,30 @@ jobs: exit 0 fi - state="success" - description="OpenCode workflow_dispatch evidence passed for current head." - if [ "${OPENCODE_MODEL_POOL_OUTCOME:-}" != "success" ] && - [ "${OPENCODE_MODEL_POOL_OUTCOME:-}" != "exhausted" ]; then - state="failure" - description="OpenCode workflow_dispatch evidence did not produce approval evidence." + state="failure" + description="OpenCode live approval evidence validation failed." + pull_request_file="$(mktemp)" + reviews_file="$(mktemp)" + cleanup_status_evidence() { + rm -f "$pull_request_file" "$reviews_file" + } + trap cleanup_status_evidence EXIT + + if gh api "repos/${GH_REPOSITORY}/pulls/${PR_NUMBER}" >"$pull_request_file" && + gh api "repos/${GH_REPOSITORY}/pulls/${PR_NUMBER}/reviews" --paginate --slurp \ + | jq 'flatten' >"$reviews_file"; then + decision_json="$( + python3 scripts/ci/opencode_dispatch_status.py \ + --model-outcome "${OPENCODE_MODEL_POOL_OUTCOME:-missing}" \ + --coverage-result "${COVERAGE_EVIDENCE_RESULT:-missing}" \ + --expected-head "$PR_HEAD_SHA" \ + --pull-request-file "$pull_request_file" \ + --reviews-file "$reviews_file" + )" + state="$(jq -r '.state // "failure"' <<<"$decision_json")" + description="$(jq -r '.description // "OpenCode live approval evidence validation failed."' <<<"$decision_json")" + else + echo "::error::OpenCode workflow_dispatch status could not read the live pull request and complete review history; publishing failure." fi printf 'Publishing OpenCode workflow_dispatch status context opencode-review for %s at %s with state=%s using %s token.\n' "$GH_REPOSITORY" "$PR_HEAD_SHA" "$state" "${OPENCODE_STATUS_TOKEN_SOURCE:-configured}" diff --git a/scripts/ci/codegraph-package/package-lock.json b/scripts/ci/codegraph-package/package-lock.json new file mode 100644 index 000000000..935ae56cc --- /dev/null +++ b/scripts/ci/codegraph-package/package-lock.json @@ -0,0 +1,108 @@ +{ + "name": "contextualwisdomlab-opencode-codegraph-tooling", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "contextualwisdomlab-opencode-codegraph-tooling", + "dependencies": { + "@colbymchenry/codegraph": "0.9.9" + } + }, + "node_modules/@colbymchenry/codegraph": { + "version": "0.9.9", + "resolved": "https://registry.npmjs.org/@colbymchenry/codegraph/-/codegraph-0.9.9.tgz", + "integrity": "sha512-23Dl9q0RHKVhJRp+Y823GfmQJjcPiRycoKN7TkaI8eJitluRvieoVlclPILqua8znvkAw9KevYziFfK3k74CBQ==", + "license": "MIT", + "bin": { + "codegraph": "npm-shim.js" + }, + "optionalDependencies": { + "@colbymchenry/codegraph-darwin-arm64": "0.9.9", + "@colbymchenry/codegraph-darwin-x64": "0.9.9", + "@colbymchenry/codegraph-linux-arm64": "0.9.9", + "@colbymchenry/codegraph-linux-x64": "0.9.9", + "@colbymchenry/codegraph-win32-arm64": "0.9.9", + "@colbymchenry/codegraph-win32-x64": "0.9.9" + } + }, + "node_modules/@colbymchenry/codegraph-darwin-arm64": { + "version": "0.9.9", + "resolved": "https://registry.npmjs.org/@colbymchenry/codegraph-darwin-arm64/-/codegraph-darwin-arm64-0.9.9.tgz", + "integrity": "sha512-Mu6xhG4bF1OLeTqN2eSI5U8z2esymK/RjxP3T8cCigA4YprDIvd3XSGmTZREbDNRCuqrCi0R1LCJ7fsdFfpAPA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@colbymchenry/codegraph-darwin-x64": { + "version": "0.9.9", + "resolved": "https://registry.npmjs.org/@colbymchenry/codegraph-darwin-x64/-/codegraph-darwin-x64-0.9.9.tgz", + "integrity": "sha512-xShaChiPJsajmBxvr1yP2XWsYstq0kJzHwQmiUG1huWrE14tP9NJnJ36hsbzHfHiqwBOJo2+5MZHuXmmzJa/vQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@colbymchenry/codegraph-linux-arm64": { + "version": "0.9.9", + "resolved": "https://registry.npmjs.org/@colbymchenry/codegraph-linux-arm64/-/codegraph-linux-arm64-0.9.9.tgz", + "integrity": "sha512-zccx1m3gGB2jAGfz41U+1GQNK12mtLGuX48XyZXiyQIGFsJQCFS8v/OvShAfPygSGhUr3zKMetbkMnztp7ULtg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@colbymchenry/codegraph-linux-x64": { + "version": "0.9.9", + "resolved": "https://registry.npmjs.org/@colbymchenry/codegraph-linux-x64/-/codegraph-linux-x64-0.9.9.tgz", + "integrity": "sha512-vQfe5VdSQb/cVo1pwO12FyzTNaDIXtN3dLuAS/LIPsg2tQVyT9fwr10QmL10qWUNBShjgnRsomHdMG7hCjW3Yg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@colbymchenry/codegraph-win32-arm64": { + "version": "0.9.9", + "resolved": "https://registry.npmjs.org/@colbymchenry/codegraph-win32-arm64/-/codegraph-win32-arm64-0.9.9.tgz", + "integrity": "sha512-U2QlTT434ulbEw6sTJVS+VNhjJUbZqCnw8tXuXSKkcuzGPZq/Hxoofe9UpY77+J9hAJu9PwwiWjEEaiDX9yb1w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@colbymchenry/codegraph-win32-x64": { + "version": "0.9.9", + "resolved": "https://registry.npmjs.org/@colbymchenry/codegraph-win32-x64/-/codegraph-win32-x64-0.9.9.tgz", + "integrity": "sha512-N7Xgt80BeEy78nNeqHaUgcsngxmy1Hty570QSa4hOh4zl6z6v6n7i9ZSUfraktESVUkfmMbUwoqfEzVwUbB0/g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + } + } +} diff --git a/scripts/ci/codegraph-package/package.json b/scripts/ci/codegraph-package/package.json new file mode 100644 index 000000000..2abe95b20 --- /dev/null +++ b/scripts/ci/codegraph-package/package.json @@ -0,0 +1,8 @@ +{ + "name": "contextualwisdomlab-opencode-codegraph-tooling", + "private": true, + "description": "Pinned CodeGraph CLI package for trusted OpenCode review workflows.", + "dependencies": { + "@colbymchenry/codegraph": "0.9.9" + } +} diff --git a/scripts/ci/collect_failed_check_evidence.sh b/scripts/ci/collect_failed_check_evidence.sh index d46ce9e11..1e1ade618 100755 --- a/scripts/ci/collect_failed_check_evidence.sh +++ b/scripts/ci/collect_failed_check_evidence.sh @@ -12,20 +12,14 @@ fi OUTPUT_FILE="$1" FAILED_CHECK_LOG_LINES="${FAILED_CHECK_LOG_LINES:-180}" +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" strip_ansi() { perl -pe 's/\x1b\[[0-9;?]*[A-Za-z]//g' } redact_sensitive_log() { - perl -pe ' - s/\b(gh[pousr]_[A-Za-z0-9_]{20,}|github_pat_[A-Za-z0-9_]{20,})/[REDACTED_GITHUB_TOKEN]/g; - s/\b(sk-[A-Za-z0-9_-]{20,})/[REDACTED_API_KEY]/g; - s/\b(xox[baprs]-[A-Za-z0-9-]{20,})/[REDACTED_SLACK_TOKEN]/g; - s/\b(AKIA[0-9A-Z]{16})/[REDACTED_AWS_ACCESS_KEY]/g; - s/((?:api[_-]?key|access[_-]?token|refresh[_-]?token|id[_-]?token|client[_-]?secret|password|passwd|secret)\s*[:=]\s*)["'\'']?[^"'\''\s]+["'\'']?/${1}[REDACTED]/ig; - s/((?:authorization|proxy-authorization)\s*:\s*(?:bearer|basic)\s+)[A-Za-z0-9._~+\/=-]+/${1}[REDACTED]/ig; - ' + python3 "$SCRIPT_DIR/redact_sensitive_log.py" } emit_bounded_file() { diff --git a/scripts/ci/opencode_dispatch_status.py b/scripts/ci/opencode_dispatch_status.py new file mode 100644 index 000000000..369e67ecd --- /dev/null +++ b/scripts/ci/opencode_dispatch_status.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +"""Decide the workflow-dispatch OpenCode status from validated live evidence.""" + +from __future__ import annotations + +import argparse +import json +import re +from pathlib import Path +from typing import Any, Sequence + +APPROVAL_AUTHORS = frozenset({"opencode-agent", "opencode-agent[bot]"}) +HEAD_SHA_RE = re.compile(r"Head SHA:\s*`?([0-9a-fA-F]{40})`?", re.IGNORECASE) + + +def _has_current_approval(reviews: Sequence[dict[str, Any]], head_sha: str) -> bool: + """Return whether the latest OpenCode decision explicitly approves the exact head.""" + for review in reversed(reviews): + author = str((review.get("user") or {}).get("login") or "").casefold() + if author not in APPROVAL_AUTHORS: + continue + if str(review.get("commit_id") or "").lower() != head_sha.lower(): + continue + body_heads = HEAD_SHA_RE.findall(str(review.get("body") or "")) + if not body_heads or body_heads[-1].lower() != head_sha.lower(): + continue + return str(review.get("state") or "").upper() == "APPROVED" + return False + + +def decide_status( + *, + model_outcome: str, + coverage_result: str, + expected_head: str, + pull_request: dict[str, Any], + reviews: Sequence[dict[str, Any]], +) -> dict[str, str]: + """Return a fail-closed GitHub commit-status decision.""" + live_head = str((pull_request.get("head") or {}).get("sha") or "") + if model_outcome != "success": + reason = "OpenCode model review did not produce approval evidence." + elif coverage_result != "success": + reason = "OpenCode coverage evidence did not pass for the current head." + elif not expected_head or live_head.lower() != expected_head.lower(): + reason = "OpenCode status target is stale or the live PR head is unavailable." + elif not _has_current_approval(reviews, expected_head): + reason = "No validated exact-current-head OpenCode approval was published." + else: + return { + "state": "success", + "description": "Validated current-head OpenCode approval and coverage passed.", + } + return {"state": "failure", "description": reason} + + +def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: + """Parse commit-status evidence inputs.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--model-outcome", required=True) + parser.add_argument("--coverage-result", required=True) + parser.add_argument("--expected-head", required=True) + parser.add_argument("--pull-request-file", required=True, type=Path) + parser.add_argument("--reviews-file", required=True, type=Path) + return parser.parse_args(argv) + + +def main(argv: Sequence[str] | None = None) -> int: + """Print one JSON commit-status decision.""" + args = parse_args(argv) + pull_request = json.loads(args.pull_request_file.read_text(encoding="utf-8")) + reviews = json.loads(args.reviews_file.read_text(encoding="utf-8")) + if not isinstance(pull_request, dict) or not isinstance(reviews, list): + raise SystemExit("pull request evidence must be an object and reviews evidence an array") + print( + json.dumps( + decide_status( + model_outcome=args.model_outcome, + coverage_result=args.coverage_result, + expected_head=args.expected_head, + pull_request=pull_request, + reviews=reviews, + ), + separators=(",", ":"), + ) + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/ci/redact_sensitive_log.py b/scripts/ci/redact_sensitive_log.py new file mode 100644 index 000000000..cb89fe67b --- /dev/null +++ b/scripts/ci/redact_sensitive_log.py @@ -0,0 +1,151 @@ +#!/usr/bin/env python3 +"""Redact credentials from CI log text before it becomes review evidence.""" + +from __future__ import annotations + +import json +import re +import sys +from typing import Any + +REDACTED = "[REDACTED]" +KEY_CHARS = frozenset("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_.-") +SENSITIVE_KEY_RE = re.compile( + r"(?:token|secret|password|passwd|credential|authorization|jwt|" + r"api[_-]?key|private[_-]?key|access[_-]?key|session[_-]?key)", + re.IGNORECASE, +) +JWT_RE = re.compile( + r"(?\b(?:authorization\s*:\s*)?(?:bearer|basic)\s+)" + r"[^\s\"'\\]+", + re.IGNORECASE, +) +PROVIDER_TOKEN_RES = ( + re.compile(r"\b(?:gh[pousr]_[A-Za-z0-9_]{20,}|github_pat_[A-Za-z0-9_]{20,})\b"), + re.compile(r"\bsk-[A-Za-z0-9_-]{20,}\b"), + re.compile(r"\bxox[baprs]-[A-Za-z0-9-]{20,}\b"), + re.compile(r"\bAKIA[0-9A-Z]{16}\b"), +) + + +def _redact_json(value: Any) -> Any: + """Recursively replace values whose JSON keys identify credentials.""" + if isinstance(value, dict): + return { + key: REDACTED if SENSITIVE_KEY_RE.search(str(key)) else _redact_json(item) + for key, item in value.items() + } + if isinstance(value, list): + return [_redact_json(item) for item in value] + return value + + +def _consume_sensitive_assignment(text: str, start: int) -> tuple[str, int] | None: + """Return a redacted key/value assignment parsed in linear time.""" + cursor = start + key_quote = "" + if cursor < len(text) and text[cursor] in "\"'": + key_quote = text[cursor] + cursor += 1 + key_start = cursor + if cursor >= len(text) or text[cursor] not in KEY_CHARS or text[cursor].isdigit(): + return None + while cursor < len(text) and text[cursor] in KEY_CHARS: + cursor += 1 + key = text[key_start:cursor] + if key_quote: + if cursor >= len(text) or text[cursor] != key_quote: + return None + cursor += 1 + if not SENSITIVE_KEY_RE.search(key): + return None + while cursor < len(text) and text[cursor].isspace(): + cursor += 1 + if cursor >= len(text) or text[cursor] not in ":=": + return None + cursor += 1 + while cursor < len(text) and text[cursor].isspace(): + cursor += 1 + if cursor >= len(text): + return None + + value_start = cursor + if text[cursor] in "\"'": + value_quote = text[cursor] + cursor += 1 + escaped = False + while cursor < len(text): + char = text[cursor] + cursor += 1 + if escaped: + escaped = False + elif char == "\\": + escaped = True + elif char == value_quote: + break + else: + while cursor < len(text) and not text[cursor].isspace() and text[cursor] not in ",}": + cursor += 1 + if cursor == value_start: + return None + return text[start:value_start] + REDACTED, cursor + + +def _redact_assignments(text: str) -> str: + """Redact sensitive key/value assignments without backtracking regexes.""" + output: list[str] = [] + cursor = 0 + while cursor < len(text): + match = _consume_sensitive_assignment(text, cursor) + if match is None: + output.append(text[cursor]) + cursor += 1 + continue + replacement, cursor = match + output.append(replacement) + return "".join(output) + + +def _redact_unstructured(text: str) -> str: + """Redact credential-shaped values from non-JSON diagnostic text.""" + cleaned = _redact_assignments(text) + cleaned = BEARER_RE.sub(lambda match: f"{match.group('prefix')}{REDACTED}", cleaned) + cleaned = JWT_RE.sub(REDACTED, cleaned) + for pattern in PROVIDER_TOKEN_RES: + cleaned = pattern.sub(REDACTED, cleaned) + return cleaned + + +def _redact_line(line: str) -> str: + """Redact one log line, preferring recursive JSON handling when valid.""" + try: + value = json.loads(line) + except json.JSONDecodeError: + return _redact_unstructured(line) + return json.dumps(_redact_json(value), ensure_ascii=False, separators=(",", ":")) + + +def redact_text(text: str) -> str: + """Return redacted log text while preserving line boundaries.""" + if not text: + return text + output: list[str] = [] + for raw_line in text.splitlines(keepends=True): + line = raw_line.rstrip("\r\n") + ending = raw_line[len(line) :] + output.append(_redact_line(line) + ending) + return "".join(output) + + +def main() -> int: + """Redact standard input to standard output.""" + sys.stdout.write(redact_text(sys.stdin.read())) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/ci/safe_pytest_command.py b/scripts/ci/safe_pytest_command.py new file mode 100644 index 000000000..a43c8a36e --- /dev/null +++ b/scripts/ci/safe_pytest_command.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +"""Discover and execute configured pytest commands without a shell.""" + +from __future__ import annotations + +import argparse +import json +import os +import pathlib +import re +import shlex +import subprocess +import sys +from collections.abc import Sequence + +RUN_LINE_RE = re.compile(r"\s*(?:-\s*)?run:\s*(.+?)\s*$") +PYTEST_EXECUTABLES = frozenset({"pytest", "py.test"}) +PYTHON_EXECUTABLES = frozenset({"python", "python3"}) +RUNNER_EXECUTABLES = frozenset({"uv", "poetry", "pipenv"}) + + +def _basename(value: str) -> str: + """Return a command token's POSIX basename.""" + return pathlib.PurePosixPath(value).name + + +def _is_pytest_argv(argv: Sequence[str]) -> bool: + """Return whether argv is a supported direct pytest invocation.""" + if not argv: + return False + executable = _basename(argv[0]) + if executable in PYTEST_EXECUTABLES: + return True + if executable in PYTHON_EXECUTABLES: + return len(argv) >= 3 and argv[1:3] == ["-m", "pytest"] + if executable in RUNNER_EXECUTABLES: + return len(argv) >= 3 and argv[1] == "run" and _is_pytest_argv(argv[2:]) + if executable == "coverage": + return len(argv) >= 4 and argv[1:4] == ["run", "-m", "pytest"] + return False + + +def _has_shell_control(value: str) -> bool: + """Return whether one argv token contains shell control syntax.""" + return any(character in value for character in ";&|<>`") or "$(" in value + + +def parse_safe_pytest_command(command: str) -> list[str] | None: + """Parse a supported command into argv, rejecting shell control syntax.""" + try: + argv = shlex.split(command) + except ValueError: + return None + if not argv or any("\n" in arg or "\x00" in arg or _has_shell_control(arg) for arg in argv): + return None + return argv if _is_pytest_argv(argv) else None + + +def discover_commands(workflow_dir: pathlib.Path) -> list[list[str]]: + """Return unique safe one-line pytest argv from ci.yml/ci.yaml files.""" + commands: list[list[str]] = [] + seen: set[tuple[str, ...]] = set() + if not workflow_dir.is_dir(): + return commands + for path in sorted(workflow_dir.glob("ci.y*ml")): + for line in path.read_text(encoding="utf-8", errors="ignore").splitlines(): + match = RUN_LINE_RE.fullmatch(line) + if match is None: + continue + argv = parse_safe_pytest_command(match.group(1).strip()) + key = tuple(argv or ()) + if not argv or key in seen: + continue + seen.add(key) + commands.append(argv) + return commands + + +def execute_command(project_dir: pathlib.Path, argv: Sequence[str]) -> int: + """Execute validated pytest argv directly in one project directory.""" + if not _is_pytest_argv(argv) or any(_has_shell_control(arg) for arg in argv): + raise ValueError("configured command is not a safe direct pytest invocation") + env = os.environ.copy() + env["PYTHONPATH"] = "." + completed = subprocess.run( + list(argv), + cwd=project_dir, + env=env, + shell=False, + check=False, + ) + return completed.returncode + + +def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: + """Parse discovery or execution arguments.""" + parser = argparse.ArgumentParser(description=__doc__) + subparsers = parser.add_subparsers(dest="action", required=True) + discover = subparsers.add_parser("discover") + discover.add_argument("--workflow-dir", required=True, type=pathlib.Path) + execute = subparsers.add_parser("execute") + execute.add_argument("--project-dir", required=True, type=pathlib.Path) + execute.add_argument("--command-json", required=True) + return parser.parse_args(argv) + + +def main(argv: Sequence[str] | None = None) -> int: + """Run configured-command discovery or shell-free execution.""" + args = parse_args(argv) + if args.action == "discover": + for command in discover_commands(args.workflow_dir): + print(json.dumps(command, separators=(",", ":"))) + return 0 + try: + command = json.loads(args.command_json) + except json.JSONDecodeError as exc: + raise SystemExit(f"invalid --command-json: {exc}") from exc + if not isinstance(command, list) or not all(isinstance(arg, str) for arg in command): + raise SystemExit("--command-json must be an array of strings") + print(f"Executing configured pytest argv: {shlex.join(command)}") + return execute_command(args.project_dir, command) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/ci/test_strix_quick_gate.sh b/scripts/ci/test_strix_quick_gate.sh index 8d97ee065..c3db54c09 100755 --- a/scripts/ci/test_strix_quick_gate.sh +++ b/scripts/ci/test_strix_quick_gate.sh @@ -425,6 +425,8 @@ assert_opencode_review_uses_codegraph_and_gpt5_fallback() { assert_file_contains "$workflow_file" "needs.coverage-evidence.result != 'cancelled'" "opencode review does not enqueue stale side-effect jobs after coverage evidence cancellation" assert_file_contains "$workflow_file" "opencode-review-target:" "opencode trusted review job owns the required check surface" assert_file_contains "$workflow_file" "Initialize CodeGraph index for OpenCode" "opencode review workflow initializes CodeGraph before review" + assert_file_contains "$workflow_file" "Validate pull request head repository trust" "opencode privileged review validates the live head repository before token exchange and PR-head tooling" + assert_file_contains "$workflow_file" "refuses external pull request heads before OIDC" "opencode privileged review fails closed for workflow-dispatched fork heads with a visible reason" assert_file_contains "$workflow_file" "actions: read" "opencode review workflow can read failed Actions logs without Actions write scope" assert_file_contains "$workflow_file" "checks: read" "opencode review workflow can read failed check-run annotations for line-specific findings" assert_file_contains "$workflow_file" "contents: read" "opencode review workflow uses read-only repository contents permission" @@ -808,7 +810,7 @@ assert_opencode_review_uses_codegraph_and_gpt5_fallback() { assert_file_contains "$workflow_file" 'cd "$1" && uv run --with-requirements requirements.txt' "opencode coverage evidence resolves requirements inside uv-managed project environments" assert_file_contains "$workflow_file" "--extra dev" "opencode coverage evidence installs pyproject optional dev extras when repositories do not use dependency-groups" assert_file_contains "$workflow_file" "configured_python_ci_test_commands()" "opencode coverage evidence prefers repository-configured CI pytest commands before falling back to the full tests tree" - assert_file_contains "$workflow_file" 'workflow_dir.glob("ci.y*ml")' "opencode coverage evidence reads default CI workflow pytest commands" + assert_file_contains "$workflow_file" 'safe_pytest_command.py" discover' "opencode coverage evidence discovers default CI workflow pytest commands through the trusted shell-free parser" assert_file_contains "$workflow_file" "Python configured CI test suite" "opencode coverage evidence labels repository-configured pytest evidence separately" assert_file_contains "$workflow_file" 'cd "$1" && PYTHONPATH=. uv run pytest tests' "opencode coverage evidence runs uv-managed Python project tests inside their project environment" assert_file_contains "$workflow_file" 'cd "$1" && PYTHONPATH=. uv run --with-requirements requirements.txt --with coverage --with pytest coverage run -m pytest tests' "opencode coverage evidence runs requirements-only Python project coverage inside its dependency environment" @@ -918,7 +920,7 @@ assert_opencode_review_uses_codegraph_and_gpt5_fallback() { assert_file_contains "$REPO_ROOT/scripts/ci/collect_failed_check_evidence.sh" 'lower_failed_conclusion' "failed-check evidence only relaxes run-id ordering for cancelled Strix helper runs" assert_file_contains "$REPO_ROOT/scripts/ci/collect_failed_check_evidence.sh" '[ "$failed_run_id" -ge "$success_run_id" ]' "failed-check evidence still uses run id ordering for non-cancelled superseded runs" assert_file_contains "$REPO_ROOT/scripts/ci/collect_failed_check_evidence.sh" 'redact_sensitive_log()' "failed-check evidence redacts sensitive values before emitting logs" - assert_file_contains "$REPO_ROOT/scripts/ci/collect_failed_check_evidence.sh" '[REDACTED_GITHUB_TOKEN]' "failed-check evidence redacts GitHub token patterns" + assert_file_contains "$REPO_ROOT/scripts/ci/collect_failed_check_evidence.sh" 'redact_sensitive_log.py' "failed-check evidence delegates structured token and JSON credential redaction to the tested scrubber" assert_file_contains "$REPO_ROOT/scripts/ci/collect_failed_check_evidence.sh" 'redact_sensitive_log >"$log_clean"' "failed-check evidence redacts collected job logs before summaries" assert_file_contains "$REPO_ROOT/scripts/ci/collect_failed_check_evidence.sh" 'awk -F '"'"'\t'"'"' -v run_id="$run_id"' "failed-check evidence avoids duplicate workflow-run evidence when statusCheckRollup already includes the run" assert_file_not_contains "$REPO_ROOT/scripts/ci/collect_failed_check_evidence.sh" '[[ ! "$run_id" =~ ^[0-9]+$ ]]' "failed-check evidence no longer suppresses failed contexts as superseded" diff --git a/tests/test_opencode_agent_contract.py b/tests/test_opencode_agent_contract.py index 6b1bd0241..c1f84af32 100644 --- a/tests/test_opencode_agent_contract.py +++ b/tests/test_opencode_agent_contract.py @@ -988,7 +988,7 @@ def test_opencode_runs_merge_scheduler_after_review_without_repo_local_dispatch( assert "statuses: write" in workflow assert 'context="opencode-review"' in workflow assert 'repos/${GH_REPOSITORY}/statuses/${PR_HEAD_SHA}' in workflow - assert "OpenCode workflow_dispatch evidence passed for current head." in workflow + assert "OpenCode live approval evidence validation failed." in workflow assert "python3 scripts/ci/pr_review_merge_scheduler.py" in workflow assert "gh workflow run pr-review-merge-scheduler.yml" not in workflow assert "github.event_name == 'pull_request_target'" in workflow @@ -1006,6 +1006,12 @@ def test_opencode_runs_merge_scheduler_after_review_without_repo_local_dispatch( in status_step ) assert "using %s token" in status_step + assert "scripts/ci/opencode_dispatch_status.py" in status_step + assert "COVERAGE_EVIDENCE_RESULT" in status_step + assert 'gh api "repos/${GH_REPOSITORY}/pulls/${PR_NUMBER}"' in status_step + assert 'gh api "repos/${GH_REPOSITORY}/pulls/${PR_NUMBER}/reviews"' in status_step + assert '[ "${OPENCODE_MODEL_POOL_OUTCOME:-}" != "success" ] &&' not in status_step + assert '[ "${OPENCODE_MODEL_POOL_OUTCOME:-}" != "exhausted" ]' not in status_step assert "SCHEDULER_ACTIONS_TOKEN: ${{ github.token }}" in workflow assert ( "SCHEDULER_READ_TOKEN: ${{ (github.event_name == 'pull_request_target' || " @@ -1023,6 +1029,55 @@ def test_opencode_runs_merge_scheduler_after_review_without_repo_local_dispatch( assert "--no-trigger-reviews" in workflow assert "--enable-auto-merge" in workflow assert "--no-update-branches" in workflow + + +def test_opencode_privileged_review_security_boundaries_are_fail_closed(): + """Guard the Strix-proven command, fork, package, and output-file boundaries.""" + workflow = Path(".github/workflows/opencode-review.yml").read_text(encoding="utf-8") + coverage_start = workflow.index(" coverage-evidence:\n") + coverage_end = workflow.index("\n opencode-review-target:", coverage_start) + coverage_job = workflow[coverage_start:coverage_end] + target_start = coverage_end + 1 + target_end = workflow.index("\n opencode-exhausted-retry:", target_start) + target_job = workflow[target_start:target_end] + + assert 'scripts/ci/safe_pytest_command.py" discover' in coverage_job + assert 'scripts/ci/safe_pytest_command.py" execute' in coverage_job + assert 'PYTHONPATH=. bash -lc "$2"' not in coverage_job + assert "COVERAGE_EOF" not in coverage_job + assert "os.urandom(24).hex()" in coverage_job + assert 'grep -Fqx "$coverage_output_delimiter" "$summary_file"' in coverage_job + + assert ( + "github.event.pull_request.head.repo.full_name == " + "github.event.pull_request.base.repo.full_name" + ) in target_job + trust_step = target_job.split(" - name: Validate pull request head repository trust", 1)[1].split( + "\n - name:", 1 + )[0] + assert ".head.repo.full_name // empty" in trust_step + assert ".base.repo.full_name // empty" in trust_step + assert "refuses external pull request heads before OIDC" in trust_step + assert target_job.index("Validate pull request head repository trust") < target_job.index( + "Exchange OpenCode app token for target repository review reads" + ) + codegraph_step = target_job.split(" - name: Initialize CodeGraph index for OpenCode", 1)[1].split( + "\n - name:", 1 + )[0] + assert "CODEGRAPH_TRUSTED_ROOT" in codegraph_step + assert "cp scripts/ci/codegraph-package/package.json" in codegraph_step + assert "scripts/ci/codegraph-package/package-lock.json" in codegraph_step + assert 'npm ci --ignore-scripts --omit=dev --prefix "$CODEGRAPH_TRUSTED_ROOT"' in codegraph_step + assert '"$CODEGRAPH_BIN" init -i' in codegraph_step + assert '"$CODEGRAPH_BIN" status' in codegraph_step + assert "npm install --ignore-scripts --no-save" not in codegraph_step + assert 'npx -y "$CODEGRAPH_PACKAGE" init -i' not in codegraph_step + package_lock = json.loads( + Path("scripts/ci/codegraph-package/package-lock.json").read_text(encoding="utf-8") + ) + codegraph_package = package_lock["packages"]["node_modules/@colbymchenry/codegraph"] + assert codegraph_package["version"] == "0.9.9" + assert codegraph_package["integrity"].startswith("sha512-") assert "Merge scheduler follow-up skipped after approval because no mutation credential was available" in workflow diff --git a/tests/test_opencode_security_boundaries.py b/tests/test_opencode_security_boundaries.py new file mode 100644 index 000000000..8253373f6 --- /dev/null +++ b/tests/test_opencode_security_boundaries.py @@ -0,0 +1,334 @@ +"""Regression tests for privileged OpenCode workflow security boundaries.""" + +from __future__ import annotations + +import io +import json +import runpy +import subprocess +import sys +from pathlib import Path + +import pytest + +from scripts.ci import opencode_dispatch_status as dispatch_status +from scripts.ci import redact_sensitive_log as redactor +from scripts.ci import safe_pytest_command as safe_pytest + + +def _synthetic_jwt() -> str: + """Build JWT-shaped fixture text without committing secret-looking literals.""" + return ".".join(("header", "payload", "signature")) + + +def test_sensitive_log_redaction_handles_json_credentials_and_jwts() -> None: + """Structured credentials and provider-independent JWTs never survive evidence redaction.""" + jwt_fixture = _synthetic_jwt() + secrets = { + "token": jwt_fixture, + "jwt": jwt_fixture, + "oidc_token": jwt_fixture, + "api_credential": "fixture-api-credential", + "client_secret": "fixture-client-secret", + "MY_SERVICE_TOKEN": "fixture-service-token", + } + cleaned = redactor.redact_text(json.dumps({"nested": secrets})) + + assert all(value not in cleaned for value in secrets.values()) + assert set(json.loads(cleaned)["nested"].values()) == {redactor.REDACTED} + + +def test_sensitive_log_redaction_preserves_normal_diagnostics() -> None: + """Ordinary failure reasons remain visible while credentials are removed.""" + source = ( + "build failed for package requests==2.31.0\n" + "Authorization: Bearer abc.def.ghi\n" + "SERVICE_TOKEN=opaque_service_value_123456789\n" + ) + cleaned = redactor.redact_text(source) + + assert "build failed for package requests==2.31.0" in cleaned + assert "abc.def.ghi" not in cleaned + assert "opaque_service_value_123456789" not in cleaned + assert cleaned.count(redactor.REDACTED) >= 2 + + +def test_sensitive_log_redaction_handles_adversarial_quoted_values() -> None: + """Quoted sensitive assignments are parsed linearly even with many escapes.""" + source = "_jwt:\"" + "\\!" * 5000 + cleaned = redactor.redact_text(source) + + assert "\\!" not in cleaned + assert cleaned == f"_jwt:{redactor.REDACTED}" + + +def test_sensitive_log_redaction_assignment_parser_edges_remain_auditable() -> None: + """Malformed assignments remain parseable while valid quoted secrets are scrubbed.""" + cases = { + "'jwt': visible": f"'jwt': {redactor.REDACTED}", + "'jwt: visible": f"'jwt: {redactor.REDACTED}", + "token : visible": f"token : {redactor.REDACTED}", + "token visible": "token visible", + "token=": "token=", + "token=,": "token=,", + } + + for source, expected in cases.items(): + assert redactor.redact_text(source) == expected + + assert redactor.redact_text('token="safe\\"inside" trailing') == ( + f"token={redactor.REDACTED} trailing" + ) + + +def test_sensitive_log_redaction_handles_lists_empty_input_and_cli(monkeypatch: pytest.MonkeyPatch) -> None: + """Recursive lists, empty input, and the streaming CLI share the same scrubber.""" + source = '{"values":[{"ok":1,"api_key":"secret-value"},2]}\n' + assert redactor.redact_text("") == "" + assert json.loads(redactor.redact_text(source))["values"] == [ + {"ok": 1, "api_key": redactor.REDACTED}, + 2, + ] + + stdin = io.StringIO("SERVICE_TOKEN=opaque-service-token-value\n") + stdout = io.StringIO() + monkeypatch.setattr(redactor.sys, "stdin", stdin) + monkeypatch.setattr(redactor.sys, "stdout", stdout) + assert redactor.main() == 0 + assert "opaque-service-token-value" not in stdout.getvalue() + + monkeypatch.setattr(sys, "argv", ["redact_sensitive_log.py"]) + monkeypatch.setattr(sys, "stdin", io.StringIO("password=hunter2\n")) + monkeypatch.setattr(sys, "stdout", io.StringIO()) + with pytest.raises(SystemExit) as exc: + runpy.run_path("scripts/ci/redact_sensitive_log.py", run_name="__main__") + assert exc.value.code == 0 + + +@pytest.mark.parametrize( + ("command", "expected"), + [ + ("pytest -q tests", ["pytest", "-q", "tests"]), + ("python3 -m pytest tests/unit", ["python3", "-m", "pytest", "tests/unit"]), + ("uv run pytest -q", ["uv", "run", "pytest", "-q"]), + ("coverage run -m pytest tests", ["coverage", "run", "-m", "pytest", "tests"]), + ], +) +def test_safe_pytest_parser_accepts_supported_argv(command: str, expected: list[str]) -> None: + """Legitimate pytest invocations are preserved as direct argv.""" + assert safe_pytest.parse_safe_pytest_command(command) == expected + + +def test_safe_pytest_argv_classifier_rejects_empty_argv() -> None: + """The direct-execution classifier fails closed for an empty command.""" + assert safe_pytest._is_pytest_argv([]) is False + + +@pytest.mark.parametrize( + "command", + [ + 'pytest ; printf PWNED > "$RUNNER_TEMP/injected"', + "pytest && curl https://attacker.invalid", + "bash -lc pytest", + "curl pytest", + "pytest `id`", + "pytest $(id)", + "pytest 'unterminated", + "", + ], +) +def test_safe_pytest_parser_rejects_shell_and_non_pytest_execution(command: str) -> None: + """PR-controlled shell syntax and unrelated executables are not accepted.""" + assert safe_pytest.parse_safe_pytest_command(command) is None + + +def test_safe_pytest_executor_never_uses_a_shell(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """The realistic configured-command boundary executes validated argv with shell disabled.""" + observed: dict[str, object] = {} + + def fake_run(argv, *, cwd, env, shell, check): + observed.update(argv=argv, cwd=cwd, env=env, shell=shell, check=check) + return subprocess.CompletedProcess(argv, 0) + + monkeypatch.setattr(safe_pytest.subprocess, "run", fake_run) + assert safe_pytest.execute_command(tmp_path, ["pytest", "-q", "tests"]) == 0 + assert observed["argv"] == ["pytest", "-q", "tests"] + assert observed["cwd"] == tmp_path + assert observed["shell"] is False + assert observed["check"] is False + assert observed["env"]["PYTHONPATH"] == "." + + +def test_configured_pytest_discovery_drops_injected_workflow_command(tmp_path: Path) -> None: + """Only supported one-line pytest argv are returned from a PR-controlled workflow file.""" + workflow_dir = tmp_path / ".github" / "workflows" + workflow_dir.mkdir(parents=True) + (workflow_dir / "ci.yml").write_text( + "steps:\n" + " - run: pytest -q tests\n" + " - run: pytest -q tests\n" + " - run: pytest ; printf PWNED > /tmp/injected\n" + " - run: curl pytest\n", + encoding="utf-8", + ) + + assert safe_pytest.discover_commands(workflow_dir) == [["pytest", "-q", "tests"]] + assert safe_pytest.discover_commands(tmp_path / "missing") == [] + + +def test_safe_pytest_cli_paths_and_invalid_execution( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """Discovery and execution CLI paths reject malformed or unsafe JSON.""" + workflow_dir = tmp_path / ".github" / "workflows" + workflow_dir.mkdir(parents=True) + (workflow_dir / "ci.yml").write_text("run: python -m pytest -q\n", encoding="utf-8") + + assert safe_pytest.main(["discover", "--workflow-dir", str(workflow_dir)]) == 0 + assert json.loads(capsys.readouterr().out) == ["python", "-m", "pytest", "-q"] + + real_execute_command = safe_pytest.execute_command + monkeypatch.setattr(safe_pytest, "execute_command", lambda project_dir, argv: 7) + assert safe_pytest.main( + ["execute", "--project-dir", str(tmp_path), "--command-json", '["pytest","-q"]'] + ) == 7 + assert "Executing configured pytest argv" in capsys.readouterr().out + + with pytest.raises(SystemExit, match="invalid --command-json"): + safe_pytest.main( + ["execute", "--project-dir", str(tmp_path), "--command-json", "not-json"] + ) + with pytest.raises(SystemExit, match="array of strings"): + safe_pytest.main( + ["execute", "--project-dir", str(tmp_path), "--command-json", '{"pytest":true}'] + ) + with pytest.raises(ValueError, match="safe direct pytest"): + real_execute_command(tmp_path, ["bash", "-lc", "pytest"]) + + monkeypatch.setattr( + sys, + "argv", + ["safe_pytest_command.py", "discover", "--workflow-dir", str(tmp_path / "missing")], + ) + with pytest.raises(SystemExit) as exc: + runpy.run_path("scripts/ci/safe_pytest_command.py", run_name="__main__") + assert exc.value.code == 0 + + +def approval_review(head_sha: str, **overrides: object) -> dict[str, object]: + """Build one exact-current-head OpenCode approval review.""" + review: dict[str, object] = { + "state": "APPROVED", + "commit_id": head_sha, + "user": {"login": "opencode-agent[bot]"}, + "body": f"- Result: APPROVE\n- Head SHA: `{head_sha}`", + } + review.update(overrides) + return review + + +def test_dispatch_status_requires_live_current_head_approval_and_coverage() -> None: + """A workflow-dispatch status succeeds only for the validated approval boundary.""" + head = "a" * 40 + decision = dispatch_status.decide_status( + model_outcome="success", + coverage_result="success", + expected_head=head, + pull_request={"head": {"sha": head}}, + reviews=[approval_review(head)], + ) + + assert decision["state"] == "success" + assert "validated" in decision["description"].lower() + + +def test_dispatch_status_latest_current_head_decision_is_authoritative() -> None: + """A later current-head change request supersedes an earlier approval.""" + head = "a" * 40 + reviews = [ + approval_review(head), + approval_review(head, state="CHANGES_REQUESTED"), + ] + + decision = dispatch_status.decide_status( + model_outcome="success", + coverage_result="success", + expected_head=head, + pull_request={"head": {"sha": head}}, + reviews=reviews, + ) + + assert decision["state"] == "failure" + + +@pytest.mark.parametrize( + ("model_outcome", "coverage_result", "live_head", "review_overrides"), + [ + ("exhausted", "success", "current", {}), + ("success", "failure", "current", {}), + ("success", "success", "stale", {}), + ("success", "success", "current", {"state": "CHANGES_REQUESTED"}), + ("success", "success", "current", {"commit_id": "b" * 40}), + ("success", "success", "current", {"user": {"login": "pull-request-author"}}), + ("success", "success", "current", {"body": "Looks good"}), + ], +) +def test_dispatch_status_fails_closed_without_validated_approval( + model_outcome: str, + coverage_result: str, + live_head: str, + review_overrides: dict[str, object], +) -> None: + """Negative, exhausted, stale, untrusted, and incomplete evidence cannot publish success.""" + head = "a" * 40 + observed_head = head if live_head == "current" else "c" * 40 + decision = dispatch_status.decide_status( + model_outcome=model_outcome, + coverage_result=coverage_result, + expected_head=head, + pull_request={"head": {"sha": observed_head}}, + reviews=[approval_review(head, **review_overrides)], + ) + + assert decision["state"] == "failure" + assert decision["description"] + + +def test_dispatch_status_cli_and_evidence_shape_validation( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """The workflow-facing CLI emits JSON and rejects malformed evidence shapes.""" + head = "a" * 40 + pr_file = tmp_path / "pr.json" + reviews_file = tmp_path / "reviews.json" + pr_file.write_text(json.dumps({"head": {"sha": head}}), encoding="utf-8") + reviews_file.write_text(json.dumps([approval_review(head)]), encoding="utf-8") + args = [ + "--model-outcome", + "success", + "--coverage-result", + "success", + "--expected-head", + head, + "--pull-request-file", + str(pr_file), + "--reviews-file", + str(reviews_file), + ] + + assert dispatch_status.main(args) == 0 + assert json.loads(capsys.readouterr().out)["state"] == "success" + + reviews_file.write_text("{}", encoding="utf-8") + with pytest.raises(SystemExit, match="reviews evidence an array"): + dispatch_status.main(args) + + reviews_file.write_text(json.dumps([approval_review(head)]), encoding="utf-8") + monkeypatch.setattr(sys, "argv", ["opencode_dispatch_status.py", *args]) + with pytest.raises(SystemExit) as exc: + runpy.run_path("scripts/ci/opencode_dispatch_status.py", run_name="__main__") + assert exc.value.code == 0