diff --git a/.changeset/patch-protocol-specific-domain-filtering.md b/.changeset/patch-protocol-specific-domain-filtering.md new file mode 100644 index 0000000000..971b579e70 --- /dev/null +++ b/.changeset/patch-protocol-specific-domain-filtering.md @@ -0,0 +1,13 @@ +--- +"gh-aw": patch +--- + +Support protocol-specific domain filtering for `network.allowed` entries. + +This change adds validation and compiler integration so `http://` and +`https://` prefixes (including wildcards) are accepted for protocol-specific +domain restrictions. It also preserves protocol prefixes through compilation, +adds unit and integration tests, and updates the documentation. + +Fixes githubnext/gh-aw#9040 + diff --git a/.changeset/patch-support-protocol-specific-domain-filtering.md b/.changeset/patch-support-protocol-specific-domain-filtering.md new file mode 100644 index 0000000000..ae6201442d --- /dev/null +++ b/.changeset/patch-support-protocol-specific-domain-filtering.md @@ -0,0 +1,8 @@ +--- +"gh-aw": patch +--- + +Support protocol-specific domain filtering for `network.allowed` entries: validation and compiler integration for `http://` and `https://` prefixes, tests, and documentation updates. + +Fixes githubnext/gh-aw#9040 + diff --git a/.github/workflows/smoke-codex-firewall.lock.yml b/.github/workflows/smoke-codex-firewall.lock.yml index 26fa15ba32..baa8baef54 100644 --- a/.github/workflows/smoke-codex-firewall.lock.yml +++ b/.github/workflows/smoke-codex-firewall.lock.yml @@ -519,7 +519,7 @@ jobs: event_name: context.eventName, staged: false, network_mode: "defaults", - allowed_domains: ["defaults","github"], + allowed_domains: ["defaults","github","https://api.github.com"], firewall_enabled: true, awf_version: "v0.8.2", steps: { @@ -564,6 +564,7 @@ jobs: 3. **File Writing Testing**: Create a test file `/tmp/gh-aw/agent/smoke-test-codex-firewall-__GH_AW_GITHUB_RUN_ID__.txt` with content "Firewall smoke test passed for Codex at $(date)" 4. **Bash Tool Testing**: Execute bash commands to verify file creation was successful (use `cat` to read the file back) 5. **Blocked Domain Testing**: Attempt to access a domain NOT in the allowed list (e.g., example.com) using curl - this should fail or be blocked + 6. **Protocol Filtering Testing**: Verify that the AWF command includes the protocol-specific domain `https://api.github.com` in the --allow-domains flag. Check logs to confirm HTTPS prefix is preserved ## Output @@ -716,7 +717,7 @@ jobs: set -o pipefail INSTRUCTION="$(cat "$GH_AW_PROMPT")" mkdir -p "$CODEX_HOME/logs" - sudo -E awf --env-all --container-workdir "${GITHUB_WORKSPACE}" --mount /tmp:/tmp:rw --mount "${GITHUB_WORKSPACE}:${GITHUB_WORKSPACE}:rw" --mount /opt/hostedtoolcache/node:/opt/hostedtoolcache/node:ro --allow-domains '*.githubusercontent.com,api.openai.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,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,github-cloud.githubusercontent.com,github-cloud.s3.amazonaws.com,github.githubassets.com,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,lfs.github.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,openai.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,raw.githubusercontent.com,s.symcb.com,s.symcd.com,security.ubuntu.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com' --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --image-tag 0.8.2 \ + sudo -E awf --env-all --container-workdir "${GITHUB_WORKSPACE}" --mount /tmp:/tmp:rw --mount "${GITHUB_WORKSPACE}:${GITHUB_WORKSPACE}:rw" --mount /opt/hostedtoolcache/node:/opt/hostedtoolcache/node:ro --allow-domains '*.githubusercontent.com,api.openai.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,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,github-cloud.githubusercontent.com,github-cloud.s3.amazonaws.com,github.githubassets.com,https://api.github.com,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,lfs.github.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,openai.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,raw.githubusercontent.com,s.symcb.com,s.symcd.com,security.ubuntu.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com' --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --image-tag 0.8.2 \ -- NODE_BIN_PATH="$(find /opt/hostedtoolcache/node -maxdepth 1 -type d | head -1 | xargs basename)/x64/bin" && export PATH="/opt/hostedtoolcache/node/$NODE_BIN_PATH:$PATH" && codex ${GH_AW_MODEL_AGENT_CODEX:+-c model="$GH_AW_MODEL_AGENT_CODEX" }exec --full-auto --skip-git-repo-check "$INSTRUCTION" \ 2>&1 | tee /tmp/gh-aw/agent-stdio.log env: @@ -759,7 +760,7 @@ jobs: uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 env: GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }} - GH_AW_ALLOWED_DOMAINS: "*.githubusercontent.com,api.openai.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,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,github-cloud.githubusercontent.com,github-cloud.s3.amazonaws.com,github.githubassets.com,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,lfs.github.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,openai.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,raw.githubusercontent.com,s.symcb.com,s.symcd.com,security.ubuntu.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com" + GH_AW_ALLOWED_DOMAINS: "*.githubusercontent.com,api.openai.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,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,github-cloud.githubusercontent.com,github-cloud.s3.amazonaws.com,github.githubassets.com,https://api.github.com,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,lfs.github.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,openai.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,raw.githubusercontent.com,s.symcb.com,s.symcd.com,security.ubuntu.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com" GITHUB_SERVER_URL: ${{ github.server_url }} GITHUB_API_URL: ${{ github.api_url }} with: diff --git a/.github/workflows/smoke-codex-firewall.md b/.github/workflows/smoke-codex-firewall.md index 8f900923c0..ce46b6f422 100644 --- a/.github/workflows/smoke-codex-firewall.md +++ b/.github/workflows/smoke-codex-firewall.md @@ -18,6 +18,7 @@ network: allowed: - defaults - github + - "https://api.github.com" # Test HTTPS-only protocol filtering safe-outputs: add-comment: hide-older-comments: true @@ -51,6 +52,7 @@ This workflow validates that the Codex engine works correctly with AWF (Applicat 3. **File Writing Testing**: Create a test file `/tmp/gh-aw/agent/smoke-test-codex-firewall-${{ github.run_id }}.txt` with content "Firewall smoke test passed for Codex at $(date)" 4. **Bash Tool Testing**: Execute bash commands to verify file creation was successful (use `cat` to read the file back) 5. **Blocked Domain Testing**: Attempt to access a domain NOT in the allowed list (e.g., example.com) using curl - this should fail or be blocked +6. **Protocol Filtering Testing**: Verify that the AWF command includes the protocol-specific domain `https://api.github.com` in the --allow-domains flag. Check logs to confirm HTTPS prefix is preserved ## Output diff --git a/.github/workflows/smoke-copilot.lock.yml b/.github/workflows/smoke-copilot.lock.yml index 3f5ed93ed3..e17b7df3b0 100644 --- a/.github/workflows/smoke-copilot.lock.yml +++ b/.github/workflows/smoke-copilot.lock.yml @@ -529,7 +529,7 @@ jobs: event_name: context.eventName, staged: false, network_mode: "defaults", - allowed_domains: ["defaults","node","github"], + allowed_domains: ["defaults","node","github","https://api.github.com","http://httpbin.org"], firewall_enabled: true, awf_version: "v0.8.2", steps: { @@ -573,7 +573,8 @@ jobs: 4. **GitHub MCP Default Toolset Testing**: Verify that the `get_me` tool is NOT available with default toolsets. Try to use it and confirm it fails with a tool not found error. 5. **Cache Memory Testing**: Write a test file to `/tmp/gh-aw/cache-memory/smoke-test-__GH_AW_GITHUB_RUN_ID__.txt` with content "Cache memory test for run __GH_AW_GITHUB_RUN_ID__" and verify it was created successfully 6. **Web Fetch Testing**: Use the web_fetch tool to fetch content from https://api.github.com/repos/githubnext/gh-aw (verify the tool is available and returns valid JSON) - 7. **Available Tools Display**: List all available tools that you have access to in this workflow execution. + 7. **Protocol Filtering Testing**: Verify that the AWF command includes protocol-specific domains in the --allow-domains flag. Check `/tmp/gh-aw/agent-stdio.log` for entries like `https://api.github.com` and `http://httpbin.org` to confirm protocol prefixes are preserved + 8. **Available Tools Display**: List all available tools that you have access to in this workflow execution. ## Output @@ -752,7 +753,7 @@ jobs: timeout-minutes: 5 run: | set -o pipefail - sudo -E awf --env-all --container-workdir "${GITHUB_WORKSPACE}" --mount /tmp:/tmp:rw --mount "${GITHUB_WORKSPACE}:${GITHUB_WORKSPACE}:rw" --mount /usr/bin/date:/usr/bin/date:ro --mount /usr/bin/gh:/usr/bin/gh:ro --mount /usr/bin/yq:/usr/bin/yq:ro --mount /usr/local/bin/copilot:/usr/local/bin/copilot:ro --mount /home/runner/.copilot:/home/runner/.copilot:rw --allow-domains '*.githubusercontent.com,api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.npms.io,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,bun.sh,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,deb.nodesource.com,deno.land,get.pnpm.io,github-cloud.githubusercontent.com,github-cloud.s3.amazonaws.com,github.com,github.githubassets.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,lfs.github.com,nodejs.org,npm.pkg.github.com,npmjs.com,npmjs.org,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,ppa.launchpad.net,raw.githubusercontent.com,registry.bower.io,registry.npmjs.com,registry.npmjs.org,registry.yarnpkg.com,repo.yarnpkg.com,s.symcb.com,s.symcd.com,security.ubuntu.com,skimdb.npmjs.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.npmjs.com,www.npmjs.org,yarnpkg.com' --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --image-tag 0.8.2 \ + sudo -E awf --env-all --container-workdir "${GITHUB_WORKSPACE}" --mount /tmp:/tmp:rw --mount "${GITHUB_WORKSPACE}:${GITHUB_WORKSPACE}:rw" --mount /usr/bin/date:/usr/bin/date:ro --mount /usr/bin/gh:/usr/bin/gh:ro --mount /usr/bin/yq:/usr/bin/yq:ro --mount /usr/local/bin/copilot:/usr/local/bin/copilot:ro --mount /home/runner/.copilot:/home/runner/.copilot:rw --allow-domains '*.githubusercontent.com,api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.npms.io,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,bun.sh,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,deb.nodesource.com,deno.land,get.pnpm.io,github-cloud.githubusercontent.com,github-cloud.s3.amazonaws.com,github.com,github.githubassets.com,host.docker.internal,http://httpbin.org,https://api.github.com,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,lfs.github.com,nodejs.org,npm.pkg.github.com,npmjs.com,npmjs.org,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,ppa.launchpad.net,raw.githubusercontent.com,registry.bower.io,registry.npmjs.com,registry.npmjs.org,registry.yarnpkg.com,repo.yarnpkg.com,s.symcb.com,s.symcd.com,security.ubuntu.com,skimdb.npmjs.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.npmjs.com,www.npmjs.org,yarnpkg.com' --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --image-tag 0.8.2 \ -- /usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --add-dir "${GITHUB_WORKSPACE}" --disable-builtin-mcps --allow-all-tools --add-dir /tmp/gh-aw/cache-memory/ --allow-all-paths --share /tmp/gh-aw/sandbox/agent/logs/conversation.md --prompt "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"${GH_AW_MODEL_AGENT_COPILOT:+ --model "$GH_AW_MODEL_AGENT_COPILOT"} \ 2>&1 | tee /tmp/gh-aw/agent-stdio.log env: @@ -795,7 +796,7 @@ jobs: uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 env: GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }} - GH_AW_ALLOWED_DOMAINS: "*.githubusercontent.com,api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.npms.io,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,bun.sh,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,deb.nodesource.com,deno.land,get.pnpm.io,github-cloud.githubusercontent.com,github-cloud.s3.amazonaws.com,github.com,github.githubassets.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,lfs.github.com,nodejs.org,npm.pkg.github.com,npmjs.com,npmjs.org,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,ppa.launchpad.net,raw.githubusercontent.com,registry.bower.io,registry.npmjs.com,registry.npmjs.org,registry.yarnpkg.com,repo.yarnpkg.com,s.symcb.com,s.symcd.com,security.ubuntu.com,skimdb.npmjs.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.npmjs.com,www.npmjs.org,yarnpkg.com" + GH_AW_ALLOWED_DOMAINS: "*.githubusercontent.com,api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.npms.io,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,bun.sh,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,deb.nodesource.com,deno.land,get.pnpm.io,github-cloud.githubusercontent.com,github-cloud.s3.amazonaws.com,github.com,github.githubassets.com,host.docker.internal,http://httpbin.org,https://api.github.com,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,lfs.github.com,nodejs.org,npm.pkg.github.com,npmjs.com,npmjs.org,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,ppa.launchpad.net,raw.githubusercontent.com,registry.bower.io,registry.npmjs.com,registry.npmjs.org,registry.yarnpkg.com,repo.yarnpkg.com,s.symcb.com,s.symcd.com,security.ubuntu.com,skimdb.npmjs.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.npmjs.com,www.npmjs.org,yarnpkg.com" GITHUB_SERVER_URL: ${{ github.server_url }} GITHUB_API_URL: ${{ github.api_url }} with: diff --git a/.github/workflows/smoke-copilot.md b/.github/workflows/smoke-copilot.md index 7943ad7b7c..ce32f2abf0 100644 --- a/.github/workflows/smoke-copilot.md +++ b/.github/workflows/smoke-copilot.md @@ -18,6 +18,8 @@ network: - defaults - node - github + - "https://api.github.com" # Test HTTPS-only protocol filtering + - "http://httpbin.org" # Test HTTP-only protocol filtering sandbox: agent: awf # Firewall enabled tools: @@ -55,7 +57,8 @@ strict: true 4. **GitHub MCP Default Toolset Testing**: Verify that the `get_me` tool is NOT available with default toolsets. Try to use it and confirm it fails with a tool not found error. 5. **Cache Memory Testing**: Write a test file to `/tmp/gh-aw/cache-memory/smoke-test-${{ github.run_id }}.txt` with content "Cache memory test for run ${{ github.run_id }}" and verify it was created successfully 6. **Web Fetch Testing**: Use the web_fetch tool to fetch content from https://api.github.com/repos/githubnext/gh-aw (verify the tool is available and returns valid JSON) -7. **Available Tools Display**: List all available tools that you have access to in this workflow execution. +7. **Protocol Filtering Testing**: Verify that the AWF command includes protocol-specific domains in the --allow-domains flag. Check `/tmp/gh-aw/agent-stdio.log` for entries like `https://api.github.com` and `http://httpbin.org` to confirm protocol prefixes are preserved +8. **Available Tools Display**: List all available tools that you have access to in this workflow execution. ## Output diff --git a/docs/src/content/docs/guides/network-configuration.md b/docs/src/content/docs/guides/network-configuration.md index 4952a62377..478b27d1b3 100644 --- a/docs/src/content/docs/guides/network-configuration.md +++ b/docs/src/content/docs/guides/network-configuration.md @@ -79,6 +79,31 @@ network: - "*.cdn.example.com" # Wildcard for subdomains ``` +## Protocol-Specific Filtering + +Restrict domains to specific protocols for enhanced security (Copilot engine with AWF firewall): + +```yaml +engine: copilot +network: + allowed: + - defaults + - "https://secure.api.example.com" # HTTPS-only + - "http://legacy.internal.com" # HTTP-only (legacy systems) + - "example.org" # Both protocols (default) +sandbox: + agent: awf # Firewall enabled +``` + +**Use Cases:** +- **HTTPS-only**: External APIs, production services +- **HTTP-only**: Legacy internal systems, development endpoints +- **Mixed**: Gradual HTTP → HTTPS migration + +**Validation:** Invalid protocols (e.g., `ftp://`) are rejected at compile time. + +See [Network Permissions - Protocol-Specific Filtering](/gh-aw/reference/network/#protocol-specific-domain-filtering) for complete details. + ## Security Best Practices 1. **Start minimal** - Only add ecosystems you actually use diff --git a/docs/src/content/docs/reference/network.md b/docs/src/content/docs/reference/network.md index 37613e2393..87c5eea707 100644 --- a/docs/src/content/docs/reference/network.md +++ b/docs/src/content/docs/reference/network.md @@ -37,6 +37,13 @@ network: - "api.example.com" # Exact domain - "trusted.com" # Includes all *.trusted.com subdomains +# Protocol-specific domain filtering (Copilot engine only) +network: + allowed: + - "https://secure.api.example.com" # HTTPS-only access + - "http://legacy.example.com" # HTTP-only access + - "example.org" # Both HTTP and HTTPS (default) + # No network access network: {} @@ -117,6 +124,55 @@ Network permissions follow the principle of least privilege with four access lev AWF does not support wildcard syntax like `*.example.com`. Instead, listing a domain automatically includes all its subdomains. Use `example.com` to allow access to `example.com`, `api.example.com`, `sub.api.example.com`, etc. ::: +## Protocol-Specific Domain Filtering + +For fine-grained security control, you can restrict domains to specific protocols (HTTP or HTTPS only). This is particularly useful when: +- Working with legacy systems that only support HTTP +- Ensuring secure connections by restricting to HTTPS-only +- Migrating from HTTP to HTTPS gradually + +:::tip[Copilot Engine Support] +Protocol-specific filtering is currently supported by the Copilot engine with AWF firewall enabled. Domains without protocol prefixes allow both HTTP and HTTPS traffic (backward compatible). +::: + +### Usage Examples + +```yaml wrap +engine: copilot +network: + allowed: + - "https://secure.api.example.com" # HTTPS-only access + - "http://legacy.example.com" # HTTP-only access + - "example.org" # Both protocols (default) + - "https://*.api.example.com" # HTTPS wildcard +``` + +**Compiled to AWF:** +```bash +--allow-domains ...,example.org,http://legacy.example.com,https://secure.api.example.com,... +``` + +### Supported Protocols + +- `https://` - HTTPS-only access +- `http://` - HTTP-only access +- No prefix - Both HTTP and HTTPS (backward compatible) + +:::caution[Protocol Validation] +Invalid protocols (e.g., `ftp://`, `ws://`) are rejected at compile time with a clear error message: +``` +error: network.allowed[0]: domain pattern 'ftp://invalid.example.com' +has invalid protocol, only 'http://' and 'https://' are allowed +``` +::: + +### Best Practices + +- **Prefer HTTPS**: Use `https://` prefix for all external APIs and services +- **Legacy Systems**: Only use `http://` for internal or legacy systems that don't support HTTPS +- **Default Behavior**: Omit the protocol prefix for domains that should accept both protocols +- **Gradual Migration**: Use protocol-specific filtering to migrate from HTTP to HTTPS incrementally + ## Content Sanitization The `network:` configuration also controls which domains are allowed in sanitized content. URLs from domains not in the allowed list are replaced with `(redacted)` to prevent potential data exfiltration through untrusted links. diff --git a/pkg/workflow/compiler.go b/pkg/workflow/compiler.go index e8740952da..9632e08e22 100644 --- a/pkg/workflow/compiler.go +++ b/pkg/workflow/compiler.go @@ -183,6 +183,21 @@ func (c *Compiler) CompileWorkflowData(workflowData *WorkflowData, markdownPath return errors.New(formattedErr) } + // Validate network allowed domains configuration + log.Printf("Validating network allowed domains") + if err := validateNetworkAllowedDomains(workflowData.NetworkPermissions); err != nil { + formattedErr := console.FormatError(console.CompilerError{ + Position: console.ErrorPosition{ + File: markdownPath, + Line: 1, + Column: 1, + }, + Type: "error", + Message: err.Error(), + }) + return errors.New(formattedErr) + } + // Emit experimental warning for sandbox-runtime feature if isSRTEnabled(workflowData) { fmt.Fprintln(os.Stderr, console.FormatWarningMessage("Using experimental feature: sandbox-runtime firewall")) diff --git a/pkg/workflow/domains_protocol_integration_test.go b/pkg/workflow/domains_protocol_integration_test.go new file mode 100644 index 0000000000..67bdd75951 --- /dev/null +++ b/pkg/workflow/domains_protocol_integration_test.go @@ -0,0 +1,301 @@ +package workflow + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// TestProtocolSpecificDomainsIntegration tests protocol-specific domain filtering end-to-end +func TestProtocolSpecificDomainsIntegration(t *testing.T) { + tests := []struct { + name string + workflow string + expectedDomains []string // domains that should appear in --allow-domains + checkAWFArgs bool // whether to check AWF arguments + }{ + { + name: "Copilot with protocol-specific domains", + workflow: `--- +on: push +permissions: + contents: read +engine: copilot +network: + allowed: + - https://secure.example.com + - http://legacy.example.com + - example.org +--- + +# Test Workflow + +Test protocol-specific domain filtering. +`, + expectedDomains: []string{ + "https://secure.example.com", + "http://legacy.example.com", + "example.org", + "api.github.com", // Copilot default + }, + checkAWFArgs: true, + }, + { + name: "Claude with HTTPS-only wildcard domains", + workflow: `--- +on: push +permissions: + contents: read +engine: claude +strict: false +network: + allowed: + - https://*.api.example.com + - https://secure.example.com +--- + +# Test Workflow + +Test HTTPS-only wildcard domains. +`, + expectedDomains: []string{ + "https://*.api.example.com", + "https://secure.example.com", + "anthropic.com", // Claude default + }, + checkAWFArgs: true, + }, + { + name: "Mixed protocol domains in safe-outputs", + workflow: `--- +on: push +permissions: + contents: read + issues: write +engine: copilot +strict: false +network: + allowed: + - https://secure.example.com + - http://legacy.example.com +safe-outputs: + create-issue: + allowed-domains: + - https://secure.example.com + - http://legacy.example.com +--- + +# Test Workflow + +Test protocol-specific domains in safe-outputs. +`, + expectedDomains: []string{ + "https://secure.example.com", + "http://legacy.example.com", + }, + checkAWFArgs: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create temporary directory and workflow file + tmpDir := t.TempDir() + workflowPath := filepath.Join(tmpDir, "test-workflow.md") + err := os.WriteFile(workflowPath, []byte(tt.workflow), 0644) + if err != nil { + t.Fatalf("Failed to write workflow file: %v", err) + } + + // Compile the workflow + compiler := NewCompiler(false, "", "test") + err = compiler.CompileWorkflow(workflowPath) + if err != nil { + t.Fatalf("Failed to compile workflow: %v", err) + } + + // Read the compiled lock file + lockPath := strings.TrimSuffix(workflowPath, ".md") + ".lock.yml" + lockContent, err := os.ReadFile(lockPath) + if err != nil { + t.Fatalf("Failed to read lock file: %v", err) + } + + lockYAML := string(lockContent) + + // Verify expected domains are present + for _, domain := range tt.expectedDomains { + if !strings.Contains(lockYAML, domain) { + t.Errorf("Expected domain %q not found in compiled workflow", domain) + } + } + + // If checking AWF args, verify --allow-domains flag is present + if tt.checkAWFArgs { + if !strings.Contains(lockYAML, "--allow-domains") { + t.Error("Expected --allow-domains flag in compiled workflow") + } + } + + // Verify protocol prefixes are preserved in the lock file + for _, domain := range tt.expectedDomains { + if strings.HasPrefix(domain, "https://") || strings.HasPrefix(domain, "http://") { + // The domain with protocol should appear in the lock file + if !strings.Contains(lockYAML, domain) { + t.Errorf("Protocol-specific domain %q should be preserved in lock file", domain) + } + } + } + }) + } +} + +// TestProtocolSpecificDomainsValidationIntegration tests that invalid protocols are rejected +func TestProtocolSpecificDomainsValidationIntegration(t *testing.T) { + tests := []struct { + name string + workflow string + wantErr bool + }{ + { + name: "Invalid protocol - FTP", + workflow: `--- +on: push +permissions: + contents: read +engine: copilot +network: + allowed: + - ftp://example.com +--- + +# Test Workflow + +Test invalid protocol rejection. +`, + wantErr: true, + }, + { + name: "Invalid protocol - ws", + workflow: `--- +on: push +permissions: + contents: read +engine: copilot +network: + allowed: + - ws://example.com +--- + +# Test Workflow + +Test websocket protocol rejection. +`, + wantErr: true, + }, + { + name: "Valid HTTPS protocol", + workflow: `--- +on: push +permissions: + contents: read +engine: copilot +network: + allowed: + - https://example.com +--- + +# Test Workflow + +Test valid HTTPS protocol. +`, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create temporary directory and workflow file + tmpDir := t.TempDir() + workflowPath := filepath.Join(tmpDir, "test-workflow.md") + err := os.WriteFile(workflowPath, []byte(tt.workflow), 0644) + if err != nil { + t.Fatalf("Failed to write workflow file: %v", err) + } + + // Compile the workflow + compiler := NewCompiler(false, "", "test") + err = compiler.CompileWorkflow(workflowPath) + + if tt.wantErr && err == nil { + t.Error("Expected compilation error but got none") + } + if !tt.wantErr && err != nil { + t.Errorf("Expected no error but got: %v", err) + } + }) + } +} + +// TestBackwardCompatibilityNoProtocol tests that domains without protocols still work +func TestBackwardCompatibilityNoProtocol(t *testing.T) { + workflow := `--- +on: push +permissions: + contents: read +engine: copilot +network: + allowed: + - example.com + - "*.example.org" + - api.test.com +--- + +# Test Workflow + +Test backward compatibility with domains without protocols. +` + + // Create temporary directory and workflow file + tmpDir := t.TempDir() + workflowPath := filepath.Join(tmpDir, "test-workflow.md") + err := os.WriteFile(workflowPath, []byte(workflow), 0644) + if err != nil { + t.Fatalf("Failed to write workflow file: %v", err) + } + + // Compile the workflow + compiler := NewCompiler(false, "", "test") + err = compiler.CompileWorkflow(workflowPath) + if err != nil { + t.Fatalf("Failed to compile workflow: %v", err) + } + + // Read the compiled lock file + lockPath := strings.TrimSuffix(workflowPath, ".md") + ".lock.yml" + lockContent, err := os.ReadFile(lockPath) + if err != nil { + t.Fatalf("Failed to read lock file: %v", err) + } + + lockYAML := string(lockContent) + + // Verify domains without protocols are still present + expectedDomains := []string{ + "example.com", + "*.example.org", + "api.test.com", + } + + for _, domain := range expectedDomains { + if !strings.Contains(lockYAML, domain) { + t.Errorf("Expected domain %q not found in compiled workflow", domain) + } + } + + // Verify --allow-domains flag is present + if !strings.Contains(lockYAML, "--allow-domains") { + t.Error("Expected --allow-domains flag in compiled workflow") + } +} diff --git a/pkg/workflow/domains_protocol_test.go b/pkg/workflow/domains_protocol_test.go new file mode 100644 index 0000000000..525a8b83cc --- /dev/null +++ b/pkg/workflow/domains_protocol_test.go @@ -0,0 +1,204 @@ +package workflow + +import ( + "strings" + "testing" +) + +// TestProtocolSpecificDomains tests that domains with protocol prefixes are correctly handled +func TestProtocolSpecificDomains(t *testing.T) { + tests := []struct { + name string + network *NetworkPermissions + expectedDomains []string // domains that should be in the output + }{ + { + name: "HTTPS-only domain", + network: &NetworkPermissions{ + Allowed: []string{"https://secure.example.com"}, + }, + expectedDomains: []string{"https://secure.example.com"}, + }, + { + name: "HTTP-only domain", + network: &NetworkPermissions{ + Allowed: []string{"http://legacy.example.com"}, + }, + expectedDomains: []string{"http://legacy.example.com"}, + }, + { + name: "Mixed protocols", + network: &NetworkPermissions{ + Allowed: []string{ + "https://secure.example.com", + "http://legacy.example.com", + "example.org", // No protocol = both + }, + }, + expectedDomains: []string{ + "https://secure.example.com", + "http://legacy.example.com", + "example.org", + }, + }, + { + name: "Protocol-specific with wildcard", + network: &NetworkPermissions{ + Allowed: []string{ + "https://*.secure.example.com", + "http://*.legacy.example.com", + }, + }, + expectedDomains: []string{ + "https://*.secure.example.com", + "http://*.legacy.example.com", + }, + }, + { + name: "Backward compatibility - no protocol", + network: &NetworkPermissions{ + Allowed: []string{ + "example.com", + "*.example.org", + }, + }, + expectedDomains: []string{ + "example.com", + "*.example.org", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Test GetAllowedDomains + result := GetAllowedDomains(tt.network) + + // Check that all expected domains are present + for _, expected := range tt.expectedDomains { + found := false + for _, domain := range result { + if domain == expected { + found = true + break + } + } + if !found { + t.Errorf("Expected domain %q not found in result: %v", expected, result) + } + } + }) + } +} + +// TestGetCopilotAllowedDomainsWithProtocol tests Copilot domain merging with protocols +func TestGetCopilotAllowedDomainsWithProtocol(t *testing.T) { + network := &NetworkPermissions{ + Allowed: []string{ + "https://secure.example.com", + "http://legacy.example.com", + }, + } + + result := GetCopilotAllowedDomains(network) + + // Should contain protocol-specific domains + if !strings.Contains(result, "https://secure.example.com") { + t.Error("Expected result to contain https://secure.example.com") + } + if !strings.Contains(result, "http://legacy.example.com") { + t.Error("Expected result to contain http://legacy.example.com") + } + + // Should also contain Copilot defaults (without protocol) + if !strings.Contains(result, "api.github.com") { + t.Error("Expected result to contain Copilot default domain api.github.com") + } +} + +// TestGetClaudeAllowedDomainsWithProtocol tests Claude domain merging with protocols +func TestGetClaudeAllowedDomainsWithProtocol(t *testing.T) { + network := &NetworkPermissions{ + Allowed: []string{ + "https://api.example.com", + }, + } + + result := GetClaudeAllowedDomains(network) + + // Should contain protocol-specific domain + if !strings.Contains(result, "https://api.example.com") { + t.Error("Expected result to contain https://api.example.com") + } + + // Should also contain Claude defaults + if !strings.Contains(result, "anthropic.com") { + t.Error("Expected result to contain Claude default domain anthropic.com") + } +} + +// TestProtocolSpecificDomainsDeduplication tests that protocol-specific domains are deduplicated +func TestProtocolSpecificDomainsDeduplication(t *testing.T) { + network := &NetworkPermissions{ + Allowed: []string{ + "https://example.com", + "https://example.com", // Duplicate + "http://example.com", // Different protocol - should NOT deduplicate + }, + } + + result := GetAllowedDomains(network) + + // Count occurrences of each domain + httpsCount := 0 + httpCount := 0 + for _, domain := range result { + if domain == "https://example.com" { + httpsCount++ + } + if domain == "http://example.com" { + httpCount++ + } + } + + // HTTPS should appear once (deduplicated) + if httpsCount != 1 { + t.Errorf("Expected https://example.com to appear once, got %d", httpsCount) + } + + // HTTP should appear once (different protocol) + if httpCount != 1 { + t.Errorf("Expected http://example.com to appear once, got %d", httpCount) + } +} + +// TestProtocolSpecificDomainsSorting tests that domains with protocols are sorted correctly +func TestProtocolSpecificDomainsSorting(t *testing.T) { + network := &NetworkPermissions{ + Allowed: []string{ + "example.org", + "https://example.com", + "http://example.com", + "https://api.example.com", + }, + } + + result := GetAllowedDomains(network) + resultStr := strings.Join(result, ",") + + // Verify the result is comma-separated and sorted + // The exact sort order depends on the SortStrings implementation, + // but we can verify that the domains are present + expectedDomains := []string{ + "example.org", + "http://example.com", + "https://api.example.com", + "https://example.com", + } + + for _, expected := range expectedDomains { + if !strings.Contains(resultStr, expected) { + t.Errorf("Expected result to contain %q", expected) + } + } +} diff --git a/pkg/workflow/safe_outputs_domains_protocol_validation_test.go b/pkg/workflow/safe_outputs_domains_protocol_validation_test.go new file mode 100644 index 0000000000..d0c6816cc3 --- /dev/null +++ b/pkg/workflow/safe_outputs_domains_protocol_validation_test.go @@ -0,0 +1,170 @@ +package workflow + +import ( + "testing" +) + +// TestValidateDomainPatternWithProtocol tests domain validation with protocol prefixes +func TestValidateDomainPatternWithProtocol(t *testing.T) { + tests := []struct { + name string + domain string + wantErr bool + }{ + // Valid domains with HTTPS protocol + { + name: "HTTPS domain", + domain: "https://example.com", + wantErr: false, + }, + { + name: "HTTPS wildcard domain", + domain: "https://*.example.com", + wantErr: false, + }, + { + name: "HTTPS subdomain", + domain: "https://api.example.com", + wantErr: false, + }, + + // Valid domains with HTTP protocol + { + name: "HTTP domain", + domain: "http://example.com", + wantErr: false, + }, + { + name: "HTTP wildcard domain", + domain: "http://*.example.com", + wantErr: false, + }, + { + name: "HTTP subdomain", + domain: "http://api.example.com", + wantErr: false, + }, + + // Valid domains without protocol (backward compatibility) + { + name: "Plain domain", + domain: "example.com", + wantErr: false, + }, + { + name: "Wildcard domain", + domain: "*.example.com", + wantErr: false, + }, + + // Invalid patterns + { + name: "Empty domain", + domain: "", + wantErr: true, + }, + { + name: "Protocol only", + domain: "https://", + wantErr: true, + }, + { + name: "HTTPS wildcard only", + domain: "https://*", + wantErr: true, + }, + { + name: "HTTP wildcard only", + domain: "http://*", + wantErr: true, + }, + { + name: "HTTPS wildcard without base domain", + domain: "https://*.", + wantErr: true, + }, + { + name: "Invalid protocol", + domain: "ftp://example.com", + wantErr: true, + }, + { + name: "Multiple wildcards with HTTPS", + domain: "https://*.*.example.com", + wantErr: true, + }, + { + name: "Wildcard in wrong position with HTTPS", + domain: "https://example.*.com", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateDomainPattern(tt.domain) + if (err != nil) != tt.wantErr { + t.Errorf("validateDomainPattern(%q) error = %v, wantErr %v", tt.domain, err, tt.wantErr) + } + }) + } +} + +// TestValidateSafeOutputsAllowedDomainsWithProtocol tests safe-outputs domain validation with protocols +func TestValidateSafeOutputsAllowedDomainsWithProtocol(t *testing.T) { + tests := []struct { + name string + config *SafeOutputsConfig + wantErr bool + }{ + { + name: "Mixed protocol domains", + config: &SafeOutputsConfig{ + AllowedDomains: []string{ + "https://secure.example.com", + "http://legacy.example.com", + "example.org", + }, + }, + wantErr: false, + }, + { + name: "HTTPS wildcard domains", + config: &SafeOutputsConfig{ + AllowedDomains: []string{ + "https://*.example.com", + "https://api.example.com", + }, + }, + wantErr: false, + }, + { + name: "Invalid protocol in list", + config: &SafeOutputsConfig{ + AllowedDomains: []string{ + "https://valid.example.com", + "ftp://invalid.example.com", + }, + }, + wantErr: true, + }, + { + name: "HTTPS with invalid domain", + config: &SafeOutputsConfig{ + AllowedDomains: []string{ + "https://", + }, + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateSafeOutputsAllowedDomains(tt.config) + if (err != nil) != tt.wantErr { + t.Errorf("validateSafeOutputsAllowedDomains() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/pkg/workflow/safe_outputs_domains_validation.go b/pkg/workflow/safe_outputs_domains_validation.go index a573c5a971..f4bacfde13 100644 --- a/pkg/workflow/safe_outputs_domains_validation.go +++ b/pkg/workflow/safe_outputs_domains_validation.go @@ -10,6 +10,35 @@ import ( var safeOutputsDomainsValidationLog = logger.New("workflow:safe_outputs_domains_validation") +// validateNetworkAllowedDomains validates the allowed domains in network configuration +func validateNetworkAllowedDomains(network *NetworkPermissions) error { + if network == nil || len(network.Allowed) == 0 { + return nil + } + + safeOutputsDomainsValidationLog.Printf("Validating %d network allowed domains", len(network.Allowed)) + + for i, domain := range network.Allowed { + // Skip ecosystem identifiers - they don't need domain pattern validation + if isEcosystemIdentifier(domain) { + continue + } + + if err := validateDomainPattern(domain); err != nil { + return fmt.Errorf("network.allowed[%d]: %w", i, err) + } + } + + return nil +} + +// isEcosystemIdentifier checks if a domain string is actually an ecosystem identifier +func isEcosystemIdentifier(domain string) bool { + // Ecosystem identifiers don't contain dots and don't have protocol prefixes + // They are simple identifiers like "defaults", "node", "python", etc. + return !strings.Contains(domain, ".") && !strings.Contains(domain, "://") +} + // domainPattern validates domain patterns including wildcards // Valid patterns: // - Plain domains: github.com, api.github.com @@ -44,52 +73,69 @@ func validateDomainPattern(domain string) error { return fmt.Errorf("domain cannot be empty") } + // Check for invalid protocol prefixes + // Only http:// and https:// are allowed + if strings.Contains(domain, "://") { + if !strings.HasPrefix(domain, "https://") && !strings.HasPrefix(domain, "http://") { + return fmt.Errorf("domain pattern '%s' has invalid protocol, only 'http://' and 'https://' are allowed", domain) + } + } + + // Strip protocol prefix if present (http:// or https://) + // This allows protocol-specific domain filtering + domainWithoutProtocol := domain + if strings.HasPrefix(domain, "https://") { + domainWithoutProtocol = strings.TrimPrefix(domain, "https://") + } else if strings.HasPrefix(domain, "http://") { + domainWithoutProtocol = strings.TrimPrefix(domain, "http://") + } + // Check for wildcard-only pattern - if domain == "*" { - return fmt.Errorf("wildcard-only domain '*' is not allowed, use a specific wildcard pattern like '*.example.com'") + if domainWithoutProtocol == "*" { + return fmt.Errorf("wildcard-only domain '*' is not allowed, use a specific wildcard pattern like '*.example.com' or 'https://*.example.com'") } // Check for wildcard without base domain (must be done before regex) - if domain == "*." { - return fmt.Errorf("wildcard pattern '%s' must have a domain after '*.' (e.g., '*.example.com')", domain) + if domainWithoutProtocol == "*." { + return fmt.Errorf("wildcard pattern '%s' must have a domain after '*.' (e.g., '*.example.com' or 'https://*.example.com')", domain) } // Check for multiple wildcards - if strings.Count(domain, "*") > 1 { - return fmt.Errorf("domain pattern '%s' contains multiple wildcards, only one wildcard at the start is allowed (e.g., '*.example.com')", domain) + if strings.Count(domainWithoutProtocol, "*") > 1 { + return fmt.Errorf("domain pattern '%s' contains multiple wildcards, only one wildcard at the start is allowed (e.g., '*.example.com' or 'https://*.example.com')", domain) } - // Check for wildcard not at the start - if strings.Contains(domain, "*") && !strings.HasPrefix(domain, "*.") { - return fmt.Errorf("domain pattern '%s' has wildcard in invalid position, wildcard must be at the start followed by a dot (e.g., '*.example.com')", domain) + // Check for wildcard not at the start (in the domain part) + if strings.Contains(domainWithoutProtocol, "*") && !strings.HasPrefix(domainWithoutProtocol, "*.") { + return fmt.Errorf("domain pattern '%s' has wildcard in invalid position, wildcard must be at the start followed by a dot (e.g., '*.example.com' or 'https://*.example.com')", domain) } // Additional validation for wildcard patterns - if strings.HasPrefix(domain, "*.") { - baseDomain := domain[2:] // Remove "*." + if strings.HasPrefix(domainWithoutProtocol, "*.") { + baseDomain := domainWithoutProtocol[2:] // Remove "*." if baseDomain == "" { - return fmt.Errorf("wildcard pattern '%s' must have a domain after '*.' (e.g., '*.example.com')", domain) + return fmt.Errorf("wildcard pattern '%s' must have a domain after '*.' (e.g., '*.example.com' or 'https://*.example.com')", domain) } // Ensure the base domain doesn't start with a dot if strings.HasPrefix(baseDomain, ".") { - return fmt.Errorf("wildcard pattern '%s' has invalid format, use '*.example.com' instead of '*.*.example.com'", domain) + return fmt.Errorf("wildcard pattern '%s' has invalid format, use '*.example.com' or 'https://*.example.com' instead", domain) } } - // Validate domain pattern format - if !domainPattern.MatchString(domain) { + // Validate domain pattern format (without protocol) + if !domainPattern.MatchString(domainWithoutProtocol) { // Provide specific error messages for common issues - if strings.HasSuffix(domain, ".") { + if strings.HasSuffix(domainWithoutProtocol, ".") { return fmt.Errorf("domain pattern '%s' cannot end with a dot", domain) } - if strings.Contains(domain, "..") { + if strings.Contains(domainWithoutProtocol, "..") { return fmt.Errorf("domain pattern '%s' cannot contain consecutive dots", domain) } - if strings.HasPrefix(domain, ".") && !strings.HasPrefix(domain, "*.") { + if strings.HasPrefix(domainWithoutProtocol, ".") && !strings.HasPrefix(domainWithoutProtocol, "*.") { return fmt.Errorf("domain pattern '%s' cannot start with a dot (except for wildcard patterns like '*.example.com')", domain) } - // Check for invalid characters - for _, char := range domain { + // Check for invalid characters (in the domain part, not protocol) + for _, char := range domainWithoutProtocol { if (char < 'a' || char > 'z') && (char < 'A' || char > 'Z') && (char < '0' || char > '9') && diff --git a/pkg/workflow/step_summary_test.go b/pkg/workflow/step_summary_test.go index cbeaa68f4d..7458019add 100644 --- a/pkg/workflow/step_summary_test.go +++ b/pkg/workflow/step_summary_test.go @@ -24,6 +24,8 @@ tools: github: allowed: [list_issues] engine: claude +features: + dangerous-permissions-write: true strict: false features: dangerous-permissions-write: true