diff --git a/docs/CURRENT-STATUS.md b/docs/CURRENT-STATUS.md index 607c4bf..7918995 100644 --- a/docs/CURRENT-STATUS.md +++ b/docs/CURRENT-STATUS.md @@ -163,3 +163,21 @@ policy are unchanged. The independently verified repository-patch run [39121692291](https://github.com/AyobamiH/agentproof/actions/runs/39121692291) remains evidence for `d89f6d54709830ff49373ab7c2797c927ced8ac9`. RC6 npm publication is unchanged; independent-installation credit remains zero. + + +## Published consequence; independent receipt pending — 18 October 2026 + +The current-main repository-patch canary [39124046639](https://github.com/AyobamiH/agentproof/actions/runs/39124046639) +passed all 17 checks at `74c12b4e42c8109d34e4764fa0e4864513cab4a9`. +Protected release run [39124373253](https://github.com/AyobamiH/agentproof-release-canary/actions/runs/39124373253) +then published the existing disposable release `416342082` once at `2026-10-18T11:43:37Z`. +The exact successful GitHub response and signed execution completion are retained. +Attempts 2 and 3 made zero publication requests and returned the identical completion. + +The separate receipt signer refused its public GitHub readback on all three attempts. +No AgentProof receipt, independent OpsTruth verdict, or DoneState VERIFIED outcome is +claimed. This change adds only bounded endpoint/status/rate-limit metadata to that +refusal. Upstream bodies, URLs, arbitrary headers and credentials are never returned. +The permanent fence, keys, original workflow identity and historical evidence remain +unchanged. Further recovery must diagnose the upstream refusal and may only observe +and sign the existing consequence; it must never publish again. diff --git a/services/release-provider/src/identity.mjs b/services/release-provider/src/identity.mjs index 2e86e81..c1e688d 100644 --- a/services/release-provider/src/identity.mjs +++ b/services/release-provider/src/identity.mjs @@ -3,6 +3,21 @@ import { createRemoteJWKSet, jwtVerify } from "jose"; export class ReleaseRefusal extends Error { constructor(code, status = 403) { super(code); this.code = code; this.status = status; } } +// Bounded public transport metadata; never include upstream bodies or credentials. +export class PublicReadbackRefusal extends ReleaseRefusal { + constructor(endpoint, response) { + super("public_readback_unavailable", 503); + const numberHeader = name => { + const value = response.headers.get(name); + return typeof value === "string" && /^\d{1,10}$/.test(value) ? Number(value) : null; + }; + if (!["repository", "tag", "release"].includes(endpoint)) throw new Error("readback_endpoint_invalid"); + this.readback = Object.freeze({ endpoint, upstreamStatus: response.status, + rateLimitRemaining: numberHeader("x-ratelimit-remaining"), + rateLimitReset: numberHeader("x-ratelimit-reset"), + retryAfterSeconds: numberHeader("retry-after") }); + } +} export function demand(condition, code, status = 403) { if (!condition) throw new ReleaseRefusal(code, status); } diff --git a/services/release-provider/src/policy.mjs b/services/release-provider/src/policy.mjs index ed6a557..127155a 100644 --- a/services/release-provider/src/policy.mjs +++ b/services/release-provider/src/policy.mjs @@ -6,7 +6,7 @@ import { GitHubReleaseV1Provider, canonicalGitHubPublishedAt } from "../../../di import { validateReleaseSubject, validateReleaseObservation, validateReleaseRequest, validateReleaseApproval, exactKeys, releaseAssert } from "../../../dist/release-v1-contract.js"; import { releasePayload, signReleaseReceiptV2 } from "../../../dist/release-v1-receipt.js"; -import { demand, ReleaseRefusal } from "./identity.mjs"; +import { demand, ReleaseRefusal, PublicReadbackRefusal } from "./identity.mjs"; import { recoveryPolicy } from "./recovery.mjs"; export const AUTHORITY_ISSUER = "agentproof.release-canary.authority.v1"; @@ -165,26 +165,29 @@ export function releaseSigner(env) { signCanonical: async input => sign(null, Buffer.from(input), key), }; } -async function anonymousGitHubJson(path) { +async function anonymousGitHubJson(path, endpoint) { const response = await fetch("https://api.github.com/repos/AyobamiH/agentproof-release-canary" + path, { method: "GET", redirect: "manual", cache: "no-store", headers: { accept: "application/vnd.github+json", "user-agent": "agentproof-separate-release-signer/1.0" }, signal: AbortSignal.timeout(12_000), }); - demand(response.ok && response.status === 200, "public_readback_unavailable", 503); + if (!response.ok || response.status !== 200) { + await response.body?.cancel().catch(() => {}); + throw new PublicReadbackRefusal(endpoint, response); + } const raw = await response.text(); demand(Buffer.byteLength(raw) <= 64 * 1024, "public_readback_oversized", 503); return strictBody(raw); } export async function publicReleaseReadback(subject) { - const repo = await anonymousGitHubJson(""); + const repo = await anonymousGitHubJson("", "repository"); demand(repo.id === subject.repositoryId && repo.owner?.id === subject.ownerId && repo.name === subject.repository && repo.owner.login === subject.owner && repo.private === false, "public_repo_identity_mismatch", 403); - const tag = await anonymousGitHubJson("/git/ref/tags/" + subject.tagName); + const tag = await anonymousGitHubJson("/git/ref/tags/" + subject.tagName, "tag"); demand(tag.ref === "refs/tags/" + subject.tagName && tag.object?.type === "commit" && tag.object.sha === subject.tagCommit, "public_tag_identity_mismatch", 403); - const release = await anonymousGitHubJson("/releases/" + subject.releaseId); + const release = await anonymousGitHubJson("/releases/" + subject.releaseId, "release"); demand(release.id === subject.releaseId && release.tag_name === subject.tagName && Array.isArray(release.assets) && release.assets.length === 2, "public_release_identity_mismatch", 403); const pkg = release.assets.find(a => a.id === subject.packageAssetId), diff --git a/services/release-provider/src/worker.mjs b/services/release-provider/src/worker.mjs index 71cfd93..2310869 100644 --- a/services/release-provider/src/worker.mjs +++ b/services/release-provider/src/worker.mjs @@ -1,7 +1,7 @@ import { recoveryPolicy, validateRecoveryGrant, validateRecordedRecoveryGrant } from "./recovery.mjs"; import { digest, stableJson } from "../../../dist/hash.js"; import { validateReleaseObservation } from "../../../dist/release-v1-contract.js"; -import { authenticate, githubToken, demand, ReleaseRefusal } from "./identity.mjs"; +import { authenticate, githubToken, demand, ReleaseRefusal, PublicReadbackRefusal } from "./identity.mjs"; import { strictBody, readTarget, privateKey, provider, validateSeed, decision, validateSignedRequest, signCompletion, validateCompletion, verifiedReceipt, targetScope, policy, reservationEvidence } from "./policy.mjs"; @@ -13,6 +13,8 @@ function reply(payload, status = 200) { }); } function failure(error) { + if (error instanceof PublicReadbackRefusal) + return reply({ error: error.code, readback: error.readback }, error.status); if (error instanceof ReleaseRefusal) return reply({ error: error.code }, error.status); if (error instanceof Error && error.message.startsWith("release_v1:")) { return reply({ error: "release_policy_rejected" }, 403); diff --git a/services/release-provider/test/provider.test.mjs b/services/release-provider/test/provider.test.mjs index b905fea..ae42849 100644 --- a/services/release-provider/test/provider.test.mjs +++ b/services/release-provider/test/provider.test.mjs @@ -332,3 +332,36 @@ test("GitHub redirects cannot reserve a release or satisfy independent signer re assert.equal(calls.length,1); } }); + + +test("signer readback refusal reports bounded upstream status without signing or disclosing bodies",async t=>{ + const api=githubFixture(t),authority=env("approval"); + const prepared=await call("approval",authority,"/v1/prepare",{seed:seed()}); + const executed=await call("approval",authority,"/v1/execute",{request:prepared.json.request}); + const normalFetch=globalThis.fetch; + let endpoint="repository",status=403,calls=0; + const paths={repository:"/repos/AyobamiH/agentproof-release-canary",tag:"/git/ref/tags/",release:"/releases/"}; + t.mock.method(globalThis,"fetch",async(input,init)=>{ + calls++;assert.equal(init.method,"GET");assert.equal(init.redirect,"manual"); + assert.equal(init.headers.authorization,undefined); + const path=new URL(String(input)).pathname; + if(endpoint==="repository"?path===paths.repository:path.includes(paths[endpoint])) + return new Response("private-upstream-body-must-not-escape",{status,headers:{ + "content-type":"text/plain",location:"https://untrusted.invalid/secret", + "x-ratelimit-remaining":"0","x-ratelimit-reset":"1792328000", + "retry-after":"secret-header-value"}}); + return normalFetch(input,init); + }); + for(endpoint of ["repository","tag","release"]) for(status of [301,403,429,503]) { + const signer=env("signing");calls=0; + const rejected=await call("signing",signer,"/v1/sign",{ + request:prepared.json.request,completion:executed.json.completion}); + assert.equal(rejected.status,503); + assert.deepEqual(rejected.json,{error:"public_readback_unavailable",readback:{ + endpoint,upstreamStatus:status,rateLimitRemaining:0,rateLimitReset:1792328000,retryAfterSeconds:null}}); + assert.equal(calls,["repository","tag","release"].indexOf(endpoint)+1); + assert.equal(signer.ledger.size,0);assert.equal(api.mutationCount,1); + assert(!JSON.stringify(rejected.json).includes("secret")); + assert(!JSON.stringify(rejected.json).includes("private-upstream")); + } +});