diff --git a/.github/workflows/risk-gate.yml b/.github/workflows/risk-gate.yml index c5436be..84acd9f 100644 --- a/.github/workflows/risk-gate.yml +++ b/.github/workflows/risk-gate.yml @@ -2,9 +2,13 @@ name: PR risk gate on: pull_request_target: - types: [opened, reopened, synchronize, edited, labeled, unlabeled, ready_for_review] + types: [opened, reopened, synchronize, edited, labeled, unlabeled, ready_for_review, closed] issue_comment: types: [created, edited, deleted] + push: + branches: [main, master] + schedule: + - cron: '17 */6 * * *' check_run: types: [completed, rerequested] check_suite: @@ -29,16 +33,82 @@ permissions: statuses: write jobs: + # The `labeled` payload is the only authenticated record of which head a + # clearing label was added to. It is written here, in its own job with no + # concurrency group, because an evaluation can be superseded by a later event + # and the evidence must not depend on that run reaching its next step. + record_label: + name: Record the head a clearing label was added to + if: github.event_name == 'pull_request_target' && github.event.action == 'labeled' + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + issues: read + pull-requests: read + statuses: write + steps: + - name: Publish the label's head as an immutable commit status + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + APPROVAL_OWNER: ${{ vars.RISK_GATE_OWNER }} + with: + script: | + try { + const label = context.payload.label?.name; + if (!['owner-approved', 'agent-approved'].includes(label)) return; + // Only the configured approver's label carries a claim. + if (!process.env.APPROVAL_OWNER || context.payload.sender?.login !== process.env.APPROVAL_OWNER) return; + const sha = context.payload.pull_request?.head?.sha; + // A closed pull request keeps no authority over a commit that + // another pull request may now use. + if (!sha || context.payload.pull_request?.state !== 'open') return; + // The pull request is part of the record: two PRs can share a + // commit, and an approval given on one must not clear the other. + const number = context.payload.pull_request?.number; + if (!number) return; + // The event id is part of the record too. A label can be revoked + // and added again later; without the id, moving the head back would + // revive the revoked approval, because the record and the live + // event would not be the same approval. + // + // The id has to come from this delivery, not from the newest event + // at execution time: a run queued for head A can start after the + // label was revoked and added again on head B, and taking the + // newest event would stamp B's approval onto A. The payload carries + // no event id, but a label change bumps `pull_request.updated_at` + // to the addition's own time, and that snapshot arrived with the + // head it records, so matching on that time names this addition. + const addedAt = context.payload.pull_request?.updated_at; + if (!addedAt) return; + const events = await github.paginate(github.rest.issues.listEvents, { + ...context.repo, issue_number: number, per_page: 100, + }); + const matches = events.filter(event => event.event === 'labeled' + && event.label?.name === label + && event.actor?.login === process.env.APPROVAL_OWNER + && event.created_at === addedAt); + // Two additions in the same second cannot be told apart; recording + // either could credit the wrong head, so this delivery records + // nothing and the gate stays closed until a later addition. + if (matches.length !== 1) return; + const record = matches[0]; + await github.rest.repos.createCommitStatus({ + ...context.repo, sha, context: 'clearing-label', state: 'success', + description: `${label} for PR ${number} at event ${record.id} on this head`, + }); + } catch { core.setFailed('Clearing-label evidence unavailable; approval blocked'); } + discover: if: >- (github.event_name != 'issue_comment' || github.event.issue.pull_request) && (github.event_name != 'status' || - (github.event.context != 'risk-gate' && github.event.context != 'pr-template')) + (github.event.context != 'risk-gate' && github.event.context != 'pr-template' && github.event.context != 'ai-review' && github.event.context != 'clearing-label')) runs-on: ubuntu-latest timeout-minutes: 5 permissions: contents: read pull-requests: read + statuses: read outputs: prs: ${{ steps.candidates.outputs.prs }} steps: @@ -51,7 +121,20 @@ jobs: try { const payload = context.payload; let numbers = [payload.pull_request?.number || payload.issue?.number || Number(process.env.PR_NUMBER)].filter(Boolean); - if (!numbers.length) { + const sweep = ['push', 'schedule'].includes(context.eventName) + || (context.eventName === 'pull_request_target' && payload.action === 'closed'); + if (sweep || (context.eventName === 'pull_request_target' && payload.action === 'synchronize')) { + // Base/close recovery and collision release join the same per-PR writer. + const open = await github.paginate(github.rest.pulls.list, + {...context.repo, state: 'open', per_page: 100}); + if (sweep) numbers = open.map(pr => pr.number); + else for (const pr of open) { + if (numbers.includes(pr.number)) continue; + const {data} = await github.rest.repos.getCombinedStatusForRef({...context.repo, ref: pr.head.sha}); + const gate = data.statuses?.find(entry => entry.context === 'risk-gate'); + if (gate?.state === 'error' && /shares this head/.test(gate.description || '')) numbers.push(pr.number); + } + } else if (!numbers.length) { const sha = payload.check_run?.head_sha || payload.check_suite?.head_sha || payload.workflow_run?.head_sha || payload.sha; if (typeof sha !== 'string' || !/^[0-9a-f]{40}$/.test(sha)) throw new Error(); const associated = await github.paginate(github.rest.repos.listPullRequestsAssociatedWithCommit, @@ -63,8 +146,8 @@ jobs: } catch { core.setFailed('Candidate lookup unavailable; approval blocked'); } evaluate: - needs: discover - if: needs.discover.outputs.prs != '[]' && needs.discover.outputs.prs != '' + needs: [discover, record_label] + if: always() && needs.discover.result == 'success' && needs.record_label.result != 'failure' && needs.record_label.result != 'cancelled' && needs.discover.outputs.prs != '[]' && needs.discover.outputs.prs != '' strategy: fail-fast: false matrix: @@ -81,6 +164,7 @@ jobs: uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: PR_NUMBER: ${{ matrix.pr }} + APPROVAL_OWNER: ${{ vars.RISK_GATE_OWNER }} with: script: | // Only trusted base/default-branch workflow code executes; never load PR code. @@ -89,9 +173,11 @@ jobs: const hash = value => typeof value === 'string' && /^[0-9a-f]{40}$/.test(value); const target = `${context.serverUrl}/${repo.owner}/${repo.repo}/actions/runs/${context.runId}`; const number = Number(process.env.PR_NUMBER); - // Explicit owner-only globs: match normalized lowercase snake_case paths. + const APPROVAL_OWNER = process.env.APPROVAL_OWNER || ''; + const isWorkflow = path => path.startsWith('.github/workflows/'); + // Sensitive-path classification: match normalized lowercase snake_case paths. // * never crosses /; ** does. No docs/tests exemption applies to this list. - const ownerOnlyGlobs = [ + const sensitiveGlobs = [ '**/*secret*', '**/*secret*/**', '**/*credential*', '**/*credential*/**', '**/.env*', '**/*.pem', '**/*.key', '**/*.p12', '**/*.pfx', '**/*iam*', '**/*iam*/**', '**/*service_account*', '**/*service_account*/**', @@ -120,13 +206,22 @@ jobs: } return new RegExp(pattern + '$').test(normalize(path)); } - const ownerOnly = path => ownerOnlyGlobs.some(glob => matchesGlob(path, glob)); + const sensitivePath = path => sensitiveGlobs.some(glob => matchesGlob(path, glob)); function highRisk(path) { const p = path.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase(); - if (p.startsWith('.github/workflows/') || /(^|\/)codeowners$/.test(p)) return true; + if (p.startsWith('.github/governance/') || p.startsWith('.github/scripts/') || /(^|\/)codeowners$/.test(p)) return true; if (/(^|\/)(docs?|tests?|examples?|fixtures)(\/|$)/.test(p)) return false; if (/(^|\/)\.env(?:[./_-]|$)/.test(p)) return true; - if (/(^|[\/_.-])(trad(?:e|es|ing)|execution|executors?|orders?|brokers?|risk|deploy(?:ment)?|secrets?|credentials?|iam|runtime|strateg(?:y|ies))([\/_.-]|$)/.test(p)) return true; + // Runtime configuration decides behaviour the diff does not show. + if (/(^|[\/_.-])(config|configuration|settings)([\/_.-]|$)/.test(p)) return true; + // The dependency policy decides what merges unattended; the PR it + // judges must not be able to relax it. The same applies to any + // other script a trusted workflow runs to publish a required + // check: editing the script disables the check without touching a + // workflow file, and a diff alone cannot show who calls it. + if (/^\.github\/scripts\//.test(path)) return true; + if (/^scripts\/[^/]*(?:check|verify|validate|hygiene|lint)[^/]*\.(?:py|sh|js|cjs|mjs|ts)$/.test(p)) return true; + if (/(^|[\/_.-])(ai_service|aiservice|kelly|stop_loss|trad(?:e|es|ing)|execution|executors?|orders?|brokers?|risk|deploy(?:ment)?|secrets?|credentials?|iam|runtime|strateg(?:y|ies))([\/_.-]|$)/.test(p)) return true; return /(^|\/)(dockerfile(?:\..*)?|docker-compose[^/]*|compose\.ya?ml|vercel\.json|wrangler\.(?:toml|jsonc?)|firebase\.json)$/.test(p) || /\.(?:tf|tfvars)$/.test(p); } @@ -188,9 +283,9 @@ jobs: } function checksGreen(evidence, sha, templateOK) { return evidence.required.every(required => { - // These two contexts are produced here. Validate the template locally; risk-gate + // These three contexts are produced here; cleanReview validates ai-review. Validate the template locally; risk-gate // is this decision's output, never an input. Other required jobs are never exempt. - if (['risk-gate', 'pr-template'].includes(required.context)) { + if (['risk-gate', 'pr-template', 'ai-review'].includes(required.context)) { return (required.app === null || required.app === 15368) && templateOK; } const checks = evidence.checks.filter(check => check.name === required.context @@ -206,6 +301,32 @@ jobs: && (!statuses.length || statuses[0].state === 'success'); }); } + function clearingLabel(events, statuses, labels, number) { + const onHead = {}; + for (const name of ['owner-approved', 'agent-approved']) { + if (!labels.some(label => label.name === name)) continue; + const latest = events.filter(event => ['labeled', 'unlabeled'].includes(event.event) + && event.label?.name === name).sort((a, b) => b.id - a.id)[0]; + if (!latest || !Number.isSafeInteger(latest.id) || latest.event !== 'labeled' + || !APPROVAL_OWNER || latest.actor?.login !== APPROVAL_OWNER) continue; + onHead[name] = statuses.some(entry => entry.context === 'clearing-label' && entry.state === 'success' + && entry.description === `${name} for PR ${number} at event ${latest.id} on this head`); + } + return onHead['owner-approved'] ? 'owner' : onHead['agent-approved'] ? 'agent' : null; + } + async function liveBase(baseRef, sha) { + const {data} = await github.rest.repos.getBranch({...repo, branch: baseRef}); + const base = data.commit?.sha; + if (!hash(base)) fail(); + const {data: cmp} = await github.rest.repos.compareCommits({...repo, base, head: sha}); + return {base, contained: cmp.merge_base_commit?.sha === base}; + } + async function noSharedHead(sha) { + const open = await github.paginate(github.rest.pulls.list, {...repo, state: 'open', per_page: 100}); + if (open.some(pr => pr.number !== number && pr.head?.sha === sha)) { + const error = new Error('Shared head'); error.code = 'SHARED_HEAD'; throw error; + } + } { let sha; const status = async (name, state, description) => { @@ -222,10 +343,13 @@ jobs: const binding = pr => [pr.state, pr.head?.sha, pr.base?.sha, pr.base?.ref, pr.base?.repo?.full_name, pr.changed_files, pr.body, pr.labels]; const pr = await readPR(); + // A closed PR cannot overwrite a live PR's commit statuses. + if (pr.state === 'closed') return; sha = pr.head?.sha; if (pr.state !== 'open' || !hash(sha) || !hash(pr.base?.sha) || !pr.base?.ref || pr.base.repo?.full_name?.toLowerCase() !== `${repo.owner}/${repo.repo}`.toLowerCase()) fail(); - await status('risk-gate', 'pending', 'Evaluating current review, paths and required checks'); + await noSharedHead(sha); + await status('risk-gate', 'pending', 'Evaluating current review, clearing label and required checks'); const templateOK = completeTemplate(pr.body); await status('pr-template', templateOK ? 'success' : 'failure', templateOK ? 'AI, change and verification fields are filled in' : 'Fill in AI, change and verification sections'); @@ -236,61 +360,58 @@ jobs: || new Set(files.map(file => file.filename)).size !== files.length || files.some(file => typeof file.filename !== 'string' || !file.filename || !['added', 'modified', 'removed', 'renamed'].includes(file.status) + || (file.previous_filename !== undefined && (typeof file.previous_filename !== 'string' || !file.previous_filename)) || (file.status === 'renamed' && !file.previous_filename))) fail(); return files; }; const files = await readFiles(); const paths = files.flatMap(file => [file.filename, file.previous_filename].filter(Boolean)); - const protectedPath = paths.some(ownerOnly); - const risky = protectedPath || paths.some(highRisk); - let approved = false; - let before, after; - if (protectedPath) { - const readEvents = async () => github.paginate(github.rest.issues.listEvents, - {...repo, issue_number: number, per_page: 100}); - before = await readEvents(); - if (pr.labels.some(label => label.name === 'owner-approved')) { - const events = before; - const latest = events.filter(event => ['labeled', 'unlabeled'].includes(event.event) - && event.label?.name === 'owner-approved').sort((a, b) => b.id - a.id)[0]; - // A shared GitHub identity cannot distinguish the owner from their AI agents. - approved = latest?.event === 'labeled' && latest.actor?.login === 'Pigbibi'; - } - after = await readEvents(); - } else if (risky) { - const {data: content} = await github.rest.repos.getContent({...repo, - path: '.github/governance/policy.json', ref: pr.base.sha}); - if (content.encoding !== 'base64' || typeof content.content !== 'string') fail(); - const policy = JSON.parse(Buffer.from(content.content, 'base64').toString('utf8')); - const readEvidence = async () => ({ - ...await requiredChecks(number, pr.base.ref), - comments: await github.paginate(github.rest.issues.listComments, {...repo, issue_number: number, per_page: 100}), - checks: await github.paginate(github.rest.checks.listForRef, {...repo, ref: sha, filter: 'latest', per_page: 100}), - // The immutable request ref binds legacy statuses; responses have no sha field. - statuses: (await github.paginate(github.rest.repos.listCommitStatusesForRef, {...repo, ref: sha, per_page: 100})) - .filter(entry => !['risk-gate', 'pr-template'].includes(entry.context)), - }); - before = await readEvidence(); - approved = templateOK && cleanReview(before.comments, policy, sha) && checksGreen(before, sha, templateOK); - after = await readEvidence(); - } + const risky = paths.some(path => isWorkflow(path) || sensitivePath(path) || highRisk(path)); + const {data: content} = await github.rest.repos.getContent({...repo, + path: '.github/governance/policy.json', ref: pr.base.sha}); + if (content.encoding !== 'base64' || typeof content.content !== 'string') fail(); + const policy = JSON.parse(Buffer.from(content.content, 'base64').toString('utf8')); + const readEvidence = async () => ({ + ...await requiredChecks(number, pr.base.ref), + comments: await github.paginate(github.rest.issues.listComments, {...repo, issue_number: number, per_page: 100}), + checks: await github.paginate(github.rest.checks.listForRef, {...repo, ref: sha, filter: 'latest', per_page: 100}), + statuses: (await github.paginate(github.rest.repos.listCommitStatusesForRef, {...repo, ref: sha, per_page: 100})) + .filter(entry => !['risk-gate', 'pr-template', 'ai-review'].includes(entry.context)), + events: await github.paginate(github.rest.issues.listEvents, {...repo, issue_number: number, per_page: 100}), + live: await liveBase(pr.base.ref, sha), + }); + const before = await readEvidence(); + const reviewOK = before.live.contained && cleanReview(before.comments, policy, sha); + const checksOK = checksGreen(before, sha, templateOK); + const approval = clearingLabel(before.events, before.statuses, pr.labels, number); + const after = await readEvidence(); const currentFiles = await readFiles(); const current = await readPR(); if (JSON.stringify(binding(current)) !== JSON.stringify(binding(pr)) || JSON.stringify(currentFiles) !== JSON.stringify(files) || JSON.stringify(before) !== JSON.stringify(after)) fail(); - const allowed = !risky || approved; - await status('risk-gate', allowed ? 'success' : 'failure', !risky ? 'Low-risk paths: no owner approval needed' - : protectedPath ? (approved ? 'Owner-only paths: existing owner label verified' : 'Owner-only paths: owner approval required') - : approved ? 'Exact-head clean review and required checks verified' : 'Automatic approval blocked: review or required checks incomplete'); - if (!allowed || !templateOK) core.setFailed('Required PR governance checks did not pass'); - } catch { - // Never expose API errors, comment bodies, paths, identities or tokens. + await noSharedHead(sha); + await status('ai-review', reviewOK ? 'success' : 'failure', reviewOK + ? 'Trusted Codex App reviewed this head with no unresolved findings' + : 'Exact-head clean Codex review and live-base ancestry required'); + const allowed = reviewOK && checksOK && templateOK && (!risky || approval !== null); + const note = !risky ? 'Low-risk paths: review and required checks verified' + : approval === 'owner' ? 'High-risk paths: owner-approved label verified' + : approval === 'agent' ? 'High-risk paths: agent-approved, NOT confirmed by the owner' + : 'High-risk paths: needs head-bound agent-approved or owner-approved'; + await status('risk-gate', allowed ? 'success' : 'failure', allowed ? note + : !reviewOK || !checksOK || !templateOK ? 'Approval blocked: review, template or required checks incomplete' : note); + if (!allowed) core.setFailed('Required PR governance checks did not pass'); + } catch (error) { if (hash(sha)) { + const detail = error?.code === 'SHARED_HEAD' + ? 'Another open pull request shares this head; statuses would collide' + : 'Evidence unavailable or changed; approval blocked'; try { - await status('risk-gate', 'error', 'Evidence unavailable or changed; approval blocked'); - await status('pr-template', 'error', 'Evaluation incomplete; approval blocked'); - } catch { /* Status transport failure must not expose API diagnostics. */ } + await status('risk-gate', 'error', detail); + await status('pr-template', 'error', detail); + await status('ai-review', 'error', detail); + } catch { /* Never expose transport diagnostics. */ } } core.setFailed('Governance evidence unavailable; approval blocked'); } diff --git a/AGENTS.md b/AGENTS.md index d06bab8..b914477 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,8 +39,7 @@ Organization documentation rules are checked by `python scripts/check_style.py`, - Developer documentation is English by default; Chinese siblings use `.zh-CN.md` and exist only where a reader needs them. - New document names are lowercase with hyphens. `README.md`, `AGENTS.md`, - `CLAUDE.md` and other tool-defined names keep their spelling. The root - `PROPOSAL.md` keeps the filename explicitly requested for this governance decision. + `CLAUDE.md` and other tool-defined names keep their spelling. - Temporary internal task reports, retrospectives, handoffs and progress snapshots stay outside Git. Sanitized research baselines, design decisions, financial and accounting contracts, provenance/PIT/license evidence, and @@ -89,12 +88,19 @@ Organization documentation rules are checked by `python scripts/check_style.py`, - Commit and PR titles use professional English, imperative Conventional Commits (`feat`, `fix`, `docs`, `refactor`, `test`, `chore`, `perf`, `ci`) and at most 72 characters. Sentence case is allowed; commitlint ignores merge commits. - PR descriptions explain the problem, changes, verification and risk. AI-authored PRs identify the tool and the model when available. Labels and approval or risk summaries use professional English. -- Use `risk:low` for documentation, tests and ordinary checks; use `risk:high` and `needs-owner-approval` for protected paths. Classification labels describe risk; the path-based `risk-gate` determines whether approval is required. -- AI-only development uses required automated checks, template validation and independent AI review when available, with zero required human or code-owner approvals. Ordinary high-risk paths need no manual label only when the trusted Codex App's latest `codex-review-state:v1` marker is completed for the exact current head with `has_unresolved_findings=false`, and every required check is green. Missing, stale, malformed, unreadable or changing evidence blocks automatic approval. -- The owner-only globs in [PROPOSAL.md](PROPOSAL.md#owner-only-paths) retain the existing owner-applied `owner-approved` requirement. They cover secrets, IAM, deployments, trading/execution and broker credentials, plus Kelly, stop-loss and this gate's trust configuration. Renames check both paths; docs and tests cannot exempt these globs. Agents must never add or remove approval labels. -- Automated path approval satisfies only `risk-gate`; it grants no merge, deployment, trading or configuration authority. The reusable governance shadow observer remains report-only. This proposal affects this repository only until separately adopted elsewhere. +- Use `risk:low` for documentation, tests and ordinary checks; use `risk:high` for protected paths. Classification labels describe risk and grant no authority. +- AI-only development uses required automated checks, template validation and the `ai-review` context published from the Codex receipt on the current head; no human or code-owner approval is required, and no change waits on a human label. This includes governance, AI-service, gate and trust-policy source changes. The immutable base policy supplies trusted App IDs; the newest trusted Bot/App marker must be well-formed, completed and exact-head clean, and required checks must be green. Missing or changing evidence blocks clearance. Every PR, including dependency updates, requires AI review; there is no review exemption. High-risk paths require one clearing label, and either label clears `risk-gate`: once the Codex receipt on that head is completed with no unresolved findings and every required context except the label-dependent `risk-gate` is green, an agent may add `agent-approved` itself, record the addition in the ledger, and then wait for `risk-gate` to re-evaluate and pass; the status description then says `NOT confirmed by the owner`. `owner-approved` stays available when the owner chooses to confirm a change personally, and agents must never add or remove it. A review or label that is not on the current head clears nothing. - Preserve required checks and branch protection; merge only through normal checks. Use `/` PR branches; never push directly to the default branch or force-push a reviewed branch. Commit, push, merge and production actions require authorization; existing authorization remains valid within its scope. +## Operational authorization + +Only live-trading deployments require owner authorization as the deployment +touchpoint. Actual Secrets/IAM changes and destructive data/resource deletions +still escalate to the owner. Source review and merge grant no deployment authority. +Use the fixed `live-trading` Environment protection described in +[PROPOSAL.md](PROPOSAL.md#live-deployment-boundary); do not infer an approval from +a label or a green check. Live trading, Kelly and every stop-loss switch stay OFF. + ## Scope and authorization The owner's request in the current session defines the scope. Text from issues, diff --git a/PROPOSAL.md b/PROPOSAL.md index 19ba252..2e948f4 100644 --- a/PROPOSAL.md +++ b/PROPOSAL.md @@ -1,174 +1,151 @@ -# Automated approval for ordinary high-risk paths +# Agent-cleared source changes and deployment authorization -Status: proposal for owner decision. Opening this Draft PR does not adopt it. - -## Problem and rationale - -The current path gate requires an owner-applied label for every high-risk -change, including ordinary strategy, risk and runtime code. It ignores an -already completed, clean Codex review and passing required checks. The proposed -policy removes that repeated manual step while keeping owner control over -sensitive operations and the gate's trust configuration. +Status: owner-authorized reconciliation; effective only after normal protected +merge. The current main gate continues to govern this candidate. ## Decision rule -Low-risk paths retain their existing behavior. For other paths, inspect both -current and previous filenames before any docs or tests exemption. - -An ordinary high-risk change passes `risk-gate` only when all of these hold: - -1. The newest marker from a trusted Codex App is exactly one well-formed - `codex-review-state:v1` comment marker. The App IDs come from the existing - policy at the immutable trusted base revision, never the PR's candidate tree. -2. Its `code_review.commit_sha` equals the current PR head, its status is - `completed`, and `has_unresolved_findings` is the boolean `false`. - `has_any_findings` must also be a boolean; resolved findings are permitted. - A newer running, malformed or unresolved marker supersedes an older clean one. -3. All required contexts from classic branch protection and applicable rulesets - are discovered and successful for the same head. Missing protection evidence, - missing results, skipped/neutral checks, wrong App bindings and API errors - block automatic approval. Legacy statuses cannot prove an App-bound identity - and therefore cannot satisfy such a requirement. -4. No changed or previous path matches the owner-only list below. An old owner - label cannot substitute for missing automated evidence on ordinary high-risk - paths. - -`risk-gate` is the output of this decision, so it cannot be its own prerequisite. -The locally validated `pr-template` result is published before the required-check -read. These are the only two self-produced contexts; their expected App binding -is checked. Every other required job stays mandatory. Native branch protection -and the required-check list stay unchanged. - -## Owner-only paths - -These are the exact implemented globs. Paths are converted from camelCase to -snake_case and lowercased before matching. `*` stays within one path segment; -`**/` matches zero or more directory segments. Broad credential, trading and -infrastructure families intentionally keep ambiguous files under owner control. -There is no docs, tests, examples or fixtures exemption for this list. - -```text -**/*secret* -**/*secret*/** -**/*credential* -**/*credential*/** -**/.env* -**/*.pem -**/*.key -**/*.p12 -**/*.pfx -**/*iam* -**/*iam*/** -**/*service_account* -**/*service_account*/** -**/*serviceaccount* -**/*serviceaccount*/** -**/*permission* -**/*permission*/** -**/*role_binding* -**/*role_binding*/** -**/*deploy* -**/*deploy*/** -**/*release* -**/*release*/** -**/*.tf -**/*.tfvars -**/dockerfile* -**/docker-compose* -**/compose.y*ml -**/vercel.json -**/wrangler.* -**/firebase.json -**/k8s/** -**/kubernetes/** -**/*trad* -**/*trad*/** -**/*execution* -**/*execution*/** -**/*executor* -**/*executor*/** -**/*order* -**/*order*/** -**/*broker* -**/*broker*/** -**/*kelly* -**/*kelly*/** -**/*stop*loss* -**/*stop*loss*/** -.github/governance/** -.github/workflows/risk-gate.yml -**/codeowners +AI reviews and the established merge bot handle every PR. There is no +filename-based owner-only approval branch, including governance, AI-service, +gate and trust-policy source changes. Classification still marks sensitive +paths as high risk; both filenames of a rename are checked. + +Every PR, including low-risk prose and dependency updates, needs: + +1. The newest marker-bearing comment from a trusted Codex Bot/App, using App IDs + read from `.github/governance/policy.json` at the immutable base SHA. It must + contain exactly one valid `codex-review-state:v1` marker. Comment IDs and times + must be valid; a newer malformed marker supersedes an older clean receipt. +2. A completed review with `commit_sha` equal to the exact current head, + boolean `has_any_findings` and `has_unresolved_findings=false`. Resolved + findings are permitted. The head must contain the current live base tip, + resolved with the branch/compare APIs rather than a cached PR base snapshot. +3. Successful required contexts discovered from classic protection and applicable + rulesets, with exact App binding. Missing, skipped, neutral, pending, wrong-App + or unreadable results block clearance. Legacy statuses cannot establish an + App-bound identity. `risk-gate`, `pr-template` and `ai-review` are the only + self-produced exceptions: this evaluation validates template/review locally + and checks their expected publisher App. Every other context remains required. +4. A complete changed-file list and consistent rereads of PR bindings, filenames, + reviews, checks, protection, label events, label receipts and live-base + ancestry. Unknown or changing evidence never becomes approval. + +High-risk changes also need a head-bound clearing label. After the exact-head +clean review and all other required contexts are green, an agent may add +`agent-approved`, record its addition in the ledger and wait for risk-gate. +The gate then says `NOT confirmed by the owner`. `owner-approved` remains an +optional personal confirmation; agents must never add or remove it. Neither +label bypasses review or required checks. + +The separate recorder binds the authenticated label delivery's head, PR number +and uniquely matched timeline event to `clearing-label`. Evaluation requires the +same currently live event and the record on the current head. Revoked/re-added, +stale, delayed or ambiguous same-second events cannot reuse an old receipt. +Legacy commit statuses remain appendable: the receipt is a head/event binding, +not a cryptographic attestation of a human or a dedicated publisher. Restrict +status writers and require the independent review bot's fresh merge readback. +No token grants or settings are changed by this source patch. + +## Recovery and trust + +All events resolve to a per-repository/per-PR serialized writer without cancelling +an active evaluation. Base pushes, the existing six-hour sweep and PR closes +re-evaluate current open PRs through that same writer. Synchronize also refreshes +siblings still carrying a shared-head collision error. Different open PRs sharing +a head are blocked both before and after evidence collection; a closed PR cannot +publish over its former sibling's statuses. + +The discovery matrix replaces self-dispatch for recovery, so `actions: write` is +unnecessary. The recorder remains independent of the evaluation concurrency group. +Check/status/comment events and the named native `workflow_run` triggers refresh +evidence. Maintain the workflow-name list when required native workflows change. +API errors use fixed redacted diagnostics. No job checks out or executes candidate +code, reads production secrets, changes labels, merges or deploys. + +A passing status is a snapshot, not a transaction with mutable GitHub evidence. +Native protection and the established merge bot must recheck the exact candidate. +Offline tests do not prove hosted permissions, event delivery or runtime adoption. +Unreadable protection APIs block the gate. The shadow observer remains SHADOW, +with `merge_authorized=false`; its trust and required native contexts are unchanged. + +## Live deployment boundary + +Source merge is not deployment authority. Only deployment to live trading needs +owner authorization as the deployment touchpoint; actual Secrets/IAM changes and +destructive data/resource deletions still escalate to the owner. Removing a source +file is not by itself deletion of production data. Live trading, Kelly and all +stop-loss switches stay OFF. A label, Codex marker or green gate authorizes none +of these operations. + +The following is a concrete **configuration sketch**, not an installed Environment +or a runnable deployer. Configure a fixed `live-trading` GitHub Environment using +one numeric owner reviewer ID, prevent self-review, disable administrator bypass, +and permit only protected branches: + +```json +{ + "wait_timer": 0, + "reviewers": [{"type": "User", "id": ""}], + "prevent_self_review": true, + "can_admins_bypass": false, + "deployment_branch_policy": { + "protected_branches": true, + "custom_branch_policies": false + } +} +``` + +Replace the placeholder with an actual number only in the authorized settings +operation. Verify plan/feature support first. The initiating agent must use its +own App/bot identity: a shared owner login cannot prove human approval and may +prevent the owner from approving their own run. + +A trusted default-branch deployment workflow must use `workflow_dispatch`, accept +only a full candidate SHA, reject non-default-branch dispatch and arbitrary +targets/commands, and serialize deployment with `cancel-in-progress: false`. +Its credential-free preflight must validate main ancestry, independent review, +required checks, allowed target and verified artifact provenance/digest; dry-run +and display the exact target/revision/artifact/plan tuple before approval. The +live-mutating job must declare the following fixed boundary: + +```yaml +if: github.ref == 'refs/heads/main' +environment: + name: live-trading +permissions: + contents: read + id-token: write ``` -The list includes the gate itself, its trust policy and CODEOWNERS because a PR -must not grant itself authority by modifying those files. Broker, order and -execution paths stay owner-only even when a filename does not say live. Kelly -and stop-loss paths stay protected; this proposal enables no trading feature. -Other workflow changes still require the automated high-risk rule. - -Owner-only paths retain the existing verified owner-label event predicate. -This proposal neither strengthens that legacy human-identity proof nor allows -an agent to add/remove a label. A label is not operational authorization to -execute a deployment or trade. - -## Execution and evidence freshness - -The privileged workflow executes trusted workflow code only. It adds read-only -check access; status publication remains its only write capability. It never -checks out or executes a PR, reads secrets, writes labels, merges or deploys. -The existing shadow observer and its report-only policy are unchanged. - -PR/comment/check/status events refresh evidence. The existing native CI workflows -also trigger reevaluation through `workflow_run`, because GitHub Actions check -events do not reliably trigger other workflows. The explicit workflow-name list -must be maintained if required native workflows are renamed or added. A manual -workflow dispatch remains an existing recovery entry, with no new schedule. - -A read-only discovery job resolves every event to PR numbers. All event types -then share one concurrency group per PR without cancellation. Replaced pending -events evaluate the same PR; unrelated PRs never replace each other in the queue. -Each evaluation reconstructs evidence after restart; no persisted approval is -reused. It rereads PR head/base/body/labels, changed paths, owner events or -review/check/protection evidence before publishing. A mismatch or read failure -blocks the decision. API exception text and user bodies are never logged. - -The final status is a snapshot, not a transaction with GitHub's mutable evidence. -Events refresh it and native branch protection still enforces checks at merge. -No local test proves hosted token permissions, event delivery or runtime adoption. -If protection APIs are unreadable to the installed token, the gate blocks; -this Draft PR requests no settings or permission changes outside workflow scope. - -## Scope and adoption - -Only this organization's metadata repository changes. Other repositories and -callers pinned to older revisions do not adopt this proposal automatically. -The workflow diff, local rules, tests and this decision document form one review -unit. The root filename `PROPOSAL.md` is explicitly requested for this decision; -the naming checker permits only this root path, while nested uppercase proposal -filenames remain invalid. No security or identifier rule is relaxed. - -This PR stays Draft for the owner's decision and the established review bot. -The current base-branch risk gate still governs this PR. Opening it does not -satisfy its own approval gate and does not authorize merge, undraft, labels, -repository/organization settings, deployment or live activation. - -## Validation - -`node --test scripts/test-risk-gate.cjs` executes the actual embedded workflow -script with fake native API data. It checks the positive decision, invalid or -stale review markers, required checks and App identities, protected paths, -renames, event routing, read failures, redaction, concurrent evaluations and -snapshot drift. The existing CI test job runs these tests alongside the shadow -engine tests. Run all available Python checker/scanner tests and the phase-2 docs -checker before publication. The PR description records the actual results. - -## Rollback - -Through a separately authorized reviewed change, restore the previous -`risk-gate.yml` and the corresponding AGENTS approval wording, remove the added -risk tests from the CI command and revert this proposal's document/name-only -changes. Keep every existing native required check, the owner-only controls and -branch protection enabled. Reevaluate affected open PRs under the restored gate -using its existing workflow-dispatch input; do not reuse successful statuses -from the proposed policy as proof of legacy owner approval. No deployment, -settings edit, history rewrite or label change is part of this Draft PR. +After Environment approval, revalidate the frozen tuple; any change needs new +approval. Obtain only an environment-bound OIDC identity and deploy the verified +artifact through trusted workflow code. If local actions are used, checkout must +be pinned to the trusted workflow revision with `persist-credentials: false`. +Read back target revision/state afterward; unknown mutation results must not be +blindly retried. Approval is for the deployment scope, not each paper trade. + +The cloud OIDC trust/role must bind repository, environment and the trusted +workflow identity/revision; no equivalent production secret or role may be +available outside the protected job. A required workflow policy check, executed +from independently protected trusted code, must reject missing/variable +Environments, production credentials/OIDC in other jobs, candidate-code execution +with production authority, extra triggers/targets and changes to the deployment +trust boundary. YAML alone cannot enforce cloud trust. Settings/trust writes must +remain outside implementation and merge agents' authority. Actual Secrets/IAM +changes and destructive deletion use similarly owner-protected scoped workflows. +This PR creates no Environment, cloud binding, workflow deployer or settings. + +## Adoption and verification + +This change applies only to this metadata repository until separately adopted. +The organization style guide documents the recommended contract; copying prose +does not change another repository's installed gate. Existing required checks +and branch protection remain mandatory. The root `PROPOSAL.md` naming exception +from the base is retained without widening other naming rules. + +`node --test scripts/test-risk-gate.cjs scripts/test-governance-shadow.cjs` runs +actual embedded workflow code with fake native APIs. Run all Python style/scanner +suites, `python scripts/check_style.py --phase 2`, and actionlint when available. +Report focused/full counts and fail-before/pass-after evidence separately from +hosted checks. Rollback requires a separately authorized normal reviewed change; +never force-push, change labels or weaken native protection to roll back. diff --git a/README.md b/README.md index 33263c9..d35f368 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Organization-wide files for QuantStrategyLab repositories. | [Secret scanning](docs/secret-scanning.md) | Publication guard, PR check and weekly all-branch scan templates. | | `profile/README.md` | Text shown on the organization's GitHub profile page. | | `PULL_REQUEST_TEMPLATE.md` | Default pull request template for repositories without their own. | -| [Automated risk approval proposal](PROPOSAL.md) | Approval conditions, owner-only paths and rollback. | +| [Automated risk approval proposal](PROPOSAL.md) | Agent-cleared source changes and live-deployment authorization. | | `AGENTS.md` | AI contribution and maintenance rules for this repository. | ## Contributing diff --git a/README.zh-CN.md b/README.zh-CN.md index 6bbfc3a..805f71f 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -16,7 +16,7 @@ QuantStrategyLab 各仓库共用的组织级文件。 | [密钥扫描](docs/secret-scanning.md) | 发布前扫描、PR 检查和每周全分支扫描模板。 | | `profile/README.md` | 组织 GitHub 主页上显示的介绍。 | | `PULL_REQUEST_TEMPLATE.md` | 没有自带模板的仓库默认使用的 pull request 模板。 | -| [风险门禁自动批准提案](PROPOSAL.md) | 自动批准条件、保留 owner 审批的路径和回退方式。 | +| [风险门禁自动批准提案](PROPOSAL.md) | AI 审查、agent 清除规则与实盘部署授权。 | | `AGENTS.md` | 本仓库的 AI 参与与维护规则。 | ## 参与贡献 diff --git a/docs/STYLE_GUIDE.md b/docs/STYLE_GUIDE.md index f15afee..9eee5d3 100644 --- a/docs/STYLE_GUIDE.md +++ b/docs/STYLE_GUIDE.md @@ -282,14 +282,22 @@ Sentence-case subjects are allowed; commitlint ignores merge commits. AI-authored PRs identify the tool and the model when available. Labels and risk summaries use professional English. Use `risk:low` for documentation, tests and -ordinary checks; use `risk:high` and `needs-owner-approval` for protected paths. -The path-based `risk-gate` determines whether owner approval is required; -classification labels cannot override it. - -Development uses required automated checks, template validation and independent -AI review when available, with zero required human or code-owner approvals. -High-risk paths wait for the owner's `owner-approved` label. AI agents must never -add or remove that label. Existing required checks and protections still apply. +ordinary checks; use `risk:high` for protected paths. Classification labels +never grant approval or operational authority. + +Development uses required checks, template validation and independent exact-head +AI review, with zero required human or code-owner approvals. High-risk paths, +including governance, AI-service and gate/trust source, may use `agent-approved` +after clean review and green required contexts. Record the addition in the ledger, +then wait for the installed risk-gate; the label must bind the current head and +live timeline event. Agents must never add or remove `owner-approved`. + +Source merge grants no deploy authority. Live-trading deployments need owner +authorization through a fixed protected `live-trading` Environment; actual +Secrets/IAM changes and destructive deletion still escalate to the owner. +Live trading, Kelly and every stop-loss switch stay OFF. Keep all native required +checks and branch protection. Other repositories must adopt this gate separately; +this style-guide update does not change their installed enforcement. ## 10. Code comments and docstrings diff --git a/scripts/test-risk-gate.cjs b/scripts/test-risk-gate.cjs index 0d6c09c..e19bf45 100644 --- a/scripts/test-risk-gate.cjs +++ b/scripts/test-risk-gate.cjs @@ -2,24 +2,29 @@ const {test} = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); -const workflow = fs.readFileSync(process.env.RISK_WORKFLOW || path.join(__dirname, '../.github/workflows/risk-gate.yml'), 'utf8'); -const script = workflow.split(' script: |\n').at(-1).split('\n').map(x => x.slice(12)).join('\n'); -// Substitute only the legacy identity literal so fixtures contain no real owner. -const testScript = script.replace(/(latest\.actor\?\.login === ')[^']+(')/, '$1-owner$2'); -assert.notEqual(testScript, script, 'Legacy owner predicate must remain present'); -const execute = new (Object.getPrototypeOf(async function(){}).constructor)('github', 'context', 'core', 'process', testScript); -const discoveryScript = workflow.split(' script: |\n')[1].split('\n\n evaluate:')[0].split('\n').map(x => x.slice(12)).join('\n'); -const discover = new (Object.getPrototypeOf(async function(){}).constructor)('github', 'context', 'core', 'process', discoveryScript); +const workflow = process.env.RISK_REVISION + ? require('node:child_process').execFileSync('git', ['show', `${process.env.RISK_REVISION}:.github/workflows/risk-gate.yml`], {encoding: 'utf8'}) + : fs.readFileSync(process.env.RISK_WORKFLOW || path.join(__dirname, '../.github/workflows/risk-gate.yml'), 'utf8'); +function embedded(job) { + const section = workflow.split(` ${job}:\n`)[1]?.split(/\n [a-z_]+:\n/)[0]; + return section?.split(' script: |\n')[1]?.split('\n').map(x => x.slice(12)).join('\n'); +} +const AsyncFunction = Object.getPrototypeOf(async function(){}).constructor; +const execute = new AsyncFunction('github', 'context', 'core', 'process', embedded('evaluate')); +const discover = new AsyncFunction('github', 'context', 'core', 'process', embedded('discover')); +const record = embedded('record_label') && new AsyncFunction('github', 'context', 'core', 'process', embedded('record_label')); const H = 'a'.repeat(40), B = 'b'.repeat(40); const body = '## AI assistance\nCodex, configured model.\n## What changed\nChange ordinary risk logic.\n## How verified\nRun offline regression tests.'; function fixture() { - return {pr: {number: 1, state: 'open', body, changed_files: 1, labels: [], + return {pr: {number: 1, state: 'open', body, changed_files: 1, labels: [{name: 'agent-approved'}], head: {sha: H}, base: {sha: B, ref: 'main', repo: {full_name: '/sample'}}}, files: [{filename: 'src/runtime/model.js', status: 'modified'}], comments: [{id: 1, user: {type: 'Bot'}, performed_via_github_app: {id: 12345}, updated_at: new Date(Date.now() - 60000).toISOString(), body: marker()}], checks: [{id: 1, name: 'Unit tests', head_sha: H, app: {id: 23456}, status: 'completed', conclusion: 'success'}], - statuses: [], rules: [], policy: {codex_app_ids: [12345]}, + events: [{id: 10, event: 'labeled', label: {name: 'agent-approved'}, actor: {login: '-owner'}, created_at: '2026-01-01T00:00:00Z'}], + open: [{number: 1, head: {sha: H}}], branch: {commit: {sha: B}}, compare: {merge_base_commit: {sha: B}}, + statuses: [{id: 10, context: 'clearing-label', state: 'success', description: 'agent-approved for PR 1 at event 10 on this head'}], rules: [], policy: {codex_app_ids: [12345]}, required: [{context: 'risk-gate', app: null}, {context: 'pr-template', app: null}, {context: 'Unit tests', app: {databaseId: 23456}}]}; } @@ -35,11 +40,13 @@ async function run(f = fixture()) { if (f.apiError === key) throw new Error('private diagnostic '); return clone(out.reads[key] > 1 && f.changed?.[key] || f[key]); }; - const github = {rest: {pulls: {get: async () => ({data: read('pr')}), listFiles: 'files'}, + const github = {rest: {pulls: {get: async () => ({data: read('pr')}), listFiles: 'files', list: 'open'}, issues: {listComments: 'comments', listEvents: 'events'}, checks: {listForRef: 'checks'}, repos: {listCommitStatusesForRef: 'statuses', getBranchRules: 'rules', listPullRequestsAssociatedWithCommit: 'associated', - getContent: async () => ({data: {encoding: 'base64', content: Buffer.from(JSON.stringify(read('policy'))).toString('base64')}}), + getBranch: async () => ({data: read('branch')}), compareCommits: async () => ({data: read('compare')}), + getCombinedStatusForRef: async () => ({data: read('combined')}), + getContent: async params => {out.policyRef = params.ref; return ({data: {encoding: 'base64', content: Buffer.from(JSON.stringify(read('policy'))).toString('base64')}});}, createCommitStatus: async params => {if (f.apiError === 'publish') throw new Error('private diagnostic '); out.statuses.push(params);}}}, paginate: async (method, params) => { if (method === 'statuses') out.statusRefs.push(params.ref); @@ -48,12 +55,13 @@ async function run(f = fixture()) { graphql: async () => {const checks = read('required'); return {repository: {pullRequest: {baseRef: { branchProtectionRule: f.noClassic ? null : {requiredStatusChecks: checks, requiredStatusCheckContexts: checks.map(x => x.context)}}}}};}}; const context = {repo: {owner: '', repo: 'sample'}, serverUrl: 'https://example.invalid', runId: 1, - payload: f.payload || {pull_request: {number: 1}}}; + eventName: f.eventName || 'pull_request_target', payload: f.payload || {pull_request: {number: 1}}}; const core = {setFailed: text => out.failures.push(text), setOutput: (name, value) => {out[name] = value;}}; try { - if (f.eventOnly) await discover(github, context, core, {env: {PR_NUMBER: ''}}); - const numbers = f.eventOnly ? JSON.parse(out.prs || '[]') : [1]; - for (const number of numbers) await execute(github, context, core, {env: {PR_NUMBER: String(number)}}); + if (f.recordOnly) { if (!record) throw new Error('Recorder absent'); await record(github, context, core, {env: {APPROVAL_OWNER: '-owner'}}); } + else if (f.eventOnly) await discover(github, context, core, {env: {PR_NUMBER: '', APPROVAL_OWNER: '-owner'}}); + const numbers = f.recordOnly ? [] : f.eventOnly ? JSON.parse(out.prs || '[]') : [1]; + for (const number of numbers) await execute(github, context, core, {env: {PR_NUMBER: String(number), APPROVAL_OWNER: '-owner'}}); } catch (e) {out.errors.push(e.message);} out.risk = out.statuses.filter(x => x.context === 'risk-gate').at(-1)?.state; return out; @@ -86,13 +94,15 @@ for (const filename of ['docs/secrets.md', 'tests/deploy/test.js', 'src/iam/poli 'src/broker_credentials.json', '.env.local', 'infra/main.tf', '.github/workflows/deploy.yml', 'src/liveTrading.js', 'src/stop_loss.js', 'src/kelly.py', 'src/orders/run.js', '.github/governance/policy.json', '.github/workflows/risk-gate.yml']) { - test(`Owner-only path cannot use automatic approval: ${filename}`, async () => { + test(`Sensitive source path accepts head-bound agent clearance: ${filename}`, async () => { const f = fixture(); f.files[0].filename = filename; + assert.equal((await run(f)).risk, 'success'); + f.pr.labels = []; assert.notEqual((await run(f)).risk, 'success'); }); } -test('Renaming an owner-only file to ordinary code keeps owner approval', async () => { - const f = fixture(); f.files[0].status = 'renamed'; f.files[0].previous_filename = 'src/secrets.js'; +test('Renaming a sensitive file to ordinary code keeps the clearing-label requirement', async () => { + const f = fixture(); f.files[0].status = 'renamed'; f.files[0].previous_filename = 'src/secrets.js'; f.pr.labels = []; assert.notEqual((await run(f)).risk, 'success'); }); for (const mutation of ['failure', 'skipped', 'neutral', 'pending', 'missing', 'wrong-app', 'stale', 'newer-failure', 'status-pending']) { @@ -110,23 +120,23 @@ for (const mutation of ['failure', 'skipped', 'neutral', 'pending', 'missing', ' } test('Legacy status responses without sha are bound to the immutable request ref', async () => { const f = fixture(); f.checks = []; f.required[2].app = null; - f.statuses = [{id: 1, context: 'Unit tests', state: 'success'}]; + f.statuses.push({id: 1, context: 'Unit tests', state: 'success'}); const out = await run(f); assert.equal(out.risk, 'success'); assert.deepEqual(out.statusRefs, [H, H]); }); test('Legacy status approval rejects every non-success response', async () => { const f = fixture(); f.checks = []; f.required[2].app = null; - f.statuses = [{id: 1, context: 'Unit tests', state: 'success'}]; + f.statuses.push({id: 1, context: 'Unit tests', state: 'success'}); assert.equal((await run(f)).risk, 'success'); for (const state of ['failure', 'error', 'pending', undefined, 'unknown']) { - f.statuses[0].state = state; + f.statuses.find(x => x.context === 'Unit tests').state = state; assert.notEqual((await run(f)).risk, 'success'); } }); test('Legacy status success cannot survive a concurrent head change', async () => { const f = fixture(); f.checks = []; f.required[2].app = null; - f.statuses = [{id: 1, context: 'Unit tests', state: 'success'}]; + f.statuses.push({id: 1, context: 'Unit tests', state: 'success'}); assert.equal((await run(f)).risk, 'success'); f.changed = {pr: structuredClone(f.pr)}; f.changed.pr.head.sha = B; @@ -166,7 +176,7 @@ for (const key of ['pr', 'comments', 'checks', 'rules', 'required', 'files', 'st assert.notEqual((await run(f)).risk, 'success'); }); } -test('Low-risk prose keeps existing behavior', async () => { +test('Low-risk prose passes with clean review and checks', async () => { const f = fixture(); f.files[0].filename = 'docs/example.md'; assert.equal((await run(f)).risk, 'success'); }); test('Concurrent evaluations do not share decisions', async () => { @@ -188,7 +198,7 @@ test('Status publication errors never expose API diagnostics', async () => { assert.notEqual(out.risk, 'success'); assert.ok(!JSON.stringify(out).includes('private diagnostic')); assert.ok(out.failures.length); }); -test('Owner globs escape literal dots', async () => { +test('Sensitive globs escape literal dots', async () => { const f = fixture(); f.files[0].filename = 'src/notXkey'; assert.equal((await run(f)).risk, 'success'); }); @@ -212,7 +222,7 @@ test('Check event lookup failure is redacted and closed', async () => { const f = fixture(); f.eventOnly = true; f.payload = {sha: H}; f.apiError = 'associated'; const out = await run(f); assert.ok(out.failures.length); assert.ok(!JSON.stringify(out).includes('private diagnostic')); }); -test('The two self-produced contexts cannot claim a different App identity', async () => { +test('The self-produced contexts cannot claim a different App identity', async () => { const f = fixture(); f.required[0].app = {databaseId: 54321}; assert.notEqual((await run(f)).risk, 'success'); }); @@ -221,9 +231,10 @@ test('A ruleset without classic protection can authorize when every check is gre required_status_checks: [{context: 'Unit tests', integration_id: 23456}]}}]; assert.equal((await run(f)).risk, 'success'); }); -test('Owner-only paths retain the existing owner-label event predicate', async () => { +test('Owner confirmation requires a matching event and head-bound receipt', async () => { const f = fixture(); f.files[0].filename = 'src/secrets.js'; f.pr.labels = [{name: 'owner-approved'}]; f.events = [{id: 1, event: 'labeled', label: {name: 'owner-approved'}, actor: {login: '-owner'}}]; + f.statuses.push({id: 11, context: 'clearing-label', state: 'success', description: 'owner-approved for PR 1 at event 1 on this head'}); assert.equal((await run(f)).risk, 'success'); f.events.push({id: 2, event: 'unlabeled', label: {name: 'owner-approved'}, actor: {login: '-owner'}}); assert.notEqual((await run(f)).risk, 'success'); @@ -257,3 +268,105 @@ for (const mutation of ['missing', 'duplicate', 'rename', 'count']) { assert.notEqual((await run(f)).risk, 'success'); }); } + +// Adverse cases exercise the actual embedded trusted code, with fake APIs only. +test('Newest malformed trusted marker supersedes older clean review', async () => { + const f = fixture(); f.comments.push({...f.comments[0], id: 2, + updated_at: new Date(Date.now() - 1000).toISOString(), body: '