diff --git a/pkg/jsonutil/json.go b/pkg/jsonutil/json.go new file mode 100644 index 0000000000..5b3397469f --- /dev/null +++ b/pkg/jsonutil/json.go @@ -0,0 +1,20 @@ +package jsonutil + +import ( + "bytes" + "encoding/json" + "strings" +) + +// MarshalCompactNoHTMLEscape marshals a value to compact JSON without HTML escaping. +// It trims the trailing newline emitted by json.Encoder. +func MarshalCompactNoHTMLEscape(v any) (string, error) { + var buf bytes.Buffer + encoder := json.NewEncoder(&buf) + encoder.SetEscapeHTML(false) + if err := encoder.Encode(v); err != nil { + return "", err + } + + return strings.TrimSuffix(buf.String(), "\n"), nil +} diff --git a/pkg/jsonutil/json_test.go b/pkg/jsonutil/json_test.go new file mode 100644 index 0000000000..54f225e798 --- /dev/null +++ b/pkg/jsonutil/json_test.go @@ -0,0 +1,23 @@ +//go:build !integration + +package jsonutil + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMarshalCompactNoHTMLEscape(t *testing.T) { + input := map[string]string{ + "expr": "${{ env.MCP_ENV == 'staging' && env.MCP_URL_STAGING || env.MCP_URL_PROD }}", + } + + result, err := MarshalCompactNoHTMLEscape(input) + require.NoError(t, err, "marshal should succeed") + + assert.Contains(t, result, "&&", "expected expression operators to be preserved") + assert.NotContains(t, result, "\\u0026", "expected '&' to not be HTML-escaped") + assert.NotContains(t, result, "\n", "expected compact JSON without trailing newline") +} diff --git a/pkg/parser/frontmatter_hash.go b/pkg/parser/frontmatter_hash.go index af96c372f8..8bcdf62c4e 100644 --- a/pkg/parser/frontmatter_hash.go +++ b/pkg/parser/frontmatter_hash.go @@ -1,10 +1,8 @@ package parser import ( - "bytes" "crypto/sha256" "encoding/hex" - "encoding/json" "errors" "fmt" "os" @@ -13,6 +11,7 @@ import ( "sort" "strings" + "github.com/github/gh-aw/pkg/jsonutil" "github.com/github/gh-aw/pkg/logger" "github.com/github/gh-aw/pkg/typeutil" ) @@ -38,15 +37,7 @@ var DefaultFileReader FileReader = os.ReadFile // marshalJSONWithoutHTMLEscape marshals a value to JSON without HTML escaping // This matches JavaScript's JSON.stringify behavior func marshalJSONWithoutHTMLEscape(v any) (string, error) { - var buf bytes.Buffer - enc := json.NewEncoder(&buf) - enc.SetEscapeHTML(false) - if err := enc.Encode(v); err != nil { - return "", err - } - // Remove the trailing newline that Encoder adds - result := buf.String() - return strings.TrimSuffix(result, "\n"), nil + return jsonutil.MarshalCompactNoHTMLEscape(v) } // marshalSorted recursively marshals data with sorted keys diff --git a/pkg/workflow/awf_config.go b/pkg/workflow/awf_config.go index 834061795f..b51db5ce34 100644 --- a/pkg/workflow/awf_config.go +++ b/pkg/workflow/awf_config.go @@ -55,6 +55,7 @@ import ( "sync" "github.com/github/gh-aw/pkg/constants" + "github.com/github/gh-aw/pkg/jsonutil" "github.com/github/gh-aw/pkg/logger" "github.com/santhosh-tekuri/jsonschema/v6" ) @@ -283,12 +284,12 @@ func BuildAWFConfigJSON(config AWFCommandConfig) (string, error) { awfConfigLog.Printf("Container section: image_tag=%s", awfImageTag) } - jsonBytes, err := json.Marshal(awfConfig) + jsonStr, err := jsonutil.MarshalCompactNoHTMLEscape(awfConfig) if err != nil { return "", fmt.Errorf("failed to marshal AWF config to JSON: %w", err) } - jsonStr := string(jsonBytes) - awfConfigLog.Printf("AWF config JSON generated: %d bytes", len(jsonBytes)) + + awfConfigLog.Printf("AWF config JSON generated: %d bytes", len(jsonStr)) if err := validateAWFConfigJSON(jsonStr); err != nil { return "", fmt.Errorf("generated AWF config failed schema validation: %w", err) diff --git a/pkg/workflow/awf_config_test.go b/pkg/workflow/awf_config_test.go index 8320208ec8..c27adb1572 100644 --- a/pkg/workflow/awf_config_test.go +++ b/pkg/workflow/awf_config_test.go @@ -195,6 +195,25 @@ func TestBuildAWFConfigJSON(t *testing.T) { assert.NotContains(t, jsonStr, "\n", "JSON output should not contain newlines (must be compact)") assert.NotContains(t, jsonStr, " ", "JSON output should not contain indentation") }) + + t.Run("github actions expressions preserve && operators in allowDomains", func(t *testing.T) { + config := AWFCommandConfig{ + EngineName: "copilot", + AllowedDomains: "${{ env.MCP_ENV == 'staging' && env.MCP_URL_STAGING || env.MCP_URL_PROD }}", + WorkflowData: &WorkflowData{ + EngineConfig: &EngineConfig{ID: "copilot"}, + NetworkPermissions: &NetworkPermissions{ + Firewall: &FirewallConfig{Enabled: true}, + }, + }, + } + + jsonStr, err := BuildAWFConfigJSON(config) + require.NoError(t, err, "BuildAWFConfigJSON should not return an error") + + assert.Contains(t, jsonStr, "&&", "JSON output should preserve && in GitHub Actions expressions") + assert.NotContains(t, jsonStr, "\\u0026", "JSON output should not HTML-escape '&' characters") + }) } // TestBuildAWFConfigSchemaURL verifies that buildAWFConfigSchemaURL returns a release-pinned @@ -416,6 +435,27 @@ func TestBuildAWFCommand_UsesConfigFile(t *testing.T) { assert.Contains(t, command, `"enabled":true`, "config JSON should have apiProxy enabled") } +func TestBuildAWFCommand_PreservesGitHubExpressionOperatorsInConfigJSON(t *testing.T) { + config := AWFCommandConfig{ + EngineName: "copilot", + EngineCommand: "copilot --prompt-file /tmp/prompt.txt", + LogFile: "/tmp/gh-aw/agent-stdio.log", + AllowedDomains: "${{ env.MCP_ENV == 'staging' && env.MCP_URL_STAGING || env.MCP_URL_PROD }}", + WorkflowData: &WorkflowData{ + EngineConfig: &EngineConfig{ID: "copilot"}, + NetworkPermissions: &NetworkPermissions{ + Firewall: &FirewallConfig{Enabled: true}, + }, + }, + } + + command := BuildAWFCommand(config) + + assert.Contains(t, command, "env.MCP_ENV == 'staging'", "expected full GitHub Actions expression to be preserved") + assert.Contains(t, command, "&&", "expected AWF config JSON in command to preserve &&") + assert.NotContains(t, command, "\\u0026", "expected AWF config JSON in command to not HTML-escape '&'") +} + // TestBuildAWFCommand_ConfigFileWithPathSetup verifies that the config file write command // is correctly integrated with the path setup section. func TestBuildAWFCommand_ConfigFileWithPathSetup(t *testing.T) {