Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

88 changes: 88 additions & 0 deletions pkg/cli/codemod_min_integrity_none_bash.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package cli

import (
"strings"

"github.com/github/gh-aw/pkg/logger"
)

var minIntegrityNoneBashCodemodLog = logger.New("cli:codemod_min_integrity_none_bash")

// getMinIntegrityNoneRequiresBashCodemod creates a codemod that adds an explicit
// 'tools.bash: false' when 'tools.github.min-integrity' is set to 'none' and
// 'tools.bash' is not already specified.
//
// Strict mode requires bash access to be explicit whenever min-integrity is none, since
// any external user can trigger the workflow. No bash tool was configured before, so
// inserting 'bash: false' preserves the existing behavior while satisfying the new
// strict-mode requirement.
func getMinIntegrityNoneRequiresBashCodemod() Codemod {
return Codemod{
ID: "min-integrity-none-requires-bash",
Name: "Add explicit 'tools.bash: false' when 'tools.github.min-integrity' is 'none'",
Description: "Inserts 'tools.bash: false' when 'tools.github.min-integrity' is set to 'none' and 'tools.bash' is not already specified, preserving current behavior while satisfying strict mode",
IntroducedIn: "1.5.0",
Apply: func(content string, frontmatter map[string]any) (string, bool, error) {
toolsMap, ok := frontmatter["tools"].(map[string]any)
if !ok {
return content, false, nil
}

if _, hasBash := toolsMap["bash"]; hasBash {
return content, false, nil
}

githubMap, ok := toolsMap["github"].(map[string]any)
if !ok {
return content, false, nil
}

minIntegrity, ok := githubMap["min-integrity"].(string)
if !ok || minIntegrity != "none" {
return content, false, nil
}

newContent, applied, err := applyFrontmatterLineTransform(content, insertBashFalseIntoTopLevelTools)
if applied {
minIntegrityNoneBashCodemodLog.Print("Inserted 'tools.bash: false' because tools.github.min-integrity is 'none'")
}
return newContent, applied, err
},
}
}

// insertBashFalseIntoTopLevelTools inserts 'bash: false' as the first child of the
// top-level 'tools:' block. It assumes the caller has already verified that 'tools'
// exists as a block mapping and that 'tools.bash' is not already present.
func insertBashFalseIntoTopLevelTools(lines []string) ([]string, bool) {
Comment thread
github-actions[bot] marked this conversation as resolved.
Comment thread
github-actions[bot] marked this conversation as resolved.
toolsLine := -1
for i, line := range lines {
if isTopLevelBlockKey(line, "tools") {
toolsLine = i
break
}
}
if toolsLine == -1 {
return lines, false
}

fieldIndent := " "
insertAt := toolsLine + 1

for i := toolsLine + 1; i < len(lines); i++ {
line := lines[i]
trimmed := strings.TrimSpace(line)
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
continue
}
if hasExitedBlock(line, "") {
break
}
fieldIndent = getIndentation(line)
insertAt = i
break
}

result := insertLine(lines, insertAt, fieldIndent+"bash: false")
return result, true
}
133 changes: 133 additions & 0 deletions pkg/cli/codemod_min_integrity_none_bash_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
//go:build !integration

package cli

import (
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestMinIntegrityNoneRequiresBashCodemod(t *testing.T) {
t.Parallel()
codemod := getMinIntegrityNoneRequiresBashCodemod()

assert.Equal(t, "min-integrity-none-requires-bash", codemod.ID)
assert.NotEmpty(t, codemod.Name)
assert.NotEmpty(t, codemod.Description)
assert.NotEmpty(t, codemod.IntroducedIn)
require.NotNil(t, codemod.Apply)

t.Run("inserts bash: false when min-integrity is none and bash is absent", func(t *testing.T) {
t.Parallel()
content := `---
on: workflow_dispatch
tools:
github:
min-integrity: none
---

# Test
`
frontmatter := map[string]any{
"tools": map[string]any{
"github": map[string]any{"min-integrity": "none"},
},
}

result, applied, err := codemod.Apply(content, frontmatter)
require.NoError(t, err)
assert.True(t, applied)
assert.Contains(t, result, " bash: false")
assert.Contains(t, result, " bash: false\n github:")
})

t.Run("does nothing when bash is already specified", func(t *testing.T) {
t.Parallel()
content := `---
on: workflow_dispatch
tools:
bash: ["cat", "ls"]
github:
min-integrity: none
---

# Test
`
frontmatter := map[string]any{
"tools": map[string]any{
"bash": []any{"cat", "ls"},
"github": map[string]any{"min-integrity": "none"},
},
}

result, applied, err := codemod.Apply(content, frontmatter)
require.NoError(t, err)
assert.False(t, applied)
assert.Equal(t, content, result)
})

t.Run("does nothing when min-integrity is not none", func(t *testing.T) {
t.Parallel()
content := `---
on: workflow_dispatch
tools:
github:
min-integrity: approved
---

# Test
`
frontmatter := map[string]any{
"tools": map[string]any{
"github": map[string]any{"min-integrity": "approved"},
},
}

result, applied, err := codemod.Apply(content, frontmatter)
require.NoError(t, err)
assert.False(t, applied)
assert.Equal(t, content, result)
})

t.Run("does nothing when tools.github is absent", func(t *testing.T) {
t.Parallel()
content := `---
on: workflow_dispatch
engine: copilot
---

# Test
`
frontmatter := map[string]any{"engine": "copilot"}

result, applied, err := codemod.Apply(content, frontmatter)
require.NoError(t, err)
assert.False(t, applied)
assert.Equal(t, content, result)
})

t.Run("does nothing when min-integrity is absent", func(t *testing.T) {
t.Parallel()
content := `---
on: workflow_dispatch
tools:
github:
allowed-repos: all
---

# Test
`
frontmatter := map[string]any{
"tools": map[string]any{
"github": map[string]any{"allowed-repos": "all"},
},
}

result, applied, err := codemod.Apply(content, frontmatter)
require.NoError(t, err)
assert.False(t, applied)
assert.Equal(t, content, result)
})
}
35 changes: 29 additions & 6 deletions pkg/cli/codemod_sandbox_runtime_profile.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const (
sandboxRuntimeDockerSudoIptables = "docker-sudo-iptables"
sandboxRuntimeDockerSbx = "docker-sbx"
sandboxRuntimeCloudHypervisor = "cloud-hypervisor"
sandboxRuntimeGvisor = "gvisor"
)

// getSandboxRuntimeProfileCodemod creates a codemod that migrates the removed
Expand All @@ -24,10 +25,13 @@ const (
// legacy-security: enable -> runtime: docker-sudo-iptables
// runtime: docker-sbx + sudo: true -> runtime: docker-sbx
// sudo: true (no other runtime) -> runtime: docker-sudo-iptables
// runtime: gvisor + sudo/legacy -> runtime: gvisor (sudo/legacy-security are dropped)
Comment thread
github-actions[bot] marked this conversation as resolved.
//
// Mixed profiles that cannot be migrated unambiguously (for example gVisor combined
// with legacy security) return an actionable error so the author can choose between
// strict isolation and the privileged iptables profile.
// gVisor combined with privileged security options keeps the strict 'runtime: gvisor'
// isolation and simply drops the no-longer-supported 'sudo'/'legacy-security' fields,
// since gVisor's network isolation already takes precedence over the privileged intent.
// Other mixed profiles that cannot be migrated unambiguously return an actionable error
// so the author can choose between strict isolation and the privileged iptables profile.
func getSandboxRuntimeProfileCodemod() Codemod {
return Codemod{
ID: "sandbox-runtime-profiles",
Expand Down Expand Up @@ -59,7 +63,7 @@ func getSandboxRuntimeProfileCodemod() Codemod {
}

newContent, applied, err := applyFrontmatterLineTransform(content, func(lines []string) ([]string, bool) {
result, modified := migrateSandboxAgentSecurityLines(lines, targetRuntime, runtime != "")
result, modified := migrateSandboxAgentSecurityLines(lines, runtime, targetRuntime)
if modified {
// Dropping the only key under sandbox.agent leaves a dangling
// "agent:" (and possibly "sandbox:") key that YAML parses as null.
Expand Down Expand Up @@ -97,6 +101,16 @@ func resolveMigratedSandboxRuntime(runtime string, sudoEnabled, legacyEnabled bo
return "", mixedSandboxProfileError(runtime)
}
return "", nil
case sandboxRuntimeGvisor:
// gVisor combined with privileged security options is no longer a supported
// combination. gVisor's strict network isolation takes precedence, so keep
// 'runtime: gvisor' and drop the 'sudo'/'legacy-security' fields instead of
// aborting the fix pass so `gh aw fix --write` can still repair the file.
Comment thread
github-actions[bot] marked this conversation as resolved.
sandboxRuntimeProfileCodemodLog.Printf(
"sandbox.agent.runtime: gvisor combined with privileged security options is not supported; keeping %q and dropping sudo/legacy-security",
sandboxRuntimeGvisor,
)
return sandboxRuntimeGvisor, nil
default:
return "", mixedSandboxProfileError(runtime)
}
Expand All @@ -114,14 +128,18 @@ func mixedSandboxProfileError(runtime string) error {
// migrateSandboxAgentSecurityLines removes the sudo and legacy-security keys from the
// sandbox.agent block. When targetRuntime is non-empty and the block has no runtime
// key yet, the first removed key is replaced by the runtime key so the profile is
// preserved in place.
func migrateSandboxAgentSecurityLines(lines []string, targetRuntime string, hasRuntime bool) ([]string, bool) {
// preserved in place. When the block already has a runtime key but its value differs
// from targetRuntime (for example gVisor migrating to the privileged iptables profile),
// the existing runtime line's value is rewritten in place.
func migrateSandboxAgentSecurityLines(lines []string, oldRuntime, targetRuntime string) ([]string, bool) {
start, end, indent, found := findSandboxAgentBlock(lines)
if !found {
return lines, false
}

hasRuntime := oldRuntime != ""
needsRuntime := targetRuntime != "" && !hasRuntime
needsRuntimeUpdate := targetRuntime != "" && hasRuntime && targetRuntime != oldRuntime
result := make([]string, 0, len(lines))
modified := false

Expand All @@ -131,6 +149,11 @@ func migrateSandboxAgentSecurityLines(lines []string, targetRuntime string, hasR
continue
}
trimmed := strings.TrimSpace(line)
if needsRuntimeUpdate && getIndentation(line) == indent && strings.HasPrefix(trimmed, "runtime:") {
result = append(result, indent+"runtime: "+targetRuntime)

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 — the runtime line rewrite now appends trailingCommentSuffix(...) (codemod_sandbox_runtime_profile.go:153), which uses the shared findTrailingCommentIndex helper to preserve any user-authored # ... suffix while replacing only the scalar value. Covered by the test case "rewritten runtime line keeps its trailing comment".

modified = true
continue
}
if getIndentation(line) != indent ||
(!strings.HasPrefix(trimmed, "sudo:") && !strings.HasPrefix(trimmed, "legacy-security:")) {
result = append(result, line)
Expand Down
12 changes: 8 additions & 4 deletions pkg/cli/codemod_sandbox_runtime_profile_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ engine: copilot
expectApplied: false,
},
{
name: "gvisor combined with legacy-security is an actionable error",
name: "gvisor combined with legacy-security keeps gvisor and drops legacy-security",
content: `---
on: workflow_dispatch
sandbox:
Expand All @@ -159,10 +159,12 @@ sandbox:
"agent": map[string]any{"runtime": "gvisor", "legacy-security": "enable"},
},
},
expectErrSubstr: "docker-sudo-iptables",
expectApplied: true,
expectContains: []string{" runtime: gvisor"},
expectExcludes: []string{"legacy-security:", "docker-sudo-iptables"},
},
{
name: "gvisor combined with sudo: true is an actionable error",
name: "gvisor combined with sudo: true keeps gvisor and drops sudo",
content: `---
on: workflow_dispatch
sandbox:
Expand All @@ -177,7 +179,9 @@ sandbox:
"agent": map[string]any{"runtime": "gvisor", "sudo": true},
},
},
expectErrSubstr: "gvisor",
expectApplied: true,
expectContains: []string{" runtime: gvisor"},
expectExcludes: []string{"sudo:", "docker-sudo-iptables"},
},
}

Comment thread
github-actions[bot] marked this conversation as resolved.
Expand Down
1 change: 1 addition & 0 deletions pkg/cli/fix_codemods.go
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ func GetAllCodemods() []Codemod {
getMentionsAllowTeamMembersCodemod(), // Rename allow-team-members to allowed-collaborators in safe-outputs.mentions
getEngineCopilotSDKDriverToDriverCodemod(), // Rename deprecated engine.copilot-sdk-driver to engine.driver
getEngineModelToTopLevelCodemod(), // Move engine.model to top-level model
getMinIntegrityNoneRequiresBashCodemod(), // Add tools.bash: false when tools.github.min-integrity is 'none'

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 — getMinIntegrityNoneRequiresBashCodemod() is now registered at fix_codemods.go:113, immediately before getCLIProxyBashDisabledCodemod() at line 114, so a single fix pass emits both bash: false and cli-proxy: false. Covered by a registry-level test in codemod_min_integrity_none_bash_test.go that runs the full registry through processWorkflowFileWithInfo and asserts both settings appear after one pass, for block and inline tools: syntax.

}
fixCodemodsLog.Printf("Loaded codemod registry: %d codemods available", len(codemods))
return codemods
Expand Down
1 change: 1 addition & 0 deletions pkg/cli/fix_codemods_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -257,5 +257,6 @@ func expectedCodemodOrder() []string {
"mentions-allow-team-members-to-allowed-collaborators",
"engine-copilot-sdk-driver-to-driver",
"engine-model-to-top-level",
"min-integrity-none-requires-bash",
}
}
Loading