Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
7 changes: 6 additions & 1 deletion pkg/workflow/compiler_activation_job.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,12 +63,17 @@ func (c *Compiler) buildActivationJob(data *WorkflowData, preActivationJobCreate
ctx.steps = append(ctx.steps, c.generateScriptModeCleanupStep())
}

permissions, err := c.buildActivationPermissions(ctx)
if err != nil {
return nil, err
}

return &Job{
Name: string(constants.ActivationJobName),
If: ctx.activationCondition,
HasWorkflowRunSafetyChecks: workflowRunRepoSafety != "",
RunsOn: c.formatFrameworkJobRunsOn(data),
Permissions: c.buildActivationPermissions(ctx),
Permissions: permissions,
Environment: c.buildActivationEnvironment(ctx),
Steps: ctx.steps,
Outputs: ctx.outputs,
Expand Down
56 changes: 54 additions & 2 deletions pkg/workflow/compiler_activation_job_builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,15 @@ type activationJobBuildContext struct {
customJobsBeforeActivation []string
activationNeeds []string
activationCondition string

// activationAllScripts holds the `run` scripts extracted from all step sections
// in jobs.activation (pre-steps, steps, post-steps), cached to avoid repeated extraction.
// pre-agent-steps is intentionally omitted: it is an agent-job-only concept (steps that
// run immediately before AI execution) and has no meaning in the activation job.
activationAllScripts []string
// activationInferredPerms holds the permissions inferred from activationAllScripts,
// cached here to avoid repeated inference.
activationInferredPerms map[PermissionScope]PermissionLevel
}

// newActivationJobBuildContext initializes activation-job state with setup, aw_info, and base outputs.
Expand Down Expand Up @@ -75,6 +84,17 @@ func (c *Compiler) newActivationJobBuildContext(
}
ctx.shouldRemoveLabel = ctx.hasLabelCommand && data.LabelCommandRemoveLabel

// Cache scripts from all step sections and inferred permissions once to avoid redundant
// extraction and inference calls in buildActivationPermissions and
// addActivationFeedbackAndValidationSteps.
activationJobName := string(constants.ActivationJobName)
for _, section := range []string{"pre-steps", "steps", "post-steps"} {
ctx.activationAllScripts = append(ctx.activationAllScripts, extractRunScriptsFromJobSection(data.Jobs, activationJobName, section)...)
}
if len(ctx.activationAllScripts) > 0 {
ctx.activationInferredPerms = inferPermissionsFromShellScripts(ctx.activationAllScripts)
}

ctx.steps = append(ctx.steps, c.generateCheckoutActionsFolder(data)...)
activationSetupTraceID := ""
activationSetupParentSpanID := ""
Expand Down Expand Up @@ -162,6 +182,17 @@ func (c *Compiler) addActivationFeedbackAndValidationSteps(ctx *activationJobBui
if ctx.needsAppTokenForAccess {
appPerms.Set(PermissionContents, PermissionRead)
}
// Add GitHub App-only permissions inferred from activation job gh CLI commands so the
// minted App token includes the scopes those commands require (e.g. codespaces: read
// for `gh codespace list`). Only App-only scopes are passed here — standard GitHub
// Actions scopes (pull-requests, issues, etc.) are already covered by the GITHUB_TOKEN
// permissions block and do not need to be re-declared on the App token.
// Uses the cached inferred permissions to avoid redundant computation.
for scope, level := range ctx.activationInferredPerms {
if IsGitHubAppOnlyScope(scope) {
appPerms.Set(scope, level)
}
}
ctx.steps = append(ctx.steps, c.buildActivationAppTokenMintStep(data.ActivationGitHubApp, appPerms)...)
ctx.outputs["activation_app_token_minting_failed"] = "${{ steps.activation-app-token.outcome == 'failure' }}"
}
Expand Down Expand Up @@ -492,7 +523,8 @@ func (c *Compiler) addActivationArtifactUploadStep(ctx *activationJobBuildContex
}

// buildActivationPermissions builds activation job permissions from workflow features and selected interactions.
func (c *Compiler) buildActivationPermissions(ctx *activationJobBuildContext) string {
// Returns an error if any activation job step section contains write gh CLI commands that would require write permissions.
func (c *Compiler) buildActivationPermissions(ctx *activationJobBuildContext) (string, error) {
permsMap := map[PermissionScope]PermissionLevel{
PermissionContents: PermissionRead,
}
Expand Down Expand Up @@ -541,7 +573,27 @@ func (c *Compiler) buildActivationPermissions(ctx *activationJobBuildContext) st
permsMap[PermissionDiscussions] = PermissionWrite
}
}
return NewPermissionsFromMap(permsMap).RenderToYAML()
// Infer permissions required by gh CLI calls in jobs.activation step sections
// (pre-steps, steps, post-steps). This ensures that user-defined steps that call
// `gh pr diff`, `gh issue view`, etc. get the permissions they need without requiring
// manual permission declarations.
// Scripts and inferred permissions are cached in ctx to avoid redundant computation.
if len(ctx.activationAllScripts) > 0 {
// Detect write commands first — these are not permitted in the activation job
// because it intentionally operates with read-only permissions.
if writeCmds := detectWriteCommandsInShellScripts(ctx.activationAllScripts); len(writeCmds) > 0 {
return "", fmt.Errorf(
"activation job uses write gh command(s) [%s]; write operations are not permitted in activation job steps because the activation job runs with read-only permissions. Move write operations to the agent job steps or use safe-outputs. See: https://github.github.com/gh-aw/reference/safe-outputs/",
strings.Join(writeCmds, ", "),
)
}
for scope, level := range ctx.activationInferredPerms {
if _, exists := permsMap[scope]; !exists {
permsMap[scope] = level
}
}
}
return NewPermissionsFromMap(permsMap).RenderToYAML(), nil
}

// buildActivationEnvironment returns manual-approval environment YAML, with ANSI removed.
Expand Down
35 changes: 35 additions & 0 deletions pkg/workflow/compiler_main_job.go
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,41 @@ func (c *Compiler) buildMainJob(data *WorkflowData, activationJobCreated bool) (
}
}

// Infer permissions required by gh CLI calls in all agent job step sections
// (pre-steps, steps, post-steps, pre-agent-steps).
// Detects write commands (which are not permitted since the agent job is read-only),
// and merges inferred read permissions into the existing permissions block.
// Skipped only when the user explicitly opted out of all permissions (permissions: {}).
agentJobName := string(constants.AgentJobName)
agentAllScripts := extractRunScriptsFromSectionYAML(data.PreSteps, "pre-steps")
agentAllScripts = append(agentAllScripts, extractRunScriptsFromSectionYAML(data.CustomSteps, "steps")...)
agentAllScripts = append(agentAllScripts, extractRunScriptsFromSectionYAML(data.PreAgentSteps, "pre-agent-steps")...)
agentAllScripts = append(agentAllScripts, extractRunScriptsFromSectionYAML(data.PostSteps, "post-steps")...)
if data.Jobs != nil {
for _, section := range []string{"pre-steps", "steps", "pre-agent-steps", "post-steps"} {
agentAllScripts = append(agentAllScripts, extractRunScriptsFromJobSection(data.Jobs, agentJobName, section)...)
}
}
if len(agentAllScripts) > 0 {
if writeCmds := detectWriteCommandsInShellScripts(agentAllScripts); len(writeCmds) > 0 {
return nil, fmt.Errorf(
"agent job uses write gh command(s) [%s]; write operations are not permitted in agent job steps because the agent job runs with read-only permissions. Use safe-outputs for write operations. See: https://github.github.com/gh-aw/reference/safe-outputs/",
strings.Join(writeCmds, ", "),
)
}
// Infer read permissions unless the user explicitly zeroed out all permissions.
// Check data.Permissions (the original value) since needsContentsRead above may have
// already expanded "permissions: {}" into an explicit block.
// Uses the same exact-string check as tools.go (the YAML parser always normalizes
// "permissions: {}" to this canonical form when parsing the frontmatter).
if data.Permissions != "permissions: {}" && permissions != "" {
inferred := inferPermissionsFromShellScripts(agentAllScripts)
if len(inferred) > 0 {
permissions = mergeInferredIntoPermissionsYAML(permissions, inferred)
}
}
}

// In script mode, explicitly add a cleanup step (mirrors post.js in dev/release/action mode).
if c.actionMode.IsScript() {
steps = append(steps, c.generateScriptModeCleanupStep())
Expand Down
185 changes: 185 additions & 0 deletions pkg/workflow/data/gh_cli_permissions.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
{
"version": "1.2",
"description": "GitHub CLI (gh) subcommand patterns and their required GitHub Actions and GitHub App permissions. Used by the compiler to infer permissions needed by pre-steps that call the gh CLI.",
"subcommand_groups": {
"pr": {
"description": "Pull request operations",
"read_subcommands": ["checkout", "checks", "diff", "list", "status", "view"],
"write_subcommands": ["close", "comment", "convert-to-draft", "create", "edit", "lock", "merge", "ready", "reopen", "review", "revert", "unlock", "update-branch"],
"read_permissions": ["pull-requests"],
"write_permissions": ["pull-requests"],
"app_read_permissions": ["pull-requests"],
"app_write_permissions": ["pull-requests"]
},
"issue": {
"description": "Issue operations",
"read_subcommands": ["list", "status", "view"],
"write_subcommands": ["close", "comment", "create", "delete", "develop", "edit", "lock", "pin", "reopen", "transfer", "unlock", "unpin"],
"read_permissions": ["issues"],
"write_permissions": ["issues"],
"app_read_permissions": ["issues"],
"app_write_permissions": ["issues"]
},
"release": {
"description": "Release operations",
"read_subcommands": ["download", "list", "verify", "verify-asset", "view"],
"write_subcommands": ["create", "delete", "delete-asset", "edit", "upload"],
"read_permissions": ["contents"],
"write_permissions": ["contents"],
"app_read_permissions": ["contents"],
"app_write_permissions": ["contents"]
},
"workflow": {
"description": "GitHub Actions workflow operations",
"read_subcommands": ["list", "view"],
"write_subcommands": ["disable", "enable", "run"],
"read_permissions": ["actions"],
"write_permissions": ["actions"],
"app_read_permissions": ["actions"],
"app_write_permissions": ["actions", "workflows"]
},
"run": {
"description": "GitHub Actions run operations",
"read_subcommands": ["download", "list", "view", "watch"],
"write_subcommands": ["cancel", "delete", "rerun"],
"read_permissions": ["actions"],
"write_permissions": ["actions"],
"app_read_permissions": ["actions"],
"app_write_permissions": ["actions"]
},
"cache": {
"description": "GitHub Actions cache operations",
"read_subcommands": ["list"],
"write_subcommands": ["delete"],
"read_permissions": ["actions"],
"write_permissions": ["actions"],
"app_read_permissions": ["actions"],
"app_write_permissions": ["actions"]
},
"repo": {
"description": "Repository operations",
"read_subcommands": ["clone", "gitignore", "license", "list", "set-default", "view"],
"write_subcommands": ["archive", "autolink", "create", "delete", "deploy-key", "edit", "fork", "rename", "sync", "unarchive"],
"read_permissions": ["contents"],
"write_permissions": ["contents"],
"app_read_permissions": ["contents"],
"app_write_permissions": ["contents", "administration"]
},
"label": {
"description": "Label operations (labels use the issues permission scope)",
"read_subcommands": ["list"],
"write_subcommands": ["clone", "create", "delete", "edit"],
"read_permissions": ["issues"],
"write_permissions": ["issues"],
"app_read_permissions": ["issues"],
"app_write_permissions": ["issues"]
},
"codespace": {
"description": "Codespace operations (requires GitHub App with codespaces permission; not available via GITHUB_TOKEN)",
"read_subcommands": ["list", "logs", "ports", "view"],
"write_subcommands": ["create", "delete", "edit", "rebuild", "rename", "restart", "start", "stop"],
"read_permissions": [],
"write_permissions": [],
"app_read_permissions": ["codespaces"],
"app_write_permissions": ["codespaces"]
}
},
"api_path_patterns": [
{
"pattern": "^/?repos/[^/\\s]+/[^/\\s]+/pulls(/|$|\\?)",
"description": "Pull requests REST API",
"permissions": ["pull-requests"],
"app_permissions": ["pull-requests"]
},
{
"pattern": "^/?repos/[^/\\s]+/[^/\\s]+/issues(/|$|\\?)",
"description": "Issues REST API",
"permissions": ["issues"],
"app_permissions": ["issues"]
},
{
"pattern": "^/?repos/[^/\\s]+/[^/\\s]+/labels(/|$|\\?)",
"description": "Labels REST API (uses issues permission scope)",
"permissions": ["issues"],
"app_permissions": ["issues"]
},
{
"pattern": "^/?repos/[^/\\s]+/[^/\\s]+/actions(/|$|\\?)",
"description": "GitHub Actions REST API",
"permissions": ["actions"],
"app_permissions": ["actions"]
},
{
"pattern": "^/?repos/[^/\\s]+/[^/\\s]+/releases(/|$|\\?)",
"description": "Releases REST API",
"permissions": ["contents"],
"app_permissions": ["contents"]
},
{
"pattern": "^/?repos/[^/\\s]+/[^/\\s]+/(git|contents|commits|branches|tags|compare)(/|$|\\?)",
"description": "Repository contents REST API",
"permissions": ["contents"],
"app_permissions": ["contents"]
},
{
"pattern": "^/?repos/[^/\\s]+/[^/\\s]+/deployments(/|$|\\?)",
"description": "Deployments REST API",
"permissions": ["deployments"],
"app_permissions": ["deployments"]
},
{
"pattern": "^/?repos/[^/\\s]+/[^/\\s]+/(statuses|commits/[^/\\s]+/statuses)(/|$|\\?)",
"description": "Commit statuses REST API",
"permissions": ["statuses"],
"app_permissions": ["statuses"]
},
{
"pattern": "^/?repos/[^/\\s]+/[^/\\s]+/(check-runs|check-suites)(/|$|\\?)",
"description": "Check runs/suites REST API",
"permissions": ["checks"],
"app_permissions": ["checks"]
},
{
"pattern": "^/?repos/[^/\\s]+/[^/\\s]+/pages(/|$|\\?)",
"description": "GitHub Pages REST API",
"permissions": ["pages"],
"app_permissions": ["pages"]
},
{
"pattern": "^/?repos/[^/\\s]+/[^/\\s]+/packages(/|$|\\?)",
"description": "Packages REST API",
"permissions": ["packages"],
"app_permissions": ["packages"]
},
{
"pattern": "^/?repos/[^/\\s]+/[^/\\s]+/environments(/|$|\\?)",
"description": "Environments REST API (GitHub App only)",
"permissions": [],
"app_permissions": ["environments"]
},
{
"pattern": "^/?repos/[^/\\s]+/[^/\\s]+/hooks(/|$|\\?)",
"description": "Repository webhooks REST API (GitHub App only)",
"permissions": [],
"app_permissions": ["repository-hooks"]
},
{
"pattern": "^/?orgs/[^/\\s]+/members(/|$|\\?)",
"description": "Organization members REST API (GitHub App only)",
"permissions": [],
"app_permissions": ["members"]
},
{
"pattern": "^/?orgs/[^/\\s]+/teams(/|$|\\?)",
"description": "Organization teams REST API (GitHub App only)",
"permissions": [],
"app_permissions": ["members"]
},
{
"pattern": "^/?orgs/[^/\\s]+/hooks(/|$|\\?)",
"description": "Organization webhooks REST API (GitHub App only)",
"permissions": [],
"app_permissions": ["organization-hooks"]
}
]
}
Loading
Loading