diff --git a/.github/workflows/governance-read-preflight.yml b/.github/workflows/governance-read-preflight.yml new file mode 100644 index 00000000..db1f40b4 --- /dev/null +++ b/.github/workflows/governance-read-preflight.yml @@ -0,0 +1,46 @@ +name: Governance Read-only Credential Preflight + +on: + pull_request: + paths: + - .github/workflows/governance-read-preflight.yml + - scripts/release/governance-read-preflight.cjs + - tests/release/governance-read-preflight.test.js + push: + branches: [main] + paths: + - .github/workflows/governance-read-preflight.yml + workflow_dispatch: + +permissions: + contents: read + +jobs: + contract-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: false + - uses: actions/setup-node@v4 + with: + node-version: '24.19.0' + - run: node --test tests/release/governance-read-preflight.test.js + + verify-private-read: + if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' + needs: contract-tests + runs-on: ubuntu-latest + timeout-minutes: 3 + steps: + - uses: actions/checkout@v4 + with: + submodules: false + - uses: actions/setup-node@v4 + with: + node-version: '24.19.0' + - name: Probe private governance read access without displaying credential + env: + CORTEX_GOVERNANCE_READ_TOKEN: ${{ secrets.CORTEX_GOVERNANCE_READ_TOKEN }} + CORTEX_AUTO_RELEASE_ENABLED: ${{ vars.CORTEX_AUTO_RELEASE_ENABLED }} + run: node scripts/release/governance-read-preflight.cjs diff --git a/scripts/release/governance-read-preflight.cjs b/scripts/release/governance-read-preflight.cjs new file mode 100644 index 00000000..a6638397 --- /dev/null +++ b/scripts/release/governance-read-preflight.cjs @@ -0,0 +1,46 @@ +"use strict"; +const records = [ + ["decisions/D-release-1.15.0-eca99a7c.json","decision_id","D-release-1.15.0-eca99a7c"], + ["waitpoints/WP-release-1.15.0-eca99a7c.json","waitpoint_id","WP-release-1.15.0-eca99a7c"] +]; +class PreflightError extends Error { + constructor(code){ super("GOVERNANCE_READ_PREFLIGHT_BLOCKED: "+code); this.code=code; } +} +async function probe(token, requester=fetch) { + if (!token || typeof token !== "string") throw new PreflightError("SECRET_MISSING"); + for (const [file,field,expected] of records) { + let response; + try { + response = await requester("https://api.github.com/repos/Kucell/cortex-agent-agent/contents/"+file+"?ref=main",{ + headers:{ + Authorization:"Bearer "+token, + Accept:"application/vnd.github+json", + "X-GitHub-Api-Version":"2022-11-28" + }, + redirect:"error", + signal:AbortSignal.timeout(10000) + }); + } catch (_) { throw new PreflightError("PRIVATE_API_UNAVAILABLE"); } + if (!response || !response.ok) throw new PreflightError("PRIVATE_CONTENT_READ_DENIED"); + let data; + try { + const payload=await response.json(); + if (payload.type!=="file" || payload.encoding!=="base64" || typeof payload.content!=="string") throw Error(); + data=JSON.parse(Buffer.from(payload.content,"base64").toString("utf8")); + } catch (_) { throw new PreflightError("PRIVATE_CONTENT_UNREADABLE"); } + if (!data || data[field]!==expected) throw new PreflightError("RECORD_ID_MISMATCH"); + } + return {read_only_access_verified:true,record_count:records.length}; +} +async function main() { + if (process.env.GITHUB_REPOSITORY!=="Kucell/cortex-agent" || + process.env.GITHUB_REF!=="refs/heads/main") throw new PreflightError("MAIN_REQUIRED"); + if (process.env.CORTEX_AUTO_RELEASE_ENABLED!=="false") throw new PreflightError("AUTO_PUBLISH_MUST_REMAIN_DISABLED"); + const result=await probe(process.env.CORTEX_GOVERNANCE_READ_TOKEN); + console.log("Private governance read access verified for "+result.record_count+" records; auto publish remains disabled."); +} +if(require.main===module) main().catch(e=>{ + console.error(e instanceof PreflightError?e.message:"GOVERNANCE_READ_PREFLIGHT_BLOCKED: UNKNOWN"); + process.exitCode=1; +}); +module.exports={probe,PreflightError}; diff --git a/tests/release/governance-read-preflight.test.js b/tests/release/governance-read-preflight.test.js new file mode 100644 index 00000000..5b549a32 --- /dev/null +++ b/tests/release/governance-read-preflight.test.js @@ -0,0 +1,39 @@ +"use strict"; +const test=require("node:test"); +const assert=require("node:assert/strict"); +const {probe}=require("../../scripts/release/governance-read-preflight.cjs"); +const docs=[ + {decision_id:"D-release-1.15.0-eca99a7c"}, + {waitpoint_id:"WP-release-1.15.0-eca99a7c"} +]; +const response=(record)=>({ok:true,async json(){return {type:"file",encoding:"base64",content:Buffer.from(JSON.stringify(record)).toString("base64")}}}); +test("reads expected immutable historical records without leaking content",async()=>{ + let index=0; + const result=await probe("FAKE_TOKEN",async(url,opts)=>{ + assert.match(url,/https:\/\/api\.github\.com\/repos\/Kucell\/cortex-agent-agent\/contents\//); + assert.equal(opts.redirect,"error"); + assert.equal(opts.headers.Authorization,"Bearer FAKE_TOKEN"); + return response(docs[index++]); + }); + assert.equal(index,2); + assert.deepEqual(result,{read_only_access_verified:true,record_count:2}); + assert.doesNotMatch(JSON.stringify(result),/FAKE_TOKEN|D-release-/); +}); +test("missing secret fails closed",async()=>{ + await assert.rejects(()=>probe(""),/SECRET_MISSING/); +}); +test("missing authorization fails closed",async()=>{ + for(const status of [401,403,404]){ + await assert.rejects(()=>probe("FAKE",async()=>({ok:false,status})),/PRIVATE_CONTENT_READ_DENIED/); + } +}); +test("invalid or mismatched record fails closed",async()=>{ + await assert.rejects(()=>probe("FAKE",async()=>response({decision_id:"WRONG"})),/RECORD_ID_MISMATCH/); + await assert.rejects(()=>probe("FAKE",async()=>({ok:true,async json(){return {type:"dir"}}})),/PRIVATE_CONTENT_UNREADABLE/); +}); +test("network error cannot reveal token",async()=>{ + let error; + try{await probe("SECRET_HIDDEN",async()=>{throw Error("SECRET_HIDDEN");});}catch(e){error=e;} + assert.equal(error.code,"PRIVATE_API_UNAVAILABLE"); + assert.doesNotMatch(error.message,/SECRET_HIDDEN/); +});