Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
72 commits
Select commit Hold shift + click to select a range
eaa6b23
feat(hosted): multi-tenant foundation — per-agent DO, service auth, D…
mikeumus Jul 17, 2026
dffd45f
feat(hosted): per-agent routes + isolation proof (routing test + live…
mikeumus Jul 17, 2026
34f2678
feat(hosted): stub image + one-shot staging deploy/smoke/teardown
mikeumus Jul 17, 2026
37068a0
fix(hosted): deploy script falls back to wrangler OAuth when file tok…
mikeumus Jul 17, 2026
8cf9a14
fix(hosted): stream deploy output + always-teardown; record live isol…
mikeumus Jul 17, 2026
c9705d4
feat(container): pin real Hermes v2026.7.7.2 (commit b7751df)
mikeumus Jul 17, 2026
200fe43
feat(hosted): functional test harness — non-root boot check + real ch…
mikeumus Jul 17, 2026
3c10e1f
docs: document hosted multi-tenant mode + fix duplicate endpoint header
mikeumus Jul 17, 2026
6b2235e
docs: record real-Hermes container build + non-root gosu validation
mikeumus Jul 17, 2026
6c9959d
feat(hosted): functional harness supports OpenAI provider + configura…
mikeumus Jul 17, 2026
0f2bf10
fix(hosted): teardown also deletes the Containers application
mikeumus Jul 17, 2026
f4c2ec3
fix(hosted): poll for SERVICE_AUTH_SECRET propagation before smoke
mikeumus Jul 17, 2026
6ac4d10
fix(hosted): boot-check retries through post-secret DO reset churn
mikeumus Jul 17, 2026
02deafc
docs: record FUNCTIONAL PROVEN LIVE — non-root boot + chat, both agents
mikeumus Jul 17, 2026
34cd96c
feat(hosted): add /hosted/agent/stop for teardown on delete
mikeumus Jul 17, 2026
ded365c
feat(hosted): full-surface per-agent proxy + connect-local-hermes docs
mikeumus Jul 17, 2026
c822630
fix(hosted): reject path traversal in the per-agent proxy route (defe…
mikeumus Jul 17, 2026
3d67dc6
feat(container): Gemini provider support (GEMINI_API_KEY/GOOGLE_API_KEY)
mikeumus Jul 17, 2026
bb61bde
feat(hosted): per-agent BYOK provider key via X-Hermes-Provider headers
mikeumus Jul 17, 2026
c1697bf
feat(container): Nous Portal support (NOUS_API_KEY)
mikeumus Jul 17, 2026
52ea5f4
chore(hosted): remove temp debug endpoint; document dead-key + model …
mikeumus Jul 17, 2026
f99c6ee
chore(deploy): drop OPENAI_API_KEY branch — dead keys poison every He…
mikeumus Jul 17, 2026
2fc60cf
feat(container): platform models — inject Divinci Vertex AI + Cloudfl…
mikeumus Jul 18, 2026
fe2bc45
perf(container): tighten idle sleepAfter 4h → 30m for GA cost control
mikeumus Jul 19, 2026
57cc902
feat(terminal): virtual terminal with an OS-enforced security boundary
mikeumus Jul 27, 2026
e42e912
chore(deploy): version the staging + production wrangler configs
mikeumus Jul 27, 2026
0afbe24
fix(security): stop hosted agents executing commands as the credentia…
mikeumus Jul 27, 2026
1694d59
feat(workspace): Google Workspace CLI (gws) in the virtual terminal
mikeumus Jul 27, 2026
478d536
test(terminal): prove the security boundary, and fix a guard crash it…
mikeumus Jul 27, 2026
2c640d8
feat(terminal): make the terminal agent-drivable via MCP (T7)
mikeumus Jul 27, 2026
72a3a39
feat(hosted): apply Slack Socket Mode config per agent
Jul 28, 2026
c646dd2
fix(container): install the musl gws build; add open-workspace Slack …
mikeumus Aug 6, 2026
876cc2f
feat(hosted): push per-agent persona and model into the container
mikeumus Aug 6, 2026
11bce5d
wip(container): register Cloudflare Workers AI as an OpenAI-compatibl…
mikeumus Aug 6, 2026
869026c
feat(hosted): add GET /hosted/agent/logs so a container's log is read…
Aug 6, 2026
b97b94c
fix(container): register cfai under providers.*, not model.providers.*
Aug 6, 2026
5f84ce4
fix(hosted): don't redact an env var NAME used as a config value
Aug 6, 2026
e43ec1d
fix(terminal): lock down IPv6 egress too — the boundary was half-open
Aug 6, 2026
ddeaa9f
fix(terminal): put the IPv6 rules in OUTPUT directly, not a custom chain
Aug 6, 2026
1601813
fix(terminal): don't `exec` gosu — it kills the SDK's session shell
Aug 6, 2026
d8cc42a
docs(net-diag): retarget the comments now that the cause is known
Aug 6, 2026
7949b83
feat(hosted): report whether a container still holds its Slack config
Aug 6, 2026
3fdefcf
test(net-diag): pin the properties the diagnostic's safety rests on
Aug 6, 2026
b31a463
test(agent-logs): assemble the redactor fixtures so push protection s…
Aug 7, 2026
2805f0b
build(container): stop shipping 563 MB of build cache in the image
Aug 7, 2026
6ec6324
fix(cost): drop Hermes sleepAfter 30m → 5m for HTTP on-demand
Aug 7, 2026
3bfb477
fix(terminal): do not exec-away the sandbox shell for gws
Aug 7, 2026
946313c
test(terminal): pin workspace command must not exec-away the session …
Aug 7, 2026
034459c
fix(cost): raise production sleepAfter 5m → 30m, above the keepalive …
Aug 9, 2026
9057e13
docs: retract the Slack-cadence evidence for the sleepAfter change
Aug 9, 2026
e00a2fe
fix(security): approvals manual in production — an email must not rea…
Aug 10, 2026
d668bcb
fix(terminal): the bounded terminal never started — `config set` stor…
Aug 14, 2026
180e61f
docs(email-guard): record why the two "obviously safe" reads stay blo…
Aug 14, 2026
c4c523e
fix(email-guard): commit the half of the plugin that makes it load
Aug 14, 2026
f9ec82f
fix(security): drop the built-in tools that run as the credential uid…
Aug 14, 2026
e0d2d39
fix(container): forward HERMES_DISABLED_TOOLSETS — the flag never rea…
Aug 14, 2026
f6cfff4
fix(security): close the one-call credential read on production Slack
Aug 14, 2026
737b6f0
chore(container): commit the terminal-boundary work that is already d…
Aug 14, 2026
89beb92
fix(security): allowlist Slack's toolsets — the denylist let execute_…
Aug 14, 2026
ed14a03
feat(email-guard): let an unattended turn answer "when are you free?"
Aug 19, 2026
6d3a60e
fix(email-guard): Calendly tool names are underscored, not hyphenated
Aug 19, 2026
f0378ac
docs(email-guard): the Calendly allowlist is Slack's, not email's
mikeumus Aug 20, 2026
4947e56
feat(email-guard): a proactive tier — the fleet had 15 tools and coul…
Aug 21, 2026
bdca1ad
test(email-guard): pin the prerequisite that would 403 every proactiv…
Aug 21, 2026
31dd476
fix(email-guard): the terminal cannot curl a Divinci host — say so, a…
Aug 21, 2026
68589e2
feat(email-guard): let a proactive wake keep a todo list
Aug 21, 2026
63c31e6
docs(email-guard): web_* is inert here — a wake still cannot reach a …
Aug 21, 2026
ce5f7b2
feat(egress): let a wake reach Divinci's own surfaces
Aug 21, 2026
6d471f7
fix(security): revoke the unattended terminal — its egress boundary i…
Aug 21, 2026
e66b7ba
fix(security): establish the terminal boundary on the path that actua…
Aug 21, 2026
c2362cd
fix(egress): forward EGRESS_ALLOWED_HOSTS into the container
Aug 21, 2026
fad7479
feat: restore the proactive tier — the boundary it rests on is now real
Aug 21, 2026
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
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ dist/
.env
.env.local
.env.*.local
*-secrets.env
.staging-test-secrets.env

# Editor / OS
.vscode/
Expand All @@ -31,3 +33,7 @@ yarn-debug.log*
# Test artifacts
coverage/
.nyc_output/

# Python bytecode from the container plugins
__pycache__/
*.pyc
31 changes: 22 additions & 9 deletions HARDENING.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,12 +39,25 @@ v0.1, mapping each audit finding to its fix. Every code change is verified by
- **`hermes dashboard --insecure`** stays — it only lets Hermes bind `0.0.0.0`
(unreachable except via the Worker), not disable transport security.

## Needs validation on the live Sandbox runtime

The non-root privilege drop (`gosu hermes`) and the `~/.hermes` ownership handoff
are correct in principle but assume the Sandbox control plane launches
`start-hermes.sh` as root (so it can `chown` before dropping). Verify on a real
deploy that: (a) the gateway/dashboard start as `hermes` and can read `~/.hermes`,
and (b) `POST /api/instance/restart` (which execs `kill -9 1`) still succeeds —
that exec runs via the control plane, not the de-rooted process, so it should,
but confirm before relying on it.
## Validated

**Container builds against real Hermes (2026-07-17).** `docker build` of this
Dockerfile with `HERMES_VERSION=v2026.7.7.2` succeeds end-to-end: the Node
tarball SHA-256 check passes (pinned hashes correct), real Hermes installs, and
the `hermes dashboard --help` verification step passes. Runtime checks on the
built image confirm the hardening landed:
`id hermes` → `uid=10001(hermes)`, `gosu` at `/usr/sbin/gosu`,
`gosu hermes id -un` → `hermes`, `hermes --version` → `Hermes Agent v0.18.2
(2026.7.7.2)`. So the non-root user + privilege-drop mechanism work with real
Hermes.

## Still needs validation on the live Sandbox runtime

The gosu drop is proven at the container level; what remains is the Sandbox
*orchestration* path. On a real hosted deploy confirm that: (a) when the Sandbox
control plane launches `start-hermes.sh` via `startProcess`, the gateway/dashboard
end up running as `hermes` and can read `~/.hermes` (the script chowns before the
gosu exec), and (b) `POST /api/instance/restart` (which execs `kill -9 1`) still
succeeds — that exec runs via the control plane, not the de-rooted process, so it
should, but confirm before relying on it. `scripts/functional-smoke.sh`'s
`boot-check` asserts (a) automatically.
30 changes: 28 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,8 +126,6 @@ The first request triggers a cold start — expect 15–60 seconds. Subsequent r

## Endpoints

| Method | Path | Description |
| ------ | --------------------------------- | ------------------------------------------------------------ |
| Method | Path | Description | Auth |
| ------ | --------------------------------- | ------------------------------------------------------------ | ----- |
| GET | `/` | Self-describing JSON | none |
Expand All @@ -144,6 +142,34 @@ The first request triggers a cold start — expect 15–60 seconds. Subsequent r
routes **fail closed** (`503`) when no token is configured, unless
`ALLOW_UNAUTHENTICATED=true`. Only `/` is public.

## Hosted (multi-tenant) mode

The routes above are single-tenant (one container per deployment). Setting
`SERVICE_AUTH_SECRET` additionally enables a **multi-tenant** surface under
`/hosted/*`: one Durable Object → one Sandbox container **per agent**, so many
isolated agents run behind a single Worker. Intended to be fronted by a trusted
backend (e.g. the Divinci app) that authenticates end users and calls the Worker
service-to-service.

| Method | Path | Description |
| ------ | --------------------------------------- | ---------------------------------------------------- |
| POST | `/hosted/agent/v1/chat/completions` | Per-agent OpenAI-compatible chat |
| GET | `/hosted/agent/boot-check` | Report the OS user the gateway runs as (non-root proof) |
| POST | `/hosted/agent/probe` | Write+read a per-container marker (isolation proof) |
| GET | `/hosted/agent/probe` | Read the marker (assert no cross-agent read) |

Every `/hosted/*` request requires:
- `Authorization: Bearer <SERVICE_AUTH_SECRET>` (constant-time checked), and
- `X-Divinci-Agent-Id: <agentId>` — a **server-trusted**, strictly-validated id
(`^[a-z0-9](?:[a-z0-9-]{6,62}[a-z0-9])$`). The DO is resolved under an
`agent:<id>` namespace, and an invalid id is rejected (400), never routed to a
shared container.

Container calls are wrapped in a bounded-retry + per-attempt-timeout helper for
resilience. Isolation is proven live by `scripts/isolation-smoke.sh` and
functional behavior (non-root boot + real chat) by `scripts/functional-smoke.sh`
— see `docs/hosted-staging-deploy.md`.

## Native dashboard (optional)

Hermes ships a built-in web dashboard (sessions, analytics, models, crons, skills). To make it reachable, wire a hostname under your control to the Worker:
Expand Down
197 changes: 186 additions & 11 deletions container/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
FROM docker.io/cloudflare/sandbox:0.7.20
FROM docker.io/cloudflare/sandbox:0.12.4
LABEL org.opencontainers.image.title="hermesworkers"
LABEL org.opencontainers.image.description="Hermes Agent running inside Cloudflare Sandbox"
LABEL org.opencontainers.image.licenses="Apache-2.0"
Expand All @@ -16,7 +16,12 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
clang make pkg-config libffi-dev libssl-dev \
ripgrep ffmpeg \
netcat-openbsd iproute2 \
&& rm -rf /var/lib/apt/lists/*
iptables nftables sudo \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*
# `rm -rf /var/lib/apt/lists/*` alone removes the package LISTS but leaves the
# downloaded .debs in /var/cache/apt/archives — measured at 323 MB in the live
# staging container on 2026-08-07. `apt-get clean` is what drops those.

# Non-root runtime user for the Hermes agent. The agent executes tools/shell
# commands on behalf of prompts, so it must not run as root even though the
Expand All @@ -25,6 +30,25 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
RUN groupadd --system --gid 10001 hermes \
&& useradd --system --uid 10001 --gid hermes --home-dir /home/hermes --shell /usr/sbin/nologin hermes

# SEPARATE, LESS-PRIVILEGED user for the virtual terminal (uid 10002).
#
# The terminal runs commands the model composes from untrusted input — a cloned
# repo's README is a prompt-injection vector. It therefore must NOT share the
# identity that owns the provider credentials: ~hermes/.hermes/ holds the Vertex
# service-account JSON, the Cloudflare API key, and any customer BYOK key, and
# `cat ~/.hermes/.env` is the first thing an injection reaches for once
# arbitrary commands are possible.
#
# uid 10002 is also the handle the network lockdown hangs off — setup-terminal.sh
# installs iptables owner-match rules against it so all egress from terminal
# commands must transit the allowlisting guard. Keep the uid stable; the rules,
# the workspace ownership, and the Worker's gosu wrapper all reference it.
RUN groupadd --system --gid 10002 hermes-term \
&& useradd --system --uid 10002 --gid hermes-term --home-dir /workspace --shell /bin/bash hermes-term \
&& mkdir -p /workspace \
&& chown hermes-term:hermes-term /workspace \
&& chmod 0750 /workspace

# Node.js 22 — Hermes install scripts bootstrap Node, pre-installing avoids a
# download at build time. The tarball is fetched over TLS *with* certificate
# verification (no -k) and its SHA-256 is checked against a pinned digest, so a
Expand All @@ -43,18 +67,107 @@ RUN ARCH="$(dpkg --print-architecture)" \
&& rm /tmp/node.tar.xz \
&& node --version

# Google Workspace CLI (`gws`) — one binary for Drive, Gmail, Calendar, Sheets,
# Docs, Chat and Admin, built dynamically from Google's Discovery Service, with
# structured JSON output. Available to the virtual terminal.
#
# PINNED for supply-chain safety, like Node and Hermes above: this binary is
# handed short-lived OAuth tokens for a customer's Google Workspace account, so
# an unpinned floating version is not acceptable. Bump deliberately.
#
# It is open source but explicitly NOT an officially supported Google product —
# worth remembering before promising customers a Google SLA on top of it.
#
# Auth is per-command via GOOGLE_WORKSPACE_CLI_TOKEN (first in the CLI's
# credential resolution order). No credential file is ever written into the
# image or the workspace: the token is injected for the duration of one command
# and never persisted. See buildWorkspaceCommand() in src/lib/terminal.ts.
# ⚠️ Install the MUSL artifact, not the npm package. `npm i -g
# @googleworkspace/cli` runs a postinstall that picks the artifact from
# `ldd --version`: glibc here, so it fetches the `-gnu` build, which is linked
# against GLIBC 2.39. This base image is jammy (GLIBC 2.35), so that build dies
# at `gws --version` with:
# /lib/x86_64-linux-gnu/libc.so.6: version `GLIBC_2.39' not found
# It only ever "worked" off a warm layer cache — a cold build has always failed,
# and `docker image prune` after a deploy is enough to expose it.
#
# The `-musl` artifact of the SAME pinned release is statically linked and
# carries no libc floor. Fetching it directly keeps both guarantees the npm path
# gave us: the URL is version-pinned to the GitHub release, and the .sha256
# published alongside it is verified before the binary is installed.
ENV GWS_VERSION=0.22.5
ENV GWS_ARTIFACT=google-workspace-cli-x86_64-unknown-linux-musl.tar.gz
RUN set -eux; \
base="https://github.com/googleworkspace/cli/releases/download/v${GWS_VERSION}"; \
curl -fsSL "${base}/${GWS_ARTIFACT}" -o /tmp/gws.tar.gz; \
curl -fsSL "${base}/${GWS_ARTIFACT}.sha256" -o /tmp/gws.sha256; \
echo "$(cut -d' ' -f1 /tmp/gws.sha256) /tmp/gws.tar.gz" | sha256sum -c -; \
tar -xzf /tmp/gws.tar.gz -C /tmp; \
install -m 0755 "$(find /tmp -maxdepth 2 -type f -name gws | head -n1)" /usr/local/bin/gws; \
rm -rf /tmp/gws.tar.gz /tmp/gws.sha256; \
gws --version

# Google Cloud CLI (`gcloud`) — pinned tarball + SHA-256, same supply-chain
# posture as Node and gws. Available to the virtual terminal for GCP dogfood
# (Cloud Run, Artifact Registry, logs). Credentials are NEVER baked in: the
# agent authenticates at runtime (short-lived token / ADC inject); without a
# token the binary is present but API calls fail closed.
#
# Image size cost is real (~90 MB compressed / ~400 MB unpacked). Acceptable for
# standard-1 sandbox instances; bump deliberately and re-verify the digest.
# Cloudflare Containers currently run linux/amd64 — refuse other arches rather
# than silently install a mismatched binary.
ENV GCLOUD_VERSION=579.0.0
RUN set -eux; \
ARCH="$(dpkg --print-architecture)"; \
case "${ARCH}" in \
amd64) GCLOUD_ARCH="x86_64"; GCLOUD_SHA256="a9a7fbe51cda37cf6142b1bbcff12227550e60a6c67e8cf84644fb301371c4de" ;; \
*) echo "gcloud: unsupported architecture ${ARCH} (Cloudflare Containers are amd64)" >&2; exit 1 ;; \
esac; \
url="https://dl.google.com/dl/cloudsdk/channels/rapid/downloads/google-cloud-cli-${GCLOUD_VERSION}-linux-${GCLOUD_ARCH}.tar.gz"; \
curl -fsSL "${url}" -o /tmp/gcloud.tgz; \
echo "${GCLOUD_SHA256} /tmp/gcloud.tgz" | sha256sum -c -; \
tar -xzf /tmp/gcloud.tgz -C /opt; \
rm /tmp/gcloud.tgz; \
CLOUDSDK_CORE_DISABLE_PROMPTS=1 /opt/google-cloud-sdk/install.sh \
--quiet \
--usage-reporting false \
--path-update false \
--command-completion false; \
ln -sf /opt/google-cloud-sdk/bin/gcloud /usr/local/bin/gcloud; \
ln -sf /opt/google-cloud-sdk/bin/gsutil /usr/local/bin/gsutil; \
ln -sf /opt/google-cloud-sdk/bin/bq /usr/local/bin/bq; \
# Drop install backups / pyc so the snapshot stays leaner.
rm -rf /opt/google-cloud-sdk/.install/.backup \
/opt/google-cloud-sdk/platform/gsutil/third_party \
/root/.config/gcloud; \
gcloud --version

# Cloudflare Wrangler — pinned npm version. Postinstall pulls the matching
# workerd binary for the host arch (expected at image build). No CF account
# token is stored; `wrangler whoami` / deploy need a short-lived token at runtime
# (or CLOUDFLARE_API_TOKEN injected for one command — never persisted under
# /workspace). registry.npmjs.org is already on the default egress allowlist so
# component fetches at build time succeed.
ENV WRANGLER_VERSION=4.120.0
RUN set -eux; \
npm install -g "wrangler@${WRANGLER_VERSION}"; \
wrangler --version; \
npm cache clean --force; \
rm -rf /root/.npm /tmp/*

# Pin HOME so Cloudflare Sandbox snapshot/backup catches Hermes state under ~/.hermes.
ENV HOME=/home/hermes
RUN mkdir -p /home/hermes

# Install Hermes from source, pinned for reproducibility.
# v2026.4.30 includes the `hermes dashboard` command (web UI on port 9119) and
# the API server. Pin an immutable commit via the HERMES_COMMIT build-arg for
# supply-chain safety (git tags are mutable; a moved tag would silently change
# what you ship). The clone FAILS HARD on a bad tag — no silent fallback to an
# unpinned default branch.
ENV HERMES_VERSION=v2026.4.30
ARG HERMES_COMMIT=""
# Pinned to NousResearch/hermes-agent v2026.7.7.2 (has `hermes gateway`,
# `hermes dashboard`, and the API server). HERMES_COMMIT defaults to that tag's
# immutable commit SHA for supply-chain safety (git tags are mutable; a moved
# tag would silently change what you ship). The clone FAILS HARD on a bad tag —
# no silent fallback to an unpinned default branch. Override both to bump.
ENV HERMES_VERSION=v2026.7.7.2
ARG HERMES_COMMIT="b7751df34688835a108e0d630f3495fc11f3df79"
WORKDIR /opt
RUN git clone --depth 1 --branch "${HERMES_VERSION}" https://github.com/NousResearch/hermes-agent.git hermes-agent \
&& if [ -n "${HERMES_COMMIT}" ]; then \
Expand All @@ -68,11 +181,17 @@ RUN python3.11 -m venv /opt/hermes-venv \
&& /opt/hermes-venv/bin/pip install --no-cache-dir --upgrade pip \
&& /opt/hermes-venv/bin/pip install --no-cache-dir uv \
&& /opt/hermes-venv/bin/uv pip install --python /opt/hermes-venv/bin/python -e ".[all]"
# uv keeps its own wheel cache under ~/.cache/uv and, unlike the pip calls
# above, has no --no-cache-dir on it — 240 MB of it shipped in the image
# (measured in the live container 2026-08-07). Cleaned after the LAST uv call
# below rather than here, so the second install still gets cache hits.

# Install the web dashboard + pty extras from the SAME pinned source tree
# (provides the native Hermes UI on port 9119). No PyPI fallback — a fallback to
# an unpinned `hermes-agent[web,pty]` would defeat the pinning above.
RUN /opt/hermes-venv/bin/uv pip install --python /opt/hermes-venv/bin/python -e ".[web,pty]"
RUN /opt/hermes-venv/bin/uv pip install --python /opt/hermes-venv/bin/python -e ".[web,pty]" \
&& /opt/hermes-venv/bin/uv cache clean \
&& rm -rf /root/.cache/uv /home/hermes/.cache/uv

# Symlink the hermes CLI globally.
RUN ln -sf /opt/hermes-venv/bin/hermes /usr/local/bin/hermes
Expand All @@ -85,12 +204,68 @@ RUN /opt/hermes-venv/bin/hermes dashboard --help > /dev/null 2>&1 \
COPY start-hermes.sh /usr/local/bin/start-hermes.sh
RUN sed -i 's/\r$//' /usr/local/bin/start-hermes.sh && chmod 0755 /usr/local/bin/start-hermes.sh

# Virtual-terminal boundary: the allowlisting egress proxy and the boot-time
# lockdown script that makes it unbypassable. Both are root-owned and
# non-writable by either runtime user — the terminal user must not be able to
# edit the thing that contains it.
# The bounded terminal, exposed to the Hermes agent as an MCP server.
#
# The sudoers grant is deliberately narrow: the `hermes` user may run exactly
# ONE program as `hermes-term`, by absolute path. That is "run this program",
# not "become that user". Both the helper and the MCP server are root-owned and
# not writable by either runtime user, so the thing being granted cannot be
# swapped out by the thing receiving the grant.
#
# The escalation runs DOWNWARD (uid 10001 -> 10002, dropping the ability to read
# ~/.hermes/), which is what makes it safe: `hermes` gains nothing it did not
# already have, and everything it runs through the helper is confined by the
# terminal boundary rather than by its own uid.
COPY hermes-term-exec /usr/local/bin/hermes-term-exec
COPY mcp-terminal-server.js /usr/local/bin/mcp-terminal-server.js
RUN sed -i 's/\r$//' /usr/local/bin/hermes-term-exec \
&& chmod 0755 /usr/local/bin/hermes-term-exec /usr/local/bin/mcp-terminal-server.js \
&& chown root:root /usr/local/bin/hermes-term-exec /usr/local/bin/mcp-terminal-server.js \
&& printf 'hermes ALL=(hermes-term) NOPASSWD: /usr/local/bin/hermes-term-exec\n' > /etc/sudoers.d/hermes-term \
&& chmod 0440 /etc/sudoers.d/hermes-term \
&& visudo -cf /etc/sudoers.d/hermes-term

# ── Unattended-turn tool guard (divinci_email_guard) ───────────────────────
#
# Staged read-only under /usr/local/share; start-hermes.sh installs it into
# ~hermes/.hermes/plugins/ at boot and enables it in `plugins.enabled`.
#
# Owned root:root and 0755/0644 so the `hermes` uid — the one an injected
# agent runs as — cannot rewrite the guard that constrains it. The copy under
# ~/.hermes IS writable by hermes; that is unavoidable (the plugin loader
# only reads from there) and is why Hermes' own shell is disabled via
# command_allowlist=[] and the bounded terminal runs as hermes-term, which
# cannot write ~hermes/.hermes/ at all. The staged original is the reference
# copy, re-installed on every boot.
COPY plugins/divinci_email_guard /usr/local/share/divinci-hermes-plugins/divinci_email_guard
RUN chown -R root:root /usr/local/share/divinci-hermes-plugins \
&& find /usr/local/share/divinci-hermes-plugins -type d -exec chmod 0755 {} + \
&& find /usr/local/share/divinci-hermes-plugins -type f -exec chmod 0644 {} +

COPY egress-guard.js /usr/local/bin/egress-guard.js
COPY setup-terminal.sh /usr/local/bin/setup-terminal.sh
RUN sed -i 's/\r$//' /usr/local/bin/setup-terminal.sh \
&& chmod 0755 /usr/local/bin/setup-terminal.sh /usr/local/bin/egress-guard.js \
&& chown root:root /usr/local/bin/setup-terminal.sh /usr/local/bin/egress-guard.js
RUN mkdir -p /var/log && touch /var/log/hermes-egress.log \
&& chown hermes:hermes /var/log/hermes-egress.log \
&& chmod 0640 /var/log/hermes-egress.log

# Hand the Hermes state + install trees to the non-root user. The a+rX keeps the
# (non-secret) install tree readable for the Sandbox snapshot; runtime secrets
# under ~/.hermes are created 0700 by start-hermes.sh, so no secret is ever
# world-readable.
RUN chown -R hermes:hermes /home/hermes /opt/hermes-agent /opt/hermes-venv \
&& chmod -R a+rX /opt/hermes-agent /opt/hermes-venv
&& chmod -R a+rX /opt/hermes-agent /opt/hermes-venv \
# 0711 lets the terminal user traverse into world-readable subpaths without
# being able to LIST /home/hermes — combined with 0700 on ~/.hermes (set by
# start-hermes.sh and re-asserted by setup-terminal.sh), the credential
# directory is neither listable nor readable from uid 10002.
&& chmod 0711 /home/hermes

WORKDIR /home/hermes

Expand Down
Loading