Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions .devcontainer/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Agent sandbox image: Go toolchain + Claude Code + egress-lockdown tooling.
# Matches the repo's builder base (golang:1.26, see cmd/*/Dockerfile).
FROM golang:1.26

# Tools the agent and the firewall need.
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates git curl jq ripgrep \
iptables ipset dnsutils sudo gnupg \
&& rm -rf /var/lib/apt/lists/*

# GitHub CLI (from the official apt repo).
RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \
| dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg \
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \
> /etc/apt/sources.list.d/github-cli.list \
&& apt-get update && apt-get install -y --no-install-recommends gh \
&& rm -rf /var/lib/apt/lists/*

# Node + Claude Code CLI.
RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
&& apt-get install -y --no-install-recommends nodejs \
&& npm install -g @anthropic-ai/claude-code \
&& rm -rf /var/lib/apt/lists/*

# Unprivileged agent user. It may run ONLY the firewall script as root (so the
# lockdown can program iptables at start); it has no general sudo.
RUN useradd -m -s /bin/bash agent \
&& echo 'agent ALL=(root) NOPASSWD: /usr/local/bin/init-firewall.sh' \
> /etc/sudoers.d/agent-firewall \
&& chmod 0440 /etc/sudoers.d/agent-firewall

COPY init-firewall.sh /usr/local/bin/init-firewall.sh
RUN chmod 0755 /usr/local/bin/init-firewall.sh

# Container-level deny rules at the user-settings layer (repo settings can't
# relax these).
COPY claude-settings.json /home/agent/.claude/settings.json
RUN chown -R agent:agent /home/agent/.claude

USER agent
WORKDIR /workspace
141 changes: 141 additions & 0 deletions .devcontainer/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
# Agent sandbox

A container for running Claude Code autonomously (`--dangerously-skip-permissions`
or default mode with a broad allowlist) without the agent being able to damage
your host or exfiltrate credentials.

> **Status: draft.** Review and adapt before wiring into a fleet. Nothing here
> is committed or active until you build and run it.

## Why a container changes the security model

In full bypass mode (`--dangerously-skip-permissions`), Claude Code consults
**none** of the `allow`/`ask`/`deny` rules. The container becomes the only
boundary. That bounds damage to your *host*, but inside the container the agent
still holds whatever you mounted — push credentials, tokens. So "the container
is the sandbox" is only half true: it protects the laptop, not the repo or the
secrets.

This setup adds three layers so broad autonomy stays safe:

| Layer | File | Stops |
|---|---|---|
| Network egress allowlist | `init-firewall.sh` | Exfiltration / C2 to any host except the Anthropic API and GitHub |
| Credential-read deny rules | `claude-settings.json` | The agent reading a key/token and leaking it through an *allowed* channel (e.g. a PR body on GitHub) |
| Scoped credentials | _operational, see below_ | Catastrophic git ops surviving even if a token leaks |

No single layer is sufficient; they cover each other's gaps.

## Egress allowlist (`init-firewall.sh`)

Default-drops all outbound traffic, then allows DNS, established return flows,
the Anthropic API, and GitHub's published CIDR ranges (pulled from
`api.github.com/meta`). It self-verifies at the end: GitHub must be reachable
and `example.com` must be blocked, or it exits non-zero.

Because the repo **vendors** its Go dependencies, `go build`/`go test` run
offline — the allowlist deliberately does *not* include the Go module proxy. If
you drop vendoring, add `proxy.golang.org`/`sum.golang.org` to `ALLOWED_DOMAINS`,
but note their `storage.googleapis.com` backend is CDN-backed and resolves to
shifting IPs; for CDN-heavy egress prefer a filtering HTTP CONNECT proxy
(tinyproxy/squid with a hostname allowlist, `HTTPS_PROXY=...`) over IP rules.

Runs once at container start via `devcontainer.json`'s `postStartCommand`.
Requires `NET_ADMIN`/`NET_RAW` (granted in `runArgs`).

## Credential-read deny rules (`claude-settings.json`)

Baked into the agent user's `~/.claude/settings.json`, so repo-level settings
can't relax them (`deny` always wins). Blocks reads of `*.pem`, `*.key`, SSH
keys, `.env`, `secrets/`, `/run/secrets`, plus `sudo` and Keychain access.

**Known gap:** denying the `Read` *tool* doesn't stop `git` from using a
credential file (different process), which is what you want — but it also
doesn't stop the agent from `cat`/`head`-ing that file via Bash, and it can't
hide an env-var token from `printenv`. Don't rely on secrecy. The robust fix is
to never put readable long-lived secrets in the container at all — see below.

## Scoped credentials (operational — do this, don't skip it)

The token in the container is the real risk surface. Make a leak survivable:

### One-time: create the agent's GitHub App

Do **not** reuse the `actions-gateway-test` App — that one is the runner
control plane (`actions:write`, `administration:write`, …) and has no
`contents`/`pull_requests`. The agent needs its own least-privilege identity.

1. github.com → org `actions-gateway` → Settings → Developer settings → GitHub
Apps → **New GitHub App**.
2. **Repository permissions:** Contents → *Read and write*; Pull requests →
*Read and write*; Metadata → *Read* (auto). Add Workflows → *Read and write*
**only** if agents edit `.github/workflows/`. Leave everything else *No access*.
3. **Webhook:** uncheck *Active* (not needed). **Where can this be installed:**
*Only on this account*.
4. Create it → note the **Client ID** (`Iv23…`) → **Generate a private key**
(downloads a `.pem`) → **Install** the App on the `github-actions-gateway`
repo only.
5. Store the key in Keychain under a *distinct account* (hex-encoded, matching
how the mint script reads it), then shred the download:
```bash
security add-generic-password -U -a actions-gateway-agent \
-s github-app-private-key \
-w "$(xxd -p < ~/Downloads/agent-app.*.private-key.pem | tr -d '\n')"
rm -P ~/Downloads/agent-app.*.private-key.pem
```

The mint script then targets this App via env overrides — no code change:

```bash
GITHUB_APP_CLIENT_ID=Iv23…theNewAppId \
KEYCHAIN_ACCOUNT=actions-gateway-agent \
scripts/mint-installation-token.sh
```

### Operational rules

1. **Short-lived, repo-scoped token, not the App PEM.** The script above mints
an *installation* token (expires in ≤1h), scoped to this one repo with only
`contents:write`+`pull_requests:write`. Pass it as `AGENT_GH_TOKEN`. The App
private key never leaves the host Keychain.
2. **Protect `main` server-side.** Enable branch protection requiring PR +
green CI and disallowing force-push. This is the only reliable guard against
a destructive push — client-side `deny` globs can't reliably tell which
branch a `git push` targets. With protection on, even a fully compromised
agent can't rewrite `main`.
3. **Prefer SSH deploy-key-over-agent-socket** if you want no token on disk or
in env at all: forward an `ssh-agent` socket holding a deploy key that lacks
force-push rights. The key bytes never enter the container.

## Build & run

```bash
# Build the image
docker build -t actions-gateway-agent .devcontainer

# Run one autonomous agent (token minted fresh on the host)
docker run --rm -it \
--cap-add=NET_ADMIN --cap-add=NET_RAW \
-e AGENT_GH_TOKEN="$(./scripts/mint-installation-token.sh)" \
-e ANTHROPIC_API_KEY="$ANTHROPIC_API_KEY" \
-v "$PWD:/workspace" \
actions-gateway-agent \
bash -lc 'sudo /usr/local/bin/init-firewall.sh && claude --dangerously-skip-permissions -p "next"'
```

(`mint-installation-token.sh` is a stub you still need — it reads the App PEM
*on the host* and exchanges it for a short-lived installation token. The PEM
stays on the host; only the token crosses into the container.)

Or open the folder in an editor/CLI that understands `devcontainer.json`.

## Residual risks (be honest about these)

- The agent can still do anything *within* its token's scope: open junk PRs,
push to non-protected branches, burn CI minutes. Scope + branch protection
cap the damage; they don't eliminate it.
- The allowlist trusts GitHub wholesale — anything reachable under GitHub's
CIDRs (gists, any repo the token can touch) is a possible exfil sink. This is
why the token must be narrowly scoped.
- `api.github.com/meta` ranges are fetched at start; if GitHub changes ranges
mid-run, new IPs aren't picked up until the next container start.
19 changes: 19 additions & 0 deletions .devcontainer/claude-settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"//": "Container-only deny rules. Baked into the agent user's ~/.claude/settings.json so repo-level settings cannot relax them. deny > ask > allow in Claude Code, so these win regardless of permission mode. These complement (do not replace) the network firewall: the firewall stops exfiltration over arbitrary hosts, but GitHub is an *allowed* host, so a secret read into a PR body would still escape — denying reads of credential material closes that channel.",
"permissions": {
"deny": [
"Read(**/*.pem)",
"Read(**/*.key)",
"Read(**/id_rsa*)",
"Read(**/id_ed25519*)",
"Read(**/.ssh/**)",
"Read(**/.env)",
"Read(**/.env.*)",
"Read(**/secrets/**)",
"Read(/run/secrets/**)",
"Read(/etc/agent/**)",
"Bash(security find-generic-password:*)",
"Bash(sudo:*)"
]
}
}
24 changes: 24 additions & 0 deletions .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"name": "actions-gateway-agent",
"build": { "dockerfile": "Dockerfile" },

// NET_ADMIN/NET_RAW let init-firewall.sh program iptables + ipset.
"runArgs": ["--cap-add=NET_ADMIN", "--cap-add=NET_RAW"],

"workspaceFolder": "/workspace",

// Lock down egress BEFORE the agent does anything. postStartCommand runs as
// the container user, which is allowed to sudo only this one script.
"postStartCommand": "sudo /usr/local/bin/init-firewall.sh",

"remoteUser": "agent",

// Credentials are injected from the host environment at run time. Prefer a
// SHORT-LIVED, repo-scoped token here, not a long-lived PAT or the App PEM.
// See README.md "Scoped credentials" — env vars are readable by the agent
// process, so the token's blast radius is the real control, not secrecy.
"containerEnv": {
"GH_TOKEN": "${localEnv:AGENT_GH_TOKEN}",
"ANTHROPIC_API_KEY": "${localEnv:ANTHROPIC_API_KEY}"
}
}
124 changes: 124 additions & 0 deletions .devcontainer/init-firewall.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
#!/usr/bin/env bash
#
# init-firewall.sh — lock down container egress to an allowlist.
#
# Description:
# Runs once at container start (as root, before the agent user starts doing
# work). Default-drops all outbound traffic except DNS, established return
# flows, and a small allowlist: the Anthropic API (so Claude Code works) and
# GitHub (so git/gh work). Everything else — including any attempt to POST a
# leaked secret to an arbitrary host — is dropped at the kernel.
#
# Go builds do NOT need egress: this repo vendors its dependencies, so
# `go build` / `go test` run fully offline. If you ever drop vendoring, add
# the Go module proxy hosts (proxy.golang.org, sum.golang.org, and their
# storage.googleapis.com backend) to ALLOWED_DOMAINS — note that CDN-backed
# hosts resolve to changing IPs, so a filtering HTTP proxy is more robust
# than IP allowlisting for those (see .devcontainer/README.md).
#
# Requires: iptables, ipset, dig (dnsutils), jq, curl. Must run with NET_ADMIN.
#
# Usage:
# sudo ./init-firewall.sh

# --- Strict mode ---
set -euo pipefail

# Hostnames the agent is permitted to reach (resolved to IPs below).
readonly ALLOWED_DOMAINS=(
"api.anthropic.com"
"statsig.anthropic.com"
)

readonly IPSET_NAME="agent-allow"

log() { printf '[init-firewall] %s\n' "$*" >&2; }

die() { log "ERROR: $*"; exit 1; }

# Remove any pre-existing rules so re-runs are idempotent.
flush_existing() {
local table
for table in filter nat mangle; do
iptables -t "$table" -F
iptables -t "$table" -X
done
ipset destroy "$IPSET_NAME" 2>/dev/null || true
}

# Build the set of allowed destination CIDRs/IPs.
build_allowset() {
ipset create "$IPSET_NAME" hash:net

# GitHub publishes its CIDR ranges via the meta API. Pull web/api/git/
# packages so git clone, gh, and HTTPS to github.com all work.
local meta
meta="$(curl -fsSL --max-time 20 https://api.github.com/meta)" \
|| die "could not fetch GitHub meta ranges"

local cidr
while IFS= read -r cidr; do
[[ -n "$cidr" ]] || continue
ipset add "$IPSET_NAME" "$cidr" -exist
done < <(jq -r '(.web + .api + .git + .packages)[]' <<<"$meta" | sort -u)

# Resolve each allowlisted hostname to its current A records.
local domain ip count
for domain in "${ALLOWED_DOMAINS[@]}"; do
count=0
while IFS= read -r ip; do
[[ -n "$ip" ]] || continue
ipset add "$IPSET_NAME" "$ip" -exist
(( count += 1 ))
done < <(dig +short A "$domain" | grep -E '^[0-9.]+$' || true)
(( count > 0 )) || die "could not resolve allowlisted host: $domain"
log "allowed ${domain} (${count} addrs)"
done
}

# Apply the default-deny policy with the allowlist carved out.
apply_rules() {
# Loopback is always fine.
iptables -A INPUT -i lo -j ACCEPT
iptables -A OUTPUT -o lo -j ACCEPT

# Keep established/related flows (return traffic for allowed connections).
iptables -A INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
iptables -A OUTPUT -m state --state ESTABLISHED,RELATED -j ACCEPT

# DNS resolution must work (for the resolver and for `dig` above).
iptables -A OUTPUT -p udp --dport 53 -j ACCEPT
iptables -A OUTPUT -p tcp --dport 53 -j ACCEPT

# Outbound to the allowlisted IP set only.
iptables -A OUTPUT -m set --match-set "$IPSET_NAME" dst -j ACCEPT

# Default deny everything else.
iptables -P INPUT DROP
iptables -P FORWARD DROP
iptables -P OUTPUT DROP
}

# Prove the policy is actually live before handing control to the agent.
verify() {
curl -fsSL --max-time 10 -o /dev/null https://api.github.com/zen \
|| die "verification failed: GitHub unreachable through allowlist"
if curl -fsSL --max-time 5 -o /dev/null https://example.com 2>/dev/null; then
die "verification failed: egress to example.com succeeded but must be blocked"
fi
log "egress lockdown verified (GitHub reachable, example.com blocked)"
}

main() {
local tool
for tool in iptables ipset dig jq curl; do
command -v "$tool" >/dev/null 2>&1 || die "missing required tool: $tool"
done
flush_existing
build_allowset
apply_rules
verify
log "firewall initialized"
}

main "$@"
3 changes: 3 additions & 0 deletions docs/STATUS.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ Plan-level view. ✅ = all criteria met. ⚠️ = code shipped, specific pieces
| Make UX | `infra` | ✅ | Phase 1 + Phase 2 done — [plan](plan/make.md) |
| Docker image speed | `speed` | ✅ | All items done or explicitly closed — [plan](plan/docker-image-speed.md) |
| e2e test speed | `speed` `tests` | ✅ | All items done — [plan](plan/e2e-tests-speed.md) |
| Agent workflow automation | `infra` | ⚠️ | Sandbox (egress lockdown + scoped tokens) in PR #107; go-live + auto-merge open as [Q62](#Q62)/[Q63](#Q63) — [plan](plan/agent-workflow-automation.md) |

---

Expand Down Expand Up @@ -82,6 +83,8 @@ Specific actionable items in priority order. Pick from the top; skip 🚫 items
| <a id="Q51"></a>Q51 | Reconcile documented vs emitted Prometheus metrics | `infra` `docs` `bug` | 🔲 | M | 6 documented metrics never registered in code (headline `pod_creation_latency_seconds` + 5 others). Per-metric decision: implement, re-point, or mark `(planned)`. See [docs-six-layer-audit.md](plan/docs-six-layer-audit.md) Layer 3. |
| <a id="Q55"></a>Q55 | Verify provisioner-test goleak cascade fix held in CI | `tests` `bug` | 🔲 | S | Intermittent ~20-test goleak cascade in `internal/provisioner` fixed by `waitForPodCreated` helper in 59c0714; delete row once CI is clean. If flakes recur, migrate remaining ~18 Eventually-on-Pod sites to the helper. |
| <a id="Q60"></a>Q60 | [Competitive analysis — GAG vs ARC-adjacent runner/queue tooling](design/appendix-d-alternatives-considered.md) | `docs` | 🔲 | M | Competitive analysis vs ARC-adjacent tooling: Kueue, Exostellar (verify the Kueue-under-ARC GPU pattern), KEDA. Expands [appendix-d](design/appendix-d-alternatives-considered.md). Narrow Kueue-vs-admission angle is in [Q59](#Q59). |
| <a id="Q62"></a>Q62 | [Agent sandbox go-live: dedicated App + branch protection](plan/agent-workflow-automation.md) | `infra` `security` | 🔲 | S | Create a least-privilege agent GitHub App (`contents`+`pull_requests` write) and protect `main`; the `actions-gateway-test` runner App can't be reused (422 — no contents/PR, has `administration:write`). Prerequisite for the PR #107 sandbox. |
| <a id="Q63"></a>Q63 | [Auto-merge + CI auto-fix wiring](plan/agent-workflow-automation.md) | `infra` | 🚫 | M | Blocked by [Q62](#Q62). Agent ends with `gh pr merge --auto`; failed CI dispatches the Claude Code GitHub Action to fix on-branch. Touches `.github/workflows/`. |
| <a id="Q17"></a>Q17 | [Unit/integration test speed improvements](plan/unit-tests-speed.md) | `speed` `tests` | 💤 | M | low priority; pick up when CI latency is the bottleneck |
| <a id="Q18"></a>Q18 | [alerting.md](plan/docs.md) | `docs` | 💤 | M | deferred until a real Prometheus/Alertmanager setup exists |
| <a id="Q19"></a>Q19 | [Proxy features: allowlist, rate-limit, audit log, TLS, per-RG pool, X25519](design/appendix-g-future-enhancements.md) | `security` | 💤 | L | explicit non-commitments; build only when a named trigger fires |
Loading
Loading