Skip to content
Merged
4 changes: 4 additions & 0 deletions .github/aw/safe-outputs.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,13 +170,17 @@ Safe outputs are the primary mechanism for write operations in agentic workflows
excluded-files: # Optional: glob patterns to strip from the patch entirely
- "**/*.lock"
protected-files: blocked # Optional: "blocked" (default), "fallback-to-issue", or "allowed"
allowed-branches: # Optional: glob patterns for allowed source branch names per run
- "feature/*"
allowed-base-branches: # Optional: glob patterns for allowed base branch overrides per run
- "release/*"
- "main"
```

**Dynamic Base Branch**: When `allowed-base-branches` is set, the agent can provide a `base` field in its output to override the default base branch for a single run — but only if the value matches one of the configured glob patterns. Without `allowed-base-branches`, only the static `base-branch:` is used. Accepts a literal array or a GitHub Actions expression resolving to a comma-separated list (e.g. `${{ inputs.allowed-base-branches }}`).

**Allowed Source Branches**: When `allowed-branches` is set, the branch used for PR creation (agent-provided `branch` or the current checkout branch when omitted) must match one of the configured glob patterns.

**File Restrictions**: **Always specify `allowed-files`** — this is the primary guardrail for `create-pull-request`. Scope it to specific file extensions (e.g., `"**/*.md"`, `"**/*.ts"`) or directory paths (e.g., `"src/**"`, `"docs/**"`) matching the workflow's purpose. Omitting `allowed-files` allows the agent to touch any file in the repository, which significantly expands blast radius. Use `excluded-files` to additionally strip specific files (e.g. lock files) from the patch before any checks. The `protected-files` field controls handling of sensitive files (package manifests, CI configs, agent instruction files): `blocked` (default, hard-block), `fallback-to-issue` (push branch and create a review issue), or `allowed` (no restriction — use only when the workflow is explicitly designed to manage these files). Object form is also supported: `protected-files: { policy: fallback-to-issue, exclude: [AGENTS.md] }`.

**Auto-Expiration**: The `expires` field auto-closes PRs after a time period. Supports integers (days) or relative formats (2h, 7d, 2w, 1m, 1y). Minimum duration: 2 hours. Only for same-repo PRs without target-repo. Generates `agentics-maintenance.yml` workflow.
Expand Down
56 changes: 56 additions & 0 deletions actions/setup/js/safe_outputs_handlers.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const { parseAllowedExtensionsEnv } = require("./allowed_extensions_helpers.cjs"
const { sanitizeTitle, applyTitlePrefix } = require("./sanitize_title.cjs");
const { parseDeduplicateByTitle, normalizeTitleForDedup, findDuplicateByTitle } = require("./issue_title_dedup.cjs");
const { validateCreatePullRequestIntent, validatePushToPullRequestBranchIntent, validateCreateIssueIntent, validateAddCommentIntent } = require("./intent_probe.cjs");
const { globPatternToRegex } = require("./glob_pattern_helpers.cjs");

/**
* @param {string} error
Expand Down Expand Up @@ -56,6 +57,45 @@ function hasUpdatePullRequestFields(args) {
return typeof safeArgs.title === "string" || typeof safeArgs.body === "string" || safeArgs.update_branch === true;
}

/**
* Parse branch pattern configuration from array or comma-separated string.
* @param {string[]|string|undefined} value
* @returns {string[]}
*/
function parseAllowedBranchPatterns(value) {
if (Array.isArray(value)) {
return value.map(item => String(item).trim()).filter(Boolean);
}
if (typeof value === "string") {
return value
.split(",")
.map(item => item.trim())
.filter(Boolean);
}
return [];
}

/**
* @param {string} branch
* @param {string[]} allowedPatterns
* @returns {boolean}
*/
function isAllowedBranch(branch, allowedPatterns) {
for (const pattern of allowedPatterns) {
if (branch === pattern) {
return true;
}
if (pattern === "*") {
// Add this fast-path
return true;
}
if (pattern.includes("*") && globPatternToRegex(pattern, { pathMode: true, caseSensitive: true }).test(branch)) {
Comment thread
dsyme marked this conversation as resolved.
Comment thread
dsyme marked this conversation as resolved.
return true;
}
}
return false;
}

/**
* Create handlers for safe output tools
* @param {Object} server - The MCP server instance for logging
Expand Down Expand Up @@ -365,6 +405,22 @@ function createHandlers(server, appendSafeOutput, config = {}) {
entry.branch = detectedBranch;
}

const allowedBranches = parseAllowedBranchPatterns(prConfig.allowed_branches);
if (allowedBranches.length > 0 && !isAllowedBranch(entry.branch, allowedBranches)) {
return {
content: [
{
type: "text",
text: JSON.stringify({
result: "error",
error: `Branch '${entry.branch}' does not match allowed-branches. Allowed patterns: ${allowedBranches.join(", ")}`,
}),
},
],
isError: true,
};
}

const intentValidationError = validateCreatePullRequestIntent(entry);
if (intentValidationError) {
return buildIntentErrorResponse(intentValidationError);
Expand Down
56 changes: 56 additions & 0 deletions actions/setup/js/safe_outputs_handlers.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -718,6 +718,62 @@ describe("safe_outputs_handlers", () => {
}
});

it("should enforce create_pull_request allowed_branches against resolved branch", async () => {
handlers = createHandlers(mockServer, mockAppendSafeOutput, {
create_pull_request: {
allow_empty: true,
base_branch: "main",
allowed_branches: ["feature/*"],
},
});

process.env.GITHUB_HEAD_REF = "feature/from-detection";
process.env.GITHUB_REF_NAME = "feature/from-detection";
try {
const result = await handlers.createPullRequestHandler({
branch: "main",
title: "Test PR",
body: "Test description",
});

expect(result.isError).toBeUndefined();
const responseData = JSON.parse(result.content[0].text);
expect(responseData.result).toBe("success");
expect(responseData.branch).toBe("feature/from-detection");
expect(mockAppendSafeOutput).toHaveBeenCalledWith(
expect.objectContaining({
type: "create_pull_request",
branch: "feature/from-detection",
})
);
} finally {
delete process.env.GITHUB_HEAD_REF;
delete process.env.GITHUB_REF_NAME;
}
});

it("should reject create_pull_request when branch does not match allowed_branches", async () => {
handlers = createHandlers(mockServer, mockAppendSafeOutput, {
create_pull_request: {
allow_empty: true,
allowed_branches: ["feature/*"],
},
});

const result = await handlers.createPullRequestHandler({
branch: "hotfix/not-allowed",
title: "Test PR",
body: "Test description",
});

expect(result.isError).toBe(true);
const responseData = JSON.parse(result.content[0].text);
expect(responseData.result).toBe("error");
expect(responseData.error).toContain("does not match allowed-branches");
expect(responseData.error).toContain("feature/*");
expect(mockAppendSafeOutput).not.toHaveBeenCalled();
});

it("should validate the resolved current branch before recording a PR intent", async () => {
handlers = createHandlers(mockServer, mockAppendSafeOutput, {
create_pull_request: {
Expand Down
2 changes: 1 addition & 1 deletion actions/setup/js/safe_outputs_tools.json
Original file line number Diff line number Diff line change
Expand Up @@ -304,7 +304,7 @@
},
"branch": {
"type": "string",
"description": "Source branch name containing the changes. If omitted, uses the current working branch."
"description": "Source branch name containing the changes. If omitted, uses the current working branch. If the workflow configuration sets safe-outputs.create-pull-request.allowed-branches, then the branch name MUST be given and match one of these patterns."
},
"base": {
"type": "string",
Expand Down
1 change: 1 addition & 0 deletions actions/setup/js/types/safe-outputs-config.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ interface CreatePullRequestConfig extends SafeOutputConfig {
assignees?: string | string[];
draft?: boolean;
"if-no-changes"?: string;
"allowed-branches"?: string[];
footer?: boolean;
"auto-close-issue"?: boolean | string;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
# ADR-33610: Collector-Time `allowed-branches` Enforcement for `create-pull-request` Safe Output

**Date**: 2026-05-20
**Status**: Draft
**Deciders**: Unknown (draft generated from PR #33610 diff)

---

## Part 1 — Narrative (Human-Friendly)

### Context

The `create-pull-request` safe output already supports `allowed-base-branches` so authors can restrict which base branches an agent may target at runtime, but there is no symmetric control over the **source** branch the PR is opened from. Some repositories enforce source-branch naming conventions (e.g. `feature/*`, `release/*`) and want those conventions to be enforced even when an agent generates the PR. Branch policy violations should also surface as soon as possible — ideally when the agent records the safe output through the MCP tool — so the agent gets actionable, immediate feedback rather than a late failure during the apply step.

### Decision

We will add an `allowed-branches` field to `safe-outputs.create-pull-request` that accepts an array of glob patterns (or a GitHub Actions expression resolving to a comma-separated list, consistent with `allowed-base-branches`). We will enforce the policy **at safe-output collection time** inside `safe_outputs_handlers.cjs`: when the agent emits a `create_pull_request` intent, the effective branch (agent-provided `branch`, or the resolved current checkout branch when omitted) MUST match one of the configured patterns, otherwise the handler returns an MCP error and the safe output is not recorded. The compiler (`pkg/workflow`) is extended to plumb the field as `allowed_branches` into the runtime handler config and to treat it as a templatable expression-array field alongside `labels`, `allowed-repos`, and `allowed-base-branches`.

### Alternatives Considered

#### Alternative 1: Enforce only at apply-time in the PR-creation workflow step

The branch policy could be checked only in the post-agent step that actually opens the PR. This was rejected because the agent would not see the rejection until much later in the pipeline (after the entire turn has completed), which wastes work and obscures the cause. Collector-time enforcement returns a structured MCP error the agent can react to inside the same turn.

#### Alternative 2: Reuse `allowed-base-branches` for source branches

We considered overloading the existing `allowed-base-branches` field with a separate boolean to also constrain source branches. This was rejected because base and source branches have different semantics (target vs origin) and conflating them would make the config harder to reason about and hide what is being constrained.

#### Alternative 3: Use regex patterns instead of glob patterns

A regex field would be more expressive. This was rejected for consistency: every other branch/path constraint in safe-outputs (`allowed-base-branches`, `allowed-files`, `excluded-files`, `protected-files`) uses glob syntax via the existing `globPatternToRegex` helper. A regex outlier would force workflow authors to learn two pattern dialects.

### Consequences

#### Positive
- Repository-level source-branch naming conventions can be enforced by workflow configuration rather than by relying on the agent's prompt obedience.
- Symmetric with `allowed-base-branches`, so the mental model is "two parallel branch policies (source + base)" and authors do not have to learn a new shape.
- Errors are surfaced inside the agent's turn (as an MCP error containing the configured patterns), enabling self-correction without a full pipeline failure.
- Expression-array support means the allow-list can be parameterized by `workflow_call` inputs (e.g. `${{ inputs['allowed-branches'] }}`).

#### Negative
- Workflow authors now have two distinct branch policy fields (`allowed-branches` and `allowed-base-branches`) and must understand the difference. Confusing the two would silently fail to constrain the intended branch.
- The runtime copy of `safe_outputs_handlers.cjs` and the compiler config plumbing must stay in sync; drift between the two would result in collector-time enforcement silently disappearing. This is partially mitigated by the new `TestSafeOutputsToolsJSONInSync` test, but the handler code itself is not similarly guarded.
- Collector-time enforcement runs in the agent's environment using its inferred branch; an agent that omits `branch` and runs from an unexpected checkout could still pass the policy if that checkout happens to match — the policy is only as strong as the branch resolution it sees.

#### Neutral
- Two copies of `safe_outputs_tools.json` (`actions/setup/js/` and `pkg/workflow/js/`) must be updated together when the tool description changes; a new test enforces tool-name parity between them.
- The `branch` tool-input description now references the workflow configuration, so generated MCP tool schemas vary slightly depending on which safe-output features are enabled.

---

## Part 2 — Normative Specification (RFC 2119)

> The key words **MUST**, **MUST NOT**, **REQUIRED**, **SHALL**, **SHALL NOT**, **SHOULD**, **SHOULD NOT**, **RECOMMENDED**, **MAY**, and **OPTIONAL** in this section are to be interpreted as described in [RFC 2119](https://www.rfc-editor.org/rfc/rfc2119).

### Configuration Surface

1. The `safe-outputs.create-pull-request` config **MUST** accept an optional `allowed-branches` field.
2. The `allowed-branches` field **MUST** accept either (a) an array of strings, or (b) a single GitHub Actions expression string of the form `${{ ... }}` that resolves to a comma-separated list.
3. Implementations **MUST** treat each entry as a glob pattern; bare strings without `*` **MUST** be matched as exact branch names.
4. Implementations **MUST NOT** reject configurations where `allowed-branches` is absent or empty — in that case no branch policy is applied.

### Collector-Time Enforcement

1. When `allowed-branches` is non-empty, the `create_pull_request` MCP handler **MUST** resolve the effective branch from (in order): the agent-supplied `branch` field if present and not equal to the configured base, otherwise the branch detected from the current checkout (`GITHUB_HEAD_REF` / `GITHUB_REF_NAME`).
2. The handler **MUST** match the resolved branch against the configured patterns using the shared `globPatternToRegex` helper in `pathMode: true, caseSensitive: true` mode.
3. If the resolved branch does not match any configured pattern, the handler **MUST** return a structured MCP error with `isError: true`, `result: "error"`, and an `error` message that includes the rejected branch and the configured patterns.
4. On rejection, the handler **MUST NOT** call `appendSafeOutput` — the PR intent **MUST NOT** be recorded.
5. The handler **MUST** apply `allowed-branches` enforcement before any intent-probe validation (`validateCreatePullRequestIntent`), so policy violations short-circuit cheaper-to-detect errors.

### Compiler Plumbing

1. `CreatePullRequestsConfig` **MUST** expose `AllowedBranches []string` with YAML tag `allowed-branches,omitempty`.
2. The list of templatable expression-array fields for `create-pull-request` **MUST** include `allowed-branches` alongside `labels`, `allowed-repos`, and `allowed-base-branches`.
3. The handler config builder **MUST** emit `allowed_branches` via `AddTemplatableStringSlice` when `AllowedBranches` is non-empty, and **MUST NOT** emit the key when it is empty.
4. The JSON-schema in `pkg/parser/schemas/main_workflow_schema.json` **MUST** describe `allowed-branches` with a `oneOf` of either an array of strings or an expression string matching `^\$\{\{.*\}\}$`.

### Tooling Description Parity

1. The `branch` parameter description in both `actions/setup/js/safe_outputs_tools.json` and `pkg/workflow/js/safe_outputs_tools.json` **MUST** state that the branch must be provided and match `allowed-branches` when the workflow configures that field.
2. The compiler-embedded and runtime copies of `safe_outputs_tools.json` **MUST** declare the same tool names in the same order, as verified by `TestSafeOutputsToolsJSONInSync`.

### Conformance

An implementation is considered conformant with this ADR if it satisfies every **MUST** and **MUST NOT** requirement above. In particular, an implementation that accepts the `allowed-branches` field but does not reject non-matching branches at MCP collection time is **non-conformant**.

---

*This is a DRAFT ADR generated by the [Design Decision Gate](https://github.com/github/gh-aw/actions/runs/26187066394) workflow. The PR author must review, complete (especially the Deciders field), and finalize this document before the PR can merge.*
16 changes: 16 additions & 0 deletions docs/src/content/docs/reference/frontmatter-full.md
Original file line number Diff line number Diff line change
Expand Up @@ -4475,6 +4475,22 @@ safe-outputs:
# (optional)
base-branch: "example-value"

# Optional list of allowed source branch patterns (glob syntax, e.g.
# 'feature/*', 'release/*'). When configured, the effective create_pull_request
# branch must match one of these patterns. Accepts an array or a GitHub Actions
# expression resolving to a comma-separated list (e.g. '${{
# inputs[\'allowed-branches\'] }}').
# (optional)
# Accepted formats:

# Format 1: Array of source branch patterns (glob syntax supported)
allowed-branches: []
# Array items: string

# Format 2: GitHub Actions expression resolving to a comma-separated list of
# source branch patterns (e.g. '${{ inputs[\'allowed-branches\'] }}')
allowed-branches: "example-value"

# Optional list of allowed base branch patterns (glob syntax, e.g. 'main',
# 'release/*'). When configured, the agent may provide a `base` field in
# create_pull_request output to override base-branch for a single run, but only if
Expand Down
5 changes: 5 additions & 0 deletions docs/src/content/docs/reference/safe-outputs-pull-requests.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ safe-outputs:
target-repo: "owner/repo" # cross-repository
allowed-repos: ["org/repo1", "org/repo2"] # additional allowed repositories
base-branch: "vnext" # target branch for PR (default: github.base_ref || github.ref_name)
allowed-branches: # allow agent-selected source branches matching these globs
- feature/*
- release/*
allowed-base-branches: # allow agent to override base branch at runtime (glob patterns)
- main
- release/*
Expand Down Expand Up @@ -73,6 +76,8 @@ safe-outputs:
- release/*
```

The `allowed-branches` field constrains which source branch names may be used for `create_pull_request`. The effective branch selected by the handler (agent-provided branch, or checkout branch fallback when omitted) must match one of the configured glob patterns. This is useful when your repository enforces source branch naming conventions (for example, only `feature/*` and `release/*`).

**Example use case:** A workflow in `org/engineering` that creates PRs in `org/docs` targeting the `vnext` branch for feature documentation:

```yaml wrap
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2262,6 +2262,7 @@ safe-outputs:

- `max`: Operation limit (default: 1)
- `base-branch`: Target branch
- `allowed-branches`: Allowed source branch patterns for `branch` tool input
- `allowed-base-branches`: Allowed base-branch override patterns for per-run `base` tool input
- `draft`: Draft status
- `commit-changes`: Auto-commit workspace
Expand Down
Loading
Loading