diff --git a/.github/workflows/pr-review-autofix.yml b/.github/workflows/pr-review-autofix.yml index e5475be1b..cc0611eef 100644 --- a/.github/workflows/pr-review-autofix.yml +++ b/.github/workflows/pr-review-autofix.yml @@ -42,6 +42,7 @@ jobs: uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: repository: ContextualWisdomLab/.github + ref: ${{ github.sha }} fetch-depth: 1 persist-credentials: false path: trusted-autofix-source @@ -231,9 +232,9 @@ jobs: EOF jq -n --arg workspace "$TARGET_WORKSPACE" '{ "$schema": "https://opencode.ai/config.json", - "model": "github-models/openai/gpt-5", - "small_model": "github-models/deepseek/deepseek-v3-0324", - "enabled_providers": ["github-models"], + "model": "nvidia-nim/mistralai/mistral-nemotron", + "small_model": "nvidia-nim/nvidia/nemotron-3-nano-30b-a3b", + "enabled_providers": ["nvidia-nim"], "permission": { "edit": "allow", "bash": "deny", @@ -242,10 +243,13 @@ jobs: "glob": "allow", "list": "allow", "task": "deny", + "skill": "deny", + "question": "deny", "webfetch": "deny", "websearch": "deny", "lsp": "deny", - "external_directory": "deny" + "external_directory": "deny", + "doom_loop": "deny" }, "agent": { "ci-autofix": { @@ -261,45 +265,40 @@ jobs: "glob": "allow", "list": "allow", "task": "deny", + "skill": "deny", + "question": "deny", "webfetch": "deny", "websearch": "deny", "lsp": "deny", - "external_directory": "deny" + "external_directory": "deny", + "doom_loop": "deny" } } }, "provider": { - "github-models": { + "nvidia-nim": { "npm": "@ai-sdk/openai-compatible", - "name": "GitHub Models", + "name": "NVIDIA NIM", "options": { - "baseURL": "https://models.github.ai/inference", - "apiKey": "{env:STRIX_GITHUB_MODELS_TOKEN}" + "baseURL": "https://integrate.api.nvidia.com/v1", + "apiKey": "{env:NVIDIA_API_KEY}" }, "models": { - "openai/gpt-5": { - "name": "OpenAI GPT-5", + "mistralai/mistral-nemotron": { + "name": "Mistral Nemotron", "tool_call": true, - "reasoning": true, - "options": { - "reasoningEffort": "high" - }, - "variants": { - "high": { - "reasoningEffort": "high" - } - }, "limit": { - "context": 200000, - "output": 100000 + "context": 128000, + "output": 4096 } }, - "deepseek/deepseek-v3-0324": { - "name": "DeepSeek V3 0324", + "nvidia/nemotron-3-nano-30b-a3b": { + "name": "Nemotron 3 Nano 30B A3B", "tool_call": true, + "reasoning": true, "limit": { "context": 128000, - "output": 4096 + "output": 32768 } } } @@ -310,16 +309,18 @@ jobs: - name: Run OpenCode review autofix if: env.RESOLVE_CONFLICT != 'true' env: - STRIX_GITHUB_MODELS_TOKEN: ${{ secrets.STRIX_GITHUB_MODELS_TOKEN || github.token }} - GITHUB_TOKEN: ${{ secrets.PR_REVIEW_MERGE_TOKEN || secrets.OPENCODE_APPROVE_TOKEN || steps.target_app_token.outputs.token || github.token }} - MODEL: github-models/openai/gpt-5 - USE_GITHUB_TOKEN: "true" + NVIDIA_API_KEY: ${{ secrets.NVIDIA_NIM_API_KEY }} + MODEL: nvidia-nim/mistralai/mistral-nemotron SHARE: "false" NPM_CONFIG_IGNORE_SCRIPTS: "true" NO_COLOR: "1" OPENCODE_AUTOFIX_WORKDIR: ${{ runner.temp }}/opencode-autofix-project run: | set -euo pipefail + if [ -z "${NVIDIA_API_KEY:-}" ]; then + echo "::error::NVIDIA_NIM_API_KEY is required for scheduled OpenCode autofix." + exit 1 + fi prompt_file="${RUNNER_TEMP}/opencode-autofix-prompt.md" allowed_paths_context="$( awk ' @@ -374,7 +375,8 @@ jobs: } trap restore_workspace_config EXIT cd "$TARGET_WORKSPACE" - timeout 18000 opencode run "$(cat "$prompt_file")" \ + env -u GITHUB_TOKEN -u GH_TOKEN -u ACTIONS_ID_TOKEN_REQUEST_TOKEN -u ACTIONS_ID_TOKEN_REQUEST_URL \ + timeout 18000 opencode run "$(cat "$prompt_file")" \ --pure \ --agent ci-autofix \ --model "$MODEL" \ @@ -446,17 +448,20 @@ jobs: - name: Merge base branch and resolve conflicts with OpenCode if: env.RESOLVE_CONFLICT == 'true' env: - STRIX_GITHUB_MODELS_TOKEN: ${{ secrets.STRIX_GITHUB_MODELS_TOKEN || github.token }} + NVIDIA_API_KEY: ${{ secrets.NVIDIA_NIM_API_KEY }} GITHUB_TOKEN: ${{ secrets.PR_REVIEW_MERGE_TOKEN || secrets.OPENCODE_APPROVE_TOKEN || steps.target_app_token.outputs.token || github.token }} GH_TOKEN: ${{ secrets.PR_REVIEW_MERGE_TOKEN || secrets.OPENCODE_APPROVE_TOKEN || steps.target_app_token.outputs.token || github.token }} - MODEL: github-models/openai/gpt-5 - USE_GITHUB_TOKEN: "true" + MODEL: nvidia-nim/mistralai/mistral-nemotron SHARE: "false" NPM_CONFIG_IGNORE_SCRIPTS: "true" NO_COLOR: "1" OPENCODE_AUTOFIX_WORKDIR: ${{ runner.temp }}/opencode-autofix-project run: | set -euo pipefail + if [ -z "${NVIDIA_API_KEY:-}" ]; then + echo "::error::NVIDIA_NIM_API_KEY is required for scheduled OpenCode autofix." + exit 1 + fi cd "$TARGET_WORKSPACE" # Merge the base branch into the detached head. A clean merge stays @@ -516,7 +521,8 @@ jobs: fi } trap restore_workspace_config EXIT - timeout 18000 opencode run "$(cat "$prompt_file")" \ + env -u GITHUB_TOKEN -u GH_TOKEN -u ACTIONS_ID_TOKEN_REQUEST_TOKEN -u ACTIONS_ID_TOKEN_REQUEST_URL \ + timeout 18000 opencode run "$(cat "$prompt_file")" \ --pure \ --agent ci-autofix \ --model "$MODEL" \ diff --git a/docs/doctoring/hourly-nvidia-nim-autofix.md b/docs/doctoring/hourly-nvidia-nim-autofix.md new file mode 100644 index 000000000..5806c766a --- /dev/null +++ b/docs/doctoring/hourly-nvidia-nim-autofix.md @@ -0,0 +1,240 @@ +# Hourly NVIDIA NIM Review-Autofix Boundary + +## Decision + +The write-capable scheduled pull-request autofix agent uses OpenCode with the +NVIDIA NIM API and the organization Actions secret `NVIDIA_NIM_API_KEY`. The +independent read-only review agent remains unchanged and continues to use its +existing credential and model-pool contract. + +This separation is intentional. Review and repair have different privileges: +the review path publishes a verdict, while the autofix path may modify and push +a same-repository pull-request branch. Sharing or silently replacing the review +credential would couple two independent controls and weaken incident +containment. + +## Central MSA ownership + +`ContextualWisdomLab/.github` owns the scheduler, dispatch authorization, +model-provider configuration, credential binding, immutable worker source, and +fail-closed repair contract. Leaf repositories receive the behavior through the +central reusable workflow and do not copy provider credentials or scheduler +implementation. + +The central scheduler established by the baseline repair runs once per hour, +dispatches at most one repair per invocation, and binds its scheduler +implementation to the immutable called-workflow source. The NVIDIA migration +changes only the model transport used by the write-capable autofix worker and +hardens that worker's own default-branch source checkout. + +## Immutable repository-dispatch worker source + +`PR Review Autofix` is a default-branch-only `repository_dispatch` workflow. +GitHub defines `GITHUB_SHA` for `repository_dispatch` as the last commit on the +default branch and runs only a workflow file present on that branch. The +workflow therefore checks out its co-located context builder and policy source +at the exact workflow-run commit: + +```yaml +repository: ContextualWisdomLab/.github +ref: ${{ github.sha }} +fetch-depth: 1 +persist-credentials: false +``` + +Without the explicit `ref`, `actions/checkout` would resolve the repository's +moving default branch at checkout time. A later default-branch push could then +replace trusted scripts after GitHub had already selected the workflow run, +creating a time-of-check/time-of-use gap around a job that receives OIDC and +branch-write capability. The explicit SHA keeps the executed helper source +aligned with the workflow revision selected for the dispatch. + +The client payload remains untrusted metadata. It can identify the intended +target PR only after the workflow re-reads live PR state and verifies exact base +and head refs and SHAs. + +## Provider contract + +The pinned OpenCode runtime is configured with one enabled provider, +`nvidia-nim`, using the OpenAI-compatible adapter and NVIDIA hosted endpoint: + +```text +https://integrate.api.nvidia.com/v1 +``` + +The primary repair model is `mistralai/mistral-nemotron`; the small model used +for bounded helper work is `nvidia/nemotron-3-nano-30b-a3b`. NVIDIA documents +both identifiers. Mistral-Nemotron supports tool calling for agentic workflows. +Nemotron 3 Nano is used as a lower-active-parameter reasoning helper, not as a +fallback provider. + +Only the `nvidia-nim` provider is enabled. GitHub Models configuration, model +identifiers, base URLs, and model-auth fallbacks are absent from the scheduled +autofix execution path. + +## Credential boundary + +The organization secret is bound as: + +```yaml +NVIDIA_API_KEY: ${{ secrets.NVIDIA_NIM_API_KEY }} +``` + +It is present only on the two steps that execute OpenCode: ordinary +review-feedback repair and merge-conflict repair. Earlier metadata collection, +checkout, context preparation, validation, commit, and push steps do not receive +the NVIDIA credential. + +The workflow passes the key through an environment variable and OpenCode +substitutes `{env:NVIDIA_API_KEY}` into provider configuration. The key is never +written to repository files, command arguments, generated prompts, or logs. A +missing secret is a fatal configuration error; the workflow does not fall back +to `GITHUB_TOKEN`, a GitHub Models token, or another provider. + +The ordinary repair step no longer binds a GitHub write token at step scope. The +conflict-repair shell retains GitHub credentials because the same shell must +re-read the live PR and push a verified merge result after model execution. In +both paths, the OpenCode child process is launched through: + +```text +env -u GITHUB_TOKEN -u GH_TOKEN \ + -u ACTIONS_ID_TOKEN_REQUEST_TOKEN -u ACTIONS_ID_TOKEN_REQUEST_URL +``` + +Consequently, model-controlled file operations receive the NVIDIA model +credential and non-secret execution controls, but cannot call GitHub APIs or +mint an OIDC token. GitHub credentials remain available only to reviewed shell +logic before or after the child process. This reduces the consequence of prompt +injection without removing the worker's independently validated branch-update +capability. + +GitHub documents that a missing secret expression resolves to an empty string +and recommends delivering secrets through inputs or environment variables rather +than embedding them in command lines. The explicit preflight prevents an +ambiguous unauthenticated provider request and preserves fail-closed behavior. + +## OpenCode repair sandbox + +OpenCode permissions are permissive unless explicitly restricted. The workflow +therefore denies every non-file interaction that is unnecessary for a bounded +review repair in both the global permission map and the named `ci-autofix` +agent: + +- `bash` +- `task` +- `skill` +- `question` +- `webfetch` +- `websearch` +- `lsp` +- `external_directory` +- `doom_loop` + +The agent may read, search, list, and edit only the validated same-repository PR +worktree. It receives an authoritative file allowlist derived from current +file-scoped actionable review context. The workflow rejects any changed path +outside that allowlist, syntax-checks changed Python, validates workflow files +when `actionlint` is available, rechecks the live head before push, and refuses +to publish unresolved merge markers. + +Explicitly denying `skill`, `question`, and `doom_loop` matters for unattended +execution. OpenCode exposes these as independent permissions; omitted +permissions are not implicitly denied. The worker must not load a broader skill, +pause for interactive approval, or repeat an identical tool action beyond the +bounded workflow contract. + +## GitHub write boundary + +The model transport change does not expand GitHub permissions. GitHub repository +credentials and the NVIDIA model credential remain separate. The existing +short-lived GitHub App/OIDC exchange and branch-write token chain are not used +for model authentication. Conversely, `NVIDIA_NIM_API_KEY` is not used for +GitHub reads or writes. + +Before editing, the workflow validates repository syntax, numeric PR identity, +forty-character base and head SHAs, same-repository branch ownership, open PR +state, and exact live base/head metadata. Before pushing, it re-reads the live +head and fails if the branch moved. The scheduler and worker cannot approve +their own changes, lower branch protection, convert queued checks into success, +or publish a release. + +## Independent review-agent boundary + +`.github/workflows/opencode-review-dispatch.yml` is not modified by this +migration. The regression contract pins that workflow's Git blob SHA +byte-for-byte rather than inferring independence from provider-name strings. +This allows the existing reviewer to retain its own evolving, separately +reviewed model-pool and credential design while proving that this autofix change +did not alter it. + +This is not cosmetic separation: review produces the verdict that gates merge, +whereas autofix proposes branch changes. Keeping their credentials, workflow +sources, and change histories independent limits the blast radius of either +path. + +## Verification contract + +Automated tests must prove all of the following: + +1. The repair scheduler retains the approved hourly cron expression. +2. The OpenCode configuration enables only `nvidia-nim`. +3. Primary and small model identifiers match NVIDIA's published identifiers. +4. The provider uses the OpenAI-compatible package, NVIDIA base URL, and + environment substitution. +5. Exactly two OpenCode execution steps receive `NVIDIA_API_KEY` from + `secrets.NVIDIA_NIM_API_KEY`. +6. GitHub Models credentials, providers, model identifiers, base URLs, and + `USE_GITHUB_TOKEN` model-auth fallback are absent from the autofix workflow. +7. The trusted autofix checkout is pinned to `${{ github.sha }}`, does not use + mutable `main`, and does not persist credentials. +8. Both OpenCode permission maps explicitly deny every non-file interaction + listed in the sandbox section. +9. Both OpenCode subprocesses explicitly remove GitHub and OIDC credentials; + the ordinary model step has no step-level GitHub token binding. +10. The independent review workflow retains its exact reviewed Git blob SHA and + contains no coupling to the autofix event. +11. A missing NVIDIA secret fails before either model process executes. +12. The exact current head passes complete workflow, Python, security, + CodeRabbit, independent-review, unresolved-thread, and branch-protection + gates before merge. + +## Scheduling and activation + +The NVIDIA worker does not create a second scheduler. It is consumed by the +hourly central review-fix scheduler established in the stacked baseline PR. The +hourly production loop becomes active only after both the baseline and this +migration are merged into the protected default branch. Draft or feature-branch +workflow files are not represented as active organization automation. + +## Rollback + +Rollback is a normal revert of the NVIDIA transport commit. A rollback must not +reintroduce an implicit GitHub-token model-auth fallback, GitHub or OIDC +credentials inside the model child process, a mutable trusted source checkout, +permissive unattended-agent tools, or any change to the independent review-agent +credential system. If NVIDIA NIM is unavailable, scheduled autofix must fail +closed while review, checks, and manual maintenance remain available. + +## References + +GitHub, Inc. (n.d.-a). *Events that trigger workflows*. GitHub Docs. Retrieved +August 4, 2026, from +https://docs.github.com/en/enterprise-cloud@latest/actions/reference/workflows-and-actions/events-that-trigger-workflows + +GitHub, Inc. (n.d.-b). *Secrets reference*. GitHub Docs. Retrieved August 4, +2026, from https://docs.github.com/en/actions/reference/security/secrets + +NVIDIA Corporation. (n.d.-a). *LLM APIs*. NVIDIA API Catalog. Retrieved August +4, 2026, from https://docs.api.nvidia.com/nim/reference/llm-apis + +NVIDIA Corporation. (n.d.-b). *Mistralai / mistral-nemotron*. NVIDIA API +Catalog. Retrieved August 4, 2026, from +https://docs.api.nvidia.com/nim/reference/mistralai-mistral-nemotron + +NVIDIA Corporation. (n.d.-c). *NVIDIA / nemotron-3-nano-30b-a3b*. NVIDIA API +Catalog. Retrieved August 4, 2026, from +https://docs.api.nvidia.com/nim/re/reference/nvidia-nemotron-3-nano-30b-a3b + +OpenCode. (2026a). *Permissions*. https://opencode.ai/docs/permissions + +OpenCode. (2026b, July 28). *Providers*. https://opencode.ai/docs/providers diff --git a/tests/test_pr_review_autofix_nvidia_nim_contract.py b/tests/test_pr_review_autofix_nvidia_nim_contract.py new file mode 100644 index 000000000..3a9940d3b --- /dev/null +++ b/tests/test_pr_review_autofix_nvidia_nim_contract.py @@ -0,0 +1,168 @@ +"""Contract tests for the scheduled OpenCode review-autofix trust boundary.""" + +from pathlib import Path +import subprocess + + +AUTOFIX_WORKFLOW = Path(".github/workflows/pr-review-autofix.yml") +FIX_SCHEDULER_WORKFLOW = Path(".github/workflows/pr-review-fix-scheduler.yml") +REVIEW_DISPATCH_WORKFLOW = Path(".github/workflows/opencode-review-dispatch.yml") +REVIEW_DISPATCH_BLOB_SHA = "d826ce67a4299c3730610b2aa5d83803af3406cc" + + +def _workflow_text(path: Path) -> str: + """Read one central workflow as UTF-8 text for static trust-boundary checks.""" + + return path.read_text(encoding="utf-8") + + +def test_review_fix_scheduler_runs_once_each_hour() -> None: + """Keep the actionable-review repair loop on the approved hourly cadence.""" + + scheduler = _workflow_text(FIX_SCHEDULER_WORKFLOW) + + assert 'cron: "23 * * * *"' in scheduler + assert 'cron: "23 */2 * * *"' not in scheduler + + +def test_scheduled_autofix_uses_only_nvidia_nim() -> None: + """Require the write-capable OpenCode autofix agent to use NVIDIA NIM only.""" + + workflow = _workflow_text(AUTOFIX_WORKFLOW) + + required_fragments = ( + '"model": "nvidia-nim/mistralai/mistral-nemotron"', + '"small_model": "nvidia-nim/nvidia/nemotron-3-nano-30b-a3b"', + '"enabled_providers": ["nvidia-nim"]', + '"nvidia-nim": {', + '"npm": "@ai-sdk/openai-compatible"', + '"baseURL": "https://integrate.api.nvidia.com/v1"', + '"apiKey": "{env:NVIDIA_API_KEY}"', + 'NVIDIA_API_KEY: ${{ secrets.NVIDIA_NIM_API_KEY }}', + 'MODEL: nvidia-nim/mistralai/mistral-nemotron', + ) + for fragment in required_fragments: + assert fragment in workflow, fragment + + forbidden_fragments = ( + 'STRIX_GITHUB_MODELS_TOKEN:', + 'MODEL: github-models/', + 'USE_GITHUB_TOKEN:', + '"enabled_providers": ["github-models"]', + '"apiKey": "{env:STRIX_GITHUB_MODELS_TOKEN}"', + '"baseURL": "https://models.github.ai/inference"', + ) + for fragment in forbidden_fragments: + assert fragment not in workflow, fragment + + +def test_trusted_autofix_source_is_bound_to_dispatch_sha() -> None: + """Prevent a moving default branch from replacing trusted autofix scripts.""" + + workflow = _workflow_text(AUTOFIX_WORKFLOW) + checkout_start = workflow.index(" - name: Checkout trusted autofix source") + checkout_end = workflow.index( + " - name: Exchange OpenCode app token", checkout_start + ) + checkout = workflow[checkout_start:checkout_end] + + assert "ref: ${{ github.sha }}" in checkout + assert "ref: main" not in checkout + assert "fetch-depth: 1" in checkout + assert "persist-credentials: false" in checkout + + +def test_opencode_agent_denies_non_file_interactions() -> None: + """Keep unattended repair bounded to local file inspection and edits.""" + + workflow = _workflow_text(AUTOFIX_WORKFLOW) + + for permission_name in ( + "bash", + "task", + "skill", + "question", + "webfetch", + "websearch", + "lsp", + "external_directory", + "doom_loop", + ): + assert workflow.count(f'"{permission_name}": "deny"') == 2 + + +def test_nvidia_nim_secret_is_scoped_to_agent_execution_steps() -> None: + """Prevent the NVIDIA credential from leaking beyond the two OpenCode runs.""" + + workflow = _workflow_text(AUTOFIX_WORKFLOW) + binding = 'NVIDIA_API_KEY: ${{ secrets.NVIDIA_NIM_API_KEY }}' + ordinary_start = workflow.index(" - name: Run OpenCode review autofix") + ordinary_end = workflow.index(" - name: Validate changed files", ordinary_start) + conflict_start = workflow.index( + " - name: Merge base branch and resolve conflicts with OpenCode" + ) + + assert workflow.count(binding) == 2 + assert binding in workflow[ordinary_start:ordinary_end] + assert binding in workflow[conflict_start:] + assert binding not in workflow[:ordinary_start] + assert binding not in workflow[ordinary_end:conflict_start] + + +def test_model_subprocesses_receive_no_github_or_oidc_write_credentials() -> None: + """Strip GitHub write and OIDC credentials from both OpenCode processes.""" + + workflow = _workflow_text(AUTOFIX_WORKFLOW) + ordinary_start = workflow.index(" - name: Run OpenCode review autofix") + ordinary_end = workflow.index(" - name: Validate changed files", ordinary_start) + ordinary = workflow[ordinary_start:ordinary_end] + conflict_start = workflow.index( + " - name: Merge base branch and resolve conflicts with OpenCode" + ) + conflict = workflow[conflict_start:] + sanitized_invocation = ( + "env -u GITHUB_TOKEN -u GH_TOKEN " + "-u ACTIONS_ID_TOKEN_REQUEST_TOKEN -u ACTIONS_ID_TOKEN_REQUEST_URL" + ) + + assert "GITHUB_TOKEN:" not in ordinary + assert "GH_TOKEN:" not in ordinary + assert sanitized_invocation in ordinary + assert sanitized_invocation in conflict + assert workflow.count(sanitized_invocation) == 2 + + +def test_missing_nvidia_nim_secret_fails_closed_before_model_execution() -> None: + """Reject an empty model credential instead of falling back to another provider.""" + + workflow = _workflow_text(AUTOFIX_WORKFLOW) + guard = ( + 'if [ -z "${NVIDIA_API_KEY:-}" ]; then\n' + ' echo "::error::NVIDIA_NIM_API_KEY is required for scheduled ' + 'OpenCode autofix."\n' + " exit 1\n" + " fi" + ) + ordinary_start = workflow.index(" - name: Run OpenCode review autofix") + ordinary_end = workflow.index(" - name: Validate changed files", ordinary_start) + conflict_start = workflow.index( + " - name: Merge base branch and resolve conflicts with OpenCode" + ) + + assert workflow.count(guard) == 2 + assert guard in workflow[ordinary_start:ordinary_end] + assert guard in workflow[conflict_start:] + + +def test_independent_review_agent_key_system_is_unchanged() -> None: + """Pin the existing read-only reviewer workflow byte-for-byte.""" + + result = subprocess.run( + ["git", "hash-object", str(REVIEW_DISPATCH_WORKFLOW)], + check=True, + capture_output=True, + text=True, + ) + + assert result.stdout.strip() == REVIEW_DISPATCH_BLOB_SHA + assert "pr-review-autofix" not in _workflow_text(REVIEW_DISPATCH_WORKFLOW)