Compare commits
38
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad1555f188 | ||
|
|
90f19b2eaa | ||
|
|
e662ac13d4 | ||
|
|
0cf50deb68 | ||
|
|
c9eea9d58c | ||
|
|
3159a9214e | ||
|
|
d01b341f41 | ||
|
|
49a57ca8f1 | ||
|
|
4f06f88fa3 | ||
|
|
5696dfface | ||
|
|
ae06a49b04 | ||
|
|
cea475b733 | ||
|
|
8c5509c0f1 | ||
|
|
254201862b | ||
|
|
24b5d9ded8 | ||
|
|
1cb847c11e | ||
|
|
cd29f1bc19 | ||
|
|
9df06e8a94 | ||
|
|
d4fad60b53 | ||
|
|
b94a68fb82 | ||
|
|
8f93f6a484 | ||
|
|
db27d7717b | ||
|
|
465c75bf7a | ||
|
|
a83f84561d | ||
|
|
89ee7cfae0 | ||
|
|
5f9520b0b6 | ||
|
|
e706d6d91f | ||
|
|
ceee48dadc | ||
|
|
eb8baaa3a5 | ||
|
|
a4cc9872e7 | ||
|
|
200eaa120d | ||
|
|
ea034b50b0 | ||
|
|
7f035634ef | ||
|
|
9d383d8b87 | ||
|
|
743102f56b | ||
|
|
27e3e0c3c1 | ||
|
|
2eae10e7ca | ||
|
|
247dbf73dd |
@@ -1,3 +1,12 @@
|
||||
# opencode is a declared dependency (see dependencies): it loads its provider
|
||||
# catalogue from models.dev at startup, so a session testing that integration
|
||||
# cannot get as far as running it without this one.
|
||||
#
|
||||
# The model API itself is deliberately not here. Which endpoint that is
|
||||
# depends on the provider an operator authenticates opencode against, and a
|
||||
# name granted by being written here is a name nobody reviewed.
|
||||
models.dev
|
||||
|
||||
docs.gitea.com
|
||||
deb.debian.org
|
||||
download.docker.com
|
||||
|
||||
@@ -1,2 +1,11 @@
|
||||
# Every harness dejima supports, so a session working on this repo can run
|
||||
# and test the integration rather than reason about it. A harness dejima
|
||||
# claims to support and cannot exercise here is one whose guards are asserted
|
||||
# rather than observed.
|
||||
#
|
||||
# claude-code is not listed: it is installed in docker/Dockerfile, being the
|
||||
# harness the image is built around.
|
||||
npm:opencode-ai
|
||||
|
||||
apt:tmux
|
||||
apt:bats
|
||||
|
||||
@@ -1,2 +1,7 @@
|
||||
.claude
|
||||
web/node_modules
|
||||
|
||||
# Python bytecode. bin/dejima-anthropic-proxy is a python program, so anything
|
||||
# that imports or compiles it leaves these beside it.
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
@@ -33,14 +33,19 @@ dependency, and it will not announce that it has been. dejima is built for
|
||||
that case: the agent works without asking, and what it can reach, take and
|
||||
break was decided before it started.
|
||||
|
||||
Runs a coding agent (Claude Code by default, or whatever command you point
|
||||
it at) inside a firewalled Docker container with
|
||||
`--dangerously-skip-permissions` (no permission prompts, all tool use
|
||||
auto-accepted), against a project bind-mounted from the host. The agent gets
|
||||
to work without stopping to ask; the container is what makes that trade a
|
||||
reasonable one.
|
||||
dejima is a **sandboxed development environment for unsupervised agentic
|
||||
work.** It runs a coding agent (Claude Code, or whatever command you point it
|
||||
at) inside a firewalled Docker container with `--dangerously-skip-permissions`
|
||||
(no permission prompts, all tool use auto-accepted), against a project
|
||||
bind-mounted from the host. The agent gets to work without stopping to ask;
|
||||
the container is what makes that trade a reasonable one.
|
||||
|
||||
Around that core is most of what running an agent unattended takes:
|
||||
A container on its own is only confinement. What makes this a development
|
||||
environment is the rest: a forge workflow the agent drives itself, project
|
||||
context written for the agent rather than for you, and enough of a trail left
|
||||
behind that you can walk away from a session and still pick up what it did.
|
||||
|
||||
**What bounds it:**
|
||||
|
||||
- **An egress allowlist.** DNS and IP, IPv4 and IPv6. Anything not on the
|
||||
list does not connect, and the agent is told so.
|
||||
@@ -56,10 +61,6 @@ Around that core is most of what running an agent unattended takes:
|
||||
mounted whole. A session gets its own project's transcripts, memory and
|
||||
edit history and nothing else, so it cannot read what you did elsewhere or
|
||||
write into the memory a future session in another project will believe.
|
||||
- **A PR and issue workflow.** `gitea-pr` and `gitea-issue` let a session
|
||||
open pull requests and issues against Gitea or Forgejo and pull review
|
||||
feedback back in, on a token scoped for exactly that. Merging stays a
|
||||
human decision, made outside the sandbox.
|
||||
- **CI hardening, because push access is code execution.** A session opens a
|
||||
pull request, the workflow fires, and your runner executes that commit
|
||||
before you've read a line of it. The container bounds what a session reaches
|
||||
@@ -68,23 +69,39 @@ Around that core is most of what running an agent unattended takes:
|
||||
takes, and
|
||||
[dejima-ci-hardener](https://gitbox.branderson.io/dejima/dejima-ci-hardener)
|
||||
for a tool that does it.
|
||||
- **Logging, off unless you turn it on.** Point `[logging] loki_url` at your
|
||||
own Loki instance and every DNS lookup and rejected connection is shipped
|
||||
there, with the session transcript too if you separately opt in. Nothing
|
||||
leaves the machine otherwise, and there is no endpoint of ours anywhere in
|
||||
this.
|
||||
- **Remote access, opt-in and off by default.** Attach to a live session
|
||||
from a terminal or a browser, with a push notification when it wants you.
|
||||
- **Guard rails inside the session.** Hooks refuse obviously destructive
|
||||
commands, pushes to anywhere but the allowed remote, and merging a PR from
|
||||
inside the sandbox. These are
|
||||
fast-fail guards against an overeager agent, not a boundary against a
|
||||
hostile one: the container is the boundary.
|
||||
- **An exit-time audit** of the host surfaces a session could have written
|
||||
to, so a change to your git config or `~/.claude` gets surfaced.
|
||||
|
||||
**What it gives an agent to work with:**
|
||||
|
||||
- **A PR and issue workflow the agent drives itself.** `gitea-pr` and
|
||||
`gitea-issue` let a session open pull requests and issues against Gitea or
|
||||
Forgejo and pull review feedback back in, on a token scoped for exactly
|
||||
that. Merging stays a human decision, made outside the sandbox.
|
||||
- **Project context written for the agent.** A project ships a `.dejima.md`
|
||||
saying how work is done in it - `CLAUDE.md`'s job under a harness-agnostic
|
||||
name - and dejima loads it alongside a briefing on where the session is,
|
||||
what it can reach, and why the limits are there. An agent that knows it is
|
||||
in a sandbox asks for a domain instead of working around the firewall.
|
||||
- **Config parity with your host.** `CLAUDE.md`, settings, hooks, plugins
|
||||
and git identity carry over; uid, home directory and project path match on
|
||||
both sides, so a session's transcript is readable from the host afterward.
|
||||
- **An exit-time audit** of the host surfaces a session could have written
|
||||
to, so a change to your git config or `~/.claude` gets surfaced.
|
||||
- **Remote access, opt-in and off by default.** Attach to a live session
|
||||
from a terminal or a browser, with a push notification when it wants you.
|
||||
- **What a long session worked out, kept.** Each time the agent compacts, the
|
||||
summary it writes is saved host-side, into a store no container mounts. It
|
||||
is the only durable account of a session's reasoning, and it otherwise goes
|
||||
when the container does.
|
||||
- **Logging, off unless you turn it on.** Point `[logging] loki_url` at your
|
||||
own Loki instance and every DNS lookup and rejected connection is shipped
|
||||
there, with the session transcript too if you separately opt in. Nothing
|
||||
leaves the machine otherwise, and there is no endpoint of ours anywhere in
|
||||
this.
|
||||
|
||||
**Early development. Not ready to be depended on.** Nothing here has been
|
||||
through outside review, an external audit, or adversarial testing by anyone
|
||||
@@ -218,6 +235,13 @@ engineering judgement or human review, and nothing here changes that.
|
||||
directly on your machine. A determined attacker, or one with a container
|
||||
escape, defeats all of it. Use it at your own risk.
|
||||
|
||||
An escape does not leave the picture, though - it moves to the layer below,
|
||||
and dejima cannot bound that layer because it runs inside it.
|
||||
[dejima-devbox-hardener](https://gitbox.branderson.io/dejima/dejima-devbox-hardener)
|
||||
is what bounds what an escape spreads to on the machine dejima runs on. See
|
||||
[docs/threat-model.md](docs/threat-model.md) for where each layer's
|
||||
compromise stops.
|
||||
|
||||
**dejima's security says nothing about your software's.** Security isn't
|
||||
retrofittable: it's a property of how something was built, at every step. If
|
||||
what you build inside dejima turns out to be insecure, that isn't something a
|
||||
@@ -273,11 +297,18 @@ sandbox escape if you get it wrong.
|
||||
|
||||
### Core (required)
|
||||
|
||||
1. `./install.sh`: symlinks the CLIs into `~/.local/bin` and the hooks into
|
||||
1. **Install your harness on the host and sign in to it there**, before any
|
||||
of the rest. dejima never authenticates anything: the credential proxy
|
||||
holds the credential your harness wrote on the host and hands the
|
||||
container a placeholder, so if there is nothing on the host there is
|
||||
nothing to hold. A session in that state starts normally and then cannot
|
||||
reach the model, which looks like a dejima fault and is not one. See
|
||||
[Sign in on the host first](#sign-in-on-the-host-first).
|
||||
2. `./install.sh`: symlinks the CLIs into `~/.local/bin` and the hooks into
|
||||
`~/.claude/hooks`, and writes a starter `~/.config/dejima/config` if none
|
||||
exists yet. It is idempotent, so re-run it after pulling; it never
|
||||
overwrites an existing config.
|
||||
2. Put `~/.local/bin` on your `PATH` if it isn't already.
|
||||
3. Put `~/.local/bin` on your `PATH` if it isn't already.
|
||||
That is enough to run `dejima`. No push credential is needed to get this far,
|
||||
because the default workflow does not push. The starter config `install.sh` writes has
|
||||
every key commented out at its default, which means `[core] forge = none` and
|
||||
@@ -391,6 +422,36 @@ dejima --ro ~/other-project ~/myproject
|
||||
**→ [docs/configuration.md](docs/configuration.md#one-off-mounts-for-a-single-launch)** -
|
||||
both forms, and which host paths a mount is refused over and why.
|
||||
|
||||
## Sign in on the host first
|
||||
|
||||
**Install your harness on the host and authenticate it there before running
|
||||
dejima.** This is a prerequisite rather than a convenience, and nothing
|
||||
enforces it.
|
||||
|
||||
dejima never signs in to anything. The credential proxy's whole design is
|
||||
that the credential stays on the host and the container gets a placeholder:
|
||||
a host-side process holds the real token, injects it per request, and owns
|
||||
the refresh. That only works if the host has a credential to hold. On a
|
||||
machine where the harness has never run and never logged in, there is
|
||||
nothing for the proxy to hold, so the proxy does not start and no credential
|
||||
is mounted.
|
||||
|
||||
What that looks like is worth knowing, because it does not look like a
|
||||
missing prerequisite. `dejima` starts normally, the container comes up, the
|
||||
firewall self-checks pass - and then the session cannot reach the model.
|
||||
Authenticating from inside is not the fix: the container is where the
|
||||
credential deliberately never goes, and the egress allowlist is not built
|
||||
around completing a sign-in flow there.
|
||||
|
||||
The same applies to the account state your harness keeps alongside the
|
||||
credential, which is read fresh from the host at every launch (see
|
||||
"Throwaway ~/.claude.json" in [docs/security.md](docs/security.md)). A host
|
||||
that has never run the harness has none of it.
|
||||
|
||||
So: run your harness once on the host, sign in, confirm it answers, and then
|
||||
point dejima at a project. When a credential expires, refresh it the same
|
||||
way - on the host, not in a session.
|
||||
|
||||
## Config parity with your host Claude Code setup
|
||||
|
||||
Global `CLAUDE.md`, settings, hooks, skills, plugins and git identity all
|
||||
@@ -637,6 +698,7 @@ covers, and how to run them.
|
||||
| --- | --- |
|
||||
| [responsible-use.md](docs/responsible-use.md) | How to use this without making things worse |
|
||||
| [security.md](docs/security.md) | Provider trust, the egress firewall, identity, and logging |
|
||||
| [threat-model.md](docs/threat-model.md) | What is assumed hostile, and where each layer's compromise stops |
|
||||
| [configuration.md](docs/configuration.md) | Per-project declaration, dependencies, config parity |
|
||||
| [providers.md](docs/providers.md) | Forge, workflow and harness axes, and writing your own |
|
||||
| [ci.md](docs/ci.md) | What push access grants on your CI host, and how to bound it |
|
||||
|
||||
+5
-1
@@ -15,7 +15,11 @@ IP, and no copy of the credentials it authenticates with. That allowlist
|
||||
bounds where a prompt injection or a compromised dependency can send code and
|
||||
where it can fetch its next instruction. It does not prevent either one.
|
||||
|
||||
Nothing here holds against a container escape or a kernel bug. The in-session
|
||||
Nothing here holds against a container escape or a kernel bug. What an escape
|
||||
then reaches is bounded by the layer below, which dejima cannot reach because
|
||||
it runs inside it - that is
|
||||
[dejima-devbox-hardener](https://gitbox.branderson.io/dejima/dejima-devbox-hardener)'s
|
||||
subject, not this project's. The in-session
|
||||
hooks are fast-fail guards, not a boundary. The exit-time audit runs after the
|
||||
session has ended and attributes nothing to it. And none of it speaks to
|
||||
whether the code a session wrote is correct, secure, or fit to merge.
|
||||
|
||||
Binary file not shown.
+70
-5
@@ -382,10 +382,20 @@ SANDBOX_EXTRA_MOUNTS="$(_append_mounts "$SANDBOX_EXTRA_MOUNTS" \
|
||||
# approval store discloses every project's approved domains and
|
||||
# dependencies, and the audit baselines say which findings have already been
|
||||
# waved through. Refusing beats mounting and hoping the mode holds (#15).
|
||||
#
|
||||
# The epitaph store is here for the same reason and a sharper one: it holds
|
||||
# each earlier session's own account of what it worked out, and a session
|
||||
# that can read those can read what every previous session in this project
|
||||
# concluded. Never mounting it is the entire design, and a static test
|
||||
# checks that nothing here mounts it - but that test cannot see a path an
|
||||
# operator names in [mounts] readonly, which is computed at run time. Listed
|
||||
# whatever [epitaph] enabled says: turning the feature off stops new
|
||||
# epitaphs being written, it does not empty the store.
|
||||
GUARDED_STORES=()
|
||||
_guarded_stores() {
|
||||
[ ${#GUARDED_STORES[@]} -eq 0 ] || return 0
|
||||
GUARDED_STORES=("$(config_root)" "$AUDIT_DIR" "$BASELINE_DIR")
|
||||
GUARDED_STORES=("$(config_root)" "$AUDIT_DIR" "$BASELINE_DIR" \
|
||||
"$(config_get epitaph dir)")
|
||||
}
|
||||
|
||||
# Refuse $2 if it overlaps any of the paths in $3.., in either direction: an
|
||||
@@ -431,7 +441,13 @@ if [ -n "$SANDBOX_RW_MOUNTS" ]; then
|
||||
# below are between canonical paths rather than whatever spelling the
|
||||
# config happened to use.
|
||||
rw_real="$(readlink -m "$path")"
|
||||
for guarded in "$(config_root)" "$AUDIT_DIR" "$BASELINE_DIR" \
|
||||
# The shared list plus this path's own extras, rather than a second
|
||||
# copy of the stores: the two lists having drifted is what let the
|
||||
# epitaph store be mountable from here after it was added to one of
|
||||
# them. A store added to _guarded_stores is now guarded on both
|
||||
# paths without anyone remembering to do it twice.
|
||||
_guarded_stores
|
||||
for guarded in ${GUARDED_STORES[@]+"${GUARDED_STORES[@]}"} \
|
||||
"$HOME/.claude" "$HOME/.local/bin" "$REPO_ROOT" "$WORKSPACE_DIR"; do
|
||||
guarded_real="$(readlink -m "$guarded")"
|
||||
_paths_overlap "$rw_real" "$guarded_real" || continue
|
||||
@@ -1495,7 +1511,7 @@ fi
|
||||
# image: now honoring $SANDBOX_IMAGE, letting the derived tag leak into this
|
||||
# build would rebuild the base Dockerfile under the derived name.
|
||||
SANDBOX_IMAGE="dejima:${SANDBOX_USER}" \
|
||||
docker compose -f "$COMPOSE_FILE" build claude
|
||||
docker compose -f "$COMPOSE_FILE" build sandbox
|
||||
|
||||
SANDBOX_IMAGE="dejima:${SANDBOX_USER}"
|
||||
|
||||
@@ -1516,13 +1532,62 @@ build_dep_layer() {
|
||||
SANDBOX_IMAGE="$tag"
|
||||
}
|
||||
|
||||
if [ -s "$(config_root)/dependencies" ] || { [ -n "$PROJECT_STORE" ] && [ -s "$PROJECT_STORE/dependencies" ]; }; then
|
||||
# The selected harness, and only it.
|
||||
#
|
||||
# The base image is shared by every project on this host, so a harness baked
|
||||
# into it would be carried by every sandbox whether or not it is the one
|
||||
# selected - and a second harness could only ever be added alongside the
|
||||
# first, never chosen between. Here it is a layer keyed on the harness's own
|
||||
# name, so switching [core] harness switches images rather than accumulating
|
||||
# into one.
|
||||
#
|
||||
# Built before the dependency layers so a project's own packages sit above the
|
||||
# harness rather than being invalidated whenever it changes.
|
||||
#
|
||||
# The spec is manifest data of type `depspec`, so manifest_get has already
|
||||
# refused anything valid_dep_spec would - the same function an operator's
|
||||
# approved dependency goes through, and the reason this string is safe to
|
||||
# interpolate into a Dockerfile RUN line. Re-checking here would be a second
|
||||
# copy of a rule with no way to disagree with the first, and no way to test.
|
||||
SANDBOX_HARNESS_INSTALL="$(manifest_get harness "$DEJIMA_HARNESS_NAME" install)"
|
||||
# What this harness needs its image to contain besides the package: the
|
||||
# directories it keeps state in, and anything it wants in the environment.
|
||||
# Both used to be in the base image, where they were one harness's needs
|
||||
# imposed on every image dejima builds.
|
||||
SANDBOX_HARNESS_IMAGE_DIRS="$(manifest_get harness "$DEJIMA_HARNESS_NAME" image_dirs)"
|
||||
SANDBOX_HARNESS_IMAGE_ENV="$(manifest_get harness "$DEJIMA_HARNESS_NAME" image_env)"
|
||||
SANDBOX_HARNESS_IMAGE_FILES="$(manifest_get harness "$DEJIMA_HARNESS_NAME" image_files)"
|
||||
|
||||
if [ -n "$SANDBOX_HARNESS_INSTALL" ] || [ -n "$SANDBOX_HARNESS_IMAGE_DIRS" ] \
|
||||
|| [ -n "$SANDBOX_HARNESS_IMAGE_ENV" ] || [ -n "$SANDBOX_HARNESS_IMAGE_FILES" ] \
|
||||
|| [ -s "$(config_root)/dependencies" ] \
|
||||
|| { [ -n "$PROJECT_STORE" ] && [ -s "$PROJECT_STORE/dependencies" ]; }; then
|
||||
SANDBOX_DERIVED_DOCKERFILE="$(mktemp "${TMPDIR:-/tmp}/dejima-derived.XXXXXX")"
|
||||
# An empty context, never $WORKSPACE_DIR: the project directory is
|
||||
# session-writable, and a root-context build with it as context would let a
|
||||
# compromised session feed files into the image it then runs in.
|
||||
SANDBOX_DERIVED_CONTEXT="$(mktemp -d "${TMPDIR:-/tmp}/dejima-derived-ctx.XXXXXX")"
|
||||
fi
|
||||
|
||||
if [ -n "$SANDBOX_HARNESS_INSTALL" ] || [ -n "$SANDBOX_HARNESS_IMAGE_DIRS" ] \
|
||||
|| [ -n "$SANDBOX_HARNESS_IMAGE_ENV" ] || [ -n "$SANDBOX_HARNESS_IMAGE_FILES" ]; then
|
||||
tag="dejima:${SANDBOX_USER}-harness-${DEJIMA_HARNESS_NAME}"
|
||||
generate_spec_dockerfile "$SANDBOX_IMAGE" "$SANDBOX_HARNESS_INSTALL" \
|
||||
"$SANDBOX_HARNESS_IMAGE_DIRS" "$SANDBOX_HARNESS_IMAGE_ENV" \
|
||||
"$SANDBOX_HARNESS_IMAGE_FILES" \
|
||||
> "$SANDBOX_DERIVED_DOCKERFILE"
|
||||
echo "Building $tag ($DEJIMA_HARNESS_NAME)..."
|
||||
if ! docker build -t "$tag" -f "$SANDBOX_DERIVED_DOCKERFILE" "$SANDBOX_DERIVED_CONTEXT"; then
|
||||
# No fallback: a session started on an image without its harness gets
|
||||
# an exec failure inside a container, which reads as dejima being
|
||||
# broken rather than as this build having failed.
|
||||
echo "Failed to build $tag." >&2
|
||||
echo "The harness installs from: $SANDBOX_HARNESS_INSTALL" >&2
|
||||
exit 1
|
||||
fi
|
||||
SANDBOX_IMAGE="$tag"
|
||||
fi
|
||||
|
||||
if [ -s "$(config_root)/dependencies" ]; then
|
||||
build_dep_layer "dejima:${SANDBOX_USER}-global" "$(config_root)"
|
||||
fi
|
||||
@@ -1623,7 +1688,7 @@ compose_status=0
|
||||
"$SCRIPT_DIR/dejima-conversation-ship" "$WORKSPACE_DIR" &
|
||||
SANDBOX_CONVERSATION_SHIP_PID=$!
|
||||
|
||||
docker compose -f "$COMPOSE_FILE" run --rm ${extra_mounts[@]+"${extra_mounts[@]}"} claude "$@" || compose_status=$?
|
||||
docker compose -f "$COMPOSE_FILE" run --rm ${extra_mounts[@]+"${extra_mounts[@]}"} sandbox "$@" || compose_status=$?
|
||||
if [ -n "${SANDBOX_ANTHROPIC_PROXY_PID:-}" ] && ! kill -0 "$SANDBOX_ANTHROPIC_PROXY_PID" 2>/dev/null; then
|
||||
echo "Warning: the credential proxy exited before the session ended - API calls in" >&2
|
||||
echo "the session may have been failing. Check for proxy errors in the output above." >&2
|
||||
|
||||
@@ -134,6 +134,29 @@ if [ "$session_wanted" -eq 1 ]; then
|
||||
echo "Resolving $domain inside $container..."
|
||||
ips=$(docker exec "$container" dig +short A "$domain" | grep -E '^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$' || true)
|
||||
if [ -z "$ips" ]; then
|
||||
# An internally-routed name can't be added to a *running* session at
|
||||
# all: dnsmasq's stop-dns-rebind drops a private answer for any domain
|
||||
# not exempted when the container started, and dns-proxy.sh writes
|
||||
# those rebind-domain-ok= lines only at startup - the servers-file this
|
||||
# script SIGHUPs carries server= lines and nothing else. Asking the
|
||||
# upstream resolver directly separates that dead end from a name that
|
||||
# simply doesn't resolve, so the message can name the fix that works.
|
||||
upstream=$(docker exec "$container" dig +short A "$domain" @127.0.0.11 2>/dev/null |
|
||||
grep -E '^(10\.|127\.|169\.254\.|192\.168\.|172\.(1[6-9]|2[0-9]|3[01])\.)' || true)
|
||||
if [ -n "$upstream" ]; then
|
||||
echo "$domain resolves to a private address (${upstream//$'\n'/ }), which the" >&2
|
||||
echo "container's DNS rebind protection drops for any domain not allowlisted" >&2
|
||||
echo "when it started." >&2
|
||||
echo "" >&2
|
||||
if [ -n "$project" ] || [ "$global" -eq 1 ]; then
|
||||
echo " (the approval recorded above already does this)" >&2
|
||||
else
|
||||
echo " dejima-allow --global $domain" >&2
|
||||
fi
|
||||
echo "" >&2
|
||||
echo "Relaunch the session to pick it up - a running one cannot be patched." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Failed to resolve $domain inside the container." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -159,6 +159,75 @@ config_path() {
|
||||
printf '%s/config' "$(config_root)"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dependency specs
|
||||
#
|
||||
# Here rather than in dejima-project-lib.sh so this base layer sources
|
||||
# nothing, and so dejima-provider-lib.sh - which validates manifests and sits
|
||||
# below project-lib - can reach them. A harness declares what installs it as
|
||||
# one of these, and it is the same grammar an operator's approved dependency
|
||||
# goes through, checked by the same function.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# <ecosystem>:<name>. The character class is deliberately narrow: an
|
||||
# approved spec gets interpolated into a generated Dockerfile RUN line, so
|
||||
# anything shell-active here would be a root-context injection at build.
|
||||
# The name must start with an alphanumeric - without that, a leading dash
|
||||
# (e.g. "--allow-unauthenticated") validates as a name and lands in the
|
||||
# generated RUN line as an option instead, quietly weakening apt/pip/npm/
|
||||
# cargo's own signature/lockfile checks.
|
||||
valid_dep_spec() {
|
||||
[[ "$1" =~ ^(apt|pip|cargo):[A-Za-z0-9][A-Za-z0-9._+-]*$ ]] && return 0
|
||||
# npm additionally allows a leading @scope/ and a trailing @version, which
|
||||
# between them cover most of the registry. Neither `@` nor `/` is
|
||||
# shell-active, so this widens what may be named without widening what a
|
||||
# RUN line can be made to do.
|
||||
[[ "$1" =~ ^npm:(@[A-Za-z0-9][A-Za-z0-9._-]*/)?[A-Za-z0-9][A-Za-z0-9._-]*(@[A-Za-z0-9][A-Za-z0-9._-]*)?$ ]] && return 0
|
||||
valid_tarball_spec "$1"
|
||||
}
|
||||
|
||||
# tarball:<name>@<sha256>:<url> - a release archive unpacked into the image,
|
||||
# for a toolchain no package ecosystem ships in a usable version.
|
||||
#
|
||||
# The digest is mandatory and is what makes this an approval at all. Without
|
||||
# it the operator would be approving a *name* whose contents the other end can
|
||||
# change afterwards, which is not a decision anyone can make once; with it,
|
||||
# what was approved is a specific archive and nothing else can take its place.
|
||||
# The generated build verifies it before unpacking, not after.
|
||||
#
|
||||
# The character classes are narrower than the URL grammar allows, for the same
|
||||
# reason as above: this string is interpolated into a Dockerfile RUN line that
|
||||
# runs as root. Everything shell-active is excluded rather than quoted around
|
||||
# - no $, backtick, quote, backslash, whitespace, or ;|&<>() - so there is no
|
||||
# quoting to get wrong later. https only: the digest already provides
|
||||
# integrity, but there is no reason to also accept a plaintext fetch.
|
||||
DEJIMA_MAX_TARBALL_URL=512
|
||||
valid_tarball_spec() {
|
||||
local spec="$1" rest name sha url
|
||||
[[ "$spec" == tarball:* ]] || return 1
|
||||
rest="${spec#tarball:}"
|
||||
|
||||
name="${rest%%@*}"
|
||||
[[ "$name" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]] || return 1
|
||||
# Becomes a directory under /opt/dejima; a name that walks out of it, or
|
||||
# that is a relative-path token, is not a name.
|
||||
case "$name" in .|..) return 1 ;; esac
|
||||
|
||||
rest="${rest#"$name"}"
|
||||
[[ "$rest" == @* ]] || return 1
|
||||
rest="${rest#@}"
|
||||
|
||||
sha="${rest%%:*}"
|
||||
[[ "$sha" =~ ^[a-f0-9]{64}$ ]] || return 1
|
||||
|
||||
url="${rest#"$sha"}"
|
||||
[[ "$url" == :* ]] || return 1
|
||||
url="${url#:}"
|
||||
[ ${#url} -le "$DEJIMA_MAX_TARBALL_URL" ] || return 1
|
||||
[[ "$url" =~ ^https://[A-Za-z0-9._~/?=+%-]+$ ]] || return 1
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Value validation
|
||||
#
|
||||
|
||||
+57
-55
@@ -30,59 +30,6 @@ source "$(dirname "${BASH_SOURCE[0]}")/dejima-provider-lib.sh"
|
||||
|
||||
DEJIMA_MAX_DECLARED=200
|
||||
|
||||
# <ecosystem>:<name>. The character class is deliberately narrow: an
|
||||
# approved spec gets interpolated into a generated Dockerfile RUN line, so
|
||||
# anything shell-active here would be a root-context injection at build.
|
||||
# The name must start with an alphanumeric - without that, a leading dash
|
||||
# (e.g. "--allow-unauthenticated") validates as a name and lands in the
|
||||
# generated RUN line as an option instead, quietly weakening apt/pip/npm/
|
||||
# cargo's own signature/lockfile checks.
|
||||
valid_dep_spec() {
|
||||
[[ "$1" =~ ^(apt|npm|pip|cargo):[A-Za-z0-9][A-Za-z0-9._+-]*$ ]] && return 0
|
||||
valid_tarball_spec "$1"
|
||||
}
|
||||
|
||||
# tarball:<name>@<sha256>:<url> - a release archive unpacked into the image,
|
||||
# for a toolchain no package ecosystem ships in a usable version.
|
||||
#
|
||||
# The digest is mandatory and is what makes this an approval at all. Without
|
||||
# it the operator would be approving a *name* whose contents the other end can
|
||||
# change afterwards, which is not a decision anyone can make once; with it,
|
||||
# what was approved is a specific archive and nothing else can take its place.
|
||||
# The generated build verifies it before unpacking, not after.
|
||||
#
|
||||
# The character classes are narrower than the URL grammar allows, for the same
|
||||
# reason as above: this string is interpolated into a Dockerfile RUN line that
|
||||
# runs as root. Everything shell-active is excluded rather than quoted around
|
||||
# - no $, backtick, quote, backslash, whitespace, or ;|&<>() - so there is no
|
||||
# quoting to get wrong later. https only: the digest already provides
|
||||
# integrity, but there is no reason to also accept a plaintext fetch.
|
||||
DEJIMA_MAX_TARBALL_URL=512
|
||||
valid_tarball_spec() {
|
||||
local spec="$1" rest name sha url
|
||||
[[ "$spec" == tarball:* ]] || return 1
|
||||
rest="${spec#tarball:}"
|
||||
|
||||
name="${rest%%@*}"
|
||||
[[ "$name" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]] || return 1
|
||||
# Becomes a directory under /opt/dejima; a name that walks out of it, or
|
||||
# that is a relative-path token, is not a name.
|
||||
case "$name" in .|..) return 1 ;; esac
|
||||
|
||||
rest="${rest#"$name"}"
|
||||
[[ "$rest" == @* ]] || return 1
|
||||
rest="${rest#@}"
|
||||
|
||||
sha="${rest%%:*}"
|
||||
[[ "$sha" =~ ^[a-f0-9]{64}$ ]] || return 1
|
||||
|
||||
url="${rest#"$sha"}"
|
||||
[[ "$url" == :* ]] || return 1
|
||||
url="${url#:}"
|
||||
[ ${#url} -le "$DEJIMA_MAX_TARBALL_URL" ] || return 1
|
||||
[[ "$url" =~ ^https://[A-Za-z0-9._~/?=+%-]+$ ]] || return 1
|
||||
}
|
||||
|
||||
# read_declared <file> <validator-fn>
|
||||
# Prints "ok <entry>" / "rejected <entry>" per entry, plus a final
|
||||
# "truncated <total>" when the cap kicked in. Invalid entries are reported
|
||||
@@ -487,12 +434,35 @@ _emit_tarball_layers() {
|
||||
# the latter would need apt/npm/PyPI/crates.io reachable as root before
|
||||
# init-firewall.sh locks egress down.
|
||||
generate_derived_dockerfile() {
|
||||
local base="$1" store="$2" entries apt npm pip cargo spec name
|
||||
local -a tarballs=()
|
||||
local base="$1" store="$2" entries
|
||||
entries="$(approved_entries "$store" dependencies)" \
|
||||
|| { echo "refusing to build from a corrupt dependency list in $store" >&2; return 1; }
|
||||
generate_spec_dockerfile "$base" "$entries"
|
||||
}
|
||||
|
||||
# The same, from specs handed in directly rather than read from an approval
|
||||
# store. What installs the selected harness is one of these, and it comes from
|
||||
# that harness's manifest rather than from an operator's approval - so it has
|
||||
# no store to be read out of, and every other property of the layer should be
|
||||
# identical to a dependency's.
|
||||
#
|
||||
# $2 is a newline-separated list. Callers are responsible for having validated
|
||||
# each one (valid_dep_spec); nothing below re-checks, for the same reason
|
||||
# approved_entries is the only way into the function above.
|
||||
#
|
||||
# $3, $4 and $5 are for a harness layer and empty for a dependency one:
|
||||
# directories to create under the sandbox user's home, NAME=VALUE pairs to
|
||||
# set, and files to create empty at 0600, all space-separated. They exist because the base image must name no harness -
|
||||
# what a harness keeps state in, and what it wants in its environment, belong
|
||||
# to that harness's own layer. Validated by the manifest types `homepath` and
|
||||
# `envpair` before they reach here, which is what makes them safe to
|
||||
# interpolate unquoted.
|
||||
generate_spec_dockerfile() {
|
||||
local base="$1" entries="$2" dirs="${3:-}" envs="${4:-}" files="${5:-}"
|
||||
local apt npm pip cargo spec name d e f
|
||||
|
||||
apt=""; npm=""; pip=""; cargo=""
|
||||
local -a tarballs=()
|
||||
while IFS= read -r spec; do
|
||||
[ -n "$spec" ] || continue
|
||||
name="${spec#*:}"
|
||||
@@ -507,6 +477,38 @@ generate_derived_dockerfile() {
|
||||
|
||||
printf 'FROM %s\n' "$base"
|
||||
printf 'USER root\n'
|
||||
if [ -n "$dirs" ]; then
|
||||
# 0700 and owned by the sandbox user, because these are the parents of
|
||||
# mounts that land inside them: Docker creates a missing parent
|
||||
# root-owned, and the session then cannot write the state it keeps
|
||||
# there. Created here rather than in the base image so an image
|
||||
# carries one harness's directories and no other's.
|
||||
printf 'RUN mkdir -p'
|
||||
for d in $dirs; do printf ' "${SANDBOX_HOME}"/%s' "$d"; done
|
||||
printf ' \\\n && chown -R ${SANDBOX_USER}:${SANDBOX_USER}'
|
||||
for d in $dirs; do printf ' "${SANDBOX_HOME}"/%s' "$d"; done
|
||||
printf ' \\\n && chmod 700'
|
||||
for d in $dirs; do printf ' "${SANDBOX_HOME}"/%s' "$d"; done
|
||||
printf '\n'
|
||||
fi
|
||||
if [ -n "$files" ]; then
|
||||
# Empty, 0600, owned by the session's user. A harness that reads a
|
||||
# credential from a fixed path needs SOMETHING there: an absent file
|
||||
# and an empty one are not reliably the same to it, and betting that
|
||||
# they are is how a session comes up unauthenticated in a way that
|
||||
# reads as a dejima fault.
|
||||
#
|
||||
# Created in the image rather than by the entrypoint, so a mount at
|
||||
# the same path simply shadows it - which is what happens on every
|
||||
# transport that mounts one. The entrypoint did it at runtime, which
|
||||
# meant every container created one harness's path whether or not that
|
||||
# harness was the one selected, and failed outright on an image whose
|
||||
# harness had never declared the directory it lives in.
|
||||
printf 'RUN install -o ${SANDBOX_USER} -g ${SANDBOX_USER} -m 600 /dev/null'
|
||||
for f in $files; do printf ' "${SANDBOX_HOME}"/%s' "$f"; done
|
||||
printf '\n'
|
||||
fi
|
||||
for e in $envs; do printf 'ENV %s\n' "$e"; done
|
||||
[ -n "$apt" ] && printf 'RUN apt-get update && apt-get install -y --no-install-recommends%s \\\n && apt-get clean && rm -rf /var/lib/apt/lists/*\n' "$apt"
|
||||
[ -n "$pip" ] && printf 'RUN pip install --break-system-packages%s\n' "$pip"
|
||||
_emit_tarball_layers "${tarballs[@]+"${tarballs[@]}"}"
|
||||
|
||||
@@ -161,6 +161,10 @@ briefing ?relpath
|
||||
EOF
|
||||
;;
|
||||
harness) cat <<'EOF'
|
||||
install ?depspec
|
||||
image_dirs ?list:homepath
|
||||
image_files ?list:homepath
|
||||
image_env ?list:envpair
|
||||
command identifier claude
|
||||
command_path ?path
|
||||
api_domains ?list:hostname
|
||||
@@ -244,6 +248,34 @@ _provider_valid_value() {
|
||||
# traversal - a manifest may only name a file it ships.
|
||||
[[ "$value" =~ ^[A-Za-z0-9][A-Za-z0-9._/-]*$ ]] && [[ "$value" != *".."* ]]
|
||||
;;
|
||||
homepath)
|
||||
# A directory the harness needs inside the sandbox user's home,
|
||||
# created in that harness's image layer. Relative to $HOME and
|
||||
# nothing else: a leading slash would put it anywhere on the
|
||||
# filesystem and `..` would walk out of the home it is scoped to,
|
||||
# and both reach a `mkdir -p` running as root at build time.
|
||||
# Unlike `relpath` a leading dot is allowed, because the
|
||||
# directories a harness keeps state in are dotfiles.
|
||||
case "$value" in
|
||||
/*|.|./*|*/..|*/../*|..|../*) return 1 ;;
|
||||
*/) return 1 ;;
|
||||
esac
|
||||
[[ "$value" =~ ^[A-Za-z0-9._][A-Za-z0-9._/-]*$ ]]
|
||||
;;
|
||||
envpair)
|
||||
# NAME=VALUE, becoming an ENV line in that harness's image layer.
|
||||
# The value is deliberately narrow rather than quoted around: no
|
||||
# whitespace and nothing shell-active, so there is no quoting to
|
||||
# get wrong in a generated Dockerfile.
|
||||
[[ "$value" =~ ^[A-Z][A-Z0-9_]*=[A-Za-z0-9._:/+-]+$ ]]
|
||||
;;
|
||||
depspec)
|
||||
# The same grammar an operator's approved dependency goes through,
|
||||
# checked by the same function - see dejima-config-lib.sh. What a
|
||||
# harness declares here is interpolated into a Dockerfile RUN line
|
||||
# that runs at build time, so nothing shell-active may survive it.
|
||||
valid_dep_spec "$value"
|
||||
;;
|
||||
export)
|
||||
# NAME=key or NAME=@key. The "@" form means "the contents of the
|
||||
# file at that config key" and is how a token reaches the container
|
||||
|
||||
+33
-39
@@ -15,21 +15,19 @@ FROM node:22
|
||||
ARG TZ
|
||||
ENV TZ="$TZ"
|
||||
|
||||
ARG CLAUDE_CODE_VERSION=latest
|
||||
|
||||
# Install basic development tools, iptables/ipset (for the egress firewall),
|
||||
# openssh-client (for git-over-ssh and agent forwarding), and the toolchain a
|
||||
# typical nvim plugin set (telescope/treesitter/mason) expects, since
|
||||
# SANDBOX_NVIM_CONFIG can point a session's editor at one.
|
||||
#
|
||||
# Deliberately NOT installing bubblewrap/socat: those exist to let Claude
|
||||
# Code's own internal per-command sandboxing (~/.claude/settings.json's
|
||||
# "sandbox" block) and /remote-control run, but that inner sandbox is
|
||||
# redundant on top of this container - it can't even function inside it
|
||||
# anyway (bwrap can't create a namespace here, and fixing that would need
|
||||
# privilege escalation approaching --privileged, undoing this container's
|
||||
# own hardening). bin/dejima mounts a settings.json copy with
|
||||
# sandbox.enabled forced to false, so there's nothing here to satisfy.
|
||||
# Deliberately NOT installing bubblewrap/socat: they exist to let a harness's
|
||||
# own internal per-command sandboxing run, and that inner sandbox is redundant
|
||||
# on top of this container - it cannot even function inside it anyway (bwrap
|
||||
# cannot create a namespace here, and fixing that would need privilege
|
||||
# escalation approaching --privileged, undoing this container's own
|
||||
# hardening). Whichever harness has one, its plugin's harness_prepare_config
|
||||
# turns it off in the override layer, so there is nothing here to satisfy.
|
||||
# Claude Code's is the "sandbox" block in settings.json.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
less \
|
||||
git \
|
||||
@@ -63,12 +61,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
# Set `DEVCONTAINER` environment variable to help with orientation
|
||||
ENV DEVCONTAINER=true
|
||||
|
||||
# Disable non-essential network traffic (telemetry/error reporting) so the
|
||||
# firewall allowlist can stay small.
|
||||
ENV CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1
|
||||
|
||||
# Lets Claude Code (and the SessionStart hook in ~/.claude/hooks) know it's
|
||||
# running inside this container rather than directly on the host.
|
||||
# Lets the harness, and the SessionStart hook it runs, know it is inside this
|
||||
# container rather than directly on the host.
|
||||
ENV DEJIMA=1
|
||||
|
||||
# Dedicated unprivileged user for ulogd2 (see entrypoint.sh) - unlike
|
||||
@@ -131,12 +125,11 @@ RUN npx -y playwright@${PLAYWRIGHT_VERSION} install-deps chromium
|
||||
|
||||
# Replace the base image's built-in `node` user (uid/gid 1000,
|
||||
# $HOME=/home/node) with one matching the host user who runs
|
||||
# bin/dejima - so absolute paths under $HOME (dotfiles, and
|
||||
# ~/.claude/plugins/installed_plugins.json's own installPath bookkeeping,
|
||||
# which Claude Code resolves as a literal host-absolute string rather than
|
||||
# relative to whatever $HOME happens to be) resolve identically inside and
|
||||
# bin/dejima - so absolute paths under $HOME resolve identically inside and
|
||||
# outside the container, instead of needing a one-off dual-mount for every
|
||||
# individual thing that assumes the real host path. Defaults below
|
||||
# individual thing that assumes the real host path. Harnesses do assume it:
|
||||
# Claude Code's installed_plugins.json records an installPath it resolves as a
|
||||
# literal host-absolute string rather than relative to whatever $HOME is. Defaults below
|
||||
# reproduce today's node/1000/1000//home/node exactly, so a build that
|
||||
# never overrides them (bin/dejima always does; the test suite
|
||||
# never does) exercises this same rename path rather than a separate,
|
||||
@@ -172,21 +165,19 @@ RUN mkdir /commandhistory && \
|
||||
|
||||
# Create the ssh directory and set permissions. The project directory itself
|
||||
# is bind-mounted at runtime at its real host path (see docker-compose.yml),
|
||||
# not a fixed /workspace, so it can share ~/.claude project/session history
|
||||
# with the host.
|
||||
# not a fixed /workspace, so a harness keying its project or session history
|
||||
# on the working directory resolves to the same key inside and outside.
|
||||
#
|
||||
# ~/.claude itself is baked in here rather than bind-mounted (see the mount
|
||||
# inventory in docker-compose.yml): only enumerated paths *inside* it come
|
||||
# from the host, and Docker creates the parents of a mount point root-owned
|
||||
# if they don't already exist - which would leave the sandbox user unable to
|
||||
# write the caches and history it keeps directly in ~/.claude.
|
||||
# projects/ is in that list for the same reason one level down: it is no
|
||||
# longer mounted itself, only individual slug directories inside it, so
|
||||
# without this Docker creates it root-owned while setting those up and the
|
||||
# session cannot write a slug of its own.
|
||||
RUN mkdir -p $SANDBOX_HOME/.ssh $SANDBOX_HOME/.claude $SANDBOX_HOME/.claude/projects $SANDBOX_HOME/.claude/file-history && \
|
||||
chown -R $SANDBOX_USER:$SANDBOX_USER $SANDBOX_HOME/.ssh $SANDBOX_HOME/.claude && \
|
||||
chmod 700 $SANDBOX_HOME/.ssh $SANDBOX_HOME/.claude
|
||||
# Only .ssh here. The directories a harness keeps state in are that harness's,
|
||||
# declared as `image_dirs` in its manifest and created in its own image layer -
|
||||
# this image is shared by every harness and must name none of them. The reason
|
||||
# they have to exist at all is the same either way: only enumerated paths
|
||||
# inside them come from the host, and Docker creates the parents of a mount
|
||||
# point root-owned if they are not already there, which would leave the
|
||||
# sandbox user unable to write the state it keeps beside them.
|
||||
RUN mkdir -p $SANDBOX_HOME/.ssh && \
|
||||
chown $SANDBOX_USER:$SANDBOX_USER $SANDBOX_HOME/.ssh && \
|
||||
chmod 700 $SANDBOX_HOME/.ssh
|
||||
|
||||
# Pre-create the nvim data/state/cache dirs owned by the sandbox user,
|
||||
# since they're mounted as named volumes in docker-compose.yml: Docker
|
||||
@@ -238,9 +229,12 @@ RUN wget --tries=3 --timeout=30 --waitretry=5 --retry-connrefused \
|
||||
-x && \
|
||||
rm /tmp/zsh-in-docker.sh
|
||||
|
||||
# Install Claude Code
|
||||
RUN npm install -g @anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}
|
||||
|
||||
# No harness here. The image is shared by every project on the host, and
|
||||
# baking one harness into it means every sandbox carries it whether or not it
|
||||
# is the one selected - and a second harness could only ever be added to the
|
||||
# same shared image, never chosen between. The selected harness installs into
|
||||
# a layer on top instead, from the `install` spec in its own manifest, the
|
||||
# same way an approved dependency does (bin/dejima, build_harness_layer).
|
||||
# Install the tree-sitter CLI, which nvim-treesitter (plugins/treesitter.lua)
|
||||
# needs to install/update parsers. Pinned below latest: newer tree-sitter-cli
|
||||
# prebuilds require glibc >=2.39, which this (Debian 12 bookworm) image
|
||||
@@ -301,5 +295,5 @@ RUN chmod +x /usr/local/bin/dns-proxy.sh /usr/local/bin/init-firewall.sh /usr/lo
|
||||
# Entrypoint runs as root (no USER node here) so it can set up the firewall
|
||||
# directly, with no sudo/setuid escalation path available to node at any
|
||||
# point afterward. It drops to node itself via gosu before execing
|
||||
# SANDBOX_CMD (default `claude` - see entrypoint.sh).
|
||||
# SANDBOX_CMD, which bin/dejima sets from the selected harness's manifest.
|
||||
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
|
||||
|
||||
@@ -10,7 +10,10 @@
|
||||
name: dejima
|
||||
|
||||
services:
|
||||
claude:
|
||||
# Named for what it is, not for one harness: the image this builds and
|
||||
# runs is whichever harness [core] harness selected, and bin/dejima refers
|
||||
# to this service by name for both.
|
||||
sandbox:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: docker/Dockerfile
|
||||
@@ -71,7 +74,11 @@ services:
|
||||
# What actually runs as the sandbox user - the selected harness's own
|
||||
# command unless [core] command overrides it. See entrypoint.sh and
|
||||
# bin/dejima's SANDBOX_NVIM_CONFIG.
|
||||
SANDBOX_CMD: ${SANDBOX_CMD:-claude}
|
||||
# No default: bin/dejima sets this from the selected harness's
|
||||
# manifest, and a literal here would name one harness in a file shared
|
||||
# by all of them. entrypoint.sh refuses an empty one rather than
|
||||
# guessing.
|
||||
SANDBOX_CMD: ${SANDBOX_CMD:-}
|
||||
# The selected harness's manifest, resolved host-side: which command
|
||||
# the wrapper shadows, the real binary behind it, the flags that make
|
||||
# the session unattended, and how this harness is told to read dejima's
|
||||
@@ -175,7 +182,7 @@ services:
|
||||
# launch (it inspects the symlinks and appends the exact mounts
|
||||
# needed), not listed statically here, since this repo can't assume
|
||||
# what else might be installed on a given host.
|
||||
- claude-code-bashhistory:/commandhistory
|
||||
- dejima-bashhistory:/commandhistory
|
||||
- nvim-data:${HOME}/.local/share/nvim
|
||||
- nvim-state:${HOME}/.local/state/nvim
|
||||
- nvim-cache:${HOME}/.cache/nvim
|
||||
@@ -205,7 +212,9 @@ services:
|
||||
tty: true
|
||||
|
||||
volumes:
|
||||
claude-code-bashhistory:
|
||||
# Shell history, shared by every harness - it is the container's shell that
|
||||
# writes it, not the agent.
|
||||
dejima-bashhistory:
|
||||
# Named explicitly so bin/dejima can give each project its own set. These
|
||||
# persist across containers by design (a plugin install should not have to
|
||||
# happen every launch), which without the split meant one project's session
|
||||
|
||||
+35
-25
@@ -178,29 +178,15 @@ if [ -d /etc/dejima-harness ]; then
|
||||
done < <(find /etc/dejima-harness -type f -print0)
|
||||
fi
|
||||
|
||||
# ~/.claude is not mounted whole, so the real OAuth credential has no path
|
||||
# into the container (#50, docs/security.md). What lands at this path instead
|
||||
# depends on the transport: the tls one needs a well-formed fake credential,
|
||||
# because Claude Code authenticates from this file rather than from an
|
||||
# ANTHROPIC_AUTH_TOKEN, and bin/dejima mounts one.
|
||||
# Nothing is mounted for the loopback transport, so give Claude Code an empty
|
||||
# file rather than betting that an absent credentials file behaves the same
|
||||
# as an empty one. Either way this is
|
||||
# skipped when something is already there - which also covers
|
||||
# [credentials] proxy = off mounting the real credential in deliberately.
|
||||
if [ ! -e "$SANDBOX_HOME/.claude/.credentials.json" ]; then
|
||||
install -o "$SANDBOX_USER" -g "$SANDBOX_USER" -m 600 /dev/null "$SANDBOX_HOME/.claude/.credentials.json"
|
||||
fi
|
||||
|
||||
# TCP front door for the host-side credential proxy (see bin/dejima and
|
||||
# bin/dejima-anthropic-proxy): the proxy listens on a bind-mounted unix
|
||||
# socket, and anthropic-proxy-forwarder.js pipes bytes into it. Only started
|
||||
# when this session actually has a proxy socket.
|
||||
#
|
||||
# In the default tls transport, api.anthropic.com is pointed at loopback here
|
||||
# and the forwarder terminates TLS for it, so Claude Code runs with no
|
||||
# ANTHROPIC_BASE_URL and no placeholder token - it is indistinguishable from a
|
||||
# stock install, which is the whole point (see docs/security.md). The hosts
|
||||
# In the default tls transport the harness's proxied_api_domain is pointed at
|
||||
# loopback here and the forwarder terminates TLS for it, so the harness runs
|
||||
# with no base-URL override and no placeholder token - it is indistinguishable
|
||||
# from a stock install, which is the whole point (see docs/security.md). The hosts
|
||||
# entry deliberately lives inside this guard rather than in compose's
|
||||
# extra_hosts: under [credentials] proxy = off nothing listens on :443, and
|
||||
# an unconditional entry would black-hole the real API on exactly the path
|
||||
@@ -225,6 +211,19 @@ case "$SANDBOX_ANTHROPIC_TRANSPORT" in
|
||||
esac
|
||||
if [ -n "${SANDBOX_ANTHROPIC_PROXY_SOCKET:-}" ]; then
|
||||
forwarder_port=18300
|
||||
# Which name the terminator answers for is the selected harness's, from
|
||||
# `proxied_api_domain` in its manifest - this image is shared by every
|
||||
# harness and must name none of them. Empty here means a harness declared
|
||||
# a credential proxy it gave no endpoint for: the terminator would come up
|
||||
# answering for nothing, the session would reach the real API directly,
|
||||
# and the credential the proxy exists to withhold would be the one in use.
|
||||
proxied_domain="${SANDBOX_HARNESS_PROXIED_DOMAIN:-}"
|
||||
if [ -z "$proxied_domain" ]; then
|
||||
echo "FATAL: a credential proxy socket is mounted, but the selected harness" >&2
|
||||
echo "declares no proxied_api_domain for the terminator to stand in front of." >&2
|
||||
echo "Not starting the session - traffic would reach the API unintercepted." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "$SANDBOX_ANTHROPIC_TRANSPORT" = "tls" ]; then
|
||||
forwarder_port=443
|
||||
# Generated here, in the container, rather than on the host: the key
|
||||
@@ -243,15 +242,15 @@ if [ -n "${SANDBOX_ANTHROPIC_PROXY_SOCKET:-}" ]; then
|
||||
# block below; openssl's success-path chatter is just progress dots.
|
||||
if ! openssl_err=$(openssl req -x509 -newkey rsa:2048 -nodes -days 365 \
|
||||
-keyout "$tls_dir/key.pem" -out "$tls_dir/cert.pem" \
|
||||
-subj "/CN=api.anthropic.com" \
|
||||
-addext "subjectAltName=DNS:api.anthropic.com" 2>&1); then
|
||||
echo "FATAL: openssl failed to generate the TLS terminator cert for api.anthropic.com." >&2
|
||||
-subj "/CN=$proxied_domain" \
|
||||
-addext "subjectAltName=DNS:$proxied_domain" 2>&1); then
|
||||
echo "FATAL: openssl failed to generate the TLS terminator cert for $proxied_domain." >&2
|
||||
echo "$openssl_err" >&2
|
||||
echo "Not starting the session - API traffic would have no valid cert to trust." >&2
|
||||
echo "On the HOST, set [credentials] transport = loopback in" >&2
|
||||
# The tilde is prose an operator reads, not a path this expands.
|
||||
# shellcheck disable=SC2088
|
||||
echo "~/.config/dejima/config, or [credentials] proxy = off to launch without" >&2
|
||||
# shellcheck disable=SC2088
|
||||
echo "~/.config/dejima/config, or [credentials] proxy = off to launch without" >&2
|
||||
echo "the proxy (the real OAuth credential is then mounted into the" >&2
|
||||
echo "container) - then relaunch dejima." >&2
|
||||
exit 1
|
||||
@@ -264,7 +263,7 @@ if [ -n "${SANDBOX_ANTHROPIC_PROXY_SOCKET:-}" ]; then
|
||||
# /etc/hosts wins over DNS in getaddrinfo, so this bypasses the DNS
|
||||
# proxy entirely - which is why fixed-domains.sh leaves the name off
|
||||
# the allowlist whenever there is a proxy socket.
|
||||
echo "127.0.0.1 api.anthropic.com" >> /etc/hosts
|
||||
echo "127.0.0.1 $proxied_domain" >> /etc/hosts
|
||||
# The trust anchor for the cert above. NODE_EXTRA_CA_CERTS rather than
|
||||
# the system store: it needs no update-ca-certificates run, and it is
|
||||
# the single most ordinary variable a corporate environment sets.
|
||||
@@ -350,4 +349,15 @@ fi
|
||||
# own command unless [core] command overrides it (e.g. to `nvim`) for an
|
||||
# editor-driven workflow; pair that with SANDBOX_NVIM_CONFIG (see bin/dejima)
|
||||
# to bring in a real config for it instead of running unconfigured.
|
||||
exec gosu "$SANDBOX_USER" ${SANDBOX_CMD:-claude} "$@"
|
||||
# No default. Naming one harness here would make this image that harness's,
|
||||
# and would name a binary an image built for a different one does not contain
|
||||
# - an exec failure reading as a broken sandbox rather than as the container
|
||||
# having been started without the thing that selects a harness.
|
||||
if [ -z "${SANDBOX_CMD:-}" ]; then
|
||||
echo "entrypoint: SANDBOX_CMD is unset, so there is no command to run." >&2
|
||||
echo "It is set from the selected harness's manifest by bin/dejima, which" >&2
|
||||
echo "is how a session starts. To poke at this image by hand, pass a" >&2
|
||||
echo "command: docker run --entrypoint sh ..." >&2
|
||||
exit 1
|
||||
fi
|
||||
exec gosu "$SANDBOX_USER" $SANDBOX_CMD "$@"
|
||||
|
||||
@@ -190,6 +190,8 @@ also patch a running session immediately, but only when it is run bare or
|
||||
with `--container` - approving here (`--project`/`--global`, with no
|
||||
`--container`) never touches one; see
|
||||
[Adding a domain to a running session](security.md#adding-a-domain-to-a-running-session).
|
||||
A domain that resolves to a private address has no live patch at all - an
|
||||
internally-routed host can only be approved and picked up at the next launch.
|
||||
Approved dependencies are baked into a derived image built `FROM` the shared
|
||||
base - first `dejima:<username>-global`, if the global store has any
|
||||
dependency approved, then `dejima:<username>-<slug>` on top of that,
|
||||
|
||||
+36
-12
@@ -10,7 +10,8 @@ run them.
|
||||
|
||||
| File | Purpose |
|
||||
| --- | --- |
|
||||
| `docker/Dockerfile` | Base image, dev tools, nvim, Claude Code + wrapper, `gitea-pr`/`gitea-issue` |
|
||||
| `docker/Dockerfile` | Base image: dev tools, nvim, Playwright, the sandbox user. Carries **no harness** - the selected one installs into a layer above it, from its manifest's `install` |
|
||||
| `docker/install-forge-bins.sh` | Installs each forge's CLIs into the image from its manifest's `install` list, and fails the build on a file that is shipped but not declared, or declared but not shipped |
|
||||
| `docker/fixed-domains.sh` | Shared domain list sourced by both `init-firewall.sh` and `dns-proxy.sh` |
|
||||
| `docker/dns-proxy.sh` | Starts the DNS-allowlisting `dnsmasq`, repoints `resolv.conf` at it |
|
||||
| `docker/init-firewall.sh` | IPv4/IPv6 egress allowlist firewall, run at container start |
|
||||
@@ -28,6 +29,7 @@ run them.
|
||||
| `bin/dejima-attach` | Lists live sessions and attaches to one; the shared core behind both remote front doors |
|
||||
| `bin/dejima-audit` | Advisory exit-time diff of host-persistence surfaces (git config, `.claude/`, etc.). `dejima-audit show` / `dejima-audit ack` default to the enclosing repo |
|
||||
| `bin/dejima-dep` | Review and approve project dependencies declared in `.dejima/dependencies` |
|
||||
| `bin/dejima-config-lib.sh` | The config file's schema, parser and value validators, plus the dependency-spec grammar. The base layer: it sources nothing, so everything else can source it. Not on PATH |
|
||||
| `bin/dejima-provider-lib.sh` | Parses forge/workflow/harness manifests and composes the session briefing; not on PATH: see [docs/providers.md](providers.md) |
|
||||
| `forges/`, `workflows/`, `harnesses/` | Provider directories for the three axes selected by `[core] forge`/`workflow`/`harness` (data, never sourced or executed); see [docs/providers.md](providers.md) |
|
||||
| `forges/gitea/bin/` | The gitea forge's own CLIs, baked into the image so the sandbox can act on them: `gitea-pr` (create a PR, read its comments, merge it), `gitea-issue` (list, view, create, comment on, close and reopen issues) and `gitea-actions` (read CI runs and job logs) |
|
||||
@@ -38,11 +40,13 @@ run them.
|
||||
| `bin/dejima-epitaph` | Host-side: keeps the summary a session leaves when its context is compacted away, in a store nothing mounts. Run from `bin/dejima`'s exit trap |
|
||||
| `bin/dejima-harness-lib.sh` | Loads the selected harness's plugin and provides the `emit_*` functions it declares through; the guarded-store refusal runs in `emit_mount`. Not on PATH |
|
||||
| `bin/harness/<name>.sh` | One per supported harness: what dejima does for that harness that no manifest value could state. Trusted computing base, sourced by `bin/dejima` |
|
||||
| `bin/dejima-color-lib.sh` | The colour and terminal-detection helpers the CLIs share. Not on PATH |
|
||||
| `bin/dejima-slug-lib.sh` | The `~/.claude/projects` slug scheme and which slugs belong to a workspace; sourced by `bin/dejima` (what to mount) and `bin/dejima-epitaph` (whose transcripts to keep), so the two cannot disagree. Not on PATH |
|
||||
| `bin/dejima-ttyd` | Launches `ttyd` on loopback in front of `dejima-attach` |
|
||||
| `systemd/*.service` | User units for `dejima-ttyd` and `dejima-notify-watch`; ship disabled, installed by hand |
|
||||
| `docs/providers.md` | The manifest contract for the forge/workflow/harness axes, key by key, with the shipped manifests as worked examples |
|
||||
| `docs/ci.md` | What push access grants a session on your CI host, and how to bound it - runner hardening, and an egress allowlist read from `main` |
|
||||
| `docs/threat-model.md` | What is assumed hostile, and where each layer's compromise stops. Carries the blast-radius table `docs/security.md` also shows; a static test keeps the two identical |
|
||||
| `docs/nginx-dejima.conf.example` | Reverse-proxy template for the web front door; deliberately not loadable as written |
|
||||
|
||||
`bin/dejima` is the entry point: it resolves the project path,
|
||||
@@ -167,12 +171,14 @@ obscurely partway through; the file says why each is needed.
|
||||
|
||||
## Adding a new harness
|
||||
|
||||
`[core] harness` picks a directory under `harnesses/`, and a harness manifest
|
||||
declares four things. Everything else dejima does for Claude Code is written
|
||||
against Claude Code, and this is the inventory of it: what a second harness
|
||||
has to supply, and where dejima has to stop assuming. Taken from a pass over
|
||||
the tree, so each row names the file to change rather than describing a
|
||||
sketch.
|
||||
`[core] harness` picks a directory under `harnesses/`, and that harness's
|
||||
manifest declares eleven keys (see [providers.md](providers.md)). The
|
||||
inventory below is everything dejima does that is shaped by *which* harness is
|
||||
selected: which parts are the manifest's, which are the plugin's, and
|
||||
which are still written against Claude Code by name. Taken from a pass over
|
||||
the tree, so each row names the file rather than describing a sketch - and a
|
||||
row that says "in the plugin" is one a second harness supplies rather than one
|
||||
it has to argue about.
|
||||
|
||||
### What a harness must be able to do
|
||||
|
||||
@@ -311,7 +317,7 @@ names. Use it; do not spell a `[harness.<name>]` section out at a call site.
|
||||
declaration only one harness can ever be read through - a second harness
|
||||
gets selected, gets validated, and is then handed the first one's paths with
|
||||
nothing erroring anywhere. Three call sites did exactly that, in
|
||||
`bin/dejima-audit` (twice) and `bin/dejima-epitaph`. A static test now holds
|
||||
`bin/dejima-audit` (twice) and `bin/dejima-epitaph`. A static test holds
|
||||
the rule.
|
||||
|
||||
It returns empty when no harness is selected. `bin/dejima` refuses to start
|
||||
@@ -322,12 +328,16 @@ harness-specific path resolving to nothing is the honest answer.
|
||||
|
||||
| Area | What is Claude Code specific | Where |
|
||||
| --- | --- | --- |
|
||||
| Install | `npm install -g @anthropic-ai/claude-code`, and a pinned `CLAUDE_CODE_VERSION` build arg | `docker/Dockerfile` |
|
||||
| Permission bypass | the flag itself, now `no_confirm_args` in the manifest; the wrapper is generic | `harnesses/<name>/harness.conf` |
|
||||
| Install | which package installs the harness is the manifest's `install`, a dependency spec. That the image carries the selected harness **and no other**, that it is a layer keyed on the harness's name rather than part of the shared base, and that the spec is validated before it reaches a `RUN` line, are core's | `harnesses/<name>/harness.conf`, `bin/dejima` |
|
||||
| Permission bypass | the flag itself, `no_confirm_args` in the manifest; the wrapper is generic | `harnesses/<name>/harness.conf` |
|
||||
| Settings layering | that overrides arrive as an extra layer rather than a mount over the operator's file (#47) is core, as is the throwaway file itself; what the layer SAYS is the plugin's, written by `harness_prepare_config` - a settings schema is a parser's grammar, so a manifest cannot carry it. The flag that carries them is `settings_args` in the manifest | `bin/harness/claude-code.sh`, `harnesses/<name>/harness.conf` |
|
||||
| Config preconditions | which config files must exist before staging, and which unusable one refuses the launch (Claude Code ignores an unparseable `settings.json` wholesale, with only a warning). Declared and enforced by the plugin, in the same hook | `bin/harness/claude-code.sh` |
|
||||
| Telemetry | `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1` | `docker/Dockerfile` |
|
||||
| Egress allowlist | `platform.claude.com` always, `api.anthropic.com` only when no credential proxy is in front of it. Now `api_domains` and `proxied_api_domain` in the manifest | `harnesses/<name>/harness.conf` |
|
||||
| State directories | which directories must exist in the sandbox user's home, 0700 and owned by them, so Docker does not create a mount's missing parent root-owned. The manifest's `image_dirs`, created in that harness's own image layer | `harnesses/<name>/harness.conf` |
|
||||
| Seeded files | that a harness reading a credential from a fixed path needs something at it, since an absent file and an empty one are not reliably the same to it. The manifest's `image_files`, created empty and 0600 in that harness's layer, where a mounted credential shadows it - rather than by `entrypoint.sh`, which runs in every image including the base one alone | `harnesses/<name>/harness.conf` |
|
||||
| Image environment | anything the harness wants set for every session of its own - for Claude Code, `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1`. The manifest's `image_env`, in the same layer | `harnesses/<name>/harness.conf` |
|
||||
| Command | there is no default anywhere shared. `SANDBOX_CMD` comes from the manifest's `command` via `bin/dejima`, and `entrypoint.sh` refuses an empty one rather than naming a harness a given image may not contain | `harnesses/<name>/harness.conf`, `docker/entrypoint.sh` |
|
||||
| Proxied endpoint | which name the in-container TLS terminator answers for, and what `/etc/hosts` pins to loopback. The manifest's `proxied_api_domain`; a credential proxy with no domain declared is refused rather than started answering for nothing | `harnesses/<name>/harness.conf`, `docker/entrypoint.sh` |
|
||||
| Egress allowlist | `platform.claude.com` always, `api.anthropic.com` only when no credential proxy is in front of it. `api_domains` and `proxied_api_domain` in the manifest | `harnesses/<name>/harness.conf` |
|
||||
| Credentials | the whole proxy exists because Claude Code authenticates from `~/.claude/.credentials.json` rather than an env var; the entrypoint seeds a well-formed fake so it has something to read | `bin/dejima-anthropic-proxy`, `docker/anthropic-proxy-forwarder.js`, `docker/entrypoint.sh` |
|
||||
| Hook protocol | the event payload arrives on stdin; `SessionStart` returns `hookSpecificOutput.hookEventName`/`additionalContext` as JSON, while a `PreToolUse` hook blocks by exiting 2 with its reason on stderr | `hooks/*.sh` |
|
||||
| Hook registration | four hooks written into `~/.claude/settings.json` in Claude Code's schema, one `SessionStart` with a `startup\|resume\|clear\|compact` matcher and three `PreToolUse` matching `Bash`. In the plugin, gated per declared guard | `bin/harness/claude-code.sh` |
|
||||
@@ -364,6 +374,20 @@ whole today.
|
||||
|
||||
### Testing a new harness
|
||||
|
||||
**Every supported harness is a dependency of this project**, declared in
|
||||
`.dejima/dependencies`, so a session working on dejima can run the one it is
|
||||
integrating rather than reason about it. A harness dejima claims to support
|
||||
and cannot exercise here is one whose guards are asserted rather than
|
||||
observed - and an untested guard is worse than a declared-absent one,
|
||||
because absent is visible to the operator and broken is not.
|
||||
|
||||
`claude-code` is no exception to that: it is declared by its own manifest and
|
||||
built into its own layer like any other. The base image carries nothing of any
|
||||
harness's - not its package, not the directories it keeps state in, not its
|
||||
environment, and not its name as a default command. What is left on the
|
||||
harness axis is the credential proxy, which is Claude Code's shape throughout
|
||||
and is the R5 row above.
|
||||
|
||||
`providers.bats` covers the manifest contract itself and is where a new
|
||||
`harness.conf` gets its parsing and validation tests. The suites that assert
|
||||
on Claude Code paths by name - `config.bats`, `audit.bats`, `static.bats`,
|
||||
|
||||
@@ -70,6 +70,9 @@ value never does:
|
||||
| --- | --- |
|
||||
| `command` | Space-separated words (a bare name or a multi-word one like `gh pr`) - lands in prose an agent runs, so nothing shell-active is legal. |
|
||||
| `relpath` | A path relative to the provider's own directory, no leading `/` and no `..` - a manifest may only name a file it ships. |
|
||||
| `homepath` | A path under the sandbox user's home: no leading `/`, no `..`, and unlike `relpath` a leading dot is allowed, since the directories a harness keeps state in are dotfiles. |
|
||||
| `envpair` | `NAME=VALUE`, where NAME is upper-case and VALUE carries no whitespace and nothing shell-active - it becomes an `ENV` line in a generated Dockerfile. |
|
||||
| `depspec` | A dependency spec: `apt:`/`npm:`/`pip:`/`cargo:` plus a package name, or `tarball:<name>@<sha256>:<url>`. Validated by `valid_dep_spec`, the same function an operator's approved dependency goes through, because it ends up in the same place - a Dockerfile `RUN` line built on the host. |
|
||||
| `export` | `NAME=key` or `NAME=@key` for the `env_export` forge key - see [Forge](#forge) below. |
|
||||
| `template` | Prose that may contain `{{placeholder}}` markers; the placeholder names are checked against the briefing whitelist at composition time, not here. |
|
||||
| `keyspec` | One element of a `keys` list - see [The `keys` field](#the-keys-field-a-providers-own-config-schema) below. |
|
||||
@@ -212,6 +215,10 @@ A workflow's `briefing.md` is plain Markdown plus two marker forms that
|
||||
|
||||
| Key | Type | Default | Meaning |
|
||||
| --- | --- | --- | --- |
|
||||
| `install` | `?depspec` | (empty) | What installs this harness into the image, as a dependency spec (`npm:`, `apt:`, `pip:`, `cargo:` or `tarball:`) - the same grammar and the same validator an operator's approved dependency goes through. The base image carries no harness: the selected one installs into a layer of its own on top of it, keyed on its name, so switching `[core] harness` switches images rather than accumulating every harness into the one every project shares. Empty means "already present", which is only true of something the base image installs for another reason. |
|
||||
| `image_dirs` | `?list:homepath` | (empty) | Directories this harness needs inside the sandbox user's home, created 0700 and owned by them in its own image layer. They exist because only enumerated paths *inside* them are mounted from the host, and Docker creates a mount's missing parent root-owned - which would leave the session unable to write the state it keeps beside them. Relative to `$HOME`; a leading `/` or a `..` is refused, since both reach a `mkdir -p` running as root at build time. |
|
||||
| `image_files` | `?list:homepath` | (empty) | Files this harness needs to exist in the sandbox user's home, created empty at 0600 in its own image layer. For a harness that reads a credential from a fixed path: an absent file and an empty one are not reliably the same to it. Created in the image rather than at container start, so whatever the credential transport mounts at that path simply shadows it. |
|
||||
| `image_env` | `?list:envpair` | (empty) | `NAME=VALUE` pairs set in this harness's image layer, for anything it wants in the environment of every session of its own. Deliberately narrow - no whitespace, nothing shell-active - because each becomes an `ENV` line in a generated Dockerfile. |
|
||||
| `command` | `identifier` | `claude` | The container's main process for this harness, and the name the wrapper shadows on `PATH`. `[core] command` defaults to it. |
|
||||
| `command_path` | `?path` | (empty) | Where the real binary lives, behind the shadow. |
|
||||
| `api_domains` | `?list:hostname` | (empty) | Hostnames this harness's own machinery must reach - its update host, its telemetry endpoint. They join the *fixed* allowlist rather than the extra one, which `dns-proxy.sh` exempts from stop-dns-rebind and `init-firewall.sh` treats as non-fatal when it fails to resolve. |
|
||||
|
||||
@@ -179,6 +179,14 @@ It is that if this container is ever compromised at the kernel level,
|
||||
everything reachable from this host account should be treated as compromised
|
||||
with it, which is a very different thing to plan around than an ordinary bug.
|
||||
|
||||
What "everything reachable from this host account" amounts to is not fixed,
|
||||
and it is the one part of this that can be narrowed from outside. Bounding
|
||||
what an escape spreads to - the machine's own network position, and what a
|
||||
compromise there can reach beyond it - is
|
||||
[dejima-devbox-hardener](https://gitbox.branderson.io/dejima/dejima-devbox-hardener)'s
|
||||
whole subject. dejima cannot do it, because dejima runs inside the layer that
|
||||
would have to be bounded.
|
||||
|
||||
## dejima's security says nothing about your software's.
|
||||
|
||||
These are different axes, not different degrees of the same thing. dejima's
|
||||
|
||||
+170
-65
@@ -16,6 +16,29 @@ person thought to check, and no outside audit or adversarial testing has
|
||||
happened. Read what follows as claims awaiting scrutiny rather than claims
|
||||
that have survived it. See [SECURITY.md](../SECURITY.md).
|
||||
|
||||
## Blast radius by layer
|
||||
|
||||
Where each layer's compromise stops, with and without the thing arranged
|
||||
against it. The middle column is what the layer reaches if nothing is; the
|
||||
last is what remains once something is.
|
||||
|
||||
| Layer | Reached by | Radius without | Bounded by | Radius with |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Agent, not yet hostile | ordinary operation | the workspace, its history, every mounted path, and the forge, at full speed | the session briefing and `.dejima.md`; the `PreToolUse` hooks | obvious destruction and out-of-remote pushes fail fast, and merging stays a human decision |
|
||||
| Container | ordinary operation | the operator's home directory, every credential in it, and the open internet | dejima | the workspace, allowlisted domains, and the forge token's scope |
|
||||
| Devbox VM, unprivileged | container escape | everything the account can read, and unrestricted egress to the internet and the LAN | `dejima-devbox-hardener` | the same VM, with LAN reach cut to what is declared; internet egress stays open, deliberately |
|
||||
| Devbox VM, root | escape plus local escalation, or misconfiguration | the whole VM, the same egress, and the model API credential | `dejima-devbox-hardener`, enforced outside the guest | the whole VM and no further: root cannot reach the rule set that holds the LAN boundary |
|
||||
| CI, confined job | a session opening a pull request | code execution on the runner, its network position, and the token the forge injects into every job | `dejima-ci-hardener`, confined tier | the allowlist, and nothing else on the LAN |
|
||||
| CI, confined job escaped | an escape from a confined job | root on the runner VM | the hypervisor firewall and a scheduled rollback | the privileged tier's position: the allowlist is gone, the LAN is not, and the loss expires |
|
||||
| CI, privileged job | `runs-on:` asking for it | root on the runner VM, by design and without any exploit | the same two controls, both external to the VM | the VM and its network position, until the next rebuild |
|
||||
|
||||
Only the first row's control is advice: hooks and context tell an agent what
|
||||
not to do, and one that has stopped cooperating ignores them. Only the first
|
||||
two rows are dejima's own. The rest is why there are two sister repositories,
|
||||
and this table is the short form of
|
||||
**→ [threat-model.md](threat-model.md)**, which has the assumptions, the
|
||||
adversaries, and what none of this claims.
|
||||
|
||||
## CI: push access is code execution elsewhere
|
||||
|
||||
The controls in this document bound what a session can reach *directly*. They
|
||||
@@ -75,7 +98,7 @@ Three specific consequences:
|
||||
requests as you, so requiring one approval means nobody can give it.
|
||||
- **The forge's audit trail cannot attribute.** "You pushed this" and "a
|
||||
session pushed this" are the same recorded event. This is the same shape as
|
||||
the exit-time audit's time-window caveat above: a record of what happened,
|
||||
the exit-time audit's time-window caveat below: a record of what happened,
|
||||
not of who caused it.
|
||||
- **It compounds the SSH agent's reach.** The agent already signs for any repo
|
||||
your key can reach (below); the identity is what makes the resulting action
|
||||
@@ -184,6 +207,8 @@ or `skip-worktree` - is a CRITICAL finding. It is still a change a session
|
||||
can make; it is one the audit reports. See
|
||||
[docs/providers.md](providers.md) for the full manifest contract.
|
||||
|
||||
## The mounted project is fully writable
|
||||
|
||||
- `--dangerously-skip-permissions` removes your chance to review tool calls
|
||||
before they run. Only point this at trusted repositories.
|
||||
- The mounted project itself has **no write protection**. `deny-dangerous-
|
||||
@@ -215,7 +240,7 @@ can make; it is one the audit reports. See
|
||||
overlaid (they're config, not fixed paths a static mount can cover) and
|
||||
remain fully writable; the exit-time audit below reports changes to
|
||||
either, but only after the session ends - it doesn't stop them.
|
||||
### The exit-time audit
|
||||
## The exit-time audit
|
||||
|
||||
- `bin/dejima-audit` snapshots host-persistence surfaces before each
|
||||
run and diffs them after the container exits: `.git/config` (and worktree
|
||||
@@ -380,6 +405,8 @@ can make; it is one the audit reports. See
|
||||
so the marker appearing is itself worth a look. The encoded form is what
|
||||
`ack` records, so what you acknowledge is the file on disk rather than a
|
||||
readable approximation of its name.
|
||||
## One project's session cannot reach another's
|
||||
|
||||
- `~/.claude/projects` (session transcripts and per-project memory) is
|
||||
**not** mounted as a tree. A session gets the workspace's own slug
|
||||
directory plus one for each live git worktree nested under it,
|
||||
@@ -440,28 +467,18 @@ can make; it is one the audit reports. See
|
||||
they are extracted from *is* session-writable, so a summary records what was
|
||||
there rather than vouching for it - and nothing loads one back into a later
|
||||
session, deliberately: that would be the channel #104 closed, rebuilt.
|
||||
- Allowing egress to a domain at all creates a channel for exfiltrating data
|
||||
through *that service's own request surface* (query strings, lookups,
|
||||
etc.), not just to a literal attacker-controlled destination. Domain-level
|
||||
allowlisting closes off reaching somewhere new; it can't inspect or
|
||||
restrict what gets sent to somewhere already trusted for normal operation
|
||||
(installing packages, calling the model API). That's a structural limit of
|
||||
allowlist-only firewalls in general, not something specific to this setup,
|
||||
and nothing here addresses it.
|
||||
- This is container-level isolation (Linux namespaces plus a small
|
||||
capability set), not VM-level isolation - no gVisor/Firecracker or
|
||||
similar. It shares the host kernel, so a container-escape vulnerability
|
||||
would defeat every other control described here at once.
|
||||
- Most of what this document asserts is re-checked by the `test/` suite on
|
||||
every change, and `runtime.bats` asserts against a real running container
|
||||
rather than a mock: IPv6 blocked, a non-allowlisted domain refused and a
|
||||
non-allowlisted destination unreachable, `node` unable to re-run
|
||||
`init-firewall.sh`, no working `sudo`. Coverage is not completeness: the
|
||||
reasoning here is judgement, nothing tests it, and a property nobody wrote
|
||||
a test for can regress quietly. A missing `ipset` dedup flag broke firewall
|
||||
startup twice before anyone caught it; there is a test for that now, which
|
||||
is the pattern to expect - each of these exists because something was got
|
||||
wrong first.
|
||||
## The model API credential never enters the container
|
||||
|
||||
**This assumes the host has one.** dejima authenticates nothing: the proxy
|
||||
holds a credential the harness wrote on the host, so a machine where the
|
||||
harness has never signed in has nothing for it to hold. The proxy then does
|
||||
not start, no credential is mounted, and the session comes up unable to
|
||||
reach the model - which reads as a dejima fault rather than a missing
|
||||
prerequisite. Sign in on the host first; see the README's
|
||||
[Sign in on the host first](../README.md#sign-in-on-the-host-first).
|
||||
Refreshing an expired credential is the same: on the host, not from inside a
|
||||
session, where the credential deliberately never goes.
|
||||
|
||||
- The Anthropic OAuth credential (`~/.claude/.credentials.json`) never
|
||||
enters the container. It isn't mounted: `~/.claude` is not shared as a
|
||||
whole (see the mount inventory below), so there is no path from
|
||||
@@ -541,6 +558,8 @@ can make; it is one the audit reports. See
|
||||
real credential into the container. Rung one is a supported, tested mode,
|
||||
not an improvised workaround; if you reach the third, treat it as a
|
||||
temporary state rather than a new baseline.
|
||||
## What the container sees of `~/.claude`
|
||||
|
||||
- `~/.claude` is **not** shared with the container as a whole. Its contents
|
||||
are enumerated in `docker-compose.yml`, in three classes, and anything not
|
||||
listed is container-local: writable during the session and gone with the
|
||||
@@ -549,8 +568,8 @@ can make; it is one the audit reports. See
|
||||
`tasks/`, `plans/`, `sessions/`, plus the entries of `projects/` and
|
||||
`file-history/` that belong to this workspace. All directories, which is
|
||||
what makes them safe (see the detachment note below). The two trees are
|
||||
not mounted whole: see the `projects/` note further down, and the
|
||||
`file-history/` note below it.
|
||||
not mounted whole: see
|
||||
[One project's session cannot reach another's](#one-projects-session-cannot-reach-anothers).
|
||||
- **Read-only**: `plugins/`, `hooks/`, `skills/`, `commands/`, `agents/`,
|
||||
`output-styles/`, plus the symlink-target directories `dejima`
|
||||
discovers at launch. Those targets are discovered rather than
|
||||
@@ -632,40 +651,6 @@ can make; it is one the audit reports. See
|
||||
nothing to persist. This also means multiple concurrent sandbox sessions
|
||||
never contend over the same file - each gets its own independent copy.
|
||||
|
||||
### Throwaway ~/.claude.json: what doesn't carry over
|
||||
|
||||
Because nothing writes back, anything a *sandboxed* session would
|
||||
normally have caused Claude Code to record into `~/.claude.json` is lost
|
||||
when that session ends, instead of showing up in a later host-side
|
||||
`claude` run. Concretely, as of the fields this file holds today:
|
||||
|
||||
- **Per-project trust/approval state** - `hasTrustDialogAccepted`,
|
||||
`allowedTools`, `enabledMcpjsonServers`/`disabledMcpjsonServers` - for
|
||||
any project directory opened only inside the sandbox. Opening that same
|
||||
project on the host later re-prompts for whatever it would have
|
||||
prompted for outside the sandbox too. Largely moot today since the
|
||||
sandbox always runs `--dangerously-skip-permissions`, but the trust
|
||||
dialog is a separate gate from tool-permission prompts.
|
||||
- **`mcpServers` additions or edits** - the point of this fix, not an
|
||||
incidental side effect. Nothing added or changed here during a
|
||||
sandboxed session ever reaches the real file.
|
||||
- **Session/usage telemetry** - `lastCost`, `lastDuration`,
|
||||
`lastTotalInputTokens`/`lastTotalOutputTokens`, `lastModelUsage`,
|
||||
`lastSessionId`, `mcpContextUris`, and similar per-project fields.
|
||||
Sandbox activity won't show up in host-side usage totals or in
|
||||
`claude --resume`'s "last session" metadata for that project.
|
||||
- **Global counters and caches** - `numStartups`, `firstStartTime`,
|
||||
`cachedGrowthBookFeatures`, `changelogLastFetched`,
|
||||
`announcementImpressions`, `tipsHistory`, `feedbackSurveyState`, and
|
||||
similar top-level bookkeeping. Purely cosmetic - these get refetched or
|
||||
recomputed as needed; sandbox runs just don't contribute to the host's
|
||||
copies of them.
|
||||
- **`oauthAccount`/`userID`/`machineID`** are read fresh from the real
|
||||
file at the start of every sandbox launch, so authentication carries in
|
||||
normally each time; anything that changes about them *during* a
|
||||
sandboxed session (e.g. a token refresh) doesn't carry back out. Worth
|
||||
re-checking if a future Claude Code version starts relying on that for
|
||||
normal operation.
|
||||
- DNS resolution itself is allowlisted (see "DNS is allowlisted too" below),
|
||||
not just the IP connections made afterward, closing off exfiltration via
|
||||
encoding data into query names to an attacker-controlled domain - verified
|
||||
@@ -755,7 +740,42 @@ can make; it is one the audit reports. See
|
||||
marked `assume-unchanged` or `skip-worktree`, is live code that runs on the
|
||||
next launch and appears nowhere in `git status`.
|
||||
|
||||
### Declaration files are session-writable
|
||||
### Throwaway ~/.claude.json: what doesn't carry over
|
||||
|
||||
Because nothing writes back, anything a *sandboxed* session would
|
||||
normally have caused Claude Code to record into `~/.claude.json` is lost
|
||||
when that session ends, instead of showing up in a later host-side
|
||||
`claude` run. Concretely, as of the fields this file holds today:
|
||||
|
||||
- **Per-project trust/approval state** - `hasTrustDialogAccepted`,
|
||||
`allowedTools`, `enabledMcpjsonServers`/`disabledMcpjsonServers` - for
|
||||
any project directory opened only inside the sandbox. Opening that same
|
||||
project on the host later re-prompts for whatever it would have
|
||||
prompted for outside the sandbox too. Largely moot today since the
|
||||
sandbox always runs `--dangerously-skip-permissions`, but the trust
|
||||
dialog is a separate gate from tool-permission prompts.
|
||||
- **`mcpServers` additions or edits** - the point of this fix, not an
|
||||
incidental side effect. Nothing added or changed here during a
|
||||
sandboxed session ever reaches the real file.
|
||||
- **Session/usage telemetry** - `lastCost`, `lastDuration`,
|
||||
`lastTotalInputTokens`/`lastTotalOutputTokens`, `lastModelUsage`,
|
||||
`lastSessionId`, `mcpContextUris`, and similar per-project fields.
|
||||
Sandbox activity won't show up in host-side usage totals or in
|
||||
`claude --resume`'s "last session" metadata for that project.
|
||||
- **Global counters and caches** - `numStartups`, `firstStartTime`,
|
||||
`cachedGrowthBookFeatures`, `changelogLastFetched`,
|
||||
`announcementImpressions`, `tipsHistory`, `feedbackSurveyState`, and
|
||||
similar top-level bookkeeping. Purely cosmetic - these get refetched or
|
||||
recomputed as needed; sandbox runs just don't contribute to the host's
|
||||
copies of them.
|
||||
- **`oauthAccount`/`userID`/`machineID`** are read fresh from the real
|
||||
file at the start of every sandbox launch, so authentication carries in
|
||||
normally each time; anything that changes about them *during* a
|
||||
sandboxed session (e.g. a token refresh) doesn't carry back out. Worth
|
||||
re-checking if a future Claude Code version starts relying on that for
|
||||
normal operation.
|
||||
|
||||
## Declaration files are session-writable
|
||||
|
||||
A project's `.dejima/` declaration files sit inside the project mount, which
|
||||
is bind-mounted read-write. A compromised session can write them, and that is
|
||||
@@ -890,15 +910,69 @@ so that restriction is unaffected.
|
||||
One gap remains: allowing an *internal* hostname (one that resolves to an
|
||||
RFC1918 address) mid-session doesn't work. dnsmasq's `stop-dns-rebind`
|
||||
rejects private answers for any domain not exempted at startup, and that
|
||||
exemption list can only be extended by restarting dnsmasq. Internal hosts
|
||||
named before the container starts (`[forge.gitea] ssh_host`, `api`,
|
||||
`[logging] loki_url`, and any globally or per-project approved domain) are
|
||||
already exempted and unaffected by this.
|
||||
exemption list can only be extended by restarting dnsmasq. `dejima-allow`
|
||||
re-asks the container's upstream resolver when a name fails to resolve, so it
|
||||
names this case and the relaunch that fixes it rather than reporting the host
|
||||
as unresolvable and sending you to look at a DNS server that is working.
|
||||
Internal hosts named before the container starts (`[forge.gitea] ssh_host`,
|
||||
`api`, `[logging] loki_url`, and any globally or per-project approved domain)
|
||||
are already exempted and unaffected by this.
|
||||
|
||||
The sandbox-context.sh `SessionStart` hook tells the agent to ask for this
|
||||
by name when a task is blocked on a single missing domain, rather than
|
||||
stopping the whole session over it.
|
||||
|
||||
### The firewall bounds the container, not the harness
|
||||
|
||||
Everything above is about what the *container* can connect to. A harness may
|
||||
also offer the session a tool whose network request does not come from the
|
||||
container at all, and the allowlist has no view of those.
|
||||
|
||||
Claude Code's `WebSearch` is the instance to reason from. Observed in one
|
||||
session, in this container:
|
||||
|
||||
```
|
||||
$ curl https://aider.chat/docs/config/options.html
|
||||
curl: (6) Could not resolve host: aider.chat
|
||||
```
|
||||
|
||||
and in the same session, `WebSearch` returned that page's content. The
|
||||
retrieval did not use the container's network, because the container has no
|
||||
route to that host. The only channel into a session that is not the
|
||||
container's own network is the model API connection - which dejima already
|
||||
routes through the host-side credential proxy - so the search ran on the API
|
||||
side and its result arrived in the model's response.
|
||||
|
||||
That cuts both ways, and neither is closed here:
|
||||
|
||||
- **Inward.** Text from any site on the internet can enter the session without
|
||||
the DNS allowlist seeing the name. The allowlist is described elsewhere in
|
||||
this document as bounding where a prompt injection could fetch its next
|
||||
instruction from; that is true of anything the container fetches, and not
|
||||
true of this.
|
||||
- **Outward.** A search query is session-controlled text sent to a third
|
||||
party. It is low bandwidth next to an HTTP body, but it is a channel out
|
||||
that no `.dejima/allowed-domains` line was ever reviewed for.
|
||||
|
||||
What this is not is unmonitored. The traffic rides the model API connection,
|
||||
which in the default configuration is exactly the connection dejima terminates
|
||||
in-container and forwards through its own host-side proxy - so it crosses a
|
||||
process the operator runs. dejima does not inspect what crosses it, and this
|
||||
document does not claim it does.
|
||||
|
||||
The lever an operator has is the harness's own, not dejima's: Claude Code
|
||||
takes a `permissions.deny` entry for a tool name, and dejima already writes a
|
||||
settings overlay it could go in. It is not written there by default, because
|
||||
the tool is useful and the exposure is a search query rather than a file.
|
||||
|
||||
**This is not a Claude Code quirk, it is a harness question.** Any harness may
|
||||
ship a tool whose fetch happens somewhere other than the container - Cline's
|
||||
tool set includes `fetch_web_content`, and which side executes it is a thing
|
||||
its integration has to establish rather than assume. A harness's own network
|
||||
reach belongs in its worked example in
|
||||
[development.md](development.md#adding-a-new-harness) alongside the rest of
|
||||
what it can and cannot do.
|
||||
|
||||
## Network request logging
|
||||
|
||||
If `[logging] loki_url` is set (see Prerequisites in the README), every DNS query and every new
|
||||
@@ -1105,3 +1179,34 @@ that file exists) points git's github.com HTTPS credential helper at `gh`,
|
||||
which only ever holds whatever token that file provides: a write-scoped
|
||||
token there would make HTTPS push possible independent of
|
||||
`--allow-github-push`, so don't use one.
|
||||
|
||||
|
||||
## What none of this closes
|
||||
|
||||
- Allowing egress to a domain at all creates a channel for exfiltrating data
|
||||
through *that service's own request surface* (query strings, lookups,
|
||||
etc.), not just to a literal attacker-controlled destination. Domain-level
|
||||
allowlisting closes off reaching somewhere new; it can't inspect or
|
||||
restrict what gets sent to somewhere already trusted for normal operation
|
||||
(installing packages, calling the model API). That's a structural limit of
|
||||
allowlist-only firewalls in general, not something specific to this setup,
|
||||
and nothing here addresses it.
|
||||
- This is container-level isolation (Linux namespaces plus a small
|
||||
capability set), not VM-level isolation - no gVisor/Firecracker or
|
||||
similar. It shares the host kernel, so a container-escape vulnerability
|
||||
would defeat every other control described here at once. What such an
|
||||
escape then reaches is a layer dejima cannot bound, because dejima runs
|
||||
inside it:
|
||||
[dejima-devbox-hardener](https://gitbox.branderson.io/dejima/dejima-devbox-hardener)
|
||||
is the project for that, and [threat-model.md](threat-model.md) has the
|
||||
row-by-row account of where each layer's compromise stops.
|
||||
- Most of what this document asserts is re-checked by the `test/` suite on
|
||||
every change, and `runtime.bats` asserts against a real running container
|
||||
rather than a mock: IPv6 blocked, a non-allowlisted domain refused and a
|
||||
non-allowlisted destination unreachable, `node` unable to re-run
|
||||
`init-firewall.sh`, no working `sudo`. Coverage is not completeness: the
|
||||
reasoning here is judgement, nothing tests it, and a property nobody wrote
|
||||
a test for can regress quietly. A missing `ipset` dedup flag broke firewall
|
||||
startup twice before anyone caught it; there is a test for that now, which
|
||||
is the pattern to expect - each of these exists because something was got
|
||||
wrong first.
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
# Threat model
|
||||
|
||||
[← dejima README](../README.md)
|
||||
|
||||
**And none of it has been reviewed by anyone else.** This is early
|
||||
development: the reasoning here is one person's, the tests assert what that
|
||||
person thought to check, and no outside audit or adversarial testing has
|
||||
happened. Read what follows as claims awaiting scrutiny rather than claims
|
||||
that have survived it. See [SECURITY.md](../SECURITY.md).
|
||||
|
||||
What dejima defends, what it does not, and where its boundary passes out of
|
||||
its hands. [security.md](security.md) is the control-by-control account; this
|
||||
is the shape of the thing those controls are arranged against.
|
||||
|
||||
## What this tool assumes
|
||||
|
||||
**The agent is hostile, or will be.** Not badly behaved: captured, mid-session,
|
||||
without announcing it. Every control here is placed as though the thing inside
|
||||
the container is already working against the operator, because a control that
|
||||
only holds while the agent cooperates is not a control.
|
||||
|
||||
**Nobody is watching.** Unsupervised operation is the design point rather than
|
||||
a degraded mode. A protection that depends on an operator reading output as it
|
||||
scrolls past does not count, which is why the exit-time audit is a report that
|
||||
survives to the next launch and not a warning printed once.
|
||||
|
||||
**The project is one you already trust.** dejima bounds what a compromised
|
||||
session reaches. It does not make it safe to point an agent at arbitrary
|
||||
adversarial input, and pointing it at a repository you would not run on your
|
||||
own machine is outside what this reasoning covers.
|
||||
|
||||
**The container is the boundary.** The hooks are not, the briefing is not, and
|
||||
`.dejima.md` is not. Those three shape what a cooperating agent does; the
|
||||
container is what holds when one stops cooperating.
|
||||
|
||||
## The adversary
|
||||
|
||||
Two of them, and conflating them is how a control ends up in the wrong place.
|
||||
|
||||
**An agent doing what it was asked, too literally.** No attacker anywhere in
|
||||
this. It force-pushes, it rewrites history to make a test pass, it merges its
|
||||
own pull request because that was the obvious next step. Common, costly, and
|
||||
the only adversary here that responds to being told not to.
|
||||
|
||||
**A prompt injection or a poisoned dependency.** Text in a web page, an issue
|
||||
body, a README or a dependency's own documentation, or code that runs during
|
||||
an ordinary build: an npm `postinstall`, a transitive update from yesterday.
|
||||
Neither was written for this machine. The injection waits for whichever agent
|
||||
reads it; the package targets whoever installs it. This one does not respond
|
||||
to instructions, because it arrives as instructions.
|
||||
|
||||
## Ranked by what is actually likely
|
||||
|
||||
1. **An agent destroying something nobody meant it to.** Needs no attacker,
|
||||
happens during ordinary use, and is the one every operator meets first.
|
||||
2. **A session reaching something it should not on the network.** It runs
|
||||
dependencies, build scripts and fetched content. None of that needs an
|
||||
exploit to attempt a connection.
|
||||
3. **Push access becoming code execution elsewhere.** A session opens a pull
|
||||
request, CI fires, and the runner executes that commit before anyone reads
|
||||
it. No operator action anywhere in the chain. See [ci.md](ci.md).
|
||||
4. **Cross-project context poisoning.** A session writes another project's
|
||||
agent memory or transcripts, and a later session there reads planted text
|
||||
as established background.
|
||||
5. **Container escape.** Real, and it recurs, but it needs an unpatched host
|
||||
or a working zero-day, and the two rows it moves between are both already
|
||||
in the table below.
|
||||
|
||||
The ordering is the point. Nothing above the last item requires an exploit.
|
||||
|
||||
## Blast radius by layer
|
||||
|
||||
Read the last two columns together: the middle one is what the layer reaches
|
||||
if nothing is arranged against it, the last is what remains once something is.
|
||||
|
||||
| Layer | Reached by | Radius without | Bounded by | Radius with |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Agent, not yet hostile | ordinary operation | the workspace, its history, every mounted path, and the forge, at full speed | the session briefing and `.dejima.md`; the `PreToolUse` hooks | obvious destruction and out-of-remote pushes fail fast, and merging stays a human decision |
|
||||
| Container | ordinary operation | the operator's home directory, every credential in it, and the open internet | dejima | the workspace, allowlisted domains, and the forge token's scope |
|
||||
| Devbox VM, unprivileged | container escape | everything the account can read, and unrestricted egress to the internet and the LAN | `dejima-devbox-hardener` | the same VM, with LAN reach cut to what is declared; internet egress stays open, deliberately |
|
||||
| Devbox VM, root | escape plus local escalation, or misconfiguration | the whole VM, the same egress, and the model API credential | `dejima-devbox-hardener`, enforced outside the guest | the whole VM and no further: root cannot reach the rule set that holds the LAN boundary |
|
||||
| CI, confined job | a session opening a pull request | code execution on the runner, its network position, and the token the forge injects into every job | `dejima-ci-hardener`, confined tier | the allowlist, and nothing else on the LAN |
|
||||
| CI, confined job escaped | an escape from a confined job | root on the runner VM | the hypervisor firewall and a scheduled rollback | the privileged tier's position: the allowlist is gone, the LAN is not, and the loss expires |
|
||||
| CI, privileged job | `runs-on:` asking for it | root on the runner VM, by design and without any exploit | the same two controls, both external to the VM | the VM and its network position, until the next rebuild |
|
||||
|
||||
Four things are worth reading twice.
|
||||
|
||||
**The first row is the only one whose control is advice.** Hooks and context
|
||||
tell an agent what not to do, and an agent that has stopped cooperating
|
||||
ignores them. They are there because the likeliest adversary is the one that
|
||||
listens, not because they would stop the one that does not.
|
||||
|
||||
**Two rows are reached with no exploit at all.** The container, because that
|
||||
is where every session already is, and the privileged CI job, because holding
|
||||
the Docker socket is the grant that tier exists to give. Neither is a failure
|
||||
state; both are ordinary operation, which is what makes the row below each of
|
||||
them worth arranging.
|
||||
|
||||
**Escape moves a layer, it does not leave the table.** A confined job that
|
||||
escapes arrives where a privileged job sits by design, and the two controls
|
||||
holding that position hold it identically however it was reached.
|
||||
|
||||
**The last three rows are bounded in time as well as in reach**, because the
|
||||
runner VM is rebuilt from a snapshot on a schedule. That is a property no
|
||||
in-guest control can offer, since root in the guest can flush any rule set it
|
||||
can see.
|
||||
|
||||
## Where dejima's boundary ends
|
||||
|
||||
dejima runs inside the machine it is protecting the rest of, which is the
|
||||
whole reason there are two other repositories. It cannot bound its own host's
|
||||
network position, and it cannot bound a runner it never touches.
|
||||
|
||||
**[dejima-devbox-hardener](https://gitbox.branderson.io/dejima/dejima-devbox-hardener)**
|
||||
covers the two VM rows: what an escape spreads to, rather than whether it can
|
||||
talk. Its
|
||||
[threat model](https://gitbox.branderson.io/dejima/dejima-devbox-hardener/src/branch/main/docs/threat-model.md#blast-radius-by-layer)
|
||||
is explicit that it does not narrow internet egress back down, and says why.
|
||||
|
||||
**[dejima-ci-hardener](https://gitbox.branderson.io/dejima/dejima-ci-hardener)**
|
||||
covers the three CI rows, on the premise that CI executes attacker-controlled
|
||||
code on every pull request deliberately, because that is what CI is for.
|
||||
Nothing there tries to stop the code running. Its
|
||||
[threat model](https://gitbox.branderson.io/dejima/dejima-ci-hardener/src/branch/main/docs/threat-model.md#blast-radius-by-layer)
|
||||
has the two tiers and where each one's boundary sits.
|
||||
|
||||
Neither is required to run dejima, and the container row holds without them.
|
||||
What they change is what the row below it costs.
|
||||
|
||||
## What this repository does not claim
|
||||
|
||||
- **That the egress allowlist bounds what reaches the model.** It bounds what
|
||||
the container can connect to. A harness tool executed outside the container
|
||||
- Claude Code's `WebSearch` is the one confirmed by observation - puts text
|
||||
from any site into the session without the allowlist seeing the name, and
|
||||
sends a session-controlled query out to a third party. It rides the model
|
||||
API connection, so it crosses dejima's own credential proxy rather than
|
||||
going somewhere unseen, but nothing inspects it. Which of a harness's tools
|
||||
fetch from the container and which do not is a per-harness question, and
|
||||
belongs in that harness's assessment.
|
||||
[security.md](security.md#the-firewall-bounds-the-container-not-the-harness)
|
||||
has the evidence.
|
||||
- **That escape is impossible.** It is a row in the table with controls of its
|
||||
own, not an outcome that has been designed away.
|
||||
- **That the hooks stop a hostile agent.** They stop an unlucky one.
|
||||
- **That a finding from the exit-time audit was caused by the session.** It
|
||||
reports that a surface changed while the session ran. A concurrent session
|
||||
or the operator's own editing lands in the same diff.
|
||||
- **That the allowlist bounds exfiltration once a session has push access.**
|
||||
A commit is an outbound channel to a host that is on the list by definition.
|
||||
See [ci.md](ci.md).
|
||||
- **That any of this has been reviewed.** Stated at the top, repeated here
|
||||
because it is the claim that qualifies all the others.
|
||||
@@ -33,9 +33,11 @@ function usage() {
|
||||
echo " gitea-actions jobs [RUN]"
|
||||
echo " gitea-actions logs [RUN] [--job NAME|INDEX] [--all] [--timestamps]"
|
||||
echo ""
|
||||
echo "RUN is the number in a run's web URL (.../actions/runs/52). Left out,"
|
||||
echo "it means the newest run for HEAD's commit - after a push, that is"
|
||||
echo "almost always the one being asked about."
|
||||
echo "RUN is either number a run has: the one 'runs' lists, or the one in"
|
||||
echo "its web URL (.../actions/runs/11387). They are different numbers on"
|
||||
echo "any instance that has run more than one workflow. Left out, RUN means"
|
||||
echo "the newest run for HEAD's commit - after a push, that is almost"
|
||||
echo "always the one being asked about."
|
||||
echo ""
|
||||
echo "'logs' prints the failed jobs of a run; --all prints every job, and"
|
||||
echo "--job picks one by name (a substring is enough) or by index."
|
||||
@@ -150,33 +152,55 @@ function fetch_tasks() {
|
||||
tasks_json="$json"
|
||||
}
|
||||
|
||||
# Sets `run_number` and `run_sha`. With no number, HEAD's commit: a run list
|
||||
# is not what someone who just pushed is asking for. Matched on head_sha
|
||||
# rather than on branch because a pull_request run reports no branch at all -
|
||||
# its ref is refs/pull/N/head.
|
||||
# Sets `run_number`, `run_web_id` and `run_sha`. With no number, HEAD's
|
||||
# commit: a run list is not what someone who just pushed is asking for.
|
||||
# Matched on head_sha rather than on branch because a pull_request run reports
|
||||
# no branch at all - its ref is refs/pull/N/head.
|
||||
#
|
||||
# A run has TWO numbers and they are not interchangeable. `run_number` is the
|
||||
# per-workflow counter this tool lists and the web UI shows (225). The URLs -
|
||||
# a commit status's target_url, and the log endpoint - carry a different,
|
||||
# repo-wide index (11387), which is the number in a run's web address and the
|
||||
# only one those paths answer to. They started out equal on a fresh instance
|
||||
# and drift apart for good the first time a second workflow runs, which is why
|
||||
# filtering statuses by run_number silently matched nothing here: every job of
|
||||
# every run was discarded, and `jobs` reported a run with no jobs at all.
|
||||
#
|
||||
# Both are accepted as $1, because this tool has printed one and its own usage
|
||||
# has described the other. run_number wins a tie: it is the smaller space, so
|
||||
# an argument that is both is far likelier to be the one the list just showed.
|
||||
function resolve_run() {
|
||||
local want="${1:-}" sha
|
||||
local want="${1:-}" sha entry
|
||||
fetch_tasks
|
||||
if [ -n "$want" ]; then
|
||||
run_sha=$(echo "$tasks_json" | jq -r --argjson n "$want" \
|
||||
'first(.workflow_runs[] | select(.run_number == $n) | .head_sha) // empty')
|
||||
if [ -z "$run_sha" ]; then
|
||||
echo "No run numbered $want in the last $TASK_SCAN job records (see 'gitea-actions runs')" >&2
|
||||
entry=$(echo "$tasks_json" | jq -c --arg w "$want" '
|
||||
[.workflow_runs[] | select((.run_number | tostring) == $w)] as $bynum
|
||||
| [.workflow_runs[] | select((.url | split("/") | last) == $w)] as $byweb
|
||||
| (($bynum + $byweb)[0]) // empty')
|
||||
if [ -z "$entry" ]; then
|
||||
echo "No run $want in the last $TASK_SCAN job records (see 'gitea-actions runs')." >&2
|
||||
echo "Both the number in the run list and the one in its web URL work." >&2
|
||||
exit 1
|
||||
fi
|
||||
run_number="$want"
|
||||
else
|
||||
sha=$(git rev-parse HEAD 2>/dev/null) || {
|
||||
echo "Not in a git repository, so there is no HEAD to look up - pass a run number" >&2
|
||||
exit 1
|
||||
}
|
||||
run_number=$(echo "$tasks_json" | jq -r --arg sha "$sha" \
|
||||
'first(.workflow_runs[] | select(.head_sha == $sha) | .run_number) // empty')
|
||||
if [ -z "$run_number" ]; then
|
||||
entry=$(echo "$tasks_json" | jq -c --arg sha "$sha" \
|
||||
'first(.workflow_runs[] | select(.head_sha == $sha)) // empty')
|
||||
if [ -z "$entry" ]; then
|
||||
echo "No run for HEAD (${sha:0:8}) in the last $TASK_SCAN job records - unpushed, or still queued?" >&2
|
||||
exit 1
|
||||
fi
|
||||
run_sha="$sha"
|
||||
fi
|
||||
run_number=$(echo "$entry" | jq -r '.run_number')
|
||||
run_sha=$(echo "$entry" | jq -r '.head_sha')
|
||||
run_web_id=$(echo "$entry" | jq -r '.url | split("/") | last')
|
||||
if [ -z "$run_web_id" ] || [ "$run_web_id" = "null" ]; then
|
||||
echo "Run $run_number reports no web URL, so there is no id to address its" >&2
|
||||
echo "statuses or logs by. This is a Gitea API shape this tool does not know." >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -198,7 +222,9 @@ function fetch_jobs() {
|
||||
# "all N jobs succeeded" to an agent that will act on it.
|
||||
api_all_pages "list jobs for run $run_number" \
|
||||
"${GITEA_API}/repos/${owner}/${repo}/commits/${run_sha}/statuses"
|
||||
jobs_json=$(echo "$json" | jq --arg rn "$run_number" '
|
||||
# $rn is the web id, not run_number: target_url carries the former. See
|
||||
# resolve_run for why they differ.
|
||||
jobs_json=$(echo "$json" | jq --arg rn "$run_web_id" '
|
||||
[.[] | select(.target_url | test("/actions/runs/" + $rn + "/jobs/[0-9]+$"))]
|
||||
| group_by(.context) | map(first)
|
||||
| map({
|
||||
@@ -220,7 +246,7 @@ function print_job_log() {
|
||||
local index="$1" name="$2" result="$3"
|
||||
echo "=== $name ($result) ==="
|
||||
api_or_die "fetch logs for job $name" \
|
||||
"${GITEA_WEB}/${owner}/${repo}/actions/runs/${run_number}/jobs/${index}/logs"
|
||||
"${GITEA_WEB}/${owner}/${repo}/actions/runs/${run_web_id}/jobs/${index}/logs"
|
||||
if [ "$KEEP_TIMESTAMPS" = 1 ]; then
|
||||
echo "$json"
|
||||
else
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
install = npm:@anthropic-ai/claude-code
|
||||
image_dirs = .claude .claude/projects .claude/file-history
|
||||
image_files = .claude/.credentials.json
|
||||
image_env = CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1
|
||||
command = claude
|
||||
command_path = /usr/local/share/npm-global/bin/claude
|
||||
api_domains = platform.claude.com
|
||||
|
||||
@@ -1728,6 +1728,41 @@ _epitaph_transcript() {
|
||||
[ "$status" -ne 0 ] || { echo "$output"; return 1; }
|
||||
}
|
||||
|
||||
# The epitaph store holds every earlier session's own account of what it
|
||||
# worked out, in a directory nothing mounts - that is the whole design. It
|
||||
# was not in the guarded list, so an operator could name it in [mounts]
|
||||
# readonly and hand a session exactly what the store exists to withhold. The
|
||||
# audit and baseline stores live beside it and were guarded; this one was
|
||||
# added later and was not.
|
||||
@test "[mounts] readonly is refused when it names the epitaph store" {
|
||||
_rw_setup
|
||||
workspace="$(mktemp -d)"
|
||||
epi="$(mktemp -d)"
|
||||
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only' \
|
||||
'[epitaph]' "dir = $epi" \
|
||||
'[mounts]' "readonly = $epi"
|
||||
_rw_launch "$workspace"
|
||||
[ "$status" -ne 0 ] || { echo "launched anyway:"; echo "$output"; return 1; }
|
||||
[[ "$output" == *"Refusing [mounts] readonly entry"* ]] || { echo "$output"; return 1; }
|
||||
rm -rf "$workspace" "$epi"
|
||||
}
|
||||
|
||||
# Writable is the worse direction: a session could edit what an earlier one
|
||||
# recorded, or plant an epitaph for the operator to read as an earlier
|
||||
# session's own account.
|
||||
@test "[mounts] read_write is refused when it names the epitaph store" {
|
||||
_rw_setup
|
||||
workspace="$(mktemp -d)"
|
||||
epi="$(mktemp -d)"
|
||||
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only' \
|
||||
'[epitaph]' "dir = $epi" \
|
||||
'[mounts]' "read_write = $epi"
|
||||
_rw_launch "$workspace"
|
||||
[ "$status" -ne 0 ] || { echo "launched anyway:"; echo "$output"; return 1; }
|
||||
[[ "$output" == *"Refusing [mounts] read_write entry"* ]] || { echo "$output"; return 1; }
|
||||
rm -rf "$workspace" "$epi"
|
||||
}
|
||||
|
||||
# --- harness plugins ------------------------------------------------------
|
||||
|
||||
@test "a harness with a manifest but no plugin is refused" {
|
||||
@@ -1937,3 +1972,106 @@ _epitaph_transcript() {
|
||||
[[ "$output" != *"not in force this session"* ]] || { echo "$output"; return 1; }
|
||||
rm -rf "$workspace"
|
||||
}
|
||||
|
||||
# --- the harness layer ----------------------------------------------------
|
||||
|
||||
@test "the selected harness is built into a layer of its own, and the session runs on it" {
|
||||
# The base image carries no harness. What the session actually runs has to
|
||||
# be the layer that installed the selected one - a launch that built the
|
||||
# layer and then started the base image would be a session with no
|
||||
# harness, failing as an exec error inside the container.
|
||||
_rw_setup
|
||||
workspace="$(mktemp -d)"
|
||||
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only'
|
||||
# SANDBOX_IMAGE is what `docker compose run` is given, and it arrives in
|
||||
# the environment rather than in argv - so the stub records it. Asserting
|
||||
# on the `docker build -t` line instead would pass a launch that built the
|
||||
# layer and then started the base image, which is the mistake worth
|
||||
# catching: the session comes up without its harness.
|
||||
STUB_DIR="$(mktemp -d)"
|
||||
cat > "$STUB_DIR/docker" <<'STUB'
|
||||
#!/bin/sh
|
||||
for a in "$@"; do printf '%s\n' "$a" >> "$SANDBOX_TEST_DOCKER_LOG"; done
|
||||
[ "$1" != compose ] || printf '%s\n' "${SANDBOX_IMAGE:-<unset>}" >> "$SANDBOX_TEST_IMAGE_LOG"
|
||||
exit 0
|
||||
STUB
|
||||
chmod +x "$STUB_DIR/docker"
|
||||
docker_log="$(mktemp)"; image_log="$(mktemp)"
|
||||
HOME="$RW_HOME" PATH="$STUB_DIR:$PATH" SSH_AUTH_SOCK= \
|
||||
SANDBOX_TEST_DOCKER_LOG="$docker_log" SANDBOX_TEST_IMAGE_LOG="$image_log" \
|
||||
run "$REPO_ROOT/bin/dejima" "$workspace"
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
[[ "$output" == *"harness-claude-code"* ]] \
|
||||
|| { echo "no harness layer was built:"; echo "$output"; return 1; }
|
||||
grep -qxF "dejima:$(id -un)-harness-claude-code" "$docker_log" \
|
||||
|| { echo "the harness layer was not tagged as expected:"; cat "$docker_log"; return 1; }
|
||||
# The LAST compose invocation is the run; the first builds the base.
|
||||
local ran
|
||||
ran="$(tail -1 "$image_log")"
|
||||
[ "$ran" = "dejima:$(id -un)-harness-claude-code" ] \
|
||||
|| { echo "the session ran on '$ran', not the harness layer"; cat "$image_log"; return 1; }
|
||||
rm -rf "$workspace" "$RW_HOME" "$docker_log" "$image_log"
|
||||
}
|
||||
|
||||
@test "a harness install spec that is not a dependency spec refuses the launch" {
|
||||
# Manifest data reaching a Dockerfile RUN line built on the host, and the
|
||||
# provider directories are session-writable when dejima sandboxes its own
|
||||
# checkout - so this is refused at launch rather than trusted for having
|
||||
# come from a file dejima ships.
|
||||
_rw_setup
|
||||
workspace="$(mktemp -d)"
|
||||
local root
|
||||
root="$(mktemp -d)"
|
||||
mkdir -p "$root/harnesses/evil" "$root/forges" "$root/workflows"
|
||||
cp -r "$REPO_ROOT/forges/." "$root/forges/"
|
||||
cp -r "$REPO_ROOT/workflows/." "$root/workflows/"
|
||||
printf 'command = evil\ninstall = npm:x$(id>/tmp/pwned)\nno_confirm_args = -y\n' \
|
||||
> "$root/harnesses/evil/harness.conf"
|
||||
write_config '[core]' "ssh_key = $RW_KEY" 'harness = evil' 'forge = none' 'workflow = local-only'
|
||||
DEJIMA_PROVIDER_ROOT="$root" _rw_launch "$workspace"
|
||||
[ "$status" -ne 0 ] || { echo "launched with an unvalidated install spec"; return 1; }
|
||||
[ ! -e /tmp/pwned ]
|
||||
rm -rf "$workspace" "$RW_HOME" "$root" "${docker_log:-}"
|
||||
}
|
||||
|
||||
@test "the harness layer creates the harness's own state directories and environment" {
|
||||
# These were in the base image, imposed on every image dejima builds. They
|
||||
# are the selected harness's now, and have to actually reach its layer -
|
||||
# a manifest key nothing consumes would leave the directories to Docker,
|
||||
# which creates a mount's missing parent root-owned and leaves the session
|
||||
# unable to write beside it.
|
||||
_rw_setup
|
||||
workspace="$(mktemp -d)"
|
||||
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only'
|
||||
STUB_DIR="$(mktemp -d)"
|
||||
dockerfile_log="$(mktemp)"
|
||||
cat > "$STUB_DIR/docker" <<'STUB'
|
||||
#!/bin/bash
|
||||
if [ "$1" = build ]; then
|
||||
nextf=0
|
||||
for a in "$@"; do
|
||||
[ "$nextf" = 1 ] && { cat "$a" >> "$SANDBOX_TEST_DOCKERFILE_LOG"; nextf=0; }
|
||||
[ "$a" = -f ] && nextf=1
|
||||
done
|
||||
fi
|
||||
exit 0
|
||||
STUB
|
||||
chmod +x "$STUB_DIR/docker"
|
||||
HOME="$RW_HOME" PATH="$STUB_DIR:$PATH" SSH_AUTH_SOCK= \
|
||||
SANDBOX_TEST_DOCKER_LOG=/dev/null SANDBOX_TEST_DOCKERFILE_LOG="$dockerfile_log" \
|
||||
run "$REPO_ROOT/bin/dejima" "$workspace"
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
local df
|
||||
df="$(cat "$dockerfile_log")"
|
||||
[[ "$df" == *'mkdir -p "${SANDBOX_HOME}"/.claude '* ]] \
|
||||
|| { echo "the layer creates no state directories:"; echo "$df"; return 1; }
|
||||
[[ "$df" == *'.claude/file-history'* ]] \
|
||||
|| { echo "a declared directory is missing from the layer:"; echo "$df"; return 1; }
|
||||
# Owned by the session's user and 0700, which is the whole reason they are
|
||||
# pre-created rather than left to Docker.
|
||||
[[ "$df" == *'chown -R ${SANDBOX_USER}:${SANDBOX_USER}'* ]] || { echo "$df"; return 1; }
|
||||
[[ "$df" == *'chmod 700'* ]] || { echo "$df"; return 1; }
|
||||
[[ "$df" == *'ENV CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1'* ]] \
|
||||
|| { echo "the declared environment is missing from the layer:"; echo "$df"; return 1; }
|
||||
rm -rf "$workspace" "$RW_HOME" "$dockerfile_log"
|
||||
}
|
||||
|
||||
+66
-5
@@ -581,6 +581,55 @@ EOF
|
||||
grep -qx 'crates.io' "$CONFIG_DIR/allowed-domains"
|
||||
}
|
||||
|
||||
# A fake `docker` whose in-container dig answers only when asked upstream
|
||||
# directly (@127.0.0.11), which is what an internally-routed name looks like
|
||||
# from here: dnsmasq drops the private answer, the upstream still has it.
|
||||
stub_docker_rebind() {
|
||||
STUB_DIR="$(mktemp -d)"
|
||||
cat > "$STUB_DIR/docker" <<'EOF'
|
||||
#!/bin/bash
|
||||
case "$*" in
|
||||
*dig*@127.0.0.11*) echo 10.0.0.1 ;;
|
||||
esac
|
||||
exit 0
|
||||
EOF
|
||||
chmod +x "$STUB_DIR/docker"
|
||||
}
|
||||
|
||||
@test "dejima-allow names the relaunch for an internally-routed domain" {
|
||||
# "Failed to resolve" sent people looking at their DNS server, which is
|
||||
# working - the container's rebind protection is what dropped the answer,
|
||||
# and no live patch can lift it.
|
||||
stub_docker_rebind
|
||||
run env PATH="$STUB_DIR:$PATH" \
|
||||
"$REPO_ROOT/bin/dejima-allow" --container c1 internal.example.com
|
||||
[ "$status" -eq 1 ]
|
||||
[[ "$output" == *"private address (10.0.0.1)"* ]]
|
||||
[[ "$output" == *"dejima-allow --global internal.example.com"* ]]
|
||||
}
|
||||
|
||||
@test "dejima-allow points an internally-routed domain at the approval it just recorded" {
|
||||
stub_docker_rebind
|
||||
run env PATH="$STUB_DIR:$PATH" \
|
||||
"$REPO_ROOT/bin/dejima-allow" --global --container c1 internal.example.com
|
||||
[ "$status" -eq 1 ]
|
||||
[[ "$output" == *"approval recorded above already does this"* ]]
|
||||
[[ "$output" == *"Relaunch the session"* ]]
|
||||
grep -qx 'internal.example.com' "$CONFIG_DIR/allowed-domains"
|
||||
}
|
||||
|
||||
@test "dejima-allow still reports a domain that resolves nowhere as unresolvable" {
|
||||
# The rebind branch above must not swallow the ordinary failure: a typo'd
|
||||
# or dead name gets no answer from the upstream either.
|
||||
STUB_DIR="$(mktemp -d)"
|
||||
printf '#!/bin/bash\nexit 0\n' > "$STUB_DIR/docker"
|
||||
chmod +x "$STUB_DIR/docker"
|
||||
run env PATH="$STUB_DIR:$PATH" \
|
||||
"$REPO_ROOT/bin/dejima-allow" --container c1 nowhere.example.com
|
||||
[ "$status" -eq 1 ]
|
||||
[[ "$output" == *"Failed to resolve nowhere.example.com"* ]]
|
||||
}
|
||||
|
||||
@test "dejima-project approve persists every domain even when a docker call would have failed" {
|
||||
# Under set -e the first dejima-allow to exit nonzero used to abort the
|
||||
# loop, silently skipping every domain after it. dejima-project's internal
|
||||
@@ -897,10 +946,13 @@ ENTRYPOINT [\"/usr/local/bin/entrypoint.sh\"]" ]]
|
||||
}
|
||||
|
||||
@test "the launcher runs the derived image only when the project has approved dependencies" {
|
||||
# The baseline is the harness layer, not the base image: the base carries
|
||||
# no harness, and the selected one installs into a layer of its own that
|
||||
# every launch runs on whether or not any dependency is approved.
|
||||
launch_setup
|
||||
run launch "$PROJ"
|
||||
[ "$status" -eq 0 ]
|
||||
[ "$(launched_env SANDBOX_IMAGE)" = "dejima:$(id -un)" ]
|
||||
[ "$(launched_env SANDBOX_IMAGE)" = "dejima:$(id -un)-harness-claude-code" ]
|
||||
|
||||
"$REPO_ROOT/bin/dejima-dep" add --project "$PROJ" apt:libpq-dev >/dev/null
|
||||
run launch "$PROJ"
|
||||
@@ -981,7 +1033,11 @@ ENTRYPOINT [\"/usr/local/bin/entrypoint.sh\"]" ]]
|
||||
run launch "$PROJ"
|
||||
[ "$status" -eq 0 ]
|
||||
slug="$(bash -c 'source "$1"; project_slug "$2"' _ "$LIB" "$PROJ")"
|
||||
dockerfile_for "dejima:$(id -un)-global" | grep -qxF "FROM dejima:$(id -un)"
|
||||
# base -> harness -> global -> project, each FROM the one before it.
|
||||
dockerfile_for "dejima:$(id -un)-harness-claude-code" \
|
||||
| grep -qxF "FROM dejima:$(id -un)"
|
||||
dockerfile_for "dejima:$(id -un)-global" \
|
||||
| grep -qxF "FROM dejima:$(id -un)-harness-claude-code"
|
||||
dockerfile_for "dejima:$(id -un)-$slug" | grep -qxF "FROM dejima:$(id -un)-global"
|
||||
}
|
||||
|
||||
@@ -989,9 +1045,14 @@ ENTRYPOINT [\"/usr/local/bin/entrypoint.sh\"]" ]]
|
||||
launch_setup
|
||||
run launch "$PROJ"
|
||||
[ "$status" -eq 0 ]
|
||||
run grep -- "-t dejima:" <(docker_log)
|
||||
[ "$status" -ne 0 ]
|
||||
[ "$(launched_env SANDBOX_IMAGE)" = "dejima:$(id -un)" ]
|
||||
# The harness layer is built regardless - it is what puts the selected
|
||||
# harness in the image - so this is about the DEPENDENCY layers, which is
|
||||
# what "no approved dependencies" is a statement about.
|
||||
local tagged
|
||||
tagged="$(grep -oE -- '-t dejima:[^ ]+' <(docker_log) | sed 's/^-t //' | sort -u)"
|
||||
[ "$tagged" = "dejima:$(id -un)-harness-claude-code" ] \
|
||||
|| { echo "expected only the harness layer to be built, got:"; echo "$tagged"; return 1; }
|
||||
[ "$(launched_env SANDBOX_IMAGE)" = "dejima:$(id -un)-harness-claude-code" ]
|
||||
}
|
||||
|
||||
@test "the launcher warns when the approval store sits inside the mounted workspace" {
|
||||
|
||||
@@ -898,3 +898,45 @@ assert_remedy_forges_exist() {
|
||||
run lib provider_validate_selection none local-only claude-code
|
||||
[ "$status" -eq 0 ] || { echo "none+local-only: $output"; return 1; }
|
||||
}
|
||||
|
||||
# The base image carries no harness, so `install` is what puts the selected one
|
||||
# there. It reaches a Dockerfile RUN line built on the host, which is why it is
|
||||
# a dependency spec rather than free text.
|
||||
@test "a harness install spec is validated as a dependency spec" {
|
||||
FAKE_ROOT="$(mktemp -d)"
|
||||
mkdir -p "$FAKE_ROOT/harnesses/h"
|
||||
local bad
|
||||
for bad in 'npm:evil;rm -rf /' 'npm:$(id)' 'sh:curl evil|sh' 'npm:a b' \
|
||||
'tarball:x@notasha:https://e.example/x.tar' 'npm:'; do
|
||||
printf 'command = h\ninstall = %s\n' "$bad" > "$FAKE_ROOT/harnesses/h/harness.conf"
|
||||
run env DEJIMA_PROVIDER_ROOT="$FAKE_ROOT" \
|
||||
bash -c 'source "$1"; manifest_validate harness h' _ "$LIB"
|
||||
[ "$status" -ne 0 ] || { echo "accepted install spec: $bad"; return 1; }
|
||||
done
|
||||
}
|
||||
|
||||
@test "a harness install spec that is a real dependency spec is accepted" {
|
||||
FAKE_ROOT="$(mktemp -d)"
|
||||
mkdir -p "$FAKE_ROOT/harnesses/h"
|
||||
local good
|
||||
for good in 'npm:@anthropic-ai/claude-code' 'npm:opencode-ai' 'apt:some-agent' \
|
||||
'npm:pkg@1.2.3'; do
|
||||
printf 'command = h\ninstall = %s\n' "$good" > "$FAKE_ROOT/harnesses/h/harness.conf"
|
||||
run env DEJIMA_PROVIDER_ROOT="$FAKE_ROOT" \
|
||||
bash -c 'source "$1"; manifest_validate harness h' _ "$LIB"
|
||||
[ "$status" -eq 0 ] || { echo "refused install spec: $good ($output)"; return 1; }
|
||||
done
|
||||
}
|
||||
|
||||
@test "every shipped harness says how it gets into the image" {
|
||||
# An empty install means "already in the base image", which is only true of
|
||||
# something the base installs for another reason. A harness that declares
|
||||
# neither is one whose launch fails with an exec error inside a container.
|
||||
local d name spec
|
||||
for d in "$REPO_ROOT"/harnesses/*/; do
|
||||
[ -d "$d" ] || continue
|
||||
name="$(basename "$d")"
|
||||
spec="$(lib manifest_get harness "$name" install)"
|
||||
[ -n "$spec" ] || { echo "$name declares no install and is not in the base image"; return 1; }
|
||||
done
|
||||
}
|
||||
|
||||
+237
-74
@@ -16,18 +16,52 @@ bats_require_minimum_version 1.5.0
|
||||
# test container's own network namespace (no --network host, no bind mounts
|
||||
# of host paths) - none of it touches the host's real firewall state, same
|
||||
# as any other container. Container/network names are derived from
|
||||
# BATS_FILE_TMPDIR (a directory bats itself guarantees is unique per run and
|
||||
# stable across setup_file/every test/teardown_file in this file), so a
|
||||
# stray real container or network can never collide with, and get torn down
|
||||
# by, this suite. Don't derive these from $$ instead - bats reruns this
|
||||
# file's top-level code in a fresh subshell per test, so $$ (unlike
|
||||
# BATS_FILE_TMPDIR) is a *different* value in every test, setup_file, and
|
||||
# teardown_file, silently pointing them at containers that don't match.
|
||||
# BATS_RUN_TMPDIR, whose basename is the `bats-run-XXXXXX` bats makes once per
|
||||
# run: unique across runs, and stable across setup_file, every test, and
|
||||
# teardown_file. Both properties are needed and they pull in opposite
|
||||
# directions, so neither obvious shortcut works.
|
||||
#
|
||||
# Not $$: bats reruns this file's top-level code in a fresh subshell per test,
|
||||
# so $$ is a *different* value in every test, setup_file and teardown_file,
|
||||
# silently pointing each at containers that do not match.
|
||||
#
|
||||
# Not BATS_FILE_TMPDIR either, which is what this used to say. Its PATH is
|
||||
# unique, but its basename is the file's index within the run - `0` for the
|
||||
# only file here, in every run there has ever been. So the names were the
|
||||
# constants `dejima-test-0` and `dejima-test-net-0`, and the collision this
|
||||
# comment promised could never happen was in fact guaranteed: a run whose
|
||||
# teardown did not complete left a network of that name with a container still
|
||||
# attached, `docker network rm` could not remove it, and the next run died in
|
||||
# setup_file on `network with name dejima-test-net-0 already exists`.
|
||||
#
|
||||
# The `dejima-test-` prefix is this suite's own namespace. Nothing else may use
|
||||
# it, which is what makes the reclaim in setup_file safe.
|
||||
|
||||
IMAGE=dejima:latest
|
||||
|
||||
container_name() { echo "dejima-test-$(basename "$BATS_FILE_TMPDIR")"; }
|
||||
network_name() { echo "dejima-test-net-$(basename "$BATS_FILE_TMPDIR")"; }
|
||||
# What a session actually runs: the base image plus the selected harness's own
|
||||
# layer, built in setup_file. Exported there because bats re-runs this file's
|
||||
# top-level code in a fresh process per test, so a plain assignment would not
|
||||
# survive - and re-defaulted here for the same reason, since the export has to
|
||||
# win over the line that re-runs.
|
||||
SESSION_IMAGE="${SESSION_IMAGE:-$IMAGE}"
|
||||
|
||||
# How old a leftover network of this suite's must be before setup_file will
|
||||
# reap it along with whatever is still attached. Hours, not minutes: the only
|
||||
# thing it has to be longer than is a real run of this file, and the only
|
||||
# thing it has to be shorter than is an operator's patience with a runner
|
||||
# filling up with dead containers.
|
||||
REAP_AFTER=$((6 * 60 * 60))
|
||||
|
||||
container_name() { echo "dejima-test-$(basename "$BATS_RUN_TMPDIR")"; }
|
||||
network_name() { echo "dejima-test-net-$(basename "$BATS_RUN_TMPDIR")"; }
|
||||
|
||||
# A container for ONE test, named the same way and for the same reasons: a
|
||||
# leftover from a run that died mid-test must not be adoptable - or, worse,
|
||||
# collide with - the next run's. `docker run` refuses a name in use, so a
|
||||
# constant one turns any failed run into a failing run afterwards, on a
|
||||
# long-lived runner, until someone clears it by hand.
|
||||
test_container_name() { echo "dejima-test-$1-$(basename "$BATS_RUN_TMPDIR")"; }
|
||||
|
||||
# Every container here must go on network_name(), never the default bridge:
|
||||
# dns-proxy.sh forwards to Docker's embedded resolver at 127.0.0.11, which
|
||||
@@ -109,13 +143,44 @@ wait_ready() {
|
||||
# One value from the selected harness's manifest. bin/dejima resolves these
|
||||
# and passes them into the container; a container started here has to do the
|
||||
# same or it is testing an arrangement that never occurs.
|
||||
# BATS_TEST_DIRNAME, not $SANDBOX_DIR: bats runs each test in its own process
|
||||
# and does not carry setup_file's plain assignments into it, so a fixture
|
||||
# variable is set here and empty in every test body. This is called from both.
|
||||
# The symptom is not an error but an empty value - which, for the proxied
|
||||
# domain, means a container that refuses to start because the harness appears
|
||||
# to have declared no endpoint.
|
||||
harness_manifest_value() {
|
||||
sed -n "s/^$1 *= *//p" "$SANDBOX_DIR/../harnesses/claude-code/harness.conf"
|
||||
sed -n "s/^$1 *= *//p" "$BATS_TEST_DIRNAME/../harnesses/claude-code/harness.conf"
|
||||
}
|
||||
|
||||
# The selected harness's image layer, on top of $1, tagged for this run so
|
||||
# teardown_file's sweep finds it. Prints the new tag.
|
||||
build_harness_layer() {
|
||||
local base="$1" tag df ctx
|
||||
tag="dejima-test-harness:$(basename "$BATS_RUN_TMPDIR")"
|
||||
df="$(mktemp)"
|
||||
# An empty build context, for the reason bin/dejima uses one: a build
|
||||
# whose context is a directory anything else can write is a way into the
|
||||
# image it then runs.
|
||||
ctx="$(mktemp -d)"
|
||||
# shellcheck source=../bin/dejima-project-lib.sh
|
||||
source "$BATS_TEST_DIRNAME/../bin/dejima-project-lib.sh"
|
||||
generate_spec_dockerfile "$base" "" \
|
||||
"$(manifest_get harness claude-code image_dirs)" \
|
||||
"$(manifest_get harness claude-code image_env)" \
|
||||
"$(manifest_get harness claude-code image_files)" > "$df"
|
||||
if ! docker build -q -t "$tag" -f "$df" "$ctx" >&2; then
|
||||
rm -rf "$df" "$ctx"
|
||||
echo "could not build the harness layer this suite runs on" >&2
|
||||
return 1
|
||||
fi
|
||||
rm -rf "$df" "$ctx"
|
||||
printf '%s' "$tag"
|
||||
}
|
||||
|
||||
assert_image_matches_checkout() {
|
||||
local expected actual
|
||||
expected=$(cd "$SANDBOX_DIR" && md5sum init-firewall.sh dns-proxy.sh entrypoint.sh | awk '{print $1}')
|
||||
expected=$(cd "$BATS_TEST_DIRNAME/../docker" && md5sum init-firewall.sh dns-proxy.sh entrypoint.sh | awk '{print $1}')
|
||||
actual=$(docker run --rm --entrypoint sh "$IMAGE" -c \
|
||||
'cd /usr/local/bin && md5sum init-firewall.sh dns-proxy.sh entrypoint.sh' 2>&1 | awk '{print $1}')
|
||||
if [ "$expected" != "$actual" ]; then
|
||||
@@ -137,6 +202,12 @@ assert_image_matches_checkout() {
|
||||
}
|
||||
|
||||
setup_file() {
|
||||
# These two are setup_file's own. bats runs every test in a separate
|
||||
# process and does not carry a plain assignment here into it, so anything
|
||||
# a test body can reach - directly, or through a helper it calls - has to
|
||||
# derive its own paths from BATS_TEST_DIRNAME, which bats exports. A
|
||||
# helper that read one of these was set in the fixture and empty in every
|
||||
# test, which is not an error, just a wrong answer.
|
||||
SANDBOX_DIR="$BATS_TEST_DIRNAME/../docker"
|
||||
REPO_ROOT="$BATS_TEST_DIRNAME/.."
|
||||
local container network
|
||||
@@ -148,8 +219,96 @@ setup_file() {
|
||||
fi
|
||||
assert_image_matches_checkout
|
||||
|
||||
# What a session runs is the base image plus its harness's own layer: the
|
||||
# directories that harness keeps state in, the files it needs seeded, its
|
||||
# environment. Several assertions in this file are about exactly that - a
|
||||
# staged statusline landing in a ~/.claude the session can unlink inside,
|
||||
# an empty credentials file it can read - and booting the base image alone
|
||||
# tests an image no session is ever given.
|
||||
#
|
||||
# Built through bin/dejima's own generator, from the manifest, so this
|
||||
# fixture cannot drift from what a real launch produces. The harness's
|
||||
# PACKAGE is deliberately left out: nothing here runs the harness binary,
|
||||
# and an npm install would put a network fetch on the critical path of
|
||||
# every run of this file. A test that does run it needs the install spec
|
||||
# passed here too.
|
||||
# Assigned before export: `export VAR="$(cmd)"` returns the export's
|
||||
# status, so a failed build would leave this empty, the default at the top
|
||||
# of the file would quietly substitute the base image, and the suite would
|
||||
# test an image no session runs while blaming the tests.
|
||||
local session_image
|
||||
session_image="$(build_harness_layer "$IMAGE")" || {
|
||||
echo "could not build the harness layer - see above" >&2
|
||||
return 1
|
||||
}
|
||||
[ -n "$session_image" ] || { echo "the harness layer built to an empty tag" >&2; return 1; }
|
||||
export SESSION_IMAGE="$session_image"
|
||||
|
||||
docker rm -f "$container" >/dev/null 2>&1 || true
|
||||
docker network rm "$network" >/dev/null 2>&1 || true
|
||||
|
||||
# A run killed between `network create` and teardown_file leaves its
|
||||
# network behind with its container still attached, and an attached
|
||||
# network cannot be removed - so the `|| true` above passes over it. Names
|
||||
# are unique per run, so that no longer blocks the next run the way the
|
||||
# old constant names did; it accumulates instead, one dead container and
|
||||
# network per killed run, on a long-lived privileged runner.
|
||||
#
|
||||
# So this reaps them, scoped to this suite's own `dejima-test-net-` prefix
|
||||
# and to networks older than REAP_AFTER. An unattached one goes on sight.
|
||||
# An attached one is only ever this suite's own leftovers, but a live run
|
||||
# elsewhere on the host has one too, and there is no way to tell "abandoned"
|
||||
# from "in use" by inspection - so age is the discriminator, set far longer
|
||||
# than any run of this suite takes. A concurrent run's network is minutes
|
||||
# old and is never touched.
|
||||
# Every name here is _-prefixed and used nowhere else in this file. The
|
||||
# loop variables of a sweep must not be the plain nouns the rest of
|
||||
# setup_file uses: `while read -r container` would leave $container
|
||||
# holding the last endpoint it saw, and every `docker run --name
|
||||
# "$container"` below would then use that instead - a failure that only
|
||||
# appears once there is something to reap, which is to say only when this
|
||||
# code matters.
|
||||
local _stale _age _endpoint _dead _created
|
||||
while IFS= read -r _stale; do
|
||||
[ -n "$_stale" ] || continue
|
||||
[ "$_stale" != "$network" ] || continue
|
||||
docker network rm "$_stale" >/dev/null 2>&1 && continue
|
||||
# Still attached. Reap only if it is old enough to be abandoned.
|
||||
_age="$(docker network inspect -f '{{.Created}}' "$_stale" 2>/dev/null)" || continue
|
||||
_age="$(date -d "$_age" +%s 2>/dev/null)" || continue
|
||||
[ "$(( $(date +%s) - _age ))" -gt "$REAP_AFTER" ] || continue
|
||||
while IFS= read -r _endpoint; do
|
||||
[ -n "$_endpoint" ] || continue
|
||||
docker rm -f "$_endpoint" >/dev/null 2>&1 || true
|
||||
# An endpoint can outlive the container that owned it, which is
|
||||
# what an unclean daemon stop - a host reboot, most often - leaves
|
||||
# behind: nothing is running, `network rm` still reports active
|
||||
# endpoints, and removing containers does not help. Forcing the
|
||||
# disconnect is the way out of that state.
|
||||
docker network disconnect -f "$_stale" "$_endpoint" >/dev/null 2>&1 || true
|
||||
done < <(docker network inspect -f '{{range .Containers}}{{.Name}}
|
||||
{{end}}' "$_stale" 2>/dev/null)
|
||||
docker network rm "$_stale" >/dev/null 2>&1 || true
|
||||
done < <(docker network ls --filter name=^dejima-test-net- --format '{{.Name}}' 2>/dev/null)
|
||||
|
||||
# And this suite's own containers, which the loop above only reaches while
|
||||
# they are still attached to a network it is reclaiming. A reboot stops
|
||||
# them and releases their endpoints, so the network goes on sight and the
|
||||
# containers are never seen again - accumulating for the life of the host.
|
||||
#
|
||||
# Same age guard. A stopped container of this suite's is already strong
|
||||
# evidence of abandonment, since every one it starts is `docker run -d`
|
||||
# and running, but the guard costs nothing and keeps one rule here.
|
||||
while IFS= read -r _dead; do
|
||||
[ -n "$_dead" ] || continue
|
||||
[ "$_dead" != "$container" ] || continue
|
||||
_created="$(docker inspect -f '{{.Created}}' "$_dead" 2>/dev/null)" || continue
|
||||
_created="$(date -d "$_created" +%s 2>/dev/null)" || continue
|
||||
[ "$(( $(date +%s) - _created ))" -gt "$REAP_AFTER" ] || continue
|
||||
docker rm -f "$_dead" >/dev/null 2>&1 || true
|
||||
done < <(docker ps -a --filter name=^dejima-test- --filter status=exited \
|
||||
--filter status=created --format '{{.Names}}' 2>/dev/null)
|
||||
|
||||
docker network create "$network" >/dev/null
|
||||
|
||||
# No DEJIMA_FORGE_HOSTS/LOKI_URL: this suite only asserts the default
|
||||
@@ -181,7 +340,7 @@ setup_file() {
|
||||
-e SANDBOX_HARNESS_DOMAINS="$(harness_manifest_value api_domains)" \
|
||||
-e SANDBOX_HARNESS_PROXIED_DOMAIN="$(harness_manifest_value proxied_api_domain)" \
|
||||
--entrypoint /usr/local/bin/entrypoint.sh \
|
||||
"$IMAGE" >/dev/null
|
||||
"$SESSION_IMAGE" >/dev/null
|
||||
|
||||
wait_ready "$container"
|
||||
}
|
||||
@@ -205,27 +364,29 @@ teardown() {
|
||||
# reporting zero executed tests.
|
||||
local pid
|
||||
for pid in ${HOST_LISTENER_PIDS:-}; do kill "$pid" 2>/dev/null || true; done
|
||||
docker rm -f "dejima-test-override-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
|
||||
docker rmi "dejima-test-override:$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
|
||||
docker rm -f "dejima-test-sshkey-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
|
||||
docker rm -f "dejima-test-ghpush-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
|
||||
docker rm -f "dejima-test-ghforge-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
|
||||
docker rm -f "dejima-test-ghextra-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
|
||||
docker rm -f "dejima-test-claudejson-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
|
||||
docker rm -f "dejima-test-rebind-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
|
||||
docker rm -f "dejima-test-overlays-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
|
||||
docker rm -f "dejima-test-proxy-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
|
||||
docker rm -f "dejima-test-proxy-mask-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
|
||||
docker rm -f "dejima-test-statusline-regular-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
|
||||
docker rm -f "dejima-test-statusline-symlink-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
|
||||
docker rm -f "dejima-test-derived-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
|
||||
docker rmi "dejima-test-derived:$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
|
||||
docker rm -f "dejima-test-ctxreq-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
|
||||
docker rm -f "dejima-test-tls-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
|
||||
docker rm -f "dejima-test-nohosts-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
|
||||
docker rm -f "dejima-test-badtransport-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
|
||||
docker rm -f "dejima-test-notifydir-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
|
||||
docker rm -f "dejima-test-nonotify-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
|
||||
# Every PER-TEST container and image this run made, found by the run's own
|
||||
# suffix rather than enumerated. The list here was twenty lines that had to
|
||||
# be kept in step with twenty test bodies by hand, and it silently stopped
|
||||
# matching any of them the moment the names changed - which is the shape of
|
||||
# leak that fills a long-lived runner and then fails the next run on a name
|
||||
# conflict.
|
||||
#
|
||||
# The file-wide container is excluded by name, not by luck: this runs after
|
||||
# EVERY test, and it is the one container that has to survive all of them.
|
||||
# test_container_name() puts a purpose between the prefix and the suffix,
|
||||
# so the file-wide name is the only one with nothing there.
|
||||
local suffix keep
|
||||
suffix="$(basename "$BATS_RUN_TMPDIR")"
|
||||
keep="$(container_name)"
|
||||
local c
|
||||
while IFS= read -r c; do
|
||||
[ -n "$c" ] || continue
|
||||
[ "$c" != "$keep" ] || continue
|
||||
docker rm -f "$c" >/dev/null 2>&1 || true
|
||||
done < <(docker ps -a --filter "name=^dejima-test-.*$suffix\$" \
|
||||
--format '{{.Names}}' 2>/dev/null)
|
||||
docker images -q "dejima-test-*:$suffix" 2>/dev/null \
|
||||
| xargs -r docker rmi -f >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
dexec() {
|
||||
@@ -237,8 +398,8 @@ dexec_node() {
|
||||
}
|
||||
|
||||
@test "the container's user actually matches overridden SANDBOX_USER/UID/GID/HOME build args, not just the defaults" {
|
||||
local test_image="dejima-test-override:$(basename "$BATS_FILE_TMPDIR")"
|
||||
local test_container="dejima-test-override-$(basename "$BATS_FILE_TMPDIR")"
|
||||
local test_image="dejima-test-override:$(basename "$BATS_RUN_TMPDIR")"
|
||||
local test_container="$(test_container_name override)"
|
||||
local sandbox_dir="$BATS_TEST_DIRNAME/../docker"
|
||||
local repo_root="$BATS_TEST_DIRNAME/.."
|
||||
|
||||
@@ -276,7 +437,7 @@ dexec_node() {
|
||||
key_dir="$BATS_TEST_TMPDIR"
|
||||
ssh-keygen -t ed25519 -N '' -f "$key_dir/testkey" >/dev/null
|
||||
|
||||
test_container="dejima-test-sshkey-$(basename "$BATS_FILE_TMPDIR")"
|
||||
test_container="$(test_container_name sshkey)"
|
||||
docker run -d --name "$test_container" \
|
||||
--cap-add=NET_ADMIN --cap-add=NET_RAW \
|
||||
--network "$(network_name)" \
|
||||
@@ -284,7 +445,7 @@ dexec_node() {
|
||||
-e GH_TOKEN="${GH_TOKEN:-}" \
|
||||
-v "$key_dir/testkey:/root/.ssh-sandbox-key-src:ro" \
|
||||
--entrypoint /usr/local/bin/entrypoint.sh \
|
||||
"$IMAGE" >/dev/null
|
||||
"$SESSION_IMAGE" >/dev/null
|
||||
|
||||
wait_ready "$test_container"
|
||||
|
||||
@@ -324,7 +485,7 @@ dexec_node() {
|
||||
# seeded with the same content as the mounted file but never mounted
|
||||
# itself, so it can only change if something actually writes back to the
|
||||
# real source - which must not happen.
|
||||
local test_container="dejima-test-claudejson-$(basename "$BATS_FILE_TMPDIR")"
|
||||
local test_container="$(test_container_name claudejson)"
|
||||
local mounted_copy="$BATS_TEST_TMPDIR/claude.json"
|
||||
local host_copy="$BATS_TEST_TMPDIR/claude-json-original.json"
|
||||
|
||||
@@ -339,7 +500,7 @@ dexec_node() {
|
||||
-e GH_TOKEN="${GH_TOKEN:-}" \
|
||||
-v "$mounted_copy:/home/node/.claude.json" \
|
||||
--entrypoint /usr/local/bin/entrypoint.sh \
|
||||
"$IMAGE" >/dev/null
|
||||
"$SESSION_IMAGE" >/dev/null
|
||||
|
||||
wait_ready "$test_container"
|
||||
|
||||
@@ -443,7 +604,7 @@ dexec_node() {
|
||||
}
|
||||
|
||||
@test "ALLOW_GITHUB_PUSH=1 opens port 22 to GitHub" {
|
||||
local test_container="dejima-test-ghpush-$(basename "$BATS_FILE_TMPDIR")"
|
||||
local test_container="$(test_container_name ghpush)"
|
||||
docker run -d --name "$test_container" \
|
||||
--cap-add=NET_ADMIN --cap-add=NET_RAW \
|
||||
--network "$(network_name)" \
|
||||
@@ -451,7 +612,7 @@ dexec_node() {
|
||||
-e GH_TOKEN="${GH_TOKEN:-}" \
|
||||
-e ALLOW_GITHUB_PUSH=1 \
|
||||
--entrypoint /usr/local/bin/entrypoint.sh \
|
||||
"$IMAGE" >/dev/null
|
||||
"$SESSION_IMAGE" >/dev/null
|
||||
|
||||
wait_ready "$test_container"
|
||||
|
||||
@@ -475,7 +636,7 @@ dexec_node() {
|
||||
# port-unrestricted allowed-domains ipset, its own startup self-check
|
||||
# finds github.com:22 reachable without ALLOW_GITHUB_PUSH=1, and
|
||||
# entrypoint.sh (set -Eeuo pipefail) dies before the agent ever runs.
|
||||
local test_container="dejima-test-ghforge-$(basename "$BATS_FILE_TMPDIR")"
|
||||
local test_container="$(test_container_name ghforge)"
|
||||
docker run -d --name "$test_container" \
|
||||
--cap-add=NET_ADMIN --cap-add=NET_RAW \
|
||||
--network "$(network_name)" \
|
||||
@@ -485,7 +646,7 @@ dexec_node() {
|
||||
-e DEJIMA_PUSH_HOSTS="github.com ssh.github.com api.github.com" \
|
||||
-e ALLOW_GITHUB_PUSH=1 \
|
||||
--entrypoint /usr/local/bin/entrypoint.sh \
|
||||
"$IMAGE" >/dev/null
|
||||
"$SESSION_IMAGE" >/dev/null
|
||||
|
||||
# The container coming up at all is half the assertion.
|
||||
wait_ready "$test_container"
|
||||
@@ -529,7 +690,7 @@ dexec_node() {
|
||||
# the port-22 self-check re-resolves the name: it catches the hole when
|
||||
# GitHub's second answer matches the first, and misses it when the name
|
||||
# has rotated in between. This test does not depend on that coin flip.
|
||||
local test_container="dejima-test-ghextra-$(basename "$BATS_FILE_TMPDIR")"
|
||||
local test_container="$(test_container_name ghextra)"
|
||||
docker run -d --name "$test_container" \
|
||||
--cap-add=NET_ADMIN --cap-add=NET_RAW \
|
||||
--network "$(network_name)" \
|
||||
@@ -538,7 +699,7 @@ dexec_node() {
|
||||
-e DEJIMA_FORGE_HOSTS= \
|
||||
-e ALLOWED_DOMAINS="github.com api.github.com" \
|
||||
--entrypoint /usr/local/bin/entrypoint.sh \
|
||||
"$IMAGE" >/dev/null
|
||||
"$SESSION_IMAGE" >/dev/null
|
||||
|
||||
# Coming up at all is part of the assertion: the port-22 self-check runs
|
||||
# during startup, and with the address in the wrong set it fails the boot.
|
||||
@@ -570,7 +731,7 @@ dexec_node() {
|
||||
# them host-side from whichever forge is configured and hands the result
|
||||
# over under this one name. Feeding the old variable here made the test
|
||||
# assert against a hostname the entrypoint never saw.
|
||||
local test_container="dejima-test-rebind-$(basename "$BATS_FILE_TMPDIR")"
|
||||
local test_container="$(test_container_name rebind)"
|
||||
docker run -d --name "$test_container" \
|
||||
--cap-add=NET_ADMIN --cap-add=NET_RAW \
|
||||
--network "$(network_name)" \
|
||||
@@ -578,7 +739,7 @@ dexec_node() {
|
||||
-e GH_TOKEN="${GH_TOKEN:-}" \
|
||||
-e DEJIMA_FORGE_HOSTS=forge.internal.example \
|
||||
--entrypoint /usr/local/bin/entrypoint.sh \
|
||||
"$IMAGE" >/dev/null
|
||||
"$SESSION_IMAGE" >/dev/null
|
||||
|
||||
wait_ready "$test_container"
|
||||
|
||||
@@ -596,7 +757,7 @@ dexec_node() {
|
||||
}
|
||||
|
||||
@test "the code-carrying ~/.claude overlay dirs are read-only inside the container" {
|
||||
local test_container="dejima-test-overlays-$(basename "$BATS_FILE_TMPDIR")"
|
||||
local test_container="$(test_container_name overlays)"
|
||||
local src; src="$(mktemp -d)"
|
||||
mkdir -p "$src/skills" "$src/commands" "$src/agents"
|
||||
docker run -d --name "$test_container" \
|
||||
@@ -608,7 +769,7 @@ dexec_node() {
|
||||
-v "$src/commands:/home/node/.claude/commands:ro" \
|
||||
-v "$src/agents:/home/node/.claude/agents:ro" \
|
||||
--entrypoint /usr/local/bin/entrypoint.sh \
|
||||
"$IMAGE" >/dev/null
|
||||
"$SESSION_IMAGE" >/dev/null
|
||||
wait_ready "$test_container"
|
||||
for d in skills commands agents; do
|
||||
run docker exec -u node "$test_container" bash -c "touch /home/node/.claude/$d/pwned 2>&1"
|
||||
@@ -630,7 +791,7 @@ dexec_node() {
|
||||
# parent were read-only instead, this is where it would hang the way a
|
||||
# blocked ~/.claude.json write did.
|
||||
@test "only the mounted project slugs exist in the container, and an unmounted one is still creatable" {
|
||||
local test_container="dejima-test-projects-$(basename "$BATS_FILE_TMPDIR")"
|
||||
local test_container="$(test_container_name projects)"
|
||||
local src; src="$(mktemp -d)"
|
||||
mkdir -p "$src/own" "$src/sibling" "$src/fh-own"
|
||||
echo host > "$src/own/existing.jsonl"
|
||||
@@ -647,7 +808,7 @@ dexec_node() {
|
||||
-v "$src/own:/home/node/.claude/projects/own" \
|
||||
-v "$src/fh-own:/home/node/.claude/file-history/own-session" \
|
||||
--entrypoint /usr/local/bin/entrypoint.sh \
|
||||
"$IMAGE" >/dev/null
|
||||
"$SESSION_IMAGE" >/dev/null
|
||||
wait_ready "$test_container"
|
||||
|
||||
# the sibling project is absent, not merely unwritable
|
||||
@@ -693,7 +854,7 @@ dexec_node() {
|
||||
# path. This test does NOT use bin/dejima or docker-compose.yml directly
|
||||
# - it builds the same shape by hand. This shared container isn't reused
|
||||
# for it (it has no staging mount at all - see setup_file).
|
||||
local test_container="dejima-test-statusline-regular-$(basename "$BATS_FILE_TMPDIR")"
|
||||
local test_container="$(test_container_name statusline-regular)"
|
||||
local dotclaude; dotclaude="$(mktemp -d)"
|
||||
printf '#!/bin/sh\necho original\n' > "$dotclaude/statusline.sh"
|
||||
chmod +x "$dotclaude/statusline.sh"
|
||||
@@ -711,7 +872,7 @@ dexec_node() {
|
||||
-e GH_TOKEN="${GH_TOKEN:-}" \
|
||||
-v "$dotclaude/statusline.sh:/etc/dejima-harness/.claude/statusline.sh:ro" \
|
||||
--entrypoint /usr/local/bin/entrypoint.sh \
|
||||
"$IMAGE" >/dev/null
|
||||
"$SESSION_IMAGE" >/dev/null
|
||||
wait_ready "$test_container"
|
||||
|
||||
run docker exec -u node "$test_container" cat /home/node/.claude/statusline.sh
|
||||
@@ -755,7 +916,7 @@ dexec_node() {
|
||||
# /etc/dejima-harness holds the real content, and entrypoint.sh copies it to
|
||||
# a path with no route back to the host at all. This test asserts the
|
||||
# replacement no longer reaches either the symlink or its target.
|
||||
local test_container="dejima-test-statusline-symlink-$(basename "$BATS_FILE_TMPDIR")"
|
||||
local test_container="$(test_container_name statusline-symlink)"
|
||||
local target_dir dotclaude
|
||||
target_dir="$(mktemp -d)"
|
||||
printf '#!/bin/sh\necho original\n' > "$target_dir/statusline.sh"
|
||||
@@ -774,7 +935,7 @@ dexec_node() {
|
||||
-e GH_TOKEN="${GH_TOKEN:-}" \
|
||||
-v "$dotclaude/statusline.sh:/etc/dejima-harness/.claude/statusline.sh:ro" \
|
||||
--entrypoint /usr/local/bin/entrypoint.sh \
|
||||
"$IMAGE" >/dev/null
|
||||
"$SESSION_IMAGE" >/dev/null
|
||||
wait_ready "$test_container"
|
||||
|
||||
run docker exec -u node "$test_container" sh -c \
|
||||
@@ -922,18 +1083,19 @@ EOF
|
||||
# more clearly than a test that fails on a bare false condition.
|
||||
chmod 666 "$TD/proxy.sock"
|
||||
|
||||
container="dejima-test-proxy-$(basename "$BATS_FILE_TMPDIR")"
|
||||
container="$(test_container_name proxy)"
|
||||
docker run -d --name "$container" \
|
||||
--cap-add=NET_ADMIN --cap-add=NET_RAW \
|
||||
--network "$(network_name)" \
|
||||
-e SANDBOX_TEST_MODE=1 \
|
||||
-e GH_TOKEN="${GH_TOKEN:-}" \
|
||||
-e SANDBOX_ANTHROPIC_PROXY_SOCKET="$TD/proxy.sock" \
|
||||
-e SANDBOX_HARNESS_PROXIED_DOMAIN="$(harness_manifest_value proxied_api_domain)" \
|
||||
-e SANDBOX_ANTHROPIC_TRANSPORT=loopback \
|
||||
-e ANTHROPIC_AUTH_TOKEN=sandbox-credential-proxy-placeholder \
|
||||
-v "$TD:$TD" \
|
||||
--entrypoint /usr/local/bin/entrypoint.sh \
|
||||
"$IMAGE" >/dev/null
|
||||
"$SESSION_IMAGE" >/dev/null
|
||||
# This test depends on the forwarder having already bound port 18300;
|
||||
# wait_ready covers that, since entrypoint.sh waits for the bind itself
|
||||
# before reaching the `sleep infinity` wait_ready matches on.
|
||||
@@ -991,7 +1153,7 @@ EOF
|
||||
chmod 666 "$TD/dotclaude/.credentials.json"
|
||||
chmod -R 777 "$TD/dotclaude"
|
||||
|
||||
container="dejima-test-proxy-mask-$(basename "$BATS_FILE_TMPDIR")"
|
||||
container="$(test_container_name proxy-mask)"
|
||||
docker run -d --name "$container" \
|
||||
--cap-add=NET_ADMIN --cap-add=NET_RAW \
|
||||
--network "$(network_name)" \
|
||||
@@ -1000,7 +1162,7 @@ EOF
|
||||
-v "$TD/dotclaude/projects:/home/node/.claude/projects" \
|
||||
-v "$TD/dotclaude/plans:/home/node/.claude/plans" \
|
||||
--entrypoint /usr/local/bin/entrypoint.sh \
|
||||
"$IMAGE" >/dev/null
|
||||
"$SESSION_IMAGE" >/dev/null
|
||||
wait_ready "$container"
|
||||
|
||||
run docker exec -u node "$container" cat /home/node/.claude/.credentials.json
|
||||
@@ -1101,7 +1263,7 @@ EOF
|
||||
local hook="$BATS_TEST_TMPDIR/sandbox-context.sh"
|
||||
cp "$BATS_TEST_DIRNAME/../hooks/sandbox-context.sh" "$hook"
|
||||
|
||||
local test_container="dejima-test-ctxreq-$(basename "$BATS_FILE_TMPDIR")"
|
||||
local test_container="$(test_container_name ctxreq)"
|
||||
docker run -d --name "$test_container" \
|
||||
--cap-add=NET_ADMIN --cap-add=NET_RAW \
|
||||
--network "$(network_name)" \
|
||||
@@ -1110,7 +1272,7 @@ EOF
|
||||
-v "$ws:$ws" \
|
||||
-v "$hook:/usr/local/bin/sandbox-context.sh:ro" \
|
||||
--entrypoint /usr/local/bin/entrypoint.sh \
|
||||
"$IMAGE" >/dev/null
|
||||
"$SESSION_IMAGE" >/dev/null
|
||||
wait_ready "$test_container"
|
||||
|
||||
# -w is deliberately an unrelated directory, not "$ws": if cwd extraction
|
||||
@@ -1128,8 +1290,8 @@ EOF
|
||||
}
|
||||
|
||||
@test "a derived project image gets the package and keeps the base privilege posture" {
|
||||
local derived="dejima-test-derived:$(basename "$BATS_FILE_TMPDIR")"
|
||||
local test_container="dejima-test-derived-$(basename "$BATS_FILE_TMPDIR")"
|
||||
local derived="dejima-test-derived:$(basename "$BATS_RUN_TMPDIR")"
|
||||
local test_container="$(test_container_name derived)"
|
||||
local ctx; ctx="$(mktemp -d)"
|
||||
local store; store="$(mktemp -d)"
|
||||
local df; df="$(mktemp)"
|
||||
@@ -1147,7 +1309,7 @@ EOF
|
||||
false
|
||||
}
|
||||
bash -c 'source "$1"; generate_derived_dockerfile "$2" "$3"' \
|
||||
_ "$BATS_TEST_DIRNAME/../bin/dejima-project-lib.sh" "$IMAGE" "$store" > "$df"
|
||||
_ "$BATS_TEST_DIRNAME/../bin/dejima-project-lib.sh" "$SESSION_IMAGE" "$store" > "$df"
|
||||
docker build -q -t "$derived" -f "$df" "$ctx" >&2
|
||||
|
||||
# The image's own config, not the running container: `docker run` below
|
||||
@@ -1288,7 +1450,7 @@ EOF
|
||||
# See the loopback test above for why this chmod is here.
|
||||
chmod 666 "$TD/proxy.sock"
|
||||
|
||||
container="dejima-test-tls-$(basename "$BATS_FILE_TMPDIR")"
|
||||
container="$(test_container_name tls)"
|
||||
# No ANTHROPIC_* env at all - that absence is the thing under test.
|
||||
docker run -d --name "$container" \
|
||||
--cap-add=NET_ADMIN --cap-add=NET_RAW \
|
||||
@@ -1296,9 +1458,10 @@ EOF
|
||||
-e SANDBOX_TEST_MODE=1 \
|
||||
-e GH_TOKEN="${GH_TOKEN:-}" \
|
||||
-e SANDBOX_ANTHROPIC_PROXY_SOCKET="$TD/proxy.sock" \
|
||||
-e SANDBOX_HARNESS_PROXIED_DOMAIN="$(harness_manifest_value proxied_api_domain)" \
|
||||
-v "$TD:$TD" \
|
||||
--entrypoint /usr/local/bin/entrypoint.sh \
|
||||
"$IMAGE" >/dev/null
|
||||
"$SESSION_IMAGE" >/dev/null
|
||||
wait_ready "$container"
|
||||
|
||||
# NODE_EXTRA_CA_CERTS has to be passed explicitly: docker exec starts from
|
||||
@@ -1349,14 +1512,14 @@ EOF
|
||||
# forwarder. If the hosts entry were unconditional (a compose extra_hosts
|
||||
# entry, say), this container would send every API call into a loopback
|
||||
# port nothing is listening on.
|
||||
container="dejima-test-nohosts-$(basename "$BATS_FILE_TMPDIR")"
|
||||
container="$(test_container_name nohosts)"
|
||||
docker run -d --name "$container" \
|
||||
--cap-add=NET_ADMIN --cap-add=NET_RAW \
|
||||
--network "$(network_name)" \
|
||||
-e SANDBOX_TEST_MODE=1 \
|
||||
-e GH_TOKEN="${GH_TOKEN:-}" \
|
||||
--entrypoint /usr/local/bin/entrypoint.sh \
|
||||
"$IMAGE" >/dev/null
|
||||
"$SESSION_IMAGE" >/dev/null
|
||||
wait_ready "$container"
|
||||
|
||||
run docker exec "$container" grep -c 'api.anthropic.com' /etc/hosts
|
||||
@@ -1371,7 +1534,7 @@ EOF
|
||||
# `Loopback` is not `loopback`: bin/dejima would take its tls branch while
|
||||
# this container took its loopback one, and every API call in the session
|
||||
# would fail with nothing naming the cause.
|
||||
container="dejima-test-badtransport-$(basename "$BATS_FILE_TMPDIR")"
|
||||
container="$(test_container_name badtransport)"
|
||||
docker rm -f "$container" >/dev/null 2>&1 || true
|
||||
|
||||
# Run in the FOREGROUND and read the container's own streams, rather than
|
||||
@@ -1401,7 +1564,7 @@ EOF
|
||||
-e GH_TOKEN="${GH_TOKEN:-}" \
|
||||
-e SANDBOX_ANTHROPIC_TRANSPORT=Loopback \
|
||||
--entrypoint /usr/local/bin/entrypoint.sh \
|
||||
"$IMAGE"
|
||||
"$SESSION_IMAGE"
|
||||
exit_code=$status
|
||||
logs=$output
|
||||
|
||||
@@ -1450,7 +1613,7 @@ EOF
|
||||
# setup_file and used by every dexec test in this file. Starting a second
|
||||
# container under that name conflicts, and removing it at the end of this
|
||||
# body would take the shared one with it.
|
||||
local container="dejima-test-notifydir-$(basename "$BATS_FILE_TMPDIR")"
|
||||
local container="$(test_container_name notifydir)"
|
||||
local host_dir; host_dir="$(mktemp -d)"
|
||||
# Unwritable by the sandbox user, which is the property the check keys on.
|
||||
# Built here rather than by removing a bind source, so the test does not
|
||||
@@ -1472,7 +1635,7 @@ EOF
|
||||
-e DEJIMA_NOTIFY_DIR=/run/dejima-notify \
|
||||
-v "$host_dir:/run/dejima-notify" \
|
||||
--entrypoint /usr/local/bin/entrypoint.sh \
|
||||
"$IMAGE" >/dev/null
|
||||
"$SESSION_IMAGE" >/dev/null
|
||||
wait_ready "$container"
|
||||
|
||||
run settled_logs "$container"
|
||||
@@ -1496,14 +1659,14 @@ EOF
|
||||
# default - a warning about an unconfigured optional feature is noise on every
|
||||
# start, and noise is what stops warnings being read.
|
||||
@test "no marker-directory warning when remote access is not enabled" {
|
||||
local container="dejima-test-nonotify-$(basename "$BATS_FILE_TMPDIR")"
|
||||
local container="$(test_container_name nonotify)"
|
||||
docker run -d --name "$container" \
|
||||
--cap-add=NET_ADMIN --cap-add=NET_RAW \
|
||||
--network "$(network_name)" \
|
||||
-e SANDBOX_TEST_MODE=1 \
|
||||
-e GH_TOKEN="${GH_TOKEN:-}" \
|
||||
--entrypoint /usr/local/bin/entrypoint.sh \
|
||||
"$IMAGE" >/dev/null
|
||||
"$SESSION_IMAGE" >/dev/null
|
||||
wait_ready "$container"
|
||||
|
||||
run settled_logs "$container"
|
||||
|
||||
+728
-24
@@ -436,13 +436,40 @@ EOF
|
||||
[ "$status" -ne 0 ]
|
||||
}
|
||||
|
||||
# Whether the selected harness declares a directory its image layer creates.
|
||||
# The requirement each of the callers below describes is unchanged; what moved
|
||||
# is where it is satisfied. The base image is shared by every harness, so a
|
||||
# directory only one of them needs is declared by that one - see `image_dirs`
|
||||
# in docs/providers.md.
|
||||
_harness_declares_file() {
|
||||
local want="$1" files f
|
||||
files="$(bash -c 'source "$1"; manifest_get harness claude-code image_files' _ \
|
||||
"$REPO_ROOT/bin/dejima-provider-lib.sh")" || return 1
|
||||
for f in $files; do [ "$f" = "$want" ] && return 0; done
|
||||
return 1
|
||||
}
|
||||
|
||||
_harness_declares_dir() {
|
||||
local want="$1" dirs d
|
||||
dirs="$(bash -c 'source "$1"; manifest_get harness claude-code image_dirs' _ \
|
||||
"$REPO_ROOT/bin/dejima-provider-lib.sh")" || return 1
|
||||
for d in $dirs; do [ "$d" = "$want" ] && return 0; done
|
||||
return 1
|
||||
}
|
||||
|
||||
@test "the image bakes in a user-owned ~/.claude for the enumerated mounts to land in" {
|
||||
# Nothing mounts the directory itself any more, so it has to exist and be
|
||||
# writable by the sandbox user - otherwise Docker auto-creates it root
|
||||
# owned while setting up the mounts nested under it, and the session
|
||||
# can't write its own cache or the config entrypoint.sh copies in.
|
||||
grep -qE '^RUN mkdir -p .*\$SANDBOX_HOME/\.claude' "$SANDBOX_DIR/Dockerfile"
|
||||
grep -qE 'chown -R \$SANDBOX_USER:\$SANDBOX_USER .*\$SANDBOX_HOME/\.claude' "$SANDBOX_DIR/Dockerfile"
|
||||
# Nothing mounts the directory itself, so it has to exist and be writable
|
||||
# by the sandbox user - otherwise Docker auto-creates it root-owned while
|
||||
# setting up the mounts nested under it, and the session cannot write its
|
||||
# own cache or the config entrypoint.sh copies in.
|
||||
_harness_declares_dir .claude \
|
||||
|| { echo "the harness does not declare ~/.claude"; return 1; }
|
||||
# And the layer that reads the declaration makes it user-owned, which is
|
||||
# the half a declaration alone would not give.
|
||||
run bash -c 'source "$1"; generate_spec_dockerfile base "" ".claude" ""' _ \
|
||||
"$REPO_ROOT/bin/dejima-project-lib.sh"
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
[[ "$output" == *'chown -R ${SANDBOX_USER}:${SANDBOX_USER}'* ]] || { echo "$output"; return 1; }
|
||||
}
|
||||
|
||||
|
||||
@@ -514,7 +541,8 @@ _declared_config_dirs() {
|
||||
# could not create a slug of its own - which is exactly what it must do when
|
||||
# the workspace has no history yet.
|
||||
@test "the image pre-creates ~/.claude/projects so the slug mounts have a writable parent" {
|
||||
grep -qE '^RUN mkdir -p .*\$SANDBOX_HOME/\.claude/projects' "$SANDBOX_DIR/Dockerfile"
|
||||
_harness_declares_dir .claude/projects \
|
||||
|| { echo "the harness does not declare ~/.claude/projects"; return 1; }
|
||||
}
|
||||
|
||||
# Each file-history/<session>/<hash>@v1 is a verbatim copy of an edited file,
|
||||
@@ -538,7 +566,8 @@ _declared_config_dirs() {
|
||||
}
|
||||
|
||||
@test "the image pre-creates ~/.claude/file-history so the session mounts have a writable parent" {
|
||||
grep -qE '\$SANDBOX_HOME/\.claude/file-history' "$SANDBOX_DIR/Dockerfile"
|
||||
_harness_declares_dir .claude/file-history \
|
||||
|| { echo "the harness does not declare ~/.claude/file-history"; return 1; }
|
||||
}
|
||||
|
||||
# One definition of "this workspace's slugs", shared by the mount and the
|
||||
@@ -1317,6 +1346,92 @@ STUB
|
||||
grep -q 'type=issues' "$REPO_ROOT/forges/gitea/bin/gitea-issue"
|
||||
}
|
||||
|
||||
# A run carries two numbers: the per-workflow counter this tool lists (225)
|
||||
# and the repo-wide index its web URL and every status target_url use (11387).
|
||||
# They are equal on a fresh instance and diverge for good once a second
|
||||
# workflow runs, so a tool that filters statuses by the first finds nothing at
|
||||
# all - `jobs` reported "no jobs" for a run whose twelve jobs were right
|
||||
# there, and `logs` could not reach the one that failed.
|
||||
#
|
||||
# The stub gives them deliberately different values, which is the only shape
|
||||
# that tells the two apart.
|
||||
actions_cli() {
|
||||
local d="$BATS_TEST_TMPDIR/acli"
|
||||
if [ ! -d "$d" ]; then
|
||||
mkdir -p "$d/stub" "$d/repo"
|
||||
cat > "$d/stub/curl" <<'STUB'
|
||||
#!/bin/bash
|
||||
url=""
|
||||
for a in "$@"; do case "$a" in https://*) url="$a" ;; esac; done
|
||||
cat >/dev/null
|
||||
case "$url" in
|
||||
*/actions/tasks*)
|
||||
cat <<'JSON'
|
||||
{"workflow_runs":[
|
||||
{"id":2663,"run_number":225,"name":"privileged","head_sha":"deadbeefdeadbeef",
|
||||
"status":"failure","event":"pull_request_target","display_title":"a change",
|
||||
"url":"https://forge.invalid/owner/name/actions/runs/11387"},
|
||||
{"id":2662,"run_number":225,"name":"confined (static)","head_sha":"deadbeefdeadbeef",
|
||||
"status":"success","event":"pull_request_target","display_title":"a change",
|
||||
"url":"https://forge.invalid/owner/name/actions/runs/11387"}
|
||||
]}
|
||||
JSON
|
||||
;;
|
||||
*/statuses*)
|
||||
# target_url carries the WEB id. A filter keyed on run_number matches
|
||||
# none of these.
|
||||
cat <<'JSON'
|
||||
[{"status":"failure","context":"tests / privileged (pull_request_target)",
|
||||
"target_url":"/owner/name/actions/runs/11387/jobs/13320"},
|
||||
{"status":"success","context":"tests / confined (static) (pull_request_target)",
|
||||
"target_url":"/owner/name/actions/runs/11387/jobs/13318"}]
|
||||
JSON
|
||||
;;
|
||||
*) echo '[]' ;;
|
||||
esac
|
||||
echo 200
|
||||
STUB
|
||||
chmod +x "$d/stub/curl"
|
||||
git -C "$d/repo" init -q -b feature
|
||||
git -C "$d/repo" -c user.name=t -c user.email=t@invalid \
|
||||
commit -q --allow-empty -m seed
|
||||
git -C "$d/repo" remote add origin git@forge.invalid:owner/name.git
|
||||
fi
|
||||
( cd "$d/repo" && env PATH="$d/stub:$PATH" \
|
||||
GITEA_SSH_HOST=forge.invalid GITEA_API=https://forge.invalid/api/v1 \
|
||||
GITEA_TOKEN=stub-token \
|
||||
"$REPO_ROOT/forges/gitea/bin/gitea-actions" "$@" )
|
||||
}
|
||||
|
||||
@test "gitea-actions finds a run's jobs when its two numbers differ" {
|
||||
run actions_cli jobs 225
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
[[ "$output" == *"privileged"* ]] \
|
||||
|| { echo "the failed job is missing:"; echo "$output"; return 1; }
|
||||
[[ "$output" == *"13320"* ]] || { echo "$output"; return 1; }
|
||||
[[ "$output" != *"reports no jobs"* ]] \
|
||||
|| { echo "the run-number filter matched nothing again"; return 1; }
|
||||
}
|
||||
|
||||
@test "gitea-actions accepts either of a run's two numbers" {
|
||||
# `runs` prints one and the usage has always described the other, so both
|
||||
# have been told to people.
|
||||
local by_number by_web
|
||||
by_number="$(actions_cli jobs 225)"
|
||||
by_web="$(actions_cli jobs 11387)"
|
||||
[ "$by_number" = "$by_web" ] || {
|
||||
echo "the two forms disagree:"; echo "$by_number"; echo "--"; echo "$by_web"; return 1; }
|
||||
}
|
||||
|
||||
@test "gitea-actions addresses a job's log by the id its URL uses" {
|
||||
# The log endpoint is under the web id too, so a request built from
|
||||
# run_number 404s - which api_or_die reports as a failed fetch rather than
|
||||
# as the wrong number having been used.
|
||||
grep -q 'actions/runs/\${run_web_id}/jobs/\${index}/logs' \
|
||||
"$REPO_ROOT/forges/gitea/bin/gitea-actions" \
|
||||
|| { echo "the log URL is not keyed on the web id"; return 1; }
|
||||
}
|
||||
|
||||
@test "gitea-actions only reads - it issues no request that could change anything" {
|
||||
# It authenticates with the same write-scoped token as gitea-pr, so
|
||||
# "it only fetches runs and logs" has to be a property of the file and
|
||||
@@ -1785,15 +1900,20 @@ registered_commands() {
|
||||
[ "$branch_line" -lt "$mount_line" ]
|
||||
}
|
||||
|
||||
@test "entrypoint.sh gives the container an empty credentials file of its own" {
|
||||
# The mask used to provide one; Claude Code shouldn't have to be trusted
|
||||
# to treat an absent credentials file the same as an empty one. Guarded
|
||||
# on non-existence so it can't clobber either of the credentials
|
||||
# bin/dejima does mount - the fake one under the tls transport, the real
|
||||
# one under SANDBOX_CREDENTIAL_PROXY=0 - since mounts are established
|
||||
# before the entrypoint runs.
|
||||
grep -q 'if \[ ! -e "\$SANDBOX_HOME/\.claude/\.credentials\.json" \]; then' "$SANDBOX_DIR/entrypoint.sh"
|
||||
grep -q 'install -o "\$SANDBOX_USER" -g "\$SANDBOX_USER" -m 600 /dev/null "\$SANDBOX_HOME/\.claude/\.credentials\.json"' "$SANDBOX_DIR/entrypoint.sh"
|
||||
@test "the container gets an empty credentials file of its own" {
|
||||
# A harness should not have to be trusted to treat an absent credentials
|
||||
# file the same as an empty one. It comes from that harness's own image
|
||||
# layer rather than from entrypoint.sh - see "a file a harness needs
|
||||
# seeded is declared" - and a mount at the same path shadows it, which is
|
||||
# what keeps it from clobbering either of the credentials bin/dejima does
|
||||
# mount: the fake one under the tls transport, the real one under
|
||||
# [credentials] proxy = off.
|
||||
_harness_declares_file .claude/.credentials.json \
|
||||
|| { echo "the harness declares no credentials file"; return 1; }
|
||||
# Shadowing only works if the mount is at the same path the layer created.
|
||||
grep -q '\.credentials\.json' "$SANDBOX_DIR/docker-compose.yml" \
|
||||
|| grep -q '\.credentials\.json' "$REPO_ROOT/bin/dejima" \
|
||||
|| { echo "nothing ever mounts a credential over it"; return 1; }
|
||||
}
|
||||
|
||||
@test "bin/dejima refuses to launch when the proxy fails to start" {
|
||||
@@ -1987,6 +2107,130 @@ this line is not json'
|
||||
[[ "$output" != *"not json"* ]]
|
||||
}
|
||||
|
||||
# runtime.bats names its container and network after the run, and both
|
||||
# properties it needs pull in opposite directions: unique ACROSS runs, so a
|
||||
# leftover from one cannot be adopted or destroyed by the next, and stable
|
||||
# WITHIN a run, so setup_file, every test and teardown_file all mean the same
|
||||
# container.
|
||||
#
|
||||
# BATS_FILE_TMPDIR looks like it gives both and gives only the second: its path
|
||||
# is unique but its basename is the file's index, `0` for the only file there.
|
||||
# That made the names the constants dejima-test-0 and dejima-test-net-0 in
|
||||
# every run ever, until a killed run left dejima-test-net-0 with a container
|
||||
# attached and every later run died in setup_file on "already exists".
|
||||
@test "runtime.bats derives its container and network names from the run, not the file index" {
|
||||
local rt="$REPO_ROOT/test/runtime.bats"
|
||||
run grep -nE '^(container|network)_name\(\)' "$rt"
|
||||
[ "$status" -eq 0 ] || { echo "the name helpers are gone"; return 1; }
|
||||
# Both derive from BATS_RUN_TMPDIR, whose basename is the per-run
|
||||
# bats-run-XXXXXX.
|
||||
local n
|
||||
n=$(grep -cE '^(container|network)_name\(\).*BATS_RUN_TMPDIR' "$rt")
|
||||
[ "$n" -eq 2 ] || { echo "expected 2 helpers keyed on BATS_RUN_TMPDIR, found $n"; return 1; }
|
||||
run grep -nE '^(container|network)_name\(\).*BATS_FILE_TMPDIR' "$rt"
|
||||
[ "$status" -ne 0 ] || {
|
||||
echo "a name helper is back on BATS_FILE_TMPDIR, whose basename is always 0:" >&2
|
||||
echo "$output" >&2
|
||||
return 1
|
||||
}
|
||||
# And the property itself, rather than only the variable it comes from: two
|
||||
# runs of the same helper text must not agree.
|
||||
local probe first second
|
||||
probe="$(mktemp "$BATS_TEST_TMPDIR/probe.XXXXXX.bats")"
|
||||
{
|
||||
grep -E '^network_name\(\)' "$rt"
|
||||
echo '@test "n" { echo "NAME=$(network_name)" >&3; }'
|
||||
} > "$probe"
|
||||
first="$(bats "$probe" 2>&1 | sed -n 's/.*NAME=//p')"
|
||||
second="$(bats "$probe" 2>&1 | sed -n 's/.*NAME=//p')"
|
||||
[ -n "$first" ] || { echo "the probe produced no name"; return 1; }
|
||||
[ "$first" != "$second" ] || {
|
||||
echo "two separate runs produced the same network name: $first" >&2
|
||||
echo "a leftover from one run would collide with the next" >&2
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
# The sweep in setup_file runs between `container=$(container_name)` and the
|
||||
# `docker run --name "$container"` that uses it. A loop variable named
|
||||
# `container` there leaves $container holding the last endpoint the sweep saw,
|
||||
# and every container this file starts is then named after someone else's
|
||||
# leftover - a failure that appears only once there is something to reap,
|
||||
# which is to say only when the sweep matters.
|
||||
#
|
||||
# Run against a fake docker that reports a stale attached network, because
|
||||
# that is the branch where the assignment happens; a clean host never enters
|
||||
# it and the bug stays invisible.
|
||||
@test "runtime.bats' cleanup sweep does not clobber the names setup_file built" {
|
||||
local rt="$REPO_ROOT/test/runtime.bats" d
|
||||
d="$BATS_TEST_TMPDIR/sweep"
|
||||
mkdir -p "$d/stub"
|
||||
cat > "$d/stub/docker" <<'STUB'
|
||||
#!/bin/bash
|
||||
case "$1 $2" in
|
||||
"network ls") echo dejima-test-net-abandoned ;;
|
||||
"network rm") exit 1 ;;
|
||||
"network inspect")
|
||||
case "$*" in
|
||||
*Created*) echo "2020-01-01T00:00:00Z" ;;
|
||||
*Containers*) printf 'dejima-test-abandoned\n' ;;
|
||||
esac ;;
|
||||
"inspect -f") echo "2020-01-01T00:00:00Z" ;;
|
||||
"ps -a") echo dejima-test-abandoned ;;
|
||||
esac
|
||||
exit 0
|
||||
STUB
|
||||
chmod +x "$d/stub/docker"
|
||||
|
||||
# The sweep exactly as it appears in setup_file, with the surrounding
|
||||
# values it depends on supplied.
|
||||
sed -n '/^ local _stale _age _endpoint _dead _created$/,/--format .{{.Names}}. 2>\/dev\/null)$/p' \
|
||||
"$rt" | sed 's/^ //' > "$d/sweep.sh"
|
||||
[ -s "$d/sweep.sh" ] || { echo "could not extract the sweep from setup_file"; return 1; }
|
||||
|
||||
run env PATH="$d/stub:$PATH" bash -c '
|
||||
container="the-name-setup_file-built"
|
||||
network="dejima-test-net-mine"
|
||||
REAP_AFTER=1
|
||||
source "$1"
|
||||
printf "container=%s\n" "$container"
|
||||
printf "network=%s\n" "$network"' _ "$d/sweep.sh"
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
[[ "$output" == *"container=the-name-setup_file-built"* ]] || {
|
||||
echo "the sweep clobbered \$container:" >&2; echo "$output" >&2; return 1; }
|
||||
[[ "$output" == *"network=dejima-test-net-mine"* ]] || {
|
||||
echo "the sweep clobbered \$network:" >&2; echo "$output" >&2; return 1; }
|
||||
}
|
||||
|
||||
# A network left behind by a killed run keeps its container attached, so
|
||||
# `docker network rm` cannot remove it and `docker network create` then fails
|
||||
# on the name - on a long-lived privileged runner, for every run afterwards.
|
||||
@test "runtime.bats clears a stale network of its own before creating one" {
|
||||
local rt="$REPO_ROOT/test/runtime.bats"
|
||||
# Scoped to this suite's own prefix: it may clear its namespace and
|
||||
# nothing else.
|
||||
grep -q 'name=\^dejima-test-net-' "$rt" \
|
||||
|| { echo "the sweep is not scoped to the dejima-test-net- prefix"; return 1; }
|
||||
# And it must come before the create, or it clears nothing in time.
|
||||
local sweep create
|
||||
sweep=$(grep -n 'name=\^dejima-test-net-' "$rt" | head -1 | cut -d: -f1)
|
||||
create=$(grep -n 'docker network create' "$rt" | head -1 | cut -d: -f1)
|
||||
[ "$sweep" -lt "$create" ] \
|
||||
|| { echo "the sweep at line $sweep runs after the create at line $create"; return 1; }
|
||||
# A network still attached is either this suite's abandoned leftover or a
|
||||
# concurrent run's live one, and nothing distinguishes them by inspection.
|
||||
# Age does, so the reap has to be guarded by it - without that, a run on
|
||||
# the same host gets its container pulled out from under it.
|
||||
grep -q 'REAP_AFTER' "$rt" \
|
||||
|| { echo "attached networks are reaped with no age guard"; return 1; }
|
||||
# And the guard has to be longer than a real run of this file.
|
||||
local reap
|
||||
reap=$(sed -n 's/^REAP_AFTER=\$((\(.*\)))$/\1/p' "$rt")
|
||||
[ -n "$reap" ] || { echo "REAP_AFTER is not a plain arithmetic constant"; return 1; }
|
||||
[ "$((reap))" -ge 3600 ] \
|
||||
|| { echo "REAP_AFTER is $((reap))s, short enough to hit a live run"; return 1; }
|
||||
}
|
||||
|
||||
@test "every container runtime.bats starts is attached to a user-defined network" {
|
||||
# On the default bridge there's no 127.0.0.11, so dns-proxy.sh aborts and
|
||||
# the container dies mid-startup - which used to surface as flaky,
|
||||
@@ -2073,7 +2317,7 @@ this line is not json'
|
||||
# anywhere in the file" check just as well, without actually applying to
|
||||
# this build.
|
||||
local build_line
|
||||
build_line=$(grep -n 'docker compose -f "\$COMPOSE_FILE" build claude' "$REPO_ROOT/bin/dejima" | cut -d: -f1)
|
||||
build_line=$(grep -n 'docker compose -f "\$COMPOSE_FILE" build sandbox' "$REPO_ROOT/bin/dejima" | cut -d: -f1)
|
||||
[ -n "$build_line" ]
|
||||
run sed -n "$((build_line - 1))p" "$REPO_ROOT/bin/dejima"
|
||||
[[ "$output" == 'SANDBOX_IMAGE="dejima:${SANDBOX_USER}" \' ]]
|
||||
@@ -2359,6 +2603,24 @@ EOF
|
||||
grep -q 'not a verdict on who caused them' "$REPO_ROOT/README.md"
|
||||
}
|
||||
|
||||
# Was: that the cross-session false-positive case is documented. The finding
|
||||
# itself is gone - a session can no longer write another project's slug, so
|
||||
# every one it could still see belonged to some other session - and what
|
||||
# needs documenting now is its absence, so that removing detection stays a
|
||||
# stated decision rather than something a reader has to infer from silence.
|
||||
@test "the credential proxy is documented under its own heading" {
|
||||
# It sits under its own heading rather than as a bullet inside the
|
||||
# 500-line "The exit-time audit" section, which is not where anyone looks
|
||||
# for how the model credential is kept out of the container.
|
||||
local sec
|
||||
sec="$(sed -n '/^## The model API credential/,/^## /p' "$REPO_ROOT/docs/security.md")"
|
||||
grep -q 'dejima-anthropic-proxy' <<< "$sec" \
|
||||
|| { echo "the proxy is not documented under its own heading"; return 1; }
|
||||
sec="$(sed -n '/^## The exit-time audit/,/^## /p' "$REPO_ROOT/docs/security.md")"
|
||||
run grep -c 'dejima-anthropic-proxy' <<< "$sec"
|
||||
[ "$output" -eq 0 ] || { echo "the audit section has absorbed the proxy again"; return 1; }
|
||||
}
|
||||
|
||||
# The absence of a finding needs documenting as much as a finding does, so
|
||||
# that not detecting something stays a stated decision rather than something
|
||||
# a reader has to infer from silence.
|
||||
@@ -2379,6 +2641,56 @@ EOF
|
||||
done
|
||||
}
|
||||
|
||||
# The egress allowlist is the control this repository leans on hardest in
|
||||
# prose - it is named as what bounds where a prompt injection could fetch its
|
||||
# next instruction from. That is true of what the container fetches and not of
|
||||
# a harness tool executed elsewhere, so both documents have to keep saying so.
|
||||
# A reader who takes the allowlist as the boundary and is not corrected has
|
||||
# been misled by us, not by the harness.
|
||||
@test "both documents say the egress allowlist does not bound a harness's own tools" {
|
||||
local sec="$REPO_ROOT/docs/security.md" tm="$REPO_ROOT/docs/threat-model.md"
|
||||
grep -q 'The firewall bounds the container, not the harness' "$sec" \
|
||||
|| { echo "security.md drops the limit of the allowlist"; return 1; }
|
||||
# The evidence, not just the assertion: a reader has to be able to see why
|
||||
# this is a fact about the setup rather than a worry about it.
|
||||
grep -q 'Could not resolve host: aider.chat' "$sec" \
|
||||
|| { echo "security.md asserts it without the observation behind it"; return 1; }
|
||||
grep -q 'egress allowlist bounds what reaches the model' "$tm" \
|
||||
|| { echo "the threat model does not disclaim it"; return 1; }
|
||||
# And that it is framed per harness rather than as one harness's quirk,
|
||||
# since the next harness has to be asked the same question.
|
||||
grep -q 'per-harness question' "$tm" \
|
||||
|| { echo "the threat model reads as a Claude Code quirk"; return 1; }
|
||||
}
|
||||
|
||||
@test "docs/threat-model.md exists and is linked from the README and docs/security.md" {
|
||||
[ -f "$REPO_ROOT/docs/threat-model.md" ]
|
||||
grep -q 'docs/threat-model\.md' "$REPO_ROOT/README.md"
|
||||
grep -q '(threat-model\.md)' "$REPO_ROOT/docs/security.md"
|
||||
}
|
||||
|
||||
@test "the blast-radius table is identical in both places that carry it" {
|
||||
# It is deliberately in two documents: security.md is where an operator
|
||||
# already is, threat-model.md is where the reasoning around it lives.
|
||||
# Two copies drift, and a table stating where a compromise stops is the
|
||||
# worst kind to have two answers for - so neither may be edited alone.
|
||||
local a b
|
||||
a="$(sed -n '/^| Layer | Reached by/,/^$/p' "$REPO_ROOT/docs/security.md")"
|
||||
b="$(sed -n '/^| Layer | Reached by/,/^$/p' "$REPO_ROOT/docs/threat-model.md")"
|
||||
[ -n "$a" ] || { echo "no table in docs/security.md"; return 1; }
|
||||
[ -n "$b" ] || { echo "no table in docs/threat-model.md"; return 1; }
|
||||
[ "$a" = "$b" ] || { diff <(printf '%s' "$a") <(printf '%s' "$b"); return 1; }
|
||||
# Every layer the two sister projects cover, so a row cannot quietly go
|
||||
# missing and leave the chain looking shorter than it is.
|
||||
local row
|
||||
for row in 'Agent, not yet hostile' 'Container' \
|
||||
'Devbox VM, unprivileged' 'Devbox VM, root' \
|
||||
'CI, confined job' 'CI, confined job escaped' \
|
||||
'CI, privileged job'; do
|
||||
grep -qF "| $row |" <<< "$b" || { echo "no row for: $row"; return 1; }
|
||||
done
|
||||
}
|
||||
|
||||
@test "core never reads a harness's config by a literal harness name" {
|
||||
# `keys` is declared per harness, so a fixed [harness.<name>] at a call
|
||||
# site makes it a declaration only one harness can ever be read through:
|
||||
@@ -2442,6 +2754,22 @@ EOF
|
||||
# its maintainer's, and saying so is the difference between disclosure and
|
||||
# letting a reader assume. Pinned because it is the kind of line that goes
|
||||
# quietly in a tidy-up, and its absence is not visible from anything else.
|
||||
@test "the README frames dejima as a development environment, not only a sandbox" {
|
||||
grep -q 'sandboxed development environment' "$REPO_ROOT/README.md"
|
||||
# The half that is not containment has to be visible as such. A reader
|
||||
# who is shown only the boundary learns what dejima stops and not what
|
||||
# it is for.
|
||||
grep -q 'What it gives an agent to work with' "$REPO_ROOT/README.md"
|
||||
}
|
||||
|
||||
@test "the README does not promise a default harness" {
|
||||
# `[core] harness` has had no default since it became required - a launch
|
||||
# is refused without it - but the opening paragraph still read "Claude
|
||||
# Code by default", which is the promise that change exists to not make.
|
||||
run grep -n 'Claude Code by default' "$REPO_ROOT/README.md"
|
||||
[ "$status" -ne 0 ] || { echo "$output"; return 1; }
|
||||
}
|
||||
|
||||
@test "the README says the prose is written by agents" {
|
||||
grep -q 'A note from the developer' "$REPO_ROOT/README.md"
|
||||
grep -q 'the words are written rapidly' "$REPO_ROOT/README.md"
|
||||
@@ -2923,16 +3251,19 @@ q
|
||||
grep -q ' run --rm ' "$DEJIMA_T/docker-log"
|
||||
}
|
||||
|
||||
@test "entrypoint terminates TLS for api.anthropic.com on loopback by default" {
|
||||
@test "entrypoint terminates TLS on loopback for the proxied domain by default" {
|
||||
# The transport is normalized in exactly one place so the forwarder, the
|
||||
# cert generation, and the readiness probe cannot disagree about which
|
||||
# mode this session is in.
|
||||
grep -q 'SANDBOX_ANTHROPIC_TRANSPORT="\${SANDBOX_ANTHROPIC_TRANSPORT:-tls}"' "$SANDBOX_DIR/entrypoint.sh"
|
||||
# CN alone is not enough - Node verifies the SAN.
|
||||
grep -q 'subjectAltName=DNS:api.anthropic.com' "$SANDBOX_DIR/entrypoint.sh"
|
||||
grep -q 'CN=api.anthropic.com' "$SANDBOX_DIR/entrypoint.sh"
|
||||
# Which name it answers for is the selected harness's, so these assert the
|
||||
# shape rather than one harness's API hostname - see "the TLS terminator
|
||||
# answers for the harness's declared domain".
|
||||
# CN alone is not enough: Node verifies the SAN.
|
||||
grep -q 'subjectAltName=DNS:\$proxied_domain' "$SANDBOX_DIR/entrypoint.sh"
|
||||
grep -q 'CN=\$proxied_domain' "$SANDBOX_DIR/entrypoint.sh"
|
||||
grep -q 'NODE_EXTRA_CA_CERTS' "$SANDBOX_DIR/entrypoint.sh"
|
||||
grep -q '127.0.0.1 api.anthropic.com' "$SANDBOX_DIR/entrypoint.sh"
|
||||
grep -q '127.0.0.1 \$proxied_domain' "$SANDBOX_DIR/entrypoint.sh"
|
||||
}
|
||||
|
||||
@test "entrypoint aborts when the forwarder never binds" {
|
||||
@@ -2950,7 +3281,8 @@ q
|
||||
local guard body
|
||||
guard=$(grep -n 'if \[ -n "\${SANDBOX_ANTHROPIC_PROXY_SOCKET:-}" \]' "$SANDBOX_DIR/entrypoint.sh" | cut -d: -f1)
|
||||
[ -n "$guard" ]
|
||||
body=$(grep -n '127.0.0.1 api.anthropic.com' "$SANDBOX_DIR/entrypoint.sh" | cut -d: -f1)
|
||||
body=$(grep -n '127.0.0.1 \$proxied_domain' "$SANDBOX_DIR/entrypoint.sh" | cut -d: -f1)
|
||||
[ -n "$body" ] || { echo "no hosts entry at all"; return 1; }
|
||||
[ "$body" -gt "$guard" ]
|
||||
}
|
||||
|
||||
@@ -3344,6 +3676,31 @@ _harness_proxied_domain() {
|
||||
[ "$status" -ne 0 ]
|
||||
}
|
||||
|
||||
@test "the docs say the harness must be signed in on the host first" {
|
||||
# dejima authenticates nothing: the proxy holds a credential the harness
|
||||
# wrote on the host. Without one, a session starts normally and then
|
||||
# cannot reach the model, which reads as a dejima fault. Nothing enforces
|
||||
# the prerequisite, so it has to be stated where someone setting up will
|
||||
# meet it.
|
||||
grep -q '^## Sign in on the host first' "$REPO_ROOT/README.md"
|
||||
sed -n '/^### Core (required)/,/^That is enough to run/p' "$REPO_ROOT/README.md" \
|
||||
| grep -qi 'sign in to it there'
|
||||
grep -q 'Sign in on the host first' "$REPO_ROOT/docs/security.md"
|
||||
}
|
||||
|
||||
@test "every statement that an escape defeats everything says what bounds the next layer" {
|
||||
# True as far as it goes, and it used to stop there. What an escape
|
||||
# reaches is a layer dejima cannot bound because it runs inside it, and
|
||||
# a reader left at "defeats all of it" has no idea there is a project
|
||||
# for exactly that.
|
||||
local f
|
||||
for f in README.md SECURITY.md docs/security.md docs/responsible-use.md; do
|
||||
grep -qi 'escape' "$REPO_ROOT/$f" || continue
|
||||
grep -q 'dejima-devbox-hardener' "$REPO_ROOT/$f" \
|
||||
|| { echo "$f describes an escape without naming what bounds it"; return 1; }
|
||||
done
|
||||
}
|
||||
|
||||
# --- harness plugins: a plugin computes, core enforces ---
|
||||
|
||||
@test "no harness plugin touches the mount list directly" {
|
||||
@@ -3700,3 +4057,350 @@ _emit_mount_at() {
|
||||
grep -qE "charAt\(0\)\.toUpperCase" "$REPO_ROOT/web/src/keys.js"
|
||||
grep -q 'Ss.pace' "$REPO_ROOT/bin/dejima-config-lib.sh"
|
||||
}
|
||||
|
||||
@test "every supported harness is available to a session working on dejima" {
|
||||
# A harness dejima claims to support and cannot run here is one whose
|
||||
# guards are asserted rather than observed. claude-code is installed in
|
||||
# the image; the rest are declared as dependencies of this project.
|
||||
local d name
|
||||
for d in "$REPO_ROOT"/harnesses/*/; do
|
||||
[ -d "$d" ] || continue
|
||||
name="$(basename "$d")"
|
||||
[ "$name" = "claude-code" ] && continue
|
||||
grep -qE "^[a-z]+:.*$name" "$REPO_ROOT/.dejima/dependencies" \
|
||||
|| { echo "$name is supported but not declared in .dejima/dependencies"; return 1; }
|
||||
done
|
||||
}
|
||||
|
||||
# The base image is shared by every project on the host. A harness baked into
|
||||
# it is carried by every sandbox whether or not it is the one selected, and a
|
||||
# second harness could only ever be added alongside the first rather than
|
||||
# chosen between.
|
||||
@test "the base image installs no harness" {
|
||||
run grep -nE 'npm install -g .*(claude-code|opencode|aider)' "$SANDBOX_DIR/Dockerfile"
|
||||
[ "$status" -ne 0 ] || { echo "the Dockerfile still bakes in a harness:"; echo "$output"; return 1; }
|
||||
# And no build arg pinning one, which is the shape it had.
|
||||
run grep -nE '^ARG .*(CLAUDE|HARNESS).*VERSION' "$SANDBOX_DIR/Dockerfile"
|
||||
[ "$status" -ne 0 ] || { echo "$output"; return 1; }
|
||||
# The Dockerfile says where the harness went, so the absence reads as a
|
||||
# decision rather than as something dropped.
|
||||
grep -qi 'harness' "$SANDBOX_DIR/Dockerfile"
|
||||
}
|
||||
|
||||
@test "the harness layer is keyed on the harness, and built under the dependency layers" {
|
||||
local f="$REPO_ROOT/bin/dejima"
|
||||
# Keyed on the name: switching [core] harness has to switch images, not
|
||||
# add to one.
|
||||
grep -q 'dejima:${SANDBOX_USER}-harness-${DEJIMA_HARNESS_NAME}' "$f" \
|
||||
|| { echo "the harness layer's tag does not name the harness"; return 1; }
|
||||
# Below the dependency layers, so a project's own packages sit on top of
|
||||
# the harness rather than being invalidated whenever it changes.
|
||||
local harness_at global_at
|
||||
harness_at=$(grep -n 'SANDBOX_USER}-harness-' "$f" | head -1 | cut -d: -f1)
|
||||
global_at=$(grep -n 'build_dep_layer "dejima:${SANDBOX_USER}-global"' "$f" | head -1 | cut -d: -f1)
|
||||
[ -n "$harness_at" ] && [ -n "$global_at" ] || { echo "could not locate both layers"; return 1; }
|
||||
[ "$harness_at" -lt "$global_at" ] \
|
||||
|| { echo "the harness layer is built after the dependency layers"; return 1; }
|
||||
}
|
||||
|
||||
@test "the harness install spec is validated before it reaches a RUN line" {
|
||||
# It is manifest data and the manifest is dejima's own, but the provider
|
||||
# directories are session-writable when dejima sandboxes its own checkout,
|
||||
# and this string is interpolated into a Dockerfile built on the host.
|
||||
#
|
||||
# The schema types it, so every read goes through the validator - which is
|
||||
# why bin/dejima does not check it a second time. Pinned as the type,
|
||||
# since a manifest key that quietly became a bare string would still read
|
||||
# correctly at every call site.
|
||||
run bash -c 'source "$1"; _manifest_type harness install' _ \
|
||||
"$REPO_ROOT/bin/dejima-provider-lib.sh"
|
||||
[ "$status" -eq 0 ] || { echo "the harness schema has no install key"; return 1; }
|
||||
[ "$output" = "?depspec" ] \
|
||||
|| { echo "install is typed '$output', not a dependency spec"; return 1; }
|
||||
# And that the type actually delegates to the shared validator, rather
|
||||
# than growing a second grammar that could drift from the approval one.
|
||||
grep -A 6 'depspec)' "$REPO_ROOT/bin/dejima-provider-lib.sh" | grep -q 'valid_dep_spec' \
|
||||
|| { echo "the depspec type does not use valid_dep_spec"; return 1; }
|
||||
}
|
||||
|
||||
# docs/development.md's inventory is the document a second harness is built
|
||||
# from, so a row that has stopped being true is worse than a missing one: it
|
||||
# sends someone to a file to change something that is not there. These pin the
|
||||
# claims that this branch made false once already.
|
||||
@test "the harness inventory does not describe an install the Dockerfile stopped doing" {
|
||||
local doc="$REPO_ROOT/docs/development.md"
|
||||
# The inventory named a RUN line and a build arg, both gone.
|
||||
run grep -nE 'npm install -g .*claude-code|CLAUDE_CODE_VERSION' "$doc"
|
||||
[ "$status" -ne 0 ] || { echo "the inventory describes an install that is gone:"; echo "$output"; return 1; }
|
||||
# And the file list said the base image carries the harness.
|
||||
run grep -nE '^\| `docker/Dockerfile` \|.*[Cc]laude' "$doc"
|
||||
[ "$status" -ne 0 ] || { echo "the file list still puts a harness in the base image:"; echo "$output"; return 1; }
|
||||
}
|
||||
|
||||
@test "every Claude Code path the base image still names has an inventory row" {
|
||||
# The inventory claims to be complete. Two things in the Dockerfile are
|
||||
# written against this harness by name; if a third appears, it needs a row
|
||||
# rather than passing unnoticed because the table looks thorough.
|
||||
local doc="$REPO_ROOT/docs/development.md"
|
||||
local dockerfile="$SANDBOX_DIR/Dockerfile"
|
||||
# Lines of actual instruction, not comments.
|
||||
run grep -nE '^[^#]*(\.claude|CLAUDE_CODE)' "$dockerfile"
|
||||
[ "$status" -ne 0 ] \
|
||||
|| { echo "the base image is back to naming a harness:"; echo "$output"; return 1; }
|
||||
# A row for each, naming where it is satisfied now.
|
||||
grep -q 'State directories' "$doc" || { echo "no inventory row for the state directories"; return 1; }
|
||||
grep -q 'Image environment' "$doc" || { echo "no inventory row for the image environment"; return 1; }
|
||||
grep -q 'Proxied endpoint' "$doc" || { echo "no inventory row for the proxied domain"; return 1; }
|
||||
}
|
||||
|
||||
@test "every file the docs' file list names exists, and the libraries are all in it" {
|
||||
local doc="$REPO_ROOT/docs/development.md" f missing=""
|
||||
# The Files table only - the manifest key tables further down use the same
|
||||
# row shape for things that are not paths.
|
||||
while IFS= read -r f; do
|
||||
case "$f" in */|*'<'*|*'*'*) continue ;; esac
|
||||
[ -e "$REPO_ROOT/$f" ] || missing="$missing $f"
|
||||
done < <(sed -n '/^## Files/,/^## /p' "$doc" | grep -oP '(?<=^\| `)[^`]+')
|
||||
[ -z "$missing" ] || { echo "the file list names files that do not exist:$missing"; return 1; }
|
||||
# And the other direction for the shared libraries, which are the entries
|
||||
# most easily forgotten because nothing puts them on PATH.
|
||||
for f in bin/dejima-config-lib.sh bin/dejima-color-lib.sh bin/dejima-provider-lib.sh \
|
||||
bin/dejima-harness-lib.sh docker/install-forge-bins.sh; do
|
||||
grep -q "\`$f\`" "$doc" || { echo "$f is shipped but not in the file list"; return 1; }
|
||||
done
|
||||
}
|
||||
|
||||
# The base image is shared by every harness. Anything in it that only one
|
||||
# harness needs is that harness imposed on every image dejima builds, and
|
||||
# makes "add a second harness" mean "add to the first one's image".
|
||||
@test "the base image names no harness's state directories or environment" {
|
||||
local d="$SANDBOX_DIR/Dockerfile"
|
||||
# Instruction lines only: a comment may name a harness as an example of a
|
||||
# general rule, and several do.
|
||||
run grep -nE '^[^#]*(\.claude|CLAUDE_CODE|\.aider|\.opencode)' "$d"
|
||||
[ "$status" -ne 0 ] || { echo "the base image names a harness's paths:"; echo "$output"; return 1; }
|
||||
# .ssh still is the base image's own, so the mkdir it shares has not
|
||||
# simply been deleted along with them.
|
||||
grep -qE '^RUN mkdir -p \$SANDBOX_HOME/\.ssh' "$d"
|
||||
}
|
||||
|
||||
@test "nothing shared names one harness as the command to run" {
|
||||
# A default here decides for every harness, and names a binary an image
|
||||
# built for a different one does not contain.
|
||||
run grep -nE 'SANDBOX_CMD:-[a-z]' "$SANDBOX_DIR/entrypoint.sh" "$SANDBOX_DIR/docker-compose.yml"
|
||||
[ "$status" -ne 0 ] || { echo "a harness is the default command:"; echo "$output"; return 1; }
|
||||
# Refused rather than defaulted, so an unset one says why.
|
||||
grep -q 'SANDBOX_CMD is unset' "$SANDBOX_DIR/entrypoint.sh"
|
||||
}
|
||||
|
||||
@test "the compose service and its shared volumes are named for dejima, not a harness" {
|
||||
local y="$SANDBOX_DIR/docker-compose.yml"
|
||||
run grep -nE '^\s{2}claude:|claude-code-[a-z]+:' "$y"
|
||||
[ "$status" -ne 0 ] || { echo "a harness names the service or a volume:"; echo "$output"; return 1; }
|
||||
grep -qE '^ sandbox:' "$y"
|
||||
# And bin/dejima refers to the service by the same name in both places.
|
||||
[ "$(grep -c 'compose -f "$COMPOSE_FILE" \(build\|run\).* sandbox' "$REPO_ROOT/bin/dejima")" -eq 2 ] \
|
||||
|| { echo "bin/dejima does not use the service name in both places"; return 1; }
|
||||
}
|
||||
|
||||
@test "the TLS terminator answers for the harness's declared domain, not a literal" {
|
||||
# The manifest declares proxied_api_domain; the entrypoint used the name
|
||||
# of one harness's API in the cert, the SAN and the /etc/hosts pin.
|
||||
local e="$SANDBOX_DIR/entrypoint.sh"
|
||||
run grep -nE '(CN=|DNS:|127\.0\.0\.1 )api\.anthropic\.com' "$e"
|
||||
[ "$status" -ne 0 ] || { echo "the terminator is pinned to one harness's API:"; echo "$output"; return 1; }
|
||||
grep -q 'SANDBOX_HARNESS_PROXIED_DOMAIN' "$e" \
|
||||
|| { echo "the entrypoint does not read the declared domain"; return 1; }
|
||||
# And a harness that declares a proxy but no domain is refused, rather
|
||||
# than bringing up a terminator that intercepts nothing.
|
||||
grep -q 'declares no proxied_api_domain' "$e" \
|
||||
|| { echo "an empty proxied domain is not refused"; return 1; }
|
||||
}
|
||||
|
||||
@test "a harness's image needs are declared in its manifest" {
|
||||
local m="$REPO_ROOT/harnesses/claude-code/harness.conf"
|
||||
grep -qE '^image_dirs\s*=.*\.claude' "$m" || { echo "no image_dirs declared"; return 1; }
|
||||
grep -qE '^image_env\s*=.*CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC' "$m" \
|
||||
|| { echo "no image_env declared"; return 1; }
|
||||
}
|
||||
|
||||
# entrypoint.sh runs in every container this repo builds, including the base
|
||||
# image on its own - runtime.bats boots exactly that, with no harness layer
|
||||
# above it. So a path only one harness has is not merely a coupling here, it
|
||||
# is a container that aborts during startup on any image whose harness never
|
||||
# declared the directory that path lives in. That is what happened: the
|
||||
# credential seeding outlived the ~/.claude the base image used to create.
|
||||
#
|
||||
# Only the privileged tier caught it, because only that tier boots a
|
||||
# container. This is the same check without a Docker daemon.
|
||||
@test "the shared entrypoint names no harness's paths" {
|
||||
local e="$SANDBOX_DIR/entrypoint.sh"
|
||||
run grep -nE '^[^#]*(\$SANDBOX_HOME|\$HOME|~)/\.(claude|aider|opencode)' "$e"
|
||||
[ "$status" -ne 0 ] || {
|
||||
echo "entrypoint.sh reaches into one harness's home directory:" >&2
|
||||
echo "$output" >&2
|
||||
echo "It runs in every image, including the base one on its own." >&2
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
@test "a file a harness needs seeded is declared, not created by shared code" {
|
||||
# An absent file and an empty one are not reliably the same to a harness
|
||||
# that reads a credential from a fixed path, so something has to be
|
||||
# there - declared by the harness that needs it, created in its own
|
||||
# layer, and shadowed by whatever the transport mounts over it.
|
||||
local m="$REPO_ROOT/harnesses/claude-code/harness.conf"
|
||||
grep -qE '^image_files\s*=.*\.credentials\.json' "$m" \
|
||||
|| { echo "the credential file is not declared"; return 1; }
|
||||
run bash -c 'source "$1"; generate_spec_dockerfile base "" "" "" ".claude/.credentials.json"' _ \
|
||||
"$REPO_ROOT/bin/dejima-project-lib.sh"
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
# Empty and 0600: it stands in for a credential, so it must not be
|
||||
# readable by anything but the session's own user.
|
||||
[[ "$output" == *'install -o ${SANDBOX_USER} -g ${SANDBOX_USER} -m 600 /dev/null'* ]] \
|
||||
|| { echo "$output"; return 1; }
|
||||
}
|
||||
|
||||
# The confined tier cannot boot a container, so it cannot see a container
|
||||
# aborting at startup. It CAN see the cross-file invariant behind one, which
|
||||
# is what this is: entrypoint.sh runs in the base image with nothing above it,
|
||||
# so every literal path it writes under the sandbox user's home has to be
|
||||
# created by the Dockerfile, or by the entrypoint itself before use.
|
||||
#
|
||||
# The credential seeding was neither. It wrote into a ~/.claude the Dockerfile
|
||||
# used to create, that moved to the harness's own layer, and every container
|
||||
# on an image whose harness had not declared it then died in startup. Only the
|
||||
# privileged tier caught it, hours later; this is the same question asked of
|
||||
# the files instead of a running container.
|
||||
@test "every home path the entrypoint writes is created by the image or by itself" {
|
||||
local e="$SANDBOX_DIR/entrypoint.sh" d="$SANDBOX_DIR/Dockerfile"
|
||||
local p top missing="" checked=0
|
||||
while IFS= read -r p; do
|
||||
[ -n "$p" ] || continue
|
||||
top="${p%%/*}"
|
||||
checked=$((checked + 1))
|
||||
# Created in the base image.
|
||||
grep -qE "mkdir -p .*\\\$SANDBOX_HOME/$top" "$d" && continue
|
||||
missing="$missing $top"
|
||||
done < <(grep -oP '(?<=\$SANDBOX_HOME/)[A-Za-z0-9._][A-Za-z0-9._/-]*' "$e" | sort -u)
|
||||
[ "$checked" -gt 0 ] \
|
||||
|| { echo "no literal home paths found - has the extraction stopped working?"; return 1; }
|
||||
[ -z "$missing" ] || {
|
||||
echo "entrypoint.sh writes under home directories nothing creates:$missing" >&2
|
||||
echo "It runs in the base image alone, so a directory a harness layer" >&2
|
||||
echo "adds is not there. Create it in the Dockerfile, or declare it" >&2
|
||||
echo "and write the file from the harness's own layer." >&2
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
# runtime.bats is the only suite that boots containers, so its fixtures are
|
||||
# the only place a container-shaped mistake can hide until the privileged
|
||||
# tier runs - twenty minutes and a queue behind every other job. These three
|
||||
# ask the questions that tier answered the hard way.
|
||||
@test "runtime.bats names every container after the run, not the file index" {
|
||||
# basename "$BATS_FILE_TMPDIR" is the file's index within the run, which
|
||||
# is "0" for the only file there - so a name built from it is the same in
|
||||
# every run. `docker run` refuses a name in use, so one leftover from a
|
||||
# failed run fails every run afterwards until someone clears it by hand.
|
||||
# That is exactly what happened, to a container the earlier fix did not
|
||||
# cover: it changed the two helpers and left twenty per-test names alone.
|
||||
local rt="$REPO_ROOT/test/runtime.bats"
|
||||
run grep -nE 'dejima-test-[a-z-]*\$?\(?basename "\$BATS_FILE_TMPDIR"' "$rt"
|
||||
[ "$status" -ne 0 ] || {
|
||||
echo "a container or image name is keyed on the file index:" >&2
|
||||
echo "$output" >&2
|
||||
return 1
|
||||
}
|
||||
grep -q '^test_container_name() {' "$rt" \
|
||||
|| { echo "there is no one place per-test names come from"; return 1; }
|
||||
}
|
||||
|
||||
@test "runtime.bats removes what it made by deriving it, not by listing it" {
|
||||
# The teardown enumerated twenty names by hand and had to be kept in step
|
||||
# with twenty test bodies. It silently stopped matching all of them the
|
||||
# moment the names changed, which is a leak that fills a runner and then
|
||||
# fails the next run on a conflict.
|
||||
local rt="$REPO_ROOT/test/runtime.bats"
|
||||
# The per-test teardown, which is where the twenty-name list lived.
|
||||
local body
|
||||
body="$(sed -n '/^teardown() {/,/^}/p' "$rt")"
|
||||
[ -n "$body" ] || { echo "no teardown"; return 1; }
|
||||
grep -q 'docker ps -a --filter' <<< "$body" \
|
||||
|| { echo "teardown does not derive the containers it removes"; return 1; }
|
||||
local listed
|
||||
listed=$(grep -c 'dejima-test-[a-z-]\+-\$' <<< "$body" || true)
|
||||
[ "$listed" -eq 0 ] \
|
||||
|| { echo "teardown still names $listed containers by hand"; return 1; }
|
||||
# It runs after every test and the file-wide container has to survive all
|
||||
# of them, so the sweep must exclude it explicitly rather than by luck.
|
||||
grep -q 'keep="$(container_name)"' <<< "$body" \
|
||||
|| { echo "the per-test sweep would remove the file-wide container"; return 1; }
|
||||
}
|
||||
|
||||
@test "every runtime.bats container that mounts a proxy socket declares the domain" {
|
||||
# entrypoint.sh refuses a credential proxy with no proxied_api_domain,
|
||||
# because a terminator answering for nothing would let the session reach
|
||||
# the real API directly. A fixture that mounts the socket without the
|
||||
# domain is not testing the proxy, it is testing that refusal - which is
|
||||
# how two of these failed.
|
||||
local rt="$REPO_ROOT/test/runtime.bats" sock dom
|
||||
sock=$(grep -c 'SANDBOX_ANTHROPIC_PROXY_SOCKET=.*proxy\.sock' "$rt")
|
||||
dom=$(grep -c 'SANDBOX_HARNESS_PROXIED_DOMAIN=' "$rt")
|
||||
[ "$sock" -gt 0 ] || { echo "no proxy fixtures found - has this moved?"; return 1; }
|
||||
# One of the domain lines is the file-wide fixture, which mounts no
|
||||
# socket, so the domains must outnumber the sockets that go through a
|
||||
# container. The one socket that does not is the host-side forwarder,
|
||||
# which never reaches entrypoint.sh.
|
||||
[ "$dom" -ge "$sock" ] || {
|
||||
echo "$sock proxy sockets but only $dom declared domains - a container" >&2
|
||||
echo "mounting the socket without the domain is refused at startup." >&2
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
# bats runs setup_file in one process and every test in another, and does not
|
||||
# carry a plain assignment from the first into the second. So a fixture
|
||||
# variable is set in setup_file and EMPTY everywhere a test can reach - which
|
||||
# is not an error, just a wrong answer, and the wrong answer here was an empty
|
||||
# proxied domain that made two containers refuse to start.
|
||||
#
|
||||
# The rule that avoids it: anything a test body can reach derives its own
|
||||
# paths from BATS_TEST_DIRNAME, which bats exports. setup_file's variables
|
||||
# stay inside setup_file.
|
||||
@test "runtime.bats' fixture variables do not escape setup_file" {
|
||||
local rt="$REPO_ROOT/test/runtime.bats" v outside start end
|
||||
# setup_file's line range, so "inside" is decided by position rather than
|
||||
# by matching its text - a body with a blank line in it makes any
|
||||
# fixed-string filter match every line in the file.
|
||||
start=$(grep -n '^setup_file() {' "$rt" | cut -d: -f1)
|
||||
[ -n "$start" ] || { echo "no setup_file"; return 1; }
|
||||
end=$(awk -v s="$start" 'NR > s && /^}/ { print NR; exit }' "$rt")
|
||||
[ -n "$end" ] || { echo "setup_file has no end"; return 1; }
|
||||
for v in SANDBOX_DIR REPO_ROOT; do
|
||||
sed -n "${start},${end}p" "$rt" | grep -q "$v=" || continue
|
||||
# Every use of it outside that range, comments aside.
|
||||
outside=$(grep -n "\$$v" "$rt" \
|
||||
| grep -v '^[0-9]*:[[:space:]]*#' \
|
||||
| awk -F: -v s="$start" -v e="$end" '$1 < s || $1 > e' || true)
|
||||
[ -z "$outside" ] || {
|
||||
echo "\$$v is assigned in setup_file and used outside it:" >&2
|
||||
echo "$outside" >&2
|
||||
echo "A test body reaching that gets an empty string, not an error." >&2
|
||||
return 1
|
||||
}
|
||||
done
|
||||
}
|
||||
|
||||
@test "runtime.bats fails loudly when the harness layer cannot be built" {
|
||||
# `export VAR="$(cmd)"` returns the export's status, so a failed build
|
||||
# leaves the tag empty, the default at the top of the file substitutes the
|
||||
# base image, and the suite tests an image no session runs while blaming
|
||||
# whichever assertions happen to depend on the harness's directories.
|
||||
local rt="$REPO_ROOT/test/runtime.bats"
|
||||
run grep -n 'export SESSION_IMAGE="\$(build_harness_layer' "$rt"
|
||||
[ "$status" -ne 0 ] || {
|
||||
echo "the layer build's failure is masked by export:" >&2; echo "$output" >&2; return 1; }
|
||||
grep -q 'session_image="$(build_harness_layer "$IMAGE")" ||' "$rt" \
|
||||
|| { echo "the layer build's status is not checked"; return 1; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user