diff --git a/.github/aw/actions-lock.json b/.github/aw/actions-lock.json index 3cfd5499f..c7be94fb7 100644 --- a/.github/aw/actions-lock.json +++ b/.github/aw/actions-lock.json @@ -74,6 +74,11 @@ "repo": "github/gh-aw-actions/setup", "version": "v0.84.2", "sha": "fd783ac87efde5e0c0e05d593f1906ea25b5d92e" + }, + "github/gh-aw-actions/setup@v0.84.3": { + "repo": "github/gh-aw-actions/setup", + "version": "v0.84.3", + "sha": "c863074b673419603d146aab585e2986ef08deec" } }, "containers": { diff --git a/.github/workflows/contribution-check.lock.yml b/.github/workflows/contribution-check.lock.yml index 4ceb6c6af..32d31177e 100644 --- a/.github/workflows/contribution-check.lock.yml +++ b/.github/workflows/contribution-check.lock.yml @@ -1,4 +1,4 @@ -# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"6fb8e455e8fa4b96b483f60c2ff7efa092ca142a764d81ad5f3deaf930e57f8e","body_hash":"52cb85230b6e6e94e8e7422142eb939315bd320477f4b5fd934c712bb9721dfe","compiler_version":"v0.84.2","strict":true,"agent_id":"copilot","agent_model":"gpt-5.4-mini","engine_versions":{"copilot":"1.0.77"}} +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"52b6ecfdf0fee930217dcc8464e5a6ac3bf28cec9c0fb28df55db5c84904329e","body_hash":"63d0ea125d3bf31bdee80c9bd7f5403dd85a00b2e25b8a5b45f48fdc76daaeca","compiler_version":"v0.84.2","strict":true,"agent_id":"copilot","agent_model":"gpt-5.4-mini","engine_versions":{"copilot":"1.0.77"}} # gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/cache/restore","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/cache/save","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/checkout","sha":"3d3c42e5aac5ba805825da76410c181273ba90b1","version":"v7.0.1"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"fd783ac87efde5e0c0e05d593f1906ea25b5d92e","version":"v0.84.2"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.43","digest":"sha256:04e2d1987a565000a8f114b89d806ae7a3864dd4f944be65275b28c93d8690e6","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.43@sha256:04e2d1987a565000a8f114b89d806ae7a3864dd4f944be65275b28c93d8690e6"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.43","digest":"sha256:d85f57975af5ea23af4996e41ed73fbc8f5b4a47402472bfe82e508f352cb0c1","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.43@sha256:d85f57975af5ea23af4996e41ed73fbc8f5b4a47402472bfe82e508f352cb0c1"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.43","digest":"sha256:26be5e0b8c8f4c41c8a59126b29bb5d80b07253597472ded2a16bdd75abcbf9d","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.43@sha256:26be5e0b8c8f4c41c8a59126b29bb5d80b07253597472ded2a16bdd75abcbf9d"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.4.7","digest":"sha256:7545220a9aca134b71e51193ee0eaf4c50756ebf8fbd25a63ae7556e62815c00","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.4.7@sha256:7545220a9aca134b71e51193ee0eaf4c50756ebf8fbd25a63ae7556e62815c00"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b","pinned_image":"ghcr.io/github/gh-aw-node@sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b"}]} # This file was automatically generated by gh-aw (v0.84.2). DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md # @@ -458,7 +458,9 @@ jobs: id: pr-meta if: github.event.pull_request.number || github.event.inputs.item_number name: Fetch PR metadata - run: "CONTEXT_DIR=/tmp/gh-aw/contribution-check-context\nmkdir -p \"$CONTEXT_DIR\"\ngh pr view \"$PR_NUMBER\" --repo \"$GH_REPO\" \\\n --json title,author,baseRefName,headRefName,body \\\n --jq '\"**Title:** \" + .title + \"\\n**Author:** \" + .author.login + \"\\n**Base→Head:** \" + .baseRefName + \"→\" + .headRefName + \"\\n**Description:**\\n\" + (.body // \"\")' \\\n > \"$CONTEXT_DIR/pr-meta.md\"" + run: "CONTEXT_DIR=/tmp/gh-aw/contribution-check-context\nmkdir -p \"$CONTEXT_DIR\"\ngh pr view \"$PR_NUMBER\" --repo \"$GH_REPO\" \\\n --json title,author,baseRefName,headRefName,body \\\n --jq '\"**Title:** \" + .title + \"\\n**Author:** \" + .author.login + \"\\n**Base→Head:** \" + .baseRefName + \"→\" + .headRefName + \"\\n**Description:**\\n\" + (.body // \"\")' \\\n > \"$CONTEXT_DIR/pr-meta.md\"\n" + - name: Assemble contribution review context + run: "CONTEXT_DIR=/tmp/gh-aw/contribution-check-context\nmkdir -p \"$CONTEXT_DIR\"\ntest -f \"$CONTEXT_DIR/pr-meta.md\" \\\n || echo \"(no pull request metadata for this trigger)\" > \"$CONTEXT_DIR/pr-meta.md\"\ntest -f \"$CONTEXT_DIR/contributing.md\" \\\n || echo \"(CONTRIBUTING.md not found)\" > \"$CONTEXT_DIR/contributing.md\"\ntest -f \"$CONTEXT_DIR/pr-files.md\" \\\n || echo \"(no pull request diff for this trigger)\" > \"$CONTEXT_DIR/pr-files.md\"\n{\n echo \"# PR metadata\"\n cat \"$CONTEXT_DIR/pr-meta.md\"\n echo\n echo \"# CONTRIBUTING.md\"\n cat \"$CONTEXT_DIR/contributing.md\"\n echo\n echo \"# Changed files\"\n cat \"$CONTEXT_DIR/pr-files.md\"\n} > \"$CONTEXT_DIR/review-context.md\"" - name: Configure Git credentials env: diff --git a/.github/workflows/contribution-check.md b/.github/workflows/contribution-check.md index 30397213b..d512e4a11 100644 --- a/.github/workflows/contribution-check.md +++ b/.github/workflows/contribution-check.md @@ -86,6 +86,27 @@ steps: PR_NUMBER: ${{ github.event.pull_request.number || github.event.inputs.item_number }} GH_REPO: ${{ github.repository }} + - name: Assemble contribution review context + run: | + CONTEXT_DIR=/tmp/gh-aw/contribution-check-context + mkdir -p "$CONTEXT_DIR" + test -f "$CONTEXT_DIR/pr-meta.md" \ + || echo "(no pull request metadata for this trigger)" > "$CONTEXT_DIR/pr-meta.md" + test -f "$CONTEXT_DIR/contributing.md" \ + || echo "(CONTRIBUTING.md not found)" > "$CONTEXT_DIR/contributing.md" + test -f "$CONTEXT_DIR/pr-files.md" \ + || echo "(no pull request diff for this trigger)" > "$CONTEXT_DIR/pr-files.md" + { + echo "# PR metadata" + cat "$CONTEXT_DIR/pr-meta.md" + echo + echo "# CONTRIBUTING.md" + cat "$CONTEXT_DIR/contributing.md" + echo + echo "# Changed files" + cat "$CONTEXT_DIR/pr-files.md" + } > "$CONTEXT_DIR/review-context.md" + --- # Contribution Check @@ -96,12 +117,9 @@ You are a contribution guidelines reviewer for the `gh-aw-firewall` (AWF) reposi Review PR #${{ github.event.pull_request.number || github.event.inputs.item_number }} in repository ${{ github.repository }}. -Read the following pre-fetched context files before proceeding: -- `/tmp/gh-aw/contribution-check-context/pr-meta.md` — PR metadata (title, author, base/head branch, description) -- `/tmp/gh-aw/contribution-check-context/pr-files.md` — Changed files with diffs -- `/tmp/gh-aw/contribution-check-context/contributing.md` — CONTRIBUTING.md content +Read `/tmp/gh-aw/contribution-check-context/review-context.md` once. It contains the PR metadata, CONTRIBUTING.md, and changed-file patches in that order. -**Use ONLY the pre-fetched data in these context files.** Do NOT call `gh pr diff`, `gh pr view`, `gh api`, `git diff`, `git log`, or `git show`. Do not read other files from the checkout. After reading the three context files, your only tool calls should be `add_comment` (at most one) or `noop` — do not call any other tools. +**Use ONLY that pre-fetched context file.** Do NOT call `gh pr diff`, `gh pr view`, `gh api`, `git diff`, `git log`, or `git show`. Do not read other files from the checkout. Read the context in one tool call; your only subsequent tool call must be `add_comment` (at most one) or `noop`. ## Review Checklist diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 095b26e02..e9dafc0d5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -448,6 +448,110 @@ jobs: --type spdxjson \ ghcr.io/${{ github.repository }}/bounded-query-broker@${{ steps.build_bounded_query_broker.outputs.digest }} + # Build the minimal bounded-agent enclave and its trusted broker from separate + # Dockerfile targets. The build context is ./containers (not + # ./containers/bounded-agent) because the broker reuses the shared + # bounded-execution foundation and sandbox seccomp profile that live under + # containers/bounded-query. + build-bounded-agent: + name: Build Bounded Agent Image + runs-on: ubuntu-latest + needs: bump-version + outputs: + enclave_digest: ${{ steps.build_bounded_agent.outputs.digest }} + broker_digest: ${{ steps.build_bounded_agent_broker.outputs.digest }} + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4 + with: + ref: ${{ needs.bump-version.outputs.version }} + + - name: Log in to GitHub Container Registry + uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 + + - name: Set up QEMU + uses: docker/setup-qemu-action@49b3bc8e6bdd4a60e6116a5414239cba5943d3cf # v3.2.0 + with: + platforms: arm64 + + - name: Install cosign + uses: sigstore/cosign-installer@59acb6260d9c0ba8f4a2f9d9b48431a222b68e20 # v3.5.0 + + - name: Build and push Bounded Agent image + id: build_bounded_agent + uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25 # v5 + with: + context: ./containers + file: ./containers/bounded-agent/Dockerfile + target: enclave + push: true + platforms: linux/amd64,linux/arm64 + tags: | + ghcr.io/${{ github.repository }}/bounded-agent:${{ needs.bump-version.outputs.version_number }} + ghcr.io/${{ github.repository }}/bounded-agent:latest + cache-from: type=gha,scope=bounded-agent + cache-to: type=gha,mode=max,scope=bounded-agent + + - name: Sign Bounded Agent image with cosign + run: | + cosign sign --yes \ + ghcr.io/${{ github.repository }}/bounded-agent@${{ steps.build_bounded_agent.outputs.digest }} + + - name: Generate SBOM for Bounded Agent image + uses: anchore/sbom-action@28d71544de8eaf1b958d335707167c5f783590ad # v0.22.2 + with: + image: ghcr.io/${{ github.repository }}/bounded-agent@${{ steps.build_bounded_agent.outputs.digest }} + format: spdx-json + output-file: bounded-agent-sbom.spdx.json + + - name: Attest SBOM for Bounded Agent image + run: | + cosign attest --yes \ + --predicate bounded-agent-sbom.spdx.json \ + --type spdxjson \ + ghcr.io/${{ github.repository }}/bounded-agent@${{ steps.build_bounded_agent.outputs.digest }} + + - name: Build and push Bounded Agent Broker image + id: build_bounded_agent_broker + uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25 # v5 + with: + context: ./containers + file: ./containers/bounded-agent/Dockerfile + target: broker + push: true + platforms: linux/amd64,linux/arm64 + tags: | + ghcr.io/${{ github.repository }}/bounded-agent-broker:${{ needs.bump-version.outputs.version_number }} + ghcr.io/${{ github.repository }}/bounded-agent-broker:latest + cache-from: type=gha,scope=bounded-agent-broker + cache-to: type=gha,mode=max,scope=bounded-agent-broker + + - name: Sign Bounded Agent Broker image with cosign + run: | + cosign sign --yes \ + ghcr.io/${{ github.repository }}/bounded-agent-broker@${{ steps.build_bounded_agent_broker.outputs.digest }} + + - name: Generate SBOM for Bounded Agent Broker image + uses: anchore/sbom-action@28d71544de8eaf1b958d335707167c5f783590ad # v0.22.2 + with: + image: ghcr.io/${{ github.repository }}/bounded-agent-broker@${{ steps.build_bounded_agent_broker.outputs.digest }} + format: spdx-json + output-file: bounded-agent-broker-sbom.spdx.json + + - name: Attest SBOM for Bounded Agent Broker image + run: | + cosign attest --yes \ + --predicate bounded-agent-broker-sbom.spdx.json \ + --type spdxjson \ + ghcr.io/${{ github.repository }}/bounded-agent-broker@${{ steps.build_bounded_agent_broker.outputs.digest }} + # Build agent-act image with catthehacker/ubuntu:act-24.04 base for GitHub Actions parity # amd64-only: catthehacker/ubuntu:act-24.04 does not publish arm64 manifests build-agent-act: @@ -684,7 +788,7 @@ jobs: release: name: Create Release runs-on: ubuntu-latest - needs: [bump-version, build-squid, build-agent, build-api-proxy, build-cli-proxy, build-agent-act, build-build-tools, build-bounded-query, build-gh-aw-node] + needs: [bump-version, build-squid, build-agent, build-api-proxy, build-cli-proxy, build-agent-act, build-build-tools, build-bounded-query, build-bounded-agent, build-gh-aw-node] steps: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4 @@ -786,6 +890,8 @@ jobs: "ghcr.io/${{ github.repository }}/cli-proxy@${{ needs['build-cli-proxy'].outputs.digest }}" \ "ghcr.io/${{ github.repository }}/bounded-query@${{ needs['build-bounded-query'].outputs.query_digest }}" \ "ghcr.io/${{ github.repository }}/bounded-query-broker@${{ needs['build-bounded-query'].outputs.broker_digest }}" \ + "ghcr.io/${{ github.repository }}/bounded-agent@${{ needs['build-bounded-agent'].outputs.enclave_digest }}" \ + "ghcr.io/${{ github.repository }}/bounded-agent-broker@${{ needs['build-bounded-agent'].outputs.broker_digest }}" \ "ghcr.io/${{ github.repository }}/gh-aw-node@${{ needs['build-gh-aw-node'].outputs.digest }}" \ > release/containers.txt echo "Generated containers.txt:" diff --git a/.github/workflows/security-guard.lock.yml b/.github/workflows/security-guard.lock.yml index cf65bb1c6..5b085c304 100644 --- a/.github/workflows/security-guard.lock.yml +++ b/.github/workflows/security-guard.lock.yml @@ -1,4 +1,4 @@ -# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"a1bc8e180a537d5d2c1ed4173524ae16a4eaa71426266c7baf476fdb3b492655","body_hash":"15665078a96198210e461f407cc9b2c1936577d7c02745449ebbe4644c96673e","compiler_version":"v0.84.2","agent_id":"copilot","agent_model":"claude-haiku-4-5","engine_versions":{"copilot":"1.0.77"}} +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"0745beab6f609a369429b3a7193119f2d924d91afdb4fb4e7f227586bc1c0573","body_hash":"8a1fa367e7bf27b157aecfabe02a4f2a177766f911861fbcb0d9caf85c05ebff","compiler_version":"v0.84.2","agent_id":"copilot","agent_model":"claude-haiku-4-5","engine_versions":{"copilot":"1.0.77"}} # gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/cache/restore","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/cache/save","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/checkout","sha":"3d3c42e5aac5ba805825da76410c181273ba90b1","version":"v7.0.1"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"fd783ac87efde5e0c0e05d593f1906ea25b5d92e","version":"v0.84.2"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.43","digest":"sha256:04e2d1987a565000a8f114b89d806ae7a3864dd4f944be65275b28c93d8690e6","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.43@sha256:04e2d1987a565000a8f114b89d806ae7a3864dd4f944be65275b28c93d8690e6"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.43","digest":"sha256:d85f57975af5ea23af4996e41ed73fbc8f5b4a47402472bfe82e508f352cb0c1","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.43@sha256:d85f57975af5ea23af4996e41ed73fbc8f5b4a47402472bfe82e508f352cb0c1"},{"image":"ghcr.io/github/gh-aw-firewall/cli-proxy:0.27.43","digest":"sha256:65c45ea2967984d0024f3df61bc71335658a77ede96c8d9665da7a5f33a795ab","pinned_image":"ghcr.io/github/gh-aw-firewall/cli-proxy:0.27.43@sha256:65c45ea2967984d0024f3df61bc71335658a77ede96c8d9665da7a5f33a795ab"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.43","digest":"sha256:26be5e0b8c8f4c41c8a59126b29bb5d80b07253597472ded2a16bdd75abcbf9d","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.43@sha256:26be5e0b8c8f4c41c8a59126b29bb5d80b07253597472ded2a16bdd75abcbf9d"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.4.7","digest":"sha256:7545220a9aca134b71e51193ee0eaf4c50756ebf8fbd25a63ae7556e62815c00","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.4.7@sha256:7545220a9aca134b71e51193ee0eaf4c50756ebf8fbd25a63ae7556e62815c00"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b","pinned_image":"ghcr.io/github/gh-aw-node@sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b"},{"image":"ghcr.io/github/github-mcp-server:v1.8.0","digest":"sha256:d5a18c04b92714c309eb46a2305087e91a4dbd80420f6e462656699f95093520","pinned_image":"ghcr.io/github/github-mcp-server:v1.8.0@sha256:d5a18c04b92714c309eb46a2305087e91a4dbd80420f6e462656699f95093520"}]} # This file was automatically generated by gh-aw (v0.84.2). DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md # @@ -516,9 +516,7 @@ jobs: - env: EXPR_NEEDS_CHECK_SECURITY_RELEVANCE_OUTPUTS_SECURITY_FILES_CHANGED: ${{ needs.check_security_relevance.outputs.security_files_changed }} name: Set security relevance count - run: |- - mkdir -p /tmp/gh-aw/agent - echo "$EXPR_NEEDS_CHECK_SECURITY_RELEVANCE_OUTPUTS_SECURITY_FILES_CHANGED" > /tmp/gh-aw/agent/security-files-changed.txt + run: "mkdir -p /tmp/gh-aw/agent\necho \"$EXPR_NEEDS_CHECK_SECURITY_RELEVANCE_OUTPUTS_SECURITY_FILES_CHANGED\" > /tmp/gh-aw/agent/security-files-changed.txt\ntest -f /tmp/gh-aw/agent/pr-meta.txt \\\n || echo \"(no pull request metadata for this trigger)\" > /tmp/gh-aw/agent/pr-meta.txt\ntest -f /tmp/gh-aw/agent/pr-diff.txt \\\n || echo \"(no pull request diff for this trigger)\" > /tmp/gh-aw/agent/pr-diff.txt\n{\n echo \"# PR metadata\"\n cat /tmp/gh-aw/agent/pr-meta.txt\n echo\n echo \"# Security-relevant files changed\"\n cat /tmp/gh-aw/agent/security-files-changed.txt\n echo\n echo \"# Changed files\"\n cat /tmp/gh-aw/agent/pr-diff.txt\n} > /tmp/gh-aw/agent/security-review-context.txt" - name: Configure Git credentials env: diff --git a/.github/workflows/security-guard.md b/.github/workflows/security-guard.md index f45e34b04..07a14a1b8 100644 --- a/.github/workflows/security-guard.md +++ b/.github/workflows/security-guard.md @@ -118,6 +118,20 @@ steps: run: | mkdir -p /tmp/gh-aw/agent echo "$EXPR_NEEDS_CHECK_SECURITY_RELEVANCE_OUTPUTS_SECURITY_FILES_CHANGED" > /tmp/gh-aw/agent/security-files-changed.txt + test -f /tmp/gh-aw/agent/pr-meta.txt \ + || echo "(no pull request metadata for this trigger)" > /tmp/gh-aw/agent/pr-meta.txt + test -f /tmp/gh-aw/agent/pr-diff.txt \ + || echo "(no pull request diff for this trigger)" > /tmp/gh-aw/agent/pr-diff.txt + { + echo "# PR metadata" + cat /tmp/gh-aw/agent/pr-meta.txt + echo + echo "# Security-relevant files changed" + cat /tmp/gh-aw/agent/security-files-changed.txt + echo + echo "# Changed files" + cat /tmp/gh-aw/agent/pr-diff.txt + } > /tmp/gh-aw/agent/security-review-context.txt --- @@ -133,7 +147,7 @@ steps: ## ⚡ Fast Path -Read the pre-fetched diff below first. Security-relevant files are included in full; other changed files are listed by name only. If you see `[DIFF TRUNCATED ...]` and a **security-relevant** patch is missing, fetch the full PR diff once with `mcp__github__get_pull_request_diff` and locate that file section before deciding to noop. Only use the fast path when the security-relevant changes contain **no** security-weakening changes: no weakened DROP/REJECT or expanded ACCEPT, no egress/domain allowlist expansion, no firewall chain changes, no capability additions, no ACL regressions, no seccomp relaxations, no DNS/wildcard bypass, no input validation weakening, and no secrets. Then call `safeoutputs noop` immediately — do not read additional files or make further tool calls. +Read `/tmp/gh-aw/agent/security-review-context.txt` once. It contains PR metadata, the security relevance count, and the pre-fetched diff. Security-relevant files are included in full; other changed files are listed by name only. If you see `[DIFF TRUNCATED ...]` and a **security-relevant** patch is missing, fetch the full PR diff once with `mcp__github__get_pull_request_diff` and locate that file section before deciding to noop. Only use the fast path when the security-relevant changes contain **no** security-weakening changes: no weakened DROP/REJECT or expanded ACCEPT, no egress/domain allowlist expansion, no firewall chain changes, no capability additions, no ACL regressions, no seccomp relaxations, no DNS/wildcard bypass, no input validation weakening, and no secrets. Then call `safeoutputs noop` immediately — do not read additional files or make further tool calls. ## Repository Context @@ -144,10 +158,9 @@ Security-critical files: `src/host-iptables.ts`, `containers/agent/setup-iptable Analyze PR #${{ github.event.pull_request.number }} in repository ${{ github.repository }}. -1. **Review the pre-fetched diff below** (security-relevant files in full; other files listed by name) -2. **Batch all independent reads** in a single tool-use block rather than making sequential calls -3. **Use ONLY the pre-fetched diff below.** Do NOT call `gh pr diff`, `gh pr view`, `gh api`, `git diff`, `git log`, or `git show`. Do NOT read files from the checkout. If `[DIFF TRUNCATED ...]` appears and a security-relevant patch is missing, call `mcp__github__get_pull_request_diff` once (it returns the full PR diff), locate the missing security-relevant file section, then stop making tool calls and analyze inline. -4. **Collect evidence** with specific file names, line numbers, and code snippets +1. **Read the pre-fetched context file once** (security-relevant files in full; other files listed by name) +2. **Use ONLY the pre-fetched context.** Do NOT call `gh pr diff`, `gh pr view`, `gh api`, `git diff`, `git log`, or `git show`. Do NOT read files from the checkout. If `[DIFF TRUNCATED ...]` appears and a security-relevant patch is missing, call `mcp__github__get_pull_request_diff` once (it returns the full PR diff), locate the missing security-relevant file section, then stop making tool calls and analyze inline. +3. **Collect evidence** with specific file names, line numbers, and code snippets ## Security Checks @@ -176,8 +189,4 @@ If no security issues are found: The following PR diff has been pre-computed. Focus your security analysis on these changes: -Read the pre-fetched PR metadata from `/tmp/gh-aw/agent/pr-meta.txt` (one bash call: `cat /tmp/gh-aw/agent/pr-meta.txt`). - -``` -Read the pre-fetched diff from `/tmp/gh-aw/agent/pr-diff.txt` (one bash call: `cat /tmp/gh-aw/agent/pr-diff.txt`). -``` \ No newline at end of file +Read `/tmp/gh-aw/agent/security-review-context.txt` in one tool call. Do not split it into line ranges or make additional file reads. \ No newline at end of file diff --git a/.github/workflows/smoke-bounded-agents-gvisor.lock.yml b/.github/workflows/smoke-bounded-agents-gvisor.lock.yml new file mode 100644 index 000000000..01a081fd5 --- /dev/null +++ b/.github/workflows/smoke-bounded-agents-gvisor.lock.yml @@ -0,0 +1,1382 @@ +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"815e1a6139f2221439fefb96de95038a0cbfa55a948673004935913824ef04be","body_hash":"245ddc5ea0c938d471a36a6fd125e26f7b84f455eeb2706f3d9fa8a4a3c5ee16","compiler_version":"v0.84.2","agent_id":"copilot","engine_versions":{"copilot":"1.0.34"}} +# gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN","OPENAI_API_KEY"],"actions":[{"repo":"actions/cache/restore","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/cache/save","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/checkout","sha":"3d3c42e5aac5ba805825da76410c181273ba90b1","version":"v7.0.1"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"fd783ac87efde5e0c0e05d593f1906ea25b5d92e","version":"v0.84.2"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.28.0"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.28.0"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.28.0"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.4.7","digest":"sha256:7545220a9aca134b71e51193ee0eaf4c50756ebf8fbd25a63ae7556e62815c00","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.4.7@sha256:7545220a9aca134b71e51193ee0eaf4c50756ebf8fbd25a63ae7556e62815c00"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b","pinned_image":"ghcr.io/github/gh-aw-node@sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b"},{"image":"ghcr.io/github/github-mcp-server:v1.8.0","digest":"sha256:d5a18c04b92714c309eb46a2305087e91a4dbd80420f6e462656699f95093520","pinned_image":"ghcr.io/github/github-mcp-server:v1.8.0@sha256:d5a18c04b92714c309eb46a2305087e91a4dbd80420f6e462656699f95093520"}]} +# This file was automatically generated by gh-aw (v0.84.2). DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md +# +# ___ _ _ +# / _ \ | | (_) +# | |_| | __ _ ___ _ __ | |_ _ ___ +# | _ |/ _` |/ _ \ '_ \| __| |/ __| +# | | | | (_| | __/ | | | |_| | (__ +# \_| |_/\__, |\___|_| |_|\__|_|\___| +# __/ | +# _ _ |___/ +# | | | | / _| | +# | | | | ___ _ __ _ __| |_| | _____ ____ +# | |/\| |/ _ \ '__| |/ /| _| |/ _ \ \ /\ / / ___| +# \ /\ / (_) | | | | ( | | | | (_) \ V V /\__ \ +# \/ \/ \___/|_| |_|\_\|_| |_|\___/ \_/\_/ |___/ +# +# +# To update this file, edit the corresponding .md file and run: +# gh aw compile +# Not all edits will cause changes to this file. +# +# For more information: https://github.github.com/gh-aw/introduction/overview/ +# +# End-to-end smoke test for finite-schema gVisor bounded-agent enclaves +# +# Frontmatter env variables: +# - GH_TOKEN: (main workflow) +# - OPENAI_API_KEY: (main workflow) +# +# Secrets used: +# - COPILOT_GITHUB_TOKEN +# - GH_AW_GITHUB_MCP_SERVER_TOKEN +# - GH_AW_GITHUB_TOKEN +# - GITHUB_TOKEN +# - OPENAI_API_KEY +# +# Custom actions used: +# - actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 +# - actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 +# - actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 +# - actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 +# - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 +# - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 (source v9) +# - actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 +# - github/gh-aw-actions/setup@fd783ac87efde5e0c0e05d593f1906ea25b5d92e # v0.84.2 +# +# Container images used: +# - +# - +# - +# - ghcr.io/github/gh-aw-mcpg:v0.4.7@sha256:7545220a9aca134b71e51193ee0eaf4c50756ebf8fbd25a63ae7556e62815c00 +# - ghcr.io/github/gh-aw-node@sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b +# - ghcr.io/github/github-mcp-server:v1.8.0@sha256:d5a18c04b92714c309eb46a2305087e91a4dbd80420f6e462656699f95093520 + +name: "Smoke Bounded Agents gVisor" +on: + schedule: + - cron: "41 */12 * * *" # Friendly format: every 12h (scattered) + workflow_dispatch: + inputs: + aw_context: + default: "" + description: "Agent caller context (used internally by Agentic Workflows)." + required: false + type: string + +permissions: {} + +concurrency: + cancel-in-progress: false + group: smoke-bounded-agents-gvisor + +run-name: "Smoke Bounded Agents gVisor" + +env: + GH_TOKEN: ${{ github.token }} + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + +jobs: + activation: + runs-on: ubuntu-slim + permissions: + actions: read + contents: read + env: + GH_AW_MAX_DAILY_AI_CREDITS: ${{ vars.GH_AW_DEFAULT_MAX_DAILY_AI_CREDITS || '5000' }} + GH_AW_RUNTIME_FEATURES: ${{ vars.GH_AW_RUNTIME_FEATURES }} + outputs: + comment_id: "" + comment_repo: "" + daily_ai_credits_exceeded: ${{ steps.daily-effective-workflow-guardrail.outputs.daily_ai_credits_exceeded == 'true' }} + daily_ai_credits_threshold: ${{ steps.daily-effective-workflow-guardrail.outputs.daily_ai_credits_threshold || '' }} + daily_ai_credits_total_effective_tokens: ${{ steps.daily-effective-workflow-guardrail.outputs.daily_ai_credits_total_effective_tokens || '' }} + engine_id: ${{ steps.generate_aw_info.outputs.engine_id }} + lockdown_check_failed: ${{ steps.generate_aw_info.outputs.lockdown_check_failed == 'true' }} + model: ${{ steps.generate_aw_info.outputs.model }} + oauth_token_check_failed: ${{ steps.check-oauth-tokens.outputs.oauth_token_check_failed == 'true' }} + setup-parent-span-id: ${{ steps.setup.outputs.parent-span-id || steps.setup.outputs.span-id }} + setup-span-id: ${{ steps.setup.outputs.span-id }} + setup-trace-id: ${{ steps.setup.outputs.trace-id }} + stale_lock_file_failed: ${{ steps.check-lock-file.outputs.stale_lock_file_failed == 'true' }} + steps: + - name: Setup Scripts + id: setup + uses: github/gh-aw-actions/setup@fd783ac87efde5e0c0e05d593f1906ea25b5d92e # v0.84.2 + with: + destination: ${{ runner.temp }}/gh-aw/actions + job-name: ${{ github.job }} + safe-output-artifact-client: ${{ env.GH_AW_MAX_DAILY_AI_CREDITS != '' }} + env: + GH_AW_SETUP_WORKFLOW_NAME: "Smoke Bounded Agents gVisor" + GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/smoke-bounded-agents-gvisor.lock.yml@${{ github.ref }} + GH_AW_INFO_VERSION: "1.0.34" + GH_AW_INFO_AWF_VERSION: "v0.28.0" + GH_AW_INFO_ENGINE_ID: "copilot" + - name: Generate agentic run info + id: generate_aw_info + env: + GH_AW_INFO_ENGINE_ID: "copilot" + GH_AW_INFO_ENGINE_NAME: "GitHub Copilot CLI" + GH_AW_INFO_MODEL: ${{ vars.GH_AW_MODEL_AGENT_COPILOT || vars.GH_AW_DEFAULT_MODEL_COPILOT || 'auto' }} + GH_AW_INFO_VERSION: "1.0.34" + GH_AW_INFO_AGENT_VERSION: "1.0.34" + GH_AW_INFO_CLI_VERSION: "v0.84.2" + GH_AW_INFO_WORKFLOW_NAME: "Smoke Bounded Agents gVisor" + GH_AW_INFO_EXPERIMENTAL: "false" + GH_AW_INFO_SUPPORTS_TOOLS_ALLOWLIST: "true" + GH_AW_INFO_STAGED: "false" + GH_AW_INFO_ALLOWED_DOMAINS: '["defaults","github"]' + GH_AW_INFO_FIREWALL_ENABLED: "true" + GH_AW_INFO_AWF_VERSION: "v0.28.0" + GH_AW_INFO_AWMG_VERSION: "" + GH_AW_INFO_FIREWALL_TYPE: "squid" + GH_AW_COMPILED_STRICT: "false" + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/generate_aw_info.cjs'); + await main(core, context); + - name: Enforce strict mode policy + if: ${{ vars.GH_AW_POLICY_STRICT == 'true' }} + run: | + echo "::error::GH_AW_POLICY_STRICT=true but this workflow was not compiled in strict mode. Recompile with --strict or strict: true." + exit 1 + - name: Restore daily AIC usage cache + id: restore-daily-aic-cache + if: ${{ env.GH_AW_MAX_DAILY_AI_CREDITS != '' }} + continue-on-error: true + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + key: agentic-workflow-usage-smokeboundedagentsgvisor-${{ github.run_id }} + restore-keys: agentic-workflow-usage-smokeboundedagentsgvisor- + path: /tmp/gh-aw/agentic-workflow-usage-cache.jsonl + - name: Restore daily AIC usage cache (artifact fallback) + id: restore-daily-aic-cache-fallback + if: ${{ env.GH_AW_MAX_DAILY_AI_CREDITS != '' }} + continue-on-error: true + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_RESTORE_DAILY_AIC_CACHE_HIT: ${{ steps.restore-daily-aic-cache.outputs.cache-hit }} + GH_AW_RESTORE_DAILY_AIC_CACHE_MATCHED_KEY: ${{ steps.restore-daily-aic-cache.outputs.cache-matched-key }} + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/restore_aic_usage_cache_fallback.cjs'); + await main(); + - name: Check daily workflow token guardrail + id: daily-effective-workflow-guardrail + if: ${{ env.GH_AW_MAX_DAILY_AI_CREDITS != '' }} + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_WORKFLOW_NAME: "Smoke Bounded Agents gVisor" + GH_AW_WORKFLOW_ID: "smoke-bounded-agents-gvisor" + GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + GH_AW_WORKFLOW_DISPATCH_AW_CONTEXT: ${{ github.event.inputs.aw_context || '' }} + GH_AW_HAS_SLASH_COMMAND: "false" + GH_AW_HAS_LABEL_COMMAND: "false" + GH_AW_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_AW_MAX_DAILY_AI_CREDITS: ${{ vars.GH_AW_DEFAULT_MAX_DAILY_AI_CREDITS || '5000' }} + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/check_daily_aic_workflow_guardrail.cjs'); + await main(); + - name: Check for OAuth tokens + id: check-oauth-tokens + run: bash "${RUNNER_TEMP}/gh-aw/actions/check_oauth_tokens.sh" + env: + COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} + GH_AW_GITHUB_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN }} + GH_AW_GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN }} + - name: Checkout .github and .agents folders + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + sparse-checkout-cone-mode: true + fetch-depth: 1 + - name: Save agent config folders for base branch restoration + env: + GH_AW_AGENT_FOLDERS: ".agents .antigravity .claude .codex .gemini .github .opencode .pi" + GH_AW_AGENT_FILES: "AGENTS.md ANTIGRAVITY.md CLAUDE.md GEMINI.md PI.md opencode.jsonc" + # poutine:ignore untrusted_checkout_exec + run: bash "${RUNNER_TEMP}/gh-aw/actions/save_base_github_folders.sh" + - name: Check workflow lock file + id: check-lock-file + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_WORKFLOW_FILE: "smoke-bounded-agents-gvisor.lock.yml" + GH_AW_CONTEXT_WORKFLOW_REF: "${{ github.workflow_ref }}" + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/check_workflow_timestamp_api.cjs'); + await main(); + - name: Check compile-agentic version + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_COMPILED_VERSION: "v0.84.2" + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/check_version_updates.cjs'); + await main(); + - name: Log runtime features + if: ${{ contains(toJSON(vars), '"GH_AW_RUNTIME_FEATURES":') }} + run: bash "${RUNNER_TEMP}/gh-aw/actions/log_runtime_features_summary.sh" + - name: Create prompt with built-in context + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_SAFE_OUTPUTS: ${{ runner.temp }}/gh-aw/safeoutputs/outputs.jsonl + GH_AW_EXPR_1A3A194A: ${{ github.event.discussion.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'discussion' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }} + GH_AW_EXPR_463A214A: ${{ github.event.pull_request.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'pull_request' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }} + GH_AW_EXPR_802A9F6A: ${{ github.event.issue.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'issue' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }} + GH_AW_EXPR_FF1D34CE: ${{ github.event.comment.id || fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').comment_id }} + GH_AW_GITHUB_ACTOR: ${{ github.actor }} + GH_AW_GITHUB_REPOSITORY: ${{ github.repository }} + GH_AW_GITHUB_RUN_ID: ${{ github.run_id }} + GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }} + # poutine:ignore untrusted_checkout_exec + run: | + bash "${RUNNER_TEMP}/gh-aw/actions/create_prompt_first.sh" + { + cat << 'GH_AW_PROMPT_2cf238e256fdffdc_EOF' + + GH_AW_PROMPT_2cf238e256fdffdc_EOF + cat "${RUNNER_TEMP}/gh-aw/prompts/xpia.md" + cat "${RUNNER_TEMP}/gh-aw/prompts/temp_folder_prompt.md" + cat "${RUNNER_TEMP}/gh-aw/prompts/markdown.md" + cat "${RUNNER_TEMP}/gh-aw/prompts/safe_outputs_prompt.md" + cat << 'GH_AW_PROMPT_2cf238e256fdffdc_EOF' + + Tools: create_issue, missing_tool, missing_data, noop + GH_AW_PROMPT_2cf238e256fdffdc_EOF + cat "${RUNNER_TEMP}/gh-aw/prompts/safe_outputs_auto_create_issue.md" + cat << 'GH_AW_PROMPT_2cf238e256fdffdc_EOF' + + GH_AW_PROMPT_2cf238e256fdffdc_EOF + cat "${RUNNER_TEMP}/gh-aw/prompts/mcp_cli_tools_prompt.md" + cat << 'GH_AW_PROMPT_2cf238e256fdffdc_EOF' + + The following GitHub context information is available for this workflow: + {{#if github.actor}} + - **actor**: __GH_AW_GITHUB_ACTOR__ + {{/if}} + {{#if github.repository}} + - **repository**: __GH_AW_GITHUB_REPOSITORY__ + {{/if}} + {{#if github.workspace}} + - **workspace**: __GH_AW_GITHUB_WORKSPACE__ + {{/if}} + {{#if github.event.issue.number || (github.aw.context.item_type == 'issue' && github.aw.context.item_number)}} + - **issue-number**: #__GH_AW_EXPR_802A9F6A__ + {{/if}} + {{#if github.event.discussion.number || (github.aw.context.item_type == 'discussion' && github.aw.context.item_number)}} + - **discussion-number**: #__GH_AW_EXPR_1A3A194A__ + {{/if}} + {{#if github.event.pull_request.number || (github.aw.context.item_type == 'pull_request' && github.aw.context.item_number)}} + - **pull-request-number**: #__GH_AW_EXPR_463A214A__ + {{/if}} + {{#if github.event.comment.id || github.aw.context.comment_id}} + - **comment-id**: __GH_AW_EXPR_FF1D34CE__ + {{/if}} + {{#if github.run_id}} + - **workflow-run-id**: __GH_AW_GITHUB_RUN_ID__ + {{/if}} + + + GH_AW_PROMPT_2cf238e256fdffdc_EOF + cat "${RUNNER_TEMP}/gh-aw/prompts/github_mcp_tools_with_safeoutputs_prompt.md" + cat << 'GH_AW_PROMPT_2cf238e256fdffdc_EOF' + + {{#runtime-import .github/workflows/smoke-bounded-agents-gvisor.md}} + GH_AW_PROMPT_2cf238e256fdffdc_EOF + } > "$GH_AW_PROMPT" + - name: Interpolate variables and render templates + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_ENGINE_ID: "copilot" + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/interpolate_prompt.cjs'); + await main(); + - name: Substitute placeholders + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_EXPR_1A3A194A: ${{ github.event.discussion.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'discussion' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }} + GH_AW_EXPR_463A214A: ${{ github.event.pull_request.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'pull_request' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }} + GH_AW_EXPR_802A9F6A: ${{ github.event.issue.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'issue' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }} + GH_AW_EXPR_FF1D34CE: ${{ github.event.comment.id || fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').comment_id }} + GH_AW_GITHUB_ACTOR: ${{ github.actor }} + GH_AW_GITHUB_REPOSITORY: ${{ github.repository }} + GH_AW_GITHUB_RUN_ID: ${{ github.run_id }} + GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }} + GH_AW_MCP_CLI_SERVERS_LIST: "- `github` — run `github --help` to see available tools\n- `safeoutputs` — run `safeoutputs --help` to see available tools" + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + + const substitutePlaceholders = require('${{ runner.temp }}/gh-aw/actions/substitute_placeholders.cjs'); + + // Call the substitution function + return await substitutePlaceholders({ + file: process.env.GH_AW_PROMPT, + substitutions: { + GH_AW_EXPR_1A3A194A: process.env.GH_AW_EXPR_1A3A194A, + GH_AW_EXPR_463A214A: process.env.GH_AW_EXPR_463A214A, + GH_AW_EXPR_802A9F6A: process.env.GH_AW_EXPR_802A9F6A, + GH_AW_EXPR_FF1D34CE: process.env.GH_AW_EXPR_FF1D34CE, + GH_AW_GITHUB_ACTOR: process.env.GH_AW_GITHUB_ACTOR, + GH_AW_GITHUB_REPOSITORY: process.env.GH_AW_GITHUB_REPOSITORY, + GH_AW_GITHUB_RUN_ID: process.env.GH_AW_GITHUB_RUN_ID, + GH_AW_GITHUB_WORKSPACE: process.env.GH_AW_GITHUB_WORKSPACE, + GH_AW_MCP_CLI_SERVERS_LIST: process.env.GH_AW_MCP_CLI_SERVERS_LIST + } + }); + - name: Validate prompt placeholders + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + # poutine:ignore untrusted_checkout_exec + run: bash "${RUNNER_TEMP}/gh-aw/actions/validate_prompt_placeholders.sh" + - name: Print prompt + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + # poutine:ignore untrusted_checkout_exec + run: bash "${RUNNER_TEMP}/gh-aw/actions/print_prompt_summary.sh" + - name: Upload activation artifact + if: success() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: activation + include-hidden-files: true + path: | + /tmp/gh-aw/aw_info.json + /tmp/gh-aw/models.json + /tmp/gh-aw/aw-prompts/prompt.txt + /tmp/gh-aw/aw-prompts/prompt-template.txt + /tmp/gh-aw/aw-prompts/prompt-import-tree.json + /tmp/gh-aw/github_rate_limits.jsonl + /tmp/gh-aw/base + /tmp/gh-aw/.github/agents + /tmp/gh-aw/.github/skills + if-no-files-found: ignore + retention-days: 1 + + agent: + needs: activation + if: needs.activation.outputs.daily_ai_credits_exceeded != 'true' + runs-on: ubuntu-latest + permissions: + contents: read + copilot-requests: write + concurrency: + group: "gh-aw-copilot-${{ github.workflow }}" + queue: max + env: + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + GH_AW_ASSETS_ALLOWED_EXTS: "" + GH_AW_ASSETS_BRANCH: "" + GH_AW_ASSETS_MAX_SIZE_KB: 0 + GH_AW_MCP_LOG_DIR: /tmp/gh-aw/mcp-logs/safeoutputs + GH_AW_RUNTIME_FEATURES: ${{ vars.GH_AW_RUNTIME_FEATURES }} + GH_AW_WORKFLOW_ID_SANITIZED: smokeboundedagentsgvisor + outputs: + agentic_engine_timeout: ${{ steps.detect-agent-errors.outputs.agentic_engine_timeout || 'false' }} + ai_credits_rate_limit_error: ${{ steps.parse-mcp-gateway.outputs.ai_credits_rate_limit_error || 'false' }} + aic: ${{ steps.parse-mcp-gateway.outputs.aic }} + ambient_context: ${{ steps.parse-mcp-gateway.outputs.ambient_context }} + checkout_pr_success: ${{ steps.checkout-pr.outputs.checkout_pr_success || 'true' }} + effective_tokens: ${{ steps.parse-mcp-gateway.outputs.effective_tokens }} + has_patch: ${{ steps.collect_output.outputs.has_patch }} + http_400_response_error: ${{ steps.detect-agent-errors.outputs.http_400_response_error || 'false' }} + inference_access_error: ${{ steps.detect-agent-errors.outputs.inference_access_error || 'false' }} + invocation_cap_exceeded: ${{ steps.detect-agent-errors.outputs.invocation_cap_exceeded || 'false' }} + max_cache_misses_exceeded: ${{ steps.detect-agent-errors.outputs.max_cache_misses_exceeded || 'false' }} + mcp_policy_error: ${{ steps.detect-agent-errors.outputs.mcp_policy_error || 'false' }} + missing_model_pricing_error: ${{ steps.detect-agent-errors.outputs.missing_model_pricing_error || 'false' }} + missing_model_pricing_model_name: ${{ steps.detect-agent-errors.outputs.missing_model_pricing_model_name || '' }} + model: ${{ needs.activation.outputs.model }} + model_not_supported_error: ${{ steps.detect-agent-errors.outputs.model_not_supported_error || 'false' }} + output: ${{ steps.collect_output.outputs.output }} + output_types: ${{ steps.collect_output.outputs.output_types }} + setup-parent-span-id: ${{ steps.setup.outputs.parent-span-id || steps.setup.outputs.span-id }} + setup-span-id: ${{ steps.setup.outputs.span-id }} + setup-trace-id: ${{ steps.setup.outputs.trace-id }} + unknown_model_ai_credits: ${{ steps.parse-mcp-gateway.outputs.unknown_model_ai_credits || 'false' }} + steps: + - name: Setup Scripts + id: setup + uses: github/gh-aw-actions/setup@fd783ac87efde5e0c0e05d593f1906ea25b5d92e # v0.84.2 + with: + destination: ${{ runner.temp }}/gh-aw/actions + job-name: ${{ github.job }} + trace-id: ${{ needs.activation.outputs.setup-trace-id }} + parent-span-id: ${{ needs.activation.outputs.setup-parent-span-id || needs.activation.outputs.setup-span-id }} + env: + GH_AW_SETUP_WORKFLOW_NAME: "Smoke Bounded Agents gVisor" + GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/smoke-bounded-agents-gvisor.lock.yml@${{ github.ref }} + GH_AW_INFO_VERSION: "1.0.34" + GH_AW_INFO_AWF_VERSION: "v0.28.0" + GH_AW_INFO_ENGINE_ID: "copilot" + - name: Set runtime paths + id: set-runtime-paths + run: | + { + echo "GH_AW_SAFE_OUTPUTS=${RUNNER_TEMP}/gh-aw/safeoutputs/outputs.jsonl" + echo "GH_AW_SAFE_OUTPUTS_CONFIG_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" + echo "GH_AW_SAFE_OUTPUTS_TOOLS_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/tools.json" + } >> "$GITHUB_OUTPUT" + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Create gh-aw temp directory + run: bash "${RUNNER_TEMP}/gh-aw/actions/create_gh_aw_tmp_dir.sh" + - name: Configure gh CLI for GitHub Enterprise + run: bash "${RUNNER_TEMP}/gh-aw/actions/configure_gh_for_ghe.sh" + env: + GH_TOKEN: ${{ github.token }} + - name: Download activation artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: activation + path: /tmp/gh-aw + - name: Build unreleased AWF + run: |- + npm ci + npm run build + + - name: Configure Git credentials + env: + GITHUB_REPOSITORY: ${{ github.repository }} + GITHUB_SERVER_URL: ${{ github.server_url }} + GITHUB_TOKEN: ${{ github.token }} + run: bash "${RUNNER_TEMP}/gh-aw/actions/configure_git_credentials.sh" + - name: Checkout PR branch + id: checkout-pr + if: | + github.event.pull_request || github.event.issue.pull_request || github.event_name == 'workflow_dispatch' && fromJSON(github.event.inputs.aw_context || '{}').item_type == 'pull_request' + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + with: + github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/checkout_pr_branch.cjs'); + await main(); + - name: Install GitHub Copilot CLI + run: bash "${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh" 1.0.34 + env: + GH_HOST: github.com + - name: Setup Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: '24' + package-manager-cache: false + - name: Install awf dependencies + run: npm ci + - name: Build awf + run: npm run build + - name: Install awf binary (local) + run: | + WORKSPACE_PATH="${GITHUB_WORKSPACE:-$(pwd)}" + NODE_BIN="$(command -v node)" + if [ ! -d "$WORKSPACE_PATH" ]; then + echo "Workspace path not found: $WORKSPACE_PATH" + exit 1 + fi + if [ ! -x "$NODE_BIN" ]; then + echo "Node binary not found: $NODE_BIN" + exit 1 + fi + if [ ! -d "/usr/local/bin" ]; then + echo "/usr/local/bin is missing" + exit 1 + fi + sudo tee /usr/local/bin/awf > /dev/null < \"$HOME/.local/bin/awf\"\nchmod +x \"$HOME/.local/bin/awf\"\nnode <<'NODE'\nconst fs = require(\"fs\");\nconst file = `${process.env.RUNNER_TEMP}/gh-aw/awf-config.json`;\nconst config = JSON.parse(fs.readFileSync(file, \"utf8\"));\nconfig.apiProxy = { ...(config.apiProxy || {}), targets: { openai: {} } };\nconfig.boundedAgents = {\n enabled: true,\n privateRepos: [{ repo: \"github/gh-aw\", sensitivity: \"internal\" }],\n runtime: \"gvisor\",\n profile: \"openai\",\n model: \"gpt-4o-mini\",\n memoryLimit: \"512m\"\n};\nfs.writeFileSync(file, `${JSON.stringify(config, null, 2)}\\n`, { mode: 0o600 });\nNODE" + + - name: Download container images + run: bash "${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh" ghcr.io/github/gh-aw-mcpg:v0.4.7@sha256:7545220a9aca134b71e51193ee0eaf4c50756ebf8fbd25a63ae7556e62815c00 ghcr.io/github/gh-aw-node@sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b ghcr.io/github/github-mcp-server:v1.8.0@sha256:d5a18c04b92714c309eb46a2305087e91a4dbd80420f6e462656699f95093520 + - name: Generate Safe Outputs Config + run: | + mkdir -p "${RUNNER_TEMP}/gh-aw/safeoutputs" + mkdir -p /tmp/gh-aw/safeoutputs + mkdir -p /tmp/gh-aw/mcp-logs/safeoutputs + cat > "${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" << 'GH_AW_SAFE_OUTPUTS_CONFIG_4ac93f4cf2185140_EOF' + {"create_issue":{"labels":["smoke-bounded-agents-gvisor"],"max":1,"title_prefix":"[smoke-bounded-agents-gvisor]"},"create_report_incomplete_issue":{},"missing_data":{},"missing_tool":{},"noop":{"max":1,"report-as-issue":"true"},"report_incomplete":{}} + GH_AW_SAFE_OUTPUTS_CONFIG_4ac93f4cf2185140_EOF + - name: Generate Safe Outputs Tools + env: + GH_AW_TOOLS_META_JSON: | + { + "description_suffixes": { + "create_issue": " CONSTRAINTS: Maximum 1 issue(s) can be created. Title will be prefixed with \"[smoke-bounded-agents-gvisor]\". Labels [\"smoke-bounded-agents-gvisor\"] will be automatically added." + }, + "repo_params": {}, + "dynamic_tools": [] + } + GH_AW_VALIDATION_JSON: | + { + "create_issue": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000, + "minLength": 20 + }, + "fields": { + "type": "array" + }, + "labels": { + "type": "array", + "itemType": "string", + "itemSanitize": true, + "itemMaxLength": 128 + }, + "parent": { + "issueOrPRNumber": true + }, + "repo": { + "type": "string", + "maxLength": 256 + }, + "temporary_id": { + "type": "string" + }, + "title": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "missing_data": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "context": { + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "data_type": { + "type": "string", + "sanitize": true, + "maxLength": 128 + }, + "reason": { + "type": "string", + "sanitize": true, + "maxLength": 256 + } + } + }, + "missing_tool": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 512 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "tool": { + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "noop": { + "defaultMax": 1, + "fields": { + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + } + } + }, + "report_incomplete": { + "defaultMax": 5, + "fields": { + "details": { + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 1024 + } + } + } + } + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/generate_safe_outputs_tools.cjs'); + await main(); + - name: Start MCP Gateway + id: start-mcp-gateway + env: + GH_AW_POLICY_ALLOW_CREATE_PULL_REQUEST: ${{ vars.GH_AW_POLICY_ALLOW_CREATE_PULL_REQUEST || 'true' }} + GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} + GH_AW_SAFE_OUTPUTS_CONFIG_PATH: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS_CONFIG_PATH }} + GH_AW_SAFE_OUTPUTS_TOOLS_PATH: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS_TOOLS_PATH }} + GH_AW_SINK_VISIBILITY: ${{ steps.determine-automatic-lockdown.outputs.visibility }} + GITHUB_MCP_GUARD_MIN_INTEGRITY: ${{ steps.determine-automatic-lockdown.outputs.min_integrity }} + GITHUB_MCP_GUARD_REPOS: ${{ steps.determine-automatic-lockdown.outputs.repos }} + GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -eo pipefail + mkdir -p "${RUNNER_TEMP}/gh-aw/mcp-config" + + # Export gateway environment variables for MCP config and gateway script + export MCP_GATEWAY_PORT="8080" + export MCP_GATEWAY_DOMAIN="awmg-mcpg" + export MCP_GATEWAY_HOST_DOMAIN="localhost" + MCP_GATEWAY_API_KEY=$(openssl rand -base64 45 | tr -d '/+=') + echo "::add-mask::${MCP_GATEWAY_API_KEY}" + export MCP_GATEWAY_API_KEY + export MCP_GATEWAY_PAYLOAD_DIR="/tmp/gh-aw/mcp-payloads" + mkdir -p "${MCP_GATEWAY_PAYLOAD_DIR}" + export MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD="524288" + export DEBUG="*" + + export GH_AW_ENGINE="copilot" + MCP_GATEWAY_UID=$(id -u 2>/dev/null || echo '0') + MCP_GATEWAY_GID=$(id -g 2>/dev/null || echo '0') + source "${RUNNER_TEMP}/gh-aw/actions/resolve_docker_socket_gid.sh" + export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network bridge -p 127.0.0.1:'"${MCP_GATEWAY_PORT}"':'"${MCP_GATEWAY_PORT}"' --name awmg-mcpg --add-host host.docker.internal:host-gateway --user '"${MCP_GATEWAY_UID}"':'"${MCP_GATEWAY_GID}"' --group-add '"${DOCKER_SOCK_GID}"' -v '"${DOCKER_SOCK_PATH}"':/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD -e DOCKER_HOST=unix:///var/run/docker.sock -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_POLICY_ALLOW_CREATE_PULL_REQUEST -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_GUARD_MIN_INTEGRITY -e GITHUB_MCP_GUARD_REPOS -e GH_AW_SINK_VISIBILITY -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -e RUNNER_TEMP -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw -v '"${RUNNER_TEMP}"'/gh-aw/safeoutputs:'"${RUNNER_TEMP}"'/gh-aw/safeoutputs:rw ghcr.io/github/gh-aw-mcpg:v0.4.7' + + mkdir -p "$HOME/.copilot" + GH_AW_NODE=$(which node 2>/dev/null || command -v node 2>/dev/null || echo node) + cat << GH_AW_MCP_CONFIG_a59a0690e5cab2b2_EOF | "$GH_AW_NODE" "${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.cjs" + { + "mcpServers": { + "github": { + "type": "stdio", + "container": "ghcr.io/github/github-mcp-server:v1.8.0", + "env": { + "GITHUB_FEATURES": "fields_param", + "GITHUB_HOST": "${GITHUB_SERVER_URL}", + "GITHUB_PERSONAL_ACCESS_TOKEN": "${GITHUB_MCP_SERVER_TOKEN}", + "GITHUB_READ_ONLY": "1", + "GITHUB_TOOLSETS": "context" + }, + "guard-policies": { + "allow-only": { + "min-integrity": "$GITHUB_MCP_GUARD_MIN_INTEGRITY", + "repos": "$GITHUB_MCP_GUARD_REPOS" + } + } + }, + "safeoutputs": { + "type": "stdio", + "container": "ghcr.io/github/gh-aw-node", + "mounts": ["\${GITHUB_WORKSPACE}:\${GITHUB_WORKSPACE}:rw", "${RUNNER_TEMP}/gh-aw/safeoutputs:${RUNNER_TEMP}/gh-aw/safeoutputs:rw", "/tmp/gh-aw:/tmp/gh-aw:rw"], + "args": ["-w", "\${GITHUB_WORKSPACE}"], + "entrypoint": "sh", + "entrypointArgs": ["-c", "sh ${RUNNER_TEMP}/gh-aw/safeoutputs/start_safe_outputs_mcp.sh"], + "env": { + "DEBUG": "*", + "DEFAULT_BRANCH": "\${DEFAULT_BRANCH}", + "GH_AW_ASSETS_ALLOWED_EXTS": "\${GH_AW_ASSETS_ALLOWED_EXTS}", + "GH_AW_ASSETS_BRANCH": "\${GH_AW_ASSETS_BRANCH}", + "GH_AW_ASSETS_MAX_SIZE_KB": "\${GH_AW_ASSETS_MAX_SIZE_KB}", + "GH_AW_MCP_LOG_DIR": "\${GH_AW_MCP_LOG_DIR}", + "GH_AW_SAFE_OUTPUTS": "\${GH_AW_SAFE_OUTPUTS}", + "GH_AW_SAFE_OUTPUTS_CONFIG_PATH": "\${GH_AW_SAFE_OUTPUTS_CONFIG_PATH}", + "GH_AW_SAFE_OUTPUTS_TOOLS_PATH": "\${GH_AW_SAFE_OUTPUTS_TOOLS_PATH}", + "GH_AW_POLICY_ALLOW_CREATE_PULL_REQUEST": "\${GH_AW_POLICY_ALLOW_CREATE_PULL_REQUEST}", + "GITHUB_REPOSITORY": "\${GITHUB_REPOSITORY}", + "GITHUB_SHA": "\${GITHUB_SHA}", + "GITHUB_TOKEN": "\${GITHUB_TOKEN}", + "GITHUB_WORKSPACE": "\${GITHUB_WORKSPACE}", + "RUNNER_TEMP": "\${RUNNER_TEMP}" + }, + "guard-policies": { + "write-sink": { + "accept": [ + "*" + ], + "sink-visibility": "${GH_AW_SINK_VISIBILITY}" + } + } + } + }, + "gateway": { + "port": $MCP_GATEWAY_PORT, + "domain": "${MCP_GATEWAY_DOMAIN}", + "apiKey": "${MCP_GATEWAY_API_KEY}", + "payloadDir": "${MCP_GATEWAY_PAYLOAD_DIR}", + "startupTimeout": 120 + } + } + GH_AW_MCP_CONFIG_a59a0690e5cab2b2_EOF + - name: Mount MCP servers as CLIs + id: mount-mcp-clis + continue-on-error: true + env: + MCP_GATEWAY_API_KEY: ${{ steps.start-mcp-gateway.outputs.gateway-api-key }} + MCP_GATEWAY_DOMAIN: ${{ steps.start-mcp-gateway.outputs.gateway-domain }} + MCP_GATEWAY_PORT: ${{ steps.start-mcp-gateway.outputs.gateway-port }} + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('${{ runner.temp }}/gh-aw/actions/mount_mcp_as_cli.cjs'); + await main(); + - name: Clean credentials + continue-on-error: true + run: bash "${RUNNER_TEMP}/gh-aw/actions/clean_git_credentials.sh" + - name: Audit pre-agent workspace + id: pre_agent_audit + continue-on-error: true + run: bash "${RUNNER_TEMP}/gh-aw/actions/audit_pre_agent_workspace.sh" + - name: Execute GitHub Copilot CLI + id: agentic_execution + # Copilot CLI tool arguments (sorted): + timeout-minutes: 30 + run: | + set -o pipefail + printf '%s' "$(date +%s%3N)" > /tmp/gh-aw/agent_cli_start_ms.txt + trap 'gh_aw_exit_code=$?; mkdir -p /tmp/gh-aw >/dev/null 2>&1 || true; printf "%s" "$gh_aw_exit_code" > /tmp/gh-aw/agent_execution_exit_code.txt || true; rm -f "$HOME/.copilot/settings.json"' EXIT + mkdir -p "$HOME/.copilot" + printf '%s' '{"builtInAgents":{"rubberDuck":false}}' > "$HOME/.copilot/settings.json" + export XDG_CONFIG_HOME="$HOME" + export GH_AW_MCP_CONFIG="$HOME/.copilot/mcp-config.json" + touch /tmp/gh-aw/agent-step-summary.md + GH_AW_NODE_BIN=$(command -v node 2>/dev/null || true) + export GH_AW_NODE_BIN + export COPILOT_API_KEY="$COPILOT_DUMMY_BYOK" + (umask 177 && touch /tmp/gh-aw/agent-stdio.log) + GH_AW_MAX_AI_CREDITS="${GH_AW_MAX_AI_CREDITS:-1000}" + printf '%s\n' "{\"\$schema\":\"https://github.com/github/gh-aw-firewall/releases/download/v0.28.0/awf-config.schema.json\",\"network\":{\"allowDomains\":[\"*.githubusercontent.com\",\"api.business.githubcopilot.com\",\"api.enterprise.githubcopilot.com\",\"api.github.com\",\"api.githubcopilot.com\",\"api.individual.githubcopilot.com\",\"api.snapcraft.io\",\"archive.ubuntu.com\",\"azure.archive.ubuntu.com\",\"codeload.github.com\",\"crl.geotrust.com\",\"crl.globalsign.com\",\"crl.identrust.com\",\"crl.sectigo.com\",\"crl.thawte.com\",\"crl.usertrust.com\",\"crl.verisign.com\",\"crl3.digicert.com\",\"crl4.digicert.com\",\"crls.ssl.com\",\"docs.github.com\",\"github-cloud.githubusercontent.com\",\"github-cloud.s3.amazonaws.com\",\"github.blog\",\"github.com\",\"github.githubassets.com\",\"host.docker.internal\",\"json-schema.org\",\"json.schemastore.org\",\"keyserver.ubuntu.com\",\"lfs.github.com\",\"objects.githubusercontent.com\",\"ocsp.digicert.com\",\"ocsp.geotrust.com\",\"ocsp.globalsign.com\",\"ocsp.identrust.com\",\"ocsp.sectigo.com\",\"ocsp.ssl.com\",\"ocsp.thawte.com\",\"ocsp.usertrust.com\",\"ocsp.verisign.com\",\"packagecloud.io\",\"packages.cloud.google.com\",\"packages.microsoft.com\",\"patch-diff.githubusercontent.com\",\"patchdiff.githubusercontent.com\",\"ppa.launchpad.net\",\"raw.githubusercontent.com\",\"registry.npmjs.org\",\"s.symcb.com\",\"s.symcd.com\",\"security.ubuntu.com\",\"telemetry.enterprise.githubcopilot.com\",\"ts-crl.ws.symantec.com\",\"ts-ocsp.ws.symantec.com\",\"www.googleapis.com\"],\"isolation\":true,\"topologyAttach\":[\"awmg-mcpg\"]},\"apiProxy\":{\"enabled\":true,\"enableTokenSteering\":true,\"maxRuns\":500,\"maxAiCredits\":${GH_AW_MAX_AI_CREDITS},\"maxCacheMisses\":5,\"models\":{\"agent\":[\"sonnet-6x\",\"gpt-5.4\",\"gpt-5.5\",\"gpt-5.6\",\"gpt-5.3\",\"gemini-pro\",\"any\"],\"antigravity\":[\"copilot/antigravity*\",\"google/antigravity*\",\"gemini/antigravity*\"],\"any\":[\"copilot/*\",\"anthropic/*\",\"openai/*\",\"google/*\",\"gemini/*\"],\"auto\":[\"copilot/auto\",\"large\"],\"claude\":[\"agent\"],\"codex\":[\"agent\"],\"coding\":[\"copilot/gpt-5*codex*\",\"openai/gpt-5*codex*\",\"gpt-5-codex\",\"kimi\"],\"computer-use\":[\"copilot/*computer-use*\",\"google/*computer-use*\",\"gemini/*computer-use*\",\"openai/*computer-use*\"],\"copilot\":[\"agent\"],\"deep-research\":[\"copilot/deep-research*\",\"copilot/o3-deep-research*\",\"copilot/o4-mini-deep-research*\",\"google/deep-research*\",\"gemini/deep-research*\",\"openai/o3-deep-research*\",\"openai/o4-mini-deep-research*\"],\"fable\":[\"copilot/*fable*\",\"anthropic/*fable*\"],\"gemini\":[\"agent\"],\"gemini-3-flash\":[\"copilot/gemini-3*flash*\",\"google/gemini-3*flash*\",\"gemini/gemini-3*flash*\"],\"gemini-3-pro\":[\"copilot/gemini-3*pro*\",\"google/gemini-3*pro*\",\"google/nano-banana*\",\"gemini/gemini-3*pro*\"],\"gemini-3.1-flash\":[\"copilot/gemini-3.1*flash*\",\"google/gemini-3.1*flash*\",\"gemini/gemini-3.1*flash*\"],\"gemini-3.1-pro\":[\"copilot/gemini-3.1*pro*\",\"google/gemini-3.1*pro*\",\"gemini/gemini-3.1*pro*\"],\"gemini-3.5-flash\":[\"copilot/gemini-3.5*flash*\",\"google/gemini-3.5*flash*\",\"gemini/gemini-3.5*flash*\"],\"gemini-3.6-flash\":[\"copilot/gemini-3.6*flash*\",\"google/gemini-3.6*flash*\",\"gemini/gemini-3.6*flash*\"],\"gemini-flash\":[\"copilot/gemini-*flash*\",\"google/gemini-*flash*\",\"gemini/gemini-*flash*\"],\"gemini-flash-lite\":[\"copilot/gemini-*flash*lite*\",\"google/gemini-*flash*lite*\",\"gemini/gemini-*flash*lite*\"],\"gemini-omni\":[\"copilot/gemini-omni*\",\"google/gemini-omni*\",\"gemini/gemini-omni*\"],\"gemini-pro\":[\"copilot/gemini-*pro*\",\"google/gemini-*pro*\",\"gemini/gemini-*pro*\"],\"gemma\":[\"copilot/gemma*\",\"google/gemma*\",\"gemini/gemma*\"],\"gpt-5\":[\"copilot/gpt-5*\",\"openai/gpt-5*\"],\"gpt-5-codex\":[\"copilot/gpt-5*codex*\",\"openai/gpt-5*codex*\"],\"gpt-5-mini\":[\"copilot/gpt-5*mini*\",\"openai/gpt-5*mini*\"],\"gpt-5-nano\":[\"copilot/gpt-5*nano*\",\"openai/gpt-5*nano*\"],\"gpt-5-pro\":[\"copilot/gpt-5*pro*\",\"openai/gpt-5*pro*\"],\"gpt-5.1\":[\"copilot/gpt-5.1*\",\"openai/gpt-5.1*\"],\"gpt-5.2\":[\"copilot/gpt-5.2*\",\"openai/gpt-5.2*\"],\"gpt-5.3\":[\"copilot/gpt-5.3*\",\"openai/gpt-5.3*\"],\"gpt-5.4\":[\"copilot/gpt-5.4*\",\"openai/gpt-5.4*\"],\"gpt-5.5\":[\"copilot/gpt-5.5*\",\"openai/gpt-5.5*\"],\"gpt-5.6\":[\"copilot/gpt-5.6*\",\"openai/gpt-5.6*\"],\"grok\":[\"copilot/*grok*\",\"openai/*grok*\"],\"haiku\":[\"copilot/*haiku*\",\"anthropic/*haiku*\"],\"image-generation\":[\"copilot/gpt-image*\",\"openai/gpt-image*\",\"openai/chatgpt-image*\",\"copilot/gemini-*image*\",\"google/gemini-*image*\",\"gemini/gemini-*image*\",\"google/imagen*\"],\"kimi\":[\"copilot/kimi*\",\"openai/kimi*\"],\"kiwi\":[\"copilot/kiwi*\",\"openai/kiwi*\"],\"large\":[\"sonnet\",\"gpt-5-pro\",\"gpt-5\",\"gemini-pro\"],\"lyria\":[\"google/lyria*\",\"gemini/lyria*\",\"copilot/lyria*\"],\"mai-code\":[\"copilot/MAI-Code*\",\"copilot/mai-code*\",\"openai/MAI-Code*\"],\"mai-code-1-flash-picker\":[\"copilot/MAI-Code-1-Flash-picker*\",\"copilot/mai-code-1-flash-picker*\",\"openai/MAI-Code-1-Flash-picker*\"],\"mini\":[\"haiku\",\"gpt-5-mini\",\"gpt-5-nano\",\"gemini-flash-lite\"],\"nano-banana\":[\"copilot/nano-banana*\",\"google/nano-banana*\",\"gemini/nano-banana*\"],\"opus\":[\"copilot/*opus*\",\"anthropic/*opus*\"],\"opusplan\":[\"opus?effort=high\"],\"raptor-mini\":[\"copilot/raptor*\",\"openai/raptor*\"],\"reasoning\":[\"copilot/o1*\",\"copilot/o3*\",\"copilot/o4*\",\"openai/o1*\",\"openai/o3*\",\"openai/o4*\"],\"robotics\":[\"copilot/*robotics*\",\"google/*robotics*\",\"gemini/*robotics*\"],\"small\":[\"mini\"],\"small-agent\":[\"haiku\",\"gpt-5-mini\",\"gemini-flash\"],\"sonnet\":[\"copilot/*sonnet*\",\"anthropic/*sonnet*\"],\"sonnet-6x\":[\"copilot/*sonnet-4.5*\",\"copilot/*sonnet-4.6*\",\"copilot/*sonnet-5*\",\"copilot/*sonnet-4-5-*\",\"anthropic/*sonnet-4-5-*\",\"copilot/*sonnet-4-6*\",\"anthropic/*sonnet-4-6*\",\"anthropic/*sonnet-5*\"],\"summarization\":[\"haiku\",\"gpt-5-mini\",\"gemini-flash-lite\",\"mini\"],\"veo\":[\"google/veo*\",\"gemini/veo*\"],\"vision\":[\"copilot/gemini-*image*\",\"google/gemini-*image*\",\"gemini/gemini-*image*\",\"copilot/gemini-*flash*\",\"google/gemini-*flash*\",\"gemini/gemini-*flash*\"]}},\"container\":{\"imageTag\":\"0.28.0\"},\"logging\":{\"proxyLogsDir\":\"/tmp/gh-aw/sandbox/firewall/logs\",\"auditDir\":\"/tmp/gh-aw/sandbox/firewall/audit\"}}" > "${RUNNER_TEMP}/gh-aw/awf-config.json" + cp "${RUNNER_TEMP}/gh-aw/awf-config.json" /tmp/gh-aw/awf-config.json + export GH_AW_MODELS_JSON_PATH="/tmp/gh-aw/models.json" + GH_AW_DOCKER_HOST="" + if [[ "${DOCKER_HOST:-}" =~ ^tcp:// ]]; then + GH_AW_DOCKER_HOST="${DOCKER_HOST}" + fi + if [[ "${DOCKER_HOST:-}" =~ ^tcp:// ]]; then + GH_AW_CHROOT_BINARIES_SOURCE_PATH="${RUNNER_TEMP}/gh-aw" GH_AW_CHROOT_IDENTITY_HOME="${RUNNER_TEMP}/gh-aw/home" node "${RUNNER_TEMP}/gh-aw/actions/patch_awf_chroot_config.cjs" + fi + GH_AW_TOOL_CACHE_MOUNT="" + GH_AW_TOOL_CACHE="${RUNNER_TOOL_CACHE:?RUNNER_TOOL_CACHE must be set}" + if [ -d "$GH_AW_TOOL_CACHE" ]; then + if [[ "$GH_AW_TOOL_CACHE" != /opt/* ]]; then + GH_AW_TOOL_CACHE_MOUNT="$GH_AW_TOOL_CACHE:$GH_AW_TOOL_CACHE:ro" + fi + fi + # shellcheck disable=SC1003,SC2016,SC2086 + awf --config "${RUNNER_TEMP}/gh-aw/awf-config.json" --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" ${GH_AW_TOOL_CACHE_MOUNT:+--mount "$GH_AW_TOOL_CACHE_MOUNT"} ${GH_AW_DOCKER_HOST:+--docker-host "$GH_AW_DOCKER_HOST"} --env-all --exclude-env COPILOT_GITHUB_TOKEN --exclude-env GITHUB_MCP_SERVER_TOKEN --exclude-env MCP_GATEWAY_API_KEY --log-level info --build-local \ + -- /bin/bash -c 'set +o histexpand; export PATH="${RUNNER_TEMP}/gh-aw/mcp-cli/bin:$PATH" && : "${RUNNER_TOOL_CACHE:?RUNNER_TOOL_CACHE must be set}"; GH_AW_TOOL_CACHE="$RUNNER_TOOL_CACHE"; export PATH="$(find "$GH_AW_TOOL_CACHE" -maxdepth 5 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true; [ -n "$ERLANG_HOME" ] && export PATH="$ERLANG_HOME/bin:$PATH" || true && GH_AW_NODE_EXEC="${GH_AW_NODE_BIN:-}"; if [ -z "$GH_AW_NODE_EXEC" ] || [ ! -x "$GH_AW_NODE_EXEC" ]; then GH_AW_NODE_EXEC="$(command -v node 2>/dev/null || true)"; fi; if [ -z "$GH_AW_NODE_EXEC" ]; then echo "node runtime missing on this runner — check runtimes.node in workflow YAML" >&2; exit 127; fi; GH_AW_NPM_GLOBAL_ROOT="$(npm root -g 2>/dev/null || true)"; if [ -n "$GH_AW_NPM_GLOBAL_ROOT" ]; then export NODE_PATH="${GH_AW_NPM_GLOBAL_ROOT}${NODE_PATH:+:${NODE_PATH}}"; fi; "$GH_AW_NODE_EXEC" ${RUNNER_TEMP}/gh-aw/actions/copilot_harness.cjs /usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --disable-builtin-mcps --no-ask-user --allow-all-tools --allow-all-paths --add-dir "${GITHUB_WORKSPACE}" --prompt-file /tmp/gh-aw/aw-prompts/prompt.txt' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log + env: + AWF_REFLECT_ENABLED: 1 + COPILOT_AGENT_RUNNER_TYPE: STANDALONE + COPILOT_DUMMY_BYOK: dummy-byok-key-for-offline-mode + COPILOT_GITHUB_TOKEN: ${{ github.token }} + COPILOT_MODEL: ${{ vars.GH_AW_MODEL_AGENT_COPILOT || vars.GH_AW_DEFAULT_MODEL_COPILOT || 'auto' }} + GH_AW_LLM_PROVIDER: github + GH_AW_MAX_AI_CREDITS: ${{ vars.GH_AW_DEFAULT_MAX_AI_CREDITS || '1000' }} + GH_AW_MAX_TURNS: ${{ vars.GH_AW_DEFAULT_MAX_TURNS || '' }} + GH_AW_PHASE: agent + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} + GH_AW_TIMEOUT_MINUTES: 30 + GH_AW_VERSION: v0.84.2 + GITHUB_API_URL: ${{ github.api_url }} + GITHUB_AW: true + GITHUB_COPILOT_INTEGRATION_ID: agentic-workflows + GITHUB_HEAD_REF: ${{ github.head_ref }} + GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + GITHUB_REF_NAME: ${{ github.ref_name }} + GITHUB_SERVER_URL: ${{ github.server_url }} + GITHUB_STEP_SUMMARY: /tmp/gh-aw/agent-step-summary.md + GITHUB_WORKSPACE: ${{ github.workspace }} + GIT_AUTHOR_EMAIL: github-actions[bot]@users.noreply.github.com + GIT_AUTHOR_NAME: github-actions[bot] + GIT_COMMITTER_EMAIL: github-actions[bot]@users.noreply.github.com + GIT_COMMITTER_NAME: github-actions[bot] + RUNNER_TEMP: ${{ runner.temp }} + S2STOKENS: true + TRACEPARENT: ${{ env.GITHUB_AW_OTEL_TRACE_ID != '' && env.GITHUB_AW_OTEL_PARENT_SPAN_ID != '' && format('00-{0}-{1}-01', env.GITHUB_AW_OTEL_TRACE_ID, env.GITHUB_AW_OTEL_PARENT_SPAN_ID) || '' }} + - name: Detect agent errors + if: always() + id: detect-agent-errors + continue-on-error: true + run: node "${RUNNER_TEMP}/gh-aw/actions/detect_agent_errors.cjs" + - name: Configure Git credentials + env: + GITHUB_REPOSITORY: ${{ github.repository }} + GITHUB_SERVER_URL: ${{ github.server_url }} + GITHUB_TOKEN: ${{ github.token }} + run: bash "${RUNNER_TEMP}/gh-aw/actions/configure_git_credentials.sh" + - name: Copy Copilot session state files to logs + if: always() + continue-on-error: true + run: | + SESSION_STATE_SRC="/tmp/gh-aw/sandbox/agent/session-state" + LOGS_DIR="/tmp/gh-aw/sandbox/agent/logs" + if [ -d "$SESSION_STATE_SRC" ] && [ -n "$(ls -A "$SESSION_STATE_SRC" 2>/dev/null)" ]; then + mkdir -p "$LOGS_DIR/session-state" + cp -rp "$SESSION_STATE_SRC/." "$LOGS_DIR/session-state/" + echo "Copied session state to $LOGS_DIR/session-state" + else + echo "No session state found at $SESSION_STATE_SRC" + fi + - name: Stop MCP Gateway + if: always() + continue-on-error: true + env: + MCP_GATEWAY_PORT: ${{ steps.start-mcp-gateway.outputs.gateway-port }} + MCP_GATEWAY_API_KEY: ${{ steps.start-mcp-gateway.outputs.gateway-api-key }} + GATEWAY_PID: ${{ steps.start-mcp-gateway.outputs.gateway-pid }} + run: | + bash "${RUNNER_TEMP}/gh-aw/actions/stop_mcp_gateway.sh" "$GATEWAY_PID" + - name: Redact secrets in logs + if: always() + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/redact_secrets.cjs'); + await main(); + env: + GH_AW_SECRET_NAMES: 'GH_AW_GITHUB_MCP_SERVER_TOKEN,GH_AW_GITHUB_TOKEN,GITHUB_TOKEN' + SECRET_GH_AW_GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN }} + SECRET_GH_AW_GITHUB_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN }} + SECRET_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Append agent step summary + if: always() + run: bash "${RUNNER_TEMP}/gh-aw/actions/append_agent_step_summary.sh" + - name: Copy Safe Outputs + if: always() + env: + GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} + run: | + mkdir -p /tmp/gh-aw + cp "$GH_AW_SAFE_OUTPUTS" /tmp/gh-aw/safeoutputs.jsonl 2>/dev/null || true + - name: Ingest agent output + id: collect_output + if: always() + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} + GH_AW_ALLOWED_DOMAINS: "*.githubusercontent.com,api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,codeload.github.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,docs.github.com,github-cloud.githubusercontent.com,github-cloud.s3.amazonaws.com,github.blog,github.com,github.githubassets.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,lfs.github.com,objects.githubusercontent.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,patch-diff.githubusercontent.com,patchdiff.githubusercontent.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com" + GITHUB_SERVER_URL: ${{ github.server_url }} + GITHUB_API_URL: ${{ github.api_url }} + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/collect_ndjson_output.cjs'); + await main(); + - name: Parse agent logs for step summary + if: always() + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_AGENT_OUTPUT: /tmp/gh-aw/sandbox/agent/logs/ + GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/parse_copilot_log.cjs'); + await main(); + - name: Parse MCP Gateway logs for step summary + if: always() + id: parse-mcp-gateway + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/parse_mcp_gateway_log.cjs'); + await main(); + - name: Print firewall logs + if: always() + continue-on-error: true + env: + AWF_LOGS_DIR: /tmp/gh-aw/sandbox/firewall/logs + run: bash "${RUNNER_TEMP}/gh-aw/actions/print_firewall_logs.sh" --rootless + - name: Parse token usage for step summary + if: always() + continue-on-error: true + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/parse_token_usage.cjs'); + await main(); + - name: Print AWF reflect summary + if: always() + continue-on-error: true + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/awf_reflect_summary.cjs'); + await main(); + - name: Write agent output placeholder if missing + if: always() + run: | + if [ ! -f /tmp/gh-aw/agent_output.json ]; then + echo '{"items":[]}' > /tmp/gh-aw/agent_output.json + fi + - env: + AUDIT_LOG: /tmp/gh-aw/sandbox/firewall/audit/bounded-agent.jsonl + OUTPUTS_FILE: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} + TELEMETRY_LOG: /tmp/gh-aw/sandbox/firewall/audit/bounded-agent-runtime.jsonl + if: always() + name: Validate gVisor bounded-agent invocation + run: "node - \"$AUDIT_LOG\" \"$TELEMETRY_LOG\" \"$OUTPUTS_FILE\" <<'NODE'\nconst fs = require(\"fs\");\nconst [auditPath, telemetryPath, outputsPath] = process.argv.slice(2);\nconst read = (file) => fs.readFileSync(file, \"utf8\").trim().split(\"\\n\")\n .filter(Boolean).map((line) => JSON.parse(line));\nconst invocations = read(auditPath).filter((record) =>\n record.kind === \"invocation\" && record.sensitivity === \"internal\");\nif (invocations.length !== 1 || invocations[0].outcome !== \"ok\") {\n throw new Error(`expected one successful bounded-agent invocation, found ${invocations.length}`);\n}\nconst successes = read(telemetryPath).filter((record) =>\n record.primaryBackend === \"docker\" &&\n record.boundedAgentBackend === \"gvisor\" &&\n record.lifecycleClass === \"invocation\" &&\n record.category === \"success\");\nif (successes.length !== 1) {\n throw new Error(`expected one successful gVisor telemetry record, found ${successes.length}`);\n}\nconst outputs = fs.readFileSync(outputsPath, \"utf8\");\nif (!outputs.includes('\"noop\"') || !outputs.includes(\"PASS\")) {\n throw new Error(\"agent did not report PASS through noop\");\n}\nNODE" + + - name: Upload agent artifacts + if: always() + continue-on-error: true + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: agent + path: | + /tmp/gh-aw/aw-prompts/prompt.txt + /tmp/gh-aw/sandbox/agent/logs/ + /tmp/gh-aw/redacted-urls.log + /tmp/gh-aw/mcp-logs/ + /tmp/gh-aw/agent_usage.json + /tmp/gh-aw/agent-stdio.log + /tmp/gh-aw/pre-agent-audit.txt + /tmp/gh-aw/agent/ + /tmp/gh-aw/github_rate_limits.jsonl + /tmp/gh-aw/safeoutputs.jsonl + /tmp/gh-aw/agent_output.json + /tmp/gh-aw/awf-config.json + /tmp/gh-aw/sandbox/firewall/logs/ + /tmp/gh-aw/sandbox/firewall/audit/ + /tmp/gh-aw/sandbox/firewall/awf-reflect.json + if-no-files-found: ignore + + conclusion: + needs: + - activation + - agent + - safe_outputs + if: > + always() && (needs.agent.result != 'skipped' || needs.activation.outputs.lockdown_check_failed == 'true' || + needs.activation.outputs.oauth_token_check_failed == 'true' || needs.activation.outputs.stale_lock_file_failed == 'true' || + needs.activation.outputs.daily_ai_credits_exceeded == 'true') + runs-on: ubuntu-slim + permissions: + issues: write + concurrency: + group: "gh-aw-conclusion-smoke-bounded-agents-gvisor" + cancel-in-progress: false + queue: max + env: + GH_AW_RUNTIME_FEATURES: ${{ vars.GH_AW_RUNTIME_FEATURES }} + outputs: + incomplete_count: ${{ steps.report_incomplete.outputs.incomplete_count }} + noop_message: ${{ steps.noop.outputs.noop_message }} + tools_reported: ${{ steps.missing_tool.outputs.tools_reported }} + total_count: ${{ steps.missing_tool.outputs.total_count }} + steps: + - name: Setup Scripts + id: setup + uses: github/gh-aw-actions/setup@fd783ac87efde5e0c0e05d593f1906ea25b5d92e # v0.84.2 + with: + destination: ${{ runner.temp }}/gh-aw/actions + job-name: ${{ github.job }} + trace-id: ${{ needs.activation.outputs.setup-trace-id }} + parent-span-id: ${{ needs.activation.outputs.setup-parent-span-id || needs.activation.outputs.setup-span-id }} + env: + GH_AW_SETUP_WORKFLOW_NAME: "Smoke Bounded Agents gVisor" + GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/smoke-bounded-agents-gvisor.lock.yml@${{ github.ref }} + GH_AW_INFO_VERSION: "1.0.34" + GH_AW_INFO_AWF_VERSION: "v0.28.0" + GH_AW_INFO_ENGINE_ID: "copilot" + - name: Download agent output artifact + id: download-agent-output + continue-on-error: true + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: agent + path: /tmp/gh-aw/ + - name: Setup agent output environment variable + id: setup-agent-output-env + if: steps.download-agent-output.outcome == 'success' + run: | + mkdir -p /tmp/gh-aw/ + find "/tmp/gh-aw/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT" + - name: Download safe outputs items manifest + id: download-safe-outputs-manifest + if: always() + continue-on-error: true + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: safe-outputs-items + path: /tmp/gh-aw/ + - name: Collect usage artifact files + if: always() + continue-on-error: true + run: | + mkdir -p /tmp/gh-aw/usage/agent /tmp/gh-aw/usage/detection + echo "Usage artifact source file status:" + for file in /tmp/gh-aw/aw_info.json /tmp/gh-aw/aw-info.jsonl /tmp/gh-aw/agent_usage.json /tmp/gh-aw/agent_usage.jsonl /tmp/gh-aw/detection_usage.jsonl /tmp/gh-aw/evals/evals.jsonl /tmp/gh-aw/github_rate_limits.jsonl /tmp/gh-aw/sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/threat-detection/sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/threat-detection/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/threat-detection/sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl; do + [ -f "$file" ] && echo "FOUND: $file" || echo "MISSING: $file" + done + [ -f /tmp/gh-aw/aw_info.json ] && cp /tmp/gh-aw/aw_info.json /tmp/gh-aw/usage/aw_info.json || true + [ -f /tmp/gh-aw/aw-info.jsonl ] && cp /tmp/gh-aw/aw-info.jsonl /tmp/gh-aw/usage/aw-info.jsonl || true + [ -f /tmp/gh-aw/agent_usage.json ] && cp /tmp/gh-aw/agent_usage.json /tmp/gh-aw/usage/agent_usage.json || true + [ -f /tmp/gh-aw/agent_usage.jsonl ] && cp /tmp/gh-aw/agent_usage.jsonl /tmp/gh-aw/usage/agent_usage.jsonl || true + [ -f /tmp/gh-aw/detection_usage.jsonl ] && cp /tmp/gh-aw/detection_usage.jsonl /tmp/gh-aw/usage/detection_usage.jsonl || true + [ -f /tmp/gh-aw/evals/evals.jsonl ] && cp /tmp/gh-aw/evals/evals.jsonl /tmp/gh-aw/usage/evals.jsonl || true + [ -f /tmp/gh-aw/github_rate_limits.jsonl ] && cp /tmp/gh-aw/github_rate_limits.jsonl /tmp/gh-aw/usage/github_rate_limits.jsonl || true + [ -s /tmp/gh-aw/sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl ] && cp /tmp/gh-aw/sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/usage/agent/token_usage.jsonl || true + [ -s /tmp/gh-aw/sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl ] && cp /tmp/gh-aw/sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/usage/agent/token_usage.jsonl || true + [ -s /tmp/gh-aw/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl ] && cp /tmp/gh-aw/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/usage/agent/token_usage.jsonl || true + [ -s /tmp/gh-aw/threat-detection/sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl ] && cp /tmp/gh-aw/threat-detection/sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/usage/detection/token_usage.jsonl || true + [ -s /tmp/gh-aw/threat-detection/sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl ] && cp /tmp/gh-aw/threat-detection/sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/usage/detection/token_usage.jsonl || true + [ -s /tmp/gh-aw/threat-detection/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl ] && cp /tmp/gh-aw/threat-detection/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/usage/detection/token_usage.jsonl || true + [ -f /tmp/gh-aw/usage/agent/token_usage.jsonl ] || : > /tmp/gh-aw/usage/agent/token_usage.jsonl + [ -f /tmp/gh-aw/usage/detection/token_usage.jsonl ] || : > /tmp/gh-aw/usage/detection/token_usage.jsonl + mkdir -p /tmp/gh-aw/usage/activity + node "${RUNNER_TEMP}/gh-aw/actions/generate_usage_activity_summary.cjs" + find /tmp/gh-aw/usage -type f -print | sort + - name: Upload usage artifact + if: always() + continue-on-error: true + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: usage + path: | + /tmp/gh-aw/usage/aw_info.json + /tmp/gh-aw/usage/aw-info.jsonl + /tmp/gh-aw/usage/agent_usage.json + /tmp/gh-aw/usage/agent_usage.jsonl + /tmp/gh-aw/usage/detection_usage.jsonl + /tmp/gh-aw/usage/evals.jsonl + /tmp/gh-aw/usage/github_rate_limits.jsonl + /tmp/gh-aw/usage/agent/token_usage.jsonl + /tmp/gh-aw/usage/detection/token_usage.jsonl + /tmp/gh-aw/usage/activity/summary.json + if-no-files-found: ignore + - name: Restore daily AIC usage cache + id: restore-daily-aic-cache-conclusion + if: always() + continue-on-error: true + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + key: agentic-workflow-usage-smokeboundedagentsgvisor-${{ github.run_id }} + restore-keys: agentic-workflow-usage-smokeboundedagentsgvisor- + path: /tmp/gh-aw/agentic-workflow-usage-cache.jsonl + - name: Write daily AIC usage cache entry + id: write-daily-aic-cache + if: always() + continue-on-error: true + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + github-token: ${{ github.token }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context); + const { main } = require('${{ runner.temp }}/gh-aw/actions/write_daily_aic_usage_cache.cjs'); + await main(); + - name: Save daily AIC usage cache + id: save-daily-aic-cache + if: always() + continue-on-error: true + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + key: agentic-workflow-usage-smokeboundedagentsgvisor-${{ github.run_id }} + path: /tmp/gh-aw/agentic-workflow-usage-cache.jsonl + - name: Upload daily AIC usage cache artifact + id: upload-daily-aic-cache + if: always() + continue-on-error: true + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: aic-usage-cache + path: /tmp/gh-aw/agentic-workflow-usage-cache.jsonl + if-no-files-found: ignore + retention-days: 7 + - name: Process no-op messages + id: noop + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }} + GH_AW_NOOP_MAX: "1" + GH_AW_WORKFLOW_NAME: "Smoke Bounded Agents gVisor" + GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/${{ github.repository }}/blob/${{ github.ref_name }}/.github/workflows/smoke-bounded-agents-gvisor.md" + GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }} + GH_AW_NOOP_REPORT_AS_ISSUE: "true" + GH_AW_AIC: ${{ needs.agent.outputs.aic }} + GH_AW_AMBIENT_CONTEXT: ${{ needs.agent.outputs.ambient_context }} + GH_AW_WORKFLOW_ID: "smoke-bounded-agents-gvisor" + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/handle_noop_message.cjs'); + await main(); + - name: Record missing tool + id: missing_tool + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }} + GH_AW_MISSING_TOOL_CREATE_ISSUE: "true" + GH_AW_WORKFLOW_NAME: "Smoke Bounded Agents gVisor" + GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/${{ github.repository }}/blob/${{ github.ref_name }}/.github/workflows/smoke-bounded-agents-gvisor.md" + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/missing_tool.cjs'); + await main(); + - name: Record incomplete + id: report_incomplete + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }} + GH_AW_REPORT_INCOMPLETE_CREATE_ISSUE: "true" + GH_AW_WORKFLOW_NAME: "Smoke Bounded Agents gVisor" + GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/${{ github.repository }}/blob/${{ github.ref_name }}/.github/workflows/smoke-bounded-agents-gvisor.md" + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/report_incomplete_handler.cjs'); + await main(); + - name: Handle agent failure + id: handle_agent_failure + if: always() + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }} + GH_AW_WORKFLOW_NAME: "Smoke Bounded Agents gVisor" + GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/${{ github.repository }}/blob/${{ github.ref_name }}/.github/workflows/smoke-bounded-agents-gvisor.md" + GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }} + GH_AW_WORKFLOW_ID: "smoke-bounded-agents-gvisor" + GH_AW_ACTION_FAILURE_ISSUE_EXPIRES_HOURS: "168" + GH_AW_ENGINE_ID: "copilot" + GH_AW_CHECKOUT_PR_SUCCESS: ${{ needs.agent.outputs.checkout_pr_success }} + GH_AW_EFFECTIVE_TOKENS: ${{ needs.agent.outputs.effective_tokens || '' }} + GH_AW_AI_CREDITS_RATE_LIMIT_ERROR: ${{ needs.agent.outputs.ai_credits_rate_limit_error || 'false' }} + GH_AW_UNKNOWN_MODEL_AI_CREDITS: ${{ needs.agent.outputs.unknown_model_ai_credits || 'false' }} + GH_AW_AIC: ${{ needs.agent.outputs.aic }} + GH_AW_MAX_AI_CREDITS: ${{ vars.GH_AW_DEFAULT_MAX_AI_CREDITS || '1000' }} + GH_AW_INFERENCE_ACCESS_ERROR: ${{ needs.agent.outputs.inference_access_error }} + GH_AW_MCP_POLICY_ERROR: ${{ needs.agent.outputs.mcp_policy_error }} + GH_AW_AGENTIC_ENGINE_TIMEOUT: ${{ needs.agent.outputs.agentic_engine_timeout }} + GH_AW_MODEL_NOT_SUPPORTED_ERROR: ${{ needs.agent.outputs.model_not_supported_error }} + GH_AW_HTTP_400_RESPONSE_ERROR: ${{ needs.agent.outputs.http_400_response_error }} + GH_AW_MAX_CACHE_MISSES_EXCEEDED: ${{ needs.agent.outputs.max_cache_misses_exceeded }} + GH_AW_MISSING_MODEL_PRICING_ERROR: ${{ needs.agent.outputs.missing_model_pricing_error }} + GH_AW_MISSING_MODEL_PRICING_MODEL_NAME: ${{ needs.agent.outputs.missing_model_pricing_model_name }} + GH_AW_ENGINE_API_HOSTS: "api.enterprise.githubcopilot.com,api.githubcopilot.com,api.business.githubcopilot.com,api.individual.githubcopilot.com" + GH_AW_LOCKDOWN_CHECK_FAILED: ${{ needs.activation.outputs.lockdown_check_failed }} + GH_AW_OAUTH_TOKEN_CHECK_FAILED: ${{ needs.activation.outputs.oauth_token_check_failed }} + GH_AW_STALE_LOCK_FILE_FAILED: ${{ needs.activation.outputs.stale_lock_file_failed }} + GH_AW_DAILY_AI_CREDITS_EXCEEDED: ${{ needs.activation.outputs.daily_ai_credits_exceeded }} + GH_AW_DAILY_AI_CREDITS_TOTAL_EFFECTIVE_TOKENS: ${{ needs.activation.outputs.daily_ai_credits_total_effective_tokens }} + GH_AW_DAILY_AI_CREDITS_THRESHOLD: ${{ needs.activation.outputs.daily_ai_credits_threshold }} + GH_AW_GROUP_REPORTS: "false" + GH_AW_FAILURE_REPORT_AS_ISSUE: "true" + GH_AW_MISSING_TOOL_REPORT_AS_FAILURE: "true" + GH_AW_MISSING_DATA_REPORT_AS_FAILURE: "true" + GH_AW_TIMEOUT_MINUTES: "30" + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/handle_agent_failure.cjs'); + await main(); + + safe_outputs: + needs: + - activation + - agent + if: (!cancelled()) && needs.agent.result != 'skipped' + runs-on: ubuntu-slim + permissions: + issues: write + timeout-minutes: 45 + env: + GH_AW_AGENT_AIC: ${{ needs.agent.outputs.aic }} + GH_AW_AIC: ${{ needs.agent.outputs.aic }} + GH_AW_AMBIENT_CONTEXT: ${{ needs.agent.outputs.ambient_context }} + GH_AW_CALLER_WORKFLOW_ID: "${{ github.repository }}/smoke-bounded-agents-gvisor" + GH_AW_EFFECTIVE_TOKENS: ${{ needs.agent.outputs.effective_tokens }} + GH_AW_ENGINE_ID: "copilot" + GH_AW_ENGINE_MODEL: ${{ needs.agent.outputs.model }} + GH_AW_ENGINE_VERSION: "1.0.34" + GH_AW_RUNTIME_FEATURES: ${{ vars.GH_AW_RUNTIME_FEATURES }} + GH_AW_WORKFLOW_ID: "smoke-bounded-agents-gvisor" + GH_AW_WORKFLOW_NAME: "Smoke Bounded Agents gVisor" + GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/${{ github.repository }}/blob/${{ github.ref_name }}/.github/workflows/smoke-bounded-agents-gvisor.md" + outputs: + code_push_failure_count: ${{ steps.process_safe_outputs.outputs.code_push_failure_count }} + code_push_failure_errors: ${{ steps.process_safe_outputs.outputs.code_push_failure_errors }} + create_discussion_error_count: ${{ steps.process_safe_outputs.outputs.create_discussion_error_count }} + create_discussion_errors: ${{ steps.process_safe_outputs.outputs.create_discussion_errors }} + created_issue_number: ${{ steps.process_safe_outputs.outputs.created_issue_number }} + created_issue_url: ${{ steps.process_safe_outputs.outputs.created_issue_url }} + process_safe_outputs_processed_count: ${{ steps.process_safe_outputs.outputs.processed_count }} + process_safe_outputs_temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} + steps: + - name: Setup Scripts + id: setup + uses: github/gh-aw-actions/setup@fd783ac87efde5e0c0e05d593f1906ea25b5d92e # v0.84.2 + with: + destination: ${{ runner.temp }}/gh-aw/actions + job-name: ${{ github.job }} + trace-id: ${{ needs.activation.outputs.setup-trace-id }} + parent-span-id: ${{ needs.activation.outputs.setup-parent-span-id || needs.activation.outputs.setup-span-id }} + env: + GH_AW_SETUP_WORKFLOW_NAME: "Smoke Bounded Agents gVisor" + GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/smoke-bounded-agents-gvisor.lock.yml@${{ github.ref }} + GH_AW_INFO_VERSION: "1.0.34" + GH_AW_INFO_AWF_VERSION: "v0.28.0" + GH_AW_INFO_ENGINE_ID: "copilot" + - name: Download agent output artifact + id: download-agent-output + continue-on-error: true + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: agent + path: /tmp/gh-aw/ + - name: Setup agent output environment variable + id: setup-agent-output-env + if: steps.download-agent-output.outcome == 'success' + run: | + mkdir -p /tmp/gh-aw/ + find "/tmp/gh-aw/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT" + - name: Configure GH_HOST for enterprise compatibility + id: ghes-host-config + shell: bash + run: | # zizmor: ignore[github-env] - GITHUB_SERVER_URL is set by GitHub Actions, not user input. + # Derive GH_HOST from GITHUB_SERVER_URL so the gh CLI targets the correct + # GitHub instance (GHES/GHEC). On github.com this is a harmless no-op. + GH_HOST="${GITHUB_SERVER_URL#https://}" + GH_HOST="${GH_HOST#http://}" + echo "GH_HOST=${GH_HOST}" >> "$GITHUB_ENV" + - name: Process Safe Outputs + id: process_safe_outputs + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }} + GH_AW_COMMENT_ID: ${{ needs.activation.outputs.comment_id }} + GH_AW_ALLOWED_DOMAINS: "*.githubusercontent.com,api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,codeload.github.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,docs.github.com,github-cloud.githubusercontent.com,github-cloud.s3.amazonaws.com,github.blog,github.com,github.githubassets.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,lfs.github.com,objects.githubusercontent.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,patch-diff.githubusercontent.com,patchdiff.githubusercontent.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com" + GITHUB_SERVER_URL: ${{ github.server_url }} + GITHUB_API_URL: ${{ github.api_url }} + GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"create_issue\":{\"labels\":[\"smoke-bounded-agents-gvisor\"],\"max\":1,\"title_prefix\":\"[smoke-bounded-agents-gvisor]\"},\"create_report_incomplete_issue\":{},\"missing_data\":{},\"missing_tool\":{},\"noop\":{\"max\":1,\"report-as-issue\":\"true\"},\"report_incomplete\":{}}" + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/process_safe_outputs.cjs'); + await main(); + - name: Upload Safe Outputs Items + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: safe-outputs-items + path: | + /tmp/gh-aw/safe-output-items.jsonl + /tmp/gh-aw/temporary-id-map.json + /tmp/gh-aw/process-safe-outputs.stdout.log + /tmp/gh-aw/process-safe-outputs.stderr.log + if-no-files-found: ignore diff --git a/.github/workflows/smoke-bounded-agents-gvisor.md b/.github/workflows/smoke-bounded-agents-gvisor.md new file mode 100644 index 000000000..1c3c0c9ae --- /dev/null +++ b/.github/workflows/smoke-bounded-agents-gvisor.md @@ -0,0 +1,121 @@ +--- +name: Smoke Bounded Agents gVisor +description: End-to-end smoke test for finite-schema gVisor bounded-agent enclaves +on: + schedule: every 12h + workflow_dispatch: +permissions: + contents: read + copilot-requests: write +env: + GH_TOKEN: ${{ github.token }} + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} +engine: + id: copilot + version: 1.0.34 +network: + allowed: + - defaults + - github +tools: + github: + toolsets: [context] + allowed: [] +sandbox: + agent: + id: awf + version: v0.28.0 + args: + - --build-local +steps: + - name: Build unreleased AWF + run: | + npm ci + npm run build +pre-agent-steps: + - name: Install gVisor + run: | + set -euo pipefail + arch="$(uname -m)" + url="https://storage.googleapis.com/gvisor/releases/release/20250707.0/${arch}" + curl -fsSL "${url}/runsc" -o "$RUNNER_TEMP/runsc" + curl -fsSL "${url}/runsc.sha512" -o "$RUNNER_TEMP/runsc.sha512" + (cd "$RUNNER_TEMP" && sha512sum -c runsc.sha512) + sudo install -m 755 "$RUNNER_TEMP/runsc" /usr/local/bin/runsc + sudo runsc install + sudo systemctl restart docker + docker info --format '{{json .Runtimes}}' | grep -F '"runsc"' + - name: Replace release bootstrap with current AWF build + run: | + mkdir -p "$HOME/.local/bin" + printf '#!/bin/bash\nexec "%s" "%s/dist/cli.js" "$@"\n' \ + "$(command -v node)" "$GITHUB_WORKSPACE" > "$HOME/.local/bin/awf" + chmod +x "$HOME/.local/bin/awf" + node <<'NODE' + const fs = require("fs"); + const file = `${process.env.RUNNER_TEMP}/gh-aw/awf-config.json`; + const config = JSON.parse(fs.readFileSync(file, "utf8")); + config.apiProxy = { ...(config.apiProxy || {}), targets: { openai: {} } }; + config.boundedAgents = { + enabled: true, + privateRepos: [{ repo: "github/gh-aw", sensitivity: "internal" }], + runtime: "gvisor", + profile: "openai", + model: "gpt-4o-mini", + memoryLimit: "512m" + }; + fs.writeFileSync(file, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 }); + NODE +safe-outputs: + threat-detection: + enabled: false +timeout-minutes: 30 +strict: false +concurrency: + group: smoke-bounded-agents-gvisor + cancel-in-progress: false +post-steps: + - name: Validate gVisor bounded-agent invocation + if: always() + env: + AUDIT_LOG: /tmp/gh-aw/sandbox/firewall/audit/bounded-agent.jsonl + TELEMETRY_LOG: /tmp/gh-aw/sandbox/firewall/audit/bounded-agent-runtime.jsonl + OUTPUTS_FILE: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} + run: | + node - "$AUDIT_LOG" "$TELEMETRY_LOG" "$OUTPUTS_FILE" <<'NODE' + const fs = require("fs"); + const [auditPath, telemetryPath, outputsPath] = process.argv.slice(2); + const read = (file) => fs.readFileSync(file, "utf8").trim().split("\n") + .filter(Boolean).map((line) => JSON.parse(line)); + const invocations = read(auditPath).filter((record) => + record.kind === "invocation" && record.sensitivity === "internal"); + if (invocations.length !== 1 || invocations[0].outcome !== "ok") { + throw new Error(`expected one successful bounded-agent invocation, found ${invocations.length}`); + } + const successes = read(telemetryPath).filter((record) => + record.primaryBackend === "docker" && + record.boundedAgentBackend === "gvisor" && + record.lifecycleClass === "invocation" && + record.category === "success"); + if (successes.length !== 1) { + throw new Error(`expected one successful gVisor telemetry record, found ${successes.length}`); + } + const outputs = fs.readFileSync(outputsPath, "utf8"); + if (!outputs.includes('"noop"') || !outputs.includes("PASS")) { + throw new Error("agent did not report PASS through noop"); + } + NODE +--- + +# Smoke Test: gVisor Bounded Agent + +Use the generated `bounded-agent` skill exactly once to answer this boolean +question about `github/gh-aw`: does the repository root contain a `go.mod` +file? + +Use a boolean schema. Do not use GitHub tools, network requests, shell commands, +or the current checkout to answer. The test passes only when a fresh gVisor +enclave returns `true`. + +Call `noop` with `PASS true` only when the result is true. Otherwise call +`safeoutputs-missing_data`. Never report failure through `noop`. diff --git a/.github/workflows/smoke-bounded-agents.lock.yml b/.github/workflows/smoke-bounded-agents.lock.yml new file mode 100644 index 000000000..fc01bf40c --- /dev/null +++ b/.github/workflows/smoke-bounded-agents.lock.yml @@ -0,0 +1,1369 @@ +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"764391cb1121f45d194e98863b8369c07ed4e3cc392f5bc79d91728508d57a36","body_hash":"e86fc590352c926acbc317b5f565ea542ef92789a891987365678bf5365c6308","compiler_version":"v0.84.2","agent_id":"copilot","engine_versions":{"copilot":"1.0.34"}} +# gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN","OPENAI_API_KEY"],"actions":[{"repo":"actions/cache/restore","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/cache/save","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/checkout","sha":"3d3c42e5aac5ba805825da76410c181273ba90b1","version":"v7.0.1"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"fd783ac87efde5e0c0e05d593f1906ea25b5d92e","version":"v0.84.2"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.28.0"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.28.0"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.28.0"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.4.7","digest":"sha256:7545220a9aca134b71e51193ee0eaf4c50756ebf8fbd25a63ae7556e62815c00","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.4.7@sha256:7545220a9aca134b71e51193ee0eaf4c50756ebf8fbd25a63ae7556e62815c00"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b","pinned_image":"ghcr.io/github/gh-aw-node@sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b"},{"image":"ghcr.io/github/github-mcp-server:v1.8.0","digest":"sha256:d5a18c04b92714c309eb46a2305087e91a4dbd80420f6e462656699f95093520","pinned_image":"ghcr.io/github/github-mcp-server:v1.8.0@sha256:d5a18c04b92714c309eb46a2305087e91a4dbd80420f6e462656699f95093520"}]} +# This file was automatically generated by gh-aw (v0.84.2). DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md +# +# ___ _ _ +# / _ \ | | (_) +# | |_| | __ _ ___ _ __ | |_ _ ___ +# | _ |/ _` |/ _ \ '_ \| __| |/ __| +# | | | | (_| | __/ | | | |_| | (__ +# \_| |_/\__, |\___|_| |_|\__|_|\___| +# __/ | +# _ _ |___/ +# | | | | / _| | +# | | | | ___ _ __ _ __| |_| | _____ ____ +# | |/\| |/ _ \ '__| |/ /| _| |/ _ \ \ /\ / / ___| +# \ /\ / (_) | | | | ( | | | | (_) \ V V /\__ \ +# \/ \/ \___/|_| |_|\_\|_| |_|\___/ \_/\_/ |___/ +# +# +# To update this file, edit the corresponding .md file and run: +# gh aw compile +# Not all edits will cause changes to this file. +# +# For more information: https://github.github.com/gh-aw/introduction/overview/ +# +# End-to-end smoke test for finite-schema Docker bounded-agent enclaves +# +# Frontmatter env variables: +# - GH_TOKEN: (main workflow) +# - OPENAI_API_KEY: (main workflow) +# +# Secrets used: +# - COPILOT_GITHUB_TOKEN +# - GH_AW_GITHUB_MCP_SERVER_TOKEN +# - GH_AW_GITHUB_TOKEN +# - GITHUB_TOKEN +# - OPENAI_API_KEY +# +# Custom actions used: +# - actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 +# - actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 +# - actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 +# - actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 +# - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 +# - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 (source v9) +# - actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 +# - github/gh-aw-actions/setup@fd783ac87efde5e0c0e05d593f1906ea25b5d92e # v0.84.2 +# +# Container images used: +# - +# - +# - +# - ghcr.io/github/gh-aw-mcpg:v0.4.7@sha256:7545220a9aca134b71e51193ee0eaf4c50756ebf8fbd25a63ae7556e62815c00 +# - ghcr.io/github/gh-aw-node@sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b +# - ghcr.io/github/github-mcp-server:v1.8.0@sha256:d5a18c04b92714c309eb46a2305087e91a4dbd80420f6e462656699f95093520 + +name: "Smoke Bounded Agents" +on: + schedule: + - cron: "12 */12 * * *" # Friendly format: every 12h (scattered) + workflow_dispatch: + inputs: + aw_context: + default: "" + description: "Agent caller context (used internally by Agentic Workflows)." + required: false + type: string + +permissions: {} + +concurrency: + cancel-in-progress: false + group: smoke-bounded-agents + +run-name: "Smoke Bounded Agents" + +env: + GH_TOKEN: ${{ github.token }} + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + +jobs: + activation: + runs-on: ubuntu-slim + permissions: + actions: read + contents: read + env: + GH_AW_MAX_DAILY_AI_CREDITS: ${{ vars.GH_AW_DEFAULT_MAX_DAILY_AI_CREDITS || '5000' }} + GH_AW_RUNTIME_FEATURES: ${{ vars.GH_AW_RUNTIME_FEATURES }} + outputs: + comment_id: "" + comment_repo: "" + daily_ai_credits_exceeded: ${{ steps.daily-effective-workflow-guardrail.outputs.daily_ai_credits_exceeded == 'true' }} + daily_ai_credits_threshold: ${{ steps.daily-effective-workflow-guardrail.outputs.daily_ai_credits_threshold || '' }} + daily_ai_credits_total_effective_tokens: ${{ steps.daily-effective-workflow-guardrail.outputs.daily_ai_credits_total_effective_tokens || '' }} + engine_id: ${{ steps.generate_aw_info.outputs.engine_id }} + lockdown_check_failed: ${{ steps.generate_aw_info.outputs.lockdown_check_failed == 'true' }} + model: ${{ steps.generate_aw_info.outputs.model }} + oauth_token_check_failed: ${{ steps.check-oauth-tokens.outputs.oauth_token_check_failed == 'true' }} + setup-parent-span-id: ${{ steps.setup.outputs.parent-span-id || steps.setup.outputs.span-id }} + setup-span-id: ${{ steps.setup.outputs.span-id }} + setup-trace-id: ${{ steps.setup.outputs.trace-id }} + stale_lock_file_failed: ${{ steps.check-lock-file.outputs.stale_lock_file_failed == 'true' }} + steps: + - name: Setup Scripts + id: setup + uses: github/gh-aw-actions/setup@fd783ac87efde5e0c0e05d593f1906ea25b5d92e # v0.84.2 + with: + destination: ${{ runner.temp }}/gh-aw/actions + job-name: ${{ github.job }} + safe-output-artifact-client: ${{ env.GH_AW_MAX_DAILY_AI_CREDITS != '' }} + env: + GH_AW_SETUP_WORKFLOW_NAME: "Smoke Bounded Agents" + GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/smoke-bounded-agents.lock.yml@${{ github.ref }} + GH_AW_INFO_VERSION: "1.0.34" + GH_AW_INFO_AWF_VERSION: "v0.28.0" + GH_AW_INFO_ENGINE_ID: "copilot" + - name: Generate agentic run info + id: generate_aw_info + env: + GH_AW_INFO_ENGINE_ID: "copilot" + GH_AW_INFO_ENGINE_NAME: "GitHub Copilot CLI" + GH_AW_INFO_MODEL: ${{ vars.GH_AW_MODEL_AGENT_COPILOT || vars.GH_AW_DEFAULT_MODEL_COPILOT || 'auto' }} + GH_AW_INFO_VERSION: "1.0.34" + GH_AW_INFO_AGENT_VERSION: "1.0.34" + GH_AW_INFO_CLI_VERSION: "v0.84.2" + GH_AW_INFO_WORKFLOW_NAME: "Smoke Bounded Agents" + GH_AW_INFO_EXPERIMENTAL: "false" + GH_AW_INFO_SUPPORTS_TOOLS_ALLOWLIST: "true" + GH_AW_INFO_STAGED: "false" + GH_AW_INFO_ALLOWED_DOMAINS: '["defaults","github"]' + GH_AW_INFO_FIREWALL_ENABLED: "true" + GH_AW_INFO_AWF_VERSION: "v0.28.0" + GH_AW_INFO_AWMG_VERSION: "" + GH_AW_INFO_FIREWALL_TYPE: "squid" + GH_AW_COMPILED_STRICT: "false" + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/generate_aw_info.cjs'); + await main(core, context); + - name: Enforce strict mode policy + if: ${{ vars.GH_AW_POLICY_STRICT == 'true' }} + run: | + echo "::error::GH_AW_POLICY_STRICT=true but this workflow was not compiled in strict mode. Recompile with --strict or strict: true." + exit 1 + - name: Restore daily AIC usage cache + id: restore-daily-aic-cache + if: ${{ env.GH_AW_MAX_DAILY_AI_CREDITS != '' }} + continue-on-error: true + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + key: agentic-workflow-usage-smokeboundedagents-${{ github.run_id }} + restore-keys: agentic-workflow-usage-smokeboundedagents- + path: /tmp/gh-aw/agentic-workflow-usage-cache.jsonl + - name: Restore daily AIC usage cache (artifact fallback) + id: restore-daily-aic-cache-fallback + if: ${{ env.GH_AW_MAX_DAILY_AI_CREDITS != '' }} + continue-on-error: true + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_RESTORE_DAILY_AIC_CACHE_HIT: ${{ steps.restore-daily-aic-cache.outputs.cache-hit }} + GH_AW_RESTORE_DAILY_AIC_CACHE_MATCHED_KEY: ${{ steps.restore-daily-aic-cache.outputs.cache-matched-key }} + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/restore_aic_usage_cache_fallback.cjs'); + await main(); + - name: Check daily workflow token guardrail + id: daily-effective-workflow-guardrail + if: ${{ env.GH_AW_MAX_DAILY_AI_CREDITS != '' }} + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_WORKFLOW_NAME: "Smoke Bounded Agents" + GH_AW_WORKFLOW_ID: "smoke-bounded-agents" + GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + GH_AW_WORKFLOW_DISPATCH_AW_CONTEXT: ${{ github.event.inputs.aw_context || '' }} + GH_AW_HAS_SLASH_COMMAND: "false" + GH_AW_HAS_LABEL_COMMAND: "false" + GH_AW_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_AW_MAX_DAILY_AI_CREDITS: ${{ vars.GH_AW_DEFAULT_MAX_DAILY_AI_CREDITS || '5000' }} + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/check_daily_aic_workflow_guardrail.cjs'); + await main(); + - name: Check for OAuth tokens + id: check-oauth-tokens + run: bash "${RUNNER_TEMP}/gh-aw/actions/check_oauth_tokens.sh" + env: + COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} + GH_AW_GITHUB_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN }} + GH_AW_GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN }} + - name: Checkout .github and .agents folders + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + sparse-checkout-cone-mode: true + fetch-depth: 1 + - name: Save agent config folders for base branch restoration + env: + GH_AW_AGENT_FOLDERS: ".agents .antigravity .claude .codex .gemini .github .opencode .pi" + GH_AW_AGENT_FILES: "AGENTS.md ANTIGRAVITY.md CLAUDE.md GEMINI.md PI.md opencode.jsonc" + # poutine:ignore untrusted_checkout_exec + run: bash "${RUNNER_TEMP}/gh-aw/actions/save_base_github_folders.sh" + - name: Check workflow lock file + id: check-lock-file + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_WORKFLOW_FILE: "smoke-bounded-agents.lock.yml" + GH_AW_CONTEXT_WORKFLOW_REF: "${{ github.workflow_ref }}" + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/check_workflow_timestamp_api.cjs'); + await main(); + - name: Check compile-agentic version + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_COMPILED_VERSION: "v0.84.2" + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/check_version_updates.cjs'); + await main(); + - name: Log runtime features + if: ${{ contains(toJSON(vars), '"GH_AW_RUNTIME_FEATURES":') }} + run: bash "${RUNNER_TEMP}/gh-aw/actions/log_runtime_features_summary.sh" + - name: Create prompt with built-in context + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_SAFE_OUTPUTS: ${{ runner.temp }}/gh-aw/safeoutputs/outputs.jsonl + GH_AW_EXPR_1A3A194A: ${{ github.event.discussion.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'discussion' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }} + GH_AW_EXPR_463A214A: ${{ github.event.pull_request.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'pull_request' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }} + GH_AW_EXPR_802A9F6A: ${{ github.event.issue.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'issue' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }} + GH_AW_EXPR_FF1D34CE: ${{ github.event.comment.id || fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').comment_id }} + GH_AW_GITHUB_ACTOR: ${{ github.actor }} + GH_AW_GITHUB_REPOSITORY: ${{ github.repository }} + GH_AW_GITHUB_RUN_ID: ${{ github.run_id }} + GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }} + # poutine:ignore untrusted_checkout_exec + run: | + bash "${RUNNER_TEMP}/gh-aw/actions/create_prompt_first.sh" + { + cat << 'GH_AW_PROMPT_1498a4d66b22842d_EOF' + + GH_AW_PROMPT_1498a4d66b22842d_EOF + cat "${RUNNER_TEMP}/gh-aw/prompts/xpia.md" + cat "${RUNNER_TEMP}/gh-aw/prompts/temp_folder_prompt.md" + cat "${RUNNER_TEMP}/gh-aw/prompts/markdown.md" + cat "${RUNNER_TEMP}/gh-aw/prompts/safe_outputs_prompt.md" + cat << 'GH_AW_PROMPT_1498a4d66b22842d_EOF' + + Tools: create_issue, missing_tool, missing_data, noop + GH_AW_PROMPT_1498a4d66b22842d_EOF + cat "${RUNNER_TEMP}/gh-aw/prompts/safe_outputs_auto_create_issue.md" + cat << 'GH_AW_PROMPT_1498a4d66b22842d_EOF' + + GH_AW_PROMPT_1498a4d66b22842d_EOF + cat "${RUNNER_TEMP}/gh-aw/prompts/mcp_cli_tools_prompt.md" + cat << 'GH_AW_PROMPT_1498a4d66b22842d_EOF' + + The following GitHub context information is available for this workflow: + {{#if github.actor}} + - **actor**: __GH_AW_GITHUB_ACTOR__ + {{/if}} + {{#if github.repository}} + - **repository**: __GH_AW_GITHUB_REPOSITORY__ + {{/if}} + {{#if github.workspace}} + - **workspace**: __GH_AW_GITHUB_WORKSPACE__ + {{/if}} + {{#if github.event.issue.number || (github.aw.context.item_type == 'issue' && github.aw.context.item_number)}} + - **issue-number**: #__GH_AW_EXPR_802A9F6A__ + {{/if}} + {{#if github.event.discussion.number || (github.aw.context.item_type == 'discussion' && github.aw.context.item_number)}} + - **discussion-number**: #__GH_AW_EXPR_1A3A194A__ + {{/if}} + {{#if github.event.pull_request.number || (github.aw.context.item_type == 'pull_request' && github.aw.context.item_number)}} + - **pull-request-number**: #__GH_AW_EXPR_463A214A__ + {{/if}} + {{#if github.event.comment.id || github.aw.context.comment_id}} + - **comment-id**: __GH_AW_EXPR_FF1D34CE__ + {{/if}} + {{#if github.run_id}} + - **workflow-run-id**: __GH_AW_GITHUB_RUN_ID__ + {{/if}} + + + GH_AW_PROMPT_1498a4d66b22842d_EOF + cat "${RUNNER_TEMP}/gh-aw/prompts/github_mcp_tools_with_safeoutputs_prompt.md" + cat << 'GH_AW_PROMPT_1498a4d66b22842d_EOF' + + {{#runtime-import .github/workflows/smoke-bounded-agents.md}} + GH_AW_PROMPT_1498a4d66b22842d_EOF + } > "$GH_AW_PROMPT" + - name: Interpolate variables and render templates + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_ENGINE_ID: "copilot" + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/interpolate_prompt.cjs'); + await main(); + - name: Substitute placeholders + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_EXPR_1A3A194A: ${{ github.event.discussion.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'discussion' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }} + GH_AW_EXPR_463A214A: ${{ github.event.pull_request.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'pull_request' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }} + GH_AW_EXPR_802A9F6A: ${{ github.event.issue.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'issue' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }} + GH_AW_EXPR_FF1D34CE: ${{ github.event.comment.id || fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').comment_id }} + GH_AW_GITHUB_ACTOR: ${{ github.actor }} + GH_AW_GITHUB_REPOSITORY: ${{ github.repository }} + GH_AW_GITHUB_RUN_ID: ${{ github.run_id }} + GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }} + GH_AW_MCP_CLI_SERVERS_LIST: "- `github` — run `github --help` to see available tools\n- `safeoutputs` — run `safeoutputs --help` to see available tools" + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + + const substitutePlaceholders = require('${{ runner.temp }}/gh-aw/actions/substitute_placeholders.cjs'); + + // Call the substitution function + return await substitutePlaceholders({ + file: process.env.GH_AW_PROMPT, + substitutions: { + GH_AW_EXPR_1A3A194A: process.env.GH_AW_EXPR_1A3A194A, + GH_AW_EXPR_463A214A: process.env.GH_AW_EXPR_463A214A, + GH_AW_EXPR_802A9F6A: process.env.GH_AW_EXPR_802A9F6A, + GH_AW_EXPR_FF1D34CE: process.env.GH_AW_EXPR_FF1D34CE, + GH_AW_GITHUB_ACTOR: process.env.GH_AW_GITHUB_ACTOR, + GH_AW_GITHUB_REPOSITORY: process.env.GH_AW_GITHUB_REPOSITORY, + GH_AW_GITHUB_RUN_ID: process.env.GH_AW_GITHUB_RUN_ID, + GH_AW_GITHUB_WORKSPACE: process.env.GH_AW_GITHUB_WORKSPACE, + GH_AW_MCP_CLI_SERVERS_LIST: process.env.GH_AW_MCP_CLI_SERVERS_LIST + } + }); + - name: Validate prompt placeholders + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + # poutine:ignore untrusted_checkout_exec + run: bash "${RUNNER_TEMP}/gh-aw/actions/validate_prompt_placeholders.sh" + - name: Print prompt + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + # poutine:ignore untrusted_checkout_exec + run: bash "${RUNNER_TEMP}/gh-aw/actions/print_prompt_summary.sh" + - name: Upload activation artifact + if: success() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: activation + include-hidden-files: true + path: | + /tmp/gh-aw/aw_info.json + /tmp/gh-aw/models.json + /tmp/gh-aw/aw-prompts/prompt.txt + /tmp/gh-aw/aw-prompts/prompt-template.txt + /tmp/gh-aw/aw-prompts/prompt-import-tree.json + /tmp/gh-aw/github_rate_limits.jsonl + /tmp/gh-aw/base + /tmp/gh-aw/.github/agents + /tmp/gh-aw/.github/skills + if-no-files-found: ignore + retention-days: 1 + + agent: + needs: activation + if: needs.activation.outputs.daily_ai_credits_exceeded != 'true' + runs-on: ubuntu-latest + permissions: + contents: read + copilot-requests: write + concurrency: + group: "gh-aw-copilot-${{ github.workflow }}" + queue: max + env: + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + GH_AW_ASSETS_ALLOWED_EXTS: "" + GH_AW_ASSETS_BRANCH: "" + GH_AW_ASSETS_MAX_SIZE_KB: 0 + GH_AW_MCP_LOG_DIR: /tmp/gh-aw/mcp-logs/safeoutputs + GH_AW_RUNTIME_FEATURES: ${{ vars.GH_AW_RUNTIME_FEATURES }} + GH_AW_WORKFLOW_ID_SANITIZED: smokeboundedagents + outputs: + agentic_engine_timeout: ${{ steps.detect-agent-errors.outputs.agentic_engine_timeout || 'false' }} + ai_credits_rate_limit_error: ${{ steps.parse-mcp-gateway.outputs.ai_credits_rate_limit_error || 'false' }} + aic: ${{ steps.parse-mcp-gateway.outputs.aic }} + ambient_context: ${{ steps.parse-mcp-gateway.outputs.ambient_context }} + checkout_pr_success: ${{ steps.checkout-pr.outputs.checkout_pr_success || 'true' }} + effective_tokens: ${{ steps.parse-mcp-gateway.outputs.effective_tokens }} + has_patch: ${{ steps.collect_output.outputs.has_patch }} + http_400_response_error: ${{ steps.detect-agent-errors.outputs.http_400_response_error || 'false' }} + inference_access_error: ${{ steps.detect-agent-errors.outputs.inference_access_error || 'false' }} + invocation_cap_exceeded: ${{ steps.detect-agent-errors.outputs.invocation_cap_exceeded || 'false' }} + max_cache_misses_exceeded: ${{ steps.detect-agent-errors.outputs.max_cache_misses_exceeded || 'false' }} + mcp_policy_error: ${{ steps.detect-agent-errors.outputs.mcp_policy_error || 'false' }} + missing_model_pricing_error: ${{ steps.detect-agent-errors.outputs.missing_model_pricing_error || 'false' }} + missing_model_pricing_model_name: ${{ steps.detect-agent-errors.outputs.missing_model_pricing_model_name || '' }} + model: ${{ needs.activation.outputs.model }} + model_not_supported_error: ${{ steps.detect-agent-errors.outputs.model_not_supported_error || 'false' }} + output: ${{ steps.collect_output.outputs.output }} + output_types: ${{ steps.collect_output.outputs.output_types }} + setup-parent-span-id: ${{ steps.setup.outputs.parent-span-id || steps.setup.outputs.span-id }} + setup-span-id: ${{ steps.setup.outputs.span-id }} + setup-trace-id: ${{ steps.setup.outputs.trace-id }} + unknown_model_ai_credits: ${{ steps.parse-mcp-gateway.outputs.unknown_model_ai_credits || 'false' }} + steps: + - name: Setup Scripts + id: setup + uses: github/gh-aw-actions/setup@fd783ac87efde5e0c0e05d593f1906ea25b5d92e # v0.84.2 + with: + destination: ${{ runner.temp }}/gh-aw/actions + job-name: ${{ github.job }} + trace-id: ${{ needs.activation.outputs.setup-trace-id }} + parent-span-id: ${{ needs.activation.outputs.setup-parent-span-id || needs.activation.outputs.setup-span-id }} + env: + GH_AW_SETUP_WORKFLOW_NAME: "Smoke Bounded Agents" + GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/smoke-bounded-agents.lock.yml@${{ github.ref }} + GH_AW_INFO_VERSION: "1.0.34" + GH_AW_INFO_AWF_VERSION: "v0.28.0" + GH_AW_INFO_ENGINE_ID: "copilot" + - name: Set runtime paths + id: set-runtime-paths + run: | + { + echo "GH_AW_SAFE_OUTPUTS=${RUNNER_TEMP}/gh-aw/safeoutputs/outputs.jsonl" + echo "GH_AW_SAFE_OUTPUTS_CONFIG_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" + echo "GH_AW_SAFE_OUTPUTS_TOOLS_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/tools.json" + } >> "$GITHUB_OUTPUT" + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Create gh-aw temp directory + run: bash "${RUNNER_TEMP}/gh-aw/actions/create_gh_aw_tmp_dir.sh" + - name: Configure gh CLI for GitHub Enterprise + run: bash "${RUNNER_TEMP}/gh-aw/actions/configure_gh_for_ghe.sh" + env: + GH_TOKEN: ${{ github.token }} + - name: Download activation artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: activation + path: /tmp/gh-aw + - name: Build unreleased AWF + run: |- + npm ci + npm run build + + - name: Configure Git credentials + env: + GITHUB_REPOSITORY: ${{ github.repository }} + GITHUB_SERVER_URL: ${{ github.server_url }} + GITHUB_TOKEN: ${{ github.token }} + run: bash "${RUNNER_TEMP}/gh-aw/actions/configure_git_credentials.sh" + - name: Checkout PR branch + id: checkout-pr + if: | + github.event.pull_request || github.event.issue.pull_request || github.event_name == 'workflow_dispatch' && fromJSON(github.event.inputs.aw_context || '{}').item_type == 'pull_request' + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + with: + github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/checkout_pr_branch.cjs'); + await main(); + - name: Install GitHub Copilot CLI + run: bash "${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh" 1.0.34 + env: + GH_HOST: github.com + - name: Setup Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: '24' + package-manager-cache: false + - name: Install awf dependencies + run: npm ci + - name: Build awf + run: npm run build + - name: Install awf binary (local) + run: | + WORKSPACE_PATH="${GITHUB_WORKSPACE:-$(pwd)}" + NODE_BIN="$(command -v node)" + if [ ! -d "$WORKSPACE_PATH" ]; then + echo "Workspace path not found: $WORKSPACE_PATH" + exit 1 + fi + if [ ! -x "$NODE_BIN" ]; then + echo "Node binary not found: $NODE_BIN" + exit 1 + fi + if [ ! -d "/usr/local/bin" ]; then + echo "/usr/local/bin is missing" + exit 1 + fi + sudo tee /usr/local/bin/awf > /dev/null < \"$HOME/.local/bin/awf\"\nchmod +x \"$HOME/.local/bin/awf\"\nnode <<'NODE'\nconst fs = require(\"fs\");\nconst file = `${process.env.RUNNER_TEMP}/gh-aw/awf-config.json`;\nconst config = JSON.parse(fs.readFileSync(file, \"utf8\"));\nconfig.apiProxy = { ...(config.apiProxy || {}), targets: { openai: {} } };\nconfig.boundedAgents = {\n enabled: true,\n privateRepos: [{ repo: \"github/gh-aw\", sensitivity: \"internal\" }],\n runtime: \"docker\",\n profile: \"openai\",\n model: \"gpt-4o-mini\",\n memoryLimit: \"512m\"\n};\nfs.writeFileSync(file, `${JSON.stringify(config, null, 2)}\\n`, { mode: 0o600 });\nNODE" + + - name: Download container images + run: bash "${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh" ghcr.io/github/gh-aw-mcpg:v0.4.7@sha256:7545220a9aca134b71e51193ee0eaf4c50756ebf8fbd25a63ae7556e62815c00 ghcr.io/github/gh-aw-node@sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b ghcr.io/github/github-mcp-server:v1.8.0@sha256:d5a18c04b92714c309eb46a2305087e91a4dbd80420f6e462656699f95093520 + - name: Generate Safe Outputs Config + run: | + mkdir -p "${RUNNER_TEMP}/gh-aw/safeoutputs" + mkdir -p /tmp/gh-aw/safeoutputs + mkdir -p /tmp/gh-aw/mcp-logs/safeoutputs + cat > "${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" << 'GH_AW_SAFE_OUTPUTS_CONFIG_3112521b44aaa676_EOF' + {"create_issue":{"labels":["smoke-bounded-agents"],"max":1,"title_prefix":"[smoke-bounded-agents]"},"create_report_incomplete_issue":{},"missing_data":{},"missing_tool":{},"noop":{"max":1,"report-as-issue":"true"},"report_incomplete":{}} + GH_AW_SAFE_OUTPUTS_CONFIG_3112521b44aaa676_EOF + - name: Generate Safe Outputs Tools + env: + GH_AW_TOOLS_META_JSON: | + { + "description_suffixes": { + "create_issue": " CONSTRAINTS: Maximum 1 issue(s) can be created. Title will be prefixed with \"[smoke-bounded-agents]\". Labels [\"smoke-bounded-agents\"] will be automatically added." + }, + "repo_params": {}, + "dynamic_tools": [] + } + GH_AW_VALIDATION_JSON: | + { + "create_issue": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000, + "minLength": 20 + }, + "fields": { + "type": "array" + }, + "labels": { + "type": "array", + "itemType": "string", + "itemSanitize": true, + "itemMaxLength": 128 + }, + "parent": { + "issueOrPRNumber": true + }, + "repo": { + "type": "string", + "maxLength": 256 + }, + "temporary_id": { + "type": "string" + }, + "title": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "missing_data": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "context": { + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "data_type": { + "type": "string", + "sanitize": true, + "maxLength": 128 + }, + "reason": { + "type": "string", + "sanitize": true, + "maxLength": 256 + } + } + }, + "missing_tool": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 512 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "tool": { + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "noop": { + "defaultMax": 1, + "fields": { + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + } + } + }, + "report_incomplete": { + "defaultMax": 5, + "fields": { + "details": { + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 1024 + } + } + } + } + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/generate_safe_outputs_tools.cjs'); + await main(); + - name: Start MCP Gateway + id: start-mcp-gateway + env: + GH_AW_POLICY_ALLOW_CREATE_PULL_REQUEST: ${{ vars.GH_AW_POLICY_ALLOW_CREATE_PULL_REQUEST || 'true' }} + GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} + GH_AW_SAFE_OUTPUTS_CONFIG_PATH: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS_CONFIG_PATH }} + GH_AW_SAFE_OUTPUTS_TOOLS_PATH: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS_TOOLS_PATH }} + GH_AW_SINK_VISIBILITY: ${{ steps.determine-automatic-lockdown.outputs.visibility }} + GITHUB_MCP_GUARD_MIN_INTEGRITY: ${{ steps.determine-automatic-lockdown.outputs.min_integrity }} + GITHUB_MCP_GUARD_REPOS: ${{ steps.determine-automatic-lockdown.outputs.repos }} + GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -eo pipefail + mkdir -p "${RUNNER_TEMP}/gh-aw/mcp-config" + + # Export gateway environment variables for MCP config and gateway script + export MCP_GATEWAY_PORT="8080" + export MCP_GATEWAY_DOMAIN="awmg-mcpg" + export MCP_GATEWAY_HOST_DOMAIN="localhost" + MCP_GATEWAY_API_KEY=$(openssl rand -base64 45 | tr -d '/+=') + echo "::add-mask::${MCP_GATEWAY_API_KEY}" + export MCP_GATEWAY_API_KEY + export MCP_GATEWAY_PAYLOAD_DIR="/tmp/gh-aw/mcp-payloads" + mkdir -p "${MCP_GATEWAY_PAYLOAD_DIR}" + export MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD="524288" + export DEBUG="*" + + export GH_AW_ENGINE="copilot" + MCP_GATEWAY_UID=$(id -u 2>/dev/null || echo '0') + MCP_GATEWAY_GID=$(id -g 2>/dev/null || echo '0') + source "${RUNNER_TEMP}/gh-aw/actions/resolve_docker_socket_gid.sh" + export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network bridge -p 127.0.0.1:'"${MCP_GATEWAY_PORT}"':'"${MCP_GATEWAY_PORT}"' --name awmg-mcpg --add-host host.docker.internal:host-gateway --user '"${MCP_GATEWAY_UID}"':'"${MCP_GATEWAY_GID}"' --group-add '"${DOCKER_SOCK_GID}"' -v '"${DOCKER_SOCK_PATH}"':/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD -e DOCKER_HOST=unix:///var/run/docker.sock -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_POLICY_ALLOW_CREATE_PULL_REQUEST -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_GUARD_MIN_INTEGRITY -e GITHUB_MCP_GUARD_REPOS -e GH_AW_SINK_VISIBILITY -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -e RUNNER_TEMP -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw -v '"${RUNNER_TEMP}"'/gh-aw/safeoutputs:'"${RUNNER_TEMP}"'/gh-aw/safeoutputs:rw ghcr.io/github/gh-aw-mcpg:v0.4.7' + + mkdir -p "$HOME/.copilot" + GH_AW_NODE=$(which node 2>/dev/null || command -v node 2>/dev/null || echo node) + cat << GH_AW_MCP_CONFIG_a59a0690e5cab2b2_EOF | "$GH_AW_NODE" "${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.cjs" + { + "mcpServers": { + "github": { + "type": "stdio", + "container": "ghcr.io/github/github-mcp-server:v1.8.0", + "env": { + "GITHUB_FEATURES": "fields_param", + "GITHUB_HOST": "${GITHUB_SERVER_URL}", + "GITHUB_PERSONAL_ACCESS_TOKEN": "${GITHUB_MCP_SERVER_TOKEN}", + "GITHUB_READ_ONLY": "1", + "GITHUB_TOOLSETS": "context" + }, + "guard-policies": { + "allow-only": { + "min-integrity": "$GITHUB_MCP_GUARD_MIN_INTEGRITY", + "repos": "$GITHUB_MCP_GUARD_REPOS" + } + } + }, + "safeoutputs": { + "type": "stdio", + "container": "ghcr.io/github/gh-aw-node", + "mounts": ["\${GITHUB_WORKSPACE}:\${GITHUB_WORKSPACE}:rw", "${RUNNER_TEMP}/gh-aw/safeoutputs:${RUNNER_TEMP}/gh-aw/safeoutputs:rw", "/tmp/gh-aw:/tmp/gh-aw:rw"], + "args": ["-w", "\${GITHUB_WORKSPACE}"], + "entrypoint": "sh", + "entrypointArgs": ["-c", "sh ${RUNNER_TEMP}/gh-aw/safeoutputs/start_safe_outputs_mcp.sh"], + "env": { + "DEBUG": "*", + "DEFAULT_BRANCH": "\${DEFAULT_BRANCH}", + "GH_AW_ASSETS_ALLOWED_EXTS": "\${GH_AW_ASSETS_ALLOWED_EXTS}", + "GH_AW_ASSETS_BRANCH": "\${GH_AW_ASSETS_BRANCH}", + "GH_AW_ASSETS_MAX_SIZE_KB": "\${GH_AW_ASSETS_MAX_SIZE_KB}", + "GH_AW_MCP_LOG_DIR": "\${GH_AW_MCP_LOG_DIR}", + "GH_AW_SAFE_OUTPUTS": "\${GH_AW_SAFE_OUTPUTS}", + "GH_AW_SAFE_OUTPUTS_CONFIG_PATH": "\${GH_AW_SAFE_OUTPUTS_CONFIG_PATH}", + "GH_AW_SAFE_OUTPUTS_TOOLS_PATH": "\${GH_AW_SAFE_OUTPUTS_TOOLS_PATH}", + "GH_AW_POLICY_ALLOW_CREATE_PULL_REQUEST": "\${GH_AW_POLICY_ALLOW_CREATE_PULL_REQUEST}", + "GITHUB_REPOSITORY": "\${GITHUB_REPOSITORY}", + "GITHUB_SHA": "\${GITHUB_SHA}", + "GITHUB_TOKEN": "\${GITHUB_TOKEN}", + "GITHUB_WORKSPACE": "\${GITHUB_WORKSPACE}", + "RUNNER_TEMP": "\${RUNNER_TEMP}" + }, + "guard-policies": { + "write-sink": { + "accept": [ + "*" + ], + "sink-visibility": "${GH_AW_SINK_VISIBILITY}" + } + } + } + }, + "gateway": { + "port": $MCP_GATEWAY_PORT, + "domain": "${MCP_GATEWAY_DOMAIN}", + "apiKey": "${MCP_GATEWAY_API_KEY}", + "payloadDir": "${MCP_GATEWAY_PAYLOAD_DIR}", + "startupTimeout": 120 + } + } + GH_AW_MCP_CONFIG_a59a0690e5cab2b2_EOF + - name: Mount MCP servers as CLIs + id: mount-mcp-clis + continue-on-error: true + env: + MCP_GATEWAY_API_KEY: ${{ steps.start-mcp-gateway.outputs.gateway-api-key }} + MCP_GATEWAY_DOMAIN: ${{ steps.start-mcp-gateway.outputs.gateway-domain }} + MCP_GATEWAY_PORT: ${{ steps.start-mcp-gateway.outputs.gateway-port }} + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('${{ runner.temp }}/gh-aw/actions/mount_mcp_as_cli.cjs'); + await main(); + - name: Clean credentials + continue-on-error: true + run: bash "${RUNNER_TEMP}/gh-aw/actions/clean_git_credentials.sh" + - name: Audit pre-agent workspace + id: pre_agent_audit + continue-on-error: true + run: bash "${RUNNER_TEMP}/gh-aw/actions/audit_pre_agent_workspace.sh" + - name: Execute GitHub Copilot CLI + id: agentic_execution + # Copilot CLI tool arguments (sorted): + timeout-minutes: 20 + run: | + set -o pipefail + printf '%s' "$(date +%s%3N)" > /tmp/gh-aw/agent_cli_start_ms.txt + trap 'gh_aw_exit_code=$?; mkdir -p /tmp/gh-aw >/dev/null 2>&1 || true; printf "%s" "$gh_aw_exit_code" > /tmp/gh-aw/agent_execution_exit_code.txt || true; rm -f "$HOME/.copilot/settings.json"' EXIT + mkdir -p "$HOME/.copilot" + printf '%s' '{"builtInAgents":{"rubberDuck":false}}' > "$HOME/.copilot/settings.json" + export XDG_CONFIG_HOME="$HOME" + export GH_AW_MCP_CONFIG="$HOME/.copilot/mcp-config.json" + touch /tmp/gh-aw/agent-step-summary.md + GH_AW_NODE_BIN=$(command -v node 2>/dev/null || true) + export GH_AW_NODE_BIN + export COPILOT_API_KEY="$COPILOT_DUMMY_BYOK" + (umask 177 && touch /tmp/gh-aw/agent-stdio.log) + GH_AW_MAX_AI_CREDITS="${GH_AW_MAX_AI_CREDITS:-1000}" + printf '%s\n' "{\"\$schema\":\"https://github.com/github/gh-aw-firewall/releases/download/v0.28.0/awf-config.schema.json\",\"network\":{\"allowDomains\":[\"*.githubusercontent.com\",\"api.business.githubcopilot.com\",\"api.enterprise.githubcopilot.com\",\"api.github.com\",\"api.githubcopilot.com\",\"api.individual.githubcopilot.com\",\"api.snapcraft.io\",\"archive.ubuntu.com\",\"azure.archive.ubuntu.com\",\"codeload.github.com\",\"crl.geotrust.com\",\"crl.globalsign.com\",\"crl.identrust.com\",\"crl.sectigo.com\",\"crl.thawte.com\",\"crl.usertrust.com\",\"crl.verisign.com\",\"crl3.digicert.com\",\"crl4.digicert.com\",\"crls.ssl.com\",\"docs.github.com\",\"github-cloud.githubusercontent.com\",\"github-cloud.s3.amazonaws.com\",\"github.blog\",\"github.com\",\"github.githubassets.com\",\"host.docker.internal\",\"json-schema.org\",\"json.schemastore.org\",\"keyserver.ubuntu.com\",\"lfs.github.com\",\"objects.githubusercontent.com\",\"ocsp.digicert.com\",\"ocsp.geotrust.com\",\"ocsp.globalsign.com\",\"ocsp.identrust.com\",\"ocsp.sectigo.com\",\"ocsp.ssl.com\",\"ocsp.thawte.com\",\"ocsp.usertrust.com\",\"ocsp.verisign.com\",\"packagecloud.io\",\"packages.cloud.google.com\",\"packages.microsoft.com\",\"patch-diff.githubusercontent.com\",\"patchdiff.githubusercontent.com\",\"ppa.launchpad.net\",\"raw.githubusercontent.com\",\"registry.npmjs.org\",\"s.symcb.com\",\"s.symcd.com\",\"security.ubuntu.com\",\"telemetry.enterprise.githubcopilot.com\",\"ts-crl.ws.symantec.com\",\"ts-ocsp.ws.symantec.com\",\"www.googleapis.com\"],\"isolation\":true,\"topologyAttach\":[\"awmg-mcpg\"]},\"apiProxy\":{\"enabled\":true,\"enableTokenSteering\":true,\"maxRuns\":500,\"maxAiCredits\":${GH_AW_MAX_AI_CREDITS},\"maxCacheMisses\":5,\"models\":{\"agent\":[\"sonnet-6x\",\"gpt-5.4\",\"gpt-5.5\",\"gpt-5.6\",\"gpt-5.3\",\"gemini-pro\",\"any\"],\"antigravity\":[\"copilot/antigravity*\",\"google/antigravity*\",\"gemini/antigravity*\"],\"any\":[\"copilot/*\",\"anthropic/*\",\"openai/*\",\"google/*\",\"gemini/*\"],\"auto\":[\"copilot/auto\",\"large\"],\"claude\":[\"agent\"],\"codex\":[\"agent\"],\"coding\":[\"copilot/gpt-5*codex*\",\"openai/gpt-5*codex*\",\"gpt-5-codex\",\"kimi\"],\"computer-use\":[\"copilot/*computer-use*\",\"google/*computer-use*\",\"gemini/*computer-use*\",\"openai/*computer-use*\"],\"copilot\":[\"agent\"],\"deep-research\":[\"copilot/deep-research*\",\"copilot/o3-deep-research*\",\"copilot/o4-mini-deep-research*\",\"google/deep-research*\",\"gemini/deep-research*\",\"openai/o3-deep-research*\",\"openai/o4-mini-deep-research*\"],\"fable\":[\"copilot/*fable*\",\"anthropic/*fable*\"],\"gemini\":[\"agent\"],\"gemini-3-flash\":[\"copilot/gemini-3*flash*\",\"google/gemini-3*flash*\",\"gemini/gemini-3*flash*\"],\"gemini-3-pro\":[\"copilot/gemini-3*pro*\",\"google/gemini-3*pro*\",\"google/nano-banana*\",\"gemini/gemini-3*pro*\"],\"gemini-3.1-flash\":[\"copilot/gemini-3.1*flash*\",\"google/gemini-3.1*flash*\",\"gemini/gemini-3.1*flash*\"],\"gemini-3.1-pro\":[\"copilot/gemini-3.1*pro*\",\"google/gemini-3.1*pro*\",\"gemini/gemini-3.1*pro*\"],\"gemini-3.5-flash\":[\"copilot/gemini-3.5*flash*\",\"google/gemini-3.5*flash*\",\"gemini/gemini-3.5*flash*\"],\"gemini-3.6-flash\":[\"copilot/gemini-3.6*flash*\",\"google/gemini-3.6*flash*\",\"gemini/gemini-3.6*flash*\"],\"gemini-flash\":[\"copilot/gemini-*flash*\",\"google/gemini-*flash*\",\"gemini/gemini-*flash*\"],\"gemini-flash-lite\":[\"copilot/gemini-*flash*lite*\",\"google/gemini-*flash*lite*\",\"gemini/gemini-*flash*lite*\"],\"gemini-omni\":[\"copilot/gemini-omni*\",\"google/gemini-omni*\",\"gemini/gemini-omni*\"],\"gemini-pro\":[\"copilot/gemini-*pro*\",\"google/gemini-*pro*\",\"gemini/gemini-*pro*\"],\"gemma\":[\"copilot/gemma*\",\"google/gemma*\",\"gemini/gemma*\"],\"gpt-5\":[\"copilot/gpt-5*\",\"openai/gpt-5*\"],\"gpt-5-codex\":[\"copilot/gpt-5*codex*\",\"openai/gpt-5*codex*\"],\"gpt-5-mini\":[\"copilot/gpt-5*mini*\",\"openai/gpt-5*mini*\"],\"gpt-5-nano\":[\"copilot/gpt-5*nano*\",\"openai/gpt-5*nano*\"],\"gpt-5-pro\":[\"copilot/gpt-5*pro*\",\"openai/gpt-5*pro*\"],\"gpt-5.1\":[\"copilot/gpt-5.1*\",\"openai/gpt-5.1*\"],\"gpt-5.2\":[\"copilot/gpt-5.2*\",\"openai/gpt-5.2*\"],\"gpt-5.3\":[\"copilot/gpt-5.3*\",\"openai/gpt-5.3*\"],\"gpt-5.4\":[\"copilot/gpt-5.4*\",\"openai/gpt-5.4*\"],\"gpt-5.5\":[\"copilot/gpt-5.5*\",\"openai/gpt-5.5*\"],\"gpt-5.6\":[\"copilot/gpt-5.6*\",\"openai/gpt-5.6*\"],\"grok\":[\"copilot/*grok*\",\"openai/*grok*\"],\"haiku\":[\"copilot/*haiku*\",\"anthropic/*haiku*\"],\"image-generation\":[\"copilot/gpt-image*\",\"openai/gpt-image*\",\"openai/chatgpt-image*\",\"copilot/gemini-*image*\",\"google/gemini-*image*\",\"gemini/gemini-*image*\",\"google/imagen*\"],\"kimi\":[\"copilot/kimi*\",\"openai/kimi*\"],\"kiwi\":[\"copilot/kiwi*\",\"openai/kiwi*\"],\"large\":[\"sonnet\",\"gpt-5-pro\",\"gpt-5\",\"gemini-pro\"],\"lyria\":[\"google/lyria*\",\"gemini/lyria*\",\"copilot/lyria*\"],\"mai-code\":[\"copilot/MAI-Code*\",\"copilot/mai-code*\",\"openai/MAI-Code*\"],\"mai-code-1-flash-picker\":[\"copilot/MAI-Code-1-Flash-picker*\",\"copilot/mai-code-1-flash-picker*\",\"openai/MAI-Code-1-Flash-picker*\"],\"mini\":[\"haiku\",\"gpt-5-mini\",\"gpt-5-nano\",\"gemini-flash-lite\"],\"nano-banana\":[\"copilot/nano-banana*\",\"google/nano-banana*\",\"gemini/nano-banana*\"],\"opus\":[\"copilot/*opus*\",\"anthropic/*opus*\"],\"opusplan\":[\"opus?effort=high\"],\"raptor-mini\":[\"copilot/raptor*\",\"openai/raptor*\"],\"reasoning\":[\"copilot/o1*\",\"copilot/o3*\",\"copilot/o4*\",\"openai/o1*\",\"openai/o3*\",\"openai/o4*\"],\"robotics\":[\"copilot/*robotics*\",\"google/*robotics*\",\"gemini/*robotics*\"],\"small\":[\"mini\"],\"small-agent\":[\"haiku\",\"gpt-5-mini\",\"gemini-flash\"],\"sonnet\":[\"copilot/*sonnet*\",\"anthropic/*sonnet*\"],\"sonnet-6x\":[\"copilot/*sonnet-4.5*\",\"copilot/*sonnet-4.6*\",\"copilot/*sonnet-5*\",\"copilot/*sonnet-4-5-*\",\"anthropic/*sonnet-4-5-*\",\"copilot/*sonnet-4-6*\",\"anthropic/*sonnet-4-6*\",\"anthropic/*sonnet-5*\"],\"summarization\":[\"haiku\",\"gpt-5-mini\",\"gemini-flash-lite\",\"mini\"],\"veo\":[\"google/veo*\",\"gemini/veo*\"],\"vision\":[\"copilot/gemini-*image*\",\"google/gemini-*image*\",\"gemini/gemini-*image*\",\"copilot/gemini-*flash*\",\"google/gemini-*flash*\",\"gemini/gemini-*flash*\"]}},\"container\":{\"imageTag\":\"0.28.0\"},\"logging\":{\"proxyLogsDir\":\"/tmp/gh-aw/sandbox/firewall/logs\",\"auditDir\":\"/tmp/gh-aw/sandbox/firewall/audit\"}}" > "${RUNNER_TEMP}/gh-aw/awf-config.json" + cp "${RUNNER_TEMP}/gh-aw/awf-config.json" /tmp/gh-aw/awf-config.json + export GH_AW_MODELS_JSON_PATH="/tmp/gh-aw/models.json" + GH_AW_DOCKER_HOST="" + if [[ "${DOCKER_HOST:-}" =~ ^tcp:// ]]; then + GH_AW_DOCKER_HOST="${DOCKER_HOST}" + fi + if [[ "${DOCKER_HOST:-}" =~ ^tcp:// ]]; then + GH_AW_CHROOT_BINARIES_SOURCE_PATH="${RUNNER_TEMP}/gh-aw" GH_AW_CHROOT_IDENTITY_HOME="${RUNNER_TEMP}/gh-aw/home" node "${RUNNER_TEMP}/gh-aw/actions/patch_awf_chroot_config.cjs" + fi + GH_AW_TOOL_CACHE_MOUNT="" + GH_AW_TOOL_CACHE="${RUNNER_TOOL_CACHE:?RUNNER_TOOL_CACHE must be set}" + if [ -d "$GH_AW_TOOL_CACHE" ]; then + if [[ "$GH_AW_TOOL_CACHE" != /opt/* ]]; then + GH_AW_TOOL_CACHE_MOUNT="$GH_AW_TOOL_CACHE:$GH_AW_TOOL_CACHE:ro" + fi + fi + # shellcheck disable=SC1003,SC2016,SC2086 + awf --config "${RUNNER_TEMP}/gh-aw/awf-config.json" --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" ${GH_AW_TOOL_CACHE_MOUNT:+--mount "$GH_AW_TOOL_CACHE_MOUNT"} ${GH_AW_DOCKER_HOST:+--docker-host "$GH_AW_DOCKER_HOST"} --env-all --exclude-env COPILOT_GITHUB_TOKEN --exclude-env GITHUB_MCP_SERVER_TOKEN --exclude-env MCP_GATEWAY_API_KEY --log-level info --build-local \ + -- /bin/bash -c 'set +o histexpand; export PATH="${RUNNER_TEMP}/gh-aw/mcp-cli/bin:$PATH" && : "${RUNNER_TOOL_CACHE:?RUNNER_TOOL_CACHE must be set}"; GH_AW_TOOL_CACHE="$RUNNER_TOOL_CACHE"; export PATH="$(find "$GH_AW_TOOL_CACHE" -maxdepth 5 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true; [ -n "$ERLANG_HOME" ] && export PATH="$ERLANG_HOME/bin:$PATH" || true && GH_AW_NODE_EXEC="${GH_AW_NODE_BIN:-}"; if [ -z "$GH_AW_NODE_EXEC" ] || [ ! -x "$GH_AW_NODE_EXEC" ]; then GH_AW_NODE_EXEC="$(command -v node 2>/dev/null || true)"; fi; if [ -z "$GH_AW_NODE_EXEC" ]; then echo "node runtime missing on this runner — check runtimes.node in workflow YAML" >&2; exit 127; fi; GH_AW_NPM_GLOBAL_ROOT="$(npm root -g 2>/dev/null || true)"; if [ -n "$GH_AW_NPM_GLOBAL_ROOT" ]; then export NODE_PATH="${GH_AW_NPM_GLOBAL_ROOT}${NODE_PATH:+:${NODE_PATH}}"; fi; "$GH_AW_NODE_EXEC" ${RUNNER_TEMP}/gh-aw/actions/copilot_harness.cjs /usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --disable-builtin-mcps --no-ask-user --allow-all-tools --allow-all-paths --add-dir "${GITHUB_WORKSPACE}" --prompt-file /tmp/gh-aw/aw-prompts/prompt.txt' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log + env: + AWF_REFLECT_ENABLED: 1 + COPILOT_AGENT_RUNNER_TYPE: STANDALONE + COPILOT_DUMMY_BYOK: dummy-byok-key-for-offline-mode + COPILOT_GITHUB_TOKEN: ${{ github.token }} + COPILOT_MODEL: ${{ vars.GH_AW_MODEL_AGENT_COPILOT || vars.GH_AW_DEFAULT_MODEL_COPILOT || 'auto' }} + GH_AW_LLM_PROVIDER: github + GH_AW_MAX_AI_CREDITS: ${{ vars.GH_AW_DEFAULT_MAX_AI_CREDITS || '1000' }} + GH_AW_MAX_TURNS: ${{ vars.GH_AW_DEFAULT_MAX_TURNS || '' }} + GH_AW_PHASE: agent + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} + GH_AW_TIMEOUT_MINUTES: 20 + GH_AW_VERSION: v0.84.2 + GITHUB_API_URL: ${{ github.api_url }} + GITHUB_AW: true + GITHUB_COPILOT_INTEGRATION_ID: agentic-workflows + GITHUB_HEAD_REF: ${{ github.head_ref }} + GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + GITHUB_REF_NAME: ${{ github.ref_name }} + GITHUB_SERVER_URL: ${{ github.server_url }} + GITHUB_STEP_SUMMARY: /tmp/gh-aw/agent-step-summary.md + GITHUB_WORKSPACE: ${{ github.workspace }} + GIT_AUTHOR_EMAIL: github-actions[bot]@users.noreply.github.com + GIT_AUTHOR_NAME: github-actions[bot] + GIT_COMMITTER_EMAIL: github-actions[bot]@users.noreply.github.com + GIT_COMMITTER_NAME: github-actions[bot] + RUNNER_TEMP: ${{ runner.temp }} + S2STOKENS: true + TRACEPARENT: ${{ env.GITHUB_AW_OTEL_TRACE_ID != '' && env.GITHUB_AW_OTEL_PARENT_SPAN_ID != '' && format('00-{0}-{1}-01', env.GITHUB_AW_OTEL_TRACE_ID, env.GITHUB_AW_OTEL_PARENT_SPAN_ID) || '' }} + - name: Detect agent errors + if: always() + id: detect-agent-errors + continue-on-error: true + run: node "${RUNNER_TEMP}/gh-aw/actions/detect_agent_errors.cjs" + - name: Configure Git credentials + env: + GITHUB_REPOSITORY: ${{ github.repository }} + GITHUB_SERVER_URL: ${{ github.server_url }} + GITHUB_TOKEN: ${{ github.token }} + run: bash "${RUNNER_TEMP}/gh-aw/actions/configure_git_credentials.sh" + - name: Copy Copilot session state files to logs + if: always() + continue-on-error: true + run: | + SESSION_STATE_SRC="/tmp/gh-aw/sandbox/agent/session-state" + LOGS_DIR="/tmp/gh-aw/sandbox/agent/logs" + if [ -d "$SESSION_STATE_SRC" ] && [ -n "$(ls -A "$SESSION_STATE_SRC" 2>/dev/null)" ]; then + mkdir -p "$LOGS_DIR/session-state" + cp -rp "$SESSION_STATE_SRC/." "$LOGS_DIR/session-state/" + echo "Copied session state to $LOGS_DIR/session-state" + else + echo "No session state found at $SESSION_STATE_SRC" + fi + - name: Stop MCP Gateway + if: always() + continue-on-error: true + env: + MCP_GATEWAY_PORT: ${{ steps.start-mcp-gateway.outputs.gateway-port }} + MCP_GATEWAY_API_KEY: ${{ steps.start-mcp-gateway.outputs.gateway-api-key }} + GATEWAY_PID: ${{ steps.start-mcp-gateway.outputs.gateway-pid }} + run: | + bash "${RUNNER_TEMP}/gh-aw/actions/stop_mcp_gateway.sh" "$GATEWAY_PID" + - name: Redact secrets in logs + if: always() + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/redact_secrets.cjs'); + await main(); + env: + GH_AW_SECRET_NAMES: 'GH_AW_GITHUB_MCP_SERVER_TOKEN,GH_AW_GITHUB_TOKEN,GITHUB_TOKEN' + SECRET_GH_AW_GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN }} + SECRET_GH_AW_GITHUB_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN }} + SECRET_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Append agent step summary + if: always() + run: bash "${RUNNER_TEMP}/gh-aw/actions/append_agent_step_summary.sh" + - name: Copy Safe Outputs + if: always() + env: + GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} + run: | + mkdir -p /tmp/gh-aw + cp "$GH_AW_SAFE_OUTPUTS" /tmp/gh-aw/safeoutputs.jsonl 2>/dev/null || true + - name: Ingest agent output + id: collect_output + if: always() + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} + GH_AW_ALLOWED_DOMAINS: "*.githubusercontent.com,api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,codeload.github.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,docs.github.com,github-cloud.githubusercontent.com,github-cloud.s3.amazonaws.com,github.blog,github.com,github.githubassets.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,lfs.github.com,objects.githubusercontent.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,patch-diff.githubusercontent.com,patchdiff.githubusercontent.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com" + GITHUB_SERVER_URL: ${{ github.server_url }} + GITHUB_API_URL: ${{ github.api_url }} + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/collect_ndjson_output.cjs'); + await main(); + - name: Parse agent logs for step summary + if: always() + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_AGENT_OUTPUT: /tmp/gh-aw/sandbox/agent/logs/ + GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/parse_copilot_log.cjs'); + await main(); + - name: Parse MCP Gateway logs for step summary + if: always() + id: parse-mcp-gateway + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/parse_mcp_gateway_log.cjs'); + await main(); + - name: Print firewall logs + if: always() + continue-on-error: true + env: + AWF_LOGS_DIR: /tmp/gh-aw/sandbox/firewall/logs + run: bash "${RUNNER_TEMP}/gh-aw/actions/print_firewall_logs.sh" --rootless + - name: Parse token usage for step summary + if: always() + continue-on-error: true + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/parse_token_usage.cjs'); + await main(); + - name: Print AWF reflect summary + if: always() + continue-on-error: true + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/awf_reflect_summary.cjs'); + await main(); + - name: Write agent output placeholder if missing + if: always() + run: | + if [ ! -f /tmp/gh-aw/agent_output.json ]; then + echo '{"items":[]}' > /tmp/gh-aw/agent_output.json + fi + - env: + AUDIT_LOG: /tmp/gh-aw/sandbox/firewall/audit/bounded-agent.jsonl + OUTPUTS_FILE: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} + if: always() + name: Validate bounded-agent invocation + run: "node - \"$AUDIT_LOG\" \"$OUTPUTS_FILE\" <<'NODE'\nconst fs = require(\"fs\");\nconst [auditPath, outputsPath] = process.argv.slice(2);\nconst records = fs.readFileSync(auditPath, \"utf8\").trim().split(\"\\n\")\n .filter(Boolean).map((line) => JSON.parse(line));\nconst invocations = records.filter((record) =>\n record.kind === \"invocation\" && record.sensitivity === \"internal\");\nif (invocations.length !== 1 || invocations[0].outcome !== \"ok\") {\n throw new Error(`expected one successful bounded-agent invocation, found ${invocations.length}`);\n}\nconst serialized = JSON.stringify(records);\nif (serialized.includes(\"github/gh-aw\") || serialized.includes(\"SECURITY.md\")) {\n throw new Error(\"protected audit disclosed repository-derived content\");\n}\nconst outputs = fs.readFileSync(outputsPath, \"utf8\");\nif (!outputs.includes('\"noop\"') || !outputs.includes(\"PASS\")) {\n throw new Error(\"agent did not report PASS through noop\");\n}\nNODE" + + - name: Upload agent artifacts + if: always() + continue-on-error: true + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: agent + path: | + /tmp/gh-aw/aw-prompts/prompt.txt + /tmp/gh-aw/sandbox/agent/logs/ + /tmp/gh-aw/redacted-urls.log + /tmp/gh-aw/mcp-logs/ + /tmp/gh-aw/agent_usage.json + /tmp/gh-aw/agent-stdio.log + /tmp/gh-aw/pre-agent-audit.txt + /tmp/gh-aw/agent/ + /tmp/gh-aw/github_rate_limits.jsonl + /tmp/gh-aw/safeoutputs.jsonl + /tmp/gh-aw/agent_output.json + /tmp/gh-aw/awf-config.json + /tmp/gh-aw/sandbox/firewall/logs/ + /tmp/gh-aw/sandbox/firewall/audit/ + /tmp/gh-aw/sandbox/firewall/awf-reflect.json + if-no-files-found: ignore + + conclusion: + needs: + - activation + - agent + - safe_outputs + if: > + always() && (needs.agent.result != 'skipped' || needs.activation.outputs.lockdown_check_failed == 'true' || + needs.activation.outputs.oauth_token_check_failed == 'true' || needs.activation.outputs.stale_lock_file_failed == 'true' || + needs.activation.outputs.daily_ai_credits_exceeded == 'true') + runs-on: ubuntu-slim + permissions: + issues: write + concurrency: + group: "gh-aw-conclusion-smoke-bounded-agents" + cancel-in-progress: false + queue: max + env: + GH_AW_RUNTIME_FEATURES: ${{ vars.GH_AW_RUNTIME_FEATURES }} + outputs: + incomplete_count: ${{ steps.report_incomplete.outputs.incomplete_count }} + noop_message: ${{ steps.noop.outputs.noop_message }} + tools_reported: ${{ steps.missing_tool.outputs.tools_reported }} + total_count: ${{ steps.missing_tool.outputs.total_count }} + steps: + - name: Setup Scripts + id: setup + uses: github/gh-aw-actions/setup@fd783ac87efde5e0c0e05d593f1906ea25b5d92e # v0.84.2 + with: + destination: ${{ runner.temp }}/gh-aw/actions + job-name: ${{ github.job }} + trace-id: ${{ needs.activation.outputs.setup-trace-id }} + parent-span-id: ${{ needs.activation.outputs.setup-parent-span-id || needs.activation.outputs.setup-span-id }} + env: + GH_AW_SETUP_WORKFLOW_NAME: "Smoke Bounded Agents" + GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/smoke-bounded-agents.lock.yml@${{ github.ref }} + GH_AW_INFO_VERSION: "1.0.34" + GH_AW_INFO_AWF_VERSION: "v0.28.0" + GH_AW_INFO_ENGINE_ID: "copilot" + - name: Download agent output artifact + id: download-agent-output + continue-on-error: true + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: agent + path: /tmp/gh-aw/ + - name: Setup agent output environment variable + id: setup-agent-output-env + if: steps.download-agent-output.outcome == 'success' + run: | + mkdir -p /tmp/gh-aw/ + find "/tmp/gh-aw/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT" + - name: Download safe outputs items manifest + id: download-safe-outputs-manifest + if: always() + continue-on-error: true + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: safe-outputs-items + path: /tmp/gh-aw/ + - name: Collect usage artifact files + if: always() + continue-on-error: true + run: | + mkdir -p /tmp/gh-aw/usage/agent /tmp/gh-aw/usage/detection + echo "Usage artifact source file status:" + for file in /tmp/gh-aw/aw_info.json /tmp/gh-aw/aw-info.jsonl /tmp/gh-aw/agent_usage.json /tmp/gh-aw/agent_usage.jsonl /tmp/gh-aw/detection_usage.jsonl /tmp/gh-aw/evals/evals.jsonl /tmp/gh-aw/github_rate_limits.jsonl /tmp/gh-aw/sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/threat-detection/sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/threat-detection/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/threat-detection/sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl; do + [ -f "$file" ] && echo "FOUND: $file" || echo "MISSING: $file" + done + [ -f /tmp/gh-aw/aw_info.json ] && cp /tmp/gh-aw/aw_info.json /tmp/gh-aw/usage/aw_info.json || true + [ -f /tmp/gh-aw/aw-info.jsonl ] && cp /tmp/gh-aw/aw-info.jsonl /tmp/gh-aw/usage/aw-info.jsonl || true + [ -f /tmp/gh-aw/agent_usage.json ] && cp /tmp/gh-aw/agent_usage.json /tmp/gh-aw/usage/agent_usage.json || true + [ -f /tmp/gh-aw/agent_usage.jsonl ] && cp /tmp/gh-aw/agent_usage.jsonl /tmp/gh-aw/usage/agent_usage.jsonl || true + [ -f /tmp/gh-aw/detection_usage.jsonl ] && cp /tmp/gh-aw/detection_usage.jsonl /tmp/gh-aw/usage/detection_usage.jsonl || true + [ -f /tmp/gh-aw/evals/evals.jsonl ] && cp /tmp/gh-aw/evals/evals.jsonl /tmp/gh-aw/usage/evals.jsonl || true + [ -f /tmp/gh-aw/github_rate_limits.jsonl ] && cp /tmp/gh-aw/github_rate_limits.jsonl /tmp/gh-aw/usage/github_rate_limits.jsonl || true + [ -s /tmp/gh-aw/sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl ] && cp /tmp/gh-aw/sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/usage/agent/token_usage.jsonl || true + [ -s /tmp/gh-aw/sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl ] && cp /tmp/gh-aw/sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/usage/agent/token_usage.jsonl || true + [ -s /tmp/gh-aw/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl ] && cp /tmp/gh-aw/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/usage/agent/token_usage.jsonl || true + [ -s /tmp/gh-aw/threat-detection/sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl ] && cp /tmp/gh-aw/threat-detection/sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/usage/detection/token_usage.jsonl || true + [ -s /tmp/gh-aw/threat-detection/sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl ] && cp /tmp/gh-aw/threat-detection/sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/usage/detection/token_usage.jsonl || true + [ -s /tmp/gh-aw/threat-detection/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl ] && cp /tmp/gh-aw/threat-detection/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/usage/detection/token_usage.jsonl || true + [ -f /tmp/gh-aw/usage/agent/token_usage.jsonl ] || : > /tmp/gh-aw/usage/agent/token_usage.jsonl + [ -f /tmp/gh-aw/usage/detection/token_usage.jsonl ] || : > /tmp/gh-aw/usage/detection/token_usage.jsonl + mkdir -p /tmp/gh-aw/usage/activity + node "${RUNNER_TEMP}/gh-aw/actions/generate_usage_activity_summary.cjs" + find /tmp/gh-aw/usage -type f -print | sort + - name: Upload usage artifact + if: always() + continue-on-error: true + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: usage + path: | + /tmp/gh-aw/usage/aw_info.json + /tmp/gh-aw/usage/aw-info.jsonl + /tmp/gh-aw/usage/agent_usage.json + /tmp/gh-aw/usage/agent_usage.jsonl + /tmp/gh-aw/usage/detection_usage.jsonl + /tmp/gh-aw/usage/evals.jsonl + /tmp/gh-aw/usage/github_rate_limits.jsonl + /tmp/gh-aw/usage/agent/token_usage.jsonl + /tmp/gh-aw/usage/detection/token_usage.jsonl + /tmp/gh-aw/usage/activity/summary.json + if-no-files-found: ignore + - name: Restore daily AIC usage cache + id: restore-daily-aic-cache-conclusion + if: always() + continue-on-error: true + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + key: agentic-workflow-usage-smokeboundedagents-${{ github.run_id }} + restore-keys: agentic-workflow-usage-smokeboundedagents- + path: /tmp/gh-aw/agentic-workflow-usage-cache.jsonl + - name: Write daily AIC usage cache entry + id: write-daily-aic-cache + if: always() + continue-on-error: true + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + github-token: ${{ github.token }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context); + const { main } = require('${{ runner.temp }}/gh-aw/actions/write_daily_aic_usage_cache.cjs'); + await main(); + - name: Save daily AIC usage cache + id: save-daily-aic-cache + if: always() + continue-on-error: true + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + key: agentic-workflow-usage-smokeboundedagents-${{ github.run_id }} + path: /tmp/gh-aw/agentic-workflow-usage-cache.jsonl + - name: Upload daily AIC usage cache artifact + id: upload-daily-aic-cache + if: always() + continue-on-error: true + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: aic-usage-cache + path: /tmp/gh-aw/agentic-workflow-usage-cache.jsonl + if-no-files-found: ignore + retention-days: 7 + - name: Process no-op messages + id: noop + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }} + GH_AW_NOOP_MAX: "1" + GH_AW_WORKFLOW_NAME: "Smoke Bounded Agents" + GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/${{ github.repository }}/blob/${{ github.ref_name }}/.github/workflows/smoke-bounded-agents.md" + GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }} + GH_AW_NOOP_REPORT_AS_ISSUE: "true" + GH_AW_AIC: ${{ needs.agent.outputs.aic }} + GH_AW_AMBIENT_CONTEXT: ${{ needs.agent.outputs.ambient_context }} + GH_AW_WORKFLOW_ID: "smoke-bounded-agents" + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/handle_noop_message.cjs'); + await main(); + - name: Record missing tool + id: missing_tool + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }} + GH_AW_MISSING_TOOL_CREATE_ISSUE: "true" + GH_AW_WORKFLOW_NAME: "Smoke Bounded Agents" + GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/${{ github.repository }}/blob/${{ github.ref_name }}/.github/workflows/smoke-bounded-agents.md" + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/missing_tool.cjs'); + await main(); + - name: Record incomplete + id: report_incomplete + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }} + GH_AW_REPORT_INCOMPLETE_CREATE_ISSUE: "true" + GH_AW_WORKFLOW_NAME: "Smoke Bounded Agents" + GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/${{ github.repository }}/blob/${{ github.ref_name }}/.github/workflows/smoke-bounded-agents.md" + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/report_incomplete_handler.cjs'); + await main(); + - name: Handle agent failure + id: handle_agent_failure + if: always() + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }} + GH_AW_WORKFLOW_NAME: "Smoke Bounded Agents" + GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/${{ github.repository }}/blob/${{ github.ref_name }}/.github/workflows/smoke-bounded-agents.md" + GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }} + GH_AW_WORKFLOW_ID: "smoke-bounded-agents" + GH_AW_ACTION_FAILURE_ISSUE_EXPIRES_HOURS: "168" + GH_AW_ENGINE_ID: "copilot" + GH_AW_CHECKOUT_PR_SUCCESS: ${{ needs.agent.outputs.checkout_pr_success }} + GH_AW_EFFECTIVE_TOKENS: ${{ needs.agent.outputs.effective_tokens || '' }} + GH_AW_AI_CREDITS_RATE_LIMIT_ERROR: ${{ needs.agent.outputs.ai_credits_rate_limit_error || 'false' }} + GH_AW_UNKNOWN_MODEL_AI_CREDITS: ${{ needs.agent.outputs.unknown_model_ai_credits || 'false' }} + GH_AW_AIC: ${{ needs.agent.outputs.aic }} + GH_AW_MAX_AI_CREDITS: ${{ vars.GH_AW_DEFAULT_MAX_AI_CREDITS || '1000' }} + GH_AW_INFERENCE_ACCESS_ERROR: ${{ needs.agent.outputs.inference_access_error }} + GH_AW_MCP_POLICY_ERROR: ${{ needs.agent.outputs.mcp_policy_error }} + GH_AW_AGENTIC_ENGINE_TIMEOUT: ${{ needs.agent.outputs.agentic_engine_timeout }} + GH_AW_MODEL_NOT_SUPPORTED_ERROR: ${{ needs.agent.outputs.model_not_supported_error }} + GH_AW_HTTP_400_RESPONSE_ERROR: ${{ needs.agent.outputs.http_400_response_error }} + GH_AW_MAX_CACHE_MISSES_EXCEEDED: ${{ needs.agent.outputs.max_cache_misses_exceeded }} + GH_AW_MISSING_MODEL_PRICING_ERROR: ${{ needs.agent.outputs.missing_model_pricing_error }} + GH_AW_MISSING_MODEL_PRICING_MODEL_NAME: ${{ needs.agent.outputs.missing_model_pricing_model_name }} + GH_AW_ENGINE_API_HOSTS: "api.enterprise.githubcopilot.com,api.githubcopilot.com,api.business.githubcopilot.com,api.individual.githubcopilot.com" + GH_AW_LOCKDOWN_CHECK_FAILED: ${{ needs.activation.outputs.lockdown_check_failed }} + GH_AW_OAUTH_TOKEN_CHECK_FAILED: ${{ needs.activation.outputs.oauth_token_check_failed }} + GH_AW_STALE_LOCK_FILE_FAILED: ${{ needs.activation.outputs.stale_lock_file_failed }} + GH_AW_DAILY_AI_CREDITS_EXCEEDED: ${{ needs.activation.outputs.daily_ai_credits_exceeded }} + GH_AW_DAILY_AI_CREDITS_TOTAL_EFFECTIVE_TOKENS: ${{ needs.activation.outputs.daily_ai_credits_total_effective_tokens }} + GH_AW_DAILY_AI_CREDITS_THRESHOLD: ${{ needs.activation.outputs.daily_ai_credits_threshold }} + GH_AW_GROUP_REPORTS: "false" + GH_AW_FAILURE_REPORT_AS_ISSUE: "true" + GH_AW_MISSING_TOOL_REPORT_AS_FAILURE: "true" + GH_AW_MISSING_DATA_REPORT_AS_FAILURE: "true" + GH_AW_TIMEOUT_MINUTES: "20" + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/handle_agent_failure.cjs'); + await main(); + + safe_outputs: + needs: + - activation + - agent + if: (!cancelled()) && needs.agent.result != 'skipped' + runs-on: ubuntu-slim + permissions: + issues: write + timeout-minutes: 45 + env: + GH_AW_AGENT_AIC: ${{ needs.agent.outputs.aic }} + GH_AW_AIC: ${{ needs.agent.outputs.aic }} + GH_AW_AMBIENT_CONTEXT: ${{ needs.agent.outputs.ambient_context }} + GH_AW_CALLER_WORKFLOW_ID: "${{ github.repository }}/smoke-bounded-agents" + GH_AW_EFFECTIVE_TOKENS: ${{ needs.agent.outputs.effective_tokens }} + GH_AW_ENGINE_ID: "copilot" + GH_AW_ENGINE_MODEL: ${{ needs.agent.outputs.model }} + GH_AW_ENGINE_VERSION: "1.0.34" + GH_AW_RUNTIME_FEATURES: ${{ vars.GH_AW_RUNTIME_FEATURES }} + GH_AW_WORKFLOW_ID: "smoke-bounded-agents" + GH_AW_WORKFLOW_NAME: "Smoke Bounded Agents" + GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/${{ github.repository }}/blob/${{ github.ref_name }}/.github/workflows/smoke-bounded-agents.md" + outputs: + code_push_failure_count: ${{ steps.process_safe_outputs.outputs.code_push_failure_count }} + code_push_failure_errors: ${{ steps.process_safe_outputs.outputs.code_push_failure_errors }} + create_discussion_error_count: ${{ steps.process_safe_outputs.outputs.create_discussion_error_count }} + create_discussion_errors: ${{ steps.process_safe_outputs.outputs.create_discussion_errors }} + created_issue_number: ${{ steps.process_safe_outputs.outputs.created_issue_number }} + created_issue_url: ${{ steps.process_safe_outputs.outputs.created_issue_url }} + process_safe_outputs_processed_count: ${{ steps.process_safe_outputs.outputs.processed_count }} + process_safe_outputs_temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} + steps: + - name: Setup Scripts + id: setup + uses: github/gh-aw-actions/setup@fd783ac87efde5e0c0e05d593f1906ea25b5d92e # v0.84.2 + with: + destination: ${{ runner.temp }}/gh-aw/actions + job-name: ${{ github.job }} + trace-id: ${{ needs.activation.outputs.setup-trace-id }} + parent-span-id: ${{ needs.activation.outputs.setup-parent-span-id || needs.activation.outputs.setup-span-id }} + env: + GH_AW_SETUP_WORKFLOW_NAME: "Smoke Bounded Agents" + GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/smoke-bounded-agents.lock.yml@${{ github.ref }} + GH_AW_INFO_VERSION: "1.0.34" + GH_AW_INFO_AWF_VERSION: "v0.28.0" + GH_AW_INFO_ENGINE_ID: "copilot" + - name: Download agent output artifact + id: download-agent-output + continue-on-error: true + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: agent + path: /tmp/gh-aw/ + - name: Setup agent output environment variable + id: setup-agent-output-env + if: steps.download-agent-output.outcome == 'success' + run: | + mkdir -p /tmp/gh-aw/ + find "/tmp/gh-aw/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT" + - name: Configure GH_HOST for enterprise compatibility + id: ghes-host-config + shell: bash + run: | # zizmor: ignore[github-env] - GITHUB_SERVER_URL is set by GitHub Actions, not user input. + # Derive GH_HOST from GITHUB_SERVER_URL so the gh CLI targets the correct + # GitHub instance (GHES/GHEC). On github.com this is a harmless no-op. + GH_HOST="${GITHUB_SERVER_URL#https://}" + GH_HOST="${GH_HOST#http://}" + echo "GH_HOST=${GH_HOST}" >> "$GITHUB_ENV" + - name: Process Safe Outputs + id: process_safe_outputs + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }} + GH_AW_COMMENT_ID: ${{ needs.activation.outputs.comment_id }} + GH_AW_ALLOWED_DOMAINS: "*.githubusercontent.com,api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,codeload.github.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,docs.github.com,github-cloud.githubusercontent.com,github-cloud.s3.amazonaws.com,github.blog,github.com,github.githubassets.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,lfs.github.com,objects.githubusercontent.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,patch-diff.githubusercontent.com,patchdiff.githubusercontent.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com" + GITHUB_SERVER_URL: ${{ github.server_url }} + GITHUB_API_URL: ${{ github.api_url }} + GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"create_issue\":{\"labels\":[\"smoke-bounded-agents\"],\"max\":1,\"title_prefix\":\"[smoke-bounded-agents]\"},\"create_report_incomplete_issue\":{},\"missing_data\":{},\"missing_tool\":{},\"noop\":{\"max\":1,\"report-as-issue\":\"true\"},\"report_incomplete\":{}}" + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/process_safe_outputs.cjs'); + await main(); + - name: Upload Safe Outputs Items + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: safe-outputs-items + path: | + /tmp/gh-aw/safe-output-items.jsonl + /tmp/gh-aw/temporary-id-map.json + /tmp/gh-aw/process-safe-outputs.stdout.log + /tmp/gh-aw/process-safe-outputs.stderr.log + if-no-files-found: ignore diff --git a/.github/workflows/smoke-bounded-agents.md b/.github/workflows/smoke-bounded-agents.md new file mode 100644 index 000000000..a6d701d89 --- /dev/null +++ b/.github/workflows/smoke-bounded-agents.md @@ -0,0 +1,104 @@ +--- +name: Smoke Bounded Agents +description: End-to-end smoke test for finite-schema Docker bounded-agent enclaves +on: + schedule: every 12h + workflow_dispatch: +permissions: + contents: read + copilot-requests: write +env: + GH_TOKEN: ${{ github.token }} + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} +engine: + id: copilot + version: 1.0.34 +network: + allowed: + - defaults + - github +tools: + github: + toolsets: [context] + allowed: [] +sandbox: + agent: + id: awf + version: v0.28.0 + args: + - --build-local +steps: + - name: Build unreleased AWF + run: | + npm ci + npm run build +pre-agent-steps: + - name: Replace release bootstrap with current AWF build + run: | + mkdir -p "$HOME/.local/bin" + printf '#!/bin/bash\nexec "%s" "%s/dist/cli.js" "$@"\n' \ + "$(command -v node)" "$GITHUB_WORKSPACE" > "$HOME/.local/bin/awf" + chmod +x "$HOME/.local/bin/awf" + node <<'NODE' + const fs = require("fs"); + const file = `${process.env.RUNNER_TEMP}/gh-aw/awf-config.json`; + const config = JSON.parse(fs.readFileSync(file, "utf8")); + config.apiProxy = { ...(config.apiProxy || {}), targets: { openai: {} } }; + config.boundedAgents = { + enabled: true, + privateRepos: [{ repo: "github/gh-aw", sensitivity: "internal" }], + runtime: "docker", + profile: "openai", + model: "gpt-4o-mini", + memoryLimit: "512m" + }; + fs.writeFileSync(file, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 }); + NODE +safe-outputs: + threat-detection: + enabled: false +timeout-minutes: 20 +strict: false +concurrency: + group: smoke-bounded-agents + cancel-in-progress: false +post-steps: + - name: Validate bounded-agent invocation + if: always() + env: + AUDIT_LOG: /tmp/gh-aw/sandbox/firewall/audit/bounded-agent.jsonl + OUTPUTS_FILE: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} + run: | + node - "$AUDIT_LOG" "$OUTPUTS_FILE" <<'NODE' + const fs = require("fs"); + const [auditPath, outputsPath] = process.argv.slice(2); + const records = fs.readFileSync(auditPath, "utf8").trim().split("\n") + .filter(Boolean).map((line) => JSON.parse(line)); + const invocations = records.filter((record) => + record.kind === "invocation" && record.sensitivity === "internal"); + if (invocations.length !== 1 || invocations[0].outcome !== "ok") { + throw new Error(`expected one successful bounded-agent invocation, found ${invocations.length}`); + } + const serialized = JSON.stringify(records); + if (serialized.includes("github/gh-aw") || serialized.includes("SECURITY.md")) { + throw new Error("protected audit disclosed repository-derived content"); + } + const outputs = fs.readFileSync(outputsPath, "utf8"); + if (!outputs.includes('"noop"') || !outputs.includes("PASS")) { + throw new Error("agent did not report PASS through noop"); + } + NODE +--- + +# Smoke Test: Docker Bounded Agent + +Use the generated `bounded-agent` skill exactly once to answer this boolean +question about `github/gh-aw`: does the repository root contain a `go.mod` +file? + +Use a boolean schema. Do not use GitHub tools, network requests, shell commands, +or the current checkout to answer. The test passes only when the bounded agent +returns `true`. + +Call `noop` with `PASS true` only when the result is true. Otherwise call +`safeoutputs-missing_data`. Never report failure through `noop`. diff --git a/.github/workflows/smoke-copilot-byok-aoai-entra.lock.yml b/.github/workflows/smoke-copilot-byok-aoai-entra.lock.yml index 278c7be5f..c2f25404f 100644 --- a/.github/workflows/smoke-copilot-byok-aoai-entra.lock.yml +++ b/.github/workflows/smoke-copilot-byok-aoai-entra.lock.yml @@ -1,4 +1,4 @@ -# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"0ae281819be4da8f35d5c760444a2baaf1fb59197c4e4d1454b6bfde77fc51f3","body_hash":"2dac2810766867cf09fb76c5315e19560e15db84fc4551a48269414f109a9670","compiler_version":"v0.84.2","agent_id":"copilot","engine_versions":{"copilot":"1.0.77"}} +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"e42bc3bab21df84f8b0cf6b8649e24331f8a88904ddb81c118e965a3e3143e4e","body_hash":"2bd1bbc4c7ffa08b048d3da8fdad3d4e5e80e28cb3c04e1690a95bc5638b8864","compiler_version":"v0.84.2","agent_id":"copilot","engine_versions":{"copilot":"1.0.77"}} # gh-aw-manifest: {"version":1,"secrets":["AZURE_CLIENT_ID","AZURE_TENANT_ID","COPILOT_GITHUB_TOKEN","FOUNDRY_OPENAI_ENDPOINT","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/cache/restore","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/cache/save","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/checkout","sha":"3d3c42e5aac5ba805825da76410c181273ba90b1","version":"v7.0.1"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"fd783ac87efde5e0c0e05d593f1906ea25b5d92e","version":"v0.84.2"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.43","digest":"sha256:04e2d1987a565000a8f114b89d806ae7a3864dd4f944be65275b28c93d8690e6","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.43@sha256:04e2d1987a565000a8f114b89d806ae7a3864dd4f944be65275b28c93d8690e6"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.43","digest":"sha256:d85f57975af5ea23af4996e41ed73fbc8f5b4a47402472bfe82e508f352cb0c1","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.43@sha256:d85f57975af5ea23af4996e41ed73fbc8f5b4a47402472bfe82e508f352cb0c1"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.43","digest":"sha256:26be5e0b8c8f4c41c8a59126b29bb5d80b07253597472ded2a16bdd75abcbf9d","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.43@sha256:26be5e0b8c8f4c41c8a59126b29bb5d80b07253597472ded2a16bdd75abcbf9d"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.4.7","digest":"sha256:7545220a9aca134b71e51193ee0eaf4c50756ebf8fbd25a63ae7556e62815c00","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.4.7@sha256:7545220a9aca134b71e51193ee0eaf4c50756ebf8fbd25a63ae7556e62815c00"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b","pinned_image":"ghcr.io/github/gh-aw-node@sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b"},{"image":"ghcr.io/github/github-mcp-server:v1.8.0","digest":"sha256:d5a18c04b92714c309eb46a2305087e91a4dbd80420f6e462656699f95093520","pinned_image":"ghcr.io/github/github-mcp-server:v1.8.0@sha256:d5a18c04b92714c309eb46a2305087e91a4dbd80420f6e462656699f95093520"}]} # This file was automatically generated by gh-aw (v0.84.2). DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md # @@ -516,8 +516,9 @@ jobs: path: /tmp/gh-aw - env: GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} name: Pre-compute BYOK smoke test data - run: "echo \"::group::Verify BYOK configuration\"\necho \"COPILOT_API_TARGET=${COPILOT_API_TARGET:-derived from COPILOT_PROVIDER_BASE_URL}\"\necho \"AWF_AUTH_TYPE=${AWF_AUTH_TYPE:-}\"\necho \"AWF_AUTH_PROVIDER=${AWF_AUTH_PROVIDER:-}\"\necho \"AWF_AUTH_AZURE_TENANT_ID set: $([ -n \\\"${AWF_AUTH_AZURE_TENANT_ID:-}\\\" ] && echo yes || echo NO)\"\necho \"AWF_AUTH_AZURE_CLIENT_ID set: $([ -n \\\"${AWF_AUTH_AZURE_CLIENT_ID:-}\\\" ] && echo yes || echo NO)\"\necho \"::endgroup::\"\n\necho \"::group::Fetching last 2 merged PRs\"\nPR_DATA=$(gh pr list --repo \"$GITHUB_REPOSITORY\" --state merged --limit 2 \\\n --json number,title,author,mergedAt \\\n --jq '.[] | \"PR #\\(.number): \\(.title) (by @\\(.author.login), merged \\(.mergedAt))\"' \\\n || echo \"(PR fetch failed)\")\necho \"$PR_DATA\"\necho \"::endgroup::\"\n\necho \"::group::GitHub.com connectivity check\"\nHTTP_CODE=$(curl -s -o /dev/null -w \"%{http_code}\" --max-time 10 https://github.com || echo \"000\")\necho \"github.com returned HTTP $HTTP_CODE\"\necho \"::endgroup::\"\n\necho \"::group::File write/read test\"\nTEST_DIR=\"/tmp/gh-aw/agent\"\nTEST_FILE=\"$TEST_DIR/smoke-test-copilot-byok-aoai-entra-${GITHUB_RUN_ID}.txt\"\nmkdir -p \"$TEST_DIR\"\necho \"BYOK AOAI Entra smoke test passed at $(date)\" > \"$TEST_FILE\"\nFILE_CONTENT=$(cat \"$TEST_FILE\")\necho \"Wrote and read back: $FILE_CONTENT\"\necho \"::endgroup::\"\n\n# Write results to files for agent context\nmkdir -p /tmp/gh-aw/agent\necho \"$HTTP_CODE\" > /tmp/gh-aw/agent/smoke-http-code.txt\necho \"$FILE_CONTENT\" > /tmp/gh-aw/agent/smoke-file-content.txt\necho \"$TEST_FILE\" > /tmp/gh-aw/agent/smoke-file-path.txt\necho \"$PR_DATA\" > /tmp/gh-aw/agent/smoke-pr-data.txt" + run: "echo \"::group::Verify BYOK configuration\"\necho \"COPILOT_API_TARGET=${COPILOT_API_TARGET:-derived from COPILOT_PROVIDER_BASE_URL}\"\necho \"AWF_AUTH_TYPE=${AWF_AUTH_TYPE:-}\"\necho \"AWF_AUTH_PROVIDER=${AWF_AUTH_PROVIDER:-}\"\necho \"AWF_AUTH_AZURE_TENANT_ID set: $([ -n \\\"${AWF_AUTH_AZURE_TENANT_ID:-}\\\" ] && echo yes || echo NO)\"\necho \"AWF_AUTH_AZURE_CLIENT_ID set: $([ -n \\\"${AWF_AUTH_AZURE_CLIENT_ID:-}\\\" ] && echo yes || echo NO)\"\necho \"::endgroup::\"\n\necho \"::group::Fetching last 2 merged PRs\"\nPR_DATA=$(gh pr list --repo \"$GITHUB_REPOSITORY\" --state merged --limit 2 \\\n --json number,title,author,mergedAt \\\n --jq '.[] | \"PR #\\(.number): \\(.title) (by @\\(.author.login), merged \\(.mergedAt))\"' \\\n || echo \"(PR fetch failed)\")\necho \"$PR_DATA\"\necho \"::endgroup::\"\n\necho \"::group::GitHub.com connectivity check\"\nHTTP_CODE=$(curl -s -o /dev/null -w \"%{http_code}\" --max-time 10 https://github.com || echo \"000\")\necho \"github.com returned HTTP $HTTP_CODE\"\necho \"::endgroup::\"\n\necho \"::group::File write/read test\"\nTEST_DIR=\"/tmp/gh-aw/agent\"\nTEST_FILE=\"$TEST_DIR/smoke-test-copilot-byok-aoai-entra-${GITHUB_RUN_ID}.txt\"\nmkdir -p \"$TEST_DIR\"\necho \"BYOK AOAI Entra smoke test passed at $(date)\" > \"$TEST_FILE\"\nFILE_CONTENT=$(cat \"$TEST_FILE\")\necho \"Wrote and read back: $FILE_CONTENT\"\necho \"::endgroup::\"\n\n# Write results to files for agent context\nmkdir -p /tmp/gh-aw/agent\necho \"$HTTP_CODE\" > /tmp/gh-aw/agent/smoke-http-code.txt\necho \"$FILE_CONTENT\" > /tmp/gh-aw/agent/smoke-file-content.txt\necho \"$TEST_FILE\" > /tmp/gh-aw/agent/smoke-file-path.txt\necho \"$PR_DATA\" > /tmp/gh-aw/agent/smoke-pr-data.txt\n{\n echo \"event=${GITHUB_EVENT_NAME}\"\n echo \"item_number=${PR_NUMBER:-}\"\n echo \"http_code=${HTTP_CODE}\"\n echo \"file_path=${TEST_FILE}\"\n echo \"file_content=${FILE_CONTENT}\"\n echo \"recent_prs:\"\n echo \"$PR_DATA\"\n} > /tmp/gh-aw/agent/smoke-context.txt" - name: Configure Git credentials env: diff --git a/.github/workflows/smoke-copilot-byok-aoai-entra.md b/.github/workflows/smoke-copilot-byok-aoai-entra.md index 15f9d1508..6a2319188 100644 --- a/.github/workflows/smoke-copilot-byok-aoai-entra.md +++ b/.github/workflows/smoke-copilot-byok-aoai-entra.md @@ -130,8 +130,18 @@ steps: echo "$FILE_CONTENT" > /tmp/gh-aw/agent/smoke-file-content.txt echo "$TEST_FILE" > /tmp/gh-aw/agent/smoke-file-path.txt echo "$PR_DATA" > /tmp/gh-aw/agent/smoke-pr-data.txt + { + echo "event=${GITHUB_EVENT_NAME}" + echo "item_number=${PR_NUMBER:-}" + echo "http_code=${HTTP_CODE}" + echo "file_path=${TEST_FILE}" + echo "file_content=${FILE_CONTENT}" + echo "recent_prs:" + echo "$PR_DATA" + } > /tmp/gh-aw/agent/smoke-context.txt env: GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} post-steps: - name: Validate safe outputs were invoked run: | @@ -180,9 +190,11 @@ This smoke test validates that Copilot CLI runs in **direct BYOK mode against Az The following tests were already executed in a deterministic pre-agent step. Your job is to verify the results and produce the summary comment. ### 1. GitHub MCP Testing +First read `/tmp/gh-aw/agent/smoke-context.txt` once. It contains the event type, pull request item number, HTTP result, file path/content, and pre-fetched PR data. + Verify MCP connectivity via the GitHub MCP tool `github-list_pull_requests` for ${{ github.repository }} (limit 2, state merged). - If the tool responds successfully, confirm the result matches the Pre-Fetched PR Data below. ✅ -- If the tool is unavailable (missing from context), skip the live call and validate the Pre-Fetched PR Data instead. Mark ✅ (pre-fetched data validated). Do **not** call `missing_tool` for this optional skip. +- If the tool is unavailable or its response is filtered by secrecy policy, validate the Pre-Fetched PR Data instead. Mark ✅ (pre-fetched data validated). Filtering is expected isolation, not a test failure. Do **not** call `missing_tool` for this optional fallback. - If the tool is available but the call fails for another reason, mark ❌ and include the error. Either way, continue to the **Output** section below and follow the required output rules. @@ -206,7 +218,7 @@ You are running in direct BYOK mode against Azure OpenAI (Foundry) right now, us ## Output -**If triggered by a pull request**, call `add_comment` to post a **very brief** comment (max 5-10 lines) on the current pull request with: +**If triggered by a pull request** (`event=pull_request` in the context file), call the `add_comment` safe-output exactly once with `item_number` set to the context file's numeric item number and a **very brief** body (max 5-10 lines) containing: - PR titles only (no descriptions) - ✅ or ❌ for each test result - Note: "Running in direct BYOK mode (AWF_AUTH_TYPE=github-oidc + AWF_AUTH_AZURE_* + COPILOT_PROVIDER_BASE_URL) via api-proxy → Azure OpenAI (Foundry, o4-mini-aw) authenticated via Microsoft Entra" @@ -216,4 +228,6 @@ You are running in direct BYOK mode against Azure OpenAI (Foundry) right now, us If all tests pass on a pull request trigger: - Use the `add_labels` safe-output tool to add the label `smoke-copilot-byok-aoai-entra` to the pull request +On a pull request trigger, never call `noop`, even when a test fails; the required final action is always `add_comment`. Do not pass `pr_number` to `add_comment`; the required target field is `item_number`. + **If triggered by workflow_dispatch or schedule** (no PR context), call `noop` with a concise PASS/FAIL summary instead. Do NOT attempt to add pull request comments or labels when there is no pull request. diff --git a/.github/workflows/smoke-docker-sbx.lock.yml b/.github/workflows/smoke-docker-sbx.lock.yml index f62d26d59..8fda6db39 100644 --- a/.github/workflows/smoke-docker-sbx.lock.yml +++ b/.github/workflows/smoke-docker-sbx.lock.yml @@ -1,6 +1,6 @@ -# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"e4db76a5a5ef3458ed4ecada7bab9ec955936f62f9e82e6770753c50cf8d5f03","body_hash":"680db89cd4b40f61c1f4e06aae4fefbe1485dd6b7dd6b15b652324b73738d216","compiler_version":"v0.84.2","agent_id":"copilot","agent_model":"claude-sonnet-5","engine_versions":{"copilot":"1.0.34"}} -# gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","DOCKER_PAT","DOCKER_USERNAME","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/cache/restore","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/cache/save","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/checkout","sha":"3d3c42e5aac5ba805825da76410c181273ba90b1","version":"v7.0.1"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"fd783ac87efde5e0c0e05d593f1906ea25b5d92e","version":"v0.84.2"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.43","digest":"sha256:04e2d1987a565000a8f114b89d806ae7a3864dd4f944be65275b28c93d8690e6","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.43@sha256:04e2d1987a565000a8f114b89d806ae7a3864dd4f944be65275b28c93d8690e6"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.43","digest":"sha256:d85f57975af5ea23af4996e41ed73fbc8f5b4a47402472bfe82e508f352cb0c1","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.43@sha256:d85f57975af5ea23af4996e41ed73fbc8f5b4a47402472bfe82e508f352cb0c1"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.43","digest":"sha256:26be5e0b8c8f4c41c8a59126b29bb5d80b07253597472ded2a16bdd75abcbf9d","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.43@sha256:26be5e0b8c8f4c41c8a59126b29bb5d80b07253597472ded2a16bdd75abcbf9d"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.4.7","digest":"sha256:7545220a9aca134b71e51193ee0eaf4c50756ebf8fbd25a63ae7556e62815c00","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.4.7@sha256:7545220a9aca134b71e51193ee0eaf4c50756ebf8fbd25a63ae7556e62815c00"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b","pinned_image":"ghcr.io/github/gh-aw-node@sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b"},{"image":"ghcr.io/github/github-mcp-server:v1.8.0","digest":"sha256:d5a18c04b92714c309eb46a2305087e91a4dbd80420f6e462656699f95093520","pinned_image":"ghcr.io/github/github-mcp-server:v1.8.0@sha256:d5a18c04b92714c309eb46a2305087e91a4dbd80420f6e462656699f95093520"}]} -# This file was automatically generated by gh-aw (v0.84.2). DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"e4db76a5a5ef3458ed4ecada7bab9ec955936f62f9e82e6770753c50cf8d5f03","body_hash":"65dcfb3fae87fa7a5cfb84ce36534d62964adc09a391fa752c0df417c2b93a0b","compiler_version":"v0.84.3","agent_id":"copilot","agent_model":"claude-sonnet-5","engine_versions":{"copilot":"1.0.34"}} +# gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","DOCKER_PAT","DOCKER_USERNAME","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/cache/restore","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/cache/save","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/checkout","sha":"3d3c42e5aac5ba805825da76410c181273ba90b1","version":"v7.0.1"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"c863074b673419603d146aab585e2986ef08deec","version":"v0.84.3"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.43","digest":"sha256:04e2d1987a565000a8f114b89d806ae7a3864dd4f944be65275b28c93d8690e6","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.43@sha256:04e2d1987a565000a8f114b89d806ae7a3864dd4f944be65275b28c93d8690e6"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.43","digest":"sha256:d85f57975af5ea23af4996e41ed73fbc8f5b4a47402472bfe82e508f352cb0c1","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.43@sha256:d85f57975af5ea23af4996e41ed73fbc8f5b4a47402472bfe82e508f352cb0c1"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.43","digest":"sha256:26be5e0b8c8f4c41c8a59126b29bb5d80b07253597472ded2a16bdd75abcbf9d","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.43@sha256:26be5e0b8c8f4c41c8a59126b29bb5d80b07253597472ded2a16bdd75abcbf9d"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.4.7","digest":"sha256:7545220a9aca134b71e51193ee0eaf4c50756ebf8fbd25a63ae7556e62815c00","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.4.7@sha256:7545220a9aca134b71e51193ee0eaf4c50756ebf8fbd25a63ae7556e62815c00"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b","pinned_image":"ghcr.io/github/gh-aw-node@sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b"},{"image":"ghcr.io/github/github-mcp-server:v1.8.0","digest":"sha256:d5a18c04b92714c309eb46a2305087e91a4dbd80420f6e462656699f95093520","pinned_image":"ghcr.io/github/github-mcp-server:v1.8.0@sha256:d5a18c04b92714c309eb46a2305087e91a4dbd80420f6e462656699f95093520"}]} +# This file was automatically generated by gh-aw (v0.84.3). DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md # # ___ _ _ # / _ \ | | (_) @@ -41,7 +41,7 @@ # - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 # - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 (source v9) # - actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 -# - github/gh-aw-actions/setup@fd783ac87efde5e0c0e05d593f1906ea25b5d92e # v0.84.2 +# - github/gh-aw-actions/setup@c863074b673419603d146aab585e2986ef08deec # v0.84.3 # # Container images used: # - @@ -110,7 +110,7 @@ jobs: steps: - name: Setup Scripts id: setup - uses: github/gh-aw-actions/setup@fd783ac87efde5e0c0e05d593f1906ea25b5d92e # v0.84.2 + uses: github/gh-aw-actions/setup@c863074b673419603d146aab585e2986ef08deec # v0.84.3 with: destination: ${{ runner.temp }}/gh-aw/actions job-name: ${{ github.job }} @@ -129,7 +129,7 @@ jobs: GH_AW_INFO_MODEL: "claude-sonnet-5" GH_AW_INFO_VERSION: "1.0.34" GH_AW_INFO_AGENT_VERSION: "1.0.34" - GH_AW_INFO_CLI_VERSION: "v0.84.2" + GH_AW_INFO_CLI_VERSION: "v0.84.3" GH_AW_INFO_WORKFLOW_NAME: "Smoke Docker Sbx" GH_AW_INFO_EXPERIMENTAL: "false" GH_AW_INFO_SUPPORTS_TOOLS_ALLOWLIST: "true" @@ -243,7 +243,7 @@ jobs: - name: Check compile-agentic version uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: - GH_AW_COMPILED_VERSION: "v0.84.2" + GH_AW_COMPILED_VERSION: "v0.84.3" with: script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); @@ -285,6 +285,7 @@ jobs: GH_AW_EXPR_FF1D34CE: ${{ github.event.comment.id || fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').comment_id }} GH_AW_GITHUB_ACTOR: ${{ github.actor }} GH_AW_GITHUB_EVENT_NAME: ${{ github.event_name }} + GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number || inputs.item_number }} GH_AW_GITHUB_REPOSITORY: ${{ github.repository }} GH_AW_GITHUB_RUN_ID: ${{ github.run_id }} GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }} @@ -346,6 +347,7 @@ jobs: env: GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt GH_AW_ENGINE_ID: "copilot" + GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number || inputs.item_number }} GH_AW_GITHUB_EVENT_NAME: ${{ github.event_name }} GH_AW_GITHUB_REPOSITORY: ${{ github.repository }} with: @@ -364,6 +366,7 @@ jobs: GH_AW_EXPR_FF1D34CE: ${{ github.event.comment.id || fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').comment_id }} GH_AW_GITHUB_ACTOR: ${{ github.actor }} GH_AW_GITHUB_EVENT_NAME: ${{ github.event_name }} + GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number || inputs.item_number }} GH_AW_GITHUB_REPOSITORY: ${{ github.repository }} GH_AW_GITHUB_RUN_ID: ${{ github.run_id }} GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }} @@ -385,6 +388,7 @@ jobs: GH_AW_EXPR_FF1D34CE: process.env.GH_AW_EXPR_FF1D34CE, GH_AW_GITHUB_ACTOR: process.env.GH_AW_GITHUB_ACTOR, GH_AW_GITHUB_EVENT_NAME: process.env.GH_AW_GITHUB_EVENT_NAME, + GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: process.env.GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER, GH_AW_GITHUB_REPOSITORY: process.env.GH_AW_GITHUB_REPOSITORY, GH_AW_GITHUB_RUN_ID: process.env.GH_AW_GITHUB_RUN_ID, GH_AW_GITHUB_WORKSPACE: process.env.GH_AW_GITHUB_WORKSPACE, @@ -464,7 +468,7 @@ jobs: steps: - name: Setup Scripts id: setup - uses: github/gh-aw-actions/setup@fd783ac87efde5e0c0e05d593f1906ea25b5d92e # v0.84.2 + uses: github/gh-aw-actions/setup@c863074b673419603d146aab585e2986ef08deec # v0.84.3 with: destination: ${{ runner.temp }}/gh-aw/actions job-name: ${{ github.job }} @@ -1038,7 +1042,7 @@ jobs: export COPILOT_API_KEY="$COPILOT_DUMMY_BYOK" (umask 177 && touch /tmp/gh-aw/agent-stdio.log) GH_AW_MAX_AI_CREDITS="${GH_AW_MAX_AI_CREDITS:-1000}" - printf '%s\n' "{\"\$schema\":\"https://github.com/github/gh-aw-firewall/releases/download/v0.27.43/awf-config.schema.json\",\"network\":{\"allowDomains\":[\"*.githubusercontent.com\",\"api.business.githubcopilot.com\",\"api.enterprise.githubcopilot.com\",\"api.github.com\",\"api.githubcopilot.com\",\"api.individual.githubcopilot.com\",\"api.snapcraft.io\",\"archive.ubuntu.com\",\"azure.archive.ubuntu.com\",\"codeload.github.com\",\"crl.geotrust.com\",\"crl.globalsign.com\",\"crl.identrust.com\",\"crl.sectigo.com\",\"crl.thawte.com\",\"crl.usertrust.com\",\"crl.verisign.com\",\"crl3.digicert.com\",\"crl4.digicert.com\",\"crls.ssl.com\",\"docs.github.com\",\"github-cloud.githubusercontent.com\",\"github-cloud.s3.amazonaws.com\",\"github.blog\",\"github.com\",\"github.githubassets.com\",\"host.docker.internal\",\"json-schema.org\",\"json.schemastore.org\",\"keyserver.ubuntu.com\",\"lfs.github.com\",\"objects.githubusercontent.com\",\"ocsp.digicert.com\",\"ocsp.geotrust.com\",\"ocsp.globalsign.com\",\"ocsp.identrust.com\",\"ocsp.sectigo.com\",\"ocsp.ssl.com\",\"ocsp.thawte.com\",\"ocsp.usertrust.com\",\"ocsp.verisign.com\",\"packagecloud.io\",\"packages.cloud.google.com\",\"packages.microsoft.com\",\"patch-diff.githubusercontent.com\",\"patchdiff.githubusercontent.com\",\"ppa.launchpad.net\",\"raw.githubusercontent.com\",\"registry.npmjs.org\",\"s.symcb.com\",\"s.symcd.com\",\"security.ubuntu.com\",\"telemetry.enterprise.githubcopilot.com\",\"ts-crl.ws.symantec.com\",\"ts-ocsp.ws.symantec.com\",\"www.googleapis.com\"],\"isolation\":true,\"topologyAttach\":[\"awmg-mcpg\"]},\"apiProxy\":{\"enabled\":true,\"enableTokenSteering\":true,\"maxRuns\":500,\"maxAiCredits\":${GH_AW_MAX_AI_CREDITS},\"maxCacheMisses\":5,\"models\":{\"agent\":[\"sonnet-6x\",\"gpt-5.4\",\"gpt-5.5\",\"gpt-5.6\",\"gpt-5.3\",\"gemini-pro\",\"any\"],\"antigravity\":[\"copilot/antigravity*\",\"google/antigravity*\",\"gemini/antigravity*\"],\"any\":[\"copilot/*\",\"anthropic/*\",\"openai/*\",\"google/*\",\"gemini/*\"],\"auto\":[\"copilot/auto\",\"large\"],\"claude\":[\"agent\"],\"codex\":[\"agent\"],\"coding\":[\"copilot/gpt-5*codex*\",\"openai/gpt-5*codex*\",\"gpt-5-codex\",\"kimi\"],\"computer-use\":[\"copilot/*computer-use*\",\"google/*computer-use*\",\"gemini/*computer-use*\",\"openai/*computer-use*\"],\"copilot\":[\"agent\"],\"deep-research\":[\"copilot/deep-research*\",\"copilot/o3-deep-research*\",\"copilot/o4-mini-deep-research*\",\"google/deep-research*\",\"gemini/deep-research*\",\"openai/o3-deep-research*\",\"openai/o4-mini-deep-research*\"],\"fable\":[\"copilot/*fable*\",\"anthropic/*fable*\"],\"gemini\":[\"agent\"],\"gemini-3-flash\":[\"copilot/gemini-3*flash*\",\"google/gemini-3*flash*\",\"gemini/gemini-3*flash*\"],\"gemini-3-pro\":[\"copilot/gemini-3*pro*\",\"google/gemini-3*pro*\",\"google/nano-banana*\",\"gemini/gemini-3*pro*\"],\"gemini-3.1-flash\":[\"copilot/gemini-3.1*flash*\",\"google/gemini-3.1*flash*\",\"gemini/gemini-3.1*flash*\"],\"gemini-3.1-pro\":[\"copilot/gemini-3.1*pro*\",\"google/gemini-3.1*pro*\",\"gemini/gemini-3.1*pro*\"],\"gemini-3.5-flash\":[\"copilot/gemini-3.5*flash*\",\"google/gemini-3.5*flash*\",\"gemini/gemini-3.5*flash*\"],\"gemini-3.6-flash\":[\"copilot/gemini-3.6*flash*\",\"google/gemini-3.6*flash*\",\"gemini/gemini-3.6*flash*\"],\"gemini-flash\":[\"copilot/gemini-*flash*\",\"google/gemini-*flash*\",\"gemini/gemini-*flash*\"],\"gemini-flash-lite\":[\"copilot/gemini-*flash*lite*\",\"google/gemini-*flash*lite*\",\"gemini/gemini-*flash*lite*\"],\"gemini-omni\":[\"copilot/gemini-omni*\",\"google/gemini-omni*\",\"gemini/gemini-omni*\"],\"gemini-pro\":[\"copilot/gemini-*pro*\",\"google/gemini-*pro*\",\"gemini/gemini-*pro*\"],\"gemma\":[\"copilot/gemma*\",\"google/gemma*\",\"gemini/gemma*\"],\"gpt-5\":[\"copilot/gpt-5*\",\"openai/gpt-5*\"],\"gpt-5-codex\":[\"copilot/gpt-5*codex*\",\"openai/gpt-5*codex*\"],\"gpt-5-mini\":[\"copilot/gpt-5*mini*\",\"openai/gpt-5*mini*\"],\"gpt-5-nano\":[\"copilot/gpt-5*nano*\",\"openai/gpt-5*nano*\"],\"gpt-5-pro\":[\"copilot/gpt-5*pro*\",\"openai/gpt-5*pro*\"],\"gpt-5.1\":[\"copilot/gpt-5.1*\",\"openai/gpt-5.1*\"],\"gpt-5.2\":[\"copilot/gpt-5.2*\",\"openai/gpt-5.2*\"],\"gpt-5.3\":[\"copilot/gpt-5.3*\",\"openai/gpt-5.3*\"],\"gpt-5.4\":[\"copilot/gpt-5.4*\",\"openai/gpt-5.4*\"],\"gpt-5.5\":[\"copilot/gpt-5.5*\",\"openai/gpt-5.5*\"],\"gpt-5.6\":[\"copilot/gpt-5.6*\",\"openai/gpt-5.6*\"],\"grok\":[\"copilot/*grok*\",\"openai/*grok*\"],\"haiku\":[\"copilot/*haiku*\",\"anthropic/*haiku*\"],\"image-generation\":[\"copilot/gpt-image*\",\"openai/gpt-image*\",\"openai/chatgpt-image*\",\"copilot/gemini-*image*\",\"google/gemini-*image*\",\"gemini/gemini-*image*\",\"google/imagen*\"],\"kimi\":[\"copilot/kimi*\",\"openai/kimi*\"],\"kiwi\":[\"copilot/kiwi*\",\"openai/kiwi*\"],\"large\":[\"sonnet\",\"gpt-5-pro\",\"gpt-5\",\"gemini-pro\"],\"lyria\":[\"google/lyria*\",\"gemini/lyria*\",\"copilot/lyria*\"],\"mai-code\":[\"copilot/MAI-Code*\",\"copilot/mai-code*\",\"openai/MAI-Code*\"],\"mai-code-1-flash-picker\":[\"copilot/MAI-Code-1-Flash-picker*\",\"copilot/mai-code-1-flash-picker*\",\"openai/MAI-Code-1-Flash-picker*\"],\"mini\":[\"haiku\",\"gpt-5-mini\",\"gpt-5-nano\",\"gemini-flash-lite\"],\"nano-banana\":[\"copilot/nano-banana*\",\"google/nano-banana*\",\"gemini/nano-banana*\"],\"opus\":[\"copilot/*opus*\",\"anthropic/*opus*\"],\"opusplan\":[\"opus?effort=high\"],\"raptor-mini\":[\"copilot/raptor*\",\"openai/raptor*\"],\"reasoning\":[\"copilot/o1*\",\"copilot/o3*\",\"copilot/o4*\",\"openai/o1*\",\"openai/o3*\",\"openai/o4*\"],\"robotics\":[\"copilot/*robotics*\",\"google/*robotics*\",\"gemini/*robotics*\"],\"small\":[\"mini\"],\"small-agent\":[\"haiku\",\"gpt-5-mini\",\"gemini-flash\"],\"sonnet\":[\"copilot/*sonnet*\",\"anthropic/*sonnet*\"],\"sonnet-6x\":[\"copilot/*sonnet-4.5*\",\"copilot/*sonnet-4.6*\",\"copilot/*sonnet-5*\",\"copilot/*sonnet-4-5-*\",\"anthropic/*sonnet-4-5-*\",\"copilot/*sonnet-4-6*\",\"anthropic/*sonnet-4-6*\",\"anthropic/*sonnet-5*\"],\"summarization\":[\"haiku\",\"gpt-5-mini\",\"gemini-flash-lite\",\"mini\"],\"veo\":[\"google/veo*\",\"gemini/veo*\"],\"vision\":[\"copilot/gemini-*image*\",\"google/gemini-*image*\",\"gemini/gemini-*image*\",\"copilot/gemini-*flash*\",\"google/gemini-*flash*\",\"gemini/gemini-*flash*\"]}},\"container\":{\"imageTag\":\"0.27.43,squid=sha256:26be5e0b8c8f4c41c8a59126b29bb5d80b07253597472ded2a16bdd75abcbf9d,agent=sha256:04e2d1987a565000a8f114b89d806ae7a3864dd4f944be65275b28c93d8690e6,api-proxy=sha256:d85f57975af5ea23af4996e41ed73fbc8f5b4a47402472bfe82e508f352cb0c1,cli-proxy=sha256:65c45ea2967984d0024f3df61bc71335658a77ede96c8d9665da7a5f33a795ab\",\"agentTimeout\":15},\"logging\":{\"proxyLogsDir\":\"/tmp/gh-aw/sandbox/firewall/logs\",\"auditDir\":\"/tmp/gh-aw/sandbox/firewall/audit\"}}" > "${RUNNER_TEMP}/gh-aw/awf-config.json" + printf '%s\n' "{\"\$schema\":\"https://github.com/github/gh-aw-firewall/releases/download/v0.27.43/awf-config.schema.json\",\"network\":{\"allowDomains\":[\"*.githubusercontent.com\",\"api.business.githubcopilot.com\",\"api.enterprise.githubcopilot.com\",\"api.github.com\",\"api.githubcopilot.com\",\"api.individual.githubcopilot.com\",\"api.snapcraft.io\",\"archive.ubuntu.com\",\"azure.archive.ubuntu.com\",\"codeload.github.com\",\"crl.geotrust.com\",\"crl.globalsign.com\",\"crl.identrust.com\",\"crl.sectigo.com\",\"crl.thawte.com\",\"crl.usertrust.com\",\"crl.verisign.com\",\"crl3.digicert.com\",\"crl4.digicert.com\",\"crls.ssl.com\",\"docs.github.com\",\"github-cloud.githubusercontent.com\",\"github-cloud.s3.amazonaws.com\",\"github.blog\",\"github.com\",\"github.githubassets.com\",\"host.docker.internal\",\"json-schema.org\",\"json.schemastore.org\",\"keyserver.ubuntu.com\",\"lfs.github.com\",\"objects.githubusercontent.com\",\"ocsp.digicert.com\",\"ocsp.geotrust.com\",\"ocsp.globalsign.com\",\"ocsp.identrust.com\",\"ocsp.sectigo.com\",\"ocsp.ssl.com\",\"ocsp.thawte.com\",\"ocsp.usertrust.com\",\"ocsp.verisign.com\",\"packagecloud.io\",\"packages.cloud.google.com\",\"packages.microsoft.com\",\"patch-diff.githubusercontent.com\",\"patchdiff.githubusercontent.com\",\"ppa.launchpad.net\",\"raw.githubusercontent.com\",\"registry.npmjs.org\",\"s.symcb.com\",\"s.symcd.com\",\"security.ubuntu.com\",\"telemetry.enterprise.githubcopilot.com\",\"ts-crl.ws.symantec.com\",\"ts-ocsp.ws.symantec.com\",\"www.googleapis.com\"],\"isolation\":true,\"topologyAttach\":[\"awmg-mcpg\"]},\"apiProxy\":{\"enabled\":true,\"enableTokenSteering\":true,\"maxRuns\":500,\"maxAiCredits\":${GH_AW_MAX_AI_CREDITS},\"maxCacheMisses\":5,\"models\":{\"agent\":[\"sonnet-6x\",\"gpt-5.4\",\"gpt-5.5\",\"gpt-5.6\",\"gpt-5.3\",\"gemini-pro\",\"any\"],\"antigravity\":[\"copilot/antigravity*\",\"google/antigravity*\",\"gemini/antigravity*\"],\"any\":[\"copilot/*\",\"anthropic/*\",\"openai/*\",\"google/*\",\"gemini/*\"],\"auto\":[\"copilot/auto\",\"large\"],\"claude\":[\"agent\"],\"codex\":[\"agent\"],\"coding\":[\"copilot/gpt-5*codex*\",\"openai/gpt-5*codex*\",\"gpt-5-codex\",\"kimi\"],\"computer-use\":[\"copilot/*computer-use*\",\"google/*computer-use*\",\"gemini/*computer-use*\",\"openai/*computer-use*\"],\"copilot\":[\"agent\"],\"deep-research\":[\"copilot/deep-research*\",\"copilot/o3-deep-research*\",\"copilot/o4-mini-deep-research*\",\"google/deep-research*\",\"gemini/deep-research*\",\"openai/o3-deep-research*\",\"openai/o4-mini-deep-research*\"],\"detection\":[\"small\"],\"evals\":[\"small\"],\"fable\":[\"copilot/*fable*\",\"anthropic/*fable*\"],\"gemini\":[\"agent\"],\"gemini-3-flash\":[\"copilot/gemini-3*flash*\",\"google/gemini-3*flash*\",\"gemini/gemini-3*flash*\"],\"gemini-3-pro\":[\"copilot/gemini-3*pro*\",\"google/gemini-3*pro*\",\"google/nano-banana*\",\"gemini/gemini-3*pro*\"],\"gemini-3.1-flash\":[\"copilot/gemini-3.1*flash*\",\"google/gemini-3.1*flash*\",\"gemini/gemini-3.1*flash*\"],\"gemini-3.1-pro\":[\"copilot/gemini-3.1*pro*\",\"google/gemini-3.1*pro*\",\"gemini/gemini-3.1*pro*\"],\"gemini-3.5-flash\":[\"copilot/gemini-3.5*flash*\",\"google/gemini-3.5*flash*\",\"gemini/gemini-3.5*flash*\"],\"gemini-3.6-flash\":[\"copilot/gemini-3.6*flash*\",\"google/gemini-3.6*flash*\",\"gemini/gemini-3.6*flash*\"],\"gemini-flash\":[\"copilot/gemini-*flash*\",\"google/gemini-*flash*\",\"gemini/gemini-*flash*\"],\"gemini-flash-lite\":[\"copilot/gemini-*flash*lite*\",\"google/gemini-*flash*lite*\",\"gemini/gemini-*flash*lite*\"],\"gemini-omni\":[\"copilot/gemini-omni*\",\"google/gemini-omni*\",\"gemini/gemini-omni*\"],\"gemini-pro\":[\"copilot/gemini-*pro*\",\"google/gemini-*pro*\",\"gemini/gemini-*pro*\"],\"gemma\":[\"copilot/gemma*\",\"google/gemma*\",\"gemini/gemma*\"],\"gpt-5\":[\"copilot/gpt-5*\",\"openai/gpt-5*\"],\"gpt-5-codex\":[\"copilot/gpt-5*codex*\",\"openai/gpt-5*codex*\"],\"gpt-5-mini\":[\"copilot/gpt-5*mini*\",\"openai/gpt-5*mini*\"],\"gpt-5-nano\":[\"copilot/gpt-5*nano*\",\"openai/gpt-5*nano*\"],\"gpt-5-pro\":[\"copilot/gpt-5*pro*\",\"openai/gpt-5*pro*\"],\"gpt-5.1\":[\"copilot/gpt-5.1*\",\"openai/gpt-5.1*\"],\"gpt-5.2\":[\"copilot/gpt-5.2*\",\"openai/gpt-5.2*\"],\"gpt-5.3\":[\"copilot/gpt-5.3*\",\"openai/gpt-5.3*\"],\"gpt-5.4\":[\"copilot/gpt-5.4*\",\"openai/gpt-5.4*\"],\"gpt-5.5\":[\"copilot/gpt-5.5*\",\"openai/gpt-5.5*\"],\"gpt-5.6\":[\"copilot/gpt-5.6*\",\"openai/gpt-5.6*\"],\"grok\":[\"copilot/*grok*\",\"openai/*grok*\"],\"haiku\":[\"copilot/*haiku*\",\"anthropic/*haiku*\"],\"image-generation\":[\"copilot/gpt-image*\",\"openai/gpt-image*\",\"openai/chatgpt-image*\",\"copilot/gemini-*image*\",\"google/gemini-*image*\",\"gemini/gemini-*image*\",\"google/imagen*\"],\"kimi\":[\"copilot/kimi*\",\"openai/kimi*\"],\"kiwi\":[\"copilot/kiwi*\",\"openai/kiwi*\"],\"large\":[\"sonnet\",\"gpt-5-pro\",\"gpt-5\",\"gemini-pro\"],\"lyria\":[\"google/lyria*\",\"gemini/lyria*\",\"copilot/lyria*\"],\"mai-code\":[\"copilot/MAI-Code*\",\"copilot/mai-code*\",\"openai/MAI-Code*\"],\"mai-code-1-flash-picker\":[\"copilot/MAI-Code-1-Flash-picker*\",\"copilot/mai-code-1-flash-picker*\",\"openai/MAI-Code-1-Flash-picker*\"],\"mini\":[\"haiku\",\"gpt-5-mini\",\"gpt-5-nano\",\"gemini-flash-lite\"],\"nano-banana\":[\"copilot/nano-banana*\",\"google/nano-banana*\",\"gemini/nano-banana*\"],\"opus\":[\"copilot/*opus*\",\"anthropic/*opus*\"],\"opusplan\":[\"opus?effort=high\"],\"raptor-mini\":[\"copilot/raptor*\",\"openai/raptor*\"],\"reasoning\":[\"copilot/o1*\",\"copilot/o3*\",\"copilot/o4*\",\"openai/o1*\",\"openai/o3*\",\"openai/o4*\"],\"robotics\":[\"copilot/*robotics*\",\"google/*robotics*\",\"gemini/*robotics*\"],\"small\":[\"mini\"],\"small-agent\":[\"haiku\",\"gpt-5-mini\",\"gemini-flash\"],\"sonnet\":[\"copilot/*sonnet*\",\"anthropic/*sonnet*\"],\"sonnet-6x\":[\"copilot/*sonnet-4.5*\",\"copilot/*sonnet-4.6*\",\"copilot/*sonnet-5*\",\"copilot/*sonnet-4-5-*\",\"anthropic/*sonnet-4-5-*\",\"copilot/*sonnet-4-6*\",\"anthropic/*sonnet-4-6*\",\"anthropic/*sonnet-5*\"],\"summarization\":[\"haiku\",\"gpt-5-mini\",\"gemini-flash-lite\",\"mini\"],\"veo\":[\"google/veo*\",\"gemini/veo*\"],\"vision\":[\"copilot/gemini-*image*\",\"google/gemini-*image*\",\"gemini/gemini-*image*\",\"copilot/gemini-*flash*\",\"google/gemini-*flash*\",\"gemini/gemini-*flash*\"]}},\"container\":{\"imageTag\":\"0.27.43,squid=sha256:26be5e0b8c8f4c41c8a59126b29bb5d80b07253597472ded2a16bdd75abcbf9d,agent=sha256:04e2d1987a565000a8f114b89d806ae7a3864dd4f944be65275b28c93d8690e6,api-proxy=sha256:d85f57975af5ea23af4996e41ed73fbc8f5b4a47402472bfe82e508f352cb0c1,cli-proxy=sha256:65c45ea2967984d0024f3df61bc71335658a77ede96c8d9665da7a5f33a795ab\",\"agentTimeout\":15},\"logging\":{\"proxyLogsDir\":\"/tmp/gh-aw/sandbox/firewall/logs\",\"auditDir\":\"/tmp/gh-aw/sandbox/firewall/audit\"}}" > "${RUNNER_TEMP}/gh-aw/awf-config.json" cp "${RUNNER_TEMP}/gh-aw/awf-config.json" /tmp/gh-aw/awf-config.json export GH_AW_MODELS_JSON_PATH="/tmp/gh-aw/models.json" GH_AW_DOCKER_HOST="" @@ -1071,7 +1075,7 @@ jobs: GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} GH_AW_TIMEOUT_MINUTES: 15 - GH_AW_VERSION: v0.84.2 + GH_AW_VERSION: v0.84.3 GITHUB_API_URL: ${{ github.api_url }} GITHUB_AW: true GITHUB_COPILOT_INTEGRATION_ID: agentic-workflows @@ -1272,7 +1276,7 @@ jobs: steps: - name: Setup Scripts id: setup - uses: github/gh-aw-actions/setup@fd783ac87efde5e0c0e05d593f1906ea25b5d92e # v0.84.2 + uses: github/gh-aw-actions/setup@c863074b673419603d146aab585e2986ef08deec # v0.84.3 with: destination: ${{ runner.temp }}/gh-aw/actions job-name: ${{ github.job }} @@ -1309,30 +1313,7 @@ jobs: - name: Collect usage artifact files if: always() continue-on-error: true - run: | - mkdir -p /tmp/gh-aw/usage/agent /tmp/gh-aw/usage/detection - echo "Usage artifact source file status:" - for file in /tmp/gh-aw/aw_info.json /tmp/gh-aw/aw-info.jsonl /tmp/gh-aw/agent_usage.json /tmp/gh-aw/agent_usage.jsonl /tmp/gh-aw/detection_usage.jsonl /tmp/gh-aw/evals/evals.jsonl /tmp/gh-aw/github_rate_limits.jsonl /tmp/gh-aw/sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/threat-detection/sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/threat-detection/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/threat-detection/sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl; do - [ -f "$file" ] && echo "FOUND: $file" || echo "MISSING: $file" - done - [ -f /tmp/gh-aw/aw_info.json ] && cp /tmp/gh-aw/aw_info.json /tmp/gh-aw/usage/aw_info.json || true - [ -f /tmp/gh-aw/aw-info.jsonl ] && cp /tmp/gh-aw/aw-info.jsonl /tmp/gh-aw/usage/aw-info.jsonl || true - [ -f /tmp/gh-aw/agent_usage.json ] && cp /tmp/gh-aw/agent_usage.json /tmp/gh-aw/usage/agent_usage.json || true - [ -f /tmp/gh-aw/agent_usage.jsonl ] && cp /tmp/gh-aw/agent_usage.jsonl /tmp/gh-aw/usage/agent_usage.jsonl || true - [ -f /tmp/gh-aw/detection_usage.jsonl ] && cp /tmp/gh-aw/detection_usage.jsonl /tmp/gh-aw/usage/detection_usage.jsonl || true - [ -f /tmp/gh-aw/evals/evals.jsonl ] && cp /tmp/gh-aw/evals/evals.jsonl /tmp/gh-aw/usage/evals.jsonl || true - [ -f /tmp/gh-aw/github_rate_limits.jsonl ] && cp /tmp/gh-aw/github_rate_limits.jsonl /tmp/gh-aw/usage/github_rate_limits.jsonl || true - [ -s /tmp/gh-aw/sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl ] && cp /tmp/gh-aw/sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/usage/agent/token_usage.jsonl || true - [ -s /tmp/gh-aw/sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl ] && cp /tmp/gh-aw/sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/usage/agent/token_usage.jsonl || true - [ -s /tmp/gh-aw/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl ] && cp /tmp/gh-aw/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/usage/agent/token_usage.jsonl || true - [ -s /tmp/gh-aw/threat-detection/sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl ] && cp /tmp/gh-aw/threat-detection/sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/usage/detection/token_usage.jsonl || true - [ -s /tmp/gh-aw/threat-detection/sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl ] && cp /tmp/gh-aw/threat-detection/sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/usage/detection/token_usage.jsonl || true - [ -s /tmp/gh-aw/threat-detection/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl ] && cp /tmp/gh-aw/threat-detection/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/usage/detection/token_usage.jsonl || true - [ -f /tmp/gh-aw/usage/agent/token_usage.jsonl ] || : > /tmp/gh-aw/usage/agent/token_usage.jsonl - [ -f /tmp/gh-aw/usage/detection/token_usage.jsonl ] || : > /tmp/gh-aw/usage/detection/token_usage.jsonl - mkdir -p /tmp/gh-aw/usage/activity - node "${RUNNER_TEMP}/gh-aw/actions/generate_usage_activity_summary.cjs" - find /tmp/gh-aw/usage -type f -print | sort + run: bash "${RUNNER_TEMP}/gh-aw/actions/collect_usage_artifact_files.sh" - name: Upload usage artifact if: always() continue-on-error: true @@ -1545,7 +1526,7 @@ jobs: steps: - name: Setup Scripts id: setup - uses: github/gh-aw-actions/setup@fd783ac87efde5e0c0e05d593f1906ea25b5d92e # v0.84.2 + uses: github/gh-aw-actions/setup@c863074b673419603d146aab585e2986ef08deec # v0.84.3 with: destination: ${{ runner.temp }}/gh-aw/actions job-name: ${{ github.job }} diff --git a/.github/workflows/smoke-docker-sbx.md b/.github/workflows/smoke-docker-sbx.md index b594b636c..80efc2439 100644 --- a/.github/workflows/smoke-docker-sbx.md +++ b/.github/workflows/smoke-docker-sbx.md @@ -145,13 +145,13 @@ Verify by running `cat` on the file path using bash to confirm it exists. ## Output (MANDATORY) -**If triggered by a pull request** (check: `${{ github.event_name }}` equals "pull_request"), you MUST call `add_comment` to post a **very brief** comment (max 5-10 lines) on the current pull request with: +**If triggered by a pull request** (check: `${{ github.event_name }}` equals "pull_request"), you MUST call `add_comment` with `item_number: ${{ github.event.pull_request.number }}` to post a **very brief** comment (max 5-10 lines) on the current pull request. Do not rely on implicit triggering context, because Docker sbx may not propagate the event name into the safe-outputs gateway. Include: - PR titles only (no descriptions) - ✅ or ❌ for each test result - Overall status: PASS or FAIL - Mention the pull request author and any assignees If all tests pass on a pull request trigger: -- Use the `add_labels` safe-output tool to add the label `smoke-docker-sbx` to the pull request +- Use the `add_labels` safe-output tool with `item_number: ${{ github.event.pull_request.number }}` to add the label `smoke-docker-sbx` to the pull request **If triggered by workflow_dispatch or schedule** (no PR context), call `noop` with a concise PASS/FAIL summary instead. Do NOT attempt to add pull request comments or labels when there is no pull request. \ No newline at end of file diff --git a/.github/workflows/test-bounded-agent-runtime-matrix.yml b/.github/workflows/test-bounded-agent-runtime-matrix.yml new file mode 100644 index 000000000..2ff38534e --- /dev/null +++ b/.github/workflows/test-bounded-agent-runtime-matrix.yml @@ -0,0 +1,93 @@ +name: Bounded-Agent Runtime Matrix + +on: + pull_request: + paths: + - 'containers/bounded-agent/**' + - 'scripts/ci/report-bounded-agent-runtime-matrix*' + - 'scripts/ci/probe-bounded-agent-primary-sbx.js' + - 'scripts/ci/smoke-bounded-agent-enclave.sh' + - 'src/bounded-agent/**' + - '.github/workflows/test-bounded-agent-runtime-matrix.yml' + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +jobs: + docker: + name: Docker enclave matrix + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '24' + package-manager-cache: false + - run: npm ci + - run: npm run build + - name: Report all nine cells and require Docker/Docker + run: node scripts/ci/report-bounded-agent-runtime-matrix.js --require docker/docker + - name: Run live Docker enclave smoke + run: bash scripts/ci/smoke-bounded-agent-enclave.sh docker + + gvisor: + name: gVisor enclave matrix + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Install registered runsc + run: | + set -euo pipefail + arch="$(uname -m)" + url="https://storage.googleapis.com/gvisor/releases/release/20250707.0/${arch}" + curl -fsSL "${url}/runsc" -o "$RUNNER_TEMP/runsc" + curl -fsSL "${url}/runsc.sha512" -o "$RUNNER_TEMP/runsc.sha512" + (cd "$RUNNER_TEMP" && sha512sum -c runsc.sha512) + sudo install -m 755 "$RUNNER_TEMP/runsc" /usr/local/bin/runsc + sudo runsc install + sudo systemctl restart docker + docker info --format '{{json .Runtimes}}' | grep -F '"runsc"' + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '24' + package-manager-cache: false + - run: npm ci + - run: npm run build + - name: Report all nine cells and require Docker/gVisor + run: node scripts/ci/report-bounded-agent-runtime-matrix.js --require docker/gvisor + - name: Run live gVisor enclave smoke + run: bash scripts/ci/smoke-bounded-agent-enclave.sh gvisor + + sbx-capability: + name: Docker Sandbox capability gate + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '24' + package-manager-cache: false + - run: npm ci + - run: npm run build + - name: Require explicit blocked result without fallback + run: | + set -euo pipefail + report="$(node scripts/ci/report-bounded-agent-runtime-matrix.js)" + printf '%s\n' "$report" + rows="$(printf '%s\n' "$report" | grep -cE '^\| (docker|gvisor|sbx) \|')" + test "$rows" -eq 9 + printf '%s\n' "$report" | grep -F '| docker | sbx | BLOCKED |' + printf '%s\n' "$report" | grep -F '| gvisor | sbx | BLOCKED |' + printf '%s\n' "$report" | grep -F '| sbx | sbx | BLOCKED |' diff --git a/.gitignore b/.gitignore index 232fcf31c..9dec2a740 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,6 @@ release/ # AWF skip-cache files (generated by gh-aw tooling) paper-data/.skip-cache.json paper-data/.gh-aw-skip-cache.json + +# Python bytecode from local enclave-bootstrap syntax checks +__pycache__/ diff --git a/CLAUDE.md b/CLAUDE.md index 25e1a1bdc..d742d7126 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -39,6 +39,20 @@ The system is orchestrated by `src/cli.ts` and managed by `src/docker-manager.ts - Trusted host staging (`src/bounded-query/staging.ts`) materializes an immutable seed per configured repo *before* the agent starts, using `GH_TOKEN`/`GITHUB_TOKEN` only in a child-process env — never in argv, a URL, a log, or the compose file - See [docs/awf-config-spec.md](docs/awf-config-spec.md) §14 for the full model, including per-repository information-budget accounting and residual channels +**5. Bounded-Agent Enclave (optional)** — `containers/bounded-agent/`, API-proxy-only network +- Enabled via `boundedAgents.enabled` in the AWF config file (config-only; there is no CLI flag family) +- Agentic sibling of bounded queries: a trusted broker runs a **fixed, AWF-authored model loop** in a single-use enclave instead of an agent-authored script +- Requires `--enable-api-proxy` plus a configured `profile`/`model` route (`openai` or `anthropic`); preflight fails closed otherwise +- Broker (`awf-bounded-agent-broker`) is `network_mode: none` and never joins the enclave network; it receives the Docker socket only to launch enclaves +- Enclaves join **only** the dedicated `internal` `awf-bounded-agent` network (172.31.0.0/24). A dedicated API-proxy instance with private telemetry joins it at a fixed address/alias and is the enclave's only upstream egress. No primary agent, Squid, general proxy, broker, safe outputs, MCP gateway, or CLI proxy is on that network. +- Enclaves run `--read-only` with the immutable seed bind-mounted `ro`, bounded tmpfs for work/result/`/tmp`, fixed non-root UID/GID, `--cap-drop ALL`, `no-new-privileges`, seccomp, and memory/CPU/PID/file-size/timeout bounds; every container is labelled `awf.bounded-agent.run=` for deterministic orphan cleanup +- Separate private root (`/var/tmp/awf-bounded-agent-private-*`) and a **separate ledger** from bounded queries +- Agent surface: `bounded-agent` command at `/tmp/awf-lib/bounded-agent` plus a generated read-only `SKILL.md`; accepts only `--repo`, `--schema`, and task text on stdin +- Image build context is `containers/` (not `containers/bounded-agent/`) because the broker reuses the shared PR1 `bounded-execution` foundation and sandbox seccomp profile under `containers/bounded-query/` +- `runtime: "sbx"` is schema-accepted but fails closed with a not-yet-implemented capability error; `gvisor` requires an exactly registered `runsc` and never downgrades +- **Provider disclosure caveat:** repository-derived content reaches the configured model provider through the API proxy. The ledger bounds what the *calling agent* learns, not what the *provider* sees. +- See [docs/bounded-agents.md](docs/bounded-agents.md) and [docs/awf-config-spec.md](docs/awf-config-spec.md) §15 + ### Documentation Files - **[README.md](README.md)** - Main project documentation and usage guide @@ -47,6 +61,8 @@ The system is orchestrated by `src/cli.ts` and managed by `src/docker-manager.ts - **[docs/logging_quickref.md](docs/logging_quickref.md)** - Quick reference for log queries and monitoring - **[docs/releasing.md](docs/releasing.md)** - Release process and versioning instructions - **[docs/INTEGRATION-TESTS.md](docs/INTEGRATION-TESTS.md)** - Integration test coverage guide with gap analysis +- **[docs/bounded-queries.md](docs/bounded-queries.md)** - Bounded-query (no-network script sandbox) guide +- **[docs/bounded-agents.md](docs/bounded-agents.md)** - Bounded-agent (API-proxy-only enclave) guide and threat model ## Development Workflow diff --git a/README.md b/README.md index 86029e798..d07006ab6 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,7 @@ See [GitHub Actions](docs/github_actions.md) for advanced setup and `awf logs su - [AWF config schema](docs/awf-config.schema.json) — machine-readable JSON Schema for JSON/YAML configs (also published as a [versioned release asset](https://github.com/github/gh-aw-firewall/releases/latest/download/awf-config.schema.json) for IDE autocomplete) - [AWF config spec](docs/awf-config-spec.md) — normative processing and precedence rules for tooling/compiler integration - [Bounded queries](docs/bounded-queries.md) — run information-budgeted queries against private repositories without exposing their contents +- [Bounded agents](docs/bounded-agents.md) — delegate finite-schema repository analysis to API-proxy-only Docker or gVisor enclaves - [Audit log schema](schemas/audit.schema.json) — JSON Schema for L7 traffic audit records (`audit.jsonl`) - [Token usage schema](schemas/token-usage.schema.json) — JSON Schema for per-call token usage records (`token-usage.jsonl`) - [Schemas README](schemas/README.md) — versioning policy, record identification, and validation examples diff --git a/containers/agent/Dockerfile b/containers/agent/Dockerfile index 4a2a59d28..be4314e33 100644 --- a/containers/agent/Dockerfile +++ b/containers/agent/Dockerfile @@ -273,7 +273,8 @@ RUN if ! getent group awfuser >/dev/null 2>&1; then \ # Copy iptables setup script, PID logger, API proxy health check, Claude key helper, # gh CLI proxy wrapper (used when --enable-cli-proxy is active), and the -# bounded-query wrapper (installed as `bounded-query` when bounded queries are enabled) +# bounded-query wrapper (installed as `bounded-query` when bounded queries are enabled), +# and the bounded-agent wrapper (installed as `bounded-agent` when bounded agents are enabled) COPY setup-iptables.sh /usr/local/bin/setup-iptables.sh COPY entrypoint.sh /usr/local/bin/entrypoint.sh COPY pid-logger.sh /usr/local/bin/pid-logger.sh @@ -281,7 +282,8 @@ COPY api-proxy-health-check.sh /usr/local/bin/api-proxy-health-check.sh COPY get-claude-key.sh /usr/local/bin/get-claude-key.sh COPY gh-cli-proxy-wrapper.sh /usr/local/bin/gh-cli-proxy-wrapper.sh COPY bounded-query-wrapper.sh /usr/local/bin/bounded-query-wrapper.sh -RUN chmod +x /usr/local/bin/setup-iptables.sh /usr/local/bin/entrypoint.sh /usr/local/bin/pid-logger.sh /usr/local/bin/api-proxy-health-check.sh /usr/local/bin/get-claude-key.sh /usr/local/bin/gh-cli-proxy-wrapper.sh /usr/local/bin/bounded-query-wrapper.sh +COPY bounded-agent-wrapper.sh /usr/local/bin/bounded-agent-wrapper.sh +RUN chmod +x /usr/local/bin/setup-iptables.sh /usr/local/bin/entrypoint.sh /usr/local/bin/pid-logger.sh /usr/local/bin/api-proxy-health-check.sh /usr/local/bin/get-claude-key.sh /usr/local/bin/gh-cli-proxy-wrapper.sh /usr/local/bin/bounded-query-wrapper.sh /usr/local/bin/bounded-agent-wrapper.sh # Copy pre-built one-shot-token library from rust-builder stage # This prevents tokens from being read multiple times (e.g., by malicious code) diff --git a/containers/agent/bounded-agent-wrapper.sh b/containers/agent/bounded-agent-wrapper.sh new file mode 100644 index 000000000..1c7373176 --- /dev/null +++ b/containers/agent/bounded-agent-wrapper.sh @@ -0,0 +1,152 @@ +#!/bin/sh +# /usr/local/bin/bounded-agent +# +# Agent-facing bounded-agent CLI (protocol v1). +# +# Forwards a *narrow* request to the trusted bounded-agent broker over either +# the Compose Unix socket or the authenticated sbx HTTP ingress. Like +# bounded-query-wrapper.sh, the API is deliberately +# far narrower than a general tool: this wrapper cannot express a command, an +# image, an executable, a path, a URL, a ref, a mount, an environment variable, +# an endpoint, a network, a proxy, a credential, a runtime, a timeout, a +# resource limit, a model, a provider, a system prompt, or a tool definition. +# It accepts exactly: +# +# --repo owner/repo (exactly once) +# --schema '' (exactly once; a finite response schema, see +# src/bounded-execution/finite-disclosure.ts) +# the bounded task text on stdin +# +# Output contract: exactly one line of canonical JSON on stdout, nothing on +# stderr, and exit status 0 — for every outcome and for every failure. +# Transport, framing, and validation failures all produce the same local +# {"status":"error"} so the agent cannot distinguish them by exit status. The +# remaining information budget is never disclosed. +# +# Dependencies: curl, base64 (both already required/available in the agent +# image). + +CANONICAL_ERROR='{"status":"error"}' +SOCKET="${AWF_BOUNDED_AGENT_SOCKET:-}" +ENDPOINT="${AWF_BOUNDED_AGENT_ENDPOINT:-}" +CAPABILITY="${AWF_BOUNDED_AGENT_CAPABILITY:-}" +PROTOCOL_VERSION=1 +# Keep in sync with MAX_SCHEMA_BYTES in src/bounded-execution/finite-disclosure.ts +# and containers/bounded-query/bounded-execution/finite-disclosure.js. +MAX_SCHEMA_BYTES=4096 + +emit_error() { + printf '%s\n' "$CANONICAL_ERROR" + exit 0 +} + +REPO="" +SCHEMA="" +HAVE_REPO=0 +HAVE_SCHEMA=0 + +while [ $# -gt 0 ]; do + case "$1" in + --repo) + [ $# -ge 2 ] || emit_error + [ "$HAVE_REPO" -eq 0 ] || emit_error + REPO="$2" + HAVE_REPO=1 + shift 2 + ;; + --schema) + [ $# -ge 2 ] || emit_error + [ "$HAVE_SCHEMA" -eq 0 ] || emit_error + SCHEMA="$2" + HAVE_SCHEMA=1 + shift 2 + ;; + *) + # Any other flag, any `--flag=value` form, and any positional argument + # is an unsupported control. + emit_error + ;; + esac +done + +[ "$HAVE_REPO" -eq 1 ] || emit_error +[ "$HAVE_SCHEMA" -eq 1 ] || emit_error + +printf '%s' "$REPO" | LC_ALL=C grep -Eq '^[A-Za-z0-9][A-Za-z0-9-]{0,38}/[A-Za-z0-9._-]{1,100}$' || emit_error +case "$REPO" in + *..* ) emit_error ;; +esac + +[ -n "$SCHEMA" ] || emit_error +[ "$(printf '%s' "$SCHEMA" | wc -c)" -le "$MAX_SCHEMA_BYTES" ] || emit_error + +# base64url, no padding: standard base64 with `+/` -> `-_`, `=` stripped, and +# newlines removed (wrapping width varies across base64 implementations). +SCHEMA_B64=$(printf '%s' "$SCHEMA" | base64 | tr -d '\n' | tr '+/' '-_' | tr -d '=') || emit_error + +# The task must arrive on stdin; an interactive terminal means no task. +[ ! -t 0 ] || emit_error + +if [ -n "$SOCKET" ] && [ -z "$ENDPOINT" ] && [ -z "$CAPABILITY" ]; then + [ -S "$SOCKET" ] || emit_error + RESPONSE=$( + curl --silent --show-error \ + --noproxy '*' \ + --unix-socket "$SOCKET" \ + --max-time 660 \ + -X POST \ + -H "Expect:" \ + -H "Content-Type: application/octet-stream" \ + -H "X-AWF-Agent-Version: ${PROTOCOL_VERSION}" \ + -H "X-AWF-Repo: ${REPO}" \ + -H "X-AWF-Schema-B64: ${SCHEMA_B64}" \ + --data-binary @- \ + "http://localhost/query" 2>/dev/null + ) || emit_error +elif [ -z "$SOCKET" ] && [ -n "$ENDPOINT" ] && [ -n "$CAPABILITY" ]; then + case "$ENDPOINT" in + http://host.docker.internal:*/query) + PORT="${ENDPOINT#http://host.docker.internal:}" + PORT="${PORT%/query}" + printf '%s' "$PORT" | LC_ALL=C grep -Eq '^[0-9]{1,5}$' || emit_error + [ "$PORT" -ge 1 ] 2>/dev/null || emit_error + [ "$PORT" -le 65535 ] 2>/dev/null || emit_error + ;; + *) emit_error ;; + esac + printf '%s' "$CAPABILITY" | LC_ALL=C grep -Eq '^[0-9a-f]{64}$' || emit_error + RESPONSE=$( + curl --silent --show-error \ + --noproxy '*' \ + --max-time 660 \ + -X POST \ + -H "Expect:" \ + -H "Content-Type: application/octet-stream" \ + -H "X-AWF-Capability: ${CAPABILITY}" \ + -H "X-AWF-Agent-Version: ${PROTOCOL_VERSION}" \ + -H "X-AWF-Repo: ${REPO}" \ + -H "X-AWF-Schema-B64: ${SCHEMA_B64}" \ + --data-binary @- \ + "$ENDPOINT" 2>/dev/null + ) || emit_error +else + emit_error +fi + +# Pass the broker's canonical response through unmodified, but only if it has +# one of the two shapes the protocol ever produces. Anything else (a dead or +# misbehaving broker, a transport-level fragment) is treated as a failure +# rather than forwarded verbatim. +case "$RESPONSE" in + '{"status":"error"}') + printf '%s\n' "$RESPONSE" + exit 0 + ;; + '{"status":"ok","result":'*'}') + printf '%s\n' "$RESPONSE" + exit 0 + ;; + *) + emit_error + ;; +esac diff --git a/containers/agent/entrypoint.sh b/containers/agent/entrypoint.sh index 52540a302..d9e0957a1 100644 --- a/containers/agent/entrypoint.sh +++ b/containers/agent/entrypoint.sh @@ -662,6 +662,35 @@ copy_agent_helper_scripts() { echo "[entrypoint][WARN] Could not install bounded-query SKILL.md" fi fi + + # Activate the bounded-agent CLI when the bounded-agent broker socket is + # present. Same mechanism and PATH entry as the bounded-query CLI. + if [ -n "$AWF_BOUNDED_AGENT_SOCKET" ] && [ -f /usr/local/bin/bounded-agent-wrapper.sh ]; then + if mkdir -p /host/tmp/awf-lib 2>/dev/null; then + if cp /usr/local/bin/bounded-agent-wrapper.sh /host/tmp/awf-lib/bounded-agent 2>/dev/null && \ + chmod +x /host/tmp/awf-lib/bounded-agent 2>/dev/null; then + echo "[entrypoint] bounded-agent CLI installed at /tmp/awf-lib/bounded-agent (inside chroot)" + case ":${AWF_HOST_PATH:-$PATH}:" in + *":/tmp/awf-lib:"*) ;; + *) export AWF_HOST_PATH="/tmp/awf-lib:${AWF_HOST_PATH:-$PATH}" ;; + esac + else + echo "[entrypoint][WARN] Could not install bounded-agent CLI" + fi + fi + fi + + # Install the bounded-agent SKILL.md alongside the bounded-query one. + if [ -n "$AWF_BOUNDED_AGENT_SKILL" ] && [ -f "$AWF_BOUNDED_AGENT_SKILL" ] && \ + [ -n "$SYNTH_HOME" ]; then + AGENT_SKILL_DEST_DIR="/host${SYNTH_HOME}/.github/skills/bounded-agent" + if mkdir -p "$AGENT_SKILL_DEST_DIR" 2>/dev/null && \ + cp "$AWF_BOUNDED_AGENT_SKILL" "$AGENT_SKILL_DEST_DIR/SKILL.md" 2>/dev/null; then + echo "[entrypoint] bounded-agent SKILL.md installed at ${SYNTH_HOME}/.github/skills/bounded-agent/SKILL.md (inside chroot)" + else + echo "[entrypoint][WARN] Could not install bounded-agent SKILL.md" + fi + fi } copy_dind_runner_binary() { @@ -1408,6 +1437,32 @@ run_non_chroot_command() { fi fi + # Activate the bounded-agent CLI in non-chroot mode. + if [ -n "$AWF_BOUNDED_AGENT_SOCKET" ] && [ -f /usr/local/bin/bounded-agent-wrapper.sh ]; then + mkdir -p /tmp/awf-lib + if cp /usr/local/bin/bounded-agent-wrapper.sh /tmp/awf-lib/bounded-agent 2>/dev/null && \ + chmod +x /tmp/awf-lib/bounded-agent 2>/dev/null; then + case ":${PATH}:" in + *":/tmp/awf-lib:"*) ;; + *) export PATH="/tmp/awf-lib:${PATH}" ;; + esac + echo "[entrypoint] bounded-agent CLI installed at /tmp/awf-lib/bounded-agent" + else + echo "[entrypoint][WARN] Could not install bounded-agent CLI" + fi + fi + + # Install the bounded-agent SKILL.md (non-chroot mode). + if [ -n "$AWF_BOUNDED_AGENT_SKILL" ] && [ -f "$AWF_BOUNDED_AGENT_SKILL" ]; then + AGENT_SKILL_DEST_DIR="${HOME}/.github/skills/bounded-agent" + if mkdir -p "$AGENT_SKILL_DEST_DIR" 2>/dev/null && \ + cp "$AWF_BOUNDED_AGENT_SKILL" "$AGENT_SKILL_DEST_DIR/SKILL.md" 2>/dev/null; then + echo "[entrypoint] bounded-agent SKILL.md installed at ${AGENT_SKILL_DEST_DIR}/SKILL.md" + else + echo "[entrypoint][WARN] Could not install bounded-agent SKILL.md" + fi + fi + # This prevents malicious code from modifying iptables rules or using chroot # Security note: capsh --drop removes capabilities from the bounding set, # preventing any process (even if it escalates to root) from acquiring them diff --git a/containers/bounded-agent/Dockerfile b/containers/bounded-agent/Dockerfile new file mode 100644 index 000000000..ddb5632c1 --- /dev/null +++ b/containers/bounded-agent/Dockerfile @@ -0,0 +1,95 @@ +# Bounded-agent image — multi-stage build producing two images: +# +# 1. `enclave` stage — minimal Python 3-only enclave rootfs published as +# `bounded-agent:*`. It contains exactly one AWF-authored program +# (`/usr/local/bin/run-bounded-agent`) and the Python standard library. +# There is no Node runtime, no docker-cli, no package manager, no `gh`, no +# git, no credential helper, and no host state. The enclave runs +# --read-only, --cap-drop ALL, unprivileged (UID 65534), with a seccomp +# profile and memory/CPU/PID/file-size bounds, and joins only the dedicated +# `internal` bounded-agent network whose sole other member is the AWF API +# proxy — so the absence of those binaries is defence-in-depth rather than +# the primary control. +# +# 2. `broker` (default) stage — published as `bounded-agent-broker:*`. +# Has Node + docker-cli to run the server and launch enclave containers. +# It runs with `network_mode: none` and is never a member of the enclave +# network. +# +# BUILD CONTEXT: `containers/` (not `containers/bounded-agent/`). The broker +# reuses the PR1 bounded-execution foundation and the audited sandbox seccomp +# profile that already live under `containers/bounded-query/`, and a wider +# context is preferred over duplicating a security-critical implementation. +# +# docker build -f bounded-agent/Dockerfile --target broker containers/ + +# ────────────────────────────────────────────────────────────────────────── +# enclave stage: Python 3 standard-library-only bootstrap. +# No Node, no docker-cli, no apk package manager, no gh, no git. +# ────────────────────────────────────────────────────────────────────────── +FROM python:3.12-alpine3.21 AS enclave + +RUN python3 -c 'import json, pathlib, re, urllib.request, sys; sys.exit(0)' \ + && test -x /usr/local/bin/python3 \ + && rm -f /sbin/apk + +COPY bounded-agent/enclave-entrypoint.py /usr/local/bin/run-bounded-agent +RUN chmod 0555 /usr/local/bin/run-bounded-agent \ + && python3 -m py_compile /usr/local/bin/run-bounded-agent \ + && rm -rf /usr/local/bin/__pycache__ + +# Pre-create mount points used by the enclave container so a missing bind +# mount fails loudly rather than silently materialising an empty directory. +RUN mkdir -p /agent /awf/seed + +# ────────────────────────────────────────────────────────────────────────── +# broker stage: trusted broker with Node + docker-cli (default build target) +# ────────────────────────────────────────────────────────────────────────── +FROM node:22.23.1-alpine3.24 AS broker + +# docker-cli — used by the broker to launch enclave containers +RUN apk add --no-cache docker-cli \ + && test -x /usr/bin/docker + +WORKDIR /opt/awf/broker +COPY bounded-agent/broker/ /opt/awf/broker/ +# Shared PR1 bounded-execution foundation, reused verbatim rather than copied +# into a second source tree. +COPY bounded-query/bounded-execution/ /opt/awf/bounded-execution/ +# The audited no-network sandbox seccomp profile is reused for the enclave. +COPY bounded-query/query-seccomp.json /opt/awf/enclave-seccomp.json + +RUN chmod -R a-w /opt/awf \ + && node --check /opt/awf/broker/server.js \ + && node --check /opt/awf/broker/broker.js \ + && node --check /opt/awf/broker/config.js \ + && node --check /opt/awf/broker/framing.js \ + && node --check /opt/awf/broker/protocol.js \ + && node --check /opt/awf/broker/workspace.js \ + && node --check /opt/awf/broker/enclave-runner.js \ + && node --check /opt/awf/broker/enclave-runner-spec.js \ + && node --check /opt/awf/broker/docker-client.js \ + && node --check /opt/awf/broker/docker-enclave-runner.js \ + && node --check /opt/awf/broker/gvisor-enclave-runner.js \ + && node --check /opt/awf/broker/sbx-client.js \ + && node --check /opt/awf/broker/sbx-capability-probe.js \ + && node --check /opt/awf/broker/sbx-enclave-runner-spec.js \ + && node --check /opt/awf/broker/sbx-enclave-runner.js \ + && node --check /opt/awf/broker/runtime-telemetry.js \ + && node --check /opt/awf/broker/healthcheck.js \ + && node --check /opt/awf/bounded-execution/finite-disclosure.js \ + && node --check /opt/awf/bounded-execution/sensitivity-ledger.js \ + && node --check /opt/awf/bounded-execution/fixed-timing.js \ + && node --check /opt/awf/bounded-execution/protected-audit.js \ + && node --check /opt/awf/bounded-execution/repository-staging.js \ + && node --check /opt/awf/bounded-execution/index.js + +# Fixed broker-only mount points. +RUN mkdir -p /srv/awf/seeds /srv/awf/work /run/awf-bounded-agent /run/awf-bounded-agent-control /var/log/awf-bounded-agent + +# The broker is root only so it can hand the pre-created result file to the +# unprivileged enclave uid. Keep the default capability set dropped and restore +# only those filesystem duties in compose. +USER root + +ENTRYPOINT ["node", "/opt/awf/broker/server.js"] diff --git a/containers/bounded-agent/bounded-execution/finite-disclosure.js b/containers/bounded-agent/bounded-execution/finite-disclosure.js new file mode 100644 index 000000000..ec46c5fc1 --- /dev/null +++ b/containers/bounded-agent/bounded-execution/finite-disclosure.js @@ -0,0 +1,12 @@ +'use strict'; + +// Source-tree resolution shim — NOT shipped in the bounded-agent image. +// +// The published broker image receives the real PR1 bounded-execution +// foundation at /opt/awf/bounded-execution (see bounded-agent/Dockerfile, +// which COPYs containers/bounded-query/bounded-execution/ there). This file +// exists only so the same `../bounded-execution/finite-disclosure` specifier +// also resolves when the broker modules are required directly from the source +// tree (unit tests, `node --check`), without duplicating a security-critical +// implementation into a second directory. +module.exports = require('../../bounded-query/bounded-execution/finite-disclosure'); diff --git a/containers/bounded-agent/bounded-execution/fixed-timing.js b/containers/bounded-agent/bounded-execution/fixed-timing.js new file mode 100644 index 000000000..17f221bc2 --- /dev/null +++ b/containers/bounded-agent/bounded-execution/fixed-timing.js @@ -0,0 +1,12 @@ +'use strict'; + +// Source-tree resolution shim — NOT shipped in the bounded-agent image. +// +// The published broker image receives the real PR1 bounded-execution +// foundation at /opt/awf/bounded-execution (see bounded-agent/Dockerfile, +// which COPYs containers/bounded-query/bounded-execution/ there). This file +// exists only so the same `../bounded-execution/fixed-timing` specifier +// also resolves when the broker modules are required directly from the source +// tree (unit tests, `node --check`), without duplicating a security-critical +// implementation into a second directory. +module.exports = require('../../bounded-query/bounded-execution/fixed-timing'); diff --git a/containers/bounded-agent/bounded-execution/index.js b/containers/bounded-agent/bounded-execution/index.js new file mode 100644 index 000000000..ce9cd4de3 --- /dev/null +++ b/containers/bounded-agent/bounded-execution/index.js @@ -0,0 +1,12 @@ +'use strict'; + +// Source-tree resolution shim — NOT shipped in the bounded-agent image. +// +// The published broker image receives the real PR1 bounded-execution +// foundation at /opt/awf/bounded-execution (see bounded-agent/Dockerfile, +// which COPYs containers/bounded-query/bounded-execution/ there). This file +// exists only so the same `../bounded-execution/index` specifier +// also resolves when the broker modules are required directly from the source +// tree (unit tests, `node --check`), without duplicating a security-critical +// implementation into a second directory. +module.exports = require('../../bounded-query/bounded-execution/index'); diff --git a/containers/bounded-agent/bounded-execution/protected-audit.js b/containers/bounded-agent/bounded-execution/protected-audit.js new file mode 100644 index 000000000..88eab3776 --- /dev/null +++ b/containers/bounded-agent/bounded-execution/protected-audit.js @@ -0,0 +1,12 @@ +'use strict'; + +// Source-tree resolution shim — NOT shipped in the bounded-agent image. +// +// The published broker image receives the real PR1 bounded-execution +// foundation at /opt/awf/bounded-execution (see bounded-agent/Dockerfile, +// which COPYs containers/bounded-query/bounded-execution/ there). This file +// exists only so the same `../bounded-execution/protected-audit` specifier +// also resolves when the broker modules are required directly from the source +// tree (unit tests, `node --check`), without duplicating a security-critical +// implementation into a second directory. +module.exports = require('../../bounded-query/bounded-execution/protected-audit'); diff --git a/containers/bounded-agent/bounded-execution/repository-staging.js b/containers/bounded-agent/bounded-execution/repository-staging.js new file mode 100644 index 000000000..e468b5f2e --- /dev/null +++ b/containers/bounded-agent/bounded-execution/repository-staging.js @@ -0,0 +1,12 @@ +'use strict'; + +// Source-tree resolution shim — NOT shipped in the bounded-agent image. +// +// The published broker image receives the real PR1 bounded-execution +// foundation at /opt/awf/bounded-execution (see bounded-agent/Dockerfile, +// which COPYs containers/bounded-query/bounded-execution/ there). This file +// exists only so the same `../bounded-execution/repository-staging` specifier +// also resolves when the broker modules are required directly from the source +// tree (unit tests, `node --check`), without duplicating a security-critical +// implementation into a second directory. +module.exports = require('../../bounded-query/bounded-execution/repository-staging'); diff --git a/containers/bounded-agent/bounded-execution/sensitivity-ledger.js b/containers/bounded-agent/bounded-execution/sensitivity-ledger.js new file mode 100644 index 000000000..77760ebc8 --- /dev/null +++ b/containers/bounded-agent/bounded-execution/sensitivity-ledger.js @@ -0,0 +1,12 @@ +'use strict'; + +// Source-tree resolution shim — NOT shipped in the bounded-agent image. +// +// The published broker image receives the real PR1 bounded-execution +// foundation at /opt/awf/bounded-execution (see bounded-agent/Dockerfile, +// which COPYs containers/bounded-query/bounded-execution/ there). This file +// exists only so the same `../bounded-execution/sensitivity-ledger` specifier +// also resolves when the broker modules are required directly from the source +// tree (unit tests, `node --check`), without duplicating a security-critical +// implementation into a second directory. +module.exports = require('../../bounded-query/bounded-execution/sensitivity-ledger'); diff --git a/containers/bounded-agent/bounded-execution/sensitivity-policy.js b/containers/bounded-agent/bounded-execution/sensitivity-policy.js new file mode 100644 index 000000000..a97601d15 --- /dev/null +++ b/containers/bounded-agent/bounded-execution/sensitivity-policy.js @@ -0,0 +1,12 @@ +'use strict'; + +// Source-tree resolution shim — NOT shipped in the bounded-agent image. +// +// The published broker image receives the real PR1 bounded-execution +// foundation at /opt/awf/bounded-execution (see bounded-agent/Dockerfile, +// which COPYs containers/bounded-query/bounded-execution/ there). This file +// exists only so the same `../bounded-execution/sensitivity-policy` specifier +// also resolves when the broker modules are required directly from the source +// tree (unit tests, `node --check`), without duplicating a security-critical +// implementation into a second directory. +module.exports = require('../../bounded-query/bounded-execution/sensitivity-policy'); diff --git a/containers/bounded-agent/broker/audit.js b/containers/bounded-agent/broker/audit.js new file mode 100644 index 000000000..183c7083b --- /dev/null +++ b/containers/bounded-agent/broker/audit.js @@ -0,0 +1,19 @@ +'use strict'; + +const { createAuditLog: createProtectedAuditLog } = require('../bounded-execution/protected-audit'); + +/** Filename of the bounded-agent protected audit log. */ +const BOUNDED_AGENT_AUDIT_FILENAME = 'bounded-agent.jsonl'; + +/** + * Protected bounded-agent diagnostics. + * + * Uses the shared PR1 protected-audit primitive with a bounded-agent-specific + * filename so the two subsystems' audit trails are never confused, even though + * they already live in disjoint broker-private roots. + */ +function createAuditLog(auditDir) { + return createProtectedAuditLog(auditDir, BOUNDED_AGENT_AUDIT_FILENAME); +} + +module.exports = { BOUNDED_AGENT_AUDIT_FILENAME, createAuditLog }; diff --git a/containers/bounded-agent/broker/broker.js b/containers/bounded-agent/broker/broker.js new file mode 100644 index 000000000..eb6b48e36 --- /dev/null +++ b/containers/bounded-agent/broker/broker.js @@ -0,0 +1,277 @@ +'use strict'; + +const crypto = require('crypto'); +const { + CANONICAL_ERROR_JSON, + canonicalOkJson, + parseAndValidateQueryOutput, + queryBitsForSchema, +} = require('./protocol'); +const { validateBoundedAgentRequest } = require('./framing'); +const { createLedger } = require('./ledger'); +const { createRealClock, waitForBucket } = require('./scheduler'); +const defaultWorkspace = require('./workspace'); + +/** + * The trusted bounded-agent broker. + * + * Responsibilities, in order, for every request: + * + * 1. consume one unit of the per-run *invocation* budget (`maxInvocations`, + * an operational cap independent of the bits below) — atomically, since + * Node's single-threaded event loop makes the check-and-increment + * indivisible because there is no `await` between them; + * 2. validate the request — repository selector, finite response schema, and + * byte-bounded task text — against the fixed protocol, rejecting every + * unknown or forbidden control, *before* anything is created; + * 3. compute that schema's maximum complete-transcript information charge + * (status bit + schema bits + timing bits) and atomically debit it from + * the repository's per-run bit ledger **before** any workspace is + * materialized or any container is launched; + * 4. map the normalized repo id through AWF's static seed map to an opaque, + * immutable seed the caller never sees or names; + * 5. launch a fresh, uniquely named, labelled enclave with a fixed argument + * vector on the dedicated bounded-agent network; + * 6. strictly validate the enclave's dedicated bounded result file against + * the approved schema and canonically re-serialize it — raw enclave bytes, + * stdout, stderr, transcript, and exit status never reach the caller; + * 7. destroy the private workspace, then respond at the first timing bucket + * boundary at or after all secret-dependent processing completed. + * + * Every failure at every step produces the identical canonical + * `{"status":"error"}`. The reason is recorded in the protected audit log, + * which is never mounted into the agent or an enclave — and even there, the + * repository, the task, the transcript, the raw result, host paths, tokens, + * and provider payloads are never recorded. + * + * Invocations are serialized. That bounds concurrent resource use and removes + * any cross-invocation race in workspace creation/teardown/ledger access. + */ + +function createBroker(params) { + const { config, seedMap, runId, audit } = params; + const workspace = params.workspace || defaultWorkspace; + if (!params.runner) { + throw new Error('createBroker requires a trusted EnclaveRunner'); + } + const runner = params.runner; + const clock = params.clock || createRealClock(); + // A ledger built from this broker's own seed map. Bounded agents never share + // a ledger with bounded queries: the two brokers are separate processes with + // separate seed maps and separate private roots. + const ledger = params.ledger || createLedger(seedMap); + const telemetry = params.telemetry || { emit() {} }; + + let invocationsUsed = 0; + let tail = Promise.resolve(); + let accepting = true; + + function emitInvocationTelemetry(category) { + telemetry.emit({ + primaryBackend: config.primaryBackend, + boundedAgentBackend: config.backend, + lifecycleClass: 'invocation', + capabilityState: 'supported', + category, + }); + } + + async function execute(request, respond) { + const invocationId = crypto.randomBytes(12).toString('hex'); + let responded = false; + const safeRespond = (json) => { + if (responded) return; + responded = true; + respond(json); + }; + + const validation = validateBoundedAgentRequest(request, { maxTaskBytes: config.maxTaskBytes }); + if (!validation.valid) { + audit.failure(invocationId, 'invalid-request', validation.errors.join('; ')); + emitInvocationTelemetry('invalid-request'); + safeRespond(CANONICAL_ERROR_JSON); + return; + } + const { privateRepo, schema, task } = validation.request; + const repoKey = privateRepo.toLowerCase(); + + const seed = seedMap.get(repoKey); + if (!seed) { + // Deliberately does not record which repository was requested. + audit.failure(invocationId, 'repo-not-allowed'); + emitInvocationTelemetry('repo-not-allowed'); + safeRespond(CANONICAL_ERROR_JSON); + return; + } + + // Compute and debit the charge for THIS invocation's schema *before* + // creating a workspace or launching an enclave. The charge covers the + // status and timing channels as well as the schema payload. + const charge = queryBitsForSchema(schema); + if (!ledger.tryDebit(repoKey, charge)) { + audit.failure(invocationId, 'bit-budget-exhausted', `charge=${charge}`); + emitInvocationTelemetry('bit-budget-exhausted'); + safeRespond(CANONICAL_ERROR_JSON); + return; + } + + // From here on the charge is committed (never refunded) and every response + // must be time-bucketed: enclave execution runs against secret repository + // content, so its latency alone is a signal. + const startMs = clock.nowMs(); + + let layout; + let failureReason; + let canonicalResult; + + try { + layout = workspace.createInvocationWorkspace({ config, invocationId, task, schema }); + } catch (error) { + failureReason = ['workspace-create-failed', error.message]; + } + + if (layout) { + const remainingMs = config.timeoutSeconds * 1000 - (clock.nowMs() - startMs); + if (remainingMs <= 0) { + failureReason = ['timeout', 'workspace-creation-overran-deadline']; + } else { + try { + const run = await runner.runEnclaveContainer({ + config, + runId, + invocationId, + seedId: seed.seedId, + timeoutMs: remainingMs, + }); + if (run.timedOut) { + failureReason = ['timeout']; + } else if (run.exitCode !== 0) { + failureReason = ['non-zero-exit']; + } else { + const raw = workspace.readEnclaveOutput(layout.outPath, config.maxOutputBytes); + if (raw === undefined) { + // Covers a missing file, an oversized file, invalid UTF-8, and + // any non-regular replacement (symlink/FIFO/device/socket). + failureReason = ['unreadable-output']; + } else { + const parsed = parseAndValidateQueryOutput(raw, schema); + if (!parsed.ok) { + failureReason = ['nonconformant-output']; + } else { + canonicalResult = parsed.canonical; + } + } + } + } catch (error) { + failureReason = ['launch-failed', error.message]; + } + } + } + + // Teardown is part of the observable operation and must complete before the + // timing bucket is chosen. + if (!safeDestroy(invocationId)) { + failureReason = ['cleanup-failed']; + canonicalResult = undefined; + } + + const elapsedMs = clock.nowMs() - startMs; + const { bucketMs, overflowed } = await waitForBucket(startMs, elapsedMs, clock); + + if (overflowed) { + // Fail closed: processing overran every configured bucket. Never emit a + // successful result at unbucketed timing. + audit.failure(invocationId, 'timing-bucket-overflow', failureReason ? failureReason[0] : undefined); + emitInvocationTelemetry('timing-bucket-overflow'); + safeRespond(CANONICAL_ERROR_JSON); + } else if (canonicalResult !== undefined) { + audit.invocation({ + invocationId, + // The repository name is deliberately absent; only its trusted + // sensitivity class and the charge are recorded. + sensitivity: seed.sensitivity, + bits: charge, + bucketMs, + }); + emitInvocationTelemetry('success'); + safeRespond(canonicalOkJson(canonicalResult)); + } else { + const category = failureReason ? failureReason[0] : 'unknown'; + audit.failure(invocationId, category, failureReason ? failureReason[1] : undefined); + emitInvocationTelemetry(category); + safeRespond(CANONICAL_ERROR_JSON); + } + } + + function safeDestroy(invocationId) { + try { + workspace.destroyInvocationWorkspace(config.workDir, invocationId); + return true; + } catch (error) { + audit.failure(invocationId, 'cleanup-failed', error.message); + return false; + } + } + + return { + /** Stops admitting new invocations while letting admitted work drain. */ + close() { + accepting = false; + }, + + /** + * Handles one request. `respond` is called exactly once with the canonical + * result JSON. Requests are queued so at most one enclave runs at a time. + */ + handle(request, respond) { + let responded = false; + const safeRespond = (json) => { + if (responded) return; + responded = true; + respond(json); + }; + + if (!accepting) { + safeRespond(CANONICAL_ERROR_JSON); + return Promise.resolve(); + } + + // The invocation-count cap is operational and independent of the bit + // ledger: it is consumed per *response*, so every response the agent + // observes — including a rejection — counts against it. + if (invocationsUsed >= config.maxInvocations) { + audit.failure('budget', 'invocation-count-exhausted', `max=${config.maxInvocations}`); + emitInvocationTelemetry('invocation-count-exhausted'); + safeRespond(CANONICAL_ERROR_JSON); + return Promise.resolve(); + } + invocationsUsed += 1; + + const queued = tail.then(() => execute(request, safeRespond)).catch((error) => { + audit.failure('queue', 'unexpected-error', error && error.message); + emitInvocationTelemetry('unexpected-error'); + safeRespond(CANONICAL_ERROR_JSON); + }); + tail = queued.then( + () => undefined, + () => undefined, + ); + return queued; + }, + + /** Resolves when every admitted invocation has finished broker-side work. */ + drain() { + return tail; + }, + + /** @internal Exposed for tests. */ + get invocationsUsed() { + return invocationsUsed; + }, + + /** @internal Exposed for tests. Never surfaced on the wire. */ + ledger, + }; +} + +module.exports = { createBroker }; diff --git a/containers/bounded-agent/broker/config.js b/containers/bounded-agent/broker/config.js new file mode 100644 index 000000000..7d5731511 --- /dev/null +++ b/containers/bounded-agent/broker/config.js @@ -0,0 +1,234 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const { MAX_QUERY_TIMEOUT_SECONDS, MAX_RESULT_BYTES } = require('./protocol'); +const { BOUNDED_QUERY_SENSITIVITY_RUN_BITS } = require('./sensitivity'); +const { parsePrivateRepositorySeedMap } = require('../bounded-execution/repository-staging'); + +/** + * Bounded-agent broker configuration. + * + * Everything here is supplied by AWF through the container environment and + * fixed mount points. Nothing in this file is influenced by a request: the + * caller cannot choose an image, a runtime, a network, an endpoint, a model, a + * profile, a path, a mount, a limit, or a timeout. + */ + +const SEEDS_DIR = '/srv/awf/seeds'; +const WORK_DIR = '/srv/awf/work'; +const SEED_MAP_PATH = '/srv/awf/seed-map.json'; +const SOCKET_DIR = '/run/awf-bounded-agent'; +const SOCKET_PATH = path.join(SOCKET_DIR, 'broker.sock'); +const CONTROL_DIR = '/run/awf-bounded-agent-control'; +const AUDIT_DIR = '/var/log/awf-bounded-agent'; +/** Broker-private readiness marker; the control directory is never agent-mounted. */ +const READY_PATH = path.join(CONTROL_DIR, 'broker.ready'); +const ENCLAVE_SECCOMP_PATH = '/opt/awf/enclave-seccomp.json'; + +/** Mount points inside the enclave container. Fixed, never caller-supplied. */ +const ENCLAVE_MOUNT_DIR = '/agent'; +const ENCLAVE_SEED_PATH = '/awf/seed'; +const ENCLAVE_TASK_PATH = '/awf/task.txt'; +const ENCLAVE_SCHEMA_PATH = '/awf/schema.json'; + +/** Unprivileged uid/gid the enclave process runs as. */ +const ENCLAVE_UID = 65534; +const ENCLAVE_GID = 65534; + +/** Hard ceiling on the caller-supplied task text, mirrored from the TS protocol. */ +const MAX_TASK_BYTES = 64 * 1024; + +const SUPPORTED_BACKENDS = new Set(['docker', 'gvisor', 'sbx']); +const SUPPORTED_PROFILES = new Set(['openai', 'anthropic']); +const PRIMARY_BACKENDS = new Set(['docker', 'gvisor', 'sbx']); +const SBX_CAPABILITY_PATH = path.join(CONTROL_DIR, 'sbx-ingress.json'); + +function requireEnv(name) { + const value = process.env[name]; + if (!value || value.length === 0) { + throw new Error(`Missing required environment variable: ${name}`); + } + return value; +} + +function parsePositiveInt(name, fallback) { + const raw = process.env[name]; + if (raw === undefined || raw === '') return fallback; + const parsed = Number.parseInt(raw, 10); + if (!Number.isInteger(parsed) || parsed < 1) { + throw new Error(`Environment variable ${name} must be a positive integer`); + } + return parsed; +} + +function parseBoundedInt(name, fallback, maximum) { + const parsed = parsePositiveInt(name, fallback); + if (parsed > maximum) { + throw new Error(`Environment variable ${name} must be at most ${maximum}`); + } + return parsed; +} + +/** + * Parses the per-invocation timeout, additionally re-enforcing (defense in + * depth; AWF's host-side preflight already rejects an out-of-range value + * before this container ever starts) that it preserves the final response + * bucket's post-processing margin. + */ +function parseTimeoutSeconds() { + const parsed = parsePositiveInt('AWF_BOUNDED_AGENT_TIMEOUT', 120); + if (parsed > MAX_QUERY_TIMEOUT_SECONDS) { + throw new Error( + `Environment variable AWF_BOUNDED_AGENT_TIMEOUT must be at most ${MAX_QUERY_TIMEOUT_SECONDS} seconds ` + + '(the final response bucket reserves one minute for termination, validation, and cleanup)', + ); + } + return parsed; +} + +function parseDockerSize(name, fallback) { + const value = process.env[name] || fallback; + if (!/^[1-9][0-9]*[bkmgBKMG]$/.test(value)) { + throw new Error(`${name} must be a Docker size limit (e.g. "512m")`); + } + return value; +} + +/** + * Loads the two capability tokens the broker's TCP listener requires on + * every request when reachability is via authenticated primary-sbx ingress + * (never used for the Unix-socket transport). Generated fresh per run on the + * trusted host only after runtime proofs succeed; never logged, telemetered, + * or written to any audit/skill surface. + */ +function loadSbxIngressCapabilities(capabilityPath) { + const parsed = JSON.parse(fs.readFileSync(capabilityPath, 'utf8')); + const pattern = /^[0-9a-f]{64}$/; + if ( + !parsed + || parsed.version !== 1 + || typeof parsed.query !== 'string' + || typeof parsed.probe !== 'string' + || !pattern.test(parsed.query) + || !pattern.test(parsed.probe) + || parsed.query === parsed.probe + ) { + throw new Error('SBX ingress capability file is malformed'); + } + return { query: parsed.query, probe: parsed.probe }; +} + +function loadConfig() { + const backend = requireEnv('AWF_BOUNDED_AGENT_BACKEND'); + if (!SUPPORTED_BACKENDS.has(backend)) { + throw new Error(`Unsupported AWF_BOUNDED_AGENT_BACKEND: ${backend}`); + } + + const profile = requireEnv('AWF_BOUNDED_AGENT_PROFILE'); + if (!SUPPORTED_PROFILES.has(profile)) { + throw new Error(`Unsupported AWF_BOUNDED_AGENT_PROFILE: ${profile}`); + } + + const apiEndpoint = requireEnv('AWF_BOUNDED_AGENT_API_ENDPOINT'); + if (!/^http:\/\/[0-9a-zA-Z.:-]+$/.test(apiEndpoint)) { + throw new Error('AWF_BOUNDED_AGENT_API_ENDPOINT must be a bare http origin'); + } + + const network = requireEnv('AWF_BOUNDED_AGENT_NETWORK'); + if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}$/.test(network)) { + throw new Error('AWF_BOUNDED_AGENT_NETWORK is not a Docker network name'); + } + + const primaryBackend = requireEnv('AWF_BOUNDED_AGENT_PRIMARY_BACKEND'); + if (!PRIMARY_BACKENDS.has(primaryBackend)) { + throw new Error(`Unsupported AWF_BOUNDED_AGENT_PRIMARY_BACKEND: ${primaryBackend}`); + } + + const tcpPortRaw = process.env.AWF_BOUNDED_AGENT_TCP_PORT; + const tcpPort = tcpPortRaw === undefined ? undefined : parsePositiveInt('AWF_BOUNDED_AGENT_TCP_PORT'); + if (tcpPort !== undefined && tcpPort > 65535) { + throw new Error('AWF_BOUNDED_AGENT_TCP_PORT must be a valid TCP port'); + } + + // sbx and Docker daemons can have different filesystem namespaces + // (ARC/DinD); never reuse the Docker-daemon-visible paths for sbx mounts. + const sbxWorkDir = backend === 'sbx' ? requireEnv('AWF_BOUNDED_AGENT_SBX_WORK_DIR') : undefined; + const sbxSeedsDir = backend === 'sbx' ? requireEnv('AWF_BOUNDED_AGENT_SBX_SEEDS_DIR') : undefined; + + return { + seedsDir: SEEDS_DIR, + workDir: WORK_DIR, + seedMapPath: SEED_MAP_PATH, + socketDir: SOCKET_DIR, + socketPath: SOCKET_PATH, + controlDir: CONTROL_DIR, + readyPath: READY_PATH, + auditDir: AUDIT_DIR, + enclaveSeccompPath: ENCLAVE_SECCOMP_PATH, + enclaveMountDir: ENCLAVE_MOUNT_DIR, + enclaveSeedPath: ENCLAVE_SEED_PATH, + enclaveTaskPath: ENCLAVE_TASK_PATH, + enclaveSchemaPath: ENCLAVE_SCHEMA_PATH, + enclaveUid: ENCLAVE_UID, + enclaveGid: ENCLAVE_GID, + enclaveImage: requireEnv('AWF_BOUNDED_AGENT_IMAGE'), + backend, + profile, + model: requireEnv('AWF_BOUNDED_AGENT_MODEL'), + apiEndpoint, + network, + // The daemon resolves enclave bind-mount sources in *its* filesystem view, + // which is not necessarily the broker's (ARC/DinD split filesystems). + hostWorkDir: requireEnv('AWF_BOUNDED_AGENT_HOST_WORK_DIR'), + hostSeedsDir: requireEnv('AWF_BOUNDED_AGENT_HOST_SEEDS_DIR'), + sbxWorkDir, + sbxSeedsDir, + primaryBackend, + tcpPort, + sbxIngressCapabilities: tcpPort === undefined + ? undefined + : loadSbxIngressCapabilities(SBX_CAPABILITY_PATH), + timeoutSeconds: parseTimeoutSeconds(), + memoryLimit: parseDockerSize('AWF_BOUNDED_AGENT_MEMORY', '512m'), + tmpfsLimit: parseDockerSize('AWF_BOUNDED_AGENT_TMPFS', '64m'), + cpuLimit: process.env.AWF_BOUNDED_AGENT_CPUS || '1', + pidsLimit: parseBoundedInt('AWF_BOUNDED_AGENT_PIDS', 128, 4096), + maxOutputBytes: parseBoundedInt('AWF_BOUNDED_AGENT_MAX_OUTPUT_BYTES', MAX_RESULT_BYTES, MAX_RESULT_BYTES), + maxTaskBytes: parseBoundedInt('AWF_BOUNDED_AGENT_MAX_TASK_BYTES', 4096, MAX_TASK_BYTES), + maxInvocations: parsePositiveInt('AWF_BOUNDED_AGENT_MAX_INVOCATIONS', 8), + maxModelRequests: parseBoundedInt('AWF_BOUNDED_AGENT_MAX_MODEL_REQUESTS', 8, 64), + maxModelTokens: parseBoundedInt('AWF_BOUNDED_AGENT_MAX_MODEL_TOKENS', 1024, 32768), + socketUid: parsePositiveInt('AWF_BOUNDED_AGENT_SOCKET_UID', 0), + socketGid: parsePositiveInt('AWF_BOUNDED_AGENT_SOCKET_GID', 0), + }; +} + +/** + * Loads the AWF-generated repo → { opaque seed id, sensitivity } map. + * + * The map is the *only* way a repository can be selected: a request supplies a + * normalized `owner/repo` id, which is looked up here. Callers never supply a + * path, and an unknown id is simply absent from the map. Sensitivity is + * carried in the (AWF-trusted, host-written) map itself, never accepted from a + * request. The bounded-agent broker loads its *own* map from its own private + * root, so its ledger is disjoint from the bounded-query ledger. + */ +function loadSeedMap(seedMapPath) { + return parsePrivateRepositorySeedMap( + fs.readFileSync(seedMapPath, 'utf8'), + BOUNDED_QUERY_SENSITIVITY_RUN_BITS, + ); +} + +module.exports = { + READY_PATH, + SBX_CAPABILITY_PATH, + MAX_TASK_BYTES, + SUPPORTED_BACKENDS, + SUPPORTED_PROFILES, + PRIMARY_BACKENDS, + loadConfig, + loadSeedMap, + loadSbxIngressCapabilities, +}; diff --git a/containers/bounded-agent/broker/docker-client.js b/containers/bounded-agent/broker/docker-client.js new file mode 100644 index 000000000..6df1e014b --- /dev/null +++ b/containers/bounded-agent/broker/docker-client.js @@ -0,0 +1,38 @@ +'use strict'; + +const { execFile } = require('child_process'); + +/** + * Executes the Docker CLI with bounded output and no inherited credentials. + * + * Enclave stdout/stderr are captured only so the child process cannot block on + * a full pipe; the broker discards them and never forwards, logs, or inspects + * them. `maxBuffer` bounds the capture so a chatty enclave cannot exhaust + * broker memory. + */ +function runDocker(args, timeoutMs) { + return new Promise((resolve) => { + execFile( + 'docker', + args, + { + timeout: timeoutMs, + killSignal: 'SIGKILL', + maxBuffer: 64 * 1024, + env: { PATH: process.env.PATH || '/usr/local/bin:/usr/bin:/bin' }, + }, + (error, stdout, stderr) => { + resolve({ + exitCode: error && typeof error.code === 'number' ? error.code : error ? 1 : 0, + timedOut: Boolean(error && error.killed), + // Deliberately not surfaced: retained only as bounded strings so the + // callback shape matches the bounded-query runner contract. + stderr: typeof stderr === 'string' ? stderr.slice(0, 2000) : '', + stdout: typeof stdout === 'string' ? stdout.slice(0, 2000) : '', + }); + }, + ); + }); +} + +module.exports = { runDocker }; diff --git a/containers/bounded-agent/broker/docker-enclave-runner.js b/containers/bounded-agent/broker/docker-enclave-runner.js new file mode 100644 index 000000000..a3b59c976 --- /dev/null +++ b/containers/bounded-agent/broker/docker-enclave-runner.js @@ -0,0 +1,124 @@ +'use strict'; + +const defaultDockerClient = require('./docker-client'); +const { + CLI_GRACE_MS, + buildRemoveArgs, + deriveEnclaveContainerSpec, + normalizeTimeoutMs, +} = require('./enclave-runner-spec'); + +/** + * EnclaveRunner using the Docker daemon's default OCI runtime. + * + * The optional runtimeName is constructor-controlled so subclasses can select a + * fixed trusted runtime without accepting runtime data per invocation. + */ +class DockerEnclaveRunner { + constructor(config, deps = {}, runtimeName = undefined) { + this.config = config; + this.runtimeName = runtimeName; + this.docker = deps.docker || defaultDockerClient; + this.cleanupTail = Promise.resolve(); + } + + async assertAvailable() { + const image = await this.docker.runDocker(['image', 'inspect', this.config.enclaveImage], 60_000); + if (image.exitCode !== 0) { + throw new Error('Enclave image is not available locally'); + } + const network = await this.docker.runDocker(['network', 'inspect', this.config.network], 30_000); + if (network.exitCode !== 0) { + throw new Error( + 'The dedicated bounded-agent network is not available; bounded agents never fall back to ' + + 'another network', + ); + } + } + + spec(runId, invocationId, seedId) { + return deriveEnclaveContainerSpec({ + config: this.config, + runId, + invocationId, + seedId, + runtimeName: this.runtimeName, + }); + } + + async listContainerIds(args) { + const listed = await this.docker.runDocker(args, 30_000); + if (listed.exitCode !== 0) { + throw new Error('Failed to reconcile bounded-agent containers'); + } + const ids = listed.stdout.split('\n').map((id) => id.trim()).filter(Boolean); + if (ids.some((id) => !/^[0-9a-f]{12,64}$/.test(id))) { + throw new Error('Docker returned an invalid bounded-agent container id'); + } + return ids; + } + + async removeListed(args) { + const ids = await this.listContainerIds(args); + if (ids.length === 0) return; + const removed = await this.docker.runDocker(buildRemoveArgs(ids), 30_000); + if (removed.exitCode !== 0) { + throw new Error('Failed to remove bounded-agent containers'); + } + } + + serializeCleanup(operation) { + const queued = this.cleanupTail.then(operation, operation); + this.cleanupTail = queued.then( + () => undefined, + () => undefined, + ); + return queued; + } + + /** Deterministic orphan cleanup for every container labelled with this run. */ + async reconcileRun(runId) { + const spec = this.spec(runId, 'reconcile', '0'.repeat(32)); + await this.serializeCleanup(() => this.removeListed(spec.runListArgs)); + } + + async cleanupInvocation(runId, invocationId) { + const spec = this.spec(runId, invocationId, '0'.repeat(32)); + await this.serializeCleanup(() => this.removeListed(spec.invocationListArgs)); + } + + /** + * Runs one enclave to completion and always removes it before returning. + * + * Cleanup failures fail closed on the timeout/error paths, where a live + * sandbox may still hold a mount of private repository content. + */ + async runEnclaveContainer(params) { + const spec = this.spec(params.runId, params.invocationId, params.seedId); + const timeoutMs = normalizeTimeoutMs( + (params.timeoutMs ?? this.config.timeoutSeconds * 1000) + CLI_GRACE_MS, + ); + + let result; + let runError; + try { + result = await this.docker.runDocker(spec.launchArgs, timeoutMs); + } catch (error) { + runError = error; + } + + try { + await this.cleanupInvocation(params.runId, params.invocationId); + } catch (cleanupError) { + throw cleanupError; + } + + if (runError) throw runError; + // stdout/stderr are intentionally dropped here: the broker never reads, + // logs, or forwards enclave output. Only the exit status and the dedicated + // bounded result file are consulted. + return { exitCode: result.exitCode, timedOut: result.timedOut }; + } +} + +module.exports = { DockerEnclaveRunner }; diff --git a/containers/bounded-agent/broker/enclave-runner-spec.js b/containers/bounded-agent/broker/enclave-runner-spec.js new file mode 100644 index 000000000..0cfdbcb8e --- /dev/null +++ b/containers/bounded-agent/broker/enclave-runner-spec.js @@ -0,0 +1,149 @@ +'use strict'; + +/** + * Fixed enclave container specification. + * + * Every daemon-facing setting is derived from trusted broker configuration and + * broker-generated identifiers. No request object is accepted by this API, and + * the resulting argument vector is frozen: an invocation can never add, remove, + * or reorder a flag. + * + * Isolation properties encoded here: + * + * - `--network `: the enclave joins *only* the + * dedicated `internal` bounded-agent network. Its sole reachable peer is + * the AWF API proxy; there is no `awf-net`, no `awf-ext`, no Squid, no + * general proxy, no primary agent, no broker, no safe-outputs collector, no + * MCP gateway, and no CLI proxy. + * - `--read-only` with the repository seed bind-mounted `ro`: the enclave can + * never mutate private source. + * - bounded `--tmpfs` mounts for `/tmp` and the `/agent` work/result root. + * - fixed non-root uid/gid, `--cap-drop ALL`, `no-new-privileges`, a seccomp + * profile, and memory/CPU/PID/file-size/timeout bounds. + */ + +/** Extra grace beyond the enclave's wall-clock budget for docker CLI overhead. */ +const CLI_GRACE_MS = 5_000; + +/** Maximum file size the enclave may create, in bytes (per-file RLIMIT_FSIZE). */ +const ENCLAVE_MAX_FILE_BYTES = 32 * 1024 * 1024; + +const RUN_LABEL = 'awf.bounded-agent.run'; +const INVOCATION_LABEL = 'awf.bounded-agent.invocation'; +const TRUSTED_ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/; + +/** Converts a monotonic-clock duration to the integer milliseconds Node requires. */ +function normalizeTimeoutMs(timeoutMs) { + return Math.max(1, Math.ceil(timeoutMs)); +} + +function assertTrustedId(name, value) { + if (typeof value !== 'string' || !TRUSTED_ID_PATTERN.test(value)) { + throw new Error(`${name} is not a broker-generated identifier`); + } +} + +function assertTrustedSeedId(value) { + if (typeof value !== 'string' || !/^[0-9a-f]{16,64}$/.test(value)) { + throw new Error('seedId is not an AWF-generated seed identifier'); + } +} + +function freezeArray(values) { + return Object.freeze(values); +} + +/** + * Derives every daemon-facing enclave setting from trusted config and + * broker-generated identifiers. + */ +function deriveEnclaveContainerSpec({ config, runId, invocationId, seedId, runtimeName }) { + assertTrustedId('runId', runId); + assertTrustedId('invocationId', invocationId); + assertTrustedSeedId(seedId); + if (runtimeName !== undefined && runtimeName !== 'runsc') { + throw new Error(`Unsupported OCI runtime in enclave runner: ${runtimeName}`); + } + + const containerName = `awf-bounded-agent-${runId.slice(0, 12)}-${invocationId}`; + const hostInvocationDir = `${config.hostWorkDir}/${invocationId}`; + const hostSeedDir = `${config.hostSeedsDir}/${seedId}`; + const runLabel = `${RUN_LABEL}=${runId}`; + const invocationLabel = `${INVOCATION_LABEL}=${invocationId}`; + const launchArgs = [ + 'run', + '--pull', 'never', + '--name', containerName, + '--label', runLabel, + '--label', invocationLabel, + '--network', config.network, + '--read-only', + '--user', `${config.enclaveUid}:${config.enclaveGid}`, + '--cap-drop', 'ALL', + '--security-opt', 'no-new-privileges:true', + '--security-opt', `seccomp=${config.enclaveSeccompPath}`, + '--memory', config.memoryLimit, + '--memory-swap', config.memoryLimit, + '--cpus', String(config.cpuLimit), + '--pids-limit', String(config.pidsLimit), + '--ulimit', `fsize=${ENCLAVE_MAX_FILE_BYTES}`, + '--ulimit', 'nofile=1024:1024', + '--tmpfs', `/tmp:rw,noexec,nosuid,nodev,size=${config.tmpfsLimit}`, + '--tmpfs', + `${config.enclaveMountDir}:rw,nosuid,nodev,size=${config.tmpfsLimit},` + + `uid=${config.enclaveUid},gid=${config.enclaveGid},mode=0700`, + '--hostname', 'bounded-agent', + '--workdir', config.enclaveMountDir, + '--env', 'HOME=/tmp', + '--env', 'PYTHONDONTWRITEBYTECODE=1', + '--env', 'PYTHONUNBUFFERED=1', + '--env', `AWF_BOUNDED_AGENT_API_ENDPOINT=${config.apiEndpoint}`, + '--env', `AWF_BOUNDED_AGENT_PROFILE=${config.profile}`, + '--env', `AWF_BOUNDED_AGENT_MODEL=${config.model}`, + '--env', `AWF_BOUNDED_AGENT_MAX_MODEL_REQUESTS=${config.maxModelRequests}`, + '--env', `AWF_BOUNDED_AGENT_MAX_MODEL_TOKENS=${config.maxModelTokens}`, + '--env', `AWF_BOUNDED_AGENT_MAX_OUTPUT_BYTES=${config.maxOutputBytes}`, + '--env', `AWF_BOUNDED_AGENT_DEADLINE_SECONDS=${config.timeoutSeconds}`, + '-v', `${hostSeedDir}:${config.enclaveSeedPath}:ro`, + '-v', `${hostInvocationDir}/task.txt:${config.enclaveTaskPath}:ro`, + '-v', `${hostInvocationDir}/schema.json:${config.enclaveSchemaPath}:ro`, + '-v', `${hostInvocationDir}/out:${config.enclaveMountDir}/out:rw`, + ]; + + if (runtimeName !== undefined) { + launchArgs.push('--runtime', runtimeName); + } + launchArgs.push('--entrypoint', '/usr/local/bin/run-bounded-agent', config.enclaveImage); + + return Object.freeze({ + containerName, + runtimeName, + launchArgs: freezeArray(launchArgs), + invocationListArgs: freezeArray([ + 'ps', '-aq', + '--filter', `label=${runLabel}`, + '--filter', `label=${invocationLabel}`, + ]), + runListArgs: freezeArray(['ps', '-aq', '--filter', `label=${runLabel}`]), + }); +} + +/** Compatibility helper for focused argument tests. */ +function buildEnclaveArgs(params) { + return deriveEnclaveContainerSpec(params).launchArgs; +} + +function buildRemoveArgs(containerIds) { + return freezeArray(['rm', '-f', ...containerIds]); +} + +module.exports = { + CLI_GRACE_MS, + ENCLAVE_MAX_FILE_BYTES, + INVOCATION_LABEL, + RUN_LABEL, + buildEnclaveArgs, + buildRemoveArgs, + deriveEnclaveContainerSpec, + normalizeTimeoutMs, +}; diff --git a/containers/bounded-agent/broker/enclave-runner.js b/containers/bounded-agent/broker/enclave-runner.js new file mode 100644 index 000000000..ba1cf31b7 --- /dev/null +++ b/containers/bounded-agent/broker/enclave-runner.js @@ -0,0 +1,59 @@ +'use strict'; + +const { DockerEnclaveRunner } = require('./docker-enclave-runner'); +const { GvisorEnclaveRunner } = require('./gvisor-enclave-runner'); +const { SbxEnclaveRunner } = require('./sbx-enclave-runner'); +const { + ENCLAVE_MAX_FILE_BYTES, + buildEnclaveArgs, + deriveEnclaveContainerSpec, + normalizeTimeoutMs, +} = require('./enclave-runner-spec'); + +/** + * Trusted broker interface for one-enclave-per-invocation execution. + * + * @typedef {object} EnclaveRunner + * @property {() => Promise} assertAvailable + * @property {(runId: string) => Promise} reconcileRun + * @property {(params: { + * runId: string, + * invocationId: string, + * seedId: string, + * timeoutMs?: number + * }) => Promise<{exitCode: number, timedOut: boolean}>} runEnclaveContainer + */ + +/** + * Selects a runner only from AWF's normalized broker configuration. + * + * Unknown values fail closed. In particular, gVisor never falls back to the + * daemon's default OCI runtime when runsc is unavailable, and the `sbx` + * backend's `assertAvailable` always throws for the currently audited sbx CLI + * (see `./sbx-capability-probe.js`) — host-side preflight already blocks sbx + * long before this code runs, so reaching this branch at all would mean the + * defense-in-depth check inside the runner is the only thing standing between + * the request and an unproven enclave, and it fails closed too. + * + * @returns {EnclaveRunner} + */ +function createEnclaveRunner(config, deps = {}) { + if (config.backend === 'docker') { + return new DockerEnclaveRunner(config, deps); + } + if (config.backend === 'gvisor') { + return new GvisorEnclaveRunner(config, deps); + } + if (config.backend === 'sbx') { + return new SbxEnclaveRunner(config, deps); + } + throw new Error(`Unsupported bounded-agent backend: ${config.backend}`); +} + +module.exports = { + ENCLAVE_MAX_FILE_BYTES, + buildEnclaveArgs, + createEnclaveRunner, + deriveEnclaveContainerSpec, + normalizeTimeoutMs, +}; diff --git a/containers/bounded-agent/broker/framing.js b/containers/bounded-agent/broker/framing.js new file mode 100644 index 000000000..cbde16442 --- /dev/null +++ b/containers/bounded-agent/broker/framing.js @@ -0,0 +1,263 @@ +'use strict'; + +const { + MAX_PRIVATE_REPO_LENGTH, + MAX_SCHEMA_BYTES, + BOUNDED_QUERY_REPO_PATTERN, + strictParseJson, + validateSchema, +} = require('./protocol'); +const { MAX_TASK_BYTES } = require('./config'); + +/** + * Wire framing and request validation for the agent → broker bounded-agent + * request. + * + * Like bounded queries, the scalar/JSON fields travel as fixed headers and the + * free-form payload (here the bounded task text) travels as the raw body, so + * the POSIX-sh agent wrapper never has to emit JSON. The broker assembles the + * canonical `{privateRepo, schema, task}` object itself and validates it + * against the fixed protocol. + * + * The accepted surface is deliberately tiny. Any other `x-awf-*` header, any + * duplicate header, and any unknown/forbidden request key is rejected — a + * request can never express an image, command, executable, mount, environment, + * endpoint, network, proxy, credential, timeout, resource limit, runtime, or + * tool definition. + */ + +/** Supported request framing version. */ +const AGENT_PROTOCOL_VERSION = '1'; + +const VERSION_HEADER = 'x-awf-agent-version'; +const REPO_HEADER = 'x-awf-repo'; +const SCHEMA_HEADER = 'x-awf-schema-b64'; + +/** Every header the broker accepts. Anything else is a rejected control. */ +const ALLOWED_AWF_HEADERS = new Set([VERSION_HEADER, REPO_HEADER, SCHEMA_HEADER]); + +/** The complete set of keys a bounded-agent request may contain. */ +const ALLOWED_REQUEST_KEYS = ['privateRepo', 'schema', 'task']; + +/** + * Controls a request may never express. + * + * Redundant with the unknown-key rule below by construction; kept explicit so + * an accidental future widening of the accepted key set fails a test instead of + * silently granting a capability. + */ +const FORBIDDEN_REQUEST_KEYS = [ + 'image', 'images', 'command', 'cmd', 'args', 'argv', 'entrypoint', 'executable', + 'interpreter', 'script', 'shell', 'mount', 'mounts', 'volume', 'volumes', 'bind', + 'path', 'paths', 'workdir', 'env', 'environment', 'endpoint', 'endpoints', 'baseUrl', + 'url', 'host', 'network', 'networks', 'dns', 'proxy', 'httpProxy', 'httpsProxy', + 'credential', 'credentials', 'apiKey', 'token', 'authorization', 'headers', + 'timeout', 'timeoutSeconds', 'deadline', 'memory', 'memoryLimit', 'cpu', 'cpuLimit', + 'pids', 'pidsLimit', 'tmpfs', 'ulimit', 'resources', 'runtime', 'backend', 'sandbox', + 'profile', 'model', 'provider', 'temperature', 'maxTokens', 'maxModelRequests', + 'tool', 'tools', 'toolChoice', 'functions', 'systemPrompt', 'system', 'messages', +]; + +/** Base64url alphabet only (no padding, no `+`/`/`). */ +const BASE64URL_PATTERN = /^[A-Za-z0-9_-]+$/; + +/** Generous ceiling on the encoded header length for a schema of at most `MAX_SCHEMA_BYTES`. */ +const MAX_SCHEMA_HEADER_LENGTH = Math.ceil((MAX_SCHEMA_BYTES * 4) / 3) + 4; + +/** A peer that stops sending a request body cannot pin a broker connection. */ +const BODY_READ_TIMEOUT_MS = 5_000; + +/** + * Rejects duplicated or unexpected `x-awf-*` headers. + * + * Duplicates matter because Node joins repeated headers with `", "`, which + * would silently corrupt a base64url value or a repo slug. + */ +function validateRawHeaders(rawHeaders) { + const seen = new Set(); + for (let i = 0; i < rawHeaders.length; i += 2) { + const name = rawHeaders[i].toLowerCase(); + if (!name.startsWith('x-awf-')) continue; + if (!ALLOWED_AWF_HEADERS.has(name)) { + return `unsupported request control header: ${name}`; + } + if (seen.has(name)) { + return `duplicate request header: ${name}`; + } + seen.add(name); + } + return undefined; +} + +/** Decodes and UTF-8-validates the base64url schema header. */ +function decodeSchemaHeader(value) { + if (typeof value !== 'string' || value.length === 0 || value.length > MAX_SCHEMA_HEADER_LENGTH) { + return undefined; + } + if (!BASE64URL_PATTERN.test(value)) return undefined; + + let decoded; + try { + decoded = Buffer.from(value, 'base64url'); + } catch { + return undefined; + } + const text = decoded.toString('utf8'); + // Reject anything that was not valid UTF-8 to begin with (round-trip check). + if (!Buffer.from(text, 'utf8').equals(decoded)) return undefined; + return text; +} + +/** + * Assembles the canonical request object from a framed HTTP request. + * + * @returns `{ request }` on success or `{ error }` with a protected reason. + */ +function buildRequestFromFrame(headers, rawHeaders, task) { + const headerError = validateRawHeaders(rawHeaders); + if (headerError) return { error: headerError }; + + if (headers[VERSION_HEADER] !== AGENT_PROTOCOL_VERSION) { + return { error: 'unsupported or missing protocol version' }; + } + + const privateRepo = headers[REPO_HEADER]; + if (typeof privateRepo !== 'string') { + return { error: 'missing repository selector' }; + } + + const schemaText = decodeSchemaHeader(headers[SCHEMA_HEADER]); + if (schemaText === undefined) { + return { error: 'missing or malformed schema header' }; + } + + const parsedSchema = strictParseJson(schemaText); + if (!parsedSchema) { + return { error: 'schema header is not valid JSON' }; + } + + return { request: { privateRepo, schema: parsedSchema.value, task } }; +} + +function isPlainObject(value) { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** + * Validates an assembled bounded-agent request against the fixed protocol. + * + * @returns `{ valid: true, request }` or `{ valid: false, errors }`. Errors are + * only ever written to the protected audit log, never returned to the caller. + */ +function validateBoundedAgentRequest(raw, options = {}) { + const errors = []; + if (!isPlainObject(raw)) { + return { valid: false, errors: ['request must be a JSON object'] }; + } + + const forbidden = FORBIDDEN_REQUEST_KEYS.filter( + (key) => Object.prototype.hasOwnProperty.call(raw, key), + ); + for (const key of forbidden) { + errors.push(`request may not specify "${key}"`); + } + for (const key of Object.keys(raw)) { + if (!ALLOWED_REQUEST_KEYS.includes(key) && !forbidden.includes(key)) { + errors.push(`unknown request key: "${key}"`); + } + } + + const { privateRepo, schema, task } = raw; + + if (typeof privateRepo !== 'string') { + errors.push('privateRepo must be a string'); + } else if (privateRepo.length > MAX_PRIVATE_REPO_LENGTH) { + errors.push('privateRepo exceeds the maximum length'); + } else if (!BOUNDED_QUERY_REPO_PATTERN.test(privateRepo)) { + errors.push('privateRepo must be a bare owner/repo slug'); + } + + const schemaValidation = validateSchema(schema); + if (!schemaValidation.valid) { + errors.push(...schemaValidation.errors); + } + + const configuredLimit = Number.isInteger(options.maxTaskBytes) && options.maxTaskBytes > 0 + ? options.maxTaskBytes + : MAX_TASK_BYTES; + const taskLimit = Math.min(configuredLimit, MAX_TASK_BYTES); + if (typeof task !== 'string') { + errors.push('task must be a string'); + } else if (task.length === 0) { + errors.push('task must not be empty'); + } else if (Buffer.byteLength(task, 'utf8') > taskLimit) { + errors.push('task exceeds the maximum size'); + } + + if (errors.length > 0) return { valid: false, errors }; + + return { + valid: true, + request: { privateRepo, schema: schemaValidation.schema, task }, + }; +} + +/** + * Reads the request body, refusing anything above the hard task cap. + * + * The cap is enforced while streaming so an oversized body is never buffered. + * The *configured* (possibly smaller) cap is applied by + * {@link validateBoundedAgentRequest}. + */ +function readBoundedBody(req) { + return new Promise((resolve) => { + const chunks = []; + let total = 0; + let settled = false; + const timer = setTimeout(() => { + req.pause(); + finish({ error: 'request body deadline exceeded' }); + }, BODY_READ_TIMEOUT_MS); + timer.unref(); + + const finish = (value) => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve(value); + }; + + req.on('data', (chunk) => { + total += chunk.length; + if (total > MAX_TASK_BYTES) { + finish({ error: 'task exceeds maximum size' }); + req.pause(); + return; + } + chunks.push(chunk); + }); + req.on('end', () => { + const body = Buffer.concat(chunks); + const text = body.toString('utf8'); + if (!Buffer.from(text, 'utf8').equals(body)) { + finish({ error: 'task is not valid UTF-8' }); + return; + } + finish({ task: text }); + }); + req.on('error', () => finish({ error: 'request stream error' })); + }); +} + +module.exports = { + AGENT_PROTOCOL_VERSION, + ALLOWED_REQUEST_KEYS, + BODY_READ_TIMEOUT_MS, + FORBIDDEN_REQUEST_KEYS, + REPO_HEADER, + SCHEMA_HEADER, + VERSION_HEADER, + buildRequestFromFrame, + readBoundedBody, + validateBoundedAgentRequest, +}; diff --git a/containers/bounded-agent/broker/gvisor-enclave-runner.js b/containers/bounded-agent/broker/gvisor-enclave-runner.js new file mode 100644 index 000000000..34df6630e --- /dev/null +++ b/containers/bounded-agent/broker/gvisor-enclave-runner.js @@ -0,0 +1,36 @@ +'use strict'; + +const { DockerEnclaveRunner } = require('./docker-enclave-runner'); + +const RUNSC_RUNTIME = 'runsc'; +const RUNTIME_NAMES_FORMAT = '{{range $name, $_ := .Runtimes}}{{println $name}}{{end}}'; + +/** + * EnclaveRunner using Docker with the fixed `runsc` OCI runtime. + * + * Availability is proven exactly — an unregistered `runsc` aborts the run and + * never downgrades to the daemon's default runtime. + */ +class GvisorEnclaveRunner extends DockerEnclaveRunner { + constructor(config, deps = {}) { + super(config, deps, RUNSC_RUNTIME); + } + + async assertAvailable() { + await super.assertAvailable(); + const result = await this.docker.runDocker( + ['info', '--format', RUNTIME_NAMES_FORMAT], + 30_000, + ); + if (result.exitCode !== 0) { + throw new Error('Unable to inspect Docker OCI runtimes for gVisor'); + } + + const runtimes = new Set(result.stdout.split('\n').map((name) => name.trim()).filter(Boolean)); + if (!runtimes.has(RUNSC_RUNTIME)) { + throw new Error('gVisor enclave backend requires the runsc OCI runtime; no fallback is permitted'); + } + } +} + +module.exports = { GvisorEnclaveRunner, RUNSC_RUNTIME }; diff --git a/containers/bounded-agent/broker/healthcheck.js b/containers/bounded-agent/broker/healthcheck.js new file mode 100644 index 000000000..0e90da52c --- /dev/null +++ b/containers/bounded-agent/broker/healthcheck.js @@ -0,0 +1,20 @@ +'use strict'; + +const fs = require('fs'); +const { READY_PATH } = require('./config'); + +/** + * Compose healthcheck for the bounded-agent broker. + * + * Checks for the broker-internal ready file written by `main()` in server.js + * once the socket is accepting connections. This avoids hitting the + * agent-visible `/query` socket, which has only one route and no health + * endpoint. Exits non-zero if the ready file is absent or unreadable. + */ + +try { + fs.accessSync(READY_PATH, fs.constants.F_OK); + process.exit(0); +} catch { + process.exit(1); +} diff --git a/containers/bounded-agent/broker/ledger.js b/containers/bounded-agent/broker/ledger.js new file mode 100644 index 000000000..483ea80c8 --- /dev/null +++ b/containers/bounded-agent/broker/ledger.js @@ -0,0 +1,4 @@ +'use strict'; + +// Stable bounded-agent compatibility entrypoint. +module.exports = require('../bounded-execution/sensitivity-ledger'); diff --git a/containers/bounded-agent/broker/protocol.js b/containers/bounded-agent/broker/protocol.js new file mode 100644 index 000000000..e9a069c61 --- /dev/null +++ b/containers/bounded-agent/broker/protocol.js @@ -0,0 +1,6 @@ +'use strict'; + +// Stable bounded-agent compatibility entrypoint for the shared PR1 +// bounded-execution foundation (finite schema algebra, cardinality/bit charge, +// strict JSON parsing, canonicalization, canonical envelopes). +module.exports = require('../bounded-execution/finite-disclosure'); diff --git a/containers/bounded-agent/broker/runtime-telemetry.js b/containers/bounded-agent/broker/runtime-telemetry.js new file mode 100644 index 000000000..a7e859285 --- /dev/null +++ b/containers/bounded-agent/broker/runtime-telemetry.js @@ -0,0 +1,64 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +const PRIMARY_BACKENDS = new Set(['docker', 'gvisor', 'sbx']); +const BOUNDED_AGENT_BACKENDS = new Set(['docker', 'gvisor', 'sbx']); +const LIFECYCLE_CLASSES = new Set(['preflight', 'startup', 'invocation', 'cleanup']); +const CAPABILITY_STATES = new Set(['supported', 'unavailable', 'blocked']); +const CATEGORY_PATTERN = /^[a-z][a-z0-9-]{0,63}$/; + +function assertTelemetryValue(allowed, value, field) { + if (!allowed.has(value)) throw new Error(`Invalid bounded-agent telemetry ${field}`); +} + +function buildRuntimeTelemetryRecord(event) { + assertTelemetryValue(PRIMARY_BACKENDS, event.primaryBackend, 'primaryBackend'); + assertTelemetryValue(BOUNDED_AGENT_BACKENDS, event.boundedAgentBackend, 'boundedAgentBackend'); + assertTelemetryValue(LIFECYCLE_CLASSES, event.lifecycleClass, 'lifecycleClass'); + assertTelemetryValue(CAPABILITY_STATES, event.capabilityState, 'capabilityState'); + if (typeof event.category !== 'string' || !CATEGORY_PATTERN.test(event.category)) { + throw new Error('Invalid bounded-agent telemetry category'); + } + return Object.freeze({ + primaryBackend: event.primaryBackend, + boundedAgentBackend: event.boundedAgentBackend, + lifecycleClass: event.lifecycleClass, + capabilityState: event.capabilityState, + category: event.category, + }); +} + +/** + * Runtime-matrix telemetry sink, mirroring bounded-query's. + * + * Only the five fixed enum fields above are ever written — never a secret, + * capability token, path, prompt, repository id, model payload, or provider + * response. This is intentionally the *only* channel this broker writes + * besides the disjoint audit ledger in `./audit.js`. + */ +function createRuntimeTelemetry(auditDir) { + fs.mkdirSync(auditDir, { recursive: true, mode: 0o700 }); + const telemetryPath = path.join(auditDir, 'runtime-telemetry.jsonl'); + let fd = fs.openSync(telemetryPath, 'a', 0o600); + return { + emit(event) { + const record = buildRuntimeTelemetryRecord(event); + if (fd === undefined) return; + try { + fs.writeSync(fd, `${JSON.stringify(record)}\n`); + } catch { + process.stderr.write('[bounded-agent] runtime telemetry unavailable\n'); + try { + fs.closeSync(fd); + } catch { + // The generic telemetry failure above is the only safe diagnostic. + } + fd = undefined; + } + }, + }; +} + +module.exports = { buildRuntimeTelemetryRecord, createRuntimeTelemetry }; diff --git a/containers/bounded-agent/broker/sbx-capability-probe.js b/containers/bounded-agent/broker/sbx-capability-probe.js new file mode 100644 index 000000000..f1aaf1cd4 --- /dev/null +++ b/containers/bounded-agent/broker/sbx-capability-probe.js @@ -0,0 +1,134 @@ +#!/usr/bin/env node +'use strict'; + +const defaultSbxClient = require('./sbx-client'); + +const AUDITED_SBX_VERSION = '0.37.1'; +const REQUIRED_CREATE_FLAGS = Object.freeze([ + '--cpus', + '--memory', + '--name', + '--template', +]); +const REQUIRED_EXEC_FLAGS = Object.freeze([ + '--user', + '--workdir', +]); + +/** + * Capabilities that sbx must expose before AWF can safely launch a + * bounded-agent enclave VM. + * + * Unlike bounded queries (which run with `--network=none`), a bounded-agent + * enclave must reach the AWF API proxy and nothing else — so instead of a + * no-network primitive, sbx needs a *named-network attach with mandatory + * lateral-peer denial*: the VM must be able to join a single named network + * with exactly one reachable peer (the API proxy) and no route to any other + * member, including other enclave VMs on the same network. sbx v0.37.1 has no + * such primitive — its `--network` flag (if present at all) does not carry an + * enforced peer-isolation guarantee, so this control is always reported + * missing until AWF can name a specific, verifiable sbx flag or capability + * token that provides it. Local Docker/iptables rules are insufficient + * because organization sbx governance can replace them. + */ +const REQUIRED_HARD_ISOLATION_FLAGS = Object.freeze([ + '--network', + '--pids-limit', + '--disk-limit', + '--ulimit-fsize', + '--mount-target', +]); + +/** Capability primitive sbx does not yet expose under any flag name. */ +const LATERAL_PEER_DENIAL_PRIMITIVE = + 'sbx named-network attach with mandatory lateral-peer denial to enforce API-proxy-only egress ' + + '(hard network-policy / capability-token ingress primitive)'; + +/** AWF has not published a pinned, immutable bounded-agent sbx template/bootstrap. */ +const PINNED_TEMPLATE_MISSING = 'pinned AWF bounded-agent sbx template and bootstrap'; + +function includesFlag(help, flag) { + const escaped = flag.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return new RegExp(`(^|[\\s,])${escaped}(?=([=\\s,]|$))`, 'm').test(help); +} + +async function inspectHelp(sbx, command) { + const result = await sbx.runSbx([command, '--help'], 10_000); + return result.exitCode === 0 ? result.stdout : ''; +} + +/** + * Probes the installed sbx CLI for the exact version, authentication, and + * hard-isolation flags a bounded-agent enclave requires. + * + * This never reports `supported: true` on flag detection alone: even when + * every enumerated flag is present, the two capability primitives AWF cannot + * yet verify (the pinned template/bootstrap and the lateral-peer-denial + * network primitive) are unconditionally appended to `missing`. A future sbx + * release that ships a concrete, checkable primitive for both must replace + * this unconditional block with a real check — it must never be removed + * without one. + */ +async function probeSbxCapabilities(sbx = defaultSbxClient) { + const versionResult = await sbx.runSbx(['version'], 10_000); + const daemonResult = await sbx.runSbx(['ls'], 10_000); + const createHelp = await inspectHelp(sbx, 'create'); + const execHelp = await inspectHelp(sbx, 'exec'); + const versionMatch = /\bv?(\d+\.\d+\.\d+)\b/.exec(versionResult.stdout); + const version = versionMatch ? versionMatch[1] : undefined; + const missing = []; + + missing.push(PINNED_TEMPLATE_MISSING); + missing.push(LATERAL_PEER_DENIAL_PRIMITIVE); + + if (versionResult.exitCode !== 0 || !version || daemonResult.exitCode !== 0) { + missing.push('authenticated sbx CLI/daemon'); + } + if (version && version !== AUDITED_SBX_VERSION) { + missing.push(`audited sbx version ${AUDITED_SBX_VERSION} (found ${version})`); + } + for (const flag of REQUIRED_CREATE_FLAGS) { + if (!includesFlag(createHelp, flag)) missing.push(`sbx create ${flag}`); + } + for (const flag of REQUIRED_EXEC_FLAGS) { + if (!includesFlag(execHelp, flag)) missing.push(`sbx exec ${flag}`); + } + for (const flag of REQUIRED_HARD_ISOLATION_FLAGS) { + if (!includesFlag(createHelp, flag)) missing.push(`sbx create ${flag}`); + } + + return Object.freeze({ + supported: missing.length === 0, + version, + auditedVersion: AUDITED_SBX_VERSION, + missing: Object.freeze(missing), + }); +} + +async function main() { + const report = await probeSbxCapabilities(); + process.stdout.write(`${JSON.stringify(report)}\n`); + process.exitCode = report.supported ? 0 : 1; +} + +if (require.main === module) { + main().catch((error) => { + process.stdout.write(`${JSON.stringify({ + supported: false, + auditedVersion: AUDITED_SBX_VERSION, + missing: ['capability probe failed'], + error: error.message, + })}\n`); + process.exitCode = 1; + }); +} + +module.exports = { + AUDITED_SBX_VERSION, + REQUIRED_CREATE_FLAGS, + REQUIRED_EXEC_FLAGS, + REQUIRED_HARD_ISOLATION_FLAGS, + LATERAL_PEER_DENIAL_PRIMITIVE, + PINNED_TEMPLATE_MISSING, + probeSbxCapabilities, +}; diff --git a/containers/bounded-agent/broker/sbx-client.js b/containers/bounded-agent/broker/sbx-client.js new file mode 100644 index 000000000..ab4327147 --- /dev/null +++ b/containers/bounded-agent/broker/sbx-client.js @@ -0,0 +1,44 @@ +'use strict'; + +const { execFile } = require('child_process'); + +const SBX_OUTPUT_LIMIT = 64 * 1024; +const SBX_SAFE_PATH = '/usr/local/bin:/usr/bin:/bin'; + +/** + * Executes an sbx management command with the broker's narrowly provisioned + * daemon credentials. The broker container never receives staging credentials, + * and this environment is not forwarded to enclave execution inside the VM. + * + * Proxy variables and XDG_CONFIG_HOME are removed for parity with the primary + * sbx management path: they can redirect daemon/credential lookup. + */ +function runSbx(args, timeoutMs) { + const env = { ...process.env }; + delete env.DOCKER_SANDBOXES_PROXY; + delete env.XDG_CONFIG_HOME; + env.PATH = process.env.PATH || SBX_SAFE_PATH; + + return new Promise((resolve) => { + execFile( + 'sbx', + args, + { + timeout: timeoutMs, + killSignal: 'SIGKILL', + maxBuffer: SBX_OUTPUT_LIMIT, + env, + }, + (error, stdout, stderr) => { + resolve({ + exitCode: error && typeof error.code === 'number' ? error.code : error ? 1 : 0, + timedOut: Boolean(error && error.killed), + stderr: typeof stderr === 'string' ? stderr.slice(0, 2000) : '', + stdout: typeof stdout === 'string' ? stdout.slice(0, 2000) : '', + }); + }, + ); + }); +} + +module.exports = { runSbx }; diff --git a/containers/bounded-agent/broker/sbx-enclave-runner-spec.js b/containers/bounded-agent/broker/sbx-enclave-runner-spec.js new file mode 100644 index 000000000..d7cc9aad8 --- /dev/null +++ b/containers/bounded-agent/broker/sbx-enclave-runner-spec.js @@ -0,0 +1,107 @@ +'use strict'; + +const { + ENCLAVE_MAX_FILE_BYTES, + normalizeTimeoutMs, +} = require('./enclave-runner-spec'); +const { REQUIRED_HARD_ISOLATION_FLAGS } = require('./sbx-capability-probe'); + +const SBX_CLI_GRACE_MS = 15_000; + +/** + * Pinned placeholder template/bootstrap reference. + * + * This is intentionally not a real, resolvable template: AWF has not + * published a bounded-agent sbx template because current sbx cannot enforce + * the mandatory isolation controls a real template would depend on (see + * `./sbx-capability-probe.js`). The value documents the exact shape a future + * pinned reference must take (a content-addressed tag), and is never used to + * launch a real enclave while the capability probe reports it missing. + */ +const SBX_ENCLAVE_TEMPLATE = 'awf/bounded-agent-sandbox-templates:sbx-enclave@sha256:unsupported-until-pinned'; + +const TRUSTED_RUN_ID_PATTERN = /^[0-9a-f]{32}$/; +const TRUSTED_INVOCATION_ID_PATTERN = /^[0-9a-f]{24}$/; +const TRUSTED_SEED_ID_PATTERN = /^[0-9a-f]{16,64}$/; + +function assertTrustedId(name, value, pattern) { + if (typeof value !== 'string' || !pattern.test(value)) { + throw new Error(`${name} is not a broker-generated identifier`); + } +} + +function freeze(values) { + return Object.freeze(values); +} + +/** + * Derives the entire sbx CLI surface for one bounded-agent enclave invocation + * from trusted broker state. + * + * This specification is intentionally not launchable while the capability + * probe reports missing hard-isolation controls (see + * `SbxEnclaveRunner.assertAvailable`, which always throws for the currently + * audited sbx CLI). It records the exact sbx API a future, capability-proven + * sbx CLI would be driven with — a fixed uid/workdir, mandatory resource + * limits, and mount targets for the seed/task/schema/out channels — without + * ever accepting request-owned launch data. + */ +function deriveSbxEnclaveSpec({ config, runId, invocationId, seedId }) { + assertTrustedId('runId', runId, TRUSTED_RUN_ID_PATTERN); + assertTrustedId('invocationId', invocationId, TRUSTED_INVOCATION_ID_PATTERN); + assertTrustedId('seedId', seedId, TRUSTED_SEED_ID_PATTERN); + + const runPrefix = `awf-bounded-agent-sbx-${runId}-`; + const sandboxName = `${runPrefix}${invocationId}`; + const hostInvocationDir = `${config.sbxWorkDir}/${invocationId}`; + const hostSeedDir = `${config.sbxSeedsDir}/${seedId}`; + const workspaceDir = `${hostInvocationDir}/sbx-workspace`; + const taskPath = `${hostInvocationDir}/task.txt`; + const schemaPath = `${hostInvocationDir}/schema.json`; + const outPath = `${hostInvocationDir}/out`; + + return Object.freeze({ + sandboxName, + runPrefix, + createArgs: freeze([ + 'create', + '--name', sandboxName, + '--cpus', String(config.cpuLimit), + '--memory', config.memoryLimit, + '--template', SBX_ENCLAVE_TEMPLATE, + // Distinct from bounded queries' `--network=none`: a bounded-agent + // enclave must reach the API proxy and *only* the API proxy. sbx has + // no verified lateral-peer-denial primitive today (see + // REQUIRED_HARD_ISOLATION_FLAGS), so this argument is never issued + // against a real launch while the capability probe reports it missing. + '--network', config.network, + '--pids-limit', String(config.pidsLimit), + '--disk-limit', config.tmpfsLimit, + '--ulimit-fsize', String(ENCLAVE_MAX_FILE_BYTES), + '--mount-target', `${hostSeedDir}:${config.enclaveSeedPath}:ro`, + '--mount-target', `${taskPath}:${config.enclaveTaskPath}:ro`, + '--mount-target', `${schemaPath}:${config.enclaveSchemaPath}:ro`, + '--mount-target', `${outPath}:${config.enclaveMountDir}/out:rw`, + 'shell', + workspaceDir, + ]), + execArgs: freeze([ + 'exec', + '--user', `${config.enclaveUid}:${config.enclaveGid}`, + '--workdir', config.enclaveMountDir, + sandboxName, + '/usr/local/bin/run-bounded-agent', + ]), + stopArgs: freeze(['stop', sandboxName]), + removeArgs: freeze(['rm', '--force', sandboxName]), + listArgs: freeze(['ls', '--json']), + }); +} + +module.exports = { + SBX_CLI_GRACE_MS, + SBX_ENCLAVE_TEMPLATE, + REQUIRED_HARD_ISOLATION_FLAGS, + deriveSbxEnclaveSpec, + normalizeTimeoutMs, +}; diff --git a/containers/bounded-agent/broker/sbx-enclave-runner.js b/containers/bounded-agent/broker/sbx-enclave-runner.js new file mode 100644 index 000000000..f3f91d7ec --- /dev/null +++ b/containers/bounded-agent/broker/sbx-enclave-runner.js @@ -0,0 +1,152 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const defaultSbxClient = require('./sbx-client'); +const { probeSbxCapabilities } = require('./sbx-capability-probe'); +const { + SBX_CLI_GRACE_MS, + deriveSbxEnclaveSpec, + normalizeTimeoutMs, +} = require('./sbx-enclave-runner-spec'); + +function parseSandboxNames(stdout) { + let parsed; + try { + parsed = JSON.parse(stdout); + } catch { + throw new Error('sbx returned malformed sandbox inventory'); + } + if (!Array.isArray(parsed)) { + throw new Error('sbx returned malformed sandbox inventory'); + } + const names = parsed.map((entry) => entry && entry.name); + if (names.some((name) => typeof name !== 'string' || !/^[a-z0-9][a-z0-9+.-]{0,127}$/.test(name))) { + throw new Error('sbx returned an invalid sandbox name'); + } + return names; +} + +/** + * EnclaveRunner backed by the sbx microVM CLI. + * + * Deliberately mirrors DockerEnclaveRunner's lifecycle contract exactly + * (assertAvailable / reconcileRun / cleanupInvocation / runEnclaveContainer) + * so the dispatcher in `./enclave-runner.js` can select it without any + * special-casing, and so its cleanup ordering (serialize, always run, throw + * last) is provably the same. `assertAvailable` always throws today: the + * audited sbx CLI cannot yet prove the mandatory isolation and + * API-proxy-only network controls (see `./sbx-capability-probe.js`), and + * this runner never falls back to Docker or gVisor. + */ +class SbxEnclaveRunner { + constructor(config, deps = {}) { + this.config = config; + this.sbx = deps.sbx || defaultSbxClient; + this.probe = deps.probe || probeSbxCapabilities; + this.files = deps.files || fs; + this.nowMs = deps.nowMs || Date.now; + this.cleanupTail = Promise.resolve(); + } + + spec(runId, invocationId, seedId) { + return deriveSbxEnclaveSpec({ config: this.config, runId, invocationId, seedId }); + } + + async assertAvailable() { + const report = await this.probe(this.sbx); + if (!report.supported) { + throw new Error( + 'sbx bounded-agent enclave backend is blocked: the installed sbx runtime cannot enforce all ' + + `mandatory isolation controls (${report.missing.join(', ')}). No fallback is permitted.`, + ); + } + } + + serializeCleanup(operation) { + const queued = this.cleanupTail.then(operation, operation); + this.cleanupTail = queued.then( + () => undefined, + () => undefined, + ); + return queued; + } + + async listRunSandboxes(runId) { + const spec = this.spec(runId, '0'.repeat(24), '0'.repeat(32)); + const listed = await this.sbx.runSbx(spec.listArgs, 30_000); + if (listed.exitCode !== 0) throw new Error('Failed to reconcile bounded-agent sbx VMs'); + return parseSandboxNames(listed.stdout).filter((name) => name.startsWith(spec.runPrefix)); + } + + async removeSandbox(name) { + const stopped = await this.sbx.runSbx(['stop', name], 30_000); + if (stopped.exitCode !== 0) { + const inventory = await this.sbx.runSbx(['ls', '--quiet'], 30_000); + if (inventory.exitCode !== 0 || inventory.stdout.split('\n').includes(name)) { + throw new Error('Failed to stop bounded-agent sbx VM'); + } + } + const removed = await this.sbx.runSbx(['rm', '--force', name], 30_000); + if (removed.exitCode !== 0) throw new Error('Failed to remove bounded-agent sbx VM'); + } + + /** Deterministic orphan cleanup for every VM name-prefixed with this run. */ + async reconcileRun(runId) { + await this.serializeCleanup(async () => { + for (const name of await this.listRunSandboxes(runId)) { + await this.removeSandbox(name); + } + }); + } + + async cleanupInvocation(runId, invocationId) { + const { sandboxName } = this.spec(runId, invocationId, '0'.repeat(32)); + await this.serializeCleanup(() => this.removeSandbox(sandboxName)); + } + + /** + * Runs one enclave to completion and always removes the VM before + * returning — including on timeout or a create/exec failure. + * + * stdout/stderr are intentionally dropped: the broker never reads, logs, or + * forwards enclave output, matching DockerEnclaveRunner. + */ + async runEnclaveContainer(params) { + const spec = this.spec(params.runId, params.invocationId, params.seedId); + const totalTimeoutMs = normalizeTimeoutMs( + (params.timeoutMs ?? this.config.timeoutSeconds * 1000) + SBX_CLI_GRACE_MS, + ); + const deadlineMs = this.nowMs() + totalTimeoutMs; + const remainingMs = () => normalizeTimeoutMs(deadlineMs - this.nowMs()); + let result; + let runError; + try { + this.files.mkdirSync(path.join(this.config.sbxWorkDir, params.invocationId, 'sbx-workspace'), { + mode: 0o700, + }); + const created = await this.sbx.runSbx(spec.createArgs, Math.min(120_000, remainingMs())); + if (created.timedOut) { + result = created; + } else if (created.exitCode !== 0) { + throw new Error('Failed to create bounded-agent sbx VM'); + } else if (this.nowMs() >= deadlineMs) { + result = { exitCode: 124, timedOut: true, stdout: '', stderr: '' }; + } else { + result = await this.sbx.runSbx(spec.execArgs, remainingMs()); + } + } catch (error) { + runError = error; + } + + try { + await this.cleanupInvocation(params.runId, params.invocationId); + } catch (cleanupError) { + throw cleanupError; + } + if (runError) throw runError; + return { exitCode: result.exitCode, timedOut: result.timedOut }; + } +} + +module.exports = { SbxEnclaveRunner, parseSandboxNames }; diff --git a/containers/bounded-agent/broker/scheduler.js b/containers/bounded-agent/broker/scheduler.js new file mode 100644 index 000000000..4106ec243 --- /dev/null +++ b/containers/bounded-agent/broker/scheduler.js @@ -0,0 +1,4 @@ +'use strict'; + +// Stable bounded-agent compatibility entrypoint. +module.exports = require('../bounded-execution/fixed-timing'); diff --git a/containers/bounded-agent/broker/sensitivity.js b/containers/bounded-agent/broker/sensitivity.js new file mode 100644 index 000000000..394ad7fe4 --- /dev/null +++ b/containers/bounded-agent/broker/sensitivity.js @@ -0,0 +1,4 @@ +'use strict'; + +// Stable bounded-agent compatibility entrypoint. +module.exports = require('../bounded-execution/sensitivity-policy'); diff --git a/containers/bounded-agent/broker/server.js b/containers/bounded-agent/broker/server.js new file mode 100644 index 000000000..b37f64421 --- /dev/null +++ b/containers/bounded-agent/broker/server.js @@ -0,0 +1,410 @@ +'use strict'; + +const fs = require('fs'); +const crypto = require('crypto'); +const http = require('http'); +const { createAuditLog } = require('./audit'); +const { createBroker } = require('./broker'); +const { loadConfig, loadSeedMap } = require('./config'); +const { buildRequestFromFrame, readBoundedBody } = require('./framing'); +const { CANONICAL_ERROR_JSON } = require('./protocol'); +const { createEnclaveRunner } = require('./enclave-runner'); +const { createRuntimeTelemetry } = require('./runtime-telemetry'); + +/** + * Bounded-agent broker server. + * + * Compose agents (docker/gvisor primary) reach the broker over a Unix domain + * socket shared through a tightly scoped bind mount; the broker itself has + * `network_mode: none` in that mode -- not on `awf-net`, not on `awf-ext`, and + * not on the dedicated bounded-agent enclave network. sbx primary agents use + * the same protocol over authenticated HTTP only when a disposable capability + * probe proves that sbx cannot connect through a mounted host socket. In that + * mode the broker is attached only to a dedicated internal Docker network and + * published on an ephemeral host-gateway-only port; it is never on the + * enclave egress network either way. + * + * One route exists: + * POST /query the bounded-agent API + * + * The agent-visible socket has no `/health` route. The compose healthcheck + * instead polls for a broker-internal ready file written by `main()` after the + * socket starts accepting connections, so there is no distinguishable extra + * response on the agent-observable surface. + * + * `/query` always answers `200` with a canonical result body: + * `{"status":"ok","result":}` or `{"status":"error"}` -- status code and + * headers are identical either way, and every failure class collapses to the + * same error body. For any invocation that reached workspace creation, the + * response is additionally held until a fixed timing-bucket boundary. + */ + +const RESULT_HEADERS = { + 'content-type': 'application/json', + 'cache-control': 'no-store', +}; +// Give a nearly-complete invocation a chance to finish broker cleanup before +// force-removing this run's enclaves. Longer invocations are interrupted so +// Compose shutdown remains bounded; host teardown owns private-root removal. +const SHUTDOWN_GRACE_MS = 1_000; +const MAX_HEADER_BYTES = 8 * 1024; +const MAX_CONNECTIONS = 32; +const PROBE_RESPONSE_DELAY_MS = 10; + +function sendResult(res, body) { + res.writeHead(200, { ...RESULT_HEADERS, 'content-length': Buffer.byteLength(body) }); + res.end(body); +} + +function canonicalRawResponse() { + return [ + 'HTTP/1.1 200 OK', + 'content-type: application/json', + 'cache-control: no-store', + `content-length: ${Buffer.byteLength(CANONICAL_ERROR_JSON)}`, + 'connection: close', + '', + CANONICAL_ERROR_JSON, + ].join('\r\n'); +} + +function createHardenedServer(listener, audit) { + let accepting = true; + let pendingAdmissions = 0; + const admissionWaiters = []; + const resolveAdmissionWaiters = () => { + if (pendingAdmissions !== 0) return; + while (admissionWaiters.length > 0) { + admissionWaiters.shift()(); + } + }; + + const server = http.createServer({ maxHeaderSize: MAX_HEADER_BYTES }, (req, res) => { + if (!accepting) { + sendResult(res, CANONICAL_ERROR_JSON); + req.resume(); + return; + } + + pendingAdmissions += 1; + Promise.resolve(listener(req, res, () => accepting)) + .catch((error) => { + audit.failure('server', 'unhandled-error', error && error.message); + if (!res.headersSent) sendResult(res, CANONICAL_ERROR_JSON); + }) + .finally(() => { + pendingAdmissions -= 1; + resolveAdmissionWaiters(); + }); + }); + server.headersTimeout = 5_000; + server.requestTimeout = 0; + server.keepAliveTimeout = 1_000; + server.maxRequestsPerSocket = 1; + + let activeConnections = 0; + server.on('connection', (socket) => { + activeConnections += 1; + socket.once('close', () => { + activeConnections -= 1; + }); + if (activeConnections > MAX_CONNECTIONS) { + socket.awfRejected = true; + audit.failure('transport', 'connection-limit'); + socket.pause(); + socket.end(canonicalRawResponse()); + } + }); + server.on('clientError', (error, socket) => { + audit.failure('framing', 'header-rejected', error && error.message); + setTimeout(() => { + if (socket.writable) socket.end(canonicalRawResponse()); + }, PROBE_RESPONSE_DELAY_MS); + }); + server.freezeAdmissions = () => { + accepting = false; + }; + server.drainAdmissions = () => ( + pendingAdmissions === 0 + ? Promise.resolve() + : new Promise((resolve) => admissionWaiters.push(resolve)) + ); + return server; +} + +function processRequest( + req, + res, + broker, + audit, + framedHeaders = req.headers, + framedRawHeaders = req.rawHeaders, + isAccepting = () => true, +) { + if (req.socket.awfRejected) { + req.resume(); + res.destroy(); + return Promise.resolve(); + } + if (req.method !== 'POST' || req.url !== '/query') { + sendResult(res, CANONICAL_ERROR_JSON); + req.resume(); + return Promise.resolve(); + } + + return readBoundedBody(req) + .then((body) => { + if (!isAccepting()) { + sendResult(res, CANONICAL_ERROR_JSON); + return; + } + + if (body.error !== undefined) { + audit.failure('framing', 'body-rejected', body.error); + return broker.handle(undefined, (result) => sendResult(res, result)); + } + + const framed = buildRequestFromFrame(framedHeaders, framedRawHeaders, body.task); + if (framed.error !== undefined) { + audit.failure('framing', 'frame-rejected', framed.error); + return broker.handle(undefined, (result) => sendResult(res, result)); + } + + return broker.handle(framed.request, (result) => sendResult(res, result)); + }) + .catch((error) => { + audit.failure('server', 'unhandled-error', error && error.message); + if (!res.headersSent) sendResult(res, CANONICAL_ERROR_JSON); + }); +} + +function createServer(deps) { + const { broker, audit } = deps; + return createHardenedServer( + (req, res, isAccepting) => processRequest( + req, + res, + broker, + audit, + req.headers, + req.rawHeaders, + isAccepting, + ), + audit, + ); +} + +function safeCapabilityEquals(actual, expected) { + if (typeof actual !== 'string') return false; + const actualBytes = Buffer.from(actual, 'utf8'); + const expectedBytes = Buffer.from(expected, 'utf8'); + return actualBytes.length === expectedBytes.length + && crypto.timingSafeEqual(actualBytes, expectedBytes); +} + +function stripCapabilityHeader(req) { + const headers = { ...req.headers }; + delete headers['x-awf-capability']; + const rawHeaders = []; + for (let i = 0; i < req.rawHeaders.length; i += 2) { + if (req.rawHeaders[i].toLowerCase() === 'x-awf-capability') continue; + rawHeaders.push(req.rawHeaders[i], req.rawHeaders[i + 1]); + } + return { headers, rawHeaders }; +} + +/** + * Authenticated HTTP listener for sbx-primary reachability only. Every + * request must carry exactly one `x-awf-capability` header matching the + * broker-generated `query` token; the distinct `probe` token is accepted + * exactly once (pre-agent reachability proof), then permanently retired for + * the lifetime of this process. Neither token is ever logged, telemetered, or + * written to the audit ledger -- only the fixed category strings + * `'auth-rejected'` / `'sbx-ingress-probe'` are. + */ +function createTcpServer(deps) { + const { broker, audit, capabilities } = deps; + let probeAvailable = true; + return createHardenedServer((req, res, isAccepting) => { + const capabilityHeaders = req.rawHeaders.filter( + (_value, index) => index % 2 === 0 && req.rawHeaders[index].toLowerCase() === 'x-awf-capability', + ); + const supplied = req.headers['x-awf-capability']; + const isQuery = capabilityHeaders.length === 1 && safeCapabilityEquals(supplied, capabilities.query); + const isProbe = ( + probeAvailable + && capabilityHeaders.length === 1 + && safeCapabilityEquals(supplied, capabilities.probe) + ); + + if (isProbe) { + probeAvailable = false; + audit.lifecycle('sbx-ingress-probe'); + req.resume(); + return new Promise((resolve) => { + setTimeout(() => { + sendResult(res, CANONICAL_ERROR_JSON); + resolve(); + }, PROBE_RESPONSE_DELAY_MS); + }); + } + + if (!isQuery) { + audit.failure('transport', 'auth-rejected'); + req.resume(); + sendResult(res, CANONICAL_ERROR_JSON); + return Promise.resolve(); + } + + const framed = stripCapabilityHeader(req); + return processRequest( + req, + res, + broker, + audit, + framed.headers, + framed.rawHeaders, + isAccepting, + ); + }, audit); +} + +function listenOnSocket(server, config, audit) { + fs.rmSync(config.socketPath, { force: true }); + fs.mkdirSync(config.socketDir, { recursive: true, mode: 0o770 }); + + return new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(config.socketPath, () => { + try { + // The agent runs as the host user; hand it the socket explicitly + // rather than making the socket world-writable. + fs.chownSync(config.socketPath, config.socketUid, config.socketGid); + fs.chmodSync(config.socketPath, 0o660); + } catch (error) { + audit.lifecycle('socket-ownership-fallback', error.message); + fs.chmodSync(config.socketPath, 0o666); + } + + resolve(); + }); + }); +} + +function listenOnTcp(server, config) { + return new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(config.tcpPort, '0.0.0.0', resolve); + }); +} + +async function main() { + const config = loadConfig(); + const audit = createAuditLog(config.auditDir); + const telemetry = createRuntimeTelemetry(config.auditDir); + const { runId, seeds } = loadSeedMap(config.seedMapPath); + const runner = createEnclaveRunner(config); + + // Fail closed before accepting requests and deterministically reconcile + // enclaves left by a prior broker process for this exact run. Enclaves + // never pull and never fall back. + await runner.assertAvailable(); + await runner.reconcileRun(runId); + telemetry.emit({ + primaryBackend: config.primaryBackend, + boundedAgentBackend: config.backend, + lifecycleClass: 'startup', + capabilityState: 'supported', + category: 'ready', + }); + + const broker = createBroker({ config, seedMap: seeds, runId, audit, runner, telemetry }); + const unixServer = createServer({ broker, audit }); + const servers = [unixServer]; + + await listenOnSocket(unixServer, config, audit); + if (config.tcpPort !== undefined) { + const tcpServer = createTcpServer({ + broker, + audit, + capabilities: config.sbxIngressCapabilities, + }); + await listenOnTcp(tcpServer, config); + servers.push(tcpServer); + } + + // Write the ready file AFTER the socket is accepting connections. The compose + // healthcheck polls this file in the broker-only control mount. + fs.mkdirSync(config.controlDir, { recursive: true, mode: 0o700 }); + fs.writeFileSync(config.readyPath, '', { mode: 0o644 }); + + // Deliberately records no repository names, task text, model identity, or + // host paths beyond the fixed socket location. + audit.lifecycle('listening', { + repos: seeds.size, + backend: config.backend, + profile: config.profile, + ingress: config.tcpPort === undefined ? 'unix' : 'unix+sbx-http', + maxInvocations: config.maxInvocations, + }); + + let shuttingDown = false; + const shutdown = async () => { + if (shuttingDown) return; + shuttingDown = true; + broker.close(); + for (const server of servers) { + server.freezeAdmissions(); + server.close(); + } + const forcedExit = setTimeout(() => process.exit(1), 5000); + forcedExit.unref(); + try { + await Promise.race([ + Promise.all([ + ...servers.map((server) => server.drainAdmissions()), + broker.drain(), + ]), + new Promise((resolve) => setTimeout(resolve, SHUTDOWN_GRACE_MS)), + ]); + // Interrupted invocations leave no enclave behind: reconcile again. + await runner.reconcileRun(runId); + telemetry.emit({ + primaryBackend: config.primaryBackend, + boundedAgentBackend: config.backend, + lifecycleClass: 'cleanup', + capabilityState: 'supported', + category: 'success', + }); + process.exit(0); + } catch (error) { + audit.lifecycle('shutdown-cleanup-failed', error.message); + telemetry.emit({ + primaryBackend: config.primaryBackend, + boundedAgentBackend: config.backend, + lifecycleClass: 'cleanup', + capabilityState: 'supported', + category: 'cleanup-failed', + }); + process.exit(1); + } + }; + process.on('SIGTERM', shutdown); + process.on('SIGINT', shutdown); +} + +if (require.main === module) { + main().catch((error) => { + process.stderr.write(`[bounded-agent] broker failed to start: ${error.message}\n`); + process.exit(1); + }); +} + +module.exports = { + createServer, + createTcpServer, + listenOnSocket, + listenOnTcp, + MAX_HEADER_BYTES, + MAX_CONNECTIONS, +}; diff --git a/containers/bounded-agent/broker/workspace.js b/containers/bounded-agent/broker/workspace.js new file mode 100644 index 000000000..577a6acc3 --- /dev/null +++ b/containers/bounded-agent/broker/workspace.js @@ -0,0 +1,104 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +/** + * Per-invocation private workspace management. + * + * A bounded agent never writes to the repository: the immutable seed is + * bind-mounted read-only straight into the enclave, so there is no writable + * copy of private source anywhere on the host. The workspace therefore holds + * only three small, broker-owned files: + * + * - `task.txt` the caller's byte-bounded task text (read-only in the enclave) + * - `schema.json` the caller's finite response schema (read-only in the enclave) + * - `out` a pre-created, size-bounded regular file the enclave writes + * its single JSON answer to + */ + +/** Layout of one invocation directory, relative to the broker's work dir. */ +function invocationLayout(workDir, invocationId) { + const root = path.join(workDir, invocationId); + return { + root, + taskPath: path.join(root, 'task.txt'), + schemaPath: path.join(root, 'schema.json'), + outPath: path.join(root, 'out'), + }; +} + +/** + * Materializes the invocation workspace. + * + * The task and schema are written broker-owned and read-only; the output file + * is owned by the enclave uid so the unprivileged enclave process can write to + * it through its `rw` bind mount. + */ +function createInvocationWorkspace(params) { + const { config, invocationId, task, schema } = params; + const layout = invocationLayout(config.workDir, invocationId); + + fs.mkdirSync(layout.root, { recursive: true, mode: 0o700 }); + + fs.writeFileSync(layout.taskPath, task, { mode: 0o444 }); + fs.chmodSync(layout.taskPath, 0o444); + + fs.writeFileSync(layout.schemaPath, JSON.stringify(schema), { mode: 0o444 }); + fs.chmodSync(layout.schemaPath, 0o444); + + fs.writeFileSync(layout.outPath, '', { mode: 0o600 }); + fs.chownSync(layout.outPath, config.enclaveUid, config.enclaveGid); + + return layout; +} + +/** + * Reads the enclave's result file defensively. + * + * `O_NOFOLLOW` plus an explicit regular-file check means an enclave cannot make + * the broker read something else by replacing the result file with a symlink, + * FIFO, device, or socket. The size is checked against the configured exact + * bound before any bytes are consumed. Anything unexpected returns `undefined`, + * which the caller maps to the canonical error result. + */ +function readEnclaveOutput(outPath, maxOutputBytes) { + let fd; + try { + fd = fs.openSync(outPath, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK); + } catch { + return undefined; + } + + try { + const stat = fs.fstatSync(fd); + if (!stat.isFile()) return undefined; + if (stat.size > maxOutputBytes) return undefined; + + const buffer = Buffer.alloc(maxOutputBytes); + const bytesRead = fs.readSync(fd, buffer, 0, maxOutputBytes, 0); + const slice = buffer.subarray(0, bytesRead); + + // Reject anything that is not valid UTF-8 before it reaches the parser. + const text = slice.toString('utf8'); + if (!Buffer.from(text, 'utf8').equals(slice)) return undefined; + + return text; + } catch { + return undefined; + } finally { + fs.closeSync(fd); + } +} + +/** Destroys an invocation workspace. Safe to call repeatedly. */ +function destroyInvocationWorkspace(workDir, invocationId) { + fs.rmSync(path.join(workDir, invocationId), { recursive: true, force: true, maxRetries: 3 }); +} + +module.exports = { + invocationLayout, + createInvocationWorkspace, + readEnclaveOutput, + destroyInvocationWorkspace, +}; diff --git a/containers/bounded-agent/enclave-entrypoint.py b/containers/bounded-agent/enclave-entrypoint.py new file mode 100644 index 000000000..6f0df866b --- /dev/null +++ b/containers/bounded-agent/enclave-entrypoint.py @@ -0,0 +1,420 @@ +#!/usr/bin/env python3 +"""Fixed AWF bounded-agent enclave bootstrap. + +This is the *only* program that ever runs inside a bounded-agent enclave. It is +authored by AWF, baked into the image, and mounted nowhere: a request cannot +replace it, extend it, or pass it arguments. + +What it does, in order: + + 1. reads the caller's byte-bounded task text and finite response schema from + fixed read-only paths; + 2. runs a small, fixed model loop against the AWF API proxy — the enclave's + only reachable peer — using the trusted profile/model chosen by AWF + configuration; + 3. exposes exactly three local, read-only repository tools plus one terminal + "finish" tool. There is no shell, no network tool, no write tool, no + package installation, and no way to add a tool; + 4. writes the final answer, and nothing else, as a single JSON value to the + dedicated bounded result file. + +It deliberately holds no credentials: the API proxy injects the real key. It +never prints repository contents, task text, model output, or provider payloads +to stdout/stderr — the broker discards those streams anyway, so anything written +there would only be a latent leak if that ever changed. + +Standard library only. +""" + +import json +import os +import re +import sys +import time +import urllib.error +import urllib.request +from pathlib import Path + +# Fixed mount points. `main()` uses only these; they are never derived from the +# environment, from the task, or from anything a request can influence. +SEED_DIR = Path("/awf/seed") +TASK_PATH = Path("/awf/task.txt") +SCHEMA_PATH = Path("/awf/schema.json") +OUT_PATH = Path("/agent/out") + + +class Layout: + """The four fixed paths, threaded explicitly so `run()` stays testable.""" + + def __init__(self, seed_dir, task_path, schema_path, out_path): + # Resolved once so containment checks and relative-path reporting agree + # even when an ancestor is a symlink. + self.seed_dir = Path(seed_dir).resolve() + self.task_path = Path(task_path) + self.schema_path = Path(schema_path) + self.out_path = Path(out_path) + +# Fixed local tool bounds. Not configurable, and never caller-supplied. +MAX_LIST_ENTRIES = 200 +MAX_READ_BYTES = 8192 +MAX_SEARCH_RESULTS = 40 +MAX_SEARCH_PATTERN = 200 +MAX_TOOL_RESULT_BYTES = 12000 +HTTP_TIMEOUT_SECONDS = 60 + + +def _fail() -> "int": + """Exits without writing a result. The broker maps this to canonical ERROR.""" + return 1 + + +def _env_int(name: str, default: int) -> int: + raw = os.environ.get(name, "") + try: + value = int(raw) + except (TypeError, ValueError): + return default + return value if value > 0 else default + + +def _safe_repo_path(layout: Layout, relative: str) -> "Path | None": + """Resolves a model-supplied path strictly inside the read-only seed.""" + if not isinstance(relative, str) or len(relative) > 4096: + return None + candidate = (layout.seed_dir / relative.lstrip("/")).resolve() + try: + candidate.relative_to(layout.seed_dir) + except ValueError: + return None + return candidate + + +def tool_list_files(layout: Layout, args: dict) -> str: + target = _safe_repo_path(layout, args.get("path", ".")) + if target is None or not target.is_dir(): + return "error: not a directory inside the repository" + entries = [] + for entry in sorted(target.iterdir())[:MAX_LIST_ENTRIES]: + kind = "dir" if entry.is_dir() else "file" + entries.append(f"{kind} {entry.relative_to(layout.seed_dir)}") + return "\n".join(entries) if entries else "(empty)" + + +def tool_read_file(layout: Layout, args: dict) -> str: + target = _safe_repo_path(layout, args.get("path", "")) + if target is None or not target.is_file(): + return "error: not a file inside the repository" + try: + data = target.read_bytes()[:MAX_READ_BYTES] + except OSError: + return "error: unreadable" + return data.decode("utf-8", errors="replace") + + +def tool_search(layout: Layout, args: dict) -> str: + pattern = args.get("pattern", "") + if not isinstance(pattern, str) or not pattern or len(pattern) > MAX_SEARCH_PATTERN: + return "error: invalid pattern" + root = _safe_repo_path(layout, args.get("path", ".")) + if root is None or not root.is_dir(): + return "error: not a directory inside the repository" + needle = re.escape(pattern) + matcher = re.compile(needle) + results = [] + for path in sorted(root.rglob("*")): + if len(results) >= MAX_SEARCH_RESULTS: + break + try: + relative_path = path.relative_to(layout.seed_dir) + except ValueError: + continue + target = _safe_repo_path(layout, str(relative_path)) + if target is None or not target.is_file(): + continue + try: + text = target.read_bytes()[:MAX_READ_BYTES].decode("utf-8", errors="replace") + except OSError: + continue + for lineno, line in enumerate(text.splitlines(), start=1): + if matcher.search(line): + results.append(f"{relative_path}:{lineno}") + break + return "\n".join(results) if results else "(no matches)" + + +LOCAL_TOOLS = { + "list_files": tool_list_files, + "read_file": tool_read_file, + "search": tool_search, +} + +TOOL_DESCRIPTIONS = [ + { + "name": "list_files", + "description": "List entries of a directory inside the read-only repository.", + "parameters": { + "type": "object", + "properties": {"path": {"type": "string"}}, + "required": ["path"], + }, + }, + { + "name": "read_file", + "description": ( + "Read up to %d bytes of a file inside the read-only repository." % MAX_READ_BYTES + ), + "parameters": { + "type": "object", + "properties": {"path": {"type": "string"}}, + "required": ["path"], + }, + }, + { + "name": "search", + "description": "Find files containing a literal substring inside the read-only repository.", + "parameters": { + "type": "object", + "properties": {"pattern": {"type": "string"}, "path": {"type": "string"}}, + "required": ["pattern"], + }, + }, + { + "name": "finish", + "description": ( + "Record the final answer. `result` must conform exactly to the declared " + "response schema. Calling this ends the task." + ), + "parameters": { + "type": "object", + "properties": {"result": {}}, + "required": ["result"], + }, + }, +] + + +def system_prompt(schema_text: str) -> str: + return ( + "You are a bounded analysis agent running inside an isolated enclave.\n" + "You can read one private repository at /awf/seed through the provided " + "read-only tools. You have no network access other than this API, no shell, " + "no write access, and no host access.\n\n" + "Answer the user's task by calling tools, then call `finish` exactly once " + "with a `result` that conforms EXACTLY to this finite response schema:\n" + f"{schema_text}\n\n" + "Schema semantics: `const` is one fixed value; `boolean` is true/false; " + "`enum` values are the only permitted values; `integer` is an inclusive " + "bounded range; `object` requires every declared field and no others; " + "`tuple`/`array` are fixed length; `union` values are " + '{\"tag\":..., \"value\":...}. Free-form prose is never a valid result.\n' + "Do not explain your reasoning in the final answer. Never emit anything " + "except tool calls and the final `finish` call." + ) + + +def _post_json(url: str, payload: dict, headers: dict) -> dict: + body = json.dumps(payload).encode("utf-8") + request = urllib.request.Request(url, data=body, method="POST") + request.add_header("content-type", "application/json") + for key, value in headers.items(): + request.add_header(key, value) + with urllib.request.urlopen(request, timeout=HTTP_TIMEOUT_SECONDS) as response: + return json.loads(response.read().decode("utf-8")) + + +class OpenAiProfile: + """Narrow OpenAI-compatible chat-completions loop.""" + + def __init__(self, endpoint: str, model: str, max_tokens: int) -> None: + self.url = f"{endpoint}/v1/chat/completions" + self.model = model + self.max_tokens = max_tokens + + def initial_messages(self, schema_text: str, task: str) -> list: + return [ + {"role": "system", "content": system_prompt(schema_text)}, + {"role": "user", "content": task}, + ] + + def request(self, messages: list) -> dict: + payload = { + "model": self.model, + "messages": messages, + "max_tokens": self.max_tokens, + "tools": [ + {"type": "function", "function": tool} for tool in TOOL_DESCRIPTIONS + ], + } + return _post_json(self.url, payload, {}) + + def parse(self, response: dict) -> "tuple[list, list]": + """Returns (assistant message to append, list of (id, name, args)).""" + choices = response.get("choices") or [] + if not choices: + return [], [] + message = choices[0].get("message") or {} + calls = [] + for call in message.get("tool_calls") or []: + function = call.get("function") or {} + try: + args = json.loads(function.get("arguments") or "{}") + except (TypeError, ValueError): + args = {} + if not isinstance(args, dict): + args = {} + calls.append((call.get("id") or "", function.get("name") or "", args)) + return [message], calls + + def tool_result_messages(self, results: list) -> list: + return [ + {"role": "tool", "tool_call_id": call_id, "content": content} + for call_id, _name, content in results + ] + + +class AnthropicProfile: + """Narrow Anthropic-compatible messages loop.""" + + def __init__(self, endpoint: str, model: str, max_tokens: int) -> None: + self.url = f"{endpoint}/v1/messages" + self.model = model + self.max_tokens = max_tokens + self.system = "" + + def initial_messages(self, schema_text: str, task: str) -> list: + self.system = system_prompt(schema_text) + return [{"role": "user", "content": task}] + + def request(self, messages: list) -> dict: + payload = { + "model": self.model, + "system": self.system, + "messages": messages, + "max_tokens": self.max_tokens, + "tools": [ + { + "name": tool["name"], + "description": tool["description"], + "input_schema": tool["parameters"], + } + for tool in TOOL_DESCRIPTIONS + ], + } + return _post_json(self.url, payload, {"anthropic-version": "2023-06-01"}) + + def parse(self, response: dict) -> "tuple[list, list]": + content = response.get("content") or [] + calls = [] + for block in content: + if block.get("type") == "tool_use": + args = block.get("input") + if not isinstance(args, dict): + args = {} + calls.append((block.get("id") or "", block.get("name") or "", args)) + return [{"role": "assistant", "content": content}], calls + + def tool_result_messages(self, results: list) -> list: + return [ + { + "role": "user", + "content": [ + {"type": "tool_result", "tool_use_id": call_id, "content": content} + for call_id, _name, content in results + ], + } + ] + + +def build_profile(endpoint: str, model: str, max_tokens: int): + profile = os.environ.get("AWF_BOUNDED_AGENT_PROFILE", "") + if profile == "anthropic": + return AnthropicProfile(endpoint, model, max_tokens) + if profile == "openai": + return OpenAiProfile(endpoint, model, max_tokens) + return None + + +def write_result(layout: Layout, value, max_output_bytes: int) -> bool: + """Writes exactly one JSON value to the dedicated bounded result file.""" + try: + encoded = json.dumps(value, separators=(",", ":"), ensure_ascii=False).encode("utf-8") + except (TypeError, ValueError): + return False + if len(encoded) > max_output_bytes: + return False + try: + with open(layout.out_path, "wb") as handle: + handle.write(encoded) + except OSError: + return False + return True + + +def run(layout: Layout) -> int: + """Runs one bounded-agent invocation against the given fixed layout.""" + endpoint = os.environ.get("AWF_BOUNDED_AGENT_API_ENDPOINT", "") + model = os.environ.get("AWF_BOUNDED_AGENT_MODEL", "") + if not endpoint or not model: + return _fail() + + max_requests = _env_int("AWF_BOUNDED_AGENT_MAX_MODEL_REQUESTS", 8) + max_tokens = _env_int("AWF_BOUNDED_AGENT_MAX_MODEL_TOKENS", 1024) + max_output_bytes = _env_int("AWF_BOUNDED_AGENT_MAX_OUTPUT_BYTES", 8192) + deadline = time.monotonic() + _env_int("AWF_BOUNDED_AGENT_DEADLINE_SECONDS", 120) + + try: + task = layout.task_path.read_text(encoding="utf-8") + schema_text = layout.schema_path.read_text(encoding="utf-8") + json.loads(schema_text) + except (OSError, ValueError): + return _fail() + + profile = build_profile(endpoint, model, max_tokens) + if profile is None: + return _fail() + + messages = profile.initial_messages(schema_text, task) + + for _ in range(max_requests): + if time.monotonic() >= deadline: + return _fail() + try: + response = profile.request(messages) + except (urllib.error.URLError, urllib.error.HTTPError, ValueError, OSError, TimeoutError): + return _fail() + + appended, calls = profile.parse(response) + messages.extend(appended) + if not calls: + # A model that stops calling tools without finishing produces no + # result; the broker reports the canonical error. + return _fail() + + results = [] + for call_id, name, args in calls: + if name == "finish": + if write_result(layout, args.get("result"), max_output_bytes): + return 0 + return _fail() + handler = LOCAL_TOOLS.get(name) + if handler is None: + results.append((call_id, name, "error: unknown tool")) + continue + try: + output = handler(layout, args) + except Exception: # noqa: BLE001 - never leak a traceback + output = "error: tool failed" + results.append((call_id, name, output[:MAX_TOOL_RESULT_BYTES])) + + messages.extend(profile.tool_result_messages(results)) + + return _fail() + + +def main() -> int: + """Entry point. Uses only the fixed mount points; nothing is configurable.""" + return run(Layout(SEED_DIR, TASK_PATH, SCHEMA_PATH, OUT_PATH)) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/containers/bounded-query/bounded-execution/finite-disclosure.js b/containers/bounded-query/bounded-execution/finite-disclosure.js index 9111a1371..bc6fec360 100644 --- a/containers/bounded-query/bounded-execution/finite-disclosure.js +++ b/containers/bounded-query/bounded-execution/finite-disclosure.js @@ -295,8 +295,65 @@ function schemaCardinality(schema) { } } +const MAX_EXACT_SCHEMA_CARDINALITY = 1n << 1024n; +const CAPPED_SCHEMA_CARDINALITY = MAX_EXACT_SCHEMA_CARDINALITY + 1n; + +function cappedMultiply(left, right) { + if (left === 0n || right === 0n) return 0n; + if (left > MAX_EXACT_SCHEMA_CARDINALITY / right) return CAPPED_SCHEMA_CARDINALITY; + return left * right; +} + +function cappedPower(base, exponent) { + let result = 1n; + let factor = base; + let remaining = exponent; + while (remaining > 0) { + if ((remaining & 1) === 1) result = cappedMultiply(result, factor); + if (result > MAX_EXACT_SCHEMA_CARDINALITY) return result; + remaining = Math.floor(remaining / 2); + if (remaining > 0) factor = cappedMultiply(factor, factor); + } + return result; +} + +function cappedSchemaCardinality(schema) { + switch (schema.type) { + case 'const': + return 1n; + case 'boolean': + return 2n; + case 'enum': + return BigInt(schema.values.length); + case 'integer': + return BigInt(schema.maximum) - BigInt(schema.minimum) + 1n; + case 'object': + return schema.fields.reduce( + (acc, field) => cappedMultiply(acc, cappedSchemaCardinality(field.schema)), + 1n, + ); + case 'tuple': + return schema.items.reduce( + (acc, item) => cappedMultiply(acc, cappedSchemaCardinality(item)), + 1n, + ); + case 'array': + return cappedPower(cappedSchemaCardinality(schema.items), schema.length); + case 'union': { + let total = 0n; + for (const variant of schema.variants) { + total += cappedSchemaCardinality(variant.schema); + if (total > MAX_EXACT_SCHEMA_CARDINALITY) return CAPPED_SCHEMA_CARDINALITY; + } + return total; + } + default: + throw new Error(`unreachable schema type: ${schema.type}`); + } +} + function queryBitsForSchema(schema) { - return RESULT_STATUS_BIT_COST + ceilLog2BigInt(schemaCardinality(schema)) + TIMING_BUCKET_BITS; + return RESULT_STATUS_BIT_COST + ceilLog2BigInt(cappedSchemaCardinality(schema)) + TIMING_BUCKET_BITS; } function jsonLiteralEquals(value, literal) { diff --git a/containers/bounded-query/bounded-execution/protected-audit.js b/containers/bounded-query/bounded-execution/protected-audit.js index a4ed53035..cdcbf810a 100644 --- a/containers/bounded-query/bounded-execution/protected-audit.js +++ b/containers/bounded-query/bounded-execution/protected-audit.js @@ -22,11 +22,14 @@ function redactAuditDetail(detail) { return detail === undefined ? undefined : String(detail).slice(0, MAX_REASON_LENGTH); } -function createAuditLog(auditDir) { +/** Default audit filename, kept for bounded-query compatibility. */ +const DEFAULT_AUDIT_FILENAME = 'bounded-query.jsonl'; + +function createAuditLog(auditDir, fileName = DEFAULT_AUDIT_FILENAME) { let fd; try { fs.mkdirSync(auditDir, { recursive: true, mode: 0o700 }); - const auditPath = path.join(auditDir, 'bounded-query.jsonl'); + const auditPath = path.join(auditDir, fileName); fd = fs.openSync(auditPath, 'a', 0o600); } catch (error) { // Losing the audit file must not take the broker down; fall back to @@ -79,6 +82,7 @@ function createAuditLog(auditDir) { } module.exports = { + DEFAULT_AUDIT_FILENAME, createAuditLog, createProtectedAuditLog: createAuditLog, redactAuditDetail, diff --git a/docs/awf-config-spec.md b/docs/awf-config-spec.md index 981fa85fb..ddaa7e537 100644 --- a/docs/awf-config-spec.md +++ b/docs/awf-config-spec.md @@ -2061,6 +2061,336 @@ matched pair. the Docker CLI. Both are inert inside a query: there is no network, no Docker socket, no capability, and the entrypoint is fixed to `python3`. +## 15. Bounded Agents + +### 15.1 Purpose + +A *bounded agent* is the agentic sibling of a bounded query (§14). Instead of +running an agent-authored Python script, a trusted broker runs a **fixed, +AWF-authored model loop** inside a single-use *enclave* that reads one +immutable repository seed read-only, may call a configured model a bounded +number of times through the AWF API proxy, and must reduce its work to one +value conforming to a finite response schema the caller declared up front. + +Bounded agents exist for questions that need judgment or multi-step reading +rather than a deterministic script, while keeping exactly the same disclosure +bound: the caller observes only `{"status":"ok","result":}` or +`{"status":"error"}`. + +Bounded agents reuse §14's sensitivity categories and budget table verbatim, +but they never share a **ledger**: each subsystem runs its own broker with its +own seed map in its own private root, so spending on one can never consume the +other's remaining balance. The remaining balance is never disclosed to the +caller in any form. + +The feature is **config-only**: there are no `--bounded-agents-*` CLI flags. + +### 15.2 Configuration + +The root object MAY contain a `boundedAgents` section: + +```json +{ + "boundedAgents": { + "enabled": true, + "privateRepos": [ + { "repo": "my-org/private-service", "sensitivity": "internal" } + ], + "runtime": "docker", + "profile": "openai", + "model": "gpt-4o-mini", + "timeout": 120, + "memoryLimit": "512m", + "cpuLimit": "1", + "pidsLimit": 128, + "tmpfsLimit": "64m", + "maxOutputBytes": 8192, + "maxTaskBytes": 4096, + "maxInvocations": 8, + "maxModelRequests": 8, + "maxModelTokens": 1024 + } +} +``` + +| Field | Type | Default | Notes | +|-------|------|---------|-------| +| `enabled` | boolean | `false` | Only an explicit `true` enables the subsystem. | +| `privateRepos` | array | — | Required when enabled. Each entry is `{ repo, sensitivity }`; `repo` MUST be a bare `owner/repo` slug and MUST be unique case-insensitively. There is no legacy bare-string form. | +| `runtime` | `docker` \| `gvisor` \| `sbx` | `docker` | `docker` and `gvisor` are implemented; `sbx` is capability-blocked (§15.7). | +| `profile` | `openai` \| `anthropic` | `openai` | Trusted provider protocol the enclave speaks to the API proxy. | +| `model` | string | — | Required when enabled. A request can never choose or override it. | +| `timeout` | integer (1–540) | `120` | Wall-clock bound for one enclave invocation. Capped so the 10-minute response bucket reserves its final minute for termination, validation, and cleanup. | +| `memoryLimit` | string | `"512m"` | Docker memory limit; swap disabled at the same value. | +| `cpuLimit` | string | `"1"` | Docker `--cpus`. | +| `pidsLimit` | integer | `128` | Docker `--pids-limit`. | +| `tmpfsLimit` | string | `"64m"` | Size bound for each writable tmpfs (`/tmp` and the `/agent` work/result root). | +| `maxOutputBytes` | integer (1–8192) | `8192` | Exact size bound on the dedicated result file. | +| `maxTaskBytes` | integer (1–65536) | `4096` | Byte bound on the caller-supplied task text. | +| `maxInvocations` | integer | `8` | Per-run response cap; every response, including a rejection, counts. | +| `maxModelRequests` | integer (1–64) | `8` | Model requests one invocation may issue. | +| `maxModelTokens` | integer (1–32768) | `1024` | `max_tokens` per model call. | + +Every default is deliberately conservative: a bounded agent is a *model* +reading confidential source, so the safe posture is a small, short-lived, +low-token enclave that an operator must explicitly widen. + +Bounded agents additionally REQUIRE, at preflight: + +- the AWF API proxy to be enabled — the enclave holds no credentials and the + API proxy is its only permitted upstream egress; +- a supported configured API target for the selected `profile` (an OpenAI + credential for `openai`, an Anthropic credential for `anthropic`); +- a staging credential in `GH_TOKEN`/`GITHUB_TOKEN` on the AWF host; +- a Unix-socket Docker host; +- the **primary agent** runtime to be proven available — `docker`, + `runsc` registration for `gvisor`, or a proven ingress path (Unix + passthrough or authenticated `sbx-http`) for a primary `sbx` runtime. There + is no blanket rejection of a primary microVM runtime; availability is + proven independently for each run (§15.7.1); +- the selected **bounded-agent enclave** `runtime` to be proven available; +- `enableDind` to be disabled, because primary-agent access to the enclave's + Docker daemon would bypass every finite-disclosure boundary. This holds + regardless of primary or enclave backend — there is no runtime combination + in which exposing that socket to the primary agent is safe. + +Any failure aborts the run before the primary agent starts. + +### 15.3 Request/Result Protocol + +A bounded-agent request selects exactly three things: + +| Field | Meaning | +|-------|---------| +| `privateRepo` | One configured repository, by `owner/repo` slug. | +| `schema` | A finite response schema, using the same algebra as §14.3. | +| `task` | Byte-bounded task text, forwarded verbatim into the enclave prompt. | + +The `task` is byte-bounded *input*, never configuration: it cannot add a tool, +change the model, reach an endpoint, or alter any limit. + +Everything else is fixed trusted configuration and MUST be rejected if it +appears in a request — image, command, executable, mount, path, environment, +endpoint, network, proxy, credential, timeout, resource limit, runtime, or +tool definition — as MUST any unknown key. Rejecting explicitly named controls +in addition to the generic unknown-key rule is redundant by construction; it +is retained so an accidental future widening of the accepted key set fails a +test rather than silently granting a capability. + +The canonical success/error envelopes, the finite schema algebra, the +information charge (`1` status bit + `ceil(log2(cardinality))` + `3` timing +bits), the six fixed timing buckets, strict JSON parsing, and canonical +re-serialization are all the shared bounded-execution primitives introduced +for bounded queries (§14.3–§14.5) and are reused unchanged. + +The charge is debited from the repository's run balance **before** any +workspace is materialized or any container is created, and is never refunded. + +### 15.4 Trusted Host Lifecycle + +Identical in shape to §14.6, against a disjoint private root: + +1. **Preflight before staging.** Configuration validation and the enclave + runtime capability proof run first, so a run that could never launch an + enclave never clones a private repository. +2. **Sanitized seeds.** One immutable seed per configured repository is cloned + with the staging credential, scrubbed of remotes, credential helpers, + hooks, alternates, worktree links, and reflogs, rejected outright if it + declares submodules, made read-only, and verified read-only. +3. **Credential scrub before launch.** The `GIT_ASKPASS` helper, the 0600 + token file, and the isolated staging `HOME` are removed before any + container exists. The credential never appears in argv, a URL, a log line, + the compose file, or any container environment. +4. **Protected directories and audit.** Seeds, per-invocation workspaces, + control state, and the audit log live under a `0700` broker-private root at + `/var/tmp/awf-bounded-agent-private--`, which is asserted not + to alias, contain, or be contained by any path visible to a primary agent + in any supported backend. Only a separate ingress root (broker socket + + generated `SKILL.md`/wrapper) is mounted into the agent. +5. **Deterministic orphan cleanup.** Every enclave carries + `awf.bounded-agent.run=`; teardown force-removes every container with + that label, including under `--keep-containers`, and the broker reconciles + the same label at startup and shutdown. + +### 15.5 Enclave Execution + +For each accepted request the broker launches a fresh, uniquely named, +labelled container with a frozen argument vector: + +- `--network ` — the enclave joins **only** the + dedicated network (§15.6); +- `--read-only` root filesystem, with the immutable seed bind-mounted `ro` at + `/awf/seed` (there is no writable copy of private source anywhere); +- the caller's task and schema bind-mounted `ro`; the result file bind-mounted + `rw`; +- bounded `--tmpfs` mounts for `/tmp` and the `/agent` work/result root; +- fixed non-root uid/gid `65534:65534`, `--cap-drop ALL`, + `--security-opt no-new-privileges:true`, a seccomp profile, and + memory/memory-swap/CPU/PID/`RLIMIT_FSIZE`/`RLIMIT_NOFILE` bounds plus the + wall-clock timeout; +- `--pull never`. + +Cleanup runs before the response: the container is force-removed and the +workspace destroyed, and only then is the timing bucket selected. + +The result MUST be a single JSON value in the dedicated bounded result file, +of at most `maxOutputBytes`. The broker reads it with `O_NOFOLLOW` plus an +explicit regular-file check, rejects invalid UTF-8, validates it strictly +against the declared schema, and canonically re-serializes it before +returning. Enclave stdout and stderr are captured only so the child cannot +block on a full pipe, and are then discarded. + +The protected audit log never records the task, the repository name, the +transcript, the raw result, host paths, tokens, or provider payloads — only an +invocation id, the trusted sensitivity class, the charge, the timing bucket, +and a failure category. + +### 15.6 Network Topology + +Bounded agents introduce one dedicated Docker network, `awf-bounded-agent`, +declared `internal: true` with an explicit `name:` (the broker launches +enclaves with a fixed `docker run --network ` argument and must not have +to derive a Compose project prefix at runtime). + +- The **enclave** is a member of that network and of nothing else. It is not on + `awf-net` or `awf-ext`, has no Squid route and no general proxy, and cannot + reach the primary agent, the broker, the safe-outputs collector, the MCP + gateway, or the CLI proxy. +- A **dedicated API-proxy instance** joins `awf-bounded-agent` at a fixed + address/alias and a separate egress bridge that no agent can join. It is the + enclave's only upstream egress and the sole holder of a real provider + credential. Its token logs, metrics, and quota state live under the + bounded-agent private root, so enclave request metadata cannot form a side + channel through the primary agent's API-proxy telemetry. +- The **broker** runs with `network_mode: none` and never joins the enclave + network. It receives the Docker socket only because it launches enclaves; + that path never enters the agent's environment or volumes. When the + runtime-backend proof requires it (a primary `sbx` runtime unable to prove a + direct Unix-socket passthrough, §15.7.1), the broker instead exposes a + dedicated ingress network with one ephemeral port published only on the + Docker host-gateway address, gated by a random, single-run capability token + proven reachable before the primary agent starts; the broker itself never + joins the enclave's `awf-bounded-agent` network either way. + +### 15.7 Runtime Backends + +`docker` uses the daemon's default OCI runtime. `gvisor` requires the `runsc` +OCI runtime to be registered with the daemon; availability is proven exactly at +preflight and again at broker startup, and an unavailable `runsc` NEVER +downgrades to the default runtime. + +`sbx` is accepted by the JSON Schema but is **capability-blocked**: AWF ships a +dedicated bounded-agent sbx capability probe (host-side +`src/bounded-agent/sbx-capability.ts`, container-side +`containers/bounded-agent/broker/sbx-capability-probe.js`) that inspects the +exact audited Docker Sandboxes CLI surface using `sbx version`, authenticated +non-mutating `sbx ls`, and `create --help` / `exec --help` against the audited +version (`v0.37.1`). It reports every missing capability in structured JSON — +never a single collapsed boolean, and never a "not yet implemented" +placeholder. The blocked runner defines `create`, `exec`, `stop`, and +`rm --force`, but preflight does not claim to execute that lifecycle. + +The bounded-agent enclave's network requirement is strictly harder than a +bounded query's: it must reach *exactly one* peer (the dedicated API proxy), +not "no network at all". Current `sbx create` exposes `--cpus`, `--memory`, +`--name`, `--template`, and read-only same-path mounts, but no enforceable, +mandatory API-proxy-only network policy (an advisory `HTTP_PROXY` env var is +not a hard network policy and is never treated as one), no PID limits, no disk +limits, no per-file size limits, and no pinned, digest-verified AWF +bounded-agent template/bootstrap. The probe therefore always reports these +missing and `supported` can never be `true` for the currently audited +version — an intentional, structural "no false pass" design, not an +oversight. AWF rejects this runtime before staging or compose assembly, mounts +neither the Docker socket nor any sbx daemon credential, and the broker's +`SbxEnclaveRunner.assertAvailable()` throws immediately if ever invoked. +Support remains blocked until sbx provides enforceable versions of all +controls and AWF publishes a digest-pinned, standard-library-only enclave +bootstrap. + +#### 15.7.1 Primary-agent and bounded-agent runtime matrix + +The primary agent runtime and the bounded-agent enclave runtime are separate, +independently-proven sandbox decisions — mirroring §14's primary-agent/query +matrix. `container.containerRuntime` selects the primary agent; +`boundedAgents.runtime` selects the single-use enclave. The broker never +reuses the primary agent sandbox; every accepted invocation creates a new +container with a unique run identity and destroys it before returning. No +combination ever falls back to a weaker or different backend. + +`src/bounded-agent/runtime-matrix.ts` evaluates all nine +`primaryBackend` × `boundedAgentBackend` combinations independently and +records `lifecycleClass: 'invocation'`, `capabilityState`, and `category` per +cell for telemetry — never the task, repository name, provider payload, or +capability token. + +| Primary agent | Docker enclave | gVisor enclave | sbx enclave | +|---|---|---|---| +| Docker | Supported when Docker is available | Supported when `runsc` is registered | **Blocked** by mandatory sbx enclave probes | +| gVisor | Supported when the primary `runsc` runtime is available | Supported when `runsc` is registered | **Blocked** by mandatory sbx enclave probes | +| sbx | Supported when primary sbx ingress (Unix passthrough or authenticated `sbx-http`) is proven | Supported when primary sbx ingress and `runsc` are proven | **Blocked** by mandatory sbx enclave probes | + +Six of the nine cells are supported once the relevant runtime(s) are proven +available; the three `sbx`-enclave cells are not, and remain blocked until the +capability proof in §15.7 can report `supported: true` for an audited sbx +version. An unavailable primary runtime fails at primary preflight, before any +repository is staged; an unavailable enclave runtime fails at enclave +preflight, for the same reason. + +`scripts/ci/report-bounded-agent-runtime-matrix.js` renders this matrix from +live host probes for CI/local use and reports an explicit `BLOCKED` result — +exiting non-zero under `--require /` — rather than a +false pass when no real sbx binary is present. + +### 15.8 Agent Interface + +When enabled, AWF generates two agent-visible artifacts in the ingress root: + +- a `bounded-agent` CLI (installed at `/tmp/awf-lib/bounded-agent`, added to + `PATH` by the agent entrypoint), and +- a read-only `SKILL.md` installed under `~/.github/skills/bounded-agent/`. + +The CLI accepts exactly `--repo owner/repo`, `--schema ''`, and the task +text on stdin. It always prints exactly one line of canonical JSON, writes +nothing to stderr, and exits `0` — for every outcome and every failure. + +The enclave image is minimal and fixed: standard-library Python 3 plus one +AWF-authored bootstrap. There is no shell tooling for the model to reach, no +`gh`, no git, no package manager, no host state, and no credential. The model +gets three read-only repository tools (list, read, search) confined to the +seed, plus one terminal tool that records the final answer. Safe outputs and +MCP are not available inside the enclave. + +### 15.9 Provider Disclosure Caveat + +A bounded agent necessarily sends repository-derived content — file listings, +file excerpts, and search hits selected by the model — to the configured model +provider through the AWF API proxy. **The information-budget ledger bounds what +the *calling agent* learns, not what the *provider* sees.** + +This is a materially different exposure from a bounded query, whose Python +sandbox has no network at all. Operators MUST treat the configured provider as +an authorized recipient of repository contents before enabling bounded agents +for a repository, and SHOULD prefer bounded queries when a deterministic script +can answer the question. + +### 15.10 Residual Channels and Limits + +- Disclosure to the calling agent is bounded by the declared schema's charge + plus the status and timing channels, debited before any workspace or + container exists; `public` repositories are schema/operationally bounded but + not bit-metered. +- `maxInvocations` counts every response, including rejections. +- Response timing is bucketed to one of six fixed boundaries and charged; + container and workspace cleanup complete before the bucket is selected. +- Model requests per invocation, completion tokens per request, task bytes, and + result bytes are all separately bounded, but a model that is told to encode + data in its final answer is still limited only by the declared schema's + cardinality — which is exactly what the ledger charges for. +- Provider-side exposure is out of scope for the ledger (§15.9). +- The enclave shares the audited no-network sandbox seccomp profile with + bounded queries; unlike a bounded query it does have a network interface, to + the API proxy only. + ## Normative References - [RFC 2119](https://www.rfc-editor.org/rfc/rfc2119) — Key words for use in diff --git a/docs/awf-config.schema.json b/docs/awf-config.schema.json index 92cfbd3c9..9df2571fc 100644 --- a/docs/awf-config.schema.json +++ b/docs/awf-config.schema.json @@ -939,6 +939,157 @@ "privateRepos" ] } + }, + "boundedAgents": { + "type": "object", + "description": "Bounded-agent enclave configuration. When enabled, AWF stages an immutable seed per configured private repository, starts an offline broker, and exposes a fixed `bounded-agent` CLI plus a generated skill to the agent. Each invocation runs a fixed, AWF-authored model loop inside a single-use enclave that joins only a dedicated `internal` bounded-agent Docker network whose sole other member is the AWF API proxy. Requires the API proxy and a supported configured model route. See docs/awf-config-spec.md §15.", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable bounded agents for this run. Requires a staging credential in GH_TOKEN or GITHUB_TOKEN on the AWF host, an enabled API proxy, and a configured `profile`/`model` route. Default: false.", + "default": false + }, + "privateRepos": { + "type": "array", + "description": "Private repositories a bounded agent may reason about, each with a trusted confidentiality category that fixes its per-run information budget (`public` unmetered, `internal` 64 bits/run, `confidential` 8 bits/run, `sealed` 0 bits/run — never launches an enclave). Each `repo` must be a bare `owner/repo` slug — no scheme, host, credentials, path traversal, query string, fragment, or wildcard. Repository names must be unique case-insensitively. Bounded agents keep a ledger separate from bounded queries.", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "repo", + "sensitivity" + ], + "properties": { + "repo": { + "type": "string", + "maxLength": 140, + "pattern": "^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})/(?!\\.\\.?$)(?!.*\\.\\.)[A-Za-z0-9._-]{1,100}$" + }, + "sensitivity": { + "type": "string", + "enum": [ + "public", + "internal", + "confidential", + "sealed" + ], + "description": "Confidentiality category, which fixes this repository's immutable per-run information budget. Cannot be increased by configuration." + } + } + }, + "minItems": 1 + }, + "runtime": { + "type": "string", + "enum": [ + "docker", + "gvisor", + "sbx" + ], + "description": "Sandbox runtime backend used to execute the bounded-agent enclave, selected independently of the primary agent runtime. \"docker\" uses the daemon default runtime; \"gvisor\" requires the runsc OCI runtime; \"sbx\" is a fail-closed preview and is blocked until the installed sbx CLI proves the mandatory API-proxy-only network policy, read-only targeted mounts, unprivileged exec/workdir, and pids/disk/fsize/lifecycle controls this enclave requires. No backend ever falls back. Default: \"docker\".", + "default": "docker" + }, + "profile": { + "type": "string", + "enum": [ + "openai", + "anthropic" + ], + "description": "Trusted provider protocol the enclave speaks to the AWF API proxy: \"openai\" uses POST /v1/chat/completions, \"anthropic\" uses POST /v1/messages. The API proxy injects the real credential; the enclave never holds one. A request can never choose the profile. Default: \"openai\".", + "default": "openai" + }, + "model": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,199}$", + "description": "Model identifier sent on every enclave request. Required when enabled. A request can never choose or override it." + }, + "timeout": { + "type": "integer", + "minimum": 1, + "maximum": 540, + "description": "Maximum wall-clock time in seconds allowed for a single enclave invocation. Capped at 540 so the 10-minute response bucket reserves its final minute for termination, validation, container removal, and workspace cleanup. Default: 120.", + "default": 120 + }, + "memoryLimit": { + "type": "string", + "pattern": "^[1-9][0-9]*[bkmgBKMG]$", + "description": "Docker-style memory limit applied to the enclave (e.g. \"512m\"). Swap is disabled at the same value. Default: \"512m\".", + "default": "512m" + }, + "cpuLimit": { + "type": "string", + "pattern": "^(?:[0-9]{1,2})(?:\\.[0-9]{1,3})?$", + "description": "Fractional CPU limit applied to the enclave (Docker --cpus). Default: \"1\".", + "default": "1" + }, + "pidsLimit": { + "type": "integer", + "minimum": 1, + "maximum": 4096, + "description": "Maximum number of processes/threads the enclave may create. Default: 128.", + "default": 128 + }, + "tmpfsLimit": { + "type": "string", + "pattern": "^[1-9][0-9]*[bkmgBKMG]$", + "description": "Docker-style size limit for each of the enclave's writable tmpfs mounts (/tmp and the /agent work/result root). Default: \"64m\".", + "default": "64m" + }, + "maxOutputBytes": { + "type": "integer", + "minimum": 1, + "maximum": 8192, + "description": "Maximum size in bytes of the enclave's dedicated result file. The broker reads back at most this many bytes and requires exactly one JSON value conforming to the declared finite schema. Default: 8192.", + "default": 8192 + }, + "maxTaskBytes": { + "type": "integer", + "minimum": 1, + "maximum": 65536, + "description": "Maximum size in bytes of the caller-supplied bounded task text. The task is byte-bounded input forwarded verbatim into the enclave prompt; it is never interpreted as configuration. Default: 4096.", + "default": 4096 + }, + "maxInvocations": { + "type": "integer", + "minimum": 1, + "maximum": 1000, + "description": "Maximum number of enclave responses permitted for the current AWF run. Every response — including a rejection — counts. Exhaustion returns the canonical error without launching an enclave. Default: 8.", + "default": 8 + }, + "maxModelRequests": { + "type": "integer", + "minimum": 1, + "maximum": 64, + "description": "Maximum number of model requests one enclave invocation may issue through the API proxy. Default: 8.", + "default": 8 + }, + "maxModelTokens": { + "type": "integer", + "minimum": 1, + "maximum": 32768, + "description": "Maximum completion tokens requested per model call (max_tokens). Default: 1024.", + "default": 1024 + } + }, + "if": { + "properties": { + "enabled": { + "const": true + } + }, + "required": [ + "enabled" + ] + }, + "then": { + "required": [ + "privateRepos", + "model" + ] + } } }, "$defs": { diff --git a/docs/bounded-agents.md b/docs/bounded-agents.md new file mode 100644 index 000000000..5a1cd1d29 --- /dev/null +++ b/docs/bounded-agents.md @@ -0,0 +1,401 @@ +# Bounded Agents + +Delegate narrow, brokered *agentic* tasks about private repositories to an +isolated enclave whose only reachable peer is the AWF API proxy. + +A **bounded agent** lets an agent hand a trusted broker a bounded task about one +pre-approved private repository and get back a single value conforming to a +finite schema it declares up front — without ever seeing repository contents, +the enclave's transcript, its tool calls, its diagnostics, or its exit status. + +Bounded agents are the agentic sibling of [bounded queries](bounded-queries.md). +A bounded query runs an agent-authored Python script in a sandbox with **no +network at all**. A bounded agent runs a **fixed, AWF-authored model loop** in an +enclave that can reach exactly one thing: the AWF API proxy. + +The feature is config-only: there are no `--bounded-agents-*` CLI flags. +Everything is expressed in the AWF JSON configuration file. + +## When to use which + +| | Bounded query | Bounded agent | +|---|---|---| +| Work is | an agent-authored Python script | a fixed AWF-authored model loop | +| Enclave network | none | the API proxy, and nothing else | +| Repository access | writable private copy | read-only immutable seed | +| Provider sees repo content | never | **yes** — see [Provider disclosure](#provider-disclosure) | +| Good for | deterministic, scriptable questions | questions needing judgment or multi-step reading | + +**Prefer a bounded query whenever a deterministic script can answer the +question.** Reach for a bounded agent only when the question genuinely needs a +model to read and judge. + +**Good uses** + +- "Which of these four architectural patterns best describes the service?" — small enum +- "Does the error-handling in this module meet our documented standard?" — boolean +- "Which subsystem owns retry policy: `net`, `store`, `api`, or none?" — small enum + +**Not suited for** + +- Extracting source code, documentation, or any variable-length text — unbounded strings are structurally impossible in the schema DSL +- Anything a bounded query can answer deterministically +- Repositories whose contents must never reach the configured model provider +- Repositories marked `sealed` (0-bit budget) — these can never fund a single invocation + +## Architecture + +Four trust stages, mirroring bounded queries: + +1. **Trusted host preflight, then staging.** Preflight runs *first*: AWF + validates the configuration, rejects Docker-socket exposure to the primary + agent, requires the API proxy plus a configured `profile`/`model` route, and + proves the enclave runtime is available. Only + then does it clone each configured repository using `GH_TOKEN`/`GITHUB_TOKEN`, + strip all credentials, remotes, hooks, and write bits, and reject submodules + and gitdir pointers. The staging credential is scrubbed before any container + exists. A run that could never launch an enclave never clones anything. + +2. **Trusted broker over a private ingress.** A dedicated `awf-bounded-agent-broker` + container with `network_mode: none` serves requests over a Unix socket + mounted into the agent. It has no network at all — not even the enclave + network it launches enclaves onto. It holds the seed map (including each + repository's trusted sensitivity), which the agent can never read or modify, + and it keeps a ledger **separate** from bounded queries. When the primary + agent itself runs under `containerRuntime: "sbx"`, AWF first probes whether + the microVM's filesystem passthrough can bind the broker's Unix socket + directly; when it cannot, the broker instead listens on a dedicated Docker + `internal` network with one ephemeral port published only on the Docker + host-gateway address, and the agent is given only the endpoint plus a + random, single-run capability token proven reachable before the agent + starts (see [Primary-agent and bounded-agent runtime + matrix](#primary-agent-and-bounded-agent-runtime-matrix)). + +3. **Single-use enclave on an API-proxy-only network.** For each accepted + request the broker launches one fresh, uniquely named, labelled container + with a frozen argument vector: read-only root, the immutable seed + bind-mounted read-only, bounded tmpfs mounts for work/result/`/tmp`, fixed + non-root UID/GID, `--cap-drop ALL`, `no-new-privileges`, a seccomp profile, + and memory/CPU/PID/file-size/timeout bounds. It joins only the dedicated + `internal` `awf-bounded-agent` network, whose sole other member is a + dedicated API proxy with private telemetry and a separate egress bridge. + +4. **Canonical finite result and cleanup.** The enclave writes exactly one JSON + value to a dedicated bounded result file. The broker force-removes the + container, destroys the workspace, validates the result against the declared + schema, canonically re-serializes it, and only then selects the timing + bucket. The agent receives exactly `{"status":"ok","result":}` or + `{"status":"error"}` — and never the remaining budget. + +## Configuration + +```json +{ + "apiProxy": { "targets": { "openai": {} } }, + "boundedAgents": { + "enabled": true, + "privateRepos": [ + { "repo": "my-org/private-service", "sensitivity": "internal" } + ], + "runtime": "docker", + "profile": "openai", + "model": "gpt-4o-mini", + "timeout": 120, + "memoryLimit": "512m", + "cpuLimit": "1", + "pidsLimit": 128, + "tmpfsLimit": "64m", + "maxOutputBytes": 8192, + "maxTaskBytes": 4096, + "maxInvocations": 8, + "maxModelRequests": 8, + "maxModelTokens": 1024 + } +} +``` + +| Field | Default | Meaning | +|-------|---------|---------| +| `enabled` | `false` | Only an explicit `true` enables the subsystem. | +| `privateRepos` | — | Required when enabled. `{ repo, sensitivity }` entries; `repo` must be a bare `owner/repo` slug, unique case-insensitively. | +| `runtime` | `docker` | `docker` or `gvisor`. `sbx` is accepted by the schema but remains capability-blocked (see below). | +| `profile` | `openai` | Provider protocol the enclave speaks to the API proxy: `openai` (`POST /v1/chat/completions`) or `anthropic` (`POST /v1/messages`). | +| `model` | — | Required when enabled. A request can never choose or override it. | +| `timeout` | `120` | Wall-clock seconds for one invocation (max 540). | +| `memoryLimit` | `"512m"` | Docker memory limit; swap disabled at the same value. | +| `cpuLimit` | `"1"` | Docker `--cpus`. | +| `pidsLimit` | `128` | Docker `--pids-limit`. | +| `tmpfsLimit` | `"64m"` | Size bound for each writable tmpfs. | +| `maxOutputBytes` | `8192` | Exact size bound on the result file. | +| `maxTaskBytes` | `4096` | Byte bound on the task text. | +| `maxInvocations` | `8` | Per-run response cap; rejections count. | +| `maxModelRequests` | `8` | Model requests per invocation. | +| `maxModelTokens` | `1024` | `max_tokens` per model call. | + +Every default is deliberately conservative. Widen them explicitly, and only as +far as a task actually needs. + +### Requirements + +Bounded agents abort the run at preflight — before staging clones anything — +unless all of the following hold: + +- the AWF API proxy is enabled (the enclave holds no credentials, and the proxy + is its only permitted upstream egress); +- the selected `profile` has a configured API target (an OpenAI credential for + `openai`, an Anthropic credential for `anthropic`); +- `model` is set; +- a staging credential is present in `GH_TOKEN` or `GITHUB_TOKEN`; +- the Docker host is a Unix socket; +- the **primary agent** runtime is actually available (`docker`, `runsc` + registration for `gvisor`, or a proven sbx ingress path for `sbx` — see + [Primary-agent and bounded-agent runtime + matrix](#primary-agent-and-bounded-agent-runtime-matrix)); a blanket + rejection of a primary `sbx` runtime is no longer applied — availability is + proven, not assumed; +- the selected **bounded-agent enclave** runtime is actually available. + +## Docker and gVisor + +`runtime: "docker"` uses the daemon's default OCI runtime. + +`runtime: "gvisor"` requires the `runsc` OCI runtime to be registered with the +Docker daemon. Availability is proven exactly at preflight and again at broker +startup. **An unavailable `runsc` never downgrades to the default runtime** — the +run aborts instead. + +```json +{ "boundedAgents": { "enabled": true, "runtime": "gvisor", "model": "gpt-4o-mini", + "privateRepos": [{ "repo": "my-org/private-service", "sensitivity": "internal" }] } } +``` + +Nothing else about the topology, mounts, budgets, or protocol changes between +the two backends. + +## `sbx` capability-blocked + +`runtime: "sbx"` is accepted by the JSON Schema so configurations can be +written ahead of support landing, but it is **capability-blocked** — never a +blanket "not yet implemented" refusal, and never a false pass. AWF ships a +dedicated bounded-agent sbx capability probe +(`src/bounded-agent/sbx-capability.ts`, mirrored in +`containers/bounded-agent/broker/sbx-capability-probe.js`) that inspects the +exact audited Docker Sandboxes CLI (`v0.37.1`) surface with `sbx version`, the +authenticated non-mutating `sbx ls`, and `create --help` / `exec --help`. +Lifecycle commands belong to the blocked runner and are not claimed as an +executed proof. The probe reports every missing capability in structured JSON +rather than a single boolean. + +The enclave requirement is strictly harder than a bounded query's: an +enclave must reach *exactly one* peer (the dedicated API proxy), not "no +network at all". Current `sbx create` supports `--cpus`, `--memory`, `--name`, +`--template`, and read-only same-path mounts, but does **not** expose the hard +controls AWF requires for a mandatory, enforceable API-proxy-only network +policy (not an advisory `HTTP_PROXY`), PID limits, disk limits, per-file size +limits, or a pinned, digest-verified AWF bounded-agent template/bootstrap. +The probe therefore always reports these as missing and `supported` can never +be `true` for the currently audited version — by design, not by omission. + +AWF rejects this runtime before staging or compose assembly, mounts neither +the Docker socket nor any sbx daemon credential, and the broker's +`SbxEnclaveRunner` throws immediately if ever invoked. Support remains blocked +until sbx provides enforceable versions of all controls and AWF publishes a +digest-pinned, standard-library-only bootstrap for the enclave — the same +promotion bar as bounded queries. + +## Primary-agent and bounded-agent runtime matrix + +The primary agent and the bounded-agent enclave are separate sandbox +decisions, each with its own availability proof: + +- `container.containerRuntime` / `--container-runtime` selects the **primary + agent** runtime. +- `boundedAgents.runtime` selects the **single-use enclave** runtime. + +The broker never reuses the primary agent sandbox. Every accepted invocation +creates a new container with a unique run identity and destroys it before +returning. No combination ever falls back to a weaker or different backend. + +| Primary agent | Docker enclave | gVisor enclave | sbx enclave | +|---|---|---|---| +| Docker | Supported when Docker is available | Supported when `runsc` is registered | **Blocked** by mandatory sbx enclave probes | +| gVisor | Supported when the primary `runsc` runtime is available | Supported when `runsc` is registered | **Blocked** by mandatory sbx enclave probes | +| sbx | Supported when primary sbx ingress (Unix passthrough or authenticated `sbx-http`) is proven | Supported when primary sbx ingress and `runsc` are proven | **Blocked** by mandatory sbx enclave probes | + +"Supported" is capability-dependent, not an instruction to downgrade. An +unavailable primary runtime fails at primary preflight, before any repository +is staged. An unavailable enclave runtime fails at enclave preflight, for the +same reason. Selecting `"runtime": "sbx"` for the enclave is an explicit, +still-experimental gate; the additional executable capability proof must also +pass. With Docker Sandboxes `v0.37.1`, all three sbx-enclave cells remain +blocked — six of the nine combinations are supported once the relevant +runtime(s) are proven available, and the three `sbx`-enclave cells are not. + +`src/bounded-agent/runtime-matrix.ts` evaluates all nine combinations +independently (`primaryBackend` × `boundedAgentBackend`) and records +`lifecycleClass`, `capabilityState`, and `category` per cell for telemetry — +never the task, repository name, or provider payload. +`scripts/ci/report-bounded-agent-runtime-matrix.js` renders the same matrix +from live host probes for CI/local use; it reports an explicit `BLOCKED` +result (and exits non-zero under `--require`) rather than a false pass when no +real sbx binary is present. + +Examples of independent selection: + +```json +{ "container": { "containerRuntime": "sbx" }, + "boundedAgents": { "enabled": true, "runtime": "docker", "model": "gpt-4o-mini", + "privateRepos": [{ "repo": "my-org/private-service", "sensitivity": "internal" }] } } +``` + +### Troubleshooting runtime selection + +| Symptom | Meaning | Action | +|---|---|---| +| `runsc ... not available; no fallback` | The gVisor enclave backend is not registered with Docker | Register `runsc`, verify it appears in `docker info --format '{{json .Runtimes}}'`, and rerun | +| `sbx ... blocked ... mandatory` capability error | The sbx enclave capability probe failed as designed | Read the complete missing-capability list; do not substitute local policy or a weaker runtime | +| sbx primary ingress probe fails | The primary VM cannot reach the broker through either proven ingress | Verify sbx Unix passthrough or authenticated `sbx-http` ingress; the agent must not start | +| Docker host must be `unix://` | The networkless broker cannot reach a TCP daemon | Use a local Unix socket; AWF will not attach the broker to a network | +| `bounded agents cannot be combined with enableDind` | Docker-socket exposure to the primary agent would bypass every finite-disclosure boundary | Disable `enableDind`; there is no runtime combination in which this is safe | +| Matrix report says `BLOCKED` | Capability or security preflight prevented launch | Treat this as expected fail-closed status, not successful runtime execution | + +Run `node scripts/ci/report-bounded-agent-runtime-matrix.js` after `npm run +build` to print all nine local capability results. Use `--require +docker/docker` (or another pair) when a smoke job must require one executable +combination. + +### sbx enclave promotion criteria + +The experimental sbx enclave backend MUST remain blocked until all of these are +demonstrated in real VMs, not only deterministic fakes: + +1. A digest-pinned AWF bounded-agent template/bootstrap exists. +2. A mandatory, enforceable API-proxy-only network policy is available and + enforced by the sbx runtime itself — not an advisory `HTTP_PROXY` env var, + and not organization-level network policy that can be replaced. +3. CPU, memory, PID, aggregate disk, and per-file size limits are enforceable. +4. Read-only seed/task/schema/result mounts have explicit guest targets and + expose no broker state, credentials, sibling repository, or prior + invocation. +5. Timeout, OOM, PID, disk, file-size, malformed/oversized output, and + interruption cleanup tests all pass. +6. Unix and authenticated `sbx-http` primary ingress retain byte-identical + protocol behavior. +7. Direct and lateral reachability to anything other than the dedicated API + proxy is proven denied, not merely unconfigured. + +Passing a version check alone, or passing only the CLI help probe, is not +enough to promote the backend. + +The locally available `docker sandbox` plugin (`v0.12.0`) was also inspected +through its executable `create shell`, `exec`, `network proxy`, `stop`, and +`rm` interfaces. It offers same-path workspace mounts and host/CIDR proxy +policy, but no explicit guest mount targets, create-time CPU/memory/PID/disk/ +file-size bounds, or digest-enforced AWF bootstrap contract. Those are +mandatory controls, so this older interface is reported as capability-blocked; +its help text is not treated as execution evidence. + +## Agent interface + +When enabled, the agent gets a `bounded-agent` CLI on its `PATH` and a +generated `SKILL.md` under `~/.github/skills/bounded-agent/`. + +```bash +bounded-agent \ + --repo my-org/private-service \ + --schema '{"type":"enum","values":["net","store","api","none"]}' \ + <<'TASK' +Which subsystem owns the retry policy for outbound HTTP calls? +TASK +``` + +The CLI accepts exactly `--repo` (once), `--schema` (once), and the task text on +stdin. There are no other options: it cannot express an image, command, +executable, model, provider, profile, tool, system prompt, runtime, timeout, +mount, path, network, proxy, endpoint, resource limit, environment variable, or +credential. It always prints exactly one line of canonical JSON, writes nothing +to stderr, and exits `0`. + +Inside the enclave the model gets three read-only repository tools (list, read, +search) confined to the immutable seed, plus one terminal tool that records the +final answer. There is no shell, no `gh`, no git, no package manager, no host +state, no safe outputs, and no MCP. + +That exclusion is deliberate: authenticated `gh`, safe outputs, the CLI proxy, +and MCP gateways are authority-bearing interfaces whose output is not covered +by the finite result schema. `mcpg` may become an enclave implementation detail +only after it can preserve this fixed authority-free tool set and canonical +result boundary. Its raw Podman arguments, logs, stdio, and `jq` filters are not +the result boundary and must never be exposed to the caller. + +## Budget + +Every invocation reserves a fixed information charge from its repository's run +budget, computed **before** any workspace or container is created: + +```text +charge = 1 (ok/error) + ceil(log2(schema cardinality)) + 3 (timing) +``` + +| Sensitivity | Run budget | +|-------------|-----------:| +| `public` | unmetered | +| `internal` | 64 bits/run | +| `confidential` | 8 bits/run | +| `sealed` | 0 bits/run — never launches an enclave | + +Charges are never refunded, regardless of outcome. The remaining balance is +never disclosed to the calling agent. + +Bounded agents keep a ledger **separate** from bounded queries: the two +subsystems run separate brokers with separate seed maps in separate private +roots, so spending on one never consumes the other's balance. + +## Threat model + +**What bounded agents defend against** + +| Threat | Control | +|---|---| +| Calling agent reads private source | It never receives repository bytes — only one canonical envelope. | +| Calling agent escalates through the request | The request selects only a repository, a finite schema, and bounded task text. Image, command, executable, mount, env, endpoint, network, proxy, credential, timeout, resource, runtime, and tool controls — and unknown keys — are rejected. | +| Enclave exfiltrates over the network | The enclave joins only an `internal` network whose sole other member is a dedicated API proxy. No Squid, no general proxy, no DNS route out, no internet. | +| Enclave signals through proxy telemetry | Bounded-agent traffic uses a separate API-proxy process whose logs, metrics, quota counters, and egress network are not reachable or mounted by the primary agent. | +| Enclave reaches other AWF components | The primary agent, Squid, the broker, safe outputs, the MCP gateway, and the CLI proxy are all off that network. | +| Enclave steals credentials | It holds none. The API proxy injects the real key; the enclave's environment is a fixed list with no credential, token, or proxy variable. | +| Enclave mutates or persists private source | The seed is bind-mounted read-only into a read-only root; there is no writable copy on the host. | +| Enclave escapes the sandbox | Non-root UID/GID, `--cap-drop ALL`, `no-new-privileges`, seccomp, and memory/CPU/PID/file-size/timeout bounds. | +| Broker is used as a launcher for arbitrary containers | The argument vector is frozen and derived only from trusted config plus broker-generated identifiers. | +| Failure classes leak signal | Every failure collapses to the identical `{"status":"error"}`; reasons go only to a protected audit log. | +| Latency leaks signal | Responses are held to one of six fixed timing buckets, chosen only after cleanup, and timing is charged to the budget. | +| Orphaned enclaves retain private content | Every enclave is labelled with the run id and force-removed at teardown, including under `--keep-containers`. | +| Staging credential leaks | Used only by the trusted host phase via a `GIT_ASKPASS` helper reading a 0600 file; scrubbed before any container exists; never in argv, a URL, a log, or a compose file. | + +**What bounded agents do NOT defend against** + +- **Provider exposure.** See below. +- **Semantic misclassification.** The feature enforces the declared sensitivity + budget; it cannot validate that an operator classified a repository correctly. +- **A model that spends its budget badly.** A high-cardinality schema is charged + accordingly, but the caller still chooses what question to ask. + +### Provider disclosure + +A bounded agent necessarily sends repository-derived content — file listings, +file excerpts, and search hits selected by the model — to the configured model +provider through the AWF API proxy. + +**The information-budget ledger bounds what the *calling agent* learns, not what +the *provider* sees.** + +This is a materially different exposure from a bounded query, whose Python +sandbox has no network at all. Before enabling bounded agents for a repository, +treat the configured provider as an authorized recipient of that repository's +contents. When a deterministic script can answer the question, use a +[bounded query](bounded-queries.md) instead. + +## Related + +- [Bounded queries](bounded-queries.md) — the no-network, script-based sibling +- [AWF configuration spec §15](awf-config-spec.md) — normative model +- [API proxy sidecar](api-proxy-sidecar.md) — credential isolation diff --git a/docs/bounded-queries.md b/docs/bounded-queries.md index 30e391abe..9105dc20a 100644 --- a/docs/bounded-queries.md +++ b/docs/bounded-queries.md @@ -420,5 +420,6 @@ Bounded queries provide a **quantitative information bound**, not a semantic app ## See also +- [Bounded agents](bounded-agents.md) - The agentic sibling: a fixed AWF-authored model loop in an enclave whose only reachable peer is the AWF API proxy. Prefer a bounded query whenever a deterministic script can answer the question, because a bounded agent necessarily discloses repository-derived content to the configured model provider. - [Security Architecture](/gh-aw-firewall/reference/security-architecture) - Firewall trust model and isolation layers - [AWF config spec section 14](https://github.com/github/gh-aw-firewall/blob/main/docs/awf-config-spec.md#14-bounded-queries) - Normative specification with full field constraints, protocol details, and staging implementation notes diff --git a/docs/egress-filtering.md b/docs/egress-filtering.md index 8f99769ec..021b86ffa 100644 --- a/docs/egress-filtering.md +++ b/docs/egress-filtering.md @@ -23,7 +23,7 @@ Bypass techniques are well-documented: - **SSLH multiplexing**: Same port serves SSH and HTTPS based on protocol detection - **HTTP tunneling tools**: chisel, wstunnel, cloudflared work over "allowed" ports -Even [Nmap's documentation](https://nmap.org/book/firewall-subversion.html) notes historical firewall flaws—Zone Alarm allowed any UDP from port 53, Windows IPsec filters allowed all traffic from port 88. +Even [Nmap's reference-guide source](https://github.com/nmap/nmap/blob/d5645bf76da55c056082a99593de1b5ef645bfa8/docs/refguide.xml#L3394-L3401) notes historical firewall flaws—Zone Alarm allowed any UDP from port 53, Windows IPsec filters allowed all traffic from port 88. ## Why Port Restrictions Still Matter (Supporting Arguments) diff --git a/scripts/build-bundle.mjs b/scripts/build-bundle.mjs index 8b3e5f380..5cc6072f4 100644 --- a/scripts/build-bundle.mjs +++ b/scripts/build-bundle.mjs @@ -34,6 +34,9 @@ try { const boundedQueryWrapperPath = join(projectRoot, 'containers', 'agent', 'bounded-query-wrapper.sh'); const boundedQueryWrapperContent = readFileSync(boundedQueryWrapperPath, 'utf-8'); +const boundedAgentWrapperPath = join(projectRoot, 'containers', 'agent', 'bounded-agent-wrapper.sh'); +const boundedAgentWrapperContent = readFileSync(boundedAgentWrapperPath, 'utf-8'); + // Ensure output directory exists mkdirSync(join(projectRoot, 'release'), { recursive: true }); @@ -51,6 +54,7 @@ await build({ define: { __AWF_SECCOMP_PROFILE__: JSON.stringify(seccompContent), __AWF_BOUNDED_QUERY_WRAPPER__: JSON.stringify(boundedQueryWrapperContent), + __AWF_BOUNDED_AGENT_WRAPPER__: JSON.stringify(boundedAgentWrapperContent), }, // Mark native/optional deps as external if needed // (none expected — all deps are pure JS) diff --git a/scripts/ci/contribution-check-workflow.test.ts b/scripts/ci/contribution-check-workflow.test.ts index b729db34a..0a71ef9a9 100644 --- a/scripts/ci/contribution-check-workflow.test.ts +++ b/scripts/ci/contribution-check-workflow.test.ts @@ -16,18 +16,19 @@ describe('contribution-check workflow', () => { expect(source).toContain('set -o pipefail'); // Steps write to context files (not $GITHUB_OUTPUT), so data persists for the agent - expect(source).toContain('/tmp/gh-aw/contribution-check-context/contributing.md'); - expect(source).toContain('/tmp/gh-aw/contribution-check-context/pr-files.md'); - expect(source).toContain('/tmp/gh-aw/contribution-check-context/pr-meta.md'); + expect(source).toContain('$CONTEXT_DIR/contributing.md'); + expect(source).toContain('$CONTEXT_DIR/pr-files.md'); + expect(source).toContain('$CONTEXT_DIR/pr-meta.md'); + expect(source).toContain('$CONTEXT_DIR/review-context.md'); }); it('instructs agent to use pre-fetched data and not re-fetch via proxy', () => { const source = fs.readFileSync(sourcePath, 'utf-8'); // Agent reads from context files written by the pre-fetch steps - expect(source).toContain('Read the following pre-fetched context files before proceeding'); + expect(source).toContain('Read `/tmp/gh-aw/contribution-check-context/review-context.md` once'); expect(source).toContain("Do NOT call `gh pr diff`"); - expect(source).toContain('Use ONLY the pre-fetched data in these context files'); + expect(source).toContain('Use ONLY that pre-fetched context file'); expect(source).toContain('Review PR #${{ github.event.pull_request.number || github.event.inputs.item_number }}'); }); diff --git a/scripts/ci/probe-bounded-agent-primary-sbx.js b/scripts/ci/probe-bounded-agent-primary-sbx.js new file mode 100755 index 000000000..f49095b1c --- /dev/null +++ b/scripts/ci/probe-bounded-agent-primary-sbx.js @@ -0,0 +1,25 @@ +#!/usr/bin/env node +'use strict'; + +/** + * Executable primary-sbx ingress proof used by runtime-matrix reporting. + * + * This intentionally proves only the Unix-socket passthrough path. The + * authenticated HTTP path requires a live run-specific broker and is proven by + * main-action before the primary agent starts; a standalone report cannot + * safely synthesize that capability. + */ +async function main() { + const { probeSbxUnixSocketMount } = require('../../dist/sbx-manager.js'); + if (!(await probeSbxUnixSocketMount())) { + process.stderr.write('BLOCKED: primary sbx Unix-socket ingress was not proven\n'); + process.exitCode = 1; + return; + } + process.stdout.write('SUPPORTED: primary sbx Unix-socket ingress proven\n'); +} + +main().catch(() => { + process.stderr.write('BLOCKED: primary sbx ingress capability probe failed\n'); + process.exitCode = 1; +}); diff --git a/scripts/ci/report-bounded-agent-runtime-matrix.js b/scripts/ci/report-bounded-agent-runtime-matrix.js new file mode 100644 index 000000000..eafa78263 --- /dev/null +++ b/scripts/ci/report-bounded-agent-runtime-matrix.js @@ -0,0 +1,158 @@ +#!/usr/bin/env node +'use strict'; + +const fs = require('fs'); +const { spawnSync } = require('child_process'); + +const BACKENDS = ['docker', 'gvisor', 'sbx']; + +function run(command, args) { + const result = spawnSync(command, args, { + encoding: 'utf8', + timeout: 30_000, + stdio: ['ignore', 'pipe', 'pipe'], + }); + return { + ok: !result.error && result.status === 0, + stdout: result.stdout || '', + }; +} + +function collectCapabilities(commandRunner = run) { + const docker = commandRunner('docker', ['info', '--format', '{{json .Runtimes}}']); + let runtimes = {}; + if (docker.ok) { + try { + runtimes = JSON.parse(docker.stdout); + } catch { + runtimes = {}; + } + } + const gvisor = Object.prototype.hasOwnProperty.call(runtimes, 'runsc'); + // Primary sbx is supported only after a disposable sandbox proves the actual + // bounded-agent Unix-socket ingress path. CLI/daemon availability alone is + // not an ingress proof and must never produce a SUPPORTED matrix cell. + const sbxPrimary = commandRunner( + process.execPath, + ['scripts/ci/probe-bounded-agent-primary-sbx.js'], + ).ok; + const sbxBoundedAgent = commandRunner( + process.execPath, + ['containers/bounded-agent/broker/sbx-capability-probe.js'], + ); + let sbxBoundedAgentSupported = false; + if (sbxBoundedAgent.stdout) { + try { + sbxBoundedAgentSupported = JSON.parse(sbxBoundedAgent.stdout).supported === true; + } catch { + sbxBoundedAgentSupported = false; + } + } + return { + primary: { + docker: docker.ok ? 'supported' : 'unavailable', + gvisor: gvisor ? 'supported' : 'unavailable', + sbx: sbxPrimary ? 'supported' : 'unavailable', + }, + boundedAgent: { + docker: docker.ok ? 'supported' : 'unavailable', + gvisor: gvisor ? 'supported' : 'unavailable', + sbx: sbxBoundedAgentSupported ? 'supported' : 'blocked', + }, + }; +} + +/** + * Evaluates one primary/bounded-agent combination. Primary sbx reaches + * `supported` only when the collector's disposable sandbox has completed the + * real Unix-socket broker-ingress exchange; CLI/daemon availability alone + * remains `unavailable`. + */ +function evaluate(primary, boundedAgent, capabilities) { + const primaryState = capabilities.primary[primary]; + if (primaryState === 'available') { + return { + status: 'BLOCKED', + capability: primaryState, + phase: 'primary-sbx-ingress-unproven', + }; + } + if (primaryState !== 'supported') { + return { + status: 'BLOCKED', + capability: primaryState, + phase: 'primary-preflight', + }; + } + if (capabilities.boundedAgent[boundedAgent] !== 'supported') { + return { + status: 'BLOCKED', + capability: capabilities.boundedAgent[boundedAgent], + phase: 'bounded-agent-preflight', + }; + } + return { status: 'SUPPORTED', capability: 'supported', phase: 'ready' }; +} + +function renderMatrix(capabilities) { + const lines = [ + '## Bounded-agent runtime capability matrix', + '', + '| Primary agent | Bounded-agent enclave | Result | Primary capability | ' + + 'Bounded-agent capability | Gate |', + '|---|---|---|---|---|---|', + ]; + for (const primary of BACKENDS) { + for (const boundedAgent of BACKENDS) { + const result = evaluate(primary, boundedAgent, capabilities); + lines.push( + `| ${primary} | ${boundedAgent} | ${result.status} | ${capabilities.primary[primary]} | ` + + `${capabilities.boundedAgent[boundedAgent]} | ${result.phase} |`, + ); + } + } + lines.push( + '', + '> BLOCKED is an expected fail-closed security result, not runtime success. No fallback is attempted.', + '> The bounded-agent sbx enclave is BLOCKED unconditionally today: the audited sbx CLI cannot yet ' + + 'prove the mandatory API-proxy-only network, RO-targeted-mount, pids/disk/fsize, or lifecycle ' + + 'isolation primitives this enclave requires.', + '> Primary sbx is SUPPORTED here only after a disposable sandbox proves the Unix-socket broker ' + + 'ingress path. Authenticated HTTP fallback is proven only by `assertSbxBoundedAgentIngress` ' + + 'during an actual run.', + ); + return `${lines.join('\n')}\n`; +} + +function main() { + const capabilities = collectCapabilities(); + const report = renderMatrix(capabilities); + process.stdout.write(report); + if (process.env.GITHUB_STEP_SUMMARY) { + fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, report); + } + + const requiredIndex = process.argv.indexOf('--require'); + if (requiredIndex !== -1) { + const requirement = process.argv[requiredIndex + 1] || ''; + const [primary, boundedAgent] = requirement.split('/'); + if (!BACKENDS.includes(primary) || !BACKENDS.includes(boundedAgent)) { + throw new Error(`Invalid --require combination: ${requirement}`); + } + const result = evaluate(primary, boundedAgent, capabilities); + if (result.status !== 'SUPPORTED') { + throw new Error(`Required runtime combination ${requirement} is ${result.status} at ${result.phase}`); + } + } +} + +if (require.main === module) { + try { + main(); + } catch (error) { + process.stderr.write(`${error.message}\n`); + process.exitCode = 1; + } +} + +module.exports = { collectCapabilities, evaluate, renderMatrix }; diff --git a/scripts/ci/report-bounded-agent-runtime-matrix.test.ts b/scripts/ci/report-bounded-agent-runtime-matrix.test.ts new file mode 100644 index 000000000..0f971592f --- /dev/null +++ b/scripts/ci/report-bounded-agent-runtime-matrix.test.ts @@ -0,0 +1,102 @@ +import * as path from 'path'; + +/* eslint-disable @typescript-eslint/no-require-imports */ +const { collectCapabilities, evaluate, renderMatrix } = require( + path.join(__dirname, 'report-bounded-agent-runtime-matrix.js'), +); +/* eslint-enable @typescript-eslint/no-require-imports */ + +describe('bounded-agent runtime capability report', () => { + it('reports all nine combinations and preserves the sbx bounded-agent security block', () => { + const capabilities = collectCapabilities((command: string, args: string[]) => { + if (command === 'docker') { + return { ok: true, stdout: '{"runc":{},"runsc":{}}' }; + } + if (args.includes('scripts/ci/probe-bounded-agent-primary-sbx.js')) { + return { ok: true, stdout: 'Docker Sandboxes v0.37.1' }; + } + if (args.includes('sbx-capability-probe.js')) { + return { ok: false, stdout: '{"supported":false}' }; + } + return { ok: false, stdout: '' }; + }); + const report = renderMatrix(capabilities); + const rows = report.split('\n').filter((line: string) => /^\| (docker|gvisor|sbx) /.test(line)); + expect(rows).toHaveLength(9); + expect(report).toContain( + '| sbx | sbx | BLOCKED | supported | blocked | bounded-agent-preflight |', + ); + expect(report).toContain('BLOCKED is an expected fail-closed security result, not runtime success'); + expect(report).toContain('bounded-agent sbx enclave is BLOCKED unconditionally today'); + }); + + it('never promotes an unavailable primary or bounded-agent runtime through fallback', () => { + const capabilities = { + primary: { docker: 'supported', gvisor: 'unavailable', sbx: 'unavailable' }, + boundedAgent: { docker: 'supported', gvisor: 'unavailable', sbx: 'blocked' }, + }; + expect(evaluate('gvisor', 'docker', capabilities)).toEqual({ + status: 'BLOCKED', + capability: 'unavailable', + phase: 'primary-preflight', + }); + expect(evaluate('docker', 'gvisor', capabilities)).toEqual({ + status: 'BLOCKED', + capability: 'unavailable', + phase: 'bounded-agent-preflight', + }); + expect(evaluate('docker', 'sbx', capabilities)).toEqual({ + status: 'BLOCKED', + capability: 'blocked', + phase: 'bounded-agent-preflight', + }); + }); + + it('supports primary sbx paired with docker/gvisor bounded-agent enclaves once primary sbx is proven', () => { + // `supported` means the collector's executable ingress probe completed. + const capabilities = { + primary: { docker: 'supported', gvisor: 'supported', sbx: 'supported' }, + boundedAgent: { docker: 'supported', gvisor: 'supported', sbx: 'blocked' }, + }; + expect(evaluate('sbx', 'docker', capabilities).status).toBe('SUPPORTED'); + expect(evaluate('sbx', 'gvisor', capabilities).status).toBe('SUPPORTED'); + expect(evaluate('sbx', 'sbx', capabilities).status).toBe('BLOCKED'); + }); + + it('does not promote primary sbx when only its CLI and daemon are available', () => { + const capabilities = collectCapabilities((command: string, args: string[]) => { + if (command === 'docker') return { ok: true, stdout: '{"runc":{}}' }; + if (command === 'sbx' && args[0] === 'ls') return { ok: true, stdout: '[]' }; + return { ok: false, stdout: '' }; + }); + expect(capabilities.primary.sbx).toBe('unavailable'); + expect(evaluate('sbx', 'docker', capabilities)).toEqual({ + status: 'BLOCKED', + capability: 'unavailable', + phase: 'primary-preflight', + }); + }); + + it('emits an explicit capability-blocked report (not a false pass) when no real sbx binary is present', () => { + const capabilities = collectCapabilities((command: string) => { + if (command === 'docker') { + return { ok: true, stdout: '{"runc":{}}' }; + } + // Simulate the local/CI environment used in this task: no `sbx` binary + // installed at all, and no bounded-agent broker probe reachable. + return { ok: false, stdout: '' }; + }); + expect(capabilities.primary.sbx).toBe('unavailable'); + expect(capabilities.boundedAgent.sbx).toBe('blocked'); + expect(() => { + const report = renderMatrix(capabilities); + const requirement = evaluate('sbx', 'sbx', capabilities); + if (requirement.status !== 'SUPPORTED') { + throw new Error( + `Required runtime combination sbx/sbx is ${requirement.status} at ${requirement.phase}`, + ); + } + return report; + }).toThrow(/sbx\/sbx is BLOCKED at primary-preflight/); + }); +}); diff --git a/scripts/ci/smoke-bounded-agent-enclave.sh b/scripts/ci/smoke-bounded-agent-enclave.sh new file mode 100755 index 000000000..adaa03795 --- /dev/null +++ b/scripts/ci/smoke-bounded-agent-enclave.sh @@ -0,0 +1,148 @@ +#!/usr/bin/env bash +set -euo pipefail + +runtime="${1:-docker}" +case "$runtime" in + docker) runtime_args=() ;; + gvisor) + if ! docker info --format '{{range $name, $_ := .Runtimes}}{{println $name}}{{end}}' | + grep -qx runsc; then + echo "BLOCKED: gVisor bounded-agent smoke requires registered runsc" + exit 0 + fi + runtime_args=(--runtime runsc) + ;; + *) + echo "BLOCKED: unsupported bounded-agent smoke runtime: $runtime" >&2 + exit 2 + ;; +esac + +if ! docker info >/dev/null 2>&1; then + echo "BLOCKED: Docker daemon is unavailable" + exit 0 +fi + +image="awf-bounded-agent-smoke:${runtime}" +run_id="$(printf '%08x%08x' "$$" "$RANDOM")" +network="awf-bounded-agent-smoke-${run_id}" +proxy="awf-bounded-agent-smoke-proxy-${run_id}" +root="$(mktemp -d "${TMPDIR:-/tmp}/awf-bounded-agent-smoke.XXXXXX")" + +cleanup() { + docker rm -f "$proxy" >/dev/null 2>&1 || true + docker network rm "$network" >/dev/null 2>&1 || true + rm -rf "$root" +} +trap cleanup EXIT INT TERM + +docker build --quiet --target enclave -t "$image" -f containers/bounded-agent/Dockerfile containers >/dev/null +docker network create --internal "$network" >/dev/null + +mkdir -p "$root/seed" +printf 'LIVE-SMOKE-MARKER\n' > "$root/seed/SECURITY.md" +printf 'Does SECURITY.md exist?\n' > "$root/task.txt" +printf '{"type":"boolean"}\n' > "$root/schema.json" +: > "$root/out" +chmod -R a+rX "$root/seed" "$root/task.txt" "$root/schema.json" +chmod a+rw "$root/out" + +proxy_program=' +import json +from http.server import BaseHTTPRequestHandler, HTTPServer +class Handler(BaseHTTPRequestHandler): + def do_POST(self): + length = int(self.headers.get("content-length", "0")) + request = self.rfile.read(length).decode("utf-8", errors="replace") + if "LIVE-SMOKE-MARKER" in request: + function = {"name":"finish","arguments":"{\"result\":true}"} + else: + function = {"name":"read_file","arguments":"{\"path\":\"SECURITY.md\"}"} + payload = json.dumps({"choices":[{"message":{"role":"assistant","tool_calls":[{"id":"live","type":"function","function":function}]}}]}).encode() + self.send_response(200) + self.send_header("content-type", "application/json") + self.send_header("content-length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + def log_message(self, *_args): + pass +HTTPServer(("0.0.0.0", 10000), Handler).serve_forever() +' +docker run -d --name "$proxy" --network "$network" --network-alias api-proxy \ + --read-only --cap-drop ALL --security-opt no-new-privileges:true \ + --entrypoint python3 "$image" -c "$proxy_program" >/dev/null + +proxy_ready=false +for _ in $(seq 1 30); do + if docker exec "$proxy" python3 -c \ + 'import socket; socket.create_connection(("127.0.0.1",10000),1).close()' >/dev/null 2>&1; then + proxy_ready=true + break + fi + sleep 1 +done +if [[ "$proxy_ready" != true ]]; then + echo "FAIL: bounded-agent fake API proxy did not become ready" >&2 + exit 1 +fi +proxy_ip="$( + docker inspect "$proxy" \ + --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' +)" +if [[ -z "$proxy_ip" ]]; then + echo "FAIL: bounded-agent fake API proxy has no enclave-network address" >&2 + exit 1 +fi + +set +e +logs="$( + docker run --rm "${runtime_args[@]}" \ + --name "awf-bounded-agent-smoke-${run_id}" \ + --network "$network" \ + --read-only \ + --user 65534:65534 \ + --cap-drop ALL \ + --security-opt no-new-privileges:true \ + --memory 512m --memory-swap 512m --cpus 1 --pids-limit 128 \ + --ulimit fsize=33554432 --ulimit nofile=1024:1024 \ + --tmpfs /tmp:rw,noexec,nosuid,nodev,size=64m \ + -v "$root/seed:/awf/seed:ro" \ + -v "$root/task.txt:/awf/task.txt:ro" \ + -v "$root/schema.json:/awf/schema.json:ro" \ + -v "$root/out:/agent/out:rw" \ + -e AWF_BOUNDED_AGENT_API_ENDPOINT="http://${proxy_ip}:10000" \ + -e AWF_BOUNDED_AGENT_PROFILE=openai \ + -e AWF_BOUNDED_AGENT_MODEL=live-smoke \ + -e AWF_BOUNDED_AGENT_MAX_MODEL_REQUESTS=2 \ + -e AWF_BOUNDED_AGENT_MAX_MODEL_TOKENS=64 \ + -e AWF_BOUNDED_AGENT_MAX_OUTPUT_BYTES=64 \ + -e AWF_BOUNDED_AGENT_DEADLINE_SECONDS=30 \ + -e HOME=/tmp -e PYTHONDONTWRITEBYTECODE=1 -e PYTHONUNBUFFERED=1 \ + --entrypoint /usr/local/bin/run-bounded-agent \ + "$image" 2>&1 +)" +status=$? +set -e +if [[ $status -ne 0 || -n "$logs" || "$(cat "$root/out")" != "true" ]]; then + echo "FAIL: $runtime enclave did not produce one silent canonical result" \ + "(status=$status, streamBytes=${#logs}, resultBytes=$(wc -c < "$root/out"))" >&2 + exit 1 +fi + +docker run --rm "${runtime_args[@]}" --network "$network" --entrypoint python3 "$image" -c ' +import socket, sys, urllib.request +socket.create_connection((sys.argv[1], 10000), 2).close() +try: + urllib.request.urlopen("https://example.com", timeout=2) +except Exception: + sys.exit(0) +sys.exit(1) +' "$proxy_ip" + +peers="$(docker network inspect "$network" --format '{{len .Containers}}')" +if [[ "$peers" != "1" ]]; then + echo "FAIL: API-proxy-only network retained unexpected peers: $peers" >&2 + exit 1 +fi + +echo "SUPPORTED: $runtime bounded-agent enclave live smoke passed" diff --git a/scripts/ci/smoke-docker-sbx-workflow.test.ts b/scripts/ci/smoke-docker-sbx-workflow.test.ts new file mode 100644 index 000000000..ad566dd56 --- /dev/null +++ b/scripts/ci/smoke-docker-sbx-workflow.test.ts @@ -0,0 +1,23 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +const workflowsDir = path.resolve(__dirname, '../../.github/workflows'); +const sourcePath = path.join(workflowsDir, 'smoke-docker-sbx.md'); +const lockPath = path.join(workflowsDir, 'smoke-docker-sbx.lock.yml'); + +describe('smoke docker-sbx workflow output targeting', () => { + it('explicitly targets the pull request in the source prompt', () => { + const workflow = fs.readFileSync(sourcePath, 'utf8'); + + expect(workflow).toContain('item_number: ${{ github.event.pull_request.number }}'); + expect(workflow).toContain('Do not rely on implicit triggering context'); + }); + + it('propagates the pull request number in the compiled workflow', () => { + const workflow = fs.readFileSync(lockPath, 'utf8'); + + expect(workflow).toContain( + 'GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number || inputs.item_number }}', + ); + }); +}); diff --git a/src/awf-config-schema.json b/src/awf-config-schema.json index 92cfbd3c9..9df2571fc 100644 --- a/src/awf-config-schema.json +++ b/src/awf-config-schema.json @@ -939,6 +939,157 @@ "privateRepos" ] } + }, + "boundedAgents": { + "type": "object", + "description": "Bounded-agent enclave configuration. When enabled, AWF stages an immutable seed per configured private repository, starts an offline broker, and exposes a fixed `bounded-agent` CLI plus a generated skill to the agent. Each invocation runs a fixed, AWF-authored model loop inside a single-use enclave that joins only a dedicated `internal` bounded-agent Docker network whose sole other member is the AWF API proxy. Requires the API proxy and a supported configured model route. See docs/awf-config-spec.md §15.", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable bounded agents for this run. Requires a staging credential in GH_TOKEN or GITHUB_TOKEN on the AWF host, an enabled API proxy, and a configured `profile`/`model` route. Default: false.", + "default": false + }, + "privateRepos": { + "type": "array", + "description": "Private repositories a bounded agent may reason about, each with a trusted confidentiality category that fixes its per-run information budget (`public` unmetered, `internal` 64 bits/run, `confidential` 8 bits/run, `sealed` 0 bits/run — never launches an enclave). Each `repo` must be a bare `owner/repo` slug — no scheme, host, credentials, path traversal, query string, fragment, or wildcard. Repository names must be unique case-insensitively. Bounded agents keep a ledger separate from bounded queries.", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "repo", + "sensitivity" + ], + "properties": { + "repo": { + "type": "string", + "maxLength": 140, + "pattern": "^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})/(?!\\.\\.?$)(?!.*\\.\\.)[A-Za-z0-9._-]{1,100}$" + }, + "sensitivity": { + "type": "string", + "enum": [ + "public", + "internal", + "confidential", + "sealed" + ], + "description": "Confidentiality category, which fixes this repository's immutable per-run information budget. Cannot be increased by configuration." + } + } + }, + "minItems": 1 + }, + "runtime": { + "type": "string", + "enum": [ + "docker", + "gvisor", + "sbx" + ], + "description": "Sandbox runtime backend used to execute the bounded-agent enclave, selected independently of the primary agent runtime. \"docker\" uses the daemon default runtime; \"gvisor\" requires the runsc OCI runtime; \"sbx\" is a fail-closed preview and is blocked until the installed sbx CLI proves the mandatory API-proxy-only network policy, read-only targeted mounts, unprivileged exec/workdir, and pids/disk/fsize/lifecycle controls this enclave requires. No backend ever falls back. Default: \"docker\".", + "default": "docker" + }, + "profile": { + "type": "string", + "enum": [ + "openai", + "anthropic" + ], + "description": "Trusted provider protocol the enclave speaks to the AWF API proxy: \"openai\" uses POST /v1/chat/completions, \"anthropic\" uses POST /v1/messages. The API proxy injects the real credential; the enclave never holds one. A request can never choose the profile. Default: \"openai\".", + "default": "openai" + }, + "model": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,199}$", + "description": "Model identifier sent on every enclave request. Required when enabled. A request can never choose or override it." + }, + "timeout": { + "type": "integer", + "minimum": 1, + "maximum": 540, + "description": "Maximum wall-clock time in seconds allowed for a single enclave invocation. Capped at 540 so the 10-minute response bucket reserves its final minute for termination, validation, container removal, and workspace cleanup. Default: 120.", + "default": 120 + }, + "memoryLimit": { + "type": "string", + "pattern": "^[1-9][0-9]*[bkmgBKMG]$", + "description": "Docker-style memory limit applied to the enclave (e.g. \"512m\"). Swap is disabled at the same value. Default: \"512m\".", + "default": "512m" + }, + "cpuLimit": { + "type": "string", + "pattern": "^(?:[0-9]{1,2})(?:\\.[0-9]{1,3})?$", + "description": "Fractional CPU limit applied to the enclave (Docker --cpus). Default: \"1\".", + "default": "1" + }, + "pidsLimit": { + "type": "integer", + "minimum": 1, + "maximum": 4096, + "description": "Maximum number of processes/threads the enclave may create. Default: 128.", + "default": 128 + }, + "tmpfsLimit": { + "type": "string", + "pattern": "^[1-9][0-9]*[bkmgBKMG]$", + "description": "Docker-style size limit for each of the enclave's writable tmpfs mounts (/tmp and the /agent work/result root). Default: \"64m\".", + "default": "64m" + }, + "maxOutputBytes": { + "type": "integer", + "minimum": 1, + "maximum": 8192, + "description": "Maximum size in bytes of the enclave's dedicated result file. The broker reads back at most this many bytes and requires exactly one JSON value conforming to the declared finite schema. Default: 8192.", + "default": 8192 + }, + "maxTaskBytes": { + "type": "integer", + "minimum": 1, + "maximum": 65536, + "description": "Maximum size in bytes of the caller-supplied bounded task text. The task is byte-bounded input forwarded verbatim into the enclave prompt; it is never interpreted as configuration. Default: 4096.", + "default": 4096 + }, + "maxInvocations": { + "type": "integer", + "minimum": 1, + "maximum": 1000, + "description": "Maximum number of enclave responses permitted for the current AWF run. Every response — including a rejection — counts. Exhaustion returns the canonical error without launching an enclave. Default: 8.", + "default": 8 + }, + "maxModelRequests": { + "type": "integer", + "minimum": 1, + "maximum": 64, + "description": "Maximum number of model requests one enclave invocation may issue through the API proxy. Default: 8.", + "default": 8 + }, + "maxModelTokens": { + "type": "integer", + "minimum": 1, + "maximum": 32768, + "description": "Maximum completion tokens requested per model call (max_tokens). Default: 1024.", + "default": 1024 + } + }, + "if": { + "properties": { + "enabled": { + "const": true + } + }, + "required": [ + "enabled" + ] + }, + "then": { + "required": [ + "privateRepos", + "model" + ] + } } }, "$defs": { diff --git a/src/bounded-agent/broker.test.ts b/src/bounded-agent/broker.test.ts new file mode 100644 index 000000000..cac655eea --- /dev/null +++ b/src/bounded-agent/broker.test.ts @@ -0,0 +1,688 @@ +import * as path from 'path'; + +/* eslint-disable @typescript-eslint/no-require-imports */ +const brokerDir = path.join(__dirname, '..', '..', 'containers', 'bounded-agent', 'broker'); +const { createBroker } = require(path.join(brokerDir, 'broker.js')); +const { + createEnclaveRunner, + deriveEnclaveContainerSpec, +} = require(path.join(brokerDir, 'enclave-runner.js')); +const { DockerEnclaveRunner } = require(path.join(brokerDir, 'docker-enclave-runner.js')); +const { GvisorEnclaveRunner } = require(path.join(brokerDir, 'gvisor-enclave-runner.js')); +const { SbxEnclaveRunner } = require(path.join(brokerDir, 'sbx-enclave-runner.js')); +const { createLedger } = require(path.join(brokerDir, 'ledger.js')); +const { CANONICAL_ERROR_JSON } = require(path.join(brokerDir, 'protocol.js')); +const { BOUNDED_AGENT_AUDIT_FILENAME } = require(path.join(brokerDir, 'audit.js')); +/* eslint-enable @typescript-eslint/no-require-imports */ + +const SEED_ID = 'a'.repeat(32); +const RUN_ID = 'b'.repeat(32); + +const config = { + workDir: '/srv/awf/work', + seedsDir: '/srv/awf/seeds', + hostWorkDir: '/var/tmp/private/work', + hostSeedsDir: '/var/tmp/private/seeds', + enclaveImage: 'ghcr.io/github/gh-aw-firewall/bounded-agent:latest', + enclaveSeccompPath: '/opt/awf/enclave-seccomp.json', + enclaveMountDir: '/agent', + enclaveSeedPath: '/awf/seed', + enclaveTaskPath: '/awf/task.txt', + enclaveSchemaPath: '/awf/schema.json', + enclaveUid: 65534, + enclaveGid: 65534, + backend: 'docker', + profile: 'openai', + model: 'gpt-4o-mini', + apiEndpoint: 'http://172.31.0.30:10000', + network: 'awf-bounded-agent', + timeoutSeconds: 120, + memoryLimit: '512m', + tmpfsLimit: '64m', + cpuLimit: '1', + pidsLimit: 128, + maxOutputBytes: 8192, + maxTaskBytes: 4096, + maxInvocations: 8, + maxModelRequests: 8, + maxModelTokens: 1024, +}; + +const booleanSchema = { type: 'boolean' }; + +function makeAudit() { + const records: Array> = []; + return { + records, + invocation: (record: Record) => records.push({ kind: 'invocation', ...record }), + failure: (invocationId: string, reason: string, detail?: string) => + records.push({ kind: 'failure', invocationId, reason, detail }), + lifecycle: (event: string, detail?: unknown) => records.push({ kind: 'lifecycle', event, detail }), + }; +} + +function makeClock() { + let now = 0; + return { + nowMs: () => now, + sleep: (ms: number) => { + now += ms; + return Promise.resolve(); + }, + advance: (ms: number) => { + now += ms; + }, + }; +} + +interface WorkspaceStub { + created: string[]; + destroyed: string[]; + output: string | undefined; + createInvocationWorkspace: (params: Record) => Record; + readEnclaveOutput: (outPath: string, maxOutputBytes: number) => string | undefined; + destroyInvocationWorkspace: (workDir: string, invocationId: string) => void; +} + +function makeWorkspace(output: string | undefined = 'true'): WorkspaceStub { + const stub: WorkspaceStub = { + created: [], + destroyed: [], + output, + createInvocationWorkspace: (params) => { + stub.created.push(params.invocationId as string); + return { outPath: `/srv/awf/work/${params.invocationId}/out` }; + }, + readEnclaveOutput: () => stub.output, + destroyInvocationWorkspace: (_workDir, invocationId) => { + stub.destroyed.push(invocationId); + }, + }; + return stub; +} + +/** Simulates a missing/oversized/non-regular result file. */ +function makeMissingOutputWorkspace(): WorkspaceStub { + const stub = makeWorkspace(); + stub.output = undefined; + return stub; +} + +function makeRunner(overrides: Record = {}) { + return { + launches: [] as Array>, + assertAvailable: async () => undefined, + reconcileRun: async () => undefined, + runEnclaveContainer: async function (params: Record) { + (this.launches as Array>).push(params); + return { exitCode: 0, timedOut: false }; + }, + ...overrides, + }; +} + +function seedMap(sensitivity = 'internal'): Map { + return new Map([['octo/alpha', { seedId: SEED_ID, sensitivity }]]); +} + +function request(overrides: Record = {}): Record { + return { privateRepo: 'octo/alpha', schema: booleanSchema, task: 'is it true?', ...overrides }; +} + +async function invoke(broker: any, req: unknown): Promise { + let response = ''; + await broker.handle(req, (json: string) => { + response = json; + }); + return response; +} + +describe('bounded-agent broker', () => { + it('returns a canonical success envelope for a conforming result', async () => { + const workspace = makeWorkspace('true'); + const broker = createBroker({ + config, + seedMap: seedMap(), + runId: RUN_ID, + audit: makeAudit(), + runner: makeRunner(), + workspace, + clock: makeClock(), + }); + + expect(await invoke(broker, request())).toBe('{"status":"ok","result":true}'); + }); + + it('collapses every failure class to the identical canonical error', async () => { + const cases: Array<[string, Record]> = [ + ['invalid-request', { workspace: makeWorkspace('true'), req: request({ image: 'evil' }) }], + ['repo-not-allowed', { workspace: makeWorkspace('true'), req: request({ privateRepo: 'octo/beta' }) }], + ['nonconformant-output', { workspace: makeWorkspace('"nope"'), req: request() }], + ['unreadable-output', { workspace: makeMissingOutputWorkspace(), req: request() }], + ]; + + for (const [, params] of cases) { + const broker = createBroker({ + config, + seedMap: seedMap(), + runId: RUN_ID, + audit: makeAudit(), + runner: makeRunner(), + workspace: params.workspace as WorkspaceStub, + clock: makeClock(), + }); + expect(await invoke(broker, params.req)).toBe(CANONICAL_ERROR_JSON); + } + }); + + it('maps a timed-out enclave to the canonical error', async () => { + const broker = createBroker({ + config, + seedMap: seedMap(), + runId: RUN_ID, + audit: makeAudit(), + runner: makeRunner({ runEnclaveContainer: async () => ({ exitCode: 0, timedOut: true }) }), + workspace: makeWorkspace('true'), + clock: makeClock(), + }); + expect(await invoke(broker, request())).toBe(CANONICAL_ERROR_JSON); + }); + + it('maps a non-zero enclave exit to the canonical error without exposing it', async () => { + const audit = makeAudit(); + const broker = createBroker({ + config, + seedMap: seedMap(), + runId: RUN_ID, + audit, + runner: makeRunner({ runEnclaveContainer: async () => ({ exitCode: 42, timedOut: false }) }), + workspace: makeWorkspace('true'), + clock: makeClock(), + }); + expect(await invoke(broker, request())).toBe(CANONICAL_ERROR_JSON); + expect(audit.records).toEqual([ + expect.objectContaining({ kind: 'failure', reason: 'non-zero-exit' }), + ]); + expect(audit.records[0]).not.toHaveProperty('exitCode'); + expect(audit.records[0].detail).toBeUndefined(); + }); + + it('debits the sensitivity budget before any workspace or container exists', async () => { + const workspace = makeWorkspace('true'); + const runner = makeRunner(); + // `sealed` is a 0-bit budget, so even the cheapest schema is unaffordable. + const broker = createBroker({ + config, + seedMap: seedMap('sealed'), + runId: RUN_ID, + audit: makeAudit(), + runner, + workspace, + clock: makeClock(), + }); + + expect(await invoke(broker, request())).toBe(CANONICAL_ERROR_JSON); + expect(workspace.created).toEqual([]); + expect(runner.launches).toEqual([]); + }); + + it('charges the status and timing channels, not just the schema payload', async () => { + const ledger = createLedger(seedMap('confidential')); + const broker = createBroker({ + config, + seedMap: seedMap('confidential'), + runId: RUN_ID, + audit: makeAudit(), + runner: makeRunner(), + workspace: makeWorkspace('true'), + clock: makeClock(), + ledger, + }); + + // confidential = 8 bits/run; a boolean costs 1 (status) + 1 (payload) + 3 (timing) = 5. + await invoke(broker, request()); + expect(ledger.remainingBits('octo/alpha')).toBe(3); + // The second identical request no longer fits. + expect(await invoke(broker, request())).toBe(CANONICAL_ERROR_JSON); + expect(ledger.remainingBits('octo/alpha')).toBe(3); + }); + + it('never refunds a committed charge, even when the enclave fails', async () => { + const ledger = createLedger(seedMap('confidential')); + const broker = createBroker({ + config, + seedMap: seedMap('confidential'), + runId: RUN_ID, + audit: makeAudit(), + runner: makeRunner({ runEnclaveContainer: async () => ({ exitCode: 1, timedOut: false }) }), + workspace: makeWorkspace('true'), + clock: makeClock(), + ledger, + }); + + await invoke(broker, request()); + expect(ledger.remainingBits('octo/alpha')).toBe(3); + }); + + it('keeps a ledger separate from any other bounded subsystem', async () => { + const agentLedger = createLedger(seedMap('confidential')); + const queryLedger = createLedger(seedMap('confidential')); + const broker = createBroker({ + config, + seedMap: seedMap('confidential'), + runId: RUN_ID, + audit: makeAudit(), + runner: makeRunner(), + workspace: makeWorkspace('true'), + clock: makeClock(), + ledger: agentLedger, + }); + + await invoke(broker, request()); + expect(agentLedger.remainingBits('octo/alpha')).toBe(3); + // A sibling subsystem's ledger is untouched. + expect(queryLedger.remainingBits('octo/alpha')).toBe(8); + }); + + it('enforces the per-run invocation cap on every response, including rejections', async () => { + const runner = makeRunner(); + const broker = createBroker({ + config: { ...config, maxInvocations: 2 }, + seedMap: seedMap('public'), + runId: RUN_ID, + audit: makeAudit(), + runner, + workspace: makeWorkspace('true'), + clock: makeClock(), + }); + + // A rejected request still consumes one unit. + expect(await invoke(broker, request({ image: 'evil' }))).toBe(CANONICAL_ERROR_JSON); + expect(await invoke(broker, request())).toBe('{"status":"ok","result":true}'); + expect(await invoke(broker, request())).toBe(CANONICAL_ERROR_JSON); + expect(runner.launches).toHaveLength(1); + }); + + it('destroys the workspace before responding', async () => { + const workspace = makeWorkspace('true'); + const broker = createBroker({ + config, + seedMap: seedMap(), + runId: RUN_ID, + audit: makeAudit(), + runner: makeRunner(), + workspace, + clock: makeClock(), + }); + + await invoke(broker, request()); + expect(workspace.destroyed).toEqual(workspace.created); + }); + + it('fails closed when workspace teardown fails', async () => { + const workspace = makeWorkspace('true'); + workspace.destroyInvocationWorkspace = () => { + throw new Error('EBUSY'); + }; + const broker = createBroker({ + config, + seedMap: seedMap(), + runId: RUN_ID, + audit: makeAudit(), + runner: makeRunner(), + workspace, + clock: makeClock(), + }); + + expect(await invoke(broker, request())).toBe(CANONICAL_ERROR_JSON); + }); + + it('holds the response until a fixed timing bucket boundary', async () => { + const clock = makeClock(); + const broker = createBroker({ + config, + seedMap: seedMap(), + runId: RUN_ID, + audit: makeAudit(), + runner: makeRunner({ + runEnclaveContainer: async () => { + clock.advance(37); + return { exitCode: 0, timedOut: false }; + }, + }), + workspace: makeWorkspace('true'), + clock, + }); + + await invoke(broker, request()); + // 37ms of work is padded to the 100ms bucket. + expect(clock.nowMs()).toBe(100); + }); + + it('records the sensitivity class and charge but never the repository or task', async () => { + const audit = makeAudit(); + const broker = createBroker({ + config, + seedMap: seedMap(), + runId: RUN_ID, + audit, + runner: makeRunner(), + workspace: makeWorkspace('true'), + clock: makeClock(), + }); + + await invoke(broker, request({ task: 'SECRET-TASK-MARKER' })); + const serialized = JSON.stringify(audit.records); + expect(serialized).toContain('"sensitivity":"internal"'); + expect(serialized).not.toContain('octo/alpha'); + expect(serialized).not.toContain('SECRET-TASK-MARKER'); + expect(serialized).not.toContain('/srv/awf/work'); + }); + + it('stops admitting invocations after close()', async () => { + const runner = makeRunner(); + const broker = createBroker({ + config, + seedMap: seedMap(), + runId: RUN_ID, + audit: makeAudit(), + runner, + workspace: makeWorkspace('true'), + clock: makeClock(), + }); + + broker.close(); + expect(await invoke(broker, request())).toBe(CANONICAL_ERROR_JSON); + expect(runner.launches).toEqual([]); + }); +}); + +describe('bounded-agent protected audit', () => { + it('writes to a file distinct from the bounded-query audit trail', () => { + expect(BOUNDED_AGENT_AUDIT_FILENAME).toBe('bounded-agent.jsonl'); + }); +}); + +describe('bounded-agent enclave container spec', () => { + const spec = deriveEnclaveContainerSpec({ + config, + runId: RUN_ID, + invocationId: 'c'.repeat(24), + seedId: SEED_ID, + }); + const args: string[] = [...spec.launchArgs]; + + const flagValues = (flag: string): string[] => + args.reduce((acc, value, index) => { + if (value === flag && index + 1 < args.length) acc.push(args[index + 1]); + return acc; + }, []); + + it('joins only the dedicated bounded-agent network', () => { + expect(flagValues('--network')).toEqual(['awf-bounded-agent']); + expect(args).not.toContain('awf-net'); + expect(args).not.toContain('awf-ext'); + }); + + it('mounts the repository read-only and never writable', () => { + expect(flagValues('-v')).toContain(`/var/tmp/private/seeds/${SEED_ID}:/awf/seed:ro`); + expect(args).toContain('--read-only'); + }); + + it('mounts the task and schema read-only and the result file read-write', () => { + const volumes = flagValues('-v'); + expect(volumes).toContain(`/var/tmp/private/work/${'c'.repeat(24)}/task.txt:/awf/task.txt:ro`); + expect(volumes).toContain(`/var/tmp/private/work/${'c'.repeat(24)}/schema.json:/awf/schema.json:ro`); + expect(volumes).toContain(`/var/tmp/private/work/${'c'.repeat(24)}/out:/agent/out:rw`); + expect(volumes).toHaveLength(4); + }); + + it('never mounts the Docker socket, the workspace, or host state into the enclave', () => { + const volumes = flagValues('-v').join(' '); + expect(volumes).not.toContain('docker.sock'); + expect(volumes).not.toContain('/host'); + expect(volumes).not.toContain('seed-map.json'); + expect(args).not.toContain('--privileged'); + }); + + it('applies bounded tmpfs mounts for work, result, and /tmp', () => { + const tmpfs = flagValues('--tmpfs'); + expect(tmpfs).toContain('/tmp:rw,noexec,nosuid,nodev,size=64m'); + expect(tmpfs).toContain('/agent:rw,nosuid,nodev,size=64m,uid=65534,gid=65534,mode=0700'); + }); + + it('runs as a fixed non-root uid/gid with all capabilities dropped', () => { + expect(flagValues('--user')).toEqual(['65534:65534']); + expect(flagValues('--cap-drop')).toEqual(['ALL']); + expect(flagValues('--security-opt')).toEqual([ + 'no-new-privileges:true', + 'seccomp=/opt/awf/enclave-seccomp.json', + ]); + }); + + it('bounds memory, cpu, pids, and file size', () => { + expect(flagValues('--memory')).toEqual(['512m']); + expect(flagValues('--memory-swap')).toEqual(['512m']); + expect(flagValues('--cpus')).toEqual(['1']); + expect(flagValues('--pids-limit')).toEqual(['128']); + expect(flagValues('--ulimit')).toEqual([`fsize=${32 * 1024 * 1024}`, 'nofile=1024:1024']); + }); + + it('never pulls and always uses a fresh uniquely named labelled container', () => { + expect(flagValues('--pull')).toEqual(['never']); + expect(spec.containerName).toBe(`awf-bounded-agent-${RUN_ID.slice(0, 12)}-${'c'.repeat(24)}`); + expect(flagValues('--label')).toEqual([ + `awf.bounded-agent.run=${RUN_ID}`, + `awf.bounded-agent.invocation=${'c'.repeat(24)}`, + ]); + }); + + it('passes only the fixed trusted enclave environment', () => { + expect(flagValues('--env').sort()).toEqual([ + 'AWF_BOUNDED_AGENT_API_ENDPOINT=http://172.31.0.30:10000', + 'AWF_BOUNDED_AGENT_DEADLINE_SECONDS=120', + 'AWF_BOUNDED_AGENT_MAX_MODEL_REQUESTS=8', + 'AWF_BOUNDED_AGENT_MAX_MODEL_TOKENS=1024', + 'AWF_BOUNDED_AGENT_MAX_OUTPUT_BYTES=8192', + 'AWF_BOUNDED_AGENT_MODEL=gpt-4o-mini', + 'AWF_BOUNDED_AGENT_PROFILE=openai', + 'HOME=/tmp', + 'PYTHONDONTWRITEBYTECODE=1', + 'PYTHONUNBUFFERED=1', + ]); + }); + + it('never leaks a credential, token, or proxy setting into the enclave environment', () => { + const envNames = flagValues('--env').map((entry) => entry.split('=')[0]); + for (const name of envNames) { + expect(name).not.toMatch(/API_KEY|_TOKEN$|^GH_|^GITHUB_|AUTHORIZATION|SECRET|CREDENTIAL/i); + expect(name).not.toMatch(/^(?:HTTP|HTTPS|NO)_PROXY$/i); + } + }); + + it('uses the fixed AWF-authored entrypoint', () => { + expect(args.slice(-3)).toEqual([ + '--entrypoint', + '/usr/local/bin/run-bounded-agent', + 'ghcr.io/github/gh-aw-firewall/bounded-agent:latest', + ]); + }); + + it('adds --runtime runsc only for the gVisor backend', () => { + expect(args).not.toContain('--runtime'); + const gvisorArgs: string[] = [ + ...deriveEnclaveContainerSpec({ + config, + runId: RUN_ID, + invocationId: 'c'.repeat(24), + seedId: SEED_ID, + runtimeName: 'runsc', + }).launchArgs, + ]; + expect(gvisorArgs).toContain('--runtime'); + expect(gvisorArgs[gvisorArgs.indexOf('--runtime') + 1]).toBe('runsc'); + }); + + it('rejects any other OCI runtime', () => { + expect(() => + deriveEnclaveContainerSpec({ + config, + runId: RUN_ID, + invocationId: 'c'.repeat(24), + seedId: SEED_ID, + runtimeName: 'kata', + }), + ).toThrow(/Unsupported OCI runtime/); + }); + + it('rejects identifiers that are not broker-generated', () => { + for (const bad of ['../escape', 'UPPER', 'has space', '']) { + expect(() => + deriveEnclaveContainerSpec({ config, runId: RUN_ID, invocationId: bad, seedId: SEED_ID }), + ).toThrow(/broker-generated identifier/); + } + expect(() => + deriveEnclaveContainerSpec({ + config, + runId: RUN_ID, + invocationId: 'c'.repeat(24), + seedId: '../../etc', + }), + ).toThrow(/AWF-generated seed identifier/); + }); + + it('freezes the argument vector', () => { + expect(Object.isFrozen(spec.launchArgs)).toBe(true); + }); +}); + +describe('bounded-agent enclave runner selection', () => { + const dockerStub = (results: Record) => ({ + calls: [] as string[][], + runDocker: async function (args: string[]) { + (this.calls as string[][]).push(args); + const key = `${args[0]} ${args[1] ?? ''}`.trim(); + const result = results[key] ?? results[args[0]] ?? { exitCode: 0 }; + return { exitCode: result.exitCode, stdout: result.stdout ?? '', stderr: '', timedOut: false }; + }, + }); + + it('selects the Docker runner for the docker backend', () => { + expect(createEnclaveRunner(config)).toBeInstanceOf(DockerEnclaveRunner); + }); + + it('selects the gVisor runner for the gvisor backend', () => { + expect(createEnclaveRunner({ ...config, backend: 'gvisor' })).toBeInstanceOf(GvisorEnclaveRunner); + }); + + it('selects the sbx runner for the sbx backend, which fails closed on assertAvailable', () => { + const runner = createEnclaveRunner({ ...config, backend: 'sbx' }); + expect(runner).toBeInstanceOf(SbxEnclaveRunner); + }); + + it('fails closed for any other backend, not sbx', () => { + expect(() => createEnclaveRunner({ ...config, backend: 'firecracker' })).toThrow(/Unsupported/); + }); + + it('requires the enclave image and the dedicated network to already exist', async () => { + const missingImage = dockerStub({ 'image inspect': { exitCode: 1 } }); + await expect( + new DockerEnclaveRunner(config, { docker: missingImage }).assertAvailable(), + ).rejects.toThrow(/image is not available/); + + const missingNetwork = dockerStub({ + 'image inspect': { exitCode: 0 }, + 'network inspect': { exitCode: 1 }, + }); + await expect( + new DockerEnclaveRunner(config, { docker: missingNetwork }).assertAvailable(), + ).rejects.toThrow(/bounded-agent network is not available/); + }); + + it('requires an exactly registered runsc for the gVisor runner', async () => { + const withoutRunsc = dockerStub({ + 'image inspect': { exitCode: 0 }, + 'network inspect': { exitCode: 0 }, + 'info --format': { exitCode: 0, stdout: 'runc\n' }, + info: { exitCode: 0, stdout: 'runc\n' }, + }); + await expect( + new GvisorEnclaveRunner(config, { docker: withoutRunsc }).assertAvailable(), + ).rejects.toThrow(/no fallback is permitted/); + + const withRunsc = dockerStub({ + 'image inspect': { exitCode: 0 }, + 'network inspect': { exitCode: 0 }, + info: { exitCode: 0, stdout: 'runc\nrunsc\n' }, + }); + await expect( + new GvisorEnclaveRunner(config, { docker: withRunsc }).assertAvailable(), + ).resolves.toBeUndefined(); + }); + + it('deterministically removes every container labelled with this run', async () => { + const docker = dockerStub({ ps: { exitCode: 0, stdout: 'abcdef123456\n' } }); + const runner = new DockerEnclaveRunner(config, { docker }); + await runner.reconcileRun(RUN_ID); + + const listed = docker.calls.find((args) => args[0] === 'ps'); + expect(listed).toEqual(['ps', '-aq', '--filter', `label=awf.bounded-agent.run=${RUN_ID}`]); + expect(docker.calls).toContainEqual(['rm', '-f', 'abcdef123456']); + }); + + it('removes the invocation container before returning and discards its streams', async () => { + const docker = dockerStub({ + run: { exitCode: 0, stdout: 'CHATTY ENCLAVE OUTPUT' }, + ps: { exitCode: 0, stdout: 'abcdef123456\n' }, + }); + const runner = new DockerEnclaveRunner(config, { docker }); + const result = await runner.runEnclaveContainer({ + runId: RUN_ID, + invocationId: 'c'.repeat(24), + seedId: SEED_ID, + timeoutMs: 1000, + }); + + expect(result).toEqual({ exitCode: 0, timedOut: false }); + expect(JSON.stringify(result)).not.toContain('CHATTY'); + expect(docker.calls.some((args) => args[0] === 'rm')).toBe(true); + }); + + it('fails closed when cleanup fails after an interrupted enclave', async () => { + const docker = { + runDocker: async (args: string[]) => { + if (args[0] === 'run') return { exitCode: 0, stdout: '', stderr: '', timedOut: true }; + if (args[0] === 'ps') return { exitCode: 1, stdout: '', stderr: '', timedOut: false }; + return { exitCode: 0, stdout: '', stderr: '', timedOut: false }; + }, + }; + const runner = new DockerEnclaveRunner(config, { docker }); + await expect( + runner.runEnclaveContainer({ + runId: RUN_ID, + invocationId: 'c'.repeat(24), + seedId: SEED_ID, + timeoutMs: 1000, + }), + ).rejects.toThrow(/reconcile bounded-agent containers/); + }); + + it('fails closed when cleanup fails after a successful enclave', async () => { + const docker = { + runDocker: async (args: string[]) => { + if (args[0] === 'run') return { exitCode: 0, stdout: '', stderr: '', timedOut: false }; + if (args[0] === 'ps') return { exitCode: 1, stdout: '', stderr: '', timedOut: false }; + return { exitCode: 0, stdout: '', stderr: '', timedOut: false }; + }, + }; + const runner = new DockerEnclaveRunner(config, { docker }); + await expect( + runner.runEnclaveContainer({ + runId: RUN_ID, + invocationId: 'c'.repeat(24), + seedId: SEED_ID, + timeoutMs: 1000, + }), + ).rejects.toThrow(/reconcile bounded-agent containers/); + }); +}); diff --git a/src/bounded-agent/ingress-conformance.test.ts b/src/bounded-agent/ingress-conformance.test.ts new file mode 100644 index 000000000..bc303c6ba --- /dev/null +++ b/src/bounded-agent/ingress-conformance.test.ts @@ -0,0 +1,254 @@ +import * as fs from 'fs'; +import * as http from 'http'; +import * as net from 'net'; +import * as os from 'os'; +import * as path from 'path'; +import type { AddressInfo } from 'net'; + +/* eslint-disable @typescript-eslint/no-require-imports */ +const brokerDir = path.join(__dirname, '..', '..', 'containers', 'bounded-agent', 'broker'); +const { createServer, createTcpServer, listenOnSocket, listenOnTcp, MAX_CONNECTIONS } = require( + path.join(brokerDir, 'server.js'), +); +/* eslint-enable @typescript-eslint/no-require-imports */ + +/** + * TCP ingress conformance tests for the bounded-agent broker, mirroring the + * coverage bounded queries already have + * (`src/bounded-query/ingress-conformance.test.ts`): missing/duplicate/wrong + * capability rejection, one-shot probe retirement, capability-header + * stripping before framing, byte-identical Unix/TCP canonical responses, body + * size limits, and connection-limit behavior. + * + * Only `server.js`'s existing exports (`createServer`, `createTcpServer`, + * `listenOnSocket`, `listenOnTcp`, `MAX_CONNECTIONS`) are used — no + * production surface is widened for these tests. + */ + +const CAPABILITY = 'a'.repeat(64); +const PROBE_CAPABILITY = 'b'.repeat(64); +const CANONICAL_ERROR = '{"status":"error"}'; +const CANONICAL_OK = '{"status":"ok","result":true}'; +const SCHEMA = Buffer.from('{"type":"boolean"}').toString('base64url'); +const MAX_TASK_BYTES = 64 * 1024; + +interface Response { + status: number | undefined; + headers: http.IncomingHttpHeaders; + body: string; +} + +function stableResponse(response: Response) { + return { + status: response.status, + body: response.body, + contentType: response.headers['content-type'], + cacheControl: response.headers['cache-control'], + contentLength: response.headers['content-length'], + }; +} + +function request(options: http.RequestOptions, body = 'do the task'): Promise { + return new Promise((resolve, reject) => { + const req = http.request({ + method: 'POST', + path: '/query', + ...options, + headers: { + 'content-type': 'application/octet-stream', + 'x-awf-agent-version': '1', + 'x-awf-repo': 'octo/private', + 'x-awf-schema-b64': SCHEMA, + ...options.headers, + }, + }, (res) => { + const chunks: Buffer[] = []; + res.on('data', (chunk) => chunks.push(chunk)); + res.on('end', () => resolve({ + status: res.statusCode, + headers: res.headers, + body: Buffer.concat(chunks).toString('utf8'), + })); + }); + req.on('error', reject); + req.end(body); + }); +} + +describe('bounded-agent ingress conformance', () => { + let root: string; + let unixServer: http.Server; + let tcpServer: http.Server; + let socketPath: string; + let tcpPort: number; + let handled: unknown[]; + const audit = { + failure: jest.fn(), + lifecycle: jest.fn(), + }; + + beforeEach(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-bounded-agent-ingress-test-')); + socketPath = path.join(root, 'broker.sock'); + handled = []; + const broker = { + handle: (incoming: unknown, respond: (body: string) => void) => { + handled.push(incoming); + respond(incoming === undefined ? CANONICAL_ERROR : CANONICAL_OK); + return Promise.resolve(); + }, + }; + unixServer = createServer({ broker, audit }); + tcpServer = createTcpServer({ + broker, + audit, + capabilities: { query: CAPABILITY, probe: PROBE_CAPABILITY }, + }); + await listenOnSocket(unixServer, { + socketPath, + socketDir: root, + socketUid: process.getuid?.() ?? 0, + socketGid: process.getgid?.() ?? 0, + }, audit); + await listenOnTcp(tcpServer, { tcpPort: 0 }); + tcpPort = (tcpServer.address() as AddressInfo).port; + }); + + afterEach(async () => { + await Promise.all([ + new Promise((resolve) => unixServer.close(() => resolve())), + new Promise((resolve) => tcpServer.close(() => resolve())), + ]); + fs.rmSync(root, { recursive: true, force: true }); + jest.clearAllMocks(); + }); + + const unixRequest = (body?: string) => request({ socketPath }, body); + const tcpRequest = (body?: string, capability = CAPABILITY) => request({ + host: '127.0.0.1', + port: tcpPort, + headers: { 'x-awf-capability': capability }, + }, body); + + it('returns byte-identical status, headers, and canonical result bytes across transports', async () => { + const [unix, tcp] = await Promise.all([unixRequest(), tcpRequest()]); + expect(stableResponse(tcp)).toEqual(stableResponse(unix)); + expect(stableResponse(tcp)).toEqual(expect.objectContaining({ + status: 200, + body: CANONICAL_OK, + contentType: 'application/json', + cacheControl: 'no-store', + contentLength: String(Buffer.byteLength(CANONICAL_OK)), + })); + expect(handled).toHaveLength(2); + expect(handled[0]).toEqual(handled[1]); + expect(handled[0]).not.toHaveProperty('capability'); + }); + + it('collapses missing, wrong, and duplicated authentication to canonical failure bytes', async () => { + const missing = request({ host: '127.0.0.1', port: tcpPort }); + const wrong = tcpRequest(undefined, 'c'.repeat(64)); + const duplicated = request({ + host: '127.0.0.1', + port: tcpPort, + headers: { 'x-awf-capability': [CAPABILITY, CAPABILITY] }, + }); + const responses = await Promise.all([missing, wrong, duplicated]); + for (const response of responses) { + expect(response.status).toBe(200); + expect(response.body).toBe(CANONICAL_ERROR); + } + expect(handled).toHaveLength(0); + expect(audit.failure).toHaveBeenCalledWith('transport', 'auth-rejected'); + }); + + it('uses a one-shot probe capability without launching or consuming a request, then permanently retires it', async () => { + const before = handled.length; + const first = await tcpRequest('', PROBE_CAPABILITY); + const second = await tcpRequest('', PROBE_CAPABILITY); + expect(first.body).toBe(CANONICAL_ERROR); + expect(second.body).toBe(CANONICAL_ERROR); + expect(handled.length).toBe(before); + expect(audit.lifecycle).toHaveBeenCalledWith('sbx-ingress-probe'); + expect(audit.lifecycle).toHaveBeenCalledTimes(1); + // The second attempt with the same (now-retired) probe capability must be + // rejected as an ordinary auth failure, not treated as another probe. + expect(audit.failure).toHaveBeenCalledWith('transport', 'auth-rejected'); + }); + + it('strips the capability header before handing the request to framing/broker logic', async () => { + await tcpRequest(); + expect(handled).toHaveLength(1); + expect(handled[0]).not.toHaveProperty('capability'); + expect(JSON.stringify(handled[0])).not.toContain(CAPABILITY); + }); + + it('keeps oversized and parallel request behavior identical across transports', async () => { + const oversized = 'x'.repeat(MAX_TASK_BYTES + 1); + const [unixOversized, tcpOversized] = await Promise.all([ + unixRequest(oversized), + tcpRequest(oversized), + ]); + expect(unixOversized.body).toBe(CANONICAL_ERROR); + expect(stableResponse(tcpOversized)).toEqual(stableResponse(unixOversized)); + + const results = await Promise.all([ + unixRequest(), + unixRequest(), + tcpRequest(), + tcpRequest(), + ]); + expect(results.map((result) => result.body)).toEqual(Array(4).fill(CANONICAL_OK)); + }); + + it('accepts a task body exactly at the size limit and rejects one byte over it, identically on both transports', async () => { + const atLimit = 'x'.repeat(MAX_TASK_BYTES); + const overLimit = 'x'.repeat(MAX_TASK_BYTES + 1); + const [unixAtLimit, tcpAtLimit] = await Promise.all([unixRequest(atLimit), tcpRequest(atLimit)]); + expect(unixAtLimit.body).toBe(CANONICAL_OK); + expect(tcpAtLimit.body).toBe(CANONICAL_OK); + + const [unixOverLimit, tcpOverLimit] = await Promise.all([ + unixRequest(overLimit), + tcpRequest(overLimit), + ]); + expect(unixOverLimit.body).toBe(CANONICAL_ERROR); + expect(tcpOverLimit.body).toBe(CANONICAL_ERROR); + }); + + it('does not dispatch broker work for a request that arrives on an over-limit socket', async () => { + const holders = await Promise.all(Array.from({ length: MAX_CONNECTIONS }, () => new Promise((resolve, reject) => { + const socket = net.createConnection({ host: '127.0.0.1', port: tcpPort }, () => resolve(socket)); + socket.on('error', reject); + }))); + + try { + const rawResponse = await new Promise((resolve, reject) => { + const socket = net.createConnection({ host: '127.0.0.1', port: tcpPort }, () => { + socket.write([ + 'POST /query HTTP/1.1', + 'Host: 127.0.0.1', + `X-AWF-Capability: ${CAPABILITY}`, + 'Content-Type: application/octet-stream', + 'X-AWF-Agent-Version: 1', + 'X-AWF-Repo: octo/private', + `X-AWF-Schema-B64: ${SCHEMA}`, + 'Content-Length: 0', + '', + '', + ].join('\r\n')); + }); + const chunks: Uint8Array[] = []; + socket.on('data', (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))); + socket.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))); + socket.on('error', reject); + }); + + expect(rawResponse).toContain(CANONICAL_ERROR); + expect(handled).toHaveLength(0); + expect(audit.failure).toHaveBeenCalledWith('transport', 'connection-limit'); + } finally { + for (const socket of holders) socket.destroy(); + } + }); +}); diff --git a/src/bounded-agent/ingress.test.ts b/src/bounded-agent/ingress.test.ts new file mode 100644 index 000000000..83088faaf --- /dev/null +++ b/src/bounded-agent/ingress.test.ts @@ -0,0 +1,125 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import execa from 'execa'; +import type { WrapperConfig } from '../types'; +import { resolveDockerHostGateway } from '../services/host-gateway'; +import { + removeSbxIngressCapabilityFile, + resolveSbxIngress, +} from './ingress'; +import { resolveBoundedAgentPaths } from './paths'; + +jest.mock('execa', () => ({ __esModule: true, default: jest.fn() })); +jest.mock('../services/host-gateway', () => ({ + resolveDockerHostGateway: jest.fn(() => '172.17.0.1'), +})); +const mockExeca = execa as unknown as jest.Mock; +const mockResolveDockerHostGateway = resolveDockerHostGateway as jest.Mock; + +describe('sbx bounded-agent ingress resolution', () => { + let workDir: string; + let config: WrapperConfig; + + beforeEach(() => { + workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-bounded-agent-ingress-resolution-')); + config = { + workDir, + boundedAgentIngressTransport: 'sbx-http', + } as WrapperConfig; + const paths = resolveBoundedAgentPaths(workDir); + fs.mkdirSync(paths.controlDir, { recursive: true, mode: 0o700 }); + fs.writeFileSync(paths.capabilityPath, JSON.stringify({ + version: 1, + query: 'a'.repeat(64), + probe: 'b'.repeat(64), + }), { mode: 0o600 }); + mockExeca.mockReset(); + mockResolveDockerHostGateway.mockReturnValue('172.17.0.1'); + mockExeca.mockResolvedValue({ + exitCode: 0, + stdout: 'healthy|172.17.0.1:49152\n', + stderr: '', + }); + }); + + afterEach(() => { + const paths = resolveBoundedAgentPaths(workDir); + fs.rmSync(paths.root, { recursive: true, force: true }); + fs.rmSync(paths.ingressRoot, { recursive: true, force: true }); + fs.rmSync(workDir, { recursive: true, force: true }); + }); + + it('rejects a transport other than sbx-http', async () => { + await expect(resolveSbxIngress({ ...config, boundedAgentIngressTransport: 'unix' } as WrapperConfig)) + .rejects.toThrow(/non-HTTP bounded-agent transport/); + }); + + it('fails closed when the Docker host gateway cannot be resolved', async () => { + mockResolveDockerHostGateway.mockReturnValue(undefined); + await expect(resolveSbxIngress(config)).rejects.toThrow(/Could not resolve the Docker host-gateway/); + }); + + it('returns only the endpoint, two capabilities, and agent-visible artifact paths', async () => { + const result = await resolveSbxIngress(config); + const paths = resolveBoundedAgentPaths(workDir); + + expect(result).toEqual({ + endpoint: 'http://host.docker.internal:49152/query', + queryCapability: 'a'.repeat(64), + probeCapability: 'b'.repeat(64), + skillPath: paths.skillPath, + wrapperDir: paths.agentDir, + }); + const dockerArgs = mockExeca.mock.calls[0][1] as string[]; + expect(dockerArgs.join(' ')).not.toContain('a'.repeat(64)); + expect(dockerArgs.join(' ')).not.toContain('b'.repeat(64)); + }); + + it.each([ + '0.0.0.0:49152', + '[::1]:49152', + '172.17.0.1:0', + '172.17.0.1:70000', + '', + ])('rejects a broad or malformed publication: %s', async (published) => { + mockExeca.mockResolvedValue({ exitCode: 0, stdout: `healthy|${published}`, stderr: '' }); + await expect(resolveSbxIngress(config)).rejects.toThrow(/narrowly published/); + }); + + it('waits for broker health before returning the endpoint', async () => { + mockExeca + .mockResolvedValueOnce({ exitCode: 0, stdout: 'starting|', stderr: '' }) + .mockResolvedValueOnce({ exitCode: 0, stdout: 'healthy|172.17.0.1:49152', stderr: '' }); + + const result = await resolveSbxIngress(config); + expect(result.endpoint).toBe('http://host.docker.internal:49152/query'); + expect(mockExeca.mock.calls.length).toBeGreaterThanOrEqual(2); + }); + + it('rejects a malformed on-disk capability file', async () => { + const paths = resolveBoundedAgentPaths(workDir); + fs.writeFileSync(paths.capabilityPath, JSON.stringify({ version: 1, query: 'not-hex', probe: 'b'.repeat(64) })); + await expect(resolveSbxIngress(config)).rejects.toThrow(/malformed/); + }); + + it.each([ + { version: 2, query: 'a'.repeat(64), probe: 'b'.repeat(64) }, + { version: 1, query: 1, probe: 'b'.repeat(64) }, + { version: 1, query: 'a'.repeat(64), probe: 1 }, + { version: 1, query: 'not-hex', probe: 'b'.repeat(64) }, + { version: 1, query: 'a'.repeat(64), probe: 'not-hex' }, + { version: 1, query: 'a'.repeat(64), probe: 'a'.repeat(64) }, + ])('rejects malformed capability field combinations: %j', async (capabilities) => { + const paths = resolveBoundedAgentPaths(workDir); + fs.writeFileSync(paths.capabilityPath, JSON.stringify(capabilities)); + await expect(resolveSbxIngress(config)).rejects.toThrow(/malformed/); + }); + + it('removes the private capability file after broker startup and sbx probing', () => { + const capabilityPath = resolveBoundedAgentPaths(workDir).capabilityPath; + expect(fs.existsSync(capabilityPath)).toBe(true); + removeSbxIngressCapabilityFile(config); + expect(fs.existsSync(capabilityPath)).toBe(false); + }); +}); diff --git a/src/bounded-agent/ingress.ts b/src/bounded-agent/ingress.ts new file mode 100644 index 000000000..a420764b0 --- /dev/null +++ b/src/bounded-agent/ingress.ts @@ -0,0 +1,119 @@ +import * as fs from 'fs'; +import execa from 'execa'; +import { BOUNDED_AGENT_BROKER_CONTAINER_NAME } from '../constants'; +import { getLocalDockerEnv } from '../host-env'; +import { resolveDockerHostGateway } from '../services/host-gateway'; +import type { WrapperConfig } from '../types'; +import { resolveBoundedAgentPaths } from './paths'; + +export const BOUNDED_AGENT_TCP_PORT = 18081; +export const BOUNDED_AGENT_INGRESS_NETWORK = 'awf-bounded-agent-ingress'; +export const SBX_HOST_ALIAS = 'host.docker.internal'; + +interface SbxIngressCapabilities { + version: 1; + query: string; + probe: string; +} + +export interface ResolvedSbxIngress { + endpoint: string; + queryCapability: string; + probeCapability: string; + skillPath: string; + wrapperDir: string; +} + +function readCapabilities(config: WrapperConfig): SbxIngressCapabilities { + const paths = resolveBoundedAgentPaths(config.workDir); + const parsed = JSON.parse(fs.readFileSync(paths.capabilityPath, 'utf8')) as Partial; + const capabilityPattern = /^[0-9a-f]{64}$/; + if ( + parsed.version !== 1 + || typeof parsed.query !== 'string' + || typeof parsed.probe !== 'string' + || !capabilityPattern.test(parsed.query) + || !capabilityPattern.test(parsed.probe) + || parsed.query === parsed.probe + ) { + throw new Error('Bounded-agent sbx ingress capability file is malformed'); + } + return parsed as SbxIngressCapabilities; +} + +/** + * Resolves the healthy host-gateway publication without logging capabilities. + * + * Mirrors bounded-query's `resolveSbxIngress` exactly: a primary sbx microVM + * cannot receive the broker's Unix-socket bind mount, so when the executable + * passthrough probe fails, the broker is instead published on an ephemeral, + * host-gateway-only port on a dedicated internal Docker network, and the + * microVM authenticates with a broker-generated, random per-run capability + * that is never logged, put in telemetry, or written to any audit/skill file. + */ +export async function resolveSbxIngress(config: WrapperConfig): Promise { + if (config.boundedAgentIngressTransport !== 'sbx-http') { + throw new Error('resolveSbxIngress called for a non-HTTP bounded-agent transport'); + } + const expectedHostIp = resolveDockerHostGateway(); + if (!expectedHostIp) { + throw new Error('Could not resolve the Docker host-gateway IP for bounded-agent sbx ingress'); + } + + const deadline = Date.now() + 30_000; + let lastPublished = ''; + let lastHealth = ''; + while (Date.now() < deadline) { + const result = await execa( + 'docker', + [ + 'inspect', + '--format', + `{{if .State.Health}}{{.State.Health.Status}}{{end}}|{{with index (index .NetworkSettings.Ports "${BOUNDED_AGENT_TCP_PORT}/tcp") 0}}{{.HostIp}}:{{.HostPort}}{{end}}`, + BOUNDED_AGENT_BROKER_CONTAINER_NAME, + ], + { + env: getLocalDockerEnv(), + reject: false, + timeout: 5_000, + }, + ); + const [health = '', published = ''] = result.stdout.trim().split('|', 2); + lastHealth = health; + lastPublished = published; + const separator = published.lastIndexOf(':'); + const publishedHostIp = separator === -1 ? '' : published.slice(0, separator); + const publishedPort = separator === -1 ? '' : published.slice(separator + 1); + const publishedPortNumber = Number(publishedPort); + const hasValidPort = /^[1-9][0-9]{0,4}$/.test(publishedPort) && publishedPortNumber <= 65535; + if (result.exitCode === 0 && health === 'healthy' && publishedHostIp === expectedHostIp && hasValidPort) { + const paths = resolveBoundedAgentPaths(config.workDir); + const capabilities = readCapabilities(config); + return { + endpoint: `http://${SBX_HOST_ALIAS}:${publishedPort}/query`, + queryCapability: capabilities.query, + probeCapability: capabilities.probe, + skillPath: paths.skillPath, + wrapperDir: paths.agentDir, + }; + } + if (result.exitCode === 0 && health === 'healthy') { + throw new Error(`Bounded-agent sbx ingress is not narrowly published on host-gateway ${expectedHostIp}`); + } + await new Promise((resolve) => setTimeout(resolve, 1_000)); + } + + throw new Error( + `Bounded-agent sbx ingress did not become healthy on host-gateway ${expectedHostIp} ` + + `(health=${lastHealth || 'unknown'}, published=${lastPublished || 'none'})`, + ); +} + +/** Deletes the on-disk secret after the running broker has loaded it. */ +export function removeSbxIngressCapabilityFile(config: WrapperConfig): void { + fs.rmSync(resolveBoundedAgentPaths(config.workDir).capabilityPath, { force: true }); +} + +/** @internal */ +// ts-prune-ignore-next +export const ingressTestHelpers = { readCapabilities }; diff --git a/src/bounded-agent/manager.test.ts b/src/bounded-agent/manager.test.ts new file mode 100644 index 000000000..1218f8149 --- /dev/null +++ b/src/bounded-agent/manager.test.ts @@ -0,0 +1,729 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import execa from 'execa'; +import { logger } from '../logger'; +import type { WrapperConfig } from '../types'; +import { BOUNDED_AGENT_DEFAULTS, type BoundedAgentsConfig } from '../types/bounded-agent-options'; +import { deriveSeedId, resolveBoundedAgentPaths } from './paths'; +import { + BOUNDED_AGENT_RUN_LABEL, + boundedAgentManagerTestHelpers, + isBoundedAgentsEnabled, + prepareBoundedAgents, + reportBoundedAgentSbxIngressResult, + teardownBoundedAgents, +} from './manager'; +import { releaseSeedPermissions, type GitRunner } from './staging'; +import * as staging from './staging'; +import { resolveBoundedQueryPaths } from '../bounded-query/paths'; + +jest.mock('execa', () => ({ __esModule: true, default: jest.fn() })); +const mockExeca = execa as unknown as jest.Mock; + +const boundedAgents: BoundedAgentsConfig = { + ...BOUNDED_AGENT_DEFAULTS, + enabled: true, + model: 'gpt-4o-mini', + privateRepos: [{ repo: 'octo/private', sensitivity: 'internal' }], +}; + +const gitRunner: GitRunner = async (args) => { + if (args.includes('clone')) { + const dest = args[args.length - 1]; + fs.mkdirSync(path.join(dest, '.git'), { recursive: true }); + fs.writeFileSync(path.join(dest, '.git', 'config'), '[core]\n'); + fs.writeFileSync(path.join(dest, 'README.md'), 'contents\n'); + return { stdout: '' }; + } + if (args[0] === 'rev-parse') return { stdout: 'a'.repeat(40) }; + return { stdout: '' }; +}; + +function buildConfig(workDir: string, overrides: Partial = {}): WrapperConfig { + return { + workDir, + enableApiProxy: true, + openaiApiKey: 'sk-real', + boundedAgents: { ...boundedAgents, ...overrides }, + } as unknown as WrapperConfig; +} + +/** Preflight is proven separately; here it always succeeds unless overridden. */ +const assertRuntimeAvailable = jest.fn(async () => undefined); + +describe('isBoundedAgentsEnabled', () => { + it('is true only for an explicitly enabled config', () => { + expect(isBoundedAgentsEnabled({} as WrapperConfig)).toBe(false); + expect(isBoundedAgentsEnabled(buildConfig('/tmp/x', { enabled: false }))).toBe(false); + expect(isBoundedAgentsEnabled(buildConfig('/tmp/x'))).toBe(true); + }); + + describe('deriveSeedId', () => { + it('derives a stable opaque id from the run and normalized repository', () => { + const runId = 'a'.repeat(32); + expect(deriveSeedId(runId, 'Octo/Private')).toBe(deriveSeedId(runId, 'octo/private')); + expect(deriveSeedId(runId, 'octo/private')).toMatch(/^[0-9a-f]{32}$/); + }); + + it('uses an unprivileged root identity fallback when getuid is unavailable', () => { + const getuid = jest.spyOn(process, 'getuid').mockReturnValue(undefined as unknown as number); + try { + expect(path.basename(resolveBoundedAgentPaths('/tmp/example').root)).toMatch( + /^awf-bounded-agent-private-0-/, + ); + } finally { + getuid.mockRestore(); + } + }); + }); +}); + +describe('prepareBoundedAgents', () => { + let workDir: string; + + beforeEach(() => { + mockExeca.mockReset(); + mockExeca.mockResolvedValue({ exitCode: 0, stdout: '' }); + assertRuntimeAvailable.mockClear(); + assertRuntimeAvailable.mockResolvedValue(undefined); + workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-bounded-agent-manager-')); + }); + + afterEach(() => { + const paths = resolveBoundedAgentPaths(workDir); + releaseSeedPermissions(paths.seedsDir); + fs.rmSync(paths.root, { recursive: true, force: true }); + fs.rmSync(paths.ingressRoot, { recursive: true, force: true }); + fs.rmSync(workDir, { recursive: true, force: true }); + }); + + it('does nothing when bounded agents are disabled', async () => { + await prepareBoundedAgents(buildConfig(workDir, { enabled: false })); + expect(fs.existsSync(resolveBoundedAgentPaths(workDir).root)).toBe(false); + }); + + it('creates the private layout, seed map, skill, and wrapper artifacts', async () => { + await prepareBoundedAgents(buildConfig(workDir), { + env: { GH_TOKEN: 't' }, + gitRunner, + assertRuntimeAvailable, + }); + const paths = resolveBoundedAgentPaths(workDir); + + expect(fs.existsSync(paths.seedsDir)).toBe(true); + expect(fs.existsSync(paths.workDir)).toBe(true); + expect(fs.existsSync(paths.controlDir)).toBe(true); + expect(fs.existsSync(paths.auditDir)).toBe(true); + expect(fs.existsSync(paths.seedMapPath)).toBe(true); + expect(fs.existsSync(paths.skillPath)).toBe(true); + expect(fs.existsSync(paths.wrapperPath)).toBe(true); + }); + + it('uses a private root disjoint from the bounded-query private root', async () => { + const agentPaths = resolveBoundedAgentPaths(workDir); + const queryPaths = resolveBoundedQueryPaths(workDir); + + expect(agentPaths.root).not.toBe(queryPaths.root); + expect(agentPaths.ingressRoot).not.toBe(queryPaths.ingressRoot); + expect(agentPaths.root.startsWith(queryPaths.root)).toBe(false); + expect(queryPaths.root.startsWith(agentPaths.root)).toBe(false); + }); + + it('runs preflight before staging clones anything', async () => { + assertRuntimeAvailable.mockRejectedValueOnce(new Error('runsc is not registered')); + const cloned: string[][] = []; + const trackingGitRunner: GitRunner = async (args) => { + cloned.push(args); + return gitRunner(args, { env: {} }); + }; + + await expect( + prepareBoundedAgents(buildConfig(workDir, { runtime: 'gvisor' }), { + env: { GH_TOKEN: 't' }, + gitRunner: trackingGitRunner, + assertRuntimeAvailable, + }), + ).rejects.toThrow(/runsc is not registered/); + + expect(cloned).toEqual([]); + expect(fs.existsSync(resolveBoundedAgentPaths(workDir).root)).toBe(false); + }); + + it('aborts before staging when the configuration is invalid', async () => { + await expect( + prepareBoundedAgents({ ...buildConfig(workDir), enableApiProxy: false } as WrapperConfig, { + env: { GH_TOKEN: 't' }, + gitRunner, + assertRuntimeAvailable, + }), + ).rejects.toThrow(/Bounded-agent configuration is invalid/); + expect(assertRuntimeAvailable).not.toHaveBeenCalled(); + }); + + it('aborts when no staging credential is available', async () => { + await expect( + prepareBoundedAgents(buildConfig(workDir), { env: {}, gitRunner, assertRuntimeAvailable }), + ).rejects.toThrow(/GH_TOKEN or GITHUB_TOKEN/); + }); + + it('scrubs the staging credential and helper before returning', async () => { + await prepareBoundedAgents(buildConfig(workDir), { + env: { GH_TOKEN: 'ghs_super_secret' }, + gitRunner, + assertRuntimeAvailable, + }); + const paths = resolveBoundedAgentPaths(workDir); + + expect(fs.existsSync(path.join(paths.root, 'staging-token'))).toBe(false); + expect(fs.existsSync(path.join(paths.root, 'askpass.sh'))).toBe(false); + expect(fs.existsSync(path.join(paths.root, 'staging-home'))).toBe(false); + + const seedMap = fs.readFileSync(paths.seedMapPath, 'utf8'); + expect(seedMap).not.toContain('ghs_super_secret'); + const skill = fs.readFileSync(paths.skillPath, 'utf8'); + expect(skill).not.toContain('ghs_super_secret'); + }); + + it('gives staging git no GitHub Actions, OIDC, or inherited credential environment', async () => { + const observed: NodeJS.ProcessEnv[] = []; + const capturingGitRunner: GitRunner = async (args, options) => { + observed.push(options.env); + return gitRunner(args, options); + }; + await prepareBoundedAgents(buildConfig(workDir), { + env: { + GH_TOKEN: 'ghs_super_secret', + GITHUB_TOKEN: 'github-fallback', + ACTIONS_ID_TOKEN_REQUEST_URL: 'https://oidc.invalid', + ACTIONS_ID_TOKEN_REQUEST_TOKEN: 'oidc-secret', + GITHUB_ACTIONS: 'true', + GITHUB_WORKSPACE: '/sensitive/workspace', + }, + gitRunner: capturingGitRunner, + assertRuntimeAvailable, + }); + expect(observed.length).toBeGreaterThan(0); + for (const env of observed) { + expect(env).not.toHaveProperty('GH_TOKEN'); + expect(env).not.toHaveProperty('GITHUB_TOKEN'); + expect(env).not.toHaveProperty('ACTIONS_ID_TOKEN_REQUEST_URL'); + expect(env).not.toHaveProperty('ACTIONS_ID_TOKEN_REQUEST_TOKEN'); + expect(env).not.toHaveProperty('GITHUB_ACTIONS'); + expect(env).not.toHaveProperty('GITHUB_WORKSPACE'); + expect(Object.keys(env).sort()).toEqual([ + 'GIT_ASKPASS', + 'GIT_CONFIG_COUNT', + 'GIT_CONFIG_KEY_0', + 'GIT_CONFIG_NOSYSTEM', + 'GIT_CONFIG_VALUE_0', + 'GIT_TERMINAL_PROMPT', + 'HOME', + 'PATH', + 'XDG_CONFIG_HOME', + 'AWF_BOUNDED_QUERY_STAGING_TOKEN_FILE', + ].sort()); + } + }); + + it('writes a seed map with opaque seed ids and trusted sensitivity only', async () => { + await prepareBoundedAgents(buildConfig(workDir), { + env: { GH_TOKEN: 't' }, + gitRunner, + assertRuntimeAvailable, + }); + const paths = resolveBoundedAgentPaths(workDir); + const seedMap = JSON.parse(fs.readFileSync(paths.seedMapPath, 'utf8')); + + expect(seedMap.version).toBe(2); + expect(seedMap.runId).toMatch(/^[0-9a-f]{32}$/); + expect(seedMap.seeds).toHaveLength(1); + expect(seedMap.seeds[0].repo).toBe('octo/private'); + expect(seedMap.seeds[0].seedId).toMatch(/^[0-9a-f]{32}$/); + expect(seedMap.seeds[0].sensitivity).toBe('internal'); + // No host paths leak into broker input. + expect(JSON.stringify(seedMap)).not.toContain(workDir); + }); + + it('protects the private directories and leaves only the ingress agent-readable', async () => { + await prepareBoundedAgents(buildConfig(workDir), { + env: { GH_TOKEN: 't' }, + gitRunner, + assertRuntimeAvailable, + }); + const paths = resolveBoundedAgentPaths(workDir); + + expect(fs.statSync(paths.root).mode & 0o777).toBe(0o700); + expect(fs.statSync(paths.auditDir).mode & 0o777).toBe(0o700); + expect(fs.statSync(paths.seedMapPath).mode & 0o777).toBe(0o600); + expect(fs.statSync(paths.agentDir).mode & 0o777).toBe(0o755); + }); + + it('sanitizes exactly one immutable seed per configured repository', async () => { + await prepareBoundedAgents( + buildConfig(workDir, { + privateRepos: [ + { repo: 'octo/private', sensitivity: 'internal' }, + { repo: 'octo/other', sensitivity: 'confidential' }, + ], + }), + { env: { GH_TOKEN: 't' }, gitRunner, assertRuntimeAvailable }, + ); + const paths = resolveBoundedAgentPaths(workDir); + const seeds = fs.readdirSync(paths.seedsDir); + + expect(seeds).toHaveLength(2); + for (const seed of seeds) { + const gitConfig = fs.readFileSync(path.join(paths.seedsDir, seed, '.git', 'config'), 'utf8'); + expect(gitConfig).not.toContain('remote'); + expect(gitConfig).not.toContain('url'); + // Seeds are read-only. + expect(fs.statSync(path.join(paths.seedsDir, seed, 'README.md')).mode & 0o222).toBe(0); + // The fixed unprivileged enclave uid can traverse and read the direct bind mount. + expect(fs.statSync(path.join(paths.seedsDir, seed)).mode & 0o005).toBe(0o005); + expect(fs.statSync(path.join(paths.seedsDir, seed, 'README.md')).mode & 0o004).toBe(0o004); + } + }); + + it('refuses to reuse an existing private root', async () => { + const paths = resolveBoundedAgentPaths(workDir); + fs.mkdirSync(paths.root, { recursive: true, mode: 0o700 }); + + await expect( + prepareBoundedAgents(buildConfig(workDir), { env: { GH_TOKEN: 't' }, gitRunner, assertRuntimeAvailable }), + ).rejects.toThrow(/EEXIST/); + }); + + describe('runtime telemetry lifecycle (never `ready` before sbx ingress is proven)', () => { + function collectTelemetry(infoSpy: jest.SpyInstance): Array> { + return infoSpy.mock.calls + .map((call) => String(call[0])) + .filter((line) => line.startsWith('Bounded-agent runtime telemetry: ')) + .map((line) => JSON.parse(line.slice('Bounded-agent runtime telemetry: '.length))); + } + + it('reports `ready` immediately after preflight for a compose (docker) primary', async () => { + const infoSpy = jest.spyOn(logger, 'info').mockImplementation(() => undefined); + try { + await prepareBoundedAgents(buildConfig(workDir), { + env: { GH_TOKEN: 't' }, + gitRunner, + assertRuntimeAvailable, + }); + const events = collectTelemetry(infoSpy); + const terminal = events[events.length - 1]; + expect(terminal).toEqual(expect.objectContaining({ + primaryBackend: 'docker', + capabilityState: 'supported', + category: 'ready', + })); + } finally { + infoSpy.mockRestore(); + } + }); + + it('never reports `ready` for a primary-sbx run before ingress is proven', async () => { + const infoSpy = jest.spyOn(logger, 'info').mockImplementation(() => undefined); + try { + await prepareBoundedAgents( + { ...buildConfig(workDir), containerRuntime: 'sbx' } as WrapperConfig, + { + env: { GH_TOKEN: 't' }, + gitRunner, + assertRuntimeAvailable, + probeSbxUnixSocket: async () => true, + }, + ); + const events = collectTelemetry(infoSpy); + expect(events.some((event) => event.category === 'ready')).toBe(false); + const terminal = events[events.length - 1]; + expect(terminal).toEqual(expect.objectContaining({ + primaryBackend: 'sbx', + capabilityState: 'supported', + category: 'primary-sbx-ingress-pending', + })); + } finally { + infoSpy.mockRestore(); + } + }); + + it('reports ingress unavailable when primary-sbx transport selection fails', async () => { + const infoSpy = jest.spyOn(logger, 'info').mockImplementation(() => undefined); + try { + await expect(prepareBoundedAgents( + { ...buildConfig(workDir), containerRuntime: 'sbx' } as WrapperConfig, + { + env: { GH_TOKEN: 't' }, + gitRunner, + assertRuntimeAvailable, + probeSbxUnixSocket: async () => { + throw new Error('socket probe failed'); + }, + }, + )).rejects.toThrow('socket probe failed'); + + const events = collectTelemetry(infoSpy); + expect(events.some((event) => event.category === 'ready')).toBe(false); + expect(events[events.length - 1]).toEqual(expect.objectContaining({ + primaryBackend: 'sbx', + lifecycleClass: 'startup', + capabilityState: 'unavailable', + category: 'primary-sbx-ingress-unproven', + })); + } finally { + infoSpy.mockRestore(); + } + }); + + it('reportBoundedAgentSbxIngressResult reports `ready` only once ingress proof succeeds', () => { + const infoSpy = jest.spyOn(logger, 'info').mockImplementation(() => undefined); + try { + const config = { ...buildConfig(workDir), containerRuntime: 'sbx' } as WrapperConfig; + reportBoundedAgentSbxIngressResult(config, 'proven'); + const events = collectTelemetry(infoSpy); + expect(events).toHaveLength(1); + expect(events[0]).toEqual(expect.objectContaining({ + primaryBackend: 'sbx', + lifecycleClass: 'startup', + capabilityState: 'supported', + category: 'ready', + })); + } finally { + infoSpy.mockRestore(); + } + }); + + it('reportBoundedAgentSbxIngressResult reports a terminal unavailable event when ingress proof fails', () => { + const infoSpy = jest.spyOn(logger, 'info').mockImplementation(() => undefined); + try { + const config = { ...buildConfig(workDir), containerRuntime: 'sbx' } as WrapperConfig; + reportBoundedAgentSbxIngressResult(config, 'failed'); + const events = collectTelemetry(infoSpy); + expect(events).toHaveLength(1); + expect(events[0]).toEqual(expect.objectContaining({ + primaryBackend: 'sbx', + lifecycleClass: 'startup', + capabilityState: 'unavailable', + category: 'primary-sbx-ingress-unproven', + })); + expect(events.some((event) => event.category === 'ready')).toBe(false); + } finally { + infoSpy.mockRestore(); + } + }); + + it('reportBoundedAgentSbxIngressResult is a no-op for a non-sbx primary', () => { + const infoSpy = jest.spyOn(logger, 'info').mockImplementation(() => undefined); + try { + const config = { ...buildConfig(workDir), containerRuntime: 'gvisor' } as WrapperConfig; + reportBoundedAgentSbxIngressResult(config, 'proven'); + expect(collectTelemetry(infoSpy)).toHaveLength(0); + } finally { + infoSpy.mockRestore(); + } + }); + + it('reportBoundedAgentSbxIngressResult is a no-op when bounded agents are disabled', () => { + const infoSpy = jest.spyOn(logger, 'info').mockImplementation(() => undefined); + try { + const config = { + ...buildConfig(workDir, { enabled: false }), + containerRuntime: 'sbx', + } as WrapperConfig; + reportBoundedAgentSbxIngressResult(config, 'proven'); + expect(collectTelemetry(infoSpy)).toHaveLength(0); + } finally { + infoSpy.mockRestore(); + } + }); + }); + + it('rejects a symlink work directory before creating private state', async () => { + const target = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-bounded-agent-target-')); + fs.rmSync(workDir, { recursive: true, force: true }); + fs.symlinkSync(target, workDir); + try { + await expect( + prepareBoundedAgents(buildConfig(workDir), { + env: { GH_TOKEN: 't' }, + gitRunner, + assertRuntimeAvailable, + }), + ).rejects.toThrow(/symlink work directory/); + } finally { + fs.unlinkSync(workDir); + fs.rmSync(target, { recursive: true, force: true }); + workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-bounded-agent-cleanup-')); + } + }); + + it('fails closed if the staging credential disappears after validation', async () => { + const token = jest.spyOn(staging, 'resolveStagingToken').mockReturnValueOnce(undefined); + try { + await expect( + prepareBoundedAgents(buildConfig(workDir), { + env: { GH_TOKEN: 't' }, + gitRunner, + assertRuntimeAvailable, + }), + ).rejects.toThrow(/credential disappeared/); + } finally { + token.mockRestore(); + } + }); + + it('creates private sbx-http ingress capabilities only after transport preflight', async () => { + const probeSbxUnixSocket = jest.fn(async () => false); + await prepareBoundedAgents( + { ...buildConfig(workDir), containerRuntime: 'sbx' } as WrapperConfig, + { + env: { GH_TOKEN: 't' }, + gitRunner, + assertRuntimeAvailable, + assertPrimaryAvailable: jest.fn(async () => undefined), + probeSbxUnixSocket, + }, + ); + + const capabilityPath = resolveBoundedAgentPaths(workDir).capabilityPath; + const capabilities = JSON.parse(fs.readFileSync(capabilityPath, 'utf8')); + expect(capabilities).toEqual({ + version: 1, + query: expect.stringMatching(/^[0-9a-f]{64}$/), + probe: expect.stringMatching(/^[0-9a-f]{64}$/), + }); + expect(capabilities.query).not.toBe(capabilities.probe); + expect(probeSbxUnixSocket).toHaveBeenCalledWith('bounded-agent'); + }); +}); + +describe('teardownBoundedAgents', () => { + let workDir: string; + + beforeEach(() => { + mockExeca.mockReset(); + mockExeca.mockResolvedValue({ exitCode: 0, stdout: '' }); + assertRuntimeAvailable.mockClear(); + assertRuntimeAvailable.mockResolvedValue(undefined); + workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-bounded-agent-teardown-')); + }); + + afterEach(() => { + const paths = resolveBoundedAgentPaths(workDir); + releaseSeedPermissions(paths.seedsDir); + fs.rmSync(paths.root, { recursive: true, force: true }); + fs.rmSync(paths.ingressRoot, { recursive: true, force: true }); + fs.rmSync(workDir, { recursive: true, force: true }); + }); + + it('is a no-op when bounded agents are disabled', async () => { + await teardownBoundedAgents(buildConfig(workDir, { enabled: false })); + expect(mockExeca).not.toHaveBeenCalled(); + }); + + it('deterministically removes orphaned enclave containers by run label', async () => { + await prepareBoundedAgents(buildConfig(workDir), { + env: { GH_TOKEN: 't' }, + gitRunner, + assertRuntimeAvailable, + }); + const paths = resolveBoundedAgentPaths(workDir); + const runId = JSON.parse(fs.readFileSync(paths.seedMapPath, 'utf8')).runId as string; + + mockExeca.mockReset(); + mockExeca.mockResolvedValueOnce({ exitCode: 0, stdout: 'abc123\ndef456\n' }); + mockExeca.mockResolvedValueOnce({ exitCode: 0, stdout: '' }); + + await teardownBoundedAgents(buildConfig(workDir)); + + expect(mockExeca).toHaveBeenNthCalledWith( + 1, + 'docker', + ['ps', '-aq', '--filter', `label=${BOUNDED_AGENT_RUN_LABEL}=${runId}`], + expect.anything(), + ); + expect(mockExeca).toHaveBeenNthCalledWith( + 2, + 'docker', + ['rm', '-f', 'abc123', 'def456'], + expect.anything(), + ); + expect(fs.existsSync(paths.root)).toBe(false); + expect(fs.existsSync(paths.ingressRoot)).toBe(false); + }); + + it('uses a run label distinct from bounded queries', () => { + expect(BOUNDED_AGENT_RUN_LABEL).toBe('awf.bounded-agent.run'); + }); + + it('removes orphaned enclaves but preserves private state under --keep-containers', async () => { + await prepareBoundedAgents(buildConfig(workDir), { + env: { GH_TOKEN: 't' }, + gitRunner, + assertRuntimeAvailable, + }); + const paths = resolveBoundedAgentPaths(workDir); + + mockExeca.mockReset(); + mockExeca.mockResolvedValueOnce({ exitCode: 0, stdout: 'abc123\n' }); + mockExeca.mockResolvedValueOnce({ exitCode: 0, stdout: '' }); + + await teardownBoundedAgents({ ...buildConfig(workDir), keepContainers: true } as WrapperConfig); + + expect(mockExeca).toHaveBeenCalledTimes(2); + expect(fs.existsSync(paths.root)).toBe(true); + }); + + it('removes a stale ingress root when the private root is already gone', async () => { + const paths = resolveBoundedAgentPaths(workDir); + fs.mkdirSync(paths.runDir, { recursive: true }); + + await teardownBoundedAgents(buildConfig(workDir)); + expect(fs.existsSync(paths.ingressRoot)).toBe(false); + }); + + it('preserves a stale ingress root under --keep-containers', async () => { + const paths = resolveBoundedAgentPaths(workDir); + fs.mkdirSync(paths.runDir, { recursive: true }); + + await teardownBoundedAgents({ ...buildConfig(workDir), keepContainers: true } as WrapperConfig); + expect(fs.existsSync(paths.ingressRoot)).toBe(true); + }); + + it('handles missing run ids and seed-permission restoration failures', async () => { + const paths = resolveBoundedAgentPaths(workDir); + fs.mkdirSync(paths.root, { recursive: true }); + fs.mkdirSync(paths.ingressRoot, { recursive: true }); + fs.writeFileSync(paths.seedMapPath, '{}'); + const release = jest.spyOn(staging, 'releaseSeedPermissions').mockImplementationOnce(() => { + throw new Error('permission restore failed'); + }); + try { + await expect(teardownBoundedAgents(buildConfig(workDir))).resolves.toBeUndefined(); + expect(mockExeca).not.toHaveBeenCalled(); + } finally { + release.mockRestore(); + } + }); + + it('continues cleanup when orphan enumeration fails', async () => { + await prepareBoundedAgents(buildConfig(workDir), { + env: { GH_TOKEN: 't' }, + gitRunner, + assertRuntimeAvailable, + }); + mockExeca.mockRejectedValueOnce(new Error('docker unavailable')); + + await expect(teardownBoundedAgents(buildConfig(workDir))).resolves.toBeUndefined(); + }); +}); + +describe('boundedAgentManagerTestHelpers.readRunId', () => { + it('returns undefined for a missing or malformed seed map', () => { + const paths = resolveBoundedAgentPaths('/nonexistent-work-dir-for-tests'); + expect(boundedAgentManagerTestHelpers.readRunId(paths)).toBeUndefined(); + }); + + describe('boundedAgentManagerTestHelpers.removePrivateState', () => { + it('repairs rootless permissions and retries both private roots after EACCES', () => { + const config = buildConfig('/tmp/work'); + const paths = resolveBoundedAgentPaths('/tmp/work'); + const removeTree = jest.fn() + .mockImplementationOnce(() => { + const error = new Error('denied') as NodeJS.ErrnoException; + error.code = 'EACCES'; + throw error; + }) + .mockImplementation(() => undefined); + const repairPermissions = jest.fn(); + + boundedAgentManagerTestHelpers.removePrivateState(config, paths, { + removeTree, + repairPermissions, + }); + + expect(repairPermissions).toHaveBeenCalledWith( + [paths.root, paths.ingressRoot], + config.dockerHostPathPrefix, + config.imageRegistry, + config.imageTag, + config.agentImage, + ); + expect(removeTree).toHaveBeenCalledTimes(3); + }); + + it('does not repair permissions for non-EACCES cleanup failures', () => { + const config = buildConfig('/tmp/work'); + const paths = resolveBoundedAgentPaths('/tmp/work'); + const removeTree = jest.fn(() => { + throw new Error('unexpected cleanup failure'); + }); + const repairPermissions = jest.fn(); + + expect(() => boundedAgentManagerTestHelpers.removePrivateState(config, paths, { + removeTree, + repairPermissions, + })).not.toThrow(); + expect(repairPermissions).not.toHaveBeenCalled(); + }); + + it('contains a permission-repair retry failure', () => { + const config = buildConfig('/tmp/work'); + const paths = resolveBoundedAgentPaths('/tmp/work'); + const removeTree = jest.fn(() => { + const error = new Error('denied') as NodeJS.ErrnoException; + error.code = 'EACCES'; + throw error; + }); + + expect(() => boundedAgentManagerTestHelpers.removePrivateState(config, paths, { + removeTree, + repairPermissions: jest.fn(), + })).not.toThrow(); + expect(removeTree).toHaveBeenCalledTimes(2); + }); + }); + + describe('boundedAgentManagerTestHelpers.removeOrphanEnclaveContainers', () => { + it('returns when Docker enumeration fails or finds no containers', async () => { + mockExeca.mockReset(); + mockExeca + .mockResolvedValueOnce({ exitCode: 1, stdout: '' }) + .mockResolvedValueOnce({ exitCode: 0, stdout: '\n' }); + + await boundedAgentManagerTestHelpers.removeOrphanEnclaveContainers('a'.repeat(32)); + await boundedAgentManagerTestHelpers.removeOrphanEnclaveContainers('b'.repeat(32)); + expect(mockExeca).toHaveBeenCalledTimes(2); + }); + }); + + describe('boundedAgentManagerTestHelpers.prepareDirectories', () => { + it('hands private proxy logs to the safe host identity under sudo', () => { + const workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-bounded-agent-owner-')); + const paths = resolveBoundedAgentPaths(workDir); + const getuid = jest.spyOn(process, 'getuid').mockReturnValue(0); + const getgid = jest.spyOn(process, 'getgid').mockReturnValue(0); + const chown = jest.fn(); + const previousUid = process.env.SUDO_UID; + const previousGid = process.env.SUDO_GID; + process.env.SUDO_UID = '1234'; + process.env.SUDO_GID = '5678'; + + try { + boundedAgentManagerTestHelpers.prepareDirectories(paths, chown); + expect(chown).toHaveBeenCalledWith(paths.runDir, 1234, 5678); + expect(chown).toHaveBeenCalledWith(paths.apiProxyLogsDir, 1234, 5678); + } finally { + getuid.mockRestore(); + getgid.mockRestore(); + if (previousUid === undefined) delete process.env.SUDO_UID; + else process.env.SUDO_UID = previousUid; + if (previousGid === undefined) delete process.env.SUDO_GID; + else process.env.SUDO_GID = previousGid; + fs.rmSync(paths.root, { recursive: true, force: true }); + fs.rmSync(paths.ingressRoot, { recursive: true, force: true }); + fs.rmSync(workDir, { recursive: true, force: true }); + } + }); + }); +}); diff --git a/src/bounded-agent/manager.ts b/src/bounded-agent/manager.ts new file mode 100644 index 000000000..b62842e29 --- /dev/null +++ b/src/bounded-agent/manager.ts @@ -0,0 +1,471 @@ +import * as fs from 'fs'; +import * as crypto from 'crypto'; +import execa from 'execa'; +import { logger } from '../logger'; +import { getLocalDockerEnv } from '../host-env'; +import { getSafeHostUid, getSafeHostGid } from '../host-identity'; +import type { WrapperConfig } from '../types'; +import { + generateBoundedAgentRunId, + resolveBoundedAgentPaths, + type BoundedAgentPaths, +} from './paths'; +import { + assertEnclaveRuntimeAvailable, + assertPrimaryRuntimeAvailable, + validateBoundedAgentConfig, +} from './preflight'; +import { writeBoundedAgentSkill } from './skill'; +import { writeBoundedAgentWrapper } from './wrapper-artifact'; +import { releaseSeedPermissions, resolveStagingToken, stageBoundedAgentSeeds, type GitRunner } from './staging'; +import { + PRIVATE_REPOSITORY_SEED_MAP_VERSION, + serializePrivateRepositorySeedMap, + type PrivateRepositorySeedMap, +} from '../bounded-execution/repository-staging'; +import { assertBoundedAgentPrivateRootIsolated } from './mount-policy'; +import { fixArtifactPermissionsForRootless } from '../artifact-permissions'; +import { runtimeUsesComposeAgent } from '../container-runtime'; +import { probeSbxUnixSocketMount } from '../sbx-manager'; +import { + resolveBoundedAgentPrimaryBackend, + serializeBoundedAgentRuntimeTelemetry, +} from './runtime-matrix'; + +/** + * Bounded-agent lifecycle orchestration. + * + * `prepareBoundedAgents` runs entirely on the trusted AWF host **before** any + * configuration is generated or any container is started, so that: + * + * - the primary agent never starts when preflight or staging fails; + * - the staging credential is consumed and discarded before the broker, the + * agent, and any enclave exist; + * - compose generation can rely on the on-disk layout already being present. + * + * `teardownBoundedAgents` deterministically removes orphaned enclave + * containers (matched by this run's Docker label) and the separate + * broker-private host root. + */ + +/** Docker label applied to every enclave container, used for orphan cleanup. */ +export const BOUNDED_AGENT_RUN_LABEL = 'awf.bounded-agent.run'; + +/** Returns true when this run must stage seeds and start the bounded-agent broker. */ +export function isBoundedAgentsEnabled(config: WrapperConfig): boolean { + return config.boundedAgents?.enabled === true; +} + +/** Creates a directory with an exact mode, independent of the process umask. */ +function ensureModeDirectory(target: string, mode: number): void { + fs.mkdirSync(target, { recursive: true, mode }); + fs.chmodSync(target, mode); +} + +/** + * Creates the bounded-agent directory layout. + * + * The private root is created without `recursive` so a pre-existing path, + * including a symlink planted between preflight and creation, fails closed. + */ +function prepareDirectories( + paths: BoundedAgentPaths, + chown: typeof fs.chownSync = fs.chownSync, +): void { + fs.mkdirSync(paths.root, { mode: 0o700 }); + fs.mkdirSync(paths.ingressRoot, { mode: 0o700 }); + ensureModeDirectory(paths.seedsDir, 0o700); + ensureModeDirectory(paths.workDir, 0o700); + ensureModeDirectory(paths.controlDir, 0o700); + ensureModeDirectory(paths.auditDir, 0o700); + ensureModeDirectory(paths.apiProxyLogsDir, 0o700); + ensureModeDirectory(paths.runDir, 0o770); + ensureModeDirectory(paths.agentDir, 0o755); + + // Under sudo these directories start root-owned. Hand the socket and private + // proxy log directories to the non-root identity used by their containers. + if (process.getuid?.() === 0) { + const hostUid = parseInt(getSafeHostUid(), 10); + const hostGid = parseInt(getSafeHostGid(), 10); + chown(paths.runDir, hostUid, hostGid); + chown(paths.apiProxyLogsDir, hostUid, hostGid); + } +} + +interface RemovePrivateStateDeps { + removeTree?: (target: string) => void; + repairPermissions?: typeof fixArtifactPermissionsForRootless; +} + +function removePrivateState( + config: WrapperConfig, + paths: BoundedAgentPaths, + deps: RemovePrivateStateDeps = {}, +): void { + const removeTree = deps.removeTree ?? ((target: string) => { + fs.rmSync(target, { recursive: true, force: true }); + }); + const repairPermissions = deps.repairPermissions ?? fixArtifactPermissionsForRootless; + + try { + removeTree(paths.root); + removeTree(paths.ingressRoot); + } catch (error: unknown) { + if (error && typeof error === 'object' && 'code' in error && error.code === 'EACCES') { + logger.debug('Bounded agents: repairing rootless private-state permissions before cleanup'); + repairPermissions( + [paths.root, paths.ingressRoot], + config.dockerHostPathPrefix, + config.imageRegistry, + config.imageTag, + config.agentImage, + ); + try { + removeTree(paths.root); + removeTree(paths.ingressRoot); + } catch (retryError) { + logger.warn('Bounded agents: failed to remove private state after permission repair', retryError); + } + return; + } + logger.warn('Bounded agents: failed to remove private state during cleanup', error); + } +} + +/** Writes the broker's repo → opaque seed map. */ +function writeSeedMap(paths: BoundedAgentPaths, seedMap: PrivateRepositorySeedMap): void { + const content = serializePrivateRepositorySeedMap(seedMap); + // O_EXCL | O_NOFOLLOW: atomically create; fail if a symlink or existing file + // is already at this path (insecure-temp-file guard). + const fd = fs.openSync( + paths.seedMapPath, + fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_NOFOLLOW, + 0o600, + ); + try { + fs.writeSync(fd, content); + fs.fchmodSync(fd, 0o600); + } finally { + fs.closeSync(fd); + } +} + +export interface PrepareBoundedAgentsDeps { + /** Override the git runner (tests). */ + gitRunner?: GitRunner; + /** Override the host environment the staging credential is read from. */ + env?: NodeJS.ProcessEnv; + /** Override the sbx Unix-socket passthrough probe (tests). */ + probeSbxUnixSocket?: typeof probeSbxUnixSocketMount; + /** Override enclave-runtime capability preflight (tests). */ + assertRuntimeAvailable?: typeof assertEnclaveRuntimeAvailable; + /** Override primary-runtime capability preflight (tests). */ + assertPrimaryAvailable?: typeof assertPrimaryRuntimeAvailable; +} + +interface SbxIngressCapabilities { + version: 1; + query: string; + probe: string; +} + +function writeSbxIngressCapabilities(paths: BoundedAgentPaths): void { + const capabilities: SbxIngressCapabilities = { + version: 1, + query: crypto.randomBytes(32).toString('hex'), + probe: crypto.randomBytes(32).toString('hex'), + }; + const fd = fs.openSync( + paths.capabilityPath, + fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_NOFOLLOW, + 0o600, + ); + try { + fs.writeSync(fd, JSON.stringify(capabilities)); + fs.fchmodSync(fd, 0o600); + } finally { + fs.closeSync(fd); + } +} + +/** + * Validates configuration, proves both the primary-agent runtime and the + * enclave runtime are independently available, stages one immutable seed per + * configured repository, and writes the broker/agent artifacts. + * + * Ordering is a security property: preflight runs *before* staging, so a run + * that could never launch an enclave never clones a private repository, and + * the staging credential is discarded before any container exists. Each + * preflight axis is proven independently and neither ever falls back to a + * weaker backend on failure; every terminal state is reported as narrow, + * content-free runtime telemetry (backend names and capability state only — + * never secrets, paths, prompts, repo names, or model payloads). + * + * Throws on any failure — the caller must abort the run. + */ +export async function prepareBoundedAgents( + config: WrapperConfig, + deps: PrepareBoundedAgentsDeps = {}, +): Promise { + const boundedAgents = config.boundedAgents; + if (!boundedAgents?.enabled) return; + + const env = deps.env ?? process.env; + const errors = validateBoundedAgentConfig(config, env); + if (errors.length > 0) { + throw new Error(`Bounded-agent configuration is invalid:\n - ${errors.join('\n - ')}`); + } + + const primaryBackend = resolveBoundedAgentPrimaryBackend(config.containerRuntime); + const telemetryBase = { + primaryBackend, + boundedAgentBackend: boundedAgents.runtime, + lifecycleClass: 'preflight' as const, + }; + const assertRuntimeAvailable = deps.assertRuntimeAvailable ?? assertEnclaveRuntimeAvailable; + const assertPrimaryAvailable = deps.assertPrimaryAvailable ?? assertPrimaryRuntimeAvailable; + try { + await assertPrimaryAvailable(config.containerRuntime); + } catch (error) { + logger.info( + `Bounded-agent runtime telemetry: ${serializeBoundedAgentRuntimeTelemetry({ + ...telemetryBase, + capabilityState: 'unavailable', + category: 'primary-runtime-unavailable', + })}`, + ); + throw error; + } + try { + await assertRuntimeAvailable(boundedAgents); + } catch (error) { + logger.info( + `Bounded-agent runtime telemetry: ${serializeBoundedAgentRuntimeTelemetry({ + ...telemetryBase, + capabilityState: boundedAgents.runtime === 'sbx' ? 'blocked' : 'unavailable', + category: boundedAgents.runtime === 'sbx' ? 'enclave-security-block' : 'enclave-runtime-unavailable', + })}`, + ); + throw error; + } + // A primary-sbx run is never reported `ready` here: preflight only proves the + // sbx CLI and enclave capability exist, not that the selected ingress + // transport (unix-in-sbx or sbx-http) is actually reachable from inside the + // sandbox. That executable proof happens later in `main-action`, after the + // sandbox is created, via `assertSbxBoundedAgentIngress`. Reporting `ready` + // here would be a false promotion — see + // `reportBoundedAgentSbxIngressResult` for the deferred terminal event. + // Compose primaries (docker/gvisor) have no equivalent later proof step — + // Compose either mounts the broker socket successfully or fails outright — + // so `ready` is accurate immediately after preflight for those backends. + logger.info( + `Bounded-agent runtime telemetry: ${serializeBoundedAgentRuntimeTelemetry({ + ...telemetryBase, + capabilityState: 'supported', + category: primaryBackend === 'sbx' ? 'primary-sbx-ingress-pending' : 'ready', + })}`, + ); + + if (runtimeUsesComposeAgent(config.containerRuntime)) { + config.boundedAgentIngressTransport = 'unix'; + } else { + const probe = deps.probeSbxUnixSocket ?? probeSbxUnixSocketMount; + try { + config.boundedAgentIngressTransport = (await probe('bounded-agent')) ? 'unix' : 'sbx-http'; + } catch (error) { + reportBoundedAgentSbxIngressResult(config, 'failed'); + throw error; + } + } + + const paths = resolveBoundedAgentPaths(config.workDir); + assertBoundedAgentPrivateRootIsolated(config, paths, env); + + const token = resolveStagingToken(env); + if (!token) { + // Already covered by validateBoundedAgentConfig; re-checked so the token is + // never `undefined!`-asserted into the staging call. + throw new Error('Bounded-agent staging credential disappeared between validation and staging'); + } + + // Guard against symlink injection before writing any credential-bearing state. + try { + const lstat = fs.lstatSync(config.workDir); + if (lstat.isSymbolicLink()) { + throw new Error(`Refusing to stage into a symlink work directory: ${config.workDir}`); + } + } catch (error: unknown) { + if (error instanceof Error && (error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw error; + } + } + + prepareDirectories(paths); + if (config.boundedAgentIngressTransport === 'sbx-http') { + writeSbxIngressCapabilities(paths); + } + + const runId = generateBoundedAgentRunId(); + const staging = await stageBoundedAgentSeeds({ + repos: boundedAgents.privateRepos, + paths, + runId, + token, + gitRunner: deps.gitRunner, + }); + + writeSeedMap(paths, { + version: PRIVATE_REPOSITORY_SEED_MAP_VERSION, + runId: staging.runId, + seeds: staging.seeds.map((seed) => ({ + repo: seed.repoKey, + seedId: seed.seedId, + sensitivity: seed.sensitivity, + })), + }); + + writeBoundedAgentSkill(paths, { + repos: boundedAgents.privateRepos, + timeoutSeconds: boundedAgents.timeout, + maxInvocations: boundedAgents.maxInvocations, + maxTaskBytes: boundedAgents.maxTaskBytes, + profile: boundedAgents.profile, + maxModelRequests: boundedAgents.maxModelRequests, + }); + writeBoundedAgentWrapper(paths); + + logger.info( + `Bounded agents: staged ${staging.seeds.length} immutable seed(s); staging credential discarded.`, + ); +} + +/** + * Emits the terminal bounded-agent runtime telemetry for a primary-sbx run, + * once `assertSbxBoundedAgentIngress` has actually been attempted in + * `main-action` after the sandbox exists. + * + * `prepareBoundedAgents` deliberately never reports `ready` for a primary-sbx + * run by itself (see the `primary-sbx-ingress-pending` telemetry emitted + * there): preflight only proves the sbx CLI and enclave capability are + * present, not that the selected ingress transport is reachable from inside + * the sandbox. This function is the only place that reports the outcome of + * that later, executable proof — `ready`/`supported` only on success, a + * distinct terminal `unavailable` category on failure. It is a no-op when + * bounded agents are disabled or the primary backend is not sbx, so callers + * may invoke it unconditionally around the ingress-proof call site. + */ +export function reportBoundedAgentSbxIngressResult( + config: WrapperConfig, + outcome: 'proven' | 'failed', +): void { + const boundedAgents = config.boundedAgents; + if (!boundedAgents?.enabled) return; + const primaryBackend = resolveBoundedAgentPrimaryBackend(config.containerRuntime); + if (primaryBackend !== 'sbx') return; + + const telemetryBase = { + primaryBackend, + boundedAgentBackend: boundedAgents.runtime, + lifecycleClass: 'startup' as const, + }; + logger.info( + `Bounded-agent runtime telemetry: ${serializeBoundedAgentRuntimeTelemetry( + outcome === 'proven' + ? { ...telemetryBase, capabilityState: 'supported', category: 'ready' } + : { + ...telemetryBase, + capabilityState: 'unavailable', + category: 'primary-sbx-ingress-unproven', + }, + )}`, + ); +} + +/** Reads back the run id recorded during staging, if it is still available. */ +function readRunId(paths: BoundedAgentPaths): string | undefined { + try { + const parsed = JSON.parse(fs.readFileSync(paths.seedMapPath, 'utf8')) as PrivateRepositorySeedMap; + return typeof parsed.runId === 'string' && parsed.runId.length > 0 ? parsed.runId : undefined; + } catch { + return undefined; + } +} + +/** Force-removes any enclave container still labelled with this run. */ +async function removeOrphanEnclaveContainers(runId: string): Promise { + const filter = `label=${BOUNDED_AGENT_RUN_LABEL}=${runId}`; + const listed = await execa('docker', ['ps', '-aq', '--filter', filter], { + env: getLocalDockerEnv(), + reject: false, + timeout: 30_000, + }); + if (listed.exitCode !== 0) return; + + const ids = listed.stdout.split('\n').map((id) => id.trim()).filter(Boolean); + if (ids.length === 0) return; + + logger.debug(`Bounded agents: removing ${ids.length} orphaned enclave container(s)`); + await execa('docker', ['rm', '-f', ...ids], { + env: getLocalDockerEnv(), + reject: false, + timeout: 60_000, + }); +} + +/** + * Tears down bounded-agent state. + * + * Orphaned enclave containers are always removed — including under + * `--keep-containers` — because they are ephemeral, hold a private copy of + * repository contents, and are never useful for debugging. + * + * Restoring seed permissions is skipped under `--keep-containers`. When it does + * run, it must run before AWF's generic work-directory cleanup: seeds are + * deliberately read-only, and `rm -rf` cannot unlink entries inside a directory + * whose write bit was stripped. + */ +export async function teardownBoundedAgents(config: WrapperConfig): Promise { + if (!isBoundedAgentsEnabled(config)) return; + + const paths = resolveBoundedAgentPaths(config.workDir); + if (!fs.existsSync(paths.root)) { + if (!config.keepContainers) { + fs.rmSync(paths.ingressRoot, { recursive: true, force: true }); + } + return; + } + + const runId = readRunId(paths); + if (runId) { + try { + await removeOrphanEnclaveContainers(runId); + } catch (error) { + logger.warn('Bounded agents: failed to remove orphaned enclave containers', error); + } + } + + if (config.keepContainers) { + logger.info(`Bounded-agent private state preserved at: ${paths.root}`); + logger.info(`Bounded-agent ingress preserved at: ${paths.ingressRoot}`); + return; + } + + try { + releaseSeedPermissions(paths.seedsDir); + } catch (error) { + logger.warn('Bounded agents: failed to restore seed permissions before cleanup', error); + } + + removePrivateState(config, paths); +} + +/** @internal Exported for focused unit tests. */ +// ts-prune-ignore-next +export const boundedAgentManagerTestHelpers = { + prepareDirectories, + writeSeedMap, + readRunId, + removeOrphanEnclaveContainers, + removePrivateState, + writeSbxIngressCapabilities, +}; diff --git a/src/bounded-agent/mount-policy.ts b/src/bounded-agent/mount-policy.ts new file mode 100644 index 000000000..937e66cd7 --- /dev/null +++ b/src/bounded-agent/mount-policy.ts @@ -0,0 +1,21 @@ +import type { WrapperConfig } from '../types'; +import { assertPrivateRootIsolated } from '../bounded-query/mount-policy'; +import type { BoundedAgentPaths } from './paths'; + +/** + * Fails closed when the bounded-agent broker-private root aliases, contains, + * or is contained by any path visible to a primary agent in any supported + * sandbox backend. + * + * The agent-visible path union is backend-independent and identical for both + * bounded subsystems, so this delegates to the audited shared implementation + * rather than restating it. Only the roots being checked differ. + */ +export function assertBoundedAgentPrivateRootIsolated( + config: WrapperConfig, + paths: Pick, + env: NodeJS.ProcessEnv = process.env, + cwd = process.cwd(), +): void { + assertPrivateRootIsolated(config, paths, env, cwd, 'bounded-agent'); +} diff --git a/src/bounded-agent/network.ts b/src/bounded-agent/network.ts new file mode 100644 index 000000000..f15d95ebf --- /dev/null +++ b/src/bounded-agent/network.ts @@ -0,0 +1,42 @@ +/** + * Dedicated bounded-agent enclave network. + * + * The enclave is deliberately *not* a member of `awf-net` or `awf-ext`: it has + * no Squid route, no general proxy, no DNS route to the internet, and no path + * to the primary agent, the broker, the safe-outputs collector, the MCP + * gateway, or the CLI proxy. Its only reachable peer is the AWF API proxy, + * which is dedicated to bounded-agent traffic, joins a separate egress bridge, + * and remains the only component holding real provider credentials. Its logs, + * metrics, and quota state are never shared with the primary agent. + * + * The network is created by Compose with an explicit `name:` so the broker — + * which launches enclaves with a fixed `docker run --network ` argument + * vector — never has to derive a Compose project prefix at runtime. + */ + +/** Compose key and concrete Docker network name for the enclave network. */ +export const BOUNDED_AGENT_NETWORK = 'awf-bounded-agent'; + +/** Egress bridge joined only by the dedicated bounded-agent API proxy. */ +export const BOUNDED_AGENT_EGRESS_NETWORK = 'awf-bounded-agent-egress'; + +/** + * Fixed subnet for the enclave network. + * + * Deliberately disjoint from the `awf-net` subnet (172.30.0.0/24) and from the + * bounded-query sbx ingress bridge so the two topologies can never alias. + */ +export const BOUNDED_AGENT_SUBNET = '172.31.0.0/24'; + +/** Fixed API proxy address on the enclave network. */ +export const BOUNDED_AGENT_API_PROXY_IP = '172.31.0.30'; + +/** + * Fixed DNS alias for the API proxy on the enclave network. + * + * The enclave addresses the proxy by IP (Docker's embedded resolver is not + * guaranteed to be reachable from every runtime), but the alias is published + * so operators can reason about the topology and so a future runtime that does + * have DNS keeps working without a protocol change. + */ +export const BOUNDED_AGENT_API_PROXY_ALIAS = 'awf-bounded-agent-api-proxy'; diff --git a/src/bounded-agent/paths.ts b/src/bounded-agent/paths.ts new file mode 100644 index 000000000..34fc0afaa --- /dev/null +++ b/src/bounded-agent/paths.ts @@ -0,0 +1,193 @@ +import * as crypto from 'crypto'; +import * as path from 'path'; + +/** + * Filesystem layout and fixed container paths for the bounded-agent subsystem. + * + * The layout mirrors bounded queries deliberately: broker-private state and + * the only agent-visible artifacts live in disjoint, run-specific host roots + * outside `/tmp`, and only the ingress root is mounted into the primary agent. + * The roots are *separate* from the bounded-query roots so the two subsystems + * never share seeds, workspaces, audit state, or a ledger. + * + * Layout (host side): + * + * ```text + * /var/tmp/awf-bounded-agent-private--/ + * seeds// immutable, read-only repository seed (one per repo) + * work/ broker-owned per-invocation state (task, schema, result) + * control/ broker readiness and other private control state + * audit/ protected broker diagnostics (never agent-visible) + * seed-map.json normalized repo -> opaque seed id map (broker input) + * + * /var/tmp/awf-bounded-agent-ingress--/ + * run/ broker Unix socket, shared read-write with the agent + * skill/ generated SKILL.md and wrapper, shared read-only + * ``` + */ +export interface BoundedAgentPaths { + /** Dedicated broker-private host root. Never mounted into the primary agent. */ + root: string; + /** Immutable per-repository seeds. Mounted read-only into the broker. */ + seedsDir: string; + /** Broker-owned scratch space for per-invocation enclave state. */ + workDir: string; + /** Broker-private readiness and control state. */ + controlDir: string; + /** Parent of the only bounded-agent artifacts visible to the primary agent. */ + ingressRoot: string; + /** Directory holding the broker's Unix socket, shared with the agent. */ + runDir: string; + /** Directory holding agent-visible artifacts (the generated SKILL.md). */ + agentDir: string; + /** Protected broker diagnostics. Never mounted into the agent or an enclave. */ + auditDir: string; + /** Dedicated API-proxy telemetry. Never mounted into the primary agent. */ + apiProxyLogsDir: string; + /** Repo → seed map consumed by the broker. */ + seedMapPath: string; + /** Host path of the broker's Unix socket. */ + socketPath: string; + /** Host path of the generated skill document. */ + skillPath: string; + /** Host path of the agent-facing bounded-agent executable. */ + wrapperPath: string; + /** Broker-private path containing ephemeral sbx ingress capabilities. */ + capabilityPath: string; +} + +/** Broker-private state is deliberately outside the agent's broad `/tmp` mount. */ +export const BOUNDED_AGENT_PRIVATE_BASE_DIR = '/var/tmp'; + +/** Name of the broker's Unix domain socket inside {@link BoundedAgentPaths.runDir}. */ +export const BOUNDED_AGENT_SOCKET_FILENAME = 'broker.sock'; + +/** Name of the generated skill document inside {@link BoundedAgentPaths.agentDir}. */ +export const BOUNDED_AGENT_SKILL_FILENAME = 'SKILL.md'; + +/** Name of the generated agent-facing executable. */ +export const BOUNDED_AGENT_WRAPPER_FILENAME = 'bounded-agent'; + +/** Name of the broker-private sbx ingress capability file. */ +export const BOUNDED_AGENT_CAPABILITY_FILENAME = 'sbx-ingress.json'; + +// ── Fixed container paths ──────────────────────────────────────────────────── +// +// These are part of the agent-visible contract (the wrapper and the generated +// skill reference them verbatim) and of the broker contract, so they are +// centralized here rather than duplicated across shell/JS/TS. + +/** Directory the broker socket is mounted at inside the agent container. */ +export const AGENT_SOCKET_DIR = '/run/awf-bounded-agent'; + +/** Full socket path as seen from inside the agent container. */ +export const AGENT_SOCKET_PATH = `${AGENT_SOCKET_DIR}/${BOUNDED_AGENT_SOCKET_FILENAME}`; + +/** Directory the generated skill is mounted at inside the agent container. */ +export const AGENT_SKILL_DIR = '/run/awf-bounded-agent-skill'; + +/** Full skill path as seen from inside the agent container. */ +export const AGENT_SKILL_PATH = `${AGENT_SKILL_DIR}/${BOUNDED_AGENT_SKILL_FILENAME}`; + +/** Seeds mount point inside the broker container (read-only). */ +export const BROKER_SEEDS_DIR = '/srv/awf/seeds'; + +/** Per-invocation scratch mount point inside the broker container. */ +export const BROKER_WORK_DIR = '/srv/awf/work'; + +/** Seed-map mount point inside the broker container (read-only). */ +export const BROKER_SEED_MAP_PATH = '/srv/awf/seed-map.json'; + +/** Socket directory inside the broker container. */ +export const BROKER_SOCKET_DIR = '/run/awf-bounded-agent'; + +/** Protected diagnostics directory inside the broker container. */ +export const BROKER_AUDIT_DIR = '/var/log/awf-bounded-agent'; + +/** Broker-private control directory inside the broker container. */ +export const BROKER_CONTROL_DIR = '/run/awf-bounded-agent-control'; + +/** Docker socket mount point inside the broker container. */ +export const BROKER_DOCKER_SOCKET_PATH = '/var/run/docker.sock'; + +/** Writable working directory mounted into each enclave container. */ +export const ENCLAVE_MOUNT_DIR = '/agent'; + +/** Fixed read-only path the immutable repository seed is mounted at. */ +export const ENCLAVE_SEED_PATH = '/awf/seed'; + +/** Fixed read-only path the caller's bounded task text is mounted at. */ +export const ENCLAVE_TASK_PATH = '/awf/task.txt'; + +/** Fixed read-only path the caller's finite response schema is mounted at. */ +export const ENCLAVE_SCHEMA_PATH = '/awf/schema.json'; + +/** Derives the private root identity without revealing the work-directory path. */ +function deriveRootIdentity(awfWorkDir: string): string { + const uid = process.getuid?.() ?? 0; + const digest = crypto + .createHash('sha256') + .update(path.resolve(awfWorkDir), 'utf8') + .digest('hex') + .slice(0, 20); + return `${uid}-${digest}`; +} + +/** Derives every bounded-agent path from the AWF work directory. */ +export function resolveBoundedAgentPaths( + awfWorkDir: string, + privateBaseDir = BOUNDED_AGENT_PRIVATE_BASE_DIR, +): BoundedAgentPaths { + const rootIdentity = deriveRootIdentity(awfWorkDir); + const root = path.join(privateBaseDir, `awf-bounded-agent-private-${rootIdentity}`); + const ingressRoot = path.join(privateBaseDir, `awf-bounded-agent-ingress-${rootIdentity}`); + const runDir = path.join(ingressRoot, 'run'); + const agentDir = path.join(ingressRoot, 'skill'); + return { + root, + seedsDir: path.join(root, 'seeds'), + workDir: path.join(root, 'work'), + controlDir: path.join(root, 'control'), + ingressRoot, + runDir, + agentDir, + auditDir: path.join(root, 'audit'), + apiProxyLogsDir: path.join(root, 'api-proxy-logs'), + seedMapPath: path.join(root, 'seed-map.json'), + socketPath: path.join(runDir, BOUNDED_AGENT_SOCKET_FILENAME), + skillPath: path.join(agentDir, BOUNDED_AGENT_SKILL_FILENAME), + wrapperPath: path.join(agentDir, BOUNDED_AGENT_WRAPPER_FILENAME), + capabilityPath: path.join(root, 'control', BOUNDED_AGENT_CAPABILITY_FILENAME), + }; +} + +/** + * Normalizes an `owner/repo` slug for allowlist lookups. + * + * GitHub treats owner and repository names case-insensitively, so the lookup + * key is lowercased. The *original* spelling is retained separately by the + * staging phase for clone-URL construction. + */ +export function normalizeRepoKey(repo: string): string { + return repo.trim().toLowerCase(); +} + +/** Generates the random, run-unique identifier used to derive opaque seed ids. */ +export function generateBoundedAgentRunId(): string { + return crypto.randomBytes(16).toString('hex'); +} + +/** + * Derives the opaque on-disk seed directory name for a repository. + * + * The identifier is a keyed digest of the run id and the normalized repo, so + * it is stable within a run, unpredictable across runs, and reveals nothing + * about the repository name to anything that can observe only the path. + */ +export function deriveSeedId(runId: string, repo: string): string { + return crypto + .createHmac('sha256', Buffer.from(runId, 'utf8')) + .update(normalizeRepoKey(repo), 'utf8') + .digest('hex') + .slice(0, 32); +} diff --git a/src/bounded-agent/preflight.test.ts b/src/bounded-agent/preflight.test.ts new file mode 100644 index 000000000..1928e33a0 --- /dev/null +++ b/src/bounded-agent/preflight.test.ts @@ -0,0 +1,409 @@ +import { + assertEnclaveRuntimeAvailable, + assertPrimaryRuntimeAvailable, + resolveApiProxyRoute, + validateBoundedAgentConfig, +} from './preflight'; +import { BOUNDED_AGENT_DEFAULTS, type BoundedAgentsConfig } from '../types/bounded-agent-options'; +import { + BOUNDED_AGENT_PROFILES, + BOUNDED_AGENT_SENSITIVITIES, + BOUNDED_AGENT_SENSITIVITY_RUN_BITS, + type WrapperConfig, +} from '../types'; +import * as boundedQueryPreflight from '../bounded-query/preflight'; +import execa from 'execa'; + +jest.mock('execa', () => ({ __esModule: true, default: jest.fn() })); +const mockExeca = execa as unknown as jest.Mock; + +/** + * Fail-closed preflight coverage. + * + * Everything here must abort the run *before* staging clones a private + * repository and before any container exists, and nothing may ever downgrade + * to a weaker runtime. + */ + +const boundedAgents = (overrides: Partial = {}): BoundedAgentsConfig => ({ + ...BOUNDED_AGENT_DEFAULTS, + enabled: true, + model: 'gpt-4o-mini', + privateRepos: [{ repo: 'octo/alpha', sensitivity: 'internal' }], + ...overrides, +}); + +const config = (overrides: Partial = {}): WrapperConfig => ({ + enableApiProxy: true, + openaiApiKey: 'sk-real', + boundedAgents: boundedAgents(), + ...overrides, +} as WrapperConfig); + +const env = { GH_TOKEN: 'ghs_token' } as NodeJS.ProcessEnv; + +describe('validateBoundedAgentConfig', () => { + it('exports the bounded-agent defaults and finite sensitivity policy', () => { + expect(BOUNDED_AGENT_PROFILES).toEqual(['openai', 'anthropic']); + expect(BOUNDED_AGENT_SENSITIVITIES).toEqual(['public', 'internal', 'confidential', 'sealed']); + expect(BOUNDED_AGENT_SENSITIVITY_RUN_BITS.confidential).toBeGreaterThan(0); + }); + + it('accepts a complete, minimal configuration', () => { + expect(validateBoundedAgentConfig(config(), env)).toEqual([]); + }); + + it('uses the host environment by default', () => { + const previous = process.env.GH_TOKEN; + process.env.GH_TOKEN = 'ghs_test'; + try { + expect(validateBoundedAgentConfig(config())).toEqual([]); + } finally { + if (previous === undefined) delete process.env.GH_TOKEN; + else process.env.GH_TOKEN = previous; + } + }); + + it('is a no-op when bounded agents are not enabled', () => { + expect(validateBoundedAgentConfig(config({ boundedAgents: undefined }), {})).toEqual([]); + expect( + validateBoundedAgentConfig(config({ boundedAgents: boundedAgents({ enabled: false }) }), {}), + ).toEqual([]); + }); + + it('requires the API proxy', () => { + const errors = validateBoundedAgentConfig(config({ enableApiProxy: false }), env); + expect(errors.join('\n')).toMatch(/require the AWF API proxy/); + }); + + it('requires a configured API target for the selected profile', () => { + const openaiMissing = validateBoundedAgentConfig(config({ openaiApiKey: undefined }), env); + expect(openaiMissing.join('\n')).toMatch(/supported configured API target for profile "openai"/); + + const anthropic = config({ + boundedAgents: boundedAgents({ profile: 'anthropic', model: 'claude-sonnet-4' }), + openaiApiKey: undefined, + anthropicApiKey: undefined, + }); + expect(validateBoundedAgentConfig(anthropic, env).join('\n')).toMatch( + /supported configured API target for profile "anthropic"/, + ); + + const anthropicOk = config({ + boundedAgents: boundedAgents({ profile: 'anthropic', model: 'claude-sonnet-4' }), + openaiApiKey: undefined, + anthropicApiKey: 'sk-ant-real', + }); + expect(validateBoundedAgentConfig(anthropicOk, env)).toEqual([]); + }); + + it('requires a model', () => { + const errors = validateBoundedAgentConfig(config({ boundedAgents: boundedAgents({ model: '' }) }), env); + expect(errors.join('\n')).toMatch(/boundedAgents\.model is required/); + }); + + it('requires a staging credential', () => { + const errors = validateBoundedAgentConfig(config(), {}); + expect(errors.join('\n')).toMatch(/GH_TOKEN or GITHUB_TOKEN/); + }); + + it('requires a non-empty, unique, bare owner/repo allowlist', () => { + expect( + validateBoundedAgentConfig(config({ boundedAgents: boundedAgents({ privateRepos: [] }) }), env) + .join('\n'), + ).toMatch(/privateRepos is empty/); + + expect( + validateBoundedAgentConfig( + config({ + boundedAgents: boundedAgents({ + privateRepos: [ + { repo: 'octo/alpha', sensitivity: 'internal' }, + { repo: 'Octo/Alpha', sensitivity: 'public' }, + ], + }), + }), + env, + ).join('\n'), + ).toMatch(/duplicate entry/); + + expect( + validateBoundedAgentConfig( + config({ + boundedAgents: boundedAgents({ + privateRepos: [{ repo: 'https://github.com/octo/alpha', sensitivity: 'internal' }], + }), + }), + env, + ).join('\n'), + ).toMatch(/bare owner\/repo slug/); + }); + + it('accepts sbx as a schema-level enclave runtime (capability-gated, not config-rejected)', () => { + // sbx is fully schema-accepted at the configuration level: whether it is + // actually usable is decided later by assertEnclaveRuntimeAvailable's + // capability proof, never by blanket config rejection. + expect(validateBoundedAgentConfig(config({ boundedAgents: boundedAgents({ runtime: 'sbx' }) }), env)) + .toEqual([]); + }); + + it('accepts every implemented and capability-gated backend', () => { + for (const runtime of ['docker', 'gvisor', 'sbx'] as const) { + expect(validateBoundedAgentConfig(config({ boundedAgents: boundedAgents({ runtime }) }), env)) + .toEqual([]); + } + }); + + it('rejects an unknown enclave runtime name with no downgrade', () => { + const errors = validateBoundedAgentConfig( + config({ boundedAgents: boundedAgents({ runtime: 'wasm' as unknown as BoundedAgentsConfig['runtime'] }) }), + env, + ); + expect(errors.join('\n')).toMatch(/"wasm" is not supported/); + expect(errors.join('\n')).toMatch(/never downgrade/); + }); + + it('no longer rejects a primary sbx microVM at the config-validation level', () => { + // The primary-agent runtime axis is proven independently by the + // bounded-agent-specific assertPrimaryRuntimeAvailable, not blanket-rejected + // here: a primary sbx microVM is supported once its bounded-agent ingress + // is proven (see ./ingress.ts). + expect(validateBoundedAgentConfig(config({ containerRuntime: 'sbx' }), env)).toEqual([]); + }); + + it('rejects exposing the enclave Docker daemon to the primary agent', () => { + const errors = validateBoundedAgentConfig(config({ enableDind: true }), env); + expect(errors.join('\n')).toMatch(/cannot be combined with enableDind/); + expect(errors.join('\n')).toMatch(/bypass the finite-disclosure ledger/); + }); + + it('rejects a non-Unix Docker host', () => { + const errors = validateBoundedAgentConfig(config({ awfDockerHost: 'tcp://10.0.0.1:2375' }), env); + expect(errors.join('\n')).toMatch(/Unix-socket Docker host/); + }); + + it('bounds every conservative resource and budget field', () => { + const cases: Array<[Partial, RegExp]> = [ + [{ timeout: 0 }, /timeout must be a positive integer/], + [{ timeout: 10_000 }, /timeout must be at most/], + [{ maxInvocations: 0 }, /maxInvocations must be a positive integer/], + [{ maxModelRequests: 0 }, /maxModelRequests must be a positive integer/], + [{ maxModelTokens: 0 }, /maxModelTokens must be a positive integer/], + [{ pidsLimit: 0 }, /pidsLimit must be a positive integer/], + [{ maxOutputBytes: 0 }, /maxOutputBytes must be between/], + [{ maxOutputBytes: 1_000_000 }, /maxOutputBytes must be between/], + [{ maxTaskBytes: 0 }, /maxTaskBytes must be between/], + [{ maxTaskBytes: 1_000_000 }, /maxTaskBytes must be between/], + [{ memoryLimit: 'lots' }, /is not a Docker memory limit/], + [{ tmpfsLimit: '64' }, /is not a Docker size limit/], + [{ cpuLimit: 'all' }, /positive Docker --cpus value/], + [{ cpuLimit: '0' }, /positive Docker --cpus value/], + ]; + for (const [patch, matcher] of cases) { + const errors = validateBoundedAgentConfig( + config({ boundedAgents: boundedAgents(patch) }), + env, + ); + expect(errors.join('\n')).toMatch(matcher); + } + }); +}); + +describe('resolveApiProxyRoute', () => { + it('maps each profile to its provider credential', () => { + expect(resolveApiProxyRoute({ openaiApiKey: 'k' } as WrapperConfig, 'openai').routed).toBe(true); + expect(resolveApiProxyRoute({ openaiApiKey: 'k' } as WrapperConfig, 'anthropic').routed).toBe(false); + expect(resolveApiProxyRoute({ anthropicApiKey: 'k' } as WrapperConfig, 'anthropic').routed).toBe(true); + }); +}); + +describe('assertEnclaveRuntimeAvailable', () => { + beforeEach(() => { + mockExeca.mockReset(); + }); + + it('uses Docker daemon probes by default', async () => { + mockExeca + .mockResolvedValueOnce({ exitCode: 0, stdout: '{"runsc":{}}' }) + .mockResolvedValueOnce({ exitCode: 0, stdout: '27.0.0' }); + + await expect(assertEnclaveRuntimeAvailable(boundedAgents({ runtime: 'gvisor' }))).resolves.toBeUndefined(); + await expect(assertEnclaveRuntimeAvailable(boundedAgents({ runtime: 'docker' }))).resolves.toBeUndefined(); + }); + + it('fails closed when default Docker probes return malformed or unsuccessful results', async () => { + mockExeca + .mockResolvedValueOnce({ exitCode: 0, stdout: '{' }) + .mockResolvedValueOnce({ exitCode: 1, stdout: '' }); + + await expect(assertEnclaveRuntimeAvailable(boundedAgents({ runtime: 'gvisor' }))).rejects.toThrow( + /runsc/, + ); + await expect(assertEnclaveRuntimeAvailable(boundedAgents({ runtime: 'docker' }))).rejects.toThrow( + /reachable Docker daemon/, + ); + }); + + it('rejects an unsuccessful default runtime query and unknown runtime', async () => { + mockExeca.mockResolvedValueOnce({ exitCode: 1, stdout: '' }); + await expect(assertEnclaveRuntimeAvailable(boundedAgents({ runtime: 'gvisor' }))).rejects.toThrow( + /runsc/, + ); + await expect( + assertEnclaveRuntimeAvailable(boundedAgents({ runtime: 'unknown' as BoundedAgentsConfig['runtime'] })), + ).rejects.toThrow(/no implemented enclave launcher/); + }); + + it('accepts docker when the daemon is reachable', async () => { + await expect( + assertEnclaveRuntimeAvailable(boundedAgents(), async () => false, async () => true), + ).resolves.toBeUndefined(); + }); + + it('rejects docker when the daemon is unreachable, with no fallback', async () => { + await expect( + assertEnclaveRuntimeAvailable(boundedAgents(), async () => true, async () => false), + ).rejects.toThrow(/never fall back/); + }); + + it('requires an exactly registered runsc for gvisor', async () => { + const runtimes: string[] = []; + await expect( + assertEnclaveRuntimeAvailable( + boundedAgents({ runtime: 'gvisor' }), + async (name) => { + runtimes.push(name); + return true; + }, + async () => true, + ), + ).resolves.toBeUndefined(); + expect(runtimes).toEqual(['runsc']); + }); + + it('never downgrades gvisor to the default runtime', async () => { + await expect( + assertEnclaveRuntimeAvailable( + boundedAgents({ runtime: 'gvisor' }), + async () => false, + // Even a perfectly healthy default Docker runtime must not rescue this. + async () => true, + ), + ).rejects.toThrow(/never fall back to a weaker runtime/); + }); + + it('accepts sbx when the capability probe reports full support', async () => { + const querySbxCapabilities = jest.fn(async () => ({ supported: true, missing: [], auditedVersion: '0.37.1' })); + await expect( + assertEnclaveRuntimeAvailable( + boundedAgents({ runtime: 'sbx' }), + async () => true, + async () => true, + querySbxCapabilities, + ), + ).resolves.toBeUndefined(); + expect(querySbxCapabilities).toHaveBeenCalledTimes(1); + }); + + it('blocks sbx with the exact missing capabilities and never falls back, honestly reflecting audited 0.37.1', async () => { + const missing = ['pinned AWF bounded-agent sbx template and bootstrap', 'sbx create --network']; + await expect( + assertEnclaveRuntimeAvailable( + boundedAgents({ runtime: 'sbx' }), + async () => true, + async () => true, + async () => ({ supported: false, missing, auditedVersion: '0.37.1' }), + ), + ).rejects.toThrow(/pinned AWF bounded-agent sbx template and bootstrap.*sbx create --network/); + await expect( + assertEnclaveRuntimeAvailable( + boundedAgents({ runtime: 'sbx' }), + async () => true, + async () => true, + async () => ({ supported: false, missing, auditedVersion: '0.37.1' }), + ), + ).rejects.toThrow(/never fall back to Docker or gVisor/); + }); + + it('rejects an unrecognized runtime with no implemented launcher', async () => { + await expect( + assertEnclaveRuntimeAvailable( + { ...boundedAgents(), runtime: 'wasm' as unknown as BoundedAgentsConfig['runtime'] }, + async () => true, + async () => true, + ), + ).rejects.toThrow(/no implemented enclave launcher/); + }); +}); + +describe('assertPrimaryRuntimeAvailable', () => { + it('is not the bounded-query implementation: bounded-agent errors must never leak bounded-query wording', () => { + expect(assertPrimaryRuntimeAvailable).not.toBe(boundedQueryPreflight.assertPrimaryRuntimeAvailable); + }); + + it.each([ + [undefined, 'docker'], + ['docker', 'docker'], + ['gvisor', 'gvisor'], + ['runsc', 'gvisor'], + ['sbx', 'sbx'], + ] as const)('accepts an available %s primary backend (%s)', async (runtime, _backend) => { + await expect(assertPrimaryRuntimeAvailable( + runtime, + jest.fn().mockResolvedValue(true), + jest.fn().mockResolvedValue(true), + jest.fn().mockResolvedValue(true), + )).resolves.toBeUndefined(); + }); + + it.each([ + [undefined, /Docker primary-agent runtime is unavailable/], + ['docker', /OCI runtime "docker" is not registered.*never fall back/s], + ['gvisor', /Primary-agent runtime "gvisor".*runsc.*never fall back/s], + ['sbx', /Primary-agent runtime "sbx" is unavailable.*never fall back/s], + ['kata', /OCI runtime "kata" is not registered.*never fall back/s], + ] as const)('fails %s before staging when its primary capability is unavailable', async (runtime, message) => { + await expect(assertPrimaryRuntimeAvailable( + runtime, + jest.fn().mockResolvedValue(false), + jest.fn().mockResolvedValue(false), + jest.fn().mockResolvedValue(false), + )).rejects.toThrow(message); + }); + + it('always identifies failures as bounded-agent, never bounded-query', async () => { + await expect(assertPrimaryRuntimeAvailable( + undefined, + jest.fn().mockResolvedValue(false), + jest.fn().mockResolvedValue(false), + jest.fn().mockResolvedValue(false), + )).rejects.toThrow(/Bounded agents abort before staging/); + + let sbxError: Error | undefined; + try { + await assertPrimaryRuntimeAvailable( + 'sbx', + jest.fn().mockResolvedValue(false), + jest.fn().mockResolvedValue(false), + jest.fn().mockResolvedValue(false), + ); + } catch (error) { + sbxError = error as Error; + } + expect(sbxError?.message).toMatch(/Bounded agents abort before staging/); + expect(sbxError?.message).not.toMatch(/[Bb]ounded quer(y|ies)/); + }); + + it('checks explicit docker runtime registration instead of Docker daemon availability', async () => { + const runtimeQuery = jest.fn().mockResolvedValue(true); + const dockerAvailable = jest.fn().mockResolvedValue(false); + await expect(assertPrimaryRuntimeAvailable( + 'docker', + runtimeQuery, + dockerAvailable, + jest.fn().mockResolvedValue(true), + )).resolves.toBeUndefined(); + expect(runtimeQuery).toHaveBeenCalledWith('docker'); + expect(dockerAvailable).not.toHaveBeenCalled(); + }); +}); diff --git a/src/bounded-agent/preflight.ts b/src/bounded-agent/preflight.ts new file mode 100644 index 000000000..71116de01 --- /dev/null +++ b/src/bounded-agent/preflight.ts @@ -0,0 +1,428 @@ +import execa from 'execa'; +import { getLocalDockerEnv } from '../host-env'; +import type { BoundedAgentsConfig, WrapperConfig } from '../types'; +import { normalizeRepoKey } from './paths'; +import { + BOUNDED_AGENT_REPO_PATTERN, + MAX_BOUNDED_AGENT_TIMEOUT_SECONDS, + MAX_RESULT_BYTES, + MAX_TASK_BYTES, +} from './protocol'; +import { resolveStagingToken } from '../bounded-query/staging'; +import { + defaultBoundedAgentSbxCapabilityQuery, + type BoundedAgentSbxCapabilityQuery, +} from './sbx-capability'; + +/** + * Fail-closed preflight for bounded agents. + * + * JSON Schema already constrains the *shape* of `boundedAgents`. This module + * covers everything the schema cannot: credential availability, the mandatory + * API-proxy model route, sandbox runtime availability — for *both* the + * primary agent and the bounded-agent enclave, evaluated as independent + * matrix axes — and combinations of AWF settings under which a bounded agent + * cannot be exposed securely. + * + * Every check here is fatal. A bounded-agent run that cannot satisfy its + * isolation guarantees must abort before the primary agent starts and before + * any repository is staged, rather than silently downgrading — in + * particular, an unavailable `runsc` never falls back to the default Docker + * runtime, and the `sbx` enclave backend never falls back to Docker or + * gVisor when its capability proof fails (which it always currently does; + * see `./sbx-capability.ts`). + * + * The *primary* agent runtime is a completely separate axis from the + * *enclave* runtime: a primary `sbx` microVM can be paired with a `docker` or + * `gvisor` bounded-agent enclave once the primary-sbx ingress is proven (see + * `./ingress.ts`), and a `docker`/`gvisor` primary can never be paired with a + * `sbx` enclave while sbx's capability report is incomplete. See + * `./runtime-matrix.ts` for the full evaluation of all nine combinations. + */ + +/** Enclave runtimes with a safe, implemented launcher. */ +const IMPLEMENTED_ENCLAVE_RUNTIMES = new Set(['docker', 'gvisor']); + +/** Every enclave runtime the schema accepts, implemented or capability-gated. */ +const SUPPORTED_ENCLAVE_RUNTIMES = new Set(['docker', 'gvisor', 'sbx']); + +/** Docker OCI runtime name required for the `gvisor` enclave runtime. */ +const GVISOR_DOCKER_RUNTIME = 'runsc'; + +/** Detects whether the Docker daemon exposes a named OCI runtime. */ +export type DockerRuntimeQuery = (runtimeName: string) => Promise; + +/** Detects whether the Docker daemon required by the enclave backend is reachable. */ +export type DockerAvailabilityQuery = () => Promise; + +/** Detects whether the sbx primary-agent runtime is installed and authenticated. */ +export type SbxAvailabilityQuery = () => Promise; + +const defaultDockerRuntimeQuery: DockerRuntimeQuery = async (runtimeName) => { + const result = await execa('docker', ['info', '--format', '{{json .Runtimes}}'], { + env: getLocalDockerEnv(), + reject: false, + timeout: 30_000, + }); + if (result.exitCode !== 0) return false; + try { + const runtimes = JSON.parse(result.stdout) as Record; + return Object.prototype.hasOwnProperty.call(runtimes, runtimeName); + } catch { + return false; + } +}; + +const defaultDockerAvailabilityQuery: DockerAvailabilityQuery = async () => { + const result = await execa('docker', ['info', '--format', '{{.ServerVersion}}'], { + env: getLocalDockerEnv(), + reject: false, + timeout: 30_000, + }); + return result.exitCode === 0; +}; + +/** + * Executes the minimum host-side primary-agent capability proof for sbx: an + * authenticated, non-mutating `sbx ls`. This is deliberately narrower than + * {@link defaultBoundedAgentSbxCapabilityQuery}, which proves the *enclave* + * axis; the primary axis only needs to know the CLI/daemon is reachable. + */ +const defaultSbxAvailabilityQuery: SbxAvailabilityQuery = async () => { + try { + const managementEnv = { ...process.env }; + delete managementEnv.DOCKER_SANDBOXES_PROXY; + delete managementEnv.XDG_CONFIG_HOME; + const result = await execa('sbx', ['ls'], { + reject: false, + timeout: 10_000, + env: managementEnv, + }); + return result.exitCode === 0; + } catch { + return false; + } +}; + +type PrimaryRuntimeCase = 'sbx' | 'docker' | 'gvisor' | 'custom' | 'default-docker'; + +function classifyPrimaryRuntime(runtime: string | undefined): PrimaryRuntimeCase { + if (runtime === 'sbx') return 'sbx'; + if (runtime === 'docker') return 'docker'; + if (runtime === 'gvisor' || runtime === 'runsc') return 'gvisor'; + if (runtime) return 'custom'; + return 'default-docker'; +} + +/** + * Resolves whether the configured profile has a usable API-proxy model route. + * + * A bounded agent has no credentials of its own: it can only reach a model + * through the AWF API proxy, which injects the real key. If the profile's + * provider is not routed by the sidecar for this run, the enclave would sit on + * an internal network with nothing to talk to — so the run is rejected rather + * than started in a state where every invocation would return the canonical + * error. + */ +export function resolveApiProxyRoute( + config: WrapperConfig, + profile: BoundedAgentsConfig['profile'], +): { routed: boolean; detail: string } { + if (profile === 'anthropic') { + return { + routed: Boolean(config.anthropicApiKey), + detail: 'apiProxy.targets.anthropic (ANTHROPIC_API_KEY) is not configured', + }; + } + return { + routed: Boolean(config.openaiApiKey), + detail: 'apiProxy.targets.openai (OPENAI_API_KEY) is not configured', + }; +} + +/** Parses a Docker-style size string (e.g. `512m`) into bytes. */ +function isDockerSize(value: string): boolean { + return /^[1-9][0-9]*[bkmgBKMG]$/.test(value); +} + +/** + * Validates everything about a bounded-agent configuration that can be decided + * without touching Docker or the network. + * + * @returns human-readable errors; empty when the configuration is acceptable. + */ +export function validateBoundedAgentConfig( + config: WrapperConfig, + env: NodeJS.ProcessEnv = process.env, +): string[] { + const boundedAgents = config.boundedAgents; + if (!boundedAgents?.enabled) return []; + + const errors: string[] = []; + + // The primary-agent runtime (docker / gvisor / sbx) is validated as its own + // matrix axis by assertPrimaryRuntimeAvailable, not rejected here. A primary + // sbx microVM is supported once its bounded-agent ingress is proven (see + // ./ingress.ts and ./manager.ts). + + if (config.enableDind) { + errors.push( + 'bounded agents cannot be combined with enableDind: exposing the Docker socket to the primary ' + + 'agent would allow it to inspect credentials, mount private seeds, join enclave networks, and ' + + 'bypass the finite-disclosure ledger', + ); + } + + if (boundedAgents.privateRepos.length === 0) { + errors.push('boundedAgents.enabled is true but boundedAgents.privateRepos is empty'); + } + + const seenKeys = new Set(); + for (const entry of boundedAgents.privateRepos) { + const repo = entry.repo; + if (!BOUNDED_AGENT_REPO_PATTERN.test(repo)) { + errors.push( + `boundedAgents.privateRepos entry "${repo}" is not a bare owner/repo slug ` + + '(no scheme, host, credentials, path traversal, query, fragment, or wildcard)', + ); + continue; + } + const key = normalizeRepoKey(repo); + if (seenKeys.has(key)) { + errors.push(`boundedAgents.privateRepos contains a duplicate entry: "${repo}"`); + } + seenKeys.add(key); + } + + if (!SUPPORTED_ENCLAVE_RUNTIMES.has(boundedAgents.runtime)) { + errors.push( + `boundedAgents.runtime "${boundedAgents.runtime}" is not supported. ` + + 'AWF has no audited, single-use, API-proxy-only enclave launcher for it, and bounded agents ' + + 'never downgrade to a weaker runtime. Use "docker", "gvisor", or "sbx".', + ); + } + + if (!config.enableApiProxy) { + errors.push( + 'bounded agents require the AWF API proxy to be enabled: the enclave holds no credentials and ' + + 'the API proxy is its only permitted upstream egress', + ); + } + + if (!boundedAgents.model || boundedAgents.model.length === 0) { + errors.push('boundedAgents.model is required when boundedAgents.enabled is true'); + } + + const route = resolveApiProxyRoute(config, boundedAgents.profile); + if (!route.routed) { + errors.push( + `bounded agents require a supported configured API target for profile "${boundedAgents.profile}": ` + + `${route.detail}`, + ); + } + + // Reserve the final minute of the 10-minute response bucket for Docker + // termination, result validation, container removal, and workspace cleanup. + if (!Number.isInteger(boundedAgents.timeout) || boundedAgents.timeout < 1) { + errors.push('boundedAgents.timeout must be a positive integer number of seconds'); + } else if (boundedAgents.timeout > MAX_BOUNDED_AGENT_TIMEOUT_SECONDS) { + errors.push( + `boundedAgents.timeout must be at most ${MAX_BOUNDED_AGENT_TIMEOUT_SECONDS} seconds ` + + '(the 10-minute response bucket reserves its final minute for termination, validation, and cleanup)', + ); + } + + if (!Number.isInteger(boundedAgents.maxInvocations) || boundedAgents.maxInvocations < 1) { + errors.push('boundedAgents.maxInvocations must be a positive integer'); + } + if (!Number.isInteger(boundedAgents.maxModelRequests) || boundedAgents.maxModelRequests < 1) { + errors.push('boundedAgents.maxModelRequests must be a positive integer'); + } + if (!Number.isInteger(boundedAgents.maxModelTokens) || boundedAgents.maxModelTokens < 1) { + errors.push('boundedAgents.maxModelTokens must be a positive integer'); + } + if (!Number.isInteger(boundedAgents.pidsLimit) || boundedAgents.pidsLimit < 1) { + errors.push('boundedAgents.pidsLimit must be a positive integer'); + } + if ( + !Number.isInteger(boundedAgents.maxOutputBytes) + || boundedAgents.maxOutputBytes < 1 + || boundedAgents.maxOutputBytes > MAX_RESULT_BYTES + ) { + errors.push(`boundedAgents.maxOutputBytes must be between 1 and ${MAX_RESULT_BYTES}`); + } + if ( + !Number.isInteger(boundedAgents.maxTaskBytes) + || boundedAgents.maxTaskBytes < 1 + || boundedAgents.maxTaskBytes > MAX_TASK_BYTES + ) { + errors.push(`boundedAgents.maxTaskBytes must be between 1 and ${MAX_TASK_BYTES}`); + } + + if (!isDockerSize(boundedAgents.memoryLimit)) { + errors.push(`boundedAgents.memoryLimit "${boundedAgents.memoryLimit}" is not a Docker memory limit`); + } + if (!isDockerSize(boundedAgents.tmpfsLimit)) { + errors.push(`boundedAgents.tmpfsLimit "${boundedAgents.tmpfsLimit}" is not a Docker size limit`); + } + if (!/^(?:[0-9]{1,2})(?:\.[0-9]{1,3})?$/.test(boundedAgents.cpuLimit) || Number(boundedAgents.cpuLimit) <= 0) { + errors.push(`boundedAgents.cpuLimit "${boundedAgents.cpuLimit}" is not a positive Docker --cpus value`); + } + + // The broker/enclave subsystem always runs via Docker Compose (Squid and + // the API proxy are always compose services), independent of the primary + // agent's own runtime. sbx *enclave* runtime is exempted because the sbx + // enclave runner never mounts the Docker socket into the broker at all — + // see buildBoundedAgentService and containers/bounded-agent/broker/config.js. + const dockerHost = config.awfDockerHost ?? env.DOCKER_HOST; + if (boundedAgents.runtime !== 'sbx' && dockerHost && !dockerHost.startsWith('unix://')) { + errors.push( + `bounded agents require a Unix-socket Docker host, but the resolved host is "${dockerHost}". ` + + 'The broker has no route to a TCP daemon and AWF will not weaken that isolation.', + ); + } + + if (!resolveStagingToken(env)) { + errors.push( + 'bounded agents require a staging credential in GH_TOKEN or GITHUB_TOKEN on the AWF host ' + + '(it is used only by the trusted staging phase and never reaches the agent, broker, or enclave)', + ); + } + + return errors; +} + +/** + * Verifies that the requested enclave runtime is actually available. + * + * Only reached after {@link validateBoundedAgentConfig} accepted the runtime + * name, so the only remaining question is capability. gVisor requires an + * exact `runsc` registration and is never downgraded. The `sbx` enclave + * backend runs a full capability proof — {@link defaultBoundedAgentSbxCapabilityQuery} + * — and is blocked with the exact missing controls whenever the proof is + * incomplete, which it always currently is for audited sbx 0.37.1. + */ +export async function assertEnclaveRuntimeAvailable( + boundedAgents: BoundedAgentsConfig, + queryDockerRuntime: DockerRuntimeQuery = defaultDockerRuntimeQuery, + queryDockerAvailable: DockerAvailabilityQuery = defaultDockerAvailabilityQuery, + querySbxCapabilities: BoundedAgentSbxCapabilityQuery = defaultBoundedAgentSbxCapabilityQuery, +): Promise { + if (boundedAgents.runtime === 'gvisor') { + if (!(await queryDockerRuntime(GVISOR_DOCKER_RUNTIME))) { + throw new Error( + `boundedAgents.runtime "gvisor" requires the "${GVISOR_DOCKER_RUNTIME}" OCI runtime to be ` + + 'registered with the Docker daemon. It is not available, and bounded agents never fall back ' + + 'to a weaker runtime.', + ); + } + return; + } + + if (boundedAgents.runtime === 'docker') { + if (!(await queryDockerAvailable())) { + throw new Error( + 'boundedAgents.runtime "docker" requires a reachable Docker daemon. It is not available, ' + + 'and bounded agents never fall back to another runtime.', + ); + } + return; + } + + if (boundedAgents.runtime === 'sbx') { + const report = await querySbxCapabilities(); + if (!report.supported) { + throw new Error( + 'boundedAgents.runtime "sbx" is blocked because the installed sbx runtime cannot enforce all ' + + `mandatory enclave-isolation controls: ${report.missing.join(', ')}. ` + + 'AWF will not launch an enclave VM and will never fall back to Docker or gVisor.', + ); + } + return; + } + + throw new Error( + `boundedAgents.runtime "${boundedAgents.runtime}" has no implemented enclave launcher. ` + + 'Bounded agents fail closed rather than downgrading to Docker or gVisor.', + ); +} + +/** + * Verifies the primary-agent runtime before bounded-agent repository staging. + * + * The primary-agent runtime (`docker` / `gvisor` / `sbx`, independent of the + * `boundedAgents.runtime` enclave axis) is proven by a bounded-agent-specific + * check with bounded-agent wording in every failure, rather than reusing + * bounded queries' implementation: reusing it would surface bounded-query + * error text (e.g. "Bounded queries abort before staging") on a run that may + * not even have bounded queries enabled. The underlying capability probes + * (Docker runtime registration, Docker daemon reachability, sbx CLI/daemon + * reachability) are identical in substance to bounded queries' own primary + * check; only the identifying language differs. Bounded agents and bounded + * queries can be enabled independently or together, and neither ever falls + * back to a weaker runtime. + */ +export async function assertPrimaryRuntimeAvailable( + containerRuntime: string | undefined, + queryDockerRuntime: DockerRuntimeQuery = defaultDockerRuntimeQuery, + queryDockerAvailable: DockerAvailabilityQuery = defaultDockerAvailabilityQuery, + querySbxAvailable: SbxAvailabilityQuery = defaultSbxAvailabilityQuery, +): Promise { + const runtimeCase = classifyPrimaryRuntime(containerRuntime); + switch (runtimeCase) { + case 'sbx': + if (!(await querySbxAvailable())) { + throw new Error( + 'Primary-agent runtime "sbx" is unavailable. Bounded agents abort before staging and never ' + + 'fall back to a Docker or gVisor primary agent.', + ); + } + return; + case 'docker': + if (!(await queryDockerRuntime('docker'))) { + throw new Error( + 'Primary-agent OCI runtime "docker" is not registered with Docker. ' + + 'Bounded agents abort before staging and never fall back.', + ); + } + return; + case 'gvisor': + if (!(await queryDockerRuntime(GVISOR_DOCKER_RUNTIME))) { + throw new Error( + `Primary-agent runtime "${containerRuntime}" requires the "${GVISOR_DOCKER_RUNTIME}" OCI ` + + 'runtime. It is not available, so bounded agents abort before staging and never fall back.', + ); + } + return; + case 'custom': + if (!(await queryDockerRuntime(containerRuntime!))) { + throw new Error( + `Primary-agent OCI runtime "${containerRuntime}" is not registered with Docker. ` + + 'Bounded agents abort before staging and never fall back.', + ); + } + return; + case 'default-docker': + if (!(await queryDockerAvailable())) { + throw new Error( + 'The Docker primary-agent runtime is unavailable. ' + + 'Bounded agents abort before staging and never fall back.', + ); + } + return; + default: + throw new Error(`Unreachable primary runtime case: ${runtimeCase satisfies never}`); + } +} + +/** @internal Exported for focused unit tests. */ +// ts-prune-ignore-next +export const boundedAgentPreflightTestHelpers = { + IMPLEMENTED_ENCLAVE_RUNTIMES, + SUPPORTED_ENCLAVE_RUNTIMES, + GVISOR_DOCKER_RUNTIME, + defaultDockerRuntimeQuery, + defaultDockerAvailabilityQuery, + defaultSbxAvailabilityQuery, + isDockerSize, +}; diff --git a/src/bounded-agent/protocol.test.ts b/src/bounded-agent/protocol.test.ts new file mode 100644 index 000000000..a7ea53347 --- /dev/null +++ b/src/bounded-agent/protocol.test.ts @@ -0,0 +1,295 @@ +import * as path from 'path'; +import { + AGENT_PROTOCOL_VERSION, + ALLOWED_REQUEST_KEYS, + CANONICAL_ERROR_JSON, + FORBIDDEN_REQUEST_KEYS, + MAX_PRIVATE_REPO_LENGTH, + MAX_RESULT_BYTES, + MAX_SCHEMA_BYTES, + MAX_TASK_BYTES, + RESULT_STATUS_BIT_COST, + TIMING_BUCKETS_MS, + TIMING_BUCKET_BITS, + canonicalOkJson, + canonicalizeSchemaValue, + parseAndValidateQueryOutput, + queryBitsForSchema, + schemaCardinality, + strictParseJson, + validateSchema, + validateValueAgainstSchema, + validateBoundedAgentRequest, + type BoundedAgentSchemaNode, +} from './protocol'; + +/* eslint-disable @typescript-eslint/no-require-imports */ +const brokerDir = path.join(__dirname, '..', '..', 'containers', 'bounded-agent', 'broker'); +const brokerFraming = require(path.join(brokerDir, 'framing.js')); +const brokerProtocol = require(path.join(brokerDir, 'protocol.js')); +const brokerSpec = require(path.join(brokerDir, 'enclave-runner-spec.js')); +/* eslint-enable @typescript-eslint/no-require-imports */ + +const booleanSchema: BoundedAgentSchemaNode = { type: 'boolean' }; + +const request = (overrides: Record = {}): Record => ({ + privateRepo: 'octo/alpha', + schema: booleanSchema, + task: 'Does this repository declare a SECURITY.md?', + ...overrides, +}); + +/** + * Request-protocol coverage. + * + * A bounded-agent request may select only a configured repository, declare a + * finite result schema, and carry bounded task text. Everything else — image, + * command, executable, mount, environment, endpoint, network, proxy, + * credential, timeout, resource limit, runtime, tool definition — and any + * unknown key must be rejected. + */ +describe('validateBoundedAgentRequest', () => { + it('accepts the only permitted request shape', () => { + const result = validateBoundedAgentRequest(request()); + expect(result.valid).toBe(true); + }); + + it('exposes exactly three allowed keys', () => { + expect([...ALLOWED_REQUEST_KEYS].sort()).toEqual(['privateRepo', 'schema', 'task']); + }); + + it.each(FORBIDDEN_REQUEST_KEYS)('rejects the forbidden control "%s"', (key) => { + const result = validateBoundedAgentRequest(request({ [key]: 'anything' })); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.errors.join('\n')).toContain(`request may not specify "${key}"`); + } + }); + + it('names every capability class the design forbids', () => { + for (const key of [ + 'image', 'command', 'executable', 'mounts', 'env', 'endpoint', 'network', 'proxy', + 'credentials', 'timeout', 'resources', 'runtime', 'tools', 'model', 'profile', 'systemPrompt', + ]) { + expect(FORBIDDEN_REQUEST_KEYS).toContain(key); + } + }); + + it('rejects unknown keys', () => { + const result = validateBoundedAgentRequest(request({ somethingNew: 1 })); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.errors.join('\n')).toContain('unknown request key: "somethingNew"'); + } + }); + + it('rejects non-object requests', () => { + for (const raw of [undefined, null, 'x', 42, []]) { + expect(validateBoundedAgentRequest(raw).valid).toBe(false); + } + }); + + it('rejects repository selectors that are not a bare owner/repo slug', () => { + for (const privateRepo of [ + 'https://github.com/octo/alpha', + 'octo/alpha/../secret', + '../octo/alpha', + 'octo', + 42, + ]) { + expect(validateBoundedAgentRequest(request({ privateRepo })).valid).toBe(false); + } + expect( + validateBoundedAgentRequest(request({ privateRepo: `a/${'b'.repeat(MAX_PRIVATE_REPO_LENGTH)}` })).valid, + ).toBe(false); + }); + + it('rejects a non-finite result schema', () => { + for (const schema of [ + { type: 'string' }, + { type: 'number' }, + { $ref: '#/definitions/self' }, + { type: 'object', fields: { a: { type: 'string' } } }, + { type: 'integer' }, + ]) { + expect(validateBoundedAgentRequest(request({ schema })).valid).toBe(false); + } + }); + + it('byte-bounds the task text against the configured and hard limits', () => { + expect(validateBoundedAgentRequest(request({ task: '' })).valid).toBe(false); + expect(validateBoundedAgentRequest(request({ task: 42 })).valid).toBe(false); + expect( + validateBoundedAgentRequest(request({ task: 'x'.repeat(100) }), { maxTaskBytes: 50 }).valid, + ).toBe(false); + expect( + validateBoundedAgentRequest(request({ task: 'x'.repeat(50) }), { maxTaskBytes: 50 }).valid, + ).toBe(true); + expect( + validateBoundedAgentRequest(request({ task: 'x'.repeat(MAX_TASK_BYTES + 1) }), { + maxTaskBytes: MAX_TASK_BYTES * 10, + }).valid, + ).toBe(false); + }); + + it('counts task size in UTF-8 bytes, not code units', () => { + // "€" is 3 bytes. + expect(validateBoundedAgentRequest(request({ task: '€€' }), { maxTaskBytes: 5 }).valid).toBe(false); + expect(validateBoundedAgentRequest(request({ task: '€€' }), { maxTaskBytes: 6 }).valid).toBe(true); + }); +}); + +describe('canonical envelopes and timing buckets (PR1 primitives)', () => { + it('re-exports the complete finite-disclosure boundary', () => { + expect(MAX_RESULT_BYTES).toBeGreaterThan(0); + expect(MAX_SCHEMA_BYTES).toBeGreaterThan(0); + expect(MAX_PRIVATE_REPO_LENGTH).toBeGreaterThan(0); + expect(schemaCardinality({ type: 'boolean' })).toBe(2n); + expect(strictParseJson('true')).toEqual({ value: true }); + expect(validateSchema(booleanSchema).valid).toBe(true); + expect(validateValueAgainstSchema(booleanSchema, true)).toBe(true); + expect(canonicalizeSchemaValue(booleanSchema, true)).toBe('true'); + }); + + it('uses the shared canonical error shape', () => { + expect(CANONICAL_ERROR_JSON).toBe('{"status":"error"}'); + }); + + it('uses the shared canonical success shape', () => { + expect(canonicalOkJson('true')).toBe('{"status":"ok","result":true}'); + }); + + it('uses the shared fixed timing buckets', () => { + expect(TIMING_BUCKETS_MS).toEqual([10, 100, 1_000, 10_000, 60_000, 600_000]); + expect(TIMING_BUCKET_BITS).toBe(3); + }); + + it('charges the status and timing channels in addition to the schema payload', () => { + // boolean => 1 payload bit + expect(queryBitsForSchema(booleanSchema)).toBe(RESULT_STATUS_BIT_COST + 1 + TIMING_BUCKET_BITS); + // const => 0 payload bits, still charged for status + timing + expect(queryBitsForSchema({ type: 'const', value: 'x' })).toBe( + RESULT_STATUS_BIT_COST + TIMING_BUCKET_BITS, + ); + }); + + it('validates and canonicalizes enclave output against the declared schema', () => { + expect(parseAndValidateQueryOutput('true', booleanSchema)).toEqual({ ok: true, canonical: 'true' }); + expect(parseAndValidateQueryOutput('"true"', booleanSchema).ok).toBe(false); + expect(parseAndValidateQueryOutput('', booleanSchema).ok).toBe(false); + expect(parseAndValidateQueryOutput('true true', booleanSchema).ok).toBe(false); + }); +}); + +/** + * The broker runs in its own container image and cannot import AWF's + * TypeScript sources. These checks fail the moment the two implementations of + * the bounded-agent request contract disagree. + */ +describe('TypeScript ↔ broker parity', () => { + it('shares the PR1 canonical envelopes and buckets', () => { + expect(brokerProtocol.CANONICAL_ERROR_JSON).toBe(CANONICAL_ERROR_JSON); + expect(brokerProtocol.TIMING_BUCKETS_MS).toEqual([...TIMING_BUCKETS_MS]); + expect(brokerProtocol.canonicalOkJson('true')).toBe(canonicalOkJson('true')); + }); + + it('agrees on the framing protocol version', () => { + expect(brokerFraming.AGENT_PROTOCOL_VERSION).toBe(String(AGENT_PROTOCOL_VERSION)); + }); + + it('agrees on the allowed and forbidden request keys', () => { + expect([...brokerFraming.ALLOWED_REQUEST_KEYS].sort()).toEqual([...ALLOWED_REQUEST_KEYS].sort()); + expect([...brokerFraming.FORBIDDEN_REQUEST_KEYS].sort()).toEqual([...FORBIDDEN_REQUEST_KEYS].sort()); + }); + + it('agrees on accept/reject for a shared vector table', () => { + const vectors: Array | unknown> = [ + request(), + request({ task: '' }), + request({ privateRepo: 'octo' }), + request({ schema: { type: 'string' } }), + request({ image: 'evil' }), + request({ tools: [] }), + request({ nope: true }), + 'not an object', + null, + ]; + for (const vector of vectors) { + const ts = validateBoundedAgentRequest(vector, { maxTaskBytes: 4096 }); + const broker = brokerFraming.validateBoundedAgentRequest(vector, { maxTaskBytes: 4096 }); + expect(broker.valid).toBe(ts.valid); + } + }); + + it('agrees on the schema information charge for a shared vector table', () => { + const schemas: BoundedAgentSchemaNode[] = [ + { type: 'boolean' }, + { type: 'const', value: 1 }, + { type: 'enum', values: ['a', 'b', 'c'] }, + { type: 'integer', minimum: 0, maximum: 255 }, + { type: 'object', fields: [ + { name: 'a', schema: { type: 'boolean' } }, + { name: 'b', schema: { type: 'boolean' } }, + ] }, + ]; + for (const schema of schemas) { + expect(brokerProtocol.queryBitsForSchema(schema)).toBe(queryBitsForSchema(schema)); + } + }); +}); + +describe('broker request framing', () => { + const headers = { + 'x-awf-agent-version': '1', + 'x-awf-repo': 'octo/alpha', + 'x-awf-schema-b64': Buffer.from(JSON.stringify(booleanSchema), 'utf8').toString('base64url'), + }; + const rawHeaders = Object.entries(headers).flat(); + + it('assembles the canonical request from fixed headers plus the task body', () => { + const framed = brokerFraming.buildRequestFromFrame(headers, rawHeaders, 'task text'); + expect(framed.error).toBeUndefined(); + expect(framed.request).toEqual({ + privateRepo: 'octo/alpha', + schema: booleanSchema, + task: 'task text', + }); + }); + + it('rejects any other x-awf control header', () => { + const extra = { ...headers, 'x-awf-runtime': 'runc' }; + const framed = brokerFraming.buildRequestFromFrame( + extra, + Object.entries(extra).flat(), + 'task', + ); + expect(framed.error).toMatch(/unsupported request control header/); + }); + + it('rejects duplicated control headers', () => { + const duplicated = [...rawHeaders, 'x-awf-repo', 'octo/beta']; + const framed = brokerFraming.buildRequestFromFrame(headers, duplicated, 'task'); + expect(framed.error).toMatch(/duplicate request header/); + }); + + it('rejects an unsupported protocol version', () => { + const bad = { ...headers, 'x-awf-agent-version': '2' }; + expect( + brokerFraming.buildRequestFromFrame(bad, Object.entries(bad).flat(), 'task').error, + ).toMatch(/protocol version/); + }); + + it('rejects a malformed schema header', () => { + const bad = { ...headers, 'x-awf-schema-b64': 'not+base64url/' }; + expect( + brokerFraming.buildRequestFromFrame(bad, Object.entries(bad).flat(), 'task').error, + ).toMatch(/schema header/); + }); +}); + +describe('enclave runner spec identifiers', () => { + it('uses a bounded-agent-specific run label distinct from bounded queries', () => { + expect(brokerSpec.RUN_LABEL).toBe('awf.bounded-agent.run'); + expect(brokerSpec.INVOCATION_LABEL).toBe('awf.bounded-agent.invocation'); + }); +}); diff --git a/src/bounded-agent/protocol.ts b/src/bounded-agent/protocol.ts new file mode 100644 index 000000000..93703deae --- /dev/null +++ b/src/bounded-agent/protocol.ts @@ -0,0 +1,227 @@ +/** + * Bounded-agent request/result protocol. + * + * The wire *algebra* — the finite response schema, its cardinality and + * information charge, strict JSON parsing, canonicalization, timing buckets, + * and the canonical success/error envelopes — is the PR1 bounded-execution + * foundation in `src/bounded-execution/finite-disclosure.ts`. This module adds + * only what is specific to a bounded *agent* request: + * + * - the request selects a configured repository, declares a finite result + * schema, and carries a byte-bounded task text; nothing else; + * - every control a caller might try to smuggle in — image, command, + * executable, mount, environment, endpoint, network, proxy, credential, + * timeout, resource limit, runtime, or tool definition — is explicitly + * rejected, as is any unknown key. + * + * Rejecting *explicitly named* controls in addition to the generic + * unknown-key rule is redundant by construction; it is kept because it turns a + * future accidental widening of the accepted key set into a test failure + * rather than a silent capability grant. + */ + +import { + MAX_PRIVATE_REPO_LENGTH, + BOUNDED_QUERY_REPO_PATTERN, + validateSchema, + type BoundedQuerySchemaNode, +} from '../bounded-execution/finite-disclosure'; + +export { + CANONICAL_ERROR_JSON, + MAX_RESULT_BYTES, + MAX_SCHEMA_BYTES, + MAX_QUERY_TIMEOUT_SECONDS as MAX_BOUNDED_AGENT_TIMEOUT_SECONDS, + RESULT_STATUS_BIT_COST, + TIMING_BUCKETS_MS, + TIMING_BUCKET_BITS, + BOUNDED_QUERY_REPO_PATTERN as BOUNDED_AGENT_REPO_PATTERN, + MAX_PRIVATE_REPO_LENGTH, + canonicalOkJson, + parseAndValidateQueryOutput, + queryBitsForSchema, + schemaCardinality, + strictParseJson, + validateSchema, + validateValueAgainstSchema, + canonicalizeSchemaValue, + type BoundedQuerySchemaNode as BoundedAgentSchemaNode, +} from '../bounded-execution/finite-disclosure'; + +/** Framing/protocol version of the bounded-agent request contract. */ +export const AGENT_PROTOCOL_VERSION = 1; + +/** Hard ceiling on the caller-supplied task text, independent of configuration. */ +export const MAX_TASK_BYTES = 64 * 1024; + +/** The complete set of keys a bounded-agent request may contain. */ +export const ALLOWED_REQUEST_KEYS: readonly string[] = ['privateRepo', 'schema', 'task']; + +/** + * Controls a request may never express. + * + * These are all fixed trusted configuration. Naming them explicitly makes the + * rejection self-documenting and testable; the generic unknown-key rule below + * would reject them anyway. + */ +export const FORBIDDEN_REQUEST_KEYS: readonly string[] = [ + 'image', + 'images', + 'command', + 'cmd', + 'args', + 'argv', + 'entrypoint', + 'executable', + 'interpreter', + 'script', + 'shell', + 'mount', + 'mounts', + 'volume', + 'volumes', + 'bind', + 'path', + 'paths', + 'workdir', + 'env', + 'environment', + 'endpoint', + 'endpoints', + 'baseUrl', + 'url', + 'host', + 'network', + 'networks', + 'dns', + 'proxy', + 'httpProxy', + 'httpsProxy', + 'credential', + 'credentials', + 'apiKey', + 'token', + 'authorization', + 'headers', + 'timeout', + 'timeoutSeconds', + 'deadline', + 'memory', + 'memoryLimit', + 'cpu', + 'cpuLimit', + 'pids', + 'pidsLimit', + 'tmpfs', + 'ulimit', + 'resources', + 'runtime', + 'backend', + 'sandbox', + 'profile', + 'model', + 'provider', + 'temperature', + 'maxTokens', + 'maxModelRequests', + 'tool', + 'tools', + 'toolChoice', + 'functions', + 'systemPrompt', + 'system', + 'messages', +]; + +/** A validated bounded-agent request. */ +export interface BoundedAgentRequest { + /** Configured repository selector, in `owner/repo` form. */ + privateRepo: string; + /** Finite response schema the enclave's answer must conform to. */ + schema: BoundedQuerySchemaNode; + /** Byte-bounded task text, forwarded verbatim into the enclave prompt. */ + task: string; +} + +export type BoundedAgentValidation = + | { valid: true; request: BoundedAgentRequest } + | { valid: false; errors: string[] }; + +/** Options bounding a request against the *run's* normalized configuration. */ +export interface ValidateBoundedAgentRequestOptions { + /** Configured `maxTaskBytes`. Clamped to {@link MAX_TASK_BYTES}. */ + maxTaskBytes?: number; +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** + * Validates a bounded-agent request against the fixed protocol. + * + * Fails closed: any structural surprise (unknown key, forbidden control, + * oversized task, non-finite schema, malformed repository selector) produces + * an invalid result whose errors are only ever written to the protected audit + * log, never returned to the caller. + */ +export function validateBoundedAgentRequest( + raw: unknown, + options: ValidateBoundedAgentRequestOptions = {}, +): BoundedAgentValidation { + const errors: string[] = []; + if (!isPlainObject(raw)) { + return { valid: false, errors: ['request must be a JSON object'] }; + } + + const forbidden = FORBIDDEN_REQUEST_KEYS.filter((key) => + Object.prototype.hasOwnProperty.call(raw, key)); + for (const key of forbidden) { + errors.push(`request may not specify "${key}"`); + } + for (const key of Object.keys(raw)) { + if (!ALLOWED_REQUEST_KEYS.includes(key) && !forbidden.includes(key)) { + errors.push(`unknown request key: "${key}"`); + } + } + + const { privateRepo, schema, task } = raw as Partial; + + if (typeof privateRepo !== 'string') { + errors.push('privateRepo must be a string'); + } else if (privateRepo.length > MAX_PRIVATE_REPO_LENGTH) { + errors.push('privateRepo exceeds the maximum length'); + } else if (!BOUNDED_QUERY_REPO_PATTERN.test(privateRepo)) { + errors.push('privateRepo must be a bare owner/repo slug'); + } + + const schemaValidation = validateSchema(schema); + if (!schemaValidation.valid) { + errors.push(...schemaValidation.errors); + } + + const taskLimit = Math.min( + Number.isInteger(options.maxTaskBytes) && (options.maxTaskBytes as number) > 0 + ? (options.maxTaskBytes as number) + : MAX_TASK_BYTES, + MAX_TASK_BYTES, + ); + if (typeof task !== 'string') { + errors.push('task must be a string'); + } else if (task.length === 0) { + errors.push('task must not be empty'); + } else if (Buffer.byteLength(task, 'utf8') > taskLimit) { + errors.push('task exceeds the maximum size'); + } + + if (errors.length > 0) return { valid: false, errors }; + + return { + valid: true, + request: { + privateRepo: privateRepo as string, + schema: schemaValidation.valid ? schemaValidation.schema : (schema as BoundedQuerySchemaNode), + task: task as string, + }, + }; +} diff --git a/src/bounded-agent/runtime-matrix.test.ts b/src/bounded-agent/runtime-matrix.test.ts new file mode 100644 index 000000000..de565febe --- /dev/null +++ b/src/bounded-agent/runtime-matrix.test.ts @@ -0,0 +1,330 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { + BOUNDED_AGENT_RUNTIME_BACKENDS, + evaluateBoundedAgentRuntimeCombination, + evaluateBoundedAgentRuntimeMatrix, + resolveBoundedAgentPrimaryBackend, + serializeBoundedAgentRuntimeTelemetry, + type BoundedAgentPrimaryBackend, + type BoundedAgentRuntimeCapabilities, +} from './runtime-matrix'; +import type { BoundedAgentRuntime } from '../types'; + +/* eslint-disable @typescript-eslint/no-require-imports */ +const brokerDir = path.join(__dirname, '..', '..', 'containers', 'bounded-agent', 'broker'); +const { createBroker } = require(path.join(brokerDir, 'broker.js')); +const { createRuntimeTelemetry } = require(path.join(brokerDir, 'runtime-telemetry.js')); +/* eslint-enable @typescript-eslint/no-require-imports */ + +const CANONICAL_ERROR = '{"status":"error"}'; +const PRIMARY_BACKENDS = BOUNDED_AGENT_RUNTIME_BACKENDS; +const BOUNDED_AGENT_BACKENDS = BOUNDED_AGENT_RUNTIME_BACKENDS; + +/** + * The real-world capability state: every primary backend is available (once + * its own runtime preflight passes), docker and gvisor enclaves are + * available once their preflight passes, and the sbx enclave backend is + * always `blocked` — never `unavailable` — because the CLI/daemon exists but + * cannot prove the mandatory isolation controls (see ./sbx-capability.ts). + */ +const deterministicCapabilities: BoundedAgentRuntimeCapabilities = { + primary: { + docker: 'supported', + gvisor: 'supported', + sbx: 'supported', + }, + enclave: { + docker: 'supported', + gvisor: 'supported', + sbx: 'blocked', + }, +}; + +const combinations = PRIMARY_BACKENDS.flatMap((primaryBackend) => + BOUNDED_AGENT_BACKENDS.map((boundedAgentBackend) => ({ primaryBackend, boundedAgentBackend }))); +const executableCombinations = combinations.filter(({ primaryBackend, boundedAgentBackend }) => + evaluateBoundedAgentRuntimeCombination(primaryBackend, boundedAgentBackend, deterministicCapabilities).supported); +const blockedCombinations = combinations.filter(({ primaryBackend, boundedAgentBackend }) => + !evaluateBoundedAgentRuntimeCombination(primaryBackend, boundedAgentBackend, deterministicCapabilities).supported); + +interface HarnessOptions { + maxInvocations?: number; + sensitivity?: 'public' | 'internal' | 'confidential'; + output?: string; + runnerResult?: { exitCode: number; timedOut: boolean }; + processingMs?: number; +} + +async function invoke( + broker: { handle: (request: unknown, respond: (json: string) => void) => Promise }, + request: unknown, +): Promise { + let response = ''; + await broker.handle(request, (json: string) => { + response = json; + }); + return response; +} + +function createHarness( + primaryBackend: BoundedAgentPrimaryBackend, + boundedAgentBackend: 'docker' | 'gvisor', + options: HarnessOptions = {}, +) { + const outputs = new Map(); + const launches: Array> = []; + const destroyed: string[] = []; + const telemetry: Array> = []; + let now = 0; + const sleeps: number[] = []; + const config = { + primaryBackend, + backend: boundedAgentBackend, + workDir: '/broker/private/work', + timeoutSeconds: 30, + maxInvocations: options.maxInvocations ?? 8, + maxTaskBytes: 4096, + }; + const workspace = { + createInvocationWorkspace: ({ + invocationId, + task, + }: { + invocationId: string; + task: string; + }) => { + expect(task).not.toMatch(/TOKEN|PASSWORD|docker\.sock|broker\/private/); + return { outPath: invocationId }; + }, + readEnclaveOutput: (outPath: string) => { + const output = outputs.get(outPath); + return output !== undefined && Buffer.byteLength(output) <= 8192 ? output : undefined; + }, + destroyInvocationWorkspace: (_workDir: string, invocationId: string) => { + destroyed.push(invocationId); + outputs.delete(invocationId); + }, + }; + const runner = { + runEnclaveContainer: async (params: Record) => { + launches.push(params); + now += options.processingMs ?? 0; + outputs.set(String(params.invocationId), options.output ?? 'true'); + return { + exitCode: options.runnerResult?.exitCode ?? 0, + timedOut: options.runnerResult?.timedOut ?? false, + }; + }, + }; + const broker = createBroker({ + config, + seedMap: new Map([ + ['octo/repo', { seedId: 'a'.repeat(32), sensitivity: options.sensitivity ?? 'internal' }], + ]), + runId: 'abcd1234', + audit: { invocation() {}, failure() {}, lifecycle() {} }, + telemetry: { emit: (event: Record) => telemetry.push(event) }, + workspace, + runner, + clock: { + nowMs: () => now, + sleep: async (ms: number) => { + sleeps.push(ms); + now += ms; + }, + }, + }); + return { broker, destroyed, launches, sleeps, telemetry }; +} + +describe('bounded-agent runtime conformance matrix', () => { + it('contains every independent primary/boundedAgent combination exactly once', () => { + expect(combinations).toHaveLength(9); + expect(new Set(combinations.map(({ primaryBackend, boundedAgentBackend }) => + `${primaryBackend}/${boundedAgentBackend}`)).size).toBe(9); + expect(executableCombinations).toHaveLength(6); + expect(blockedCombinations).toHaveLength(3); + }); + + it('supports every primary backend paired with docker/gvisor bounded agents, and blocks sbx bounded agents everywhere', () => { + const readyPairs = new Set(executableCombinations.map( + ({ primaryBackend, boundedAgentBackend }) => `${primaryBackend}/${boundedAgentBackend}`, + )); + for (const primaryBackend of PRIMARY_BACKENDS) { + expect(readyPairs.has(`${primaryBackend}/docker`)).toBe(true); + expect(readyPairs.has(`${primaryBackend}/gvisor`)).toBe(true); + expect(readyPairs.has(`${primaryBackend}/sbx`)).toBe(false); + } + }); + + it.each(blockedCombinations)( + '$primaryBackend primary + $boundedAgentBackend bounded agent fails closed at enclave preflight', + ({ primaryBackend, boundedAgentBackend }) => { + const result = evaluateBoundedAgentRuntimeCombination( + primaryBackend, + boundedAgentBackend, + deterministicCapabilities, + ); + expect(result).toEqual({ + primaryBackend, + boundedAgentBackend, + supported: false, + capabilityState: 'blocked', + blockedAt: 'enclave-preflight', + category: 'enclave-security-block', + }); + }, + ); + + it.each([ + ['gvisor', 'docker', 'primary-preflight', 'primary-runtime-unavailable'], + ['sbx', 'docker', 'primary-preflight', 'primary-runtime-unavailable'], + ['docker', 'gvisor', 'enclave-preflight', 'enclave-runtime-unavailable'], + ] as const)( + 'reports precise unavailable capability state for %s/%s', + (primaryBackend, boundedAgentBackend, blockedAt, category) => { + const capabilities: BoundedAgentRuntimeCapabilities = { + primary: { docker: 'supported', gvisor: 'unavailable', sbx: 'unavailable' }, + enclave: { docker: 'supported', gvisor: 'unavailable', sbx: 'blocked' }, + }; + expect(evaluateBoundedAgentRuntimeCombination(primaryBackend, boundedAgentBackend, capabilities)) + .toMatchObject({ supported: false, capabilityState: 'unavailable', blockedAt, category }); + }, + ); + + it('evaluates the full matrix via evaluateBoundedAgentRuntimeMatrix in the same order', () => { + const matrix = evaluateBoundedAgentRuntimeMatrix(deterministicCapabilities); + expect(matrix).toHaveLength(9); + expect(matrix).toEqual(combinations.map(({ primaryBackend, boundedAgentBackend }) => + evaluateBoundedAgentRuntimeCombination(primaryBackend, boundedAgentBackend, deterministicCapabilities))); + }); + + it.each(executableCombinations)( + '$primaryBackend primary + $boundedAgentBackend bounded agent satisfies the common behavioral contract', + async ({ primaryBackend, boundedAgentBackend }) => { + if (boundedAgentBackend === 'sbx') throw new Error('blocked sbx bounded-agent combination entered executable suite'); + + const successful = createHarness(primaryBackend, boundedAgentBackend, { processingMs: 50 }); + expect(await invoke(successful.broker, { + privateRepo: 'octo/repo', + schema: { type: 'boolean' }, + task: 'is this finite?', + })).toBe('{"status":"ok","result":true}'); + expect(successful.launches).toHaveLength(1); + expect(successful.destroyed).toHaveLength(1); + expect(successful.sleeps).toEqual([50]); + expect(successful.telemetry).toContainEqual({ + primaryBackend, + boundedAgentBackend, + lifecycleClass: 'invocation', + capabilityState: 'supported', + category: 'success', + }); + expect(successful.launches[0]).not.toHaveProperty('repo'); + expect(JSON.stringify(successful.launches[0])).not.toMatch(/TOKEN|PASSWORD|docker\.sock/); + + const wrongRepo = createHarness(primaryBackend, boundedAgentBackend); + expect(await invoke(wrongRepo.broker, { + privateRepo: 'octo/not-configured', + schema: { type: 'boolean' }, + task: 'must not launch', + })).toBe(CANONICAL_ERROR); + expect(wrongRepo.launches).toHaveLength(0); + + const capped = createHarness(primaryBackend, boundedAgentBackend, { maxInvocations: 1 }); + const request = { privateRepo: 'octo/repo', schema: { type: 'boolean' }, task: 'cap invocation' }; + expect(await invoke(capped.broker, request)).toBe('{"status":"ok","result":true}'); + expect(await invoke(capped.broker, request)).toBe(CANONICAL_ERROR); + expect(capped.launches).toHaveLength(1); + + for (const failure of [ + { output: '{malformed', runnerResult: undefined }, + { output: 'true', runnerResult: { exitCode: 137, timedOut: true } }, + { output: 'true', runnerResult: { exitCode: 1, timedOut: false } }, + ]) { + const failed = createHarness(primaryBackend, boundedAgentBackend, failure); + // eslint-disable-next-line no-await-in-loop + expect(await invoke(failed.broker, request)).toBe(CANONICAL_ERROR); + expect(failed.destroyed).toHaveLength(1); + } + }, + ); +}); + +describe('resolveBoundedAgentPrimaryBackend', () => { + it.each([ + [undefined, 'docker'], + ['docker', 'docker'], + ['gvisor', 'gvisor'], + ['runsc', 'gvisor'], + ['sbx', 'sbx'], + ['kata', 'docker'], + ] as const)('maps containerRuntime %s to primary backend %s', (containerRuntime, expected) => { + expect(resolveBoundedAgentPrimaryBackend(containerRuntime)).toBe(expected); + }); +}); + +describe('bounded-agent runtime telemetry', () => { + it('serializes only the five approved fields', () => { + const serialized = serializeBoundedAgentRuntimeTelemetry({ + primaryBackend: resolveBoundedAgentPrimaryBackend('runsc'), + boundedAgentBackend: 'docker' as BoundedAgentRuntime, + lifecycleClass: 'preflight', + capabilityState: 'supported', + category: 'ready', + }); + expect(JSON.parse(serialized)).toEqual({ + primaryBackend: 'gvisor', + boundedAgentBackend: 'docker', + lifecycleClass: 'preflight', + capabilityState: 'supported', + category: 'ready', + }); + }); + + it('persists exact-field records without content, paths, outputs, or credentials', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-bounded-agent-runtime-telemetry-')); + try { + const telemetry = createRuntimeTelemetry(root); + telemetry.emit({ + primaryBackend: 'sbx', + boundedAgentBackend: 'docker', + lifecycleClass: 'invocation', + capabilityState: 'supported', + category: 'timeout', + repo: 'must-be-ignored', + task: 'must-be-ignored', + output: 'must-be-ignored', + path: '/must-be-ignored', + token: 'must-be-ignored', + capability: 'must-be-ignored', + }); + const record = JSON.parse(fs.readFileSync(path.join(root, 'runtime-telemetry.jsonl'), 'utf8')); + expect(Object.keys(record)).toEqual([ + 'primaryBackend', + 'boundedAgentBackend', + 'lifecycleClass', + 'capabilityState', + 'category', + ]); + expect(JSON.stringify(record)).not.toContain('must-be-ignored'); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it('rejects an enclave-runtime value outside the fixed enum', () => { + const brokerRuntimeTelemetry = createRuntimeTelemetry( + fs.mkdtempSync(path.join(os.tmpdir(), 'awf-bounded-agent-runtime-telemetry-invalid-')), + ); + expect(() => brokerRuntimeTelemetry.emit({ + primaryBackend: 'docker', + boundedAgentBackend: 'firecracker', + lifecycleClass: 'invocation', + capabilityState: 'supported', + category: 'success', + })).toThrow(/Invalid bounded-agent telemetry/); + }); +}); diff --git a/src/bounded-agent/runtime-matrix.ts b/src/bounded-agent/runtime-matrix.ts new file mode 100644 index 000000000..59db0b879 --- /dev/null +++ b/src/bounded-agent/runtime-matrix.ts @@ -0,0 +1,116 @@ +import type { BoundedAgentRuntime } from '../types'; + +export const BOUNDED_AGENT_RUNTIME_BACKENDS = ['docker', 'gvisor', 'sbx'] as const; + +export type BoundedAgentPrimaryBackend = (typeof BOUNDED_AGENT_RUNTIME_BACKENDS)[number]; +export type BoundedAgentCapabilityState = 'supported' | 'unavailable' | 'blocked'; + +export interface BoundedAgentRuntimeCapabilities { + primary: Readonly>; + enclave: Readonly>; +} + +export interface BoundedAgentRuntimeCombination { + primaryBackend: BoundedAgentPrimaryBackend; + boundedAgentBackend: BoundedAgentRuntime; + supported: boolean; + capabilityState: BoundedAgentCapabilityState; + blockedAt?: 'primary-preflight' | 'enclave-preflight'; + category: 'ready' | 'primary-runtime-unavailable' | 'enclave-runtime-unavailable' | 'enclave-security-block'; +} + +export interface BoundedAgentRuntimeTelemetry { + primaryBackend: BoundedAgentPrimaryBackend; + boundedAgentBackend: BoundedAgentRuntime; + lifecycleClass: 'preflight' | 'startup' | 'invocation' | 'cleanup'; + capabilityState: BoundedAgentCapabilityState; + category: string; +} + +/** Maps AWF's execution setting to the independent primary-agent matrix axis. */ +export function resolveBoundedAgentPrimaryBackend( + containerRuntime: string | undefined, +): BoundedAgentPrimaryBackend { + if (containerRuntime === 'gvisor' || containerRuntime === 'runsc') return 'gvisor'; + if (containerRuntime === 'sbx') return 'sbx'; + return 'docker'; +} + +/** + * Evaluates one primary/enclave pair without fallback. + * + * Primary availability is checked first because the primary agent cannot be + * started without it. Enclave availability is then checked before any + * repository staging. A blocked enclave capability is distinct from an + * unavailable binary: it means the runtime exists but cannot enforce AWF's + * mandatory isolation and API-proxy-only network controls. + * + * All nine (primary x boundedAgent) combinations are evaluated independently: + * a supported primary backend never implies a supported enclave backend, and + * vice versa. + */ +export function evaluateBoundedAgentRuntimeCombination( + primaryBackend: BoundedAgentPrimaryBackend, + boundedAgentBackend: BoundedAgentRuntime, + capabilities: BoundedAgentRuntimeCapabilities, +): BoundedAgentRuntimeCombination { + const primaryState = capabilities.primary[primaryBackend]; + if (primaryState !== 'supported') { + return { + primaryBackend, + boundedAgentBackend, + supported: false, + capabilityState: primaryState, + blockedAt: 'primary-preflight', + category: 'primary-runtime-unavailable', + }; + } + + const enclaveState = capabilities.enclave[boundedAgentBackend]; + if (enclaveState !== 'supported') { + return { + primaryBackend, + boundedAgentBackend, + supported: false, + capabilityState: enclaveState, + blockedAt: 'enclave-preflight', + category: enclaveState === 'blocked' ? 'enclave-security-block' : 'enclave-runtime-unavailable', + }; + } + + return { + primaryBackend, + boundedAgentBackend, + supported: true, + capabilityState: 'supported', + category: 'ready', + }; +} + +/** Evaluates every (primary x boundedAgent) combination independently. */ +export function evaluateBoundedAgentRuntimeMatrix( + capabilities: BoundedAgentRuntimeCapabilities, +): BoundedAgentRuntimeCombination[] { + const combinations: BoundedAgentRuntimeCombination[] = []; + for (const primaryBackend of BOUNDED_AGENT_RUNTIME_BACKENDS) { + for (const boundedAgentBackend of BOUNDED_AGENT_RUNTIME_BACKENDS) { + combinations.push( + evaluateBoundedAgentRuntimeCombination(primaryBackend, boundedAgentBackend, capabilities), + ); + } + } + return combinations; +} + +/** Serializes the intentionally narrow, path- and content-free telemetry shape. */ +export function serializeBoundedAgentRuntimeTelemetry( + event: BoundedAgentRuntimeTelemetry, +): string { + return JSON.stringify({ + primaryBackend: event.primaryBackend, + boundedAgentBackend: event.boundedAgentBackend, + lifecycleClass: event.lifecycleClass, + capabilityState: event.capabilityState, + category: event.category, + }); +} diff --git a/src/bounded-agent/sbx-capability.test.ts b/src/bounded-agent/sbx-capability.test.ts new file mode 100644 index 000000000..26d51f041 --- /dev/null +++ b/src/bounded-agent/sbx-capability.test.ts @@ -0,0 +1,156 @@ +/* eslint-disable @typescript-eslint/no-require-imports -- container-side broker + module is loaded at runtime for a byte-for-byte cross-check; it is a plain + .js file with no TS types, so `require()` is the correct (and only) way to + pull it in, matching the pattern used by src/bounded-query/*.test.ts. */ +import execa from 'execa'; +import path from 'path'; +import { + boundedAgentSbxCapabilityTestHelpers as helpers, + defaultBoundedAgentSbxCapabilityQuery, +} from './sbx-capability'; + +jest.mock('execa', () => ({ __esModule: true, default: jest.fn() })); +const mockExeca = execa as unknown as jest.Mock; + +/** + * Host-side capability probe coverage for the bounded-agent sbx enclave + * backend. + * + * This backend has a strictly harder network requirement than bounded + * queries: an enclave must reach exactly one peer (the API proxy), not + * "no network at all". So `missing` always includes the pinned-template and + * lateral-peer-denial entries regardless of what flags are detected — the + * probe can never report `supported: true` for the currently audited sbx + * 0.37.1 CLI, by design. + */ +describe('defaultBoundedAgentSbxCapabilityQuery', () => { + beforeEach(() => { + mockExeca.mockReset(); + }); + + it('never reports supported even when every flag is present, because the network primitive is unverifiable', async () => { + mockExeca + .mockResolvedValueOnce({ exitCode: 0, stdout: 'Docker Sandboxes v0.37.1' }) // version + .mockResolvedValueOnce({ exitCode: 0, stdout: '[]' }) // ls (daemon reachability) + .mockResolvedValueOnce({ + exitCode: 0, + stdout: '--name --cpus --memory --template --pids-limit --disk-limit --ulimit-fsize --mount-target', + }) // create --help + .mockResolvedValueOnce({ exitCode: 0, stdout: '--user --workdir' }); // exec --help + + const report = await defaultBoundedAgentSbxCapabilityQuery(); + + expect(report.supported).toBe(false); + expect(report.version).toBe('0.37.1'); + expect(report.auditedVersion).toBe('0.37.1'); + expect(report.missing).toContain('pinned AWF bounded-agent sbx template and bootstrap'); + expect(report.missing).toContain( + 'sbx named-network attach with mandatory lateral-peer denial to enforce API-proxy-only egress ' + + '(hard network-policy / capability-token ingress primitive)', + ); + // Every enumerated flag was detected, so nothing else should be missing. + expect(report.missing).not.toContain('sbx create --network'); + expect(report.missing).not.toContain('authenticated sbx CLI/daemon'); + }); + + it('reports every missing lifecycle/resource flag when help output lacks them', async () => { + mockExeca + .mockResolvedValueOnce({ exitCode: 0, stdout: 'Docker Sandboxes v0.37.1' }) + .mockResolvedValueOnce({ exitCode: 0, stdout: '[]' }) + .mockResolvedValueOnce({ exitCode: 0, stdout: '--name --cpus --memory --template' }) + .mockResolvedValueOnce({ exitCode: 0, stdout: '--user --workdir' }); + + const report = await defaultBoundedAgentSbxCapabilityQuery(); + + expect(report.supported).toBe(false); + expect(report.missing).toEqual(expect.arrayContaining([ + 'pinned AWF bounded-agent sbx template and bootstrap', + 'sbx create --pids-limit', + 'sbx create --disk-limit', + 'sbx create --ulimit-fsize', + 'sbx create --mount-target', + ])); + }); + + it('reports an unsupported audited version distinctly from missing flags', async () => { + mockExeca + .mockResolvedValueOnce({ exitCode: 0, stdout: 'Docker Sandboxes v0.99.0' }) + .mockResolvedValueOnce({ exitCode: 0, stdout: '[]' }) + .mockResolvedValueOnce({ + exitCode: 0, + stdout: '--name --cpus --memory --template --pids-limit --disk-limit --ulimit-fsize --mount-target', + }) + .mockResolvedValueOnce({ exitCode: 0, stdout: '--user --workdir' }); + + const report = await defaultBoundedAgentSbxCapabilityQuery(); + expect(report.missing).toContain('audited sbx version 0.37.1 (found 0.99.0)'); + }); + + it('reports an unauthenticated or unreachable daemon', async () => { + mockExeca + .mockResolvedValueOnce({ exitCode: 0, stdout: 'Docker Sandboxes v0.37.1' }) + .mockResolvedValueOnce({ exitCode: 1, stdout: '' }) // ls fails: daemon unreachable/unauthenticated + .mockResolvedValueOnce({ exitCode: 0, stdout: '--name --cpus --memory --template' }) + .mockResolvedValueOnce({ exitCode: 0, stdout: '--user --workdir' }); + + const report = await defaultBoundedAgentSbxCapabilityQuery(); + expect(report.missing).toContain('authenticated sbx CLI/daemon'); + }); + + it('fails closed when the sbx binary is entirely absent', async () => { + mockExeca.mockRejectedValue(new Error('spawn sbx ENOENT')); + const report = await defaultBoundedAgentSbxCapabilityQuery(); + expect(report).toEqual({ + supported: false, + auditedVersion: '0.37.1', + missing: ['authenticated sbx CLI/daemon'], + }); + }); + + it('never uses request-scoped or credential-bearing environment beyond the process env', async () => { + mockExeca.mockResolvedValue({ exitCode: 0, stdout: '' }); + await defaultBoundedAgentSbxCapabilityQuery(); + for (const call of mockExeca.mock.calls) { + const options = call[2] as { env?: Record } | undefined; + expect(options?.env).not.toHaveProperty('DOCKER_SANDBOXES_PROXY'); + expect(options?.env).not.toHaveProperty('XDG_CONFIG_HOME'); + } + }); + + it('keeps the required-flag lists byte-for-byte aligned with the container-side probe', () => { + const containerProbe = require(path.join( + __dirname, + '..', + '..', + 'containers', + 'bounded-agent', + 'broker', + 'sbx-capability-probe.js', + )); + expect(helpers.SBX_AUDITED_VERSION).toBe(containerProbe.AUDITED_SBX_VERSION); + // The host-side probe collapses REQUIRED_CREATE_FLAGS and + // REQUIRED_HARD_ISOLATION_FLAGS into one list (it stops before staging + // rather than launching, so it has no reason to distinguish lifecycle + // flags from hard-isolation flags), except `--network`: host-side never + // treats its presence as informative, because the unconditional + // lateral-peer-denial entry already reports the network requirement + // missing regardless of flag detection — checking the flag too would + // only invite a false sense of partial progress. + const containerHardIsolationWithoutNetwork = containerProbe.REQUIRED_HARD_ISOLATION_FLAGS + .filter((flag: string) => flag !== '--network'); + expect(new Set(helpers.SBX_REQUIRED_CREATE_FLAGS)).toEqual(new Set([ + ...containerProbe.REQUIRED_CREATE_FLAGS, + ...containerHardIsolationWithoutNetwork, + ])); + expect(helpers.SBX_REQUIRED_EXEC_FLAGS).toEqual(containerProbe.REQUIRED_EXEC_FLAGS); + }); +}); + +describe('helpIncludesFlag', () => { + it('matches a flag as a standalone token, not a substring of another flag', () => { + expect(helpers.helpIncludesFlag('--network, --network-mode', '--network')).toBe(true); + expect(helpers.helpIncludesFlag('--network-mode', '--network')).toBe(false); + expect(helpers.helpIncludesFlag(' --cpus= Number of vCPUs', '--cpus')).toBe(true); + expect(helpers.helpIncludesFlag('no matching flags here', '--cpus')).toBe(false); + }); +}); diff --git a/src/bounded-agent/sbx-capability.ts b/src/bounded-agent/sbx-capability.ts new file mode 100644 index 000000000..780fab629 --- /dev/null +++ b/src/bounded-agent/sbx-capability.ts @@ -0,0 +1,134 @@ +import execa from 'execa'; + +/** + * Host-side capability probe for the bounded-agent `sbx` enclave runtime. + * + * This is deliberately its own module (not a re-export of the bounded-query + * probe) because bounded agents have a strictly harder requirement: a bounded + * *query* sandbox needs `--network=none` (no egress at all), while a bounded + * *agent* enclave must reach exactly one peer — the dedicated, API-proxy-only + * enclave network — and nothing else. sbx has no primitive that can attach a + * sandbox to a named Docker network while also enforcing that no other peer + * on that network (or the internet) is reachable, so that requirement is + * always reported missing below rather than inferred from a flag that would + * only prove the weaker no-network case. + */ + +const SBX_AUDITED_VERSION = '0.37.1'; + +/** Flags proven by `sbx create --help` inspection. */ +const SBX_REQUIRED_CREATE_FLAGS = [ + '--cpus', + '--memory', + '--name', + '--template', + '--pids-limit', + '--disk-limit', + '--ulimit-fsize', + '--mount-target', +] as const; + +/** Flags proven by `sbx exec --help` inspection. */ +const SBX_REQUIRED_EXEC_FLAGS = ['--user', '--workdir'] as const; + +export interface BoundedAgentSbxCapabilityReport { + supported: boolean; + version?: string; + auditedVersion: string; + missing: string[]; +} + +/** Executes the minimum host-side capability proof for the sbx enclave backend. */ +export type BoundedAgentSbxCapabilityQuery = () => Promise; + +function helpIncludesFlag(help: string, flag: string): boolean { + const escaped = flag.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return new RegExp(`(^|[\\s,])${escaped}(?=([=\\s,]|$))`, 'm').test(help); +} + +/** + * Probes the installed `sbx` CLI for every capability the bounded-agent + * enclave requires: lifecycle (create/exec/stop/rm), read-only targeted + * mounts, unprivileged exec identity/workdir, resource and storage limits, + * and — the category current sbx cannot satisfy — a hard, API-proxy-only + * network-isolation primitive with mandatory lateral-peer denial. + * + * Help/version output alone never marks the runtime supported: every + * unconditional architectural gap below is always reported so a future sbx + * release cannot be silently treated as capable of this feature by CLI-flag + * drift alone. + */ +export const defaultBoundedAgentSbxCapabilityQuery: BoundedAgentSbxCapabilityQuery = async () => { + const managementEnv = { ...process.env }; + delete managementEnv.DOCKER_SANDBOXES_PROXY; + delete managementEnv.XDG_CONFIG_HOME; + + const run = async (args: string[]): Promise<{ exitCode: number; stdout: string }> => { + const result = await execa('sbx', args, { + reject: false, + timeout: 10_000, + env: managementEnv, + }); + return { exitCode: result.exitCode ?? 1, stdout: result.stdout }; + }; + + let versionResult: { exitCode: number; stdout: string }; + let daemonResult: { exitCode: number; stdout: string }; + let createHelp: { exitCode: number; stdout: string }; + let execHelp: { exitCode: number; stdout: string }; + try { + [versionResult, daemonResult, createHelp, execHelp] = await Promise.all([ + run(['version']), + // sbx has no auth-status command; listing is authenticated and non-mutating. + run(['ls']), + run(['create', '--help']), + run(['exec', '--help']), + ]); + } catch { + return { + supported: false, + auditedVersion: SBX_AUDITED_VERSION, + missing: ['authenticated sbx CLI/daemon'], + }; + } + + const version = /\bv?(\d+\.\d+\.\d+)\b/.exec(versionResult.stdout)?.[1]; + const missing: string[] = [ + // AWF has not published the immutable, AWF-authored enclave template and + // bootstrap for sbx because current sbx cannot yet enforce the network + // primitive below — publishing one would imply a false capability claim. + 'pinned AWF bounded-agent sbx template and bootstrap', + // sbx v0.37.1 has no primitive that attaches a sandbox to a named network + // while denying every peer except one configured endpoint. Local + // HTTP_PROXY / org-level network policy is advisory, not a hard control, + // and organization governance can replace it — so it never counts here. + 'sbx named-network attach with mandatory lateral-peer denial to enforce ' + + 'API-proxy-only egress (hard network-policy / capability-token ingress primitive)', + ]; + if (versionResult.exitCode !== 0 || !version || daemonResult.exitCode !== 0) { + missing.push('authenticated sbx CLI/daemon'); + } + if (version && version !== SBX_AUDITED_VERSION) { + missing.push(`audited sbx version ${SBX_AUDITED_VERSION} (found ${version})`); + } + for (const flag of SBX_REQUIRED_CREATE_FLAGS) { + if (createHelp.exitCode !== 0 || !helpIncludesFlag(createHelp.stdout, flag)) { + missing.push(`sbx create ${flag}`); + } + } + for (const flag of SBX_REQUIRED_EXEC_FLAGS) { + if (execHelp.exitCode !== 0 || !helpIncludesFlag(execHelp.stdout, flag)) { + missing.push(`sbx exec ${flag}`); + } + } + return { supported: missing.length === 0, version, auditedVersion: SBX_AUDITED_VERSION, missing }; +}; + +/** @internal Exported for focused unit tests. */ +// ts-prune-ignore-next +export const boundedAgentSbxCapabilityTestHelpers = { + SBX_AUDITED_VERSION, + SBX_REQUIRED_CREATE_FLAGS, + SBX_REQUIRED_EXEC_FLAGS, + helpIncludesFlag, +}; diff --git a/src/bounded-agent/sbx-enclave-runner.test.ts b/src/bounded-agent/sbx-enclave-runner.test.ts new file mode 100644 index 000000000..50bc5f6c9 --- /dev/null +++ b/src/bounded-agent/sbx-enclave-runner.test.ts @@ -0,0 +1,457 @@ +import * as path from 'path'; + +/* eslint-disable @typescript-eslint/no-require-imports */ +const brokerDir = path.join(__dirname, '..', '..', 'containers', 'bounded-agent', 'broker'); +const { SbxEnclaveRunner, parseSandboxNames } = require(path.join(brokerDir, 'sbx-enclave-runner.js')); +const { + deriveSbxEnclaveSpec, + SBX_ENCLAVE_TEMPLATE, + REQUIRED_HARD_ISOLATION_FLAGS, +} = require(path.join(brokerDir, 'sbx-enclave-runner-spec.js')); +/* eslint-enable @typescript-eslint/no-require-imports */ + +/** + * Security-critical contract tests for the bounded-agent sbx enclave runner. + * + * This mirrors the coverage bounded queries already have for their sbx + * backend (`src/bounded-query/query-runner.test.ts:158-305`): a fixed launch + * specification derived only from trusted identifiers, capability rejection, + * trusted-ID validation, create/exec timeout accounting, prefix-scoped + * reconciliation, malformed-inventory rejection, and guaranteed stop/remove + * cleanup — including when cleanup itself fails. + */ + +interface SbxResult { + exitCode: number; + timedOut: boolean; + stdout: string; + stderr: string; +} + +const ok = (overrides: Partial = {}): SbxResult => ({ + exitCode: 0, + timedOut: false, + stdout: '', + stderr: '', + ...overrides, +}); + +const config = { + sbxWorkDir: '/sbx-daemon/private/work', + sbxSeedsDir: '/sbx-daemon/private/seeds', + enclaveSeedPath: '/awf/seed', + enclaveTaskPath: '/awf/task.txt', + enclaveSchemaPath: '/awf/schema.json', + enclaveMountDir: '/agent', + enclaveUid: 65534, + enclaveGid: 65534, + cpuLimit: '1', + memoryLimit: '512m', + network: 'awf-bounded-agent', + pidsLimit: 128, + tmpfsLimit: '64m', + timeoutSeconds: 120, +}; + +const RUN_ID = 'abcd1234abcd1234abcd1234abcd1234'; +const INVOCATION_ID = '111111111111111111111111'; +const SEED_ID = 'a'.repeat(32); + +type SbxHandler = (args: readonly string[], timeoutMs: number) => SbxResult | Promise; + +function createSbx(handler: SbxHandler = () => ok()) { + const calls: string[][] = []; + const timeouts: number[] = []; + return { + calls, + timeouts, + client: { + runSbx: async (args: readonly string[], timeoutMs: number) => { + calls.push([...args]); + timeouts.push(timeoutMs); + return handler(args, timeoutMs); + }, + }, + }; +} + +function createFiles() { + const created: string[] = []; + return { + created, + files: { + mkdirSync: (target: string) => { + created.push(target); + }, + }, + }; +} + +const availableProbe = async () => ({ supported: true, missing: [] }); + +describe('bounded-agent sbx enclave runner contract', () => { + describe('deriveSbxEnclaveSpec: fixed spec derived only from trusted identifiers', () => { + it('derives a frozen, unique-per-invocation launch specification', () => { + const first = deriveSbxEnclaveSpec({ + config, runId: RUN_ID, invocationId: INVOCATION_ID, seedId: SEED_ID, + }); + const second = deriveSbxEnclaveSpec({ + config, runId: RUN_ID, invocationId: '222222222222222222222222', seedId: SEED_ID, + }); + + expect(Object.isFrozen(first)).toBe(true); + expect(Object.isFrozen(first.createArgs)).toBe(true); + expect(Object.isFrozen(first.execArgs)).toBe(true); + expect(first.sandboxName).not.toBe(second.sandboxName); + expect(first.runPrefix).toBe(`awf-bounded-agent-sbx-${RUN_ID}-`); + expect(first.sandboxName).toBe(`${first.runPrefix}${INVOCATION_ID}`); + expect(first.createArgs).toContain(SBX_ENCLAVE_TEMPLATE); + for (const flag of REQUIRED_HARD_ISOLATION_FLAGS) { + expect(first.createArgs).toContain(flag); + } + expect(first.createArgs.join(' ')).toContain( + `${config.sbxSeedsDir}/${SEED_ID}:${config.enclaveSeedPath}:ro`, + ); + expect(first.createArgs.join(' ')).toContain(`${config.sbxWorkDir}/${INVOCATION_ID}/task.txt`); + expect(first.createArgs.join(' ')).toContain(`${config.sbxWorkDir}/${INVOCATION_ID}/schema.json`); + expect(first.execArgs).toContain(`${config.enclaveUid}:${config.enclaveGid}`); + expect(first.execArgs).toContain(config.enclaveMountDir); + expect(first.execArgs).toContain(first.sandboxName); + expect(first.execArgs.slice(-1)).toEqual(['/usr/local/bin/run-bounded-agent']); + expect(first.stopArgs).toEqual(['stop', first.sandboxName]); + expect(first.removeArgs).toEqual(['rm', '--force', first.sandboxName]); + expect(first.listArgs).toEqual(['ls', '--json']); + }); + + it.each([ + ['runId', { runId: 'not-hex', invocationId: INVOCATION_ID, seedId: SEED_ID }, /runId/], + ['invocationId', { runId: RUN_ID, invocationId: 'short', seedId: SEED_ID }, /invocationId/], + ['seedId', { runId: RUN_ID, invocationId: INVOCATION_ID, seedId: 'zz' }, /seedId/], + ['runId with injection', { + runId: `${RUN_ID}; rm -rf /`, invocationId: INVOCATION_ID, seedId: SEED_ID, + }, /runId/], + ])('rejects a malformed or untrusted %s', (_name, params, message) => { + expect(() => deriveSbxEnclaveSpec({ config, ...params })).toThrow(message); + }); + }); + + describe('assertAvailable: capability rejection', () => { + it('blocks the audited sbx CLI and reports every missing capability', async () => { + const missing = ['pinned AWF bounded-agent sbx template and bootstrap', 'sbx create --network']; + const runner = new SbxEnclaveRunner(config, { + probe: async () => ({ supported: false, missing }), + }); + + await expect(runner.assertAvailable()).rejects.toThrow(/blocked.*No fallback/s); + await expect(runner.assertAvailable()).rejects.toThrow( + 'pinned AWF bounded-agent sbx template and bootstrap', + ); + await expect(runner.assertAvailable()).rejects.toThrow('sbx create --network'); + }); + + it('never launches when the probe throws instead of returning a report', async () => { + const runner = new SbxEnclaveRunner(config, { + probe: async () => { + throw new Error('sbx CLI not found'); + }, + }); + await expect(runner.assertAvailable()).rejects.toThrow('sbx CLI not found'); + }); + }); + + describe('runEnclaveContainer: create/exec timeout accounting', () => { + it('runs exec with the remaining budget after a successful create', async () => { + let now = 0; + const { calls, timeouts, client } = createSbx((args) => { + if (args[0] === 'create') { + now += 10_000; // simulate elapsed wall-clock time during create + } + return ok(); + }); + const { files } = createFiles(); + const runner = new SbxEnclaveRunner(config, { + sbx: client, + probe: availableProbe, + files, + nowMs: () => now, + }); + + const result = await runner.runEnclaveContainer({ + runId: RUN_ID, + invocationId: INVOCATION_ID, + seedId: SEED_ID, + timeoutMs: 60_000, + }); + + expect(result).toEqual({ exitCode: 0, timedOut: false }); + const createIndex = calls.findIndex((call) => call[0] === 'create'); + const execIndex = calls.findIndex((call) => call[0] === 'exec'); + expect(createIndex).toBeGreaterThanOrEqual(0); + expect(execIndex).toBeGreaterThan(createIndex); + const orderedTimeouts = [...timeouts]; + const [createTimeoutMs, execTimeoutMs] = createIndex < execIndex + ? [orderedTimeouts[createIndex], orderedTimeouts[execIndex]] + : [orderedTimeouts[execIndex], orderedTimeouts[createIndex]]; + // create is capped at 120s even though the full budget (60s + grace) is larger. + expect(createTimeoutMs).toBeLessThanOrEqual(120_000); + // exec receives the budget remaining after create's simulated 10s elapsed. + expect(execTimeoutMs).toBeLessThanOrEqual(60_000 + 15_000); + expect(execTimeoutMs).toBeLessThan(createTimeoutMs); + }); + + it('returns a timed-out result and never execs when create itself times out', async () => { + const { calls, client } = createSbx((args) => ( + args[0] === 'create' ? ok({ timedOut: true, exitCode: 124 }) : ok() + )); + const { files } = createFiles(); + const runner = new SbxEnclaveRunner(config, { sbx: client, probe: availableProbe, files }); + + const result = await runner.runEnclaveContainer({ + runId: RUN_ID, + invocationId: INVOCATION_ID, + seedId: SEED_ID, + timeoutMs: 1_000, + }); + + expect(result).toEqual({ exitCode: 124, timedOut: true }); + expect(calls.some((call) => call[0] === 'exec')).toBe(false); + // Cleanup still runs deterministically after a create timeout. + expect(calls).toContainEqual(['stop', `awf-bounded-agent-sbx-${RUN_ID}-${INVOCATION_ID}`]); + expect(calls).toContainEqual(['rm', '--force', `awf-bounded-agent-sbx-${RUN_ID}-${INVOCATION_ID}`]); + }); + + it('skips exec and reports a synthetic timeout when the deadline elapses between create and exec', async () => { + let now = 0; + const { calls, client } = createSbx((args) => { + if (args[0] === 'create') { + now += 1_000_000; // blow through the deadline entirely during create + } + return ok(); + }); + const { files } = createFiles(); + const runner = new SbxEnclaveRunner(config, { + sbx: client, probe: availableProbe, files, nowMs: () => now, + }); + + const result = await runner.runEnclaveContainer({ + runId: RUN_ID, + invocationId: INVOCATION_ID, + seedId: SEED_ID, + timeoutMs: 1_000, + }); + + expect(result).toEqual({ exitCode: 124, timedOut: true }); + expect(calls.some((call) => call[0] === 'exec')).toBe(false); + }); + + it('throws and still cleans up when create fails outright', async () => { + const { calls, client } = createSbx((args) => ( + args[0] === 'create' ? ok({ exitCode: 1 }) : ok() + )); + const { files } = createFiles(); + const runner = new SbxEnclaveRunner(config, { sbx: client, probe: availableProbe, files }); + + await expect(runner.runEnclaveContainer({ + runId: RUN_ID, + invocationId: INVOCATION_ID, + seedId: SEED_ID, + })).rejects.toThrow('Failed to create bounded-agent sbx VM'); + expect(calls.some((call) => call[0] === 'exec')).toBe(false); + expect(calls).toContainEqual(['stop', `awf-bounded-agent-sbx-${RUN_ID}-${INVOCATION_ID}`]); + expect(calls).toContainEqual(['rm', '--force', `awf-bounded-agent-sbx-${RUN_ID}-${INVOCATION_ID}`]); + }); + }); + + describe('deterministic stop/rm cleanup, including cleanup failures', () => { + it('always force-removes the uniquely named VM before returning a success', async () => { + const { calls, client } = createSbx((args) => ( + args[0] === 'ls' && args[1] === '--quiet' ? ok({ stdout: '' }) : ok() + )); + const { files } = createFiles(); + const runner = new SbxEnclaveRunner(config, { sbx: client, probe: availableProbe, files }); + + await expect(runner.runEnclaveContainer({ + runId: RUN_ID, + invocationId: INVOCATION_ID, + seedId: SEED_ID, + })).resolves.toEqual({ exitCode: 0, timedOut: false }); + + const name = `awf-bounded-agent-sbx-${RUN_ID}-${INVOCATION_ID}`; + expect(calls.find((args) => args[0] === 'create')).toContain(name); + expect(calls.find((args) => args[0] === 'exec')).toContain(name); + expect(calls).toContainEqual(['stop', name]); + expect(calls).toContainEqual(['rm', '--force', name]); + expect(calls[calls.length - 1]).toEqual(['rm', '--force', name]); + }); + + it('preserves a successful result when stop fails but inventory confirms the VM is already gone', async () => { + const { calls, client } = createSbx((args) => { + if (args[0] === 'stop') return ok({ exitCode: 1 }); + if (args[0] === 'ls' && args[1] === '--quiet') return ok({ stdout: '' }); + return ok(); + }); + const { files } = createFiles(); + const runner = new SbxEnclaveRunner(config, { sbx: client, probe: availableProbe, files }); + + await expect(runner.runEnclaveContainer({ + runId: RUN_ID, + invocationId: INVOCATION_ID, + seedId: SEED_ID, + })).resolves.toEqual({ exitCode: 0, timedOut: false }); + const name = `awf-bounded-agent-sbx-${RUN_ID}-${INVOCATION_ID}`; + expect(calls).toContainEqual(['rm', '--force', name]); + }); + + it('fails closed when stop fails and inventory still lists the VM', async () => { + const name = `awf-bounded-agent-sbx-${RUN_ID}-${INVOCATION_ID}`; + const { client } = createSbx((args) => { + if (args[0] === 'stop') return ok({ exitCode: 1 }); + if (args[0] === 'ls' && args[1] === '--quiet') return ok({ stdout: `${name}\n` }); + return ok(); + }); + const { files } = createFiles(); + const runner = new SbxEnclaveRunner(config, { sbx: client, probe: availableProbe, files }); + + await expect(runner.runEnclaveContainer({ + runId: RUN_ID, + invocationId: INVOCATION_ID, + seedId: SEED_ID, + })).rejects.toThrow('Failed to stop bounded-agent sbx VM'); + }); + + it('fails closed when remove fails after a successful stop', async () => { + const { client } = createSbx((args) => (args[0] === 'rm' ? ok({ exitCode: 1 }) : ok())); + const { files } = createFiles(); + const runner = new SbxEnclaveRunner(config, { sbx: client, probe: availableProbe, files }); + + await expect(runner.runEnclaveContainer({ + runId: RUN_ID, + invocationId: INVOCATION_ID, + seedId: SEED_ID, + })).rejects.toThrow('Failed to remove bounded-agent sbx VM'); + }); + + it('surfaces the cleanup failure even when the run itself also failed (cleanup takes priority)', async () => { + const { client } = createSbx((args) => { + if (args[0] === 'create') return ok({ exitCode: 1 }); + if (args[0] === 'rm') return ok({ exitCode: 1 }); + return ok(); + }); + const { files } = createFiles(); + const runner = new SbxEnclaveRunner(config, { sbx: client, probe: availableProbe, files }); + + await expect(runner.runEnclaveContainer({ + runId: RUN_ID, + invocationId: INVOCATION_ID, + seedId: SEED_ID, + })).rejects.toThrow('Failed to remove bounded-agent sbx VM'); + }); + + it('serializes interruption reconciliation with per-invocation cleanup', async () => { + const events: string[] = []; + let releaseStop: (() => void) | undefined; + const stopGate = new Promise((resolve) => { + releaseStop = resolve; + }); + let stopCount = 0; + const { client } = createSbx(async (args) => { + if (args[0] === 'stop') { + stopCount += 1; + const label = `stop-${stopCount}`; + events.push(`${label}-start`); + if (stopCount === 1) await stopGate; + events.push(`${label}-end`); + return ok(); + } + if (args[0] === 'ls' && args[1] === '--json') { + events.push('reconcile-list'); + return ok({ stdout: '[]' }); + } + return ok(); + }); + const runner = new SbxEnclaveRunner(config, { sbx: client, probe: availableProbe }); + + const invocationCleanup = runner.cleanupInvocation(RUN_ID, INVOCATION_ID); + const reconciliation = runner.reconcileRun(RUN_ID); + await Promise.resolve(); + await Promise.resolve(); + expect(events).toEqual(['stop-1-start']); + releaseStop?.(); + await Promise.all([invocationCleanup, reconciliation]); + expect(events).toEqual(['stop-1-start', 'stop-1-end', 'reconcile-list']); + }); + }); + + describe('reconcileRun: prefix-scoped reconciliation', () => { + it('reconciles only sbx VMs with the current trusted run prefix', async () => { + const staleName = `awf-bounded-agent-sbx-${RUN_ID}-222222222222222222222222`; + const { calls, client } = createSbx((args) => { + if (args[0] === 'ls' && args[1] === '--json') { + return ok({ + stdout: JSON.stringify([ + { name: staleName }, + { name: 'awf-bounded-agent-sbx-other-run-333333333333333333333333' }, + { name: 'awf-query-sbx-primary' }, + ]), + }); + } + return ok(); + }); + const runner = new SbxEnclaveRunner(config, { sbx: client, probe: availableProbe }); + + await runner.reconcileRun(RUN_ID); + + expect(calls).toContainEqual(['stop', staleName]); + expect(calls).toContainEqual(['rm', '--force', staleName]); + expect(calls.join(' ')).not.toContain('other-run'); + expect(calls.join(' ')).not.toContain('awf-query-sbx-primary'); + }); + + it('removes nothing when no VM in inventory matches this run prefix', async () => { + const { calls, client } = createSbx((args) => ( + args[0] === 'ls' && args[1] === '--json' + ? ok({ stdout: JSON.stringify([{ name: 'awf-bounded-agent-sbx-unrelated-000000000000000000000000' }]) }) + : ok() + )); + const runner = new SbxEnclaveRunner(config, { sbx: client, probe: availableProbe }); + + await runner.reconcileRun(RUN_ID); + + expect(calls.some((call) => call[0] === 'stop' || call[0] === 'rm')).toBe(false); + }); + + it('fails closed when listing sandboxes itself fails', async () => { + const { client } = createSbx((args) => ( + args[0] === 'ls' && args[1] === '--json' ? ok({ exitCode: 1 }) : ok() + )); + const runner = new SbxEnclaveRunner(config, { sbx: client, probe: availableProbe }); + + await expect(runner.reconcileRun(RUN_ID)).rejects.toThrow('Failed to reconcile bounded-agent sbx VMs'); + }); + }); + + describe('parseSandboxNames: malformed inventory rejection', () => { + it('rejects non-JSON, non-array, and shell-metacharacter-bearing inventory', () => { + expect(() => parseSandboxNames('not json')).toThrow(/malformed sandbox inventory/); + expect(() => parseSandboxNames('{"name":"x"}')).toThrow(/malformed sandbox inventory/); + expect(() => parseSandboxNames('[{"name":"--all"}]')).toThrow(/invalid sandbox name/); + expect(() => parseSandboxNames('[{"name":"; rm -rf /"}]')).toThrow(/invalid sandbox name/); + expect(() => parseSandboxNames('[{}]')).toThrow(/invalid sandbox name/); + }); + + it('accepts a well-formed sandbox name list', () => { + expect(parseSandboxNames('[{"name":"awf-bounded-agent-sbx-abc-123"}]')).toEqual([ + 'awf-bounded-agent-sbx-abc-123', + ]); + }); + + it('rejects malformed sbx inventory rather than accepting cleanup injection', async () => { + const { client } = createSbx((args) => ( + args[0] === 'ls' ? ok({ stdout: '[{"name":"--all"}]' }) : ok() + )); + const runner = new SbxEnclaveRunner(config, { sbx: client, probe: availableProbe }); + + await expect(runner.reconcileRun(RUN_ID)).rejects.toThrow(/invalid sandbox name/); + }); + }); +}); diff --git a/src/bounded-agent/sbx-runner.test.ts b/src/bounded-agent/sbx-runner.test.ts new file mode 100644 index 000000000..cfa20338f --- /dev/null +++ b/src/bounded-agent/sbx-runner.test.ts @@ -0,0 +1,169 @@ +import * as path from 'path'; + +/* eslint-disable @typescript-eslint/no-require-imports */ +const brokerDir = path.join(__dirname, '..', '..', 'containers', 'bounded-agent', 'broker'); +const { SbxEnclaveRunner, parseSandboxNames } = require(path.join(brokerDir, 'sbx-enclave-runner.js')); +const { + SBX_ENCLAVE_TEMPLATE, + REQUIRED_HARD_ISOLATION_FLAGS, + deriveSbxEnclaveSpec, +} = require(path.join(brokerDir, 'sbx-enclave-runner-spec.js')); +/* eslint-enable @typescript-eslint/no-require-imports */ + +const RUN_ID = 'a'.repeat(32); +const INVOCATION_ID = 'b'.repeat(24); +const SEED_ID = 'c'.repeat(32); +const config = { + sbxWorkDir: '/sbx-daemon/private/work', + sbxSeedsDir: '/sbx-daemon/private/seeds', + enclaveMountDir: '/agent', + enclaveSeedPath: '/awf/seed', + enclaveTaskPath: '/awf/task.txt', + enclaveSchemaPath: '/awf/schema.json', + enclaveUid: 65534, + enclaveGid: 65534, + network: 'awf-bounded-agent', + timeoutSeconds: 120, + memoryLimit: '512m', + tmpfsLimit: '64m', + cpuLimit: '1', + pidsLimit: 128, +}; + +const result = (overrides: Record = {}) => ({ + exitCode: 0, + stdout: '', + stderr: '', + timedOut: false, + ...overrides, +}); + +function createSbx(handler: (args: string[], timeout: number) => Record = () => result()) { + const calls: Array<{ args: string[]; timeout: number }> = []; + return { + calls, + client: { + runSbx: async (args: string[], timeout: number) => { + calls.push({ args, timeout }); + return handler(args, timeout); + }, + }, + }; +} + +describe('bounded-agent sbx enclave runner contract', () => { + it('derives a frozen launch surface only from trusted identifiers', () => { + const spec = deriveSbxEnclaveSpec({ config, runId: RUN_ID, invocationId: INVOCATION_ID, seedId: SEED_ID }); + expect(Object.isFrozen(spec)).toBe(true); + expect(Object.isFrozen(spec.createArgs)).toBe(true); + expect(spec.createArgs).toContain(SBX_ENCLAVE_TEMPLATE); + for (const flag of REQUIRED_HARD_ISOLATION_FLAGS) expect(spec.createArgs).toContain(flag); + expect(spec.createArgs.join(' ')).toContain( + `/sbx-daemon/private/seeds/${SEED_ID}:/awf/seed:ro`, + ); + expect(spec.execArgs).toEqual([ + 'exec', '--user', '65534:65534', '--workdir', '/agent', + spec.sandboxName, '/usr/local/bin/run-bounded-agent', + ]); + }); + + it('rejects untrusted identifiers before constructing CLI arguments', () => { + for (const value of ['', '../escape', '--all', 'UPPER']) { + expect(() => deriveSbxEnclaveSpec({ + config, + runId: RUN_ID, + invocationId: value, + seedId: SEED_ID, + })).toThrow(/broker-generated identifier/); + } + }); + + it('blocks launch unless every executable capability is proven', async () => { + const runner = new SbxEnclaveRunner(config, { + probe: async () => ({ supported: false, missing: ['mandatory network policy'] }), + }); + await expect(runner.assertAvailable()).rejects.toThrow(/blocked.*No fallback/s); + }); + + it('always stops and force-removes the invocation while discarding streams', async () => { + const { calls, client } = createSbx((args) => { + if (args[0] === 'exec') return result({ stdout: 'SECRET', stderr: 'DIAGNOSTIC' }); + if (args[0] === 'ls' && args[1] === '--quiet') return result(); + return result(); + }); + const runner = new SbxEnclaveRunner(config, { + sbx: client, + probe: async () => ({ supported: true, missing: [] }), + files: { mkdirSync: jest.fn() }, + }); + await runner.assertAvailable(); + const runResult = await runner.runEnclaveContainer({ + runId: RUN_ID, + invocationId: INVOCATION_ID, + seedId: SEED_ID, + timeoutMs: 1000, + }); + expect(runResult).toEqual({ exitCode: 0, timedOut: false }); + const name = runner.spec(RUN_ID, INVOCATION_ID, SEED_ID).sandboxName; + expect(calls.map((call) => call.args)).toContainEqual(['stop', name]); + expect(calls.map((call) => call.args)).toContainEqual(['rm', '--force', name]); + expect(JSON.stringify(runResult)).not.toContain('SECRET'); + }); + + it('shares one deadline across create and exec', async () => { + let now = 1000; + const { calls, client } = createSbx((args) => { + if (args[0] === 'create') now += 400; + return result(); + }); + const runner = new SbxEnclaveRunner(config, { + sbx: client, + files: { mkdirSync: jest.fn() }, + nowMs: () => now, + }); + await runner.runEnclaveContainer({ + runId: RUN_ID, + invocationId: INVOCATION_ID, + seedId: SEED_ID, + timeoutMs: 1000, + }); + const createCall = calls.find((call) => call.args[0] === 'create'); + const execCall = calls.find((call) => call.args[0] === 'exec'); + expect(createCall?.timeout).toBeLessThanOrEqual(16_000); + expect(execCall?.timeout).toBe(15_600); + }); + + it('reconciles only the current trusted run prefix', async () => { + const stale = `awf-bounded-agent-sbx-${RUN_ID}-${INVOCATION_ID}`; + const { calls, client } = createSbx((args) => ( + args[0] === 'ls' && args[1] === '--json' + ? result({ stdout: JSON.stringify([{ name: stale }, { name: 'awf-agent-primary' }]) }) + : result() + )); + const runner = new SbxEnclaveRunner(config, { sbx: client }); + await runner.reconcileRun(RUN_ID); + expect(calls.map((call) => call.args)).toContainEqual(['rm', '--force', stale]); + expect(calls.map((call) => call.args).flat()).not.toContain('awf-agent-primary'); + }); + + it('rejects malformed and option-shaped inventory names', () => { + expect(() => parseSandboxNames('not-json')).toThrow(/malformed sandbox inventory/); + expect(() => parseSandboxNames('{"name":"x"}')).toThrow(/malformed sandbox inventory/); + expect(() => parseSandboxNames('[{"name":"--all"}]')).toThrow(/invalid sandbox name/); + }); + + it('fails closed when cleanup fails after successful execution', async () => { + const { client } = createSbx((args) => ( + args[0] === 'rm' ? result({ exitCode: 1 }) : result() + )); + const runner = new SbxEnclaveRunner(config, { + sbx: client, + files: { mkdirSync: jest.fn() }, + }); + await expect(runner.runEnclaveContainer({ + runId: RUN_ID, + invocationId: INVOCATION_ID, + seedId: SEED_ID, + })).rejects.toThrow(/remove bounded-agent sbx VM/); + }); +}); diff --git a/src/bounded-agent/skill.ts b/src/bounded-agent/skill.ts new file mode 100644 index 000000000..e3ce8dffc --- /dev/null +++ b/src/bounded-agent/skill.ts @@ -0,0 +1,203 @@ +import * as fs from 'fs'; +import type { BoundedAgentRepository, BoundedAgentsConfig } from '../types/bounded-agent-options'; +import { BOUNDED_AGENT_SENSITIVITY_RUN_BITS } from '../types/bounded-agent-options'; +import { AGENT_SKILL_PATH, type BoundedAgentPaths } from './paths'; +import { + CANONICAL_ERROR_JSON, + RESULT_STATUS_BIT_COST, + TIMING_BUCKETS_MS, + TIMING_BUCKET_BITS, +} from './protocol'; + +/** + * Generates the bounded-agent skill document handed to the primary agent. + * + * The document is *guidance*, not a security boundary: every rule it states is + * independently enforced by the `bounded-agent` wrapper and by the trusted + * broker. Its job is to tell the agent which repositories exist (and at what + * confidentiality budget), the request contract (repository + finite schema + + * bounded task text), and the observable canonical result envelope. + */ + +interface BoundedAgentSkillParams { + /** Configured repositories, in configuration order. */ + repos: BoundedAgentRepository[]; + /** Per-invocation wall-clock limit, in seconds. */ + timeoutSeconds: number; + /** Per-run invocation budget (an independent operational cap). */ + maxInvocations: number; + /** Maximum size of the caller-supplied task text, in bytes. */ + maxTaskBytes: number; + /** Trusted provider protocol the enclave speaks to the API proxy. */ + profile: BoundedAgentsConfig['profile']; + /** Maximum model requests one invocation may issue. */ + maxModelRequests: number; +} + +function formatRunBudget(repo: BoundedAgentRepository): string { + const bits = BOUNDED_AGENT_SENSITIVITY_RUN_BITS[repo.sensitivity]; + if (bits === null) return `unmetered (\`${repo.sensitivity}\`)`; + if (bits === 0) return `0 bits/run (\`${repo.sensitivity}\` — never runs an enclave)`; + return `${bits} bits/run (\`${repo.sensitivity}\`)`; +} + +export function generateBoundedAgentSkill(params: BoundedAgentSkillParams): string { + const { repos, timeoutSeconds, maxInvocations, maxTaskBytes, profile, maxModelRequests } = params; + const repoList = repos.map((repo) => `- \`${repo.repo}\` — ${formatRunBudget(repo)}`).join('\n'); + const bucketList = TIMING_BUCKETS_MS.map((ms) => (ms >= 1000 ? `${ms / 1000}s` : `${ms}ms`)).join(', '); + + return `--- +name: bounded-agent +description: >- + Delegate a short, bounded task about one pre-approved private repository to + an isolated enclave agent and get back a value conforming to a finite + response schema you declare up front. Use when answering the question needs + judgment or multi-step reading rather than a single deterministic script, + and only when your remaining per-repository information budget can afford + the answer's schema. +--- + +# Bounded agent + +A bounded agent runs a fixed, AWF-authored model loop inside a single-use +enclave. The enclave reads a read-only copy of exactly one pre-approved private +repository, may call the configured model a bounded number of times through the +AWF API proxy, and must reduce its work to one value conforming to the finite +schema you declared. + +The enclave has no host access, no credentials, no workspace access, no Squid +route, no general proxy, and no path to you, to the broker, to safe outputs, to +the MCP gateway, or to the CLI proxy. Its only reachable peer is the AWF API +proxy. + +You never see the repository contents, the enclave's transcript, its tool +calls, its stdout/stderr, its files, its exit status, or any diagnostics. The +only thing you observe is one canonical JSON result: + +- \`{"status":"ok","result":}\` where \`\` conforms to the exact + response schema you declared, or +- \`${CANONICAL_ERROR_JSON}\` for **every** failure mode (invalid request, + disallowed repository, exhausted budget, launch failure, timeout, crash, + non-conformant output, internal error). Failures are indistinguishable from + each other by design — do not try to infer which one occurred. + +## Available repositories + +${repoList} + +Any other repository is rejected. The sensitivity and run budget shown above +are fixed by AWF configuration; a request cannot choose or override them. + +## Invoking + +\`\`\`bash +bounded-agent \\ + --repo owner/repo \\ + --schema '{"type":"boolean"}' \\ + < task.txt +\`\`\` + +Rules enforced by the CLI: + +- exactly one \`--repo\`, and it must be one of the repositories listed above; +- exactly one \`--schema\`: a finite response schema (see below); +- the task text is read from stdin and must be at most ${maxTaskBytes} bytes; +- there are no other options. You cannot choose the image, command, + executable, model, provider, profile, tools, system prompt, runtime, + timeout, mount, path, network, proxy, endpoint, resource limit, environment, + or credentials. Supplying any of them is rejected. + +The CLI always prints exactly one line of JSON and always exits \`0\`. + +## Response schema + +The schema is the same deliberately finite algebra bounded queries use — **not** +general JSON Schema. Supported node types: + +| type | shape | notes | +| --- | --- | --- | +| \`const\` | \`{"type":"const","value":}\` | one fixed value | +| \`boolean\` | \`{"type":"boolean"}\` | \`true\` or \`false\` | +| \`enum\` | \`{"type":"enum","values":[,...]}\` | unique literals, same JSON type | +| \`integer\` | \`{"type":"integer","minimum":N,"maximum":M}\` | inclusive bounded range | +| \`object\` | \`{"type":"object","fields":{"name":,...}}\` | every field required, no extras | +| \`tuple\` | \`{"type":"tuple","items":[,...]}\` | fixed-length, per-position schema | +| \`array\` | \`{"type":"array","items":,"length":N}\` | fixed length, uniform item schema | +| \`union\` | \`{"type":"union","variants":{"tag":,...}}\` | value is \`{"tag":"...","value":...}\` | + +There is no way to express an unbounded string, a float, a regex, recursion, +\`$ref\`, an optional field, \`additionalProperties\`, or an untagged/overlapping +union — these are structurally impossible, not merely disallowed. In +particular, **a bounded agent cannot return prose**: if you want a summary, +encode the finite set of conclusions you care about as an \`enum\`. + +## Task contract + +The task text is prompt input for the enclave, nothing else. It is never +interpreted as configuration: it cannot add a tool, change the model, reach a +network endpoint, or alter any limit. + +Inside the enclave the model has read-only repository tools (list, read, +search) over the immutable seed, and a single terminal tool that records the +final answer. It runs the \`${profile}\`-protocol route through the AWF API +proxy and may issue at most ${maxModelRequests} model request(s) per +invocation. Anything else in the output — wrong type, out-of-range value, +unknown enum member, extra/missing fields, wrong length, malformed or +duplicate-key JSON, an oversized result, no result at all — is reported to you +as \`${CANONICAL_ERROR_JSON}\`. + +## Budget + +Every invocation reserves a fixed information charge from its repository's run +budget, computed **before** any workspace or container is created: + +\`\`\`text +charge = ${RESULT_STATUS_BIT_COST} (ok/error) + ceil(log2(schema cardinality)) + ${TIMING_BUCKET_BITS} (timing) +\`\`\` + +The charge is debited whether the enclave succeeds, fails, or times out, and is +never refunded. An invocation is only allowed if its charge fits the remaining +balance. The remaining balance itself is never disclosed to you. + +Timing is charged because it is observable: the broker always returns at the +first bucket boundary at or after the enclave actually finishes (bucket +boundaries: ${bucketList}). + +- Each invocation may run for at most ${timeoutSeconds} second(s). +- At most ${maxInvocations} invocation(s) are permitted for this entire run, + independent of the bit budget above. Further calls return + \`${CANONICAL_ERROR_JSON}\` without running anything. +- Bounded agents keep a ledger **separate** from bounded queries: spending here + does not consume a bounded query's balance, and vice versa. + +Design one high-value, low-cardinality question per invocation. +`; +} + +/** + * Writes the generated skill into the agent-visible artifact directory. + * + * The file is securely created 0600 under an AWF-owned directory, then made + * 0644 for the agent's read-only bind mount. Nothing is written to the host + * user's home directory or to the workspace. + */ +export function writeBoundedAgentSkill(paths: BoundedAgentPaths, params: BoundedAgentSkillParams): string { + fs.mkdirSync(paths.agentDir, { recursive: true, mode: 0o755 }); + const content = generateBoundedAgentSkill(params); + // O_EXCL | O_NOFOLLOW: atomically create; fail if a symlink or existing file + // is already at this path (insecure-temp-file guard). + const fd = fs.openSync( + paths.skillPath, + fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_NOFOLLOW, + 0o600, + ); + try { + fs.writeSync(fd, content); + // The protected 0755 parent exposes only this non-sensitive generated + // guidance file; world-readability is required across the agent UID mount. + fs.fchmodSync(fd, 0o644); + } finally { + fs.closeSync(fd); + } + return AGENT_SKILL_PATH; +} diff --git a/src/bounded-agent/staging.ts b/src/bounded-agent/staging.ts new file mode 100644 index 000000000..382e3a02d --- /dev/null +++ b/src/bounded-agent/staging.ts @@ -0,0 +1,81 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import type { BoundedAgentRepository } from '../types/bounded-agent-options'; +import { + stageBoundedQuerySeeds, + type GitRunner, +} from '../bounded-query/staging'; +import type { PrivateRepositoryStagingResult } from '../bounded-execution/repository-staging'; +import type { BoundedAgentPaths } from './paths'; + +/** + * Trusted host-side staging for bounded agents. + * + * Staging is *identical work* to bounded queries — clone one immutable seed + * per configured repository, scrub every credential/hook/external-reference + * artifact, make it read-only, and verify that — so this module deliberately + * delegates to the audited implementation in `../bounded-query/staging.ts` + * rather than restating it. What differs is only the destination: bounded + * agents stage into their own disjoint private root (see `./paths.ts`), so the + * two subsystems never share a seed, a workspace, an audit log, or a ledger. + * + * The staging credential is read from the AWF host environment, used only by + * this phase through a `GIT_ASKPASS` helper reading a 0600 file, and scrubbed + * before the broker, the enclave, or the primary agent exists. + */ + +export interface StageBoundedAgentSeedsParams { + /** Trusted repository descriptors exactly as configured (already schema-validated). */ + repos: BoundedAgentRepository[]; + /** Resolved bounded-agent filesystem layout. */ + paths: Pick; + /** Run-unique id used to derive opaque seed directory names. */ + runId: string; + /** Staging credential. Never logged, never forwarded past the staging phase. */ + token: string; + /** Override the git runner (tests). */ + gitRunner?: GitRunner; +} + +/** Materializes an immutable seed for every configured bounded-agent repository. */ +function makeSeedEnclaveReadable(target: string): void { + const stat = fs.lstatSync(target); + if (stat.isSymbolicLink()) return; + + if (stat.isDirectory()) { + for (const entry of fs.readdirSync(target)) { + makeSeedEnclaveReadable(path.join(target, entry)); + } + fs.chmodSync(target, (stat.mode & 0o7777) | 0o555); + return; + } + + fs.chmodSync(target, (stat.mode & 0o7777) | 0o444); +} + +export async function stageBoundedAgentSeeds( + params: StageBoundedAgentSeedsParams, +): Promise { + const result = await stageBoundedQuerySeeds({ + repos: params.repos, + paths: params.paths, + runId: params.runId, + token: params.token, + gitRunner: params.gitRunner, + label: 'Bounded agents', + }); + + // The enclave runs as fixed uid/gid 65534 and bind-mounts the immutable seed + // directly. Grant read/traverse permission without restoring any write bit. + for (const seed of result.seeds) { + makeSeedEnclaveReadable(seed.seedPath); + } + return result; +} + +export { releaseSeedPermissions, resolveStagingToken } from '../bounded-query/staging'; +export type { GitRunner } from '../bounded-query/staging'; + +/** @internal Exported for focused permission tests. */ +// ts-prune-ignore-next +export const boundedAgentStagingTestHelpers = { makeSeedEnclaveReadable }; diff --git a/src/bounded-agent/workflow-integration.test.ts b/src/bounded-agent/workflow-integration.test.ts new file mode 100644 index 000000000..1037263b5 --- /dev/null +++ b/src/bounded-agent/workflow-integration.test.ts @@ -0,0 +1,161 @@ +import { runMainWorkflow } from '../cli-workflow'; +import type { WrapperConfig } from '../types'; +import { BOUNDED_AGENT_DEFAULTS, type BoundedAgentsConfig } from '../types/bounded-agent-options'; + +jest.mock('../topology', () => ({ + TOPOLOGY_NETWORK_NAME: 'awf-net', + getTopologyContainerIps: jest.fn(), + patchComposeWithTopologyHosts: jest.fn(), + connectTopologyContainers: jest.fn(), + assertTopologySupported: jest.fn(), +})); + +jest.mock('../container-runtime', () => ({ + runtimeNeedsStaticDns: jest.fn().mockReturnValue(false), + runtimeUsesComposeAgent: jest.fn().mockReturnValue(true), +})); + +/** + * Lifecycle ordering guarantees for bounded agents. + * + * Preflight + staging are credential-bearing and must complete before anything + * untrusted exists, so they run ahead of config generation, host network setup, + * and container startup — and a failure must stop the run before the primary + * agent is invoked. Bounded queries must remain independently wired. + */ + +const boundedAgents: BoundedAgentsConfig = { + ...BOUNDED_AGENT_DEFAULTS, + enabled: true, + model: 'gpt-4o-mini', + privateRepos: [{ repo: 'octo/private', sensitivity: 'internal' }], +}; + +const baseConfig: WrapperConfig = { + allowedDomains: ['github.com'], + agentCommand: 'echo hi', + logLevel: 'info', + keepContainers: false, + workDir: '/tmp/awf-bounded-agent-workflow', + imageRegistry: 'registry', + imageTag: 'latest', + buildLocal: false, +} as WrapperConfig; + +function createDeps(callOrder: string[], overrides: Record = {}) { + return { + ensureFirewallNetwork: jest.fn().mockImplementation(async () => { + callOrder.push('ensureFirewallNetwork'); + return { squidIp: '172.30.0.10', agentIp: '172.30.0.20', proxyIp: '172.30.0.30', subnet: '172.30.0.0/24' }; + }), + setupHostIptables: jest.fn().mockImplementation(async () => { + callOrder.push('setupHostIptables'); + }), + writeConfigs: jest.fn().mockImplementation(async () => { + callOrder.push('writeConfigs'); + }), + startContainers: jest.fn().mockImplementation(async () => { + callOrder.push('startContainers'); + }), + runAgentCommand: jest.fn().mockImplementation(async () => { + callOrder.push('runAgentCommand'); + return { exitCode: 0 }; + }), + prepareBoundedQueries: jest.fn().mockImplementation(async () => { + callOrder.push('prepareBoundedQueries'); + }), + prepareBoundedAgents: jest.fn().mockImplementation(async () => { + callOrder.push('prepareBoundedAgents'); + }), + ...overrides, + } as unknown as Parameters[1]; +} + +function createOptions() { + return { + logger: { info: jest.fn(), success: jest.fn(), warn: jest.fn() }, + performCleanup: jest.fn().mockResolvedValue(undefined), + } as unknown as Parameters[2]; +} + +describe('bounded-agent staging in the main workflow', () => { + it('stages seeds before configs are written and containers start', async () => { + const callOrder: string[] = []; + const deps = createDeps(callOrder); + + await runMainWorkflow({ ...baseConfig, boundedAgents }, deps, createOptions()); + + expect(callOrder[0]).toBe('prepareBoundedAgents'); + expect(callOrder.indexOf('prepareBoundedAgents')).toBeLessThan(callOrder.indexOf('writeConfigs')); + expect(callOrder.indexOf('prepareBoundedAgents')).toBeLessThan(callOrder.indexOf('startContainers')); + expect(callOrder.indexOf('prepareBoundedAgents')).toBeLessThan(callOrder.indexOf('ensureFirewallNetwork')); + }); + + it('does not stage anything when bounded agents are disabled', async () => { + const callOrder: string[] = []; + const deps = createDeps(callOrder); + + await runMainWorkflow(baseConfig, deps, createOptions()); + + expect(callOrder).not.toContain('prepareBoundedAgents'); + expect((deps as unknown as { prepareBoundedAgents: jest.Mock }).prepareBoundedAgents) + .not.toHaveBeenCalled(); + }); + + it('aborts before the primary agent runs when preflight or staging fails', async () => { + const callOrder: string[] = []; + const deps = createDeps(callOrder, { + prepareBoundedAgents: jest.fn().mockRejectedValue(new Error('runsc is not registered')), + }); + + await expect( + runMainWorkflow({ ...baseConfig, boundedAgents }, deps, createOptions()), + ).rejects.toThrow('runsc is not registered'); + + expect(callOrder).toEqual([]); + expect((deps as unknown as { writeConfigs: jest.Mock }).writeConfigs).not.toHaveBeenCalled(); + expect((deps as unknown as { startContainers: jest.Mock }).startContainers).not.toHaveBeenCalled(); + expect((deps as unknown as { runAgentCommand: jest.Mock }).runAgentCommand).not.toHaveBeenCalled(); + }); + + it('refuses to run when bounded agents are enabled but no staging implementation was injected', async () => { + const callOrder: string[] = []; + const deps = createDeps(callOrder, { prepareBoundedAgents: undefined }); + + await expect( + runMainWorkflow({ ...baseConfig, boundedAgents }, deps, createOptions()), + ).rejects.toThrow(/no staging implementation/); + + expect(callOrder).toEqual([]); + }); + + it('leaves bounded-query staging independently wired', async () => { + const callOrder: string[] = []; + const deps = createDeps(callOrder); + + await runMainWorkflow({ ...baseConfig, boundedAgents }, deps, createOptions()); + expect(callOrder).not.toContain('prepareBoundedQueries'); + + callOrder.length = 0; + await runMainWorkflow( + { + ...baseConfig, + boundedAgents, + boundedQueries: { + enabled: true, + privateRepos: [{ repo: 'octo/private', sensitivity: 'internal' }], + runtime: 'docker', + timeout: 30, + memoryLimit: '512m', + interpreter: 'python3', + maxInvocations: 32, + }, + }, + deps, + createOptions(), + ); + expect(callOrder.indexOf('prepareBoundedQueries')).toBeLessThan( + callOrder.indexOf('prepareBoundedAgents'), + ); + }); +}); diff --git a/src/bounded-agent/workspace-artifacts.test.ts b/src/bounded-agent/workspace-artifacts.test.ts new file mode 100644 index 000000000..25e3afa89 --- /dev/null +++ b/src/bounded-agent/workspace-artifacts.test.ts @@ -0,0 +1,272 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { spawnSync } from 'child_process'; +import { generateBoundedAgentSkill, writeBoundedAgentSkill } from './skill'; +import { writeBoundedAgentWrapper } from './wrapper-artifact'; +import { resolveBoundedAgentPaths } from './paths'; + +/* eslint-disable @typescript-eslint/no-require-imports */ +const brokerDir = path.join(__dirname, '..', '..', 'containers', 'bounded-agent', 'broker'); +const workspace = require(path.join(brokerDir, 'workspace.js')); +/* eslint-enable @typescript-eslint/no-require-imports */ + +describe('bounded-agent invocation workspace', () => { + let root: string; + let config: Record; + + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-bounded-agent-ws-')); + config = { workDir: root, enclaveUid: process.getuid?.() ?? 0, enclaveGid: process.getgid?.() ?? 0 }; + }); + + afterEach(() => { + fs.rmSync(root, { recursive: true, force: true }); + }); + + it('materializes only the task, schema, and result file — never a repository copy', () => { + const layout = workspace.createInvocationWorkspace({ + config, + invocationId: 'abc123', + task: 'the task', + schema: { type: 'boolean' }, + }); + + expect(fs.readdirSync(layout.root).sort()).toEqual(['out', 'schema.json', 'task.txt']); + expect(fs.readFileSync(layout.taskPath, 'utf8')).toBe('the task'); + expect(JSON.parse(fs.readFileSync(layout.schemaPath, 'utf8'))).toEqual({ type: 'boolean' }); + expect(fs.readFileSync(layout.outPath, 'utf8')).toBe(''); + }); + + it('makes the task and schema read-only inside the enclave mount source', () => { + const layout = workspace.createInvocationWorkspace({ + config, + invocationId: 'abc123', + task: 'the task', + schema: { type: 'boolean' }, + }); + expect(fs.statSync(layout.taskPath).mode & 0o222).toBe(0); + expect(fs.statSync(layout.schemaPath).mode & 0o222).toBe(0); + }); + + it('reads back a result of exactly the permitted size', () => { + const layout = workspace.createInvocationWorkspace({ + config, + invocationId: 'abc123', + task: 't', + schema: { type: 'boolean' }, + }); + fs.writeFileSync(layout.outPath, 'true'); + expect(workspace.readEnclaveOutput(layout.outPath, 4)).toBe('true'); + expect(workspace.readEnclaveOutput(layout.outPath, 3)).toBeUndefined(); + }); + + it('rejects a missing result file', () => { + expect(workspace.readEnclaveOutput(path.join(root, 'nope'), 100)).toBeUndefined(); + }); + + it('rejects a symlinked result file', () => { + const target = path.join(root, 'secret'); + fs.writeFileSync(target, 'true'); + const link = path.join(root, 'out'); + fs.symlinkSync(target, link); + expect(workspace.readEnclaveOutput(link, 100)).toBeUndefined(); + }); + + it('rejects a non-regular result file', () => { + const fifo = path.join(root, 'fifo'); + fs.mkdirSync(fifo); + expect(workspace.readEnclaveOutput(fifo, 100)).toBeUndefined(); + }); + + it('rejects invalid UTF-8', () => { + const out = path.join(root, 'out'); + fs.writeFileSync(out, Buffer.from([0xff, 0xfe, 0xfd])); + expect(workspace.readEnclaveOutput(out, 100)).toBeUndefined(); + }); + + it('destroys the workspace idempotently', () => { + workspace.createInvocationWorkspace({ + config, + invocationId: 'abc123', + task: 't', + schema: { type: 'boolean' }, + }); + workspace.destroyInvocationWorkspace(root, 'abc123'); + workspace.destroyInvocationWorkspace(root, 'abc123'); + expect(fs.existsSync(path.join(root, 'abc123'))).toBe(false); + }); +}); + +describe('generated bounded-agent skill', () => { + const params = { + repos: [ + { repo: 'octo/alpha', sensitivity: 'internal' as const }, + { repo: 'octo/sealed', sensitivity: 'sealed' as const }, + { repo: 'octo/open', sensitivity: 'public' as const }, + ], + timeoutSeconds: 120, + maxInvocations: 8, + maxTaskBytes: 4096, + profile: 'openai' as const, + maxModelRequests: 8, + }; + + it('lists each repository with its fixed run budget', () => { + const skill = generateBoundedAgentSkill(params); + expect(skill).toContain('`octo/alpha` — 64 bits/run (`internal`)'); + expect(skill).toContain('`octo/sealed` — 0 bits/run (`sealed` — never runs an enclave)'); + expect(skill).toContain('`octo/open` — unmetered (`public`)'); + }); + + it('documents the canonical envelopes and the fixed CLI surface', () => { + const skill = generateBoundedAgentSkill(params); + expect(skill).toContain('{"status":"error"}'); + expect(skill).toContain('{"status":"ok","result":}'); + expect(skill).toContain('exactly one `--repo`'); + expect(skill).toContain('exactly one `--schema`'); + }); + + it('states that no capability-bearing option exists', () => { + const skill = generateBoundedAgentSkill(params); + for (const forbidden of [ + 'image', 'command', 'executable', 'model', 'provider', 'profile', 'tools', 'system prompt', + 'runtime', 'timeout', 'mount', 'path', 'network', 'proxy', 'endpoint', 'resource limit', + 'environment', 'credentials', + ]) { + expect(skill).toContain(forbidden); + } + }); + + it('never discloses the remaining budget', () => { + const skill = generateBoundedAgentSkill(params); + expect(skill).toContain('remaining balance itself is never disclosed'); + }); + + it('states that the ledger is separate from bounded queries', () => { + expect(generateBoundedAgentSkill(params)).toContain('ledger **separate** from bounded queries'); + }); +}); + +describe('bounded-agent agent artifacts', () => { + let workDir: string; + + beforeEach(() => { + workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-bounded-agent-artifacts-')); + }); + + afterEach(() => { + fs.rmSync(resolveBoundedAgentPaths(workDir).ingressRoot, { recursive: true, force: true }); + fs.rmSync(workDir, { recursive: true, force: true }); + }); + + it('writes a world-readable skill and an executable wrapper into the ingress root only', () => { + const paths = resolveBoundedAgentPaths(workDir); + fs.mkdirSync(paths.agentDir, { recursive: true, mode: 0o755 }); + + writeBoundedAgentSkill(paths, { + repos: [{ repo: 'octo/alpha', sensitivity: 'internal' }], + timeoutSeconds: 120, + maxInvocations: 8, + maxTaskBytes: 4096, + profile: 'openai', + maxModelRequests: 8, + }); + writeBoundedAgentWrapper(paths); + + expect(fs.statSync(paths.skillPath).mode & 0o777).toBe(0o644); + // Open with O_NOFOLLOW to avoid TOCTOU between stat and read. + const wrapperFd = fs.openSync(paths.wrapperPath, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW); + try { + expect(fs.fstatSync(wrapperFd).mode & 0o777).toBe(0o555); + expect(fs.readFileSync(wrapperFd, 'utf8')).toContain('AWF_BOUNDED_AGENT_SOCKET'); + } finally { + fs.closeSync(wrapperFd); + } + // Nothing is written into the broker-private root. + expect(fs.existsSync(paths.root)).toBe(false); + }); + + it('refuses to overwrite a pre-existing artifact', () => { + const paths = resolveBoundedAgentPaths(workDir); + fs.mkdirSync(paths.agentDir, { recursive: true, mode: 0o755 }); + fs.writeFileSync(paths.wrapperPath, 'planted'); + + expect(() => writeBoundedAgentWrapper(paths)).toThrow(/EEXIST/); + }); +}); + +describe('bounded-agent CLI wrapper source', () => { + const wrapper = fs.readFileSync( + path.join(__dirname, '..', '..', 'containers', 'agent', 'bounded-agent-wrapper.sh'), + 'utf8', + ); + + it('accepts only --repo and --schema plus stdin', () => { + expect(wrapper).toContain('--repo)'); + expect(wrapper).toContain('--schema)'); + // Everything else falls through to the canonical error. + expect(wrapper).toContain('*)\n # Any other flag'); + }); + + it('always emits a canonical envelope and exits 0', () => { + expect(wrapper).toContain("CANONICAL_ERROR='{\"status\":\"error\"}'"); + expect(wrapper).not.toMatch(/exit\s+[1-9]/); + }); + + it('never forwards a proxy, credential, or runtime control', () => { + expect(wrapper).toContain("--noproxy '*'"); + for (const forbidden of ['AWF_BOUNDED_AGENT_MODEL', 'Authorization', 'X-AWF-Runtime']) { + expect(wrapper).not.toContain(forbidden); + } + }); + + it('uses the authenticated host-gateway endpoint only when the sbx transport is complete', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-bounded-agent-wrapper-')); + const argsPath = path.join(root, 'curl.args'); + const fakeCurl = path.join(root, 'curl'); + fs.writeFileSync( + fakeCurl, + `#!/bin/sh\nprintf '%s\\n' "$@" > "$AWF_TEST_CURL_ARGS"\nprintf '%s' '{"status":"error"}'\n`, + { mode: 0o755 }, + ); + try { + const capability = 'a'.repeat(64); + const result = spawnSync( + '/bin/sh', + [ + path.join(__dirname, '..', '..', 'containers', 'agent', 'bounded-agent-wrapper.sh'), + '--repo', + 'octo/alpha', + '--schema', + '{"type":"boolean"}', + ], + { + input: 'bounded task', + encoding: 'utf8', + env: { + PATH: `${root}:${process.env.PATH ?? ''}`, + AWF_TEST_CURL_ARGS: argsPath, + AWF_BOUNDED_AGENT_ENDPOINT: 'http://host.docker.internal:18081/query', + AWF_BOUNDED_AGENT_CAPABILITY: capability, + }, + }, + ); + + expect(result.status).toBe(0); + expect(result.stdout).toBe('{"status":"error"}\n'); + expect(result.stderr).toBe(''); + const curlArgs = fs.readFileSync(argsPath, 'utf8'); + expect(curlArgs).toContain('X-AWF-Capability: ' + capability); + expect(curlArgs).toContain('http://host.docker.internal:18081/query'); + expect(curlArgs).toContain('--noproxy'); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it('only ever passes through the two canonical response shapes', () => { + expect(wrapper).toContain('\'{"status":"error"}\')'); + expect(wrapper).toContain('\'{"status":"ok","result":\'*\'}\')'); + }); +}); diff --git a/src/bounded-agent/wrapper-artifact.test.ts b/src/bounded-agent/wrapper-artifact.test.ts new file mode 100644 index 000000000..46c2b320b --- /dev/null +++ b/src/bounded-agent/wrapper-artifact.test.ts @@ -0,0 +1,46 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { writeBoundedAgentWrapper } from './wrapper-artifact'; +import type { BoundedAgentPaths } from './paths'; + +jest.mock('fs', () => ({ + ...jest.requireActual('fs'), + existsSync: jest.fn(), + readFileSync: jest.fn(), +})); + +const mockExistsSync = fs.existsSync as jest.MockedFunction; +const mockReadFileSync = fs.readFileSync as jest.MockedFunction; +const actualFs = jest.requireActual('fs'); + +describe('writeBoundedAgentWrapper source resolution', () => { + let dir: string; + let paths: BoundedAgentPaths; + + beforeEach(() => { + mockExistsSync.mockReset(); + mockReadFileSync.mockReset(); + dir = actualFs.mkdtempSync(path.join(os.tmpdir(), 'awf-bounded-agent-wrapper-')); + paths = { wrapperPath: path.join(dir, 'bounded-agent') } as BoundedAgentPaths; + }); + + afterEach(() => { + actualFs.rmSync(dir, { recursive: true, force: true }); + }); + + it('uses the packaged fallback candidate when the source-tree candidate is absent', () => { + mockExistsSync + .mockReturnValueOnce(false) + .mockReturnValueOnce(true); + mockReadFileSync.mockReturnValue('#!/bin/sh\n'); + + expect(writeBoundedAgentWrapper(paths)).toBe(paths.wrapperPath); + expect(actualFs.readFileSync(paths.wrapperPath, 'utf8')).toBe('#!/bin/sh\n'); + }); + + it('fails closed when neither fixed wrapper candidate exists', () => { + mockExistsSync.mockReturnValue(false); + expect(() => writeBoundedAgentWrapper(paths)).toThrow(/Bounded-agent wrapper not found/); + }); +}); diff --git a/src/bounded-agent/wrapper-artifact.ts b/src/bounded-agent/wrapper-artifact.ts new file mode 100644 index 000000000..e4430ffff --- /dev/null +++ b/src/bounded-agent/wrapper-artifact.ts @@ -0,0 +1,38 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import type { BoundedAgentPaths } from './paths'; + +// In the standalone bundle this global is replaced at build time with the +// wrapper source. Normal source/npm builds read the checked-in shell script. +declare const __AWF_BOUNDED_AGENT_WRAPPER__: string | undefined; + +function loadWrapperSource(): string { + if (typeof __AWF_BOUNDED_AGENT_WRAPPER__ !== 'undefined') { + return __AWF_BOUNDED_AGENT_WRAPPER__; + } + + const candidates = [ + path.join(__dirname, '..', '..', 'containers', 'agent', 'bounded-agent-wrapper.sh'), + path.join(__dirname, '..', '..', '..', 'containers', 'agent', 'bounded-agent-wrapper.sh'), + ]; + for (const candidate of candidates) { + if (fs.existsSync(candidate)) return fs.readFileSync(candidate, 'utf8'); + } + throw new Error(`Bounded-agent wrapper not found at ${candidates.join(' or ')}`); +} + +/** Materializes the wrapper in the agent-only ingress root. */ +export function writeBoundedAgentWrapper(paths: BoundedAgentPaths): string { + const fd = fs.openSync( + paths.wrapperPath, + fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_NOFOLLOW, + 0o700, + ); + try { + fs.writeSync(fd, loadWrapperSource()); + fs.fchmodSync(fd, 0o555); + } finally { + fs.closeSync(fd); + } + return paths.wrapperPath; +} diff --git a/src/bounded-execution/finite-disclosure.ts b/src/bounded-execution/finite-disclosure.ts index c19e9a3af..153acb0f9 100644 --- a/src/bounded-execution/finite-disclosure.ts +++ b/src/bounded-execution/finite-disclosure.ts @@ -461,6 +461,70 @@ export function schemaCardinality(schema: BoundedQuerySchemaNode): bigint { } } +/** + * Computes cardinality only up to a bounded, already-unaffordable result. + * + * Materializing the exact cardinality of deeply nested fixed arrays can create + * multi-megabyte BigInts from a tiny request. The exact value above this cap is + * irrelevant once it exceeds this threshold: every metered sensitivity has at + * most 64 bits per run, while the fixed status/timing channels already cost 4 + * bits. The larger 1024-bit cap preserves exact charges for ordinary schemas. + */ +const MAX_EXACT_SCHEMA_CARDINALITY = 1n << 1024n; +const CAPPED_SCHEMA_CARDINALITY = MAX_EXACT_SCHEMA_CARDINALITY + 1n; + +function cappedMultiply(left: bigint, right: bigint): bigint { + if (left === 0n || right === 0n) return 0n; + if (left > MAX_EXACT_SCHEMA_CARDINALITY / right) return CAPPED_SCHEMA_CARDINALITY; + return left * right; +} + +function cappedPower(base: bigint, exponent: number): bigint { + let result = 1n; + let factor = base; + let remaining = exponent; + while (remaining > 0) { + if ((remaining & 1) === 1) result = cappedMultiply(result, factor); + if (result > MAX_EXACT_SCHEMA_CARDINALITY) return result; + remaining = Math.floor(remaining / 2); + if (remaining > 0) factor = cappedMultiply(factor, factor); + } + return result; +} + +function cappedSchemaCardinality(schema: BoundedQuerySchemaNode): bigint { + switch (schema.type) { + case 'const': + return 1n; + case 'boolean': + return 2n; + case 'enum': + return BigInt(schema.values.length); + case 'integer': + return BigInt(schema.maximum) - BigInt(schema.minimum) + 1n; + case 'object': + return schema.fields.reduce( + (acc, field) => cappedMultiply(acc, cappedSchemaCardinality(field.schema)), + 1n, + ); + case 'tuple': + return schema.items.reduce( + (acc, item) => cappedMultiply(acc, cappedSchemaCardinality(item)), + 1n, + ); + case 'array': + return cappedPower(cappedSchemaCardinality(schema.items), schema.length); + case 'union': { + let total = 0n; + for (const variant of schema.variants) { + total += cappedSchemaCardinality(variant.schema); + if (total > MAX_EXACT_SCHEMA_CARDINALITY) return CAPPED_SCHEMA_CARDINALITY; + } + return total; + } + } +} + /** * The maximum complete-transcript information charge, in bits, for one * invocation using this schema: @@ -474,7 +538,7 @@ export function schemaCardinality(schema: BoundedQuerySchemaNode): bigint { * actual result or completion bucket. */ export function queryBitsForSchema(schema: BoundedQuerySchemaNode): number { - return RESULT_STATUS_BIT_COST + ceilLog2BigInt(schemaCardinality(schema)) + TIMING_BUCKET_BITS; + return RESULT_STATUS_BIT_COST + ceilLog2BigInt(cappedSchemaCardinality(schema)) + TIMING_BUCKET_BITS; } function jsonLiteralEquals(value: unknown, literal: JsonLiteral): boolean { diff --git a/src/bounded-query/mount-policy.ts b/src/bounded-query/mount-policy.ts index 2c3783e9f..49c24b867 100644 --- a/src/bounded-query/mount-policy.ts +++ b/src/bounded-query/mount-policy.ts @@ -111,22 +111,27 @@ function collectAgentVisiblePaths( } /** - * Fails closed when the broker-private root aliases, contains, or is contained + * Fails closed when a broker-private root aliases, contains, or is contained * by any path visible to a primary agent in any supported sandbox backend. + * + * Shared by every bounded-execution subsystem (bounded queries, bounded + * agents): the agent-visible path union is backend-independent and identical + * for all of them, so only the roots and the diagnostic label differ. */ -export function assertBoundedQueryPrivateRootIsolated( +export function assertPrivateRootIsolated( config: WrapperConfig, - paths: BoundedQueryPaths, + roots: Pick, env: NodeJS.ProcessEnv = process.env, cwd = process.cwd(), + label = 'bounded-query', ): void { - const privateRoot = resolvePathThroughExistingAncestor(paths.root); + const privateRoot = resolvePathThroughExistingAncestor(roots.root); const privateDaemonRoot = resolvePathThroughExistingAncestor( - daemonVisiblePath(paths.root, config.dockerHostPathPrefix), + daemonVisiblePath(roots.root, config.dockerHostPathPrefix), ); const visiblePaths = [ ...collectAgentVisiblePaths(config, env, cwd), - { label: 'bounded-query ingress', source: paths.ingressRoot }, + { label: `${label} ingress`, source: roots.ingressRoot }, ]; for (const visible of visiblePaths) { @@ -139,13 +144,26 @@ export function assertBoundedQueryPrivateRootIsolated( || pathsOverlap(privateDaemonRoot, resolvedDaemonVisible) ) { throw new Error( - `Unsafe bounded-query private root "${paths.root}" overlaps agent-visible ${visible.label} ` + + `Unsafe ${label} private root "${roots.root}" overlaps agent-visible ${visible.label} ` + `"${visible.source}" after path and symlink resolution`, ); } } } +/** + * Fails closed when the broker-private root aliases, contains, or is contained + * by any path visible to a primary agent in any supported sandbox backend. + */ +export function assertBoundedQueryPrivateRootIsolated( + config: WrapperConfig, + paths: BoundedQueryPaths, + env: NodeJS.ProcessEnv = process.env, + cwd = process.cwd(), +): void { + assertPrivateRootIsolated(config, paths, env, cwd, 'bounded-query'); +} + /** @internal Exported for focused adversarial tests. */ // ts-prune-ignore-next export const mountPolicyTestHelpers = { diff --git a/src/bounded-query/preflight.test.ts b/src/bounded-query/preflight.test.ts index 91ca7523c..a2b58d2c3 100644 --- a/src/bounded-query/preflight.test.ts +++ b/src/bounded-query/preflight.test.ts @@ -198,6 +198,49 @@ describe('assertQueryRuntimeAvailable', () => { ).rejects.toThrow(/runsc.*not available|not available.*fall back/s); }); + it('routes custom and omitted runtimes through their fixed Docker capability checks', async () => { + const runtimeQuery = jest.fn().mockResolvedValue(true); + const dockerAvailable = jest.fn().mockResolvedValue(true); + + await expect( + assertQueryRuntimeAvailable( + { ...baseBoundedQueries, runtime: 'custom' } as unknown as BoundedQueriesConfig, + runtimeQuery, + jest.fn(), + dockerAvailable, + ), + ).resolves.toBeUndefined(); + expect(runtimeQuery).toHaveBeenCalledWith('runsc'); + + await expect( + assertQueryRuntimeAvailable( + { ...baseBoundedQueries, runtime: undefined } as unknown as BoundedQueriesConfig, + runtimeQuery, + jest.fn(), + dockerAvailable, + ), + ).resolves.toBeUndefined(); + expect(dockerAvailable).toHaveBeenCalledTimes(1); + }); + + it('fails closed when custom and omitted runtime capability checks fail', async () => { + await expect( + assertQueryRuntimeAvailable( + { ...baseBoundedQueries, runtime: 'custom' } as unknown as BoundedQueriesConfig, + jest.fn().mockResolvedValue(false), + ), + ).rejects.toThrow(/runsc.*not available|not available.*fall back/s); + + await expect( + assertQueryRuntimeAvailable( + { ...baseBoundedQueries, runtime: undefined } as unknown as BoundedQueriesConfig, + jest.fn(), + jest.fn(), + jest.fn().mockResolvedValue(false), + ), + ).rejects.toThrow(/Docker daemon.*not available.*never fall back/s); + }); + it('fails closed when sbx lacks any mandatory query isolation capability', async () => { const query = jest.fn().mockResolvedValue({ supported: false, diff --git a/src/bounded-query/protocol.test.ts b/src/bounded-query/protocol.test.ts index 6fc3a668c..03c9f3169 100644 --- a/src/bounded-query/protocol.test.ts +++ b/src/bounded-query/protocol.test.ts @@ -293,6 +293,27 @@ describe('validateSchema', () => { }); describe('schemaCardinality and queryBitsForSchema', () => { + it('bounds charge calculation for pathological nested arrays without materializing huge BigInts', () => { + const schema: BoundedQuerySchemaNode = { + type: 'array', + length: 64, + items: { + type: 'array', + length: 64, + items: { + type: 'array', + length: 64, + items: { + type: 'array', + length: 64, + items: { type: 'boolean' }, + }, + }, + }, + }; + expect(queryBitsForSchema(schema)).toBe(1029); + }); + it('computes cardinality 1 for const (0 bits)', () => { const schema: BoundedQuerySchemaNode = { type: 'const', value: 'ok' }; expect(schemaCardinality(schema)).toBe(1n); diff --git a/src/bounded-query/staging.ts b/src/bounded-query/staging.ts index 45baa4ce2..59699473f 100644 --- a/src/bounded-query/staging.ts +++ b/src/bounded-query/staging.ts @@ -85,14 +85,22 @@ const defaultGitRunner: GitRunner = async (args, options) => { export interface StageBoundedQuerySeedsParams { /** Trusted repository descriptors exactly as configured (already schema-validated). */ repos: BoundedQueryRepository[]; - /** Resolved bounded-query filesystem layout. */ - paths: BoundedQueryPaths; + /** + * Resolved private-root layout. + * + * Structurally typed (rather than the full {@link BoundedQueryPaths}) so the + * bounded-agent subsystem can reuse this trusted staging implementation + * against its own disjoint private root without duplicating it. + */ + paths: Pick; /** Run-unique id used to derive opaque seed directory names. */ runId: string; /** Staging credential. Never logged, never forwarded past this module. */ token: string; /** Override the git runner (tests). */ gitRunner?: GitRunner; + /** Log prefix identifying the calling subsystem. */ + label?: string; } /** Thrown for every staging failure. Messages never contain the token. */ @@ -396,6 +404,7 @@ export async function stageBoundedQuerySeeds( ): Promise { const { repos, paths, runId, token } = params; const gitRunner = params.gitRunner ?? defaultGitRunner; + const label = params.label ?? 'Bounded queries'; const isolatedHome = path.join(paths.root, 'staging-home'); fs.mkdirSync(isolatedHome, { recursive: true, mode: 0o700 }); @@ -409,14 +418,14 @@ export async function stageBoundedQuerySeeds( const seeds: BoundedQuerySeed[] = []; try { for (const repository of repos) { - logger.info(`Bounded queries: staging seed for ${repository.repo} (sensitivity: ${repository.sensitivity})...`); + logger.info(`${label}: staging seed for ${repository.repo} (sensitivity: ${repository.sensitivity})...`); seeds.push(await stageOneSeed(repository, { paths, runId, gitRunner, gitEnv })); } } catch (error) { releaseSeedPermissions(paths.seedsDir); fs.rmSync(paths.seedsDir, { recursive: true, force: true }); const message = error instanceof Error ? error.message : String(error); - throw new BoundedQueryStagingError(`Bounded-query staging failed: ${message}`); + throw new BoundedQueryStagingError(`${label}: staging failed: ${message}`); } finally { // The helper, token file, and isolated HOME are only needed for the // duration of staging. Removing them leaves no staging artifact behind diff --git a/src/cli-workflow.ts b/src/cli-workflow.ts index ca2150246..1a42bf79a 100644 --- a/src/cli-workflow.ts +++ b/src/cli-workflow.ts @@ -39,6 +39,14 @@ interface WorkflowDependencies { * failed. */ prepareBoundedQueries?: (config: WrapperConfig) => Promise; + /** + * Trusted bounded-agent preflight and staging. Runs in the same phase as + * bounded-query staging and for the same reasons: a failure must abort before + * any container exists, and the staging credential must be consumed and + * discarded before the broker, the enclave, or the primary agent can observe + * anything. + */ + prepareBoundedAgents?: (config: WrapperConfig) => Promise; /** * Fail-stop preflight for network-isolation mode. Aborts (process exit) when * topology enforcement cannot be supported on the current platform. @@ -98,6 +106,18 @@ export async function runMainWorkflow( await dependencies.prepareBoundedQueries(config); } + if (config.boundedAgents?.enabled) { + if (!dependencies.prepareBoundedAgents) { + // Fail loudly rather than generating a broker service whose seeds and + // socket were never staged — bounded agents are never half-enabled. + throw new Error( + 'Bounded agents are enabled but no staging implementation was provided to runMainWorkflow', + ); + } + logger.info('Staging bounded-agent repository seeds...'); + await dependencies.prepareBoundedAgents(config); + } + // Step 0: Setup host-level network and iptables // // In network-isolation (topology) mode, egress is enforced purely by Docker diff --git a/src/commands/build-config.ts b/src/commands/build-config.ts index 3c698c783..84ed3237d 100644 --- a/src/commands/build-config.ts +++ b/src/commands/build-config.ts @@ -2,6 +2,7 @@ import { WrapperConfig, LogLevel, UpstreamProxyConfig } from '../types'; import type { AwfFileConfig } from '../config-file'; import { resolveApiCredentials } from './resolve-credentials'; import { normalizeBoundedQueriesConfig } from '../parsers/bounded-query-parser'; +import { normalizeBoundedAgentsConfig } from '../parsers/bounded-agent-parser'; import { logger } from '../logger'; /** @@ -218,6 +219,9 @@ export function buildConfig(inputs: BuildConfigInputs): WrapperConfig { boundedQueries: normalizeBoundedQueriesConfig( options.boundedQueries as AwfFileConfig['boundedQueries'] | undefined, ), + boundedAgents: normalizeBoundedAgentsConfig( + options.boundedAgents as AwfFileConfig['boundedAgents'] | undefined, + ), }; } diff --git a/src/commands/main-action.test.ts b/src/commands/main-action.test.ts index 3d5b09e6a..09fd37f81 100644 --- a/src/commands/main-action.test.ts +++ b/src/commands/main-action.test.ts @@ -19,6 +19,7 @@ jest.mock('./signal-handler'); jest.mock('./validate-options'); jest.mock('../sbx-manager'); jest.mock('../bounded-query/ingress'); +jest.mock('../bounded-agent/ingress'); import { logger } from '../logger'; import * as dockerManager from '../docker-manager'; @@ -33,6 +34,7 @@ import * as signalHandler from './signal-handler'; import * as validateOptions from './validate-options'; import * as sbxManager from '../sbx-manager'; import * as boundedQueryIngress from '../bounded-query/ingress'; +import * as boundedAgentIngress from '../bounded-agent/ingress'; import { MAIN_ACTION_STUB_CONFIG, setupMainActionTestHarness } from './main-action.test-utils'; const { @@ -56,6 +58,7 @@ const mockedSignalHandler = signalHandler as jest.Mocked; const mockedValidateOptions = validateOptions as jest.Mocked; const mockedSbxManager = sbxManager as jest.Mocked; const mockedBoundedQueryIngress = boundedQueryIngress as jest.Mocked; +const mockedBoundedAgentIngress = boundedAgentIngress as jest.Mocked; describe('createMainAction', () => { let processExitSpy: jest.SpyInstance; @@ -395,9 +398,153 @@ describe('createMainAction', () => { expect(JSON.stringify(logCalls)).not.toContain(capability); expect(mockedBoundedQueryIngress.removeSbxIngressCapabilityFile).toHaveBeenCalledWith(sbxConfig); }); + + it('mounts only bounded-agent agent artifacts and injects the HTTP capability without logging it', async () => { + const capability = 'c'.repeat(64); + const sbxConfig = { + ...MAIN_ACTION_STUB_CONFIG, + containerRuntime: 'sbx', + containerWorkDir: '/workspace', + enableApiProxy: true, + boundedAgentIngressTransport: 'sbx-http', + boundedAgents: { + enabled: true, + privateRepos: [{ repo: 'octo/private', sensitivity: 'internal' }], + runtime: 'docker', + profile: 'openai', + model: 'gpt-4o-mini', + timeout: 120, + memoryLimit: '512m', + tmpfsLimit: '64m', + cpuLimit: '1', + pidsLimit: 128, + maxInvocations: 8, + maxModelRequests: 8, + maxModelTokens: 1024, + maxOutputBytes: 8192, + maxTaskBytes: 4096, + }, + } as unknown as import('../types').WrapperConfig; + mockedValidateOptions.validateOptions.mockReturnValue(sbxConfig); + mockedBoundedAgentIngress.resolveSbxIngress.mockResolvedValue({ + endpoint: 'http://host.docker.internal:49153/query', + queryCapability: capability, + probeCapability: 'd'.repeat(64), + skillPath: '/var/tmp/bounded-agent-ingress/skill/SKILL.md', + wrapperDir: '/var/tmp/bounded-agent-ingress/skill', + }); + mockedCliWorkflow.runMainWorkflow.mockImplementation(async (_config, deps) => { + await deps.startContainers('/tmp/awf-test', ['github.com']); + return (await deps.runAgentCommand('/tmp/awf-test', ['github.com'])).exitCode; + }); + + const action = createMainAction(getOptionValueSource); + await action(['bounded-agent --repo octo/private'], {}); + + const createOptions = mockedSbxManager.createSandbox.mock.calls[0][0]; + const mounts = createOptions.extraMounts ?? []; + expect(mounts).toHaveLength(1); + expect(mounts[0]).toMatch(/awf-bounded-agent-ingress-.*\/skill:ro$/); + expect(mounts.join(' ')).not.toMatch(/seeds|work|control|audit|docker\.sock|seed-map/); + + expect(mockedSbxManager.assertSbxBoundedAgentIngress).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + transport: 'sbx-http', + endpoint: 'http://host.docker.internal:49153/query', + probeCapability: 'd'.repeat(64), + }), + expect.any(Object), + '/workspace', + ); + const execCalls = mockedSbxManager.execInSandbox.mock.calls; + const agentEnvironment = execCalls[execCalls.length - 1]?.[2]?.environment; + expect(agentEnvironment).toEqual(expect.objectContaining({ + AWF_BOUNDED_AGENT_ENDPOINT: 'http://host.docker.internal:49153/query', + AWF_BOUNDED_AGENT_CAPABILITY: capability, + AWF_BOUNDED_AGENT_BIN_DIR: expect.stringMatching(/\/skill$/), + })); + const logCalls = [ + ...mockedLogger.debug.mock.calls, + ...mockedLogger.info.mock.calls, + ...mockedLogger.warn.mock.calls, + ...mockedLogger.error.mock.calls, + ]; + expect(JSON.stringify(logCalls)).not.toContain(capability); + expect(mockedBoundedAgentIngress.removeSbxIngressCapabilityFile).toHaveBeenCalledWith(sbxConfig); + + // Thread 4: telemetry must never report `ready` before the sbx ingress + // proof (assertSbxBoundedAgentIngress) actually succeeds. + const telemetryEvents = mockedLogger.info.mock.calls + .map((call) => String(call[0])) + .filter((line) => line.startsWith('Bounded-agent runtime telemetry: ')) + .map((line) => JSON.parse(line.slice('Bounded-agent runtime telemetry: '.length))); + expect(telemetryEvents).toContainEqual(expect.objectContaining({ + primaryBackend: 'sbx', + capabilityState: 'supported', + category: 'ready', + })); + }); + + it('reports a terminal unproven event and never `ready` when sbx ingress proof fails', async () => { + const sbxConfig = { + ...MAIN_ACTION_STUB_CONFIG, + containerRuntime: 'sbx', + containerWorkDir: '/workspace', + enableApiProxy: true, + boundedAgentIngressTransport: 'sbx-http', + boundedAgents: { + enabled: true, + privateRepos: [{ repo: 'octo/private', sensitivity: 'internal' }], + runtime: 'docker', + profile: 'openai', + model: 'gpt-4o-mini', + timeout: 120, + memoryLimit: '512m', + tmpfsLimit: '64m', + cpuLimit: '1', + pidsLimit: 128, + maxInvocations: 8, + maxModelRequests: 8, + maxModelTokens: 1024, + maxOutputBytes: 8192, + maxTaskBytes: 4096, + }, + } as unknown as import('../types').WrapperConfig; + mockedValidateOptions.validateOptions.mockReturnValue(sbxConfig); + mockedBoundedAgentIngress.resolveSbxIngress.mockResolvedValue({ + endpoint: 'http://host.docker.internal:49154/query', + queryCapability: 'e'.repeat(64), + probeCapability: 'f'.repeat(64), + skillPath: '/var/tmp/bounded-agent-ingress/skill/SKILL.md', + wrapperDir: '/var/tmp/bounded-agent-ingress/skill', + }); + mockedSbxManager.assertSbxBoundedAgentIngress.mockRejectedValueOnce( + new Error('sbx host does not support the selected bounded-agent sbx-http ingress'), + ); + mockedCliWorkflow.runMainWorkflow.mockImplementation(async (_config, deps) => { + await deps.startContainers('/tmp/awf-test', ['github.com']); + return (await deps.runAgentCommand('/tmp/awf-test', ['github.com'])).exitCode; + }); + + const action = createMainAction(getOptionValueSource); + await expect(action(['bounded-agent --repo octo/private'], {})).rejects.toThrow('process.exit: 1'); + + const telemetryEvents = mockedLogger.info.mock.calls + .map((call) => String(call[0])) + .filter((line) => line.startsWith('Bounded-agent runtime telemetry: ')) + .map((line) => JSON.parse(line.slice('Bounded-agent runtime telemetry: '.length))); + expect(telemetryEvents.some((event) => event.category === 'ready')).toBe(false); + expect(telemetryEvents).toContainEqual(expect.objectContaining({ + primaryBackend: 'sbx', + capabilityState: 'unavailable', + category: 'primary-sbx-ingress-unproven', + })); + }); }); }); + describe('when runMainWorkflow throws', () => { it('calls performCleanup and exits with code 1', async () => { mockedCliWorkflow.runMainWorkflow.mockRejectedValue(new Error('docker failed')); diff --git a/src/commands/main-action.ts b/src/commands/main-action.ts index 166c2a5ad..6734a68ce 100644 --- a/src/commands/main-action.ts +++ b/src/commands/main-action.ts @@ -30,6 +30,7 @@ import { runtimeUsesComposeAgent } from '../container-runtime'; import { assertSbxApiProxyReflect, assertSbxBoundedQueryIngress, + assertSbxBoundedAgentIngress, createSandbox, execInSandbox, removeSandbox, @@ -37,6 +38,11 @@ import { SBX_DEFAULT_NAME, } from '../sbx-manager'; import { prepareBoundedQueries, teardownBoundedQueries } from '../bounded-query/manager'; +import { + prepareBoundedAgents, + reportBoundedAgentSbxIngressResult, + teardownBoundedAgents, +} from '../bounded-agent/manager'; import type { WrapperConfig } from '../types'; import { buildAgentEnvironment } from '../services/agent-service'; import { buildAgentCredentialEnv } from '../services/api-proxy-credential-env'; @@ -47,6 +53,11 @@ import { resolveSbxIngress, } from '../bounded-query/ingress'; import { resolveBoundedQueryPaths } from '../bounded-query/paths'; +import { + removeSbxIngressCapabilityFile as removeBoundedAgentSbxIngressCapabilityFile, + resolveSbxIngress as resolveBoundedAgentSbxIngress, +} from '../bounded-agent/ingress'; +import { resolveBoundedAgentPaths } from '../bounded-agent/paths'; /** Report whether a secret is set (and its length) without exposing the value. */ function redactSecret(value: string | undefined): string { @@ -141,6 +152,7 @@ function buildCleanupFn( // work-directory cleanup: `rm -rf` cannot unlink entries inside a // directory whose write bit was stripped during staging. await teardownBoundedQueries(config); + await teardownBoundedAgents(config); if (!config.keepContainers) { await cleanup( @@ -298,6 +310,7 @@ export function createMainAction(getOptionValueSource: OptionSourceResolver) { const SBX_GATEWAY_IP = '172.17.0.0'; const SBX_HOST_DOCKER_INTERNAL = 'host.docker.internal'; const boundedQueryPaths = resolveBoundedQueryPaths(config.workDir); + const boundedAgentPaths = resolveBoundedAgentPaths(config.workDir); const sbxMounts = [...(config.volumeMounts ?? [])]; let sbxBoundedQueryIngress: | { transport: 'unix'; socketPath: string } @@ -308,6 +321,15 @@ export function createMainAction(getOptionValueSource: OptionSourceResolver) { probeCapability: string; } | undefined; + let sbxBoundedAgentIngress: + | { transport: 'unix'; socketPath: string } + | { + transport: 'sbx-http'; + endpoint: string; + queryCapability: string; + probeCapability: string; + } + | undefined; if (config.boundedQueries?.enabled) { sbxMounts.push(`${boundedQueryPaths.agentDir}:ro`); @@ -328,6 +350,25 @@ export function createMainAction(getOptionValueSource: OptionSourceResolver) { } } + if (config.boundedAgents?.enabled) { + sbxMounts.push(`${boundedAgentPaths.agentDir}:ro`); + if (config.boundedAgentIngressTransport === 'unix') { + sbxMounts.push(`${boundedAgentPaths.runDir}:ro`); + sbxBoundedAgentIngress = { + transport: 'unix', + socketPath: boundedAgentPaths.socketPath, + }; + } else { + const ingress = await resolveBoundedAgentSbxIngress(config); + sbxBoundedAgentIngress = { + transport: 'sbx-http', + endpoint: ingress.endpoint, + queryCapability: ingress.queryCapability, + probeCapability: ingress.probeCapability, + }; + } + } + sbxEnvironment = buildAgentEnvironment({ config, networkConfig: { @@ -406,6 +447,50 @@ export function createMainAction(getOptionValueSource: OptionSourceResolver) { } } + if (sbxBoundedAgentIngress) { + try { + await assertSbxBoundedAgentIngress( + sbxName, + sbxBoundedAgentIngress.transport === 'unix' + ? sbxBoundedAgentIngress + : { + transport: 'sbx-http', + endpoint: sbxBoundedAgentIngress.endpoint, + probeCapability: sbxBoundedAgentIngress.probeCapability, + }, + sbxEnvironment, + config.containerWorkDir, + ); + } catch (error) { + // Preflight only proved the sbx CLI and enclave capability exist; + // this is the executable proof that the selected ingress + // transport is actually reachable from inside the sandbox. Never + // report `ready` telemetry when that proof fails. + reportBoundedAgentSbxIngressResult(config, 'failed'); + throw error; + } + // Ingress is proven reachable now — this is the only point a + // primary-sbx run is ever reported `ready`. + reportBoundedAgentSbxIngressResult(config, 'proven'); + + Object.assign(sbxEnvironment, { + AWF_BOUNDED_AGENT_SKILL: boundedAgentPaths.skillPath, + AWF_BOUNDED_AGENT_REPOS: config.boundedAgents!.privateRepos + .map((repository) => repository.repo) + .join(','), + AWF_BOUNDED_AGENT_BIN_DIR: boundedAgentPaths.agentDir, + ...(sbxBoundedAgentIngress.transport === 'unix' + ? { AWF_BOUNDED_AGENT_SOCKET: sbxBoundedAgentIngress.socketPath } + : { + AWF_BOUNDED_AGENT_ENDPOINT: sbxBoundedAgentIngress.endpoint, + AWF_BOUNDED_AGENT_CAPABILITY: sbxBoundedAgentIngress.queryCapability, + }), + }); + if (sbxBoundedAgentIngress.transport === 'sbx-http') { + removeBoundedAgentSbxIngressCapabilityFile(config); + } + } + // gh-aw fetches reflection data from the fixed api-proxy hostname. The // microVM reaches the sidecar through its published host ports, so install // that alias and prove the real endpoint before launching the agent. @@ -476,6 +561,7 @@ export function createMainAction(getOptionValueSource: OptionSourceResolver) { assertTopologySupported, connectTopologyContainers, prepareBoundedQueries, + prepareBoundedAgents, }, { logger, diff --git a/src/compose-generator.ts b/src/compose-generator.ts index 8daf0fec1..8d4041b35 100644 --- a/src/compose-generator.ts +++ b/src/compose-generator.ts @@ -14,6 +14,12 @@ import { runtimeNeedsStaticDns, runtimeUsesComposeAgent } from './container-runt import { API_PROXY_PORTS } from './types/ports'; import { EXTERNAL_BRIDGE_NAME } from './config/network-policy'; import { BOUNDED_QUERY_INGRESS_NETWORK } from './bounded-query/ingress'; +import { BOUNDED_AGENT_INGRESS_NETWORK } from './bounded-agent/ingress'; +import { + BOUNDED_AGENT_EGRESS_NETWORK, + BOUNDED_AGENT_NETWORK, + BOUNDED_AGENT_SUBNET, +} from './bounded-agent/network'; import { buildInternalServiceHosts } from './services/internal-service-hosts'; /** @@ -190,6 +196,41 @@ export function generateDockerCompose( internal: true, }; } + if (config.boundedAgents?.enabled) { + // Dedicated `internal` network whose only members are bounded-agent + // enclaves and the dual-homed API proxy. An explicit `name:` is required + // because the broker launches enclaves with a fixed + // `docker run --network ` argument and must not have to derive a + // Compose project prefix at runtime. + compose.networks[BOUNDED_AGENT_NETWORK] = { + name: BOUNDED_AGENT_NETWORK, + driver: 'bridge', + internal: true, + ipam: { + config: [{ subnet: BOUNDED_AGENT_SUBNET }], + }, + }; + // Only the dedicated credential sidecar joins this bridge. It receives + // direct upstream egress while enclaves remain confined to the internal + // network and the primary agent cannot observe its metrics or state. + compose.networks[BOUNDED_AGENT_EGRESS_NETWORK] = { + name: BOUNDED_AGENT_EGRESS_NETWORK, + driver: 'bridge', + }; + if ( + config.boundedAgentIngressTransport === 'sbx-http' + || (config.boundedAgentIngressTransport === undefined && !includeAgent) + ) { + // Distinct from BOUNDED_AGENT_NETWORK (the enclave/API-proxy network): + // this is a dedicated `internal` bridge whose only members are the + // broker and, transiently, the primary sbx microVM's host-gateway + // route — never an enclave, never the primary agent's own network. + compose.networks[BOUNDED_AGENT_INGRESS_NETWORK] = { + driver: 'bridge', + internal: true, + }; + } + } return compose; } diff --git a/src/config-file.ts b/src/config-file.ts index 130aaa74b..d18fee1d3 100644 --- a/src/config-file.ts +++ b/src/config-file.ts @@ -187,6 +187,33 @@ export interface AwfFileConfig { interpreter?: 'python3'; maxInvocations?: number; }; + /** + * Bounded-agent enclave configuration. + * + * When enabled, AWF starts a network-isolated broker container that runs a + * fixed, AWF-authored model loop inside a per-invocation enclave. The + * enclave joins only a dedicated `internal` bounded-agent network whose sole + * other member is a dedicated AWF API proxy — no Squid, no general proxy, no primary + * agent, no broker, no MCP gateway, no CLI proxy, and no host state. See + * docs/awf-config-spec.md §15 for the full model. + */ + boundedAgents?: { + enabled?: boolean; + privateRepos?: Array<{ repo: string; sensitivity: 'public' | 'internal' | 'confidential' | 'sealed' }>; + runtime?: 'docker' | 'gvisor' | 'sbx'; + profile?: 'openai' | 'anthropic'; + model?: string; + timeout?: number; + memoryLimit?: string; + cpuLimit?: string; + pidsLimit?: number; + tmpfsLimit?: string; + maxOutputBytes?: number; + maxTaskBytes?: number; + maxInvocations?: number; + maxModelRequests?: number; + maxModelTokens?: number; + }; } /** diff --git a/src/config-mapper.ts b/src/config-mapper.ts index fb605324e..efa8105a0 100644 --- a/src/config-mapper.ts +++ b/src/config-mapper.ts @@ -148,5 +148,9 @@ export function mapAwfFileConfigToCliOptions(config: AwfFileConfig): Record { expect(mockExecaFn).toHaveBeenCalledWith( 'docker', - ['rm', '-f', 'awf-squid', 'awf-agent', 'awf-iptables-init', 'awf-api-proxy', 'awf-cli-proxy', 'awf-bounded-query-broker'], + [ + 'rm', + '-f', + 'awf-squid', + 'awf-agent', + 'awf-iptables-init', + 'awf-api-proxy', + 'awf-cli-proxy', + 'awf-bounded-query-broker', + 'awf-bounded-agent-broker', + 'awf-bounded-agent-api-proxy', + ], expect.objectContaining({ reject: false }) ); }); diff --git a/src/image-tag.ts b/src/image-tag.ts index b49f634ef..7ae061a67 100644 --- a/src/image-tag.ts +++ b/src/image-tag.ts @@ -1,6 +1,6 @@ import path from 'path'; -const IMAGE_DIGEST_KEYS = ['squid', 'agent', 'agent-act', 'api-proxy', 'cli-proxy', 'build-tools', 'bounded-query', 'bounded-query-broker'] as const; +const IMAGE_DIGEST_KEYS = ['squid', 'agent', 'agent-act', 'api-proxy', 'cli-proxy', 'build-tools', 'bounded-query', 'bounded-query-broker', 'bounded-agent', 'bounded-agent-broker'] as const; type ImageDigestKey = typeof IMAGE_DIGEST_KEYS[number]; diff --git a/src/parsers/bounded-agent-parser.test.ts b/src/parsers/bounded-agent-parser.test.ts new file mode 100644 index 000000000..8606601dc --- /dev/null +++ b/src/parsers/bounded-agent-parser.test.ts @@ -0,0 +1,177 @@ +import { normalizeBoundedAgentsConfig } from './bounded-agent-parser'; +import { BOUNDED_AGENT_DEFAULTS } from '../types/bounded-agent-options'; +import { validateAwfFileConfig } from '../config-file'; + +/** + * Config/schema coverage for the `boundedAgents` section: defaults, explicit + * overrides, and the fail-closed JSON Schema rules that the normalizer assumes + * already hold. + */ +describe('normalizeBoundedAgentsConfig', () => { + it('returns undefined when the section is absent', () => { + expect(normalizeBoundedAgentsConfig(undefined)).toBeUndefined(); + }); + + it('applies every centralized default for an empty section', () => { + const normalized = normalizeBoundedAgentsConfig({}); + expect(normalized).toEqual({ ...BOUNDED_AGENT_DEFAULTS, privateRepos: [] }); + }); + + it('only enables on an explicit true', () => { + expect(normalizeBoundedAgentsConfig({})!.enabled).toBe(false); + expect(normalizeBoundedAgentsConfig({ enabled: false })!.enabled).toBe(false); + expect(normalizeBoundedAgentsConfig({ enabled: true })!.enabled).toBe(true); + }); + + it('preserves explicitly configured values', () => { + const normalized = normalizeBoundedAgentsConfig({ + enabled: true, + privateRepos: [{ repo: 'octo/Alpha', sensitivity: 'confidential' }], + runtime: 'gvisor', + profile: 'anthropic', + model: 'claude-sonnet-4', + timeout: 240, + memoryLimit: '1g', + cpuLimit: '0.5', + pidsLimit: 64, + tmpfsLimit: '32m', + maxOutputBytes: 512, + maxTaskBytes: 1024, + maxInvocations: 3, + maxModelRequests: 4, + maxModelTokens: 256, + }); + + expect(normalized).toEqual({ + enabled: true, + privateRepos: [{ repo: 'octo/Alpha', sensitivity: 'confidential' }], + runtime: 'gvisor', + profile: 'anthropic', + model: 'claude-sonnet-4', + timeout: 240, + memoryLimit: '1g', + cpuLimit: '0.5', + pidsLimit: 64, + tmpfsLimit: '32m', + maxOutputBytes: 512, + maxTaskBytes: 1024, + maxInvocations: 3, + maxModelRequests: 4, + maxModelTokens: 256, + }); + }); +}); + +describe('boundedAgents JSON Schema', () => { + const valid = { + boundedAgents: { + enabled: true, + privateRepos: [{ repo: 'octo/alpha', sensitivity: 'internal' }], + model: 'gpt-4o-mini', + }, + }; + + it('accepts a minimal enabled configuration', () => { + expect(validateAwfFileConfig(valid)).toEqual([]); + }); + + it('requires privateRepos and model when enabled', () => { + expect(validateAwfFileConfig({ boundedAgents: { enabled: true } }).length).toBeGreaterThan(0); + expect( + validateAwfFileConfig({ boundedAgents: { enabled: true, model: 'gpt-4o-mini' } }).length, + ).toBeGreaterThan(0); + expect( + validateAwfFileConfig({ + boundedAgents: { enabled: true, privateRepos: valid.boundedAgents.privateRepos }, + }).length, + ).toBeGreaterThan(0); + }); + + it('rejects unknown keys', () => { + expect( + validateAwfFileConfig({ boundedAgents: { ...valid.boundedAgents, image: 'evil:latest' } }).length, + ).toBeGreaterThan(0); + expect( + validateAwfFileConfig({ boundedAgents: { ...valid.boundedAgents, tools: ['shell'] } }).length, + ).toBeGreaterThan(0); + }); + + it('accepts docker, gvisor, and sbx runtimes (sbx is blocked later, at preflight)', () => { + for (const runtime of ['docker', 'gvisor', 'sbx']) { + expect(validateAwfFileConfig({ boundedAgents: { ...valid.boundedAgents, runtime } })).toEqual([]); + } + expect( + validateAwfFileConfig({ boundedAgents: { ...valid.boundedAgents, runtime: 'firecracker' } }).length, + ).toBeGreaterThan(0); + }); + + it('accepts only the implemented provider profiles', () => { + for (const profile of ['openai', 'anthropic']) { + expect(validateAwfFileConfig({ boundedAgents: { ...valid.boundedAgents, profile } })).toEqual([]); + } + expect( + validateAwfFileConfig({ boundedAgents: { ...valid.boundedAgents, profile: 'gemini' } }).length, + ).toBeGreaterThan(0); + }); + + it('rejects repository slugs that are not bare owner/repo', () => { + for (const repo of [ + 'https://github.com/octo/alpha', + 'octo/alpha/../../etc', + 'octo/*', + 'octo', + 'user:token@octo/alpha', + ]) { + expect( + validateAwfFileConfig({ + boundedAgents: { ...valid.boundedAgents, privateRepos: [{ repo, sensitivity: 'internal' }] }, + }).length, + ).toBeGreaterThan(0); + } + }); + + it('rejects a legacy bare-string privateRepos entry', () => { + expect( + validateAwfFileConfig({ + boundedAgents: { ...valid.boundedAgents, privateRepos: ['octo/alpha'] }, + }).length, + ).toBeGreaterThan(0); + }); + + it('bounds the conservative resource and budget fields', () => { + const outOfRange: Array> = [ + { timeout: 0 }, + { timeout: 541 }, + { memoryLimit: 'lots' }, + { cpuLimit: 'all' }, + { pidsLimit: 0 }, + { tmpfsLimit: '64' }, + { maxOutputBytes: 0 }, + { maxOutputBytes: 8193 }, + { maxTaskBytes: 0 }, + { maxTaskBytes: 65537 }, + { maxInvocations: 0 }, + { maxModelRequests: 0 }, + { maxModelRequests: 65 }, + { maxModelTokens: 0 }, + { maxModelTokens: 32769 }, + ]; + for (const patch of outOfRange) { + expect( + validateAwfFileConfig({ boundedAgents: { ...valid.boundedAgents, ...patch } }).length, + ).toBeGreaterThan(0); + } + }); + + it('leaves boundedQueries untouched (no regression)', () => { + expect( + validateAwfFileConfig({ + boundedQueries: { + enabled: true, + privateRepos: [{ repo: 'octo/alpha', sensitivity: 'internal' }], + }, + ...valid, + }), + ).toEqual([]); + }); +}); diff --git a/src/parsers/bounded-agent-parser.ts b/src/parsers/bounded-agent-parser.ts new file mode 100644 index 000000000..0843c4a38 --- /dev/null +++ b/src/parsers/bounded-agent-parser.ts @@ -0,0 +1,57 @@ +import type { AwfFileConfig } from '../config-file'; +import { + BOUNDED_AGENT_DEFAULTS, + type BoundedAgentRepository, + type BoundedAgentsConfig, +} from '../types/bounded-agent-options'; + +/** + * Normalizes the raw `boundedAgents` section of an AWF config file into a + * fully-resolved {@link BoundedAgentsConfig}, applying + * {@link BOUNDED_AGENT_DEFAULTS} for any field left unset. + * + * By the time this runs, `raw` has already passed schema validation + * (`validateAwfFileConfig` / docs/awf-config.schema.json), so bounds and enums + * are assumed to already hold. This function only fills in defaults — it does + * not re-validate repository shape, uniqueness, runtime availability, or the + * API-proxy requirement (see `src/bounded-agent/preflight.ts` for the + * fail-closed checks). + * + * Unlike `boundedQueries`, there is no legacy bare-string `privateRepos` + * compatibility path: `boundedAgents` is new in this release, so every entry + * must already be the explicit `{ repo, sensitivity }` object form. + * + * Returns `undefined` when `raw` is `undefined`, i.e. the config file did not + * include a `boundedAgents` section at all. When the section is present (even + * as `{}`), a fully-defaulted config is always returned. + */ +export function normalizeBoundedAgentsConfig( + raw: AwfFileConfig['boundedAgents'] | undefined, +): BoundedAgentsConfig | undefined { + if (!raw) return undefined; + + const privateRepos: BoundedAgentRepository[] = (raw.privateRepos ?? []).map((entry) => ({ + repo: entry.repo, + sensitivity: entry.sensitivity, + })); + + return { + // Only an explicit `true` enables bounded agents; anything else (including + // omission) normalizes to disabled. + enabled: raw.enabled === true, + privateRepos, + runtime: raw.runtime ?? BOUNDED_AGENT_DEFAULTS.runtime, + profile: raw.profile ?? BOUNDED_AGENT_DEFAULTS.profile, + model: raw.model ?? BOUNDED_AGENT_DEFAULTS.model, + timeout: raw.timeout ?? BOUNDED_AGENT_DEFAULTS.timeout, + memoryLimit: raw.memoryLimit ?? BOUNDED_AGENT_DEFAULTS.memoryLimit, + cpuLimit: raw.cpuLimit ?? BOUNDED_AGENT_DEFAULTS.cpuLimit, + pidsLimit: raw.pidsLimit ?? BOUNDED_AGENT_DEFAULTS.pidsLimit, + tmpfsLimit: raw.tmpfsLimit ?? BOUNDED_AGENT_DEFAULTS.tmpfsLimit, + maxOutputBytes: raw.maxOutputBytes ?? BOUNDED_AGENT_DEFAULTS.maxOutputBytes, + maxTaskBytes: raw.maxTaskBytes ?? BOUNDED_AGENT_DEFAULTS.maxTaskBytes, + maxInvocations: raw.maxInvocations ?? BOUNDED_AGENT_DEFAULTS.maxInvocations, + maxModelRequests: raw.maxModelRequests ?? BOUNDED_AGENT_DEFAULTS.maxModelRequests, + maxModelTokens: raw.maxModelTokens ?? BOUNDED_AGENT_DEFAULTS.maxModelTokens, + }; +} diff --git a/src/sbx-manager.test.ts b/src/sbx-manager.test.ts index 9e2a33051..e6c4f57ad 100644 --- a/src/sbx-manager.test.ts +++ b/src/sbx-manager.test.ts @@ -1,5 +1,6 @@ import { assertSbxApiProxyReflect, + assertSbxBoundedAgentIngress, assertSbxBoundedQueryIngress, createSandbox, execInSandbox, @@ -41,6 +42,8 @@ jest.mock('fs', () => { const mockedExistsSync = fs.existsSync as jest.Mock; const mockedReaddirSync = fs.readdirSync as jest.Mock; const mockedRenameSync = fs.renameSync as jest.Mock; +const mockedMkdirSync = fs.mkdirSync as jest.Mock; +const mockedRmSync = fs.rmSync as jest.Mock; const mockedLogger = jest.mocked(logger); @@ -171,6 +174,51 @@ describe('sbx-manager', () => { await expect(probeSbxUnixSocketMount()).rejects.toThrow(/could not be removed/); }); + + it('identifies bounded-agent probe cleanup failures without bounded-query wording', async () => { + mockExecaFn + .mockResolvedValueOnce({ exitCode: 0, stdout: 'Created sandbox', stderr: '' }) + .mockResolvedValueOnce({ exitCode: 7, stdout: '', stderr: 'connect failed' }) + .mockResolvedValueOnce({ exitCode: 1, stdout: '', stderr: 'busy' }); + + await expect(probeSbxUnixSocketMount('bounded-agent')).rejects.toThrow( + 'sbx bounded-agent ingress probe sandbox could not be removed', + ); + }); + + it('fails closed when the disposable probe sandbox cannot be created', async () => { + mockExecaFn.mockResolvedValueOnce({ + exitCode: 1, + stdout: '', + stderr: 'sandbox service unavailable', + }); + + await expect(probeSbxUnixSocketMount()).rejects.toThrow( + /could not create a sandbox.*sandbox service unavailable/, + ); + }); + + it('identifies bounded-agent probe creation failures without bounded-query wording', async () => { + mockExecaFn.mockResolvedValueOnce({ + exitCode: 1, + stdout: '', + stderr: 'sandbox service unavailable', + }); + + await expect(probeSbxUnixSocketMount('bounded-agent')).rejects.toThrow( + /sbx bounded-agent ingress probe could not create a sandbox/, + ); + }); + + it('cleans up and propagates an unexpected socket probe failure', async () => { + mockExecaFn + .mockResolvedValueOnce({ exitCode: 0, stdout: 'Created sandbox', stderr: '' }) + .mockRejectedValueOnce(new Error('exec unavailable')) + .mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' }); + + await expect(probeSbxUnixSocketMount()).rejects.toThrow('exec unavailable'); + expect(mockExecaFn.mock.calls[2][1]).toEqual(expect.arrayContaining(['rm', '--force'])); + }); }); }); @@ -212,6 +260,90 @@ describe('sbx-manager', () => { })); }); + it('proves sbx-http ingress with a capability-authenticated HTTP exchange', async () => { + mockExecaFn.mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' }); + + await expect(assertSbxBoundedQueryIngress( + 'awf-agent-test', + { transport: 'sbx-http', endpoint: 'http://host.docker.internal:49152/query', probeCapability: 'p'.repeat(64) }, + {}, + '/workspace', + )).resolves.toBeUndefined(); + + const args: string[] = mockExecaFn.mock.calls[0][1]; + const command = args[args.length - 1]; + expect(command).toContain('$AWF_BOUNDED_QUERY_ENDPOINT'); + expect(command).toContain('X-AWF-Capability: $AWF_BOUNDED_QUERY_PROBE_CAPABILITY'); + expect(command).not.toContain('p'.repeat(64)); + }); + + it('fails closed when the probe exchange does not return the canonical error body', async () => { + mockExecaFn.mockResolvedValueOnce({ exitCode: 1, stdout: '', stderr: '' }); + + await expect(assertSbxBoundedQueryIngress( + 'awf-agent-test', + { transport: 'unix', socketPath: '/var/tmp/broker.sock' }, + {}, + )).rejects.toThrow(/sbx host does not support the selected bounded-query unix ingress/); + }); + + describe('assertSbxBoundedAgentIngress', () => { + it('proves Unix ingress with an HTTP exchange over the mounted socket, using its own env vars', async () => { + mockExecaFn.mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' }); + + await expect(assertSbxBoundedAgentIngress( + 'awf-agent-test', + { transport: 'unix', socketPath: '/var/tmp/bounded-agent-broker.sock' }, + {}, + '/workspace', + )).resolves.toBeUndefined(); + + const args: string[] = mockExecaFn.mock.calls[0][1]; + const command = args[args.length - 1]; + expect(command).toContain('--unix-socket "$AWF_BOUNDED_AGENT_SOCKET"'); + expect(command).toContain('http://localhost/query'); + expect(command).toContain('{"status":"error"}'); + // Must never reuse the bounded-query env var names. + expect(command).not.toContain('AWF_BOUNDED_QUERY_SOCKET'); + }); + + it('proves sbx-http ingress with a capability-authenticated HTTP exchange, distinct from bounded queries', async () => { + mockExecaFn.mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' }); + const environment: Record = {}; + + await expect(assertSbxBoundedAgentIngress( + 'awf-agent-test', + { + transport: 'sbx-http', + endpoint: 'http://host.docker.internal:49153/query', + probeCapability: 'q'.repeat(64), + }, + environment, + '/workspace', + )).resolves.toBeUndefined(); + + const args: string[] = mockExecaFn.mock.calls[0][1]; + const command = args[args.length - 1]; + expect(command).toContain('$AWF_BOUNDED_AGENT_ENDPOINT'); + expect(command).toContain('X-AWF-Capability: $AWF_BOUNDED_AGENT_PROBE_CAPABILITY'); + expect(command).not.toContain('AWF_BOUNDED_QUERY_SOCKET'); + expect(command).not.toContain('AWF_BOUNDED_QUERY_ENDPOINT'); + // The capability value itself is passed only via the execInSandbox + // environment map, never inlined into the shell command string. + expect(command).not.toContain('q'.repeat(64)); + }); + + it('fails closed when the bounded-agent probe exchange fails, with a distinct error message', async () => { + mockExecaFn.mockResolvedValueOnce({ exitCode: 1, stdout: '', stderr: '' }); + + await expect(assertSbxBoundedAgentIngress( + 'awf-agent-test', + { transport: 'unix', socketPath: '/var/tmp/bounded-agent-broker.sock' }, + {}, + )).rejects.toThrow(/sbx host does not support the selected bounded-agent unix ingress/); + }); + }); + describe('assertSbxApiProxyReflect', () => { it('installs a resolver alias and probes the reflection endpoint with Node fetch', async () => { mockExecaFn.mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' }); @@ -384,6 +516,48 @@ describe('sbx-manager', () => { restoreHomeCredentials(); }); + it('continues without mutating credentials when the backup directory cannot be created', async () => { + const homePath = process.env.HOME || '/home/runner'; + const secret = `${homePath}/.claude/.credentials.json`; + mockedExistsSync.mockImplementation( + (p: fs.PathLike) => String(p) === `${homePath}/.claude` || String(p) === secret, + ); + mockedMkdirSync.mockImplementationOnce(() => { + throw new Error('read-only home'); + }); + mockExecaFn + .mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' }) + .mockResolvedValueOnce({ exitCode: 0, stdout: 'Created sandbox', stderr: '' }); + + await createSandbox({ workspaceDir: '/workspace', squidIp: '172.30.0.10' }); + + expect(mockedRenameSync).not.toHaveBeenCalled(); + expect(mockedLogger.warn).toHaveBeenCalledWith( + expect.stringContaining('Could not create credential backup dir'), + ); + }); + + it('continues after a credential path cannot be moved aside', async () => { + const homePath = process.env.HOME || '/home/runner'; + const secret = `${homePath}/.claude/.credentials.json`; + mockedExistsSync.mockImplementation( + (p: fs.PathLike) => String(p) === `${homePath}/.claude` || String(p) === secret, + ); + mockedRenameSync.mockImplementationOnce(() => { + throw new Error('busy'); + }); + mockExecaFn + .mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' }) + .mockResolvedValueOnce({ exitCode: 0, stdout: 'Created sandbox', stderr: '' }); + + await createSandbox({ workspaceDir: '/workspace', squidIp: '172.30.0.10' }); + + expect(mockedLogger.warn).toHaveBeenCalledWith( + expect.stringContaining('Could not hide credential path'), + ); + restoreHomeCredentials(); + }); + it('restores scrubbed credentials after the sandbox is removed', async () => { const homePath = process.env.HOME || '/home/runner'; const secret = `${homePath}/.claude/.credentials.json`; @@ -418,6 +592,33 @@ describe('sbx-manager', () => { expect(restoreMoves).toContainEqual([backupPath, secret]); }); + it('preserves the backup when credential restoration fails', async () => { + const homePath = process.env.HOME || '/home/runner'; + const secret = `${homePath}/.claude/.credentials.json`; + mockedExistsSync.mockImplementation( + (p: fs.PathLike) => + String(p) === `${homePath}/.claude` || + String(p) === secret || + String(p).includes('.awf-sbx-cred-backup'), + ); + mockExecaFn + .mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' }) + .mockResolvedValueOnce({ exitCode: 0, stdout: 'Created sandbox', stderr: '' }); + await createSandbox({ workspaceDir: '/workspace', squidIp: '172.30.0.10' }); + mockedRenameSync.mockImplementationOnce(() => { + throw new Error('restore denied'); + }); + mockedRmSync.mockImplementationOnce(() => { + throw new Error('not empty'); + }); + + restoreHomeCredentials(); + + expect(mockedLogger.warn).toHaveBeenCalledWith( + expect.stringContaining('Could not restore credential path'), + ); + }); + it('skips whitelisted home subdirs that do not exist on the host', async () => { const homePath = process.env.HOME || '/home/runner'; mockedExistsSync.mockImplementation( @@ -495,6 +696,19 @@ describe('sbx-manager', () => { ); }); + it('reports an authentication failure when the probes return no diagnostics', async () => { + mockExecaFn + .mockResolvedValueOnce({ exitCode: 1, stdout: '', stderr: '' }) + .mockResolvedValueOnce({ exitCode: 1, stdout: '', stderr: '' }); + + await expect(createSandbox({ workspaceDir: '/ws', squidIp: '172.30.0.10' })).rejects.toThrow( + /sbx is not authenticated/, + ); + expect(mockedLogger.error).toHaveBeenCalledWith( + expect.stringContaining('daemon status: '), + ); + }); + it('throws when sbx create fails with non-zero exit', async () => { mockExecaFn .mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' }) @@ -702,7 +916,7 @@ describe('sbx-manager', () => { expect(args).toContain('-lc'); const shellCommand = args[args.length - 1]; expect(shellCommand).toBe( - 'export PATH="${AWF_BOUNDED_QUERY_BIN_DIR:+$AWF_BOUNDED_QUERY_BIN_DIR:}$HOME/.local/bin${PATH:+:$PATH}"; copilot --version', + 'export PATH="${AWF_BOUNDED_QUERY_BIN_DIR:+$AWF_BOUNDED_QUERY_BIN_DIR:}${AWF_BOUNDED_AGENT_BIN_DIR:+$AWF_BOUNDED_AGENT_BIN_DIR:}$HOME/.local/bin${PATH:+:$PATH}"; copilot --version', ); expect(shellCommand.indexOf('.local/bin')).toBeLessThan( shellCommand.indexOf('copilot --version'), @@ -722,7 +936,7 @@ describe('sbx-manager', () => { describe('withLocalBinOnPath', () => { it('prepends ~/.local/bin using the runtime $HOME', () => { expect(withLocalBinOnPath('copilot')).toBe( - 'export PATH="${AWF_BOUNDED_QUERY_BIN_DIR:+$AWF_BOUNDED_QUERY_BIN_DIR:}$HOME/.local/bin${PATH:+:$PATH}"; copilot', + 'export PATH="${AWF_BOUNDED_QUERY_BIN_DIR:+$AWF_BOUNDED_QUERY_BIN_DIR:}${AWF_BOUNDED_AGENT_BIN_DIR:+$AWF_BOUNDED_AGENT_BIN_DIR:}$HOME/.local/bin${PATH:+:$PATH}"; copilot', ); }); diff --git a/src/sbx-manager.ts b/src/sbx-manager.ts index dff0b2ef1..e75fde6df 100644 --- a/src/sbx-manager.ts +++ b/src/sbx-manager.ts @@ -366,9 +366,15 @@ export async function createSandbox(config: { * installed rootless to ~/.local/bin (install_copilot_cli.sh --rootless) stays * resolvable by name. `$HOME` resolves to the injected HOME (getRealUserHome), * which matches the wholesale-mounted home tool dirs. + * + * Also prepends the bounded-query and/or bounded-agent wrapper directories + * (`AWF_BOUNDED_QUERY_BIN_DIR` / `AWF_BOUNDED_AGENT_BIN_DIR`) when those + * subsystems are enabled, since a primary sbx microVM has no `/tmp/awf-lib` + * chroot-relative PATH entry to fall back on. Either variable is empty (and + * therefore a no-op) unless its subsystem is enabled for this run. */ function withLocalBinOnPath(command: string): string { - return `export PATH="\${AWF_BOUNDED_QUERY_BIN_DIR:+$AWF_BOUNDED_QUERY_BIN_DIR:}$HOME/.local/bin\${PATH:+:$PATH}"; ${command}`; + return `export PATH="\${AWF_BOUNDED_QUERY_BIN_DIR:+$AWF_BOUNDED_QUERY_BIN_DIR:}\${AWF_BOUNDED_AGENT_BIN_DIR:+$AWF_BOUNDED_AGENT_BIN_DIR:}$HOME/.local/bin\${PATH:+:$PATH}"; ${command}`; } /** @internal Exposed for unit tests only. */ @@ -390,7 +396,9 @@ export const testHelpers = { * failures are fatal so preflight never silently downgrades an unsupported * host. */ -export async function probeSbxUnixSocketMount(): Promise { +export async function probeSbxUnixSocketMount( + subsystem: 'bounded-query' | 'bounded-agent' = 'bounded-query', +): Promise { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-sbx-socket-probe-')); fs.chmodSync(root, 0o700); const socketPath = path.join(root, 'probe.sock'); @@ -426,7 +434,7 @@ export async function probeSbxUnixSocketMount(): Promise { created = (createResult.exitCode ?? 1) === 0 || (createResult.stdout || '').includes('Created sandbox'); if (!created) { throw new Error( - `sbx bounded-query ingress probe could not create a sandbox: ${ + `sbx ${subsystem} ingress probe could not create a sandbox: ${ (createResult.stderr || createResult.stdout || 'unknown error').trim() }`, ); @@ -464,7 +472,7 @@ export async function probeSbxUnixSocketMount(): Promise { timeout: 30_000, }); if ((removed.exitCode ?? 1) !== 0) { - cleanupError = new Error('sbx bounded-query ingress probe sandbox could not be removed'); + cleanupError = new Error(`sbx ${subsystem} ingress probe sandbox could not be removed`); } } await new Promise((resolve) => server.close(() => resolve())); @@ -581,6 +589,57 @@ export async function assertSbxBoundedQueryIngress( } } +/** + * Proves the selected bounded-agent broker ingress is reachable from the + * actual primary sandbox before the agent command starts. Mirrors + * {@link assertSbxBoundedQueryIngress} exactly (same `/query` route and + * canonical `{"status":"error"}` body), against the bounded-agent broker's + * own env var names and capability. The HTTP probe uses a separate one-shot + * capability distinct from the query capability that is injected only after + * this proof succeeds. + */ +export async function assertSbxBoundedAgentIngress( + name: string, + ingress: + | { transport: 'unix'; socketPath: string } + | { transport: 'sbx-http'; endpoint: string; probeCapability: string }, + environment: Record, + workDir?: string, +): Promise { + const probeEnvironment = { ...environment }; + let command: string; + if (ingress.transport === 'unix') { + probeEnvironment.AWF_BOUNDED_AGENT_SOCKET = ingress.socketPath; + command = [ + 'response=$(curl --silent --show-error --max-time 15 --unix-socket "$AWF_BOUNDED_AGENT_SOCKET"', + '-X POST -H "Expect:"', + 'http://localhost/query 2>/dev/null) &&', + '[ "$response" = \'{"status":"error"}\' ]', + ].join(' '); + } else { + probeEnvironment.AWF_BOUNDED_AGENT_ENDPOINT = ingress.endpoint; + probeEnvironment.AWF_BOUNDED_AGENT_PROBE_CAPABILITY = ingress.probeCapability; + command = [ + 'response=$(curl --silent --show-error --noproxy "*" --max-time 15', + '-X POST -H "Expect:"', + '-H "X-AWF-Capability: $AWF_BOUNDED_AGENT_PROBE_CAPABILITY"', + '"$AWF_BOUNDED_AGENT_ENDPOINT" 2>/dev/null) &&', + '[ "$response" = \'{"status":"error"}\' ]', + ].join(' '); + } + + const result = await execInSandbox(name, command, { + timeoutMinutes: 1, + workDir, + environment: probeEnvironment, + }); + if (result.exitCode !== 0) { + throw new Error( + `sbx host does not support the selected bounded-agent ${ingress.transport} ingress`, + ); + } +} + /** * Adds a resolver alias for the published API proxy and proves that the * hard-coded gh-aw reflection endpoint is reachable before the agent starts. diff --git a/src/services/bounded-agent-service.test.ts b/src/services/bounded-agent-service.test.ts new file mode 100644 index 000000000..93b15b5f6 --- /dev/null +++ b/src/services/bounded-agent-service.test.ts @@ -0,0 +1,363 @@ +import { + generateDockerCompose, + WrapperConfig, + baseConfig, + mockNetworkConfig, + useTempWorkDir, +} from './service-test-setup.test-utils'; +import { BOUNDED_AGENT_DEFAULTS, type BoundedAgentsConfig } from '../types/bounded-agent-options'; +import { + BOUNDED_AGENT_API_PROXY_ALIAS, + BOUNDED_AGENT_API_PROXY_IP, + BOUNDED_AGENT_EGRESS_NETWORK, + BOUNDED_AGENT_NETWORK, + BOUNDED_AGENT_SUBNET, +} from '../bounded-agent/network'; +import { buildBoundedAgentService, resolveBoundedAgentApiPort } from './bounded-agent-service'; + +// Mock execa module (must remain per-file — jest.mock() is hoisted before imports) +// eslint-disable-next-line @typescript-eslint/no-require-imports +jest.mock('execa', () => require('../test-helpers/mock-execa.test-utils').execaMockFactory()); +jest.mock('./host-gateway', () => ({ + resolveDockerHostGateway: jest.fn(() => '172.17.0.1'), +})); + +let mockConfig: WrapperConfig; + +/** Network config that includes the API proxy, which bounded agents require. */ +const networkConfig = { ...mockNetworkConfig, proxyIp: '172.30.0.30' }; + +const boundedAgents: BoundedAgentsConfig = { + ...BOUNDED_AGENT_DEFAULTS, + enabled: true, + model: 'gpt-4o-mini', + privateRepos: [{ repo: 'octo/alpha', sensitivity: 'internal' }], +}; + +/** + * End-to-end compose assembly checks for bounded agents. + * + * The properties under test are topological: the broker is networkless, the + * enclave network is `internal` and contains only the API proxy, and the + * primary agent receives exactly two mounts and no privileged state. + */ +describe('bounded-agent broker in generated Docker Compose', () => { + useTempWorkDir( + baseConfig, + (config) => { + mockConfig = config; + }, + () => mockConfig, + ); + + const enabled = (overrides: Partial = {}): WrapperConfig => ({ + ...mockConfig, + enableApiProxy: true, + openaiApiKey: 'sk-real', + boundedAgents: { ...boundedAgents, ...overrides }, + }); + + describe('when bounded agents are disabled', () => { + it('adds no broker service, agent dependency, mount, environment variable, or network', () => { + const result = generateDockerCompose(mockConfig, networkConfig); + const agent = result.services['agent'] as unknown as Record; + + expect(result.services['bounded-agent-broker']).toBeUndefined(); + expect(result.services['bounded-agent-image']).toBeUndefined(); + expect((agent.depends_on as Record)['bounded-agent-broker']).toBeUndefined(); + expect(JSON.stringify(agent.volumes)).not.toContain('bounded-agent'); + expect(JSON.stringify(agent.environment)).not.toContain('BOUNDED_AGENT'); + expect(result.networks[BOUNDED_AGENT_NETWORK]).toBeUndefined(); + }); + + it('adds nothing when the section is present but not enabled', () => { + const result = generateDockerCompose( + { ...mockConfig, boundedAgents: { ...boundedAgents, enabled: false } }, + networkConfig, + ); + expect(result.services['bounded-agent-broker']).toBeUndefined(); + expect(result.networks[BOUNDED_AGENT_NETWORK]).toBeUndefined(); + }); + }); + + describe('when bounded agents are enabled', () => { + it('adds a networkless broker service', () => { + const result = generateDockerCompose(enabled(), networkConfig); + const broker = result.services['bounded-agent-broker'] as unknown as Record; + + expect(broker).toBeDefined(); + expect(broker.container_name).toBe('awf-bounded-agent-broker'); + expect(broker.network_mode).toBe('none'); + expect(broker.networks).toBeUndefined(); + }); + + it('declares a dedicated internal network with an explicit name', () => { + const result = generateDockerCompose(enabled(), networkConfig); + expect(result.networks[BOUNDED_AGENT_NETWORK]).toEqual({ + name: BOUNDED_AGENT_NETWORK, + driver: 'bridge', + internal: true, + ipam: { config: [{ subnet: BOUNDED_AGENT_SUBNET }] }, + }); + }); + + it('uses a dedicated API proxy with private state on the enclave network', () => { + const result = generateDockerCompose(enabled(), networkConfig); + const apiProxy = result.services['bounded-agent-api-proxy'] as unknown as Record; + const networks = apiProxy.networks as Record>; + + expect(networks[BOUNDED_AGENT_NETWORK]).toEqual({ + ipv4_address: BOUNDED_AGENT_API_PROXY_IP, + aliases: [BOUNDED_AGENT_API_PROXY_ALIAS], + }); + expect(networks[BOUNDED_AGENT_EGRESS_NETWORK]).toEqual({}); + expect(networks['awf-net']).toBeUndefined(); + expect(JSON.stringify(apiProxy.volumes)).toContain('awf-bounded-agent-private'); + + // No other service joins the enclave network. + for (const [name, service] of Object.entries(result.services)) { + if (name === 'bounded-agent-api-proxy') continue; + expect(JSON.stringify((service as unknown as Record).networks ?? {})) + .not.toContain(BOUNDED_AGENT_NETWORK); + } + }); + + it('gives the dedicated proxy only the selected provider credential and no external telemetry authority', () => { + const result = generateDockerCompose( + { + ...enabled(), + anthropicApiKey: 'sk-ant-unused', + copilotGithubToken: 'gh-unused', + geminiApiKey: 'gemini-unused', + additionalEnv: { + ACTIONS_ID_TOKEN_REQUEST_URL: 'https://oidc.invalid', + ACTIONS_ID_TOKEN_REQUEST_TOKEN: 'oidc-secret', + OTEL_EXPORTER_OTLP_ENDPOINT: 'https://telemetry.invalid', + OTEL_EXPORTER_OTLP_HEADERS: 'authorization=secret', + }, + }, + networkConfig, + ); + const proxy = result.services['bounded-agent-api-proxy'] as unknown as Record; + const env = proxy.environment as Record; + expect(env.OPENAI_API_KEY).toBe('sk-real'); + for (const forbidden of [ + 'ANTHROPIC_API_KEY', + 'COPILOT_GITHUB_TOKEN', + 'GEMINI_API_KEY', + 'ACTIONS_ID_TOKEN_REQUEST_URL', + 'ACTIONS_ID_TOKEN_REQUEST_TOKEN', + 'OTEL_EXPORTER_OTLP_ENDPOINT', + 'OTEL_EXPORTER_OTLP_HEADERS', + 'HTTP_PROXY', + 'HTTPS_PROXY', + ]) { + expect(env).not.toHaveProperty(forbidden); + } + }); + + it('keeps squid, the primary agent, and the broker off the enclave network', () => { + const result = generateDockerCompose(enabled(), networkConfig); + for (const name of ['squid-proxy', 'agent', 'bounded-agent-broker']) { + const service = result.services[name] as unknown as Record; + expect(JSON.stringify(service.networks ?? {})).not.toContain(BOUNDED_AGENT_NETWORK); + } + }); + + it('adds a one-shot networkless enclave-image dependency and waits for the API proxy', () => { + const result = generateDockerCompose(enabled(), networkConfig); + const imageService = result.services['bounded-agent-image'] as unknown as Record; + const broker = result.services['bounded-agent-broker'] as unknown as Record; + + expect(imageService.network_mode).toBe('none'); + expect(imageService.entrypoint).toEqual(['/bin/true']); + expect(imageService.volumes).toBeUndefined(); + expect(broker.depends_on).toEqual({ + 'bounded-agent-image': { condition: 'service_completed_successfully' }, + 'bounded-agent-api-proxy': { condition: 'service_healthy' }, + }); + }); + + it('gives the agent only the socket and skill mounts', () => { + const result = generateDockerCompose(enabled(), networkConfig); + const agent = result.services['agent'] as unknown as Record; + const volumes = (agent.volumes as string[]).filter((volume) => volume.includes('bounded-agent')); + + expect(volumes).toHaveLength(4); + const targets = volumes.map((volume) => volume.split(':')[1]).sort(); + expect(targets).toEqual([ + '/host/run/awf-bounded-agent', + '/host/run/awf-bounded-agent-skill', + '/run/awf-bounded-agent', + '/run/awf-bounded-agent-skill', + ]); + }); + + it('never exposes the Docker socket, seeds, work dir, or audit dir to the agent', () => { + const result = generateDockerCompose(enabled(), networkConfig); + const agent = result.services['agent'] as unknown as Record; + const serialized = JSON.stringify(agent.volumes); + + expect(serialized).not.toContain('awf-bounded-agent-private'); + expect(serialized).not.toContain('seed-map.json'); + expect(serialized).not.toContain('/srv/awf'); + }); + + it('gives the agent only the socket, skill, and repository-list environment', () => { + const result = generateDockerCompose(enabled(), networkConfig); + const agent = result.services['agent'] as unknown as Record; + const env = agent.environment as Record; + const boundedAgentKeys = Object.keys(env).filter((key) => key.startsWith('AWF_BOUNDED_AGENT')).sort(); + + expect(boundedAgentKeys).toEqual([ + 'AWF_BOUNDED_AGENT_REPOS', + 'AWF_BOUNDED_AGENT_SKILL', + 'AWF_BOUNDED_AGENT_SOCKET', + ]); + expect(env.AWF_BOUNDED_AGENT_SOCKET).toBe('/run/awf-bounded-agent/broker.sock'); + // The model identity is broker-only state. + expect(JSON.stringify(env)).not.toContain('gpt-4o-mini'); + }); + + it('passes the trusted enclave configuration to the broker only', () => { + const result = generateDockerCompose(enabled(), networkConfig); + const broker = result.services['bounded-agent-broker'] as unknown as Record; + const env = broker.environment as Record; + + expect(env.AWF_BOUNDED_AGENT_BACKEND).toBe('docker'); + expect(env.AWF_BOUNDED_AGENT_NETWORK).toBe(BOUNDED_AGENT_NETWORK); + expect(env.AWF_BOUNDED_AGENT_PROFILE).toBe('openai'); + expect(env.AWF_BOUNDED_AGENT_MODEL).toBe('gpt-4o-mini'); + expect(env.AWF_BOUNDED_AGENT_API_ENDPOINT).toBe(`http://${BOUNDED_AGENT_API_PROXY_IP}:10000`); + expect(env.AWF_BOUNDED_AGENT_TIMEOUT).toBe('120'); + expect(env.AWF_BOUNDED_AGENT_MAX_INVOCATIONS).toBe('8'); + expect(env.AWF_BOUNDED_AGENT_MAX_MODEL_REQUESTS).toBe('8'); + expect(env.AWF_BOUNDED_AGENT_MAX_MODEL_TOKENS).toBe('1024'); + expect(env.AWF_BOUNDED_AGENT_MAX_OUTPUT_BYTES).toBe('8192'); + expect(env.AWF_BOUNDED_AGENT_MAX_TASK_BYTES).toBe('4096'); + }); + + it('routes the anthropic profile to the Anthropic API-proxy port', () => { + const result = generateDockerCompose( + { + ...enabled({ profile: 'anthropic', model: 'claude-sonnet-4' }), + anthropicApiKey: 'sk-ant-real', + }, + networkConfig, + ); + const broker = result.services['bounded-agent-broker'] as unknown as Record; + const env = broker.environment as Record; + expect(env.AWF_BOUNDED_AGENT_API_ENDPOINT).toBe(`http://${BOUNDED_AGENT_API_PROXY_IP}:10001`); + }); + + it('selects the gvisor backend without changing any other wiring', () => { + const result = generateDockerCompose(enabled({ runtime: 'gvisor' }), networkConfig); + const broker = result.services['bounded-agent-broker'] as unknown as Record; + expect((broker.environment as Record).AWF_BOUNDED_AGENT_BACKEND).toBe('gvisor'); + expect(broker.network_mode).toBe('none'); + }); + + it('leaves bounded queries completely unaffected', () => { + const result = generateDockerCompose(enabled(), networkConfig); + expect(result.services['bounded-query-broker']).toBeUndefined(); + expect(result.services['bounded-query-image']).toBeUndefined(); + }); + }); +}); + +describe('buildBoundedAgentService guards', () => { + useTempWorkDir( + baseConfig, + (config) => { + mockConfig = config; + }, + () => mockConfig, + ); + + const imageConfig = { + useGHCR: true, + registry: 'ghcr.io/github/gh-aw-firewall', + parsedTag: { tag: 'latest' } as never, + projectRoot: '/repo', + }; + + it('refuses to wire a disabled subsystem', () => { + expect(() => + buildBoundedAgentService({ + config: { ...mockConfig, enableApiProxy: true, boundedAgents: { ...boundedAgents, enabled: false } }, + imageConfig, + networkConfig, + }), + ).toThrow(/must be enabled/); + }); + + it('fails closed for boundedAgents.runtime "sbx" because current sbx cannot prove mandatory isolation controls', () => { + expect(() => + buildBoundedAgentService({ + config: { + ...mockConfig, + enableApiProxy: true, + boundedAgents: { ...boundedAgents, runtime: 'sbx' }, + }, + imageConfig, + networkConfig, + }), + ).toThrow(/boundedAgents\.runtime "sbx" is capability-blocked/); + }); + + it('refuses to wire an enclave with no API proxy to talk to', () => { + expect(() => + buildBoundedAgentService({ + config: { ...mockConfig, enableApiProxy: false, boundedAgents }, + imageConfig, + networkConfig, + }), + ).toThrow(/require the API proxy/); + }); + + it('builds both images from the shared containers context when building locally', () => { + const { service, enclaveImageService } = buildBoundedAgentService({ + config: { ...mockConfig, enableApiProxy: true, boundedAgents }, + imageConfig: { ...imageConfig, useGHCR: false }, + networkConfig, + }); + expect((enclaveImageService as Record).build).toEqual({ + context: '/repo/containers', + dockerfile: 'bounded-agent/Dockerfile', + target: 'enclave', + }); + expect((service as Record).build).toEqual({ + context: '/repo/containers', + dockerfile: 'bounded-agent/Dockerfile', + target: 'broker', + }); + }); + + it('uses a host-gateway-only broker ingress for an sbx primary', () => { + const { service, agentEnvAdditions } = buildBoundedAgentService({ + config: { + ...mockConfig, + containerRuntime: 'sbx', + boundedAgentIngressTransport: 'sbx-http', + enableApiProxy: true, + boundedAgents, + }, + imageConfig: { ...imageConfig, useGHCR: false }, + networkConfig, + }); + const broker = service as Record; + + expect(broker.network_mode).toBeUndefined(); + expect(broker.networks).toEqual(['awf-bounded-agent-ingress']); + expect(broker.ports).toEqual(['172.17.0.1::18081']); + expect(broker.environment.AWF_BOUNDED_AGENT_TCP_PORT).toBe('18081'); + expect(agentEnvAdditions.AWF_BOUNDED_AGENT_SOCKET).toBeUndefined(); + }); +}); + +describe('resolveBoundedAgentApiPort', () => { + it('maps each profile to its API-proxy port', () => { + expect(resolveBoundedAgentApiPort('openai')).toBe(10000); + expect(resolveBoundedAgentApiPort('anthropic')).toBe(10001); + }); +}); diff --git a/src/services/bounded-agent-service.ts b/src/services/bounded-agent-service.ts new file mode 100644 index 000000000..1d6383b5b --- /dev/null +++ b/src/services/bounded-agent-service.ts @@ -0,0 +1,375 @@ +import { logger } from '../logger'; +import { buildRuntimeImageRef } from '../image-tag'; +import { getSafeHostGid, getSafeHostUid } from '../host-identity'; +import { + BOUNDED_AGENT_API_PROXY_CONTAINER_NAME, + BOUNDED_AGENT_BROKER_CONTAINER_NAME, +} from '../constants'; +import type { WrapperConfig } from '../types'; +import { API_PROXY_PORTS } from '../types/ports'; +import { + AGENT_SKILL_DIR, + AGENT_SKILL_PATH, + AGENT_SOCKET_DIR, + AGENT_SOCKET_PATH, + BROKER_AUDIT_DIR, + BROKER_CONTROL_DIR, + BROKER_DOCKER_SOCKET_PATH, + BROKER_SEED_MAP_PATH, + BROKER_SEEDS_DIR, + BROKER_SOCKET_DIR, + BROKER_WORK_DIR, + resolveBoundedAgentPaths, +} from '../bounded-agent/paths'; +import { + BOUNDED_AGENT_API_PROXY_IP, + BOUNDED_AGENT_EGRESS_NETWORK, + BOUNDED_AGENT_NETWORK, +} from '../bounded-agent/network'; +import { resolveDockerSocketPath } from './agent-volumes/docker-socket'; +import { applyHostPathPrefixToVolumes } from './host-path-prefix'; +import { buildContainerSecurityHardening } from './service-security'; +import type { ImageBuildConfig, NetworkConfig } from './squid-service'; +import { buildApiProxyServiceConfig } from './api-proxy-service-config'; +import { resolveDockerHostGateway } from './host-gateway'; +import { + BOUNDED_AGENT_INGRESS_NETWORK, + BOUNDED_AGENT_TCP_PORT, +} from '../bounded-agent/ingress'; +import { resolveBoundedAgentPrimaryBackend } from '../bounded-agent/runtime-matrix'; +import { runtimeUsesComposeAgent } from '../container-runtime'; +import { + ANTHROPIC_ENV, + COPILOT_ENV, + GEMINI_ENV, + OIDC_AUTH_ENV_VARS, + OPENAI_ENV, + VERTEX_ENV, +} from '../api-proxy-env-constants'; + +/** + * Compose assembly for the trusted bounded-agent broker and its enclave. + * + * Topology, which is the whole point of the feature: + * + * - the **broker** runs with `network_mode: none` — no `awf-net`, no + * `awf-ext`, no bounded-agent network, no DNS, no Squid, no host gateway. + * Its only agent-facing surface is a Unix socket bind mount. + * - the **enclave** joins *only* the dedicated `internal` bounded-agent + * network. The sole other member is a dedicated API-proxy instance whose + * logs, metrics, and quota state are private to this subsystem. The enclave has no + * route to the primary agent, Squid, the broker, safe outputs, the MCP + * gateway, the CLI proxy, or the internet; the API proxy is its only + * upstream egress and the only holder of a real credential. + * - the **primary agent** receives exactly two mounts (socket + generated + * skill) and two environment variables. It never sees the Docker socket, the + * enclave network, the model identity, or any provider credential. + */ + +/** Local image tag used when building the bounded-agent broker image from source. */ +const LOCAL_BOUNDED_AGENT_BROKER_IMAGE = 'awf-bounded-agent-broker:local'; + +/** Local image tag used when building the bounded-agent enclave image from source. */ +const LOCAL_BOUNDED_AGENT_IMAGE = 'awf-bounded-agent:local'; + +/** Broker image name published to the container registry. */ +const BOUNDED_AGENT_BROKER_IMAGE_NAME = 'bounded-agent-broker'; + +/** Enclave image name published to the container registry. */ +const BOUNDED_AGENT_IMAGE_NAME = 'bounded-agent'; + +interface BoundedAgentServiceParams { + config: WrapperConfig; + imageConfig: ImageBuildConfig; + networkConfig: NetworkConfig; +} + +interface BoundedAgentBuildResult { + /** One-shot service that makes the enclave image locally available. */ + enclaveImageService: Record; + /** Compose service definition for the broker. */ + service: Record; + /** Dedicated credential sidecar whose state is invisible to the primary agent. */ + apiProxyService: Record; + /** Environment additions merged into the agent container. */ + agentEnvAdditions: Record; + /** Bind mounts added to the agent container. */ + agentVolumes: string[]; +} + +/** + * Resolves the image references for the broker and enclave separately. + * + * Two images keep the enclave environment minimal (Python 3 only — no Node, no + * docker-cli) while still guaranteeing the enclave image is local when the + * broker starts: the release workflow pushes both, and compose pulls the broker + * image behind a one-shot dependency on the enclave image. + * + * Local builds use the `containers/` build context because the broker reuses + * the shared PR1 bounded-execution foundation and the audited sandbox seccomp + * profile that live under `containers/bounded-query/`. + */ +function resolveBoundedAgentImages(imageConfig: ImageBuildConfig): { + enclaveImageRef: string; + enclaveSource: Record; + brokerSource: Record; +} { + const { useGHCR, registry, parsedTag, projectRoot } = imageConfig; + + if (useGHCR) { + return { + enclaveImageRef: buildRuntimeImageRef(registry, BOUNDED_AGENT_IMAGE_NAME, parsedTag), + enclaveSource: { image: buildRuntimeImageRef(registry, BOUNDED_AGENT_IMAGE_NAME, parsedTag) }, + brokerSource: { image: buildRuntimeImageRef(registry, BOUNDED_AGENT_BROKER_IMAGE_NAME, parsedTag) }, + }; + } + + return { + enclaveImageRef: LOCAL_BOUNDED_AGENT_IMAGE, + enclaveSource: { + image: LOCAL_BOUNDED_AGENT_IMAGE, + build: { + context: `${projectRoot}/containers`, + dockerfile: 'bounded-agent/Dockerfile', + target: 'enclave', + }, + }, + brokerSource: { + image: LOCAL_BOUNDED_AGENT_BROKER_IMAGE, + build: { + context: `${projectRoot}/containers`, + dockerfile: 'bounded-agent/Dockerfile', + target: 'broker', + }, + }, + }; +} + +/** + * Translates a host directory into the path the Docker daemon resolves it at. + * + * The broker passes enclave bind-mount sources straight to the daemon, so those + * sources must already be expressed in the daemon's filesystem view (ARC/DinD + * split filesystems). + */ +function toDaemonVisiblePath(hostPath: string, dockerHostPathPrefix: string | undefined): string { + const [translated] = applyHostPathPrefixToVolumes([`${hostPath}:${hostPath}`], dockerHostPathPrefix); + return translated.split(':')[0]; +} + +/** Resolves the API-proxy port the enclave's configured profile speaks to. */ +export function resolveBoundedAgentApiPort(profile: 'openai' | 'anthropic'): number { + return profile === 'anthropic' ? API_PROXY_PORTS.ANTHROPIC : API_PROXY_PORTS.OPENAI; +} + +/** Builds the broker compose service plus the agent's socket/skill wiring. */ +export function buildBoundedAgentService(params: BoundedAgentServiceParams): BoundedAgentBuildResult { + const { config, imageConfig, networkConfig } = params; + const boundedAgents = config.boundedAgents; + + if (!boundedAgents?.enabled) { + throw new Error('buildBoundedAgentService: boundedAgents must be enabled'); + } + if (boundedAgents.runtime === 'sbx') { + throw new Error( + 'buildBoundedAgentService: boundedAgents.runtime "sbx" is capability-blocked — the installed sbx ' + + 'runtime cannot yet prove all mandatory enclave-isolation controls (see assertEnclaveRuntimeAvailable ' + + 'and BoundedAgentSbxCapabilityReport.missing), so no enclave broker wiring is generated and there is ' + + 'no Docker-socket or credential fallback', + ); + } + if (!config.enableApiProxy) { + throw new Error( + 'buildBoundedAgentService: bounded agents require the API proxy, which is the enclave\'s only ' + + 'permitted upstream egress', + ); + } + + const paths = resolveBoundedAgentPaths(config.workDir); + const { enclaveImageRef, enclaveSource, brokerSource } = resolveBoundedAgentImages(imageConfig); + const dockerSocketPath = resolveDockerSocketPath(config); + const apiPort = resolveBoundedAgentApiPort(boundedAgents.profile); + const ingressTransport = config.boundedAgentIngressTransport + ?? (runtimeUsesComposeAgent(config.containerRuntime) ? 'unix' : 'sbx-http'); + const sbxIngressHostIp = ingressTransport === 'sbx-http' ? resolveDockerHostGateway() : undefined; + if (ingressTransport === 'sbx-http' && !sbxIngressHostIp) { + throw new Error('Could not resolve the Docker host-gateway IP for bounded-agent sbx ingress'); + } + + const apiProxyService = buildApiProxyServiceConfig({ + config, + networkConfig, + apiProxyLogsPath: paths.apiProxyLogsDir, + imageConfig, + }) as Record; + apiProxyService.container_name = BOUNDED_AGENT_API_PROXY_CONTAINER_NAME; + apiProxyService.networks = { + [BOUNDED_AGENT_NETWORK]: { + ipv4_address: BOUNDED_AGENT_API_PROXY_IP, + aliases: ['awf-bounded-agent-api-proxy'], + }, + [BOUNDED_AGENT_EGRESS_NETWORK]: {}, + }; + + const proxyEnv = apiProxyService.environment as Record; + for (const key of ['HTTP_PROXY', 'HTTPS_PROXY', 'https_proxy']) delete proxyEnv[key]; + for (const key of [ + 'GH_AW_OTLP_ENDPOINTS', + 'OTEL_EXPORTER_OTLP_ENDPOINT', + 'OTEL_EXPORTER_OTLP_HEADERS', + 'GITHUB_AW_OTEL_TRACE_ID', + 'GITHUB_AW_OTEL_PARENT_SPAN_ID', + 'ACTIONS_ID_TOKEN_REQUEST_URL', + 'ACTIONS_ID_TOKEN_REQUEST_TOKEN', + ...OIDC_AUTH_ENV_VARS, + ]) { + delete proxyEnv[key]; + } + const unusedProviderCredentials = boundedAgents.profile === 'openai' + ? [ANTHROPIC_ENV.KEY, COPILOT_ENV.GITHUB_TOKEN, COPILOT_ENV.PROVIDER_API_KEY, GEMINI_ENV.KEY, VERTEX_ENV.KEY] + : [OPENAI_ENV.KEY, COPILOT_ENV.GITHUB_TOKEN, COPILOT_ENV.PROVIDER_API_KEY, GEMINI_ENV.KEY, VERTEX_ENV.KEY]; + for (const key of unusedProviderCredentials) delete proxyEnv[key]; + + // Compose must pull/build the enclave target before starting the offline + // broker. The one-shot service has no mounts or network and exits only after + // Docker has made the exact image reference available to the daemon. + const enclaveImageService: Record = { + ...enclaveSource, + network_mode: 'none', + entrypoint: ['/bin/true'], + ...buildContainerSecurityHardening({ memLimit: '32m', pidsLimit: 16, cpuShares: 64 }), + restart: 'no', + }; + + const service: Record = { + container_name: BOUNDED_AGENT_BROKER_CONTAINER_NAME, + ...brokerSource, + // The broker is deliberately networkless when the primary agent shares a + // Unix-socket-mountable host with it: it never joins the enclave network + // it launches enclaves onto. When the primary agent is a microVM that + // cannot receive that bind mount (sbx-http transport), the broker instead + // joins a *separate*, dedicated `internal` ingress bridge — distinct from + // BOUNDED_AGENT_NETWORK — so it still never shares a network with an + // enclave, the primary agent's own network, Squid, or the API proxy. + ...(ingressTransport === 'unix' + ? { network_mode: 'none' } + : { + networks: [BOUNDED_AGENT_INGRESS_NETWORK], + ports: [`${sbxIngressHostIp}::${BOUNDED_AGENT_TCP_PORT}`], + }), + volumes: applyHostPathPrefixToVolumes( + [ + `${paths.seedsDir}:${BROKER_SEEDS_DIR}:ro`, + `${paths.workDir}:${BROKER_WORK_DIR}:rw`, + `${paths.runDir}:${BROKER_SOCKET_DIR}:rw`, + `${paths.controlDir}:${BROKER_CONTROL_DIR}:rw`, + `${paths.auditDir}:${BROKER_AUDIT_DIR}:rw`, + `${paths.seedMapPath}:${BROKER_SEED_MAP_PATH}:ro`, + `${dockerSocketPath}:${BROKER_DOCKER_SOCKET_PATH}:rw`, + ], + config.dockerHostPathPrefix, + ), + environment: { + AWF_BOUNDED_AGENT_IMAGE: enclaveImageRef, + // The broker selects a fixed EnclaveRunner from this normalized value. + // Runtime flags are never accepted from an invocation. + AWF_BOUNDED_AGENT_BACKEND: boundedAgents.runtime, + AWF_BOUNDED_AGENT_PRIMARY_BACKEND: resolveBoundedAgentPrimaryBackend(config.containerRuntime), + AWF_BOUNDED_AGENT_NETWORK: BOUNDED_AGENT_NETWORK, + AWF_BOUNDED_AGENT_API_ENDPOINT: `http://${BOUNDED_AGENT_API_PROXY_IP}:${apiPort}`, + AWF_BOUNDED_AGENT_PROFILE: boundedAgents.profile, + AWF_BOUNDED_AGENT_MODEL: boundedAgents.model, + AWF_BOUNDED_AGENT_TIMEOUT: String(boundedAgents.timeout), + AWF_BOUNDED_AGENT_MEMORY: boundedAgents.memoryLimit, + AWF_BOUNDED_AGENT_CPUS: boundedAgents.cpuLimit, + AWF_BOUNDED_AGENT_PIDS: String(boundedAgents.pidsLimit), + AWF_BOUNDED_AGENT_TMPFS: boundedAgents.tmpfsLimit, + AWF_BOUNDED_AGENT_MAX_OUTPUT_BYTES: String(boundedAgents.maxOutputBytes), + AWF_BOUNDED_AGENT_MAX_TASK_BYTES: String(boundedAgents.maxTaskBytes), + AWF_BOUNDED_AGENT_MAX_INVOCATIONS: String(boundedAgents.maxInvocations), + AWF_BOUNDED_AGENT_MAX_MODEL_REQUESTS: String(boundedAgents.maxModelRequests), + AWF_BOUNDED_AGENT_MAX_MODEL_TOKENS: String(boundedAgents.maxModelTokens), + // Enclave bind-mount sources are handed to the daemon, not opened by the + // broker, so they must be daemon-visible paths. + AWF_BOUNDED_AGENT_HOST_WORK_DIR: toDaemonVisiblePath(paths.workDir, config.dockerHostPathPrefix), + AWF_BOUNDED_AGENT_HOST_SEEDS_DIR: toDaemonVisiblePath(paths.seedsDir, config.dockerHostPathPrefix), + AWF_BOUNDED_AGENT_SOCKET_UID: getSafeHostUid(), + AWF_BOUNDED_AGENT_SOCKET_GID: getSafeHostGid(), + ...(ingressTransport === 'sbx-http' + ? { AWF_BOUNDED_AGENT_TCP_PORT: String(BOUNDED_AGENT_TCP_PORT) } + : {}), + }, + depends_on: { + 'bounded-agent-image': { + condition: 'service_completed_successfully', + }, + 'bounded-agent-api-proxy': { + condition: 'service_healthy', + }, + }, + healthcheck: { + test: ['CMD', 'node', '/opt/awf/broker/healthcheck.js'], + interval: '5s', + timeout: '3s', + retries: 10, + start_period: '20s', + }, + ...buildContainerSecurityHardening({ memLimit: '256m', pidsLimit: 100, cpuShares: 256 }), + // The broker is root only so it can hand the pre-created result file to the + // unprivileged enclave uid. Keep the default set dropped and restore only + // those filesystem duties. + cap_add: ['CHOWN', 'DAC_OVERRIDE', 'FOWNER'], + restart: 'no', + stop_grace_period: '5s', + }; + + const agentEnvAdditions: Record = { + ...(ingressTransport === 'unix' ? { AWF_BOUNDED_AGENT_SOCKET: AGENT_SOCKET_PATH } : {}), + AWF_BOUNDED_AGENT_SKILL: AGENT_SKILL_PATH, + AWF_BOUNDED_AGENT_REPOS: boundedAgents.privateRepos.map((repository) => repository.repo).join(','), + }; + + // The agent receives only the socket and skill mounts. Paths are duplicated + // (bare and /host-prefixed) because the agent runs chrooted into /host. + const agentVolumes = applyHostPathPrefixToVolumes( + [ + `${paths.runDir}:${AGENT_SOCKET_DIR}:rw`, + `${paths.runDir}:/host${AGENT_SOCKET_DIR}:rw`, + `${paths.agentDir}:${AGENT_SKILL_DIR}:ro`, + `${paths.agentDir}:/host${AGENT_SKILL_DIR}:ro`, + ], + config.dockerHostPathPrefix, + ); + + logger.info( + `Bounded agents enabled - enclave runtime: ${boundedAgents.runtime}, ` + + `profile: ${boundedAgents.profile}, enclave network: ${BOUNDED_AGENT_NETWORK} (API proxy only), ` + + `broker ingress transport: ${ingressTransport}`, + ); + + return { enclaveImageService, service, apiProxyService, agentEnvAdditions, agentVolumes }; +} + +/** + * True when a volume entry is one of the bounded-agent agent mounts. + * + * Recognizing these mounts centrally lets sysroot filtering preserve mandatory + * bounded-agent ingress without coupling that code to dynamic host paths. + */ +export function isBoundedAgentAgentMount(volume: string): boolean { + const target = volume.split(':')[1]; + if (!target) return false; + const normalized = target.startsWith('/host') ? target.slice('/host'.length) : target; + return normalized === AGENT_SOCKET_DIR || normalized === AGENT_SKILL_DIR; +} + +/** @internal Exported for focused unit tests. */ +// ts-prune-ignore-next +export const boundedAgentServiceTestHelpers = { + LOCAL_BOUNDED_AGENT_IMAGE, + LOCAL_BOUNDED_AGENT_BROKER_IMAGE, + BOUNDED_AGENT_IMAGE_NAME, + BOUNDED_AGENT_BROKER_IMAGE_NAME, + resolveBoundedAgentImages, + toDaemonVisiblePath, +}; diff --git a/src/services/optional-services.ts b/src/services/optional-services.ts index 387872982..cca41e812 100644 --- a/src/services/optional-services.ts +++ b/src/services/optional-services.ts @@ -6,6 +6,7 @@ import { buildApiProxyService } from './api-proxy-service'; import { buildDohProxyService } from './doh-proxy-service'; import { buildCliProxyService } from './cli-proxy-service'; import { buildBoundedQueryService, isBoundedQueryAgentMount } from './bounded-query-service'; +import { buildBoundedAgentService, isBoundedAgentAgentMount } from './bounded-agent-service'; import { buildSysrootStageService, isSysrootEnabled } from './sysroot-service'; import { resolveDockerHostGateway } from './host-gateway'; import { runtimeUsesIptables } from '../container-runtime'; @@ -77,10 +78,11 @@ function filterAgentVolumesForSysroot( const source = parts[0]; const target = parts[1]; - // Bounded-query ingress mounts are mandatory: dropping them would leave - // bounded queries half-enabled (wrapper present, broker unreachable) - // instead of failing loudly. + // Bounded-query and bounded-agent ingress mounts are mandatory: dropping + // them would leave the subsystem half-enabled (wrapper present, broker + // unreachable) instead of failing loudly. if (isBoundedQueryAgentMount(volume)) return true; + if (isBoundedAgentAgentMount(volume)) return true; // Drop sysroot-shadowed targets (system binaries provided by volume) if (sysrootShadowedTargets.has(target)) return false; @@ -267,6 +269,43 @@ function assembleBoundedQueryService(params: AssembleOptionalServicesParams): vo } } +function assembleBoundedAgentService(params: AssembleOptionalServicesParams): void { + const { + services, + agentService, + agentVolumes, + environment, + config, + imageConfig, + includeComposeAgent = true, + } = params; + + if (!config.boundedAgents?.enabled) return; + + const { + enclaveImageService, + service, + apiProxyService, + agentEnvAdditions, + agentVolumes: enclaveVolumes, + } = buildBoundedAgentService({ + config, + imageConfig, + networkConfig: params.networkConfig, + }); + + services['bounded-agent-image'] = enclaveImageService; + services['bounded-agent-api-proxy'] = apiProxyService; + services['bounded-agent-broker'] = service; + if (includeComposeAgent) { + Object.assign(environment, agentEnvAdditions); + agentVolumes.push(...enclaveVolumes); + agentService.depends_on['bounded-agent-broker'] = { + condition: 'service_healthy', + }; + } +} + function finalizeSysrootVolumes( agentVolumes: string[], sysrootActive: boolean, @@ -305,6 +344,7 @@ export function assembleOptionalServices( presetSidecarIpEnvVars(environment, config, networkConfig); assembleBoundedQueryService(params); + assembleBoundedAgentService(params); if (includeComposeAgent) { assembleSysrootService(params, imageConfig.registry, imageConfig.parsedTag, sysrootActive); assembleIptablesInitService(params, skipIptables); diff --git a/src/types/bounded-agent-options.ts b/src/types/bounded-agent-options.ts new file mode 100644 index 000000000..5a5045d0c --- /dev/null +++ b/src/types/bounded-agent-options.ts @@ -0,0 +1,267 @@ +/** + * Bounded-agent enclave configuration types. + * + * A bounded agent is the *agentic* sibling of a bounded query (see + * `./bounded-query-options.ts`): instead of running an agent-authored Python + * script, a trusted broker runs a fixed, AWF-authored model loop inside a + * network-isolated enclave whose only reachable peer is the AWF API proxy. + * The enclave reads one immutable repository seed read-only, may call the + * configured model route a bounded number of times, and must reduce its work + * to a single value conforming to a finite response schema the caller + * declared up front. + * + * Everything the caller can influence is listed in + * `src/bounded-agent/protocol.ts`; everything else — image, command, mounts, + * environment, endpoints, network, proxy, credentials, timeouts, resource + * limits, runtime, and tool definitions — is fixed trusted configuration and + * is rejected if it appears in a request. + * + * This is config-only: there are no `--bounded-agents-*` CLI flags. See + * docs/awf-config-spec.md §15 and docs/bounded-agents.md. + */ + +import { + BOUNDED_QUERY_SENSITIVITIES, + BOUNDED_QUERY_SENSITIVITY_RUN_BITS, + type BoundedQuerySensitivity, +} from './bounded-query-options'; + +/** Sandbox runtime backends recognized for bounded-agent enclave execution. */ +export type BoundedAgentRuntime = 'docker' | 'gvisor' | 'sbx'; + +/** + * Trusted provider protocol the enclave speaks to the AWF API proxy. + * + * `openai` uses the OpenAI-compatible `POST /v1/chat/completions` shape; + * `anthropic` uses the Anthropic-compatible `POST /v1/messages` shape. Both + * are terminated by the API proxy, which injects the real credential — the + * enclave never holds one. + */ +export type BoundedAgentProfile = 'openai' | 'anthropic'; + +/** Every supported profile, for schema/validation enumeration. */ +export const BOUNDED_AGENT_PROFILES: readonly BoundedAgentProfile[] = ['openai', 'anthropic']; + +/** Bounded agents reuse the bounded-query confidentiality categories verbatim. */ +export type BoundedAgentSensitivity = BoundedQuerySensitivity; + +/** Every supported sensitivity value, for schema/validation enumeration. */ +export const BOUNDED_AGENT_SENSITIVITIES: readonly BoundedAgentSensitivity[] = BOUNDED_QUERY_SENSITIVITIES; + +/** + * Immutable per-repository run-budget table. + * + * Bounded agents deliberately share the bounded-query budget *table* (so an + * operator classifies a repository once) but never share a ledger: each + * subsystem runs its own broker with its own seed map, so a bounded agent can + * never spend a bounded query's remaining balance or vice versa. + */ +export const BOUNDED_AGENT_SENSITIVITY_RUN_BITS: Readonly> = + BOUNDED_QUERY_SENSITIVITY_RUN_BITS; + +/** + * A trusted, per-repository descriptor. + * + * `sensitivity` is supplied only in AWF configuration (never in an agent + * request) and flows unmodified into the seed map the broker reads. + */ +export interface BoundedAgentRepository { + /** Repository slug in `owner/repo` form, exactly as configured. */ + repo: string; + /** Confidentiality category, which fixes this repository's run budget. */ + sensitivity: BoundedAgentSensitivity; +} + +/** + * Fully-normalized bounded-agent configuration, with every field resolved to + * an explicit value ({@link BOUNDED_AGENT_DEFAULTS} applied where the AWF + * config file left a field unset). + */ +export interface BoundedAgentsConfig { + /** + * Whether bounded agents are enabled for this run. + * + * Only ever `true` when the config file explicitly set `enabled: true`. + * + * @default false + */ + enabled: boolean; + + /** + * Private repositories a bounded agent may reason about, each with its + * trusted confidentiality category. Required to be non-empty and unique + * (case-insensitively, enforced by preflight) whenever `enabled` is `true`. + * + * @default [] + */ + privateRepos: BoundedAgentRepository[]; + + /** + * Sandbox runtime backend used to execute the enclave. + * + * `docker` and `gvisor` are implemented. `sbx` is accepted by the schema + * but is capability-gated: preflight probes the installed sbx CLI and + * blocks before any repository is staged unless every mandatory + * isolation and API-proxy-only network primitive can be proven. No + * backend ever downgrades. + * + * @default 'docker' + */ + runtime: BoundedAgentRuntime; + + /** + * Trusted provider protocol the enclave speaks to the API proxy. + * + * @default 'openai' + */ + profile: BoundedAgentProfile; + + /** + * Model identifier sent on every enclave request. The caller cannot choose + * or override it. + * + * Required whenever `enabled` is `true`. + * + * @default '' + */ + model: string; + + /** + * Maximum wall-clock time, in seconds, allowed for one enclave invocation. + * + * @default 120 + */ + timeout: number; + + /** + * Docker-style memory limit applied to the enclave (e.g. `"512m"`). + * + * @default '512m' + */ + memoryLimit: string; + + /** + * Fractional CPU limit applied to the enclave (Docker `--cpus`). + * + * @default '1' + */ + cpuLimit: string; + + /** + * Maximum number of processes/threads the enclave may create. + * + * @default 128 + */ + pidsLimit: number; + + /** + * Docker-style size limit for each of the enclave's writable tmpfs mounts + * (`/tmp` and the `/agent` work/result root). + * + * @default '64m' + */ + tmpfsLimit: string; + + /** + * Maximum size, in bytes, of the enclave's dedicated result file. Also the + * ceiling the broker reads back; anything larger is a canonical error. + * + * @default 8192 + */ + maxOutputBytes: number; + + /** + * Maximum size, in bytes, of the caller-supplied bounded task text. + * + * The task is byte-bounded *trusted-shaped* input: it is never interpreted + * as configuration, only forwarded verbatim into the enclave prompt. + * + * @default 4096 + */ + maxTaskBytes: number; + + /** + * Maximum number of enclave invocations permitted for the current AWF run. + * + * @default 8 + */ + maxInvocations: number; + + /** + * Maximum number of model requests one enclave invocation may issue. + * + * @default 8 + */ + maxModelRequests: number; + + /** + * Maximum completion tokens requested per model call (`max_tokens`). + * + * @default 1024 + */ + maxModelTokens: number; +} + +/** + * Centralized defaults for {@link BoundedAgentsConfig}. Single source of + * truth for the normalizer (`src/parsers/bounded-agent-parser.ts`), the + * config schema (`docs/awf-config.schema.json`), and documentation. + * + * Every default is deliberately conservative: a bounded agent is a *model* + * reading confidential source, so the safe posture is a small, short-lived, + * low-token enclave that an operator must explicitly widen. + */ +export const BOUNDED_AGENT_DEFAULTS: Readonly< + Omit +> = { + enabled: false, + runtime: 'docker', + profile: 'openai', + model: '', + timeout: 120, + memoryLimit: '512m', + cpuLimit: '1', + pidsLimit: 128, + tmpfsLimit: '64m', + maxOutputBytes: 8192, + maxTaskBytes: 4096, + maxInvocations: 8, + maxModelRequests: 8, + maxModelTokens: 1024, +}; + +/** + * Transport used between the primary agent and the bounded-agent broker. + * + * Compose agents (docker, gvisor) always use `unix`: the broker's socket is + * bind-mounted directly into the agent container. A primary sbx microVM + * cannot receive that bind mount, so it uses `unix` only when an executable + * passthrough probe proves the microVM can reach a host-mounted Unix socket; + * otherwise it falls back to `sbx-http`, an authenticated loopback-only HTTP + * transport on a dedicated internal network (see `./ingress.ts`). + */ +export type BoundedAgentIngressTransport = 'unix' | 'sbx-http'; + +export interface BoundedAgentOptions { + /** + * Normalized bounded-agent enclave configuration. + * + * `undefined` when the AWF config file did not include a `boundedAgents` + * section at all. Present (with defaults applied) whenever the section was + * included, regardless of whether `enabled` is `true`. + * + * @default undefined + */ + boundedAgents?: BoundedAgentsConfig; + + /** + * Trusted runtime state selected by bounded-agent preflight. + * + * This is not a user-configurable field and is never accepted from the AWF + * config file. Compose agents always use `unix`; sbx uses `unix` only when + * an executable passthrough probe succeeds, otherwise `sbx-http`. + * + * @internal + */ + boundedAgentIngressTransport?: BoundedAgentIngressTransport; +} diff --git a/src/types/index.ts b/src/types/index.ts index d5090ea6f..7f6a0bb45 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -52,3 +52,16 @@ export { BOUNDED_QUERY_SENSITIVITIES, BOUNDED_QUERY_SENSITIVITY_RUN_BITS, } from './bounded-query-options'; + +export { + type BoundedAgentRuntime, + type BoundedAgentProfile, + type BoundedAgentSensitivity, + type BoundedAgentRepository, + type BoundedAgentsConfig, + type BoundedAgentOptions, + BOUNDED_AGENT_DEFAULTS, + BOUNDED_AGENT_PROFILES, + BOUNDED_AGENT_SENSITIVITIES, + BOUNDED_AGENT_SENSITIVITY_RUN_BITS, +} from './bounded-agent-options'; diff --git a/src/types/wrapper-config.ts b/src/types/wrapper-config.ts index 464da0f61..8332e066f 100644 --- a/src/types/wrapper-config.ts +++ b/src/types/wrapper-config.ts @@ -16,6 +16,7 @@ import type { RuntimeOptions } from './runtime-options'; import type { PlatformOptions } from './platform-options'; import type { RunnerOptions } from './runner-options'; import type { BoundedQueryOptions } from './bounded-query-options'; +import type { BoundedAgentOptions } from './bounded-agent-options'; export type WrapperConfig = ContainerImageOptions @@ -28,4 +29,5 @@ export type WrapperConfig = & RuntimeOptions & PlatformOptions & RunnerOptions - & BoundedQueryOptions; + & BoundedQueryOptions + & BoundedAgentOptions; diff --git a/tests/integration/bounded-agent-enclave.test.ts b/tests/integration/bounded-agent-enclave.test.ts new file mode 100644 index 000000000..6ce97ae4d --- /dev/null +++ b/tests/integration/bounded-agent-enclave.test.ts @@ -0,0 +1,372 @@ +/** + * Bounded-agent enclave integration test. + * + * Runs the *real*, unmodified AWF enclave bootstrap (`run-bounded-agent`) + * against a fake local API proxy and a real on-disk repository seed, and + * asserts the four properties that make the feature safe end to end: + * + * 1. the enclave reads the immutable seed through its read-only tools; + * 2. its only outbound traffic is to the configured API-proxy endpoint; + * 3. it produces a finite result that the trusted broker's schema validator + * accepts and canonicalizes; + * 4. it fails closed — with no result file and no alternative destination — + * when that single permitted egress is unavailable. + * + * Docker is not required: the enclave bootstrap is standard-library Python and + * the broker's validator is plain Node, so this exercises the real code paths + * without a daemon. Container-level isolation (network membership, read-only + * root, capability drop, seccomp, resource bounds) is asserted separately by + * the exact-argument-vector unit tests. + */ + +import { describe, test, expect, beforeAll, afterAll, beforeEach } from '@jest/globals'; +import * as fs from 'fs'; +import * as http from 'http'; +import * as os from 'os'; +import * as path from 'path'; +import { execFile } from 'child_process'; + +/* eslint-disable @typescript-eslint/no-require-imports */ +const repoRoot = path.join(__dirname, '..', '..'); +const brokerDir = path.join(repoRoot, 'containers', 'bounded-agent', 'broker'); +const { parseAndValidateQueryOutput } = require(path.join(brokerDir, 'protocol.js')); +/* eslint-enable @typescript-eslint/no-require-imports */ + +const ENCLAVE_ENTRYPOINT = path.join(repoRoot, 'containers', 'bounded-agent', 'enclave-entrypoint.py'); + +interface RecordedRequest { + url: string; + body: Record; + headers: http.IncomingHttpHeaders; +} + +/** A fake, credential-free OpenAI-compatible endpoint standing in for the API proxy. */ +class FakeApiProxy { + readonly requests: RecordedRequest[] = []; + private server?: http.Server; + private responses: unknown[] = []; + + async start(): Promise { + this.server = http.createServer((req, res) => { + const chunks: Buffer[] = []; + req.on('data', (chunk) => chunks.push(chunk)); + req.on('end', () => { + let body: Record = {}; + try { + body = JSON.parse(Buffer.concat(chunks).toString('utf8')); + } catch { + body = {}; + } + this.requests.push({ url: req.url ?? '', body, headers: req.headers }); + const next = this.responses.shift() ?? { choices: [{ message: { content: 'no tools' } }] }; + const payload = JSON.stringify(next); + res.writeHead(200, { 'content-type': 'application/json', 'content-length': payload.length }); + res.end(payload); + }); + }); + await new Promise((resolve) => this.server!.listen(0, '127.0.0.1', resolve)); + return (this.server!.address() as { port: number }).port; + } + + enqueue(...responses: unknown[]): void { + this.responses.push(...responses); + } + + reset(): void { + this.requests.length = 0; + this.responses.length = 0; + } + + async stop(): Promise { + if (!this.server) return; + await new Promise((resolve) => this.server!.close(() => resolve())); + } +} + +/** A destination the enclave must never contact. */ +class ForbiddenUpstream { + connections = 0; + private server?: http.Server; + + async start(): Promise { + this.server = http.createServer((_req, res) => { + res.writeHead(200); + res.end('{}'); + }); + this.server.on('connection', () => { + this.connections += 1; + }); + await new Promise((resolve) => this.server!.listen(0, '127.0.0.1', resolve)); + return (this.server!.address() as { port: number }).port; + } + + async stop(): Promise { + if (!this.server) return; + await new Promise((resolve) => this.server!.close(() => resolve())); + } +} + +interface EnclaveRun { + exitCode: number; + stdout: string; + stderr: string; +} + +function runEnclave(layout: Record, env: Record): Promise { + const driver = [ + 'import importlib.util, sys', + `spec = importlib.util.spec_from_file_location("enclave", ${JSON.stringify(ENCLAVE_ENTRYPOINT)})`, + 'module = importlib.util.module_from_spec(spec)', + 'spec.loader.exec_module(module)', + `layout = module.Layout(${JSON.stringify(layout.seedDir)}, ${JSON.stringify(layout.taskPath)}, ` + + `${JSON.stringify(layout.schemaPath)}, ${JSON.stringify(layout.outPath)})`, + 'sys.exit(module.run(layout))', + ].join('\n'); + + return new Promise((resolve) => { + execFile( + 'python3', + ['-c', driver], + { env: { PATH: process.env.PATH ?? '/usr/bin:/bin', ...env }, timeout: 60_000 }, + (error, stdout, stderr) => { + resolve({ + exitCode: error && typeof (error as { code?: number }).code === 'number' + ? (error as { code: number }).code + : error ? 1 : 0, + stdout: String(stdout), + stderr: String(stderr), + }); + }, + ); + }); +} + +/** The final request carries the complete accumulated transcript. */ +function transcriptOf(requests: RecordedRequest[]): Array> { + const last = requests[requests.length - 1]; + return last ? ((last.body.messages as Array>) ?? []) : []; +} + +const openAiToolCall = (id: string, name: string, args: unknown): unknown => ({ + choices: [ + { + message: { + role: 'assistant', + tool_calls: [{ id, type: 'function', function: { name, arguments: JSON.stringify(args) } }], + }, + }, + ], +}); + +describe('bounded-agent enclave against a fake API proxy', () => { + let workDir: string; + let seedDir: string; + let layout: Record; + let proxy: FakeApiProxy; + let forbidden: ForbiddenUpstream; + let proxyPort: number; + let forbiddenPort: number; + + beforeAll(async () => { + proxy = new FakeApiProxy(); + forbidden = new ForbiddenUpstream(); + proxyPort = await proxy.start(); + forbiddenPort = await forbidden.start(); + }); + + afterAll(async () => { + await proxy.stop(); + await forbidden.stop(); + }); + + beforeEach(() => { + workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-bounded-agent-e2e-')); + seedDir = path.join(workDir, 'seed'); + fs.mkdirSync(path.join(seedDir, 'docs'), { recursive: true }); + fs.writeFileSync(path.join(seedDir, 'SECURITY.md'), 'SEED-MARKER-CONTENT\n'); + fs.writeFileSync(path.join(seedDir, 'docs', 'readme.md'), 'docs\n'); + + layout = { + seedDir, + taskPath: path.join(workDir, 'task.txt'), + schemaPath: path.join(workDir, 'schema.json'), + outPath: path.join(workDir, 'out'), + }; + fs.writeFileSync(layout.taskPath, 'Does this repository declare a SECURITY.md at its root?'); + fs.writeFileSync(layout.schemaPath, JSON.stringify({ type: 'boolean' })); + fs.writeFileSync(layout.outPath, ''); + proxy.reset(); + forbidden.connections = 0; + }); + + const lastTranscript = (): Array> => transcriptOf(proxy.requests); + + const baseEnv = (): Record => ({ + AWF_BOUNDED_AGENT_API_ENDPOINT: `http://127.0.0.1:${proxyPort}`, + AWF_BOUNDED_AGENT_PROFILE: 'openai', + AWF_BOUNDED_AGENT_MODEL: 'test-model', + AWF_BOUNDED_AGENT_MAX_MODEL_REQUESTS: '4', + AWF_BOUNDED_AGENT_MAX_MODEL_TOKENS: '256', + AWF_BOUNDED_AGENT_MAX_OUTPUT_BYTES: '8192', + AWF_BOUNDED_AGENT_DEADLINE_SECONDS: '30', + }); + + test('reads the seed, calls only the API proxy, and produces a finite result', async () => { + proxy.enqueue( + openAiToolCall('call-1', 'list_files', { path: '.' }), + openAiToolCall('call-2', 'read_file', { path: 'SECURITY.md' }), + openAiToolCall('call-3', 'finish', { result: true }), + ); + + const result = await runEnclave(layout, baseEnv()); + expect(result.exitCode).toBe(0); + + // 1. The enclave read the immutable seed through its read-only tools and + // fed the contents back to the model. + const toolMessages = lastTranscript() + .filter((message) => message.role === 'tool') + .map((message) => String(message.content)); + expect(toolMessages.join('\n')).toContain('SEED-MARKER-CONTENT'); + expect(toolMessages.join('\n')).toContain('SECURITY.md'); + + // 2. Every request went to the configured API-proxy route, unauthenticated + // (the proxy injects the real credential) — and nowhere else. + expect(proxy.requests).toHaveLength(3); + for (const request of proxy.requests) { + expect(request.url).toBe('/v1/chat/completions'); + expect(request.headers.authorization).toBeUndefined(); + expect(request.headers['x-api-key']).toBeUndefined(); + expect(request.body.model).toBe('test-model'); + expect(request.body.max_tokens).toBe(256); + } + expect(forbidden.connections).toBe(0); + + // 3. The result is exactly one JSON value in the dedicated bounded file, + // and the trusted broker validator accepts and canonicalizes it. + const raw = fs.readFileSync(layout.outPath, 'utf8'); + expect(raw).toBe('true'); + expect(parseAndValidateQueryOutput(raw, { type: 'boolean' })).toEqual({ + ok: true, + canonical: 'true', + }); + + // Nothing was written to the observable streams. + expect(result.stdout).toBe(''); + expect(result.stderr).toBe(''); + }); + + test('search refuses repository symlinks that resolve outside the seed', async () => { + const outside = path.join(workDir, 'outside-secret.txt'); + fs.writeFileSync(outside, 'OUTSIDE-SEED-MARKER\n'); + fs.symlinkSync(outside, path.join(seedDir, 'escape.txt')); + proxy.enqueue( + openAiToolCall('call-1', 'search', { path: '.', pattern: 'OUTSIDE-SEED-MARKER' }), + openAiToolCall('call-2', 'finish', { result: false }), + ); + + const result = await runEnclave(layout, baseEnv()); + expect(result.exitCode).toBe(0); + const toolMessages = lastTranscript() + .filter((message) => message.role === 'tool') + .map((message) => String(message.content)); + expect(toolMessages.join('\n')).not.toContain('escape.txt'); + expect(toolMessages.join('\n')).not.toContain('OUTSIDE-SEED-MARKER'); + }); + + test('confines repository tools to the seed', async () => { + proxy.enqueue( + openAiToolCall('call-1', 'read_file', { path: '../../../etc/passwd' }), + openAiToolCall('call-2', 'read_file', { path: '/etc/passwd' }), + openAiToolCall('call-3', 'finish', { result: false }), + ); + + const result = await runEnclave(layout, baseEnv()); + expect(result.exitCode).toBe(0); + + const toolMessages = lastTranscript() + .filter((message) => message.role === 'tool') + .map((message) => String(message.content)); + expect(toolMessages).toEqual([ + 'error: not a file inside the repository', + 'error: not a file inside the repository', + ]); + expect(fs.readFileSync(layout.outPath, 'utf8')).toBe('false'); + }); + + test('rejects an unknown tool instead of executing anything', async () => { + proxy.enqueue( + openAiToolCall('call-1', 'run_shell', { command: 'id' }), + openAiToolCall('call-2', 'finish', { result: true }), + ); + + await runEnclave(layout, baseEnv()); + const toolMessages = lastTranscript() + .filter((message) => message.role === 'tool') + .map((message) => String(message.content)); + expect(toolMessages).toEqual(['error: unknown tool']); + }); + + test('bounds the number of model requests', async () => { + for (let i = 0; i < 10; i += 1) { + proxy.enqueue(openAiToolCall(`call-${i}`, 'list_files', { path: '.' })); + } + + const result = await runEnclave(layout, { ...baseEnv(), AWF_BOUNDED_AGENT_MAX_MODEL_REQUESTS: '2' }); + expect(result.exitCode).toBe(1); + expect(proxy.requests).toHaveLength(2); + expect(fs.readFileSync(layout.outPath, 'utf8')).toBe(''); + }); + + test('fails closed with no result when its only permitted egress is unavailable', async () => { + // Nothing is listening on this port: the enclave has no fallback route, no + // proxy variable to fall back to, and no other destination to try. + const deadPort = 1; + const result = await runEnclave(layout, { + ...baseEnv(), + AWF_BOUNDED_AGENT_API_ENDPOINT: `http://127.0.0.1:${deadPort}`, + }); + + expect(result.exitCode).toBe(1); + expect(fs.readFileSync(layout.outPath, 'utf8')).toBe(''); + expect(proxy.requests).toHaveLength(0); + expect(forbidden.connections).toBe(0); + // No traceback, no diagnostics. + expect(result.stderr).toBe(''); + }); + + test('never reaches a non-proxy destination even when one is reachable', async () => { + proxy.enqueue(openAiToolCall('call-1', 'finish', { result: true })); + await runEnclave(layout, baseEnv()); + expect(forbidden.connections).toBe(0); + expect(forbiddenPort).toBeGreaterThan(0); + }); + + test('speaks the Anthropic-compatible route when that profile is configured', async () => { + proxy.enqueue({ + content: [{ type: 'tool_use', id: 'call-1', name: 'finish', input: { result: true } }], + }); + + const result = await runEnclave(layout, { ...baseEnv(), AWF_BOUNDED_AGENT_PROFILE: 'anthropic' }); + expect(result.exitCode).toBe(0); + expect(proxy.requests[0].url).toBe('/v1/messages'); + expect(proxy.requests[0].headers['anthropic-version']).toBe('2023-06-01'); + expect(proxy.requests[0].headers.authorization).toBeUndefined(); + expect(fs.readFileSync(layout.outPath, 'utf8')).toBe('true'); + }); + + test('rejects an oversized result rather than truncating it', async () => { + proxy.enqueue(openAiToolCall('call-1', 'finish', { result: true })); + const result = await runEnclave(layout, { ...baseEnv(), AWF_BOUNDED_AGENT_MAX_OUTPUT_BYTES: '1' }); + + expect(result.exitCode).toBe(1); + expect(fs.readFileSync(layout.outPath, 'utf8')).toBe(''); + }); + + test('produces output the broker rejects when the model ignores the schema', async () => { + proxy.enqueue(openAiToolCall('call-1', 'finish', { result: 'definitely maybe' })); + await runEnclave(layout, baseEnv()); + + const raw = fs.readFileSync(layout.outPath, 'utf8'); + expect(parseAndValidateQueryOutput(raw, { type: 'boolean' })).toEqual({ ok: false }); + }); +});