Skip to content

Compiler JSON-encodes && to \u0026\u0026 inside ${{ }} expressions in AWF config printf, breaking workflow parse #30695

@bryanchen-d

Description

@bryanchen-d

Summary

After the AWF JSON-config migration (#29220), the compiler emits the AWF config as a JSON string inside a printf '%s\n' "<json>" step in the generated .lock.yml. Any & characters in that string get JSON-encoded as \u0026including the && operators inside ${{ … }} GitHub Actions expressions that are interpolated into network.allowDomains. The Actions expression parser does not decode \u0026 and aborts with:

(Line: 867, Col: 14): Unexpected symbol: '\u0026\u0026'.
Located at position 26 within expression: env.MCP_ENV == 'staging' \u0026\u0026 env.MCP_URL_STAGING || env.MCP_URL_PROD

This blocks every workflow that uses a GitHub Actions expression with && (or &) inside network.allowed. We hit this on six errors-* workflows in microsoft/vscode-engineering after gh aw compile produced lock files with v0.25.40.

Repro

Minimal .md:

---
on: workflow_dispatch
permissions: { contents: read }
env:
  MCP_URL_PROD: 'https://prod.example.com/mcp'
  MCP_URL_STAGING: 'https://staging.example.com/mcp'
  MCP_ENV: 'prod'
engine: { id: copilot }
mcp-servers:
  demo:
    url: ${{ env.MCP_ENV == 'staging' && env.MCP_URL_STAGING || env.MCP_URL_PROD }}
network:
  allowed:
    - "prod.example.com"
    - "staging.example.com"
---

# demo

Run:

gh aw compile .github/workflows/demo.md

Inspect the generated .lock.yml — the firewall step contains:

run: |

  printf '%s\n' "{\"$schema\":\"…\",\"network\":{\"allowDomains\":[\"${{ env.MCP_ENV == 'staging' \u0026\u0026 env.MCP_URL_STAGING || env.MCP_URL_PROD }}\", …

The \u0026\u0026 lives inside a ${{ … }} block, so GH Actions expression parser rejects the workflow before any step runs.

Root cause (likely)

In the codepath that builds the AWF config (introduced in #29220), the entire config object is json.Marshal'd (or equivalent), which escapes &\u0026 per Go's default HTMLEscape behavior. ${{ … }} placeholders are interpolated as raw strings before JSON serialization, so the operators inside them get escaped along with everything else.

Suggested fixes (any one works)

  1. Disable HTML escaping when serializing the AWF config string. In Go: use json.Encoder with SetEscapeHTML(false). This is the lowest-risk fix and matches what the Actions runtime / firewall actually consume (raw JSON, not HTML).
  2. Substitute ${{ … }} placeholders after JSON serialization (template the config, then inject expressions). Avoids any future escape-class bugs.
  3. Avoid the dynamic expression inside allowDomains entirely by pre-computing the union of possible hosts at compile time. (Workaround for users today; doesn't fix the underlying bug.)

Workaround

Manually sed -i 's/\\u0026/\&/g' the printf line in each affected .lock.yml. Survives until the next gh aw compile.

Affected versions

Reproduced with gh-aw-firewall schema URL …/v0.25.40/awf-config.schema.json in generated lock files. Likely all gh-aw versions since #29220 landed (closed 2026-04-30).

Impact

All 6 errors-* workflows in microsoft/vscode-engineering that use staging/prod MCP URL switching:

  • errors-triage, errors-stable-scan, errors-fix, errors-regression-scan, errors-dedup-test, errors-investigate

Metadata

Metadata

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions