Repository navigation
runner-policy: reusable contracts cannot be scoped by repo visibility, so a sensitive input enabled for a private consumer is enabled for public ones too #277
Description
The gap
approvedReusableWorkflowContracts entries in components/runner-policy/policy.json are keyed by path@SHA and are shared by every governed consumer repository. The schema has no way to scope a contract to a repository or to a visibility class.
allowedInputs is a permission, not a requirement — listing an input lets any governed caller pass it; omitting it forbids every caller from passing it. There is no middle setting.
So a contract that legitimately enables a sensitive input for a private consumer simultaneously enables it for every public consumer at that SHA.
Why it is not currently biting
The concrete case is claude-review.yml's standards-ref input, which mounts melodic-software/standards into the review agent via a GitHub App token. Today:
- The contracts approved for
@e2951077(ci(runner-policy): approve claude-review and claude-security-review pin e295107 #275) omitstandards-refand bothSTANDARDS_REVIEW_APP_*secrets, so no governed caller can pass them — verified in the merged entries. - No consumer sets the input anywhere. All six callers of
claude-review.yml(standards,medley,provisioning,dotfiles,github-iac,claude-code-plugins) pass onlyrunnerandskip-actors. - The credential itself is unprovisioned (
distribution/REVIEW-CREDENTIAL.md: "Status: classified, not provisioned"), and docs(distribution): correct the review-credential visibility facts #276 records that its premise is void becausestandardsis public.
The gap is latent, not live. It becomes live the moment someone adds standards-ref to a contract "for the private consumers".
The invariant to record
No
claude-review/claude-security-reviewcontract may liststandards-refor theSTANDARDS_REVIEW_APP_*secrets while any governed caller repository is public — unless the contract schema first grows repository- or visibility-scoped entries.
Two public governed callers exist today: ci-workflows and claude-code-plugins, plus standards itself (verified live 2026-07-26; see #276, which corrects the stale classification that said otherwise).
Options
- Record the invariant only — a note in the runner-policy README or
distribution/THREAT-MODEL.md. Cheapest; relies on a human reading it at the moment they editpolicy.json. - Enforce it in the gate — teach
runner-policy.mjsto fail when a contract lists a denylisted input and the evaluating repository is public. It already knows the repository's visibility (CI_REPOSITORY_VISIBILITYis passed to the job), so this is a local check, not new plumbing. Stronger, because it fires at the point of use rather than the point of authorship. - Scope contracts by visibility in the schema — most general, most work, and unjustified until a second case exists.
Option 2 is the one that actually holds, since the failure mode is a future editor who does not read the note. Option 1 is worth doing regardless and immediately.
Trigger for revisiting
Any of: a private consumer genuinely needs the standards-ref mount; the schema gains repo- or visibility-scoped contracts; or REVIEW-CREDENTIAL.md is re-derived (per #276) into an actual provisioning plan.
Provenance
Surfaced during the security review of the @e2951077 contract approval (#275), as the one way that approval could go wrong later. The approval itself is sound — the entries omit the sensitive surface, which is exactly what makes the mount unpassable today.
Activity
kyle-sexton commentedon Jul 27, 2026
This was generated by AI during triage.
Claiming this issue for a triage pass: verifying the body's load-bearing claims against the repository, then posting an executable brief.
kyle-sexton commentedon Jul 27, 2026
This was generated by AI during triage.
Triage pass: every load-bearing claim in the body was checked against the repository. Five hold; claim 4 — the one that makes Option 2 cheap — is refuted as written, and the correction is security-relevant. The body's public-caller enumeration also over-counts by one. Details below; a cold agent can execute from this comment alone.
The invariant (corrected enumeration)
Public gate-executing repositories today (verified live 2026-07-27 via
gh api repos/melodic-software/<r> --jq .visibility):standards.github/workflows/ci.yml:198-200claude-code-pluginssync-manifest.yml:192ci-workflowssync-manifest.yml:168-179does not managerunner-policydotfiles,github-iac,medley,provisioningThe body names
ci-workflowsas a public governed caller. It is public, and it is a sync target, but it does not manage therunner-policycomponent, so the gate never evaluates it. It is the producer of these reusable workflows and calls them locally. Two public repos execute the gate, not three.Verified-claims table
path@SHA, shared by every consumer, no repo/visibility scoping in the schemapolicy.schema.json:49-52—propertyNames: reusableWorkflowReference,additionalProperties: reusableWorkflowContract. Both contract variants areadditionalProperties: false(:182,:200), so no scoping field is even expressible. Keys are barepath@SHA(policy.json:447,:460). Onepolicy.jsonis materialized to all five targets (sync-manifest.yml:138).allowedInputsis a permission, not a requirementrunner-policy.mjs:1005—Object.keys(inputs).filter((name) => !contract.allowedInputs.has(name))rejects only inputs present but unlisted. Nothing requires a listed input. Sole exception:runnerInputis separately required at:1015, andvalidatePolicyforcesallowedInputsto contain it at:169.@e2951077contracts omitstandards-refand bothSTANDARDS_REVIEW_APP_*policy.json:447-459—allowedInputs: ["runner","skip-actors"],allowedSecrets=CLAUDE_CODE_OAUTH_TOKENonly.policy.json:460-472(claude-security-review) adds onlypaths.CI_REPOSITORY_VISIBILITY, so the check is local, not new plumbingREVIEW-CREDENTIAL.mdsays "classified, not provisioned"distribution/REVIEW-CREDENTIAL.md:15, but the line continues"— re-derived 2026-07-26 as not currently needed", and:19-20adds "under the current visibility state there is nothing to provision".runnerandskip-actorsci-workflows/.github/workflows/claude-review-self.yml:31via a local./ref with nowith:block at all.claude-review.yml@grep misses it.Claim 6 per-caller evidence
All under
D:/repos/github.com/melodic-software/, all passing exactlyrunner+skip-actors, secretsCLAUDE_CODE_OAUTH_TOKENonly, all clones verified present and clean onmain:standards/.github/workflows/claude-review.yml:29,medley/…:60,provisioning/…:52,dotfiles/…:46,github-iac/…:53,claude-code-plugins/…:25. Five are pinned@90f1c54935203fa31b5b3d1f41531228be2c2b7f;claude-code-pluginsalone is on@e2951077a7b43c09fc5a8dee4da52ba6f0fb39ed. Nothing could not be verified. (Suffixed worktree directories were excluded — a caller change could be pending in one.)The adjacent fact that matters most, which the body does not state:
standards-ref(ci-workflows/.github/workflows/claude-review.yml:117,type: string,default: "") and bothSTANDARDS_REVIEW_APP_ID(:139) /STANDARDS_REVIEW_APP_PRIVATE_KEY(:146) already exist in the reusable workflow'sworkflow_callinterface — byte-identically at both pinned SHAs. The mount steps are gated at:227,:241,:253oninputs.standards-ref != ''. The surface is live in the workflow; thepolicy.jsoncontract is the only thing standing between it and use. That is the strongest argument for Option 2 over Option 1 — Option 1 leaves a live workflow surface guarded by a note.Claim 4 — the critical finding
Is the visibility value available at the input-validation site? No.
repositoryVisibilityenters atrunner-policy.mjs:3524(values["repository-visibility"] ?? environment.CI_REPOSITORY_VISIBILITY) and is consumed at exactly one place::3135-3142, guarded byif (repositoryVisibility)— an optional consistency check againstconfig.visibility, nothing more.runner-policy.mjs:1005, insidereusableWorkflowStatus(job, policy, workflow)(:964, called at:2248and:2757). Itspolicyargument is assembled at:3145-3153and carriesrepositoryOwnerandapprovedSelectorReferences— not visibility.grep 'policy\.visibility'over the file returns zero hits. Neitherconfignor the env var is in scope there.auditRepositorybody:config.visibility, required byrepository-policy.schema.json:7withenum ["public","private"]at:14, already read at:3212and:3382.Cost verdict: Option 2's cost magnitude survives, its stated mechanism does not. Threading
visibility: config.visibilityonto the policy object at:3145exactly mirrors howrepositoryOwneris already threaded — roughly a one-line change. So the option is still cheap. But the difference is not cosmetic:Empirically every consumer sets it today —
claude-code-plugins/.github/workflows/ci.yml:916,dotfiles/…/ci.yml:343,github-iac/…/ci.yml:355,medley/…/ci-status.yml:492,provisioning/…/ci.yml:368,standards/…/ci.yml:200. But no workflow is in the sync manifest (sync-manifest.yml:135-141lists only the two schemas,policy.json,runner-policy.mjs,package.json,package-lock.json). Each consumer owns its own enforcement step and its own env wiring, so nothing structurally guarantees the variable is passed — a new consumer or a local edit can silently drop it.The three options
components/runner-policy/README.mdor aTHREAT-MODEL.mdworkflow_callsurface guarded by prose.runner-policy.mjsfails when a contract lists a denylisted input/secret and the evaluating repository is publicadditionalProperties: false(policy.schema.json:182,:200), so a new field plus validator plus migrationapprovedSelectorReferencesByRepositoryOwner(runner-policy.mjs:3151) already owner-scopes selector references — but not contracts. Unjustified until a second case exists.Option 1 is settled — do it regardless, immediately, and it needs no decision.
Option 2 is directionally settled but its mechanism is an open decision, because claim 4 was wrong about where the value comes from.
config.visibilityis consumer-declared in that repo's own.github/runner-policy.json; the env-var cross-check at:3139is the only thing tying it to reality, and it is optional. A public repo that declaresvisibility: "private"and omits the variable bypasses aconfig.visibility-based gate entirely.Exact files a fix would touch
components/runner-policy/runner-policy.mjsauditRepositorypolicy assembly,:3145-3153— addvisibility: config.visibilitybesiderepositoryOwner.reusableWorkflowStatus,:964— new check adjacent to theallowedInputsfilter at:1005. The check must fire on the contract's own listing, not on the caller'swith:block, so it catches the surface at approval-effective time even while no caller passes it. Checking onlyjob.withwould leave the gap wide open.validatePolicy(:155-235) if the denylist should reject at policy-load time; note that path has no visibility context, so it can only be an unconditional ban, not a visibility-scoped one.standards-ref,STANDARDS_REVIEW_APP_ID,STANDARDS_REVIEW_APP_PRIVATE_KEY.components/runner-policy/runner-policy.test.mjs— new cases (below).components/runner-policy/README.mdand/orcomponents/runner-policy/THREAT-MODEL.md;distribution/THREAT-MODEL.mdif the invariant belongs at distribution scope.components/runner-policy/policy.schema.json— only under Option 3.policy.jsoncontract entries — the@e2951077approval is correct as merged.Test strategy
Real command, verified in
package.json:27:npm run test:runner-policy→node --test components/runner-policy/runner-policy.test.mjs. Self-check on this repo:npm run lint:runner-policy(package.json:22) →node components/runner-policy/runner-policy.mjs --root ..Existing harness to reuse: fixture helper
repository({ visibility, ... })atrunner-policy.test.mjs:105(writes.github/runner-policy.json, defaultsvisibility = "private"at:107); wrapperaudit(root, options)at:168. Nearest precedent test:"GitHub visibility evidence must agree with governed inventory"at:1037.Cases that must be covered:
audit()called withoutrepositoryVisibilityagainst a contract listing a denylisted entry must fail closed with a distinct reason naming the missing evidence — and the same call against a clean contract must stay green.@e2951077entries plus all seven real callers stay green.Failure-mode warning
runner-policy.mjsis the CI gate every governed consumer runs on every pull request. An over-broad check fails theEnforce runner policystep in all five consumers plusstandards, blocking every PR fleet-wide — and becausepolicy.jsonis a single shared file, a check that trips on any one contract entry trips for every repository regardless of which one motivated it. Recovery is not instant: the correctedrunner-policy.mjshas to travel back out through astandards-syncPR per consumer.Staging a fix, in order:
npm run test:runner-policygreen.standardsitself:npm run lint:runner-policywithCI_REPOSITORY_VISIBILITY=public(public path).runner-policy.mjs --root <path>against at least one private consumer clone (dotfiles,github-iac,medley, orprovisioning) and confirm no new finding.Blast radius
runner-policyis a sync-managed distributed component (distribution/sync-manifest.yml:132-141).runner-policy.mjs(:141),policy.json(:138), and both schemas (:139-140) are managed files, materialized to.github/standards/runner-policy/in five targets —claude-code-plugins(:192),dotfiles(:214),github-iac(:231),medley(:238),provisioning(:257). Per the repo's own convention these materializations are upstream-owned — the change belongs here, never in a consumer's copy.One asymmetry worth flagging:
runner-policy.test.mjsis not in the manifest. Consumers receive gate code with no test materialization, so upstream tests are the only proof the gate is correct before it ships.Related
#276is a merged PR, not an open issue —docs(distribution): correct the review-credential visibility facts. It establishes thatstandardsandclaude-code-pluginsare public (correcting a "confirmed firsthand" private classification), voids the credential's stated premise, and records that the proposed org-secret visibility lists would have made bothSTANDARDS_REVIEW_APP_*secrets resolvable in public-repo workflow runs. It deliberately leaves the superseded scope lists unedited so a re-derivation sees the original proposal. It is resolved; nothing in it is pending.Out of scope
REVIEW-CREDENTIAL.md:15).ci-workflows'standards-refimplementation.policy.jsoncontract entries.Acceptance criteria
ci-workflowsis public but not gate-governed).standards-ref/STANDARDS_REVIEW_APP_*in a contract when the evaluating repository is public, readingconfig.visibility— notCI_REPOSITORY_VISIBILITYdirectly.with:block.npm run test:runner-policygreen;npm run lint:runner-policygreen onstandards.Triage disposition
Labels applied:
priority: medium,area: security,work-class: structural,needs-human,status: needs-decision.Priority is medium, not high, and the body's own "latent, not live" framing is the reason — with the one correction that the latency is thinner than described. No caller passes the surface, no contract lists it, and the credential is re-derived as unneeded; but the input and both secrets do exist in the reusable workflow at both pinned SHAs, so the only thing holding the gap shut is a
policy.jsonentry that a single well-intentioned edit could widen. Escalate topriority: highon any of the body's triggers: a private consumer genuinely needing the mount, the schema gaining scoped contracts, orREVIEW-CREDENTIAL.mdbeing re-derived into an actual provisioning plan.needs-humanandstatus: needs-decisionbecause the enforcement mechanism is unresolved on a security invariant, andwork-class: structuralbecause the change lands in a distributed gate that five consumer repositories execute on every PR — a false positive blocks CI fleet-wide, a false negative leaves a credential-exposure path open.kyle-sexton commentedon Aug 11, 2026
Frontier consensus (Fable + Sol) — ACCEPT
Decision: Fail closed when
CI_REPOSITORY_VISIBILITYis absent for denylisted sensitive inputs; ship Option 1 docs in the same PR wave.Implementing in-session.
🤖
kyle-sexton commentedon Aug 11, 2026
Lease claim for in-session drain. Implementing now. 🤖
runner-policy: enforce visibility-scoped Claude lane contract invariant
runner-policy: enforce visibility-scoped Claude lane contract invariant