Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 69 additions & 11 deletions .github/workflows/reusable-dispatch.yml
Original file line number Diff line number Diff line change
Expand Up @@ -124,11 +124,15 @@ jobs:
# checkout@v7 blocks fork PR checkouts on pull_request_target by
# default. Safe here: only .fullsend/ config is read, no fork code
# is executed, and credentials are not persisted. Pin to base branch
# so kill-switch / role gating always reads trusted config.
ref: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.base.sha || github.sha }}
# SHA for PR-scoped events so kill-switch / role gating always reads
# trusted config — PR authors cannot self-authorize.
ref: ${{ (github.event_name == 'pull_request_target' || github.event_name == 'pull_request_review') && github.event.pull_request.base.sha || github.sha }}
persist-credentials: false
allow-unsafe-pr-checkout: ${{ github.event_name == 'pull_request_target' }}
sparse-checkout: .fullsend/config.yaml
sparse-checkout: |
.fullsend/config.yaml
/OWNERS
/OWNERS_ALIASES
sparse-checkout-cone-mode: false

- name: Determine stage
Expand Down Expand Up @@ -159,11 +163,63 @@ jobs:
STAGE=""
TRIGGER_SOURCE=""

# Check direct membership or alias membership in an OWNERS list.
# Used by has_repo_permission when OWNERS-file auth is enabled.
# yq errors are intentionally suppressed (&>/dev/null): a parse
# failure or missing key means "no match", falling through to
# the collaborator API — not a fail-open gate.
_owners_has_user() {
local key="${1}" user="${2}"
if yq -e ".${key}[] | select((. | downcase) == \"${user}\")" OWNERS &>/dev/null; then
return 0
fi
if [[ -f "OWNERS_ALIASES" ]]; then
local entry
while IFS= read -r entry; do
[[ -z "${entry}" ]] && continue
[[ ! "${entry}" =~ ^[a-zA-Z0-9_-]+$ ]] && continue
if yq -e ".aliases.\"${entry}\"[] | select((. | downcase) == \"${user}\")" OWNERS_ALIASES &>/dev/null; then
return 0
fi
done < <(yq ".${key}[]" OWNERS 2>/dev/null)
fi
return 1
}

# Collaborator role_name vs min (write|triage). See #5223 / ADR 0054.
# API resolves org membership regardless of visibility (gh-aw-mcpg#2862).
has_repo_permission() {
local username="${1:-}" min="${2:-write}" role api_err
[[ -z "${username}" ]] && return 1

# OWNERS-file authorization (opt-in via authorization providers list in config.yaml).
# Approvers get write-equivalent access; reviewers get triage-equivalent.
# Safe: sparse-checkout pins to base branch SHA, so PR authors cannot
# self-authorize by adding themselves to OWNERS.
if [[ -f "OWNERS" && -f ".fullsend/config.yaml" ]]; then
if [[ "$(yq '(.authorization // []) | any_c(.provider == "owners_file")' .fullsend/config.yaml)" == "true" ]]; then
if [[ ! "${username}" =~ ^[a-zA-Z0-9-]+$ ]]; then
echo "::warning::OWNERS auth skipped: username '${username}' contains unexpected characters" >&2
else
local lc_user="${username,,}"
case "${min}" in
write|triage)
if _owners_has_user approvers "${lc_user}"; then
echo "::notice::OWNERS file resolved user '${username}' as approver (requested: ${min})"
return 0
fi
;;&
triage)
if _owners_has_user reviewers "${lc_user}"; then
echo "::notice::OWNERS file resolved user '${username}' as reviewer (requested: ${min})"
return 0
fi
;;
esac
fi
fi
fi

api_err=$(mktemp) || {
echo "::warning::Failed to create temp file for permission check of ${username}" >&2
return 1
Expand Down Expand Up @@ -600,7 +656,7 @@ jobs:
- name: Checkout config repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.base.sha || github.sha }}
ref: ${{ (github.event_name == 'pull_request_target' || github.event_name == 'pull_request_review') && github.event.pull_request.base.sha || github.sha }}
persist-credentials: false
allow-unsafe-pr-checkout: ${{ github.event_name == 'pull_request_target' }}

Expand Down Expand Up @@ -711,7 +767,7 @@ jobs:
- name: Checkout config repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.base.sha || github.sha }}
ref: ${{ (github.event_name == 'pull_request_target' || github.event_name == 'pull_request_review') && github.event.pull_request.base.sha || github.sha }}
persist-credentials: false
allow-unsafe-pr-checkout: ${{ github.event_name == 'pull_request_target' }}

Expand Down Expand Up @@ -849,7 +905,7 @@ jobs:
- name: Checkout config repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.base.sha || github.sha }}
ref: ${{ (github.event_name == 'pull_request_target' || github.event_name == 'pull_request_review') && github.event.pull_request.base.sha || github.sha }}
persist-credentials: false
allow-unsafe-pr-checkout: ${{ github.event_name == 'pull_request_target' }}

Expand Down Expand Up @@ -978,7 +1034,7 @@ jobs:
- name: Checkout config repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.base.sha || github.sha }}
ref: ${{ (github.event_name == 'pull_request_target' || github.event_name == 'pull_request_review') && github.event.pull_request.base.sha || github.sha }}
persist-credentials: false
allow-unsafe-pr-checkout: ${{ github.event_name == 'pull_request_target' }}

Expand Down Expand Up @@ -1256,7 +1312,7 @@ jobs:
- name: Checkout config repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.base.sha || github.sha }}
ref: ${{ (github.event_name == 'pull_request_target' || github.event_name == 'pull_request_review') && github.event.pull_request.base.sha || github.sha }}
persist-credentials: false
allow-unsafe-pr-checkout: ${{ github.event_name == 'pull_request_target' }}

Expand Down Expand Up @@ -1366,7 +1422,7 @@ jobs:
- name: Checkout config repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.base.sha || github.sha }}
ref: ${{ (github.event_name == 'pull_request_target' || github.event_name == 'pull_request_review') && github.event.pull_request.base.sha || github.sha }}
persist-credentials: false
allow-unsafe-pr-checkout: ${{ github.event_name == 'pull_request_target' }}

Expand Down Expand Up @@ -1462,11 +1518,13 @@ jobs:
- name: Checkout caller repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.base.sha || github.sha }}
ref: ${{ (github.event_name == 'pull_request_target' || github.event_name == 'pull_request_review') && github.event.pull_request.base.sha || github.sha }}
persist-credentials: false
allow-unsafe-pr-checkout: ${{ github.event_name == 'pull_request_target' }}
sparse-checkout: |
.fullsend/
/OWNERS
/OWNERS_ALIASES
.defaults/action.yml
.defaults/.github/actions/install-fullsend-cli/
.fullsend/.defaults/action.yml
Expand Down Expand Up @@ -1622,7 +1680,7 @@ jobs:
- name: Checkout config repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.base.sha || github.sha }}
ref: ${{ (github.event_name == 'pull_request_target' || github.event_name == 'pull_request_review') && github.event.pull_request.base.sha || github.sha }}
persist-credentials: false
allow-unsafe-pr-checkout: ${{ github.event_name == 'pull_request_target' }}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,35 @@ permission list, not by bypassing the check.
> any closer can trigger read-only lifecycle accounting. This follows
> the extension path above rather than bypassing the check.

> **Note (2026-08-10, [#6042](https://github.com/fullsend-ai/fullsend/issues/6042)):**
> Prow-based repositories (e.g., OpenShift) use OWNERS files rather than
> GitHub collaborator roles to define contributor authority.
> `has_repo_permission` now supports an opt-in OWNERS-file authorization
> path: when `owners_file` is listed in the `authorization` providers in
> `.fullsend/config.yaml`, the function checks the repo-root `OWNERS`
> (and `OWNERS_ALIASES`) before falling back to the collaborator API.
> OWNERS approvers get write-equivalent access; reviewers get
> triage-equivalent. The sparse-checkout pins to the base branch SHA for
> PR-scoped events (`pull_request_target`, `pull_request_review`) and the
> default-branch head otherwise, so PR authors cannot self-authorize by
> modifying OWNERS in their PR.
> This follows the extension path above (extending the allowed permission
> sources in `has_repo_permission`) rather than bypassing the check.
> OWNERS auth applies to both built-in stages (bash routing) and the
> harness/custom-agent dispatch path (`internal/harnessdispatch`), where
> `owners.Resolve` computes an effective role for the `IsAuthorized`
> gate without mutating the original event.
>
> OWNERS reviewer access (triage-equivalent) applies to built-in
> bash-routed stages only (e.g. `/fs-triage`, `/fs-review`). Custom
> harness dispatch requires write-level access — OWNERS approver or
> GitHub write+ collaborator — because `IsAuthorized` gates all
> harness triggers at the write level.
>
> v1 limitation: only repo-root flat `approvers`/`reviewers` lists are
> read. Prow `filters:` blocks and nested per-directory OWNERS files
> are not supported.

## Consequences

- All dispatch paths require write-level repository permission,
Expand Down
2 changes: 2 additions & 0 deletions docs/contributing/workflow-contracts.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@

**Silent failures and required-flag consistency:** Omitting a secret that is `required: true` at every hop in the chain fails loudly at workflow-call validation time and self-enforces. However, a secret whose `required` flag is `false` at any upstream hop can still arrive as an empty string at a downstream `required: true` consumer — GitHub Actions' required-secret validation only checks key presence, not that the resolved value is non-empty. For example, `FULLSEND_GCP_WIF_PROVIDER` is `required: false` in `reusable-dispatch.yml` but `required: true` in every downstream `reusable-<stage>.yml`, so an installer that never sets it satisfies the key-presence check while the actual value is empty. Treat a missing forwarding hop the same as a missing sync — it is a correctness bug, not a cosmetic issue. Required-flag consistency across the *whole* chain matters, not just the flag at the final consumer.

**OWNERS-file authorization:** `has_repo_permission` in both `reusable-dispatch.yml` and the scaffold `dispatch.yml` supports an opt-in OWNERS-file path gated by `owners_file` in the `authorization` providers list in `.fullsend/config.yaml`. When enabled, the function checks the repo-root `OWNERS` (and `OWNERS_ALIASES` if present) before falling back to the collaborator API. Approvers get write-equivalent access; reviewers get triage-equivalent. Changes to `_owners_has_user` or the OWNERS authorization block must be applied to both workflow files — `TestDispatchPerStageAuthorization` checks `has_repo_permission` parity including OWNERS role-mapping invariants. The Go harness-dispatch path (`internal/harnessdispatch/core.go`) has equivalent OWNERS resolution via `internal/owners`; changes to the OWNERS schema or role mapping must be kept in sync across both implementations.

**Security — consuming threaded inputs:** When a newly-threaded entry carries user- or event-controlled data, consume it via `env:` in the final `run:` step — never interpolate `${{ ... }}` directly into a shell block (see the Security note atop `reusable-dispatch.yml`). This prevents the GHA script-injection class of bugs the project defends against elsewhere.

**When reviewing PRs:** If a diff adds or renames a `secrets:` or `inputs:` entry in a reusable workflow, check that all callers in both chains have been updated. Flag a missing forwarding hop as a medium-severity or higher finding. New secrets/inputs must be forwarded only to the hop(s)/stage(s) that need them — do not use `secrets: inherit` as a substitute for explicit forwarding (`OTEL_EXPORTER_OTLP_TRACES_HEADERS`, for example, is explicitly forwarded to every inline stage job in `reusable-dispatch.yml` because each stage runs an agent that emits traces). `workflow_call_alignment_test.go` already automates much of this verification — see `TestWorkflowCallInputAlignment` (validates required inputs/secrets are threaded through both chains) and `TestOTELHeadersSecretThreading` (bespoke test for optional secrets). For new optional secrets/inputs, extend those tests or add a similar one rather than relying solely on manual tracing.
43 changes: 43 additions & 0 deletions docs/guides/infrastructure/layered-config-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,16 @@ the overlay → base → code defaults chain.
| `models.aliases` | `map[string]string` (nested) | Per-key merge | `nil` (fleet defaults) |
| `create_issues` | `*CreateIssuesConfig` | Replace whole object if set | `nil` |
| `status_notifications` | `*StatusNotificationConfig` | Replace whole object if set | `nil` |
| `authorization`¹ | `[]AuthorizationProvider` | Overlay only (not layered) | `nil` |

> ¹ `authorization` is a list of authorization providers
> (`AuthorizationProvider` / `AuthorizationOwnersFile()`) consumed
> by both the dispatch workflow's bash/yq and `internal/harnessdispatch`.
> It is intentionally **overlay-only**: `AuthorizationOwnersFile()`
> does not fall through to the parent config, so setting it in
> `config.base.yaml` has no effect — each repo must opt in explicitly.
> See [#6072](https://github.com/fullsend-ai/fullsend/issues/6072) for
> the planned migration to the Go config layer.

### Per-agent `runtime`, `model`, `effort` on `agents:` entries

Expand Down Expand Up @@ -366,6 +376,39 @@ The `status_notifications` field uses the same replace-if-set semantics as
- Non-nil — replaces the parent value entirely, including nested
`comment.start`/`comment.completion` settings.

### `authorization` — provider list

The `authorization` field is a list of authorization providers that
supplement the default collaborator-API permission check. Native GitHub
collaborator-API auth always runs implicitly; the list names additional
backends. Currently one provider is supported:

- `owners_file` — the dispatch routing logic checks the repo-root
`OWNERS` file (and `OWNERS_ALIASES` if present) before falling back
to the GitHub collaborator API. OWNERS approvers get write-equivalent
access; reviewers get triage-equivalent. If the user is not listed in
OWNERS, authorization falls through to the collaborator API — OWNERS
never blocks a collaborator who isn't in the file.

This applies to both the bash routing path (built-in stages) and the
Go harness-dispatch path (custom agents). A missing or malformed OWNERS
file fails closed: the OWNERS check is skipped and authorization falls
through to the collaborator API.

v1 limitation: only the repo-root flat `approvers`/`reviewers` lists
are read. Prow `filters:` blocks and nested per-directory OWNERS files
are not supported.

Example:

```yaml
authorization:
- provider: owners_file
```

See [ADR 0054](../../ADRs/0054-require-authorization-on-all-agent-dispatch-paths.md)
for the full design rationale.

## Code defaults reference

When neither the overlay nor the base layer sets a field, the following
Expand Down
Loading
Loading