// Set up

Bring your own agent

Instead of a model key, a coding agent you already pay for can write the fixes: Claude Code, Codex, OpenCode, Hermes Agent or OpenClaw. Upseam still finds the change, matches it to your code, asks for approval, checks the result with its own gates and opens the pull request. Your agent runs in your GitHub Actions with your credentials, and Upseam never sees them.

Status. This mode is new. The workflow below is built from the official documentation of each action but has not yet been run end to end on GitHub Actions, and it needs the first release of upseam/action. Try it on a test repository first.

Set it up

  1. Name the agent in .github/upseam.yml on the default branch:

    agent: claude-code
    

    codex, opencode, hermes and openclaw work the same way. agent: claude-code is short for agent: { runner: claude-code }; both forms mean the same. The default upseam keeps the model from the settings page (see Connect a model). With any other value, this repository uses its own agent even when no model is connected.

  2. Add the workflow to .github/workflows/ on the default branch and your agent's credential as an Actions secret. Until a workflow there listens for repository_dispatch with the type upseam-agent, Upseam sends nothing and says what to add in the dashboard issue and in Slack.

  3. Pin upseam/action to the full commit SHA of a release: git ls-remote https://github.com/upseam/action refs/tags/<tag>.

What Upseam sends

After approval (the default ask mode) Upseam sends one repository_dispatch event with the type upseam-agent. It carries the group key, the commit to start from, the branch head Upseam expects, the id of this dispatch, the runner, the files the agent may edit, and a task for the agent: rules, those files, the matched places and the change data. The task has no file contents, no key and no token. The vendor's change text in it is marked as untrusted.

How an edit becomes a pull request

Your CI and other systems that build branches run upseam/* branches with their secrets before anyone reviews them, and workflow artifacts of a public repository can be downloaded by anyone signed in to GitHub. So nothing your agent writes leaves its job or reaches a branch before Upseam's checks pass:

  1. The agent job can only read the repository. It runs your agent.
  2. In the same job, a fresh checkout receives only the listed files, and upseam/action checks them: only edits of existing files, and the same patch gates as for a model's patch. A secret written into a file is a new literal, so the job stops. Only if the check passes are the files copied out of the workspace and uploaded.
  3. The push job runs no agent and holds no agent credential. It checks the files again and pushes one commit to upseam/<group>, marked with the id of the dispatch.
  4. Upseam checks the push once more. If anything fails, the finding moves to Needs you with the reason, no pull request opens, and Upseam moves the branch back to where it was. A push from an older dispatch is dropped the same way.

If nothing is pushed within an hour, the finding moves to Needs you with a hint to look at the upseam-agent workflow runs. The pull request says your agent wrote it and Upseam checked it.

The workflow

This workflow is for Claude Code with anthropics/claude-code-action. Create the secret CLAUDE_CODE_OAUTH_TOKEN with claude setup-token, or use anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} instead.

name: upseam-agent
on:
  repository_dispatch:
    types: [upseam-agent]
permissions: {}
concurrency: upseam-agent-${{ github.event.client_payload.group }}
env:
  FILES: ${{ toJSON(github.event.client_payload.files) }}
jobs:
  agent:
    if: github.event.client_payload.runner == 'claude-code'
    runs-on: ubuntu-latest
    timeout-minutes: 30
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
        with:
          ref: ${{ github.event.client_payload.sha }}
          persist-credentials: false
      - uses: anthropics/claude-code-action@8cf3482550831fb35a4fc3fbf7ca139cf8028b4c # v1.0.233
        with:
          claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
          github_token: ${{ github.token }}
          allowed_bots: ${{ github.actor }}
          prompt: ${{ github.event.client_payload.task }}
          settings: '{"permissions":{"blockReadsOutsideWorkingDirectories":true,"deny":["Read(//proc/**)","Read(//sys/**)","Read(//etc/**)","Read(//tmp/**)","Read(./.git/**)","Edit(./.git/**)","Edit(./.github/**)","Edit(//home/runner/work/_actions/**)","Edit(//home/runner/work/_temp/**)","Edit(//tmp/**)"]}}'
          claude_args: --allowedTools Read,Edit,Glob,Grep --disallowedTools Bash,WebFetch,WebSearch --max-turns 30
      - run: rm -rf .upseam-check
      - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
        with:
          ref: ${{ github.event.client_payload.sha }}
          path: .upseam-check
          persist-credentials: false
      - name: Copy the listed files into the fresh checkout
        run: |
          set -euo pipefail
          total=0
          while IFS= read -r -d '' f; do
            case "$f" in "" | /* | ../* | */../* | */.. | .git/* | */.git/*) echo "::error::Bad path in files."; exit 1 ;; esac
            if [ -f "$f" ] && [ ! -L "$f" ] && [ -f ".upseam-check/$f" ] && [ ! -L ".upseam-check/$f" ]; then
              total=$((total + $(stat -c %s -- "$f")))
              [ "$total" -le 1048576 ] || { echo "::error::The edits are over 1 MB."; exit 1; }
              cp -- "$f" ".upseam-check/$f"
            fi
          done < <(jq -j '.[] | ., "\u0000"' <<<"$FILES")
      - uses: upseam/action@<40-character SHA> # v0.x, after the first release
        with:
          path: .upseam-check
          check-only: true
      - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
        with:
          name: upseam-edits
          path: ${{ runner.temp }}/upseam-edits
          include-hidden-files: true
          if-no-files-found: error
          retention-days: 1
  push:
    needs: agent
    runs-on: ubuntu-latest
    timeout-minutes: 15
    permissions:
      contents: write
    steps:
      - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
        with:
          ref: ${{ github.event.client_payload.sha }}
          persist-credentials: false
      - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
        with:
          name: upseam-edits
          path: ${{ runner.temp }}/upseam-edits
      - name: Copy the listed files over the checkout
        run: |
          set -euo pipefail
          src="$RUNNER_TEMP/upseam-edits"
          total=0
          while IFS= read -r -d '' f; do
            case "$f" in "" | /* | ../* | */../* | */.. | .git/* | */.git/*) echo "::error::Bad path in files."; exit 1 ;; esac
            if [ -f "$src/$f" ] && [ ! -L "$src/$f" ] && [ -f "$f" ] && [ ! -L "$f" ]; then
              total=$((total + $(stat -c %s -- "$src/$f")))
              [ "$total" -le 1048576 ] || { echo "::error::The edits are over 1 MB."; exit 1; }
              cp -- "$src/$f" "$f"
            fi
          done < <(jq -j '.[] | ., "\u0000"' <<<"$FILES")
      - uses: upseam/action@<40-character SHA> # v0.x, after the first release
  • The agent gets only the Read, Edit, Glob and Grep tools. The settings block stops reads outside the repository and of /proc, where the agent's own environment lives, and edits of .git, workflow files and the runner's action and temporary directories. The paths assume GitHub-hosted Ubuntu runners.
  • github_token: ${{ github.token }} keeps the action on the read-only workflow token, so the Claude GitHub App is not needed.
  • allowed_bots: ${{ github.actor }} lets the Upseam App's bot start the action. Only accounts with write access can send the event at all.

Codex

openai/codex-action takes an OpenAI API key (secret OPENAI_API_KEY). Replace the if and the Claude Code step of the agent job with:

    if: github.event.client_payload.runner == 'codex'
    # ...
      - uses: openai/codex-action@86365089eb2b84e0a8fb0717b304f8bdcb13b20e # v1.12
        with:
          openai-api-key: ${{ secrets.OPENAI_API_KEY }}
          prompt: ${{ github.event.client_payload.task }}
          permission-profile: ":workspace"
          allow-bot-users: ${{ github.actor }}

The :workspace permission profile limits where Codex writes, and the action removes sudo before Codex runs by default. Upseam has not checked which paths outside the workspace this profile lets Codex read.

OpenCode

OpenCode has no official GitHub Action for this, so the job runs the pinned CLI itself. It takes the key of the model provider you choose: Anthropic (ANTHROPIC_API_KEY), OpenAI (OPENAI_API_KEY) or OpenRouter (OPENROUTER_API_KEY), whose SDKs are built into OpenCode. The job turns off OpenCode's built-in plugins, so their sign-in (OAuth or CLI) methods are unavailable, for example GitHub Copilot or ChatGPT sign-in for Codex; xAI and Azure still work with an API key. Set MODEL to provider/model, and replace the agent job with this one; the rest of the workflow stays the same:

jobs:
  agent:
    if: github.event.client_payload.runner == 'opencode'
    runs-on: ubuntu-latest
    timeout-minutes: 30
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
        with:
          ref: ${{ github.event.client_payload.sha }}
          persist-credentials: false
      - name: Install OpenCode 1.18.32 and ripgrep 15.1.0
        run: |
          set -euo pipefail
          [ -z "${XDG_DATA_HOME:-}" ] && [ "$HOME" = /home/runner ] || { echo "::error::The OpenCode job needs a GitHub-hosted Ubuntu runner without XDG_DATA_HOME."; exit 1; }
          dir="$RUNNER_TEMP/opencode"
          mkdir -p "$dir"
          curl -fsSL --proto '=https' -o "$dir/opencode.tgz" https://registry.npmjs.org/opencode-linux-x64/-/opencode-linux-x64-1.18.32.tgz
          echo "0886adbe8ca2f15e5ae7ac72c3a6549ec2b107dc2a60866a7cd7aa50d5a10416386843409bdd3b2cfd315c0c0bead4747186ea365dbe0efc7c98ea972316c379  $dir/opencode.tgz" | sha512sum -c -
          tar -xzf "$dir/opencode.tgz" -C "$dir" package/bin/opencode
          curl -fsSL --proto '=https' -o "$dir/rg.tgz" https://github.com/BurntSushi/ripgrep/releases/download/15.1.0/ripgrep-15.1.0-x86_64-unknown-linux-musl.tar.gz
          echo "1c9297be4a084eea7ecaedf93eb03d058d6faae29bbc57ecdaf5063921491599  $dir/rg.tgz" | sha256sum -c -
          bin="${XDG_CACHE_HOME:-$HOME/.cache}/opencode/bin"
          mkdir -p "$bin"
          tar -xzf "$dir/rg.tgz" -C "$bin" --strip-components=1 ripgrep-15.1.0-x86_64-unknown-linux-musl/rg
          config="${XDG_CONFIG_HOME:-$HOME/.config}/opencode"
          mkdir -p "$config/node_modules"
          echo '{"dependencies":{"@opencode-ai/plugin":"1.18.32"}}' > "$config/package.json"
          echo '{"lockfileVersion":3,"packages":{"":{"dependencies":{"@opencode-ai/plugin":"1.18.32"}}}}' > "$config/package-lock.json"
      - name: Run OpenCode
        env:
          ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
          MODEL: anthropic/<model id>
          TASK: ${{ github.event.client_payload.task }}
          OPENCODE_CONFIG_CONTENT: '{"autoupdate":false,"share":"disabled","snapshot":false,"lsp":false,"formatter":false,"permission":{"*":"deny","read":{"*":"allow","*.env":"deny","*.env.*":"deny",".git":"deny",".git/*":"deny"},"glob":"allow","grep":"allow","edit":{"*":"allow",".git":"deny",".git/*":"deny",".github/*":"deny","opencode.json":"deny","opencode.jsonc":"deny",".opencode/*":"deny"},"bash":"deny","webfetch":"deny","websearch":"deny","task":"deny","skill":"deny","question":"deny","lsp":"deny","external_directory":{"*":"deny","/home/runner/.local/share/opencode/tool-output/*":"deny"},"doom_loop":"deny"},"agent":{"build":{"steps":30}}}'
          OPENCODE_DISABLE_PROJECT_CONFIG: "1"
          OPENCODE_DISABLE_AUTOUPDATE: "1"
          OPENCODE_DISABLE_MODELS_FETCH: "1"
          OPENCODE_DISABLE_LSP_DOWNLOAD: "1"
          OPENCODE_DISABLE_DEFAULT_PLUGINS: "1"
          OPENCODE_DISABLE_CLAUDE_CODE: "1"
        run: |
          set -euo pipefail
          rm -rf opencode.json opencode.jsonc .opencode
          find . -path ./.git -prune -o -type l -exec rm -f -- {} +
          printf '%s' "$TASK" | "$RUNNER_TEMP/opencode/package/bin/opencode" --pure run --model "$MODEL" --agent build
      - run: rm -rf .upseam-check
      - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
        with:
          ref: ${{ github.event.client_payload.sha }}
          path: .upseam-check
          persist-credentials: false
      - name: Copy the listed files into the fresh checkout
        run: |
          set -euo pipefail
          total=0
          while IFS= read -r -d '' f; do
            case "$f" in "" | /* | ../* | */../* | */.. | .git/* | */.git/*) echo "::error::Bad path in files."; exit 1 ;; esac
            if [ -f "$f" ] && [ ! -L "$f" ] && [ -f ".upseam-check/$f" ] && [ ! -L ".upseam-check/$f" ]; then
              total=$((total + $(stat -c %s -- "$f")))
              [ "$total" -le 1048576 ] || { echo "::error::The edits are over 1 MB."; exit 1; }
              cp -- "$f" ".upseam-check/$f"
            fi
          done < <(jq -j '.[] | ., "\u0000"' <<<"$FILES")
      - uses: upseam/action@<40-character SHA> # v0.x, after the first release
        with:
          path: .upseam-check
          check-only: true
      - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
        with:
          name: upseam-edits
          path: ${{ runner.temp }}/upseam-edits
          include-hidden-files: true
          if-no-files-found: error
          retention-days: 1
  • The job downloads OpenCode 1.18.32 for Linux x64 from npm and checks a pinned SHA-512 before unpacking it; no install script runs. Upseam checked that this binary is identical to the one in OpenCode's attested GitHub release. It also installs ripgrep 15.1.0, which OpenCode's search tools use, after checking its SHA-256, and marks OpenCode's plugin package as installed, so OpenCode itself downloads neither at startup.
  • The profile in OPENCODE_CONFIG_CONTENT denies every tool first, then allows only reading, searching and editing files in the repository. There is no shell, no web access, no subagents and no access outside the repository, including OpenCode's own tool-output directory. The agent cannot open .env files or .git, or edit workflow files or OpenCode's config. Its search tool does not apply these file rules, so it can show lines of a .env file that is committed to the repository; anyone who can read the repository can already see such a file.
  • Without --auto, a headless run rejects anything the profile does not allow. Never add --auto, --yolo or --dangerously-skip-permissions.
  • Before the run, the step deletes any OpenCode config and symbolic links in the checkout, so the repository cannot add tools, plugins or MCP servers or point a file outside it. The task reaches OpenCode on stdin only.
  • Upseam checked these settings against the OpenCode docs and the source of this version but has not run this job end to end yet, and has not checked which model ids it accepts with the remote model list turned off.

Remaining risk (Claude Code, Codex, OpenCode)

  • The agent job holds your agent's credential while the agent reads vendor text that could try to steer it. The tool limits and read rules above are what keep the agent from reaching it.
  • In a public repository, the uploaded files and the run logs are public. Only files that passed the check are uploaded, so they hold nothing that would not be in the pull request.

Hermes Agent

Hermes Agent cannot be limited with its own settings the way Claude Code is: its file tools read any path its user can, including its own environment in /proc/self/environ, and its write guard is "not a hard boundary" by its own documentation. So Hermes runs inside a Docker container, and the container is the boundary. Create the Actions secret ANTHROPIC_API_KEY with an Anthropic API key and replace the agent job of the workflow with this one; the push job stays the same.

jobs:
  agent:
    if: github.event.client_payload.runner == 'hermes'
    runs-on: ubuntu-latest
    timeout-minutes: 30
    permissions:
      contents: read
    env:
      HERMES_SHA: f97608f178d1ffeca59860195ab7da295f7c8e5f
      IMAGE: ghcr.io/astral-sh/uv:0.11.6-python3.13-trixie@sha256:b3c543b6c4f23a5f2df22866bd7857e5d304b67a564f4feab6ac22044dde719b
      PROVIDER: anthropic
      MODEL: claude-sonnet-4-6
      PROVIDER_HOST: api.anthropic.com
    steps:
      - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
        with:
          ref: ${{ github.event.client_payload.sha }}
          path: .upseam-check
          persist-credentials: false
      - name: Copy the listed files into the sandbox directory
        run: |
          set -euo pipefail
          work="$RUNNER_TEMP/upseam-work"
          mkdir -p "$work"
          root="$(realpath -- .upseam-check)"
          while IFS= read -r -d '' f; do
            case "$f" in "" | /* | ../* | */../* | */.. | .git/* | */.git/*) echo "::error::Bad path in files."; exit 1 ;; esac
            if [ -f ".upseam-check/$f" ] && [ "$(realpath -e -- ".upseam-check/$f")" = "$root/$f" ]; then
              mkdir -p -- "$work/$(dirname -- "$f")"
              cp -- ".upseam-check/$f" "$work/$f"
            fi
          done < <(jq -j '.[] | ., "\u0000"' <<<"$FILES")
          chmod -R a+rwX "$work"
      - name: Build Hermes Agent from its commit
        run: |
          set -euo pipefail
          src="$RUNNER_TEMP/hermes-src"
          git init -q "$src"
          git -C "$src" fetch -q --depth 1 https://github.com/NousResearch/hermes-agent.git "$HERMES_SHA"
          git -C "$src" -c advice.detachedHead=false checkout -q FETCH_HEAD
          [ "$(git -C "$src" rev-parse HEAD)" = "$HERMES_SHA" ]
          docker build -q -t upseam-hermes --build-arg IMAGE -f - "$src" <<'DOCKERFILE'
          ARG IMAGE
          FROM $IMAGE
          WORKDIR /opt/hermes
          COPY . .
          RUN uv sync --locked --no-dev --no-install-project --no-build --no-python-downloads --python 3.13 --extra anthropic && printf 'docker\n' > .install_method
          ENV PATH=/opt/hermes/.venv/bin:$PATH PYTHONDONTWRITEBYTECODE=1
          DOCKERFILE
      - name: Start the egress proxy
        env:
          PROXY_PY: |
            import asyncio, os, time
            ALLOW = set(os.environ["ALLOW"].split(","))
            SLOTS = asyncio.Semaphore(32)
            IDLE = 300
            async def pipe(r, w, seen):
                try:
                    while data := await r.read(65536):
                        seen[0] = time.monotonic()
                        w.write(data)
                        await w.drain()
                    w.close()
                except OSError:
                    w.transport.abort()
            async def handle(r, w):
                if SLOTS.locked():
                    w.close()
                    return
                async with SLOTS:
                    try:
                        head = await asyncio.wait_for(r.readuntil(b"\r\n\r\n"), 10)
                        method, target, _ = head.split(b"\r\n")[0].split(b" ")
                        host, port = target.decode("ascii").rsplit(":", 1)
                        if method != b"CONNECT" or port != "443" or host not in ALLOW:
                            raise PermissionError
                        ur, uw = await asyncio.wait_for(asyncio.open_connection(host, 443), 10)
                    except Exception:
                        print("deny", flush=True)
                        w.write(b"HTTP/1.1 403 Forbidden\r\n\r\n")
                        w.close()
                        return
                    print("allow", host, flush=True)
                    w.write(b"HTTP/1.1 200 Connection established\r\n\r\n")
                    seen = [time.monotonic()]
                    tunnel = asyncio.gather(pipe(r, uw, seen), pipe(ur, w, seen))
                    while not tunnel.done():
                        await asyncio.wait([tunnel], timeout=10)
                        if time.monotonic() - seen[0] > IDLE:
                            uw.transport.abort()
                            w.transport.abort()
            async def main():
                server = await asyncio.start_server(handle, "0.0.0.0", 3128)
                await server.serve_forever()
            asyncio.run(main())
        run: |
          set -euo pipefail
          docker network create --internal -o com.docker.network.bridge.gateway_mode_ipv4=isolated upseam-sandbox
          docker network create upseam-egress
          docker run -d --name upseam-proxy --network upseam-egress \
            --read-only --cap-drop=ALL --security-opt no-new-privileges \
            --user 65534:65534 --pids-limit 64 --memory 128m --memory-swap 128m \
            -e ALLOW="$PROVIDER_HOST" \
            "$IMAGE" python -c "$PROXY_PY"
          docker network connect upseam-sandbox upseam-proxy
      - name: Run Hermes Agent in the sandbox
        env:
          ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
          TASK: ${{ github.event.client_payload.task }}
        run: |
          set -euo pipefail
          printf '%s' "$TASK" | docker run --rm -i --network upseam-sandbox \
            --read-only --tmpfs /tmp:rw,nosuid,nodev,size=512m \
            --cap-drop=ALL --security-opt no-new-privileges \
            --user 65534:65534 --pids-limit 256 --memory 2g --memory-swap 2g \
            -v "$RUNNER_TEMP/upseam-work:/work" -w /work \
            -e HOME=/tmp -e HERMES_HOME=/tmp/hermes \
            -e HTTPS_PROXY=http://upseam-proxy:3128 \
            -e HERMES_WRITE_SAFE_ROOT=/work \
            -e TIRITH_ENABLED=0 -e HERMES_DISABLE_LAZY_INSTALLS=1 \
            -e ANTHROPIC_API_KEY \
            upseam-hermes \
            python /opt/hermes/hermes chat --safe-mode -Q --query-file - -t file \
            --provider "$PROVIDER" --model "$MODEL" --max-turns 30 >/dev/null 2>&1 || status=$?
          echo "Agent exit code: ${status:-0}"
          exit "${status:-0}"
      - name: Show the proxy log and remove the sandbox
        if: always()
        run: |
          docker logs upseam-proxy || true
          docker rm -f upseam-proxy || true
          docker network rm upseam-sandbox upseam-egress || true
      - name: Copy the edits into the checkout
        run: |
          set -euo pipefail
          work="$(realpath -- "$RUNNER_TEMP/upseam-work")"
          root="$(realpath -- .upseam-check)"
          total=0
          while IFS= read -r -d '' f; do
            case "$f" in "" | /* | ../* | */../* | */.. | .git/* | */.git/*) echo "::error::Bad path in files."; exit 1 ;; esac
            if [ -f "$work/$f" ] && [ "$(realpath -e -- "$work/$f")" = "$work/$f" ] && [ -f ".upseam-check/$f" ] && [ ! -L ".upseam-check/$f" ] && [ "$(realpath -e -- ".upseam-check/$f")" = "$root/$f" ]; then
              total=$((total + $(stat -c %s -- "$work/$f")))
              [ "$total" -le 1048576 ] || { echo "::error::The edits are over 1 MB."; exit 1; }
              cp -- "$work/$f" ".upseam-check/$f"
            fi
          done < <(jq -j '.[] | ., "\u0000"' <<<"$FILES")
      - uses: upseam/action@<40-character SHA> # v0.x, after the first release
        with:
          path: .upseam-check
          check-only: true
      - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
        with:
          name: upseam-edits
          path: ${{ runner.temp }}/upseam-edits
          include-hidden-files: true
          if-no-files-found: error
          retention-days: 1

For OpenRouter, set PROVIDER: openrouter, a model such as anthropic/claude-sonnet-4.6 and PROVIDER_HOST: openrouter.ai, and pass OPENROUTER_API_KEY instead of ANTHROPIC_API_KEY in the run step, in both env and -e.

How the sandbox works

  • Only the listed files. The job copies the files Upseam listed into a separate directory and mounts only that directory, at /work. The container sees no checkout, no .git, no .github, no runner home or temporary directory and no Docker socket.
  • A locked-down container. The root filesystem is read-only, /tmp is an in-memory scratch space, and Hermes runs as the unprivileged user 65534 with every Linux capability dropped, no-new-privileges and limits on memory (swap included) and processes.
  • Only the model key. The container gets the model key and a few Hermes settings, nothing else: no GITHUB_TOKEN, no GH_TOKEN, no Actions runtime token. What Hermes can read in /proc/self/environ is the key you already gave the model.
  • Only the model's host. The container sits on an internal Docker network with no route out and no address for the runner itself (Docker 28 or later, as on GitHub-hosted runners). Its one way out is a small proxy, shown in full in the job, that only opens connections to PROVIDER_HOST on port 443. It logs the host of each allowed connection and a bare deny for each refused one, never what the client sent.
  • Only file tools. -t file gives Hermes read, write, patch and search. --safe-mode turns off plugins, MCP servers, hooks, user config and AGENTS.md injection; the environment turns off the tirith download and runtime package installs. Hermes sends no telemetry by default, and its update check has no git checkout to look at.
  • Checked like any other agent. After the container exits, only regular files at their listed paths, reached without symlinks, are copied back into a checkout the container never saw, and upseam/action checks them with the same patch gates before anything is uploaded or pushed.

Hermes is built from source at a fixed commit, f97608f (tag v2026.9.24, package version 0.21.5), checked with git rev-parse; its Python packages come from its own uv.lock, which fixes every file by hash, and only as prebuilt wheels. The base image is the uv image pinned by digest, the one Hermes' own Dockerfile uses as a build stage. Nous Research does not sign its release tags, so the commit hash is the only link to what they published.

Remaining risk (Hermes Agent)

  • Hermes can read the model key. The provider already has it; a listed file that contains it is a new literal the gates reject; Hermes' output and error output are not printed, only its exit code, since a reshaped copy would get past GitHub's log masking; and the proxy log holds no client data. What stays visible is whether the job passed, the exit code, how long it ran and how many connections the proxy allowed.
  • Hermes can fill the runner's disk by writing into /work; the job then fails, and nothing leaves it.
  • Vendor text can still steer the edits. The patch gates decide what reaches a branch.
  • Hermes sends the task and the files it reads to the model provider, as any agent does. It has no web or shell tool of its own.
  • The job needs a GitHub-hosted Ubuntu runner with Docker Engine 28 or later (for the isolated gateway). On Docker older than 26, an internal network may still send DNS queries out through the runner's resolver. Self-hosted runners are not supported: jobs sharing one Docker daemon would collide on the fixed container and network names, one job's cleanup could remove another's proxy, and a cancelled job could leave its agent container running.
  • The template is built from Hermes source and Docker documentation and has not yet been run on GitHub Actions.

OpenClaw

OpenClaw runs in the same sandbox as Hermes Agent. Its headless openclaw agent exec turns on the shell by default, so the job also denies every tool except the five file tools in its config; the container is still the boundary. Create the Actions secret ANTHROPIC_API_KEY and replace the agent job with this one; the push job stays the same.

jobs:
  agent:
    if: github.event.client_payload.runner == 'openclaw'
    runs-on: ubuntu-latest
    timeout-minutes: 30
    permissions:
      contents: read
    env:
      OPENCLAW_VERSION: 2026.9.6
      OPENCLAW_INTEGRITY: sha512-Ie0kyQSCVfFqixsgVg39vevUDq01Ch5u3+7Yu5Y3qARczmdAe+lzp8bVnO9925rHiW/+CFp70zfORCyPmCH31g==
      IMAGE: node:24.21.0-bookworm-slim@sha256:0e0ff40c39bc087845bfb27465a0df4ea419520094bc35842ff83dd8cbe6f9b6
      PROXY_IMAGE: ghcr.io/astral-sh/uv:0.11.6-python3.13-trixie@sha256:b3c543b6c4f23a5f2df22866bd7857e5d304b67a564f4feab6ac22044dde719b
      MODEL: anthropic/claude-sonnet-4-6
      PROVIDER_HOST: api.anthropic.com
    steps:
      - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
        with:
          ref: ${{ github.event.client_payload.sha }}
          path: .upseam-check
          persist-credentials: false
      - name: Copy the listed files into the sandbox directory
        run: |
          set -euo pipefail
          work="$RUNNER_TEMP/upseam-work"
          mkdir -p "$work"
          root="$(realpath -- .upseam-check)"
          while IFS= read -r -d '' f; do
            case "$f" in "" | /* | ../* | */../* | */.. | .git/* | */.git/*) echo "::error::Bad path in files."; exit 1 ;; esac
            if [ -f ".upseam-check/$f" ] && [ "$(realpath -e -- ".upseam-check/$f")" = "$root/$f" ]; then
              mkdir -p -- "$work/$(dirname -- "$f")"
              cp -- ".upseam-check/$f" "$work/$f"
            fi
          done < <(jq -j '.[] | ., "\u0000"' <<<"$FILES")
          chmod -R a+rwX "$work"
      - name: Build OpenClaw from its npm release
        env:
          OPENCLAW_CONFIG: |
            {
              update: { checkOnStart: false },
              env: { shellEnv: { enabled: false } },
              agents: {
                defaults: {
                  sandbox: { mode: "off" },
                  skipBootstrap: true,
                  contextInjection: "never",
                  skills: [],
                },
              },
              tools: {
                profile: "coding",
                allow: ["ls", "read", "write", "edit", "apply_patch"],
                deny: ["group:runtime", "group:web", "group:ui", "group:sessions", "group:memory", "group:automation", "group:messaging", "group:nodes", "group:agents", "group:media", "group:openclaw", "group:plugins", "bundle-mcp"],
                fs: { workspaceOnly: true },
                exec: { mode: "deny", applyPatch: { workspaceOnly: true } },
                elevated: { enabled: false },
                codeMode: false,
              },
            }
        run: |
          set -euo pipefail
          ctx="$RUNNER_TEMP/openclaw-image"
          mkdir -p "$ctx"
          jq -n --arg v "$OPENCLAW_VERSION" '{private: true, dependencies: {openclaw: $v}}' > "$ctx/package.json"
          printf '%s' "$OPENCLAW_CONFIG" > "$ctx/openclaw.json5"
          docker build -q -t upseam-openclaw --build-arg IMAGE --build-arg OPENCLAW_INTEGRITY -f - "$ctx" <<'DOCKERFILE'
          ARG IMAGE
          FROM $IMAGE
          ARG OPENCLAW_INTEGRITY
          WORKDIR /opt/openclaw
          COPY package.json openclaw.json5 ./
          RUN npm install --ignore-scripts --no-audit --no-fund --before=2026-09-24T00:00:00Z && [ "$(node -p 'require("./node_modules/.package-lock.json").packages["node_modules/openclaw"].integrity')" = "$OPENCLAW_INTEGRITY" ] && OPENCLAW_DISABLE_BUNDLED_PLUGIN_POSTINSTALL=1 node node_modules/openclaw/scripts/postinstall-bundled-plugins.mjs && test ! -e node_modules/openclaw/.openclaw-lifecycle-pending
          ENV PATH=/opt/openclaw/node_modules/.bin:$PATH
          DOCKERFILE
      - name: Start the egress proxy
        env:
          PROXY_PY: |
            import asyncio, os, time
            ALLOW = set(os.environ["ALLOW"].split(","))
            SLOTS = asyncio.Semaphore(32)
            IDLE = 300
            async def pipe(r, w, seen):
                try:
                    while data := await r.read(65536):
                        seen[0] = time.monotonic()
                        w.write(data)
                        await w.drain()
                    w.close()
                except OSError:
                    w.transport.abort()
            async def handle(r, w):
                if SLOTS.locked():
                    w.close()
                    return
                async with SLOTS:
                    try:
                        head = await asyncio.wait_for(r.readuntil(b"\r\n\r\n"), 10)
                        method, target, _ = head.split(b"\r\n")[0].split(b" ")
                        host, port = target.decode("ascii").rsplit(":", 1)
                        if method != b"CONNECT" or port != "443" or host not in ALLOW:
                            raise PermissionError
                        ur, uw = await asyncio.wait_for(asyncio.open_connection(host, 443), 10)
                    except Exception:
                        print("deny", flush=True)
                        w.write(b"HTTP/1.1 403 Forbidden\r\n\r\n")
                        w.close()
                        return
                    print("allow", host, flush=True)
                    w.write(b"HTTP/1.1 200 Connection established\r\n\r\n")
                    seen = [time.monotonic()]
                    tunnel = asyncio.gather(pipe(r, uw, seen), pipe(ur, w, seen))
                    while not tunnel.done():
                        await asyncio.wait([tunnel], timeout=10)
                        if time.monotonic() - seen[0] > IDLE:
                            uw.transport.abort()
                            w.transport.abort()
            async def main():
                server = await asyncio.start_server(handle, "0.0.0.0", 3128)
                await server.serve_forever()
            asyncio.run(main())
        run: |
          set -euo pipefail
          docker network create --internal -o com.docker.network.bridge.gateway_mode_ipv4=isolated upseam-sandbox
          docker network create upseam-egress
          docker run -d --name upseam-proxy --network upseam-egress \
            --read-only --cap-drop=ALL --security-opt no-new-privileges \
            --user 65534:65534 --pids-limit 64 --memory 128m --memory-swap 128m \
            -e ALLOW="$PROVIDER_HOST" \
            "$PROXY_IMAGE" python -c "$PROXY_PY"
          docker network connect upseam-sandbox upseam-proxy
      - name: Run OpenClaw in the sandbox
        env:
          ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
          TASK: ${{ github.event.client_payload.task }}
        run: |
          set -euo pipefail
          printf '%s' "$TASK" | docker run --rm -i --network upseam-sandbox \
            --read-only --tmpfs /tmp:rw,nosuid,nodev,size=512m \
            --cap-drop=ALL --security-opt no-new-privileges \
            --user 65534:65534 --pids-limit 256 --memory 2g --memory-swap 2g \
            -v "$RUNNER_TEMP/upseam-work:/work" -w /tmp \
            -e HOME=/tmp -e CI=true -e DO_NOT_TRACK=1 \
            -e OPENCLAW_NO_AUTO_UPDATE=1 -e OPENCLAW_TELEMETRY=0 \
            -e HTTPS_PROXY=http://upseam-proxy:3128 \
            -e HTTP_PROXY=http://upseam-proxy:3128 \
            -e ANTHROPIC_API_KEY \
            upseam-openclaw \
            openclaw agent exec --config /opt/openclaw/openclaw.json5 \
            --cwd /work --message-file - --model "$MODEL" --timeout 900 >/dev/null 2>&1 || status=$?
          echo "Agent exit code: ${status:-0}"
          exit "${status:-0}"
      - name: Show the proxy log and remove the sandbox
        if: always()
        run: |
          docker logs upseam-proxy || true
          docker rm -f upseam-proxy || true
          docker network rm upseam-sandbox upseam-egress || true
      - name: Copy the edits into the checkout
        run: |
          set -euo pipefail
          work="$(realpath -- "$RUNNER_TEMP/upseam-work")"
          root="$(realpath -- .upseam-check)"
          total=0
          while IFS= read -r -d '' f; do
            case "$f" in "" | /* | ../* | */../* | */.. | .git/* | */.git/*) echo "::error::Bad path in files."; exit 1 ;; esac
            if [ -f "$work/$f" ] && [ "$(realpath -e -- "$work/$f")" = "$work/$f" ] && [ -f ".upseam-check/$f" ] && [ ! -L ".upseam-check/$f" ] && [ "$(realpath -e -- ".upseam-check/$f")" = "$root/$f" ]; then
              total=$((total + $(stat -c %s -- "$work/$f")))
              [ "$total" -le 1048576 ] || { echo "::error::The edits are over 1 MB."; exit 1; }
              cp -- "$work/$f" ".upseam-check/$f"
            fi
          done < <(jq -j '.[] | ., "\u0000"' <<<"$FILES")
      - uses: upseam/action@<40-character SHA> # v0.x, after the first release
        with:
          path: .upseam-check
          check-only: true
      - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
        with:
          name: upseam-edits
          path: ${{ runner.temp }}/upseam-edits
          include-hidden-files: true
          if-no-files-found: error
          retention-days: 1

For OpenRouter, set MODEL to an openrouter/... model id and PROVIDER_HOST: openrouter.ai, and pass OPENROUTER_API_KEY instead of ANTHROPIC_API_KEY in the run step, in both env and -e.

How the OpenClaw sandbox differs

  • The container, the mount, the environment and the proxy are the same as for Hermes Agent. The proxy runs from its Python image, OpenClaw from a Node image; both are pinned by digest.
  • OpenClaw gets only ls, read, write, edit and apply_patch, limited to the workspace. The shell, web, browser, sessions, memory, messaging, plugins and MCP tool groups are denied, the shell mode is deny, Code Mode is off, and skills, AGENTS.md-style context files and the update check are off.
  • OpenClaw is installed from npm at a fixed version whose package hash the build compares with the one in the job. Its dependencies are resolved as of the day of that release, and npm checks each against the registry's hash. The package's npm provenance names build commit a6e3d5d, not the commit the v2026.9.6 tag points to; the job does not check provenance.
  • The job runs OpenClaw with a pinned config file. The model key reaches it only through the environment: the run starts with an empty state directory and an empty home, so there are no stored credentials to find.

Remaining risk (OpenClaw)

  • The same as for Hermes Agent: where the model key can go, disk use in /work, vendor text steering the edits, the runner requirements (Docker 28, DNS on Docker older than 26, no self-hosted runners), and the template has not yet been run on GitHub Actions.
  • Upseam has not yet confirmed on a run that OpenClaw starts on a read-only root, that the final tool list is exactly the five file tools, and that the npm package matches its build commit.

Terms

  • Anthropic documents claude setup-token and CLAUDE_CODE_OAUTH_TOKEN for GitHub Actions. Its legal and compliance page says subscription sign-in is meant for ordinary use of Claude Code. Upseam never receives or routes your token, but whether a workflow that Upseam triggers counts as ordinary use is not stated. Use an API key if in doubt; Anthropic also recommends one for a secret shared across an organization.
  • OpenAI recommends API keys for Codex in CI, and the Codex workflow uses one.
  • OpenCode is open source (MIT) and uses the model provider's key you give it, so that provider's terms apply. The OpenCode job uses an API key.
  • Hermes Agent is MIT-licensed and sends requests with the key you give it, so the model provider's terms apply. Use an API key: Anthropic's page above reserves subscription sign-in for Claude Code and other native Anthropic applications.
  • OpenClaw is MIT-licensed and, like Hermes Agent, calls the provider with the key you give it; the template uses an API key.