From f449a8de61a113808acaa14369d5b6fa7874dd34 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 17 Aug 2026 00:58:49 +0900 Subject: [PATCH 1/3] fix(opencode): hook private free-model policy onto the live runner Do not replace the current model-pool implementation. Source a thin hook that opt-in enables governed anonymous free candidates from an unchanged trusted-base policy and wraps opencode with a provider credential guard. Unit tests that pass candidates directly are left alone unless PR_BASE_SHA or visibility is set. --- CHANGELOG.md | 1 + .../opencode-private-free-model-policy.md | 241 ++++++++++++++++ .../opencode-private-free-models.json | 6 + .../ci/opencode_private_free_model_hook.sh | 204 +++++++++++++ .../ci/opencode_private_free_model_policy.py | 269 ++++++++++++++++++ scripts/ci/opencode_provider_guard.sh | 141 +++++++++ scripts/ci/run_opencode_review_model_pool.sh | 5 + ...encode_private_free_model_hook_contract.py | 24 ++ ...st_opencode_private_free_model_policy_1.py | 251 ++++++++++++++++ ...st_opencode_private_free_model_policy_2.py | 226 +++++++++++++++ ...st_opencode_private_free_model_policy_3.py | 220 ++++++++++++++ tests/test_opencode_provider_guard.py | 258 +++++++++++++++++ 12 files changed, 1846 insertions(+) create mode 100644 docs/doctoring/opencode-private-free-model-policy.md create mode 100644 docs/examples/opencode-private-free-models.json create mode 100755 scripts/ci/opencode_private_free_model_hook.sh create mode 100755 scripts/ci/opencode_private_free_model_policy.py create mode 100755 scripts/ci/opencode_provider_guard.sh create mode 100644 tests/test_opencode_private_free_model_hook_contract.py create mode 100644 tests/test_opencode_private_free_model_policy_1.py create mode 100644 tests/test_opencode_private_free_model_policy_2.py create mode 100644 tests/test_opencode_private_free_model_policy_3.py create mode 100644 tests/test_opencode_provider_guard.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 1de9130a5..77f0814c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ Semantic Versioning where the repository publishes a release. ### Fixed +- 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..73cfc3b97 --- /dev/null +++ b/docs/doctoring/opencode-private-free-model-policy.md @@ -0,0 +1,241 @@ +# 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: + +- a trusted caller may provide `OPENCODE_REPOSITORY_IS_PRIVATE=false`; or +- when that 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. + +`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/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..46d28e672 --- /dev/null +++ b/tests/test_opencode_private_free_model_hook_contract.py @@ -0,0 +1,24 @@ +"""The live model-pool runner must source the private free-model hook.""" + +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" + + +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 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) From 943b804033512c838daca89f9881c05dc0971fdd Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 17 Aug 2026 01:21:24 +0900 Subject: [PATCH 2/3] fix(opencode): pass trusted visibility into the live model pool Export validate-pr-metadata is_private as OPENCODE_REPOSITORY_IS_PRIVATE next to PR_BASE_SHA so public reviews keep the governed free catalog without a credential-free ls-remote probe. Keep ls-remote as fallback only when that trusted signal is absent. Add hook-level visibility contracts and pin the updated review-dispatch blob. --- .../workflows/opencode-review-dispatch.yml | 1 + CHANGELOG.md | 1 + .../opencode-private-free-model-policy.md | 11 +- scripts/ci/test_strix_quick_gate.sh | 2 + ...encode_private_free_model_hook_contract.py | 214 ++++++++++++++++++ ...t_pr_review_autofix_nvidia_nim_contract.py | 2 +- 6 files changed, 227 insertions(+), 4 deletions(-) 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 77f0814c9..03f9939d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ 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). diff --git a/docs/doctoring/opencode-private-free-model-policy.md b/docs/doctoring/opencode-private-free-model-policy.md index 73cfc3b97..1d65d814d 100644 --- a/docs/doctoring/opencode-private-free-model-policy.md +++ b/docs/doctoring/opencode-private-free-model-policy.md @@ -92,10 +92,15 @@ or merge gates. 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 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. +- 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 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 index 46d28e672..c567800bf 100644 --- a/tests/test_opencode_private_free_model_hook_contract.py +++ b/tests/test_opencode_private_free_model_hook_contract.py @@ -1,5 +1,11 @@ """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 @@ -8,6 +14,17 @@ 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: @@ -22,3 +39,200 @@ def test_live_runner_sources_private_free_model_hook() -> None: 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_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: From bf317d7b2f081d01879da452857fd05305f17563 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 16:27:31 +0000 Subject: [PATCH 3/3] fix(opencode): fail closed when visibility and base SHA are missing Strip preconfigured opencode-free aliases when both trusted visibility and the PR base SHA are absent, and never forward COPILOT_GITHUB_TOKEN into an OpenCode child process. Co-authored-by: Seongho Bae --- AGENTS.md | 1 + ARCHITECTURE.md | 30 +++++++++++++++++++ CHANGELOG.md | 2 ++ .../opencode-private-free-model-policy.md | 28 +++++++++++------ .../ci/opencode_private_free_model_hook.sh | 8 ----- scripts/ci/opencode_provider_guard.sh | 1 + tests/test_opencode_model_pool_runner.py | 1 + ...encode_private_free_model_hook_contract.py | 10 +++++++ tests/test_opencode_provider_guard.py | 2 ++ 9 files changed, 66 insertions(+), 17 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 16f0981c0..cceb5e810 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,3 +5,4 @@ Materialize accepts only exact SHA-256 pins or a bounded relative `-r` include (no `.`/`..`); a lone `--require-hashes` directive is not trust evidence. See [`docs/doctoring/hourly-nvidia-nim-autofix.md`](docs/doctoring/hourly-nvidia-nim-autofix.md). Conflict-scope roots fail closed when the immediate parent directory is a symbolic link. +Private repositories use anonymous `opencode-free/*` reviewers only from an unchanged trusted-base policy; missing visibility and missing base SHA strip those aliases. The provider guard never forwards `COPILOT_GITHUB_TOKEN`. See [`docs/doctoring/opencode-private-free-model-policy.md`](docs/doctoring/opencode-private-free-model-policy.md). diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 6fe6621b6..7c333c3ee 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -74,6 +74,34 @@ sequenceDiagram MS->>PR: merge only on current-head approval + green checks ``` +## Private free-model opt-in + +```mermaid +flowchart TD + Vis{"Trusted OPENCODE_REPOSITORY_IS_PRIVATE?"} + Public["Keep only the governed zero-cost catalog"] + Strip["Strip every opencode-free alias"] + Policy{"Unchanged trusted-base policy?"} + Enable["Prepend governed free catalog"] + Keyed["Keep keyed fallbacks only"] + Guard["Provider guard: one credential, never COPILOT_GITHUB_TOKEN"] + + Vis -->|"false"| Public + Vis -->|"true / missing / invalid"| Strip + Strip --> Policy + Policy -->|"yes"| Enable + Policy -->|"no"| Keyed + Public --> Guard + Enable --> Guard + Keyed --> Guard +``` + +A private sibling repository may use anonymous `opencode-free/*` reviewers only +after its protected base already contains +`.github/opencode-private-free-models.json` and the reviewed head does not +change that file. Missing visibility and missing base SHA fail closed. See +[`docs/doctoring/opencode-private-free-model-policy.md`](docs/doctoring/opencode-private-free-model-policy.md). + ## Trust boundaries - Required review workflows execute **base-branch** scripts. A PR that edits @@ -104,6 +132,8 @@ tests pin workflow structure and governance prose so drift fails closed. — Project #1 operation. - [`PR_GOVERNANCE_AUDIT.md`](PR_GOVERNANCE_AUDIT.md) — live review/merge contract. +- [`docs/doctoring/opencode-private-free-model-policy.md`](docs/doctoring/opencode-private-free-model-policy.md) + — trusted-base private free-model opt-in and provider-scoped credentials. - [`docs/doctoring/hourly-nvidia-nim-autofix.md`](docs/doctoring/hourly-nvidia-nim-autofix.md) — current increment's repair-worker decision and APA 7th citations. - [`docs/doctoring/fast-mlsirm-hourly-review-caller.md`](docs/doctoring/fast-mlsirm-hourly-review-caller.md) diff --git a/CHANGELOG.md b/CHANGELOG.md index 03f9939d1..0f47206f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,8 @@ Semantic Versioning where the repository publishes a release. ### Fixed +- Treat missing OpenCode repository visibility and missing PR base SHA as fail-closed: strip preconfigured `opencode-free/*` aliases instead of keeping them for unit-test convenience. +- Remove `COPILOT_GITHUB_TOKEN` from every OpenCode child process so the provider guard cannot leak a Copilot token to anonymous or keyed models. - 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. diff --git a/docs/doctoring/opencode-private-free-model-policy.md b/docs/doctoring/opencode-private-free-model-policy.md index 1d65d814d..21f95c33c 100644 --- a/docs/doctoring/opencode-private-free-model-policy.md +++ b/docs/doctoring/opencode-private-free-model-policy.md @@ -104,10 +104,12 @@ first needs positive visibility evidence: `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. +fail-closed. Missing both `OPENCODE_REPOSITORY_IS_PRIVATE` and `PR_BASE_SHA` is +the same fail-closed case: the wrapper strips every preconfigured anonymous +candidate rather than treating a unit-test convenience as production +authorization. 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 @@ -119,7 +121,8 @@ 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 tokens, including `GITHUB_TOKEN`, `GH_TOKEN`, and + `COPILOT_GITHUB_TOKEN` - GitHub Actions OIDC request credentials - GitHub Actions runtime, cache, or results credentials - OpenCode application tokens @@ -198,6 +201,12 @@ review evidence may be confidential even when no Actions secret is configured. Rejected because untrusted code could authorize its own external disclosure. The marker must already exist on the base and remain unchanged in the head. +### Skip policy evaluation when unit tests omit visibility and base SHA + +Rejected because a missing production export must not keep preconfigured +anonymous candidates. Local runner fixtures that need the public catalog must +set `OPENCODE_REPOSITORY_IS_PRIVATE=false` explicitly. + ### Trust a preconfigured `opencode-free/*` candidate as proof of eligibility Rejected because candidate text is not a data-classification or visibility @@ -219,10 +228,11 @@ 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. +anonymous free credential removal including `COPILOT_GITHUB_TOKEN`, 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, missing visibility and base SHA +fail-closed stripping, 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 diff --git a/scripts/ci/opencode_private_free_model_hook.sh b/scripts/ci/opencode_private_free_model_hook.sh index 66553c1f8..5a3f051e9 100755 --- a/scripts/ci/opencode_private_free_model_hook.sh +++ b/scripts/ci/opencode_private_free_model_hook.sh @@ -190,14 +190,6 @@ apply_private_free_model_policy() { 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_provider_guard.sh b/scripts/ci/opencode_provider_guard.sh index fcd2e4078..b7fe78872 100755 --- a/scripts/ci/opencode_provider_guard.sh +++ b/scripts/ci/opencode_provider_guard.sh @@ -71,6 +71,7 @@ environment=( env -u GH_TOKEN -u GITHUB_TOKEN + -u COPILOT_GITHUB_TOKEN -u OPENCODE_APP_TOKEN -u ACTIONS_ID_TOKEN_REQUEST_TOKEN -u ACTIONS_ID_TOKEN_REQUEST_URL diff --git a/tests/test_opencode_model_pool_runner.py b/tests/test_opencode_model_pool_runner.py index 08d17f000..4a504ffd1 100644 --- a/tests/test_opencode_model_pool_runner.py +++ b/tests/test_opencode_model_pool_runner.py @@ -194,6 +194,7 @@ def run_failed_model( "OPENCODE_MODEL_CANDIDATES": model_candidates, "OPENCODE_OUTPUT_FILE": bash_path(tmp_path / "selected-output.md"), "OPENCODE_POOL_MAX_CYCLES": "1", + "OPENCODE_REPOSITORY_IS_PRIVATE": "false", "OPENCODE_REVIEW_WORKDIR": bash_path(review_dir), "OPENCODE_RUN_TIMEOUT_SECONDS": "10", "OPENCODE_SOURCE_WORKDIR": bash_path(source_dir), diff --git a/tests/test_opencode_private_free_model_hook_contract.py b/tests/test_opencode_private_free_model_hook_contract.py index c567800bf..e46b141ec 100644 --- a/tests/test_opencode_private_free_model_hook_contract.py +++ b/tests/test_opencode_private_free_model_hook_contract.py @@ -214,6 +214,16 @@ def test_unchanged_eligible_base_policy_prepends_governed_catalog( assert "opencode-free/deepseek-v4-flash-free" in candidates +def test_missing_visibility_and_base_sha_strips_free_aliases(tmp_path: Path) -> None: + """Missing visibility and base SHA are not a unit-test exemption.""" + result = run_hook(tmp_path, extra_env={}) + 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_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" diff --git a/tests/test_opencode_provider_guard.py b/tests/test_opencode_provider_guard.py index db7211e0c..2f43a6cd6 100644 --- a/tests/test_opencode_provider_guard.py +++ b/tests/test_opencode_provider_guard.py @@ -15,6 +15,7 @@ ALL_CREDENTIALS = { "GH_TOKEN": "gh-secret", "GITHUB_TOKEN": "github-secret", + "COPILOT_GITHUB_TOKEN": "copilot-secret", "OPENCODE_APP_TOKEN": "app-secret", "ACTIONS_ID_TOKEN_REQUEST_TOKEN": "oidc-secret", "ACTIONS_ID_TOKEN_REQUEST_URL": "https://oidc.invalid", @@ -32,6 +33,7 @@ COMMON_SENSITIVE_NAMES = { "GH_TOKEN", "GITHUB_TOKEN", + "COPILOT_GITHUB_TOKEN", "OPENCODE_APP_TOKEN", "ACTIONS_ID_TOKEN_REQUEST_TOKEN", "ACTIONS_ID_TOKEN_REQUEST_URL",