diff --git a/.github/workflows/example-engine-network-permissions.md b/.github/workflows/example-engine-network-permissions.md index 13604ccebe..af496ff982 100644 --- a/.github/workflows/example-engine-network-permissions.md +++ b/.github/workflows/example-engine-network-permissions.md @@ -10,10 +10,10 @@ permissions: engine: id: claude - permissions: - network: - allowed: - - "docs.github.com" + +network: + allowed: + - "docs.github.com" tools: claude: diff --git a/.github/workflows/test-claude-add-issue-comment.lock.yml b/.github/workflows/test-claude-add-issue-comment.lock.yml index cc12f83cce..bb61ad0587 100644 --- a/.github/workflows/test-claude-add-issue-comment.lock.yml +++ b/.github/workflows/test-claude-add-issue-comment.lock.yml @@ -195,6 +195,113 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v5 + - name: Generate Network Permissions Hook + run: | + mkdir -p .claude/hooks + cat > .claude/hooks/network_permissions.py << 'EOF' + #!/usr/bin/env python3 + """ + Network permissions validator for Claude Code engine. + Generated by gh-aw from engine network permissions configuration. + """ + + import json + import sys + import urllib.parse + import re + + # Domain whitelist (populated during generation) + ALLOWED_DOMAINS = ["crl3.digicert.com","crl4.digicert.com","ocsp.digicert.com","ts-crl.ws.symantec.com","ts-ocsp.ws.symantec.com","crl.geotrust.com","ocsp.geotrust.com","crl.thawte.com","ocsp.thawte.com","crl.verisign.com","ocsp.verisign.com","crl.globalsign.com","ocsp.globalsign.com","crls.ssl.com","ocsp.ssl.com","crl.identrust.com","ocsp.identrust.com","crl.sectigo.com","ocsp.sectigo.com","crl.usertrust.com","ocsp.usertrust.com","s.symcb.com","s.symcd.com","ghcr.io","registry.hub.docker.com","*.docker.io","*.docker.com","production.cloudflare.docker.com","dl.k8s.io","pkgs.k8s.io","quay.io","mcr.microsoft.com","gcr.io","auth.docker.io","nuget.org","dist.nuget.org","api.nuget.org","nuget.pkg.github.com","dotnet.microsoft.com","pkgs.dev.azure.com","builds.dotnet.microsoft.com","dotnetcli.blob.core.windows.net","nugetregistryv2prod.blob.core.windows.net","azuresearch-usnc.nuget.org","azuresearch-ussc.nuget.org","dc.services.visualstudio.com","dot.net","ci.dot.net","www.microsoft.com","oneocsp.microsoft.com","pub.dev","pub.dartlang.org","*.githubusercontent.com","raw.githubusercontent.com","objects.githubusercontent.com","lfs.github.com","github-cloud.githubusercontent.com","github-cloud.s3.amazonaws.com","codeload.github.com","go.dev","golang.org","proxy.golang.org","sum.golang.org","pkg.go.dev","goproxy.io","releases.hashicorp.com","apt.releases.hashicorp.com","yum.releases.hashicorp.com","registry.terraform.io","haskell.org","*.hackage.haskell.org","get-ghcup.haskell.org","downloads.haskell.org","www.java.com","jdk.java.net","api.adoptium.net","adoptium.net","repo.maven.apache.org","maven.apache.org","repo1.maven.org","maven.pkg.github.com","maven.oracle.com","repo.spring.io","gradle.org","services.gradle.org","plugins.gradle.org","plugins-artifacts.gradle.org","repo.grails.org","download.eclipse.org","download.oracle.com","jcenter.bintray.com","json-schema.org","json.schemastore.org","archive.ubuntu.com","security.ubuntu.com","ppa.launchpad.net","keyserver.ubuntu.com","azure.archive.ubuntu.com","api.snapcraft.io","deb.debian.org","security.debian.org","keyring.debian.org","packages.debian.org","debian.map.fastlydns.net","apt.llvm.org","dl.fedoraproject.org","mirrors.fedoraproject.org","download.fedoraproject.org","mirror.centos.org","vault.centos.org","dl-cdn.alpinelinux.org","pkg.alpinelinux.org","mirror.archlinux.org","archlinux.org","download.opensuse.org","cdn.redhat.com","packagecloud.io","packages.cloud.google.com","packages.microsoft.com","npmjs.org","npmjs.com","registry.npmjs.com","registry.npmjs.org","skimdb.npmjs.com","npm.pkg.github.com","api.npms.io","nodejs.org","yarnpkg.com","registry.yarnpkg.com","repo.yarnpkg.com","deb.nodesource.com","get.pnpm.io","bun.sh","deno.land","registry.bower.io","cpan.org","www.cpan.org","metacpan.org","cpan.metacpan.org","repo.packagist.org","packagist.org","getcomposer.org","playwright.download.prss.microsoft.com","cdn.playwright.dev","pypi.python.org","pypi.org","pip.pypa.io","*.pythonhosted.org","files.pythonhosted.org","bootstrap.pypa.io","conda.binstar.org","conda.anaconda.org","binstar.org","anaconda.org","repo.continuum.io","repo.anaconda.com","rubygems.org","api.rubygems.org","rubygems.pkg.github.com","bundler.rubygems.org","gems.rubyforge.org","gems.rubyonrails.org","index.rubygems.org","cache.ruby-lang.org","*.rvm.io","crates.io","index.crates.io","static.crates.io","sh.rustup.rs","static.rust-lang.org","download.swift.org","swift.org","cocoapods.org","cdn.cocoapods.org"] + + def extract_domain(url_or_query): + """Extract domain from URL or search query.""" + if not url_or_query: + return None + + if url_or_query.startswith(('http://', 'https://')): + return urllib.parse.urlparse(url_or_query).netloc.lower() + + # Check for domain patterns in search queries + match = re.search(r'site:([a-zA-Z0-9.-]+\.[a-zA-Z]{2,})', url_or_query) + if match: + return match.group(1).lower() + + return None + + def is_domain_allowed(domain): + """Check if domain is allowed.""" + if not domain: + # If no domain detected, allow only if not under deny-all policy + return bool(ALLOWED_DOMAINS) # False if empty list (deny-all), True if has domains + + # Empty allowed domains means deny all + if not ALLOWED_DOMAINS: + return False + + for pattern in ALLOWED_DOMAINS: + regex = pattern.replace('.', r'\.').replace('*', '.*') + if re.match(f'^{regex}$', domain): + return True + return False + + # Main logic + try: + data = json.load(sys.stdin) + tool_name = data.get('tool_name', '') + tool_input = data.get('tool_input', {}) + + if tool_name not in ['WebFetch', 'WebSearch']: + sys.exit(0) # Allow other tools + + target = tool_input.get('url') or tool_input.get('query', '') + domain = extract_domain(target) + + # For WebSearch, apply domain restrictions consistently + # If no domain detected in search query, check if restrictions are in place + if tool_name == 'WebSearch' and not domain: + # Since this hook is only generated when network permissions are configured, + # empty ALLOWED_DOMAINS means deny-all policy + if not ALLOWED_DOMAINS: # Empty list means deny all + print(f"Network access blocked: deny-all policy in effect", file=sys.stderr) + print(f"No domains are allowed for WebSearch", file=sys.stderr) + sys.exit(2) # Block under deny-all policy + else: + print(f"Network access blocked for WebSearch: no specific domain detected", file=sys.stderr) + print(f"Allowed domains: {', '.join(ALLOWED_DOMAINS)}", file=sys.stderr) + sys.exit(2) # Block general searches when domain allowlist is configured + + if not is_domain_allowed(domain): + print(f"Network access blocked for domain: {domain}", file=sys.stderr) + print(f"Allowed domains: {', '.join(ALLOWED_DOMAINS)}", file=sys.stderr) + sys.exit(2) # Block with feedback to Claude + + sys.exit(0) # Allow + + except Exception as e: + print(f"Network validation error: {e}", file=sys.stderr) + sys.exit(2) # Block on errors + + EOF + chmod +x .claude/hooks/network_permissions.py + - name: Generate Claude Settings + run: | + cat > .claude/settings.json << 'EOF' + { + "hooks": { + "PreToolUse": [ + { + "matcher": "WebFetch|WebSearch", + "hooks": [ + { + "type": "command", + "command": ".claude/hooks/network_permissions.py" + } + ] + } + ] + } + } + EOF - name: Setup agent output id: setup_agent_output uses: actions/github-script@v7 @@ -392,6 +499,7 @@ jobs: GITHUB_AW_SAFE_OUTPUTS: ${{ env.GITHUB_AW_SAFE_OUTPUTS }} mcp_config: /tmp/mcp-config/mcp-servers.json prompt_file: /tmp/aw-prompts/prompt.txt + settings: .claude/settings.json timeout_minutes: 5 env: GITHUB_AW_SAFE_OUTPUTS: ${{ env.GITHUB_AW_SAFE_OUTPUTS }} diff --git a/.github/workflows/test-claude-add-issue-labels.lock.yml b/.github/workflows/test-claude-add-issue-labels.lock.yml index 74d3f97d6e..1c32c25bef 100644 --- a/.github/workflows/test-claude-add-issue-labels.lock.yml +++ b/.github/workflows/test-claude-add-issue-labels.lock.yml @@ -195,6 +195,113 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v5 + - name: Generate Network Permissions Hook + run: | + mkdir -p .claude/hooks + cat > .claude/hooks/network_permissions.py << 'EOF' + #!/usr/bin/env python3 + """ + Network permissions validator for Claude Code engine. + Generated by gh-aw from engine network permissions configuration. + """ + + import json + import sys + import urllib.parse + import re + + # Domain whitelist (populated during generation) + ALLOWED_DOMAINS = ["crl3.digicert.com","crl4.digicert.com","ocsp.digicert.com","ts-crl.ws.symantec.com","ts-ocsp.ws.symantec.com","crl.geotrust.com","ocsp.geotrust.com","crl.thawte.com","ocsp.thawte.com","crl.verisign.com","ocsp.verisign.com","crl.globalsign.com","ocsp.globalsign.com","crls.ssl.com","ocsp.ssl.com","crl.identrust.com","ocsp.identrust.com","crl.sectigo.com","ocsp.sectigo.com","crl.usertrust.com","ocsp.usertrust.com","s.symcb.com","s.symcd.com","ghcr.io","registry.hub.docker.com","*.docker.io","*.docker.com","production.cloudflare.docker.com","dl.k8s.io","pkgs.k8s.io","quay.io","mcr.microsoft.com","gcr.io","auth.docker.io","nuget.org","dist.nuget.org","api.nuget.org","nuget.pkg.github.com","dotnet.microsoft.com","pkgs.dev.azure.com","builds.dotnet.microsoft.com","dotnetcli.blob.core.windows.net","nugetregistryv2prod.blob.core.windows.net","azuresearch-usnc.nuget.org","azuresearch-ussc.nuget.org","dc.services.visualstudio.com","dot.net","ci.dot.net","www.microsoft.com","oneocsp.microsoft.com","pub.dev","pub.dartlang.org","*.githubusercontent.com","raw.githubusercontent.com","objects.githubusercontent.com","lfs.github.com","github-cloud.githubusercontent.com","github-cloud.s3.amazonaws.com","codeload.github.com","go.dev","golang.org","proxy.golang.org","sum.golang.org","pkg.go.dev","goproxy.io","releases.hashicorp.com","apt.releases.hashicorp.com","yum.releases.hashicorp.com","registry.terraform.io","haskell.org","*.hackage.haskell.org","get-ghcup.haskell.org","downloads.haskell.org","www.java.com","jdk.java.net","api.adoptium.net","adoptium.net","repo.maven.apache.org","maven.apache.org","repo1.maven.org","maven.pkg.github.com","maven.oracle.com","repo.spring.io","gradle.org","services.gradle.org","plugins.gradle.org","plugins-artifacts.gradle.org","repo.grails.org","download.eclipse.org","download.oracle.com","jcenter.bintray.com","json-schema.org","json.schemastore.org","archive.ubuntu.com","security.ubuntu.com","ppa.launchpad.net","keyserver.ubuntu.com","azure.archive.ubuntu.com","api.snapcraft.io","deb.debian.org","security.debian.org","keyring.debian.org","packages.debian.org","debian.map.fastlydns.net","apt.llvm.org","dl.fedoraproject.org","mirrors.fedoraproject.org","download.fedoraproject.org","mirror.centos.org","vault.centos.org","dl-cdn.alpinelinux.org","pkg.alpinelinux.org","mirror.archlinux.org","archlinux.org","download.opensuse.org","cdn.redhat.com","packagecloud.io","packages.cloud.google.com","packages.microsoft.com","npmjs.org","npmjs.com","registry.npmjs.com","registry.npmjs.org","skimdb.npmjs.com","npm.pkg.github.com","api.npms.io","nodejs.org","yarnpkg.com","registry.yarnpkg.com","repo.yarnpkg.com","deb.nodesource.com","get.pnpm.io","bun.sh","deno.land","registry.bower.io","cpan.org","www.cpan.org","metacpan.org","cpan.metacpan.org","repo.packagist.org","packagist.org","getcomposer.org","playwright.download.prss.microsoft.com","cdn.playwright.dev","pypi.python.org","pypi.org","pip.pypa.io","*.pythonhosted.org","files.pythonhosted.org","bootstrap.pypa.io","conda.binstar.org","conda.anaconda.org","binstar.org","anaconda.org","repo.continuum.io","repo.anaconda.com","rubygems.org","api.rubygems.org","rubygems.pkg.github.com","bundler.rubygems.org","gems.rubyforge.org","gems.rubyonrails.org","index.rubygems.org","cache.ruby-lang.org","*.rvm.io","crates.io","index.crates.io","static.crates.io","sh.rustup.rs","static.rust-lang.org","download.swift.org","swift.org","cocoapods.org","cdn.cocoapods.org"] + + def extract_domain(url_or_query): + """Extract domain from URL or search query.""" + if not url_or_query: + return None + + if url_or_query.startswith(('http://', 'https://')): + return urllib.parse.urlparse(url_or_query).netloc.lower() + + # Check for domain patterns in search queries + match = re.search(r'site:([a-zA-Z0-9.-]+\.[a-zA-Z]{2,})', url_or_query) + if match: + return match.group(1).lower() + + return None + + def is_domain_allowed(domain): + """Check if domain is allowed.""" + if not domain: + # If no domain detected, allow only if not under deny-all policy + return bool(ALLOWED_DOMAINS) # False if empty list (deny-all), True if has domains + + # Empty allowed domains means deny all + if not ALLOWED_DOMAINS: + return False + + for pattern in ALLOWED_DOMAINS: + regex = pattern.replace('.', r'\.').replace('*', '.*') + if re.match(f'^{regex}$', domain): + return True + return False + + # Main logic + try: + data = json.load(sys.stdin) + tool_name = data.get('tool_name', '') + tool_input = data.get('tool_input', {}) + + if tool_name not in ['WebFetch', 'WebSearch']: + sys.exit(0) # Allow other tools + + target = tool_input.get('url') or tool_input.get('query', '') + domain = extract_domain(target) + + # For WebSearch, apply domain restrictions consistently + # If no domain detected in search query, check if restrictions are in place + if tool_name == 'WebSearch' and not domain: + # Since this hook is only generated when network permissions are configured, + # empty ALLOWED_DOMAINS means deny-all policy + if not ALLOWED_DOMAINS: # Empty list means deny all + print(f"Network access blocked: deny-all policy in effect", file=sys.stderr) + print(f"No domains are allowed for WebSearch", file=sys.stderr) + sys.exit(2) # Block under deny-all policy + else: + print(f"Network access blocked for WebSearch: no specific domain detected", file=sys.stderr) + print(f"Allowed domains: {', '.join(ALLOWED_DOMAINS)}", file=sys.stderr) + sys.exit(2) # Block general searches when domain allowlist is configured + + if not is_domain_allowed(domain): + print(f"Network access blocked for domain: {domain}", file=sys.stderr) + print(f"Allowed domains: {', '.join(ALLOWED_DOMAINS)}", file=sys.stderr) + sys.exit(2) # Block with feedback to Claude + + sys.exit(0) # Allow + + except Exception as e: + print(f"Network validation error: {e}", file=sys.stderr) + sys.exit(2) # Block on errors + + EOF + chmod +x .claude/hooks/network_permissions.py + - name: Generate Claude Settings + run: | + cat > .claude/settings.json << 'EOF' + { + "hooks": { + "PreToolUse": [ + { + "matcher": "WebFetch|WebSearch", + "hooks": [ + { + "type": "command", + "command": ".claude/hooks/network_permissions.py" + } + ] + } + ] + } + } + EOF - name: Setup agent output id: setup_agent_output uses: actions/github-script@v7 @@ -392,6 +499,7 @@ jobs: GITHUB_AW_SAFE_OUTPUTS: ${{ env.GITHUB_AW_SAFE_OUTPUTS }} mcp_config: /tmp/mcp-config/mcp-servers.json prompt_file: /tmp/aw-prompts/prompt.txt + settings: .claude/settings.json timeout_minutes: 5 env: GITHUB_AW_SAFE_OUTPUTS: ${{ env.GITHUB_AW_SAFE_OUTPUTS }} diff --git a/.github/workflows/test-claude-command.lock.yml b/.github/workflows/test-claude-command.lock.yml index 53af18c3c8..9179cb1f28 100644 --- a/.github/workflows/test-claude-command.lock.yml +++ b/.github/workflows/test-claude-command.lock.yml @@ -433,6 +433,113 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v5 + - name: Generate Network Permissions Hook + run: | + mkdir -p .claude/hooks + cat > .claude/hooks/network_permissions.py << 'EOF' + #!/usr/bin/env python3 + """ + Network permissions validator for Claude Code engine. + Generated by gh-aw from engine network permissions configuration. + """ + + import json + import sys + import urllib.parse + import re + + # Domain whitelist (populated during generation) + ALLOWED_DOMAINS = ["crl3.digicert.com","crl4.digicert.com","ocsp.digicert.com","ts-crl.ws.symantec.com","ts-ocsp.ws.symantec.com","crl.geotrust.com","ocsp.geotrust.com","crl.thawte.com","ocsp.thawte.com","crl.verisign.com","ocsp.verisign.com","crl.globalsign.com","ocsp.globalsign.com","crls.ssl.com","ocsp.ssl.com","crl.identrust.com","ocsp.identrust.com","crl.sectigo.com","ocsp.sectigo.com","crl.usertrust.com","ocsp.usertrust.com","s.symcb.com","s.symcd.com","ghcr.io","registry.hub.docker.com","*.docker.io","*.docker.com","production.cloudflare.docker.com","dl.k8s.io","pkgs.k8s.io","quay.io","mcr.microsoft.com","gcr.io","auth.docker.io","nuget.org","dist.nuget.org","api.nuget.org","nuget.pkg.github.com","dotnet.microsoft.com","pkgs.dev.azure.com","builds.dotnet.microsoft.com","dotnetcli.blob.core.windows.net","nugetregistryv2prod.blob.core.windows.net","azuresearch-usnc.nuget.org","azuresearch-ussc.nuget.org","dc.services.visualstudio.com","dot.net","ci.dot.net","www.microsoft.com","oneocsp.microsoft.com","pub.dev","pub.dartlang.org","*.githubusercontent.com","raw.githubusercontent.com","objects.githubusercontent.com","lfs.github.com","github-cloud.githubusercontent.com","github-cloud.s3.amazonaws.com","codeload.github.com","go.dev","golang.org","proxy.golang.org","sum.golang.org","pkg.go.dev","goproxy.io","releases.hashicorp.com","apt.releases.hashicorp.com","yum.releases.hashicorp.com","registry.terraform.io","haskell.org","*.hackage.haskell.org","get-ghcup.haskell.org","downloads.haskell.org","www.java.com","jdk.java.net","api.adoptium.net","adoptium.net","repo.maven.apache.org","maven.apache.org","repo1.maven.org","maven.pkg.github.com","maven.oracle.com","repo.spring.io","gradle.org","services.gradle.org","plugins.gradle.org","plugins-artifacts.gradle.org","repo.grails.org","download.eclipse.org","download.oracle.com","jcenter.bintray.com","json-schema.org","json.schemastore.org","archive.ubuntu.com","security.ubuntu.com","ppa.launchpad.net","keyserver.ubuntu.com","azure.archive.ubuntu.com","api.snapcraft.io","deb.debian.org","security.debian.org","keyring.debian.org","packages.debian.org","debian.map.fastlydns.net","apt.llvm.org","dl.fedoraproject.org","mirrors.fedoraproject.org","download.fedoraproject.org","mirror.centos.org","vault.centos.org","dl-cdn.alpinelinux.org","pkg.alpinelinux.org","mirror.archlinux.org","archlinux.org","download.opensuse.org","cdn.redhat.com","packagecloud.io","packages.cloud.google.com","packages.microsoft.com","npmjs.org","npmjs.com","registry.npmjs.com","registry.npmjs.org","skimdb.npmjs.com","npm.pkg.github.com","api.npms.io","nodejs.org","yarnpkg.com","registry.yarnpkg.com","repo.yarnpkg.com","deb.nodesource.com","get.pnpm.io","bun.sh","deno.land","registry.bower.io","cpan.org","www.cpan.org","metacpan.org","cpan.metacpan.org","repo.packagist.org","packagist.org","getcomposer.org","playwright.download.prss.microsoft.com","cdn.playwright.dev","pypi.python.org","pypi.org","pip.pypa.io","*.pythonhosted.org","files.pythonhosted.org","bootstrap.pypa.io","conda.binstar.org","conda.anaconda.org","binstar.org","anaconda.org","repo.continuum.io","repo.anaconda.com","rubygems.org","api.rubygems.org","rubygems.pkg.github.com","bundler.rubygems.org","gems.rubyforge.org","gems.rubyonrails.org","index.rubygems.org","cache.ruby-lang.org","*.rvm.io","crates.io","index.crates.io","static.crates.io","sh.rustup.rs","static.rust-lang.org","download.swift.org","swift.org","cocoapods.org","cdn.cocoapods.org"] + + def extract_domain(url_or_query): + """Extract domain from URL or search query.""" + if not url_or_query: + return None + + if url_or_query.startswith(('http://', 'https://')): + return urllib.parse.urlparse(url_or_query).netloc.lower() + + # Check for domain patterns in search queries + match = re.search(r'site:([a-zA-Z0-9.-]+\.[a-zA-Z]{2,})', url_or_query) + if match: + return match.group(1).lower() + + return None + + def is_domain_allowed(domain): + """Check if domain is allowed.""" + if not domain: + # If no domain detected, allow only if not under deny-all policy + return bool(ALLOWED_DOMAINS) # False if empty list (deny-all), True if has domains + + # Empty allowed domains means deny all + if not ALLOWED_DOMAINS: + return False + + for pattern in ALLOWED_DOMAINS: + regex = pattern.replace('.', r'\.').replace('*', '.*') + if re.match(f'^{regex}$', domain): + return True + return False + + # Main logic + try: + data = json.load(sys.stdin) + tool_name = data.get('tool_name', '') + tool_input = data.get('tool_input', {}) + + if tool_name not in ['WebFetch', 'WebSearch']: + sys.exit(0) # Allow other tools + + target = tool_input.get('url') or tool_input.get('query', '') + domain = extract_domain(target) + + # For WebSearch, apply domain restrictions consistently + # If no domain detected in search query, check if restrictions are in place + if tool_name == 'WebSearch' and not domain: + # Since this hook is only generated when network permissions are configured, + # empty ALLOWED_DOMAINS means deny-all policy + if not ALLOWED_DOMAINS: # Empty list means deny all + print(f"Network access blocked: deny-all policy in effect", file=sys.stderr) + print(f"No domains are allowed for WebSearch", file=sys.stderr) + sys.exit(2) # Block under deny-all policy + else: + print(f"Network access blocked for WebSearch: no specific domain detected", file=sys.stderr) + print(f"Allowed domains: {', '.join(ALLOWED_DOMAINS)}", file=sys.stderr) + sys.exit(2) # Block general searches when domain allowlist is configured + + if not is_domain_allowed(domain): + print(f"Network access blocked for domain: {domain}", file=sys.stderr) + print(f"Allowed domains: {', '.join(ALLOWED_DOMAINS)}", file=sys.stderr) + sys.exit(2) # Block with feedback to Claude + + sys.exit(0) # Allow + + except Exception as e: + print(f"Network validation error: {e}", file=sys.stderr) + sys.exit(2) # Block on errors + + EOF + chmod +x .claude/hooks/network_permissions.py + - name: Generate Claude Settings + run: | + cat > .claude/settings.json << 'EOF' + { + "hooks": { + "PreToolUse": [ + { + "matcher": "WebFetch|WebSearch", + "hooks": [ + { + "type": "command", + "command": ".claude/hooks/network_permissions.py" + } + ] + } + ] + } + } + EOF - name: Setup agent output id: setup_agent_output uses: actions/github-script@v7 @@ -630,6 +737,7 @@ jobs: GITHUB_AW_SAFE_OUTPUTS: ${{ env.GITHUB_AW_SAFE_OUTPUTS }} mcp_config: /tmp/mcp-config/mcp-servers.json prompt_file: /tmp/aw-prompts/prompt.txt + settings: .claude/settings.json timeout_minutes: 5 env: GITHUB_AW_SAFE_OUTPUTS: ${{ env.GITHUB_AW_SAFE_OUTPUTS }} diff --git a/.github/workflows/test-claude-create-issue.lock.yml b/.github/workflows/test-claude-create-issue.lock.yml index 611e078442..aa213e81ab 100644 --- a/.github/workflows/test-claude-create-issue.lock.yml +++ b/.github/workflows/test-claude-create-issue.lock.yml @@ -16,7 +16,7 @@ run-name: "Test Claude Create Issue" jobs: test-claude-create-issue: runs-on: ubuntu-latest - permissions: {} + permissions: read-all outputs: output: ${{ steps.collect_output.outputs.output }} steps: @@ -38,7 +38,7 @@ jobs: import re # Domain whitelist (populated during generation) - ALLOWED_DOMAINS = [] + ALLOWED_DOMAINS = ["crl3.digicert.com","crl4.digicert.com","ocsp.digicert.com","ts-crl.ws.symantec.com","ts-ocsp.ws.symantec.com","crl.geotrust.com","ocsp.geotrust.com","crl.thawte.com","ocsp.thawte.com","crl.verisign.com","ocsp.verisign.com","crl.globalsign.com","ocsp.globalsign.com","crls.ssl.com","ocsp.ssl.com","crl.identrust.com","ocsp.identrust.com","crl.sectigo.com","ocsp.sectigo.com","crl.usertrust.com","ocsp.usertrust.com","s.symcb.com","s.symcd.com","ghcr.io","registry.hub.docker.com","*.docker.io","*.docker.com","production.cloudflare.docker.com","dl.k8s.io","pkgs.k8s.io","quay.io","mcr.microsoft.com","gcr.io","auth.docker.io","nuget.org","dist.nuget.org","api.nuget.org","nuget.pkg.github.com","dotnet.microsoft.com","pkgs.dev.azure.com","builds.dotnet.microsoft.com","dotnetcli.blob.core.windows.net","nugetregistryv2prod.blob.core.windows.net","azuresearch-usnc.nuget.org","azuresearch-ussc.nuget.org","dc.services.visualstudio.com","dot.net","ci.dot.net","www.microsoft.com","oneocsp.microsoft.com","pub.dev","pub.dartlang.org","*.githubusercontent.com","raw.githubusercontent.com","objects.githubusercontent.com","lfs.github.com","github-cloud.githubusercontent.com","github-cloud.s3.amazonaws.com","codeload.github.com","go.dev","golang.org","proxy.golang.org","sum.golang.org","pkg.go.dev","goproxy.io","releases.hashicorp.com","apt.releases.hashicorp.com","yum.releases.hashicorp.com","registry.terraform.io","haskell.org","*.hackage.haskell.org","get-ghcup.haskell.org","downloads.haskell.org","www.java.com","jdk.java.net","api.adoptium.net","adoptium.net","repo.maven.apache.org","maven.apache.org","repo1.maven.org","maven.pkg.github.com","maven.oracle.com","repo.spring.io","gradle.org","services.gradle.org","plugins.gradle.org","plugins-artifacts.gradle.org","repo.grails.org","download.eclipse.org","download.oracle.com","jcenter.bintray.com","json-schema.org","json.schemastore.org","archive.ubuntu.com","security.ubuntu.com","ppa.launchpad.net","keyserver.ubuntu.com","azure.archive.ubuntu.com","api.snapcraft.io","deb.debian.org","security.debian.org","keyring.debian.org","packages.debian.org","debian.map.fastlydns.net","apt.llvm.org","dl.fedoraproject.org","mirrors.fedoraproject.org","download.fedoraproject.org","mirror.centos.org","vault.centos.org","dl-cdn.alpinelinux.org","pkg.alpinelinux.org","mirror.archlinux.org","archlinux.org","download.opensuse.org","cdn.redhat.com","packagecloud.io","packages.cloud.google.com","packages.microsoft.com","npmjs.org","npmjs.com","registry.npmjs.com","registry.npmjs.org","skimdb.npmjs.com","npm.pkg.github.com","api.npms.io","nodejs.org","yarnpkg.com","registry.yarnpkg.com","repo.yarnpkg.com","deb.nodesource.com","get.pnpm.io","bun.sh","deno.land","registry.bower.io","cpan.org","www.cpan.org","metacpan.org","cpan.metacpan.org","repo.packagist.org","packagist.org","getcomposer.org","playwright.download.prss.microsoft.com","cdn.playwright.dev","pypi.python.org","pypi.org","pip.pypa.io","*.pythonhosted.org","files.pythonhosted.org","bootstrap.pypa.io","conda.binstar.org","conda.anaconda.org","binstar.org","anaconda.org","repo.continuum.io","repo.anaconda.com","rubygems.org","api.rubygems.org","rubygems.pkg.github.com","bundler.rubygems.org","gems.rubyforge.org","gems.rubyonrails.org","index.rubygems.org","cache.ruby-lang.org","*.rvm.io","crates.io","index.crates.io","static.crates.io","sh.rustup.rs","static.rust-lang.org","download.swift.org","swift.org","cocoapods.org","cdn.cocoapods.org"] def extract_domain(url_or_query): """Extract domain from URL or search query.""" diff --git a/.github/workflows/test-claude-create-issue.md b/.github/workflows/test-claude-create-issue.md index c15a7c1292..f1644e88a9 100644 --- a/.github/workflows/test-claude-create-issue.md +++ b/.github/workflows/test-claude-create-issue.md @@ -4,7 +4,6 @@ on: engine: id: claude -strict: true safe-outputs: create-issue: title-prefix: "[claude-test] " diff --git a/.github/workflows/test-claude-create-pull-request.lock.yml b/.github/workflows/test-claude-create-pull-request.lock.yml index 5ed650bcee..4da9d023d9 100644 --- a/.github/workflows/test-claude-create-pull-request.lock.yml +++ b/.github/workflows/test-claude-create-pull-request.lock.yml @@ -22,6 +22,113 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v5 + - name: Generate Network Permissions Hook + run: | + mkdir -p .claude/hooks + cat > .claude/hooks/network_permissions.py << 'EOF' + #!/usr/bin/env python3 + """ + Network permissions validator for Claude Code engine. + Generated by gh-aw from engine network permissions configuration. + """ + + import json + import sys + import urllib.parse + import re + + # Domain whitelist (populated during generation) + ALLOWED_DOMAINS = ["crl3.digicert.com","crl4.digicert.com","ocsp.digicert.com","ts-crl.ws.symantec.com","ts-ocsp.ws.symantec.com","crl.geotrust.com","ocsp.geotrust.com","crl.thawte.com","ocsp.thawte.com","crl.verisign.com","ocsp.verisign.com","crl.globalsign.com","ocsp.globalsign.com","crls.ssl.com","ocsp.ssl.com","crl.identrust.com","ocsp.identrust.com","crl.sectigo.com","ocsp.sectigo.com","crl.usertrust.com","ocsp.usertrust.com","s.symcb.com","s.symcd.com","ghcr.io","registry.hub.docker.com","*.docker.io","*.docker.com","production.cloudflare.docker.com","dl.k8s.io","pkgs.k8s.io","quay.io","mcr.microsoft.com","gcr.io","auth.docker.io","nuget.org","dist.nuget.org","api.nuget.org","nuget.pkg.github.com","dotnet.microsoft.com","pkgs.dev.azure.com","builds.dotnet.microsoft.com","dotnetcli.blob.core.windows.net","nugetregistryv2prod.blob.core.windows.net","azuresearch-usnc.nuget.org","azuresearch-ussc.nuget.org","dc.services.visualstudio.com","dot.net","ci.dot.net","www.microsoft.com","oneocsp.microsoft.com","pub.dev","pub.dartlang.org","*.githubusercontent.com","raw.githubusercontent.com","objects.githubusercontent.com","lfs.github.com","github-cloud.githubusercontent.com","github-cloud.s3.amazonaws.com","codeload.github.com","go.dev","golang.org","proxy.golang.org","sum.golang.org","pkg.go.dev","goproxy.io","releases.hashicorp.com","apt.releases.hashicorp.com","yum.releases.hashicorp.com","registry.terraform.io","haskell.org","*.hackage.haskell.org","get-ghcup.haskell.org","downloads.haskell.org","www.java.com","jdk.java.net","api.adoptium.net","adoptium.net","repo.maven.apache.org","maven.apache.org","repo1.maven.org","maven.pkg.github.com","maven.oracle.com","repo.spring.io","gradle.org","services.gradle.org","plugins.gradle.org","plugins-artifacts.gradle.org","repo.grails.org","download.eclipse.org","download.oracle.com","jcenter.bintray.com","json-schema.org","json.schemastore.org","archive.ubuntu.com","security.ubuntu.com","ppa.launchpad.net","keyserver.ubuntu.com","azure.archive.ubuntu.com","api.snapcraft.io","deb.debian.org","security.debian.org","keyring.debian.org","packages.debian.org","debian.map.fastlydns.net","apt.llvm.org","dl.fedoraproject.org","mirrors.fedoraproject.org","download.fedoraproject.org","mirror.centos.org","vault.centos.org","dl-cdn.alpinelinux.org","pkg.alpinelinux.org","mirror.archlinux.org","archlinux.org","download.opensuse.org","cdn.redhat.com","packagecloud.io","packages.cloud.google.com","packages.microsoft.com","npmjs.org","npmjs.com","registry.npmjs.com","registry.npmjs.org","skimdb.npmjs.com","npm.pkg.github.com","api.npms.io","nodejs.org","yarnpkg.com","registry.yarnpkg.com","repo.yarnpkg.com","deb.nodesource.com","get.pnpm.io","bun.sh","deno.land","registry.bower.io","cpan.org","www.cpan.org","metacpan.org","cpan.metacpan.org","repo.packagist.org","packagist.org","getcomposer.org","playwright.download.prss.microsoft.com","cdn.playwright.dev","pypi.python.org","pypi.org","pip.pypa.io","*.pythonhosted.org","files.pythonhosted.org","bootstrap.pypa.io","conda.binstar.org","conda.anaconda.org","binstar.org","anaconda.org","repo.continuum.io","repo.anaconda.com","rubygems.org","api.rubygems.org","rubygems.pkg.github.com","bundler.rubygems.org","gems.rubyforge.org","gems.rubyonrails.org","index.rubygems.org","cache.ruby-lang.org","*.rvm.io","crates.io","index.crates.io","static.crates.io","sh.rustup.rs","static.rust-lang.org","download.swift.org","swift.org","cocoapods.org","cdn.cocoapods.org"] + + def extract_domain(url_or_query): + """Extract domain from URL or search query.""" + if not url_or_query: + return None + + if url_or_query.startswith(('http://', 'https://')): + return urllib.parse.urlparse(url_or_query).netloc.lower() + + # Check for domain patterns in search queries + match = re.search(r'site:([a-zA-Z0-9.-]+\.[a-zA-Z]{2,})', url_or_query) + if match: + return match.group(1).lower() + + return None + + def is_domain_allowed(domain): + """Check if domain is allowed.""" + if not domain: + # If no domain detected, allow only if not under deny-all policy + return bool(ALLOWED_DOMAINS) # False if empty list (deny-all), True if has domains + + # Empty allowed domains means deny all + if not ALLOWED_DOMAINS: + return False + + for pattern in ALLOWED_DOMAINS: + regex = pattern.replace('.', r'\.').replace('*', '.*') + if re.match(f'^{regex}$', domain): + return True + return False + + # Main logic + try: + data = json.load(sys.stdin) + tool_name = data.get('tool_name', '') + tool_input = data.get('tool_input', {}) + + if tool_name not in ['WebFetch', 'WebSearch']: + sys.exit(0) # Allow other tools + + target = tool_input.get('url') or tool_input.get('query', '') + domain = extract_domain(target) + + # For WebSearch, apply domain restrictions consistently + # If no domain detected in search query, check if restrictions are in place + if tool_name == 'WebSearch' and not domain: + # Since this hook is only generated when network permissions are configured, + # empty ALLOWED_DOMAINS means deny-all policy + if not ALLOWED_DOMAINS: # Empty list means deny all + print(f"Network access blocked: deny-all policy in effect", file=sys.stderr) + print(f"No domains are allowed for WebSearch", file=sys.stderr) + sys.exit(2) # Block under deny-all policy + else: + print(f"Network access blocked for WebSearch: no specific domain detected", file=sys.stderr) + print(f"Allowed domains: {', '.join(ALLOWED_DOMAINS)}", file=sys.stderr) + sys.exit(2) # Block general searches when domain allowlist is configured + + if not is_domain_allowed(domain): + print(f"Network access blocked for domain: {domain}", file=sys.stderr) + print(f"Allowed domains: {', '.join(ALLOWED_DOMAINS)}", file=sys.stderr) + sys.exit(2) # Block with feedback to Claude + + sys.exit(0) # Allow + + except Exception as e: + print(f"Network validation error: {e}", file=sys.stderr) + sys.exit(2) # Block on errors + + EOF + chmod +x .claude/hooks/network_permissions.py + - name: Generate Claude Settings + run: | + cat > .claude/settings.json << 'EOF' + { + "hooks": { + "PreToolUse": [ + { + "matcher": "WebFetch|WebSearch", + "hooks": [ + { + "type": "command", + "command": ".claude/hooks/network_permissions.py" + } + ] + } + ] + } + } + EOF - name: Setup agent output id: setup_agent_output uses: actions/github-script@v7 @@ -238,6 +345,7 @@ jobs: GITHUB_AW_SAFE_OUTPUTS: ${{ env.GITHUB_AW_SAFE_OUTPUTS }} mcp_config: /tmp/mcp-config/mcp-servers.json prompt_file: /tmp/aw-prompts/prompt.txt + settings: .claude/settings.json timeout_minutes: 5 env: GITHUB_AW_SAFE_OUTPUTS: ${{ env.GITHUB_AW_SAFE_OUTPUTS }} diff --git a/.github/workflows/test-claude-mcp.lock.yml b/.github/workflows/test-claude-mcp.lock.yml index 739508c6b1..c3d2ff398a 100644 --- a/.github/workflows/test-claude-mcp.lock.yml +++ b/.github/workflows/test-claude-mcp.lock.yml @@ -192,6 +192,113 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v5 + - name: Generate Network Permissions Hook + run: | + mkdir -p .claude/hooks + cat > .claude/hooks/network_permissions.py << 'EOF' + #!/usr/bin/env python3 + """ + Network permissions validator for Claude Code engine. + Generated by gh-aw from engine network permissions configuration. + """ + + import json + import sys + import urllib.parse + import re + + # Domain whitelist (populated during generation) + ALLOWED_DOMAINS = ["crl3.digicert.com","crl4.digicert.com","ocsp.digicert.com","ts-crl.ws.symantec.com","ts-ocsp.ws.symantec.com","crl.geotrust.com","ocsp.geotrust.com","crl.thawte.com","ocsp.thawte.com","crl.verisign.com","ocsp.verisign.com","crl.globalsign.com","ocsp.globalsign.com","crls.ssl.com","ocsp.ssl.com","crl.identrust.com","ocsp.identrust.com","crl.sectigo.com","ocsp.sectigo.com","crl.usertrust.com","ocsp.usertrust.com","s.symcb.com","s.symcd.com","ghcr.io","registry.hub.docker.com","*.docker.io","*.docker.com","production.cloudflare.docker.com","dl.k8s.io","pkgs.k8s.io","quay.io","mcr.microsoft.com","gcr.io","auth.docker.io","nuget.org","dist.nuget.org","api.nuget.org","nuget.pkg.github.com","dotnet.microsoft.com","pkgs.dev.azure.com","builds.dotnet.microsoft.com","dotnetcli.blob.core.windows.net","nugetregistryv2prod.blob.core.windows.net","azuresearch-usnc.nuget.org","azuresearch-ussc.nuget.org","dc.services.visualstudio.com","dot.net","ci.dot.net","www.microsoft.com","oneocsp.microsoft.com","pub.dev","pub.dartlang.org","*.githubusercontent.com","raw.githubusercontent.com","objects.githubusercontent.com","lfs.github.com","github-cloud.githubusercontent.com","github-cloud.s3.amazonaws.com","codeload.github.com","go.dev","golang.org","proxy.golang.org","sum.golang.org","pkg.go.dev","goproxy.io","releases.hashicorp.com","apt.releases.hashicorp.com","yum.releases.hashicorp.com","registry.terraform.io","haskell.org","*.hackage.haskell.org","get-ghcup.haskell.org","downloads.haskell.org","www.java.com","jdk.java.net","api.adoptium.net","adoptium.net","repo.maven.apache.org","maven.apache.org","repo1.maven.org","maven.pkg.github.com","maven.oracle.com","repo.spring.io","gradle.org","services.gradle.org","plugins.gradle.org","plugins-artifacts.gradle.org","repo.grails.org","download.eclipse.org","download.oracle.com","jcenter.bintray.com","json-schema.org","json.schemastore.org","archive.ubuntu.com","security.ubuntu.com","ppa.launchpad.net","keyserver.ubuntu.com","azure.archive.ubuntu.com","api.snapcraft.io","deb.debian.org","security.debian.org","keyring.debian.org","packages.debian.org","debian.map.fastlydns.net","apt.llvm.org","dl.fedoraproject.org","mirrors.fedoraproject.org","download.fedoraproject.org","mirror.centos.org","vault.centos.org","dl-cdn.alpinelinux.org","pkg.alpinelinux.org","mirror.archlinux.org","archlinux.org","download.opensuse.org","cdn.redhat.com","packagecloud.io","packages.cloud.google.com","packages.microsoft.com","npmjs.org","npmjs.com","registry.npmjs.com","registry.npmjs.org","skimdb.npmjs.com","npm.pkg.github.com","api.npms.io","nodejs.org","yarnpkg.com","registry.yarnpkg.com","repo.yarnpkg.com","deb.nodesource.com","get.pnpm.io","bun.sh","deno.land","registry.bower.io","cpan.org","www.cpan.org","metacpan.org","cpan.metacpan.org","repo.packagist.org","packagist.org","getcomposer.org","playwright.download.prss.microsoft.com","cdn.playwright.dev","pypi.python.org","pypi.org","pip.pypa.io","*.pythonhosted.org","files.pythonhosted.org","bootstrap.pypa.io","conda.binstar.org","conda.anaconda.org","binstar.org","anaconda.org","repo.continuum.io","repo.anaconda.com","rubygems.org","api.rubygems.org","rubygems.pkg.github.com","bundler.rubygems.org","gems.rubyforge.org","gems.rubyonrails.org","index.rubygems.org","cache.ruby-lang.org","*.rvm.io","crates.io","index.crates.io","static.crates.io","sh.rustup.rs","static.rust-lang.org","download.swift.org","swift.org","cocoapods.org","cdn.cocoapods.org"] + + def extract_domain(url_or_query): + """Extract domain from URL or search query.""" + if not url_or_query: + return None + + if url_or_query.startswith(('http://', 'https://')): + return urllib.parse.urlparse(url_or_query).netloc.lower() + + # Check for domain patterns in search queries + match = re.search(r'site:([a-zA-Z0-9.-]+\.[a-zA-Z]{2,})', url_or_query) + if match: + return match.group(1).lower() + + return None + + def is_domain_allowed(domain): + """Check if domain is allowed.""" + if not domain: + # If no domain detected, allow only if not under deny-all policy + return bool(ALLOWED_DOMAINS) # False if empty list (deny-all), True if has domains + + # Empty allowed domains means deny all + if not ALLOWED_DOMAINS: + return False + + for pattern in ALLOWED_DOMAINS: + regex = pattern.replace('.', r'\.').replace('*', '.*') + if re.match(f'^{regex}$', domain): + return True + return False + + # Main logic + try: + data = json.load(sys.stdin) + tool_name = data.get('tool_name', '') + tool_input = data.get('tool_input', {}) + + if tool_name not in ['WebFetch', 'WebSearch']: + sys.exit(0) # Allow other tools + + target = tool_input.get('url') or tool_input.get('query', '') + domain = extract_domain(target) + + # For WebSearch, apply domain restrictions consistently + # If no domain detected in search query, check if restrictions are in place + if tool_name == 'WebSearch' and not domain: + # Since this hook is only generated when network permissions are configured, + # empty ALLOWED_DOMAINS means deny-all policy + if not ALLOWED_DOMAINS: # Empty list means deny all + print(f"Network access blocked: deny-all policy in effect", file=sys.stderr) + print(f"No domains are allowed for WebSearch", file=sys.stderr) + sys.exit(2) # Block under deny-all policy + else: + print(f"Network access blocked for WebSearch: no specific domain detected", file=sys.stderr) + print(f"Allowed domains: {', '.join(ALLOWED_DOMAINS)}", file=sys.stderr) + sys.exit(2) # Block general searches when domain allowlist is configured + + if not is_domain_allowed(domain): + print(f"Network access blocked for domain: {domain}", file=sys.stderr) + print(f"Allowed domains: {', '.join(ALLOWED_DOMAINS)}", file=sys.stderr) + sys.exit(2) # Block with feedback to Claude + + sys.exit(0) # Allow + + except Exception as e: + print(f"Network validation error: {e}", file=sys.stderr) + sys.exit(2) # Block on errors + + EOF + chmod +x .claude/hooks/network_permissions.py + - name: Generate Claude Settings + run: | + cat > .claude/settings.json << 'EOF' + { + "hooks": { + "PreToolUse": [ + { + "matcher": "WebFetch|WebSearch", + "hooks": [ + { + "type": "command", + "command": ".claude/hooks/network_permissions.py" + } + ] + } + ] + } + } + EOF - name: Setup agent output id: setup_agent_output uses: actions/github-script@v7 @@ -414,6 +521,7 @@ jobs: GITHUB_AW_SAFE_OUTPUTS: ${{ env.GITHUB_AW_SAFE_OUTPUTS }} mcp_config: /tmp/mcp-config/mcp-servers.json prompt_file: /tmp/aw-prompts/prompt.txt + settings: .claude/settings.json timeout_minutes: 5 env: GITHUB_AW_SAFE_OUTPUTS: ${{ env.GITHUB_AW_SAFE_OUTPUTS }} diff --git a/.github/workflows/test-claude-push-to-branch.lock.yml b/.github/workflows/test-claude-push-to-branch.lock.yml index 5d42e92e5e..ac9cb6e1a6 100644 --- a/.github/workflows/test-claude-push-to-branch.lock.yml +++ b/.github/workflows/test-claude-push-to-branch.lock.yml @@ -72,6 +72,113 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v5 + - name: Generate Network Permissions Hook + run: | + mkdir -p .claude/hooks + cat > .claude/hooks/network_permissions.py << 'EOF' + #!/usr/bin/env python3 + """ + Network permissions validator for Claude Code engine. + Generated by gh-aw from engine network permissions configuration. + """ + + import json + import sys + import urllib.parse + import re + + # Domain whitelist (populated during generation) + ALLOWED_DOMAINS = ["crl3.digicert.com","crl4.digicert.com","ocsp.digicert.com","ts-crl.ws.symantec.com","ts-ocsp.ws.symantec.com","crl.geotrust.com","ocsp.geotrust.com","crl.thawte.com","ocsp.thawte.com","crl.verisign.com","ocsp.verisign.com","crl.globalsign.com","ocsp.globalsign.com","crls.ssl.com","ocsp.ssl.com","crl.identrust.com","ocsp.identrust.com","crl.sectigo.com","ocsp.sectigo.com","crl.usertrust.com","ocsp.usertrust.com","s.symcb.com","s.symcd.com","ghcr.io","registry.hub.docker.com","*.docker.io","*.docker.com","production.cloudflare.docker.com","dl.k8s.io","pkgs.k8s.io","quay.io","mcr.microsoft.com","gcr.io","auth.docker.io","nuget.org","dist.nuget.org","api.nuget.org","nuget.pkg.github.com","dotnet.microsoft.com","pkgs.dev.azure.com","builds.dotnet.microsoft.com","dotnetcli.blob.core.windows.net","nugetregistryv2prod.blob.core.windows.net","azuresearch-usnc.nuget.org","azuresearch-ussc.nuget.org","dc.services.visualstudio.com","dot.net","ci.dot.net","www.microsoft.com","oneocsp.microsoft.com","pub.dev","pub.dartlang.org","*.githubusercontent.com","raw.githubusercontent.com","objects.githubusercontent.com","lfs.github.com","github-cloud.githubusercontent.com","github-cloud.s3.amazonaws.com","codeload.github.com","go.dev","golang.org","proxy.golang.org","sum.golang.org","pkg.go.dev","goproxy.io","releases.hashicorp.com","apt.releases.hashicorp.com","yum.releases.hashicorp.com","registry.terraform.io","haskell.org","*.hackage.haskell.org","get-ghcup.haskell.org","downloads.haskell.org","www.java.com","jdk.java.net","api.adoptium.net","adoptium.net","repo.maven.apache.org","maven.apache.org","repo1.maven.org","maven.pkg.github.com","maven.oracle.com","repo.spring.io","gradle.org","services.gradle.org","plugins.gradle.org","plugins-artifacts.gradle.org","repo.grails.org","download.eclipse.org","download.oracle.com","jcenter.bintray.com","json-schema.org","json.schemastore.org","archive.ubuntu.com","security.ubuntu.com","ppa.launchpad.net","keyserver.ubuntu.com","azure.archive.ubuntu.com","api.snapcraft.io","deb.debian.org","security.debian.org","keyring.debian.org","packages.debian.org","debian.map.fastlydns.net","apt.llvm.org","dl.fedoraproject.org","mirrors.fedoraproject.org","download.fedoraproject.org","mirror.centos.org","vault.centos.org","dl-cdn.alpinelinux.org","pkg.alpinelinux.org","mirror.archlinux.org","archlinux.org","download.opensuse.org","cdn.redhat.com","packagecloud.io","packages.cloud.google.com","packages.microsoft.com","npmjs.org","npmjs.com","registry.npmjs.com","registry.npmjs.org","skimdb.npmjs.com","npm.pkg.github.com","api.npms.io","nodejs.org","yarnpkg.com","registry.yarnpkg.com","repo.yarnpkg.com","deb.nodesource.com","get.pnpm.io","bun.sh","deno.land","registry.bower.io","cpan.org","www.cpan.org","metacpan.org","cpan.metacpan.org","repo.packagist.org","packagist.org","getcomposer.org","playwright.download.prss.microsoft.com","cdn.playwright.dev","pypi.python.org","pypi.org","pip.pypa.io","*.pythonhosted.org","files.pythonhosted.org","bootstrap.pypa.io","conda.binstar.org","conda.anaconda.org","binstar.org","anaconda.org","repo.continuum.io","repo.anaconda.com","rubygems.org","api.rubygems.org","rubygems.pkg.github.com","bundler.rubygems.org","gems.rubyforge.org","gems.rubyonrails.org","index.rubygems.org","cache.ruby-lang.org","*.rvm.io","crates.io","index.crates.io","static.crates.io","sh.rustup.rs","static.rust-lang.org","download.swift.org","swift.org","cocoapods.org","cdn.cocoapods.org"] + + def extract_domain(url_or_query): + """Extract domain from URL or search query.""" + if not url_or_query: + return None + + if url_or_query.startswith(('http://', 'https://')): + return urllib.parse.urlparse(url_or_query).netloc.lower() + + # Check for domain patterns in search queries + match = re.search(r'site:([a-zA-Z0-9.-]+\.[a-zA-Z]{2,})', url_or_query) + if match: + return match.group(1).lower() + + return None + + def is_domain_allowed(domain): + """Check if domain is allowed.""" + if not domain: + # If no domain detected, allow only if not under deny-all policy + return bool(ALLOWED_DOMAINS) # False if empty list (deny-all), True if has domains + + # Empty allowed domains means deny all + if not ALLOWED_DOMAINS: + return False + + for pattern in ALLOWED_DOMAINS: + regex = pattern.replace('.', r'\.').replace('*', '.*') + if re.match(f'^{regex}$', domain): + return True + return False + + # Main logic + try: + data = json.load(sys.stdin) + tool_name = data.get('tool_name', '') + tool_input = data.get('tool_input', {}) + + if tool_name not in ['WebFetch', 'WebSearch']: + sys.exit(0) # Allow other tools + + target = tool_input.get('url') or tool_input.get('query', '') + domain = extract_domain(target) + + # For WebSearch, apply domain restrictions consistently + # If no domain detected in search query, check if restrictions are in place + if tool_name == 'WebSearch' and not domain: + # Since this hook is only generated when network permissions are configured, + # empty ALLOWED_DOMAINS means deny-all policy + if not ALLOWED_DOMAINS: # Empty list means deny all + print(f"Network access blocked: deny-all policy in effect", file=sys.stderr) + print(f"No domains are allowed for WebSearch", file=sys.stderr) + sys.exit(2) # Block under deny-all policy + else: + print(f"Network access blocked for WebSearch: no specific domain detected", file=sys.stderr) + print(f"Allowed domains: {', '.join(ALLOWED_DOMAINS)}", file=sys.stderr) + sys.exit(2) # Block general searches when domain allowlist is configured + + if not is_domain_allowed(domain): + print(f"Network access blocked for domain: {domain}", file=sys.stderr) + print(f"Allowed domains: {', '.join(ALLOWED_DOMAINS)}", file=sys.stderr) + sys.exit(2) # Block with feedback to Claude + + sys.exit(0) # Allow + + except Exception as e: + print(f"Network validation error: {e}", file=sys.stderr) + sys.exit(2) # Block on errors + + EOF + chmod +x .claude/hooks/network_permissions.py + - name: Generate Claude Settings + run: | + cat > .claude/settings.json << 'EOF' + { + "hooks": { + "PreToolUse": [ + { + "matcher": "WebFetch|WebSearch", + "hooks": [ + { + "type": "command", + "command": ".claude/hooks/network_permissions.py" + } + ] + } + ] + } + } + EOF - name: Setup agent output id: setup_agent_output uses: actions/github-script@v7 @@ -321,6 +428,7 @@ jobs: GITHUB_AW_SAFE_OUTPUTS: ${{ env.GITHUB_AW_SAFE_OUTPUTS }} mcp_config: /tmp/mcp-config/mcp-servers.json prompt_file: /tmp/aw-prompts/prompt.txt + settings: .claude/settings.json timeout_minutes: 5 env: GITHUB_AW_SAFE_OUTPUTS: ${{ env.GITHUB_AW_SAFE_OUTPUTS }} diff --git a/.github/workflows/test-claude-update-issue.lock.yml b/.github/workflows/test-claude-update-issue.lock.yml index 38c033cb0b..092dcb4e0d 100644 --- a/.github/workflows/test-claude-update-issue.lock.yml +++ b/.github/workflows/test-claude-update-issue.lock.yml @@ -195,6 +195,113 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v5 + - name: Generate Network Permissions Hook + run: | + mkdir -p .claude/hooks + cat > .claude/hooks/network_permissions.py << 'EOF' + #!/usr/bin/env python3 + """ + Network permissions validator for Claude Code engine. + Generated by gh-aw from engine network permissions configuration. + """ + + import json + import sys + import urllib.parse + import re + + # Domain whitelist (populated during generation) + ALLOWED_DOMAINS = ["crl3.digicert.com","crl4.digicert.com","ocsp.digicert.com","ts-crl.ws.symantec.com","ts-ocsp.ws.symantec.com","crl.geotrust.com","ocsp.geotrust.com","crl.thawte.com","ocsp.thawte.com","crl.verisign.com","ocsp.verisign.com","crl.globalsign.com","ocsp.globalsign.com","crls.ssl.com","ocsp.ssl.com","crl.identrust.com","ocsp.identrust.com","crl.sectigo.com","ocsp.sectigo.com","crl.usertrust.com","ocsp.usertrust.com","s.symcb.com","s.symcd.com","ghcr.io","registry.hub.docker.com","*.docker.io","*.docker.com","production.cloudflare.docker.com","dl.k8s.io","pkgs.k8s.io","quay.io","mcr.microsoft.com","gcr.io","auth.docker.io","nuget.org","dist.nuget.org","api.nuget.org","nuget.pkg.github.com","dotnet.microsoft.com","pkgs.dev.azure.com","builds.dotnet.microsoft.com","dotnetcli.blob.core.windows.net","nugetregistryv2prod.blob.core.windows.net","azuresearch-usnc.nuget.org","azuresearch-ussc.nuget.org","dc.services.visualstudio.com","dot.net","ci.dot.net","www.microsoft.com","oneocsp.microsoft.com","pub.dev","pub.dartlang.org","*.githubusercontent.com","raw.githubusercontent.com","objects.githubusercontent.com","lfs.github.com","github-cloud.githubusercontent.com","github-cloud.s3.amazonaws.com","codeload.github.com","go.dev","golang.org","proxy.golang.org","sum.golang.org","pkg.go.dev","goproxy.io","releases.hashicorp.com","apt.releases.hashicorp.com","yum.releases.hashicorp.com","registry.terraform.io","haskell.org","*.hackage.haskell.org","get-ghcup.haskell.org","downloads.haskell.org","www.java.com","jdk.java.net","api.adoptium.net","adoptium.net","repo.maven.apache.org","maven.apache.org","repo1.maven.org","maven.pkg.github.com","maven.oracle.com","repo.spring.io","gradle.org","services.gradle.org","plugins.gradle.org","plugins-artifacts.gradle.org","repo.grails.org","download.eclipse.org","download.oracle.com","jcenter.bintray.com","json-schema.org","json.schemastore.org","archive.ubuntu.com","security.ubuntu.com","ppa.launchpad.net","keyserver.ubuntu.com","azure.archive.ubuntu.com","api.snapcraft.io","deb.debian.org","security.debian.org","keyring.debian.org","packages.debian.org","debian.map.fastlydns.net","apt.llvm.org","dl.fedoraproject.org","mirrors.fedoraproject.org","download.fedoraproject.org","mirror.centos.org","vault.centos.org","dl-cdn.alpinelinux.org","pkg.alpinelinux.org","mirror.archlinux.org","archlinux.org","download.opensuse.org","cdn.redhat.com","packagecloud.io","packages.cloud.google.com","packages.microsoft.com","npmjs.org","npmjs.com","registry.npmjs.com","registry.npmjs.org","skimdb.npmjs.com","npm.pkg.github.com","api.npms.io","nodejs.org","yarnpkg.com","registry.yarnpkg.com","repo.yarnpkg.com","deb.nodesource.com","get.pnpm.io","bun.sh","deno.land","registry.bower.io","cpan.org","www.cpan.org","metacpan.org","cpan.metacpan.org","repo.packagist.org","packagist.org","getcomposer.org","playwright.download.prss.microsoft.com","cdn.playwright.dev","pypi.python.org","pypi.org","pip.pypa.io","*.pythonhosted.org","files.pythonhosted.org","bootstrap.pypa.io","conda.binstar.org","conda.anaconda.org","binstar.org","anaconda.org","repo.continuum.io","repo.anaconda.com","rubygems.org","api.rubygems.org","rubygems.pkg.github.com","bundler.rubygems.org","gems.rubyforge.org","gems.rubyonrails.org","index.rubygems.org","cache.ruby-lang.org","*.rvm.io","crates.io","index.crates.io","static.crates.io","sh.rustup.rs","static.rust-lang.org","download.swift.org","swift.org","cocoapods.org","cdn.cocoapods.org"] + + def extract_domain(url_or_query): + """Extract domain from URL or search query.""" + if not url_or_query: + return None + + if url_or_query.startswith(('http://', 'https://')): + return urllib.parse.urlparse(url_or_query).netloc.lower() + + # Check for domain patterns in search queries + match = re.search(r'site:([a-zA-Z0-9.-]+\.[a-zA-Z]{2,})', url_or_query) + if match: + return match.group(1).lower() + + return None + + def is_domain_allowed(domain): + """Check if domain is allowed.""" + if not domain: + # If no domain detected, allow only if not under deny-all policy + return bool(ALLOWED_DOMAINS) # False if empty list (deny-all), True if has domains + + # Empty allowed domains means deny all + if not ALLOWED_DOMAINS: + return False + + for pattern in ALLOWED_DOMAINS: + regex = pattern.replace('.', r'\.').replace('*', '.*') + if re.match(f'^{regex}$', domain): + return True + return False + + # Main logic + try: + data = json.load(sys.stdin) + tool_name = data.get('tool_name', '') + tool_input = data.get('tool_input', {}) + + if tool_name not in ['WebFetch', 'WebSearch']: + sys.exit(0) # Allow other tools + + target = tool_input.get('url') or tool_input.get('query', '') + domain = extract_domain(target) + + # For WebSearch, apply domain restrictions consistently + # If no domain detected in search query, check if restrictions are in place + if tool_name == 'WebSearch' and not domain: + # Since this hook is only generated when network permissions are configured, + # empty ALLOWED_DOMAINS means deny-all policy + if not ALLOWED_DOMAINS: # Empty list means deny all + print(f"Network access blocked: deny-all policy in effect", file=sys.stderr) + print(f"No domains are allowed for WebSearch", file=sys.stderr) + sys.exit(2) # Block under deny-all policy + else: + print(f"Network access blocked for WebSearch: no specific domain detected", file=sys.stderr) + print(f"Allowed domains: {', '.join(ALLOWED_DOMAINS)}", file=sys.stderr) + sys.exit(2) # Block general searches when domain allowlist is configured + + if not is_domain_allowed(domain): + print(f"Network access blocked for domain: {domain}", file=sys.stderr) + print(f"Allowed domains: {', '.join(ALLOWED_DOMAINS)}", file=sys.stderr) + sys.exit(2) # Block with feedback to Claude + + sys.exit(0) # Allow + + except Exception as e: + print(f"Network validation error: {e}", file=sys.stderr) + sys.exit(2) # Block on errors + + EOF + chmod +x .claude/hooks/network_permissions.py + - name: Generate Claude Settings + run: | + cat > .claude/settings.json << 'EOF' + { + "hooks": { + "PreToolUse": [ + { + "matcher": "WebFetch|WebSearch", + "hooks": [ + { + "type": "command", + "command": ".claude/hooks/network_permissions.py" + } + ] + } + ] + } + } + EOF - name: Setup agent output id: setup_agent_output uses: actions/github-script@v7 @@ -395,6 +502,7 @@ jobs: GITHUB_AW_SAFE_OUTPUTS: ${{ env.GITHUB_AW_SAFE_OUTPUTS }} mcp_config: /tmp/mcp-config/mcp-servers.json prompt_file: /tmp/aw-prompts/prompt.txt + settings: .claude/settings.json timeout_minutes: 5 env: GITHUB_AW_SAFE_OUTPUTS: ${{ env.GITHUB_AW_SAFE_OUTPUTS }} diff --git a/.github/workflows/test-codex-command.lock.yml b/.github/workflows/test-codex-command.lock.yml index 61b317b0f1..cfa1d53de9 100644 --- a/.github/workflows/test-codex-command.lock.yml +++ b/.github/workflows/test-codex-command.lock.yml @@ -433,6 +433,113 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v5 + - name: Generate Network Permissions Hook + run: | + mkdir -p .claude/hooks + cat > .claude/hooks/network_permissions.py << 'EOF' + #!/usr/bin/env python3 + """ + Network permissions validator for Claude Code engine. + Generated by gh-aw from engine network permissions configuration. + """ + + import json + import sys + import urllib.parse + import re + + # Domain whitelist (populated during generation) + ALLOWED_DOMAINS = ["crl3.digicert.com","crl4.digicert.com","ocsp.digicert.com","ts-crl.ws.symantec.com","ts-ocsp.ws.symantec.com","crl.geotrust.com","ocsp.geotrust.com","crl.thawte.com","ocsp.thawte.com","crl.verisign.com","ocsp.verisign.com","crl.globalsign.com","ocsp.globalsign.com","crls.ssl.com","ocsp.ssl.com","crl.identrust.com","ocsp.identrust.com","crl.sectigo.com","ocsp.sectigo.com","crl.usertrust.com","ocsp.usertrust.com","s.symcb.com","s.symcd.com","ghcr.io","registry.hub.docker.com","*.docker.io","*.docker.com","production.cloudflare.docker.com","dl.k8s.io","pkgs.k8s.io","quay.io","mcr.microsoft.com","gcr.io","auth.docker.io","nuget.org","dist.nuget.org","api.nuget.org","nuget.pkg.github.com","dotnet.microsoft.com","pkgs.dev.azure.com","builds.dotnet.microsoft.com","dotnetcli.blob.core.windows.net","nugetregistryv2prod.blob.core.windows.net","azuresearch-usnc.nuget.org","azuresearch-ussc.nuget.org","dc.services.visualstudio.com","dot.net","ci.dot.net","www.microsoft.com","oneocsp.microsoft.com","pub.dev","pub.dartlang.org","*.githubusercontent.com","raw.githubusercontent.com","objects.githubusercontent.com","lfs.github.com","github-cloud.githubusercontent.com","github-cloud.s3.amazonaws.com","codeload.github.com","go.dev","golang.org","proxy.golang.org","sum.golang.org","pkg.go.dev","goproxy.io","releases.hashicorp.com","apt.releases.hashicorp.com","yum.releases.hashicorp.com","registry.terraform.io","haskell.org","*.hackage.haskell.org","get-ghcup.haskell.org","downloads.haskell.org","www.java.com","jdk.java.net","api.adoptium.net","adoptium.net","repo.maven.apache.org","maven.apache.org","repo1.maven.org","maven.pkg.github.com","maven.oracle.com","repo.spring.io","gradle.org","services.gradle.org","plugins.gradle.org","plugins-artifacts.gradle.org","repo.grails.org","download.eclipse.org","download.oracle.com","jcenter.bintray.com","json-schema.org","json.schemastore.org","archive.ubuntu.com","security.ubuntu.com","ppa.launchpad.net","keyserver.ubuntu.com","azure.archive.ubuntu.com","api.snapcraft.io","deb.debian.org","security.debian.org","keyring.debian.org","packages.debian.org","debian.map.fastlydns.net","apt.llvm.org","dl.fedoraproject.org","mirrors.fedoraproject.org","download.fedoraproject.org","mirror.centos.org","vault.centos.org","dl-cdn.alpinelinux.org","pkg.alpinelinux.org","mirror.archlinux.org","archlinux.org","download.opensuse.org","cdn.redhat.com","packagecloud.io","packages.cloud.google.com","packages.microsoft.com","npmjs.org","npmjs.com","registry.npmjs.com","registry.npmjs.org","skimdb.npmjs.com","npm.pkg.github.com","api.npms.io","nodejs.org","yarnpkg.com","registry.yarnpkg.com","repo.yarnpkg.com","deb.nodesource.com","get.pnpm.io","bun.sh","deno.land","registry.bower.io","cpan.org","www.cpan.org","metacpan.org","cpan.metacpan.org","repo.packagist.org","packagist.org","getcomposer.org","playwright.download.prss.microsoft.com","cdn.playwright.dev","pypi.python.org","pypi.org","pip.pypa.io","*.pythonhosted.org","files.pythonhosted.org","bootstrap.pypa.io","conda.binstar.org","conda.anaconda.org","binstar.org","anaconda.org","repo.continuum.io","repo.anaconda.com","rubygems.org","api.rubygems.org","rubygems.pkg.github.com","bundler.rubygems.org","gems.rubyforge.org","gems.rubyonrails.org","index.rubygems.org","cache.ruby-lang.org","*.rvm.io","crates.io","index.crates.io","static.crates.io","sh.rustup.rs","static.rust-lang.org","download.swift.org","swift.org","cocoapods.org","cdn.cocoapods.org"] + + def extract_domain(url_or_query): + """Extract domain from URL or search query.""" + if not url_or_query: + return None + + if url_or_query.startswith(('http://', 'https://')): + return urllib.parse.urlparse(url_or_query).netloc.lower() + + # Check for domain patterns in search queries + match = re.search(r'site:([a-zA-Z0-9.-]+\.[a-zA-Z]{2,})', url_or_query) + if match: + return match.group(1).lower() + + return None + + def is_domain_allowed(domain): + """Check if domain is allowed.""" + if not domain: + # If no domain detected, allow only if not under deny-all policy + return bool(ALLOWED_DOMAINS) # False if empty list (deny-all), True if has domains + + # Empty allowed domains means deny all + if not ALLOWED_DOMAINS: + return False + + for pattern in ALLOWED_DOMAINS: + regex = pattern.replace('.', r'\.').replace('*', '.*') + if re.match(f'^{regex}$', domain): + return True + return False + + # Main logic + try: + data = json.load(sys.stdin) + tool_name = data.get('tool_name', '') + tool_input = data.get('tool_input', {}) + + if tool_name not in ['WebFetch', 'WebSearch']: + sys.exit(0) # Allow other tools + + target = tool_input.get('url') or tool_input.get('query', '') + domain = extract_domain(target) + + # For WebSearch, apply domain restrictions consistently + # If no domain detected in search query, check if restrictions are in place + if tool_name == 'WebSearch' and not domain: + # Since this hook is only generated when network permissions are configured, + # empty ALLOWED_DOMAINS means deny-all policy + if not ALLOWED_DOMAINS: # Empty list means deny all + print(f"Network access blocked: deny-all policy in effect", file=sys.stderr) + print(f"No domains are allowed for WebSearch", file=sys.stderr) + sys.exit(2) # Block under deny-all policy + else: + print(f"Network access blocked for WebSearch: no specific domain detected", file=sys.stderr) + print(f"Allowed domains: {', '.join(ALLOWED_DOMAINS)}", file=sys.stderr) + sys.exit(2) # Block general searches when domain allowlist is configured + + if not is_domain_allowed(domain): + print(f"Network access blocked for domain: {domain}", file=sys.stderr) + print(f"Allowed domains: {', '.join(ALLOWED_DOMAINS)}", file=sys.stderr) + sys.exit(2) # Block with feedback to Claude + + sys.exit(0) # Allow + + except Exception as e: + print(f"Network validation error: {e}", file=sys.stderr) + sys.exit(2) # Block on errors + + EOF + chmod +x .claude/hooks/network_permissions.py + - name: Generate Claude Settings + run: | + cat > .claude/settings.json << 'EOF' + { + "hooks": { + "PreToolUse": [ + { + "matcher": "WebFetch|WebSearch", + "hooks": [ + { + "type": "command", + "command": ".claude/hooks/network_permissions.py" + } + ] + } + ] + } + } + EOF - name: Setup agent output id: setup_agent_output uses: actions/github-script@v7 @@ -630,6 +737,7 @@ jobs: GITHUB_AW_SAFE_OUTPUTS: ${{ env.GITHUB_AW_SAFE_OUTPUTS }} mcp_config: /tmp/mcp-config/mcp-servers.json prompt_file: /tmp/aw-prompts/prompt.txt + settings: .claude/settings.json timeout_minutes: 5 env: GITHUB_AW_SAFE_OUTPUTS: ${{ env.GITHUB_AW_SAFE_OUTPUTS }} diff --git a/.github/workflows/test-proxy.lock.yml b/.github/workflows/test-proxy.lock.yml index 6833a5b384..77899a4740 100644 --- a/.github/workflows/test-proxy.lock.yml +++ b/.github/workflows/test-proxy.lock.yml @@ -26,6 +26,113 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v5 + - name: Generate Network Permissions Hook + run: | + mkdir -p .claude/hooks + cat > .claude/hooks/network_permissions.py << 'EOF' + #!/usr/bin/env python3 + """ + Network permissions validator for Claude Code engine. + Generated by gh-aw from engine network permissions configuration. + """ + + import json + import sys + import urllib.parse + import re + + # Domain whitelist (populated during generation) + ALLOWED_DOMAINS = ["crl3.digicert.com","crl4.digicert.com","ocsp.digicert.com","ts-crl.ws.symantec.com","ts-ocsp.ws.symantec.com","crl.geotrust.com","ocsp.geotrust.com","crl.thawte.com","ocsp.thawte.com","crl.verisign.com","ocsp.verisign.com","crl.globalsign.com","ocsp.globalsign.com","crls.ssl.com","ocsp.ssl.com","crl.identrust.com","ocsp.identrust.com","crl.sectigo.com","ocsp.sectigo.com","crl.usertrust.com","ocsp.usertrust.com","s.symcb.com","s.symcd.com","ghcr.io","registry.hub.docker.com","*.docker.io","*.docker.com","production.cloudflare.docker.com","dl.k8s.io","pkgs.k8s.io","quay.io","mcr.microsoft.com","gcr.io","auth.docker.io","nuget.org","dist.nuget.org","api.nuget.org","nuget.pkg.github.com","dotnet.microsoft.com","pkgs.dev.azure.com","builds.dotnet.microsoft.com","dotnetcli.blob.core.windows.net","nugetregistryv2prod.blob.core.windows.net","azuresearch-usnc.nuget.org","azuresearch-ussc.nuget.org","dc.services.visualstudio.com","dot.net","ci.dot.net","www.microsoft.com","oneocsp.microsoft.com","pub.dev","pub.dartlang.org","*.githubusercontent.com","raw.githubusercontent.com","objects.githubusercontent.com","lfs.github.com","github-cloud.githubusercontent.com","github-cloud.s3.amazonaws.com","codeload.github.com","go.dev","golang.org","proxy.golang.org","sum.golang.org","pkg.go.dev","goproxy.io","releases.hashicorp.com","apt.releases.hashicorp.com","yum.releases.hashicorp.com","registry.terraform.io","haskell.org","*.hackage.haskell.org","get-ghcup.haskell.org","downloads.haskell.org","www.java.com","jdk.java.net","api.adoptium.net","adoptium.net","repo.maven.apache.org","maven.apache.org","repo1.maven.org","maven.pkg.github.com","maven.oracle.com","repo.spring.io","gradle.org","services.gradle.org","plugins.gradle.org","plugins-artifacts.gradle.org","repo.grails.org","download.eclipse.org","download.oracle.com","jcenter.bintray.com","json-schema.org","json.schemastore.org","archive.ubuntu.com","security.ubuntu.com","ppa.launchpad.net","keyserver.ubuntu.com","azure.archive.ubuntu.com","api.snapcraft.io","deb.debian.org","security.debian.org","keyring.debian.org","packages.debian.org","debian.map.fastlydns.net","apt.llvm.org","dl.fedoraproject.org","mirrors.fedoraproject.org","download.fedoraproject.org","mirror.centos.org","vault.centos.org","dl-cdn.alpinelinux.org","pkg.alpinelinux.org","mirror.archlinux.org","archlinux.org","download.opensuse.org","cdn.redhat.com","packagecloud.io","packages.cloud.google.com","packages.microsoft.com","npmjs.org","npmjs.com","registry.npmjs.com","registry.npmjs.org","skimdb.npmjs.com","npm.pkg.github.com","api.npms.io","nodejs.org","yarnpkg.com","registry.yarnpkg.com","repo.yarnpkg.com","deb.nodesource.com","get.pnpm.io","bun.sh","deno.land","registry.bower.io","cpan.org","www.cpan.org","metacpan.org","cpan.metacpan.org","repo.packagist.org","packagist.org","getcomposer.org","playwright.download.prss.microsoft.com","cdn.playwright.dev","pypi.python.org","pypi.org","pip.pypa.io","*.pythonhosted.org","files.pythonhosted.org","bootstrap.pypa.io","conda.binstar.org","conda.anaconda.org","binstar.org","anaconda.org","repo.continuum.io","repo.anaconda.com","rubygems.org","api.rubygems.org","rubygems.pkg.github.com","bundler.rubygems.org","gems.rubyforge.org","gems.rubyonrails.org","index.rubygems.org","cache.ruby-lang.org","*.rvm.io","crates.io","index.crates.io","static.crates.io","sh.rustup.rs","static.rust-lang.org","download.swift.org","swift.org","cocoapods.org","cdn.cocoapods.org"] + + def extract_domain(url_or_query): + """Extract domain from URL or search query.""" + if not url_or_query: + return None + + if url_or_query.startswith(('http://', 'https://')): + return urllib.parse.urlparse(url_or_query).netloc.lower() + + # Check for domain patterns in search queries + match = re.search(r'site:([a-zA-Z0-9.-]+\.[a-zA-Z]{2,})', url_or_query) + if match: + return match.group(1).lower() + + return None + + def is_domain_allowed(domain): + """Check if domain is allowed.""" + if not domain: + # If no domain detected, allow only if not under deny-all policy + return bool(ALLOWED_DOMAINS) # False if empty list (deny-all), True if has domains + + # Empty allowed domains means deny all + if not ALLOWED_DOMAINS: + return False + + for pattern in ALLOWED_DOMAINS: + regex = pattern.replace('.', r'\.').replace('*', '.*') + if re.match(f'^{regex}$', domain): + return True + return False + + # Main logic + try: + data = json.load(sys.stdin) + tool_name = data.get('tool_name', '') + tool_input = data.get('tool_input', {}) + + if tool_name not in ['WebFetch', 'WebSearch']: + sys.exit(0) # Allow other tools + + target = tool_input.get('url') or tool_input.get('query', '') + domain = extract_domain(target) + + # For WebSearch, apply domain restrictions consistently + # If no domain detected in search query, check if restrictions are in place + if tool_name == 'WebSearch' and not domain: + # Since this hook is only generated when network permissions are configured, + # empty ALLOWED_DOMAINS means deny-all policy + if not ALLOWED_DOMAINS: # Empty list means deny all + print(f"Network access blocked: deny-all policy in effect", file=sys.stderr) + print(f"No domains are allowed for WebSearch", file=sys.stderr) + sys.exit(2) # Block under deny-all policy + else: + print(f"Network access blocked for WebSearch: no specific domain detected", file=sys.stderr) + print(f"Allowed domains: {', '.join(ALLOWED_DOMAINS)}", file=sys.stderr) + sys.exit(2) # Block general searches when domain allowlist is configured + + if not is_domain_allowed(domain): + print(f"Network access blocked for domain: {domain}", file=sys.stderr) + print(f"Allowed domains: {', '.join(ALLOWED_DOMAINS)}", file=sys.stderr) + sys.exit(2) # Block with feedback to Claude + + sys.exit(0) # Allow + + except Exception as e: + print(f"Network validation error: {e}", file=sys.stderr) + sys.exit(2) # Block on errors + + EOF + chmod +x .claude/hooks/network_permissions.py + - name: Generate Claude Settings + run: | + cat > .claude/settings.json << 'EOF' + { + "hooks": { + "PreToolUse": [ + { + "matcher": "WebFetch|WebSearch", + "hooks": [ + { + "type": "command", + "command": ".claude/hooks/network_permissions.py" + } + ] + } + ] + } + } + EOF - name: Setup agent output id: setup_agent_output uses: actions/github-script@v7 @@ -392,6 +499,7 @@ jobs: GITHUB_AW_SAFE_OUTPUTS: ${{ env.GITHUB_AW_SAFE_OUTPUTS }} mcp_config: /tmp/mcp-config/mcp-servers.json prompt_file: /tmp/aw-prompts/prompt.txt + settings: .claude/settings.json timeout_minutes: 5 env: GITHUB_AW_SAFE_OUTPUTS: ${{ env.GITHUB_AW_SAFE_OUTPUTS }} diff --git a/docs/frontmatter.md b/docs/frontmatter.md index e71eeff659..ba74dca817 100644 --- a/docs/frontmatter.md +++ b/docs/frontmatter.md @@ -18,11 +18,11 @@ The YAML frontmatter supports standard GitHub Actions properties plus additional - `steps`: Custom steps for the job **Properties specific to GitHub Agentic Workflows:** -- `engine`: AI engine configuration (claude/codex) with optional max-turns setting and network permissions +- `engine`: AI engine configuration (claude/codex) with optional max-turns setting +- `network`: Network access control for AI engines (supports `defaults`, `{}`, or `{ allowed: [...] }`) - `tools`: Available tools and MCP servers for the AI engine - `cache`: Cache configuration for workflow dependencies - `safe-outputs`: [Safe Output Processing](safe-outputs.md) for automatic issue creation and comment posting. -- `strict`: Enable strict mode to enforce deny-by-default permissions for engine and MCP servers ## Trigger Events (`on:`) @@ -164,11 +164,6 @@ engine: version: beta # Optional: version of the action model: claude-3-5-sonnet-20241022 # Optional: specific LLM model max-turns: 5 # Optional: maximum chat iterations per run - permissions: # Optional: engine-level permissions (only Claude is supported) - network: # Network access control - allowed: # List of allowed domains - - "api.example.com" - - "*.trusted.com" ``` **Fields:** @@ -176,9 +171,6 @@ engine: - **`version`** (optional): Action version (`beta`, `stable`) - **`model`** (optional): Specific LLM model to use - **`max-turns`** (optional): Maximum number of chat iterations per run (cost-control option) -- **`permissions`** (optional): Engine-level permissions - - **`network`** (optional): Network access control - - **`allowed`** (optional): List of allowed domains for WebFetch and WebSearch **Model Defaults:** - **Claude**: Uses the default model from the claude-code-base-action (typically latest Claude model) @@ -202,25 +194,42 @@ engine: 3. Helps prevent runaway chat loops and control costs 4. Only applies to engines that support turn limiting (currently Claude) -## Engine Network Permissions +## Network Permissions (`network:`) > This is only supported by the claude engine today. -Control network access for AI engines using the `permissions` field in the `engine` block: +Control network access for AI engines using the top-level `network` field. If no `network:` permission is specified, it defaults to `network: defaults` which uses a curated whitelist of common development and package manager domains. + +### Supported Formats ```yaml +# Default whitelist (curated list of development domains) +engine: + id: claude + +network: defaults + +# Or allow specific domains only engine: id: claude - permissions: - network: - allowed: - - "api.example.com" # Exact domain match - - "*.trusted.com" # Wildcard matches any subdomain (including nested subdomains) + +network: + allowed: + - "api.example.com" # Exact domain match + - "*.trusted.com" # Wildcard matches any subdomain (including nested subdomains) + +# Or deny all network access (empty object) +engine: + id: claude + +network: {} ``` ### Security Model -- **Deny by Default**: When network permissions are specified, only listed domains are accessible +- **Default Whitelist**: When no network permissions are specified or `network: defaults` is used, access is restricted to a curated whitelist of common development domains (package managers, container registries, etc.) +- **Selective Access**: When `network: { allowed: [...] }` is specified, only listed domains are accessible +- **No Access**: When `network: {}` is specified, all network access is denied - **Engine vs Tools**: Engine permissions control the AI engine itself, separate from MCP tool permissions - **Hook Enforcement**: Uses Claude Code's hook system for runtime network access control - **Domain Validation**: Supports exact matches and wildcard patterns (`*` matches any characters including dots, allowing nested subdomains) @@ -228,114 +237,92 @@ engine: ### Examples ```yaml +# Default whitelist (common development domains like npmjs.org, pypi.org, etc.) +engine: + id: claude + +network: defaults + # Allow specific APIs only engine: id: claude - permissions: - network: - allowed: - - "api.github.com" - - "httpbin.org" + +network: + allowed: + - "api.github.com" + - "httpbin.org" # Allow all subdomains of a trusted domain # Note: "*.github.com" matches api.github.com, subdomain.github.com, and even nested.api.github.com engine: id: claude - permissions: - network: - allowed: - - "*.company-internal.com" - - "public-api.service.com" -# Deny all network access (empty list) +network: + allowed: + - "*.company-internal.com" + - "public-api.service.com" + +# Deny all network access (empty object) engine: id: claude - permissions: - network: - allowed: [] + +network: {} ``` +### Default Whitelist Domains + +The `network: defaults` mode includes access to these categories of domains: +- **Package Managers**: npmjs.org, pypi.org, rubygems.org, crates.io, nuget.org, etc. +- **Container Registries**: docker.io, ghcr.io, quay.io, mcr.microsoft.com, etc. +- **Development Tools**: github.com domains, golang.org, maven.apache.org, etc. +- **Certificate Authorities**: Various OCSP and CRL endpoints for certificate validation +- **Language-specific Repositories**: For Go, Python, Node.js, Java, .NET, Rust, etc. + +### Migration from Previous Versions + +The previous `strict:` mode has been removed. Network permissions now work as follows: +- **No `network:` field**: Defaults to `network: defaults` (curated whitelist) +- **`network: defaults`**: Curated whitelist of development domains +- **`network: {}`**: No network access +- **`network: { allowed: [...] }`**: Restricted to listed domains only + + ### Permission Modes -1. **No network permissions**: Unrestricted access (backwards compatible) +1. **Default whitelist**: Curated list of development domains (default when no `network:` field specified) ```yaml engine: id: claude - # No permissions block - full network access + # No network block - defaults to curated whitelist ``` -2. **Empty allowed list**: Complete network access denial +2. **Explicit default whitelist**: Curated list of development domains (explicit) ```yaml engine: id: claude - permissions: - network: - allowed: [] # Deny all network access + + network: defaults # Curated whitelist of development domains ``` -3. **Specific domains**: Granular access control to listed domains only +3. **No network access**: Complete network access denial ```yaml engine: id: claude - permissions: - network: - allowed: - - "trusted-api.com" - - "*.safe-domain.org" - ``` - -## Strict Mode (`strict:`) - -Strict mode enforces deny-by-default permissions for both engine and MCP servers even when no explicit permissions are configured. This provides a zero-trust security model that adheres to security best practices. - -```yaml -strict: true # Enable strict mode (default: false) -``` -### Behavior - -When strict mode is enabled: - -1. **No explicit network permissions**: Automatically enforces deny-all policy - ```yaml - strict: true - engine: claude - # No engine.permissions.network specified - # Result: All network access is denied (same as empty allowed list) + network: {} # Deny all network access ``` -2. **Explicit network permissions**: Uses the specified permissions normally +4. **Specific domains**: Granular access control to listed domains only ```yaml - strict: true engine: id: claude - permissions: - network: - allowed: ["api.github.com"] - # Result: Only api.github.com is accessible - ``` -3. **Strict mode disabled**: Maintains backwards-compatible behavior - ```yaml - strict: false # or omitted entirely - engine: claude - # No engine.permissions.network specified - # Result: Unrestricted network access (backwards compatible) + network: + allowed: + - "trusted-api.com" + - "*.safe-domain.org" ``` -### Use Cases - -- **Security-first workflows**: When you want to ensure no accidental network access -- **Compliance requirements**: For environments requiring deny-by-default policies -- **Zero-trust environments**: When explicit permissions should always be required -- **Migration assistance**: Gradually migrate existing workflows to explicit permissions - -### Compatibility - -- Only applies to engines that support network permissions (currently Claude) -- Non-Claude engines ignore strict mode setting -- Backwards compatible when `strict: false` or omitted - ## Safe Outputs Configuration (`safe-outputs:`) See [Safe Outputs Processing](safe-outputs.md) for automatic issue creation, comment posting and other safe outputs. diff --git a/docs/security-notes.md b/docs/security-notes.md index f09c02f430..37cff597fe 100644 --- a/docs/security-notes.md +++ b/docs/security-notes.md @@ -246,27 +246,27 @@ Engine network permissions provide fine-grained control over network access for ```yaml engine: id: claude - # No permissions block - full network access + # No network block - full network access ``` 2. **Empty allowed list**: Complete network access denial ```yaml engine: id: claude - permissions: - network: - allowed: [] # Deny all network access + + network: + allowed: [] # Deny all network access ``` 3. **Specific domains**: Granular access control to listed domains only ```yaml engine: id: claude - permissions: - network: - allowed: - - "api.github.com" - - "*.company-internal.com" + + network: + allowed: + - "api.github.com" + - "*.company-internal.com" ``` ## Engine Security Notes diff --git a/pkg/cli/templates/instructions.md b/pkg/cli/templates/instructions.md index e6046d329b..10605d6582 100644 --- a/pkg/cli/templates/instructions.md +++ b/pkg/cli/templates/instructions.md @@ -69,17 +69,18 @@ The YAML frontmatter supports these fields: version: beta # Optional: version of the action model: claude-3-5-sonnet-20241022 # Optional: LLM model to use max-turns: 5 # Optional: maximum chat iterations per run - permissions: # Optional: engine-level permissions - network: # Network access control for Claude Code - allowed: # List of allowed domains - - "example.com" - - "*.trusted-domain.com" ``` - -- **`strict:`** - Enable strict mode for deny-by-default permissions (boolean, default: false) - ```yaml - strict: true # Enforce deny-all network permissions when no explicit permissions set - ``` + +- **`network:`** - Network access control for Claude Code engine (top-level field) + - String format: `"defaults"` (curated whitelist of development domains) + - Empty object format: `{}` (no network access) + - Object format for custom permissions: + ```yaml + network: + allowed: + - "example.com" + - "*.trusted-domain.com" + ``` - **`tools:`** - Tool configuration for coding agent - `github:` - GitHub API tools @@ -351,37 +352,38 @@ tools: ### Engine Network Permissions -Control network access for the Claude Code engine itself (not MCP tools): +Control network access for the Claude Code engine using the top-level `network:` field. If no `network:` permission is specified, it defaults to `network: defaults` which uses a curated whitelist of common development and package manager domains. ```yaml engine: id: claude - permissions: - network: - allowed: - - "api.github.com" - - "*.trusted-domain.com" - - "example.com" + +# Default whitelist (curated list of development domains) +network: defaults + +# Or allow specific domains only +network: + allowed: + - "api.github.com" + - "*.trusted-domain.com" + - "example.com" + +# Or deny all network access +network: {} ``` **Important Notes:** - Network permissions apply to Claude Code's WebFetch and WebSearch tools -- When permissions are specified, deny-by-default policy is enforced +- Uses top-level `network:` field (not nested under engine permissions) +- When custom permissions are specified with `allowed:` list, deny-by-default policy is enforced - Supports exact domain matches and wildcard patterns (where `*` matches any characters, including nested subdomains) - Currently supported for Claude engine only (Codex support planned) - Uses Claude Code hooks for enforcement, not network proxies -**Three Permission Modes:** -1. **No network permissions**: Unrestricted access (backwards compatible) -2. **Empty allowed list**: Complete network access denial - ```yaml - engine: - id: claude - permissions: - network: - allowed: [] # Deny all network access - ``` -3. **Specific domains**: Granular access control to listed domains only +**Permission Modes:** +1. **Default whitelist**: `network: defaults` or no `network:` field (curated development domains) +2. **No network access**: `network: {}` (deny all) +3. **Specific domains**: `network: { allowed: [...] }` (granular access control) ## @include Directive System diff --git a/pkg/parser/schema_test.go b/pkg/parser/schema_test.go index f94100c4e9..ff123c757f 100644 --- a/pkg/parser/schema_test.go +++ b/pkg/parser/schema_test.go @@ -475,79 +475,79 @@ func TestValidateMainWorkflowFrontmatterWithSchema(t *testing.T) { errContains: "additional properties 'invalid_prop' not allowed", }, { - name: "valid strict mode true", + name: "valid claude engine with network permissions", frontmatter: map[string]any{ - "on": "push", - "strict": true, + "on": "push", + "engine": map[string]any{ + "id": "claude", + }, }, wantErr: false, }, { - name: "valid strict mode false", + name: "valid codex engine without permissions", frontmatter: map[string]any{ - "on": "push", - "strict": false, + "on": "push", + "engine": map[string]any{ + "id": "codex", + "model": "gpt-4o", + }, }, wantErr: false, }, { - name: "invalid strict mode as string", + name: "valid codex string engine (no permissions possible)", frontmatter: map[string]any{ "on": "push", - "strict": "true", + "engine": "codex", }, - wantErr: true, - errContains: "want boolean", + wantErr: false, }, { - name: "valid claude engine with network permissions", + name: "valid network defaults", frontmatter: map[string]any{ - "on": "push", - "engine": map[string]any{ - "id": "claude", - "permissions": map[string]any{ - "network": map[string]any{ - "allowed": []string{"example.com", "*.trusted.com"}, - }, - }, - }, + "on": "push", + "network": "defaults", }, wantErr: false, }, { - name: "invalid codex engine with permissions", + name: "valid network empty object", frontmatter: map[string]any{ - "on": "push", - "engine": map[string]any{ - "id": "codex", - "permissions": map[string]any{ - "network": map[string]any{ - "allowed": []string{"example.com"}, - }, - }, - }, + "on": "push", + "network": map[string]any{}, }, - wantErr: true, - errContains: "engine permissions are not supported for codex engine", + wantErr: false, }, { - name: "valid codex engine without permissions", + name: "valid network with allowed domains", frontmatter: map[string]any{ "on": "push", - "engine": map[string]any{ - "id": "codex", - "model": "gpt-4o", + "network": map[string]any{ + "allowed": []string{"example.com", "*.trusted.com"}, }, }, wantErr: false, }, { - name: "valid codex string engine (no permissions possible)", + name: "invalid network string (not defaults)", frontmatter: map[string]any{ - "on": "push", - "engine": "codex", + "on": "push", + "network": "invalid", }, - wantErr: false, + wantErr: true, + errContains: "oneOf", + }, + { + name: "invalid network object with unknown property", + frontmatter: map[string]any{ + "on": "push", + "network": map[string]any{ + "invalid": []string{"example.com"}, + }, + }, + wantErr: true, + errContains: "additional properties 'invalid' not allowed", }, } diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index c4452bb0a9..b873cd571a 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -595,6 +595,31 @@ } ] }, + "network": { + "description": "Network access control configuration", + "oneOf": [ + { + "type": "string", + "enum": ["defaults"], + "description": "Use default network permissions (currently full network access, will change later)" + }, + { + "type": "object", + "description": "Custom network access configuration", + "properties": { + "allowed": { + "type": "array", + "description": "List of allowed domains for network access", + "items": { + "type": "string", + "description": "Domain name (supports wildcards with * prefix)" + } + } + }, + "additionalProperties": false + } + ] + }, "if": { "type": "string", "description": "Conditional execution expression" @@ -673,29 +698,6 @@ "max-turns": { "type": "integer", "description": "Maximum number of chat iterations per run" - }, - "permissions": { - "type": "object", - "description": "Engine-level permissions configuration", - "properties": { - "network": { - "type": "object", - "description": "Network access control for the engine", - "properties": { - "allowed": { - "type": "array", - "description": "List of allowed domains for network access", - "items": { - "type": "string", - "description": "Domain name (supports wildcards with * prefix)" - } - } - }, - "required": ["allowed"], - "additionalProperties": false - } - }, - "additionalProperties": false } }, "required": ["id"], @@ -972,10 +974,6 @@ } ] }, - "strict": { - "type": "boolean", - "description": "Enable strict mode to enforce deny-by-default permissions for engine and MCP servers even when permissions are not explicitly set" - }, "safe-outputs": { "type": "object", "description": "Output configuration for automatic safe outputs", diff --git a/pkg/workflow/agentic_engine.go b/pkg/workflow/agentic_engine.go index 89a45bd0c2..c95c435708 100644 --- a/pkg/workflow/agentic_engine.go +++ b/pkg/workflow/agentic_engine.go @@ -37,10 +37,10 @@ type AgenticEngine interface { GetDeclaredOutputFiles() []string // GetInstallationSteps returns the GitHub Actions steps needed to install this engine - GetInstallationSteps(engineConfig *EngineConfig) []GitHubActionStep + GetInstallationSteps(engineConfig *EngineConfig, networkPermissions *NetworkPermissions) []GitHubActionStep // GetExecutionConfig returns the configuration for executing this engine - GetExecutionConfig(workflowName string, logFile string, engineConfig *EngineConfig, hasOutput bool) ExecutionConfig + GetExecutionConfig(workflowName string, logFile string, engineConfig *EngineConfig, networkPermissions *NetworkPermissions, hasOutput bool) ExecutionConfig // RenderMCPConfig renders the MCP configuration for this engine to the given YAML builder RenderMCPConfig(yaml *strings.Builder, tools map[string]any, mcpTools []string) diff --git a/pkg/workflow/claude_engine.go b/pkg/workflow/claude_engine.go index 000ba25cb5..a4cfc8f9bc 100644 --- a/pkg/workflow/claude_engine.go +++ b/pkg/workflow/claude_engine.go @@ -30,16 +30,16 @@ func NewClaudeEngine() *ClaudeEngine { } } -func (e *ClaudeEngine) GetInstallationSteps(engineConfig *EngineConfig) []GitHubActionStep { +func (e *ClaudeEngine) GetInstallationSteps(engineConfig *EngineConfig, networkPermissions *NetworkPermissions) []GitHubActionStep { var steps []GitHubActionStep - // Check if network permissions are configured - if ShouldEnforceNetworkPermissions(engineConfig) { + // Check if network permissions are configured (only for Claude engine) + if engineConfig != nil && engineConfig.ID == "claude" && ShouldEnforceNetworkPermissions(networkPermissions) { // Generate network hook generator and settings generator hookGenerator := &NetworkHookGenerator{} settingsGenerator := &ClaudeSettingsGenerator{} - allowedDomains := GetAllowedDomains(engineConfig) + allowedDomains := GetAllowedDomains(networkPermissions) // Add hook generation step hookStep := hookGenerator.GenerateNetworkHookWorkflowStep(allowedDomains) @@ -58,7 +58,7 @@ func (e *ClaudeEngine) GetDeclaredOutputFiles() []string { return []string{"output.txt"} } -func (e *ClaudeEngine) GetExecutionConfig(workflowName string, logFile string, engineConfig *EngineConfig, hasOutput bool) ExecutionConfig { +func (e *ClaudeEngine) GetExecutionConfig(workflowName string, logFile string, engineConfig *EngineConfig, networkPermissions *NetworkPermissions, hasOutput bool) ExecutionConfig { // Determine the action version to use actionVersion := DefaultClaudeActionVersion // Default version if engineConfig != nil && engineConfig.Version != "" { @@ -94,7 +94,7 @@ func (e *ClaudeEngine) GetExecutionConfig(workflowName string, logFile string, e } // Add settings parameter if network permissions are configured - if ShouldEnforceNetworkPermissions(engineConfig) { + if engineConfig != nil && engineConfig.ID == "claude" && ShouldEnforceNetworkPermissions(networkPermissions) { config.Inputs["settings"] = ".claude/settings.json" } diff --git a/pkg/workflow/claude_engine_network_test.go b/pkg/workflow/claude_engine_network_test.go index f3bced7d4a..1b14a8f781 100644 --- a/pkg/workflow/claude_engine_network_test.go +++ b/pkg/workflow/claude_engine_network_test.go @@ -14,7 +14,7 @@ func TestClaudeEngineNetworkPermissions(t *testing.T) { Model: "claude-3-5-sonnet-20241022", } - steps := engine.GetInstallationSteps(config) + steps := engine.GetInstallationSteps(config, nil) if len(steps) != 0 { t.Errorf("Expected 0 installation steps without network permissions, got %d", len(steps)) } @@ -24,14 +24,13 @@ func TestClaudeEngineNetworkPermissions(t *testing.T) { config := &EngineConfig{ ID: "claude", Model: "claude-3-5-sonnet-20241022", - Permissions: &EnginePermissions{ - Network: &NetworkPermissions{ - Allowed: []string{"example.com", "*.trusted.com"}, - }, - }, } - steps := engine.GetInstallationSteps(config) + networkPermissions := &NetworkPermissions{ + Allowed: []string{"example.com", "*.trusted.com"}, + } + + steps := engine.GetInstallationSteps(config, networkPermissions) if len(steps) != 2 { t.Errorf("Expected 2 installation steps with network permissions, got %d", len(steps)) } @@ -59,9 +58,6 @@ func TestClaudeEngineNetworkPermissions(t *testing.T) { if !strings.Contains(settingsStepStr, ".claude/settings.json") { t.Error("Second step should create settings file") } - if !strings.Contains(settingsStepStr, "WebFetch|WebSearch") { - t.Error("Settings should match WebFetch and WebSearch tools") - } }) t.Run("ExecutionConfig without network permissions", func(t *testing.T) { @@ -70,7 +66,7 @@ func TestClaudeEngineNetworkPermissions(t *testing.T) { Model: "claude-3-5-sonnet-20241022", } - execConfig := engine.GetExecutionConfig("test-workflow", "test-log", config, false) + execConfig := engine.GetExecutionConfig("test-workflow", "test-log", config, nil, false) // Verify settings parameter is not present if settings, exists := execConfig.Inputs["settings"]; exists { @@ -87,86 +83,62 @@ func TestClaudeEngineNetworkPermissions(t *testing.T) { config := &EngineConfig{ ID: "claude", Model: "claude-3-5-sonnet-20241022", - Permissions: &EnginePermissions{ - Network: &NetworkPermissions{ - Allowed: []string{"example.com"}, - }, - }, } - execConfig := engine.GetExecutionConfig("test-workflow", "test-log", config, false) + networkPermissions := &NetworkPermissions{ + Allowed: []string{"example.com"}, + } + + execConfig := engine.GetExecutionConfig("test-workflow", "test-log", config, networkPermissions, false) // Verify settings parameter is present if settings, exists := execConfig.Inputs["settings"]; !exists { t.Error("Settings parameter should be present with network permissions") } else if settings != ".claude/settings.json" { - t.Errorf("Expected settings '.claude/settings.json', got '%s'", settings) + t.Errorf("Expected settings parameter '.claude/settings.json', got '%s'", settings) } // Verify other inputs are still correct if execConfig.Inputs["model"] != "claude-3-5-sonnet-20241022" { t.Errorf("Expected model 'claude-3-5-sonnet-20241022', got '%s'", execConfig.Inputs["model"]) } - - // Verify other expected inputs are present (except claude_env when hasOutput=false for security) - expectedInputs := []string{"prompt_file", "anthropic_api_key", "mcp_config", "allowed_tools", "timeout_minutes", "max_turns"} - for _, input := range expectedInputs { - if _, exists := execConfig.Inputs[input]; !exists { - t.Errorf("Expected input '%s' should be present", input) - } - } - - // claude_env should not be present when hasOutput=false (security improvement) - if _, hasClaudeEnv := execConfig.Inputs["claude_env"]; hasClaudeEnv { - t.Errorf("Expected no claude_env input for security reasons when hasOutput=false") - } }) - t.Run("ExecutionConfig with empty network permissions", func(t *testing.T) { + t.Run("ExecutionConfig with empty allowed domains (deny all)", func(t *testing.T) { config := &EngineConfig{ ID: "claude", Model: "claude-3-5-sonnet-20241022", - Permissions: &EnginePermissions{ - Network: &NetworkPermissions{ - Allowed: []string{}, // Empty allowed list means deny-all policy - }, - }, } - execConfig := engine.GetExecutionConfig("test-workflow", "test-log", config, false) + networkPermissions := &NetworkPermissions{ + Allowed: []string{}, // Empty list means deny all + } + + execConfig := engine.GetExecutionConfig("test-workflow", "test-log", config, networkPermissions, false) - // With empty allowed list, we should enforce deny-all policy via settings + // Verify settings parameter is present even with deny-all policy if settings, exists := execConfig.Inputs["settings"]; !exists { - t.Error("Settings parameter should be present with empty network permissions (deny-all policy)") + t.Error("Settings parameter should be present with deny-all network permissions") } else if settings != ".claude/settings.json" { - t.Errorf("Expected settings '.claude/settings.json', got '%s'", settings) + t.Errorf("Expected settings parameter '.claude/settings.json', got '%s'", settings) } }) - t.Run("ExecutionConfig version handling with network permissions", func(t *testing.T) { + t.Run("ExecutionConfig with non-Claude engine", func(t *testing.T) { config := &EngineConfig{ - ID: "claude", - Version: "v1.2.3", - Permissions: &EnginePermissions{ - Network: &NetworkPermissions{ - Allowed: []string{"example.com"}, - }, - }, + ID: "codex", // Non-Claude engine + Model: "gpt-4", } - execConfig := engine.GetExecutionConfig("test-workflow", "test-log", config, false) - - // Verify action version uses config version - expectedAction := "anthropics/claude-code-base-action@v1.2.3" - if execConfig.Action != expectedAction { - t.Errorf("Expected action '%s', got '%s'", expectedAction, execConfig.Action) + networkPermissions := &NetworkPermissions{ + Allowed: []string{"example.com"}, } - // Verify settings parameter is still present - if settings, exists := execConfig.Inputs["settings"]; !exists { - t.Error("Settings parameter should be present with network permissions") - } else if settings != ".claude/settings.json" { - t.Errorf("Expected settings '.claude/settings.json', got '%s'", settings) + execConfig := engine.GetExecutionConfig("test-workflow", "test-log", config, networkPermissions, false) + + // Verify settings parameter is not present for non-Claude engines + if settings, exists := execConfig.Inputs["settings"]; exists { + t.Errorf("Settings parameter should not be present for non-Claude engine, got '%s'", settings) } }) } @@ -177,15 +149,14 @@ func TestNetworkPermissionsIntegration(t *testing.T) { config := &EngineConfig{ ID: "claude", Model: "claude-3-5-sonnet-20241022", - Permissions: &EnginePermissions{ - Network: &NetworkPermissions{ - Allowed: []string{"api.github.com", "*.example.com", "trusted.org"}, - }, - }, + } + + networkPermissions := &NetworkPermissions{ + Allowed: []string{"api.github.com", "*.example.com", "trusted.org"}, } // Get installation steps - steps := engine.GetInstallationSteps(config) + steps := engine.GetInstallationSteps(config, networkPermissions) if len(steps) != 2 { t.Fatalf("Expected 2 installation steps, got %d", len(steps)) } @@ -199,53 +170,55 @@ func TestNetworkPermissionsIntegration(t *testing.T) { } } - // Verify settings generation step - settingsStep := strings.Join(steps[1], "\n") - if !strings.Contains(settingsStep, "PreToolUse") { - t.Error("Settings step should configure PreToolUse hooks") - } - // Get execution config - execConfig := engine.GetExecutionConfig("test-workflow", "test-log", config, false) - if execConfig.Inputs["settings"] != ".claude/settings.json" { - t.Error("Execution config should reference generated settings file") - } + execConfig := engine.GetExecutionConfig("test-workflow", "test-log", config, networkPermissions, false) - // Verify all pieces work together - if !HasNetworkPermissions(config) { - t.Error("Config should have network permissions") + // Verify settings is configured + if settings, exists := execConfig.Inputs["settings"]; !exists { + t.Error("Settings parameter should be present") + } else if settings != ".claude/settings.json" { + t.Errorf("Expected settings parameter '.claude/settings.json', got '%s'", settings) } - domains := GetAllowedDomains(config) + + // Test the GetAllowedDomains function + domains := GetAllowedDomains(networkPermissions) if len(domains) != 3 { - t.Errorf("Expected 3 allowed domains, got %d", len(domains)) + t.Fatalf("Expected 3 allowed domains, got %d", len(domains)) + } + + expectedDomainsList := []string{"api.github.com", "*.example.com", "trusted.org"} + for i, expected := range expectedDomainsList { + if domains[i] != expected { + t.Errorf("Expected domain %d to be '%s', got '%s'", i, expected, domains[i]) + } } }) - t.Run("Multiple engine instances consistency", func(t *testing.T) { + t.Run("Engine consistency", func(t *testing.T) { engine1 := NewClaudeEngine() engine2 := NewClaudeEngine() config := &EngineConfig{ - ID: "claude", - Permissions: &EnginePermissions{ - Network: &NetworkPermissions{ - Allowed: []string{"example.com"}, - }, - }, + ID: "claude", + Model: "claude-3-5-sonnet-20241022", + } + + networkPermissions := &NetworkPermissions{ + Allowed: []string{"example.com"}, } - steps1 := engine1.GetInstallationSteps(config) - steps2 := engine2.GetInstallationSteps(config) + steps1 := engine1.GetInstallationSteps(config, networkPermissions) + steps2 := engine2.GetInstallationSteps(config, networkPermissions) if len(steps1) != len(steps2) { - t.Error("Different engine instances should generate same number of steps") + t.Errorf("Engine instances should produce same number of steps, got %d and %d", len(steps1), len(steps2)) } - execConfig1 := engine1.GetExecutionConfig("test", "log", config, false) - execConfig2 := engine2.GetExecutionConfig("test", "log", config, false) + execConfig1 := engine1.GetExecutionConfig("test", "log", config, networkPermissions, false) + execConfig2 := engine2.GetExecutionConfig("test", "log", config, networkPermissions, false) - if execConfig1.Inputs["settings"] != execConfig2.Inputs["settings"] { - t.Error("Different engine instances should generate consistent execution configs") + if execConfig1.Action != execConfig2.Action { + t.Errorf("Engine instances should produce same action, got '%s' and '%s'", execConfig1.Action, execConfig2.Action) } }) } diff --git a/pkg/workflow/claude_engine_test.go b/pkg/workflow/claude_engine_test.go index 930cc0cf6b..8800fa043c 100644 --- a/pkg/workflow/claude_engine_test.go +++ b/pkg/workflow/claude_engine_test.go @@ -30,13 +30,13 @@ func TestClaudeEngine(t *testing.T) { } // Test installation steps (should be empty for Claude) - steps := engine.GetInstallationSteps(nil) + steps := engine.GetInstallationSteps(nil, nil) if len(steps) != 0 { t.Errorf("Expected no installation steps for Claude, got %v", steps) } // Test execution config - config := engine.GetExecutionConfig("test-workflow", "test-log", nil, false) + config := engine.GetExecutionConfig("test-workflow", "test-log", nil, nil, false) if config.StepName != "Execute Claude Code Action" { t.Errorf("Expected step name 'Execute Claude Code Action', got '%s'", config.StepName) } @@ -85,7 +85,7 @@ func TestClaudeEngineWithOutput(t *testing.T) { engine := NewClaudeEngine() // Test execution config with hasOutput=true - config := engine.GetExecutionConfig("test-workflow", "test-log", nil, true) + config := engine.GetExecutionConfig("test-workflow", "test-log", nil, nil, true) // Should include GITHUB_AW_SAFE_OUTPUTS when hasOutput=true, but no GH_TOKEN for security expectedClaudeEnv := "|\n GITHUB_AW_SAFE_OUTPUTS: ${{ env.GITHUB_AW_SAFE_OUTPUTS }}" @@ -109,7 +109,7 @@ func TestClaudeEngineConfiguration(t *testing.T) { for _, tc := range testCases { t.Run(tc.workflowName, func(t *testing.T) { - config := engine.GetExecutionConfig(tc.workflowName, tc.logFile, nil, false) + config := engine.GetExecutionConfig(tc.workflowName, tc.logFile, nil, nil, false) // Verify the configuration is consistent regardless of input if config.StepName != "Execute Claude Code Action" { @@ -146,7 +146,7 @@ func TestClaudeEngineWithVersion(t *testing.T) { Model: "claude-3-5-sonnet-20241022", } - config := engine.GetExecutionConfig("test-workflow", "test-log", engineConfig, false) + config := engine.GetExecutionConfig("test-workflow", "test-log", engineConfig, nil, false) // Check that the version is correctly used in the action expectedAction := "anthropics/claude-code-base-action@v1.2.3" @@ -169,7 +169,7 @@ func TestClaudeEngineWithoutVersion(t *testing.T) { Model: "claude-3-5-sonnet-20241022", } - config := engine.GetExecutionConfig("test-workflow", "test-log", engineConfig, false) + config := engine.GetExecutionConfig("test-workflow", "test-log", engineConfig, nil, false) // Check that default version is used expectedAction := fmt.Sprintf("anthropics/claude-code-base-action@%s", DefaultClaudeActionVersion) diff --git a/pkg/workflow/codex_engine.go b/pkg/workflow/codex_engine.go index 19f5aaf205..9749d0b206 100644 --- a/pkg/workflow/codex_engine.go +++ b/pkg/workflow/codex_engine.go @@ -25,7 +25,7 @@ func NewCodexEngine() *CodexEngine { } } -func (e *CodexEngine) GetInstallationSteps(engineConfig *EngineConfig) []GitHubActionStep { +func (e *CodexEngine) GetInstallationSteps(engineConfig *EngineConfig, networkPermissions *NetworkPermissions) []GitHubActionStep { // Build the npm install command, optionally with version installCmd := "npm install -g @openai/codex" if engineConfig != nil && engineConfig.Version != "" { @@ -46,7 +46,7 @@ func (e *CodexEngine) GetInstallationSteps(engineConfig *EngineConfig) []GitHubA } } -func (e *CodexEngine) GetExecutionConfig(workflowName string, logFile string, engineConfig *EngineConfig, hasOutput bool) ExecutionConfig { +func (e *CodexEngine) GetExecutionConfig(workflowName string, logFile string, engineConfig *EngineConfig, networkPermissions *NetworkPermissions, hasOutput bool) ExecutionConfig { // Use model from engineConfig if available, otherwise default to o4-mini model := "o4-mini" if engineConfig != nil && engineConfig.Model != "" { diff --git a/pkg/workflow/codex_engine_test.go b/pkg/workflow/codex_engine_test.go index b0ca45238c..6ca93fefc9 100644 --- a/pkg/workflow/codex_engine_test.go +++ b/pkg/workflow/codex_engine_test.go @@ -26,7 +26,7 @@ func TestCodexEngine(t *testing.T) { } // Test installation steps - steps := engine.GetInstallationSteps(nil) + steps := engine.GetInstallationSteps(nil, nil) expectedStepCount := 2 // Setup Node.js and Install Codex if len(steps) != expectedStepCount { t.Errorf("Expected %d installation steps, got %d", expectedStepCount, len(steps)) @@ -47,7 +47,7 @@ func TestCodexEngine(t *testing.T) { } // Test execution config - config := engine.GetExecutionConfig("test-workflow", "test-log", nil, false) + config := engine.GetExecutionConfig("test-workflow", "test-log", nil, nil, false) if config.StepName != "Run Codex" { t.Errorf("Expected step name 'Run Codex', got '%s'", config.StepName) } @@ -74,7 +74,7 @@ func TestCodexEngineWithVersion(t *testing.T) { engine := NewCodexEngine() // Test installation steps without version - stepsNoVersion := engine.GetInstallationSteps(nil) + stepsNoVersion := engine.GetInstallationSteps(nil, nil) foundNoVersionInstall := false for _, step := range stepsNoVersion { for _, line := range step { @@ -93,7 +93,7 @@ func TestCodexEngineWithVersion(t *testing.T) { ID: "codex", Version: "3.0.1", } - stepsWithVersion := engine.GetInstallationSteps(engineConfig) + stepsWithVersion := engine.GetInstallationSteps(engineConfig, nil) foundVersionInstall := false for _, step := range stepsWithVersion { for _, line := range step { diff --git a/pkg/workflow/compiler.go b/pkg/workflow/compiler.go index d3edf8c4b2..57216d4889 100644 --- a/pkg/workflow/compiler.go +++ b/pkg/workflow/compiler.go @@ -117,6 +117,7 @@ type WorkflowData struct { Name string On string Permissions string + Network string // top-level network permissions configuration Concurrency string RunName string Env string @@ -131,13 +132,14 @@ type WorkflowData struct { AI string // "claude" or "codex" (for backwards compatibility) EngineConfig *EngineConfig // Extended engine configuration StopTime string - Command string // for /command trigger support - CommandOtherEvents map[string]any // for merging command with other events - AIReaction string // AI reaction type like "eyes", "heart", etc. - Jobs map[string]any // custom job configurations with dependencies - Cache string // cache configuration - NeedsTextOutput bool // whether the workflow uses ${{ needs.task.outputs.text }} - SafeOutputs *SafeOutputsConfig // output configuration for automatic output routes + Command string // for /command trigger support + CommandOtherEvents map[string]any // for merging command with other events + AIReaction string // AI reaction type like "eyes", "heart", etc. + Jobs map[string]any // custom job configurations with dependencies + Cache string // cache configuration + NeedsTextOutput bool // whether the workflow uses ${{ needs.task.outputs.text }} + NetworkPermissions *NetworkPermissions // parsed network permissions + SafeOutputs *SafeOutputsConfig // output configuration for automatic output routes } // SafeOutputsConfig holds configuration for automatic output routes @@ -464,23 +466,14 @@ func (c *Compiler) parseWorkflowFile(markdownPath string) (*WorkflowData, error) // Extract AI engine setting from frontmatter engineSetting, engineConfig := c.extractEngineConfig(result.Frontmatter) - // Extract strict mode setting from frontmatter - strictMode := c.extractStrictMode(result.Frontmatter) - - // Apply strict mode: inject deny-all network permissions if strict mode is enabled - // and no explicit network permissions are configured - if strictMode && engineConfig != nil && engineConfig.ID == "claude" { - if engineConfig.Permissions == nil || engineConfig.Permissions.Network == nil { - // Initialize permissions structure if needed - if engineConfig.Permissions == nil { - engineConfig.Permissions = &EnginePermissions{} - } - if engineConfig.Permissions.Network == nil { - // Inject deny-all network permissions (empty allowed list) - engineConfig.Permissions.Network = &NetworkPermissions{ - Allowed: []string{}, // Empty list means deny-all - } - } + // Extract network permissions from frontmatter + networkPermissions := c.extractNetworkPermissions(result.Frontmatter) + + // Default to full network access if no network permissions specified + if networkPermissions == nil && engineConfig != nil && engineConfig.ID == "claude" { + // Default to "defaults" mode (full network access for now) + networkPermissions = &NetworkPermissions{ + Mode: "defaults", } } @@ -604,18 +597,20 @@ func (c *Compiler) parseWorkflowFile(markdownPath string) (*WorkflowData, error) // Build workflow data workflowData := &WorkflowData{ - Name: workflowName, - Tools: tools, - MarkdownContent: markdownContent, - AI: engineSetting, - EngineConfig: engineConfig, - NeedsTextOutput: needsTextOutput, + Name: workflowName, + Tools: tools, + MarkdownContent: markdownContent, + AI: engineSetting, + EngineConfig: engineConfig, + NetworkPermissions: networkPermissions, + NeedsTextOutput: needsTextOutput, } // Extract YAML sections from frontmatter - use direct frontmatter map extraction // to avoid issues with nested keys (e.g., tools.mcps.*.env being confused with top-level env) workflowData.On = c.extractTopLevelYAMLSection(result.Frontmatter, "on") workflowData.Permissions = c.extractTopLevelYAMLSection(result.Frontmatter, "permissions") + workflowData.Network = c.extractTopLevelYAMLSection(result.Frontmatter, "network") workflowData.Concurrency = c.extractTopLevelYAMLSection(result.Frontmatter, "concurrency") workflowData.RunName = c.extractTopLevelYAMLSection(result.Frontmatter, "run-name") workflowData.Env = c.extractTopLevelYAMLSection(result.Frontmatter, "env") @@ -722,7 +717,7 @@ func (c *Compiler) parseWorkflowFile(markdownPath string) (*WorkflowData, error) } // Apply defaults - c.applyDefaults(workflowData, markdownPath, strictMode) + c.applyDefaults(workflowData, markdownPath) // Apply pull request draft filter if specified c.applyPullRequestDraftFilter(workflowData, result.Frontmatter) @@ -733,6 +728,41 @@ func (c *Compiler) parseWorkflowFile(markdownPath string) (*WorkflowData, error) return workflowData, nil } +// extractNetworkPermissions extracts network permissions from frontmatter +func (c *Compiler) extractNetworkPermissions(frontmatter map[string]any) *NetworkPermissions { + if network, exists := frontmatter["network"]; exists { + // Handle string format: "defaults" + if networkStr, ok := network.(string); ok { + if networkStr == "defaults" { + return &NetworkPermissions{ + Mode: "defaults", + } + } + // Unknown string format, return nil + return nil + } + + // Handle object format: { allowed: [...] } or {} + if networkObj, ok := network.(map[string]any); ok { + permissions := &NetworkPermissions{} + + // Extract allowed domains if present + if allowed, hasAllowed := networkObj["allowed"]; hasAllowed { + if allowedSlice, ok := allowed.([]any); ok { + for _, domain := range allowedSlice { + if domainStr, ok := domain.(string); ok { + permissions.Allowed = append(permissions.Allowed, domainStr) + } + } + } + } + // Empty object {} means no network access (empty allowed list) + return permissions + } + } + return nil +} + // extractTopLevelYAMLSection extracts a top-level YAML section from the frontmatter map // This ensures we only extract keys at the root level, avoiding nested keys with the same name func (c *Compiler) extractTopLevelYAMLSection(frontmatter map[string]any, key string) string { @@ -924,7 +954,7 @@ func (c *Compiler) extractCommandName(frontmatter map[string]any) string { } // applyDefaults applies default values for missing workflow sections -func (c *Compiler) applyDefaults(data *WorkflowData, markdownPath string, strictMode bool) { +func (c *Compiler) applyDefaults(data *WorkflowData, markdownPath string) { // Check if this is a command trigger workflow (by checking if user specified "on.command") isCommandTrigger := false if data.On == "" { @@ -1022,16 +1052,8 @@ func (c *Compiler) applyDefaults(data *WorkflowData, markdownPath string, strict } if data.Permissions == "" { - if strictMode { - // In strict mode, default to empty permissions instead of read-all - data.Permissions = `permissions: {}` - } else { - // Default behavior: use read-all permissions - data.Permissions = `permissions: read-all` - } - } else if strictMode { - // In strict mode, validate permissions and warn about write permissions - c.validatePermissionsInStrictMode(data.Permissions) + // Default behavior: use read-all permissions + data.Permissions = `permissions: read-all` } // Generate concurrency configuration using the dedicated concurrency module @@ -2324,7 +2346,7 @@ func (c *Compiler) generateMainJobSteps(yaml *strings.Builder, data *WorkflowDat } // Add engine-specific installation steps - installSteps := engine.GetInstallationSteps(data.EngineConfig) + installSteps := engine.GetInstallationSteps(data.EngineConfig, data.NetworkPermissions) for _, step := range installSteps { for _, line := range step { yaml.WriteString(line + "\n") @@ -3177,7 +3199,7 @@ func (c *Compiler) convertStepToYAML(stepMap map[string]any) (string, error) { // generateEngineExecutionSteps generates the execution steps for the specified agentic engine func (c *Compiler) generateEngineExecutionSteps(yaml *strings.Builder, data *WorkflowData, engine AgenticEngine, logFile string) { - executionConfig := engine.GetExecutionConfig(data.Name, logFile, data.EngineConfig, data.SafeOutputs != nil) + executionConfig := engine.GetExecutionConfig(data.Name, logFile, data.EngineConfig, data.NetworkPermissions, data.SafeOutputs != nil) if executionConfig.Command != "" { // Command-based execution (e.g., Codex) diff --git a/pkg/workflow/compiler_network_test.go b/pkg/workflow/compiler_network_test.go new file mode 100644 index 0000000000..92f514f89c --- /dev/null +++ b/pkg/workflow/compiler_network_test.go @@ -0,0 +1,370 @@ +package workflow + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestCompilerNetworkPermissionsExtraction(t *testing.T) { + compiler := NewCompiler(false, "", "test") + + // Helper function to create a temporary workflow file for testing + createTempWorkflowFile := func(content string) (string, func()) { + tmpDir, err := os.MkdirTemp("", "test-workflow-") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + + filePath := filepath.Join(tmpDir, "test.md") + err = os.WriteFile(filePath, []byte(content), 0644) + if err != nil { + t.Fatalf("Failed to write temp file: %v", err) + } + + cleanup := func() { + os.RemoveAll(tmpDir) + } + + return filePath, cleanup + } + + t.Run("Extract top-level network permissions", func(t *testing.T) { + yamlContent := `--- +engine: + id: claude + model: claude-3-5-sonnet-20241022 +network: + allowed: + - "github.com" + - "*.example.com" + - "api.trusted.com" +--- + +# Test Workflow +This is a test workflow with network permissions.` + + filePath, cleanup := createTempWorkflowFile(yamlContent) + defer cleanup() + + workflowData, err := compiler.parseWorkflowFile(filePath) + if err != nil { + t.Fatalf("Failed to parse workflow: %v", err) + } + + if workflowData.NetworkPermissions == nil { + t.Fatal("Expected network permissions to be extracted") + } + + expectedDomains := []string{"github.com", "*.example.com", "api.trusted.com"} + if len(workflowData.NetworkPermissions.Allowed) != len(expectedDomains) { + t.Fatalf("Expected %d allowed domains, got %d", len(expectedDomains), len(workflowData.NetworkPermissions.Allowed)) + } + + for i, expected := range expectedDomains { + if workflowData.NetworkPermissions.Allowed[i] != expected { + t.Errorf("Expected domain %d to be '%s', got '%s'", i, expected, workflowData.NetworkPermissions.Allowed[i]) + } + } + }) + + t.Run("No network permissions specified", func(t *testing.T) { + yamlContent := `--- +engine: + id: claude + model: claude-3-5-sonnet-20241022 +--- + +# Test Workflow +This workflow has no network permissions.` + + filePath, cleanup := createTempWorkflowFile(yamlContent) + defer cleanup() + + workflowData, err := compiler.parseWorkflowFile(filePath) + if err != nil { + t.Fatalf("Failed to parse workflow: %v", err) + } + + // When no network field is specified, should default to Mode: "defaults" + if workflowData.NetworkPermissions == nil { + t.Error("Expected network permissions to default to 'defaults' mode when not specified") + } else if workflowData.NetworkPermissions.Mode != "defaults" { + t.Errorf("Expected default mode to be 'defaults', got '%s'", workflowData.NetworkPermissions.Mode) + } + }) + + t.Run("Empty network permissions", func(t *testing.T) { + yamlContent := `--- +engine: + id: claude + model: claude-3-5-sonnet-20241022 +network: + allowed: [] +--- + +# Test Workflow +This workflow has empty network permissions (deny all).` + + filePath, cleanup := createTempWorkflowFile(yamlContent) + defer cleanup() + + workflowData, err := compiler.parseWorkflowFile(filePath) + if err != nil { + t.Fatalf("Failed to parse workflow: %v", err) + } + + if workflowData.NetworkPermissions == nil { + t.Fatal("Expected network permissions to be present even when empty") + } + + if len(workflowData.NetworkPermissions.Allowed) != 0 { + t.Errorf("Expected 0 allowed domains, got %d", len(workflowData.NetworkPermissions.Allowed)) + } + }) + + t.Run("Network permissions with single domain", func(t *testing.T) { + yamlContent := `--- +engine: + id: claude + model: claude-3-5-sonnet-20241022 +network: + allowed: + - "single.domain.com" +--- + +# Test Workflow +This workflow has a single allowed domain.` + + filePath, cleanup := createTempWorkflowFile(yamlContent) + defer cleanup() + + workflowData, err := compiler.parseWorkflowFile(filePath) + if err != nil { + t.Fatalf("Failed to parse workflow: %v", err) + } + + if workflowData.NetworkPermissions == nil { + t.Fatal("Expected network permissions to be extracted") + } + + if len(workflowData.NetworkPermissions.Allowed) != 1 { + t.Fatalf("Expected 1 allowed domain, got %d", len(workflowData.NetworkPermissions.Allowed)) + } + + if workflowData.NetworkPermissions.Allowed[0] != "single.domain.com" { + t.Errorf("Expected domain 'single.domain.com', got '%s'", workflowData.NetworkPermissions.Allowed[0]) + } + }) + + t.Run("Network permissions passed to compilation", func(t *testing.T) { + yamlContent := `--- +engine: + id: claude + model: claude-3-5-sonnet-20241022 +network: + allowed: + - "compilation.test.com" +--- + +# Test Workflow +Test that network permissions are passed to engine during compilation.` + + filePath, cleanup := createTempWorkflowFile(yamlContent) + defer cleanup() + + workflowData, err := compiler.parseWorkflowFile(filePath) + if err != nil { + t.Fatalf("Failed to parse workflow: %v", err) + } + + // Test that network permissions are present in the parsed data + if workflowData.NetworkPermissions == nil { + t.Fatal("Expected network permissions to be present") + } + + if len(workflowData.NetworkPermissions.Allowed) != 1 || + workflowData.NetworkPermissions.Allowed[0] != "compilation.test.com" { + t.Error("Network permissions not correctly extracted") + } + }) + + t.Run("Multiple workflows with different network permissions", func(t *testing.T) { + yaml1 := `--- +engine: + id: claude + model: claude-3-5-sonnet-20241022 +network: + allowed: + - "first.domain.com" +--- + +# First Workflow` + + yaml2 := `--- +engine: + id: claude + model: claude-3-5-sonnet-20241022 +network: + allowed: + - "second.domain.com" + - "third.domain.com" +--- + +# Second Workflow` + + filePath1, cleanup1 := createTempWorkflowFile(yaml1) + defer cleanup1() + filePath2, cleanup2 := createTempWorkflowFile(yaml2) + defer cleanup2() + + workflowData1, err := compiler.parseWorkflowFile(filePath1) + if err != nil { + t.Fatalf("Failed to parse first workflow: %v", err) + } + + workflowData2, err := compiler.parseWorkflowFile(filePath2) + if err != nil { + t.Fatalf("Failed to parse second workflow: %v", err) + } + + // Verify first workflow + if len(workflowData1.NetworkPermissions.Allowed) != 1 { + t.Errorf("First workflow should have 1 domain, got %d", len(workflowData1.NetworkPermissions.Allowed)) + } + if workflowData1.NetworkPermissions.Allowed[0] != "first.domain.com" { + t.Errorf("First workflow domain should be 'first.domain.com', got '%s'", workflowData1.NetworkPermissions.Allowed[0]) + } + + // Verify second workflow + if len(workflowData2.NetworkPermissions.Allowed) != 2 { + t.Errorf("Second workflow should have 2 domains, got %d", len(workflowData2.NetworkPermissions.Allowed)) + } + expectedDomains := []string{"second.domain.com", "third.domain.com"} + for i, expected := range expectedDomains { + if workflowData2.NetworkPermissions.Allowed[i] != expected { + t.Errorf("Second workflow domain %d should be '%s', got '%s'", i, expected, workflowData2.NetworkPermissions.Allowed[i]) + } + } + }) +} + +func TestNetworkPermissionsUtilities(t *testing.T) { + t.Run("GetAllowedDomains with various inputs", func(t *testing.T) { + // Test with nil - should return default whitelist + domains := GetAllowedDomains(nil) + if len(domains) == 0 { + t.Errorf("Expected default whitelist domains for nil input, got %d", len(domains)) + } + + // Test with defaults mode - should return default whitelist + defaultsPerms := &NetworkPermissions{Mode: "defaults"} + domains = GetAllowedDomains(defaultsPerms) + if len(domains) == 0 { + t.Errorf("Expected default whitelist domains for defaults mode, got %d", len(domains)) + } + + // Test with empty permissions object (no allowed list) + emptyPerms := &NetworkPermissions{Allowed: []string{}} + domains = GetAllowedDomains(emptyPerms) + if len(domains) != 0 { + t.Errorf("Expected 0 domains for empty allowed list, got %d", len(domains)) + } + + // Test with multiple domains + perms := &NetworkPermissions{ + Allowed: []string{"domain1.com", "*.domain2.com", "domain3.org"}, + } + domains = GetAllowedDomains(perms) + if len(domains) != 3 { + t.Errorf("Expected 3 domains, got %d", len(domains)) + } + + expected := []string{"domain1.com", "*.domain2.com", "domain3.org"} + for i, expectedDomain := range expected { + if domains[i] != expectedDomain { + t.Errorf("Expected domain %d to be '%s', got '%s'", i, expectedDomain, domains[i]) + } + } + }) + + t.Run("Deprecated HasNetworkPermissions still works", func(t *testing.T) { + // Test the deprecated function that takes EngineConfig + config := &EngineConfig{ + ID: "claude", + Model: "claude-3-5-sonnet-20241022", + } + + // This should return false since the deprecated function + // doesn't have the nested permissions anymore + if HasNetworkPermissions(config) { + t.Error("Expected false for engine config without nested permissions") + } + }) +} + +// Test helper functions for network permissions +func TestNetworkPermissionHelpers(t *testing.T) { + t.Run("hasNetworkPermissionsInConfig utility", func(t *testing.T) { + // Test that we can check if network permissions exist + perms := &NetworkPermissions{ + Allowed: []string{"example.com"}, + } + + if len(perms.Allowed) == 0 { + t.Error("Network permissions should have allowed domains") + } + + // Test empty permissions + emptyPerms := &NetworkPermissions{Allowed: []string{}} + + if len(emptyPerms.Allowed) != 0 { + t.Error("Empty network permissions should have 0 allowed domains") + } + }) + + t.Run("domain matching logic", func(t *testing.T) { + // Test basic domain matching patterns that would be used + // in a real implementation + allowedDomains := []string{"example.com", "*.trusted.com", "api.github.com"} + + testCases := []struct { + domain string + expected bool + }{ + {"example.com", true}, + {"api.github.com", true}, + {"subdomain.trusted.com", true}, // wildcard match + {"another.trusted.com", true}, // wildcard match + {"blocked.com", false}, + {"untrusted.com", false}, + {"example.com.malicious.com", false}, // not a true subdomain + } + + for _, tc := range testCases { + // Simple domain matching logic for testing + allowed := false + for _, allowedDomain := range allowedDomains { + if allowedDomain == tc.domain { + allowed = true + break + } + if strings.HasPrefix(allowedDomain, "*.") { + suffix := allowedDomain[2:] // Remove "*." + if strings.HasSuffix(tc.domain, suffix) && tc.domain != suffix { + // Ensure it's actually a subdomain, not just ending with the suffix + if strings.HasSuffix(tc.domain, "."+suffix) { + allowed = true + break + } + } + } + } + + if allowed != tc.expected { + t.Errorf("Domain %s: expected %v, got %v", tc.domain, tc.expected, allowed) + } + } + }) +} diff --git a/pkg/workflow/compiler_test.go b/pkg/workflow/compiler_test.go index b27d5e5b8a..db19b6dd9a 100644 --- a/pkg/workflow/compiler_test.go +++ b/pkg/workflow/compiler_test.go @@ -2933,23 +2933,22 @@ func TestGenerateJobName(t *testing.T) { } } -func TestStrictModeNetworkPermissions(t *testing.T) { +func TestNetworkPermissionsDefaultBehavior(t *testing.T) { compiler := NewCompiler(false, "", "test") tmpDir := t.TempDir() - t.Run("strict mode disabled with no permissions (default behavior)", func(t *testing.T) { + t.Run("no network field defaults to full access", func(t *testing.T) { testContent := `--- on: push engine: claude -strict: false --- # Test Workflow This is a test workflow without network permissions. ` - testFile := filepath.Join(tmpDir, "no-strict-workflow.md") + testFile := filepath.Join(tmpDir, "no-network-workflow.md") if err := os.WriteFile(testFile, []byte(testContent), 0644); err != nil { t.Fatal(err) } @@ -2961,33 +2960,30 @@ This is a test workflow without network permissions. } // Read the compiled output - lockFile := filepath.Join(tmpDir, "no-strict-workflow.lock.yml") + lockFile := filepath.Join(tmpDir, "no-network-workflow.lock.yml") lockContent, err := os.ReadFile(lockFile) if err != nil { t.Fatalf("Failed to read lock file: %v", err) } - // Should not contain network hook setup (no restrictions) - if strings.Contains(string(lockContent), "Generate Network Permissions Hook") { - t.Error("Should not contain network hook setup when strict mode is disabled and no permissions set") - } - if strings.Contains(string(lockContent), ".claude/settings.json") { - t.Error("Should not reference settings.json when strict mode is disabled and no permissions set") + // Should contain network hook setup (defaults to whitelist) + if !strings.Contains(string(lockContent), "Generate Network Permissions Hook") { + t.Error("Should contain network hook setup when no network field specified (defaults to whitelist)") } }) - t.Run("strict mode enabled with no explicit permissions (should enforce deny-all)", func(t *testing.T) { + t.Run("network: defaults should enforce whitelist restrictions", func(t *testing.T) { testContent := `--- on: push engine: claude -strict: true +network: defaults --- # Test Workflow -This is a test workflow with strict mode but no explicit network permissions. +This is a test workflow with explicit defaults network permissions. ` - testFile := filepath.Join(tmpDir, "strict-no-perms-workflow.md") + testFile := filepath.Join(tmpDir, "defaults-network-workflow.md") if err := os.WriteFile(testFile, []byte(testContent), 0644); err != nil { t.Fatal(err) } @@ -2999,41 +2995,30 @@ This is a test workflow with strict mode but no explicit network permissions. } // Read the compiled output - lockFile := filepath.Join(tmpDir, "strict-no-perms-workflow.lock.yml") + lockFile := filepath.Join(tmpDir, "defaults-network-workflow.lock.yml") lockContent, err := os.ReadFile(lockFile) if err != nil { t.Fatalf("Failed to read lock file: %v", err) } - // Should contain network hook setup (deny-all enforcement) + // Should contain network hook setup (defaults mode uses whitelist) if !strings.Contains(string(lockContent), "Generate Network Permissions Hook") { - t.Error("Should contain network hook setup when strict mode is enabled") - } - if !strings.Contains(string(lockContent), ".claude/settings.json") { - t.Error("Should reference settings.json when strict mode is enabled") - } - // Should have empty ALLOWED_DOMAINS array for deny-all - if !strings.Contains(string(lockContent), "ALLOWED_DOMAINS = []") { - t.Error("Should have empty ALLOWED_DOMAINS array for deny-all policy") + t.Error("Should contain network hook setup for network: defaults (uses whitelist)") } }) - t.Run("strict mode enabled with explicit network permissions (should use explicit permissions)", func(t *testing.T) { + t.Run("network: {} should enforce deny-all", func(t *testing.T) { testContent := `--- on: push -engine: - id: claude - permissions: - network: - allowed: ["example.com", "api.github.com"] -strict: true +engine: claude +network: {} --- # Test Workflow -This is a test workflow with strict mode and explicit network permissions. +This is a test workflow with empty network permissions (deny all). ` - testFile := filepath.Join(tmpDir, "strict-with-perms-workflow.md") + testFile := filepath.Join(tmpDir, "deny-all-workflow.md") if err := os.WriteFile(testFile, []byte(testContent), 0644); err != nil { t.Fatal(err) } @@ -3045,35 +3030,36 @@ This is a test workflow with strict mode and explicit network permissions. } // Read the compiled output - lockFile := filepath.Join(tmpDir, "strict-with-perms-workflow.lock.yml") + lockFile := filepath.Join(tmpDir, "deny-all-workflow.lock.yml") lockContent, err := os.ReadFile(lockFile) if err != nil { t.Fatalf("Failed to read lock file: %v", err) } - // Should contain network hook setup with specified domains + // Should contain network hook setup (deny-all enforcement) if !strings.Contains(string(lockContent), "Generate Network Permissions Hook") { - t.Error("Should contain network hook setup when strict mode is enabled with explicit permissions") - } - if !strings.Contains(string(lockContent), `"example.com"`) { - t.Error("Should contain example.com in allowed domains") + t.Error("Should contain network hook setup for network: {}") } - if !strings.Contains(string(lockContent), `"api.github.com"`) { - t.Error("Should contain api.github.com in allowed domains") + // Should have empty ALLOWED_DOMAINS array for deny-all + if !strings.Contains(string(lockContent), "ALLOWED_DOMAINS = []") { + t.Error("Should have empty ALLOWED_DOMAINS array for deny-all policy") } }) - t.Run("strict mode not specified (should default to false)", func(t *testing.T) { + t.Run("network with allowed domains should enforce restrictions", func(t *testing.T) { testContent := `--- on: push -engine: claude +engine: + id: claude +network: + allowed: ["example.com", "api.github.com"] --- # Test Workflow -This is a test workflow without strict mode specified. +This is a test workflow with explicit network permissions. ` - testFile := filepath.Join(tmpDir, "no-strict-field-workflow.md") + testFile := filepath.Join(tmpDir, "allowed-domains-workflow.md") if err := os.WriteFile(testFile, []byte(testContent), 0644); err != nil { t.Fatal(err) } @@ -3085,30 +3071,37 @@ This is a test workflow without strict mode specified. } // Read the compiled output - lockFile := filepath.Join(tmpDir, "no-strict-field-workflow.lock.yml") + lockFile := filepath.Join(tmpDir, "allowed-domains-workflow.lock.yml") lockContent, err := os.ReadFile(lockFile) if err != nil { t.Fatalf("Failed to read lock file: %v", err) } - // Should not contain network hook setup (default behavior) - if strings.Contains(string(lockContent), "Generate Network Permissions Hook") { - t.Error("Should not contain network hook setup when strict mode is not specified") + // Should contain network hook setup with specified domains + if !strings.Contains(string(lockContent), "Generate Network Permissions Hook") { + t.Error("Should contain network hook setup with explicit network permissions") + } + if !strings.Contains(string(lockContent), `"example.com"`) { + t.Error("Should contain example.com in allowed domains") + } + if !strings.Contains(string(lockContent), `"api.github.com"`) { + t.Error("Should contain api.github.com in allowed domains") } }) - t.Run("strict mode with non-claude engine (should be ignored)", func(t *testing.T) { + t.Run("network permissions with non-claude engine should be ignored", func(t *testing.T) { testContent := `--- on: push engine: codex -strict: true +network: + allowed: ["example.com"] --- # Test Workflow -This is a test workflow with strict mode and codex engine. +This is a test workflow with network permissions and codex engine. ` - testFile := filepath.Join(tmpDir, "strict-codex-workflow.md") + testFile := filepath.Join(tmpDir, "codex-network-workflow.md") if err := os.WriteFile(testFile, []byte(testContent), 0644); err != nil { t.Fatal(err) } @@ -3120,7 +3113,7 @@ This is a test workflow with strict mode and codex engine. } // Read the compiled output - lockFile := filepath.Join(tmpDir, "strict-codex-workflow.lock.yml") + lockFile := filepath.Join(tmpDir, "codex-network-workflow.lock.yml") lockContent, err := os.ReadFile(lockFile) if err != nil { t.Fatalf("Failed to read lock file: %v", err) @@ -3133,51 +3126,6 @@ This is a test workflow with strict mode and codex engine. }) } -func TestExtractStrictMode(t *testing.T) { - compiler := NewCompiler(false, "", "test") - - tests := []struct { - name string - frontmatter map[string]any - expected bool - }{ - { - name: "strict mode true", - frontmatter: map[string]any{"strict": true}, - expected: true, - }, - { - name: "strict mode false", - frontmatter: map[string]any{"strict": false}, - expected: false, - }, - { - name: "strict mode not specified", - frontmatter: map[string]any{"on": "push"}, - expected: false, - }, - { - name: "strict mode as string (should default to false)", - frontmatter: map[string]any{"strict": "true"}, - expected: false, - }, - { - name: "empty frontmatter", - frontmatter: map[string]any{}, - expected: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := compiler.extractStrictMode(tt.frontmatter) - if result != tt.expected { - t.Errorf("extractStrictMode() = %v, want %v", result, tt.expected) - } - }) - } -} - func TestMCPImageField(t *testing.T) { // Create temporary directory for test files tmpDir, err := os.MkdirTemp("", "mcp-container-test") diff --git a/pkg/workflow/engine.go b/pkg/workflow/engine.go index d1f62d0966..a3528f3dde 100644 --- a/pkg/workflow/engine.go +++ b/pkg/workflow/engine.go @@ -6,21 +6,22 @@ import ( // EngineConfig represents the parsed engine configuration type EngineConfig struct { - ID string - Version string - Model string - MaxTurns string - Permissions *EnginePermissions `yaml:"permissions,omitempty"` -} - -// EnginePermissions represents the permissions configuration for an engine -type EnginePermissions struct { - Network *NetworkPermissions `yaml:"network,omitempty"` + ID string + Version string + Model string + MaxTurns string } // NetworkPermissions represents network access permissions type NetworkPermissions struct { - Allowed []string `yaml:"allowed,omitempty"` + Mode string `yaml:"mode,omitempty"` // "defaults" for default access + Allowed []string `yaml:"allowed,omitempty"` // List of allowed domains +} + +// EngineNetworkConfig combines engine configuration with top-level network permissions +type EngineNetworkConfig struct { + Engine *EngineConfig + Network *NetworkPermissions } // extractEngineConfig extracts engine configuration from frontmatter, supporting both string and object formats @@ -67,31 +68,6 @@ func (c *Compiler) extractEngineConfig(frontmatter map[string]any) (string, *Eng } } - // Extract optional 'permissions' field - if permissions, hasPermissions := engineObj["permissions"]; hasPermissions { - if permissionsObj, ok := permissions.(map[string]any); ok { - config.Permissions = &EnginePermissions{} - - // Extract network permissions - if network, hasNetwork := permissionsObj["network"]; hasNetwork { - if networkObj, ok := network.(map[string]any); ok { - config.Permissions.Network = &NetworkPermissions{} - - // Extract allowed domains - if allowed, hasAllowed := networkObj["allowed"]; hasAllowed { - if allowedSlice, ok := allowed.([]any); ok { - for _, domain := range allowedSlice { - if domainStr, ok := domain.(string); ok { - config.Permissions.Network.Allowed = append(config.Permissions.Network.Allowed, domainStr) - } - } - } - } - } - } - } - } - // Return the ID as the engineSetting for backwards compatibility return config.ID, config } diff --git a/pkg/workflow/engine_config_test.go b/pkg/workflow/engine_config_test.go index 8e8101bc57..36709c5eba 100644 --- a/pkg/workflow/engine_config_test.go +++ b/pkg/workflow/engine_config_test.go @@ -299,7 +299,7 @@ func TestEngineConfigurationWithModel(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - config := tt.engine.GetExecutionConfig("test-workflow", "test-log", tt.engineConfig, false) + config := tt.engine.GetExecutionConfig("test-workflow", "test-log", tt.engineConfig, nil, false) switch tt.engine.GetID() { case "claude": @@ -330,7 +330,7 @@ func TestNilEngineConfig(t *testing.T) { for _, engine := range engines { t.Run(engine.GetID(), func(t *testing.T) { // Should not panic when engineConfig is nil - config := engine.GetExecutionConfig("test-workflow", "test-log", nil, false) + config := engine.GetExecutionConfig("test-workflow", "test-log", nil, nil, false) if config.StepName == "" { t.Errorf("Expected non-empty step name for engine %s", engine.GetID()) diff --git a/pkg/workflow/engine_network_hooks.go b/pkg/workflow/engine_network_hooks.go index 6ed765c36f..da0bbcec0c 100644 --- a/pkg/workflow/engine_network_hooks.go +++ b/pkg/workflow/engine_network_hooks.go @@ -128,29 +128,272 @@ chmod +x .claude/hooks/network_permissions.py`, hookScript) return GitHubActionStep(lines) } +// getDefaultAllowedDomains returns the default whitelist of domains for network: defaults mode +// Taken from https://github.com/github/ebpf-padawan-egress-firewall/tree/main/docs/rules/default_padawan +func getDefaultAllowedDomains() []string { + return []string{ + // Certificate Authority and OCSP domains + "crl3.digicert.com", + "crl4.digicert.com", + "ocsp.digicert.com", + "ts-crl.ws.symantec.com", + "ts-ocsp.ws.symantec.com", + "crl.geotrust.com", + "ocsp.geotrust.com", + "crl.thawte.com", + "ocsp.thawte.com", + "crl.verisign.com", + "ocsp.verisign.com", + "crl.globalsign.com", + "ocsp.globalsign.com", + "crls.ssl.com", + "ocsp.ssl.com", + "crl.identrust.com", + "ocsp.identrust.com", + "crl.sectigo.com", + "ocsp.sectigo.com", + "crl.usertrust.com", + "ocsp.usertrust.com", + "s.symcb.com", + "s.symcd.com", + + // Container Registries + "ghcr.io", + "registry.hub.docker.com", + "*.docker.io", + "*.docker.com", + "production.cloudflare.docker.com", + "dl.k8s.io", + "pkgs.k8s.io", + "quay.io", + "mcr.microsoft.com", + "gcr.io", + "auth.docker.io", + + // .NET and NuGet + "nuget.org", + "dist.nuget.org", + "api.nuget.org", + "nuget.pkg.github.com", + "dotnet.microsoft.com", + "pkgs.dev.azure.com", + "builds.dotnet.microsoft.com", + "dotnetcli.blob.core.windows.net", + "nugetregistryv2prod.blob.core.windows.net", + "azuresearch-usnc.nuget.org", + "azuresearch-ussc.nuget.org", + "dc.services.visualstudio.com", + "dot.net", + "ci.dot.net", + "www.microsoft.com", + "oneocsp.microsoft.com", + + // Dart/Flutter + "pub.dev", + "pub.dartlang.org", + + // GitHub + "*.githubusercontent.com", + "raw.githubusercontent.com", + "objects.githubusercontent.com", + "lfs.github.com", + "github-cloud.githubusercontent.com", + "github-cloud.s3.amazonaws.com", + "codeload.github.com", + + // Go + "go.dev", + "golang.org", + "proxy.golang.org", + "sum.golang.org", + "pkg.go.dev", + "goproxy.io", + + // HashiCorp + "releases.hashicorp.com", + "apt.releases.hashicorp.com", + "yum.releases.hashicorp.com", + "registry.terraform.io", + + // Haskell + "haskell.org", + "*.hackage.haskell.org", + "get-ghcup.haskell.org", + "downloads.haskell.org", + + // Java/Maven/Gradle + "www.java.com", + "jdk.java.net", + "api.adoptium.net", + "adoptium.net", + "repo.maven.apache.org", + "maven.apache.org", + "repo1.maven.org", + "maven.pkg.github.com", + "maven.oracle.com", + "repo.spring.io", + "gradle.org", + "services.gradle.org", + "plugins.gradle.org", + "plugins-artifacts.gradle.org", + "repo.grails.org", + "download.eclipse.org", + "download.oracle.com", + "jcenter.bintray.com", + + // JSON Schema + "json-schema.org", + "json.schemastore.org", + + // Linux Package Repositories + // Ubuntu + "archive.ubuntu.com", + "security.ubuntu.com", + "ppa.launchpad.net", + "keyserver.ubuntu.com", + "azure.archive.ubuntu.com", + "api.snapcraft.io", + // Debian + "deb.debian.org", + "security.debian.org", + "keyring.debian.org", + "packages.debian.org", + "debian.map.fastlydns.net", + "apt.llvm.org", + // Fedora + "dl.fedoraproject.org", + "mirrors.fedoraproject.org", + "download.fedoraproject.org", + // CentOS + "mirror.centos.org", + "vault.centos.org", + // Alpine + "dl-cdn.alpinelinux.org", + "pkg.alpinelinux.org", + // Arch + "mirror.archlinux.org", + "archlinux.org", + // SUSE + "download.opensuse.org", + // Red Hat + "cdn.redhat.com", + // Common Package Mirrors + "packagecloud.io", + "packages.cloud.google.com", + // Microsoft Sources + "packages.microsoft.com", + + // Node.js/NPM/Yarn + "npmjs.org", + "npmjs.com", + "registry.npmjs.com", + "registry.npmjs.org", + "skimdb.npmjs.com", + "npm.pkg.github.com", + "api.npms.io", + "nodejs.org", + "yarnpkg.com", + "registry.yarnpkg.com", + "repo.yarnpkg.com", + "deb.nodesource.com", + "get.pnpm.io", + "bun.sh", + "deno.land", + "registry.bower.io", + + // Perl + "cpan.org", + "www.cpan.org", + "metacpan.org", + "cpan.metacpan.org", + + // PHP + "repo.packagist.org", + "packagist.org", + "getcomposer.org", + + // Playwright + "playwright.download.prss.microsoft.com", + "cdn.playwright.dev", + + // Python + "pypi.python.org", + "pypi.org", + "pip.pypa.io", + "*.pythonhosted.org", + "files.pythonhosted.org", + "bootstrap.pypa.io", + "conda.binstar.org", + "conda.anaconda.org", + "binstar.org", + "anaconda.org", + "repo.continuum.io", + "repo.anaconda.com", + + // Ruby + "rubygems.org", + "api.rubygems.org", + "rubygems.pkg.github.com", + "bundler.rubygems.org", + "gems.rubyforge.org", + "gems.rubyonrails.org", + "index.rubygems.org", + "cache.ruby-lang.org", + "*.rvm.io", + + // Rust + "crates.io", + "index.crates.io", + "static.crates.io", + "sh.rustup.rs", + "static.rust-lang.org", + + // Swift + "download.swift.org", + "swift.org", + "cocoapods.org", + "cdn.cocoapods.org", + + // Google Cloud Storage (used by various package managers) + //"storage.googleapis.com", + // TODO: paths + //url: { scheme: ["https"], domain: storage.googleapis.com, path: "/pub-packages/" } + //url: { scheme: ["https"], domain: storage.googleapis.com, path: "/proxy-golang-org-prod/" } + //url: { scheme: ["https"], domain: uploads.github.com, path: "/copilot/chat/attachments/" } + + } +} + // ShouldEnforceNetworkPermissions checks if network permissions should be enforced -// Returns true if the engine config has a network permissions block configured -// (regardless of whether the allowed list is empty or has domains) -func ShouldEnforceNetworkPermissions(engineConfig *EngineConfig) bool { - return engineConfig != nil && - engineConfig.ID == "claude" && - engineConfig.Permissions != nil && - engineConfig.Permissions.Network != nil +// Returns true if network permissions are configured and not in "defaults" mode +func ShouldEnforceNetworkPermissions(network *NetworkPermissions) bool { + if network == nil { + return false // No network config, defaults to full access + } + if network.Mode == "defaults" { + return true // "defaults" mode uses restricted whitelist (enforcement needed) + } + return true // Object format means some restriction is configured } -// GetAllowedDomains returns the allowed domains from engine config -// Returns nil if no network permissions configured (unrestricted for backwards compatibility) +// GetAllowedDomains returns the allowed domains from network permissions +// Returns default whitelist if no network permissions configured or in "defaults" mode // Returns empty slice if network permissions configured but no domains allowed (deny all) // Returns domain list if network permissions configured with allowed domains -func GetAllowedDomains(engineConfig *EngineConfig) []string { - if !ShouldEnforceNetworkPermissions(engineConfig) { - return nil // No restrictions - backwards compatibility +func GetAllowedDomains(network *NetworkPermissions) []string { + if network == nil { + return getDefaultAllowedDomains() // Default whitelist for backwards compatibility + } + if network.Mode == "defaults" { + return getDefaultAllowedDomains() // Default whitelist for defaults mode } - return engineConfig.Permissions.Network.Allowed // Could be empty for deny-all + return network.Allowed // Could be empty for deny-all } // HasNetworkPermissions is deprecated - use ShouldEnforceNetworkPermissions instead // Kept for backwards compatibility but will be removed in future versions func HasNetworkPermissions(engineConfig *EngineConfig) bool { - return ShouldEnforceNetworkPermissions(engineConfig) + // This function is now deprecated since network permissions are top-level + // Return false for backwards compatibility + return false } diff --git a/pkg/workflow/engine_network_test.go b/pkg/workflow/engine_network_test.go index e12f4e16c6..7ef2d9c4a3 100644 --- a/pkg/workflow/engine_network_test.go +++ b/pkg/workflow/engine_network_test.go @@ -36,195 +36,126 @@ func TestNetworkHookGenerator(t *testing.T) { if !strings.Contains(script, "def is_domain_allowed") { t.Error("Script should define is_domain_allowed function") } - - // Check for WebFetch and WebSearch handling - if !strings.Contains(script, "WebFetch") && !strings.Contains(script, "WebSearch") { - t.Error("Script should handle WebFetch and WebSearch tools") - } }) t.Run("GenerateNetworkHookWorkflowStep", func(t *testing.T) { - allowedDomains := []string{"example.com", "test.org"} + allowedDomains := []string{"api.github.com", "*.trusted.com"} step := generator.GenerateNetworkHookWorkflowStep(allowedDomains) - // Check step structure - if len(step) == 0 { - t.Fatal("Step should not be empty") - } - stepStr := strings.Join(step, "\n") - if !strings.Contains(stepStr, "Generate Network Permissions Hook") { + + // Check that the step contains proper YAML structure + if !strings.Contains(stepStr, "name: Generate Network Permissions Hook") { t.Error("Step should have correct name") } - if !strings.Contains(stepStr, "mkdir -p .claude/hooks") { - t.Error("Step should create hooks directory") - } if !strings.Contains(stepStr, ".claude/hooks/network_permissions.py") { - t.Error("Step should create network permissions hook file") + t.Error("Step should create hook file in correct location") } if !strings.Contains(stepStr, "chmod +x") { t.Error("Step should make hook executable") } - }) - t.Run("EmptyDomainsList", func(t *testing.T) { - script := generator.GenerateNetworkHookScript([]string{}) - if !strings.Contains(script, "ALLOWED_DOMAINS = []") { - t.Error("Empty domains list should result in empty ALLOWED_DOMAINS array") + // Check that domains are included in the hook + if !strings.Contains(stepStr, "api.github.com") { + t.Error("Step should contain api.github.com domain") } - }) -} - -func TestClaudeSettingsGenerator(t *testing.T) { - generator := &ClaudeSettingsGenerator{} - - t.Run("GenerateSettingsJSON", func(t *testing.T) { - settingsJSON := generator.GenerateSettingsJSON() - - // Check JSON structure - if !strings.Contains(settingsJSON, `"hooks"`) { - t.Error("Settings should contain hooks section") - } - if !strings.Contains(settingsJSON, `"PreToolUse"`) { - t.Error("Settings should contain PreToolUse hooks") - } - if !strings.Contains(settingsJSON, `"WebFetch|WebSearch"`) { - t.Error("Settings should match WebFetch and WebSearch tools") - } - if !strings.Contains(settingsJSON, `.claude/hooks/network_permissions.py`) { - t.Error("Settings should reference network permissions hook") - } - if !strings.Contains(settingsJSON, `"type": "command"`) { - t.Error("Settings should specify command hook type") + if !strings.Contains(stepStr, "*.trusted.com") { + t.Error("Step should contain *.trusted.com domain") } }) - t.Run("GenerateSettingsWorkflowStep", func(t *testing.T) { - step := generator.GenerateSettingsWorkflowStep() - - // Check step structure - if len(step) == 0 { - t.Fatal("Step should not be empty") - } + t.Run("EmptyDomainsGeneration", func(t *testing.T) { + allowedDomains := []string{} // Empty list means deny-all + script := generator.GenerateNetworkHookScript(allowedDomains) - stepStr := strings.Join(step, "\n") - if !strings.Contains(stepStr, "Generate Claude Settings") { - t.Error("Step should have correct name") - } - if !strings.Contains(stepStr, ".claude/settings.json") { - t.Error("Step should create settings.json file") + // Should still generate a valid script + if !strings.Contains(script, "ALLOWED_DOMAINS = []") { + t.Error("Script should handle empty domains list (deny-all policy)") } - if !strings.Contains(stepStr, "EOF") { - t.Error("Step should use heredoc syntax") + if !strings.Contains(script, "def is_domain_allowed") { + t.Error("Script should still define required functions") } }) } -func TestNetworkPermissionsHelpers(t *testing.T) { - t.Run("HasNetworkPermissions", func(t *testing.T) { - // Test nil config - if HasNetworkPermissions(nil) { - t.Error("nil config should not have network permissions") - } - - // Test config without permissions - config := &EngineConfig{ID: "claude"} - if HasNetworkPermissions(config) { - t.Error("Config without permissions should not have network permissions") - } - - // Test config with empty permissions - config.Permissions = &EnginePermissions{} - if HasNetworkPermissions(config) { - t.Error("Config with empty permissions should not have network permissions") +func TestShouldEnforceNetworkPermissions(t *testing.T) { + t.Run("nil permissions", func(t *testing.T) { + if ShouldEnforceNetworkPermissions(nil) { + t.Error("Should not enforce permissions when nil") } + }) - // Test config with empty network permissions (empty struct) - config.Permissions.Network = &NetworkPermissions{} - if !HasNetworkPermissions(config) { - t.Error("Config with empty network permissions struct should have network permissions (deny-all policy)") + t.Run("valid permissions with domains", func(t *testing.T) { + permissions := &NetworkPermissions{ + Allowed: []string{"example.com", "*.trusted.com"}, } - - // Test config with network permissions - config.Permissions.Network.Allowed = []string{"example.com"} - if !HasNetworkPermissions(config) { - t.Error("Config with network permissions should have network permissions") + if !ShouldEnforceNetworkPermissions(permissions) { + t.Error("Should enforce permissions when provided") } + }) - // Test non-Claude engine with network permissions (should be false) - nonClaudeConfig := &EngineConfig{ - ID: "codex", - Permissions: &EnginePermissions{ - Network: &NetworkPermissions{ - Allowed: []string{"example.com"}, - }, - }, + t.Run("empty permissions (deny-all)", func(t *testing.T) { + permissions := &NetworkPermissions{ + Allowed: []string{}, // Empty list means deny-all } - if HasNetworkPermissions(nonClaudeConfig) { - t.Error("Non-Claude engine should not have network permissions even if configured") + if !ShouldEnforceNetworkPermissions(permissions) { + t.Error("Should enforce permissions even with empty allowed list (deny-all policy)") } }) +} - t.Run("GetAllowedDomains", func(t *testing.T) { - // Test nil config +func TestGetAllowedDomains(t *testing.T) { + t.Run("nil permissions", func(t *testing.T) { domains := GetAllowedDomains(nil) - if domains != nil { - t.Error("nil config should return nil (no restrictions)") + if domains == nil { + t.Error("Should return default whitelist when permissions are nil") } - - // Test config without permissions - config := &EngineConfig{ID: "claude"} - domains = GetAllowedDomains(config) - if domains != nil { - t.Error("Config without permissions should return nil (no restrictions)") + if len(domains) == 0 { + t.Error("Expected default whitelist domains for nil permissions, got empty list") } + }) - // Test config with empty network permissions (deny-all policy) - config.Permissions = &EnginePermissions{ - Network: &NetworkPermissions{ - Allowed: []string{}, // Empty list means deny-all - }, + t.Run("empty permissions (deny-all)", func(t *testing.T) { + permissions := &NetworkPermissions{ + Allowed: []string{}, // Empty list means deny-all } - domains = GetAllowedDomains(config) + domains := GetAllowedDomains(permissions) if domains == nil { - t.Error("Config with empty network permissions should return empty slice (deny-all policy)") + t.Error("Should return empty slice, not nil, for deny-all policy") } if len(domains) != 0 { t.Errorf("Expected 0 domains for deny-all policy, got %d", len(domains)) } + }) - // Test config with network permissions - config.Permissions = &EnginePermissions{ - Network: &NetworkPermissions{ - Allowed: []string{"example.com", "*.trusted.com", "api.service.org"}, - }, + t.Run("valid permissions with domains", func(t *testing.T) { + permissions := &NetworkPermissions{ + Allowed: []string{"example.com", "*.trusted.com", "api.service.org"}, } - domains = GetAllowedDomains(config) - if len(domains) != 3 { - t.Errorf("Expected 3 domains, got %d", len(domains)) - } - if domains[0] != "example.com" { - t.Errorf("Expected first domain to be 'example.com', got '%s'", domains[0]) - } - if domains[1] != "*.trusted.com" { - t.Errorf("Expected second domain to be '*.trusted.com', got '%s'", domains[1]) + domains := GetAllowedDomains(permissions) + expectedDomains := []string{"example.com", "*.trusted.com", "api.service.org"} + if len(domains) != len(expectedDomains) { + t.Fatalf("Expected %d domains, got %d", len(expectedDomains), len(domains)) } - if domains[2] != "api.service.org" { - t.Errorf("Expected third domain to be 'api.service.org', got '%s'", domains[2]) + + for i, expected := range expectedDomains { + if domains[i] != expected { + t.Errorf("Expected domain %d to be '%s', got '%s'", i, expected, domains[i]) + } } + }) +} - // Test non-Claude engine with network permissions (should return empty) - nonClaudeConfig := &EngineConfig{ - ID: "codex", - Permissions: &EnginePermissions{ - Network: &NetworkPermissions{ - Allowed: []string{"example.com", "test.org"}, - }, - }, +func TestDeprecatedHasNetworkPermissions(t *testing.T) { + t.Run("deprecated function always returns false", func(t *testing.T) { + // Test that the deprecated function always returns false + if HasNetworkPermissions(nil) { + t.Error("Deprecated HasNetworkPermissions should always return false") } - domains = GetAllowedDomains(nonClaudeConfig) - if len(domains) != 0 { - t.Error("Non-Claude engine should return empty domains even if configured") + + config := &EngineConfig{ID: "claude"} + if HasNetworkPermissions(config) { + t.Error("Deprecated HasNetworkPermissions should always return false") } }) } @@ -234,48 +165,25 @@ func TestEngineConfigParsing(t *testing.T) { t.Run("ParseNetworkPermissions", func(t *testing.T) { frontmatter := map[string]any{ - "engine": map[string]any{ - "id": "claude", - "model": "claude-3-5-sonnet-20241022", - "permissions": map[string]any{ - "network": map[string]any{ - "allowed": []any{"example.com", "*.trusted.com", "api.service.org"}, - }, - }, + "network": map[string]any{ + "allowed": []any{"example.com", "*.trusted.com", "api.service.org"}, }, } - engineSetting, engineConfig := compiler.extractEngineConfig(frontmatter) - - if engineSetting != "claude" { - t.Errorf("Expected engine setting 'claude', got '%s'", engineSetting) - } - - if engineConfig == nil { - t.Fatal("Engine config should not be nil") - } - - if engineConfig.ID != "claude" { - t.Errorf("Expected engine ID 'claude', got '%s'", engineConfig.ID) - } - - if engineConfig.Model != "claude-3-5-sonnet-20241022" { - t.Errorf("Expected model 'claude-3-5-sonnet-20241022', got '%s'", engineConfig.Model) - } + networkPermissions := compiler.extractNetworkPermissions(frontmatter) - if !HasNetworkPermissions(engineConfig) { - t.Error("Engine config should have network permissions") + if networkPermissions == nil { + t.Fatal("Network permissions should not be nil") } - domains := GetAllowedDomains(engineConfig) expectedDomains := []string{"example.com", "*.trusted.com", "api.service.org"} - if len(domains) != len(expectedDomains) { - t.Fatalf("Expected %d domains, got %d", len(expectedDomains), len(domains)) + if len(networkPermissions.Allowed) != len(expectedDomains) { + t.Fatalf("Expected %d domains, got %d", len(expectedDomains), len(networkPermissions.Allowed)) } for i, expected := range expectedDomains { - if domains[i] != expected { - t.Errorf("Expected domain %d to be '%s', got '%s'", i, expected, domains[i]) + if networkPermissions.Allowed[i] != expected { + t.Errorf("Expected domain %d to be '%s', got '%s'", i, expected, networkPermissions.Allowed[i]) } } }) @@ -288,23 +196,28 @@ func TestEngineConfigParsing(t *testing.T) { }, } - engineSetting, engineConfig := compiler.extractEngineConfig(frontmatter) + networkPermissions := compiler.extractNetworkPermissions(frontmatter) - if engineSetting != "claude" { - t.Errorf("Expected engine setting 'claude', got '%s'", engineSetting) + if networkPermissions != nil { + t.Error("Network permissions should be nil when not specified") } + }) - if engineConfig == nil { - t.Fatal("Engine config should not be nil") + t.Run("ParseEmptyNetworkPermissions", func(t *testing.T) { + frontmatter := map[string]any{ + "network": map[string]any{ + "allowed": []any{}, // Empty list means deny-all + }, } - if HasNetworkPermissions(engineConfig) { - t.Error("Engine config should not have network permissions") + networkPermissions := compiler.extractNetworkPermissions(frontmatter) + + if networkPermissions == nil { + t.Fatal("Network permissions should not be nil") } - domains := GetAllowedDomains(engineConfig) - if len(domains) != 0 { - t.Errorf("Expected 0 domains, got %d", len(domains)) + if len(networkPermissions.Allowed) != 0 { + t.Errorf("Expected 0 domains for deny-all policy, got %d", len(networkPermissions.Allowed)) } }) } diff --git a/pkg/workflow/strict.go b/pkg/workflow/strict.go deleted file mode 100644 index 0e30ef2041..0000000000 --- a/pkg/workflow/strict.go +++ /dev/null @@ -1,29 +0,0 @@ -package workflow - -import ( - "fmt" - "strings" - - "github.com/githubnext/gh-aw/pkg/console" -) - -// extractStrictMode extracts strict mode setting from frontmatter -func (c *Compiler) extractStrictMode(frontmatter map[string]any) bool { - if strict, exists := frontmatter["strict"]; exists { - if strictBool, ok := strict.(bool); ok { - return strictBool - } - } - return false // Default to false if not specified or not a boolean -} - -// validatePermissionsInStrictMode checks permissions in strict mode and warns about write permissions -func (c *Compiler) validatePermissionsInStrictMode(permissions string) { - if permissions == "" { - return - } - hasWritePermissions := strings.Contains(permissions, "write") - if hasWritePermissions { - fmt.Println(console.FormatWarningMessage("Strict mode: Found 'write' permissions. Consider using 'read' permissions only for better security.")) - } -}