diff --git a/.github/workflows/noema-review.yml b/.github/workflows/noema-review.yml index 22ab52deb9..5c60782adb 100644 --- a/.github/workflows/noema-review.yml +++ b/.github/workflows/noema-review.yml @@ -316,10 +316,11 @@ jobs: echo "::error::Noema reviewer credential selection succeeded but no token was minted; review cannot submit a verdict." exit 1 fi - if [ -z "${CONTEXTUAL_ORCHESTRATOR_BASE_URL:-}" ] || [ -z "${CONTEXTUAL_ORCHESTRATOR_TOKEN:-}" ]; then + if [ -z "${CONTEXTUAL_ORCHESTRATOR_BASE_URL:-}" ] || [ -z "${CONTEXTUAL_ORCHESTRATOR_TOKEN_FILE:-}" ]; then echo "::error::contextual-orchestrator review sidecar must be provisioned before Noema LLM review." exit 1 fi + source "$GITHUB_WORKSPACE/scripts/ci/load_contextual_orchestrator_token.sh" export NOEMA_LLM_API_URL="${CONTEXTUAL_ORCHESTRATOR_BASE_URL%/}/v1/chat/completions" export NOEMA_LLM_MODEL="orchestrator/free" export NOEMA_LLM_API_KEY="${CONTEXTUAL_ORCHESTRATOR_TOKEN}" diff --git a/.github/workflows/opencode-review-dispatch.yml b/.github/workflows/opencode-review-dispatch.yml index 8d09540844..3068fbc365 100644 --- a/.github/workflows/opencode-review-dispatch.yml +++ b/.github/workflows/opencode-review-dispatch.yml @@ -3998,6 +3998,7 @@ jobs: RUN_ATTEMPT: ${{ github.run_attempt }} run: | set -euo pipefail + source "$GITHUB_WORKSPACE/scripts/ci/load_contextual_orchestrator_token.sh" set +e timeout --kill-after=30s "${OPENCODE_POOL_STEP_TIMEOUT_SECONDS:-3600}s" \ bash "$GITHUB_WORKSPACE/scripts/ci/run_opencode_review_model_pool.sh" @@ -5932,6 +5933,7 @@ jobs: printf 'Skipping publish-step failed-check OpenCode diagnosis for central review-process self-repair; using collected current-head failed-check logs/SARIF fallback so the publish step stays bounded.\n' >&2 return 1 fi + source "$GITHUB_WORKSPACE/scripts/ci/load_contextual_orchestrator_token.sh" if [ -z "${CONTEXTUAL_ORCHESTRATOR_TOKEN:-}" ]; then return 1 fi diff --git a/.github/workflows/pr-review-autofix.yml b/.github/workflows/pr-review-autofix.yml index 5de7ae89ca..005303b822 100644 --- a/.github/workflows/pr-review-autofix.yml +++ b/.github/workflows/pr-review-autofix.yml @@ -376,10 +376,11 @@ jobs: OPENCODE_AUTOFIX_WORKDIR: ${{ runner.temp }}/opencode-autofix-project run: | set -euo pipefail - if [ -z "${CONTEXTUAL_ORCHESTRATOR_BASE_URL:-}" ] || [ -z "${CONTEXTUAL_ORCHESTRATOR_TOKEN:-}" ]; then + if [ -z "${CONTEXTUAL_ORCHESTRATOR_BASE_URL:-}" ] || [ -z "${CONTEXTUAL_ORCHESTRATOR_TOKEN_FILE:-}" ]; then echo "::error::contextual-orchestrator review sidecar must be provisioned before scheduled OpenCode autofix." exit 1 fi + source "$GITHUB_WORKSPACE/trusted-autofix-source/scripts/ci/load_contextual_orchestrator_token.sh" prompt_file="${RUNNER_TEMP}/opencode-autofix-prompt.md" allowed_paths_zlist="${RUNNER_TEMP}/pr-review-autofix-allowed-paths.zlist" allowed_paths_context="$( @@ -581,10 +582,11 @@ jobs: echo "::error::Conflict-resolution mutation requires PR_REVIEW_MERGE_TOKEN, OPENCODE_APPROVE_TOKEN, or the exchanged OpenCode app token; github.token remains read-only." exit 1 fi - if [ -z "${CONTEXTUAL_ORCHESTRATOR_BASE_URL:-}" ] || [ -z "${CONTEXTUAL_ORCHESTRATOR_TOKEN:-}" ]; then + if [ -z "${CONTEXTUAL_ORCHESTRATOR_BASE_URL:-}" ] || [ -z "${CONTEXTUAL_ORCHESTRATOR_TOKEN_FILE:-}" ]; then echo "::error::contextual-orchestrator review sidecar must be provisioned before scheduled OpenCode conflict resolution." exit 1 fi + source "$GITHUB_WORKSPACE/trusted-autofix-source/scripts/ci/load_contextual_orchestrator_token.sh" cd "$TARGET_WORKSPACE" # Merge the base branch into the detached head. A clean merge stays diff --git a/.github/workflows/strix.yml b/.github/workflows/strix.yml index 345d02f0d4..eb90154eae 100644 --- a/.github/workflows/strix.yml +++ b/.github/workflows/strix.yml @@ -650,6 +650,8 @@ jobs: env: PROVIDER_MODE: ${{ steps.gate.outputs.provider_mode }} run: | + set -euo pipefail + source "$TRUSTED_STRIX_SOURCE/scripts/ci/load_contextual_orchestrator_token.sh" if [ "$PROVIDER_MODE" != "contextual_orchestrator" ]; then echo '::error::Strix must use the contextual-orchestrator provider.' exit 1 @@ -670,6 +672,8 @@ jobs: env: PROVIDER_MODE: ${{ steps.gate.outputs.provider_mode }} run: | + set -euo pipefail + source "$TRUSTED_STRIX_SOURCE/scripts/ci/load_contextual_orchestrator_token.sh" if [ "$PROVIDER_MODE" != "contextual_orchestrator" ]; then echo '::error::Strix must use the contextual-orchestrator provider.' exit 1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a0a95f547..257f84a0e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,17 @@ this file. The format follows Keep a Changelog, and versioned releases follow Semantic Versioning where the repository publishes a release. ## [Unreleased] +- Harden the contextual-orchestrator Strix sidecar by rejecting line-breaking + bearer tokens and masking the token before clone, install, launch, or health + diagnostics can emit it. The raw bearer no longer enters `GITHUB_ENV` (where + a later step header could render it before masking); only a mode-0600 token + file path crosses steps, and each model consumer validates and masks the file + inside its own step. The bounded required-workflow smoke now parses every + governed shell input independently, including the sidecar and token loader. + Strix also qualifies only the loopback child model as + `openai/orchestrator/free`, which satisfies LiteLLM's explicit-provider + contract while preserving `orchestrator/free` at the gateway boundary; a + missing, empty, or non-pinned contextual-orchestrator API base fails closed. - Restore OpenCode coverage honesty and mermaid surfaces stacked on main after #1360 squash `17052a7c`: `publish_fallback_diff_review` posts a COMMENT product-file review then `request_changes_for_coverage_evidence_failure` sets the status comment to `COVERAGE_BLOCKED` so a coverage miss never looks finished as `Gate result: COMMENT`; mermaid labels crates/packages instead of generic `Changed file (N files)` and does not invent class edges; findings say `Review process` instead of `.github/workflows/opencode-review.yml:1` unless that file is in the diff. Does not change `noema-review.yml` (PM owns `feat/noema-orchestrator-free-zdr`) and is not NIM-2h or GitHub Models. - Required OpenCode dispatch and Strix now use the vendored `contextual-orchestrator/orchestrator/free` gateway for model execution and diff --git a/docs/doctoring/contextual-orchestrator-vendored-sidecar.md b/docs/doctoring/contextual-orchestrator-vendored-sidecar.md index 52854c5c72..07f53f28a9 100644 --- a/docs/doctoring/contextual-orchestrator-vendored-sidecar.md +++ b/docs/doctoring/contextual-orchestrator-vendored-sidecar.md @@ -52,6 +52,10 @@ orchestrator's `review_gateway.REVIEW_CREDENTIAL_NAMES`. - The gateway binds to loopback only; it never leaves the runner. Secrets are bootstrap transport into the KV and are never read back from environment at request time. +- The generated bearer is stored in a runner-owned mode-0600 regular file. + `GITHUB_ENV` carries only that path; every Noema, Strix, OpenCode review, and + autofix consumer validates ownership, mode, symlink status, size, and line + structure before reading and masking the bearer inside its own step. - Noema reviewer identity is unchanged: `NOEMA_REVIEW_TOKEN` / GitHub App / OIDC. Review mutation is still not `github.token`. @@ -77,7 +81,9 @@ training (OpenRouter's own stance). Evidence sources: - `scripts/ci/contextual_orchestrator_review_launcher.py` — same-process KV registration + discovery + serve (runs in the vendored runtime only). - `scripts/ci/contextual_orchestrator_review_sidecar.sh` — pinned-SHA vendoring + - health gate + GITHUB_ENV export. + health gate + private token-file creation and path export. +- `scripts/ci/load_contextual_orchestrator_token.sh` — per-step file validation, + bearer masking, and process-local export for the consuming model command. - `tests/test_zdr_policy.py`, `tests/test_contextual_orchestrator_review_policy.py`, `tests/test_contextual_orchestrator_review_sidecar_contract.py`, @@ -91,27 +97,34 @@ training (OpenRouter's own stance). Evidence sources: The first post-merge Strix execution (`33139957477`) failed before serving: the pinned orchestrator's `load_agents()` indexes the top-level `agents` field, but the launcher persisted only the list value. Follow-up PR [#1370](https://github.com/ContextualWisdomLab/.github/pull/1370) -wraps both the launcher output and the standalone policy builder output in the -loader-compatible `{"agents": [...]}` envelope. The regression is covered by -`tests/test_contextual_orchestrator_review_policy.py` and the sidecar contract; -the full local suite passed with `1689 passed, 1 skipped, 16 subtests passed`. - -The PR-target Noema check still runs the trusted base copy until this trusted -workflow change is merged, so its reproduction of the old error is retained as -bootstrap evidence rather than treated as a current-head runtime result. - -## 2026-08-28 post-#1370 runtime correction - -Main push run `33141468804` confirmed that the catalog envelope correction -reached the Strix sidecar, but LiteLLM rejected the child model -`orchestrator/free` because it had no provider prefix. Follow-up commits -`9f58d74` and `5aa0a20` map only the pinned gateway request to -`openai/orchestrator/free`, fail closed when that gateway base is absent or -not loopback, and keep the loopback sidecar receiving `orchestrator/free`. - -The same follow-up masks the dynamic sidecar bearer before writing `GITHUB_ENV` -and rejects carriage returns/newlines in an override. This closes the runtime -log exposure observed in the Noema step environment block. Focused contracts -pass (`32 passed`) and the full local suite passes (`1689 passed, 1 skipped`). -The main Strix rerun and an independently authorized Noema model verdict are -still required before claiming end-to-end review completion. +wrapped both the launcher output and the standalone policy builder output in the +loader-compatible `{"agents": [...]}` envelope and merged exact head +`0f40d415b112ca0055f5db5b2f434788b08f01f1` into protected +`main@24ee38b097dbfc1a895e1199ade48cff36431d05`. The regression is covered by +`tests/test_contextual_orchestrator_review_policy.py` and the sidecar contract. + +The earlier PR-target Noema failure remains bootstrap evidence because it ran +the pre-fix trusted base copy. Operational acceptance now requires a fresh +protected-main run that starts the corrected sidecar, passes authenticated +health, and reaches the scanner; queued or cancelled jobs are non-passing. + +The first corrected-catalog Noema canary reached authenticated health and the +review gate, but its retained job log showed that exporting the raw bearer via +`GITHUB_ENV` exposed it in the next step's rendered environment header before +that step could mask it. The causal repair therefore exports only a private +token-file path and rehydrates the bearer after each consumer step starts. No +credential value is retained in this record. + +Protected-main Strix run `33141468804` then proved that the corrected catalog +reached the sidecar, but LiteLLM rejected the unqualified child model +`orchestrator/free` because its provider was not explicit. The repair keeps the +public/gateway model `contextual-orchestrator/orchestrator/free` and maps only +the scanner child to `openai/orchestrator/free` when its API base is exactly +`http://127.0.0.1:18080/v1`. Missing, empty, or other contextual-orchestrator +API bases fail closed. A fresh protected-main run is still required for +operational acceptance. + +PR #1373 merged the model qualification into +`main@8f84b661e468de451ba5c076dc938f342bf52d70`, but retained the raw bearer in +`GITHUB_ENV`. PR #1369 supersedes that credential boundary with file-only +cross-step transport; source integration alone remains insufficient acceptance. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 25b5a8061f..5a31b19147 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -258,35 +258,29 @@ flowchart LR - Post-merge Strix run `33139957477` exposed a real sidecar runtime defect: `contextual_orchestrator.orchestrator.load_agents()` requires an `{"agents": [...]}` catalog envelope, while the launcher wrote a bare list. - Follow-up #1370 fixes the launcher and the standalone policy catalog writer - in commit `861463c11a7ca8b1f9179073e2a3db9eba5aa5ab`; its current head is - `38e0307c655823a1e474b29aae89f8cfcb1edbc0`. Focused tests and the full local - suite pass (`1689 passed, 1 skipped, 16 subtests passed`). -- #1370 merged on `24ee38b…`; its pre-merge PR-target Noema run - `33140830199` executed the trusted base launcher and reproduced the - pre-fix bare-list error. The post-merge push run below shows that the - catalog-envelope fix reached the Strix sidecar successfully. - -## 2026-08-28 post-#1370 Strix runtime recheck - -- Main push run `33141468804` reached `Provision contextual-orchestrator Strix - sidecar` successfully, then failed in `Run Strix (quick)`. LiteLLM rejected - the unqualified child model `orchestrator/free` with `LLM Provider NOT - provided`; this is a request-shape defect, not evidence that the sidecar - catalog failed. -- Follow-up commits `9f58d74` and `5aa0a20` qualify only the LiteLLM child - request as `openai/orchestrator/free` when the API base is the pinned - loopback gateway; the gateway still receives `orchestrator/free` and owns - discovery/failover. They also fail closed when that base is absent or not - the pinned loopback, register the dynamic bearer token with `::add-mask::` - before exporting `GITHUB_ENV`, and reject token newlines. Focused contracts - pass (`32 passed`); the full local suite passes (`1689 passed, 1 skipped`). -- A real current-main Noema run on #1369 (`33141494393`) also booted the - sidecar and executed the Noema gate, but correctly skipped the LLM verdict - because the exact head had no primary OpenCode approval. A successful - sidecar/bootstrap step is not counted as a model-review result; post-fix - Strix completion and an independently authorized Noema verdict remain - separate evidence items. + Follow-up #1370 fixes the launcher and the standalone policy catalog writer. + Its exact head `0f40d415b112ca0055f5db5b2f434788b08f01f1` merged as + `24ee38b097dbfc1a895e1199ade48cff36431d05`. +- #1370's earlier PR-target Noema run `33140830199` executed the pre-fix trusted + base launcher and is retained only as bootstrap reproduction evidence. A + fresh protected-main canary must start the corrected sidecar and reach the + scanner before the runtime gap is closed; queued or cancelled jobs do not + satisfy that acceptance boundary. +- Protected-main Strix run `33141468804` crossed the corrected catalog and + sidecar boundary, then LiteLLM rejected the unqualified scanner child model + `orchestrator/free` because the provider was not explicit. The follow-up maps + only that child to `openai/orchestrator/free` when the API base is the pinned + loopback gateway; the public gateway model remains + `contextual-orchestrator/orchestrator/free`, and absent, empty, or non-pinned + bases fail closed. This is reproduction evidence, not operational acceptance. +- #1370 merged with no `APPROVED` review; all recorded Reviews API verdicts are + `COMMENTED`. That governance contradiction is tracked in #1340 and is not + retrospective approval evidence for this runtime correction. +- #1373 merged the model qualification as `8f84b661…` but retained the raw + bearer in `GITHUB_ENV`, so its log-exposure claim is contradicted by source. + #1369 preserves the merged model behavior while moving cross-step credential + transport to a validated mode-0600 file. Fresh protected-main Strix and Noema + evidence is still required after that stronger boundary integrates. ## 2026-08-28 post-#1373 request-envelope recheck diff --git a/scripts/ci/contextual_orchestrator_review_sidecar.sh b/scripts/ci/contextual_orchestrator_review_sidecar.sh old mode 100644 new mode 100755 index fdb300ed29..023cedb386 --- a/scripts/ci/contextual_orchestrator_review_sidecar.sh +++ b/scripts/ci/contextual_orchestrator_review_sidecar.sh @@ -1,7 +1,9 @@ #!/usr/bin/env bash # Provision the vendored contextual-orchestrator review sidecar on a GitHub -# Actions runner and export CONTEXTUAL_ORCHESTRATOR_BASE_URL / _TOKEN to -# $GITHUB_ENV (when set). +# Actions runner and export the loopback URL plus a private bearer-file path to +# $GITHUB_ENV (when set). The raw bearer must never cross a step boundary in the +# runner environment because GitHub renders that environment before a later +# step can issue its own add-mask command. # # The five provider secrets arrive as bootstrap transport only (Actions env) and # are registered into the process-local KV by the launcher in the SAME process @@ -45,16 +47,25 @@ fi log "provider secrets present: $provider_secret_count of 5" ORCHESTRATOR_TOKEN="${ORCHESTRATOR_TOKEN:-$(python3 -c 'import secrets; print(secrets.token_urlsafe(32))')}" -if [[ "$ORCHESTRATOR_TOKEN" == *$'\r'* || "$ORCHESTRATOR_TOKEN" == *$'\n'* ]]; then - fail "ORCHESTRATOR_TOKEN must not contain carriage returns or newlines" -fi -if [ -n "${GITHUB_ACTIONS:-}" ]; then - # Register the process-local bearer token before exporting it through - # GITHUB_ENV; later step environment blocks otherwise echo it verbatim. - echo "::add-mask::$ORCHESTRATOR_TOKEN" +case "$ORCHESTRATOR_TOKEN" in + *$'\r'*|*$'\n'*) fail "ORCHESTRATOR_TOKEN must not contain CR or LF" ;; +esac +# Mask the bearer before clone, dependency installation, launcher startup, or +# health diagnostics can emit it. Later masking is too late for earlier logs, +# but workflow commands are safe only on an Actions runner; elsewhere this +# would print the raw bearer to ordinary stdout. +if [ "${GITHUB_ACTIONS:-}" = "true" ]; then + printf '::add-mask::%s\n' "$ORCHESTRATOR_TOKEN" fi mkdir -p "$ORCHESTRATOR_WORK" +chmod 700 -- "$ORCHESTRATOR_WORK" +token_file="$ORCHESTRATOR_WORK/bearer.token" +( + umask 077 + printf '%s' "$ORCHESTRATOR_TOKEN" > "$token_file" +) +chmod 600 -- "$token_file" rm -rf "$ORCHESTRATOR_SOURCE" log "vendoring contextual-orchestrator @ ${ORCHESTRATOR_PIN_SHA}" git clone --quiet --filter=blob:none --no-checkout "$ORCHESTRATOR_GIT_URL" "$ORCHESTRATOR_SOURCE" @@ -144,7 +155,7 @@ log "healthz confirmed after ${i}s (pid $sidecar_pid)" if [ -n "$ORCHESTRATOR_GITHUB_ENV" ]; then { printf 'CONTEXTUAL_ORCHESTRATOR_BASE_URL=http://%s:%s\n' "$ORCHESTRATOR_HOST" "$ORCHESTRATOR_PORT" - printf 'CONTEXTUAL_ORCHESTRATOR_TOKEN=%s\n' "$ORCHESTRATOR_TOKEN" + printf 'CONTEXTUAL_ORCHESTRATOR_TOKEN_FILE=%s\n' "$token_file" printf 'CONTEXTUAL_ORCHESTRATOR_EVIDENCE=%s\n' "$policy_report" } >> "$ORCHESTRATOR_GITHUB_ENV" log "exported gateway env to $ORCHESTRATOR_GITHUB_ENV" diff --git a/scripts/ci/load_contextual_orchestrator_token.sh b/scripts/ci/load_contextual_orchestrator_token.sh new file mode 100755 index 0000000000..05eeeac0cb --- /dev/null +++ b/scripts/ci/load_contextual_orchestrator_token.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# Source inside a model-consuming GitHub Actions step. The provisioner exports +# only this file path across steps so the raw bearer cannot appear in a later +# step's rendered environment header before masking takes effect. + +_contextual_orchestrator_token_fail() { + printf '::error::%s\n' "$*" >&2 + return 1 +} + +_contextual_orchestrator_load_token() { + local token_file token_size + + token_file="${CONTEXTUAL_ORCHESTRATOR_TOKEN_FILE:-}" + if [ -z "$token_file" ]; then + _contextual_orchestrator_token_fail "CONTEXTUAL_ORCHESTRATOR_TOKEN_FILE is required; the review sidecar was not provisioned." || return 1 + fi + if [ ! -f "$token_file" ] || [ -L "$token_file" ]; then + _contextual_orchestrator_token_fail "CONTEXTUAL_ORCHESTRATOR_TOKEN_FILE must name a regular, non-symlink file." || return 1 + fi + if [ "$(stat -c %u -- "$token_file")" != "$(id -u)" ]; then + _contextual_orchestrator_token_fail "CONTEXTUAL_ORCHESTRATOR_TOKEN_FILE must be owned by the current runner user." || return 1 + fi + if [ "$(stat -c %a -- "$token_file")" != "600" ]; then + _contextual_orchestrator_token_fail "CONTEXTUAL_ORCHESTRATOR_TOKEN_FILE must have mode 600." || return 1 + fi + token_size="$(wc -c < "$token_file")" + if [ "$token_size" -lt 1 ] || [ "$token_size" -gt 4096 ]; then + _contextual_orchestrator_token_fail "CONTEXTUAL_ORCHESTRATOR_TOKEN must contain between 1 and 4096 bytes." || return 1 + fi + if [ "$(wc -l < "$token_file")" -ne 0 ] || grep -q $'\r' -- "$token_file"; then + _contextual_orchestrator_token_fail "CONTEXTUAL_ORCHESTRATOR_TOKEN must not contain CR or LF." || return 1 + fi + + CONTEXTUAL_ORCHESTRATOR_TOKEN="$(cat -- "$token_file")" + if [ "${GITHUB_ACTIONS:-}" = "true" ]; then + printf '::add-mask::%s\n' "$CONTEXTUAL_ORCHESTRATOR_TOKEN" + fi + export CONTEXTUAL_ORCHESTRATOR_TOKEN +} + +_contextual_orchestrator_load_token || { + _contextual_orchestrator_status=$? + unset -f _contextual_orchestrator_load_token _contextual_orchestrator_token_fail + return "$_contextual_orchestrator_status" +} +unset -f _contextual_orchestrator_load_token _contextual_orchestrator_token_fail diff --git a/scripts/ci/strix_required_workflow_smoke.sh b/scripts/ci/strix_required_workflow_smoke.sh index 166fbf0f51..da0e14892f 100755 --- a/scripts/ci/strix_required_workflow_smoke.sh +++ b/scripts/ci/strix_required_workflow_smoke.sh @@ -18,6 +18,8 @@ fi workflow_file="$workflow_root/.github/workflows/strix.yml" gate_script="$repo_root/scripts/ci/strix_quick_gate.sh" full_gate_test="$repo_root/scripts/ci/test_strix_quick_gate.sh" +sidecar_script="$repo_root/scripts/ci/contextual_orchestrator_review_sidecar.sh" +token_loader_script="$repo_root/scripts/ci/load_contextual_orchestrator_token.sh" failures=0 @@ -117,9 +119,11 @@ PY fi } -if ! bash -n "$gate_script" "$full_gate_test"; then - record_failure "Strix gate scripts must pass bash syntax checks" -fi +for shell_script in "$gate_script" "$full_gate_test" "$sidecar_script" "$token_loader_script"; do + if ! bash -n -- "$shell_script"; then + record_failure "Strix gate script must pass bash syntax checks: $shell_script" + fi +done echo "Checking Strix workflow contract in $workflow_file" diff --git a/tests/test_contextual_orchestrator_review_sidecar_contract.py b/tests/test_contextual_orchestrator_review_sidecar_contract.py index 4cc0fb1d45..6d8c7d66f6 100644 --- a/tests/test_contextual_orchestrator_review_sidecar_contract.py +++ b/tests/test_contextual_orchestrator_review_sidecar_contract.py @@ -11,11 +11,14 @@ from __future__ import annotations +import os from pathlib import Path +import subprocess _ORG_REPO_ROOT = Path(__file__).resolve().parents[1] SIDECAR = _ORG_REPO_ROOT / "scripts/ci/contextual_orchestrator_review_sidecar.sh" +TOKEN_LOADER = _ORG_REPO_ROOT / "scripts/ci/load_contextual_orchestrator_token.sh" LAUNCHER = _ORG_REPO_ROOT / "scripts/ci/contextual_orchestrator_review_launcher.py" AUTOFIX_WORKFLOW = _ORG_REPO_ROOT / ".github/workflows/pr-review-autofix.yml" NOEMA_WORKFLOW = _ORG_REPO_ROOT / ".github/workflows/noema-review.yml" @@ -80,19 +83,170 @@ def test_sidecar_feeds_discovery_and_policy_artifacts_to_the_launcher() -> None: def test_sidecar_exports_gateway_env_for_review_steps() -> None: - """The gateway address and bearer token land in GITHUB_ENV for later steps.""" + """Only a private token-file path crosses the GitHub step boundary.""" text = _read(SIDECAR) - assert 'echo "::add-mask::$ORCHESTRATOR_TOKEN"' in text - assert "ORCHESTRATOR_TOKEN must not contain carriage returns or newlines" in text - assert text.index('echo "::add-mask::$ORCHESTRATOR_TOKEN"') < text.index( + guarded_mask = ( + 'if [ "${GITHUB_ACTIONS:-}" = "true" ]; then\n' + " printf '::add-mask::%s\\n' \"$ORCHESTRATOR_TOKEN\"\n" + "fi" + ) + assert guarded_mask in text + assert "ORCHESTRATOR_TOKEN must not contain CR or LF" in text + assert text.index(guarded_mask) < text.index( 'if [ -n "$ORCHESTRATOR_GITHUB_ENV" ]; then' ) assert "CONTEXTUAL_ORCHESTRATOR_BASE_URL=http://%s:%s\\n' \"$ORCHESTRATOR_HOST\" \"$ORCHESTRATOR_PORT\"" in text - assert "CONTEXTUAL_ORCHESTRATOR_TOKEN=%s\\n' \"$ORCHESTRATOR_TOKEN\"" in text + assert "CONTEXTUAL_ORCHESTRATOR_TOKEN_FILE=%s\\n' \"$token_file\"" in text + assert "CONTEXTUAL_ORCHESTRATOR_TOKEN=%s\\n" not in text + assert 'token_file="$ORCHESTRATOR_WORK/bearer.token"' in text + assert 'chmod 600 -- "$token_file"' in text assert "CONTEXTUAL_ORCHESTRATOR_EVIDENCE=%s\\n' \"$policy_report\"" in text assert '>> "$ORCHESTRATOR_GITHUB_ENV"' in text +def test_token_loader_rehydrates_and_masks_bearer_inside_each_consumer_step() -> None: + """Consumer steps read a private regular file instead of logging raw step env.""" + text = _read(TOKEN_LOADER) + assert 'CONTEXTUAL_ORCHESTRATOR_TOKEN_FILE:-' in text + assert '[ ! -f "$token_file" ]' in text + assert '[ -L "$token_file" ]' in text + assert 'stat -c %a -- "$token_file"' in text + assert 'stat -c %u -- "$token_file"' in text + assert "CONTEXTUAL_ORCHESTRATOR_TOKEN must not contain CR or LF" in text + assert "printf '::add-mask::%s\\n' \"$CONTEXTUAL_ORCHESTRATOR_TOKEN\"" in text + assert "export CONTEXTUAL_ORCHESTRATOR_TOKEN" in text + + +def test_token_loader_accepts_only_private_owned_single_line_files(tmp_path: Path) -> None: + """Exercise the loader's real file boundary, including mode and symlinks.""" + token_file = tmp_path / "bearer.token" + token_file.write_text("synthetic-test-bearer", encoding="utf-8") + token_file.chmod(0o600) + command = ( + 'set -euo pipefail; source "$TOKEN_LOADER"; ' + 'printf "loaded=%s\\n" "$CONTEXTUAL_ORCHESTRATOR_TOKEN"' + ) + + def run(candidate: Path) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["bash", "-c", command], + env={ + **os.environ, + "GITHUB_ACTIONS": "false", + "TOKEN_LOADER": str(TOKEN_LOADER), + "CONTEXTUAL_ORCHESTRATOR_TOKEN_FILE": str(candidate), + }, + text=True, + capture_output=True, + check=False, + ) + + accepted = run(token_file) + assert accepted.returncode == 0, accepted.stderr + assert "::add-mask::synthetic-test-bearer" not in accepted.stdout + assert "loaded=synthetic-test-bearer" in accepted.stdout + + actions = subprocess.run( + ["bash", "-c", command], + env={ + **os.environ, + "GITHUB_ACTIONS": "true", + "TOKEN_LOADER": str(TOKEN_LOADER), + "CONTEXTUAL_ORCHESTRATOR_TOKEN_FILE": str(token_file), + }, + text=True, + capture_output=True, + check=False, + ) + assert actions.returncode == 0, actions.stderr + assert "::add-mask::synthetic-test-bearer" in actions.stdout + + token_file.chmod(0o644) + wrong_mode = run(token_file) + assert wrong_mode.returncode != 0 + assert "must have mode 600" in wrong_mode.stderr + + token_file.chmod(0o600) + symlink = tmp_path / "bearer.link" + symlink.symlink_to(token_file) + linked = run(symlink) + assert linked.returncode != 0 + assert "regular, non-symlink" in linked.stderr + + token_file.write_bytes(b"synthetic\nsecond-line") + multiline = run(token_file) + assert multiline.returncode != 0 + assert "must not contain CR or LF" in multiline.stderr + + +def test_token_loader_preserves_caller_locals_and_removes_helpers(tmp_path: Path) -> None: + """Sourcing the loader must not clobber common caller names or leak functions.""" + token_path = tmp_path / "bearer.token" + token_path.write_text("synthetic-test-bearer", encoding="utf-8") + token_path.chmod(0o600) + command = ( + 'set -euo pipefail; token_file=caller-file; token_size=caller-size; ' + 'source "$TOKEN_LOADER"; ' + 'declare -F _contextual_orchestrator_token_fail >/dev/null && exit 91; ' + 'declare -F _contextual_orchestrator_load_token >/dev/null && exit 92; ' + 'printf "caller=%s:%s\\n" "$token_file" "$token_size"' + ) + result = subprocess.run( + ["bash", "-c", command], + env={ + **os.environ, + "TOKEN_LOADER": str(TOKEN_LOADER), + "CONTEXTUAL_ORCHESTRATOR_TOKEN_FILE": str(token_path), + }, + text=True, + capture_output=True, + check=False, + ) + + assert result.returncode == 0, result.stderr + assert "caller=caller-file:caller-size" in result.stdout + + +def test_sidecar_scopes_private_umask_to_token_creation() -> None: + """Private token creation must not change modes of later sidecar artifacts.""" + text = _read(SIDECAR) + assert "(\n umask 077\n printf '%s' \"$ORCHESTRATOR_TOKEN\" > \"$token_file\"\n)" in text + assert "\numask 077\nprintf '%s' \"$ORCHESTRATOR_TOKEN\"" not in text + + +def test_every_model_consumer_loads_the_bearer_inside_its_own_step() -> None: + """No workflow relies on a raw bearer persisted through GITHUB_ENV.""" + noema = _read(NOEMA_WORKFLOW) + strix = _read(STRIX_WORKFLOW) + dispatch = _read(OPENCODE_DISPATCH_WORKFLOW) + autofix = _read(AUTOFIX_WORKFLOW) + + assert 'source "$GITHUB_WORKSPACE/scripts/ci/load_contextual_orchestrator_token.sh"' in noema + assert 'source "$TRUSTED_STRIX_SOURCE/scripts/ci/load_contextual_orchestrator_token.sh"' in strix + assert dispatch.count( + 'source "$GITHUB_WORKSPACE/scripts/ci/load_contextual_orchestrator_token.sh"' + ) >= 2 + assert autofix.count( + 'source "$GITHUB_WORKSPACE/trusted-autofix-source/scripts/ci/load_contextual_orchestrator_token.sh"' + ) == 2 + + +def test_sidecar_masks_gateway_token_before_startup_can_emit_logs() -> None: + """The bearer is masked before clone, install, launch, or health output.""" + text = _read(SIDECAR) + mask = "printf '::add-mask::%s\\n' \"$ORCHESTRATOR_TOKEN\"" + assert "ORCHESTRATOR_TOKEN must not contain CR or LF" in text + assert mask in text + mask_index = text.index(mask) + for later_operation in ( + "git clone", + "python3 -m pip install", + '"$ORCHESTRATOR_WORK/launch_sidecar.py"', + "healthz", + ): + assert mask_index < text.index(later_operation) + + def test_launcher_registers_secrets_into_the_kv_once() -> None: """Secrets enter the KV in the same process that serves — never os.getenv later.""" text = _read(LAUNCHER) diff --git a/tests/test_pr_review_autofix_nvidia_nim_contract.py b/tests/test_pr_review_autofix_nvidia_nim_contract.py index 8444bda49b..25f74765ac 100644 --- a/tests/test_pr_review_autofix_nvidia_nim_contract.py +++ b/tests/test_pr_review_autofix_nvidia_nim_contract.py @@ -19,7 +19,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 = "8d09540844a7c3e2421dfe2cf5b44e915c1fb41a" +REVIEW_DISPATCH_BLOB_SHA = "3068fbc365dfe22d82adb523c3b36c0703e9c0a8" def _workflow_text(path: Path) -> str: @@ -149,7 +149,7 @@ def test_missing_gateway_env_fails_closed_before_model_execution() -> None: workflow = _workflow_text(AUTOFIX_WORKFLOW) ordinary_guard = ( 'if [ -z "${CONTEXTUAL_ORCHESTRATOR_BASE_URL:-}" ] ' - '|| [ -z "${CONTEXTUAL_ORCHESTRATOR_TOKEN:-}" ]; then\n' + '|| [ -z "${CONTEXTUAL_ORCHESTRATOR_TOKEN_FILE:-}" ]; then\n' ' echo "::error::contextual-orchestrator review sidecar must be ' 'provisioned before scheduled OpenCode autofix."\n' " exit 1\n" @@ -157,7 +157,7 @@ def test_missing_gateway_env_fails_closed_before_model_execution() -> None: ) conflict_guard = ( 'if [ -z "${CONTEXTUAL_ORCHESTRATOR_BASE_URL:-}" ] ' - '|| [ -z "${CONTEXTUAL_ORCHESTRATOR_TOKEN:-}" ]; then\n' + '|| [ -z "${CONTEXTUAL_ORCHESTRATOR_TOKEN_FILE:-}" ]; then\n' ' echo "::error::contextual-orchestrator review sidecar must be ' 'provisioned before scheduled OpenCode conflict resolution."\n' " exit 1\n" diff --git a/tests/test_strix_contextual_orchestrator_contract.py b/tests/test_strix_contextual_orchestrator_contract.py new file mode 100644 index 0000000000..0278b018c5 --- /dev/null +++ b/tests/test_strix_contextual_orchestrator_contract.py @@ -0,0 +1,119 @@ +"""Contracts for routing default Strix scans through contextual-orchestrator.""" + +from __future__ import annotations + +from pathlib import Path +import shutil +import subprocess +import unittest + +ROOT = Path(__file__).resolve().parents[1] +WORKFLOW = ROOT / ".github/workflows/strix.yml" +SIDECAR = ROOT / "scripts/ci/contextual_orchestrator_review_sidecar.sh" +TOKEN_LOADER = ROOT / "scripts/ci/load_contextual_orchestrator_token.sh" +SMOKE = ROOT / "scripts/ci/strix_required_workflow_smoke.sh" + + +class StrixContextualOrchestratorContract(unittest.TestCase): + """Pin the protected-main gateway-only Strix contract.""" + + def setUp(self) -> None: + """Load the tracked workflow and helper contracts.""" + self.workflow = WORKFLOW.read_text(encoding="utf-8") + self.sidecar = SIDECAR.read_text(encoding="utf-8") + self.smoke = SMOKE.read_text(encoding="utf-8") + + def test_default_scan_provisions_the_existing_gateway_sidecar(self) -> None: + """Every scan uses the five-provider gateway, never a direct pool.""" + self.assertIn("Provision contextual-orchestrator Strix sidecar", self.workflow) + self.assertIn("STRIX_MODEL: contextual-orchestrator/orchestrator/free", self.workflow) + self.assertIn("provider_mode=contextual_orchestrator", self.workflow) + self.assertIn("STRIX_FALLBACK_MODELS: \"\"", self.workflow) + self.assertNotIn( + "steps.resolve_nvidia_models.outputs.primary || 'gpt-5.4'", + self.workflow, + ) + + def test_gateway_is_openai_compatible_and_loopback_bound(self) -> None: + """Strix calls the local OpenAI-compatible route with a bearer token.""" + self.assertIn("CONTEXTUAL_ORCHESTRATOR_BASE_URL", self.workflow) + self.assertIn("CONTEXTUAL_ORCHESTRATOR_TOKEN", self.workflow) + self.assertIn( + 'if [ "$sidecar_base" != "http://127.0.0.1:18080" ]; then', + self.workflow, + ) + self.assertIn("printf '%s/v1' \"${sidecar_base%/}\"", self.workflow) + + def test_model_override_cannot_escape_the_gateway(self) -> None: + """A dispatch payload cannot select a direct provider route.""" + self.assertIn("github.event.client_payload.strix_llm", self.workflow) + self.assertIn( + "Strix model overrides are limited to contextual-orchestrator/orchestrator/free", + self.workflow, + ) + for direct_route in ("nvidia_nim/*)", "openrouter/free", "openai-direct/gpt-5.4"): + self.assertNotIn(direct_route, self.workflow) + + def test_private_gateway_scans_require_zdr_only_routing(self) -> None: + """Private source never enters the gateway's non-ZDR fallback tier.""" + self.assertIn( + "CONTEXTUAL_ORCHESTRATOR_REQUIRE_ZDR: ${{ steps.target_visibility.outputs.is_private }}", + self.workflow, + ) + + def test_gateway_install_is_hash_locked_and_token_is_masked(self) -> None: + """The vendored dependencies are hash locked and bearer logs are masked.""" + self.assertIn("--require-hashes", self.sidecar) + self.assertIn( + 'requirements_lock="$ORCHESTRATOR_SOURCE/requirements.lock"', + self.sidecar, + ) + self.assertIn("::add-mask::%s", self.sidecar) + + def test_required_smoke_pins_the_gateway_default(self) -> None: + """The bounded required-path smoke rejects a future direct-default regression.""" + self.assertIn("contextual-orchestrator Strix sidecar", self.smoke) + self.assertIn("STRIX_MODEL: contextual-orchestrator/orchestrator/free", self.smoke) + self.assertIn("Strix does not resolve a direct provider outside the gateway", self.smoke) + + def test_required_smoke_rejects_invalid_sidecar_syntax(self) -> None: + """Every shell input is parsed, not passed as an argument to one parse.""" + with self.subTest("malformed sidecar"): + import tempfile + + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + (root / "scripts/ci").mkdir(parents=True) + (root / ".github/workflows").mkdir(parents=True) + for source in ( + ROOT / "scripts/ci/strix_required_workflow_smoke.sh", + ROOT / "scripts/ci/strix_quick_gate.sh", + ROOT / "scripts/ci/test_strix_quick_gate.sh", + SIDECAR, + TOKEN_LOADER, + ): + shutil.copy2(source, root / source.relative_to(ROOT)) + shutil.copy2(WORKFLOW, root / WORKFLOW.relative_to(ROOT)) + copied_sidecar = root / SIDECAR.relative_to(ROOT) + copied_sidecar.write_text( + copied_sidecar.read_text(encoding="utf-8") + "\nif broken; then\n", + encoding="utf-8", + ) + + result = subprocess.run( + ["bash", str(root / "scripts/ci/strix_required_workflow_smoke.sh")], + cwd=root, + text=True, + capture_output=True, + check=False, + ) + + output = result.stdout + result.stderr + self.assertNotEqual(result.returncode, 0, output) + self.assertIn("Strix gate script must pass bash syntax checks", output) + self.assertIn("contextual_orchestrator_review_sidecar.sh", output) + self.assertNotIn("load_contextual_orchestrator_token.sh", output) + + +if __name__ == "__main__": + unittest.main()