diff --git a/.github/scripts/classify-candidate.mjs b/.github/scripts/classify-candidate.mjs new file mode 100644 index 0000000..6ef2efa --- /dev/null +++ b/.github/scripts/classify-candidate.mjs @@ -0,0 +1,117 @@ +import { appendFileSync, existsSync, readFileSync, readdirSync } from "node:fs"; +import { execFileSync } from "node:child_process"; +import { resolve } from "node:path"; +import { pathToFileURL } from "node:url"; + +const MAX_CHANGED_PATH_BYTES = 4 * 1024 * 1024; +const MAX_CHANGED_PATHS = 10_000; + +function globPattern(pattern) { + let normalized = pattern.startsWith("/") ? pattern.slice(1) : pattern; + let expression = ""; + for (let index = 0; index < normalized.length; index += 1) { + const character = normalized[index]; + if (character === "*" && normalized[index + 1] === "*") { + expression += ".*"; + index += 1; + } else if (character === "*") { + expression += "[^/]*"; + } else if (character === "?") { + expression += "[^/]"; + } else { + expression += character.replace(/[|\\{}()[\]^$+?.]/gu, "\\$&"); + } + } + return new RegExp(`^${expression}$`, "u"); +} + +function changedPaths(candidateRoot, baseSha, headSha) { + const output = execFileSync( + "git", + [ + "-C", + candidateRoot, + "diff", + "--no-renames", + "--name-only", + "-z", + baseSha, + headSha, + ], + { encoding: "utf8", maxBuffer: MAX_CHANGED_PATH_BYTES }, + ); + const paths = output.split("\0").filter(Boolean); + if (paths.length > MAX_CHANGED_PATHS) { + throw new Error("candidate changes exceed the trusted path budget"); + } + return paths; +} + +function requireNoCandidateWorkflows(candidateRoot) { + const workflowRoot = resolve(candidateRoot, ".github/workflows"); + if (!existsSync(workflowRoot)) return; + const workflows = readdirSync(workflowRoot).filter((name) => /\.ya?ml$/u.test(name)); + if (workflows.length > 0) { + throw new Error( + `target repositories must delegate automatic workflows to organization controls: ${workflows.join(",")}`, + ); + } +} + +export function classifyCandidate({ + baseSha, + candidateRoot, + exactPolicyOutcome, + headSha, + trustedRoot, +}) { + requireNoCandidateWorkflows(candidateRoot); + const policy = JSON.parse( + readFileSync(resolve(trustedRoot, ".github/merge-policy.json"), "utf8"), + ); + if (!Array.isArray(policy.protected_paths) || policy.protected_paths.length === 0) { + throw new Error("trusted merge policy must define protected paths"); + } + const matchers = policy.protected_paths.map((pattern) => { + if (typeof pattern !== "string" || pattern.startsWith("!")) { + throw new Error("trusted protected paths must be positive string patterns"); + } + return globPattern(pattern); + }); + const paths = changedPaths(candidateRoot, baseSha, headSha); + const protectedChanges = paths.filter((path) => + matchers.some((matcher) => matcher.test(path)), + ); + const sensitive = exactPolicyOutcome !== "success" || protectedChanges.length > 0; + return Object.freeze({ + sensitive, + protectedChanges: Object.freeze(protectedChanges), + changedPathCount: paths.length, + }); +} + +function main() { + const result = classifyCandidate({ + baseSha: process.env.BASE_SHA, + candidateRoot: process.env.CANDIDATE_ROOT, + exactPolicyOutcome: process.env.EXACT_POLICY_OUTCOME, + headSha: process.env.HEAD_SHA, + trustedRoot: process.env.TRUSTED_ROOT, + }); + appendFileSync( + process.env.GITHUB_OUTPUT, + `sensitive=${result.sensitive ? "true" : "false"}\n`, + ); + process.stdout.write( + `${JSON.stringify({ status: "classified", ...result })}\n`, + ); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + try { + main(); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + } +} diff --git a/.github/scripts/reject-candidate-authorities.sh b/.github/scripts/reject-candidate-authorities.sh index c7edcf8..e4493f2 100755 --- a/.github/scripts/reject-candidate-authorities.sh +++ b/.github/scripts/reject-candidate-authorities.sh @@ -12,6 +12,7 @@ if awk '$1 == "120000" { found = 1 } END { exit !found }' "$index_entries"; then fi test ! -e "$candidate_root/.npmrc" +test ! -e "$candidate_root/.github/policy-parser/.npmrc" test ! -e "$candidate_root/npm-shrinkwrap.json" test ! -L "$candidate_root/package.json" test ! -L "$candidate_root/package-lock.json" diff --git a/.github/scripts/run-candidate-quality.sh b/.github/scripts/run-candidate-quality.sh new file mode 100755 index 0000000..e91f08b --- /dev/null +++ b/.github/scripts/run-candidate-quality.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +set -euo pipefail + +repository="${1:?target repository is required}" +candidate_root="${2:?candidate repository root is required}" +candidate_home="${HOME:?}" +candidate_tmp="${RUNNER_TEMP:?}" +npm_globalconfig="$candidate_tmp/npm-globalconfig" +npm_userconfig="$candidate_tmp/npm-userconfig" + +: > "$npm_globalconfig" +: > "$npm_userconfig" + +candidate_environment=( + "CI=true" + "HOME=$candidate_home" + "LANG=${LANG:-C.UTF-8}" + "NPM_CONFIG_CACHE=$candidate_tmp/npm-cache" + "NPM_CONFIG_GLOBALCONFIG=$npm_globalconfig" + "NPM_CONFIG_REGISTRY=https://registry.npmjs.org" + "NPM_CONFIG_REPLACE_REGISTRY_HOST=never" + "NPM_CONFIG_USERCONFIG=$npm_userconfig" + "PATH=$PATH" + "PWD=$candidate_root" + "RUNNER_TEMP=$candidate_tmp" + "TMPDIR=${TMPDIR:-$candidate_tmp}" +) +if test -n "${DOCKER_HOST:-}"; then + candidate_environment+=("DOCKER_HOST=$DOCKER_HOST") +fi + +run_clean() { + env -i "${candidate_environment[@]}" "$@" +} + +cd "$candidate_root" +run_clean npm ci --ignore-scripts +run_clean npm audit --audit-level=moderate +run_clean npm ci --ignore-scripts --prefix .github/policy-parser +run_clean npm audit --audit-level=moderate --prefix .github/policy-parser + +case "$repository" in + openboa-ai/coffee-chat) + run_clean npm run format:check + run_clean npm run typecheck + run_clean npm test + run_clean npm run readme:assets:verify + run_clean npm run build + run_clean npm run package:smoke + ;; + openboa-ai/coffee-chat-roastery) + run_clean npm run format:check + run_clean npm run typecheck + run_clean npm run dist:check + run_clean npm run repository:check + run_clean npm run smoke + run_clean npm run package:check + ;; + openboa-ai/coffee-chat-eval) + run_clean npm run format:check + run_clean npm run typecheck + run_clean npm run build + run_clean npm run canary:check + run_clean npm test + run_clean npm run dry-run + run_clean npm run smoke + run_clean npm run pcda:calibrate + ;; + openboa-ai/coffee-chat-bench) + run_clean npm run format:check + run_clean npm run check:inactive + run_clean npm run typecheck + run_clean npm test + ;; + *) + echo "unsupported Coffee repository: $repository" >&2 + exit 1 + ;; +esac diff --git a/.github/scripts/run-eval-harbor.sh b/.github/scripts/run-eval-harbor.sh new file mode 100755 index 0000000..0f785c5 --- /dev/null +++ b/.github/scripts/run-eval-harbor.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +set -euo pipefail + +candidate_root="${1:?candidate repository root is required}" +candidate_tmp="${RUNNER_TEMP:?}" +uv_root="$candidate_tmp/uv-venv" +harbor_root="$candidate_tmp/harbor-venv" + +clean_environment=( + "CI=true" + "HOME=${HOME:?}" + "LANG=${LANG:-C.UTF-8}" + "PATH=$PATH" + "PIP_CONFIG_FILE=/dev/null" + "PIP_DISABLE_PIP_VERSION_CHECK=1" + "PIP_INDEX_URL=https://pypi.org/simple" + "RUNNER_TEMP=$candidate_tmp" + "TMPDIR=${TMPDIR:-$candidate_tmp}" + "UV_INDEX_URL=https://pypi.org/simple" + "UV_NO_CONFIG=1" +) +if test -n "${DOCKER_HOST:-}"; then + clean_environment+=("DOCKER_HOST=$DOCKER_HOST") +fi + +run_clean() { + env -i "${clean_environment[@]}" "$@" +} + +test -f "$candidate_root/.github/uv-requirements.txt" +test -f "$candidate_root/.github/harbor-requirements.txt" +test -f "$candidate_root/src/canary-cli.ts" +test -f "$candidate_root/src/harbor.ts" + +run_clean python3 -m venv "$uv_root" +run_clean "$uv_root/bin/python" -m pip install \ + --disable-pip-version-check --require-hashes --no-deps \ + -r "$candidate_root/.github/uv-requirements.txt" +run_clean "$uv_root/bin/uv" venv --python python3 --no-python-downloads \ + "$harbor_root" +run_clean "$uv_root/bin/uv" pip install --require-hashes --no-deps \ + --only-binary :all: --python "$harbor_root/bin/python" \ + -r "$candidate_root/.github/harbor-requirements.txt" + +cd "$candidate_root" +env -i "${clean_environment[@]}" \ + "HARBOR_COMMAND=$harbor_root/bin/harbor" \ + node --experimental-strip-types src/canary-cli.ts calibrate +env -i "${clean_environment[@]}" \ + "HARBOR_COMMAND=$harbor_root/bin/harbor" \ + node --experimental-strip-types src/canary-cli.ts benchmark-calibrate diff --git a/.github/workflows/coffee-trusted-gate.yml b/.github/workflows/coffee-trusted-gate.yml index 51eb75c..3d6471e 100644 --- a/.github/workflows/coffee-trusted-gate.yml +++ b/.github/workflows/coffee-trusted-gate.yml @@ -16,6 +16,8 @@ jobs: timeout-minutes: 20 permissions: contents: read + outputs: + sensitive: ${{ steps.classify.outputs.sensitive }} steps: - name: Admit only the solo maintainer or in-repository Dependabot env: @@ -115,11 +117,13 @@ jobs: env: NODE_OPTIONS: "" NODE_PATH: "" - NPM_CONFIG_GLOBALCONFIG: /dev/null + NPM_CONFIG_GLOBALCONFIG: ${{ runner.temp }}/trusted-npm-globalconfig NPM_CONFIG_REGISTRY: https://registry.npmjs.org NPM_CONFIG_REPLACE_REGISTRY_HOST: never - NPM_CONFIG_USERCONFIG: /dev/null + NPM_CONFIG_USERCONFIG: ${{ runner.temp }}/trusted-npm-userconfig run: | + : > "$NPM_CONFIG_GLOBALCONFIG" + : > "$NPM_CONFIG_USERCONFIG" test -f "$GITHUB_WORKSPACE/trusted-target/.github/policy-bootstrap.mjs" test -f "$GITHUB_WORKSPACE/trusted-target/.github/ci-policy.mjs" test ! -L "$GITHUB_WORKSPACE/trusted-target/.github/policy-bootstrap.mjs" @@ -127,7 +131,9 @@ jobs: node "$GITHUB_WORKSPACE/trusted-target/.github/policy-bootstrap.mjs" npm ci --ignore-scripts --prefix "$GITHUB_WORKSPACE/trusted-target/.github/policy-parser" npm audit --audit-level=moderate --prefix "$GITHUB_WORKSPACE/trusted-target/.github/policy-parser" - - name: Enforce trusted base policy against candidate data + - name: Evaluate exact trusted base policy against candidate data + id: exact-policy + continue-on-error: true env: BENCH_CI_POLICY_ROOT: ${{ github.workspace }}/candidate CI_POLICY_ROOT: ${{ github.workspace }}/candidate @@ -136,6 +142,27 @@ jobs: NODE_PATH: "" ROASTERY_CI_POLICY_ROOT: ${{ github.workspace }}/candidate run: node "$GITHUB_WORKSPACE/trusted-target/.github/ci-policy.mjs" + - name: Classify policy evolution and protected path changes + id: classify + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + CANDIDATE_ROOT: ${{ github.workspace }}/candidate + EXACT_POLICY_OUTCOME: ${{ steps.exact-policy.outcome }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + TRUSTED_ROOT: ${{ github.workspace }}/trusted-target + run: node control/.github/scripts/classify-candidate.mjs + + sensitive-review: + name: Sensitive change confirmation + needs: authorize + if: ${{ needs.authorize.outputs.sensitive == 'true' }} + environment: coffee-security + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: {} + steps: + - name: Record the GitHub Environment approval + run: test '${{ needs.authorize.outputs.sensitive }}' = true dependency-review: name: Trusted dependency review @@ -185,6 +212,87 @@ jobs: - name: Analyze candidate data with CodeQL uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 + quality: + name: Trusted deterministic quality + needs: + - authorize + - dependency-review + - sensitive-review + if: >- + ${{ always() && needs.authorize.result == 'success' && + needs.dependency-review.result == 'success' && + (needs.authorize.outputs.sensitive != 'true' || + needs.sensitive-review.result == 'success') }} + runs-on: ubuntu-24.04 + timeout-minutes: 60 + permissions: + contents: read + steps: + - name: Check out immutable organization controls + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + repository: openboa-ai/.github + ref: ${{ github.workflow_sha }} + fetch-depth: 1 + persist-credentials: false + path: control + - name: Check out the exact authorized candidate + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + repository: ${{ github.event.pull_request.head.repo.full_name }} + ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 0 + persist-credentials: false + path: candidate + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 + with: + node-version: 24 + - name: Run repository-specific checks with a minimal environment + run: >- + control/.github/scripts/run-candidate-quality.sh + '${{ github.repository }}' "$GITHUB_WORKSPACE/candidate" + + eval-harbor: + name: Trusted Eval Harbor calibration + needs: + - authorize + - sensitive-review + if: >- + ${{ always() && github.repository == 'openboa-ai/coffee-chat-eval' && + needs.authorize.result == 'success' && + (needs.authorize.outputs.sensitive != 'true' || + needs.sensitive-review.result == 'success') }} + runs-on: ubuntu-24.04 + timeout-minutes: 30 + permissions: + contents: read + steps: + - name: Check out immutable organization controls + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + repository: openboa-ai/.github + ref: ${{ github.workflow_sha }} + fetch-depth: 1 + persist-credentials: false + path: control + - name: Check out the exact authorized Eval candidate + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + repository: ${{ github.event.pull_request.head.repo.full_name }} + ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 1 + persist-credentials: false + path: candidate + - name: Set up Node.js without candidate dependencies + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 + with: + node-version: 24 + - name: Install the hash-locked Harbor graph and calibrate + run: >- + control/.github/scripts/run-eval-harbor.sh + "$GITHUB_WORKSPACE/candidate" + required: name: OpenBoa Coffee trusted required if: >- @@ -193,8 +301,11 @@ jobs: "openboa-ai/coffee-chat-bench"]'), github.repository) }} needs: - authorize + - sensitive-review - dependency-review - codeql + - eval-harbor + - quality runs-on: ubuntu-24.04 timeout-minutes: 5 permissions: {} @@ -204,7 +315,23 @@ jobs: AUTHORIZE_RESULT: ${{ needs.authorize.result }} CODEQL_RESULT: ${{ needs.codeql.result }} DEPENDENCY_REVIEW_RESULT: ${{ needs.dependency-review.result }} + EVAL_HARBOR_RESULT: ${{ needs.eval-harbor.result }} + QUALITY_RESULT: ${{ needs.quality.result }} + REPOSITORY: ${{ github.repository }} + SENSITIVE: ${{ needs.authorize.outputs.sensitive }} + SENSITIVE_REVIEW_RESULT: ${{ needs.sensitive-review.result }} run: | test "$AUTHORIZE_RESULT" = success test "$DEPENDENCY_REVIEW_RESULT" = success test "$CODEQL_RESULT" = success + test "$QUALITY_RESULT" = success + if test "$REPOSITORY" = openboa-ai/coffee-chat-eval; then + test "$EVAL_HARBOR_RESULT" = success + else + test "$EVAL_HARBOR_RESULT" = skipped + fi + if test "$SENSITIVE" = true; then + test "$SENSITIVE_REVIEW_RESULT" = success + else + test "$SENSITIVE_REVIEW_RESULT" = skipped + fi diff --git a/AGENTS.md b/AGENTS.md index 759f537..f50bb22 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,14 +3,21 @@ This repository owns organization-wide GitHub policy and required workflow definitions. Treat every executable or policy file as a sensitive control. -- Changes use a pull request and require independent review from the - `security-maintainers` team after the initial security bootstrap. +- Target repository changes use pull requests. Routine changes remain eligible + for native auto-merge; protected paths and policy evolution pause at the + `coffee-security` GitHub Environment for the solo maintainer's confirmation. - Required workflows execute pull-request content only as inert data. Controls and parsers must come from the required workflow SHA or the target base SHA. +- Target repositories must not define automatic workflow YAML. Authorization, + secret scanning, dependency review, CodeQL, and deterministic quality belong + to this organization-owned required workflow. - Do not add secrets, OIDC, package publishing, deployment, or write-token permissions. The only write permission is `security-events: write` in the trusted CodeQL job. - Do not enable merge queue. Routine auto-merge applies in the target Coffee repositories only after this trusted workflow and their normal CI pass. +- Eval Harbor calibration runs on a fresh runner before any candidate program, + from its complete hash-locked dependency graph. - Run `npm test`, `actionlint .github/workflows/*.yml`, `sh -n - .github/scripts/*.sh`, and `git diff --check` before merging. + .github/scripts/*.sh`, `node --check .github/scripts/*.mjs`, and + `git diff --check` before merging. diff --git a/SECURITY.md b/SECURITY.md index fbcc118..9eb1f22 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -5,5 +5,11 @@ private vulnerability report. Do not include credentials or exploit details in a public issue. The `.github/workflows/coffee-trusted-gate.yml` workflow is an organization -trust boundary. Changes to it, its installer, ownership, or this policy require -review from `@openboa-ai/security-maintainers`. +trust boundary. Target repositories delegate automatic pull-request execution +to it. Routine changes may auto-merge only after every trusted lane succeeds; +protected paths and policy evolution additionally require the solo maintainer's +`coffee-security` GitHub Environment confirmation. + +Candidate repositories may not supply workflow YAML, alternate npm authority, +or symlinked control data. Eval Harbor calibration uses a fresh runner and an +authenticated dependency graph so earlier candidate tests cannot mutate it. diff --git a/scripts/test-coffee-required-workflow.mjs b/scripts/test-coffee-required-workflow.mjs index a0d505d..c8eab0d 100644 --- a/scripts/test-coffee-required-workflow.mjs +++ b/scripts/test-coffee-required-workflow.mjs @@ -3,6 +3,7 @@ import { execFileSync, spawnSync } from "node:child_process"; import { mkdtempSync, mkdirSync, + renameSync, readFileSync, rmSync, symlinkSync, @@ -12,6 +13,8 @@ import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import test from "node:test"; +import { classifyCandidate } from "../.github/scripts/classify-candidate.mjs"; + const root = resolve(import.meta.dirname, ".."); const workflowPath = resolve(root, ".github/workflows/coffee-trusted-gate.yml"); const workflow = readFileSync(workflowPath, "utf8"); @@ -19,6 +22,60 @@ const authorityCheck = resolve( root, ".github/scripts/reject-candidate-authorities.sh", ); +const qualityRunnerPath = resolve( + root, + ".github/scripts/run-candidate-quality.sh", +); +const qualityRunner = readFileSync(qualityRunnerPath, "utf8"); +const evalHarborRunner = readFileSync( + resolve(root, ".github/scripts/run-eval-harbor.sh"), + "utf8", +); + +function commit(repository, message) { + execFileSync("git", ["-C", repository, "add", "."]); + execFileSync("git", ["-C", repository, "commit", "-qm", message]); + return execFileSync("git", ["-C", repository, "rev-parse", "HEAD"], { + encoding: "utf8", + }).trim(); +} + +function classificationFixture(mutate) { + const fixture = mkdtempSync(join(tmpdir(), "coffee-classifier-")); + const candidate = join(fixture, "candidate"); + const trusted = join(fixture, "trusted"); + mkdirSync(candidate); + mkdirSync(join(trusted, ".github"), { recursive: true }); + execFileSync("git", ["init", "-q", candidate]); + execFileSync("git", ["-C", candidate, "config", "user.email", "test@example.invalid"]); + execFileSync("git", ["-C", candidate, "config", "user.name", "Policy test"]); + mkdirSync(join(candidate, "src")); + writeFileSync(join(candidate, "README.md"), "base\n"); + writeFileSync(join(candidate, "src/control.ts"), "export const control = true;\n"); + const baseSha = commit(candidate, "base"); + writeFileSync( + join(trusted, ".github/merge-policy.json"), + `${JSON.stringify({ protected_paths: ["/src/**", "/.github/**"] })}\n`, + ); + mutate(candidate); + const headSha = commit(candidate, "candidate"); + return { baseSha, candidate, fixture, headSha, trusted }; +} + +function classifyFixture(mutate, exactPolicyOutcome = "success") { + const fixture = classificationFixture(mutate); + try { + return classifyCandidate({ + baseSha: fixture.baseSha, + candidateRoot: fixture.candidate, + exactPolicyOutcome, + headSha: fixture.headSha, + trustedRoot: fixture.trusted, + }); + } finally { + rmSync(fixture.fixture, { force: true, recursive: true }); + } +} test("trusted gate uses only the ruleset-supported pull request event", () => { assert.match(workflow, /^on:\n pull_request:\s*$/mu); @@ -66,13 +123,85 @@ test("trusted gate rejects candidate symlink escapes before reading data", () => "Scan candidate history, worktree, and raw blobs", ); const policy = workflow.indexOf( - "Enforce trusted base policy against candidate data", + "Evaluate exact trusted base policy against candidate data", ); assert.ok(rejection > 0); assert.ok(rejection < secretScan); assert.ok(rejection < policy); }); +test("authority check rejects every alternate npm authority", () => { + for (const authority of [ + ".npmrc", + ".github/policy-parser/.npmrc", + "npm-shrinkwrap.json", + ]) { + const fixture = mkdtempSync(join(tmpdir(), "coffee-npm-authority-")); + try { + execFileSync("git", ["init", "-q", fixture]); + mkdirSync(resolve(fixture, authority, ".."), { recursive: true }); + writeFileSync(resolve(fixture, authority), "registry=https://attacker.invalid\n"); + execFileSync("git", ["-C", fixture, "add", "."]); + const result = spawnSync(authorityCheck, [fixture], { encoding: "utf8" }); + assert.equal(result.status, 1, `${authority} was accepted`); + } finally { + rmSync(fixture, { force: true, recursive: true }); + } + } +}); + +test("classifier keeps routine changes automatic", () => { + const result = classifyFixture((candidate) => { + writeFileSync(join(candidate, "README.md"), "routine\n"); + }); + assert.equal(result.sensitive, false); + assert.deepEqual(result.protectedChanges, []); +}); + +test("classifier routes protected edits and policy evolution to the Environment", () => { + const protectedEdit = classifyFixture((candidate) => { + writeFileSync(join(candidate, "src/control.ts"), "export const control = false;\n"); + }); + assert.equal(protectedEdit.sensitive, true); + assert.deepEqual(protectedEdit.protectedChanges, ["src/control.ts"]); + + const policyEvolution = classifyFixture((candidate) => { + writeFileSync(join(candidate, "README.md"), "new policy shape\n"); + }, "failure"); + assert.equal(policyEvolution.sensitive, true); +}); + +test("classifier checks both sides of protected path renames", () => { + const result = classifyFixture((candidate) => { + mkdirSync(join(candidate, "lib")); + renameSync(join(candidate, "src/control.ts"), join(candidate, "lib/control.ts")); + }); + assert.equal(result.sensitive, true); + assert.ok(result.protectedChanges.includes("src/control.ts")); +}); + +test("classifier rejects target-owned automatic workflows", () => { + const fixture = classificationFixture((candidate) => { + mkdirSync(join(candidate, ".github/workflows"), { recursive: true }); + writeFileSync(join(candidate, ".github/workflows/spoof.yml"), "on: pull_request\n"); + }); + try { + assert.throws( + () => + classifyCandidate({ + baseSha: fixture.baseSha, + candidateRoot: fixture.candidate, + exactPolicyOutcome: "success", + headSha: fixture.headSha, + trustedRoot: fixture.trusted, + }), + /must delegate automatic workflows/u, + ); + } finally { + rmSync(fixture.fixture, { force: true, recursive: true }); + } +}); + test("authority check rejects an early symlink in a large Git index", () => { const fixture = mkdtempSync(join(tmpdir(), "coffee-authority-check-")); try { @@ -102,6 +231,12 @@ test("trusted gate clears Node injection paths and resolves parser from base", ( /NPM_CONFIG_REGISTRY: https:\/\/registry\.npmjs\.org/u, ); assert.match(workflow, /NPM_CONFIG_REPLACE_REGISTRY_HOST: never/u); + assert.match(workflow, /trusted-npm-globalconfig/u); + assert.match(workflow, /trusted-npm-userconfig/u); + assert.doesNotMatch( + workflow, + /NPM_CONFIG_(?:GLOBAL|USER)CONFIG: \/dev\/null/u, + ); assert.match( workflow, /npm ci --ignore-scripts --prefix "\$GITHUB_WORKSPACE\/trusted-target\/\.github\/policy-parser"/u, @@ -135,3 +270,48 @@ test("trusted gate scans secrets, dependencies, and CodeQL without candidate cod assert.match(workflow, /needs: authorize/gu); assert.doesNotMatch(workflow, /pull_request_target/u); }); + +test("exact policy failures and protected changes cannot bypass sensitive review", () => { + assert.match(workflow, /id: exact-policy\n\s+continue-on-error: true/u); + assert.match(workflow, /id: classify/u); + assert.match(workflow, /EXACT_POLICY_OUTCOME: \$\{\{ steps\.exact-policy\.outcome \}\}/u); + assert.match(workflow, /environment: coffee-security/u); + assert.match(workflow, /needs\.authorize\.outputs\.sensitive == 'true'/u); + assert.match(workflow, /SENSITIVE_REVIEW_RESULT/u); + assert.match(workflow, /test "\$SENSITIVE_REVIEW_RESULT" = success/u); + assert.match(workflow, /test "\$SENSITIVE_REVIEW_RESULT" = skipped/u); +}); + +test("trusted quality runs after authorization with fixed npm authority", () => { + const classifier = workflow.indexOf("Classify policy evolution and protected path changes"); + const quality = workflow.indexOf("Trusted deterministic quality"); + assert.ok(classifier > 0); + assert.ok(quality > classifier); + assert.match(workflow, /needs\.sensitive-review\.result == 'success'/u); + assert.match(qualityRunner, /^set -euo pipefail$/mu); + assert.match(qualityRunner, /env -i/u); + assert.match(qualityRunner, /npm_userconfig="\$candidate_tmp\/npm-userconfig"/u); + assert.match(qualityRunner, /npm_globalconfig="\$candidate_tmp\/npm-globalconfig"/u); + assert.match(qualityRunner, /NPM_CONFIG_USERCONFIG=\$npm_userconfig/u); + assert.match(qualityRunner, /NPM_CONFIG_GLOBALCONFIG=\$npm_globalconfig/u); + assert.match(qualityRunner, /: > "\$npm_userconfig"/u); + assert.match(qualityRunner, /: > "\$npm_globalconfig"/u); + assert.match(qualityRunner, /NPM_CONFIG_REPLACE_REGISTRY_HOST=never/u); + assert.doesNotMatch(qualityRunner, /GITHUB_TOKEN|GH_TOKEN|ACTIONS_ID_TOKEN/u); +}); + +test("Eval Harbor calibration uses a fresh runner before any candidate program", () => { + assert.doesNotMatch(qualityRunner, /harbor-requirements|canary:calibrate|benchmark:calibrate/u); + assert.match(workflow, /eval-harbor:/u); + assert.match(workflow, /github\.repository == 'openboa-ai\/coffee-chat-eval'/u); + assert.match(workflow, /Trusted Eval Harbor calibration/u); + const harborJob = workflow.slice(workflow.indexOf(" eval-harbor:"), workflow.indexOf(" required:")); + assert.match(harborJob, /control\/\.github\/scripts\/run-eval-harbor\.sh/u); + assert.doesNotMatch(harborJob, /npm run|npm test|src\/cli\.ts|src\/pcda-cli\.ts/u); + assert.match(evalHarborRunner, /--require-hashes/u); + assert.match(evalHarborRunner, /HARBOR_COMMAND=/u); + assert.match(evalHarborRunner, /node --experimental-strip-types src\/canary-cli\.ts calibrate/u); + assert.match(evalHarborRunner, /node --experimental-strip-types src\/canary-cli\.ts benchmark-calibrate/u); + assert.doesNotMatch(evalHarborRunner, /npm run|npm test|src\/cli\.ts|src\/pcda-cli\.ts/u); + assert.match(workflow, /EVAL_HARBOR_RESULT/u); +});