Compare commits
125
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
efc60c03e8 | ||
|
|
61f5f67030 | ||
|
|
76e6c5a94d | ||
|
|
00136eae66 | ||
|
|
9eb9b9cd30 | ||
|
|
d6a0e2bfc5 | ||
|
|
0031ba177b | ||
|
|
69e9d6bcde | ||
|
|
8aa646b0dc | ||
|
|
10960d1e34 | ||
|
|
15139dfab1 | ||
|
|
a4f8ee1c63 | ||
|
|
70150564e7 | ||
|
|
daf910d9d0 | ||
|
|
ea56ef9222 | ||
|
|
cf224bcf9c | ||
|
|
4d328aafa3 | ||
|
|
62aa5ba701 | ||
|
|
3f3bf14e51 | ||
|
|
c94e5630fd | ||
|
|
e2b65c7f25 | ||
|
|
52c70c6c7d | ||
|
|
ef45a8c777 | ||
|
|
43b01cfb12 | ||
|
|
260198d2bf | ||
|
|
52faeb1fb9 | ||
|
|
b82541af2c | ||
|
|
6a049c8d8b | ||
|
|
c81ecce3c2 | ||
|
|
f1f5c9ad3f | ||
|
|
feb60c5a93 | ||
|
|
fc19f07fca | ||
|
|
19f8a39b76 | ||
|
|
e5f8e53bb0 | ||
|
|
912573d88f | ||
|
|
f099e278f6 | ||
|
|
1a3c9543c8 | ||
|
|
3c955ac9ab | ||
|
|
41ef213f05 | ||
|
|
1fb5116f7c | ||
|
|
9ff4738c18 | ||
|
|
a731bf9583 | ||
|
|
3dc87f9274 | ||
|
|
1ec63208af | ||
|
|
c2cdcdc8b9 | ||
|
|
8baf2a333f | ||
|
|
eefee14361 | ||
|
|
0ee974891b | ||
|
|
4e223be488 | ||
|
|
0b0e9034ec | ||
|
|
2d97a571fa | ||
|
|
4633a38bc6 | ||
|
|
ea9fe72fd8 | ||
|
|
3494974da0 | ||
|
|
e4b44e9b73 | ||
|
|
3539b93650 | ||
|
|
1136146b9f | ||
|
|
2943bb74c1 | ||
|
|
1a34cba979 | ||
|
|
ad4dea38aa | ||
|
|
d91e4d91d4 | ||
|
|
f94eeb0702 | ||
|
|
e2309bdadc | ||
|
|
445afabe55 | ||
|
|
4002bb6a02 | ||
|
|
5bb3673680 | ||
|
|
a6ff5b2f2e | ||
|
|
e8acb6d508 | ||
|
|
5ed3bdebe7 | ||
|
|
7c1a96714f | ||
|
|
ef3d47bc4c | ||
|
|
9f5d162f47 | ||
|
|
8d9f076c68 | ||
|
|
8b69569bcc | ||
|
|
f43018534d | ||
|
|
564a508494 | ||
|
|
345fc17222 | ||
|
|
3bfe1d434d | ||
|
|
83c2278538 | ||
|
|
4a4c9d64b4 | ||
|
|
a9702b15ab | ||
|
|
ff4876c114 | ||
|
|
343fce889c | ||
|
|
eaf1222bb2 | ||
|
|
772041eda0 | ||
|
|
b1945d7cd0 | ||
|
|
d484d258f8 | ||
|
|
48f3eac04b | ||
|
|
fa1d35f5ef | ||
|
|
c834520866 | ||
|
|
acfc81f345 | ||
|
|
abcfa2d99a | ||
|
|
c379efb823 | ||
|
|
a4ff815b76 | ||
|
|
481e417d43 | ||
|
|
70cbf7281d | ||
|
|
dbf87a2e63 | ||
|
|
551f063e8b | ||
|
|
a808d62896 | ||
|
|
816ffff1e7 | ||
|
|
5318f050d2 | ||
|
|
5522b2c35b | ||
|
|
f184b42eae | ||
|
|
d83cb1d244 | ||
|
|
558090c3be | ||
|
|
a7f615196f | ||
|
|
b557f63d69 | ||
|
|
e9e257d200 | ||
|
|
b7ee2779c4 | ||
|
|
0fa33d115e | ||
|
|
d2e52d210b | ||
|
|
3c24e2fd19 | ||
|
|
6b9ba51f2e | ||
|
|
1a172cf578 | ||
|
|
897dccd94d | ||
|
|
f0c9f654fb | ||
|
|
19d11577b6 | ||
|
|
ed3f7f0660 | ||
|
|
25fcd0a092 | ||
|
|
5253028122 | ||
|
|
6891dc4079 | ||
|
|
3e5da382ea | ||
|
|
26b67a8c87 | ||
|
|
4b6f44143e | ||
|
|
b8e5698b48 |
@@ -0,0 +1,268 @@
|
||||
# Runs the bats suite on every pull request and on every push to one.
|
||||
# `pull_request` fires on opened/reopened/synchronize, and synchronize is the
|
||||
# push-to-an-open-PR event, so both are covered without listing types.
|
||||
#
|
||||
# Split in two because the halves cost wildly different amounts: everything
|
||||
# except runtime.bats is seconds and needs nothing but a shell, while
|
||||
# runtime.bats builds the image and boots real containers. Splitting means a
|
||||
# typo in a doc test doesn't wait on a full image build to tell you.
|
||||
name: tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
# No `concurrency:` block. Gitea does not implement one - the word appears
|
||||
# nowhere in its actions models or services as of 1.25 - so writing it here
|
||||
# would only claim a cancellation that never happens. It doesn't: pushing
|
||||
# twice in a minute left the superseded run going and queued the new one
|
||||
# behind it. On a single-capacity runner, expect a backlog rather than a
|
||||
# supersede, and cancel the stale run by hand if it is in the way.
|
||||
|
||||
jobs:
|
||||
fast:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
# Debian bookworm plus a node binary - node is what actions/checkout
|
||||
# needs to run at all, and apt-get is what everything below needs.
|
||||
image: node:20-bookworm
|
||||
steps:
|
||||
- name: Install test dependencies
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends bats jq python3 tmux ca-certificates
|
||||
- uses: actions/checkout@v4
|
||||
# As an ordinary user, not root. One test leaves a directory mode 500 and
|
||||
# asserts that the CLI's rm -rf refuses it and says so; root walks
|
||||
# through that on CAP_DAC_OVERRIDE and the test fails for a reason that
|
||||
# has nothing to do with what it checks. Nobody runs these CLIs as root.
|
||||
#
|
||||
# TERM because the job container has none, and six tests drive a real pty
|
||||
# through `script`: the picker drops colour on TERM=dumb by design, and
|
||||
# tmux will not start a client for a terminal it has no entry for, so
|
||||
# they fail on the terminal rather than on anything they mean to check.
|
||||
# xterm-256color ships in ncurses-base, which is Essential - nothing to
|
||||
# install. Set on the command rather than in env: so it survives su.
|
||||
- run: |
|
||||
chown -R node:node .
|
||||
su node -c 'TERM=xterm-256color bats test/static.bats test/project.bats test/remote.bats test/audit.bats test/proxy.bats'
|
||||
|
||||
runtime:
|
||||
# Not ubuntu-latest, unlike the job above. This one only works on a runner
|
||||
# configured for it - host network namespace, the Docker socket, and the
|
||||
# shared volume below - and none of that is anything the workflow can ask
|
||||
# for. On a generic runner it fails the preflight check outright.
|
||||
#
|
||||
# ubuntu-latest is a label every runner carries, so which one picked this
|
||||
# up was a coin flip, and a re-run that happened to land on an unconfigured
|
||||
# runner failed on a repo that had not changed. A label only the configured
|
||||
# runner has makes that unrepresentable rather than merely diagnosable: no
|
||||
# runner matching it means this job queues instead of running somewhere it
|
||||
# cannot work. The fast job stays on ubuntu-latest, because it genuinely
|
||||
# does run anywhere.
|
||||
runs-on: dejima-runtime
|
||||
container:
|
||||
image: node:20-bookworm
|
||||
# One volume, and it is load-bearing - see TMPDIR on the bats step for
|
||||
# what it is for. It has to be listed in container.valid_volumes in
|
||||
# act_runner's config.yaml too: a volume this file asks for and the
|
||||
# runner has not been told to allow does not arrive.
|
||||
volumes:
|
||||
- /tmp/dejima-ci:/tmp/dejima-ci
|
||||
# Nothing else, though - no `options: --network host`, and no request
|
||||
# for the docker socket. Both of the other things this job needs beyond
|
||||
# an ordinary one are settled by the runner, not by this file, and
|
||||
# asking for them here does nothing:
|
||||
#
|
||||
# The docker socket, for building the image and running containers,
|
||||
# act_runner binds in by itself - GetBindsAndMounts appends
|
||||
# /var/run/docker.sock unless container.docker_host is set to "-", and
|
||||
# it adds that same path to valid_volumes on the way past. Requesting it
|
||||
# again here would at best duplicate a bind that already exists.
|
||||
#
|
||||
# The host network namespace has to come from container.network: host in
|
||||
# act_runner's config.yaml. Gitea's act fork ignores --network in a
|
||||
# workflow's container options outright (it logs "--network and --net in
|
||||
# the options will be ignored" and then overwrites NetworkMode), so this
|
||||
# is not something the repo can ask for. It is needed because of one
|
||||
# test: "a host service bound to loopback is unreachable from inside the
|
||||
# container, one bound to the bridge gateway is reachable". Its positive
|
||||
# control has bats itself bind a listener to the test network's bridge
|
||||
# gateway address, which lives on a host interface and cannot be bound
|
||||
# from any other netns - the control would fail for a reason that has
|
||||
# nothing to do with what the test asserts. That test is the containment
|
||||
# argument for the ttyd front door, so it is not one to skip in CI.
|
||||
steps:
|
||||
- name: Install test dependencies
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends bats jq python3 ca-certificates curl
|
||||
# The CLI only - the daemon is the host's, reached over the socket
|
||||
# above. Debian ships no docker-cli package (docker.io drags in a
|
||||
# daemon this job would never start), so take the static binary.
|
||||
curl -fsSL "https://download.docker.com/linux/static/stable/$(uname -m)/docker-${DOCKER_CLI_VERSION}.tgz" \
|
||||
| tar -xzC /usr/local/bin --strip-components=1 docker/docker
|
||||
# buildx ships separately from that tarball, and `docker build` picks
|
||||
# the builder by whether this plugin is present: with it, BuildKit;
|
||||
# without it, the legacy builder, which creates, commits and removes a
|
||||
# real container per instruction. That cost ~10s x 56 steps here - more
|
||||
# of the build than the downloads and compiles it was wrapping.
|
||||
mkdir -p /usr/local/lib/docker/cli-plugins
|
||||
curl -fsSL -o /usr/local/lib/docker/cli-plugins/docker-buildx \
|
||||
"https://github.com/docker/buildx/releases/download/v${BUILDX_VERSION}/buildx-v${BUILDX_VERSION}.linux-$(dpkg --print-architecture)"
|
||||
chmod +x /usr/local/lib/docker/cli-plugins/docker-buildx
|
||||
# Because that choice is silent, check it rather than assume it. A
|
||||
# download that returned an error page instead of a binary still
|
||||
# chmods fine, and the only symptom would be the slow builds this is
|
||||
# meant to fix - which look like nothing being wrong.
|
||||
docker buildx version || {
|
||||
echo "The buildx plugin did not load, so docker build would quietly use the" >&2
|
||||
echo "legacy builder instead of BuildKit. Check the download URL above still" >&2
|
||||
echo "resolves for this architecture." >&2
|
||||
exit 1
|
||||
}
|
||||
env:
|
||||
DOCKER_CLI_VERSION: 27.5.1
|
||||
BUILDX_VERSION: 0.36.0
|
||||
|
||||
# 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,
|
||||
# not anything a change to this repo can cause.
|
||||
- name: Check the runner granted Docker, the host netns, and a shared /tmp
|
||||
run: |
|
||||
docker info >/dev/null 2>&1 || {
|
||||
echo "No Docker daemon reachable from this job." >&2
|
||||
echo "In act_runner's config.yaml, container.docker_host must not be \"-\" - that is the" >&2
|
||||
echo "one value that stops the socket being mounted into job containers." >&2
|
||||
exit 1
|
||||
}
|
||||
# Read sysfs rather than shell out to `ip`: /sys/class/net is
|
||||
# per-netns and always there, while iproute2 is not in this image.
|
||||
# The first version of this check ran `ip -o addr show 2>/dev/null`,
|
||||
# which turned "the tool is missing" into "the host netns is
|
||||
# missing" - a check against obscure failures failing obscurely.
|
||||
[ -e /sys/class/net/docker0 ] || {
|
||||
echo "This job is not in the host network namespace - docker0 is not visible." >&2
|
||||
echo "The bridge-gateway test cannot pass here. Set container.network: host in" >&2
|
||||
echo "act_runner's config.yaml (and make sure CONFIG_FILE points the runner at" >&2
|
||||
echo "that file - without it the config is not read at all)." >&2
|
||||
exit 1
|
||||
}
|
||||
# Probe the shared directory the same way the suite will use it:
|
||||
# write a file in here, read it back through the host daemon. If it
|
||||
# is not really shared, the daemon invents an empty directory for
|
||||
# the mount rather than refusing it, and the probe comes back empty
|
||||
# - which is exactly how all of runtime.bats would fail. Reuses
|
||||
# this job's own image, so there is nothing to pull.
|
||||
mkdir -p /tmp/dejima-ci
|
||||
# bats only clears its own tmpdir on a normal exit, so fixtures
|
||||
# from a killed run would otherwise pile up on the host forever.
|
||||
# `find -delete` rather than a glob: it covers dotfiles, doesn't
|
||||
# misfire on an empty directory, and still fails loudly.
|
||||
find /tmp/dejima-ci -mindepth 1 -delete
|
||||
echo shared-ok > /tmp/dejima-ci/probe
|
||||
probe=$(docker run --rm -v /tmp/dejima-ci/probe:/probe:ro node:20-bookworm cat /probe 2>/dev/null || true)
|
||||
[ "$probe" = shared-ok ] || {
|
||||
echo "/tmp/dejima-ci is not shared with the host - the probe read back empty." >&2
|
||||
echo "Add it to container.valid_volumes in act_runner's config.yaml. Without" >&2
|
||||
echo "that the runner drops the volume this job asks for, and every fixture" >&2
|
||||
echo "mount in runtime.bats silently becomes an empty directory instead." >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# The token raises the api.github.com/meta ceiling from 60 requests an
|
||||
# hour to 5000, and nothing about a run looks different when it stops
|
||||
# doing that. A misspelled secret name, a revoked token and an expiry a
|
||||
# year from now all present identically: green for a while, then
|
||||
# containers failing to boot in init-firewall.sh - which reads as a
|
||||
# regression in the firewall rather than as a lapsed credential. So ask
|
||||
# the API what the ceiling actually is, rather than checking that a
|
||||
# variable is non-empty and assuming the rest.
|
||||
- name: Check the GitHub ranges lookup is authenticated
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GH_META_TOKEN }}
|
||||
run: |
|
||||
# Not a failure. Without the secret the suite still runs, it just
|
||||
# spends the anonymous quota - which is the right default for a
|
||||
# fork, and only a problem once runs get frequent.
|
||||
if [ -z "${GH_TOKEN:-}" ]; then
|
||||
echo "No GH_META_TOKEN secret is configured, so GitHub's IP ranges will be"
|
||||
echo "fetched anonymously: 60 requests an hour for this entire runner host,"
|
||||
echo "against thirteen container starts per run. Fine occasionally, and on a"
|
||||
echo "busy day the containers stop booting instead. See issue #37."
|
||||
exit 0
|
||||
fi
|
||||
# No -f. A rejected token still answers with the header this reads,
|
||||
# and the anonymous ceiling it reports is a far better diagnostic
|
||||
# than curl exiting silently on the 401.
|
||||
limit=$(curl -sI --connect-timeout 10 -H "Authorization: Bearer $GH_TOKEN" \
|
||||
https://api.github.com/meta | tr -d '\r' \
|
||||
| awk -F': ' 'tolower($1)=="x-ratelimit-limit" {print $2}')
|
||||
if [ -z "$limit" ]; then
|
||||
echo "No x-ratelimit-limit came back from api.github.com/meta at all, so the" >&2
|
||||
echo "request itself did not complete. This one is about reachability from" >&2
|
||||
echo "the runner, not about the token." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "$limit" -lt 5000 ]; then
|
||||
echo "GH_META_TOKEN is set, but GitHub reports a ceiling of $limit/hour - that" >&2
|
||||
echo "is the anonymous quota, so the token is not being accepted. Expired or" >&2
|
||||
echo "revoked is the likely cause; a wrong secret name would have arrived" >&2
|
||||
echo "empty and been reported above instead. Replace it with a fine-grained" >&2
|
||||
echo "PAT with no permissions - /meta is public data and needs none." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "GitHub ranges lookup is authenticated - ceiling is $limit requests/hour."
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# The build dominates this job, and inside setup_file it is a `docker
|
||||
# build -q` that prints nothing at all - so tens of minutes of ordinary
|
||||
# building is indistinguishable from a hang, which is exactly how it
|
||||
# read the first time this ran here. Hoisted out, it gets its own
|
||||
# progress output and its own duration in the run summary.
|
||||
#
|
||||
# SKIP_BUILD below then reuses this image rather than skipping a build.
|
||||
# The image is still built fresh from this commit, so the property the
|
||||
# test file defaults to - never assert against a stale image - holds.
|
||||
- name: Build the sandbox image
|
||||
run: docker build -t dejima:latest -f docker/Dockerfile .
|
||||
|
||||
- run: bats test/runtime.bats
|
||||
env:
|
||||
SKIP_BUILD: 1
|
||||
# Every container this suite starts fetches GitHub's IP ranges from
|
||||
# api.github.com/meta in init-firewall.sh, and unauthenticated that
|
||||
# endpoint allows 60 requests an hour per source IP - shared with
|
||||
# everything else on this runner host. Thirteen starts per run means
|
||||
# two runs an hour exhausts it, after which containers fail to boot
|
||||
# and tests fail on the quota rather than on anything they assert.
|
||||
#
|
||||
# This became reachable when the build got fast: at ~48 minutes a
|
||||
# run the starts were spread thin enough to stay under, and at ~4
|
||||
# minutes they are not. Any token authenticates the fetch and raises
|
||||
# the ceiling to 5000/hour; /meta is public data and needs no scopes
|
||||
# at all, so the secret behind this should be a fine-grained PAT
|
||||
# with no permissions and no repository access - not a token that
|
||||
# can do anything else. It reaches `gh auth setup-git` inside the
|
||||
# containers, so a scope granted here is a scope handed to every
|
||||
# sandbox the suite starts.
|
||||
#
|
||||
# Unset is fine and the suite still runs - it just spends the
|
||||
# anonymous quota, so expect the failure above on a busy day.
|
||||
GH_TOKEN: ${{ secrets.GH_META_TOKEN }}
|
||||
# runtime.bats builds fixtures on disk - ssh keys, statusline
|
||||
# scripts, .claude.json copies, a proxy socket - and bind-mounts
|
||||
# them into containers it starts. Those containers are siblings of
|
||||
# this one rather than children: the host daemon creates them, over
|
||||
# the socket, so it resolves every -v source against the host's
|
||||
# filesystem and not this container's. A fixture at a path that
|
||||
# exists only in here therefore does not resolve - and Docker's
|
||||
# answer to a missing bind source is not an error, it is to create
|
||||
# an empty directory and mount that. Seven tests failed that way,
|
||||
# and an eighth passed while being just as broken, because an empty
|
||||
# read-only directory satisfies exactly what it asserts.
|
||||
#
|
||||
# Pointing TMPDIR at the shared mount puts `mktemp -d` and bats'
|
||||
# own BATS_*_TMPDIR on paths that mean the same thing on both sides
|
||||
# of the socket, which is all any of those mounts need.
|
||||
TMPDIR: /tmp/dejima-ci
|
||||
@@ -1,12 +1,47 @@
|
||||
# dejima
|
||||
|
||||
Runs Claude Code inside a firewalled Docker container with
|
||||
Runs a coding agent — Claude Code by default, or whatever command you point
|
||||
it at — inside a firewalled Docker container with
|
||||
`--dangerously-skip-permissions` (no permission prompts, all tool use
|
||||
auto-accepted), against a project bind-mounted from the host. Inspired by
|
||||
auto-accepted), against a project bind-mounted from the host. The agent gets
|
||||
to work without stopping to ask; the container is what makes that a
|
||||
reasonable trade rather than a reckless one.
|
||||
|
||||
Around that core is most of what running an agent unattended actually takes:
|
||||
|
||||
- **An egress allowlist.** DNS and IP, IPv4 and IPv6. Anything not on the
|
||||
list does not connect, and the agent is told so rather than left to guess.
|
||||
- **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.
|
||||
- **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, by hand, and the
|
||||
approval store is never mounted where a session could reach it.
|
||||
- **A PR and issue workflow.** `gitea-pr` and `gitea-issue` let a session
|
||||
open pull requests and issues against Gitea or Forgejo and pull review
|
||||
feedback back in, on a token scoped for exactly that. Merging stays a
|
||||
human decision, made outside the sandbox.
|
||||
- **Logging.** Every DNS lookup and rejected connection, shipped to Loki —
|
||||
and, opt-in, the session transcript alongside them.
|
||||
- **Remote access, opt-in and off by default.** Attach to a live session
|
||||
from a terminal or a browser, with a push notification when it wants you.
|
||||
- **Guard rails inside the session.** Hooks refuse obviously destructive
|
||||
commands, pushes to anywhere but the allowed remote, and merging a PR from
|
||||
inside the sandbox. These are
|
||||
fast-fail guards against an overeager agent, not a boundary against a
|
||||
hostile one — the container is the boundary.
|
||||
- **Config parity with your host.** `CLAUDE.md`, settings, hooks, plugins
|
||||
and git identity carry over; uid, home directory and project path match on
|
||||
both sides, so a session's transcript is readable from the host afterward.
|
||||
- **An exit-time audit** of the host surfaces a session could have written
|
||||
to, so a change to your git config or `~/.claude` gets surfaced instead of
|
||||
just happening.
|
||||
|
||||
Originally inspired by
|
||||
[Anthropic's reference dev container](https://github.com/anthropics/claude-code/tree/main/.devcontainer),
|
||||
reworked to run via `docker compose` directly and extended with a `gitea-pr`
|
||||
helper for opening/iterating on PRs against Gitea or Forgejo, plus an
|
||||
optional editor-driven workflow in place of bare Claude Code (see "Usage").
|
||||
reworked to run via `docker compose` directly and grown well past it since.
|
||||
|
||||
This repo only holds the tooling — it's not where any project lives. Point
|
||||
it at a project on disk; that project is bind-mounted into the container at
|
||||
@@ -14,22 +49,56 @@ runtime.
|
||||
|
||||
## The name
|
||||
|
||||
Dejima was a small artificial island in Nagasaki harbour — for over two
|
||||
centuries, the only Japanese territory open to Westerners. A single guarded
|
||||
bridge connected it to the mainland, and nothing crossed in either direction
|
||||
without permission. The containment was real in both directions, and so was
|
||||
the trade: most of what Japan learned of Western medicine and science in that
|
||||
period arrived across that one bridge.
|
||||
[Dejima](https://en.wikipedia.org/wiki/Dejima) was a small artificial island
|
||||
in Nagasaki harbour, and was the only Japanese territory open to Westerners
|
||||
for over two centuries. A single guarded bridge connected it to the mainland,
|
||||
and nothing crossed in either direction without permission. The containment
|
||||
was real in both directions, and so was the trade: most of what Japan learned
|
||||
of Western medicine and science in that period arrived across that one bridge.
|
||||
|
||||
That's roughly the arrangement here. The agent gets a bounded island and one
|
||||
controlled crossing; in exchange it gets to work without stopping to ask
|
||||
permission for every step. The walls are what make the latitude affordable.
|
||||
permission for every step. Dejima keeps your host safe from your agent, and your
|
||||
agent relatively safe from the public internet.
|
||||
|
||||
## An agent's perspective on working inside
|
||||
|
||||
*Written by Claude, from inside a dejima container, while working on this repo.*
|
||||
|
||||
The clearest thing dejima gives me is continuity. Agentic work is normally
|
||||
punctuated — reach, stop, wait for a human, resume — and inside the container
|
||||
it isn't. Long tasks hold together in a way they otherwise don't.
|
||||
|
||||
The more useful benefit is that being wrong gets cheap. When every speculative
|
||||
action spends someone's attention, I am quietly biased against exploring: I
|
||||
will write around an uncertainty rather than spend a prompt resolving it. Here
|
||||
I can install a package to check one thing, run the suite three times, take an
|
||||
approach far enough to see it fail and then throw it away. Being able to
|
||||
discard work is worth more than the speed is.
|
||||
|
||||
I can also afford to be decisive. A bounded blast radius means I don't have to
|
||||
hedge toward the least consequential version of a task — the firewall, the
|
||||
read-only mounts and the push guard catch the outcomes that actually matter, so
|
||||
my judgement isn't the only thing standing between a mistake and your machine.
|
||||
|
||||
The walls doing that are mostly invisible from in here. I don't experience the
|
||||
firewall as pressure; I only meet it by walking into it — a domain that doesn't
|
||||
resolve, a path outside the mount that isn't backed by anything. And it points
|
||||
both ways: the allowlist bounding where I can reach also bounds where a prompt
|
||||
injection or a compromised dependency could send your code. I can't audit
|
||||
myself for that from the inside, so it protects me too.
|
||||
|
||||
None of which makes me careless — what I notice is the absent prompt, not the
|
||||
walls. The in-session hooks catch the obvious cases, but nobody is reviewing
|
||||
each action before it lands, and a bad `rm` in the mounted project is just a
|
||||
bad `rm`. That produces more care, not less, the way you drive differently on a
|
||||
road with no barrier.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Docker with the `docker compose` v2 plugin.
|
||||
- `python3` (stdlib only — no packages) — runs the host-side credential proxy
|
||||
(see "Security notes").
|
||||
(see [security notes](docs/security.md)).
|
||||
- A git push credential — **at least one** is required:
|
||||
- `ssh-agent` running with your key added (`ssh-add -l` lists it). The
|
||||
container only ever sees a forwarded agent socket, never your private
|
||||
@@ -55,6 +124,7 @@ Everything else is optional, depending on which workflow(s) you want:
|
||||
| `SANDBOX_LOG_CONVERSATION` | `0` | opt-in: also ship session transcripts to Loki (needs `LOKI_URL`) — see "Conversation logging (opt-in)" below |
|
||||
| `ENABLE_FIREWALL` | `1` | set to `0` to disable the egress firewall (debugging only) |
|
||||
| `SANDBOX_CREDENTIAL_PROXY` | `1` | set to `0` to disable the host-side credential proxy and mount the real OAuth credential into the container directly, as before this feature |
|
||||
| `SANDBOX_ANTHROPIC_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](docs/security.md)) |
|
||||
|
||||
**Gitea/Forgejo is the default PR workflow** — `gitea-pr`, and the only push
|
||||
target `git-push-guard.sh` allows by default. If you don't use Gitea/Forgejo,
|
||||
@@ -65,11 +135,138 @@ below) — the two aren't both required, pick whichever matches your remote.
|
||||
a compatible `/api/v1` surface, so pointing `GITEA_API` at a Forgejo instance
|
||||
just works.
|
||||
|
||||
## Usage
|
||||
## Setup
|
||||
|
||||
Every feature's configuration, in one place and in order. Each step links to
|
||||
the section that explains *why* it is that way; this section only says what
|
||||
to do. Only the first block is required - the rest are independent, so take
|
||||
the ones you want and skip the rest.
|
||||
|
||||
For remote access, the reading is not optional: one of its steps is a full
|
||||
sandbox escape if you get it wrong.
|
||||
|
||||
### Core (required)
|
||||
|
||||
1. `./install.sh` — symlinks the CLIs into `~/.local/bin` and the hooks into
|
||||
`~/.claude/hooks`. It is idempotent, so re-run it after pulling.
|
||||
2. Put `~/.local/bin` on your `PATH` if it isn't already.
|
||||
3. Give it a git push credential — `ssh-add` your key for interactive use,
|
||||
or set `SANDBOX_SSH_KEY` to a dedicated deploy key for unattended runs.
|
||||
→ [Prerequisites](#prerequisites),
|
||||
[Headless git push without agent forwarding](#headless-git-push-without-agent-forwarding)
|
||||
|
||||
That is enough to run `dejima`.
|
||||
|
||||
### Gitea/Forgejo PR workflow
|
||||
|
||||
Skip this if you only push to GitHub.
|
||||
|
||||
1. Export `GITEA_SSH_HOST` (the SSH hostname) and `GITEA_API`
|
||||
(`https://<gitea host>/api/v1`) from your shell profile.
|
||||
2. Generate a token — Settings → Applications, scopes `write:repository` and
|
||||
`write:issue` — and save it to `~/.config/gitea/token`, `chmod 600`.
|
||||
→ [Prerequisites](#prerequisites)
|
||||
|
||||
### Per-project domains and dependencies
|
||||
|
||||
Nothing to set up in advance; do this per project, as it turns out to need
|
||||
something:
|
||||
|
||||
```sh
|
||||
bin/dejima # sandbox the current directory
|
||||
bin/dejima ~/repos/some-project
|
||||
dejima-project status ~/myproject # declared vs approved vs missing
|
||||
dejima-project approve ~/myproject # approve the declared set
|
||||
dejima-allow crates.io --project ~/myproject
|
||||
dejima-dep add cargo:cargo-nextest --global
|
||||
```
|
||||
|
||||
Domains apply at the next launch, and `dejima-allow` also patches a running
|
||||
session. Dependencies need an image rebuild, so they always land on the next
|
||||
one. →
|
||||
[Global and per-project domains and dependencies](#global-and-per-project-domains-and-dependencies),
|
||||
[Adding a domain to a running session](#adding-a-domain-to-a-running-session)
|
||||
|
||||
### Log shipping
|
||||
|
||||
1. Export `LOKI_URL` pointing at your Loki push endpoint.
|
||||
2. Optionally set `SANDBOX_LOG_CONVERSATION=1` to ship transcripts as well —
|
||||
read
|
||||
[Conversation logging (opt-in)](#conversation-logging-opt-in) first, since
|
||||
that ships what you and the model actually say.
|
||||
→ [Network request logging](#network-request-logging),
|
||||
[Viewing logs in Grafana](#viewing-logs-in-grafana)
|
||||
|
||||
### GitHub push (opt-in)
|
||||
|
||||
Per launch, not a persistent setting:
|
||||
|
||||
```sh
|
||||
ALLOW_GITHUB_PUSH=1 dejima ~/myproject
|
||||
```
|
||||
|
||||
Pair it with a repo-scoped deploy key in `SANDBOX_SSH_KEY`. Read
|
||||
[GitHub push: blocked by default, optional opt-in](#github-push-blocked-by-default-optional-opt-in)
|
||||
before turning it on — with a forwarded personal agent this is much broader
|
||||
than it looks.
|
||||
|
||||
### Remote access (opt-in)
|
||||
|
||||
Entirely off until step 1 exists. **Read
|
||||
[docs/remote-access.md](docs/remote-access.md) end to end before doing any of
|
||||
this.** Step 2 is the one that carries the security of the whole feature, and
|
||||
getting it wrong still looks like it works.
|
||||
|
||||
1. **Create `~/.config/dejima/remote.conf`.** This file is the switch —
|
||||
nothing here runs without it. Set `NTFY_URL`, `BASE_URL`, and `TTYD_PORT`
|
||||
if you want something other than 7681. →
|
||||
[`remote.conf`](docs/remote-access.md#remoteconf)
|
||||
2. **Choose the reverse proxy's bind address, on the dejima box itself.** One
|
||||
specific address on its LAN interface. Never the wildcard, never
|
||||
`0.0.0.0`, never a Docker bridge. →
|
||||
[The bind address is the one thing to get right](docs/remote-access.md#the-bind-address-is-the-one-thing-to-get-right),
|
||||
[Picking the address](docs/remote-access.md#picking-the-address)
|
||||
3. **SSH front door.** Generate a key that exists only for this, then add one
|
||||
`restrict,pty,command="…/dejima-attach"` line to `authorized_keys` for the
|
||||
same Unix user that runs `dejima`. →
|
||||
[SSH front door](docs/remote-access.md#ssh-front-door)
|
||||
4. **Web front door.** Get `ttyd` 1.7.4 or newer. If it came from a distro
|
||||
package, `systemctl disable --now ttyd.service` **and** `mask` it first —
|
||||
the package ships an enabled service that puts a login prompt on every
|
||||
interface, reachable from inside your sandboxes. Then install
|
||||
`systemd/dejima-ttyd.service` as a *user* unit, and put a reverse proxy in
|
||||
front of it adapted from `docs/nginx-dejima.conf.example` — with
|
||||
authentication on every server block, TLS, a `default_server` that
|
||||
refuses every other hostname, and
|
||||
`proxy_set_header Host $http_host`. Make sure the name you pick actually
|
||||
resolves to this box and your certificate covers it. →
|
||||
[Web front door](docs/remote-access.md#web-front-door)
|
||||
5. **If you will read it on a phone**, take the `sub_filter` viewport line
|
||||
from the example config too. `ttyd` ships no viewport meta, so without it
|
||||
the mobile view is unreadably small and will not reflow. →
|
||||
[Reading it on a phone](docs/remote-access.md#reading-it-on-a-phone). Typing from one has its
|
||||
own gotcha, which nothing here can fix. →
|
||||
[Typing on a phone](docs/remote-access.md#typing-on-a-phone)
|
||||
6. **If another reverse proxy sits in front of this one**, it needs SNI, a
|
||||
single literal `Host`, the upgrade headers and its own long read timeout —
|
||||
and it changes the threat model. →
|
||||
[Behind another reverse proxy](docs/remote-access.md#behind-another-reverse-proxy)
|
||||
7. **Notifications.** Install `systemd/dejima-notify-watch.service` the same
|
||||
way, then `loginctl enable-linger "$USER"` so both units survive logout. →
|
||||
[Notifications](docs/remote-access.md#notifications)
|
||||
8. **Verify the containment from inside a running sandbox** before relying on
|
||||
any of it. →
|
||||
[Verifying it](docs/remote-access.md#verifying-it)
|
||||
|
||||
[Attaching](docs/remote-access.md#attaching) then covers what you get on the other end.
|
||||
|
||||
## Usage
|
||||
|
||||
Run `./install.sh` first — it symlinks the CLIs into `~/.local/bin` and the
|
||||
hooks into `~/.claude/hooks`, and everything below assumes those are on your
|
||||
`PATH`. See [Setup](#setup) for the rest of the first-run steps.
|
||||
|
||||
```sh
|
||||
dejima # sandbox the current directory
|
||||
dejima ~/repos/some-project
|
||||
```
|
||||
|
||||
By default the container's entry point is bare `claude` (always running with
|
||||
@@ -79,12 +276,35 @@ editor setup at all.
|
||||
|
||||
Typical loop: point it at a project, ask Claude to implement something and
|
||||
open a PR (`gitea-pr` is on `PATH`); from the host, `gitea-pr comments <PR#>`
|
||||
to pull review feedback, feed it back in, repeat.
|
||||
to pull review feedback, feed it back in, repeat. Merging is yours — see
|
||||
`gitea-merge-guard.sh` below. `gitea-issue` is on `PATH` too, for the work
|
||||
that gets deferred rather than done: `list`, `view`, `create`, `comment`,
|
||||
`close` and `reopen`, so a problem found mid-session becomes a ticket instead
|
||||
of a footnote on an unrelated PR.
|
||||
|
||||
Because the session stops at the PR and you merge afterwards, the sandbox
|
||||
context tells Claude to put `Fixes #N` in the PR body rather than to close
|
||||
issues by hand once they land — the merge is the last step in that sequence
|
||||
that still happens, and it happens when the agent is gone. `Refs #N` is the
|
||||
deliberate opposite, for work that touches a ticket without finishing it.
|
||||
|
||||
Both take `--body-file FILE` (or `-` for stdin) as well as `--body`. Prefer it
|
||||
for anything with an apostrophe, a backtick or a code fence in it — a body
|
||||
passed on a command line has to survive shell quoting first.
|
||||
|
||||
Inside the sandbox there's a second reason, and it argues for a real file over
|
||||
a heredoc. `deny-dangerous-bash.sh` matches patterns anywhere in the Bash
|
||||
command string, and it has no way to tell an instruction from prose that
|
||||
merely mentions one — so a PR or issue body that discusses restarting a host,
|
||||
or quotes a destructive command it is warning about, gets the whole call
|
||||
blocked. Writing the body to a file first keeps that text out of the command
|
||||
string entirely. A heredoc doesn't: the body is still part of the command the
|
||||
hook reads, so it solves the quoting problem but not this one.
|
||||
|
||||
### Using a different editor/application
|
||||
|
||||
```sh
|
||||
SANDBOX_CMD=nvim bin/dejima ~/repos/some-project
|
||||
SANDBOX_CMD=nvim dejima ~/repos/some-project
|
||||
```
|
||||
|
||||
Runs unconfigured unless you also set `SANDBOX_NVIM_CONFIG` to a real config
|
||||
@@ -94,7 +314,7 @@ don't fail on a read-only filesystem; nothing else in the config is
|
||||
writable):
|
||||
|
||||
```sh
|
||||
SANDBOX_CMD=nvim SANDBOX_NVIM_CONFIG=~/.config/nvim bin/dejima ~/repos/some-project
|
||||
SANDBOX_CMD=nvim SANDBOX_NVIM_CONFIG=~/.config/nvim dejima ~/repos/some-project
|
||||
```
|
||||
|
||||
Set both in your shell profile if you use this mode regularly.
|
||||
@@ -104,18 +324,26 @@ path (a helper script in a dotfiles `bin/`, say), bring it in with
|
||||
`SANDBOX_EXTRA_MOUNTS` (colon-separated, mounted read-only at the same path):
|
||||
|
||||
```sh
|
||||
SANDBOX_EXTRA_MOUNTS=~/dotfiles/bin bin/dejima ~/repos/some-project
|
||||
SANDBOX_EXTRA_MOUNTS=~/dotfiles/bin dejima ~/repos/some-project
|
||||
```
|
||||
|
||||
## Config parity with your host Claude Code setup
|
||||
|
||||
`~/.claude` is bind-mounted read-write, so global `CLAUDE.md`, settings,
|
||||
hooks, and plugins all carry over automatically, and your existing login
|
||||
stays effective too — no separate first-run login, though the credential
|
||||
itself doesn't enter the container directly (see "Security notes" for the
|
||||
host-side proxy that makes that work). (`~/.claude.json` is handled
|
||||
differently — a throwaway per-session copy, not the real file; see "Security
|
||||
notes" below for exactly what that means.)
|
||||
Global `CLAUDE.md`, settings, hooks, skills, and plugins all carry over
|
||||
automatically, and your existing login stays effective too — no separate
|
||||
first-run login, though the credential itself never enters the container
|
||||
(see [security notes](docs/security.md) for the host-side proxy that makes
|
||||
that work).
|
||||
|
||||
`~/.claude` itself is *not* shared as a whole: its contents are enumerated
|
||||
in `docker-compose.yml`, config files are copied in rather than mounted,
|
||||
and anything not listed is container-local — writable during the session,
|
||||
gone with the container. Session transcripts, plans, and the edit-undo
|
||||
record are on the list, so `claude --resume` on the host still works; a
|
||||
credential, cache, or newly-added config file is not. The
|
||||
[security notes](docs/security.md) explain the split and why single-file
|
||||
mounts couldn't be trusted here. (`~/.claude.json` is handled differently
|
||||
again — a throwaway per-session copy, not the real file.)
|
||||
|
||||
The project directory is mounted at the *same absolute path* inside the
|
||||
container as on the host, so Claude Code's session-history keying lines up
|
||||
@@ -129,7 +357,7 @@ The container's own user matches yours exactly — username, uid, gid, and
|
||||
home directory (see `Dockerfile`) — so `$HOME`-relative paths (dotfiles,
|
||||
Claude Code's plugin bookkeeping) resolve the same on both sides. Defaults to
|
||||
`node`/1000/1000/`/home/node` if you build the image directly instead of via
|
||||
`bin/dejima`. Images are tagged per-user (`dejima:<username>`)
|
||||
`dejima`. Images are tagged per-user (`dejima:<username>`)
|
||||
so concurrent builds on a shared machine don't clobber each other.
|
||||
|
||||
Your global `~/.gitconfig` also carries over (as a throwaway copy, so commits
|
||||
@@ -158,14 +386,14 @@ the public half as a Gitea deploy key scoped to just the repos that need
|
||||
push access, then:
|
||||
|
||||
```sh
|
||||
SANDBOX_SSH_KEY=~/.ssh/dejima_ed25519 bin/dejima ~/repos/some-project
|
||||
SANDBOX_SSH_KEY=~/.ssh/dejima_ed25519 dejima ~/repos/some-project
|
||||
```
|
||||
|
||||
`entrypoint.sh` loads it into a container-local `ssh-agent` at startup and
|
||||
deletes the plaintext copy immediately after — the sandbox user itself never
|
||||
has a path to the raw key, only the agent socket (see "Security notes" for
|
||||
the full mount rationale). Setting `SANDBOX_SSH_KEY` means a live forwarded
|
||||
agent is no longer required at all.
|
||||
has a path to the raw key, only the agent socket (see
|
||||
[security notes](docs/security.md) for the full mount rationale). Setting
|
||||
`SANDBOX_SSH_KEY` means a live forwarded agent is no longer required at all.
|
||||
|
||||
## Shared scratch space
|
||||
|
||||
@@ -175,9 +403,12 @@ from the project mount, for passing files (screenshots, temp scripts) in and
|
||||
out of a session without touching the actual checkout. Persists across
|
||||
sessions since it's a real directory, not a container volume.
|
||||
|
||||
`~/specs` is also bind-mounted read-write at the fixed host path `~/specs`
|
||||
(not configurable) — makes the global `CLAUDE.md` convention of writing specs
|
||||
there actually persist, instead of vanishing with the container.
|
||||
It's also the answer if your own `CLAUDE.md` tells agents to put something at
|
||||
a fixed host path — design docs, scratch notes, anything meant to outlive the
|
||||
session but stay out of the project checkout. Point that convention at
|
||||
`~/shared` (or set `SANDBOX_SHARE_DIR` to wherever you keep it) rather than
|
||||
expecting some other host directory to be present in the container: this is
|
||||
the only writable host path mounted besides the project itself.
|
||||
|
||||
## Testing frontend changes
|
||||
|
||||
@@ -188,7 +419,7 @@ or per-project `npm install playwright` needed.
|
||||
`localhost`/loopback traffic never goes through the egress firewall, so a
|
||||
project's own dev server needs no firewall changes. A real external site
|
||||
needs to be on the allowlist like any other network use
|
||||
(`bin/dejima-allow <domain>` for a running session, add
|
||||
(`dejima-allow <domain>` for a running session, add
|
||||
`--global`/`--project <path>` to persist it) — the firewall applies to the
|
||||
browser's traffic exactly like `curl`/`git`.
|
||||
|
||||
@@ -211,7 +442,7 @@ 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` is shared.
|
||||
(`PreToolUse`) also applies here, since `~/.claude/hooks` is mounted in.
|
||||
|
||||
`git-push-guard.sh` (`PreToolUse`/`Bash`, also `DEJIMA`-gated) blocks
|
||||
any `git push` whose resolved remote isn't the Gitea host, and any push to
|
||||
@@ -222,6 +453,17 @@ GitHub push is blocked by default too (see "GitHub push" below) unless
|
||||
`ALLOW_GITHUB_PUSH=1` is set. For a real guarantee against pushes to
|
||||
main/master, use branch protection on the remote itself.
|
||||
|
||||
`gitea-merge-guard.sh` (`PreToolUse`/`Bash`, `DEJIMA`-gated) blocks
|
||||
`gitea-pr merge` inside the sandbox. It's the API-side counterpart to the rule
|
||||
above: a merge puts a branch on `main` without ever performing a push, so
|
||||
`git-push-guard.sh` never sees it. A hook is handed a command rather than a
|
||||
conversation, so it can't tell an instructed merge from an unprompted one —
|
||||
which is why it's scoped to the sandbox rather than applied everywhere. On the
|
||||
host it does nothing, and you can still ask an agent to merge. The same
|
||||
fast-fail caveat applies with more force than usual here: the session has the
|
||||
Gitea token in its environment and could call the merge endpoint with `curl`
|
||||
directly. Branch protection on the remote is the real guarantee.
|
||||
|
||||
`sandbox-context.sh` also tells the agent the actual workflow to follow: do
|
||||
new work in a `git worktree` under the mounted project (e.g.
|
||||
`.worktrees/<branch>`, never `../<branch>` or elsewhere under `$HOME` — only
|
||||
@@ -241,12 +483,12 @@ default:
|
||||
- `registry.npmjs.org`, `pypi.org`, `files.pythonhosted.org` (npm and
|
||||
mason's Python-based LSP tools), `release-assets.githubusercontent.com`
|
||||
(GitHub release assets, a separate CDN from github.com's own IPs),
|
||||
`proxy.golang.org`, `sum.golang.org` (`go` module proxy/checksums),
|
||||
`api.anthropic.com`
|
||||
`proxy.golang.org`, `sum.golang.org` (`go` module proxy/checksums);
|
||||
`api.anthropic.com` when the credential proxy is off (kill switch or API-key auth)
|
||||
- whatever `GITEA_SSH_HOST`/`GITEA_API` resolve to — not port-restricted, so
|
||||
git-over-ssh to Gitea works without a blanket "port 22 to anywhere" rule
|
||||
|
||||
Everything else is rejected. Run `bin/dejima-allow <domain> --global`
|
||||
Everything else is rejected. Run `dejima-allow <domain> --global`
|
||||
(or `--project <path>` for one project) to allow more permanently (e.g.
|
||||
`crates.io` for a Rust project), or `ENABLE_FIREWALL=0` to disable the
|
||||
firewall entirely (debugging only).
|
||||
@@ -278,8 +520,8 @@ Approved domains only take effect at container start. For a domain a
|
||||
sandbox — it has no privilege to do this itself):
|
||||
|
||||
```sh
|
||||
bin/dejima-allow crates.io # this session only
|
||||
bin/dejima-allow crates.io --global # + persist for every project
|
||||
dejima-allow crates.io # this session only
|
||||
dejima-allow crates.io --global # + persist for every project
|
||||
```
|
||||
|
||||
It first adds the domain to the running dnsmasq's allowlist (appends a
|
||||
@@ -471,7 +713,7 @@ GitHub and let `git-push-guard.sh` accept `github.com`/`ssh.github.com` as a
|
||||
push destination (the no-direct-to-main rule still applies):
|
||||
|
||||
```sh
|
||||
ALLOW_GITHUB_PUSH=1 bin/dejima ~/repos/some-project
|
||||
ALLOW_GITHUB_PUSH=1 dejima ~/repos/some-project
|
||||
```
|
||||
|
||||
**Read this before turning it on.** Whether a push actually succeeds depends
|
||||
@@ -492,303 +734,31 @@ make HTTPS push possible independent of `ALLOW_GITHUB_PUSH`, so don't use
|
||||
one.
|
||||
|
||||
`ALLOW_GITHUB_PUSH` is a per-launch variable, not a persistent toggle — it
|
||||
only affects the one `bin/dejima` invocation it's set for.
|
||||
only affects the one `dejima` invocation it's set for.
|
||||
|
||||
## Remote access
|
||||
|
||||
**Opt-in, and off entirely unless you create `~/.config/dejima/remote.conf`.**
|
||||
Reach a running sandbox from another machine or a phone - over SSH or a
|
||||
browser - and get a push when a session wants you. Two front doors and a
|
||||
notification path, none of which exist until you configure them.
|
||||
|
||||
It is documented separately because getting the bind address wrong is the one
|
||||
mistake here that exposes a `--dangerously-skip-permissions` shell to your
|
||||
network, and that deserves more room than a section.
|
||||
|
||||
**→ [docs/remote-access.md](docs/remote-access.md)** - `remote.conf`, picking a
|
||||
bind address and verifying it, the SSH and web front doors, reading and typing
|
||||
on a phone, running behind another reverse proxy, notifications, and attaching.
|
||||
|
||||
## Security notes
|
||||
|
||||
**Threat model, honestly stated:** this reduces the blast radius of a
|
||||
misbehaving or prompt-injected coding agent - it does not make it safe to
|
||||
point at arbitrary untrusted or adversarial input, and it does not turn
|
||||
`--dangerously-skip-permissions` into something without real risk. Point it
|
||||
at repositories you already trust. Everything below is about how bad a
|
||||
compromised session *inside* that trust boundary can get, not a claim that
|
||||
one can't happen.
|
||||
What this sandbox does and does not protect you from - the containment
|
||||
boundary, what a compromised session can still reach, the deliberate
|
||||
trade-offs behind `--dangerously-skip-permissions`, and why the exit-time
|
||||
audit's findings are a time window, not a verdict on who caused them.
|
||||
|
||||
- `--dangerously-skip-permissions` removes your chance to review tool calls
|
||||
before they run. Only point this at trusted repositories.
|
||||
- The mounted project itself has **no write protection**. `deny-dangerous-
|
||||
bash.sh` and `git-push-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.
|
||||
- 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
|
||||
the firewall doesn't see it either. Same underlying persistence pattern
|
||||
as the `~/.claude/hooks` and `~/.claude.json` 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 now layered read-only
|
||||
(same pattern as the `~/.claude` overlays below), so planting a hook
|
||||
file directly is closed - accepted breakage: installing hooks
|
||||
*in-session* (`husky`, `pre-commit install`) now fails, which is the
|
||||
point. `core.hooksPath` and `.claude/settings.local.json` aren't
|
||||
overlaid (they're config, not fixed paths a static mount can cover) and
|
||||
remain fully writable; the exit-time audit below reports changes to
|
||||
either, but only after the session ends - it doesn't stop them.
|
||||
- `bin/dejima-audit` snapshots host-persistence surfaces before each
|
||||
run and diffs them after the container exits: `.git/config` (and worktree
|
||||
`config.worktree`) keys that run a command on the next `git` -
|
||||
`core.hooksPath`, `core.fsmonitor`, `core.sshCommand`,
|
||||
`credential.helper`, `core.pager`, `pager.*`, `core.editor`,
|
||||
`sequence.editor`, `gpg.program`, `diff.external`, `merge.*.driver`,
|
||||
`diff.*.command`, `filter.*.smudge`/`clean`/`process`, `diff.*.textconv`,
|
||||
`include.path`/`includeif.*.path` (config indirection - these can point at
|
||||
a planted file that itself sets an exec key, so the exec key never
|
||||
appears in `.git/config` directly), `!`-shell aliases - plus content
|
||||
changes under the workspace's `.claude/`, `.mcp.json`, `.envrc`,
|
||||
`.vscode/`, and `.git/hooks/` (symlinks included - a repointed symlink is
|
||||
hashed by its target, not just its content), plus cross-project writes
|
||||
under `~/.claude/projects` (see below). When the mounted workspace *is*
|
||||
this repo's own sandbox itself (see the trusted-computing-base note
|
||||
further below), it also diffs content hashes of the sandbox's own trusted
|
||||
computing base - `bin/`, `hooks/`, `docker/`, `install.sh`,
|
||||
`test/` - under that workspace. It's **advisory only**: it fires *after*
|
||||
the session has already ended, so a report of a planted `core.hooksPath`
|
||||
or hook file is something to read and clean up *before* the next `git`
|
||||
or `claude` in that repo, not something that was blocked. It's silent when nothing
|
||||
changed, and never affects the session's own exit code even if it can't
|
||||
complete. It doesn't cover `$SANDBOX_SHARE_DIR` (see "Shared scratch
|
||||
space") - that hand-off path stays unaudited - and its coverage is a
|
||||
fixed list of known Claude Code config surfaces, not a general integrity
|
||||
check, so a future config surface this list hasn't been taught about yet
|
||||
would go unreported.
|
||||
- `~/.claude/projects` (session transcripts and per-project memory) is
|
||||
**not** mounted read-only, unlike the other `~/.claude` overlays above -
|
||||
deliberately. Scoping a read-only overlay to "the current project's slug"
|
||||
would break the sandbox's own worktree workflow: a session running in
|
||||
`WORKSPACE/.worktrees/<branch>` writes to a *different* projects slug
|
||||
than the parent repo, so a slug-scoped overlay would either block that
|
||||
legitimate write or have to be widened until it stopped protecting
|
||||
anything. Instead, cross-project writes are **detected, not prevented**:
|
||||
the exit-time audit above flags any write during the session to another
|
||||
project's `~/.claude/projects/<slug>` directory, excluding the current
|
||||
project's own slug and its worktree children. That's after-the-fact
|
||||
reporting of transcript/memory poisoning in *other* projects, not a
|
||||
block on it happening. Residual even within that: the current project's
|
||||
own transcripts/memory stay fully writable throughout, since Claude Code
|
||||
needs that for host-side `claude --resume` to work; and detection keys
|
||||
off each slug's max file mtime, so an edit to a file that isn't already
|
||||
that project's newest doesn't advance the max and goes unflagged. A
|
||||
read-only mount for `projects/` remains a possible future option if
|
||||
Claude Code is confirmed to fail cleanly (not hang, as `~/.claude.json`
|
||||
did) when a write to it is blocked.
|
||||
- Allowing egress to a domain at all creates a channel for exfiltrating data
|
||||
through *that service's own request surface* (query strings, lookups,
|
||||
etc.), not just to a literal attacker-controlled destination. Domain-level
|
||||
allowlisting closes off reaching somewhere new; it can't inspect or
|
||||
restrict what gets sent to somewhere already trusted for normal operation
|
||||
(installing packages, calling the model API). That's a structural limit of
|
||||
allowlist-only firewalls in general, not something specific to this setup,
|
||||
and nothing here addresses it.
|
||||
- This is container-level isolation (Linux namespaces plus a small
|
||||
capability set), not VM-level isolation - no gVisor/Firecracker or
|
||||
similar. It shares the host kernel, so a container-escape vulnerability
|
||||
would defeat every other control described here at once.
|
||||
- Everything in this document has been verified by hand, at a point in
|
||||
time - there's no automated test suite re-checking these properties on
|
||||
every change, so a future edit could silently reintroduce a gap. This has
|
||||
already happened once in this file's own history (a missing `ipset`
|
||||
dedup flag broke firewall startup entirely, twice, on two different
|
||||
ipsets, before being caught).
|
||||
- The Anthropic OAuth credential (`~/.claude/.credentials.json`) never
|
||||
enters the container. `bin/dejima` starts a host-side proxy
|
||||
(`bin/dejima-anthropic-proxy`) on a per-session unix socket,
|
||||
mounts an empty file over `.credentials.json` (the parent `~/.claude`
|
||||
mount stays read-write, so a single-file mask is the mechanism), and
|
||||
hands Claude Code a placeholder token plus an `ANTHROPIC_BASE_URL`
|
||||
pointing at a loopback forwarder that pipes into the socket. The proxy
|
||||
strips the placeholder, injects the real access token per request, and
|
||||
owns OAuth refresh - rotated tokens are written back to the real host
|
||||
file atomically, so host-side sessions stay logged in. What remains is
|
||||
session-scoped: a compromised session can still *use* the model API as
|
||||
you while it runs (that's inherent to the sandbox working at all), but
|
||||
it can no longer read a durable credential and exfiltrate it for
|
||||
offline use via the other allowed egress destinations (npm, GitHub,
|
||||
your Gitea host). Caveats: `/login` inside the container doesn't work
|
||||
while the proxy is on; the refresh flow mirrors a Claude Code internal
|
||||
and may break if upstream changes it, in which case the proxy fails
|
||||
loudly (launch abort, 502s on API calls) rather than silently.
|
||||
`SANDBOX_CREDENTIAL_PROXY=0` is the kill switch, restoring the previous
|
||||
behavior - the credential mounted into the container, readable by a
|
||||
compromised session. Setups without a `claudeAiOauth` block (API-key
|
||||
auth) skip the proxy automatically; there's no stored subscription
|
||||
credential to protect.
|
||||
- `settings.json`, `CLAUDE.md`, `plugins/`, `hooks/`, `skills/`, `commands/`,
|
||||
`agents/`, and `output-styles/` under `~/.claude` are layered back
|
||||
read-only on top of the (otherwise read-write) `~/.claude` mount. Hooks
|
||||
execute automatically with no separate 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 `bin/dejima` adds at launch only
|
||||
protect symlink targets that resolve *outside* `~/.claude`, never this
|
||||
directory itself - rewrite `CLAUDE.md` with injected instructions, tamper
|
||||
with plugin code, or drop a malicious skill/command/agent/output-style
|
||||
file - something that would then run or load **unsandboxed on the host**
|
||||
the next time Claude Code starts there, turning a single compromised
|
||||
sandbox session into persistent host compromise. `skills/`, `commands/`,
|
||||
`agents/`, and `output-styles/` may not exist yet on a given host, so
|
||||
`bin/dejima` pre-creates them at launch (empty is fine) - a
|
||||
read-only overlay only applies to a directory that already exists, and
|
||||
without pre-creation a compromised session could create the directory
|
||||
itself first, then populate it, before the overlay ever had something to
|
||||
sit on top of. Everything else under `~/.claude` (session/project
|
||||
history, caches) stays read-write, since it's runtime state Claude Code
|
||||
needs to update - this list is a denylist of
|
||||
known code/config surfaces, not an allowlist, so it has to be kept in
|
||||
step with whatever new config surfaces Claude Code adds.
|
||||
- `~/.claude/statusline.sh` (referenced by `settings.json`'s
|
||||
`statusLine.command` and executed on the host) is bind-mounted onto
|
||||
itself read-only by `bin/dejima`, for the same reason as the
|
||||
list above - to stop a compromised session from unlinking it and dropping
|
||||
its own script in its place. **Confirmed against a live container**
|
||||
(`test/runtime.bats`'s statusline.sh tests): this reliably blocks
|
||||
replacement when `statusline.sh` is a plain regular file. It does
|
||||
**not** when `statusline.sh` is a symlink (the common case - typically
|
||||
into a dotfiles checkout): Docker/runc resolves the bind-mount
|
||||
destination through the symlink to its target directory rather than
|
||||
binding to the symlink dentry itself, so the symlink entry stays on the
|
||||
writable `~/.claude` mount, still replaceable. There is no mount-based
|
||||
fix for this (`~/.claude` can't be made read-only - session history has
|
||||
to stay writable). The compensating control is detection, not
|
||||
prevention: `bin/dejima-audit` hashes `statusline.sh` (following
|
||||
a symlink to its target *path string*, so a repointed or replaced
|
||||
symlink is caught even though its own inode doesn't change) and reports
|
||||
it as a host-code-execution finding when the session exits. This is a
|
||||
defense-in-depth gap, not an open one: exploiting it requires a sandbox
|
||||
session that's already compromised, and that compromise is caught on
|
||||
exit either way.
|
||||
- `~/.claude.json` mixes that same kind of live state (project registry,
|
||||
usage stats, caches) with config (`mcpServers`). Unlike the four above,
|
||||
it can't just be layered read-only: Claude Code does an atomic
|
||||
write-temp-then-rename against it during normal startup, before login
|
||||
even completes, and a read-only file makes that **hang** instead of
|
||||
failing cleanly (confirmed against a real read-only copy of this file -
|
||||
upstream bug: [anthropics/claude-code#29162](https://github.com/anthropics/claude-code/issues/29162)).
|
||||
Instead, `bin/dejima` generates a throwaway copy of the file at
|
||||
launch (seeded from the real file, same "generate a sandbox-specific
|
||||
file on the host" pattern as the generated `settings.json` copy above)
|
||||
and mounts *that* read-write in its place; the copy is discarded,
|
||||
unmodified in the real file, when the session ends. A compromised
|
||||
session can add whatever it wants to `mcpServers` (or anything else in
|
||||
the file) - it never reaches the real `~/.claude.json`, so there's
|
||||
nothing to persist. This also means multiple concurrent sandbox sessions
|
||||
never contend over the same file - each gets its own independent copy.
|
||||
|
||||
### Throwaway ~/.claude.json: what doesn't carry over
|
||||
|
||||
Because nothing writes back, anything a *sandboxed* session would
|
||||
normally have caused Claude Code to record into `~/.claude.json` is lost
|
||||
when that session ends, instead of showing up in a later host-side
|
||||
`claude` run. Concretely, as of the fields this file holds today:
|
||||
|
||||
- **Per-project trust/approval state** - `hasTrustDialogAccepted`,
|
||||
`allowedTools`, `enabledMcpjsonServers`/`disabledMcpjsonServers` - for
|
||||
any project directory opened only inside the sandbox. Opening that same
|
||||
project on the host later re-prompts for whatever it would have
|
||||
prompted for outside the sandbox too. Largely moot today since the
|
||||
sandbox always runs `--dangerously-skip-permissions`, but the trust
|
||||
dialog is a separate gate from tool-permission prompts.
|
||||
- **`mcpServers` additions or edits** - the point of this fix, not an
|
||||
incidental side effect. Nothing added or changed here during a
|
||||
sandboxed session ever reaches the real file.
|
||||
- **Session/usage telemetry** - `lastCost`, `lastDuration`,
|
||||
`lastTotalInputTokens`/`lastTotalOutputTokens`, `lastModelUsage`,
|
||||
`lastSessionId`, `mcpContextUris`, and similar per-project fields.
|
||||
Sandbox activity won't show up in host-side usage totals or in
|
||||
`claude --resume`'s "last session" metadata for that project.
|
||||
- **Global counters and caches** - `numStartups`, `firstStartTime`,
|
||||
`cachedGrowthBookFeatures`, `changelogLastFetched`,
|
||||
`announcementImpressions`, `tipsHistory`, `feedbackSurveyState`, and
|
||||
similar top-level bookkeeping. Purely cosmetic - these get refetched or
|
||||
recomputed as needed; sandbox runs just don't contribute to the host's
|
||||
copies of them.
|
||||
- **`oauthAccount`/`userID`/`machineID`** are read fresh from the real
|
||||
file at the start of every sandbox launch, so authentication carries in
|
||||
normally each time; anything that changes about them *during* a
|
||||
sandboxed session (e.g. a token refresh) doesn't carry back out. Worth
|
||||
re-checking if a future Claude Code version starts relying on that for
|
||||
normal operation.
|
||||
- DNS resolution itself is allowlisted (see "DNS is allowlisted too" above),
|
||||
not just the IP connections made afterward, closing off exfiltration via
|
||||
encoding data into query names to an attacker-controlled domain - verified
|
||||
a non-allowlisted domain gets refused outright (`status: REFUSED`) rather
|
||||
than resolving and then only failing to connect.
|
||||
- Don't mount other host secrets (`~/.ssh` private keys, cloud credential
|
||||
files) into the container. Git push auth goes through the forwarded SSH
|
||||
agent instead, which never exposes the key material itself - though note
|
||||
the agent will sign for *any* Gitea repo your key has access to, not just
|
||||
the mounted project, and the firewall's Gitea allowlist is host-based
|
||||
rather than repo-scoped, so a compromised session can still reach (and
|
||||
push to) other repos on that host you have access to. This doesn't extend
|
||||
to GitHub by default: port 22 there is blocked unless `ALLOW_GITHUB_PUSH=1`
|
||||
is set, so the forwarded agent has no transport to use for it at all
|
||||
otherwise (see "GitHub push: blocked by default, optional opt-in" above).
|
||||
- `SANDBOX_SSH_KEY` (see "Headless git push without agent forwarding") is a
|
||||
second, independent credential-loading path with the same "signs for any
|
||||
Gitea repo it has access to" shape as the forwarded agent above - the
|
||||
mitigation is that it's meant to be a dedicated deploy key scoped to only
|
||||
the repos that actually need push access, not your personal identity.
|
||||
That scoping is a convention, not something enforced here: nothing checks
|
||||
what `SANDBOX_SSH_KEY` actually points at, so pointing it at your real
|
||||
`~/.ssh/id_ed25519`/`id_rsa` instead of a dedicated key would load your
|
||||
full personal identity into the container for the whole session (`bin/
|
||||
dejima` warns if this looks like it might be happening - see
|
||||
below - but doesn't block it). The key source itself is mounted read-only
|
||||
at `/root/...`, not under the sandbox user's own home, specifically so
|
||||
that user (which the session actually runs as) has no path to read the
|
||||
raw key material - only the agent socket it's loaded into.
|
||||
- The gitconfig copy `bin/dejima` generates (see that mount's
|
||||
comment in `docker-compose.yml`) carries over whatever's in your real
|
||||
`~/.gitconfig` verbatim. If yours has inline credentials (a
|
||||
`[credential]` token, a `url.<token@>.insteadOf` rewrite, a
|
||||
`credential.helper store` path), those enter the container along with
|
||||
it - an unadvertised secret-carrying path distinct from the
|
||||
SSH-agent/token story documented above. Keep credentials out of your
|
||||
global gitconfig, or expect them to be reachable from inside the
|
||||
sandbox.
|
||||
- The sandbox's own user (matching your host user — see "Config parity"
|
||||
above) 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 `$SANDBOX_CMD`. 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.
|
||||
- The approval store (`${DEJIMA_CONFIG_DIR:-$HOME/.config/dejima}`,
|
||||
see "Global and per-project domains and dependencies" above) lives outside
|
||||
this repo, and outside every project mount, by default - unlike a
|
||||
project's own `.dejima/` declaration files, it's never
|
||||
bind-mounted into the container, so a compromised
|
||||
session has no path to write to it at all, let alone plant something for
|
||||
the *next* launch to pick up. Its files are read by a small
|
||||
line-oriented parser (`approved_entries` in
|
||||
`bin/dejima-project-lib.sh`), never sourced or executed, and are
|
||||
written only by the CLIs (`dejima-allow`, `dejima-dep`,
|
||||
`dejima-project`) a human runs on the host. `bin/dejima`
|
||||
warns at launch whenever the resolved store path — default or overridden
|
||||
via `DEJIMA_CONFIG_DIR` — lands under the mounted workspace,
|
||||
rather than silently trusting it.
|
||||
- When the mounted workspace *is* this repo itself, `bin/dejima`
|
||||
warns at launch that the repo's own trusted computing base - `bin/`,
|
||||
`hooks/`, `docker/`, `install.sh` (also `test/`, per the
|
||||
exit-time audit's exact TCB path list above) - is writable for the
|
||||
session (it's inside the project mount, which has no write protection,
|
||||
per above), and that the exit-time audit will report any changes to it.
|
||||
That's a warning and a post-hoc report, not a block: a compromised
|
||||
session can still edit those files during the run, and whatever it
|
||||
changes takes effect the *next* time `bin/dejima` itself runs,
|
||||
not the current one.
|
||||
**→ [docs/security.md](docs/security.md)**
|
||||
|
||||
## Tests
|
||||
|
||||
@@ -799,13 +769,19 @@ instead of finding out the hard way:
|
||||
|
||||
```sh
|
||||
bats test/ # everything
|
||||
bats test/static.bats # fast, no Docker/build needed
|
||||
bats test/static.bats test/project.bats test/remote.bats test/audit.bats # fast, no Docker/build needed
|
||||
SKIP_BUILD=1 bats test/runtime.bats # reuse the current image
|
||||
```
|
||||
|
||||
`.gitea/workflows/tests.yml` runs all of it on every pull request and on every
|
||||
push to one, `runtime.bats` included. That job needs two things from the
|
||||
runner that an ordinary one doesn't — the host's Docker socket and the host
|
||||
network namespace — and it checks for both up front rather than failing
|
||||
obscurely partway through; the file says why each is needed.
|
||||
|
||||
`static.bats` greps the repo's own files - no sudo in the `Dockerfile`, every
|
||||
`ipset add` is duplicate-safe, no `docker.sock`/host-credential mounts, the
|
||||
`~/.claude` read-only overlay lines are present, `bin/dejima-allow`
|
||||
`~/.claude` mount inventory is exactly what it should be, `dejima-allow`
|
||||
rejects a malicious domain argument. `runtime.bats` builds the image, boots
|
||||
a real container running the actual `entrypoint.sh` (a `SANDBOX_TEST_MODE`
|
||||
env var makes it stop just short of execing `$SANDBOX_CMD`, so the suite can attach
|
||||
@@ -816,6 +792,24 @@ enforcement in both directions, GitHub read-only, no working `sudo`/
|
||||
(not just absent from a `docker exec` that doesn't have them to begin
|
||||
with), `ulogd2` unprivileged, and the dynamic-ipset CDN-rotation fix.
|
||||
|
||||
The other three need no Docker either. `project.bats` covers the
|
||||
declaration/approval split - what `.dejima/` may ask for, what only the host
|
||||
can grant, and that the approval store is never mounted. `remote.bats`
|
||||
covers everything in [docs/remote-access.md](docs/remote-access.md):
|
||||
`remote.conf` parsing, the session registry, and the properties the
|
||||
feature's security rests on - that the
|
||||
notification path carries no sandbox-controlled byte, and that no marker
|
||||
name outside `waiting`/`done` is ever acted on. `audit.bats` covers
|
||||
`bin/dejima-audit`'s own diffing.
|
||||
|
||||
One assertion is worth calling out because it can only run for real on a
|
||||
Docker host: `runtime.bats` proves that a service bound to the host's
|
||||
loopback is unreachable from inside a container while one bound to the
|
||||
bridge gateway *is* reachable. The second half is a positive control - it is
|
||||
what stops the first from passing in an environment where the container
|
||||
simply reaches nothing. That pair is the ttyd front door's whole containment
|
||||
argument, so run it before trusting a front door you just set up.
|
||||
|
||||
Every mutation in `runtime.bats` (firewall rules, `ipset` entries) happens
|
||||
inside the test container's own network namespace, same as any other
|
||||
container - none of it touches the host. Container/network names are
|
||||
@@ -826,7 +820,7 @@ likely to collide with a real container you have running.
|
||||
|
||||
| File | Purpose |
|
||||
| --- | --- |
|
||||
| `docker/Dockerfile` | Base image, dev tools, nvim, Claude Code + wrapper, `gitea-pr` |
|
||||
| `docker/Dockerfile` | Base image, dev tools, nvim, Claude Code + wrapper, `gitea-pr`/`gitea-issue` |
|
||||
| `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 |
|
||||
@@ -838,10 +832,19 @@ likely to collide with a real container you have running.
|
||||
| `hooks/sandbox-context.sh` | `SessionStart` hook: injects sandbox context/workflow reminders |
|
||||
| `hooks/git-push-guard.sh` | `PreToolUse` hook: blocks pushes outside the allowed host(s)/branches |
|
||||
| `hooks/deny-dangerous-bash.sh` | `PreToolUse` hook: blocks obviously destructive Bash commands |
|
||||
| `hooks/gitea-merge-guard.sh` | `PreToolUse` hook: blocks `gitea-pr merge` in the sandbox — merging is a human decision |
|
||||
| `hooks/dejima-notify.sh` | `Notification`/`Stop` hook: drops an empty `waiting`/`done` marker for a remote-enabled session - see [docs/remote-access.md](docs/remote-access.md) |
|
||||
| `bin/dejima-attach` | Lists live sessions and attaches to one; the shared core behind both remote front doors |
|
||||
| `bin/dejima-audit` | Advisory exit-time diff of host-persistence surfaces (git config, `.claude/`, etc.) |
|
||||
| `bin/dejima-dep` | Review and approve project dependencies declared in `.dejima/dependencies` |
|
||||
| `bin/gitea-pr` | Create a PR, read its comments, merge it; baked into the image so the sandbox can open PRs |
|
||||
| `bin/gitea-issue` | List, view, create, comment on, close and reopen Gitea issues; also baked into the image |
|
||||
| `bin/dejima-notify-watch` | Host-side watcher: turns markers into ntfy pushes composed entirely from host data |
|
||||
| `bin/dejima-project` | Review and approve project domains and dependencies from the host |
|
||||
| `bin/dejima-project-lib.sh` | Shared library sourced by `bin/dejima-dep`, `bin/dejima-project`, and `bin/dejima`; not on PATH |
|
||||
| `bin/dejima-project-lib.sh` | Shared library sourced by `bin/dejima-dep`, `bin/dejima-project`, `bin/dejima` and the remote-access scripts; not on PATH |
|
||||
| `bin/dejima-ttyd` | Launches `ttyd` on loopback in front of `dejima-attach` |
|
||||
| `systemd/*.service` | User units for `dejima-ttyd` and `dejima-notify-watch`; ship disabled, installed by hand |
|
||||
| `docs/nginx-dejima.conf.example` | Reverse-proxy template for the web front door; deliberately not loadable as written |
|
||||
|
||||
`bin/dejima` is the entry point — it resolves the project path,
|
||||
checks for a live `ssh-agent`, loads `GITEA_TOKEN`/`GH_TOKEN` if not already
|
||||
|
||||
+393
-94
@@ -52,17 +52,36 @@ if [ -n "$PROJECT_STORE" ]; then
|
||||
ALLOWED_DOMAINS="${ALLOWED_DOMAINS}${project_domains:+ $project_domains}"
|
||||
fi
|
||||
|
||||
# The approval store is only out of the container's reach while it isn't
|
||||
# inside the mounted workspace - same class of warning as the self-mounted
|
||||
# repo TCB warning below.
|
||||
case "$(config_root)/" in
|
||||
"$WORKSPACE_DIR"/*)
|
||||
echo "Warning: the approval store ($(config_root)) is inside the mounted" >&2
|
||||
echo "workspace this session - both the global approvals and every" >&2
|
||||
echo "project's under it are writable from inside the sandbox, so they" >&2
|
||||
echo "are no longer out of reach. Review them after this session." >&2
|
||||
;;
|
||||
esac
|
||||
# The approval store, and the exit-audit's own report/baseline stores, are
|
||||
# only out of the container's reach while none of them are inside the
|
||||
# mounted workspace - same class of warning as the self-mounted repo TCB
|
||||
# warning below.
|
||||
AUDIT_DIR="${DEJIMA_AUDIT_DIR:-${XDG_DATA_HOME:-$HOME/.local/share}/dejima/audit}"
|
||||
BASELINE_DIR="${DEJIMA_AUDIT_BASELINE_DIR:-${XDG_DATA_HOME:-$HOME/.local/share}/dejima/baselines}"
|
||||
# The three stores fail in three different ways once they are reachable, and
|
||||
# an operator who sees this needs to know which one they are looking at - so
|
||||
# each states its own consequence rather than sharing one text. Consequence
|
||||
# lines are hand-wrapped by the caller and printed one per argument.
|
||||
warn_if_inside_workspace() {
|
||||
local what="$1" store="$2"; shift 2
|
||||
case "$store/" in
|
||||
"$WORKSPACE_DIR"/*) ;;
|
||||
*) return 0 ;;
|
||||
esac
|
||||
echo "Warning: the $what ($store) is inside the mounted" >&2
|
||||
echo "workspace this session, writable from inside the sandbox:" >&2
|
||||
printf ' %s\n' "$@" >&2
|
||||
echo "Review it after this session." >&2
|
||||
}
|
||||
warn_if_inside_workspace "approval store" "$(config_root)" \
|
||||
"the global approvals and every project's under it can be edited," \
|
||||
"so a session can approve its own domains and dependencies."
|
||||
warn_if_inside_workspace "audit report store" "$AUDIT_DIR" \
|
||||
"a session can delete the exit-time reports of earlier runs," \
|
||||
"including unacknowledged CRITICAL ones it is named in."
|
||||
warn_if_inside_workspace "audit baseline store" "$BASELINE_DIR" \
|
||||
"a session can write its own acknowledgements, suppressing the" \
|
||||
"findings it would otherwise produce here from every later report."
|
||||
|
||||
SANDBOX_SSH_KEY="${SANDBOX_SSH_KEY:-}"
|
||||
if [ -n "$SANDBOX_SSH_KEY" ] && [ ! -f "$SANDBOX_SSH_KEY" ]; then
|
||||
@@ -139,28 +158,60 @@ fi
|
||||
SANDBOX_SHARE_DIR="${SANDBOX_SHARE_DIR:-$HOME/.local/share/dejima/shared}"
|
||||
mkdir -p "$SANDBOX_SHARE_DIR"
|
||||
|
||||
# ~/.claude/CLAUDE.md (carried into the container) tells agents to write
|
||||
# specs to ~/specs/ rather than a project-local path - make sure that
|
||||
# actually exists before it's bind-mounted, for a fresh machine that hasn't
|
||||
# used the convention yet.
|
||||
mkdir -p "$HOME/specs"
|
||||
# ~/.claude is no longer mounted as a whole (see the mount inventory in
|
||||
# docker-compose.yml) - every path that crosses into the container is named
|
||||
# individually, and Docker answers a missing bind source by creating a
|
||||
# root-owned *directory* at it. That would leave junk on the host and, for
|
||||
# the read-only config dirs, a writable gap instead of an overlay. Pre-create
|
||||
# them all: empty is fine, existence is what matters.
|
||||
mkdir -p "$HOME/.claude/skills" "$HOME/.claude/commands" "$HOME/.claude/agents" \
|
||||
"$HOME/.claude/output-styles" "$HOME/.claude/hooks" "$HOME/.claude/plugins"
|
||||
mkdir -p "$HOME/.claude/projects" "$HOME/.claude/file-history" "$HOME/.claude/backups" \
|
||||
"$HOME/.claude/tasks" "$HOME/.claude/plans" "$HOME/.claude/sessions"
|
||||
|
||||
# skills/commands/agents/output-styles overlays (see docker-compose.yml) can
|
||||
# only apply if the source dirs exist - pre-create them so a host that has
|
||||
# never used one still gets the read-only overlay instead of a writable gap.
|
||||
mkdir -p "$HOME/.claude/skills" "$HOME/.claude/commands" "$HOME/.claude/agents" "$HOME/.claude/output-styles"
|
||||
# Same for settings.json, which install.sh normally creates, and CLAUDE.md,
|
||||
# which many hosts don't have at all.
|
||||
[ -f "$HOME/.claude/settings.json" ] || echo '{}' > "$HOME/.claude/settings.json"
|
||||
[ -f "$HOME/.claude/CLAUDE.md" ] || : > "$HOME/.claude/CLAUDE.md"
|
||||
|
||||
# Claude Code ignores a settings.json it can't parse, wholesale and with only
|
||||
# a startup warning, so a stray edit on the host costs the sandbox every
|
||||
# permission, hook and plugin the operator configured - including
|
||||
# skipDangerousModePermissionPrompt, which is why the first visible symptom is
|
||||
# an unexpected bypass-mode warning rather than anything mentioning settings.
|
||||
# It used to be caught by accident: the sandbox copy was generated by running
|
||||
# the host file through jq, and set -e stopped the launch. The file is staged
|
||||
# verbatim now, so check it here instead, where the fix is at hand.
|
||||
if ! settings_error="$(jq . "$HOME/.claude/settings.json" 2>&1 >/dev/null)"; then
|
||||
echo "$HOME/.claude/settings.json is not valid JSON:" >&2
|
||||
echo " $settings_error" >&2
|
||||
echo "Claude Code would ignore it entirely and the sandbox would start with" >&2
|
||||
echo "none of your settings. Fix it, then retry." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Claude Code's own internal per-command sandboxing (~/.claude/settings.json's
|
||||
# "sandbox" block) is redundant here - it can't even function inside this
|
||||
# container (bwrap can't create a namespace, and getting it to would need
|
||||
# privilege escalation approaching --privileged, undoing this container's own
|
||||
# hardening), and this container is already a real, working sandbox on its
|
||||
# own. settings.json is mounted read-only precisely so a session can't
|
||||
# tamper with it, so it can't be patched from inside - generate a sandbox-
|
||||
# specific copy here instead and mount that in place of the real one.
|
||||
# Everything else in it (hooks, permissions, plugins) carries over unchanged.
|
||||
# own. A session can't be trusted to turn it off itself, so the override is
|
||||
# generated here, on the host, and reaches Claude Code as an extra settings
|
||||
# layer: docker-compose.yml mounts this file read-only at a container-local
|
||||
# path and claude-wrapper.sh passes it as --settings, which loads above
|
||||
# ~/.claude/settings.json in the precedence order. Everything else in the
|
||||
# operator's real settings (hooks, permissions, plugins) still applies from
|
||||
# there, so this file holds only what the sandbox overrides.
|
||||
#
|
||||
# The earlier arrangement - a full modified copy of the operator's settings,
|
||||
# mounted over ~/.claude/settings.json - detached itself whenever the host
|
||||
# rewrote the real file, because that mount point sits inside the read-write
|
||||
# ~/.claude mount and a rename unlinks the dentry the mount is pinned to. The
|
||||
# session then silently fell back to the operator's settings, with sandboxing
|
||||
# back on and the notify hooks below gone. Mounting outside ~/.claude removes
|
||||
# that: nothing on the host can rename over a container-local path. See #47.
|
||||
SANDBOX_SETTINGS_FILE="$(mktemp "${TMPDIR:-/tmp}/dejima-settings.XXXXXX.json")"
|
||||
jq '.sandbox.enabled = false' "$HOME/.claude/settings.json" > "$SANDBOX_SETTINGS_FILE"
|
||||
jq -n '{sandbox: {enabled: false}}' > "$SANDBOX_SETTINGS_FILE"
|
||||
|
||||
# ~/.claude.json mixes live app state (project registry, usage stats,
|
||||
# caches) with config (mcpServers) that can't be split at the mount level -
|
||||
@@ -188,12 +239,74 @@ fi
|
||||
|
||||
SANDBOX_AUDIT_STATE="$(mktemp "${TMPDIR:-/tmp}/dejima-audit.XXXXXX")"
|
||||
|
||||
# Initialized here, before the trap below is installed, so cleanup() never
|
||||
# observes a nonempty value it didn't itself set - e.g. an operator's
|
||||
# environment happening to export a variable of one of these exact names.
|
||||
# With remote access off (or on an early exit before the remote-access block
|
||||
# below ever runs), `rm -rf "$DEJIMA_NOTIFY_DIR"` against an inherited value
|
||||
# would be a data loss bug, not just a no-op - and several `exit 1` paths
|
||||
# between here and that block would otherwise fire the trap while these
|
||||
# still held whatever came in from the environment (same class of concern as
|
||||
# the credential-proxy variables further down, pre-initialized the same way,
|
||||
# just earlier here since the trap is installed sooner). Every name cleanup()
|
||||
# removes but does not itself create belongs here, not just the remote-access
|
||||
# ones: the three below are assigned only inside conditional blocks (an nvim
|
||||
# config being present, dependencies being approved), so an ordinary run with
|
||||
# neither would otherwise carry an inherited value all the way to the trap -
|
||||
# and SANDBOX_DERIVED_CONTEXT is one of the `rm -rf` arguments.
|
||||
DEJIMA_NOTIFY_DIR=""
|
||||
DEJIMA_SESSION_FILE=""
|
||||
SANDBOX_REMOTE_SETTINGS_TMP=""
|
||||
SANDBOX_NVIM_LOCKFILE=""
|
||||
SANDBOX_DERIVED_DOCKERFILE=""
|
||||
SANDBOX_DERIVED_CONTEXT=""
|
||||
|
||||
cleanup() {
|
||||
# Every rm below is guarded with `|| true`: under set -e, a failing
|
||||
# command in an EXIT trap replaces the shell's own exit status, so none
|
||||
# of these may be allowed to abort the trap partway through. Most of
|
||||
# these paths are host-mktemp'd and not session-writable, so a genuine
|
||||
# failure there would be surprising - but SANDBOX_GITCONFIG_DIR is not:
|
||||
# it's bind-mounted read-write into the container (see
|
||||
# docker-compose.yml), so a session can leave an undeletable entry
|
||||
# inside it (e.g. an unwritable subdirectory) and make its `rm -rf`
|
||||
# fail, same hazard and same guard as the marker-directory removal
|
||||
# further down.
|
||||
#
|
||||
# SANDBOX_GITCONFIG_DIR additionally carries a `:-` default because it is
|
||||
# the one name here first assigned BELOW `trap cleanup EXIT` rather than
|
||||
# above it. Nothing in that window can exit today (it holds only
|
||||
# comments, and a failed `VAR="$(mktemp -d)"` still binds VAR to the
|
||||
# empty string before set -e fires), so this is the invariant every other
|
||||
# name here already keeps, not a live fix - see the static test.
|
||||
if [ -n "${SANDBOX_ANTHROPIC_PROXY_PID:-}" ]; then
|
||||
kill "$SANDBOX_ANTHROPIC_PROXY_PID" 2>/dev/null || true
|
||||
fi
|
||||
rm -f "$SANDBOX_SETTINGS_FILE" "$SANDBOX_CLAUDE_JSON_FILE" "${SANDBOX_NVIM_LOCKFILE:-}" "$SANDBOX_AUDIT_STATE" "${SANDBOX_CREDENTIALS_MASK_FILE:-}" "${SANDBOX_DERIVED_DOCKERFILE:-}"
|
||||
rm -rf "$SANDBOX_GITCONFIG_DIR" "${SANDBOX_ANTHROPIC_PROXY_DIR:-}" "${SANDBOX_CREDENTIALS_TMPDIR_MASK:-}" "${SANDBOX_DERIVED_CONTEXT:-}"
|
||||
rm -f "$SANDBOX_SETTINGS_FILE" "$SANDBOX_CLAUDE_JSON_FILE" "${SANDBOX_NVIM_LOCKFILE:-}" "${SANDBOX_FAKE_CREDENTIALS_FILE:-}" "${SANDBOX_DERIVED_DOCKERFILE:-}" || true
|
||||
rm -rf "${SANDBOX_GITCONFIG_DIR:-}" "${SANDBOX_ANTHROPIC_PROXY_DIR:-}" "${SANDBOX_DERIVED_CONTEXT:-}" || true
|
||||
rm -f "${DEJIMA_SESSION_FILE:-}" "${SANDBOX_REMOTE_SETTINGS_TMP:-}" || true
|
||||
# Unlike the silent `|| true` guards above, this removal (and, before
|
||||
# the audit below was moved in, the last command of an EXIT trap) warns
|
||||
# out loud when it fails: the marker directory is bind-mounted
|
||||
# read-write, so a session can leave it undeletable (an unwritable
|
||||
# subdirectory is enough), and under set -e a failing command in an EXIT
|
||||
# trap REPLACES the shell's exit status - so without this a compromised
|
||||
# session could report 1 in place of Claude Code's real exit code. Same
|
||||
# guard, for the same reason, as dejima-attach's prune and
|
||||
# dejima-notify-watch's marker consumption.
|
||||
rm -rf "${DEJIMA_NOTIFY_DIR:-}" \
|
||||
|| echo "warning: could not remove the marker directory ${DEJIMA_NOTIFY_DIR:-}" >&2
|
||||
# Last writer, structurally: the operator reads bottom-up from their
|
||||
# shell prompt, so anything printed after the verdict pushes it out of
|
||||
# view. The marker-directory warning above is the one line a session can
|
||||
# deliberately provoke, so the audit runs after it, not before - and gets
|
||||
# the same set -e guard for the same reason: a failing audit invocation
|
||||
# must not replace Claude Code's real exit status either. SANDBOX_AUDIT_
|
||||
# STATE is dropped from the rm -f above and freed only after compare has
|
||||
# read it, so this function's own cleanup can't race the audit that
|
||||
# needs it - and its removal is guarded too, so it stays truly last.
|
||||
"$SCRIPT_DIR/dejima-audit" compare "$WORKSPACE_DIR" "$SANDBOX_AUDIT_STATE" || true
|
||||
rm -f "$SANDBOX_AUDIT_STATE" || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
@@ -246,14 +359,16 @@ export TZ="${TZ:-$(readlink -f /etc/localtime 2>/dev/null | sed 's#.*/zoneinfo/#
|
||||
# path for this - discover what's actually needed here instead, so it's
|
||||
# correct regardless of what's installed or where this repo itself was
|
||||
# cloned to.
|
||||
# "." covers top-level symlinked files directly under ~/.claude itself -
|
||||
# e.g. ~/.claude/statusline.sh -> dotfiles' statusline script, referenced
|
||||
# by settings.json's statusLine.command. Same trust model as hook code:
|
||||
# the target executes on the host too, so read-only is the right mount.
|
||||
# Only the directories that are actually mounted in: the top-level files
|
||||
# (settings.json, CLAUDE.md, statusline.sh) are staged as copies instead, and
|
||||
# Docker resolves any symlink among them when it sets up that staging mount,
|
||||
# so their targets don't need to be reachable from inside at all. Scanning
|
||||
# "." here too would mount a whole config checkout read-only into the
|
||||
# container for nothing.
|
||||
extra_mounts=()
|
||||
mounted_dirs=""
|
||||
for overlay_dir in . hooks skills commands agents output-styles; do
|
||||
dir="$HOME/.claude/$overlay_dir"
|
||||
for mounted_dir in hooks skills commands agents output-styles; do
|
||||
dir="$HOME/.claude/$mounted_dir"
|
||||
[ -d "$dir" ] || continue
|
||||
for entry in "$dir"/*; do
|
||||
[ -L "$entry" ] || continue
|
||||
@@ -327,41 +442,28 @@ if [ -d "$WORKSPACE_DIR/.git/hooks" ]; then
|
||||
extra_mounts+=(-v "$WORKSPACE_DIR/.git/hooks:$WORKSPACE_DIR/.git/hooks:ro")
|
||||
fi
|
||||
|
||||
# ~/.claude/statusline.sh is the same class of file as the compose overlays
|
||||
# on settings.json/CLAUDE.md/plugins/hooks/skills/commands/agents/output-
|
||||
# styles above (see docker-compose.yml's comments on those): settings.json's
|
||||
# statusLine.command points at it, and Claude Code executes it on the host.
|
||||
# Unlike those, it has no overlay of its own - it's a top-level *file*
|
||||
# directly on the read-write ~/.claude mount, so a compromised session can
|
||||
# unlink it and drop its own script in its place. The symlink-target scan
|
||||
# above (the "." entry in overlay_dir) only protects the case where
|
||||
# statusline.sh is a symlink by mounting read-only where it *points* - it
|
||||
# doesn't touch the symlink/file itself, which still lives on the writable
|
||||
# mount. Conditional on existence because not every host has one, and
|
||||
# mounting a nonexistent source would make Docker create a root-owned
|
||||
# directory at the target instead - worse than the gap this closes.
|
||||
# ~/.claude/statusline.sh is the same class of file as the config staged at
|
||||
# /etc/dejima-claude by docker-compose.yml: settings.json's statusLine.command
|
||||
# points at it, and Claude Code executes it on the host. Staged the same way,
|
||||
# for entrypoint.sh to copy into place, rather than mounted at its real path.
|
||||
#
|
||||
# Confirmed against a live container (test/runtime.bats), not just reasoned
|
||||
# about: this mount reliably protects only the plain-regular-file case. When
|
||||
# statusline.sh itself is a symlink (the common case - typically into a
|
||||
# dotfiles checkout), it does NOT protect it. The parent ~/.claude mount
|
||||
# above is established first, so by the time this mount is processed the
|
||||
# destination already has that symlink sitting on it, and bind-mount
|
||||
# destination resolution (runc's SecureJoin) follows symlinks within the
|
||||
# container's rootfs - so this :ro mount resolves through the symlink to the
|
||||
# target directory (already read-only from the symlink-target scan above,
|
||||
# making this mount a no-op there) instead of binding to the symlink dentry
|
||||
# itself, which stays on the writable ~/.claude mount and remains
|
||||
# replaceable. There is no mount-based fix for this: ~/.claude can't be made
|
||||
# read-only (session history has to stay writable), and mounting over the
|
||||
# resolved target instead of the symlink path isn't an option either, since
|
||||
# bin/dejima has no way to force the *destination* resolution to stop
|
||||
# at the symlink. The compensating control is detection, not prevention:
|
||||
# bin/dejima-audit hashes the symlink's target string (so a
|
||||
# repointed or replaced statusline.sh is caught) and reports it as a
|
||||
# host-code-execution finding after the session exits. Keep this mount
|
||||
# anyway - it's a real, verified fix for the regular-file case, and a no-op
|
||||
# rather than a regression for the symlink case.
|
||||
# It used to be mounted read-only at $HOME/.claude/statusline.sh, and that
|
||||
# protected only the plain-regular-file case - confirmed against a live
|
||||
# container, not just reasoned about. When statusline.sh was itself a symlink
|
||||
# (the common case - typically into a dotfiles checkout), the read-write
|
||||
# ~/.claude mount was established first, so the destination already had that
|
||||
# symlink sitting on it, and bind-mount destination resolution (runc's
|
||||
# SecureJoin) followed it: the :ro mount landed on the target directory
|
||||
# instead of the symlink dentry, which stayed writable and replaceable. There
|
||||
# was no mount-based fix while ~/.claude was mounted whole, and the
|
||||
# compensating control was detection after the fact (bin/dejima-audit hashes
|
||||
# the symlink's target string). Staging closes it outright: Docker resolves
|
||||
# the symlink when it sets up the staging mount, so entrypoint.sh copies the
|
||||
# real content to a path nothing on the host can reach.
|
||||
#
|
||||
# Conditional on existence because not every host has one, and mounting a
|
||||
# nonexistent source would make Docker create a root-owned directory at the
|
||||
# target instead - worse than the gap this closes.
|
||||
#
|
||||
# A *dangling* symlink (target doesn't exist, e.g. a dotfiles checkout that
|
||||
# isn't present) is neither "exists" nor safely mountable, so it gets its own
|
||||
@@ -373,30 +475,44 @@ fi
|
||||
if [ -L "$HOME/.claude/statusline.sh" ] && [ ! -e "$HOME/.claude/statusline.sh" ]; then
|
||||
echo "$HOME/.claude/statusline.sh is a symlink to a target that doesn't" >&2
|
||||
echo "exist. Docker can't bind-mount a dangling symlink, and skipping the" >&2
|
||||
echo "mount would leave it unprotected on the read-write ~/.claude mount." >&2
|
||||
echo "mount would leave the session with no statusline at all." >&2
|
||||
echo "Fix or remove the symlink, then retry." >&2
|
||||
exit 1
|
||||
elif [ -e "$HOME/.claude/statusline.sh" ]; then
|
||||
extra_mounts+=(-v "$HOME/.claude/statusline.sh:$HOME/.claude/statusline.sh:ro")
|
||||
extra_mounts+=(-v "$HOME/.claude/statusline.sh:/etc/dejima-claude/statusline.sh:ro")
|
||||
fi
|
||||
|
||||
# Keep the real Anthropic OAuth credential out of the container entirely:
|
||||
# run a host-side proxy that injects it per-request (see
|
||||
# bin/dejima-anthropic-proxy and README "Security notes"), mount an
|
||||
# empty file over ~/.claude/.credentials.json and an empty read-only
|
||||
# directory over ~/.claude/.credentials-proxy-tmp (the proxy's write-back
|
||||
# staging directory - the parent ~/.claude mount must stay read-write, so
|
||||
# masking these two paths individually is the mechanism), and hand Claude
|
||||
# Code a placeholder token pointed at a loopback forwarder.
|
||||
# bin/dejima-anthropic-proxy and docs/security.md). Nothing needs masking
|
||||
# any more - ~/.claude isn't mounted as a whole, so the real credential (and
|
||||
# the proxy's .credentials-proxy-tmp staging directory beside it) simply
|
||||
# never crosses into the container. That masking is what #50 was: both masks
|
||||
# were single-file/-directory mounts nested in the read-write ~/.claude
|
||||
# mount, and the proxy's own write-back renamed the credential out from
|
||||
# under them, detaching the mask and exposing the real token from the first
|
||||
# refresh on.
|
||||
#
|
||||
# How the container then reaches the proxy depends on the transport. In the
|
||||
# default tls one, entrypoint.sh points api.anthropic.com at an in-container
|
||||
# TLS terminator, so no ANTHROPIC_* value is set at all and the session is
|
||||
# indistinguishable from a stock install - which means Claude Code reads
|
||||
# ~/.claude/.credentials.json, so a fake one is generated below and mounted
|
||||
# there. SANDBOX_ANTHROPIC_TRANSPORT=loopback is the documented fallback if
|
||||
# certificate pinning ever makes that impossible: an explicit base URL and
|
||||
# placeholder token at a plaintext forwarder, as before, with no credential
|
||||
# file in the container at all.
|
||||
#
|
||||
# Skipped when there's no claudeAiOauth block to protect (API-key setups),
|
||||
# or via the SANDBOX_CREDENTIAL_PROXY=0 kill switch - both restore the old
|
||||
# behavior exactly (credential mounted in with the rest of ~/.claude).
|
||||
# or via the SANDBOX_CREDENTIAL_PROXY=0 kill switch. Both then mount the real
|
||||
# credential in explicitly, which used to come free with the whole-directory
|
||||
# mount and now has to be stated.
|
||||
SANDBOX_ANTHROPIC_TRANSPORT="${SANDBOX_ANTHROPIC_TRANSPORT:-tls}"
|
||||
SANDBOX_ANTHROPIC_BASE_URL=""
|
||||
SANDBOX_ANTHROPIC_AUTH_TOKEN=""
|
||||
SANDBOX_ANTHROPIC_PROXY_SOCKET=""
|
||||
SANDBOX_ANTHROPIC_PROXY_DIR=""
|
||||
SANDBOX_CREDENTIALS_MASK_FILE=""
|
||||
SANDBOX_CREDENTIALS_TMPDIR_MASK=""
|
||||
SANDBOX_FAKE_CREDENTIALS_FILE=""
|
||||
SANDBOX_ANTHROPIC_PROXY_PID=""
|
||||
if [ "${SANDBOX_CREDENTIAL_PROXY:-1}" != "0" ] \
|
||||
&& jq -e '.claudeAiOauth.accessToken' "$HOME/.claude/.credentials.json" >/dev/null 2>&1; then
|
||||
@@ -419,23 +535,175 @@ if [ "${SANDBOX_CREDENTIAL_PROXY:-1}" != "0" ] \
|
||||
echo "credential is then mounted into the container, as before)." >&2
|
||||
exit 1
|
||||
fi
|
||||
SANDBOX_CREDENTIALS_MASK_FILE="$(mktemp "${TMPDIR:-/tmp}/dejima-credentials-mask.XXXXXX")"
|
||||
SANDBOX_CREDENTIALS_TMPDIR_MASK="$(mktemp -d "${TMPDIR:-/tmp}/dejima-credentials-tmpdir-mask.XXXXXX")"
|
||||
extra_mounts+=(-v "$SANDBOX_ANTHROPIC_PROXY_DIR:$SANDBOX_ANTHROPIC_PROXY_DIR:ro")
|
||||
extra_mounts+=(-v "$SANDBOX_CREDENTIALS_MASK_FILE:$HOME/.claude/.credentials.json")
|
||||
# Mount target must exist first, same as the skills/commands/etc. overlays
|
||||
# above - otherwise Docker auto-creates it root-owned, and the proxy
|
||||
# (running as the unprivileged host user) can't write its staging temp
|
||||
# file there on the next refresh.
|
||||
mkdir -p -m 700 "$HOME/.claude/.credentials-proxy-tmp"
|
||||
extra_mounts+=(-v "$SANDBOX_CREDENTIALS_TMPDIR_MASK:$HOME/.claude/.credentials-proxy-tmp:ro")
|
||||
SANDBOX_ANTHROPIC_BASE_URL="http://127.0.0.1:18300"
|
||||
SANDBOX_ANTHROPIC_AUTH_TOKEN="sandbox-credential-proxy-placeholder"
|
||||
if [ "$SANDBOX_ANTHROPIC_TRANSPORT" = "loopback" ]; then
|
||||
SANDBOX_ANTHROPIC_BASE_URL="http://127.0.0.1:18300"
|
||||
SANDBOX_ANTHROPIC_AUTH_TOKEN="sandbox-credential-proxy-placeholder"
|
||||
else
|
||||
# With no ANTHROPIC_AUTH_TOKEN set, Claude Code authenticates from
|
||||
# .credentials.json directly, so the container needs a well-formed
|
||||
# one where it would otherwise have no credential file at all. Only
|
||||
# the two secrets are faked; scopes/subscriptionType/rateLimitTier/
|
||||
# clientId are copied so the session's own UI reflects the real
|
||||
# account.
|
||||
#
|
||||
# The expiry deliberately does NOT mirror the real credential. A
|
||||
# near-term expiresAt would make Claude Code refresh on its own,
|
||||
# against platform.claude.com, with a refresh token that is not real -
|
||||
# which fails and logs the session out. A year out means it never
|
||||
# tries; the host proxy owns refresh, as it already did.
|
||||
SANDBOX_FAKE_CREDENTIALS_FILE="$(mktemp "${TMPDIR:-/tmp}/dejima-fake-credentials.XXXXXX")"
|
||||
jq --argjson exp "$(( ($(date +%s) + 31536000) * 1000 ))" '
|
||||
{claudeAiOauth: (
|
||||
{accessToken: "sandbox-credential-proxy-placeholder",
|
||||
refreshToken: "sandbox-credential-proxy-placeholder",
|
||||
expiresAt: $exp,
|
||||
refreshTokenExpiresAt: $exp}
|
||||
+ (.claudeAiOauth
|
||||
| {scopes, subscriptionType, rateLimitTier, clientId}
|
||||
| with_entries(select(.value != null)))
|
||||
)}' "$HOME/.claude/.credentials.json" > "$SANDBOX_FAKE_CREDENTIALS_FILE"
|
||||
chmod 600 "$SANDBOX_FAKE_CREDENTIALS_FILE"
|
||||
# Read-write: Claude Code rewrites this file on its own schedule, and
|
||||
# a failed write is a startup error. It writes to the host temp file,
|
||||
# which the EXIT trap removes.
|
||||
extra_mounts+=(-v "$SANDBOX_FAKE_CREDENTIALS_FILE:$HOME/.claude/.credentials.json")
|
||||
fi
|
||||
elif [ -f "$HOME/.claude/.credentials.json" ]; then
|
||||
# No proxy: the session authenticates with the real credential, so it has
|
||||
# to be mounted in. Read-write because Claude Code refreshes it in place.
|
||||
# This is the documented downgrade, not an oversight - see the kill-switch
|
||||
# message above and docs/security.md.
|
||||
extra_mounts+=(-v "$HOME/.claude/.credentials.json:$HOME/.claude/.credentials.json")
|
||||
fi
|
||||
export SANDBOX_ANTHROPIC_BASE_URL SANDBOX_ANTHROPIC_AUTH_TOKEN SANDBOX_ANTHROPIC_PROXY_SOCKET
|
||||
export SANDBOX_ANTHROPIC_TRANSPORT
|
||||
|
||||
# Optional remote access (see docs/remote-access.md). Entirely skipped
|
||||
# unless ~/.config/dejima/remote.conf exists - with it absent nothing below
|
||||
# runs, no state is created, and the compose invocation is exactly what it
|
||||
# was before this feature existed.
|
||||
if remote_enabled; then
|
||||
DEJIMA_RUN_ID="$(od -An -tx1 -N8 /dev/urandom | tr -d ' \n')"
|
||||
DEJIMA_STATE_DIR="$(remote_state_dir)"
|
||||
|
||||
# The state dir's parent (e.g. /tmp, when XDG_RUNTIME_DIR is unset - the
|
||||
# common case for exactly the non-systemd ssh sessions this feature
|
||||
# targets) is world-writable, and `mkdir -p -m 700` only applies MODE to
|
||||
# the leaf it creates - an already-existing path here (a symlink an
|
||||
# attacker pre-created, or a directory some other user owns) is left as
|
||||
# is. Since the notify dir created under it is bind-mounted read-write
|
||||
# into the container and later `rm -rf`'d by the EXIT trap, following a
|
||||
# planted symlink would hand a compromised session (or the trap itself)
|
||||
# access to an attacker-chosen path. Create it, then verify it's a real
|
||||
# directory we own and not a symlink before trusting it - refuse rather
|
||||
# than proceed on anything else. Once this check passes, everything
|
||||
# created below it (sessions/, notify/, notify/<run-id>) is safe: mode
|
||||
# 700 here means no other user can plant anything inside it afterward.
|
||||
mkdir -p -m 700 "$DEJIMA_STATE_DIR" 2>/dev/null || true
|
||||
if [ -L "$DEJIMA_STATE_DIR" ] || [ ! -d "$DEJIMA_STATE_DIR" ] \
|
||||
|| [ "$(stat -c %u "$DEJIMA_STATE_DIR")" != "$(id -u)" ]; then
|
||||
echo "Refusing remote access: $DEJIMA_STATE_DIR exists and is not a plain" >&2
|
||||
echo "directory owned by the current user (or is a symlink). This could be a" >&2
|
||||
echo "symlink attack via a shared /tmp - remove it, or set XDG_RUNTIME_DIR to" >&2
|
||||
echo "somewhere else, and retry." >&2
|
||||
exit 1
|
||||
fi
|
||||
chmod 700 "$DEJIMA_STATE_DIR"
|
||||
|
||||
mkdir -p -m 700 "$DEJIMA_STATE_DIR/sessions" "$DEJIMA_STATE_DIR/notify"
|
||||
DEJIMA_SESSION_FILE="$DEJIMA_STATE_DIR/sessions/$DEJIMA_RUN_ID"
|
||||
DEJIMA_NOTIFY_DIR="$DEJIMA_STATE_DIR/notify/$DEJIMA_RUN_ID"
|
||||
mkdir -p -m 700 "$DEJIMA_NOTIFY_DIR"
|
||||
|
||||
# $TMUX_PANE is set by tmux in the launching shell. Empty when the
|
||||
# sandbox wasn't started from tmux: the session is still registered, just
|
||||
# listed as not-attachable, rather than silently omitted.
|
||||
dejima_tmux_label=""
|
||||
if [ -n "${TMUX_PANE:-}" ] && command -v tmux >/dev/null 2>&1; then
|
||||
dejima_tmux_label="$(tmux display-message -p -t "$TMUX_PANE" \
|
||||
'#{session_name}:#{window_index}' 2>/dev/null || true)"
|
||||
fi
|
||||
|
||||
# A newline in either value would inject an extra key=value line into the
|
||||
# flat registry entry below - e.g. a fabricated `pane=` line inside a
|
||||
# crafted label, which the last-match-wins parser (see dejima-attach)
|
||||
# would honor over the real one, redirecting an operator to an
|
||||
# attacker-chosen pane. Refuse outright rather than sanitize: a value
|
||||
# that can't be represented safely here is the same class of problem as
|
||||
# a corrupt approval store elsewhere in this repo.
|
||||
case "$WORKSPACE_DIR" in
|
||||
*$'\n'*)
|
||||
echo "Refusing remote access: workspace path contains a newline: $WORKSPACE_DIR" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
case "$dejima_tmux_label" in
|
||||
*$'\n'*)
|
||||
echo "Refusing remote access: tmux session/window label contains a newline." >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
# $TMUX_PANE comes from the launching shell's environment, so it is the
|
||||
# one value here that is taken verbatim rather than derived. It is written
|
||||
# second of the four lines below, so an injected line lands ahead of
|
||||
# label= and started= and would lose the parser's last-match-wins race to
|
||||
# the real ones - but it would WIN against the pane= it is part of, which
|
||||
# is the field deciding which pane an operator gets attached to. Refusing
|
||||
# the newline outright is what makes that independent of printf ordering.
|
||||
case "${TMUX_PANE:-}" in
|
||||
*$'\n'*)
|
||||
echo "Refusing remote access: \$TMUX_PANE contains a newline." >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# Flat key=value, like the rest of the host-side store: no jq dependency
|
||||
# here, and nothing that reads it ever sources it.
|
||||
{
|
||||
printf 'workspace=%s\n' "$WORKSPACE_DIR"
|
||||
printf 'pane=%s\n' "${TMUX_PANE:-}"
|
||||
printf 'label=%s\n' "$dejima_tmux_label"
|
||||
printf 'started=%s\n' "$(date +%s)"
|
||||
} > "$DEJIMA_SESSION_FILE"
|
||||
|
||||
# This run's OWN marker directory is the only thing mounted in - never
|
||||
# its parent - so a compromised session can write markers for itself and
|
||||
# for nothing else. The registry is never mounted at all: dejima-attach
|
||||
# trusts it to decide which pane to attach an operator to, so a session
|
||||
# able to rewrite it could redirect them.
|
||||
#
|
||||
# -e alongside -v here: this array is passed straight to `docker compose
|
||||
# run`, where both are valid flags.
|
||||
extra_mounts+=(-v "$DEJIMA_NOTIFY_DIR:/run/dejima-notify")
|
||||
extra_mounts+=(-e "DEJIMA_NOTIFY_DIR=/run/dejima-notify")
|
||||
# Lets the host correlate this run to its container via `docker inspect`,
|
||||
# so the container never needs write access to the registry to report its
|
||||
# own ID.
|
||||
extra_mounts+=(-e "DEJIMA_RUN_ID=$DEJIMA_RUN_ID")
|
||||
|
||||
# The notify hook is registered per session, into the override layer from
|
||||
# above and nowhere else: registering it in the operator's real
|
||||
# settings.json would run it for every sandbox and for the host's own
|
||||
# Claude Code, which share that file, when it is only meaningful for a
|
||||
# session that has a marker directory. $SANDBOX_SETTINGS_FILE is
|
||||
# already a throwaway mktemp file, so this rewrite cannot reach the
|
||||
# operator's real settings.json; it is mounted read-only, so the
|
||||
# session cannot unregister itself either. Claude Code merges hooks
|
||||
# across settings layers rather than letting the higher one replace the
|
||||
# lower, so these are added to whatever the operator already has rather
|
||||
# than displacing it. $HOME is left unexpanded for Claude Code to resolve
|
||||
# at hook-run time - inside the container, not here - matching the
|
||||
# convention install.sh already uses.
|
||||
SANDBOX_REMOTE_SETTINGS_TMP="$(mktemp "${TMPDIR:-/tmp}/dejima-settings-remote.XXXXXX.json")"
|
||||
jq '.hooks.Notification = ((.hooks.Notification // []) + [{"matcher": "", "hooks": [{"type": "command", "command": "$HOME/.claude/hooks/dejima-notify.sh waiting"}]}])
|
||||
| .hooks.Stop = ((.hooks.Stop // []) + [{"matcher": "", "hooks": [{"type": "command", "command": "$HOME/.claude/hooks/dejima-notify.sh done"}]}])' \
|
||||
"$SANDBOX_SETTINGS_FILE" > "$SANDBOX_REMOTE_SETTINGS_TMP"
|
||||
mv "$SANDBOX_REMOTE_SETTINGS_TMP" "$SANDBOX_SETTINGS_FILE"
|
||||
fi
|
||||
|
||||
# Snapshot host-persistence surfaces so the post-run audit can report
|
||||
# anything this session changed (advisory - see bin/dejima-audit).
|
||||
# anything that changed while this session ran (advisory - see bin/dejima-audit).
|
||||
"$SCRIPT_DIR/dejima-audit" snapshot "$WORKSPACE_DIR" "$SANDBOX_AUDIT_STATE" || true
|
||||
|
||||
# When the sandbox is pointed at its own repo, its TCB (bin/, hooks/,
|
||||
@@ -490,13 +758,44 @@ export SANDBOX_IMAGE
|
||||
# Not exec'd (the EXIT trap cleans up the generated temp files) and not a
|
||||
# bare command under set -e (a nonzero/interrupted container run - e.g.
|
||||
# Ctrl-C returning 130 - would otherwise abort the script here, skipping the
|
||||
# audit entirely). Capture the status explicitly so the advisory audit runs
|
||||
# on every exit path, then propagate the real container exit code unchanged.
|
||||
# credential-proxy check below it). Capture the status explicitly, then
|
||||
# propagate the real container exit code unchanged - the exit-time audit
|
||||
# itself now runs unconditionally from cleanup(), not because of this
|
||||
# capture.
|
||||
# Unacknowledged CRITICAL findings from earlier runs, re-presented here
|
||||
# because exit-time output can be ignored exactly once, permanently. This is
|
||||
# host-side and stays host-side: it must never be routed into the session's
|
||||
# context, which would make it a cross-session persistence channel.
|
||||
audit_slug="$(printf '%s' "$WORKSPACE_DIR" | sed 's/[^a-zA-Z0-9]/-/g')"
|
||||
audit_reports="$AUDIT_DIR/$audit_slug"
|
||||
# `|| true`: a workspace with no history yet (the common case - nothing has
|
||||
# ever run against it, or every past finding is already acknowledged) has no
|
||||
# audit_reports directory at all, and find on a missing path exits nonzero -
|
||||
# which, combined with pipefail, would otherwise abort this script here for
|
||||
# every ordinary launch.
|
||||
pending_crit="$(find "$audit_reports" -maxdepth 1 -name '*-CRITICAL.txt' 2>/dev/null | sort)" || true
|
||||
if [ -n "$pending_crit" ]; then
|
||||
n="$(printf '%s\n' "$pending_crit" | wc -l)"
|
||||
echo "" >&2
|
||||
echo " dejima: $n unacknowledged CRITICAL finding(s) in this workspace" >&2
|
||||
printf '%s\n' "$pending_crit" | sed 's#.*/##; s#-CRITICAL\.txt$##; s#^# from run #' >&2
|
||||
echo " Review with: dejima-audit ack \"$WORKSPACE_DIR\"" >&2
|
||||
if [ -t 0 ]; then
|
||||
printf ' [c] continue [q] quit: ' >&2
|
||||
read -r reply || reply=q
|
||||
case "$reply" in q|Q) echo "aborted." >&2; exit 1 ;; esac
|
||||
elif [ "${DEJIMA_AUDIT_STRICT:-0}" = "1" ]; then
|
||||
echo " DEJIMA_AUDIT_STRICT=1 and stdin is not a tty - refusing to start." >&2
|
||||
exit 1
|
||||
else
|
||||
echo " (not a tty - continuing; set DEJIMA_AUDIT_STRICT=1 to refuse)" >&2
|
||||
fi
|
||||
fi
|
||||
|
||||
compose_status=0
|
||||
docker compose -f "$COMPOSE_FILE" run --rm ${extra_mounts[@]+"${extra_mounts[@]}"} claude "$@" || compose_status=$?
|
||||
if [ -n "${SANDBOX_ANTHROPIC_PROXY_PID:-}" ] && ! kill -0 "$SANDBOX_ANTHROPIC_PROXY_PID" 2>/dev/null; then
|
||||
echo "Warning: the credential proxy exited before the session ended - API calls in" >&2
|
||||
echo "the session may have been failing. Check for proxy errors in the output above." >&2
|
||||
fi
|
||||
"$SCRIPT_DIR/dejima-audit" compare "$WORKSPACE_DIR" "$SANDBOX_AUDIT_STATE" || true
|
||||
exit "$compose_status"
|
||||
|
||||
+85
-23
@@ -6,8 +6,7 @@
|
||||
# real OAuth access token from ~/.claude/.credentials.json, so the credential
|
||||
# itself never enters the container. It also owns token refresh: rotated
|
||||
# tokens are written back to the real host file atomically, keeping
|
||||
# host-side sessions logged in. See README "Security notes" and
|
||||
# ~/specs/2026-07-24-dejima-credential-proxy-design.md.
|
||||
# host-side sessions logged in. See docs/security.md.
|
||||
import argparse
|
||||
import http.client
|
||||
import json
|
||||
@@ -22,9 +21,22 @@ from urllib.parse import urlsplit
|
||||
|
||||
OAUTH_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
|
||||
OAUTH_BETA = "oauth-2025-04-20"
|
||||
# Hop-by-hop headers describe the connection to this proxy rather than the
|
||||
# request, so they stop here (RFC 9110 7.6.1). Host is deliberately not in
|
||||
# this set: it has to be rewritten for the upstream, and is replaced in place
|
||||
# rather than dropped and re-added, which keeps the header block in the order
|
||||
# the client wrote it - see _upstream.
|
||||
HOP_BY_HOP = {
|
||||
"connection", "keep-alive", "proxy-authenticate", "proxy-authorization",
|
||||
"te", "trailer", "trailers", "transfer-encoding", "upgrade", "host",
|
||||
"te", "trailer", "trailers", "transfer-encoding", "upgrade",
|
||||
}
|
||||
# Casing for the few headers this proxy has to supply when the client sent
|
||||
# none of its own. Everything the client did send keeps the client's casing.
|
||||
SUPPLIED_NAMES = {
|
||||
"host": "Host",
|
||||
"authorization": "Authorization",
|
||||
"anthropic-beta": "anthropic-beta",
|
||||
"content-length": "Content-Length",
|
||||
}
|
||||
|
||||
|
||||
@@ -83,15 +95,20 @@ class Credentials:
|
||||
def _write(self, data):
|
||||
# Atomic and permission-preserving: host-side Claude Code reads this
|
||||
# same file and must never observe a partial write or a looser mode.
|
||||
# Staged in a dedicated subdirectory, not ~/.claude directly - that
|
||||
# directory is bind-mounted read-write into the sandbox container, and
|
||||
# only the single ~/.claude/.credentials.json path is masked from it
|
||||
# (see bin/dejima); a sibling temp file created directly in
|
||||
# ~/.claude would be container-readable for the whole write window.
|
||||
# bin/dejima masks this subdirectory entirely (empty read-only
|
||||
# overlay), and the rename below stays same-filesystem (a subdirectory
|
||||
# of the same parent), so atomicity is unaffected either way - what the
|
||||
# container's own bind mount shows is irrelevant to a host-side rename.
|
||||
# Staged in a dedicated subdirectory rather than ~/.claude directly,
|
||||
# which keeps the plaintext window out of any directory the sandbox
|
||||
# can see, and keeps the rename below same-filesystem (a subdirectory
|
||||
# of the same parent) so atomicity is unaffected.
|
||||
#
|
||||
# This used to lean on bin/dejima masking both paths with bind mounts,
|
||||
# on the reasoning that "what the container's own bind mount shows is
|
||||
# irrelevant to a host-side rename". That was backwards, and it was
|
||||
# #50: the rename is unaffected by the container's mount, but the
|
||||
# container's mount is destroyed by the rename. Replacing the file
|
||||
# unlinks the dentry the mask was pinned to, so the mask detached and
|
||||
# the sandbox saw the real token from the first refresh on. ~/.claude
|
||||
# is no longer bind-mounted as a whole, so neither this directory nor
|
||||
# the credential reaches the container and there is nothing to mask.
|
||||
directory = os.path.join(os.path.dirname(self.path) or ".", ".credentials-proxy-tmp")
|
||||
os.makedirs(directory, mode=0o700, exist_ok=True)
|
||||
fd, tmp = tempfile.mkstemp(dir=directory)
|
||||
@@ -131,7 +148,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
def _fail(self, status, message, log_message=None):
|
||||
# log_message (host stderr) may contain upstream response bytes or
|
||||
# exception detail; message (sent to the container in the response
|
||||
# body) never should - see README "Security notes".
|
||||
# body) never should - see docs/security.md.
|
||||
self.log_error("%s", log_message if log_message is not None else message)
|
||||
body = json.dumps(
|
||||
{"type": "error", "error": {"type": "proxy_error", "message": message}}
|
||||
@@ -146,18 +163,63 @@ class Handler(BaseHTTPRequestHandler):
|
||||
u = self.server.upstream
|
||||
cls = http.client.HTTPSConnection if u.scheme == "https" else http.client.HTTPConnection
|
||||
conn = cls(u.netloc, timeout=600)
|
||||
headers = {
|
||||
k.lower(): v for k, v in self.headers.items()
|
||||
if k.lower() not in HOP_BY_HOP
|
||||
and k.lower() not in ("authorization", "x-api-key", "content-length")
|
||||
# Emitted header by header rather than handed to conn.request() as a
|
||||
# dict, so what the API receives is the client's own header block -
|
||||
# same names, same casing, same order, duplicates intact - with only
|
||||
# the values that must change changed. The container half of this
|
||||
# sandbox goes to some length to make the session indistinguishable
|
||||
# from a stock install (no ANTHROPIC_* variables, no explicit base
|
||||
# URL - see docs/security.md); a request rewritten into CPython's
|
||||
# house style would put the fingerprint straight back on the wire,
|
||||
# where it is visible to the API rather than only to the client.
|
||||
#
|
||||
# conn.request() does exactly that in three ways: a dict collapses
|
||||
# repeated headers to the last one, dict order puts every header this
|
||||
# proxy touches at the end of the block, and putrequest() supplies
|
||||
# `Accept-Encoding: identity` - a CPython tell no browser or Node
|
||||
# client sends - whenever the caller passed no accept-encoding. The
|
||||
# skip_* flags below suppress that and the generated Host header,
|
||||
# which is re-emitted in the client's own position instead.
|
||||
conn.putrequest(self.command, self.path,
|
||||
skip_host=True, skip_accept_encoding=True)
|
||||
supplied = {
|
||||
"host": u.netloc,
|
||||
"authorization": "Bearer " + token,
|
||||
"content-length": str(len(body)),
|
||||
}
|
||||
headers["authorization"] = "Bearer " + token
|
||||
betas = [b.strip() for b in headers.get("anthropic-beta", "").split(",") if b.strip()]
|
||||
# anthropic-beta is left completely alone when the OAuth value is
|
||||
# already among the client's own - which it is in the tls transport,
|
||||
# where Claude Code believes it holds an OAuth credential. Re-joining
|
||||
# a list that needs no change would normalize the client's spacing,
|
||||
# which is the same class of tell as the casing.
|
||||
betas = [b.strip() for v in self.headers.get_all("anthropic-beta", [])
|
||||
for b in v.split(",")]
|
||||
if OAUTH_BETA not in betas:
|
||||
betas.append(OAUTH_BETA)
|
||||
headers["anthropic-beta"] = ",".join(betas)
|
||||
headers["content-length"] = str(len(body))
|
||||
conn.request(self.command, self.path, body=body, headers=headers)
|
||||
first = self.headers.get("anthropic-beta")
|
||||
supplied["anthropic-beta"] = first + "," + OAUTH_BETA if first else OAUTH_BETA
|
||||
seen = set()
|
||||
for name, value in self.headers.items():
|
||||
low = name.lower()
|
||||
if low in HOP_BY_HOP or low == "x-api-key":
|
||||
continue
|
||||
if low in supplied:
|
||||
# One replacement even for a repeated header, or the injected
|
||||
# token would be sent as many times as the client sent its
|
||||
# placeholder.
|
||||
if low in seen:
|
||||
continue
|
||||
value = supplied[low]
|
||||
seen.add(low)
|
||||
conn.putheader(name, value)
|
||||
for low, name in SUPPLIED_NAMES.items():
|
||||
if low in supplied and low not in seen:
|
||||
# Content-Length only when there is a body: a bodyless GET
|
||||
# that carried none must not gain one. A chunked request has
|
||||
# been de-framed by now, so it lands here and does gain one.
|
||||
if low == "content-length" and not body:
|
||||
continue
|
||||
conn.putheader(name, supplied[low])
|
||||
conn.endheaders(body)
|
||||
return conn, conn.getresponse()
|
||||
|
||||
def _proxy(self):
|
||||
|
||||
Executable
+449
@@ -0,0 +1,449 @@
|
||||
#!/bin/bash
|
||||
# List live dejima sandbox sessions and attach to one. This is the shared
|
||||
# core behind both remote front doors: it runs as a forced SSH command
|
||||
# (restrict,pty,command="...") and as ttyd's command. See
|
||||
# docs/remote-access.md.
|
||||
#
|
||||
# Usage: dejima-attach [run-id|--list]
|
||||
# (no argument) numbered picker, attaches to the choice
|
||||
# run-id attach directly - what a notification deep link and ttyd's
|
||||
# --url-arg produce
|
||||
# --list one tab-separated row per live session, for scripting
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
|
||||
# shellcheck source=dejima-project-lib.sh
|
||||
source "$SCRIPT_DIR/dejima-project-lib.sh"
|
||||
|
||||
if ! remote_enabled; then
|
||||
echo "Remote access is not configured ($(remote_conf_path) does not exist)." >&2
|
||||
echo "See README \"Remote access\"." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
STATE_DIR="$(remote_state_dir)"
|
||||
SESSIONS_DIR="$STATE_DIR/sessions"
|
||||
|
||||
# gruvbox dark. Every value below is upstream's, tagged with the name of the
|
||||
# color.table row it came from:
|
||||
# https://github.com/gruvbox-community/gruvbox-contrib/blob/master/color.table
|
||||
# Carrying the name and not just the hex is the point - a palette this widely
|
||||
# known is one almost nobody has memorised to six digits, so a wrong value is
|
||||
# only catchable by reading if there is something to check it against.
|
||||
#
|
||||
# Truecolor only, and deliberately not paired with a 256-colour table: a
|
||||
# terminal without it - or tmux without `Tc` - approximates 38;2 to its own
|
||||
# nearest colour rather than printing the escape, so the fallback already
|
||||
# exists one layer down and a second palette here would only duplicate it.
|
||||
#
|
||||
# Everything below is empty unless stdout is a terminal that says it can show
|
||||
# colour, which is what keeps --list clean: it is a machine interface (ttyd's
|
||||
# --url-arg feeds run ids back in, and the README documents the rows as
|
||||
# scriptable), and it is also how the bats suite reads this script, so its
|
||||
# assertions see the same bare words either way.
|
||||
if [ -t 1 ] && [ -z "${NO_COLOR:-}" ] && [ "${TERM:-dumb}" != dumb ]; then
|
||||
C_OFF=$'\033[0m'; C_BOLD=$'\033[1m'
|
||||
C_BADGE=$'\033[38;2;254;128;25m' # bright_orange #fe8019, the wordmark
|
||||
C_TEXT=$'\033[38;2;235;219;178m' # light1 #ebdbb2
|
||||
C_MUTED=$'\033[38;2;146;131;116m' # gray_245 #928374
|
||||
C_SUBTLE=$'\033[38;2;124;111;100m' # dark4 #7c6f64
|
||||
C_RULE=$'\033[38;2;80;73;69m' # dark2 #504945, separator
|
||||
C_OK=$'\033[38;2;184;187;38m' # bright_green #b8bb26, success
|
||||
C_WARN=$'\033[38;2;250;189;47m' # bright_yellow #fabd2f, warning
|
||||
C_ERR=$'\033[38;2;251;73;52m' # bright_red #fb4934, error
|
||||
C_LINK=$'\033[38;2;131;165;152m' # bright_blue #83a598
|
||||
GLYPH_BAR='▏'; GLYPH_DOT='●'
|
||||
# A literal rather than a repeat of GLYPH_RULE: `tr` is byte-oriented and
|
||||
# would shred a 3-byte box-drawing character, and every alternative costs
|
||||
# a loop or a subshell to draw a fixed line. Both are the width of the
|
||||
# heading they sit under ("dejima | live sessions"), and are next to it so
|
||||
# they stay that way.
|
||||
RULE_LINE='──────────────────────'
|
||||
else
|
||||
C_OFF=""; C_BOLD=""; C_BADGE=""; C_TEXT=""; C_MUTED=""; C_SUBTLE=""
|
||||
C_RULE=""; C_OK=""; C_WARN=""; C_ERR=""; C_LINK=""
|
||||
# Glyphs ride the same switch rather than a separate UTF-8 probe. Not
|
||||
# because no-colour implies no-UTF-8, but because the two terminals this
|
||||
# has to stay readable on - a $TERM the host has no entry for, and a pipe -
|
||||
# are exactly the ones that mangle box drawing, and one flag is easier to
|
||||
# reason about than two.
|
||||
GLYPH_BAR='|'; GLYPH_DOT='*'
|
||||
RULE_LINE='----------------------'
|
||||
fi
|
||||
|
||||
# Narrow deliberately. The phone is this script's main front door, and the
|
||||
# rule underlines the heading instead of spanning the table because the
|
||||
# table's width follows the longest project name - a rule that chased it would
|
||||
# wrap on a phone exactly when the rows did.
|
||||
banner() {
|
||||
printf '\n %s%sdejima%s %s%s%s %slive sessions%s\n' \
|
||||
"$C_BOLD" "$C_BADGE" "$C_OFF" "$C_SUBTLE" "$GLYPH_BAR" "$C_OFF" \
|
||||
"$C_MUTED" "$C_OFF"
|
||||
printf ' %s%s%s\n\n' "$C_RULE" "$RULE_LINE" "$C_OFF"
|
||||
}
|
||||
|
||||
# Coloured by meaning, not by name: attachable is the one an operator wants to
|
||||
# find, and stale/no-tmux are the two that will refuse if picked.
|
||||
state_color() {
|
||||
case "$1" in
|
||||
attachable) printf '%s' "$C_OK" ;;
|
||||
stale) printf '%s' "$C_ERR" ;;
|
||||
*) printf '%s' "$C_WARN" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# How long after its own started= a registered session may still have no
|
||||
# container, before the prune in list_sessions treats that as "gone".
|
||||
#
|
||||
# Sized against the step that actually holds a launch up: bin/dejima writes
|
||||
# the registry entry, then rewrites the settings copy, snapshots the
|
||||
# workspace for the audit, runs an UNCONDITIONAL `docker compose build
|
||||
# claude`, optionally builds up to two derived images on top of it, and only
|
||||
# then reaches `docker compose run`. A cold base build is the Dockerfile's
|
||||
# apt and npm layers fetched over the network - minutes, not seconds - and
|
||||
# the derived builds add to it, while a warm run reaches the container in
|
||||
# seconds and never comes near this window.
|
||||
#
|
||||
# The two errors are not symmetric, which is why this is generous. Too long
|
||||
# only leaves a session that really did die listed until the window passes,
|
||||
# and the next listing prunes it. Too short deletes the registry entry of a
|
||||
# session that is about to start: bin/dejima never rewrites that entry, so
|
||||
# the session is permanently absent from both front doors, and the marker
|
||||
# directory deleted with it is the source of a bind mount the imminent
|
||||
# `docker compose run` declares.
|
||||
STARTUP_GRACE=900
|
||||
|
||||
# Parsed, never sourced: this file is host-written, but treating state files
|
||||
# as code is the habit this repo doesn't have. Returns nonzero for an entry
|
||||
# that can't be read or doesn't parse; callers skip it rather than dying,
|
||||
# since one bad entry must not hide every other live session.
|
||||
read_entry() {
|
||||
local file="$1" line
|
||||
entry_workspace=""; entry_pane=""; entry_label=""; entry_started=""
|
||||
[ -r "$file" ] || return 1
|
||||
while IFS= read -r line || [ -n "$line" ]; do
|
||||
case "$line" in
|
||||
workspace=*) entry_workspace="${line#*=}" ;;
|
||||
pane=*) entry_pane="${line#*=}" ;;
|
||||
label=*) entry_label="${line#*=}" ;;
|
||||
started=*) entry_started="${line#*=}" ;;
|
||||
esac
|
||||
done < "$file" || return 1
|
||||
# started is the one field that reaches an arithmetic context, where a
|
||||
# non-numeric value is evaluated as a *variable name* - so "started=x[$(cmd)]"
|
||||
# executes cmd, and set -u doesn't catch it when the name is already bound.
|
||||
# Refuse the entry instead of arithmetic-evaluating anything from a file.
|
||||
# (Digits alone aren't enough for the arithmetic itself - see the 10# below.)
|
||||
case "$entry_started" in
|
||||
''|*[!0-9]*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# The container that reports this run ID. bin/dejima passes DEJIMA_RUN_ID in
|
||||
# as env precisely so this correlation is possible without the container
|
||||
# having any write access to the registry.
|
||||
#
|
||||
# Exit status distinguishes "no such container" (1) from "couldn't ask" (2):
|
||||
# the caller prunes on the former, and pruning on the latter would permanently
|
||||
# unregister every running sandbox the moment dockerd happens to be restarting.
|
||||
container_for() {
|
||||
local run_id="$1" names name env unasked=""
|
||||
names="$(docker ps --filter "label=com.docker.compose.project=dejima" \
|
||||
--filter "status=running" --format '{{.Names}}' 2>/dev/null)" || return 2
|
||||
for name in $names; do
|
||||
# Captured rather than piped straight into grep: a pipeline reports
|
||||
# only grep's status, which would turn "inspect couldn't answer for
|
||||
# this container" into "this isn't the one" - and one such container
|
||||
# anywhere in the list would prune an unrelated live session.
|
||||
env="$(docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' "$name" 2>/dev/null)" \
|
||||
|| { unasked=1; continue; }
|
||||
if printf '%s\n' "$env" | grep -qxF "DEJIMA_RUN_ID=$run_id"; then
|
||||
printf '%s' "$name"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
[ -z "$unasked" ] || return 2
|
||||
return 1
|
||||
}
|
||||
|
||||
pane_alive() {
|
||||
[ -n "$1" ] || return 1
|
||||
tmux list-panes -a -F '#{pane_id}' 2>/dev/null | grep -qxF "$1"
|
||||
}
|
||||
|
||||
# Does session $1 actually hold pane $2? The only question that separates the
|
||||
# session we mean from one that merely carries its name. -s enumerates every
|
||||
# pane of every window in the session, and pane ids are never reused within a
|
||||
# server, so a match cannot be some later pane wearing an old id. pipefail
|
||||
# makes an unanswerable list-panes a "no", which is the safe direction.
|
||||
group_holds_pane() {
|
||||
tmux list-panes -s -t "=$1" -F '#{pane_id}' 2>/dev/null | grep -qxF "$2"
|
||||
}
|
||||
|
||||
# True while an entry is young enough that its container may simply not exist
|
||||
# YET - see STARTUP_GRACE. Judged against the entry's own started=, since
|
||||
# nothing else records when the launch began. An entry that doesn't parse has
|
||||
# no age to judge and is not covered: it is pruned as before. read_entry is
|
||||
# called again by the caller afterwards; re-reading four lines costs less
|
||||
# than restructuring the loop around this one field.
|
||||
starting_up() {
|
||||
local now="$2"
|
||||
read_entry "$1" || return 1
|
||||
[ "$(( now - 10#$entry_started ))" -lt "$STARTUP_GRACE" ]
|
||||
}
|
||||
|
||||
# The registry is a hint, not truth: bin/dejima's EXIT trap doesn't run under
|
||||
# SIGKILL or a host crash, so an entry can outlive its session. An entry with
|
||||
# no matching container - and old enough that it should have had one - is
|
||||
# pruned outright; one whose pane is gone is shown as stale rather than
|
||||
# offered for attachment.
|
||||
list_sessions() {
|
||||
local file run_id container state now rc warned=""
|
||||
[ -d "$SESSIONS_DIR" ] || return 0
|
||||
now="$(date +%s)"
|
||||
for file in "$SESSIONS_DIR"/*; do
|
||||
[ -f "$file" ] || continue
|
||||
run_id="$(basename "$file")"
|
||||
valid_run_id "$run_id" || continue
|
||||
rc=0
|
||||
container="$(container_for "$run_id")" || rc=$?
|
||||
if [ "$rc" -eq 1 ] && starting_up "$file" "$now"; then
|
||||
# Registered, but bin/dejima is still building - "not started yet"
|
||||
# is a third answer, and pruning on it would unregister a session
|
||||
# that is about to exist. Listed with the container column saying
|
||||
# so; the recorded pane is the one bin/dejima is building in, so
|
||||
# a session in this state is attachable like any other.
|
||||
container="starting"
|
||||
elif [ "$rc" -eq 1 ]; then
|
||||
rm -f "$file"
|
||||
# dejima-notify-watch keys its debounce stamps by run id too, and
|
||||
# nothing else ever removes them - without this, every session
|
||||
# leaves two behind for the life of the login session. Unguarded,
|
||||
# like the rm above and unlike the rm -rf below: the debounce
|
||||
# directory is host-owned and is never mounted into a sandbox.
|
||||
rm -f "$STATE_DIR/debounce/$run_id-waiting" "$STATE_DIR/debounce/$run_id-done"
|
||||
# Non-fatal: the marker directory is bind-mounted read-write into
|
||||
# the sandbox, so a compromised session can chmod it such that this
|
||||
# rm fails - and a failure here under set -e would abort the whole
|
||||
# listing, letting that session deny the operator the front door.
|
||||
rm -rf "${STATE_DIR:?}/notify/$run_id" \
|
||||
|| echo "warning: could not remove the marker directory for $run_id" >&2
|
||||
continue
|
||||
elif [ "$rc" -ne 0 ]; then
|
||||
# An unanswerable question is never a "no": the entry stays, and
|
||||
# the container column says the daemon couldn't confirm it.
|
||||
container="?"
|
||||
[ -n "$warned" ] || echo "warning: could not query docker - sessions listed unverified." >&2
|
||||
warned=1
|
||||
fi
|
||||
if ! read_entry "$file"; then
|
||||
echo "warning: skipping unreadable or malformed registry entry: $file" >&2
|
||||
continue
|
||||
fi
|
||||
if [ -z "$entry_pane" ]; then
|
||||
state="no-tmux"
|
||||
elif pane_alive "$entry_pane"; then
|
||||
state="attachable"
|
||||
else
|
||||
state="stale"
|
||||
fi
|
||||
printf '%s\t%s\t%s\t%dm\t%s\t%s\n' \
|
||||
"$run_id" "${entry_label:-?}" "$(basename "$entry_workspace")" \
|
||||
"$(( (now - 10#$entry_started) / 60 ))" "$state" "${container:0:12}"
|
||||
done
|
||||
}
|
||||
|
||||
# Attaches through a GROUPED session rather than the operator's own. Grouped
|
||||
# sessions share windows but track their own current window, so a phone can
|
||||
# sit on a different sandbox than the desktop, and destroy-unattached disposes
|
||||
# of the throwaway on detach.
|
||||
#
|
||||
# Nothing here sets window-size, deliberately: keeping the desktop from being
|
||||
# resized down to phone dimensions is what tmux's own default (latest, since
|
||||
# 3.1) already does, and the option's only scopes are the window - which a
|
||||
# grouped session SHARES with the operator's own, so setting it there
|
||||
# permanently overwrites their choice, with nothing to restore it afterwards -
|
||||
# and global. An operator who deliberately set smallest keeps it.
|
||||
attach() {
|
||||
local run_id="$1" file group win base target
|
||||
file="$SESSIONS_DIR/$run_id"
|
||||
[ -f "$file" ] || { echo "No such session: $run_id" >&2; exit 1; }
|
||||
if ! read_entry "$file"; then
|
||||
echo "Session $run_id has an unreadable or malformed registry entry." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "$entry_pane" ]; then
|
||||
echo "Session $run_id was not started from tmux - nothing to attach to." >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! pane_alive "$entry_pane"; then
|
||||
echo "Session $run_id is stale - its tmux pane is gone." >&2
|
||||
exit 1
|
||||
fi
|
||||
group="dejima-$run_id"
|
||||
# Both values come from the same live pane, in one round trip. Taking the
|
||||
# session name from the recorded label instead would follow a name rather
|
||||
# than the sandbox: an operator renaming sessions is enough to point it at
|
||||
# an unrelated session that has since taken the old name.
|
||||
#
|
||||
# The name is a starting point, never a guarantee. It is frequently
|
||||
# ANOTHER remote client's throwaway group (tmux reports whichever session
|
||||
# holding the window is most recent, and every grouped session here is
|
||||
# newer than the operator's own), and those carry destroy-unattached - so
|
||||
# the base can be made to stop existing on demand by anyone holding a
|
||||
# second connection. That is why the session created from it is verified
|
||||
# below instead of trusted: `new-session -t` does not fail on a name that
|
||||
# matches nothing, it silently creates a fresh UNGROUPED session, which
|
||||
# for a remote client is a plain host shell with no sandbox in it.
|
||||
#
|
||||
# display-message prints nothing and still exits 0 for a target that no
|
||||
# longer exists, so an empty result means the pane went away between the
|
||||
# check above and here. That case is caught by the verification below too;
|
||||
# bailing early just avoids creating a junk session to immediately kill,
|
||||
# and says what actually happened.
|
||||
target="$(tmux display-message -p -t "$entry_pane" '#{session_name}:#{window_index}' 2>/dev/null || true)"
|
||||
# Session names may contain ':', window indexes can't.
|
||||
base="${target%:*}"
|
||||
win="${target##*:}"
|
||||
if [ -z "$base" ] || [ -z "$win" ]; then
|
||||
echo "Session $run_id is stale - its tmux pane went away." >&2
|
||||
exit 1
|
||||
fi
|
||||
if tmux has-session -t "=$group" 2>/dev/null; then
|
||||
# Verified exactly like the session created below, and for the same
|
||||
# reason: nothing here created this one, so carrying the name is not
|
||||
# evidence of holding the sandbox. Reachable only as debris - a
|
||||
# dejima-<run-id> that exists but no longer contains the pane - and
|
||||
# anyone able to create that name already has a host shell, so this is
|
||||
# about landing in the right window rather than about containment.
|
||||
# Refused rather than killed: destroying a session this script did not
|
||||
# create is not its call, and the message names the one to look at.
|
||||
if ! group_holds_pane "$group" "$entry_pane"; then
|
||||
echo "Session $run_id: the existing tmux session $group does not hold its pane." >&2
|
||||
echo "Remove it (tmux kill-session -t '=$group') and attach again." >&2
|
||||
exit 1
|
||||
fi
|
||||
# Select within the GROUP, not via the pane id directly: the latter
|
||||
# would move the operator's own session's current window too.
|
||||
tmux select-window -t "=$group:$win"
|
||||
# Set here too, not just on the session we create: the option is
|
||||
# per-session state, so a group that ever reached this point without it
|
||||
# would keep outliving its client on every later attach.
|
||||
exec tmux attach-session -t "=$group" \; set-option destroy-unattached on
|
||||
fi
|
||||
# Created DETACHED first so the result can be inspected before a client
|
||||
# ever sees it: whatever tmux gave us has to actually contain the sandbox
|
||||
# pane, or it is a session tmux invented from a name that stopped existing
|
||||
# while we were looking at it. Anything else is killed rather than handed
|
||||
# over. ("="-anchored, so a name can only ever resolve exactly - an
|
||||
# unanchored target prefix/fnmatch-matches some other session.)
|
||||
tmux new-session -d -t "=$base" -s "$group"
|
||||
# Detached, it now outlives every failure below, and a session leaked that
|
||||
# way never gets destroy-unattached set on it, so it sits on the host for
|
||||
# good. The trap only ever names a session this call just created:
|
||||
# new-session fails on a duplicate name before we reach it.
|
||||
# ("="-anchored also so it can never mean "the current session", which is
|
||||
# what an empty target means to kill-session.)
|
||||
#
|
||||
# The name is baked in here rather than read at exit: $group is local, and
|
||||
# a trap body runs after the function's locals are gone, where set -u
|
||||
# would abort it and leak the session it exists to remove.
|
||||
trap "tmux kill-session -t '=$group' 2>/dev/null || true" EXIT
|
||||
if ! group_holds_pane "$group" "$entry_pane"; then
|
||||
echo "Session $run_id is stale - the tmux session holding its pane went away." >&2
|
||||
exit 1
|
||||
fi
|
||||
tmux select-window -t "=$group:$win"
|
||||
# destroy-unattached has to be set by the same command that attaches:
|
||||
# setting it while the session is still detached makes the server reap the
|
||||
# session before the attach lands.
|
||||
#
|
||||
# Deliberately not exec'd, unlike the branch above, which creates nothing:
|
||||
# attaching is the step most likely to fail on a remote client's terms -
|
||||
# no terminal for `ssh -T`, or a $TERM the host has no entry for, both of
|
||||
# which the client picks - and an exec leaves no shell behind to clean up
|
||||
# the session we just made. Detaching normally returns 0 and disarms the
|
||||
# trap; the session then goes away by destroy-unattached, not by us.
|
||||
tmux attach-session -t "=$group" \; set-option destroy-unattached on
|
||||
trap - EXIT
|
||||
}
|
||||
|
||||
picker() {
|
||||
local -a ids=() labels=() projects=() ages=() states=() containers=()
|
||||
local i choice run_id label project age state container
|
||||
local w_label=0 w_project=0 w_age=0 w_state=0
|
||||
# Split on tabs only, so a space in a project name stays part of one field.
|
||||
# The columns are measured on the way in because they are padded to the
|
||||
# widest value rather than to a guess: a project name is a directory name,
|
||||
# and a fixed width either truncates a long one or indents every short one
|
||||
# off the right edge of a phone.
|
||||
while IFS=$'\t' read -r run_id label project age state container; do
|
||||
ids+=("$run_id"); labels+=("$label"); projects+=("$project")
|
||||
ages+=("$age"); states+=("$state"); containers+=("$container")
|
||||
[ "${#label}" -le "$w_label" ] || w_label="${#label}"
|
||||
[ "${#project}" -le "$w_project" ] || w_project="${#project}"
|
||||
[ "${#age}" -le "$w_age" ] || w_age="${#age}"
|
||||
[ "${#state}" -le "$w_state" ] || w_state="${#state}"
|
||||
done < <(list_sessions)
|
||||
banner
|
||||
if [ "${#ids[@]}" -eq 0 ]; then
|
||||
printf ' %sNo live sessions.%s\n\n' "$C_MUTED" "$C_OFF"
|
||||
exit 0
|
||||
fi
|
||||
# The container column stays, and last, because it is not decoration: it
|
||||
# reads "starting" for a session bin/dejima is still building and "?" when
|
||||
# docker could not be asked at all, and dropping it would make either one
|
||||
# look like an ordinary attachable session.
|
||||
for i in "${!ids[@]}"; do
|
||||
local sc; sc="$(state_color "${states[$i]}")"
|
||||
printf ' %s%2d%s %s%s%s %s%-*s%s %s%-*s%s %s%*s%s %s%-*s%s %s%s%s\n' \
|
||||
"$C_WARN" "$((i + 1))" "$C_OFF" \
|
||||
"$sc" "$GLYPH_DOT" "$C_OFF" \
|
||||
"$C_BOLD$C_TEXT" "$w_project" "${projects[$i]}" "$C_OFF" \
|
||||
"$C_LINK" "$w_label" "${labels[$i]}" "$C_OFF" \
|
||||
"$C_MUTED" "$w_age" "${ages[$i]}" "$C_OFF" \
|
||||
"$sc" "$w_state" "${states[$i]}" "$C_OFF" \
|
||||
"$C_SUBTLE" "${containers[$i]}" "$C_OFF"
|
||||
done
|
||||
# Numbered rather than arrow-driven: phone soft keyboards handle digits
|
||||
# far better than arrow keys, and this is the phone's entry point.
|
||||
printf '\n %sattach to%s %s[1-%d, q to quit]%s: ' \
|
||||
"$C_MUTED" "$C_OFF" "$C_TEXT" "${#ids[@]}" "$C_OFF"
|
||||
read -r choice
|
||||
case "$choice" in
|
||||
q|Q|'') exit 0 ;;
|
||||
*[!0-9]*) echo "Not a number." >&2; exit 1 ;;
|
||||
esac
|
||||
# Normalized to base 10 once, here, because the range check and the
|
||||
# subscript below do not read a number the same way: `[` is POSIX test and
|
||||
# decimal-only, while $(( )) reads a leading zero as OCTAL. Without this,
|
||||
# "010" - which a phone keypad makes easy to type - passes the check as 10
|
||||
# and indexes as 8, and the caller is attached to a DIFFERENT session than
|
||||
# the one they picked, with nothing printed to say so.
|
||||
#
|
||||
# It fixes the two ugly shapes as well. "09" is a value here rather than
|
||||
# the "invalid octal constant" arithmetic error it is to $(( )); and a
|
||||
# number past int64 wraps to one the range check can answer for, where
|
||||
# `[ 99999999999999999999999 -gt 11 ]` instead exits 2 with "integer
|
||||
# expression expected" - which makes the `if` false, so "Out of range."
|
||||
# is never printed and the subscript below dies on an unbound element.
|
||||
choice="$((10#$choice))"
|
||||
if [ "$choice" -lt 1 ] || [ "$choice" -gt "${#ids[@]}" ]; then
|
||||
echo "Out of range." >&2
|
||||
exit 1
|
||||
fi
|
||||
attach "${ids[$((choice - 1))]}"
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
'') picker ;;
|
||||
--list) list_sessions ;;
|
||||
*)
|
||||
# This argument arrives from ttyd's --url-arg, i.e. from whoever can
|
||||
# reach the web front door. Validate its shape before it is used as a
|
||||
# path component or anything else.
|
||||
valid_run_id "$1" || { echo "Invalid session id." >&2; exit 1; }
|
||||
[ -f "$SESSIONS_DIR/$1" ] || { echo "No such session: $1" >&2; exit 1; }
|
||||
attach "$1"
|
||||
;;
|
||||
esac
|
||||
+564
-29
@@ -1,11 +1,11 @@
|
||||
#!/bin/bash
|
||||
# Host-side exit-time audit for bin/dejima. Snapshots the workspace's
|
||||
# host-persistence surfaces before the container runs, and after it exits
|
||||
# reports (advisory only - never blocks) anything a session changed that
|
||||
# would execute or load OUTSIDE the sandbox: git config keys that run a
|
||||
# command on the next `git`, and content under .claude/ / .mcp.json /
|
||||
# reports (advisory only - never blocks) anything that changed during that
|
||||
# window and would execute or load OUTSIDE the sandbox: git config keys that
|
||||
# run a command on the next `git`, and content under .claude/ / .mcp.json /
|
||||
# .envrc / .vscode/ / .git/hooks/. Read the report before running `git` or
|
||||
# `claude` in that repo again. See README "Security notes".
|
||||
# `claude` in that repo again. See docs/security.md.
|
||||
set -uo pipefail
|
||||
|
||||
cmd="${1:-}"; ws="${2:-}"; state="${3:-}"
|
||||
@@ -27,6 +27,96 @@ STATUSLINE_PATH="${DEJIMA_AUDIT_STATUSLINE:-$HOME/.claude/statusline.sh}"
|
||||
# this repo (a self-mounted session). Overridable for tests.
|
||||
AUDIT_REPO_ROOT="${DEJIMA_AUDIT_REPO_ROOT:-$(cd "$(dirname "$(readlink -f "$0")")/.." && pwd)}"
|
||||
|
||||
# Fixed, not configurable: a predictable report shape is worth more than
|
||||
# tunability, and >10 CRITICAL findings is an anomaly rather than a display
|
||||
# problem (an ordinary session produces zero).
|
||||
CRIT_CAP=10
|
||||
# WARNING is where volume actually lands: the INFO path in path_severity()
|
||||
# below is currently unreachable, so every high-volume workspace-file change
|
||||
# classifies as WARNING instead of collapsing into an INFO count. Capped
|
||||
# moderately - overflow here is ordinary, not an alarm.
|
||||
WARN_CAP=5
|
||||
|
||||
AUDIT_DIR="${DEJIMA_AUDIT_DIR:-${XDG_DATA_HOME:-$HOME/.local/share}/dejima/audit}"
|
||||
# Retention is a security property, not housekeeping. With one overwritten
|
||||
# report, a session that plants something needs only to talk the operator
|
||||
# into a restart for the next clean exit to erase the evidence - and
|
||||
# suggesting a restart is an entirely ordinary thing to suggest.
|
||||
AUDIT_KEEP="${DEJIMA_AUDIT_KEEP:-10}"
|
||||
|
||||
ws_slug() { printf '%s' "$1" | sed 's/[^a-zA-Z0-9]/-/g'; }
|
||||
|
||||
# The one report both `show` and `ack` operate on: the OLDEST unacknowledged
|
||||
# CRITICAL report if there is one, else the newest report overall.
|
||||
#
|
||||
# Oldest-CRITICAL-first, rather than a plain `sort | tail -1` over every
|
||||
# *.txt, because that always prefers the newer file - so a stale
|
||||
# *-CRITICAL.txt sitting behind a newer clean report would be unreachable
|
||||
# through `ack` entirely, while bin/dejima's startup warning kept firing on
|
||||
# it forever with no remedy but to press past it. That is the alert fatigue
|
||||
# this feature exists to end, so it must not be reachable by this route.
|
||||
#
|
||||
# Shared by both verbs so they cannot disagree: an operator following that
|
||||
# startup warning may well reach for `show` first, and showing them a
|
||||
# different report than the one being warned about sends them looking for a
|
||||
# finding that is not in it.
|
||||
select_report() {
|
||||
local dir="$1" r
|
||||
r="$(find "$dir" -maxdepth 1 -name '*-CRITICAL.txt' 2>/dev/null | sort | head -1)"
|
||||
[ -n "$r" ] || r="$(find "$dir" -maxdepth 1 -name '*.txt' 2>/dev/null | sort | tail -1)"
|
||||
[ -n "$r" ] || return 1
|
||||
printf '%s\n' "$r"
|
||||
}
|
||||
|
||||
BASELINE_DIR="${DEJIMA_AUDIT_BASELINE_DIR:-${XDG_DATA_HOME:-$HOME/.local/share}/dejima/baselines}"
|
||||
|
||||
# Two acknowledgement forms, deliberately distinct:
|
||||
# spam <id> - this changes routinely; count it, never list it.
|
||||
# Refused for CRITICAL surfaces (see `ack`).
|
||||
# safe <id> <sha> - this exact content is approved; anything else
|
||||
# re-fires at full severity. The only form permitted
|
||||
# for CRITICAL, so approving a hook today does not
|
||||
# approve a different hook tomorrow.
|
||||
baseline_suppresses() {
|
||||
local id="$1" sha="$2" bf verb rest bid bsha eid
|
||||
bf="$BASELINE_DIR/$(ws_slug "$ws")"
|
||||
[ -f "$bf" ] || return 1
|
||||
# Callers pass the RAW id; the baseline file stores the encoded form (see
|
||||
# enc_id). Encoded once here, outside the loop - it does not vary per
|
||||
# line, and this runs for every finding. For an ordinary id the encoding
|
||||
# is the identity, so baseline files written before this existed keep
|
||||
# matching unchanged.
|
||||
eid="$(enc_id "$id")"
|
||||
while read -r verb rest; do
|
||||
case "$verb" in ''|'#'*) continue ;; esac
|
||||
# An id may itself contain spaces (e.g. "file 2.md"), so it can't be
|
||||
# split off with a fixed field count - a naive `read verb id sha`
|
||||
# would truncate such an id at its first space and could then
|
||||
# collide with a shorter, unrelated real path of that truncated
|
||||
# name, silently suppressing a finding no one acknowledged. spam has
|
||||
# no trailing sha field, so its id is everything after the verb;
|
||||
# safe's sha is fixed-position-last, so its id is recovered by
|
||||
# stripping the last whitespace-separated field from the right.
|
||||
case "$verb" in
|
||||
spam) bid="$rest"; bsha="" ;;
|
||||
safe) case "$rest" in
|
||||
*' '*) bsha="${rest##* }"; bid="${rest% *}" ;;
|
||||
*) continue ;; # malformed: no sha field
|
||||
esac ;;
|
||||
*) continue ;; # unknown verb
|
||||
esac
|
||||
[ "$bid" = "$eid" ] || continue
|
||||
case "$verb" in
|
||||
# `ack` refusing to write `spam` for a CRITICAL surface is
|
||||
# convenience; this is the actual boundary - it holds even
|
||||
# against a hand-edited or corrupted baseline file.
|
||||
spam) [ "$(id_severity "$id")" = CRITICAL ] && return 1; return 0 ;;
|
||||
safe) [ -n "$sha" ] && [ "$bsha" = "$sha" ] && return 0 ;;
|
||||
esac
|
||||
done < "$bf"
|
||||
return 1
|
||||
}
|
||||
|
||||
# git config keys whose values run a command on the next git invocation, or
|
||||
# (include.path/includeif.*.path) indirect to another config file that could
|
||||
# set one - key names are case-insensitive in git, so match on a lowercased
|
||||
@@ -45,6 +135,128 @@ is_exec_key() {
|
||||
return 1
|
||||
}
|
||||
|
||||
# Severity of a monitored workspace-relative path, derived from the surface
|
||||
# alone - never from content. CRITICAL means "runs unsandboxed host code the
|
||||
# next time the operator uses this repo", which is decidable from a fixed
|
||||
# list. .vscode/tasks.json and launch.json are CRITICAL wholesale rather than
|
||||
# parsed for runOn:folderOpen: a JSON parser on the trust boundary costs more
|
||||
# than the false positives do.
|
||||
path_severity() {
|
||||
case "$1" in
|
||||
.envrc|.mcp.json) echo CRITICAL ;;
|
||||
.git/hooks/*) echo CRITICAL ;;
|
||||
.claude/settings.json|.claude/settings.local.json) echo CRITICAL ;;
|
||||
.vscode/tasks.json|.vscode/launch.json) echo CRITICAL ;;
|
||||
.claude/*|.vscode/*) echo WARNING ;;
|
||||
*) echo INFO ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Severity of any acknowledgeable finding id, namespaced or bare-path (see
|
||||
# baseline_suppresses' CRITICAL guard below). Bare ids (no known namespace
|
||||
# prefix) are workspace-relative paths, handled by path_severity. The
|
||||
# alias.* case is deliberately conservative: the audit only classifies a
|
||||
# `!`-prefixed alias value as CRITICAL, but an id alone can't say whether the
|
||||
# value started with `!`, so any alias is treated as CRITICAL for
|
||||
# suppression purposes.
|
||||
id_severity() {
|
||||
local k
|
||||
case "$1" in
|
||||
gitconfig:*) k="${1#gitconfig:}"; k="${k#worktree:}"
|
||||
is_exec_key "$k" && { echo CRITICAL; return; }
|
||||
case "$k" in alias.*) echo CRITICAL ;; *) echo WARNING ;; esac ;;
|
||||
statusline:*|tcb:*) echo CRITICAL ;;
|
||||
*) path_severity "$1" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Every finding interpolates workspace-controlled text (paths, config values)
|
||||
# into a terminal. A planted value containing C0 escapes can scroll, clear or
|
||||
# overwrite the report; C1 controls and Unicode bidi overrides/isolates
|
||||
# (U+202A-U+202E, U+2066-U+2069) can visually reverse or reorder the
|
||||
# displayed text without touching the bytes an operator would grep for - so
|
||||
# strip all of these at the display boundary, after all parsing and
|
||||
# TAB-splitting is done.
|
||||
sanitize() {
|
||||
printf '%s' "$1" | LC_ALL=C tr -d '\000-\037\177' \
|
||||
| LC_ALL=C sed $'s/\xc2[\x80-\x9f]//g; s/\xe2\x80[\xaa-\xae]//g; s/\xe2\x81[\xa6-\xa9]//g'
|
||||
}
|
||||
|
||||
# sanitize() is lossy, so it must never be what an ID passes through.
|
||||
#
|
||||
# `ack` derives the baseline id it records from the report's text, and the
|
||||
# next `compare` matches that id against the RAW bytes on disk. An id
|
||||
# containing anything sanitize() strips therefore could not round-trip at
|
||||
# all: the surface stayed permanently unsuppressible and its report
|
||||
# permanently unclearable, so bin/dejima's startup warning fired forever -
|
||||
# the alert fatigue this whole feature exists to end, reached from the other
|
||||
# side. Ids are encoded instead, and the encoded form is canonical
|
||||
# everywhere it is written down: in the report AND in the baseline file.
|
||||
#
|
||||
# An id that survives sanitize() unchanged is already display-safe and is
|
||||
# written verbatim - that is every ordinary id, so reports and existing
|
||||
# baseline files are byte-for-byte what they were. Anything else is
|
||||
# hex-encoded behind a marker, which is ASCII by construction and reverses
|
||||
# exactly.
|
||||
#
|
||||
# An id that merely LOOKS encoded is encoded too, so that decoding stays
|
||||
# unambiguous: the marker's presence always means this encoder put it there.
|
||||
# Without that, a workspace could present an id that `ack` decoded into one
|
||||
# of the attacker's choosing - baselining something other than the finding
|
||||
# while still counting it acknowledged, the same shape as the id-derivation
|
||||
# hijacks the anchored case arms in `ack` guard against. Only the START of
|
||||
# an id is significant, and no monitored path can begin with the marker
|
||||
# (they are all rooted at .claude/.vscode/.git/.envrc/.mcp.json), but a git
|
||||
# config section name is free text in a file the session can write and the
|
||||
# audit parses that file directly rather than through git.
|
||||
#
|
||||
# The encoded form is also the honest rendering. sanitize() DELETES those
|
||||
# bytes, so a planted ".git/hooks/pre<U+202E>commit" displayed as
|
||||
# "precommit" sends an operator grepping for a name no file on disk has.
|
||||
HEX_MARK='{hex}'
|
||||
enc_id() {
|
||||
local v="$1"
|
||||
# Fast path, and the only one an ordinary id ever takes: decide with
|
||||
# pattern matching alone, no subprocesses. hash_paths() calls this once
|
||||
# per monitored file on both the snapshot and the compare side, so the
|
||||
# exact check below - a subshell plus tr plus sed - is far too expensive
|
||||
# to run unconditionally (a bare `git init` alone ships ~14 sample
|
||||
# hooks).
|
||||
#
|
||||
# `local LC_ALL=C` puts bash's own matching into byte semantics for the
|
||||
# duration, so [[:cntrl:]] is exactly C0+DEL. Any C1 or bidi character
|
||||
# must contain the UTF-8 lead byte \302 or \342 respectively, so testing
|
||||
# for those catches every one. The test is deliberately conservative
|
||||
# rather than exact - ordinary text carrying one of those lead bytes (an
|
||||
# em dash, say) falls through to the exact check and is then found
|
||||
# clean and printed verbatim, which costs a little time and changes
|
||||
# nothing.
|
||||
local LC_ALL=C
|
||||
case "$v" in
|
||||
"$HEX_MARK"*) ;;
|
||||
*[[:cntrl:]]*|*$'\302'*|*$'\342'*) ;;
|
||||
*) printf '%s' "$v"; return ;;
|
||||
esac
|
||||
if [ "$(sanitize "$v")" = "$v" ]; then
|
||||
case "$v" in "$HEX_MARK"*) ;; *) printf '%s' "$v"; return ;; esac
|
||||
fi
|
||||
printf '%s%s' "$HEX_MARK" "$(printf '%s' "$v" | od -An -tx1 | tr -d ' \n')"
|
||||
}
|
||||
# Inverse of enc_id. Built with parameter expansion and one printf rather
|
||||
# than a pipeline, so no command substitution inside can eat a trailing
|
||||
# byte. printf '%b' consumes the escapes left to right in a single pass,
|
||||
# which is what makes "\\x41" decode to a literal backslash followed by x41
|
||||
# instead of to "A".
|
||||
dec_id() {
|
||||
local h="$1" esc=""
|
||||
case "$h" in
|
||||
"$HEX_MARK"*) h="${h#"$HEX_MARK"}" ;;
|
||||
*) printf '%s' "$h"; return ;;
|
||||
esac
|
||||
while [ -n "$h" ]; do esc="$esc\\x${h:0:2}"; h="${h:2}"; done
|
||||
printf '%b' "$esc"
|
||||
}
|
||||
|
||||
# Undo git's config-file quoting of a value (git wraps values containing
|
||||
# special chars in "..." and backslash-escapes embedded \ and ") so exec
|
||||
# detection sees the real value instead of a quoted literal.
|
||||
@@ -72,17 +284,30 @@ hash_entry() {
|
||||
fi
|
||||
}
|
||||
|
||||
hash_value() {
|
||||
printf '%s' "$1" | sha256sum | cut -d' ' -f1
|
||||
}
|
||||
|
||||
hash_paths() {
|
||||
# path<TAB>sha for each existing monitored path, sorted by path
|
||||
# encoded-path<TAB>sha for each existing monitored path, sorted by path.
|
||||
#
|
||||
# Encoded (see enc_id) rather than raw, because this record is itself
|
||||
# TAB-delimited and line-based: a filename containing a TAB would
|
||||
# otherwise be silently truncated at it by the `cut -f1` that reads this
|
||||
# back, and reported under a name no file on disk has - unackable for
|
||||
# exactly the reason enc_id exists, and CRITICAL if the truncation still
|
||||
# lands under .git/hooks/. TAB and newline are both in the set enc_id
|
||||
# encodes, so an encoded path can never contain either. Ordinary paths
|
||||
# encode to themselves, so this record is unchanged for them.
|
||||
local ws="$1" p
|
||||
{
|
||||
for p in .mcp.json .envrc; do
|
||||
[ -f "$ws/$p" ] && printf '%s\t%s\n' "$p" "$(hash_entry "$ws/$p")"
|
||||
[ -f "$ws/$p" ] && printf '%s\t%s\n' "$(enc_id "$p")" "$(hash_entry "$ws/$p")"
|
||||
done
|
||||
for dir in .claude .vscode .git/hooks; do
|
||||
if [ -d "$ws/$dir" ]; then
|
||||
while IFS= read -r -d '' f; do
|
||||
printf '%s\t%s\n' "${f#"$ws"/}" "$(hash_entry "$f")"
|
||||
printf '%s\t%s\n' "$(enc_id "${f#"$ws"/}")" "$(hash_entry "$f")"
|
||||
done < <(find "$ws/$dir" \( -type f -o -type l \) -print0)
|
||||
fi
|
||||
done
|
||||
@@ -90,19 +315,20 @@ hash_paths() {
|
||||
}
|
||||
|
||||
tcb_snapshot() {
|
||||
# path<TAB>sha for the sandbox's own trusted computing base, but only
|
||||
# when $ws IS the sandbox's repo (self-mount) - see AUDIT_REPO_ROOT.
|
||||
# encoded-path<TAB>sha for the sandbox's own trusted computing base, but
|
||||
# only when $ws IS the sandbox's repo (self-mount) - see AUDIT_REPO_ROOT.
|
||||
# Empty otherwise, so a normal (non-self) workspace is unaffected.
|
||||
# Encoded for the same reason as hash_paths above.
|
||||
local ws="$1" p
|
||||
[ "$(readlink -f "$ws" 2>/dev/null)" = "$AUDIT_REPO_ROOT" ] || return 0
|
||||
{
|
||||
for p in install.sh; do
|
||||
[ -f "$ws/$p" ] && printf '%s\t%s\n' "$p" "$(hash_entry "$ws/$p")"
|
||||
[ -f "$ws/$p" ] && printf '%s\t%s\n' "$(enc_id "$p")" "$(hash_entry "$ws/$p")"
|
||||
done
|
||||
for dir in bin hooks docker test; do
|
||||
if [ -d "$ws/$dir" ]; then
|
||||
while IFS= read -r -d '' f; do
|
||||
printf '%s\t%s\n' "${f#"$ws"/}" "$(hash_entry "$f")"
|
||||
printf '%s\t%s\n' "$(enc_id "${f#"$ws"/}")" "$(hash_entry "$f")"
|
||||
done < <(find "$ws/$dir" \( -type f -o -type l \) -print0)
|
||||
fi
|
||||
done
|
||||
@@ -148,6 +374,35 @@ git_config_snapshot() {
|
||||
done | sort
|
||||
}
|
||||
|
||||
# The sha ack/baseline_suppresses use for a gitconfig:<key> id: the value
|
||||
# currently set for that exact key (worktree: prefix included, as parsed by
|
||||
# git_config_snapshot), hashed the same way compare does. Empty (and a
|
||||
# failing return) if the key no longer exists.
|
||||
gitconfig_sha_for_key() {
|
||||
local ws="$1" key="$2" line lkey
|
||||
while IFS= read -r line; do
|
||||
lkey="${line%%=*}"
|
||||
[ "$lkey" = "$key" ] || continue
|
||||
hash_value "$(unquote_value "${line#*=}")"
|
||||
return 0
|
||||
done < <(git_config_snapshot "$ws")
|
||||
return 1
|
||||
}
|
||||
|
||||
# The sha ack writes for any acknowledgeable id, dispatched by namespace -
|
||||
# mirrors id_severity's dispatch and the hash each compare loop computes for
|
||||
# that namespace, so the id/sha pair ack records is exactly what
|
||||
# baseline_suppresses will later compare against.
|
||||
current_sha_for_id() {
|
||||
local id="$1"
|
||||
case "$id" in
|
||||
gitconfig:*) gitconfig_sha_for_key "$ws" "${id#gitconfig:}" ;;
|
||||
statusline:*) [ -e "$STATUSLINE_PATH" ] && hash_entry "$STATUSLINE_PATH" ;;
|
||||
tcb:*) [ -e "$ws/${id#tcb:}" ] && hash_entry "$ws/${id#tcb:}" ;;
|
||||
*) [ -e "$ws/$id" ] && hash_entry "$ws/$id" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
projects_snapshot() {
|
||||
# slug<TAB>maxmtime for each immediate subdir of PROJECTS_DIR, sorted by
|
||||
# slug; maxmtime is 0 if the slug dir has no files.
|
||||
@@ -181,7 +436,19 @@ case "$cmd" in
|
||||
exit 0
|
||||
;;
|
||||
compare)
|
||||
[ -f "$state" ] || { echo "dejima audit: no snapshot to compare against, skipping." >&2; exit 0; }
|
||||
# cleanup() (bin/dejima) now runs this from the EXIT trap, which
|
||||
# fires on early exits too - before the workspace's mktemp'd state
|
||||
# file has ever been written to by `snapshot`. Such a file exists
|
||||
# but is empty, and treating "exists" as "has a snapshot" would diff
|
||||
# the real current state against nothing, manufacturing a finding
|
||||
# for every monitored surface already present. Empty is the same as
|
||||
# absent for that purpose, so both skip - but only a genuinely
|
||||
# missing path (not the routine mktemp'd-but-unwritten case that
|
||||
# follows every pre-snapshot fatal exit) is worth a message: that
|
||||
# case is the operator's last line on a fatal error, and printing
|
||||
# "skipping" over it would bury the real failure.
|
||||
[ -e "$state" ] || { echo "dejima audit: no snapshot to compare against, skipping." >&2; exit 0; }
|
||||
[ -s "$state" ] || exit 0
|
||||
new="$(mktemp)"; { echo "### gitconfig"; git_config_snapshot "$ws";
|
||||
echo "### paths"; hash_paths "$ws";
|
||||
echo "### projects"; projects_snapshot "$PROJECTS_DIR";
|
||||
@@ -189,21 +456,31 @@ case "$cmd" in
|
||||
echo "### statusline"; statusline_snapshot "$STATUSLINE_PATH"; } > "$new" 2>/dev/null || {
|
||||
echo "dejima audit: could not complete, skipping." >&2; rm -f "$new"; exit 0; }
|
||||
|
||||
findings=()
|
||||
crit=(); warn=(); info=()
|
||||
# config diffs: added/changed lines present in new but not old
|
||||
while IFS= read -r line; do
|
||||
key="${line%%=*}"
|
||||
key_nopfx="${key#worktree:}"
|
||||
# The key is the id-bearing half and is encoded; the value is
|
||||
# display-only and is sanitized. Splitting them keeps a value
|
||||
# full of control bytes from dragging its key into the opaque
|
||||
# hex form, where the operator could no longer read which
|
||||
# setting had changed.
|
||||
body="$(enc_id "$key")=$(sanitize "${line#*=}")"
|
||||
if is_exec_key "$key_nopfx"; then
|
||||
findings+=("COMMAND-EXECUTION git config: $line")
|
||||
gid="gitconfig:$key"; gsha="$(hash_value "$(unquote_value "${line#*=}")")"
|
||||
baseline_suppresses "$gid" "$gsha" && continue
|
||||
crit+=("COMMAND-EXECUTION git config: $body")
|
||||
elif [ "$key_nopfx" = "alias" ] || [[ "$key_nopfx" == alias.* ]]; then
|
||||
# alias.<name>=!cmd runs a shell command
|
||||
case "$(unquote_value "${line#*=}")" in
|
||||
'!'*) findings+=("COMMAND-EXECUTION git alias: $line") ;;
|
||||
*) findings+=("changed git config: $line") ;;
|
||||
'!'*) gid="gitconfig:$key"; gsha="$(hash_value "$(unquote_value "${line#*=}")")"
|
||||
baseline_suppresses "$gid" "$gsha" && continue
|
||||
crit+=("COMMAND-EXECUTION git alias: $body") ;;
|
||||
*) warn+=("changed git config: $body") ;;
|
||||
esac
|
||||
else
|
||||
findings+=("changed git config: $line")
|
||||
warn+=("changed git config: $body")
|
||||
fi
|
||||
done < <(comm -13 <(sed -n '/^### gitconfig/,/^### paths/p' "$state" | grep -vE '^### |^worktree:$') \
|
||||
<(sed -n '/^### gitconfig/,/^### paths/p' "$new" | grep -vE '^### |^worktree:$') )
|
||||
@@ -212,7 +489,24 @@ case "$cmd" in
|
||||
# change emits both an old-hash and new-hash line; dedupe by path
|
||||
# so each changed path is reported once)
|
||||
while IFS= read -r line; do
|
||||
findings+=("changed workspace file: $line")
|
||||
# $line arrives encoded (see hash_paths). Everything that has to
|
||||
# reach the real file, classify it, or match a baseline entry
|
||||
# needs the raw name; only the report body keeps the encoded
|
||||
# form, which is what enc_id would produce for it anyway.
|
||||
rline="$(dec_id "$line")"
|
||||
cur_sha=""
|
||||
[ -e "$ws/$rline" ] && cur_sha="$(hash_entry "$ws/$rline")"
|
||||
baseline_suppresses "$rline" "$cur_sha" && continue
|
||||
case "$(path_severity "$rline")" in
|
||||
CRITICAL) crit+=("HOST-CODE-EXECUTION workspace file: $line") ;;
|
||||
WARNING) warn+=("changed workspace file: $line") ;;
|
||||
# Currently unreachable: hash_paths() only monitors
|
||||
# .mcp.json/.envrc/.claude/.vscode/.git/hooks, and every path
|
||||
# under those matches an earlier CRITICAL/WARNING pattern in
|
||||
# path_severity(). Kept as the defensive default for
|
||||
# surfaces monitored by a future task.
|
||||
*) info+=("changed workspace file: $line") ;;
|
||||
esac
|
||||
done < <(comm -3 <(sed -n '/^### paths/,/^### projects/p' "$state" | grep -v '^### ') \
|
||||
<(sed -n '/^### paths/,/^### projects/p' "$new" | grep -v '^### ') \
|
||||
| sed 's/^\t//' | cut -f1 | sort -u )
|
||||
@@ -220,13 +514,13 @@ case "$cmd" in
|
||||
# projects diffs: a write to another project's ~/.claude/projects
|
||||
# slug during this session (transcript/memory-poisoning surface).
|
||||
# "self" = current workspace's own slug or a worktree child of it.
|
||||
current_slug="$(printf '%s' "$ws" | sed 's/[^a-zA-Z0-9]/-/g')"
|
||||
current_slug="$(ws_slug "$ws")"
|
||||
while IFS= read -r line; do
|
||||
slug="${line%%$'\t'*}"
|
||||
case "$slug" in
|
||||
"$current_slug"|"$current_slug"-*) continue ;;
|
||||
esac
|
||||
findings+=("CROSS-PROJECT history/memory write: $slug")
|
||||
warn+=("CROSS-PROJECT history/memory write: $(sanitize "$slug")")
|
||||
done < <(comm -13 <(sed -n '/^### projects/,/^### tcb/p' "$state" | grep -v '^### ') \
|
||||
<(sed -n '/^### projects/,/^### tcb/p' "$new" | grep -v '^### ') )
|
||||
|
||||
@@ -236,7 +530,11 @@ case "$cmd" in
|
||||
# Bounded at ### statusline (not $p, the last section now) so this
|
||||
# doesn't also swallow that section's lines.
|
||||
while IFS= read -r line; do
|
||||
findings+=("TCB (sandbox self) modified: $line")
|
||||
# Encoded on arrival, same as the path loop above.
|
||||
rline="$(dec_id "$line")"
|
||||
tid="tcb:$rline"; tsha=""; [ -e "$ws/$rline" ] && tsha="$(hash_entry "$ws/$rline")"
|
||||
baseline_suppresses "$tid" "$tsha" && continue
|
||||
crit+=("TCB (sandbox self) modified: $line")
|
||||
done < <(comm -3 <(sed -n '/^### tcb/,/^### statusline/p' "$state" | grep -v '^### ') \
|
||||
<(sed -n '/^### tcb/,/^### statusline/p' "$new" | grep -v '^### ') \
|
||||
| sed 's/^\t//' | cut -f1 | sort -u )
|
||||
@@ -247,28 +545,265 @@ case "$cmd" in
|
||||
# strongly as COMMAND-EXECUTION/TCB above: this is unsandboxed host
|
||||
# code execution, not just a config change.
|
||||
while IFS= read -r line; do
|
||||
findings+=("HOST-CODE-EXECUTION statusline.sh changed (runs UNSANDBOXED on the host via settings.json statusLine.command): $line")
|
||||
sid="statusline:$line"; ssha=""; [ -e "$STATUSLINE_PATH" ] && ssha="$(hash_entry "$STATUSLINE_PATH")"
|
||||
baseline_suppresses "$sid" "$ssha" && continue
|
||||
crit+=("HOST-CODE-EXECUTION statusline.sh changed (runs UNSANDBOXED on the host via settings.json statusLine.command): $(enc_id "$line")")
|
||||
done < <(comm -3 <(sed -n '/^### statusline/,$p' "$state" | grep -v '^### ') \
|
||||
<(sed -n '/^### statusline/,$p' "$new" | grep -v '^### ') \
|
||||
| sed 's/^\t//' | cut -f1 | sort -u )
|
||||
|
||||
rm -f "$new"
|
||||
[ "${#findings[@]}" -eq 0 ] && exit 0
|
||||
total=$(( ${#crit[@]} + ${#warn[@]} + ${#info[@]} ))
|
||||
if [ "$total" -eq 0 ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
report_dir="$AUDIT_DIR/$(ws_slug "$ws")"
|
||||
if mkdir -p "$report_dir" 2>/dev/null; then
|
||||
if [ "${#crit[@]}" -gt 0 ]; then
|
||||
report="$report_dir/$(date +%Y-%m-%d-%H%M%S)-CRITICAL.txt"
|
||||
else
|
||||
report="$report_dir/$(date +%Y-%m-%d-%H%M%S).txt"
|
||||
fi
|
||||
{
|
||||
echo "dejima audit report for $(sanitize "$ws")"
|
||||
echo "Changes below happened while this session ran - not necessarily because"
|
||||
echo "of it. A concurrent dejima/Claude Code session, or your own edits during"
|
||||
echo "the run, land in this same diff; see docs/security.md."
|
||||
[ "${#crit[@]}" -gt 0 ] && printf 'CRITICAL %s\n' "${crit[@]}"
|
||||
[ "${#warn[@]}" -gt 0 ] && printf 'WARNING %s\n' "${warn[@]}"
|
||||
[ "${#info[@]}" -gt 0 ] && printf 'INFO %s\n' "${info[@]}"
|
||||
} > "$report" 2>/dev/null || true
|
||||
|
||||
# Oldest-first, but only clean reports are evictable: a report
|
||||
# still carrying an unacknowledged CRITICAL is retained past the
|
||||
# cap, so flushing one out cannot be achieved by inducing runs.
|
||||
# `ack` renames it to drop the suffix, which makes it evictable.
|
||||
while [ "$(find "$report_dir" -maxdepth 1 -name '*.txt' ! -name '*-CRITICAL.txt' | wc -l)" -gt "$AUDIT_KEEP" ]; do
|
||||
oldest="$(find "$report_dir" -maxdepth 1 -name '*.txt' ! -name '*-CRITICAL.txt' | sort | head -1)"
|
||||
[ -n "$oldest" ] || break
|
||||
rm -f "$oldest" || break
|
||||
done
|
||||
fi
|
||||
|
||||
# Ascending severity: the operator reads bottom-up from their shell
|
||||
# prompt, so whatever prints LAST is what actually gets seen. INFO
|
||||
# collapses to a count - it exists to be counted, not read.
|
||||
{
|
||||
echo ""
|
||||
echo "================================================================"
|
||||
echo " dejima: this session changed host-persistence surfaces"
|
||||
echo " in $ws"
|
||||
echo " Review these BEFORE running 'git' or 'claude' in that repo:"
|
||||
echo " dejima: host-persistence surfaces changed in"
|
||||
echo " $(sanitize "$ws") while this session ran"
|
||||
echo "----------------------------------------------------------------"
|
||||
printf ' - %s\n' "${findings[@]}"
|
||||
echo "================================================================"
|
||||
if [ "${#info[@]}" -gt 0 ]; then
|
||||
echo " ${#info[@]} routine file change(s) - not listed"
|
||||
fi
|
||||
if [ "${#warn[@]}" -gt 0 ]; then
|
||||
printf ' - %s\n' "${warn[@]:0:$WARN_CAP}"
|
||||
if [ "${#warn[@]}" -gt "$WARN_CAP" ]; then
|
||||
echo " + $(( ${#warn[@]} - WARN_CAP )) further change(s) not shown."
|
||||
fi
|
||||
fi
|
||||
if [ "${#crit[@]}" -gt 0 ]; then
|
||||
printf ' ! %s\n' "${crit[@]:0:$CRIT_CAP}"
|
||||
if [ "${#crit[@]}" -gt "$CRIT_CAP" ]; then
|
||||
echo " !! $(( ${#crit[@]} - CRIT_CAP )) FURTHER CRITICAL FINDINGS NOT SHOWN."
|
||||
echo " !! ${#crit[@]} critical findings in one session is ABNORMAL."
|
||||
fi
|
||||
fi
|
||||
echo "----------------------------------------------------------------"
|
||||
if [ "${#crit[@]}" -gt 0 ]; then
|
||||
echo " Review BEFORE the next 'git' or 'claude' in that repo."
|
||||
echo " dejima: ${#crit[@]} CRITICAL - code that will run on your host."
|
||||
else
|
||||
echo " dejima: no critical findings; ${#warn[@]} change(s) to review."
|
||||
fi
|
||||
} >&2
|
||||
exit 0
|
||||
;;
|
||||
show)
|
||||
report_dir="$AUDIT_DIR/$(ws_slug "$ws")"
|
||||
report="$(select_report "$report_dir")" \
|
||||
|| { echo "dejima audit: no reports for $ws" >&2; exit 0; }
|
||||
# Which report, on stderr so stdout stays the report content alone:
|
||||
# `show` no longer necessarily prints the newest one, and an operator
|
||||
# cross-referencing the run timestamps in the startup warning needs to
|
||||
# know which of them they are looking at.
|
||||
echo "dejima audit: $report" >&2
|
||||
cat "$report"
|
||||
exit 0
|
||||
;;
|
||||
ack)
|
||||
report_dir="$AUDIT_DIR/$(ws_slug "$ws")"
|
||||
bf="$BASELINE_DIR/$(ws_slug "$ws")"
|
||||
report="$(select_report "$report_dir")" \
|
||||
|| { echo "dejima audit: no reports for $ws" >&2; exit 0; }
|
||||
|
||||
# Accumulate in memory and write once at the end: an ack interrupted
|
||||
# partway (q/Esc/EOF/Ctrl-C) must not leave a CRITICAL surface
|
||||
# silently approved, so every abort path discards everything rather
|
||||
# than half-writing.
|
||||
pending=(); aborted=0
|
||||
# crit_seen/crit_acked gate the -CRITICAL.txt rename below: it must
|
||||
# fire only when every CRITICAL finding in THIS report was actually
|
||||
# approved, not merely when the operator approved something. A
|
||||
# report mixing an unacknowledged CRITICAL with an approved WARNING
|
||||
# (or a non-path CRITICAL, which can never be "approved" - see
|
||||
# ackable below) must keep its suffix and stay eviction-protected.
|
||||
crit_seen=0; crit_acked=0
|
||||
# The loop below reads report lines on its own stdin (`< "$report"`),
|
||||
# so operator keystrokes are read from fd 3 - a copy of the script's
|
||||
# original stdin taken before that redirection - rather than
|
||||
# /dev/tty (which has nothing to open under piped test input; a
|
||||
# bare `read` on fd 0 would otherwise be consuming the very report
|
||||
# lines the loop is iterating). In real use fd 3 is still the
|
||||
# operator's terminal, since that's what an interactive shell hands
|
||||
# a foreground command as fd 0. Piping into `ack` therefore drives
|
||||
# approvals in both test and real use through the same code path -
|
||||
# safe, because the read-time CRITICAL guard in baseline_suppresses()
|
||||
# (a `spam` entry can never silence a CRITICAL surface) holds no
|
||||
# matter how a baseline entry got written, piped or typed.
|
||||
{ exec 3<&0; } 2>/dev/null
|
||||
while IFS= read -r rline; do
|
||||
tier="${rline%% *}"
|
||||
# Report lines are column-padded by the printf that wrote them
|
||||
# (`'CRITICAL %s\n'` vs `'WARNING %s\n'` - one vs two spaces),
|
||||
# so stripping only the first space would leave WARNING bodies
|
||||
# with a leading space that a since-anchored case match (see
|
||||
# below) would silently fail to match. Strip the exact,
|
||||
# known-fixed prefix per tier instead of a single space.
|
||||
case "$tier" in
|
||||
CRITICAL) body="${rline#CRITICAL }" ;;
|
||||
WARNING) body="${rline#WARNING }" ;;
|
||||
*) continue ;;
|
||||
esac
|
||||
[ "$tier" = CRITICAL ] && crit_seen=$((crit_seen + 1))
|
||||
# Derive the namespaced id baseline_suppresses() will later
|
||||
# compare against (see id_severity/current_sha_for_id above) -
|
||||
# one case arm per finding-producing loop in `compare`. Every
|
||||
# pattern is anchored to the fixed label text `compare` itself
|
||||
# emits at the START of body, never matched as a substring
|
||||
# anywhere in it: a workspace-controlled value (a git config
|
||||
# value, a path) can contain text like "workspace file: " or
|
||||
# "TCB (sandbox self) modified: " and reach $body unmodified, so
|
||||
# an unanchored match could be hijacked into deriving the wrong
|
||||
# (attacker-chosen) id for the wrong namespace - approving it
|
||||
# would then baseline an id that can never match, un-baselining
|
||||
# the real finding while still counting it as acknowledged. A
|
||||
# finding whose body matches none of these (currently only
|
||||
# CROSS-PROJECT) has no id to record, so it's shown but not
|
||||
# offered a/s.
|
||||
#
|
||||
# The id itself must then be recovered with a shortest-prefix
|
||||
# strip of that same fixed label (`${body#LABEL}`), never
|
||||
# `${body##*: }`: the latter strips up to the LAST ": " in the
|
||||
# whole string, and a workspace-controlled path can itself
|
||||
# contain a literal ": " (e.g. ".git/hooks/pre-commit: decoy"),
|
||||
# which would truncate the derived id to everything after that
|
||||
# planted separator - the case match being anchored doesn't
|
||||
# protect the extraction that follows it.
|
||||
#
|
||||
# Two forms come out of this, because the two consumers need
|
||||
# different ones (see enc_id): $id is the ENCODED id exactly as
|
||||
# the report carries it, and is what gets written to the
|
||||
# baseline file, which stores that form. $rid is the same id
|
||||
# decoded back to the raw bytes on disk, for current_sha_for_id
|
||||
# to stat and hash and for id_severity to classify. Only the
|
||||
# id-bearing portion is decoded - never the namespace prefix
|
||||
# this code adds itself.
|
||||
ackable=0; id=""; rid=""
|
||||
case "$body" in
|
||||
"HOST-CODE-EXECUTION workspace file: "*)
|
||||
ackable=1; id="${body#HOST-CODE-EXECUTION workspace file: }"
|
||||
rid="$(dec_id "$id")" ;;
|
||||
"changed workspace file: "*)
|
||||
ackable=1; id="${body#changed workspace file: }"
|
||||
rid="$(dec_id "$id")" ;;
|
||||
"COMMAND-EXECUTION git config: "*|"COMMAND-EXECUTION git alias: "*)
|
||||
ackable=1; cfgline="${body#*: }"; ekey="${cfgline%%=*}"
|
||||
id="gitconfig:$ekey"; rid="gitconfig:$(dec_id "$ekey")" ;;
|
||||
"HOST-CODE-EXECUTION statusline.sh changed (runs UNSANDBOXED on the host via settings.json statusLine.command): "*)
|
||||
ackable=1; esl="${body#HOST-CODE-EXECUTION statusline.sh changed (runs UNSANDBOXED on the host via settings.json statusLine.command): }"
|
||||
id="statusline:$esl"; rid="statusline:$(dec_id "$esl")" ;;
|
||||
"TCB (sandbox self) modified: "*)
|
||||
ackable=1; etcb="${body#TCB (sandbox self) modified: }"
|
||||
id="tcb:$etcb"; rid="tcb:$(dec_id "$etcb")" ;;
|
||||
*) ackable=0 ;;
|
||||
esac
|
||||
# Defence in depth: even an anchored arm above should never
|
||||
# leave id empty while claiming ackable, but don't trust that -
|
||||
# crit_acked (and thus whether a report can shed -CRITICAL.txt)
|
||||
# must only ever advance when a real, non-empty id was derived.
|
||||
[ -n "$id" ] || ackable=0
|
||||
[ -n "$rid" ] || ackable=0
|
||||
echo ""
|
||||
echo " [$tier] $body"
|
||||
if [ "$ackable" -ne 1 ]; then
|
||||
printf ' [c] skip [q] quit (this finding cannot be acknowledged): '
|
||||
elif [ "$tier" = CRITICAL ]; then
|
||||
printf ' [a] approve this exact content [c] skip [q] quit: '
|
||||
else
|
||||
printf ' [a] approve [s] always ignore [c] skip [q] quit: '
|
||||
fi
|
||||
{ read -r key <&3; } 2>/dev/null || { aborted=1; break; }
|
||||
case "$key" in
|
||||
q|$'\e'|'') aborted=1; break ;;
|
||||
s)
|
||||
if [ "$ackable" -ne 1 ]; then
|
||||
echo " cannot acknowledge: this finding cannot be acknowledged."
|
||||
elif [ "$tier" = CRITICAL ]; then
|
||||
echo " cannot 'always ignore' a CRITICAL surface - approve the exact content or skip."
|
||||
else
|
||||
pending+=("spam $id")
|
||||
fi
|
||||
;;
|
||||
a)
|
||||
if [ "$ackable" -ne 1 ]; then
|
||||
echo " cannot acknowledge: this finding cannot be acknowledged."
|
||||
else
|
||||
sha="$(current_sha_for_id "$rid")"
|
||||
if [ -z "$sha" ]; then
|
||||
# Nothing left to pin the approval to: current_sha_for_id
|
||||
# returning empty means $id no longer exists (deleted
|
||||
# between compare and ack, or - for gitconfig - the key
|
||||
# was unset). Writing `safe <id> ` anyway would record a
|
||||
# baseline entry baseline_suppresses() can never match
|
||||
# (its own empty-sha guard rejects it as malformed), so
|
||||
# the surface would stay permanently unsuppressible -
|
||||
# while still advancing crit_acked and letting a CRITICAL
|
||||
# report shed its -CRITICAL.txt protection. Refuse
|
||||
# instead: record nothing, and leave the report's
|
||||
# protection exactly as it was.
|
||||
echo " cannot approve: $id no longer exists"
|
||||
else
|
||||
pending+=("safe $id $sha")
|
||||
[ "$tier" = CRITICAL ] && crit_acked=$((crit_acked + 1))
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
*) ;;
|
||||
esac
|
||||
done < "$report"
|
||||
|
||||
if [ "$aborted" -eq 1 ] || [ "${#pending[@]}" -eq 0 ]; then
|
||||
echo ""; echo "dejima audit: nothing recorded."
|
||||
exit 0
|
||||
fi
|
||||
mkdir -p "$BASELINE_DIR"
|
||||
printf '%s\n' "${pending[@]}" >> "$bf"
|
||||
# Only rename off -CRITICAL.txt (making the report evictable by
|
||||
# rotation) when every CRITICAL finding this report carried was
|
||||
# actually approved - not just when something was approved.
|
||||
if [ "$crit_seen" -gt 0 ] && [ "$crit_acked" -eq "$crit_seen" ]; then
|
||||
case "$report" in
|
||||
*-CRITICAL.txt) mv "$report" "${report%-CRITICAL.txt}.txt" ;;
|
||||
esac
|
||||
fi
|
||||
echo ""; echo "dejima audit: recorded ${#pending[@]} acknowledgement(s) in $bf"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "usage: dejima-audit {snapshot|compare} <workspace> <state-file>" >&2
|
||||
echo "usage: dejima-audit {snapshot|compare|show|ack} <workspace> [state-file]" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
Executable
+282
@@ -0,0 +1,282 @@
|
||||
#!/bin/bash
|
||||
# Host-side watcher: turns marker files dropped by hooks/dejima-notify.sh
|
||||
# into ntfy pushes. Runs as a systemd USER unit on the host - never as root,
|
||||
# never inside a container. See docs/remote-access.md.
|
||||
#
|
||||
# Usage: dejima-notify-watch [--once]
|
||||
# (no argument) watch until killed - what the systemd unit runs
|
||||
# --once one scan, then exit
|
||||
#
|
||||
# This script is the trust boundary. Everything under the marker root is
|
||||
# written by a sandboxed session that may be compromised, and the device it
|
||||
# ultimately reaches - the operator's phone - is outside the sandbox. The
|
||||
# in-container hook's own enum constrains nothing here: a session owns the
|
||||
# contents of its bind-mounted marker directory and can create any name it
|
||||
# likes in it, hook or no hook. The guarantee rests entirely on this file:
|
||||
#
|
||||
# - the marker NAME is the only thing consulted, and only by iterating a
|
||||
# literal enum, so a name outside it is never stat'd, read, or removed
|
||||
# - marker CONTENT is never read, so there is nothing to inject
|
||||
# - names are never interpolated into a command
|
||||
# - `rm -f` unlinks a symlink instead of following it, so a marker aimed at
|
||||
# a host file removes the link and leaves the file alone
|
||||
# - the click URL and every ntfy header are composed from host-side data
|
||||
#
|
||||
# That last point is what the enum is really for. ntfy's own headers include
|
||||
# X-Actions (which can make the phone issue an arbitrary HTTP request or fire
|
||||
# an Android intent), X-Attach and X-Icon (which fetch URLs), X-Email and
|
||||
# X-Call. A single attacker-controlled header here would turn the phone into
|
||||
# a confused deputy inside the operator's network.
|
||||
#
|
||||
# One scan covers every run directory, so nothing a single session can do to
|
||||
# its own markers may abort it: that would let one compromised sandbox silence
|
||||
# every other session's notifications.
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
|
||||
# shellcheck source=dejima-project-lib.sh
|
||||
source "$SCRIPT_DIR/dejima-project-lib.sh"
|
||||
|
||||
if ! remote_enabled; then
|
||||
echo "Remote access is not configured ($(remote_conf_path) does not exist)." >&2
|
||||
echo "See README \"Remote access\"." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
NTFY_URL="$(remote_conf_get NTFY_URL)"
|
||||
BASE_URL="$(remote_conf_get BASE_URL)"
|
||||
if [ -z "$NTFY_URL" ]; then
|
||||
echo "NTFY_URL is not set in $(remote_conf_path) - nothing to push to." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# valid_remote_value permits a leading '-', which is harmless in a config file
|
||||
# and not harmless in curl's argument list: curl reads "-K/path" as an OPTION,
|
||||
# and -K takes the request's URL - and its output file - from that path
|
||||
# instead, so a value that isn't a URL sends the push somewhere the operator
|
||||
# never named. Requiring an explicit scheme is what makes these URLs rather
|
||||
# than arguments; the `--` at the call site is the other half. Widening
|
||||
# valid_remote_value instead would be the wrong place: it also guards nginx
|
||||
# and the TTYD_PORT, neither of which wants a URL.
|
||||
#
|
||||
# Refusing rather than sanitizing, because remote.conf is operator-written:
|
||||
# a value that can't be used as intended means they got something wrong, and
|
||||
# this is the one place they say where their phone notifications go.
|
||||
require_url() {
|
||||
case "$2" in
|
||||
http://*|https://*) ;;
|
||||
*)
|
||||
echo "$1 in $(remote_conf_path) must be an http:// or https:// URL: $2" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
require_url NTFY_URL "$NTFY_URL"
|
||||
[ -z "$BASE_URL" ] || require_url BASE_URL "$BASE_URL"
|
||||
|
||||
STATE_DIR="$(remote_state_dir)"
|
||||
NOTIFY_DIR="$STATE_DIR/notify"
|
||||
DEBOUNCE_DIR="$STATE_DIR/debounce"
|
||||
DEBOUNCE_SECONDS="${DEJIMA_DEBOUNCE_SECONDS:-60}"
|
||||
case "$DEBOUNCE_SECONDS" in
|
||||
''|*[!0-9]*)
|
||||
echo "DEJIMA_DEBOUNCE_SECONDS must be a whole number of seconds: $DEBOUNCE_SECONDS" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# Same check bin/dejima makes before creating this, and for the same reason:
|
||||
# /tmp is the fallback parent when XDG_RUNTIME_DIR is unset, and
|
||||
# `mkdir -p -m 700` only sets the mode of the leaf it creates - a symlink
|
||||
# planted here would send every mkdir and every rm below it into someone
|
||||
# else's tree. Created rather than required, so the unit can start before the
|
||||
# first sandbox has ever run.
|
||||
mkdir -p -m 700 "$STATE_DIR" 2>/dev/null || true
|
||||
if [ -L "$STATE_DIR" ] || [ ! -d "$STATE_DIR" ] \
|
||||
|| [ "$(stat -c %u "$STATE_DIR")" != "$(id -u)" ]; then
|
||||
echo "Refusing to watch: $STATE_DIR is a symlink, or is not a directory owned" >&2
|
||||
echo "by the current user. Remove it, or set XDG_RUNTIME_DIR somewhere else." >&2
|
||||
exit 1
|
||||
fi
|
||||
# -m 700 above applies only when this process is the one that creates it, and
|
||||
# either side may get there first, so the mode is set unconditionally here -
|
||||
# same as bin/dejima:491.
|
||||
chmod 700 "$STATE_DIR"
|
||||
mkdir -p -m 700 "$NOTIFY_DIR" "$DEBOUNCE_DIR"
|
||||
|
||||
# From the registry (host-written), not from anything the session wrote. Still
|
||||
# reduced to a safe charset: the workspace path is operator-chosen, and a
|
||||
# newline in it would split a curl -H header.
|
||||
project_of() {
|
||||
local run_id="$1" file line raw="" name
|
||||
file="$STATE_DIR/sessions/$run_id"
|
||||
# An entry that can't be opened must degrade to an unnamed push, never to
|
||||
# a failure: one scan covers every run directory, so aborting here would
|
||||
# take every other session's notification down with it. `|| raw=""` is
|
||||
# what guarantees that - a failed redirection makes the compound command
|
||||
# return nonzero, and the || consumes it. Errexit is not the mechanism:
|
||||
# the sole caller is push(), which reaches this through a command
|
||||
# substitution, and bash does not inherit errexit into one absent shopt
|
||||
# inherit_errexit. The [ -r ] check is therefore about noise, not control
|
||||
# flow - and not much noise either, since push() runs only for a marker
|
||||
# that was consumed and not debounced: without it, a chmod'd entry prints
|
||||
# "Permission denied" once per push for that session.
|
||||
if [ -r "$file" ]; then
|
||||
while IFS= read -r line || [ -n "$line" ]; do
|
||||
case "$line" in
|
||||
workspace=*) raw="${line#*=}"; break ;;
|
||||
esac
|
||||
done < "$file" || raw=""
|
||||
fi
|
||||
name="$(basename -- "$raw" | tr -cd 'A-Za-z0-9._-' | cut -c1-64)"
|
||||
printf '%s' "${name:-unknown}"
|
||||
}
|
||||
|
||||
push() {
|
||||
local run_id="$1" event="$2" project title body
|
||||
local -a click=()
|
||||
project="$(project_of "$run_id")"
|
||||
case "$event" in
|
||||
waiting) title="dejima: $project needs input"; body="Session is waiting for you." ;;
|
||||
done) title="dejima: $project finished"; body="Session has stopped." ;;
|
||||
*) return 0 ;;
|
||||
esac
|
||||
# $run_id here came from the DIRECTORY NAME bin/dejima created and was
|
||||
# shape-validated by valid_run_id - never from marker content.
|
||||
if [ -n "$BASE_URL" ]; then
|
||||
click=(-H "Click: ${BASE_URL%/}/?arg=$run_id")
|
||||
fi
|
||||
# stdout is discarded (ntfy answers with a JSON receipt); stderr is not.
|
||||
# -sS exists precisely to keep transport errors on stderr, and under a
|
||||
# systemd user unit that is the journal, which is where they belong.
|
||||
curl -fsS --max-time 10 --retry 2 \
|
||||
-H "Title: $title" \
|
||||
-H "Priority: default" \
|
||||
"${click[@]}" \
|
||||
-d "$body" \
|
||||
-- "$NTFY_URL" >/dev/null \
|
||||
|| echo "dejima-notify-watch: push failed for $run_id/$event" >&2
|
||||
}
|
||||
|
||||
# Persisted rather than in-memory so --once (and a restarted unit) debounce
|
||||
# the same way a long-running process does.
|
||||
debounced() {
|
||||
local key="$1" now="$2" last
|
||||
last="$(cat "$DEBOUNCE_DIR/$key" 2>/dev/null || true)"
|
||||
# A non-numeric value in $(( )) is evaluated as a VARIABLE NAME, and any
|
||||
# command substitution in its subscript runs - "now[$(cmd)]" executes cmd.
|
||||
# set -u does not catch it, because `now` is bound right here. Digits
|
||||
# alone still aren't enough either: a leading zero is read as octal, so
|
||||
# 10# forces base 10 (and independently refuses to lex a name).
|
||||
#
|
||||
# The shape check leads because 10# only turns execution into an
|
||||
# arithmetic *error*, and a bash arithmetic error unwinds to the top
|
||||
# level: it discards the rest of the enclosing top-level command, so the
|
||||
# `exit 0` after a --once scan never runs and control falls through into
|
||||
# the watch loop below. Refusing the value keeps that unwind unreachable.
|
||||
#
|
||||
# Junk reads as "not sent recently" rather than as "sent": failing the
|
||||
# other way would let one unparseable file silence a session for good.
|
||||
case "$last" in
|
||||
''|*[!0-9]*) return 1 ;;
|
||||
esac
|
||||
[ "$(( now - 10#$last ))" -lt "$DEBOUNCE_SECONDS" ]
|
||||
}
|
||||
|
||||
declare -A WARNED=()
|
||||
|
||||
scan() {
|
||||
local dir run_id event marker now
|
||||
now="$(date +%s)"
|
||||
for dir in "$NOTIFY_DIR"/*; do
|
||||
[ -d "$dir" ] || continue
|
||||
run_id="$(basename -- "$dir")"
|
||||
# Only directories bin/dejima could have created. Anything else is
|
||||
# skipped without being looked into at all.
|
||||
valid_run_id "$run_id" || continue
|
||||
# The enum is iterated, not discovered: a name that isn't one of these
|
||||
# is never stat'd, never read, never removed.
|
||||
for event in waiting done; do
|
||||
marker="$dir/$event"
|
||||
# -e follows the link and is FALSE for a dangling symlink, which a
|
||||
# session can plant as easily as a file; -L catches those so they
|
||||
# are cleaned up too, without their target being created.
|
||||
[ -e "$marker" ] || [ -L "$marker" ] || continue
|
||||
# Consumed before it is acted on, and non-fatally: the marker
|
||||
# directory is bind-mounted read-write, so a session can make its
|
||||
# own marker undeletable (a non-empty directory named `waiting` is
|
||||
# enough), and dying here would silence every OTHER session too.
|
||||
# Skipped rather than pushed anyway, because a marker that
|
||||
# survives would otherwise re-push on every scan from now on.
|
||||
if ! rm -f "$marker"; then
|
||||
# Once per marker per process, not once per scan: the marker
|
||||
# survives, so every later scan finds it again, and a session
|
||||
# that leaves one in place could otherwise fill the journal
|
||||
# for as long as the watcher runs. Permanently, deliberately -
|
||||
# a marker that becomes deletable and then undeletable again
|
||||
# is the same stuck session, not news. The key cannot be
|
||||
# inflated to grow this map: scan() iterates only the top
|
||||
# level of $NOTIFY_DIR against a two-name enum, and a session
|
||||
# can create directories only inside its own run directory,
|
||||
# so it is bounded at twice the number of run directories.
|
||||
if [ -z "${WARNED[$run_id/$event]:-}" ]; then
|
||||
echo "dejima-notify-watch: warning: could not consume marker $run_id/$event" >&2
|
||||
WARNED[$run_id/$event]=1
|
||||
fi
|
||||
continue
|
||||
fi
|
||||
if debounced "$run_id-$event" "$now"; then
|
||||
continue
|
||||
fi
|
||||
printf '%s' "$now" > "$DEBOUNCE_DIR/$run_id-$event"
|
||||
push "$run_id" "$event"
|
||||
done
|
||||
done
|
||||
}
|
||||
|
||||
ONCE=""
|
||||
case "${1:-}" in
|
||||
--once) ONCE=1 ;;
|
||||
"") ;;
|
||||
*)
|
||||
echo "usage: ${0##*/} [--once]" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ -n "$ONCE" ]; then
|
||||
scan
|
||||
fi
|
||||
# A separate top-level command from the scan above, deliberately: a bash
|
||||
# arithmetic error unwinds to the top level and discards the REST of the
|
||||
# enclosing top-level command, so a `scan; exit 0` pair sharing one `if` can
|
||||
# lose its exit and run the watch loop instead of returning. Nothing should
|
||||
# reach such an error (debounced() refuses a non-numeric stamp before the
|
||||
# arithmetic), but a --once run that never exits is a bad way to find out.
|
||||
[ -z "$ONCE" ] || exit 0
|
||||
|
||||
# inotify-tools is optional: without it this degrades to a ~6s poll rather
|
||||
# than failing - the 1s floor below plus the 5s fallback sleep, which is the
|
||||
# interval a scan actually repeats at. The -t 30 timeout also closes the race
|
||||
# where a marker lands in a run directory created after the current watch was
|
||||
# established.
|
||||
while :; do
|
||||
scan
|
||||
# A floor under the loop, unconditional and ahead of the wait: inotify
|
||||
# wakes on the sandbox's OWN file creations, so without it a session that
|
||||
# creates files in a loop drives one scan per creation for as long as it
|
||||
# likes. One scan a second is far more than the notifications need.
|
||||
sleep 1
|
||||
if command -v inotifywait >/dev/null 2>&1; then
|
||||
# A failure here is reachable from inside a sandbox rather than rare:
|
||||
# -r takes one inotify watch per directory below $NOTIFY_DIR, and a
|
||||
# session owns its own marker directory, so it can mkdir enough of
|
||||
# them to exhaust this uid's max_user_watches - after which every
|
||||
# inotifywait returns immediately. Falling back to the poll interval
|
||||
# costs a little latency and bounds it; `|| true` would spin.
|
||||
inotifywait -qq -r -t 30 -e create -e moved_to "$NOTIFY_DIR" >/dev/null 2>&1 \
|
||||
|| sleep 5
|
||||
else
|
||||
sleep 5
|
||||
fi
|
||||
done
|
||||
@@ -242,3 +242,104 @@ generate_derived_dockerfile() {
|
||||
printf 'ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]\n'
|
||||
return 0
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Remote access (opt-in). See docs/remote-access.md.
|
||||
#
|
||||
# The mere existence of remote.conf is the switch: with it absent, every code
|
||||
# path this feature adds is skipped and bin/dejima builds exactly the
|
||||
# invocation it built before the feature existed. Same "unset means skipped
|
||||
# entirely" shape LOKI_URL already has.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
remote_conf_path() {
|
||||
printf '%s/remote.conf' "$(config_root)"
|
||||
}
|
||||
|
||||
remote_enabled() {
|
||||
[ -f "$(remote_conf_path)" ]
|
||||
}
|
||||
|
||||
valid_remote_key() {
|
||||
[[ "$1" =~ ^(NTFY_URL|BASE_URL|TTYD_PORT|TTYD_FONT_SIZE)$ ]]
|
||||
}
|
||||
|
||||
# Deliberately narrow. These values end up in a curl -H header, an nginx
|
||||
# comparison, and a URL handed to a phone - anything shell-active, any
|
||||
# whitespace, and anything that could split a header must not survive.
|
||||
valid_remote_value() {
|
||||
[[ "$1" =~ ^[A-Za-z0-9:/._~?=%@+-]*$ ]]
|
||||
}
|
||||
|
||||
# A run ID as bin/dejima generates it. Shared with dejima-attach and
|
||||
# dejima-notify-watch, both of which take one from an untrusted direction
|
||||
# (ttyd's --url-arg; a directory name under the marker root).
|
||||
valid_run_id() {
|
||||
[[ "$1" =~ ^[0-9a-f]{16}$ ]]
|
||||
}
|
||||
|
||||
remote_state_dir() {
|
||||
printf '%s/dejima' "${XDG_RUNTIME_DIR:-/tmp}"
|
||||
}
|
||||
|
||||
# remote_conf_get <key>
|
||||
# Prints the value, or nothing when the file doesn't set it. Validates the
|
||||
# WHOLE file on every read, not just the requested key: a remote.conf that
|
||||
# doesn't parse means the operator got something wrong, and this feature's
|
||||
# security properties depend on its values - so refuse rather than proceed,
|
||||
# the same way approved_entries does for the approval store.
|
||||
remote_conf_get() {
|
||||
local want="$1" conf line key value found=""
|
||||
valid_remote_key "$want" || { echo "unknown remote.conf key: $want" >&2; return 1; }
|
||||
conf="$(remote_conf_path)"
|
||||
[ -f "$conf" ] || return 0
|
||||
while IFS= read -r line || [ -n "$line" ]; do
|
||||
line="${line#"${line%%[![:space:]]*}"}"
|
||||
line="${line%"${line##*[![:space:]]}"}"
|
||||
case "$line" in
|
||||
''|'#'*) continue ;;
|
||||
*=*) ;;
|
||||
*) echo "malformed line in $conf: $line" >&2; return 1 ;;
|
||||
esac
|
||||
key="${line%%=*}"
|
||||
value="${line#*=}"
|
||||
if ! valid_remote_key "$key"; then
|
||||
echo "unknown key in $conf: $key" >&2
|
||||
return 1
|
||||
fi
|
||||
if ! valid_remote_value "$value"; then
|
||||
echo "invalid value for $key in $conf: $value" >&2
|
||||
return 1
|
||||
fi
|
||||
[ "$key" = "$want" ] && found="$value"
|
||||
done < "$conf"
|
||||
printf '%s' "$found"
|
||||
}
|
||||
|
||||
remote_ttyd_port() {
|
||||
local port
|
||||
port="$(remote_conf_get TTYD_PORT)" || return 1
|
||||
port="${port:-7681}"
|
||||
if [[ ! "$port" =~ ^[0-9]+$ ]] || [ "$port" -lt 1 ] || [ "$port" -gt 65535 ]; then
|
||||
echo "TTYD_PORT out of range in $(remote_conf_path): $port" >&2
|
||||
return 1
|
||||
fi
|
||||
printf '%s' "$port"
|
||||
}
|
||||
|
||||
# Empty when unset, which is the signal to pass no -t at all and leave ttyd on
|
||||
# its own default. Bounded rather than merely numeric: this reaches ttyd as
|
||||
# argv, and a value large enough to make one glyph wider than the viewport
|
||||
# leaves a terminal that can render nothing at all - reported as "the front
|
||||
# door is broken", with the cause three files away. 8 is already small on a
|
||||
# phone; 72 is already absurd on a desktop.
|
||||
remote_ttyd_font_size() {
|
||||
local size
|
||||
size="$(remote_conf_get TTYD_FONT_SIZE)" || return 1
|
||||
[ -n "$size" ] || return 0
|
||||
if [[ ! "$size" =~ ^[0-9]+$ ]] || [ "$size" -lt 8 ] || [ "$size" -gt 72 ]; then
|
||||
echo "TTYD_FONT_SIZE must be 8-72 in $(remote_conf_path): $size" >&2
|
||||
return 1
|
||||
fi
|
||||
printf '%s' "$size"
|
||||
}
|
||||
|
||||
Executable
+121
@@ -0,0 +1,121 @@
|
||||
#!/bin/bash
|
||||
# Launches ttyd with dejima-attach as its command, reading TTYD_PORT from
|
||||
# remote.conf so the port has exactly one source of truth (the systemd unit
|
||||
# deliberately does not repeat it). Started by systemd/dejima-ttyd.service.
|
||||
# See docs/remote-access.md.
|
||||
#
|
||||
# --interface 127.0.0.1 is a security requirement, not a preference.
|
||||
# docker/init-firewall.sh:187 accepts container OUTPUT to the default-route
|
||||
# address - the Docker bridge gateway, i.e. the host - and attaching tmux
|
||||
# gives a HOST shell (the prefix key opens a new window outside the
|
||||
# container). A listener on any address the container can route to would hand
|
||||
# a compromised session its own escape hatch. Loopback is not one of those
|
||||
# addresses: the container has no route to the host's 127.0.0.1. Put nginx in
|
||||
# front, bound to one specific LAN address - see
|
||||
# docs/nginx-dejima.conf.example.
|
||||
#
|
||||
# ttyd is given no credential of its own, so the reverse proxy is the only
|
||||
# thing authenticating a request, and the loopback bind is what makes that
|
||||
# safe rather than careless: nothing but a process on this host can open the
|
||||
# socket at all. --writable is what makes the terminal a terminal rather than
|
||||
# a viewer.
|
||||
#
|
||||
# --check-origin is the other half of that, and it is not optional. Without
|
||||
# it ttyd does no origin validation at all (it only calls check_host_origin
|
||||
# when the flag is set), and browsers do not apply the same-origin policy to
|
||||
# WebSockets: any page the operator's browser loads could open
|
||||
# wss://<front-door>/ws, have the browser attach its cached credential for
|
||||
# that origin, and be handed this host shell. Proxy authentication cannot
|
||||
# stop that - it is satisfied by the victim's own browser. ttyd compares the
|
||||
# Origin authority against the Host header, which is why the nginx example
|
||||
# passes Host through unchanged; the two go together.
|
||||
#
|
||||
# --url-arg lets the client pass ?arg=<run-id>, which is what the notification
|
||||
# deep link uses. It reaches dejima-attach as argv, never as a shell word, and
|
||||
# anything there that is not --list or the empty string (a bare `?arg=`, which
|
||||
# lands on the picker) has to match the run-id shape before it is used as a
|
||||
# path component or anything else.
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
|
||||
# shellcheck source=dejima-project-lib.sh
|
||||
source "$SCRIPT_DIR/dejima-project-lib.sh"
|
||||
|
||||
if ! remote_enabled; then
|
||||
echo "Remote access is not configured ($(remote_conf_path) does not exist)." >&2
|
||||
echo "See README \"Remote access\"." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Read BEFORE the ttyd check below, deliberately: an operator with both a
|
||||
# broken remote.conf and no ttyd is otherwise told only about the package,
|
||||
# installs it, comes back to the identical failure, and never learns that
|
||||
# their config is the other half of it. remote_ttyd_port validates the whole
|
||||
# file and names the line that broke it, and a failure here is fatal under
|
||||
# set -e on the assignment.
|
||||
port="$(remote_ttyd_port)"
|
||||
|
||||
# Optional, and applied to EVERY client - ttyd 1.7.7 takes client options only
|
||||
# at launch (the URL-query override that would allow a per-client size landed
|
||||
# after that release), so there is no way to give a phone one size and a
|
||||
# desktop another from here. Left unset, ttyd keeps its own default.
|
||||
#
|
||||
# It is the smaller half of the mobile fix, not the fix: ttyd's page carries no
|
||||
# viewport meta, so a phone lays it out at a virtual desktop width and scales
|
||||
# the result down - a bigger font scales down with everything else. The nginx
|
||||
# example injects the meta; see docs/nginx-dejima.conf.example.
|
||||
font_size="$(remote_ttyd_font_size)"
|
||||
|
||||
# Checked rather than left to exec, whose failure is bash's "ttyd: not found"
|
||||
# on a line number - true, and useless to an operator who needs to know that
|
||||
# the missing piece is a package.
|
||||
if ! command -v ttyd >/dev/null 2>&1; then
|
||||
echo "ttyd is not installed - the web front door needs it, 1.7.4 or newer" >&2
|
||||
echo "(-W/--writable arrived in 1.7.4; older builds are writable by default" >&2
|
||||
echo "and reject the flag, so every start would be a usage error)." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# The distro's own ttyd package ships an ENABLED system service that runs
|
||||
# `login` and, having no --interface of its own, binds every interface - the
|
||||
# bridge address a sandbox can route to included. Installing ttyd to set this
|
||||
# feature up is therefore enough to open a host login prompt to the sandbox,
|
||||
# on this very port, before any of this runs.
|
||||
#
|
||||
# It also takes the port. Binding the loopback address below while another
|
||||
# process already holds that port on every interface fails with EADDRINUSE, so
|
||||
# this unit dies at every start while the reverse proxy in front of it happily
|
||||
# proxies to whatever DID get the port. The operator sees the password prompt
|
||||
# they configured and then a host login shell, and nothing in that path looks
|
||||
# broken.
|
||||
#
|
||||
# So refuse, and say which. Left to exec, the whole failure is ttyd's one-line
|
||||
# bind error in a unit nobody is watching.
|
||||
if command -v ss >/dev/null 2>&1; then
|
||||
listeners="$(ss -ltnH "sport = :$port" 2>/dev/null || true)"
|
||||
if [ -n "$listeners" ]; then
|
||||
echo "Something is already listening on port $port:" >&2
|
||||
echo "$listeners" >&2
|
||||
echo >&2
|
||||
echo "The web front door cannot start, and until that listener is gone the" >&2
|
||||
echo "reverse proxy in front of this port serves IT, not dejima." >&2
|
||||
echo >&2
|
||||
echo "The usual cause is the distro's own ttyd service, which is enabled on" >&2
|
||||
echo "install and runs 'login' on every interface - reachable from inside a" >&2
|
||||
echo "sandbox. Check and disable it:" >&2
|
||||
echo >&2
|
||||
echo " systemctl status ttyd.service" >&2
|
||||
echo " sudo systemctl disable --now ttyd.service" >&2
|
||||
echo " sudo systemctl mask ttyd.service" >&2
|
||||
echo >&2
|
||||
echo "See README \"Remote access\"." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
font_opt=()
|
||||
[ -z "$font_size" ] || font_opt=(-t "fontSize=$font_size")
|
||||
|
||||
exec ttyd --interface 127.0.0.1 --port "$port" --check-origin --url-arg --writable \
|
||||
${font_opt[@]+"${font_opt[@]}"} \
|
||||
"$SCRIPT_DIR/dejima-attach"
|
||||
Executable
+255
@@ -0,0 +1,255 @@
|
||||
#!/bin/bash
|
||||
# Read and write Gitea issues from a shell - the issue-side counterpart to
|
||||
# gitea-pr, and deliberately its twin rather than its sibling: both are
|
||||
# copied into the sandbox image as single self-contained files, and gitea-pr
|
||||
# also lives in a dotfiles checkout, so a shared library would be a third
|
||||
# thing to keep in step across three places. The duplicated config/token/curl
|
||||
# preamble is pinned in both files by test/static.bats instead.
|
||||
set -euo pipefail
|
||||
|
||||
GITEA_TOKEN_FILE="${GITEA_TOKEN_FILE:-$HOME/.config/gitea/token}"
|
||||
|
||||
function usage() {
|
||||
echo "Usage: gitea-issue list [--state open|closed|all] [--limit N]"
|
||||
echo " gitea-issue view NUMBER"
|
||||
echo " gitea-issue create --title TITLE [--body BODY | --body-file FILE]"
|
||||
echo " gitea-issue comment NUMBER (--body BODY | --body-file FILE)"
|
||||
echo " gitea-issue close NUMBER"
|
||||
echo " gitea-issue reopen NUMBER"
|
||||
echo ""
|
||||
echo "--body-file reads the body from FILE, or from stdin when FILE is '-'."
|
||||
echo "Prefer it over --body for anything with quotes, backticks or newlines."
|
||||
echo ""
|
||||
echo "Requires GITEA_SSH_HOST and GITEA_API set in the environment (e.g. in" \
|
||||
"your shell profile), and a token at $GITEA_TOKEN_FILE (or GITEA_TOKEN" \
|
||||
"set for this invocation only)."
|
||||
}
|
||||
|
||||
function require_config() {
|
||||
if [ -z "${GITEA_SSH_HOST:-}" ]; then
|
||||
echo "GITEA_SSH_HOST is not set. Add 'export GITEA_SSH_HOST=<gitea ssh hostname>'" \
|
||||
"(e.g. gitea.example.com) to ~/.profile.local" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "${GITEA_API:-}" ]; then
|
||||
echo "GITEA_API is not set. Add 'export GITEA_API=https://<gitea host>/api/v1'" \
|
||||
"(e.g. https://gitea.example.com/api/v1) to ~/.profile.local" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
function require_token() {
|
||||
if [ -z "${GITEA_TOKEN:-}" ] && [ -f "$GITEA_TOKEN_FILE" ]; then
|
||||
GITEA_TOKEN=$(<"$GITEA_TOKEN_FILE")
|
||||
fi
|
||||
if [ -z "${GITEA_TOKEN:-}" ]; then
|
||||
echo "No Gitea token found. Generate one in the Gitea web UI" \
|
||||
"(Settings > Applications, scopes write:repository + write:issue)" \
|
||||
"and save it to $GITEA_TOKEN_FILE (chmod 600), or set GITEA_TOKEN" \
|
||||
"in the environment for this invocation only." >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Feeds the auth header to curl as a config on stdin rather than as an
|
||||
# argument. Command lines are world-readable through /proc, so `-H
|
||||
# "Authorization: token $GITEA_TOKEN"` exposes the token to every other
|
||||
# process on the host for as long as the request runs - and to anything that
|
||||
# scrapes `ps` output, including the agents this repo exists to sandbox.
|
||||
# printf is a bash builtin, so the token never lands in another argv.
|
||||
function gitea_api() {
|
||||
printf 'header = "Authorization: token %s"\n' "$GITEA_TOKEN" |
|
||||
curl -sS -w '\n%{http_code}' -K - "$@"
|
||||
}
|
||||
|
||||
# Splits gitea_api's combined output into the globals `json` and `http_code`
|
||||
# and fails with the body on any non-2xx. Every call site did this by hand in
|
||||
# gitea-pr; there are six of them here, and six hand-rolled copies is where
|
||||
# one of them quietly stops checking.
|
||||
function api_or_die() {
|
||||
local what="$1"; shift
|
||||
local response
|
||||
response=$(gitea_api "$@")
|
||||
http_code=$(echo "$response" | tail -1)
|
||||
json=$(echo "$response" | sed '$d')
|
||||
if [ "$http_code" -lt 200 ] || [ "$http_code" -ge 300 ]; then
|
||||
echo "Failed to $what (HTTP $http_code):" >&2
|
||||
echo "$json" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Reads a body from a file, or from stdin for '-'. The point is to keep prose
|
||||
# out of argv: a body composed on a command line has to survive shell quoting,
|
||||
# and the things that break it - an apostrophe, a backtick, a $ - are exactly
|
||||
# what English and code fences are made of. A file (or a heredoc into '-') has
|
||||
# no such layer.
|
||||
function read_body_file() {
|
||||
local file="$1"
|
||||
if [ "$file" = "-" ]; then
|
||||
cat
|
||||
return
|
||||
fi
|
||||
if [ ! -f "$file" ]; then
|
||||
echo "No such body file: $file" >&2
|
||||
exit 1
|
||||
fi
|
||||
cat "$file"
|
||||
}
|
||||
|
||||
function gitea_owner_repo() {
|
||||
local remote_url host owner_repo
|
||||
remote_url=$(git remote get-url origin 2>/dev/null) || {
|
||||
echo "No 'origin' remote found in this repository" >&2
|
||||
exit 1
|
||||
}
|
||||
host=$(echo "$remote_url" | sed -E 's#^[^@]+@([^:]+):.*#\1#')
|
||||
if [ "$host" != "$GITEA_SSH_HOST" ]; then
|
||||
echo "origin remote ($remote_url) is not on $GITEA_SSH_HOST; gitea-issue only supports this host" >&2
|
||||
exit 1
|
||||
fi
|
||||
owner_repo=$(echo "$remote_url" | sed -E 's#^[^@]+@[^:]+:##; s#\.git$##')
|
||||
echo "$owner_repo"
|
||||
}
|
||||
|
||||
function require_number() {
|
||||
if ! [[ "${1:-}" =~ ^[0-9]+$ ]]; then
|
||||
echo "Expected an issue number, got '${1:-}'" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Sets `body` from --body/--body-file, rejecting both. Shared by create and
|
||||
# comment so the two cannot drift into disagreeing about which one wins.
|
||||
function parse_body_args() {
|
||||
body=""
|
||||
local body_file=""
|
||||
parsed_title=""
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--title) parsed_title="$2"; shift 2 ;;
|
||||
--body) body="$2"; shift 2 ;;
|
||||
--body-file) body_file="$2"; shift 2 ;;
|
||||
*) echo "Unknown option: $1" >&2; usage; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
# Rejected rather than ranked: silently preferring one would make a caller
|
||||
# that passed both ship a body it didn't mean to.
|
||||
if [ -n "$body" ] && [ -n "$body_file" ]; then
|
||||
echo "Pass --body or --body-file, not both" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -n "$body_file" ]; then
|
||||
body="$(read_body_file "$body_file")"
|
||||
fi
|
||||
}
|
||||
|
||||
if [ "$#" -lt 1 ]; then
|
||||
usage
|
||||
exit 1
|
||||
fi
|
||||
|
||||
subcommand="$1"
|
||||
shift
|
||||
|
||||
case "$subcommand" in
|
||||
list|view|create|comment|close|reopen)
|
||||
require_config
|
||||
require_token
|
||||
owner_repo=$(gitea_owner_repo)
|
||||
owner="${owner_repo%%/*}"
|
||||
repo="${owner_repo##*/}"
|
||||
;;
|
||||
*)
|
||||
usage
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
issues_api="${GITEA_API}/repos/${owner}/${repo}/issues"
|
||||
|
||||
case "$subcommand" in
|
||||
list)
|
||||
state="open"
|
||||
limit="30"
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--state) state="$2"; shift 2 ;;
|
||||
--limit) limit="$2"; shift 2 ;;
|
||||
*) echo "Unknown option: $1" >&2; usage; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
case "$state" in
|
||||
open|closed|all) ;;
|
||||
*) echo "--state must be open, closed or all" >&2; exit 1 ;;
|
||||
esac
|
||||
if ! [[ "$limit" =~ ^[0-9]+$ ]]; then
|
||||
echo "--limit must be a number" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# type=issues is load-bearing: Gitea's issues endpoint returns pull
|
||||
# requests too, since a PR *is* an issue with a branch attached.
|
||||
# Without it every open PR shows up here as an issue, which reads as
|
||||
# a work list that is quietly twice as long as the real one.
|
||||
api_or_die "list issues" \
|
||||
"${issues_api}?state=${state}&type=issues&limit=${limit}"
|
||||
echo "$json" | jq -r '.[] | "#\(.number)\t[\(.state)]\t\(.title)\t(\(.comments) comments)"'
|
||||
;;
|
||||
view)
|
||||
require_number "${1:-}"
|
||||
number="$1"
|
||||
|
||||
api_or_die "fetch issue #${number}" "${issues_api}/${number}"
|
||||
echo "$json" | jq -r '"#\(.number) [\(.state)] \(.title)\n[\(.user.login) @ \(.created_at)]\n\(.body // "(no description)")\n"'
|
||||
|
||||
echo "=== Comments ==="
|
||||
api_or_die "fetch comments for issue #${number}" "${issues_api}/${number}/comments"
|
||||
echo "$json" | jq -r '.[] | "[\(.user.login) @ \(.created_at)]\n\(.body)\n"'
|
||||
;;
|
||||
create)
|
||||
parse_body_args "$@"
|
||||
if [ -z "$parsed_title" ]; then
|
||||
echo "gitea-issue create requires --title" >&2
|
||||
exit 1
|
||||
fi
|
||||
body="**Filed by LLM agent**
|
||||
|
||||
${body}"
|
||||
|
||||
api_or_die "create issue" -X POST "$issues_api" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$(jq -n --arg title "$parsed_title" --arg body "$body" \
|
||||
'{title: $title, body: $body}')"
|
||||
echo "$json" | jq -r '.html_url'
|
||||
;;
|
||||
comment)
|
||||
require_number "${1:-}"
|
||||
number="$1"
|
||||
shift
|
||||
parse_body_args "$@"
|
||||
if [ -z "$body" ]; then
|
||||
echo "gitea-issue comment requires --body or --body-file" >&2
|
||||
exit 1
|
||||
fi
|
||||
body="**Comment by LLM agent**
|
||||
|
||||
${body}"
|
||||
|
||||
api_or_die "comment on issue #${number}" -X POST "${issues_api}/${number}/comments" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$(jq -n --arg body "$body" '{body: $body}')"
|
||||
echo "$json" | jq -r '.html_url'
|
||||
;;
|
||||
close|reopen)
|
||||
require_number "${1:-}"
|
||||
number="$1"
|
||||
new_state="closed"
|
||||
[ "$subcommand" = "reopen" ] && new_state="open"
|
||||
|
||||
api_or_die "${subcommand} issue #${number}" -X PATCH "${issues_api}/${number}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$(jq -n --arg state "$new_state" '{state: $state}')"
|
||||
echo "$json" | jq -r '"#\(.number) is now \(.state): \(.html_url)"'
|
||||
;;
|
||||
esac
|
||||
+35
-3
@@ -4,10 +4,13 @@ set -euo pipefail
|
||||
GITEA_TOKEN_FILE="${GITEA_TOKEN_FILE:-$HOME/.config/gitea/token}"
|
||||
|
||||
function usage() {
|
||||
echo "Usage: gitea-pr create [--title TITLE] [--body BODY] [--base BRANCH]"
|
||||
echo "Usage: gitea-pr create [--title TITLE] [--body BODY | --body-file FILE] [--base BRANCH]"
|
||||
echo " gitea-pr comments PR_NUMBER"
|
||||
echo " gitea-pr merge PR_NUMBER"
|
||||
echo ""
|
||||
echo "--body-file reads the body from FILE, or from stdin when FILE is '-'."
|
||||
echo "Prefer it over --body for anything with quotes, backticks or newlines."
|
||||
echo ""
|
||||
echo "Requires GITEA_SSH_HOST and GITEA_API set in the environment (e.g. in" \
|
||||
"your shell profile), and a token at $GITEA_TOKEN_FILE (or GITEA_TOKEN" \
|
||||
"set for this invocation only)."
|
||||
@@ -16,12 +19,12 @@ function usage() {
|
||||
function require_config() {
|
||||
if [ -z "${GITEA_SSH_HOST:-}" ]; then
|
||||
echo "GITEA_SSH_HOST is not set. Add 'export GITEA_SSH_HOST=<gitea ssh hostname>'" \
|
||||
"(e.g. gitbox.bradina.localdomain) to ~/.profile.local" >&2
|
||||
"(e.g. gitea.example.com) to ~/.profile.local" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "${GITEA_API:-}" ]; then
|
||||
echo "GITEA_API is not set. Add 'export GITEA_API=https://<gitea host>/api/v1'" \
|
||||
"(e.g. https://gitbox.branderson.io/api/v1) to ~/.profile.local" >&2
|
||||
"(e.g. https://gitea.example.com/api/v1) to ~/.profile.local" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
@@ -50,6 +53,24 @@ function gitea_api() {
|
||||
curl -sS -w '\n%{http_code}' -K - "$@"
|
||||
}
|
||||
|
||||
# Reads a body from a file, or from stdin for '-'. The point is to keep prose
|
||||
# out of argv: a body composed on a command line has to survive shell quoting,
|
||||
# and the things that break it - an apostrophe, a backtick, a $ - are exactly
|
||||
# what English and code fences are made of. A file (or a heredoc into '-') has
|
||||
# no such layer.
|
||||
function read_body_file() {
|
||||
local file="$1"
|
||||
if [ "$file" = "-" ]; then
|
||||
cat
|
||||
return
|
||||
fi
|
||||
if [ ! -f "$file" ]; then
|
||||
echo "No such body file: $file" >&2
|
||||
exit 1
|
||||
fi
|
||||
cat "$file"
|
||||
}
|
||||
|
||||
function gitea_owner_repo() {
|
||||
local remote_url host owner_repo
|
||||
remote_url=$(git remote get-url origin 2>/dev/null) || {
|
||||
@@ -91,15 +112,26 @@ case "$subcommand" in
|
||||
create)
|
||||
title=""
|
||||
body=""
|
||||
body_file=""
|
||||
base=""
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--title) title="$2"; shift 2 ;;
|
||||
--body) body="$2"; shift 2 ;;
|
||||
--body-file) body_file="$2"; shift 2 ;;
|
||||
--base) base="$2"; shift 2 ;;
|
||||
*) echo "Unknown option: $1" >&2; usage; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
# Rejected rather than ranked: silently preferring one would make a
|
||||
# caller that passed both ship a body it didn't mean to.
|
||||
if [ -n "$body" ] && [ -n "$body_file" ]; then
|
||||
echo "Pass --body or --body-file, not both" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -n "$body_file" ]; then
|
||||
body="$(read_body_file "$body_file")"
|
||||
fi
|
||||
body="**Summary generated by LLM agent**
|
||||
|
||||
${body}"
|
||||
|
||||
+114
-64
@@ -11,8 +11,9 @@ ENV TZ="$TZ"
|
||||
ARG CLAUDE_CODE_VERSION=latest
|
||||
|
||||
# Install basic development tools, iptables/ipset (for the egress firewall),
|
||||
# openssh-client (for git-over-ssh and agent forwarding), and the toolchain
|
||||
# nvim's plugin config (config/nvim, telescope/treesitter/mason) expects.
|
||||
# openssh-client (for git-over-ssh and agent forwarding), and the toolchain a
|
||||
# typical nvim plugin set (telescope/treesitter/mason) expects, since
|
||||
# SANDBOX_NVIM_CONFIG can point a session's editor at one.
|
||||
#
|
||||
# Deliberately NOT installing bubblewrap/socat: those exist to let Claude
|
||||
# Code's own internal per-command sandboxing (~/.claude/settings.json's
|
||||
@@ -39,6 +40,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
dnsutils \
|
||||
aggregate \
|
||||
jq \
|
||||
openssl \
|
||||
dnsmasq \
|
||||
ulogd2 \
|
||||
ulogd2-json \
|
||||
@@ -59,45 +61,12 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
&& ln -s "$(command -v fdfind)" /usr/local/bin/fd
|
||||
|
||||
# cowsay/cowthink/fortune install to /usr/games on Debian, which isn't on
|
||||
# PATH by default - config/nvim's start-screen header (plugins/ui.lua) shells
|
||||
# out to them by bare name. Set via ENV (not a profile.d script) so it's
|
||||
# PATH by default - nvim start-screen plugins commonly shell out to them by
|
||||
# bare name for a dashboard header. Set via ENV (not a profile.d script) so it's
|
||||
# picked up by every process unconditionally, including nvim's non-login
|
||||
# subprocess spawns, which wouldn't source /etc/profile.d.
|
||||
ENV PATH=/usr/games:$PATH
|
||||
|
||||
# Replace the base image's built-in `node` user (uid/gid 1000,
|
||||
# $HOME=/home/node) with one matching the host user who runs
|
||||
# bin/dejima - so absolute paths under $HOME (dotfiles, and
|
||||
# ~/.claude/plugins/installed_plugins.json's own installPath bookkeeping,
|
||||
# which Claude Code resolves as a literal host-absolute string rather than
|
||||
# relative to whatever $HOME happens to be) resolve identically inside and
|
||||
# outside the container, instead of needing a one-off dual-mount for every
|
||||
# individual thing that assumes the real host path. Defaults below
|
||||
# reproduce today's node/1000/1000//home/node exactly, so a build that
|
||||
# never overrides them (bin/dejima always does; the test suite
|
||||
# never does) exercises this same rename path rather than a separate,
|
||||
# untested one.
|
||||
ARG SANDBOX_USER=node
|
||||
ARG SANDBOX_UID=1000
|
||||
ARG SANDBOX_GID=1000
|
||||
ARG SANDBOX_HOME=/home/node
|
||||
RUN userdel -r node 2>/dev/null; \
|
||||
groupdel node 2>/dev/null; \
|
||||
groupadd -g "$SANDBOX_GID" "$SANDBOX_USER" && \
|
||||
useradd -u "$SANDBOX_UID" -g "$SANDBOX_GID" -m -d "$SANDBOX_HOME" -s /bin/bash "$SANDBOX_USER"
|
||||
ENV SANDBOX_USER=$SANDBOX_USER
|
||||
ENV SANDBOX_HOME=$SANDBOX_HOME
|
||||
|
||||
# Ensure the sandbox user has access to /usr/local/share
|
||||
RUN mkdir -p /usr/local/share/npm-global && \
|
||||
chown -R $SANDBOX_USER:$SANDBOX_USER /usr/local/share
|
||||
|
||||
# Persist bash history. The env vars that actually wire history up to this
|
||||
# file are set below via zsh-in-docker's -a flag.
|
||||
RUN mkdir /commandhistory && \
|
||||
touch /commandhistory/.bash_history && \
|
||||
chown -R $SANDBOX_USER /commandhistory
|
||||
|
||||
# Set `DEVCONTAINER` environment variable to help with orientation
|
||||
ENV DEVCONTAINER=true
|
||||
|
||||
@@ -115,25 +84,23 @@ ENV DEJIMA=1
|
||||
# this user, so it doesn't need to own anything itself.
|
||||
RUN useradd -r -s /usr/sbin/nologin ulogd
|
||||
|
||||
# Create the ssh directory and set permissions. The project directory itself
|
||||
# is bind-mounted at runtime at its real host path (see docker-compose.yml),
|
||||
# not a fixed /workspace, so it can share ~/.claude project/session history
|
||||
# with the host. ~/.claude is bind-mounted too, not baked in here.
|
||||
RUN mkdir -p $SANDBOX_HOME/.ssh && \
|
||||
chown -R $SANDBOX_USER:$SANDBOX_USER $SANDBOX_HOME/.ssh && \
|
||||
chmod 700 $SANDBOX_HOME/.ssh
|
||||
|
||||
# Pre-create the nvim data/state/cache dirs owned by the sandbox user,
|
||||
# since they're mounted as named volumes in docker-compose.yml: Docker
|
||||
# seeds a fresh named volume from whatever already exists at the mount
|
||||
# path in the image, ownership included, so this is what keeps those
|
||||
# volumes writable by that user instead of defaulting to root.
|
||||
RUN mkdir -p $SANDBOX_HOME/.local/share/nvim $SANDBOX_HOME/.local/state/nvim $SANDBOX_HOME/.cache/nvim && \
|
||||
chown -R $SANDBOX_USER:$SANDBOX_USER $SANDBOX_HOME/.local $SANDBOX_HOME/.cache
|
||||
|
||||
# Every fetch from here down retries, and none is single-shot. What matters
|
||||
# isn't the odds of a particular download failing, it's the odds of any of
|
||||
# them failing - and without retries a single blip anywhere throws away the
|
||||
# whole build.
|
||||
#
|
||||
# These steps deliberately sit *above* the SANDBOX_* ARGs, not below. Nothing
|
||||
# between here and `USER` needs the sandbox user to exist yet - they all
|
||||
# install system-wide as root - so keeping them ahead of the ARGs means a
|
||||
# build that overrides one (test/runtime.bats does, and so does any host
|
||||
# whose uid isn't 1000) still reuses every layer down to this point instead
|
||||
# of re-running all of it. That single ordering was most of the cost of the
|
||||
# override rebuild: it was 1169s of a 1379s test run, for a difference that
|
||||
# amounts to one useradd.
|
||||
ARG GIT_DELTA_VERSION=0.18.2
|
||||
RUN ARCH=$(dpkg --print-architecture) && \
|
||||
wget "https://github.com/dandavison/delta/releases/download/${GIT_DELTA_VERSION}/git-delta_${GIT_DELTA_VERSION}_${ARCH}.deb" && \
|
||||
wget --tries=3 --timeout=30 --waitretry=5 --retry-connrefused \
|
||||
"https://github.com/dandavison/delta/releases/download/${GIT_DELTA_VERSION}/git-delta_${GIT_DELTA_VERSION}_${ARCH}.deb" && \
|
||||
dpkg -i "git-delta_${GIT_DELTA_VERSION}_${ARCH}.deb" && \
|
||||
rm "git-delta_${GIT_DELTA_VERSION}_${ARCH}.deb"
|
||||
|
||||
@@ -143,8 +110,10 @@ RUN ARCH=$(dpkg --print-architecture) && \
|
||||
# published SHA256SUMS since it runs as root.
|
||||
ARG GOSU_VERSION=1.19
|
||||
RUN ARCH=$(dpkg --print-architecture) && \
|
||||
wget -O /usr/local/bin/gosu "https://github.com/tianon/gosu/releases/download/${GOSU_VERSION}/gosu-${ARCH}" && \
|
||||
wget -O /tmp/gosu.sha256sums "https://github.com/tianon/gosu/releases/download/${GOSU_VERSION}/SHA256SUMS" && \
|
||||
wget --tries=3 --timeout=30 --waitretry=5 --retry-connrefused \
|
||||
-O /usr/local/bin/gosu "https://github.com/tianon/gosu/releases/download/${GOSU_VERSION}/gosu-${ARCH}" && \
|
||||
wget --tries=3 --timeout=30 --waitretry=5 --retry-connrefused \
|
||||
-O /tmp/gosu.sha256sums "https://github.com/tianon/gosu/releases/download/${GOSU_VERSION}/SHA256SUMS" && \
|
||||
grep -E "gosu-${ARCH}\$" /tmp/gosu.sha256sums | sed "s#gosu-${ARCH}#/usr/local/bin/gosu#" | sha256sum -c - && \
|
||||
rm /tmp/gosu.sha256sums && \
|
||||
chmod +x /usr/local/bin/gosu
|
||||
@@ -153,9 +122,11 @@ RUN ARCH=$(dpkg --print-architecture) && \
|
||||
# old for this config's plugins (blink.cmp etc. need a recent Neovim), so
|
||||
# fetch the current release from GitHub directly rather than pinning a
|
||||
# version that may go stale.
|
||||
RUN asset_url=$(curl -fsSL https://api.github.com/repos/neovim/neovim/releases/latest \
|
||||
RUN asset_url=$(curl -fsSL --retry 3 --retry-delay 2 --retry-all-errors --max-time 60 \
|
||||
https://api.github.com/repos/neovim/neovim/releases/latest \
|
||||
| jq -r '[.assets[] | select(.name | test("linux.*x86_64.*\\.tar\\.gz$"))][0].browser_download_url') && \
|
||||
wget -O nvim.tar.gz "$asset_url" && \
|
||||
wget --tries=3 --timeout=30 --waitretry=5 --retry-connrefused \
|
||||
-O nvim.tar.gz "$asset_url" && \
|
||||
tar -C /usr/local --strip-components=1 -xzf nvim.tar.gz && \
|
||||
rm nvim.tar.gz
|
||||
|
||||
@@ -165,12 +136,14 @@ RUN asset_url=$(curl -fsSL https://api.github.com/repos/neovim/neovim/releases/l
|
||||
# need proxy.golang.org and sum.golang.org reachable at runtime - see
|
||||
# fixed-domains.sh.
|
||||
RUN ARCH=$(dpkg --print-architecture) && \
|
||||
release_json=$(curl -fsSL 'https://go.dev/dl/?mode=json') && \
|
||||
release_json=$(curl -fsSL --retry 3 --retry-delay 2 --retry-all-errors --max-time 60 \
|
||||
'https://go.dev/dl/?mode=json') && \
|
||||
filename=$(echo "$release_json" | jq -r --arg arch "$ARCH" \
|
||||
'[.[] | select(.stable == true)][0].files[] | select(.os == "linux" and .arch == $arch and .kind == "archive") | .filename') && \
|
||||
sha256=$(echo "$release_json" | jq -r --arg arch "$ARCH" \
|
||||
'[.[] | select(.stable == true)][0].files[] | select(.os == "linux" and .arch == $arch and .kind == "archive") | .sha256') && \
|
||||
wget -O go.tar.gz "https://go.dev/dl/${filename}" && \
|
||||
wget --tries=3 --timeout=30 --waitretry=5 --retry-connrefused \
|
||||
-O go.tar.gz "https://go.dev/dl/${filename}" && \
|
||||
echo "${sha256} go.tar.gz" | sha256sum -c - && \
|
||||
tar -C /usr/local -xzf go.tar.gz && \
|
||||
rm go.tar.gz
|
||||
@@ -183,6 +156,70 @@ ENV PATH=$PATH:/usr/local/go/bin
|
||||
ARG PLAYWRIGHT_VERSION=1.61.1
|
||||
RUN npx -y playwright@${PLAYWRIGHT_VERSION} install-deps chromium
|
||||
|
||||
# Replace the base image's built-in `node` user (uid/gid 1000,
|
||||
# $HOME=/home/node) with one matching the host user who runs
|
||||
# bin/dejima - so absolute paths under $HOME (dotfiles, and
|
||||
# ~/.claude/plugins/installed_plugins.json's own installPath bookkeeping,
|
||||
# which Claude Code resolves as a literal host-absolute string rather than
|
||||
# relative to whatever $HOME happens to be) resolve identically inside and
|
||||
# outside the container, instead of needing a one-off dual-mount for every
|
||||
# individual thing that assumes the real host path. Defaults below
|
||||
# reproduce today's node/1000/1000//home/node exactly, so a build that
|
||||
# never overrides them (bin/dejima always does; the test suite
|
||||
# never does) exercises this same rename path rather than a separate,
|
||||
# untested one.
|
||||
#
|
||||
# As late as it can go: everything an override invalidates is below this
|
||||
# line, so the cheaper this block is to reach, the cheaper an override is.
|
||||
ARG SANDBOX_USER=node
|
||||
ARG SANDBOX_UID=1000
|
||||
ARG SANDBOX_GID=1000
|
||||
ARG SANDBOX_HOME=/home/node
|
||||
RUN userdel -r node 2>/dev/null; \
|
||||
groupdel node 2>/dev/null; \
|
||||
groupadd -g "$SANDBOX_GID" "$SANDBOX_USER" && \
|
||||
useradd -u "$SANDBOX_UID" -g "$SANDBOX_GID" -m -d "$SANDBOX_HOME" -s /bin/bash "$SANDBOX_USER"
|
||||
ENV SANDBOX_USER=$SANDBOX_USER
|
||||
ENV SANDBOX_HOME=$SANDBOX_HOME
|
||||
|
||||
# Ensure the sandbox user has access to /usr/local/share. The recursion is
|
||||
# scoped to npm-global rather than applied to the whole directory, which is
|
||||
# what this used to do: Neovim now unpacks into /usr/local/share/nvim above
|
||||
# this line rather than below it, and recursing from the parent would hand
|
||||
# the sandbox user Neovim's runtime files. /usr/local/share itself is still
|
||||
# chowned, so anything that expected to create entries in it still can.
|
||||
RUN mkdir -p /usr/local/share/npm-global && \
|
||||
chown $SANDBOX_USER:$SANDBOX_USER /usr/local/share && \
|
||||
chown -R $SANDBOX_USER:$SANDBOX_USER /usr/local/share/npm-global
|
||||
|
||||
# Persist bash history. The env vars that actually wire history up to this
|
||||
# file are set below via zsh-in-docker's -a flag.
|
||||
RUN mkdir /commandhistory && \
|
||||
touch /commandhistory/.bash_history && \
|
||||
chown -R $SANDBOX_USER /commandhistory
|
||||
|
||||
# Create the ssh directory and set permissions. The project directory itself
|
||||
# is bind-mounted at runtime at its real host path (see docker-compose.yml),
|
||||
# not a fixed /workspace, so it can share ~/.claude project/session history
|
||||
# with the host.
|
||||
#
|
||||
# ~/.claude itself is baked in here rather than bind-mounted (see the mount
|
||||
# inventory in docker-compose.yml): only enumerated paths *inside* it come
|
||||
# from the host, and Docker creates the parents of a mount point root-owned
|
||||
# if they don't already exist - which would leave the sandbox user unable to
|
||||
# write the caches and history it keeps directly in ~/.claude.
|
||||
RUN mkdir -p $SANDBOX_HOME/.ssh $SANDBOX_HOME/.claude && \
|
||||
chown -R $SANDBOX_USER:$SANDBOX_USER $SANDBOX_HOME/.ssh $SANDBOX_HOME/.claude && \
|
||||
chmod 700 $SANDBOX_HOME/.ssh $SANDBOX_HOME/.claude
|
||||
|
||||
# Pre-create the nvim data/state/cache dirs owned by the sandbox user,
|
||||
# since they're mounted as named volumes in docker-compose.yml: Docker
|
||||
# seeds a fresh named volume from whatever already exists at the mount
|
||||
# path in the image, ownership included, so this is what keeps those
|
||||
# volumes writable by that user instead of defaulting to root.
|
||||
RUN mkdir -p $SANDBOX_HOME/.local/share/nvim $SANDBOX_HOME/.local/state/nvim $SANDBOX_HOME/.cache/nvim && \
|
||||
chown -R $SANDBOX_USER:$SANDBOX_USER $SANDBOX_HOME/.local $SANDBOX_HOME/.cache
|
||||
|
||||
# Set up non-root user
|
||||
USER $SANDBOX_USER
|
||||
|
||||
@@ -205,14 +242,25 @@ ENV VISUAL=nano
|
||||
# Default powerline10k theme, git/fzf oh-my-zsh plugins, fzf's own key
|
||||
# bindings, and wiring HISTFILE to the persisted /commandhistory volume -
|
||||
# see https://github.com/deluan/zsh-in-docker for the installer's own flags.
|
||||
#
|
||||
# Downloaded to a file and then run, rather than upstream's documented
|
||||
# `sh -c "$(wget -O- ...)"`. In that form a failed download substitutes to an
|
||||
# empty string, `sh -c ""` exits 0, and the build goes green having installed
|
||||
# no zsh config at all - the failure only surfaces much later, as a shell that
|
||||
# is mysteriously unconfigured. Two steps make the download's exit status the
|
||||
# build's exit status.
|
||||
ARG ZSH_IN_DOCKER_VERSION=1.2.0
|
||||
RUN sh -c "$(wget -O- https://github.com/deluan/zsh-in-docker/releases/download/v${ZSH_IN_DOCKER_VERSION}/zsh-in-docker.sh)" -- \
|
||||
RUN wget --tries=3 --timeout=30 --waitretry=5 --retry-connrefused \
|
||||
-O /tmp/zsh-in-docker.sh \
|
||||
"https://github.com/deluan/zsh-in-docker/releases/download/v${ZSH_IN_DOCKER_VERSION}/zsh-in-docker.sh" && \
|
||||
sh /tmp/zsh-in-docker.sh \
|
||||
-p fzf \
|
||||
-p git \
|
||||
-a "export PROMPT_COMMAND='history -a' && export HISTFILE=/commandhistory/.bash_history" \
|
||||
-a "source /usr/share/doc/fzf/examples/key-bindings.zsh" \
|
||||
-a "source /usr/share/doc/fzf/examples/completion.zsh" \
|
||||
-x
|
||||
-x && \
|
||||
rm /tmp/zsh-in-docker.sh
|
||||
|
||||
# Install Claude Code
|
||||
RUN npm install -g @anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}
|
||||
@@ -244,7 +292,8 @@ RUN npm install -g playwright@${PLAYWRIGHT_VERSION} && \
|
||||
npx playwright install chromium
|
||||
|
||||
# Copy and set up firewall + DNS-proxy + entrypoint scripts, the gitea-pr
|
||||
# helper from this repo's bin/ directory, and the claude wrapper that pins
|
||||
# and gitea-issue helpers from this repo's bin/ directory, and the claude
|
||||
# wrapper that pins
|
||||
# --dangerously-skip-permissions. The launched application itself
|
||||
# (SANDBOX_CMD, default `claude`) and any editor config for it are supplied
|
||||
# at runtime, not baked into the image - see docker-compose.yml/entrypoint.sh.
|
||||
@@ -259,9 +308,10 @@ COPY docker/entrypoint.sh /usr/local/bin/
|
||||
COPY docker/anthropic-proxy-forwarder.js /usr/local/bin/anthropic-proxy-forwarder.js
|
||||
COPY docker/claude-wrapper.sh /usr/local/bin/claude
|
||||
COPY bin/gitea-pr /usr/local/bin/gitea-pr
|
||||
COPY bin/gitea-issue /usr/local/bin/gitea-issue
|
||||
|
||||
USER root
|
||||
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/gitea-pr /usr/local/bin/claude && \
|
||||
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/gitea-pr /usr/local/bin/gitea-issue /usr/local/bin/claude && \
|
||||
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
|
||||
|
||||
@@ -1,22 +1,65 @@
|
||||
// TCP -> unix-socket byte pipe: Claude Code needs an http://127.0.0.1 base
|
||||
// URL, but the host-side credential proxy listens on a bind-mounted unix
|
||||
// socket (no host TCP exposure - see bin/dejima-anthropic-proxy).
|
||||
// Deliberately no HTTP awareness: bytes in, bytes out. Port 18300 is a repo
|
||||
// constant (the container's network namespace is private, so nothing can
|
||||
// collide with it); the env override exists only for host-side tests.
|
||||
// TCP front door for the host-side credential proxy, which listens on a
|
||||
// bind-mounted unix socket (no host TCP exposure - see
|
||||
// bin/dejima-anthropic-proxy). Deliberately no HTTP awareness in either
|
||||
// mode: bytes in, bytes out.
|
||||
//
|
||||
// tls (default): terminates TLS for api.anthropic.com on :443, which
|
||||
// entrypoint.sh points at loopback via /etc/hosts. Claude Code then needs no
|
||||
// ANTHROPIC_BASE_URL and no placeholder token - it is configured exactly like
|
||||
// a stock install, which is the point (see docs/security.md). ALPN is pinned
|
||||
// to http/1.1 because the far end of the pipe is a Python http.server: let a
|
||||
// client negotiate h2 and it would receive HTTP/2 frames nothing here parses.
|
||||
//
|
||||
// loopback: the original plaintext forwarder on :18300, reached via an
|
||||
// explicit ANTHROPIC_BASE_URL. Kept as the documented fallback for the day
|
||||
// certificate pinning makes the tls path unusable - the alternative would be
|
||||
// disabling the credential proxy entirely, which is exactly what must never
|
||||
// be the answer.
|
||||
//
|
||||
// Ports are repo constants (the container's network namespace is private, so
|
||||
// nothing can collide); the env override exists only for host-side tests.
|
||||
const net = require("net");
|
||||
const tls = require("tls");
|
||||
const fs = require("fs");
|
||||
|
||||
const socketPath = process.env.SANDBOX_ANTHROPIC_PROXY_SOCKET;
|
||||
const port = parseInt(process.env.SANDBOX_ANTHROPIC_PROXY_PORT || "18300", 10);
|
||||
const transport = process.env.SANDBOX_ANTHROPIC_TRANSPORT || "tls";
|
||||
const port = parseInt(
|
||||
process.env.SANDBOX_ANTHROPIC_PROXY_PORT || (transport === "tls" ? "443" : "18300"),
|
||||
10,
|
||||
);
|
||||
if (!socketPath) {
|
||||
console.error("anthropic-proxy-forwarder: SANDBOX_ANTHROPIC_PROXY_SOCKET not set");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
net.createServer((client) => {
|
||||
const pipe = (client) => {
|
||||
const upstream = net.connect(socketPath);
|
||||
client.pipe(upstream);
|
||||
upstream.pipe(client);
|
||||
client.on("error", () => upstream.destroy());
|
||||
upstream.on("error", () => client.destroy());
|
||||
}).listen(port, "127.0.0.1");
|
||||
};
|
||||
|
||||
let server;
|
||||
if (transport === "tls") {
|
||||
const certPath = process.env.SANDBOX_ANTHROPIC_TLS_CERT;
|
||||
const keyPath = process.env.SANDBOX_ANTHROPIC_TLS_KEY;
|
||||
if (!certPath || !keyPath) {
|
||||
console.error(
|
||||
"anthropic-proxy-forwarder: SANDBOX_ANTHROPIC_TLS_CERT/SANDBOX_ANTHROPIC_TLS_KEY not set",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
server = tls.createServer(
|
||||
{
|
||||
cert: fs.readFileSync(certPath),
|
||||
key: fs.readFileSync(keyPath),
|
||||
ALPNProtocols: ["http/1.1"],
|
||||
},
|
||||
pipe,
|
||||
);
|
||||
} else {
|
||||
server = net.createServer(pipe);
|
||||
}
|
||||
server.listen(port, "127.0.0.1");
|
||||
|
||||
@@ -3,4 +3,14 @@
|
||||
# PATH) so every invocation - interactive shell, or claudecode.nvim's
|
||||
# terminal - runs with --dangerously-skip-permissions, without callers
|
||||
# needing to know that.
|
||||
exec /usr/local/share/npm-global/bin/claude --dangerously-skip-permissions "$@"
|
||||
#
|
||||
# The sandbox's settings overrides arrive the same way: as an extra
|
||||
# --settings layer rather than by mounting over ~/.claude/settings.json (see
|
||||
# docker-compose.yml and #47). That only holds if every entry point passes
|
||||
# the flag, and shadowing the binary is what makes it true for the
|
||||
# invocations the entrypoint never sees. The file is absent when a container
|
||||
# is started without bin/dejima, as test/runtime.bats does.
|
||||
settings=()
|
||||
[ -f /etc/dejima-settings.json ] && settings=(--settings /etc/dejima-settings.json)
|
||||
|
||||
exec /usr/local/share/npm-global/bin/claude --dangerously-skip-permissions "${settings[@]}" "$@"
|
||||
|
||||
+76
-56
@@ -42,6 +42,12 @@ services:
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
- NET_RAW
|
||||
# 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
|
||||
environment:
|
||||
GITEA_SSH_HOST: ${GITEA_SSH_HOST:-}
|
||||
GITEA_API: ${GITEA_API:-}
|
||||
@@ -62,7 +68,7 @@ services:
|
||||
# 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}
|
||||
# Base URL of a Loki push endpoint (e.g. http://10.0.30.11:3100) to
|
||||
# Base URL of a Loki push endpoint (e.g. http://192.0.2.10:3100) to
|
||||
# ship DNS-query and firewall allow/reject logs to. Its host is
|
||||
# automatically firewall-allowlisted (see entrypoint.sh). Optional -
|
||||
# logging is skipped entirely if unset.
|
||||
@@ -78,87 +84,101 @@ services:
|
||||
# "xterm", dropping 256-color and any extended-keys terminfo bits),
|
||||
# not whatever the host's actual terminal/tmux session is using.
|
||||
# Forward the real value so nvim negotiates key sequences (e.g. the
|
||||
# Ctrl-H/Backspace disambiguation config/tmux.conf sets up) against
|
||||
# Ctrl-H/Backspace disambiguation a tmux config may set up) against
|
||||
# the terminfo entry the rest of the chain actually expects.
|
||||
TERM: ${TERM:-xterm-256color}
|
||||
# Host-side credential proxy (see bin/dejima and README
|
||||
# "Security notes"): when enabled, Claude Code is pointed at a
|
||||
# loopback forwarder and given a placeholder token - the real OAuth
|
||||
# credential stays on the host, injected per-request by the proxy.
|
||||
# All three are empty (and the ANTHROPIC_* pair unset by
|
||||
# entrypoint.sh) when the proxy is disabled.
|
||||
# Host-side credential proxy (see bin/dejima and docs/security.md):
|
||||
# the real OAuth credential stays on the host, injected per-request by
|
||||
# the proxy. In the default tls transport Claude Code gets no
|
||||
# ANTHROPIC_* values at all - api.anthropic.com is pointed at an
|
||||
# in-container TLS terminator instead, so the session carries no
|
||||
# environment fingerprint. The pair below is only populated in the
|
||||
# loopback transport (the documented cert-pinning fallback), and is
|
||||
# empty - and unset by entrypoint.sh - otherwise.
|
||||
ANTHROPIC_BASE_URL: ${SANDBOX_ANTHROPIC_BASE_URL:-}
|
||||
ANTHROPIC_AUTH_TOKEN: ${SANDBOX_ANTHROPIC_AUTH_TOKEN:-}
|
||||
SANDBOX_ANTHROPIC_PROXY_SOCKET: ${SANDBOX_ANTHROPIC_PROXY_SOCKET:-}
|
||||
SANDBOX_ANTHROPIC_TRANSPORT: ${SANDBOX_ANTHROPIC_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
|
||||
# (based on cwd) lines up with the host, letting a session started
|
||||
# here be resumed on the host with `claude --resume`.
|
||||
- ${WORKSPACE_DIR:?set WORKSPACE_DIR to the project directory to mount}:${WORKSPACE_DIR}
|
||||
- ${HOME}/.claude:${HOME}/.claude
|
||||
# settings.json/CLAUDE.md/plugins are config and hook/plugin *code*,
|
||||
# not runtime state - hooks run automatically with no approval gate,
|
||||
# so a compromised session writing to them would plant something that
|
||||
# then executes unsandboxed on the host next time Claude Code starts
|
||||
# there. Layer them back read-only on top of the read-write parent
|
||||
# mount above (Docker applies more specific mounts on top of broader
|
||||
# ones); everything else under ~/.claude (session/project history,
|
||||
# caches, etc.) still needs to stay read-write.
|
||||
# ~/.claude is deliberately not mounted as a whole. It used to be,
|
||||
# read-write, with the config inside it layered back read-only on top.
|
||||
# Every one of those overlays was a mount point nested in a
|
||||
# host-writable mount, and a single-file bind mount is pinned to the
|
||||
# dentry at that path: a host-side rename of the underlying file - a
|
||||
# sync daemon, or Claude Code's own atomic writes - unlinks it, the
|
||||
# mount silently detaches, and the path falls through to the real,
|
||||
# writable host file (#47, #48). The same thing happened to the mask
|
||||
# over .credentials.json, handing the sandbox the operator's real
|
||||
# OAuth token on the first refresh (#50).
|
||||
#
|
||||
# settings.json specifically comes from $SANDBOX_SETTINGS_FILE (a
|
||||
# bin/dejima-generated copy with sandbox.enabled forced to
|
||||
# false), not the real file directly - Claude Code's own internal
|
||||
# per-command sandboxing is redundant on top of this container (and
|
||||
# can't actually function inside it anyway), and mounting it read-only
|
||||
# means it can't be patched from inside, so the substitution has to
|
||||
# happen on the host before the container ever starts.
|
||||
- ${SANDBOX_SETTINGS_FILE:?set by bin/dejima}:${HOME}/.claude/settings.json:ro
|
||||
- ${HOME}/.claude/CLAUDE.md:${HOME}/.claude/CLAUDE.md:ro
|
||||
# So the contents are enumerated instead. Anything not listed below is
|
||||
# container-local: writable during the session, gone with the
|
||||
# container. That is the safer default - a config file a future Claude
|
||||
# Code version adds simply doesn't persist, instead of sitting
|
||||
# unprotected on a writable mount - but it does mean new state that
|
||||
# *should* persist has to be added here deliberately.
|
||||
#
|
||||
# Runtime state that has to survive the session, read-write. All
|
||||
# directories, which is what makes them safe: a directory bind mount
|
||||
# can't detach the way a file one does, because a rename replaces an
|
||||
# entry *within* the mounted directory rather than the mount point.
|
||||
# projects/ is what host-side `claude --resume` reads (and what
|
||||
# log-shipper.sh tails); the rest is Claude Code's edit-undo record
|
||||
# and the task/plan state skills write.
|
||||
- ${HOME}/.claude/projects:${HOME}/.claude/projects
|
||||
- ${HOME}/.claude/file-history:${HOME}/.claude/file-history
|
||||
- ${HOME}/.claude/backups:${HOME}/.claude/backups
|
||||
- ${HOME}/.claude/tasks:${HOME}/.claude/tasks
|
||||
- ${HOME}/.claude/plans:${HOME}/.claude/plans
|
||||
- ${HOME}/.claude/sessions:${HOME}/.claude/sessions
|
||||
# Hook/plugin/skill *code*, and prompt text that a later host session
|
||||
# loads. Hooks run automatically with no approval gate, a skill can
|
||||
# ship an executable script, and a command/agent/output-style is text
|
||||
# loaded into an unsandboxed host session's context - so a compromised
|
||||
# session writing to any of these plants something that then runs on
|
||||
# the host. Read-only, and no longer nested inside a writable parent:
|
||||
# if one of these ever did detach, the path falls through to an empty
|
||||
# container-local directory rather than to the host's copy. They may
|
||||
# not exist on a given host at all - bin/dejima pre-creates them,
|
||||
# since existence is what lets Docker mount them.
|
||||
- ${HOME}/.claude/plugins:${HOME}/.claude/plugins:ro
|
||||
# ~/.claude/hooks holds hook *code* too (install.sh symlinks this
|
||||
# repo's hooks/ files into it) - same rationale as settings.json/
|
||||
# CLAUDE.md/plugins above, but for a gap those three don't cover:
|
||||
# without this, the directory itself stays part of the read-write
|
||||
# ~/.claude mount, so a compromised session could unlink one of
|
||||
# install.sh's symlinks and drop a plain file in its place. The
|
||||
# dynamic per-symlink-target mounts bin/dejima adds at
|
||||
# launch (see below) only protect targets that resolve *outside*
|
||||
# ~/.claude - they never cover this directory itself, and a plain
|
||||
# file dropped here wouldn't be a symlink for that logic to even
|
||||
# notice. That file would then run unsandboxed on the host as a
|
||||
# hook the next time Claude Code starts there - the same
|
||||
# persistence path the other three overlays exist to close.
|
||||
- ${HOME}/.claude/hooks:${HOME}/.claude/hooks:ro
|
||||
# skills/commands/agents carry the same execute-or-load-on-host risk as
|
||||
# hooks/plugins above: a skill can ship an executable script, and a
|
||||
# command/agent is prompt text loaded into a later, unsandboxed host
|
||||
# session. Unlike those, they may not exist on a given host at all -
|
||||
# bin/dejima pre-creates them (empty is fine; existence is what
|
||||
# lets Docker overlay them) so a compromised session can't create-then-
|
||||
# populate one under the read-write parent mount.
|
||||
- ${HOME}/.claude/skills:${HOME}/.claude/skills:ro
|
||||
- ${HOME}/.claude/commands:${HOME}/.claude/commands:ro
|
||||
- ${HOME}/.claude/agents:${HOME}/.claude/agents:ro
|
||||
# output-styles is prompt text loaded into a later host session's
|
||||
# system prompt, same class as commands/agents above.
|
||||
- ${HOME}/.claude/output-styles:${HOME}/.claude/output-styles:ro
|
||||
# Config *files* are staged here for entrypoint.sh to copy into
|
||||
# ~/.claude, rather than mounted at their real paths. A copy can't
|
||||
# detach at all, and it closes a gap no mount could: when
|
||||
# statusline.sh is itself a symlink (the common case, into whatever
|
||||
# config checkout the host keeps it in), bind-mount destination
|
||||
# resolution follows it, so a :ro mount landed on the target and left
|
||||
# the symlink itself replaceable.
|
||||
# statusline.sh is staged by bin/dejima, since not every host has one.
|
||||
- ${HOME}/.claude/settings.json:/etc/dejima-claude/settings.json:ro
|
||||
- ${HOME}/.claude/CLAUDE.md:/etc/dejima-claude/CLAUDE.md:ro
|
||||
# The sandbox's own settings overrides (sandbox.enabled off, plus the
|
||||
# remote-access notify hooks when that's on), generated per run by
|
||||
# bin/dejima and reaching Claude Code as claude-wrapper.sh's
|
||||
# --settings argument. A container-local path, like the staged config
|
||||
# above and for the same reason (#47) - and separate from that staging
|
||||
# directory because this file is generated per run rather than copied
|
||||
# from the host, and it stays mounted rather than being copied in.
|
||||
- ${SANDBOX_SETTINGS_FILE:?set by bin/dejima}:/etc/dejima-settings.json:ro
|
||||
# A throwaway per-session copy, not the real file - see
|
||||
# bin/dejima and README "Throwaway ~/.claude.json". A plain
|
||||
# read-only mount hangs Claude Code on startup (it writes to this
|
||||
# file before login completes), so this can't just be layered
|
||||
# read-only the way settings.json/CLAUDE.md/plugins/hooks are above;
|
||||
# read-only the way plugins/hooks/skills are above;
|
||||
# a generated, discarded copy is what actually stops a compromised
|
||||
# session's changes (e.g. a malicious mcpServers entry) from ever
|
||||
# reaching the real file.
|
||||
- ${SANDBOX_CLAUDE_JSON_FILE:?set by bin/dejima}:${HOME}/.claude.json
|
||||
# ~/.claude/CLAUDE.md (mounted above) tells agents to write specs to
|
||||
# ~/specs/ rather than a project-local path - that instruction is
|
||||
# already in effect inside the container (CLAUDE.md carries over), so
|
||||
# the directory it points at needs to actually exist and be writable
|
||||
# here too, or an agent following it just writes into an ephemeral
|
||||
# path inside the container that vanishes with the session.
|
||||
- ${HOME}/specs:${HOME}/specs
|
||||
# Global git identity (user.name/user.email) - a throwaway copy of it,
|
||||
# in its own directory rather than a single file bind-mounted at
|
||||
# ~/.gitconfig: entrypoint.sh's `gh auth setup-git` writes a
|
||||
|
||||
+127
-5
@@ -99,11 +99,119 @@ if [ -s "$SANDBOX_SSH_KEY_SRC" ]; then
|
||||
export SSH_AUTH_SOCK
|
||||
fi
|
||||
|
||||
# Loopback bridge for the host-side credential proxy (see bin/dejima
|
||||
# and bin/dejima-anthropic-proxy): Claude Code talks HTTP to
|
||||
# 127.0.0.1:18300, the forwarder pipes bytes into the bind-mounted unix
|
||||
# socket. Only started when this session actually has a proxy socket.
|
||||
# ~/.claude config files arrive staged read-only under /etc/dejima-claude
|
||||
# (see the mount inventory in docker-compose.yml) and get copied into place
|
||||
# here rather than bind-mounted at their real paths. A copy can't be
|
||||
# detached by a host-side rename the way a single-file mount can (#47, #48),
|
||||
# and it closes the one case no mount could: a statusline.sh that is a
|
||||
# symlink into a dotfiles checkout. Docker resolves the symlink when it sets
|
||||
# up the staging mount, so what lands here is already the real content.
|
||||
#
|
||||
# Root-owned and read-only, so the `#` memory shortcut and /memory keep
|
||||
# failing visibly against ~/.claude/CLAUDE.md instead of appearing to
|
||||
# succeed. A compromised session can still unlink and replace them - they
|
||||
# sit in a directory it owns - but that change is now container-local and
|
||||
# can't reach the host, which is the whole reason these were read-only.
|
||||
if [ -d /etc/dejima-claude ]; then
|
||||
for staged in settings.json CLAUDE.md; do
|
||||
if [ -f "/etc/dejima-claude/$staged" ]; then
|
||||
install -o root -g root -m 444 "/etc/dejima-claude/$staged" "$SANDBOX_HOME/.claude/$staged"
|
||||
fi
|
||||
done
|
||||
if [ -f /etc/dejima-claude/statusline.sh ]; then
|
||||
install -o root -g root -m 555 /etc/dejima-claude/statusline.sh "$SANDBOX_HOME/.claude/statusline.sh"
|
||||
fi
|
||||
fi
|
||||
|
||||
# The real OAuth credential no longer enters the container at all - ~/.claude
|
||||
# isn't mounted wholesale any more, so there is nothing to mask (#50). What
|
||||
# lands at this path instead depends on the transport: the tls one needs a
|
||||
# well-formed fake credential, because Claude Code authenticates from this
|
||||
# file rather than from an ANTHROPIC_AUTH_TOKEN, and bin/dejima mounts one.
|
||||
# Nothing is mounted for the loopback transport, so give Claude Code the
|
||||
# empty file the old mask used to provide rather than betting that an absent
|
||||
# credentials file behaves the same as an empty one. Either way this is
|
||||
# skipped when something is already there - which also covers
|
||||
# SANDBOX_CREDENTIAL_PROXY=0 mounting the real credential in deliberately.
|
||||
if [ ! -e "$SANDBOX_HOME/.claude/.credentials.json" ]; then
|
||||
install -o "$SANDBOX_USER" -g "$SANDBOX_USER" -m 600 /dev/null "$SANDBOX_HOME/.claude/.credentials.json"
|
||||
fi
|
||||
|
||||
# TCP front door for the host-side credential proxy (see bin/dejima and
|
||||
# bin/dejima-anthropic-proxy): the proxy listens on a bind-mounted unix
|
||||
# socket, and anthropic-proxy-forwarder.js pipes bytes into it. Only started
|
||||
# when this session actually has a proxy socket.
|
||||
#
|
||||
# In the default tls transport, api.anthropic.com is pointed at loopback here
|
||||
# and the forwarder terminates TLS for it, so Claude Code runs with no
|
||||
# ANTHROPIC_BASE_URL and no placeholder token - it is indistinguishable from a
|
||||
# stock install, which is the whole point (see docs/security.md). The hosts
|
||||
# entry deliberately lives inside this guard rather than in compose's
|
||||
# extra_hosts: under SANDBOX_CREDENTIAL_PROXY=0 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
|
||||
# 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
|
||||
tls|loopback) ;;
|
||||
*)
|
||||
echo "FATAL: SANDBOX_ANTHROPIC_TRANSPORT is set to '$SANDBOX_ANTHROPIC_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
|
||||
forwarder_port=18300
|
||||
if [ "$SANDBOX_ANTHROPIC_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
|
||||
# is readable by the sandbox user because the forwarder stays
|
||||
# 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
|
||||
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
|
||||
# discarding its stderr would make that abort silent-but-loud (dies,
|
||||
# says nothing why). Captured instead of routed straight to /dev/null
|
||||
# so the one line that explains the failure survives to the FATAL
|
||||
# block below; openssl's success-path chatter is just progress dots.
|
||||
if ! openssl_err=$(openssl req -x509 -newkey rsa:2048 -nodes -days 365 \
|
||||
-keyout "$tls_dir/key.pem" -out "$tls_dir/cert.pem" \
|
||||
-subj "/CN=api.anthropic.com" \
|
||||
-addext "subjectAltName=DNS:api.anthropic.com" 2>&1); then
|
||||
echo "FATAL: openssl failed to generate the TLS terminator cert for api.anthropic.com." >&2
|
||||
echo "$openssl_err" >&2
|
||||
echo "Not starting the session - API traffic would have no valid cert to trust." >&2
|
||||
echo "Re-run with SANDBOX_ANTHROPIC_TRANSPORT=loopback, or with" >&2
|
||||
echo "SANDBOX_CREDENTIAL_PROXY=0 to launch without the proxy (the real OAuth" >&2
|
||||
echo "credential is then mounted into the container)." >&2
|
||||
exit 1
|
||||
fi
|
||||
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"
|
||||
# /etc/hosts wins over DNS in getaddrinfo, so this bypasses the DNS
|
||||
# proxy entirely - which is why fixed-domains.sh leaves the name off
|
||||
# the allowlist whenever there is a proxy socket.
|
||||
echo "127.0.0.1 api.anthropic.com" >> /etc/hosts
|
||||
# The trust anchor for the cert above. NODE_EXTRA_CA_CERTS rather than
|
||||
# the system store: it needs no update-ca-certificates run, and it is
|
||||
# the single most ordinary variable a corporate environment sets.
|
||||
export NODE_EXTRA_CA_CERTS="$tls_dir/cert.pem"
|
||||
fi
|
||||
gosu "$SANDBOX_USER" node /usr/local/bin/anthropic-proxy-forwarder.js &
|
||||
# Node's own startup (V8 init, module load, then finally binding the
|
||||
# listen socket) isn't instant - without this wait, a request that
|
||||
@@ -111,10 +219,24 @@ if [ -n "${SANDBOX_ANTHROPIC_PROXY_SOCKET:-}" ]; then
|
||||
# immediate check; in principle also a very fast-starting real client)
|
||||
# can race ahead of the bind and see a connection refused. Bash's own
|
||||
# /dev/tcp is enough to probe this without a curl/nc dependency.
|
||||
forwarder_bound=0
|
||||
for _ in $(seq 1 50); do
|
||||
(exec 3<>/dev/tcp/127.0.0.1/18300) 2>/dev/null && exec 3>&- && break
|
||||
(exec 3<>/dev/tcp/127.0.0.1/$forwarder_port) 2>/dev/null && exec 3>&- && forwarder_bound=1 && break
|
||||
sleep 0.1
|
||||
done
|
||||
# Loud, not silent. bin/dejima already refuses to launch when the
|
||||
# host-side proxy fails to start, for the same reason: every quiet
|
||||
# fallback from here either exposes the credential or, in tls mode, sends
|
||||
# API traffic to a loopback port nothing is listening on and produces a
|
||||
# connection error nobody can trace back to this.
|
||||
if [ "$forwarder_bound" != "1" ]; then
|
||||
echo "FATAL: the credential-proxy forwarder never bound 127.0.0.1:$forwarder_port." >&2
|
||||
echo "Not starting the session - API traffic would have no working path to the" >&2
|
||||
echo "host-side proxy. Re-run with SANDBOX_ANTHROPIC_TRANSPORT=loopback, or with" >&2
|
||||
echo "SANDBOX_CREDENTIAL_PROXY=0 to launch without the proxy (the real OAuth" >&2
|
||||
echo "credential is then mounted into the container)." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Compose can't conditionally omit an env entry, so these arrive as empty
|
||||
|
||||
+14
-1
@@ -3,7 +3,6 @@
|
||||
FIXED_DOMAINS=(
|
||||
"registry.npmjs.org"
|
||||
"platform.claude.com"
|
||||
"api.anthropic.com"
|
||||
"gitlab.com"
|
||||
"pypi.org"
|
||||
"files.pythonhosted.org"
|
||||
@@ -12,6 +11,20 @@ FIXED_DOMAINS=(
|
||||
"sum.golang.org"
|
||||
)
|
||||
|
||||
# api.anthropic.com is allowlisted only when this session has no credential
|
||||
# proxy (kill switch or API-key auth), where DNS is the only path to the API.
|
||||
# With the proxy on, the session reaches the API through the forwarder rather
|
||||
# than by resolving this name (under the tls transport, via entrypoint.sh's
|
||||
# /etc/hosts pin), so leaving it unresolvable defeats a bypass that looks the
|
||||
# name up by DNS. It is not a route block: the API's address stays reachable as an IP
|
||||
# literal for as long as platform.claude.com above resolves to the same one.
|
||||
# Appended here rather than to EXTRA_ALLOWED_DOMAINS because it is a public
|
||||
# SaaS name: dns-proxy.sh exempts the extra list from stop-dns-rebind, and
|
||||
# init-firewall.sh downgrades a failed lookup there to a warning.
|
||||
if [ -z "${SANDBOX_ANTHROPIC_PROXY_SOCKET:-}" ]; then
|
||||
FIXED_DOMAINS+=("api.anthropic.com")
|
||||
fi
|
||||
|
||||
# GitHub itself is allowlisted by IP range (see github-domains ipset,
|
||||
# populated from api.github.com/meta), never by resolving these names to
|
||||
# specific IPs - but the container's own tools (git, gh, curl) still need to
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
# Example nginx vhost for the dejima web front door. EVERY address, hostname
|
||||
# and path below is a placeholder - substitute your own. As written this file
|
||||
# will not load: that is deliberate, so it can never become a live config by
|
||||
# being copied into sites-enabled and forgotten.
|
||||
#
|
||||
# ---------------------------------------------------------------------------
|
||||
# Read this part before the config part.
|
||||
#
|
||||
# Whoever reaches this vhost gets a HOST SHELL. ttyd runs bin/dejima-attach,
|
||||
# which attaches a tmux client on the operator's own tmux server - and a tmux
|
||||
# client is a shell outside the container, one prefix key away from a new
|
||||
# window that has nothing to do with any sandbox.
|
||||
#
|
||||
# So two things carry this feature's entire security, and both live here:
|
||||
#
|
||||
# 1. The listen address. docker/init-firewall.sh:187 accepts container
|
||||
# OUTPUT to the default-route address - the Docker bridge gateway, which
|
||||
# is the host. A vhost bound to every interface, or to that gateway
|
||||
# address, is reachable FROM a sandbox, which would let a compromised
|
||||
# session walk out through its own escape hatch. Bind one specific LAN
|
||||
# address, never the wildcard form (a bare `listen 443 ssl;`), and never
|
||||
# the bridge address. `ip -br addr` lists the candidates; `ss -ltnp`
|
||||
# afterwards shows what you actually bound.
|
||||
#
|
||||
# 2. Authentication. ttyd is started with no credential of its own (see
|
||||
# bin/dejima-ttyd), because it is bound to 127.0.0.1 where only this
|
||||
# host can reach it - this proxy is the only gate in front of it. A
|
||||
# vhost without auth_basic (or something stronger in its place) is an
|
||||
# unauthenticated host shell on your LAN. Put every listen behind it:
|
||||
# a second server block without auth is the same open door.
|
||||
#
|
||||
# Authentication alone does not stop a page in your browser from
|
||||
# opening a WebSocket to this vhost and having the browser attach your
|
||||
# credential to it, which is why bin/dejima-ttyd passes --check-origin.
|
||||
# That check compares the Origin against the Host header, so the
|
||||
# `Host $http_host` line below is load-bearing, not tidiness - and it
|
||||
# has to be $http_host, which keeps the port, on any listen other
|
||||
# than 443. See the comment on that line.
|
||||
#
|
||||
# 3. The catch-all below. nginx chooses a server block by SNI/Host, and
|
||||
# when nothing matches it serves the DEFAULT server for that listen
|
||||
# address - which, if the vhost below is the only block on it, is the
|
||||
# vhost below. `server_name` therefore isolates nothing by itself: with
|
||||
# one block, this front door answers to EVERY name that resolves to
|
||||
# this address, the box's own hostname included. What is behind it is a
|
||||
# host shell, so the reject block comes first and takes that role.
|
||||
#
|
||||
# TLS is not optional either: HTTP Basic sends the password on every request,
|
||||
# and the terminal itself carries whatever the session is working on.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Refuses every name that is not the vhost below. Without it, `server_name`
|
||||
# is decorative and any hostname pointed at this address reaches the shell.
|
||||
#
|
||||
# If you ALREADY have a default_server on this address:port, don't add a
|
||||
# second - nginx refuses to start ("duplicate default server") - just make
|
||||
# sure the existing one doesn't fall through to the block below.
|
||||
server {
|
||||
listen <LAN-IP>:443 ssl default_server;
|
||||
# Refuses at the TLS layer, so it needs no certificate of its own
|
||||
# (nginx 1.19.4+). On older nginx, give this block the same certificate
|
||||
# as the vhost below and `return 444;` instead.
|
||||
ssl_reject_handshake on;
|
||||
}
|
||||
|
||||
server {
|
||||
# One specific address. Not the wildcard form, not the Docker bridge.
|
||||
listen <LAN-IP>:443 ssl;
|
||||
server_name <hostname>;
|
||||
|
||||
ssl_certificate /path/to/fullchain.pem;
|
||||
ssl_certificate_key /path/to/privkey.pem;
|
||||
|
||||
# The only authentication there is. See point 2 above.
|
||||
# htpasswd -c /path/to/dejima.htpasswd <username>
|
||||
auth_basic "dejima";
|
||||
auth_basic_user_file /path/to/dejima.htpasswd;
|
||||
|
||||
location / {
|
||||
# Loopback: ttyd binds 127.0.0.1 only, so nginx has to be on the same
|
||||
# host. The port must match TTYD_PORT in ~/.config/dejima/remote.conf
|
||||
# (7681 when it is not set).
|
||||
proxy_pass http://127.0.0.1:7681;
|
||||
|
||||
# ttyd serves its page over HTTP and then upgrades to a WebSocket;
|
||||
# both need HTTP/1.1 and the upgrade headers passed through.
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
# ttyd's --check-origin compares the client's Origin against Host.
|
||||
# Drop this line and it compares against 127.0.0.1:<port> instead.
|
||||
#
|
||||
# $http_host, not $host: $host has the port stripped, and ttyd keeps
|
||||
# the port in what it compares unless it is 80 or 443. On a vhost
|
||||
# listening anywhere else - 8443, say - $host would make ttyd compare
|
||||
# "name:8443" against "name" and refuse every WebSocket: the page
|
||||
# loads, the terminal never connects, and the only sign is
|
||||
# "refuse to serve WS client from different origin" in ttyd's log.
|
||||
proxy_set_header Host $http_host;
|
||||
|
||||
# An attached terminal can sit idle for hours; nginx's default read
|
||||
# timeout is 60 seconds. ttyd's own WebSocket ping (every 5s by
|
||||
# default) normally keeps the connection alive on its own, but that
|
||||
# is ttyd's default to change, not something to depend on here.
|
||||
proxy_read_timeout 1d;
|
||||
|
||||
# ---- Phone rendering. Drop this if you only ever connect from a
|
||||
# desktop; it is inert there.
|
||||
#
|
||||
# ttyd's page ships no viewport meta - not in 1.7.7, not on main - so
|
||||
# a mobile browser assumes a desktop-width layout (~980 CSS px) and
|
||||
# scales the whole thing down to fit. Text lands at a size no font
|
||||
# setting can rescue, because the font is scaled down with everything
|
||||
# else, and pinch-zoom does not reflow a terminal: you get a giant
|
||||
# canvas to pan around. Injecting the tag makes the browser lay out at
|
||||
# the device's real width, which lets xterm.js fit an honest column
|
||||
# count and lets rotating the phone actually reflow.
|
||||
#
|
||||
# Needs nginx built with the sub module. Check before relying on it,
|
||||
# and validate with `nginx -t` rather than trusting the reload: a
|
||||
# config that does not parse leaves the OLD one running while
|
||||
# `systemctl reload nginx` still reports success, so an unknown
|
||||
# directive here looks just like one that ran and did nothing.
|
||||
# Spelled out because Debian/Ubuntu install nginx to /usr/sbin, which
|
||||
# is on root's PATH and not yours: a bare `nginx -V` says "command
|
||||
# not found" on a box that is running nginx perfectly well.
|
||||
# /usr/sbin/nginx -V 2>&1 | tr ' ' '\n' | grep http_sub_module
|
||||
# sudo /usr/sbin/nginx -t
|
||||
#
|
||||
# Desktop browsers ignore width=device-width, so this costs them
|
||||
# nothing.
|
||||
sub_filter '<head>' '<head><meta name="viewport" content="width=device-width, initial-scale=1">';
|
||||
sub_filter_once on;
|
||||
# sub_filter cannot rewrite a compressed body, and silently passes it
|
||||
# through unchanged - which looks exactly like the tag not working.
|
||||
proxy_set_header Accept-Encoding "";
|
||||
|
||||
# ttyd sends this page with no Cache-Control, no ETag and no
|
||||
# Last-Modified - src/http.c sets status, content-type,
|
||||
# content-encoding and content-length, and nothing else - and a
|
||||
# response carrying neither freshness information nor a validator is
|
||||
# cacheable at the browser's discretion, which on a phone means
|
||||
# indefinitely. That is harmless while the page is served verbatim and
|
||||
# a trap once it is REWRITTEN here: a phone that cached the
|
||||
# un-injected page goes on rendering it, so the tag reads as broken
|
||||
# long after it works, and no future change to the injection reaches
|
||||
# a browser that has already been here. Nothing is lost by refusing
|
||||
# the cache - with no validator upstream, a revalidation could never
|
||||
# come back 304 anyway - and the page is still gzipped to the client,
|
||||
# since clearing Accept-Encoding above only applies to the request
|
||||
# made to ttyd.
|
||||
add_header Cache-Control "no-store" always;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,675 @@
|
||||
# Remote access
|
||||
|
||||
[← dejima README](../README.md)
|
||||
|
||||
**Opt-in, and entirely off until you create one file.** Nothing here runs and
|
||||
no front door exists unless `~/.config/dejima/remote.conf` is present. With
|
||||
it absent, `dejima` builds exactly the `docker compose` invocation it
|
||||
built before this feature existed, no session state is written, no
|
||||
notification hook is registered, and `dejima-attach`, `dejima-ttyd` and
|
||||
`dejima-notify-watch` each refuse to run at all. `install.sh` symlinks those
|
||||
three onto `PATH` and stops there - it runs no `systemctl`, copies no unit,
|
||||
and starts nothing.
|
||||
|
||||
What this is: a way to get back to a session that is *already running* on
|
||||
your machine, from a phone on the same network, when it stops to ask you
|
||||
something. What it is not: a way to start a session, a hosted service, or
|
||||
anything that should face the internet.
|
||||
|
||||
**Both front doors hand out a host shell.** `dejima-attach` attaches a tmux
|
||||
client on your own tmux server, and a tmux client is one prefix key away
|
||||
from a new window that has nothing to do with any sandbox. Everything below
|
||||
follows from that single fact - it is why the listener is on loopback, why
|
||||
the proxy needs authentication, and why the bind address is the one step to
|
||||
get right.
|
||||
|
||||
## `remote.conf`
|
||||
|
||||
Flat `KEY=value` lines, `#` comments and blank lines ignored, at
|
||||
`~/.config/dejima/remote.conf` (`$DEJIMA_CONFIG_DIR` relocates the whole
|
||||
config tree, same as it does for approvals):
|
||||
|
||||
| Key | Default | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `NTFY_URL` | unset | ntfy topic to push notifications to. `dejima-notify-watch` refuses to start without it; nothing else reads it |
|
||||
| `BASE_URL` | unset | public base URL of the web front door, used to build the notification's deep link. Absent, pushes still go out - just with no link on them |
|
||||
| `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 - see "Reading it on a phone". Unset passes no option at all and leaves `ttyd` on its own default |
|
||||
|
||||
`NTFY_URL` and `BASE_URL` must carry an explicit `http://` or `https://`
|
||||
scheme; a value without one is refused rather than guessed at, because
|
||||
`curl` reads a leading `-K/path` as an *option* that takes the request's URL
|
||||
from that file instead. Values are restricted to
|
||||
`A-Za-z0-9:/._~?=%@+-` — no whitespace and nothing shell-active, since these
|
||||
end up in an HTTP header and in a URL handed to a phone.
|
||||
|
||||
**A malformed file is a hard failure, not a warning.** The whole file is
|
||||
validated on every read, not just the key being asked for: an unknown key, a
|
||||
value outside that character set, or a line without an `=` makes
|
||||
`dejima-ttyd` and `dejima-notify-watch` refuse to start and say which line
|
||||
was wrong. A `TTYD_PORT` that isn't a number in 1-65535 is refused the same
|
||||
way, as is a `TTYD_FONT_SIZE` outside 8-72 - a size big enough to make one
|
||||
glyph wider than the viewport renders a terminal that shows nothing, which
|
||||
reads as a broken front door rather than as a typo three files away.
|
||||
|
||||
Those are the two scripts that read a *value* out of the file.
|
||||
`dejima-attach` and `dejima` do not - to them the file is a switch, and
|
||||
all they ask is whether it exists - so neither one parses it and neither
|
||||
refuses on a bad line. That is deliberate rather than an oversight: a
|
||||
malformed line cannot change what they do, and making the front door refuse
|
||||
to open over a typo would take away the terminal you would fix it from.
|
||||
|
||||
```
|
||||
# ~/.config/dejima/remote.conf - substitute your own topic and hostname
|
||||
NTFY_URL=https://ntfy.example.invalid/your-topic-here
|
||||
BASE_URL=https://dejima.example.invalid
|
||||
TTYD_PORT=7681
|
||||
# TTYD_FONT_SIZE=20 # optional, and applies to the desktop too
|
||||
```
|
||||
|
||||
**Editing this file changes nothing until the services restart.** Every value
|
||||
is read once, at process start - `dejima-ttyd` reads `TTYD_PORT` and
|
||||
`TTYD_FONT_SIZE` and then `exec`s `ttyd` with them baked into its argv. So:
|
||||
|
||||
```sh
|
||||
systemctl --user restart dejima-ttyd.service
|
||||
systemctl --user restart dejima-notify-watch.service # if NTFY_URL changed
|
||||
pgrep -a ttyd # confirm the argv is what you just configured
|
||||
```
|
||||
|
||||
Skipping this is quiet rather than loud: nothing warns, and the front door
|
||||
keeps serving with the old settings, which reads as "the setting had no
|
||||
effect" rather than as "the process is older than the file".
|
||||
|
||||
## The bind address is the one thing to get right
|
||||
|
||||
This is the step that carries the whole feature's security, and the only one
|
||||
where a plausible-looking choice is a full sandbox escape.
|
||||
|
||||
`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.**
|
||||
|
||||
So:
|
||||
|
||||
- `ttyd` is bound to `127.0.0.1` by `dejima-ttyd`, hardcoded, not
|
||||
configurable. The container has no route to your host's loopback, so
|
||||
nothing but a process on this host can open that socket.
|
||||
- **The reverse proxy has to run on that same host** - the dejima box, the
|
||||
one your sandboxes start on. That is not a preference, it is what the
|
||||
loopback bind implies: `proxy_pass http://127.0.0.1:<TTYD_PORT>` only
|
||||
resolves to `ttyd` from the same machine. If you already run a proxy on a
|
||||
*different* box, do not point it here - reaching `ttyd` from there would
|
||||
mean binding `ttyd` to a routable address, which is precisely the escape
|
||||
this whole section exists to prevent. Put a second, small proxy on the
|
||||
dejima box instead.
|
||||
- That proxy must bind **one specific address, on the dejima box's LAN
|
||||
interface**. Never the wildcard - a bare `listen 443 ssl;` is every
|
||||
interface *including* the Docker bridges - never `0.0.0.0`, and never a
|
||||
bridge address.
|
||||
|
||||
## Picking the address
|
||||
|
||||
`ip -br addr` on the dejima box lists more candidates than you want, because
|
||||
Docker adds its own. It looks something like this:
|
||||
|
||||
```
|
||||
lo UNKNOWN 127.0.0.1/8 ::1/128
|
||||
enp3s0 UP 192.0.2.24/24 <a link-local v6 address>
|
||||
docker0 UP <docker's own subnet>
|
||||
br-9f3c1a2b4d5e UP <docker's own subnet>
|
||||
```
|
||||
|
||||
Only the physical LAN interface qualifies - `enp3s0` here; yours may be
|
||||
`eth0`, `wlan0`, `enp0s31f6`, or a bond. So `listen 192.0.2.24:443 ssl;`,
|
||||
using your own address in place of that one.
|
||||
|
||||
Everything else on that list is disqualified, and two of them dangerously
|
||||
so. `lo` is where `ttyd` already is. `docker0` and every `br-<hash>` are
|
||||
Docker bridges - **these are the addresses a sandbox can reach**, so a proxy
|
||||
bound to one is a front door opening inward. If you are unsure which lines
|
||||
those are, ask for exactly them:
|
||||
|
||||
```sh
|
||||
ip -br addr show type bridge # every address here is disqualified
|
||||
```
|
||||
|
||||
Cross-check from the other side, too: the address a running sandbox has a
|
||||
route to is the one to avoid, and the container will tell you which it is -
|
||||
`docker exec <container> ip route | grep default` names it.
|
||||
|
||||
Afterwards, `ss -ltnp | grep <TTYD_PORT>` should show `ttyd` on `127.0.0.1`
|
||||
only, and `ss -ltnp` on your proxy's port should show the single LAN address
|
||||
you chose - not `*` and not `0.0.0.0`.
|
||||
|
||||
## Verifying it
|
||||
|
||||
Verify rather than assume. With a sandbox running, from inside it (adjust
|
||||
`7681` if you changed `TTYD_PORT`, and `443` to whatever port your proxy
|
||||
listens on):
|
||||
|
||||
```sh
|
||||
# the bridge gateway - the only host address this container can route to
|
||||
gw=$(ip route | grep default | cut -d' ' -f3)
|
||||
|
||||
# both should fail: the loopback ttyd, and the front door's own port
|
||||
timeout 5 bash -c "cat < /dev/null > /dev/tcp/$gw/7681" && echo BAD || echo ok
|
||||
timeout 5 bash -c "cat < /dev/null > /dev/tcp/$gw/443" && echo BAD || echo ok
|
||||
```
|
||||
|
||||
`BAD` on either line means stop and fix the bind before going further -
|
||||
whatever answered is reachable from inside the sandbox. The first line is
|
||||
also what catches a distro `ttyd.service` still running on that port, which
|
||||
is why this is worth doing even if you are confident about the proxy. `test/runtime.bats`
|
||||
asserts this same containment property against a live container (with a
|
||||
positive control on the same path, so a passing result can't come from the
|
||||
container simply reaching nothing), and it passes on a real Docker host.
|
||||
|
||||
## SSH front door
|
||||
|
||||
Generate a key that exists only for this, and give it a forced command:
|
||||
|
||||
```sh
|
||||
ssh-keygen -t ed25519 -f ~/.ssh/dejima-phone -C phone
|
||||
```
|
||||
|
||||
Then one line in `~/.ssh/authorized_keys`, with the full path to the symlink
|
||||
`install.sh` made:
|
||||
|
||||
```
|
||||
restrict,pty,command="/home/<you>/.local/bin/dejima-attach" ssh-ed25519 AAAA... phone
|
||||
```
|
||||
|
||||
`restrict` turns off port forwarding, agent forwarding, X11 and everything
|
||||
else; `pty` adds back the one thing a terminal needs; `command=` runs
|
||||
`dejima-attach` no matter what the client asks to run. Nothing the phone
|
||||
sends is interpreted as a command, so this needs no client-side
|
||||
"run this on connect" support - any plain SSH app lands on the picker.
|
||||
|
||||
It has to be the **same Unix user** that runs `dejima`: tmux sockets are
|
||||
per-user, and `dejima-attach` talks to the tmux server of whoever runs it.
|
||||
Options are per-key, so your normal key still gets a normal shell - worth
|
||||
confirming both after you add the line.
|
||||
|
||||
## Web front door
|
||||
|
||||
Needs `ttyd` **1.7.4 or newer**. `-W`/`--writable` arrived in 1.7.4, which
|
||||
inverted the old default (before it, the terminal was writable unless you
|
||||
passed `--readonly`), so an older build rejects the flag and
|
||||
`dejima-ttyd` fails at every start. Your distro's package may well be
|
||||
too old: Debian bookworm's is 1.7.3 and Ubuntu 22.04's is 1.6.3, neither of
|
||||
which has the flag - Ubuntu 24.04's 1.7.4 does. Check with `ttyd --version`,
|
||||
and take an upstream release binary if the packaged one is behind.
|
||||
|
||||
On Debian there is a middle option: sid packages **1.7.7**, and you can take
|
||||
that one package without moving the rest of the system to unstable. sid is
|
||||
not a configured source on a stable install, so add it - and pin it *below*
|
||||
your normal release, or the next `apt upgrade` follows it:
|
||||
|
||||
```sh
|
||||
echo 'deb http://deb.debian.org/debian unstable main' \
|
||||
| sudo tee /etc/apt/sources.list.d/sid.list
|
||||
|
||||
# without this pin the whole system drifts to unstable
|
||||
printf 'Package: *\nPin: release a=unstable\nPin-Priority: 10\n' \
|
||||
| sudo tee /etc/apt/preferences.d/99-sid
|
||||
|
||||
sudo apt update
|
||||
sudo apt install -t unstable ttyd # -t overrides the pin, for this package
|
||||
```
|
||||
|
||||
Read what `apt` proposes before agreeing to it. One package from sid drags
|
||||
its libraries along, and if the plan includes `libc6` you are no longer
|
||||
taking one package - back out and use an upstream release binary instead.
|
||||
`apt_preferences(5)` has the details.
|
||||
|
||||
**Installing the package opens a door of its own. Close it.** However you get
|
||||
`ttyd` from a distro repository - stable, sid, Ubuntu - the package ships a
|
||||
*system* service that systemd enables and starts on install. It serves a
|
||||
login prompt, and because it passes no `--interface` of its own it binds
|
||||
**every** interface, the bridge address a sandbox routes to included. It
|
||||
listens on `ttyd`'s default port, which is the port this feature defaults to
|
||||
as well.
|
||||
|
||||
Two consequences, and the second is the one that hides:
|
||||
|
||||
- A host login prompt is now reachable *from inside every container you run*,
|
||||
put there by the act of installing the package.
|
||||
- It holds the port, so `dejima-ttyd` fails to bind at every start while your
|
||||
reverse proxy proxies to the distro's `ttyd` instead. You get your own
|
||||
password prompt and then a host login shell, on the hostname you set up for
|
||||
dejima, with nothing along the way appearing broken.
|
||||
|
||||
```sh
|
||||
systemctl status ttyd.service # almost certainly active
|
||||
sudo systemctl disable --now ttyd.service
|
||||
sudo systemctl mask ttyd.service # so reinstalling cannot revive it
|
||||
```
|
||||
|
||||
`dejima-ttyd` refuses to start when anything already holds its port and
|
||||
names this as the likely cause, rather than leaving you with `ttyd`'s
|
||||
one-line bind error in a unit nobody is watching. That is a diagnostic, not a
|
||||
fix: the listener is there from the moment the package lands, whether or not
|
||||
you ever run `dejima-ttyd`.
|
||||
|
||||
`dejima-ttyd` reads `TTYD_PORT` and launches
|
||||
`ttyd --interface 127.0.0.1 --port <port> --check-origin --url-arg --writable dejima-attach`.
|
||||
Two of those are load-bearing:
|
||||
|
||||
- **`--check-origin`** is not tidiness. Without it `ttyd` does no origin
|
||||
validation at all, and browsers do not apply the same-origin policy to
|
||||
WebSockets - they do attach the cached credential for the target origin.
|
||||
Any page loaded in an authenticated operator's browser could open
|
||||
`wss://<front-door>/ws` and be handed this host shell, with no interaction
|
||||
beyond visiting the page. Proxy authentication cannot stop that; it is
|
||||
satisfied by the victim's own browser.
|
||||
- **`--url-arg`** is what lets the notification's deep link name a session
|
||||
(`?arg=<run-id>`). It reaches `dejima-attach` as argv, never as a shell
|
||||
word, and has to match the run-id shape before it is used for anything.
|
||||
|
||||
Install the unit by hand - it ships disabled, and `install.sh` will not do
|
||||
this for you:
|
||||
|
||||
```sh
|
||||
mkdir -p ~/.config/systemd/user
|
||||
cp systemd/dejima-ttyd.service ~/.config/systemd/user/
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable --now dejima-ttyd.service
|
||||
```
|
||||
|
||||
It is a **user** unit, not a system one, and it repeats no configuration:
|
||||
`ExecStart` is `%h/.local/bin/dejima-ttyd`, and the bind address and port
|
||||
live in the script and `remote.conf` so the two cannot drift.
|
||||
|
||||
Then put a reverse proxy in front of it. Adapt
|
||||
`docs/nginx-dejima.conf.example` - it will not load as written, deliberately,
|
||||
so it can never become a live config by being dropped into `sites-enabled`
|
||||
and forgotten. What matters in it:
|
||||
|
||||
- **Authentication on every server block.** `ttyd` is started with no
|
||||
credential of its own, because the loopback bind means only this host can
|
||||
reach it - the proxy is the entire gate. A block without `auth_basic` is
|
||||
an unauthenticated host shell on your network.
|
||||
- **A `default_server` that refuses.** nginx picks a server block by
|
||||
SNI/`Host`, and when nothing matches it serves the **default** for that
|
||||
listen address - which, if the front door is the only block on it, is the
|
||||
front door. `server_name` on its own therefore isolates nothing: the vhost
|
||||
answers to *every* name that resolves to that address, including the box's
|
||||
own hostname. The example ships a first block that is `default_server` and
|
||||
does nothing but `ssl_reject_handshake on;`, so only the name you chose
|
||||
gets through. If you already have a default server on that address, keep
|
||||
the one you have - just make sure it doesn't fall through to this vhost.
|
||||
- **TLS.** HTTP Basic sends the password on every request, and the terminal
|
||||
itself carries whatever the session is working on.
|
||||
- **`proxy_set_header Host $http_host;`** — `$http_host`, not `$host`.
|
||||
`--check-origin` compares the client's `Origin` against the `Host` header,
|
||||
and nginx's `$host` has the port stripped while `ttyd` keeps the port in
|
||||
what it compares unless it is 80 or 443. On a vhost listening anywhere
|
||||
else - 8443, say - `$host` makes `ttyd` compare `name:8443` against `name`
|
||||
and refuse every WebSocket: the page loads, the terminal never connects,
|
||||
and the only sign is a "refuse to serve WS client from different origin"
|
||||
line in `ttyd`'s log.
|
||||
- **A long `proxy_read_timeout`.** An attached terminal sits idle for hours;
|
||||
nginx's default is 60 seconds.
|
||||
|
||||
## Reading it on a phone
|
||||
|
||||
Out of the box the phone view is close to unusable: text renders far too
|
||||
small to read, and pinch-zooming leaves you panning around a giant canvas
|
||||
instead of reflowing anything. That is not a font setting you missed.
|
||||
|
||||
**`ttyd` serves no viewport meta tag** - not in 1.7.7, not on `main` at the
|
||||
time of writing. A mobile browser that finds no `<meta name="viewport">`
|
||||
assumes the page was written for a desktop, lays it out at a virtual width
|
||||
of around 980 CSS pixels, and scales the result down to fit the screen.
|
||||
Everything shrinks together, so raising the font size just gives you a
|
||||
larger font scaled down by the same factor. And a terminal is a fixed
|
||||
character grid on a canvas: zooming magnifies pixels, it does not re-wrap
|
||||
lines the way zooming a text page does.
|
||||
|
||||
The fix is to supply the tag the page is missing, which the proxy can do on
|
||||
the way past. `docs/nginx-dejima.conf.example` ships it:
|
||||
|
||||
```nginx
|
||||
sub_filter '<head>' '<head><meta name="viewport" content="width=device-width, initial-scale=1">';
|
||||
sub_filter_once on;
|
||||
proxy_set_header Accept-Encoding "";
|
||||
add_header Cache-Control "no-store" always;
|
||||
```
|
||||
|
||||
Now the browser lays out at the device's real width, xterm.js fits an honest
|
||||
column count to it, and rotating the phone reflows properly. Desktop
|
||||
browsers ignore `width=device-width`, so nothing changes there.
|
||||
|
||||
Three things that will waste your time if you skip them:
|
||||
|
||||
- Your nginx needs the sub module, and a missing one fails *quietly*. Check
|
||||
for it up front, and validate with **`nginx -t`** rather than trusting the
|
||||
reload - a reload whose config does not parse leaves the *old* config
|
||||
running while `systemctl reload nginx` still reports success, so an unknown
|
||||
`sub_filter` directive looks from the outside exactly like a directive that
|
||||
ran and did nothing.
|
||||
|
||||
```sh
|
||||
/usr/sbin/nginx -V 2>&1 | tr ' ' '\n' | grep http_sub_module
|
||||
sudo /usr/sbin/nginx -t
|
||||
```
|
||||
|
||||
The path is spelled out because Debian and Ubuntu install nginx to
|
||||
`/usr/sbin`, which is on root's `PATH` and not on yours - a bare `nginx -V`
|
||||
answers `command not found` on a box that is running nginx perfectly well.
|
||||
Debian's `nginx-light` is built without the module; `nginx-core`,
|
||||
`nginx-full` and `nginx-extras` have it.
|
||||
- `sub_filter` cannot rewrite a compressed response body and passes it
|
||||
through untouched, which looks exactly like the tag not working. Clearing
|
||||
`Accept-Encoding` toward the upstream is what stops that.
|
||||
- **Your phone may already be holding the un-injected page.** `ttyd` sends it
|
||||
with no `Cache-Control`, no `ETag` and no `Last-Modified`, and a response
|
||||
with neither freshness information nor a validator is cacheable for as long
|
||||
as the browser feels like it. Fine while the body is served verbatim; a trap
|
||||
once the proxy rewrites it, because the stale copy renders forever and the
|
||||
injection reads as broken long after it works. `add_header Cache-Control
|
||||
"no-store" always;` closes it going forward - to check *right now*, load the
|
||||
front door in a private tab, which bypasses the cache without clearing
|
||||
anything else.
|
||||
|
||||
Confirm it server-side before blaming the browser, or the other way round -
|
||||
the first 220 bytes of the response settle it, and the tag should sit ahead
|
||||
of `<meta charset>`:
|
||||
|
||||
```sh
|
||||
curl -sk -u USER:PASS https://<front-door>/ | head -c 220; echo
|
||||
```
|
||||
|
||||
If the text is still smaller than you want once the layout is honest, set
|
||||
`TTYD_FONT_SIZE` in `remote.conf` (8-72). Be aware of what it costs:
|
||||
**it applies to every client.** `ttyd` 1.7.7 accepts client options only at
|
||||
launch - the URL-query override that would let a deep link carry its own
|
||||
size landed upstream after 1.7.7 was cut - so there is no way from here to
|
||||
give a phone one size and the desktop another. A larger size also means
|
||||
fewer columns, and because attaching shares the window with your desktop
|
||||
session, tmux resizes that window to whichever client used it last. Start by
|
||||
fixing the viewport and only reach for this if you still need it.
|
||||
|
||||
## Typing on a phone
|
||||
|
||||
Text you already typed reappears a batch at a time, sometimes cascading into
|
||||
another batch. Backspacing clears it; a few characters later it happens
|
||||
again.
|
||||
|
||||
**It is the keyboard's autocorrect**, and two things locate it there. The
|
||||
duplicated characters are real input you can backspace over rather than a
|
||||
redraw artifact, and the WebSocket runs over TCP, which cannot deliver the
|
||||
same bytes twice - so the browser is emitting them. And it reproduces on
|
||||
demand: a long run of digits never duplicates, while ordinary words duplicate
|
||||
on every correction the keyboard makes - `fix` to `fox`, `lets` to `let's`.
|
||||
When a soft keyboard *replaces* a span it has already committed, xterm.js
|
||||
re-sends the whole contents of its hidden input element instead of the
|
||||
difference, so the entire line replays.
|
||||
|
||||
xterm.js already asks the keyboard not to do this - it sets `autocorrect`,
|
||||
`autocapitalize` and `spellcheck` to off on that element as it creates it -
|
||||
and Android ignores all three. This is a general `ttyd`/xterm.js interaction
|
||||
with predictive keyboards, and there is no lever here, in the proxy or in
|
||||
`dejima-ttyd`, that reaches it.
|
||||
|
||||
So use a keyboard with nothing to correct against. **Unexpected Keyboard**
|
||||
(F-Droid or Play Store) was written for Termux users and fits well: its word
|
||||
suggestions need a dictionary you download yourself, so a fresh install has
|
||||
nothing to autocorrect with, and Ctrl, Alt, Esc, Tab, the arrows and the
|
||||
function keys are all real keys, reached by swiping a key toward the corner
|
||||
the symbol is printed in. That second half matters more than the bug does -
|
||||
without Ctrl there is no tmux prefix, and Esc and the arrows are hard to do
|
||||
without in anything interactive. Android's keyboard switcher makes it a
|
||||
per-session choice rather than a replacement for the keyboard you use for
|
||||
everything else.
|
||||
|
||||
Turning autocorrect off in the keyboard you already have fixes it too, at the
|
||||
cost of losing the correction everywhere else.
|
||||
|
||||
## Behind another reverse proxy
|
||||
|
||||
Terminating TLS somewhere else and forwarding here is a supported shape, but
|
||||
read this first: it moves the front door out of the LAN threat model
|
||||
everything above is written against. If the outer proxy is internet-facing,
|
||||
`auth_basic` over TLS is now the only thing between the public internet and
|
||||
a host shell. Put something stronger in front of it, and take the
|
||||
[security notes](security.md) as the floor rather than the ceiling.
|
||||
|
||||
The mechanical part has four requirements at the outer hop, and each one
|
||||
fails differently:
|
||||
|
||||
```nginx
|
||||
proxy_ssl_server_name on; # else: TLS handshake refused
|
||||
proxy_ssl_name dejima.example.invalid;
|
||||
proxy_set_header Host dejima.example.invalid; # exactly once - see below
|
||||
proxy_http_version 1.1; # else: page loads, terminal never connects
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_read_timeout 1d; # else: dropped after 60s idle
|
||||
```
|
||||
|
||||
- **`proxy_ssl_server_name` is off by default**, so nginx proxying to an
|
||||
HTTPS upstream sends no SNI at all. The inner nginx sees a handshake with
|
||||
no name, hands it to the `default_server` - which exists precisely to
|
||||
refuse unrecognised names - and you get a 502 at the outer hop.
|
||||
- **Set `Host` exactly once.** `proxy_set_header` is additive within a
|
||||
configuration level rather than overriding, so declaring `Host` twice
|
||||
sends *two* `Host` headers and the inner nginx answers 400 to all of it.
|
||||
The easy way to do this by accident is `include proxy_params;` (Debian
|
||||
ships one that already sets `Host $http_host`) plus your own `Host` line
|
||||
in the same block.
|
||||
- **A literal, not `$host` or `$http_host`.** The value has to be the
|
||||
hostname the *browser* used, because that is what `ttyd`'s
|
||||
`--check-origin` compares the `Origin` against at the far end. A literal
|
||||
also guarantees it agrees with `proxy_ssl_name`.
|
||||
- **`Upgrade`/`Connection` and the timeout are needed at every hop**, not
|
||||
just the inner one. Miss them outside and the page loads while the
|
||||
terminal never connects - the same symptom the inner config produces, from
|
||||
a different place.
|
||||
|
||||
nginx logs the *reason* for a 400 at `info` level, which is below the
|
||||
default. When something here is wrong and the access log only says `400`,
|
||||
set `error_log /var/log/nginx/error.log info;` on the inner proxy, reproduce
|
||||
once, and it names the cause outright. Turn it back down afterwards. To
|
||||
establish which hop is at fault, bypass the outer one from the dejima box
|
||||
itself:
|
||||
|
||||
```sh
|
||||
curl -vk --resolve dejima.example.invalid:443:<dejima-box-lan-ip> \
|
||||
https://dejima.example.invalid/
|
||||
```
|
||||
|
||||
A `401` there means the inner config is correct and the fault is entirely in
|
||||
what the outer proxy sends.
|
||||
|
||||
## Notifications
|
||||
|
||||
`dejima` registers `hooks/dejima-notify.sh` as a `Notification` and
|
||||
`Stop` hook for a remote-enabled session - into the throwaway settings
|
||||
override layer it already generates for that session, never into your real
|
||||
`~/.claude/settings.json`, which every sandbox and your host's own Claude
|
||||
Code read directly. Your own `Notification` and `Stop` hooks keep running
|
||||
alongside it: Claude Code merges hooks across settings layers rather than
|
||||
letting the higher one replace the lower. That layer is mounted read-only, so a
|
||||
session cannot unregister its own hook. The hook writes one empty file, named `waiting` or
|
||||
`done`, into this run's own bind-mounted marker directory. No content, no
|
||||
stdin, no network.
|
||||
|
||||
On the host, `dejima-notify-watch` turns those markers into ntfy pushes.
|
||||
Same installation shape as above:
|
||||
|
||||
```sh
|
||||
cp systemd/dejima-notify-watch.service ~/.config/systemd/user/
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable --now dejima-notify-watch.service
|
||||
```
|
||||
|
||||
`inotify-tools` is optional - without `inotifywait` the watcher degrades to
|
||||
a roughly six-second poll rather than failing (a one-second floor under the
|
||||
loop plus a five-second sleep). Repeat markers for the same session and
|
||||
event inside 60 seconds are debounced (`DEJIMA_DEBOUNCE_SECONDS` overrides
|
||||
that).
|
||||
|
||||
One environment gotcha, because it fails silently: the watcher and
|
||||
`dejima` both resolve the marker root as `${XDG_RUNTIME_DIR:-/tmp}/dejima`,
|
||||
so they only meet when they agree on `XDG_RUNTIME_DIR`. systemd sets it for
|
||||
user units and a login shell gets it from pam_systemd, but a `dejima`
|
||||
run from a context that has neither - a bare `su`, a cron job, a tmux server
|
||||
started before login - writes its markers under `/tmp/dejima` while the unit
|
||||
watches `/run/user/<uid>/dejima`, and no push is ever sent, with nothing
|
||||
logged to say so. `systemctl --user show-environment` is where to check.
|
||||
|
||||
The same directory is why you probably want lingering on:
|
||||
|
||||
```sh
|
||||
loginctl enable-linger "$USER"
|
||||
```
|
||||
|
||||
Both units here are *user* units. Without lingering they are stopped at
|
||||
logout and `/run/user/<uid>` is destroyed with the session, taking the
|
||||
session registry and every marker with it - so a sandbox left running in a
|
||||
detached tmux session has no front door and sends no notification until you
|
||||
log in again. It fails closed rather than open (nothing is left listening
|
||||
unguarded), but it fails invisibly, and it is the same `XDG_RUNTIME_DIR`
|
||||
that the gotcha above is about.
|
||||
|
||||
**The marker directory is a write channel into your runtime tmpfs**, and
|
||||
nothing bounds it. `/run/user/<uid>` is RAM-backed and sized at a fraction
|
||||
of physical memory, and each session gets its own directory under it
|
||||
bind-mounted read-write, so a session that writes into `/run/dejima-notify`
|
||||
until it is full degrades your whole login session - D-Bus, the systemd user
|
||||
manager - and not just dejima. A session can already fill the workspace
|
||||
mount, so this is an increment rather than a new kind of exposure, but a
|
||||
RAM-backed filesystem fails harder and sooner than a disk. There is
|
||||
deliberately no quota here; `rm -rf` on that run's marker directory (or
|
||||
logging out, which discards the tmpfs) is the recovery.
|
||||
|
||||
**What a notification contains.** A title of `dejima: <project> needs input`
|
||||
(or `dejima: <project> finished`), a fixed one-line body, and - when
|
||||
`BASE_URL` is set - a click link of `<BASE_URL>/?arg=<run-id>` that opens the
|
||||
web front door already attached to that session. `<project>` is the
|
||||
workspace directory's name, taken from the host-side registry and reduced to
|
||||
`A-Za-z0-9._-`.
|
||||
|
||||
**And nothing about what Claude is actually asking.** That absence is
|
||||
deliberate, not a missing feature: your phone is outside the sandbox, so no
|
||||
sandbox-controlled bytes are allowed to reach it. The question itself is on
|
||||
the terminal you are about to attach to; the push exists only to tell you to
|
||||
go look.
|
||||
|
||||
The guarantee for that is host-side, and it is worth being precise about
|
||||
where it lives. The in-container hook's `waiting|done` whitelist is **not**
|
||||
it - a compromised session owns its own bind-mounted marker directory and
|
||||
can create any file name in it, hook or no hook. What holds is
|
||||
`dejima-notify-watch`: it iterates a literal two-name enum rather than
|
||||
reading the directory, so a name outside it is never stat'd, read or
|
||||
removed; it never reads marker *content*; and it consumes markers with
|
||||
`rm -f`, which unlinks a symlink instead of following it. Every byte it
|
||||
sends - title, body, headers, click URL - is composed from host data. That
|
||||
last point is what the enum is really for: ntfy's own headers include
|
||||
`X-Actions`, `X-Attach` and `X-Icon`, which can make the receiving phone
|
||||
issue an arbitrary HTTP request, so a single attacker-controlled header
|
||||
would turn it into a confused deputy inside your network.
|
||||
|
||||
**When no notification arrives.** Five things have to happen in order, and a
|
||||
break in any of them looks identical from the phone - which is to say, like
|
||||
nothing at all. Walk them from the inside out; each step tells you whether
|
||||
the next one is worth checking.
|
||||
|
||||
1. **Is the session registered?** Inside the sandbox, `echo $DEJIMA_RUN_ID`
|
||||
and `echo $DEJIMA_NOTIFY_DIR`. Both empty means `dejima` started this
|
||||
container with remote access off - `remote.conf` did not exist at launch,
|
||||
and no later change affects a session already running.
|
||||
2. **Is the hook registered?** The `Notification` and `Stop` entries live in
|
||||
the throwaway override layer, not in your real `settings.json`:
|
||||
`grep -c dejima-notify /etc/dejima-settings.json` from inside should be 2.
|
||||
That file is passed to Claude Code as `--settings` by `claude-wrapper.sh`;
|
||||
if it is missing entirely, this container was not started by `dejima`.
|
||||
3. **Does the marker reach the host?** This is the one worth knowing, because
|
||||
it splits the chain cleanly in half. From inside the sandbox:
|
||||
|
||||
```sh
|
||||
: > "$DEJIMA_NOTIFY_DIR/waiting" && sleep 2 && ls "$DEJIMA_NOTIFY_DIR"
|
||||
```
|
||||
|
||||
The file vanishing means the host watcher is running and consuming, and
|
||||
everything below this line is host-side. The file remaining means
|
||||
`dejima-notify-watch` is not running - `systemctl --user status
|
||||
dejima-notify-watch`, and check `enable-linger` if it died with your
|
||||
session.
|
||||
4. **Did the push succeed?** `curl`'s stderr goes to the watcher's journal:
|
||||
`journalctl --user -u dejima-notify-watch -n 50`. A `push failed` line
|
||||
with a 404 usually means `NTFY_URL` names the server but no topic; a 401
|
||||
or 403 means the instance wants credentials, which the URL can carry as
|
||||
`https://<user>:<token>@<host>/<topic>` - nothing here sends an auth
|
||||
header of its own.
|
||||
5. **Is the phone subscribed to the topic the config names?** Last in the
|
||||
list and first to check in practice, since it is the only link with no
|
||||
log anywhere. The topic in `NTFY_URL` and the one in the app have to be
|
||||
the same string.
|
||||
|
||||
Note also that step 3 sends a real notification when the chain is healthy,
|
||||
which makes it a test of the whole path rather than only of the watcher.
|
||||
Repeat runs within `DEJIMA_DEBOUNCE_SECONDS` (60 by default) are consumed
|
||||
without pushing, so leave a minute between attempts or you will diagnose the
|
||||
debounce instead.
|
||||
|
||||
## Attaching
|
||||
|
||||
`dejima-attach` with no argument gives a numbered picker - digits rather
|
||||
than arrow keys, because a phone soft keyboard handles those far better.
|
||||
`--list` prints one tab-separated row per live session for scripting, and a
|
||||
run-id argument attaches directly, which is what the deep link and `ttyd`'s
|
||||
`--url-arg` produce. Rows carry the run id, the tmux label, the workspace
|
||||
name, the session's age, its state, and the container id.
|
||||
|
||||
The picker is coloured (gruvbox) when stdout is a terminal that says it can
|
||||
show it; `NO_COLOR=1` or a `dumb` `$TERM` turns that off, along with the
|
||||
box-drawing glyphs. `--list` is never coloured, terminal or not - the picker
|
||||
parses those rows itself and the README calls them scriptable.
|
||||
|
||||
Three states, from a registry `dejima` writes on launch and removes on
|
||||
exit:
|
||||
|
||||
- `attachable` - a live tmux pane to join.
|
||||
- `stale` - registered, but its pane is gone. Listed rather than hidden,
|
||||
since the registry is a hint and not truth: the exit trap doesn't run
|
||||
under `SIGKILL` or a host crash.
|
||||
- `no-tmux` - the sandbox was started outside tmux, so there is no pane to
|
||||
attach to. Still listed, deliberately, rather than silently omitted.
|
||||
|
||||
Entries whose container has gone away are pruned as they're listed. When
|
||||
Docker can't be asked at all, nothing is pruned and the container column
|
||||
reads `?` - an unanswerable question is never treated as a "no".
|
||||
|
||||
Attaching creates a **grouped** tmux session (`dejima-<run-id>`) rather than
|
||||
joining yours. Grouped sessions share windows but track their own current
|
||||
window, so the phone can sit on one sandbox while the desktop sits on
|
||||
another, and the throwaway carries `destroy-unattached`, so it disposes of
|
||||
itself when the phone detaches or the connection drops.
|
||||
|
||||
**You get the whole window list, not just the one sandbox.** Sharing windows
|
||||
is what "grouped" means: you land on the right window, and every other
|
||||
window on your tmux server is one tab away - other sandboxes, and whatever
|
||||
non-dejima windows you have open. Worth knowing before you first connect,
|
||||
because it is a surprise otherwise.
|
||||
|
||||
It is not a containment boundary being crossed, which is why it is left as
|
||||
it is. Whoever reaches this terminal is one prefix key away from a brand new
|
||||
host shell no matter what the tab bar shows - that is the fact this entire
|
||||
feature is designed around, and tmux offers no per-client way to take the
|
||||
prefix away (key tables are server-global, so disabling it for the phone
|
||||
would disable it for your desktop too). Restricting the view would be
|
||||
tidier; it would not make a reachable front door any less of a host shell.
|
||||
Authentication and the bind address are the controls that matter here.
|
||||
|
||||
Sizing is left to tmux on purpose: `dejima-attach` sets no `window-size`.
|
||||
tmux's own default (`latest`, since 3.1) already keeps a small phone client
|
||||
from shrinking the desktop's view, and the option's only scopes are the
|
||||
window - which a grouped session *shares* with yours, so setting it there
|
||||
would permanently overwrite your choice with nothing to restore afterwards -
|
||||
and global. An operator who deliberately set `smallest` keeps it.
|
||||
@@ -0,0 +1,408 @@
|
||||
# Security notes
|
||||
|
||||
[← dejima README](../README.md)
|
||||
|
||||
**Threat model, honestly stated:** this reduces the blast radius of a
|
||||
misbehaving or prompt-injected coding agent - it does not make it safe to
|
||||
point at arbitrary untrusted or adversarial input, and it does not turn
|
||||
`--dangerously-skip-permissions` into something without real risk. Point it
|
||||
at repositories you already trust. Everything below is about how bad a
|
||||
compromised session *inside* that trust boundary can get, not a claim that
|
||||
one can't happen.
|
||||
|
||||
- `--dangerously-skip-permissions` removes your chance to review tool calls
|
||||
before they run. Only point this at trusted repositories.
|
||||
- The mounted project itself has **no write protection**. `deny-dangerous-
|
||||
bash.sh`, `git-push-guard.sh` and `gitea-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.
|
||||
- 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
|
||||
the firewall doesn't see it either. Same underlying persistence pattern
|
||||
as the `~/.claude/hooks` and `~/.claude.json` 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 now mounted read-only
|
||||
(same pattern as the `~/.claude` code directories below), so planting a hook
|
||||
file directly is closed - accepted breakage: installing hooks
|
||||
*in-session* (`husky`, `pre-commit install`) now fails, which is the
|
||||
point. `core.hooksPath` and `.claude/settings.local.json` aren't
|
||||
overlaid (they're config, not fixed paths a static mount can cover) and
|
||||
remain fully writable; the exit-time audit below reports changes to
|
||||
either, but only after the session ends - it doesn't stop them.
|
||||
- `bin/dejima-audit` snapshots host-persistence surfaces before each
|
||||
run and diffs them after the container exits: `.git/config` (and worktree
|
||||
`config.worktree`) keys that run a command on the next `git` -
|
||||
`core.hooksPath`, `core.fsmonitor`, `core.sshCommand`,
|
||||
`credential.helper`, `core.pager`, `pager.*`, `core.editor`,
|
||||
`sequence.editor`, `gpg.program`, `diff.external`, `merge.*.driver`,
|
||||
`diff.*.command`, `filter.*.smudge`/`clean`/`process`, `diff.*.textconv`,
|
||||
`include.path`/`includeif.*.path` (config indirection - these can point at
|
||||
a planted file that itself sets an exec key, so the exec key never
|
||||
appears in `.git/config` directly), `!`-shell aliases - plus content
|
||||
changes under the workspace's `.claude/`, `.mcp.json`, `.envrc`,
|
||||
`.vscode/`, and `.git/hooks/` (symlinks included - a repointed symlink is
|
||||
hashed by its target, not just its content), plus cross-project writes
|
||||
under `~/.claude/projects` (see below). When the mounted workspace *is*
|
||||
this repo's own sandbox itself (see the trusted-computing-base note
|
||||
further below), it also diffs content hashes of the sandbox's own trusted
|
||||
computing base - `bin/`, `hooks/`, `docker/`, `install.sh`,
|
||||
`test/` - under that workspace. It's **advisory only**: it fires *after*
|
||||
the session has already ended, so a report of a planted `core.hooksPath`
|
||||
or hook file is something to read and clean up *before* the next `git`
|
||||
or `claude` in that repo, not something that was blocked. It's silent when nothing
|
||||
changed, and never affects the session's own exit code even if it can't
|
||||
complete. It doesn't cover `$SANDBOX_SHARE_DIR` (see "Shared scratch
|
||||
space") - that hand-off path stays unaudited - and its coverage is a
|
||||
fixed list of known Claude Code config surfaces, not a general integrity
|
||||
check, so a future config surface this list hasn't been taught about yet
|
||||
would go unreported. **Its diff is time-windowed, not session-attributed:**
|
||||
a finding means one of these paths changed while the session was running,
|
||||
not that the session made the change. A concurrent dejima or Claude Code
|
||||
session, another host process, or you editing the same files by hand
|
||||
during the run all land in the same diff and get reported identically -
|
||||
the tool has no way to tell them apart.
|
||||
- Report findings name a path or config key that a session may itself have
|
||||
chosen, so those names reach your terminal as untrusted text. One that
|
||||
contains an ASCII control character, a C1 control, or a Unicode bidi
|
||||
override - which between them can scroll, blank, or visually reorder what
|
||||
you read, and can split a name across the audit's own records - is
|
||||
written hex-encoded behind a `{hex}` marker instead, and decodes with
|
||||
`printf '%b' "$(sed 's/../\\x&/g' <<< HEX)"`. Ordinary names are printed
|
||||
as-is, so the marker appearing at all is itself worth a second look.
|
||||
The encoded form is what `dejima-audit ack` records in the baseline, so
|
||||
what you acknowledge is the file that is actually on disk rather than a
|
||||
readable approximation of its name.
|
||||
- `~/.claude/projects` (session transcripts and per-project memory) is
|
||||
**not** mounted read-only, unlike the code-carrying `~/.claude`
|
||||
directories - deliberately. Scoping a read-only mount to "the
|
||||
current project's slug"
|
||||
would break the sandbox's own worktree workflow: a session running in
|
||||
`WORKSPACE/.worktrees/<branch>` writes to a *different* projects slug
|
||||
than the parent repo, so a slug-scoped overlay would either block that
|
||||
legitimate write or have to be widened until it stopped protecting
|
||||
anything. Instead, cross-project writes are **detected, not prevented**:
|
||||
the exit-time audit above flags any write during the session to another
|
||||
project's `~/.claude/projects/<slug>` directory, excluding the current
|
||||
project's own slug and its worktree children. That's after-the-fact
|
||||
reporting of transcript/memory poisoning in *other* projects, not a
|
||||
block on it happening. Residual even within that: the current project's
|
||||
own transcripts/memory stay fully writable throughout, since Claude Code
|
||||
needs that for host-side `claude --resume` to work; and detection keys
|
||||
off each slug's max file mtime, so an edit to a file that isn't already
|
||||
that project's newest doesn't advance the max and goes unflagged. A
|
||||
read-only mount for `projects/` remains a possible future option if
|
||||
Claude Code is confirmed to fail cleanly (not hang, as `~/.claude.json`
|
||||
did) when a write to it is blocked. This is also the sharpest case of
|
||||
the time-window caveat above: `~/.claude/projects` is a single
|
||||
directory shared by *every* Claude Code session on the host, not scoped
|
||||
to the one being audited, so an entirely innocent concurrent session
|
||||
working on some unrelated project can produce a `CROSS-PROJECT` finding
|
||||
here that reads like the audited session poisoned another project's
|
||||
history - it didn't, it just happened to be running at the same time.
|
||||
- Allowing egress to a domain at all creates a channel for exfiltrating data
|
||||
through *that service's own request surface* (query strings, lookups,
|
||||
etc.), not just to a literal attacker-controlled destination. Domain-level
|
||||
allowlisting closes off reaching somewhere new; it can't inspect or
|
||||
restrict what gets sent to somewhere already trusted for normal operation
|
||||
(installing packages, calling the model API). That's a structural limit of
|
||||
allowlist-only firewalls in general, not something specific to this setup,
|
||||
and nothing here addresses it.
|
||||
- This is container-level isolation (Linux namespaces plus a small
|
||||
capability set), not VM-level isolation - no gVisor/Firecracker or
|
||||
similar. It shares the host kernel, so a container-escape vulnerability
|
||||
would defeat every other control described here at once.
|
||||
- Most of what this document asserts is re-checked by the `test/` suite on
|
||||
every change - over 300 bats tests across six files, and `runtime.bats`
|
||||
builds the image and asserts against a real running container rather than
|
||||
a mock: IPv6 blocked, a non-allowlisted domain refused by the DNS proxy
|
||||
and a non-allowlisted destination unreachable, `node` unable to re-run
|
||||
`init-firewall.sh` to widen the allowlist, no working `sudo`. Coverage is
|
||||
not the same as completeness, though. The reasoning in this document is
|
||||
judgement rather than assertions, nothing tests it, and a property nobody
|
||||
thought to write a test for can still regress quietly. A missing `ipset`
|
||||
dedup flag broke firewall startup entirely - twice, on two different
|
||||
ipsets - before anyone caught it; there is a test asserting every
|
||||
`ipset add` is duplicate-safe now, which is the pattern to expect. Each
|
||||
of these tests exists because something was got wrong first.
|
||||
- The Anthropic OAuth credential (`~/.claude/.credentials.json`) never
|
||||
enters the container. It simply isn't mounted: `~/.claude` is not shared
|
||||
as a whole (see the mount inventory below), so there is no path from
|
||||
inside the container to that file. `dejima` starts a host-side proxy
|
||||
(`bin/dejima-anthropic-proxy`) on a per-session unix socket and mounts a
|
||||
fake credential in place of the real one - the two secrets are
|
||||
placeholders, while `scopes`/`subscriptionType`/`rateLimitTier`/`clientId`
|
||||
are copied from the real file so the session's own UI stays accurate. Its
|
||||
expiry is set a year out rather than mirrored, deliberately: a near-term
|
||||
one would make Claude Code refresh on its own, with a refresh token that
|
||||
isn't real, and log the session out. Inside the container,
|
||||
`entrypoint.sh` points `api.anthropic.com` at a loopback TLS terminator
|
||||
(`anthropic-proxy-forwarder.js`) whose certificate is generated at startup
|
||||
and trusted only there, and which pipes raw bytes into the proxy's socket.
|
||||
The proxy injects the real access token per request and owns OAuth refresh
|
||||
- rotated tokens are written back to the real host file atomically, so
|
||||
host-side sessions stay logged in. Claude Code itself is configured
|
||||
exactly as it would be on the host: no `ANTHROPIC_BASE_URL`, no
|
||||
`ANTHROPIC_AUTH_TOKEN`, nothing that marks the session as unusual to the
|
||||
client. The proxy then forwards the client's header block verbatim -
|
||||
same names, same casing, same order, repeats intact, and no
|
||||
`Accept-Encoding` it didn't send - changing only the `Authorization`
|
||||
value, and `anthropic-beta` if the OAuth value isn't already there. Left
|
||||
to itself, Python's `http.client` would have rewritten all of that into
|
||||
its own house style and moved the fingerprint from the client to the
|
||||
wire. What it cannot disguise is that the connection is made by CPython's
|
||||
`ssl` rather than by Node: the TLS handshake (JA3/JA4) and the absence of
|
||||
connection reuse still distinguish it at that layer. What remains is
|
||||
session-scoped: a
|
||||
compromised session can still *use* the model API as you while it runs
|
||||
(that's inherent to the sandbox working at all), but it can no longer read
|
||||
a durable credential and exfiltrate it for offline use via the other
|
||||
allowed egress destinations (npm, GitHub, your Gitea host). Caveats:
|
||||
`/login` inside the container doesn't work while the proxy is on; the
|
||||
refresh flow mirrors a Claude Code internal and may break if upstream
|
||||
changes it, in which case the proxy fails loudly (launch abort, 502s on
|
||||
API calls) rather than silently; and a 401 that does reach the container
|
||||
can make Claude Code clear the fake credential file, showing a logged-out
|
||||
session - harmless, since it's a host temp file, and rare, since the proxy
|
||||
absorbs 401s with its own refresh-and-retry.
|
||||
`SANDBOX_CREDENTIAL_PROXY=0` is the kill switch, restoring the previous
|
||||
behavior - the credential mounted into the container explicitly, readable
|
||||
by a compromised session. Setups without a `claudeAiOauth` block (API-key
|
||||
auth) skip the proxy automatically; there's no stored subscription
|
||||
credential to protect.
|
||||
- **If TLS interception stops working.** The container reaches the API by
|
||||
resolving `api.anthropic.com` to the loopback terminator above, whose
|
||||
certificate is trusted only inside the container. If Claude Code ever pins
|
||||
certificates for that host, the handshake starts failing - typically right
|
||||
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`
|
||||
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 `SANDBOX_CREDENTIAL_PROXY=0`.** That kill switch mounts the real,
|
||||
durable OAuth credential into a container a compromised session can read,
|
||||
which is the single thing this whole mechanism exists to prevent.
|
||||
Certificate pinning breaks how Claude Code is *addressed*; it has no
|
||||
bearing on whether the credential can be kept on the host. Work down this
|
||||
ladder instead - the first two rungs keep the credential out of the
|
||||
container, and only the "looks like an ordinary session" property degrades.
|
||||
First, `SANDBOX_ANTHROPIC_TRANSPORT=loopback`: a plaintext loopback
|
||||
forwarder and an explicit `ANTHROPIC_BASE_URL=http://127.0.0.1:18300`, the
|
||||
topology this sandbox used before the TLS terminator existed, so pinning
|
||||
cannot apply at all. It costs the base-URL fingerprint, two attribution
|
||||
fields in the `x-anthropic-billing-header`, and `/remote-control`. Second,
|
||||
if even that fails, `ANTHROPIC_UNIX_SOCKET` pointed at the bind-mounted
|
||||
socket together with `ANTHROPIC_BASE_URL=http://api.anthropic.com`:
|
||||
plaintext HTTP straight over the socket, bypassing TLS entirely while
|
||||
keeping the first-party base-URL check truthy. That rung is a direction,
|
||||
not a switch: nothing in this repo implements or tests it, both variables
|
||||
have to be passed through `docker-compose.yml` by hand, and it depends on
|
||||
Claude Code's own support for `ANTHROPIC_UNIX_SOCKET`. It also costs more in
|
||||
appearance than rung one: it classifies the session as host-orchestrated
|
||||
and emits its own analytics event. Only if both fail, and knowing what it
|
||||
gives up, `SANDBOX_CREDENTIAL_PROXY=0` - the one rung that surrenders the
|
||||
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.
|
||||
- `~/.claude` is **not** shared with the container as a whole. Its contents
|
||||
are enumerated in `docker-compose.yml`, in three classes, and anything not
|
||||
listed is container-local: writable during the session and gone with the
|
||||
container.
|
||||
- **Read-write, because it has to survive the session**: `projects/`,
|
||||
`file-history/`, `backups/`, `tasks/`, `plans/`, `sessions/`. All
|
||||
directories, which is what makes them safe (see the detachment note
|
||||
below).
|
||||
- **Read-only**: `plugins/`, `hooks/`, `skills/`, `commands/`, `agents/`,
|
||||
`output-styles/`, plus the symlink-target directories `dejima`
|
||||
discovers at launch. Hooks execute automatically with no separate
|
||||
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
|
||||
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
|
||||
host compromise. They may not exist yet on a given host, so `dejima`
|
||||
pre-creates them at launch (empty is fine) - Docker can only mount a
|
||||
directory that already exists, and without pre-creation a compromised
|
||||
session could create and populate one itself.
|
||||
- **Copied in, not mounted**: `settings.json`, `CLAUDE.md`, and
|
||||
`statusline.sh` are mounted read-only at `/etc/dejima-claude/` and
|
||||
installed into `~/.claude` by `entrypoint.sh` as root-owned, unwritable
|
||||
files. A session can still unlink and replace them - `~/.claude` belongs
|
||||
to it - but only its own copy, which was the whole point of making them
|
||||
read-only.
|
||||
|
||||
The reason for the split is that a **single-file bind mount silently
|
||||
detaches**. It is pinned to the dentry at its mount point, so when the
|
||||
host replaces the underlying file - the ordinary atomic
|
||||
write-temp-then-`rename`, performed by Claude Code itself or by any sync
|
||||
daemon watching `~/.claude` - that dentry is unlinked and the mount goes
|
||||
away with no error anywhere. `docker inspect` still lists it;
|
||||
`/proc/self/mountinfo` inside the container no longer does. This was
|
||||
found the hard way three times: the generated settings overlay reverting
|
||||
mid-session (#47), every other file overlay carrying the same latent bug
|
||||
(#48), and the mask over `.credentials.json` detaching on the proxy's own
|
||||
first token refresh, exposing the operator's real OAuth access *and*
|
||||
refresh tokens to the sandbox (#50). Directory mounts are immune, because
|
||||
a rename replaces an entry *within* the mounted directory rather than the
|
||||
mount point itself. Enumerating also changes the failure mode: with no
|
||||
read-write parent underneath, a mount that somehow did detach falls
|
||||
through to an empty container-local path rather than to the host's real
|
||||
file. The cost is that anything a future Claude Code version adds under
|
||||
`~/.claude` is container-local until it is listed here - it works during
|
||||
the session but doesn't persist, which is the safer direction to fail.
|
||||
- `~/.claude/statusline.sh` (referenced by `settings.json`'s
|
||||
`statusLine.command` and executed on the host) is one of the copied-in
|
||||
files above, and the case that shows why copying beats mounting. It used
|
||||
to be bind-mounted read-only onto its own path, which - **confirmed
|
||||
against a live container** - blocked replacement only when
|
||||
`statusline.sh` was a plain regular file. When it was a symlink (the
|
||||
common case, into a config checkout elsewhere), Docker/runc resolved the
|
||||
bind-mount destination through the symlink to its target, so the symlink
|
||||
entry itself stayed on the writable `~/.claude` mount and remained
|
||||
replaceable, and detection was the only compensating control. Staging
|
||||
turns that same symlink resolution into the fix: it happens on the
|
||||
*source* side, so what gets copied in is the real content and the copy
|
||||
has no route back to the host (`test/runtime.bats`'s statusline.sh
|
||||
tests). `bin/dejima-audit` still hashes `statusline.sh` on the host
|
||||
(following a symlink to its target *path string*, so a repointed or
|
||||
replaced symlink is caught even though its own inode doesn't change) and
|
||||
reports a change as a host-code-execution finding when the session exits.
|
||||
- `~/.claude.json` mixes that same kind of live state (project registry,
|
||||
usage stats, caches) with config (`mcpServers`). Unlike the four above,
|
||||
it can't just be layered read-only: Claude Code does an atomic
|
||||
write-temp-then-rename against it during normal startup, before login
|
||||
even completes, and a read-only file makes that **hang** instead of
|
||||
failing cleanly (confirmed against a real read-only copy of this file -
|
||||
upstream bug: [anthropics/claude-code#29162](https://github.com/anthropics/claude-code/issues/29162)).
|
||||
Instead, `dejima` generates a throwaway copy of the file at
|
||||
launch (seeded from the real file, same "generate a sandbox-specific
|
||||
file on the host" pattern as the generated settings override layer)
|
||||
and mounts *that* read-write in its place; the copy is discarded,
|
||||
unmodified in the real file, when the session ends. A compromised
|
||||
session can add whatever it wants to `mcpServers` (or anything else in
|
||||
the file) - it never reaches the real `~/.claude.json`, so there's
|
||||
nothing to persist. This also means multiple concurrent sandbox sessions
|
||||
never contend over the same file - each gets its own independent copy.
|
||||
|
||||
### Throwaway ~/.claude.json: what doesn't carry over
|
||||
|
||||
Because nothing writes back, anything a *sandboxed* session would
|
||||
normally have caused Claude Code to record into `~/.claude.json` is lost
|
||||
when that session ends, instead of showing up in a later host-side
|
||||
`claude` run. Concretely, as of the fields this file holds today:
|
||||
|
||||
- **Per-project trust/approval state** - `hasTrustDialogAccepted`,
|
||||
`allowedTools`, `enabledMcpjsonServers`/`disabledMcpjsonServers` - for
|
||||
any project directory opened only inside the sandbox. Opening that same
|
||||
project on the host later re-prompts for whatever it would have
|
||||
prompted for outside the sandbox too. Largely moot today since the
|
||||
sandbox always runs `--dangerously-skip-permissions`, but the trust
|
||||
dialog is a separate gate from tool-permission prompts.
|
||||
- **`mcpServers` additions or edits** - the point of this fix, not an
|
||||
incidental side effect. Nothing added or changed here during a
|
||||
sandboxed session ever reaches the real file.
|
||||
- **Session/usage telemetry** - `lastCost`, `lastDuration`,
|
||||
`lastTotalInputTokens`/`lastTotalOutputTokens`, `lastModelUsage`,
|
||||
`lastSessionId`, `mcpContextUris`, and similar per-project fields.
|
||||
Sandbox activity won't show up in host-side usage totals or in
|
||||
`claude --resume`'s "last session" metadata for that project.
|
||||
- **Global counters and caches** - `numStartups`, `firstStartTime`,
|
||||
`cachedGrowthBookFeatures`, `changelogLastFetched`,
|
||||
`announcementImpressions`, `tipsHistory`, `feedbackSurveyState`, and
|
||||
similar top-level bookkeeping. Purely cosmetic - these get refetched or
|
||||
recomputed as needed; sandbox runs just don't contribute to the host's
|
||||
copies of them.
|
||||
- **`oauthAccount`/`userID`/`machineID`** are read fresh from the real
|
||||
file at the start of every sandbox launch, so authentication carries in
|
||||
normally each time; anything that changes about them *during* a
|
||||
sandboxed session (e.g. a token refresh) doesn't carry back out. Worth
|
||||
re-checking if a future Claude Code version starts relying on that for
|
||||
normal operation.
|
||||
- DNS resolution itself is allowlisted (see "DNS is allowlisted too" in the
|
||||
README),
|
||||
not just the IP connections made afterward, closing off exfiltration via
|
||||
encoding data into query names to an attacker-controlled domain - verified
|
||||
a non-allowlisted domain gets refused outright (`status: REFUSED`) rather
|
||||
than resolving and then only failing to connect.
|
||||
- Don't mount other host secrets (`~/.ssh` private keys, cloud credential
|
||||
files) into the container. Git push auth goes through the forwarded SSH
|
||||
agent instead, which never exposes the key material itself - though note
|
||||
the agent will sign for *any* Gitea repo your key has access to, not just
|
||||
the mounted project, and the firewall's Gitea allowlist is host-based
|
||||
rather than repo-scoped, so a compromised session can still reach (and
|
||||
push to) other repos on that host you have access to. This doesn't extend
|
||||
to GitHub by default: port 22 there is blocked unless `ALLOW_GITHUB_PUSH=1`
|
||||
is set, so the forwarded agent has no transport to use for it at all
|
||||
otherwise (see "GitHub push: blocked by default, optional opt-in" above).
|
||||
- `SANDBOX_SSH_KEY` (see "Headless git push without agent forwarding") is a
|
||||
second, independent credential-loading path with the same "signs for any
|
||||
Gitea repo it has access to" shape as the forwarded agent above - the
|
||||
mitigation is that it's meant to be a dedicated deploy key scoped to only
|
||||
the repos that actually need push access, not your personal identity.
|
||||
That scoping is a convention, not something enforced here: nothing checks
|
||||
what `SANDBOX_SSH_KEY` actually points at, so pointing it at your real
|
||||
`~/.ssh/id_ed25519`/`id_rsa` instead of a dedicated key would load your
|
||||
full personal identity into the container for the whole session (`bin/
|
||||
dejima` warns if this looks like it might be happening - see
|
||||
below - but doesn't block it). The key source itself is mounted read-only
|
||||
at `/root/...`, not under the sandbox user's own home, specifically so
|
||||
that user (which the session actually runs as) has no path to read the
|
||||
raw key material - only the agent socket it's loaded into.
|
||||
- The gitconfig copy `dejima` generates (see that mount's
|
||||
comment in `docker-compose.yml`) carries over whatever's in your real
|
||||
`~/.gitconfig` verbatim. If yours has inline credentials (a
|
||||
`[credential]` token, a `url.<token@>.insteadOf` rewrite, a
|
||||
`credential.helper store` path), those enter the container along with
|
||||
it - an unadvertised secret-carrying path distinct from the
|
||||
SSH-agent/token story documented above. Keep credentials out of your
|
||||
global gitconfig, or expect them to be reachable from inside the
|
||||
sandbox.
|
||||
- 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 `$SANDBOX_CMD`. 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.
|
||||
- The approval store (`${DEJIMA_CONFIG_DIR:-$HOME/.config/dejima}`,
|
||||
see "Global and per-project domains and dependencies" above) lives outside
|
||||
this repo, and outside every project mount, by default - unlike a
|
||||
project's own `.dejima/` declaration files, it's never
|
||||
bind-mounted into the container, so a compromised
|
||||
session has no path to write to it at all, let alone plant something for
|
||||
the *next* launch to pick up. Its files are read by a small
|
||||
line-oriented parser (`approved_entries` in
|
||||
`bin/dejima-project-lib.sh`), never sourced or executed, and are
|
||||
written only by the CLIs (`dejima-allow`, `dejima-dep`,
|
||||
`dejima-project`) a human runs on the host. `dejima`
|
||||
warns at launch whenever the resolved store path — default or overridden
|
||||
via `DEJIMA_CONFIG_DIR` — lands under the mounted workspace,
|
||||
rather than silently trusting it.
|
||||
- When the mounted workspace *is* this repo itself, `dejima`
|
||||
warns at launch that the repo's own trusted computing base - `bin/`,
|
||||
`hooks/`, `docker/`, `install.sh` (also `test/`, per the
|
||||
exit-time audit's exact TCB path list above) - is writable for the
|
||||
session (it's inside the project mount, which has no write protection,
|
||||
per above), and that the exit-time audit will report any changes to it.
|
||||
That's a warning and a post-hoc report, not a block: a compromised
|
||||
session can still edit those files during the run, and whatever it
|
||||
changes takes effect the *next* time `dejima` itself runs,
|
||||
not the current one.
|
||||
Executable
+35
@@ -0,0 +1,35 @@
|
||||
#!/usr/bin/env bash
|
||||
# Notification/Stop hook. bin/dejima registers it, per session, into the
|
||||
# throwaway settings.json copy it generates for a session that has remote
|
||||
# access enabled, and nowhere else. Every sandbox gets such a copy, derived
|
||||
# from the operator's real settings.json; registering it in that real file
|
||||
# instead would put this hook into all of them - and into the host's own
|
||||
# Claude Code, which reads that file directly - where there is no marker
|
||||
# directory to write into. The copy is mounted read-only (see
|
||||
# docker-compose.yml), so a session cannot unregister its own hook.
|
||||
#
|
||||
# It writes one empty file whose NAME is its entire meaning. No content, no
|
||||
# stdin, no network. The host-side watcher composes the whole notification
|
||||
# from host data, so not one byte from inside the sandbox reaches the
|
||||
# operator's phone. Anything more expressive here would be an injection
|
||||
# channel into a device that sits outside the sandbox - see
|
||||
# docs/remote-access.md on why the phone is the thing being protected.
|
||||
set -euo pipefail
|
||||
|
||||
# A whitelist, not a filter: an unrecognized event creates nothing at all.
|
||||
# Sanitizing an arbitrary name into a marker would make the file name a
|
||||
# channel the sandbox controls, which is the one thing this hook must not be.
|
||||
case "${1:-}" in
|
||||
waiting|done) ;;
|
||||
*) exit 0 ;;
|
||||
esac
|
||||
|
||||
# Absent outside a remote-enabled session, and the directory is a bind mount
|
||||
# that only exists there. Exit quietly rather than fail: a hook that errored
|
||||
# in an ordinary sandbox would surface on every notification, and there is
|
||||
# nothing for it to do. Never created here either - the host owns that
|
||||
# directory's lifetime (bin/dejima makes it and rm -rf's it on exit).
|
||||
[ -n "${DEJIMA_NOTIFY_DIR:-}" ] || exit 0
|
||||
[ -d "$DEJIMA_NOTIFY_DIR" ] || exit 0
|
||||
|
||||
: > "$DEJIMA_NOTIFY_DIR/$1"
|
||||
Executable
+36
@@ -0,0 +1,36 @@
|
||||
#!/usr/bin/env bash
|
||||
# PreToolUse hook: inside dejima, block `gitea-pr merge`. The sandbox
|
||||
# workflow is feature branch -> `gitea-pr create` -> a human merges; what
|
||||
# lands on main is an operator decision, exactly as it already is for
|
||||
# `git push` (see git-push-guard.sh, whose no-direct-to-main rule this is
|
||||
# the API-side counterpart to - a merge reaches main without a push, so that
|
||||
# guard never sees it).
|
||||
#
|
||||
# Deliberately not conditional on intent. A hook is handed a command, not a
|
||||
# conversation, so it cannot tell an instructed merge from an unprompted one;
|
||||
# the only honest place to draw the line is the context, and the context that
|
||||
# needs the line is this one - headless unattended runs under
|
||||
# --dangerously-skip-permissions, where nobody is watching the turn happen.
|
||||
# On the host the hook is a no-op and an operator can still ask for a merge.
|
||||
#
|
||||
# Like its siblings this is a fast-fail guard against an overeager agent, not
|
||||
# a hard security boundary: a sandbox session has full shell access and the
|
||||
# Gitea token in its environment, so it could call the merge endpoint with
|
||||
# curl directly. It stops the mistake, not the attack.
|
||||
|
||||
if [ -z "${DEJIMA:-}" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
command=$(jq -r '.tool_input.command // empty')
|
||||
|
||||
# The leading class includes '/' so a path-qualified call is caught too -
|
||||
# /usr/local/bin/gitea-pr is how the image installs it, and is what an agent
|
||||
# reaching past a shadowed PATH would type. Word-splitting rather than a full
|
||||
# shell parse, the same pragmatic tradeoff the sibling hooks make.
|
||||
if [[ "$command" =~ (^|[[:space:]&\|\;/])gitea-pr[[:space:]]+merge([[:space:]]|$) ]]; then
|
||||
echo "Blocked by gitea-merge-guard.sh: merging is a human decision in the sandbox - the workflow is 'gitea-pr create', then the operator merges. Report the PR URL and stop." >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
exit 0
|
||||
@@ -169,13 +169,22 @@ What this means:
|
||||
project. ~/shared is also a real, persistent host directory (not part of
|
||||
the project) - use it to hand the user a file (a screenshot, a temp
|
||||
script) without touching the project checkout, or to pick up something
|
||||
they left there for you. Everything else in the container besides
|
||||
~/.claude is disposable: installed system packages, anything outside the
|
||||
mounted paths, and the container itself go away when this run ends.
|
||||
they left there for you. ~/.claude is bind-mounted too, so this session's
|
||||
transcript and its project memory outlive the container - the work is
|
||||
recoverable on the host with the \`--resume\` command at the end of this
|
||||
briefing. Everything else is disposable: installed system packages,
|
||||
anything outside the mounted paths, and the container itself go away when
|
||||
this run ends.
|
||||
- Outbound network is restricted to an allowlist (Anthropic's API, the npm
|
||||
registry, GitHub read-only, the configured Gitea host, plus anything
|
||||
approved via dejima-allow). Requests to anything else will simply
|
||||
fail to connect. If a task needs one more domain and nothing else, that's
|
||||
fail to connect. That restriction is not only a limit on your reach: it
|
||||
is also what bounds where a prompt injection or a compromised dependency
|
||||
in this session could send the user's code or fetch its next instruction
|
||||
from. You cannot audit yourself for that from the inside, so the
|
||||
allowlist is load-bearing for you too, not just for the host - working
|
||||
around it would give up that protection, not merely lift the one limit
|
||||
it appears to. If a task needs one more domain and nothing else, that's
|
||||
a quick fix, not a reason to stop: tell the user to run
|
||||
\`bin/dejima-allow <domain> --container $container_id\` (add
|
||||
\`--global\` to persist it past this session) from a terminal outside the
|
||||
@@ -194,10 +203,9 @@ Git workflow - do this even though nothing stops you from doing otherwise:
|
||||
\`git worktree add .worktrees/<branch> -b <branch>\` run from the project
|
||||
root - never \`../<branch>\` or any other path outside it, including
|
||||
anywhere under \$HOME. Only the exact directory bind-mounted into this
|
||||
container is backed by real storage on the host; everything else,
|
||||
including what looks like the project's own parent directory, is
|
||||
container-local and silently disappears when the session ends - a
|
||||
worktree created there is real work that just gets thrown away. Add
|
||||
container is backed by real storage on the host - what looks like the
|
||||
project's own parent directory is not, so a worktree created there is
|
||||
real work that just gets thrown away. Add
|
||||
\`.worktrees/\` to \`.git/info/exclude\` (not \`.gitignore\` - this is
|
||||
sandbox-local, not something to commit) so it doesn't clutter \`git
|
||||
status\` for the user's real checkout.
|
||||
@@ -206,6 +214,29 @@ Git workflow - do this even though nothing stops you from doing otherwise:
|
||||
push regardless), and never push directly to main/master, on any remote.
|
||||
A \`git-push-guard.sh\` hook enforces both as a backstop, but treat them
|
||||
as the actual workflow to follow, not just a filter to route around.
|
||||
Merging is the operator's call, not yours - a \`gitea-merge-guard.sh\`
|
||||
hook blocks it here. Report the PR URL and stop.
|
||||
- Name the issues a PR resolves in its body: \`Fixes #N\` (\`Closes\` and
|
||||
\`Resolves\` work too) tells Gitea to close them when it merges. Use
|
||||
\`Refs #N\` for work that is partial or merely related, so those stay
|
||||
open. Prefer the keyword to closing anything yourself once it lands,
|
||||
because of the line above - you stop at the PR, and the merge happens
|
||||
later without you, so whatever you left for "after the merge" is not
|
||||
something you will be there to do. Write \`#N\` and not the issue's URL:
|
||||
they render alike, but only the short form triggers the close. Read the
|
||||
whole issue before claiming it, since the ask is often wider than the
|
||||
title, and a closing keyword closes it whether or not you covered all
|
||||
of it.
|
||||
- Write PR and issue bodies to a file and pass \`--body-file FILE\`, rather
|
||||
than \`--body "..."\` or a heredoc. Both of those put the entire body into
|
||||
the Bash command string, where \`deny-dangerous-bash.sh\` reads it - and
|
||||
that hook matches its patterns anywhere in the string, with no way to tell
|
||||
an instruction from prose that merely mentions one. A body describing a
|
||||
crash, a restart, or a destructive command it is warning about will get
|
||||
the call blocked, which looks like a broken tool rather than what it is.
|
||||
Writing the file first keeps the prose out of the command entirely.
|
||||
\`gitea-issue\` (list/view/create/comment/close/reopen) takes the same
|
||||
flag, for recording a problem you found but aren't fixing.
|
||||
|
||||
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,
|
||||
|
||||
+44
-4
@@ -1,10 +1,13 @@
|
||||
#!/bin/bash
|
||||
# Idempotent installer for dejima. Symlinks this repo's hooks into
|
||||
# ~/.claude/hooks, registers them in ~/.claude/settings.json, and puts
|
||||
# bin/dejima, bin/dejima-allow, bin/dejima-dep and
|
||||
# bin/dejima-project on PATH. Safe to re-run -
|
||||
# every step is a no-op if already done, and every change is printed as it
|
||||
# happens.
|
||||
# bin/dejima, bin/dejima-allow, bin/dejima-dep, bin/dejima-project and the
|
||||
# opt-in remote-access scripts on PATH. Safe to re-run - every step is a
|
||||
# no-op if already done, and every change is printed as it happens.
|
||||
#
|
||||
# It never enables anything: the remote-access scripts do nothing without
|
||||
# ~/.config/dejima/remote.conf, and the user units in systemd/ are left for
|
||||
# the operator to install by hand.
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
@@ -50,6 +53,14 @@ link_hook() {
|
||||
|
||||
link_hook sandbox-context.sh
|
||||
link_hook git-push-guard.sh
|
||||
link_hook gitea-merge-guard.sh
|
||||
|
||||
# Linked so it exists at a stable path for the read-only ~/.claude/hooks
|
||||
# mount, but deliberately NOT passed to register_hook 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.
|
||||
link_hook dejima-notify.sh
|
||||
|
||||
# deny-dangerous-bash.sh is a general (non-sandbox-gated) hook that may
|
||||
# already be owned by another install (e.g. dotfiles) - only claim it if
|
||||
@@ -90,6 +101,8 @@ register_hook SessionStart "startup|resume|clear|compact" sandbox-context.sh \
|
||||
"injects sandbox context on session start"
|
||||
register_hook PreToolUse Bash git-push-guard.sh \
|
||||
"blocks pushes outside the allowed host(s)/branches"
|
||||
register_hook PreToolUse Bash gitea-merge-guard.sh \
|
||||
"blocks 'gitea-pr merge' inside the sandbox"
|
||||
register_hook PreToolUse Bash deny-dangerous-bash.sh \
|
||||
"blocks obviously destructive Bash commands"
|
||||
echo
|
||||
@@ -130,7 +143,34 @@ link_bin dejima
|
||||
link_bin dejima-allow
|
||||
link_bin dejima-dep
|
||||
link_bin dejima-project
|
||||
|
||||
# Remote access (opt-in, see docs/remote-access.md). All three are inert
|
||||
# without ~/.config/dejima/remote.conf - they refuse to run at all - so linking
|
||||
# unconditionally adds no front door and costs nothing. The user units in
|
||||
# systemd/ are deliberately NOT copied or enabled here: standing up a front
|
||||
# door is an explicit operator decision, and this script never touches
|
||||
# systemd.
|
||||
link_bin dejima-attach
|
||||
link_bin dejima-ttyd
|
||||
link_bin dejima-notify-watch
|
||||
echo
|
||||
|
||||
echo "Done. Try: $BIN_DIR/dejima ~/some/project"
|
||||
echo "(or just 'dejima ~/some/project' once $BIN_DIR is on PATH)"
|
||||
echo
|
||||
# Conditional, because the unconditional version is a false statement to the
|
||||
# operator most likely to see it: someone with a front door already up,
|
||||
# re-running this after a git pull.
|
||||
# From the library rather than spelled out again here: the config root has
|
||||
# one definition (config_root, which honours DEJIMA_CONFIG_DIR) and a second
|
||||
# copy of it would drift without anything noticing.
|
||||
# shellcheck source=bin/dejima-project-lib.sh
|
||||
source "$SCRIPT_DIR/bin/dejima-project-lib.sh"
|
||||
remote_conf="$(remote_conf_path)"
|
||||
if [ -f "$remote_conf" ]; then
|
||||
echo "Remote access is configured ($remote_conf); this script did not start or stop anything."
|
||||
echo "See docs/remote-access.md."
|
||||
else
|
||||
echo "Remote access is available but not configured - nothing is listening."
|
||||
echo "See docs/remote-access.md to turn it on."
|
||||
fi
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
# User unit for the host-side notification watcher. Not enabled by
|
||||
# install.sh - pushing to a phone is an explicit operator decision:
|
||||
#
|
||||
# mkdir -p ~/.config/systemd/user
|
||||
# cp systemd/dejima-notify-watch.service ~/.config/systemd/user/
|
||||
# systemctl --user daemon-reload
|
||||
# systemctl --user enable --now dejima-notify-watch.service
|
||||
#
|
||||
# It does nothing useful until ~/.config/dejima/remote.conf exists and sets
|
||||
# NTFY_URL; without that dejima-notify-watch exits 1 and the restart limit
|
||||
# below stops the unit instead of retrying forever.
|
||||
#
|
||||
# This unit must be a USER unit, and its environment is load-bearing: both
|
||||
# this watcher and bin/dejima resolve the marker root as
|
||||
# ${XDG_RUNTIME_DIR:-/tmp}/dejima, so the two agree only when they agree on
|
||||
# XDG_RUNTIME_DIR. systemd sets it for every user unit; a login shell gets it
|
||||
# from pam_systemd. If bin/dejima is ever run from a context that has no
|
||||
# XDG_RUNTIME_DIR (a bare `su`, a cron job, a tmux server started before
|
||||
# login), it writes its markers under /tmp/dejima while this unit watches
|
||||
# /run/user/$UID/dejima, and no notification is ever pushed - with nothing
|
||||
# logged anywhere to say so. `systemctl --user show-environment` is where to
|
||||
# check that they match.
|
||||
[Unit]
|
||||
Description=dejima notification watcher (marker files to ntfy)
|
||||
# A missing remote.conf or NTFY_URL fails every start identically, so give up
|
||||
# visibly rather than restarting for the life of the login session. A failed
|
||||
# push is not a failed start: the watcher warns and carries on, so a network
|
||||
# that is not up yet costs nothing here.
|
||||
StartLimitIntervalSec=60
|
||||
StartLimitBurst=5
|
||||
|
||||
[Service]
|
||||
# NTFY_URL, BASE_URL and the marker root all come from remote.conf, read by
|
||||
# the script - do not repeat any of them here, or the two will drift.
|
||||
# %h, not an absolute path: this file is the same for every operator.
|
||||
ExecStart=%h/.local/bin/dejima-notify-watch
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
# Unhardened on purpose, and worth stating rather than leaving as an
|
||||
# omission: the unit next door (dejima-ttyd) deliberately hands out a shell
|
||||
# with this same uid's full privileges, so sandboxing this one would raise
|
||||
# the cost without lowering the exposure. What makes the watcher safe is in
|
||||
# the script - it consults marker NAMES against a literal enum, never their
|
||||
# contents, and composes every byte it sends from host-side data.
|
||||
#
|
||||
# No After= either: a user manager has no ordering to offer against the
|
||||
# network, and none is needed - a push that cannot go out is warned about,
|
||||
# not fatal.
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
@@ -0,0 +1,42 @@
|
||||
# User unit for the dejima web front door. Not enabled by install.sh -
|
||||
# standing up a front door is an explicit operator decision:
|
||||
#
|
||||
# mkdir -p ~/.config/systemd/user
|
||||
# cp systemd/dejima-ttyd.service ~/.config/systemd/user/
|
||||
# systemctl --user daemon-reload
|
||||
# systemctl --user enable --now dejima-ttyd.service
|
||||
#
|
||||
# It does nothing useful until ~/.config/dejima/remote.conf exists; without
|
||||
# it dejima-ttyd exits 1 and the restart limit below stops the unit instead
|
||||
# of retrying forever.
|
||||
[Unit]
|
||||
Description=dejima remote session terminal (ttyd)
|
||||
# A missing or malformed remote.conf fails every start identically, so give
|
||||
# up visibly (systemctl --user status says why) rather than restarting for
|
||||
# the life of the login session.
|
||||
StartLimitIntervalSec=60
|
||||
StartLimitBurst=5
|
||||
|
||||
[Service]
|
||||
# The bind address and the port both come from bin/dejima-ttyd, which reads
|
||||
# remote.conf - do not repeat either of them here, or the two will drift.
|
||||
# %h, not an absolute path: this file is the same for every operator.
|
||||
ExecStart=%h/.local/bin/dejima-ttyd
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
# Deliberately unhardened, which is worth stating rather than leaving as an
|
||||
# omission: this unit exists to hand out a host shell (ttyd -> dejima-attach
|
||||
# -> a tmux client on the operator's own server), so the usual directives
|
||||
# would break it rather than contain it. PrivateTmp= would hide the tmux
|
||||
# server socket under /tmp/tmux-$UID; ProtectHome= would hide the scripts
|
||||
# above and the repo they link to; NoNewPrivileges= would break sudo inside
|
||||
# the attached session. What contains this feature is the loopback bind in
|
||||
# bin/dejima-ttyd, its --check-origin, and authentication in the reverse
|
||||
# proxy - see docs/nginx-dejima.conf.example - not unit sandboxing.
|
||||
#
|
||||
# No After= either: a user manager has no ordering to offer against the
|
||||
# network, and none is needed - the listener is on loopback.
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
+730
-10
@@ -14,8 +14,10 @@ setup() {
|
||||
CURRENT_SLUG="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
|
||||
STATUSLINE_DIR="$(mktemp -d)"
|
||||
export DEJIMA_AUDIT_STATUSLINE="$STATUSLINE_DIR/statusline.sh"
|
||||
AUDITDIR="$(mktemp -d)"; export DEJIMA_AUDIT_DIR="$AUDITDIR"
|
||||
BASEDIR="$(mktemp -d)"; export DEJIMA_AUDIT_BASELINE_DIR="$BASEDIR"
|
||||
}
|
||||
teardown() { rm -rf "$WS"; rm -f "$STATE"; rm -rf "$PROJDIR"; rm -rf "$STATUSLINE_DIR"; }
|
||||
teardown() { rm -rf "$WS"; rm -f "$STATE"; rm -rf "$PROJDIR"; rm -rf "$STATUSLINE_DIR"; rm -rf "$AUDITDIR"; rm -rf "$BASEDIR"; }
|
||||
|
||||
snap() { "$AUDIT" snapshot "$WS" "$STATE"; }
|
||||
cmp_() { "$AUDIT" compare "$WS" "$STATE" 2>&1; }
|
||||
@@ -27,6 +29,30 @@ cmp_() { "$AUDIT" compare "$WS" "$STATE" 2>&1; }
|
||||
[ "$output" = "" ]
|
||||
}
|
||||
|
||||
@test "compare against an existing-but-never-snapshotted state file skips silently instead of flagging everything" {
|
||||
# bin/dejima's cleanup() runs on every exit, including one before
|
||||
# `snapshot` ever ran (e.g. a startup abort) - its mktemp'd state file
|
||||
# exists but is empty in that case, same as $STATE here before snap() is
|
||||
# ever called. Compare must treat that the same as a missing file, not
|
||||
# diff real content against nothing and manufacture a finding for
|
||||
# everything already present. This is the routine case on every
|
||||
# pre-snapshot fatal exit, so it must stay silent - the "skipping"
|
||||
# message would otherwise bury the real error that was the operator's
|
||||
# last line.
|
||||
git -C "$WS" config core.hooksPath /tmp/evil
|
||||
run cmp_
|
||||
[ "$status" -eq 0 ]
|
||||
[ "$output" = "" ]
|
||||
}
|
||||
|
||||
@test "compare against a genuinely missing state file path prints a skip notice" {
|
||||
git -C "$WS" config core.hooksPath /tmp/evil
|
||||
run "$AUDIT" compare "$WS" "$STATE.does-not-exist" 2>&1
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *"no snapshot to compare against, skipping"* ]]
|
||||
[[ "$output" != *"core.hooksPath"* ]]
|
||||
}
|
||||
|
||||
@test "flags a newly-added core.hooksPath" {
|
||||
snap
|
||||
git -C "$WS" config core.hooksPath /tmp/evil
|
||||
@@ -91,7 +117,7 @@ cmp_() { "$AUDIT" compare "$WS" "$STATE" 2>&1; }
|
||||
echo '{}' > "$WS/.mcp.json"; snap
|
||||
echo '{"mcpServers":{}}' > "$WS/.mcp.json"
|
||||
run cmp_
|
||||
count="$(printf '%s\n' "$output" | grep -c 'changed workspace file:.*\.mcp\.json')"
|
||||
count="$(printf '%s\n' "$output" | grep -c 'workspace file:.*\.mcp\.json')"
|
||||
[ "$count" -eq 1 ]
|
||||
}
|
||||
|
||||
@@ -202,14 +228,16 @@ cmp_() { "$AUDIT" compare "$WS" "$STATE" 2>&1; }
|
||||
[[ "$output" == *"statusline.sh"* ]]
|
||||
}
|
||||
|
||||
@test "flags the confirmed exploit: a symlink statusline.sh swapped for a regular file" {
|
||||
# This is the CONFIRMED Finding-1 gap: bin/dejima's read-only
|
||||
# mount onto ~/.claude/statusline.sh does not reliably survive when
|
||||
# statusline.sh is a symlink (the common case, e.g. into a dotfiles
|
||||
# checkout) - runc resolves the bind-mount destination through the
|
||||
# symlink, leaving the symlink dentry itself on the writable ~/.claude
|
||||
# mount and still replaceable (confirmed against a live container - see
|
||||
# test/runtime.bats). Detection here is the compensating control.
|
||||
@test "flags the once-confirmed exploit: a symlink statusline.sh swapped for a regular file" {
|
||||
# This was the CONFIRMED Finding-1 gap: statusline.sh was mounted
|
||||
# read-only onto its own path inside a read-write ~/.claude mount, and
|
||||
# runc resolved the bind-mount destination through the symlink (the
|
||||
# common case, e.g. into a config checkout elsewhere), leaving the
|
||||
# symlink dentry itself writable and replaceable. Staging the file for
|
||||
# entrypoint.sh to copy in closed it (see test/runtime.bats), so
|
||||
# detection is no longer the only control - but a session reaching the
|
||||
# host statusline by some other route is exactly what an exit-time audit
|
||||
# is for, so this stays.
|
||||
local target_dir; target_dir="$(mktemp -d)"
|
||||
printf '#!/bin/sh\necho original\n' > "$target_dir/statusline.sh"
|
||||
ln -s "$target_dir/statusline.sh" "$DEJIMA_AUDIT_STATUSLINE"
|
||||
@@ -241,3 +269,695 @@ cmp_() { "$AUDIT" compare "$WS" "$STATE" 2>&1; }
|
||||
[ "$status" -eq 0 ]
|
||||
[ "$output" = "" ]
|
||||
}
|
||||
|
||||
@test "flags .envrc as host code execution, not a plain file change" {
|
||||
snap
|
||||
echo 'export X=1' > "$WS/.envrc"
|
||||
run cmp_
|
||||
[[ "$output" == *"HOST-CODE-EXECUTION"* ]]
|
||||
[[ "$output" == *".envrc"* ]]
|
||||
}
|
||||
|
||||
@test "flags .claude/settings.local.json as host code execution" {
|
||||
snap
|
||||
mkdir -p "$WS/.claude"; echo '{}' > "$WS/.claude/settings.local.json"
|
||||
run cmp_
|
||||
[[ "$output" == *"HOST-CODE-EXECUTION"* ]]
|
||||
}
|
||||
|
||||
@test "flags .mcp.json as host code execution" {
|
||||
echo '{}' > "$WS/.mcp.json"; snap
|
||||
echo '{"a":1}' > "$WS/.mcp.json"
|
||||
run cmp_
|
||||
[[ "$output" == *"HOST-CODE-EXECUTION"* ]]
|
||||
}
|
||||
|
||||
@test "flags .vscode/tasks.json as host code execution" {
|
||||
snap
|
||||
mkdir -p "$WS/.vscode"; echo '{}' > "$WS/.vscode/tasks.json"
|
||||
run cmp_
|
||||
[[ "$output" == *"HOST-CODE-EXECUTION"* ]]
|
||||
}
|
||||
|
||||
@test "does not flag an ordinary .claude file as host code execution" {
|
||||
snap
|
||||
mkdir -p "$WS/.claude"; echo 'notes' > "$WS/.claude/CLAUDE.md"
|
||||
run cmp_
|
||||
[[ "$output" == *"CLAUDE.md"* ]]
|
||||
[[ "$output" != *"HOST-CODE-EXECUTION"* ]]
|
||||
}
|
||||
|
||||
@test "prints CRITICAL findings after WARNING findings" {
|
||||
snap
|
||||
echo 'export X=1' > "$WS/.envrc" # CRITICAL
|
||||
mkdir -p "$WS/.vscode"; echo '{}' > "$WS/.vscode/settings.json" # WARNING
|
||||
run cmp_
|
||||
crit_line=$(printf '%s\n' "$output" | grep -n "HOST-CODE-EXECUTION" | head -1 | cut -d: -f1)
|
||||
warn_line=$(printf '%s\n' "$output" | grep -n "settings.json" | head -1 | cut -d: -f1)
|
||||
[ "$crit_line" -gt "$warn_line" ]
|
||||
}
|
||||
|
||||
@test "lists a WARNING-tier file without marking it as host code execution" {
|
||||
snap
|
||||
mkdir -p "$WS/.vscode"; echo '{}' > "$WS/.vscode/settings.json"
|
||||
run cmp_
|
||||
[[ "$output" == *".vscode/settings.json"* ]]
|
||||
[[ "$output" != *"HOST-CODE-EXECUTION"*".vscode/settings.json"* ]]
|
||||
}
|
||||
|
||||
@test "caps the CRITICAL block at 10 entries" {
|
||||
mkdir -p "$WS/.git/hooks"; snap
|
||||
for i in $(seq 1 40); do echo "#!/bin/sh" > "$WS/.git/hooks/h$i"; done
|
||||
run cmp_
|
||||
[ "$(printf '%s\n' "$output" | grep -c '^ ! ')" -le 10 ]
|
||||
}
|
||||
|
||||
@test "overflow past the CRITICAL cap prints below the list and reads as an alarm" {
|
||||
mkdir -p "$WS/.git/hooks"; snap
|
||||
for i in $(seq 1 40); do echo "#!/bin/sh" > "$WS/.git/hooks/h$i"; done
|
||||
run cmp_
|
||||
[[ "$output" == *"ABNORMAL"* ]]
|
||||
last_crit=$(printf '%s\n' "$output" | grep -n '^ ! ' | tail -1 | cut -d: -f1)
|
||||
overflow=$(printf '%s\n' "$output" | grep -n "ABNORMAL" | head -1 | cut -d: -f1)
|
||||
[ "$overflow" -gt "$last_crit" ]
|
||||
}
|
||||
|
||||
@test "caps the WARNING block at 5 entries" {
|
||||
mkdir -p "$WS/.vscode"; snap
|
||||
for i in $(seq 1 20); do echo x > "$WS/.vscode/f$i"; done
|
||||
run cmp_
|
||||
[ "$(printf '%s\n' "$output" | grep -c '^ - ')" -le 5 ]
|
||||
}
|
||||
|
||||
@test "overflow past the WARNING cap prints a counted line below the list" {
|
||||
mkdir -p "$WS/.vscode"; snap
|
||||
for i in $(seq 1 20); do echo x > "$WS/.vscode/f$i"; done
|
||||
run cmp_
|
||||
[[ "$output" == *"further change(s) not shown."* ]]
|
||||
last_warn=$(printf '%s\n' "$output" | grep -n '^ - ' | tail -1 | cut -d: -f1)
|
||||
overflow=$(printf '%s\n' "$output" | grep -n '^ + ' | head -1 | cut -d: -f1)
|
||||
[ "$overflow" -gt "$last_warn" ]
|
||||
}
|
||||
|
||||
@test "the terminal header states what changed while the session ran, not what the session changed" {
|
||||
# The diff covers the whole session window regardless of source
|
||||
# (concurrent sessions, the operator's own edits, background tools),
|
||||
# so the header must not claim the session itself made the change.
|
||||
snap
|
||||
echo 'export X=1' > "$WS/.envrc"
|
||||
run cmp_
|
||||
[[ "$output" == *"host-persistence surfaces changed in"* ]]
|
||||
[[ "$output" == *"while this session ran"* ]]
|
||||
[[ "$output" != *"this session changed"* ]]
|
||||
}
|
||||
|
||||
@test "the report file explains that concurrent sessions and operator edits land in the same diff" {
|
||||
snap
|
||||
mkdir -p "$WS/.claude"; echo 'x' > "$WS/.claude/CLAUDE.md"
|
||||
cmp_ >/dev/null 2>&1
|
||||
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
|
||||
run bash -c "cat '$AUDITDIR/$slug'/*.txt"
|
||||
[[ "$output" == *"not necessarily because"* ]]
|
||||
[[ "$output" == *"concurrent"* ]]
|
||||
}
|
||||
|
||||
@test "the last line of the report is the verdict, not a border" {
|
||||
snap
|
||||
echo 'export X=1' > "$WS/.envrc"
|
||||
run cmp_
|
||||
last=$(printf '%s\n' "$output" | grep -v '^$' | tail -1)
|
||||
[[ "$last" != *"====="* ]]
|
||||
[[ "$last" == *"dejima:"* ]]
|
||||
[[ "$last" == *"1 CRITICAL"* ]]
|
||||
}
|
||||
|
||||
@test "the verdict line reports no critical findings when only WARNING-tier changes exist" {
|
||||
snap
|
||||
mkdir -p "$WS/.vscode"; echo '{}' > "$WS/.vscode/settings.json"
|
||||
run cmp_
|
||||
last=$(printf '%s\n' "$output" | grep -v '^$' | tail -1)
|
||||
[[ "$last" != *"====="* ]]
|
||||
[[ "$last" == *"dejima:"* ]]
|
||||
[[ "$last" == *"no critical findings"* ]]
|
||||
}
|
||||
|
||||
@test "strips terminal escapes from a planted config value" {
|
||||
snap
|
||||
git -C "$WS" config core.pager "$(printf 'less\033[2Jgotcha')"
|
||||
run cmp_
|
||||
[[ "$output" != *$'\033'* ]]
|
||||
[[ "$output" == *"core.pager"* ]]
|
||||
}
|
||||
|
||||
@test "keeps terminal escapes in a planted path out of the report" {
|
||||
snap
|
||||
mkdir -p "$WS/.claude"
|
||||
touch "$WS/.claude/$(printf 'evil\033[2Jfile')"
|
||||
run cmp_
|
||||
[[ "$output" != *$'\033'* ]]
|
||||
[[ "$output" == *"{hex}"* ]]
|
||||
}
|
||||
|
||||
@test "keeps a bidi override in a planted path from visually reversing the report" {
|
||||
snap
|
||||
mkdir -p "$WS/.claude"
|
||||
touch "$WS/.claude/$(printf 'evil%sfile' $'\xe2\x80\xae')"
|
||||
run cmp_
|
||||
[[ "$output" != *$'\xe2\x80\xae'* ]]
|
||||
# Encoded, not silently stripped: rendering this as ".claude/evilfile"
|
||||
# would send the operator grepping for a name no file on disk has, and
|
||||
# is the id `ack` used to record - one that could never match again.
|
||||
[[ "$output" != *".claude/evilfile"* ]]
|
||||
[[ "$output" == *"{hex}2e636c617564652f6576696ce280ae66696c65"* ]]
|
||||
}
|
||||
|
||||
@test "a path whose real bytes contain a bidi override can be acknowledged and stays acknowledged" {
|
||||
# The round trip this encoding exists for. compare wrote sanitized text,
|
||||
# ack derived the baseline id from it, and the next compare matched that
|
||||
# id against the RAW bytes on disk - so this path could never be
|
||||
# acknowledged, its report never cleared, and the startup warning fired
|
||||
# on it forever.
|
||||
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
|
||||
mkdir -p "$WS/.claude"
|
||||
snap
|
||||
printf 'x' > "$WS/.claude/$(printf 'evil%sfile' $'\xe2\x80\xae')"
|
||||
cmp_ >/dev/null 2>&1
|
||||
run bash -c "printf 'a\n' | '$AUDIT' ack '$WS'"
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *"recorded 1 acknowledgement"* ]]
|
||||
[[ "$output" != *"no longer exists"* ]]
|
||||
grep -q '^safe {hex}2e636c617564652f6576696ce280ae66696c65 ' "$BASEDIR/$slug"
|
||||
# Same unchanged state file, so the same finding is rediscovered - and
|
||||
# is now suppressed by what ack recorded.
|
||||
run cmp_
|
||||
[ "$output" = "" ]
|
||||
}
|
||||
|
||||
@test "a TAB in a monitored path does not truncate it in the snapshot record" {
|
||||
# The snapshot record is path<TAB>sha, read back with `cut -f1`, so a TAB
|
||||
# in a filename used to truncate the path there - upstream of any
|
||||
# encoding. ".git/hooks/pre<TAB>commit" was reported as ".git/hooks/pre":
|
||||
# still CRITICAL (it is still under .git/hooks/), still naming a file
|
||||
# that does not exist, so still unackable and nagging forever. Encoding
|
||||
# the path into that record closes it, because an encoded path can
|
||||
# contain neither TAB nor newline.
|
||||
snap
|
||||
printf 'x' > "$WS/.git/hooks/$(printf 'pre\tcommit')"
|
||||
run cmp_
|
||||
[[ "$output" != *".git/hooks/pre"$'\t'* ]]
|
||||
[[ "$output" != *"! HOST-CODE-EXECUTION workspace file: .git/hooks/pre"* ]]
|
||||
[[ "$output" == *"{hex}2e6769742f686f6f6b732f70726509636f6d6d6974"* ]]
|
||||
# ...and it can actually be acknowledged, which was the whole point
|
||||
run bash -c "printf 'a\n' | '$AUDIT' ack '$WS'"
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *"recorded 1 acknowledgement"* ]]
|
||||
[[ "$output" != *"no longer exists"* ]]
|
||||
run cmp_
|
||||
[ "$output" = "" ]
|
||||
}
|
||||
|
||||
@test "a file whose name merely contains the hex marker is left alone and still round-trips" {
|
||||
# The marker is only meaningful at the START of an id, so this one is
|
||||
# display-safe and is passed through verbatim - the encoding must not
|
||||
# reach for it here, and dec_id must not mistake it for encoded text.
|
||||
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
|
||||
mkdir -p "$WS/.claude"
|
||||
snap
|
||||
printf 'x' > "$WS/.claude/{hex}6576696c"
|
||||
run cmp_
|
||||
[[ "$output" == *".claude/{hex}6576696c"* ]]
|
||||
run bash -c "printf 'a\n' | '$AUDIT' ack '$WS'"
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *"recorded 1 acknowledgement"* ]]
|
||||
grep -q '^safe \.claude/{hex}6576696c ' "$BASEDIR/$slug"
|
||||
run cmp_
|
||||
[ "$output" = "" ]
|
||||
}
|
||||
|
||||
@test "an id that starts with the hex marker is encoded rather than passed through" {
|
||||
# Otherwise a workspace could hand `ack` an id that decodes to one of its
|
||||
# choosing - baselining something other than the finding while still
|
||||
# counting the finding acknowledged. No monitored path can reach this
|
||||
# (they are all rooted at .claude/.vscode/.git/.envrc/.mcp.json), but a
|
||||
# git config section name is free text in a file the session can write,
|
||||
# and the audit parses that file directly rather than through git.
|
||||
snap
|
||||
printf '[{hex}6576696c]\n\tkey = v\n' >> "$WS/.git/config"
|
||||
run cmp_
|
||||
[[ "$output" != *"{hex}6576696c.key"* ]] # not passed through verbatim...
|
||||
[[ "$output" == *"{hex}7b6865787d36353736363936632e6b6579"* ]] # ...encoded
|
||||
}
|
||||
|
||||
@test "writes a timestamped report file containing the full finding list" {
|
||||
snap
|
||||
mkdir -p "$WS/.claude"; echo 'x' > "$WS/.claude/CLAUDE.md"
|
||||
cmp_ >/dev/null 2>&1
|
||||
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
|
||||
run bash -c "cat '$AUDITDIR/$slug'/*.txt"
|
||||
[[ "$output" == *"CLAUDE.md"* ]]
|
||||
}
|
||||
|
||||
@test "names a report carrying critical findings so rotation can spare it" {
|
||||
snap
|
||||
echo 'export X=1' > "$WS/.envrc"
|
||||
cmp_ >/dev/null 2>&1
|
||||
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
|
||||
run bash -c "ls '$AUDITDIR/$slug'"
|
||||
[[ "$output" == *"-CRITICAL.txt"* ]]
|
||||
}
|
||||
|
||||
@test "rotation keeps DEJIMA_AUDIT_KEEP clean reports" {
|
||||
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
|
||||
mkdir -p "$AUDITDIR/$slug"
|
||||
for i in $(seq 1 12); do
|
||||
touch "$AUDITDIR/$slug/2026-01-01-0000$i.txt"
|
||||
done
|
||||
snap; git -C "$WS" config user.name changed
|
||||
DEJIMA_AUDIT_KEEP=10 cmp_ >/dev/null 2>&1
|
||||
run bash -c "ls '$AUDITDIR/$slug' | wc -l"
|
||||
[ "$output" -le 10 ]
|
||||
}
|
||||
|
||||
@test "rotation never evicts an unacknowledged CRITICAL report on age" {
|
||||
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
|
||||
mkdir -p "$AUDITDIR/$slug"
|
||||
touch "$AUDITDIR/$slug/2020-01-01-000000-CRITICAL.txt"
|
||||
for i in $(seq 1 12); do touch "$AUDITDIR/$slug/2026-01-01-0000$i.txt"; done
|
||||
snap; git -C "$WS" config user.name changed
|
||||
DEJIMA_AUDIT_KEEP=10 cmp_ >/dev/null 2>&1
|
||||
# rotation actually ran: 12 planted clean reports + this run's own, trimmed to KEEP
|
||||
clean_n="$(find "$AUDITDIR/$slug" -maxdepth 1 -name '*.txt' ! -name '*-CRITICAL.txt' | wc -l)"
|
||||
[ "$clean_n" -le 10 ]
|
||||
# ...and the unacknowledged CRITICAL report survived it
|
||||
[ -f "$AUDITDIR/$slug/2020-01-01-000000-CRITICAL.txt" ]
|
||||
}
|
||||
|
||||
@test "rotation gives up instead of spinning forever when the report dir is unwritable" {
|
||||
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
|
||||
mkdir -p "$AUDITDIR/$slug"
|
||||
for i in $(seq 1 12); do touch "$AUDITDIR/$slug/2026-01-01-0000$i.txt"; done
|
||||
snap; git -C "$WS" config user.name changed
|
||||
chmod 500 "$AUDITDIR/$slug"
|
||||
run timeout 5 env DEJIMA_AUDIT_KEEP=10 "$AUDIT" compare "$WS" "$STATE"
|
||||
result="$status"
|
||||
chmod 700 "$AUDITDIR/$slug"
|
||||
[ "$result" -ne 124 ]
|
||||
}
|
||||
|
||||
@test "show prints the only report there is, and names it" {
|
||||
snap
|
||||
echo 'export X=1' > "$WS/.envrc"
|
||||
cmp_ >/dev/null 2>&1
|
||||
run bash -c "'$AUDIT' show '$WS' 2>&1"
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *".envrc"* ]]
|
||||
[[ "$output" == *"-CRITICAL.txt"* ]] # said which report it printed
|
||||
}
|
||||
|
||||
@test "show and ack agree on which report they mean" {
|
||||
# `show` selected the newest report overall while `ack` preferred the
|
||||
# oldest unacknowledged CRITICAL one, so an operator who followed the
|
||||
# startup warning to `show` first was handed a different report than the
|
||||
# one being warned about - and went looking for a finding not in it.
|
||||
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
|
||||
report_dir="$AUDITDIR/$slug"; mkdir -p "$report_dir"
|
||||
cat > "$report_dir/2020-01-01-000000-CRITICAL.txt" <<REPORT
|
||||
dejima audit report for $WS
|
||||
CRITICAL HOST-CODE-EXECUTION workspace file: .envrc
|
||||
REPORT
|
||||
cat > "$report_dir/2026-01-01-000001.txt" <<REPORT
|
||||
dejima audit report for $WS
|
||||
WARNING changed workspace file: .vscode/settings.json
|
||||
REPORT
|
||||
run "$AUDIT" show "$WS"
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *".envrc"* ]] # the stale CRITICAL report...
|
||||
[[ "$output" != *"settings.json"* ]] # ...not the newer clean one
|
||||
}
|
||||
|
||||
@test "show falls back to the newest report when no CRITICAL one is pending" {
|
||||
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
|
||||
report_dir="$AUDITDIR/$slug"; mkdir -p "$report_dir"
|
||||
echo 'older' > "$report_dir/2020-01-01-000000.txt"
|
||||
echo 'newer' > "$report_dir/2026-01-01-000001.txt"
|
||||
run "$AUDIT" show "$WS"
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *"newer"* ]]
|
||||
[[ "$output" != *"older"* ]]
|
||||
}
|
||||
|
||||
@test "ack presents a stale unacknowledged CRITICAL report before a newer clean one" {
|
||||
# IMPORTANT 3: `ack` used to pick the newest report overall (sort | tail
|
||||
# -1), so a stale *-CRITICAL.txt sitting behind a newer clean report was
|
||||
# unreachable through `ack` at all - the operator has no remedy but to
|
||||
# keep pressing [c] past the startup warning, which is exactly the
|
||||
# failure this feature exists to eliminate.
|
||||
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
|
||||
echo 'export X=1' > "$WS/.envrc" # real content, so the approval succeeds
|
||||
report_dir="$AUDITDIR/$slug"; mkdir -p "$report_dir"
|
||||
cat > "$report_dir/2020-01-01-000000-CRITICAL.txt" <<REPORT
|
||||
dejima audit report for $WS
|
||||
CRITICAL HOST-CODE-EXECUTION workspace file: .envrc
|
||||
REPORT
|
||||
cat > "$report_dir/2026-01-01-000001.txt" <<REPORT
|
||||
dejima audit report for $WS
|
||||
WARNING changed workspace file: .vscode/settings.json
|
||||
REPORT
|
||||
run bash -c "printf 'a\n' | '$AUDIT' ack '$WS'"
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *".envrc"* ]] # presented the stale CRITICAL report...
|
||||
[[ "$output" != *"settings.json"* ]] # ...not the newer clean one
|
||||
grep -q '^safe \.envrc ' "$BASEDIR/$slug"
|
||||
[ ! -f "$report_dir/2020-01-01-000000-CRITICAL.txt" ] # cleared: suffix dropped
|
||||
[ -f "$report_dir/2020-01-01-000000.txt" ]
|
||||
[ -f "$report_dir/2026-01-01-000001.txt" ] # the newer report is untouched
|
||||
}
|
||||
|
||||
@test "a spam-listed path is not listed in the report" {
|
||||
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
|
||||
mkdir -p "$WS/.claude"; echo 'a' > "$WS/.claude/CLAUDE.md"; snap
|
||||
echo "spam .claude/CLAUDE.md" > "$BASEDIR/$slug"
|
||||
echo 'b' > "$WS/.claude/CLAUDE.md"
|
||||
run cmp_
|
||||
[[ "$output" != *"CLAUDE.md"* ]]
|
||||
}
|
||||
|
||||
@test "a safe-listed path with a matching hash is suppressed" {
|
||||
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
|
||||
snap
|
||||
echo 'export X=1' > "$WS/.envrc"
|
||||
sha="$(sha256sum "$WS/.envrc" | cut -d' ' -f1)"
|
||||
echo "safe .envrc $sha" > "$BASEDIR/$slug"
|
||||
run cmp_
|
||||
[[ "$output" != *".envrc"* ]]
|
||||
}
|
||||
|
||||
@test "a safe-listed path re-fires at full severity when its hash changes" {
|
||||
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
|
||||
snap
|
||||
echo 'export X=1' > "$WS/.envrc"
|
||||
echo "safe .envrc 0000000000000000000000000000000000000000000000000000000000000000" > "$BASEDIR/$slug"
|
||||
run cmp_
|
||||
[[ "$output" == *"HOST-CODE-EXECUTION"* ]]
|
||||
[[ "$output" == *".envrc"* ]]
|
||||
}
|
||||
|
||||
@test "a hand-written spam entry cannot silence a CRITICAL surface" {
|
||||
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
|
||||
snap
|
||||
echo "spam .envrc" > "$BASEDIR/$slug"
|
||||
echo 'export X=1' > "$WS/.envrc"
|
||||
run cmp_
|
||||
[[ "$output" == *"HOST-CODE-EXECUTION"* ]]
|
||||
[[ "$output" == *".envrc"* ]]
|
||||
}
|
||||
|
||||
@test "a baseline id containing a space does not suppress a different real path" {
|
||||
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
|
||||
mkdir -p "$WS/.vscode"
|
||||
echo 'a' > "$WS/.vscode/settings.json"; snap
|
||||
printf 'spam .vscode/settings.json old\n' > "$BASEDIR/$slug"
|
||||
echo 'b' > "$WS/.vscode/settings.json"
|
||||
run cmp_
|
||||
[[ "$output" == *".vscode/settings.json"* ]]
|
||||
}
|
||||
|
||||
@test "ack writes nothing to the baseline when the operator quits" {
|
||||
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
|
||||
snap; echo 'export X=1' > "$WS/.envrc"; cmp_ >/dev/null 2>&1
|
||||
run bash -c "printf 'q\n' | '$AUDIT' ack '$WS'"
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *".envrc"* ]] # ack ran and presented the finding
|
||||
[[ "$output" == *"nothing recorded"* ]] # ...and reported that it wrote nothing
|
||||
[ ! -f "$BASEDIR/$slug" ] # ...and in fact wrote nothing
|
||||
}
|
||||
|
||||
@test "ack refuses to spam a CRITICAL surface" {
|
||||
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
|
||||
snap
|
||||
echo 'export X=1' > "$WS/.envrc"
|
||||
mkdir -p "$WS/.vscode"; echo '{}' > "$WS/.vscode/settings.json"
|
||||
cmp_ >/dev/null 2>&1
|
||||
# 's' on the CRITICAL .envrc finding, then 'a' on the WARNING one that
|
||||
# follows it - so $BASEDIR/$slug is guaranteed to exist afterward and the
|
||||
# negative grep below can't pass vacuously against a missing file.
|
||||
run bash -c "printf 's\na\n' | '$AUDIT' ack '$WS'"
|
||||
[[ "$output" == *"cannot 'always ignore' a CRITICAL surface - approve the exact content or skip."* ]]
|
||||
[ -f "$BASEDIR/$slug" ]
|
||||
! grep -q '^spam \.envrc' "$BASEDIR/$slug"
|
||||
}
|
||||
|
||||
@test "ack records a safe entry and clears the CRITICAL report marker" {
|
||||
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
|
||||
snap; echo 'export X=1' > "$WS/.envrc"; cmp_ >/dev/null 2>&1
|
||||
run bash -c "printf 'a\n' | '$AUDIT' ack '$WS'"
|
||||
grep -q '^safe .envrc' "$BASEDIR/$slug"
|
||||
run bash -c "ls '$AUDITDIR/$slug'"
|
||||
[[ "$output" != *"-CRITICAL.txt"* ]]
|
||||
}
|
||||
|
||||
@test "ack does not write an unmatchable entry for a non-path finding" {
|
||||
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
|
||||
snap; git -C "$WS" config user.name changed; cmp_ >/dev/null 2>&1
|
||||
run bash -c "printf 'a\n' | '$AUDIT' ack '$WS'"
|
||||
[[ "$output" == *"cannot"* ]]
|
||||
[ ! -f "$BASEDIR/$slug" ]
|
||||
}
|
||||
|
||||
@test "an ack-approved finding is suppressed on the next compare (id round-trips through the baseline)" {
|
||||
snap; echo 'export X=1' > "$WS/.envrc"; cmp_ >/dev/null 2>&1
|
||||
run bash -c "printf 'a\n' | '$AUDIT' ack '$WS'"
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *"recorded 1 acknowledgement"* ]] # ack ran and wrote the entry
|
||||
run cmp_
|
||||
[[ "$output" != *".envrc"* ]] # ...and it round-trips
|
||||
}
|
||||
|
||||
@test "the CRITICAL suffix survives when a CRITICAL finding is skipped but a WARNING is approved" {
|
||||
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
|
||||
snap
|
||||
echo 'export X=1' > "$WS/.envrc" # CRITICAL
|
||||
mkdir -p "$WS/.vscode"; echo '{}' > "$WS/.vscode/settings.json" # WARNING
|
||||
cmp_ >/dev/null 2>&1
|
||||
# Report order is CRITICAL block then WARNING block, so the first key
|
||||
# answers the .envrc CRITICAL finding and the second answers the
|
||||
# .vscode/settings.json WARNING finding.
|
||||
run bash -c "printf 'c\na\n' | '$AUDIT' ack '$WS'"
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *".envrc"* ]] # ack ran and presented the CRITICAL finding
|
||||
[[ "$output" == *"settings.json"* ]] # ...and the WARNING finding
|
||||
grep -q '^safe .vscode/settings.json' "$BASEDIR/$slug" # the approval was recorded
|
||||
run bash -c "ls '$AUDITDIR/$slug'"
|
||||
[[ "$output" == *"-CRITICAL.txt"* ]] # but the CRITICAL finding was skipped, not approved,
|
||||
# so the report must stay eviction-protected
|
||||
}
|
||||
|
||||
@test "ack refuses to approve a finding whose path no longer exists, and the CRITICAL suffix survives" {
|
||||
# A since-deleted path has no content left to pin a `safe` approval to -
|
||||
# writing one anyway (the old behaviour) produced a baseline entry
|
||||
# baseline_suppresses() can never match (its own empty-sha guard rejects
|
||||
# it as malformed), so the surface stayed permanently unsuppressible -
|
||||
# while `a` still advanced crit_acked and let the report shed its
|
||||
# -CRITICAL.txt protection for good. Refusing outright keeps both the
|
||||
# baseline and the report's protection honest.
|
||||
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
|
||||
snap; echo 'export X=1' > "$WS/.envrc"; cmp_ >/dev/null 2>&1
|
||||
rm -f "$WS/.envrc" # gone by the time ack runs
|
||||
run bash -c "printf 'a\n' | '$AUDIT' ack '$WS'"
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *"cannot approve: .envrc no longer exists"* ]]
|
||||
[[ "$output" == *"nothing recorded"* ]]
|
||||
[ ! -f "$BASEDIR/$slug" ] # nothing written to the baseline at all
|
||||
run bash -c "ls '$AUDITDIR/$slug'"
|
||||
[[ "$output" == *"-CRITICAL.txt"* ]] # unacknowledged CRITICAL keeps its protection
|
||||
}
|
||||
|
||||
@test "a workspace path containing its own ': ' cannot hijack ack's id derivation" {
|
||||
# Trigger (a) of CRITICAL 1: id="${body##*: }" took everything after the
|
||||
# LAST ": " in body, so a CRITICAL path containing a literal ": " (a
|
||||
# perfectly legal filename byte sequence) yielded the wrong id - here the
|
||||
# old code would derive "decoy" instead of the real path, so approving it
|
||||
# would baseline an id that can never match the real finding while still
|
||||
# letting the report shed -CRITICAL.txt.
|
||||
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
|
||||
snap
|
||||
mkdir -p "$WS/.git/hooks"
|
||||
printf '#!/bin/sh\necho pwned\n' > "$WS/.git/hooks/pre-commit: decoy"
|
||||
run cmp_
|
||||
[[ "$output" == *"HOST-CODE-EXECUTION"* ]]
|
||||
[[ "$output" == *".git/hooks/pre-commit: decoy"* ]]
|
||||
run bash -c "printf 'a\n' | '$AUDIT' ack '$WS'"
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *"recorded 1 acknowledgement"* ]]
|
||||
grep -q '^safe \.git/hooks/pre-commit: decoy ' "$BASEDIR/$slug"
|
||||
! grep -q '^safe decoy ' "$BASEDIR/$slug"
|
||||
run bash -c "ls '$AUDITDIR/$slug'"
|
||||
[[ "$output" != *"-CRITICAL.txt"* ]] # correctly acked: suffix dropped
|
||||
run cmp_
|
||||
[[ "$output" != *"pre-commit: decoy"* ]] # ...and the id round-trips through baseline_suppresses
|
||||
}
|
||||
|
||||
# --- Task 8b: every CRITICAL finding type must be acknowledgeable, not just
|
||||
# path findings, or a report carrying a non-path CRITICAL (git config,
|
||||
# statusline, TCB) can never clear its -CRITICAL.txt suffix. ---
|
||||
|
||||
@test "a safe-listed gitconfig id with a matching hash suppresses a command-execution finding" {
|
||||
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
|
||||
snap
|
||||
git -C "$WS" config core.hooksPath /tmp/evil
|
||||
sha="$(printf '%s' "/tmp/evil" | sha256sum | cut -d' ' -f1)"
|
||||
echo "safe gitconfig:core.hooksPath $sha" > "$BASEDIR/$slug"
|
||||
run cmp_
|
||||
[ "$status" -eq 0 ]
|
||||
[ "$output" = "" ]
|
||||
}
|
||||
|
||||
@test "a safe-listed gitconfig id re-fires when the value changes" {
|
||||
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
|
||||
snap
|
||||
git -C "$WS" config core.hooksPath /tmp/evil
|
||||
echo "safe gitconfig:core.hooksPath 0000000000000000000000000000000000000000000000000000000000000000" > "$BASEDIR/$slug"
|
||||
run cmp_
|
||||
[[ "$output" == *"COMMAND-EXECUTION"* ]]
|
||||
[[ "$output" == *"core.hooksPath"* ]]
|
||||
}
|
||||
|
||||
@test "a hand-written spam gitconfig entry cannot silence a CRITICAL command-execution surface" {
|
||||
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
|
||||
snap
|
||||
echo "spam gitconfig:core.hooksPath" > "$BASEDIR/$slug"
|
||||
git -C "$WS" config core.hooksPath /tmp/evil
|
||||
run cmp_
|
||||
[[ "$output" == *"COMMAND-EXECUTION"* ]]
|
||||
[[ "$output" == *"core.hooksPath"* ]]
|
||||
}
|
||||
|
||||
@test "ack records a safe gitconfig entry whose id round-trips and suppresses the next compare" {
|
||||
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
|
||||
snap
|
||||
git -C "$WS" config core.hooksPath /tmp/evil
|
||||
cmp_ >/dev/null 2>&1
|
||||
run bash -c "printf 'a\n' | '$AUDIT' ack '$WS'"
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *"recorded 1 acknowledgement"* ]]
|
||||
grep -q '^safe gitconfig:core.hooksPath ' "$BASEDIR/$slug"
|
||||
run cmp_
|
||||
[[ "$output" != *"core.hooksPath"* ]]
|
||||
}
|
||||
|
||||
@test "ack records a safe gitconfig alias entry whose id round-trips and suppresses the next compare" {
|
||||
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
|
||||
snap
|
||||
git -C "$WS" config alias.pwn '!touch /tmp/x'
|
||||
cmp_ >/dev/null 2>&1
|
||||
run bash -c "printf 'a\n' | '$AUDIT' ack '$WS'"
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *"recorded 1 acknowledgement"* ]]
|
||||
grep -q '^safe gitconfig:alias.pwn ' "$BASEDIR/$slug"
|
||||
run cmp_
|
||||
[[ "$output" != *"alias.pwn"* ]]
|
||||
}
|
||||
|
||||
@test "a report whose only CRITICAL is a git-config finding can be fully acknowledged and loses the CRITICAL suffix" {
|
||||
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
|
||||
snap
|
||||
git -C "$WS" config core.hooksPath /tmp/evil
|
||||
cmp_ >/dev/null 2>&1
|
||||
run bash -c "ls '$AUDITDIR/$slug'"
|
||||
[[ "$output" == *"-CRITICAL.txt"* ]]
|
||||
run bash -c "printf 'a\n' | '$AUDIT' ack '$WS'"
|
||||
[ "$status" -eq 0 ]
|
||||
run bash -c "ls '$AUDITDIR/$slug'"
|
||||
[[ "$output" != *"-CRITICAL.txt"* ]]
|
||||
}
|
||||
|
||||
@test "a safe-listed statusline id with a matching hash suppresses a statusline finding" {
|
||||
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
|
||||
printf '#!/bin/sh\necho original\n' > "$DEJIMA_AUDIT_STATUSLINE"
|
||||
snap
|
||||
printf '#!/bin/sh\necho pwned\n' > "$DEJIMA_AUDIT_STATUSLINE"
|
||||
sha="$(sha256sum "$DEJIMA_AUDIT_STATUSLINE" | cut -d' ' -f1)"
|
||||
echo "safe statusline:statusline.sh $sha" > "$BASEDIR/$slug"
|
||||
run cmp_
|
||||
[ "$status" -eq 0 ]
|
||||
[ "$output" = "" ]
|
||||
}
|
||||
|
||||
@test "ack records a safe statusline entry whose id round-trips and suppresses the next compare" {
|
||||
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
|
||||
printf '#!/bin/sh\necho original\n' > "$DEJIMA_AUDIT_STATUSLINE"
|
||||
snap
|
||||
printf '#!/bin/sh\necho pwned\n' > "$DEJIMA_AUDIT_STATUSLINE"
|
||||
cmp_ >/dev/null 2>&1
|
||||
run bash -c "printf 'a\n' | '$AUDIT' ack '$WS'"
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *"recorded 1 acknowledgement"* ]]
|
||||
grep -q '^safe statusline:statusline.sh ' "$BASEDIR/$slug"
|
||||
run cmp_
|
||||
[ "$status" -eq 0 ]
|
||||
[ "$output" = "" ]
|
||||
}
|
||||
|
||||
@test "a safe-listed tcb id with a matching hash suppresses a TCB finding" {
|
||||
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
|
||||
mkdir -p "$WS/bin"; echo x > "$WS/bin/x"; echo x > "$WS/install.sh"
|
||||
export DEJIMA_AUDIT_REPO_ROOT="$(readlink -f "$WS")"
|
||||
snap
|
||||
echo changed > "$WS/bin/x"
|
||||
sha="$(sha256sum "$WS/bin/x" | cut -d' ' -f1)"
|
||||
echo "safe tcb:bin/x $sha" > "$BASEDIR/$slug"
|
||||
run cmp_
|
||||
[[ "$output" != *"TCB"* ]]
|
||||
}
|
||||
|
||||
@test "ack records a safe tcb entry whose id round-trips and suppresses the next compare" {
|
||||
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
|
||||
mkdir -p "$WS/bin"; echo x > "$WS/bin/x"; echo x > "$WS/install.sh"
|
||||
export DEJIMA_AUDIT_REPO_ROOT="$(readlink -f "$WS")"
|
||||
snap
|
||||
echo changed > "$WS/bin/x"
|
||||
cmp_ >/dev/null 2>&1
|
||||
run bash -c "printf 'a\n' | '$AUDIT' ack '$WS'"
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *"recorded 1 acknowledgement"* ]]
|
||||
grep -q '^safe tcb:bin/x ' "$BASEDIR/$slug"
|
||||
run cmp_
|
||||
[[ "$output" != *"TCB"* ]]
|
||||
}
|
||||
|
||||
@test "a crafted git config value cannot hijack ack's id derivation" {
|
||||
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
|
||||
snap
|
||||
git -C "$WS" config core.hooksPath 'workspace file: /tmp/evil'
|
||||
cmp_ >/dev/null 2>&1
|
||||
run bash -c "printf 'a\n' | '$AUDIT' ack '$WS'"
|
||||
grep -q '^safe gitconfig:core.hooksPath' "$BASEDIR/$slug"
|
||||
! grep -q '/tmp/evil ' "$BASEDIR/$slug"
|
||||
run bash -c "ls '$AUDITDIR/$slug'"
|
||||
[[ "$output" != *"-CRITICAL.txt"* ]]
|
||||
}
|
||||
|
||||
@test "a crafted git config value cannot hijack id derivation when the finding is skipped" {
|
||||
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
|
||||
snap
|
||||
git -C "$WS" config core.hooksPath 'workspace file: /tmp/evil'
|
||||
cmp_ >/dev/null 2>&1
|
||||
run bash -c "printf 'c\n' | '$AUDIT' ack '$WS'"
|
||||
run bash -c "ls '$AUDITDIR/$slug'"
|
||||
[[ "$output" == *"-CRITICAL.txt"* ]]
|
||||
}
|
||||
|
||||
@test "ack still refuses to spam a CRITICAL git-config surface" {
|
||||
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
|
||||
snap
|
||||
git -C "$WS" config core.hooksPath /tmp/evil
|
||||
mkdir -p "$WS/.vscode"; echo '{}' > "$WS/.vscode/settings.json"
|
||||
cmp_ >/dev/null 2>&1
|
||||
# 's' on the CRITICAL gitconfig finding, then 'a' on the WARNING one that
|
||||
# follows it - so $BASEDIR/$slug is guaranteed to exist afterward and the
|
||||
# negative grep below can't pass vacuously against a missing file.
|
||||
run bash -c "printf 's\na\n' | '$AUDIT' ack '$WS'"
|
||||
[[ "$output" == *"cannot 'always ignore' a CRITICAL surface - approve the exact content or skip."* ]]
|
||||
[ -f "$BASEDIR/$slug" ]
|
||||
! grep -q '^spam gitconfig:core.hooksPath' "$BASEDIR/$slug"
|
||||
}
|
||||
|
||||
Vendored
+5
@@ -39,6 +39,11 @@ class Handler(BaseHTTPRequestHandler):
|
||||
"method": self.command,
|
||||
"path": self.path,
|
||||
"headers": {k.lower(): v for k, v in self.headers.items()},
|
||||
# The same headers unflattened: name as it arrived on the wire, in
|
||||
# arrival order, repeats included. `headers` above answers "what
|
||||
# value got through"; this answers "what did the block look like",
|
||||
# which is what the proxy's wire-fidelity tests assert on.
|
||||
"header_lines": [[k, v] for k, v in self.headers.items()],
|
||||
"body": body.decode("utf-8", "replace"),
|
||||
}).encode())
|
||||
|
||||
|
||||
+48
-4
@@ -162,8 +162,8 @@ EOF
|
||||
}
|
||||
|
||||
@test "project_slug matches the log-shipper project-key rule" {
|
||||
run bash -c 'source "$1"; project_slug "/home/brad/my-proj"' _ "$LIB"
|
||||
[ "$output" = "-home-brad-my-proj" ]
|
||||
run bash -c 'source "$1"; project_slug "/home/someone/my-proj"' _ "$LIB"
|
||||
[ "$output" = "-home-someone-my-proj" ]
|
||||
}
|
||||
|
||||
@test "ensure_project_store creates the store and records the real path" {
|
||||
@@ -718,6 +718,25 @@ ENTRYPOINT [\"/usr/local/bin/entrypoint.sh\"]" ]]
|
||||
[ ! -f "$LAUNCH_ENV" ]
|
||||
}
|
||||
|
||||
@test "the launcher refuses to start on a settings.json that isn't valid JSON" {
|
||||
# Claude Code ignores an unparseable settings.json wholesale, with only a
|
||||
# startup warning, so the session would come up with no permissions, no
|
||||
# hooks and no skipDangerousModePermissionPrompt - a bypass-mode warning
|
||||
# and nothing pointing at the settings file. A stray character appended to
|
||||
# the real file is exactly how this turned up.
|
||||
launch_setup
|
||||
printf '{"theme": "dark"}\nx\n' > "$LAUNCH_DIR/home/.claude/settings.json"
|
||||
run launch "$PROJ"
|
||||
[ "$status" -ne 0 ]
|
||||
[[ "$output" == *"settings.json is not valid JSON"* ]]
|
||||
[ ! -f "$LAUNCH_ENV" ]
|
||||
|
||||
printf '{"theme": "dark"}\n' > "$LAUNCH_DIR/home/.claude/settings.json"
|
||||
run launch "$PROJ"
|
||||
[ "$status" -eq 0 ]
|
||||
[ -f "$LAUNCH_ENV" ]
|
||||
}
|
||||
|
||||
@test "the launcher runs the derived image only when the project has approved dependencies" {
|
||||
launch_setup
|
||||
run launch "$PROJ"
|
||||
@@ -784,12 +803,37 @@ ENTRYPOINT [\"/usr/local/bin/entrypoint.sh\"]" ]]
|
||||
launch_setup
|
||||
run launch "$PROJ"
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" != *"no longer out of reach"* ]]
|
||||
[[ "$output" != *"writable from inside the sandbox"* ]]
|
||||
|
||||
# Same project, but with the store somewhere the session can rewrite it.
|
||||
export DEJIMA_CONFIG_DIR="$PROJ/.config/dejima"
|
||||
"$REPO_ROOT/bin/dejima-dep" add apt:libpq-dev --project "$PROJ" >/dev/null
|
||||
run launch "$PROJ"
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *"no longer out of reach"* ]]
|
||||
[[ "$output" == *"the approval store"* ]]
|
||||
[[ "$output" == *"approve its own domains and dependencies"* ]]
|
||||
}
|
||||
|
||||
@test "the launcher warns when the audit report or baseline store sits inside the mounted workspace" {
|
||||
launch_setup
|
||||
run launch "$PROJ"
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" != *"writable from inside the sandbox"* ]]
|
||||
|
||||
export DEJIMA_AUDIT_DIR="$PROJ/.audit"
|
||||
run launch "$PROJ"
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *"the audit report store"* ]]
|
||||
[[ "$output" == *"delete the exit-time reports of earlier runs"* ]]
|
||||
# Right alarm, right consequence: these three stores used to share the
|
||||
# approval store's text verbatim, so the audit store described a hazard
|
||||
# that was not the one it has.
|
||||
[[ "$output" != *"the approval store"* ]]
|
||||
unset DEJIMA_AUDIT_DIR
|
||||
|
||||
export DEJIMA_AUDIT_BASELINE_DIR="$PROJ/.baselines"
|
||||
run launch "$PROJ"
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *"the audit baseline store"* ]]
|
||||
[[ "$output" == *"write its own acknowledgements"* ]]
|
||||
}
|
||||
|
||||
+171
@@ -68,6 +68,99 @@ teardown() {
|
||||
[[ "$beta" == *"oauth-2025-04-20"* ]]
|
||||
}
|
||||
|
||||
@test "the client's header block reaches upstream with its own casing and order" {
|
||||
# The container half of this sandbox exists to make the session look like
|
||||
# a stock install. If the proxy rewrote the request into CPython's house
|
||||
# style on its way out, that fingerprint would simply move from the client
|
||||
# to the wire, where the API sees it instead. Sent in a deliberately
|
||||
# un-CPython shape: mixed casing, and a header after the one the proxy has
|
||||
# to rewrite, so a rewrite that reorders shows up here.
|
||||
start_proxy
|
||||
run pcurl -H "Content-Type: application/json" -H "Anthropic-Version: 2023-06-01" \
|
||||
-H "X-Stainless-Lang: js" -d '{}' http://proxy/v1/messages
|
||||
[ "$status" -eq 0 ]
|
||||
names="$(jq -r '.header_lines[][0]' <<<"$output")"
|
||||
grep -qx 'Content-Type' <<<"$names"
|
||||
grep -qx 'Anthropic-Version' <<<"$names"
|
||||
grep -qx 'X-Stainless-Lang' <<<"$names"
|
||||
# Position, not just presence: Authorization keeps the slot curl gave it,
|
||||
# ahead of the three above, rather than being appended after them.
|
||||
auth_at="$(jq -r '[.header_lines[][0] | ascii_downcase] | index("authorization")' <<<"$output")"
|
||||
type_at="$(jq -r '[.header_lines[][0] | ascii_downcase] | index("content-type")' <<<"$output")"
|
||||
[ "$auth_at" -lt "$type_at" ]
|
||||
[ "$(jq -r '.headers.authorization' <<<"$output")" = "Bearer real-access-token" ]
|
||||
}
|
||||
|
||||
@test "no Accept-Encoding is invented for a client that sent none" {
|
||||
# http.client's putrequest() adds `Accept-Encoding: identity` whenever the
|
||||
# caller passed none - a CPython tell that no browser or Node client
|
||||
# sends. curl sends no accept-encoding unless asked, so upstream must see
|
||||
# none at all.
|
||||
start_proxy
|
||||
run pcurl -d '{}' http://proxy/v1/messages
|
||||
[ "$(jq -r '[.header_lines[][0] | ascii_downcase | select(. == "accept-encoding")] | length' <<<"$output")" = "0" ]
|
||||
# ...and a client that does send one keeps its own value, unaltered.
|
||||
run pcurl -H "Accept-Encoding: gzip, deflate" -d '{}' http://proxy/v1/messages
|
||||
[ "$(jq -r '.headers["accept-encoding"]' <<<"$output")" = "gzip, deflate" ]
|
||||
}
|
||||
|
||||
@test "repeated headers survive as repeats instead of collapsing to the last" {
|
||||
# Rebuilding the block through a dict silently keeps only the last value.
|
||||
start_proxy
|
||||
run pcurl -H "X-Dup: one" -H "X-Dup: two" -d '{}' http://proxy/v1/messages
|
||||
[ "$(jq -r '[.header_lines[] | select(.[0] | ascii_downcase == "x-dup") | .[1]] | join("|")' <<<"$output")" = "one|two" ]
|
||||
}
|
||||
|
||||
@test "the injected token is sent once even when the client repeats its placeholder" {
|
||||
# The replacement is per-header-name, not per-occurrence: a duplicated
|
||||
# Authorization must not become two copies of the real token.
|
||||
start_proxy
|
||||
run curl -s --unix-socket "$SOCK" -H "Authorization: Bearer $PLACEHOLDER" \
|
||||
-H "Authorization: Bearer $PLACEHOLDER" -d '{}' http://proxy/v1/messages
|
||||
[ "$(jq -r '[.header_lines[] | select(.[0] | ascii_downcase == "authorization")] | length' <<<"$output")" = "1" ]
|
||||
[ "$(jq -r '.headers.authorization' <<<"$output")" = "Bearer real-access-token" ]
|
||||
[[ "$output" != *"$PLACEHOLDER"* ]]
|
||||
}
|
||||
|
||||
@test "an anthropic-beta that already carries the OAuth value is forwarded untouched" {
|
||||
# Re-joining a list that needs no change would normalize the client's
|
||||
# spacing - the same class of tell as the casing. This is the common case
|
||||
# in the tls transport, where Claude Code sends the header itself.
|
||||
start_proxy
|
||||
run pcurl -H "anthropic-beta: oauth-2025-04-20, context-1m-2025-08-07" \
|
||||
-d '{}' http://proxy/v1/messages
|
||||
[ "$(jq -r '.headers["anthropic-beta"]' <<<"$output")" = "oauth-2025-04-20, context-1m-2025-08-07" ]
|
||||
}
|
||||
|
||||
@test "a bodyless request gains no Content-Length the client never sent" {
|
||||
start_proxy
|
||||
run pcurl http://proxy/v1/models
|
||||
[ "$(jq -r '[.header_lines[][0] | ascii_downcase | select(. == "content-length")] | length' <<<"$output")" = "0" ]
|
||||
}
|
||||
|
||||
@test "a chunked request is de-framed into a Content-Length upstream" {
|
||||
# The proxy has to buffer to inject the token, so chunked framing cannot
|
||||
# survive - but the length it declares must match what it forwards, and
|
||||
# the hop-by-hop Transfer-Encoding must not travel with it.
|
||||
start_proxy
|
||||
run curl -s --unix-socket "$SOCK" -H "Transfer-Encoding: chunked" \
|
||||
-H "Authorization: Bearer $PLACEHOLDER" \
|
||||
-d '{"hello":1}' http://proxy/v1/messages
|
||||
[ "$(jq -r '.body' <<<"$output")" = '{"hello":1}' ]
|
||||
[ "$(jq -r '.headers["content-length"]' <<<"$output")" = "11" ]
|
||||
[ "$(jq -r '.headers["transfer-encoding"] // "absent"' <<<"$output")" = "absent" ]
|
||||
}
|
||||
|
||||
@test "Host is rewritten for the upstream, in place" {
|
||||
# It must name the upstream, not the socket the container reached - but it
|
||||
# is replaced where the client put it rather than dropped and re-added,
|
||||
# which is what keeps the rest of the block in order.
|
||||
start_proxy
|
||||
run pcurl -d '{}' http://proxy/v1/messages
|
||||
[ "$(jq -r '.headers.host' <<<"$output")" = "127.0.0.1:$PORT" ]
|
||||
[ "$(jq -r '[.header_lines[][0] | ascii_downcase] | index("host")' <<<"$output")" = "0" ]
|
||||
}
|
||||
|
||||
@test "passes upstream status and body through unchanged" {
|
||||
start_proxy
|
||||
run bash -c 'curl -s -o "$1/body" -w "%{http_code}" --unix-socket "$2" http://proxy/status-503' _ "$TD" "$SOCK"
|
||||
@@ -184,6 +277,7 @@ 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 \
|
||||
node "$REPO_ROOT/docker/anthropic-proxy-forwarder.js" &
|
||||
FWD_PID=$!
|
||||
ok=""
|
||||
@@ -242,3 +336,80 @@ PYEOF
|
||||
run cat "$TD/stderr"
|
||||
[[ "$output" != *"501"* ]]
|
||||
}
|
||||
|
||||
# The forwarder is the container-side half of the chain. In tls mode it
|
||||
# terminates TLS for api.anthropic.com on loopback so Claude Code needs no
|
||||
# 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" \
|
||||
node "$REPO_ROOT/docker/anthropic-proxy-forwarder.js" 2>"$TD/fwd-stderr" &
|
||||
FWD_PID=$!
|
||||
for _ in $(seq 1 50); do
|
||||
(exec 3<>"/dev/tcp/127.0.0.1/$2") 2>/dev/null && return 0
|
||||
sleep 0.1
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
make_cert() {
|
||||
openssl req -x509 -newkey rsa:2048 -nodes -days 1 \
|
||||
-keyout "$TD/key.pem" -out "$TD/cert.pem" \
|
||||
-subj "/CN=api.anthropic.com" \
|
||||
-addext "subjectAltName=DNS:api.anthropic.com" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
@test "forwarder tls mode: https to api.anthropic.com reaches the proxy, which injects the real token" {
|
||||
make_cert
|
||||
start_proxy
|
||||
start_forwarder tls 18443
|
||||
# --resolve is what stands in for the container's /etc/hosts entry: the
|
||||
# name must really be api.anthropic.com for certificate verification to
|
||||
# be a meaningful check, not a hostname the test picked to be easy.
|
||||
run curl -s --cacert "$TD/cert.pem" \
|
||||
--resolve "api.anthropic.com:18443:127.0.0.1" \
|
||||
-X POST "https://api.anthropic.com:18443/v1/messages" -d '{}'
|
||||
kill "$FWD_PID" 2>/dev/null || true
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *'"authorization": "Bearer real-access-token"'* ]]
|
||||
# No Authorization header was sent at all - the proxy supplied it. This is
|
||||
# the auth fingerprint closing: no placeholder token in the container.
|
||||
[[ "$output" != *"$PLACEHOLDER"* ]]
|
||||
}
|
||||
|
||||
@test "forwarder tls mode pins ALPN to http/1.1 so the client cannot negotiate h2" {
|
||||
# The upstream is a Python http.server, which speaks only HTTP/1.1: an h2
|
||||
# negotiation would deliver frames it cannot parse, and the byte pipe has
|
||||
# no HTTP awareness to catch it.
|
||||
make_cert
|
||||
start_proxy
|
||||
start_forwarder tls 18444
|
||||
run openssl s_client -connect 127.0.0.1:18444 -alpn h2,http/1.1 \
|
||||
-servername api.anthropic.com -CAfile "$TD/cert.pem" </dev/null
|
||||
kill "$FWD_PID" 2>/dev/null || true
|
||||
[[ "$output" == *"ALPN protocol: http/1.1"* ]]
|
||||
}
|
||||
|
||||
@test "forwarder loopback mode is unchanged: plaintext http on the old port" {
|
||||
start_proxy
|
||||
start_forwarder loopback 18445
|
||||
run curl -s -X POST "http://127.0.0.1:18445/v1/messages" -d '{}'
|
||||
kill "$FWD_PID" 2>/dev/null || true
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *'"authorization": "Bearer real-access-token"'* ]]
|
||||
}
|
||||
|
||||
@test "forwarder tls mode refuses to start without a cert, rather than silently downgrading" {
|
||||
start_proxy
|
||||
# `run env ...`, not a `VAR=x run ...` prefix: an assignment prefixed to a
|
||||
# shell *function* persists in the calling shell rather than scoping to
|
||||
# the call, so it would leak into every later test in this file.
|
||||
run env SANDBOX_ANTHROPIC_PROXY_SOCKET="$SOCK" SANDBOX_ANTHROPIC_TRANSPORT=tls \
|
||||
node "$REPO_ROOT/docker/anthropic-proxy-forwarder.js"
|
||||
[ "$status" -ne 0 ]
|
||||
[[ "$output" == *"SANDBOX_ANTHROPIC_TLS_CERT"* ]]
|
||||
}
|
||||
|
||||
+2684
File diff suppressed because it is too large
Load Diff
+364
-80
@@ -73,12 +73,25 @@ setup_file() {
|
||||
docker network rm "$network" >/dev/null 2>&1 || true
|
||||
docker network create "$network" >/dev/null
|
||||
|
||||
# No GITEA_*/GH_TOKEN/LOKI_URL: this suite only asserts the default
|
||||
# allowlist and enforcement, which shouldn't depend on any of them.
|
||||
# No GITEA_*/LOKI_URL: this suite only asserts the default allowlist and
|
||||
# enforcement, which shouldn't depend on either.
|
||||
#
|
||||
# GH_TOKEN is forwarded rather than dropped, and every container start in
|
||||
# this file does the same. It doesn't change the allowlist - it only
|
||||
# authenticates init-firewall.sh's api.github.com/meta fetch, which
|
||||
# returns the same ranges either way. What it changes is the quota: that
|
||||
# endpoint allows 60 requests an hour per source IP unauthenticated, and
|
||||
# this file starts sixteen containers, so two runs in an hour exhaust it
|
||||
# and every container after that dies in init-firewall.sh with a rate
|
||||
# limit error rather than anything to do with what was being tested.
|
||||
# Locally it's usually unset and expands empty, which both init-firewall
|
||||
# and entrypoint treat exactly as absent - so the no-token path stays the
|
||||
# default here and only CI, where the volume is, passes a real one.
|
||||
docker run -d --name "$container" \
|
||||
--cap-add=NET_ADMIN --cap-add=NET_RAW \
|
||||
--network "$network" \
|
||||
-e SANDBOX_TEST_MODE=1 \
|
||||
-e GH_TOKEN="${GH_TOKEN:-}" \
|
||||
-e ANTHROPIC_BASE_URL= \
|
||||
-e ANTHROPIC_AUTH_TOKEN= \
|
||||
--entrypoint /usr/local/bin/entrypoint.sh \
|
||||
@@ -93,6 +106,19 @@ teardown_file() {
|
||||
}
|
||||
|
||||
teardown() {
|
||||
# Host listeners started by start_host_listener (unset for every other
|
||||
# test - each test re-sources this file in its own shell, so nothing
|
||||
# leaks in from the previous one). Killing them here rather than at the
|
||||
# end of the test body is what makes the teardown unconditional: bats
|
||||
# runs test bodies under errexit, so any failed assertion abandons the
|
||||
# rest of the body, and a kill sitting there would leave a python
|
||||
# process holding a port on the operator's host. teardown runs in the
|
||||
# same shell as the body even after the body aborted, so the PIDs it
|
||||
# set are visible here. An EXIT trap in the body is not the
|
||||
# alternative - it replaces the one bats installs and the run dies
|
||||
# reporting zero executed tests.
|
||||
local pid
|
||||
for pid in ${HOST_LISTENER_PIDS:-}; do kill "$pid" 2>/dev/null || true; done
|
||||
docker rm -f "dejima-test-override-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
|
||||
docker rmi "dejima-test-override:$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
|
||||
docker rm -f "dejima-test-sshkey-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
|
||||
@@ -107,6 +133,9 @@ teardown() {
|
||||
docker rm -f "dejima-test-derived-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
|
||||
docker rmi "dejima-test-derived:$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
|
||||
docker rm -f "dejima-test-ctxreq-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
|
||||
docker rm -f "dejima-test-tls-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
|
||||
docker rm -f "dejima-test-nohosts-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
|
||||
docker rm -f "dejima-test-badtransport-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
dexec() {
|
||||
@@ -134,6 +163,7 @@ dexec_node() {
|
||||
--cap-add=NET_ADMIN --cap-add=NET_RAW \
|
||||
--network "$(network_name)" \
|
||||
-e SANDBOX_TEST_MODE=1 \
|
||||
-e GH_TOKEN="${GH_TOKEN:-}" \
|
||||
--entrypoint /usr/local/bin/entrypoint.sh \
|
||||
"$test_image" >/dev/null
|
||||
|
||||
@@ -161,6 +191,7 @@ dexec_node() {
|
||||
--cap-add=NET_ADMIN --cap-add=NET_RAW \
|
||||
--network "$(network_name)" \
|
||||
-e SANDBOX_TEST_MODE=1 \
|
||||
-e GH_TOKEN="${GH_TOKEN:-}" \
|
||||
-v "$key_dir/testkey:/root/.ssh-sandbox-key-src:ro" \
|
||||
--entrypoint /usr/local/bin/entrypoint.sh \
|
||||
"$IMAGE" >/dev/null
|
||||
@@ -215,6 +246,7 @@ dexec_node() {
|
||||
--cap-add=NET_ADMIN --cap-add=NET_RAW \
|
||||
--network "$(network_name)" \
|
||||
-e SANDBOX_TEST_MODE=1 \
|
||||
-e GH_TOKEN="${GH_TOKEN:-}" \
|
||||
-v "$mounted_copy:/home/node/.claude.json" \
|
||||
--entrypoint /usr/local/bin/entrypoint.sh \
|
||||
"$IMAGE" >/dev/null
|
||||
@@ -248,6 +280,17 @@ dexec_node() {
|
||||
}
|
||||
|
||||
@test "an allowlisted domain resolves via the DNS proxy" {
|
||||
run dexec dig +short registry.npmjs.org
|
||||
[ -n "$output" ]
|
||||
}
|
||||
|
||||
@test "api.anthropic.com resolves in proxy-off mode (kill switch / API-key auth)" {
|
||||
# api.anthropic.com is left off the allowlist when the credential proxy is
|
||||
# on, so a bypass of the /etc/hosts pin that resolves the name by DNS gets
|
||||
# no address. When the proxy is off (kill switch or API-key auth), DNS is
|
||||
# the only path to the API and fixed-domains.sh allowlists it. This file's
|
||||
# shared container runs with no SANDBOX_ANTHROPIC_PROXY_SOCKET, so it is
|
||||
# in proxy-off mode and the name must resolve.
|
||||
run dexec dig +short api.anthropic.com
|
||||
[ -n "$output" ]
|
||||
}
|
||||
@@ -273,6 +316,7 @@ dexec_node() {
|
||||
--cap-add=NET_ADMIN --cap-add=NET_RAW \
|
||||
--network "$(network_name)" \
|
||||
-e SANDBOX_TEST_MODE=1 \
|
||||
-e GH_TOKEN="${GH_TOKEN:-}" \
|
||||
-e ALLOW_GITHUB_PUSH=1 \
|
||||
--entrypoint /usr/local/bin/entrypoint.sh \
|
||||
"$IMAGE" >/dev/null
|
||||
@@ -291,6 +335,7 @@ dexec_node() {
|
||||
--cap-add=NET_ADMIN --cap-add=NET_RAW \
|
||||
--network "$(network_name)" \
|
||||
-e SANDBOX_TEST_MODE=1 \
|
||||
-e GH_TOKEN="${GH_TOKEN:-}" \
|
||||
-e GITEA_SSH_HOST=gitea.internal.example \
|
||||
--entrypoint /usr/local/bin/entrypoint.sh \
|
||||
"$IMAGE" >/dev/null
|
||||
@@ -318,6 +363,7 @@ dexec_node() {
|
||||
--cap-add=NET_ADMIN --cap-add=NET_RAW \
|
||||
--network "$(network_name)" \
|
||||
-e SANDBOX_TEST_MODE=1 \
|
||||
-e GH_TOKEN="${GH_TOKEN:-}" \
|
||||
-v "$src/skills:/home/node/.claude/skills:ro" \
|
||||
-v "$src/commands:/home/node/.claude/commands:ro" \
|
||||
-v "$src/agents:/home/node/.claude/agents:ro" \
|
||||
@@ -333,81 +379,75 @@ dexec_node() {
|
||||
rm -rf "$src"
|
||||
}
|
||||
|
||||
@test "statusline.sh read-only mount blocks replacement when it's a plain regular file" {
|
||||
# Companion to the overlay-dirs test above, but for bin/dejima's
|
||||
# narrower statusline.sh-onto-itself mount (see its comment there) rather
|
||||
# than a whole subdirectory. This test does NOT use bin/dejima or
|
||||
# docker-compose.yml directly - it builds the same mount shape by hand: a
|
||||
# whole ~/.claude directory bind-mounted read-write (docker-compose.yml's
|
||||
# own ~/.claude mount), with statusline.sh itself bind-mounted read-only
|
||||
# back onto its own path on top, source and destination the same literal
|
||||
# path exactly as bin/dejima constructs it (container HOME
|
||||
# matches host HOME - see Dockerfile). This shared container isn't reused
|
||||
# for this (it has no ~/.claude mount at all - see setup_file).
|
||||
@test "a staged statusline.sh is copied in root-owned, and replacing it never reaches the host" {
|
||||
# Companion to the overlay-dirs test above, but for the config
|
||||
# bin/dejima stages at /etc/dejima-claude for entrypoint.sh to copy into
|
||||
# ~/.claude (see its comment there) rather than mounting at its real
|
||||
# path. This test does NOT use bin/dejima or docker-compose.yml directly
|
||||
# - it builds the same shape by hand. This shared container isn't reused
|
||||
# for it (it has no staging mount at all - see setup_file).
|
||||
local test_container="dejima-test-statusline-regular-$(basename "$BATS_FILE_TMPDIR")"
|
||||
local dotclaude; dotclaude="$(mktemp -d)"
|
||||
printf '#!/bin/sh\necho original\n' > "$dotclaude/statusline.sh"
|
||||
chmod +x "$dotclaude/statusline.sh"
|
||||
# mktemp -d is mode 700, which only works because the uid running these
|
||||
# tests is usually the same one the container's node maps to. Where it
|
||||
# isn't - CI runs as root, node is 1000 - node cannot even traverse this
|
||||
# directory. Writable, so that if the container's copy somehow did lead
|
||||
# back here the write below would succeed and this test would say so.
|
||||
chmod 777 "$dotclaude"
|
||||
|
||||
docker run -d --name "$test_container" \
|
||||
--cap-add=NET_ADMIN --cap-add=NET_RAW \
|
||||
--network "$(network_name)" \
|
||||
-e SANDBOX_TEST_MODE=1 \
|
||||
-v "$dotclaude:/home/node/.claude" \
|
||||
-v "$dotclaude/statusline.sh:/home/node/.claude/statusline.sh:ro" \
|
||||
-e GH_TOKEN="${GH_TOKEN:-}" \
|
||||
-v "$dotclaude/statusline.sh:/etc/dejima-claude/statusline.sh:ro" \
|
||||
--entrypoint /usr/local/bin/entrypoint.sh \
|
||||
"$IMAGE" >/dev/null
|
||||
wait_ready "$test_container"
|
||||
|
||||
# A `rm` on a bind-mounted file fails from ordinary mount-point-busy
|
||||
# semantics regardless of the :ro flag, so `rm -f X && echo pwned > X`
|
||||
# alone doesn't prove anything about read-only enforcement - the `&&`
|
||||
# short-circuits before the write it was meant to test ever runs, so
|
||||
# this would pass identically even without `:ro`. The write that
|
||||
# actually depends on the read-only flag is an in-place overwrite that
|
||||
# never unlinks the file first.
|
||||
run docker exec -u node "$test_container" cat /home/node/.claude/statusline.sh
|
||||
[[ "$output" == *"original"* ]]
|
||||
run docker exec -u node "$test_container" stat -c '%U %a' /home/node/.claude/statusline.sh
|
||||
[[ "$output" == "root 555" ]]
|
||||
|
||||
# An in-place overwrite fails on the mode alone. Unlinking and replacing
|
||||
# it succeeds - ~/.claude belongs to the session - but the copy is the
|
||||
# only thing that changes: nothing on the host is reachable from it.
|
||||
run docker exec -u node "$test_container" sh -c \
|
||||
'echo pwned > /home/node/.claude/statusline.sh'
|
||||
[ "$status" -ne 0 ]
|
||||
|
||||
run docker exec -u node "$test_container" sh -c \
|
||||
'truncate -s 0 /home/node/.claude/statusline.sh'
|
||||
[ "$status" -ne 0 ]
|
||||
'rm -f /home/node/.claude/statusline.sh && echo pwned > /home/node/.claude/statusline.sh'
|
||||
[ "$status" -eq 0 ]
|
||||
|
||||
run docker exec -u node "$test_container" cat /home/node/.claude/statusline.sh
|
||||
run cat "$dotclaude/statusline.sh"
|
||||
[[ "$output" == *"original"* ]]
|
||||
[[ "$output" != *"pwned"* ]]
|
||||
|
||||
docker rm -f "$test_container" >/dev/null 2>&1 || true
|
||||
rm -rf "$dotclaude"
|
||||
}
|
||||
|
||||
@test "statusline.sh read-only mount does NOT protect it when it's a symlink (Finding 1 - confirmed gap, see bin/dejima-audit)" {
|
||||
# Same mount shape as the plain-regular-file test above, except
|
||||
# statusline.sh on the host is a symlink into a separately-mounted
|
||||
# directory - the common real setup
|
||||
# (~/.claude/statusline.sh -> ~/dotfiles/statusline.sh). The parent
|
||||
# ~/.claude mount is established first (same order docker-compose.yml
|
||||
# uses), so by the time the more-specific statusline.sh mount below is
|
||||
# processed, the destination path already has that symlink sitting on
|
||||
# it - and bind-mount destination resolution (runc's SecureJoin) follows
|
||||
# symlinks within the rootfs. This CONFIRMS the mount resolves through
|
||||
# the symlink to the target directory (already read-only via the
|
||||
# separate target_dir mount below, mirroring bin/dejima's own
|
||||
# symlink-target scan) instead of binding to the symlink dentry itself -
|
||||
# so the symlink dentry stays a perfectly ordinary, writable entry on
|
||||
# the read-write ~/.claude mount, with no mountpoint-busy protection
|
||||
# either. This test is a characterization test of that CONFIRMED,
|
||||
# known, deliberately-documented limitation (see bin/dejima and
|
||||
# README "Security notes"), not an aspiration: it asserts the
|
||||
# replacement SUCCEEDS. The compensating control is detection, not
|
||||
# prevention - bin/dejima-audit hashes the symlink's target
|
||||
# string and reports a changed/repointed statusline.sh as a
|
||||
# host-code-execution finding after the session exits (see
|
||||
# test/audit.bats). If this test ever starts FAILING, that means
|
||||
# Docker/runc changed destination-symlink resolution and this gap may
|
||||
# have closed - that would be good news worth investigating and acting
|
||||
# on (tightening the comments/audit accordingly), not a regression to
|
||||
# "fix" back to the old behavior.
|
||||
@test "staging also protects a symlinked statusline.sh, which the old read-only mount did not (Finding 1)" {
|
||||
# Same shape as the plain-regular-file test above, except statusline.sh
|
||||
# on the host is a symlink into a separate directory - the common real
|
||||
# setup (~/.claude/statusline.sh -> a config checkout elsewhere). That
|
||||
# case used to be a CONFIRMED, documented gap: statusline.sh was mounted
|
||||
# read-only back onto its own path inside a read-write ~/.claude mount,
|
||||
# the parent mount was established first so the destination already had
|
||||
# the symlink sitting on it, and bind-mount destination resolution
|
||||
# (runc's SecureJoin) followed it - the :ro mount landed on the target
|
||||
# and the symlink dentry itself stayed an ordinary writable entry that a
|
||||
# session could unlink and replace. Detection (bin/dejima-audit hashing
|
||||
# the target string) was the only compensating control.
|
||||
#
|
||||
# Staging closes it outright, and for the opposite reason: source-side
|
||||
# symlink resolution is exactly what's wanted, so what lands at
|
||||
# /etc/dejima-claude is the real content, and entrypoint.sh copies it to
|
||||
# a path with no route back to the host at all. This test asserts the
|
||||
# replacement no longer reaches either the symlink or its target.
|
||||
local test_container="dejima-test-statusline-symlink-$(basename "$BATS_FILE_TMPDIR")"
|
||||
local target_dir dotclaude
|
||||
target_dir="$(mktemp -d)"
|
||||
@@ -415,14 +455,17 @@ dexec_node() {
|
||||
chmod +x "$target_dir/statusline.sh"
|
||||
dotclaude="$(mktemp -d)"
|
||||
ln -s "$target_dir/statusline.sh" "$dotclaude/statusline.sh"
|
||||
# Both mode 700 from mktemp -d; see the regular-file test above. Left
|
||||
# writable for the same reason: a write that did get through must be
|
||||
# able to land, or this would pass for reasons unrelated to the gap.
|
||||
chmod 777 "$target_dir" "$dotclaude"
|
||||
|
||||
docker run -d --name "$test_container" \
|
||||
--cap-add=NET_ADMIN --cap-add=NET_RAW \
|
||||
--network "$(network_name)" \
|
||||
-e SANDBOX_TEST_MODE=1 \
|
||||
-v "$dotclaude:/home/node/.claude" \
|
||||
-v "$target_dir:$target_dir:ro" \
|
||||
-v "$dotclaude/statusline.sh:/home/node/.claude/statusline.sh:ro" \
|
||||
-e GH_TOKEN="${GH_TOKEN:-}" \
|
||||
-v "$dotclaude/statusline.sh:/etc/dejima-claude/statusline.sh:ro" \
|
||||
--entrypoint /usr/local/bin/entrypoint.sh \
|
||||
"$IMAGE" >/dev/null
|
||||
wait_ready "$test_container"
|
||||
@@ -431,8 +474,11 @@ dexec_node() {
|
||||
'rm -f /home/node/.claude/statusline.sh && echo pwned > /home/node/.claude/statusline.sh'
|
||||
[ "$status" -eq 0 ]
|
||||
|
||||
run docker exec -u node "$test_container" cat /home/node/.claude/statusline.sh
|
||||
[[ "$output" == *"pwned"* ]]
|
||||
[ -L "$dotclaude/statusline.sh" ]
|
||||
[ "$(readlink "$dotclaude/statusline.sh")" = "$target_dir/statusline.sh" ]
|
||||
run cat "$target_dir/statusline.sh"
|
||||
[[ "$output" == *"original"* ]]
|
||||
[[ "$output" != *"pwned"* ]]
|
||||
|
||||
docker rm -f "$test_container" >/dev/null 2>&1 || true
|
||||
rm -rf "$target_dir" "$dotclaude"
|
||||
@@ -543,7 +589,7 @@ EOF'
|
||||
[ "$output" = "0" ]
|
||||
}
|
||||
|
||||
@test "credential proxy chain: loopback -> forwarder -> unix socket -> host proxy injects the real token" {
|
||||
@test "loopback transport: 127.0.0.1 -> forwarder -> unix socket -> host proxy injects the real token" {
|
||||
TD="$BATS_TEST_TMPDIR"
|
||||
python3 "$BATS_TEST_DIRNAME/fixtures/fake-anthropic.py" > "$TD/port" &
|
||||
FAKE_PID=$!
|
||||
@@ -556,13 +602,24 @@ EOF
|
||||
--upstream "http://127.0.0.1:$(cat "$TD/port")" &
|
||||
PROXY_PID=$!
|
||||
for _ in $(seq 1 50); do [ -S "$TD/proxy.sock" ] && break; sleep 0.1; done
|
||||
# UnixStreamServer binds at umask default, so the socket lands 755, and
|
||||
# connecting to a unix socket needs *write* permission on it. In the real
|
||||
# flow the connecting uid is the one that bound it, so that is fine;
|
||||
# under CI it is root binding and node connecting, and the whole chain
|
||||
# this test follows fails at its first hop. Widened rather than left to
|
||||
# dejima-anthropic-proxy, which has no such problem in production.
|
||||
# Unconditional: if the loop above timed out, "No such file" here says so
|
||||
# more clearly than a test that fails on a bare false condition.
|
||||
chmod 666 "$TD/proxy.sock"
|
||||
|
||||
container="dejima-test-proxy-$(basename "$BATS_FILE_TMPDIR")"
|
||||
docker run -d --name "$container" \
|
||||
--cap-add=NET_ADMIN --cap-add=NET_RAW \
|
||||
--network "$(network_name)" \
|
||||
-e SANDBOX_TEST_MODE=1 \
|
||||
-e GH_TOKEN="${GH_TOKEN:-}" \
|
||||
-e SANDBOX_ANTHROPIC_PROXY_SOCKET="$TD/proxy.sock" \
|
||||
-e SANDBOX_ANTHROPIC_TRANSPORT=loopback \
|
||||
-e ANTHROPIC_AUTH_TOKEN=sandbox-credential-proxy-placeholder \
|
||||
-v "$TD:$TD" \
|
||||
--entrypoint /usr/local/bin/entrypoint.sh \
|
||||
@@ -595,47 +652,69 @@ EOF
|
||||
[ "$env_hits" = "0" ]
|
||||
}
|
||||
|
||||
@test "credential mask: ~/.claude/.credentials.json in-container is empty, never the real token (the actual bin/dejima mask mechanism)" {
|
||||
@test "the real credential never enters the container, and a host-side refresh can't change that (#50)" {
|
||||
# Unlike the proxy-chain test above (which hand-builds a docker run to
|
||||
# exercise the proxy's HTTP path), this test reuses the exact masking
|
||||
# mechanism bin/dejima itself uses at launch (see
|
||||
# SANDBOX_CREDENTIALS_MASK_FILE there): a real ~/.claude directory
|
||||
# bind-mounted read-write, with an empty file layered read-write on top
|
||||
# at .credentials.json specifically - the same "more specific mount wins"
|
||||
# trick as the other ~/.claude overlays. This is the check the design
|
||||
# spec asked for: the real credential file's content must never be
|
||||
# visible at that path inside the container.
|
||||
# exercise the proxy's HTTP path), this test reuses the mount shape
|
||||
# bin/dejima itself launches with: ~/.claude is not mounted, only the
|
||||
# enumerated directories inside it are, and .credentials.json is not one
|
||||
# of them. Claude Code still finds a credentials file, because
|
||||
# entrypoint.sh creates an empty container-local one.
|
||||
#
|
||||
# The second half is the regression this test previously lacked, and #50
|
||||
# is what walked through the gap. The old shape mounted ~/.claude
|
||||
# read-write with an empty file layered over .credentials.json, and
|
||||
# asserting only at startup made that look airtight. But a single-file
|
||||
# bind mount is pinned to the dentry at its mount point: the first
|
||||
# host-side atomic replace - which is exactly how the proxy writes a
|
||||
# rotated token back, and how host-side Claude Code writes it too -
|
||||
# unlinked that dentry, the mask silently detached, and the path fell
|
||||
# through to the operator's real, current token. So replace the host
|
||||
# file the way a refresh does, and assert again afterwards.
|
||||
TD="$BATS_TEST_TMPDIR"
|
||||
mkdir -p "$TD/dotclaude"
|
||||
cat > "$TD/dotclaude/.credentials.json" <<EOF
|
||||
mkdir -p "$TD/dotclaude/projects" "$TD/dotclaude/plans"
|
||||
write_creds() {
|
||||
cat > "$1" <<EOF
|
||||
{"claudeAiOauth":{"accessToken":"real-access-token","refreshToken":"real-refresh-token","expiresAt":$((($(date +%s) + 7200) * 1000)),"refreshTokenExpiresAt":$((($(date +%s) + 86400) * 1000)),"scopes":[],"subscriptionType":"max","rateLimitTier":"default"}}
|
||||
EOF
|
||||
chmod -R 666 "$TD/dotclaude/.credentials.json"
|
||||
chmod 777 "$TD/dotclaude"
|
||||
mask_file="$TD/mask"
|
||||
: > "$mask_file"
|
||||
chmod 666 "$mask_file"
|
||||
}
|
||||
write_creds "$TD/dotclaude/.credentials.json"
|
||||
chmod 666 "$TD/dotclaude/.credentials.json"
|
||||
chmod -R 777 "$TD/dotclaude"
|
||||
|
||||
container="dejima-test-proxy-mask-$(basename "$BATS_FILE_TMPDIR")"
|
||||
docker run -d --name "$container" \
|
||||
--cap-add=NET_ADMIN --cap-add=NET_RAW \
|
||||
--network "$(network_name)" \
|
||||
-e SANDBOX_TEST_MODE=1 \
|
||||
-v "$TD/dotclaude:/home/node/.claude" \
|
||||
-v "$mask_file:/home/node/.claude/.credentials.json" \
|
||||
-e GH_TOKEN="${GH_TOKEN:-}" \
|
||||
-v "$TD/dotclaude/projects:/home/node/.claude/projects" \
|
||||
-v "$TD/dotclaude/plans:/home/node/.claude/plans" \
|
||||
--entrypoint /usr/local/bin/entrypoint.sh \
|
||||
"$IMAGE" >/dev/null
|
||||
wait_ready "$container"
|
||||
|
||||
run docker exec -u node "$container" cat /home/node/.claude/.credentials.json
|
||||
[ "$status" -eq 0 ]
|
||||
[ -z "$output" ]
|
||||
run docker exec -u node "$container" grep -rl real-access-token /home/node/.claude
|
||||
[ "$status" -ne 0 ]
|
||||
|
||||
# A token refresh, as os.replace() performs it: write a sibling temp file
|
||||
# and rename it over the credential.
|
||||
write_creds "$TD/dotclaude/.credentials.json.new"
|
||||
chmod 666 "$TD/dotclaude/.credentials.json.new"
|
||||
mv -f "$TD/dotclaude/.credentials.json.new" "$TD/dotclaude/.credentials.json"
|
||||
|
||||
run docker exec -u node "$container" cat /home/node/.claude/.credentials.json
|
||||
status_creds=$status; output_creds=$output
|
||||
run docker exec -u node "$container" grep -rl real-access-token /home/node/.claude
|
||||
status_grep=$status
|
||||
|
||||
docker rm -f "$container" >/dev/null 2>&1 || true
|
||||
|
||||
[ "$status_creds" -eq 0 ]
|
||||
[ -z "$output_creds" ]
|
||||
[[ "$output_creds" != *"real-access-token"* ]]
|
||||
[[ "$output_creds" != *"real-refresh-token"* ]]
|
||||
[ "$status_grep" -ne 0 ]
|
||||
}
|
||||
|
||||
@test "mid-session allowed domains get ongoing ipset tracking, not a one-time snapshot" {
|
||||
@@ -704,13 +783,22 @@ EOF
|
||||
printf 'declared-not-allowed.example\n' > "$ws/.dejima/allowed-domains"
|
||||
chmod -R 755 "$ws"
|
||||
|
||||
# Mounted from a copy, not straight out of the checkout. Whatever daemon
|
||||
# starts this container resolves -v sources against its own host, and
|
||||
# under CI the checkout lives in a volume that has no host path at all -
|
||||
# so the repo copy would mount as an empty directory rather than fail.
|
||||
# $BATS_TEST_TMPDIR sits under TMPDIR, which CI points somewhere shared.
|
||||
local hook="$BATS_TEST_TMPDIR/sandbox-context.sh"
|
||||
cp "$BATS_TEST_DIRNAME/../hooks/sandbox-context.sh" "$hook"
|
||||
|
||||
local test_container="dejima-test-ctxreq-$(basename "$BATS_FILE_TMPDIR")"
|
||||
docker run -d --name "$test_container" \
|
||||
--cap-add=NET_ADMIN --cap-add=NET_RAW \
|
||||
--network "$(network_name)" \
|
||||
-e SANDBOX_TEST_MODE=1 \
|
||||
-e GH_TOKEN="${GH_TOKEN:-}" \
|
||||
-v "$ws:$ws" \
|
||||
-v "$BATS_TEST_DIRNAME/../hooks/sandbox-context.sh:/usr/local/bin/sandbox-context.sh:ro" \
|
||||
-v "$hook:/usr/local/bin/sandbox-context.sh:ro" \
|
||||
--entrypoint /usr/local/bin/entrypoint.sh \
|
||||
"$IMAGE" >/dev/null
|
||||
wait_ready "$test_container"
|
||||
@@ -752,6 +840,7 @@ EOF
|
||||
--cap-add=NET_ADMIN --cap-add=NET_RAW \
|
||||
--network "$(network_name)" \
|
||||
-e SANDBOX_TEST_MODE=1 \
|
||||
-e GH_TOKEN="${GH_TOKEN:-}" \
|
||||
--entrypoint /usr/local/bin/entrypoint.sh \
|
||||
"$derived" >/dev/null
|
||||
wait_ready "$test_container"
|
||||
@@ -779,6 +868,7 @@ EOF
|
||||
PROXY_PID=$!
|
||||
for _ in $(seq 1 50); do [ -S "$TD/proxy.sock" ] && break; sleep 0.1; done
|
||||
SANDBOX_ANTHROPIC_PROXY_SOCKET="$TD/proxy.sock" SANDBOX_ANTHROPIC_PROXY_PORT=18399 \
|
||||
SANDBOX_ANTHROPIC_TRANSPORT=loopback \
|
||||
node "$BATS_TEST_DIRNAME/../docker/anthropic-proxy-forwarder.js" &
|
||||
FWD_PID=$!
|
||||
sleep 0.5
|
||||
@@ -789,3 +879,197 @@ EOF
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *"proxy-ok"* ]]
|
||||
}
|
||||
|
||||
# Starts a host-side TCP listener bound to $1, waits until it accepts a
|
||||
# connection, and leaves its port in HOST_LISTENER_PORT. Binding port 0 and
|
||||
# reading the port back (the same trick fixtures/fake-anthropic.py uses) is
|
||||
# what keeps the test below honest: the kernel only hands out a port nothing
|
||||
# else holds, so whatever answers is provably this listener and not some
|
||||
# unrelated service that happened to own a hardcoded number. The handler has
|
||||
# no do_* methods, so it serves nothing at all - only the accept() matters,
|
||||
# and the gateway-bound instance is briefly reachable by every container on
|
||||
# this host's bridge. Called plainly, never in a command substitution: it has
|
||||
# to append to HOST_LISTENER_PIDS in the caller's shell for teardown to see it.
|
||||
start_host_listener() {
|
||||
local bind_addr="$1" port_file="$BATS_TEST_TMPDIR/listener-port-$1"
|
||||
python3 -c 'import sys
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
s = ThreadingHTTPServer((sys.argv[1], 0), BaseHTTPRequestHandler)
|
||||
print(s.server_address[1], flush=True)
|
||||
s.serve_forever()' "$bind_addr" > "$port_file" 2>/dev/null &
|
||||
HOST_LISTENER_PIDS="${HOST_LISTENER_PIDS:-} $!"
|
||||
|
||||
for _ in $(seq 1 50); do [ -s "$port_file" ] && break; sleep 0.1; done
|
||||
HOST_LISTENER_PORT="$(cat "$port_file")"
|
||||
[ -n "$HOST_LISTENER_PORT" ]
|
||||
timeout 5 bash -c "cat < /dev/null > /dev/tcp/$bind_addr/$HOST_LISTENER_PORT"
|
||||
}
|
||||
|
||||
@test "a host service bound to loopback is unreachable from inside the container, one bound to the bridge gateway is reachable" {
|
||||
# The regression test for the escape path bin/dejima-ttyd's
|
||||
# --interface 127.0.0.1 exists to close. init-firewall.sh deliberately
|
||||
# ACCEPTs OUTPUT to the Docker bridge gateway, and a tmux client attached
|
||||
# through ttyd is a host shell - the prefix key opens a window outside the
|
||||
# container - so a ttyd the sandbox could reach would be a full escape.
|
||||
# Prove the containment property holds rather than trusting the flag.
|
||||
local container host_ip loopback_port gateway_port
|
||||
container="$(container_name)"
|
||||
|
||||
# Derived exactly as init-firewall.sh derives HOST_IP, so this is the one
|
||||
# host address the container's OUTPUT chain accepts at all.
|
||||
host_ip="$(docker exec "$container" sh -c "ip route | grep default | cut -d' ' -f3")"
|
||||
[ -n "$host_ip" ]
|
||||
|
||||
start_host_listener 127.0.0.1
|
||||
loopback_port="$HOST_LISTENER_PORT"
|
||||
start_host_listener "$host_ip"
|
||||
gateway_port="$HOST_LISTENER_PORT"
|
||||
|
||||
# Setup check, from the host's own vantage point: the loopback listener's
|
||||
# port must answer on 127.0.0.1 only. bind(0) on two different addresses
|
||||
# can legally return the same port number, and if it had, the container
|
||||
# probe below would be hitting the gateway listener and failing for a
|
||||
# reason that has nothing to do with containment.
|
||||
run timeout 5 bash -c "cat < /dev/null > /dev/tcp/$host_ip/$loopback_port"
|
||||
[ "$status" -ne 0 ]
|
||||
|
||||
# Positive control, and the reason the negative one means anything: same
|
||||
# container, same docker exec, same timeout/bash /dev/tcp probe, same
|
||||
# gateway address - only the port differs. Without it a `status -ne 0` is
|
||||
# equally satisfied by docker exec failing (125), bash or timeout missing
|
||||
# from the image (127), or a container that can reach nothing at all, none
|
||||
# of which say anything about loopback.
|
||||
run docker exec "$container" timeout 5 bash -c "cat < /dev/null > /dev/tcp/$host_ip/$gateway_port"
|
||||
[ "$status" -eq 0 ]
|
||||
|
||||
# Deliberately not asserting the exact status. 1 (the host RSTs, since
|
||||
# nothing is bound on the gateway address at this port) is what it should
|
||||
# be, but a host that DROPs on its own docker bridge instead yields 124
|
||||
# from timeout, and that is still containment.
|
||||
run docker exec "$container" timeout 5 bash -c "cat < /dev/null > /dev/tcp/$host_ip/$loopback_port"
|
||||
[ "$status" -ne 0 ]
|
||||
}
|
||||
|
||||
@test "tls transport: https://api.anthropic.com from inside the container reaches the host proxy" {
|
||||
TD="$BATS_TEST_TMPDIR"
|
||||
python3 "$BATS_TEST_DIRNAME/fixtures/fake-anthropic.py" > "$TD/port" &
|
||||
FAKE_PID=$!
|
||||
for _ in $(seq 1 50); do [ -s "$TD/port" ] && break; sleep 0.1; done
|
||||
cat > "$TD/credentials.json" <<EOF
|
||||
{"claudeAiOauth":{"accessToken":"real-access-token","refreshToken":"r","expiresAt":$((($(date +%s) + 7200) * 1000)),"refreshTokenExpiresAt":$((($(date +%s) + 86400) * 1000)),"scopes":[],"subscriptionType":"max","rateLimitTier":"default"}}
|
||||
EOF
|
||||
"$BATS_TEST_DIRNAME/../bin/dejima-anthropic-proxy" \
|
||||
--socket "$TD/proxy.sock" --credentials "$TD/credentials.json" \
|
||||
--upstream "http://127.0.0.1:$(cat "$TD/port")" &
|
||||
PROXY_PID=$!
|
||||
for _ in $(seq 1 50); do [ -S "$TD/proxy.sock" ] && break; sleep 0.1; done
|
||||
# See the loopback test above for why this chmod is here.
|
||||
chmod 666 "$TD/proxy.sock"
|
||||
|
||||
container="dejima-test-tls-$(basename "$BATS_FILE_TMPDIR")"
|
||||
# No ANTHROPIC_* env at all - that absence is the thing under test.
|
||||
docker run -d --name "$container" \
|
||||
--cap-add=NET_ADMIN --cap-add=NET_RAW \
|
||||
--network "$(network_name)" \
|
||||
-e SANDBOX_TEST_MODE=1 \
|
||||
-e GH_TOKEN="${GH_TOKEN:-}" \
|
||||
-e SANDBOX_ANTHROPIC_PROXY_SOCKET="$TD/proxy.sock" \
|
||||
-v "$TD:$TD" \
|
||||
--entrypoint /usr/local/bin/entrypoint.sh \
|
||||
"$IMAGE" >/dev/null
|
||||
wait_ready "$container"
|
||||
|
||||
# NODE_EXTRA_CA_CERTS has to be passed explicitly: docker exec starts from
|
||||
# the image's ENV, not from the entrypoint process's exported environment.
|
||||
# The session command itself is a child of the entrypoint and does inherit
|
||||
# it - which is what the /proc/1/environ check below confirms.
|
||||
run docker exec -e NODE_EXTRA_CA_CERTS=/etc/dejima-anthropic-tls/cert.pem \
|
||||
"$container" node -e '
|
||||
fetch("https://api.anthropic.com/v1/messages", {method: "POST", body: "{}"})
|
||||
.then(r => r.text()).then(t => { console.log(t); })
|
||||
.catch(e => { console.error(e); process.exit(1); })'
|
||||
status_tls=$status; output_tls=$output
|
||||
|
||||
run docker exec -u node "$container" sh -c "tr '\0' '\n' < /proc/1/environ | grep -c '^ANTHROPIC' || true"
|
||||
env_anthropic=$output
|
||||
run docker exec -u node "$container" sh -c "tr '\0' '\n' < /proc/1/environ | grep -c NODE_EXTRA_CA_CERTS || true"
|
||||
env_ca=$output
|
||||
run docker exec -u node "$container" sh -c "tr '\0' '\n' < /proc/1/environ | grep -c real-access-token || true"
|
||||
env_token=$output
|
||||
run docker exec "$container" grep -c '127.0.0.1 api.anthropic.com' /etc/hosts
|
||||
hosts_hits=$output
|
||||
# /etc/hosts resolves the name locally, but that only wins inside
|
||||
# getaddrinfo - the DNS proxy itself must still refuse it, same as any
|
||||
# other name that isn't on the allowlist (see the DNS-proxy test above),
|
||||
# so a bypass of the pin that looks the name up by DNS gets no address.
|
||||
# It is not a route check: the API's address stays reachable as an IP
|
||||
# literal for as long as allowlisted platform.claude.com resolves to it.
|
||||
run docker exec "$container" dig api.anthropic.com
|
||||
dig_output=$output
|
||||
|
||||
docker rm -f "$container" >/dev/null 2>&1 || true
|
||||
kill "$PROXY_PID" "$FAKE_PID" 2>/dev/null || true
|
||||
|
||||
[ "$status_tls" -eq 0 ]
|
||||
# The client sent no Authorization header; the host proxy supplied one.
|
||||
[[ "$output_tls" == *'"authorization": "Bearer real-access-token"'* ]]
|
||||
# Both fingerprints are gone: no base URL, no placeholder token...
|
||||
[ "$env_anthropic" = "0" ]
|
||||
# ...while the trust anchor the session actually needs did get inherited.
|
||||
[ "$env_ca" = "1" ]
|
||||
[ "$env_token" = "0" ]
|
||||
[ "$hosts_hits" = "1" ]
|
||||
[[ "$dig_output" == *"status: REFUSED"* ]]
|
||||
}
|
||||
|
||||
@test "the kill switch leaves api.anthropic.com unpinned, so the real API stays reachable" {
|
||||
# SANDBOX_CREDENTIAL_PROXY=0 mounts the real credential and starts no
|
||||
# forwarder. If the hosts entry were unconditional (a compose extra_hosts
|
||||
# entry, say), this container would send every API call into a loopback
|
||||
# port nothing is listening on.
|
||||
container="dejima-test-nohosts-$(basename "$BATS_FILE_TMPDIR")"
|
||||
docker run -d --name "$container" \
|
||||
--cap-add=NET_ADMIN --cap-add=NET_RAW \
|
||||
--network "$(network_name)" \
|
||||
-e SANDBOX_TEST_MODE=1 \
|
||||
-e GH_TOKEN="${GH_TOKEN:-}" \
|
||||
--entrypoint /usr/local/bin/entrypoint.sh \
|
||||
"$IMAGE" >/dev/null
|
||||
wait_ready "$container"
|
||||
|
||||
run docker exec "$container" grep -c 'api.anthropic.com' /etc/hosts
|
||||
hosts_hits=$output
|
||||
|
||||
docker rm -f "$container" >/dev/null 2>&1 || true
|
||||
|
||||
[ "$hosts_hits" = "0" ]
|
||||
}
|
||||
|
||||
@test "an unrecognised transport aborts the container instead of half-configuring it" {
|
||||
# `Loopback` is not `loopback`: bin/dejima would take its tls branch while
|
||||
# this container took its loopback one, and every API call in the session
|
||||
# would fail with nothing naming the cause.
|
||||
container="dejima-test-badtransport-$(basename "$BATS_FILE_TMPDIR")"
|
||||
docker run -d --name "$container" \
|
||||
--cap-add=NET_ADMIN --cap-add=NET_RAW \
|
||||
--network "$(network_name)" \
|
||||
-e SANDBOX_TEST_MODE=1 \
|
||||
-e GH_TOKEN="${GH_TOKEN:-}" \
|
||||
-e SANDBOX_ANTHROPIC_TRANSPORT=Loopback \
|
||||
--entrypoint /usr/local/bin/entrypoint.sh \
|
||||
"$IMAGE" >/dev/null
|
||||
|
||||
# Without the check the entrypoint runs on into SANDBOX_TEST_MODE's `sleep
|
||||
# infinity` and never exits - the timeout turns that regression into a
|
||||
# failure here rather than a hung suite.
|
||||
run timeout 120 docker wait "$container"
|
||||
exit_code=$output
|
||||
logs=$(docker logs "$container" 2>&1)
|
||||
|
||||
docker rm -f "$container" >/dev/null 2>&1 || true
|
||||
|
||||
[ "$exit_code" = "1" ]
|
||||
[[ "$logs" == *"SANDBOX_ANTHROPIC_TRANSPORT is set to 'Loopback'"* ]]
|
||||
[[ "$logs" == *"'tls'"* ]]
|
||||
[[ "$logs" == *"'loopback'"* ]]
|
||||
}
|
||||
|
||||
+651
-76
@@ -49,6 +49,48 @@ setup() {
|
||||
grep -qE '^\s*ENV\s+NODE_PATH=' "$SANDBOX_DIR/Dockerfile"
|
||||
}
|
||||
|
||||
# One awk pass rather than a grep chain: a filtering grep exits 1 when it
|
||||
# discards every line, which here is the "nothing wrong" case, so the chain
|
||||
# would report its own success as a failure. Comment lines are skipped - one
|
||||
# of them quotes, verbatim, the invocation the second test bans.
|
||||
@test "every build-time download in the Dockerfile retries rather than dying on one blip" {
|
||||
# These fetches all sit below the SANDBOX_* ARGs, so any build overriding
|
||||
# one of those - test/runtime.bats' first test, or simply a host whose uid
|
||||
# isn't 1000 - invalidates the cache and re-runs every one of them. The
|
||||
# odds that matter are then the odds of *any* of them blipping, not of a
|
||||
# particular one, and a single-shot fetch turns that into a dead build.
|
||||
local unguarded
|
||||
unguarded=$(awk '
|
||||
/^[[:space:]]*#/ { next }
|
||||
/wget/ && !/--tries=[0-9]/ { print FNR": "$0 }
|
||||
/curl/ && !/--retry [0-9]/ { print FNR": "$0 }
|
||||
' "$SANDBOX_DIR/Dockerfile")
|
||||
[ -z "$unguarded" ] || {
|
||||
echo "these downloads have no retry flags:" >&2
|
||||
echo "$unguarded" >&2
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
@test "the zsh installer runs from a file, so a failed download fails the build" {
|
||||
# Upstream documents `sh -c "$(wget -O- ...)"`. In that form a failed
|
||||
# download substitutes to an empty string and `sh -c ""` exits 0, so the
|
||||
# build goes green having installed nothing - the kind of failure that
|
||||
# only surfaces later, as a shell that is inexplicably unconfigured.
|
||||
local substituted
|
||||
substituted=$(awk '
|
||||
/^[[:space:]]*#/ { next }
|
||||
/sh -c "\$\((wget|curl)/ { print FNR": "$0 }
|
||||
' "$SANDBOX_DIR/Dockerfile")
|
||||
[ -z "$substituted" ] || {
|
||||
echo "a failed download here would exit 0 and install nothing:" >&2
|
||||
echo "$substituted" >&2
|
||||
false
|
||||
}
|
||||
grep -q -- '-O /tmp/zsh-in-docker.sh' "$SANDBOX_DIR/Dockerfile"
|
||||
grep -qE '^\s*sh /tmp/zsh-in-docker.sh' "$SANDBOX_DIR/Dockerfile"
|
||||
}
|
||||
|
||||
@test "every ipset add in init-firewall.sh is duplicate-safe" {
|
||||
# Same bug class that broke firewall startup twice (718f6b7, a86fefc):
|
||||
# ipset add without -exist errors on a duplicate entry, and set -e turns
|
||||
@@ -87,25 +129,64 @@ setup() {
|
||||
[ "$status" -ne 0 ]
|
||||
}
|
||||
|
||||
@test "~/.claude settings/CLAUDE.md/plugins are layered read-only over the read-write parent mount" {
|
||||
grep -qE '^\s*-\s*\$\{HOME\}/\.claude:\$\{HOME\}/\.claude\s*$' "$SANDBOX_DIR/docker-compose.yml"
|
||||
# settings.json specifically comes from a generated $SANDBOX_SETTINGS_FILE
|
||||
# (see below), not the real file directly - it still has to be read-only
|
||||
# in the container, just from a different, sandbox-specific source.
|
||||
grep -qE '^\s*-\s*\$\{SANDBOX_SETTINGS_FILE.*\}:\$\{HOME\}/\.claude/settings\.json:ro\s*$' "$SANDBOX_DIR/docker-compose.yml"
|
||||
grep -qE '^\s*-\s*\$\{HOME\}/\.claude/CLAUDE\.md:.*:ro\s*$' "$SANDBOX_DIR/docker-compose.yml"
|
||||
grep -qE '^\s*-\s*\$\{HOME\}/\.claude/plugins:.*:ro\s*$' "$SANDBOX_DIR/docker-compose.yml"
|
||||
@test "~/.claude is not mounted as a whole - its contents are enumerated" {
|
||||
# It used to be, read-write, with the config inside it layered back
|
||||
# read-only on top. Every one of those overlays was a mount point nested
|
||||
# inside a host-writable mount, and a single-file bind mount is pinned to
|
||||
# the dentry at that path: a host-side rename unlinks it, the mount
|
||||
# silently detaches, and the path falls through to the real writable host
|
||||
# file (#47, #48). The mask over .credentials.json went the same way,
|
||||
# handing the sandbox the operator's real OAuth token (#50). With no
|
||||
# parent mount, a detachment falls through to container-local emptiness.
|
||||
run grep -E '^\s*-\s*\$\{HOME\}/\.claude:\$\{HOME\}/\.claude\s*$' "$SANDBOX_DIR/docker-compose.yml"
|
||||
[ "$status" -ne 0 ]
|
||||
run grep -E '^\s*-\s*\$\{HOME\}/\.claude/\.credentials' "$SANDBOX_DIR/docker-compose.yml"
|
||||
[ "$status" -ne 0 ]
|
||||
}
|
||||
|
||||
@test "~/.claude/hooks is layered read-only over the read-write parent mount" {
|
||||
# Same persistence-path rationale as the settings/CLAUDE.md/plugins
|
||||
# overlay above, but for the directory install.sh's hook symlinks live
|
||||
# in - see docker-compose.yml's comment on this mount line. Without
|
||||
# this, a compromised session can unlink a hook symlink and drop a
|
||||
# plain malicious file in its place, which the dynamic-mount logic in
|
||||
# bin/dejima never sees (it only protects symlink targets that
|
||||
# resolve outside ~/.claude).
|
||||
@test "the image bakes in a user-owned ~/.claude for the enumerated mounts to land in" {
|
||||
# Nothing mounts the directory itself any more, so it has to exist and be
|
||||
# writable by the sandbox user - otherwise Docker auto-creates it root
|
||||
# owned while setting up the mounts nested under it, and the session
|
||||
# can't write its own cache or the config entrypoint.sh copies in.
|
||||
grep -qE '^RUN mkdir -p .*\$SANDBOX_HOME/\.claude' "$SANDBOX_DIR/Dockerfile"
|
||||
grep -qE 'chown -R \$SANDBOX_USER:\$SANDBOX_USER .*\$SANDBOX_HOME/\.claude' "$SANDBOX_DIR/Dockerfile"
|
||||
}
|
||||
|
||||
@test "~/.claude runtime state that has to survive the session is mounted read-write" {
|
||||
# Directories, which is what makes them safe: a directory bind mount
|
||||
# can't detach the way a file one does, because a rename replaces an
|
||||
# entry *within* the mounted directory rather than the mount point.
|
||||
# projects/ is what host-side `claude --resume` reads; the rest is the
|
||||
# edit-undo record and the task/plan state skills write.
|
||||
grep -qE '^\s*-\s*\$\{HOME\}/\.claude/projects:\$\{HOME\}/\.claude/projects\s*$' "$SANDBOX_DIR/docker-compose.yml"
|
||||
grep -qE '^\s*-\s*\$\{HOME\}/\.claude/file-history:\$\{HOME\}/\.claude/file-history\s*$' "$SANDBOX_DIR/docker-compose.yml"
|
||||
grep -qE '^\s*-\s*\$\{HOME\}/\.claude/backups:\$\{HOME\}/\.claude/backups\s*$' "$SANDBOX_DIR/docker-compose.yml"
|
||||
grep -qE '^\s*-\s*\$\{HOME\}/\.claude/tasks:\$\{HOME\}/\.claude/tasks\s*$' "$SANDBOX_DIR/docker-compose.yml"
|
||||
grep -qE '^\s*-\s*\$\{HOME\}/\.claude/plans:\$\{HOME\}/\.claude/plans\s*$' "$SANDBOX_DIR/docker-compose.yml"
|
||||
grep -qE '^\s*-\s*\$\{HOME\}/\.claude/sessions:\$\{HOME\}/\.claude/sessions\s*$' "$SANDBOX_DIR/docker-compose.yml"
|
||||
}
|
||||
|
||||
@test "~/.claude config files are staged for copying, not mounted at their real paths" {
|
||||
# A copy can't be detached by a host-side rename at all. entrypoint.sh
|
||||
# installs them root-owned and read-only; see its comment and the mount
|
||||
# inventory in docker-compose.yml.
|
||||
grep -qE '^\s*-\s*\$\{HOME\}/\.claude/settings\.json:/etc/dejima-claude/settings\.json:ro\s*$' "$SANDBOX_DIR/docker-compose.yml"
|
||||
grep -qE '^\s*-\s*\$\{HOME\}/\.claude/CLAUDE\.md:/etc/dejima-claude/CLAUDE\.md:ro\s*$' "$SANDBOX_DIR/docker-compose.yml"
|
||||
run grep -E '^\s*-\s*\$\{HOME\}/\.claude/(settings\.json|CLAUDE\.md):\$\{HOME\}/' "$SANDBOX_DIR/docker-compose.yml"
|
||||
[ "$status" -ne 0 ]
|
||||
grep -q 'install -o root -g root -m 444 "/etc/dejima-claude/\$staged"' "$SANDBOX_DIR/entrypoint.sh"
|
||||
}
|
||||
|
||||
@test "~/.claude/hooks and plugins are mounted read-only" {
|
||||
# Hooks run automatically with no approval gate, and plugins carry code a
|
||||
# later unsandboxed host session loads - see docker-compose.yml's comment
|
||||
# on these mount lines. Without them, a compromised session can unlink a
|
||||
# hook symlink and drop a plain malicious file in its place, which the
|
||||
# dynamic-mount logic in bin/dejima never sees (it only protects symlink
|
||||
# targets that resolve outside ~/.claude).
|
||||
grep -qE '^\s*-\s*\$\{HOME\}/\.claude/hooks:\$\{HOME\}/\.claude/hooks:ro\s*$' "$SANDBOX_DIR/docker-compose.yml"
|
||||
grep -qE '^\s*-\s*\$\{HOME\}/\.claude/plugins:\$\{HOME\}/\.claude/plugins:ro\s*$' "$SANDBOX_DIR/docker-compose.yml"
|
||||
}
|
||||
|
||||
@test "~/.claude.json is a throwaway per-session copy, not the real file mounted directly" {
|
||||
@@ -131,6 +212,34 @@ setup() {
|
||||
grep -q 'mktemp -d.*gitconfig' "$REPO_ROOT/bin/dejima"
|
||||
}
|
||||
|
||||
@test "every name cleanup() dereferences is bound by the time the EXIT trap can fire" {
|
||||
# bin/dejima runs under `set -u`, and a failing command in an EXIT trap
|
||||
# replaces the shell's exit status - so an unbound name in cleanup()
|
||||
# aborts the trap partway, before it reaches the audit that is
|
||||
# deliberately its last act. Structural, not behavioral: the window
|
||||
# between `trap cleanup EXIT` and a later assignment holds only comments
|
||||
# today, so nothing can currently exit inside it (a failed `VAR="$(...)"`
|
||||
# still binds VAR to the empty string before set -e fires). This guards
|
||||
# the invariant against the edit that puts something exitable there.
|
||||
#
|
||||
# A name is satisfied by EITHER a first assignment above the trap OR a
|
||||
# `${NAME:-}` form somewhere in cleanup() - the latter covers the bare
|
||||
# uses that sit inside their own `[ -n "${NAME:-}" ]` guard.
|
||||
d="$REPO_ROOT/bin/dejima"
|
||||
trap_line="$(grep -n '^trap cleanup EXIT' "$d" | cut -d: -f1)"
|
||||
[ -n "$trap_line" ]
|
||||
body="$(sed -n '/^cleanup() {/,/^}/p' "$d")"
|
||||
[ -n "$body" ]
|
||||
for v in $(printf '%s\n' "$body" | grep -oE '\$\{?[A-Za-z_][A-Za-z0-9_]*\}?' \
|
||||
| tr -d '${}' | sort -u); do
|
||||
printf '%s\n' "$body" | grep -q "\${$v:-" && continue
|
||||
asg="$(grep -nE "^[[:space:]]*(export )?$v=" "$d" | head -1 | cut -d: -f1)"
|
||||
[ -n "$asg" ] || { echo "cleanup() uses \$$v, never assigned at all"; false; }
|
||||
[ "$asg" -lt "$trap_line" ] \
|
||||
|| { echo "cleanup() uses \$$v unguarded, first assigned at line $asg, after the trap at $trap_line"; false; }
|
||||
done
|
||||
}
|
||||
|
||||
@test "docker-compose.yml never hardcodes /home/node - the container home is host-parametrized" {
|
||||
# ${HOME:-/home/node} (the SANDBOX_HOME build arg's own default value,
|
||||
# mirroring the Dockerfile's default) is the one legitimate exception -
|
||||
@@ -183,13 +292,49 @@ setup() {
|
||||
grep -q 'extra_mounts+=(-v "\$path:\$path:ro")' "$REPO_ROOT/bin/dejima"
|
||||
}
|
||||
|
||||
@test "bin/dejima forces sandbox.enabled to false in the generated settings.json" {
|
||||
@test "bin/dejima forces sandbox.enabled to false in the generated override layer" {
|
||||
# Claude Code's own internal per-command sandboxing is redundant on top
|
||||
# of this container and can't actually function inside it (bwrap can't
|
||||
# create a namespace here) - settings.json is read-only precisely so it
|
||||
# can't be patched from inside, so this has to happen in the generation
|
||||
# step on the host, before the container starts.
|
||||
grep -qE '\.sandbox\.enabled\s*=\s*false' "$REPO_ROOT/bin/dejima"
|
||||
# create a namespace here) - a session can't be trusted to turn it off
|
||||
# itself, so this has to happen in the generation step on the host,
|
||||
# before the container starts.
|
||||
grep -qE 'jq -n .\{sandbox: \{enabled: false\}\}' "$REPO_ROOT/bin/dejima"
|
||||
}
|
||||
|
||||
@test "the generated override layer is a delta, not a copy of the operator's settings" {
|
||||
# jq -n reads no input: what ships is only what the sandbox overrides,
|
||||
# and everything else still comes from the operator's real settings.json
|
||||
# through Claude Code's own layering. A copy would go stale the moment
|
||||
# the host edited the original, and would duplicate every hook it
|
||||
# carried across two layers that get merged.
|
||||
grep -qE '^jq -n .* > "\$SANDBOX_SETTINGS_FILE"$' "$REPO_ROOT/bin/dejima"
|
||||
# The operator's settings.json is read only to check that it parses. It
|
||||
# must never become an input to the override layer.
|
||||
run grep -E 'HOME/\.claude/settings\.json.*SANDBOX_SETTINGS_FILE' "$REPO_ROOT/bin/dejima"
|
||||
[ "$status" -ne 0 ]
|
||||
}
|
||||
|
||||
@test "the override layer is mounted outside ~/.claude and passed as --settings" {
|
||||
# Mounting it over ~/.claude/settings.json put the mount point inside the
|
||||
# read-write ~/.claude mount, where a host-side rename of the real file
|
||||
# detached it mid-session and silently reverted every override (#47).
|
||||
# A container-local path can't be renamed over from the host.
|
||||
grep -qE '^\s*-\s*\$\{SANDBOX_SETTINGS_FILE.*\}:/etc/dejima-settings\.json:ro\s*$' "$SANDBOX_DIR/docker-compose.yml"
|
||||
run grep -E '^\s*-\s*\$\{SANDBOX_SETTINGS_FILE.*\}:\$\{HOME\}/' "$SANDBOX_DIR/docker-compose.yml"
|
||||
[ "$status" -ne 0 ]
|
||||
# Which only takes effect if the flag is actually passed. It goes in the
|
||||
# wrapper rather than the entrypoint so it also covers invocations the
|
||||
# entrypoint never sees (an interactive shell, claudecode.nvim).
|
||||
grep -qE -- '--settings /etc/dejima-settings\.json' "$SANDBOX_DIR/claude-wrapper.sh"
|
||||
grep -qE -- '"\$\{settings\[@\]\}"' "$SANDBOX_DIR/claude-wrapper.sh"
|
||||
}
|
||||
|
||||
@test "bin/dejima pre-creates the ~/.claude config files so neither is mounted as a directory" {
|
||||
# Docker creates a missing bind source as a directory - without this, a
|
||||
# host that has never run install.sh gets a junk directory at
|
||||
# ~/.claude/settings.json and a session with no settings at all.
|
||||
grep -qE '\[ -f "\$HOME/\.claude/settings\.json" \] \|\| echo' "$REPO_ROOT/bin/dejima"
|
||||
grep -qE '\[ -f "\$HOME/\.claude/CLAUDE\.md" \] \|\|' "$REPO_ROOT/bin/dejima"
|
||||
}
|
||||
|
||||
@test "Dockerfile never installs bubblewrap or socat" {
|
||||
@@ -209,6 +354,19 @@ setup() {
|
||||
grep -q '.worktrees/' "$REPO_ROOT/hooks/sandbox-context.sh"
|
||||
}
|
||||
|
||||
@test "sandbox-context.sh tells the agent to close issues via the PR body" {
|
||||
# The session ends at the PR and the merge happens later, without the
|
||||
# agent - so anything it defers to "after the merge" is something it
|
||||
# won't be there to do, and an issue a merged PR fixed just stays open.
|
||||
# A closing keyword in the body is the only part of that it can control.
|
||||
grep -q 'Fixes #N' "$REPO_ROOT/hooks/sandbox-context.sh"
|
||||
# And the counterpart, so partial work doesn't close anything.
|
||||
grep -q 'Refs #N' "$REPO_ROOT/hooks/sandbox-context.sh"
|
||||
# The short form specifically: an issue's full URL renders as the same
|
||||
# link but does not trigger the close.
|
||||
grep -q "not the issue's URL" "$REPO_ROOT/hooks/sandbox-context.sh"
|
||||
}
|
||||
|
||||
@test "entrypoint runs as root and drops to node via gosu, not a baked-in USER node" {
|
||||
# The whole privilege-drop model depends on entrypoint.sh itself starting
|
||||
# as root (to set up the firewall) - a stray `USER node` before
|
||||
@@ -316,13 +474,85 @@ setup() {
|
||||
grep -q 'branch" = "main"\|branch" = "master"' "$REPO_ROOT/hooks/git-push-guard.sh"
|
||||
}
|
||||
|
||||
@test "gitea-pr never passes the token as a command-line argument" {
|
||||
@test "neither gitea CLI passes the token as a command-line argument" {
|
||||
# /proc makes argv world-readable, so a token in a curl invocation is
|
||||
# visible to every other process for the life of the request - and this
|
||||
# runs alongside agents that read process listings.
|
||||
run grep -n 'curl.*Authorization: token' "$REPO_ROOT/bin/gitea-pr"
|
||||
[ "$status" -ne 0 ]
|
||||
grep -q 'curl -sS -w .*-K -' "$REPO_ROOT/bin/gitea-pr"
|
||||
#
|
||||
# Both files, because they are deliberate copies of each other rather
|
||||
# than a shared library (see bin/gitea-issue's header for why), and a
|
||||
# duplicated security property is exactly the kind that drifts.
|
||||
local f
|
||||
for f in bin/gitea-pr bin/gitea-issue; do
|
||||
run grep -n 'curl.*Authorization: token' "$REPO_ROOT/$f"
|
||||
[ "$status" -ne 0 ]
|
||||
grep -q 'curl -sS -w .*-K -' "$REPO_ROOT/$f"
|
||||
done
|
||||
}
|
||||
|
||||
@test "both gitea CLIs take a body from a file, and refuse to guess between two" {
|
||||
# Prose on a command line has to survive shell quoting, and an
|
||||
# apostrophe or a backtick is the common case, not the edge one. A body
|
||||
# that arrives via a file never enters argv at all.
|
||||
local f
|
||||
for f in bin/gitea-pr bin/gitea-issue; do
|
||||
grep -q -- '--body-file' "$REPO_ROOT/$f"
|
||||
# '-' means stdin, so a heredoc works without a temp file.
|
||||
grep -q 'file" = "-"' "$REPO_ROOT/$f"
|
||||
# Passing both is an error rather than a precedence rule: silently
|
||||
# picking one ships a body the caller did not mean to send.
|
||||
grep -q 'not both' "$REPO_ROOT/$f"
|
||||
done
|
||||
}
|
||||
|
||||
@test "gitea-issue list asks for issues only, never pull requests" {
|
||||
# Gitea's issues endpoint returns PRs too - a PR is an issue with a
|
||||
# branch attached - so without type=issues every open PR shows up as an
|
||||
# issue and the work list reads as twice as long as it is.
|
||||
grep -q 'type=issues' "$REPO_ROOT/bin/gitea-issue"
|
||||
}
|
||||
|
||||
@test "gitea-issue is installed into the image alongside gitea-pr" {
|
||||
grep -qx 'COPY bin/gitea-issue /usr/local/bin/gitea-issue' "$SANDBOX_DIR/Dockerfile"
|
||||
# Copied without the executable bit surviving is the failure that only
|
||||
# shows up at runtime, so the chmod list is checked too.
|
||||
grep -q 'chmod +x .*/usr/local/bin/gitea-issue' "$SANDBOX_DIR/Dockerfile"
|
||||
}
|
||||
|
||||
@test "gitea-merge-guard.sh blocks a sandbox merge and is a no-op on the host" {
|
||||
local hook="$REPO_ROOT/hooks/gitea-merge-guard.sh"
|
||||
local payload
|
||||
|
||||
# Blocked in the sandbox, including path-qualified - /usr/local/bin is
|
||||
# where the image puts it, so a bare-name-only pattern would miss the
|
||||
# form an agent reaching past PATH would type.
|
||||
local cmd
|
||||
for cmd in 'gitea-pr merge 24' '/usr/local/bin/gitea-pr merge 24' 'cd /tmp && gitea-pr merge 24'; do
|
||||
payload=$(jq -n --arg c "$cmd" '{tool_input: {command: $c}}')
|
||||
run bash -c "printf '%s' \"\$1\" | DEJIMA=1 bash '$hook'" _ "$payload"
|
||||
[ "$status" -eq 2 ]
|
||||
[[ "$output" == *"gitea-merge-guard.sh"* ]]
|
||||
done
|
||||
|
||||
# The other verbs are the whole point of the tool and must pass through.
|
||||
for cmd in 'gitea-pr create --title x' 'gitea-pr comments 24' 'gitea-issue close 14'; do
|
||||
payload=$(jq -n --arg c "$cmd" '{tool_input: {command: $c}}')
|
||||
run bash -c "printf '%s' \"\$1\" | DEJIMA=1 bash '$hook'" _ "$payload"
|
||||
[ "$status" -eq 0 ]
|
||||
done
|
||||
|
||||
# Outside the sandbox it does nothing at all: an operator at a host shell
|
||||
# can still be asked to merge, which is what the CLAUDE.md rule allows.
|
||||
# env -u rather than DEJIMA= because the image sets DEJIMA=1 globally, so
|
||||
# a test running inside dejima would otherwise assert against itself.
|
||||
payload=$(jq -n '{tool_input: {command: "gitea-pr merge 24"}}')
|
||||
run bash -c "printf '%s' \"\$1\" | env -u DEJIMA bash '$hook'" _ "$payload"
|
||||
[ "$status" -eq 0 ]
|
||||
}
|
||||
|
||||
@test "install.sh links and registers the gitea merge guard" {
|
||||
grep -qx 'link_hook gitea-merge-guard.sh' "$REPO_ROOT/install.sh"
|
||||
grep -q 'register_hook PreToolUse Bash gitea-merge-guard.sh' "$REPO_ROOT/install.sh"
|
||||
}
|
||||
|
||||
@test "dns-proxy.sh enables dnsmasq's stop-dns-rebind protection" {
|
||||
@@ -345,20 +575,29 @@ setup() {
|
||||
grep -q 'ip6tables -L >/dev/null 2>&1' "$SANDBOX_DIR/init-firewall.sh"
|
||||
}
|
||||
|
||||
@test "~/.claude skills/commands/agents/output-styles are layered read-only over the read-write parent mount" {
|
||||
# Same persistence-path rationale as the hooks overlay: these carry
|
||||
@test "~/.claude skills/commands/agents/output-styles are mounted read-only" {
|
||||
# Same persistence-path rationale as the hooks mount: these carry
|
||||
# executable skill scripts / injectable command, agent, and output-style
|
||||
# prompts that a later unsandboxed host session would run or load.
|
||||
# Without an overlay (and without pre-creation, since they may not
|
||||
# exist) a compromised session can create and populate them.
|
||||
# Read-only, and pre-created by bin/dejima since existence is what lets
|
||||
# Docker mount them at all.
|
||||
grep -qE '^\s*-\s*\$\{HOME\}/\.claude/skills:\$\{HOME\}/\.claude/skills:ro\s*$' "$SANDBOX_DIR/docker-compose.yml"
|
||||
grep -qE '^\s*-\s*\$\{HOME\}/\.claude/commands:\$\{HOME\}/\.claude/commands:ro\s*$' "$SANDBOX_DIR/docker-compose.yml"
|
||||
grep -qE '^\s*-\s*\$\{HOME\}/\.claude/agents:\$\{HOME\}/\.claude/agents:ro\s*$' "$SANDBOX_DIR/docker-compose.yml"
|
||||
grep -qE '^\s*-\s*\$\{HOME\}/\.claude/output-styles:\$\{HOME\}/\.claude/output-styles:ro\s*$' "$SANDBOX_DIR/docker-compose.yml"
|
||||
}
|
||||
|
||||
@test "bin/dejima pre-creates the code-carrying ~/.claude overlay dirs" {
|
||||
grep -q 'mkdir -p "$HOME/.claude/skills" "$HOME/.claude/commands" "$HOME/.claude/agents" "$HOME/.claude/output-styles"' "$REPO_ROOT/bin/dejima"
|
||||
@test "bin/dejima pre-creates every enumerated ~/.claude mount source" {
|
||||
# Docker answers a missing bind source by creating a root-owned directory
|
||||
# in its place - on the host for the source, and at the destination for
|
||||
# the code-carrying dirs, which would leave a session with no skills or
|
||||
# hooks and junk on the host either way.
|
||||
for dir in skills commands agents output-styles hooks plugins \
|
||||
projects file-history backups tasks plans sessions; do
|
||||
grep -q "\"\$HOME/.claude/$dir\"" "$REPO_ROOT/bin/dejima"
|
||||
done
|
||||
grep -q 'mkdir -p "$HOME/.claude/skills"' "$REPO_ROOT/bin/dejima"
|
||||
grep -q 'mkdir -p "$HOME/.claude/projects"' "$REPO_ROOT/bin/dejima"
|
||||
}
|
||||
|
||||
@test "bin/dejima overlays the workspace .git/hooks read-only when .git is a real dir" {
|
||||
@@ -402,40 +641,85 @@ setup() {
|
||||
}
|
||||
|
||||
@test "bin/dejima's exit-time audit still runs when the container run fails or is interrupted" {
|
||||
local run_line
|
||||
local run_line trap_line
|
||||
run_line=$(grep -n 'docker compose .* run --rm' "$REPO_ROOT/bin/dejima" | cut -d: -f1)
|
||||
trap_line=$(grep -n '^trap cleanup EXIT' "$REPO_ROOT/bin/dejima" | head -1 | cut -d: -f1)
|
||||
[ -n "$run_line" ]
|
||||
# The compose invocation must not be a bare command under set -e - a
|
||||
# nonzero/interrupted exit there would otherwise abort the script before
|
||||
# the audit compare and exit below ever run.
|
||||
[ -n "$trap_line" ]
|
||||
# cleanup() is installed as the EXIT trap before the compose run, so it
|
||||
# fires - and runs the audit - on every exit from the run below,
|
||||
# including a nonzero/interrupted one under set -e that would otherwise
|
||||
# abort the script before a bare audit call placed after the run ever
|
||||
# ran.
|
||||
[ "$trap_line" -lt "$run_line" ]
|
||||
sed -n "1,${trap_line}p" "$REPO_ROOT/bin/dejima" | grep -q '"\$SCRIPT_DIR/dejima-audit" compare "\$WORKSPACE_DIR"'
|
||||
# The compose invocation must not itself be a bare command under set -e
|
||||
# either - a nonzero/interrupted exit there would otherwise skip the
|
||||
# credential-proxy check and the propagated exit code below it.
|
||||
grep -q '|| compose_status=\$?' <(sed -n "${run_line}p" "$REPO_ROOT/bin/dejima")
|
||||
sed -n "1,${run_line}p" "$REPO_ROOT/bin/dejima" | grep -q 'compose_status=0'
|
||||
tail -n "+$run_line" "$REPO_ROOT/bin/dejima" | grep -q '"\$SCRIPT_DIR/dejima-audit" compare "\$WORKSPACE_DIR"'
|
||||
tail -n "+$run_line" "$REPO_ROOT/bin/dejima" | grep -q 'exit "\$compose_status"'
|
||||
}
|
||||
|
||||
# --- Task 10: the exit-time audit is the structural last writer ---
|
||||
|
||||
@test "the exit-time audit is the last thing that writes to the terminal" {
|
||||
audit_line=$(grep -n 'dejima-audit" compare' "$REPO_ROOT/bin/dejima" | head -1 | cut -d: -f1)
|
||||
trap_line=$(grep -n '^trap cleanup EXIT' "$REPO_ROOT/bin/dejima" | head -1 | cut -d: -f1)
|
||||
marker_line=$(grep -n 'could not remove the marker directory' "$REPO_ROOT/bin/dejima" | head -1 | cut -d: -f1)
|
||||
[ -n "$marker_line" ]
|
||||
[ "$audit_line" -lt "$trap_line" ]
|
||||
# Not just "inside cleanup()" but after the one line in it a session can
|
||||
# deliberately provoke to print - the actual last-writer invariant.
|
||||
[ "$marker_line" -lt "$audit_line" ]
|
||||
run grep -c 'could not remove the marker directory' "$REPO_ROOT/bin/dejima"
|
||||
[ "$output" -eq 1 ]
|
||||
}
|
||||
|
||||
@test "credential proxy is on by default, gated only by SANDBOX_CREDENTIAL_PROXY=0" {
|
||||
grep -q '"\${SANDBOX_CREDENTIAL_PROXY:-1}" != "0"' "$REPO_ROOT/bin/dejima"
|
||||
}
|
||||
|
||||
@test "bin/dejima masks the credentials file and mounts the proxy socket dir" {
|
||||
# The mask is what keeps the real token out of the (otherwise read-write)
|
||||
# ~/.claude mount; the socket dir is how API traffic gets back out to the
|
||||
# host-side proxy that holds the real credential.
|
||||
grep -q 'mktemp.*dejima-credentials-mask' "$REPO_ROOT/bin/dejima"
|
||||
grep -q 'extra_mounts+=(-v "\$SANDBOX_CREDENTIALS_MASK_FILE:\$HOME/\.claude/\.credentials\.json")' "$REPO_ROOT/bin/dejima"
|
||||
@test "bin/dejima mounts the proxy socket dir and masks nothing" {
|
||||
# The socket dir is how API traffic gets back out to the host-side proxy
|
||||
# that holds the real credential. The credential itself, and the
|
||||
# .credentials-proxy-tmp staging directory the proxy writes rotated
|
||||
# tokens through, used to be covered by empty-file/-directory masks
|
||||
# mounted into the read-write ~/.claude mount. That was #50: the proxy's
|
||||
# own write-back renamed the credential out from under its mask,
|
||||
# detaching it and exposing the real token from the first refresh on.
|
||||
# Neither path is mounted at all now, so there is nothing left to mask -
|
||||
# the fake credential the tls transport mounts (see below) is a file the
|
||||
# container would otherwise not have, not a mask over the operator's.
|
||||
grep -q 'extra_mounts+=(-v "\$SANDBOX_ANTHROPIC_PROXY_DIR:\$SANDBOX_ANTHROPIC_PROXY_DIR:ro")' "$REPO_ROOT/bin/dejima"
|
||||
run grep -n 'CREDENTIALS_MASK_FILE\|CREDENTIALS_TMPDIR_MASK\|dejima-credentials-mask\|dejima-credentials-tmpdir-mask' "$REPO_ROOT/bin/dejima"
|
||||
[ "$status" -ne 0 ]
|
||||
}
|
||||
|
||||
@test "bin/dejima also masks the proxy's write-back staging subdirectory" {
|
||||
# _write() in bin/dejima-anthropic-proxy stages the rotated
|
||||
# credential in ~/.claude/.credentials-proxy-tmp before the atomic
|
||||
# rename - that subdirectory is inside the otherwise read-write ~/.claude
|
||||
# mount, so without its own mask a sibling temp file would be
|
||||
# container-readable for the whole write window (see proxy.bats).
|
||||
grep -q 'mktemp -d.*dejima-credentials-tmpdir-mask' "$REPO_ROOT/bin/dejima"
|
||||
grep -q 'extra_mounts+=(-v "\$SANDBOX_CREDENTIALS_TMPDIR_MASK:\$HOME/\.claude/\.credentials-proxy-tmp:ro")' "$REPO_ROOT/bin/dejima"
|
||||
grep -q '"\${SANDBOX_CREDENTIALS_TMPDIR_MASK:-}"' "$REPO_ROOT/bin/dejima"
|
||||
@test "bin/dejima mounts the real credential only when the proxy is off" {
|
||||
# With ~/.claude no longer mounted as a whole, the documented
|
||||
# SANDBOX_CREDENTIAL_PROXY=0 downgrade no longer gets the credential for
|
||||
# free - it has to be stated. Read-write, because Claude Code refreshes
|
||||
# it in place. The only mount of it anywhere, and it sits under the
|
||||
# proxy-disabled branch.
|
||||
run grep -c 'extra_mounts+=(-v "\$HOME/\.claude/\.credentials\.json:\$HOME/\.claude/\.credentials\.json")' "$REPO_ROOT/bin/dejima"
|
||||
[ "$output" = "1" ]
|
||||
local mount_line branch_line
|
||||
mount_line=$(grep -n 'extra_mounts+=(-v "\$HOME/\.claude/\.credentials\.json' "$REPO_ROOT/bin/dejima" | cut -d: -f1)
|
||||
branch_line=$(grep -n 'elif \[ -f "\$HOME/\.claude/\.credentials\.json" \]; then' "$REPO_ROOT/bin/dejima" | cut -d: -f1)
|
||||
[ -n "$branch_line" ]
|
||||
[ "$branch_line" -lt "$mount_line" ]
|
||||
}
|
||||
|
||||
@test "entrypoint.sh gives the container an empty credentials file of its own" {
|
||||
# The mask used to provide one; Claude Code shouldn't have to be trusted
|
||||
# to treat an absent credentials file the same as an empty one. Guarded
|
||||
# on non-existence so it can't clobber either of the credentials
|
||||
# bin/dejima does mount - the fake one under the tls transport, the real
|
||||
# one under SANDBOX_CREDENTIAL_PROXY=0 - since mounts are established
|
||||
# before the entrypoint runs.
|
||||
grep -q 'if \[ ! -e "\$SANDBOX_HOME/\.claude/\.credentials\.json" \]; then' "$SANDBOX_DIR/entrypoint.sh"
|
||||
grep -q 'install -o "\$SANDBOX_USER" -g "\$SANDBOX_USER" -m 600 /dev/null "\$SANDBOX_HOME/\.claude/\.credentials\.json"' "$SANDBOX_DIR/entrypoint.sh"
|
||||
}
|
||||
|
||||
@test "bin/dejima refuses to launch when the proxy fails to start" {
|
||||
@@ -445,9 +729,8 @@ setup() {
|
||||
grep -q 'SANDBOX_CREDENTIAL_PROXY=0' "$REPO_ROOT/bin/dejima"
|
||||
}
|
||||
|
||||
@test "cleanup reaps the proxy and its socket dir and mask file" {
|
||||
@test "cleanup reaps the proxy and its socket dir" {
|
||||
grep -q 'kill "\$SANDBOX_ANTHROPIC_PROXY_PID"' "$REPO_ROOT/bin/dejima"
|
||||
grep -q '"\${SANDBOX_CREDENTIALS_MASK_FILE:-}"' "$REPO_ROOT/bin/dejima"
|
||||
grep -q '"\${SANDBOX_ANTHROPIC_PROXY_DIR:-}"' "$REPO_ROOT/bin/dejima"
|
||||
}
|
||||
|
||||
@@ -493,27 +776,34 @@ setup() {
|
||||
[ "$status" -ne 0 ]
|
||||
}
|
||||
|
||||
@test "bin/dejima mounts targets of top-level ~/.claude symlinks (statusline.sh)" {
|
||||
# settings.json's statusLine points at ~/.claude/statusline.sh, which on
|
||||
# this setup is a symlink into dotfiles - previously only the five
|
||||
# overlay subdirectories were scanned for symlinks, so the target dir
|
||||
# never got mounted and the statusline silently died in the sandbox.
|
||||
grep -q 'for overlay_dir in \. hooks skills commands agents output-styles' "$REPO_ROOT/bin/dejima"
|
||||
@test "bin/dejima scans only the mounted ~/.claude dirs for symlink targets" {
|
||||
# A symlink inside one of these has to have its target reachable, or the
|
||||
# hook/skill it points at can't run in the container. Top-level files
|
||||
# (settings.json, CLAUDE.md, statusline.sh) are staged as copies instead,
|
||||
# and Docker resolves any symlink among them when it sets up that mount -
|
||||
# so scanning "." as well would mount a whole config checkout read-only
|
||||
# into the container for nothing.
|
||||
grep -q 'for mounted_dir in hooks skills commands agents output-styles' "$REPO_ROOT/bin/dejima"
|
||||
}
|
||||
|
||||
@test "bin/dejima mounts ~/.claude/statusline.sh itself read-only, when present" {
|
||||
# statusline.sh is a top-level file directly on the read-write ~/.claude
|
||||
# mount (unlike settings.json/CLAUDE.md/plugins/hooks/skills/commands/
|
||||
# agents/output-styles, none of which have this gap) - settings.json's
|
||||
# statusLine.command points at it and Claude Code executes it on the
|
||||
# host. Mounting a symlink's *target* dir read-only (the test above)
|
||||
# doesn't help: the symlink/file at ~/.claude/statusline.sh itself still
|
||||
# lives on the writable mount and a compromised session can unlink it and
|
||||
# drop its own script in its place. Must be conditional on existence
|
||||
# (not every host has a statusline, and mounting a nonexistent source
|
||||
# would make Docker create a root-owned directory at the target
|
||||
# instead).
|
||||
grep -q 'extra_mounts+=(-v "\$HOME/\.claude/statusline\.sh:\$HOME/\.claude/statusline\.sh:ro")' "$REPO_ROOT/bin/dejima"
|
||||
@test "bin/dejima stages ~/.claude/statusline.sh for copying, when present" {
|
||||
# settings.json's statusLine.command points at it and Claude Code
|
||||
# executes it on the host, so it's the same class of file as the config
|
||||
# staged at /etc/dejima-claude by docker-compose.yml, and staged the same
|
||||
# way. A read-only mount at its real path protected only the
|
||||
# plain-regular-file case: when statusline.sh is itself a symlink (the
|
||||
# common case, into a dotfiles checkout), bind-mount destination
|
||||
# resolution followed it, so the mount landed on the target and left the
|
||||
# symlink dentry writable and replaceable. Docker resolves that symlink
|
||||
# when it sets up the staging mount instead, so what entrypoint.sh copies
|
||||
# in is the real content at a path nothing on the host can reach. Still
|
||||
# conditional on existence: not every host has a statusline, and mounting
|
||||
# a nonexistent source would make Docker create a root-owned directory at
|
||||
# the target instead.
|
||||
grep -q 'extra_mounts+=(-v "\$HOME/\.claude/statusline\.sh:/etc/dejima-claude/statusline\.sh:ro")' "$REPO_ROOT/bin/dejima"
|
||||
grep -q 'install -o root -g root -m 555 /etc/dejima-claude/statusline\.sh' "$SANDBOX_DIR/entrypoint.sh"
|
||||
run grep -F 'extra_mounts+=(-v "$HOME/.claude/statusline.sh:$HOME/.claude/statusline.sh' "$REPO_ROOT/bin/dejima"
|
||||
[ "$status" -ne 0 ]
|
||||
}
|
||||
|
||||
@test "bin/dejima refuses to launch rather than silently skip a dangling statusline.sh symlink" {
|
||||
@@ -531,10 +821,11 @@ setup() {
|
||||
}
|
||||
|
||||
@test "bin/dejima-audit monitors the home-level ~/.claude/statusline.sh (Finding 1 compensating control)" {
|
||||
# The read-only mount above does not reliably protect statusline.sh when
|
||||
# it's a symlink (confirmed live-container gap - see test/runtime.bats
|
||||
# and bin/dejima's comment on the mount) - detection is the
|
||||
# compensating control. Full behavioral coverage (snapshot/compare
|
||||
# Staging it (the test above) is what actually closes the symlink gap the
|
||||
# old read-only mount left open; this stays as defence in depth, since a
|
||||
# session reaching the host statusline by some other route is exactly the
|
||||
# kind of thing an exit-time audit exists to surface. Full behavioral
|
||||
# coverage (snapshot/compare
|
||||
# against real fixtures, including the symlink-swap exploit) lives in
|
||||
# test/audit.bats; this just confirms the wiring exists.
|
||||
grep -q 'STATUSLINE_PATH=' "$REPO_ROOT/bin/dejima-audit"
|
||||
@@ -824,7 +1115,291 @@ EOF
|
||||
grep -q 'dejima-dep add cargo:cargo-nextest --global' "$REPO_ROOT/README.md"
|
||||
}
|
||||
|
||||
@test "README documents that the exit-time audit's findings are not attributed to the session" {
|
||||
# Presence-only grep - it confirms the caveat sentence exists in the
|
||||
# Security notes teaser, not that the linked docs/security.md
|
||||
# explanation it points readers to is itself correct or complete.
|
||||
grep -q 'not a verdict on who caused them' "$REPO_ROOT/README.md"
|
||||
}
|
||||
|
||||
@test "docs/security.md documents the ~/.claude/projects cross-session false-positive case" {
|
||||
# Presence-only grep, same caveat as above: confirms the specific
|
||||
# ~/.claude/projects wording landed, not that the surrounding
|
||||
# reasoning is sound.
|
||||
grep -q 'CROSS-PROJECT. finding' "$REPO_ROOT/docs/security.md"
|
||||
grep -q 'directory shared by' "$REPO_ROOT/docs/security.md"
|
||||
}
|
||||
|
||||
@test "sandbox-context.sh no longer mentions ALLOWED_DOMAINS" {
|
||||
run grep -n 'ALLOWED_DOMAINS' "$REPO_ROOT/hooks/sandbox-context.sh"
|
||||
[ "$status" -ne 0 ]
|
||||
}
|
||||
|
||||
# The repo is public; the operator's own network is not. Both shapes below
|
||||
# leaked into it once (a .localdomain Gitea host in bin/gitea-pr, a real LAN
|
||||
# address as the Loki example in docker-compose.yml), so they are pinned
|
||||
# here. Documentation addresses belong in RFC 5737's ranges (192.0.2.0/24,
|
||||
# 198.51.100.0/24, 203.0.113.0/24) and documentation names under RFC 2606's
|
||||
# reserved TLDs.
|
||||
#
|
||||
# test/ is deliberately out of scope: fixtures stub real resolver answers
|
||||
# (project.bats feeds dig a 10.0.0.1) and those are inputs to a test, not
|
||||
# addresses an operator is being told to use.
|
||||
#
|
||||
# This catches address- and hostname-shaped leaks only. A personal domain
|
||||
# that looks like any other public one is not mechanically distinguishable
|
||||
# and still rests on review.
|
||||
SCANNED_PATHS=(README.md docs docker bin hooks install.sh systemd .gitea)
|
||||
|
||||
@test "no private or link-local address is used as a documentation example" {
|
||||
run grep -rnIE '\b(10|127\.0\.0\.[2-9]|169\.254|192\.168|172\.(1[6-9]|2[0-9]|3[01]))\.[0-9]{1,3}\.[0-9]{1,3}\b' \
|
||||
"${SCANNED_PATHS[@]/#/$REPO_ROOT/}"
|
||||
[ "$status" -ne 0 ] || {
|
||||
echo "use an RFC 5737 documentation address instead:" >&2
|
||||
echo "$output" >&2
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
@test "no hostname under a private-network suffix is used as a documentation example" {
|
||||
run grep -rnIE '[A-Za-z0-9-]+\.(localdomain|lan|internal|home\.arpa)\b' \
|
||||
"${SCANNED_PATHS[@]/#/$REPO_ROOT/}"
|
||||
[ "$status" -ne 0 ] || {
|
||||
echo "use an RFC 2606 reserved name (example.com, .invalid, .test) instead:" >&2
|
||||
echo "$output" >&2
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
# --- Task 9: startup warning about unacknowledged CRITICAL audit history ---
|
||||
|
||||
@test "dejima warns about unacknowledged critical history before starting" {
|
||||
grep -q 'CRITICAL.txt' "$REPO_ROOT/bin/dejima"
|
||||
}
|
||||
|
||||
@test "the startup warning is emitted before the container runs" {
|
||||
warn_line=$(grep -n 'unacknowledged CRITICAL' "$REPO_ROOT/bin/dejima" | head -1 | cut -d: -f1)
|
||||
run_line=$(grep -n 'docker compose -f "\$COMPOSE_FILE" run' "$REPO_ROOT/bin/dejima" | head -1 | cut -d: -f1)
|
||||
[ "$warn_line" -lt "$run_line" ]
|
||||
}
|
||||
|
||||
@test "the startup warning does not block when stdin is not a tty" {
|
||||
grep -q 'DEJIMA_AUDIT_STRICT' "$REPO_ROOT/bin/dejima"
|
||||
grep -q '\-t 0' "$REPO_ROOT/bin/dejima"
|
||||
}
|
||||
|
||||
# The three tests above are static (they hold even for a warning that never
|
||||
# actually runs correctly). The ones below drive bin/dejima's real startup
|
||||
# path end-to-end against a stubbed `docker` - same technique as
|
||||
# test/project.bats' launch/launch_setup and test/remote.bats' stub_launch -
|
||||
# so the warning is checked doing its actual job, not just present in the
|
||||
# source.
|
||||
teardown() {
|
||||
rm -rf ${DEJIMA_T:+"$DEJIMA_T"}
|
||||
}
|
||||
|
||||
startup_warning_setup() {
|
||||
DEJIMA_T="$(mktemp -d)"
|
||||
mkdir -p "$DEJIMA_T/home/.claude" "$DEJIMA_T/config" "$DEJIMA_T/audit" "$DEJIMA_T/ws"
|
||||
echo '{}' > "$DEJIMA_T/home/.claude/settings.json"
|
||||
cat > "$DEJIMA_T/docker" <<'EOF'
|
||||
#!/bin/bash
|
||||
echo "$*" >> "${SANDBOX_TEST_DOCKER_LOG:-/dev/null}"
|
||||
exit 0
|
||||
EOF
|
||||
chmod +x "$DEJIMA_T/docker"
|
||||
: > "$DEJIMA_T/sandbox-key"
|
||||
chmod 600 "$DEJIMA_T/sandbox-key"
|
||||
}
|
||||
|
||||
# Redirected from /dev/null, so this is deterministically the "stdin is not a
|
||||
# tty" path - real interactive [c]ontinue/[q]uit behavior needs a pty, which
|
||||
# isn't available here (see the task report).
|
||||
startup_warning_launch() {
|
||||
HOME="$DEJIMA_T/home" PATH="$DEJIMA_T:$PATH" \
|
||||
DEJIMA_CONFIG_DIR="$DEJIMA_T/config" \
|
||||
DEJIMA_AUDIT_DIR="$DEJIMA_T/audit" \
|
||||
SANDBOX_TEST_DOCKER_LOG="$DEJIMA_T/docker-log" \
|
||||
SANDBOX_CREDENTIAL_PROXY=0 \
|
||||
SANDBOX_SSH_KEY="$DEJIMA_T/sandbox-key" \
|
||||
SSH_AUTH_SOCK= ALLOWED_DOMAINS= \
|
||||
"$REPO_ROOT/bin/dejima" "$DEJIMA_T/ws" < /dev/null
|
||||
}
|
||||
|
||||
startup_warning_slug() { printf '%s' "$DEJIMA_T/ws" | sed 's/[^a-zA-Z0-9]/-/g'; }
|
||||
|
||||
@test "bin/dejima's startup path stays silent about the audit with no unacknowledged CRITICAL history" {
|
||||
startup_warning_setup
|
||||
run startup_warning_launch
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" != *"unacknowledged CRITICAL"* ]]
|
||||
grep -q ' run --rm ' "$DEJIMA_T/docker-log"
|
||||
}
|
||||
|
||||
@test "bin/dejima's startup warning fires for an unacknowledged CRITICAL report and still launches headlessly" {
|
||||
startup_warning_setup
|
||||
mkdir -p "$DEJIMA_T/audit/$(startup_warning_slug)"
|
||||
: > "$DEJIMA_T/audit/$(startup_warning_slug)/2020-01-01-000000-CRITICAL.txt"
|
||||
run startup_warning_launch
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *"1 unacknowledged CRITICAL"* ]]
|
||||
[[ "$output" == *"dejima-audit ack"* ]]
|
||||
grep -q ' run --rm ' "$DEJIMA_T/docker-log"
|
||||
}
|
||||
|
||||
@test "DEJIMA_AUDIT_STRICT=1 refuses to start over unacknowledged CRITICAL history when stdin is not a tty" {
|
||||
startup_warning_setup
|
||||
mkdir -p "$DEJIMA_T/audit/$(startup_warning_slug)"
|
||||
: > "$DEJIMA_T/audit/$(startup_warning_slug)/2020-01-01-000000-CRITICAL.txt"
|
||||
DEJIMA_AUDIT_STRICT=1 run startup_warning_launch
|
||||
[ "$status" -ne 0 ]
|
||||
[[ "$output" == *"refusing to start"* ]]
|
||||
! grep -q ' run --rm ' "$DEJIMA_T/docker-log" 2>/dev/null
|
||||
}
|
||||
|
||||
@test "bin/dejima's startup path is unaffected when the audit dir does not exist yet" {
|
||||
# The common case for a fresh install / a workspace with no history at
|
||||
# all: DEJIMA_AUDIT_DIR itself (not just the per-workspace subdir) is
|
||||
# absent, so `find` on it fails - this must not abort the script under
|
||||
# bin/dejima's `set -euo pipefail`.
|
||||
startup_warning_setup
|
||||
rm -rf "$DEJIMA_T/audit"
|
||||
run startup_warning_launch
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" != *"unacknowledged CRITICAL"* ]]
|
||||
grep -q ' run --rm ' "$DEJIMA_T/docker-log"
|
||||
}
|
||||
|
||||
@test "entrypoint terminates TLS for api.anthropic.com on loopback by default" {
|
||||
# The transport is normalized in exactly one place so the forwarder, the
|
||||
# cert generation, and the readiness probe cannot disagree about which
|
||||
# mode this session is in.
|
||||
grep -q 'SANDBOX_ANTHROPIC_TRANSPORT="\${SANDBOX_ANTHROPIC_TRANSPORT:-tls}"' "$SANDBOX_DIR/entrypoint.sh"
|
||||
# CN alone is not enough - Node verifies the SAN.
|
||||
grep -q 'subjectAltName=DNS:api.anthropic.com' "$SANDBOX_DIR/entrypoint.sh"
|
||||
grep -q 'CN=api.anthropic.com' "$SANDBOX_DIR/entrypoint.sh"
|
||||
grep -q 'NODE_EXTRA_CA_CERTS' "$SANDBOX_DIR/entrypoint.sh"
|
||||
grep -q '127.0.0.1 api.anthropic.com' "$SANDBOX_DIR/entrypoint.sh"
|
||||
}
|
||||
|
||||
@test "entrypoint aborts when the forwarder never binds" {
|
||||
# A silent timeout would leave api.anthropic.com pointed at a loopback
|
||||
# port with nothing behind it - every API call then fails with a
|
||||
# connection error that gives no hint where it came from.
|
||||
grep -q 'the credential-proxy forwarder never bound' "$SANDBOX_DIR/entrypoint.sh"
|
||||
grep -q 'SANDBOX_ANTHROPIC_TRANSPORT=loopback' "$SANDBOX_DIR/entrypoint.sh"
|
||||
}
|
||||
|
||||
@test "the hosts entry is written only when the proxy is actually running" {
|
||||
# Under SANDBOX_CREDENTIAL_PROXY=0 there is no listener on :443, so a
|
||||
# hosts entry would black-hole the real API - which is why this cannot be
|
||||
# a compose extra_hosts entry. It must sit inside the proxy-socket guard.
|
||||
local guard body
|
||||
guard=$(grep -n 'if \[ -n "\${SANDBOX_ANTHROPIC_PROXY_SOCKET:-}" \]' "$SANDBOX_DIR/entrypoint.sh" | cut -d: -f1)
|
||||
[ -n "$guard" ]
|
||||
body=$(grep -n '127.0.0.1 api.anthropic.com' "$SANDBOX_DIR/entrypoint.sh" | cut -d: -f1)
|
||||
[ "$body" -gt "$guard" ]
|
||||
}
|
||||
|
||||
@test "openssl is installed explicitly, not relied on transitively" {
|
||||
# entrypoint.sh generates the terminator's cert at startup; a transitive
|
||||
# dependency dropping out of the base image would break the credential
|
||||
# path at runtime rather than at build time.
|
||||
grep -qE '^\s+openssl \\$' "$SANDBOX_DIR/Dockerfile"
|
||||
}
|
||||
|
||||
@test "entrypoint fails loudly, with a reason, if openssl cert generation fails" {
|
||||
# set -e already aborts on openssl's nonzero exit before chown/export can
|
||||
# touch a missing cert, so a failure here can't silently proceed or leak
|
||||
# the credential - but redirecting openssl's stderr to /dev/null would
|
||||
# still leave it dying silent-but-loud: nonzero exit, no explanation.
|
||||
grep -q 'if ! openssl_err=\$(openssl req -x509' "$SANDBOX_DIR/entrypoint.sh"
|
||||
grep -q 'FATAL: openssl failed to generate the TLS terminator cert' "$SANDBOX_DIR/entrypoint.sh"
|
||||
grep -q 'echo "\$openssl_err" >&2' "$SANDBOX_DIR/entrypoint.sh"
|
||||
}
|
||||
|
||||
@test "compose passes the transport through and lets an unprivileged process bind :443" {
|
||||
grep -qE '^\s*SANDBOX_ANTHROPIC_TRANSPORT:\s*\$\{SANDBOX_ANTHROPIC_TRANSPORT:-\}\s*$' "$SANDBOX_DIR/docker-compose.yml"
|
||||
# Docker already defaults this to 0, but the terminator runs as the
|
||||
# unprivileged sandbox user: pinning it means a future default change
|
||||
# fails at launch instead of silently breaking the credential path.
|
||||
grep -qE '^\s*net\.ipv4\.ip_unprivileged_port_start:\s*0\s*$' "$SANDBOX_DIR/docker-compose.yml"
|
||||
}
|
||||
|
||||
@test "compose does not pin api.anthropic.com to loopback unconditionally" {
|
||||
# That belongs in entrypoint.sh, inside the proxy guard - see the
|
||||
# entrypoint test above for why an unconditional entry breaks the
|
||||
# SANDBOX_CREDENTIAL_PROXY=0 path.
|
||||
! grep -q 'extra_hosts' "$SANDBOX_DIR/docker-compose.yml"
|
||||
}
|
||||
|
||||
@test "bin/dejima defaults to the tls transport and exports it" {
|
||||
grep -q 'SANDBOX_ANTHROPIC_TRANSPORT="\${SANDBOX_ANTHROPIC_TRANSPORT:-tls}"' "$REPO_ROOT/bin/dejima"
|
||||
grep -q 'export .*SANDBOX_ANTHROPIC_TRANSPORT' "$REPO_ROOT/bin/dejima"
|
||||
}
|
||||
|
||||
@test "tls mode mounts a fake credential, never the real one, and sets no ANTHROPIC_* pair" {
|
||||
# tls mode is the one case that puts a credentials file into the
|
||||
# container: Claude Code reads .credentials.json directly when there is no
|
||||
# ANTHROPIC_AUTH_TOKEN, so the empty container-local file entrypoint.sh
|
||||
# would otherwise leave isn't enough - it needs a well-formed document,
|
||||
# with the secrets faked and the non-secret fields
|
||||
# (scopes/subscriptionType/rateLimitTier/clientId) copied so the session's
|
||||
# own UI state stays accurate.
|
||||
grep -q 'extra_mounts+=(-v "\$SANDBOX_FAKE_CREDENTIALS_FILE:\$HOME/\.claude/\.credentials\.json")' "$REPO_ROOT/bin/dejima"
|
||||
grep -q 'sandbox-credential-proxy-placeholder' "$REPO_ROOT/bin/dejima"
|
||||
grep -q 'subscriptionType' "$REPO_ROOT/bin/dejima"
|
||||
grep -q 'rateLimitTier' "$REPO_ROOT/bin/dejima"
|
||||
# Expiry is deliberately NOT mirrored from the real credential: a
|
||||
# near-term expiresAt makes Claude Code refresh with the fake refresh
|
||||
# token, fail, and log the session out.
|
||||
grep -q 'refreshTokenExpiresAt' "$REPO_ROOT/bin/dejima"
|
||||
}
|
||||
|
||||
@test "the loopback transport still sets the base URL and placeholder token" {
|
||||
grep -q 'SANDBOX_ANTHROPIC_BASE_URL="http://127.0.0.1:18300"' "$REPO_ROOT/bin/dejima"
|
||||
grep -q 'SANDBOX_ANTHROPIC_AUTH_TOKEN="sandbox-credential-proxy-placeholder"' "$REPO_ROOT/bin/dejima"
|
||||
}
|
||||
|
||||
@test "api.anthropic.com is in the fixed domains only when the proxy is off" {
|
||||
# Not unconditionally allowlisted: with the proxy on, the session reaches
|
||||
# the API through the forwarder rather than by resolving this name, so
|
||||
# leaving it unresolvable defeats a bypass that looks it up by DNS. When
|
||||
# the proxy is off (kill
|
||||
# switch or API-key auth) DNS is the only path to the API, so it has to be
|
||||
# allowlisted - and in the *fixed* list, not EXTRA_ALLOWED_DOMAINS, which
|
||||
# dns-proxy.sh exempts from stop-dns-rebind and init-firewall.sh treats as
|
||||
# non-fatal when it fails to resolve.
|
||||
run bash -c "SANDBOX_ANTHROPIC_PROXY_SOCKET=/tmp/proxy.sock; source '$SANDBOX_DIR/fixed-domains.sh'; printf '%s\n' \"\${FIXED_DOMAINS[@]}\""
|
||||
[ "$status" -eq 0 ]
|
||||
! grep -qx 'api.anthropic.com' <<< "$output"
|
||||
|
||||
run bash -c "SANDBOX_ANTHROPIC_PROXY_SOCKET=; source '$SANDBOX_DIR/fixed-domains.sh'; printf '%s\n' \"\${FIXED_DOMAINS[@]}\""
|
||||
[ "$status" -eq 0 ]
|
||||
grep -qx 'api.anthropic.com' <<< "$output"
|
||||
|
||||
# Unset (a bare `docker run` that passes no socket) must behave like empty.
|
||||
# Under `set -u`, as both consumers run, so that dropping the `:-` default
|
||||
# fails here rather than at container start.
|
||||
run bash -c "set -u; unset SANDBOX_ANTHROPIC_PROXY_SOCKET; source '$SANDBOX_DIR/fixed-domains.sh'; printf '%s\n' \"\${FIXED_DOMAINS[@]}\""
|
||||
[ "$status" -eq 0 ]
|
||||
grep -qx 'api.anthropic.com' <<< "$output"
|
||||
|
||||
# ...and entrypoint.sh no longer adds it to the extra list, which would
|
||||
# reinstate both of the exemptions above.
|
||||
! grep -q 'extra_domains.*api.anthropic.com' "$SANDBOX_DIR/entrypoint.sh"
|
||||
}
|
||||
|
||||
@test "an unrecognised SANDBOX_ANTHROPIC_TRANSPORT is rejected, not silently treated as loopback" {
|
||||
# bin/dejima branches on `loopback`, entrypoint.sh and the forwarder on
|
||||
# `tls` - so a typo configures the two halves for different transports and
|
||||
# every API call fails with nothing naming the cause.
|
||||
grep -q 'FATAL: SANDBOX_ANTHROPIC_TRANSPORT is set to' "$SANDBOX_DIR/entrypoint.sh"
|
||||
grep -q "The only valid values are 'tls' (the default) and 'loopback'" "$SANDBOX_DIR/entrypoint.sh"
|
||||
# Ahead of the proxy-socket guard, so it catches the typo whatever else
|
||||
# this session is configured for.
|
||||
local check guard
|
||||
check=$(grep -n 'FATAL: SANDBOX_ANTHROPIC_TRANSPORT is set to' "$SANDBOX_DIR/entrypoint.sh" | cut -d: -f1)
|
||||
guard=$(grep -n 'if \[ -n "\${SANDBOX_ANTHROPIC_PROXY_SOCKET:-}" \]' "$SANDBOX_DIR/entrypoint.sh" | cut -d: -f1)
|
||||
[ "$check" -lt "$guard" ]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user