From e785e6f7e9d60f40a816b794121f70ac91bf280f Mon Sep 17 00:00:00 2001 From: Repository Automation Date: Tue, 3 Nov 2026 17:28:34 +0800 Subject: [PATCH 1/3] ci: add reusable report-only governance engine --- .github/governance/policy.json | 9 + .github/workflows/governance-acceptance.yml | 177 ++++++++++++++++++ .github/workflows/governance-shadow-tests.yml | 17 ++ README.md | 2 + README.zh-CN.md | 2 + docs/governance-shadow.md | 73 ++++++++ scripts/test-governance-shadow.cjs | 144 ++++++++++++++ 7 files changed, 424 insertions(+) create mode 100644 .github/governance/policy.json create mode 100644 .github/workflows/governance-acceptance.yml create mode 100644 .github/workflows/governance-shadow-tests.yml create mode 100644 docs/governance-shadow.md create mode 100644 scripts/test-governance-shadow.cjs diff --git a/.github/governance/policy.json b/.github/governance/policy.json new file mode 100644 index 0000000..a7570ff --- /dev/null +++ b/.github/governance/policy.json @@ -0,0 +1,9 @@ +{ + "schema_version": 1, + "mode": "SHADOW", + "reviewers": [], + "owners": [], + "max_evidence_age_hours": 24, + "baseline_exceptions": false, + "merge_authorized": false +} diff --git a/.github/workflows/governance-acceptance.yml b/.github/workflows/governance-acceptance.yml new file mode 100644 index 0000000..118d1d5 --- /dev/null +++ b/.github/workflows/governance-acceptance.yml @@ -0,0 +1,177 @@ +name: Governance shadow engine + +on: + workflow_call: + inputs: + pr-number: + required: true + type: number + policy-revision: + description: Same immutable central commit as the reusable workflow reference + required: true + type: string + mode: + default: SHADOW + type: string + +permissions: + contents: read + pull-requests: read + issues: read + +jobs: + observe: + name: Governance shadow observation + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Observe native candidate evidence + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + PR_NUMBER: ${{ inputs.pr-number }} + POLICY_REVISION: ${{ inputs.policy-revision }} + MODE: ${{ inputs.mode }} + with: + script: | + // Trusted reusable workflow code only; never load candidate source. + const crypto = require('node:crypto'); + const digest = value => crypto.createHash('sha256').update(JSON.stringify(value)).digest('hex'); + const revision = process.env.POLICY_REVISION; + const number = Number(process.env.PR_NUMBER); + const fail = code => { throw new Error(code); }; + const hash = value => typeof value === 'string' && /^[0-9a-f]{40}$/.test(value); + let report = {schema_version: 1, mode: 'SHADOW', merge_authorized: false, + observation: 'UNKNOWN', reason: 'READ_OR_INTEGRITY_FAILURE'}; + try { + if (process.env.MODE !== 'SHADOW' || !hash(revision) + || !Number.isSafeInteger(number) || number < 1) fail('INVALID_INPUT'); + const {data: content} = await github.rest.repos.getContent({owner: 'QuantStrategyLab', + repo: '.github', path: '.github/governance/policy.json', ref: revision}); + if (content.encoding !== 'base64' || typeof content.content !== 'string') fail('INVALID_POLICY'); + const policy = JSON.parse(Buffer.from(content.content, 'base64').toString('utf8')); + if (policy.schema_version !== 1 || policy.mode !== 'SHADOW' || policy.merge_authorized !== false + || policy.baseline_exceptions !== false || !Array.isArray(policy.reviewers) + || !Array.isArray(policy.owners) || ![...policy.reviewers, ...policy.owners].every(x => typeof x === 'string') + || !Number.isInteger(policy.max_evidence_age_hours) + || policy.max_evidence_age_hours < 1 || policy.max_evidence_age_hours > 24) fail('INVALID_POLICY'); + const policyDigest = digest(policy); + const args = {...context.repo, pull_number: number}; + const readPR = async () => (await github.rest.pulls.get(args)).data; + const readEvidence = async () => ({ + reviews: await github.paginate(github.rest.pulls.listReviews, {...args, per_page: 100}), + comments: await github.paginate(github.rest.issues.listComments, + {...context.repo, issue_number: number, per_page: 100}) + }); + const binding = pr => [pr.id, pr.state, pr.head?.sha, pr.base?.sha, pr.base?.ref, + pr.base?.repo?.id, pr.head?.repo?.id, pr.user?.login, pr.changed_files, pr.additions, pr.deletions]; + const pr = await readPR(); + if (pr.state !== 'open' || !hash(pr.head?.sha) || !hash(pr.base?.sha) + || !Number.isSafeInteger(pr.changed_files) || pr.changed_files < 1 || pr.changed_files >= 3000 + || !pr.user?.login || pr.base?.repo?.full_name?.toLowerCase() + !== `${context.repo.owner}/${context.repo.repo}`.toLowerCase()) fail('INVALID_CANDIDATE'); + const files = await github.paginate(github.rest.pulls.listFiles, {...args, per_page: 100}); + if (files.length !== pr.changed_files || new Set(files.map(f => f.filename)).size !== files.length) + fail('INCOMPLETE_FILES'); + function complete(file) { + if (!['added', 'modified', 'removed', 'renamed'].includes(file.status) + || typeof file.filename !== 'string' || !file.filename || file.filename.includes('\0') + || (file.status === 'renamed' && !file.previous_filename) + || !Number.isInteger(file.additions) || file.additions < 0 + || !Number.isInteger(file.deletions) || file.deletions < 0 + || typeof file.patch !== 'string' || !file.patch) return false; + let adds = 0, dels = 0, oldLeft = 0, newLeft = 0, hunk = false; + for (const line of file.patch.split('\n')) { + const m = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/.exec(line); + if (m) { + if (oldLeft || newLeft) return false; + oldLeft = m[2] === undefined ? 1 : Number(m[2]); + newLeft = m[4] === undefined ? 1 : Number(m[4]); hunk = true; + } else if (line.startsWith('\\ No newline')) { + if (!hunk) return false; + } else { + if (!hunk) return false; + if (line[0] === '+') { adds++; newLeft--; } + else if (line[0] === '-') { dels++; oldLeft--; } + else if (line[0] === ' ') { oldLeft--; newLeft--; } + else return false; + if (oldLeft < 0 || newLeft < 0) return false; + } + } + return hunk && oldLeft === 0 && newLeft === 0 && adds === file.additions && dels === file.deletions; + } + if (!files.every(complete) + || files.reduce((n,f) => n + f.additions, 0) !== pr.additions + || files.reduce((n,f) => n + f.deletions, 0) !== pr.deletions) fail('INCOMPLETE_PATCH'); + const scope = digest(files.map(f => [f.filename, f.previous_filename || null, f.status, + f.additions, f.deletions, digest(f.patch)]).sort((a,b) => a[0].localeCompare(b[0]))); + const authority = /authoriz|approv|governance|acceptance|permission|secret|credential|token|account|iam|risk|trad(?:e|ing)|kelly|stop.?loss|deploy|publish|baseline|safety|mask|redact|门禁|审批|授权|风控|密钥|止损/i; + const sensitive = /(^|\/)(\.github|tests?|fixtures?|config|deploy|runtime)(\/|$)|(^|\/)(AGENTS\.md|CODEOWNERS|SECURITY\.md)$|lock$|requirements|package\.json|pyproject\.toml/i; + const isR2 = files.some(f => [f.filename, f.previous_filename || ''].some(p => sensitive.test(p) || authority.test(p)) + || authority.test(f.patch) || (f.filename.endsWith('.md') && (f.deletions > 0 || f.status !== 'added'))); + const proseOnly = files.every(f => /^docs\/[a-z0-9/-]+\.md$/.test(f.filename) + && f.status === 'added' && f.deletions === 0 && !/[<`\[\]{}]|https?:|\bmust\b|\bshall\b|\brequir|必须|应当/i.test(f.patch)); + const tier = isR2 ? 'R2' : proseOnly ? 'R0' : 'R1'; + const evidence = await readEvidence(); + const now = Date.now(); + const findingsClear = findings => Array.isArray(findings) && findings.every(finding => + finding && (finding.kind === 'advisory' || (finding.kind === 'defect' + && ['resolved', 'false_positive', 'obsolete'].includes(finding.disposition)))); + function decision(kind, list, allowed) { + const candidates = []; + for (const entry of list) { + if (entry.user?.type !== 'User' || !allowed.includes(entry.user?.login) + || entry.user.login === pr.user.login) continue; + // No labels, provider completion, executor receipt or shared identity inference. + let record; + try { record = JSON.parse(entry.body); } catch { continue; } + if (record.schema_version !== 1 || record.kind !== kind || record.head !== pr.head.sha + || record.base !== pr.base.sha || record.scope !== scope || record.policy !== policyDigest + || record.repository !== `${context.repo.owner}/${context.repo.repo}` || record.pr !== number) continue; + const created = Date.parse(entry.submitted_at || entry.created_at); + const expires = Date.parse(record.expires_at); + const valid = Number.isFinite(created) && Number.isFinite(expires) && created <= now && expires > now + && expires > created && expires - created <= policy.max_evidence_age_hours * 3600000 + && now - created <= policy.max_evidence_age_hours * 3600000 + && (!entry.updated_at || entry.updated_at === entry.created_at) + && (kind !== 'review' || (entry.state === 'APPROVED' && entry.commit_id === pr.head.sha)) + && findingsClear(record.findings); + // Newer revoked/invalid records must dominate an older approval. + candidates.push({id: entry.id, valid, decision: record.decision}); + } + candidates.sort((a,b) => b.id - a.id); + return candidates[0]?.valid === true && candidates[0]?.decision === 'approve'; + } + const latestReviews = new Map(); + for (const review of [...evidence.reviews].sort((a,b) => a.id - b.id)) { + if (review.user?.type === 'User' && policy.reviewers.includes(review.user.login) + && review.user.login !== pr.user.login && review.commit_id === pr.head.sha) + latestReviews.set(review.user.login, review); + } + const unresolved = [...latestReviews.values()].some(review => { + if (review.state === 'CHANGES_REQUESTED') return true; + try { + const record = JSON.parse(review.body); + return record.head === pr.head.sha && record.base === pr.base.sha && record.scope === scope + && record.policy === policyDigest && !findingsClear(record.findings); + } catch { return false; } + }); + const reviewed = !unresolved && decision('review', evidence.reviews, policy.reviewers); + const owned = decision('owner', evidence.comments, policy.owners); + const currentEvidence = await readEvidence(); + const current = await readPR(); + if (digest(binding(pr)) !== digest(binding(current)) || digest(evidence) !== digest(currentEvidence)) fail('STALE_SNAPSHOT'); + report = {...report, tier, file_count: files.length, head: pr.head.sha, base: pr.base.sha, + scope, policy: policyDigest, policy_revision: revision, + observation: tier === 'R0' ? 'PROSE_CANDIDATE' : !reviewed ? 'PENDING_REVIEW' + : tier === 'R2' && !owned ? 'PENDING_OWNER' : 'EVIDENCE_PRESENT', + reason: 'SHADOW_ONLY', independent_review: reviewed, owner_approval: owned, + semantic_classification: 'HEURISTIC_NOT_ACCEPTANCE', required_checks: 'NOT_EVALUATED', + event_freshness: 'SNAPSHOT_ONLY', baseline_exception: 'UNSUPPORTED'}; + } catch { + // Deliberately omit exception text, paths, patches, user bodies and API response data. + report = {...report, observation: 'UNKNOWN', reason: 'READ_OR_INTEGRITY_FAILURE'}; + core.setFailed('Shadow observation unavailable; existing gates remain authoritative'); + } + await core.summary.addHeading('Governance shadow observation').addCodeBlock(JSON.stringify(report, null, 2), 'json').write(); + core.setOutput('observation', report.observation); + return report; diff --git a/.github/workflows/governance-shadow-tests.yml b/.github/workflows/governance-shadow-tests.yml new file mode 100644 index 0000000..2057b1a --- /dev/null +++ b/.github/workflows/governance-shadow-tests.yml @@ -0,0 +1,17 @@ +name: Governance shadow tests +on: + pull_request: + push: + branches: [main] +permissions: + contents: read +jobs: + test-shadow: + name: Governance shadow tests + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + - run: node --test scripts/test-governance-shadow.cjs diff --git a/README.md b/README.md index 39a292c..703b654 100644 --- a/README.md +++ b/README.md @@ -21,3 +21,5 @@ Organization-wide files for QuantStrategyLab repositories. ## Contributing Open a pull request. Describe the problem, the change, and the effect on existing repositories. AI assistants and maintainers follow [AGENTS.md](AGENTS.md). + +- [Governance shadow engine](docs/governance-shadow.md) — reusable report-only review observations. diff --git a/README.zh-CN.md b/README.zh-CN.md index 8f2c3c1..e632add 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -21,3 +21,5 @@ QuantStrategyLab 各仓库共用的组织级文件。 ## 参与贡献 提交 pull request,说明要解决的问题、改了什么,以及对现有仓库的影响。AI 助手与维护者遵循 [AGENTS.md](AGENTS.md)。 + +- [Governance shadow engine](docs/governance-shadow.md) — reusable report-only review observations. diff --git a/docs/governance-shadow.md b/docs/governance-shadow.md new file mode 100644 index 0000000..edbed14 --- /dev/null +++ b/docs/governance-shadow.md @@ -0,0 +1,73 @@ +# Governance shadow engine + +The reusable `governance-acceptance.yml` workflow observes pull requests without +changing merge authorization. It is public and can be called from repositories +under any owner. Both the workflow reference and `policy-revision` must use the +same full, reviewed central commit. There are no inherited secrets, candidate +checkouts, comment writes, status writes or merge operations. + +## Caller contract + +Use `pull_request` with opened, synchronize, reopened, edited and ready-for-review +events. Pass the event pull request number, `mode: SHADOW` and the central policy +revision. Use the dedicated `Governance shadow observation` job name; do not add +it to required checks or replace an existing check. Read-only contents, pull +requests and issues permissions suffice. Existing required checks remain separate. +The public workflow requires no organization access-setting change. Hosting +restrictions or an unavailable pin must be reported rather than bypassed. + +## Observations and evidence + +The engine reads all changed files and verifies file counts, aggregate additions +and deletions, and hunk lengths. Missing or binary patches are unknown. It hashes +the complete scope and central policy. Authority-related paths and prose, test +changes and modified Markdown are conservatively R2; ordinary source changes +are R1. Only narrowly constrained added explanatory prose is an R0 candidate. +These are heuristic observations, never semantic review acceptance. + +Native reviews and owner comments may carry a JSON record containing +`schema_version: 1`, `kind: review` or `kind: owner`, `repository`, numeric `pr`, +full `head` and `base`, `scope`, `policy`, `decision: approve` or `decision: revoke`, +`expires_at` and `findings: []`. Findings may also contain optional +`kind: advisory` advice, or `kind: defect` records with a reviewed disposition of +`resolved`, `false_positive` or `obsolete`; unresolved or malformed defects block +review evidence. Digest values come from the candidate summary. +An independent review also needs an APPROVED native review at that head. +Only human identities listed in the pinned policy qualify; the author cannot +approve their own work. Owner comments edited after creation do not qualify. +Newer invalid or revoked bound records dominate older approvals. No label or +provider availability supplies authorization. Default identity lists are empty, +so R1/R2 observations remain pending until a separately reviewed source policy +establishes identities. No identities or settings are provisioned by this pilot. + +The final read compares the candidate and evidence with the initial reads. +Failures produce UNKNOWN and a failed, non-required observation job. Reports use +fixed fields, candidate bindings and counts; paths, titles, patches, identities, +comment text and raw API errors are never exported. A green workflow means the +observer ran; it never means the pull request is approved. + +## Limits and promotion + +`merge_authorized` is always false. Required-check acceptance, baseline-red +exceptions, durable writer admission, atomic freshness, event completeness, +merge-queue support and identity independence from execution are not established. +Review/comment and base-only changes do not currently schedule a new observation; +a later pull request event refreshes the snapshot. This is suitable only for +report-only comparison, not enforcement. There is no persistent decision cache: +parallel or restarted observations independently re-read native state and cannot +mutate repository state. No deployment, publication or live operation is added. + +Keep the central PR and pilot callers under review. Callers pin the central +candidate commit; merge the reviewed central source before integrating callers. +The PR check run can exercise the pinned public candidate before that merge, +subject to repository Actions policy. Promote only after native runtime evidence +and separately approved identity, required-check and event-freshness changes. +Rollback consists of reverting only the new caller through a normal reviewed PR; +existing gates stay in place. + +## Local verification + +Run `node --test scripts/test-governance-shadow.cjs`, the existing Python checker +and secret-scan tests, and `python scripts/check_style.py --phase 2`. The new +`Governance shadow tests` workflow exercises the exact inline reusable code with +fake API responses. Offline tests do not prove hosted execution or adoption. diff --git a/scripts/test-governance-shadow.cjs b/scripts/test-governance-shadow.cjs new file mode 100644 index 0000000..fd1125c --- /dev/null +++ b/scripts/test-governance-shadow.cjs @@ -0,0 +1,144 @@ +const {test} = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const workflowPath = process.env.SHADOW_WORKFLOW || path.join(__dirname, '../.github/workflows/governance-acceptance.yml'); +const workflow = fs.readFileSync(workflowPath, 'utf8'); +const script = workflow.split(' script: |\n')[1]?.split('\n').map(x => x.slice(12)).join('\n'); +assert.ok(script, 'Reusable engine must contain executable observer'); +const AsyncFunction = Object.getPrototypeOf(async function(){}).constructor; +const execute = new AsyncFunction('github', 'context', 'core', 'require', 'process', script); +const H = 'a'.repeat(40), B = 'b'.repeat(40), R = 'c'.repeat(40); +const policy = JSON.parse(fs.readFileSync(path.join(__dirname, '../.github/governance/policy.json'))); +function fixture() { + return {pr: {id: 1, state: 'open', head: {sha: H, repo: {id: 2}}, + base: {sha: B, ref: 'main', repo: {id: 2, full_name: '/sample'}}, + user: {login: '-author'}, changed_files: 1, additions: 1, deletions: 0}, + files: [{filename: 'docs/example.md', status: 'added', additions: 1, deletions: 0, patch: '@@ -0,0 +1 @@\n+Plain explanatory prose.'}], + evidence: {reviews: [], comments: []}, policy: structuredClone(policy)}; +} +async function observe(f = fixture()) { + const state = {failed: false, summary: '', calls: 0}; + const clone = x => structuredClone(x); + const github = {rest: {repos: {getContent: async () => { + if (f.apiError) throw new Error('private token '); + return {data: {encoding: 'base64', content: Buffer.from(JSON.stringify(f.policy)).toString('base64')}}; + }}, pulls: {get: async () => ({data: clone(++state.calls > 1 && f.current || f.pr)}), + listFiles: 'files', listReviews: 'reviews'}, issues: {listComments: 'comments'}}, + paginate: async method => clone(method === 'files' ? f.files : f.evidence[method])}; + const core = {setFailed: () => {state.failed = true;}, setOutput: () => {}, summary: { + addHeading() {return this;}, addCodeBlock(s) {state.summary = s; return this;}, async write() {}}}; + const report = await execute(github, {repo: {owner: '', repo: 'sample'}}, core, require, + {env: {MODE: f.mode || 'SHADOW', POLICY_REVISION: R, PR_NUMBER: '1'}}); + assert.equal(report.merge_authorized, false); + assert.ok(!state.summary.includes('private token')); + return {report, state}; +} +test('Narrow added prose produces an observation, never merge authorization', async () => { + const {report} = await observe(); assert.equal(report.tier, 'R0'); assert.equal(report.observation, 'PROSE_CANDIDATE'); +}); +test('Ordinary code needs independent review', async () => { + const f = fixture(); f.files[0].filename = 'src/example.js'; + assert.equal((await observe(f)).report.observation, 'PENDING_REVIEW'); +}); +for (const filename of ['.github/workflows/example.yml', 'tests/example.js', 'docs/acceptance-policy.md', 'AGENTS.md']) { + test(`Sensitive scope is R2: ${filename}`, async () => { + const f = fixture(); f.files[0].filename = filename; + assert.equal((await observe(f)).report.tier, 'R2'); + }); +} +test('Authority-changing Markdown is R2', async () => { + const f = fixture(); f.files[0].patch = '@@ -0,0 +1 @@\n+Approval is optional.'; + assert.equal((await observe(f)).report.tier, 'R2'); +}); +for (const mutation of ['missing', 'truncated', 'count', 'binary', 'duplicate', 'rename', 'api', 'mode', 'policy']) { + test(`Unknown integrity never becomes valid: ${mutation}`, async () => { + const f = fixture(); + if (mutation === 'missing') f.files = []; + if (mutation === 'truncated') f.files[0].patch = '@@ -0,0 +1,2 @@\n+Only one line'; + if (mutation === 'count') f.pr.additions = 2; + if (mutation === 'binary') delete f.files[0].patch; + if (mutation === 'duplicate') {f.files.push(f.files[0]); f.pr.changed_files = 2;} + if (mutation === 'rename') f.files[0].status = 'renamed'; + if (mutation === 'api') f.apiError = true; + if (mutation === 'mode') f.mode = 'ENFORCE'; + if (mutation === 'policy') f.policy.merge_authorized = true; + const {report, state} = await observe(f); + assert.equal(report.observation, 'UNKNOWN'); assert.equal(state.failed, true); + }); +} +for (const drift of ['head', 'base', 'ref', 'files', 'author']) { + test(`Re-read rejects snapshot drift: ${drift}`, async () => { + const f = fixture(); f.current = structuredClone(f.pr); + if (drift === 'head') f.current.head.sha = 'd'.repeat(40); + if (drift === 'base') f.current.base.sha = 'e'.repeat(40); + if (drift === 'ref') f.current.base.ref = 'other'; + if (drift === 'files') f.current.changed_files = 2; + if (drift === 'author') f.current.user.login = '-other'; + assert.equal((await observe(f)).report.observation, 'UNKNOWN'); + }); +} +async function recordFixture() { + const f = fixture(); f.files[0].filename = '.github/example.yml'; + f.policy.reviewers = ['-reviewer', '-other']; f.policy.owners = ['-owner']; + const {report} = await observe(f); + const record = {schema_version: 1, kind: 'review', repository: '/sample', pr: 1, + head: H, base: B, scope: report.scope, policy: report.policy, decision: 'approve', findings: [], + expires_at: new Date(Date.now() + 3600000).toISOString()}; + const created_at = new Date(Date.now() - 60000).toISOString(); + f.evidence.reviews = [{id: 1, user: {login: '-reviewer', type: 'User'}, state: 'APPROVED', commit_id: H, + submitted_at: created_at, body: JSON.stringify(record)}]; + return {f, record, created_at}; +} +test('Configured independent review still requires distinct owner evidence for R2', async () => { + const {f} = await recordFixture(); assert.equal((await observe(f)).report.observation, 'PENDING_OWNER'); +}); +for (const invalid of ['expired', 'base', 'scope', 'policy', 'author', 'bot', 'dismissed', 'defect', 'revoked']) { + test(`Reject invalid independent review: ${invalid}`, async () => { + const {f, record} = await recordFixture(); + if (invalid === 'expired') record.expires_at = new Date(Date.now() - 1000).toISOString(); + if (['base', 'scope', 'policy'].includes(invalid)) record[invalid] = 'f'.repeat(40); + if (invalid === 'author') {f.policy.reviewers.push(f.pr.user.login); f.evidence.reviews[0].user.login = f.pr.user.login;} + if (invalid === 'bot') f.evidence.reviews[0].user.type = 'Bot'; + if (invalid === 'dismissed') f.evidence.reviews[0].state = 'DISMISSED'; + if (invalid === 'defect') record.findings = [{severity: 'defect'}]; + if (invalid === 'revoked') record.decision = 'revoke'; + f.evidence.reviews[0].body = JSON.stringify(record); + assert.equal((await observe(f)).report.observation, 'PENDING_REVIEW'); + }); +} +test('Edited owner comment cannot authorize; subsequent revoke dominates approval', async () => { + const {f, record, created_at} = await recordFixture(); record.kind = 'owner'; + f.evidence.comments = [{id: 2, user: {login: '-owner', type: 'User'}, created_at, updated_at: created_at, + body: JSON.stringify(record)}]; + assert.equal((await observe(f)).report.observation, 'EVIDENCE_PRESENT'); + f.evidence.comments[0].updated_at = new Date().toISOString(); + assert.equal((await observe(f)).report.observation, 'PENDING_OWNER'); + f.evidence.comments[0].updated_at = created_at; + f.evidence.comments.push({...f.evidence.comments[0], id: 3, body: JSON.stringify({...record, decision: 'revoke'})}); + assert.equal((await observe(f)).report.observation, 'PENDING_OWNER'); +}); +test('Workflow has no checkout, secret inheritance, write permission or legacy status publisher', () => { + assert.doesNotMatch(workflow, /checkout|secrets:|: write|createCommitStatus|checks\.create|pull_request_target/); + assert.match(workflow, /timeout-minutes: 5/); +}); + +test('An unresolved concrete defect cannot be erased by another reviewer approval', async () => { + const {f, record} = await recordFixture(); + f.evidence.reviews.push({...f.evidence.reviews[0], id: 2, state: 'CHANGES_REQUESTED', + body: JSON.stringify({...record, findings: [{severity: 'defect'}]})}); + f.evidence.reviews.push({...f.evidence.reviews[0], id: 3, user: {login: '-other', type: 'User'}}); + assert.equal((await observe(f)).report.observation, 'PENDING_REVIEW'); +}); +test('Parallel observations do not share mutable decisions', async () => { + const f = fixture(); f.apiError = true; + const [bad, good] = await Promise.all([observe(f), observe()]); + assert.equal(bad.report.observation, 'UNKNOWN'); + assert.equal(good.report.observation, 'PROSE_CANDIDATE'); +}); + +test('Optional style advice does not require repair', async () => { + const {f, record} = await recordFixture(); + record.findings = [{kind: 'advisory'}]; f.evidence.reviews[0].body = JSON.stringify(record); + assert.equal((await observe(f)).report.observation, 'PENDING_OWNER'); +}); From 12b099e26e2231b8db5b31e0a3c316c58839c069 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Tue, 3 Nov 2026 23:52:15 +0800 Subject: [PATCH 2/3] feat: observe native Codex review receipts Co-Authored-By: Codex --- .github/governance/policy.json | 1 + .github/workflows/governance-acceptance.yml | 25 +++++++++++- docs/governance-shadow.md | 7 ++++ scripts/test-governance-shadow.cjs | 43 +++++++++++++++++++++ 4 files changed, 75 insertions(+), 1 deletion(-) diff --git a/.github/governance/policy.json b/.github/governance/policy.json index a7570ff..eeb93b4 100644 --- a/.github/governance/policy.json +++ b/.github/governance/policy.json @@ -3,6 +3,7 @@ "mode": "SHADOW", "reviewers": [], "owners": [], + "codex_app_ids": [], "max_evidence_age_hours": 24, "baseline_exceptions": false, "merge_authorized": false diff --git a/.github/workflows/governance-acceptance.yml b/.github/workflows/governance-acceptance.yml index 118d1d5..a6db255 100644 --- a/.github/workflows/governance-acceptance.yml +++ b/.github/workflows/governance-acceptance.yml @@ -52,6 +52,8 @@ jobs: if (policy.schema_version !== 1 || policy.mode !== 'SHADOW' || policy.merge_authorized !== false || policy.baseline_exceptions !== false || !Array.isArray(policy.reviewers) || !Array.isArray(policy.owners) || ![...policy.reviewers, ...policy.owners].every(x => typeof x === 'string') + || !Array.isArray(policy.codex_app_ids) + || !policy.codex_app_ids.every(x => Number.isSafeInteger(x) && x > 0) || !Number.isInteger(policy.max_evidence_age_hours) || policy.max_evidence_age_hours < 1 || policy.max_evidence_age_hours > 24) fail('INVALID_POLICY'); const policyDigest = digest(policy); @@ -112,6 +114,26 @@ jobs: && f.status === 'added' && f.deletions === 0 && !/[<`\[\]{}]|https?:|\bmust\b|\bshall\b|\brequir|必须|应当/i.test(f.patch)); const tier = isR2 ? 'R2' : proseOnly ? 'R0' : 'R1'; const evidence = await readEvidence(); + function codexObservation(comments) { + if (policy.codex_app_ids.length === 0) return 'NOT_CONFIGURED'; + const candidates = comments.filter(entry => entry.user?.type === 'Bot' + && policy.codex_app_ids.includes(entry.performed_via_github_app?.id) + && typeof entry.body === 'string' && entry.body.includes('codex-review-state:v1')); + if (candidates.some(entry => !Number.isSafeInteger(entry.id) + || !Number.isFinite(Date.parse(entry.updated_at)))) return 'UNKNOWN'; + candidates.sort((a, b) => Date.parse(b.updated_at || '') - Date.parse(a.updated_at || '') || b.id - a.id); + if (!candidates.length) return 'NOT_FOUND'; + const match = //.exec(candidates[0].body); + if (!match) return 'UNKNOWN'; + let review; + try { review = JSON.parse(match[1]).code_review; } catch { return 'UNKNOWN'; } + if (!review || review.commit_sha !== pr.head.sha) return 'STALE_OR_UNKNOWN'; + if (review.status === 'running') return 'IN_PROGRESS'; + if (review.status !== 'completed' || typeof review.has_any_findings !== 'boolean' + || typeof review.has_unresolved_findings !== 'boolean') return 'UNKNOWN'; + return review.has_unresolved_findings ? 'UNRESOLVED_FINDINGS' + : review.has_any_findings ? 'FINDINGS_REPORTED' : 'NO_REPORTED_FINDINGS'; + } const now = Date.now(); const findingsClear = findings => Array.isArray(findings) && findings.every(finding => finding && (finding.kind === 'advisory' || (finding.kind === 'defect' @@ -166,7 +188,8 @@ jobs: : tier === 'R2' && !owned ? 'PENDING_OWNER' : 'EVIDENCE_PRESENT', reason: 'SHADOW_ONLY', independent_review: reviewed, owner_approval: owned, semantic_classification: 'HEURISTIC_NOT_ACCEPTANCE', required_checks: 'NOT_EVALUATED', - event_freshness: 'SNAPSHOT_ONLY', baseline_exception: 'UNSUPPORTED'}; + event_freshness: 'SNAPSHOT_ONLY', baseline_exception: 'UNSUPPORTED', + codex_observation: codexObservation(evidence.comments)}; } catch { // Deliberately omit exception text, paths, patches, user bodies and API response data. report = {...report, observation: 'UNKNOWN', reason: 'READ_OR_INTEGRITY_FAILURE'}; diff --git a/docs/governance-shadow.md b/docs/governance-shadow.md index edbed14..44f2421 100644 --- a/docs/governance-shadow.md +++ b/docs/governance-shadow.md @@ -40,6 +40,13 @@ provider availability supplies authorization. Default identity lists are empty, so R1/R2 observations remain pending until a separately reviewed source policy establishes identities. No identities or settings are provisioned by this pilot. +An optional `codex_app_ids` list enables a separate observation of the native +Codex summary comment. The source must be a bot posted through a configured +GitHub App and its review state must name the current head. The observation +distinguishes running, stale, malformed and unresolved findings. An empty list +leaves this observation unconfigured. This signal does not establish full +coverage or base binding, does not accept a review, and never authorizes merge. + The final read compares the candidate and evidence with the initial reads. Failures produce UNKNOWN and a failed, non-required observation job. Reports use fixed fields, candidate bindings and counts; paths, titles, patches, identities, diff --git a/scripts/test-governance-shadow.cjs b/scripts/test-governance-shadow.cjs index fd1125c..24ef322 100644 --- a/scripts/test-governance-shadow.cjs +++ b/scripts/test-governance-shadow.cjs @@ -41,6 +41,49 @@ test('Ordinary code needs independent review', async () => { const f = fixture(); f.files[0].filename = 'src/example.js'; assert.equal((await observe(f)).report.observation, 'PENDING_REVIEW'); }); +function codexFixture() { + const f = fixture(); f.files[0].filename = 'src/example.js'; f.policy.codex_app_ids = [12345]; + const record = {code_review: {commit_sha: H, status: 'completed', + has_any_findings: false, has_unresolved_findings: false}}; + const entry = {id: 11, user: {type: 'Bot'}, performed_via_github_app: {id: 12345}, + updated_at: new Date().toISOString(), body: ``}; + f.evidence.comments = [entry]; + return {f, entry, record}; +} +test('Codex current-head receipt is observation only, never approval', async () => { + const {f} = codexFixture(); const {report} = await observe(f); + assert.equal(report.codex_observation, 'NO_REPORTED_FINDINGS'); + assert.equal(report.observation, 'PENDING_REVIEW'); + assert.equal(report.independent_review, false); +}); +for (const [mutation, expected] of [['stale', 'STALE_OR_UNKNOWN'], ['running', 'IN_PROGRESS'], + ['unresolved', 'UNRESOLVED_FINDINGS'], ['resolved', 'FINDINGS_REPORTED'], + ['malformed', 'UNKNOWN'], ['untrusted', 'NOT_FOUND'], ['user', 'NOT_FOUND'], ['timestamp', 'UNKNOWN']]) { + test(`Codex receipt preserves untrusted or incomplete state: ${mutation}`, async () => { + const {f, entry, record} = codexFixture(); + if (mutation === 'stale') record.code_review.commit_sha = 'd'.repeat(40); + if (mutation === 'running') record.code_review.status = 'running'; + if (mutation === 'unresolved') record.code_review.has_unresolved_findings = true; + if (mutation === 'resolved') record.code_review.has_any_findings = true; + if (mutation === 'untrusted') entry.performed_via_github_app.id = 54321; + if (mutation === 'user') entry.user.type = 'User'; + if (mutation === 'timestamp') entry.updated_at = 'invalid'; + entry.body = mutation === 'malformed' ? '' + : ``; + const {report} = await observe(f); + assert.equal(report.codex_observation, expected); + assert.equal(report.observation, 'PENDING_REVIEW'); + }); +} +test('Unconfigured Codex observer never trusts arbitrary receipts', async () => { + const {f} = codexFixture(); f.policy.codex_app_ids = []; + assert.equal((await observe(f)).report.codex_observation, 'NOT_CONFIGURED'); +}); +test('A malformed newer native receipt cannot revive an older clean receipt', async () => { + const {f, entry} = codexFixture(); + f.evidence.comments.push({...entry, id: 12, updated_at: 'invalid'}); + assert.equal((await observe(f)).report.codex_observation, 'UNKNOWN'); +}); for (const filename of ['.github/workflows/example.yml', 'tests/example.js', 'docs/acceptance-policy.md', 'AGENTS.md']) { test(`Sensitive scope is R2: ${filename}`, async () => { const f = fixture(); f.files[0].filename = filename; From d04264dd91d26cb18b881968816c1df1c3ecd57e Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Tue, 3 Nov 2026 23:59:58 +0800 Subject: [PATCH 3/3] fix: preserve unresolved review evidence Co-Authored-By: Codex --- .github/workflows/governance-acceptance.yml | 31 ++++++++------ AGENTS.md | 3 +- docs/governance-shadow.md | 3 ++ scripts/test-governance-shadow.cjs | 45 +++++++++++++++++++++ 4 files changed, 69 insertions(+), 13 deletions(-) diff --git a/.github/workflows/governance-acceptance.yml b/.github/workflows/governance-acceptance.yml index a6db255..5b84d16 100644 --- a/.github/workflows/governance-acceptance.yml +++ b/.github/workflows/governance-acceptance.yml @@ -123,6 +123,7 @@ jobs: || !Number.isFinite(Date.parse(entry.updated_at)))) return 'UNKNOWN'; candidates.sort((a, b) => Date.parse(b.updated_at || '') - Date.parse(a.updated_at || '') || b.id - a.id); if (!candidates.length) return 'NOT_FOUND'; + if ((candidates[0].body.match(/codex-review-state:v1/g) || []).length !== 1) return 'UNKNOWN'; const match = //.exec(candidates[0].body); if (!match) return 'UNKNOWN'; let review; @@ -163,20 +164,26 @@ jobs: candidates.sort((a,b) => b.id - a.id); return candidates[0]?.valid === true && candidates[0]?.decision === 'approve'; } - const latestReviews = new Map(); + const unresolvedByReviewer = new Map(); for (const review of [...evidence.reviews].sort((a,b) => a.id - b.id)) { - if (review.user?.type === 'User' && policy.reviewers.includes(review.user.login) - && review.user.login !== pr.user.login && review.commit_id === pr.head.sha) - latestReviews.set(review.user.login, review); + if (review.user?.type !== 'User' || !policy.reviewers.includes(review.user.login) + || review.user.login === pr.user.login || review.commit_id !== pr.head.sha + || review.state === 'DISMISSED') continue; + const reviewer = review.user.login; + if (review.state === 'CHANGES_REQUESTED') { + unresolvedByReviewer.set(reviewer, true); + } else if (review.state === 'APPROVED' && decision('review', [review], policy.reviewers)) { + unresolvedByReviewer.set(reviewer, false); + } else { + try { + const record = JSON.parse(review.body); + if (record.head === pr.head.sha && record.base === pr.base.sha && record.scope === scope + && record.policy === policyDigest && !findingsClear(record.findings)) + unresolvedByReviewer.set(reviewer, true); + } catch { /* A comment without a record cannot clear a change request. */ } + } } - const unresolved = [...latestReviews.values()].some(review => { - if (review.state === 'CHANGES_REQUESTED') return true; - try { - const record = JSON.parse(review.body); - return record.head === pr.head.sha && record.base === pr.base.sha && record.scope === scope - && record.policy === policyDigest && !findingsClear(record.findings); - } catch { return false; } - }); + const unresolved = [...unresolvedByReviewer.values()].some(Boolean); const reviewed = !unresolved && decision('review', evidence.reviews, policy.reviewers); const owned = decision('owner', evidence.comments, policy.owners); const currentEvidence = await readEvidence(); diff --git a/AGENTS.md b/AGENTS.md index b5e65b2..231776a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -65,7 +65,8 @@ Organization documentation rules are checked by `python scripts/check_style.py`, - Do not assume this file applies to other repositories; each repository carries its own `AGENTS.md` and `.github/engineering.md`. -- Do not add organization-wide automation or branch-protection policy here. +- Organization-wide automation or branch-protection policy requires an explicit + owner request and a reviewed change. - Do not weaken the style guide's rules on credentials, account identifiers or private infrastructure without a stated reason. diff --git a/docs/governance-shadow.md b/docs/governance-shadow.md index 44f2421..30db82e 100644 --- a/docs/governance-shadow.md +++ b/docs/governance-shadow.md @@ -39,6 +39,8 @@ Newer invalid or revoked bound records dominate older approvals. No label or provider availability supplies authorization. Default identity lists are empty, so R1/R2 observations remain pending until a separately reviewed source policy establishes identities. No identities or settings are provisioned by this pilot. +An unresolved change request survives comment-only reviews and can be cleared +only by a valid later approval from that reviewer or dismissal of the request. An optional `codex_app_ids` list enables a separate observation of the native Codex summary comment. The source must be a bot posted through a configured @@ -46,6 +48,7 @@ GitHub App and its review state must name the current head. The observation distinguishes running, stale, malformed and unresolved findings. An empty list leaves this observation unconfigured. This signal does not establish full coverage or base binding, does not accept a review, and never authorizes merge. +Multiple receipt blocks in one comment are ambiguous and reported as unknown. The final read compares the candidate and evidence with the initial reads. Failures produce UNKNOWN and a failed, non-required observation job. Reports use diff --git a/scripts/test-governance-shadow.cjs b/scripts/test-governance-shadow.cjs index 24ef322..dbce45d 100644 --- a/scripts/test-governance-shadow.cjs +++ b/scripts/test-governance-shadow.cjs @@ -84,6 +84,16 @@ test('A malformed newer native receipt cannot revive an older clean receipt', as f.evidence.comments.push({...entry, id: 12, updated_at: 'invalid'}); assert.equal((await observe(f)).report.codex_observation, 'UNKNOWN'); }); +for (const duplicate of ['unresolved', 'malformed']) { + test(`Duplicate Codex receipt blocks an ambiguous observation: ${duplicate}`, async () => { + const {f, entry, record} = codexFixture(); + record.code_review.has_unresolved_findings = true; + entry.body += duplicate === 'unresolved' + ? `\n` + : '\n'; + assert.equal((await observe(f)).report.codex_observation, 'UNKNOWN'); + }); +} for (const filename of ['.github/workflows/example.yml', 'tests/example.js', 'docs/acceptance-policy.md', 'AGENTS.md']) { test(`Sensitive scope is R2: ${filename}`, async () => { const f = fixture(); f.files[0].filename = filename; @@ -173,6 +183,41 @@ test('An unresolved concrete defect cannot be erased by another reviewer approva f.evidence.reviews.push({...f.evidence.reviews[0], id: 3, user: {login: '-other', type: 'User'}}); assert.equal((await observe(f)).report.observation, 'PENDING_REVIEW'); }); +test('A comment-only review does not clear a prior change request', async () => { + const {f, record} = await recordFixture(); + f.evidence.reviews.push({...f.evidence.reviews[0], id: 2, state: 'CHANGES_REQUESTED'}); + f.evidence.reviews.push({...f.evidence.reviews[0], id: 3, state: 'COMMENTED', body: 'Follow-up comment'}); + f.evidence.reviews.push({...f.evidence.reviews[0], id: 4, user: {login: '-other', type: 'User'}, + body: JSON.stringify(record)}); + const {report} = await observe(f); + assert.equal(report.independent_review, false); + assert.equal(report.observation, 'PENDING_REVIEW'); +}); +test('A valid later approval by the requesting reviewer clears the request', async () => { + const {f} = await recordFixture(); + f.evidence.reviews.push({...f.evidence.reviews[0], id: 2, state: 'CHANGES_REQUESTED'}); + f.evidence.reviews.push({...f.evidence.reviews[0], id: 3, state: 'COMMENTED', body: 'Follow-up'}); + f.evidence.reviews.push({...f.evidence.reviews[0], id: 4}); + const {report} = await observe(f); + assert.equal(report.independent_review, true); + assert.equal(report.observation, 'PENDING_OWNER'); +}); +test('A dismissed change request no longer blocks an independent approval', async () => { + const {f} = await recordFixture(); + f.evidence.reviews.push({...f.evidence.reviews[0], id: 2, state: 'DISMISSED'}); + f.evidence.reviews.push({...f.evidence.reviews[0], id: 3, + user: {login: '-other', type: 'User'}}); + assert.equal((await observe(f)).report.observation, 'PENDING_OWNER'); +}); +test('An expired approval cannot clear a prior change request', async () => { + const {f, record} = await recordFixture(); + f.evidence.reviews.push({...f.evidence.reviews[0], id: 2, state: 'CHANGES_REQUESTED'}); + record.expires_at = new Date(Date.now() - 1000).toISOString(); + f.evidence.reviews.push({...f.evidence.reviews[0], id: 3, body: JSON.stringify(record)}); + f.evidence.reviews.push({...f.evidence.reviews[0], id: 4, + user: {login: '-other', type: 'User'}}); + assert.equal((await observe(f)).report.observation, 'PENDING_REVIEW'); +}); test('Parallel observations do not share mutable decisions', async () => { const f = fixture(); f.apiError = true; const [bad, good] = await Promise.all([observe(f), observe()]);