Skip to content
Merged
64 changes: 64 additions & 0 deletions docs/src/content/docs/reference/permissions.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,70 @@ Write operations use safe outputs instead of direct API access. This provides co

Run `gh aw compile workflow.md` to validate permissions. Common errors include undefined permissions, direct write permissions in the main job (use safe outputs instead), and insufficient permissions for declared tools. Use `--strict` mode to enforce read-only permissions and require explicit network configuration.

### Write Permission Policy

Write permissions are blocked by default to enforce the security-first design. Workflows with write permissions will fail compilation with an error:

```
Write permissions are not allowed unless the 'dangerous-permissions-write' feature flag is enabled.

Found write permissions:
- contents: write

To fix this issue, you have two options:

Option 1: Change write permissions to read:
permissions:
contents: read

Option 2: Enable the feature flag (use with caution):
features:
dangerous-permissions-write: true
```

#### Migrating Existing Workflows

To migrate workflows with write permissions, you can:

1. **Use the automated codemod** (recommended):
```bash
# Check what would be changed (dry-run)
gh aw fix workflow.md

# Apply the fix
gh aw fix workflow.md --write
```

This automatically converts all write permissions to read permissions.

2. **Enable the feature flag** (for workflows that genuinely need write access):
```yaml
features:
dangerous-permissions-write: true
permissions:
contents: write
```

:::danger[Use with extreme caution]
The `dangerous-permissions-write` feature flag bypasses the security-first design. Only use it when:
- You have a specific use case that requires direct write access
- You fully understand the security implications
- You have reviewed and approved the AI's access to repository writes

In most cases, use [safe outputs](/gh-aw/reference/safe-outputs/) instead.
:::

#### Scope

This validation applies to:
- Top-level workflow `permissions:` configuration

This validation does **not** apply to:
- Custom jobs (defined in `jobs:` section)
- Safe outputs jobs (defined in `safe-outputs.job:` section)

Custom jobs and safe outputs jobs can have their own permission requirements based on their specific needs.

### Tool-Specific Requirements

Some tools require specific permissions to function:
Expand Down
8 changes: 8 additions & 0 deletions pkg/cli/compile_security_benchmark_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ permissions:
contents: read
pull-requests: write
engine: copilot
features:
dangerous-permissions-write: true
strict: false
tools:
github:
Expand Down Expand Up @@ -76,6 +78,8 @@ permissions:
contents: read
issues: write
engine: claude
features:
dangerous-permissions-write: true
strict: false
tools:
github:
Expand Down Expand Up @@ -243,6 +247,8 @@ permissions:
contents: read
pull-requests: write
engine: copilot
features:
dangerous-permissions-write: true
strict: false
tools:
github:
Expand Down Expand Up @@ -294,6 +300,8 @@ permissions:
contents: read
issues: write
engine: claude
features:
dangerous-permissions-write: true
strict: false
tools:
github:
Expand Down
128 changes: 128 additions & 0 deletions pkg/cli/fix_codemods.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ func GetAllCodemods() []Codemod {
getCommandToSlashCommandCodemod(),
getSafeInputsModeCodemod(),
getUploadAssetsCodemod(),
getWritePermissionsCodemod(),
getAgentTaskToAgentSessionCodemod(),
}
}
Expand Down Expand Up @@ -589,6 +590,133 @@ func getUploadAssetsCodemod() Codemod {
}
}

// getWritePermissionsCodemod creates a codemod for converting write permissions to read
func getWritePermissionsCodemod() Codemod {
return Codemod{
ID: "write-permissions-to-read-migration",
Name: "Convert write permissions to read",
Description: "Converts all write permissions to read permissions to comply with the new security policy",
IntroducedIn: "0.4.0",
Apply: func(content string, frontmatter map[string]any) (string, bool, error) {
// Check if permissions exist
permissionsValue, hasPermissions := frontmatter["permissions"]
if !hasPermissions {
return content, false, nil
}

// Check if any write permissions exist
hasWritePermissions := false

// Handle string shorthand (write-all, write)
if strValue, ok := permissionsValue.(string); ok {
if strValue == "write-all" || strValue == "write" {
hasWritePermissions = true
}
}

// Handle map format
if mapValue, ok := permissionsValue.(map[string]any); ok {
for _, value := range mapValue {
if strValue, ok := value.(string); ok && strValue == "write" {
hasWritePermissions = true
break
}
}
}

if !hasWritePermissions {
return content, false, nil
}

// Parse frontmatter to get raw lines
result, err := parser.ExtractFrontmatterFromContent(content)
if err != nil {
return content, false, fmt.Errorf("failed to parse frontmatter: %w", err)
}

// Find and replace write permissions
var modified bool
var inPermissionsBlock bool
var permissionsIndent string

frontmatterLines := make([]string, len(result.FrontmatterLines))

for i, line := range result.FrontmatterLines {
trimmedLine := strings.TrimSpace(line)

// Track if we're in the permissions block
if strings.HasPrefix(trimmedLine, "permissions:") {
inPermissionsBlock = true
permissionsIndent = line[:len(line)-len(strings.TrimLeft(line, " \t"))]

// Handle shorthand on same line: "permissions: write-all" or "permissions: write"
if strings.Contains(trimmedLine, ": write-all") {
frontmatterLines[i] = strings.Replace(line, ": write-all", ": read-all", 1)
modified = true
codemodsLog.Printf("Replaced permissions: write-all with permissions: read-all on line %d", i+1)
continue
} else if strings.Contains(trimmedLine, ": write") && !strings.Contains(trimmedLine, "write-all") {
frontmatterLines[i] = strings.Replace(line, ": write", ": read", 1)
modified = true
codemodsLog.Printf("Replaced permissions: write with permissions: read on line %d", i+1)
continue
}

frontmatterLines[i] = line
continue
}

// Check if we've left the permissions block (new top-level key with same or less indentation)
if inPermissionsBlock && len(trimmedLine) > 0 && !strings.HasPrefix(trimmedLine, "#") {
currentIndent := line[:len(line)-len(strings.TrimLeft(line, " \t"))]
if len(currentIndent) <= len(permissionsIndent) && strings.Contains(line, ":") {
inPermissionsBlock = false
}
}

// Replace write with read if in permissions block
if inPermissionsBlock && strings.Contains(trimmedLine, ": write") {
// Preserve indentation and everything else
// Extract the key, value, and any trailing comment
parts := strings.SplitN(line, ":", 2)
if len(parts) >= 2 {
key := parts[0]
valueAndComment := parts[1]

// Replace "write" with "read" in the value part
newValueAndComment := strings.Replace(valueAndComment, " write", " read", 1)
frontmatterLines[i] = fmt.Sprintf("%s:%s", key, newValueAndComment)
modified = true
codemodsLog.Printf("Replaced write with read on line %d", i+1)
} else {
frontmatterLines[i] = line
}
} else {
frontmatterLines[i] = line
}
}

if !modified {
return content, false, nil
}

// Reconstruct the content
var lines []string
lines = append(lines, "---")
lines = append(lines, frontmatterLines...)
lines = append(lines, "---")
if result.Markdown != "" {
lines = append(lines, "")
lines = append(lines, result.Markdown)
}

newContent := strings.Join(lines, "\n")
codemodsLog.Print("Applied write permissions to read migration")
return newContent, true, nil
},
}
}

// getAgentTaskToAgentSessionCodemod creates a codemod for migrating create-agent-task to create-agent-session
func getAgentTaskToAgentSessionCodemod() Codemod {
return Codemod{
Expand Down
2 changes: 2 additions & 0 deletions pkg/constants/constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -408,6 +408,8 @@ const (
MCPGatewayFeatureFlag FeatureFlag = "mcp-gateway"
// SandboxRuntimeFeatureFlag is the feature flag name for sandbox runtime
SandboxRuntimeFeatureFlag FeatureFlag = "sandbox-runtime"
// DangerousPermissionsWriteFeatureFlag is the feature flag name for allowing write permissions
DangerousPermissionsWriteFeatureFlag FeatureFlag = "dangerous-permissions-write"
)

// Step IDs for pre-activation job
Expand Down
1 change: 1 addition & 0 deletions pkg/constants/constants_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,7 @@ func TestFeatureFlagConstants(t *testing.T) {
{"SafeInputsFeatureFlag", SafeInputsFeatureFlag, "safe-inputs"},
{"MCPGatewayFeatureFlag", MCPGatewayFeatureFlag, "mcp-gateway"},
{"SandboxRuntimeFeatureFlag", SandboxRuntimeFeatureFlag, "sandbox-runtime"},
{"DangerousPermissionsWriteFeatureFlag", DangerousPermissionsWriteFeatureFlag, "dangerous-permissions-write"},
}

for _, tt := range tests {
Expand Down
2 changes: 2 additions & 0 deletions pkg/workflow/action_sha_validation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ on:
issues:
types: [opened]
engine: copilot
features:
dangerous-permissions-write: true
permissions:
contents: read
issues: write
Expand Down
6 changes: 6 additions & 0 deletions pkg/workflow/activation_checkout_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ permissions:
contents: read
issues: write
engine: claude
features:
dangerous-permissions-write: true
strict: false
---`,
description: "Activation job should not include checkout step - uses GitHub API instead",
Expand All @@ -40,6 +42,8 @@ on:
permissions:
issues: write
engine: claude
features:
dangerous-permissions-write: true
strict: false
---`,
description: "Activation job should not include checkout - uses GitHub API instead",
Expand All @@ -54,6 +58,8 @@ on:
permissions:
issues: write
engine: claude
features:
dangerous-permissions-write: true
strict: false
---`,
description: "Activation job with reaction should not include checkout - uses GitHub API instead",
Expand Down
4 changes: 4 additions & 0 deletions pkg/workflow/agentic_output_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ tools:
github:
allowed: [list_issues]
engine: claude
features:
dangerous-permissions-write: true
strict: false
safe-outputs:
add-labels:
Expand Down Expand Up @@ -125,6 +127,8 @@ tools:
github:
allowed: [list_issues]
engine: codex
features:
dangerous-permissions-write: true
strict: false
safe-outputs:
add-labels:
Expand Down
12 changes: 12 additions & 0 deletions pkg/workflow/allow_github_references_env_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ func TestAllowGitHubReferencesEnvVar(t *testing.T) {
workflow: `---
on: push
engine: copilot
features:
dangerous-permissions-write: true
strict: false
permissions:
contents: read
Expand All @@ -42,6 +44,8 @@ Test workflow with allowed-github-references.
workflow: `---
on: push
engine: copilot
features:
dangerous-permissions-write: true
strict: false
permissions:
contents: read
Expand All @@ -63,6 +67,8 @@ Test workflow with multiple allowed repos.
workflow: `---
on: push
engine: copilot
features:
dangerous-permissions-write: true
strict: false
permissions:
contents: read
Expand All @@ -82,6 +88,8 @@ Test workflow without allowed-github-references.
workflow: `---
on: push
engine: copilot
features:
dangerous-permissions-write: true
strict: false
permissions:
contents: read
Expand All @@ -103,6 +111,8 @@ Test workflow with special characters in repo names.
workflow: `---
on: push
engine: copilot
features:
dangerous-permissions-write: true
strict: false
permissions:
contents: read
Expand All @@ -124,6 +134,8 @@ Test workflow mixing repo keyword with specific repos.
workflow: `---
on: push
engine: copilot
features:
dangerous-permissions-write: true
strict: false
permissions:
contents: read
Expand Down
2 changes: 2 additions & 0 deletions pkg/workflow/aw_info_tmp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ tools:
github:
allowed: [list_issues]
engine: claude
features:
dangerous-permissions-write: true
strict: false
---

Expand Down
Loading