Skip to content
Merged
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
31 changes: 31 additions & 0 deletions pkg/workflow/awf_config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -456,6 +456,37 @@ func TestBuildAWFCommand_PreservesGitHubExpressionOperatorsInConfigJSON(t *testi
assert.NotContains(t, command, "\\u0026", "expected AWF config JSON in command to not HTML-escape '&'")
}

// TestBuildAWFCommand_SchemaKeyEscapedWhenExpressionPresent verifies that the $schema JSON
// key is written as \$schema in the shell command when the AWF config JSON is double-quoted
// (i.e. when AllowedDomains contains a GitHub Actions expression). Without the escaping,
// bash expands $schema as a variable — which is always unset — producing an empty key that
// causes AWF to reject the config with "config. is not supported".
func TestBuildAWFCommand_SchemaKeyEscapedWhenExpressionPresent(t *testing.T) {
config := AWFCommandConfig{
EngineName: "copilot",
EngineCommand: "copilot --prompt-file /tmp/prompt.txt",
LogFile: "/tmp/gh-aw/agent-stdio.log",
// AllowedDomains contains a GitHub Actions expression, forcing double-quote
// shell wrapping for the config JSON. The $schema key must still be safe.
AllowedDomains: "${{ env.DOMAINS }}",
WorkflowData: &WorkflowData{
EngineConfig: &EngineConfig{ID: "copilot"},
NetworkPermissions: &NetworkPermissions{
Firewall: &FirewallConfig{Enabled: true},
},
},
}

command := BuildAWFCommand(config)

// The config JSON is double-quoted because AllowedDomains contains ${{ }}.
// The $schema key must appear as \$schema so bash does not expand it.
assert.Contains(t, command, `\$schema`, "expected \\$schema (escaped) in double-quoted config JSON to prevent bash variable expansion")

// The GitHub Actions expression must remain unescaped for runner evaluation.
assert.Contains(t, command, "${{ env.DOMAINS }}", "expected GitHub Actions expression to be preserved unescaped")
}

// TestBuildAWFCommand_ConfigFileWithPathSetup verifies that the config file write command
// is correctly integrated with the path setup section.
func TestBuildAWFCommand_ConfigFileWithPathSetup(t *testing.T) {
Expand Down
39 changes: 39 additions & 0 deletions pkg/workflow/shell.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,13 @@ func shellEscapeArg(arg string) string {
if containsExpression(arg) {
shellLog.Print("Argument contains GitHub Actions expression, using double-quote wrapping")
escaped := strings.ReplaceAll(arg, `"`, `\"`)
// Escape bare $ signs (those not part of a ${{ }} expression) so that bash
// does not perform variable expansion inside the double-quoted string.
// For example, the JSON key "$schema" must become "\$schema" so bash writes
// the literal dollar sign rather than expanding the (unset) shell variable
// $schema to an empty string. ${{ … }} expressions are left untouched because
// GitHub Actions resolves them before the shell ever runs.
escaped = escapeBareShellDollarSigns(escaped)
return `"` + escaped + `"`
}

Expand All @@ -49,6 +56,38 @@ func shellEscapeArg(arg string) string {
return arg
}

// escapeBareShellDollarSigns replaces every $ that is NOT the start of a ${{ }}
// GitHub Actions expression with \$. This prevents bash from performing variable
// expansion when the string is embedded inside a double-quoted shell argument.
//
// For example, the JSON key "$schema" would be mis-expanded by bash as the (usually
// unset) variable $schema, producing an empty string. Writing \$schema instead causes
// bash to treat the dollar sign as a literal character.
//
// ${{ }} expressions are intentionally left untouched: GitHub Actions resolves them
// at the YAML evaluation layer, before the shell runs, so they must remain verbatim
// in the script text. Any other $ — including $varname, ${varname}, and $0-$9
// positional parameters — is escaped.
func escapeBareShellDollarSigns(s string) string {
var result strings.Builder
result.Grow(len(s))
for i := 0; i < len(s); i++ {
if s[i] != '$' {
result.WriteByte(s[i])
continue
}
// It is a $; check whether it opens a ${{ }} GitHub Actions expression.
if i+2 < len(s) && s[i+1] == '{' && s[i+2] == '{' {
// Start of ${{ }}: leave as-is so GitHub Actions can evaluate it.
result.WriteByte(s[i])
} else {
// Bare $: escape to \$ so bash treats it as a literal dollar sign.
result.WriteString(`\$`)
}
}
return result.String()
}

// buildDockerCommandWithExpandableVars builds a properly quoted docker command
// that allows ${VAR_NAME} variables to be expanded at runtime.
func buildDockerCommandWithExpandableVars(cmd string) string {
Expand Down
87 changes: 87 additions & 0 deletions pkg/workflow/shell_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,25 @@ func TestShellEscapeArg(t *testing.T) {
input: `${{ env.X == "test" && env.Y || env.Z }}`,
expected: `"${{ env.X == \"test\" && env.Y || env.Z }}"`,
},
// --- $schema / bare-dollar escaping in double-quote mode ---
// When a string contains both a GitHub Actions expression and a bare $ (e.g. a
// JSON $schema key), the bare $ must be escaped to \$ so bash does not expand
// it as a variable inside the double-quoted shell argument.
{
name: "JSON with $schema key and GitHub Actions expression escapes bare dollar",
input: `{"$schema":"https://example.com","network":{"allowDomains":["${{ env.DOMAINS }}"]}}`,
expected: `"{\"\$schema\":\"https://example.com\",\"network\":{\"allowDomains\":[\"${{ env.DOMAINS }}\"]}}"`,
},
{
name: "GitHub Actions expression preceded by bare dollar sign escapes the bare dollar",
input: "plain-$var,${{ env.DOMAINS }}",
expected: `"plain-\$var,${{ env.DOMAINS }}"`,
},
{
name: "sole GitHub Actions expression has no bare dollar, no extra escaping needed",
input: "${{ env.DOMAINS }}",
expected: `"${{ env.DOMAINS }}"`,
},
}

for _, tt := range tests {
Expand Down Expand Up @@ -290,3 +309,71 @@ func TestBuildDockerCommandWithExpandableVars_UnbracedVariable(t *testing.T) {
t.Errorf("Unbraced $GITHUB_WORKSPACE should be quoted normally (not preserved for expansion), got %q, expected %q", result, expected)
}
}

func TestEscapeBareShellDollarSigns(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{
name: "empty string",
input: "",
expected: "",
},
{
name: "no dollar signs",
input: "no dollars here",
expected: "no dollars here",
},
{
name: "bare dollar followed by identifier",
input: "$schema",
expected: `\$schema`,
},
{
name: "dollar sign at end of string",
input: "trailing$",
expected: `trailing\$`,
},
{
name: "GitHub Actions expression is preserved",
input: "${{ env.DOMAINS }}",
expected: "${{ env.DOMAINS }}",
},
{
name: "multiple GitHub Actions expressions are preserved",
input: "${{ env.A }},${{ env.B }}",
expected: "${{ env.A }},${{ env.B }}",
},
{
name: "bare dollar mixed with GitHub Actions expression",
input: `$schema and ${{ env.X }}`,
expected: `\$schema and ${{ env.X }}`,
},
{
name: "JSON $schema key alongside expression",
input: `{"$schema":"https://example.com","allowDomains":["${{ env.DOMAINS }}"]}`,
expected: `{"\$schema":"https://example.com","allowDomains":["${{ env.DOMAINS }}"]}`,
},
{
name: "dollar followed by single open brace is escaped",
input: "${HOME}",
expected: `\${HOME}`,
},
{
name: "dollar followed by ${{ is preserved",
input: "${{",
expected: "${{",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := escapeBareShellDollarSigns(tt.input)
if result != tt.expected {
t.Errorf("escapeBareShellDollarSigns(%q) = %q, expected %q", tt.input, result, tt.expected)
}
})
}
}