diff --git a/.github/workflows/test-proxy.lock.yml b/.github/workflows/test-proxy.lock.yml new file mode 100644 index 00000000000..49213963d61 --- /dev/null +++ b/.github/workflows/test-proxy.lock.yml @@ -0,0 +1,616 @@ +# This file was automatically generated by gh-aw. DO NOT EDIT. +# To update this file, edit the corresponding .md file and run: +# gh aw compile + +name: "Test Network Permissions" +on: + pull_request: + branches: + - main + workflow_dispatch: null + +permissions: {} + +concurrency: + group: "gh-aw-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}" + cancel-in-progress: true + +run-name: "Test Network Permissions" + +jobs: + test-network-permissions: + runs-on: ubuntu-latest + permissions: + issues: write + outputs: + output: ${{ steps.collect_output.outputs.output }} + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Setup agent output + id: setup_agent_output + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + const crypto = require('crypto'); + // Generate a random filename for the output file + const randomId = crypto.randomBytes(8).toString('hex'); + const outputFile = `/tmp/aw_output_${randomId}.txt`; + // Ensure the /tmp directory exists and create empty output file + fs.mkdirSync('/tmp', { recursive: true }); + fs.writeFileSync(outputFile, '', { mode: 0o644 }); + // Verify the file was created and is writable + if (!fs.existsSync(outputFile)) { + throw new Error(`Failed to create output file: ${outputFile}`); + } + // Set the environment variable for subsequent steps + core.exportVariable('GITHUB_AW_OUTPUT', outputFile); + console.log('Created agentic output file:', outputFile); + // Also set as step output for reference + core.setOutput('output_file', outputFile); + - name: Setup Proxy Configuration for MCP Network Restrictions + run: | + echo "Generating proxy configuration files for MCP tools with network restrictions..." + + # Generate Squid proxy configuration + cat > squid.conf << 'EOF' + # Squid configuration for egress traffic control + # This configuration implements a whitelist-based proxy + + # Access log and cache configuration + access_log /var/log/squid/access.log squid + cache_log /var/log/squid/cache.log + cache deny all + + # Port configuration + http_port 3128 + + # ACL definitions for allowed domains + acl allowed_domains dstdomain "/etc/squid/allowed_domains.txt" + acl localnet src 10.0.0.0/8 + acl localnet src 172.16.0.0/12 + acl localnet src 192.168.0.0/16 + acl SSL_ports port 443 + acl Safe_ports port 80 + acl Safe_ports port 443 + acl CONNECT method CONNECT + + # Access rules + # Deny requests to unknown domains (not in whitelist) + http_access deny !allowed_domains + http_access deny !Safe_ports + http_access deny CONNECT !SSL_ports + http_access allow localnet + http_access deny all + + # Disable caching + cache deny all + + # DNS settings + dns_nameservers 8.8.8.8 8.8.4.4 + + # Forwarded headers + forwarded_for delete + via off + + # Error page customization + error_directory /usr/share/squid/errors/English + + # Logging + logformat combined %>a %[ui %[un [%tl] "%rm %ru HTTP/%rv" %>Hs %h" "%{User-Agent}>h" %Ss:%Sh + access_log /var/log/squid/access.log combined + + # Memory and file descriptor limits + cache_mem 64 MB + maximum_object_size 0 KB + + EOF + + # Generate allowed domains file + cat > allowed_domains.txt << 'EOF' + # Allowed domains for egress traffic + # Add one domain per line + example.com + + EOF + + # Generate Docker Compose configuration for fetch + cat > docker-compose-fetch.yml << 'EOF' + services: + squid-proxy: + image: ubuntu/squid:latest + container_name: squid-proxy-fetch + ports: + - "3128:3128" + volumes: + - ./squid.conf:/etc/squid/squid.conf:ro + - ./allowed_domains.txt:/etc/squid/allowed_domains.txt:ro + - squid-logs:/var/log/squid + healthcheck: + test: ["CMD", "squid", "-k", "check"] + interval: 30s + timeout: 10s + retries: 3 + restart: unless-stopped + networks: + awproxy-fetch: + ipv4_address: 172.28.179.10 + + fetch: + image: mcp/fetch + container_name: fetch-mcp + stdin_open: true + tty: true + environment: + - PROXY_HOST=squid-proxy + - PROXY_PORT=3128 + - HTTP_PROXY=http://squid-proxy:3128 + - HTTPS_PROXY=http://squid-proxy:3128 + networks: + - awproxy-fetch + depends_on: + squid-proxy: + condition: service_healthy + + volumes: + squid-logs: + + networks: + awproxy-fetch: + driver: bridge + ipam: + config: + - subnet: 172.28.179.0/24 + + EOF + + echo "Proxy configuration files generated." + - name: Pre-pull images and start Squid proxy + run: | + set -e + echo 'Pre-pulling Docker images for proxy-enabled MCP tools...' + docker pull ubuntu/squid:latest + echo 'Pulling mcp/fetch for tool fetch' + docker pull mcp/fetch + echo 'Starting squid-proxy service for fetch' + docker compose -f docker-compose-fetch.yml up -d squid-proxy + echo 'Enforcing egress to proxy for fetch (subnet 172.28.179.0/24, squid 172.28.179.10)' + if command -v sudo >/dev/null 2>&1; then SUDO=sudo; else SUDO=; fi + $SUDO iptables -C DOCKER-USER -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT 2>/dev/null || $SUDO iptables -I DOCKER-USER 1 -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT + $SUDO iptables -C DOCKER-USER -s 172.28.179.10 -j ACCEPT 2>/dev/null || $SUDO iptables -I DOCKER-USER 2 -s 172.28.179.10 -j ACCEPT + $SUDO iptables -C DOCKER-USER -s 172.28.179.0/24 -d 172.28.179.10 -p tcp --dport 3128 -j ACCEPT 2>/dev/null || $SUDO iptables -I DOCKER-USER 3 -s 172.28.179.0/24 -d 172.28.179.10 -p tcp --dport 3128 -j ACCEPT + $SUDO iptables -C DOCKER-USER -s 172.28.179.0/24 -j REJECT 2>/dev/null || $SUDO iptables -A DOCKER-USER -s 172.28.179.0/24 -j REJECT + - name: Setup MCPs + run: | + mkdir -p /tmp/mcp-config + cat > /tmp/mcp-config/mcp-servers.json << 'EOF' + { + "mcpServers": { + "fetch": { + "command": "docker", + "args": [ + "compose", + "-f", + "docker-compose-fetch.yml", + "run", + "--rm", + "fetch" + ] + }, + "github": { + "command": "docker", + "args": [ + "run", + "-i", + "--rm", + "-e", + "GITHUB_PERSONAL_ACCESS_TOKEN", + "ghcr.io/github/github-mcp-server:sha-45e90ae" + ], + "env": { + "GITHUB_PERSONAL_ACCESS_TOKEN": "${{ secrets.GITHUB_TOKEN }}" + } + } + } + } + EOF + - name: Create prompt + env: + GITHUB_AW_OUTPUT: ${{ env.GITHUB_AW_OUTPUT }} + run: | + mkdir -p /tmp/aw-prompts + cat > /tmp/aw-prompts/prompt.txt << 'EOF' + # Test Network Permissions + + ## Task Description + + Test the MCP network permissions feature to validate that domain restrictions are properly enforced. + + - Use the fetch tool to successfully retrieve content from `https://example.com/` (the only allowed domain) + - Attempt to access blocked domains and verify they fail with network errors: + - `https://httpbin.org/json` + - `https://api.github.com/user` + - `https://www.google.com/` + - `http://malicious-example.com/` + - Verify that all blocked requests fail at the network level (proxy enforcement) + - Confirm that only example.com is accessible through the Squid proxy + + Create a GitHub issue with the test results, documenting: + - Which domains were successfully accessed vs blocked + - Error messages received for blocked domains + - Confirmation that network isolation is working correctly + - Any security observations or recommendations + + The test should demonstrate that MCP containers are properly isolated and can only access explicitly allowed domains through the network proxy. + + + --- + + **IMPORTANT**: If you need to provide output that should be captured as a workflow output variable, write it to the file "${{ env.GITHUB_AW_OUTPUT }}". This file is available for you to write any output that should be exposed from this workflow. The content of this file will be made available as the 'output' workflow output. + EOF + - name: Print prompt to step summary + run: | + echo "## Generated Prompt" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo '``````markdown' >> $GITHUB_STEP_SUMMARY + cat /tmp/aw-prompts/prompt.txt >> $GITHUB_STEP_SUMMARY + echo '``````' >> $GITHUB_STEP_SUMMARY + - name: Generate agentic run info + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + + const awInfo = { + engine_id: "claude", + engine_name: "Claude Code", + model: "", + version: "", + workflow_name: "Test Network Permissions", + experimental: false, + supports_tools_whitelist: true, + supports_http_transport: true, + run_id: context.runId, + run_number: context.runNumber, + run_attempt: process.env.GITHUB_RUN_ATTEMPT, + repository: context.repo.owner + '/' + context.repo.repo, + ref: context.ref, + sha: context.sha, + actor: context.actor, + event_name: context.eventName, + created_at: new Date().toISOString() + }; + + // Write to /tmp directory to avoid inclusion in PR + const tmpPath = '/tmp/aw_info.json'; + fs.writeFileSync(tmpPath, JSON.stringify(awInfo, null, 2)); + console.log('Generated aw_info.json at:', tmpPath); + console.log(JSON.stringify(awInfo, null, 2)); + - name: Upload agentic run info + if: always() + uses: actions/upload-artifact@v4 + with: + name: aw_info.json + path: /tmp/aw_info.json + if-no-files-found: warn + - name: Execute Claude Code Action + id: agentic_execution + uses: anthropics/claude-code-base-action@v0.0.56 + with: + # Allowed tools (sorted): + # - Glob + # - Grep + # - LS + # - NotebookRead + # - Read + # - Task + # - mcp__fetch__fetch + # - mcp__github__create_comment + # - mcp__github__create_issue + # - mcp__github__download_workflow_run_artifact + # - mcp__github__get_code_scanning_alert + # - mcp__github__get_commit + # - mcp__github__get_dependabot_alert + # - mcp__github__get_discussion + # - mcp__github__get_discussion_comments + # - mcp__github__get_file_contents + # - mcp__github__get_issue + # - mcp__github__get_issue_comments + # - mcp__github__get_job_logs + # - mcp__github__get_me + # - mcp__github__get_notification_details + # - mcp__github__get_pull_request + # - mcp__github__get_pull_request_comments + # - mcp__github__get_pull_request_diff + # - mcp__github__get_pull_request_files + # - mcp__github__get_pull_request_reviews + # - mcp__github__get_pull_request_status + # - mcp__github__get_secret_scanning_alert + # - mcp__github__get_tag + # - mcp__github__get_workflow_run + # - mcp__github__get_workflow_run_logs + # - mcp__github__get_workflow_run_usage + # - mcp__github__list_branches + # - mcp__github__list_code_scanning_alerts + # - mcp__github__list_commits + # - mcp__github__list_dependabot_alerts + # - mcp__github__list_discussion_categories + # - mcp__github__list_discussions + # - mcp__github__list_issues + # - mcp__github__list_notifications + # - mcp__github__list_pull_requests + # - mcp__github__list_secret_scanning_alerts + # - mcp__github__list_tags + # - mcp__github__list_workflow_jobs + # - mcp__github__list_workflow_run_artifacts + # - mcp__github__list_workflow_runs + # - mcp__github__list_workflows + # - mcp__github__search_code + # - mcp__github__search_issues + # - mcp__github__search_orgs + # - mcp__github__search_pull_requests + # - mcp__github__search_repositories + # - mcp__github__search_users + allowed_tools: "Glob,Grep,LS,NotebookRead,Read,Task,mcp__fetch__fetch,mcp__github__create_comment,mcp__github__create_issue,mcp__github__download_workflow_run_artifact,mcp__github__get_code_scanning_alert,mcp__github__get_commit,mcp__github__get_dependabot_alert,mcp__github__get_discussion,mcp__github__get_discussion_comments,mcp__github__get_file_contents,mcp__github__get_issue,mcp__github__get_issue_comments,mcp__github__get_job_logs,mcp__github__get_me,mcp__github__get_notification_details,mcp__github__get_pull_request,mcp__github__get_pull_request_comments,mcp__github__get_pull_request_diff,mcp__github__get_pull_request_files,mcp__github__get_pull_request_reviews,mcp__github__get_pull_request_status,mcp__github__get_secret_scanning_alert,mcp__github__get_tag,mcp__github__get_workflow_run,mcp__github__get_workflow_run_logs,mcp__github__get_workflow_run_usage,mcp__github__list_branches,mcp__github__list_code_scanning_alerts,mcp__github__list_commits,mcp__github__list_dependabot_alerts,mcp__github__list_discussion_categories,mcp__github__list_discussions,mcp__github__list_issues,mcp__github__list_notifications,mcp__github__list_pull_requests,mcp__github__list_secret_scanning_alerts,mcp__github__list_tags,mcp__github__list_workflow_jobs,mcp__github__list_workflow_run_artifacts,mcp__github__list_workflow_runs,mcp__github__list_workflows,mcp__github__search_code,mcp__github__search_issues,mcp__github__search_orgs,mcp__github__search_pull_requests,mcp__github__search_repositories,mcp__github__search_users" + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + claude_env: | + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_AW_OUTPUT: ${{ env.GITHUB_AW_OUTPUT }} + mcp_config: /tmp/mcp-config/mcp-servers.json + prompt_file: /tmp/aw-prompts/prompt.txt + timeout_minutes: 5 + env: + GITHUB_AW_OUTPUT: ${{ env.GITHUB_AW_OUTPUT }} + - name: Capture Agentic Action logs + if: always() + run: | + # Copy the detailed execution file from Agentic Action if available + if [ -n "${{ steps.agentic_execution.outputs.execution_file }}" ] && [ -f "${{ steps.agentic_execution.outputs.execution_file }}" ]; then + cp ${{ steps.agentic_execution.outputs.execution_file }} /tmp/test-network-permissions.log + else + echo "No execution file output found from Agentic Action" >> /tmp/test-network-permissions.log + fi + + # Ensure log file exists + touch /tmp/test-network-permissions.log + - name: Check if workflow-complete.txt exists, if so upload it + id: check_file + run: | + if [ -f workflow-complete.txt ]; then + echo "File exists" + echo "upload=true" >> $GITHUB_OUTPUT + else + echo "File does not exist" + echo "upload=false" >> $GITHUB_OUTPUT + fi + - name: Upload workflow-complete.txt + if: steps.check_file.outputs.upload == 'true' + uses: actions/upload-artifact@v4 + with: + name: workflow-complete + path: workflow-complete.txt + - name: Collect agent output + id: collect_output + uses: actions/github-script@v7 + with: + script: | + /** + * Sanitizes content for safe output in GitHub Actions + * @param {string} content - The content to sanitize + * @returns {string} The sanitized content + */ + function sanitizeContent(content) { + if (!content || typeof content !== 'string') { + return ''; + } + // Read allowed domains from environment variable + const allowedDomainsEnv = process.env.GITHUB_AW_ALLOWED_DOMAINS; + const defaultAllowedDomains = [ + 'github.com', + 'github.io', + 'githubusercontent.com', + 'githubassets.com', + 'github.dev', + 'codespaces.new' + ]; + const allowedDomains = allowedDomainsEnv + ? allowedDomainsEnv.split(',').map(d => d.trim()).filter(d => d) + : defaultAllowedDomains; + let sanitized = content; + // Neutralize @mentions to prevent unintended notifications + sanitized = neutralizeMentions(sanitized); + // Remove control characters (except newlines and tabs) + sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ''); + // XML character escaping + sanitized = sanitized + .replace(/&/g, '&') // Must be first to avoid double-escaping + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + // URI filtering - replace non-https protocols with "(redacted)" + // Step 1: Temporarily mark HTTPS URLs to protect them + sanitized = sanitizeUrlProtocols(sanitized); + // Domain filtering for HTTPS URIs + // Match https:// URIs and check if domain is in allowlist + sanitized = sanitizeUrlDomains(sanitized); + // Limit total length to prevent DoS (0.5MB max) + const maxLength = 524288; + if (sanitized.length > maxLength) { + sanitized = sanitized.substring(0, maxLength) + '\n[Content truncated due to length]'; + } + // Limit number of lines to prevent log flooding (65k max) + const lines = sanitized.split('\n'); + const maxLines = 65000; + if (lines.length > maxLines) { + sanitized = lines.slice(0, maxLines).join('\n') + '\n[Content truncated due to line count]'; + } + // Remove ANSI escape sequences + sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ''); + // Neutralize common bot trigger phrases + sanitized = neutralizeBotTriggers(sanitized); + // Trim excessive whitespace + return sanitized.trim(); + /** + * Remove unknown domains + * @param {string} s - The string to process + * @returns {string} The string with unknown domains redacted + */ + function sanitizeUrlDomains(s) { + s = s.replace(/\bhttps:\/\/([^\/\s\])}'"<>&\x00-\x1f]+)/gi, (match, domain) => { + // Extract the hostname part (before first slash, colon, or other delimiter) + const hostname = domain.split(/[\/:\?#]/)[0].toLowerCase(); + // Check if this domain or any parent domain is in the allowlist + const isAllowed = allowedDomains.some(allowedDomain => { + const normalizedAllowed = allowedDomain.toLowerCase(); + return hostname === normalizedAllowed || hostname.endsWith('.' + normalizedAllowed); + }); + return isAllowed ? match : '(redacted)'; + }); + return s; + } + /** + * Remove unknown protocols except https + * @param {string} s - The string to process + * @returns {string} The string with non-https protocols redacted + */ + function sanitizeUrlProtocols(s) { + // Match both protocol:// and protocol: patterns + // This covers URLs like https://example.com, javascript:alert(), mailto:user@domain.com, etc. + return s.replace(/\b(\w+):(?:\/\/)?[^\s\])}'"<>&\x00-\x1f]+/gi, (match, protocol) => { + // Allow https (case insensitive), redact everything else + return protocol.toLowerCase() === 'https' ? match : '(redacted)'; + }); + } + /** + * Neutralizes @mentions by wrapping them in backticks + * @param {string} s - The string to process + * @returns {string} The string with neutralized mentions + */ + function neutralizeMentions(s) { + // Replace @name or @org/team outside code with `@name` + return s.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, + (_m, p1, p2) => `${p1}\`@${p2}\``); + } + /** + * Neutralizes bot trigger phrases by wrapping them in backticks + * @param {string} s - The string to process + * @returns {string} The string with neutralized bot triggers + */ + function neutralizeBotTriggers(s) { + // Neutralize common bot trigger phrases like "fixes #123", "closes #asdfs", etc. + return s.replace(/\b(fixes?|closes?|resolves?|fix|close|resolve)\s+#(\w+)/gi, + (match, action, ref) => `\`${action} #${ref}\``); + } + } + async function main() { + const fs = require("fs"); + const outputFile = process.env.GITHUB_AW_OUTPUT; + if (!outputFile) { + console.log('GITHUB_AW_OUTPUT not set, no output to collect'); + core.setOutput('output', ''); + return; + } + if (!fs.existsSync(outputFile)) { + console.log('Output file does not exist:', outputFile); + core.setOutput('output', ''); + return; + } + const outputContent = fs.readFileSync(outputFile, 'utf8'); + if (outputContent.trim() === '') { + console.log('Output file is empty'); + core.setOutput('output', ''); + } else { + const sanitizedContent = sanitizeContent(outputContent); + console.log('Collected agentic output (sanitized):', sanitizedContent.substring(0, 200) + (sanitizedContent.length > 200 ? '...' : '')); + core.setOutput('output', sanitizedContent); + } + } + await main(); + - name: Print agent output to step summary + env: + GITHUB_AW_OUTPUT: ${{ env.GITHUB_AW_OUTPUT }} + run: | + echo "## Agent Output" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo '``````markdown' >> $GITHUB_STEP_SUMMARY + cat ${{ env.GITHUB_AW_OUTPUT }} >> $GITHUB_STEP_SUMMARY + echo '``````' >> $GITHUB_STEP_SUMMARY + - name: Upload agentic output file + if: always() && steps.collect_output.outputs.output != '' + uses: actions/upload-artifact@v4 + with: + name: aw_output.txt + path: ${{ env.GITHUB_AW_OUTPUT }} + if-no-files-found: warn + - name: Upload agent logs + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-network-permissions.log + path: /tmp/test-network-permissions.log + if-no-files-found: warn + - name: Generate git patch + if: always() + run: | + # Check current git status + echo "Current git status:" + git status + # Get the initial commit SHA from the base branch of the pull request + if [ "$GITHUB_EVENT_NAME" = "pull_request" ] || [ "$GITHUB_EVENT_NAME" = "pull_request_review_comment" ]; then + INITIAL_SHA="$GITHUB_BASE_REF" + else + INITIAL_SHA="$GITHUB_SHA" + fi + echo "Base commit SHA: $INITIAL_SHA" + # Configure git user for GitHub Actions + git config --global user.email "action@github.com" + git config --global user.name "GitHub Action" + # Stage any unstaged files + git add -A || true + # Check if there are staged files to commit + if ! git diff --cached --quiet; then + echo "Staged files found, committing them..." + git commit -m "[agent] staged files" || true + echo "Staged files committed" + else + echo "No staged files to commit" + fi + # Check updated git status + echo "Updated git status after committing staged files:" + git status + # Show compact diff information between initial commit and HEAD (committed changes only) + echo '## Git diff' >> $GITHUB_STEP_SUMMARY + echo '' >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + git diff --name-only "$INITIAL_SHA"..HEAD >> $GITHUB_STEP_SUMMARY || true + echo '```' >> $GITHUB_STEP_SUMMARY + echo '' >> $GITHUB_STEP_SUMMARY + # Check if there are any committed changes since the initial commit + if git diff --quiet "$INITIAL_SHA" HEAD; then + echo "No committed changes detected since initial commit" + echo "Skipping patch generation - no committed changes to create patch from" + else + echo "Committed changes detected, generating patch..." + # Generate patch from initial commit to HEAD (committed changes only) + git format-patch "$INITIAL_SHA"..HEAD --stdout > /tmp/aw.patch || echo "Failed to generate patch" > /tmp/aw.patch + echo "Patch file created at /tmp/aw.patch" + ls -la /tmp/aw.patch + # Show the first 50 lines of the patch for review + echo '## Git Patch' >> $GITHUB_STEP_SUMMARY + echo '' >> $GITHUB_STEP_SUMMARY + echo '```diff' >> $GITHUB_STEP_SUMMARY + head -50 /tmp/aw.patch >> $GITHUB_STEP_SUMMARY || echo "Could not display patch contents" >> $GITHUB_STEP_SUMMARY + echo '...' >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + echo '' >> $GITHUB_STEP_SUMMARY + fi + - name: Upload git patch + if: always() + uses: actions/upload-artifact@v4 + with: + name: aw.patch + path: /tmp/aw.patch + if-no-files-found: ignore + diff --git a/.github/workflows/test-proxy.md b/.github/workflows/test-proxy.md new file mode 100644 index 00000000000..2f30a841086 --- /dev/null +++ b/.github/workflows/test-proxy.md @@ -0,0 +1,53 @@ +--- +on: + pull_request: + branches: [ "main" ] + workflow_dispatch: + +permissions: + issues: write # needed to write the output report to an issue + +tools: + fetch: + mcp: + type: stdio + container: mcp/fetch + permissions: + network: + allowed: + - "example.com" + allowed: + - "fetch" + + github: + allowed: + - "create_issue" + - "create_comment" + - "get_issue" + +engine: claude +runs-on: ubuntu-latest +--- + +# Test Network Permissions + +## Task Description + +Test the MCP network permissions feature to validate that domain restrictions are properly enforced. + +- Use the fetch tool to successfully retrieve content from `https://example.com/` (the only allowed domain) +- Attempt to access blocked domains and verify they fail with network errors: + - `https://httpbin.org/json` + - `https://api.github.com/user` + - `https://www.google.com/` + - `http://malicious-example.com/` +- Verify that all blocked requests fail at the network level (proxy enforcement) +- Confirm that only example.com is accessible through the Squid proxy + +Create a GitHub issue with the test results, documenting: +- Which domains were successfully accessed vs blocked +- Error messages received for blocked domains +- Confirmation that network isolation is working correctly +- Any security observations or recommendations + +The test should demonstrate that MCP containers are properly isolated and can only access explicitly allowed domains through the network proxy. diff --git a/pkg/parser/schemas/mcp_config_schema.json b/pkg/parser/schemas/mcp_config_schema.json index 2902c9f3d5a..8ba2fd7fbb6 100644 --- a/pkg/parser/schemas/mcp_config_schema.json +++ b/pkg/parser/schemas/mcp_config_schema.json @@ -14,30 +14,133 @@ "command": { "type": "string", "description": "Command for stdio MCP connections" + }, + "container": { + "type": "string", + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9/_.-]*$", + "description": "Container image for stdio MCP connections (alternative to command)" + }, + "args": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Arguments for command or container execution" + }, + "env": { + "type": "object", + "patternProperties": { + "^[A-Z_][A-Z0-9_]*$": { + "type": "string" + } + }, + "additionalProperties": false, + "description": "Environment variables for MCP server" + }, + "headers": { + "type": "object", + "patternProperties": { + "^[A-Za-z0-9-]+$": { + "type": "string" + } + }, + "additionalProperties": false, + "description": "HTTP headers for HTTP MCP connections" + }, + "permissions": { + "type": "object", + "properties": { + "network": { + "type": "object", + "properties": { + "allowed": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[a-zA-Z0-9]([a-zA-Z0-9\\-]{0,61}[a-zA-Z0-9])?(\\.[a-zA-Z0-9]([a-zA-Z0-9\\-]{0,61}[a-zA-Z0-9])?)*$", + "description": "Allowed domain name" + }, + "minItems": 1, + "uniqueItems": true, + "description": "List of allowed domain names for network access" + } + }, + "required": ["allowed"], + "additionalProperties": false, + "description": "Network access permissions" + } + }, + "additionalProperties": false, + "description": "Permissions configuration for container-based MCP servers" } }, "required": ["type"], - "additionalProperties": true, - "if": { - "properties": { - "type": { - "const": "http" + "additionalProperties": false, + "allOf": [ + { + "if": { + "properties": { + "type": { + "const": "http" + } + } + }, + "then": { + "required": ["url"], + "not": { + "anyOf": [ + {"required": ["command"]}, + {"required": ["container"]}, + {"required": ["permissions"]} + ] + } } - } - }, - "then": { - "required": ["url"] - }, - "else": { - "if": { - "properties": { - "type": { - "const": "stdio" + }, + { + "if": { + "properties": { + "type": { + "const": "stdio" + } + } + }, + "then": { + "anyOf": [ + {"required": ["command"]}, + {"required": ["container"]} + ], + "not": { + "allOf": [ + {"required": ["command"]}, + {"required": ["container"]} + ] } } }, - "then": { - "required": ["command"] + { + "if": { + "required": ["container"] + }, + "then": { + "properties": { + "type": { + "const": "stdio" + } + } + } + }, + { + "if": { + "required": ["permissions"] + }, + "then": { + "required": ["container"], + "properties": { + "type": { + "const": "stdio" + } + } + } } - } + ] } \ No newline at end of file diff --git a/pkg/workflow/compiler.go b/pkg/workflow/compiler.go index 438d97f8fdf..0bdc7c6dbcb 100644 --- a/pkg/workflow/compiler.go +++ b/pkg/workflow/compiler.go @@ -1912,6 +1912,8 @@ func (c *Compiler) generateSafetyChecks(yaml *strings.Builder, data *WorkflowDat func (c *Compiler) generateMCPSetup(yaml *strings.Builder, tools map[string]any, engine AgenticEngine) { // Collect tools that need MCP server configuration var mcpTools []string + var proxyTools []string + for toolName, toolValue := range tools { // Standard MCP tools if toolName == "github" { @@ -1920,12 +1922,72 @@ func (c *Compiler) generateMCPSetup(yaml *strings.Builder, tools map[string]any, // Check if it's explicitly marked as MCP type in the new format if hasMcp, _ := hasMCPConfig(mcpConfig); hasMcp { mcpTools = append(mcpTools, toolName) + + // Check if this tool needs proxy + if needsProxySetup, _ := needsProxy(mcpConfig); needsProxySetup { + proxyTools = append(proxyTools, toolName) + } } } } - // Sort MCP tools to ensure stable code generation + // Sort tools to ensure stable code generation sort.Strings(mcpTools) + sort.Strings(proxyTools) + + // Generate proxy configuration files inline for proxy-enabled tools + // These files will be used automatically by docker compose when MCP tools run + if len(proxyTools) > 0 { + yaml.WriteString(" - name: Setup Proxy Configuration for MCP Network Restrictions\n") + yaml.WriteString(" run: |\n") + yaml.WriteString(" echo \"Generating proxy configuration files for MCP tools with network restrictions...\"\n") + yaml.WriteString(" \n") + + // Generate proxy configurations inline for each proxy-enabled tool + for _, toolName := range proxyTools { + if toolConfig, ok := tools[toolName].(map[string]any); ok { + c.generateInlineProxyConfig(yaml, toolName, toolConfig) + } + } + + yaml.WriteString(" echo \"Proxy configuration files generated.\"\n") + + // Pre-pull images and start squid proxy ahead of time to avoid timeouts + yaml.WriteString(" - name: Pre-pull images and start Squid proxy\n") + yaml.WriteString(" run: |\n") + yaml.WriteString(" set -e\n") + yaml.WriteString(" echo 'Pre-pulling Docker images for proxy-enabled MCP tools...'\n") + yaml.WriteString(" docker pull ubuntu/squid:latest\n") + + // Pull each tool's container image if specified, and bring up squid service + for _, toolName := range proxyTools { + if toolConfig, ok := tools[toolName].(map[string]any); ok { + if mcpConf, err := getMCPConfig(toolConfig, toolName); err == nil { + if containerVal, hasContainer := mcpConf["container"]; hasContainer { + if containerStr, ok := containerVal.(string); ok && containerStr != "" { + yaml.WriteString(fmt.Sprintf(" echo 'Pulling %s for tool %s'\n", containerStr, toolName)) + yaml.WriteString(fmt.Sprintf(" docker pull %s\n", containerStr)) + } + } + } + yaml.WriteString(fmt.Sprintf(" echo 'Starting squid-proxy service for %s'\n", toolName)) + yaml.WriteString(fmt.Sprintf(" docker compose -f docker-compose-%s.yml up -d squid-proxy\n", toolName)) + + // Enforce that egress from this tool's network can only reach the Squid proxy + subnetCIDR, squidIP, _ := computeProxyNetworkParams(toolName) + yaml.WriteString(fmt.Sprintf(" echo 'Enforcing egress to proxy for %s (subnet %s, squid %s)'\n", toolName, subnetCIDR, squidIP)) + yaml.WriteString(" if command -v sudo >/dev/null 2>&1; then SUDO=sudo; else SUDO=; fi\n") + // Accept established/related connections first (position 1) + yaml.WriteString(" $SUDO iptables -C DOCKER-USER -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT 2>/dev/null || $SUDO iptables -I DOCKER-USER 1 -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT\n") + // Accept all egress from Squid IP (position 2) + yaml.WriteString(fmt.Sprintf(" $SUDO iptables -C DOCKER-USER -s %s -j ACCEPT 2>/dev/null || $SUDO iptables -I DOCKER-USER 2 -s %s -j ACCEPT\n", squidIP, squidIP)) + // Allow traffic to squid:3128 from the subnet (position 3) + yaml.WriteString(fmt.Sprintf(" $SUDO iptables -C DOCKER-USER -s %s -d %s -p tcp --dport 3128 -j ACCEPT 2>/dev/null || $SUDO iptables -I DOCKER-USER 3 -s %s -d %s -p tcp --dport 3128 -j ACCEPT\n", subnetCIDR, squidIP, subnetCIDR, squidIP)) + // Then reject all other egress from that subnet (append to end) + yaml.WriteString(fmt.Sprintf(" $SUDO iptables -C DOCKER-USER -s %s -j REJECT 2>/dev/null || $SUDO iptables -A DOCKER-USER -s %s -j REJECT\n", subnetCIDR, subnetCIDR)) + } + } + } // If no MCP tools, no configuration needed if len(mcpTools) == 0 { diff --git a/pkg/workflow/compiler_test.go b/pkg/workflow/compiler_test.go index b8e84e7b587..e3968d74de9 100644 --- a/pkg/workflow/compiler_test.go +++ b/pkg/workflow/compiler_test.go @@ -3210,7 +3210,7 @@ func TestTransformImageToDockerCommand(t *testing.T) { mcpConfig[k] = v } - err := transformContainerToDockerCommand(mcpConfig) + err := transformContainerToDockerCommand(mcpConfig, "test") if tt.wantErr { if err == nil { diff --git a/pkg/workflow/docker_compose.go b/pkg/workflow/docker_compose.go new file mode 100644 index 00000000000..6552e666375 --- /dev/null +++ b/pkg/workflow/docker_compose.go @@ -0,0 +1,119 @@ +package workflow + +import ( + "fmt" + "hash/crc32" + "strings" +) + +// getStandardProxyArgs returns the standard proxy arguments for all MCP containers +// This defines the standard interface that all proxy-enabled MCP containers should support +func getStandardProxyArgs() []string { + // We no longer rely on CLI flags like --proxy-url. + // Leave empty so we don't override container entrypoints. + return []string{} +} + +// formatYAMLArray formats a string slice as a YAML array +func formatYAMLArray(items []string) string { + if len(items) == 0 { + return "[]" + } + + var parts []string + for _, item := range items { + parts = append(parts, fmt.Sprintf(`"%s"`, item)) + } + return "[" + strings.Join(parts, ", ") + "]" +} + +// generateDockerCompose generates the Docker Compose configuration +func generateDockerCompose(containerImage string, envVars map[string]any, toolName string, customProxyArgs []string) string { + // Derive a stable, non-conflicting subnet and network name for this tool + octet := 100 + (int(crc32.ChecksumIEEE([]byte(toolName))) % 100) // 100-199 + subnet := fmt.Sprintf("172.28.%d.0/24", octet) + squidIP := fmt.Sprintf("172.28.%d.10", octet) + networkName := "awproxy-" + toolName + + compose := `services: + squid-proxy: + image: ubuntu/squid:latest + container_name: squid-proxy-` + toolName + ` + ports: + - "3128:3128" + volumes: + - ./squid.conf:/etc/squid/squid.conf:ro + - ./allowed_domains.txt:/etc/squid/allowed_domains.txt:ro + - squid-logs:/var/log/squid + healthcheck: + test: ["CMD", "squid", "-k", "check"] + interval: 30s + timeout: 10s + retries: 3 + restart: unless-stopped + networks: + ` + networkName + `: + ipv4_address: ` + squidIP + ` + + ` + toolName + `: + image: ` + containerImage + ` + container_name: ` + toolName + `-mcp + stdin_open: true + tty: true + environment: + - PROXY_HOST=squid-proxy + - PROXY_PORT=3128 + - HTTP_PROXY=http://squid-proxy:3128 + - HTTPS_PROXY=http://squid-proxy:3128 + networks: + - ` + networkName + `` + + // Add environment variables + for key, value := range envVars { + if valueStr, ok := value.(string); ok { + compose += "\n - " + key + "=" + valueStr + } + } + + // Set proxy-aware command - use standard proxy args for all containers + var proxyArgs []string + if len(customProxyArgs) > 0 { + // Use user-provided proxy args (for advanced users or non-standard containers) + proxyArgs = customProxyArgs + } else { + // Use standard proxy args for all MCP containers + proxyArgs = getStandardProxyArgs() + } + // Only set command if custom args were explicitly provided + if len(proxyArgs) > 0 { + compose += ` + command: ` + formatYAMLArray(proxyArgs) + } + + compose += ` + depends_on: + squid-proxy: + condition: service_healthy + +volumes: + squid-logs: + +networks: + ` + networkName + `: + driver: bridge + ipam: + config: + - subnet: ` + subnet + ` +` + + return compose +} + +// computeProxyNetworkParams returns the subnet CIDR, squid IP and network name for a given tool +func computeProxyNetworkParams(toolName string) (subnetCIDR string, squidIP string, networkName string) { + octet := 100 + (int(crc32.ChecksumIEEE([]byte(toolName))) % 100) // 100-199 + subnetCIDR = fmt.Sprintf("172.28.%d.0/24", octet) + squidIP = fmt.Sprintf("172.28.%d.10", octet) + networkName = "awproxy-" + toolName + return +} diff --git a/pkg/workflow/mcp-config.go b/pkg/workflow/mcp-config.go index e1285b4deef..7e70b91b34b 100644 --- a/pkg/workflow/mcp-config.go +++ b/pkg/workflow/mcp-config.go @@ -20,7 +20,7 @@ type MCPConfigRenderer struct { // This function handles the common logic for rendering MCP configurations across different engines func renderSharedMCPConfig(yaml *strings.Builder, toolName string, toolConfig map[string]any, isLast bool, renderer MCPConfigRenderer) error { // Get MCP configuration in the new format - mcpConfig, err := getMCPConfig(toolConfig) + mcpConfig, err := getMCPConfig(toolConfig, toolName) if err != nil { return fmt.Errorf("failed to parse MCP config for tool '%s': %w", toolName, err) } @@ -200,7 +200,7 @@ func renderSharedMCPConfig(yaml *strings.Builder, toolName string, toolConfig ma } // getMCPConfig extracts MCP configuration from a tool config in the new format -func getMCPConfig(toolConfig map[string]any) (map[string]any, error) { +func getMCPConfig(toolConfig map[string]any, toolName string) (map[string]any, error) { result := make(map[string]any) // Check new format: mcp.type, mcp.url, mcp.command, etc. @@ -223,8 +223,16 @@ func getMCPConfig(toolConfig map[string]any) (map[string]any, error) { } } + // Check if this container needs proxy support + if _, hasContainer := result["container"]; hasContainer { + if hasNetPerms, _ := hasNetworkPermissions(toolConfig); hasNetPerms { + // Mark this configuration as proxy-enabled + result["__uses_proxy"] = true + } + } + // Transform container field to docker command if present - if err := transformContainerToDockerCommand(result); err != nil { + if err := transformContainerToDockerCommand(result, toolName); err != nil { return nil, err } @@ -232,7 +240,8 @@ func getMCPConfig(toolConfig map[string]any) (map[string]any, error) { } // transformContainerToDockerCommand converts a container field to docker command and args -func transformContainerToDockerCommand(mcpConfig map[string]any) error { +// For proxy-enabled containers, it sets special markers instead of docker commands +func transformContainerToDockerCommand(mcpConfig map[string]any, toolName string) error { container, hasContainer := mcpConfig["container"] if !hasContainer { return nil // No container field, nothing to transform @@ -249,6 +258,17 @@ func transformContainerToDockerCommand(mcpConfig map[string]any) error { return fmt.Errorf("cannot specify both 'container' and 'command' fields") } + // Check if this is a proxy-enabled container (has special marker) + if _, hasProxyFlag := mcpConfig["__uses_proxy"]; hasProxyFlag { + // For proxy-enabled containers, use docker compose run to connect to the MCP server + mcpConfig["command"] = "docker" + if toolName != "" { + mcpConfig["args"] = []any{"compose", "-f", fmt.Sprintf("docker-compose-%s.yml", toolName), "run", "--rm", toolName} + } + // Keep the container field for compose file generation + return nil + } + // Set docker command mcpConfig["command"] = "docker" @@ -397,6 +417,59 @@ func validateStringProperty(toolName, propertyName string, value any, exists boo return nil } +// hasNetworkPermissions checks if a tool configuration has network permissions +func hasNetworkPermissions(toolConfig map[string]any) (bool, []string) { + extract := func(perms any) (bool, []string) { + permsMap, ok := perms.(map[string]any) + if !ok { + return false, nil + } + network, hasNetwork := permsMap["network"] + if !hasNetwork { + return false, nil + } + networkMap, ok := network.(map[string]any) + if !ok { + return false, nil + } + allowed, hasAllowed := networkMap["allowed"] + if !hasAllowed { + return false, nil + } + allowedSlice, ok := allowed.([]any) + if !ok { + return false, nil + } + var domains []string + for _, item := range allowedSlice { + if str, ok := item.(string); ok { + domains = append(domains, str) + } + } + return len(domains) > 0, domains + } + + // First, check top-level permissions + if permissions, hasPerms := toolConfig["permissions"]; hasPerms { + if ok, domains := extract(permissions); ok { + return true, domains + } + } + + // Then, check permissions nested under mcp (alternate schema used in some configs) + if mcpSection, hasMcp := toolConfig["mcp"]; hasMcp { + if m, ok := mcpSection.(map[string]any); ok { + if permissions, hasPerms := m["permissions"]; hasPerms { + if ok, domains := extract(permissions); ok { + return true, domains + } + } + } + } + + return false, nil +} + // validateMCPRequirements validates the specific requirements for MCP configuration func validateMCPRequirements(toolName string, mcpConfig map[string]any) error { // Validate 'type' property diff --git a/pkg/workflow/mcp_json_test.go b/pkg/workflow/mcp_json_test.go index ec5daaf9e08..f83bae836ff 100644 --- a/pkg/workflow/mcp_json_test.go +++ b/pkg/workflow/mcp_json_test.go @@ -61,7 +61,7 @@ func TestGetMCPConfigJSONString(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result, err := getMCPConfig(tt.toolConfig) + result, err := getMCPConfig(tt.toolConfig, "test") if tt.wantErr != (err != nil) { t.Errorf("getMCPConfig() error = %v, wantErr %v", err, tt.wantErr) diff --git a/pkg/workflow/network_proxy.go b/pkg/workflow/network_proxy.go new file mode 100644 index 00000000000..79a74989021 --- /dev/null +++ b/pkg/workflow/network_proxy.go @@ -0,0 +1,178 @@ +package workflow + +import ( + "fmt" + "strings" +) + +// needsProxy determines if a tool configuration requires proxy setup +func needsProxy(toolConfig map[string]any) (bool, []string) { + // Check if tool has MCP container configuration + mcpConfig, err := getMCPConfig(toolConfig, "") + if err != nil { + return false, nil + } + + // Check if it has a container field + _, hasContainer := mcpConfig["container"] + if !hasContainer { + return false, nil + } + + // Check if it has network permissions + hasNetPerms, domains := hasNetworkPermissions(toolConfig) + + return hasNetPerms, domains +} + +// generateSquidConfig generates the Squid proxy configuration +func generateSquidConfig() string { + return `# Squid configuration for egress traffic control +# This configuration implements a whitelist-based proxy + +# Access log and cache configuration +access_log /var/log/squid/access.log squid +cache_log /var/log/squid/cache.log +cache deny all + +# Port configuration +http_port 3128 + +# ACL definitions for allowed domains +acl allowed_domains dstdomain "/etc/squid/allowed_domains.txt" +acl localnet src 10.0.0.0/8 +acl localnet src 172.16.0.0/12 +acl localnet src 192.168.0.0/16 +acl SSL_ports port 443 +acl Safe_ports port 80 +acl Safe_ports port 443 +acl CONNECT method CONNECT + +# Access rules +# Deny requests to unknown domains (not in whitelist) +http_access deny !allowed_domains +http_access deny !Safe_ports +http_access deny CONNECT !SSL_ports +http_access allow localnet +http_access deny all + +# Disable caching +cache deny all + +# DNS settings +dns_nameservers 8.8.8.8 8.8.4.4 + +# Forwarded headers +forwarded_for delete +via off + +# Error page customization +error_directory /usr/share/squid/errors/English + +# Logging +logformat combined %>a %[ui %[un [%tl] "%rm %ru HTTP/%rv" %>Hs %h" "%{User-Agent}>h" %Ss:%Sh +access_log /var/log/squid/access.log combined + +# Memory and file descriptor limits +cache_mem 64 MB +maximum_object_size 0 KB +` +} + +// generateAllowedDomainsFile generates the allowed domains file content +func generateAllowedDomainsFile(domains []string) string { + content := "# Allowed domains for egress traffic\n# Add one domain per line\n" + for _, domain := range domains { + content += domain + "\n" + } + return content +} + +// generateProxyFiles generates Squid proxy configuration files for a tool +// Removed unused generateProxyFiles; inline generation is used instead. + +// generateInlineProxyConfig generates proxy configuration files inline in the workflow +func (c *Compiler) generateInlineProxyConfig(yaml *strings.Builder, toolName string, toolConfig map[string]any) { + needsProxySetup, allowedDomains := needsProxy(toolConfig) + if !needsProxySetup { + return + } + + // Get container image and environment variables from MCP config + mcpConfig, err := getMCPConfig(toolConfig, toolName) + if err != nil { + if c.verbose { + fmt.Printf("Error getting MCP config for %s: %v\n", toolName, err) + } + return + } + + containerImage, hasContainer := mcpConfig["container"] + if !hasContainer { + if c.verbose { + fmt.Printf("Proxy-enabled tool '%s' missing container configuration\n", toolName) + } + return + } + + containerStr, ok := containerImage.(string) + if !ok { + if c.verbose { + fmt.Printf("Container image must be a string for tool %s\n", toolName) + } + return + } + + var envVars map[string]any + if env, hasEnv := mcpConfig["env"]; hasEnv { + if envMap, ok := env.(map[string]any); ok { + envVars = envMap + } + } + + if c.verbose { + fmt.Printf("Generating inline proxy configuration for tool '%s'\n", toolName) + } + + // Generate squid.conf inline + yaml.WriteString(" # Generate Squid proxy configuration\n") + yaml.WriteString(" cat > squid.conf << 'EOF'\n") + squidConfigContent := generateSquidConfig() + for _, line := range strings.Split(squidConfigContent, "\n") { + yaml.WriteString(fmt.Sprintf(" %s\n", line)) + } + yaml.WriteString(" EOF\n") + yaml.WriteString(" \n") + + // Generate allowed_domains.txt inline + yaml.WriteString(" # Generate allowed domains file\n") + yaml.WriteString(" cat > allowed_domains.txt << 'EOF'\n") + allowedDomainsContent := generateAllowedDomainsFile(allowedDomains) + for _, line := range strings.Split(allowedDomainsContent, "\n") { + yaml.WriteString(fmt.Sprintf(" %s\n", line)) + } + yaml.WriteString(" EOF\n") + yaml.WriteString(" \n") + + // Extract custom proxy args from MCP config if present + var customProxyArgs []string + if proxyArgsInterface, hasProxyArgs := mcpConfig["proxy_args"]; hasProxyArgs { + if proxyArgsSlice, ok := proxyArgsInterface.([]any); ok { + for _, arg := range proxyArgsSlice { + if argStr, ok := arg.(string); ok { + customProxyArgs = append(customProxyArgs, argStr) + } + } + } + } + + // Generate docker-compose.yml inline + yaml.WriteString(fmt.Sprintf(" # Generate Docker Compose configuration for %s\n", toolName)) + yaml.WriteString(fmt.Sprintf(" cat > docker-compose-%s.yml << 'EOF'\n", toolName)) + dockerComposeContent := generateDockerCompose(containerStr, envVars, toolName, customProxyArgs) + for _, line := range strings.Split(dockerComposeContent, "\n") { + yaml.WriteString(fmt.Sprintf(" %s\n", line)) + } + yaml.WriteString(" EOF\n") + yaml.WriteString(" \n") +}