feat(security): guard Actions-secret expiry with a committed record - #129
Merged
Conversation
The skills sync depends on one fine-grained PAT, ORG_PAT, expiring 2027-08-11. That date lived only in prose and in closed core#15, so nothing in the repository noticed the window opening. Add .github/secrets-expiry.json (closed, credential-free schema; 60-day lead time, 30-day floor, never_relax), scripts/check-secret-expiry.mjs plus scripts/lib/secret-expiry.mjs, and a daily .github/workflows/secrets-expiry.yml that fails once a declared secret is inside its rotation window or past expiry. The guard reads only the record and a reference day — never the secret — and stays outside verify/check so a pending rotation cannot block unrelated pull requests. Record the decision as ADR-0045, index it, rewrite the design doc's rotation section to cite the record instead of core#15, resolve plan 0001's open question, and add the AGENTS.md convention. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UigLsmd17TVDuaaSN4yfdU
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
ORG_PAT— the one fine-grained PAT the skills sync depends on — expires2027-08-11. Until now that date lived only in a sentence in
docs/design/skills-sync-operations.mdand incore#15, which is closed.
Neither can fail, so nothing in the repository would notice the window
opening; the sync would just start failing consumer clones with
Resource not accessible by personal access token.This adds a credential-free, repository-enforced pre-expiry guard:
.github/secrets-expiry.json— the canonical record. Closed schema(unknown fields are rejected, so there is no field a token value could live
in),
never_relax: true, and one entry per secret withexpires_on,warn_days_before,owner,used_by, androtation_runbook.ORG_PATdeclares
2027-08-11with a 60-day lead time.scripts/check-secret-expiry.mjs+scripts/lib/secret-expiry.mjs—validates the record (30-day floor on
warn_days_before,never_relaxmuststay true) and classifies each secret against a reference day:
ok,expiring(days_remaining <= warn_days_before),expired. Anything butokexits non-zero naming the secret, the date, and the runbook. Its onlyinputs are the record and the day — it never reads, resolves, fingerprints,
or prints the secret, and never touches
process.envfor one..github/workflows/secrets-expiry.yml— runs the guard daily (06:41 UTC)and on dispatch.
permissions: {}at the workflow,contents: readon thejob, SHA-pinned actions,
persist-credentials: false(ADR-0021). A failingscheduled run is the notification.
make verify/make ci. An approaching rotation isan operations task, not a defect in the PR under review; blocking every merge
for 60 days is how guardrails get bypassed. What is in the PR gate is
test/secret-expiry.test.mjs, which pins the behaviour by injecting dates.rotation" section that cites the record instead of core#15 (plus a recovery
table row); plan 0001's open question is resolved to the ADR;
AGENTS.mdgains the convention.
Rotation stays a human act: replace the secret in repository settings, then
update
expires_onin the record in the same PR.Closes nothing on GitHub; supersedes closed core#15 as the tracking mechanism.
Risk tier
Risk tier: high — touches two review-required protected boundaries in
policies/agent-governance.json:workflow-and-permissions(new.github/workflows/secrets-expiry.yml) andquality-gates(scripts/lib/**,test/**). Scale per that policy'srisk_classification(low/moderate/high/critical, highest-applicable); ADR-0019. No production
behaviour changes and no existing gate is relaxed —
never_relaxguardrailsare added, not loosened.
Docs housekeeping
TEMPLATE.md— ADR-0045 followsdocs/adr/TEMPLATE.mddocs/README.mddesign doc Rationale/Executable-contracts → ADR-0045; plan 0001
References → ADR-0045
ADR-0045
instead (
AGENTS.md, not itsCLAUDE.md/GEMINI.md/CONTRIBUTING.mdsymlinks)
Verification
make verifygreen (exit 0) on the committed tree:node scripts/check-docs.mjsgreen — the three lines above are itsoutput (run inside
make verify)npm ci && npm testin.agents/skills/frostyard-docs-site/scaffold/:# vX.Y.Z),permissions: {},persist-credentials: false(ADR-0021) —actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2,jdx/mise-action@3c2e0cf82a5b2e5249f0d3635a4d83d0ae861518 # v4.2.5,the same pins
ci.ymlalready uses.actionlintis not installed onthis host; the workflow was parsed with
js-yamlinstead and theresulting object was inspected (schedule, permissions, job, pins).
The guard's own boundaries, demonstrated against the committed record:
test/secret-expiry.test.mjs(12 cases, included in the 51 above) derivesthose three dates from the record itself, so they stay correct if the lead
time changes; it also pins the exact window boundaries, the schema floors,
that an unknown field such as
valueis rejected, and that the guard's outputcontains no canary token placed in
ORG_PAT/GH_TOKENin its environment.Also verified the workflow's
npm run check:secret-expirystep works with nonode_modulespresent (the script is dependency-free, so the job needs nonpm ciand no registry access).