diff --git a/.coveragerc b/.coveragerc index 05e98c72e..a152f7651 100644 --- a/.coveragerc +++ b/.coveragerc @@ -5,6 +5,7 @@ source = scripts.check_workspace_contract scripts.check_docstrings scripts.check_coverage + scripts.prepare_agent_pr_message [report] fail_under = 100 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2d2896f86..85666766e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,7 +55,7 @@ jobs: with: persist-credentials: false - name: Install pinned Python quality dependency - run: python3 -m pip install --disable-pip-version-check --no-deps -r requirements-quality.txt + run: python3 -m pip install --disable-pip-version-check --no-deps --require-hashes -r requirements-quality.txt - name: Exercise repository tooling with branch coverage run: python3 -m coverage run --branch -m unittest discover -s tests/quality -p 'test_*.py' - name: Enforce repository tooling coverage diff --git a/.github/workflows/hourly-nim-product-development.yml b/.github/workflows/hourly-nim-product-development.yml new file mode 100644 index 000000000..93ec6061c --- /dev/null +++ b/.github/workflows/hourly-nim-product-development.yml @@ -0,0 +1,601 @@ +name: Hourly NVIDIA NIM Product Development + +on: + workflow_dispatch: + inputs: + dry_run: + description: Evaluate the gate and task contract without running OpenCode + required: false + default: false + type: boolean + schedule: + - cron: "47 * * * *" + +concurrency: + group: hourly-nim-product-development-${{ github.repository }} + cancel-in-progress: false + +permissions: + contents: read + +env: + DEFAULT_BRANCH: main + # Upstream 1.18.13 was observed during implementation, but this workflow keeps + # the independently checksum-verified 1.17.13 archive until a reviewed digest + # for the newer Linux x64 asset is committed. + OPENCODE_VERSION: "1.17.13" + OPENCODE_SHA256: 157afa289d1a8d9372de0ce19ac726119b937a1f6b201808d46f06e4e59bb348 + OPENCODE_MODELS: >- + nvidia-nim/nvidia/llama-3.3-nemotron-super-49b-v1.5 + nvidia-nim/nvidia/nemotron-3-super-120b-a12b + nvidia-nim/deepseek-ai/deepseek-v4-pro + OPENCODE_RUN_TIMEOUT_SECONDS: "900" + OPENCODE_KILL_GRACE_SECONDS: "30" + MAX_CHANGED_FILES: "40" + MAX_DIFF_BYTES: "500000" + MAX_PR_TITLE_BYTES: "120" + MAX_PR_BODY_BYTES: "20000" + +jobs: + propose_product_increment: + if: github.repository == 'ContextualWisdomLab/TEPP' + runs-on: ubuntu-latest + timeout-minutes: 55 + permissions: + contents: read + pull-requests: read + outputs: + has_changes: ${{ steps.proposal.outputs.has_changes }} + base_sha: ${{ steps.base.outputs.base_sha }} + patch_sha256: ${{ steps.proposal.outputs.patch_sha256 }} + changed_files: ${{ steps.proposal.outputs.changed_files }} + diff_bytes: ${{ steps.proposal.outputs.diff_bytes }} + artifact_id: ${{ steps.upload.outputs.artifact-id }} + artifact_digest: ${{ steps.upload.outputs.artifact-digest }} + env: + DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || false }} + steps: + - name: Enforce zero-open-PR single-flight gate + id: gate + shell: bash + env: + GH_TOKEN: ${{ github.token }} + NIM_CONFIGURED: ${{ secrets.NVIDIA_NIM_API_KEY != '' }} + APP_ID_CONFIGURED: ${{ vars.TEPP_MAINTAINER_APP_CLIENT_ID != '' }} + APP_KEY_CONFIGURED: ${{ secrets.TEPP_MAINTAINER_APP_PRIVATE_KEY != '' }} + run: | + set -euo pipefail + if ! open_prs="$(gh pr list --repo "$GITHUB_REPOSITORY" --state open --limit 1 --json number,url)"; then + echo 'dispatch=false' >>"$GITHUB_OUTPUT" + echo 'reason=pull_request_inventory_unavailable' >>"$GITHUB_OUTPUT" + exit 0 + fi + if [ "$(jq 'length' <<<"$open_prs")" -gt 0 ]; then + echo 'dispatch=false' >>"$GITHUB_OUTPUT" + echo 'reason=open_pull_request' >>"$GITHUB_OUTPUT" + exit 0 + fi + if [ "$NIM_CONFIGURED" != true ] && [ "$DRY_RUN" != true ]; then + echo 'dispatch=false' >>"$GITHUB_OUTPUT" + echo 'reason=nim_api_key_unavailable' >>"$GITHUB_OUTPUT" + exit 0 + fi + if { [ "$APP_ID_CONFIGURED" != true ] || [ "$APP_KEY_CONFIGURED" != true ]; } \ + && [ "$DRY_RUN" != true ]; then + echo 'dispatch=false' >>"$GITHUB_OUTPUT" + echo 'reason=maintainer_app_unavailable' >>"$GITHUB_OUTPUT" + exit 0 + fi + echo 'dispatch=true' >>"$GITHUB_OUTPUT" + echo 'reason=ready' >>"$GITHUB_OUTPUT" + + - name: Prepare bounded commercial-quality task + if: steps.gate.outputs.dispatch == 'true' + shell: bash + run: | + cat >"$RUNNER_TEMP/agent-prompt.md" <<'PROMPT' + Continue commercial-quality product development for ContextualWisdomLab/TEPP. + Read AGENTS.md, CLAUDE.md, ARCHITECTURE.md, README.md, CHANGELOG.md, + product, technical, operations, security, doctoring, source, prompts, + tests, workflows, releases, issues, and recently merged pull requests. + Select the single highest-value buyer-visible Gap that fits exactly one bounded pull request. + + Preserve standalone operation and modular MSA integration with + ContextualWisdomLab/.github, naruon, contextual-orchestrator, and other + CWL services. Do not alter existing reviewer-agent identity, credentials, + or provider routing. Work test-first and preserve RED-to-GREEN evidence. + Maintain 100% production statement and branch coverage and 100% public + docstring coverage. Public behavior must be beginner-readable. + + For LLM paths, use or improve contextual-orchestrator and ground + test-time compute in Fugu, Conductor, and TRINITY. Allocate between + single-model routing and deep multi-agent orchestration; tune workflow + stages, recursive depth, task decomposition, and access lists; use + role-specific reasoning effort; and perform reasoning-level ablation. + Speed is not a priority. Deterministic calculation evidence remains immutable. + + Use current authoritative standards, primary documentation, and + peer-reviewed research. Record APA 7 sources and claim boundaries in + docs/doctoring. Use Figma only for a genuinely visual user flow. Any new + database object must use a descriptive two-word-or-longer snake_case name. + Update CHANGELOG.md and affected architecture, API, security, operations, + support, and buyer documentation. Run the complete verification gate. + + Do not merge. Do not release. Do not deploy. Do not publish. Do not + weaken exact-head review, Checks, security, privacy, coverage, docstring, + credential, deterministic-grounding, or fail-closed controls. Leave one + coherent working-tree increment and create PR_MESSAGE.md: title first, + then a body with Gap, design, RED/GREEN evidence, verification, APA 7 + sources, version decision, and residual risk. + PROMPT + + - name: Record dry-run task contract + if: steps.gate.outputs.dispatch == 'true' && env.DRY_RUN == 'true' + run: cat "$RUNNER_TEMP/agent-prompt.md" >>"$GITHUB_STEP_SUMMARY" + + - name: Check out trusted default branch without persisted credentials + if: steps.gate.outputs.dispatch == 'true' && env.DRY_RUN != 'true' + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + ref: main + fetch-depth: 1 + persist-credentials: false + + - name: Set up Python + if: steps.gate.outputs.dispatch == 'true' && env.DRY_RUN != 'true' + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 + with: + python-version: '3.14' + cache: pip + cache-dependency-path: requirements-quality.txt + + - name: Capture exact base and install dependencies + id: base + if: steps.gate.outputs.dispatch == 'true' && env.DRY_RUN != 'true' + shell: bash + run: | + set -euo pipefail + base_sha="$(git rev-parse HEAD)" + [[ "$base_sha" =~ ^[0-9a-f]{40}$ ]] + echo "base_sha=$base_sha" >>"$GITHUB_OUTPUT" + python3 -m pip install --disable-pip-version-check --no-deps --require-hashes -r requirements-quality.txt + + - name: Install checksum-pinned OpenCode CLI + if: steps.gate.outputs.dispatch == 'true' && env.DRY_RUN != 'true' + shell: bash + run: | + set -euo pipefail + archive="$RUNNER_TEMP/opencode-linux-x64.tar.gz" + curl --fail --silent --show-error --location --proto '=https' \ + --connect-timeout 10 --max-time 120 --output "$archive" \ + "https://github.com/anomalyco/opencode/releases/download/v${OPENCODE_VERSION}/opencode-linux-x64.tar.gz" + printf '%s %s\n' "$OPENCODE_SHA256" "$archive" | sha256sum -c - + tar -xzf "$archive" -C "$RUNNER_TEMP" + install -D -m 0755 "$RUNNER_TEMP/opencode" "$HOME/.opencode/bin/opencode" + echo "$HOME/.opencode/bin" >>"$GITHUB_PATH" + + - name: Configure OpenCode for NVIDIA NIM only + if: steps.gate.outputs.dispatch == 'true' && env.DRY_RUN != 'true' + shell: bash + run: | + cat >"$RUNNER_TEMP/opencode.json" <<'CONFIG' + { + "$schema": "https://opencode.ai/config.json", + "share": "disabled", + "autoupdate": false, + "lsp": false, + "mcp": {}, + "enabled_providers": ["nvidia-nim"], + "permission": { + "*": "allow", + "external_directory": "deny", + "task": "deny", + "question": "deny", + "webfetch": "deny", + "websearch": "deny", + "bash": { + "*": "allow", + "curl *": "deny", + "wget *": "deny", + "ssh *": "deny", + "git clone *": "deny", + "git fetch *": "deny", + "git pull *": "deny", + "git commit *": "deny", + "git push *": "deny", + "git tag *": "deny", + "git remote *": "deny", + "gh *": "deny" + } + }, + "provider": { + "nvidia-nim": { + "npm": "@ai-sdk/openai-compatible", + "name": "NVIDIA NIM", + "options": { + "baseURL": "https://integrate.api.nvidia.com/v1", + "apiKey": "{env:NVIDIA_NIM_API_KEY}" + }, + "models": { + "nvidia/llama-3.3-nemotron-super-49b-v1.5": {"tool_call": true}, + "nvidia/nemotron-3-super-120b-a12b": {"tool_call": true}, + "deepseek-ai/deepseek-v4-pro": {"tool_call": true} + } + } + } + } + CONFIG + chmod 0400 "$RUNNER_TEMP/opencode.json" + + - name: Run bounded NVIDIA NIM fallback + if: steps.gate.outputs.dispatch == 'true' && env.DRY_RUN != 'true' + shell: bash + env: + NVIDIA_NIM_API_KEY: ${{ secrets.NVIDIA_NIM_API_KEY }} + OPENCODE_CONFIG: ${{ runner.temp }}/opencode.json + OPENCODE_DISABLE_AUTOUPDATE: 'true' + XDG_CONFIG_HOME: ${{ runner.temp }}/opencode-config + XDG_CACHE_HOME: ${{ runner.temp }}/opencode-cache + run: | + set -euo pipefail + prompt="$(cat "$RUNNER_TEMP/agent-prompt.md")" + status=1 + read -r -a models <<<"$OPENCODE_MODELS" + model_count=${#models[@]} + for ((model_index = 0; model_index < model_count; model_index++)); do + model="${models[$model_index]}" + if timeout --kill-after="${OPENCODE_KILL_GRACE_SECONDS}s" \ + "${OPENCODE_RUN_TIMEOUT_SECONDS}s" \ + env -u GH_TOKEN -u GITHUB_TOKEN -u REPOSITORY_TOKEN \ + -u ACTIONS_ID_TOKEN_REQUEST_TOKEN -u ACTIONS_ID_TOKEN_REQUEST_URL \ + -u ACTIONS_RUNTIME_TOKEN -u ACTIONS_RUNTIME_URL \ + -u ACTIONS_RESULTS_URL -u ACTIONS_CACHE_URL \ + -u GITHUB_ENV -u GITHUB_OUTPUT -u GITHUB_PATH \ + -u GITHUB_STATE -u GITHUB_STEP_SUMMARY \ + opencode run "$prompt" --agent build --model "$model"; then + status=0 + break + fi + if [ "$model_index" -eq $((model_count - 1)) ]; then + break + fi + git reset --hard HEAD + git clean -fdx + env -u NVIDIA_NIM_API_KEY \ + timeout --kill-after=15s 180s \ + python3 -m pip install --disable-pip-version-check --no-deps --require-hashes -r requirements-quality.txt + done + [ "$status" -eq 0 ] || { echo '::error::All NIM candidates failed'; exit 1; } + + - name: Verify, bound, and export the uncredentialed proposal + id: proposal + if: steps.gate.outputs.dispatch == 'true' && env.DRY_RUN != 'true' + shell: bash + run: | + set -euo pipefail + if [ -z "$(git status --porcelain)" ]; then + echo 'has_changes=false' >>"$GITHUB_OUTPUT" + exit 0 + fi + git add -A + git diff --cached --check + if git diff --cached --raw | grep -Eq '(^| )(120000|160000)( |$)'; then + echo '::error::Symlink or gitlink proposals are forbidden' + exit 1 + fi + changed_files="$(git diff --cached --name-only -z | python -c 'import sys; print(len([x for x in sys.stdin.buffer.read().split(b"\0") if x]))')" + proposal_dir="$RUNNER_TEMP/tepp-product-proposal" + install -d -m 0700 "$proposal_dir" + git diff --cached --binary --full-index >"$proposal_dir/proposal.patch" + diff_bytes="$(wc -c <"$proposal_dir/proposal.patch" | tr -d '[:space:]')" + [ "$changed_files" -le "$MAX_CHANGED_FILES" ] + [ "$diff_bytes" -le "$MAX_DIFF_BYTES" ] + patch_sha256="$(sha256sum "$proposal_dir/proposal.patch" | awk '{print $1}')" + { + echo 'has_changes=true' + echo "patch_sha256=$patch_sha256" + echo "changed_files=$changed_files" + echo "diff_bytes=$diff_bytes" + } >>"$GITHUB_OUTPUT" + + - name: Upload bounded proposal patch + id: upload + if: steps.proposal.outputs.has_changes == 'true' + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: tepp-product-proposal-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/tepp-product-proposal/proposal.patch + if-no-files-found: error + retention-days: 1 + compression-level: 0 + overwrite: false + + package_product_increment: + if: >- + github.repository == 'ContextualWisdomLab/TEPP' + && needs.propose_product_increment.outputs.has_changes == 'true' + needs: propose_product_increment + runs-on: ubuntu-latest + timeout-minutes: 90 + permissions: + actions: read + contents: read + pull-requests: read + steps: + - name: Check out exact proposal base without persisted credentials + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + ref: ${{ needs.propose_product_increment.outputs.base_sha }} + fetch-depth: 1 + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 + with: + python-version: '3.14' + cache: pip + cache-dependency-path: requirements-quality.txt + + - name: Download exact immutable proposal + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + artifact-ids: ${{ needs.propose_product_increment.outputs.artifact_id }} + path: ${{ runner.temp }}/tepp-product-proposal + + - name: Verify immutable artifact identity and apply proposal + env: + GH_TOKEN: ${{ github.token }} + ARTIFACT_ID: ${{ needs.propose_product_increment.outputs.artifact_id }} + EXPECTED_ARTIFACT_DIGEST: ${{ needs.propose_product_increment.outputs.artifact_digest }} + EXPECTED_PATCH: ${{ needs.propose_product_increment.outputs.patch_sha256 }} + EXPECTED_DIFF_BYTES: ${{ needs.propose_product_increment.outputs.diff_bytes }} + EXPECTED_CHANGED_FILES: ${{ needs.propose_product_increment.outputs.changed_files }} + shell: bash + run: | + set -euo pipefail + patch="$RUNNER_TEMP/tepp-product-proposal/proposal.patch" + artifact_id="$ARTIFACT_ID" + artifact_name="tepp-product-proposal-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" + expected_artifact_digest="$EXPECTED_ARTIFACT_DIGEST" + expected_patch="$EXPECTED_PATCH" + [[ "$artifact_id" =~ ^[1-9][0-9]*$ ]] || { echo '::error::Malformed artifact ID'; exit 1; } + [[ "$expected_artifact_digest" =~ ^[0-9a-f]{64}$ ]] || { echo '::error::Malformed artifact digest'; exit 1; } + [[ "$expected_patch" =~ ^[0-9a-f]{64}$ ]] || { echo '::error::Malformed patch digest'; exit 1; } + artifact_json="$(gh api "repos/${GITHUB_REPOSITORY}/actions/artifacts/${artifact_id}")" + downloaded_digest="$(jq -r '.digest // empty' <<<"$artifact_json")" + [[ "$downloaded_digest" =~ ^sha256:[0-9a-f]{64}$ ]] + [ "${downloaded_digest#sha256:}" = "$expected_artifact_digest" ] + jq -e --argjson artifact_id "$artifact_id" --arg artifact_name "$artifact_name" \ + --argjson run_id "$GITHUB_RUN_ID" \ + '.id == $artifact_id and .name == $artifact_name and .expired == false and .workflow_run.id == $run_id' \ + <<<"$artifact_json" >/dev/null + printf '%s %s\n' "$expected_patch" "$patch" | sha256sum -c - + [ "$(wc -c <"$patch" | tr -d '[:space:]')" = "$EXPECTED_DIFF_BYTES" ] + git apply --check --binary "$patch" + git apply --index --binary "$patch" + git diff --cached --check + if git diff --cached --raw | grep -Eq '(^| )(120000|160000)( |$)'; then + echo '::error::Symlink or gitlink proposals are forbidden' + exit 1 + fi + actual_files="$(git diff --cached --name-only -z | python -c 'import sys; print(len([x for x in sys.stdin.buffer.read().split(b"\0") if x]))')" + [ "$actual_files" = "$EXPECTED_CHANGED_FILES" ] + + - name: Restore pinned Rust quality tools + id: rust-tools-cache + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + with: + path: | + ~/.cargo/bin/cargo-nextest + ~/.cargo/bin/cargo-deny + key: ${{ runner.os }}-${{ runner.arch }}-tepp-rust-tools-nextest-0.9.140-deny-0.19.7 + + - name: Restore pinned cargo-llvm-cov + id: llvm-cov-cache + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + with: + path: ~/.cargo/bin/cargo-llvm-cov + key: ${{ runner.os }}-${{ runner.arch }}-tepp-cargo-llvm-cov-0.8.6 + + - name: Install pinned Rust quality tools + shell: bash + run: | + set -euo pipefail + if [ "${{ steps.rust-tools-cache.outputs.cache-hit }}" != true ]; then + cargo install cargo-nextest --locked --version 0.9.140 + cargo install cargo-deny --locked --version 0.19.7 + fi + if [ "${{ steps.llvm-cov-cache.outputs.cache-hit }}" != true ]; then + cargo install cargo-llvm-cov --locked --version 0.8.6 + fi + rustup toolchain install nightly-2026-08-01 --profile minimal --component llvm-tools-preview + + - name: Run every release-quality gate + env: + EXPECTED_PATCH: ${{ needs.propose_product_increment.outputs.patch_sha256 }} + shell: bash + run: | + set -euo pipefail + unset GH_TOKEN GITHUB_TOKEN REPOSITORY_TOKEN + unset ACTIONS_ID_TOKEN_REQUEST_TOKEN ACTIONS_ID_TOKEN_REQUEST_URL + unset ACTIONS_RUNTIME_TOKEN ACTIONS_RUNTIME_URL + unset ACTIONS_RESULTS_URL ACTIONS_CACHE_URL + unset GITHUB_ENV GITHUB_OUTPUT GITHUB_PATH + unset GITHUB_STATE GITHUB_STEP_SUMMARY + python3 -m pip install --disable-pip-version-check --no-deps --require-hashes -r requirements-quality.txt + python3 -m compileall -q scripts tests/quality + python3 scripts/check_workspace_contract.py + python3 scripts/check_docstrings.py + python3 -m coverage run --branch -m unittest discover -s tests/quality -p 'test_*.py' + python3 -m coverage report --fail-under=100 --show-missing + rustup toolchain install 1.97.1 --profile minimal --component clippy --component rustfmt --component llvm-tools-preview + cargo fmt --all -- --check + cargo clippy --workspace --all-targets --all-features -- -D warnings + cargo nextest run --workspace --all-features + cargo test --doc --workspace --all-features + cargo doc --workspace --all-features --no-deps + cargo deny check + line_coverage="$RUNNER_TEMP/coverage.lcov" + branch_coverage="$RUNNER_TEMP/coverage-branches.json" + cargo llvm-cov --workspace --all-features --lcov --output-path "$line_coverage" + python3 scripts/check_coverage.py "$line_coverage" --kind lines --format lcov + cargo +nightly-2026-08-01 llvm-cov --branch --workspace --all-features --json --summary-only --output-path "$branch_coverage" + python3 scripts/check_coverage.py "$branch_coverage" --kind branches + [ -z "$(git diff --name-only)" ] + [ -z "$(git ls-files --others --exclude-standard)" ] + post_patch="$RUNNER_TEMP/post-verify.patch" + git diff --cached --binary --full-index >"$post_patch" + [ "$(sha256sum "$post_patch" | awk '{print $1}')" = "$EXPECTED_PATCH" ] + + publish_product_increment: + if: >- + github.repository == 'ContextualWisdomLab/TEPP' + && needs.propose_product_increment.outputs.has_changes == 'true' + && needs.package_product_increment.result == 'success' + needs: + - propose_product_increment + - package_product_increment + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + actions: read + contents: read + pull-requests: read + steps: + - name: Check out exact base for non-executing publication + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + ref: ${{ needs.propose_product_increment.outputs.base_sha }} + fetch-depth: 1 + persist-credentials: false + + - name: Preserve trusted metadata parser before applying the proposal + run: install -m 0500 scripts/prepare_agent_pr_message.py "$RUNNER_TEMP/prepare_agent_pr_message.py" + + - name: Download the same immutable proposal + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + artifact-ids: ${{ needs.propose_product_increment.outputs.artifact_id }} + path: ${{ runner.temp }}/tepp-product-proposal + + - name: Verify and apply immutable proposal without executing it + env: + GH_TOKEN: ${{ github.token }} + ARTIFACT_ID: ${{ needs.propose_product_increment.outputs.artifact_id }} + EXPECTED_ARTIFACT_DIGEST: ${{ needs.propose_product_increment.outputs.artifact_digest }} + EXPECTED_PATCH: ${{ needs.propose_product_increment.outputs.patch_sha256 }} + EXPECTED_DIFF_BYTES: ${{ needs.propose_product_increment.outputs.diff_bytes }} + EXPECTED_CHANGED_FILES: ${{ needs.propose_product_increment.outputs.changed_files }} + shell: bash + run: | + set -euo pipefail + patch="$RUNNER_TEMP/tepp-product-proposal/proposal.patch" + artifact_id="$ARTIFACT_ID" + artifact_name="tepp-product-proposal-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" + expected_artifact_digest="$EXPECTED_ARTIFACT_DIGEST" + expected_patch="$EXPECTED_PATCH" + [[ "$artifact_id" =~ ^[1-9][0-9]*$ ]] || { echo '::error::Malformed artifact ID'; exit 1; } + [[ "$expected_artifact_digest" =~ ^[0-9a-f]{64}$ ]] || { echo '::error::Malformed artifact digest'; exit 1; } + [[ "$expected_patch" =~ ^[0-9a-f]{64}$ ]] || { echo '::error::Malformed patch digest'; exit 1; } + artifact_json="$(gh api "repos/${GITHUB_REPOSITORY}/actions/artifacts/${artifact_id}")" + downloaded_digest="$(jq -r '.digest // empty' <<<"$artifact_json")" + [[ "$downloaded_digest" =~ ^sha256:[0-9a-f]{64}$ ]] + [ "${downloaded_digest#sha256:}" = "$expected_artifact_digest" ] + jq -e --argjson artifact_id "$artifact_id" --arg artifact_name "$artifact_name" \ + --argjson run_id "$GITHUB_RUN_ID" \ + '.id == $artifact_id and .name == $artifact_name and .expired == false and .workflow_run.id == $run_id' \ + <<<"$artifact_json" >/dev/null + printf '%s %s\n' "$expected_patch" "$patch" | sha256sum -c - + [ "$(wc -c <"$patch" | tr -d '[:space:]')" = "$EXPECTED_DIFF_BYTES" ] + git apply --check --binary "$patch" + git apply --index --binary "$patch" + git diff --cached --check + if git diff --cached --raw | grep -Eq '(^| )(120000|160000)( |$)'; then + echo '::error::Symlink or gitlink proposals are forbidden' + exit 1 + fi + actual_files="$(git diff --cached --name-only -z | python -c 'import sys; print(len([x for x in sys.stdin.buffer.read().split(b"\0") if x]))')" + [ "$actual_files" = "$EXPECTED_CHANGED_FILES" ] + + - name: Parse bounded untrusted pull-request metadata + shell: bash + run: | + set -euo pipefail + umask 077 + title_file="$RUNNER_TEMP/pr-title.txt" + body_file="$RUNNER_TEMP/pr-body.md" + if [ -e PR_MESSAGE.md ] || [ -L PR_MESSAGE.md ]; then + MAX_PR_TITLE_BYTES="$MAX_PR_TITLE_BYTES" MAX_PR_BODY_BYTES="$MAX_PR_BODY_BYTES" \ + python "$RUNNER_TEMP/prepare_agent_pr_message.py" PR_MESSAGE.md "$title_file" "$body_file" + rm -f PR_MESSAGE.md + else + printf '%s' 'TEPP autonomous product increment' >"$title_file" + printf '%s\n' 'OpenCode produced one bounded proposal for exact-head review.' >"$body_file" + chmod 0600 "$title_file" "$body_file" + fi + git add -A + git diff --cached --check + + - name: Mint dedicated maintainer App token only for publication + id: app + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 + with: + client-id: ${{ vars.TEPP_MAINTAINER_APP_CLIENT_ID }} + private-key: ${{ secrets.TEPP_MAINTAINER_APP_PRIVATE_KEY }} + owner: ContextualWisdomLab + repositories: ${{ github.event.repository.name }} + permission-contents: write + permission-metadata: read + permission-pull-requests: write + + - name: Revalidate queue and default-branch head + env: + GH_TOKEN: ${{ steps.app.outputs.token }} + EXPECTED_BASE: ${{ needs.propose_product_increment.outputs.base_sha }} + shell: bash + run: | + set -euo pipefail + [ "$(git rev-parse HEAD)" = "$EXPECTED_BASE" ] || { echo '::error::base_branch_advanced'; exit 1; } + if ! open_prs="$(gh pr list --repo "$GITHUB_REPOSITORY" --state open --limit 1 --json number,url)"; then + echo '::error::pull_request_inventory_unavailable_after_generation' + exit 1 + fi + [ "$(jq 'length' <<<"$open_prs")" -eq 0 ] || { echo '::error::open_pull_request_after_generation'; exit 1; } + live_base="$(gh api "repos/${GITHUB_REPOSITORY}/git/ref/heads/${DEFAULT_BRANCH}" --jq '.object.sha')" + [ "$live_base" = "$expected_base" ] || { echo '::error::base_branch_advanced'; exit 1; } + + - name: Package exactly one pull request in a trusted step + env: + GH_TOKEN: ${{ steps.app.outputs.token }} + shell: bash + run: | + set -euo pipefail + title="$(cat "$RUNNER_TEMP/pr-title.txt")" + body_file="$RUNNER_TEMP/pr-body.md" + branch="nim-agent/product-dev-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git checkout -b "$branch" + git -c core.hooksPath=/dev/null commit -m "$title" + gh auth setup-git + set +e + git ls-remote --exit-code --heads origin "refs/heads/${branch}" >/dev/null 2>&1 + remote_status=$? + set -e + case "$remote_status" in + 0) + echo '::error::proposal_branch_already_exists' + exit 1 + ;; + 2) ;; + *) + echo '::error::proposal_branch_inventory_unavailable' + exit 1 + ;; + esac + cleanup() { git push origin --delete "$branch" >/dev/null 2>&1 || true; } + trap cleanup ERR + git push origin "HEAD:refs/heads/${branch}" + gh pr create --repo "$GITHUB_REPOSITORY" --base "$DEFAULT_BRANCH" \ + --head "$branch" --title "$title" --body-file "$body_file" + trap - ERR diff --git a/CHANGELOG.md b/CHANGELOG.md index b4844300c..14a5759df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ All notable changes to TEPP are documented here. The format follows Keep a Chang - `corpus_split` leakage-safe knowledge-cutoff snapshots, relation-connected co-partition groups, rolling-origin windows, and group-normalized ESS weight contracts. - `persistence_postgres` live SQL port: `SqlSession` transport, migration batch applicator, document/audit SQL contracts, `LiveDocumentRepository`, and fail-closed `DATABASE_URL`/`LiveSqlxConfig` gate for SQLx pool wiring (live pool/query driver remains accepted-target). - `membership_core` Kish effective sample size, design effect, and group-normalized ESS helpers for multiple-membership estimation inputs. +- Credential-separated hourly NVIDIA NIM/OpenCode product-development workflow (issue #2): proposal, independent verification, and late Maintainer-App publication with `NVIDIA_NIM_API_KEY` only for model work. - Documented modular naruon consumer contract for TEPP analysis-run and export surfaces, with a committed example request payload. - Documented contextual-orchestrator interpretation port boundary and credential separation for TEPP. - Foundation validation/release-readiness ledger at `docs/validation/temporal-event-foundation.md` tracking capability maturity and scientific acceptance gates. diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index f77232def..3eb744ce5 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -30,6 +30,8 @@ TEPP's approved PRD v0.4 and implementation plan are the primary product baselin | Governance | [`GOVERNANCE.md`](GOVERNANCE.md) | | Agent development rules | [`AGENTS.md`](AGENTS.md) | | Agent context | [`CLAUDE.md`](CLAUDE.md) | +| Hourly NIM product-development operations | [`docs/operations/HOURLY_NIM_PRODUCT_DEVELOPMENT.md`](docs/operations/HOURLY_NIM_PRODUCT_DEVELOPMENT.md) | +| Hourly NIM OpenCode doctoring | [`docs/doctoring/hourly-nim-opencode-development.md`](docs/doctoring/hourly-nim-opencode-development.md) | | Change history | [`CHANGELOG.md`](CHANGELOG.md) | ## Maturity vocabulary diff --git a/docs/adr/0015-autonomous-development-review-and-merge-authority.md b/docs/adr/0015-autonomous-development-review-and-merge-authority.md index d5367bbc3..a10b82bfb 100644 --- a/docs/adr/0015-autonomous-development-review-and-merge-authority.md +++ b/docs/adr/0015-autonomous-development-review-and-merge-authority.md @@ -1,7 +1,7 @@ # ADR 0015 — Autonomous development, review, and merge authority separation **Decision status:** Accepted -**Implementation maturity:** accepted-target +**Implementation maturity:** active-PR — the hourly credential-separated product-development workflow remains unmerged; independent review and merge authority remain deployment/policy owned **Date:** 2026-08-12 **Supersedes:** None; narrows and clarifies the automation clauses previously mixed into ADR 0006. diff --git a/docs/adr/README.md b/docs/adr/README.md index c4fda2e0e..8e939c9e9 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -20,7 +20,7 @@ Read [`ADR_POLICY.md`](ADR_POLICY.md) first. **Decision status and implementatio | [0012](0012-temporal-relational-shared-latent-topic-measurement.md) | Temporal Relational Shared-Latent Topic Measurement (TRSL-TM) | Accepted | accepted-target | Owns topic backend compatibility, global topic identity, method effects, K/model-selection prerequisites, and compositional topic coordinates. | | [0013](0013-bitemporal-persistence-reproducibility-and-split-authority.md) | Bitemporal persistence, reproducibility manifests, and relation-aware split authority | Accepted | partial | Owns PostgreSQL adapter semantics, immutable run/split manifests, leakage-safe partitions, and recovery identity; live SQLx pool wiring remains accepted-target. | | [0014](0014-scientific-claim-promotion-and-release-evidence.md) | Scientific claim promotion and release evidence authority | Accepted | partial | Separates design, implementation, scientific/product claim, and release authority. | -| [0015](0015-autonomous-development-review-and-merge-authority.md) | Autonomous development, review, and merge authority separation | Accepted | accepted-target | Separates model proposal, deterministic verification, publication, independent review, and merge/release authority. | +| [0015](0015-autonomous-development-review-and-merge-authority.md) | Autonomous development, review, and merge authority separation | Accepted | active-PR | Separates model proposal, deterministic verification, publication, independent review, and merge/release authority. | | [0016](0016-tdt-chronos-event-intelligence-boundary.md) | TDT, CHRONOS, and Event Ontology intelligence boundary | Accepted | accepted-target | Separates observed evidence, detection/tracking, prediction/schema inference, temporal consistency, and promoted transition authority. | ## Decision ownership summary diff --git a/docs/doctoring/hourly-nim-opencode-development.md b/docs/doctoring/hourly-nim-opencode-development.md new file mode 100644 index 000000000..e5fc6ca18 --- /dev/null +++ b/docs/doctoring/hourly-nim-opencode-development.md @@ -0,0 +1,116 @@ +# Hourly NVIDIA NIM OpenCode Development — Evidence Doctoring + +## Claim boundary + +This workflow can propose one bounded pull request. It does not claim autonomous +semantic correctness, production safety, customer acceptance, scientific +validity, or permission to merge, release, deploy, or approve. Deterministic +tests, schemas, security scanners, CodeRabbit/OpenCode review, human judgment, +and exact-head branch rules remain independent controls. + +## Source-supported facts and project decisions + +| Topic | Source-supported fact | TEPP decision | +|---|---|---| +| GitHub jobs | Jobs run on separate runner instances and receive scoped tokens | Model, verifier, and publisher are separate jobs | +| GitHub App authentication | A workflow can mint an installation token with explicit repository permissions | Publication token is minted only after non-executing validation | +| Artifact handoff | Artifact upload/download actions expose immutable IDs and digests | Patch ID, digest, base, size, count, and modes are checked twice | +| OpenCode | OpenCode is a programmable coding agent with provider configuration | One checksum-pinned binary uses NVIDIA NIM only | +| NVIDIA NIM | NIM exposes OpenAI-compatible hosted inference APIs | Only `NVIDIA_NIM_API_KEY` reaches the proposal process | +| SSDF | NIST SP 800-218 recommends protected build environments, review, provenance, and vulnerability response | Fresh verification and ordinary PR governance remain mandatory | +| AI risk | ISO/IEC 23894:2023 and 42001:2023 require contextual risk treatment and controlled change | Stable no-op reasons, bounded proposals, traceable decisions, and rollback are documented | +| Test-time compute | Fugu, Conductor, and TRINITY distinguish routing from deeper role-based workflows | Runtime LLM increments must preserve route/conduct and access-list controls | + +The GitHub, OpenCode, NVIDIA, NIST, ISO, and paper references below follow APA 7 +conventions as closely as the source type permits. + +## Upstream version evidence + +The official OpenCode GitHub release API identified OpenCode 1.18.13 as the +current upstream release on 2026-08-06. The workflow deliberately pins OpenCode 1.17.13 because its Linux x64 archive SHA-256 +`157afa289d1a8d9372de0ce19ac726119b937a1f6b201808d46f06e4e59bb348` +had already been independently reviewed in the CWL Noema workflow. The newer +archive is not adopted until its exact asset digest is independently captured, +reviewed, committed, and exercised. “Latest” is not allowed to mean +“unverified.” + +This is a supply-chain project decision, not a claim that OpenCode 1.17.13 is +functionally superior. The scheduled agent cannot auto-update itself. + +## Orchestration research application + +**Fugu.** Fugu frames orchestration as selecting between direct model use and +deeper coordinated execution. TEPP already exposes explicit +`auto|route|conduct` organization modes; the autonomous prompt requires future +LLM changes to preserve this distinction. + +**Conductor.** Conductor generates natural-language subtasks, worker assignment, +and access lists. The prompt therefore requires explicit workflow stages, +dependencies, least-privilege evidence access, and bounded recursive depth. + +**TRINITY.** TRINITY emphasizes specialized thinker, worker, verifier, and +synthesis roles. The prompt requires role-specific reasoning effort and +reasoning-level ablation rather than one undifferentiated maximum-effort call. + +These papers motivate evaluation hypotheses. They do not prove that deeper +orchestration always improves TEPP reports. Deterministic grounding, +latency-insensitive quality tests, provider-reported usage where available, and +human review must compare forced routing and conducted cells. + +## APA 7th references + +GitHub. (n.d.). *Security hardening for GitHub Actions*. GitHub Docs. Retrieved +August 6, 2026, from +https://docs.github.com/en/actions/security-for-github-actions/security-guides/security-hardening-for-github-actions + +GitHub. (n.d.). *Making authenticated API requests with a GitHub App in a GitHub +Actions workflow*. GitHub Docs. Retrieved August 6, 2026, from +https://docs.github.com/en/apps/creating-github-apps/writing-code-for-a-github-app/making-authenticated-api-requests-with-a-github-app-in-a-github-actions-workflow + +International Organization for Standardization, & International Electrotechnical +Commission. (2023a). *Information technology—Artificial intelligence—Guidance +on risk management* (ISO/IEC Standard No. 23894:2023). +https://www.iso.org/standard/77304.html + +International Organization for Standardization, & International Electrotechnical +Commission. (2023b). *Information technology—Artificial +intelligence—Management system* (ISO/IEC Standard No. 42001:2023). +https://www.iso.org/standard/42001 + +National Institute of Standards and Technology. (2022). *Secure Software +Development Framework (SSDF) version 1.1: Recommendations for mitigating the +risk of software vulnerabilities* (NIST SP 800-218). +https://doi.org/10.6028/NIST.SP.800-218 + +Nielsen, S., Cetin, E., Schwendeman, P., Sun, Q., Xu, J., & Tang, Y. (2025). +Learning to orchestrate agents in natural language with the Conductor. *arXiv*. +https://arxiv.org/abs/2512.04388 + +NVIDIA. (n.d.). *NVIDIA NIM APIs*. NVIDIA API Catalog. Retrieved August 6, 2026, +from https://build.nvidia.com/ + +OpenCode. (2026, August 4). *OpenCode 1.18.13* [Computer software release]. +GitHub. https://github.com/anomalyco/opencode/releases/tag/v1.18.13 + +Sakana AI. (2026, June 22). *Sakana Fugu: One model to command them all*. +https://sakana.ai/fugu-release/ + +Xu, J., Sun, Q., Schwendeman, P., Nielsen, S., Cetin, E., & Tang, Y. (2025). +TRINITY: An evolved LLM coordinator. *arXiv*. +https://arxiv.org/abs/2512.04695 + +## Tooling limitations recorded + +Consensus search was requested for this increment but did not return a usable +paper record. Context7 reported its monthly quota exhausted. Primary paper +records, official documentation, and the existing Contextual Orchestrator +doctoring were therefore used directly. This limitation does not weaken the +deterministic workflow tests and must be revisited in a later literature update. + +## Residual risk + +The NIM key exists inside the model process, proposed code is executed by an +uncredentialed verifier with ordinary hosted-runner egress, and artifact digests +do not establish semantic safety. A later broker can narrow NIM egress; a later +central reusable workflow can remove duplicated repository policy. Neither +future improvement may combine model execution with publication authority. diff --git a/docs/operations/HOURLY_NIM_PRODUCT_DEVELOPMENT.md b/docs/operations/HOURLY_NIM_PRODUCT_DEVELOPMENT.md new file mode 100644 index 000000000..652acfe21 --- /dev/null +++ b/docs/operations/HOURLY_NIM_PRODUCT_DEVELOPMENT.md @@ -0,0 +1,147 @@ +# Hourly NVIDIA NIM Product Development + +The hourly NVIDIA NIM product-development workflow proposes one bounded +commercial-quality increment when the repository has no open pull request. It is +separate from the deterministic minute-17 quality sentinel and never merges, +releases, deploys, approves, or changes reviewer credentials. + +## Schedule and queue behavior + +`.github/workflows/hourly-nim-product-development.yml` runs at minute 47 of every +hour and supports `workflow_dispatch` with `dry_run=true`. The nonzero minute +avoids the busiest scheduler boundary. A repository-scoped concurrency group +does not cancel an active run. + +Before checkout or model execution, the proposal job reads at most one open pull +request. Unreadable inventory, any open PR, a missing `NVIDIA_NIM_API_KEY`, or a +missing Maintainer App configuration produces a stable fail-closed no-op. A dry +run may print the task contract without either credential. + +When a PR exists, normal review → repair → exact-head Checks → merge governance +owns the hour. The scheduler does not create a competing branch. + +## Required repository configuration + +Configure these repository or organization values: + +- Secret `NVIDIA_NIM_API_KEY` for the proposal runner only. +- Variable `TEPP_MAINTAINER_APP_CLIENT_ID`. +- Secret `TEPP_MAINTAINER_APP_PRIVATE_KEY`. +- A repository-scoped GitHub App installation with metadata read, contents + write, and pull-request write permissions only. + +Do not place GitHub App credentials in the proposal or verifier jobs. Do not +reuse the existing review App or alter its variable, secret, identity, or +provider route. Do not configure `COPILOT_GITHUB_TOKEN`. + +A manual dry run verifies scheduling, queue, and prompt contracts without model +or publication credentials. Missing production credentials leave the hourly +developer disabled rather than falling back to `GITHUB_TOKEN`. + +## Three-runner trust boundary + +### 1. Proposal runner + +The proposal runner has read-only repository and pull-request permissions. +OpenCode is downloaded from an immutable versioned URL and checked against a +committed SHA-256. Its only model provider is NVIDIA NIM. + +The OpenCode process receives `NVIDIA_NIM_API_KEY` but has GitHub, OIDC, Actions +runtime/cache, and runner command-file variables removed. Network tools, GitHub +CLI, remote Git operations, commits, pushes, tags, external-directory access, +task delegation, interactive questions, and OpenCode web tools are denied. + +The model may edit the local working tree and run repository tests. The trusted +step stages the complete proposal, rejects whitespace errors, symbolic links, +gitlinks, excessive file count, and excessive patch bytes, then uploads one +binary full-index patch with a one-day retention period. + +### 2. Fresh verifier + +A new runner checks out the exact base SHA and downloads by immutable numeric +artifact ID. It validates the upload digest, patch SHA-256, workflow-run +identity, expiration, base SHA, file count, byte count, and Git modes before +applying the patch. + +The verifier receives neither model nor publication credentials. It installs +the hash-locked Python environment, compiles the quality scripts and tests, +validates the workspace and documentation contracts, runs the quality tests +with 100% statement and branch coverage, and executes the pinned Rust format, +lint, test, documentation, dependency, line-coverage, and branch-coverage +gates. Coverage artifacts remain under `$RUNNER_TEMP`. It rejects any +verification mutation and proves the post-verification patch is byte-identical. + +### 3. Fresh publisher + +A third runner checks out the exact base and copies the trusted PR-message parser +to `RUNNER_TEMP` before applying the proposal. It repeats the immutable artifact +checks and applies the patch only as Git data. It executes no proposed tests, +build scripts, packages, binaries, or shell files. + +The copied parser rejects symlinks, non-regular files, malformed UTF-8, +unsupported controls, bidirectional spoofing, and byte-limit violations. Only +after bounded metadata is written does the publisher mint the repository-scoped +Maintainer App token. It then rechecks open-PR inventory and live `main`, pushes +one unique branch, and calls `gh pr create` exactly once. + +## Proposal contract + +The autonomous prompt requires one buyer-visible gap, standalone and modular MSA +compatibility, realistic test-first evidence, 100% coverage and docstrings, +database naming policy, CHANGELOG and operations updates, and APA 7 doctoring. +LLM work must use or improve Contextual Orchestrator and consider Fugu, +Conductor, TRINITY, workflow stages, access lists, bounded recursion, +role-specific reasoning effort, and ablation. + +The model must write `PR_MESSAGE.md` with a bounded title on the first line and a +body describing the product gap, design, RED-to-GREEN evidence, verification, +sources, version decision, and residual risk. Missing metadata receives a +minimal trusted fallback body. + +## Failure and recovery + +Stable no-op reasons are: + +- `pull_request_inventory_unavailable` +- `open_pull_request` +- `nim_api_key_unavailable` +- `maintainer_app_unavailable` + +A failed model candidate is discarded before a later candidate runs. A cleanup +or reinstall failure stops fallback. A failed verifier publishes nothing. A +publisher aborts if the artifact, base, queue, or metadata changed. If branch +push succeeds but PR creation fails, the error trap removes the orphan branch. + +Investigate the exact run and job log, reproduce the relevant command on the +same commit, add or retain a failing regression, and repair through a normal PR. +Never bypass the verifier, substitute stale check evidence, or grant the model a +write token. + +## Disablement and rollback + +Disable scheduled development by disabling the workflow, removing its schedule, +or removing either dedicated credential. Removing only the model secret stops +model execution; removing the Maintainer App values stops publication. The +minute-17 deterministic quality sentinel continues independently. + +Rollback a faulty workflow through a reviewed revert PR. Do not edit branch +protection, review workflows, or release workflows as an incident shortcut. +Delete orphan `nim-agent/product-dev-*` branches only after confirming no open PR +references them. + +## Residual risks + +- The model process necessarily receives the NIM credential. A future narrow + inference broker could keep the upstream secret outside the model process. +- NVIDIA NIM may process repository source; operators must review confidentiality, + retention, regional, and contractual obligations. +- The verifier executes untrusted code on an ephemeral hosted runner with + outbound network access, but receives no publication, NIM, OIDC, artifact/cache + runtime, command-file, or reviewer credential. +- GitHub artifact storage, hosted runners, and pinned actions remain trusted + infrastructure. Digests prove identity, not semantic correctness. +- GitHub cannot atomically create a PR only when none exists. Final queue and + base revalidation, unique branches, review, and exact-head Checks bound the + race. +- The pinned OpenCode release intentionally trails the latest observed upstream + release until the exact Linux asset digest is independently reviewed. diff --git a/requirements-quality.txt b/requirements-quality.txt index e389a676d..3ce4fefe3 100644 --- a/requirements-quality.txt +++ b/requirements-quality.txt @@ -1 +1,93 @@ -coverage==7.15.2 +# Hash-pinned quality dependency set for release gates and automation. +coverage==7.15.2 \ + --hash=sha256:9b5bd92ff1ec22e535eab0de75fa6db021992791f461a2aceb7822c625a1187d \ + --hash=sha256:44826758cfe73fcd0e6af5deb4ba6d5417cc1d13df3acb35c93484a11160f846 \ + --hash=sha256:09f5c6ec5901f667bd97dd140b5b9a2586b10efec66f46fb1e6d8135f8b95bdf \ + --hash=sha256:1d16e3a7104ea84f03e614611b3edbf6fb6892554b3ab0fe7fbb3f2b2ef04376 \ + --hash=sha256:d46e62cb35d91e6e2589fda6d28074426b0e276422b5d2ebef2c6b11dc60dbfd \ + --hash=sha256:dfd3db045e95960ae3683059571e597fda7cc610106a8916f77c5839048c1deb \ + --hash=sha256:affd532502d34c0472d0cdb181325c89f1d2c44992fef0c17e88e7b1576259a1 \ + --hash=sha256:d17d7512151fedfcc64c1821a8977fc9be0dbf495754669afcab7b57abc98ae9 \ + --hash=sha256:e26ff680768b8095e8874aabe0e9d3a47a2a9f176a8340d05f8604c56457c23a \ + --hash=sha256:7e8f27131dc7cd53de2c137dd207b3720919320b3c20d499dc30aa9ee6173287 \ + --hash=sha256:728a33676d4c3f0db977990a4bd421dcaa3be3e53b5b6273036fff6666008e89 \ + --hash=sha256:29c052f7c83ccfcc5c577eaae025d2e4a9bb80daf03c0ac31c996e83b000ce88 \ + --hash=sha256:1268ac8fb9ddcd783d3948dbabaf80a5d53bfdaa0575e873e2139a692f797443 \ + --hash=sha256:9f4432898c4bf2fba0435bbe35dd4437d7264565e5a88a21f5b49d8662a6b629 \ + --hash=sha256:2f1ec6f304b156669cfde653b4e9a953f5de87e247ea02ac599bce0ab2744036 \ + --hash=sha256:4d3361879d736f469f45723c11ea1a5bbdaf1f6928f0e632c940378b5aa9b660 \ + --hash=sha256:c6a98d698f9e2c8008d0370ec7fc452ebfcc530002ae2d0061170d768b992589 \ + --hash=sha256:d50dd325e18ec25bfcc10cd7f99b04df1ab9ec76b0918c260e60817ad0643dee \ + --hash=sha256:67d7602480a47bdf5b675635403625553ebaa70d5a62a657c035149fd401cea0 \ + --hash=sha256:cee0f89f4767a6057c8fbf168f8135f18be651300496086bd873e3189fed0487 \ + --hash=sha256:a29ec5305a7335aacee2d799e3422e91e1c8a12474986e2b3b07e315c91be82f \ + --hash=sha256:48ccc6395958eda89093ecdc35644c86f23a8b23a7f4d44958812b721aad67c1 \ + --hash=sha256:81f382c5a94b434ec1f6da607edb904c76d7212e618cd4d1bc9f97bed4120ef5 \ + --hash=sha256:bbc808daf4f5cd567af8075ecc72d21c6dfef9a254709a621a84c217c935ebc0 \ + --hash=sha256:a4c46b247b5d4b78f613bd89fea926d32b25c6cc61a50bd1e99ba310348f3dad \ + --hash=sha256:094dd37f3ef7b2da8b068b583d1f4c40f91c65197e16c52a71962d5d537fc5db \ + --hash=sha256:a63b9e190711134d581c4d703df5df09851b1acf99792c7aacbbe9f41f0283c9 \ + --hash=sha256:8bb9f4b4279187560796a4cdaca3b0a93dd97e48ee667df005f4ed9a97403688 \ + --hash=sha256:8c726b232659cbd2ae57ade46509eb068c9bd7a06df9fcbff6fe484870006934 \ + --hash=sha256:1adac78e5abc7c5438f7a209c9ca69d06542f0bf481d728b6989ea80b813fdf9 \ + --hash=sha256:b868acc62aa5de3be7a9d05c2333bf8359ca987e43f9cb30ff8fbda6a024ab73 \ + --hash=sha256:6f6966fc30e6f06ca8f98fb0ce51eda6b111b3ee8d066a8b1ec9e77fa06ab55d \ + --hash=sha256:68af907f595ab01a78f794932ff3bdf929c316d3000810d38dbc247129e26f8b \ + --hash=sha256:afa29e2eff3d5729267e2cb2fd4ce9d61c952932fb2694e34ccb5d9540c6a296 \ + --hash=sha256:bbf44513ceb1589e31948e20eafbde9deaface90e1a1afa5f5f77b4423d17ce6 \ + --hash=sha256:9deddf09eecb717b7f980414b43d90a5b22ff3967d2949ab29cb0aa83d9e9098 \ + --hash=sha256:ae901f7e55ba405c84ee1cab3d3e962e4e871e4a2bcb9c90911adbd69b42ac5a \ + --hash=sha256:a0f47002c6eeb7c280228467a4cb0cc15ca2103a8421b986b2d3ec04a0f9bd8b \ + --hash=sha256:1cd7a5beb7af3e864a13b1f0fb26efd3695da43ef0daf71e586adfffaf34d5b2 \ + --hash=sha256:97a5c5457a9fb1d6c4e06cfb5dc835871fbfb6a6a51addc9e925bdeff5ef7440 \ + --hash=sha256:0901cfe6c13bcd2302da4f83e884555d2a22bda6e4c476f09ef204ba20ca536e \ + --hash=sha256:b171bdd71cb7ff792bf32e376173b0ace7e7963e7e57c58dfc42063a6a7174cd \ + --hash=sha256:582edc45c2040543fef83341be23c43024a3ab3ae0c2d8bc498a06282905ad40 \ + --hash=sha256:a638db90c61cd219aeee65e83a24fdaa57269a741ae0cf773309208ac862cee3 \ + --hash=sha256:1121caa19159a38b5463eaae4b1e1fde81e525b15ecc5e000cd5b1a108f743a8 \ + --hash=sha256:a300c6934e0989c327b9e8a1e110329da4641149f872bbe9f70168be66da76c1 \ + --hash=sha256:2617f8799d268fabdeef42a7e89ac3a23e1deee9025427db2df970f99a89a578 \ + --hash=sha256:7dc2950a2992cd676d35c20ae63522836deeb034f08874699d14068710af3dc1 \ + --hash=sha256:9e36686f7a442185db2400b3df171aac520869faf9deb59df687d28659eda2a6 \ + --hash=sha256:7d29ca7bd67af6e12e74632d65f026eabc1364da5c254494cd914446a28a3ef7 \ + --hash=sha256:db9c8438057e5b0f6a22a0af99c0c1d26b57fbbdbd1be5861ddb8f897fcc3a2d \ + --hash=sha256:63022c4c8dec1d0342f05c3ede99842fe3d007689acc45e86f123a1746e4a026 \ + --hash=sha256:6c0be82b4d4aa5b2704e08518e2252f3e3d110164bcca826816801052e48a7aa \ + --hash=sha256:4510fb9cdf6bb02dfa6af0be4a534b8102d086e22e4a33f8836df663da3d660d \ + --hash=sha256:42ec3d989421b174a2ab607c1539f24127ad362757b7f1c0c0d7a2993f7eb37b \ + --hash=sha256:e8f91bce78e32343af184c3b7fa28fcf5a9e2641f4b6623d392038f804939188 \ + --hash=sha256:434e68d531858205895eb0d74b73d20b84260de426387d53c422a5acda2cf050 \ + --hash=sha256:26c3b04a6377fd7c09800921fa934e3a17c0020439cd59df73e73ae1d4b6a78c \ + --hash=sha256:3ed010aa1b69cda8e827aabfca9866216c980e2dca82ab9a78c5f83689964c8b \ + --hash=sha256:40f633c5c5fc783732f6312280122e859538fa24461235597c13d803ea9a108a \ + --hash=sha256:075560438765b7a2ef43bf7aa7758661b53d889df47f062a31bda6c1ade553a2 \ + --hash=sha256:25fd15dd40a0a2c51a500d664ca29053c09c3259d998407bf982b6e114696138 \ + --hash=sha256:b9a6367e4aff723e8ee8190836836124284e8fcd4265e307c844010cfa074f3f \ + --hash=sha256:9854ca62c152874b2060772503535be2e8f53f70b8aaa7686b094888d872f984 \ + --hash=sha256:913b6c56e110da40e035bbd168353bf7aaa2544a5eaccea5d98a4629aac156c7 \ + --hash=sha256:aaccad4129d735a8a4d526f26929894c9a4e8ef7034566f210b176749d6906e3 \ + --hash=sha256:a164b50081fc7357331c4024ef4d17b78ba325f8380d05f5a69599a7e05257ee \ + --hash=sha256:bfd341ccf78128e72c094bc70cc25b3ef309c33c7c2c66ba3ed4309549e02de1 \ + --hash=sha256:1473b3ba8e7ee0f076117b1a72c23f579a2b9e2bb742f48a8d86ea27ca93f91a \ + --hash=sha256:17c432b5f73ad52ef46fb06019f6fa7c66ce381961cf0f7dfd1d3a4bd3a98145 \ + --hash=sha256:77f0ef5011df53a4bd1b35211ab122287f8d9b8d7aa1c4553e5c2deb24b1d446 \ + --hash=sha256:835ec4e20b45f0a7f63ed78f94065aca00de033403df8377bfe8b9c6abc0a7be \ + --hash=sha256:7466cc7ab6dc0db871d264bf99e8779f0917ee63d40730af0552f71535a6e072 \ + --hash=sha256:e370c12133095ff18432de8c044962be85a5a96d90c6fcbce8e17e76236d2328 \ + --hash=sha256:fe41909c9515c3bfdb5f02c4d1f857dba322d9a9a1178069b91eea77889df63a \ + --hash=sha256:6aa28cfb6488e5453b5b762d65f73aa586380f6693a04d58078ce228a29b06c0 \ + --hash=sha256:bcc0aae933921d03096f53b0b03eeb702129fd406dee59f08d2efacc68681fa5 \ + --hash=sha256:7c63387e21ab21f512c69c9756a8c7dadd322c7275edb064064433c9a09c3743 \ + --hash=sha256:0e55510bc98ae943cece9e667a6c0fe94c6a92913720dea34243657a17993d0c \ + --hash=sha256:2ff08701be2d1556fc78b326c80a3e8042da09352ecb3819105f8e386c8a3071 \ + --hash=sha256:38c9518b7103826c403a461544e3c2e77151e8676d06eaed85911a97e962584a \ + --hash=sha256:dee88b1ed88587abd8c0269a1fc1f4cc77f7750d1dfde2869e2a123af420e67d \ + --hash=sha256:2fbeeeecea279727f8ac16c8e1133ddfeee793e985c86ae343d6a5ce744eef8c \ + --hash=sha256:cb0fddaa6884be6aae36ced9544b5e90f7d5f03845a2853bf47a14953a4e8688 \ + --hash=sha256:77f091ea3a9cc611cd29f433565476bc1936c084ac8eee00ea0e7e70c27e4199 \ + --hash=sha256:6fc448c377d6eeb00a47c673494bd9bae29280ca53987e1869e67ebedfe20658 \ + --hash=sha256:f653e5d7248c1191ec988a85c72edeab46c3ff44f90639a4ed4874ec0be90243 \ + --hash=sha256:9911f31aad8906abe337c271343485cf20df5e70df5d2f57f9f136e7b55f26bc \ + --hash=sha256:e38def96ad59853824c97953fdcd2c320a84ba3ce99b417db78af8bb6c3db635 \ + --hash=sha256:eb6bcae8d1a9d305351ecb108232441d11c5cfe9de840a04388ba5d2db8d735c \ + --hash=sha256:3df60dc267f0a2ca23cb7a9ab1109c62b9335ffbf519fcfe167157c28c09b81d diff --git a/scripts/prepare_agent_pr_message.py b/scripts/prepare_agent_pr_message.py new file mode 100644 index 000000000..b20cf9db3 --- /dev/null +++ b/scripts/prepare_agent_pr_message.py @@ -0,0 +1,190 @@ +"""Safely parse untrusted OpenCode pull-request metadata for a trusted publisher.""" + +from __future__ import annotations + +import argparse +import os +import stat +import sys +import unicodedata +from collections.abc import Sequence +from pathlib import Path + +_BIDIRECTIONAL_CONTROLS = frozenset( + { + "\u061c", + "\u200e", + "\u200f", + "\u202a", + "\u202b", + "\u202c", + "\u202d", + "\u202e", + "\u2066", + "\u2067", + "\u2068", + "\u2069", + } +) + + +def _positive_limit(value: int, field_name: str) -> int: + """Return a positive integer limit or raise a stable validation error.""" + if isinstance(value, bool) or not isinstance(value, int) or value <= 0: + raise ValueError(f"{field_name} must be a positive integer") + return value + + +def _read_regular_file(path: Path, maximum_bytes: int) -> bytes: + """Read a stable regular file without following a symbolic link.""" + try: + before = path.lstat() + except OSError as exc: + raise ValueError("PR message must be a readable regular file") from exc + if stat.S_ISLNK(before.st_mode) or not stat.S_ISREG(before.st_mode): + raise ValueError("PR message must be a regular file") + + flags = os.O_RDONLY + if hasattr(os, "O_NOFOLLOW"): + flags |= os.O_NOFOLLOW + try: + descriptor = os.open(path, flags) + except OSError as exc: + raise ValueError("PR message must be a readable regular file") from exc + + try: + opened = os.fstat(descriptor) + if not stat.S_ISREG(opened.st_mode): + raise ValueError("PR message must be a regular file") + if (before.st_dev, before.st_ino) != (opened.st_dev, opened.st_ino): + raise ValueError("PR message file identity changed while opening") + with os.fdopen(descriptor, "rb", closefd=True) as handle: + descriptor = -1 + payload = handle.read(maximum_bytes + 1) + finally: + if descriptor >= 0: + os.close(descriptor) + + if len(payload) > maximum_bytes: + raise ValueError("PR message exceeds the combined byte budget") + return payload + + +def _reject_unsafe_text(value: str) -> None: + """Reject control and bidirectional characters that can spoof metadata.""" + for character in value: + if character in _BIDIRECTIONAL_CONTROLS: + raise ValueError("PR message contains a bidirectional control character") + if unicodedata.category(character) == "Cc" and character not in {"\n", "\t"}: + raise ValueError("PR message contains an unsupported control character") + + +def _write_private_text(path: Path, value: str) -> None: + """Replace one UTF-8 output file with owner-only permissions.""" + path.parent.mkdir(parents=True, exist_ok=True) + flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC + descriptor = os.open(path, flags, 0o600) + try: + os.fchmod(descriptor, 0o600) + with os.fdopen( + descriptor, + "w", + encoding="utf-8", + newline="\n", + closefd=True, + ) as handle: + descriptor = -1 + handle.write(value) + finally: + if descriptor >= 0: + os.close(descriptor) + + +def parse_pr_message( + source_path: Path, + title_path: Path, + body_path: Path, + *, + max_title_bytes: int, + max_body_bytes: int, +) -> tuple[str, str]: + """Validate one model-authored PR message and write bounded trusted outputs. + + The first line is the title. The remaining lines are the body; one optional + blank separator line is removed. The parser validates strict UTF-8, byte + budgets, stable regular-file identity, control characters, and bidirectional + spoofing before any credential-bearing publication step consumes the text. + """ + title_limit = _positive_limit(max_title_bytes, "max_title_bytes") + body_limit = _positive_limit(max_body_bytes, "max_body_bytes") + maximum_source_bytes = title_limit + body_limit + 4 + payload = _read_regular_file(Path(source_path), maximum_source_bytes) + + try: + text = payload.decode("utf-8", errors="strict") + except UnicodeDecodeError as exc: + raise ValueError("PR message must be valid UTF-8") from exc + text = text.replace("\r\n", "\n").replace("\r", "\n") + _reject_unsafe_text(text) + + lines = text.split("\n") + title = lines[0].strip() + body_lines = lines[1:] + if body_lines and not body_lines[0].strip(): + body_lines = body_lines[1:] + body = "\n".join(body_lines).strip() + + if not title: + raise ValueError("PR message title must not be empty") + if not body: + raise ValueError("PR message body must not be empty") + if len(title.encode("utf-8")) > title_limit: + raise ValueError("PR message title exceeds the UTF-8 byte budget") + if len(body.encode("utf-8")) > body_limit: + raise ValueError("PR message body exceeds the UTF-8 byte budget") + + _write_private_text(Path(title_path), title) + _write_private_text(Path(body_path), body) + return title, body + + +def _parser() -> argparse.ArgumentParser: + """Build the command-line parser for the trusted publication boundary.""" + parser = argparse.ArgumentParser( + description="Validate an untrusted PR_MESSAGE.md and write trusted outputs." + ) + parser.add_argument("source_path", type=Path) + parser.add_argument("title_path", type=Path) + parser.add_argument("body_path", type=Path) + parser.add_argument( + "--max-title-bytes", + type=int, + default=int(os.environ.get("MAX_PR_TITLE_BYTES", "120")), + ) + parser.add_argument( + "--max-body-bytes", + type=int, + default=int(os.environ.get("MAX_PR_BODY_BYTES", "20000")), + ) + return parser + + +def main(argv: Sequence[str] | None = None) -> int: + """Parse CLI arguments, emit trusted files, and return a process status.""" + arguments = _parser().parse_args(argv) + try: + parse_pr_message( + arguments.source_path, + arguments.title_path, + arguments.body_path, + max_title_bytes=arguments.max_title_bytes, + max_body_bytes=arguments.max_body_bytes, + ) + except ValueError as exc: + print(f"metadata validation failed: {exc}", file=sys.stderr) + return 2 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/quality/test_hourly_nim_product_development.py b/tests/quality/test_hourly_nim_product_development.py new file mode 100644 index 000000000..7c56183ca --- /dev/null +++ b/tests/quality/test_hourly_nim_product_development.py @@ -0,0 +1,233 @@ +"""Lock the hourly NVIDIA NIM OpenCode development security contract.""" + +from __future__ import annotations + +import unittest +from pathlib import Path +from types import ModuleType + +WORKFLOW = Path(".github/workflows/hourly-nim-product-development.yml") +PARSER = Path("scripts/prepare_agent_pr_message.py") +RUNBOOK = Path("docs/operations/HOURLY_NIM_PRODUCT_DEVELOPMENT.md") +DOCTORING = Path("docs/doctoring/hourly-nim-opencode-development.md") + + +def _text(path: Path) -> str: + """Return one required UTF-8 contract file.""" + + assert path.is_file(), f"required contract file is missing: {path}" + return path.read_text(encoding="utf-8") + + +def _parser_module() -> ModuleType: + """Load the trusted pull-request metadata parser as a covered module.""" + + assert PARSER.is_file() + import scripts.prepare_agent_pr_message as module + + return module + + +class HourlyNimProductDevelopmentContractTests(unittest.TestCase): + """Structural tests for the credential-separated product-development loop.""" + + def test_hourly_workflow_schedule_credentials_and_queue_gate(self) -> None: + """Run at minute 47 with NIM only and fail closed around PR inventory.""" + + text = _text(WORKFLOW) + for token in ( + 'cron: "47 * * * *"', + "workflow_dispatch:", + "dry_run:", + "hourly-nim-product-development-${{ github.repository }}", + "cancel-in-progress: false", + "secrets.NVIDIA_NIM_API_KEY", + "{env:NVIDIA_NIM_API_KEY}", + "OPENCODE_VERSION", + "OPENCODE_SHA256", + "sha256sum -c", + "pull_request_inventory_unavailable", + "open_pull_request", + "nim_api_key_unavailable", + "maintainer_app_unavailable", + "base_branch_advanced", + "open_pull_request_after_generation", + "ContextualWisdomLab/TEPP", + ): + self.assertIn(token, text) + self.assertNotIn("COPILOT_GITHUB_TOKEN", text) + self.assertNotIn("CONTEXTUAL_ORCHESTRATOR_TOKEN", text) + self.assertEqual(text.count("gh pr create"), 1) + self.assertNotIn("gh pr merge", text) + self.assertNotIn("gh release create", text) + + def test_hourly_workflow_separates_three_runner_trust_boundaries(self) -> None: + """Separate model execution, verification, and late publication authority.""" + + text = _text(WORKFLOW) + proposer = text.split("propose_product_increment:", 1)[1].split( + "package_product_increment:", 1 + )[0] + verifier = text.split("package_product_increment:", 1)[1].split( + "publish_product_increment:", 1 + )[0] + publisher = text.split("publish_product_increment:", 1)[1] + + self.assertIn("NVIDIA_NIM_API_KEY", proposer) + self.assertNotIn("create-github-app-token", proposer) + self.assertNotIn("gh pr create", proposer) + self.assertNotIn("NVIDIA_NIM_API_KEY", verifier) + self.assertNotIn("create-github-app-token", verifier) + self.assertIn("Run every release-quality gate", verifier) + self.assertNotIn("NVIDIA_NIM_API_KEY", publisher) + self.assertIn("create-github-app-token", publisher) + self.assertIn("gh pr create", publisher) + self.assertNotIn("cargo test", publisher) + self.assertNotIn("pytest ", publisher) + self.assertLess( + text.index("Preserve trusted metadata parser"), + text.index("Verify and apply immutable proposal without executing it"), + ) + self.assertLess( + text.index("Parse bounded untrusted pull-request metadata"), + text.index("Mint dedicated maintainer App token only for publication"), + ) + + def test_hourly_workflow_binds_artifacts_and_strips_runtime_channels(self) -> None: + """Bind the patch exactly and remove untrusted GitHub mutation channels.""" + + text = _text(WORKFLOW) + for token in ( + "artifact-id", + "artifact-digest", + "patch_sha256", + "changed_files", + "diff_bytes", + "MAX_CHANGED_FILES", + "MAX_DIFF_BYTES", + "120000", + "160000", + "git diff --cached --check", + "git apply --check --binary", + "retention-days: 1", + "overwrite: false", + "-u GH_TOKEN", + "-u GITHUB_TOKEN", + "-u ACTIONS_ID_TOKEN_REQUEST_TOKEN", + "-u ACTIONS_RUNTIME_TOKEN", + "-u ACTIONS_RESULTS_URL", + "-u ACTIONS_CACHE_URL", + "-u GITHUB_ENV", + "-u GITHUB_OUTPUT", + "-u GITHUB_PATH", + "-u GITHUB_STATE", + "-u GITHUB_STEP_SUMMARY", + "timeout --kill-after", + '"webfetch": "deny"', + '"websearch": "deny"', + '"external_directory": "deny"', + '"task": "deny"', + '"git push *": "deny"', + '"git tag *": "deny"', + '"gh *": "deny"', + "TEPP_MAINTAINER_APP_CLIENT_ID", + "TEPP_MAINTAINER_APP_PRIVATE_KEY", + ): + self.assertIn(token, text) + self.assertEqual(text.count("artifact-ids:"), 2) + + def test_hourly_prompt_and_verifier_keep_commercial_quality_gates(self) -> None: + """Require one buyer gap, research-grounded orchestration, and full checks.""" + + text = _text(WORKFLOW) + normalized = " ".join(text.casefold().split()) + for token in ( + "buyer-visible", + "exactly one bounded pull request", + "standalone", + "modular MSA", + "ContextualWisdomLab/.github", + "naruon", + "contextual-orchestrator", + "Fugu", + "Conductor", + "TRINITY", + "single-model", + "deep multi-agent", + "reasoning effort", + "access lists", + "recursive depth", + "ablation", + "Speed is not a priority", + "100% production statement and branch coverage", + "100% public docstring coverage", + "two-word-or-longer snake_case", + "APA 7", + "CHANGELOG.md", + "Do not merge", + "Do not release", + "Do not deploy", + "Rust", + ): + self.assertIn(token.casefold(), normalized) + + verifier = text.split("package_product_increment:", 1)[1].split( + "publish_product_increment:", 1 + )[0] + for command in ( + "python3 scripts/check_workspace_contract.py", + "python3 scripts/check_docstrings.py", + "cargo clippy --workspace --all-targets --all-features -- -D warnings", + "cargo nextest run --workspace --all-features", + "cargo deny check", + 'line_coverage="$RUNNER_TEMP/coverage.lcov"', + 'branch_coverage="$RUNNER_TEMP/coverage-branches.json"', + 'python3 scripts/check_coverage.py "$line_coverage" --kind lines --format lcov', + 'python3 scripts/check_coverage.py "$branch_coverage" --kind branches', + ): + self.assertIn(command, verifier) + + def test_parser_accepts_unicode_and_owner_only_outputs(self) -> None: + """Parse realistic Korean metadata and protect trusted output files.""" + + import tempfile + + parser = _parser_module() + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + source = tmp_path / "PR_MESSAGE.md" + title_path = tmp_path / "title.txt" + body_path = tmp_path / "body.md" + source.write_text( + "feat: 시간별 NIM 제품 개발 루프 추가\r\n\r\n" + "구매자가 체감하는 제품 Gap 하나를 안전하게 닫습니다.\r\n", + encoding="utf-8", + ) + parser.main( + [ + str(source), + str(title_path), + str(body_path), + ] + ) + self.assertEqual( + title_path.read_text(encoding="utf-8"), + "feat: 시간별 NIM 제품 개발 루프 추가", + ) + self.assertIn("구매자가 체감하는 제품 Gap", body_path.read_text(encoding="utf-8")) + for path in (title_path, body_path): + self.assertEqual(path.stat().st_mode & 0o777, 0o600) + + def test_supporting_runbook_and_doctoring_exist(self) -> None: + """Keep operations and research doctoring discoverable.""" + + runbook = _text(RUNBOOK) + doctoring = _text(DOCTORING) + for token in ("NVIDIA_NIM_API_KEY", "proposal", "verification", "publication"): + self.assertIn(token, runbook) + self.assertIn("APA", doctoring) + self.assertIn("Do not configure `COPILOT_GITHUB_TOKEN`", runbook) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/quality/test_prepare_agent_pr_message.py b/tests/quality/test_prepare_agent_pr_message.py new file mode 100644 index 000000000..dd7aeaa46 --- /dev/null +++ b/tests/quality/test_prepare_agent_pr_message.py @@ -0,0 +1,308 @@ +"""Exercise the trusted PR metadata parser fail-closed paths.""" + +from __future__ import annotations + +import os +import stat +import tempfile +import unittest +import unittest.mock +from pathlib import Path +from types import ModuleType + + +def _load_parser() -> ModuleType: + import scripts.prepare_agent_pr_message as module + + return module + + +class PrepareAgentPrMessageTests(unittest.TestCase): + """Cover validation, limits, and CLI status codes for publication safety.""" + + def setUp(self) -> None: + self.parser = _load_parser() + self.tmp = tempfile.TemporaryDirectory() + self.root = Path(self.tmp.name) + + def tearDown(self) -> None: + self.tmp.cleanup() + + def _write(self, name: str, payload: bytes | str) -> Path: + path = self.root / name + if isinstance(payload, str): + path.write_text(payload, encoding="utf-8") + else: + path.write_bytes(payload) + return path + + def test_positive_limit_rejects_non_positive_values(self) -> None: + with self.assertRaises(ValueError): + self.parser._positive_limit(0, "max_title_bytes") + with self.assertRaises(ValueError): + self.parser._positive_limit(-1, "max_title_bytes") + with self.assertRaises(ValueError): + self.parser._positive_limit(True, "max_title_bytes") # type: ignore[arg-type] + self.assertEqual(self.parser._positive_limit(12, "max_title_bytes"), 12) + + def test_parse_happy_path_strips_blank_separator_and_sets_mode(self) -> None: + source = self._write( + "PR_MESSAGE.md", + "feat: add temporal reasoner\n\nBody explains buyer gap and RED/GREEN evidence.\n", + ) + title_path = self.root / "title.txt" + body_path = self.root / "nested" / "body.md" + title, body = self.parser.parse_pr_message( + source, + title_path, + body_path, + max_title_bytes=120, + max_body_bytes=20000, + ) + self.assertEqual(title, "feat: add temporal reasoner") + self.assertIn("buyer gap", body) + self.assertEqual(stat.S_IMODE(title_path.stat().st_mode), 0o600) + self.assertEqual(stat.S_IMODE(body_path.stat().st_mode), 0o600) + + def test_rejects_missing_symlink_directory_invalid_utf8_and_controls(self) -> None: + missing = self.root / "missing.md" + with self.assertRaises(ValueError): + self.parser.parse_pr_message( + missing, self.root / "t", self.root / "b", max_title_bytes=20, max_body_bytes=20 + ) + + directory = self.root / "dir" + directory.mkdir() + with self.assertRaises(ValueError): + self.parser.parse_pr_message( + directory, self.root / "t", self.root / "b", max_title_bytes=20, max_body_bytes=20 + ) + + link = self.root / "link.md" + target = self._write("target.md", "title\n\nbody\n") + link.symlink_to(target) + with self.assertRaises(ValueError): + self.parser.parse_pr_message( + link, self.root / "t", self.root / "b", max_title_bytes=20, max_body_bytes=20 + ) + + invalid = self._write("bad.md", b"\xff\xfe title\n\nbody\n") + with self.assertRaises(ValueError): + self.parser.parse_pr_message( + invalid, self.root / "t", self.root / "b", max_title_bytes=40, max_body_bytes=40 + ) + + bidi = self._write("bidi.md", "title\u202e\n\nbody text\n") + with self.assertRaises(ValueError): + self.parser.parse_pr_message( + bidi, self.root / "t", self.root / "b", max_title_bytes=40, max_body_bytes=40 + ) + + control = self._write("ctrl.md", "title\x01\n\nbody text\n") + with self.assertRaises(ValueError): + self.parser.parse_pr_message( + control, self.root / "t", self.root / "b", max_title_bytes=40, max_body_bytes=40 + ) + + def test_rejects_empty_title_body_and_budget_overruns(self) -> None: + empty_title = self._write("empty-title.md", "\n\nbody only\n") + with self.assertRaises(ValueError): + self.parser.parse_pr_message( + empty_title, self.root / "t", self.root / "b", max_title_bytes=40, max_body_bytes=40 + ) + + empty_body = self._write("empty-body.md", "title only\n") + with self.assertRaises(ValueError): + self.parser.parse_pr_message( + empty_body, self.root / "t", self.root / "b", max_title_bytes=40, max_body_bytes=40 + ) + + long_title = self._write("long-title.md", "t" * 50 + "\n\nbody\n") + with self.assertRaises(ValueError): + self.parser.parse_pr_message( + long_title, self.root / "t", self.root / "b", max_title_bytes=10, max_body_bytes=40 + ) + + long_body = self._write("long-body.md", "title\n\n" + ("b" * 50) + "\n") + with self.assertRaises(ValueError): + self.parser.parse_pr_message( + long_body, self.root / "t", self.root / "b", max_title_bytes=40, max_body_bytes=10 + ) + + oversized = self._write("oversized.md", "x" * 100) + with self.assertRaises(ValueError): + self.parser.parse_pr_message( + oversized, self.root / "t", self.root / "b", max_title_bytes=5, max_body_bytes=5 + ) + + def test_main_returns_success_and_failure_codes(self) -> None: + source = self._write("ok.md", "feat: title\n\nBody for the product gap.\n") + title_path = self.root / "title.txt" + body_path = self.root / "body.md" + status = self.parser.main( + [str(source), str(title_path), str(body_path), "--max-title-bytes", "120"] + ) + self.assertEqual(status, 0) + self.assertTrue(title_path.is_file()) + + missing = self.root / "nope.md" + status = self.parser.main([str(missing), str(title_path), str(body_path)]) + self.assertEqual(status, 2) + + def test_crlf_and_cr_normalization(self) -> None: + source = self._write("crlf.md", "feat: title\r\n\r\nBody line\rMore\n") + title, body = self.parser.parse_pr_message( + source, + self.root / "t", + self.root / "b", + max_title_bytes=40, + max_body_bytes=40, + ) + self.assertEqual(title, "feat: title") + self.assertIn("Body line", body) + + +if __name__ == "__main__": + unittest.main() + + +class PrepareAgentPrMessageHardeningTests(unittest.TestCase): + """Cover rare filesystem race and budget paths with controlled doubles.""" + + def setUp(self) -> None: + self.parser = _load_parser() + self.tmp = tempfile.TemporaryDirectory() + self.root = Path(self.tmp.name) + + def tearDown(self) -> None: + self.tmp.cleanup() + + def test_title_and_body_byte_budgets_after_successful_read(self) -> None: + # Keep total payload under title+body+4 so the size gate does not fire first. + source = self.root / "budget.md" + source.write_text("abcdefghij\n\nbody-ok\n", encoding="utf-8") + with self.assertRaises(ValueError): + self.parser.parse_pr_message( + source, + self.root / "t", + self.root / "b", + max_title_bytes=5, + max_body_bytes=40, + ) + source.write_text("title\n\n" + ("x" * 20), encoding="utf-8") + with self.assertRaises(ValueError): + self.parser.parse_pr_message( + source, + self.root / "t", + self.root / "b", + max_title_bytes=40, + max_body_bytes=5, + ) + + def test_body_without_blank_separator_line(self) -> None: + source = self.root / "no-sep.md" + source.write_text("feat: title\nBody continues immediately.\n", encoding="utf-8") + title, body = self.parser.parse_pr_message( + source, + self.root / "t", + self.root / "b", + max_title_bytes=40, + max_body_bytes=80, + ) + self.assertEqual(title, "feat: title") + self.assertTrue(body.startswith("Body continues")) + + def test_open_failures_and_identity_races_are_fail_closed(self) -> None: + source = self.root / "race.md" + source.write_text("title\n\nbody\n", encoding="utf-8") + + with unittest.mock.patch( + "scripts.prepare_agent_pr_message.os.open", side_effect=OSError("denied") + ): + with self.assertRaises(ValueError): + self.parser._read_regular_file(source, 100) + + real_fstat = os.fstat + + def fake_fstat(fd: int): # noqa: ANN001 + info = real_fstat(fd) + # Mutate mode so S_ISREG fails. + return os.stat_result( + (info.st_mode & ~stat.S_IFREG, *info[1:]) + ) + + with unittest.mock.patch( + "scripts.prepare_agent_pr_message.os.fstat", side_effect=fake_fstat + ): + with self.assertRaises(ValueError): + self.parser._read_regular_file(source, 100) + + # st_dev/st_ino positions: mode,ino,dev,... actually index: st_mode=0,st_ino=1,st_dev=2 + def identity_shift2(fd: int): # noqa: ANN001 + info = real_fstat(fd) + values = list(info) + values[1] = info.st_ino + 99 + return os.stat_result(values) + + with unittest.mock.patch( + "scripts.prepare_agent_pr_message.os.fstat", side_effect=identity_shift2 + ): + with self.assertRaises(ValueError): + self.parser._read_regular_file(source, 100) + + def test_write_and_read_finally_close_paths(self) -> None: + path = self.root / "out.txt" + with unittest.mock.patch( + "scripts.prepare_agent_pr_message.os.fdopen", + side_effect=OSError("write-fail"), + ): + with self.assertRaises(OSError): + self.parser._write_private_text(path, "value") + + source = self.root / "read-fail.md" + source.write_text("title\n\nbody\n", encoding="utf-8") + + def boom(*args, **kwargs): # noqa: ANN001, ANN002 + raise OSError("read-fail") + + with unittest.mock.patch( + "scripts.prepare_agent_pr_message.os.fdopen", side_effect=boom + ): + with self.assertRaises(OSError): + self.parser._read_regular_file(source, 100) + + def test_module_entrypoint_invokes_main(self) -> None: + import runpy + + source = self.root / "entry.md" + source.write_text("feat: entry\n\nBody text for entrypoint coverage.\n", encoding="utf-8") + title = self.root / "t.txt" + body = self.root / "b.md" + with unittest.mock.patch( + "sys.argv", + [ + "prepare_agent_pr_message.py", + str(source), + str(title), + str(body), + ], + ): + with self.assertRaises(SystemExit) as raised: + runpy.run_module("scripts.prepare_agent_pr_message", run_name="__main__") + self.assertEqual(raised.exception.code, 0) + + def test_read_without_nofollow_flag_when_unavailable(self) -> None: + source = self.root / "plain.md" + source.write_text("title\n\nbody\n", encoding="utf-8") + real_hasattr = hasattr + + def selective_hasattr(obj, name): # noqa: ANN001 + if obj is os and name == "O_NOFOLLOW": + return False + return real_hasattr(obj, name) + + with unittest.mock.patch( + "scripts.prepare_agent_pr_message.hasattr", side_effect=selective_hasattr + ): + payload = self.parser._read_regular_file(source, 100) + self.assertTrue(payload.startswith(b"title"))