Skip to content

feat: OIDC token mint dispatch — deprecate PAT, migrate to OIDC#503

Merged
waynesun09 merged 8 commits into
mainfrom
oidc-gcf-dispatch
May 12, 2026
Merged

feat: OIDC token mint dispatch — deprecate PAT, migrate to OIDC#503
waynesun09 merged 8 commits into
mainfrom
oidc-gcf-dispatch

Conversation

@waynesun09
Copy link
Copy Markdown
Contributor

@waynesun09 waynesun09 commented Apr 28, 2026

Summary

  • Deprecate PAT dispatch modedispatch.mode: "pat" is now rejected; OIDC mint is the sole dispatch mechanism
  • Enrolled repos use GitHub OIDC tokens to request scoped installation tokens from a centralized token mint (GCP Cloud Function)
  • The mint validates OIDC JWTs via Workload Identity Federation, fetches per-role PEM keys from Secret Manager, and returns short-lived GitHub App installation tokens
  • Seamless upgrade: existing PAT orgs re-run fullsend admin install — stale FULLSEND_DISPATCH_TOKEN secret is auto-cleaned, enrolled repo shims are updated via reconcile-repos.sh
  • Multi-org support: a single GCP project can serve multiple GitHub orgs — WIF conditions, env vars, and secrets are merged additively on each admin install
  • Org-scoped PEM naming: secrets use fullsend-{org}--{role}-app-pem with double-hyphen separator (GitHub org names cannot contain --). Each org gets its own PEM per role, enabling both shared public apps and per-org private apps.
  • Dual WIF audiences: the WIF provider registers both fullsend-mint (custom OIDC audience) and the IAM-format audience (https://iam.googleapis.com/projects/{num}/.../providers/{id}) required by google-github-actions/auth@v3 for STS token exchange
  • --public flag: admin install --public creates public unlisted GitHub Apps installable by additional orgs without re-creating the app
  • Config repo defaults to public.fullsend is always created as a public repo. Cross-repo workflow_call only works reliably when the called repo is public, across all GitHub plan tiers (free, team, enterprise). If an admin later makes it private, only private repos can trigger workflows; public and internal repos fail silently. The installer warns on both creation and detection of a private config repo.
  • CLI flag rename: --dispatch-* flags renamed to --mint-* (--mint-provider, --mint-project, --mint-region, --mint-source-dir) to reflect what they configure
  • Related: ADR 0027 (Central Token Mint) is in PR docs: ADR 0029 — central token mint for secretless .fullsend #655

Multi-org: one GCP project, many GitHub orgs

A single GCP project hosts the mint infrastructure for multiple GitHub orgs. Each admin install merges its org into the existing deployment:

Resource Scoping Merge behavior
WIF pool Shared (fullsend-pool) Created once, idempotent on 409
WIF provider Shared (github-oidc) attributeCondition merged additively (single-org == → multi-org in [...]). oidc.allowedAudiences updated to include both fullsend-mint and the IAM audience. On 409, falls through to PATCH both fields.
Service account Shared (fullsend-dispatch) Created once, idempotent on 409
Secret Manager Per org×role (fullsend-{org}--{role}-app-pem) Each org gets its own PEM per role. SA granted secretAccessor on each secret individually.
Cloud Function Shared (fullsend-mint) ALLOWED_ORGS unioned, ROLE_APP_IDS merged (org-scoped keys: {org}/{role} → app ID). ALLOWED_ROLES derived from merged keys. Redeployed only when env vars or source hash change.

What happens when org B installs after org A?

  1. GetWIFProvider reads existing condition (== 'orgA') and audiences
  2. Merge org lists → ['orgA', 'orgB']
  3. CreateWIFProvider → 409 → UpdateWIFProvider PATCHes both condition (in ['orgA', 'orgB']) and audiences
  4. PEMs stored under fullsend-orgB--{role}-app-pem (orgA's PEMs untouched)
  5. GetFunction reads existing env vars → mergeAllowedOrgs unions org lists → mergeRoleAppIDs merges app ID maps → deploy if changed

Key separator: -- (double hyphen)

GitHub org names allow single hyphens but not consecutive hyphens (--). Role names are fullsend-defined (triage, coder, etc.) and don't use --. This makes fullsend-{org}--{role}-app-pem unambiguously parseable.

Deployment profiles

Three profiles from an org admin's perspective:

Profile Who runs mint infra? GCP project PEM storage GitHub Apps Onboarding a new org
Self-managed You Your own Your project's Secret Manager (fullsend-{org}--{role}-app-pem) Private apps (one set per org) Full admin install per org — PEMs stored org-scoped, WIF/env merged additively
Bundled Central team Shared Shared project's Secret Manager (fullsend-{org}--{role}-app-pem) Public unlisted apps (shared across orgs) admin install --mint-url — stores org-scoped PEMs in shared project, skips infra deploy
SaaS Fullsend-hosted Fullsend Fullsend-managed Fullsend-managed public apps admin install --mint-url — no GCP project needed

Bundled onboarding with public apps still requires PEM storage (one per org×role), since each org's installation has a unique PEM even when sharing the same app.

PAT → OIDC migration

What happens to existing PAT orgs?

Command Behavior
fullsend admin install Overwrites config with oidc-mint, deletes stale FULLSEND_DISPATCH_TOKEN secret, provisions OIDC mint infra. Logs "migrating to OIDC mint" when stale PAT detected.
fullsend admin uninstall BothModesDispatchLayer cleans both PAT secrets and OIDC variables regardless of config.
fullsend admin analyze Reports current state — shows OIDC as "not installed" for PAT orgs (correct).
Enrolled repos reconcile-repos.sh detects stale PAT shim content, opens update PRs to replace with workflow_call shim. Same filename (.github/workflows/fullsend.yaml), no orphaned files.

What was removed

  • templates/shim-workflow.yaml (469-line PAT shim) — deleted
  • NewDispatchTokenLayer, installPAT, createSecret, PromptTokenFunc, promptDispatchToken (~700 lines)
  • workflow_dispatch trigger from dispatch.yml — only workflow_call remains
  • CLI --dispatch-mode=pat acceptance — replaced by --mint-provider (default gcf)
  • All PAT e2e automation: createDispatchPAT, deleteDispatchPAT, deleteAllDispatchPATs (~590 lines)

What was kept (for migration cleanup)

  • uninstallPAT / analyzePAT — used by NewBothModesDispatchLayer to detect and clean stale PAT secrets during uninstall
  • Stale PAT cleanup in installOIDC — auto-deletes FULLSEND_DISPATCH_TOKEN when migrating, logs warning if check fails

Architecture

ENROLLED REPO                          TOKEN MINT (Cloud Function)
─────────────                          ──────────────────────────
fullsend.yaml (workflow_call shim)     1. Validate OIDC JWT (WIF STS)
  │                                    2. Check job_workflow_ref claim
  ├─ Trigger: issue/PR event           3. Derive org from repository_owner
  ├─ workflow_call → .fullsend         4. Fetch PEM from Secret Manager
  └─ dispatch.yml                           (fullsend-{org}--{role}-app-pem)
       ├─ Mint token via OIDC          5. Mint scoped installation token
       ├─ Checkout .fullsend           6. Return {token, expires_at}
       └─ gh workflow run → agent workflows

Token flow (OIDC only)

Token source Checkout target --repo target
OIDC mint via Cloud Function job.workflow_repository = .fullsend $DISPATCH_REPO = .fullsend

vars.FULLSEND_MINT_URL (org variable) stores the mint endpoint. Both the OIDC JWT and minted installation token are masked with ::add-mask::.

Secret naming convention

Format Example Scope
fullsend-{org}--{role}-app-pem fullsend-acme--coder-app-pem One PEM per org×role in Secret Manager
{org}/{role} acme/coder Keys in ROLE_APP_IDS env var (JSON map → app ID)

Org-scoped naming means each org gets its own PEM per role. The mint's AccessPEM(ctx, org, role) derives the org from the validated JWT's repository_owner claim — never from user input.

What's new

Token mint (internal/mint/)

  • HTTP handler: validates OIDC tokens (defense-in-depth pre-validation + authoritative STS exchange), mints scoped GitHub App installation tokens per role
  • Role-based permission downscoping (triage, coder, review, fix, fullsend)
  • job_workflow_ref claim pins requests to .fullsend workflow files
  • Multi-org: org derived from JWT repository_owner claim (validated against ALLOWED_ORGS env var), not hardcoded
  • Org-scoped PEM access: PEMAccessor.AccessPEM(ctx, org, role) — org from validated JWT, secrets stored as fullsend-{org}--{role}-app-pem
  • Clock skew tolerance: 30s grace on OIDC token expiry to handle issuer/host clock drift
  • STS error diagnostics: non-200 STS responses include response body for debugging WIF misconfigurations
  • Bounded response body drains: all error-path io.Copy(io.Discard, resp.Body) calls use io.LimitReader(resp.Body, 4096)

GCP provisioner (internal/dispatch/gcf/)

  • gcf.go: Provisions WIF pool/provider, service account, Secret Manager secrets, Cloud Function deployment
  • gcp.go: GCP REST API client for all infrastructure operations
  • Self-managed mode: deploys full infrastructure
  • Existing mint mode (--mint-url): stores PEMs in existing mint's Secret Manager, skips deployment
  • Multi-org: Config.GitHubOrgs []string with per-org validation, lowercase normalization, and deduplication
  • Additive provisioning: mergeAllowedOrgs unions org lists, mergeRoleAppIDs merges org-scoped app ID maps, deriveAllowedRoles extracts unique roles from merged keys
  • WIF provider merge: reads existing provider via GetWIFProvider, merges org lists, creates or updates with merged condition + dual audiences
  • Dual WIF audiences: registers both fullsend-mint and https://iam.googleapis.com/projects/{num}/.../providers/{id} (required by google-github-actions/auth@v3 for STS exchange)
  • Separate validation patterns: gcpProjectIDPattern for project IDs, gcpRegionPattern for regions
  • IAM conflict retry: SetSecretIAMBinding retries on 409 Conflict with escalating backoff (read-modify-write race protection)
  • Source bundle caching: bundleFunctionSource called once during validation and reused at deploy time (eliminates TOCTOU window and redundant I/O)

App setup (internal/appsetup/)

  • --public flag: AppManifest supports public: true for creating public unlisted GitHub Apps
  • App slug convention: AppSlug(role) returns fullsend-{role} (no org prefix)

Dispatch workflow (dispatch.yml)

  • OIDC-only token acquisition: mints dispatch token via Cloud Function, no PAT fallback
  • workflow_call only: secrets, vars, and github.repository resolve from the caller — dispatch.yml uses OIDC mint for its own token
  • No secrets flow through the dispatch chainsecrets: {} in shims is correct by design
  • Security hardening: OIDC JWT masked before use, no intermediate response variable
  • Transient failure resilience: curl --retry 3 --retry-delay 2 --retry-all-errors on both OIDC token and mint requests

Shim workflow (templates/shim-workflow-call.yaml)

  • Native workflow_call shim — enrolled repos call dispatch.yml directly with secrets: {}
  • All 7 dispatch jobs declare id-token: write for OIDC flow

Infrastructure layers

  • internal/layers/configrepo.go: Config repo always created as public; warns when existing repo is private (workflow_call will fail for non-private callers)
  • internal/layers/dispatch.go: OIDC mode wiring, stale PAT migration logging with error visibility
  • internal/config/config.go: oidc-mint is the only accepted dispatch mode
  • internal/cli/admin.go: --mint-provider (default gcf), --mint-project, --mint-region, --mint-source-dir, --mint-url, --public flags
  • internal/forge/: Org-level variable CRUD support
  • internal/gcp/: Shared GCP ADC + HTTP client

Security model

  • No secrets in enrolled repos — OIDC tokens are the only credential, issued by GitHub's runtime
  • No secrets in .fullsend — PEMs stored in Secret Manager, tokens minted at runtime
  • job_workflow_ref authorization — only .fullsend workflow files can request tokens
  • WIF repository_owner condition — cross-org token requests rejected at the GCP level
  • Two-layer org validation — (1) pre-validation against ALLOWED_ORGS, (2) WIF STS exchange with CEL attribute condition on repository_owner
  • Org derived from signed JWTrepository_owner is cryptographically signed, not user-supplied
  • Org-scoped PEMs — each org's PEM is stored under fullsend-{org}--{role}-app-pem. The org in AccessPEM is derived from the validated JWT, not from user input. Even if an attacker could influence the org parameter, Secret Manager path resolution would reject traversal attempts.
  • Script injection prevention — all attacker-controlled fields passed via env: + jq --arg
  • Fork PR blocking — fix agent checks head.repo == base.repo before dispatching
  • PEM zeroing — private keys scrubbed from memory via defer after use
  • Token masking — both OIDC JWT and minted token masked with ::add-mask::

Test plan

  • go test ./internal/... — all unit tests pass (19 packages)
  • go vet ./internal/... — clean
  • go vet -tags e2e ./e2e/... — e2e tests compile clean
  • Multi-org provisioner tests: WIF CEL, per-org PEM storage, duplicate org rejection, additive merge of env vars and WIF condition
  • Multi-org handler tests: org derived from JWT, wrong org rejected
  • WIF audience tests: both fullsend-mint and IAM audience registered on create and update
  • PAT rejection: config validation rejects dispatch.mode: "pat"
  • Stale PAT cleanup: install detects and removes FULLSEND_DISPATCH_TOKEN, logs migration message
  • BothModes uninstall/analyze: cleans both PAT and OIDC artifacts
  • Edge cases: nil dispatcher, stale cleanup failure continuation, delete variable error propagation
  • Org-scoped PEM naming: secretID(org, role) returns fullsend-{org}--{role}-app-pem, AccessPEM(ctx, org, role) signature verified
  • App slug convention: AppSlug(role) returns fullsend-{role} without org prefix
  • 2 rounds of 10-agent review (claude-coder, claude-researcher, gemini, cursor) — all MEDIUM+ issues resolved
  • Rebased on latest main (a7c1e50), 2 import conflicts resolved, all tests pass
  • Manual: fullsend admin install --mint-project=... nonflux — 5 existing nonflux-* apps reused via loadKnownSlugs, PEMs stored with org-scoped naming, mint redeployed, enrollment succeeded. Found and fixed 2 bugs: Secret Manager replication.autoreplication.automatic (API 400), coder/review manifest issues:readissues:write (permission mismatch with installed apps).
  • Manual: fullsend admin install --public --mint-project=... myorg for multi-org
  • Manual: verify enrolled repo shim update PR created by reconcile-repos.sh
  • Manual: verify dispatch chain: enrolled repo → shim → dispatch.yml → OIDC mint → agent workflows

@github-actions
Copy link
Copy Markdown

github-actions Bot commented Apr 28, 2026

Site preview

Preview: https://d995ab16-site.fullsend-ai.workers.dev

Commit: 86acd66a1fbdb154d96105e86f699d3566664124

Copy link
Copy Markdown

@fullsend-ai-review fullsend-ai-review Bot left a comment

Choose a reason for hiding this comment

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

Review: #503

Head SHA: 7d62d54
Timestamp: 2026-04-28T00:00:00Z
Outcome: request-changes

Summary

This PR adds a well-architected OIDC dispatch mode as an alternative to PAT-based dispatch, directly addressing #308. The design follows existing patterns (mode-switched layer, interface-based GCP client), the test coverage is thorough, and the shim workflow correctly uses environment variables to prevent script injection. However, there are two high-severity issues that must be resolved before merge: (1) the Cloud Function source code is never uploaded to GCS, so deployment will fail at runtime, and (2) the workflow_file request parameter is used unsanitized in the dispatch URL path, creating a path traversal risk in the security-critical dispatch proxy.

Findings

High

  • [Correctness] internal/dispatch/gcf/gcf.go:149-162 — Cloud Function source never uploaded to GCS. The Provision method calls CreateFunction which references a storage source ({projectID}-gcf-source/{functionName}/source.zip), but no step in the provisioning flow uploads the Go source zip to that bucket. The FunctionConfig.Source field exists but is never populated. Deployment will fail at runtime with a "source not found" error.
    Remediation: Add a step between secret provisioning and function deployment that (a) creates the GCS bucket if needed, (b) zips the embedded Cloud Function source, and (c) uploads it to the expected storage path. The implementation plan mentions //go:embed for the source — implement this.

  • [Platform security] internal/dispatch/gcf/function/main.go:175-178workflow_file input is not validated before being interpolated into the GitHub API URL path. A caller that passes OIDC validation (any repo in the org) could send a workflow_file value containing path traversal characters (e.g., ../../other-org/other-repo/actions/workflows/evil.yml) to construct a dispatch URL targeting a different repository. While the GitHub API would likely reject malformed paths, defense-in-depth requires validating the input at the proxy layer.
    Remediation: Validate req.WorkflowFile against a strict pattern like ^[a-zA-Z0-9_.-]+\.ya?ml$ before using it in URL construction. Reject requests that don't match.

  • [Platform security] internal/dispatch/gcf/function/main.go:175source_repo in the request body is trusted without verification against the OIDC token claims. After OIDC validation, the Cloud Function knows the caller is from the configured org, but source_repo is taken from the JSON body, not from the OIDC token's repository claim. A workflow in org repo A could dispatch with source_repo set to org repo B, potentially causing the downstream triage/code/review workflow to operate on the wrong repository context.
    Remediation: After STS token exchange, decode the original OIDC token's repository claim and validate that req.SourceRepo matches it. Alternatively, extract source_repo from the token itself rather than the request body.

Medium

  • [Correctness] internal/dispatch/gcf/gcf.go:155-162 — Cloud Function URL is a hardcoded format string placeholder, not the actual deployed URL. When a new function is created, the code constructs https://{region}-{project}.cloudfunctions.net/{name} and discards the LRO operation name (_ = opName). Cloud Functions v2 use Cloud Run URLs (https://{name}-{hash}-{region}.a.run.app), not the v1 format. The stored FULLSEND_DISPATCH_URL will be wrong.
    Remediation: After CreateFunction returns the operation name, poll the operation until complete, then call GetFunction to retrieve the actual serviceConfig.uri.

  • [Platform security] internal/dispatch/gcf/gcp.go:279-294 — No concurrency or instance limits set on the Cloud Function. The implementation plan explicitly called out DoS mitigation via "Cloud Function concurrency caps," but the CreateFunction payload doesn't set maxInstanceCount or maxInstanceRequestConcurrency in the service config.
    Remediation: Add "maxInstanceCount": 10 (or a configurable value) to the serviceConfig in the function creation payload.

  • [Correctness] internal/dispatch/gcf/gcp.go:189-211SetIAMBinding appends a new binding on every call without checking for duplicates. Repeated fullsend admin install runs will accumulate duplicate IAM bindings on the secret resource.
    Remediation: Before appending, check whether the role+member combination already exists in the current policy bindings.

Low

  • [Style/conventions] internal/dispatch/gcf/function/main.go:395-409GenerateTestRSAKey is exported in production code (function/main.go) rather than in a test file. It uses a 1024-bit key explicitly marked "DO NOT use in production" but is available to any importer.
    Remediation: Move this function to a _test.go file or a testutil package.

  • [Platform security] internal/dispatch/gcf/function/main.go:131 — Error responses from the Cloud Function include internal details (e.g., "OIDC validation failed: STS returned 403: {GCP error body}"). Since the function is internet-accessible to org members, these details could leak GCP infrastructure information.
    Remediation: Return generic error messages to callers and log detailed errors server-side.

  • [Correctness] internal/cli/admin.go:218-233 — When dispatchMode == "oidc-gcf", the code searches agentCreds for a credential with Role == "fullsend" but silently leaves appPEM and appID as zero values if none is found. The provisioner will reject empty PEM, but the error message ("GitHub App PEM is required") won't indicate that the root cause is a missing fullsend role credential.
    Remediation: Add an explicit check: if no fullsend role credential is found, return a descriptive error like "--dispatch-mode=oidc-gcf requires a 'fullsend' role agent credential".

Info

  • [Style/conventions] The DispatchTokenLayer struct name is now a misnomer in OIDC mode since it doesn't manage a token. Consider renaming to DispatchLayer in a follow-up to match the conceptual shift.

  • [Correctness] The shim workflow OIDC template (shim-workflow-oidc.yaml) correctly uses pull_request_target (not pull_request) and passes event data via environment variables, following security best practices for untrusted PR workflows. The core.setSecret(token) call properly masks the OIDC token in logs.

Footer

Outcome: request-changes
This review applies to SHA 7d62d547b18b8cc5e8416f6a2e9afff7a91c9b96. Any push to the PR head clears this review and requires a new evaluation.

Copy link
Copy Markdown

@fullsend-ai-review fullsend-ai-review Bot left a comment

Choose a reason for hiding this comment

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

Review: #503

Head SHA: 04bc260
Timestamp: 2026-04-28T22:00:00Z
Outcome: request-changes

Summary

This PR adds a well-architected OIDC dispatch mode via GCP Cloud Functions, with strong security design (WIF attribute conditions, OIDC pre-validation, input sanitization, PEM zeroing). However, the Cloud Function deployment path is incomplete — the source upload to GCS is missing and the function module lacks GCF v2 entry point registration, which means fullsend admin install --dispatch-mode=oidc-gcf will fail at the deploy step. The installation token requested by the Cloud Function is also unnecessarily broad (full app permissions instead of scoped-down actions:write on .fullsend). These issues must be resolved before merging.

Findings

High

  • [correctness] internal/dispatch/gcf/gcp.go:431-480 — Cloud Function deployment is incomplete. CreateFunction references a GCS storage source (projectID-gcf-source bucket, functionName/source.zip object) but no code creates the bucket or uploads the source zip. The FunctionConfig.Source field (line 35) holds []byte for zipped source but is never used in CreateFunction. Furthermore, Provision() in gcf.go:184 passes an empty Source field. The PR description mentions source "embedded via //go:embed" but no //go:embed directive exists anywhere in the diff. Deployment will fail with a missing-source error.
    Remediation: Add a GCS bucket creation step and a source upload step before calling CreateFunction. Either embed the function source via //go:embed in the provisioner binary or use the Cloud Functions v2 inline upload API (generateUploadUrl + PUT).

  • [correctness] internal/dispatch/gcf/function/main.go — The Cloud Function module lacks GCF v2 entry point registration. Cloud Functions v2 (Go) requires calling functions.HTTP("functionName", handler) in an init() function (from github.com/GoogleCloudPlatform/functions-framework-go). The module has no init(), no main(), and no functions-framework dependency in go.mod. Even if the source were uploaded, the Cloud Function would not start.
    Remediation: Add github.com/GoogleCloudPlatform/functions-framework-go to function/go.mod, add an init() that registers the handler via functions.HTTP(), and ensure EntryPoint in FunctionConfig matches the registered name.

Medium

  • [platform-security] internal/dispatch/gcf/function/main.go:390-419createInstallationToken sends an empty POST body, so GitHub returns a token with ALL permissions the app has. The Cloud Function only needs actions:write on the .fullsend repo. An over-scoped token increases blast radius if the function is compromised.
    Remediation: Scope the installation token request by including {"permissions": {"actions": "write"}, "repositories": [".fullsend"]} in the POST body. This follows the principle of least privilege.

  • [correctness] internal/dispatch/gcf/gcp.go:483-517WaitForOperation polls every 5 seconds with no maximum iteration count or built-in timeout. If the operation never completes and the caller's context has no deadline (CLI commands typically don't), this will block indefinitely.
    Remediation: Add a maximum timeout (e.g., 10 minutes) or iteration cap. Consider context.WithTimeout wrapping the poll loop.

  • [correctness] internal/forge/github/github.go:1480-1498OrgVariableExists doesn't handle HTTP 403 like the existing OrgSecretExists does (which returns a clear "insufficient permissions" error). When the token lacks admin:org scope, the user gets a generic error instead of actionable guidance.
    Remediation: Add a case http.StatusForbidden branch mirroring OrgSecretExists (line 1394-1395).

Low

  • [correctness] internal/dispatch/gcf/gcf.go:143-153 — When the Secret Manager secret already exists (secretExists == true), AddSecretVersion is skipped. If the GitHub App PEM has been rotated since the last install, re-running install will leave the stale PEM in Secret Manager, causing dispatch failures with the new key.
    Remediation: Consider always adding a new secret version (Secret Manager retains version history), or add a --force-update-pem flag.

Info

  • [style] internal/dispatch/gcf/function/main.go:41event_type is validated as non-empty but not restricted to known values (issues, issue_comment, pull_request_target). An allowlist would provide defense-in-depth against unexpected event routing.

Footer

Outcome: request-changes
This review applies to SHA 04bc260d747ac3c1df05c4b0746acca46da27d2d. Any push to the PR head clears this review and requires a new evaluation.

waynesun09 added a commit that referenced this pull request Apr 29, 2026
- Add GCF v2 entry point registration with functions-framework-go
- Add SecretAccessor implementation using metadata server auth
- Add source upload via generateUploadUrl API before CreateFunction
- Scope installation token to actions:write only (least privilege)
- Add 10-minute timeout to WaitForOperation polling
- Add 403 handling to OrgVariableExists matching OrgSecretExists

Signed-off-by: Wayne Sun <gsun@redhat.com>
@fullsend-ai-review
Copy link
Copy Markdown

fullsend-ai-review Bot commented Apr 29, 2026

Review: #503

Head SHA: 86acd66
Timestamp: 2026-05-12T00:00:00Z
Outcome: comment-only

Summary

This PR is a substantial, well-structured migration from PAT-based dispatch to OIDC token minting. The security architecture is sound: OIDC tokens are the only credential, PEM keys are stored in Secret Manager (never in GitHub), job_workflow_ref pins requests to .fullsend workflows, and the two-layer org validation (pre-validation + WIF STS) provides strong defense-in-depth. The code demonstrates careful attention to script injection prevention (env vars + jq --arg throughout), token masking, and PEM zeroing. No critical or high findings were identified. The findings below are medium/low/info improvements that would strengthen defense-in-depth and maintainability.

Findings

Medium

  1. [correctness] internal/dispatch/gcf/provisioner.go:539-542mergeRoleAppIDs silently returns without error when existing or desired ROLE_APP_IDS JSON is malformed. This means corrupted state in a deployed function's env vars will silently prevent merging — the new org's values overwrite all existing entries rather than merging additively, with no warning to the operator.
    Remediation: Return an error from mergeRoleAppIDs when JSON parsing fails and propagate it to the caller.

  2. [correctness] internal/dispatch/gcf/provisioner.go:591-603parseConditionOrgs uses naive string-split-on-single-quotes to extract org names from WIF CEL conditions. If the condition format changes or includes additional predicates, parsing will produce incorrect results. For example, a condition with assertion.actor != 'org2' would incorrectly extract org2 as a valid org.
    Remediation: Validate that the condition exactly matches one of the two known formats before parsing, and return an error if it does not match.

  3. [security] internal/scaffold/fullsend-repo/.github/scripts/setup-agent-env.sh:12 — The heredoc delimiter uses $RANDOM (15-bit value) + $$ (PID). An attacker controlling environment variable content could potentially inject a matching delimiter to break out of the heredoc. The fix.yml workflow already uses openssl rand -hex 8 for its delimiter.
    Remediation: Replace ENV_${RANDOM}_${RANDOM}_$$ with ENV_$(openssl rand -hex 16) for a cryptographically random delimiter.

  4. [security] internal/scaffold/fullsend-repo/.github/workflows/triage.yml:47 — The triage workflow's app-token is generated without permission-* restrictions, receiving the full default permissions of the GitHub App installation. Other workflows (review, code, fix) correctly create separate scoped tokens.
    Remediation: Split into a read-only sandbox token and a scoped write token with only permission-issues: write, matching the pattern used by other workflows.

  5. [security] internal/scaffold/fullsend-repo/.github/workflows/prioritize.yml:42 — Same over-scoped token issue as triage — the prioritize workflow's app-token is unscoped.
    Remediation: Scope to only the permissions the prioritize agent requires.

  6. [test-integrity] internal/mint/main_test.go:502-528TestHandler_OIDCPrevalidation_MissingJobWorkflowRef omits iat from the test token claims, causing it to fail on the iat == 0 check (line 557-558 of main.go) before reaching the job_workflow_ref validation that the test intends to exercise. The test passes (asserts 403) but tests the wrong code path.
    Remediation: Add "iat": time.Now().Unix() to the claims map to ensure the test reaches the job_workflow_ref check.

  7. [correctness] internal/mint/main.go:280-288 — The oidcClaims struct does not parse sub (subject) or nbf (not-before) claims. While STS validates the full token, the pre-validation layer could catch nbf violations early, and logging sub would provide a valuable audit trail of who requested tokens.
    Remediation: Add Sub and NotBefore fields to oidcClaims, validate nbf, and log sub in the success path.

Low

  1. [correctness] internal/dispatch/gcf/provisioner.go:60-61rolePattern allows hyphens in role names ([a-z][a-z0-9_-]*), but no check prevents -- in role names. A role name containing -- would create ambiguous secret IDs (e.g., fullsend-org--ro--le-app-pem) that break the org/role parsing convention.
    Remediation: Add strings.Contains(role, "--") check in the Provision validation loop, matching what StoreAgentPEM does.

  2. [maintainability] internal/scaffold/fullsend-repo/.github/workflows/retro.yml and prioritize.yml — These workflows inline GCP authentication steps rather than using the setup-gcp composite action. This duplicated logic will drift when GCP auth is updated (e.g., OIDC migration).
    Remediation: Refactor to use ./.github/actions/setup-gcp like the other agent workflows.

  3. [maintainability] internal/scaffold/fullsend-repo/.github/workflows/retro.yml:40-56 — The retro workflow duplicates enrollment validation inline rather than using the validate-enrollment composite action.
    Remediation: Replace with uses: ./.github/actions/validate-enrollment.

  4. [test-coverage] internal/mint/main.go:687-688 — The retro and prioritize roles are defined in rolePermissions but have no entries in the test fixture ROLE_APP_IDS (testmain_test.go). No test verifies their permission downscoping, including prioritize's organization_projects: write.
    Remediation: Add test fixture entries and test cases for these roles.

Info

  1. [defense-depth] internal/mint/main.go — The pre-validation / STS two-layer design is well-architected. Pre-validation catches obvious issues cheaply before making external API calls. STS is the authoritative validator.

  2. [defense-depth] internal/scaffold/fullsend-repo/.github/workflows/dispatch.yml — Reading from $GITHUB_EVENT_PATH (on-disk JSON) instead of toJSON(github.event) to build the event payload is a strong script injection defense.

  3. [defense-depth] internal/mint/main.go:620-624 — PEM zeroing via defer is good practice, though Go's GC may retain intermediate copies.

  4. [defense-depth] internal/dispatch/gcf/gcp.go:634-643 — Upload URL validation prevents SSRF by checking the host is *.storage.googleapis.com with HTTPS.

PR-Specific Checks

PR body injection defense: The PR body contains no non-rendering Unicode, no prompt injection patterns, and no directives to skip checks or approve unconditionally. The body is a thorough technical description of the change. Clean.

Scope authorization: No linked issue. The PR is labeled fullsend-no-fix. The scope — deprecating PAT dispatch and migrating to OIDC token minting — is a major architectural change that is coherent and self-consistent. No scope concern.

Footer

Outcome: comment-only
This review applies to SHA 86acd66a1fbdb154d96105e86f699d3566664124. Any push to the PR head clears this review and requires a new evaluation.

Previous run

Review: #503

Head SHA: 3676a72
Timestamp: 2026-05-12T00:00:00Z
Outcome: comment-only

Summary

This is a well-architected migration from PAT-based dispatch to OIDC token minting with comprehensive security controls. The token mint handler implements defense-in-depth with two-layer org validation (prevalidation + STS), job_workflow_ref pinning, role-scoped permissions, PEM zeroing, bounded reads, and proper input validation. The GCP provisioner handles multi-org merge scenarios correctly with additive WIF conditions and per-org PEM storage. Test coverage is thorough (1097+ lines for the mint handler alone, covering org boundaries, input validation, and full flow). The PAT deprecation path retains cleanup stubs for migration. No critical or high findings — the items below are defense-in-depth improvements worth considering.

Findings

Medium

  • [Platform security] internal/mint/main.go:139-144smPEMAccessor.AccessPEM validates the role parameter with rolePattern before interpolating it into the Secret Manager path, but does not validate org. The org value is constrained by the ALLOWED_ORGS allowlist check in prevalidateOIDCToken and comes from a cryptographically signed JWT claim, so exploitation risk is low. However, adding an explicit org format check (analogous to the role check) would provide defense-in-depth consistency and guard against future code paths that might call AccessPEM without prior validation.
    Remediation: Add if !githubOrgPattern.MatchString(org) { return nil, fmt.Errorf("invalid org name %q", org) } at the start of AccessPEM, using the same githubOrgPattern already defined in internal/dispatch/gcf/provisioner.go. Consider extracting the pattern to a shared package.

Low

  • [Correctness] internal/mint/main.go:116 — The PR description states "STS error diagnostics: non-200 STS responses include response body for debugging WIF misconfigurations", but the actual implementation deliberately discards the body (io.Copy(io.Discard, ...)) with a comment explaining it avoids leaking GCP project details. The code behavior is correct and more secure — the description is simply stale. No code change needed; updating the PR description for accuracy would avoid confusion for future readers.

  • [Style/conventions] internal/mint/main.go:73internalClient is a package-level *http.Client shared by smPEMAccessor and metadataToken. It has a 10s timeout while the handler's httpClient (used for GitHub API calls) has 30s. The asymmetry is fine for the different use cases but the package-level var makes the PEM accessor harder to test independently. Since the accessor already works well with the fake interface in tests, this is a minor structural observation.

Info

  • [Platform security] internal/dispatch/gcf/gcp.go:470-564SetCloudRunInvoker grants allUsers the roles/run.invoker role, making the Cloud Function publicly invocable from the internet. This is documented as intentional — the function's OIDC validation is the security boundary, not network-level access control. This is an accepted design trade-off for the OIDC flow to work from GitHub Actions runners, but it does expand the attack surface to include unauthenticated probing (rate limiting, health endpoint information disclosure, etc.).

  • [Correctness] internal/dispatch/gcf/provisioner.go:384-404 — When building orgScopedAppIDs, the same AgentAppIDs map (role→appID) is applied to every org in GitHubOrgs. This means all orgs sharing a GCP project must use the same set of app IDs per role. The PR description's multi-org table documents that public apps can be shared, and per-org private apps get their own PEMs. This is consistent, but if an org has different app IDs per role (e.g., from a prior installation with different apps), the current org's values will overwrite them. The mergeRoleAppIDs function preserves existing entries for other orgs, so this is only a concern if the same org reinstalls with different app IDs (which is the intended overwrite behavior).

  • [Injection defense] PR body — No prompt injection, non-rendering Unicode, or suspicious directives detected. The PR body is a thorough technical description with no instruction-like patterns targeting the reviewer.

Footer

Outcome: comment-only
This review applies to SHA 3676a7218394934dc28959bedf77c90efa6a699a. Any push to the PR head clears this review and requires a new evaluation.

Previous run (2)

Review: #503

Head SHA: cf995d2
Timestamp: 2026-05-11T00:00:00Z
Outcome: comment-only

Summary

This is a substantial and well-engineered migration from PAT-based dispatch to OIDC token minting via a GCP Cloud Function. The security architecture is strong: multi-layer OIDC validation (pre-validation + STS exchange), role-based permission scoping, PEM zeroing, defense-in-depth input validation throughout workflow templates, and systematic script-injection prevention via env: bindings. No critical or high severity issues were found across any of the six review dimensions. The findings below are medium and low severity items covering observability gaps in the migration path, minor race conditions in concurrent provisioning, and a few consistency issues.

Findings

Medium

  • [correctness] internal/layers/dispatch.goanalyzeOIDC does not detect stale PAT secrets. After a partial migration where PAT cleanup failed, fullsend admin analyze will report the dispatch layer as "installed" without flagging the orphaned FULLSEND_DISPATCH_TOKEN secret. Only the install path warns about it (once).
    Remediation: Add a stale-PAT check to analyzeOIDC that sets StatusDegraded when FULLSEND_DISPATCH_TOKEN still exists alongside OIDC variables.

  • [correctness] internal/layers/dispatch.go — Repo-level variable copies set on dot-prefixed repos during installOIDC are not cleaned up during uninstallOIDC. While .fullsend gets deleted by the config-repo layer, other dot-prefixed repos (e.g., .github) retain stale FULLSEND_MINT_URL variables.
    Remediation: Add repo-level variable cleanup to uninstallOIDC for dot-prefixed repos, mirroring the install logic.

  • [correctness] internal/layers/secrets.go — In OIDC mode, Analyze reports stale secrets as WouldFix, but no operation in the layer actually removes them. Install is a no-op in OIDC mode, and Uninstall is always a no-op. The WouldFix entries are misleading.
    Remediation: Either implement stale secret cleanup in Install (OIDC mode), or change the output to indicate manual cleanup is needed.

  • [correctness] internal/dispatch/gcf/provisioner.go:332-345 — Race condition in WIF provider org merge. Between the GetWIFProvider read and the CreateWIFProvider/UpdateWIFProvider write, a concurrent provisioner run could update the provider, and the merged org list would overwrite the other run's additions. The window is narrow in practice since provisioning is typically serialized.
    Remediation: Document the serialization assumption, or re-read the provider before patching on the 409→update fallback.

  • [missing-validation] internal/mint/main.gofindInstallation uses only repos[0] to look up the installation ID, but the token is scoped to all repos in the list. If repos belong to different installations, the caller gets a confusing 502 instead of an explicit validation error. GitHub's API would reject the mismatch, so this is not exploitable.
    Remediation: Document the single-installation assumption or validate all repos share the same installation.

  • [platform-security] internal/appsetup/appsetup.go:165-168 — The --public flag allows creating GitHub Apps installable by any org via URL. While the OIDC mint enforces org-level audience validation, a misconfigured mint could allow unintended cross-org installation.
    Remediation: Consider adding a confirmation prompt or warning when --public is used.

  • [correctness] Workflow files (code.yml:18, review.yml:18, etc.) — Concurrency group keys use fromJSON(inputs.event_payload) which will fail the workflow opaquely if event_payload contains malformed JSON, rather than producing a clear error.
    Remediation: Accept this as fail-closed behavior, or add a validation step before the concurrency group is evaluated.

Low

  • [consistency] internal/forge/github/types.go:39-41DefaultAgentRoles() returns ["fullsend", "triage", "coder", "review", "fix"] but the codebase now fully supports retro and prioritize roles (workflows, harness, agent configs). If this function drives app creation, those roles' apps won't be created automatically.
    Remediation: Add "retro" and "prioritize" to DefaultAgentRoles(), or rename the function to clarify it returns only core roles.

  • [missing-test] internal/mint/main_test.go — The retro and prioritize roles in rolePermissions have no test coverage. The organization_projects: write permission on prioritize is high-privilege and warrants explicit test verification.
    Remediation: Add test entries for retro and prioritize roles in ROLE_APP_IDS.

  • [missing-test] internal/dispatch/gcf/gcp_test.go — No test covers the 409 retry path in SetSecretIAMBinding, the SecretExists method, or the operation-name regex rejection in WaitForOperation.
    Remediation: Add test cases for these paths.

  • [error-handling] internal/dispatch/gcf/gcp.go:646-648 — The storage upload error path uses string(body) directly instead of ExtractErrorMessage(body), inconsistent with all other error paths which sanitize GCP responses.
    Remediation: Use ExtractErrorMessage for consistency.

  • [correctness] internal/layers/configrepo.go:79,91CreateRepo is called with l.hasPrivate but the success message unconditionally says "(public)". In practice hasPrivate is always false, making this a dead-code inconsistency.
    Remediation: Remove hasPrivate from ConfigRepoLayer or make the message conditional.

  • [hardening] internal/mint/main_test.go:822-833TestHandler_LargeBody passes because truncated data isn't valid JSON, not because the size limit is enforced with an explicit error. A valid JSON payload at exactly 64KB with trailing data would be silently truncated.
    Remediation: Read one extra byte to detect truncation, or document reliance on JSON parsing for oversized payload detection.

Info

  • [security-positive] Workflow templates demonstrate strong injection defense: env: bindings for all attacker-controlled inputs, $GITHUB_EVENT_PATH instead of ${{ toJSON(github.event) }}, random heredoc delimiters (openssl rand -hex), input validation with regex, fork PR blocking in fix workflow, and correct ::add-mask:: usage for OIDC tokens, minted tokens, and mint URLs.

  • [security-positive] Token mint implements defense-in-depth: pre-validation + STS exchange, job_workflow_ref pinning, ALLOWED_ORGS allowlist, PEM zeroing via defer, 64KB request body limit, bounded response draining, and error messages that avoid leaking internal details.

  • [security-positive] GCP provisioner has correct upload URL SSRF check (validates storage.googleapis.com domain), bounded IAM retry loops (max 3), CEL injection prevention (quote character check in org names), and proper temp file cleanup.

  • [injection-defense] No prompt injection patterns, non-rendering Unicode, bidirectional overrides, or suspicious directive-like content found in the PR body, commit messages, code comments, or string literals.

Footer

Outcome: comment-only
This review applies to SHA cf995d29116205ea550a010276ba56b2791be175. Any push to the PR head clears this review and requires a new evaluation.

Previous run (3)

Review: #503

Head SHA: 75be54d
Timestamp: 2026-05-11T00:00:00Z
Outcome: comment-only

Summary

This is a well-structured migration from PAT-based dispatch to OIDC token minting. The security posture improves significantly: long-lived PATs are eliminated, tokens are short-lived and role-scoped, and the mint validates OIDC JWTs via WIF with defense-in-depth (pre-validation + STS exchange). The implementation demonstrates strong security practices throughout — PEM zeroing, bounded response body drains, token masking, script injection prevention via env: + jq --arg, and job_workflow_ref pinning. The PAT removal (~1800 lines of PAT lifecycle code deleted) meaningfully reduces attack surface and e2e test complexity. No critical or high findings were identified; the medium findings below are worth addressing but should not block this PR.

Findings

Medium

  • [correctness] internal/mint/main.go (rolePermissions map) — The retro and prioritize roles are defined in rolePermissions but excluded from test defaults (which only cover triage, coder, review, fix, fullsend), leaving zero test coverage for these permission maps. If these role permission maps had typos or wrong access levels, tests would not catch it.
    Remediation: Add retro and prioritize to the test helper defaults and add test cases that exercise these roles.

  • [correctness] internal/dispatch/gcf/provisioner.go:536-586bundleFunctionSource only processes top-level files in the source directory, silently excluding subdirectories. Currently safe because internal/mint/ is flat, but fragile if the mint ever grows subpackages.
    Remediation: Use filepath.WalkDir or document the flat-directory constraint explicitly.

  • [style] internal/layers/dispatch.go:24 — The struct is still named DispatchTokenLayer but it no longer manages a dispatch token — it manages OIDC mint configuration. The constructor NewOIDCDispatchLayer returns a *DispatchTokenLayer, creating a naming mismatch that makes the code harder to follow.
    Remediation: Rename the struct to OIDCDispatchLayer or DispatchLayer.

  • [correctness] internal/dispatch/gcf/gcp.goSetCloudRunInvoker and SetSecretIAMBinding use inconsistent retry patterns for 409 Conflict responses (typed conflictError vs. plain error string with done bool return). This makes the retry behavior harder to reason about and test uniformly.
    Remediation: Unify on a single retry-on-conflict pattern across both functions.

Low

  • [least-privilege] internal/scaffold/fullsend-repo/.github/workflows/code.yml, fix.yml — The code and fix workflows now use a single mint-token call and the same token for both sandbox (read-only clone) and push operations. The previous implementation had separate token calls with explicit permission scoping. This reduces least-privilege separation.
    Remediation: If the mint supports permission scoping, consider two mint calls with different scopes. Otherwise, document this as a known trade-off.

  • [correctness] internal/layers/configrepo.go:22 — The hasPrivate parameter is always passed as false in both runDryRun and runInstall. This parameter and the conditional logic it drives are effectively dead code.
    Remediation: Remove the dead parameter or document when it would be non-false.

  • [style] internal/mint/main.go — Import ordering: errors placed between crypto and crypto/rand breaks alphabetical stdlib grouping.
    Remediation: Move errors to its alphabetical position.

  • [correctness] internal/layers/dispatch.go:99-108 — The config repo ID auto-inclusion logic has no test for the case where GetRepo fails (config repo not yet created). The code silently skips adding the config repo ID, which is correct behavior, but is undocumented by tests.
    Remediation: Add a test case for GetRepo failure.

Info

  • [security-improvement] The migration from a shared long-lived PAT to per-run OIDC tokens scoped per-role is a meaningful security improvement. The two-layer org validation (pre-validation against ALLOWED_ORGS + WIF STS CEL condition on repository_owner) is solid defense-in-depth.

  • [injection-defense] All workflow files correctly avoid shell injection — no ${{ }} expressions appear inside run: blocks; all attacker-controlled GitHub event fields are passed via env: and consumed through shell variable expansion or jq parsing. The event payload construction in dispatch.yml uses $GITHUB_EVENT_PATH (on-disk JSON parsed by jq) rather than toJSON(github.event) expression interpolation.

  • [masking] Token masking is complete across all token-handling steps. The mint-token composite action masks $MINT_URL, $OIDC_TOKEN, and the final $TOKEN. Dispatch.yml inline OIDC logic also masks all three.

  • [scope] The PR cleanly removes ~1800 lines of PAT lifecycle code (596 lines from e2e PAT helpers, 529 lines from the PAT shim template, ~700 lines from PAT dispatch layer code). The reconcile-repos.sh script properly guards private repos with a fail-closed check.

  • [pr-body] No prompt injection patterns or non-rendering Unicode detected in the PR description or commit messages.

Footer

Outcome: comment-only
This review applies to SHA 75be54df839f7ccdac0f2fa05c530e8d297ce034. Any push to the PR head clears this review and requires a new evaluation.

Previous run (4)

Review: #503

Head SHA: 309c491
Timestamp: 2026-05-11T17:55:00Z
Outcome: comment-only

Summary

The OIDC token mint implementation is well-engineered with strong security properties — defense-in-depth JWT validation (pre-validation + STS), strict input validation via regex, PEM zeroing after use, token masking in workflows, and safe workflow template patterns (all attacker-controlled data via env: + jq --arg, event payloads read from $GITHUB_EVENT_PATH instead of toJSON(github.event)). The architecture eliminates long-lived secrets from enrolled repos and the config repo, replacing them with OIDC-minted short-lived installation tokens. No critical or high findings were identified. The findings below are informational and defense-in-depth suggestions.

Findings

Critical

(none)

High

(none)

Medium

  • [Intent alignment] docs/ADRs/0033-per-repo-installation-mode.md (deleted) — This PR deletes ADR 0033 which had Accepted status and is referenced by ADR 0034 (centralized-shim-routing-via-dispatch.md, lines 155-164). Deleting an accepted ADR is a significant governance action that goes beyond the stated scope of OIDC token mint dispatch. ADR 0036 (Proposed) and the agent-execution-environment plan are also deleted — these appear unrelated to PAT→OIDC migration. Consider splitting document deletions into a separate PR with explicit justification, or at minimum update ADR 0034's references.
    Remediation: Either restore the deleted ADRs or create a follow-up PR that explicitly supersedes them with a new ADR explaining why they are no longer applicable.

  • [Correctness] internal/mint/main.go (generateAppJWT) — The function parses PEM data into block.Bytes and an *rsa.PrivateKey struct, but only the raw pemData byte slice is zeroed by the caller's defer. The decoded block.Bytes and the parsed RSA key struct remain in memory until garbage collected. This is a defense-in-depth gap — the threat is limited to memory-dump attacks on the Cloud Function runtime.
    Remediation: Zero block.Bytes after parsing the private key, e.g. defer func() { for i := range block.Bytes { block.Bytes[i] = 0 } }().

  • [Style/conventions] internal/dispatch/gcf/provisioner.go + internal/mint/main.gorolePattern regex (^[a-z][a-z0-9_-]*$) is defined independently in both packages. Divergence risk if one is updated without the other.
    Remediation: Extract to a shared package (e.g. internal/dispatch/dispatch.go which already exists) and import from both consumers.

Low

  • [Correctness] Test plan — Three manual verification items remain unchecked: multi-org with --public, shim update PR via reconcile-repos.sh, and full dispatch chain. The test plan is transparent about this, but these cover the end-to-end critical path that unit tests cannot exercise.
    Remediation: Complete manual verification before merging, or document the remaining items as tracked follow-up work.

  • [Style/conventions] internal/scaffold/fullsend-repo/.github/workflows/dispatch.yml — The source_repo input was removed and replaced with ${{ github.repository }} (inherited from the caller via workflow_call). This is a good security improvement, but the Validate inputs step no longer validates source_repo format since it comes from GitHub's trusted context. The removal is correct but the step comment could note why the validation was dropped.

Info

  • [Platform security] The STS error handling correctly discards the response body with bounded reads (io.LimitReader(resp.Body, 4096)) and logs only the status code, preventing leakage of WIF pool names, CEL conditions, or project details that could aid reconnaissance.

  • [Content security] Event payload construction in dispatch.yml properly uses jq from $GITHUB_EVENT_PATH with field selection and truncation (comment.body[:4096]), eliminating the previous toJSON(github.event) expression-context injection risk.

  • [Injection defense] No prompt injection patterns, non-rendering Unicode, bidirectional overrides, or instruction-like directives were found in the PR body, commit messages, or code comments. The PR body's claim of prior agent reviews does not influence this review per zero-trust policy.

  • [Platform security] The two-layer org validation (pre-validation against ALLOWED_ORGS + WIF STS exchange with CEL repository_owner condition) provides robust cross-org isolation. Role-only PEM naming is safe because org isolation is enforced at the OIDC/WIF layer, not the naming layer.

Footer

Outcome: comment-only
This review applies to SHA 309c491ed32cb33761680ac571ec7261b44f599e. Any push to the PR head clears this review and requires a new evaluation.

Previous run (5)

Review: #503

Head SHA: fdf155e
Timestamp: 2026-05-11T00:00:00Z
Outcome: comment-only

Summary

This is a well-architected migration from PAT-based dispatch to OIDC token minting via GCP Cloud Functions. The security model is sound: defense-in-depth with pre-validation + STS exchange, job_workflow_ref pinning, role-based permission downscoping, PEM zeroing, and proper token masking. The code demonstrates thorough input validation, bounded response body drains, and safe handling of secrets throughout. The migration path preserves backward compatibility via BothModesDispatchLayer. Findings are limited to medium and low severity observations — no critical or high issues were identified.

Findings

Medium

  • [Platform security] internal/dispatch/gcf/gcp.go:421-424 — The SetCloudRunInvoker function sets allUsers as the Cloud Run invoker, making the mint endpoint publicly reachable on the internet. While the function's own OIDC validation is the security boundary (and the comment at line 424 acknowledges this), this means the endpoint is exposed to unauthenticated probing, DDoS, and brute-force attempts. Consider whether Cloud Run's built-in IAM could be used with a more restrictive invoker policy (e.g., service account for GitHub's OIDC), or add rate limiting within the function.
    Remediation: Document why allUsers is necessary (GitHub Actions runners don't have GCP identities to authenticate to Cloud Run IAM), or add rate limiting/Cloud Armor in front of the function.

  • [Correctness] internal/mint/main.go:607findInstallation uses only repos[0] to look up the installation ID, but the installation token is then scoped to all repos in req.Repos. If the app is installed per-repo rather than org-wide, this could fail for repos not covered by the installation found via repos[0]. The comment at line 620-622 acknowledges this is for future per-repo support, but the current behavior silently assumes all requested repos share the same installation.
    Remediation: Add a comment or validation that all requested repos must be under the same installation, or iterate repos to verify.

  • [Correctness] internal/dispatch/gcf/provisioner.go:441-444 — The function URL validation only allows *.run.app and *.cloudfunctions.net domains. If GCP introduces new Cloud Run domain patterns (e.g., regional domains), this will fail without an obvious error message. The error message says "not a valid Cloud Run URL" which is helpful, but this is a hardcoded allowlist that may need maintenance.
    Remediation: Consider making the allowed domains configurable or at minimum document this constraint.

Low

  • [Style/conventions] internal/mint/main.go:9-14 — Import grouping has crypto and errors out of standard alphabetical order (errors appears after crypto/rand). Minor style issue.
    Remediation: Reorder to standard goimports grouping.

  • [Correctness] internal/mint/main.go:73internalClient is a package-level http.Client shared by both smPEMAccessor and metadataToken. While http.Client is safe for concurrent use, using a single client for both metadata server calls and Secret Manager API calls means they share the same 10s timeout, which may be too short for Secret Manager under load.
    Remediation: Consider separate clients with role-appropriate timeouts, or use the handler's httpClient (30s timeout) for Secret Manager calls.

  • [Style/conventions] internal/dispatch/gcf/provisioner.go:551-553 — The symlink skip using entry.Type()&os.ModeSymlink != 0 is correct but os.ReadDir entries of type fs.DirEntry may not report symlink status correctly via Type() on all platforms. However since this runs on the developer machine during admin install, this is a minor concern.
    Remediation: No action needed — defensive coding is fine here.

  • [Correctness] internal/layers/dispatch.go:99-108 — The config repo ID deduplication creates a new slice with append(append([]int64(nil), repoIDs...), configRepo.ID) to avoid mutating the original. This is correct but slightly unusual — a comment explaining the defensive copy would help readability.
    Remediation: Add a brief comment.

Info

  • [Intent alignment] PR body references "2 rounds of 10-agent review" which is an unusual claim. The code quality is consistent with thorough review, so this does not raise concern, but it is noted.

  • [Injection defense] PR body and code comments are free of prompt injection patterns and non-rendering Unicode. The PR description is informational and well-structured.

  • [Platform security] The shim workflow correctly uses pull_request_target (not pull_request) which runs the base branch version, preventing PR authors from modifying the workflow to exfiltrate credentials. The fix agent correctly blocks fork PRs. Script injection is prevented by passing attacker-controlled fields via env: and jq --arg.

  • [Platform security] The dispatch.yml workflow correctly reads event payload from $GITHUB_EVENT_PATH (on-disk JSON) rather than using expression contexts like toJSON(github.event), preventing GitHub expression injection.

  • [Platform security] Token masking with ::add-mask:: is applied to OIDC tokens, minted tokens, and mint URLs in all workflow files. PEM zeroing is implemented in both the mint handler and the provisioner.

Footer

Outcome: comment-only
This review applies to SHA fdf155eebafd626ba6c34966e23cd02ac320a750. Any push to the PR head clears this review and requires a new evaluation.

Previous run (6)

Review: #503

Head SHA: 55f39ea
Timestamp: 2026-05-11T00:00:00Z
Outcome: request-changes

Summary

This is a large, well-structured migration from PAT-based dispatch to OIDC-based dispatch via a centralized token mint Cloud Function. The architecture is sound: OIDC tokens replace long-lived PATs, role-based permission downscoping enforces least privilege per agent role, WIF CEL conditions enforce org isolation, and the migration path correctly handles stale PAT cleanup. The code demonstrates strong security practices including bounded error-path response reads, PEM zeroing, token masking, and script injection prevention via env: + jq --arg.

However, three high-severity findings require resolution: (1) the collapse of separate read-only/write tokens into a single role-scoped token removes defense-in-depth within agent execution, (2) the Cloud Run invoker is set to allUsers which removes the infrastructure authentication layer from the token mint, and (3) the source_repo format validation was removed from dispatch.yml without replacement.

Findings

High

  • [privilege-escalation] dispatch.yml, code.yml, fix.yml, review.yml, retro.yml — The old design minted two separate tokens per workflow: a read-only sandbox token for checkout/agent execution and a write-scoped push token used only when needed. The new design mints a single token via the mint-token action and uses it for both CODE_GH_TOKEN (sandbox) and PUSH_TOKEN (write). This means the agent process — which handles untrusted issue/PR content — always runs with write-capable credentials. If the agent is compromised via prompt injection, the attacker immediately has write access. The mint server enforces per-role permission scoping, but within a single role there is no read/write separation.
    Remediation: Either mint two tokens with different permission scopes (e.g., role: coder-read and role: coder-write), or document the explicit design decision that role-level scoping replaces token-level separation, with justification for why the blast radius is acceptable.

  • [platform-security] internal/dispatch/gcf/gcp.go:427-521SetCloudRunInvoker grants roles/run.invoker to allUsers, making the token mint publicly invocable without authentication. The code comment states "The function's own OIDC validation is the security boundary" but this removes defense-in-depth. If the OIDC validation has any bug or bypass, the endpoint is fully exposed. This is especially concerning for an endpoint that mints GitHub App installation tokens.
    Remediation: If public access is required for GitHub Actions OIDC flows (Actions workers lack GCP credentials), document the explicit threat model justification. Consider adding rate limiting or abuse prevention. At minimum, add a comment explaining why allUsers is necessary and what mitigations exist.

  • [platform-security] internal/scaffold/fullsend-repo/.github/workflows/dispatch.yml — The old dispatch.yml validated source_repo format with ^[a-zA-Z0-9._-]+/[a-zA-Z0-9._-]+$ before using it in shell contexts and API calls. The new version removes this validation because source_repo now comes from github.repository (a trusted context value in workflow_call). Defense-in-depth argues for keeping the validation. If dispatch.yml is ever re-exposed as workflow_dispatch or called from an unexpected context, the missing validation becomes exploitable.
    Remediation: Re-add the source_repo format validation. The cost is one regex check; the benefit is protection against future refactoring.

Medium

  • [privilege-escalation] internal/forge/github/types.go:79-100 — Several agent app permissions were escalated: coder and review gain issues:write (was read), triage and prioritize gain contents:read (new). These broaden the trust boundary for agent apps without documented rationale.
    Remediation: Document the rationale for each permission change.

  • [data-exposure] internal/cli/admin.go — The .fullsend config repo is now created as public by default. This exposes config.yaml (agent roles, inference provider, enabled repo list) and workflow logs. While no secrets are stored there, the org configuration metadata may be considered sensitive by some users.
    Remediation: The UI warning is good. Consider also documenting what information is exposed in user-facing docs.

  • [correctness] internal/dispatch/gcf/provisioner.go:324-338 — The WIF CEL condition uses case-sensitive == comparison against lowercased org names. GitHub's repository_owner OIDC claim may preserve original casing. If an org is created as MyOrg, the CEL condition comparing against myorg would reject valid tokens.
    Remediation: Verify whether GitHub's OIDC repository_owner claim is always lowercased. If not, use a case-normalizing CEL expression.

  • [platform-security] internal/scaffold/fullsend-repo/templates/shim-workflow-call.yaml — The dispatch-fix-human job no longer blocks fork PRs at the shim layer. The old shim verified head.repo.full_name == base.repo.full_name before dispatching. The new design defers this check to fix.yml, but the token mint is called before the fork check runs.
    Remediation: Add a job-level if condition blocking fork PRs, or document why the delayed check is acceptable (e.g., OIDC tokens are scoped to .fullsend and cannot access the source repo).

  • [logic-error] internal/forge/github/types.go:61 — App slug changed from <org>-<role> to fullsend-<role>. GitHub App names are globally unique. For --public mode this is intentional (create once, install everywhere), but without --public, multiple orgs would collide on the same app name.
    Remediation: Document collision behavior for non-public mode or consider keeping org-prefixed names for non-public installs.

  • [resource-exhaustion] internal/mint/main.go:127,181,209,647,709 — On success paths, json.NewDecoder(resp.Body).Decode(...) reads response bodies without size limits. Error paths correctly use io.LimitReader(resp.Body, 4096), but success paths are inconsistent.
    Remediation: Wrap success-path response bodies with io.LimitReader (e.g., 1 MB limit).

  • [content-security] internal/scaffold/fullsend-repo/.github/workflows/dispatch.yml — Comment body truncation to 4096 chars may split multi-byte UTF-8 sequences or create a discrepancy between what is validated (truncated body) and what agents later fetch (full body via API).
    Remediation: Truncate at a UTF-8-safe boundary and document that agents must not re-fetch the full comment body, or remove truncation since workflow_call avoids the 65KB workflow_dispatch input limit.

Low

  • [data-exposure] internal/mint/main.go:599-602 — PEM zeroing covers the byte slice but not the parsed rsa.PrivateKey struct or string(pemData) copies. Go string immutability prevents zeroing string-backed PEM copies.

  • [correctness] internal/dispatch/gcf/provisioner.go:548-554 — Symlink detection in bundleFunctionSource relies on DirEntry.Type() which may return DT_UNKNOWN on some filesystems, causing symlinks to go undetected.

  • [platform-security] internal/dispatch/gcf/provisioner.go:249-251MintURL validation in bundled mode (--mint-url) accepts any HTTPS URL, while self-managed mode restricts to .run.app / .cloudfunctions.net domains. A malicious --mint-url could steal OIDC tokens.

  • [logic-error] internal/cli/admin.go--dry-run unconditionally sets Dispatch.Mode = "oidc-mint" without requiring --mint-project.

  • [missing-test] e2e/admin/admin_test.go — No test for stale PAT detection/cleanup during OIDC install (the migration path).

  • [missing-test] internal/scaffold/scaffold_test.go — No test coverage for --public flag behavior on app manifest generation.

Info

  • [injection-defense] PR body, commit messages, code comments — No prompt injection patterns, suspicious Unicode, or agent instruction fragments detected.

  • [platform-security] mint-token/action.yml — OIDC JWT, minted token, and mint URL are all properly masked with ::add-mask:: before use. Good practice.

  • [platform-security] shim-workflow-call.yamlsecrets: {} on workflow_call jobs prevents accidental secret inheritance. Good practice.

  • [style] setup-agent-env.sh — Heredoc delimiter upgraded from $RANDOM/$$ to openssl rand -hex 16. Positive security improvement.

Footer

Outcome: request-changes
This review applies to SHA 55f39ea607532240a6d20e743272b9134c42086a. Any push to the PR head clears this review and requires a new evaluation.

Previous run (7)

Review: #503

Head SHA: 37aa6dc
Timestamp: 2026-05-11T00:00:00Z
Outcome: comment-only

Summary

This is a well-executed migration from PAT-based dispatch to OIDC token minting with a centralized Cloud Function. The security architecture is sound: defense-in-depth with pre-validation + STS exchange, proper input validation, bounded reads, PEM zeroing, and consistent script-injection prevention in workflows. No critical findings. Several medium findings warrant attention — notably incomplete key material zeroing, the job_workflow_ref not being pinned to specific branch refs, a potential symlink traversal in source bundling, and a weak heredoc delimiter in setup-agent-env.sh. The allUsers Cloud Run invoker policy is a deliberate and architecturally correct design choice for an OIDC token endpoint (clients cannot authenticate before they have a token), with the mint's own JWT validation as the security boundary. Overall the change is thorough, well-tested, and ready to ship with the medium items tracked for follow-up.

Findings

Critical

None.

High

None.

Medium

  • [platform-security] internal/dispatch/gcf/provisioner.go:596SetCloudRunInvoker grants allUsers the roles/run.invoker role, making the Cloud Function publicly invocable. This is architecturally correct for an OIDC token endpoint (clients need to reach it pre-authentication), and the mint's own OIDC JWT validation is the security boundary. However, any flaw in the mint's validation logic would leave the function unprotected at the network layer. Remediation: Document this as an explicit design decision; consider adding a WAF or rate-limiting layer in front of the function for defense-in-depth.

  • [auth-bypass] internal/mint/main.go:550-556 — The job_workflow_ref validation checks that the ref starts with {owner}/.fullsend/.github/workflows/ but does not validate the @ref suffix. An attacker with push access to a non-default branch of .fullsend could run arbitrary workflow code from that branch while still passing validation. Remediation: Pin to @refs/heads/main or a configurable allowlist of branch refs, or document that .fullsend branch protection is a hard security requirement.

  • [correctness] internal/mint/main.go:599-603 — PEM zeroing only clears the raw pemData byte slice, but pem.Decode and x509.ParsePKCS1PrivateKey create copies of the key material in the *pem.Block and *rsa.PrivateKey structs that are not zeroed. Remediation: Zero block.Bytes, key.D, key.Primes, and key.Precomputed fields after JWT generation.

  • [correctness] internal/appsetup/appsetup.gorecoverPEM defers zeroing pemData but the string copy (pemStr) survives and is returned to the caller, making the zeroing cosmetic. Remediation: Return []byte instead of string, or document that the caller is responsible for zeroing.

  • [platform-security] internal/dispatch/gcf/provisioner.go:693-695 — The symlink check in bundleFunctionSource uses entry.Type()&os.ModeSymlink != 0, but os.ReadDir may resolve symlinks, so Type() may not reliably report ModeSymlink. A symlink pointing outside the source directory could allow arbitrary file inclusion in the zip bundle. Remediation: Use os.Lstat on the full path to check for symlinks explicitly.

  • [platform-security] internal/dispatch/gcf/gcp.go:3162-3168 — Upload URL validation uses strings.HasSuffix(parsedURL.Host, ".googleapis.com") which could match a domain like evil-googleapis.com. Remediation: Require the host ends with .storage.googleapis.com or is exactly storage.googleapis.com.

  • [injection-defense] internal/scaffold/fullsend-repo/.github/scripts/setup-agent-env.sh:12 — Heredoc delimiter uses $RANDOM (15-bit) and $$ (PID), both low-entropy. An attacker who can control environment variable values and predict these could craft a value containing the delimiter to inject into GITHUB_ENV. The fix workflow already uses the stronger openssl rand -hex 8 pattern. Remediation: Use openssl rand -hex 16 for the delimiter.

Low

  • [correctness] internal/dispatch/gcf/provisioner.go:559 — Runtime hardcoded as "go126". Verify this is a valid Cloud Functions v2 runtime identifier.

  • [correctness] internal/dispatch/gcf/provisioner.gobundleFunctionSource only includes top-level files via os.ReadDir, silently skipping subdirectories. If the mint source has subdirectory structure, the bundle will be incomplete.

  • [permissions-scope] internal/scaffold/fullsend-repo/.github/workflows/prioritize.yml:41-42 — The app-token step generates a token with no explicit permission scoping, getting the full default App permissions. Other workflows scope explicitly.

  • [permissions-scope] internal/scaffold/fullsend-repo/.github/workflows/triage.yml:44-46 — Same unscoped token issue as prioritize.yml.

  • [data-exposure] internal/mint/main.go:489 — Logs the full list of repos on every successful mint. Consider logging only the count.

  • [correctness] internal/cli/admin.gohasPrivate/privateRepo is hardcoded to false and GetOrgPlan is never called. Dead infrastructure.

  • [concurrency] internal/mint/main.go:79 — Package-level internalClient shared across all requests is inconsistent with the injected-client pattern used by Handler.

Info

  • [correctness] internal/mint/main.go:740-745 — The iat = now - 60s backdating for GitHub App JWTs is a known correct pattern.
  • [test-coverage] Token mint test suite is thorough: invalid JWT formats, bad issuer, expired tokens, wrong org, bad audience, missing/bad job_workflow_ref, role validation, multi-org flow.
  • [injection-defense] No prompt injection patterns found in PR body or commit messages. The PR body content is descriptive and consistent with the actual diff.

Footer

Outcome: comment-only
This review applies to SHA 37aa6dc8c8e739d12a8532341348212c0d2249c2. Any push to the PR head clears this review and requires a new evaluation.

Previous run (8)

Review: automated review

Outcome: failure
Reason: stale-head

The review agent reviewed commit d8607578497cbe89945063524b9c6a107abbfcad but the PR HEAD is now b8d311d6df3e12c882851bf652ae4c2eea567050. This review was discarded to avoid approving unreviewed code.

Previous run (9)

Review: #503

Head SHA: c8562f7
Timestamp: 2026-05-11T00:00:00Z
Outcome: comment-only

Summary

This is a well-executed migration from PAT-based dispatch to OIDC token minting via GCP Cloud Functions. The security architecture is sound: defense-in-depth OIDC validation (pre-validation + STS), role-based permission downscoping, PEM zeroing, script injection prevention in workflows, and proper credential masking. PAT deprecation is cleanly enforced in config validation, and the BothModes uninstall layer correctly handles migration cleanup. No critical or high findings were identified. The medium findings below are defense-in-depth improvements — none represent exploitable vulnerabilities in the current deployment context. No prompt injection patterns or suspicious non-rendering Unicode detected in the PR body or commit messages.

Findings

Medium

  • [input-validation] internal/mint/main.go:222repoNamePattern allows a repo name of . (single dot). While .. is explicitly rejected by the strings.Contains check on line 444, a single . would pass validation and construct a URL like /repos/org/./installation, which could behave unexpectedly depending on HTTP path normalization.
    Remediation: Add an explicit check rejecting repo names that are exactly . or ...

  • [auth-defense-depth] internal/mint/main.go:125-127 — After STS token exchange, the code checks only that the returned access_token is non-empty. A non-JWT garbage string would pass. If STS ever malfunctions and returns a valid-looking response with a non-JWT token, the pre-validation would not catch it.
    Remediation: Consider validating that the STS access token has a minimum expected structure (e.g., contains two dots for JWT format) as a lightweight defense layer.

  • [missing-test] internal/mint/main.go:604findInstallation uses only repos[0] to look up the installation ID, but the token is then scoped to all repos in the list. If the app is installed on repos[0] but not on repos[1], the error from GitHub's installation token endpoint is untested. No validation ensures all requested repos belong to the same installation.
    Remediation: Add a test covering the case where createInstallationToken fails because some repos are not accessible to the installation.

  • [logic-error] internal/mint/main.go:555 — The @ index check uses atIdx > 0 which means a job_workflow_ref where @ immediately follows the prefix would leave @... in relPath. The logic is accidentally safe (the .github/workflows/ check would fail), but the intent is unclear.
    Remediation: Change to atIdx >= 0 for clarity, or add a comment explaining why > 0 is intentional.

  • [auth-scope] internal/dispatch/gcf/gcp.go:421-515SetCloudRunInvoker grants roles/run.invoker to allUsers, making the Cloud Function publicly invocable. The code comment acknowledges the function's OIDC validation is the security boundary, which is a valid design. However, if the mint's OIDC validation has a bug, there is no network-level barrier.
    Remediation: Consider whether IAM-level restriction to the WIF pool principal would be feasible as an alternative to allUsers for defense-in-depth.

  • [missing-validation] internal/dispatch/gcf/provisioner.go:51githubOrgPattern allows org names of arbitrary length. GitHub org names are limited to 39 characters. Excessively long org names could produce CEL expressions that exceed GCP's attribute condition size limits.
    Remediation: Add a length check (max 39 characters) to org name validation.

  • [auth-scope] internal/scaffold/fullsend-repo/.github/workflows/prioritize.yml:41-43 — The prioritize workflow's app-token step generates a token without explicit permission-* restrictions, unlike other workflows (code, review, fix) which scope sandbox tokens. The app's installation permissions limit the blast radius, but this is inconsistent.
    Remediation: Add explicit permission-* fields to the prioritize token generation step for consistency and defense-in-depth.

Low

  • [missing-test] internal/mint/main.go:716-732generateAppJWT handles both PKCS1 and PKCS8 key formats, but only PKCS1 is tested. No test covers the PKCS8 fallback path.
    Remediation: Add a test generating a PKCS8-encoded RSA key.

  • [missing-test] internal/mint/main.go:593-597 — PEM zeroing logic depends on the PEMAccessor returning a mutable byte slice. No test verifies zeroing actually occurs.

  • [robustness] internal/gcp/client.go:54-67adcToken calls google.FindDefaultCredentials on every DoRequest invocation. Using oauth2.ReuseTokenSource would be more efficient.

  • [non-determinism] internal/layers/dispatch.go:110 — Map iteration over variables is non-deterministic. Currently only one variable (FULLSEND_MINT_URL) is returned, so this is benign.

  • [misleading-output] internal/layers/configrepo.go:91 — Log message always says "(public)" regardless of the hasPrivate parameter. Currently hasPrivate is always false in the CLI, so the message is correct in practice.

  • [missing-test] internal/dispatch/gcf/provisioner.go:463-506waitForReady has no direct unit test for retry/backoff logic or timeout behavior.

  • [missing-test] internal/dispatch/gcf/provisioner.go:137-147SecretExists is a public method used by the CLI but has no direct unit test.

Info

  • [good-practice] Token masking, PEM zeroing, credential data zeroing, and html.EscapeString in the manifest flow are all properly implemented.
  • [good-practice] All workflow templates consistently use env: + jq --arg for attacker-controlled values, preventing shell injection.
  • [good-practice] reconcile-repos.sh validates repo names with ^[a-zA-Z0-9._-]+$ and uses hardcoded branch names, eliminating injection vectors.
  • [good-practice] The shim uses pull_request_target (not pull_request) and fix workflows check head.repo == base.repo to block fork PRs.
  • [good-practice] Defense-in-depth quote-injection check for org names before CEL construction in provisioner.go:318-322.
  • [good-practice] dispatch.yml top-level permissions: {} correctly denies all permissions by default.
  • [no-injection] No prompt injection patterns, non-rendering Unicode, or bidirectional override characters detected in PR body, commit messages, or code.

Footer

Outcome: comment-only
This review applies to SHA c8562f733e2dbcb5ae23849961a89f98b4d11c80. Any push to the PR head clears this review and requires a new evaluation.

Previous run (10)

Review: #503

Head SHA: d825fd6
Timestamp: 2026-05-11T14:30:00Z
Outcome: comment-only

Summary

This is a substantial architectural change that deprecates PAT-based dispatch in favor of OIDC token minting via a GCP Cloud Function. The security design is sound overall: OIDC validation is layered (pre-validation + authoritative STS), attacker-controlled values in workflow YAML are consistently routed through env: + jq --arg to prevent script injection, token masking is thorough, and the workflow_call shim architecture correctly avoids passing secrets to enrolled repos. The new shim-workflow-call.yaml is a significant improvement over the old PAT shim. No critical findings were identified. Several medium-severity items are noted below for consideration, primarily around defense-in-depth and operational robustness. None are blocking.

Findings

High

No findings.

Critical

No findings.

Medium

  • [Platform security] internal/mint/main.go — PEM key material is zeroed in the raw pemData byte slice via defer, but the parsed *rsa.PrivateKey struct (containing the private exponent and primes in separate heap allocations) and block.Bytes intermediate are never zeroed. Cryptographic key material persists in Cloud Function memory until GC reclaims the pages.
    Remediation: Zero block.Bytes after parsing. For the *rsa.PrivateKey, zero key.D, key.Primes, and key.Precomputed fields. Alternatively, consider KMS-based signing.

  • [Platform security] internal/mint/main.go — The caller-supplied repos list is validated only for format (regex) but never checked against the repository claim in the OIDC token. A .fullsend workflow can request tokens scoped to any repos within the org, not just the triggering repo. This may be intentional (dispatch workflows legitimately access multiple repos), but the blast radius of a compromised .fullsend workflow is all repos the GitHub App is installed on.
    Remediation: Document this as an accepted risk, or consider validating that requested repos include the triggering repository.

  • [Platform security] internal/dispatch/gcf/gcp.go:421-515SetCloudRunInvoker grants roles/run.invoker to allUsers, making the Cloud Function publicly invocable. The security model depends entirely on application-layer OIDC validation being correct.
    Remediation: Consider restricting to allAuthenticatedUsers or documenting this as an accepted defense-in-depth tradeoff. allUsers may be necessary since GitHub Actions OIDC tokens are presented directly (not exchanged for GCP credentials first).

  • [Correctness] internal/dispatch/gcf/gcp.go:212-240 — When a WIF provider's attributeCondition no longer matches (e.g., admin adds a second org), the code returns a hard error requiring manual deletion. Multi-org onboarding requires manual WIF provider management.
    Remediation: Use the PATCH endpoint to update the existing WIF provider's attributeCondition, or document this limitation in the installation guide.

  • [Correctness] internal/dispatch/gcf/provisioner.go — Old Secret Manager versions are never disabled/destroyed. PEM key versions accumulate. Any principal with secretmanager.versions.access can read old rotated keys.
    Remediation: Disable or destroy non-latest secret versions after adding a new version.

  • [Correctness] internal/mint/main.go — Response bodies from STS, Secret Manager, metadata server, and GitHub API are not bounded on success paths (only error paths use io.LimitReader). A misbehaving upstream could cause excessive memory consumption in the Cloud Function.
    Remediation: Wrap success-path body reads with io.LimitReader (e.g., 1MB for PEM, 64KB for other JSON responses).

Low

  • [Style/conventions] internal/scaffold/fullsend-repo/.github/workflows/retro.yml and prioritize.yml — These inline GCP authentication steps instead of using the shared setup-gcp composite action. Future security improvements to setup-gcp won't automatically apply.
    Remediation: Refactor to use ./.github/actions/setup-gcp for consistency.

  • [Correctness] internal/dispatch/gcf/provisioner.go:543-548 — Symlink detection via entry.Type()&os.ModeSymlink in bundleFunctionSource may not reliably detect symlinks on all platforms. If this is security-relevant (preventing path traversal via symlink), use os.Lstat for reliable detection.

  • [Correctness] internal/dispatch/gcf/provisioner.go:440-445 — Function URL validation only accepts *.run.app or *.cloudfunctions.net. Custom domains or future GCP URL format changes would break provisioning.

Info

  • [Injection defense] PR body: No non-rendering Unicode characters, bidirectional overrides, or prompt injection patterns detected.

  • [Content security] The new shim-workflow-call.yaml correctly routes all attacker-controlled values (comment.body, issue.number, html_url, etc.) through env: mappings and jq --arg, preventing script injection. The dispatch-stop-fix job uses github.token (not a dispatch PAT), which is correct for source-repo operations.

  • [Platform security] The mint-token composite action properly masks both the OIDC JWT and the minted token with ::add-mask::. The OIDC audience is correctly hardcoded to fullsend-mint. Request body construction uses jq -nc with --arg/--argjson for safe JSON assembly.

  • [Correctness] The delim generation in setup-agent-env.sh was upgraded from $RANDOM (predictable) to openssl rand -hex 16 (cryptographic), preventing heredoc delimiter collision attacks. Good improvement.

  • [Platform security] No hardcoded secrets, credentials, GCP project names, or service account identifiers found anywhere in the diff.

Footer

Outcome: comment-only
This review applies to SHA d825fd613e6e49f9e8ec8f335e8f90d4b49f2803. Any push to the PR head clears this review and requires a new evaluation.

Copy link
Copy Markdown

@fullsend-ai-review fullsend-ai-review Bot left a comment

Choose a reason for hiding this comment

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

See the review comment above for full details.

Copy link
Copy Markdown

@fullsend-ai-review fullsend-ai-review Bot left a comment

Choose a reason for hiding this comment

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

See the review comment above for full details.

Copy link
Copy Markdown

@fullsend-ai-review fullsend-ai-review Bot left a comment

Choose a reason for hiding this comment

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

See the review comment above for full details.

Copy link
Copy Markdown

@fullsend-ai-review fullsend-ai-review Bot left a comment

Choose a reason for hiding this comment

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

See the review comment above for full details.

@waynesun09 waynesun09 marked this pull request as ready for review April 29, 2026 14:34
Copy link
Copy Markdown

@fullsend-ai-review fullsend-ai-review Bot left a comment

Choose a reason for hiding this comment

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

See the review comment above for full details.

Copy link
Copy Markdown

@fullsend-ai-review fullsend-ai-review Bot left a comment

Choose a reason for hiding this comment

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

See the review comment above for full details.

Copy link
Copy Markdown

@fullsend-ai-review fullsend-ai-review Bot left a comment

Choose a reason for hiding this comment

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

See the review comment above for full details.

Copy link
Copy Markdown

@fullsend-ai-review fullsend-ai-review Bot left a comment

Choose a reason for hiding this comment

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

See the review comment above for full details.

Copy link
Copy Markdown

@fullsend-ai-review fullsend-ai-review Bot left a comment

Choose a reason for hiding this comment

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

See the review comment above for full details.

waynesun09 added a commit that referenced this pull request Apr 29, 2026
- Add GCF v2 entry point registration with functions-framework-go
- Add SecretAccessor implementation using metadata server auth
- Add source upload via generateUploadUrl API before CreateFunction
- Scope installation token to actions:write only (least privilege)
- Add 10-minute timeout to WaitForOperation polling
- Add 403 handling to OrgVariableExists matching OrgSecretExists

Signed-off-by: Wayne Sun <gsun@redhat.com>
Copy link
Copy Markdown

@fullsend-ai-review fullsend-ai-review Bot left a comment

Choose a reason for hiding this comment

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

See the review comment above for full details.

Copy link
Copy Markdown

@fullsend-ai-review fullsend-ai-review Bot left a comment

Choose a reason for hiding this comment

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

See the review comment above for full details.

@github-actions
Copy link
Copy Markdown

fullsend review is working on this — view logs

@github-actions
Copy link
Copy Markdown

fullsend review is working on this — view logs

@@ -0,0 +1,612 @@
// Package gcf implements the dispatch.Dispatcher interface using a GCP
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

perhaps this file should be renamed

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Renamed gcf.goprovisioner.go (and gcf_test.goprovisioner_test.go). The file contains the Provisioner type and mint infrastructure setup — the name should describe what it does, not the GCP product.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in baf935a — renamed gcf.goprovisioner.go and gcf_test.goprovisioner_test.go.

Comment thread docs/guides/admin/installation.md Outdated
--mint-project "$GCP_PROJECT"
```

`--mint-project` specifies the GCP project where the OIDC token mint Cloud Function is deployed. It can be the same project as `--gcp-project` or a separate project. The installer automatically provisions a Cloud Function, WIF pool (`fullsend-pool`), WIF provider (`github-oidc`), service account (`fullsend-dispatch`), and Secret Manager secrets in the mint project.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

do we still need a service account for dispatching when using the mint?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

No — the admin does not need to create a service account for dispatching. The fullsend-dispatch SA mentioned on this line is auto-provisioned by the installer (step 2 in provisionSelfManaged). It serves as the Cloud Function's runtime identity so it can read GitHub App PEM keys from Secret Manager.

The manual SA setup in Section 1 (steps 1a–1c) is for Vertex AI inference, not dispatching.

I'll clarify the wording here to make it obvious that the SA is auto-provisioned infrastructure, not something the admin creates.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

updated

Comment thread internal/mint/main.go
gcpProjectNum: os.Getenv("GCP_PROJECT_NUMBER"),
wifPoolName: os.Getenv("WIF_POOL_NAME"),
wifProviderName: os.Getenv("WIF_PROVIDER_NAME"),
},
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Might as well pass the rest of the Env-var based values here rather then have additional Getenv calls in NewHandler.

That way all the configuration inputs are in one place and we can easily decide to add e.g. commad line parameters

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed — NewHandler currently reads OIDC_AUDIENCE, ROLE_APP_IDS, ALLOWED_ORGS, ALLOWED_ROLES, and ALLOWED_WORKFLOW_FILES via os.Getenv internally, while init() reads the WIF/STS values and passes them explicitly. Moving all env var reads into init() and passing them as explicit parameters (or a config struct) to NewHandler would make the configuration flow visible in one place and simplify testing.

Deferring to a follow-up to keep this PR focused — the current code is functionally correct and tests use t.Setenv + TestMain defaults.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@github-actions
Copy link
Copy Markdown

fullsend review is working on this — view logs

@github-actions
Copy link
Copy Markdown

fullsend review is working on this — view logs

@github-actions
Copy link
Copy Markdown

fullsend review is working on this — view logs

@github-actions
Copy link
Copy Markdown

fullsend review is working on this — view logs

@github-actions
Copy link
Copy Markdown

fullsend review is working on this — view logs

@github-actions
Copy link
Copy Markdown

fullsend review is working on this — view logs

@github-actions
Copy link
Copy Markdown

fullsend review is working on this — view logs

Copy link
Copy Markdown

@fullsend-ai-review fullsend-ai-review Bot left a comment

Choose a reason for hiding this comment

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

See the review comment for full details.

@github-actions
Copy link
Copy Markdown

fullsend review is working on this — view logs

Add GCP client abstraction, Cloud Function token mint, GCF provisioner
with WIF/STS integration, dispatch interface, and forge extensions for
org variable and repo variable management. The mint validates GitHub
OIDC JWTs via Workload Identity Federation and issues scoped GitHub App
installation tokens per agent role.

Signed-off-by: Wayne Sun <gsun@redhat.com>
Wire the GCF provisioner into the admin install/uninstall flow, add
--mint-url and --mint-project CLI flags, update config to store OIDC
dispatch settings, and migrate layers to manage org/repo variables
instead of PAT secrets. Remove legacy PAT e2e helpers.

Signed-off-by: Wayne Sun <gsun@redhat.com>
Replace PAT-based dispatch in all agent workflows with OIDC token
minting via the shared mint-token action. Convert enrolled-repo shim
templates from workflow_dispatch to workflow_call. Add dispatch-prioritize
job, port auto-triage on issue open/edit, and add lint-workflow-size caps.

Signed-off-by: Wayne Sun <gsun@redhat.com>
@github-actions
Copy link
Copy Markdown

fullsend review is working on this — view logs

Update architecture overview and installation guide to reflect the
OIDC mint dispatch model replacing PAT-based dispatch.

Signed-off-by: Wayne Sun <gsun@redhat.com>
@github-actions
Copy link
Copy Markdown

fullsend review is working on this — view logs

Copy link
Copy Markdown
Contributor

@ralphbean ralphbean left a comment

Choose a reason for hiding this comment

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

Review: #503

Head SHA: 75be54df839f7ccdac0f2fa05c530e8d297ce034

Strategic Assessment

This PR is a strong security improvement — replacing long-lived PATs with OIDC-minted, role-scoped, short-lived installation tokens directly addresses the project's threat model. The architecture is sound (defense-in-depth with pre-validation + STS, role-based permission downscoping, proper workflow injection prevention), and the ~1800 lines of PAT code removal meaningfully reduces attack surface. The implementation has been through 3+ review rounds with 70+ inline comments, most resolved. Approve with deferred notes below.

Deferred Notes

Six moderate/minor findings noted inline for future consideration — none blocking merge. Several overlap with items already tracked in #741.

startsWith(github.event.comment.body, '/review ') ||
startsWith(github.event.comment.body, format('{0}{1}', '/review', fromJSON('"\n"')))
)) ||
(github.event_name == 'pull_request_target'
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[moderate — note, defer] dispatch-review and dispatch-retro fire on pull_request_target without the fork guard that dispatch-fix-bot correctly uses (github.event.pull_request.head.repo.full_name == github.event.pull_request.base.repo.full_name). This means fork PRs trigger review/retro agents, which mint OIDC tokens scoped to the target repo for untrusted fork contributions. While pull_request_target runs the base branch workflow (so the shim itself is safe), the downstream dispatch still mints tokens.

If reviewing fork PRs is intentional, add a comment explaining why. Otherwise, add the fork guard to both jobs.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Tracked in #741 — fork guard for dispatch-review/dispatch-retro.

Comment thread internal/mint/main.go

// generateAppJWT creates a signed JWT for GitHub App authentication.
func generateAppJWT(appID string, pemData []byte) (string, error) {
block, _ := pem.Decode(pemData)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[moderate — note, defer] pemData is zeroed by the caller's defer, but pem.Decode(pemData) creates block.Bytes (sub-slice or copy) and x509.ParsePKCS1PrivateKey creates an *rsa.PrivateKey with internal big.Int values — neither is zeroed. Consider zeroing block.Bytes after parsing. Go's GC makes full memory scrubbing of the parsed RSA key impractical, but documenting this limitation would help.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Tracked in #741 — PEM memory zeroing for block.Bytes and RSA key internals.

Comment thread internal/mint/main.go


// rolePattern restricts role to safe lowercase identifiers.
var rolePattern = regexp.MustCompile(`^[a-z][a-z0-9_-]*$`)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[minor — note, defer] rolePattern (^[a-z][a-z0-9_-]*$) is defined independently here and in internal/dispatch/gcf/provisioner.go. Divergence risk if one is updated without the other. Consider extracting to a shared package.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Tracked in #741 — extract shared rolePattern to avoid divergence.

Comment thread internal/mint/main.go
// rolePermissions defines the minimum GitHub App permissions per agent role.
// Tokens are always downscoped to these permissions regardless of what the
// App itself has configured.
var rolePermissions = map[string]map[string]string{
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[minor — note, defer] retro and prioritize roles are defined in rolePermissions but excluded from test defaults in testmain_test.go (ALLOWED_ROLES=triage,coder,review,fix,fullsend). These permission maps have zero test coverage — typos or wrong access levels would not be caught.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Tracked in #741 — add retro and prioritize to test defaults for permission map coverage.

Comment thread internal/gcp/client.go

// adcToken obtains a token via Application Default Credentials.
func (c *Client) adcToken(ctx context.Context) (string, error) {
creds, err := google.FindDefaultCredentials(ctx, "https://www.googleapis.com/auth/cloud-platform")
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[minor — note, defer] adcToken calls google.FindDefaultCredentials on every API request. This re-reads environment variables, files, and the metadata server each time. The TokenSource from FindDefaultCredentials already handles token refresh internally — caching the creds at construction time or on first use would avoid repeated credential discovery.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Tracked in #741 — cache FindDefaultCredentials result at construction time.

Comment thread internal/mint/main.go
// audience handles the OIDC aud claim which can be a string or array of strings.
type audience []string

func (a *audience) UnmarshalJSON(data []byte) error {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[minor — note, defer] audience.UnmarshalJSON accepts an empty string "" silently, producing audience{""}. The empty-array check only applies to the array branch. Consider adding if s == "" { return fmt.Errorf("aud must not be empty") } in the single-string branch for defense-in-depth.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Tracked in #741 — validate against empty string in audience.UnmarshalJSON single-string branch.

…isioning

The GCF provisioner and mint function now support multiple orgs sharing
a single GCP project. Secrets use org-scoped names (fullsend-{org}--{role}-app-pem),
ROLE_APP_IDS uses org-scoped keys ({org}/{role}), and WIF attribute conditions
are merged additively. ALLOWED_ROLES is derived from ROLE_APP_IDS keys.

Signed-off-by: Wayne Sun <gsun@redhat.com>
@github-actions
Copy link
Copy Markdown

fullsend review is working on this — view logs

google-github-actions/auth@v3 uses the IAM-format audience URI
for STS token exchange, not just the custom "fullsend-mint" audience.
Without both audiences registered on the WIF provider, token exchange
fails at runtime.

Register both audiences on create and update: the custom OIDC audience
("fullsend-mint") and the IAM audience
("https://iam.googleapis.com/projects/{num}/.../providers/{id}").
UpdateWIFProvider now patches oidc.allowedAudiences alongside
attributeCondition. GetWIFProvider now returns AllowedAudiences.

Signed-off-by: Wayne Sun <gsun@redhat.com>
@github-actions
Copy link
Copy Markdown

fullsend review is working on this — view logs

- Preserve installing orgs before WIF merge to prevent PEM/env-var
  overwrites of existing orgs' secrets during additive provisioning
- Pass e2eDispatcher to BothModesDispatchLayer in E2E uninstall so
  OIDC variables are actually cleaned up (nil dispatcher was a no-op)
- Add FULLSEND_MINT_URL assertion in verifyNotInstalled
- Add fullsend-{role} slug convention to E2E cleanup
- Enable WithOIDCMode on E2E install SecretsLayer
- Add githubOrgPattern validation in smPEMAccessor.AccessPEM
- Fix stale PEM naming in docs and comments to match org-scoped format

Signed-off-by: Wayne Sun <gsun@redhat.com>
@github-actions
Copy link
Copy Markdown

fullsend review is working on this — view logs

… cap

Defense-in-depth: the "--" separator in secret names
(fullsend-{org}--{role}-app-pem) requires that neither org nor role
contain "--". Add explicit strings.Contains checks in both mint
AccessPEM and provisioner validation. Align provisioner githubOrgPattern
with mint's 39-char length cap. Add non-empty assertion to multi-org
merge test. Remove dead isStale condition in E2E cleanup.

Signed-off-by: Wayne Sun <gsun@redhat.com>
@github-actions
Copy link
Copy Markdown

fullsend review is working on this — view logs

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants