Skip to content

feat(runner-policy): auto-approve Dependabot SHA bumps with an unchanged security surface - #119

Merged
kyle-sexton merged 21 commits into
mainfrom
feat/runner-policy-dependabot-autoapprove
Jul 17, 2026
Merged

feat(runner-policy): auto-approve Dependabot SHA bumps with an unchanged security surface#119
kyle-sexton merged 21 commits into
mainfrom
feat/runner-policy-dependabot-autoapprove

Conversation

@kyle-sexton

@kyle-sexton kyle-sexton commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

Problem

Runner policy correctly requires an explicitly reviewed contract for each reusable-workflow path and immutable SHA, but that made every Dependabot SHA update fail even when the already-reviewed workflow source retained the same security and execution surface.

Implementation

The analyzer now considers a new SHA only when the same workflow path already has a reviewed contract. It fetches both immutable revisions and compares a canonical, fail-closed surface before inheriting the reviewed contract with provenance.

The comparison covers:

  • whether on.workflow_call remains declared and structurally valid;
  • declared inputs and secrets;
  • workflow permissions and every job's effective permissions, distinguishing omission from an explicit empty map;
  • job routing and execution boundaries, including runs-on, reusable-job uses, container, services, and environment;
  • recursively canonicalized mappings and arrays so key order is irrelevant without erasing semantic differences.

Any incomplete basis fetch/parse/validation evidence, schema or declaration change, permission or routing change, or ambiguity among matching reviewed contracts declines automatic approval. There is no same-organization trust exception. disableAutoApproval and CI_RUNNER_POLICY_DISABLE_AUTO_APPROVAL preserve the explicit-contract-only mode and make no network request.

Distribution

Runner policy remains Standards-managed and will reach enrolled consumers only through the existing generated sync process. This PR does not hand-copy managed policy into downstream repositories.

Verification

  • Runner-policy tests: 153 passed, 0 failed, including adversarial omission-versus-empty permissions, removed or malformed workflow_call, nested execution boundaries, changed inputs/secrets/permissions/routing, fetch and parse failures, and both disable switches.
  • Repository self-audit passes with no findings.
  • Biome, Markdownlint, diff check, and Lefthook pre-commit gates pass.

Authoritative basis

…ged security surface

Dependabot bumps the SHA of an already-reviewed reusable workflow with no
change to its own commit history in policy.json, so every bump fails closed
with "no reviewed runner-input contract" until a maintainer manually adds a
new contract entry -- confirmed recurring 5x on one PR in
melodic-software/provisioning#72, plus hits in claude-code-plugins, standards,
and github-iac.

Before per-job checks run, resolveAutoApprovedContracts() now looks for
path@SHA candidates that share a workflow path with an already-approved
contract but have no contract of their own. For each, it fetches the
previously reviewed revision and the candidate revision from the source
repository (raw.githubusercontent.com, no token needed since ci-workflows is
public) and structurally diffs permissions and
on.workflow_call.inputs/secrets. A byte-identical surface auto-approves the
candidate under the reviewed contract, stamped with autoApproved: { basisSha,
approvedAt } provenance. Any fetch failure, parse failure, or surface change
still fails closed, with the declined reason folded into the existing
diagnostic instead of a bare "no reviewed runner-input contract".

This is a deterministic structural comparison, not an LLM judgment call, and
grants no blanket trust to a source repository: every other file in the
called workflow, and any change to permissions/inputs/secrets themselves,
still requires a human to add a new contract entry. Set disableAutoApproval:
true (or CI_RUNNER_POLICY_DISABLE_AUTO_APPROVAL=true in CI) to restore
today's behavior and require an explicit contract for every SHA.

Also hardened the test suite's shared audit() helper to default to a
hermetic fetch stub -- without it, the existing "obsolete reusable workflow
SHA" test (whose fixture shares a workflow path with an approved contract)
silently made a live network call to raw.githubusercontent.com on every run.

Updates the threat model: the analyzer's "does not fetch external bytes"
claim no longer holds, so the data flow, trust boundaries, threats table, and
residual risk sections now describe the new fetch-and-diff step and its
bounded blast radius.

Verification: 118/118 components/runner-policy tests pass (112 existing + 6
new covering identical-surface auto-approval, changed-permissions and
changed-inputs decline paths, a fetch-failure decline, the disableAutoApproval
option, and the CI_RUNNER_POLICY_DISABLE_AUTO_APPROVAL env var). Confirmed
via instrumented fetch that this repository's own "Enforce runner policy on
standards" CI self-audit makes zero network calls today (no candidates exist
until the next Dependabot bump). lefthook pre-commit (typos, editorconfig,
gitleaks, biome, markdownlint) all pass.

Propagation: runner-policy is a managed component per
distribution/sync-manifest.yml. Consumer repositories receive this fix
through the existing sync mechanism once this merges; this PR does not touch
consumer repositories directly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qmz53fqQcp7BqStweHH41H

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d1c93e8355

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread components/runner-policy/runner-policy.mjs
Comment thread components/runner-policy/runner-policy.mjs
Resolves conflicts in runner-policy.mjs/test.mjs: main rewrote
routeStatus (required repository-local runner inputs, unroutable
failure sentinel) after this branch's tip; this branch's routeStatus
was untouched, so main's version is kept in full alongside this
branch's new Dependabot auto-approval functions
(resolveAutoApprovedContracts, reusableWorkflowSecuritySurface, etc).
@claude

claude Bot commented Jul 15, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 1s —— View job


I'll analyze this and get back to you.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8450e02804

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread components/runner-policy/runner-policy.mjs
Comment thread components/runner-policy/runner-policy.mjs Outdated
…in the auto-approval security surface

The Dependabot auto-approval diff (resolveAutoApprovedContracts) only
compared workflow-level permissions plus workflow_call inputs/secrets
between the previously reviewed basis SHA and the candidate SHA. Two
P1 review findings (chatgpt-codex-connector) identified gaps that let
a security-relevant change through as auto-approved:

- A job-level `permissions:` block can grant more than the
  workflow-level default; only the workflow-level block was compared.
  jobPermissionsSurface now captures each job's effective permissions
  (job.permissions ?? workflow.permissions, via the existing
  effectivePermissions helper) and includes it in the diffed surface.

- The reviewed reusable-workflow contract this feature auto-approves
  is specifically a routing contract (runner-input or hosted-only),
  but the diff ignored jobs.*.runs-on entirely. A candidate SHA could
  keep the same inputs/secrets/permissions while flipping runs-on
  (e.g. `${{ inputs.runner }}` -> `self-hosted`) and still be
  auto-approved. jobRoutingSurface now captures each job's runs-on and
  strategy (matrix values can change a matrix-driven runs-on's
  resolved target without changing the runs-on literal) and includes
  it in the diffed surface.

Both additions extend securitySurfaceDiffField's compared keys, so any
difference in either surface declines auto-approval with a specific
diagnostic naming the changed field, same as the existing
permissions/inputs/secrets diagnostics.
@claude

claude Bot commented Jul 15, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 0s —— View job


I'll analyze this and get back to you.

Preserve GitHub's distinction between omitted permissions and an explicit empty mapping at both workflow and effective job scope. Require the fetched candidate to retain a valid workflow_call trigger, and extend the compared runner boundary to nested reusable calls, containers, services, and deployment environments.

Add adversarial regression coverage and align the component documentation and threat model with the enforced structural contract.
@claude

claude Bot commented Jul 15, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 1s —— View job


I'll analyze this and get back to you.

@kyle-sexton

Copy link
Copy Markdown
Contributor Author

@codex review

@claude

claude Bot commented Jul 15, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 1s —— View job


I'll analyze this and get back to you.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7b1f10493d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread components/runner-policy/runner-policy.mjs Outdated
@kyle-sexton

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7b1f10493d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread components/runner-policy/runner-policy.mjs Outdated
@claude

claude Bot commented Jul 15, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 1s —— View job


I'll analyze this and get back to you.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: dd30e28cc1

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread components/runner-policy/runner-policy.mjs Outdated
@claude

claude Bot commented Jul 15, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 1s —— View job


I'll analyze this and get back to you.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 898087b2d0

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread components/runner-policy/runner-policy.mjs
@claude

claude Bot commented Jul 15, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 1s —— View job


I'll analyze this and get back to you.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: bff65f2b2a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread components/runner-policy/runner-policy.mjs
…oval surface

A Dependabot SHA bump of a reviewed reusable workflow was auto-approved
by diffing only permissions, workflow_call, and job routing/boundaries
between the previously reviewed and candidate revisions. The fetched
candidate's job steps/env were never checked, so a bump could add a
localCredentialActions entry (e.g. actions/create-github-app-token) or
an unapproved credential expression to a called job while leaving that
compared surface unchanged, and the job would inherit the old
self-hosted contract without ever passing the privileged-hosted
credential check already enforced against every directly declared or
repository-local job.

Extend the auto-approval security surface with a credentials dimension
that runs the existing privilegedHostedRequirement() against each job
in the fetched reusable workflow, the same detection already trusted
for direct/local jobs. Update THREAT-MODEL.md and README.md so the
documented compared surface matches the code.

Verified the regression test fails without the fix (auto-approval
silently accepts the credential-action bump) and passes with it.
@claude

claude Bot commented Jul 15, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 1s —— View job


I'll analyze this and get back to you.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 31d1349809

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread components/runner-policy/runner-policy.mjs
jobPermissionsSurface, jobRoutingSurface, and jobCredentialSurface each
filtered out a job whose value was not a mapping (e.g. jobs.extra: []
or a scalar), the same shape auditRepository rejects locally as
job-shape, before comparing surfaces. Filtering kept those surfaces
from ever calling privilegedHostedRequirement with a malformed job,
but it also made the malformed job invisible to the diff: a bumped SHA
could add one without changing anything the compared surface
inspected, so the candidate could still match the reviewed basis and
inherit its contract, only to fail later when GitHub actually
validated the called workflow.

Add malformedJobIds() and check both the candidate and every reviewed
basis for a malformed job before their per-job surfaces are computed
or diffed. A malformed candidate job declines auto-approval with a
diagnostic identifying the job; a malformed basis job is treated the
same as an unparseable basis, folding into the existing "could not be
fetched, parsed, or validated" failure path so it fails closed instead
of silently matching.

Verified both regression tests fail without the fix (a malformed
candidate or basis job is silently auto-approved) and pass with it.
Validation passed: runner-policy 156/156, repository self-audit,
Biome, and Lefthook validation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@claude

claude Bot commented Jul 15, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 0s —— View job


I'll analyze this and get back to you.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5107550f76

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread components/runner-policy/runner-policy.mjs
Comment thread components/runner-policy/runner-policy.mjs
…ir category

jobCredentialSurface recorded only privilegedHostedRequirement()'s
category for each job in a fetched reusable workflow (e.g. "an
unapproved or transformed credential expression"), never the
credential-bearing value itself. A candidate SHA that swapped an
already-declared/allowed secret for a different secret in the
identical env/with position tripped the same category and produced an
identical requirement object, so the coarse comparison alone let the
candidate silently inherit the previously reviewed contract even
though the actual credential referenced had changed.

Add credentialReferences to the auto-approval security surface:
jobCredentialReferenceSurface() mirrors localCredentialRequirement's
own traversal (workflow-level env, job condition, job fields outside
steps, then each step's condition, non-credential fields, env, and
with), but instead of stopping at the first credential-bearing value
and returning a category, it records every credential-bearing value's
own normalized text per job. Include the new field in
securitySurfaceDiffField so a same-category, different-secret change
is a visible diff. Update THREAT-MODEL.md and README.md so the
documented compared surface matches the code.

Verified the regression test fails without the fix (auto-approval
silently accepts the swapped-secret bump, producing no finding at all)
and passes with it. Full suite: runner-policy 158/158,
lint:runner-policy against the repository itself, and lint:md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@claude

claude Bot commented Jul 16, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 0s —— View job


I'll analyze this and get back to you.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 52e0b6c30b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread components/runner-policy/runner-policy.mjs
…direction and selector-result reporters

Two Codex P1 findings identified auto-approval surface gaps the structural
diff cannot close: a job's runs-on (or other routing field) can stay
byte-identical while resolving through needs.<job>.outputs.<name> to a
producing job whose value changes the real runner boundary, and a
selectorResultInput fail-closed reporter's required-check guarantee depends
on its own steps honoring the forwarded selector result, which sits outside
the compared surface entirely. Both are undetectable by structural diffing,
so both now decline auto-approval unconditionally and require a human to add
a new contract entry, consistent with this feature's existing fail-closed
posture.
@claude

claude Bot commented Jul 16, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 11m 44s —— View job


PR Review

  • Read CLAUDE.md and repo guidelines
  • Review PR diff and changed files
  • Analyze security surface comparison logic
  • Review test coverage
  • Post findings

Summary

This PR is well-designed and the iterative hardening through the Codex review thread is thorough. All P1 Codex findings are addressed in the final commits. The architecture is fail-closed at every stage: incomplete basis evidence, surface diffs, contract ambiguity, malformed jobs, and dynamic routing indirection all decline. Below are the findings from my independent read.


Findings

1. workflowCallSurface treats additional triggers as outside scope — worth documenting as residual risk

runner-policy.mjs:995

workflowCallSurface checks that on.workflow_call is declared and structurally valid, but does not compare the full on event set. A bump that changes on: workflow_call to on: [workflow_call, push] produces identical surface output when inputs and secrets are empty, so auto-approval succeeds.

For runner policy this is arguably correct scope: the concern is where code runs, not what events invoke it. A push trigger on a hosted-only contract still runs on a hosted label; a push trigger on a runner-input contract with no call-supplied input falls back to the declared default (or fails), which is the same runner already covered by the reviewed contract. However, the THREAT-MODEL lists selectorResultInput and needs-output indirection as explicit unconditional declines — this additional-trigger case is neither declined nor called out as accepted residual risk, creating a documentation gap.

Recommendation: Add a bullet to the "Residual and accepted risk" section in THREAT-MODEL.md noting that bumps adding non-workflow_call triggers are not part of the compared surface and are accepted as outside the runner-routing security perimeter, since they do not change the runner target. No code change required.


2. env-based routing indirection not detected by dynamicRoutingReferenceJobIds

runner-policy.mjs:1201

DYNAMIC_ROUTING_FIELDS and containsNeedsOutputReference correctly block direct runs-on: ${{ needs.pick.outputs.runner }}. However, a workflow that routes through a job-level env variable — env: RUNNER: ${{ needs.pick.outputs.runner }} and runs-on: ${{ env.RUNNER }} — is not caught: the literal runs-on value contains no needs.*.outputs.* pattern, so the indirection check passes.

This is an extremely exotic pattern (reusable workflows do not typically route via env), and any change to the env mapping that references a needs output would appear in credentialReferences or the routing surface if the producing job's output path changed the env literal itself. In practice this is a negligible risk, but it is an undetected form of needs-output indirection.

Recommendation: Low priority. The THREAT-MODEL's routing-indirection threat row could note this as a residual case. Alternatively, extend DYNAMIC_ROUTING_FIELDS to include env and re-run the regex against the env mapping's values. Fix this →


3. workflowCallDeclaration silently normalizes on.workflow_call: <non-mapping> to {}, but workflowCallSurface independently catches it — dual-path logic

runner-policy.mjs:358 / runner-policy.mjs:995

workflowCallDeclaration returns {} when on.workflow_call is an array or non-null non-object (lines 376–378), so callers that use its return value to read .inputs/.secrets get empty maps. reusableWorkflowSecuritySurface computes inputs/secrets from workflowCallDeclaration's return value, but also computes workflowCall via workflowCallSurface, which independently re-inspects workflow.on.workflow_call and returns { declared: true, valid: false } for the same malformed case. This means the workflowCall field captures invalidity while inputs/secrets silently normalize to empty. The candidate is still declined (because workflowCall differs), but the two functions have inconsistent semantics for the same inputs.

This is not a correctness bug — the decline happens via workflowCall's diff — but it is a subtle layering issue that could confuse future readers who assume normalizeDeclarationSurface(declaration.inputs) in reusableWorkflowSecuritySurface faithfully reflects whether the inputs declaration was valid.

Recommendation: Consider unifying: workflowCallSurface could expose a validDeclaration object (instead of just { declared, valid }) that reusableWorkflowSecuritySurface uses for inputs/secrets extraction, or malformedWorkflowCallMappingField could be extended to also catch the array/scalar workflow_call body case. Neither is blocking; the current behavior is safe.


What's done well

Security architecture:

  • The fail-closed ordering is rigorous: malformedJobIds runs before any surface is computed; basisFailures check runs before any matching-basis contract is considered; contractDiffFields check runs before the selectorResultInput check. No permutation lets a "good" result hide a "bad" one.
  • basisFailures.length > 0 propagates any single unreachable/invalid basis as a whole-candidate decline rather than skipping silently to other bases. This directly addresses the Codex P2 at line 1426 and the test at line 2180 exercises both insertion orders.
  • The selectorResultInput unconditional decline (line 1479) is correctly positioned after the contract-ambiguity check so an ambiguous selectorResultInput + non-selectorResultInput pair still fails closed via ambiguity rather than needing the later guard.
  • jobCredentialReferenceSurface captures the exact normalized text of every credential-bearing value (not just the category from privilegedHostedRequirement), closing the "same-category, different-secret" gap raised in the Codex P2 at line 1047.

Test coverage:
153 tests with adversarial cases for: omission-vs-empty permissions at both workflow and effective-job scope, malformed workflow_call body, malformed inputs/secrets (boolean, scalar, array), removed callability, changed routing/containers/services/environments/nested-reusable-calls, changed credential category and exact reference, malformed jobs (array and scalar) in candidate and in basis, needs-output indirection in candidate and basis, selectorResultInput unconditional decline, contract ambiguity under both insertion orders, partial-evidence declines under both insertion orders, fetch failure, and both disable switches with no-network verification.

Schema: autoApprovalProvenance correctly validates basisSha as a 40-character hex string and approvedAt as an ISO 8601 UTC timestamp. Auto-approved contracts remain in-memory only and are not persisted to the policy file, preserving the human-review requirement for initial and any changed contract.

THREAT-MODEL.md: Accurately describes the auto-approval data flow, the two new unconditional-decline categories, and the residual risk that raw.githubusercontent.com availability is a dependency. The threat table entries are directly traceable to test cases.


@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f30f6f99b9

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread components/runner-policy/runner-policy.mjs Outdated
kyle-sexton and others added 4 commits July 16, 2026 12:10
…pendabot-autoapprove

# Conflicts:
#	components/runner-policy/policy.schema.json
…ract ambiguity checks

reviewedContractSurface() feeds differingReviewedContractFields(), which
exists specifically so a Dependabot SHA bump cannot silently inherit one
of several structurally-matching reviewed revisions' contract terms when
those revisions disagree (see THREAT-MODEL.md's "inherits a broader
contract from one of several matching reviewed revisions by insertion
order" threat). It already compares every existing human-reviewed
contract term (routing, runnerInput, selectorResultInput, allowedInputs,
allowedSecrets, fixedRunsOn) but was written before allowedCallerPermissions
existed, so a future contract carrying that field would bypass the
ambiguity check entirely.

allowedCallerPermissions is the same kind of term: an exact caller-side
permission grant a human approved for one reviewed SHA, not something the
diffed callee workflow surface encodes. Add it to reviewedContractSurface
so disagreement between matching bases fails closed instead of silently
picking whichever basis sorts first.

This is a no-op today: no contract in this component's schema carries
allowedCallerPermissions yet. It becomes load-bearing once a contract
with that field exists, so it must land before or alongside such a
contract rather than be added retroactively.

runner-policy tests: 160/160 passed. Biome clean.
…s grants in auto-approval

A future reviewed reusable-workflow contract can carry an
allowedCallerPermissions grant: an exact caller-side permission and
secret boundary that lets a job reach a self-hosted runner despite an
elevated GITHUB_TOKEN grant (e.g. pull-requests:write, id-token:write),
bypassing the local-routing permission and credential checks that
otherwise force such jobs hosted. This is a narrow, deliberately
human-reviewed exception.

resolveAutoApprovedContracts' structural surface diff (workflow_call
declaration, permissions, job routing, and credential *references*)
proves the called workflow's caller-facing contract and execution
boundary are unchanged between a reviewed SHA and a Dependabot-bumped
SHA, but it never inspects step bodies (run: scripts, non-credential
uses:) for content. A bumped SHA could keep every compared field
identical while its steps do something different with an already
privileged grant -- for example exfiltrating an id-token-derived
credential or misusing a pull-requests:write grant -- and auto-approval
would silently extend the already-reviewed grant onto that unreviewed
content. This is the same class of gap selectorResultInput's existing
unconditional decline closes for fail-closed selector reporting, with a
larger blast radius (privileged permissions and self-hosted
reachability rather than a required-check result).

This corrects the previous commit on this branch tip, which only added
allowedCallerPermissions to reviewedContractSurface's ambiguity check.
That alone is inert in the common single-reviewed-SHA case (e.g. one
approved claude-review@SHA): with only one basis there is nothing to
disagree with, so the ambiguity check never fires and a Dependabot bump
would still silently inherit the privileged grant. The unconditional
decline below is the fix that actually closes that case; the ambiguity
comparison remains as defense in depth for when multiple reviewed
revisions exist and disagree.

Two changes:

- resolveAutoApprovedContracts declines auto-approval unconditionally
  whenever the matched reviewed basis carries allowedCallerPermissions,
  mirroring the existing selectorResultInput decline.
- reviewedContractSurface also compares allowedCallerPermissions, so if
  multiple already-reviewed revisions of the same workflow path
  structurally match a candidate but disagree on their caller-permission
  grant, that disagreement is caught as ambiguity instead of silently
  picking whichever matching basis sorts first.

THREAT-MODEL.md documents the new decline alongside the existing
selectorResultInput one.

Neither change has any effect today: no contract in this component's
schema carries allowedCallerPermissions yet (that field does not exist
on this branch). Both become load-bearing the moment a contract with
that field exists, so they must land before or alongside such a
contract rather than be added retroactively. Behavioral test coverage
for the new decline path lands together with the schema field that
introduces allowedCallerPermissions, since a contract carrying it is
not currently schema-valid on this branch.

runner-policy tests: 160/160 passed (unchanged). Biome and markdownlint
clean.
…ssion test

The two preceding commits added the allowedCallerPermissions fail-closed
decline before allowedCallerPermissions existed as a schema-valid contract
field, so their behavioral test coverage was deferred to land together
with the schema field that introduces it (see #140).

#140 has now merged main into this branch and added allowedCallerPermissions
to policy.schema.json, runnerInputContract validation, and
reusableWorkflowStatus's caller-permission check (scoped to
target.kind === "selector-output" for the runtime waiver, a scoping
decision that is orthogonal to and does not affect this auto-approval
decline, which fires unconditionally whenever the matched reviewed basis
carries the field regardless of how the candidate job routes).

Add the deferred regression test: a Dependabot SHA bump of a reviewed
allowedCallerPermissions contract is declined even when the fetched
candidate source is byte-identical to the reviewed basis, proving the
decline is unconditional rather than incidentally triggered by a
structural diff.

Also remove two now-stale comments/citations that claimed
allowedCallerPermissions had no live effect or was not yet schema-valid:
that was true when those commits were originally authored, against #119's
pre-merge base, but is no longer true now that #140's schema field and
components/runner-policy/policy.json's claude-review.yml contract both
exist on main.

runner-policy tests: 169/169 passed. Biome and markdownlint clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LBb8hPQQsw3GZV4QL6Zpvs
@claude

claude Bot commented Jul 16, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 6m 25s —— View job


PR Review

  • Read CLAUDE.md and repo guidelines
  • Review PR diff and changed files
  • Check whether latest Codex P1 (index-syntax needs-output routing) is addressed
  • Review allowedCallerPermissions integration
  • Post findings

Summary

The allowedCallerPermissions integration added in the three commits since the last Codex review round is well-designed and fail-closed. One Codex P1 from the 2026-07-16T13:32:07Z review round is still open in the current HEAD; everything else brought in those commits looks correct.


Finding 1 — Codex P1 still open: bracket-notation needs-output references not caught

runner-policy.mjs:1225

The NEEDS_OUTPUT_REFERENCE regex in the current HEAD is unchanged since the Codex P1 was filed:

const NEEDS_OUTPUT_REFERENCE = /needs\.[A-Za-z0-9_-]+\.outputs\.[A-Za-z0-9_-]+/;

GitHub's expression syntax supports bracket notation as a full semantic equivalent of dot notation, so runs-on: ${{ needs.pick.outputs['runner'] }} is a valid, self-consistent routing expression that the regex does not match. A SHA bump could keep that literal runs-on string byte-identical while the producer job changes the output value from ubuntu-24.04 to self-hosted; dynamicRoutingReferenceJobIds would not detect the indirection, the surface diff would see no routing change, and auto-approval would succeed.

The THREAT-MODEL.md describes the control as applying to needs.<job-id>.outputs.<name> (dot notation only), so the code and the model agree — but both are incomplete relative to GitHub's actual expression grammar.

Recommended fix: Extend the regex to match bracket notation as well:

const NEEDS_OUTPUT_REFERENCE =
  /needs(?:\.[A-Za-z0-9_-]+|\[['"][A-Za-z0-9_-]+['"]\])\.outputs(?:\.[A-Za-z0-9_-]+|\[['"][A-Za-z0-9_-]+['"]\])/;

Alternatively, fail closed on any routing field that contains needs and outputs anywhere, since there is no realistic reason to spell needs-output references any other way. Fix this →


Finding 2 — autoApproved in the stored policy schema is potentially misleading

policy.schema.json:172 / policy.schema.json:184

autoApproved is added to both runnerInputContract and hostedOnlyContract in policy.schema.json, which governs the on-disk policy file. The PR description states auto-approved contracts are in-memory only. validatePolicy confirms this: the contract object it constructs at line 213 does not copy autoApproved even if the field is present in the JSON. So a consumer who writes autoApproved into their policy.json gets a schema-valid file whose autoApproved entry is silently dropped on load.

This is not a security issue — the in-memory drop is safe — but the gap between "the schema allows it" and "the runtime ignores it" could confuse a future maintainer who assumes the field survives a round-trip. A one-line schema comment or a corresponding validation guard that rejects autoApproved in stored contracts would close the gap. Not blocking.


What's correct in the new commits

allowedCallerPermissions enforcement: The feature is fail-closed at every decision point.

  • validatePolicy (lines 185–201) requires at least one write value in allowedCallerPermissions and rejects any allowedSecrets entry that isn't the exact canonical ${{ secrets.<NAME> }} form when allowedCallerPermissions is set. Both checks run before the contract is stored in the in-memory Map.
  • reusableWorkflowStatus (lines 900–910) calls exactCanonicalMap(effectivePermissions(workflow, job), contract.allowedCallerPermissions, ...), so a caller job whose effective permissions don't exactly match the reviewed grant is rejected. Non-mapping permission values (undefined, "write-all") fail the exactCanonicalMap non-object guard and produce a diagnostic.
  • resolveAutoApprovedContracts (lines 1539–1562) declines auto-approval unconditionally for any contract carrying allowedCallerPermissions, with a diagnostic naming the path and the reason. This is the correct ordering: the decline fires after the basisFailures and contractDiffFields checks but before the selectorResultInput check, so it does not create a path where an ambiguous multi-basis result could reach the allowedCallerPermissions guard first.
  • privilegedHostedRequirement (lines 2375–2428) correctly bypasses only permissionHostedRequirement and the secrets-field credential scan when reviewedCallerPermissions is true; the environment check (line 2399) and credentialAction check (line 2419) still run unconditionally. Excluding job.secrets from localCredentialRequirement is safe because validatePolicy has already enforced canonical form and reusableWorkflowStatus has already matched the caller's actual secrets against those exact forms.
  • reviewedContractSurface (lines 1307–1313) includes allowedCallerPermissions in the reviewed-contract ambiguity check, so two reviewed SHAs with different caller permission grants will be detected as differing contracts and fail the ambiguity guard rather than silently selecting the first match.

Test coverage: The regression test for allowedCallerPermissions auto-approval decline (line 3381) is well-scoped: it passes an identical surface for both basis and candidate (REUSABLE_WORKFLOW_BASIS_SOURCE for both fetched SHAs) to confirm that a surface match is not sufficient and the diagnostic message is exact.


Carry-over from previous Claude review (still open, previously noted as non-blocking)

  • Finding feat(powershell): PowerShell static-analysis module #1 (additional triggers not in scope): workflowCallSurface still does not compare the full on event set; a bump adding push: alongside workflow_call: remains outside the compared surface. THREAT-MODEL.md could note this as accepted residual risk.
  • Finding feat(markdown): Markdown (GFM) static-analysis module #2 (env-based routing indirection): dynamicRoutingReferenceJobIds still does not inspect job.env values for needs-output references that feed runs-on: ${{ env.RUNNER }}. Low priority, as noted.
  • Finding feat(hygiene): add editorconfig base + checker module #3 (dual-path malformed workflow_call normalization): the layering mismatch between workflowCallDeclaration's silent normalization and workflowCallSurface's independent invalidity detection is still present. Not a correctness issue.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5eb61c4d3f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread components/runner-policy/runner-policy.mjs Outdated
…dential-action ref bumps

Two bot findings on PR #119 auto-approval hardening:

- dynamicRoutingReferenceJobIds() matched only property-dereference
  needs-output syntax (needs.<job>.outputs.<name>), missing GitHub's
  equivalent index syntax (needs.<job>.outputs['<name>']). A fetched
  reusable workflow using index syntax could keep its runs-on
  byte-identical across a SHA bump while the producing job's output
  value changed underneath it, silently inheriting the reviewed
  contract. The detector now matches outputs followed by either `.`
  or `[`, covering any bracket-index spelling.

- jobCredentialSurface recorded only privilegedHostedRequirement's
  category (e.g. "credential-minting action actions/create-github-app-token"),
  never the step's pinned @ref. A Dependabot bump repointing an
  already-reviewed credential-minting action at a different,
  unreviewed ref left the compared surface identical. A new
  credentialActionUses() helper backs both credentialAction() and a
  new per-step credentialAction field in jobCredentialReferenceSurface,
  so the full normalized uses: value (including ref) is part of the
  diffed credentialReferences surface.

Adds regression tests proving both dot- and index-syntax needs-output
routing decline auto-approval, and that a ref-only change to an
existing credential-minting action declines. Updates README.md and
THREAT-MODEL.md to document both surfaces precisely.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LBb8hPQQsw3GZV4QL6Zpvs
@claude

claude Bot commented Jul 16, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 3m 30s —— View job


PR Review

  • Read CLAUDE.md and repo guidelines
  • Review latest commits for P1 fixes (index-syntax needs-output routing, credential-action ref bumps)
  • Verify NEEDS_OUTPUT_REFERENCE regex and test coverage
  • Verify credential-action ref surface inclusion
  • Check for any new issues introduced
  • Post findings

Summary

Both P1s from the previous review round are correctly addressed in a9a83ce. The fixes are well-scoped, fail-closed, and backed by targeted regression tests. One negligible residual gap is worth noting for the THREAT-MODEL; nothing here is blocking.


Fix 1 — NEEDS_OUTPUT_REFERENCE regex (index-syntax gap)

runner-policy.mjs:1253

// Before
const NEEDS_OUTPUT_REFERENCE = /needs\.[A-Za-z0-9_-]+\.outputs\.[A-Za-z0-9_-]+/;

// After
const NEEDS_OUTPUT_REFERENCE = /needs\.[A-Za-z0-9_-]+\.outputs(?:\.[A-Za-z0-9_-]+|\[)/;

The \[ suffix (rather than a full quoted-string pattern) is the correct fail-closed choice: it matches any bracket-index form — single-quoted, double-quoted, extra whitespace, or a non-literal index — without needing to enumerate quoting variants. Test coverage includes both single-quoted (outputs['runner']) and double-quoted (outputs["runner"]) forms.

Negligible residual: The regex uses needs\. (dot) for the job-id segment, so needs['pick'].outputs.runner (bracket notation for the job ID itself) would not match. GitHub Actions job IDs are restricted to [A-Za-z0-9_-]+, making them always valid for dot notation; bracket-indexed job IDs in reusable workflows are essentially unused. This is a low-priority residual if the THREAT-MODEL wants to be exhaustive.


Fix 2 — Credential-action @ref in the compared surface

runner-policy.mjs:2391 / runner-policy.mjs:1140

The new credentialActionUses(step, policy) helper returns the full uses: value (including @ref) when the step is a credential-minting action, and undefined otherwise. Both credentialAction() (category, for findings/jobCredentialSurface) and jobCredentialReferenceSurface (exact ref, for the auto-approval diff) derive from it, so the two paths can never disagree on which steps count as credential-minting. The credentialAction field added to each step entry captures the normalized full uses: string, making a ref-only change a visible diff in credentialReferences.

The regression test (REUSABLE_WORKFLOW_CREDENTIAL_ACTION_REF_BASIS_SOURCE vs REUSABLE_WORKFLOW_CREDENTIAL_ACTION_REF_CHANGED_SOURCE) isolates the exact gap: identical workflow surface except for the credential action's pinned SHA, and asserts credentialReferences changed in the diagnostic.


Carry-over non-blocking observations (unchanged from prior rounds)

These were noted in the previous review and remain; none are blocking.

  • Additional triggers not in scope: A bump adding push: alongside workflow_call: is outside the compared surface. THREAT-MODEL could note this as accepted residual risk.
  • env-based routing indirection: dynamicRoutingReferenceJobIds does not inspect job.env values for needs-output references that feed runs-on: ${{ env.RUNNER }}. Low-priority residual.
  • Dual-path malformed workflow_call normalization: workflowCallDeclaration silently normalizes non-mapping workflow_call bodies to {} while workflowCallSurface independently catches the invalidity; the decline is correct, but the layering is subtle. Not a correctness issue.

What remains correct

All points from the prior review round are still correct in the current HEAD. The implementation is fail-closed at every decision point, the 165 test cases cover both new regression scenarios and all prior adversarial paths, and both disable switches remain untouched. The THREAT-MODEL and README are updated to accurately describe both the dot- and index-syntax routing coverage and the exact-ref credentialAction inclusion.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a9a83ce92b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread components/runner-policy/runner-policy.mjs Outdated
The previous commit's needs-output detector only matched bracket
indexing on the output-name segment (needs.pick.outputs['runner']),
missing that GitHub's index syntax is a generic property accessor
equally valid for the job-id segment (needs['pick'].outputs.runner)
and the literal outputs segment (needs.pick['outputs'].runner), per
GitHub's expression docs, which list `.` (property de-reference) and
`[ ]` (index) as interchangeable operators, not one reserved for a
specific segment.

NEEDS_OUTPUT_REFERENCE now accepts dot-or-bracket form (with optional
whitespace) for both the job-id and outputs segments, so any
GitHub-valid spelling of a needs-output reference is caught. Adds a
regression test with every segment bracketed
(needs['pick']['outputs']['runner']), confirmed to fail against the
prior narrower fix before this change. Corrects README.md and
THREAT-MODEL.md prose that described only the single-segment case.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LBb8hPQQsw3GZV4QL6Zpvs
@claude

claude Bot commented Jul 16, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 3m 43s —— View job


PR Review

  • Read CLAUDE.md and repo guidelines
  • Review latest commits (allowedCallerPermissions + bracket-notation job-ID fix)
  • Verify NEEDS_OUTPUT_REFERENCE regex covers all segment forms
  • Review allowedCallerPermissions integration in full
  • Check for any remaining open issues
  • Post findings

Summary

The final commit b9b5d65 correctly addresses the Codex P1 filed at 2026-07-16T17:24:28Z (bracket notation for the job-ID segment). The allowedCallerPermissions integration (3e1471c / f6cfa7e / 5eb61c4) was confirmed correct in the previous Claude round and is unchanged. All prior Codex P1s are resolved. No new correctness issues were found. There are two non-blocking observations below; nothing is blocking.


Finding 1 — NEEDS_OUTPUT_REFERENCE regex: completed correctly

runner-policy.mjs:1264

The previous regex (/needs\.[A-Za-z0-9_-]+\.outputs(?:\.[A-Za-z0-9_-]+|\[)/) was hardcoded to a literal needs. prefix and .outputs segment, missing bracket notation on either of those segments. The replacement correctly decomposes the pattern into independent dot-or-bracket alternatives for both the job-ID segment and the outputs keyword:

// job-id segment: .pick or ['pick'] (with optional whitespace)
const NEEDS_OUTPUT_JOB_ACCESS_SOURCE = `(?:\\.\\s*[A-Za-z0-9_-]+|\\[\\s*(?:'...'|"...")\\s*\\])`;
// outputs segment: .outputs or ['outputs'] / ["outputs"]
const NEEDS_OUTPUT_OUTPUTS_ACCESS_SOURCE = "(?:\\.\\s*outputs\\b|\\[\\s*(?:'outputs'|\"outputs\")\\s*\\])";

This matches every GitHub-valid spelling of a needs-output reference: needs.pick.outputs.runner, needs['pick'].outputs.runner, needs.pick['outputs'].runner, and needs['pick']['outputs']['runner']. All three bracket forms are regression-tested. The regex deliberately does not require the output-name segment — any reference to needs.<job>.outputs (in any form) is sufficient to decline.

One negligible residual worth noting: NEEDS_OUTPUT_OUTPUTS_ACCESS_SOURCE matches only the literal quoted strings 'outputs' and "outputs" in bracket form; a computed index like needs['pick'][computed_key] would not match. However, accessing outputs via a computed expression is not valid GitHub Actions YAML context syntax (the outputs property is a fixed keyword, not a dynamic key), so this is a theoretical gap that cannot occur in practice.


Finding 2 — autoApproved in stored schema (non-blocking, carry-over)

policy.schema.json:172 / policy.schema.json:184

autoApproved is a valid field in both runnerInputContract and hostedOnlyContract per the schema. validatePolicy builds the in-memory contract object by enumerating each field explicitly (lines 213–231); autoApproved is not in that enumeration, so it is silently dropped on load. A consumer who writes autoApproved into their policy.json gets a schema-valid file that the runtime ignores — not a security issue (the drop is safe), but the schema claim and the runtime behavior disagree. A schema "description" note or a validation guard that rejects stored autoApproved entries would close the gap. Not blocking.


Carry-over non-blocking observations (unchanged from prior rounds)

  • Additional triggers not compared: A bump that adds push: alongside workflow_call: passes the surface diff undetected. Runner routing doesn't change (the declared runs-on is still governed by the reviewed contract), but the gap is undocumented. A bullet in THREAT-MODEL.md noting this as accepted residual risk outside the runner-routing perimeter would close the documentation gap.
  • env-based routing indirection: dynamicRoutingReferenceJobIds checks routing fields for needs-output references but does not inspect job.env values that feed runs-on: ${{ env.RUNNER }}. Extremely exotic pattern in reusable workflows; low priority.
  • Dual-path malformed workflow_call normalization: workflowCallDeclaration silently normalizes non-mapping workflow_call bodies to {} while workflowCallSurface independently detects invalidity; the decline is correct, the layering is subtle. Not a correctness issue.

What's correct in the full PR

NEEDS_OUTPUT_REFERENCE progression: The regex went through three iterations (dot-only → index on output name → index on all segments) and each Codex P1 was addressed completely and immediately. The final form is both correct and provably exhaustive for GitHub's expression grammar.

allowedCallerPermissions integration (confirmed in prior round, unchanged):

  • validatePolicy requires at least one write value and canonical secret form before storing the contract
  • reusableWorkflowStatus enforces exact caller permission match via exactCanonicalMap before approving the reusable call
  • resolveAutoApprovedContracts declines unconditionally for any contract carrying allowedCallerPermissions, correctly positioned after contract-ambiguity checks
  • privilegedHostedRequirement excludes job.secrets from localCredentialRequirement when reviewedCallerPermissions is true, without relaxing the environment or credentialAction checks
  • reviewedContractSurface includes allowedCallerPermissions in the reviewed-contract ambiguity comparison

Test count: 165 tests covering all adversarial cases. The three new regression tests for bracket-notation (single-quoted, double-quoted, fully-bracketed) confirm the detector fails without the fix and passes with it.

Security architecture: Fail-closed ordering is rigorous throughout — malformedJobIdsbasisFailurescontractDiffFieldsselectorResultInputallowedCallerPermissions → approval. No permutation of "good" results can hide a "bad" one.


@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b9b5d65670

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread components/runner-policy/runner-policy.mjs Outdated
…outing fields

Seven rounds of P1 findings each closed one precise needs-indirection
syntax (dot, then index/bracket forms) while leaving the next one open;
an object-filter route (needs.*.outputs.runner, typically wrapped in
join(...)) has no named job-id segment for a job-id-shaped pattern to
match at all. Replace the enumerated blocklist with a coarse
allowlist-style catch-all: decline whenever a routing-relevant field
mentions the needs context at all (needs. or needs[, matched
case-insensitively since GitHub's expression evaluator treats context
and property names case-insensitively), regardless of what follows.
False positives just mean a human reviews instead of auto-approving,
which is the safe direction to err for this gate.
@claude

claude Bot commented Jul 16, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 2m 40s —— View job


PR Review

  • Read CLAUDE.md and repo guidelines
  • Identify what changed since last review round (latest commit: 16096b8)
  • Review the new needs-reference routing decline logic
  • Check test coverage for the Codex P1 (object-filter needs.*.outputs)
  • Review for any new issues introduced
  • Post findings

Summary

The single commit 16096b8 correctly addresses the Codex P1 filed at 2026-07-16T17:37:53Z (object-filter needs.*.outputs.runner routing not caught by the job-id-shaped detector). All prior Codex P1s remain resolved. No new correctness issues found. Two non-blocking observations carried over from prior rounds are still present and still non-blocking.


Codex P1 resolved: object-filter output route

runner-policy.mjs:1269

The precise, enumeration-based regex that previously matched specific spellings of needs.<job-id>.outputs.<name> has been replaced with a coarse, allowlist-style catch-all:

const NEEDS_REFERENCE = /\bneeds\b\s*[.[]/i;

This is the correct architectural response to a detector that had been patched three times (dot-only → bracket on outputs segment → bracket on both segments) and still missed needs.*.outputs.runner. The object-filter form has no named job-id segment, so any job-id-shaped pattern will structurally fail to enumerate it. Rather than continuing that arms race, the implementation now only permits routing fields that provably do not mention needs at all — any occurrence of needs followed by a property or index accessor declines, regardless of what follows.

Regex correctness: \bneeds\b requires a word boundary on both sides, correctly excluding identifiers like selfhostedneeds (no leading boundary) while including needs-analysis-adjacent values only when . or [ immediately follows (which needs-analysis does not satisfy). The case-insensitive /i flag correctly matches GitHub's own case-insensitive context/property evaluation. Optional \s* before the accessor handles expression whitespace variants. The pattern catches every form demonstrated so far — dot dereference, index syntax on any segment, object filter, uppercase letter case, needs.<job>.result, and any future needs-prefixed expression — without requiring this file to know about them in advance.

False positive direction: The comment explicitly states that false positives (routing fields mentioning needs that are not actually exploitable) are accepted, since they only cost a human review rather than an auto-approval. This is the safe direction for a security gate and is correctly documented in both the source comment and THREAT-MODEL.md.

Tests: Three new test fixtures and a new parameterized test loop (168 total test invocations, up from 165) cover exactly the three cases the old detector would have missed:

  • Object-filter output route (join(needs.*.outputs.runner, '')): the exact reported gap — no named job-id segment.
  • Case-variant needs reference (NEEDS.pick.OUTPUTS.runner): confirms the /i flag.
  • Needs job-result reference (needs.pick.result == 'success' && 'self-hosted' || 'ubuntu-24.04'): confirms the catch-all is not limited to .outputs paths — any needs property access in a routing field is declined.

The prior dot/bracket/fully-bracketed tests are kept and re-verified (rather than deleted) with an explanatory comment confirming the coarse catch-all subsumes all specific prior cases.

Documentation: THREAT-MODEL.md and README.md are updated to describe the allowlist rationale, name the object-filter form explicitly, and explain why the enumeration approach was abandoned. The diagnostic message update ("references needs in a routing-relevant field" instead of "routes through a needs.<job>.outputs reference") is consistent with the broader semantics.


Carry-over non-blocking observations (unchanged from prior rounds)

None of these are new; all were noted as non-blocking in prior Claude review rounds and none have been addressed (nor need to be for correctness).

  • Additional triggers not compared: A bump adding push: alongside workflow_call: still passes the surface diff undetected. THREAT-MODEL.md could note this as accepted residual risk outside the runner-routing perimeter.
  • autoApproved in the stored schema: policy.schema.json:172 allows autoApproved on stored contracts, but validatePolicy silently drops it on load. Not a security issue, but the schema claim and runtime behavior disagree.
  • env-based routing indirection: dynamicRoutingReferenceJobIds checks DYNAMIC_ROUTING_FIELDS for needs references but does not inspect job.env values that feed runs-on: ${{ env.RUNNER }}. Extremely exotic pattern in reusable workflows; low priority.
  • Dual-path malformed workflow_call normalization: workflowCallDeclaration silently normalizes non-mapping workflow_call bodies to {} while workflowCallSurface independently catches the invalidity; the decline is correct, the layering is subtle. Not a correctness issue.

What remains correct across the full PR

The full review of all prior commits (through b9b5d65) was completed in the 2026-07-16T17:30:28Z round and is unchanged:

  • All Codex P1s from every round are now addressed.
  • allowedCallerPermissions integration is fail-closed at every decision point (validate → status → decline-on-auto-approval → privilege check).
  • NEEDS_OUTPUT_REFERENCENEEDS_REFERENCE progression correctly subsumes all prior specific-syntax fixes.
  • 168 tests cover all adversarial paths, including every routing indirection variant the detector has been reported to miss.
  • Fail-closed ordering — malformedJobIdsbasisFailurescontractDiffFieldsselectorResultInputallowedCallerPermissions → approval — is rigorous throughout. No permutation of "good" results hides a "bad" one.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 16096b8cc9

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread components/runner-policy/runner-policy.mjs Outdated
The coarse needs-reference catch-all still required a property or
index accessor (`.`/`[`) immediately after the `needs` token, which is
itself an enumerated dereference shape. GitHub's expression functions
can take `needs` as a bare argument and return a dereferenceable
object, e.g. `fromJSON(toJSON(needs)).pick.outputs.runner`, where the
token right after `needs` is a function's closing `)`, not `.` or `[`;
that byte-identical routing field could keep passing the diff while
the producer job's output changed the actual runner underneath it.
Match the bare `needs` word itself instead of requiring an immediate
accessor, matching the catch-all's own documented intent to decline
any routing field that mentions `needs` at all.

Adds a function-wrapped-needs regression case to the existing
needs-reference catch-all test loop.
@claude

claude Bot commented Jul 17, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 2m 47s —— View job


PR Review

  • Read CLAUDE.md and repo guidelines
  • Review latest commit (3bc63c2) — function-wrapped needs references fix
  • Review full diff for any remaining gaps
  • Check test coverage for the new fix
  • Verify carry-over non-blocking items from prior rounds
  • Post findings

Summary

The latest commit (3bc63c2) correctly addresses the Codex P1 for function-wrapped needs references. All Codex P1s from every review round are now resolved. One negligible documentation gap is noted below; nothing is blocking.


Fix — NEEDS_REFERENCE simplification is correct and complete

runner-policy.mjs:1275

// Before (16096b8)
const NEEDS_REFERENCE = /\bneeds\b\s*[.[]/i;

// After (3bc63c2)
const NEEDS_REFERENCE = /\bneeds\b/i;

The prior pattern required a property/index accessor (. or [) immediately after the needs token — itself an enumeration of one dereference shape that the GitHub expression grammar can route around. When needs is passed as a bare argument to a function (fromJSON(toJSON(needs)).pick.outputs.runner), the character after the token is ), not . or [, so the old pattern did not fire and a byte-identical routing field could silently change the runner underneath it.

The replacement matches the bare needs word in any letter case, regardless of what precedes or follows it. This is the correct terminal form:

  • Word boundary (\b) correctness: GitHub Actions YAML values that reference the needs context always have expression delimiters (${{, spaces, operators, (, )) around the token — not adjacent word characters. An env-var reference like NEEDS_RUNNER has no \b between NEEDS and _, so it does not match. The false-positive surface is genuinely narrow.
  • Case-insensitive flag (/i) correctness: GitHub's expression evaluator treats context and property names case-insensitively; NEEDS.pick.outputs.runner is semantically equivalent to needs.pick.outputs.runner. The prior regex already carried this flag, and it is retained correctly.
  • False-positive direction is accepted: The documented tradeoff — false positives require human review rather than auto-approving — is the correct direction to err for a security gate, and the implementation comment and THREAT-MODEL.md both state this explicitly.

Test coverage: The new REUSABLE_WORKFLOW_DYNAMIC_ROUTING_FUNCTION_WRAPPED_SOURCE fixture is a targeted replacement of needs.pick.outputs.runnerfromJSON(toJSON(needs)).pick.outputs.runner in the existing routing source, and the parameterized test loop at line 3579 verifies both that a surface-identical basis+candidate with this routing expression is declined AND that a cosmetic non-routing change does not add any additional decline (the basis-side decline propagates correctly). All four variants in the loop (object-filter, case-variant, job-result, function-wrapped) exercise the same test body.


Minor documentation gap — THREAT-MODEL.md test evidence column not updated

THREAT-MODEL.md:121

The test evidence column for the routing-indirection threat row was last updated in commit 16096b8 and lists: "property dereference, index-syntax, object-filter, case-variant, and non-outputs needs property cases." The "function-wrapped needs reference" case added in 3bc63c2 is not listed there. The narrative prose above the threat table (lines 83–96) does say "any other property or index reachable off needs" and "GitHub's expression grammar for context/property access is large and can grow (new index forms, new filters, new functions)" — so the spirit is captured — but the evidence column is now slightly out of date with the test suite.

Recommendation: Add "function-wrapped needs reference" to the test evidence list in the threat row. Not blocking. Fix this →


Carry-over non-blocking observations (unchanged from prior rounds)

These were noted in prior Claude review rounds and remain present; none are blocking.

  • Additional triggers not compared: A bump that adds push: alongside workflow_call: still passes the surface diff undetected; THREAT-MODEL.md's "Residual and accepted risk" section could note this as outside the runner-routing perimeter.
  • autoApproved in the stored schema: policy.schema.json:172 allows autoApproved on stored contracts, but validatePolicy silently drops it on load. Schema claim and runtime behavior disagree; not a security issue.
  • env-based routing indirection: dynamicRoutingReferenceJobIds checks DYNAMIC_ROUTING_FIELDS but not job.env values that feed runs-on: ${{ env.RUNNER }}. Extremely exotic pattern; low priority.
  • Dual-path malformed workflow_call normalization: workflowCallDeclaration silently normalizes non-mapping workflow_call bodies to {} while workflowCallSurface independently catches invalidity; decline is correct, layering is subtle. Not a correctness issue.

What remains correct across the full PR

All points from prior Claude review rounds are unchanged in the current HEAD:

  • NEEDS_REFERENCE progression: Dot-only → index on output segment → index on all segments → object-filter catch-all (./[ required) → bare-token catch-all. Each iteration addressed the reported gap; the final form is provably exhaustive for the GitHub expression grammar because it requires no enumeration of expression shapes at all.
  • allowedCallerPermissions integration: Fail-closed at validatePolicy, reusableWorkflowStatus, resolveAutoApprovedContracts, privilegedHostedRequirement, and reviewedContractSurface.
  • Credential-action @ref surface: Full normalized uses: value is in credentialReferences; a ref-only bump produces a visible diff.
  • Test suite: 165 test() declarations plus the four-entry parameterized needs-reference loop — all adversarial paths covered including both insertion orders for multi-basis ambiguity, partial-evidence declines, function-wrapped routing, and both disable switches with no-network verification.
  • Fail-closed ordering: malformedJobIdsbasisFailurescontractDiffFieldsselectorResultInputallowedCallerPermissions → approval. No permutation of "good" results hides a "bad" one.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3bc63c2144

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread components/runner-policy/runner-policy.mjs Outdated
jobCredentialReferenceSurface recorded only the fields of a
credential-bearing step that themselves contained a credential
expression (condition/env/with, filtered through
credentialBearingEntries), never the rest of the step. A bumped SHA
could keep an already-reviewed step's credential expression
byte-identical while rewriting the step's `run:` body, or swapping a
non-localCredentialActions `uses:` action, to consume that same
credential differently; the filtered surface stayed unchanged and the
diff never saw it, so auto-approval copied the reviewed runner
contract onto unreviewed executable code.

Once a step is identified as credential-bearing, record its full
normalized content (`other`, `env`, `with`) instead of only the
filtered subset, so any body change becomes a visible diff. The
credential-bearing gate itself now checks every field family
(condition, env, with, the remaining step body, and credentialAction)
instead of omitting the remaining-step-body check, which would have
dropped a step whose only credential expression lives directly in
`run:` (no env/with block) out of the surface entirely.

Adds a step-body-change regression case (env-based credential,
run: rewritten) and a second case proving the gate itself fires when
the credential expression is inline in `run:` with no env/with block.
@claude

claude Bot commented Jul 17, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 2m 41s —— View job


PR Review

  • Read CLAUDE.md and repo guidelines
  • Review latest commit (1e93a9e) — credential-bearing step body changes fix
  • Verify the fix correctly declines step body changes
  • Check test coverage for the new fix
  • Check carry-over non-blocking items from prior rounds
  • Post findings

Summary

The latest commit (1e93a9e) correctly addresses the Codex P1 filed at 2026-07-17T00:53:01Z. All Codex P1s from every review round are now resolved. Two documentation gaps below; nothing is blocking.


Fix — Credential-bearing step body changes

runner-policy.mjs:1150

The fix restructures jobCredentialReferenceSurface in two coordinated ways:

Gate expansion: isCredentialBearing now checks every field family — conditionContainsCredentialReference, credentialBearingEntries(env), credentialBearingEntries(inputs), credentialBearingEntries(stepWithoutCredentialMappings), and credentialActionRef — rather than only the first few. The fifth check (stepWithoutCredentialMappings) is the new path that catches steps whose sole credential expression lives in run: inline, with no env or with block; the old gate omitted this family and would have silently excluded such steps from the surface entirely.

Surface widening: Once a step is confirmed credential-bearing, the recorded other, env, and with fields switch from credentialBearingEntries(...) (the filtered subset of entries that themselves contain a credential expression) to normalizeStructuralValue(...) (the full normalized mapping). The prior filtered recording is what let a bump keep a credential expression byte-identical while rewriting the run: body to consume it differently — the run: key itself never contains ${{ secrets.* }}, so credentialBearingEntries never captured it.

The combination is correct: non-credential-bearing steps are still excluded entirely (no false positives on cosmetic run: changes to ordinary steps), while credential-bearing steps are now recorded in full so any body change produces a visible diff. The tradeoff — cosmetic changes to a credential-bearing step's non-credential fields (e.g. a renamed name: label, a changed LOG_LEVEL env var alongside a credential) now also decline — is the right direction: false positives require human review rather than auto-approving.

Test coverage: Two new regression cases:

  • REUSABLE_WORKFLOW_CREDENTIAL_STEP_BODY_*env: SCAN_TOKEN is byte-identical in both sources; only the run: body differs (echo scancurl ... -d "token=$SCAN_TOKEN"). This is the exact attacker scenario: same credential expression, rewritten step body.
  • REUSABLE_WORKFLOW_CREDENTIAL_RUN_BODY_ONLY_* — credential expression is inline in run: with no env/with block at all (deploy --token ${{ secrets.DEPLOY_TOKEN_A }}_B). This exercises the new fifth gate check and confirms the step is not silently dropped from the surface.

Finding 1 — THREAT-MODEL.md threat and evidence columns not updated (non-blocking)

THREAT-MODEL.md:120

The threat description in the row at line 120 lists the attack surfaces the auto-approval diff covers, but does not describe the new gap: keeping a credential expression byte-identical while rewriting the step body that consumes it. The evidence column lists test case categories ("changed-credential-reference", "changed-credential-action-ref", etc.) but does not name the new cases added in 1e93a9e (changed-credential-step-body and inline-run-credential). The prior Claude round noted a similar gap for function-wrapped needs references (test evidence at line 121, not updated in commit 3bc63c2); that gap is also still present.

Recommendation: Add "rewrites a credential-bearing step's body to consume the same credential differently" to the threat column description. Add "changed-credential-step-body and inline-run-credential cases" to the evidence column. Add "function-wrapped needs reference" to the evidence column at line 121. Not blocking. Fix this →


Finding 2 — autoApproved in the stored schema (non-blocking, carry-over from prior rounds)

policy.schema.json:172

autoApproved is a valid field in both runnerInputContract and hostedOnlyContract per the schema. validatePolicy builds the in-memory contract object by enumerating fields explicitly; autoApproved is not in that enumeration, so it is silently dropped on load. A consumer who writes autoApproved into their policy.json gets a schema-valid file that the runtime ignores. Not a security issue — the in-memory drop is safe — but the schema claim and runtime behavior disagree. A schema "description" note or validation guard that rejects stored autoApproved entries would close the gap. Carried over from the 2026-07-16T16:25:53Z review round.


What remains correct

Security architecture: All P1 findings from every review round are resolved. The fail-closed ordering is rigorous — malformedJobIdsbasisFailurescontractDiffFieldsselectorResultInputallowedCallerPermissions → approval — and no permutation of "good" results hides a "bad" one.

NEEDS_REFERENCE progression: Dot-only → index on output segment → index on all segments → object-filter/bare-token catch-all (./[ required after needs) → bare-token catch-all (no accessor required). Each iteration addressed the reported gap; the final /\bneeds\b/i form is provably exhaustive for the GitHub expression grammar because it requires no enumeration of expression shapes at all.

Credential surface widening: The fix is architecturally correct. Non-credential-bearing steps are still excluded (preserving the non-security-relevant cosmetic change path), while credential-bearing steps now record their full body. The gate expansion correctly covers the fifth field family (run: inline credentials) that the old gate omitted.

Test suite: 167 test declarations (including the new credential step body and inline run body cases) plus the four-entry parameterized needs-reference loop; all adversarial paths covered, both disable switches confirmed no-network.

THREAT-MODEL.md and README.md: Both are accurate descriptions of the auto-approval data flow, with the exception of the two evidence-column entries noted above.


@kyle-sexton
kyle-sexton merged commit a77747e into main Jul 17, 2026
41 checks passed
@kyle-sexton
kyle-sexton deleted the feat/runner-policy-dependabot-autoapprove branch July 17, 2026 01:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant