Compare commits
123
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d58afa548a | ||
|
|
ff8062622e | ||
|
|
9ef51488ad | ||
|
|
c637d1dee6 | ||
|
|
6a0ec12d41 | ||
|
|
44bcee193f | ||
|
|
c88240b4b6 | ||
|
|
c6a0a3ff77 | ||
|
|
e953bfa5fa | ||
|
|
5e0a390839 | ||
|
|
88d54cab78 | ||
|
|
d6f7f8c9d7 | ||
|
|
881e6343e5 | ||
|
|
668c3feccf | ||
|
|
8fb4a4d39b | ||
|
|
3b6c5c016f | ||
|
|
0a6b55b5b9 | ||
|
|
e250c705ed | ||
|
|
2cbf1a414c | ||
|
|
3100a5f454 | ||
|
|
b32c8921c2 | ||
|
|
e151faf4ec | ||
|
|
251beaab45 | ||
|
|
af6f0e4aa7 | ||
|
|
3a6c233a0f | ||
|
|
924dd4ed51 | ||
|
|
271d7843fe | ||
|
|
9b6ad238f3 | ||
|
|
19fb87c1f8 | ||
|
|
75eb427e62 | ||
|
|
aa3a198e26 | ||
|
|
1de83f8a16 | ||
|
|
bd96919504 | ||
|
|
a721e3d1fb | ||
|
|
d7e4c6be0a | ||
|
|
2c31d83a49 | ||
|
|
077cd241a7 | ||
|
|
4e47c30457 | ||
|
|
5b7250f8f0 | ||
|
|
f1f2fe4b02 | ||
|
|
a4f4d1428f | ||
|
|
a301bae1c4 | ||
|
|
849ec326a2 | ||
|
|
a0b45bdacc | ||
|
|
77e65513d9 | ||
|
|
a41aebb09f | ||
|
|
b872fabdf6 | ||
|
|
1e788548a0 | ||
|
|
3734a614bd | ||
|
|
55c33a8cc5 | ||
|
|
95b90761dd | ||
|
|
a438bf368b | ||
|
|
2a20ae62cc | ||
|
|
2efb93c644 | ||
|
|
cdad2fb1e9 | ||
|
|
b1d7975aee | ||
|
|
f2f40caa6b | ||
|
|
6f4e176400 | ||
|
|
bc5e5dc05b | ||
|
|
3a26bd2618 | ||
|
|
483e44978d | ||
|
|
f51403a3dc | ||
|
|
ebfc5ce7ff | ||
|
|
3e1521c412 | ||
|
|
6baf78d05c | ||
|
|
26ed2e6a5b | ||
|
|
1f251efd5b | ||
|
|
58c6f1b64d | ||
|
|
3642156e7c | ||
|
|
df7c32154c | ||
|
|
ea352d21ce | ||
|
|
ec1b662429 | ||
|
|
9007e34c20 | ||
|
|
3f4d6f217d | ||
|
|
d751f53e68 | ||
|
|
288d28a210 | ||
|
|
1e4d28021e | ||
|
|
9c609f344d | ||
|
|
49f68f4066 | ||
|
|
ecd683b192 | ||
|
|
2954afda34 | ||
|
|
0c5ae0e371 | ||
|
|
2110b9bc16 | ||
|
|
7051a01cf1 | ||
|
|
dff6a40dd1 | ||
|
|
642fcd15b3 | ||
|
|
79c8c25983 | ||
|
|
757360a742 | ||
|
|
62eea1002d | ||
|
|
53da70aae9 | ||
|
|
314b711b6c | ||
|
|
14a91be8f1 | ||
|
|
b497353fc6 | ||
|
|
af480bbf95 | ||
|
|
476c0e3e82 | ||
|
|
237653320f | ||
|
|
4edf40f2fa | ||
|
|
def800d1be | ||
|
|
103ef3f9c5 | ||
|
|
3f3ddb782b | ||
|
|
ae0f31079e | ||
|
|
1e35c5c90a | ||
|
|
bd0d5f41d0 | ||
|
|
698020693d | ||
|
|
5d16b273be | ||
|
|
90e32b8936 | ||
|
|
bcb360a29b | ||
|
|
707ffaaa69 | ||
|
|
6b8eda6ff4 | ||
|
|
2758c998ed | ||
|
|
b10f5ff89f | ||
|
|
dda28a3864 | ||
|
|
4202e1fea1 | ||
|
|
785c9d34d7 | ||
|
|
585d1c3208 | ||
|
|
cb31e463f9 | ||
|
|
3e995a477a | ||
|
|
ef6939f5d3 | ||
|
|
72599e5c2d | ||
|
|
63e79d81cc | ||
|
|
2c14b10888 | ||
|
|
4706a27ab2 | ||
|
|
8350f68071 |
+22
-9
@@ -100,13 +100,23 @@ locally is not a reason to stop, to hedge, or to leave a change unfinished. Run
|
||||
the confined suites, open the PR, and say plainly in the PR body that the
|
||||
privileged half was not run locally.
|
||||
|
||||
**But do not read "CI covers it" as "it passed."** That job is pinned to
|
||||
`runs-on: dejima-privileged`, a label only the one configured self-hosted runner
|
||||
carries — it needs the host network namespace, the Docker socket, and a shared
|
||||
volume, none of which a workflow can request. If that runner is offline the job
|
||||
**queues instead of failing**, and the PR sits with the runtime half neither
|
||||
green nor red. Check the PR's actual checks before treating it as verified, and
|
||||
if the job is still queued, say so rather than implying it ran.
|
||||
**Green means it ran.** The forge does not show a check green until every job
|
||||
in it has finished and passed, and `runtime.bats` is one of those jobs — so a
|
||||
green PR is one where the privileged half executed. There is no state in which
|
||||
the checks are green and that suite was skipped.
|
||||
|
||||
What a queued run looks like is yellow or grey, not green. That job is pinned
|
||||
to `runs-on: dejima-privileged`, a label only the one configured self-hosted
|
||||
runner carries — it needs the host network namespace, the Docker socket and a
|
||||
shared volume, none of which a workflow can request — so if that runner is
|
||||
offline the job **queues instead of failing**, and the PR sits unfinished
|
||||
rather than passing.
|
||||
|
||||
So: read the colour, and say what it says. Green is verified end to end and
|
||||
should be reported that way. Yellow or grey is a run that has not happened yet,
|
||||
and reporting it as passing is the thing to avoid. Hedging a green one is the
|
||||
opposite error and is the more common one here — it reads as doubt about a
|
||||
result that is not in doubt.
|
||||
|
||||
If a change touches what gets mounted, whether the firewall runs, or which
|
||||
credentials cross the container boundary, that runtime run is the only thing
|
||||
@@ -162,7 +172,7 @@ only holds where nothing else set it. Both passed in CI and failed exactly where
|
||||
the code was being worked on:
|
||||
|
||||
- `proxy.bats`'s cert check meant "no cert configured" by not setting
|
||||
`SANDBOX_ANTHROPIC_TLS_CERT` — but `entrypoint.sh` exports it into every
|
||||
`SANDBOX_CREDENTIAL_TLS_CERT` — but `entrypoint.sh` exports it into every
|
||||
tls-transport session, so inside a sandbox the guard under test never fired.
|
||||
- `remote.bats`'s default-port check asserted on 7681, which is the port a real
|
||||
`dejima-ttyd` front door holds, so it failed on any host running the feature.
|
||||
@@ -253,7 +263,10 @@ to do as a follow-on to finishing a branch. It has its own pass - suites,
|
||||
security review, documentation review, and the manual checks nothing here can
|
||||
run - in [docs/release.md](docs/release.md), and `test/release-pass.sh` runs
|
||||
what can be run and prints the rest. If you are asked to prepare a release,
|
||||
start there rather than opening the pull request first.
|
||||
start there rather than opening the pull request first. When the ask is to
|
||||
run the pass, the pass is the whole of it: the report is the deliverable, the
|
||||
release pull request is not part of it, and you never open one unasked.
|
||||
Prompt the user to create it - the report is what they decide on.
|
||||
|
||||
`gitea-pr` prints the base it is opening against, every time, and reads
|
||||
`git config gitea.prBase` before falling back to the repository's default
|
||||
|
||||
@@ -80,6 +80,7 @@ jobs:
|
||||
matrix:
|
||||
suite:
|
||||
- static
|
||||
- launch
|
||||
- project
|
||||
- remote
|
||||
- audit
|
||||
@@ -327,9 +328,30 @@ jobs:
|
||||
echo "resolves for this architecture." >&2
|
||||
exit 1
|
||||
}
|
||||
# compose, from the same place and for a reason of the same kind: it
|
||||
# is a plugin, so without it `docker compose` is not a command at
|
||||
# all. Every launch on a real host goes through one, and nothing in
|
||||
# CI did - so the compose file was the one input to a launch that no
|
||||
# job had ever parsed.
|
||||
#
|
||||
# Its assets are named for `uname -m`, like the CLI tarball above and
|
||||
# unlike buildx, which uses the Debian architecture name.
|
||||
curl -fsSL -o /usr/local/lib/docker/cli-plugins/docker-compose \
|
||||
"https://github.com/docker/compose/releases/download/v${COMPOSE_VERSION}/docker-compose-linux-$(uname -m)"
|
||||
chmod +x /usr/local/lib/docker/cli-plugins/docker-compose
|
||||
# Checked for the same reason buildx is: an error page chmods just as
|
||||
# well as a binary, and the symptom would be a test that skips rather
|
||||
# than one that fails.
|
||||
docker compose version || {
|
||||
echo "The compose plugin did not load, so the compose-file test will skip" >&2
|
||||
echo "instead of running. Check the download URL above still resolves for" >&2
|
||||
echo "this architecture." >&2
|
||||
exit 1
|
||||
}
|
||||
env:
|
||||
DOCKER_CLI_VERSION: 27.5.1
|
||||
BUILDX_VERSION: 0.36.0
|
||||
COMPOSE_VERSION: 5.5.1
|
||||
|
||||
# Fails here, with the fix in the message, rather than thirty tests deep
|
||||
# in a suite that cannot work. Both of these are runner configuration,
|
||||
|
||||
@@ -45,18 +45,18 @@ starts.
|
||||
|
||||
- **An egress allowlist.** DNS and IP, IPv4 and IPv6. Anything not on the
|
||||
list does not connect, and the agent is told so.
|
||||
- **Credentials that stay on the host.** Your Anthropic OAuth credential
|
||||
never enters the container: a host-side proxy holds it and the container
|
||||
gets a placeholder. Your SSH key doesn't either; the container sees an
|
||||
agent socket, never the key.
|
||||
- **Credentials that stay on the host.** Your model credential never enters
|
||||
the container, whichever backend you selected: a host-side proxy holds it
|
||||
and injects it per request, and the container gets a placeholder. Your SSH
|
||||
key doesn't either; the container sees an agent socket, never the key.
|
||||
- **Per-project declaration and approval.** A project declares the domains
|
||||
and dependencies it needs in `.dejima/`. Declaring is not granting: the
|
||||
approval is a separate decision you make on the host, and the approval
|
||||
store is never mounted where a session could reach it.
|
||||
- **One project's session cannot reach another's.** `~/.claude` is not
|
||||
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 another project's memory.
|
||||
- **One project's session cannot reach another's.** Your harness's state
|
||||
directory is not 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 another project's memory.
|
||||
- **CI hardening, because push access is code execution elsewhere.** 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
|
||||
@@ -64,12 +64,17 @@ starts.
|
||||
[docs/ci.md](docs/ci.md) for what hardening a runner takes, and
|
||||
[dejima-ci-hardener](https://gitbox.branderson.io/dejima/dejima-ci-hardener)
|
||||
for a tool that does it.
|
||||
- **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.
|
||||
- **Guard rails inside the session.** Pushes to anywhere but the allowed
|
||||
remote, and merging a PR from inside the sandbox, are refused whichever
|
||||
harness is selected - they shadow `git` and the forge CLI on `PATH`. Refusing
|
||||
an obviously destructive command needs a hook the harness can host, so a
|
||||
harness without one goes without it, and the launch says so rather than
|
||||
leaving you to assume. All of 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` does not go unnoticed.
|
||||
so a change to your git config, to the workspace's own agent configuration,
|
||||
or to a file your harness runs on the host does not go unnoticed.
|
||||
|
||||
**What it gives an agent to work with:**
|
||||
|
||||
@@ -83,9 +88,10 @@ starts.
|
||||
name - and dejima loads it alongside a briefing on where the session is and
|
||||
why the limits are there. An agent that knows it is in a sandbox asks for a
|
||||
domain rather than 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.
|
||||
- **Config parity with your host.** Your harness's own global instructions,
|
||||
settings, hooks, skills, rules and plugins carry over, and so does your git
|
||||
identity; uid, home directory and project path match on both sides, so a
|
||||
session's transcript is readable from the host afterward.
|
||||
- **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
|
||||
@@ -145,6 +151,10 @@ what each one buys, what goes wrong without it, and where each one's boundary
|
||||
ends. None of them is required to run dejima; what they change is what the
|
||||
layer below the container costs, and whether anyone finds out.
|
||||
|
||||
**→ [Why a container, and not a microVM?](docs/threat-model.md#why-a-container-and-not-a-microvm)** -
|
||||
the most common question about this design, and why the effort went into the
|
||||
layers below the container rather than into a harder one.
|
||||
|
||||
## Setup
|
||||
|
||||
This repo only holds the tooling; no project lives here. Point it at a project
|
||||
@@ -165,18 +175,27 @@ ones you want.
|
||||
[Sign in on the host first](#sign-in-on-the-host-first) - and skip it
|
||||
entirely for a harness pointed at a model you serve yourself, which needs
|
||||
no credential.
|
||||
2. **`./install.sh`**, which symlinks the CLIs into `~/.local/bin`, writes a
|
||||
starter `~/.config/dejima/config` if none exists, and registers whatever
|
||||
guard hooks the harness you selected can host. Idempotent, so re-run it
|
||||
after pulling; it never overwrites an existing config.
|
||||
2. **`./install.sh`**, which symlinks the CLIs into `~/.local/bin` and writes
|
||||
a starter `~/.config/dejima/config` if none exists. It writes nothing to
|
||||
your harness's own config: the sandbox's guards are baked into the image and
|
||||
registered per launch into a settings layer of dejima's own, so installing
|
||||
dejima does not change how your harness behaves outside a sandbox.
|
||||
Idempotent, so re-run it after pulling; it never overwrites an existing
|
||||
config.
|
||||
3. **Put `~/.local/bin` on your `PATH`** if it isn't already.
|
||||
4. **Set `[core] harness`.** It has no default and a launch is refused without
|
||||
it, which is what stops a later release changing your selection for you.
|
||||
|
||||
That is enough to run `dejima`, and no push credential is needed to get this
|
||||
far. The rest of the starter config is commented out at its defaults, which
|
||||
means `[core] forge = none` and `[core] workflow = local-only`: the agent works
|
||||
in the sandbox and commits locally, and nothing is pushed anywhere.
|
||||
far. Everything else runs at its default, which means `[core] forge = none` and
|
||||
`[core] workflow = local-only`: the agent works in the sandbox and commits
|
||||
locally, and nothing is pushed anywhere.
|
||||
|
||||
**Those defaults are pinned to this machine, once.** The first launch writes
|
||||
them to `~/.config/dejima/defaults.conf`, which your own config overrides, so
|
||||
a later release cannot move a setting you never chose. `./install.sh` adds keys
|
||||
a release introduced and names any shipped default that has moved, without
|
||||
taking it.
|
||||
|
||||
**→ [docs/configuration.md](docs/configuration.md#configuration-reference)** -
|
||||
every key, its default, and what it is for. `dejima --print-config` prints the
|
||||
@@ -293,8 +312,8 @@ works if the host has a credential to hold. 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, read
|
||||
fresh from the host at every launch (see "Throwaway ~/.claude.json" in
|
||||
[docs/security.md](docs/security.md)).
|
||||
fresh from the host at every launch (Claude Code's is "Throwaway
|
||||
~/.claude.json" in [docs/security.md](docs/security.md)).
|
||||
|
||||
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,
|
||||
@@ -387,9 +406,11 @@ equivalent for it.
|
||||
|
||||
The allowlist covers what the sandbox needs to function - DNS, the Docker host,
|
||||
GitHub on port 443 for read access whichever forge is selected, whatever the
|
||||
selected forge needs, the harness's own update and model endpoints, and
|
||||
`api.anthropic.com` when the credential proxy is off - plus whatever a project
|
||||
has declared and had approved.
|
||||
selected forge needs, the harness's own update endpoints, and the selected
|
||||
backend's API endpoint when the credential proxy is off - plus whatever a
|
||||
project has declared and had approved. With the proxy on that last name is
|
||||
deliberately absent: the session reaches the API through the in-container
|
||||
terminator rather than by resolving it.
|
||||
|
||||
DNS is allowlisted as well as IPs: a local resolver only forwards lookups for
|
||||
names already on that same allowlist, so an unapproved domain does not resolve
|
||||
@@ -438,17 +459,22 @@ exact command to hand you.
|
||||
tarball dependencies, the symlink refusal that protects the approval store,
|
||||
and the complete `dejima-allow`/`dejima-dep`/`dejima-project` reference.
|
||||
|
||||
## Config parity with your host Claude Code setup
|
||||
## Config parity with your host harness setup
|
||||
|
||||
Global `CLAUDE.md`, settings, hooks, skills, plugins and git identity all carry
|
||||
over, and your existing login stays effective, with the credential itself never
|
||||
entering the container. `~/.claude` is not shared as a whole - only an
|
||||
enumerated subset does, so a session transcript is readable from the host
|
||||
afterward but a credential or cache isn't. The container's own user matches
|
||||
yours exactly, so `$HOME`-relative paths resolve the same on both sides.
|
||||
Your harness's own global instructions, settings, hooks, skills, rules and
|
||||
plugins carry over, along with your git identity, and your existing login stays
|
||||
effective with the credential itself never entering the container. The
|
||||
harness's config directory is not shared as a whole - only an enumerated subset
|
||||
is, so a session transcript is readable from the host afterward but a
|
||||
credential or cache isn't. The container's own user matches yours exactly, so
|
||||
`$HOME`-relative paths resolve the same on both sides.
|
||||
|
||||
Which files those are is each harness's own question, answered in its plugin:
|
||||
`~/.claude` for Claude Code, `~/.agents` and `~/.cline` for Cline.
|
||||
|
||||
**→ [docs/configuration.md](docs/configuration.md)** - exactly what carries
|
||||
over and what doesn't, and why single-file mounts couldn't be trusted here.
|
||||
over and what doesn't, per harness, and why single-file mounts couldn't be
|
||||
trusted here.
|
||||
|
||||
## Shared scratch space
|
||||
|
||||
@@ -525,9 +551,9 @@ what a forwarded personal agent changes, and the persistent alternative.
|
||||
|
||||
Before each run, dejima snapshots the host surfaces a session could write to
|
||||
and persist through: `.git/config` keys that run a command on the next `git`,
|
||||
`.git/hooks/`, the workspace's `.claude/`, `.mcp.json`, `.envrc`, and writes
|
||||
under `~/.claude/projects`. After the container exits it diffs them and tells
|
||||
you what changed.
|
||||
`.git/hooks/`, the workspace's `.claude/` and `.vscode/`, `.mcp.json`, `.envrc`,
|
||||
and any host-side file the selected harness declares as one it runs by itself.
|
||||
After the container exits it diffs them and tells you what changed.
|
||||
|
||||
It is **advisory, not a block**, firing after the session has ended, so a
|
||||
finding is something to clean up before your next `git` command or session in
|
||||
|
||||
+691
-54
File diff suppressed because it is too large
Load Diff
+59
-5
@@ -268,6 +268,59 @@ if [ "$session_wanted" -eq 1 ]; then
|
||||
[ -n "$container" ] || container="${containers%%$'\t'*}"
|
||||
fi
|
||||
|
||||
# The dnsmasq and the ipsets are not in the session's container. They are
|
||||
# in the filter container that owns its network namespace, which is what
|
||||
# keeps a session from reaching either. So everything that CHANGES the
|
||||
# allowlist below runs there, and only the reachability check at the end
|
||||
# runs in the session's own container - because that is the container whose
|
||||
# reachability this command claims to have changed.
|
||||
#
|
||||
# Both are derived from whichever one the caller named, rather than
|
||||
# recorded anywhere: Docker already knows the pairing, because joining a
|
||||
# namespace is how the pair is built.
|
||||
#
|
||||
# Naming either half has to work, and not as a convenience. A session that
|
||||
# is asked for its own container id reads /etc/hostname, and Docker names a
|
||||
# container that JOINED another's network namespace after the container it
|
||||
# joined - so the id in a briefing, the one an agent hands its operator, is
|
||||
# the filter container's. Nothing about the pair is worth making an
|
||||
# operator hold in their head to type one command.
|
||||
mode="$(docker inspect -f '{{.HostConfig.NetworkMode}}' "$container" 2>/dev/null)" || mode=""
|
||||
case "$mode" in
|
||||
container:*)
|
||||
# A session container: it names its own filter.
|
||||
filter="${mode#container:}"
|
||||
;;
|
||||
*)
|
||||
# Then it is a filter container, or not part of a pair at all.
|
||||
# `docker exec` takes a name or an id; NetworkMode reports the id,
|
||||
# so the search below compares ids.
|
||||
filter_id="$(docker inspect -f '{{.Id}}' "$container" 2>/dev/null)" || filter_id=""
|
||||
session=""
|
||||
if [ -n "$filter_id" ]; then
|
||||
while IFS=' ' read -r _id _mode; do
|
||||
[ "$_mode" = "container:$filter_id" ] || continue
|
||||
session="$_id"
|
||||
break
|
||||
done < <(docker ps -q 2>/dev/null | xargs -r docker inspect \
|
||||
-f '{{.Id}} {{.HostConfig.NetworkMode}}' 2>/dev/null)
|
||||
fi
|
||||
if [ -z "$session" ]; then
|
||||
echo "$container is neither a running session nor the filter container of" >&2
|
||||
echo "one, so there is no allowlist to add this domain to." >&2
|
||||
echo "" >&2
|
||||
echo "A session's own id is in its startup context, or from 'cat /etc/hostname'" >&2
|
||||
echo "inside it - either half of the pair is accepted here. If the session has" >&2
|
||||
echo "already exited, record the domain for the next one instead:" >&2
|
||||
echo "" >&2
|
||||
echo " dejima-allow --project $domain" >&2
|
||||
exit 1
|
||||
fi
|
||||
filter="$container"
|
||||
container="$session"
|
||||
;;
|
||||
esac
|
||||
|
||||
# The DNS proxy (dns-proxy.sh) NXDOMAINs anything not in its allowlist, so
|
||||
# this has to happen *before* resolving the domain below - otherwise the dig
|
||||
# right after this would itself get blocked. Append a server= line to
|
||||
@@ -277,15 +330,16 @@ if [ "$session_wanted" -eq 1 ]; then
|
||||
# single-quoted heredoc) rather than interpolated into the script text - it's
|
||||
# validated above, but this keeps it that way even if the validation regex
|
||||
# above is ever loosened later.
|
||||
docker exec -u root "$container" bash -c '
|
||||
docker exec -u root "$filter" bash -c '
|
||||
domain="$1"
|
||||
entry="server=/${domain}/127.0.0.11"
|
||||
grep -qxF "$entry" /etc/dnsmasq-sandbox-servers.conf 2>/dev/null || echo "$entry" >> /etc/dnsmasq-sandbox-servers.conf
|
||||
kill -HUP "$(cat /var/run/dnsmasq-sandbox.pid)"
|
||||
' -- "$domain"
|
||||
|
||||
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)
|
||||
# Named for where the lookup happens, which is where the DNS allowlist is.
|
||||
echo "Resolving $domain in $filter, this session's egress control..."
|
||||
ips=$(docker exec "$filter" 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
|
||||
@@ -294,7 +348,7 @@ if [ "$session_wanted" -eq 1 ]; then
|
||||
# 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 |
|
||||
upstream=$(docker exec "$filter" 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
|
||||
@@ -320,7 +374,7 @@ if [ "$session_wanted" -eq 1 ]; then
|
||||
target_set=$(ipset_for_domain "$domain")
|
||||
for ip in $ips; do
|
||||
echo "Allowing $ip ($domain -> $target_set)"
|
||||
docker exec -u root "$container" ipset add "$target_set" "$ip" -exist
|
||||
docker exec -u root "$filter" ipset add "$target_set" "$ip" -exist
|
||||
done
|
||||
|
||||
echo "$domain allowed for the running session ($container)."
|
||||
|
||||
+168
-5
@@ -53,6 +53,7 @@ core.known_hosts path ~/.ssh/known_hosts
|
||||
github.token_file path ~/.config/gh/sandbox-token
|
||||
mounts.readonly ?list:path
|
||||
mounts.read_write ?list:path
|
||||
mounts.read_write_per_project ?list:path
|
||||
mounts.nvim_config ?path
|
||||
credentials.proxy bool on
|
||||
credentials.transport enum:tls|loopback tls
|
||||
@@ -165,6 +166,30 @@ _config_keys() {
|
||||
_config_extra_keys
|
||||
}
|
||||
|
||||
# A key that used to exist and now lives somewhere else.
|
||||
#
|
||||
# "unknown key" is true and useless on its own: an operator who wrote a key
|
||||
# dejima once accepted has no way to tell a typo from a move, and the answer
|
||||
# is in a document they have no reason to think they need. A rename that
|
||||
# ships without this costs every operator who had the old key the same search.
|
||||
#
|
||||
# Keep this to keys that dejima ITSELF moved. A key nobody ever accepted is a
|
||||
# typo, and guessing at what it was meant to be is worse than saying nothing.
|
||||
_config_moved_hint() { # <section> <key>
|
||||
local section="$1" key="$2"
|
||||
case "$section.$key" in
|
||||
harness.*.base_url|harness.*base_url)
|
||||
printf ' Where the model is belongs to the backend now, not the harness.\n' >&2
|
||||
printf ' For one you serve yourself:\n\n' >&2
|
||||
printf ' [harness.%s]\n backend = self-hosted\n\n' \
|
||||
"${section#harness.}" >&2
|
||||
printf ' [backend.self-hosted]\n base_url = ...\n\n' >&2
|
||||
printf ' A hosted API is a backend of its own instead, which carries its own\n' >&2
|
||||
printf ' address - see docs/providers.md, "Model backends".\n' >&2
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
config_root() {
|
||||
printf '%s' "${DEJIMA_CONFIG_DIR:-$HOME/.config/dejima}"
|
||||
}
|
||||
@@ -173,6 +198,17 @@ config_path() {
|
||||
printf '%s/config' "$(config_root)"
|
||||
}
|
||||
|
||||
# The pinned defaults, beside the operator's own file rather than under
|
||||
# ~/.local/share. It explains the file next to it, and the answer to "why is
|
||||
# this value what it is" belongs where someone would look for it.
|
||||
#
|
||||
# Machine-written, and the operator is told not to edit it - which is why the
|
||||
# layer it produces is parsed leniently (see _config_parse_defaults) where
|
||||
# theirs is not.
|
||||
config_defaults_path() {
|
||||
printf '%s/defaults.conf' "$(config_root)"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dependency specs
|
||||
#
|
||||
@@ -371,6 +407,25 @@ _config_valid_value() {
|
||||
identifier)
|
||||
[[ "$value" =~ ^[A-Za-z0-9._-]+$ ]]
|
||||
;;
|
||||
modelid)
|
||||
# A model's name at whatever endpoint serves it, which is not an
|
||||
# identifier: every hosted catalogue namespaces by vendor with a
|
||||
# slash (`z-ai/glm-5.3`) and several qualify a variant with a colon
|
||||
# (`deepseek/deepseek-r1:free`, `llama3:8b`), and Anthropic
|
||||
# brackets a context variant (`claude-opus-5[1m]`). Typed
|
||||
# `identifier`, not one OpenRouter id could be configured at all,
|
||||
# so the backend this harness exists to reach was unusable; the
|
||||
# first fix for that missed the brackets, which is why the set
|
||||
# below is the union of what real catalogues spell rather than
|
||||
# what the last reported id needed.
|
||||
#
|
||||
# Still narrow, and for the same reason `path` is: this reaches an
|
||||
# agent's configuration file. It is written there with jq --arg, so
|
||||
# the JSON escaping is not what this is protecting - what it
|
||||
# protects is that a value carrying a newline or a tab would split
|
||||
# a record every reader of this config expects to be one line.
|
||||
[[ "$value" =~ ^[A-Za-z0-9][][A-Za-z0-9._:/-]*$ ]]
|
||||
;;
|
||||
jsonfield)
|
||||
# Which value inside a credentials file is the key: dot-separated
|
||||
# object names, nothing else. No array indices and no jq syntax,
|
||||
@@ -469,12 +524,21 @@ _config_trim_into() {
|
||||
# place; nothing shipped does that today (install.sh writes the file but never
|
||||
# reads a value back).
|
||||
declare -gA _CFG_VAL=() _CFG_SEEN=()
|
||||
# The pinned layer, in arrays of its own rather than pre-seeded into the two
|
||||
# above. Keeping them apart is what lets config_is_set keep meaning "the
|
||||
# operator wrote this": a base file naming every key would otherwise answer
|
||||
# yes for all of them, and every caller of it means the operator.
|
||||
declare -gA _CFG_BASE_VAL=() _CFG_BASE_SEEN=()
|
||||
_CFG_BASE_STALE=0
|
||||
_CFG_PARSED_PATH=""
|
||||
_CFG_PARSE_STATUS=1
|
||||
|
||||
config_reset() {
|
||||
_CFG_VAL=()
|
||||
_CFG_SEEN=()
|
||||
_CFG_BASE_VAL=()
|
||||
_CFG_BASE_SEEN=()
|
||||
_CFG_BASE_STALE=0
|
||||
_CFG_PARSED_PATH=""
|
||||
_CFG_PARSE_STATUS=1
|
||||
}
|
||||
@@ -485,6 +549,16 @@ config_validate() {
|
||||
if [ "$_CFG_PARSED_PATH" = "$conf" ]; then
|
||||
return "$_CFG_PARSE_STATUS"
|
||||
fi
|
||||
# Cleared here rather than by each parser, so that neither one's
|
||||
# correctness rests on the other having tidied up first. Seeding the
|
||||
# pinned layer into the operator's arrays would then be a bug that
|
||||
# SHOWS, instead of one the following parse happens to wipe.
|
||||
_CFG_VAL=(); _CFG_SEEN=()
|
||||
_CFG_BASE_VAL=(); _CFG_BASE_SEEN=(); _CFG_BASE_STALE=0
|
||||
# The pinned layer first, so a value read during the operator's own parse
|
||||
# resolves the same way one read afterwards does. It cannot fail the
|
||||
# validation: nothing an operator can fix lives in it.
|
||||
_config_parse_defaults "$(config_defaults_path)"
|
||||
# $? captured before anything else runs - an assignment would reset it to
|
||||
# 0 and cache every malformed config as valid.
|
||||
_config_parse "$conf"
|
||||
@@ -495,8 +569,6 @@ config_validate() {
|
||||
|
||||
_config_parse() {
|
||||
local conf="$1" line section="" key value type
|
||||
_CFG_VAL=()
|
||||
_CFG_SEEN=()
|
||||
if [ ! -f "$conf" ]; then
|
||||
echo "no dejima config at $conf" >&2
|
||||
return 1
|
||||
@@ -532,6 +604,7 @@ _config_parse() {
|
||||
_config_trim_into value "${line#*=}"
|
||||
if ! _config_type_into type "$section.$key"; then
|
||||
echo "unknown key in $conf: [$section] $key" >&2
|
||||
_config_moved_hint "$section" "$key"
|
||||
return 1
|
||||
fi
|
||||
if ! _config_valid_value "$type" "$value"; then
|
||||
@@ -560,6 +633,65 @@ _config_parse() {
|
||||
|
||||
# Expands a leading "~" to $HOME, per element for a list type. Only for path
|
||||
# types: a tilde inside a URL is a legal path character and must survive.
|
||||
# The pinned layer. Same grammar as the operator's file and a different
|
||||
# answer to everything wrong with it: skip the line and carry on, rather than
|
||||
# refuse the launch.
|
||||
#
|
||||
# That is not leniency for its own sake. An unknown key here is a key dejima
|
||||
# RETIRED, on a machine whose defaults.conf predates the release that dropped
|
||||
# it - the operator did not write it and is told not to edit the file, so
|
||||
# refusing would leave them a launch that will not start and nothing to do
|
||||
# about it. The schema answers for anything skipped, and the count is reported
|
||||
# once with the command that rebuilds the file.
|
||||
#
|
||||
# Absent is not an error either. A checkout that has never been launched has
|
||||
# no pinned layer, and the schema is the right answer for every key until
|
||||
# there is one.
|
||||
_config_parse_defaults() {
|
||||
local conf="$1" line section="" key value type skipped=0
|
||||
[ -f "$conf" ] || return 0
|
||||
while IFS= read -r line || [ -n "$line" ]; do
|
||||
_config_trim_into line "$line"
|
||||
case "$line" in
|
||||
''|'#'*|';'*) continue ;;
|
||||
'['*']')
|
||||
section="${line#\[}"
|
||||
section="${section%\]}"
|
||||
_config_trim_into section "$section"
|
||||
if ! _config_section_known "$section"; then
|
||||
# Its keys are skipped with it: an unknown section is one
|
||||
# whose provider is gone, and every key under it names
|
||||
# something that no longer exists.
|
||||
section=""
|
||||
skipped=$((skipped + 1))
|
||||
fi
|
||||
continue ;;
|
||||
*=*) ;;
|
||||
*) skipped=$((skipped + 1)); continue ;;
|
||||
esac
|
||||
[ -n "$section" ] || { skipped=$((skipped + 1)); continue; }
|
||||
_config_trim_into key "${line%%=*}"
|
||||
_config_trim_into value "${line#*=}"
|
||||
if ! _config_type_into type "$section.$key"; then
|
||||
skipped=$((skipped + 1)); continue
|
||||
fi
|
||||
if ! _config_valid_value "$type" "$value"; then
|
||||
skipped=$((skipped + 1)); continue
|
||||
fi
|
||||
_CFG_BASE_VAL["$section.$key"]="$value"
|
||||
_CFG_BASE_SEEN["$section.$key"]=1
|
||||
done < "$conf"
|
||||
_CFG_BASE_STALE="$skipped"
|
||||
return 0
|
||||
}
|
||||
|
||||
# How many lines of the pinned layer this process could not use. Read by the
|
||||
# launch, which says so once with the command that rebuilds it.
|
||||
config_defaults_stale() {
|
||||
config_validate >/dev/null 2>&1 || true
|
||||
printf '%s' "${_CFG_BASE_STALE:-0}"
|
||||
}
|
||||
|
||||
_config_expand() {
|
||||
local type="$1" value="$2" element out=""
|
||||
case "$type" in
|
||||
@@ -597,8 +729,14 @@ _config_get_from_file() {
|
||||
return 1
|
||||
fi
|
||||
config_validate || return 1
|
||||
# Operator, then what this machine pinned, then the schema. The schema is
|
||||
# reachable only for a key the pinned layer has no line for - a fresh
|
||||
# install, or one added since the last ./install.sh - which is the one
|
||||
# case where dejima's current answer is the right one.
|
||||
if [ -n "${_CFG_SEEN[$section.$want]+x}" ]; then
|
||||
found="${_CFG_VAL[$section.$want]}"
|
||||
elif [ -n "${_CFG_BASE_SEEN[$section.$want]+x}" ]; then
|
||||
found="${_CFG_BASE_VAL[$section.$want]}"
|
||||
else
|
||||
found="$(config_default "$section.$want")"
|
||||
fi
|
||||
@@ -611,9 +749,18 @@ _config_get_from_file() {
|
||||
config_get() { _config_get_from_file "$@"; }
|
||||
|
||||
# config_is_set <section> <key>
|
||||
# Whether the key APPEARS in the config file, as distinct from having a
|
||||
# value: a key with a schema default always has a value, so "has a value" is
|
||||
# the wrong question for anything asking what the operator configured.
|
||||
# Whether the key APPEARS in the OPERATOR'S config file, as distinct from
|
||||
# having a value: a key with a schema default always has a value, so "has a
|
||||
# value" is the wrong question for anything asking what the operator
|
||||
# configured.
|
||||
#
|
||||
# The pinned layer is deliberately not consulted, and this is the whole reason
|
||||
# it lives in arrays of its own. Every caller here means "did the operator
|
||||
# write this" - the [core] ssh_key / [identity.default] ssh_key refusal fires
|
||||
# only when both are present, identity settings take precedence over a forge's
|
||||
# only when the identity names one, and the ttyd knobs treat unset as "the
|
||||
# page decides from the pointer type", which no host-wide value can see. A
|
||||
# defaults.conf naming every key would answer yes to all of them.
|
||||
config_is_set() {
|
||||
local section="$1" want="$2" _t
|
||||
if ! _config_type_into _t "$section.$want"; then
|
||||
@@ -625,6 +772,22 @@ config_is_set() {
|
||||
[ -n "${_CFG_SEEN[$section.$want]+x}" ]
|
||||
}
|
||||
|
||||
# Which layer answered for a key: user, default (this machine's pinned value)
|
||||
# or schema (nothing has pinned it yet). What --print-config prints beside a
|
||||
# value, and the fastest answer to "why is this what it is".
|
||||
config_origin() {
|
||||
local section="$1" want="$2" _t
|
||||
_config_type_into _t "$section.$want" || return 1
|
||||
config_validate || return 1
|
||||
if [ -n "${_CFG_SEEN[$section.$want]+x}" ]; then
|
||||
printf 'user'
|
||||
elif [ -n "${_CFG_BASE_SEEN[$section.$want]+x}" ]; then
|
||||
printf 'default'
|
||||
else
|
||||
printf 'schema'
|
||||
fi
|
||||
}
|
||||
|
||||
config_get_bool() {
|
||||
local value
|
||||
value="$(config_get "$1" "$2")" || return 1
|
||||
|
||||
+166
-4
@@ -28,6 +28,7 @@ import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import zlib
|
||||
from http.server import BaseHTTPRequestHandler
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
@@ -234,6 +235,102 @@ class BodyTooLarge(Exception):
|
||||
answered as if the session had merely sent too much."""
|
||||
|
||||
|
||||
# What an upstream error body says, as text fit to put on a terminal.
|
||||
#
|
||||
# Two things stand between the bytes and the log. The client's own
|
||||
# Accept-Encoding is forwarded verbatim - a Node harness asks for gzip - so
|
||||
# what comes back is compressed, and the first version wrote those bytes
|
||||
# straight out: mojibake on the operator's terminal and a bash warning about
|
||||
# a null byte in a command substitution. And whatever the encoding, this is
|
||||
# upstream-controlled text landing where dejima-audit already refuses to put
|
||||
# session-controlled text unescaped.
|
||||
#
|
||||
# So: decompress what the stdlib can, then keep only printable characters.
|
||||
# The sanitising is the half that has to hold - it is not conditional on
|
||||
# recognising the encoding, and an unknown one degrades to a note rather than
|
||||
# to bytes.
|
||||
def _brotli_decompress(raw):
|
||||
"""A brotli decoder if the host has one, else None.
|
||||
|
||||
Optional on purpose. This runs on the host as the operator's user, and a
|
||||
diagnostic is not worth a hard dependency on a module Debian ships
|
||||
separately - so a host without it gets a note naming what to install
|
||||
rather than a launch that refuses.
|
||||
|
||||
Not solved by asking the API for an encoding this can read: the request
|
||||
the API sees is deliberately the harness's own header block, byte for
|
||||
byte, and Accept-Encoding is part of what makes a session look like a
|
||||
stock install. Rewriting it to make a log line readable trades a property
|
||||
the sandbox is for against a convenience.
|
||||
"""
|
||||
for name in ("brotli", "brotlicffi"):
|
||||
try:
|
||||
mod = __import__(name)
|
||||
except ImportError:
|
||||
continue
|
||||
try:
|
||||
# A bounded prefix is a partial stream. The one-shot decompress
|
||||
# wants a whole one, so this is the incremental decoder in both
|
||||
# modules - and a prefix that ends mid-symbol yields what it can.
|
||||
return mod.Decompressor().process(raw)
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _readable(raw, encoding):
|
||||
enc = (encoding or "").strip().lower()
|
||||
if enc in ("gzip", "x-gzip", "deflate"):
|
||||
try:
|
||||
# A bounded prefix is a partial stream, so decompressobj rather
|
||||
# than gzip.decompress, which wants a whole member.
|
||||
wbits = 16 + zlib.MAX_WBITS if enc != "deflate" else zlib.MAX_WBITS
|
||||
raw = zlib.decompressobj(wbits).decompress(raw)
|
||||
except zlib.error:
|
||||
return "(%d bytes, %s, undecodable)" % (len(raw), enc)
|
||||
elif enc == "br":
|
||||
decoded = _brotli_decompress(raw)
|
||||
if decoded is None:
|
||||
return ("(%d bytes, br-encoded - no brotli module on this host, so "
|
||||
"the message cannot be read here; pip install brotli, or "
|
||||
"apt install python3-brotli)" % len(raw))
|
||||
raw = decoded
|
||||
elif enc:
|
||||
# zstd and anything else. Saying which is more use than printing the
|
||||
# bytes, and printing the bytes is what this exists to stop.
|
||||
return "(%d bytes, %s-encoded)" % (len(raw), enc)
|
||||
# Decodes only. Making the text fit for a terminal is _diag's, so that
|
||||
# every producer is covered rather than this one path.
|
||||
return raw.decode("utf-8", "replace").strip()
|
||||
|
||||
|
||||
# One line, timestamped, appended. Falls back to stderr when the file cannot
|
||||
# be written rather than losing the message: a diagnostic that disappears
|
||||
# because the thing that reports problems had a problem is the worst kind.
|
||||
def _diag(path, line):
|
||||
# Sanitised at the sink, not at each producer. bin/dejima prints this file
|
||||
# to the operator's terminal after a session ends, so it is a terminal-
|
||||
# bound channel carrying text chosen by an upstream API and, through the
|
||||
# request path and exception messages, by the session itself. That is the
|
||||
# hazard docs/security.md describes for audit findings, which are
|
||||
# hex-encoded for it.
|
||||
#
|
||||
# Nothing gets through today - CPython's request-line parser and
|
||||
# http.client both refuse a control character in a URL, and both repr what
|
||||
# they refuse - but that is two coincidences rather than a property, and
|
||||
# neither of them covers a producer added later.
|
||||
line = "".join(c if c.isprintable() or c == " " else " " for c in str(line))
|
||||
stamped = "%s %s\n" % (time.strftime("%H:%M:%S"), line)
|
||||
if path:
|
||||
try:
|
||||
with open(path, "a") as fh:
|
||||
fh.write(stamped)
|
||||
return
|
||||
except OSError:
|
||||
pass
|
||||
sys.stderr.write(stamped)
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
protocol_version = "HTTP/1.1"
|
||||
|
||||
@@ -241,7 +338,13 @@ class Handler(BaseHTTPRequestHandler):
|
||||
pass # per-request lines would interleave with the session's own UI
|
||||
|
||||
def log_error(self, fmt, *args):
|
||||
sys.stderr.write("dejima-credential-proxy: " + fmt % args + "\n")
|
||||
_diag(getattr(self.server, "diag_path", None),
|
||||
"dejima-credential-proxy: " + fmt % args)
|
||||
|
||||
# How much of an upstream error body is worth keeping. An API's rejection
|
||||
# is a short JSON object; anything longer is not one, and this runs on the
|
||||
# host with the session on the other end of it.
|
||||
MAX_DIAG_BODY = 2048
|
||||
|
||||
# Capped, because the client is the sandboxed session and this runs on the
|
||||
# host as the operator's user. Every other session-controlled size in this
|
||||
@@ -401,8 +504,51 @@ class Handler(BaseHTTPRequestHandler):
|
||||
"see host terminal",
|
||||
log_message="upstream request failed: %s" % e)
|
||||
try:
|
||||
self.send_response(resp.status)
|
||||
length = resp.getheader("Content-Length")
|
||||
# An upstream rejection, said out loud. The proxy is the only
|
||||
# thing that sees both the request the session made and the answer
|
||||
# the API gave, and it used to forward a 4xx verbatim and record
|
||||
# nothing - so a session that "spun for a bit and gave an error"
|
||||
# left the operator with no way to find out why. It is not the
|
||||
# container's job to explain: the harness shows whatever it shows.
|
||||
#
|
||||
# Read whole only when upstream declared a small length. A
|
||||
# streaming or oversized body is logged by status alone rather
|
||||
# than buffered here, which keeps this off the success path
|
||||
# entirely - a 200 never gets read into memory.
|
||||
early = None
|
||||
if resp.status >= 400:
|
||||
# Read a bounded prefix whatever the framing. The first
|
||||
# version did this only where upstream declared a small
|
||||
# Content-Length, and the first real rejection it caught -
|
||||
# three 429s from a rate-limited account - recorded the status
|
||||
# and nothing else, which is the half an operator cannot act
|
||||
# on. read1 returns what has arrived rather than waiting for a
|
||||
# full buffer, so this costs one chunk and is forwarded below
|
||||
# before the stream continues normally.
|
||||
early = resp.read1(self.MAX_DIAG_BODY)
|
||||
detail = _readable(early, resp.getheader("content-encoding"))
|
||||
# The headers carry more than the body does, which is not a
|
||||
# guess: a real rate-limited call came back
|
||||
# {"type":"rate_limit_error","message":"Error"} - the word
|
||||
# Error and nothing else. request-id is what Anthropic support
|
||||
# asks for, and retry-after is the only thing that says how
|
||||
# long; neither is in the body, and a reader should not have to
|
||||
# know to go looking for them.
|
||||
#
|
||||
# Deliberately not the account and workspace ids the same
|
||||
# response carries. They identify the operator's org, this
|
||||
# line gets pasted into bug reports, and request-id is enough
|
||||
# to find the request from the other side.
|
||||
extra = ""
|
||||
for name in ("request-id", "retry-after"):
|
||||
value = resp.getheader(name)
|
||||
if value:
|
||||
extra += " %s=%s" % (name, value)
|
||||
self.log_error("upstream answered %s for %s%s%s",
|
||||
resp.status, self.path, extra,
|
||||
" " + detail if detail else "")
|
||||
self.send_response(resp.status)
|
||||
for k, v in resp.getheaders():
|
||||
if k.lower() in HOP_BY_HOP or k.lower() in ("content-length", "date", "server"):
|
||||
continue
|
||||
@@ -416,6 +562,12 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self.send_header("Connection", "close")
|
||||
self.end_headers()
|
||||
self.wfile.flush()
|
||||
if early:
|
||||
# Already read for the log above; written out before the loop
|
||||
# picks up where it left off, so the session receives the same
|
||||
# bytes in the same order whether or not anything was logged.
|
||||
self.wfile.write(early)
|
||||
self.wfile.flush()
|
||||
# Stream response body, flushing after each chunk for unbuffered delivery.
|
||||
# Use read1() to get partial reads immediately rather than waiting for
|
||||
# a full buffer or EOF, ensuring SSE events stream unbuffered. HTTPResponse.read1()
|
||||
@@ -440,10 +592,11 @@ class Handler(BaseHTTPRequestHandler):
|
||||
class ProxyServer(socketserver.ThreadingMixIn, socketserver.UnixStreamServer):
|
||||
daemon_threads = True
|
||||
|
||||
def __init__(self, socket_path, upstream, creds, auth):
|
||||
def __init__(self, socket_path, upstream, creds, auth, diag_path=None):
|
||||
self.upstream = upstream
|
||||
self.creds = creds
|
||||
self.auth = auth
|
||||
self.diag_path = diag_path
|
||||
super().__init__(socket_path, Handler)
|
||||
|
||||
def get_request(self):
|
||||
@@ -496,6 +649,14 @@ def main():
|
||||
p.add_argument("--oauth-endpoint")
|
||||
p.add_argument("--oauth-client-id")
|
||||
p.add_argument("--refresh-margin", type=int, default=300)
|
||||
# Where diagnostics go. A file rather than stderr, because stderr here is
|
||||
# the operator's terminal and the session owns it: a line written mid-run
|
||||
# lands in the middle of whatever the harness is drawing. bin/dejima reads
|
||||
# this back after the container exits, alongside the exit audit.
|
||||
#
|
||||
# Absent, they go to stderr, which is what a proxy run by hand wants.
|
||||
p.add_argument("--log", default=None,
|
||||
help="append diagnostics here instead of stderr")
|
||||
args = p.parse_args()
|
||||
|
||||
if "{token}" not in args.auth_format:
|
||||
@@ -543,7 +704,8 @@ def main():
|
||||
|
||||
if os.path.exists(args.socket):
|
||||
os.unlink(args.socket)
|
||||
server = ProxyServer(args.socket, urlsplit(args.upstream), creds, auth)
|
||||
server = ProxyServer(args.socket, urlsplit(args.upstream), creds, auth,
|
||||
diag_path=args.log)
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
|
||||
@@ -392,9 +392,26 @@ harness_config_dirs_load() {
|
||||
DEJIMA_STAGED_CONFIG=()
|
||||
|
||||
# host path, and the path under $HOME it is installed at.
|
||||
# `ensure` asks for a placeholder at that path in the container when the host
|
||||
# has none, so a harness that needs a file to exist gets one without dejima
|
||||
# creating it on the operator's machine.
|
||||
#
|
||||
# Two kinds, because empty is not always a valid file. `ensure` writes nothing;
|
||||
# `ensure-json` writes `{}`, and settings.json needs that one - an empty file
|
||||
# is not valid JSON, and Claude Code discards a settings.json it cannot parse
|
||||
# wholesale, taking every permission, hook and plugin the operator configured
|
||||
# with it.
|
||||
#
|
||||
# Opt-in per file rather than implied: an empty statusline.sh would be
|
||||
# installed executable and run as the status line, so that one stays absent.
|
||||
emit_staged_config() {
|
||||
local src="$1" rel="$2"
|
||||
local src="$1" rel="$2" ensure="${3:-}"
|
||||
[ -n "$src" ] && [ -n "$rel" ] || return 0
|
||||
case "$ensure" in
|
||||
''|ensure|ensure-json) ;;
|
||||
*) echo "Harness plugin emitted an invalid staged-config flag: $ensure" >&2
|
||||
exit 1 ;;
|
||||
esac
|
||||
# The relative path becomes a location under $HOME inside the container.
|
||||
# Anything that could climb out of it, or that is not relative at all, is
|
||||
# a plugin bug rather than something to normalise quietly.
|
||||
@@ -403,7 +420,7 @@ emit_staged_config() {
|
||||
echo "Harness plugin emitted an invalid staged-config path: $rel" >&2
|
||||
exit 1 ;;
|
||||
esac
|
||||
DEJIMA_STAGED_CONFIG+=("$src"$'\t'"$rel")
|
||||
DEJIMA_STAGED_CONFIG+=("$src"$'\t'"$rel"$'\t'"$ensure")
|
||||
}
|
||||
|
||||
harness_staged_config_load() {
|
||||
|
||||
+187
-7
@@ -914,6 +914,174 @@ _identity_key_specs() {
|
||||
done
|
||||
}
|
||||
|
||||
# The pinned defaults file, rendered. Every key that HAS a default, at the
|
||||
# value this release ships, grouped by section.
|
||||
#
|
||||
# Keys with no default are deliberately absent, because there is nothing to
|
||||
# pin: they are the ones an operator must supply, and "has no line in
|
||||
# defaults.conf" is exactly what docs/configuration.md marks as required. The
|
||||
# document and the file agree by construction rather than by someone keeping
|
||||
# them in step.
|
||||
#
|
||||
# EVERY installed provider, not only the selected one. Pinning just the
|
||||
# harness in use would leave the next one an operator selects handing them
|
||||
# ITS current defaults - the same failure one axis over, a year later, when
|
||||
# nothing about their config changed.
|
||||
# $1: "keep" to take a value already pinned in defaults.conf over the shipped
|
||||
# one, which is what a rebuild does - it adds keys and drops retired ones
|
||||
# without moving anything already decided. Empty renders the shipped defaults,
|
||||
# which is what a first pin does.
|
||||
config_defaults_render() {
|
||||
local keep="${1:-}" key type default axis name spec section last=""
|
||||
printf '# dejima'"'"'s own defaults, pinned when this machine first saw each key.\n'
|
||||
printf '#\n'
|
||||
printf '# NOT yours to edit - ./install.sh rewrites it, keeping the values already\n'
|
||||
printf '# here so a release cannot move a setting you never chose. Put your own\n'
|
||||
printf '# settings in %s, which overrides everything below and which\n' "$(config_path)"
|
||||
printf '# dejima never writes.\n'
|
||||
while IFS= read -r key; do
|
||||
[ -n "$key" ] || continue
|
||||
default="$(config_default "$key")" || continue
|
||||
[ -n "$default" ] || continue
|
||||
[ "$keep" = keep ] && [ -n "${_CFG_BASE_SEEN[$key]+x}" ] \
|
||||
&& default="${_CFG_BASE_VAL[$key]}"
|
||||
section="${key%.*}"
|
||||
[ "$section" = "$last" ] || { printf '\n[%s]\n' "$section"; last="$section"; }
|
||||
printf '%s = %s\n' "${key##*.}" "$default"
|
||||
done < <(_config_keys_core_sorted)
|
||||
for axis in forge harness backend; do
|
||||
while IFS= read -r name; do
|
||||
[ -n "$name" ] || continue
|
||||
last=""
|
||||
for spec in $(_provider_keys_field "$axis" "$name" 2>/dev/null); do
|
||||
IFS=$'\t' read -r key type default <<< "$(_provider_keyspec_parse "$spec")"
|
||||
[ -n "$default" ] || continue
|
||||
[ "$keep" = keep ] && [ -n "${_CFG_BASE_SEEN[$axis.$name.$key]+x}" ] \
|
||||
&& default="${_CFG_BASE_VAL[$axis.$name.$key]}"
|
||||
[ -n "$last" ] || { printf '\n[%s.%s]\n' "$axis" "$name"; last=1; }
|
||||
printf '%s = %s\n' "$key" "$default"
|
||||
done
|
||||
done < <(provider_list "$axis" 2>/dev/null)
|
||||
done
|
||||
}
|
||||
|
||||
# The core schema's keys with their sections kept together, so the rendered
|
||||
# file groups rather than repeating a header per key. The schema's own order
|
||||
# is otherwise preserved: it is the order docs/configuration.md follows.
|
||||
_config_keys_core_sorted() {
|
||||
local key member seen=" " sect
|
||||
local -a keys=()
|
||||
while IFS= read -r key; do
|
||||
[ -n "$key" ] || continue
|
||||
keys+=("$key")
|
||||
done < <(_config_table | cut -f1 | grep -E '^[a-z-]+\.[a-z_]+$')
|
||||
for key in "${keys[@]}"; do
|
||||
sect="${key%.*}"
|
||||
case "$seen" in *" $sect "*) continue ;; esac
|
||||
seen="$seen$sect "
|
||||
for member in "${keys[@]}"; do
|
||||
[ "${member%.*}" = "$sect" ] && printf '%s\n' "$member"
|
||||
done
|
||||
done
|
||||
}
|
||||
|
||||
# Rebuild the pinned file: keys this release added appended, keys it retired
|
||||
# dropped, and every value already decided left exactly as it was.
|
||||
#
|
||||
# Both halves fall out of rendering with "keep" rather than being two rules
|
||||
# somebody has to remember, and pruning is not the tidier half: an unknown key
|
||||
# is one this dejima cannot read, so a file that only ever grew would collect
|
||||
# them until the notice about it became permanent.
|
||||
#
|
||||
# Prints what it did, and what has drifted - a shipped default that no longer
|
||||
# matches the pinned one - naming both values and the line that would adopt
|
||||
# it. It does not adopt it. That would be dejima writing a decision into a
|
||||
# session's behaviour on an upgrade, which is the thing this exists to stop.
|
||||
config_defaults_rebuild() {
|
||||
local path tmp key section leaf pinned shipped
|
||||
local added=0 dropped=0 drifted=0
|
||||
path="$(config_defaults_path)"
|
||||
_config_parse_defaults "$path"
|
||||
dropped="$_CFG_BASE_STALE"
|
||||
mkdir -p "$(dirname "$path")" || return 1
|
||||
tmp="$(mktemp "$path.XXXXXX")" || return 1
|
||||
config_defaults_render keep > "$tmp" || { rm -f "$tmp"; return 1; }
|
||||
chmod 600 "$tmp"
|
||||
|
||||
# Counted from the rendered file rather than guessed at: a key with no
|
||||
# line in the old one is a key this release added.
|
||||
while IFS= read -r key; do
|
||||
[ -n "${_CFG_BASE_SEEN[$key]+x}" ] || added=$((added + 1))
|
||||
done < <(_config_defaults_keys "$tmp")
|
||||
|
||||
if [ ! -e "$path" ]; then
|
||||
mv "$tmp" "$path" || { rm -f "$tmp"; return 1; }
|
||||
printf '\nPinned this release'"'"'s defaults in %s.\n' "$path"
|
||||
return 0
|
||||
fi
|
||||
mv "$tmp" "$path" || { rm -f "$tmp"; return 1; }
|
||||
|
||||
# Each line carries the blank one before it, so a rebuild with nothing to
|
||||
# say prints nothing at all rather than a gap where a message would go.
|
||||
[ "$added" -eq 0 ] || printf '\nPinned %s new default(s) in %s.\n' "$added" "$path"
|
||||
[ "$dropped" -eq 0 ] || printf '\nDropped %s retired line(s) from %s.\n' "$dropped" "$path"
|
||||
|
||||
# Drift, last, because it is the part that asks something of the operator.
|
||||
while IFS= read -r key; do
|
||||
[ -n "${_CFG_BASE_SEEN[$key]+x}" ] || continue
|
||||
pinned="${_CFG_BASE_VAL[$key]}"
|
||||
shipped="$(config_default "$key")" || continue
|
||||
[ "$pinned" = "$shipped" ] && continue
|
||||
[ "$drifted" -eq 0 ] && {
|
||||
printf '\ndejima ships a different default now for %s:\n' "$path"
|
||||
}
|
||||
drifted=$((drifted + 1))
|
||||
section="${key%.*}"; leaf="${key##*.}"
|
||||
printf ' [%s] %s: yours stays %s, dejima now ships %s\n' \
|
||||
"$section" "$leaf" "$pinned" "$shipped"
|
||||
done < <(_config_defaults_keys "$path")
|
||||
[ "$drifted" -eq 0 ] || {
|
||||
printf '\nYours are kept, so nothing about your sandbox changed. To take one,\n'
|
||||
printf 'write it in %s under its section - never in the file above.\n' "$(config_path)"
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
# "section.key" for each line of a rendered defaults file, in file order.
|
||||
_config_defaults_keys() {
|
||||
awk '
|
||||
/^\[/ { s = $0; gsub(/^\[|\]$/, "", s); next }
|
||||
/^[a-z_]+ = / && s != "" { split($0, p, " = "); print s "." p[1] }
|
||||
' "$1"
|
||||
}
|
||||
|
||||
# Write it if it is not there. dejima's own file, so dejima creating it is not
|
||||
# the kind of write this whole arrangement removes - it is the opposite of
|
||||
# one. Without it the pin would exist only for operators who ran ./install.sh,
|
||||
# and dejima runs perfectly well from a checkout without it.
|
||||
#
|
||||
# Present is left alone: no rebuild, no prune, no drift check. Those belong to
|
||||
# ./install.sh, which is when the shipped defaults actually change. Doing them
|
||||
# per launch would put a standing condition in front of every one.
|
||||
#
|
||||
# Prints one line, and only when it creates the file.
|
||||
config_defaults_ensure() {
|
||||
local path tmp
|
||||
path="$(config_defaults_path)"
|
||||
[ -e "$path" ] && return 0
|
||||
mkdir -p "$(dirname "$path")" || return 0
|
||||
tmp="$(mktemp "$path.XXXXXX")" || return 0
|
||||
if config_defaults_render > "$tmp" 2>/dev/null; then
|
||||
chmod 600 "$tmp"
|
||||
mv "$tmp" "$path" || { rm -f "$tmp"; return 0; }
|
||||
printf 'Pinned dejima'"'"'s current defaults in %s so a later release cannot move them.\n' \
|
||||
"$path" >&2
|
||||
else
|
||||
rm -f "$tmp"
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# Looks up one [forge.<name>]/[harness.<name>] key's TYPE<TAB>DEFAULT in that
|
||||
# provider's `keys` manifest entry instead of a row hardcoded in core.
|
||||
_config_extra_spec() {
|
||||
@@ -1107,7 +1275,7 @@ identity_validate() {
|
||||
# "once" that a session could not reach, and a deprecation nobody sees
|
||||
# until removal is not a deprecation.
|
||||
if config_is_set core ssh_key; then
|
||||
printf ' %s[core] ssh_key is deprecated%s - it is [identity.default] ssh_key\n' \
|
||||
printf '\n %s[core] ssh_key is deprecated%s - it is [identity.default] ssh_key\n' \
|
||||
"${C_WARN:-}" "${C_RESET:-}" >&2
|
||||
printf ' under its old name.\n' >&2
|
||||
if [ -n "$name" ]; then
|
||||
@@ -1123,8 +1291,10 @@ identity_validate() {
|
||||
# force it answers with THAT identity's key - and printing it as the
|
||||
# thing to move would name a key the operator is not being asked to
|
||||
# touch.
|
||||
printf ' [identity.default]\n ssh_key = %s\n\n' \
|
||||
printf ' [identity.default]\n ssh_key = %s\n' \
|
||||
"$(_config_get_from_file core ssh_key)" >&2
|
||||
# Worth a pause before the build scrolls it away - see bin/dejima.
|
||||
DEJIMA_STARTUP_NOTICE=1
|
||||
fi
|
||||
|
||||
[ -n "$name" ] || return 0
|
||||
@@ -1670,8 +1840,6 @@ If a task genuinely needs something else the sandbox can't provide -
|
||||
broader network access than a domain or two, host-level or system changes,
|
||||
GUI/hardware access - stop and explain clearly to the user what you need
|
||||
and why, instead of trying to work around the firewall or sandbox yourself.
|
||||
They can continue this exact session outside the container by running on
|
||||
the host:
|
||||
BRIEFING_EOF
|
||||
}
|
||||
|
||||
@@ -1834,6 +2002,16 @@ _briefing_resolve_key() {
|
||||
if [ -n "${DEJIMA_RW_MOUNTS:-}" ]; then
|
||||
printf '\n These host directories are bind-mounted read-write at their own paths and\n outlive the container too: %s.\n ' "$DEJIMA_RW_MOUNTS"
|
||||
fi
|
||||
# A separate sentence, because the first one is not true of these.
|
||||
# A per-project mount arrives at the path it names, like the
|
||||
# others, but what is behind it is this project's own slice of
|
||||
# that directory rather than the directory - so "these host
|
||||
# directories ... at their own paths" would tell a session it can
|
||||
# see something it cannot, and imply the next project's session
|
||||
# will read what it leaves.
|
||||
if [ -n "${DEJIMA_RW_PP_MOUNTS:-}" ]; then
|
||||
printf '\n These are bind-mounted read-write too and also outlive the container, but\n each is THIS project'"'"'s own directory rather than one every project shares -\n the same arrangement as ~/shared: %s.\n ' "$DEJIMA_RW_PP_MOUNTS"
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
echo "internal error: no resolver for whitelisted briefing key: $key" >&2
|
||||
@@ -2025,9 +2203,11 @@ briefing_compose() {
|
||||
# The harness fragment closes the briefing: it supplies both the resume
|
||||
# command closing_a has just promised and the sentence about where the
|
||||
# session history it resumes from actually lives. Both are the harness's
|
||||
# own facts - Claude Code resumes from a bind-mounted ~/.claude, Cline
|
||||
# from a per-project state directory named in the command itself - and
|
||||
# core stating either one meant stating Claude Code's.
|
||||
# own facts, INCLUDING whether there is anything to say. Claude Code
|
||||
# resumes from a bind-mounted ~/.claude and says so; Cline cannot be
|
||||
# resumed outside dejima at all, and a core sentence promising it could
|
||||
# was Claude Code's fact stated as everyone's - which left a Cline session
|
||||
# able to send its operator somewhere that does not work.
|
||||
#
|
||||
# $requirements and $project_doc (the missing-dependency report and
|
||||
# .dejima.md) stay hook-computed and hook-appended after this - they read
|
||||
|
||||
+20
-8
@@ -17,16 +17,28 @@
|
||||
# runs inside the container, the other is a separate library).
|
||||
ws_slug() { printf '%s' "$1" | sed 's/[^a-zA-Z0-9]/-/g'; }
|
||||
|
||||
# Every per-project share directory lives under this one subdirectory of
|
||||
# `[core] share_dir` rather than directly in it, which is what makes "left
|
||||
# over from before the split" a decidable question: anything at share_dir's
|
||||
# top level other than this directory predates the layout, because nothing
|
||||
# writes there any more. It is also the directory to mount over sshfs - one
|
||||
# entry per project, with no loose files mixed in.
|
||||
share_projects_root() { printf '%s/projects' "${1%/}"; }
|
||||
# The per-project layout, and there is one of it because there is more than one
|
||||
# caller: `[core] share_dir` slices this way, and so does every
|
||||
# `[mounts] read_write_per_project` entry. A host directory sliced per project
|
||||
# holds one `projects` subdirectory, and one directory per slug inside it.
|
||||
#
|
||||
# Everything lives under that single subdirectory rather than directly in the
|
||||
# root, which is what makes "left over from before, or put there by hand" a
|
||||
# decidable question: anything at the root's top level other than this
|
||||
# directory was not written by dejima. It is also the directory to mount over
|
||||
# sshfs - one entry per project, with no loose files mixed in.
|
||||
projects_root() { printf '%s/projects' "${1%/}"; }
|
||||
|
||||
# The directory a launch against $2 gets from a root sliced this way.
|
||||
project_dir_for() { printf '%s/%s' "$(projects_root "$1")" "$(ws_slug "$2")"; }
|
||||
|
||||
# share_dir's own spelling of the two above. `[core] share_dir` is one
|
||||
# instance of the layout, and these names are what let bin/dejima-share go on
|
||||
# talking about a share rather than about "a per-project root".
|
||||
share_projects_root() { projects_root "$1"; }
|
||||
|
||||
# The host directory mounted at ~/shared for a launch against $2.
|
||||
share_dir_for() { printf '%s/%s' "$(share_projects_root "$1")" "$(ws_slug "$2")"; }
|
||||
share_dir_for() { project_dir_for "$1" "$2"; }
|
||||
|
||||
# The slugs belonging to $1: its own, plus one per live git worktree nested
|
||||
# underneath it. One per line, sorted, no duplicates.
|
||||
|
||||
+15
-6
@@ -33,7 +33,7 @@ dejima-update-check - say when this dejima checkout is behind its remote
|
||||
Usage: dejima-update-check
|
||||
|
||||
Compares the checkout bin/dejima runs from against the branch it tracks and
|
||||
prints one line when the remote has commits this checkout does not. Run at
|
||||
prints a notice when the remote has commits this checkout does not. Run at
|
||||
startup by bin/dejima; safe to run by hand.
|
||||
|
||||
Silent when the checkout is current or ahead, when [core] update_check is off,
|
||||
@@ -175,16 +175,25 @@ color_init
|
||||
# fire on the terminal of the operator this is trying to inform.
|
||||
_remote_shown="$(sanitize "$remote")"
|
||||
_branch_shown="$(sanitize "${upstream#refs/heads/}")"
|
||||
# A blank line either side, the indent the other startup warnings carry, and
|
||||
# C_WARN: a launch prints this between its startup lines and the image build,
|
||||
# which starts scrolling immediately after, and two plain lines there are
|
||||
# indistinguishable from either.
|
||||
printf '\n' >&2
|
||||
# The count only when it is known. It is not, whenever the remote's tip is a
|
||||
# commit this checkout has never fetched, which is the common case - and a
|
||||
# number invented for those would be the number of commits it can see, not the
|
||||
# number there are.
|
||||
if [ -n "$count" ] && [ "$count" != "0" ]; then
|
||||
if [ "$count" = "1" ]; then noun="commit"; else noun="commits"; fi
|
||||
printf '%sdejima%s: %s/%s has %s %s this checkout does not.\n' \
|
||||
"$C_KEY" "$C_RESET" "$_remote_shown" "$_branch_shown" "$C_INFO$count$C_RESET" "$noun" >&2
|
||||
printf ' %sdejima: %s/%s has %s %s this checkout does not.%s\n' \
|
||||
"$C_WARN" "$_remote_shown" "$_branch_shown" "$count" "$noun" "$C_RESET" >&2
|
||||
else
|
||||
printf '%sdejima%s: %s/%s has commits this checkout does not.\n' \
|
||||
"$C_KEY" "$C_RESET" "$_remote_shown" "$_branch_shown" >&2
|
||||
printf ' %sdejima: %s/%s has commits this checkout does not.%s\n' \
|
||||
"$C_WARN" "$_remote_shown" "$_branch_shown" "$C_RESET" >&2
|
||||
fi
|
||||
printf ' Update with %sgit -C %s pull%s\n' "$C_KEY" "$REPO_ROOT" "$C_RESET" >&2
|
||||
# install.sh is safe to re-run, and a release can change what it links on PATH,
|
||||
# so pulling is only half the update. Both paths absolute because the operator
|
||||
# reading this is not necessarily standing in this checkout.
|
||||
printf ' Update with %sgit -C %s pull && %s/install.sh%s\n\n' \
|
||||
"$C_KEY" "$REPO_ROOT" "$REPO_ROOT" "$C_RESET" >&2
|
||||
|
||||
+111
-122
@@ -31,10 +31,16 @@
|
||||
# Written through a temp file and renamed: the destination is the settings
|
||||
# layer bin/dejima is about to mount, and a jq that failed partway would
|
||||
# otherwise leave the session with a truncated one.
|
||||
# Where dejima's own hooks live inside the container. Baked into the image
|
||||
# (see docker/Dockerfile) rather than reached through the ~/.claude/hooks
|
||||
# mount, because that mount is the operator's configuration and dejima should
|
||||
# not have to write to it to make its own sandbox work.
|
||||
DEJIMA_HOOK_DIR=/usr/local/lib/dejima/hooks
|
||||
|
||||
harness_register_notify() {
|
||||
local settings="$1" tmp
|
||||
tmp="$(mktemp "${TMPDIR:-/tmp}/dejima-settings-remote.XXXXXX.json")"
|
||||
if jq '.hooks.Stop = ((.hooks.Stop // []) + [{"matcher": "", "hooks": [{"type": "command", "command": "$HOME/.claude/hooks/dejima-notify.sh done"}]}])' \
|
||||
if jq '.hooks.Stop = ((.hooks.Stop // []) + [{"matcher": "", "hooks": [{"type": "command", "command": "'"$DEJIMA_HOOK_DIR"'/dejima-notify.sh done"}]}])' \
|
||||
"$settings" > "$tmp"; then
|
||||
mv "$tmp" "$settings"
|
||||
else
|
||||
@@ -107,30 +113,16 @@ harness_state_mounts() {
|
||||
# what it can host (see `guards` in its manifest), and registering something
|
||||
# it did not declare would contradict the launch message that says so.
|
||||
harness_register_guards() {
|
||||
local repo="$1" backup="$2" settings="$HOME/.claude/settings.json"
|
||||
local repo="$1" settings="$HOME/.claude/settings.json"
|
||||
|
||||
mkdir -p "$HOME/.claude/hooks"
|
||||
|
||||
_link_hook() {
|
||||
local name="$1" src="$repo/hooks/$1" dest="$HOME/.claude/hooks/$1"
|
||||
if [ -L "$dest" ] && [ "$(readlink -f "$dest")" = "$(readlink -f "$src")" ]; then
|
||||
echo "Skipping: $name already linked to this repo's copy"
|
||||
return
|
||||
fi
|
||||
if [ -e "$dest" ] || [ -L "$dest" ]; then
|
||||
mkdir -p "$backup"
|
||||
echo "Moving: existing ~/.claude/hooks/$name to $backup/$name"
|
||||
mv "$dest" "$backup/$name"
|
||||
fi
|
||||
echo "Linking: $name ($src -> $dest)"
|
||||
ln -s "$src" "$dest"
|
||||
}
|
||||
|
||||
# Not a guard: this is R3, the briefing's delivery mechanism, and a
|
||||
# session without it does not know it is in a sandbox.
|
||||
_link_hook sandbox-context.sh
|
||||
harness_has_guard push-guard && _link_hook git-push-guard.sh
|
||||
harness_has_guard merge-guard && _link_hook merge-guard.sh
|
||||
# Registers nothing. Every hook dejima uses is baked into the image and
|
||||
# registered per launch into the sandbox's own --settings overlay, so
|
||||
# installing dejima writes nothing to the operator's Claude Code config.
|
||||
#
|
||||
# What is left is taking back what older versions of this function put
|
||||
# there. The hook is kept rather than deleted because "what a harness
|
||||
# needs on the host" is a real question a future one may answer
|
||||
# differently; for Claude Code the answer is now nothing.
|
||||
|
||||
# Symlink from before the gitea-merge-guard.sh -> merge-guard.sh rename
|
||||
# points at a hooks/ path that no longer exists. Only remove it if it is
|
||||
@@ -141,42 +133,18 @@ harness_register_guards() {
|
||||
rm -f "$old_merge_guard"
|
||||
fi
|
||||
|
||||
# Linked so it exists at a stable path for the read-only ~/.claude/hooks
|
||||
# mount, but deliberately NOT registered below: bin/dejima registers it
|
||||
# per session, into a throwaway settings copy, and only when remote
|
||||
# access is configured. Registering it here would fire it for every
|
||||
# unsandboxed host session too, where it has no marker directory to
|
||||
# write to.
|
||||
harness_has_guard notify && _link_hook dejima-notify.sh
|
||||
|
||||
# deny-dangerous-bash.sh is the one hook that is NOT sandbox-gated: it
|
||||
# has no DEJIMA check, so it applies to the operator's own host sessions
|
||||
# as well. That is deliberate, and it is why it may already be owned by
|
||||
# another install (dotfiles, say) - only claim it if nothing else
|
||||
# provides it, so this repo works standalone without clobbering an
|
||||
# existing setup.
|
||||
if harness_has_guard bash-deny; then
|
||||
local deny_dest="$HOME/.claude/hooks/deny-dangerous-bash.sh"
|
||||
if [ -e "$deny_dest" ] || [ -L "$deny_dest" ]; then
|
||||
echo "Skipping: deny-dangerous-bash.sh already present at $deny_dest (leaving it as-is)"
|
||||
else
|
||||
echo "Linking: deny-dangerous-bash.sh ($repo/hooks/deny-dangerous-bash.sh -> $deny_dest)"
|
||||
ln -s "$repo/hooks/deny-dangerous-bash.sh" "$deny_dest"
|
||||
fi
|
||||
fi
|
||||
echo
|
||||
|
||||
if [ ! -f "$settings" ]; then
|
||||
echo "Creating $settings"
|
||||
echo '{}' > "$settings"
|
||||
fi
|
||||
# No settings.json is created here either: a file dejima writes nothing
|
||||
# into is a file it has no business bringing into existence.
|
||||
[ -f "$settings" ] || return 0
|
||||
|
||||
# The registration that went with the symlink removed above. Two things
|
||||
# depend on it going: Claude Code reports a missing hook command on every
|
||||
# session start while it points at a path that no longer exists, and -
|
||||
# until the dedupe below stopped matching it - it also stood in for the
|
||||
# registration it was renamed from, so `_register merge-guard.sh` skipped
|
||||
# and the merge guard was registered nowhere at all. Dropped only when
|
||||
# registration it was renamed from, so the merge guard's own registration
|
||||
# was skipped and it was registered nowhere at all. Dropped only when
|
||||
# the path it names is gone, so an operator's own file at that name keeps
|
||||
# its registration, matching the symlink check above.
|
||||
if [ ! -e "$old_merge_guard" ] && jq -e --arg s "/gitea-merge-guard.sh" \
|
||||
@@ -191,34 +159,34 @@ harness_register_guards() {
|
||||
"$settings" > "$stale_tmp" && mv "$stale_tmp" "$settings"
|
||||
fi
|
||||
|
||||
_register() {
|
||||
local event="$1" matcher="$2" script_name="$3" description="$4"
|
||||
# endswith on the leading slash, not test(): test() is an unanchored
|
||||
# regex, so a hook whose name merely ends in another's - which is what
|
||||
# a rename like gitea-merge-guard.sh -> merge-guard.sh produces - read
|
||||
# as already registered, and the new hook was silently never added.
|
||||
if jq -e --arg s "/$script_name" \
|
||||
"[.hooks.${event}[]?.hooks[]?.command // empty] | any(endswith(\$s))" \
|
||||
"$settings" >/dev/null 2>&1; then
|
||||
echo "Skipping: $script_name already registered as a $event hook"
|
||||
return
|
||||
# An upgrade has to take back what an older install.sh put here, or the
|
||||
# claim above is only true of a fresh one. Removed by name and only when
|
||||
# the symlink points into THIS repo: an operator's own file at one of
|
||||
# these names is theirs.
|
||||
local moved dest
|
||||
for moved in sandbox-context.sh git-push-guard.sh merge-guard.sh dejima-notify.sh \
|
||||
deny-dangerous-bash.sh; do
|
||||
dest="$HOME/.claude/hooks/$moved"
|
||||
if [ -L "$dest" ] && [ "$(readlink -f "$dest")" = "$(readlink -f "$repo/hooks/$moved")" ]; then
|
||||
echo "Removing: $moved from ~/.claude/hooks (the sandbox registers its own now)"
|
||||
rm -f "$dest"
|
||||
fi
|
||||
echo "Registering $script_name as a $event hook in $settings ($description)"
|
||||
local tmp
|
||||
tmp=$(mktemp)
|
||||
jq --arg matcher "$matcher" --arg cmd "\$HOME/.claude/hooks/$script_name" \
|
||||
".hooks.${event} = ((.hooks.${event} // []) + [{\"matcher\": \$matcher, \"hooks\": [{\"type\": \"command\", \"command\": \$cmd}]}])" \
|
||||
"$settings" > "$tmp" && mv "$tmp" "$settings"
|
||||
}
|
||||
|
||||
_register SessionStart "startup|resume|clear|compact" sandbox-context.sh \
|
||||
"injects sandbox context on session start"
|
||||
harness_has_guard push-guard && _register PreToolUse Bash git-push-guard.sh \
|
||||
"blocks pushes outside the allowed host(s)/branches"
|
||||
harness_has_guard merge-guard && _register PreToolUse Bash merge-guard.sh \
|
||||
"blocks the configured forge's merge command inside the sandbox"
|
||||
harness_has_guard bash-deny && _register PreToolUse Bash deny-dangerous-bash.sh \
|
||||
"blocks obviously destructive Bash commands"
|
||||
# And its registration, matched the same endswith way _register does.
|
||||
local ev
|
||||
for ev in SessionStart PreToolUse Stop; do
|
||||
if jq -e --arg s "/$moved" \
|
||||
"[.hooks.${ev}[]?.hooks[]?.command // empty] | any(endswith(\$s))" \
|
||||
"$settings" >/dev/null 2>&1; then
|
||||
echo "Removing: $moved's $ev registration from $settings"
|
||||
local mv_tmp; mv_tmp=$(mktemp)
|
||||
jq --arg s "/$moved" \
|
||||
".hooks.${ev} = ((.hooks.${ev} // [])
|
||||
| map(.hooks = ((.hooks // []) | map(select((.command // \"\") | endswith(\$s) | not))))
|
||||
| map(select((.hooks | length) > 0)))" \
|
||||
"$settings" > "$mv_tmp" && mv "$mv_tmp" "$settings"
|
||||
fi
|
||||
done
|
||||
done
|
||||
echo
|
||||
return 0
|
||||
}
|
||||
@@ -380,14 +348,10 @@ harness_prepare_config() {
|
||||
local overlay="$1" settings="$HOME/.claude/settings.json" settings_error
|
||||
|
||||
|
||||
# install.sh normally creates settings.json, and many hosts have no
|
||||
# CLAUDE.md at all. Created empty rather than left absent because
|
||||
# harness_staged_config below emits both unconditionally and core skips
|
||||
# staging a file that does not exist - so without this the session would
|
||||
# silently start without them. statusline.sh is deliberately NOT created:
|
||||
# an empty one would be staged executable and run as the status line.
|
||||
[ -f "$settings" ] || echo '{}' > "$settings"
|
||||
[ -f "$HOME/.claude/CLAUDE.md" ] || : > "$HOME/.claude/CLAUDE.md"
|
||||
# Nothing is created on the host. A settings.json or CLAUDE.md the
|
||||
# operator does not have is not one dejima should make on their machine so
|
||||
# that it has something to copy - harness_staged_config asks for an empty
|
||||
# one in the CONTAINER instead, which is where it is needed.
|
||||
|
||||
# Claude Code ignores a settings.json it cannot parse, wholesale and with
|
||||
# only a startup warning, so a stray edit on the host costs the sandbox
|
||||
@@ -396,7 +360,8 @@ harness_prepare_config() {
|
||||
# symptom is an unexpected bypass-mode warning rather than anything
|
||||
# mentioning settings. The file is staged verbatim, so nothing else parses
|
||||
# it before the session reads it - check here, where the fix is at hand.
|
||||
if ! settings_error="$(jq . "$settings" 2>&1 >/dev/null)"; then
|
||||
# Absent is not unparseable: there is simply nothing to stage.
|
||||
if [ -f "$settings" ] && ! settings_error="$(jq . "$settings" 2>&1 >/dev/null)"; then
|
||||
echo "$settings is not valid JSON:" >&2
|
||||
echo " $settings_error" >&2
|
||||
echo "Claude Code would ignore it entirely and the sandbox would start with" >&2
|
||||
@@ -411,7 +376,38 @@ harness_prepare_config() {
|
||||
# --settings layer (the manifest's settings_args) above
|
||||
# ~/.claude/settings.json, which holds only what the sandbox overrides;
|
||||
# the operator's hooks, permissions and plugins still apply from there.
|
||||
#
|
||||
# And dejima's own hooks, registered HERE rather than in the operator's
|
||||
# ~/.claude/settings.json. Claude Code merges this layer with theirs, so
|
||||
# their hooks still run - proven by the one dejima deliberately leaves on
|
||||
# the host, deny-dangerous-bash.sh, which fires inside a container while
|
||||
# being registered only there.
|
||||
#
|
||||
# Registering them here is what lets install.sh stop writing to a config
|
||||
# that is not dejima's. The scripts are baked into the image, so a session
|
||||
# cannot unlink a guard the way it could one staged into a directory it
|
||||
# owns.
|
||||
jq -n '{sandbox: {enabled: false}}' > "$overlay"
|
||||
_register_overlay_hook() { # <event> <matcher> <script>
|
||||
local event="$1" matcher="$2" script="$3" tmp
|
||||
tmp="$(mktemp "${TMPDIR:-/tmp}/dejima-overlay.XXXXXX.json")" || return 1
|
||||
if jq --arg m "$matcher" --arg c "$DEJIMA_HOOK_DIR/$script" \
|
||||
".hooks.${event} = ((.hooks.${event} // []) +
|
||||
[{\"matcher\": \$m, \"hooks\": [{\"type\": \"command\", \"command\": \$c}]}])" \
|
||||
"$overlay" > "$tmp"; then
|
||||
mv "$tmp" "$overlay"
|
||||
else
|
||||
rm -f "$tmp"
|
||||
echo "warning: could not register $script; it will not run this session." >&2
|
||||
fi
|
||||
}
|
||||
# R3: without this a session is not told it is in a sandbox at all, which
|
||||
# is why it is registered whether or not any guard is declared.
|
||||
_register_overlay_hook SessionStart "startup|resume|clear|compact" sandbox-context.sh
|
||||
harness_has_guard push-guard && _register_overlay_hook PreToolUse Bash git-push-guard.sh
|
||||
harness_has_guard merge-guard && _register_overlay_hook PreToolUse Bash merge-guard.sh
|
||||
harness_has_guard bash-deny && _register_overlay_hook PreToolUse Bash deny-dangerous-bash.sh
|
||||
return 0
|
||||
}
|
||||
|
||||
harness_staged_config() {
|
||||
@@ -419,10 +415,13 @@ harness_staged_config() {
|
||||
# and what a dangling symlink means, are core's to decide - filtering
|
||||
# here would skip a dangling one silently, which is the case that must
|
||||
# refuse rather than quietly leave the session without the file.
|
||||
local f
|
||||
for f in settings.json CLAUDE.md statusline.sh; do
|
||||
emit_staged_config "$HOME/.claude/$f" ".claude/$f"
|
||||
done
|
||||
# settings.json and CLAUDE.md are asked to exist even when the host has
|
||||
# none: Claude Code reads both, and an empty one reads the same as a file
|
||||
# the operator never made. statusline.sh is not - an empty one would be
|
||||
# installed executable and run as the status line.
|
||||
emit_staged_config "$HOME/.claude/settings.json" ".claude/settings.json" ensure-json
|
||||
emit_staged_config "$HOME/.claude/CLAUDE.md" ".claude/CLAUDE.md" ensure
|
||||
emit_staged_config "$HOME/.claude/statusline.sh" ".claude/statusline.sh"
|
||||
}
|
||||
|
||||
# Where this workspace's transcripts are, and which store key each belongs
|
||||
@@ -602,35 +601,25 @@ harness_transform_transcript() {
|
||||
' 2>/dev/null
|
||||
}
|
||||
|
||||
# Which of the hooks install.sh registers are actually in the operator's
|
||||
# settings, and which are not. Prints the missing ones, one per line, and
|
||||
# returns 1 if there are any - so the launch can name what is not in force
|
||||
# rather than assert that nothing is.
|
||||
# harness_guards_registered is deliberately not defined.
|
||||
#
|
||||
# Every hook this harness would install is checked, not just the briefing:
|
||||
# the realistic failure (install.sh never run with a harness set) is
|
||||
# all-or-nothing, but a hand-edited settings.json missing one guard is a
|
||||
# session quietly running without it, which is the case worth catching.
|
||||
# It read the operator's own ~/.claude/settings.json and named any of dejima's
|
||||
# hooks missing from it, because that is where install.sh used to put them and
|
||||
# "install.sh never run with a harness set" was a real way to get a session
|
||||
# with no briefing and no guards.
|
||||
#
|
||||
# dejima-notify.sh is deliberately absent: bin/dejima registers it per
|
||||
# session into a throwaway layer, and it is never in the operator's file.
|
||||
# Nothing is written there now: the hooks are baked into the image and
|
||||
# registered per launch into dejima's own --settings overlay, so an absent
|
||||
# registration in the operator's file is the correct state rather than a
|
||||
# broken one. Left defined, it named all four as not in force on every launch
|
||||
# of a host that had never run the old installer - and told the operator to
|
||||
# run ./install.sh, which strips exactly those registrations, making the
|
||||
# message permanent.
|
||||
#
|
||||
# Reads the operator's real settings rather than dejima's own layer, which
|
||||
# is composed fresh each launch and always looks right.
|
||||
harness_guards_registered() {
|
||||
local settings="$HOME/.claude/settings.json" missing=0
|
||||
_registered() {
|
||||
[ -f "$settings" ] || return 1
|
||||
jq -e --arg s "/$2" "[.hooks.$1[]?.hooks[]?.command // empty]
|
||||
| any(endswith(\$s))" "$settings" >/dev/null 2>&1
|
||||
}
|
||||
_registered SessionStart sandbox-context.sh \
|
||||
|| { printf 'the session briefing\n'; missing=1; }
|
||||
harness_has_guard push-guard && { _registered PreToolUse git-push-guard.sh \
|
||||
|| { printf 'push-guard\n'; missing=1; }; }
|
||||
harness_has_guard merge-guard && { _registered PreToolUse merge-guard.sh \
|
||||
|| { printf 'merge-guard\n'; missing=1; }; }
|
||||
harness_has_guard bash-deny && { _registered PreToolUse deny-dangerous-bash.sh \
|
||||
|| { printf 'bash-deny\n'; missing=1; }; }
|
||||
return "$missing"
|
||||
}
|
||||
# There is nothing to put in its place. What the overlay carries is composed
|
||||
# fresh each launch from the manifest, so reading it back would be asking
|
||||
# dejima whether it did what it just did; which guards the harness cannot host
|
||||
# at all is the manifest's, and harness_report_guards already says it.
|
||||
#
|
||||
# The hook itself stays in the plugin interface. What a harness needs wired on
|
||||
# the host is a real question, and a future one may answer it differently.
|
||||
|
||||
@@ -332,6 +332,91 @@ harness_prepare_config() {
|
||||
# nothing inside the container has to be told where its state is - the source
|
||||
# is keyed by dejima's workspace slug and only the target follows the harness,
|
||||
# which is what emit_mount_at is for.
|
||||
# The operator's own configuration, brought in from the host.
|
||||
#
|
||||
# Read-only, and that is the whole point rather than caution: everything here
|
||||
# is either an instruction the agent follows or a program cline runs. A
|
||||
# session that could rewrite them would be choosing what happens on the HOST
|
||||
# the next time cline starts outside a container - persistence for a prompt
|
||||
# injection, in files nobody re-reads before the next run.
|
||||
#
|
||||
# `scan` for the same reason claude-code's directories are scanned: on a Linux
|
||||
# host these are routinely symlinks into a dotfiles checkout, and the target
|
||||
# has to be mounted for the link to resolve inside the container. Core refuses
|
||||
# a target that overlaps a guarded store.
|
||||
#
|
||||
# ~/.agents/rules is deliberately absent. It reads like the obvious companion
|
||||
# to ~/.agents/skills and cline does not load it - checked by planting a file
|
||||
# in it and one in ~/Documents/Cline/Rules and asking `cline config` which it
|
||||
# had: only the second appeared. Mounting a directory the harness never reads
|
||||
# would be a promise dejima cannot keep.
|
||||
harness_config_dirs() {
|
||||
emit_config_dir "$HOME/.agents/skills" ro scan
|
||||
# Not confirmed loaded by that same probe, unlike skills. Mounted because
|
||||
# it is the documented default for --hooks-dir and because the cost of
|
||||
# being wrong is an empty directory, where the cost of leaving it out is a
|
||||
# hook that silently does not run.
|
||||
emit_config_dir "$HOME/.agents/hooks" ro scan
|
||||
emit_config_dir "$HOME/.cline/hooks" ro scan
|
||||
}
|
||||
|
||||
# AGENTS.md is instructions the agent follows, so a session that could write
|
||||
# the host's copy would be choosing what the next session is told. Staged
|
||||
# rather than mounted: the copy lands root-owned and read-only, and a file
|
||||
# that is a symlink into a dotfiles checkout arrives as its real content
|
||||
# rather than as a link the host can still swap.
|
||||
harness_staged_config() {
|
||||
emit_staged_config "$HOME/.agents/AGENTS.md" ".agents/AGENTS.md"
|
||||
|
||||
# Global rules, from both host locations into the one directory cline
|
||||
# actually reads.
|
||||
#
|
||||
# cline loads global rules from Documents/Cline/Rules and nowhere else -
|
||||
# ~/.agents/rules reads like its companion and is not read, checked by
|
||||
# planting a file in each and asking `cline config` which it had. So
|
||||
# ~/.agents/rules is supported by staging its files INTO the directory
|
||||
# that is read, rather than by mounting it where it sits and having
|
||||
# nothing load it.
|
||||
#
|
||||
# File by file rather than as a directory mount, because dejima writes its
|
||||
# own briefing into that same directory: a read-only mount over it would
|
||||
# leave nowhere to put the briefing.
|
||||
local d f base briefing seen=" "
|
||||
# The briefing's own name, from the manifest rather than repeated here.
|
||||
# dejima wins that collision, and entrypoint.sh already delivers it after
|
||||
# the staged files - so staging a host file it is about to overwrite would
|
||||
# only add a mount nobody reads.
|
||||
briefing="$(basename "$(manifest_get harness cline briefing_path)")"
|
||||
# Documents first, so a name present in both wins there: it is cline's own
|
||||
# location for this, and where an operator editing rules through cline is
|
||||
# writing. Two sources staged to one destination would otherwise be two
|
||||
# bind mounts at the same container path.
|
||||
for d in "$HOME/Documents/Cline/Rules" "$HOME/.agents/rules"; do
|
||||
[ -d "$d" ] || continue
|
||||
# .md and .txt are what cline reads out of a rules directory.
|
||||
for f in "$d"/*.md "$d"/*.txt; do
|
||||
[ -f "$f" ] || continue
|
||||
base="$(basename "$f")"
|
||||
[ "$base" = "$briefing" ] && continue
|
||||
case "$seen" in *" $base "*) continue ;; esac
|
||||
seen="$seen$base "
|
||||
emit_staged_config "$f" "Documents/Cline/Rules/$base"
|
||||
done
|
||||
done
|
||||
}
|
||||
|
||||
# Writable copies, discarded with the container.
|
||||
#
|
||||
# cline rewrites both of these as it runs - its settings TUI toggles them - so
|
||||
# a read-only copy would fail the write rather than the persistence, and the
|
||||
# persistence is the part that matters. cline_mcp_settings.json names MCP
|
||||
# servers, which are commands cline executes: a session that could write the
|
||||
# host's copy would be choosing what runs there next time.
|
||||
harness_seed() {
|
||||
emit_seed_from "$HOME/.cline/settings.json" ".cline/settings.json"
|
||||
emit_seed_from "$HOME/.cline/cline_mcp_settings.json" ".cline/cline_mcp_settings.json"
|
||||
}
|
||||
|
||||
harness_state_mounts() {
|
||||
local dir
|
||||
dir="$(_cline_state_dir "$1")" || return 0
|
||||
|
||||
+21
-1
@@ -28,6 +28,11 @@ ENV TZ="$TZ"
|
||||
# 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.
|
||||
#
|
||||
# libcap2-bin is capsh, which entrypoint.sh uses to hand the session a
|
||||
# container it holds no capabilities in. It was already here as a dependency of
|
||||
# iproute2; a security control resting on another package's dependency graph is
|
||||
# one an unrelated package change removes.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
less \
|
||||
git \
|
||||
@@ -41,6 +46,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
iptables \
|
||||
ipset \
|
||||
iproute2 \
|
||||
libcap2-bin \
|
||||
dnsutils \
|
||||
aggregate \
|
||||
jq \
|
||||
@@ -266,8 +272,11 @@ RUN npm install -g playwright@${PLAYWRIGHT_VERSION} && \
|
||||
# are supplied at runtime, not baked into the image - see
|
||||
# docker-compose.yml/entrypoint.sh.
|
||||
COPY docker/fixed-domains.sh /usr/local/bin/
|
||||
COPY docker/caps-lib.sh /usr/local/bin/
|
||||
COPY docker/dns-proxy.sh /usr/local/bin/
|
||||
COPY docker/init-firewall.sh /usr/local/bin/
|
||||
COPY docker/firewall-up.sh /usr/local/bin/
|
||||
COPY docker/filter-entrypoint.sh /usr/local/bin/
|
||||
COPY docker/log-shipper.sh /usr/local/bin/
|
||||
COPY docker/ulogd.conf /etc/ulogd-sandbox.conf
|
||||
# Into $VIMRUNTIME/plugin, not /etc/xdg/nvim - see the file's own comment.
|
||||
@@ -287,6 +296,17 @@ COPY docker/forge-guard.sh /usr/local/lib/dejima/forge-guard.sh
|
||||
# entrypoint.sh, which renders the briefing before the session starts - to
|
||||
# hand to the harness as an argument, or to write to a file it reads.
|
||||
COPY hooks/dejima-briefing-render.sh /usr/local/lib/dejima/dejima-briefing-render.sh
|
||||
# dejima's own hooks, for the same reason and one more: they are registered
|
||||
# into the sandbox's --settings overlay rather than into the operator's
|
||||
# ~/.claude/settings.json, so nothing has to be written to a host config to
|
||||
# make the sandbox work. Baked rather than mounted from the checkout because a
|
||||
# guard whose script can change under a running session is not one.
|
||||
#
|
||||
COPY hooks/deny-dangerous-bash.sh /usr/local/lib/dejima/hooks/deny-dangerous-bash.sh
|
||||
COPY hooks/sandbox-context.sh /usr/local/lib/dejima/hooks/sandbox-context.sh
|
||||
COPY hooks/git-push-guard.sh /usr/local/lib/dejima/hooks/git-push-guard.sh
|
||||
COPY hooks/merge-guard.sh /usr/local/lib/dejima/hooks/merge-guard.sh
|
||||
COPY hooks/dejima-notify.sh /usr/local/lib/dejima/hooks/dejima-notify.sh
|
||||
# Back to root before the first RUN that writes outside /home/node. The npm
|
||||
# installs above run as node deliberately; COPY writes as root regardless of
|
||||
# USER, so the copies above are unaffected, but the install(1) below is a RUN
|
||||
@@ -312,7 +332,7 @@ RUN bash /tmp/install-forge-bins.sh /tmp/forges /usr/local/lib/dejima/forge /usr
|
||||
# and describes what a page should look like instead of going and seeing.
|
||||
RUN ln -s /usr/local/share/npm-global/lib/node_modules /node_modules
|
||||
|
||||
RUN chmod +x /usr/local/bin/dns-proxy.sh /usr/local/bin/init-firewall.sh /usr/local/bin/log-shipper.sh /usr/local/bin/entrypoint.sh /usr/local/bin/dejima-harness-wrapper /usr/local/lib/dejima/git-guard.sh /usr/local/lib/dejima/forge-guard.sh /usr/local/lib/dejima/dejima-briefing-render.sh && \
|
||||
RUN chmod +x /usr/local/bin/dns-proxy.sh /usr/local/bin/init-firewall.sh /usr/local/bin/firewall-up.sh /usr/local/bin/filter-entrypoint.sh /usr/local/bin/log-shipper.sh /usr/local/bin/entrypoint.sh /usr/local/bin/dejima-harness-wrapper /usr/local/lib/dejima/git-guard.sh /usr/local/lib/dejima/forge-guard.sh /usr/local/lib/dejima/dejima-briefing-render.sh /usr/local/lib/dejima/hooks/*.sh && \
|
||||
echo 'export PATH="$PATH:/usr/local/share/npm-global/bin"' > /etc/profile.d/npm-global-path.sh
|
||||
|
||||
# Entrypoint runs as root (no USER node here) so it can set up the firewall
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
# Sourced by every entrypoint in this image. Nothing here runs on its own.
|
||||
#
|
||||
# The startup sequence needs real capabilities - it builds the packet filter,
|
||||
# binds an NFLOG socket, chowns files and drops privilege twice. Nothing after
|
||||
# it does, and a bounding set survives execve: a process that reaches uid 0
|
||||
# anywhere downstream recovers its permitted set from whatever bounding set it
|
||||
# inherited. So an unprivileged session whose effective set is empty still sits
|
||||
# inside a tree holding every capability the container declares.
|
||||
#
|
||||
# `drop_caps` is how anything that outlives a startup script is started, and it
|
||||
# is what makes a declared floor a description of the root phase rather than of
|
||||
# the session's whole life.
|
||||
#
|
||||
# capsh, not `setpriv --bounding-set`, which is the obvious tool and silently
|
||||
# the wrong one: without CAP_SETPCAP it exits 0 having changed nothing, so a
|
||||
# container that lost the capability would run every session with the full set
|
||||
# and report success at every step. capsh refuses out loud, which is the only
|
||||
# behaviour worth having here.
|
||||
#
|
||||
# `-- -c 'exec "$@"'` runs the command through capsh's shell and immediately
|
||||
# replaces it, so nothing is left between the caller and the process.
|
||||
drop_caps() {
|
||||
exec capsh --drop=all "$@"
|
||||
}
|
||||
|
||||
# Checked rather than assumed, because the failure it prevents is invisible:
|
||||
# capsh would abort each drop_caps call, and in an entrypoint the last of those
|
||||
# is the session, so the container would die at the point it looked ready.
|
||||
# Named here instead, against the file the floor actually lives in.
|
||||
#
|
||||
# Called once by each top-level entrypoint rather than from drop_caps: it is a
|
||||
# precondition of the whole script, and the point is to refuse before doing
|
||||
# work rather than partway through it.
|
||||
require_setpcap() {
|
||||
if capsh --decode="$(awk '/^CapEff:/ {print $2}' /proc/self/status)" \
|
||||
| grep -q cap_setpcap; then
|
||||
return 0
|
||||
fi
|
||||
echo "${0##*/}: this container does not hold CAP_SETPCAP, so the capabilities" >&2
|
||||
echo "the startup sequence needs cannot be taken away again before the session" >&2
|
||||
echo "starts - it would run inside a tree holding all of them." >&2
|
||||
echo "Refusing to start rather than running with a floor that is not the one" >&2
|
||||
echo "docker/docker-compose.yml declares. Add SETPCAP to its cap_add." >&2
|
||||
exit 1
|
||||
}
|
||||
@@ -22,14 +22,14 @@ const net = require("net");
|
||||
const tls = require("tls");
|
||||
const fs = require("fs");
|
||||
|
||||
const socketPath = process.env.SANDBOX_ANTHROPIC_PROXY_SOCKET;
|
||||
const transport = process.env.SANDBOX_ANTHROPIC_TRANSPORT || "tls";
|
||||
const socketPath = process.env.SANDBOX_CREDENTIAL_PROXY_SOCKET;
|
||||
const transport = process.env.SANDBOX_CREDENTIAL_TRANSPORT || "tls";
|
||||
const port = parseInt(
|
||||
process.env.SANDBOX_ANTHROPIC_PROXY_PORT || (transport === "tls" ? "443" : "18300"),
|
||||
process.env.SANDBOX_CREDENTIAL_PROXY_PORT || (transport === "tls" ? "443" : "18300"),
|
||||
10,
|
||||
);
|
||||
if (!socketPath) {
|
||||
console.error("credential-proxy-forwarder: SANDBOX_ANTHROPIC_PROXY_SOCKET not set");
|
||||
console.error("credential-proxy-forwarder: SANDBOX_CREDENTIAL_PROXY_SOCKET not set");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -43,11 +43,11 @@ const pipe = (client) => {
|
||||
|
||||
let server;
|
||||
if (transport === "tls") {
|
||||
const certPath = process.env.SANDBOX_ANTHROPIC_TLS_CERT;
|
||||
const keyPath = process.env.SANDBOX_ANTHROPIC_TLS_KEY;
|
||||
const certPath = process.env.SANDBOX_CREDENTIAL_TLS_CERT;
|
||||
const keyPath = process.env.SANDBOX_CREDENTIAL_TLS_KEY;
|
||||
if (!certPath || !keyPath) {
|
||||
console.error(
|
||||
"credential-proxy-forwarder: SANDBOX_ANTHROPIC_TLS_CERT/SANDBOX_ANTHROPIC_TLS_KEY not set",
|
||||
"credential-proxy-forwarder: SANDBOX_CREDENTIAL_TLS_CERT/SANDBOX_CREDENTIAL_TLS_KEY not set",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
+78
-32
@@ -48,43 +48,87 @@ services:
|
||||
# ten per run - becomes a permanent zombie, and a long session eventually
|
||||
# exhausts the cgroup pids limit and can no longer fork at all.
|
||||
init: true
|
||||
# An explicit floor rather than Docker's default set plus two. The default
|
||||
# carries about fourteen capabilities and this container uses seven: it
|
||||
# writes and chowns files, runs the firewall, drops privilege twice (gosu
|
||||
# for the session, `ulogd -u` for the log daemon), and signals dnsmasq.
|
||||
# FSETID, MKNOD, SETFCAP, SETPCAP, SYS_CHROOT, AUDIT_WRITE and
|
||||
# NET_BIND_SERVICE are used by nothing here - the last one because the
|
||||
# sysctl below is what lets the TLS terminator bind :443 unprivileged.
|
||||
# The session does not get a network namespace of its own. It joins the one
|
||||
# the filter container owns, where the egress rules are already installed -
|
||||
# so the filter bounding this session sits somewhere it declares no
|
||||
# capability to reach. bin/dejima starts that container and passes its name
|
||||
# here; see the block above its compose invocation, and docs/security.md.
|
||||
#
|
||||
# KILL is the one worth explaining, because it is not obvious from reading
|
||||
# entrypoint.sh and was missing from the first version of this list. It is
|
||||
# not for anything the entrypoint does. `dejima-allow --container` adds a
|
||||
# domain to a RUNNING session by appending a server= line and sending
|
||||
# dnsmasq a SIGHUP, from outside via docker exec - and dnsmasq drops to its
|
||||
# own user (`user=dnsmasq`), so root in this container cannot signal it
|
||||
# without CAP_KILL. Without this line, adding a domain mid-session fails
|
||||
# with "Operation not permitted", which is a documented feature every
|
||||
# session briefing tells the agent to ask for.
|
||||
# The default is for a `docker compose` invocation by hand, which this
|
||||
# file's first line says is not how to run it. It is `network_mode: none`
|
||||
# rather than any real network, because the value that makes a session safe
|
||||
# arrives from outside this file, and the question a default answers is
|
||||
# what happens when it does not arrive: no network at all is a sandbox that
|
||||
# visibly cannot work, where a real one is a sandbox that works and has
|
||||
# unfiltered egress.
|
||||
#
|
||||
# It does not affect `docker compose build`, which reads `build.network`.
|
||||
network_mode: ${SANDBOX_NETNS_CONTAINER:-none}
|
||||
# An explicit floor rather than Docker's default set. The default carries
|
||||
# about fourteen capabilities; this container writes and chowns files and
|
||||
# drops privilege once, to the session user. FSETID, MKNOD, SETFCAP,
|
||||
# SYS_CHROOT, AUDIT_WRITE and NET_BIND_SERVICE are used by nothing here -
|
||||
# the last one because a sysctl on this namespace, not a capability, is
|
||||
# what lets the TLS terminator bind :443 unprivileged.
|
||||
#
|
||||
# No network capability at all, which is the point of the arrangement
|
||||
# rather than an economy. This container is inside a network namespace the
|
||||
# filter container owns and has already installed the egress rules in, and
|
||||
# capabilities are per process: without NET_ADMIN, nothing here can alter
|
||||
# those rules even at uid 0. NET_RAW goes with it - a raw socket is the
|
||||
# other way to speak past a filter, and nothing here opens one.
|
||||
#
|
||||
# KILL is here for tini, and for nothing else in this container.
|
||||
#
|
||||
# `init: true` above makes PID 1 the init that reaps for the session, and
|
||||
# tini forwards every signal it receives to its child. That child is the
|
||||
# entrypoint, which changes uid in place before exec'ing the session - so
|
||||
# tini is uid 0 signalling a process whose real AND saved uids are the
|
||||
# sandbox user's. kill(2) permits that only with CAP_KILL or a uid match,
|
||||
# and there is no match, so without this every forwarded signal is EPERM.
|
||||
#
|
||||
# tini treats a failed forward as fatal, and PID 1 exiting ends the
|
||||
# container. The signal that arrives in practice is SIGWINCH: the kernel
|
||||
# sends it to the pty's foreground process group on any terminal resize,
|
||||
# which includes tini. So without this line, splitting a tmux pane kills
|
||||
# the session with `[FATAL tini (1)] Unexpected error when forwarding
|
||||
# signal: 'Operation not permitted'`.
|
||||
#
|
||||
# It used to be here for `dejima-allow --container`, which signals dnsmasq;
|
||||
# that reason moved to the filter container with dnsmasq, and this one was
|
||||
# not noticed until the capability went away. Nothing in the suite ran an
|
||||
# init at all - see runtime.bats.
|
||||
#
|
||||
# The general lesson, since the next capability will be the same shape:
|
||||
# what the container needs is not only what its entrypoint does. Things
|
||||
# reach into it later.
|
||||
# what a container needs is not only what its entrypoint does. Things reach
|
||||
# into it later.
|
||||
#
|
||||
# This narrows the ROOT phase. The session user's capabilities were already
|
||||
# empty: they are cleared on the gosu drop, which runtime.bats asserts by
|
||||
# reading CapEff of the running process. What this adds is that the set was
|
||||
# never there to be regained.
|
||||
# empty: they are cleared on the privilege drop, which runtime.bats asserts
|
||||
# by reading CapEff of the running process. What this adds is that the set
|
||||
# was never there to be regained.
|
||||
#
|
||||
# SETPCAP is the odd one out: nothing in the startup sequence uses it for
|
||||
# its own work. It is spent, once, to take every capability above away from
|
||||
# the session before it starts - see entrypoint.sh. A bounding set survives
|
||||
# execve and a uid-0 process recovers its permitted set from it, so without
|
||||
# this the whole list is held by everything the session can reach, effective
|
||||
# set or not.
|
||||
#
|
||||
# Adding a capability to remove capabilities is worth stating plainly. It
|
||||
# can only reduce: since Linux 4.3 SETPCAP grants nothing a process does
|
||||
# not already hold in its permitted set, so what it buys is the drop and
|
||||
# nothing else.
|
||||
cap_drop:
|
||||
- ALL
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
- NET_RAW
|
||||
- KILL
|
||||
- CHOWN
|
||||
- DAC_OVERRIDE
|
||||
- FOWNER
|
||||
- SETUID
|
||||
- SETGID
|
||||
- KILL
|
||||
- SETPCAP
|
||||
# No path back up. gosu drops privilege rather than gaining it, so this
|
||||
# does not interfere with it - what it forbids is a setuid binary or a file
|
||||
# capability raising privileges on exec, which is the shape any escalation
|
||||
@@ -92,12 +136,13 @@ services:
|
||||
# structural instead of only observed.
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
# The credential-proxy TLS terminator binds :443 as the unprivileged
|
||||
# sandbox user (see entrypoint.sh). Docker already defaults this to 0, but
|
||||
# pinning it makes the dependency explicit: a future default change then
|
||||
# fails the launch instead of silently breaking the credential path.
|
||||
sysctls:
|
||||
net.ipv4.ip_unprivileged_port_start: 0
|
||||
# No sysctls here. The one this container depends on -
|
||||
# net.ipv4.ip_unprivileged_port_start, which lets the credential proxy's
|
||||
# TLS terminator bind :443 as the unprivileged sandbox user - is a property
|
||||
# of a network namespace, and this container does not own the one it is in.
|
||||
# bin/dejima sets it on the filter container that does, so it is declared
|
||||
# once, by the container it belongs to. runtime.bats reads the value back
|
||||
# from in here, which is where it has to be true.
|
||||
environment:
|
||||
# The forge's own env vars (declared by its manifest's env_export) and
|
||||
# the DEJIMA_* resolved values (DEJIMA_FORGE_HOSTS, DEJIMA_PUSH_HOSTS,
|
||||
@@ -141,6 +186,7 @@ services:
|
||||
SANDBOX_HARNESS_BRIEFING_ARGS: ${SANDBOX_HARNESS_BRIEFING_ARGS:-}
|
||||
SANDBOX_HARNESS_BRIEFING_PATH: ${SANDBOX_HARNESS_BRIEFING_PATH:-}
|
||||
SANDBOX_HARNESS_SEED: ${SANDBOX_HARNESS_SEED:-}
|
||||
SANDBOX_HARNESS_STAGED_EMPTY: ${SANDBOX_HARNESS_STAGED_EMPTY:-}
|
||||
# Opt-in escape hatch from the default hard GitHub-push block - see
|
||||
# init-firewall.sh and git-push-guard.sh. Off unless explicitly set.
|
||||
ALLOW_GITHUB_PUSH: ${ALLOW_GITHUB_PUSH:-0}
|
||||
@@ -172,8 +218,8 @@ services:
|
||||
# never enters the container; the host-side proxy swaps this for it per
|
||||
# request, exactly as it does for a subscription login's token.
|
||||
ANTHROPIC_API_KEY: ${SANDBOX_ANTHROPIC_API_KEY:-}
|
||||
SANDBOX_ANTHROPIC_PROXY_SOCKET: ${SANDBOX_ANTHROPIC_PROXY_SOCKET:-}
|
||||
SANDBOX_ANTHROPIC_TRANSPORT: ${SANDBOX_ANTHROPIC_TRANSPORT:-}
|
||||
SANDBOX_CREDENTIAL_PROXY_SOCKET: ${SANDBOX_CREDENTIAL_PROXY_SOCKET:-}
|
||||
SANDBOX_CREDENTIAL_TRANSPORT: ${SANDBOX_CREDENTIAL_TRANSPORT:-}
|
||||
volumes:
|
||||
# Mounted at the same absolute path as on the host (rather than a
|
||||
# fixed /workspace) so Claude Code's project/session-history keying
|
||||
|
||||
+69
-59
@@ -8,56 +8,30 @@ set -Eeuo pipefail
|
||||
# refuse deliberately, and say so (#59).
|
||||
trap 'status=$?; echo "ERROR: entrypoint.sh aborted at line $LINENO (exit $status): $BASH_COMMAND" >&2' ERR
|
||||
|
||||
# DEJIMA_FORGE_HOSTS is resolved host-side by bin/dejima (forge_hosts, in
|
||||
# dejima-provider-lib.sh) from whichever forge is configured, so this file
|
||||
# does not derive or name any one forge's hostnames itself.
|
||||
# drop_caps and require_setpcap, shared with the filter container's entrypoint.
|
||||
# Sourced rather than defined here because the callers must not drift: what
|
||||
# `drop_caps` does is the difference between a session holding nothing and a
|
||||
# session holding the whole floor, and that is not a thing to have two of.
|
||||
# shellcheck source=caps-lib.sh
|
||||
source /usr/local/bin/caps-lib.sh
|
||||
require_setpcap
|
||||
|
||||
# The egress control for this namespace is already up: the filter container
|
||||
# that owns it installed the rules, and bin/dejima waited for that before
|
||||
# starting this container. Nothing here brings it up, and nothing here could -
|
||||
# this container declares none of the capabilities it would take.
|
||||
#
|
||||
# LOKI_URL (a full URL) still needs its own derivation here - Loki's host
|
||||
# has to be reachable for log-shipper.sh to actually deliver anything.
|
||||
loki_host=""
|
||||
if [ -n "${LOKI_URL:-}" ]; then
|
||||
loki_host=$(echo "$LOKI_URL" | sed -E 's#^[a-zA-Z]+://##; s#[:/].*##')
|
||||
fi
|
||||
|
||||
extra_domains="${DEJIMA_FORGE_HOSTS:-} ${loki_host} ${ALLOWED_DOMAINS:-}"
|
||||
|
||||
# What this container does need is to be pointed at the resolver enforcing the
|
||||
# name allowlist. /etc/resolv.conf is per container even when the namespace is
|
||||
# shared, so dns-proxy.sh repointing the filter container's file says nothing
|
||||
# about this one - which Docker generated pointing straight at its embedded
|
||||
# resolver. Left alone, every lookup the session makes would go around dnsmasq
|
||||
# and the DNS allowlist would apply to nothing.
|
||||
#
|
||||
# The same value dns-proxy.sh writes for itself, at the same loopback address,
|
||||
# because loopback is shared with the container the resolver runs in.
|
||||
if [ "${ENABLE_FIREWALL:-1}" = "1" ]; then
|
||||
# dns-proxy.sh must run first: it repoints /etc/resolv.conf at a local
|
||||
# dnsmasq that only resolves allowlisted names, so init-firewall.sh's own
|
||||
# dig calls (and everything afterward) go through the same restriction.
|
||||
EXTRA_ALLOWED_DOMAINS="${extra_domains}" /usr/local/bin/dns-proxy.sh
|
||||
EXTRA_ALLOWED_DOMAINS="${extra_domains}" /usr/local/bin/init-firewall.sh
|
||||
|
||||
# NFLOG groups only exist once init-firewall.sh has run, so ulogd2 (the
|
||||
# NFLOG consumer) and the Loki shipper start after it. Both are
|
||||
# best-effort visibility on top of the enforcement above, not required
|
||||
# for it - log-shipper.sh itself no-ops if LOKI_URL isn't set. Runs as
|
||||
# its own unprivileged `ulogd` user (opens the NFLOG socket and output
|
||||
# files first, then drops), not root - it's parsing headers from
|
||||
# attacker-influenceable packets (whatever a compromised session sends),
|
||||
# so a bug in it shouldn't be a path back to root in the container.
|
||||
#
|
||||
# `ulogd -d` daemonizes immediately and always exits 0 from this shell's
|
||||
# point of view, regardless of whether the daemon itself goes on to
|
||||
# start successfully - so `set -e` above won't catch a failure here (nor
|
||||
# should it: logging is best-effort, not something worth failing the
|
||||
# whole container over). Check separately so a failure is at least
|
||||
# visible instead of silently producing no logs.
|
||||
/usr/sbin/ulogd -d -u ulogd -c /etc/ulogd-sandbox.conf
|
||||
sleep 0.5
|
||||
if ! pgrep -x ulogd >/dev/null; then
|
||||
echo "WARNING: ulogd2 failed to start - connection logging will be unavailable (see /var/log/ulogd.log)" >&2
|
||||
fi
|
||||
# Unlike ulogd2 above, this one stays root - deliberately, not an
|
||||
# oversight. What it ships is an oversight log of the session running
|
||||
# beside it, and letting the sandbox user own the shipper would let a
|
||||
# compromised session tamper with or kill its own logging. Root buys
|
||||
# tamper-resistance that a security log needs and a best-effort netflow
|
||||
# counter does not.
|
||||
#
|
||||
# What it reads is dnsmasq's query log and ulogd2's output, both written
|
||||
# by root-owned daemons here and neither session-writable.
|
||||
/usr/local/bin/log-shipper.sh &
|
||||
echo "nameserver 127.0.0.1" > /etc/resolv.conf
|
||||
fi
|
||||
|
||||
# A normal login/ssh session always chowns the allocated pty to the logging
|
||||
@@ -235,6 +209,36 @@ if [ -d /etc/dejima-harness ]; then
|
||||
done < <(find /etc/dejima-harness -type f -print0)
|
||||
fi
|
||||
|
||||
# Files the harness needs to exist whether or not the operator has one.
|
||||
#
|
||||
# Created HERE rather than on the host. A harness that reads a settings file
|
||||
# still reads one when it is empty, and dejima creating that file on the
|
||||
# operator's machine - so there is something to copy - is a write to their
|
||||
# configuration for dejima's own convenience. Root-owned and read-only, the
|
||||
# same as anything else staged, so the container's copy is not a channel back.
|
||||
for _spec in ${SANDBOX_HARNESS_STAGED_EMPTY:-}; do
|
||||
_kind="${_spec%%:*}"
|
||||
_rel="${_spec#*:}"
|
||||
case "$_rel" in
|
||||
/*|*..*)
|
||||
echo "entrypoint: refusing staged-config path '$_rel': it must be" >&2
|
||||
echo "relative to the sandbox user's home and must not contain '..'." >&2
|
||||
exit 1 ;;
|
||||
esac
|
||||
_dest="$SANDBOX_HOME/$_rel"
|
||||
[ -e "$_dest" ] && continue
|
||||
mkdir -p "$(dirname "$_dest")"
|
||||
# An empty file is not valid JSON, and a harness that discards a config it
|
||||
# cannot parse would lose everything in it rather than nothing.
|
||||
if [ "$_kind" = ensure-json ]; then
|
||||
printf '{}\n' > "$_dest"
|
||||
else
|
||||
: > "$_dest"
|
||||
fi
|
||||
chown root:root "$_dest"
|
||||
chmod 444 "$_dest"
|
||||
done
|
||||
|
||||
# The harness's own writable state. Unlike the staged config above, these are
|
||||
# owned by the sandbox user and stay writable: they are what the harness
|
||||
# rewrites as it runs - a plugin registry, a catalogue, an install marker -
|
||||
@@ -360,25 +364,25 @@ fi
|
||||
# extra_hosts: under [credentials] proxy = off nothing listens on :443, and
|
||||
# an unconditional entry would black-hole the real API on exactly the path
|
||||
# that has to keep working.
|
||||
SANDBOX_ANTHROPIC_TRANSPORT="${SANDBOX_ANTHROPIC_TRANSPORT:-tls}"
|
||||
export SANDBOX_ANTHROPIC_TRANSPORT
|
||||
SANDBOX_CREDENTIAL_TRANSPORT="${SANDBOX_CREDENTIAL_TRANSPORT:-tls}"
|
||||
export SANDBOX_CREDENTIAL_TRANSPORT
|
||||
# Checked here rather than in bin/dejima because every launch reaches this
|
||||
# script, including a bare `docker run` (see test/runtime.bats). The two halves
|
||||
# branch on opposite polarity - bin/dejima treats anything but `loopback` as
|
||||
# tls, this script and the forwarder treat anything but `tls` as loopback - so
|
||||
# a typo would configure them for different transports and fail every API call
|
||||
# with nothing pointing back at the cause.
|
||||
case "$SANDBOX_ANTHROPIC_TRANSPORT" in
|
||||
case "$SANDBOX_CREDENTIAL_TRANSPORT" in
|
||||
tls|loopback) ;;
|
||||
*)
|
||||
echo "FATAL: SANDBOX_ANTHROPIC_TRANSPORT is set to '$SANDBOX_ANTHROPIC_TRANSPORT'." >&2
|
||||
echo "FATAL: SANDBOX_CREDENTIAL_TRANSPORT is set to '$SANDBOX_CREDENTIAL_TRANSPORT'." >&2
|
||||
echo "The only valid values are 'tls' (the default) and 'loopback'; they are" >&2
|
||||
echo "case-sensitive. Not starting the session - an unrecognised value would" >&2
|
||||
echo "otherwise be read as one transport here and the other by bin/dejima." >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
if [ -n "${SANDBOX_ANTHROPIC_PROXY_SOCKET:-}" ]; then
|
||||
if [ -n "${SANDBOX_CREDENTIAL_PROXY_SOCKET:-}" ]; then
|
||||
forwarder_port=18300
|
||||
# Which name the terminator answers for is the selected harness's, from
|
||||
# `proxied_domain` in its manifest - this image is shared by every
|
||||
@@ -393,7 +397,7 @@ if [ -n "${SANDBOX_ANTHROPIC_PROXY_SOCKET:-}" ]; then
|
||||
echo "Not starting the session - traffic would reach the API unintercepted." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "$SANDBOX_ANTHROPIC_TRANSPORT" = "tls" ]; then
|
||||
if [ "$SANDBOX_CREDENTIAL_TRANSPORT" = "tls" ]; then
|
||||
forwarder_port=443
|
||||
# Generated here, in the container, rather than on the host: the key
|
||||
# never touches host disk and there is no host state to clean up. It
|
||||
@@ -401,7 +405,7 @@ if [ -n "${SANDBOX_ANTHROPIC_PROXY_SOCKET:-}" ]; then
|
||||
# unprivileged - and worthless to anyone else, since the only thing
|
||||
# that trusts this anchor is this container, which is already reading
|
||||
# that traffic in the clear.
|
||||
tls_dir=/etc/dejima-anthropic-tls
|
||||
tls_dir=/etc/dejima-credential-tls
|
||||
mkdir -p "$tls_dir"
|
||||
# set -e aborts the script on openssl's nonzero exit either way, so a
|
||||
# failure here never proceeds to chown/export a missing cert - but
|
||||
@@ -427,8 +431,8 @@ if [ -n "${SANDBOX_ANTHROPIC_PROXY_SOCKET:-}" ]; then
|
||||
chown "$SANDBOX_USER" "$tls_dir/key.pem" "$tls_dir/cert.pem"
|
||||
chmod 600 "$tls_dir/key.pem"
|
||||
chmod 644 "$tls_dir/cert.pem"
|
||||
export SANDBOX_ANTHROPIC_TLS_CERT="$tls_dir/cert.pem"
|
||||
export SANDBOX_ANTHROPIC_TLS_KEY="$tls_dir/key.pem"
|
||||
export SANDBOX_CREDENTIAL_TLS_CERT="$tls_dir/cert.pem"
|
||||
export SANDBOX_CREDENTIAL_TLS_KEY="$tls_dir/key.pem"
|
||||
# /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.
|
||||
@@ -508,13 +512,19 @@ fi
|
||||
# exercising the actual code path instead of a reimplementation of it that
|
||||
# could drift out of sync.
|
||||
if [ "${SANDBOX_TEST_MODE:-0}" = "1" ]; then
|
||||
exec gosu "$SANDBOX_USER" sleep infinity
|
||||
drop_caps --user="$SANDBOX_USER" -- -c 'exec "$@"' dejima sleep infinity
|
||||
fi
|
||||
|
||||
# Drop root -> the sandbox user for the actual session; that user has no
|
||||
# sudo/setuid path back to root after this, so it can't re-run
|
||||
# init-firewall.sh itself to widen the allowlist mid-session.
|
||||
#
|
||||
# And drop the bounding set with it, so "no path back to root" and "no
|
||||
# capability if there were one" are two separate things rather than one
|
||||
# resting on the other. capsh does both in one process because it has to: the
|
||||
# bounding set can only be reduced while CAP_SETPCAP is still held, and
|
||||
# changing uid first gives it away.
|
||||
#
|
||||
# SANDBOX_CMD is what actually runs as that user - the selected harness's
|
||||
# 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)
|
||||
@@ -530,4 +540,4 @@ if [ -z "${SANDBOX_CMD:-}" ]; then
|
||||
echo "command: docker run --entrypoint sh ..." >&2
|
||||
exit 1
|
||||
fi
|
||||
exec gosu "$SANDBOX_USER" $SANDBOX_CMD "$@"
|
||||
drop_caps --user="$SANDBOX_USER" -- -c 'exec "$@"' dejima $SANDBOX_CMD "$@"
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
#!/bin/bash
|
||||
# PID 1 of the filter container: brings this launch's egress control up, says
|
||||
# so, and then holds the network namespace open for the sandbox to join.
|
||||
#
|
||||
# The sandbox is a separate container that joins this namespace and declares no
|
||||
# network capability of its own. Capabilities are per process, so it cannot
|
||||
# alter the filter installed here even at uid 0 - which is the whole reason the
|
||||
# two are separate. See docs/security.md.
|
||||
#
|
||||
# Nothing a session runs ever executes in here. This container holds the
|
||||
# privilege; the one beside it holds the session.
|
||||
set -Eeuo pipefail
|
||||
trap 'status=$?; echo "ERROR: filter-entrypoint.sh aborted at line $LINENO (exit $status): $BASH_COMMAND" >&2' ERR
|
||||
|
||||
# shellcheck source=caps-lib.sh
|
||||
source /usr/local/bin/caps-lib.sh
|
||||
require_setpcap
|
||||
|
||||
/usr/local/bin/firewall-up.sh
|
||||
|
||||
# Written last, and only on the success path above - `set -e` means a firewall
|
||||
# that failed to come up never reaches this line. bin/dejima waits for this
|
||||
# file before it starts the sandbox, so the ordering that makes the arrangement
|
||||
# fail-closed is a fact about the filesystem rather than about how long a
|
||||
# launcher guessed the firewall would take.
|
||||
#
|
||||
# Under /run, which is a tmpfs: a container that is restarted rather than
|
||||
# relaunched must not inherit a marker saying its filter is up before it is.
|
||||
: > /run/dejima-firewall-ready
|
||||
echo "filter: egress control is up; holding the namespace for the session"
|
||||
|
||||
# Held open by a process that does nothing else. The namespace lives as long as
|
||||
# a process is in it, and this container is started with an init that forwards
|
||||
# SIGTERM, so `docker stop` ends it promptly rather than waiting out a timeout.
|
||||
exec sleep infinity
|
||||
@@ -0,0 +1,81 @@
|
||||
#!/bin/bash
|
||||
# Brings up this network namespace's egress control: the DNS proxy, the packet
|
||||
# filter, and the two log producers that watch them.
|
||||
#
|
||||
# Its own script rather than a block inside entrypoint.sh because the namespace
|
||||
# it acts on and the session that runs in it need not be in the same container.
|
||||
# What this does is a property of a network namespace; everything else an
|
||||
# entrypoint does is a property of a container.
|
||||
#
|
||||
# Called with the caller's environment: DEJIMA_FORGE_HOSTS, LOKI_URL,
|
||||
# ALLOWED_DOMAINS, ENABLE_FIREWALL and the harness values fixed-domains.sh
|
||||
# reads. It resolves nothing host-side itself.
|
||||
set -Eeuo pipefail
|
||||
# Same reason as entrypoint.sh's: under `set -e` alone a failure here aborts
|
||||
# with no output, and the only symptom is a container that never appeared (#59).
|
||||
trap 'status=$?; echo "ERROR: firewall-up.sh aborted at line $LINENO (exit $status): $BASH_COMMAND" >&2' ERR
|
||||
|
||||
# shellcheck source=caps-lib.sh
|
||||
source /usr/local/bin/caps-lib.sh
|
||||
|
||||
# DEJIMA_FORGE_HOSTS is resolved host-side by bin/dejima (forge_hosts, in
|
||||
# dejima-provider-lib.sh) from whichever forge is configured, so this file
|
||||
# does not derive or name any one forge's hostnames itself.
|
||||
#
|
||||
# LOKI_URL (a full URL) still needs its own derivation here - Loki's host
|
||||
# has to be reachable for log-shipper.sh to actually deliver anything.
|
||||
loki_host=""
|
||||
if [ -n "${LOKI_URL:-}" ]; then
|
||||
loki_host=$(echo "$LOKI_URL" | sed -E 's#^[a-zA-Z]+://##; s#[:/].*##')
|
||||
fi
|
||||
|
||||
extra_domains="${DEJIMA_FORGE_HOSTS:-} ${loki_host} ${ALLOWED_DOMAINS:-}"
|
||||
|
||||
if [ "${ENABLE_FIREWALL:-1}" = "1" ]; then
|
||||
# dns-proxy.sh must run first: it repoints /etc/resolv.conf at a local
|
||||
# dnsmasq that only resolves allowlisted names, so init-firewall.sh's own
|
||||
# dig calls (and everything afterward) go through the same restriction.
|
||||
EXTRA_ALLOWED_DOMAINS="${extra_domains}" /usr/local/bin/dns-proxy.sh
|
||||
EXTRA_ALLOWED_DOMAINS="${extra_domains}" /usr/local/bin/init-firewall.sh
|
||||
|
||||
# NFLOG groups only exist once init-firewall.sh has run, so ulogd2 (the
|
||||
# NFLOG consumer) and the Loki shipper start after it. Both are
|
||||
# best-effort visibility on top of the enforcement above, not required
|
||||
# for it - log-shipper.sh itself no-ops if LOKI_URL isn't set. Runs as
|
||||
# its own unprivileged `ulogd` user (opens the NFLOG socket and output
|
||||
# files first, then drops), not root - it's parsing headers from
|
||||
# attacker-influenceable packets (whatever a compromised session sends),
|
||||
# so a bug in it shouldn't be a path back to root in the container.
|
||||
#
|
||||
# `ulogd -d` daemonizes immediately and always exits 0 from this shell's
|
||||
# point of view, regardless of whether the daemon itself goes on to
|
||||
# start successfully - so `set -e` above won't catch a failure here (nor
|
||||
# should it: logging is best-effort, not something worth failing the
|
||||
# whole container over). Check separately so a failure is at least
|
||||
# visible instead of silently producing no logs.
|
||||
/usr/sbin/ulogd -d -u ulogd -c /etc/ulogd-sandbox.conf
|
||||
sleep 0.5
|
||||
if ! pgrep -x ulogd >/dev/null; then
|
||||
echo "WARNING: ulogd2 failed to start - connection logging will be unavailable (see /var/log/ulogd.log)" >&2
|
||||
fi
|
||||
# Unlike ulogd2 above, this one stays root - deliberately, not an
|
||||
# oversight. What it ships is an oversight log of the session running
|
||||
# beside it, and letting the sandbox user own the shipper would let a
|
||||
# compromised session tamper with or kill its own logging. Root buys
|
||||
# tamper-resistance that a security log needs and a best-effort netflow
|
||||
# counter does not.
|
||||
#
|
||||
# What it reads is dnsmasq's query log and ulogd2's output, both written
|
||||
# by root-owned daemons here and neither session-writable.
|
||||
#
|
||||
# Root, and nothing else: it parses content the session's own traffic
|
||||
# decides, and it needs no capability to read two files its gid already
|
||||
# opens or to POST them. So it keeps the uid that makes it untamperable
|
||||
# and holds none of the rest.
|
||||
#
|
||||
# It outlives this script, and reparents to the container's init when this
|
||||
# one exits - which is where a daemon watching a session belongs. Started
|
||||
# from the entrypoint, it was a child of the very session process it exists
|
||||
# to keep an eye on.
|
||||
drop_caps -- -c 'exec "$@"' dejima /usr/local/bin/log-shipper.sh &
|
||||
fi
|
||||
@@ -53,7 +53,7 @@ fi
|
||||
#
|
||||
# Which name this is comes from the provider (proxied_domain); the rule
|
||||
# about when to allowlist it is dejima's, and stays here.
|
||||
if [ -z "${SANDBOX_ANTHROPIC_PROXY_SOCKET:-}" ] \
|
||||
if [ -z "${SANDBOX_CREDENTIAL_PROXY_SOCKET:-}" ] \
|
||||
&& [ -n "${SANDBOX_BACKEND_PROXIED_DOMAIN:-}" ]; then
|
||||
FIXED_DOMAINS+=("$SANDBOX_BACKEND_PROXIED_DOMAIN")
|
||||
fi
|
||||
|
||||
+20
-2
@@ -38,6 +38,12 @@ fi
|
||||
repo_dir="$PWD"
|
||||
subcommand=""
|
||||
argv=("$@")
|
||||
# Every `-c key=value` this push carries, kept rather than only skipped. The
|
||||
# question below is where the push will go, and git answers that from the
|
||||
# config the push runs under - so resolving it without these would answer for
|
||||
# a different configuration than the one about to be used. A one-command
|
||||
# `-c remote.origin.pushurl=...` is exactly that case.
|
||||
config_overrides=()
|
||||
i=0
|
||||
while [ "$i" -lt "${#argv[@]}" ]; do
|
||||
case "${argv[$i]}" in
|
||||
@@ -45,7 +51,12 @@ while [ "$i" -lt "${#argv[@]}" ]; do
|
||||
i=$((i + 1))
|
||||
[ "$i" -lt "${#argv[@]}" ] && repo_dir="${argv[$i]}"
|
||||
;;
|
||||
-c|--exec-path|--git-dir|--work-tree|--namespace|--super-prefix)
|
||||
-c)
|
||||
i=$((i + 1))
|
||||
[ "$i" -lt "${#argv[@]}" ] \
|
||||
&& config_overrides+=(-c "${argv[$i]}")
|
||||
;;
|
||||
--exec-path|--git-dir|--work-tree|--namespace|--super-prefix)
|
||||
i=$((i + 1))
|
||||
;;
|
||||
-*)
|
||||
@@ -97,7 +108,14 @@ while [ "$i" -lt "${#argv[@]}" ]; do
|
||||
done
|
||||
[ -n "$remote" ] || remote=origin
|
||||
|
||||
remote_url="$("$REAL_GIT" -C "$repo_dir" remote get-url "$remote" 2>/dev/null)"
|
||||
# --push, because that is the URL the push will use. Without it this reads
|
||||
# remote.<name>.url while git pushes to remote.<name>.pushurl when one is set,
|
||||
# so a pushurl naming any other host is checked against the fetch URL and
|
||||
# allowed. It needs no evasion to reach: `git -c remote.origin.pushurl=...
|
||||
# push origin branch` sets one for a single command, and .git/config is in the
|
||||
# read-write project mount, so a session can also just write one down.
|
||||
remote_url="$("$REAL_GIT" ${config_overrides[@]+"${config_overrides[@]}"} \
|
||||
-C "$repo_dir" remote get-url --push "$remote" 2>/dev/null)"
|
||||
# A remote this cannot resolve is refused rather than passed through. It is
|
||||
# usually a typo, which git would reject in a moment anyway - but it is also
|
||||
# what a disagreement between this parser and git's looks like, and the
|
||||
|
||||
+22
-20
@@ -28,17 +28,30 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck source=fixed-domains.sh
|
||||
source "$SCRIPT_DIR/fixed-domains.sh"
|
||||
|
||||
# Docker's embedded-DNS NAT rules live in the same table we're about to
|
||||
# flush - snapshot them first so they can be reinstated afterward.
|
||||
docker_dns_nat_rules=$(iptables-save -t nat | grep "127\.0\.0\.11" || true)
|
||||
|
||||
# Reset to a known-empty state before laying down the real ruleset.
|
||||
# What this script owns, and therefore what it clears: the `filter` table and
|
||||
# its two ipsets. Every rule below is a `filter` append or policy - the whole
|
||||
# egress decision is made there, and nothing here writes anywhere else.
|
||||
#
|
||||
# `nat` and `mangle` belong to the daemon that built this namespace, and are
|
||||
# left to it. That is a decision rather than an omission, because `nat` can
|
||||
# reach the decision this script is making: for a locally generated packet the
|
||||
# order is `nat OUTPUT` -> routing -> `filter OUTPUT`, so a DNAT rewrites the
|
||||
# destination before the allowlist is consulted. A rule there chooses what the
|
||||
# rules below match against.
|
||||
#
|
||||
# It is still the daemon's to hold. It created this namespace and put the
|
||||
# embedded resolver in it; a dockerd rewriting `nat` to slip a destination past
|
||||
# the allowlist is one that could have skipped the firewall container
|
||||
# altogether. Nothing else can write there - the session container declares no
|
||||
# NET_ADMIN, and in here this script is the only thing that touches netfilter.
|
||||
#
|
||||
# What this script does rely on from that table is the resolver at 127.0.0.11,
|
||||
# which every allowlisted lookup below goes through. Two things already refuse
|
||||
# a namespace without it: dns-proxy.sh, which runs first and checks
|
||||
# /etc/resolv.conf names it, and the self-check at the end of this file, which
|
||||
# resolves a name and reports the whole path when it cannot.
|
||||
iptables -F
|
||||
iptables -X
|
||||
iptables -t nat -F
|
||||
iptables -t nat -X
|
||||
iptables -t mangle -F
|
||||
iptables -t mangle -X
|
||||
ipset destroy allowed-domains 2>/dev/null || true
|
||||
ipset destroy github-domains 2>/dev/null || true
|
||||
|
||||
@@ -104,17 +117,6 @@ else
|
||||
echo "ip6tables unusable, and no IPv6 address outside loopback - nothing to filter"
|
||||
fi
|
||||
|
||||
# Reinsert just the rules captured above - nothing else Docker had set up in
|
||||
# nat comes back, so the reset above stays meaningful.
|
||||
if [ -n "$docker_dns_nat_rules" ]; then
|
||||
echo "Reinstating captured Docker DNS rules"
|
||||
iptables -t nat -N DOCKER_OUTPUT 2>/dev/null || true
|
||||
iptables -t nat -N DOCKER_POSTROUTING 2>/dev/null || true
|
||||
echo "$docker_dns_nat_rules" | xargs -L 1 iptables -t nat
|
||||
else
|
||||
echo "No Docker DNS rules were present to restore"
|
||||
fi
|
||||
|
||||
# Allow localhost. Every DNS query this container makes starts here: to
|
||||
# dnsmasq on 127.0.0.1, and from there to Docker's embedded resolver on
|
||||
# 127.0.0.11 (see resolv.conf). What this rule does NOT cover is the hop
|
||||
|
||||
@@ -48,6 +48,19 @@ ship_stream() {
|
||||
last_flush=$(date +%s)
|
||||
}
|
||||
|
||||
# tail's stderr is discarded below because -F is meant to wait for a file
|
||||
# that does not exist yet, and complains once per second while it does.
|
||||
# That also swallows a permission error, which is not transient and which
|
||||
# looks identical from the outside: a stream that ships nothing, which is
|
||||
# also what a quiet session looks like.
|
||||
#
|
||||
# This process holds no capabilities (see entrypoint.sh), so it reads these
|
||||
# two files on their ordinary permissions and nothing else. Say so once
|
||||
# when that is not enough, rather than shipping an empty stream.
|
||||
if [ -e "$file" ] && [ ! -r "$file" ]; then
|
||||
echo "WARNING: $file is not readable by $(id -un) - the $job stream will ship nothing." >&2
|
||||
fi
|
||||
|
||||
last_flush=$(date +%s)
|
||||
tail -F -n0 "$file" 2>/dev/null | while true; do
|
||||
if IFS= read -r -t "$BATCH_MAX_SECONDS" line; then
|
||||
|
||||
+395
-102
@@ -9,80 +9,218 @@ scratch space, and what the sandbox knows about itself.
|
||||
|
||||
## Configuration reference
|
||||
|
||||
Every key dejima reads, its default, and what it is for. All of it lives in
|
||||
one file, `~/.config/dejima/config`. `./install.sh` writes a starter copy with
|
||||
every key present and commented out at its default, so nothing here has to be
|
||||
set to get a working sandbox: uncomment what you need. `dejima --print-config`
|
||||
prints the resolved schema with real values, which is the fastest way to see
|
||||
what a given launch will actually use.
|
||||
Every key dejima reads, its default, and what it is for.
|
||||
|
||||
### Two files, and only one of them is yours
|
||||
|
||||
`~/.config/dejima/config` is **yours**. dejima reads it and never writes it.
|
||||
`./install.sh` creates it if you have none, carrying your selection and
|
||||
anything it could migrate from an older setup, and nothing else.
|
||||
|
||||
`~/.config/dejima/defaults.conf` beside it is **dejima's**, and holds every
|
||||
defaulted key at the value dejima shipped *when this machine first saw that
|
||||
key*. Your file overrides it; it overrides what the current release would have
|
||||
chosen.
|
||||
|
||||
That second file is why a release cannot move a setting you never chose.
|
||||
`[core] harness` has no default at all for this reason - a default that could
|
||||
change later is a config file that is not the source of truth - and this
|
||||
applies the same argument to every other key. It is not hypothetical: the
|
||||
`forge` and `workflow` defaults once changed from `gitea`+`worktree-pr` to
|
||||
`none`+`local-only`, which silently stopped existing sandboxes pushing.
|
||||
|
||||
The first launch on a machine writes it and says so once. `./install.sh`
|
||||
rebuilds it: keys a release added are appended, keys it retired are dropped,
|
||||
and **every value already in it stays**. When a shipped default no longer
|
||||
matches your pinned one, `./install.sh` names both and the line that would
|
||||
take the new one. It does not take it - that is the whole point - and the line
|
||||
goes in *your* file, never in `defaults.conf`, which the next rebuild rewrites.
|
||||
|
||||
Do not edit `defaults.conf`. Anything in it that dejima no longer understands
|
||||
is skipped rather than refused, since you are not the one who put it there, and
|
||||
a launch tells you to re-run `./install.sh`.
|
||||
|
||||
`dejima --print-config` prints every key as it resolves. Which of the three
|
||||
answered is readable from the files themselves: in `config` it is yours, in
|
||||
`defaults.conf` it is pinned, in neither it is what this release ships.
|
||||
|
||||
**`required` in the Default column is what you have to set yourself**, and it
|
||||
is exactly the set with no line in `defaults.conf`: a key with no default has
|
||||
nothing to pin. Exactly one is required unconditionally - `[core] harness`.
|
||||
Every other `required` is conditional on a selection you made, and each table
|
||||
below says which; a section belonging to something you did not select is never
|
||||
read, so its required keys are not yours to fill in.
|
||||
|
||||
Every tool takes `--help`, which describes its own arguments and what they do.
|
||||
That is the fastest answer to "what does this flag mean"; this page is for the
|
||||
config file behind them.
|
||||
|
||||
### `[core]` - what this sandbox runs
|
||||
|
||||
The three selections come first because everything below depends on them: the
|
||||
harness decides which `[harness.<name>]` section is read, and the forge decides
|
||||
which `[forge.<name>]`.
|
||||
|
||||
| Key | Default | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `[core] harness` | *(none - must be set)* | agent harness the container runs; refused if unset, so a later default cannot change your selection. Also settable per launch with `--harness`, which is how a host keeps one harness as its default and reaches another when it wants to |
|
||||
| `[core] forge` | `none` | forge integration: `gitea` (`gitea-pr`/`gitea-issue`), `github` (`gh`/`github-pr`, opens `ssh.github.com:22` for push), or `none` (no forge at all) |
|
||||
| `[core] workflow` | `local-only` | workflow `sandbox-context.sh` tells the agent to follow: `worktree-pr` (push + open a PR), `branch-push` (push, stop at the branch), or `local-only` (commit locally, push nothing) |
|
||||
| `[core] command` | *(the selected harness's own `command`)* | container's main process (e.g. `nvim`), also settable per launch with `--cmd`. Overriding it changes what dejima launches, not which harness is wrapped |
|
||||
| `[core] identity` | unset | which `[identity.<name>]` section supplies the credentials and git name this session acts under; also settable per launch with `--identity`, which is how one host runs a session per account. Unset selects `[identity.default]` where the config has one. A name with no section is refused rather than falling back |
|
||||
| `[core] tools` | unset | optional tools to put in the image, colon-separated; also `.dejima/tools` in a project (approved first) and `--include` per launch. None by default. See [docs/tools.md](tools.md) |
|
||||
| `[core] update_check` | `on` | at startup, say when this checkout is behind the branch it tracks. One `ls-remote` to your own remote, at most hourly, never fetching; `off` makes dejima contact nothing of its own accord. The answer is cached in `~/.local/share/dejima/update-check`, which is safe to delete |
|
||||
| `[ui] banner` | `on` | the three-line summary at the top of a launch - project and branch, the selection, and the switches whose state changes what a session can do. `off` prints nothing. Never drawn when output is redirected |
|
||||
| `[core] firewall` | `on` | egress firewall; `off` disables it (debugging only), also settable per launch with `--no-firewall` |
|
||||
| `[core] share_dir` | `~/.local/share/dejima/shared` | host-visible read/write scratch dir. One directory per project underneath it, and only that project's is mounted at `~/shared`. `dejima-share` is the host side of it |
|
||||
| `[core] ssh_key` | unset | **deprecated** - the pre-identity spelling of `[identity.default] ssh_key`. Still read when the config has no `[identity.default]`; refused when it has one, since the two say the same thing |
|
||||
| `[core] known_hosts` | `~/.ssh/known_hosts` | known-hosts file used for git-over-ssh |
|
||||
| `[harness.claude-code] backend` | *(none - must be set)* | which model API this harness talks to, and so which credential the proxy injects. A directory under `backends/`, each shipping its own `[backend.<name>]` keys. Per harness rather than global, because running Claude Code against Anthropic and Cline against something else is the ordinary case. No default, for the reason `[core] harness` has none: one that could change later would move a session to another endpoint without its config changing. Unset, dejima writes `anthropic` into your config once and says so - a migration rather than a default, so nothing it decides later can move you. Also settable per launch with `--backend`. See [providers.md](providers.md#model-backends) |
|
||||
| `[harness.claude-code] projects_dir` | `~/.claude/projects` | where Claude Code session transcripts live |
|
||||
| `[harness.claude-code] file_history_dir` | `~/.claude/file-history` | Claude Code's edit-undo record, mounted per session rather than whole |
|
||||
| `[harness.claude-code] statusline_path` | `~/.claude/statusline.sh` | statusline script path |
|
||||
| `[harness.cline] backend` | *(none - must be set)* | as above, and the risk is concrete here: Cline supports many backends and has one of its own. Nothing is written for you either, because what this harness talked to before the key existed depends on `base_url`. Also settable per launch with `--backend` |
|
||||
| `[backend.anthropic] credential_file` | `~/.claude/.credentials.json` | the host file this backend's credential is read from, per request. A path, never a secret, and never mounted into the container |
|
||||
| `[backend.self-hosted] base_url` | *(none - must be set)* | where you serve the model. A loopback or RFC 1918 address, or a host you have allowlisted - it has to be an endpoint needing no credential, since there is none to withhold. On the backend rather than the harness, so a local endpoint stays configured while a hosted API is selected |
|
||||
| `[backend.anthropic-api-key] credential_file` | `~/.claude.json` | where your Anthropic API key is. Defaulted to where Claude Code's console login puts it, so the common case needs nothing set; point it at a file of your own if you hold the key yourself. Read host-side per request and never mounted, and the key is stripped from the copy of `~/.claude.json` dejima stages |
|
||||
| `[backend.anthropic-api-key] credential_field` | `primaryApiKey` | which value inside that file is the key, as dot-separated object names. Clear it (`credential_field =`) when the file is nothing but the key |
|
||||
| `[backend.<name>] proxy` | *(inherits `[credentials] proxy`)* | whether the credential proxy runs for this backend. Per backend so that one backend the proxy cannot serve is not a reason to stop withholding credentials from the ones it can. Off means this backend's real credential is given to the session, and dejima says so at launch |
|
||||
| `[backend.openrouter] credential_file` | *(none - must be set)* | your OpenRouter API key, in a file. Read host-side per request and never mounted; the container only ever holds a placeholder. No default, because a key's location is yours and guessing at one would point dejima at a file it has no reason to think exists |
|
||||
| `[harness.cline] model` | *(none - must be set)* | model id the endpoint serves; no default, because any shipped one goes stale with the next Cline release. Also settable per launch with `--model` |
|
||||
| `[harness.cline] context_window` | unset | tokens Cline may use before compacting. Needed for a model it has no catalogue entry for, which is every self-served one. Also settable per launch with `--context-window` |
|
||||
| `[harness.cline] state_dir` | `~/.local/share/dejima/cline` | per-project Cline state; one directory per workspace |
|
||||
| `[forge.gitea] ssh_host` | unset | Gitea/Forgejo SSH hostname: skip if you only push to GitHub |
|
||||
| `[forge.gitea] api` | unset | `https://<gitea host>/api/v1`: skip if you only push to GitHub |
|
||||
| `[forge.gitea] token_file` | `~/.config/gitea/token` | path to the Gitea/Forgejo API token (the file, not the token itself) |
|
||||
| `[forge.github] push_token_file` | unset | path to a **write-capable** PAT, used only when `[core] forge = github`: deliberately separate from `[github] token_file` below; see "GitHub push" in docs/security.md |
|
||||
| `[github] token_file` | `~/.config/gh/sandbox-token` | path to a read-only PAT for private GitHub repos / `gh` |
|
||||
| `[mounts] readonly` | unset | extra host paths (colon-separated) a personal config needs, mounted read-only at their own path |
|
||||
| `[mounts] read_write` | unset | host paths (colon-separated) mounted **read-write** at their own path: for directories meant to outlive the session; refused if they overlap anything dejima needs out of a session's reach |
|
||||
| `[mounts] nvim_config` | unset | nvim config dir, used when `[core] command = nvim` |
|
||||
| `[credentials] proxy` | `on` | host-side credential proxy; `off` mounts the real OAuth credential into the container directly |
|
||||
| `[credentials] transport` | `tls` | set to `loopback` to fall back to a plaintext forwarder with an explicit `ANTHROPIC_BASE_URL`, keeping the credential out of the container but reinstating the base-URL fingerprint: for when certificate pinning breaks the default TLS path (see [security notes](security.md)) |
|
||||
| `[logging] loki_url` | unset | ship DNS/firewall logs to a Loki endpoint; `--no-log` clears it for one launch, which silences everything downstream of it |
|
||||
| `[epitaph] enabled` | `on` | keep the summary a harness writes when a session compacts; `--no-epitaph` skips it for one launch |
|
||||
| `[epitaph] dir` | `~/.local/share/dejima/epitaphs` | where they are kept; never mounted into a container |
|
||||
| `[epitaph] loki` | `off` | also push each epitaph to `[logging] loki_url`, host-side |
|
||||
| `[logging] conversation` | `off` | opt-in: also ship session transcripts to Loki (needs `loki_url`); see "Conversation logging (opt-in)" in docs/security.md. `--no-conversation` turns it off for one launch, and `--no-log` implies it |
|
||||
| `[audit] dir` | `~/.local/share/dejima/audit` | where exit-time audit findings are stored |
|
||||
| `[audit] baselines_dir` | `~/.local/share/dejima/baselines` | where audit baselines are stored |
|
||||
| `[audit] keep` | `10` | how many past findings/baselines to retain |
|
||||
| `[audit] diffs` | `on` | keep the diff of what changed alongside each finding; `off` records that something changed without retaining the content |
|
||||
| `[audit] strict` | `off` | refuse a headless (non-tty) launch over unacknowledged CRITICAL findings; also settable per launch with `--strict-audit` |
|
||||
| `[remote-access] enabled` | `off` | the switch: nothing in "Remote access" below runs until this is `on` |
|
||||
| `[remote-access] ntfy_url` | unset | ntfy topic URL for session notifications |
|
||||
| `[remote-access] base_url` | unset | public URL of the web front door, for notification links |
|
||||
| `[remote-access] ttyd_port` | `7681` | port `ttyd` listens on |
|
||||
| `[remote-access] ttyd_font_size` | unset | `ttyd` terminal font size |
|
||||
| `[remote-access] ttyd_tmux_prefix` | unset | the tmux prefix the page's buttons send; the page assumes stock `C-b` without it |
|
||||
| `[remote-access] ttyd_focus_on_tap` | unset | `on` lets a tap on the terminal raise the phone keyboard; unset means only the KBD button does |
|
||||
| `[remote-access] ttyd_button_colors` | unset | `off` returns the toolbar to monochrome |
|
||||
| `[remote-access] debounce` | `60` | seconds between repeat notifications for the same session |
|
||||
| `harness` | **required** | agent harness the container runs; refused if unset, so a later default cannot change your selection. Also settable per launch with `--harness`, which is how a host keeps one harness as its default and reaches another when it wants to |
|
||||
| `forge` | `none` | forge integration: `gitea` (`gitea-pr`/`gitea-issue`), `github` (`gh`/`github-pr`, opens `ssh.github.com:22` for push), or `none` (no forge at all) |
|
||||
| `workflow` | `local-only` | workflow the session briefing tells the agent to follow: `worktree-pr` (push + open a PR), `branch-push` (push, stop at the branch), or `local-only` (commit locally, push nothing) |
|
||||
| `identity` | unset | which `[identity.<name>]` section supplies the credentials and git name this session acts under; also settable per launch with `--identity`, which is how one host runs a session per account. Unset selects `[identity.default]` where the config has one. A name with no section is refused rather than falling back |
|
||||
| `command` | *(the selected harness's own)* | container's main process (e.g. `nvim`), also settable per launch with `--cmd`. Overriding it changes what dejima launches, not which harness is wrapped |
|
||||
| `tools` | unset | optional tools to put in the image, colon-separated; also `.dejima/tools` in a project (approved first) and `--include` per launch. None by default. See [docs/tools.md](tools.md) |
|
||||
| `firewall` | `on` | egress firewall; `off` disables it (debugging only), also settable per launch with `--no-firewall` |
|
||||
| `share_dir` | `~/.local/share/dejima/shared` | host-visible read/write scratch dir. One directory per project underneath it, and only that project's is mounted at `~/shared`. `dejima-share` is the host side of it |
|
||||
| `known_hosts` | `~/.ssh/known_hosts` | known-hosts file used for git-over-ssh |
|
||||
| `update_check` | `on` | at startup, say when this checkout is behind the branch it tracks. One `ls-remote` to your own remote, at most hourly, never fetching; `off` makes dejima contact nothing of its own accord. The answer is cached in `~/.local/share/dejima/update-check`, which is safe to delete |
|
||||
| `ssh_key` | unset | **deprecated** - the pre-identity spelling of `[identity.default] ssh_key`. Still read when the config has no `[identity.default]`; refused when it has one, since the two say the same thing |
|
||||
|
||||
**No forge is the default**: `[core] forge = none` with
|
||||
`[core] workflow = local-only`, so the sandbox works locally and pushes
|
||||
nowhere. Every other workflow needs forge credentials, which is what you are
|
||||
opting into when you pick one.
|
||||
|
||||
### `[harness.<name>]` - the harness you selected
|
||||
|
||||
One section per harness, and only the one `[core] harness` names is read. Each
|
||||
ships its own keys; see [providers.md](providers.md) for the manifest contract
|
||||
behind them.
|
||||
|
||||
#### `[harness.claude-code]`
|
||||
|
||||
| Key | Default | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `backend` | **required** | which model API this harness talks to, and so which credential the proxy injects. A directory under `backends/`, each shipping its own `[backend.<name>]` keys. Per harness rather than global, because running Claude Code against Anthropic and Cline against something else is the ordinary case. No default, for the reason `[core] harness` has none: one that could change later would move a session to another endpoint without its config changing. Unset, dejima writes `anthropic` into your config once and says so - a migration rather than a default, so nothing it decides later can move you. Also settable per launch with `--backend`. See [providers.md](providers.md#model-backends) |
|
||||
| `projects_dir` | `~/.claude/projects` | where Claude Code session transcripts live |
|
||||
| `file_history_dir` | `~/.claude/file-history` | Claude Code's edit-undo record, mounted per session rather than whole |
|
||||
| `statusline_path` | `~/.claude/statusline.sh` | statusline script path |
|
||||
|
||||
#### `[harness.cline]`
|
||||
|
||||
| Key | Default | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `backend` | **required** | as above, and the risk is concrete here: Cline supports many backends and has one of its own. Nothing is written into your config for you either, unlike Claude Code: there is no one endpoint every existing Cline session was already using, so any value dejima recorded would be a guess. Also settable per launch with `--backend` |
|
||||
| `model` | **required** | model id the endpoint serves, exactly as that endpoint spells it - a hosted catalogue namespaces by vendor (`z-ai/glm-5.3`) and may qualify a variant (`deepseek/deepseek-r1:free`). No default, because any shipped one goes stale with the next Cline release. Also settable per launch with `--model` |
|
||||
| `context_window` | unset | tokens Cline may use before compacting. Needed for a model it has no catalogue entry for, which is every self-served one. Also settable per launch with `--context-window` |
|
||||
| `state_dir` | `~/.local/share/dejima/cline` | per-project Cline state; one directory per workspace |
|
||||
|
||||
### `[backend.<name>]` - the model API you selected
|
||||
|
||||
One section per backend, and only the one your harness's `backend` names is
|
||||
read. `proxy` is available in every one of them; the rest are that backend's
|
||||
own. See [providers.md](providers.md#model-backends) for how much use each has
|
||||
actually had.
|
||||
|
||||
| Key | Default | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `[backend.<name>] proxy` | *(inherits `[credentials] proxy`)* | whether the credential proxy runs for this backend. Per backend so that one backend the proxy cannot serve is not a reason to stop withholding credentials from the ones it can. Off means this backend's real credential is given to the session, and dejima says so at launch |
|
||||
| `[backend.anthropic] credential_file` | `~/.claude/.credentials.json` | the host file this backend's credential is read from, per request. A path, never a secret, and never mounted into the container |
|
||||
| `[backend.anthropic-api-key] credential_file` | `~/.claude.json` | where your Anthropic API key is. Defaulted to where Claude Code's console login puts it, so the common case needs nothing set; point it at a file of your own if you hold the key yourself. Read host-side per request and never mounted, and the key is stripped from the copy of `~/.claude.json` dejima stages |
|
||||
| `[backend.anthropic-api-key] credential_field` | `primaryApiKey` | which value inside that file is the key, as dot-separated object names. Clear it (`credential_field =`) when the file is nothing but the key |
|
||||
| `[backend.openrouter] credential_file` | **required** | your OpenRouter API key, in a file. Read host-side per request and never mounted; the container only ever holds a placeholder. No default, because a key's location is yours and guessing at one would point dejima at a file it has no reason to think exists |
|
||||
| `[backend.self-hosted] base_url` | **required** | where you serve the model. A loopback or RFC 1918 address, or a host you have allowlisted - it has to be an endpoint needing no credential, since there is none to withhold. On the backend rather than the harness, so a local endpoint stays configured while a hosted API is selected |
|
||||
|
||||
### `[credentials]` - how the credential is withheld
|
||||
|
||||
| Key | Default | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `proxy` | `on` | host-side credential proxy; `off` mounts the real credential into the container directly. Overridable per backend with `[backend.<name>] proxy` |
|
||||
| `transport` | `tls` | set to `loopback` to fall back to a plaintext forwarder with an explicit base URL, keeping the credential out of the container but reinstating the base-URL fingerprint: for when certificate pinning breaks the default TLS path (see [security notes](security.md)) |
|
||||
|
||||
### `[identity.<name>]` - who the session acts as
|
||||
|
||||
Groups the SSH key, the git name and the forge credential belonging to one
|
||||
account, so a host can run a session per account. Selected by `[core]
|
||||
identity`, or `[identity.default]` when that is unset. Nothing here is
|
||||
required: an identity supplies what it names and no more.
|
||||
|
||||
| Key | Default | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `ssh_key` | unset | the SSH key this identity pushes with |
|
||||
| `user_name` | unset | `user.name` for commits made in the session |
|
||||
| `user_email` | unset | `user.email` for commits made in the session |
|
||||
| `token_file` | *(the forge's own default)* | this identity's Gitea/Forgejo API token, overriding `[forge.gitea] token_file`. Read only when `[core] forge = gitea` |
|
||||
| `push_token_file` | unset | this identity's write-capable GitHub PAT, overriding `[forge.github] push_token_file`. Read only when `[core] forge = github` |
|
||||
|
||||
See [Identities](#identities) below for the worked examples.
|
||||
|
||||
### `[forge.<name>]` and `[github]` - forge credentials
|
||||
|
||||
Only the section `[core] forge` names is read. `[github]` is separate from
|
||||
`[forge.github]` on purpose: the first is read access to GitHub whichever forge
|
||||
you selected, the second is push access when GitHub *is* your forge.
|
||||
|
||||
| Key | Default | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `[forge.gitea] ssh_host` | **required** for `forge = gitea` | Gitea/Forgejo SSH hostname |
|
||||
| `[forge.gitea] api` | **required** for `forge = gitea` | `https://<gitea host>/api/v1` |
|
||||
| `[forge.gitea] token_file` | `~/.config/gitea/token` | path to the Gitea/Forgejo API token (the file, not the token itself) |
|
||||
| `[forge.github] push_token_file` | unset | path to a **write-capable** PAT, used only when `[core] forge = github`: deliberately separate from `[github] token_file`; see "GitHub push" in [docs/security.md](security.md) |
|
||||
| `[github] token_file` | `~/.config/gh/sandbox-token` | path to a read-only PAT for private GitHub repos / `gh`. Read whichever forge is selected, and it should stay read-only |
|
||||
|
||||
### `[mounts]` - extra host paths
|
||||
|
||||
| Key | Default | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `readonly` | unset | extra host paths (colon-separated) a personal config needs, mounted read-only at their own path |
|
||||
| `read_write` | unset | host paths (colon-separated) mounted **read-write** at their own path: for directories meant to outlive the session and be shared by every project; refused if they overlap anything dejima needs out of a session's reach |
|
||||
| `read_write_per_project` | unset | the same, mounted at the path named but backed by `<path>/projects/<slug>` - one directory per project rather than one for the host. For anything a session writes to be read later as background, where another project reading it would be a channel rather than a convenience |
|
||||
| `nvim_config` | unset | nvim config dir, used when `[core] command = nvim` |
|
||||
|
||||
### `[audit]` - the exit-time audit
|
||||
|
||||
| Key | Default | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `dir` | `~/.local/share/dejima/audit` | where exit-time audit findings are stored |
|
||||
| `baselines_dir` | `~/.local/share/dejima/baselines` | where audit baselines are stored |
|
||||
| `keep` | `10` | how many past findings/baselines to retain |
|
||||
| `diffs` | `on` | keep the diff of what changed alongside each finding; `off` records that something changed without retaining the content |
|
||||
| `strict` | `off` | refuse a headless (non-tty) launch over unacknowledged CRITICAL findings; also settable per launch with `--strict-audit` |
|
||||
|
||||
### `[epitaph]` - what a compacted session leaves behind
|
||||
|
||||
| Key | Default | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `enabled` | `on` | keep the summary a harness writes when a session compacts; `--no-epitaph` skips it for one launch |
|
||||
| `dir` | `~/.local/share/dejima/epitaphs` | where they are kept; never mounted into a container |
|
||||
| `loki` | `off` | also push each epitaph to `[logging] loki_url`, host-side |
|
||||
|
||||
### `[logging]` - shipping logs off the host
|
||||
|
||||
Off entirely until `loki_url` is set. Nothing leaves the machine otherwise,
|
||||
and there is no endpoint of dejima's own anywhere in this.
|
||||
|
||||
| Key | Default | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `loki_url` | unset | ship DNS/firewall logs to a Loki endpoint; `--no-log` clears it for one launch, which silences everything downstream of it |
|
||||
| `conversation` | `off` | opt-in: also ship session transcripts to Loki (needs `loki_url`); see "Conversation logging (opt-in)" in [docs/security.md](security.md). `--no-conversation` turns it off for one launch, and `--no-log` implies it |
|
||||
|
||||
### `[remote-access]` - the web front door
|
||||
|
||||
Off entirely until `enabled` is `on`. The `ttyd_*` keys configure the terminal
|
||||
page and are read once at process start, so editing them needs
|
||||
`systemctl --user restart dejima-ttyd.service`. See
|
||||
[docs/remote-access.md](remote-access.md).
|
||||
|
||||
| Key | Default | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `enabled` | `off` | the switch: nothing else here runs until this is `on` |
|
||||
| `ntfy_url` | **required** for notifications | ntfy topic URL for session notifications. `dejima-notify-watch` refuses to start without it; nothing else reads it |
|
||||
| `base_url` | unset | public URL of the web front door, for the notification's deep link. Absent, pushes still go out - just with no link on them |
|
||||
| `debounce` | `60` | seconds between repeat notifications for the same session |
|
||||
| `ttyd_port` | `7681` | loopback port `ttyd` listens on; the reverse proxy has to `proxy_pass` to the same one |
|
||||
| `ttyd_font_size` | unset | terminal font size in px, 8-72. Applies to every client, phone and desktop alike |
|
||||
| `ttyd_tmux_prefix` | unset | the tmux prefix the page's buttons send; the page assumes stock `C-b` without it |
|
||||
| `ttyd_focus_on_tap` | unset | `on` lets a tap on the terminal raise the phone keyboard; unset means only the KBD button does |
|
||||
| `ttyd_button_colors` | unset | `off` returns the toolbar to monochrome |
|
||||
|
||||
### `[ui]` - what a launch prints
|
||||
|
||||
| Key | Default | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `banner` | `on` | the three-line summary at the top of a launch - project and branch, the selection, and the switches whose state changes what a session can do. `off` prints nothing. Never drawn when output is redirected |
|
||||
|
||||
|
||||
## Global and per-project domains and dependencies
|
||||
|
||||
Allowed domains and dependencies each stack through their own layers. The
|
||||
@@ -340,7 +478,9 @@ you don't have one.
|
||||
|
||||
### Cline
|
||||
|
||||
Almost nothing carries over, and that is the design rather than a gap.
|
||||
Your own configuration carries over the same way Claude Code's does - the
|
||||
table under [What comes in from your host](#what-comes-in-from-your-host) is
|
||||
the list. Your *sessions* do not, and that is the design rather than a gap.
|
||||
|
||||
Cline keeps one `sessions.db` indexing every session it has ever run, with
|
||||
the project a session belonged to as a field inside the record rather than a
|
||||
@@ -351,21 +491,33 @@ under `[harness.cline] state_dir` (default `~/.local/share/dejima/cline`),
|
||||
mounted at `~/.cline/data` in the container. Sessions persist across runs of
|
||||
the same project and are invisible to every other project.
|
||||
|
||||
Because that directory is dejima's rather than yours, resuming on the host
|
||||
needs `CLINE_DATA_DIR` set to it. The exact command is at the end of every
|
||||
session's briefing.
|
||||
Because that directory is dejima's rather than yours, resuming on the host is
|
||||
not a supported path - see
|
||||
[Sessions, and where they live](#sessions-and-where-they-live) below. Relaunch
|
||||
dejima on the same project instead, which is what the briefing tells the
|
||||
session to offer.
|
||||
|
||||
The model configuration is written fresh by dejima at each launch and is
|
||||
not yours either. Two shapes are supported:
|
||||
The model configuration is written fresh by dejima at each launch and is not
|
||||
yours either. Where the model is comes from `[harness.cline] backend` and
|
||||
nowhere else, so there is no second setting for it to disagree with, and two
|
||||
shapes are supported:
|
||||
|
||||
- **`base_url` unset** - Anthropic, reached through dejima's own credential
|
||||
proxy exactly as Claude Code is. What is written into the container is the
|
||||
placeholder `sandbox-credential-proxy-placeholder`; the real credential
|
||||
stays on the host.
|
||||
- **`base_url` set to a model you serve yourself** - either a loopback or
|
||||
RFC 1918 address, or a host you have allowlisted with `dejima-allow`.
|
||||
- **A backend the proxy serves** (`anthropic`, `anthropic-api-key`,
|
||||
`openrouter`) - reached through dejima's own credential proxy exactly as
|
||||
Claude Code is. What is written into the container is the placeholder
|
||||
`sandbox-credential-proxy-placeholder` and the endpoint's real hostname; the
|
||||
credential stays on the host. Which of Cline's own provider types dejima
|
||||
writes comes from the backend's `api_style`: its native Anthropic client for
|
||||
one, its OpenAI-compatible client for the other.
|
||||
- **A backend with no credential** (`self-hosted`, carrying its own
|
||||
`[backend.self-hosted] base_url`) - a model you serve, at either a loopback
|
||||
or RFC 1918 address or a host you have allowlisted with `dejima-allow`.
|
||||
Nothing needs a credential, so there is nothing to withhold.
|
||||
|
||||
Keeping the address on the backend rather than on the harness is what lets you
|
||||
have a local model and a hosted API both configured and switch between them by
|
||||
changing one word.
|
||||
|
||||
A name qualifies through the allowlist rather than through how it is spelled,
|
||||
because that is the same store deciding whether the container can reach the
|
||||
endpoint at all: one that fails the check is one the session could not have
|
||||
@@ -373,13 +525,13 @@ connected to anyway. A model server on your own network normally has a name,
|
||||
so requiring a literal address would have made this path unusable for the
|
||||
ordinary case.
|
||||
|
||||
Anything else is refused at launch - a hosted backend's API key would have to
|
||||
go inside the container, and dejima has no proxy that could hold one back on
|
||||
its behalf. Note what the check is *not* doing: dejima writes a placeholder
|
||||
into the container on this path whatever the endpoint is, so no real
|
||||
credential enters it either way. What the check decides is whether the
|
||||
configuration can work at all, which is worth saying at launch rather than as
|
||||
an auth error three layers down. See
|
||||
Anything else is refused at launch - a hosted API pointed at through a
|
||||
no-credential backend would want its key inside the container, and dejima has
|
||||
no proxy that could hold one back on its behalf. Note what the check is *not*
|
||||
doing: dejima writes a placeholder into the container on the proxied path
|
||||
whatever the endpoint is, so no real credential enters it either way. What the
|
||||
check decides is whether the configuration can work at all, which is worth
|
||||
saying at launch rather than as an auth error three layers down. See
|
||||
[development.md](development.md#worked-example-cline).
|
||||
|
||||
If the endpoint serves a model Cline has no catalogue entry for - which is every
|
||||
@@ -415,6 +567,96 @@ editing the base `Dockerfile`: a dependency added that way is declared,
|
||||
approved and layered like any other, and it survives a rebuild. Editing the
|
||||
Dockerfile directly puts it outside that record.
|
||||
|
||||
|
||||
#### Sessions, and where they live
|
||||
|
||||
This project's Cline state - sessions, transcripts, tasks - is kept on the host
|
||||
in `~/.local/share/dejima/cline/<project>` and mounted at `~/.cline/data`. It
|
||||
survives the container being replaced, so **launching dejima again on the same
|
||||
project picks up where the last session left off.**
|
||||
|
||||
A directory per project rather than one shared: Cline indexes every session it
|
||||
has ever run in a single database, so sharing one would put every project's
|
||||
history in every project's context - the same boundary `~/.claude/projects`
|
||||
gets, for the same reason.
|
||||
|
||||
**Cline sessions are not importable or exportable.** A session started by
|
||||
running `cline` on your own machine is in `~/.cline/data` and is not visible
|
||||
inside dejima; a session started inside dejima is in the per-project directory
|
||||
and is not offered back to a host-side Cline. There is no import, no export,
|
||||
and no merge - see
|
||||
[#301](https://gitbox.branderson.io/dejima/dejima/issues/301) for why that was
|
||||
looked at and not built.
|
||||
|
||||
The session briefing tells a Cline agent this, so it will not offer to hand
|
||||
you a session to resume outside the sandbox. Claude Code can be resumed on the
|
||||
host and its briefing says so; the claim lives in each harness's own briefing
|
||||
fragment rather than in the shared preamble, because only one of them can make
|
||||
it.
|
||||
|
||||
#### What comes in from your host
|
||||
|
||||
The same shape as Claude Code's, and for the same reason: your own
|
||||
configuration should reach a session, and nothing the session writes should
|
||||
reach your machine.
|
||||
|
||||
| Host path | How it arrives | Why |
|
||||
| --- | --- | --- |
|
||||
| `~/.agents/skills/` | read-only mount | skills are instructions the agent follows |
|
||||
| `~/.agents/hooks/`, `~/.cline/hooks/` | read-only mount | hooks are programs cline runs |
|
||||
| `~/.agents/AGENTS.md` | read-only copy | instructions; a copy also survives it being a symlink into a dotfiles checkout |
|
||||
| `~/.agents/rules/*.md`, `*.txt` | read-only copy, into `Documents/Cline/Rules/` | see below |
|
||||
| `~/Documents/Cline/Rules/*.md`, `*.txt` | read-only copy | the location cline actually reads |
|
||||
| `~/.cline/settings.json`, `~/.cline/cline_mcp_settings.json` | writable copy, discarded | cline rewrites both as it runs |
|
||||
| `~/.cline/data/` | this project's own state directory | see above |
|
||||
|
||||
Read-only is the point rather than caution. A session that could rewrite a
|
||||
hook, a skill or an MCP server definition would be choosing what happens on
|
||||
**your** machine the next time you start cline outside a container - which is
|
||||
persistence for a prompt injection, in files nobody re-reads before the next
|
||||
run. The two settings files are copies rather than read-only mounts because
|
||||
cline rewrites them itself; the copy is discarded with the container.
|
||||
|
||||
The read-only directories are scanned for symlinks, so a `skills/` that is a
|
||||
link into a dotfiles checkout brings its target too. dejima refuses a target
|
||||
that overlaps one of its own guarded stores.
|
||||
|
||||
**Global rules come from two places and land in one.** cline loads them from
|
||||
`~/Documents/Cline/Rules` and nowhere else - `~/.agents/rules` reads like the
|
||||
obvious companion to `~/.agents/skills` and is not loaded, which you can see
|
||||
by planting a file in each and opening `cline config`. So dejima copies the
|
||||
files from *both* host locations into the one directory cline reads, rather
|
||||
than mounting `~/.agents/rules` where nothing would look at it.
|
||||
|
||||
They are copied file by file rather than mounted as a directory, because
|
||||
dejima writes its own briefing into that same directory and a read-only mount
|
||||
over it would leave nowhere to put it.
|
||||
|
||||
Two collisions have fixed answers. A name present in **both** host locations
|
||||
is taken from `Documents/Cline/Rules`, since that is cline's own location for
|
||||
rules and where it writes when you edit them through cline. A host file named
|
||||
`00-dejima.md` is skipped: that is the briefing's name, and dejima's wins.
|
||||
## What dejima says before it starts
|
||||
|
||||
Some launches print a notice first: an environment variable that is no longer
|
||||
read, a deprecated key, a guard that is not registered, a line dejima wrote
|
||||
into your config. Everything after that is the image build, which on a cold
|
||||
cache is thousands of lines - so when a notice was printed, dejima waits for
|
||||
Enter before starting it.
|
||||
|
||||
The wait happens only when there was something to read, and only when a
|
||||
terminal is attached. An unattended launch never blocks on it.
|
||||
|
||||
`--no-pause` skips the wait and still prints the notice. It is for starting
|
||||
several sessions at once - a tmuxinator layout, a script - where every one of
|
||||
them has a terminal and none of them wants a keypress. It is a flag rather
|
||||
than a config key on purpose: it describes the terminal in front of one
|
||||
launch, not a standing preference.
|
||||
|
||||
A long list of entries left in the shared directory is sampled rather than
|
||||
printed whole, for the same reason: it is a standing condition you clear once,
|
||||
and it should not push the things about this run off the screen.
|
||||
|
||||
## Identities
|
||||
|
||||
**Several accounts on one machine, and none of them yours.** An
|
||||
@@ -601,23 +843,57 @@ session is meant to write to and whose contents should outlive the container:
|
||||
|
||||
```
|
||||
[mounts]
|
||||
read_write = ~/specs
|
||||
read_write = ~/.cache/shared-toolchain
|
||||
```
|
||||
|
||||
Both lists mount at the host path they name, so a convention that says
|
||||
`[mounts] read_write_per_project` mounts at the same path, but backs it with
|
||||
one directory per project instead of the whole thing:
|
||||
|
||||
```
|
||||
[mounts]
|
||||
read_write_per_project = ~/specs
|
||||
```
|
||||
|
||||
The container sees `~/specs` either way. Behind it, `read_write` puts the
|
||||
directory itself - every session's contents, including other projects' - and
|
||||
`read_write_per_project` puts `~/specs/projects/<slug>`, keyed the same way
|
||||
`[core] share_dir` and the audit store are.
|
||||
|
||||
**Which to reach for is decided by one question: should another project's
|
||||
session read what this one leaves there?** For a build cache or a directory of
|
||||
scripts, yes, and `read_write` is right. For design docs, scratch notes,
|
||||
anything an agent writes to be read later as background - no, and that is what
|
||||
`read_write_per_project` is for. A spec is written to be read as context, so a
|
||||
shared one is a channel from any project into every other.
|
||||
|
||||
All three lists mount at the host path they name, so a convention that says
|
||||
"specs live in `~/specs`" keeps working inside the container without being
|
||||
rewritten. That is the difference from `[core] share_dir`, which is
|
||||
mounted at `~/shared` regardless of where it lives on the host because its job
|
||||
is to be a well-known handoff point.
|
||||
|
||||
dejima creates the per-project directory; it never creates the one you named,
|
||||
so a path that is not there is refused as the typo it is. Naming a
|
||||
`read_write_per_project` root's `projects` directory in either of the other
|
||||
two lists is refused as well - handing that over is handing over every
|
||||
project's.
|
||||
|
||||
An entry is **refused, not warned about**, if it overlaps anything dejima
|
||||
relies on being out of a session's reach: the approval store, either audit
|
||||
store, `~/.claude`, `~/.local/bin`, dejima's own checkout, or the project mount
|
||||
(already read-write, so listing it again is a mistake worth surfacing). A
|
||||
writable mount over any of those would let a session approve its own domains,
|
||||
delete the audit findings naming it, or plant code that runs unsandboxed on the
|
||||
host next time you start an agent. Overlap is checked in both directions after
|
||||
resolving symlinks, so `read_write = ~` is caught too.
|
||||
store, the epitaph store, every project's share, `~/.local/bin`, dejima's own
|
||||
checkout, the project mount (already read-write, so listing it again is a
|
||||
mistake worth surfacing), and **everything the selected harness keeps on the
|
||||
host** - its config directories, the files staged and seeded from them, and
|
||||
the state directories its manifest declares. A writable mount over any of
|
||||
those would let a session approve its own domains, delete the audit findings
|
||||
naming it, read another project's transcripts, or plant code that runs
|
||||
unsandboxed on the host next time you start an agent. Overlap is checked in
|
||||
both directions after resolving symlinks, so `read_write = ~` is caught too.
|
||||
|
||||
The harness half of that list is derived from what the harness declares, not
|
||||
written down in dejima, so it covers whichever one you selected - `~/.claude`
|
||||
for Claude Code, `~/.agents` and `~/.cline` for Cline - and covers a harness
|
||||
added later without anyone extending a list.
|
||||
|
||||
Sessions are told which directories these are, so "everything else is
|
||||
disposable" stays true in the briefing rather than becoming a trap.
|
||||
@@ -696,17 +972,27 @@ dejima-share adopt # move files left loose in share_dir into this proj
|
||||
launching. That is what makes the inbound direction work without a mount that
|
||||
every project can read.
|
||||
|
||||
**From another machine,** `sshfs` puts a project's share in your own file
|
||||
manager over the SSH access you already have. Read
|
||||
[Mounting the box's files](remote-access.md#mounting-the-boxs-files) before
|
||||
setting that up: every byte and every filename in there was written by a
|
||||
sandboxed session, and a desktop that mounts the directory indexes,
|
||||
thumbnails and previews what it finds without waiting to be asked. Nothing
|
||||
dejima runs is in that path, so the sanitising it does for its own output does
|
||||
not reach there.
|
||||
|
||||
Treat it as a handoff point, not as general-purpose storage. If your own
|
||||
project instructions tell agents to write somewhere at a fixed host path -
|
||||
design docs, scratch notes, anything meant to outlive the session but stay out
|
||||
of the project checkout - mount that directory instead, with
|
||||
`[mounts] read_write`. It arrives at its own absolute path, so a convention
|
||||
that says "put specs in `~/specs`" keeps working inside the container without
|
||||
being rewritten to point at `~/shared`.
|
||||
`[mounts] read_write_per_project`. It arrives at its own absolute path, so a
|
||||
convention that says "put specs in `~/specs`" keeps working inside the
|
||||
container without being rewritten to point at `~/shared` - and, like `~/shared`
|
||||
itself, what a session finds there is this project's and not every project's.
|
||||
|
||||
`~/shared`, the project mount, and anything in `[mounts] read_write` are the
|
||||
only writable host paths in the container. Everything else a session writes
|
||||
goes away with it.
|
||||
`~/shared`, the project mount, and anything in `[mounts] read_write` or
|
||||
`[mounts] read_write_per_project` are the only writable host paths in the
|
||||
container. Everything else a session writes goes away with it.
|
||||
|
||||
**Clear it out periodically.** The per-project split bounds which sessions can
|
||||
read what you left, and bounds nothing about how long. A project's share
|
||||
@@ -820,14 +1106,21 @@ session can run.
|
||||
|
||||
### The session briefing
|
||||
|
||||
A `SessionStart` hook (`~/.claude/hooks/sandbox-context.sh`) injects a
|
||||
reminder when `DEJIMA` is set: the session is running in a
|
||||
disposable, firewalled container and should stay within the task at hand -
|
||||
if something genuinely needs to break out (broader network, host changes,
|
||||
GUI/hardware), stop and ask rather than trying to work around the firewall,
|
||||
since the same session can be resumed unrestricted on the host via
|
||||
`claude --resume`. No-op outside the container. `deny-dangerous-bash.sh`
|
||||
(`PreToolUse`) also applies here, since `~/.claude/hooks` is mounted in.
|
||||
Every session is told where it is running: a disposable, firewalled container
|
||||
it should stay within, and if something genuinely needs to break out - broader
|
||||
network, host changes, GUI/hardware - it should stop and ask rather than work
|
||||
around the firewall. Whether the session can be resumed unrestricted on the
|
||||
host afterwards is the harness's own answer, and each harness's briefing
|
||||
fragment gives it: Claude Code's names `claude --resume`, Cline's says plainly
|
||||
that its sessions do not leave the sandbox.
|
||||
|
||||
*How* the briefing reaches a session is per harness too, because they do not
|
||||
agree on a mechanism. Claude Code takes it as a system prompt through the flag
|
||||
its manifest names; Cline takes no such flag and registers no hook, so
|
||||
`entrypoint.sh` renders it into a file the harness reads, root-owned and
|
||||
read-only. A harness doing neither gets it through a `SessionStart` hook. What
|
||||
the briefing *says* is none of theirs - one script composes it for all three
|
||||
routes. See [development.md](development.md).
|
||||
|
||||
When GitHub is a push destination this session - `--allow-github-push`, or
|
||||
`forge = github` under a pushing workflow - the briefing also tells the
|
||||
|
||||
+54
-34
@@ -14,10 +14,13 @@ run them.
|
||||
| `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 |
|
||||
| `docker/init-firewall.sh` | IPv4/IPv6 egress allowlist firewall, run at filter-container start |
|
||||
| `docker/firewall-up.sh` | Brings a network namespace's egress control up: the DNS proxy, the packet filter, and the two log producers. What a namespace needs, rather than what a container does |
|
||||
| `docker/filter-entrypoint.sh` | PID 1 of the filter container: runs `firewall-up.sh`, signals readiness, then holds the namespace open for the sandbox to join |
|
||||
| `docker/caps-lib.sh` | `drop_caps` and the `CAP_SETPCAP` precondition, sourced by both entrypoints so the two cannot drift |
|
||||
| `docker/ulogd.conf` | `ulogd2` config: turns `init-firewall.sh`'s NFLOG-tagged packets into JSON |
|
||||
| `docker/log-shipper.sh` | Tails the dnsmasq/ulogd2 logs and ships them to the configured Loki endpoint (`[logging] loki_url`). Container-side, because those files only exist there |
|
||||
| `docker/entrypoint.sh` | Runs as root: DNS proxy, firewall, logging, drops to the sandbox user via `gosu`, then execs the session's command |
|
||||
| `docker/entrypoint.sh` | PID 1 of the session container: runs as root to prepare the session's own state, points `resolv.conf` at the namespace's resolver, then execs the session's command as the sandbox user with an empty capability bounding set. The egress control is the filter container's to bring up, not this one's |
|
||||
| `docker/harness-wrapper.sh` | Shadows the selected harness's command on `PATH` to add its no-confirm flags and dejima's settings layer. Named by no harness; `entrypoint.sh` links it into place |
|
||||
| `docker/docker-compose.yml` | Wires up mounts/env; invoked via `bin/dejima`, not directly |
|
||||
| `hooks/sandbox-context.sh` | `SessionStart` hook: injects sandbox context/workflow reminders, and any `.dejima.md` the project ships |
|
||||
@@ -64,8 +67,10 @@ top-level entries like `statusline.sh`) to resolve, and runs
|
||||
`docker compose run`. `bin/dejima-allow`
|
||||
adds a domain to a running session's firewall from the host - see
|
||||
[Adding a domain to a running session](security.md#adding-a-domain-to-a-running-session).
|
||||
`install.sh` installs the hooks into
|
||||
`~/.claude/hooks` and registers them in `~/.claude/settings.json`.
|
||||
`install.sh` writes nothing to `~/.claude`. Every hook dejima uses is baked
|
||||
into the image at `/usr/local/lib/dejima/hooks` and registered per launch into
|
||||
the `--settings` overlay, which Claude Code merges with your
|
||||
`~/.claude/settings.json` rather than replacing it.
|
||||
|
||||
## Tests
|
||||
|
||||
@@ -201,10 +206,12 @@ it has to argue about.
|
||||
|
||||
### What a harness must be able to do
|
||||
|
||||
Support is per harness and deliberate. A supported harness gets the same
|
||||
depth of integration Claude Code has - the same mounts, the same guards, the
|
||||
same audit surfaces - so a harness is not a manifest someone drops in. That
|
||||
is only possible if the harness can host what those protections need.
|
||||
Support is per harness and deliberate. A supported harness gets everything
|
||||
dejima has to give: its state scoped per project, its config carried in
|
||||
read-only, its audit surfaces watched, and its guards installed wherever it
|
||||
can host one. So a harness is not a manifest someone drops in - and where it
|
||||
cannot host a protection, the launch says so rather than leaving the gap
|
||||
unnamed.
|
||||
|
||||
These are the requirements. A harness that fails one of the first four cannot
|
||||
be supported, and that is a decision about the harness rather than a gap in
|
||||
@@ -315,7 +322,7 @@ or better is the requirement.
|
||||
| **R3** | **Yes, by running it, and the documentation is wrong about how.** A rules file at `~/Documents/Cline/Rules/00-dejima.md` reached the model: asked for a codeword only that file contained, the session answered with it. `~/.agents/AGENTS.md` works too. `~/Cline/Rules`, which the documentation names, is **not** read on Linux, and `CLINE_RULES` pointing at a file did nothing. So the briefing is a file rather than a hook, which is what `briefing_path` in the manifest is for. |
|
||||
| **Network reach** | **In the container, unlike Claude Code's.** Not a requirement, but the thing [security.md](security.md#the-firewall-bounds-the-container-not-the-harness) says has to be established per harness rather than assumed. Asked to fetch a page the allowlist does not carry, Cline's web-fetch tool ran and returned "Unable to connect" - the same answer `curl` gives. So the egress allowlist bounds what reaches a Cline session, which is not true of Claude Code's `WebSearch`. |
|
||||
| **R4** | **Yes, by running it, and this is where Cline is strongest.** `--data-dir` relocates the whole store - `db/`, `settings/`, `logs/`, `cache/`. A full run against a chosen directory put everything there and left nothing in the default location. There are per-path environment variables too: `CLINE_DATA_DIR`, `CLINE_DB_DATA_DIR`, `CLINE_SESSION_DATA_DIR`, `CLINE_TEAM_DATA_DIR`, `CLINE_PROVIDER_SETTINGS_PATH`, `CLINE_HOOKS_LOG_PATH`. Per-project scoping is a flag rather than an inference. |
|
||||
| **R5** | **Yes, and confirmed end to end.** Credentials are a plain file, `settings/providers.json`, whose path `CLINE_PROVIDER_SETTINGS_PATH` relocates - so dejima can decline to mount the operator's. The substitute is the one that already exists: Cline configured for `anthropic` with the placeholder key `sandbox-credential-proxy-placeholder`, pointed at the real hostname, ran a real completion through dejima's in-container terminator and host-side proxy. No base-URL override and no new proxy work - the same arrangement as Claude Code's faked `.credentials.json`, and the session is indistinguishable from a stock install. |
|
||||
| **R5** | **Yes, and confirmed end to end.** Credentials are a plain file, `settings/providers.json`, whose path `CLINE_PROVIDER_SETTINGS_PATH` relocates - so dejima can decline to mount the operator's. The substitute is the one that already exists: Cline configured for `anthropic` with the placeholder key `sandbox-credential-proxy-placeholder`, pointed at the real hostname, ran a real completion through dejima's in-container terminator and host-side proxy. No base-URL override and no new proxy work - the same arrangement as Claude Code's faked `.credentials.json`, and the session is indistinguishable from a stock install. **What that completion did not establish is which models the subscription credential will serve a client that is not Claude Code**: Anthropic gates that per model and answers a refused one with a 429, so the arrangement is confirmed and the entitlement is not dejima's to give. See [providers.md](providers.md#a-subscription-credential-is-claude-codes-and-anthropic-enforces-that). |
|
||||
|
||||
Two notes for whoever writes the integration. Cline's plugin manifest schema
|
||||
still carries a field described as "not read by Claude Code", so its plugin
|
||||
@@ -354,20 +361,26 @@ that command lives in the harness's briefing fragment rather than in
|
||||
`resume_command`: briefing substitution is one pass, so a placeholder inside
|
||||
a substituted value is never resolved.
|
||||
|
||||
*Provider-agnostic is not the same as endpoint-agnostic.* Cline can be pointed
|
||||
anywhere; dejima cannot follow it everywhere. With `base_url` unset it runs
|
||||
against Anthropic through dejima's own credential proxy and the key never
|
||||
enters the container. Pointed at someone else's hosted API there is no proxy
|
||||
that could hold a key back, so the key would have to go in - which is R5
|
||||
failing, not a network restriction. The plugin therefore accepts `base_url`
|
||||
only for a model the operator serves themselves - a loopback or RFC 1918
|
||||
address, or a host they have allowlisted - where an open-weight model needs no
|
||||
credential at all and R5 has nothing to protect. The allowlist is what
|
||||
qualifies a name, because it is the same store deciding what the container can
|
||||
reach: an endpoint failing the check is one the session could not have
|
||||
connected to anyway, and a model server on someone's own network normally has
|
||||
a name rather than a literal address. A provider-generic proxy would widen
|
||||
this to hosted providers; nobody has written one (#171).
|
||||
*Backend-agnostic is not the same as endpoint-agnostic.* Cline can be pointed
|
||||
anywhere; dejima cannot follow it everywhere. A backend the proxy serves is
|
||||
fine whatever its dialect - the proxy takes the credential's kind and header
|
||||
names as arguments, so `openrouter` works the way `anthropic` does and the key
|
||||
never enters the container either way. Whether the ENDPOINT then serves the
|
||||
request is a separate question dejima does not answer: a Claude subscription
|
||||
credential is served to Claude Code and refused to other clients for the
|
||||
larger models, which is Anthropic's call and not one dejima works around
|
||||
(see [providers.md](providers.md#a-subscription-credential-is-claude-codes-and-anthropic-enforces-that)). What dejima cannot follow is a hosted
|
||||
API reached *without* a backend: there is no proxy holding a key back on its
|
||||
behalf, so the key would have to go in, which is R5 failing rather than a
|
||||
network restriction.
|
||||
|
||||
So the `credential = none` path is only accepted for a model the operator
|
||||
serves themselves - a loopback or RFC 1918 address, or a host they have
|
||||
allowlisted - where the endpoint needs no credential at all and R5 has nothing
|
||||
to protect. The allowlist is what qualifies a name, because it is the same
|
||||
store deciding what the container can reach: an endpoint failing the check is
|
||||
one the session could not have connected to anyway, and a model server on
|
||||
someone's own network normally has a name rather than a literal address.
|
||||
|
||||
*Conversation logging works; epitaphs do not, and only one of those is
|
||||
Cline's fault.* Cline's transcripts are
|
||||
@@ -499,9 +512,9 @@ harness-specific path resolving to nothing is the honest answer.
|
||||
| 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-credential-proxy`, `docker/credential-proxy-forwarder.js`, `docker/entrypoint.sh` |
|
||||
| Proxied endpoint | which name the in-container TLS terminator answers for, and what `/etc/hosts` pins to loopback. The BACKEND's `proxied_domain`, not the harness's: a harness reaches whichever endpoint it is configured for, so the name belongs to the endpoint. A proxied backend with no domain declared is refused rather than started answering for nothing | `backends/<name>/backend.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` on the harness, `proxied_domain` on the backend | `harnesses/<name>/harness.conf`, `backends/<name>/backend.conf` |
|
||||
| Credentials | which substitute the harness accepts, and where it reads it from: a well-formed fake `~/.claude/.credentials.json` the entrypoint seeds for Claude Code, a placeholder key in `settings/providers.json` for Cline, a placeholder in an environment variable on the API-key path. The proxy itself is neither harness's - what the credential is, which header carries it and whether it refreshes are the backend's, passed as arguments | `bin/dejima-credential-proxy`, `docker/credential-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` |
|
||||
| Briefing delivery | **by which of three routes**, not whether. Claude Code takes the briefing as a system prompt, through the flag its manifest names in `briefing_args`, because a `SessionStart` payload past roughly nine kilobytes is persisted and replaced with a preview - so the hook route silently under-delivered a briefing carrying a project's `.dejima.md`. Cline takes no such flag and registers no hook, so it declares a `briefing_path` and reads a file `entrypoint.sh` writes root-owned and read-only. A harness doing neither still gets it through the hook. What the briefing *says* is none of theirs: composing it host-side is core's, and the container-side part - this container's id, the declared-versus-installed gap, the project's `.dejima.md` - is one script every route calls | `hooks/dejima-briefing-render.sh`, `hooks/sandbox-context.sh`, `docker/entrypoint.sh` |
|
||||
@@ -551,13 +564,20 @@ 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.
|
||||
environment, and not its name as a default command.
|
||||
|
||||
Nor is the credential proxy harness-shaped. Which header carries the
|
||||
credential, what shape it takes, what must not travel beside it and whether it
|
||||
can be refreshed are all arguments the selected backend supplies, so a second
|
||||
harness reaches a proxied backend without a branch of its own - the R5 row
|
||||
above. Cline has run real completions through the same terminator
|
||||
and the same host-side proxy, against `anthropic` and against `openrouter`,
|
||||
which is a different api_style through the same code.
|
||||
|
||||
`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`,
|
||||
`runtime.bats` - are the ones that will need a second set, and a green run
|
||||
against the existing harness proves nothing about a new one. Nothing in
|
||||
`test/` currently runs a second harness end to end.
|
||||
`harness.conf` gets its parsing and validation tests. `config.bats`,
|
||||
`static.bats`, `project.bats` and `conversation.bats` each carry a second
|
||||
harness's cases beside Claude Code's, which is the shape a third one should
|
||||
follow. What is still missing is the end of the ladder: `runtime.bats` boots
|
||||
only `claude-code`, so nothing in `test/` runs a second harness end to end in
|
||||
a real container, and a green confined run says nothing about that.
|
||||
|
||||
+93
-4
@@ -232,11 +232,10 @@ A workflow's `briefing.md` is plain Markdown plus two marker forms that
|
||||
| `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. |
|
||||
| `proxied_api_domain` | `?hostname` | (empty) | The model API endpoint the credential proxy stands in front of. Allowlisted **only when the proxy is off**: with it on, the session reaches the API through the forwarder, so leaving the name unresolvable defeats a bypass that looks it up by DNS. Which name it is comes from here; when to allowlist it is dejima's rule. |
|
||||
| `api_styles` | `?list:identifier` | (empty) | Which backend `api_style`s this harness has a client for. `claude-code` speaks `anthropic`; `cline` speaks both, which is what makes it the backend-agnostic one. Empty means unchecked. |
|
||||
| `guards` | `?list:identifier` | (empty) | Which of dejima's guard rails this harness can host: `bash-deny`, `push-guard`, `merge-guard`, `notify`. A name outside that set is refused rather than treated as an unknown guard, since a typo would otherwise turn a guard off silently. The ones a harness does not declare are named at launch - see "What a harness must be able to do" in [development.md](development.md). |
|
||||
| `briefing_args` | `?args` | (empty) | The flag that carries the briefing to a harness taking it on the command line - for Claude Code, `--append-system-prompt`. `dejima-harness-wrapper` reads the rendered briefing and passes it as a single value, so every invocation carries it and not only the entrypoint's. Set, the session-start hook stops carrying the briefing and reduces to the session id, which is the one thing that does not exist until the harness starts. |
|
||||
| `briefing_path` | `?homepath` | (empty) | Where this harness reads its briefing from, relative to `$HOME`, for a harness that has no session-start hook to be handed it through. Set, `entrypoint.sh` renders the briefing into that path root-owned and read-only before the session starts, and refuses to start it at all if that fails. Empty for a harness that registers a hook instead, which is how Claude Code gets it. |
|
||||
| `briefing_path` | `?homepath` | (empty) | Where this harness reads its briefing from, relative to `$HOME`, for a harness that has no session-start hook to be handed it through. Set, `entrypoint.sh` renders the briefing into that path root-owned and read-only before the session starts, and refuses to start it at all if that fails. Empty for a harness that takes the briefing on the command line (`briefing_args`, which is how Claude Code gets it) or that can be handed it through a session-start hook. |
|
||||
| `no_confirm_args` | `?template` | (empty) | The flags that make the harness run without confirming each tool call. Required in practice: dejima refuses to launch a harness that names none, because unattended operation is the premise the container is built on (R1 in [development.md](development.md)). |
|
||||
| `settings_args` | `?template` | (empty) | How this harness is told to read dejima's own settings layer, mounted at `/etc/dejima-settings.json`. Empty for a harness that takes its overrides some other way. |
|
||||
| `keys` | `?list:keyspec` | (empty) | This harness's own `[harness.<name>]` config schema - same grammar as a forge's `keys`. |
|
||||
@@ -299,6 +298,13 @@ Compatibility is not a list anyone maintains: it is the backend's `api_style`
|
||||
against the harness's `api_styles`, checked at launch. This table is what those
|
||||
declarations currently work out to.
|
||||
|
||||
It answers whether dejima will pair the two, which is a question about
|
||||
dialects. Whether the endpoint then serves the request is the endpoint's to
|
||||
answer, and for a Claude subscription credential the answer depends on the
|
||||
harness - see [A subscription credential is Claude
|
||||
Code's](#a-subscription-credential-is-claude-codes-and-anthropic-enforces-that)
|
||||
below.
|
||||
|
||||
| Backend | `claude-code` | `cline` | Credential | You configure |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `anthropic` | yes | yes | OAuth, refreshed and written back | nothing, once you have logged in on the host |
|
||||
@@ -316,6 +322,54 @@ Adding a backend does not change this table by hand: a new directory whose
|
||||
`api_style` is one a harness already declares works with that harness the day
|
||||
it lands.
|
||||
|
||||
### A subscription credential is Claude Code's, and Anthropic enforces that
|
||||
|
||||
`backends/anthropic` reads the OAuth credential Claude Code writes when you
|
||||
sign in with a Claude subscription. **Anthropic serves that credential to
|
||||
Claude Code and refuses other clients, per model.** Observed on a Max
|
||||
subscription: `claude-haiku-4-5` is served, `claude-opus-5` and
|
||||
`claude-sonnet-5` are refused, while an otherwise identical request from
|
||||
Claude Code succeeds.
|
||||
|
||||
The refusal arrives as **HTTP 429 `rate_limit_error`**, with a body whose
|
||||
message is the word `Error`. It is indistinguishable from a quota problem
|
||||
from the inside, which is worth knowing before you spend an afternoon on
|
||||
your usage graphs: the account can be nowhere near any limit and every
|
||||
request will still be refused.
|
||||
|
||||
So with `[core] harness = cline` and `backend = anthropic`, expect Haiku to
|
||||
work and the larger models not to. For those, point cline at a backend whose
|
||||
credential is yours to spend rather than a subscription's:
|
||||
|
||||
```
|
||||
[harness.cline]
|
||||
backend = anthropic-api-key # console billing, key held on the host
|
||||
# or
|
||||
backend = openrouter
|
||||
```
|
||||
|
||||
`claude-code` with `backend = anthropic` is unaffected - that pairing is the
|
||||
one Anthropic is serving.
|
||||
|
||||
**dejima does not work around this**, and that is a limit on what dejima does
|
||||
rather than an opinion about the attempt.
|
||||
|
||||
The credential proxy substitutes the credential and changes nothing else: the
|
||||
header block the API receives is the harness's own, down to not adding an
|
||||
`Accept-Encoding` the client did not send. Getting past this gate would mean
|
||||
sending a system prompt the harness never wrote, so that the request presents
|
||||
as a different product - which is the one thing the proxy is built not to do,
|
||||
and would put a second answer to "which client is this" inside the component
|
||||
whose whole job is to pass the first one through unchanged.
|
||||
|
||||
It would also be brittle in a way that costs the operator rather than dejima:
|
||||
it works until the shape it copies changes, and the failure lands on their
|
||||
account.
|
||||
|
||||
Which models are gated is Anthropic's to change, and the table above is an
|
||||
observation on one subscription rather than a documented policy - if it moves,
|
||||
this section moves with it.
|
||||
|
||||
**Which backend a launch uses is the harness's setting**, `[harness.<name>]
|
||||
backend`, overridable for one launch with `--backend`. Per harness because
|
||||
that is how it is used: one harness against Anthropic and another against
|
||||
@@ -391,8 +445,8 @@ known until the container is actually running.
|
||||
|
||||
### How the briefing reaches the session
|
||||
|
||||
**A session-start hook is a size-limited channel, which is why it is no longer
|
||||
the only route.** Claude Code persists a large `SessionStart` payload to a file
|
||||
**A session-start hook is a size-limited channel, which is why it is not the
|
||||
only route.** Claude Code persists a large `SessionStart` payload to a file
|
||||
and injects a short preview in its place: measured in a dejima container, 9KB
|
||||
arrived whole and 21KB did not. A composed briefing plus a project's own
|
||||
`.dejima.md` runs well past that, so most of the briefing silently never
|
||||
@@ -554,6 +608,41 @@ can be pointed at any endpoint, including a model you serve yourself - and
|
||||
expect to be the person who finds the next crack.
|
||||
|
||||
|
||||
### Model backend maturity
|
||||
|
||||
The same caveat the harnesses and forges get, and for the same reason: four
|
||||
backends in one table invites the reading that each got the same amount of
|
||||
use, and they did not.
|
||||
|
||||
**`anthropic` is the one dejima was built around.** The credential proxy, the
|
||||
in-container terminator, the refresh-and-write-back - all of it was designed
|
||||
against a subscription login and generalised afterwards. It has been used for
|
||||
real work continuously, which is the coverage no test substitutes for.
|
||||
|
||||
**`anthropic-api-key` works and has not been leaned on.** It was verified end
|
||||
to end on a real machine: the model answers, billing shows as API usage, the
|
||||
key stays on the host and the container holds only a placeholder. That is one
|
||||
confirmed launch rather than months of use, and the difference matters for the
|
||||
same reason it does above.
|
||||
|
||||
**`openrouter` is at the same level.** Built from the same manifest shape,
|
||||
exercised against a fake upstream and through a full launch, and confirmed
|
||||
against the real endpoint. What has not happened yet is a long tail of
|
||||
sessions through it, so expect the rough edges that only volume finds.
|
||||
|
||||
**`self-hosted` runs no proxy at all**, which makes it the simplest of the
|
||||
four and the one with least to go wrong in dejima: there is no credential to
|
||||
withhold, so the session reaches the endpoint directly and dejima's only job
|
||||
is refusing an address that is not host-local. It has been run against a local
|
||||
model. What it does not get is any of the credential machinery's protection,
|
||||
because there is nothing to protect.
|
||||
|
||||
If you are choosing with no other reason to prefer one, and you have a
|
||||
subscription, `anthropic` is the path with the most miles on it. The other
|
||||
three are supported in the same sense the second harness is: built
|
||||
deliberately, tested, and newer than the thing they sit beside.
|
||||
|
||||
|
||||
### Forge integration maturity
|
||||
|
||||
The same caveat the harnesses get, for the same reason: two forges listed in
|
||||
|
||||
+7
-4
@@ -46,11 +46,14 @@ at all.
|
||||
`test/runtime.bats` needs Docker, which a sandbox does not have. It is the only
|
||||
thing that exercises the mounts, the firewall and the entrypoint end to end, so
|
||||
a release is exactly when not running it stops being acceptable. Either run it
|
||||
on the host, or **check that the privileged CI job ran** - it is pinned to one
|
||||
self-hosted runner and queues silently when that runner is down, so a pull
|
||||
request can sit with the runtime half neither green nor red.
|
||||
on the host, or **read the pull request's checks**: green means every job
|
||||
finished and passed, `runtime.bats` among them. Yellow or grey means the
|
||||
privileged job has not run - it is pinned to one self-hosted runner and queues
|
||||
silently when that runner is down.
|
||||
|
||||
"CI covers it" is not "it passed."
|
||||
So the answer is the colour. Green is verified end to end; say so. Anything
|
||||
else is a run that has not happened, and reporting it as passing is what this
|
||||
paragraph exists to prevent.
|
||||
|
||||
## 2. Security review
|
||||
|
||||
|
||||
+59
-13
@@ -52,12 +52,11 @@ running.
|
||||
|
||||
## The bind address is the one thing to get right
|
||||
|
||||
`docker/init-firewall.sh` deliberately ACCEPTs container OUTPUT to the
|
||||
container's default-route address - the Docker bridge gateway, which is your
|
||||
host. That is the one host address a sandbox can reach at all. Combine it
|
||||
with the fact above (a tmux client is a host shell) and the consequence is
|
||||
direct: **a front door the sandbox can reach is a compromised session's own
|
||||
escape hatch.**
|
||||
`docker/init-firewall.sh` deliberately ACCEPTs OUTPUT to the session's
|
||||
default-route address - the Docker bridge gateway, which is your host. That is
|
||||
the one host address a sandbox can reach at all. Combine it with the fact above
|
||||
(a tmux client is a host shell) and the consequence is direct: **a front door
|
||||
the sandbox can reach is a compromised session's own escape hatch.**
|
||||
|
||||
So:
|
||||
|
||||
@@ -542,6 +541,46 @@ nvim split separator resizes the split. Neither needs any server-side
|
||||
configuration - they are stock tmux and nvim behaviour; the page's only
|
||||
contribution is delivering the drag.
|
||||
|
||||
### A scroll gesture jumps too far, and the amount is tmux's
|
||||
|
||||
A scroll gesture is sent as wheel notches, one per row of finger travel. What a
|
||||
notch is then worth belongs to whoever receives it, and this page is not it:
|
||||
when the pane is scrolling its own history, tmux is.
|
||||
|
||||
**Find out what yours does before changing anything:**
|
||||
|
||||
```sh
|
||||
tmux list-keys -T copy-mode-vi | grep WheelUpPane
|
||||
```
|
||||
|
||||
Stock tmux scrolls five lines (`send-keys -X -N 5 scroll-up`), which is a fifth
|
||||
of a desktop terminal and better than half a phone one. A configuration that
|
||||
binds `halfpage-up` instead - a common enough idiom that it is worth naming -
|
||||
scrolls half the pane, so twenty lines in a forty-line one. That is the case
|
||||
this section exists for: on a phone it throws the screen on the smallest
|
||||
movement of a thumb, and placing a particular line takes several tries.
|
||||
|
||||
Set it to a few lines, in your own `~/.tmux.conf`:
|
||||
|
||||
```tmux
|
||||
bind -T copy-mode-vi WheelUpPane select-pane \; send -X -N 4 scroll-up
|
||||
bind -T copy-mode-vi WheelDownPane select-pane \; send -X -N 4 scroll-down
|
||||
bind -T copy-mode WheelUpPane select-pane \; send -X -N 4 scroll-up
|
||||
bind -T copy-mode WheelDownPane select-pane \; send -X -N 4 scroll-down
|
||||
```
|
||||
|
||||
Both tables, because which one is active follows `mode-keys`. `scroll-up` takes
|
||||
no amount of its own, so the `-N` repeat count is the amount. It applies to
|
||||
every client of that tmux server, phone and desktop alike - which is the reason
|
||||
it is not something dejima sets for you: `dejima-attach` joins the tmux session
|
||||
you launched from, and its configuration is yours.
|
||||
|
||||
Nothing in dejima can make the step smaller from this side. The page can only
|
||||
choose how many notches to send, and sending fewer means dragging further for
|
||||
each one - which trades a jumpy scroll for a stiff one rather than fixing
|
||||
either. An application reading the mouse directly, rather than through tmux,
|
||||
picks its own figure and there is no binding for that at all.
|
||||
|
||||
### Toolbar
|
||||
|
||||
```
|
||||
@@ -798,13 +837,20 @@ what the outer proxy sends.
|
||||
|
||||
## Notifications
|
||||
|
||||
`dejima` registers `hooks/dejima-notify.sh` as a `Stop` hook for a
|
||||
remote-enabled session, into the throwaway settings override layer it already
|
||||
generates - never into your real `~/.claude/settings.json`. Your own hooks
|
||||
keep running alongside it, since Claude Code merges hooks across layers rather
|
||||
than replacing. The layer is mounted read-only, so a session cannot
|
||||
unregister its own hook. The hook writes one empty file named `done` into
|
||||
this run's marker directory: no content, no stdin, no network.
|
||||
**Only for a harness that declares the `notify` guard**, because a
|
||||
notification needs a session-lifecycle signal to hang off and not every
|
||||
harness has one. Claude Code does; Cline does not, and a remote-enabled Cline
|
||||
session gets attach and the web terminal but no push. The launch says which
|
||||
guards a harness is going without, so that is visible rather than a
|
||||
notification that silently never arrives.
|
||||
|
||||
Where it is declared, `dejima` registers `hooks/dejima-notify.sh` as a `Stop`
|
||||
hook for a remote-enabled session, into the throwaway settings override layer
|
||||
it already generates - never into your real `~/.claude/settings.json`. Your
|
||||
own hooks keep running alongside it, since Claude Code merges hooks across
|
||||
layers rather than replacing. The layer is mounted read-only, so a session
|
||||
cannot unregister its own hook. The hook writes one empty file named `done`
|
||||
into this run's marker directory: no content, no stdin, no network.
|
||||
|
||||
**One push per stop, and not a second one a minute later.** The hook and the
|
||||
watcher both understand a `waiting` event as well, and Claude Code's
|
||||
|
||||
@@ -57,7 +57,11 @@ doing any work.
|
||||
hostile?** The field says no for untrusted code, and tiers gVisor and
|
||||
per-workload VM kernels above it. dejima's answer is that escape moves a
|
||||
layer - true where the layer exists. See
|
||||
[Which deployment this describes](threat-model.md#which-deployment-this-describes).
|
||||
[Which deployment this describes](threat-model.md#which-deployment-this-describes)
|
||||
and [Why a container, and not a microVM](threat-model.md#why-a-container-and-not-a-microvm),
|
||||
which is where the reasoning is written down so a sweep can contradict it.
|
||||
The live half of this question is question 5: whether a current escape
|
||||
clears this configuration's floor.
|
||||
2. **Does a name-based egress allowlist hold?** It holds against an agent that
|
||||
must resolve a name. It does not hold against exfiltration through an
|
||||
allowed domain or against DNS query-name channels, both of which are listed
|
||||
|
||||
+172
-46
@@ -271,7 +271,7 @@ In that configuration the provider directories are in the exit-time audit's
|
||||
TCB list, alongside `bin/`, `hooks/`, `docker/`, `test/` and `tools/`. They
|
||||
hold no code and nothing executes them, but what they hold decides what the
|
||||
next launch permits: a forge's `fixed_hosts` reaches `DEJIMA_FORGE_HOSTS`,
|
||||
which `entrypoint.sh` joins into the domains `init-firewall.sh` allowlists, so
|
||||
which `firewall-up.sh` joins into the domains `init-firewall.sh` allowlists, so
|
||||
a line added to a manifest widens the egress boundary on the next run.
|
||||
`tools/*/tool.conf` is the same class, and is read by the host rather than the
|
||||
container: it names a tarball URL, the sha256 checked against it and the
|
||||
@@ -291,29 +291,31 @@ can make; it is one the audit reports. See
|
||||
|
||||
- Running the harness with its permission prompts off 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-
|
||||
bash.sh`, `git-push-guard.sh` and `merge-guard.sh` are regex
|
||||
pattern-matching on the literal Bash command string - a fast-fail guard
|
||||
against an overeager agent doing something obviously destructive, not a
|
||||
boundary against one that's actively trying to get around them (a
|
||||
different scripting language, an encoded command, editing `.git/config`
|
||||
directly all sail right through).
|
||||
Nothing here stops a compromised or badly-instructed session from
|
||||
modifying or deleting the real working copy it's mounted against.
|
||||
- The mounted project itself has **no write protection**. The guards are
|
||||
fast-fail checks against an overeager agent doing something obviously
|
||||
destructive, not a boundary against one actively trying to get around them.
|
||||
`deny-dangerous-bash.sh` is regex pattern-matching on the literal Bash
|
||||
command string, so a different scripting language, an encoded command or
|
||||
editing `.git/config` directly all sail right through. The push and merge
|
||||
guards shadow `git` and the forge CLI on `PATH` and read argv, so quoting
|
||||
and pipelines stop mattering - but a session that means to get round one can
|
||||
call `/usr/bin/git` directly. Nothing here stops a compromised or
|
||||
badly-instructed session from modifying or deleting the real working copy
|
||||
it's mounted against.
|
||||
- That same read-write access to the mounted project is also a
|
||||
host-code-execution vector, not just a data-destruction one: a
|
||||
compromised session could plant `.git/hooks/post-commit` (or
|
||||
`pre-push`), set `core.hooksPath`, or write a
|
||||
`.claude/settings.local.json` with a malicious hook command, into the
|
||||
project. Any of those run unsandboxed on the host the next time you run
|
||||
`git` or open Claude Code in that repo - no network egress required, so
|
||||
`git` or open your harness in that repo - no network egress required, so
|
||||
the firewall doesn't see it either. Same underlying persistence pattern
|
||||
as the `~/.claude/hooks` and `~/.claude.json` risks above, just through
|
||||
as the harness config risks above, just through
|
||||
the project mount instead of the config mount. "Only point this at
|
||||
trusted repositories" (above) covers input trust; this is about the
|
||||
sandbox writing a persistence payload into an otherwise-trusted repo.
|
||||
The workspace's own `.git/hooks` directory is mounted read-only
|
||||
(same pattern as the `~/.claude` code directories below), so planting a hook
|
||||
(same pattern as the harness's own code directories below), so planting a hook
|
||||
file directly is closed - accepted breakage: installing hooks
|
||||
*in-session* (`husky`, `pre-commit install`) fails, which is the
|
||||
point. `core.hooksPath` and `.claude/settings.local.json` aren't
|
||||
@@ -498,6 +500,16 @@ approximation of its name.
|
||||
|
||||
## One project's session cannot reach another's
|
||||
|
||||
The property is the same for every harness; the mechanism is not, because it
|
||||
depends on how the harness keys its own state. Claude Code stores a directory
|
||||
per project and dejima mounts a slice of it. Cline stores one flat database
|
||||
indexing every session it has ever run, with the project as a field inside the
|
||||
record, so there is no slice to take - dejima gives each workspace a data
|
||||
directory of its own instead. Both reach the same place: a session sees its
|
||||
own project's history and no other's.
|
||||
|
||||
### Claude Code: a slice of your own tree
|
||||
|
||||
- `~/.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,
|
||||
@@ -551,6 +563,31 @@ approximation of its name.
|
||||
That costs undo history for that session if it is later resumed on the
|
||||
host, and it is the reason this cannot be scoped as completely as
|
||||
`projects/`, where the workspace's own slug is known at launch.
|
||||
|
||||
### Cline: a directory dejima owns, one per workspace
|
||||
|
||||
- `~/.cline/data` is **not** mounted. Cline indexes every session it has ever
|
||||
run in one `sessions.db`, with the project a session belonged to as a field
|
||||
inside the record rather than a directory above it, so there is no
|
||||
per-project subtree to slice: mounting your data directory would put every
|
||||
project's transcripts in every session, which is this section's whole
|
||||
subject.
|
||||
|
||||
A launch instead creates `<[harness.cline] state_dir>/<workspace slug>` on
|
||||
the host and mounts *that* at `~/.cline/data`. Every session under it belongs
|
||||
to this project by construction rather than by a filter that could be wrong,
|
||||
which is why Cline clears this requirement more cleanly than Claude Code
|
||||
rather than less.
|
||||
|
||||
The cost is that the directory is dejima's rather than yours: a session
|
||||
started inside the sandbox is not offered back to a host-side Cline, and one
|
||||
you started on the host is not visible inside. There is no import or export
|
||||
([#301](https://gitbox.branderson.io/dejima/dejima/issues/301)), and the
|
||||
briefing tells the session so, so it will not offer you a resume command that
|
||||
cannot work.
|
||||
|
||||
### Whichever harness is selected
|
||||
|
||||
- Epitaphs - the summary a session leaves when its context is compacted away -
|
||||
are kept host-side in `[epitaph] dir`, and that directory is never mounted. A session can neither read what earlier
|
||||
sessions concluded nor edit what this one is about to record, which is the
|
||||
@@ -589,7 +626,7 @@ it.
|
||||
| **Proxied, refreshing** (`credential = anthropic-oauth`) | A fake credential file whose secrets are placeholders. The real one is read host-side per request. | The placeholder. | Use the API as you, through the proxy, while the session runs. |
|
||||
| **Proxied, static key** (`credential = static`) | A placeholder key in the harness's own model configuration, and the endpoint's real hostname. | The placeholder. | The same. |
|
||||
| **A backend with no credential** (`credential = none`, a model you serve) | Nothing but the endpoint's address. It needs no credential, so there is none to withhold. | Nothing. | Reach that endpoint, which is why it has to be one you allowlisted. |
|
||||
| **No proxy** (`[credentials] proxy = off`, or a Claude Code login the selected backend cannot serve) | The real credential file, mounted. | **The credential itself**, durable and usable offline. | Everything the credential allows, for as long as it is valid. |
|
||||
| **No proxy** (`[credentials] proxy = off`, or `[backend.<name>] proxy = off`) | The real credential file, mounted. | **The credential itself**, durable and usable offline. | Everything the credential allows, for as long as it is valid. |
|
||||
|
||||
**The first three keep the credential out; the fourth does not.** That is the
|
||||
whole of the difference, and it is why `proxy = off` is described here as a
|
||||
@@ -604,8 +641,16 @@ the host and injected per request. On the API-key path the container gets
|
||||
`ANTHROPIC_API_KEY=sandbox-credential-proxy-placeholder` and no credential file
|
||||
at all.
|
||||
|
||||
What lands in the fourth row is `proxy = off`, and a login whose shape no
|
||||
selected backend serves.
|
||||
**Only `proxy = off` lands in the fourth row, and only where you wrote it.**
|
||||
A credential the selected backend cannot serve - a Claude Code signed in with
|
||||
a console API token, whose file holds no `claudeAiOauth` block, against
|
||||
`backends/anthropic` - is a refusal, naming the backend, the file, the shape
|
||||
it expected and the two ways forward. A kill switch that can engage itself is
|
||||
not one, which is why this row is a kill switch rather than an option.
|
||||
|
||||
The refusal is scoped to a credential that *exists* and cannot be served. An
|
||||
absent one mounts nothing and downgrades nothing - that is a host where the
|
||||
harness has not been signed in yet, and it still launches.
|
||||
|
||||
**The proxy is switched per backend, not globally.** `[backend.<name>] proxy`
|
||||
falls back to `[credentials] proxy` when unset. One backend whose credential
|
||||
@@ -739,6 +784,14 @@ prerequisite. Sign in on the host first; see the README's
|
||||
Refreshing an expired credential is the same: on the host, not from inside a
|
||||
session, where the credential deliberately never goes.
|
||||
|
||||
### The refreshing OAuth path, in detail
|
||||
|
||||
The `backends/anthropic` arrangement, at the level of what the proxy actually
|
||||
does. The machinery is not Anthropic's - the credential's kind, which header
|
||||
carries it, what must not travel beside it are all arguments a backend
|
||||
supplies - but it is worth reading through one backend rather than in the
|
||||
abstract, and this is the one with the most miles on it.
|
||||
|
||||
- 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
|
||||
@@ -795,7 +848,7 @@ session, where the credential deliberately never goes.
|
||||
after an upgrade, as `UNABLE_TO_VERIFY_LEAF_SIGNATURE`,
|
||||
`ERR_TLS_CERT_ALTNAME_INVALID`, or a pin-specific error. What
|
||||
distinguishes pinning from a merely broken certificate is that
|
||||
`curl --cacert /etc/dejima-anthropic-tls/cert.pem https://api.anthropic.com/v1/messages`
|
||||
`curl --cacert /etc/dejima-credential-tls/cert.pem https://api.anthropic.com/v1/messages`
|
||||
still succeeds from inside the container while Claude Code does not: the
|
||||
certificate is fine, the client is refusing it. **This is not a reason to
|
||||
set `[credentials] proxy = off`.** That kill switch mounts the real,
|
||||
@@ -824,7 +877,13 @@ session, where the credential deliberately never goes.
|
||||
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`
|
||||
## What the container sees of Claude Code's `~/.claude`
|
||||
|
||||
One harness's inventory, because there is no shared one to give: what a
|
||||
harness keeps where, and which of it is safe to mount, is the harness's own
|
||||
answer and each plugin gives it. Cline's is the table in
|
||||
[configuration.md](configuration.md#what-comes-in-from-your-host); the
|
||||
reasoning below is what both are built on.
|
||||
|
||||
- `~/.claude` is **not** shared with the container as a whole. Its contents
|
||||
are enumerated in `docker-compose.yml`, in three classes, and anything not
|
||||
@@ -847,10 +906,10 @@ session, where the credential deliberately never goes.
|
||||
approval step, skills can ship executable scripts, and
|
||||
commands/agents/output-styles are prompt text loaded into a later host
|
||||
session, so without this a compromised session could plant a backdoored
|
||||
hook - including by unlinking one of `install.sh`'s hook symlinks and
|
||||
dropping a plain file in its place, since the dynamic per-target mounts
|
||||
only protect symlink targets that resolve *outside* `~/.claude`, never
|
||||
this directory itself - tamper with plugin code, or drop a malicious
|
||||
hook - including by unlinking one of your own hook symlinks and dropping a
|
||||
plain file in its place, since the dynamic per-target mounts only protect
|
||||
symlink targets that resolve *outside* `~/.claude`, never this directory
|
||||
itself - tamper with plugin code, or drop a malicious
|
||||
skill/command/agent/output-style file. Any of those would then run or
|
||||
load **unsandboxed on the host** the next time Claude Code starts
|
||||
there, turning a single compromised sandbox session into persistent
|
||||
@@ -958,15 +1017,52 @@ session, where the credential deliberately never goes.
|
||||
SSH-agent/token story documented above. Keep credentials out of your
|
||||
global gitconfig, or expect them to be reachable from inside the
|
||||
sandbox.
|
||||
- **The egress filter is not in the container the session runs in.** A launch
|
||||
starts two: a filter container that owns the network namespace and installs
|
||||
the packet filter, and the sandbox, which joins that namespace with
|
||||
`network_mode: container:` rather than getting one of its own. Capabilities
|
||||
are per process rather than per namespace, so the sandbox cannot alter the
|
||||
filter bounding it even at uid 0 - measured, not reasoned: `iptables` and
|
||||
`ipset` both return `Operation not permitted` to a joiner that lacks
|
||||
`NET_ADMIN`.
|
||||
|
||||
Two things follow. The namespace has its rules before anything is created in
|
||||
it, so there is no window in which a session holds an interface and no
|
||||
filter; `bin/dejima` waits for the filter container to say it is up and
|
||||
refuses the launch otherwise. And killing the filter container leaves the
|
||||
sandbox holding loopback alone - no network at all, rather than an unfiltered
|
||||
one.
|
||||
|
||||
Nothing a session runs ever executes in the filter container. It holds the
|
||||
privilege; the one beside it holds the session. Each launch gets a network of
|
||||
its own, so concurrent sessions are separated at the link layer as well.
|
||||
|
||||
A launch removes both on the way out, and reconciles at the start against
|
||||
whatever a `SIGKILL` or a restarted host left behind - a filter container
|
||||
with no session in its namespace, older than the window in which a pair is
|
||||
still being built. What that reclaims is address space rather than safety: a
|
||||
leaked filter container filters a namespace nothing is in, and Docker's
|
||||
address pool is finite enough that exhausting it fails a later launch with an
|
||||
error naming neither cause nor remedy.
|
||||
- The sandbox's own user (matching your host user; see "Config parity" in
|
||||
the README) has no `sudo`/setuid path back to root once the container is up:
|
||||
`entrypoint.sh` runs as root, sets up the firewall directly, then drops to
|
||||
that user via `gosu` before execing the configured command (`[core] command`,
|
||||
or the selected harness's own if that is unset). A compromised session can't
|
||||
`entrypoint.sh` runs as root, prepares the session's own state, then drops to
|
||||
that user before execing the configured command (`[core] command`, or the
|
||||
selected harness's own if that is unset). A compromised session can't
|
||||
re-run `init-firewall.sh` with a wider allowlist to open its own
|
||||
exfiltration path. Verified: capabilities (`NET_ADMIN`/`NET_RAW`) are
|
||||
cleared from the effective/permitted sets on the `gosu` drop, and that
|
||||
user gets a permission error running `iptables` directly.
|
||||
exfiltration path: that script runs in the container beside it, so there is
|
||||
no copy of the allowlist within reach to re-run against. Verified: that user
|
||||
gets a permission error running `iptables` directly.
|
||||
- **The session's capability bounding set is empty**, not merely its effective
|
||||
one. The distinction is the whole of it: an empty effective set is what
|
||||
changing uid gives you anyway, while the bounding set is what a process that
|
||||
reached uid 0 in that tree would recover its capabilities from. So the two
|
||||
claims above - no path back to root, and no capability if there were one -
|
||||
hold separately rather than one resting on the other. The drop and the uid
|
||||
change happen in one `capsh` process because they have to: the bounding set
|
||||
can only be reduced while `CAP_SETPCAP` is still held, and changing uid first
|
||||
gives it away. The same applies to `log-shipper.sh`, the one root process
|
||||
that outlives the entrypoint.
|
||||
- A forge credential reaches the container as `-e NAME`, never
|
||||
`-e NAME=value`. The value comes from the launcher's own environment, so it
|
||||
sits in `/proc/<pid>/environ` (mode 0400, owner only) rather than in
|
||||
@@ -974,17 +1070,45 @@ session, where the credential deliberately never goes.
|
||||
anyone. `docker inspect` still shows the resolved value to whoever can
|
||||
reach the daemon - that is daemon access rather than any process at all,
|
||||
and it is not something the launcher can change.
|
||||
- The container runs with `no-new-privileges` and an explicit capability
|
||||
floor - `cap_drop: ALL` plus the ones it actually uses: what the
|
||||
entrypoint's root phase needs, and `KILL`. `KILL` is there for
|
||||
`dejima-allow --container`, not for the entrypoint - adding a domain to a
|
||||
running session signals dnsmasq, and dnsmasq runs as its own user. The list
|
||||
itself is in `docker/docker-compose.yml`, which is where it stays correct. The first means a setuid binary or a file capability cannot raise
|
||||
- **Each container runs with `no-new-privileges` and a capability floor of its
|
||||
own**, `cap_drop: ALL` plus what that container actually uses. The two lists
|
||||
are the arrangement stated as configuration:
|
||||
|
||||
The session container declares **no network capability at all** - not
|
||||
`NET_ADMIN`, not `NET_RAW`. It is inside a namespace the filter container
|
||||
owns and has already installed the rules in, and capabilities are per
|
||||
process, so uid 0 in there is refused by the kernel rather than by policy.
|
||||
What it keeps is what its root phase does before the session starts: write
|
||||
and chown files, and drop privilege once. `SETPCAP` is there to spend rather
|
||||
than to use - it is what lets the entrypoint take the rest of the list away
|
||||
again before execing the session, and since Linux 4.3 it grants nothing a
|
||||
process does not already hold.
|
||||
|
||||
The filter container holds the network capabilities. The session's list is in
|
||||
`docker/docker-compose.yml` and the filter's is in `bin/dejima`, each beside
|
||||
the container it describes.
|
||||
|
||||
`KILL` is on both lists, for unrelated reasons, and it is the one worth
|
||||
reading twice. The filter container needs it because adding a domain to a
|
||||
running session signals dnsmasq, which runs as its own user. The session
|
||||
container needs it because `init: true` makes PID 1 an init that forwards
|
||||
signals to the session - and the entrypoint changes uid in place before
|
||||
exec'ing it, so that is uid 0 signalling a process whose real and saved uids
|
||||
are the sandbox user's. Without the capability every forwarded signal is
|
||||
`EPERM`, tini treats a failed forward as fatal, and an ordinary terminal
|
||||
resize ends the session.
|
||||
|
||||
`no-new-privileges` means a setuid binary or a file capability cannot raise
|
||||
privileges on exec, which is the shape any escalation from the session user
|
||||
would have to take; the second means the capabilities nothing here uses were
|
||||
never in the bounding set to be regained. Both are asserted against a running
|
||||
container rather than against the compose file, and the capability list the
|
||||
test uses is read out of that file so the two cannot drift.
|
||||
would have to take. The floor means the capabilities nothing uses were never
|
||||
in the bounding set to be regained. Both are asserted against running
|
||||
containers rather than against the files, and both lists the tests use are
|
||||
read out of those files so neither can drift.
|
||||
|
||||
What a floor describes is a container's **root phase**. A launch runs with
|
||||
`init: true`, so PID 1 is the init that reaps for the session and it holds
|
||||
that container's floor for as long as the container lives; the session is its
|
||||
child and holds nothing.
|
||||
- The approval store (`${DEJIMA_CONFIG_DIR:-$HOME/.config/dejima}`,
|
||||
see
|
||||
[Global and per-project domains and dependencies](../README.md#global-and-per-project-domains-and-dependencies)
|
||||
@@ -1095,11 +1219,13 @@ default:
|
||||
- DNS resolution via a local proxy (see "DNS is allowlisted too")
|
||||
- the Docker host itself (not its whole subnet)
|
||||
- GitHub's published IP ranges, port 443 only (see "GitHub push" below)
|
||||
- `registry.npmjs.org` and `platform.claude.com` (the harness and its
|
||||
updates), `release-assets.githubusercontent.com` (GitHub release assets, a
|
||||
separate CDN from github.com's own IPs, and where a good deal of tooling
|
||||
fetches its binaries); `api.anthropic.com` when the credential proxy is off
|
||||
(kill switch or API-key auth)
|
||||
- `registry.npmjs.org`, plus whatever the selected harness declares as its own
|
||||
(`platform.claude.com` for Claude Code; Cline declares none, so a session
|
||||
falling back to Cline's hosted endpoints fails to connect, which is the
|
||||
right outcome); `release-assets.githubusercontent.com` (GitHub release
|
||||
assets, a separate CDN from github.com's own IPs, and where a good deal of
|
||||
tooling fetches its binaries); and the selected backend's API endpoint when
|
||||
the credential proxy is off for it
|
||||
|
||||
That is the whole built-in list, and it is meant to stay that way: it covers
|
||||
what the sandbox itself cannot run without, not what a project might want.
|
||||
@@ -1213,9 +1339,9 @@ 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 session briefing 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
|
||||
|
||||
|
||||
@@ -263,6 +263,80 @@ repository's behalf.
|
||||
**What this repository can do about that is say so**, which is the whole of
|
||||
this section. Nothing in dejima can bound its own host.
|
||||
|
||||
## Why a container, and not a microVM
|
||||
|
||||
The most frequent question about this project.
|
||||
|
||||
The short answer is that a microVM is the stronger boundary and nothing here
|
||||
disputes it. Container escape is the least likely of the six threats above and
|
||||
the only one that needs an exploit at all, and the layer it lands on is already
|
||||
bounded by a control enforced from outside the guest. The effort went there
|
||||
rather than into a harder container.
|
||||
|
||||
**There is already a VM here**, which is what the usual framing misses. It is
|
||||
put as container versus microVM, as though the alternative were a container
|
||||
with nothing underneath. On the deployment this document describes, dejima runs
|
||||
inside a devbox VM whose rule set is enforced from the hypervisor. So the choice
|
||||
is not one boundary against two. It is an outer VM enforced from outside the
|
||||
guest, against that plus an inner one managed by software running on the box it
|
||||
is protecting - inside the blast radius of the row it is meant to bound. A
|
||||
boundary enforced from outside is worth more than one enforced by the thing
|
||||
inside it, and that is most of the answer.
|
||||
|
||||
**The field's ordering is not in dispute.** gVisor's syscall interposition and
|
||||
a per-workload VM kernel are both stronger than a namespaced container.
|
||||
Firecracker is what AWS runs Lambda on, for the reason that applies here too:
|
||||
untrusted code, multi-tenant, on shared hardware. The disagreement is about
|
||||
where marginal effort goes, not about which primitive is stronger.
|
||||
|
||||
### What a stronger runtime would and would not change
|
||||
|
||||
It would not touch the five threats ranked above escape. Two of them would get
|
||||
*harder*, because both are enforced by how the container is configured rather
|
||||
than by what isolates it:
|
||||
|
||||
- **One project's session cannot reach another's**, because each path crossing
|
||||
the boundary is named individually rather than mounting a tree
|
||||
([#104](https://gitbox.branderson.io/dejima/dejima/issues/104)). Under a VM
|
||||
each becomes a filesystem daemon.
|
||||
- **The container sees an agent socket, never your key.** That is a live host
|
||||
socket, and a VM has no equivalent to hand it one.
|
||||
|
||||
One would get easier: egress rules on a tap device sit where the guest holds no
|
||||
capability to touch them. That is available without changing the runtime, which
|
||||
is what [#305](https://gitbox.branderson.io/dejima/dejima/issues/305) does.
|
||||
|
||||
**And "swap the runtime" understates the work**, because several controls are
|
||||
implemented *in* Docker rather than merely hosted by it. The dependency
|
||||
approval is a derived image build; the tooling reaches live sessions through
|
||||
it; the capability floor is an argument about Docker's model. Porting them is a
|
||||
rewrite of the parts that *are* the security argument, in a project that says
|
||||
at the top that none of it has been reviewed by anyone else.
|
||||
|
||||
### What would change the answer
|
||||
|
||||
The decision is a judgement about effort, not a door held shut, and these are
|
||||
the things that would reopen it:
|
||||
|
||||
- **Kata Containers is the cheap path if this reasoning is wrong.** It is an
|
||||
OCI runtime, so the compose file, the mounts and the tooling survive, and it
|
||||
can use Firecracker underneath. A per-launch switch rather than a fork.
|
||||
- **Pointing dejima at projects chosen by someone else.** That contradicts an
|
||||
assumption stated at the top of this document, and reorders the ranked list.
|
||||
- **A container escape in the wild that clears this configuration's floor**:
|
||||
non-root session user, `no-new-privileges`, `cap_drop: ALL` and a short list
|
||||
added back. The class recurs, and whether a live one clears that floor is
|
||||
asked on a schedule in
|
||||
[research-review.md](research-review.md#the-standing-questions) rather than
|
||||
assumed either way.
|
||||
|
||||
What is done instead is depth below the container, which is
|
||||
[the stack](#the-stack-and-what-each-piece-buys): two of the three layers under
|
||||
dejima are enforced where the guest cannot reach them, and the third bounds a
|
||||
compromise in time. A sandbox that reaches for a microVM and stops there has
|
||||
nothing underneath it, so an escape from the VM ends its argument. Here it is a
|
||||
row with controls of its own.
|
||||
|
||||
## What this repository does not claim
|
||||
|
||||
- **That the egress allowlist bounds what reaches the model.** It bounds what
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
They can continue this exact session outside the container by running on
|
||||
the host:
|
||||
|
||||
{{resume_command}}
|
||||
|
||||
(session history is shared via the bind-mounted ~/.claude, so it will pick
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
export CLINE_DATA_DIR={{harness_state_dir}}
|
||||
cline history # find this session's id
|
||||
cline --id <session-id>
|
||||
This session cannot be continued outside dejima, so do not offer that or ask
|
||||
the operator to try it. It CAN be continued by launching dejima again on this
|
||||
project: this project's Cline state - sessions, transcripts, tasks - is kept
|
||||
on the host in a directory of its own and survives the container being
|
||||
replaced, so the next launch picks up where this one left off.
|
||||
|
||||
(the session id is deliberately absent from this briefing: Cline mints one when
|
||||
a run starts, which is after this text was written, so `cline history` is where
|
||||
to read it. CLINE_DATA_DIR has to be set because this project's Cline state is
|
||||
kept in that directory on the host rather than in ~/.cline/data - Cline indexes
|
||||
every session it has ever run in a single database, so dejima gives each
|
||||
project its own and one project's history is never another's context.)
|
||||
(Cline indexes every session it has ever run in a single database, so dejima
|
||||
gives each project its own rather than sharing one. That directory is
|
||||
dejima's, not Cline's own, and pointing a host-side Cline at it is not a
|
||||
supported path - which is why resuming outside is not on offer.)
|
||||
|
||||
@@ -14,7 +14,9 @@ install = npm:cline
|
||||
# Documents/Cline/Rules is where the briefing is staged. That path is not a
|
||||
# guess and not what Cline's documentation says: ~/Documents/Cline/Rules
|
||||
# reaches the model on Linux and ~/Cline/Rules does not.
|
||||
image_dirs = .cline .cline/data Documents Documents/Cline Documents/Cline/Rules
|
||||
# .agents and .cline/hooks are the operator's own config, mounted from the
|
||||
# host by the plugin; they need a mount point in the image to land on.
|
||||
image_dirs = .cline .cline/data .cline/hooks .agents .agents/skills .agents/hooks Documents Documents/Cline Documents/Cline/Rules
|
||||
|
||||
# DO_NOT_TRACK and DISABLE_TELEMETRY are both honoured; CLINE_NO_AUTO_UPDATE
|
||||
# stops a session upgrading its own harness mid-run, which would replace a
|
||||
@@ -89,7 +91,7 @@ no_confirm_args = --auto-approve true
|
||||
# meant a model the operator serves - so there is no single value to record,
|
||||
# and recording the wrong one would write a proxied endpoint into the config
|
||||
# of a session that never used one. Making the two coherent is its own change.
|
||||
keys = backend:?provider:backend state_dir:path=~/.local/share/dejima/cline model:?identifier context_window:?int:512:100000000
|
||||
keys = backend:?provider:backend state_dir:path=~/.local/share/dejima/cline model:?modelid context_window:?int:512:100000000
|
||||
|
||||
# Deliberately empty: resuming needs the state directory's path as well as the
|
||||
# session id, briefing placeholders do not nest, and `template` forbids the
|
||||
|
||||
@@ -35,11 +35,16 @@ session_id="${1:-}"
|
||||
resume_id="${session_id:-<your session id>}"
|
||||
project_dir="${2:-$PWD}"
|
||||
|
||||
# /etc/hostname in a docker container is the container's own ID, which
|
||||
# docker exec accepts directly - substituting it into the briefing below
|
||||
# means the agent states its real container instead of guessing (a wrong
|
||||
# guess that matches *another* running sandbox makes dejima-allow
|
||||
# "succeed" against the wrong container).
|
||||
# The id of this launch, read from the only place a session can see one.
|
||||
#
|
||||
# Not this container's own id, and worth being exact about: Docker names a
|
||||
# container that joined another's network namespace after the container it
|
||||
# joined, so what a session reads here is its FILTER container's id. That is
|
||||
# still the right thing to put in the briefing - `dejima-allow` takes either
|
||||
# half of the pair and derives the other - and it is what the agent has, so
|
||||
# substituting it means stating a real id rather than guessing one. A wrong
|
||||
# guess that matched another running sandbox would make dejima-allow "succeed"
|
||||
# against somebody else's session.
|
||||
container_id=$(cat /etc/hostname 2>/dev/null)
|
||||
container_id="${container_id:-<container-id>}"
|
||||
|
||||
|
||||
+92
-19
@@ -25,28 +25,92 @@ input=$(cat)
|
||||
command=$(echo "$input" | jq -r '.tool_input.command // empty')
|
||||
cwd=$(echo "$input" | jq -r '.cwd // empty')
|
||||
|
||||
# Only care about `git push` invocations.
|
||||
if ! [[ "$command" =~ (^|[[:space:]&\|;])git([[:space:]]+-C[[:space:]]+[^[:space:]]+)?[[:space:]]+push([[:space:]]|$) ]]; then
|
||||
exit 0
|
||||
fi
|
||||
# Only care about `git push` invocations - found by walking tokens rather than
|
||||
# by matching a shape.
|
||||
#
|
||||
# The pattern this replaced recognised `git push` and `git -C <dir> push` and
|
||||
# nothing else, so every other spelling of git's own options - `git -c k=v
|
||||
# push`, `git --git-dir=... push`, `git --no-pager push` - was not a push as
|
||||
# far as this hook was concerned, and it exited 0 before any check ran. The
|
||||
# PATH shadow in docker/git-guard.sh walks argv and caught them regardless,
|
||||
# which is why this was a quiet gap in the earlier warning rather than an open
|
||||
# one.
|
||||
#
|
||||
# Split on the shell separators first, because a push can be the second half
|
||||
# of a compound command, and only the segment that STARTS with git is a git
|
||||
# invocation - "echo push" is not.
|
||||
push_seen=0
|
||||
segment=""
|
||||
IFS=$'\n' read -rd '' -a _segments < <(
|
||||
printf '%s' "$command" | sed 's/&&/\n/g; s/||/\n/g; s/;/\n/g; s/|/\n/g'
|
||||
printf '\0')
|
||||
for seg in ${_segments[@]+"${_segments[@]}"}; do
|
||||
read -ra _t <<< "$seg"
|
||||
[ "${#_t[@]}" -gt 0 ] || continue
|
||||
[ "${_t[0]}" = git ] || continue
|
||||
_i=1
|
||||
while [ "$_i" -lt "${#_t[@]}" ]; do
|
||||
case "${_t[$_i]}" in
|
||||
-c|-C|--exec-path|--git-dir|--work-tree|--namespace|--super-prefix)
|
||||
_i=$((_i + 1)) ;;
|
||||
-*) ;;
|
||||
push) push_seen=1; segment="$seg"; break ;;
|
||||
*) break ;;
|
||||
esac
|
||||
_i=$((_i + 1))
|
||||
done
|
||||
[ "$push_seen" -eq 1 ] && break
|
||||
done
|
||||
[ "$push_seen" -eq 1 ] || exit 0
|
||||
# Everything below reads the git invocation, not the whole command line.
|
||||
command="$segment"
|
||||
|
||||
repo_dir="$cwd"
|
||||
if [[ "$command" =~ git[[:space:]]+-C[[:space:]]+([^[:space:]]+) ]]; then
|
||||
repo_dir="${BASH_REMATCH[1]}"
|
||||
fi
|
||||
|
||||
# Positionally parse everything after "push": first non-flag token is the
|
||||
# remote, second (if any) is the refspec. Word-splitting rather than a full
|
||||
# shell parse, so quoting edge cases aren't handled - same pragmatic
|
||||
# tradeoff as the sibling deny-dangerous-bash.sh denylist.
|
||||
after_push="${command#*push}"
|
||||
read -ra tokens <<< "$after_push"
|
||||
# One pass over the whole command, the way the shadow walks argv: skip git's
|
||||
# own options until the first token that IS the subcommand, then take the
|
||||
# first non-flag token as the remote and the second as the refspec.
|
||||
#
|
||||
# Splitting on the first occurrence of the substring "push" is what this
|
||||
# replaced, and it split inside `remote.origin.pushurl=...`, a branch named
|
||||
# push-fix, and anything else carrying the word. What came out was a nonsense
|
||||
# remote, which then took the unresolvable path below.
|
||||
#
|
||||
# Word-splitting rather than a full shell parse, so quoting edge cases are not
|
||||
# handled - the same pragmatic tradeoff as the sibling
|
||||
# deny-dangerous-bash.sh denylist, and the reason the unresolvable case
|
||||
# refuses rather than guesses.
|
||||
#
|
||||
# `-c key=value` is kept rather than dropped: git resolves where a push goes
|
||||
# from the config the push runs under, so asking without these answers for a
|
||||
# different configuration. `git -c remote.origin.pushurl=... push origin
|
||||
# branch` is that case.
|
||||
config_overrides=()
|
||||
positional=()
|
||||
for tok in "${tokens[@]}"; do
|
||||
case "$tok" in
|
||||
-*) continue ;;
|
||||
*) positional+=("$tok") ;;
|
||||
esac
|
||||
read -ra tokens <<< "$command"
|
||||
t=0
|
||||
seen_push=0
|
||||
while [ "$t" -lt "${#tokens[@]}" ]; do
|
||||
tok="${tokens[$t]}"
|
||||
if [ "$seen_push" -eq 0 ]; then
|
||||
case "$tok" in
|
||||
-c) t=$((t + 1))
|
||||
[ "$t" -lt "${#tokens[@]}" ] && config_overrides+=(-c "${tokens[$t]}") ;;
|
||||
-C|--exec-path|--git-dir|--work-tree|--namespace|--super-prefix)
|
||||
t=$((t + 1)) ;;
|
||||
push) seen_push=1 ;;
|
||||
*) ;;
|
||||
esac
|
||||
else
|
||||
case "$tok" in
|
||||
-*) ;;
|
||||
*) positional+=("$tok") ;;
|
||||
esac
|
||||
fi
|
||||
t=$((t + 1))
|
||||
done
|
||||
|
||||
remote="origin"
|
||||
@@ -58,11 +122,20 @@ if [ "${#positional[@]}" -ge 2 ]; then
|
||||
refspec="${positional[1]}"
|
||||
fi
|
||||
|
||||
remote_url=$(git -C "$repo_dir" remote get-url "$remote" 2>/dev/null)
|
||||
# --push: the URL the push will actually use. remote.<name>.pushurl wins over
|
||||
# remote.<name>.url when it is set, and reading the latter checks a host the
|
||||
# push is not going to. See docker/git-guard.sh, which asks the same question.
|
||||
remote_url=$(git ${config_overrides[@]+"${config_overrides[@]}"} \
|
||||
-C "$repo_dir" remote get-url --push "$remote" 2>/dev/null)
|
||||
if [ -z "$remote_url" ]; then
|
||||
# Can't resolve a remote from here - let git produce the real error
|
||||
# rather than block or guess.
|
||||
exit 0
|
||||
# Refused, not passed through. A remote that does not resolve is usually a
|
||||
# typo git would reject in a moment anyway - but it is also what a
|
||||
# disagreement between this parser and git's own looks like, and the
|
||||
# failure of that disagreement has to be a refusal rather than a push
|
||||
# nobody checked. docker/git-guard.sh takes the same direction; the two
|
||||
# answering differently was the bug.
|
||||
echo "Blocked by git-push-guard.sh: cannot resolve the remote '$remote' to a URL, so there is nothing to check this push against. Name a remote that exists." >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
host=$(echo "$remote_url" | sed -E 's#^[a-zA-Z]+://([^/@]+@)?([^/:]+).*#\2#; s#^[^@/]+@([^:/]+)[:/].*#\1#')
|
||||
|
||||
@@ -41,11 +41,11 @@ if [ "${DEJIMA_BRIEFING_VIA_ARGS:-0}" = "1" ]; then
|
||||
fi
|
||||
|
||||
# The image's copy first, because that is the one that exists in a real
|
||||
# session: this file reaches the container through the ~/.claude/hooks mount,
|
||||
# and install.sh links it there one file at a time, so its sibling in the repo
|
||||
# is not alongside it. The sibling is the fallback so the hook can be run
|
||||
# session: this file is baked in beside it and registered by absolute path
|
||||
# into the sandbox's --settings overlay, so nothing about it depends on the
|
||||
# operator's ~/.claude. The sibling is the fallback so the hook can be run
|
||||
# against a checkout - which is what the test suite does - and readlink -f is
|
||||
# what turns the linked path back into the repo one.
|
||||
# what turns a linked path back into the repo one.
|
||||
#
|
||||
# Missing or unreadable degrades to no briefing rather than failing the hook:
|
||||
# a SessionStart hook that exits non-zero drops the whole session's context,
|
||||
|
||||
+40
-21
@@ -1,6 +1,9 @@
|
||||
#!/bin/bash
|
||||
# Idempotent installer for dejima. Registers the selected harness's guard
|
||||
# hooks wherever that harness reads them from, and puts
|
||||
# Idempotent installer for dejima. Writes NOTHING to the harness's own
|
||||
# configuration: every hook dejima uses is baked into the image and registered
|
||||
# per launch into a settings layer of dejima's own, so installing this does not
|
||||
# change how the harness behaves outside a sandbox. What it still does is take
|
||||
# back what older versions put there. It puts
|
||||
# bin/dejima, bin/dejima-allow, bin/dejima-dep, bin/dejima-project,
|
||||
# bin/dejima-audit, bin/dejima-share and the opt-in remote-access scripts on
|
||||
# PATH. Safe to re-run - every step is a no-op if already done, and every
|
||||
@@ -127,9 +130,14 @@ else
|
||||
_starter_extra_keys="$(_config_extra_keys_for forge gitea)"
|
||||
fi
|
||||
# Seeded from any legacy variables still exported, so the one-time
|
||||
# migration is a review rather than a retyping. Everything else is
|
||||
# written commented-out at its schema default, so the file doubles as
|
||||
# the reference for what can be set.
|
||||
# migration is a review rather than a retyping.
|
||||
#
|
||||
# Only what is seeded. This file used to carry every key commented out at
|
||||
# its schema default, so it doubled as the reference - which meant a second
|
||||
# copy of the defaults, in comments nothing regenerates, drifting against
|
||||
# the schema as keys were added. defaults.conf beside it is that reference
|
||||
# now, complete and rewritten by every ./install.sh, and this file holds
|
||||
# only what the operator has actually decided.
|
||||
{
|
||||
printf '# dejima configuration. See README.md.\n'
|
||||
printf '# Every key below is shown at its default; uncomment to change one.\n'
|
||||
@@ -151,11 +159,6 @@ else
|
||||
[ -n "$_key" ] || continue
|
||||
_leaf="${_key##*.}"
|
||||
_sec="${_key%.*}"
|
||||
_default="$(config_default "$_key")"
|
||||
if [ "$_sec" != "$_section" ]; then
|
||||
printf '\n[%s]\n' "$_sec"
|
||||
_section="$_sec"
|
||||
fi
|
||||
_seed=""
|
||||
case "$_key" in
|
||||
forge.gitea.ssh_host) _seed="${GITEA_SSH_HOST:-}" ;;
|
||||
@@ -183,24 +186,40 @@ else
|
||||
[ -n "${XDG_DATA_HOME:-}" ] && _seed="$XDG_DATA_HOME/dejima/baselines"
|
||||
;;
|
||||
esac
|
||||
if [ -n "$_seed" ]; then
|
||||
printf '%s = %s\n' "$_leaf" "$_seed"
|
||||
else
|
||||
printf '# %s = %s\n' "$_leaf" "$_default"
|
||||
[ -n "$_seed" ] || continue
|
||||
# The header is written with the first key that lands under it, so
|
||||
# a section nothing seeded leaves no empty stanza behind.
|
||||
if [ "$_sec" != "$_section" ]; then
|
||||
printf '\n[%s]\n' "$_sec"
|
||||
_section="$_sec"
|
||||
fi
|
||||
printf '%s = %s\n' "$_leaf" "$_seed"
|
||||
done < <(_config_keys; [ -z "$_starter_extra_keys" ] || printf '%s\n' "$_starter_extra_keys")
|
||||
} > "$CONFIG_FILE"
|
||||
chmod 600 "$CONFIG_FILE"
|
||||
fi
|
||||
|
||||
# The pinned defaults, rebuilt: keys this release added appended, keys it
|
||||
# retired dropped, everything already decided left where it is. This is the
|
||||
# only place that reports a shipped default having moved, because this is when
|
||||
# it moves - you pulled and re-ran this. A launch checking would be a standing
|
||||
# condition in front of every one.
|
||||
# The blank line is the rebuild's, not this script's: on a run with nothing to
|
||||
# add, drop or report it says nothing, and an unconditional `echo` here left a
|
||||
# second blank line between two paragraphs that were already separated.
|
||||
config_defaults_rebuild
|
||||
|
||||
# Guard registration, after the starter config above rather than before it:
|
||||
# where dejima's guards go, and in what shape, is the selected harness's
|
||||
# business, and on a fresh install that selection is the one just seeded.
|
||||
# Registering before reading it would mean writing one harness's config for
|
||||
# an operator who has chosen another.
|
||||
#
|
||||
# Delegated to the harness's plugin, which is also what gates each guard on
|
||||
# the harness having declared it (see `guards` in its manifest).
|
||||
# Nothing to register: the sandbox's hooks are baked into the image and
|
||||
# registered per launch into its own settings overlay. What is delegated to
|
||||
# the plugin is the host-side cleanup an upgrade needs - taking back what
|
||||
# older versions wrote into the harness's config - which still has to know
|
||||
# which harness is selected.
|
||||
echo
|
||||
source "$SCRIPT_DIR/bin/dejima-harness-lib.sh"
|
||||
# A config that does not parse leaves config_get empty for EVERY key, so
|
||||
@@ -209,20 +228,20 @@ source "$SCRIPT_DIR/bin/dejima-harness-lib.sh"
|
||||
# fault, on stderr, which is the answer to a question this cannot answer.
|
||||
_harness=""
|
||||
if config_exists && ! config_validate; then
|
||||
echo "$CONFIG_FILE could not be read, so no guard hooks were registered."
|
||||
echo "$CONFIG_FILE could not be read, so no harness host-side setup was checked."
|
||||
echo "Fix what is reported above and re-run ./install.sh."
|
||||
elif ! _harness="$(config_get core harness 2>/dev/null)" || [ -z "$_harness" ]; then
|
||||
_harness=""
|
||||
echo "No [core] harness is set in $CONFIG_FILE, so no guard hooks were"
|
||||
echo "registered. Set one and re-run ./install.sh."
|
||||
echo "No [core] harness is set in $CONFIG_FILE, so no harness host-side"
|
||||
echo "setup was checked. Set one and re-run ./install.sh."
|
||||
else
|
||||
DEJIMA_HARNESS_NAME="$_harness"
|
||||
harness_plugin_load "$_harness"
|
||||
if declare -F harness_register_guards >/dev/null 2>&1; then
|
||||
echo "Registering $_harness's guard hooks"
|
||||
echo "Checking $_harness's host-side setup"
|
||||
harness_register_guards "$SCRIPT_DIR" "$BACKUP_DIR"
|
||||
else
|
||||
echo "Harness '$_harness' installs no guard hooks."
|
||||
echo "Harness '$_harness' needs nothing on the host."
|
||||
echo
|
||||
fi
|
||||
fi
|
||||
|
||||
+716
-85
@@ -20,6 +20,7 @@ setup() {
|
||||
unset GITEA_SSH_HOST GITEA_API GITEA_TOKEN_FILE GITEA_TOKEN GH_TOKEN_FILE \
|
||||
GH_TOKEN SANDBOX_CMD SANDBOX_SSH_KEY SANDBOX_SHARE_DIR SANDBOX_EXTRA_MOUNTS \
|
||||
SANDBOX_NVIM_CONFIG SANDBOX_CREDENTIAL_PROXY SANDBOX_ANTHROPIC_TRANSPORT \
|
||||
SANDBOX_CREDENTIAL_TRANSPORT \
|
||||
SANDBOX_LOG_CONVERSATION LOKI_URL ENABLE_FIREWALL SSH_KNOWN_HOSTS \
|
||||
ALLOW_GITHUB_PUSH DEJIMA_AUDIT_DIR DEJIMA_AUDIT_BASELINE_DIR \
|
||||
DEJIMA_AUDIT_KEEP DEJIMA_AUDIT_STRICT DEJIMA_AUDIT_STATUSLINE \
|
||||
@@ -218,6 +219,232 @@ write_config() {
|
||||
esac
|
||||
}
|
||||
|
||||
# --- the pinned defaults layer ------------------------------------------------
|
||||
#
|
||||
# defaults.conf holds what dejima's defaults were when this machine first saw
|
||||
# each key, so a release that changes one cannot move a session whose config
|
||||
# never changed. The operator's file overrides it; the schema answers only for
|
||||
# a key it has no line for.
|
||||
|
||||
write_defaults() { printf '%s\n' "$@" > "$CONFIG_DIR/defaults.conf"; }
|
||||
|
||||
@test "the operator's value beats the pinned one, which beats the schema" {
|
||||
write_config '[core]' 'firewall = off'
|
||||
write_defaults '[core]' 'firewall = on' 'workflow = branch-push' \
|
||||
'[audit]' 'keep = 3'
|
||||
# Theirs.
|
||||
run lib config_get core firewall
|
||||
[ "$output" = "off" ] || { echo "$output"; return 1; }
|
||||
# Pinned, over a schema that says local-only and 10.
|
||||
run lib config_get core workflow
|
||||
[ "$output" = "branch-push" ] || { echo "$output"; return 1; }
|
||||
run lib config_get audit keep
|
||||
[ "$output" = "3" ] || { echo "$output"; return 1; }
|
||||
# Schema, for a key nothing has pinned.
|
||||
run lib config_get audit diffs
|
||||
[ "$output" = "on" ] || { echo "$output"; return 1; }
|
||||
}
|
||||
|
||||
@test "config_origin names which of the three answered" {
|
||||
write_config '[core]' 'firewall = off'
|
||||
write_defaults '[core]' 'workflow = branch-push'
|
||||
run lib config_origin core firewall
|
||||
[ "$output" = "user" ] || { echo "firewall: $output"; return 1; }
|
||||
run lib config_origin core workflow
|
||||
[ "$output" = "default" ] || { echo "workflow: $output"; return 1; }
|
||||
run lib config_origin audit diffs
|
||||
[ "$output" = "schema" ] || { echo "diffs: $output"; return 1; }
|
||||
}
|
||||
|
||||
@test "a pinned key is not one the operator set" {
|
||||
# The trap the whole two-array split exists for. Ten callers of
|
||||
# config_is_set mean "did the operator write this" - the ssh_key
|
||||
# deprecation refusal, identity-over-forge precedence, and the ttyd knobs
|
||||
# where unset means the page decides. A defaults.conf naming every key
|
||||
# would answer yes to all of them.
|
||||
write_config '[core]' 'firewall = off'
|
||||
write_defaults '[core]' 'workflow = branch-push' '[remote-access]' \
|
||||
'ttyd_button_colors = on'
|
||||
run lib config_is_set core firewall
|
||||
[ "$status" -eq 0 ] || { echo "the operator's own key read as unset"; return 1; }
|
||||
run lib config_is_set core workflow
|
||||
[ "$status" -ne 0 ] || { echo "a pinned key read as one the operator set"; return 1; }
|
||||
run lib config_is_set remote-access ttyd_button_colors
|
||||
[ "$status" -ne 0 ] || { echo "a pinned ttyd knob read as one the operator set"; return 1; }
|
||||
}
|
||||
|
||||
@test "no pinned layer at all is not an error" {
|
||||
# A checkout that has never been launched. The schema answers for
|
||||
# everything until there is a file.
|
||||
write_config '[core]' 'firewall = on'
|
||||
[ ! -e "$CONFIG_DIR/defaults.conf" ]
|
||||
run lib config_get audit keep
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
[ "$output" = "10" ]
|
||||
}
|
||||
|
||||
@test "the pinned layer is skipped line by line where the operator's is refused" {
|
||||
# An unknown key here is one dejima RETIRED, on a machine whose
|
||||
# defaults.conf predates the release that dropped it. The operator did not
|
||||
# write it and is told not to edit the file, so refusing would leave them a
|
||||
# launch that will not start and nothing to do about it.
|
||||
write_config '[core]' 'firewall = on'
|
||||
write_defaults '[core]' 'workflow = branch-push' 'retired_key = whatever' \
|
||||
'share_dir = ' '[gone-axis]' 'something = 1' \
|
||||
'[audit]' 'keep = 3' 'this line is malformed'
|
||||
# It still launches, and the good lines still count.
|
||||
run lib config_get core workflow
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
[ "$output" = "branch-push" ]
|
||||
# Including one after the unknown section, or a stale section would take
|
||||
# the rest of the file with it.
|
||||
run lib config_get audit keep
|
||||
[ "$output" = "3" ] || { echo "keep: $output"; return 1; }
|
||||
# A value the schema refuses falls through rather than being honoured:
|
||||
# share_dir is not optional, so an empty one is not a value.
|
||||
run lib config_origin core share_dir
|
||||
[ "$output" = "schema" ] || { echo "share_dir origin: $output"; return 1; }
|
||||
# And the count is available for the launch to name once.
|
||||
run lib config_defaults_stale
|
||||
[ "$output" -ge 4 ] || { echo "counted $output stale lines"; return 1; }
|
||||
}
|
||||
|
||||
@test "the operator's own file is still refused line by line" {
|
||||
# The leniency above is for the file they cannot edit. Theirs is theirs.
|
||||
write_defaults '[core]' 'workflow = branch-push'
|
||||
printf '[core]\nharness = claude-code\nnonsense = 1\n' > "$CONFIG_DIR/config"
|
||||
run lib config_get core forge
|
||||
[ "$status" -ne 0 ] || { echo "an unknown key in the operator's file was accepted"; return 1; }
|
||||
[[ "$output" == *"unknown key"* ]] || { echo "$output"; return 1; }
|
||||
}
|
||||
|
||||
@test "a launch pins the defaults once, and says so only that time" {
|
||||
_rw_setup
|
||||
workspace="$(mktemp -d)"
|
||||
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only'
|
||||
[ ! -e "$CONFIG_DIR/defaults.conf" ]
|
||||
|
||||
_rw_launch "$workspace"
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
[ -f "$CONFIG_DIR/defaults.conf" ] || { echo "nothing was pinned"; return 1; }
|
||||
[[ "$output" == *"Pinned dejima's current defaults"* ]] \
|
||||
|| { echo "created the file without saying so: $output"; return 1; }
|
||||
# It is dejima's own, not the operator's: 0600 and a header saying so.
|
||||
[ "$(stat -c '%a' "$CONFIG_DIR/defaults.conf")" = "600" ]
|
||||
grep -q 'NOT yours to edit' "$CONFIG_DIR/defaults.conf"
|
||||
|
||||
# A second launch is silent about it, and does not rewrite it: a standing
|
||||
# line on every launch is one an operator learns to scroll past.
|
||||
local before; before="$(cat "$CONFIG_DIR/defaults.conf")"
|
||||
_rw_launch "$workspace"
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
[[ "$output" != *"Pinned dejima"* ]] \
|
||||
|| { echo "said it again: $output"; return 1; }
|
||||
[ "$before" = "$(cat "$CONFIG_DIR/defaults.conf")" ] \
|
||||
|| { echo "a launch rewrote the pinned file"; return 1; }
|
||||
rm -rf "$workspace"
|
||||
}
|
||||
|
||||
@test "what a launch pins is every defaulted key, and no key without one" {
|
||||
_rw_setup
|
||||
workspace="$(mktemp -d)"
|
||||
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only'
|
||||
_rw_launch "$workspace"
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
local f="$CONFIG_DIR/defaults.conf"
|
||||
|
||||
# A defaulted core key, a defaulted key of a harness that is NOT selected -
|
||||
# pinning only the selected one leaves the next harness an operator picks
|
||||
# handing them its current defaults - and one of a backend.
|
||||
grep -q '^keep = 10$' "$f" || { echo "no [audit] keep"; cat "$f"; return 1; }
|
||||
grep -q '^\[harness.cline\]$' "$f" || { echo "no unselected harness"; cat "$f"; return 1; }
|
||||
grep -q '^\[backend.anthropic\]$' "$f" || { echo "no backend"; cat "$f"; return 1; }
|
||||
|
||||
# And nothing that has no default, because there is nothing to pin: those
|
||||
# are exactly the keys docs/configuration.md marks required.
|
||||
grep -q '^harness = ' "$f" && { echo "pinned a key with no default"; return 1; }
|
||||
grep -q '^model = ' "$f" && { echo "pinned a key with no default"; return 1; }
|
||||
|
||||
# It reads back with nothing skipped, which a hand-kept renderer would
|
||||
# drift out of.
|
||||
run lib config_defaults_stale
|
||||
[ "$output" = "0" ] || { echo "the file it wrote has $output unreadable lines"; return 1; }
|
||||
rm -rf "$workspace"
|
||||
}
|
||||
|
||||
@test "a launch names a pinned key this dejima has retired, and carries on" {
|
||||
_rw_setup
|
||||
workspace="$(mktemp -d)"
|
||||
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only'
|
||||
printf '[core]\nworkflow = local-only\nretired_key = 1\n' > "$CONFIG_DIR/defaults.conf"
|
||||
_rw_launch "$workspace"
|
||||
[ "$status" -eq 0 ] || { echo "a retired pinned key stopped the launch: $output"; return 1; }
|
||||
[[ "$output" == *"no longer reads"* ]] || { echo "$output"; return 1; }
|
||||
[[ "$output" == *"./install.sh"* ]] \
|
||||
|| { echo "named the problem without the fix: $output"; return 1; }
|
||||
rm -rf "$workspace"
|
||||
}
|
||||
|
||||
@test "install.sh rebuilds the pinned file without moving what is in it" {
|
||||
stub_docker
|
||||
run_install
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
local f="$CONFIG_DIR/defaults.conf"
|
||||
[ -f "$f" ] || { echo "install.sh pinned nothing"; return 1; }
|
||||
|
||||
# A value already decided, a key this release would add, and a key it has
|
||||
# retired - the three things a rebuild has to get right at once.
|
||||
sed -i 's/^keep = 10$/keep = 25/' "$f"
|
||||
sed -i '/^diffs = on$/d' "$f"
|
||||
printf '\n[core]\nretired_thing = 1\n' >> "$f"
|
||||
|
||||
run_install
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
grep -q '^keep = 25$' "$f" || { echo "a rebuild moved a pinned value"; cat "$f"; return 1; }
|
||||
grep -q '^diffs = on$' "$f" || { echo "a rebuild did not add a new key"; return 1; }
|
||||
grep -q 'retired_thing' "$f" && { echo "a rebuild kept a retired key"; return 1; }
|
||||
|
||||
# Pruning is not the tidier half: an unknown key is one dejima cannot
|
||||
# read, so a file that only grew would collect them.
|
||||
run lib config_defaults_stale
|
||||
[ "$output" = "0" ] || { echo "$output unreadable lines survived the rebuild"; return 1; }
|
||||
}
|
||||
|
||||
@test "install.sh names a default that has moved, and does not take it" {
|
||||
stub_docker
|
||||
run_install
|
||||
sed -i 's/^keep = 10$/keep = 25/' "$CONFIG_DIR/defaults.conf"
|
||||
run_install
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
[[ "$output" == *"yours stays 25"* ]] || { echo "$output"; return 1; }
|
||||
[[ "$output" == *"dejima now ships 10"* ]] || { echo "$output"; return 1; }
|
||||
# Named, not adopted, and the file it points at for taking one is the
|
||||
# operator's - never the pinned file, which the next rebuild rewrites.
|
||||
grep -q '^keep = 25$' "$CONFIG_DIR/defaults.conf" \
|
||||
|| { echo "install.sh adopted the shipped default"; return 1; }
|
||||
[[ "$output" == *"$CONFIG_DIR/config"* ]] || { echo "$output"; return 1; }
|
||||
}
|
||||
|
||||
@test "the starter config carries only what was seeded, not every key commented" {
|
||||
# It used to write every key commented at its schema default, which was a
|
||||
# second copy of the defaults in comments nothing regenerates.
|
||||
# defaults.conf is that reference now.
|
||||
stub_docker
|
||||
run_install
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
local c="$CONFIG_DIR/config"
|
||||
grep -q '^harness = claude-code$' "$c" || { echo "the selection is not written"; cat "$c"; return 1; }
|
||||
grep -qE '^# [a-z_]+ = ' "$c" && { echo "still writing commented defaults:"; cat "$c"; return 1; }
|
||||
# And no stanza left behind by a section nothing seeded. Comments and
|
||||
# blanks dropped first: the header prose names sections in prose.
|
||||
grep -vE '^\s*(#|$)' "$c" | awk '
|
||||
/^\[/ { if (open) { print "empty: " prev; bad = 1 } open = 1; prev = $0; next }
|
||||
{ open = 0 }
|
||||
END { if (open) { print "empty: " prev; bad = 1 } exit bad }
|
||||
' || { echo "an empty section survived:"; cat "$c"; return 1; }
|
||||
[ "$(grep -c . "$c")" -gt 0 ]
|
||||
}
|
||||
|
||||
@test "config_get returns the schema default when the key is absent" {
|
||||
write_config '[core]' 'firewall = on'
|
||||
run lib config_get audit keep
|
||||
@@ -485,6 +712,17 @@ write_config() {
|
||||
[ "$(printf '%s\n' "$output" | wc -l)" -eq "$expected" ]
|
||||
}
|
||||
|
||||
@test "--print-config inspects without pinning anything" {
|
||||
# A query that creates a file as a side effect is not a query. Nothing is
|
||||
# lost by waiting: with nothing pinned every key resolves through the
|
||||
# schema, which is what the first real launch then pins.
|
||||
write_config '[core]' 'firewall = on'
|
||||
run "$REPO_ROOT/bin/dejima" --print-config
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
[ ! -e "$CONFIG_DIR/defaults.conf" ] \
|
||||
|| { echo "--print-config created the pinned file"; return 1; }
|
||||
}
|
||||
|
||||
@test "warming the config cache does not swallow the reason it is invalid" {
|
||||
# bin/dejima parses the config once in its own shell before anything reads
|
||||
# a key, so the parse survives into the subshells that do the reading. That
|
||||
@@ -675,16 +913,23 @@ _identity_fixture() {
|
||||
@test "--harness changes what the session is actually told, not just the printed config" {
|
||||
# The flag has to reach the composed briefing, or it would select a harness
|
||||
# for the container while the session was briefed about a different one.
|
||||
# Cline's fragment names its own resume command, which Claude Code's does
|
||||
# not, so the two are distinguishable.
|
||||
# What distinguishes them is what each says about resuming: Claude Code
|
||||
# offers a command, Cline rules it out. Checked in both directions, so a
|
||||
# briefing that somehow carried both would not pass either.
|
||||
write_config '[core]' 'harness = claude-code' 'forge = none' 'workflow = local-only' \
|
||||
'[harness.cline]' 'model = m'
|
||||
run "$REPO_ROOT/bin/dejima" --print-briefing
|
||||
[[ "$output" != *"CLINE_DATA_DIR"* ]] || { echo "default briefed as cline"; return 1; }
|
||||
[[ "$output" == *"claude --resume"* ]] \
|
||||
|| { echo "the default is not briefed as claude-code: $output"; return 1; }
|
||||
[[ "$output" != *"cannot be continued outside dejima"* ]] \
|
||||
|| { echo "default briefed as cline"; return 1; }
|
||||
|
||||
run "$REPO_ROOT/bin/dejima" --harness cline --print-briefing
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
[[ "$output" == *"CLINE_DATA_DIR"* ]] \
|
||||
[[ "$output" == *"cannot be continued outside dejima"* ]] \
|
||||
|| { echo "the briefing is still the default harness's: $output"; return 1; }
|
||||
[[ "$output" != *"claude --resume"* ]] \
|
||||
|| { echo "the cline briefing still offers Claude Code's resume: $output"; return 1; }
|
||||
}
|
||||
|
||||
@test "--print-config refuses an invalid config rather than printing defaults" {
|
||||
@@ -1863,6 +2108,65 @@ EOF
|
||||
rm -rf "$workspace" "$RW_HOME"
|
||||
}
|
||||
|
||||
# The guard named ~/.claude literally, so a Cline operator could hand a
|
||||
# session write access to the hooks and skills their own machine runs next.
|
||||
# Every path here comes from the plugin or its manifest rather than from a
|
||||
# list in bin/dejima, which is what makes the second harness covered without
|
||||
# anyone having remembered it.
|
||||
@test "the selected harness's own host config is guarded, whichever harness" {
|
||||
_rw_setup
|
||||
workspace="$(mktemp -d)"
|
||||
# Created first: a missing directory is refused for being missing, which
|
||||
# would pass this test without the overlap rule ever running.
|
||||
mkdir -p "$RW_HOME/.agents/skills" "$RW_HOME/.cline/hooks" \
|
||||
"$RW_HOME/.local/share/dejima/cline" \
|
||||
"$RW_HOME/.claude/projects" "$RW_HOME/.claude/plugins"
|
||||
# A config dir the plugin mounts, a seeded file's directory, and the
|
||||
# state root the manifest declares as a `path` key - one of each way a
|
||||
# path reaches the list.
|
||||
for guarded in "$RW_HOME/.agents" "$RW_HOME/.cline" \
|
||||
"$RW_HOME/.local/share/dejima/cline"; do
|
||||
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only' \
|
||||
'harness = cline' \
|
||||
'[harness.cline]' 'backend = anthropic' 'model = m' \
|
||||
'[mounts]' "read_write = $guarded"
|
||||
_rw_launch "$workspace"
|
||||
[ "$status" -ne 0 ] || { echo "accepted a writable mount over $guarded"; return 1; }
|
||||
[[ "$output" == *"Refusing"* ]] \
|
||||
|| { echo "wrong failure for $guarded: $output"; return 1; }
|
||||
done
|
||||
# Claude Code's, which the literal used to cover. projects/ is a manifest
|
||||
# `path` key and plugins/ is reached only through a seed destination, so
|
||||
# both would be missed by a list built from mounted directories alone.
|
||||
for guarded in "$RW_HOME/.claude" "$RW_HOME/.claude/projects" \
|
||||
"$RW_HOME/.claude/plugins"; do
|
||||
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only' \
|
||||
'[mounts]' "read_write = $guarded"
|
||||
_rw_launch "$workspace"
|
||||
[ "$status" -ne 0 ] || { echo "accepted a writable mount over $guarded"; return 1; }
|
||||
[[ "$output" == *"Refusing"* ]] \
|
||||
|| { echo "wrong failure for $guarded: $output"; return 1; }
|
||||
done
|
||||
rm -rf "$workspace" "$RW_HOME"
|
||||
}
|
||||
|
||||
# Refusing everything would pass the test above, and a read_write mount is a
|
||||
# supported feature rather than a thing to break in the course of guarding it.
|
||||
@test "a read_write entry outside the harness's config is still accepted" {
|
||||
_rw_setup
|
||||
workspace="$(mktemp -d)"
|
||||
rw="$(mktemp -d)"
|
||||
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only' \
|
||||
'harness = cline' \
|
||||
'[harness.cline]' 'backend = anthropic' 'model = m' \
|
||||
'[mounts]' "read_write = $rw"
|
||||
_rw_launch "$workspace"
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
grep -qxF "$rw:$rw" "$docker_log" \
|
||||
|| { echo "not mounted read-write:"; cat "$docker_log"; return 1; }
|
||||
rm -rf "$workspace" "$rw" "$RW_HOME"
|
||||
}
|
||||
|
||||
@test "the audit and baseline stores are guarded, not just the obvious paths" {
|
||||
# Named explicitly because a regression that dropped them from the guarded
|
||||
# list would otherwise pass every other test here.
|
||||
@@ -1917,6 +2221,139 @@ EOF
|
||||
rm -rf "$workspace" "$RW_HOME"
|
||||
}
|
||||
|
||||
@test "a read_write_per_project entry mounts this project's slice at the path it names" {
|
||||
_rw_setup
|
||||
pp="$(mktemp -d)"
|
||||
workspace="$(mktemp -d)"
|
||||
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only' \
|
||||
'[mounts]' "read_write_per_project = $pp"
|
||||
_rw_launch "$workspace"
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
|
||||
# The container gets the path the operator named; the host gives it one
|
||||
# directory of that path, keyed the same way share_dir and the audit store
|
||||
# are. Both halves asserted, because either alone is satisfied by the
|
||||
# arrangement this replaces.
|
||||
slug="$(printf '%s' "$workspace" | sed 's/[^a-zA-Z0-9]/-/g')"
|
||||
grep -qxF "$pp/projects/$slug:$pp" "$docker_log" \
|
||||
|| { echo "not mounted as a per-project slice:"; grep "$pp" "$docker_log"; return 1; }
|
||||
run grep -qxF "$pp:$pp" "$docker_log"
|
||||
[ "$status" -ne 0 ] \
|
||||
|| { echo "the whole directory was handed over as well"; return 1; }
|
||||
[ -d "$pp/projects/$slug" ] || { echo "the slice was never created"; return 1; }
|
||||
rm -rf "$pp" "$workspace" "$RW_HOME" "$docker_log"
|
||||
}
|
||||
|
||||
@test "two projects get different slices of one read_write_per_project entry" {
|
||||
# The whole point, and not deducible from one launch: the same declared
|
||||
# directory has to give two workspaces two directories, or it is a shared
|
||||
# pile with extra steps.
|
||||
_rw_setup
|
||||
pp="$(mktemp -d)"
|
||||
ws_a="$(mktemp -d)"; ws_b="$(mktemp -d)"
|
||||
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only' \
|
||||
'[mounts]' "read_write_per_project = $pp"
|
||||
_rw_launch "$ws_a"; [ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
a_log="$docker_log"
|
||||
_rw_launch "$ws_b"; [ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
|
||||
a="$(grep -oE "$pp/projects/[^:]+" "$a_log" | head -1)"
|
||||
b="$(grep -oE "$pp/projects/[^:]+" "$docker_log" | head -1)"
|
||||
[ -n "$a" ] && [ -n "$b" ] || { echo "no slice mounted for one of them"; return 1; }
|
||||
[ "$a" != "$b" ] \
|
||||
|| { echo "both projects were given the same directory: $a"; return 1; }
|
||||
rm -rf "$pp" "$ws_a" "$ws_b" "$RW_HOME" "$a_log" "$docker_log"
|
||||
}
|
||||
|
||||
@test "read_write is refused when it names a read_write_per_project root" {
|
||||
# The share guard's reason, one directory over: handing over the root that
|
||||
# holds every project's slice puts back the pile the slicing removed.
|
||||
_rw_setup
|
||||
pp="$(mktemp -d)"
|
||||
workspace="$(mktemp -d)"
|
||||
mkdir -p "$pp/projects"
|
||||
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only' \
|
||||
'[mounts]' "read_write = $pp/projects" "read_write_per_project = $pp"
|
||||
_rw_launch "$workspace"
|
||||
[ "$status" -ne 0 ] || { echo "the pile was handed back: $output"; return 1; }
|
||||
[[ "$output" == *"holds one directory per project"* ]] || { echo "$output"; return 1; }
|
||||
rm -rf "$pp" "$workspace" "$RW_HOME" "$docker_log"
|
||||
}
|
||||
|
||||
@test "readonly is refused when it names a read_write_per_project root" {
|
||||
_rw_setup
|
||||
pp="$(mktemp -d)"
|
||||
workspace="$(mktemp -d)"
|
||||
mkdir -p "$pp/projects"
|
||||
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only' \
|
||||
'[mounts]' "readonly = $pp/projects" "read_write_per_project = $pp"
|
||||
_rw_launch "$workspace"
|
||||
[ "$status" -ne 0 ] || { echo "every project's slice was readable: $output"; return 1; }
|
||||
rm -rf "$pp" "$workspace" "$RW_HOME" "$docker_log"
|
||||
}
|
||||
|
||||
@test "a read_write_per_project entry overlapping a protected path is refused" {
|
||||
# Slicing does not make a guarded store safe to hand over - it only makes
|
||||
# less of it reachable, which is not the property the guard is about.
|
||||
_rw_setup
|
||||
workspace="$(mktemp -d)"
|
||||
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only' \
|
||||
'[mounts]' "read_write_per_project = $CONFIG_DIR"
|
||||
_rw_launch "$workspace"
|
||||
[ "$status" -ne 0 ] || { echo "the approval store was mounted: $output"; return 1; }
|
||||
[[ "$output" == *"only how much of it there is"* ]] || { echo "$output"; return 1; }
|
||||
rm -rf "$workspace" "$RW_HOME" "$docker_log"
|
||||
}
|
||||
|
||||
@test "the same path in read_write and read_write_per_project is refused" {
|
||||
# Not by a duplicate check: the two mean different things, so naming a path
|
||||
# in both makes it an ancestor of its own per-project root, and the refusal
|
||||
# that fires is the one about handing over every project's directory. That
|
||||
# is the right answer, and it says which list the path belongs in.
|
||||
_rw_setup
|
||||
pp="$(mktemp -d)"
|
||||
workspace="$(mktemp -d)"
|
||||
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only' \
|
||||
'[mounts]' "read_write = $pp" "read_write_per_project = $pp"
|
||||
_rw_launch "$workspace"
|
||||
[ "$status" -ne 0 ] || { echo "$output"; return 1; }
|
||||
[[ "$output" == *"holds one directory per project"* ]] || { echo "$output"; return 1; }
|
||||
[[ "$output" == *"one list or the other and not in both"* ]] || { echo "$output"; return 1; }
|
||||
rm -rf "$pp" "$workspace" "$RW_HOME" "$docker_log"
|
||||
}
|
||||
|
||||
@test "a read_write_per_project entry that is not an existing directory is refused" {
|
||||
# The slice is dejima's to create; the root is not. A path that is not
|
||||
# there is a typo, and creating it would turn the typo into a directory.
|
||||
_rw_setup
|
||||
workspace="$(mktemp -d)"
|
||||
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only' \
|
||||
'[mounts]' "read_write_per_project = /nonexistent/specs"
|
||||
_rw_launch "$workspace"
|
||||
[ "$status" -ne 0 ]
|
||||
[[ "$output" == *"not an existing directory"* ]] || { echo "$output"; return 1; }
|
||||
[ ! -d /nonexistent/specs ]
|
||||
rm -rf "$workspace" "$RW_HOME" "$docker_log"
|
||||
}
|
||||
|
||||
@test "--print-briefing names a per-project mount as this project's own" {
|
||||
# A separate sentence from the read_write one, because the read_write
|
||||
# sentence is not true of these: a session told "these host directories at
|
||||
# their own paths" would believe the whole directory is behind the mount.
|
||||
pp="$(mktemp -d)"
|
||||
rw="$(mktemp -d)"
|
||||
write_config '[core]' 'forge = none' 'workflow = local-only' \
|
||||
'[mounts]' "read_write = $rw" "read_write_per_project = $pp"
|
||||
run "$REPO_ROOT/bin/dejima" --print-briefing
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
[[ "$output" == *"at their own paths"*"$rw"* ]] || { echo "$output"; return 1; }
|
||||
[[ "$output" == *"THIS project's own directory"*"$pp"* ]] || { echo "$output"; return 1; }
|
||||
# And composing a briefing leaves nothing behind: the slice belongs to a
|
||||
# launch, not to a diagnostic that prints one.
|
||||
[ ! -d "$pp/projects" ] || { echo "--print-briefing created the slice"; return 1; }
|
||||
rm -rf "$pp" "$rw"
|
||||
}
|
||||
|
||||
@test "a path in both read_write and readonly is refused rather than resolved by mount order" {
|
||||
_rw_setup
|
||||
d="$(mktemp -d)"
|
||||
@@ -2381,35 +2818,89 @@ _epitaph_three() {
|
||||
"$REPO_ROOT/backends" "$proot/"
|
||||
sed -i 's/^guards .*/guards = bash-deny/' \
|
||||
"$proot/harnesses/claude-code/harness.conf"
|
||||
run_install DEJIMA_PROVIDER_ROOT="$proot"
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
run jq -r '[.hooks.PreToolUse[]?.hooks[]?.command] | join(" ")' \
|
||||
"$CONFIG_DIR/.claude/settings.json"
|
||||
[[ "$output" == *"deny-dangerous-bash.sh"* ]] || { echo "$output"; return 1; }
|
||||
[[ "$output" != *"git-push-guard.sh"* ]] \
|
||||
|| { echo "registered an undeclared guard: $output"; return 1; }
|
||||
[[ "$output" != *"merge-guard.sh"* ]] \
|
||||
|| { echo "registered an undeclared guard: $output"; return 1; }
|
||||
rm -rf "$proot"
|
||||
# The gating moved to the settings overlay with the registration itself,
|
||||
# so this asks the overlay what it registered rather than the host.
|
||||
local ov; ov="$(mktemp)"
|
||||
DEJIMA_PROVIDER_ROOT="$proot" HOME="$CONFIG_DIR" bash -c '
|
||||
source "'"$REPO_ROOT"'/bin/dejima-provider-lib.sh"
|
||||
source "'"$REPO_ROOT"'/bin/dejima-harness-lib.sh"
|
||||
DEJIMA_HARNESS_NAME=claude-code
|
||||
harness_plugin_load claude-code
|
||||
harness_hook harness_prepare_config "$1"
|
||||
' _ "$ov" >/dev/null 2>&1
|
||||
local cmds; cmds="$(jq -r '[.hooks.PreToolUse[]?.hooks[]?.command] | join(" ")' "$ov")"
|
||||
[[ "$cmds" == *"deny-dangerous-bash.sh"* ]] \
|
||||
|| { echo "the declared guard was not registered: $cmds"; rm -rf "$proot" "$ov"; return 1; }
|
||||
[[ "$cmds" != *"git-push-guard.sh"* ]] \
|
||||
|| { echo "registered an undeclared guard: $cmds"; rm -rf "$proot" "$ov"; return 1; }
|
||||
[[ "$cmds" != *"merge-guard.sh"* ]] \
|
||||
|| { echo "registered an undeclared guard: $cmds"; rm -rf "$proot" "$ov"; return 1; }
|
||||
rm -rf "$proot" "$ov"
|
||||
}
|
||||
|
||||
@test "install.sh registers every guard when the harness declares them all" {
|
||||
# The shipped harness, unmodified: the behaviour this replaced, kept.
|
||||
@test "install.sh writes nothing to the operator's harness config" {
|
||||
# The shipped harness, unmodified. Every guard reaches a session through
|
||||
# the sandbox's own settings overlay, so installing dejima changes nothing
|
||||
# about how the harness behaves outside a sandbox - including the one hook
|
||||
# that used to be registered there deliberately.
|
||||
run_install
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
local h
|
||||
for h in sandbox-context.sh git-push-guard.sh merge-guard.sh dejima-notify.sh \
|
||||
deny-dangerous-bash.sh; do
|
||||
[ ! -e "$CONFIG_DIR/.claude/hooks/$h" ] \
|
||||
|| { echo "$h was linked into the operator's hooks directory"; return 1; }
|
||||
done
|
||||
# And no registration either. The file may not exist at all, which is the
|
||||
# strongest form of this - so its absence counts as passing.
|
||||
if [ -f "$CONFIG_DIR/.claude/settings.json" ]; then
|
||||
local cmds
|
||||
cmds="$(jq -r '[.hooks[]?[]?.hooks[]?.command] | join(" ")' \
|
||||
"$CONFIG_DIR/.claude/settings.json")"
|
||||
[ -z "$cmds" ] || { echo "install.sh registered hooks on the host: $cmds"; return 1; }
|
||||
fi
|
||||
}
|
||||
|
||||
# The half that was missing when the registrations moved into the overlay:
|
||||
# every test above asks what the overlay got, and none asked what the launch
|
||||
# then SAYS about it. So the check that reads the operator's file kept
|
||||
# reporting all four guards missing - correctly, since nothing writes there
|
||||
# any more - and told the operator to run ./install.sh, which strips the very
|
||||
# registrations it was looking for.
|
||||
@test "a launch with nothing in the operator's settings does not claim the guards are off" {
|
||||
_rw_setup
|
||||
workspace="$(mktemp -d)"
|
||||
# The state a correct install now leaves: settings.json present, dejima
|
||||
# nowhere in it. An empty file would not do - that is unparseable to
|
||||
# Claude Code and a different case.
|
||||
printf '{}\n' > "$RW_HOME/.claude/settings.json"
|
||||
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only' \
|
||||
'harness = claude-code'
|
||||
_rw_launch "$workspace"
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
[[ "$output" != *"not in force this session"* ]] \
|
||||
|| { echo "claimed the guards were off: $output"; return 1; }
|
||||
rm -rf "$workspace" "$RW_HOME"
|
||||
|
||||
# The positive half, or the assertion above passes just as well on a
|
||||
# launch that registered nothing anywhere. Read from the overlay the
|
||||
# plugin composes, which is where they now live.
|
||||
local ov; ov="$(mktemp)"
|
||||
HOME="$CONFIG_DIR" bash -c '
|
||||
source "'"$REPO_ROOT"'/bin/dejima-provider-lib.sh"
|
||||
source "'"$REPO_ROOT"'/bin/dejima-harness-lib.sh"
|
||||
DEJIMA_HARNESS_NAME=claude-code
|
||||
harness_plugin_load claude-code
|
||||
harness_hook harness_prepare_config "$1"
|
||||
' _ "$ov" >/dev/null 2>&1
|
||||
local cmds
|
||||
cmds="$(jq -r '[.hooks.PreToolUse[]?.hooks[]?.command,
|
||||
.hooks.SessionStart[]?.hooks[]?.command] | join(" ")' \
|
||||
"$CONFIG_DIR/.claude/settings.json")"
|
||||
cmds="$(jq -r '[.hooks[]?[]?.hooks[]?.command] | join(" ")' "$ov")"
|
||||
local h
|
||||
for h in sandbox-context.sh git-push-guard.sh merge-guard.sh deny-dangerous-bash.sh; do
|
||||
[[ "$cmds" == *"$h"* ]] || { echo "$h not registered: $cmds"; return 1; }
|
||||
[[ "$cmds" == *"$h"* ]] \
|
||||
|| { echo "$h is in force nowhere: $cmds"; rm -f "$ov"; return 1; }
|
||||
done
|
||||
# Linked for the read-only hooks mount, but registered per session by
|
||||
# bin/dejima rather than here, where it would fire for host sessions too.
|
||||
[ -L "$CONFIG_DIR/.claude/hooks/dejima-notify.sh" ]
|
||||
[[ "$cmds" != *"dejima-notify.sh"* ]] \
|
||||
|| { echo "notify registered host-wide: $cmds"; return 1; }
|
||||
rm -f "$ov"
|
||||
}
|
||||
|
||||
@test "install.sh registers no guards when no harness is selected" {
|
||||
@@ -2458,58 +2949,6 @@ _epitaph_three() {
|
||||
rm -rf "$workspace"
|
||||
}
|
||||
|
||||
@test "a launch says so when the harness's hooks were never registered" {
|
||||
# install.sh registers them gated on [core] harness. An operator who set
|
||||
# that key without re-running it gets no briefing and no guards, on every
|
||||
# launch, with nothing saying why.
|
||||
_rw_setup
|
||||
workspace="$(mktemp -d)"
|
||||
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only'
|
||||
# _rw_setup writes a settings.json with no hooks in it at all.
|
||||
_rw_launch "$workspace"
|
||||
[[ "$output" == *"not in force this session"* ]] || { echo "$output"; return 1; }
|
||||
[[ "$output" == *"the session briefing"* ]] || { echo "$output"; return 1; }
|
||||
rm -rf "$workspace"
|
||||
}
|
||||
|
||||
@test "a launch names the one guard a hand-edited config dropped" {
|
||||
# The realistic failure is all-or-nothing, so a check on the briefing
|
||||
# alone would pass here - and a session would run without the merge
|
||||
# guard, silently, which is what the report exists to prevent.
|
||||
_rw_setup
|
||||
workspace="$(mktemp -d)"
|
||||
jq -n '{hooks: {
|
||||
SessionStart: [{matcher: "startup", hooks: [{type: "command",
|
||||
command: "$HOME/.claude/hooks/sandbox-context.sh"}]}],
|
||||
PreToolUse: [{matcher: "Bash", hooks: [
|
||||
{type: "command", command: "$HOME/.claude/hooks/git-push-guard.sh"},
|
||||
{type: "command", command: "$HOME/.claude/hooks/deny-dangerous-bash.sh"}]}]}}' \
|
||||
> "$RW_HOME/.claude/settings.json"
|
||||
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only'
|
||||
_rw_launch "$workspace"
|
||||
[[ "$output" == *"merge-guard"* ]] || { echo "$output"; return 1; }
|
||||
[[ "$output" != *"the session briefing"* ]] \
|
||||
|| { echo "named something that is registered: $output"; return 1; }
|
||||
rm -rf "$workspace"
|
||||
}
|
||||
|
||||
@test "a launch stays quiet when the harness's hooks are registered" {
|
||||
_rw_setup
|
||||
workspace="$(mktemp -d)"
|
||||
jq -n '{hooks: {
|
||||
SessionStart: [{matcher: "startup", hooks: [{type: "command",
|
||||
command: "$HOME/.claude/hooks/sandbox-context.sh"}]}],
|
||||
PreToolUse: [{matcher: "Bash", hooks: [
|
||||
{type: "command", command: "$HOME/.claude/hooks/git-push-guard.sh"},
|
||||
{type: "command", command: "$HOME/.claude/hooks/merge-guard.sh"},
|
||||
{type: "command", command: "$HOME/.claude/hooks/deny-dangerous-bash.sh"}]}]}}' \
|
||||
> "$RW_HOME/.claude/settings.json"
|
||||
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only'
|
||||
_rw_launch "$workspace"
|
||||
[[ "$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" {
|
||||
@@ -2721,10 +3160,30 @@ _upd_cache() { printf '%s/.local/share/dejima/update-check' "$HOME"; }
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *"origin/trunk"* ]] \
|
||||
|| { echo "does not name what it is behind: $output"; return 1; }
|
||||
# The path, because the checkout that is behind is not necessarily the
|
||||
# The paths, because the checkout that is behind is not necessarily the
|
||||
# directory the operator is standing in when they read this.
|
||||
[[ "$output" == *"git -C $UPD_CHECKOUT pull"* ]] \
|
||||
|| { echo "does not say how to update: $output"; return 1; }
|
||||
# install.sh too, because pulling does not re-link what a new release
|
||||
# adds on PATH.
|
||||
[[ "$output" == *"$UPD_CHECKOUT/install.sh"* ]] \
|
||||
|| { echo "does not say to re-run install.sh: $output"; return 1; }
|
||||
}
|
||||
|
||||
@test "the notice is set off by a blank line either side" {
|
||||
_upd_fixture
|
||||
_upd_commit "$UPD_ORIGIN" second
|
||||
# Read from the file the tool wrote: bats' run strips the blank lines at
|
||||
# either end of what it captures, and those are the thing being asserted -
|
||||
# they are what keeps the launch's startup lines and image build from
|
||||
# reading as part of the notice.
|
||||
local out="$UPD_DIR/notice"
|
||||
"$UPD_CHECKOUT/bin/dejima-update-check" >"$out" 2>&1
|
||||
grep -q "commits this checkout" "$out" || { cat "$out"; return 1; }
|
||||
[ -z "$(head -n 1 "$out")" ] \
|
||||
|| { echo "no blank line before it: $(cat "$out")"; return 1; }
|
||||
[ -z "$(tail -n 1 "$out")" ] \
|
||||
|| { echo "no blank line after it: $(cat "$out")"; return 1; }
|
||||
}
|
||||
|
||||
@test "the commit count is given only when the commits are actually here" {
|
||||
@@ -3089,7 +3548,7 @@ EOF
|
||||
|
||||
_rw_launch "$workspace"
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
[[ "$output" == *"Recorded [harness.claude-code] backend = anthropic"* ]] \
|
||||
[[ "$output" == *"wrote a line into"* ]] && [[ "$output" == *"backend = anthropic"* ]] \
|
||||
|| { echo "the migration was silent: $output"; return 1; }
|
||||
# In the operator's file, so the next launch reads it rather than assuming
|
||||
# it again.
|
||||
@@ -3099,7 +3558,7 @@ EOF
|
||||
# And it happens once. A second launch finds the value and says nothing.
|
||||
_rw_launch "$workspace"
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
[[ "$output" != *"Recorded [harness"* ]] \
|
||||
[[ "$output" != *"wrote a line into"* ]] \
|
||||
|| { echo "the migration ran again: $output"; return 1; }
|
||||
[ "$(grep -c 'backend = anthropic' "$CONFIG_DIR/config")" -eq 1 ] \
|
||||
|| { echo "the value was written twice:"; cat "$CONFIG_DIR/config"; return 1; }
|
||||
@@ -3198,7 +3657,7 @@ EOF
|
||||
stub="$(mktemp -d)"; log="$(mktemp)"
|
||||
cat > "$stub/docker" <<EOF
|
||||
#!/bin/sh
|
||||
env | grep '^SANDBOX_ANTHROPIC' >> "$log"
|
||||
env | grep -E '^SANDBOX_(ANTHROPIC|CREDENTIAL)' >> "$log"
|
||||
printf '%s\n' "\$@" >> "$log"
|
||||
exit 0
|
||||
EOF
|
||||
@@ -3207,7 +3666,7 @@ EOF
|
||||
run "$REPO_ROOT/bin/dejima" "$workspace"
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
|
||||
grep -qE '^SANDBOX_ANTHROPIC_PROXY_SOCKET=.+' "$log" \
|
||||
grep -qE '^SANDBOX_CREDENTIAL_PROXY_SOCKET=.+' "$log" \
|
||||
|| { echo "the proxy did not start:"; cat "$log"; return 1; }
|
||||
grep -q -- "-v $RW_HOME/.claude/.credentials.json:" "$log" \
|
||||
&& { echo "the real credential file was mounted"; return 1; }
|
||||
@@ -3240,7 +3699,7 @@ EOF
|
||||
stub="$(mktemp -d)"; log="$(mktemp)"
|
||||
cat > "$stub/docker" <<EOF
|
||||
#!/bin/sh
|
||||
env | grep '^SANDBOX_ANTHROPIC' >> "$log"
|
||||
env | grep -E '^SANDBOX_(ANTHROPIC|CREDENTIAL)' >> "$log"
|
||||
printf '%s\n' "\$@" >> "$log"
|
||||
exit 0
|
||||
EOF
|
||||
@@ -3364,7 +3823,7 @@ EOF
|
||||
# as a copy - so the copy has to lose the key while the proxy gains it.
|
||||
_console_launch
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
grep -qE '^SANDBOX_ANTHROPIC_PROXY_SOCKET=.+' "$CLOG" \
|
||||
grep -qE '^SANDBOX_CREDENTIAL_PROXY_SOCKET=.+' "$CLOG" \
|
||||
|| { echo "the proxy did not start:"; cat "$CLOG"; return 1; }
|
||||
[ "$(jq -r '.primaryApiKey // "absent"' "$CSTAGED")" = "absent" ] \
|
||||
|| { echo "the real key reached the container"; return 1; }
|
||||
@@ -3390,7 +3849,7 @@ EOF
|
||||
|| { echo "the downgrade did not deliver the credential"; return 1; }
|
||||
[[ "$output" == *"proxy is off"* ]] || { echo "$output"; return 1; }
|
||||
# And no proxy was started for it.
|
||||
grep -qE '^SANDBOX_ANTHROPIC_PROXY_SOCKET=.+' "$CLOG" \
|
||||
grep -qE '^SANDBOX_CREDENTIAL_PROXY_SOCKET=.+' "$CLOG" \
|
||||
&& { echo "a proxy ran for a backend with proxy = off"; return 1; }
|
||||
rm -rf "$workspace" "$CSTUB" "$CLOG" "$CSTAGED"
|
||||
}
|
||||
@@ -3791,3 +4250,175 @@ JSON
|
||||
grep -q 'CANARY' <<< "$output" && { echo "a value leaked"; return 1; }
|
||||
rm -rf "$d"
|
||||
}
|
||||
|
||||
@test "the migration writes into the operator's existing section, not a second one" {
|
||||
# Reported from a real launch: the config already had a
|
||||
# [harness.claude-code] section, and dejima appended a second one holding
|
||||
# only the migrated key. It parses, and it reads as a mistake dejima made
|
||||
# in the operator's own file.
|
||||
_rw_setup
|
||||
workspace="$(mktemp -d)"
|
||||
printf '[core]\nharness = claude-code\nssh_key = %s\nforge = none\nworkflow = local-only\n[harness.claude-code]\nprojects_dir = ~/.claude/projects\n' \
|
||||
"$RW_KEY" > "$CONFIG_DIR/config"
|
||||
_rw_launch "$workspace"
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
|
||||
[ "$(grep -c '^\[harness\.claude-code\]' "$CONFIG_DIR/config")" -eq 1 ] \
|
||||
|| { echo "a second section was appended:"; cat "$CONFIG_DIR/config"; return 1; }
|
||||
grep -qx 'backend = anthropic' "$CONFIG_DIR/config" \
|
||||
|| { echo "the key was not written:"; cat "$CONFIG_DIR/config"; return 1; }
|
||||
# The key the operator already had is untouched, and still in that section.
|
||||
grep -qx 'projects_dir = ~/.claude/projects' "$CONFIG_DIR/config" \
|
||||
|| { echo "an existing key was lost:"; cat "$CONFIG_DIR/config"; return 1; }
|
||||
# And the file keeps its own permissions rather than a fresh temp file's.
|
||||
[ "$(stat -c '%a' "$CONFIG_DIR/config")" = "$(stat -c '%a' "$RW_KEY")" ] \
|
||||
|| echo "note: config mode is now $(stat -c '%a' "$CONFIG_DIR/config")"
|
||||
rm -rf "$workspace"
|
||||
}
|
||||
|
||||
@test "a key dejima moved says where it went, not just that it is unknown" {
|
||||
# base_url left the harness for the backend in the provider axis. An
|
||||
# operator who had it gets "unknown key", which is true and useless: it
|
||||
# does not distinguish a typo from a move, and the answer is in a document
|
||||
# they have no reason to think they need.
|
||||
_rw_setup
|
||||
printf '[core]\nharness = cline\nssh_key = %s\nforge = none\nworkflow = local-only\n[harness.cline]\nmodel = m\nbase_url = http://127.0.0.1:8080/v1\n' \
|
||||
"$RW_KEY" > "$CONFIG_DIR/config"
|
||||
HOME="$RW_HOME" run "$REPO_ROOT/bin/dejima" --print-config
|
||||
[ "$status" -ne 0 ] || { echo "an unknown key was accepted: $output"; return 1; }
|
||||
[[ "$output" == *"unknown key"* ]] || { echo "$output"; return 1; }
|
||||
[[ "$output" == *"backend = self-hosted"* ]] \
|
||||
|| { echo "it does not name where the key went: $output"; return 1; }
|
||||
[[ "$output" == *"[backend.self-hosted]"* ]] \
|
||||
|| { echo "it does not name the new section: $output"; return 1; }
|
||||
}
|
||||
|
||||
@test "a long orphaned-share list is sampled, not printed whole" {
|
||||
# Reported: ninety entries pushed everything about the run off the screen.
|
||||
# It is a standing condition an operator clears once, so it gets a sample.
|
||||
_rw_setup
|
||||
workspace="$(mktemp -d)"
|
||||
mkdir -p "$RW_HOME/share"
|
||||
local i
|
||||
for i in $(seq 1 31); do : > "$RW_HOME/share/leftover-$i.md"; done
|
||||
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only' \
|
||||
'harness = claude-code' 'share_dir = ~/share'
|
||||
_rw_launch "$workspace"
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
[[ "$output" == *"no session can see any more"* ]] || { echo "$output"; return 1; }
|
||||
[ "$(grep -c 'leftover-' <<< "$output")" -le 8 ] \
|
||||
|| { echo "the whole list was printed:"; echo "$output"; return 1; }
|
||||
[[ "$output" == *"and 23 more"* ]] \
|
||||
|| { echo "it does not say how many were left out: $output"; return 1; }
|
||||
rm -rf "$workspace"
|
||||
}
|
||||
|
||||
@test "a notice pauses before the build output scrolls it away" {
|
||||
# The positive half. Without it, the test below passes just as well when
|
||||
# the pause does not exist at all - which is exactly how it read after an
|
||||
# accidental revert wiped the feature and left its guard test green.
|
||||
_rw_setup
|
||||
workspace="$(mktemp -d)"
|
||||
printf '[core]\nharness = claude-code\nssh_key = %s\nforge = none\nworkflow = local-only\n[harness.claude-code]\nbackend = anthropic\n' \
|
||||
"$RW_KEY" > "$CONFIG_DIR/config"
|
||||
STUB_DIR="$(mktemp -d)"
|
||||
printf '#!/bin/sh\necho BUILD-OUTPUT-STARTS\nexit 0\n' > "$STUB_DIR/docker"
|
||||
chmod +x "$STUB_DIR/docker"
|
||||
HOME="$RW_HOME" PATH="$STUB_DIR:$PATH" SSH_AUTH_SOCK= \
|
||||
run python3 "$BATS_TEST_DIRNAME/fixtures/answer-prompt.py" \
|
||||
--marker "Press Enter" "" -- "$REPO_ROOT/bin/dejima" "$workspace"
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
[[ "$output" == *"Press Enter to continue"* ]] \
|
||||
|| { echo "no pause before the build: $output"; return 1; }
|
||||
# And it is a pause, not a stop: the build runs once answered.
|
||||
[[ "$output" == *"BUILD-OUTPUT-STARTS"* ]] \
|
||||
|| { echo "answering did not continue: $output"; return 1; }
|
||||
# The pause comes AFTER the notice, or it has not protected anything.
|
||||
[[ "${output%%Press Enter*}" == *"ssh_key is deprecated"* ]] \
|
||||
|| { echo "the pause came before the notice: $output"; return 1; }
|
||||
rm -rf "$workspace" "$STUB_DIR"
|
||||
}
|
||||
|
||||
@test "--no-pause reaches the prompt without a keypress, and still says it" {
|
||||
# Starting eight sessions from a tmuxinator layout: every one has a
|
||||
# terminal, so the pause fires on every one, and the operator wants them
|
||||
# all at a prompt rather than eight keys to press. The notices still
|
||||
# print - skipping the wait is not skipping the telling.
|
||||
_rw_setup
|
||||
workspace="$(mktemp -d)"
|
||||
printf '[core]\nharness = claude-code\nssh_key = %s\nforge = none\nworkflow = local-only\n[harness.claude-code]\nbackend = anthropic\n' \
|
||||
"$RW_KEY" > "$CONFIG_DIR/config"
|
||||
STUB_DIR="$(mktemp -d)"
|
||||
printf '#!/bin/sh\necho BUILD-OUTPUT-STARTS\nexit 0\n' > "$STUB_DIR/docker"
|
||||
chmod +x "$STUB_DIR/docker"
|
||||
# A real terminal and NO answer: without the flag this would sit at the
|
||||
# prompt until the fixture gave up and killed it.
|
||||
HOME="$RW_HOME" PATH="$STUB_DIR:$PATH" SSH_AUTH_SOCK= \
|
||||
run python3 "$BATS_TEST_DIRNAME/fixtures/answer-prompt.py" \
|
||||
--marker "nothing-matches-this" -- \
|
||||
"$REPO_ROOT/bin/dejima" --no-pause "$workspace"
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
[[ "$output" != *"Press Enter"* ]] \
|
||||
|| { echo "--no-pause still waited: $output"; return 1; }
|
||||
[[ "$output" == *"BUILD-OUTPUT-STARTS"* ]] \
|
||||
|| { echo "it never reached the build: $output"; return 1; }
|
||||
# The notice is still printed; only the wait is skipped.
|
||||
[[ "$output" == *"ssh_key is deprecated"* ]] \
|
||||
|| { echo "--no-pause silenced the notice: $output"; return 1; }
|
||||
# And it did not merely get killed for hanging.
|
||||
[[ "$output" != *"never exited"* ]] || { echo "it hung: $output"; return 1; }
|
||||
rm -rf "$workspace" "$STUB_DIR"
|
||||
}
|
||||
|
||||
@test "an unattended launch never waits for a keystroke nobody will press" {
|
||||
# The pause before the build exists so a notice can be read. With no
|
||||
# terminal there is nobody to read it, and blocking would hang every
|
||||
# scheduled or remote launch that has a deprecation set.
|
||||
_rw_setup
|
||||
workspace="$(mktemp -d)"
|
||||
# [core] ssh_key is a deprecation, so a notice fires on this launch.
|
||||
printf '[core]\nharness = claude-code\nssh_key = %s\nforge = none\nworkflow = local-only\n[harness.claude-code]\nbackend = anthropic\n' \
|
||||
"$RW_KEY" > "$CONFIG_DIR/config"
|
||||
_rw_launch "$workspace"
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
[[ "$output" == *"ssh_key is deprecated"* ]] \
|
||||
|| { echo "the fixture raised no notice: $output"; return 1; }
|
||||
[[ "$output" != *"Press Enter"* ]] \
|
||||
|| { echo "it waited for input with no terminal: $output"; return 1; }
|
||||
rm -rf "$workspace"
|
||||
}
|
||||
|
||||
@test "an invalid config reports its reason once, not once per parsing context" {
|
||||
# The early validation runs suppressed and, on failure, used to drop the
|
||||
# cache so the next caller would parse and report. The next caller is
|
||||
# usually a subshell - $(config_get ...) - whose cache dies with it, so
|
||||
# every parsing context reported the same problem. One line each was
|
||||
# merely odd; a message with a hint under it printed twenty lines.
|
||||
_rw_setup
|
||||
printf '[core]\nharness = cline\nssh_key = %s\nforge = none\nworkflow = local-only\n[harness.cline]\nmodel = m\nbase_url = http://127.0.0.1:8080/v1\n' \
|
||||
"$RW_KEY" > "$CONFIG_DIR/config"
|
||||
|
||||
HOME="$RW_HOME" run "$REPO_ROOT/bin/dejima" --print-config
|
||||
[ "$status" -ne 0 ] || { echo "an invalid config launched: $output"; return 1; }
|
||||
[ "$(grep -c 'unknown key' <<< "$output")" -eq 1 ] \
|
||||
|| { echo "reported $(grep -c 'unknown key' <<< "$output") times:"; echo "$output"; return 1; }
|
||||
|
||||
# And on the launch path, which parses in more places than --print-config.
|
||||
workspace="$(mktemp -d)"
|
||||
_rw_launch "$workspace"
|
||||
[ "$status" -ne 0 ] || { echo "an invalid config launched: $output"; return 1; }
|
||||
[ "$(grep -c 'unknown key' <<< "$output")" -eq 1 ] \
|
||||
|| { echo "reported $(grep -c 'unknown key' <<< "$output") times:"; echo "$output"; return 1; }
|
||||
rm -rf "$workspace"
|
||||
}
|
||||
|
||||
@test "--help still answers when the config is invalid" {
|
||||
# The fix above must not become an exit: flags are not parsed yet at that
|
||||
# point, and someone whose config is broken is exactly who needs --help.
|
||||
_rw_setup
|
||||
printf '[core]\nharness = cline\nssh_key = %s\n[harness.cline]\nbase_url = x\n' \
|
||||
"$RW_KEY" > "$CONFIG_DIR/config"
|
||||
HOME="$RW_HOME" run "$REPO_ROOT/bin/dejima" --help
|
||||
[ "$status" -eq 0 ] || { echo "--help failed on a broken config: $output"; return 1; }
|
||||
[[ "$output" == *"dejima - run an agent harness"* ]] || { echo "$output"; return 1; }
|
||||
}
|
||||
|
||||
Vendored
+54
-4
@@ -6,7 +6,7 @@ point - an unattended launch must not block on a question nobody will see. So
|
||||
a test that drives the interactive path needs a pty rather than a pipe, or it
|
||||
exercises the non-interactive branch while claiming to test the other one.
|
||||
|
||||
Usage: answer-prompt.py <answer-per-prompt>... -- <command> [args...]
|
||||
Usage: answer-prompt.py [--marker TEXT] <answer-per-prompt>... -- <command> [args...]
|
||||
|
||||
Each answer is sent when the child writes a line ending in the prompt marker.
|
||||
Everything the child wrote is printed on exit, and the child's exit status is
|
||||
@@ -15,12 +15,27 @@ this program's.
|
||||
|
||||
import os
|
||||
import pty
|
||||
import signal
|
||||
import re
|
||||
import select
|
||||
import sys
|
||||
import time
|
||||
|
||||
# The default is the credential scan's prompt. --marker takes another, so a
|
||||
# test for a different prompt does not need a second copy of this.
|
||||
MARKER = "[s/a/o/r]"
|
||||
TIMEOUT = 60.0
|
||||
|
||||
# dejima pauses before the image build when it printed a notice worth reading.
|
||||
# That is not the prompt any test here is about - it is ambient, and every
|
||||
# launch fixture that sets [core] ssh_key raises it - so it is answered
|
||||
# automatically rather than costing every caller an extra argument.
|
||||
AMBIENT = "Press Enter to continue"
|
||||
# An IDLE timeout: how long to wait with no output before giving up. Generous
|
||||
# because the thing it guards against is a command that never finishes, and
|
||||
# any finite bound catches that - while a tight one just times the machine.
|
||||
# Sixty seconds was enough on an idle box and not enough under the full gate,
|
||||
# where every suite runs at once (#295).
|
||||
TIMEOUT = 240.0
|
||||
|
||||
|
||||
def main():
|
||||
@@ -28,7 +43,13 @@ def main():
|
||||
print("usage: answer-prompt.py <answer>... -- <command>...", file=sys.stderr)
|
||||
return 2
|
||||
split = sys.argv.index("--")
|
||||
answers = list(sys.argv[1:split])
|
||||
args = list(sys.argv[1:split])
|
||||
global MARKER
|
||||
if "--marker" in args:
|
||||
i = args.index("--marker")
|
||||
MARKER = args[i + 1]
|
||||
del args[i:i + 2]
|
||||
answers = args
|
||||
command = sys.argv[split + 1:]
|
||||
if not command:
|
||||
print("no command given", file=sys.stderr)
|
||||
@@ -64,11 +85,40 @@ def main():
|
||||
# marker appearing inside prose does not consume an answer.
|
||||
if pending and MARKER in text:
|
||||
os.write(fd, (pending.pop(0) + "\n").encode())
|
||||
elif AMBIENT in text and AMBIENT != MARKER:
|
||||
os.write(fd, b"\n")
|
||||
|
||||
_, status = os.waitpid(pid, 0)
|
||||
# The read loop ends when the child stops WRITING, which is not when it
|
||||
# stops running: dejima does its exit audit and stops its shipper after
|
||||
# the last thing it prints. So give it a grace period to finish before
|
||||
# concluding anything - killing it there reported a child that was merely
|
||||
# busy as a hang, and left it taking SIGPIPE on a pty nobody was reading.
|
||||
#
|
||||
# Only then kill, because an unanswered prompt is the other way this ends
|
||||
# and waiting on that turns a failing test into a hung suite. The caller
|
||||
# is told which it was.
|
||||
stuck = False
|
||||
status = 0
|
||||
grace = time.time() + 60.0
|
||||
try:
|
||||
while True:
|
||||
done, status = os.waitpid(pid, os.WNOHANG)
|
||||
if done != 0:
|
||||
break
|
||||
if time.time() >= grace:
|
||||
stuck = True
|
||||
os.kill(pid, signal.SIGKILL)
|
||||
_, status = os.waitpid(pid, 0)
|
||||
break
|
||||
time.sleep(0.1)
|
||||
except OSError:
|
||||
status = 0
|
||||
sys.stdout.write("".join(out))
|
||||
if pending:
|
||||
sys.stdout.write("\n[answer-prompt: %d answer(s) never used]\n" % len(pending))
|
||||
if stuck:
|
||||
sys.stdout.write("\n[answer-prompt: the command never exited - killed. "
|
||||
"It was probably waiting at a prompt nothing answered.]\n")
|
||||
return os.waitstatus_to_exitcode(status) if hasattr(os, "waitstatus_to_exitcode") \
|
||||
else (status >> 8)
|
||||
|
||||
|
||||
Vendored
+34
@@ -6,6 +6,8 @@
|
||||
# records the request body)
|
||||
# GET /oauth-hits -> number of refresh calls so far (text/plain)
|
||||
# GET /oauth-last -> JSON body of the most recent refresh request
|
||||
# * /status-429 -> 429, chunked, retry-after: 41 (a rate limiter's shape)
|
||||
# * /status-429-gzip -> the same, gzip-encoded (what a client asking for gzip gets)
|
||||
# * /status-503 -> 503, body "upstream-error"
|
||||
# * /echo-401-once -> 401 on the first call ever, then echoes
|
||||
# GET /sse -> two SSE events 2s apart, no Content-Length
|
||||
@@ -62,6 +64,38 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self._send(200, str(STATE["oauth_hits"]).encode(), "text/plain")
|
||||
elif self.path == "/oauth-last":
|
||||
self._send(200, json.dumps(STATE["oauth_last"]).encode())
|
||||
elif self.path == "/status-429-gzip":
|
||||
# gzip + chunked + retry-after: what a rate limiter answers a
|
||||
# client that asked for gzip, which every Node harness does. The
|
||||
# proxy forwards the client's Accept-Encoding verbatim, so this is
|
||||
# the shape it actually meets - and the first version wrote those
|
||||
# bytes to the operator's terminal.
|
||||
import gzip as _gzip
|
||||
b = _gzip.compress(
|
||||
b'{"type":"error","error":{"type":"rate_limit_error","message":"slow down"}}')
|
||||
self.send_response(429)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Encoding", "gzip")
|
||||
self.send_header("retry-after", "41")
|
||||
self.send_header("Transfer-Encoding", "chunked")
|
||||
self.end_headers()
|
||||
self.wfile.write(b"%x\r\n" % len(b) + b + b"\r\n0\r\n\r\n")
|
||||
elif self.path == "/status-429":
|
||||
# Chunked, with no Content-Length, and a retry-after - the framing
|
||||
# a rate limiter actually uses, and the one the proxy's first
|
||||
# attempt at logging could not read a body out of.
|
||||
self.send_response(429)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("retry-after", "41")
|
||||
self.send_header("request-id", "req_011CehAZUPXFbQGikXfM3Mp6")
|
||||
# The ids a real response also carries, so the test that they stay
|
||||
# out of the log has something to stay out of.
|
||||
self.send_header("anthropic-organization-id", "e528bbab-dead-beef")
|
||||
self.send_header("anthropic-workspace-id", "wrkspc_01MpMcAs9xg5")
|
||||
self.send_header("Transfer-Encoding", "chunked")
|
||||
self.end_headers()
|
||||
b = b'{"type":"error","error":{"type":"rate_limit_error","message":"slow down"}}'
|
||||
self.wfile.write(b"%x\r\n" % len(b) + b + b"\r\n0\r\n\r\n")
|
||||
elif self.path == "/status-503":
|
||||
self._send(503, b"upstream-error", "text/plain")
|
||||
elif self.path == "/echo-401-once":
|
||||
|
||||
Vendored
+26
-5
@@ -276,6 +276,28 @@ def received():
|
||||
return b""
|
||||
|
||||
|
||||
def await_received(predicate, timeout=15.0):
|
||||
"""Wait for what the container received to satisfy predicate.
|
||||
|
||||
The third instance of the same bug in this file - see await_raw and the
|
||||
reclaim wait above. A byte written to the master pty has to reach the
|
||||
client, cross into the container's stand-in and be appended to a file
|
||||
before the check can see it, and pumping for a fixed 0.8s and then looking
|
||||
was timing the machine rather than waiting for the thing. It failed in CI
|
||||
exactly that way: the check for ^C saw the tail of the line typed before
|
||||
it, because ^C had not arrived yet.
|
||||
|
||||
Returns the predicate's answer either way, so a real failure still fails
|
||||
rather than hanging until the suite's own timeout.
|
||||
"""
|
||||
end = time.time() + timeout
|
||||
while time.time() < end:
|
||||
if predicate(received()):
|
||||
return True
|
||||
pump(0.05)
|
||||
return predicate(received())
|
||||
|
||||
|
||||
def check(name, ok, detail=""):
|
||||
print(("PASS " + name) if ok else ("FAIL %s %s" % (name, detail)))
|
||||
if not ok:
|
||||
@@ -332,13 +354,12 @@ try:
|
||||
|
||||
before = received()
|
||||
send("typed\n")
|
||||
pump(0.8)
|
||||
check("input-reaches-the-resumed-session", received() != before)
|
||||
check("input-reaches-the-resumed-session",
|
||||
await_received(lambda got: got != before))
|
||||
|
||||
before = received()
|
||||
send("\x03")
|
||||
pump(0.8)
|
||||
check("ctrl-c-still-reaches-the-session", received().endswith(b"\x03"),
|
||||
check("ctrl-c-still-reaches-the-session",
|
||||
await_received(lambda got: got.endswith(b"\x03")),
|
||||
repr(received()[-4:]))
|
||||
|
||||
# The finding, asserted: the client is stopped by SIGSTOP, which it cannot
|
||||
|
||||
@@ -0,0 +1,382 @@
|
||||
#!/usr/bin/env bats
|
||||
# How a launch is arranged: the containers it starts, in what order, on what
|
||||
# network, and what it takes down again.
|
||||
#
|
||||
# The subject is bin/dejima's side of the filter container - the extra
|
||||
# container per launch that owns the network namespace and the packet filter,
|
||||
# which the sandbox joins and has no capability to alter. Whether the filter
|
||||
# actually filters is runtime.bats's question and needs a real Docker; whether
|
||||
# the launcher builds the arrangement that puts one there is this file's, and
|
||||
# needs only a stub.
|
||||
#
|
||||
# Everything here runs against a `docker` that records its argv and exits 0, so
|
||||
# a launch completes without a daemon. That makes the ORDER of the calls, and
|
||||
# the flags on each, the thing being asserted.
|
||||
|
||||
setup() {
|
||||
REPO_ROOT="$BATS_TEST_DIRNAME/.."
|
||||
SANDBOX_DIR="$REPO_ROOT/docker"
|
||||
LIB="$REPO_ROOT/bin/dejima-project-lib.sh"
|
||||
CONFIG_DIR="$(mktemp -d)"
|
||||
RUNTIME_DIR="$(mktemp -d)"
|
||||
export DEJIMA_CONFIG_DIR="$CONFIG_DIR"
|
||||
export XDG_RUNTIME_DIR="$RUNTIME_DIR"
|
||||
}
|
||||
|
||||
teardown() {
|
||||
rm -rf "$CONFIG_DIR" "$RUNTIME_DIR" ${LAUNCH_DIR:+"$LAUNCH_DIR"} ${PROJ:+"$PROJ"}
|
||||
}
|
||||
|
||||
# A `docker` that logs one line per invocation and succeeds at everything,
|
||||
# including the readiness probe. Tests that need a failure override it.
|
||||
stub_launch() {
|
||||
LAUNCH_DIR="$(mktemp -d)"
|
||||
mkdir -p "$LAUNCH_DIR/home/.claude"
|
||||
echo '{}' > "$LAUNCH_DIR/home/.claude/settings.json"
|
||||
cat > "$LAUNCH_DIR/docker" <<'EOF'
|
||||
#!/bin/bash
|
||||
echo "$*" >> "${SANDBOX_TEST_DOCKER_LOG:-/dev/null}"
|
||||
exit 0
|
||||
EOF
|
||||
chmod +x "$LAUNCH_DIR/docker"
|
||||
: > "$LAUNCH_DIR/sandbox-key"
|
||||
chmod 600 "$LAUNCH_DIR/sandbox-key"
|
||||
PROJ="$(mktemp -d)"
|
||||
}
|
||||
|
||||
launch() {
|
||||
local conf
|
||||
conf="$(bash -c 'source "$1"; config_path' _ "$LIB")"
|
||||
mkdir -p "$(dirname "$conf")"
|
||||
printf '[core]\nharness = claude-code\nupdate_check = off\nssh_key = %s\n' \
|
||||
"$LAUNCH_DIR/sandbox-key" >> "$conf"
|
||||
HOME="$LAUNCH_DIR/home" PATH="$LAUNCH_DIR:$PATH" \
|
||||
SANDBOX_TEST_DOCKER_LOG="$LAUNCH_DIR/docker-log" \
|
||||
SSH_AUTH_SOCK= ALLOWED_DOMAINS= \
|
||||
"${DEJIMA_BIN:-$REPO_ROOT/bin/dejima}" "$@"
|
||||
}
|
||||
|
||||
log_line() { grep -- "$1" "$LAUNCH_DIR/docker-log" | tail -1; }
|
||||
# 1-indexed position of the last line matching a pattern, for ordering claims.
|
||||
log_pos() { grep -n -- "$1" "$LAUNCH_DIR/docker-log" | tail -1 | cut -d: -f1; }
|
||||
|
||||
@test "a launch creates a network of its own and a filter container on it" {
|
||||
stub_launch
|
||||
launch "$PROJ"
|
||||
|
||||
local net; net="$(log_line 'network create')"
|
||||
[ -n "$net" ] || { echo "no network was created"; return 1; }
|
||||
[[ "$net" =~ dejima-run-[0-9a-f]{16} ]] \
|
||||
|| { echo "the network is not named for this run: $net"; return 1; }
|
||||
[[ "$net" == *"--label dejima.run="* ]] \
|
||||
|| { echo "the network carries no run label, so nothing can reconcile it: $net"; return 1; }
|
||||
|
||||
local run; run="$(log_line 'run -d --init')"
|
||||
[ -n "$run" ] || { echo "no filter container was started"; return 1; }
|
||||
[[ "$run" =~ --name\ dejima-filter-[0-9a-f]{16} ]] \
|
||||
|| { echo "the filter container is not named for this run: $run"; return 1; }
|
||||
[[ "$run" == *"--entrypoint /usr/local/bin/filter-entrypoint.sh"* ]] \
|
||||
|| { echo "the filter container does not run the filter entrypoint: $run"; return 1; }
|
||||
|
||||
# One network per launch, and the filter container on that one - not on
|
||||
# whatever bridge Docker would have picked. dns-proxy.sh forwards to the
|
||||
# embedded resolver at 127.0.0.11, which only exists on a user-defined
|
||||
# network, so this is load-bearing rather than tidiness.
|
||||
local id; id="$(sed -E 's/.*dejima-run-([0-9a-f]{16}).*/\1/' <<< "$net")"
|
||||
[[ "$run" == *"--network dejima-run-$id"* ]] \
|
||||
|| { echo "the filter container is not on this launch's network: $run"; return 1; }
|
||||
}
|
||||
|
||||
@test "the sandbox joins the filter container's namespace rather than getting one" {
|
||||
stub_launch
|
||||
launch "$PROJ"
|
||||
|
||||
# The compose file is what carries it, from an environment variable the
|
||||
# launcher exports - so both halves are asserted. Either alone passes on an
|
||||
# arrangement where the session still has a network of its own.
|
||||
grep -qE '^\s*network_mode: \$\{SANDBOX_NETNS_CONTAINER' "$SANDBOX_DIR/docker-compose.yml" \
|
||||
|| { echo "the compose file does not join a namespace"; return 1; }
|
||||
|
||||
# And what it falls back to when nothing supplies one. Only a `docker
|
||||
# compose` invocation by hand reaches this, but a default naming a real
|
||||
# network is a sandbox that comes up working with unfiltered egress, and
|
||||
# nothing about it looks wrong. `none` fails the same case visibly.
|
||||
grep -qE '^\s*network_mode: \$\{SANDBOX_NETNS_CONTAINER:-none\}\s*$' \
|
||||
"$SANDBOX_DIR/docker-compose.yml" \
|
||||
|| { echo "the network_mode default is not fail-closed"; return 1; }
|
||||
|
||||
local run; run="$(log_line 'run -d --init')"
|
||||
local id; id="$(sed -E 's/.*--name dejima-filter-([0-9a-f]{16}).*/\1/' <<< "$run")"
|
||||
[ -n "$id" ]
|
||||
run grep -c "SANDBOX_NETNS_CONTAINER" "$REPO_ROOT/bin/dejima"
|
||||
[ "$status" -eq 0 ]
|
||||
grep -q 'export SANDBOX_NETNS_CONTAINER="container:\$SANDBOX_FILTER_CONTAINER"' \
|
||||
"$REPO_ROOT/bin/dejima" \
|
||||
|| { echo "the launcher does not point the sandbox at the filter container"; return 1; }
|
||||
}
|
||||
|
||||
@test "the filter container comes up before the session, not beside it" {
|
||||
# The ordering IS the control. A namespace that gets its rules after
|
||||
# something is already running in it has a window where a session holds an
|
||||
# interface and no filter, which is what this arrangement exists to remove
|
||||
# by construction rather than to bound by argument.
|
||||
stub_launch
|
||||
launch "$PROJ"
|
||||
|
||||
local net_at filter_at ready_at session_at
|
||||
net_at="$(log_pos 'network create')"
|
||||
filter_at="$(log_pos 'run -d --init')"
|
||||
ready_at="$(log_pos 'dejima-firewall-ready')"
|
||||
session_at="$(log_pos ' run --rm ')"
|
||||
[ -n "$net_at" ] && [ -n "$filter_at" ] && [ -n "$ready_at" ] && [ -n "$session_at" ] \
|
||||
|| { echo "one of the four steps never happened"; cat "$LAUNCH_DIR/docker-log"; return 1; }
|
||||
|
||||
[ "$net_at" -lt "$filter_at" ] \
|
||||
|| { echo "the filter container started before its network existed"; return 1; }
|
||||
[ "$filter_at" -lt "$ready_at" ] \
|
||||
|| { echo "readiness was probed before the filter container was started"; return 1; }
|
||||
[ "$ready_at" -lt "$session_at" ] \
|
||||
|| { echo "the session started before the filter was confirmed up"; return 1; }
|
||||
}
|
||||
|
||||
@test "a filter container that never comes up refuses the launch" {
|
||||
# The failure this is really about is the quiet one: a launcher that waited,
|
||||
# gave up, and started the session anyway would produce a working sandbox
|
||||
# with no egress control, and nothing downstream would notice.
|
||||
stub_launch
|
||||
cat > "$LAUNCH_DIR/docker" <<'EOF'
|
||||
#!/bin/bash
|
||||
echo "$*" >> "${SANDBOX_TEST_DOCKER_LOG:-/dev/null}"
|
||||
case "$*" in
|
||||
# The marker never appears, and the container is reported gone - which is
|
||||
# what a firewall that refused to start leaves behind.
|
||||
*dejima-firewall-ready*) exit 1 ;;
|
||||
*inspect*State.Running*) echo false; exit 0 ;;
|
||||
*logs*) echo "init-firewall.sh: DNS lookup for registry.npmjs.org failed"; exit 0 ;;
|
||||
esac
|
||||
exit 0
|
||||
EOF
|
||||
chmod +x "$LAUNCH_DIR/docker"
|
||||
|
||||
run launch "$PROJ"
|
||||
[ "$status" -ne 0 ] \
|
||||
|| { echo "the launch succeeded with no egress control"; return 1; }
|
||||
[[ "$output" == *"did not come up"* ]] \
|
||||
|| { echo "it refused without saying why: $output"; return 1; }
|
||||
# And it hands over what the filter container said, or the operator is left
|
||||
# with a refusal and nowhere to look.
|
||||
[[ "$output" == *"registry.npmjs.org"* ]] \
|
||||
|| { echo "the refusal does not carry the filter container's own output: $output"; return 1; }
|
||||
|
||||
run grep -c -- ' run --rm ' "$LAUNCH_DIR/docker-log"
|
||||
[ "$output" = "0" ] \
|
||||
|| { echo "the session was started anyway"; return 1; }
|
||||
}
|
||||
|
||||
@test "the launch takes its own container and network down again" {
|
||||
stub_launch
|
||||
launch "$PROJ"
|
||||
|
||||
local run id
|
||||
run="$(log_line 'run -d --init')"
|
||||
id="$(sed -E 's/.*--name dejima-filter-([0-9a-f]{16}).*/\1/' <<< "$run")"
|
||||
[ -n "$id" ]
|
||||
|
||||
grep -q -- "rm -f dejima-filter-$id" "$LAUNCH_DIR/docker-log" \
|
||||
|| { echo "the filter container was left running"; return 1; }
|
||||
grep -q -- "network rm dejima-run-$id" "$LAUNCH_DIR/docker-log" \
|
||||
|| { echo "the network was left behind"; return 1; }
|
||||
|
||||
# After the session, not before it. Docker permits removing a namespace
|
||||
# owner from under a live joiner, so this order is the launcher's to keep.
|
||||
local session_at rm_at
|
||||
session_at="$(log_pos ' run --rm ')"
|
||||
rm_at="$(log_pos "rm -f dejima-filter-$id")"
|
||||
[ "$session_at" -lt "$rm_at" ] \
|
||||
|| { echo "the filter container was removed before the session ran"; return 1; }
|
||||
}
|
||||
|
||||
@test "the filter container runs the base image, never a project's derived one" {
|
||||
# It is the privileged half of the pair. A project's declared dependencies
|
||||
# are installed into the derived image for the session to use, and there is
|
||||
# no reason for any of them to be present in the container holding
|
||||
# NET_ADMIN.
|
||||
stub_launch
|
||||
launch "$PROJ"
|
||||
|
||||
local run; run="$(log_line 'run -d --init')"
|
||||
[[ "$run" =~ localhost/dejima:[^[:space:]-]+$ ]] \
|
||||
|| { echo "the filter container's image is not the plain base tag: $run"; return 1; }
|
||||
}
|
||||
|
||||
@test "the filter container declares the network capabilities and drops the rest" {
|
||||
stub_launch
|
||||
launch "$PROJ"
|
||||
|
||||
local run; run="$(log_line 'run -d --init')"
|
||||
[[ "$run" == *"--cap-drop ALL"* ]] \
|
||||
|| { echo "the filter container keeps Docker's default set: $run"; return 1; }
|
||||
[[ "$run" == *"--security-opt no-new-privileges:true"* ]] \
|
||||
|| { echo "no-new-privileges is missing from the filter container: $run"; return 1; }
|
||||
local cap
|
||||
for cap in NET_ADMIN NET_RAW KILL SETPCAP; do
|
||||
[[ "$run" == *"--cap-add $cap"* ]] \
|
||||
|| { echo "the filter container cannot do its job without $cap: $run"; return 1; }
|
||||
done
|
||||
}
|
||||
|
||||
@test "every variable the firewall path reads is passed to the filter container" {
|
||||
# The drift guard this arrangement needs most. The sandbox gets its
|
||||
# environment from docker-compose.yml; the filter container is started with
|
||||
# `docker run` and gets nothing that is not named on that command line.
|
||||
#
|
||||
# So a firewall script that starts reading a new variable does not fail - it
|
||||
# reads an empty one, and allowlists less than it was told to, which
|
||||
# presents as a domain the operator approved being unreachable rather than
|
||||
# as anything about this. Derived from the scripts rather than listed here,
|
||||
# so the test cannot drift from them either.
|
||||
stub_launch
|
||||
launch "$PROJ"
|
||||
local run; run="$(log_line 'run -d --init')"
|
||||
[ -n "$run" ]
|
||||
|
||||
local v missing=""
|
||||
while read -r v; do
|
||||
[ -n "$v" ] || continue
|
||||
[[ "$run" == *"-e $v"* ]] || missing="$missing $v"
|
||||
done < <(cat "$SANDBOX_DIR/firewall-up.sh" "$SANDBOX_DIR/dns-proxy.sh" \
|
||||
"$SANDBOX_DIR/init-firewall.sh" "$SANDBOX_DIR/fixed-domains.sh" \
|
||||
"$SANDBOX_DIR/log-shipper.sh" \
|
||||
| grep -oE '\$\{?(ALLOW[A-Z_]*|DEJIMA_[A-Z_]*|ENABLE_[A-Z_]*|GH_TOKEN|LOKI_URL|SANDBOX_[A-Z_]*)' \
|
||||
| tr -d '${' | sort -u \
|
||||
| grep -vE '^(EXTRA_ALLOWED_DOMAINS|SANDBOX_TEST.*)$')
|
||||
|
||||
[ -z "$missing" ] \
|
||||
|| { echo "the firewall path reads these, and the filter container is not given them:$missing"; return 1; }
|
||||
|
||||
# And the other half of the same failure, which the flag alone cannot show.
|
||||
# `docker run -e NAME` takes the value from the launcher's environment, so
|
||||
# a name that is assigned but never exported passes an empty one - the
|
||||
# filter container then allowlists less than it was told to, and what an
|
||||
# operator sees is a domain they approved being unreachable, with nothing
|
||||
# pointing here.
|
||||
#
|
||||
# Not hypothetical: `export DEJIMA_FORGE_HOSTS` is a line that had to be
|
||||
# added by hand when the filter container was introduced, because until
|
||||
# then that variable had only ever needed to reach the sandbox as
|
||||
# `-e NAME=value`, which carries its own value.
|
||||
#
|
||||
# Continuations are flattened first, because the exports here run to
|
||||
# several lines; and `export` is matched anywhere on a line rather than at
|
||||
# its start, because two of these are exported after a `;` on the line that
|
||||
# assigns them.
|
||||
local exported unexported=""
|
||||
exported="$(sed -z 's/\\\n[[:space:]]*/ /g' "$REPO_ROOT/bin/dejima" \
|
||||
| grep -oE 'export [A-Za-z_][A-Za-z0-9_ ]*')"
|
||||
[ -n "$exported" ] || { echo "found no exports in bin/dejima at all"; return 1; }
|
||||
|
||||
while read -r v; do
|
||||
[ -n "$v" ] || continue
|
||||
grep -qE "(^|[[:space:]])$v([[:space:]]|\$)" <<< "$exported" \
|
||||
|| unexported="$unexported $v"
|
||||
done < <(grep -oE '^\s+-e [A-Z_]+' "$REPO_ROOT/bin/dejima" | awk '{print $2}' | sort -u)
|
||||
|
||||
[ -z "$unexported" ] \
|
||||
|| { echo "passed as -e NAME but never exported, so the value is empty:$unexported"; return 1; }
|
||||
}
|
||||
|
||||
# A `docker` that reports one filter container and one network, with whatever
|
||||
# age, joiner and identity a test needs, and records every removal.
|
||||
#
|
||||
# Each of these tests is about a branch that decides NOT to remove something,
|
||||
# and a reconciler that removed nothing at all would pass every one of them -
|
||||
# so the first test here is the one that proves the mechanism fires.
|
||||
stub_orphan() { # <age-seconds> <joined-by|"">
|
||||
local age="$1" joiner="$2"
|
||||
stub_launch
|
||||
# Each query answers in the format Docker really answers it in, and the two
|
||||
# differ: `Created` is a string field on a container and a time.Time on a
|
||||
# network, so a template prints the second through String() and appends a
|
||||
# timezone NAME. One format for both is what let the network half of the
|
||||
# reconciler pass a test while reaping nothing.
|
||||
cat > "$LAUNCH_DIR/docker" <<EOF
|
||||
#!/bin/bash
|
||||
echo "\$*" >> "\${SANDBOX_TEST_DOCKER_LOG:-/dev/null}"
|
||||
epoch=\$(( \$(date +%s) - $age ))
|
||||
made=\$(date -u -d "@\$epoch" +%Y-%m-%dT%H:%M:%S.000000000Z)
|
||||
made_go=\$(date -u -d "@\$epoch" '+%Y-%m-%d %H:%M:%S.000000000 +0000 UTC')
|
||||
case "\$*" in
|
||||
# The running containers, and what namespace each is in.
|
||||
"ps -q") [ -n "$joiner" ] && echo j0 ; exit 0 ;;
|
||||
*HostConfig.NetworkMode*) [ -n "$joiner" ] && echo "container:$joiner" ; exit 0 ;;
|
||||
# The candidates.
|
||||
*"ps -aq --filter label=dejima.filter"*) echo orphan0 ; exit 0 ;;
|
||||
*"{{.Id}} {{.Name}} {{.Created}}"*) echo "orphan0 /dejima-filter-old \$made" ; exit 0 ;;
|
||||
*"network ls -q --filter label=dejima.run"*) echo net0 ; exit 0 ;;
|
||||
*"{{.Name}} {{.Created}}"*) echo "dejima-run-old \$made_go" ; exit 0 ;;
|
||||
esac
|
||||
exit 0
|
||||
EOF
|
||||
chmod +x "$LAUNCH_DIR/docker"
|
||||
}
|
||||
|
||||
@test "a filter container nothing is joined to, left over from before, is removed" {
|
||||
stub_orphan 6000 ""
|
||||
launch "$PROJ"
|
||||
grep -q -- 'rm -f orphan0' "$LAUNCH_DIR/docker-log" \
|
||||
|| { echo "the orphaned filter container was left running"; return 1; }
|
||||
grep -q -- 'network rm dejima-run-old' "$LAUNCH_DIR/docker-log" \
|
||||
|| { echo "its network was left behind"; return 1; }
|
||||
}
|
||||
|
||||
@test "the reconcile says what it removed, so a leak is not silently routine" {
|
||||
stub_orphan 6000 ""
|
||||
run launch "$PROJ"
|
||||
[[ "$output" == *"left behind"* ]] \
|
||||
|| { echo "an orphan was removed with nothing said about it: $output"; return 1; }
|
||||
}
|
||||
|
||||
@test "a filter container with a session still in its namespace is left alone" {
|
||||
# The one that matters on a host running several sessions: reaping this is
|
||||
# taking the network out from under somebody's running agent.
|
||||
stub_orphan 6000 orphan0
|
||||
launch "$PROJ"
|
||||
run grep -c -- 'rm -f orphan0' "$LAUNCH_DIR/docker-log"
|
||||
[ "$output" = "0" ] \
|
||||
|| { echo "a filter container with a live session was removed"; return 1; }
|
||||
}
|
||||
|
||||
@test "a filter container younger than the grace is left alone" {
|
||||
# A pair being built right now by a concurrent launch: between `docker run`
|
||||
# for the filter and `docker compose run` for the session, a healthy filter
|
||||
# container has no joiner. Age is what tells that apart from abandonment.
|
||||
stub_orphan 5 ""
|
||||
launch "$PROJ"
|
||||
run grep -c -- 'rm -f orphan0' "$LAUNCH_DIR/docker-log"
|
||||
[ "$output" = "0" ] \
|
||||
|| { echo "a filter container still being paired was removed"; return 1; }
|
||||
run grep -c -- 'network rm dejima-run-old' "$LAUNCH_DIR/docker-log"
|
||||
[ "$output" = "0" ] \
|
||||
|| { echo "a network still being attached to was removed"; return 1; }
|
||||
}
|
||||
|
||||
@test "a docker that cannot answer the reconcile does not fail the launch" {
|
||||
# It runs before the launch's own network and container, so an error here
|
||||
# would refuse a session over tidying up. The launch that follows reports
|
||||
# its own failures.
|
||||
stub_launch
|
||||
cat > "$LAUNCH_DIR/docker" <<'EOF'
|
||||
#!/bin/bash
|
||||
echo "$*" >> "${SANDBOX_TEST_DOCKER_LOG:-/dev/null}"
|
||||
case "$*" in
|
||||
ps*|*inspect*|*network\ ls*) echo "Cannot connect to the Docker daemon" >&2; exit 1 ;;
|
||||
esac
|
||||
exit 0
|
||||
EOF
|
||||
chmod +x "$LAUNCH_DIR/docker"
|
||||
run launch "$PROJ"
|
||||
[ "$status" -eq 0 ] \
|
||||
|| { echo "a failing reconcile refused the launch: $output"; return 1; }
|
||||
grep -q -- ' run --rm ' "$LAUNCH_DIR/docker-log" \
|
||||
|| { echo "the session never started"; return 1; }
|
||||
}
|
||||
+75
-1
@@ -31,6 +31,11 @@ if [ "\$1" = ps ]; then
|
||||
exit 0
|
||||
fi
|
||||
case "\$*" in
|
||||
# A session container shares a filter container's network namespace, and
|
||||
# that is where the dnsmasq and the ipsets are. dejima-allow derives the
|
||||
# one from the other rather than being told, so a stub that answers this
|
||||
# with nothing is a session with no filter to add a domain to.
|
||||
*inspect*NetworkMode*) echo "container:dejima-test-filter"; exit 0 ;;
|
||||
*dig*) echo 10.0.0.1; exit 0 ;;
|
||||
*curl*) exit $2 ;;
|
||||
*) exit 0 ;;
|
||||
@@ -651,6 +656,66 @@ EOF
|
||||
grep -qx 'crates.io' "$(store_of "$PROJ")/allowed-domains"
|
||||
}
|
||||
|
||||
@test "dejima-allow takes the filter container's id, which is what a session reads" {
|
||||
# A session asked for its own container id runs `cat /etc/hostname`, and
|
||||
# Docker names a container that joined another's network namespace after
|
||||
# the container it joined - so the id an agent hands its operator is the
|
||||
# FILTER container's. That id has to work, or the one command every
|
||||
# briefing tells an agent to ask for fails on the id the briefing gave it.
|
||||
STUB_DIR="$(mktemp -d)"
|
||||
cat > "$STUB_DIR/docker" <<'EOF'
|
||||
#!/bin/bash
|
||||
echo "$*" >> "${DOCKER_CALL_LOG:-/dev/null}"
|
||||
case "$*" in
|
||||
# Named container is a filter: it owns a network rather than joining one.
|
||||
*inspect*NetworkMode*c1*) echo "dejima-run-abcd" ;;
|
||||
*inspect*'{{.Id}}'*c1*) echo "filterid" ;;
|
||||
# And one running container is in its namespace.
|
||||
"ps -q") echo sessionid ;;
|
||||
*'{{.Id}} {{.HostConfig.NetworkMode}}'*) echo "sessionid container:filterid" ;;
|
||||
*dig*) echo 10.0.0.1 ;;
|
||||
esac
|
||||
exit 0
|
||||
EOF
|
||||
chmod +x "$STUB_DIR/docker"
|
||||
PATH="$STUB_DIR:$PATH" DOCKER_CALL_LOG="$STUB_DIR/calls" \
|
||||
run "$REPO_ROOT/bin/dejima-allow" --container c1 crates.io
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
|
||||
# Asserted on the calls rather than the messages: which container each
|
||||
# half runs in is the whole point, and a message could name either while
|
||||
# the exec went to the wrong one.
|
||||
grep -q 'exec -u root c1 ipset add' "$STUB_DIR/calls" \
|
||||
|| { echo "the ipset add did not go to the filter container:"; cat "$STUB_DIR/calls"; return 1; }
|
||||
grep -q 'exec sessionid curl' "$STUB_DIR/calls" \
|
||||
|| { echo "the probe did not run in the session container:"; cat "$STUB_DIR/calls"; return 1; }
|
||||
rm -rf "$STUB_DIR"
|
||||
}
|
||||
|
||||
@test "dejima-allow refuses a container that is neither half of a pair" {
|
||||
# A launch predating the split has a namespace of its own and no filter
|
||||
# container in it. The useful answer is what to do instead, not a docker
|
||||
# exec that fails somewhere inside a heredoc.
|
||||
STUB_DIR="$(mktemp -d)"
|
||||
cat > "$STUB_DIR/docker" <<'EOF'
|
||||
#!/bin/bash
|
||||
case "$*" in
|
||||
*inspect*NetworkMode*) echo "bridge"; exit 0 ;;
|
||||
*inspect*'{{.Id}}'*) echo "loneid"; exit 0 ;;
|
||||
"ps -q") exit 0 ;;
|
||||
esac
|
||||
exit 0
|
||||
EOF
|
||||
chmod +x "$STUB_DIR/docker"
|
||||
PATH="$STUB_DIR:$PATH" run "$REPO_ROOT/bin/dejima-allow" --container c1 crates.io
|
||||
[ "$status" -ne 0 ]
|
||||
[[ "$output" == *"neither a running session nor the filter container"* ]] \
|
||||
|| { echo "$output"; return 1; }
|
||||
[[ "$output" == *"dejima-allow --project crates.io"* ]] \
|
||||
|| { echo "the refusal does not say what to do instead: $output"; return 1; }
|
||||
rm -rf "$STUB_DIR"
|
||||
}
|
||||
|
||||
@test "dejima-allow --global persists without needing a container" {
|
||||
run "$REPO_ROOT/bin/dejima-allow" --global example.com
|
||||
[ "$status" -eq 0 ]
|
||||
@@ -704,6 +769,7 @@ stub_docker_rebind() {
|
||||
cat > "$STUB_DIR/docker" <<'EOF'
|
||||
#!/bin/bash
|
||||
case "$*" in
|
||||
*inspect*NetworkMode*) echo "container:dejima-test-filter"; exit 0 ;;
|
||||
*dig*@127.0.0.11*) echo 10.0.0.1 ;;
|
||||
esac
|
||||
exit 0
|
||||
@@ -737,7 +803,15 @@ EOF
|
||||
# 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"
|
||||
# Answers where the filter container is and nothing else, so every lookup
|
||||
# comes back empty - which is the case this is about.
|
||||
cat > "$STUB_DIR/docker" <<'EOF'
|
||||
#!/bin/bash
|
||||
case "$*" in
|
||||
*inspect*NetworkMode*) echo "container:dejima-test-filter" ;;
|
||||
esac
|
||||
exit 0
|
||||
EOF
|
||||
chmod +x "$STUB_DIR/docker"
|
||||
run env PATH="$STUB_DIR:$PATH" \
|
||||
"$REPO_ROOT/bin/dejima-allow" --container c1 nowhere.example.com
|
||||
|
||||
+197
-11
@@ -292,8 +292,8 @@ teardown() {
|
||||
|
||||
@test "forwarder bridges TCP to the proxy's unix socket" {
|
||||
start_proxy
|
||||
SANDBOX_ANTHROPIC_PROXY_SOCKET="$SOCK" SANDBOX_ANTHROPIC_PROXY_PORT=18399 \
|
||||
SANDBOX_ANTHROPIC_TRANSPORT=loopback \
|
||||
SANDBOX_CREDENTIAL_PROXY_SOCKET="$SOCK" SANDBOX_CREDENTIAL_PROXY_PORT=18399 \
|
||||
SANDBOX_CREDENTIAL_TRANSPORT=loopback \
|
||||
node "$REPO_ROOT/docker/credential-proxy-forwarder.js" &
|
||||
FWD_PID=$!
|
||||
ok=""
|
||||
@@ -358,11 +358,11 @@ PYEOF
|
||||
# ANTHROPIC_BASE_URL at all, then pipes raw bytes into the same unix socket
|
||||
# the loopback mode uses - the host proxy cannot tell the two apart.
|
||||
start_forwarder() { # start_forwarder <transport> <port>; extra env passes through
|
||||
SANDBOX_ANTHROPIC_PROXY_SOCKET="$SOCK" \
|
||||
SANDBOX_ANTHROPIC_TRANSPORT="$1" \
|
||||
SANDBOX_ANTHROPIC_PROXY_PORT="$2" \
|
||||
SANDBOX_ANTHROPIC_TLS_CERT="$TD/cert.pem" \
|
||||
SANDBOX_ANTHROPIC_TLS_KEY="$TD/key.pem" \
|
||||
SANDBOX_CREDENTIAL_PROXY_SOCKET="$SOCK" \
|
||||
SANDBOX_CREDENTIAL_TRANSPORT="$1" \
|
||||
SANDBOX_CREDENTIAL_PROXY_PORT="$2" \
|
||||
SANDBOX_CREDENTIAL_TLS_CERT="$TD/cert.pem" \
|
||||
SANDBOX_CREDENTIAL_TLS_KEY="$TD/key.pem" \
|
||||
node "$REPO_ROOT/docker/credential-proxy-forwarder.js" 2>"$TD/fwd-stderr" &
|
||||
FWD_PID=$!
|
||||
for _ in $(seq 1 50); do
|
||||
@@ -435,12 +435,12 @@ make_cert() {
|
||||
#
|
||||
# The port override is belt-and-braces: the cert check precedes the listen
|
||||
# today, so a fall-through lands on an unused port if that ever changes.
|
||||
run env -u SANDBOX_ANTHROPIC_TLS_CERT -u SANDBOX_ANTHROPIC_TLS_KEY \
|
||||
SANDBOX_ANTHROPIC_PROXY_SOCKET="$SOCK" SANDBOX_ANTHROPIC_TRANSPORT=tls \
|
||||
SANDBOX_ANTHROPIC_PROXY_PORT=18398 \
|
||||
run env -u SANDBOX_CREDENTIAL_TLS_CERT -u SANDBOX_CREDENTIAL_TLS_KEY \
|
||||
SANDBOX_CREDENTIAL_PROXY_SOCKET="$SOCK" SANDBOX_CREDENTIAL_TRANSPORT=tls \
|
||||
SANDBOX_CREDENTIAL_PROXY_PORT=18398 \
|
||||
node "$REPO_ROOT/docker/credential-proxy-forwarder.js"
|
||||
[ "$status" -ne 0 ]
|
||||
[[ "$output" == *"SANDBOX_ANTHROPIC_TLS_CERT"* ]]
|
||||
[[ "$output" == *"SANDBOX_CREDENTIAL_TLS_CERT"* ]]
|
||||
}
|
||||
|
||||
@test "an oversized request body is refused rather than read into host RAM" {
|
||||
@@ -630,3 +630,189 @@ start_static() {
|
||||
[[ "$output" == *"oauth-endpoint"* ]]
|
||||
[ ! -S "$SOCK" ]
|
||||
}
|
||||
|
||||
# The proxy is the only thing that sees both the request the session made and
|
||||
# the answer the API gave. It forwarded a rejection verbatim and recorded
|
||||
# nothing, so a session that spun and then errored left the operator with
|
||||
# nothing to act on - reported from a real cline launch against the anthropic
|
||||
# backend.
|
||||
|
||||
@test "an upstream rejection is recorded, and still reaches the session whole" {
|
||||
start_proxy --log "$TD/proxy.log"
|
||||
run pcurl -i "http://localhost/status-503"
|
||||
[ "$status" -eq 0 ]
|
||||
# /status-503 answers without a Content-Length. The first version read the
|
||||
# body only where upstream declared a small one, and the first real
|
||||
# rejection it met - a rate limiter - recorded the status and nothing
|
||||
# else, which is the half an operator cannot act on.
|
||||
# Byte for byte: what the session sees is what the API said, not the
|
||||
# proxy's summary of it.
|
||||
[[ "$output" == *"503"* ]] || { echo "$output"; return 1; }
|
||||
[[ "$output" == *"upstream-error"* ]] \
|
||||
|| { echo "the body did not reach the client: $output"; return 1; }
|
||||
[ -s "$TD/proxy.log" ] || { echo "nothing was recorded"; return 1; }
|
||||
grep -q '503' "$TD/proxy.log" || { echo "$(cat "$TD/proxy.log")"; return 1; }
|
||||
grep -q 'upstream-error' "$TD/proxy.log" \
|
||||
|| { echo "recorded the status without the reason: $(cat "$TD/proxy.log")"; return 1; }
|
||||
}
|
||||
|
||||
@test "a request the API accepted records nothing at all" {
|
||||
# The success path must stay silent, and must not read the body to decide:
|
||||
# logging every request would put the operator's traffic in a file and
|
||||
# would buffer a streaming response to do it.
|
||||
start_proxy --log "$TD/proxy.log"
|
||||
run pcurl "http://localhost/v1/messages" -d '{"model":"x"}'
|
||||
[ "$status" -eq 0 ]
|
||||
[ ! -s "$TD/proxy.log" ] \
|
||||
|| { echo "a successful request was logged: $(cat "$TD/proxy.log")"; return 1; }
|
||||
}
|
||||
|
||||
@test "with no log file the diagnostics still go somewhere" {
|
||||
# A proxy run by hand has no file to read afterwards, so stderr is right
|
||||
# there. Losing the message because no --log was passed would make the
|
||||
# by-hand case the one with no diagnostics.
|
||||
start_proxy
|
||||
run pcurl "http://localhost/status-503"
|
||||
[ "$status" -eq 0 ]
|
||||
grep -q '503' "$TD/stderr" \
|
||||
|| { echo "nothing on stderr: $(cat "$TD/stderr")"; return 1; }
|
||||
}
|
||||
|
||||
@test "a rate limiter's retry-after is named, not left among the headers" {
|
||||
# Three 429s four seconds apart is what a real launch produced, and the
|
||||
# actionable part of one is not in the body.
|
||||
start_proxy --log "$TD/proxy.log"
|
||||
run pcurl -i "http://localhost/status-429"
|
||||
[ "$status" -eq 0 ]
|
||||
grep -q 'retry-after=41' "$TD/proxy.log" \
|
||||
|| { echo "$(cat "$TD/proxy.log")"; return 1; }
|
||||
# The identifier Anthropic support asks for. A real rate-limited call came
|
||||
# back with a body of {"message":"Error"} - the word Error and nothing
|
||||
# else - so on that response this is the only actionable thing there is.
|
||||
grep -q 'request-id=req_011CehAZUPXFbQGikXfM3Mp6' "$TD/proxy.log" \
|
||||
|| { echo "no request id: $(cat "$TD/proxy.log")"; return 1; }
|
||||
# And not the ids that name the operator's org. This line gets pasted
|
||||
# into bug reports.
|
||||
grep -qi 'workspace\|organization' "$TD/proxy.log" \
|
||||
&& { echo "an account identifier reached the log"; return 1; }
|
||||
grep -q 'rate_limit_error' "$TD/proxy.log" \
|
||||
|| { echo "the body did not reach the log: $(cat "$TD/proxy.log")"; return 1; }
|
||||
# And the client still gets both.
|
||||
[[ "$output" == *"retry-after: 41"* ]] || { echo "$output"; return 1; }
|
||||
[[ "$output" == *"rate_limit_error"* ]] || { echo "$output"; return 1; }
|
||||
}
|
||||
|
||||
@test "a compressed rejection is logged as text, not as bytes" {
|
||||
# The client's own Accept-Encoding is forwarded verbatim, so a harness
|
||||
# that asks for gzip gets a compressed error body - and the first version
|
||||
# of this logging wrote those bytes out, which reached the operator as
|
||||
# mojibake and a bash warning about a null byte in a command
|
||||
# substitution. Reported from a real launch.
|
||||
start_proxy --log "$TD/proxy.log"
|
||||
run pcurl -i --compressed "http://localhost/status-429-gzip"
|
||||
[ "$status" -eq 0 ]
|
||||
grep -q 'slow down' "$TD/proxy.log" \
|
||||
|| { echo "the message did not survive decompression: $(cat "$TD/proxy.log")"; return 1; }
|
||||
# Nothing unprintable reaches the file at all. This is the half that has
|
||||
# to hold whatever the encoding: upstream text landing on the operator's
|
||||
# terminal is what dejima-audit already refuses to do unescaped.
|
||||
[ -z "$(tr -d '\11\12\15\40-\176' < "$TD/proxy.log")" ] \
|
||||
|| { echo "unprintable bytes reached the log"; od -c "$TD/proxy.log" | head -3; return 1; }
|
||||
# And the client still received the compressed bytes untouched.
|
||||
[[ "$output" == *"slow down"* ]] || { echo "$output"; return 1; }
|
||||
}
|
||||
|
||||
@test "an encoding the stdlib cannot read says so rather than printing bytes" {
|
||||
# br and zstd have no stdlib decoder. Naming the encoding is more use than
|
||||
# the bytes, and the bytes are the thing this exists to keep off a
|
||||
# terminal.
|
||||
run python3 -c '
|
||||
import sys; sys.path.insert(0, "'"$REPO_ROOT"'/bin")
|
||||
import importlib.util, importlib.machinery
|
||||
# The proxy has no .py suffix, so the loader has to be named rather than
|
||||
# inferred from the extension.
|
||||
loader = importlib.machinery.SourceFileLoader("p", "'"$REPO_ROOT"'/bin/dejima-credential-proxy")
|
||||
spec = importlib.util.spec_from_loader("p", loader)
|
||||
m = importlib.util.module_from_spec(spec); loader.exec_module(m)
|
||||
print(m._readable(b"\x1f\x8b\x00\x01\x02", "br"))
|
||||
print(m._readable(b"\x00\x01\x02not gzip at all", "gzip"))
|
||||
print(m._readable(b"plain \x00\x07text", ""))
|
||||
'
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
[[ "$output" == *"br-encoded"* ]] || { echo "$output"; return 1; }
|
||||
[[ "$output" == *"undecodable"* ]] || { echo "$output"; return 1; }
|
||||
# Control characters become spaces rather than being dropped, so a
|
||||
# message is not silently re-joined into a different one. The exact run
|
||||
# length is not the point; that none of them survives is.
|
||||
[[ "$output" == *"plain"*"text"* ]] || { echo "$output"; return 1; }
|
||||
}
|
||||
|
||||
@test "brotli is read when the host can, and named when it cannot" {
|
||||
# What a Node client actually negotiates. gzip was the first guess and
|
||||
# the real one came back br - so the encoding this could not read was the
|
||||
# common case, not an edge.
|
||||
#
|
||||
# Optional on purpose: this runs on the host as the operator, and a
|
||||
# diagnostic is not worth a hard dependency. So the test asserts whichever
|
||||
# half this host can demonstrate, and the note has to name the fix.
|
||||
run python3 -c '
|
||||
import sys, importlib.util, importlib.machinery
|
||||
l = importlib.machinery.SourceFileLoader("p", "'"$REPO_ROOT"'/bin/dejima-credential-proxy")
|
||||
m = importlib.util.module_from_spec(importlib.util.spec_from_loader("p", l)); l.exec_module(m)
|
||||
try:
|
||||
import brotli as b
|
||||
except ImportError:
|
||||
try:
|
||||
import brotlicffi as b
|
||||
except ImportError:
|
||||
b = None
|
||||
if b is None:
|
||||
print("ABSENT " + m._readable(b"\x1b\x0e\x00\xf8\x25", "br"))
|
||||
else:
|
||||
print("PRESENT " + m._readable(b.compress(b"slow down"), "br"))
|
||||
'
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
case "$output" in
|
||||
PRESENT*) [[ "$output" == *"slow down"* ]] \
|
||||
|| { echo "a decoder was available and the message did not survive: $output"; return 1; } ;;
|
||||
ABSENT*) [[ "$output" == *"br-encoded"* ]] \
|
||||
|| { echo "$output"; return 1; }
|
||||
# Naming the encoding is not enough on its own: the operator
|
||||
# is one install away from the message, and the line has to
|
||||
# say so.
|
||||
[[ "$output" == *"brotli"* ]] \
|
||||
|| { echo "the note does not say what would fix it: $output"; return 1; } ;;
|
||||
*) echo "unexpected: $output"; return 1 ;;
|
||||
esac
|
||||
# Either way, no bytes.
|
||||
[ -z "$(printf '%s' "$output" | tr -d '\11\12\15\40-\176')" ] \
|
||||
|| { echo "unprintable output survived"; return 1; }
|
||||
}
|
||||
|
||||
@test "nothing unprintable reaches the diagnostics file, whatever wrote it" {
|
||||
# Asserted at the sink rather than on one producer. bin/dejima prints this
|
||||
# file to the operator's terminal after a session ends, so it carries text
|
||||
# an upstream API chose and - through the request path and exception
|
||||
# messages - text the session chose. Filtering it where it is written
|
||||
# covers a producer added later; filtering it in one formatter does not.
|
||||
#
|
||||
# CPython refuses a control character in a URL today, in two places, and
|
||||
# reprs what it refuses. That is a coincidence this should not rest on.
|
||||
run python3 -c '
|
||||
import importlib.util, importlib.machinery, tempfile, os
|
||||
l = importlib.machinery.SourceFileLoader("p", "'"$REPO_ROOT"'/bin/dejima-credential-proxy")
|
||||
m = importlib.util.module_from_spec(importlib.util.spec_from_loader("p", l)); l.exec_module(m)
|
||||
d = tempfile.mkdtemp(); f = os.path.join(d, "log")
|
||||
m._diag(f, "path=/v1/\x1b[2J\x1b[1;31mred\x1b[0m\x07 null=\x00 tab=\t")
|
||||
raw = open(f, "rb").read()
|
||||
print("BYTES", sum(1 for b in raw if b < 32 and b != 10))
|
||||
print(raw.decode().strip())
|
||||
'
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
[[ "$output" == *"BYTES 0"* ]] \
|
||||
|| { echo "control bytes reached the file: $output"; return 1; }
|
||||
# And the text around them survives, so a sanitised line is still a
|
||||
# readable one rather than a row of spaces.
|
||||
[[ "$output" == *"path=/v1/"* ]] && [[ "$output" == *"red"* ]] \
|
||||
|| { echo "$output"; return 1; }
|
||||
}
|
||||
|
||||
+74
-11
@@ -10,13 +10,43 @@
|
||||
set -uo pipefail
|
||||
|
||||
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
base="${1:-main}"
|
||||
# The remote-tracking ref by default, not the local branch. Everything this
|
||||
# prints is derived from base..head - the manual checks from which paths the
|
||||
# diff touches, the rollback list from which config keys it adds - so a base
|
||||
# behind the real one silently reports a different release. That is not
|
||||
# hypothetical: two passes ran against a local `main` 260 commits behind
|
||||
# origin/main and reported keys and a trust-anchor domain that had shipped
|
||||
# releases earlier. A gate answering confidently about the wrong diff is worse
|
||||
# than one that does not run.
|
||||
base="${1:-}"
|
||||
if [ -z "$base" ]; then
|
||||
if git rev-parse --verify --quiet origin/main >/dev/null 2>&1; then
|
||||
base=origin/main
|
||||
else
|
||||
base=main
|
||||
fi
|
||||
fi
|
||||
head="${2:-HEAD}"
|
||||
# Guarded, because everything below is relative to it: a cd that failed
|
||||
# silently would have this reporting on some other directory's suites and
|
||||
# diff, which for a release gate is the worst way to be wrong.
|
||||
cd "$root" || { echo "cannot enter $root" >&2; exit 1; }
|
||||
|
||||
# A named base that is behind its own upstream is refused rather than used.
|
||||
# Passing `main` explicitly is the ordinary thing to do and is exactly how the
|
||||
# stale-base runs happened, so this has to catch the argument as well as the
|
||||
# default.
|
||||
base_upstream="$(git rev-parse --abbrev-ref --symbolic-full-name "$base@{upstream}" 2>/dev/null || true)"
|
||||
if [ -n "$base_upstream" ]; then
|
||||
behind="$(git rev-list --count "$base..$base_upstream" 2>/dev/null || echo 0)"
|
||||
if [ "${behind:-0}" -gt 0 ]; then
|
||||
echo "release-pass: '$base' is $behind commit(s) behind $base_upstream." >&2
|
||||
echo "Everything below is derived from $base..$head, so this would report on" >&2
|
||||
echo "a release that is not the one being made. Fetch, or name $base_upstream." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
case "${1:-}" in
|
||||
-h|--help)
|
||||
echo "Usage: test/release-pass.sh [base] [head]"
|
||||
@@ -65,9 +95,11 @@ if docker info >/dev/null 2>&1; then
|
||||
else
|
||||
fail "no Docker here - test/runtime.bats did NOT run"
|
||||
echo " It is the only thing that exercises mounts, the firewall and"
|
||||
echo " the entrypoint end to end. Run it on the host, or check that"
|
||||
echo " the privileged CI job RAN rather than queued: it is pinned to"
|
||||
echo " one self-hosted runner and queues silently when that is down."
|
||||
echo " the entrypoint end to end. Run it on the host, or read the"
|
||||
echo " pull request's checks: GREEN means every job finished and"
|
||||
echo " passed, this one among them. Yellow or grey means the"
|
||||
echo " privileged job is still queued - it is pinned to one"
|
||||
echo " self-hosted runner and queues silently when that is down."
|
||||
fi
|
||||
|
||||
# --- what a person has to do, chosen by what the release touches -------------
|
||||
@@ -105,6 +137,9 @@ touches '^(harnesses/|bin/harness/|docker/harness-wrapper)' &&
|
||||
manual "Launch each affected harness against a real model."
|
||||
touches '^(bin/dejima-attach|bin/dejima-ttyd|systemd/)' &&
|
||||
manual "Attach over the web front door and detach again."
|
||||
touches '^tools/' &&
|
||||
manual "Launch with the affected tool included and use it. A tool is
|
||||
layered into the image and put on PATH; nothing here runs one."
|
||||
touches '^\.dejima/allowed-domains$' &&
|
||||
manual "A domain here is read from main by the CI hardener and widens every
|
||||
future CI job on that runner. Say in the PR what it is for."
|
||||
@@ -133,10 +168,29 @@ added="$(comm -13 \
|
||||
# The singular is spelled out rather than derived: "harnesses" does not lose
|
||||
# an s to become "harness", and a manifest is named for the singular.
|
||||
#
|
||||
# Every axis, including backends: the newest one is exactly the one a stale
|
||||
# list would miss, because a list is only ever updated by whoever remembers.
|
||||
# Every axis there is, and the list is checked against the tree below rather
|
||||
# than trusted: the newest axis is exactly the one a hand-kept list misses,
|
||||
# because a list is only ever updated by whoever remembers. tools is an axis
|
||||
# like the rest - `[core] tools = rtk` on a dejima that does not ship rtk is
|
||||
# refused the same way an unknown harness is.
|
||||
DEJIMA_RELEASE_AXES="harnesses:harness forges:forge workflows:workflow backends:backend tools:tool"
|
||||
# The singular is spelled out above rather than derived - "harnesses" does not
|
||||
# lose an s to become "harness", and a manifest is named for the singular - so
|
||||
# this is the check that the spelling out has not fallen behind the tree.
|
||||
for _d in */; do
|
||||
_d="${_d%/}"
|
||||
case "$_d" in
|
||||
harnesses|forges|workflows|backends|tools) ;;
|
||||
*) continue ;;
|
||||
esac
|
||||
case " $DEJIMA_RELEASE_AXES " in
|
||||
*" $_d:"*) ;;
|
||||
*) fail "$_d/ is a provider axis this script does not check for rollback" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
new_providers=""
|
||||
for pair in harnesses:harness forges:forge workflows:workflow backends:backend; do
|
||||
for pair in $DEJIMA_RELEASE_AXES; do
|
||||
axis="${pair%%:*}"
|
||||
one="${pair#*:}"
|
||||
[ -d "$axis" ] || continue
|
||||
@@ -153,7 +207,7 @@ done
|
||||
# backend` is one: adopting it is what makes the rollback refuse, and nothing
|
||||
# said so.
|
||||
new_keys=""
|
||||
for pair in harnesses:harness forges:forge workflows:workflow backends:backend; do
|
||||
for pair in $DEJIMA_RELEASE_AXES; do
|
||||
axis="${pair%%:*}"
|
||||
one="${pair#*:}"
|
||||
[ -d "$axis" ] || continue
|
||||
@@ -172,7 +226,15 @@ done
|
||||
|
||||
if [ -n "$added" ] || [ -n "$new_providers" ] || [ -n "$new_keys" ]; then
|
||||
[ -z "$added" ] || printf ' %s\n' $added
|
||||
[ -z "$new_providers" ] || printf ' [%s] as a config section\n' $new_providers
|
||||
# How an older dejima refuses a new provider differs by axis, and saying
|
||||
# "as a config section" for all of them was wrong for tools: a tool has no
|
||||
# keys and so no section. It is refused through the value that names it.
|
||||
for entry in $new_providers; do
|
||||
case "$entry" in
|
||||
tool.*) printf ' [core] tools = %s\n' "${entry#tool.}" ;;
|
||||
*) printf ' [%s] as a config section\n' "$entry" ;;
|
||||
esac
|
||||
done
|
||||
for entry in $new_keys; do
|
||||
printf ' %s %s\n' "${entry%%|*}" "${entry#*|}"
|
||||
done
|
||||
@@ -181,6 +243,7 @@ else
|
||||
fi
|
||||
|
||||
note "Then"
|
||||
echo " Say in the release PR what was run and what was not. A privileged job"
|
||||
echo " that queued is not a job that passed."
|
||||
echo " Say in the release PR what was run and what was not. Green checks mean"
|
||||
echo " the privileged job ran; a queued one is yellow or grey, and is not a"
|
||||
echo " job that passed."
|
||||
exit "$status"
|
||||
|
||||
+73
-22
@@ -215,6 +215,30 @@ run_line() { grep -- ' run --rm ' "$LAUNCH_DIR/docker-log" | tail -1; }
|
||||
rm -rf "$PROJ"
|
||||
}
|
||||
|
||||
@test "an inherited state dir and run id are not what the exit trap deletes" {
|
||||
# cleanup() removes "$DEJIMA_STATE_DIR/debounce/$DEJIMA_RUN_ID-{waiting,done}".
|
||||
# With remote access off nothing used to assign either name, so both came
|
||||
# from the caller's environment if it had them - and the trap then removed
|
||||
# a path of the environment's choosing. Both are bound by dejima before the
|
||||
# trap is installed now, so the join can only ever name a path it made.
|
||||
#
|
||||
# Two canaries, because either name alone being dejima's is enough to miss
|
||||
# the path: the removal needs both halves to come from the environment.
|
||||
stub_launch
|
||||
local canary="$LAUNCH_DIR/canary"
|
||||
mkdir -p "$canary/debounce"
|
||||
: > "$canary/debounce/deadbeefdeadbeef-waiting"
|
||||
: > "$canary/debounce/deadbeefdeadbeef-done"
|
||||
|
||||
DEJIMA_STATE_DIR="$canary" DEJIMA_RUN_ID=deadbeefdeadbeef launch "$PROJ"
|
||||
|
||||
[ -f "$canary/debounce/deadbeefdeadbeef-waiting" ] \
|
||||
|| { echo "the trap removed a file named by the environment"; return 1; }
|
||||
[ -f "$canary/debounce/deadbeefdeadbeef-done" ] \
|
||||
|| { echo "the trap removed a file named by the environment"; return 1; }
|
||||
rm -rf "$PROJ"
|
||||
}
|
||||
|
||||
@test "with remote access configured a registry entry is written and removed on exit" {
|
||||
stub_launch
|
||||
write_remote_config
|
||||
@@ -420,11 +444,21 @@ EOF
|
||||
write_remote_config
|
||||
# The EXIT trap removes the entry, so snapshot it from inside the run:
|
||||
# the stub stands in for the container, and runs while it still exists.
|
||||
#
|
||||
# Matched on the session's own compose run rather than on every docker
|
||||
# call. A launch makes several - a build, this launch's filter container,
|
||||
# and two removals from the exit trap - and snapshotting on each meant the
|
||||
# last one won. Those two removals run after the entry is gone, so what
|
||||
# this read was an empty directory.
|
||||
cat > "$LAUNCH_DIR/docker" <<EOF
|
||||
#!/bin/bash
|
||||
echo "\$*" >> "\${SANDBOX_TEST_DOCKER_LOG:-/dev/null}"
|
||||
rm -rf "$LAUNCH_DIR/sessions-snapshot"
|
||||
cp -r "$RUNTIME_DIR/dejima/sessions" "$LAUNCH_DIR/sessions-snapshot" 2>/dev/null || true
|
||||
case "\$*" in
|
||||
*" run --rm "*)
|
||||
rm -rf "$LAUNCH_DIR/sessions-snapshot"
|
||||
cp -r "$RUNTIME_DIR/dejima/sessions" "$LAUNCH_DIR/sessions-snapshot" 2>/dev/null || true
|
||||
;;
|
||||
esac
|
||||
exit 0
|
||||
EOF
|
||||
chmod +x "$LAUNCH_DIR/docker"
|
||||
@@ -2734,10 +2768,14 @@ nginx_blocks() {
|
||||
}
|
||||
|
||||
@test "install.sh links the remote scripts and the notify hook, registers neither, and enables nothing" {
|
||||
# The hook is linked by the harness plugin now - which file it is, and
|
||||
# where the harness reads it from, is the harness's business - while the
|
||||
# CLIs stay install.sh's, being dejima's own.
|
||||
grep -q '_link_hook dejima-notify.sh' "$REPO_ROOT/bin/harness/claude-code.sh"
|
||||
# The hook is baked into the image and registered by absolute path, so
|
||||
# install.sh puts nothing of it on the host - which file it is, and where
|
||||
# a session reads it from, is the harness plugin's business. The CLIs stay
|
||||
# install.sh's, being dejima's own.
|
||||
grep -q 'COPY hooks/dejima-notify.sh /usr/local/lib/dejima/hooks/dejima-notify.sh' \
|
||||
"$REPO_ROOT/docker/Dockerfile"
|
||||
grep -q '_link_hook' "$REPO_ROOT/bin/harness/claude-code.sh" \
|
||||
&& { echo "install.sh still links hooks into ~/.claude/hooks"; return 1; }
|
||||
grep -q '^link_bin dejima-attach$' "$REPO_ROOT/install.sh"
|
||||
grep -q '^link_bin dejima-ttyd$' "$REPO_ROOT/install.sh"
|
||||
grep -q '^link_bin dejima-notify-watch$' "$REPO_ROOT/install.sh"
|
||||
@@ -2769,13 +2807,25 @@ nginx_blocks() {
|
||||
# $output, and this is a statement about what THIS run printed.
|
||||
[[ "$output" == *"nothing is listening"* ]]
|
||||
|
||||
# bin/dejima registers $HOME/.claude/hooks/dejima-notify.sh per session,
|
||||
# and ~/.claude/hooks is what gets mounted read-only into the container.
|
||||
# Without this link every notification hook is an exit-127.
|
||||
[ -L "$FAKE_HOME/.claude/hooks/dejima-notify.sh" ]
|
||||
[ "$(readlink -f "$FAKE_HOME/.claude/hooks/dejima-notify.sh")" \
|
||||
= "$(readlink -f "$REPO_ROOT/hooks/dejima-notify.sh")" ]
|
||||
[ -x "$FAKE_HOME/.claude/hooks/dejima-notify.sh" ]
|
||||
# The notify hook is baked into the image and registered per session by
|
||||
# its absolute path there, so nothing about it lands on the host. Without
|
||||
# the baked copy every notification hook would be an exit-127.
|
||||
[ ! -e "$FAKE_HOME/.claude/hooks/dejima-notify.sh" ] \
|
||||
|| { echo "install.sh still puts the notify hook on the host"; return 1; }
|
||||
# Asserted on what the registration PRODUCES rather than on the source
|
||||
# text: the path is a variable there, and a grep for the literal passes or
|
||||
# fails on how it happens to be spelled rather than on where it points.
|
||||
local st; st="$(mktemp)"; echo '{}' > "$st"
|
||||
bash -c '
|
||||
source "'"$REPO_ROOT"'/bin/dejima-provider-lib.sh"
|
||||
source "'"$REPO_ROOT"'/bin/dejima-harness-lib.sh"
|
||||
harness_plugin_load claude-code
|
||||
harness_hook harness_register_notify "$1"
|
||||
' _ "$st" >/dev/null 2>&1
|
||||
[ "$(jq -r '.hooks.Stop[0].hooks[0].command' "$st")" \
|
||||
= "/usr/local/lib/dejima/hooks/dejima-notify.sh done" ] \
|
||||
|| { echo "notify is not registered at its baked path: $(cat "$st")"; rm -f "$st"; return 1; }
|
||||
rm -f "$st"
|
||||
local b
|
||||
for b in dejima-attach dejima-ttyd dejima-notify-watch; do
|
||||
[ -L "$FAKE_HOME/.local/bin/$b" ]
|
||||
@@ -2783,14 +2833,15 @@ nginx_blocks() {
|
||||
[ -x "$FAKE_HOME/.local/bin/$b" ]
|
||||
done
|
||||
|
||||
# Positive first: an absence assertion against a settings.json that did
|
||||
# not get written would pass on its own.
|
||||
run jq -e '[.hooks[]?[]?.hooks[]?.command] | any(test("sandbox-context.sh"))' \
|
||||
"$FAKE_HOME/.claude/settings.json"
|
||||
[ "$status" -eq 0 ]
|
||||
run jq -e '[.hooks[]?[]?.hooks[]?.command] | any(test("dejima-notify"))' \
|
||||
"$FAKE_HOME/.claude/settings.json"
|
||||
[ "$status" -ne 0 ]
|
||||
# install.sh registers nothing in the harness's config now, so there is
|
||||
# nothing positive to check there - the settings file may not exist at
|
||||
# all. The positive assertion that matters moved above: the notify hook IS
|
||||
# registered, at its baked path, in the sandbox's own overlay.
|
||||
if [ -f "$FAKE_HOME/.claude/settings.json" ]; then
|
||||
run jq -e '[.hooks[]?[]?.hooks[]?.command] | any(test("dejima-notify"))' \
|
||||
"$FAKE_HOME/.claude/settings.json"
|
||||
[ "$status" -ne 0 ]
|
||||
fi
|
||||
|
||||
# Nothing was enabled, and nothing under the runtime dir was created: the
|
||||
# feature stays off until the operator sets [remote-access] enabled = on.
|
||||
@@ -2801,7 +2852,7 @@ nginx_blocks() {
|
||||
run env HOME="$FAKE_HOME" PATH="$STUB_DIR:$PATH" "$REPO_ROOT/install.sh"
|
||||
[ "$status" -eq 0 ]
|
||||
[ ! -e "$FAKE_HOME/.dejima-backup" ]
|
||||
[ -L "$FAKE_HOME/.claude/hooks/dejima-notify.sh" ]
|
||||
[ ! -e "$FAKE_HOME/.claude/hooks/dejima-notify.sh" ]
|
||||
|
||||
# And with one, it may not: re-running the installer after a git pull is
|
||||
# exactly what an operator with a front door already up does, and telling
|
||||
|
||||
+546
-229
File diff suppressed because it is too large
Load Diff
+11
-6
@@ -27,12 +27,17 @@ list_scripts() {
|
||||
ls test/*.sh 2>/dev/null || true
|
||||
} | sort -u | while read -r f; do
|
||||
[ -f "$f" ] || continue
|
||||
# Harness plugins are sourced, not executed, so they carry no shebang
|
||||
# and the discovery below would drop them - silently, which is the
|
||||
# worst version of that. They are in the trusted computing base, so
|
||||
# they are included by name and the -s bash below tells shellcheck
|
||||
# what they are.
|
||||
case "$f" in bin/harness/*.sh) printf '%s\n' "$f"; continue ;; esac
|
||||
# Sourced fragments are not executed, so they carry no shebang and the
|
||||
# discovery below would drop them - silently, which is the worst
|
||||
# version of that. Harness plugins are one kind; docker/ has two more,
|
||||
# and they are not incidental: caps-lib.sh holds the drop that decides
|
||||
# what a session's capability set is, and fixed-domains.sh is the seam
|
||||
# keeping the DNS allowlist and the ipset allowlist from drifting.
|
||||
#
|
||||
# Matched by directory rather than by name, because a list is a thing
|
||||
# to forget to add to. Everything under docker/ is in the trusted
|
||||
# computing base; the -s bash below tells shellcheck what these are.
|
||||
case "$f" in bin/harness/*.sh|docker/*.sh) printf '%s\n' "$f"; continue ;; esac
|
||||
head -1 "$f" | grep -qE '^#!.*(bash|sh)' && printf '%s\n' "$f"
|
||||
done
|
||||
}
|
||||
|
||||
+993
-88
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,96 @@
|
||||
'use strict';
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
require('../src/gestures.js');
|
||||
const { createGestures } = globalThis.DEJIMA.gestures;
|
||||
|
||||
const CELL = 20; // rows are 20px tall in every world below
|
||||
|
||||
// An element that records the listeners createGestures attaches, so a test can
|
||||
// fire a touch sequence at it without a DOM.
|
||||
function makeElement() {
|
||||
const listeners = {};
|
||||
return {
|
||||
addEventListener: (type, fn) => { (listeners[type] = listeners[type] || []).push(fn); },
|
||||
fire: (type, event) => { (listeners[type] || []).forEach((fn) => fn(event)); }
|
||||
};
|
||||
}
|
||||
|
||||
function touchEvent(points) {
|
||||
return {
|
||||
touches: points.map((p) => ({ clientX: p.x, clientY: p.y })),
|
||||
preventDefault: function () { this.defaultPrevented = true; },
|
||||
defaultPrevented: false
|
||||
};
|
||||
}
|
||||
|
||||
// reporting: whether the application has asked for mouse reports, which is
|
||||
// what decides who receives a notch and so what one is worth.
|
||||
function makeWorld(reporting) {
|
||||
const element = makeElement();
|
||||
const scrolled = []; // this page's own scrollback, one row per entry
|
||||
const wheeled = []; // notches sent to the application
|
||||
const gestures = createGestures({
|
||||
element: element,
|
||||
term: {
|
||||
scrollLines: (n) => scrolled.push(n),
|
||||
getSelection: () => ''
|
||||
},
|
||||
mouse: {
|
||||
cellHeight: () => CELL,
|
||||
enabled: () => !!reporting,
|
||||
cellAt: () => ({ col: 1, row: 1 }),
|
||||
wheel: (cell, up) => wheeled.push(up ? 'up' : 'down'),
|
||||
altScreen: () => false,
|
||||
press: () => {}, motion: () => {}, release: () => {}
|
||||
},
|
||||
dragMode: () => false,
|
||||
onCopy: () => {}
|
||||
});
|
||||
gestures.attach();
|
||||
return {
|
||||
element, scrolled, wheeled,
|
||||
// A one-finger drag of `rows` rows, downward, which pulls earlier output
|
||||
// into view. Delivered as one touchmove: scrollBy accumulates travel, so
|
||||
// the split into events is not what the count under test depends on.
|
||||
dragDown: function (rows) {
|
||||
element.fire('touchstart', touchEvent([{ x: 100, y: 100 }]));
|
||||
element.fire('touchmove', touchEvent([{ x: 100, y: 100 + rows * CELL }]));
|
||||
element.fire('touchend', touchEvent([]));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// One notch per row of finger travel, whichever side receives the notch.
|
||||
// What a notch is then WORTH belongs to the receiver: this page moves one row,
|
||||
// and tmux's copy mode moves five until its binding says otherwise.
|
||||
|
||||
test('scrollback moves one row per row of finger travel', () => {
|
||||
const w = makeWorld(false);
|
||||
w.dragDown(10);
|
||||
assert.equal(w.scrolled.length, 10);
|
||||
assert.ok(w.scrolled.every((n) => n === -1), 'dragging down goes back in history');
|
||||
assert.equal(w.wheeled.length, 0, 'no application is listening');
|
||||
});
|
||||
|
||||
test('an application gets one notch per row of finger travel', () => {
|
||||
const w = makeWorld(true);
|
||||
w.dragDown(10);
|
||||
assert.equal(w.wheeled.length, 10);
|
||||
assert.ok(w.wheeled.every((d) => d === 'up'), 'dragging down scrolls up');
|
||||
assert.equal(w.scrolled.length, 0, 'the page did not also scroll itself');
|
||||
});
|
||||
|
||||
test('travel shorter than a row sends nothing rather than rounding up', () => {
|
||||
const w = makeWorld(true);
|
||||
w.element.fire('touchstart', touchEvent([{ x: 100, y: 200 }]));
|
||||
w.element.fire('touchmove', touchEvent([{ x: 100, y: 200 + CELL / 2 }]));
|
||||
assert.equal(w.wheeled.length, 0);
|
||||
});
|
||||
|
||||
test('dragging up scrolls forward again', () => {
|
||||
const w = makeWorld(true);
|
||||
w.element.fire('touchstart', touchEvent([{ x: 100, y: 300 }]));
|
||||
w.element.fire('touchmove', touchEvent([{ x: 100, y: 300 - 3 * CELL }]));
|
||||
assert.deepEqual(w.wheeled, ['down', 'down', 'down']);
|
||||
});
|
||||
Reference in New Issue
Block a user