diff --git a/.github/extensions/agentic-workflows-dashboard/src/dashboard-cli.ts b/.github/extensions/agentic-workflows-dashboard/src/dashboard-cli.ts index 6c7a0d349ed..e9d5cca3c18 100644 --- a/.github/extensions/agentic-workflows-dashboard/src/dashboard-cli.ts +++ b/.github/extensions/agentic-workflows-dashboard/src/dashboard-cli.ts @@ -157,14 +157,7 @@ async function findDevBinary(cwd: string, accessFn: AccessLike = access, platfor } } -export function createGhAwRunner({ - getWorkspacePath, - accessFn = access, - execFileFn = spawnExecFile, - platform = process.platform, - env = process.env, - resolveBin, -}: RunnerOptions): (args: string[]) => Promise { +export function createGhAwRunner({ getWorkspacePath, accessFn = access, execFileFn = spawnExecFile, platform = process.platform, env = process.env, resolveBin }: RunnerOptions): (args: string[]) => Promise { // Memoize per cwd so findDevBinary is called at most once per workspace path. const binCache = new Map>(); const _resolveBin = diff --git a/.github/workflows/smoke-claude.lock.yml b/.github/workflows/smoke-claude.lock.yml index d4060af51a8..35210c09eac 100644 --- a/.github/workflows/smoke-claude.lock.yml +++ b/.github/workflows/smoke-claude.lock.yml @@ -1,4 +1,4 @@ -# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"c985ca9f0fb4bfa2e3eedadb7345641ff2694aeaa8ff58a660fbbe5cffc5b227","body_hash":"36c065e0560b79a1c971cb1ef686449073e0170c2e67f5e10ecd9ebd481b02fc","strict":true,"agent_id":"claude","engine_versions":{"claude":"2.1.195"}} +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"3bb54a9af96721ea336a4258cc72a97f2cf1d57bfebeaf81badedab154b87260","body_hash":"36c065e0560b79a1c971cb1ef686449073e0170c2e67f5e10ecd9ebd481b02fc","strict":true,"agent_id":"claude","engine_versions":{"claude":"2.1.195"}} # gh-aw-manifest: {"version":1,"secrets":["ANTHROPIC_API_KEY","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GH_AW_OTEL_GRAFANA_AUTHORIZATION","GH_AW_OTEL_GRAFANA_ENDPOINT","GH_AW_OTEL_SENTRY_AUTHORIZATION","GH_AW_OTEL_SENTRY_ENDPOINT","GITHUB_TOKEN","TAVILY_API_KEY"],"actions":[{"repo":"actions/cache/restore","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/cache/save","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/checkout","sha":"9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0","version":"v7.0.0"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-go","sha":"924ae3a1cded613372ab5595356fb5720e22ba16","version":"v6.5.0"},{"repo":"actions/setup-node","sha":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"docker/build-push-action","sha":"f9f3042f7e2789586610d6e8b85c8f03e5195baf","version":"v7.2.0"},{"repo":"docker/setup-buildx-action","sha":"d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5","version":"v4.1.0"},{"repo":"github/codeql-action/upload-sarif","sha":"8aad20d150bbac5944a9f9d289da16a4b0d87c1e","version":"v4.36.2"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.13","digest":"sha256:691a06b64961b5b35aac117eaace202fa721e91da19d1d2e22dcdd6663cd571b","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.13@sha256:691a06b64961b5b35aac117eaace202fa721e91da19d1d2e22dcdd6663cd571b"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.13","digest":"sha256:c57febf4aeeefbb4fd96f5b12c07f4279ca55edca6a700032debf9dd0787286e","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.13@sha256:c57febf4aeeefbb4fd96f5b12c07f4279ca55edca6a700032debf9dd0787286e"},{"image":"ghcr.io/github/gh-aw-firewall/cli-proxy:0.27.13","digest":"sha256:79dfd3a5d139bd1956ba6d7d1782b831a07175cf5afa29c45cb20bb0140f23c5","pinned_image":"ghcr.io/github/gh-aw-firewall/cli-proxy:0.27.13@sha256:79dfd3a5d139bd1956ba6d7d1782b831a07175cf5afa29c45cb20bb0140f23c5"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.13","digest":"sha256:700b1b5a73098373b04fb684f291e95d9be0124ab559717b04f27acaf8b41bed","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.13@sha256:700b1b5a73098373b04fb684f291e95d9be0124ab559717b04f27acaf8b41bed"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.32","digest":"sha256:63e46b56dfd70895a701b6fc6dd0189e11e2d875f327f1781e81b31848735477","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.32@sha256:63e46b56dfd70895a701b6fc6dd0189e11e2d875f327f1781e81b31848735477"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b","pinned_image":"ghcr.io/github/gh-aw-node@sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b"},{"image":"ghcr.io/github/github-mcp-server:v1.5.0","digest":"sha256:e25564dccc9110a70a77b9df560cbde11aa392fcb5f08b9abe5c4ebc6d146ea4","pinned_image":"ghcr.io/github/github-mcp-server:v1.5.0@sha256:e25564dccc9110a70a77b9df560cbde11aa392fcb5f08b9abe5c4ebc6d146ea4"}]} # This file was automatically generated by gh-aw. DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md # @@ -1713,7 +1713,7 @@ jobs: touch /tmp/gh-aw/agent-step-summary.md (umask 177 && touch /tmp/gh-aw/agent-stdio.log) GH_AW_MAX_AI_CREDITS="${{ vars.GH_AW_DEFAULT_MAX_AI_CREDITS || '1000' }}" - printf '%s\n' "{\"\$schema\":\"https://github.com/github/gh-aw-firewall/releases/download/v0.27.13/awf-config.schema.json\",\"network\":{\"allowDomains\":[\"*.githubusercontent.com\",\"*.grafana.net\",\"*.sentry.io\",\"anthropic.com\",\"api.anthropic.com\",\"api.github.com\",\"api.snapcraft.io\",\"archive.ubuntu.com\",\"azure.archive.ubuntu.com\",\"cdn.playwright.dev\",\"codeload.github.com\",\"crl.geotrust.com\",\"crl.globalsign.com\",\"crl.identrust.com\",\"crl.sectigo.com\",\"crl.thawte.com\",\"crl.usertrust.com\",\"crl.verisign.com\",\"crl3.digicert.com\",\"crl4.digicert.com\",\"crls.ssl.com\",\"docs.github.com\",\"files.pythonhosted.org\",\"ghcr.io\",\"github-cloud.githubusercontent.com\",\"github-cloud.s3.amazonaws.com\",\"github.blog\",\"github.com\",\"github.githubassets.com\",\"go.dev\",\"golang.org\",\"goproxy.io\",\"host.docker.internal\",\"json-schema.org\",\"json.schemastore.org\",\"keyserver.ubuntu.com\",\"lfs.github.com\",\"mcp.tavily.com\",\"objects.githubusercontent.com\",\"ocsp.digicert.com\",\"ocsp.geotrust.com\",\"ocsp.globalsign.com\",\"ocsp.identrust.com\",\"ocsp.sectigo.com\",\"ocsp.ssl.com\",\"ocsp.thawte.com\",\"ocsp.usertrust.com\",\"ocsp.verisign.com\",\"packagecloud.io\",\"packages.cloud.google.com\",\"packages.microsoft.com\",\"patch-diff.githubusercontent.com\",\"patchdiff.githubusercontent.com\",\"pkg.go.dev\",\"playwright.download.prss.microsoft.com\",\"ppa.launchpad.net\",\"proxy.golang.org\",\"pypi.org\",\"raw.githubusercontent.com\",\"registry.npmjs.org\",\"s.symcb.com\",\"s.symcd.com\",\"security.ubuntu.com\",\"sentry.io\",\"statsig.anthropic.com\",\"storage.googleapis.com\",\"sum.golang.org\",\"ts-crl.ws.symantec.com\",\"ts-ocsp.ws.symantec.com\",\"www.googleapis.com\"],\"isolation\":true,\"topologyAttach\":[\"awmg-mcpg\",\"awmg-cli-proxy\"]},\"apiProxy\":{\"enabled\":true,\"enableTokenSteering\":true,\"maxRuns\":100,\"maxAiCredits\":${GH_AW_MAX_AI_CREDITS},\"maxCacheMisses\":5,\"models\":{\"agent\":[\"sonnet-6x\",\"gpt-5.5\",\"gpt-5.4\",\"gpt-5.3\",\"gemini-pro\",\"any\"],\"antigravity\":[\"copilot/antigravity*\",\"google/antigravity*\",\"gemini/antigravity*\"],\"any\":[\"copilot/*\",\"anthropic/*\",\"openai/*\",\"google/*\",\"gemini/*\"],\"claude\":[\"agent\"],\"codex\":[\"agent\"],\"coding\":[\"copilot/gpt-5*codex*\",\"openai/gpt-5*codex*\",\"gpt-5-codex\"],\"computer-use\":[\"copilot/*computer-use*\",\"google/*computer-use*\",\"gemini/*computer-use*\",\"openai/*computer-use*\"],\"copilot\":[\"agent\"],\"deep-research\":[\"copilot/deep-research*\",\"copilot/o3-deep-research*\",\"copilot/o4-mini-deep-research*\",\"google/deep-research*\",\"gemini/deep-research*\",\"openai/o3-deep-research*\",\"openai/o4-mini-deep-research*\"],\"gemini\":[\"agent\"],\"gemini-3-flash\":[\"copilot/gemini-3*flash*\",\"google/gemini-3*flash*\",\"gemini/gemini-3*flash*\"],\"gemini-3-pro\":[\"copilot/gemini-3*pro*\",\"google/gemini-3*pro*\",\"google/nano-banana*\",\"gemini/gemini-3*pro*\"],\"gemini-3.1-flash\":[\"copilot/gemini-3.1*flash*\",\"google/gemini-3.1*flash*\",\"gemini/gemini-3.1*flash*\"],\"gemini-3.1-pro\":[\"copilot/gemini-3.1*pro*\",\"google/gemini-3.1*pro*\",\"gemini/gemini-3.1*pro*\"],\"gemini-3.5-flash\":[\"copilot/gemini-3.5*flash*\",\"google/gemini-3.5*flash*\",\"gemini/gemini-3.5*flash*\"],\"gemini-flash\":[\"copilot/gemini-*flash*\",\"google/gemini-*flash*\",\"gemini/gemini-*flash*\"],\"gemini-flash-lite\":[\"copilot/gemini-*flash*lite*\",\"google/gemini-*flash*lite*\",\"gemini/gemini-*flash*lite*\"],\"gemini-pro\":[\"copilot/gemini-*pro*\",\"google/gemini-*pro*\",\"gemini/gemini-*pro*\"],\"gemma\":[\"copilot/gemma*\",\"google/gemma*\",\"gemini/gemma*\"],\"gpt-5\":[\"copilot/gpt-5*\",\"openai/gpt-5*\"],\"gpt-5-codex\":[\"copilot/gpt-5*codex*\",\"openai/gpt-5*codex*\"],\"gpt-5-mini\":[\"copilot/gpt-5*mini*\",\"openai/gpt-5*mini*\"],\"gpt-5-nano\":[\"copilot/gpt-5*nano*\",\"openai/gpt-5*nano*\"],\"gpt-5-pro\":[\"copilot/gpt-5*pro*\",\"openai/gpt-5*pro*\"],\"gpt-5.1\":[\"copilot/gpt-5.1*\",\"openai/gpt-5.1*\"],\"gpt-5.2\":[\"copilot/gpt-5.2*\",\"openai/gpt-5.2*\"],\"gpt-5.3\":[\"copilot/gpt-5.3*\",\"openai/gpt-5.3*\"],\"gpt-5.4\":[\"copilot/gpt-5.4*\",\"openai/gpt-5.4*\"],\"gpt-5.5\":[\"copilot/gpt-5.5*\",\"openai/gpt-5.5*\"],\"haiku\":[\"copilot/*haiku*\",\"anthropic/*haiku*\"],\"image-generation\":[\"copilot/gpt-image*\",\"openai/gpt-image*\",\"openai/chatgpt-image*\",\"copilot/gemini-*image*\",\"google/gemini-*image*\",\"gemini/gemini-*image*\",\"google/imagen*\"],\"large\":[\"sonnet\",\"gpt-5-pro\",\"gpt-5\",\"gemini-pro\"],\"mai-code\":[\"copilot/MAI-Code*\",\"copilot/mai-code*\",\"openai/MAI-Code*\"],\"mini\":[\"haiku\",\"gpt-5-mini\",\"gpt-5-nano\",\"gemini-flash-lite\"],\"nano-banana\":[\"copilot/nano-banana*\",\"google/nano-banana*\",\"gemini/nano-banana*\"],\"opus\":[\"copilot/*opus*\",\"anthropic/*opus*\"],\"opusplan\":[\"opus?effort=high\"],\"reasoning\":[\"copilot/o1*\",\"copilot/o3*\",\"copilot/o4*\",\"openai/o1*\",\"openai/o3*\",\"openai/o4*\"],\"robotics\":[\"copilot/*robotics*\",\"google/*robotics*\",\"gemini/*robotics*\"],\"small\":[\"mini\"],\"small-agent\":[\"haiku\",\"gpt-5-mini\",\"gemini-flash\"],\"sonnet\":[\"copilot/*sonnet*\",\"anthropic/*sonnet*\"],\"sonnet-6x\":[\"copilot/*sonnet-4.5*\",\"copilot/*sonnet-4.6*\",\"copilot/*sonnet-4-5-*\",\"anthropic/*sonnet-4-5-*\",\"copilot/*sonnet-4-6*\",\"anthropic/*sonnet-4-6*\"],\"summarization\":[\"haiku\",\"gpt-5-mini\",\"gemini-flash-lite\",\"mini\"],\"vision\":[\"copilot/gemini-*image*\",\"google/gemini-*image*\",\"gemini/gemini-*image*\",\"copilot/gemini-*flash*\",\"google/gemini-*flash*\",\"gemini/gemini-*flash*\"]}},\"container\":{\"imageTag\":\"0.27.13,squid=sha256:700b1b5a73098373b04fb684f291e95d9be0124ab559717b04f27acaf8b41bed,agent=sha256:691a06b64961b5b35aac117eaace202fa721e91da19d1d2e22dcdd6663cd571b,agent-act=sha256:676de58a0abad8054cd9d4fc45b861715856819f083a268372d60bca0090018c,api-proxy=sha256:c57febf4aeeefbb4fd96f5b12c07f4279ca55edca6a700032debf9dd0787286e,cli-proxy=sha256:79dfd3a5d139bd1956ba6d7d1782b831a07175cf5afa29c45cb20bb0140f23c5\"}}" > "${RUNNER_TEMP}/gh-aw/awf-config.json" + printf '%s\n' "{\"\$schema\":\"https://github.com/github/gh-aw-firewall/releases/download/v0.27.13/awf-config.schema.json\",\"network\":{\"allowDomains\":[\"*.githubusercontent.com\",\"*.grafana.net\",\"*.sentry.io\",\"anthropic.com\",\"api.anthropic.com\",\"api.github.com\",\"api.snapcraft.io\",\"archive.ubuntu.com\",\"azure.archive.ubuntu.com\",\"cdn.playwright.dev\",\"codeload.github.com\",\"crl.geotrust.com\",\"crl.globalsign.com\",\"crl.identrust.com\",\"crl.sectigo.com\",\"crl.thawte.com\",\"crl.usertrust.com\",\"crl.verisign.com\",\"crl3.digicert.com\",\"crl4.digicert.com\",\"crls.ssl.com\",\"docs.github.com\",\"files.pythonhosted.org\",\"ghcr.io\",\"github-cloud.githubusercontent.com\",\"github-cloud.s3.amazonaws.com\",\"github.blog\",\"github.com\",\"github.githubassets.com\",\"go.dev\",\"golang.org\",\"goproxy.io\",\"host.docker.internal\",\"json-schema.org\",\"json.schemastore.org\",\"keyserver.ubuntu.com\",\"lfs.github.com\",\"mcp.tavily.com\",\"objects.githubusercontent.com\",\"ocsp.digicert.com\",\"ocsp.geotrust.com\",\"ocsp.globalsign.com\",\"ocsp.identrust.com\",\"ocsp.sectigo.com\",\"ocsp.ssl.com\",\"ocsp.thawte.com\",\"ocsp.usertrust.com\",\"ocsp.verisign.com\",\"packagecloud.io\",\"packages.cloud.google.com\",\"packages.microsoft.com\",\"patch-diff.githubusercontent.com\",\"patchdiff.githubusercontent.com\",\"pkg.go.dev\",\"playwright.download.prss.microsoft.com\",\"ppa.launchpad.net\",\"proxy.golang.org\",\"pypi.org\",\"raw.githubusercontent.com\",\"registry.npmjs.org\",\"s.symcb.com\",\"s.symcd.com\",\"security.ubuntu.com\",\"sentry.io\",\"statsig.anthropic.com\",\"storage.googleapis.com\",\"sum.golang.org\",\"ts-crl.ws.symantec.com\",\"ts-ocsp.ws.symantec.com\",\"www.googleapis.com\"],\"isolation\":true,\"topologyAttach\":[\"awmg-mcpg\",\"awmg-cli-proxy\"]},\"apiProxy\":{\"enabled\":true,\"enableTokenSteering\":true,\"maxRuns\":100,\"maxAiCredits\":${GH_AW_MAX_AI_CREDITS},\"maxCacheMisses\":5,\"models\":{\"agent\":[\"sonnet-6x\",\"gpt-5.5\",\"gpt-5.4\",\"gpt-5.3\",\"gemini-pro\",\"any\"],\"antigravity\":[\"copilot/antigravity*\",\"google/antigravity*\",\"gemini/antigravity*\"],\"any\":[\"copilot/*\",\"anthropic/*\",\"openai/*\",\"google/*\",\"gemini/*\"],\"claude\":[\"agent\"],\"codex\":[\"agent\"],\"coding\":[\"copilot/gpt-5*codex*\",\"openai/gpt-5*codex*\",\"gpt-5-codex\"],\"computer-use\":[\"copilot/*computer-use*\",\"google/*computer-use*\",\"gemini/*computer-use*\",\"openai/*computer-use*\"],\"copilot\":[\"agent\"],\"deep-research\":[\"copilot/deep-research*\",\"copilot/o3-deep-research*\",\"copilot/o4-mini-deep-research*\",\"google/deep-research*\",\"gemini/deep-research*\",\"openai/o3-deep-research*\",\"openai/o4-mini-deep-research*\"],\"gemini\":[\"agent\"],\"gemini-3-flash\":[\"copilot/gemini-3*flash*\",\"google/gemini-3*flash*\",\"gemini/gemini-3*flash*\"],\"gemini-3-pro\":[\"copilot/gemini-3*pro*\",\"google/gemini-3*pro*\",\"google/nano-banana*\",\"gemini/gemini-3*pro*\"],\"gemini-3.1-flash\":[\"copilot/gemini-3.1*flash*\",\"google/gemini-3.1*flash*\",\"gemini/gemini-3.1*flash*\"],\"gemini-3.1-pro\":[\"copilot/gemini-3.1*pro*\",\"google/gemini-3.1*pro*\",\"gemini/gemini-3.1*pro*\"],\"gemini-3.5-flash\":[\"copilot/gemini-3.5*flash*\",\"google/gemini-3.5*flash*\",\"gemini/gemini-3.5*flash*\"],\"gemini-flash\":[\"copilot/gemini-*flash*\",\"google/gemini-*flash*\",\"gemini/gemini-*flash*\"],\"gemini-flash-lite\":[\"copilot/gemini-*flash*lite*\",\"google/gemini-*flash*lite*\",\"gemini/gemini-*flash*lite*\"],\"gemini-pro\":[\"copilot/gemini-*pro*\",\"google/gemini-*pro*\",\"gemini/gemini-*pro*\"],\"gemma\":[\"copilot/gemma*\",\"google/gemma*\",\"gemini/gemma*\"],\"gpt-5\":[\"copilot/gpt-5*\",\"openai/gpt-5*\"],\"gpt-5-codex\":[\"copilot/gpt-5*codex*\",\"openai/gpt-5*codex*\"],\"gpt-5-mini\":[\"copilot/gpt-5*mini*\",\"openai/gpt-5*mini*\"],\"gpt-5-nano\":[\"copilot/gpt-5*nano*\",\"openai/gpt-5*nano*\"],\"gpt-5-pro\":[\"copilot/gpt-5*pro*\",\"openai/gpt-5*pro*\"],\"gpt-5.1\":[\"copilot/gpt-5.1*\",\"openai/gpt-5.1*\"],\"gpt-5.2\":[\"copilot/gpt-5.2*\",\"openai/gpt-5.2*\"],\"gpt-5.3\":[\"copilot/gpt-5.3*\",\"openai/gpt-5.3*\"],\"gpt-5.4\":[\"copilot/gpt-5.4*\",\"openai/gpt-5.4*\"],\"gpt-5.5\":[\"copilot/gpt-5.5*\",\"openai/gpt-5.5*\"],\"haiku\":[\"copilot/*haiku*\",\"anthropic/*haiku*\"],\"image-generation\":[\"copilot/gpt-image*\",\"openai/gpt-image*\",\"openai/chatgpt-image*\",\"copilot/gemini-*image*\",\"google/gemini-*image*\",\"gemini/gemini-*image*\",\"google/imagen*\"],\"large\":[\"sonnet\",\"gpt-5-pro\",\"gpt-5\",\"gemini-pro\"],\"mai-code\":[\"copilot/MAI-Code*\",\"copilot/mai-code*\",\"openai/MAI-Code*\"],\"mini\":[\"haiku\",\"gpt-5-mini\",\"gpt-5-nano\",\"gemini-flash-lite\"],\"nano-banana\":[\"copilot/nano-banana*\",\"google/nano-banana*\",\"gemini/nano-banana*\"],\"opus\":[\"copilot/*opus*\",\"anthropic/*opus*\"],\"opusplan\":[\"opus?effort=high\"],\"reasoning\":[\"copilot/o1*\",\"copilot/o3*\",\"copilot/o4*\",\"openai/o1*\",\"openai/o3*\",\"openai/o4*\"],\"robotics\":[\"copilot/*robotics*\",\"google/*robotics*\",\"gemini/*robotics*\"],\"small\":[\"mini\"],\"small-agent\":[\"haiku\",\"gpt-5-mini\",\"gemini-flash\"],\"sonnet\":[\"copilot/*sonnet*\",\"anthropic/*sonnet*\"],\"sonnet-6x\":[\"copilot/*sonnet-4.5*\",\"copilot/*sonnet-4.6*\",\"copilot/*sonnet-4-5-*\",\"anthropic/*sonnet-4-5-*\",\"copilot/*sonnet-4-6*\",\"anthropic/*sonnet-4-6*\"],\"summarization\":[\"haiku\",\"gpt-5-mini\",\"gemini-flash-lite\",\"mini\"],\"vision\":[\"copilot/gemini-*image*\",\"google/gemini-*image*\",\"gemini/gemini-*image*\",\"copilot/gemini-*flash*\",\"google/gemini-*flash*\",\"gemini/gemini-*flash*\"]},\"disallowedModels\":[\"*opus*\"]},\"container\":{\"imageTag\":\"0.27.13,squid=sha256:700b1b5a73098373b04fb684f291e95d9be0124ab559717b04f27acaf8b41bed,agent=sha256:691a06b64961b5b35aac117eaace202fa721e91da19d1d2e22dcdd6663cd571b,agent-act=sha256:676de58a0abad8054cd9d4fc45b861715856819f083a268372d60bca0090018c,api-proxy=sha256:c57febf4aeeefbb4fd96f5b12c07f4279ca55edca6a700032debf9dd0787286e,cli-proxy=sha256:79dfd3a5d139bd1956ba6d7d1782b831a07175cf5afa29c45cb20bb0140f23c5\"}}" > "${RUNNER_TEMP}/gh-aw/awf-config.json" cp "${RUNNER_TEMP}/gh-aw/awf-config.json" /tmp/gh-aw/awf-config.json export GH_AW_MODELS_JSON_PATH="/tmp/gh-aw/models.json" GH_AW_DOCKER_HOST="" diff --git a/.github/workflows/smoke-claude.md b/.github/workflows/smoke-claude.md index 343f3b5b87e..aba38480226 100644 --- a/.github/workflows/smoke-claude.md +++ b/.github/workflows/smoke-claude.md @@ -21,6 +21,8 @@ permissions: actions: read name: Smoke Claude +models: + blocked: ["*opus*"] max-turns: 100 engine: id: claude diff --git a/pkg/parser/import_field_extractor.go b/pkg/parser/import_field_extractor.go index f1baa9d5764..4aec7cf66b7 100644 --- a/pkg/parser/import_field_extractor.go +++ b/pkg/parser/import_field_extractor.go @@ -56,6 +56,7 @@ type importAccumulator struct { caches []string features []map[string]any models []map[string][]string // model alias maps from each imported file (appended in import order) + modelPolicies []map[string][]string // model policy sets from each imported file (appended in import order) modelCosts []map[string]any // model pricing overlays from each imported file (appended in import order) runInstallScripts bool // true if any imported workflow sets runtimes.node.run-install-scripts: true agentFile string @@ -89,6 +90,11 @@ type importAccumulator struct { warnings []string } +const ( + modelPolicyAllowedKey = "allowed" + modelPolicyBlockedKey = "blocked" +) + // newImportAccumulator creates and initializes a new importAccumulator. // Maps (botsSet, etc.) are explicitly initialized to prevent nil map panics // during deduplication. Slices are left as nil, which is valid for append operations. @@ -583,7 +589,7 @@ func (acc *importAccumulator) extractFeatureAndObservabilityFields(fm map[string acc.mergeLabels(fm) acc.appendCacheField(fm) acc.appendFeaturesField(fm) - acc.appendModelsField(fm) + acc.appendModelsField(fm, fullPath) acc.extractRunInstallScripts(fm, fullPath) acc.appendObservabilityField(fm, fullPath) } @@ -612,48 +618,149 @@ func (acc *importAccumulator) appendFeaturesField(fm map[string]any) { } } -func (acc *importAccumulator) appendModelsField(fm map[string]any) { +func (acc *importAccumulator) appendModelsField(fm map[string]any, importPath string) { modelsContent, err := extractFieldJSONFromMap(fm, "models", "{}") if err != nil || modelsContent == "" || modelsContent == "{}" { return } var rawModels map[string]any if jsonErr := json.Unmarshal([]byte(modelsContent), &rawModels); jsonErr != nil { + acc.warnings = append(acc.warnings, fmt.Sprintf("import %q: models field is not a valid object; skipping invalid value", importPath)) return } - if _, hasProviders := rawModels["providers"]; hasProviders { - acc.modelCosts = append(acc.modelCosts, rawModels) - if providers, ok := rawModels["providers"].(map[string]any); ok { - parserLog.Printf("Extracted model costs from import: providers=%d", len(providers)) - } else { - parserLog.Printf("Extracted model costs from import") + if modelPolicy := normalizeModelPolicies(rawModels, importPath, &acc.warnings); len(modelPolicy) > 0 { + acc.modelPolicies = append(acc.modelPolicies, modelPolicy) + parserLog.Printf("Extracted model policy from import: allowed=%d, blocked=%d", len(modelPolicy["allowed"]), len(modelPolicy["blocked"])) + } + if providers, hasProviders := rawModels["providers"]; hasProviders { + if providerMap, ok := sanitizeModelProvidersForCosts(providers, importPath, &acc.warnings); ok { + acc.modelCosts = append(acc.modelCosts, map[string]any{"providers": providerMap}) + parserLog.Printf("Extracted model costs from import: providers=%d", len(providerMap)) } - return } - modelsMap := normalizeModelAliases(rawModels) + aliasModels := make(map[string]any, len(rawModels)) + for key, value := range rawModels { + // providers is reserved for model-cost overlays and should not be treated + // as an alias key, even when aliases and providers coexist. + if key == "providers" || isModelPolicyKey(key) { + continue + } + aliasModels[key] = value + } + if len(aliasModels) == 0 { + return + } + modelsMap := normalizeModelAliases(aliasModels) if len(modelsMap) > 0 { acc.models = append(acc.models, modelsMap) parserLog.Printf("Extracted model aliases from import: %d entries", len(modelsMap)) } } +func normalizeModelPolicies(rawModels map[string]any, importPath string, warnings *[]string) map[string][]string { + parse := func(key string) []string { + value, exists := rawModels[key] + if !exists { + return nil + } + return parseModelPolicyField(value, key, importPath, warnings) + } + allowed := parse(modelPolicyAllowedKey) + blocked := parse(modelPolicyBlockedKey) + if len(allowed) == 0 && len(blocked) == 0 { + return nil + } + return map[string][]string{ + modelPolicyAllowedKey: allowed, + modelPolicyBlockedKey: blocked, + } +} + func normalizeModelAliases(rawModels map[string]any) map[string][]string { modelsMap := make(map[string][]string, len(rawModels)) for k, v := range rawModels { - patterns, ok := v.([]any) + strs := parseStringSliceField(v, true) + if len(strs) == 0 { + continue + } + modelsMap[k] = strs + } + return modelsMap +} + +// parseModelPolicyField parses one imported models policy field as a string list. +// Invalid field shapes or entries are ignored and appended to warnings. +func parseModelPolicyField(value any, fieldName, importPath string, warnings *[]string) []string { + values, ok := value.([]any) + if !ok { + *warnings = append(*warnings, fmt.Sprintf("import %q: models.%s must be an array; skipping invalid value", importPath, fieldName)) + return nil + } + result := make([]string, 0, len(values)) + for _, v := range values { + s, ok := v.(string) if !ok { + *warnings = append(*warnings, fmt.Sprintf("import %q: models.%s contains a non-string entry; skipping invalid entry", importPath, fieldName)) + continue + } + if s == "" { + *warnings = append(*warnings, fmt.Sprintf("import %q: models.%s contains an empty string entry; skipping invalid entry", importPath, fieldName)) continue } - strs := make([]string, 0, len(patterns)) - for _, p := range patterns { - if s, ok := p.(string); ok { - strs = append(strs, s) + result = append(result, s) + } + if len(result) == 0 { + return nil + } + return result +} + +// sanitizeModelProvidersForCosts validates models.providers from an import. +// It returns the provider map and true when the input is a non-empty object; otherwise false. +func sanitizeModelProvidersForCosts(providers any, importPath string, warnings *[]string) (map[string]any, bool) { + providerMap, ok := providers.(map[string]any) + if !ok || len(providerMap) == 0 { + *warnings = append(*warnings, fmt.Sprintf("import %q: models.providers must be a non-empty object; skipping invalid value", importPath)) + return nil, false + } + sanitizedProviders := make(map[string]any, len(providerMap)) + for providerName, providerValue := range providerMap { + if isModelPolicyKey(providerName) || providerName == "blocked" { + *warnings = append(*warnings, fmt.Sprintf("import %q: models.providers.%s is reserved for policy and ignored in cost data", importPath, providerName)) + continue + } + sanitizedProviders[providerName] = providerValue + } + if len(sanitizedProviders) == 0 { + *warnings = append(*warnings, fmt.Sprintf("import %q: models.providers must contain at least one non-policy provider key", importPath)) + return nil, false + } + return sanitizedProviders, true +} + +func parseStringSliceField(value any, keepEmpty bool) []string { + values, ok := value.([]any) + if !ok { + return nil + } + result := make([]string, 0, len(values)) + for _, v := range values { + if s, ok := v.(string); ok { + if s == "" && !keepEmpty { + continue } + result = append(result, s) } - modelsMap[k] = strs } - return modelsMap + if len(result) == 0 { + return nil + } + return result +} + +func isModelPolicyKey(key string) bool { + return key == modelPolicyAllowedKey || key == modelPolicyBlockedKey } func (acc *importAccumulator) extractRunInstallScripts(fm map[string]any, fullPath string) { @@ -737,6 +844,7 @@ func (acc *importAccumulator) toImportsResult(topologicalOrder []string) *Import MergedEnvSources: acc.envSources, MergedFeatures: acc.features, MergedModels: acc.models, + MergedModelPolicies: acc.modelPolicies, MergedModelCosts: acc.modelCosts, MergedObservability: mergeObservabilityConfigs(acc.observabilityConfigs), ImportedFiles: topologicalOrder, diff --git a/pkg/parser/import_field_extractor_test.go b/pkg/parser/import_field_extractor_test.go index 97d6d4d2818..324970ceec5 100644 --- a/pkg/parser/import_field_extractor_test.go +++ b/pkg/parser/import_field_extractor_test.go @@ -699,3 +699,136 @@ func TestExtractConfigFields_FirstWinsAndAccumulates(t *testing.T) { assert.Contains(t, acc.secretMaskingBuilder.String(), "enabled") assert.Contains(t, acc.secretMaskingBuilder.String(), "log-mask") } + +func TestAppendModelsField_ExtractsModelPolicySets(t *testing.T) { + acc := newImportAccumulator() + fm := map[string]any{ + "models": map[string]any{ + "allowed": []any{"gpt-5", "claude-sonnet"}, + "blocked": []any{"gpt-5-pro"}, + }, + } + + acc.appendModelsField(fm, "import-a.md") + + require.Len(t, acc.modelPolicies, 1, "expected one model policy set") + assert.Equal(t, []string{"gpt-5", "claude-sonnet"}, acc.modelPolicies[0]["allowed"]) + assert.Equal(t, []string{"gpt-5-pro"}, acc.modelPolicies[0]["blocked"]) + assert.Empty(t, acc.models, "policy fields should not be interpreted as model aliases") +} + +func TestAppendModelsField_ExtractsModelCostsAndPolicyTogether(t *testing.T) { + acc := newImportAccumulator() + fm := map[string]any{ + "models": map[string]any{ + "allowed": []any{"gpt-5-mini"}, + "providers": map[string]any{ + "openai": map[string]any{ + "models": map[string]any{ + "gpt-5-mini": map[string]any{ + "cost": map[string]any{"input": "1e-6"}, + }, + }, + }, + }, + }, + } + + acc.appendModelsField(fm, "import-b.md") + + require.Len(t, acc.modelCosts, 1, "expected one model cost overlay") + require.Len(t, acc.modelPolicies, 1, "expected one model policy set") + assert.Equal(t, []string{"gpt-5-mini"}, acc.modelPolicies[0]["allowed"]) + assert.Contains(t, acc.modelCosts[0], "providers") + assert.Len(t, acc.modelCosts[0], 1) + for _, key := range []string{"allowed", "blocked"} { + _, present := acc.modelCosts[0][key] + assert.Falsef(t, present, "model cost overlay should not contain policy key %q", key) + } +} + +func TestAppendModelsField_InvalidPolicyAndProviders_EmitsWarningsAndSkipsCosts(t *testing.T) { + acc := newImportAccumulator() + fm := map[string]any{ + "models": map[string]any{ + "allowed": "gpt-5", + "providers": "not-an-object", + }, + } + + acc.appendModelsField(fm, "import-c.md") + + assert.Empty(t, acc.modelPolicies) + assert.Empty(t, acc.modelCosts) + require.NotEmpty(t, acc.warnings) + warningsText := strings.Join(acc.warnings, "\n") + assert.Contains(t, warningsText, "models.allowed") + assert.Contains(t, warningsText, "models.providers") +} + +func TestAppendModelsField_InvalidModelsShape_EmitsWarning(t *testing.T) { + acc := newImportAccumulator() + fm := map[string]any{ + "models": []any{"not-an-object"}, + } + + acc.appendModelsField(fm, "import-d.md") + + assert.Empty(t, acc.modelPolicies) + assert.Empty(t, acc.modelCosts) + require.NotEmpty(t, acc.warnings) + assert.Contains(t, strings.Join(acc.warnings, "\n"), "models field is not a valid object") +} + +func TestAppendModelsField_ProvidersPolicyKeysAreExcludedFromModelCosts(t *testing.T) { + acc := newImportAccumulator() + fm := map[string]any{ + "models": map[string]any{ + "providers": map[string]any{ + "allowed": []any{"gpt-5"}, + "openai": map[string]any{ + "models": map[string]any{ + "gpt-5": map[string]any{ + "cost": map[string]any{"input": "1e-6"}, + }, + }, + }, + }, + }, + } + + acc.appendModelsField(fm, "import-e.md") + + require.Len(t, acc.modelCosts, 1) + providers, ok := acc.modelCosts[0]["providers"].(map[string]any) + require.True(t, ok) + assert.Contains(t, providers, "openai") + assert.NotContains(t, providers, "allowed") + assert.NotContains(t, providers, "blocked") + require.NotEmpty(t, acc.warnings) + assert.Contains(t, strings.Join(acc.warnings, "\n"), "models.providers.allowed is reserved for policy") +} + +func TestAppendModelsField_ProvidersAndAliasesBothExtracted(t *testing.T) { + acc := newImportAccumulator() + fm := map[string]any{ + "models": map[string]any{ + "providers": map[string]any{ + "openai": map[string]any{ + "models": map[string]any{ + "gpt-5": map[string]any{ + "cost": map[string]any{"input": "1e-6"}, + }, + }, + }, + }, + "agent": []any{"gpt-5"}, + }, + } + + acc.appendModelsField(fm, "import-f.md") + + require.Len(t, acc.modelCosts, 1) + require.Len(t, acc.models, 1) + assert.Equal(t, []string{"gpt-5"}, acc.models[0]["agent"]) +} diff --git a/pkg/parser/import_processor.go b/pkg/parser/import_processor.go index 18a3c94a3be..c5b5949db00 100644 --- a/pkg/parser/import_processor.go +++ b/pkg/parser/import_processor.go @@ -59,6 +59,7 @@ type ImportsResult struct { MergedEnvSources map[string]string // env var name → source import path (for conflict detection and lock file header listing) MergedFeatures []map[string]any // Merged features configuration from all imports (parsed YAML structures) MergedModels []map[string][]string // Merged model alias definitions from all imports (first import to define a key wins among imports) + MergedModelPolicies []map[string][]string // Merged model policy sets from all imports (models.allowed/blocked) MergedModelCosts []map[string]any // Merged model pricing overlays (models.json provider structure) from all imports MergedObservability string // Merged observability config (JSON) from all imports as an endpoint array (deduped by URL) MergedEngineMCPToolTimeout string // First engine.mcp.tool-timeout found across all imports (Go duration string, e.g. "10m") diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 9a4f6080fb2..2af2df9fe24 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -2761,10 +2761,23 @@ ] }, "models": { - "description": "Custom model pricing data in the same structure as models.json. Merged with the built-in models.json at runtime; frontmatter entries override matching models and fill gaps for unknown models. Useful for custom or private models, or to adjust pricing for AI Credits cost accounting.", + "description": "Model policy and optional pricing configuration. The policy fields (allowed/blocked) are experimental and merged as unions across imports. The providers field is optional and supplies pricing data merged by provider/model key.", "type": "object", - "required": ["providers"], "properties": { + "allowed": { + "type": "array", + "description": "Experimental allowlist of model names/patterns. Mapped to AWF apiProxy.allowedModels.", + "items": { + "type": "string" + } + }, + "blocked": { + "type": "array", + "description": "Experimental denylist of model names/patterns. Mapped to AWF apiProxy.disallowedModels.", + "items": { + "type": "string" + } + }, "providers": { "type": "object", "description": "Provider-keyed map of model pricing data.", diff --git a/pkg/workflow/awf_config.go b/pkg/workflow/awf_config.go index ab97441fef4..e70f761233f 100644 --- a/pkg/workflow/awf_config.go +++ b/pkg/workflow/awf_config.go @@ -71,6 +71,7 @@ import ( "github.com/github/gh-aw/pkg/jsonutil" "github.com/github/gh-aw/pkg/logger" "github.com/github/gh-aw/pkg/setutil" + "github.com/github/gh-aw/pkg/workflow/compilerenv" ) //go:embed schemas/awf-config.schema.json @@ -229,6 +230,11 @@ type AWFAPIProxyConfig struct { // AWF resolves aliases recursively; loops are not permitted. // Per the AWF config schema, this lives under apiProxy.models. Models map[string][]string `json:"models,omitempty"` + + // AllowedModels is the explicit allowlist policy for model names/patterns. + AllowedModels []string `json:"allowedModels,omitempty"` + // DisallowedModels is the explicit denylist policy for model names/patterns. + DisallowedModels []string `json:"disallowedModels,omitempty"` } // AWFModelFallbackConfig is the "apiProxy.modelFallback" section of the AWF config file. @@ -479,6 +485,15 @@ func BuildAWFConfigJSON(config AWFCommandConfig) (string, error) { apiProxy.Models = config.WorkflowData.ModelMappings awfConfigLog.Printf("Models section: %d alias entries", len(config.WorkflowData.ModelMappings)) } + allowedModels, disallowedModels := resolveModelPolicyForAWFConfig(config.WorkflowData) + if len(allowedModels) > 0 { + apiProxy.AllowedModels = allowedModels + awfConfigLog.Printf("Models policy: %d allowed model pattern(s)", len(allowedModels)) + } + if len(disallowedModels) > 0 { + apiProxy.DisallowedModels = disallowedModels + awfConfigLog.Printf("Models policy: %d disallowed model pattern(s)", len(disallowedModels)) + } awfConfig.APIProxy = apiProxy @@ -537,6 +552,73 @@ func splitDomainList(domains string) []string { return result } +// resolveModelPolicyForAWFConfig applies policy precedence independently per list: +// allowed rules are narrowed using intersection with env policy, while blocked +// rules are widened using union with env policy. +func resolveModelPolicyForAWFConfig(workflowData *WorkflowData) ([]string, []string) { + envAllowed, hasAllowedOverride := compilerenv.ResolvePolicyModelsAllowed() + envBlocked, hasBlockedOverride := compilerenv.ResolvePolicyModelsBlocked() + var allowed []string + var blocked []string + if workflowData != nil { + allowed = workflowData.ModelPolicyAllowed + blocked = workflowData.ModelPolicyBlocked + } + if hasAllowedOverride { + allowed = intersectModelPolicyRules(allowed, envAllowed) + } + if hasBlockedOverride { + blocked = unionModelPolicyRules(blocked, envBlocked) + } + blockedSet := make(map[string]struct{}, len(blocked)) + for _, model := range blocked { + blockedSet[model] = struct{}{} + } + allowed = filterAllowedModelConflictsWithSet(allowed, blockedSet) + return allowed, blocked +} + +func intersectModelPolicyRules(local, override []string) []string { + if len(override) == 0 { + return append([]string(nil), local...) + } + // No local allow-list means no workflow restriction; keep the env allow-list. + if len(local) == 0 { + return append([]string(nil), override...) + } + localSet := make(map[string]struct{}, len(local)) + for _, model := range local { + localSet[model] = struct{}{} + } + result := make([]string, 0, len(override)) + for _, model := range override { + if _, ok := localSet[model]; ok { + result = append(result, model) + } + } + return result +} + +func unionModelPolicyRules(local, override []string) []string { + result := make([]string, 0, len(local)+len(override)) + seen := make(map[string]struct{}, len(local)+len(override)) + for _, model := range local { + if _, ok := seen[model]; ok { + continue + } + seen[model] = struct{}{} + result = append(result, model) + } + for _, model := range override { + if _, ok := seen[model]; ok { + continue + } + seen[model] = struct{}{} + result = append(result, model) + } + return result +} + func extractModelMultipliers(workflowData *WorkflowData) map[string]float64 { if workflowData == nil || workflowData.EngineConfig == nil || workflowData.EngineConfig.TokenWeights == nil { return nil diff --git a/pkg/workflow/awf_config_test.go b/pkg/workflow/awf_config_test.go index 9db4f12bb22..ce7e7eea03e 100644 --- a/pkg/workflow/awf_config_test.go +++ b/pkg/workflow/awf_config_test.go @@ -1619,3 +1619,157 @@ func TestBuildAWFTopologyAttachList(t *testing.T) { assert.Equal(t, []string{"awmg-mcpg", "awmg-cli-proxy"}, targets) }) } + +func TestBuildAWFConfigJSON_EmitsModelPolicyFromWorkflowData(t *testing.T) { + config := AWFCommandConfig{ + EngineName: "copilot", + AllowedDomains: "github.com", + WorkflowData: &WorkflowData{ + EngineConfig: &EngineConfig{ID: "copilot"}, + NetworkPermissions: &NetworkPermissions{ + Firewall: &FirewallConfig{Enabled: true}, + }, + ModelPolicyAllowed: []string{"gpt-5", "claude-sonnet"}, + ModelPolicyBlocked: []string{"gpt-5-pro", "claude-opus"}, + }, + } + + jsonStr, err := BuildAWFConfigJSON(config) + require.NoError(t, err) + assert.Contains(t, jsonStr, `"allowedModels":["gpt-5","claude-sonnet"]`) + assert.Contains(t, jsonStr, `"disallowedModels":["gpt-5-pro","claude-opus"]`) +} + +func TestBuildAWFConfigJSON_ModelPolicyEnvOverridePrecedence(t *testing.T) { + t.Setenv(compilerenv.PolicyModelsAllowed, "gemini-pro,gpt-5-mini") + t.Setenv(compilerenv.PolicyModelsBlocked, "claude-opus, gpt-5-pro") + + config := AWFCommandConfig{ + EngineName: "copilot", + AllowedDomains: "github.com", + WorkflowData: &WorkflowData{ + EngineConfig: &EngineConfig{ID: "copilot"}, + NetworkPermissions: &NetworkPermissions{ + Firewall: &FirewallConfig{Enabled: true}, + }, + ModelPolicyAllowed: []string{"frontmatter-allowed", "gpt-5-mini"}, + ModelPolicyBlocked: []string{"frontmatter-blocked"}, + }, + } + + jsonStr, err := BuildAWFConfigJSON(config) + require.NoError(t, err) + assert.Contains(t, jsonStr, `"allowedModels":["gpt-5-mini"]`) + assert.Contains(t, jsonStr, `"disallowedModels":["frontmatter-blocked","claude-opus","gpt-5-pro"]`) + assert.NotContains(t, jsonStr, "frontmatter-allowed") + assert.NotContains(t, jsonStr, "gemini-pro") +} + +func TestBuildAWFConfigJSON_ModelPolicyEnvOverride_IsPerList(t *testing.T) { + t.Setenv(compilerenv.PolicyModelsAllowed, "gemini-pro") + t.Setenv(compilerenv.PolicyModelsBlocked, "") + + config := AWFCommandConfig{ + EngineName: "copilot", + AllowedDomains: "github.com", + WorkflowData: &WorkflowData{ + EngineConfig: &EngineConfig{ID: "copilot"}, + NetworkPermissions: &NetworkPermissions{ + Firewall: &FirewallConfig{Enabled: true}, + }, + ModelPolicyAllowed: []string{"frontmatter-allowed", "gemini-pro"}, + ModelPolicyBlocked: []string{"frontmatter-disallowed"}, + }, + } + + jsonStr, err := BuildAWFConfigJSON(config) + require.NoError(t, err) + assert.Contains(t, jsonStr, `"allowedModels":["gemini-pro"]`) + assert.Contains(t, jsonStr, `"disallowedModels":["frontmatter-disallowed"]`) +} + +func TestBuildAWFConfigJSON_ModelPolicyEnvBlockedUnionOnly(t *testing.T) { + t.Setenv(compilerenv.PolicyModelsAllowed, "") + t.Setenv(compilerenv.PolicyModelsBlocked, "claude-opus") + + config := AWFCommandConfig{ + EngineName: "copilot", + AllowedDomains: "github.com", + WorkflowData: &WorkflowData{ + EngineConfig: &EngineConfig{ID: "copilot"}, + NetworkPermissions: &NetworkPermissions{ + Firewall: &FirewallConfig{Enabled: true}, + }, + ModelPolicyAllowed: []string{"frontmatter-allowed"}, + ModelPolicyBlocked: []string{"frontmatter-blocked"}, + }, + } + + jsonStr, err := BuildAWFConfigJSON(config) + require.NoError(t, err) + assert.Contains(t, jsonStr, `"allowedModels":["frontmatter-allowed"]`) + assert.Contains(t, jsonStr, `"disallowedModels":["frontmatter-blocked","claude-opus"]`) +} + +func TestBuildAWFConfigJSON_ModelPolicyEnvAllowedIntersectionCanBeEmpty(t *testing.T) { + t.Setenv(compilerenv.PolicyModelsAllowed, "gemini-pro") + t.Setenv(compilerenv.PolicyModelsBlocked, "") + + config := AWFCommandConfig{ + EngineName: "copilot", + AllowedDomains: "github.com", + WorkflowData: &WorkflowData{ + EngineConfig: &EngineConfig{ID: "copilot"}, + NetworkPermissions: &NetworkPermissions{ + Firewall: &FirewallConfig{Enabled: true}, + }, + ModelPolicyAllowed: []string{"frontmatter-allowed"}, + ModelPolicyBlocked: []string{"frontmatter-blocked"}, + }, + } + + jsonStr, err := BuildAWFConfigJSON(config) + require.NoError(t, err) + var parsed map[string]any + require.NoError(t, json.Unmarshal([]byte(jsonStr), &parsed)) + apiProxy, ok := parsed["apiProxy"].(map[string]any) + require.True(t, ok) + _, hasAllowedModels := apiProxy["allowedModels"] + assert.False(t, hasAllowedModels) + assert.Contains(t, jsonStr, `"disallowedModels":["frontmatter-blocked"]`) +} + +func TestIntersectModelPolicyRules_EmptyOverrideKeepsLocal(t *testing.T) { + got := intersectModelPolicyRules([]string{"gpt-5"}, nil) + assert.Equal(t, []string{"gpt-5"}, got) +} + +func TestIntersectModelPolicyRules_EmptyLocalUsesOverride(t *testing.T) { + got := intersectModelPolicyRules(nil, []string{"gpt-5"}) + assert.Equal(t, []string{"gpt-5"}, got) +} + +func TestIntersectModelPolicyRules_OverlapOnly(t *testing.T) { + got := intersectModelPolicyRules([]string{"gpt-5", "claude-sonnet"}, []string{"gemini-pro", "gpt-5"}) + assert.Equal(t, []string{"gpt-5"}, got) +} + +func TestBuildAWFConfigJSON_ModelPolicyConflictDisallowedWins(t *testing.T) { + config := AWFCommandConfig{ + EngineName: "copilot", + AllowedDomains: "github.com", + WorkflowData: &WorkflowData{ + EngineConfig: &EngineConfig{ID: "copilot"}, + NetworkPermissions: &NetworkPermissions{ + Firewall: &FirewallConfig{Enabled: true}, + }, + ModelPolicyAllowed: []string{"gpt-5", "claude-sonnet"}, + ModelPolicyBlocked: []string{"gpt-5"}, + }, + } + + jsonStr, err := BuildAWFConfigJSON(config) + require.NoError(t, err) + assert.Contains(t, jsonStr, `"allowedModels":["claude-sonnet"]`) + assert.Contains(t, jsonStr, `"disallowedModels":["gpt-5"]`) +} diff --git a/pkg/workflow/compiler_types.go b/pkg/workflow/compiler_types.go index 7e085984236..8d0662887ac 100644 --- a/pkg/workflow/compiler_types.go +++ b/pkg/workflow/compiler_types.go @@ -610,6 +610,8 @@ type WorkflowData struct { KnownActionCredentialEnvVars map[string]struct{} // env vars for clean_known_action_credentials.sh; keyed by GH_AW_CLEAN_* names; nil when no known credential-leaking actions are detected ModelMappings map[string][]string // merged model alias map (builtins + imported workflow aliases + main frontmatter overrides, in priority order); NOT yet emitted to AWF config JSON — pending AWF firewall support (config.models) ModelCosts map[string]any // model pricing data from frontmatter `models` field (providers structure); merged with built-in models.json at runtime by generate_aw_info.cjs + ModelPolicyAllowed []string // merged models.allowed policy list (union across imports + main frontmatter) + ModelPolicyBlocked []string // merged models.blocked policy list (union across imports + main frontmatter) ActionPinMappings map[string]string // action-pin redirect table from aw.json action_pins: maps "owner/repo@version" → "owner/repo@version" } diff --git a/pkg/workflow/compilerenv/manager.go b/pkg/workflow/compilerenv/manager.go index bf1da22975f..0525182080a 100644 --- a/pkg/workflow/compilerenv/manager.go +++ b/pkg/workflow/compilerenv/manager.go @@ -50,6 +50,14 @@ const ( // PolicyStrict enables runtime enforcement that workflows must be compiled in strict mode // when GH_AW_POLICY_STRICT is set to the string value "true". PolicyStrict = "GH_AW_POLICY_STRICT" + // PolicyModelsAllowed applies experimental models.allowed policy from env. + // It intersects with workflow models.allowed and does not change + // models.blocked unless PolicyModelsBlocked is also set. + PolicyModelsAllowed = "GHAW_POLICY_MODELS_ALLOWED" + // PolicyModelsBlocked applies experimental models.blocked policy from env. + // It unions with workflow models.blocked and does not change models.allowed + // unless PolicyModelsAllowed is also set. + PolicyModelsBlocked = "GHAW_POLICY_MODELS_BLOCKED" // PolicyAllowCreatePullRequest controls whether create-pull-request safe-outputs // remain runtime-compliant. Set to the string value "false" to disable the // create_pull_request safe-output tool at runtime. @@ -165,3 +173,50 @@ func BuildModelOverrideExpression(primaryVar, enterpriseDefaultVar, builtinFallb func BuildModelOverrideExpressionEmptyFallback(primaryVar, enterpriseDefaultVar string) string { return fmt.Sprintf("${{ vars.%s || vars.%s || '' }}", primaryVar, enterpriseDefaultVar) } + +// ResolvePolicyModelsAllowed returns configured allowed model policy entries. +// When the env var is unset/empty, ok=false and callers should use frontmatter policy. +func ResolvePolicyModelsAllowed() ([]string, bool) { + return resolveModelListEnv(PolicyModelsAllowed) +} + +// ResolvePolicyModelsBlocked returns configured blocked model policy entries. +// When the env var is unset/empty, ok=false and callers should use frontmatter policy. +func ResolvePolicyModelsBlocked() ([]string, bool) { + return resolveModelListEnv(PolicyModelsBlocked) +} + +func resolveModelListEnv(name string) ([]string, bool) { + raw := strings.TrimSpace(os.Getenv(name)) + if raw == "" { + return nil, false + } + parts := strings.FieldsFunc(raw, func(r rune) bool { + return r == ',' || r == '\n' || r == '\r' + }) + if len(parts) == 0 { + return nil, false + } + result := make([]string, 0, len(parts)) + seen := map[string]struct{}{} + for _, part := range parts { + model := strings.TrimSpace(part) + if model == "" { + continue + } + if strings.ContainsAny(model, " \t") { + managerLog.Printf("Skipping invalid model policy entry in %s: %q (use comma/newline separators)", name, model) + continue + } + if _, exists := seen[model]; exists { + continue + } + seen[model] = struct{}{} + result = append(result, model) + } + if len(result) == 0 { + return nil, false + } + managerLog.Printf("Applying model policy override %s with %d model(s)", name, len(result)) + return result, true +} diff --git a/pkg/workflow/compilerenv/manager_test.go b/pkg/workflow/compilerenv/manager_test.go index cea6733c18b..9517e04d1b4 100644 --- a/pkg/workflow/compilerenv/manager_test.go +++ b/pkg/workflow/compilerenv/manager_test.go @@ -150,3 +150,42 @@ func TestResolveDefaultUTC(t *testing.T) { assert.Equal(t, "-08:00", ResolveDefaultUTC("+00:00")) }) } + +func TestResolvePolicyModelsAllowed(t *testing.T) { + t.Run("unset returns no override", func(t *testing.T) { + t.Setenv(PolicyModelsAllowed, "") + got, ok := ResolvePolicyModelsAllowed() + assert.False(t, ok) + assert.Nil(t, got) + }) + + t.Run("comma-separated list is parsed", func(t *testing.T) { + t.Setenv(PolicyModelsAllowed, "gpt-5, claude-sonnet, gpt-5") + got, ok := ResolvePolicyModelsAllowed() + assert.True(t, ok) + assert.Equal(t, []string{"gpt-5", "claude-sonnet"}, got) + }) + + t.Run("space-separated list is rejected", func(t *testing.T) { + t.Setenv(PolicyModelsAllowed, "gpt-5 claude-sonnet") + got, ok := ResolvePolicyModelsAllowed() + assert.False(t, ok) + assert.Nil(t, got) + }) +} + +func TestResolvePolicyModelsBlocked(t *testing.T) { + t.Run("unset returns no override", func(t *testing.T) { + t.Setenv(PolicyModelsBlocked, "") + got, ok := ResolvePolicyModelsBlocked() + assert.False(t, ok) + assert.Nil(t, got) + }) + + t.Run("comma/newline-separated list is parsed", func(t *testing.T) { + t.Setenv(PolicyModelsBlocked, "gpt-5-pro,\nclaude-opus") + got, ok := ResolvePolicyModelsBlocked() + assert.True(t, ok) + assert.Equal(t, []string{"gpt-5-pro", "claude-opus"}, got) + }) +} diff --git a/pkg/workflow/frontmatter_parsing.go b/pkg/workflow/frontmatter_parsing.go index cb32f6762fe..1047d64bca9 100644 --- a/pkg/workflow/frontmatter_parsing.go +++ b/pkg/workflow/frontmatter_parsing.go @@ -90,10 +90,47 @@ func ParseFrontmatterConfig(frontmatter map[string]any) (*FrontmatterConfig, err // legacy bare-array form and the new object form are available as ExperimentConfig // structs without callers needing to type-assert config.Experiments entries. config.ExperimentConfigs = extractExperimentConfigsFromFrontmatter(frontmatter) + config.ModelPolicyAllowed, config.ModelPolicyBlocked = extractModelPolicyFromFrontmatter(frontmatter) frontmatterTypesLog.Printf("Successfully parsed frontmatter config: name=%s, engine=%v", config.Name, config.Engine) return &config, nil } + +func extractModelPolicyFromFrontmatter(frontmatter map[string]any) ([]string, []string) { + modelsRaw, ok := frontmatter["models"].(map[string]any) + if !ok { + return nil, nil + } + return parseModelPolicyList(modelsRaw["allowed"]), parseModelPolicyList(modelsRaw["blocked"]) +} + +func parseModelPolicyList(value any) []string { + values, ok := value.([]any) + if !ok { + if value != nil { + frontmatterTypesLog.Printf("Skipping model policy list: expected array, got %T", value) + } + return nil + } + result := make([]string, 0, len(values)) + for _, v := range values { + s, ok := v.(string) + if !ok { + frontmatterTypesLog.Printf("Skipping model policy entry: expected string, got %T", v) + continue + } + if s == "" { + frontmatterTypesLog.Printf("Skipping model policy entry: empty string") + continue + } + result = append(result, s) + } + if len(result) == 0 { + return nil + } + return result +} + func parseOnNeedsConfig(on map[string]any) ([]string, error) { return parseOnNeedsValues(on) } diff --git a/pkg/workflow/frontmatter_types.go b/pkg/workflow/frontmatter_types.go index 975fa3d7fff..9253db9120d 100644 --- a/pkg/workflow/frontmatter_types.go +++ b/pkg/workflow/frontmatter_types.go @@ -377,6 +377,10 @@ type FrontmatterConfig struct { // so that custom or adjusted cost values are reflected in effective-token accounting. // Structure: {"providers": {"": {"models": {"": {"cost": {...}}}}}} ModelCosts map[string]any `json:"models,omitempty"` + // ModelPolicyAllowed is the experimental frontmatter models.allowed (allowlist), merged as a union across imports. + ModelPolicyAllowed []string `json:"-"` + // ModelPolicyBlocked is the experimental frontmatter models.blocked (denylist), merged as a union across imports. + ModelPolicyBlocked []string `json:"-"` // Rate limiting configuration RateLimit *RateLimitConfig `json:"user-rate-limit,omitempty"` diff --git a/pkg/workflow/model_aliases_test.go b/pkg/workflow/model_aliases_test.go index 64f0886ea87..b32b9be7f51 100644 --- a/pkg/workflow/model_aliases_test.go +++ b/pkg/workflow/model_aliases_test.go @@ -331,4 +331,36 @@ func TestFrontmatterModelsField(t *testing.T) { require.True(t, ok, "ModelCosts should contain a providers key") assert.Contains(t, providers, "anthropic", "providers should contain anthropic") }) + + t.Run("models policy fields populate parsed model policy lists", func(t *testing.T) { + frontmatter := map[string]any{ + "name": "test-workflow", + "models": map[string]any{ + "allowed": []any{"gpt-5", "claude-sonnet"}, + "blocked": []any{"gpt-5-pro"}, + }, + } + + config, err := ParseFrontmatterConfig(frontmatter) + require.NoError(t, err, "ParseFrontmatterConfig should succeed with model policy fields") + require.NotNil(t, config, "parsed config should not be nil") + assert.Equal(t, []string{"gpt-5", "claude-sonnet"}, config.ModelPolicyAllowed) + assert.Equal(t, []string{"gpt-5-pro"}, config.ModelPolicyBlocked) + }) + + t.Run("models policy fields ignore invalid entries but keep valid strings", func(t *testing.T) { + frontmatter := map[string]any{ + "name": "test-workflow", + "models": map[string]any{ + "allowed": []any{"gpt-5", 123, ""}, + "blocked": []any{"claude-opus", false}, + }, + } + + config, err := ParseFrontmatterConfig(frontmatter) + require.NoError(t, err, "ParseFrontmatterConfig should succeed with mixed policy entries") + require.NotNil(t, config, "parsed config should not be nil") + assert.Equal(t, []string{"gpt-5"}, config.ModelPolicyAllowed) + assert.Equal(t, []string{"claude-opus"}, config.ModelPolicyBlocked) + }) } diff --git a/pkg/workflow/workflow_builder.go b/pkg/workflow/workflow_builder.go index 8bd374e877e..fb68fe6e1bb 100644 --- a/pkg/workflow/workflow_builder.go +++ b/pkg/workflow/workflow_builder.go @@ -4,6 +4,8 @@ import ( "encoding/json" "fmt" "maps" + "regexp" + "sort" "strings" "github.com/github/gh-aw/pkg/logger" @@ -152,6 +154,14 @@ func (c *Compiler) buildInitialWorkflowData( if len(mergedModelCosts) > 0 { workflowData.ModelCosts = mergedModelCosts } + mainModelPolicy := extractMainModelPolicyOverlay(toolsResult, result.Frontmatter) + allowedModels, disallowedModels := mergeModelPolicyOverlays(importsResult.MergedModelPolicies, mainModelPolicy) + if len(allowedModels) > 0 { + workflowData.ModelPolicyAllowed = allowedModels + } + if len(disallowedModels) > 0 { + workflowData.ModelPolicyBlocked = disallowedModels + } return workflowData } @@ -188,7 +198,12 @@ func extractMainModelCostsOverlay(toolsResult *toolsProcessingResult, frontmatte // Fall back to raw frontmatter when ParseFrontmatterConfig failed (e.g. due to unrecognized // tool config shapes like bash: ["*"]). if toolsResult.parsedFrontmatter != nil && len(toolsResult.parsedFrontmatter.ModelCosts) > 0 { - return toolsResult.parsedFrontmatter.ModelCosts + if providers, hasProviders := toolsResult.parsedFrontmatter.ModelCosts["providers"]; hasProviders { + if providersMap, ok := providers.(map[string]any); ok && len(providersMap) > 0 { + return map[string]any{"providers": providersMap} + } + } + return nil } rawModels, ok := frontmatter["models"] @@ -199,10 +214,15 @@ func extractMainModelCostsOverlay(toolsResult *toolsProcessingResult, frontmatte if !ok { return nil } - if _, hasProviders := modelsMap["providers"]; !hasProviders { + providers, hasProviders := modelsMap["providers"] + if !hasProviders { + return nil + } + providersMap, ok := providers.(map[string]any) + if !ok || len(providersMap) == 0 { return nil } - return modelsMap + return map[string]any{"providers": providersMap} } func mergeModelCostOverlays(importedOverlays []map[string]any, mainOverlay map[string]any) map[string]any { @@ -276,6 +296,116 @@ func mergeModelCostOverlayPair(base, overlay map[string]any) map[string]any { return result } +// extractMainModelPolicyOverlay returns only models.allowed/blocked policy +// entries and never treats providers data as policy. +func extractMainModelPolicyOverlay(toolsResult *toolsProcessingResult, frontmatter map[string]any) map[string][]string { + if toolsResult.parsedFrontmatter != nil { + mainPolicy := map[string][]string{ + "allowed": toolsResult.parsedFrontmatter.ModelPolicyAllowed, + "blocked": toolsResult.parsedFrontmatter.ModelPolicyBlocked, + } + if len(mainPolicy["allowed"]) > 0 || len(mainPolicy["blocked"]) > 0 { + return mainPolicy + } + } + modelsMap, ok := frontmatter["models"].(map[string]any) + if !ok { + return nil + } + mainPolicy := map[string][]string{ + "allowed": parseModelPolicyList(modelsMap["allowed"]), + "blocked": parseModelPolicyList(modelsMap["blocked"]), + } + if len(mainPolicy["allowed"]) == 0 && len(mainPolicy["blocked"]) == 0 { + return nil + } + return mainPolicy +} + +func mergeModelPolicyOverlays(importedPolicies []map[string][]string, mainPolicy map[string][]string) ([]string, []string) { + overlays := make([]map[string][]string, 0, len(importedPolicies)+1) + overlays = append(overlays, importedPolicies...) + if len(mainPolicy) > 0 { + overlays = append(overlays, mainPolicy) + } + if len(overlays) == 0 { + return nil, nil + } + + allowedSet := map[string]struct{}{} + disallowedSet := map[string]struct{}{} + for _, overlay := range overlays { + for _, model := range overlay["allowed"] { + if model != "" { + allowedSet[model] = struct{}{} + } + } + for _, model := range overlay["blocked"] { + if model != "" { + disallowedSet[model] = struct{}{} + } + } + } + + allowedModels := make([]string, 0, len(allowedSet)) + for model := range allowedSet { + allowedModels = append(allowedModels, model) + } + disallowedModels := make([]string, 0, len(disallowedSet)) + for model := range disallowedSet { + disallowedModels = append(disallowedModels, model) + } + allowedModels = filterAllowedModelConflictsWithSet(allowedModels, disallowedSet) + sort.Strings(allowedModels) + sort.Strings(disallowedModels) + return allowedModels, disallowedModels +} + +func filterAllowedModelConflictsWithSet(allowed []string, disallowedSet map[string]struct{}) []string { + if len(allowed) == 0 || len(disallowedSet) == 0 { + return allowed + } + filtered := make([]string, 0, len(allowed)) + for _, model := range allowed { + if modelConflictsWithDisallowedPolicy(model, disallowedSet) { + continue + } + filtered = append(filtered, model) + } + return filtered +} + +func modelConflictsWithDisallowedPolicy(model string, disallowedSet map[string]struct{}) bool { + for disallowed := range disallowedSet { + if disallowed == model { + return true + } + if modelPolicyPatternMatches(disallowed, model) { + return true + } + // Also check the inverse direction so an allowed wildcard pattern (for example + // "*opus*") conflicts with a disallowed exact entry ("claude-opus"). + if modelPolicyPatternMatches(model, disallowed) { + return true + } + } + return false +} + +func modelPolicyPatternMatches(pattern, value string) bool { + if pattern == value { + return true + } + if !strings.ContainsAny(pattern, "*?") { + return false + } + re := "^" + regexp.QuoteMeta(pattern) + "$" + re = strings.ReplaceAll(re, `\*`, ".*") + re = strings.ReplaceAll(re, `\?`, ".") + matched, err := regexp.MatchString(re, value) + return err == nil && matched +} + // resolveInlinedImports returns true if inlined-imports is enabled. // It reads the value directly from the raw (pre-parsed) frontmatter map, which is always // populated regardless of whether ParseFrontmatterConfig succeeded. diff --git a/pkg/workflow/workflow_builder_model_policy_test.go b/pkg/workflow/workflow_builder_model_policy_test.go new file mode 100644 index 00000000000..7ef8bc96064 --- /dev/null +++ b/pkg/workflow/workflow_builder_model_policy_test.go @@ -0,0 +1,132 @@ +//go:build !integration + +package workflow + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMergeModelPolicyOverlays_UnionizesAllowedAndBlocked(t *testing.T) { + imported := []map[string][]string{ + { + "allowed": {"gpt-5", "claude-sonnet"}, + "blocked": {"gpt-5-pro"}, + }, + { + "allowed": {"gpt-5-mini"}, + "blocked": {"claude-opus"}, + }, + } + main := map[string][]string{ + "allowed": {"gpt-5"}, + "blocked": {"gemini-pro"}, + } + + allowed, disallowed := mergeModelPolicyOverlays(imported, main) + assert.Equal(t, []string{"claude-sonnet", "gpt-5", "gpt-5-mini"}, allowed) + assert.Equal(t, []string{"claude-opus", "gemini-pro", "gpt-5-pro"}, disallowed) +} + +func TestMergeModelPolicyOverlays_BlockedWinsOnConflict(t *testing.T) { + imported := []map[string][]string{ + { + "allowed": {"gpt-5"}, + "blocked": {"gpt-5"}, + }, + } + allowed, disallowed := mergeModelPolicyOverlays(imported, nil) + assert.Empty(t, allowed) + assert.Equal(t, []string{"gpt-5"}, disallowed) +} + +func TestMergeModelPolicyOverlays_BlockedWildcardWinsOnConflict(t *testing.T) { + imported := []map[string][]string{ + { + "allowed": {"claude-opus", "claude-sonnet"}, + "blocked": {"*opus*"}, + }, + } + allowed, disallowed := mergeModelPolicyOverlays(imported, nil) + assert.Equal(t, []string{"claude-sonnet"}, allowed) + assert.Equal(t, []string{"*opus*"}, disallowed) +} + +func TestMergeModelPolicyOverlays_AllowedWildcardConflictsWithBlockedExact(t *testing.T) { + imported := []map[string][]string{ + { + "allowed": {"*opus*"}, + "blocked": {"claude-opus"}, + }, + } + allowed, disallowed := mergeModelPolicyOverlays(imported, nil) + assert.Empty(t, allowed) + assert.Equal(t, []string{"claude-opus"}, disallowed) +} + +func TestExtractMainModelPolicyOverlay_UsesParsedFrontmatterWhenPresent(t *testing.T) { + toolsResult := &toolsProcessingResult{ + parsedFrontmatter: &FrontmatterConfig{ + ModelPolicyAllowed: []string{"gpt-5"}, + ModelPolicyBlocked: []string{"gpt-5-pro"}, + }, + } + + policy := extractMainModelPolicyOverlay(toolsResult, map[string]any{}) + require.NotNil(t, policy) + assert.Equal(t, []string{"gpt-5"}, policy["allowed"]) + assert.Equal(t, []string{"gpt-5-pro"}, policy["blocked"]) +} + +func TestExtractMainModelPolicyOverlay_FallsBackToRawFrontmatter(t *testing.T) { + toolsResult := &toolsProcessingResult{} + frontmatter := map[string]any{ + "models": map[string]any{ + "allowed": []any{"gpt-5-mini"}, + "blocked": []any{"claude-opus"}, + }, + } + + policy := extractMainModelPolicyOverlay(toolsResult, frontmatter) + require.NotNil(t, policy) + assert.Equal(t, []string{"gpt-5-mini"}, policy["allowed"]) + assert.Equal(t, []string{"claude-opus"}, policy["blocked"]) +} + +func TestExtractMainModelCostsOverlay_ExtractsNilWhenModelCostsHasOnlyPolicyKeys(t *testing.T) { + toolsResult := &toolsProcessingResult{ + parsedFrontmatter: &FrontmatterConfig{ + ModelCosts: map[string]any{ + "allowed": []any{"gpt-5"}, + }, + }, + } + + costs := extractMainModelCostsOverlay(toolsResult, map[string]any{}) + assert.Nil(t, costs) +} + +func TestExtractMainModelCostsOverlay_ExtractsOnlyProvidersAndExcludesPolicyKeys(t *testing.T) { + toolsResult := &toolsProcessingResult{} + frontmatter := map[string]any{ + "models": map[string]any{ + "allowed": []any{"gpt-5"}, + "providers": map[string]any{ + "openai": map[string]any{ + "models": map[string]any{ + "gpt-5": map[string]any{ + "cost": map[string]any{"input": "1e-6"}, + }, + }, + }, + }, + }, + } + + costs := extractMainModelCostsOverlay(toolsResult, frontmatter) + require.NotNil(t, costs) + assert.Contains(t, costs, "providers") + assert.NotContains(t, costs, "allowed") +}