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
-
Name the agent in
.github/upseam.ymlon the default branch:agent: claude-codecodex,opencode,hermesandopenclawwork the same way.agent: claude-codeis short foragent: { runner: claude-code }; both forms mean the same. The defaultupseamkeeps 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. -
Add the workflow to
.github/workflows/on the default branch and your agent's credential as an Actions secret. Until a workflow there listens forrepository_dispatchwith the typeupseam-agent, Upseam sends nothing and says what to add in the dashboard issue and in Slack. -
Pin
upseam/actionto 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:
- The
agentjob can only read the repository. It runs your agent. - In the same job, a fresh checkout receives only the listed files, and
upseam/actionchecks 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. - The
pushjob runs no agent and holds no agent credential. It checks the files again and pushes one commit toupseam/<group>, marked with the id of the dispatch. - 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,GlobandGreptools. Thesettingsblock 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_CONTENTdenies 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.envfiles 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.envfile 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,--yoloor--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
agentjob 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,
/tmpis an in-memory scratch space, and Hermes runs as the unprivileged user65534with every Linux capability dropped,no-new-privilegesand 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, noGH_TOKEN, no Actions runtime token. What Hermes can read in/proc/self/environis 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_HOSTon port 443. It logs the host of each allowed connection and a baredenyfor each refused one, never what the client sent. - Only file tools.
-t filegives Hermes read, write, patch and search.--safe-modeturns off plugins, MCP servers, hooks, user config andAGENTS.mdinjection; 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/actionchecks 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,editandapply_patch, limited to the workspace. The shell, web, browser, sessions, memory, messaging, plugins and MCP tool groups are denied, the shell mode isdeny, 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 thev2026.9.6tag 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-tokenandCLAUDE_CODE_OAUTH_TOKENfor 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.