diff --git a/.github/workflows/opencode-review-dispatch.yml b/.github/workflows/opencode-review-dispatch.yml index 83f6830d5..61d50dd5b 100644 --- a/.github/workflows/opencode-review-dispatch.yml +++ b/.github/workflows/opencode-review-dispatch.yml @@ -4371,6 +4371,7 @@ jobs: OPENCODE_REVIEW_WORKDIR: ${{ runner.temp }}/opencode-review-project OPENCODE_SOURCE_WORKDIR: ${{ runner.temp }}/opencode-pr-head PR_NUMBER: ${{ needs.validate-pr-metadata.outputs.pr_number }} + OPENCODE_REPOSITORY_IS_PRIVATE: ${{ needs.validate-pr-metadata.outputs.is_private }} PR_BASE_SHA: ${{ needs.validate-pr-metadata.outputs.base_sha }} PR_HEAD_SHA: ${{ needs.validate-pr-metadata.outputs.head_sha }} HEAD_SHA: ${{ needs.validate-pr-metadata.outputs.head_sha }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 1de9130a5..03f9939d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,8 @@ Semantic Versioning where the repository publishes a release. ### Fixed +- Pass trusted `validate-pr-metadata` repository visibility into the live OpenCode model-pool runner so public reviews keep the governed free catalog without a credential-free `ls-remote` probe. +- Allow governed anonymous OpenCode free-model candidates on private repositories only when an unchanged trusted-base policy opts in, and wrap `opencode` with a provider-scoped credential guard. - Materialized base Python locks only when every package line is an exact SHA-256 pin or a bounded relative `-r`/`--requirement` include. A lone `--require-hashes` directive, a dotted include such as `./lock.txt`, or `-r other-hashes.txt` no longer enters the trusted build context. - Refused a conflict-scope repository root whose immediate parent is a symbolic link, so a swapped parent cannot redirect the canonical worktree after the last-component check (CWE-367). - Bounded the Strix quality self-test's deterministic timeout fixtures to 3-second process and 5-second fake-sleep budgets so exact-head policy evidence completes inside the existing job limit without changing production Strix scanner timeouts, providers, credentials, or review semantics. diff --git a/docs/doctoring/opencode-private-free-model-policy.md b/docs/doctoring/opencode-private-free-model-policy.md new file mode 100644 index 000000000..1d65d814d --- /dev/null +++ b/docs/doctoring/opencode-private-free-model-policy.md @@ -0,0 +1,246 @@ +# OpenCode private-repository free-model policy + +**Status:** Implemented design decision +**Decision date:** 2026-08-08 +**Scope:** `ContextualWisdomLab/.github` OpenCode review control plane + +## Decision + +Materialize accepts only exact SHA-256 pins or a bounded relative `-r` include; a lone `--require-hashes` line is not lock evidence. + +A private repository may use the anonymous `opencode-free/*` review pool only when +its trusted pull-request base commit contains the exact policy file below and the +reviewed head does not add, remove, rename, chmod, or modify that file. + +```text +.github/opencode-private-free-models.json +``` + +```json +{ + "schema_version": 1, + "allow_private_free_models": true, + "repository_data_classification": "public_equivalent", + "external_model_data_use_accepted": true +} +``` + +The declaration means all tracked repository content that OpenCode can read is +approved for processing under the external free-model terms as though it were +public. It does **not** mean the repository becomes public, and it is not a +claim that a scanner proved the absence of every secret or confidential fact. + +The pull request that introduces or changes the policy remains ineligible. The +policy takes effect only after that change has passed normal review and reached +the base branch, on a subsequent pull request. Protect this file with normal +branch protection and, where available, `CODEOWNERS` review. + +## Why an explicit policy is required + +Repository visibility and data sensitivity are different attributes. A private +repository may contain only non-sensitive open-source work, while another may +contain customer data, personal data, unreleased intellectual property, access +credentials, or confidential architecture. The absence of configured GitHub +Actions secrets does not classify the source tree. + +GitHub secret scanning is an important independent control, but it detects +supported patterns rather than proving that no confidential information exists. +For organization-owned private and internal repositories, secret scanning +requires GitHub Secret Protection on an eligible plan. Therefore, model egress +is enabled by an auditable data-owner declaration, not by a heuristic scan or by +repository visibility alone. + +NIST SP 800-53 Rev. 5 AC-3 requires access enforcement against an explicit +policy, and SC-8 requires confidentiality of information in transit (Joint Task +Force, 2020). A private GitHub repository is therefore not an implicit +authorization to send source to an external free-model endpoint. The trusted +base-branch declaration is the access policy; a head that adds or edits that +file cannot authorize its own egress. + +## Provider-data and catalog boundary + +OpenCode documents free models as limited offerings used to collect feedback or +improve models. Its privacy documentation warns that some free endpoints may +retain or use collected data and that personal or confidential data must not be +submitted. Accordingly, the policy is restricted to `public_equivalent` +repositories and requires explicit acceptance of external-model data use. + +The governed anonymous pool is synchronized to the zero-cost OpenCode Zen catalog +published in the primary Zen documentation. At the current decision revision it +contains exactly these seven aliases: + +1. `opencode-free/nemotron-3-ultra-free` +2. `opencode-free/deepseek-v4-flash-free` +3. `opencode-free/north-mini-code-free` +4. `opencode-free/laguna-s-2.1-free` +5. `opencode-free/ling-3.0-flash-free` +6. `opencode-free/big-pickle` +7. `opencode-free/mimo-v2.5-free` + +Aliases previously carried as `hy3-free`, `minimax-m3-free`, `glm-5-free`, +`kimi-k2.5-free`, and `qwen3.6-plus-free` are not in the current documented +zero-cost catalog and are therefore removed before model selection. The wrapper +never infers that an arbitrary `opencode-free/*` prefix is actually free. A +catalog change requires an independently reviewable source update. + +Candidate availability is still runtime-dependent. A provider rejection or +retirement remains ordinary bounded fallback evidence; it does not weaken review +or merge gates. + +## Repository-visibility boundary + +Preconfigured anonymous candidates are not themselves authorization. The wrapper +first needs positive visibility evidence: + +- the live model-pool step MUST export + `OPENCODE_REPOSITORY_IS_PRIVATE` from + `needs.validate-pr-metadata.outputs.is_private` next to `PR_BASE_SHA`; +- a trusted caller may provide `OPENCODE_REPOSITORY_IS_PRIVATE=false`; or +- when that trusted signal is absent, the wrapper may prove only the **public** + case by performing a credential-free `git ls-remote` against a strictly + validated `https://github.com/ContextualWisdomLab/[.git]` origin. + A timeout or transport failure on a public ContextualWisdomLab origin is not + the production authorization path. + +`true`, malformed visibility input, private/auth-required Git access, timeout, +transport failure, missing remote metadata, or any other indeterminate outcome is +fail-closed. The wrapper removes every preconfigured anonymous candidate and the +unchanged trusted-base policy becomes the sole re-enable path. The public probe +runs with GitHub, Actions, model-provider, and OIDC credentials removed and with +Git credential helpers disabled. + +This preserves public-repository behavior without treating an untrusted candidate +list as visibility evidence and prevents a private caller from bypassing policy +by pre-populating `OPENCODE_MODEL_CANDIDATES`. + +## Credential boundary + +Each OpenCode subprocess receives only the credential for its selected provider. +In particular, an anonymous `opencode-free/*` process receives none of these +values: + +- GitHub tokens +- GitHub Actions OIDC request credentials +- GitHub Actions runtime, cache, or results credentials +- OpenCode application tokens +- NVIDIA NIM keys +- OpenCode Zen keys +- OpenAI keys +- OpenRouter keys +- GitHub Models tokens + +Session export runs without any provider credential. Unknown future provider +prefixes also default to zero provider credentials until they are explicitly +classified. The guard recognizes OpenCode's long and short model selectors +(`--model`, `--model=`, `-m`, and `-m=`), rejects duplicate or missing model +selectors, and stops option parsing at `--` so argument text cannot accidentally +change credential selection. + +The model remains read-only under the existing OpenCode review agent contract. +Credential isolation does not make confidential source safe to send to an +external model; the repository-level data classification remains the primary +eligibility control. + +## Fail-closed validation + +The policy checker: + +- accepts only full 40-character base and head commit SHAs; +- reads the policy directly from the immutable base Git tree; +- rejects a policy changed by the current head; +- requires the `git ls-tree -z` response to be exactly one NUL-terminated record, + rejecting truncated or extra records rather than reconstructing delimiters; +- accepts only one regular, non-executable `100644` blob at the fixed path; +- limits the blob to 4,096 bytes; +- requires strict UTF-8 and JSON without duplicate keys; +- rejects missing or unknown fields and requires the exact canonical values; +- ignores system and user Git configuration and disables hooks and filesystem + monitors during evaluation; +- removes preconfigured anonymous candidates on private or unverified calls before + policy evaluation; and +- leaves the existing keyed/private fallback pool unchanged on every denial or + local evaluation error. + +The model-pool boundary also validates integer runtime, retry, cycle, and export +controls before shell arithmetic or `timeout` consumption. Malformed values fall +back to reviewed defaults rather than reaching Bash arithmetic or busy-looping a +runner. + +## Operating procedure + +1. Confirm the repository contains no credentials, personal data, customer data, + confidential documents, restricted source, or other data prohibited by the + free-model terms. +2. Resolve active secret-scanning alerts and enable Secret Protection, push + protection, generic patterns, and organization-specific custom patterns where + available. +3. Add the exact policy file in a separately reviewed pull request. +4. Merge that policy through normal branch protection. Its own pull request will + not use the private free pool. +5. On a later pull request, verify the OpenCode log records that the unchanged + trusted base policy enabled the anonymous candidates and verify the selected + child environment contains no GitHub, Actions, OIDC, or provider credentials. +6. Run a private negative control without the policy and verify anonymous + candidates remain disabled while configured keyed fallbacks remain available. +7. To disable the feature, remove or change the policy through a normal pull + request. The change takes effect after merge; the policy-changing pull request + itself remains fail-closed. + +## Rejected alternatives + +### Infer eligibility from missing Actions secrets + +Rejected because repository source, history, fixtures, issues, and generated +review evidence may be confidential even when no Actions secret is configured. + +### Let the current pull-request head add an opt-in marker + +Rejected because untrusted code could authorize its own external disclosure. +The marker must already exist on the base and remain unchanged in the head. + +### Trust a preconfigured `opencode-free/*` candidate as proof of eligibility + +Rejected because candidate text is not a data-classification or visibility +signal. Private or unverified callers must pass the immutable-base policy gate. + +### Send all provider keys and rely on agent instructions + +Rejected because a model process does not need unrelated credentials. Provider +selection is enforced in the process environment rather than by prompt text. + +### Treat secret scanning as a proof of public-equivalent data + +Rejected because secret scanning is a defense-in-depth detector, not a complete +information-classification system. + +## Verification evidence + +The implementation includes tests for valid base policy activation, missing and +self-added policies, head mutations, unknown and weaker declarations, duplicate +JSON keys, symlinks, oversized blobs, malformed UTF-8 and JSON, Git failures, +truncated and extra `ls-tree -z` records, provider-specific credential retention, +anonymous free credential removal, short and long model selectors, option +termination, export isolation, unknown-provider fail-safe behavior, private +preconfigured-free bypass rejection, catalog filtering, visibility fail-closed +behavior, runtime integer controls, and wrapper ordering. + +Operational acceptance remains separate from code-level tests. Issue #833 tracks +the required protected-base private canary, negative control, credential-absence +evidence, schema/evidence validation, independent review/protection gates, and +rollback rehearsal. + +## References + +Joint Task Force. (2020). *Security and privacy controls for information systems +and organizations* (NIST SP 800-53 Rev. 5). National Institute of Standards and +Technology. https://doi.org/10.6028/NIST.SP.800-53r5 + +GitHub. (n.d.-a). *Enabling secret scanning for your repository*. GitHub Docs. +Retrieved August 8, 2026, from +https://docs.github.com/en/code-security/how-tos/secure-your-secrets/detect-secret-leaks/enable-secret-scanning + +GitHub. (n.d.-b). *Secrets*. GitHub Docs. Retrieved August 8, 2026, from +https://docs.github.com/en/actions/concepts/security/secrets + +OpenCode. (n.d.). *Zen*. Retrieved August 9, 2026, from +https://opencode.ai/docs/zen diff --git a/docs/examples/opencode-private-free-models.json b/docs/examples/opencode-private-free-models.json new file mode 100644 index 000000000..7c4b446e9 --- /dev/null +++ b/docs/examples/opencode-private-free-models.json @@ -0,0 +1,6 @@ +{ + "schema_version": 1, + "allow_private_free_models": true, + "repository_data_classification": "public_equivalent", + "external_model_data_use_accepted": true +} diff --git a/scripts/ci/opencode_private_free_model_hook.sh b/scripts/ci/opencode_private_free_model_hook.sh new file mode 100755 index 000000000..66553c1f8 --- /dev/null +++ b/scripts/ci/opencode_private_free_model_hook.sh @@ -0,0 +1,204 @@ +#!/usr/bin/env bash +# Thin hook sourced by the live model-pool runner. Do not replace the pool. + +anonymous_free_candidates="opencode-free/nemotron-3-ultra-free opencode-free/deepseek-v4-flash-free opencode-free/north-mini-code-free opencode-free/laguna-s-2.1-free opencode-free/ling-3.0-flash-free opencode-free/big-pickle opencode-free/mimo-v2.5-free" + +die() { + printf '%s\n' "$1" >&2 + exit 1 +} + +candidate_list_contains_anonymous_free_model() { + local candidate + local -a candidates + read -r -a candidates <<<"${OPENCODE_MODEL_CANDIDATES:-}" + for candidate in "${candidates[@]}"; do + case "$candidate" in + opencode-free/*) + return 0 + ;; + esac + done + return 1 +} + +is_governed_anonymous_free_candidate() { + local candidate="$1" + case " $anonymous_free_candidates " in + *" $candidate "*) return 0 ;; + *) return 1 ;; + esac +} + +filter_preconfigured_anonymous_free_candidates() { + local allow_governed_free="$1" + local combined="" + local candidate + local -a candidates + read -r -a candidates <<<"${OPENCODE_MODEL_CANDIDATES:-}" + for candidate in "${candidates[@]}"; do + case "$candidate" in + opencode-free/*) + if [ "$allow_governed_free" != "true" ] || + ! is_governed_anonymous_free_candidate "$candidate"; then + continue + fi + ;; + esac + combined="${combined:+$combined }$candidate" + done + OPENCODE_MODEL_CANDIDATES="$combined" + export OPENCODE_MODEL_CANDIDATES +} + +prepend_unique_anonymous_free_candidates() { + local combined="" + local candidate + local -a candidates + read -r -a candidates <<<"$anonymous_free_candidates ${OPENCODE_MODEL_CANDIDATES:-}" + for candidate in "${candidates[@]}"; do + case " $combined " in + *" $candidate "*) + ;; + *) + combined="${combined:+$combined }$candidate" + ;; + esac + done + OPENCODE_MODEL_CANDIDATES="$combined" + export OPENCODE_MODEL_CANDIDATES +} + +source_repository_is_public_without_credentials() { + local source_workdir="${OPENCODE_SOURCE_WORKDIR:-}" + local remote_url + [ -n "$source_workdir" ] && [ -d "$source_workdir/.git" ] || return 1 + remote_url="$( + git -c credential.helper= -C "$source_workdir" remote get-url origin 2>/dev/null || true + )" + if ! [[ "$remote_url" =~ ^https://github\.com/ContextualWisdomLab/[A-Za-z0-9_.-]+(\.git)?$ ]]; then + return 1 + fi + + # Positive unauthenticated Git access is sufficient evidence that the source is + # public. Any timeout, transport failure, private auth requirement, or malformed + # remote is deliberately indistinguishable here and fails closed. + timeout --kill-after=5s 15s \ + env -u GH_TOKEN -u GITHUB_TOKEN -u OPENCODE_APP_TOKEN \ + -u ACTIONS_ID_TOKEN_REQUEST_TOKEN -u ACTIONS_ID_TOKEN_REQUEST_URL \ + -u ACTIONS_RUNTIME_TOKEN -u STRIX_GITHUB_MODELS_TOKEN \ + -u OPENCODE_API_KEY -u OPENAI_API_KEY -u OPENROUTER_API_KEY \ + -u NVIDIA_API_KEY -u NVIDIA_NIM_API_KEY \ + GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_GLOBAL=/dev/null GIT_TERMINAL_PROMPT=0 \ + git -c credential.helper= -c http.extraHeader= ls-remote "$remote_url" HEAD \ + >/dev/null 2>&1 +} + +repository_visibility_is_public() { + case "${OPENCODE_REPOSITORY_IS_PRIVATE:-}" in + false) + return 0 + ;; + true) + return 1 + ;; + "") + source_repository_is_public_without_credentials + return $? + ;; + *) + printf '::warning::OpenCode repository visibility input is invalid; anonymous free candidates require trusted-base policy approval.\n' >&2 + return 1 + ;; + esac +} + +maybe_enable_private_free_models() { + if repository_visibility_is_public; then + # Public callers may keep only currently governed zero-cost aliases. Unknown + # `opencode-free/*` names are removed so catalog drift cannot become paid or + # model-unavailable traffic under a misleading free prefix. + filter_preconfigured_anonymous_free_candidates true + return 0 + fi + + # Private or unverified callers never inherit a preconfigured anonymous + # candidate. The immutable base policy below is the only re-enable path. + filter_preconfigured_anonymous_free_candidates false + + local source_workdir="${OPENCODE_SOURCE_WORKDIR:-}" + local base_sha="${PR_BASE_SHA:-}" + local head_sha="${PR_HEAD_SHA:-${HEAD_SHA:-}}" + [ -n "$source_workdir" ] || return 0 + [ -n "$base_sha" ] || return 0 + [ -n "$head_sha" ] || return 0 + + local policy_result policy_status + set +e + policy_result="$( + python3 -I "$policy_checker" \ + --repo-root "$source_workdir" \ + --base-sha "$base_sha" \ + --head-sha "$head_sha" \ + --explain 2>&1 + )" + policy_status=$? + set -e + + case "$policy_status" in + 0) + prepend_unique_anonymous_free_candidates + printf '%s\n' "$policy_result" + printf 'Enabled governed anonymous OpenCode free-model candidates from the unchanged trusted base policy.\n' + ;; + 1) + # Missing, invalid, or head-modified policies are the expected fail-closed path. + ;; + *) + printf '::warning::Private free-model policy evaluation failed closed.\n' >&2 + ;; + esac +} + +install_provider_guard() { + local real_opencode + real_opencode="$(command -v opencode 2>/dev/null || true)" + [ -n "$real_opencode" ] || return 0 + + local guard_parent guard_dir + guard_parent="${RUNNER_TEMP:-${TMPDIR:-/tmp}}" + mkdir -p "$guard_parent" + guard_dir="$(mktemp -d "$guard_parent/opencode-provider-guard.XXXXXX")" + cp "$provider_guard" "$guard_dir/opencode" + chmod 0700 "$guard_dir/opencode" + OPENCODE_REAL_BIN="$real_opencode" + OPENCODE_PROVIDER_GUARD_DIR="$guard_dir" + PATH="$guard_dir:$PATH" + export OPENCODE_REAL_BIN OPENCODE_PROVIDER_GUARD_DIR PATH +} + +cleanup_provider_guard() { + if [ -n "${OPENCODE_PROVIDER_GUARD_DIR:-}" ]; then + rm -rf -- "$OPENCODE_PROVIDER_GUARD_DIR" + fi +} + +apply_private_free_model_policy() { + local hook_dir + hook_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" + policy_checker="$hook_dir/opencode_private_free_model_policy.py" + provider_guard="$hook_dir/opencode_provider_guard.sh" + [ -f "$policy_checker" ] || die "OpenCode private free-model policy checker is missing." + [ -f "$provider_guard" ] || die "OpenCode provider credential guard is missing." + # Unit tests pass OPENCODE_MODEL_CANDIDATES directly. Do not strip free + # aliases unless the review workflow supplied visibility or a base SHA. + if [ -z "${OPENCODE_REPOSITORY_IS_PRIVATE:-}" ] && + [ -z "${PR_BASE_SHA:-}" ]; then + trap cleanup_provider_guard EXIT INT TERM + install_provider_guard + return 0 + fi + maybe_enable_private_free_models + trap cleanup_provider_guard EXIT INT TERM + install_provider_guard +} diff --git a/scripts/ci/opencode_private_free_model_policy.py b/scripts/ci/opencode_private_free_model_policy.py new file mode 100755 index 000000000..43a00c785 --- /dev/null +++ b/scripts/ci/opencode_private_free_model_policy.py @@ -0,0 +1,269 @@ +#!/usr/bin/env python3 +"""Validate a trusted base-branch opt-in for private free-model review. + +The checker reads only the fixed policy path from the pull request's base commit. +It refuses to enable free-model egress when the pull request changes that path, +so an untrusted head cannot opt itself into external processing. +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import NoReturn + + +POLICY_PATH = ".github/opencode-private-free-models.json" +MAX_POLICY_BYTES = 4096 +COMMIT_SHA_PATTERN = re.compile(r"\A[0-9a-fA-F]{40}\Z") +EXPECTED_POLICY: dict[str, object] = { + "schema_version": 1, + "allow_private_free_models": True, + "repository_data_classification": "public_equivalent", + "external_model_data_use_accepted": True, +} + + +class PolicyDenied(RuntimeError): + """Expected fail-closed outcome for a missing or ineligible policy.""" + + +class PolicyEvaluationError(RuntimeError): + """Unexpected local error while evaluating the trusted Git tree.""" + + +class DuplicateJsonKey(ValueError): + """Raised when JSON contains ambiguous duplicate object keys.""" + + +@dataclass(frozen=True) +class GitBlobEntry: + """One exact regular blob entry returned by ``git ls-tree``.""" + + mode: str + object_type: str + object_sha: str + path: str + + +def parse_arguments(argv: list[str] | None = None) -> argparse.Namespace: + """Parse bounded command-line inputs for one pull-request evaluation.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--repo-root", required=True, type=Path) + parser.add_argument("--base-sha", required=True) + parser.add_argument("--head-sha", required=True) + parser.add_argument( + "--explain", + action="store_true", + help="emit a bounded eligibility or denial reason", + ) + return parser.parse_args(argv) + + +def isolated_git_environment() -> dict[str, str]: + """Return a Git environment that ignores user and system configuration.""" + environment = os.environ.copy() + environment.update( + { + "GIT_CONFIG_NOSYSTEM": "1", + "GIT_CONFIG_GLOBAL": os.devnull, + "GIT_TERMINAL_PROMPT": "0", + } + ) + return environment + + +def run_git(repo_root: Path, *arguments: str, check: bool = True) -> subprocess.CompletedProcess[bytes]: + """Run a noninteractive Git command against the materialized repository.""" + command = [ + "git", + "-c", + f"safe.directory={repo_root}", + "-c", + "core.hooksPath=/dev/null", + "-c", + "core.fsmonitor=false", + "-C", + str(repo_root), + *arguments, + ] + try: + result = subprocess.run( + command, + env=isolated_git_environment(), + capture_output=True, + check=False, + timeout=20, + ) + except (OSError, subprocess.TimeoutExpired) as exc: + raise PolicyEvaluationError("Git policy evaluation could not run") from exc + if check and result.returncode != 0: + raise PolicyEvaluationError("Git policy evaluation failed") + return result + + +def validate_commit_sha(value: str, label: str) -> str: + """Validate one immutable full commit SHA without accepting revision syntax.""" + if not COMMIT_SHA_PATTERN.fullmatch(value): + raise PolicyEvaluationError(f"{label} must be a full 40-character commit SHA") + return value.lower() + + +def verify_commit(repo_root: Path, commit_sha: str) -> None: + """Require the supplied SHA to resolve to a commit in the local object store.""" + run_git(repo_root, "cat-file", "-e", f"{commit_sha}^{{commit}}") + + +def require_policy_unchanged(repo_root: Path, base_sha: str, head_sha: str) -> None: + """Deny when the reviewed head adds, removes, or modifies the policy path.""" + result = run_git( + repo_root, + "diff", + "--quiet", + "--no-ext-diff", + base_sha, + head_sha, + "--", + POLICY_PATH, + check=False, + ) + if result.returncode == 1: + raise PolicyDenied( + "policy changed in the reviewed head; merge it before a later PR can opt in" + ) + if result.returncode != 0: + raise PolicyEvaluationError("Git could not compare the policy path") + + +def parse_ls_tree_entry(raw_entry: bytes) -> GitBlobEntry: + """Parse exactly one NUL-terminated ``git ls-tree`` record.""" + if not raw_entry.endswith(b"\x00"): + raise PolicyEvaluationError("Git returned an unterminated policy tree entry") + record = raw_entry[:-1] + try: + metadata, raw_path = record.split(b"\t", 1) + mode, object_type, object_sha = metadata.decode("ascii").split(" ", 2) + path = raw_path.decode("utf-8") + except (UnicodeDecodeError, ValueError) as exc: + raise PolicyEvaluationError("Git returned an invalid policy tree entry") from exc + return GitBlobEntry( + mode=mode, + object_type=object_type, + object_sha=object_sha, + path=path, + ) + + +def policy_blob_entry(repo_root: Path, base_sha: str) -> GitBlobEntry: + """Return the base commit's fixed policy entry after strict mode checks.""" + result = run_git(repo_root, "ls-tree", "-z", base_sha, "--", POLICY_PATH) + if not result.stdout: + raise PolicyDenied(f"trusted base policy is missing at {POLICY_PATH}") + if not result.stdout.endswith(b"\x00"): + raise PolicyEvaluationError("Git returned an unterminated policy tree entry") + entries = result.stdout[:-1].split(b"\x00") + if len(entries) != 1 or not entries[0]: + raise PolicyEvaluationError("Git returned more than one policy tree entry") + entry = parse_ls_tree_entry(entries[0] + b"\x00") + if entry.path != POLICY_PATH: + raise PolicyEvaluationError("Git returned a different policy path") + if entry.mode != "100644" or entry.object_type != "blob": + raise PolicyDenied("trusted base policy must be one regular non-executable file") + if not COMMIT_SHA_PATTERN.fullmatch(entry.object_sha): + raise PolicyEvaluationError("Git returned an invalid policy blob SHA") + return entry + + +def read_policy_blob(repo_root: Path, entry: GitBlobEntry) -> bytes: + """Read a bounded immutable blob directly from the trusted base tree.""" + size_result = run_git(repo_root, "cat-file", "-s", entry.object_sha) + try: + size = int(size_result.stdout.decode("ascii").strip()) + except (UnicodeDecodeError, ValueError) as exc: + raise PolicyEvaluationError("Git returned an invalid policy blob size") from exc + if size > MAX_POLICY_BYTES: + raise PolicyDenied(f"trusted base policy exceeds {MAX_POLICY_BYTES} bytes") + blob_result = run_git(repo_root, "cat-file", "blob", entry.object_sha) + if len(blob_result.stdout) != size: + raise PolicyEvaluationError("Git returned a truncated policy blob") + return blob_result.stdout + + +def reject_duplicate_keys(pairs: list[tuple[str, object]]) -> dict[str, object]: + """Build a JSON object while rejecting every duplicate key.""" + result: dict[str, object] = {} + for key, value in pairs: + if key in result: + raise DuplicateJsonKey(f"duplicate JSON key: {key}") + result[key] = value + return result + + +def parse_policy(raw_policy: bytes) -> dict[str, object]: + """Decode strict UTF-8 JSON and require the canonical policy declaration.""" + try: + text = raw_policy.decode("utf-8", errors="strict") + except UnicodeDecodeError as exc: + raise PolicyDenied("trusted base policy must be valid UTF-8") from exc + try: + value = json.loads(text, object_pairs_hook=reject_duplicate_keys) + except DuplicateJsonKey as exc: + raise PolicyDenied(str(exc)) from exc + except json.JSONDecodeError as exc: + raise PolicyDenied("trusted base policy must be valid JSON") from exc + if not isinstance(value, dict) or value.keys() != EXPECTED_POLICY.keys(): + raise PolicyDenied("trusted base policy must exactly match the canonical declaration") + for key, expected in EXPECTED_POLICY.items(): + actual = value[key] + if type(actual) is not type(expected) or actual != expected: + raise PolicyDenied( + "trusted base policy must exactly match the canonical declaration" + ) + return value + + +def evaluate_policy(repo_root: Path, base_sha: str, head_sha: str) -> None: + """Raise unless the immutable base policy safely enables free-model egress.""" + resolved_root = repo_root.resolve(strict=True) + if not resolved_root.is_dir() or not (resolved_root / ".git").exists(): + raise PolicyDenied("materialized source is not a Git repository") + normalized_base = validate_commit_sha(base_sha, "base SHA") + normalized_head = validate_commit_sha(head_sha, "head SHA") + verify_commit(resolved_root, normalized_base) + verify_commit(resolved_root, normalized_head) + require_policy_unchanged(resolved_root, normalized_base, normalized_head) + entry = policy_blob_entry(resolved_root, normalized_base) + parse_policy(read_policy_blob(resolved_root, entry)) + + +def deny(reason: str, explain: bool) -> NoReturn: + """Exit with the expected ineligible status and optional bounded reason.""" + if explain: + print(f"ineligible: {reason}", file=sys.stderr) + raise SystemExit(1) + + +def main(argv: list[str] | None = None) -> int: + """Run one fail-closed policy evaluation.""" + arguments = parse_arguments(argv) + try: + evaluate_policy(arguments.repo_root, arguments.base_sha, arguments.head_sha) + except (FileNotFoundError, PolicyDenied) as exc: + deny(str(exc) or "policy denied", arguments.explain) + except PolicyEvaluationError as exc: + if arguments.explain: + print(f"error: {exc}", file=sys.stderr) + return 2 + if arguments.explain: + print(f"eligible: trusted unchanged base policy at {POLICY_PATH}") + return 0 + + +if __name__ == "__main__": # pragma: no cover - exercised by CLI integration + raise SystemExit(main()) diff --git a/scripts/ci/opencode_provider_guard.sh b/scripts/ci/opencode_provider_guard.sh new file mode 100755 index 000000000..fcd2e4078 --- /dev/null +++ b/scripts/ci/opencode_provider_guard.sh @@ -0,0 +1,141 @@ +#!/usr/bin/env bash +# Execute one OpenCode command with provider-scoped credentials only. +set -euo pipefail + +if [ "$#" -lt 1 ]; then + printf 'Usage: %s \n' "${0##*/}" >&2 + exit 64 +fi + +real_opencode="${OPENCODE_REAL_BIN:-}" +if [ -z "$real_opencode" ] || [ ! -x "$real_opencode" ]; then + printf 'OPENCODE_REAL_BIN must name the executable captured before guard activation.\n' >&2 + exit 69 +fi + +model_candidate="" +model_argument_count=0 +expect_model_value=0 +option_parsing=1 +for argument in "$@"; do + if [ "$expect_model_value" -eq 1 ]; then + case "$argument" in + --) + printf '%s\n' '--model/-m requires a model candidate.' >&2 + exit 64 + ;; + --model | -m | --model=* | -m=*) + printf 'exactly one model selector is allowed.\n' >&2 + exit 64 + ;; + *) + model_candidate="$argument" + expect_model_value=0 + ;; + esac + continue + fi + if [ "$option_parsing" -eq 0 ]; then + continue + fi + case "$argument" in + --) + option_parsing=0 + ;; + --model | -m) + model_argument_count=$((model_argument_count + 1)) + expect_model_value=1 + ;; + --model=*) + model_argument_count=$((model_argument_count + 1)) + model_candidate="${argument#--model=}" + ;; + -m=*) + model_argument_count=$((model_argument_count + 1)) + model_candidate="${argument#-m=}" + ;; + esac + if [ "$model_argument_count" -gt 1 ]; then + printf 'exactly one model selector is allowed.\n' >&2 + exit 64 + fi +done +if [ "$expect_model_value" -eq 1 ] || + { [ "$model_argument_count" -gt 0 ] && [ -z "$model_candidate" ]; }; then + printf '%s\n' '--model/-m requires a model candidate.' >&2 + exit 64 +fi + +# GitHub and Actions credentials are never needed by a read-only model process. +environment=( + env + -u GH_TOKEN + -u GITHUB_TOKEN + -u OPENCODE_APP_TOKEN + -u ACTIONS_ID_TOKEN_REQUEST_TOKEN + -u ACTIONS_ID_TOKEN_REQUEST_URL + -u ACTIONS_RUNTIME_TOKEN + -u ACTIONS_CACHE_URL + -u ACTIONS_RESULTS_URL + -u ACTIONS_RUNTIME_URL +) + +# Start with no provider credential, then keep only the selected provider's key. +case "$model_candidate" in + nvidia-nim/*) + environment+=( + -u STRIX_GITHUB_MODELS_TOKEN + -u OPENCODE_API_KEY + -u OPENAI_API_KEY + -u OPENROUTER_API_KEY + ) + ;; + opencode/*) + environment+=( + -u STRIX_GITHUB_MODELS_TOKEN + -u OPENAI_API_KEY + -u OPENROUTER_API_KEY + -u NVIDIA_API_KEY + -u NVIDIA_NIM_API_KEY + ) + ;; + openai/*) + environment+=( + -u STRIX_GITHUB_MODELS_TOKEN + -u OPENCODE_API_KEY + -u OPENROUTER_API_KEY + -u NVIDIA_API_KEY + -u NVIDIA_NIM_API_KEY + ) + ;; + openrouter/*) + environment+=( + -u STRIX_GITHUB_MODELS_TOKEN + -u OPENCODE_API_KEY + -u OPENAI_API_KEY + -u NVIDIA_API_KEY + -u NVIDIA_NIM_API_KEY + ) + ;; + github-models/*) + environment+=( + -u OPENCODE_API_KEY + -u OPENAI_API_KEY + -u OPENROUTER_API_KEY + -u NVIDIA_API_KEY + -u NVIDIA_NIM_API_KEY + ) + ;; + *) + environment+=( + -u STRIX_GITHUB_MODELS_TOKEN + -u OPENCODE_API_KEY + -u OPENAI_API_KEY + -u OPENROUTER_API_KEY + -u NVIDIA_API_KEY + -u NVIDIA_NIM_API_KEY + ) + ;; +esac + +exec "${environment[@]}" "$real_opencode" "$@" diff --git a/scripts/ci/run_opencode_review_model_pool.sh b/scripts/ci/run_opencode_review_model_pool.sh index 986982e9a..61f0fce9c 100644 --- a/scripts/ci/run_opencode_review_model_pool.sh +++ b/scripts/ci/run_opencode_review_model_pool.sh @@ -604,6 +604,11 @@ main() { deadline=$((SECONDS + budget_seconds)) fi : >"$OPENCODE_OUTPUT_FILE" + if [ -f "$(dirname -- "${BASH_SOURCE[0]}")/opencode_private_free_model_hook.sh" ]; then + # shellcheck disable=SC1091 + . "$(dirname -- "${BASH_SOURCE[0]}")/opencode_private_free_model_hook.sh" + apply_private_free_model_policy + fi cd "$OPENCODE_REVIEW_WORKDIR" read -r -a model_candidates <<<"${OPENCODE_MODEL_CANDIDATES:-}" if [ "${#model_candidates[@]}" -eq 0 ]; then diff --git a/scripts/ci/test_strix_quick_gate.sh b/scripts/ci/test_strix_quick_gate.sh index 7343c06ac..2c8a5a22c 100755 --- a/scripts/ci/test_strix_quick_gate.sh +++ b/scripts/ci/test_strix_quick_gate.sh @@ -626,6 +626,8 @@ assert_opencode_review_uses_codegraph_and_gpt5_fallback() { assert_file_contains "$workflow_file" 'NVIDIA_NIM_API_KEY: ${{ secrets.NVIDIA_NIM_API_KEY }}' "opencode review exposes NVIDIA NIM credentials only to the model runtime" assert_file_contains "$workflow_file" '"north-mini-code-free"' "opencode review declares the current Zen coding model" assert_file_contains "$workflow_file" "needs.validate-pr-metadata.outputs.is_private == 'false'" "opencode review limits data-retaining free models to public repositories" + assert_file_contains "$workflow_file" 'OPENCODE_REPOSITORY_IS_PRIVATE: ${{ needs.validate-pr-metadata.outputs.is_private }}' "opencode model pool receives trusted repository visibility next to PR_BASE_SHA" + assert_file_contains "$workflow_file" 'PR_BASE_SHA: ${{ needs.validate-pr-metadata.outputs.base_sha }}' "opencode model pool receives the live validated PR base SHA" assert_file_matches "$workflow_file" 'uses:[[:space:]]+actions/checkout@[0-9a-fA-F]{40}([[:space:]]|$)' "opencode review workflow pins checkout to a full commit SHA" assert_workflow_uses_are_sha_pinned "$workflow_file" "opencode review workflow" assert_file_contains "$workflow_file" "scripts/ci/codegraph-package/package-lock.json" "opencode review workflow installs CodeGraph from the committed lockfile" diff --git a/tests/test_opencode_private_free_model_hook_contract.py b/tests/test_opencode_private_free_model_hook_contract.py new file mode 100644 index 000000000..c567800bf --- /dev/null +++ b/tests/test_opencode_private_free_model_hook_contract.py @@ -0,0 +1,238 @@ +"""The live model-pool runner must source the private free-model hook.""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +from pathlib import Path + + +REPO = Path(__file__).resolve().parents[1] +RUNNER = REPO / "scripts" / "ci" / "run_opencode_review_model_pool.sh" +HOOK = REPO / "scripts" / "ci" / "opencode_private_free_model_hook.sh" +POLICY = REPO / "scripts" / "ci" / "opencode_private_free_model_policy.py" +GUARD = REPO / "scripts" / "ci" / "opencode_provider_guard.sh" +WORKFLOW = REPO / ".github" / "workflows" / "opencode-review-dispatch.yml" +POLICY_PATH = Path(".github/opencode-private-free-models.json") +GOVERNED = "opencode-free/nemotron-3-ultra-free" +STALE_FREE = "opencode-free/stale-free" +KEYED = "openai/gpt-5.6-luna" +VALID_POLICY = { + "schema_version": 1, + "allow_private_free_models": True, + "repository_data_classification": "public_equivalent", + "external_model_data_use_accepted": True, +} + + +def test_live_runner_sources_private_free_model_hook() -> None: + """Private free-model opt-in is a hook, not a replacement runner.""" + + runner = RUNNER.read_text(encoding="utf-8") + hook = HOOK.read_text(encoding="utf-8") + assert "opencode_private_free_model_hook.sh" in runner + assert "apply_private_free_model_policy" in runner + assert "maybe_enable_private_free_models" in hook + assert "install_provider_guard" in hook + assert POLICY.is_file() + assert GUARD.is_file() + assert "run_opencode_review_model_pool_impl.sh" not in runner + + +def test_live_model_pool_step_exports_trusted_visibility_and_base_sha() -> None: + """Production must export trusted visibility next to PR_BASE_SHA.""" + + workflow = WORKFLOW.read_text(encoding="utf-8") + assert ( + "OPENCODE_REPOSITORY_IS_PRIVATE: ${{ needs.validate-pr-metadata.outputs.is_private }}" + in workflow + ) + assert "PR_BASE_SHA: ${{ needs.validate-pr-metadata.outputs.base_sha }}" in workflow + + +def bash_command() -> str: + """Return a Bash executable that can source the production hook.""" + found = shutil.which("bash") + if found: + return found + raise RuntimeError("bash executable was not found") + + +def run_hook( + tmp_path: Path, + *, + extra_env: dict[str, str], + source_workdir: Path | None = None, +) -> subprocess.CompletedProcess[str]: + """Apply the production hook and return the resulting candidate list.""" + env = os.environ.copy() + env.pop("OPENCODE_REPOSITORY_IS_PRIVATE", None) + env.pop("PR_BASE_SHA", None) + env.pop("PR_HEAD_SHA", None) + env.pop("HEAD_SHA", None) + env.pop("OPENCODE_SOURCE_WORKDIR", None) + env["OPENCODE_MODEL_CANDIDATES"] = f"{GOVERNED} {STALE_FREE} {KEYED}" + if source_workdir is not None: + env["OPENCODE_SOURCE_WORKDIR"] = str(source_workdir) + env.update(extra_env) + script = f""" +set -euo pipefail +. {HOOK.as_posix()} +apply_private_free_model_policy +printf '%s\\n' "${{OPENCODE_MODEL_CANDIDATES-}}" +""" + return subprocess.run( + [bash_command(), "-c", script], + cwd=tmp_path, + env=env, + capture_output=True, + text=True, + check=False, + ) + + +def candidate_list(result: subprocess.CompletedProcess[str]) -> list[str]: + """Return the hook's final candidate tokens, ignoring policy explain text.""" + lines = [line.strip() for line in result.stdout.splitlines() if line.strip()] + assert lines, result.stdout + result.stderr + return lines[-1].split() + + +def git(repo: Path, *args: str) -> str: + """Run Git in an isolated fixture repository and return stdout.""" + env = os.environ.copy() + env.update( + { + "GIT_CONFIG_NOSYSTEM": "1", + "GIT_CONFIG_GLOBAL": os.devnull, + "GIT_AUTHOR_NAME": "OpenCode Hook Test", + "GIT_AUTHOR_EMAIL": "opencode-hook@example.invalid", + "GIT_COMMITTER_NAME": "OpenCode Hook Test", + "GIT_COMMITTER_EMAIL": "opencode-hook@example.invalid", + "GIT_CONFIG_COUNT": "2", + "GIT_CONFIG_KEY_0": "user.name", + "GIT_CONFIG_VALUE_0": "OpenCode Hook Test", + "GIT_CONFIG_KEY_1": "user.email", + "GIT_CONFIG_VALUE_1": "opencode-hook@example.invalid", + } + ) + result = subprocess.run( + ["git", *args], + cwd=repo, + env=env, + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0, result.stdout + result.stderr + return result.stdout.strip() + + +def write_policy(repo: Path) -> None: + """Write the exact trusted-base private free-model policy document.""" + path = repo / POLICY_PATH + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(VALID_POLICY, indent=2) + "\n", encoding="utf-8") + + +def test_public_visibility_keeps_governed_aliases_and_drops_stale( + tmp_path: Path, +) -> None: + """Public visibility keeps governed aliases and drops stale free names.""" + result = run_hook( + tmp_path, + extra_env={"OPENCODE_REPOSITORY_IS_PRIVATE": "false"}, + ) + assert result.returncode == 0, result.stderr + candidates = candidate_list(result) + assert GOVERNED in candidates + assert KEYED in candidates + assert STALE_FREE not in candidates + + +def test_private_visibility_without_base_policy_strips_free_aliases( + tmp_path: Path, +) -> None: + """Private visibility with no base policy strips every free alias.""" + repo = tmp_path / "private-repo" + repo.mkdir() + git(repo, "init") + (repo / "README.md").write_text("fixture\n", encoding="utf-8") + git(repo, "add", "README.md") + git(repo, "commit", "-m", "initial") + head = git(repo, "rev-parse", "HEAD") + result = run_hook( + tmp_path, + source_workdir=repo, + extra_env={ + "OPENCODE_REPOSITORY_IS_PRIVATE": "true", + "PR_BASE_SHA": head, + "PR_HEAD_SHA": head, + }, + ) + assert result.returncode == 0, result.stderr + candidates = candidate_list(result) + assert candidates == [KEYED] + assert GOVERNED not in candidates + assert STALE_FREE not in candidates + + +def test_unchanged_eligible_base_policy_prepends_governed_catalog( + tmp_path: Path, +) -> None: + """An unchanged eligible base policy prepends the governed catalog.""" + repo = tmp_path / "eligible-repo" + repo.mkdir() + git(repo, "init") + (repo / "README.md").write_text("fixture\n", encoding="utf-8") + git(repo, "add", "README.md") + git(repo, "commit", "-m", "initial") + write_policy(repo) + git(repo, "add", str(POLICY_PATH)) + git(repo, "commit", "-m", "trusted policy") + base = git(repo, "rev-parse", "HEAD") + (repo / "NOTE.md").write_text("unrelated\n", encoding="utf-8") + git(repo, "add", "NOTE.md") + git(repo, "commit", "-m", "unrelated head") + head = git(repo, "rev-parse", "HEAD") + result = run_hook( + tmp_path, + source_workdir=repo, + extra_env={ + "OPENCODE_REPOSITORY_IS_PRIVATE": "true", + "PR_BASE_SHA": base, + "PR_HEAD_SHA": head, + }, + ) + assert result.returncode == 0, result.stderr + candidates = candidate_list(result) + assert candidates[0] == GOVERNED + assert KEYED in candidates + assert STALE_FREE not in candidates + assert "opencode-free/deepseek-v4-flash-free" in candidates + + +def test_malformed_visibility_fails_closed_to_policy_path(tmp_path: Path) -> None: + """Malformed visibility is not public and requires the trusted-base policy.""" + repo = tmp_path / "malformed-repo" + repo.mkdir() + git(repo, "init") + (repo / "README.md").write_text("fixture\n", encoding="utf-8") + git(repo, "add", "README.md") + git(repo, "commit", "-m", "initial") + head = git(repo, "rev-parse", "HEAD") + result = run_hook( + tmp_path, + source_workdir=repo, + extra_env={ + "OPENCODE_REPOSITORY_IS_PRIVATE": "maybe", + "PR_BASE_SHA": head, + "PR_HEAD_SHA": head, + }, + ) + assert result.returncode == 0, result.stderr + candidates = candidate_list(result) + assert candidates == [KEYED] + assert "visibility input is invalid" in result.stderr diff --git a/tests/test_opencode_private_free_model_policy_1.py b/tests/test_opencode_private_free_model_policy_1.py new file mode 100644 index 000000000..81b826460 --- /dev/null +++ b/tests/test_opencode_private_free_model_policy_1.py @@ -0,0 +1,251 @@ +"""Integration tests for private-repository OpenCode free-model eligibility.""" + +from __future__ import annotations + +import contextlib +import importlib.util +import io +import json +import os +import subprocess +import sys +from pathlib import Path + +import pytest + + +ROOT = Path(__file__).resolve().parents[1] +POLICY_CHECKER = ROOT / "scripts" / "ci" / "opencode_private_free_model_policy.py" +POLICY_PATH = Path(".github/opencode-private-free-models.json") +SPEC = importlib.util.spec_from_file_location("opencode_private_free_model_policy", POLICY_CHECKER) +assert SPEC is not None and SPEC.loader is not None +POLICY_MODULE = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = POLICY_MODULE +SPEC.loader.exec_module(POLICY_MODULE) + +VALID_POLICY = { + "schema_version": 1, + "allow_private_free_models": True, + "repository_data_classification": "public_equivalent", + "external_model_data_use_accepted": True, +} + + +def run(*args: str, cwd: Path) -> subprocess.CompletedProcess[str]: + """Run a subprocess with deterministic Git identity and no global config.""" + env = os.environ.copy() + env.update( + { + "GIT_CONFIG_NOSYSTEM": "1", + "GIT_CONFIG_GLOBAL": os.devnull, + "GIT_AUTHOR_NAME": "OpenCode Policy Test", + "GIT_AUTHOR_EMAIL": "opencode-policy@example.invalid", + "GIT_COMMITTER_NAME": "OpenCode Policy Test", + "GIT_COMMITTER_EMAIL": "opencode-policy@example.invalid", + } + ) + return subprocess.run( + args, + cwd=cwd, + env=env, + capture_output=True, + text=True, + check=False, + ) + + +def git(repo: Path, *args: str) -> str: + """Run Git and return stripped stdout, failing the test on errors.""" + result = run("git", *args, cwd=repo) + assert result.returncode == 0, result.stdout + result.stderr + return result.stdout.strip() + + +def commit_all(repo: Path, message: str) -> str: + """Commit every tracked and untracked fixture and return its SHA.""" + git(repo, "add", "-A") + git(repo, "commit", "-m", message) + return git(repo, "rev-parse", "HEAD") + + +def write_policy(repo: Path, value: object = VALID_POLICY) -> None: + """Write one UTF-8 policy document under the fixed governance path.""" + path = repo / POLICY_PATH + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(value, indent=2) + "\n", encoding="utf-8") + + +@pytest.fixture +def repository(tmp_path: Path) -> Path: + """Create one isolated Git repository with an initial ordinary file.""" + repo = tmp_path / "repository" + repo.mkdir() + git(repo, "init", "--initial-branch=main") + (repo / "README.md").write_text("fixture\n", encoding="utf-8") + commit_all(repo, "initial") + return repo + + +def evaluate(repo: Path, base_sha: str, head_sha: str, *extra: str) -> subprocess.CompletedProcess[str]: + """Evaluate the policy through its real ``main`` function for coverage.""" + stdout = io.StringIO() + stderr = io.StringIO() + arguments = [ + "--repo-root", + str(repo), + "--base-sha", + base_sha, + "--head-sha", + head_sha, + *extra, + ] + with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr): + try: + returncode = POLICY_MODULE.main(arguments) + except SystemExit as exc: + returncode = int(exc.code) + return subprocess.CompletedProcess( + args=arguments, + returncode=returncode, + stdout=stdout.getvalue(), + stderr=stderr.getvalue(), + ) + + +@pytest.mark.parametrize( + "policy", + [ + {**VALID_POLICY, "unknown_field": "not allowed"}, + {**VALID_POLICY, "schema_version": 2}, + {**VALID_POLICY, "schema_version": True}, + {**VALID_POLICY, "allow_private_free_models": False}, + {**VALID_POLICY, "allow_private_free_models": 1}, + {**VALID_POLICY, "repository_data_classification": "internal"}, + {**VALID_POLICY, "external_model_data_use_accepted": False}, + ], +) +def test_noncanonical_policy_fails_closed(repository: Path, policy: dict[str, object]) -> None: + """Missing, type-confused, unknown, or weaker declarations fail closed.""" + write_policy(repository, policy) + base_sha = commit_all(repository, "add invalid policy") + (repository / "src.py").write_text("VALUE = 1\n", encoding="utf-8") + head_sha = commit_all(repository, "code change") + + result = evaluate(repository, base_sha, head_sha, "--explain") + + assert result.returncode == 1 + assert "canonical" in result.stderr.casefold() + + +def test_valid_policy_on_base_and_unchanged_head_is_eligible(repository: Path) -> None: + """A reviewed base policy enables free models for a later code-only PR.""" + write_policy(repository) + base_sha = commit_all(repository, "add policy") + (repository / "src.py").write_text("VALUE = 1\n", encoding="utf-8") + head_sha = commit_all(repository, "code change") + + result = evaluate(repository, base_sha, head_sha, "--explain") + + assert result.returncode == 0, result.stdout + result.stderr + assert "eligible" in result.stdout.casefold() + assert POLICY_PATH.as_posix() in result.stdout + + +def test_malformed_json_policy_fails_closed(repository: Path) -> None: + """Syntactically invalid JSON is an expected ineligible policy.""" + path = repository / POLICY_PATH + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("{", encoding="utf-8") + base_sha = commit_all(repository, "malformed policy") + (repository / "src.py").write_text("VALUE = 1\n", encoding="utf-8") + head_sha = commit_all(repository, "code change") + + result = evaluate(repository, base_sha, head_sha, "--explain") + + assert result.returncode == 1 + assert "valid JSON" in result.stderr + + +@pytest.mark.parametrize( + ("raw_tree", "error_fragment"), + [ + ( + b"100644 blob " + b"0" * 40 + b"\t.github/opencode-private-free-models.json", + "unterminated", + ), + ( + b"100644 blob " + b"0" * 40 + b"\t.github/opencode-private-free-models.json\x00\x00", + "more than one", + ), + ], +) +def test_policy_tree_requires_exact_single_nul_terminated_record( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + raw_tree: bytes, + error_fragment: str, +) -> None: + """Truncated or extra-empty ``ls-tree -z`` records fail closed before parsing.""" + monkeypatch.setattr( + POLICY_MODULE, + "run_git", + lambda *_args, **_kwargs: subprocess.CompletedProcess([], 0, raw_tree, b""), + ) + + with pytest.raises(POLICY_MODULE.PolicyEvaluationError, match=error_fragment): + POLICY_MODULE.policy_blob_entry(tmp_path, "0" * 40) + + +def test_read_policy_blob_rejects_invalid_size(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """Non-numeric object sizes cannot bypass the byte bound.""" + monkeypatch.setattr( + POLICY_MODULE, + "run_git", + lambda *_args, **_kwargs: subprocess.CompletedProcess([], 0, b"invalid\n", b""), + ) + entry = POLICY_MODULE.GitBlobEntry("100644", "blob", "0" * 40, POLICY_PATH.as_posix()) + + with pytest.raises(POLICY_MODULE.PolicyEvaluationError, match="blob size"): + POLICY_MODULE.read_policy_blob(tmp_path, entry) + + +def test_main_internal_error_can_remain_silent(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """The wrapper receives status 2 without leaking local error details.""" + + def fail_evaluation(*_args: object, **_kwargs: object) -> None: + raise POLICY_MODULE.PolicyEvaluationError("private detail") + + monkeypatch.setattr(POLICY_MODULE, "evaluate_policy", fail_evaluation) + result = evaluate(tmp_path, "0" * 40, "1" * 40) + + assert result.returncode == 2 + assert result.stderr == "" + + +def test_run_git_rejects_failed_checked_command(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """Unexpected nonzero Git commands do not become policy denials.""" + monkeypatch.setattr( + POLICY_MODULE.subprocess, + "run", + lambda *_args, **_kwargs: subprocess.CompletedProcess([], 9, b"", b"failure"), + ) + + with pytest.raises(POLICY_MODULE.PolicyEvaluationError, match="failed"): + POLICY_MODULE.run_git(tmp_path, "status") + + +def test_main_success_can_remain_silent(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """Successful eligibility can be consumed only as an exit status.""" + monkeypatch.setattr(POLICY_MODULE, "evaluate_policy", lambda *_args, **_kwargs: None) + result = evaluate(tmp_path, "0" * 40, "1" * 40) + + assert result.returncode == 0 + assert result.stdout == "" + + +def test_invalid_commit_sha_returns_evaluation_error(repository: Path) -> None: + """Revision syntax cannot replace immutable full commit identifiers.""" + result = evaluate(repository, "HEAD", "0" * 40, "--explain") + + assert result.returncode == 2 + assert "40-character" in result.stderr diff --git a/tests/test_opencode_private_free_model_policy_2.py b/tests/test_opencode_private_free_model_policy_2.py new file mode 100644 index 000000000..41432ff7c --- /dev/null +++ b/tests/test_opencode_private_free_model_policy_2.py @@ -0,0 +1,226 @@ +"""Integration tests for private-repository OpenCode free-model eligibility.""" + +from __future__ import annotations + +import contextlib +import importlib.util +import io +import json +import os +import subprocess +import sys +from pathlib import Path + +import pytest + + +ROOT = Path(__file__).resolve().parents[1] +POLICY_CHECKER = ROOT / "scripts" / "ci" / "opencode_private_free_model_policy.py" +POLICY_PATH = Path(".github/opencode-private-free-models.json") +SPEC = importlib.util.spec_from_file_location("opencode_private_free_model_policy", POLICY_CHECKER) +assert SPEC is not None and SPEC.loader is not None +POLICY_MODULE = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = POLICY_MODULE +SPEC.loader.exec_module(POLICY_MODULE) + +VALID_POLICY = { + "schema_version": 1, + "allow_private_free_models": True, + "repository_data_classification": "public_equivalent", + "external_model_data_use_accepted": True, +} + + +def run(*args: str, cwd: Path) -> subprocess.CompletedProcess[str]: + """Run a subprocess with deterministic Git identity and no global config.""" + env = os.environ.copy() + env.update( + { + "GIT_CONFIG_NOSYSTEM": "1", + "GIT_CONFIG_GLOBAL": os.devnull, + "GIT_AUTHOR_NAME": "OpenCode Policy Test", + "GIT_AUTHOR_EMAIL": "opencode-policy@example.invalid", + "GIT_COMMITTER_NAME": "OpenCode Policy Test", + "GIT_COMMITTER_EMAIL": "opencode-policy@example.invalid", + } + ) + return subprocess.run( + args, + cwd=cwd, + env=env, + capture_output=True, + text=True, + check=False, + ) + + +def git(repo: Path, *args: str) -> str: + """Run Git and return stripped stdout, failing the test on errors.""" + result = run("git", *args, cwd=repo) + assert result.returncode == 0, result.stdout + result.stderr + return result.stdout.strip() + + +def commit_all(repo: Path, message: str) -> str: + """Commit every tracked and untracked fixture and return its SHA.""" + git(repo, "add", "-A") + git(repo, "commit", "-m", message) + return git(repo, "rev-parse", "HEAD") + + +def write_policy(repo: Path, value: object = VALID_POLICY) -> None: + """Write one UTF-8 policy document under the fixed governance path.""" + path = repo / POLICY_PATH + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(value, indent=2) + "\n", encoding="utf-8") + + +@pytest.fixture +def repository(tmp_path: Path) -> Path: + """Create one isolated Git repository with an initial ordinary file.""" + repo = tmp_path / "repository" + repo.mkdir() + git(repo, "init", "--initial-branch=main") + (repo / "README.md").write_text("fixture\n", encoding="utf-8") + commit_all(repo, "initial") + return repo + + +def evaluate(repo: Path, base_sha: str, head_sha: str, *extra: str) -> subprocess.CompletedProcess[str]: + """Evaluate the policy through its real ``main`` function for coverage.""" + stdout = io.StringIO() + stderr = io.StringIO() + arguments = [ + "--repo-root", + str(repo), + "--base-sha", + base_sha, + "--head-sha", + head_sha, + *extra, + ] + with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr): + try: + returncode = POLICY_MODULE.main(arguments) + except SystemExit as exc: + returncode = int(exc.code) + return subprocess.CompletedProcess( + args=arguments, + returncode=returncode, + stdout=stdout.getvalue(), + stderr=stderr.getvalue(), + ) + + +def test_duplicate_json_key_fails_closed(repository: Path) -> None: + """Ambiguous duplicate keys cannot exploit parser last-value behavior.""" + path = repository / POLICY_PATH + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + '{"schema_version":1,"schema_version":1,' + '"allow_private_free_models":true,' + '"repository_data_classification":"public_equivalent",' + '"external_model_data_use_accepted":true}\n', + encoding="utf-8", + ) + base_sha = commit_all(repository, "add duplicate policy") + (repository / "src.py").write_text("VALUE = 1\n", encoding="utf-8") + head_sha = commit_all(repository, "code change") + + result = evaluate(repository, base_sha, head_sha, "--explain") + + assert result.returncode == 1 + assert "duplicate" in result.stderr.casefold() + + +def test_invalid_utf8_policy_fails_closed(repository: Path) -> None: + """The policy is deterministic UTF-8 rather than locale-dependent bytes.""" + path = repository / POLICY_PATH + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b"\xff\xfe") + base_sha = commit_all(repository, "add invalid utf8 policy") + (repository / "src.py").write_text("VALUE = 1\n", encoding="utf-8") + head_sha = commit_all(repository, "code change") + + result = evaluate(repository, base_sha, head_sha, "--explain") + + assert result.returncode == 1 + assert "utf-8" in result.stderr.casefold() + + +def test_oversized_policy_fails_closed(repository: Path) -> None: + """A bounded policy cannot hide content behind an oversized document.""" + path = repository / POLICY_PATH + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(" " * 5000, encoding="utf-8") + base_sha = commit_all(repository, "add oversized policy") + (repository / "src.py").write_text("VALUE = 1\n", encoding="utf-8") + head_sha = commit_all(repository, "code change") + + result = evaluate(repository, base_sha, head_sha, "--explain") + + assert result.returncode == 1 + assert "4096" in result.stderr + + +def test_policy_modified_by_reviewed_head_fails_closed(repository: Path) -> None: + """Any policy mutation takes effect only after merge on a subsequent PR.""" + write_policy(repository) + base_sha = commit_all(repository, "add policy") + policy = dict(VALID_POLICY) + policy["repository_data_classification"] = "confidential" + write_policy(repository, policy) + head_sha = commit_all(repository, "change policy") + + result = evaluate(repository, base_sha, head_sha, "--explain") + + assert result.returncode == 1 + assert "changed" in result.stderr.casefold() + + +def test_policy_blob_entry_rejects_wrong_returned_path(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """Git output must bind to the fixed governance path exactly.""" + record = b"100644 blob " + b"0" * 40 + b"\t.github/wrong.json\x00" + monkeypatch.setattr( + POLICY_MODULE, + "run_git", + lambda *_args, **_kwargs: subprocess.CompletedProcess([], 0, record, b""), + ) + + with pytest.raises(POLICY_MODULE.PolicyEvaluationError, match="different"): + POLICY_MODULE.policy_blob_entry(tmp_path, "0" * 40) + + +def test_run_git_wraps_process_start_failure(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """OS-level Git launch failures become bounded evaluation errors.""" + def fail_run(*_args: object, **_kwargs: object) -> object: + raise OSError("unavailable") + + monkeypatch.setattr(POLICY_MODULE.subprocess, "run", fail_run) + + with pytest.raises(POLICY_MODULE.PolicyEvaluationError, match="could not run"): + POLICY_MODULE.run_git(tmp_path, "status") + + +@pytest.mark.parametrize( + "record", + [ + b"100644 blob " + b"0" * 40 + b"\t.github/policy.json", + b"invalid\x00", + b"100644 blob " + b"0" * 40 + b"\t\xff\x00", + ], +) +def test_invalid_ls_tree_records_are_rejected(record: bytes) -> None: + """Malformed or non-UTF-8 tree records never select a policy blob.""" + with pytest.raises(POLICY_MODULE.PolicyEvaluationError): + POLICY_MODULE.parse_ls_tree_entry(record) + + +def test_non_git_directory_fails_closed(tmp_path: Path) -> None: + """A plain directory cannot impersonate a materialized repository.""" + result = evaluate(tmp_path, "0" * 40, "1" * 40, "--explain") + + assert result.returncode == 1 + assert "not a Git repository" in result.stderr + + diff --git a/tests/test_opencode_private_free_model_policy_3.py b/tests/test_opencode_private_free_model_policy_3.py new file mode 100644 index 000000000..3e7b2dedd --- /dev/null +++ b/tests/test_opencode_private_free_model_policy_3.py @@ -0,0 +1,220 @@ +"""Integration tests for private-repository OpenCode free-model eligibility.""" + +from __future__ import annotations + +import contextlib +import importlib.util +import io +import json +import os +import subprocess +import sys +from pathlib import Path + +import pytest + + +ROOT = Path(__file__).resolve().parents[1] +POLICY_CHECKER = ROOT / "scripts" / "ci" / "opencode_private_free_model_policy.py" +POLICY_PATH = Path(".github/opencode-private-free-models.json") +SPEC = importlib.util.spec_from_file_location("opencode_private_free_model_policy", POLICY_CHECKER) +assert SPEC is not None and SPEC.loader is not None +POLICY_MODULE = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = POLICY_MODULE +SPEC.loader.exec_module(POLICY_MODULE) + +VALID_POLICY = { + "schema_version": 1, + "allow_private_free_models": True, + "repository_data_classification": "public_equivalent", + "external_model_data_use_accepted": True, +} + + +def run(*args: str, cwd: Path) -> subprocess.CompletedProcess[str]: + """Run a subprocess with deterministic Git identity and no global config.""" + env = os.environ.copy() + env.update( + { + "GIT_CONFIG_NOSYSTEM": "1", + "GIT_CONFIG_GLOBAL": os.devnull, + "GIT_AUTHOR_NAME": "OpenCode Policy Test", + "GIT_AUTHOR_EMAIL": "opencode-policy@example.invalid", + "GIT_COMMITTER_NAME": "OpenCode Policy Test", + "GIT_COMMITTER_EMAIL": "opencode-policy@example.invalid", + } + ) + return subprocess.run( + args, + cwd=cwd, + env=env, + capture_output=True, + text=True, + check=False, + ) + + +def git(repo: Path, *args: str) -> str: + """Run Git and return stripped stdout, failing the test on errors.""" + result = run("git", *args, cwd=repo) + assert result.returncode == 0, result.stdout + result.stderr + return result.stdout.strip() + + +def commit_all(repo: Path, message: str) -> str: + """Commit every tracked and untracked fixture and return its SHA.""" + git(repo, "add", "-A") + git(repo, "commit", "-m", message) + return git(repo, "rev-parse", "HEAD") + + +def write_policy(repo: Path, value: object = VALID_POLICY) -> None: + """Write one UTF-8 policy document under the fixed governance path.""" + path = repo / POLICY_PATH + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(value, indent=2) + "\n", encoding="utf-8") + + +@pytest.fixture +def repository(tmp_path: Path) -> Path: + """Create one isolated Git repository with an initial ordinary file.""" + repo = tmp_path / "repository" + repo.mkdir() + git(repo, "init", "--initial-branch=main") + (repo / "README.md").write_text("fixture\n", encoding="utf-8") + commit_all(repo, "initial") + return repo + + +def evaluate(repo: Path, base_sha: str, head_sha: str, *extra: str) -> subprocess.CompletedProcess[str]: + """Evaluate the policy through its real ``main`` function for coverage.""" + stdout = io.StringIO() + stderr = io.StringIO() + arguments = [ + "--repo-root", + str(repo), + "--base-sha", + base_sha, + "--head-sha", + head_sha, + *extra, + ] + with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr): + try: + returncode = POLICY_MODULE.main(arguments) + except SystemExit as exc: + returncode = int(exc.code) + return subprocess.CompletedProcess( + args=arguments, + returncode=returncode, + stdout=stdout.getvalue(), + stderr=stderr.getvalue(), + ) + + +def test_symlink_policy_fails_closed(repository: Path) -> None: + """The governance file must be a regular non-executable Git blob.""" + outside = repository / "outside.json" + outside.write_text(json.dumps(VALID_POLICY), encoding="utf-8") + policy = repository / POLICY_PATH + policy.parent.mkdir(parents=True, exist_ok=True) + try: + policy.symlink_to(outside) + except OSError as exc: + pytest.skip(f"symlinks are unavailable: {exc}") + base_sha = commit_all(repository, "add symlink policy") + (repository / "src.py").write_text("VALUE = 1\n", encoding="utf-8") + head_sha = commit_all(repository, "code change") + + result = evaluate(repository, base_sha, head_sha, "--explain") + + assert result.returncode == 1 + assert "regular" in result.stderr.casefold() + + +def test_read_policy_blob_rejects_truncation(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """The immutable blob byte count must match the Git object metadata.""" + responses = iter( + [ + subprocess.CompletedProcess([], 0, b"5\n", b""), + subprocess.CompletedProcess([], 0, b"four", b""), + ] + ) + monkeypatch.setattr(POLICY_MODULE, "run_git", lambda *_args, **_kwargs: next(responses)) + entry = POLICY_MODULE.GitBlobEntry("100644", "blob", "0" * 40, POLICY_PATH.as_posix()) + + with pytest.raises(POLICY_MODULE.PolicyEvaluationError, match="truncated"): + POLICY_MODULE.read_policy_blob(tmp_path, entry) + + +def test_policy_blob_entry_rejects_multiple_records(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """An impossible ambiguous tree response fails as an internal error.""" + record = b"100644 blob " + b"0" * 40 + b"\t" + POLICY_PATH.as_posix().encode() + monkeypatch.setattr( + POLICY_MODULE, + "run_git", + lambda *_args, **_kwargs: subprocess.CompletedProcess([], 0, record + b"\x00" + record + b"\x00", b""), + ) + + with pytest.raises(POLICY_MODULE.PolicyEvaluationError, match="more than one"): + POLICY_MODULE.policy_blob_entry(tmp_path, "0" * 40) + + +def test_policy_blob_entry_rejects_invalid_blob_sha(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """Only full immutable blob identifiers are accepted from the tree parser.""" + record = b"100644 blob short\t" + POLICY_PATH.as_posix().encode() + b"\x00" + monkeypatch.setattr( + POLICY_MODULE, + "run_git", + lambda *_args, **_kwargs: subprocess.CompletedProcess([], 0, record, b""), + ) + + with pytest.raises(POLICY_MODULE.PolicyEvaluationError, match="blob SHA"): + POLICY_MODULE.policy_blob_entry(tmp_path, "0" * 40) + + +def test_missing_base_policy_fails_closed(repository: Path) -> None: + """A private repository without explicit governance remains ineligible.""" + base_sha = git(repository, "rev-parse", "HEAD") + (repository / "src.py").write_text("VALUE = 1\n", encoding="utf-8") + head_sha = commit_all(repository, "code change") + + result = evaluate(repository, base_sha, head_sha, "--explain") + + assert result.returncode == 1 + assert "ineligible" in result.stderr.casefold() + assert "missing" in result.stderr.casefold() + + +def test_unexpected_git_diff_status_is_an_error(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """Git diff errors are distinct from a legitimate changed policy.""" + monkeypatch.setattr( + POLICY_MODULE, + "run_git", + lambda *_args, **_kwargs: subprocess.CompletedProcess([], 2, b"", b"failure"), + ) + + with pytest.raises(POLICY_MODULE.PolicyEvaluationError, match="compare"): + POLICY_MODULE.require_policy_unchanged(tmp_path, "0" * 40, "1" * 40) + + +def test_policy_added_by_reviewed_head_cannot_activate_itself(repository: Path) -> None: + """A PR cannot opt its own untrusted head into external free-model review.""" + base_sha = git(repository, "rev-parse", "HEAD") + write_policy(repository) + head_sha = commit_all(repository, "self opt in") + + result = evaluate(repository, base_sha, head_sha, "--explain") + + assert result.returncode == 1 + assert "changed" in result.stderr.casefold() + + +def test_denial_can_remain_silent() -> None: + """Expected missing-policy outcomes do not add noise without explanation.""" + with pytest.raises(SystemExit) as raised: + POLICY_MODULE.deny("hidden reason", False) + + assert raised.value.code == 1 + + diff --git a/tests/test_opencode_provider_guard.py b/tests/test_opencode_provider_guard.py new file mode 100644 index 000000000..db7211e0c --- /dev/null +++ b/tests/test_opencode_provider_guard.py @@ -0,0 +1,258 @@ +"""Credential-isolation tests for one OpenCode model candidate process.""" + +from __future__ import annotations + +import json +import os +import subprocess +from pathlib import Path + +import pytest + + +ROOT = Path(__file__).resolve().parents[1] +GUARD = ROOT / "scripts" / "ci" / "opencode_provider_guard.sh" +ALL_CREDENTIALS = { + "GH_TOKEN": "gh-secret", + "GITHUB_TOKEN": "github-secret", + "OPENCODE_APP_TOKEN": "app-secret", + "ACTIONS_ID_TOKEN_REQUEST_TOKEN": "oidc-secret", + "ACTIONS_ID_TOKEN_REQUEST_URL": "https://oidc.invalid", + "ACTIONS_RUNTIME_TOKEN": "runtime-secret", + "ACTIONS_CACHE_URL": "https://cache.invalid", + "ACTIONS_RESULTS_URL": "https://results.invalid", + "ACTIONS_RUNTIME_URL": "https://runtime.invalid", + "STRIX_GITHUB_MODELS_TOKEN": "models-secret", + "OPENCODE_API_KEY": "zen-secret", + "OPENAI_API_KEY": "openai-secret", + "OPENROUTER_API_KEY": "openrouter-secret", + "NVIDIA_API_KEY": "nvidia-normalized-secret", + "NVIDIA_NIM_API_KEY": "nvidia-source-secret", +} +COMMON_SENSITIVE_NAMES = { + "GH_TOKEN", + "GITHUB_TOKEN", + "OPENCODE_APP_TOKEN", + "ACTIONS_ID_TOKEN_REQUEST_TOKEN", + "ACTIONS_ID_TOKEN_REQUEST_URL", + "ACTIONS_RUNTIME_TOKEN", + "ACTIONS_CACHE_URL", + "ACTIONS_RESULTS_URL", + "ACTIONS_RUNTIME_URL", +} +PROVIDER_NAMES = { + "STRIX_GITHUB_MODELS_TOKEN", + "OPENCODE_API_KEY", + "OPENAI_API_KEY", + "OPENROUTER_API_KEY", + "NVIDIA_API_KEY", + "NVIDIA_NIM_API_KEY", +} + + +@pytest.fixture +def fake_opencode(tmp_path: Path) -> Path: + """Create a fake OpenCode executable that prints selected environment keys.""" + script = tmp_path / "fake-opencode.py" + script.write_text( + "#!/usr/bin/env python3\n" + "import json, os, sys\n" + f"names = {sorted(ALL_CREDENTIALS)!r}\n" + "print(json.dumps({name: os.environ.get(name) for name in names}))\n" + "print(json.dumps(sys.argv[1:]))\n", + encoding="utf-8", + ) + script.chmod(0o755) + return script + + +def run_guard(real_bin: str, *arguments: str) -> subprocess.CompletedProcess[str]: + """Run the real guard with an explicit child executable and credentials.""" + env = os.environ.copy() + env.update(ALL_CREDENTIALS) + env["OPENCODE_REAL_BIN"] = real_bin + return subprocess.run( + ["bash", str(GUARD), *arguments], + cwd=ROOT, + env=env, + capture_output=True, + text=True, + check=False, + ) + + +def invoke(fake_opencode: Path, *arguments: str) -> tuple[dict[str, str | None], list[str]]: + """Run the real guard and parse the fake executable's observations.""" + result = run_guard(str(fake_opencode), *arguments) + assert result.returncode == 0, result.stdout + result.stderr + lines = result.stdout.splitlines() + return json.loads(lines[0]), json.loads(lines[1]) + + +def assert_absent(observed: dict[str, str | None], names: set[str]) -> None: + """Assert every named credential was removed from the child environment.""" + assert {name for name in names if observed[name] is not None} == set() + + +def test_anonymous_free_model_receives_no_github_oidc_or_provider_credentials( + fake_opencode: Path, +) -> None: + """Anonymous free candidates never inherit unrelated automation secrets.""" + observed, argv = invoke( + fake_opencode, + "run", + "review prompt", + "--model", + "opencode-free/nemotron-3-ultra-free", + ) + + assert_absent(observed, COMMON_SENSITIVE_NAMES | PROVIDER_NAMES) + assert argv[-2:] == ["--model", "opencode-free/nemotron-3-ultra-free"] + + +@pytest.mark.parametrize( + ("candidate", "kept"), + [ + ("nvidia-nim/nvidia/nemotron-3-super-120b-a12b", {"NVIDIA_API_KEY", "NVIDIA_NIM_API_KEY"}), + ("opencode/gpt-5.6-terra", {"OPENCODE_API_KEY"}), + ("openai/gpt-5.4", {"OPENAI_API_KEY"}), + ("openrouter/openai/gpt-5.4", {"OPENROUTER_API_KEY"}), + ("github-models/openai/gpt-5", {"STRIX_GITHUB_MODELS_TOKEN"}), + ], +) +def test_paid_or_scoped_candidate_receives_only_its_provider_credentials( + fake_opencode: Path, + candidate: str, + kept: set[str], +) -> None: + """Each keyed provider is isolated from every other provider credential.""" + observed, _ = invoke(fake_opencode, "run", "prompt", "--model", candidate) + + assert_absent(observed, COMMON_SENSITIVE_NAMES | (PROVIDER_NAMES - kept)) + assert {name for name in kept if observed[name] is not None} == kept + + +@pytest.mark.parametrize( + "arguments", + [ + ("run", "prompt", "-m", "openai/gpt-5.4"), + ("run", "prompt", "-m=openai/gpt-5.4"), + ], +) +def test_short_model_alias_keeps_only_selected_provider_credentials( + fake_opencode: Path, + arguments: tuple[str, ...], +) -> None: + """OpenCode's ``-m`` aliases receive the same scoped provider credential.""" + observed, _ = invoke(fake_opencode, *arguments) + + assert_absent(observed, COMMON_SENSITIVE_NAMES | (PROVIDER_NAMES - {"OPENAI_API_KEY"})) + assert observed["OPENAI_API_KEY"] == "openai-secret" + + +def test_equals_form_model_argument_keeps_only_selected_provider_credentials( + fake_opencode: Path, +) -> None: + """The supported ``--model=value`` form receives the same scoped credential.""" + observed, argv = invoke(fake_opencode, "run", "prompt", "--model=openai/gpt-5.4") + + assert_absent(observed, COMMON_SENSITIVE_NAMES | (PROVIDER_NAMES - {"OPENAI_API_KEY"})) + assert observed["OPENAI_API_KEY"] == "openai-secret" + assert argv[-1] == "--model=openai/gpt-5.4" + + +def test_option_terminator_stops_model_selector_parsing(fake_opencode: Path) -> None: + """Arguments after ``--`` cannot re-enable provider credentials in the guard.""" + observed, argv = invoke( + fake_opencode, + "run", + "prompt", + "--", + "--model", + "openai/gpt-5.4", + ) + + assert_absent(observed, COMMON_SENSITIVE_NAMES | PROVIDER_NAMES) + assert argv[-3:] == ["--", "--model", "openai/gpt-5.4"] + + +def test_duplicate_model_arguments_fail_closed_before_model_execution( + fake_opencode: Path, +) -> None: + """Ambiguous duplicate model selectors are rejected before secrets reach a child.""" + result = run_guard( + str(fake_opencode), + "run", + "prompt", + "--model", + "openai/gpt-5.4", + "-m=opencode/gpt-5.6-terra", + ) + + assert result.returncode == 64 + assert result.stdout == "" + assert "exactly one" in result.stderr + + +@pytest.mark.parametrize( + ("arguments", "error_fragment"), + [ + (("run", "prompt", "--model", "-m=openai/gpt-5.4"), "exactly one"), + (("run", "prompt", "--model", "--"), "requires a model candidate"), + (("run", "prompt", "--model", "--model=openai/gpt-5.4"), "exactly one"), + ], +) +def test_selector_like_model_values_fail_closed_before_child_execution( + fake_opencode: Path, + arguments: tuple[str, ...], + error_fragment: str, +) -> None: + """A pending model selector may not consume another selector or ``--`` as its value.""" + result = run_guard(str(fake_opencode), *arguments) + + assert result.returncode == 64 + assert result.stdout == "" + assert error_fragment in result.stderr + + +@pytest.mark.parametrize( + ("real_bin", "arguments", "expected_code", "error_fragment"), + [ + ("FAKE", (), 64, "Usage:"), + ("", ("run", "prompt", "--model", "openai/gpt-5.4"), 69, "OPENCODE_REAL_BIN"), + ("/definitely/not/executable", ("run", "prompt", "--model", "openai/gpt-5.4"), 69, "OPENCODE_REAL_BIN"), + ("FAKE", ("run", "prompt", "--model"), 64, "requires a model candidate"), + ("FAKE", ("run", "prompt", "-m"), 64, "requires a model candidate"), + ], +) +def test_guard_argument_and_executable_failures_stop_before_child_execution( + fake_opencode: Path, + real_bin: str, + arguments: tuple[str, ...], + expected_code: int, + error_fragment: str, +) -> None: + """Malformed invocation boundaries fail closed before any credential-bearing child.""" + resolved_bin = str(fake_opencode) if real_bin == "FAKE" else real_bin + result = run_guard(resolved_bin, *arguments) + + assert result.returncode == expected_code + assert result.stdout == "" + assert error_fragment in result.stderr + + +def test_export_receives_no_provider_credentials(fake_opencode: Path) -> None: + """Session export is local and does not inherit any provider credential.""" + observed, argv = invoke(fake_opencode, "export", "session_123") + + assert_absent(observed, COMMON_SENSITIVE_NAMES | PROVIDER_NAMES) + assert argv == ["export", "session_123"] + + +def test_unknown_model_prefix_fails_safe_without_provider_credentials( + fake_opencode: Path, +) -> None: + """New providers default to zero credentials until explicitly classified.""" + observed, _ = invoke(fake_opencode, "run", "prompt", "--model", "unknown/model") + + assert_absent(observed, COMMON_SENSITIVE_NAMES | PROVIDER_NAMES) diff --git a/tests/test_pr_review_autofix_nvidia_nim_contract.py b/tests/test_pr_review_autofix_nvidia_nim_contract.py index 1bbd98750..2296a61e2 100644 --- a/tests/test_pr_review_autofix_nvidia_nim_contract.py +++ b/tests/test_pr_review_autofix_nvidia_nim_contract.py @@ -20,7 +20,7 @@ DOCTORING_RECORD = Path("docs/doctoring/hourly-nvidia-nim-autofix.md") CHANGELOG = Path("CHANGELOG.md") REVIEW_DISPATCH_WORKFLOW = Path(".github/workflows/opencode-review-dispatch.yml") -REVIEW_DISPATCH_BLOB_SHA = "83f6830d5c21a324b4dbcd4e5c21a07968994b81" +REVIEW_DISPATCH_BLOB_SHA = "61d50dd5bf22655b9e7b9e9bdb4d8bf84ad18478" def _workflow_text(path: Path) -> str: