Skip to content
Merged
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
105 changes: 105 additions & 0 deletions .changelog/unreleased/added-reviewer-sandbox-image.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
- **A checksum-pinned reviewer sandbox image, a trusted runtime table and a launcher that builds a PR the way its CI job would, as advisory evidence for the reviewer, without any credential or host access reachable from the review (Refs #425).**

The reviewer environment now has its own image and launch path, defined in
this repository. Its verdict, `review-build-ok`, is advisory evidence for the
reviewer, not a merge gate: CI remains the gate on every PR. The boundary it
holds is that no credential and no host access are reachable from the review;
how well it predicts CI is best effort, with its known limits documented in
`docker/reviewer/README.md`.

**The image** (`docker/reviewer/Dockerfile`, linux/amd64) is built `FROM`
debian:bookworm-slim pinned by digest — OpenClaw publishes no digest-pinnable
sandbox base; its documented sandbox build is a local `FROM
debian:bookworm-slim`, and this image carries the same package set (python3 is
what OpenClaw's sandbox write/edit helpers run) — plus `git`, a `gh` with no
credential of its own, Bun and Node, each at an exact version verified against
a checksum from the trusted table. It has no entrypoint and its CMD is `sleep
infinity`, matching how OpenClaw starts sandboxes (read-only root, tmpfs on
`/tmp`, `/var/tmp`, `/run`). Its default `HOME`, `USERPROFILE`, `TMPDIR` and
Bun/npm caches point under `/tmp/review`, a tmpfs discarded with the sandbox.
It carries no credentials and no configured credential helpers.

**The trusted table** (`docker/reviewer/runtime-matrix.json`) is maintained
here and installed on the host. The reviewed checkout can neither extend it
nor choose a download source.

**The launcher** (`/opt/reviewer/bin/reviewer-launch`, from
`scripts/reviewer/reviewer-launch.mjs`) is run explicitly. Build mode takes no
arguments and always builds the worktree the host mounted at `/workspace`
(`--self-check` is the only other mode). Before any repository code runs it
reads the fixed table and the image's baked identity; takes the workflow, job
and base branch only from the environment the HOST gave the sandbox at
creation (`REVIEWER_CI_WORKFLOW`, `REVIEWER_CI_JOB`, `REVIEWER_CI_BASE`),
refusing a caller that passes different values (that integrity holds only if
the host denies same-user process-memory writes, not yet verified:
tpsdev-ai/cli#436); refuses symlinked lockfiles
and symlinked directories in the worktree; plans the job and the jobs it
`needs`; resolves the reviewed commit's declarations (`packageManager`,
`engines`, `.nvmrc`, `.node-version`, `.bun-version`, `.tool-versions`, each
read with size bounds and inside the worktree) and every planned job's runtime
pins to ONE matrix image with npm-semver range semantics; refuses unless that
image is the one it is running in; builds the child environment from an
allowlist (hermetic values, a fixed `PATH`, `LANG`, `LC_ALL`, `TERM`, `TZ`,
`CI=true`); and runs the image's own Node and Bun at fixed paths to verify
their versions. Then, job by job in dependency order, it removes what earlier
jobs of the build created, gives each job a fresh `HOME`/`TMPDIR`/cache root,
refuses a worktree whose effective git configuration leaves a documented safe
baseline (no `core.fsmonitor`, `core.hooksPath`, `core.pager`, `protocol.*`,
`url.*.insteadOf`, credential helpers, auth headers, ...) or whose repository
holds hooks, and refuses unless the worktree passes a clean-clone check (HEAD
and remote URLs/refspecs unchanged since the build started, no
assume-unchanged or skip-worktree index bits, `git status` clean with replace
objects ignored, history shaped as `fetch-depth` asks — not a byte comparison
with a host-pinned commit; per-job isolation is tpsdev-ai/cli#435). It runs
every `run:` step as one script under `/bin/bash --noprofile --norc -eo
pipefail` after re-checking the step's effective environment and that its
resolved working directory stays inside the worktree, enforces
`timeout-minutes` (the job's, default 360, and each planned `run:` step's),
and kills what a job left running when it ends. After every job it
refuses symlinked lockfiles and symlinked directories. It reports
`review-build-ok` only when every job in the closure ran, every step exited 0,
and no lockfile in the worktree (outside `node_modules/` and `.git/`)
changed, appeared or disappeared.

**The planner** (`scripts/reviewer/ci-job.mjs`) bounds the workflow (bytes,
YAML nodes counting every alias use, depth) before trusting it, requires
printable-ASCII keys, and requires a `pull_request` trigger that covers the
host-named base branch; a workflow CI would not run for the review is refused.
Every job must begin with `actions/checkout` and run on `ubuntu-latest` or
`ubuntu-24.04`. It skips checkout (first step only), setup-bun and setup-node
(exact versions only), socketdev (firewall-free), cache (path and key
required) and upload-artifact (path required; unique, valid name) only at
reviewed commit SHAs, with every input the real action needs and only input
values whose skip is equivalent; it names them in the verdict and refuses
every other action, ref or input. It refuses what it cannot reproduce: a
`${{ }}` expression anywhere it would have to be evaluated (scripts, env
values, working directories, runner labels, timeouts and every input of a
skipped action — the only expressions accepted are `if:` conditions that are
exactly `success()` or `always()`), `timeout-minutes` on a skipped `uses:`
step, conditions other than success/always, `continue-on-error`, matrices and
containers; and credential-shaped or config-redirecting workflow env
(`GH_TOKEN`, `*_TOKEN`, `*_SECRET`, `*_KEY`, `GIT_*`, `NPM_CONFIG_*`, `SSH_*`,
`NODE_OPTIONS`, `BASH_ENV`, `LD_*`, proxies, ...). Ambiguous, conflicting,
malformed and out-of-matrix runtime requirements are refused by name; an
out-of-matrix refusal names the missing image (e.g. `missing image: node >=25
with bun 1.3.10`).

The "no credential" statements cover the image, the environments the launcher
builds and the worktree's git configuration; a credential that repository
code itself supplies is outside them. The host assignment's integrity relies
on the deployed sandbox denying same-user process-memory writes; that
host-integration check is not in place yet (tpsdev-ai/cli#436). The git
baseline allowlists keys; remote URL and refspec values are the host clone's
and are pinned for the build, not approved.

This repository now declares `engines.node: "22.x"` (the Node major its CI
runs), so it resolves to exactly one reviewer image.

A dedicated CI job builds every matrix image and runs the image-level checks
(A2 integrity and build-path refusals, including a caller naming another job,
a `--workspace` argument, impostor binaries on the caller's `PATH`, an
enforced `timeout-minutes`, a worktree that fails the clean-clone check and a git
`core.fsmonitor`; A3 hermetic defaults observed inside a build run the way
OpenClaw runs the sandbox; A9 tokenless `gh`, a worktree git auth header and a
workflow `GH_TOKEN`), then requires the A9 checks to fail on derived images
carrying planted fake credentials.
49 changes: 49 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,55 @@ jobs:
entry="$PWD/plugins/openclaw-tps-mail/dist/src/index.js"
node -e "try{require('$entry');console.log('REQUIRE_OK')}catch(e){console.error(e.code||'',String(e.message).split('\n')[0]);process.exit(2)}"

# cli#425 section A — the reviewer sandbox image matrix (linux/amd64). Builds
# every trusted runtime image and runs the image-level acceptance checks: A2
# integrity, the image's own resolver refusals and the build-path fixtures (an
# out-of-matrix pin, a pin for another image, a caller naming another job and a
# --workspace argument run nothing; an in-matrix pin runs the host-named job
# while impostor binaries on the caller's PATH never run; a run: step's
# timeout-minutes is enforced; a worktree that fails the clean-clone check and
# a git core.fsmonitor are refused before anything runs), A3 hermetic defaults
# observed inside a build run the way OpenClaw runs the sandbox (read-only
# root, tmpfs, no network, `sleep infinity` + exec), and A9 tokenless gh plus
# the launcher refusing a worktree git auth header and a workflow GH_TOKEN. The
# A9 red proofs then require the same A9 checks to FAIL on derived images
# carrying planted fake credentials, so the lane proves A9 fires on every run.
# No registry: the local image ids are recorded as evidence of this build only
# (a host builds its own at install time). The host unit tests for the
# planner/resolver/launcher run in the cli suite.
reviewer-image:
name: Reviewer sandbox image (A2/A3/A9)
permissions:
contents: read # actions/checkout reads the repository
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with:
persist-credentials: false
- name: Build and check each matrix image
run: |
set -euo pipefail
ids="$(node -e 'const t=require("./docker/reviewer/runtime-matrix.json"); process.stdout.write(t.images.map((i)=>i.id).join(" "))')"
count=0
for id in $ids; do
count=$((count+1))
tag="reviewer-image:${id}"
node scripts/reviewer/build-reviewer-image.mjs "$id" --tag "$tag" | tee "build-${id}.json"
bash scripts/reviewer/image-checks.sh "$tag" "$id" docker/reviewer/runtime-matrix.json | tee "checks-${id}.log"
done
test "$count" -ge 1 # an empty matrix must not read as a pass
- name: A9 red proofs (the A9 checks must fail on credential-bearing derived images)
run: |
set -euo pipefail
id="$(node -e 'process.stdout.write(require("./docker/reviewer/runtime-matrix.json").images[0].id)')"
bash scripts/reviewer/a9-red-proofs.sh "reviewer-image:${id}" "$id" docker/reviewer/runtime-matrix.json
- name: Record the local image ids of this build
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: reviewer-image-ids
path: build-*.json
if-no-files-found: error

audit:
name: Dependency Audit
permissions:
Expand Down
121 changes: 121 additions & 0 deletions docker/reviewer/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
# Reviewer sandbox image (cli#425 section A). linux/amd64 only.
#
# BASE. debian:bookworm-slim, pinned by digest. OpenClaw publishes no
# digest-pinnable sandbox base: its default sandbox image
# (openclaw-sandbox:bookworm-slim) is built locally, and the build OpenClaw
# 2026.8.1 documents for npm installs is `FROM debian:bookworm-slim` (a floating
# tag) plus bash, ca-certificates, curl, git, jq, python3 and ripgrep. This image
# pins that same base by digest and carries the same packages (python3 is what
# OpenClaw's sandbox write/edit helpers run), plus Node, Bun and a TOKENLESS gh,
# each at an exact version verified against a checksum from the trusted table
# (docker/reviewer/runtime-matrix.json). scripts/reviewer/build-reviewer-image.mjs
# supplies every ARG from that table.
#
# RUN MODEL. OpenClaw 2026.8.1 creates the sandbox as
# docker create --init --read-only --tmpfs /tmp --tmpfs /var/tmp --tmpfs /run
# --network none --cap-drop ALL --security-opt no-new-privileges
# --workdir /workspace -v <worktree>:/workspace --env-file <env> <image> sleep infinity
# and does not override ENTRYPOINT. So this image has no entrypoint and its CMD
# is `sleep infinity`. The review build is the explicit command
# /opt/reviewer/bin/reviewer-launch, which creates its own hermetic
# HOME/TMPDIR/cache directories under /tmp/review (a tmpfs) before any child
# process starts, and runs the CI job the host names.
#
# The image carries NO credentials and NO credential helpers. Authenticated
# GitHub operations happen only through the host-side github_review tool.

ARG BASE_REF=debian:bookworm-slim@sha256:3783cc01769c7b2b1b83a5c5ad96c815348e28ed7da68e2e3687004faa906251
FROM --platform=linux/amd64 ${BASE_REF}

ARG NODE_VERSION
ARG NODE_SHA256
ARG BUN_VERSION
ARG BUN_SHA256
ARG GH_VERSION
ARG GH_SHA256
ARG JSYAML_VERSION
ARG JSYAML_SHA256
ARG REVIEWER_IMAGE_ID

ENV DEBIAN_FRONTEND=noninteractive

# OpenClaw's sandbox package set, plus the download/unpack tools and procps.
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
bash ca-certificates curl git jq python3 ripgrep \
xz-utils unzip procps coreutils \
&& rm -rf /var/lib/apt/lists/*

# Node — exact version, verified against the trusted checksum.
RUN set -eux; \
curl -fsSLo /tmp/node.tar.xz "https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-x64.tar.xz"; \
echo "${NODE_SHA256} /tmp/node.tar.xz" | sha256sum -c -; \
tar -xJf /tmp/node.tar.xz -C /usr/local --strip-components=1 --no-same-owner; \
rm -f /tmp/node.tar.xz; \
node -v; npm -v

# Bun — exact version, verified against the trusted checksum.
RUN set -eux; \
curl -fsSLo /tmp/bun.zip "https://github.com/oven-sh/bun/releases/download/bun-v${BUN_VERSION}/bun-linux-x64.zip"; \
echo "${BUN_SHA256} /tmp/bun.zip" | sha256sum -c -; \
unzip -q /tmp/bun.zip -d /tmp/bun; \
install -m 0755 /tmp/bun/bun-linux-x64/bun /usr/local/bin/bun; \
rm -rf /tmp/bun /tmp/bun.zip; \
bun --version

# gh — exact version, verified against the trusted checksum, installed TOKENLESS.
RUN set -eux; \
curl -fsSLo /tmp/gh.deb "https://github.com/cli/cli/releases/download/v${GH_VERSION}/gh_${GH_VERSION}_linux_amd64.deb"; \
echo "${GH_SHA256} /tmp/gh.deb" | sha256sum -c -; \
dpkg -i /tmp/gh.deb; rm -f /tmp/gh.deb; \
gh --version

# js-yaml — the launcher's workflow parser, at the version bun.lock pins, from
# the npm registry tarball, verified against the trusted checksum.
RUN set -eux; \
curl -fsSLo /tmp/js-yaml.tgz "https://registry.npmjs.org/js-yaml/-/js-yaml-${JSYAML_VERSION}.tgz"; \
echo "${JSYAML_SHA256} /tmp/js-yaml.tgz" | sha256sum -c -; \
mkdir -p /opt/reviewer/lib/node_modules/js-yaml; \
tar -xzf /tmp/js-yaml.tgz -C /opt/reviewer/lib/node_modules/js-yaml --strip-components=1 --no-same-owner; \
rm -f /tmp/js-yaml.tgz

# The trusted launcher, its planner and resolver, the runtime table, the
# entry command and the sfw shim, baked in and root-owned.
COPY scripts/reviewer/reviewer-launch.mjs scripts/reviewer/resolve-runtime.mjs scripts/reviewer/ci-job.mjs /opt/reviewer/lib/
COPY docker/reviewer/runtime-matrix.json /opt/reviewer/runtime-matrix.json
COPY docker/reviewer/bin/reviewer-launch /opt/reviewer/bin/reviewer-launch
COPY docker/reviewer/shims/sfw /opt/reviewer/shims/sfw

# The image's identity (the matrix entry it was built for), baked read-only; and
# a non-root reviewer user (uid 1000, the reviewer VMs' host uid).
RUN set -eux; \
test -n "${REVIEWER_IMAGE_ID}"; \
printf '%s\n' "${REVIEWER_IMAGE_ID}" > /opt/reviewer/image-id; \
chmod 0755 /opt/reviewer/bin/reviewer-launch /opt/reviewer/shims/sfw; \
chmod -R go-w /opt/reviewer; \
useradd -u 1000 -m -s /bin/bash reviewer

# Hermetic defaults (section A), identical to reviewer-launch.mjs HERMETIC:
# HOME, USERPROFILE, TMPDIR and the Bun/npm caches point under /tmp/review, a
# tmpfs discarded with the sandbox. The launcher creates the directories.
ENV REVIEWER_IMAGE_ID=${REVIEWER_IMAGE_ID} \
HOME=/tmp/review/home \
USERPROFILE=/tmp/review/home \
TMPDIR=/tmp/review/tmp \
TMP=/tmp/review/tmp \
TEMP=/tmp/review/tmp \
XDG_CACHE_HOME=/tmp/review/cache \
XDG_CONFIG_HOME=/tmp/review/config \
XDG_DATA_HOME=/tmp/review/data \
XDG_STATE_HOME=/tmp/review/state \
npm_config_cache=/tmp/review/cache/npm \
BUN_INSTALL_CACHE_DIR=/tmp/review/cache/bun \
BUN_TMPDIR=/tmp/review/tmp \
PIP_CACHE_DIR=/tmp/review/cache/pip \
GOCACHE=/tmp/review/cache/go

# The only persistent writable bind is the review worktree at /workspace.
WORKDIR /workspace
USER reviewer

CMD ["sleep", "infinity"]
Loading
Loading