diff --git a/.github/workflows/dot-review-submit.yml b/.github/workflows/dot-review-submit.yml index 59daca9..b24f3c8 100644 --- a/.github/workflows/dot-review-submit.yml +++ b/.github/workflows/dot-review-submit.yml @@ -32,7 +32,10 @@ jobs: submit: if: >- github.ref == 'refs/heads/main' && vars.DOT_REVIEW_READY == 'true' && - (github.event_name == 'workflow_dispatch' || vars.DOT_REVIEW_AUTO_ENABLED == 'true') + (github.event_name == 'workflow_dispatch' || + (github.event_name == 'workflow_run' && + github.event.workflow_run.event == 'pull_request_target' && + vars.DOT_REVIEW_AUTO_ENABLED == 'true')) runs-on: ubuntu-latest timeout-minutes: 5 permissions: diff --git a/.github/workflows/risk-gate.yml b/.github/workflows/risk-gate.yml index 1b2fdce..086bc63 100644 --- a/.github/workflows/risk-gate.yml +++ b/.github/workflows/risk-gate.yml @@ -2,7 +2,19 @@ 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] + # Codex publishes and refreshes its review receipt as a PR comment, which + # raises no pull_request event. Only that bot can re-trigger the evaluation. + issue_comment: + types: [created, edited, deleted] + # A merge to the base branch can invalidate a review that already passed and + # raises no PR event, so the gate re-sweeps the open pull requests. + push: + branches: [main, master] + # A merge made with `GITHUB_TOKEN` (the Dependabot auto-merge path) raises no + # push event, so a periodic sweep catches the base moves no event reports. + schedule: + - cron: '17 */6 * * *' workflow_dispatch: inputs: pr_number: @@ -11,46 +23,141 @@ on: type: string # No checkout, PR code execution, secret access, label writes or merge permission. +# `actions: write` is only used to dispatch a per-PR evaluation after a base push. permissions: contents: read pull-requests: read issues: read statuses: write - -concurrency: - group: risk-gate-${{ github.event.pull_request.number || inputs.pr_number }} - cancel-in-progress: true + actions: write jobs: evaluate: name: Evaluate PR governance + if: github.event_name != 'issue_comment' || (github.event.issue.pull_request && (github.event.comment.user.login == 'chatgpt-codex-connector[bot]' || github.event.comment.user.login == 'chatgpt-codex-connector')) runs-on: ubuntu-latest timeout-minutes: 5 + # Job-level, so a comment from anyone else skips the job without cancelling + # an evaluation already running for this PR. + concurrency: + group: risk-gate-${{ github.event.pull_request.number || github.event.issue.number || inputs.pr_number }} + cancel-in-progress: true steps: - - name: Publish risk-gate and PR template checks on the PR head + - name: Publish risk-gate, AI review and PR template checks on the PR head uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: PR_NUMBER: ${{ inputs.pr_number }} + # Required setup: set the RISK_GATE_OWNER repository variable to the + # approver's login (for example, ). The approver is + # repository configuration, not a literal, so the owner can change + # without editing the gate. Unset accepts no label and leaves + # high-risk merges blocked. + APPROVAL_OWNER: ${{ vars.RISK_GATE_OWNER }} with: script: | // Executed only by the trusted base-branch workflow. Never load PR code. - const number = context.payload.pull_request?.number || Number(process.env.PR_NUMBER); - if (!Number.isSafeInteger(number) || number < 1) throw new Error('Invalid PR number'); + const eventName = context.eventName; + // A push or a scheduled sweep has no single PR; both re-evaluate + // them all. Closing a PR can end a shared-head collision, so it + // re-evaluates the remaining open PRs instead of the closed one. + const sweep = ['push', 'schedule'].includes(eventName) + || (eventName === 'pull_request_target' && context.payload.action === 'closed'); + const single = !sweep; + const number = context.payload.pull_request?.number + || context.payload.issue?.number + || Number(process.env.PR_NUMBER); + if (single && (!Number.isSafeInteger(number) || number < 1)) throw new Error('Invalid PR number'); const repo = context.repo; const target = `${context.serverUrl}/${repo.owner}/${repo.repo}/actions/runs/${context.runId}`; - let sha; - const status = (name, state, description) => github.rest.repos.createCommitStatus({ - ...repo, sha, context: name, state, description, target_url: target, - }); + // Definitions that decide what passes cannot be relaxed by a PR. + const ALWAYS_RISK_WORKFLOWS = new Set([ + '.github/workflows/risk-gate.yml', + '.github/workflows/offline-submission-regression.yml', + '.github/workflows/repository-hygiene.yml', + '.github/workflows/engineering-governance.yml', + '.github/workflows/dependabot-policy.yml', + ]); + const WORKFLOW_PRIVILEGE = [ + /^\s*permissions\s*:/, + // Any granted scope, quoted or trailed by a comment, including scopes + // GitHub adds later. An unknown key still fails closed here. + /^\s*["']?[a-z][a-z0-9-]*["']?\s*:\s*["']?write["']?\s*(?:#.*)?$/, + /\bsecrets\b/, + /\b(?:GH_TOKEN|GITHUB_TOKEN|github\.token)\b/, + /\bpull_request_target\b/, + /\bworkflow_run\b/, + // Events an unprivileged user can raise against a write-capable workflow. + /\bissue_comment\b/, + /\bpull_request_review_comment\b/, + /\bdiscussion_comment\b/, + /\brepository_dispatch\b/, + ]; + const ACTION_PIN = /^\s*-?\s*uses\s*:/; + const ACTION_TARGET = /^\s*-?\s*uses\s*:\s*["']?([^@"'\s]+)@/; + // The owner login comes from repository configuration so the gate + // does not hard-code an account. An empty value matches nobody, so + // a missing variable blocks high-risk merges instead of widening. + const APPROVAL_OWNER = process.env.APPROVAL_OWNER || ''; + const isWorkflow = path => path.startsWith('.github/workflows/'); 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 (/(^|\/)codeowners$/.test(p)) return true; if (/(^|\/)(docs?|tests?|examples?|fixtures)(\/|$)/.test(p)) return false; if (/(^|\/)\.env(?:[./_-]|$)/.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 (/(^|[\/_.-])(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); } + // A repin keeps the action and changes only its ref. Swapping in a + // different action is a different change and takes the normal tier. + function sameAction(entries) { + const targets = sign => entries.filter(entry => entry.sign === sign) + .map(entry => (ACTION_TARGET.exec(entry.text) || [])[1]); + const removed = targets('-'); + const added = targets('+'); + return removed.length > 0 && removed.length === added.length + && removed.every((target, index) => target === added[index]); + } + async function workflowHighRisk(file, isDependabot, actionFiles) { + // A rename must not slip a protected definition past the check. + if (ALWAYS_RISK_WORKFLOWS.has(file.filename) || ALWAYS_RISK_WORKFLOWS.has(file.previous_filename)) return true; + // Renaming a workflow changes the file GitHub discovers, and moving + // it out of the directory disables the checks it published. A + // `.yml.disabled` suffix is still inside the directory but no + // longer a workflow, so any path change takes the protected tier. + if (file.previous_filename && file.previous_filename !== file.filename) return true; + // Without a readable diff the change cannot be proven benign. + if (typeof file.patch !== 'string') return true; + const changed = file.patch.split('\n') + .filter(line => /^[+-]/.test(line) && !/^(?:\+\+\+|---)/.test(line)) + .map(line => ({ sign: line[0], text: line.slice(1) })); + if (changed.some(entry => WORKFLOW_PRIVILEGE.some(pattern => pattern.test(entry.text)))) return true; + // A verified Dependabot update only repins action versions, so the + // change is nothing but one `uses:` line replaced by another. The + // base-branch dependency policy names the workflows whose action + // updates are eligible; anything else takes the normal path. + const pinned = changed.filter(entry => ACTION_PIN.test(entry.text)); + if (isDependabot && actionFiles?.has(file.filename) + && pinned.length === changed.length && sameAction(pinned)) return false; + // Every remaining edit can change what CI verifies: a removed + // assertion, an added `if: false`, a `continue-on-error`. Even a + // `#` line is not provably inert, because inside a `run:` block it + // is shell text that GitHub still expands expressions in before + // Bash sees it, and a comment after a backslash continuation cuts + // the command short. A workflow that publishes a required status + // is exactly where guessing is most expensive, so this tier needs + // a clearing label. + return true; + } function completeTemplate(body) { const text = (body || '').replace(//g, ''); const sections = [...text.matchAll(/^#{1,6}\s+(.+)\r?\n([\s\S]*?)(?=^#{1,6}\s|$(?![\s\S]))/gm)]; @@ -61,39 +168,265 @@ jobs: && present(/what changed|summary|problem|changes|变更|改动/i) && present(/how verified|verif|validat|test(?:ing| plan)?|验证|测试/i); } - try { - const { data: pr } = await github.rest.pulls.get({ ...repo, pull_number: number }); + // REST and GraphQL disagree on the bot suffix, so accept both forms. + const CODEX_LOGIN = /^chatgpt-codex-connector(?:\[bot\])?$/; + // The receipt is a single JSON object closed just before the HTML + // comment terminator, so the capture must stay greedy. + const REVIEW_RECEIPT = //; + // A receipt names only the head it reviewed, and commit dates are author + // metadata rather than the time a branch moved, so an old review can + // outlive a base push. Git ancestry is the authenticated evidence: the + // reviewed head must contain the current base head, which GitHub + // resolves from the object graph. pr.base.sha lags the live branch, so + // comparing it would miss the same drift. + async function headContainsBase(baseRef, headSha) { + const { data } = await github.rest.repos.getBranch({ ...repo, branch: baseRef }); + const baseHead = data.commit.sha; + const { data: cmp } = await github.rest.repos.compareCommits({ ...repo, base: baseHead, head: headSha }); + return cmp.merge_base_commit?.sha === baseHead; + } + // A Dependabot skip only holds for commits Dependabot itself wrote. + async function dependabotHead(prNumber) { + const commits = await github.paginate(github.rest.pulls.listCommits, { ...repo, pull_number: prNumber, per_page: 100 }); + // The API truncates at 250 entries; an incomplete history cannot + // prove the bot wrote every commit in the head. + if (!commits.length || commits.length >= 250) return false; + // `author.login` is the account GitHub links from the commit email, + // and the author name is free text; a human can set either. A + // verified signature only proves some key signed it, so the + // committer must be GitHub itself. One commit outside that shape + // sends the PR down the normal review path. + return commits.every(commit => commit.author?.login === 'dependabot[bot]' + && commit.committer?.login === 'web-flow' + && commit.commit?.verification?.verified === true); + } + // The dependency policy names the workflows whose action updates may + // merge unattended. It is read from the base branch, so a PR cannot + // grant itself the exemption by editing the policy it is judged by. + async function eligibleActionFiles(baseRef) { + try { + const { data } = await github.rest.repos.getContent({ + ...repo, path: '.github/scripts/dependabot-policy.json', ref: baseRef, + }); + if (data.type !== 'file' || typeof data.content !== 'string') return null; + const policy = JSON.parse(Buffer.from(data.content, 'base64').toString('utf8')); + return new Set(Array.isArray(policy.action_files) ? policy.action_files : []); + } catch { + // An unreadable or missing policy grants no exemption. + return null; + } + } + // Codex publishes a machine-readable receipt in its rolling summary + // comment. Only a completed review of the current head with no + // unresolved finding counts as a pass; anything else blocks the gate. + async function aiReview(prNumber, headSha, baseContained) { + // A review cannot outlive a base push. Re-requesting a review on the + // same head does not clear this: the merge would still combine that + // head with a base the review never saw, so the branch comes first. + if (!baseContained) { + return { state: 'failure', description: 'Base branch advanced since this head was built; update the branch then comment @codex review' }; + } + const comments = await github.paginate(github.rest.issues.listComments, { ...repo, issue_number: prNumber, per_page: 100 }); + const receipts = comments + .filter(comment => CODEX_LOGIN.test(comment.user?.login || '')) + .map(comment => ({ comment, match: REVIEW_RECEIPT.exec(comment.body || '') })) + .filter(entry => entry.match) + .sort((a, b) => new Date(b.comment.updated_at) - new Date(a.comment.updated_at)); + if (!receipts.length) return { state: 'pending', description: 'No Codex review yet; comment @codex review to start one' }; + let receipt; + try { + receipt = JSON.parse(receipts[0].match[1]); + } catch { + return { state: 'error', description: 'Codex review receipt is not valid JSON' }; + } + const review = receipt?.code_review || {}; + if (review.status !== 'completed') { + return { state: 'pending', description: `Codex review ${review.status || 'not finished'}; comment @codex review to restart it` }; + } + const reviewed = typeof review.commit_sha === 'string' ? review.commit_sha : ''; + if (reviewed !== headSha) { + return { state: 'failure', description: `Codex reviewed ${reviewed.slice(0, 7) || 'another commit'}, not this head; comment @codex review` }; + } + if (review.has_unresolved_findings !== false) { + return { state: 'failure', description: 'Codex review still has unresolved findings on this head' }; + } + return { state: 'success', description: 'Codex reviewed this head with no unresolved findings' }; + } + // Returns true when the gate passes for this PR. Errors are rethrown + // after the statuses are marked, so the caller decides how loud to be. + async function evaluate(number) { + let sha; + const status = (name, state, description) => github.rest.repos.createCommitStatus({ + ...repo, sha, context: name, state, description: String(description).slice(0, 140), target_url: target, + }); + try { + const { data: pr } = await github.rest.pulls.get({ ...repo, pull_number: number }); + // A closed PR keeps no authority over its head: publishing on it + // could overwrite a live PR that now uses the same commit. + if (pr.state !== 'open') return true; sha = pr.head.sha; - await status('risk-gate', 'pending', 'Evaluating changed paths and owner approval'); + // Commit statuses are keyed by SHA, not by PR. A second open PR on + // the same head would share these contexts, and one verdict could + // overwrite the other, so no status is proof while that is true. + const siblings = await github.paginate(github.rest.pulls.list, { ...repo, state: 'open', per_page: 100 }); + if (siblings.some(item => item.number !== number && item.head?.sha === sha)) { + // The description is the marker the synchronize refresh below + // looks for, so the collision must be named, not folded into + // the generic failure text. + const collision = new Error('Another open pull request shares this head; statuses would collide'); + collision.code = 'SHARED_HEAD'; + throw collision; + } + // Dependabot bodies use their own template; the risk-gate path check still applies below. + // The exemption follows the head commit, not the PR author: a human + // pushing onto a bot branch must not inherit the skip. + const isDependabot = pr.user?.login === 'dependabot[bot]' && await dependabotHead(number); + const actionFiles = isDependabot ? await eligibleActionFiles(pr.base.ref) : null; + await status('risk-gate', 'pending', 'Evaluating changed paths, owner approval and AI review'); await status('pr-template', 'pending', 'Checking AI, change and verification fields'); + await status('ai-review', 'pending', 'Reading the Codex review receipt'); const files = await github.paginate(github.rest.pulls.listFiles, { ...repo, pull_number: number, per_page: 100 }); // GitHub caps this API at 3000 files. Incomplete diffs must not pass as low risk. if (files.length !== pr.changed_files || files.length >= 3000) throw new Error('Incomplete PR file list'); - const risky = files.some(file => [file.filename, file.previous_filename].filter(Boolean).some(highRisk)); - let approved = false; - if (pr.labels.some(label => label.name === 'owner-approved')) { + let risky = false; + for (const file of files) { + const names = [file.filename, file.previous_filename].filter(Boolean); + risky = names.some(isWorkflow) ? await workflowHighRisk(file, isDependabot, actionFiles) : names.some(highRisk); + if (risky) break; + } + // Two labels, two different claims. The owner and their agents share + // one GitHub login, so the event actor cannot tell them apart; the + // label name is the only honest signal left. owner-approved claims a + // human confirmed it, agent-approved claims an agent did. The status + // description always names which one applied, so an agent-cleared + // high-risk merge is visible instead of passing as a human review. + const APPROVAL_LABELS = ['owner-approved', 'agent-approved']; + let approval = null; + if (pr.labels.some(label => APPROVAL_LABELS.includes(label.name))) { const events = await github.paginate(github.rest.issues.listEvents, { ...repo, issue_number: number, per_page: 100 }); - 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'; + const liveLabel = name => { + const latest = events.filter(event => ['labeled', 'unlabeled'].includes(event.event) + && event.label?.name === name).sort((a, b) => b.id - a.id)[0]; + return latest?.event === 'labeled' && APPROVAL_OWNER !== '' + && latest.actor?.login === APPROVAL_OWNER; + }; + // A human confirmation outranks an agent's when both are present. + if (liveLabel('owner-approved')) approval = 'owner'; + else if (liveLabel('agent-approved')) approval = 'agent'; } const { data: current } = await github.rest.pulls.get({ ...repo, pull_number: number }); if (current.head.sha !== sha || current.body !== pr.body || JSON.stringify(current.labels.map(x => x.name).sort()) !== JSON.stringify(pr.labels.map(x => x.name).sort())) { throw new Error('PR changed during evaluation; wait for the latest event'); } - const allowed = !risky || approved; - await status('risk-gate', allowed ? 'success' : 'failure', !risky ? 'Low-risk paths: no owner approval needed' - : approved ? 'High-risk paths: owner-approved label verified' : 'High-risk paths: needs-owner-approval; owner must add owner-approved'); - const templateOK = completeTemplate(pr.body); - await status('pr-template', templateOK ? 'success' : 'failure', templateOK - ? 'AI, change and verification fields are filled in' : 'Fill in AI assistance, What changed and How verified sections'); - if (!allowed || !templateOK) core.setFailed('Required PR governance checks did not pass'); - } catch (error) { - if (sha) { - await status('risk-gate', 'error', 'Evaluation failed; inspect the trusted workflow run'); - await status('pr-template', 'error', 'Evaluation failed; inspect the trusted workflow run'); + const review = isDependabot + ? { state: 'success', description: 'Automated dependency update: Codex review not required' } + : await aiReview(number, sha, await headContainsBase(pr.base.ref, sha)); + // The opening snapshot cannot see a pull request that appeared on + // this head while the evaluation ran. Publishing a verdict now + // would overwrite the collision error that the newcomer is about + // to write, leaving two open PRs sharing a passing status, so the + // condition is rechecked immediately before any status is written. + const lateSiblings = await github.paginate(github.rest.pulls.list, { ...repo, state: 'open', per_page: 100 }); + if (lateSiblings.some(item => item.number !== number && item.head?.sha === sha)) { + const collision = new Error('Another open pull request shares this head; statuses would collide'); + collision.code = 'SHARED_HEAD'; + throw collision; + } + await status('ai-review', review.state, review.description); + const tierOK = !risky || approval !== null; + const tierNote = !risky ? 'Low-risk paths: no owner approval needed' + : approval === 'owner' ? 'High-risk paths: owner-approved label verified' + : approval === 'agent' ? 'High-risk paths: cleared by agent-approved, NOT confirmed by the owner' + : 'High-risk paths: needs-owner-approval; owner must add owner-approved'; + let gateState; + let gateNote; + if (!tierOK) { + gateState = 'failure'; + gateNote = tierNote; + } else if (review.state === 'success') { + gateState = 'success'; + gateNote = `${tierNote}; AI review passed`; + } else if (review.state === 'pending') { + // An unfinished review is neither a pass nor a failure. + gateState = 'pending'; + gateNote = `${tierNote}; waiting for AI review`; + } else { + gateState = 'failure'; + gateNote = `${tierNote}; ${review.description}`; + } + await status('risk-gate', gateState, gateNote); + const templateOK = isDependabot ? true : completeTemplate(pr.body); + await status('pr-template', templateOK ? 'success' : 'failure', isDependabot + ? 'Automated dependency update: PR template not required' + : templateOK ? 'AI, change and verification fields are filled in' : 'Fill in AI assistance, What changed and How verified sections'); + return gateState !== 'failure' && templateOK; + } catch (error) { + if (sha) { + const detail = error?.code === 'SHARED_HEAD' + ? 'Another open pull request shares this head; statuses would collide' + : 'Evaluation failed; inspect the trusted workflow run'; + await status('risk-gate', 'error', detail); + await status('pr-template', 'error', detail); + await status('ai-review', 'error', detail); + } + throw error; + } + } + // A synchronize moves one pull request off a shared head. The other + // pull request that shared it still carries the collision error and + // would otherwise keep it until its own event or the sweep, so + // re-dispatch only the siblings still showing that verdict. Other + // errors are not collateral: an evaluation that failed for its own + // reasons must not be re-run by an unrelated push. + async function refreshCollidedSiblings() { + if (eventName !== 'pull_request_target' || context.payload.action !== 'synchronize') return; + const open = await github.paginate(github.rest.pulls.list, { ...repo, state: 'open', per_page: 100 }); + for (const item of open) { + if (item.number === number) continue; + const { data: combined } = await github.rest.repos.getCombinedStatusForRef({ ...repo, ref: item.head.sha }); + const gate = (combined.statuses || []).find(entry => entry.context === 'risk-gate'); + if (gate?.state === 'error' && /shares this head/.test(gate.description || '')) { + await github.rest.actions.createWorkflowDispatch({ + ...repo, workflow_id: 'risk-gate.yml', ref: context.payload.repository.default_branch, + inputs: { pr_number: String(item.number) }, + }); + } + } + } + if (single) { + let passed; + try { + passed = await evaluate(number); + } finally { + // A synchronize can release a sibling from a shared head even + // when this evaluation throws, and a stale collision error + // would otherwise keep that sibling blocked until its own + // event or the sweep. Best effort: a refresh failure must not + // replace the evaluation error. + try { + await refreshCollidedSiblings(); + } catch (refreshError) { + core.warning(`Sibling refresh failed: ${refreshError.message}`); + } + } + // A dispatched run only refreshes statuses after the base moved, and + // the verdict already lives on the PR's commit status. Failing the run + // here would paint every merge red for open PRs that are legitimately + // not ready, which buries a real evaluation error. Event-driven runs + // keep failing loudly. + if (!passed && eventName !== 'workflow_dispatch') core.setFailed('Required PR governance checks did not pass'); + } else { + // Merging moves the base without raising a PR event. Dispatch a normal + // per-PR evaluation for each open PR instead of evaluating inline, so + // each one joins that PR's concurrency group and cannot race an + // event-driven run into publishing a stale status. + const open = await github.paginate(github.rest.pulls.list, { ...repo, state: 'open', per_page: 100 }); + for (const item of open) { + await github.rest.actions.createWorkflowDispatch({ + ...repo, workflow_id: 'risk-gate.yml', ref: context.payload.repository.default_branch, + inputs: { pr_number: String(item.number) }, + }); } - throw error; + core.info(`Dispatched ${open.length} pull request evaluations after the base branch moved.`); } diff --git a/AGENTS.md b/AGENTS.md index ec992e5..f3860a9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -98,7 +98,7 @@ 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. High-risk paths require the owner's `owner-approved` label; agents must never add or remove it. +- AI-only development uses required automated checks, template validation and the `ai-review` context published from the Codex receipt on the current head, with zero required human or code-owner approvals. High-risk paths require a clearing label: `owner-approved` states that the owner confirmed the change; `agent-approved` states that an agent cleared it, and the `risk-gate` description then says `NOT confirmed by the owner`. Agents must never add or remove `owner-approved`. - 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. ## Scope and authorization