Compare commits
225
Commits
run-id
...
allow-multi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d8c86ea44 | ||
|
|
60a319b3ba | ||
|
|
c0bc131fd4 | ||
|
|
00a2df3b0c | ||
|
|
e7769bb3d6 | ||
|
|
e346ad3ae2 | ||
|
|
3f5dae88b5 | ||
|
|
5569e8d2fe | ||
|
|
e900c51e20 | ||
|
|
e91b5771f6 | ||
|
|
3b17604178 | ||
|
|
6511a0537c | ||
|
|
023efb3171 | ||
|
|
23462a3c71 | ||
|
|
2ddcf964c3 | ||
|
|
faeafa7f2d | ||
|
|
2cc6057c88 | ||
|
|
1d8725f089 | ||
|
|
aa40e0c455 | ||
|
|
d180581293 | ||
|
|
f61795381d | ||
|
|
805d9a2c35 | ||
|
|
5884930592 | ||
|
|
452d49394f | ||
|
|
1cda465898 | ||
|
|
cacb74fb28 | ||
|
|
4a9e789ba1 | ||
|
|
9b96d5781b | ||
|
|
bac3d45814 | ||
|
|
fa29e2ac00 | ||
|
|
b3f8252c73 | ||
|
|
720b91a569 | ||
|
|
3086bb884e | ||
|
|
02f5dd8b19 | ||
|
|
990ab94a40 | ||
|
|
940f632447 | ||
|
|
c8fd2dc605 | ||
|
|
ba2158e0e8 | ||
|
|
51c0a8bc65 | ||
|
|
d418de83df | ||
|
|
225abccece | ||
|
|
43081852ea | ||
|
|
4adfe9b36c | ||
|
|
12a222cace | ||
|
|
af0fce3326 | ||
|
|
2217d6d3c3 | ||
|
|
300097dcae | ||
|
|
30a2a5c288 | ||
|
|
ab67534887 | ||
|
|
bb4f06657f | ||
|
|
6d77293880 | ||
|
|
c7521c7b6d | ||
|
|
3474c96f95 | ||
|
|
4c81605df6 | ||
|
|
07d863d6dc | ||
|
|
2d3a8ef32a | ||
|
|
f0cf3b98f7 | ||
|
|
513187cef3 | ||
|
|
4caf9d8d76 | ||
|
|
bad60b2cbe | ||
|
|
b906f33ab5 | ||
|
|
2f921a0d6a | ||
|
|
79e18b916a | ||
|
|
ba2fb5b434 | ||
|
|
03b96b2da8 | ||
|
|
f8726b2ea9 | ||
|
|
1d91d07d5c | ||
|
|
172988a67c | ||
|
|
a7d7ec9dda | ||
|
|
6d6e40d243 | ||
|
|
eb5b34f3be | ||
|
|
9329c722cd | ||
|
|
978480c73d | ||
|
|
4f0fbff39c | ||
|
|
34ae5c4044 | ||
|
|
c400ff3ad0 | ||
|
|
57c76aa573 | ||
|
|
d188eb20be | ||
|
|
88779bd656 | ||
|
|
7535f4795b | ||
|
|
bd01875c7f | ||
|
|
d8263120e3 | ||
|
|
c302aaae96 | ||
|
|
a899d60d59 | ||
|
|
4425d83d88 | ||
|
|
c73fb4702b | ||
|
|
896773bfc5 | ||
|
|
8b579fdd59 | ||
|
|
f44fe47657 | ||
|
|
378ad85d8e | ||
|
|
6522ad56e0 | ||
|
|
b91b8f5fb3 | ||
|
|
e9e1c2bc52 | ||
|
|
cb793edead | ||
|
|
832c4a4f6d | ||
|
|
0daae02833 | ||
|
|
a88393f769 | ||
|
|
b8c34bb98c | ||
|
|
df7a74481c | ||
|
|
337b2bce04 | ||
|
|
d8845206c4 | ||
|
|
c0935a52c3 | ||
|
|
2184bc76b2 | ||
|
|
393853a024 | ||
|
|
09d1881633 | ||
|
|
26ee847e35 | ||
|
|
c805208fac | ||
|
|
ebe63cfc30 | ||
|
|
9764ddd4fc | ||
|
|
5ee405fd6e | ||
|
|
39b7c9888d | ||
|
|
10aed998aa | ||
|
|
f4017b878f | ||
|
|
10aab4846a | ||
|
|
35cd88c25a | ||
|
|
64cc56589c | ||
|
|
3c6912b0f9 | ||
|
|
fc750688c1 | ||
|
|
7f828e8416 | ||
|
|
bf82907e88 | ||
|
|
554bda9003 | ||
|
|
dbcbe5c89e | ||
|
|
1cc9d8c7d7 | ||
|
|
2f2475a66d | ||
|
|
40feada4bb | ||
|
|
90222214e4 | ||
|
|
6bee272d5c | ||
|
|
86be44752c | ||
|
|
1df757afe6 | ||
|
|
0516676fce | ||
|
|
9f4b9a9011 | ||
|
|
1009810c52 | ||
|
|
f20075941b | ||
|
|
5e7d1d2fbb | ||
|
|
666f2427e1 | ||
|
|
ce6e739201 | ||
|
|
1a7c22764a | ||
|
|
e95eb49cc9 | ||
|
|
a75bf8a5a5 | ||
|
|
9467159c8f | ||
|
|
fdd1bc68ae | ||
|
|
72d7fe8fea | ||
|
|
0b6012a78c | ||
|
|
9370e88ebe | ||
|
|
1cc6744e2e | ||
|
|
077f37580f | ||
|
|
b651709cae | ||
|
|
9b61708ace | ||
|
|
8fcc16eaf3 | ||
|
|
ca9c03b88d | ||
|
|
7c7b2ec533 | ||
|
|
290a50b878 | ||
|
|
36eb1f1644 | ||
|
|
edbeab1cc8 | ||
|
|
25b5dd48ee | ||
|
|
a7658e790b | ||
|
|
0d0b3d8cde | ||
|
|
8812232e00 | ||
|
|
842cfd7a4d | ||
|
|
4b24065438 | ||
|
|
886b652133 | ||
|
|
ec875dcbe2 | ||
|
|
18905fd8d0 | ||
|
|
612a641893 | ||
|
|
bddd13ee04 | ||
|
|
bdde256123 | ||
|
|
9f5dd0148e | ||
|
|
59e0d5e8fc | ||
|
|
311310f4bd | ||
|
|
eb9970d471 | ||
|
|
bbc23b0881 | ||
|
|
fb203206db | ||
|
|
b20a0bc9b8 | ||
|
|
ed15a30d1d | ||
|
|
9ef51488ad | ||
|
|
c637d1dee6 | ||
|
|
6a0ec12d41 | ||
|
|
44bcee193f | ||
|
|
c88240b4b6 | ||
|
|
c6a0a3ff77 | ||
|
|
e953bfa5fa | ||
|
|
5e0a390839 | ||
|
|
88d54cab78 | ||
|
|
d6f7f8c9d7 | ||
|
|
881e6343e5 | ||
|
|
668c3feccf | ||
|
|
8fb4a4d39b | ||
|
|
3b6c5c016f | ||
|
|
0a6b55b5b9 | ||
|
|
e250c705ed | ||
|
|
2cbf1a414c | ||
|
|
3100a5f454 | ||
|
|
b32c8921c2 | ||
|
|
e151faf4ec | ||
|
|
251beaab45 | ||
|
|
af6f0e4aa7 | ||
|
|
3a6c233a0f | ||
|
|
924dd4ed51 | ||
|
|
271d7843fe | ||
|
|
9b6ad238f3 | ||
|
|
19fb87c1f8 | ||
|
|
75eb427e62 | ||
|
|
aa3a198e26 | ||
|
|
1de83f8a16 | ||
|
|
bd96919504 | ||
|
|
a721e3d1fb | ||
|
|
d7e4c6be0a | ||
|
|
2c31d83a49 | ||
|
|
077cd241a7 | ||
|
|
4e47c30457 | ||
|
|
5b7250f8f0 | ||
|
|
f1f2fe4b02 | ||
|
|
a4f4d1428f | ||
|
|
a301bae1c4 | ||
|
|
849ec326a2 | ||
|
|
b872fabdf6 | ||
|
|
1e788548a0 | ||
|
|
3734a614bd | ||
|
|
55c33a8cc5 | ||
|
|
95b90761dd | ||
|
|
a438bf368b | ||
|
|
2a20ae62cc | ||
|
|
2efb93c644 | ||
|
|
cdad2fb1e9 | ||
|
|
b1d7975aee |
+104
-40
@@ -80,6 +80,7 @@ jobs:
|
||||
matrix:
|
||||
suite:
|
||||
- static
|
||||
- launch
|
||||
- project
|
||||
- remote
|
||||
- audit
|
||||
@@ -94,14 +95,6 @@ jobs:
|
||||
# needs to run at all, and apt-get is what everything below needs.
|
||||
image: node:20-bookworm
|
||||
steps:
|
||||
# Before apt, because apt's failure for this says "Unable to locate
|
||||
# package bats" and nothing about why. On a runner behind the egress
|
||||
# allowlist a name that does not resolve is the resolver or the
|
||||
# allowlist, not the package - and ci-runner.bats, which would say so
|
||||
# precisely, cannot run yet because installing it is what just failed.
|
||||
#
|
||||
# Uses only what the image already has: getent is libc, and the
|
||||
# resolver's address is in resolv.conf.
|
||||
# Before apt, because apt's failure for this says "Unable to locate
|
||||
# package bats" and nothing about why. On a runner behind the egress
|
||||
# allowlist a name that does not resolve is the resolver or the
|
||||
@@ -113,7 +106,8 @@ jobs:
|
||||
# job on a single lookup would be stricter than the step it is
|
||||
# protecting. Six attempts over ~15s, then a diagnosis.
|
||||
#
|
||||
# Uses only what the image already has: getent is libc.
|
||||
# Uses only what the image already has: getent is libc, and the
|
||||
# resolver's address is in resolv.conf.
|
||||
- name: Check the job network can resolve before installing anything
|
||||
run: |
|
||||
for attempt in 1 2 3 4 5 6; do
|
||||
@@ -146,7 +140,11 @@ jobs:
|
||||
- name: Install test dependencies
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends bats jq python3 tmux ca-certificates
|
||||
# iproute2 for `ss`: the one test asserting dejima-ttyd refuses to
|
||||
# start when something already holds its port skips without it, and a
|
||||
# permanent skip on the check guarding a reverse-proxy port takeover
|
||||
# is the same as not having it.
|
||||
apt-get install -y --no-install-recommends bats jq python3 tmux ca-certificates iproute2
|
||||
# The code under test, named explicitly - see the header. Without `ref`
|
||||
# this checks out the base branch and the suite passes without ever
|
||||
# seeing the change.
|
||||
@@ -250,14 +248,6 @@ jobs:
|
||||
# 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:
|
||||
# Before apt, because apt's failure for this says "Unable to locate
|
||||
# package bats" and nothing about why. On a runner behind the egress
|
||||
# allowlist a name that does not resolve is the resolver or the
|
||||
# allowlist, not the package - and ci-runner.bats, which would say so
|
||||
# precisely, cannot run yet because installing it is what just failed.
|
||||
#
|
||||
# Uses only what the image already has: getent is libc, and the
|
||||
# resolver's address is in resolv.conf.
|
||||
# Before apt, because apt's failure for this says "Unable to locate
|
||||
# package bats" and nothing about why. On a runner behind the egress
|
||||
# allowlist a name that does not resolve is the resolver or the
|
||||
@@ -269,7 +259,8 @@ jobs:
|
||||
# job on a single lookup would be stricter than the step it is
|
||||
# protecting. Six attempts over ~15s, then a diagnosis.
|
||||
#
|
||||
# Uses only what the image already has: getent is libc.
|
||||
# Uses only what the image already has: getent is libc, and the
|
||||
# resolver's address is in resolv.conf.
|
||||
- name: Check the job network can resolve before installing anything
|
||||
run: |
|
||||
for attempt in 1 2 3 4 5 6; do
|
||||
@@ -327,9 +318,30 @@ jobs:
|
||||
echo "resolves for this architecture." >&2
|
||||
exit 1
|
||||
}
|
||||
# compose, from the same place and for a reason of the same kind: it
|
||||
# is a plugin, so without it `docker compose` is not a command at
|
||||
# all. Every launch on a real host goes through one, and nothing in
|
||||
# CI did - so the compose file was the one input to a launch that no
|
||||
# job had ever parsed.
|
||||
#
|
||||
# Its assets are named for `uname -m`, like the CLI tarball above and
|
||||
# unlike buildx, which uses the Debian architecture name.
|
||||
curl -fsSL -o /usr/local/lib/docker/cli-plugins/docker-compose \
|
||||
"https://github.com/docker/compose/releases/download/v${COMPOSE_VERSION}/docker-compose-linux-$(uname -m)"
|
||||
chmod +x /usr/local/lib/docker/cli-plugins/docker-compose
|
||||
# Checked for the same reason buildx is: an error page chmods just as
|
||||
# well as a binary, and the symptom would be a test that skips rather
|
||||
# than one that fails.
|
||||
docker compose version || {
|
||||
echo "The compose plugin did not load, so the compose-file test will skip" >&2
|
||||
echo "instead of running. Check the download URL above still resolves for" >&2
|
||||
echo "this architecture." >&2
|
||||
exit 1
|
||||
}
|
||||
env:
|
||||
DOCKER_CLI_VERSION: 27.5.1
|
||||
BUILDX_VERSION: 0.36.0
|
||||
COMPOSE_VERSION: 5.5.1
|
||||
|
||||
# Fails here, with the fix in the message, rather than thirty tests deep
|
||||
# in a suite that cannot work. Both of these are runner configuration,
|
||||
@@ -380,7 +392,7 @@ jobs:
|
||||
# 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
|
||||
# containers failing to boot in docker/egress-policy - 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.
|
||||
@@ -420,6 +432,58 @@ jobs:
|
||||
fi
|
||||
echo "GitHub ranges lookup is authenticated - ceiling is $limit requests/hour."
|
||||
|
||||
# One fetch for the whole run, into the directory the job already shares
|
||||
# with the containers it starts. test/runtime.bats starts twenty-seven
|
||||
# container pairs and each filter fetched the ranges for itself, which is
|
||||
# roughly twenty-seven requests in five minutes - comfortably inside the
|
||||
# 5000/hour a token allows, and exactly the burst GitHub's SECONDARY limit
|
||||
# answers with a 403 (#357). One request cannot be a burst.
|
||||
#
|
||||
# Best-effort: a failure here leaves no file, and runtime.bats falls back
|
||||
# to each container fetching for itself. That is the behaviour this
|
||||
# replaces, so the worst case is the status quo rather than a red job -
|
||||
# and the step says which of the two the run is getting.
|
||||
- name: Fetch the GitHub ranges once for the whole run
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GH_META_TOKEN }}
|
||||
run: |
|
||||
# POSIX sh, like every other run: block here - the runner's default
|
||||
# shell is `sh -e`, where an array is a syntax error and the step dies
|
||||
# before any of this runs. Two curl branches rather than one with a
|
||||
# built-up argument list, for the same reason.
|
||||
mkdir -p /tmp/dejima-ci
|
||||
out=/tmp/dejima-ci/github-meta.json
|
||||
rm -f "$out" "$out.part"
|
||||
fetched=0
|
||||
if [ -n "${GH_TOKEN:-}" ]; then
|
||||
if curl -fsS --max-time 30 -H "Authorization: Bearer $GH_TOKEN" \
|
||||
https://api.github.com/meta -o "$out.part" 2>/dev/null; then
|
||||
fetched=1
|
||||
fi
|
||||
else
|
||||
if curl -fsS --max-time 30 \
|
||||
https://api.github.com/meta -o "$out.part" 2>/dev/null; then
|
||||
fetched=1
|
||||
fi
|
||||
fi
|
||||
# Written only once it parses AND carries ranges, because a rate-limit
|
||||
# body is a 200 with a message and no ranges - and a cached one would
|
||||
# then fail every container in the run instead of one.
|
||||
ranges=""
|
||||
if [ "$fetched" = 1 ]; then
|
||||
ranges="$(jq -r '[.git[]?, .api[]?, .web[]?] | unique | .[]' < "$out.part" 2>/dev/null || true)"
|
||||
fi
|
||||
if [ -n "$ranges" ]; then
|
||||
mv "$out.part" "$out"
|
||||
echo "Cached GitHub ranges for this run: $(printf '%s\n' "$ranges" | wc -l) CIDRs."
|
||||
else
|
||||
rm -f "$out.part"
|
||||
echo "Could not cache the ranges, so each container will fetch its own."
|
||||
echo "That is the pre-#357 behaviour, not a new failure - but it is what"
|
||||
echo "trips the secondary limit, so a 403 in a container boot below is"
|
||||
echo "explained by this line."
|
||||
fi
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
@@ -446,11 +510,25 @@ jobs:
|
||||
- name: Build the sandbox image
|
||||
run: docker build -t localhost/dejima:latest -f docker/Dockerfile .
|
||||
|
||||
- run: bats test/runtime.bats
|
||||
# -T prints each test's duration. This job is the slow one and the only
|
||||
# one nobody can run locally, so without it the cost of a test is
|
||||
# guesswork - and the guess is wrong, because the expensive tests here are
|
||||
# the ones that build an image rather than the ones that assert the most.
|
||||
# Serial execution (see below) makes the numbers straightforwardly
|
||||
# additive.
|
||||
#
|
||||
# Not --jobs, unlike the confined half. One container is shared across
|
||||
# this file and a handful of tests mutate it - signalling dnsmasq,
|
||||
# reloading the address sets - so running tests concurrently would have
|
||||
# them racing over state the others read. Parallelising this suite means
|
||||
# first separating the tests that only read the shared container from the
|
||||
# ones that change it, which is a larger change than a flag.
|
||||
- run: bats -T 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
|
||||
# api.github.com/meta (fetch_forge_ranges in docker/egress-policy),
|
||||
# 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
|
||||
@@ -500,14 +578,6 @@ jobs:
|
||||
container:
|
||||
image: node:20-bookworm
|
||||
steps:
|
||||
# Before apt, because apt's failure for this says "Unable to locate
|
||||
# package bats" and nothing about why. On a runner behind the egress
|
||||
# allowlist a name that does not resolve is the resolver or the
|
||||
# allowlist, not the package - and ci-runner.bats, which would say so
|
||||
# precisely, cannot run yet because installing it is what just failed.
|
||||
#
|
||||
# Uses only what the image already has: getent is libc, and the
|
||||
# resolver's address is in resolv.conf.
|
||||
# Before apt, because apt's failure for this says "Unable to locate
|
||||
# package bats" and nothing about why. On a runner behind the egress
|
||||
# allowlist a name that does not resolve is the resolver or the
|
||||
@@ -519,7 +589,8 @@ jobs:
|
||||
# job on a single lookup would be stricter than the step it is
|
||||
# protecting. Six attempts over ~15s, then a diagnosis.
|
||||
#
|
||||
# Uses only what the image already has: getent is libc.
|
||||
# Uses only what the image already has: getent is libc, and the
|
||||
# resolver's address is in resolv.conf.
|
||||
- name: Check the job network can resolve before installing anything
|
||||
run: |
|
||||
for attempt in 1 2 3 4 5 6; do
|
||||
@@ -571,14 +642,6 @@ jobs:
|
||||
container:
|
||||
image: node:20-bookworm
|
||||
steps:
|
||||
# Before apt, because apt's failure for this says "Unable to locate
|
||||
# package bats" and nothing about why. On a runner behind the egress
|
||||
# allowlist a name that does not resolve is the resolver or the
|
||||
# allowlist, not the package - and ci-runner.bats, which would say so
|
||||
# precisely, cannot run yet because installing it is what just failed.
|
||||
#
|
||||
# Uses only what the image already has: getent is libc, and the
|
||||
# resolver's address is in resolv.conf.
|
||||
# Before apt, because apt's failure for this says "Unable to locate
|
||||
# package bats" and nothing about why. On a runner behind the egress
|
||||
# allowlist a name that does not resolve is the resolver or the
|
||||
@@ -590,7 +653,8 @@ jobs:
|
||||
# job on a single lookup would be stricter than the step it is
|
||||
# protecting. Six attempts over ~15s, then a diagnosis.
|
||||
#
|
||||
# Uses only what the image already has: getent is libc.
|
||||
# Uses only what the image already has: getent is libc, and the
|
||||
# resolver's address is in resolv.conf.
|
||||
- name: Check the job network can resolve before installing anything
|
||||
run: |
|
||||
for attempt in 1 2 3 4 5 6; do
|
||||
|
||||
@@ -160,8 +160,8 @@ layers below the container rather than into a harder one.
|
||||
This repo only holds the tooling; no project lives here. Point it at a project
|
||||
on disk and that project is bind-mounted into the container at runtime.
|
||||
|
||||
**Prerequisites:** Docker with the `docker compose` v2 plugin, and `python3`
|
||||
(stdlib only, no packages) for the host-side credential proxy.
|
||||
**Prerequisites:** Docker with the `docker compose` v2 plugin, `jq`, and
|
||||
`python3` (stdlib only, no packages) for the host-side credential proxy.
|
||||
|
||||
Only the first block below is required. The rest are independent, so take the
|
||||
ones you want.
|
||||
@@ -184,7 +184,9 @@ ones you want.
|
||||
config.
|
||||
3. **Put `~/.local/bin` on your `PATH`** if it isn't already.
|
||||
4. **Set `[core] harness`.** It has no default and a launch is refused without
|
||||
it, which is what stops a later release changing your selection for you.
|
||||
it, which is what stops a later release changing your selection for you. The
|
||||
starter config states `claude-code`, because a file with no default to fall
|
||||
back on has to name a choice; change that line for Cline.
|
||||
|
||||
That is enough to run `dejima`, and no push credential is needed to get this
|
||||
far. Everything else runs at its default, which means `[core] forge = none` and
|
||||
@@ -293,9 +295,10 @@ dejima --ro ~/other-project ~/myproject
|
||||
**→ [docs/configuration.md](docs/configuration.md#one-off-mounts-for-a-single-launch)** -
|
||||
both forms, and which host paths a mount is refused over and why.
|
||||
|
||||
**Ctrl-Z backgrounds all of dejima**, and `fg` brings back a working session.
|
||||
It makes no progress while suspended, though, so for one that should keep
|
||||
working while you are away, run dejima inside tmux and use
|
||||
**Ctrl-Z backgrounds dejima's host side**, and `fg` brings back a working
|
||||
session. It gets your terminal back rather than pausing the agent - the
|
||||
container keeps running - so for a session you want to leave working and pick
|
||||
up elsewhere, run dejima inside tmux and use
|
||||
[`dejima-attach`](docs/remote-access.md).
|
||||
→ [why that takes doing](docs/configuration.md#suspending-a-session)
|
||||
|
||||
@@ -398,11 +401,13 @@ behind each of these, and the fork-and-review workflow in full.
|
||||
|
||||
## Network egress firewall
|
||||
|
||||
Outbound access is default-deny: the container builds an allowlist-only
|
||||
`iptables`/`ipset` firewall at start, and anything not explicitly allowed is
|
||||
rejected outright rather than left to time out, so a session is told when
|
||||
something doesn't connect. IPv6 is blocked outright, with no allowlist
|
||||
equivalent for it.
|
||||
Outbound access is default-deny: the filter container establishes an
|
||||
allowlist-only `nftables` policy at start, and anything not explicitly allowed
|
||||
is rejected outright rather than left to time out, so a session is told when
|
||||
something doesn't connect rather than waiting out a timeout and reporting a
|
||||
broken tool. IPv6 is refused outright rather than allowlisted: a sandbox has no
|
||||
IPv6 egress at all, and one table states that in the same transaction as
|
||||
everything else, so there is no moment where the family is partly filtered.
|
||||
|
||||
The allowlist covers what the sandbox needs to function - DNS, the Docker host,
|
||||
GitHub on port 443 for read access whichever forge is selected, whatever the
|
||||
@@ -428,8 +433,8 @@ the one that patches a live session. `dejima --no-firewall` disables the
|
||||
firewall for one launch (debugging only).
|
||||
|
||||
**→ [docs/security.md](docs/security.md#network-egress-firewall)** - how the
|
||||
ipsets are built, how `dnsmasq` is configured, and the NFLOG groups behind the
|
||||
request log below.
|
||||
address sets are built, how the resolver is restricted, and the two records
|
||||
behind the request log below.
|
||||
|
||||
## Global and per-project domains and dependencies
|
||||
|
||||
@@ -501,7 +506,7 @@ at a fixed host path.
|
||||
|
||||
`.dejima.md` at a project's root is that project's agent-instruction file, the
|
||||
harness-agnostic equivalent of `CLAUDE.md`, loaded into the session's context
|
||||
with no authority over the sandbox itself. A `SessionStart` hook also briefs the
|
||||
with no authority over the sandbox itself. dejima also briefs the
|
||||
session on where it is running and on the instructions belonging to whichever
|
||||
workflow you selected, so a session under `local-only` is told something
|
||||
different from one under `worktree-pr`.
|
||||
@@ -531,13 +536,16 @@ name, on a queue its maintainers are already drowning in.
|
||||
|
||||
The sandbox can always read from GitHub. By default it can never push there:
|
||||
GitHub's IP ranges are only allowlisted on port 443, so port 22 is physically
|
||||
unreachable rather than merely discouraged by a hook. That rests on **two**
|
||||
properties, both worth naming because the claim stays readable if either
|
||||
quietly stops being true. The allowlist decides what is reachable over IPv4;
|
||||
what makes port 22 unreachable *at all* is that the container also blocks IPv6
|
||||
outright, since an allowlist naming one address family says nothing about the
|
||||
other. `init-firewall.sh` refuses to start rather than run with IPv6 live and
|
||||
unfilterable.
|
||||
unreachable rather than merely discouraged by a hook. Two properties carry
|
||||
that, both worth naming because the claim stays readable if either quietly
|
||||
stops being true. The restriction belongs to the *destination* rather than to
|
||||
the path it was reached by, so resolving a GitHub name cannot open a port its
|
||||
published ranges do not have. And IPv6 is refused as a whole family, because an
|
||||
allowlist naming one address family says nothing about the other - so "port 22
|
||||
is closed on those ranges" would mean nothing if a v6 route to the same host
|
||||
stayed open. The refusal is the first rule of the chain and there is no v6
|
||||
permit path anywhere in it, which the policy checks at startup rather than
|
||||
assuming.
|
||||
|
||||
`--allow-github-push` opens port 22 for one launch, paired with a repo-scoped
|
||||
deploy key in `[core] ssh_key` rather than a forwarded personal agent. The one
|
||||
@@ -557,9 +565,8 @@ After the container exits it diffs them and tells you what changed.
|
||||
|
||||
It is **advisory, not a block**, firing after the session has ended, so a
|
||||
finding is something to clean up before your next `git` command or session in
|
||||
that repo. Its diff is time-windowed: a finding says a path changed while the
|
||||
session was running, not that the session changed it. Findings are a time
|
||||
window, not a verdict on who caused them.
|
||||
that repo. Its findings are a time window, not a verdict on who caused them:
|
||||
one says a path changed while the session was running.
|
||||
|
||||
`dejima-audit show` indexes what is waiting and `dejima-audit ack` walks it, one
|
||||
finding at a time. `[audit] strict` refuses a headless launch while CRITICAL
|
||||
@@ -710,6 +717,3 @@ The dejima wordmark and logo are not code and are not covered by that licence.
|
||||
They are trademarks; who holds them and what may be done with them is stated
|
||||
once for the organisation, in [ATTRIBUTION.md](ATTRIBUTION.md).
|
||||
|
||||
The egress firewall's original shape came from
|
||||
[Anthropic's reference dev container](https://github.com/anthropics/claude-code/tree/main/.devcontainer).
|
||||
`docker/init-firewall.sh` records what it kept and what it did not.
|
||||
|
||||
+597
-25
@@ -645,6 +645,10 @@ unset _tools_wanted _tool
|
||||
|
||||
AUDIT_DIR="$(config_get audit dir)"
|
||||
BASELINE_DIR="$(config_get audit baselines_dir)"
|
||||
# Read before either guard runs, because what this list declares becomes
|
||||
# something the other two lists are refused for naming - see PP_PROJECT_ROOTS.
|
||||
SANDBOX_RW_PP_MOUNTS="$(config_get mounts read_write_per_project)"
|
||||
|
||||
SANDBOX_EXTRA_MOUNTS="$(config_get mounts readonly)"
|
||||
SANDBOX_EXTRA_MOUNTS="$(_append_mounts "$SANDBOX_EXTRA_MOUNTS" \
|
||||
${EXTRA_RO_FLAGS[@]+"${EXTRA_RO_FLAGS[@]}"})"
|
||||
@@ -745,6 +749,30 @@ _harness_host_config
|
||||
# arrives at ~/shared without going through this list; naming the root that
|
||||
# holds every project's would put the pile back, which is the thing the split
|
||||
# removed.
|
||||
# Refuse $2 if it reaches a per-project root. Its own check rather than a row
|
||||
# in GUARDED_STORES, because the generic refusal there is about approval and
|
||||
# audit stores and says so - an operator who wrote the same path into two
|
||||
# mount lists would be told their specs directory lets a session approve its
|
||||
# own domains, which is both untrue and no help.
|
||||
_refuse_pp_root() {
|
||||
local label="$1" path="$2" real root root_real
|
||||
_pp_project_roots
|
||||
[ ${#PP_PROJECT_ROOTS[@]} -gt 0 ] || return 0
|
||||
real="$(readlink -m "$path")"
|
||||
for root in "${PP_PROJECT_ROOTS[@]}"; do
|
||||
root_real="$(readlink -m "$root")"
|
||||
_paths_overlap "$real" "$root_real" || continue
|
||||
echo "Refusing $label: $path" >&2
|
||||
echo "It reaches $root_real, which holds one directory per project for a" >&2
|
||||
echo "[mounts] read_write_per_project entry." >&2
|
||||
echo "" >&2
|
||||
echo "Handing that over is handing over every project's, which is the pile" >&2
|
||||
echo "slicing it removed. If this path is meant to be shared by every" >&2
|
||||
echo "project, it belongs in one list or the other and not in both." >&2
|
||||
exit 1
|
||||
done
|
||||
}
|
||||
|
||||
GUARDED_STORES=()
|
||||
_guarded_stores() {
|
||||
[ ${#GUARDED_STORES[@]} -eq 0 ] || return 0
|
||||
@@ -753,6 +781,28 @@ _guarded_stores() {
|
||||
"$(share_projects_root "$(config_get core share_dir)")")
|
||||
}
|
||||
|
||||
# The `projects` subdirectory of every read_write_per_project root: the
|
||||
# directory that holds one slice per project, exactly as share_dir's does.
|
||||
#
|
||||
# Guarded separately from the stores above because the two lists need
|
||||
# different answers. For `readonly` and `read_write`, naming one of these is
|
||||
# the mistake the share guard already names: handing over the root that holds
|
||||
# every project's directory puts back the pile the split removed. For the
|
||||
# per-project list itself it is not a mistake at all - it is where that list
|
||||
# puts things - so that loop consults GUARDED_STORES alone.
|
||||
PP_PROJECT_ROOTS=()
|
||||
_pp_project_roots() {
|
||||
[ ${#PP_PROJECT_ROOTS[@]} -eq 0 ] || return 0
|
||||
local path
|
||||
[ -n "$SANDBOX_RW_PP_MOUNTS" ] || return 0
|
||||
IFS=':' read -ra _pp_declared <<< "$SANDBOX_RW_PP_MOUNTS"
|
||||
for path in "${_pp_declared[@]}"; do
|
||||
[ -n "$path" ] || continue
|
||||
PP_PROJECT_ROOTS+=("$(projects_root "$path")")
|
||||
done
|
||||
unset _pp_declared
|
||||
}
|
||||
|
||||
# Refuse $2 if it overlaps any of the paths in $3.., in either direction: an
|
||||
# ancestor of a store exposes it just as surely as the store itself.
|
||||
_refuse_guarded_mount() {
|
||||
@@ -802,6 +852,7 @@ if [ -n "$SANDBOX_RW_MOUNTS" ]; then
|
||||
# epitaph store be mountable from here after it was added to one of
|
||||
# them. A store added to _guarded_stores is now guarded on both
|
||||
# paths without anyone remembering to do it twice.
|
||||
_refuse_pp_root "$(_mount_label "$path" "[mounts] read_write entry" "--extra-read-write path")" "$path"
|
||||
_guarded_stores
|
||||
for guarded in ${GUARDED_STORES[@]+"${GUARDED_STORES[@]}"} \
|
||||
${HARNESS_HOST_CONFIG[@]+"${HARNESS_HOST_CONFIG[@]}"} \
|
||||
@@ -837,6 +888,78 @@ if [ -n "$SANDBOX_RW_MOUNTS" ]; then
|
||||
done
|
||||
fi
|
||||
|
||||
# [mounts] read_write_per_project: the same idea as read_write, sliced the way
|
||||
# [core] share_dir is. An operator declares ~/specs; the container gets ~/specs,
|
||||
# backed by ~/specs/projects/<slug> on the host rather than by the whole thing.
|
||||
#
|
||||
# Which keeps the property that separates these lists from share_dir - a mount
|
||||
# arrives at the host path it names, so a project instruction saying "put specs
|
||||
# in ~/specs" goes on working - while removing the one share_dir was split to
|
||||
# remove: every session seeing every other project's, across projects. A spec
|
||||
# is written to be read as background, which makes that threat-model item 4
|
||||
# with nobody attacking anything.
|
||||
#
|
||||
# Its own key rather than a marker on read_write, and that is what makes it
|
||||
# small: a key that did not previously parse has nothing in it on the day it
|
||||
# lands. Nothing moves, nothing is stranded, and there is no migration. Some
|
||||
# writable mounts are meant to be shared - a cache, a bin directory - and
|
||||
# read_write stays right for those.
|
||||
RW_PP_MOUNT_SOURCES=()
|
||||
RW_PP_MOUNT_TARGETS=()
|
||||
DEJIMA_RW_PP_MOUNTS=""
|
||||
# Every host path this launch makes writable, which for a plain read_write
|
||||
# entry is the path itself and for a per-project one is the slice. The audit
|
||||
# reads this; the briefing reads the two target lists.
|
||||
DEJIMA_RW_SOURCES="$DEJIMA_RW_MOUNTS"
|
||||
if [ -n "$SANDBOX_RW_PP_MOUNTS" ]; then
|
||||
IFS=':' read -ra rw_pp_paths <<< "$SANDBOX_RW_PP_MOUNTS"
|
||||
for path in "${rw_pp_paths[@]}"; do
|
||||
[ -n "$path" ] || continue
|
||||
# The declared root must exist, like a read_write entry: dejima creates
|
||||
# the slice below it, never the root itself, so a typo stays a typo
|
||||
# rather than becoming a new empty directory nobody meant.
|
||||
if [ ! -d "$path" ]; then
|
||||
echo "[mounts] read_write_per_project entry is not an existing directory: $path" >&2
|
||||
echo "dejima creates the per-project directory inside it, but not the" >&2
|
||||
echo "directory itself - so a path that is not there is a typo, not a" >&2
|
||||
echo "layout to be built." >&2
|
||||
exit 1
|
||||
fi
|
||||
pp_real="$(readlink -m "$path")"
|
||||
# GUARDED_STORES alone, deliberately: PP_PROJECT_ROOTS holds this
|
||||
# list's own `projects` directories, which the other two lists are
|
||||
# refused for naming and this one exists to write into.
|
||||
_guarded_stores
|
||||
for guarded in ${GUARDED_STORES[@]+"${GUARDED_STORES[@]}"} \
|
||||
${HARNESS_HOST_CONFIG[@]+"${HARNESS_HOST_CONFIG[@]}"} \
|
||||
"$HOME/.local/bin" "$REPO_ROOT" "$WORKSPACE_DIR"; do
|
||||
guarded_real="$(readlink -m "$guarded")"
|
||||
_paths_overlap "$pp_real" "$guarded_real" || continue
|
||||
echo "Refusing [mounts] read_write_per_project entry: $path" >&2
|
||||
echo "It overlaps $guarded_real, which dejima needs out of a session's reach." >&2
|
||||
echo "Slicing it per project does not change what the session could reach" >&2
|
||||
echo "through it - only how much of it there is." >&2
|
||||
exit 1
|
||||
done
|
||||
# Resolved here and created later, on the launch path. This block runs
|
||||
# before --print-briefing so the briefing it composes names the same
|
||||
# mounts a launch would, and a diagnostic that prints something should
|
||||
# not leave a directory behind - which is why share_dir's own mkdir
|
||||
# sits after that exit too.
|
||||
pp_source="$(project_dir_for "$path" "$WORKSPACE_DIR")"
|
||||
RW_PP_MOUNT_TARGETS+=("$path")
|
||||
RW_PP_MOUNT_SOURCES+=("$pp_source")
|
||||
# Two lists, because the two readers want different answers and for a
|
||||
# per-project mount they differ. The briefing tells the SESSION what it
|
||||
# can write, which is the path inside the container. The exit audit
|
||||
# tells the OPERATOR what on their host this session could reach, and
|
||||
# naming the root there would claim a blast radius this feature exists
|
||||
# to have narrowed.
|
||||
DEJIMA_RW_PP_MOUNTS="${DEJIMA_RW_PP_MOUNTS:+$DEJIMA_RW_PP_MOUNTS }$path"
|
||||
DEJIMA_RW_SOURCES="${DEJIMA_RW_SOURCES:+$DEJIMA_RW_SOURCES }$pp_source"
|
||||
done
|
||||
fi
|
||||
|
||||
# --print-briefing composes and exits, the same way --print-config does -
|
||||
# before anything is built or mounted, and before a workspace argument is
|
||||
# required. It exists so the composed briefing can be inspected (and, per
|
||||
@@ -859,12 +982,12 @@ DEJIMA_WORKFLOW_PUSH="$(manifest_get workflow "$DEJIMA_WORKFLOW_NAME" push)"
|
||||
# A forge declaring hosts_allowlisted_by_core says its hosts are ones core
|
||||
# already allowlists in its own right - the GitHub ranges every session can
|
||||
# read from over 443. Those must NOT reach DEJIMA_FORGE_HOSTS, which
|
||||
# entrypoint.sh folds into EXTRA_ALLOWED_DOMAINS: that path resolves a name
|
||||
# into the port-UNRESTRICTED allowed-domains ipset and exempts it from
|
||||
# dnsmasq's stop-dns-rebind, both of which are deliberate for an internal,
|
||||
# self-hosted forge host and both of which are a widening for a public one.
|
||||
# It would also make init-firewall.sh's startup self-check ("port 22 was
|
||||
# reachable without the push grant") fail, killing the container outright.
|
||||
# firewall-up.sh puts in the open class: that path permits a name on every
|
||||
# port and exempts it from the check that refuses an answer pointing inside a
|
||||
# private network, both of which are deliberate for an internal, self-hosted
|
||||
# forge host and both of which are a widening for a public one. It would also
|
||||
# make the policy's startup self-check ("port 22 was reachable without the
|
||||
# push grant") fail, killing the container outright.
|
||||
#
|
||||
# They still reach DEJIMA_PUSH_HOSTS: git-push-guard.sh's allowlist is a
|
||||
# separate question from which names the firewall resolves, and a push to a
|
||||
@@ -884,6 +1007,12 @@ if [ "$(manifest_get forge "$DEJIMA_FORGE_NAME" hosts_allowlisted_by_core)" = "o
|
||||
else
|
||||
DEJIMA_FORGE_HOSTS="$_forge_all_hosts"
|
||||
fi
|
||||
# Exported as well as passed to the sandbox with `-e NAME=value`. The filter
|
||||
# container is started with `docker run` and takes this the `-e NAME` way, so
|
||||
# it has to be in this process's environment - and that form is what keeps a
|
||||
# value out of a command line, which is the rule the credential beside it in
|
||||
# that list follows.
|
||||
export DEJIMA_FORGE_HOSTS
|
||||
if [ "$DEJIMA_WORKFLOW_PUSH" = "on" ]; then
|
||||
DEJIMA_PUSH_HOSTS="$_forge_all_hosts"
|
||||
else
|
||||
@@ -892,7 +1021,7 @@ fi
|
||||
# --allow-github-push's actual effect: git-push-guard.sh only ever checks
|
||||
# DEJIMA_PUSH_HOSTS, so granting it a github.com push means adding these
|
||||
# hosts to that list, not just exporting ALLOW_GITHUB_PUSH - that variable
|
||||
# reaches only init-firewall.sh's port-22 grant and its startup self-check,
|
||||
# reaches only the egress policy's port-22 grant and its startup self-check,
|
||||
# never the guard. Keyed to the
|
||||
# flag alone, not to ALLOW_CORE_PUSH_GRANT: a forge whose own hosts these
|
||||
# are has already contributed them above, and adding them twice would leave
|
||||
@@ -1027,6 +1156,14 @@ if [ -n "$SANDBOX_SSH_KEY" ] && [ ! -f "$SANDBOX_SSH_KEY" ]; then
|
||||
echo "[core] ssh_key is set but not a file: $SANDBOX_SSH_KEY" >&2
|
||||
exit 1
|
||||
fi
|
||||
# An empty file is not a key: it passes the check above and loads nothing in the
|
||||
# container. Treated as no key at all rather than refused, because the only
|
||||
# launch it costs anything is one that pushes - and that case is Rule 4's
|
||||
# below, which already says what to do about having neither.
|
||||
have_usable_key=1
|
||||
if [ -z "$SANDBOX_SSH_KEY" ] || [ ! -s "$SANDBOX_SSH_KEY" ]; then
|
||||
have_usable_key=0
|
||||
fi
|
||||
if [ -n "$SANDBOX_SSH_KEY" ]; then
|
||||
key_perms=$(stat -c '%a' "$SANDBOX_SSH_KEY")
|
||||
if [ "${key_perms: -2}" != "00" ]; then
|
||||
@@ -1056,7 +1193,10 @@ fi
|
||||
# Rule 4: a workflow that never pushes has no use for either - skip the
|
||||
# requirement entirely rather than force an operator doing local-only work
|
||||
# to set up an agent or a deploy key for nothing.
|
||||
if [ "$DEJIMA_WORKFLOW_PUSH" = "on" ] && [ "$have_forwarded_agent" = 0 ] && [ -z "$SANDBOX_SSH_KEY" ]; then
|
||||
if [ "$DEJIMA_WORKFLOW_PUSH" = "on" ] && [ "$have_forwarded_agent" = 0 ] && [ "$have_usable_key" = 0 ]; then
|
||||
if [ -n "$SANDBOX_SSH_KEY" ]; then
|
||||
echo "[core] ssh_key names an empty file, so there is no key to load: $SANDBOX_SSH_KEY" >&2
|
||||
fi
|
||||
echo "SSH_AUTH_SOCK is not set or not a valid socket, and [core] ssh_key is not set." >&2
|
||||
echo "Either start ssh-agent and run ssh-add, or set [core] ssh_key in $(config_path) to a" >&2
|
||||
echo "dedicated key for headless use - see docs/configuration.md." >&2
|
||||
@@ -1240,7 +1380,13 @@ DEJIMA_STATE_DIR=""
|
||||
# different statement: what the trap must never inherit.
|
||||
SANDBOX_BRIEFING_FILE=""
|
||||
SANDBOX_CREDENTIAL_PROXY_DIR=""
|
||||
SANDBOX_CREDENTIAL_PROXY_LOG=""
|
||||
SANDBOX_FAKE_CREDENTIALS_FILE=""
|
||||
# The same rule, for two names cleanup() hands to `docker rm` rather than to
|
||||
# `rm`. An inherited value there removes a container or a network belonging to
|
||||
# something else on this host, which is a worse thing to get wrong than a file.
|
||||
SANDBOX_FILTER_CONTAINER=""
|
||||
SANDBOX_RUN_NETWORK=""
|
||||
SANDBOX_NVIM_LOCKFILE=""
|
||||
SANDBOX_DERIVED_DOCKERFILE=""
|
||||
SANDBOX_DERIVED_CONTEXT=""
|
||||
@@ -1353,28 +1499,76 @@ cleanup() {
|
||||
# retries turns one condition into several identical lines - three
|
||||
# 429s four seconds apart was the first real instance - and a list of
|
||||
# them says no more than the count does.
|
||||
printf '\n%sThe model API answered with an error this session:%s\n' \
|
||||
"${C_WARN:-}" "${C_RESET:-}" >&2
|
||||
#
|
||||
# match() on the whole line rather than a loop over fields. A loop
|
||||
# needs an index, and an awk index dereferenced inside single quotes
|
||||
# is indistinguishable to the test that every name cleanup() uses is
|
||||
# bound before the EXIT trap can fire - it reads one and reports an
|
||||
# unbound name. That test is right to be strict, so this stays out of
|
||||
# its way rather than asking it to parse quoting.
|
||||
awk '{ if (match($0, /answered [0-9]+/))
|
||||
n[substr($0, RSTART + 9, RLENGTH - 9)]++ }
|
||||
#
|
||||
# Anchored to the whole line the proxy writes - its prefix, then
|
||||
# `upstream answered N for` or `cannot load|refresh credentials:`,
|
||||
# the spellings in bin/dejima-credential-proxy - not to a phrase
|
||||
# appearing anywhere. The log also carries text chosen elsewhere: an
|
||||
# upstream error-body prefix, an exception echoing the session's
|
||||
# request path. Classified on a floating match, a token endpoint
|
||||
# whose error body said `answered 503` fabricated a status count
|
||||
# here, and a session whose only lines were connection failures was
|
||||
# reported as unable to authenticate. Each section prints on its own
|
||||
# evidence, and a session can have earned both.
|
||||
_audit_counts="$(awk '{ if (match($0, /^[0-9:]* dejima-credential-proxy: upstream answered [0-9]+ for /)) {
|
||||
match($0, / answered [0-9]+ for /)
|
||||
n[substr($0, RSTART + 10, RLENGTH - 15)]++ } }
|
||||
END { for (s in n) printf " %d x HTTP %s\n", n[s], s }' \
|
||||
"$SANDBOX_CREDENTIAL_PROXY_LOG" >&2
|
||||
printf ' most recent: %s\n' \
|
||||
"$(tail -n 1 "$SANDBOX_CREDENTIAL_PROXY_LOG" \
|
||||
| sed 's/^[0-9:]* dejima-credential-proxy: //')" >&2
|
||||
# Said because the count above cannot distinguish them: a harness that
|
||||
# retried a 429 or a 5xx may well have got its answer in the end, so
|
||||
# this is not on its own a session that failed.
|
||||
printf ' A retried status may still have succeeded; this is what the API said,\n' >&2
|
||||
printf ' not a verdict on the session.\n' >&2
|
||||
"$SANDBOX_CREDENTIAL_PROXY_LOG")"
|
||||
_audit_api_last="$(awk '{ if (match($0, /^[0-9:]* dejima-credential-proxy: upstream answered [0-9]+ for /))
|
||||
line = $0 }
|
||||
END { print line }' "$SANDBOX_CREDENTIAL_PROXY_LOG" \
|
||||
| sed 's/^[0-9:]* dejima-credential-proxy: //')"
|
||||
# The `cannot ...` line names what failed; the line the proxy writes
|
||||
# right after it is the remedy, and is the one an operator can act on.
|
||||
_audit_cred="$(awk '
|
||||
{ if (match($0, /^[0-9:]* dejima-credential-proxy: cannot (load|refresh) credentials: /)) {
|
||||
cred = $0; want = NR + 1; remedy = "" }
|
||||
else if (NR == want) remedy = $0 }
|
||||
END { if (cred != "") { print cred; if (remedy != "") print remedy } }' \
|
||||
"$SANDBOX_CREDENTIAL_PROXY_LOG" \
|
||||
| sed 's/^[0-9:]* dejima-credential-proxy: //; s/^/ /')"
|
||||
_audit_last="$(tail -n 1 "$SANDBOX_CREDENTIAL_PROXY_LOG" \
|
||||
| sed 's/^[0-9:]* dejima-credential-proxy: //')"
|
||||
if [ -n "$_audit_counts" ]; then
|
||||
printf '\n%sThe model API answered with an error this session:%s\n' \
|
||||
"${C_WARN:-}" "${C_RESET:-}" >&2
|
||||
printf '%s\n' "$_audit_counts" >&2
|
||||
printf ' most recent: %s\n' "$_audit_api_last" >&2
|
||||
# Said because the count above cannot distinguish them: a harness
|
||||
# that retried a 429 or a 5xx may well have got its answer in the
|
||||
# end, so this is not on its own a session that failed.
|
||||
printf ' A retried status may still have succeeded; this is what the API said,\n' >&2
|
||||
printf ' not a verdict on the session.\n' >&2
|
||||
fi
|
||||
if [ -n "$_audit_cred" ]; then
|
||||
# The proxy logged a failure the API never answered - it could not
|
||||
# load or refresh the credential, and no request was made. Saying
|
||||
# "the model API answered with an error" there sends an operator to
|
||||
# check the provider's availability, and the retry caveat above it
|
||||
# invites them to hope it succeeded anyway. Neither is true.
|
||||
printf '\n%sThe session could not authenticate to the model API:%s\n' \
|
||||
"${C_WARN:-}" "${C_RESET:-}" >&2
|
||||
printf '%s\n' "$_audit_cred" >&2
|
||||
fi
|
||||
if [ -z "$_audit_counts" ] && [ -z "$_audit_cred" ]; then
|
||||
# Something else: a request that never reached the upstream, a
|
||||
# body the proxy refused. Neither an API answer nor a credential
|
||||
# failure, so this claims neither and shows the line instead.
|
||||
printf '\n%sThe credential proxy reported a problem this session:%s\n' \
|
||||
"${C_WARN:-}" "${C_RESET:-}" >&2
|
||||
printf ' %s\n' "$_audit_last" >&2
|
||||
fi
|
||||
unset _audit_counts _audit_last _audit_cred _audit_api_last
|
||||
fi
|
||||
rm -f "$SANDBOX_SETTINGS_FILE" "$SANDBOX_CLAUDE_JSON_FILE" "${SANDBOX_NVIM_LOCKFILE:-}" "${SANDBOX_FAKE_CREDENTIALS_FILE:-}" "${SANDBOX_DERIVED_DOCKERFILE:-}" "${SANDBOX_BRIEFING_FILE:-}" || true
|
||||
rm -f "$SANDBOX_SETTINGS_FILE" "$SANDBOX_CLAUDE_JSON_FILE" "${SANDBOX_NVIM_LOCKFILE:-}" "${SANDBOX_FAKE_CREDENTIALS_FILE:-}" "${SANDBOX_DERIVED_DOCKERFILE:-}" "${SANDBOX_BRIEFING_FILE:-}" "${SANDBOX_CREDENTIAL_PROXY_LOG:-}" || true
|
||||
rm -rf "${SANDBOX_GITCONFIG_DIR:-}" "${SANDBOX_CREDENTIAL_PROXY_DIR:-}" "${SANDBOX_DERIVED_CONTEXT:-}" || true
|
||||
rm -f "${DEJIMA_SESSION_FILE:-}" || true
|
||||
# dejima-notify-watch keys its debounce stamps by run id. Removed here,
|
||||
@@ -1399,6 +1593,21 @@ cleanup() {
|
||||
# dejima-notify-watch's marker consumption.
|
||||
rm -rf "${DEJIMA_NOTIFY_DIR:-}" \
|
||||
|| echo "warning: could not remove the marker directory ${DEJIMA_NOTIFY_DIR:-}" >&2
|
||||
# This launch's egress control, and the network under it. The sandbox has
|
||||
# already exited by the time the trap runs, so nothing is left holding the
|
||||
# namespace - Docker permits removing an owner from under a live joiner
|
||||
# anyway, which is why the order here is deliberate rather than incidental.
|
||||
#
|
||||
# `|| true` on both, like every other removal in this trap: a Docker daemon
|
||||
# that has gone away must not replace the session's exit status. What that
|
||||
# leaves behind is inert - a filter container filters a namespace nothing
|
||||
# is in - and a later launch reconciles it.
|
||||
if [ -n "${SANDBOX_FILTER_CONTAINER:-}" ]; then
|
||||
docker rm -f "$SANDBOX_FILTER_CONTAINER" >/dev/null 2>&1 || true
|
||||
fi
|
||||
if [ -n "${SANDBOX_RUN_NETWORK:-}" ]; then
|
||||
docker network rm "$SANDBOX_RUN_NETWORK" >/dev/null 2>&1 || true
|
||||
fi
|
||||
exit_step "session ended - keeping what it compacted away"
|
||||
# Keep whatever the session compacted away, into a store nothing mounts.
|
||||
# `|| true` for the same reason every other line in this trap has it: a
|
||||
@@ -1431,7 +1640,11 @@ cleanup() {
|
||||
# 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.
|
||||
exit_step "checking what changed on the host while it ran"
|
||||
DEJIMA_RW_MOUNTS="$DEJIMA_RW_MOUNTS" "$SCRIPT_DIR/dejima-audit" compare "$WORKSPACE_DIR" "$SANDBOX_AUDIT_STATE" || true
|
||||
# DEJIMA_RW_SOURCES, not DEJIMA_RW_MOUNTS: this report names what on the
|
||||
# HOST the session could have written, and for a per-project mount that is
|
||||
# the slice rather than the root it sits in. Naming the root would claim a
|
||||
# blast radius the per-project split exists to have narrowed.
|
||||
DEJIMA_RW_MOUNTS="$DEJIMA_RW_SOURCES" "$SCRIPT_DIR/dejima-audit" compare "$WORKSPACE_DIR" "$SANDBOX_AUDIT_STATE" || true
|
||||
rm -f "$SANDBOX_AUDIT_STATE" || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
@@ -1651,6 +1864,13 @@ export SANDBOX_GITCONFIG_DIR
|
||||
# forwarded either - left empty here, docker-compose.yml's
|
||||
# ${SSH_AUTH_SOCK:-/dev/null} mounts nothing.
|
||||
[ "$DEJIMA_WORKFLOW_PUSH" = "on" ] || SSH_AUTH_SOCK=""
|
||||
# And neither does a launch that configured its own key. entrypoint.sh loads
|
||||
# that key into an agent of its own and points SSH_AUTH_SOCK at it, so the
|
||||
# forwarded one was already unused - but it stayed mounted at the path
|
||||
# docker-compose.yml names, where `SSH_AUTH_SOCK=/ssh-agent ssh ...` signs with
|
||||
# the operator's personal identities. Choosing a dedicated key has to mean the
|
||||
# personal agent is not in the container, not that it is merely not the default.
|
||||
[ "$have_usable_key" = 0 ] || SSH_AUTH_SOCK=""
|
||||
export SSH_AUTH_SOCK
|
||||
export SANDBOX_SSH_KEY_FILE="$SANDBOX_SSH_KEY"
|
||||
SSH_KNOWN_HOSTS="$(config_get core known_hosts)"; export SSH_KNOWN_HOSTS
|
||||
@@ -1859,6 +2079,9 @@ _suggest_backends() { # <harness-section-name>
|
||||
# declare still stops the launch.
|
||||
DEJIMA_BACKEND_NAME=""
|
||||
SANDBOX_BACKEND_PROXIED_DOMAIN=""
|
||||
# The host of a backend the operator serves themselves, when the selected one
|
||||
# has a base_url. Empty for every backend dejima proxies.
|
||||
DEJIMA_BACKEND_HOST=""
|
||||
DEJIMA_BACKEND_CREDENTIAL_KIND=""
|
||||
DEJIMA_BACKEND_CREDENTIAL_FILE=""
|
||||
DEJIMA_BACKEND_CREDENTIAL_FIELD=""
|
||||
@@ -2031,6 +2254,29 @@ if [ -n "$DEJIMA_BACKEND_NAME" ]; then
|
||||
if config_type "backend.$DEJIMA_BACKEND_NAME.credential_field" >/dev/null 2>&1; then
|
||||
DEJIMA_BACKEND_CREDENTIAL_FIELD="$(config_get "backend.$DEJIMA_BACKEND_NAME" credential_field)"
|
||||
fi
|
||||
# A backend the operator serves rather than one dejima stands in front of:
|
||||
# where it is comes from their own config, and the sandbox has to be able
|
||||
# to reach it or the session has no model at all.
|
||||
#
|
||||
# Handed to the firewall path the way the forge's hosts and Loki's are.
|
||||
# Without this the endpoint is reachable only if the operator ALSO approves
|
||||
# its name as a domain - which works, and then looks like any other
|
||||
# approved public name, so a self-hosted endpoint on their own network gets
|
||||
# the answer check meant for names nobody reviewed. dejima configured this
|
||||
# address; there is nothing for an answer to decide about it.
|
||||
#
|
||||
# Only for a backend dejima does NOT stand in front of, which today is the
|
||||
# same set as "has a base_url" and is guarded rather than assumed. A
|
||||
# backend with both would be proxied AND have its host permitted and
|
||||
# exempted: that re-opens the DNS bypass leaving a proxied name
|
||||
# unresolvable exists to close, and hands a public SaaS name the exemption
|
||||
# fixed-domains.sh withholds from it on purpose. No manifest declares both,
|
||||
# and this is what keeps that from being a coincidence.
|
||||
if [ -z "$SANDBOX_BACKEND_PROXIED_DOMAIN" ] \
|
||||
&& config_type "backend.$DEJIMA_BACKEND_NAME.base_url" >/dev/null 2>&1; then
|
||||
DEJIMA_BACKEND_HOST="$(config_get "backend.$DEJIMA_BACKEND_NAME" base_url \
|
||||
| sed -E 's#^[a-zA-Z]+://##; s#[:/].*##')"
|
||||
fi
|
||||
|
||||
fi
|
||||
# Everything staged for the container, checked before anything is put back
|
||||
@@ -2088,7 +2334,7 @@ SANDBOX_HARNESS_BRIEFING_PATH="$(manifest_get harness "$DEJIMA_HARNESS_NAME" bri
|
||||
export SANDBOX_HARNESS_COMMAND SANDBOX_HARNESS_REAL SANDBOX_HARNESS_ARGS \
|
||||
SANDBOX_HARNESS_SETTINGS SANDBOX_HARNESS_DOMAINS \
|
||||
SANDBOX_BACKEND_PROXIED_DOMAIN SANDBOX_HARNESS_BRIEFING_ARGS \
|
||||
SANDBOX_HARNESS_BRIEFING_PATH
|
||||
SANDBOX_HARNESS_BRIEFING_PATH DEJIMA_BACKEND_HOST
|
||||
|
||||
# [core] command defaults to the selected harness's own command rather than
|
||||
# to a literal in core's schema.
|
||||
@@ -2101,6 +2347,86 @@ export SANDBOX_CMD
|
||||
ENABLE_FIREWALL="$(config_get_bool core firewall)"; export ENABLE_FIREWALL
|
||||
LOKI_URL="$(config_get logging loki_url)"; export LOKI_URL
|
||||
|
||||
# Approved names that genuinely resolve inside the operator's own network.
|
||||
#
|
||||
# The egress policy refuses an answer for a permitted name that points at a
|
||||
# private address, because an answer decides what the allowlist contains and an
|
||||
# answer comes from outside - so one influenced reply for a public name would
|
||||
# otherwise be egress to an internal host. The configured forge and Loki are
|
||||
# exempt without being listed here: dejima resolves their addresses itself, so
|
||||
# there is nothing for an answer to decide. An approved domain is not, because
|
||||
# a project's declared domains are reviewed with the project and expected to be
|
||||
# public names.
|
||||
#
|
||||
# This is for the case where that expectation is wrong on purpose - an internal
|
||||
# mirror, a service on the operator's LAN. It exempts and does not grant: a
|
||||
# name here is reachable only if it is also approved, and firewall-up.sh stops
|
||||
# the launch when one is not, rather than leaving an exemption attached to
|
||||
# nothing. Colon-separated in the config file like every other list;
|
||||
# space-separated from here on, which is what the firewall scripts read.
|
||||
DEJIMA_INTERNAL_DOMAINS="$(config_get core internal_domains | tr ':' ' ')"
|
||||
export DEJIMA_INTERNAL_DOMAINS
|
||||
|
||||
# Every host that becomes a permitted destination has to name at least two
|
||||
# labels, checked here because here is where the operator can act on it.
|
||||
#
|
||||
# The policy refuses a dotless entry anyway, and correctly: an entry becomes a
|
||||
# `server=/<entry>/` line, which dnsmasq matches against the name and
|
||||
# everything under it, so a bare label grants its whole suffix - `org` would
|
||||
# permit every .org name. There is no exact-name form to use instead. Refusing
|
||||
# one here is a floor on how much a single entry can grant, not a judgement
|
||||
# about the name.
|
||||
#
|
||||
# But the policy refuses it at container start, which an operator meets as a
|
||||
# sandbox that will not boot, on a value every check on the way in accepted.
|
||||
# `valid_domain` cannot simply require a dot: it also gates host-side consumers
|
||||
# where a bare label is fine. So the requirement lives with the keys that reach
|
||||
# the firewall, and names the one it found.
|
||||
#
|
||||
# The derived hosts are checked, not the values they came from. A URL yields
|
||||
# its host through a sed, and a slip there - a `base_url` carrying userinfo
|
||||
# reduces to the user name - produces a bare label nobody typed. That is the
|
||||
# case this most wants to catch, because it is the one nobody would look for.
|
||||
_require_fqdn() { # <config key, for the message> <host>
|
||||
[ -n "$2" ] || return 0
|
||||
# A host name with at least two labels. Counting dots alone would pass the
|
||||
# residue of a derivation that went wrong in a way that happens to leave
|
||||
# one: `https://user@host.example/` reduces to `user@host.example`, and
|
||||
# `http://loki./` to `loki.`, neither of which is a host name. Those are the
|
||||
# slips this exists to catch, so it asks the same question of the value that
|
||||
# the policy will ask, rather than a cheaper one.
|
||||
if valid_domain "$2"; then
|
||||
case "$2" in
|
||||
*.*) return 0 ;;
|
||||
esac
|
||||
fi
|
||||
echo "ERROR: $1 gives the host '$2', which this cannot permit." >&2
|
||||
echo "" >&2
|
||||
echo "A permitted destination has to be a host name of at least two" >&2
|
||||
echo "labels. The sandbox's resolver does not forward a dotless query, so" >&2
|
||||
echo "a bare label would not resolve; and a permitted name covers" >&2
|
||||
echo "everything under it, so a bare label would grant its whole suffix." >&2
|
||||
echo "" >&2
|
||||
echo "If the value came from a URL, check it parses to the host you meant:" >&2
|
||||
echo "userinfo and a trailing dot both survive into the derived name." >&2
|
||||
exit 1
|
||||
}
|
||||
for _host in ${DEJIMA_FORGE_HOSTS:-}; do
|
||||
_require_fqdn "the configured forge's host" "$_host"
|
||||
done
|
||||
for _host in ${ALLOWED_DOMAINS:-}; do
|
||||
_require_fqdn "an approved domain" "$_host"
|
||||
done
|
||||
for _host in ${DEJIMA_INTERNAL_DOMAINS:-}; do
|
||||
_require_fqdn "[core] internal_domains" "$_host"
|
||||
done
|
||||
_require_fqdn "[backend.$DEJIMA_BACKEND_NAME] base_url" "${DEJIMA_BACKEND_HOST:-}"
|
||||
if [ -n "${LOKI_URL:-}" ]; then
|
||||
_require_fqdn "[logging] loki_url" \
|
||||
"$(echo "$LOKI_URL" | sed -E 's#^[a-zA-Z]+://##; s#[:/].*##')"
|
||||
fi
|
||||
unset _host
|
||||
|
||||
# ~/.claude/hooks may contain symlinks whose real targets live outside
|
||||
# ~/.claude (this repo's own hooks/, and potentially others - e.g. dotfiles'
|
||||
# config/claude/hooks/ for deny-dangerous-bash.sh, if that's what a given
|
||||
@@ -2258,6 +2584,7 @@ if [ -n "$SANDBOX_EXTRA_MOUNTS" ]; then
|
||||
# [mounts] read_write has been guarded since it was added; this one
|
||||
# never was, so `readonly = ~/.config/dejima` mounted the approval
|
||||
# store into the container and nothing said a word (#15).
|
||||
_refuse_pp_root "$(_mount_label "$path" "[mounts] readonly entry" "--extra-readonly path")" "$path"
|
||||
_guarded_stores
|
||||
_refuse_guarded_mount "$(_mount_label "$path" "[mounts] readonly entry" "--extra-readonly path")" \
|
||||
"$path" "${GUARDED_STORES[@]}"
|
||||
@@ -2280,6 +2607,17 @@ fi
|
||||
for path in ${RW_MOUNT_PATHS[@]+"${RW_MOUNT_PATHS[@]}"}; do
|
||||
extra_mounts+=(-v "$path:$path")
|
||||
done
|
||||
# And the per-project ones, which are the only mounts in dejima whose source
|
||||
# and target differ on purpose: the slice on the host, the declared path in
|
||||
# the container. Indices are paired, filled together in one loop above.
|
||||
for _i in ${RW_PP_MOUNT_TARGETS[@]+"${!RW_PP_MOUNT_TARGETS[@]}"}; do
|
||||
# dejima's own layout below a directory the operator made, so the slice is
|
||||
# created and the root never is. Here rather than where it was resolved,
|
||||
# because this is the launch path and that was also --print-briefing's.
|
||||
mkdir -p "${RW_PP_MOUNT_SOURCES[$_i]}"
|
||||
extra_mounts+=(-v "${RW_PP_MOUNT_SOURCES[$_i]}:${RW_PP_MOUNT_TARGETS[$_i]}")
|
||||
done
|
||||
unset _i
|
||||
|
||||
# The session briefing, composed host-side from the core text and the
|
||||
# selected workflow/harness fragments (see briefing_compose) and mounted
|
||||
@@ -2484,10 +2822,15 @@ if [ -n "$DEJIMA_BACKEND_NAME" ] \
|
||||
&& _credential_usable "$DEJIMA_BACKEND_CREDENTIAL_KIND" "$DEJIMA_BACKEND_CREDENTIAL_FILE" "$DEJIMA_BACKEND_CREDENTIAL_FIELD"; then
|
||||
SANDBOX_CREDENTIAL_PROXY_DIR="$(mktemp -d "${TMPDIR:-/tmp}/dejima-credential-proxy.XXXXXX")"
|
||||
SANDBOX_CREDENTIAL_PROXY_SOCKET="$SANDBOX_CREDENTIAL_PROXY_DIR/proxy.sock"
|
||||
# Outside SANDBOX_CREDENTIAL_PROXY_DIR, which is bind-mounted into the
|
||||
# session below so the socket is reachable. The log holds what the proxy
|
||||
# learned from the provider - statuses, error-body prefixes - and
|
||||
# docs/security.md says that stays on the host, so it must not share the
|
||||
# one proxy path the session can read.
|
||||
SANDBOX_CREDENTIAL_PROXY_LOG="$(mktemp "${TMPDIR:-/tmp}/dejima-credential-proxy-log.XXXXXX")"
|
||||
# Every value comes from backends/<name>/backend.conf. Nothing about a
|
||||
# backend is written here, which is what makes adding one a directory
|
||||
# rather than an edit to this file.
|
||||
SANDBOX_CREDENTIAL_PROXY_LOG="$SANDBOX_CREDENTIAL_PROXY_DIR/proxy.log"
|
||||
_proxy_args=(
|
||||
--socket "$SANDBOX_CREDENTIAL_PROXY_SOCKET"
|
||||
--log "$SANDBOX_CREDENTIAL_PROXY_LOG"
|
||||
@@ -3103,6 +3446,235 @@ if [ -n "$pending_crit" ] && [ ! -t 0 ]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
# The egress control runs in a container of its own, which owns this launch's
|
||||
# network namespace. The sandbox joins that namespace rather than getting one
|
||||
# of its own, so the filter bounding it is installed somewhere it has no
|
||||
# capability to reach - capabilities are per process, and the sandbox declares
|
||||
# none of the network ones.
|
||||
#
|
||||
# Ordering is the point, and it is why this is here rather than inside the
|
||||
# sandbox's own startup. The namespace has its rules before anything is created
|
||||
# in it, so the window where a session holds an interface and no filter does
|
||||
# not exist, rather than being bounded by an argument about how fast an
|
||||
# entrypoint runs.
|
||||
#
|
||||
# The base image, never the per-project derived one: this is the privileged
|
||||
# half of the pair and has no use for a project's declared dependencies.
|
||||
SANDBOX_RUN_NETWORK="dejima-run-$DEJIMA_RUN_ID"
|
||||
SANDBOX_FILTER_CONTAINER="dejima-filter-$DEJIMA_RUN_ID"
|
||||
|
||||
# A launch removes its own pair on the way out, which covers every exit the
|
||||
# trap reaches. It does not cover SIGKILL, a host reboot, or a docker daemon
|
||||
# restart, and each of those leaves a filter container holding a network open.
|
||||
#
|
||||
# The leak is inert - a filter container filters a namespace nothing is in - so
|
||||
# this is hygiene rather than a second control. What makes it worth doing is
|
||||
# the address pool: Docker carves a bounded number of networks out of its
|
||||
# default pool, and exhausting it fails a launch with an error about address
|
||||
# space that says nothing about the cause.
|
||||
#
|
||||
# Never fatal. A daemon that cannot answer any of this is one that is about to
|
||||
# fail the launch a few lines down for a reason worth reporting, and a tidy-up
|
||||
# pass should not be what reports it.
|
||||
|
||||
# How old a filter container or network has to be before an absent session
|
||||
# counts as abandoned rather than as not started yet.
|
||||
#
|
||||
# Between `docker run` for the filter container and `docker compose run` for
|
||||
# the session there is a window in which a healthy pair has no joiner, and it
|
||||
# lasts as long as the firewall takes to come up - seconds, or half a minute
|
||||
# when the GitHub ranges fetch retries. The same window exists for a network
|
||||
# between its creation and its first endpoint. Minutes against seconds, because
|
||||
# reaping a concurrent launch out from under itself costs that launch, while
|
||||
# waiting costs one more pass.
|
||||
DEJIMA_ORPHAN_GRACE=300
|
||||
|
||||
# Seconds since $1, a timestamp as Docker rendered it, or nothing at all if it
|
||||
# cannot be read as one.
|
||||
#
|
||||
# Two renderings arrive here and only one is RFC 3339, because the field is not
|
||||
# the same type on both of the structs this asks about: a container's `Created`
|
||||
# is a string, a network's is a `time.Time`. A Go template prints a `time.Time`
|
||||
# through String(), which appends a timezone NAME - "+0000 UTC" - and date(1)
|
||||
# refuses that outright.
|
||||
#
|
||||
# So the name is stripped rather than the format assumed. Assuming the format
|
||||
# is what broke this: the comment here used to say every caller read RFC 3339,
|
||||
# which was true of one of them, and the network half therefore reaped nothing
|
||||
# at all while looking exactly like the half that worked.
|
||||
#
|
||||
# An unparseable value still yields no age rather than a huge one, so the
|
||||
# failure stays "nothing was reaped" and never becomes "everything was".
|
||||
_epoch_age() {
|
||||
local made stamp="$1"
|
||||
# "2026-09-09 18:00:00.1 +0000 UTC" -> "2026-09-09 18:00:00.1 +0000", and
|
||||
# an RFC 3339 stamp, which has no spaces, through unchanged.
|
||||
case "$stamp" in
|
||||
*' '[A-Z]*) stamp="${stamp% *}" ;;
|
||||
esac
|
||||
made="$(date -d "$stamp" +%s 2>/dev/null)" || return 1
|
||||
[ -n "$made" ] || return 1
|
||||
printf '%s' "$(( $(date +%s) - made ))"
|
||||
}
|
||||
|
||||
reconcile_orphans() {
|
||||
local candidates joined id name created age
|
||||
|
||||
# The candidates first, so a host with no filter containers at all asks one
|
||||
# question and stops. The query below is the expensive one - it inspects
|
||||
# every running container on the host, dejima's or not - and there is no
|
||||
# point paying for it to answer about nothing.
|
||||
candidates="$(docker ps -aq --filter 'label=dejima.filter' 2>/dev/null \
|
||||
| xargs -r docker inspect -f '{{.Id}} {{.Name}} {{.Created}}' 2>/dev/null)"
|
||||
|
||||
if [ -n "$candidates" ]; then
|
||||
# Whose namespace something is still in. Read once: asking per
|
||||
# candidate would be a docker call per candidate, on every launch.
|
||||
#
|
||||
# NetworkMode is `container:<id>` for a joiner - the id, even where the
|
||||
# launcher passed a name, so what is compared below are ids.
|
||||
joined="$(docker ps -q 2>/dev/null | xargs -r docker inspect \
|
||||
-f '{{.HostConfig.NetworkMode}}' 2>/dev/null | sed -n 's/^container://p')"
|
||||
fi
|
||||
|
||||
while read -r id name created; do
|
||||
[ -n "$id" ] && [ -n "$created" ] || continue
|
||||
grep -qxF "$id" <<< "${joined:-}" && continue
|
||||
age="$(_epoch_age "$created")" || continue
|
||||
[ "$age" -gt "$DEJIMA_ORPHAN_GRACE" ] || continue
|
||||
echo "dejima: removing a filter container an earlier session left behind (${name#/})" >&2
|
||||
docker rm -f "$id" >/dev/null 2>&1 || true
|
||||
done <<< "$candidates"
|
||||
|
||||
# Networks defend themselves twice over: `docker network rm` refuses one
|
||||
# that still has an endpoint, and the grace covers the moment between a
|
||||
# network being created and its filter container attaching to it.
|
||||
while read -r name created; do
|
||||
[ -n "$name" ] && [ -n "$created" ] || continue
|
||||
age="$(_epoch_age "$created")" || continue
|
||||
[ "$age" -gt "$DEJIMA_ORPHAN_GRACE" ] || continue
|
||||
docker network rm "$name" >/dev/null 2>&1 || true
|
||||
done < <(docker network ls -q --filter 'label=dejima.run' 2>/dev/null \
|
||||
| xargs -r docker network inspect -f '{{.Name}} {{.Created}}' 2>/dev/null)
|
||||
}
|
||||
reconcile_orphans || true
|
||||
|
||||
# One network per launch, for two reasons, and the first is not optional:
|
||||
# Docker's embedded resolver at 127.0.0.11 exists only on a user-defined
|
||||
# network, and the restricted resolver forwards to it. The second is that
|
||||
# concurrent
|
||||
# sessions are then separated at the link layer as well, rather than only by
|
||||
# each namespace's own egress default-deny.
|
||||
docker network create --label "dejima.run=$DEJIMA_RUN_ID" "$SANDBOX_RUN_NETWORK" >/dev/null
|
||||
|
||||
# Everything the firewall path reads, passed as `-e NAME` so a value is taken
|
||||
# from this process's environment rather than written into a command line every
|
||||
# process on the host can read - GH_TOKEN is in this list.
|
||||
#
|
||||
# Derived from nothing: this IS the declaration, because the filter container
|
||||
# is started with `docker run` and so gets none of the environment
|
||||
# docker-compose.yml declares for the sandbox. A static test reads the same
|
||||
# names back out of the firewall scripts, so a script that starts reading a new
|
||||
# one fails rather than silently allowlisting less than it was told to.
|
||||
# The only two names here this launcher does not derive: they come from the
|
||||
# operator's own environment and travel through untouched. Marked exported
|
||||
# anyway, so that `-e NAME` carries a value rather than an empty string on a
|
||||
# shell where they were set without being exported.
|
||||
export EGRESS_HOST_ADDR EGRESS_HOST_PORTS
|
||||
|
||||
filter_env=(
|
||||
-e ENABLE_FIREWALL # whether to install rules at all
|
||||
-e ALLOWED_DOMAINS # the operator's approved names
|
||||
-e DEJIMA_FORGE_HOSTS # the configured forge's, resolved host-side
|
||||
-e ALLOW_GITHUB_PUSH # whether port 22 to GitHub opens
|
||||
-e GH_TOKEN # authenticates the GitHub ranges fetch
|
||||
-e LOKI_URL # its host is allowlisted, and the shipper needs it
|
||||
-e DEJIMA_INTERNAL_DOMAINS # approved names that really do resolve inside
|
||||
-e DEJIMA_BACKEND_HOST # a self-served model's host, from its base_url
|
||||
# The host entry: which address stands for "this container's host", and
|
||||
# which of its TCP ports open. Both default inside egress-policy and
|
||||
# neither has a config key, so passing them is what makes them reachable
|
||||
# at all - without this the clamp would have no escape hatch an operator
|
||||
# could use.
|
||||
-e EGRESS_HOST_ADDR
|
||||
-e EGRESS_HOST_PORTS
|
||||
-e SANDBOX_HARNESS_DOMAINS
|
||||
-e SANDBOX_BACKEND_PROXIED_DOMAIN
|
||||
-e SANDBOX_CREDENTIAL_PROXY_SOCKET
|
||||
)
|
||||
|
||||
# The capabilities the egress control needs, and the sysctl the namespace it
|
||||
# owns needs. Every one of these used to be in docker-compose.yml, and none of
|
||||
# them belongs to a container running a session:
|
||||
#
|
||||
# NET_ADMIN, NET_RAW the packet filter and its named address sets, which
|
||||
# the resolver also writes into as it answers
|
||||
# KILL the resolver drops to its own user, and dejima-allow
|
||||
# restarts it to add a domain mid-session
|
||||
# CHOWN, DAC_OVERRIDE, FOWNER, SETUID, SETGID
|
||||
# the log files, and the daemons that drop privilege
|
||||
# after opening what they need
|
||||
# SETPCAP spent taking the rest away from log-shipper.sh
|
||||
#
|
||||
# The sysctl lets the credential proxy's TLS terminator bind :443 as the
|
||||
# unprivileged sandbox user. It is a property of the network namespace rather
|
||||
# than of a container, so it is set here, by the container that owns one, and
|
||||
# it applies to the session container that joins it.
|
||||
docker run -d --init \
|
||||
--name "$SANDBOX_FILTER_CONTAINER" \
|
||||
--label "dejima.filter=$DEJIMA_RUN_ID" \
|
||||
--network "$SANDBOX_RUN_NETWORK" \
|
||||
--cap-drop ALL \
|
||||
--cap-add NET_ADMIN --cap-add NET_RAW \
|
||||
--cap-add KILL \
|
||||
--cap-add CHOWN --cap-add DAC_OVERRIDE --cap-add FOWNER \
|
||||
--cap-add SETUID --cap-add SETGID \
|
||||
--cap-add SETPCAP \
|
||||
--security-opt no-new-privileges:true \
|
||||
--sysctl net.ipv4.ip_unprivileged_port_start=0 \
|
||||
${filter_env[@]+"${filter_env[@]}"} \
|
||||
--entrypoint /usr/local/bin/filter-entrypoint.sh \
|
||||
"${SANDBOX_IMAGE_REPO}:${SANDBOX_USER}" >/dev/null
|
||||
|
||||
# Named, not the id `docker run -d` printed. Both work for `container:`, and a
|
||||
# name we chose is one this launcher already knows without reading anything
|
||||
# back - which is also what lets the teardown below name it after a failure
|
||||
# that happened before any output could be captured.
|
||||
export SANDBOX_NETNS_CONTAINER="container:$SANDBOX_FILTER_CONTAINER"
|
||||
|
||||
# Waited for, rather than assumed. The filter container resolves domains and
|
||||
# fetches GitHub's published ranges, which is seconds on a good day and retries
|
||||
# for half a minute on a bad one - so "it will be up by the time compose has
|
||||
# started the sandbox" is a guess, and the thing it guesses about is whether
|
||||
# the session gets a filter.
|
||||
#
|
||||
# Two ways out, and the second is why this is a loop rather than a sleep: the
|
||||
# marker appears, or the container is gone. A firewall that refused to come up
|
||||
# exits the container, and without that check this would wait out the whole
|
||||
# timeout before reporting a failure that had already been decided.
|
||||
echo "dejima: bringing up this session's egress control..." >&2
|
||||
_filter_ready=0
|
||||
for _ in $(seq 1 240); do
|
||||
if docker exec "$SANDBOX_FILTER_CONTAINER" \
|
||||
test -f /run/dejima-firewall-ready 2>/dev/null; then
|
||||
_filter_ready=1
|
||||
break
|
||||
fi
|
||||
if [ "$(docker inspect -f '{{.State.Running}}' "$SANDBOX_FILTER_CONTAINER" 2>/dev/null)" != "true" ]; then
|
||||
break
|
||||
fi
|
||||
sleep 0.5
|
||||
done
|
||||
if [ "$_filter_ready" -ne 1 ]; then
|
||||
echo "" >&2
|
||||
echo "The egress control for this session did not come up, so there is no" >&2
|
||||
echo "filter for the sandbox to run behind. Refusing to start it." >&2
|
||||
echo "" >&2
|
||||
echo "What the filter container said:" >&2
|
||||
docker logs "$SANDBOX_FILTER_CONTAINER" 2>&1 | sed 's/^/ /' >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
compose_status=0
|
||||
# Conversation shipping, host-side: it reads transcripts that are on the host
|
||||
# already, so it never needed to be in the container - and being there put it
|
||||
|
||||
+308
-119
@@ -6,11 +6,12 @@
|
||||
# actually needs, it should tell you the exact command to run here rather
|
||||
# than try to work around the firewall.
|
||||
#
|
||||
# Usage: dejima-allow [--global] [--project [path]] [--container <id>] <domain>
|
||||
# Usage: dejima-allow [--global] [--project [path]] [--container <id>] <domain>...
|
||||
# dejima-allow crates.io # this running session only
|
||||
# dejima-allow --global crates.io # persist for every project
|
||||
# dejima-allow --project crates.io # persist for the project in $PWD
|
||||
# dejima-allow --project ~/myproj crates.io # persist for one named project
|
||||
# dejima-allow dl.example.com files.example.org # several, confirmed first
|
||||
#
|
||||
# --project and --global are approvals for future sessions and deliberately do
|
||||
# not touch a running container: a scope flag means "record this", not "patch
|
||||
@@ -25,32 +26,32 @@ case "${1:-}" in
|
||||
cat <<'DEJIMA_ALLOW_HELP'
|
||||
dejima-allow - let a sandbox reach a domain the egress firewall blocks
|
||||
|
||||
Usage: dejima-allow [--global] [--project [path]] [--container <id>] <domain>
|
||||
Usage: dejima-allow [--global] [--project [path]] [--container <id>] <domain>...
|
||||
Up to twenty. More than one is confirmed first, defaulting to no, which
|
||||
needs a terminal - so a script does them one at a time.
|
||||
|
||||
Scope, which decides who the grant is for and how long it lasts:
|
||||
(none) the running container, right now, until it exits. Finds it
|
||||
if exactly one is running. With several, it offers them to
|
||||
pick from on a terminal, and lists them and stops anywhere
|
||||
else - a prompt is no use to a script.
|
||||
if exactly one is running. With several it offers them to
|
||||
pick from on a terminal, by project and by the number the
|
||||
dejima-attach picker gives each; anywhere else it lists
|
||||
them and stops, a prompt being no use to a script.
|
||||
--container <id> the same, for a named container. Use this when a session
|
||||
told you its own id rather than letting anything guess.
|
||||
With several running, they are listed by project and by
|
||||
the number the dejima-attach picker gives each one. On a
|
||||
terminal it offers them to pick from instead.
|
||||
--project [path] every future session in one project. Also writes the domain
|
||||
to that project's .dejima/allowed-domains, so it is shared
|
||||
with everyone working on it. Path defaults to $PWD.
|
||||
--global every future session in every project on this host.
|
||||
|
||||
They combine. --project and --global deliberately do NOT touch a session that
|
||||
is already running: they are approvals for future ones. Pass --container as
|
||||
well to do both at once.
|
||||
They combine, and --project/--global deliberately do NOT touch a running
|
||||
session: they are approvals for future ones, so pass --container to do both.
|
||||
Prefer --project to --global - a domain one project needs is that project's,
|
||||
and a global grant widens every other project's firewall too.
|
||||
|
||||
Prefer --project to --global. A domain one project needs is that project's, and
|
||||
a global grant widens every other project's firewall too.
|
||||
|
||||
With --container, the domain is verified reachable afterwards and a warning is
|
||||
With --container, each domain is verified reachable afterwards and a warning is
|
||||
printed if it is not, so a typo or a blocked port does not look like success.
|
||||
One that fails stops there naming what landed; a scope flag has them all by
|
||||
then regardless.
|
||||
|
||||
See docs/configuration.md; docs/security.md explains what the allowlist bounds.
|
||||
DEJIMA_ALLOW_HELP
|
||||
@@ -60,10 +61,10 @@ esac
|
||||
SCRIPT_DIR="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
|
||||
# shellcheck source=dejima-project-lib.sh
|
||||
source "$SCRIPT_DIR/dejima-project-lib.sh"
|
||||
# For ipset_for_domain: the live add below writes into a running container's
|
||||
# ipsets, and which set a name belongs in is the container's rule, not this
|
||||
# script's. Sourced rather than restated so the two cannot drift - the same
|
||||
# reason init-firewall.sh and dns-proxy.sh share this file.
|
||||
# For dejima_class_for: the live add below changes a running container's
|
||||
# permitted set, and which class a name belongs to is the container's rule, not
|
||||
# this script's. Sourced rather than restated so the two cannot drift - the
|
||||
# same reason firewall-up.sh reads this file.
|
||||
# shellcheck source=../docker/fixed-domains.sh
|
||||
source "$SCRIPT_DIR/../docker/fixed-domains.sh"
|
||||
# The palette, heading and prompt dejima-attach uses. Picking one session out
|
||||
@@ -73,34 +74,138 @@ source "$SCRIPT_DIR/../docker/fixed-domains.sh"
|
||||
source "$SCRIPT_DIR/dejima-tui-lib.sh"
|
||||
|
||||
usage() {
|
||||
echo "Usage: dejima-allow [--global] [--project [path]] [--container <id>] <domain>" >&2
|
||||
echo "Usage: dejima-allow [--global] [--project [path]] [--container <id>] <domain>..." >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# 1: the domain is the required trailing positional, which is what tells
|
||||
# parse_scope_args whether a token after --project is a path or the domain.
|
||||
# The most domains one command will take. A cap rather than no limit, because
|
||||
# the confirmation below only works if the operator reads the list, and a list
|
||||
# long enough to scroll is one nobody reads to the end of - which is where
|
||||
# something would be hidden. Twenty is enough for the real case (a toolchain
|
||||
# that fetches from several hosts) and short enough to take in at once.
|
||||
readonly DEJIMA_ALLOW_MAX=20
|
||||
|
||||
# 1: at least one trailing positional, which is what tells parse_scope_args
|
||||
# whether a token after --project is a path or a domain. It is a minimum, not
|
||||
# an exact count - several domains may follow.
|
||||
parse_scope_args 1 "$@" || usage
|
||||
[ "${#dj_positional[@]}" -eq 1 ] || usage
|
||||
domain="${dj_positional[0]}"
|
||||
[ "${#dj_positional[@]}" -ge 1 ] || usage
|
||||
domains=("${dj_positional[@]}")
|
||||
global="$dj_global"
|
||||
project=""
|
||||
if [ "$dj_project_set" -eq 1 ]; then
|
||||
# parse_scope_args consumes a token after --project as the path whenever
|
||||
# enough positionals follow it, which with several domains is any of them.
|
||||
# A path that is not a directory and does read as a domain was the first
|
||||
# domain: `dejima-allow --project a.com b.com` means both of those, in the
|
||||
# project in $PWD. A directory wins, so a project whose name happens to
|
||||
# look like a hostname still works.
|
||||
if [ -n "$dj_project" ] && [ ! -d "$dj_project" ] \
|
||||
&& [[ "$dj_project" =~ ^[a-zA-Z0-9]([a-zA-Z0-9-]{0,62}\.)*[a-zA-Z0-9-]{1,63}$ ]]; then
|
||||
domains=("$dj_project" "${domains[@]}")
|
||||
dj_project=""
|
||||
fi
|
||||
project="$(cd "${dj_project:-$PWD}" && pwd)"
|
||||
disclose_project_symlink "$project"
|
||||
fi
|
||||
container="$dj_container"
|
||||
|
||||
# Validated up front, not just passed as a positional arg below: this also
|
||||
# has to be a well-formed hostname to be a valid dnsmasq server=/domain/...
|
||||
# line (e.g. no embedded "/"), on top of ruling out shell metacharacters. A
|
||||
# sandboxed agent can suggest this exact command to the user to copy-paste
|
||||
# (see sandbox-context.sh), so a prompt-injected agent handing back a
|
||||
# malicious "domain" is a real path here, not just a theoretical one.
|
||||
if [[ ! "$domain" =~ ^[a-zA-Z0-9]([a-zA-Z0-9-]{0,62}\.)*[a-zA-Z0-9-]{1,63}$ ]]; then
|
||||
echo "Not a valid domain: $domain" >&2
|
||||
# Repeats dropped, order kept. A list with the same name twice asks the
|
||||
# operator to approve it twice and makes the count they are reading wrong.
|
||||
_seen=""
|
||||
_unique=()
|
||||
for _d in "${domains[@]}"; do
|
||||
case "$_seen" in *"|${_d,,}|"*) continue ;; esac
|
||||
_seen="$_seen|${_d,,}|"
|
||||
_unique+=("$_d")
|
||||
done
|
||||
domains=("${_unique[@]}")
|
||||
|
||||
# What to call this request where it is described as a whole, rather than once
|
||||
# per name. Anything outside the per-domain loops below has no $domain to use,
|
||||
# and under set -u reaching for one is fatal rather than merely wrong - which is
|
||||
# how the container picker's own prompt took this script down.
|
||||
if [ "${#domains[@]}" -eq 1 ]; then
|
||||
domain_label="${domains[0]}"
|
||||
else
|
||||
domain_label="${#domains[@]} domains"
|
||||
fi
|
||||
|
||||
if [ "${#domains[@]}" -gt "$DEJIMA_ALLOW_MAX" ]; then
|
||||
echo "That is ${#domains[@]} domains, and this takes at most $DEJIMA_ALLOW_MAX at once." >&2
|
||||
echo "" >&2
|
||||
echo "The limit is there because the confirmation below is only worth anything if" >&2
|
||||
echo "you read the whole list. Split it up, and read each part." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Every domain validated BEFORE any of them is shown or recorded, and the whole
|
||||
# command refused if any fails. Two reasons beyond the obvious one.
|
||||
#
|
||||
# The list the operator confirms has to be the list that gets added: validating
|
||||
# as we went would ask them to approve names, then add a prefix of them and
|
||||
# fail. And the confirmation rests on the list being readable as printed - this
|
||||
# pattern allows letters, digits, hyphens and dots and nothing else, so no
|
||||
# entry can carry a carriage return or an escape sequence that rewrites the
|
||||
# lines above it on the way past.
|
||||
#
|
||||
# It also has to be a well-formed hostname to be a valid dnsmasq
|
||||
# server=/domain/... line (e.g. no embedded "/"), on top of ruling out shell
|
||||
# metacharacters. A sandboxed agent can suggest this exact command for the
|
||||
# operator to copy-paste (see sandbox-context.sh), so a prompt-injected agent
|
||||
# handing back a malicious "domain" is a real path here, not a theoretical one.
|
||||
#
|
||||
# All of them reported, not the first: an operator fixing a typo in a list of
|
||||
# ten should not have to run it ten times to find the second one.
|
||||
_bad=()
|
||||
for _d in "${domains[@]}"; do
|
||||
[[ "$_d" =~ ^[a-zA-Z0-9]([a-zA-Z0-9-]{0,62}\.)*[a-zA-Z0-9-]{1,63}$ ]] || _bad+=("$_d")
|
||||
done
|
||||
if [ "${#_bad[@]}" -gt 0 ]; then
|
||||
if [ "${#_bad[@]}" -eq 1 ]; then
|
||||
echo "Not a valid domain: ${_bad[0]}" >&2
|
||||
else
|
||||
echo "Not valid domains:" >&2
|
||||
printf ' %s\n' "${_bad[@]}" >&2
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# One domain goes through as it always has. More than one is confirmed first,
|
||||
# because a list is where something unwanted can ride along unread - and the
|
||||
# agent that suggested the command is exactly what might have put it there.
|
||||
#
|
||||
# Default no, and the list on stdout one per line so nothing is off the right
|
||||
# edge of a narrow terminal.
|
||||
#
|
||||
# No terminal means no confirmation is possible, so it refuses rather than
|
||||
# assuming either answer. A script that wants several does them one at a time,
|
||||
# which is the behaviour this had before and still has for one.
|
||||
if [ "${#domains[@]}" -gt 1 ]; then
|
||||
echo "About to allow ${#domains[@]} domains:"
|
||||
printf ' %s\n' "${domains[@]}"
|
||||
echo ""
|
||||
_where=()
|
||||
[ -n "$project" ] && _where+=("every future session in $project")
|
||||
[ "$global" -eq 1 ] && _where+=("every future session in every project")
|
||||
[ -n "$container" ] && _where+=("the running session $container")
|
||||
[ "${#_where[@]}" -gt 0 ] || _where=("the running session")
|
||||
printf 'This grants them to: %s\n' "$(printf '%s; ' "${_where[@]}" | sed 's/; $//')"
|
||||
echo ""
|
||||
if [ ! -t 0 ]; then
|
||||
echo "Several domains at once needs a terminal to confirm on, and this is not one." >&2
|
||||
echo "Run them one at a time, which needs no confirmation." >&2
|
||||
exit 1
|
||||
fi
|
||||
printf 'Add all %s? [y/N] ' "${#domains[@]}"
|
||||
read -r _reply || _reply=""
|
||||
case "$_reply" in
|
||||
y|Y|yes|YES|Yes) ;;
|
||||
*) echo "Nothing was added."; exit 1 ;;
|
||||
esac
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# Recorded first, and unconditionally: --project/--global are the user's
|
||||
# approval decision for future sessions, not a claim about this one. Gating
|
||||
# either on a live container would break the documented workflow outright -
|
||||
@@ -111,6 +216,7 @@ fi
|
||||
# given together, and each persists to its own store.
|
||||
if [ -n "$project" ]; then
|
||||
store="$(ensure_project_store "$project")"
|
||||
for domain in "${domains[@]}"; do
|
||||
approve_entry "$store" allowed-domains "$domain"
|
||||
echo "Added $domain to the approved domains for $project (applies to every future session in it)."
|
||||
# Printed only after the approval above, and guarded rather than left to
|
||||
@@ -124,11 +230,14 @@ if [ -n "$project" ]; then
|
||||
echo "The approval above IS recorded and applies to every future session in it;" >&2
|
||||
echo "only the project's own .dejima declaration could not be written." >&2
|
||||
fi
|
||||
done
|
||||
fi
|
||||
if [ "$global" -eq 1 ]; then
|
||||
store="$(ensure_global_store)"
|
||||
approve_entry "$store" allowed-domains "$domain"
|
||||
echo "Added $domain to the global approved domains (applies to every future session)."
|
||||
for domain in "${domains[@]}"; do
|
||||
approve_entry "$store" allowed-domains "$domain"
|
||||
echo "Added $domain to the global approved domains (applies to every future session)."
|
||||
done
|
||||
fi
|
||||
|
||||
# A scope flag means "record this approval", not "patch what is running now" -
|
||||
@@ -225,7 +334,7 @@ pick_container() {
|
||||
"$C_LINK" "$CT_W_NAME" "${CT_NAMES[$i]}" "$C_OFF" \
|
||||
"$C_MUTED" "${CT_ATTACH[$i]}" "$C_OFF"
|
||||
done
|
||||
tui_pick "${#CT_IDS[@]}" "allow $domain in"
|
||||
tui_pick "${#CT_IDS[@]}" "allow $domain_label in"
|
||||
container="${CT_NAMES[$((TUI_CHOICE - 1))]}"
|
||||
}
|
||||
|
||||
@@ -268,92 +377,172 @@ if [ "$session_wanted" -eq 1 ]; then
|
||||
[ -n "$container" ] || container="${containers%%$'\t'*}"
|
||||
fi
|
||||
|
||||
# The DNS proxy (dns-proxy.sh) NXDOMAINs anything not in its allowlist, so
|
||||
# this has to happen *before* resolving the domain below - otherwise the dig
|
||||
# right after this would itself get blocked. Append a server= line to
|
||||
# dnsmasq's servers-file (idempotent - skip if already present from an
|
||||
# earlier call) and SIGHUP it to reload just that file, no restart needed.
|
||||
# $domain is passed as a positional arg to the inner script (`$1`, inside a
|
||||
# single-quoted heredoc) rather than interpolated into the script text - it's
|
||||
# validated above, but this keeps it that way even if the validation regex
|
||||
# above is ever loosened later.
|
||||
docker exec -u root "$container" bash -c '
|
||||
domain="$1"
|
||||
entry="server=/${domain}/127.0.0.11"
|
||||
grep -qxF "$entry" /etc/dnsmasq-sandbox-servers.conf 2>/dev/null || echo "$entry" >> /etc/dnsmasq-sandbox-servers.conf
|
||||
kill -HUP "$(cat /var/run/dnsmasq-sandbox.pid)"
|
||||
' -- "$domain"
|
||||
|
||||
echo "Resolving $domain inside $container..."
|
||||
ips=$(docker exec "$container" dig +short A "$domain" | grep -E '^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$' || true)
|
||||
if [ -z "$ips" ]; then
|
||||
# An internally-routed name can't be added to a *running* session at
|
||||
# all: dnsmasq's stop-dns-rebind drops a private answer for any domain
|
||||
# not exempted when the container started, and dns-proxy.sh writes
|
||||
# those rebind-domain-ok= lines only at startup - the servers-file this
|
||||
# script SIGHUPs carries server= lines and nothing else. Asking the
|
||||
# upstream resolver directly separates that dead end from a name that
|
||||
# simply doesn't resolve, so the message can name the fix that works.
|
||||
upstream=$(docker exec "$container" dig +short A "$domain" @127.0.0.11 2>/dev/null |
|
||||
grep -E '^(10\.|127\.|169\.254\.|192\.168\.|172\.(1[6-9]|2[0-9]|3[01])\.)' || true)
|
||||
if [ -n "$upstream" ]; then
|
||||
echo "$domain resolves to a private address (${upstream//$'\n'/ }), which the" >&2
|
||||
echo "container's DNS rebind protection drops for any domain not allowlisted" >&2
|
||||
echo "when it started." >&2
|
||||
echo "" >&2
|
||||
if [ -n "$project" ] || [ "$global" -eq 1 ]; then
|
||||
echo " (the approval recorded above already does this)" >&2
|
||||
else
|
||||
echo " dejima-allow --global $domain" >&2
|
||||
fi
|
||||
echo "" >&2
|
||||
echo "Relaunch the session to pick it up - a running one cannot be patched." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Failed to resolve $domain inside the container." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# allowed-domains carries no port restriction, so a GitHub address added
|
||||
# there is reachable on 22 as well - which is the read-only guarantee gone,
|
||||
# from a command whose whole output says it granted one domain.
|
||||
target_set=$(ipset_for_domain "$domain")
|
||||
for ip in $ips; do
|
||||
echo "Allowing $ip ($domain -> $target_set)"
|
||||
docker exec -u root "$container" ipset add "$target_set" "$ip" -exist
|
||||
done
|
||||
|
||||
echo "$domain allowed for the running session ($container)."
|
||||
|
||||
# TCP-level truth check from inside the very container that was just
|
||||
# updated - this script's job is the firewall, so "TCP connect succeeded"
|
||||
# is the claim to verify. curl exit 6 (resolve failed), 7 (connect
|
||||
# refused/blocked) and 28 (connect or overall timeout) mean still
|
||||
# unreachable; anything else (0, or a TLS/HTTP-layer code like 35/52) means
|
||||
# the connection itself went through the firewall fine. --max-time bounds
|
||||
# the whole request, not just the connect phase - without it, a server that
|
||||
# accepts the TCP connection and then stalls (never sends TLS/HTTP bytes)
|
||||
# would hang this script indefinitely; curl maps that case to the same
|
||||
# exit 28 the case below already handles, so no new branch is needed. This
|
||||
# is a report on this run, not a gate on either scope's approval: both
|
||||
# --project and --global are already recorded above, deliberately - a
|
||||
# declared host may legitimately not answer on 443 (an internal service,
|
||||
# an SSH-only host, an apex that only serves a subdomain), and the natural
|
||||
# moment to add a global approval is precisely when nothing is running to
|
||||
# probe in the first place. The exit 2 below still means what it says:
|
||||
# the live patch to this session did not verify.
|
||||
echo "Verifying $domain is reachable from $container..."
|
||||
rc=0
|
||||
docker exec "$container" curl -sI --connect-timeout 5 --max-time 10 "https://$domain/" >/dev/null 2>&1 || rc=$?
|
||||
case "$rc" in
|
||||
6|7|28)
|
||||
echo "WARNING: $domain is still NOT reachable from $container (curl exit $rc)." >&2
|
||||
echo "If the service doesn't listen on 443 this check can fail spuriously;" >&2
|
||||
echo "otherwise the allow did not take effect - re-check the container ID." >&2
|
||||
exit 2
|
||||
# The resolver and the packet filter are not in the session's container.
|
||||
# They are in the filter container that owns its network namespace, which
|
||||
# is what keeps a session from reaching either. So everything that CHANGES the
|
||||
# allowlist below runs there, and only the reachability check at the end
|
||||
# runs in the session's own container - because that is the container whose
|
||||
# reachability this command claims to have changed.
|
||||
#
|
||||
# Both are derived from whichever one the caller named, rather than
|
||||
# recorded anywhere: Docker already knows the pairing, because joining a
|
||||
# namespace is how the pair is built.
|
||||
#
|
||||
# Naming either half has to work, and not as a convenience. A session that
|
||||
# is asked for its own container id reads /etc/hostname, and Docker names a
|
||||
# container that JOINED another's network namespace after the container it
|
||||
# joined - so the id in a briefing, the one an agent hands its operator, is
|
||||
# the filter container's. Nothing about the pair is worth making an
|
||||
# operator hold in their head to type one command.
|
||||
mode="$(docker inspect -f '{{.HostConfig.NetworkMode}}' "$container" 2>/dev/null)" || mode=""
|
||||
case "$mode" in
|
||||
container:*)
|
||||
# A session container: it names its own filter.
|
||||
filter="${mode#container:}"
|
||||
;;
|
||||
*)
|
||||
echo "Verified: $domain is reachable from $container."
|
||||
# Then it is a filter container, or not part of a pair at all.
|
||||
# `docker exec` takes a name or an id; NetworkMode reports the id,
|
||||
# so the search below compares ids.
|
||||
filter_id="$(docker inspect -f '{{.Id}}' "$container" 2>/dev/null)" || filter_id=""
|
||||
session=""
|
||||
if [ -n "$filter_id" ]; then
|
||||
while IFS=' ' read -r _id _mode; do
|
||||
[ "$_mode" = "container:$filter_id" ] || continue
|
||||
session="$_id"
|
||||
break
|
||||
done < <(docker ps -q 2>/dev/null | xargs -r docker inspect \
|
||||
-f '{{.Id}} {{.HostConfig.NetworkMode}}' 2>/dev/null)
|
||||
fi
|
||||
if [ -z "$session" ]; then
|
||||
echo "$container is neither a running session nor the filter container of" >&2
|
||||
echo "one, so there is no allowlist to add this domain to." >&2
|
||||
echo "" >&2
|
||||
echo "A session's own id is in its startup context, or from 'cat /etc/hostname'" >&2
|
||||
echo "inside it - either half of the pair is accepted here. If the session has" >&2
|
||||
echo "already exited, record the domain for the next one instead:" >&2
|
||||
echo "" >&2
|
||||
echo " dejima-allow --project ${domains[*]}" >&2
|
||||
exit 1
|
||||
fi
|
||||
filter="$container"
|
||||
container="$session"
|
||||
;;
|
||||
esac
|
||||
|
||||
# One command in the filter container does the whole change: it records the
|
||||
# entry, regenerates and restarts the restricted resolver so the name
|
||||
# resolves, waits for the answer, and confirms the address reached the set
|
||||
# its class uses. If any of that fails it puts the permitted set back the
|
||||
# way it was and exits non-zero, so there is no half-added name that
|
||||
# resolves and is then refused.
|
||||
#
|
||||
# The class is this script's to state and the container's to enforce - a
|
||||
# forge name added to the open class would be reachable on every port,
|
||||
# which is the read-only guarantee gone from a command whose whole output
|
||||
# says it granted one domain.
|
||||
#
|
||||
# --internal-ok for a name in [core] internal_domains, and for no other.
|
||||
#
|
||||
# The policy refuses an answer for a permitted name that points inside a
|
||||
# private network, because an answer decides what the allowlist contains
|
||||
# and comes from outside. An approval does not lift that: a project's
|
||||
# declared domains are reviewed with the project and expected to be public,
|
||||
# and exempting a public name is the pivot the check exists to stop. What
|
||||
# lifts it is the operator saying a specific name really is internal.
|
||||
#
|
||||
# Read from the same key a launch reads, so the two cannot disagree. They
|
||||
# did: this passed the exemption for any approval while firewall-up.sh
|
||||
# withheld it, so a live add succeeded and the next launch refused to start
|
||||
# on the same name - with the failed launch as the operator's first hint
|
||||
# that the two commands had different ideas.
|
||||
#
|
||||
# Each domain reaches the container as an argument rather than inside the
|
||||
# command string: they are validated above, and this keeps it that way if
|
||||
# that validation is ever loosened.
|
||||
#
|
||||
# Guarded on the file existing: config_get says so on stderr when it does
|
||||
# not, which is right for a launch and noise for this, where a host with no
|
||||
# configuration at all is an ordinary case and the answer is simply "not
|
||||
# listed". A config that exists and is malformed still reports itself.
|
||||
# One domain at a time, because each is its own change to the running
|
||||
# policy: the class it belongs to, whether the operator declared it
|
||||
# internal, and whether it then answers. A failure stops the loop rather
|
||||
# than carrying on, so what did and did not land is the order printed
|
||||
# above - and the names after it are still recorded under any scope flag
|
||||
# given, which happened before this and is not undone by a live add
|
||||
# failing.
|
||||
for domain in "${domains[@]}"; do
|
||||
internal=0
|
||||
if [ -f "$(config_path)" ]; then
|
||||
# `|| [ -n "$_d" ]`, because config_get prints its value with no
|
||||
# trailing newline - so a single-entry list is one line read never
|
||||
# returns, and the loop body would not run at all.
|
||||
# Case-folded, like the class decision below and dejima_mark_internal:
|
||||
# a live add that misses the exemption the operator configured leaves
|
||||
# the name without it until the next launch, which then refuses to
|
||||
# start on the same mismatch.
|
||||
while IFS= read -r _d || [ -n "$_d" ]; do
|
||||
[ "${_d,,}" = "${domain,,}" ] && internal=1
|
||||
done < <(config_get core internal_domains | tr ':' '\n')
|
||||
fi
|
||||
|
||||
permit_args=(permit "$domain")
|
||||
[ "$(dejima_class_for "$domain")" = forge ] && permit_args+=(--forge)
|
||||
[ "$internal" -eq 1 ] && permit_args+=(--internal-ok)
|
||||
|
||||
echo "Permitting $domain in $filter, this session's egress control..."
|
||||
if ! docker exec -u root "$filter" /usr/local/sbin/egress-policy "${permit_args[@]}"; then
|
||||
echo "" >&2
|
||||
echo "The permitted set was left as it was - the reason is above." >&2
|
||||
if [ "$internal" -eq 0 ]; then
|
||||
echo "" >&2
|
||||
echo "If $domain resolves inside your own network, say so in the config and" >&2
|
||||
echo "relaunch - an answer pointing inside is refused for every name that is" >&2
|
||||
echo "not listed there, whether or not it is approved:" >&2
|
||||
echo "" >&2
|
||||
echo " [core]" >&2
|
||||
echo " internal_domains = $domain" >&2
|
||||
echo "" >&2
|
||||
echo "It still needs an approval to be reachable at all:" >&2
|
||||
echo "" >&2
|
||||
echo " dejima-allow --global $domain" >&2
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "$domain allowed for the running session ($container)."
|
||||
|
||||
# TCP-level truth check from inside the very container that was just
|
||||
# updated - this script's job is the firewall, so "TCP connect succeeded"
|
||||
# is the claim to verify. curl exit 6 (resolve failed), 7 (connect
|
||||
# refused/blocked) and 28 (connect or overall timeout) mean still
|
||||
# unreachable; anything else (0, or a TLS/HTTP-layer code like 35/52) means
|
||||
# the connection itself went through the firewall fine. --max-time bounds
|
||||
# the whole request, not just the connect phase - without it, a server that
|
||||
# accepts the TCP connection and then stalls (never sends TLS/HTTP bytes)
|
||||
# would hang this script indefinitely; curl maps that case to the same
|
||||
# exit 28 the case below already handles, so no new branch is needed. This
|
||||
# is a report on this run, not a gate on either scope's approval: both
|
||||
# --project and --global are already recorded above, deliberately - a
|
||||
# declared host may legitimately not answer on 443 (an internal service,
|
||||
# an SSH-only host, an apex that only serves a subdomain), and the natural
|
||||
# moment to add a global approval is precisely when nothing is running to
|
||||
# probe in the first place. The exit 2 below still means what it says:
|
||||
# the live patch to this session did not verify.
|
||||
echo "Verifying $domain is reachable from $container..."
|
||||
rc=0
|
||||
docker exec "$container" curl -sI --connect-timeout 5 --max-time 10 "https://$domain/" >/dev/null 2>&1 || rc=$?
|
||||
case "$rc" in
|
||||
6|7|28)
|
||||
echo "WARNING: $domain is still NOT reachable from $container (curl exit $rc)." >&2
|
||||
echo "If the service doesn't listen on 443 this check can fail spuriously;" >&2
|
||||
echo "otherwise the allow did not take effect - re-check the container ID." >&2
|
||||
exit 2
|
||||
;;
|
||||
*)
|
||||
echo "Verified: $domain is reachable from $container."
|
||||
;;
|
||||
esac
|
||||
done
|
||||
fi
|
||||
|
||||
+2
-2
@@ -953,8 +953,8 @@ tcb_snapshot() {
|
||||
#
|
||||
# A forge's `fixed_hosts` and a harness's paths are resolved on the
|
||||
# host and handed to the container: fixed_hosts reaches
|
||||
# DEJIMA_FORGE_HOSTS, which entrypoint.sh joins into extra_domains and
|
||||
# init-firewall.sh allowlists. So a line added to a manifest widens the
|
||||
# DEJIMA_FORGE_HOSTS, which firewall-up.sh puts into the permitted set
|
||||
# the egress policy is given. So a line added to a manifest widens the
|
||||
# egress allowlist on the next launch - the boundary that bounds where
|
||||
# a compromised session can send anything.
|
||||
#
|
||||
|
||||
@@ -45,6 +45,7 @@ core.command ?identifier
|
||||
core.identity ?identifier
|
||||
core.tools ?list:identifier
|
||||
core.firewall bool on
|
||||
core.internal_domains ?list:hostname
|
||||
ui.banner bool on
|
||||
core.update_check bool on
|
||||
core.share_dir path ~/.local/share/dejima/shared
|
||||
@@ -53,6 +54,7 @@ core.known_hosts path ~/.ssh/known_hosts
|
||||
github.token_file path ~/.config/gh/sandbox-token
|
||||
mounts.readonly ?list:path
|
||||
mounts.read_write ?list:path
|
||||
mounts.read_write_per_project ?list:path
|
||||
mounts.nvim_config ?path
|
||||
credentials.proxy bool on
|
||||
credentials.transport enum:tls|loopback tls
|
||||
@@ -285,12 +287,11 @@ valid_tarball_spec() {
|
||||
# anything that could split a header must not survive validation.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# A declared domain has to survive being written into a dnsmasq
|
||||
# server=/domain/ line. Here rather than in dejima-project-lib.sh as well, so
|
||||
# there is one definition and no pair of them to drift apart.
|
||||
# A hostname, label by label. The matcher is a trust boundary: what it accepts
|
||||
# becomes a dnsmasq `server=` line and an ipset entry, so it should accept only
|
||||
# what it means to rather than only what it can see a use for.
|
||||
# A hostname, label by label. Here rather than in dejima-project-lib.sh as well,
|
||||
# so there is one definition and no pair of them to drift apart. The matcher is
|
||||
# a trust boundary: what it accepts becomes a dnsmasq `server=` line and an
|
||||
# entry in the packet filter's address sets, so it should accept only what it
|
||||
# means to rather than only what it can see a use for.
|
||||
#
|
||||
# The previous form allowed an EMPTY label - `a..b.com` matched - because its
|
||||
# repeated group could consume a dot having matched no characters before it.
|
||||
@@ -299,9 +300,13 @@ valid_tarball_spec() {
|
||||
# came from a matcher accepting a string the resolver read differently.
|
||||
#
|
||||
# Per label: 1-63 characters, starting and ending alphanumeric, hyphens in
|
||||
# between. That also newly refuses a label with a leading or trailing hyphen,
|
||||
# which is not a hostname either. A single label with no dot stays legal - an
|
||||
# internal name resolved by the host's own resolver is a real case.
|
||||
# between. That also refuses a label with a leading or trailing hyphen, which is
|
||||
# not a hostname either.
|
||||
#
|
||||
# A single label stays legal here, because this also gates host-side consumers
|
||||
# where an internal name resolved by the host's own resolver is a real case. A
|
||||
# domain that becomes a permitted destination is narrower and has its own rule:
|
||||
# valid_permitted_domain in dejima-project-lib.sh, which requires two labels.
|
||||
valid_domain() {
|
||||
local label='[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?'
|
||||
[[ "$1" =~ ^$label(\.$label)*$ ]]
|
||||
|
||||
+103
-12
@@ -52,6 +52,35 @@ SUPPLIED_NAMES = {
|
||||
}
|
||||
|
||||
|
||||
# What an operator has to do when the stored credential can no longer be
|
||||
# refreshed, in one place. Both the startup check and a refresh that the
|
||||
# provider rejects reach this, and an operator who reads one and not the other
|
||||
# would otherwise get the remedy from whichever happened to fire.
|
||||
# Written to be read in a crowded terminal: the action first, in one short
|
||||
# sentence, because this is competing with a wall of harness output and an
|
||||
# operator scanning it should not have to reach the end of a clause to find the
|
||||
# verb. What went wrong is the subordinate half - by the time this is printed,
|
||||
# what to do about it is the only part that changes anything.
|
||||
REAUTH_REMEDY = ("run `claude` on the host to log in again - the stored "
|
||||
"credential expired and cannot be refreshed")
|
||||
|
||||
|
||||
class CredentialNeedsOperator(RuntimeError):
|
||||
"""A credential failure whose remedy is a command, not a diagnosis.
|
||||
|
||||
Carries two texts. The message is written here rather than derived from a
|
||||
provider's response, which is what makes it safe to send to the container
|
||||
as well as the host; `detail` is whatever the provider actually said and
|
||||
stays host-side. The rule being respected is that upstream bytes and
|
||||
exception detail do not cross into the session - not that the session must
|
||||
be told nothing.
|
||||
"""
|
||||
|
||||
def __init__(self, message, detail=None):
|
||||
super().__init__(message)
|
||||
self.detail = detail or message
|
||||
|
||||
|
||||
class StaticCredential:
|
||||
"""A key in a file, sent as-is. No expiry, so nothing to refresh."""
|
||||
|
||||
@@ -130,11 +159,9 @@ class OAuthCredential:
|
||||
expires = self._read()["claudeAiOauth"].get("refreshTokenExpiresAt")
|
||||
if expires is not None and expires / 1000 < time.time():
|
||||
# Concrete, not generic. This class reads Claude Code's own
|
||||
# credential shape, so the remedy is a command the operator can
|
||||
# run rather than an instruction to work out.
|
||||
raise RuntimeError(
|
||||
"the refresh token has expired - run `claude` on the host "
|
||||
"and log in again first")
|
||||
# credential shape, so the remedy is a command the operator can run
|
||||
# rather than an instruction to work out.
|
||||
raise CredentialNeedsOperator(REAUTH_REMEDY)
|
||||
|
||||
def _read(self):
|
||||
with open(self.path) as f:
|
||||
@@ -172,6 +199,19 @@ class OAuthCredential:
|
||||
payload = resp.read()
|
||||
conn.close()
|
||||
if resp.status != 200:
|
||||
# invalid_grant is the provider saying this refresh token is
|
||||
# finished - not a transient failure, and not something waiting
|
||||
# or retrying improves. It is what an overnight session meets in
|
||||
# the morning, and the raw body says `invalid_grant` without
|
||||
# saying that logging in on the host is the way out.
|
||||
#
|
||||
# The body is still logged in full: this only decides which
|
||||
# remedy to name.
|
||||
if b"invalid_grant" in payload:
|
||||
raise CredentialNeedsOperator(
|
||||
REAUTH_REMEDY,
|
||||
detail="token refresh failed: HTTP %d %.200s"
|
||||
% (resp.status, payload))
|
||||
raise RuntimeError("token refresh failed: HTTP %d %.200s"
|
||||
% (resp.status, payload))
|
||||
fresh = json.loads(payload)
|
||||
@@ -360,29 +400,54 @@ class Handler(BaseHTTPRequestHandler):
|
||||
# forwarded upstream is a worse outcome than a rejected one.
|
||||
MAX_BODY_BYTES = 32 * 1024 * 1024
|
||||
|
||||
def _length(self, raw, what):
|
||||
"""A byte count from the wire, or BodyTooLarge.
|
||||
|
||||
`int()` alone accepts a negative, and a negative passes a `> MAX` test
|
||||
and then means "read to EOF" to a buffered reader - so the cap above,
|
||||
whose whole reason is that this runs on the host as the operator, was
|
||||
bypassed by sending `Content-Length: -1`.
|
||||
|
||||
A value that is not a number still raises out of here, deliberately: the
|
||||
suppression around handle_error is meant to be specific to the client
|
||||
going away, and a malformed chunk length is the fault the test for that
|
||||
uses to prove it. Refusing it cleanly here would remove the canary and
|
||||
trade noise for blindness.
|
||||
"""
|
||||
n = int(raw, 16) if what == "chunk size" else int(raw)
|
||||
if n < 0 or n > self.MAX_BODY_BYTES:
|
||||
# The offending value, not the cap. The host log said "refused a
|
||||
# request body: 33554432 bytes" whatever arrived, so a hostile -1
|
||||
# and a genuinely oversized upload read identically - and the
|
||||
# comment on the 413 path promises the log says what came in.
|
||||
raise BodyTooLarge("%s of %d, cap %d"
|
||||
% (what, n, self.MAX_BODY_BYTES))
|
||||
return n
|
||||
|
||||
def _read_body(self):
|
||||
if self.headers.get("Transfer-Encoding", "").lower() == "chunked":
|
||||
chunks = []
|
||||
total = 0
|
||||
while True:
|
||||
size = int(self.rfile.readline().split(b";")[0], 16)
|
||||
size = self._length(self.rfile.readline().split(b";")[0], "chunk size")
|
||||
if size == 0:
|
||||
self.rfile.readline()
|
||||
return b"".join(chunks)
|
||||
total += size
|
||||
if total > self.MAX_BODY_BYTES:
|
||||
raise BodyTooLarge("%d bytes" % self.MAX_BODY_BYTES)
|
||||
raise BodyTooLarge("chunked body of %d or more, cap %d"
|
||||
% (total, self.MAX_BODY_BYTES))
|
||||
chunks.append(self.rfile.read(size))
|
||||
self.rfile.read(2)
|
||||
length = int(self.headers.get("Content-Length", 0))
|
||||
if length > self.MAX_BODY_BYTES:
|
||||
raise BodyTooLarge("%d bytes" % self.MAX_BODY_BYTES)
|
||||
length = self._length(self.headers.get("Content-Length", 0), "Content-Length")
|
||||
return self.rfile.read(length)
|
||||
|
||||
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 docs/security.md.
|
||||
# exception detail; message (sent to the container in the response body)
|
||||
# never should. The one thing that does cross is text written in this
|
||||
# file naming something the operator must do - see CredentialNeedsOperator,
|
||||
# and docs/security.md for why that is not an exception to the rule.
|
||||
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}}
|
||||
@@ -479,10 +544,29 @@ class Handler(BaseHTTPRequestHandler):
|
||||
# 413 rather than a traceback and a 500: the session gets a status
|
||||
# that says what to do about it, and the host log says what came
|
||||
# in. Nothing is forwarded upstream.
|
||||
#
|
||||
# And the connection ends here. The body was refused before it was
|
||||
# read, so its bytes are still queued; left open, the next parse
|
||||
# takes them as a request line and answers a keep-alive client with
|
||||
# garbage 400s instead of one clean refusal.
|
||||
self.close_connection = True
|
||||
return self._fail(413, "credential proxy: request body too large",
|
||||
log_message="refused a request body: %s" % e)
|
||||
try:
|
||||
token = self.server.creds.token()
|
||||
except CredentialNeedsOperator as e:
|
||||
# This one names the remedy in the response as well. The session is
|
||||
# where the failure is met, the host terminal is behind a window the
|
||||
# agent cannot read, and "see host terminal" costs an operator the
|
||||
# trip to find one sentence. Safe to send because the text is this
|
||||
# file's own - see the class.
|
||||
# Two lines, detail first. bin/dejima's exit audit prints the LAST
|
||||
# line of this log when a session ends, so the one an operator reads
|
||||
# without going looking is the one that says what to do. The
|
||||
# provider's own words stay directly above it for anyone who does go
|
||||
# looking.
|
||||
self.log_error("%s", "cannot load credentials: %s" % e.detail)
|
||||
return self._fail(502, "credential proxy: %s" % e, log_message=str(e))
|
||||
except Exception as e:
|
||||
return self._fail(502, "credential proxy: could not load or refresh the "
|
||||
"credential - see host terminal",
|
||||
@@ -499,6 +583,13 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self.server.creds.refresh(stale_token=token)
|
||||
token = self.server.creds.token()
|
||||
conn, resp = self._upstream(token, body)
|
||||
except CredentialNeedsOperator as e:
|
||||
# The refresh on the 401 retry above can reach this, and it is not
|
||||
# an upstream failure - reporting it as one sends whoever reads it
|
||||
# looking at the provider's availability instead of at their own
|
||||
# login. Same remedy as the load path, for the same reason.
|
||||
self.log_error("%s", "cannot refresh credentials: %s" % e.detail)
|
||||
return self._fail(502, "credential proxy: %s" % e, log_message=str(e))
|
||||
except Exception as e:
|
||||
return self._fail(502, "credential proxy: upstream request failed - "
|
||||
"see host terminal",
|
||||
|
||||
+45
-21
@@ -160,17 +160,31 @@ def classify(path, value):
|
||||
return (None, None)
|
||||
|
||||
|
||||
def walk_json(doc, prefix=""):
|
||||
def walk_json(doc, prefix="", parts=()):
|
||||
"""Yields (display path, value, components).
|
||||
|
||||
The display path is what an operator reads and what finding_id hashes, so
|
||||
its spelling is fixed - it is recorded in the decisions file and a change
|
||||
would orphan every standing answer. It is also ambiguous: a dict key of
|
||||
`x[0]` and element 0 of a list `x` produce the same string, and so do a key
|
||||
containing a dot and the traversal a dot denotes.
|
||||
|
||||
The components are the same walk as a tuple, which is not ambiguous. Anything
|
||||
that has to FIND the value again uses those; re-parsing the string deleted
|
||||
the wrong element, and for a dotted key raised KeyError out of the scrub,
|
||||
leaving the file to be copied unscrubbed.
|
||||
"""
|
||||
if isinstance(doc, dict):
|
||||
for k, v in doc.items():
|
||||
yield from walk_json(v, "%s.%s" % (prefix, k) if prefix else str(k))
|
||||
yield from walk_json(v, "%s.%s" % (prefix, k) if prefix else str(k),
|
||||
parts + (("key", k),))
|
||||
elif isinstance(doc, list):
|
||||
# Indexed, so a decision recorded against one element does not silently
|
||||
# cover a different one later.
|
||||
for i, v in enumerate(doc):
|
||||
yield from walk_json(v, "%s[%d]" % (prefix, i))
|
||||
yield from walk_json(v, "%s[%d]" % (prefix, i), parts + (("idx", i),))
|
||||
else:
|
||||
yield (prefix, doc)
|
||||
yield (prefix, doc, parts)
|
||||
|
||||
|
||||
def ini_logical_lines(text):
|
||||
@@ -227,17 +241,28 @@ def walk_ini(text):
|
||||
yield (key, value)
|
||||
|
||||
|
||||
def delete_json_path(doc, path):
|
||||
# Mirrors walk_json's construction exactly, including list indices.
|
||||
parts = re.findall(r"[^.\[\]]+|\[\d+\]", path)
|
||||
cur = doc
|
||||
for part in parts[:-1]:
|
||||
cur = cur[int(part[1:-1])] if part.startswith("[") else cur[part]
|
||||
last = parts[-1]
|
||||
if last.startswith("["):
|
||||
del cur[int(last[1:-1])]
|
||||
else:
|
||||
del cur[last]
|
||||
def components_sort_key(parts):
|
||||
"""Orders paths so a list element is deleted after any later sibling.
|
||||
|
||||
`del cur[i]` shifts every later index down, so applying findings in document
|
||||
order deletes the wrong elements from the second onwards: two secrets in one
|
||||
array left the second one in the file and removed an innocent value instead,
|
||||
and the file is then mounted into the container - the operator answers "keep
|
||||
it out" and it goes in. Deepest and highest index first makes each deletion
|
||||
independent of the ones after it.
|
||||
"""
|
||||
return [(1, p[1]) if p[0] == "idx" else (0, p[1]) for p in parts]
|
||||
|
||||
|
||||
def delete_json_components(doc, drop):
|
||||
"""Every component path, in an order under which no deletion invalidates
|
||||
another: deepest and highest index first, so a list deletion cannot shift an
|
||||
element that is still to be removed."""
|
||||
for parts in sorted(drop, key=components_sort_key, reverse=True):
|
||||
cur = doc
|
||||
for kind, item in parts[:-1]:
|
||||
cur = cur[item]
|
||||
del cur[parts[-1][1]]
|
||||
|
||||
|
||||
def drop_ini_paths(text, paths):
|
||||
@@ -311,21 +336,20 @@ def main():
|
||||
print("%s is not readable as JSON" % args.label, file=sys.stderr)
|
||||
return 2
|
||||
else:
|
||||
pairs = list(walk_ini(text))
|
||||
pairs = [(p, v, ()) for p, v in walk_ini(text)]
|
||||
|
||||
if args.scrub:
|
||||
wanted = set(load_list(args.scrub))
|
||||
drop = [path for path, value in pairs
|
||||
drop = [(path, parts) for path, value, parts in pairs
|
||||
if finding_id(args.label, path) in wanted]
|
||||
if not drop:
|
||||
return 0
|
||||
if args.format == "json":
|
||||
doc = json.loads(text)
|
||||
for path in drop:
|
||||
delete_json_path(doc, path)
|
||||
delete_json_components(doc, [parts for _path, parts in drop])
|
||||
out = json.dumps(doc, indent=2) + "\n"
|
||||
else:
|
||||
out = drop_ini_paths(text, drop)
|
||||
out = drop_ini_paths(text, [p for p, _parts in drop])
|
||||
with open(args.file, "w") as f:
|
||||
f.write(out)
|
||||
return 0
|
||||
@@ -350,7 +374,7 @@ def main():
|
||||
return True
|
||||
return False
|
||||
|
||||
for path, value in pairs:
|
||||
for path, value, _parts in pairs:
|
||||
fid = finding_id(args.label, path)
|
||||
if fid in decided or judged_safe("%s\t%s" % (args.label, path)):
|
||||
continue
|
||||
|
||||
@@ -308,10 +308,10 @@ emit_host_exec() {
|
||||
echo "Harness plugin emitted a host-exec leaf or label containing a tab or newline" >&2
|
||||
exit 1 ;;
|
||||
esac
|
||||
[ -n "$leaf" ] && [ -n "$label" ] || {
|
||||
if [ -z "$leaf" ] || [ -z "$label" ]; then
|
||||
echo "Harness plugin emitted a host-exec surface with no leaf or label" >&2
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
DEJIMA_HOST_EXEC+=("$ns"$'\t'"$leaf"$'\t'"$path"$'\t'"$label")
|
||||
}
|
||||
|
||||
|
||||
+4
-4
@@ -162,7 +162,7 @@ case "$action" in
|
||||
echo "dependencies:"
|
||||
report dependencies valid_dep_spec
|
||||
echo "allowed-domains:"
|
||||
report allowed-domains valid_domain
|
||||
report allowed-domains valid_permitted_domain
|
||||
echo "corpus:"
|
||||
report corpus valid_corpus_rule
|
||||
echo "tools:"
|
||||
@@ -170,12 +170,12 @@ case "$action" in
|
||||
;;
|
||||
approve)
|
||||
pending_deps=$(classify dependencies valid_dep_spec | sed -n 's/^pending //p')
|
||||
pending_doms=$(classify allowed-domains valid_domain | sed -n 's/^pending //p')
|
||||
pending_doms=$(classify allowed-domains valid_permitted_domain | sed -n 's/^pending //p')
|
||||
pending_corpus=$(classify corpus valid_corpus_rule | sed -n 's/^pending //p')
|
||||
pending_tools=$(classify tools valid_tool_name | sed -n 's/^pending //p')
|
||||
rejected=$( { classify dependencies valid_dep_spec; classify allowed-domains valid_domain; classify corpus valid_corpus_rule; classify tools valid_tool_name; } | sed -n 's/^rejected //p')
|
||||
rejected=$( { classify dependencies valid_dep_spec; classify allowed-domains valid_permitted_domain; classify corpus valid_corpus_rule; classify tools valid_tool_name; } | sed -n 's/^rejected //p')
|
||||
trunc_deps=$(classify dependencies valid_dep_spec | sed -n 's/^truncated //p')
|
||||
trunc_doms=$(classify allowed-domains valid_domain | sed -n 's/^truncated //p')
|
||||
trunc_doms=$(classify allowed-domains valid_permitted_domain | sed -n 's/^truncated //p')
|
||||
if [ -n "$trunc_deps" ]; then
|
||||
echo "Warning: dependencies declares $trunc_deps entries; only the first $DEJIMA_MAX_DECLARED were considered." >&2
|
||||
fi
|
||||
|
||||
@@ -262,6 +262,27 @@ valid_tool_name() {
|
||||
provider_dir tool "$1" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
# A domain a project may ask to reach, which is narrower than a valid host
|
||||
# name: it has to name at least two labels.
|
||||
#
|
||||
# A permitted name covers everything under it - the resolver is configured per
|
||||
# name and matches the name and its subdomains, with no exact-name form to use
|
||||
# instead - so a bare label grants its whole suffix, and `org` would ask for
|
||||
# every .org name. The floor is what keeps one entry from being that large.
|
||||
#
|
||||
# `valid_domain` stays as it is, because it also gates host-side consumers
|
||||
# where a single label is an ordinary thing to write. This is the rule for
|
||||
# entries that reach the firewall, refused at declaration time so the operator
|
||||
# is told while they are looking at the file rather than by a sandbox that
|
||||
# will not start.
|
||||
valid_permitted_domain() {
|
||||
valid_domain "$1" || return 1
|
||||
case "$1" in
|
||||
*.*) return 0 ;;
|
||||
esac
|
||||
return 1
|
||||
}
|
||||
|
||||
valid_corpus_rule() {
|
||||
case " $DEJIMA_CORPUS_RULES " in
|
||||
*" $1 "*) return 0 ;;
|
||||
@@ -291,6 +312,19 @@ approve_entry() {
|
||||
local store="$1" kind="$2" entry="$3" validator
|
||||
validator="$(_approved_validator "$kind")" || return 1
|
||||
"$validator" "$entry" || { echo "Invalid $kind entry: $entry" >&2; return 1; }
|
||||
# Granting is where the narrower rule belongs, not reading. An approval
|
||||
# already in the store was valid when it was made, and a rule that tightens
|
||||
# afterwards must not turn it into the corruption message below - that
|
||||
# message means tampering, and it would be saying so about the operator's
|
||||
# own file on every launch.
|
||||
if [ "$kind" = allowed-domains ] && ! valid_permitted_domain "$entry"; then
|
||||
echo "Not a domain this can permit: $entry" >&2
|
||||
echo "A permitted destination has to name at least two labels. The" >&2
|
||||
echo "sandbox's resolver does not forward a dotless query, and a bare" >&2
|
||||
echo "label would be permitted as a suffix, covering every name under" >&2
|
||||
echo "it. Use the fully qualified name." >&2
|
||||
return 1
|
||||
fi
|
||||
touch "$store/$kind"
|
||||
grep -qxF "$entry" "$store/$kind" || printf '%s\n' "$entry" >> "$store/$kind"
|
||||
}
|
||||
@@ -481,7 +515,7 @@ _emit_tarball_layers() {
|
||||
# Installs run at BUILD time, where there's no egress firewall - which is
|
||||
# why this is a derived image rather than an install step in entrypoint.sh:
|
||||
# the latter would need apt/npm/PyPI/crates.io reachable as root before
|
||||
# init-firewall.sh locks egress down.
|
||||
# the egress policy locks egress down.
|
||||
generate_derived_dockerfile() {
|
||||
local base="$1" store="$2" entries
|
||||
entries="$(approved_entries "$store" dependencies)" \
|
||||
@@ -574,6 +608,12 @@ generate_spec_dockerfile() {
|
||||
# above left behind - entrypoint.sh must still start as root and drop
|
||||
# via gosu, with no escalation path for the session user.
|
||||
printf 'USER root\n'
|
||||
# The base image swept its own setuid bits and file capabilities, and then
|
||||
# the apt/pip/tarball layers above installed more software on top. Sweep
|
||||
# again here, so a project declaring a dependency that happens to ship a
|
||||
# setuid binary fails its build rather than handing every session on that
|
||||
# project an escalation path the base image was careful not to have.
|
||||
printf 'RUN /usr/local/sbin/strip-privilege-bits\n'
|
||||
printf 'ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]\n'
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -1696,8 +1696,10 @@ What this means:
|
||||
and leave the screenshot in ~/shared.
|
||||
- Outbound network is restricted to an allowlist (Anthropic's API, the npm
|
||||
registry, {{network_allowlist}}, plus anything
|
||||
approved via dejima-allow). Requests to anything else will simply
|
||||
fail to connect. That restriction is not only a limit on your reach: it
|
||||
approved via dejima-allow). Anything else is refused at once rather than
|
||||
left to time out, so a connection that HANGS is not this allowlist - it is
|
||||
something past the sandbox, and from in here the timing is the only thing
|
||||
that tells the two apart. 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
|
||||
@@ -1709,6 +1711,10 @@ What this means:
|
||||
dejima-allow --container {{container_id}} <domain> # just this session
|
||||
dejima-allow --project <path> <domain> # this project, always
|
||||
dejima-allow --global <domain> # every project
|
||||
Several domains can go on one line, up to twenty, and more than one is
|
||||
printed back for the operator to confirm before anything is added. So ask
|
||||
for the whole set a task needs in one command rather than one at a time -
|
||||
but ask only for what it needs, because the list is read.
|
||||
Pick deliberately rather than asking for all three: a domain this project
|
||||
needs is --project, not --global - a global grant widens every other
|
||||
project's firewall too, which is rarely what one project's dependency
|
||||
@@ -2002,6 +2008,16 @@ _briefing_resolve_key() {
|
||||
if [ -n "${DEJIMA_RW_MOUNTS:-}" ]; then
|
||||
printf '\n These host directories are bind-mounted read-write at their own paths and\n outlive the container too: %s.\n ' "$DEJIMA_RW_MOUNTS"
|
||||
fi
|
||||
# A separate sentence, because the first one is not true of these.
|
||||
# A per-project mount arrives at the path it names, like the
|
||||
# others, but what is behind it is this project's own slice of
|
||||
# that directory rather than the directory - so "these host
|
||||
# directories ... at their own paths" would tell a session it can
|
||||
# see something it cannot, and imply the next project's session
|
||||
# will read what it leaves.
|
||||
if [ -n "${DEJIMA_RW_PP_MOUNTS:-}" ]; then
|
||||
printf '\n These are bind-mounted read-write too and also outlive the container, but\n each is THIS project'"'"'s own directory rather than one every project shares -\n the same arrangement as ~/shared: %s.\n ' "$DEJIMA_RW_PP_MOUNTS"
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
echo "internal error: no resolver for whitelisted briefing key: $key" >&2
|
||||
|
||||
+20
-8
@@ -17,16 +17,28 @@
|
||||
# runs inside the container, the other is a separate library).
|
||||
ws_slug() { printf '%s' "$1" | sed 's/[^a-zA-Z0-9]/-/g'; }
|
||||
|
||||
# Every per-project share directory lives under this one subdirectory of
|
||||
# `[core] share_dir` rather than directly in it, which is what makes "left
|
||||
# over from before the split" a decidable question: anything at share_dir's
|
||||
# top level other than this directory predates the layout, because nothing
|
||||
# writes there any more. It is also the directory to mount over sshfs - one
|
||||
# entry per project, with no loose files mixed in.
|
||||
share_projects_root() { printf '%s/projects' "${1%/}"; }
|
||||
# The per-project layout, and there is one of it because there is more than one
|
||||
# caller: `[core] share_dir` slices this way, and so does every
|
||||
# `[mounts] read_write_per_project` entry. A host directory sliced per project
|
||||
# holds one `projects` subdirectory, and one directory per slug inside it.
|
||||
#
|
||||
# Everything lives under that single subdirectory rather than directly in the
|
||||
# root, which is what makes "left over from before, or put there by hand" a
|
||||
# decidable question: anything at the root's top level other than this
|
||||
# directory was not written by dejima. It is also the directory to mount over
|
||||
# sshfs - one entry per project, with no loose files mixed in.
|
||||
projects_root() { printf '%s/projects' "${1%/}"; }
|
||||
|
||||
# The directory a launch against $2 gets from a root sliced this way.
|
||||
project_dir_for() { printf '%s/%s' "$(projects_root "$1")" "$(ws_slug "$2")"; }
|
||||
|
||||
# share_dir's own spelling of the two above. `[core] share_dir` is one
|
||||
# instance of the layout, and these names are what let bin/dejima-share go on
|
||||
# talking about a share rather than about "a per-project root".
|
||||
share_projects_root() { projects_root "$1"; }
|
||||
|
||||
# The host directory mounted at ~/shared for a launch against $2.
|
||||
share_dir_for() { printf '%s/%s' "$(share_projects_root "$1")" "$(ws_slug "$2")"; }
|
||||
share_dir_for() { project_dir_for "$1" "$2"; }
|
||||
|
||||
# The slugs belonging to $1: its own, plus one per live git worktree nested
|
||||
# underneath it. One per line, sorted, no duplicates.
|
||||
|
||||
+2
-2
@@ -5,8 +5,8 @@
|
||||
# systemd/dejima-ttyd.service.
|
||||
# See docs/remote-access.md.
|
||||
#
|
||||
# --interface 127.0.0.1 is a requirement, not a preference. init-firewall.sh
|
||||
# accepts container OUTPUT to the bridge gateway (the host), and attaching
|
||||
# --interface 127.0.0.1 is a requirement, not a preference. The egress policy
|
||||
# permits traffic to the container's own host, and attaching
|
||||
# tmux gives a HOST shell - the prefix key opens a window outside the
|
||||
# container. A listener on any address the container can route to is a
|
||||
# compromised session's escape hatch; loopback is not one of them. Put nginx
|
||||
|
||||
+15
-6
@@ -33,7 +33,7 @@ dejima-update-check - say when this dejima checkout is behind its remote
|
||||
Usage: dejima-update-check
|
||||
|
||||
Compares the checkout bin/dejima runs from against the branch it tracks and
|
||||
prints one line when the remote has commits this checkout does not. Run at
|
||||
prints a notice when the remote has commits this checkout does not. Run at
|
||||
startup by bin/dejima; safe to run by hand.
|
||||
|
||||
Silent when the checkout is current or ahead, when [core] update_check is off,
|
||||
@@ -175,16 +175,25 @@ color_init
|
||||
# fire on the terminal of the operator this is trying to inform.
|
||||
_remote_shown="$(sanitize "$remote")"
|
||||
_branch_shown="$(sanitize "${upstream#refs/heads/}")"
|
||||
# A blank line either side, the indent the other startup warnings carry, and
|
||||
# C_WARN: a launch prints this between its startup lines and the image build,
|
||||
# which starts scrolling immediately after, and two plain lines there are
|
||||
# indistinguishable from either.
|
||||
printf '\n' >&2
|
||||
# The count only when it is known. It is not, whenever the remote's tip is a
|
||||
# commit this checkout has never fetched, which is the common case - and a
|
||||
# number invented for those would be the number of commits it can see, not the
|
||||
# number there are.
|
||||
if [ -n "$count" ] && [ "$count" != "0" ]; then
|
||||
if [ "$count" = "1" ]; then noun="commit"; else noun="commits"; fi
|
||||
printf '%sdejima%s: %s/%s has %s %s this checkout does not.\n' \
|
||||
"$C_KEY" "$C_RESET" "$_remote_shown" "$_branch_shown" "$C_INFO$count$C_RESET" "$noun" >&2
|
||||
printf ' %sdejima: %s/%s has %s %s this checkout does not.%s\n' \
|
||||
"$C_WARN" "$_remote_shown" "$_branch_shown" "$count" "$noun" "$C_RESET" >&2
|
||||
else
|
||||
printf '%sdejima%s: %s/%s has commits this checkout does not.\n' \
|
||||
"$C_KEY" "$C_RESET" "$_remote_shown" "$_branch_shown" >&2
|
||||
printf ' %sdejima: %s/%s has commits this checkout does not.%s\n' \
|
||||
"$C_WARN" "$_remote_shown" "$_branch_shown" "$C_RESET" >&2
|
||||
fi
|
||||
printf ' Update with %sgit -C %s pull%s\n' "$C_KEY" "$REPO_ROOT" "$C_RESET" >&2
|
||||
# install.sh is safe to re-run, and a release can change what it links on PATH,
|
||||
# so pulling is only half the update. Both paths absolute because the operator
|
||||
# reading this is not necessarily standing in this checkout.
|
||||
printf ' Update with %sgit -C %s pull && %s/install.sh%s\n\n' \
|
||||
"$C_KEY" "$REPO_ROOT" "$REPO_ROOT" "$C_RESET" >&2
|
||||
|
||||
+169
-101
@@ -1,9 +1,3 @@
|
||||
# Loosely inspired by the general shape of Anthropic's public reference dev
|
||||
# container (https://github.com/anthropics/claude-code/tree/main/.devcontainer),
|
||||
# substantially extended and reworked for this project's own sandboxing model
|
||||
# (parametrized SANDBOX_USER, DNS proxy + ulogd2 logging, gosu-based privilege
|
||||
# drop, nvim/Playwright/tree-sitter toolchain, gitea-pr helper, etc).
|
||||
#
|
||||
# What belongs in here is what the sandbox itself needs, plus the editor and
|
||||
# browser tooling that has no route through the dependency mechanism. Language
|
||||
# toolchains and per-project utilities do have one - `dejima-dep`, including
|
||||
@@ -12,13 +6,41 @@
|
||||
# below is added for every project, forever, with no review anywhere.
|
||||
FROM node:22
|
||||
|
||||
ARG TZ
|
||||
# Timestamps inside the container match the ones outside it. Validated rather
|
||||
# than trusted: an unknown zone leaves the container silently on UTC, and a log
|
||||
# line an hour out of step with the operator's own is harder to distrust than a
|
||||
# build that refused. /etc/localtime covers everything that reads the system
|
||||
# zone; TZ in the environment covers what consults it directly, Node among them.
|
||||
ARG TZ=Etc/UTC
|
||||
RUN [ -f "/usr/share/zoneinfo/$TZ" ] || { echo "unknown timezone: $TZ" >&2; exit 1; }; \
|
||||
ln -snf "/usr/share/zoneinfo/$TZ" /etc/localtime && \
|
||||
echo "$TZ" > /etc/timezone
|
||||
ENV TZ="$TZ"
|
||||
|
||||
# Install basic development tools, iptables/ipset (for the egress firewall),
|
||||
# openssh-client (for git-over-ssh and agent forwarding), and the toolchain a
|
||||
# typical nvim plugin set (telescope/treesitter/mason) expects, since
|
||||
# SANDBOX_NVIM_CONFIG can point a session's editor at one.
|
||||
# What a session is expected to have without installing anything: git and a
|
||||
# forge client, tools to read and search a tree, an editor, the network tools
|
||||
# the egress policy is written against, and enough of a toolchain to build a
|
||||
# small thing. Sorted, so adding one is a one-line diff rather than a judgement
|
||||
# about where it goes.
|
||||
#
|
||||
# nftables rather than iptables and ipset. One tool covers both packet
|
||||
# filtering and the named address sets the policy needs; one inet table covers
|
||||
# v4 and v6, so an allowlist cannot cover one family and silently ignore the
|
||||
# other; sets take CIDR intervals directly; and a whole ruleset loads as a
|
||||
# single transaction, so the namespace is never left half-filtered. See
|
||||
# docker/egress-policy.
|
||||
#
|
||||
# dnsmasq-base rather than dnsmasq: the binary without the init script, service
|
||||
# user and default configuration, none of which apply - there is no init system
|
||||
# here to run them, and egress-policy generates the configuration per launch.
|
||||
#
|
||||
# No CIDR aggregation package. Collapsing a list of ranges is a few lines of the
|
||||
# Python standard library, which is here anyway, and the packaged option
|
||||
# predates IPv6 - see docker/cidr-collapse.
|
||||
#
|
||||
# bash-completion rather than a shell framework: the session shell is the base
|
||||
# image's bash, configured by this image in /etc/bash.bashrc.d rather than by an
|
||||
# installer fetched at build time. See the shell section below.
|
||||
#
|
||||
# Deliberately NOT installing bubblewrap/socat: they exist to let a harness's
|
||||
# own internal per-command sandboxing run, and that inner sandbox is redundant
|
||||
@@ -28,49 +50,69 @@ ENV TZ="$TZ"
|
||||
# hardening). Whichever harness has one, its plugin's harness_prepare_config
|
||||
# turns it off in the override layer, so there is nothing here to satisfy.
|
||||
# Claude Code's is the "sandbox" block in settings.json.
|
||||
#
|
||||
# libcap2-bin is capsh, which entrypoint.sh uses to hand the session a
|
||||
# container it holds no capabilities in. It was already here as a dependency of
|
||||
# iproute2; a security control resting on another package's dependency graph is
|
||||
# one an unrelated package change removes.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
less \
|
||||
git \
|
||||
openssh-client \
|
||||
procps \
|
||||
fzf \
|
||||
zsh \
|
||||
unzip \
|
||||
gnupg2 \
|
||||
gh \
|
||||
iptables \
|
||||
ipset \
|
||||
iproute2 \
|
||||
dnsutils \
|
||||
aggregate \
|
||||
jq \
|
||||
openssl \
|
||||
dnsmasq \
|
||||
ulogd2 \
|
||||
ulogd2-json \
|
||||
nano \
|
||||
ripgrep \
|
||||
fd-find \
|
||||
bash-completion \
|
||||
bind9-dnsutils \
|
||||
build-essential \
|
||||
dnsmasq-base \
|
||||
fd-find \
|
||||
fzf \
|
||||
gh \
|
||||
git \
|
||||
gnupg2 \
|
||||
iproute2 \
|
||||
jq \
|
||||
less \
|
||||
libcap2-bin \
|
||||
nano \
|
||||
nftables \
|
||||
openssh-client \
|
||||
openssl \
|
||||
procps \
|
||||
python3 \
|
||||
python3-pip \
|
||||
ripgrep \
|
||||
ulogd2 \
|
||||
ulogd2-json \
|
||||
unzip \
|
||||
xz-utils \
|
||||
&& apt-get clean && rm -rf /var/lib/apt/lists/* \
|
||||
&& ln -s "$(command -v fdfind)" /usr/local/bin/fd
|
||||
|
||||
# Set `DEVCONTAINER` environment variable to help with orientation
|
||||
ENV DEVCONTAINER=true
|
||||
# Deliberately NOT set: DEVCONTAINER, or any other variable announcing this as
|
||||
# a generic development container. It is not one - it is a sandbox with an
|
||||
# egress firewall and no path back to root - and tools change behaviour when
|
||||
# they see such a flag, on an assumption about their environment that does not
|
||||
# hold here. What a harness actually needs to know is below, and says the thing
|
||||
# that is true.
|
||||
|
||||
# Lets the harness, and the SessionStart hook it runs, know it is inside this
|
||||
# container rather than directly on the host.
|
||||
ENV DEJIMA=1
|
||||
|
||||
# Dedicated unprivileged user for ulogd2 (see entrypoint.sh) - unlike
|
||||
# dnsmasq, the ulogd2 Debian package doesn't create one of its own. ulogd2
|
||||
# opens its log/output files and the NFLOG netlink socket before dropping to
|
||||
# this user, so it doesn't need to own anything itself.
|
||||
# Dedicated unprivileged user for ulogd2 (see firewall-up.sh) - the ulogd2
|
||||
# Debian package doesn't create one of its own. ulogd2 opens its log/output
|
||||
# files and the NFLOG netlink socket before dropping to this user, so it
|
||||
# doesn't need to own anything itself.
|
||||
RUN useradd -r -s /usr/sbin/nologin ulogd
|
||||
|
||||
# The account the DNS forwarder drops to, and nothing else ever runs as.
|
||||
# Permission to speak to a resolver outside this network namespace is a uid
|
||||
# match in the packet filter (docker/egress-policy), so this account IS that
|
||||
# permission: anything else running as it would inherit the one route out that
|
||||
# the name allowlist exists to close.
|
||||
#
|
||||
# `nobody` would be the obvious choice and is the wrong one. It is where every
|
||||
# unprivileged program lands when it drops privilege without having an account
|
||||
# of its own, so a permission keyed to it is a permission granted to all of
|
||||
# them. Created here rather than by a package, so nothing else can be given it.
|
||||
RUN useradd -r -M -s /usr/sbin/nologin egress-resolver
|
||||
|
||||
# Every fetch from here down retries. What matters is not the odds of one
|
||||
# download failing but of ANY of them failing, and without retries a single
|
||||
# blip discards the whole build.
|
||||
@@ -148,20 +190,29 @@ RUN userdel -r node 2>/dev/null; \
|
||||
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: Neovim unpacks into /usr/local/share/nvim above this
|
||||
# line, 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
|
||||
# Where globally installed Node CLIs go: this image's own later stages, and an
|
||||
# operator's declared npm dependencies at launch. Both have to work as the
|
||||
# sandbox user, which has no sudo and never will, so the prefix has to be a
|
||||
# directory that user owns.
|
||||
#
|
||||
# A directory of its own rather than a subdirectory of a shared one, because
|
||||
# "owned by the sandbox user" and "put here by npm" then describe the same set
|
||||
# of files. Under /usr/local/share they do not: Neovim unpacks into
|
||||
# /usr/local/share/nvim, and one careless -R would hand the session write access
|
||||
# to the editor's own runtime.
|
||||
RUN mkdir -p /opt/node-tools/bin /opt/node-tools/lib/node_modules && \
|
||||
chown -R $SANDBOX_USER:$SANDBOX_USER /opt/node-tools
|
||||
|
||||
# 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
|
||||
# Shell history, on a named volume (docker-compose.yml) so a session started
|
||||
# tomorrow sees what was typed today. Created here so the directory exists with
|
||||
# the right owner and mode even on a run with nothing mounted over it - Docker
|
||||
# seeds a fresh named volume from what is already at the mount path, ownership
|
||||
# included, so this is also what keeps the volume writable by that user rather
|
||||
# than root. The shell that reads it is configured below.
|
||||
RUN mkdir -p /var/opt/shell-history && \
|
||||
touch /var/opt/shell-history/.bash_history && \
|
||||
chown -R $SANDBOX_USER:$SANDBOX_USER /var/opt/shell-history && \
|
||||
chmod 0700 /var/opt/shell-history
|
||||
|
||||
# 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),
|
||||
@@ -187,47 +238,35 @@ RUN mkdir -p $SANDBOX_HOME/.ssh && \
|
||||
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
|
||||
# Everything from here to the `USER root` below runs as the sandbox user, so
|
||||
# that global npm installs land in the prefix that user owns.
|
||||
USER $SANDBOX_USER
|
||||
|
||||
# Install global packages
|
||||
ENV NPM_CONFIG_PREFIX=/usr/local/share/npm-global
|
||||
ENV PATH=$PATH:/usr/local/share/npm-global/bin
|
||||
# A global npm install only puts a package's CLI on PATH, not its module on
|
||||
# Node's require() resolution path - NODE_PATH is what makes `require
|
||||
# ("playwright")` work from any script anywhere, not just the project's own
|
||||
# node_modules (which it likely doesn't have without its own npm install).
|
||||
ENV NODE_PATH=/usr/local/share/npm-global/lib/node_modules
|
||||
ENV NPM_CONFIG_PREFIX=/opt/node-tools
|
||||
# Appended, not prepended. /usr/local/bin holds the harness wrapper, which
|
||||
# shadows the harness's real binary to pin the no-confirm, settings and
|
||||
# briefing flags - and the real binary is npm-installed, so putting this
|
||||
# directory first would let it win and drop every one of those flags silently.
|
||||
ENV PATH=$PATH:/opt/node-tools/bin
|
||||
|
||||
# Set the default shell to zsh rather than sh
|
||||
ENV SHELL=/bin/zsh
|
||||
# The session shell is the base image's own bash. It is what the sandbox user's
|
||||
# account is already set to, and configuring it costs this image one file
|
||||
# instead of a package and an installer downloaded at build time.
|
||||
#
|
||||
# That download is the reason, not the package. A build-time fetch of a script
|
||||
# that is then executed is a supply-chain surface, and this image is a security
|
||||
# boundary: whatever it installs runs in every sandbox on the host, forever,
|
||||
# with no review anywhere. What a prompt framework would have set up is written
|
||||
# out in session-bashrc.sh instead, where it can be read.
|
||||
ENV SHELL=/bin/bash
|
||||
|
||||
# Set the default editor and visual
|
||||
# Anything that spawns an editor - `git commit` with no message, an interactive
|
||||
# rebase - reads these. Unset, git falls back to whatever /usr/bin/editor points
|
||||
# at, and an unattended session that lands in a modal editor it cannot name the
|
||||
# exit key for will sit there until something kills it. nano prints its own.
|
||||
ENV EDITOR=nano
|
||||
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 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 && \
|
||||
rm /tmp/zsh-in-docker.sh
|
||||
ENV PAGER=less
|
||||
|
||||
# No harness here. The image is shared by every project on the host, and
|
||||
# baking one harness into it means every sandbox carries it whether or not it
|
||||
@@ -265,11 +304,15 @@ RUN npm install -g playwright@${PLAYWRIGHT_VERSION} && \
|
||||
# The launched application itself (SANDBOX_CMD) and any editor config for it
|
||||
# are supplied at runtime, not baked into the image - see
|
||||
# docker-compose.yml/entrypoint.sh.
|
||||
COPY docker/session-bashrc.sh /etc/bash.bashrc.d/10-session.bash
|
||||
COPY docker/fixed-domains.sh /usr/local/bin/
|
||||
COPY docker/dns-proxy.sh /usr/local/bin/
|
||||
COPY docker/init-firewall.sh /usr/local/bin/
|
||||
COPY docker/caps-lib.sh /usr/local/bin/
|
||||
COPY docker/egress-policy /usr/local/sbin/egress-policy
|
||||
COPY docker/dns-lookup-log /usr/local/sbin/dns-lookup-log
|
||||
COPY docker/cidr-collapse /usr/local/bin/cidr-collapse
|
||||
COPY docker/firewall-up.sh /usr/local/bin/
|
||||
COPY docker/filter-entrypoint.sh /usr/local/bin/
|
||||
COPY docker/log-shipper.sh /usr/local/bin/
|
||||
COPY docker/ulogd.conf /etc/ulogd-sandbox.conf
|
||||
# Into $VIMRUNTIME/plugin, not /etc/xdg/nvim - see the file's own comment.
|
||||
COPY docker/sandbox-clipboard.lua /usr/local/share/nvim/runtime/plugin/sandbox-clipboard.lua
|
||||
COPY docker/entrypoint.sh /usr/local/bin/
|
||||
@@ -281,6 +324,9 @@ COPY docker/harness-wrapper.sh /usr/local/bin/dejima-harness-wrapper
|
||||
# nothing until something puts them on PATH.
|
||||
COPY docker/git-guard.sh /usr/local/lib/dejima/git-guard.sh
|
||||
COPY docker/forge-guard.sh /usr/local/lib/dejima/forge-guard.sh
|
||||
# The policy both of those apply, and that the two hooks beside them apply to
|
||||
# the same command from the other side. One copy, so a bypass is fixed once.
|
||||
COPY docker/guard-lib.sh /usr/local/lib/dejima/guard-lib.sh
|
||||
# The briefing renderer, which every harness needs and no harness owns. Baked
|
||||
# in rather than reached through the ~/.claude/hooks mount, because that mount
|
||||
# is Claude Code's configuration: sandbox-context.sh calls this, and so does
|
||||
@@ -312,19 +358,41 @@ COPY docker/install-forge-bins.sh /tmp/install-forge-bins.sh
|
||||
RUN bash /tmp/install-forge-bins.sh /tmp/forges /usr/local/lib/dejima/forge /usr/local/bin \
|
||||
&& rm -rf /tmp/forges /tmp/install-forge-bins.sh
|
||||
|
||||
# The other half of NODE_PATH, which only ever applied to require(): ESM
|
||||
# resolution ignores it entirely and walks up from the importing file looking
|
||||
# for node_modules, so `import { chromium } from "playwright"` fails from
|
||||
# every directory a script is likely to be written in while require() of the
|
||||
# same package succeeds. At the root, that walk always ends here.
|
||||
# What makes a globally installed package resolvable from a script in any
|
||||
# directory. Both of Node's resolvers find a bare specifier by walking
|
||||
# node_modules directories upwards from the importing file, and every path
|
||||
# bottoms out at /, so one here is visible everywhere - including from a
|
||||
# directory with no dependency tree of its own.
|
||||
#
|
||||
# It is worth a line because of how the failure reads. An agent writing the
|
||||
# modern form gets ERR_MODULE_NOT_FOUND, concludes the image has no browser,
|
||||
# and describes what a page should look like instead of going and seeing.
|
||||
RUN ln -s /usr/local/share/npm-global/lib/node_modules /node_modules
|
||||
# It is worth a line because of how the failure reads without it. An agent
|
||||
# writing `import { chromium } from "playwright"` gets ERR_MODULE_NOT_FOUND,
|
||||
# concludes the image has no browser, and describes what a page should look
|
||||
# like instead of going and seeing.
|
||||
RUN ln -s /opt/node-tools/lib/node_modules /node_modules
|
||||
|
||||
RUN chmod +x /usr/local/bin/dns-proxy.sh /usr/local/bin/init-firewall.sh /usr/local/bin/log-shipper.sh /usr/local/bin/entrypoint.sh /usr/local/bin/dejima-harness-wrapper /usr/local/lib/dejima/git-guard.sh /usr/local/lib/dejima/forge-guard.sh /usr/local/lib/dejima/dejima-briefing-render.sh /usr/local/lib/dejima/hooks/*.sh && \
|
||||
echo 'export PATH="$PATH:/usr/local/share/npm-global/bin"' > /etc/profile.d/npm-global-path.sh
|
||||
# Source the snippet directory from the system rc file, so every interactive
|
||||
# bash gets it, and from the sandbox user's own ~/.bashrc, so it is applied
|
||||
# after the Debian skeleton rc file rather than being undone by it. The guard on
|
||||
# the append keeps a rebuild on a changed base from adding the loop twice.
|
||||
RUN grep -q 'bash.bashrc.d' /etc/bash.bashrc || \
|
||||
printf '\nfor __rc in /etc/bash.bashrc.d/*.bash; do\n [ -r "$__rc" ] && . "$__rc"\ndone\nunset __rc\n' >> /etc/bash.bashrc; \
|
||||
printf '%s\n' \
|
||||
'# The sandbox shell configuration this image installs. Sourced here as well' \
|
||||
'# as from /etc/bash.bashrc so it is applied last, after the Debian defaults.' \
|
||||
'[ -r /etc/bash.bashrc.d/10-session.bash ] && . /etc/bash.bashrc.d/10-session.bash' \
|
||||
> "$SANDBOX_HOME/.bashrc" && \
|
||||
chown "$SANDBOX_USER:$SANDBOX_USER" "$SANDBOX_HOME/.bashrc"
|
||||
|
||||
RUN chmod +x /usr/local/sbin/egress-policy /usr/local/sbin/dns-lookup-log /usr/local/bin/cidr-collapse /usr/local/bin/firewall-up.sh /usr/local/bin/filter-entrypoint.sh /usr/local/bin/log-shipper.sh /usr/local/bin/entrypoint.sh /usr/local/bin/dejima-harness-wrapper /usr/local/lib/dejima/git-guard.sh /usr/local/lib/dejima/forge-guard.sh /usr/local/lib/dejima/dejima-briefing-render.sh /usr/local/lib/dejima/hooks/*.sh && \
|
||||
echo 'export PATH="$PATH:/opt/node-tools/bin"' > /etc/profile.d/node-tools-path.sh
|
||||
|
||||
# Last instruction that changes the filesystem, and it has to stay last: it
|
||||
# removes every setuid bit, setgid bit and file capability in the image and
|
||||
# fails the build if one survives, which only holds for what already exists
|
||||
# when it runs. A derived per-project layer runs it again at its own end, for
|
||||
# the packages it installs after this point - see bin/dejima-project-lib.sh.
|
||||
COPY docker/strip-privilege-bits.sh /usr/local/sbin/strip-privilege-bits
|
||||
RUN chmod +x /usr/local/sbin/strip-privilege-bits && /usr/local/sbin/strip-privilege-bits
|
||||
|
||||
# Entrypoint runs as root (no USER node here) so it can set up the firewall
|
||||
# directly, with no sudo/setuid escalation path available to node at any
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
# Sourced by every entrypoint in this image. Nothing here runs on its own.
|
||||
#
|
||||
# The startup sequence needs real capabilities - it builds the packet filter,
|
||||
# binds an NFLOG socket, chowns files and drops privilege twice. Nothing after
|
||||
# it does, and a bounding set survives execve: a process that reaches uid 0
|
||||
# anywhere downstream recovers its permitted set from whatever bounding set it
|
||||
# inherited. So an unprivileged session whose effective set is empty still sits
|
||||
# inside a tree holding every capability the container declares.
|
||||
#
|
||||
# `drop_caps` is how anything that outlives a startup script is started, and it
|
||||
# is what makes a declared floor a description of the root phase rather than of
|
||||
# the session's whole life.
|
||||
#
|
||||
# capsh, not `setpriv --bounding-set`, which is the obvious tool and silently
|
||||
# the wrong one: without CAP_SETPCAP it exits 0 having changed nothing, so a
|
||||
# container that lost the capability would run every session with the full set
|
||||
# and report success at every step. capsh refuses out loud, which is the only
|
||||
# behaviour worth having here.
|
||||
#
|
||||
# `-- -c 'exec "$@"'` runs the command through capsh's shell and immediately
|
||||
# replaces it, so nothing is left between the caller and the process.
|
||||
drop_caps() {
|
||||
exec capsh --drop=all "$@"
|
||||
}
|
||||
|
||||
# Checked rather than assumed, because the failure it prevents is invisible:
|
||||
# capsh would abort each drop_caps call, and in an entrypoint the last of those
|
||||
# is the session, so the container would die at the point it looked ready.
|
||||
# Named here instead, against the file the floor actually lives in.
|
||||
#
|
||||
# Called once by each top-level entrypoint rather than from drop_caps: it is a
|
||||
# precondition of the whole script, and the point is to refuse before doing
|
||||
# work rather than partway through it.
|
||||
require_setpcap() {
|
||||
# Decoded first and matched with `[[ ]]`, rather than piped into `grep -q`.
|
||||
# Both callers set `pipefail`, and grep -q leaves as soon as it matches,
|
||||
# which SIGPIPEs the writer and makes the pipeline's status 141 - so the
|
||||
# check reports a missing capability BECAUSE it found one. capsh's output is
|
||||
# one short line and would almost always win that race, which is the worst
|
||||
# place for it: this is the guard that decides whether the container starts.
|
||||
local decoded
|
||||
decoded=$(capsh --decode="$(awk '/^CapEff:/ {print $2}' /proc/self/status)") || decoded=""
|
||||
[[ "$decoded" == *cap_setpcap* ]] && return 0
|
||||
echo "${0##*/}: this container does not hold CAP_SETPCAP, so the capabilities" >&2
|
||||
echo "the startup sequence needs cannot be taken away again before the session" >&2
|
||||
echo "starts - it would run inside a tree holding all of them." >&2
|
||||
echo "Refusing to start rather than running with a floor that is not the one" >&2
|
||||
echo "docker/docker-compose.yml declares. Add SETPCAP to its cap_add." >&2
|
||||
exit 1
|
||||
}
|
||||
Executable
+36
@@ -0,0 +1,36 @@
|
||||
#!/usr/bin/python3
|
||||
"""Collapse CIDRs into the smallest equivalent list.
|
||||
|
||||
Reads networks from the arguments, or from stdin if there are none, separated
|
||||
by whitespace or commas. v4 and v6 may be mixed; each family is collapsed
|
||||
independently and printed v4 first.
|
||||
|
||||
Every entry is validated on the way through, so a malformed range from an
|
||||
upstream document stops the caller rather than being loaded into a live
|
||||
allowlist. egress-policy relies on both halves of that: the collapse keeps the
|
||||
interval sets small, and the validation is the only thing checking what the
|
||||
forge's published document actually contained.
|
||||
"""
|
||||
import ipaddress
|
||||
import re
|
||||
import sys
|
||||
|
||||
|
||||
def main():
|
||||
words = sys.argv[1:] or re.split(r"[\s,]+", sys.stdin.read())
|
||||
families = {4: [], 6: []}
|
||||
for word in words:
|
||||
if not word:
|
||||
continue
|
||||
try:
|
||||
net = ipaddress.ip_network(word, strict=False)
|
||||
except ValueError as exc:
|
||||
sys.exit(f"cidr-collapse: {exc}")
|
||||
families[net.version].append(net)
|
||||
for version in (4, 6):
|
||||
for net in ipaddress.collapse_addresses(families[version]):
|
||||
print(net)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+241
@@ -0,0 +1,241 @@
|
||||
#!/usr/bin/python3
|
||||
"""Turn the resolver's query log into one JSON record per name lookup.
|
||||
|
||||
A lookup that is refused never becomes a connection, so the packet log cannot
|
||||
account for it - and a refused lookup is the interesting event, because the
|
||||
query itself is what carries data out. This follows dnsmasq's own log and emits
|
||||
one record per lookup, with its outcome, next to the connection log.
|
||||
|
||||
dnsmasq has no structured output of its own, so its text log is parsed here
|
||||
rather than in whatever reads the result: the parsing then lives in one place
|
||||
and is tested once, instead of in every supervisor.
|
||||
|
||||
Outcomes:
|
||||
answered the name was permitted and the resolver returned addresses
|
||||
refused not-permitted the name is not in the permitted set
|
||||
non-global-answer permitted, but answered with a private,
|
||||
loopback or link-local address, which the
|
||||
resolver would not hand out
|
||||
no-such-name permitted, upstream says it does not exist
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
|
||||
# "Sep 12 00:45:07 dnsmasq[4553]: " - dropped; the record is stamped when the
|
||||
# line is read, which is accurate to well within the resolution that matters and
|
||||
# does not depend on a log format that carries no year.
|
||||
PREFIX = re.compile(r"^\w{3}\s+\d+\s+[\d:]+\s+\S*dnsmasq\[\d+\]:\s*")
|
||||
|
||||
PATTERNS = {
|
||||
"query": re.compile(r"^query\[(?P<type>[^\]]+)\]\s+(?P<name>\S+)\s+from\s+(?P<client>\S+)"),
|
||||
"not-permitted": re.compile(r"^config\s+(?P<name>\S+)\s+is\s+NXDOMAIN"),
|
||||
"non-global-answer": re.compile(r"^possible DNS-rebind attack detected:\s*(?P<name>\S+)"),
|
||||
"forwarded": re.compile(r"^forwarded\s+(?P<name>\S+)\s+to\s+(?P<server>\S+)"),
|
||||
"reply": re.compile(r"^(?:reply|cached)\s+(?P<name>\S+)\s+is\s+(?P<value>.+?)\s*$"),
|
||||
}
|
||||
|
||||
IDLE_FLUSH = 1.0 # seconds a lookup waits for further lines before it is written
|
||||
|
||||
|
||||
def now():
|
||||
return datetime.now(timezone.utc).isoformat(timespec="milliseconds")
|
||||
|
||||
|
||||
# Outcomes, worst last. A lookup is written with the highest-ranked outcome its
|
||||
# lines reported: a name answered on one query type and refused on another is a
|
||||
# refusal, and the reason a supervisor would want to be woken for outranks the
|
||||
# rest.
|
||||
RANK = {
|
||||
None: 0,
|
||||
# Below "answered" on purpose: a name with an A record and no AAAA is
|
||||
# answered and reports NODATA for the other query, and that is the ordinary
|
||||
# case, not a failure.
|
||||
("refused", "no-such-name"): 1,
|
||||
("answered", None): 2,
|
||||
("refused", "not-permitted"): 3,
|
||||
("refused", "non-global-answer"): 4,
|
||||
}
|
||||
|
||||
|
||||
class Lookups:
|
||||
"""Open lookups, keyed by name, flushed when they stop changing.
|
||||
|
||||
One lookup produces several log lines - query, forwarded, one reply per
|
||||
address - and a resolver library asking for A and AAAA emits two queries for
|
||||
the same name before either is answered, which is what a C library's stub
|
||||
does by default. So a second query for a name already open joins it rather
|
||||
than closing it: closing would write the first out as unanswered while its
|
||||
reply was still in flight, and a routine event that reads as a failure is
|
||||
worse in an alerting log than no event at all.
|
||||
|
||||
Keying by name and type instead would be the obvious alternative and does
|
||||
not work: the lines that report a refusal carry no query type, so there is
|
||||
nothing to attribute them to. Name is the only key every line has.
|
||||
"""
|
||||
|
||||
def __init__(self, out):
|
||||
self.out = out
|
||||
self.open = {}
|
||||
|
||||
def blank(self, name):
|
||||
return {
|
||||
"ts": now(),
|
||||
"name": name,
|
||||
"types": [],
|
||||
"queries": 0,
|
||||
"client": None,
|
||||
"outcome": None,
|
||||
"reason": None,
|
||||
"addresses": [],
|
||||
"upstream": None,
|
||||
"rank": 0,
|
||||
"touched": time.monotonic(),
|
||||
}
|
||||
|
||||
def start(self, name, fields):
|
||||
entry = self.open.setdefault(name, self.blank(name))
|
||||
if fields.get("type") and fields["type"] not in entry["types"]:
|
||||
entry["types"].append(fields["type"])
|
||||
entry["client"] = entry["client"] or fields.get("client")
|
||||
# How many query lines this record stands for. Merging by name means a
|
||||
# name asked for twice in the same second is one record; the count keeps
|
||||
# that from reading as one lookup.
|
||||
entry["queries"] += 1
|
||||
entry["touched"] = time.monotonic()
|
||||
|
||||
def get(self, name):
|
||||
entry = self.open.setdefault(name, self.blank(name))
|
||||
entry["touched"] = time.monotonic()
|
||||
return entry
|
||||
|
||||
def outcome(self, entry, outcome, reason=None):
|
||||
rank = RANK[(outcome, reason)]
|
||||
if rank >= entry["rank"]:
|
||||
entry["rank"] = rank
|
||||
entry["outcome"] = outcome
|
||||
entry["reason"] = reason
|
||||
|
||||
def flush_name(self, name):
|
||||
entry = self.open.pop(name, None)
|
||||
if entry is None:
|
||||
return
|
||||
entry.pop("touched", None)
|
||||
entry.pop("rank", None)
|
||||
if entry["outcome"] is None:
|
||||
# A query whose outcome never appeared: the upstream did not answer
|
||||
# in time, or dnsmasq logged something this does not recognise.
|
||||
entry["outcome"] = "unanswered"
|
||||
for field in ("types", "addresses", "upstream", "reason"):
|
||||
if not entry[field]:
|
||||
entry.pop(field)
|
||||
self.out.write(json.dumps(entry) + "\n")
|
||||
self.out.flush()
|
||||
|
||||
def flush_idle(self, force=False):
|
||||
cutoff = time.monotonic() - IDLE_FLUSH
|
||||
for name in [n for n, e in self.open.items() if force or e["touched"] < cutoff]:
|
||||
self.flush_name(name)
|
||||
|
||||
|
||||
def handle(line, lookups):
|
||||
line = PREFIX.sub("", line.rstrip("\n"))
|
||||
|
||||
match = PATTERNS["query"].match(line)
|
||||
if match:
|
||||
lookups.start(match.group("name"), match.groupdict())
|
||||
return
|
||||
|
||||
for reason in ("not-permitted", "non-global-answer"):
|
||||
match = PATTERNS[reason].match(line)
|
||||
if match:
|
||||
entry = lookups.get(match.group("name"))
|
||||
lookups.outcome(entry, "refused", reason)
|
||||
# A name nobody was allowed to ask for is noise; a permitted name
|
||||
# answered with an address pointing inside the operator's own
|
||||
# network is someone trying to steer the policy, and is marked so a
|
||||
# supervisor can alert on it without parsing reasons.
|
||||
if reason == "non-global-answer":
|
||||
entry["severity"] = "alert"
|
||||
# Not written out here: the client's other query for the same name
|
||||
# may still be in flight, and these lines arrive once per query.
|
||||
return
|
||||
|
||||
match = PATTERNS["forwarded"].match(line)
|
||||
if match:
|
||||
lookups.get(match.group("name"))["upstream"] = match.group("server")
|
||||
return
|
||||
|
||||
match = PATTERNS["reply"].match(line)
|
||||
if match:
|
||||
entry = lookups.get(match.group("name"))
|
||||
value = match.group("value")
|
||||
if value in ("NXDOMAIN", "NODATA") or value.startswith("NODATA"):
|
||||
lookups.outcome(entry, "refused", "no-such-name")
|
||||
else:
|
||||
lookups.outcome(entry, "answered")
|
||||
if value not in entry["addresses"]:
|
||||
entry["addresses"].append(value)
|
||||
return
|
||||
|
||||
|
||||
def follow(path, lookups):
|
||||
"""Read the log as it is appended to, across restarts of the writer.
|
||||
|
||||
The resolver is restarted when a name is added to a running session; it
|
||||
reopens the same file in append mode, so following the path rather than the
|
||||
handle keeps working. A file that shrank was truncated by something else and
|
||||
is read again from the start.
|
||||
"""
|
||||
handle_ = None
|
||||
position = 0
|
||||
last_flush = 0.0
|
||||
while True:
|
||||
try:
|
||||
if handle_ is None:
|
||||
handle_ = open(path, "r", errors="replace")
|
||||
handle_.seek(position)
|
||||
if os.path.getsize(path) < position:
|
||||
position = 0
|
||||
handle_.seek(0)
|
||||
line = handle_.readline()
|
||||
if line:
|
||||
position = handle_.tell()
|
||||
try:
|
||||
handle(line, lookups)
|
||||
except Exception as exc: # one bad line must not end the log
|
||||
print(f"dns-lookup-log: {exc}", file=sys.stderr, flush=True)
|
||||
# Busy periods must not starve the flush: a steady stream of
|
||||
# queries would otherwise hold every record open indefinitely.
|
||||
if time.monotonic() - last_flush > IDLE_FLUSH:
|
||||
lookups.flush_idle()
|
||||
last_flush = time.monotonic()
|
||||
continue
|
||||
except FileNotFoundError:
|
||||
handle_ = None
|
||||
lookups.flush_idle()
|
||||
last_flush = time.monotonic()
|
||||
time.sleep(0.2)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--source", required=True, help="the resolver's query log")
|
||||
parser.add_argument("--output", required=True, help="where to write the JSON records")
|
||||
args = parser.parse_args()
|
||||
|
||||
with open(args.output, "a", buffering=1) as out:
|
||||
lookups = Lookups(out)
|
||||
try:
|
||||
follow(args.source, lookups)
|
||||
except KeyboardInterrupt:
|
||||
lookups.flush_idle(force=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,144 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Restricts DNS resolution itself to an allowlist, rather than only
|
||||
# filtering the IP connections made afterward: without this, Docker's
|
||||
# embedded resolver at 127.0.0.11 will still resolve *any* domain even once
|
||||
# the firewall is locked down (verified: `dig` on a non-allowlisted domain
|
||||
# resolves fine, only the subsequent connect gets blocked), so a compromised
|
||||
# session could exfiltrate data by encoding it into query names to an
|
||||
# attacker-controlled domain. Runs a local dnsmasq bound to 127.0.0.1 with
|
||||
# no fallback resolver, forwarding only allowlisted names to Docker's real
|
||||
# resolver; everything else gets no upstream configured at all, so it fails
|
||||
# outright instead of leaking the query. Must run before init-firewall.sh so
|
||||
# that script's own dig calls (and everything afterward) go through it.
|
||||
set -Eeuo pipefail
|
||||
# Same reasoning as init-firewall.sh's: this runs before the firewall in the
|
||||
# container's startup, and a silent `set -e` abort here is indistinguishable
|
||||
# from the sandbox never starting for any other reason (#59).
|
||||
trap 'status=$?; echo "ERROR: dns-proxy.sh aborted at line $LINENO (exit $status): $BASH_COMMAND" >&2' ERR
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck source=fixed-domains.sh
|
||||
source "$SCRIPT_DIR/fixed-domains.sh"
|
||||
|
||||
CONF_FILE=/etc/dnsmasq-sandbox.conf
|
||||
SERVERS_FILE=/etc/dnsmasq-sandbox-servers.conf
|
||||
LOG_FILE=/var/log/dnsmasq.log
|
||||
|
||||
# Every allowlisted name below is forwarded to Docker's embedded resolver at
|
||||
# 127.0.0.11, which only exists for containers on a user-defined network
|
||||
# (compose creates one for the real sandbox). On the default bridge the
|
||||
# container gets the host's own nameservers instead, dnsmasq has nothing to
|
||||
# forward to, and the readiness probe at the bottom fails ~24s later with a
|
||||
# generic timeout that says nothing about why. Falling back to whatever
|
||||
# resolver is in resolv.conf isn't an option: init-firewall.sh deliberately
|
||||
# has no egress rule for UDP/53 to an arbitrary destination (see its comment
|
||||
# on keeping DNS on loopback), so that would just move the failure. Fail
|
||||
# here instead, naming the actual cause.
|
||||
if ! grep -qE '^nameserver[[:space:]]+127\.0\.0\.11[[:space:]]*$' /etc/resolv.conf; then
|
||||
echo "ERROR: Docker's embedded resolver (127.0.0.11) is not this container's" >&2
|
||||
echo "nameserver, so the DNS allowlist has no upstream to forward to. Attach" >&2
|
||||
echo "the container to a user-defined docker network (compose does this by" >&2
|
||||
echo "default); the default bridge network does not provide the resolver." >&2
|
||||
echo "Current /etc/resolv.conf nameservers:" >&2
|
||||
grep -E '^nameserver' /etc/resolv.conf >&2 || echo " (none)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
touch "$LOG_FILE"
|
||||
chown dnsmasq:dnsmasq "$LOG_FILE" 2>/dev/null || true
|
||||
|
||||
# server=/domain/upstream lines live in their own file (rather than the main
|
||||
# conf) since dnsmasq only re-reads a --servers-file on SIGHUP - lets
|
||||
# bin/dejima-allow add a domain to a running session by appending a
|
||||
# line here and signaling the container's dnsmasq, no restart needed.
|
||||
{
|
||||
for domain in "${FIXED_DOMAINS[@]}" "${GITHUB_DNS_SUFFIXES[@]}" ${EXTRA_ALLOWED_DOMAINS:-}; do
|
||||
# Literal IPs (e.g. a monitoring host given as an IP, not a hostname)
|
||||
# don't need a DNS entry - there's nothing to resolve.
|
||||
if [[ "$domain" =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]; then
|
||||
continue
|
||||
fi
|
||||
echo "server=/$domain/127.0.0.11"
|
||||
done
|
||||
} > "$SERVERS_FILE"
|
||||
|
||||
{
|
||||
cat <<EOF
|
||||
no-resolv
|
||||
no-hosts
|
||||
bind-interfaces
|
||||
# IPv6 is blocked outright at the firewall (see init-firewall.sh's
|
||||
# ip6tables policies) - strip AAAA answers too so nothing wastes a
|
||||
# connection attempt on an address that can never actually be reached.
|
||||
filter-AAAA
|
||||
# Reject (and log) any answer for FIXED_DOMAINS/GITHUB_DNS_SUFFIXES that
|
||||
# resolves to a private RFC1918/loopback/link-local address. Without this,
|
||||
# a hijacked upstream answer for one of those public domains could resolve
|
||||
# to an internal address, and since allowed-domains is matched with no
|
||||
# port restriction (see init-firewall.sh), the top-up below would then
|
||||
# hand out full TCP-any-port egress to that internal host - turning one
|
||||
# attacker-influenced public domain into a general internal-network pivot.
|
||||
# Exempted per-domain below for EXTRA_ALLOWED_DOMAINS, which are expected
|
||||
# to legitimately be internal hosts (a self-hosted Gitea/Loki instance).
|
||||
stop-dns-rebind
|
||||
listen-address=127.0.0.1
|
||||
port=53
|
||||
servers-file=$SERVERS_FILE
|
||||
log-queries
|
||||
log-facility=$LOG_FILE
|
||||
user=dnsmasq
|
||||
pid-file=/var/run/dnsmasq-sandbox.pid
|
||||
EOF
|
||||
|
||||
# EXTRA_ALLOWED_DOMAINS (the configured forge's hosts and LOKI_URL's, plus
|
||||
# the approved domains the launcher passes in ALLOWED_DOMAINS) are
|
||||
# expected to potentially be internal/self-hosted addresses, unlike
|
||||
# FIXED_DOMAINS/GITHUB_DNS_SUFFIXES above, which are always public SaaS
|
||||
# domains - exempt just these from stop-dns-rebind so a legitimately
|
||||
# internal forge or Loki host still resolves.
|
||||
for domain in ${EXTRA_ALLOWED_DOMAINS:-}; do
|
||||
if [[ "$domain" =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]; then
|
||||
continue
|
||||
fi
|
||||
echo "rebind-domain-ok=/$domain/"
|
||||
done
|
||||
|
||||
# init-firewall.sh's ipset population is a one-time snapshot at
|
||||
# container start; without ongoing tracking, a domain can start failing
|
||||
# again when its CDN edge rotates to an IP that was never added
|
||||
# (pypi.org/files.pythonhosted.org/GitHub's release-assets CDN all do
|
||||
# this). A single wildcard keeps allowed-domains topped up for every
|
||||
# name dnsmasq ever answers: only allowlisted names have an upstream at
|
||||
# all (no-resolv + servers-file), so "gets answered" and "is
|
||||
# allowlisted" are the same set - including domains added mid-session
|
||||
# by bin/dejima-allow (ipset= can't live in the SIGHUP-reloadable
|
||||
# servers-file).
|
||||
# The explicit github-domains lines below still win for GitHub names -
|
||||
# dnsmasq applies the longest matching ipset= domain - which is what
|
||||
# keeps GitHub IPs out of this port-unrestricted set (github-domains
|
||||
# is restricted to 443 by init-firewall.sh; that's the read-only
|
||||
# guarantee).
|
||||
echo "ipset=/#/allowed-domains"
|
||||
for domain in "${GITHUB_DNS_SUFFIXES[@]}"; do
|
||||
echo "ipset=/$domain/github-domains"
|
||||
done
|
||||
} > "$CONF_FILE"
|
||||
|
||||
dnsmasq --conf-file="$CONF_FILE"
|
||||
|
||||
# dnsmasq daemonizes and returns almost immediately, but give it a moment to
|
||||
# actually be answering before repointing resolv.conf at it.
|
||||
ready=0
|
||||
for _ in $(seq 1 20); do
|
||||
if dig +time=1 +tries=1 @127.0.0.1 api.anthropic.com >/dev/null 2>&1; then
|
||||
ready=1
|
||||
break
|
||||
fi
|
||||
sleep 0.2
|
||||
done
|
||||
if [ "$ready" -ne 1 ]; then
|
||||
echo "ERROR: dnsmasq did not come up in time" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "nameserver 127.0.0.1" > /etc/resolv.conf
|
||||
+83
-33
@@ -48,43 +48,87 @@ services:
|
||||
# ten per run - becomes a permanent zombie, and a long session eventually
|
||||
# exhausts the cgroup pids limit and can no longer fork at all.
|
||||
init: true
|
||||
# An explicit floor rather than Docker's default set plus two. The default
|
||||
# carries about fourteen capabilities and this container uses seven: it
|
||||
# writes and chowns files, runs the firewall, drops privilege twice (gosu
|
||||
# for the session, `ulogd -u` for the log daemon), and signals dnsmasq.
|
||||
# FSETID, MKNOD, SETFCAP, SETPCAP, SYS_CHROOT, AUDIT_WRITE and
|
||||
# NET_BIND_SERVICE are used by nothing here - the last one because the
|
||||
# sysctl below is what lets the TLS terminator bind :443 unprivileged.
|
||||
# The session does not get a network namespace of its own. It joins the one
|
||||
# the filter container owns, where the egress rules are already installed -
|
||||
# so the filter bounding this session sits somewhere it declares no
|
||||
# capability to reach. bin/dejima starts that container and passes its name
|
||||
# here; see the block above its compose invocation, and docs/security.md.
|
||||
#
|
||||
# KILL is the one worth explaining, because it is not obvious from reading
|
||||
# entrypoint.sh and was missing from the first version of this list. It is
|
||||
# not for anything the entrypoint does. `dejima-allow --container` adds a
|
||||
# domain to a RUNNING session by appending a server= line and sending
|
||||
# dnsmasq a SIGHUP, from outside via docker exec - and dnsmasq drops to its
|
||||
# own user (`user=dnsmasq`), so root in this container cannot signal it
|
||||
# without CAP_KILL. Without this line, adding a domain mid-session fails
|
||||
# with "Operation not permitted", which is a documented feature every
|
||||
# session briefing tells the agent to ask for.
|
||||
# The default is for a `docker compose` invocation by hand, which this
|
||||
# file's first line says is not how to run it. It is `network_mode: none`
|
||||
# rather than any real network, because the value that makes a session safe
|
||||
# arrives from outside this file, and the question a default answers is
|
||||
# what happens when it does not arrive: no network at all is a sandbox that
|
||||
# visibly cannot work, where a real one is a sandbox that works and has
|
||||
# unfiltered egress.
|
||||
#
|
||||
# It does not affect `docker compose build`, which reads `build.network`.
|
||||
network_mode: ${SANDBOX_NETNS_CONTAINER:-none}
|
||||
# An explicit floor rather than Docker's default set. The default carries
|
||||
# about fourteen capabilities; this container writes and chowns files and
|
||||
# drops privilege once, to the session user. FSETID, MKNOD, SETFCAP,
|
||||
# SYS_CHROOT, AUDIT_WRITE and NET_BIND_SERVICE are used by nothing here -
|
||||
# the last one because a sysctl on this namespace, not a capability, is
|
||||
# what lets the TLS terminator bind :443 unprivileged.
|
||||
#
|
||||
# No network capability at all, which is the point of the arrangement
|
||||
# rather than an economy. This container is inside a network namespace the
|
||||
# filter container owns and has already installed the egress rules in, and
|
||||
# capabilities are per process: without NET_ADMIN, nothing here can alter
|
||||
# those rules even at uid 0. NET_RAW goes with it - a raw socket is the
|
||||
# other way to speak past a filter, and nothing here opens one.
|
||||
#
|
||||
# KILL is here for tini, and for nothing else in this container.
|
||||
#
|
||||
# `init: true` above makes PID 1 the init that reaps for the session, and
|
||||
# tini forwards every signal it receives to its child. That child is the
|
||||
# entrypoint, which changes uid in place before exec'ing the session - so
|
||||
# tini is uid 0 signalling a process whose real AND saved uids are the
|
||||
# sandbox user's. kill(2) permits that only with CAP_KILL or a uid match,
|
||||
# and there is no match, so without this every forwarded signal is EPERM.
|
||||
#
|
||||
# tini treats a failed forward as fatal, and PID 1 exiting ends the
|
||||
# container. The signal that arrives in practice is SIGWINCH: the kernel
|
||||
# sends it to the pty's foreground process group on any terminal resize,
|
||||
# which includes tini. So without this line, splitting a tmux pane kills
|
||||
# the session with `[FATAL tini (1)] Unexpected error when forwarding
|
||||
# signal: 'Operation not permitted'`.
|
||||
#
|
||||
# It used to be here for `dejima-allow --container`, which signals dnsmasq;
|
||||
# that reason moved to the filter container with dnsmasq, and this one was
|
||||
# not noticed until the capability went away. Nothing in the suite ran an
|
||||
# init at all - see runtime.bats.
|
||||
#
|
||||
# The general lesson, since the next capability will be the same shape:
|
||||
# what the container needs is not only what its entrypoint does. Things
|
||||
# reach into it later.
|
||||
# what a container needs is not only what its entrypoint does. Things reach
|
||||
# into it later.
|
||||
#
|
||||
# This narrows the ROOT phase. The session user's capabilities were already
|
||||
# empty: they are cleared on the gosu drop, which runtime.bats asserts by
|
||||
# reading CapEff of the running process. What this adds is that the set was
|
||||
# never there to be regained.
|
||||
# empty: they are cleared on the privilege drop, which runtime.bats asserts
|
||||
# by reading CapEff of the running process. What this adds is that the set
|
||||
# was never there to be regained.
|
||||
#
|
||||
# SETPCAP is the odd one out: nothing in the startup sequence uses it for
|
||||
# its own work. It is spent, once, to take every capability above away from
|
||||
# the session before it starts - see entrypoint.sh. A bounding set survives
|
||||
# execve and a uid-0 process recovers its permitted set from it, so without
|
||||
# this the whole list is held by everything the session can reach, effective
|
||||
# set or not.
|
||||
#
|
||||
# Adding a capability to remove capabilities is worth stating plainly. It
|
||||
# can only reduce: since Linux 4.3 SETPCAP grants nothing a process does
|
||||
# not already hold in its permitted set, so what it buys is the drop and
|
||||
# nothing else.
|
||||
cap_drop:
|
||||
- ALL
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
- NET_RAW
|
||||
- KILL
|
||||
- CHOWN
|
||||
- DAC_OVERRIDE
|
||||
- FOWNER
|
||||
- SETUID
|
||||
- SETGID
|
||||
- KILL
|
||||
- SETPCAP
|
||||
# No path back up. gosu drops privilege rather than gaining it, so this
|
||||
# does not interfere with it - what it forbids is a setuid binary or a file
|
||||
# capability raising privileges on exec, which is the shape any escalation
|
||||
@@ -92,12 +136,13 @@ services:
|
||||
# structural instead of only observed.
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
# The credential-proxy TLS terminator binds :443 as the unprivileged
|
||||
# sandbox user (see entrypoint.sh). Docker already defaults this to 0, but
|
||||
# pinning it makes the dependency explicit: a future default change then
|
||||
# fails the launch instead of silently breaking the credential path.
|
||||
sysctls:
|
||||
net.ipv4.ip_unprivileged_port_start: 0
|
||||
# No sysctls here. The one this container depends on -
|
||||
# net.ipv4.ip_unprivileged_port_start, which lets the credential proxy's
|
||||
# TLS terminator bind :443 as the unprivileged sandbox user - is a property
|
||||
# of a network namespace, and this container does not own the one it is in.
|
||||
# bin/dejima sets it on the filter container that does, so it is declared
|
||||
# once, by the container it belongs to. runtime.bats reads the value back
|
||||
# from in here, which is where it has to be true.
|
||||
environment:
|
||||
# The forge's own env vars (declared by its manifest's env_export) and
|
||||
# the DEJIMA_* resolved values (DEJIMA_FORGE_HOSTS, DEJIMA_PUSH_HOSTS,
|
||||
@@ -143,7 +188,7 @@ services:
|
||||
SANDBOX_HARNESS_SEED: ${SANDBOX_HARNESS_SEED:-}
|
||||
SANDBOX_HARNESS_STAGED_EMPTY: ${SANDBOX_HARNESS_STAGED_EMPTY:-}
|
||||
# Opt-in escape hatch from the default hard GitHub-push block - see
|
||||
# init-firewall.sh and git-push-guard.sh. Off unless explicitly set.
|
||||
# the egress policy 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://192.0.2.10:3100) to
|
||||
# ship DNS-query and firewall allow/reject logs to. Its host is
|
||||
@@ -237,12 +282,17 @@ services:
|
||||
# launch (it inspects the symlinks and appends the exact mounts
|
||||
# needed), not listed statically here, since this repo can't assume
|
||||
# what else might be installed on a given host.
|
||||
- dejima-bashhistory:/commandhistory
|
||||
# Same volume as before this path moved, so an operator's existing
|
||||
# history carries over rather than being orphaned under the old name.
|
||||
- dejima-bashhistory:/var/opt/shell-history
|
||||
- nvim-data:${HOME}/.local/share/nvim
|
||||
- nvim-state:${HOME}/.local/state/nvim
|
||||
- nvim-cache:${HOME}/.cache/nvim
|
||||
# Required only if SANDBOX_SSH_KEY isn't set - bin/dejima
|
||||
# itself enforces that at least one of the two is present.
|
||||
# itself enforces that at least one of the two is present, and blanks
|
||||
# SSH_AUTH_SOCK when the key is the one chosen, so this mounts nothing
|
||||
# then. A forwarded agent left here signs with the operator's personal
|
||||
# identities for anything in the container that names this path.
|
||||
- ${SSH_AUTH_SOCK:-/dev/null}:/ssh-agent
|
||||
- ${SSH_KNOWN_HOSTS:-/dev/null}:${HOME}/.ssh/known_hosts:ro
|
||||
# Dedicated, purpose-built key for headless git push - see README
|
||||
|
||||
Executable
+1711
File diff suppressed because it is too large
Load Diff
+70
-79
@@ -8,56 +8,35 @@ set -Eeuo pipefail
|
||||
# refuse deliberately, and say so (#59).
|
||||
trap 'status=$?; echo "ERROR: entrypoint.sh aborted at line $LINENO (exit $status): $BASH_COMMAND" >&2' ERR
|
||||
|
||||
# DEJIMA_FORGE_HOSTS is resolved host-side by bin/dejima (forge_hosts, in
|
||||
# dejima-provider-lib.sh) from whichever forge is configured, so this file
|
||||
# does not derive or name any one forge's hostnames itself.
|
||||
# drop_caps and require_setpcap, shared with the filter container's entrypoint.
|
||||
# Sourced rather than defined here because the callers must not drift: what
|
||||
# `drop_caps` does is the difference between a session holding nothing and a
|
||||
# session holding the whole floor, and that is not a thing to have two of.
|
||||
# shellcheck source=caps-lib.sh
|
||||
source /usr/local/bin/caps-lib.sh
|
||||
require_setpcap
|
||||
|
||||
# The egress control for this namespace is already up: the filter container
|
||||
# that owns it installed the rules, and bin/dejima waited for that before
|
||||
# starting this container. Nothing here brings it up, and nothing here could -
|
||||
# this container declares none of the capabilities it would take.
|
||||
#
|
||||
# LOKI_URL (a full URL) still needs its own derivation here - Loki's host
|
||||
# has to be reachable for log-shipper.sh to actually deliver anything.
|
||||
loki_host=""
|
||||
if [ -n "${LOKI_URL:-}" ]; then
|
||||
loki_host=$(echo "$LOKI_URL" | sed -E 's#^[a-zA-Z]+://##; s#[:/].*##')
|
||||
fi
|
||||
|
||||
extra_domains="${DEJIMA_FORGE_HOSTS:-} ${loki_host} ${ALLOWED_DOMAINS:-}"
|
||||
|
||||
# What this container does need is to be pointed at the resolver enforcing the
|
||||
# name allowlist. /etc/resolv.conf is per container even when the namespace is
|
||||
# shared, so egress-policy repointing the filter container's file says nothing
|
||||
# about this one - which Docker generated pointing straight at its embedded
|
||||
# resolver. Left alone, every lookup the session makes would go around the
|
||||
# restricted resolver and the name allowlist would apply to nothing.
|
||||
#
|
||||
# It would not silently succeed, either: the policy restricts which uids may
|
||||
# reach a resolver outside this namespace, and the session user is not one of
|
||||
# them - so a session left pointing at the embedded resolver resolves nothing
|
||||
# at all rather than resolving anything it likes.
|
||||
#
|
||||
# The value below is the same one egress-policy writes for itself, at the same
|
||||
# loopback address, because loopback is shared with the resolver's container.
|
||||
if [ "${ENABLE_FIREWALL:-1}" = "1" ]; then
|
||||
# dns-proxy.sh must run first: it repoints /etc/resolv.conf at a local
|
||||
# dnsmasq that only resolves allowlisted names, so init-firewall.sh's own
|
||||
# dig calls (and everything afterward) go through the same restriction.
|
||||
EXTRA_ALLOWED_DOMAINS="${extra_domains}" /usr/local/bin/dns-proxy.sh
|
||||
EXTRA_ALLOWED_DOMAINS="${extra_domains}" /usr/local/bin/init-firewall.sh
|
||||
|
||||
# NFLOG groups only exist once init-firewall.sh has run, so ulogd2 (the
|
||||
# NFLOG consumer) and the Loki shipper start after it. Both are
|
||||
# best-effort visibility on top of the enforcement above, not required
|
||||
# for it - log-shipper.sh itself no-ops if LOKI_URL isn't set. Runs as
|
||||
# its own unprivileged `ulogd` user (opens the NFLOG socket and output
|
||||
# files first, then drops), not root - it's parsing headers from
|
||||
# attacker-influenceable packets (whatever a compromised session sends),
|
||||
# so a bug in it shouldn't be a path back to root in the container.
|
||||
#
|
||||
# `ulogd -d` daemonizes immediately and always exits 0 from this shell's
|
||||
# point of view, regardless of whether the daemon itself goes on to
|
||||
# start successfully - so `set -e` above won't catch a failure here (nor
|
||||
# should it: logging is best-effort, not something worth failing the
|
||||
# whole container over). Check separately so a failure is at least
|
||||
# visible instead of silently producing no logs.
|
||||
/usr/sbin/ulogd -d -u ulogd -c /etc/ulogd-sandbox.conf
|
||||
sleep 0.5
|
||||
if ! pgrep -x ulogd >/dev/null; then
|
||||
echo "WARNING: ulogd2 failed to start - connection logging will be unavailable (see /var/log/ulogd.log)" >&2
|
||||
fi
|
||||
# Unlike ulogd2 above, this one stays root - deliberately, not an
|
||||
# oversight. What it ships is an oversight log of the session running
|
||||
# beside it, and letting the sandbox user own the shipper would let a
|
||||
# compromised session tamper with or kill its own logging. Root buys
|
||||
# tamper-resistance that a security log needs and a best-effort netflow
|
||||
# counter does not.
|
||||
#
|
||||
# What it reads is dnsmasq's query log and ulogd2's output, both written
|
||||
# by root-owned daemons here and neither session-writable.
|
||||
/usr/local/bin/log-shipper.sh &
|
||||
echo "nameserver 127.0.0.1" > /etc/resolv.conf
|
||||
fi
|
||||
|
||||
# A normal login/ssh session always chowns the allocated pty to the logging
|
||||
@@ -65,7 +44,10 @@ fi
|
||||
# involved), leaving the tty root-owned. nvim's clipboard fallback needs to
|
||||
# write to it directly as the sandbox user, so fix the ownership up here,
|
||||
# before dropping privileges.
|
||||
if [ -t 1 ]; then
|
||||
# Both halves read stdin, because that is what `tty` reports on. Testing stdout
|
||||
# instead meant a container with one attached and the other not produced an
|
||||
# empty path and a chown that silently did nothing.
|
||||
if [ -t 0 ]; then
|
||||
chown "$SANDBOX_USER" "$(tty)" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
@@ -91,6 +73,9 @@ fi
|
||||
# Mounted at /root (not under $SANDBOX_HOME) so only this root-run block
|
||||
# can ever read it - copied out, loaded into the agent, and the copy
|
||||
# deleted, all before dropping to the sandbox user below.
|
||||
# The forwarded agent is not mounted when this key is configured (bin/dejima
|
||||
# blanks SSH_AUTH_SOCK), so this is the only agent in the container rather than
|
||||
# the preferred one of two.
|
||||
SANDBOX_SSH_KEY_SRC="/root/.ssh-sandbox-key-src"
|
||||
if [ -s "$SANDBOX_SSH_KEY_SRC" ]; then
|
||||
install -o "$SANDBOX_USER" -g "$SANDBOX_USER" -m 600 "$SANDBOX_SSH_KEY_SRC" "$SANDBOX_HOME/.ssh/sandbox-key"
|
||||
@@ -111,31 +96,25 @@ fi
|
||||
# Linked here rather than COPYed into place at build time because both depend
|
||||
# on values that are only known per launch: which hosts this forge may be
|
||||
# pushed to, and which subcommand this workflow blocks.
|
||||
if [ -n "${DEJIMA_PUSH_HOSTS:-}${DEJIMA_BLOCKED_BRANCHES:-}" ]; then
|
||||
# /usr/local/bin precedes /usr/bin, so this shadows git without moving it
|
||||
# and the guard execs /usr/bin/git directly.
|
||||
ln -sf /usr/local/lib/dejima/git-guard.sh /usr/local/bin/git
|
||||
fi
|
||||
|
||||
# Every CLI the shadow has something to do about: the one whose merge a
|
||||
# workflow blocks, and every one that writes a body the session authored. More
|
||||
# than one, because a forge can have a CLI per artifact rather than one with
|
||||
# subcommands, and because a forge with nothing blocked can still have
|
||||
# something to disclose.
|
||||
# The git shadow is installed unconditionally, because DEJIMA_PUSH_HOSTS fails
|
||||
# CLOSED: empty means no host may be pushed to, which is how a no-forge or
|
||||
# non-pushing workflow becomes enforced rather than described. Gating the
|
||||
# shadow on a non-empty value would leave it absent in exactly that case - and
|
||||
# local-only, the shipped default, is exactly that case.
|
||||
#
|
||||
# The first word of each record names the CLI. The real one is already behind
|
||||
# the symlink this replaces (see install-forge-bins.sh), so the guard has a
|
||||
# fixed path to exec.
|
||||
_shadow_clis() {
|
||||
[ -z "${DEJIMA_MERGE_BLOCK:-}" ] || printf '%s\n' "${DEJIMA_MERGE_BLOCK%% *}"
|
||||
# "<kind> <cli> <subcommand>...", so the CLI is the second word.
|
||||
[ -z "${DEJIMA_DISCLOSE:-}" ] || printf '%s\n' "$DEJIMA_DISCLOSE" |
|
||||
while read -r _ _cli _; do
|
||||
[ -z "$_cli" ] || printf '%s\n' "$_cli"
|
||||
done
|
||||
}
|
||||
# /usr/local/bin precedes /usr/bin, so this shadows git without moving it and
|
||||
# the guard execs /usr/bin/git directly.
|
||||
ln -sf /usr/local/lib/dejima/git-guard.sh /usr/local/bin/git
|
||||
|
||||
_shadow_cli_list="$(_shadow_clis | sort -u)"
|
||||
# Every CLI the shadow has something to do about. Derived by the same function
|
||||
# hooks/merge-guard.sh uses to decide whether a command is one of these, so the
|
||||
# set the shadow is linked over and the set the hook checks against cannot
|
||||
# disagree. The real CLI is already behind the symlink this replaces (see
|
||||
# install-forge-bins.sh), so the guard has a fixed path to exec.
|
||||
# shellcheck source=guard-lib.sh
|
||||
source "${DEJIMA_GUARD_LIB:-/usr/local/lib/dejima/guard-lib.sh}"
|
||||
|
||||
_shadow_cli_list="$(dejima_forge_commands | sort -u)"
|
||||
if [ -n "$_shadow_cli_list" ]; then
|
||||
while read -r _cli; do
|
||||
[ -n "$_cli" ] || continue
|
||||
@@ -336,7 +315,11 @@ if [ -n "${SANDBOX_HARNESS_BRIEFING_ARGS:-}" ] || [ -n "${SANDBOX_HARNESS_BRIEFI
|
||||
mkdir -p /etc/dejima
|
||||
# Written and renamed, so a harness starting while this is written reads
|
||||
# either the whole briefing or no file at all.
|
||||
if ! /usr/local/lib/dejima/dejima-briefing-render.sh "" "$PWD" \
|
||||
# As the sandbox user, not as root. It reads the project's own .dejima.md
|
||||
# and asks the package managers what is installed, with its cwd inside the
|
||||
# workspace the session can write - and the file it produces is written by
|
||||
# the redirect out here, which root still owns.
|
||||
if ! gosu "$SANDBOX_USER" /usr/local/lib/dejima/dejima-briefing-render.sh "" "$PWD" \
|
||||
> "$DEJIMA_BRIEFING_RENDERED.part"; then
|
||||
rm -f "$DEJIMA_BRIEFING_RENDERED.part"
|
||||
echo "entrypoint: could not render the session briefing. Refusing to" >&2
|
||||
@@ -532,18 +515,26 @@ if [ -n "${DEJIMA_NOTIFY_DIR:-}" ]; then
|
||||
fi
|
||||
|
||||
# Test harness hook (test/): everything above this point is the real
|
||||
# startup sequence - DNS proxy, firewall, IPv6 block, logging,
|
||||
# privilege-drop prep. Skip execing $SANDBOX_CMD so the suite can `docker
|
||||
# startup sequence - resolver, packet filter, logging, privilege-drop prep.
|
||||
# Skip execing $SANDBOX_CMD so the suite can `docker
|
||||
# exec` into a live, fully-initialized container and assert against it,
|
||||
# exercising the actual code path instead of a reimplementation of it that
|
||||
# could drift out of sync.
|
||||
if [ "${SANDBOX_TEST_MODE:-0}" = "1" ]; then
|
||||
exec gosu "$SANDBOX_USER" sleep infinity
|
||||
drop_caps --user="$SANDBOX_USER" -- -c 'exec "$@"' dejima sleep infinity
|
||||
fi
|
||||
|
||||
# Drop root -> the sandbox user for the actual session; that user has no
|
||||
# sudo/setuid path back to root after this, so it can't re-run
|
||||
# init-firewall.sh itself to widen the allowlist mid-session.
|
||||
# sudo/setuid path back to root after this, so it can't re-run the egress
|
||||
# policy itself to widen the allowlist mid-session. It could not anyway: the
|
||||
# policy is installed from the container beside this one, which holds the
|
||||
# capability, and this one declares none.
|
||||
#
|
||||
# And drop the bounding set with it, so "no path back to root" and "no
|
||||
# capability if there were one" are two separate things rather than one
|
||||
# resting on the other. capsh does both in one process because it has to: the
|
||||
# bounding set can only be reduced while CAP_SETPCAP is still held, and
|
||||
# changing uid first gives it away.
|
||||
#
|
||||
# SANDBOX_CMD is what actually runs as that user - the selected harness's
|
||||
# own command unless [core] command overrides it (e.g. to `nvim`) for an
|
||||
@@ -560,4 +551,4 @@ if [ -z "${SANDBOX_CMD:-}" ]; then
|
||||
echo "command: docker run --entrypoint sh ..." >&2
|
||||
exit 1
|
||||
fi
|
||||
exec gosu "$SANDBOX_USER" $SANDBOX_CMD "$@"
|
||||
drop_caps --user="$SANDBOX_USER" -- -c 'exec "$@"' dejima $SANDBOX_CMD "$@"
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
#!/bin/bash
|
||||
# PID 1 of the filter container: brings this launch's egress control up, says
|
||||
# so, and then holds the network namespace open for the sandbox to join.
|
||||
#
|
||||
# The sandbox is a separate container that joins this namespace and declares no
|
||||
# network capability of its own. Capabilities are per process, so it cannot
|
||||
# alter the filter installed here even at uid 0 - which is the whole reason the
|
||||
# two are separate. See docs/security.md.
|
||||
#
|
||||
# Nothing a session runs ever executes in here. This container holds the
|
||||
# privilege; the one beside it holds the session.
|
||||
set -Eeuo pipefail
|
||||
trap 'status=$?; echo "ERROR: filter-entrypoint.sh aborted at line $LINENO (exit $status): $BASH_COMMAND" >&2' ERR
|
||||
|
||||
# shellcheck source=caps-lib.sh
|
||||
source /usr/local/bin/caps-lib.sh
|
||||
require_setpcap
|
||||
|
||||
/usr/local/bin/firewall-up.sh
|
||||
|
||||
# Written last, and only on the success path above - `set -e` means a firewall
|
||||
# that failed to come up never reaches this line. bin/dejima waits for this
|
||||
# file before it starts the sandbox, so the ordering that makes the arrangement
|
||||
# fail-closed is a fact about the filesystem rather than about how long a
|
||||
# launcher guessed the firewall would take.
|
||||
#
|
||||
# Under /run, which is a tmpfs: a container that is restarted rather than
|
||||
# relaunched must not inherit a marker saying its filter is up before it is.
|
||||
: > /run/dejima-firewall-ready
|
||||
echo "filter: egress control is up; holding the namespace for the session"
|
||||
|
||||
# Held open by a process that does nothing else. The namespace lives as long as
|
||||
# a process is in it, and this container is started with an init that forwards
|
||||
# SIGTERM, so `docker stop` ends it promptly rather than waiting out a timeout.
|
||||
exec sleep infinity
|
||||
@@ -0,0 +1,170 @@
|
||||
#!/bin/bash
|
||||
# Brings up this network namespace's egress control: the packet filter, the
|
||||
# restricted resolver, and the log producers that watch them. All of that is
|
||||
# docker/egress-policy; this file is what turns a launch's configuration into
|
||||
# the permitted set that policy is given.
|
||||
#
|
||||
# Its own script rather than a block inside entrypoint.sh because the namespace
|
||||
# it acts on and the session that runs in it need not be in the same container.
|
||||
# What this does is a property of a network namespace; everything else an
|
||||
# entrypoint does is a property of a container.
|
||||
#
|
||||
# Called with the caller's environment: DEJIMA_FORGE_HOSTS, LOKI_URL,
|
||||
# ALLOWED_DOMAINS, ALLOW_GITHUB_PUSH, GH_TOKEN, ENABLE_FIREWALL and the harness
|
||||
# values fixed-domains.sh reads. It resolves nothing host-side itself.
|
||||
set -Eeuo pipefail
|
||||
# Same reason as entrypoint.sh's: under `set -e` alone a failure here aborts
|
||||
# with no output, and the only symptom is a container that never appeared (#59).
|
||||
trap 'status=$?; echo "ERROR: firewall-up.sh aborted at line $LINENO (exit $status): $BASH_COMMAND" >&2' ERR
|
||||
|
||||
# shellcheck source=caps-lib.sh
|
||||
source /usr/local/bin/caps-lib.sh
|
||||
# shellcheck source=fixed-domains.sh
|
||||
source /usr/local/bin/fixed-domains.sh
|
||||
|
||||
[ "${ENABLE_FIREWALL:-1}" = "1" ] || exit 0
|
||||
|
||||
# DEJIMA_FORGE_HOSTS is resolved host-side by bin/dejima (forge_hosts, in
|
||||
# dejima-provider-lib.sh) from whichever forge is configured, so this file does
|
||||
# not derive or name any one forge's hostnames itself. Marked internal: a
|
||||
# self-hosted forge is routinely on the operator's own network, and its address
|
||||
# is something they configured rather than something an answer decides.
|
||||
for _host in ${DEJIMA_FORGE_HOSTS:-}; do
|
||||
dejima_permit_open "$_host" internal
|
||||
done
|
||||
|
||||
# LOKI_URL is a full URL and still needs its own derivation here - Loki's host
|
||||
# has to be reachable for log-shipper.sh to deliver anything. Internal for the
|
||||
# same reason as the forge.
|
||||
if [ -n "${LOKI_URL:-}" ]; then
|
||||
dejima_permit_open "$(echo "$LOKI_URL" | sed -E 's#^[a-zA-Z]+://##; s#[:/].*##')" internal
|
||||
fi
|
||||
|
||||
# A model the operator serves themselves, from [backend.<name>] base_url. Same
|
||||
# class as the forge and Loki and exempted for the same reason: they configured
|
||||
# the address, so there is nothing for an answer to decide - and a self-served
|
||||
# endpoint is on their own network about as often as not.
|
||||
dejima_permit_open "${DEJIMA_BACKEND_HOST:-}" internal
|
||||
|
||||
# Approved domains, from `dejima-allow --global` and the project's own
|
||||
# `.dejima/allowed-domains`. NOT marked internal: these are reviewed with the
|
||||
# project and expected to be public names, and the whole value of the answer
|
||||
# check is that a public name answered with an internal address is refused. An
|
||||
# approved domain that really is internal makes the container say so at start,
|
||||
# naming the entry - see egress-policy's check on the permitted names.
|
||||
#
|
||||
# Added in its own class rather than all in one. A forge-class name in the open
|
||||
# class would get the resolver a line more specific than the forge suffix entry,
|
||||
# feeding the open-class set, and the port restriction would rest on the forge's
|
||||
# published ranges alone - one layer where the design has two. A project
|
||||
# declaring github.com is the ordinary way to reach that.
|
||||
#
|
||||
# Recorded rather than skipped, even though the suffix entry already makes it
|
||||
# reachable: [core] internal_domains is checked against these lists by exact
|
||||
# string, so a name reachable only by suffix reads as one nothing permits, and
|
||||
# the launch stops on a configuration that is in fact correct.
|
||||
for _domain in ${ALLOWED_DOMAINS:-}; do
|
||||
if [ "$(dejima_class_for "$_domain")" = forge ]; then
|
||||
dejima_permit_forge "$_domain"
|
||||
else
|
||||
dejima_permit_open "$_domain"
|
||||
fi
|
||||
done
|
||||
|
||||
# Declared internal destinations, for the case the check above names: an
|
||||
# operator whose approved domain genuinely points inside their network says so
|
||||
# here rather than losing the check for every other name.
|
||||
#
|
||||
# This exempts; it does not permit. Reaching a destination is an approval and
|
||||
# lives in the lists above, and a key that did both would make naming a host
|
||||
# here enough to reach it - which is not what it says it does, and is a grant
|
||||
# nobody reviewing an allowlist would think to look for.
|
||||
#
|
||||
# Naming something nothing permitted is a disagreement inside the operator's
|
||||
# own configuration, so it stops the container here rather than leaving a
|
||||
# destination silently unreachable and an exemption silently unused.
|
||||
_unknown=""
|
||||
for _domain in ${DEJIMA_INTERNAL_DOMAINS:-}; do
|
||||
dejima_mark_internal "$_domain" || _unknown="$_unknown $_domain"
|
||||
done
|
||||
if [ -n "$_unknown" ]; then
|
||||
echo "ERROR: [core] internal_domains names destinations nothing permits:$_unknown" >&2
|
||||
echo "That key exempts a name from the check that refuses an answer pointing" >&2
|
||||
echo "inside a private network. It does not make the name reachable - approve" >&2
|
||||
echo "it as well, per project or globally:" >&2
|
||||
echo "" >&2
|
||||
for _domain in $_unknown; do
|
||||
echo " dejima-allow --global $_domain" >&2
|
||||
done
|
||||
exit 1
|
||||
fi
|
||||
|
||||
_join() { local IFS=' '; echo "${*:-}"; }
|
||||
|
||||
# The forge class wins wherever the two overlap, applied here rather than
|
||||
# asserted in a comment. Every route into the open class is normalised, not just
|
||||
# the approved-domains loop: the built-in list carries
|
||||
# release-assets.githubusercontent.com, which is under a forge suffix, and a
|
||||
# harness or backend domain under one would arrive the same way. Left alone, such
|
||||
# a name gets a resolver line more specific than the suffix entry, feeding the
|
||||
# open-class set - and the port restriction then rests on the forge's published
|
||||
# ranges alone, which is one layer where the design has two.
|
||||
_forge_extra=""
|
||||
_open_kept=""
|
||||
for _domain in ${DEJIMA_OPEN_CLASS[@]+"${DEJIMA_OPEN_CLASS[@]}"}; do
|
||||
if [ "$(dejima_class_for "$_domain")" = forge ]; then
|
||||
_forge_extra="$_forge_extra $_domain"
|
||||
else
|
||||
_open_kept="$_open_kept $_domain"
|
||||
fi
|
||||
done
|
||||
|
||||
# shellcheck disable=SC2086
|
||||
EGRESS_FORGE_NAMES="$(_join ${DEJIMA_FORGE_CLASS[@]+"${DEJIMA_FORGE_CLASS[@]}"} $_forge_extra)"
|
||||
# shellcheck disable=SC2086
|
||||
EGRESS_ALLOWED_NAMES="$(_join $_open_kept)"
|
||||
EGRESS_INTERNAL_OK="$(_join ${DEJIMA_INTERNAL_CLASS[@]+"${DEJIMA_INTERNAL_CLASS[@]}"})"
|
||||
# What the policy should call that list when it tells an operator to add
|
||||
# something to it. It knows the variable it reads; only dejima knows the
|
||||
# configuration key an operator actually sets.
|
||||
EGRESS_INTERNAL_OK_HINT='[core] internal_domains'
|
||||
export EGRESS_FORGE_NAMES EGRESS_ALLOWED_NAMES EGRESS_INTERNAL_OK EGRESS_INTERNAL_OK_HINT
|
||||
export EGRESS_FORGE_SSH="${ALLOW_GITHUB_PUSH:-0}"
|
||||
# The published-ranges endpoint is rate-limited per source address when
|
||||
# unauthenticated, across everything on the host - so a host starting sandboxes
|
||||
# in a loop exhausts it and every launch after that fails on a credential that
|
||||
# was available. Same read-only token `gh` already gets (docker-compose.yml),
|
||||
# sent to the API it belongs to and nowhere else.
|
||||
[ -n "${GH_TOKEN:-}" ] && export EGRESS_FORGE_META_AUTH="Authorization: Bearer $GH_TOKEN"
|
||||
# The raw token has no other use here, and the header is the only thing the
|
||||
# policy needs it for. Both are a credential anything started below has no
|
||||
# reason to hold: they parse content the session's own traffic decides.
|
||||
unset GH_TOKEN
|
||||
|
||||
/usr/local/sbin/egress-policy apply
|
||||
# The header carries the token in another wrapper, so it goes the moment the
|
||||
# request that needed it is made. egress-policy scrubs its own daemons at the
|
||||
# line that starts each one; nothing started below this point can hold either
|
||||
# half, which is how the shipper is covered.
|
||||
unset EGRESS_FORGE_META_AUTH
|
||||
|
||||
# Best-effort visibility on top of the enforcement above, not part of it -
|
||||
# log-shipper.sh no-ops if LOKI_URL isn't set. It stays root, deliberately:
|
||||
# what it ships is an oversight log of the session running beside it, and
|
||||
# letting the sandbox user own the shipper would let a compromised session
|
||||
# tamper with or kill its own logging. Root buys tamper-resistance that a
|
||||
# security log needs.
|
||||
#
|
||||
# Root, and nothing else: it parses content the session's own traffic decides,
|
||||
# and needs no capability to read two files its uid already opens or to POST
|
||||
# them. So it keeps the uid that makes it untamperable and holds none of the
|
||||
# rest.
|
||||
#
|
||||
# It outlives this script, and reparents to the container's init when this one
|
||||
# exits - which is where a daemon watching a session belongs. Started from the
|
||||
# entrypoint, it was a child of the very session process it exists to watch.
|
||||
# No `env -u` prefix here, unlike the daemons egress-policy starts: drop_caps
|
||||
# is a shell function, and env can only exec a binary - so a prefix turns this
|
||||
# line into exit 127, which the `&` hides from set -e. The two unsets above
|
||||
# already ran, which is what this one relies on instead.
|
||||
drop_caps -- -c 'exec "$@"' dejima /usr/local/bin/log-shipper.sh &
|
||||
+151
-71
@@ -1,17 +1,26 @@
|
||||
# Sourced by both init-firewall.sh (IP-based ipset allowlist) and
|
||||
# dns-proxy.sh (name-based DNS allowlist) so the two can't drift apart.
|
||||
# Composes the permitted set egress-policy is given, from what the sandbox's
|
||||
# own machinery needs plus what this launch was configured with.
|
||||
#
|
||||
# What belongs here is what the sandbox's own machinery cannot run without -
|
||||
# the release CDN the tools baked into the image fetch binaries from, the npm
|
||||
# registry those tools update through, and whatever the SELECTED HARNESS
|
||||
# declared it needs (api_domains in its manifest, arriving as
|
||||
# Sourced by firewall-up.sh. Exports three lists, because a destination's
|
||||
# permitted ports are a property of the destination and not of how it was
|
||||
# named:
|
||||
#
|
||||
# DEJIMA_FORGE_CLASS reachable on HTTPS only, plus SSH with the opt-in
|
||||
# DEJIMA_OPEN_CLASS reachable on any port but the DNS ports
|
||||
# DEJIMA_INTERNAL_CLASS entries whose answers may point inside a private
|
||||
# network; every one must also be in a list above
|
||||
#
|
||||
# What belongs in the built-in part of these lists is what the sandbox cannot
|
||||
# run without - the release CDN the tools baked into the image fetch binaries
|
||||
# from, the npm registry those tools update through, and whatever the SELECTED
|
||||
# HARNESS declared it needs (api_domains in its manifest, arriving as
|
||||
# SANDBOX_HARNESS_DOMAINS). Nothing else.
|
||||
#
|
||||
# The harness's names are not written here because they are the harness's: a
|
||||
# name built in is inherited by every harness while a second one still lacks
|
||||
# its own. registry.npmjs.org stays, because it is not only the
|
||||
# harness's - nvim's mason fetches language servers through it, and
|
||||
# [core] command = nvim is a supported way to run a session.
|
||||
# its own. registry.npmjs.org stays, because it is not only the harness's -
|
||||
# nvim's mason fetches language servers through it, and [core] command = nvim
|
||||
# is a supported way to run a session.
|
||||
#
|
||||
# Language ecosystems and editor tooling deliberately do not: a Python, Go or
|
||||
# Rust project reaches its own package host through an approval, either
|
||||
@@ -21,78 +30,149 @@
|
||||
# use for it, and - since a project's declaration is also what a hardened CI
|
||||
# runner reads its allowlist from (docs/ci.md) - a name that is granted by
|
||||
# being built in is a name nobody ever reviewed.
|
||||
FIXED_DOMAINS=(
|
||||
"registry.npmjs.org"
|
||||
"release-assets.githubusercontent.com"
|
||||
)
|
||||
# Split on spaces explicitly, not on whatever IFS the caller happens to have.
|
||||
# init-firewall.sh sources this file with IFS=$'\n\t' set, dns-proxy.sh with
|
||||
# the default - so an unquoted expansion gives the two consumers different
|
||||
# lists from the same value, which is the drift this shared file exists to
|
||||
# prevent. A harness declaring two api_domains would reach init-firewall as
|
||||
# one glued element and fail its DNS check fatally, refusing to start.
|
||||
# init-firewall.sh already restores IFS=' ' around its own extra-domains loop
|
||||
# for exactly this reason.
|
||||
if [ -n "${SANDBOX_HARNESS_DOMAINS:-}" ]; then
|
||||
IFS=' ' read -ra _dejima_harness_domains <<< "$SANDBOX_HARNESS_DOMAINS"
|
||||
FIXED_DOMAINS+=(${_dejima_harness_domains[@]+"${_dejima_harness_domains[@]}"})
|
||||
fi
|
||||
|
||||
# The harness's API endpoint is allowlisted only when this session has no
|
||||
# credential proxy (kill switch or API-key auth), where DNS is the only path
|
||||
# to it. 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 another allowlisted name
|
||||
# resolves to the same one.
|
||||
# Forge class. GitHub is reachable for reading - clone, fetch, gh over HTTPS -
|
||||
# and never for pushing, and the port restriction is what makes that a fact
|
||||
# rather than a policy: with 22 closed there is no transport left for the
|
||||
# forwarded agent's real key to authenticate a push with, whatever a hook does
|
||||
# or does not catch. ALLOW_GITHUB_PUSH=1 is the only thing that opens it.
|
||||
#
|
||||
# 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.
|
||||
#
|
||||
# Which name this is comes from the provider (proxied_domain); the rule
|
||||
# about when to allowlist it is dejima's, and stays here.
|
||||
if [ -z "${SANDBOX_CREDENTIAL_PROXY_SOCKET:-}" ] \
|
||||
&& [ -n "${SANDBOX_BACKEND_PROXIED_DOMAIN:-}" ]; then
|
||||
FIXED_DOMAINS+=("$SANDBOX_BACKEND_PROXIED_DOMAIN")
|
||||
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
|
||||
# resolve these names to *something* before connecting, so the DNS proxy
|
||||
# needs to know they're allowed even though init-firewall.sh never digs them.
|
||||
# Suffix matching means these also cover their actual subdomains in use
|
||||
# (api.github.com, codeload.github.com, objects.githubusercontent.com, etc.)
|
||||
# without enumerating each one.
|
||||
GITHUB_DNS_SUFFIXES=(
|
||||
# Suffix entries, because dnsmasq matches a domain and everything under it: one
|
||||
# line covers api.github.com, codeload.github.com and the rest without
|
||||
# enumerating them. Stating the class here is what keeps the restriction
|
||||
# attached to the destination - an address reached by resolving one of these
|
||||
# names gets the same ports as one reached from the published ranges, so a name
|
||||
# cannot be the way around the range's restriction.
|
||||
DEJIMA_FORGE_CLASS=(
|
||||
"github.com"
|
||||
"githubusercontent.com"
|
||||
"githubassets.com"
|
||||
)
|
||||
|
||||
# Which ipset an allowlisted name's addresses belong in.
|
||||
# Open class: reachable on any port, because these are destinations whose
|
||||
# protocol dejima does not get to decide.
|
||||
DEJIMA_OPEN_CLASS=(
|
||||
"registry.npmjs.org"
|
||||
"release-assets.githubusercontent.com"
|
||||
)
|
||||
|
||||
DEJIMA_INTERNAL_CLASS=()
|
||||
|
||||
# release-assets.githubusercontent.com is under a forge-class suffix, so it ends
|
||||
# up in the forge class: firewall-up.sh routes every open-class entry through
|
||||
# dejima_class_for, this list included, rather than only the ones a project
|
||||
# declared. It is fetched over HTTPS like everything else under that suffix, so
|
||||
# the narrower class costs it nothing, and the alternative is a name that
|
||||
# quietly widens the forge - which is what it did until the normalisation
|
||||
# covered this list too.
|
||||
|
||||
# Split on spaces explicitly, not on whatever IFS the caller happens to have,
|
||||
# so every consumer gets the same list from the same value. A harness declaring
|
||||
# two api_domains would otherwise arrive as one glued element and fail its
|
||||
# lookup, refusing to start.
|
||||
if [ -n "${SANDBOX_HARNESS_DOMAINS:-}" ]; then
|
||||
IFS=' ' read -ra _dejima_harness_domains <<< "$SANDBOX_HARNESS_DOMAINS"
|
||||
DEJIMA_OPEN_CLASS+=(${_dejima_harness_domains[@]+"${_dejima_harness_domains[@]}"})
|
||||
fi
|
||||
|
||||
# The harness's API endpoint is permitted only when this session has no
|
||||
# credential proxy (kill switch or API-key auth), where DNS is the only path to
|
||||
# it. 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 another permitted name
|
||||
# resolves to the same one.
|
||||
#
|
||||
# GitHub's are restricted to port 443 by init-firewall.sh, and that
|
||||
# restriction IS the read-only guarantee: no transport is left for a
|
||||
# forwarded agent key to push with. allowed-domains has no port restriction,
|
||||
# so a GitHub address landing there silently undoes the arrangement.
|
||||
# Open class and not exempted from the answer check, because it is a public
|
||||
# SaaS name: an answer for it pointing inside the operator's network is the
|
||||
# thing that check exists to refuse.
|
||||
#
|
||||
# dnsmasq applies the longest matching ipset= line, so dns-proxy.sh gets this
|
||||
# right for free. Every other path has to ask, and two did not - the
|
||||
# extra-domains loop and bin/dejima-allow's live add both appended
|
||||
# unconditionally. A project declaring github.com therefore opened port 22 to
|
||||
# whatever address was cached at container start, presenting as an
|
||||
# intermittent startup failure because the self-check re-resolves and only
|
||||
# sees the hole when the second answer matches the first.
|
||||
ipset_for_domain() {
|
||||
local domain="$1" suffix
|
||||
for suffix in "${GITHUB_DNS_SUFFIXES[@]}"; do
|
||||
if [ "$domain" = "$suffix" ] || [[ "$domain" == *".$suffix" ]]; then
|
||||
printf 'github-domains'
|
||||
# Which name this is comes from the provider (proxied_domain); the rule about
|
||||
# when to permit it is dejima's, and stays here.
|
||||
if [ -z "${SANDBOX_CREDENTIAL_PROXY_SOCKET:-}" ] \
|
||||
&& [ -n "${SANDBOX_BACKEND_PROXIED_DOMAIN:-}" ]; then
|
||||
DEJIMA_OPEN_CLASS+=("$SANDBOX_BACKEND_PROXIED_DOMAIN")
|
||||
fi
|
||||
|
||||
# Adds one entry to the open class, marking it exempt from the answer check
|
||||
# when it is a destination dejima already knows is inside the operator's own
|
||||
# network. That exemption is not handed out to approved domains in general:
|
||||
# `.dejima/allowed-domains` is reviewed with the project and is expected to
|
||||
# carry public names, and exempting a public name is what turns one
|
||||
# attacker-influenced answer into egress to an internal host. A self-hosted
|
||||
# forge or log sink is different - the operator configured its address, so
|
||||
# there is nothing for an answer to decide.
|
||||
dejima_permit_open() {
|
||||
local entry="$1" internal="${2:-}"
|
||||
[ -n "$entry" ] || return 0
|
||||
DEJIMA_OPEN_CLASS+=("$entry")
|
||||
[ "$internal" = internal ] && DEJIMA_INTERNAL_CLASS+=("$entry")
|
||||
return 0
|
||||
}
|
||||
|
||||
# The same, in the class whose ports are restricted. For a name a forge suffix
|
||||
# already covers: the suffix makes it reachable, but only an entry of its own
|
||||
# makes it something the rest of the system can name - dejima_mark_internal and
|
||||
# the policy's own internal_domains check both look for the exact string, and a
|
||||
# name reachable by suffix alone is invisible to them.
|
||||
dejima_permit_forge() {
|
||||
local entry="$1"
|
||||
[ -n "$entry" ] || return 0
|
||||
DEJIMA_FORGE_CLASS+=("$entry")
|
||||
return 0
|
||||
}
|
||||
|
||||
# Exempts an entry from the answer check WITHOUT permitting it. The two are
|
||||
# separate decisions and only one of them is a grant: whether a destination may
|
||||
# be reached is an approval, and whether an answer for it may point inside the
|
||||
# operator's network is this. A key that did both would mean naming a host in
|
||||
# [core] internal_domains was enough to make it reachable, which is not what
|
||||
# the key says it does and not what anyone reviewing an allowlist would expect
|
||||
# to find in it.
|
||||
#
|
||||
# Returns non-zero for an entry nothing has permitted, so the caller can say
|
||||
# which one rather than leaving it to be discovered as a destination that is
|
||||
# quietly unreachable.
|
||||
dejima_mark_internal() {
|
||||
# Case-folded on both sides, for the reason dejima_class_for gives. Here the
|
||||
# cost of comparing verbatim is not a lost restriction but a refused launch:
|
||||
# this returns non-zero for an entry nothing permits, and the caller stops
|
||||
# the container naming it - so [core] internal_domains and the key that
|
||||
# permitted the same host, differing only in case, is a configuration that
|
||||
# is correct and will not start.
|
||||
local entry="${1,,}" known
|
||||
[ -n "$entry" ] || return 0
|
||||
for known in ${DEJIMA_OPEN_CLASS[@]+"${DEJIMA_OPEN_CLASS[@]}"} \
|
||||
${DEJIMA_FORGE_CLASS[@]+"${DEJIMA_FORGE_CLASS[@]}"}; do
|
||||
if [ "${known,,}" = "$entry" ]; then
|
||||
DEJIMA_INTERNAL_CLASS+=("$entry")
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
printf 'allowed-domains'
|
||||
return 1
|
||||
}
|
||||
|
||||
# Which class a name belongs to, by suffix. Anything under a forge-class entry
|
||||
# is forge class, whoever is asking and however it arrived.
|
||||
#
|
||||
# This is why the file is shared rather than restated: bin/dejima-allow adds a
|
||||
# domain to a session that is already running, and a forge name added to the
|
||||
# open class there would be reachable on every port - the read-only guarantee
|
||||
# gone, from a command whose whole output says it granted one domain. It went
|
||||
# wrong exactly that way when the answer lived in two places.
|
||||
dejima_class_for() {
|
||||
# Case-folded, because DNS names are case-insensitive and the resolver
|
||||
# treats them so. Comparing verbatim put GITHUB.COM in the open class while
|
||||
# github.com was forge, which is the narrower class silently lost on a
|
||||
# spelling an operator is free to use.
|
||||
local domain="${1,,}" suffix
|
||||
for suffix in ${DEJIMA_FORGE_CLASS[@]+"${DEJIMA_FORGE_CLASS[@]}"}; do
|
||||
suffix="${suffix,,}"
|
||||
if [ "$domain" = "$suffix" ] || [[ "$domain" == *".$suffix" ]]; then
|
||||
printf 'forge'
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
printf 'open'
|
||||
}
|
||||
|
||||
+25
-187
@@ -3,21 +3,22 @@
|
||||
# one subcommand a workflow blocks is refused, a command aimed at another
|
||||
# repository is refused, and a body the session authored says so.
|
||||
#
|
||||
# The first rule is the one hooks/merge-guard.sh applies - a merge is the
|
||||
# operator's decision, so the sandbox opens a PR and stops - moved off the
|
||||
# harness's hook protocol and onto the command. It works for a harness that
|
||||
# cannot intercept a tool call, and it compares argv rather than
|
||||
# regex-matching a command string, so quoting, spacing and pipelines stop
|
||||
# mattering.
|
||||
# The first two are docker/guard-lib.sh, which hooks/merge-guard.sh applies to
|
||||
# the same command from the other side. Here they sit on the command itself: it
|
||||
# works for a harness that cannot intercept a tool call, and it compares argv
|
||||
# rather than regex-matching a command string, so quoting, spacing and
|
||||
# pipelines stop mattering.
|
||||
#
|
||||
# The second is here for the same reason it is not in the forge CLIs: dejima
|
||||
# writes two of them and not the third, so a disclosure living in the tools is
|
||||
# one that depends on which forge an operator picked. This is the one place
|
||||
# every forge passes through.
|
||||
# The third is this layer's alone, because a hook is handed a command string
|
||||
# and cannot rewrite the body an argument points at. It is here rather than in
|
||||
# the forge CLIs for the same reason the repository rule is: dejima writes two
|
||||
# of them and not the third, so a disclosure living in the tools is one that
|
||||
# depends on which forge an operator picked. This is the one place every forge
|
||||
# passes through.
|
||||
#
|
||||
# Fast-fail, not a boundary, exactly as the hook was: what actually decides
|
||||
# whether a merge is possible is the scope of the token the forge issued. This
|
||||
# stops an eager agent doing it by accident.
|
||||
# Fast-fail, not a boundary: what actually decides whether a merge is possible
|
||||
# is the scope of the token the forge issued. This stops an eager agent doing
|
||||
# it by accident.
|
||||
#
|
||||
# DEJIMA_MERGE_BLOCK the full command prefix to refuse, e.g. "gitea-pr
|
||||
# merge". Its first word is the command this shadows;
|
||||
@@ -51,180 +52,12 @@ if [ -z "${DEJIMA:-}" ] ||
|
||||
exec "$real" "$@"
|
||||
fi
|
||||
|
||||
# The words of a command prefix, minus the CLI name, appear as a run of
|
||||
# consecutive non-flag arguments. Flags are dropped first, so `gitea-pr
|
||||
# --verbose merge 7` is still a merge.
|
||||
#
|
||||
# Anywhere in that run rather than at the start of it, because a flag's value
|
||||
# is indistinguishable from a subcommand here: in `gh --repo o/r pr merge 7`
|
||||
# the first non-flag argument is `o/r`, and requiring the match to begin there
|
||||
# let the most ordinary way to name a PR from outside its checkout walk
|
||||
# straight past the block. Knowing which flags take a value would mean knowing
|
||||
# each CLI, which is the coupling this guard exists to avoid.
|
||||
#
|
||||
# The cost is a command that happens to pass the words as consecutive values -
|
||||
# `--title pr --body merge` - matching. For the merge block that is a loud
|
||||
# refusal against a merge going through unnoticed; for a disclosure it is a
|
||||
# line of text on something an agent did write anyway. Both are the direction
|
||||
# to be wrong in.
|
||||
plain=()
|
||||
for arg in "$@"; do
|
||||
case "$arg" in -*) continue ;; esac
|
||||
plain+=("$arg")
|
||||
done
|
||||
# shellcheck source=guard-lib.sh
|
||||
source "${DEJIMA_GUARD_LIB:-/usr/local/lib/dejima/guard-lib.sh}"
|
||||
|
||||
# argv_has <word>... - true when those words appear consecutively in `plain`.
|
||||
argv_has() {
|
||||
local want=("$@") n="${#plain[@]}" m="$#" i=0 k
|
||||
[ "$m" -gt 0 ] || return 1
|
||||
while [ "$((i + m))" -le "$n" ]; do
|
||||
k=0
|
||||
while [ "$k" -lt "$m" ] && [ "${plain[$((i + k))]}" = "${want[$k]}" ]; do
|
||||
k=$((k + 1))
|
||||
done
|
||||
[ "$k" -lt "$m" ] || return 0
|
||||
i=$((i + 1))
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# --- the merge block --------------------------------------------------------
|
||||
|
||||
if [ -n "${DEJIMA_MERGE_BLOCK:-}" ]; then
|
||||
read -ra blocked <<< "$DEJIMA_MERGE_BLOCK"
|
||||
# The first word names the command; only the rest is a subcommand sequence
|
||||
# to compare against argv. A block naming a different command than the one
|
||||
# being run is not this invocation's business.
|
||||
if [ "${#blocked[@]}" -ge 1 ] && [ "${blocked[0]}" = "$self" ]; then
|
||||
if [ "${#blocked[@]}" -eq 1 ]; then
|
||||
# The whole command is blocked, not one of its subcommands.
|
||||
echo "Blocked by dejima's forge guard: '$self' is not available in the sandbox." >&2
|
||||
exit 1
|
||||
fi
|
||||
if argv_has "${blocked[@]:1}"; then
|
||||
echo "Blocked by dejima's forge guard: merging is a human decision in the sandbox - open a PR/MR, then the operator merges. Report its URL and stop." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- the repository the session was launched against ------------------------
|
||||
#
|
||||
# A forge whose CLI can be aimed elsewhere declares how (repo_flags,
|
||||
# repo_env). The gitea CLIs declare neither, because they resolve the repo
|
||||
# from origin and refuse another host; `gh` declares both, so on that path the
|
||||
# most ordinary way to name a PR from outside its checkout is also the way to
|
||||
# file an issue on a repository the session has nothing to do with (#187).
|
||||
#
|
||||
# Fast-fail, not a boundary, for the same reason the merge block is not one:
|
||||
# the session holds the token and could call the API directly. What bounds
|
||||
# this is the scope of push_token_file.
|
||||
#
|
||||
# Refused rather than skipped when the launch resolved no repository at all: a
|
||||
# workspace with no origin has nothing for --repo to legitimately name, and
|
||||
# "cannot confirm" is not a reason to let it through.
|
||||
if [ -n "${DEJIMA_FORGE_REPO_FLAGS:-}" ] || [ -n "${DEJIMA_FORGE_REPO_ENV:-}" ] ||
|
||||
[ -n "${DEJIMA_FORGE_REPO_PATHS:-}" ]; then
|
||||
refuse_repo() {
|
||||
echo "Blocked by dejima's forge guard: this session was launched against ${DEJIMA_FORGE_REPO:-no repository}, and $1 names '$2'. Act on the repository the sandbox mounts." >&2
|
||||
exit 1
|
||||
}
|
||||
# Case-insensitively: forges treat an owner and a repository name as
|
||||
# case-preserving but not case-sensitive, and a refusal over capitalisation
|
||||
# would be a false one.
|
||||
same_repo() {
|
||||
[ -n "${DEJIMA_FORGE_REPO:-}" ] && [ "${1,,}" = "${DEJIMA_FORGE_REPO,,}" ]
|
||||
}
|
||||
if [ -n "${DEJIMA_FORGE_REPO_ENV:-}" ]; then
|
||||
env_value="${!DEJIMA_FORGE_REPO_ENV:-}"
|
||||
if [ -n "$env_value" ] && ! same_repo "$env_value"; then
|
||||
refuse_repo "$DEJIMA_FORGE_REPO_ENV" "$env_value"
|
||||
fi
|
||||
fi
|
||||
if [ -n "${DEJIMA_FORGE_REPO_FLAGS:-}" ]; then
|
||||
read -ra repo_flags <<< "$DEJIMA_FORGE_REPO_FLAGS"
|
||||
args_left=("$@")
|
||||
while [ "${#args_left[@]}" -gt 0 ]; do
|
||||
arg="${args_left[0]}"
|
||||
args_left=("${args_left[@]:1}")
|
||||
for flag in "${repo_flags[@]}"; do
|
||||
value=""
|
||||
case "$arg" in
|
||||
"$flag")
|
||||
# The value is the next argument, and its absence is
|
||||
# the CLI's error to report rather than this one's.
|
||||
[ "${#args_left[@]}" -gt 0 ] || continue
|
||||
value="${args_left[0]}"
|
||||
;;
|
||||
"$flag"=*) value="${arg#*=}" ;;
|
||||
*)
|
||||
# One-dash flags only; a two-dash flag carries its
|
||||
# value with `=` or as the next argument, both above.
|
||||
case "$flag" in
|
||||
-?) ;;
|
||||
*) continue ;;
|
||||
esac
|
||||
# Short flags cluster, so the letter can sit anywhere
|
||||
# in one: `-Rowner/name`, `-wR owner/name` and
|
||||
# `-wRowner/name` all name a repository and none of
|
||||
# them is `-R` as its own argument (#243). Split at the
|
||||
# letter the way getopt does - what follows it in the
|
||||
# same argument is the value, and nothing following it
|
||||
# means the next argument is.
|
||||
letter="${flag#-}"
|
||||
# A cluster, not a value that happens to start with a
|
||||
# dash: one dash, then the letter somewhere in a run of
|
||||
# letters. Without the second check, `--search foo-R/x`
|
||||
# style arguments would be mined for flag letters.
|
||||
case "$arg" in -[!-]*) ;; *) continue ;; esac
|
||||
rest="${arg#-}"
|
||||
case "$rest" in *"$letter"*) ;; *) continue ;; esac
|
||||
case "${rest%%"$letter"*}" in *[!A-Za-z]*) continue ;; esac
|
||||
value="${rest#*"$letter"}"
|
||||
if [ -z "$value" ]; then
|
||||
[ "${#args_left[@]}" -gt 0 ] || continue
|
||||
value="${args_left[0]}"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
[ -n "$value" ] || continue
|
||||
same_repo "$value" || refuse_repo "$flag" "$value"
|
||||
done
|
||||
done
|
||||
fi
|
||||
|
||||
# A repository named in a path rather than in a flag - `gh api
|
||||
# repos/<owner>/<name>/issues` reaches any repository the token does,
|
||||
# without a --repo anywhere in it (#243).
|
||||
#
|
||||
# Only when the declared subcommand is argv's first word. The alternative
|
||||
# is inspecting every positional that contains two slashes, which would
|
||||
# refuse `gh issue create --title repos/a/b` - and a false refusal costs an
|
||||
# honest agent a working command, which is the wrong way for a fast-fail to
|
||||
# be wrong.
|
||||
if [ -n "${DEJIMA_FORGE_REPO_PATHS:-}" ] && [ "$#" -gt 0 ]; then
|
||||
read -ra repo_paths <<< "$DEJIMA_FORGE_REPO_PATHS"
|
||||
for spec in "${repo_paths[@]}"; do
|
||||
[ "$1" = "${spec%%:*}" ] || continue
|
||||
segment="${spec#*:}"
|
||||
for arg in "$@"; do
|
||||
case "$arg" in -*) continue ;; esac
|
||||
# gh substitutes {owner} and {repo} from the checkout, so a
|
||||
# path carrying them names this repository by construction and
|
||||
# comparing the literal braces would refuse it.
|
||||
case "$arg" in *'{'*) continue ;; esac
|
||||
path="${arg#/}"
|
||||
case "$path" in "$segment"/*/*) ;; *) continue ;; esac
|
||||
path="${path#"$segment"/}"
|
||||
owner="${path%%/*}"
|
||||
path="${path#*/}"
|
||||
name="${path%%/*}"
|
||||
[ -n "$owner" ] && [ -n "$name" ] || continue
|
||||
same_repo "$owner/$name" \
|
||||
|| refuse_repo "the path '$arg'" "$owner/$name"
|
||||
done
|
||||
done
|
||||
fi
|
||||
if ! dejima_forge_verdict "$self" "$@"; then
|
||||
echo "Blocked by dejima's forge guard: $DEJIMA_GUARD_REASON" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- the authorship disclosure ---------------------------------------------
|
||||
@@ -242,13 +75,18 @@ marker_for() {
|
||||
esac
|
||||
}
|
||||
|
||||
# The non-flag arguments the records below are compared against. Set here
|
||||
# rather than relied on from the verdict above, so this section says what it
|
||||
# depends on.
|
||||
dejima_forge_plain "$@"
|
||||
|
||||
kind=""
|
||||
while read -r record_kind record_rest; do
|
||||
[ -n "$record_kind" ] || continue
|
||||
read -ra words <<< "$record_rest"
|
||||
[ "${#words[@]}" -ge 1 ] || continue
|
||||
[ "${words[0]}" = "$self" ] || continue
|
||||
if argv_has "${words[@]:1}"; then
|
||||
if dejima_argv_has "${words[@]:1}"; then
|
||||
kind="$record_kind"
|
||||
break
|
||||
fi
|
||||
|
||||
+17
-129
@@ -1,20 +1,20 @@
|
||||
#!/bin/bash
|
||||
# Shadows git on PATH so `git push` is checked whatever ran it.
|
||||
#
|
||||
# The rules are the ones hooks/git-push-guard.sh applies - push only to the
|
||||
# forge's own hosts, never straight to a blocked branch - moved off the
|
||||
# harness's hook protocol and onto the command itself. Two things follow from
|
||||
# that. It works for a harness with no way to intercept a tool call, which is
|
||||
# most of them. And it reads the decision out of argv rather than out of a
|
||||
# regex over a reconstructed command string, so `git push`, `git -c k=v
|
||||
# push` and a push buried in a pipeline are all just argv here.
|
||||
# The rules are in docker/guard-lib.sh, which hooks/git-push-guard.sh applies
|
||||
# to the same push from the other side - push only to the forge's own hosts,
|
||||
# never straight to a blocked branch. Here they sit on the command itself,
|
||||
# which means two things. It works for a harness with no way to intercept a
|
||||
# tool call, which is most of them. And it reads the decision out of argv
|
||||
# rather than out of a regex over a reconstructed command string, so
|
||||
# `git push`, `git -c k=v push` and a push buried in a pipeline are all just
|
||||
# argv here.
|
||||
#
|
||||
# It is a fast-fail guard, not a boundary, and the distinction is the same one
|
||||
# docs/security.md draws about the hook it replaces: a session that means to
|
||||
# get round it can call /usr/bin/git directly, exactly as it could have used a
|
||||
# tool the hook did not cover. What stops a push reaching somewhere it should
|
||||
# not is the firewall, and what stops it being accepted is the forge. This
|
||||
# stops an eager agent doing the obvious thing by accident.
|
||||
# docs/security.md draws about the hook beside it: a session that means to get
|
||||
# round it can call /usr/bin/git directly, exactly as it could have used a tool
|
||||
# the hook did not cover. What stops a push reaching somewhere it should not is
|
||||
# the firewall, and what stops it being accepted is the forge.
|
||||
#
|
||||
# Everything it needs is already in the container's environment, resolved on
|
||||
# the host from the forge and workflow manifests (see bin/dejima):
|
||||
@@ -32,125 +32,13 @@ if [ -z "${DEJIMA:-}" ]; then
|
||||
exec "$REAL_GIT" "$@"
|
||||
fi
|
||||
|
||||
# Find the subcommand, and -C if it is there. git's own global options come
|
||||
# before the subcommand and several take a value, so the ones that do are
|
||||
# skipped in pairs rather than assumed to be attached.
|
||||
repo_dir="$PWD"
|
||||
subcommand=""
|
||||
argv=("$@")
|
||||
# Every `-c key=value` this push carries, kept rather than only skipped. The
|
||||
# question below is where the push will go, and git answers that from the
|
||||
# config the push runs under - so resolving it without these would answer for
|
||||
# a different configuration than the one about to be used. A one-command
|
||||
# `-c remote.origin.pushurl=...` is exactly that case.
|
||||
config_overrides=()
|
||||
i=0
|
||||
while [ "$i" -lt "${#argv[@]}" ]; do
|
||||
case "${argv[$i]}" in
|
||||
-C)
|
||||
i=$((i + 1))
|
||||
[ "$i" -lt "${#argv[@]}" ] && repo_dir="${argv[$i]}"
|
||||
;;
|
||||
-c)
|
||||
i=$((i + 1))
|
||||
[ "$i" -lt "${#argv[@]}" ] \
|
||||
&& config_overrides+=(-c "${argv[$i]}")
|
||||
;;
|
||||
--exec-path|--git-dir|--work-tree|--namespace|--super-prefix)
|
||||
i=$((i + 1))
|
||||
;;
|
||||
-*)
|
||||
;;
|
||||
*)
|
||||
subcommand="${argv[$i]}"
|
||||
break
|
||||
;;
|
||||
esac
|
||||
i=$((i + 1))
|
||||
done
|
||||
DEJIMA_GUARD_GIT="$REAL_GIT"
|
||||
# shellcheck source=guard-lib.sh
|
||||
source "${DEJIMA_GUARD_LIB:-/usr/local/lib/dejima/guard-lib.sh}"
|
||||
|
||||
[ "$subcommand" = "push" ] || exec "$REAL_GIT" "$@"
|
||||
|
||||
# What comes after `push`: the first two non-flag words are the remote and the
|
||||
# refspec. Flags that take a value are skipped in pairs for the same reason as
|
||||
# above - `git push --repo origin main` must not read "origin" as the remote.
|
||||
# --repo names the remote in git's own legacy form and wins over a positional,
|
||||
# so it is read rather than skipped: reading it as an opaque flag would leave
|
||||
# `git push --repo=origin main` looking like a push to a remote called "main",
|
||||
# which resolves to no URL - and passing that through unchecked is a push to
|
||||
# origin/main that nothing looked at.
|
||||
remote=""
|
||||
refspec=""
|
||||
i=$((i + 1))
|
||||
while [ "$i" -lt "${#argv[@]}" ]; do
|
||||
case "${argv[$i]}" in
|
||||
--repo=*)
|
||||
remote="${argv[$i]#--repo=}"
|
||||
;;
|
||||
--repo)
|
||||
i=$((i + 1))
|
||||
[ "$i" -lt "${#argv[@]}" ] && remote="${argv[$i]}"
|
||||
;;
|
||||
-o|--push-option|--receive-pack|--exec)
|
||||
i=$((i + 1))
|
||||
;;
|
||||
-*)
|
||||
;;
|
||||
*)
|
||||
if [ -z "$remote" ]; then
|
||||
remote="${argv[$i]}"
|
||||
elif [ -z "$refspec" ]; then
|
||||
refspec="${argv[$i]}"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
i=$((i + 1))
|
||||
done
|
||||
[ -n "$remote" ] || remote=origin
|
||||
|
||||
# --push, because that is the URL the push will use. Without it this reads
|
||||
# remote.<name>.url while git pushes to remote.<name>.pushurl when one is set,
|
||||
# so a pushurl naming any other host is checked against the fetch URL and
|
||||
# allowed. It needs no evasion to reach: `git -c remote.origin.pushurl=...
|
||||
# push origin branch` sets one for a single command, and .git/config is in the
|
||||
# read-write project mount, so a session can also just write one down.
|
||||
remote_url="$("$REAL_GIT" ${config_overrides[@]+"${config_overrides[@]}"} \
|
||||
-C "$repo_dir" remote get-url --push "$remote" 2>/dev/null)"
|
||||
# A remote this cannot resolve is refused rather than passed through. It is
|
||||
# usually a typo, which git would reject in a moment anyway - but it is also
|
||||
# what a disagreement between this parser and git's looks like, and the
|
||||
# failure of that disagreement has to be a refusal rather than a push nobody
|
||||
# checked.
|
||||
if [ -z "$remote_url" ]; then
|
||||
echo "Blocked by dejima's git guard: cannot resolve the remote '$remote' to a URL," >&2
|
||||
echo "so there is nothing to check this push against. Name a remote that exists." >&2
|
||||
if ! dejima_push_verdict "$PWD" "$@"; then
|
||||
echo "Blocked by dejima's git guard: $DEJIMA_GUARD_REASON" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
host="$(printf '%s' "$remote_url" \
|
||||
| sed -E 's#^[a-zA-Z]+://([^/@]+@)?([^/:]+).*#\2#; s#^[^@/]+@([^:/]+)[:/].*#\1#')"
|
||||
|
||||
host_allowed=0
|
||||
for allowed_host in ${DEJIMA_PUSH_HOSTS:-}; do
|
||||
[ "$host" = "$allowed_host" ] && { host_allowed=1; break; }
|
||||
done
|
||||
if [ "$host_allowed" != 1 ]; then
|
||||
echo "Blocked by dejima's git guard: this sandbox may only push to ${DEJIMA_FORGE_NAME:-the configured forge}'s allowed host(s) (${DEJIMA_PUSH_HOSTS:-none allowed}), not '$host'." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -n "$refspec" ]; then
|
||||
branch="${refspec##*:}"
|
||||
else
|
||||
branch="$("$REAL_GIT" -C "$repo_dir" branch --show-current 2>/dev/null)"
|
||||
fi
|
||||
branch="${branch#refs/heads/}"
|
||||
|
||||
for blocked_branch in ${DEJIMA_BLOCKED_BRANCHES:-}; do
|
||||
if [ "$branch" = "$blocked_branch" ]; then
|
||||
echo "Blocked by dejima's git guard: direct pushes to '$branch' aren't allowed in the sandbox. Push a feature branch and open a PR/MR instead." >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
exec "$REAL_GIT" "$@"
|
||||
|
||||
@@ -0,0 +1,563 @@
|
||||
#!/bin/bash
|
||||
# The push and merge policy, once, for the two layers that apply it.
|
||||
#
|
||||
# Each rule used to exist twice: as a PATH shadow over the command
|
||||
# (docker/git-guard.sh, docker/forge-guard.sh) and as a PreToolUse hook over
|
||||
# the command string a harness reports (hooks/git-push-guard.sh,
|
||||
# hooks/merge-guard.sh). The two overlap deliberately - a hook sees
|
||||
# `/usr/bin/git push`, which walks past the shadow, and a shadow sees a push
|
||||
# from an editor integration or a script, which the harness never reports - so
|
||||
# neither can be dropped. What they cannot do is disagree, and they had already
|
||||
# drifted: `git push -o x origin main` was fine to one and a hard refusal from
|
||||
# the other, for the same invocation in the same session.
|
||||
#
|
||||
# So the decision lives here and both call it. A bypass is then fixed once.
|
||||
#
|
||||
# Every function reads argv, never a command string. A hook holds a string, so
|
||||
# it splits it into segments and words first (dejima_guard_segments) and hands
|
||||
# the words over - which is also why the hooks inherit the shadows' indifference
|
||||
# to spacing, quoting and pipelines.
|
||||
#
|
||||
# None of this is a boundary, and the distinction is the one docs/security.md
|
||||
# draws: a session that means to get round these can call /usr/bin/git or the
|
||||
# forge API directly. What stops a push reaching somewhere it should not is the
|
||||
# firewall, and what stops it being accepted is the forge's token scope. These
|
||||
# stop an eager agent doing the obvious thing by accident.
|
||||
#
|
||||
# Sourced, so nothing here exits: a refusal is a return of 1 with the reason in
|
||||
# DEJIMA_GUARD_REASON. The caller supplies its own prefix, because an operator
|
||||
# reading a refusal needs to know which layer spoke.
|
||||
|
||||
# The real git, never the shadow. The shadow passes a non-push through, so
|
||||
# calling it would work - but it would also mean every question this asks git
|
||||
# goes through a script that is asking git questions.
|
||||
DEJIMA_GUARD_GIT="${DEJIMA_GUARD_GIT:-/usr/bin/git}"
|
||||
|
||||
DEJIMA_GUARD_REASON=""
|
||||
|
||||
# --- splitting a command string into git/forge invocations -------------------
|
||||
|
||||
# dejima_guard_segments <command string>
|
||||
#
|
||||
# One segment per line. A guarded command can be the second half of a compound
|
||||
# one, and only a segment whose first word is the command in question is that
|
||||
# command - `echo push` is not a push.
|
||||
#
|
||||
# Word-splitting rather than a full shell parse, so quoting edge cases are not
|
||||
# handled. That is the same pragmatic tradeoff as the sibling
|
||||
# deny-dangerous-bash.sh denylist, and the reason every rule below refuses
|
||||
# rather than guesses when it cannot read what it is looking at.
|
||||
# The trailing newline is load-bearing: without it `read` returns false on the
|
||||
# last segment even though it read one, so a command with no separators in it -
|
||||
# every ordinary push - was never looked at.
|
||||
dejima_guard_segments() {
|
||||
printf '%s\n' "$1" | sed 's/&&/\n/g; s/||/\n/g; s/;/\n/g; s/|/\n/g'
|
||||
}
|
||||
|
||||
# --- git push ---------------------------------------------------------------
|
||||
|
||||
# Where a push will go and which branches it will write, read out of argv the
|
||||
# way git reads it. Sets DEJIMA_GUARD_PUSH=1 when the argv is a push at all.
|
||||
#
|
||||
# git's own global options come before the subcommand and several take a
|
||||
# separate value, so those are skipped in pairs rather than assumed attached.
|
||||
#
|
||||
# `-c key=value` is kept rather than skipped: git resolves where a push goes
|
||||
# from the config the push runs under, so asking without these answers for a
|
||||
# different configuration than the one about to be used. A one-command
|
||||
# `-c remote.origin.pushurl=...` is exactly that case.
|
||||
#
|
||||
# --git-dir and --work-tree are REFUSED rather than skipped or honoured. Both
|
||||
# used to be consumed and ignored, so `git --git-dir /tmp/x/.git push origin
|
||||
# main` resolved `origin` in the mounted project, found it allowed, and then
|
||||
# ran against a repository whose origin points somewhere else. Honouring them
|
||||
# would mean reimplementing how they combine with -C and with each other;
|
||||
# refusing is the direction the unresolvable-remote case below already takes,
|
||||
# and a session with a second repository to push from can cd into it or use -C.
|
||||
dejima_push_verdict() { # <repo-dir> <git argv...>
|
||||
local repo_dir="$1"; shift
|
||||
local argv=("$@") i=0 subcommand="" branch
|
||||
local -a config_overrides=() refspecs=()
|
||||
local remote="" repo_flag="" push_all=0 push_mirror=0 repo_override=0
|
||||
DEJIMA_GUARD_REASON=""
|
||||
DEJIMA_GUARD_PUSH=0
|
||||
|
||||
while [ "$i" -lt "${#argv[@]}" ]; do
|
||||
case "${argv[$i]}" in
|
||||
-C)
|
||||
i=$((i + 1))
|
||||
[ "$i" -lt "${#argv[@]}" ] && repo_dir="${argv[$i]}"
|
||||
;;
|
||||
-c)
|
||||
i=$((i + 1))
|
||||
[ "$i" -lt "${#argv[@]}" ] \
|
||||
&& config_overrides+=(-c "${argv[$i]}")
|
||||
;;
|
||||
--git-dir|--work-tree)
|
||||
repo_override=1
|
||||
i=$((i + 1))
|
||||
;;
|
||||
--git-dir=*|--work-tree=*)
|
||||
repo_override=1
|
||||
;;
|
||||
--exec-path|--namespace|--super-prefix)
|
||||
i=$((i + 1))
|
||||
;;
|
||||
-*)
|
||||
;;
|
||||
*)
|
||||
subcommand="${argv[$i]}"
|
||||
break
|
||||
;;
|
||||
esac
|
||||
i=$((i + 1))
|
||||
done
|
||||
|
||||
[ "$subcommand" = push ] || return 0
|
||||
DEJIMA_GUARD_PUSH=1
|
||||
|
||||
# Noted while parsing and refused here, so that only a push is refused:
|
||||
# these say nothing about any other git command, and a guard that refused
|
||||
# `git --git-dir X status` would be refusing the flag rather than the push.
|
||||
if [ "$repo_override" = 1 ]; then
|
||||
DEJIMA_GUARD_REASON="a push carrying --git-dir or --work-tree cannot be checked, because the repository it writes from is not the one this would resolve the remote in. Run it from that repository, or name it with -C."
|
||||
return 1
|
||||
fi
|
||||
|
||||
# What comes after `push`. --repo names the remote in git's own legacy form
|
||||
# and is read rather than treated as an opaque flag, because skipping it
|
||||
# would leave `git push --repo=origin main` looking like a bare push and
|
||||
# resolve a remote the command never named.
|
||||
#
|
||||
# It LOSES to a positional, which is what git documents: "this option is
|
||||
# equivalent to the <repository> argument. If both are specified, the
|
||||
# command-line argument takes precedence." So in `git push --repo=origin
|
||||
# other-remote feature` the remote is other-remote, and checking origin
|
||||
# instead would approve a host this push never goes to.
|
||||
#
|
||||
# Every positional after the remote is a refspec, not just the first.
|
||||
# `git push origin feature main` checked `feature` alone and let `main`
|
||||
# through beside it.
|
||||
i=$((i + 1))
|
||||
while [ "$i" -lt "${#argv[@]}" ]; do
|
||||
case "${argv[$i]}" in
|
||||
--repo=*) repo_flag="${argv[$i]#--repo=}" ;;
|
||||
--repo)
|
||||
i=$((i + 1))
|
||||
[ "$i" -lt "${#argv[@]}" ] && repo_flag="${argv[$i]}"
|
||||
;;
|
||||
--all) push_all=1 ;;
|
||||
--mirror) push_mirror=1 ;;
|
||||
-o|--push-option|--receive-pack|--exec)
|
||||
i=$((i + 1))
|
||||
;;
|
||||
-*) ;;
|
||||
*)
|
||||
if [ -z "$remote" ]; then
|
||||
remote="${argv[$i]}"
|
||||
else
|
||||
refspecs+=("${argv[$i]}")
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
i=$((i + 1))
|
||||
done
|
||||
[ -n "$remote" ] || remote="$repo_flag"
|
||||
[ -n "$remote" ] || remote=origin
|
||||
|
||||
# --push, because that is the URL the push will use. Without it this reads
|
||||
# remote.<name>.url while git pushes to remote.<name>.pushurl when one is
|
||||
# set, so a pushurl naming any other host is checked against the fetch URL
|
||||
# and allowed. It needs no evasion to reach: `git -c
|
||||
# remote.origin.pushurl=... push origin branch` sets one for a single
|
||||
# command, and .git/config is in the read-write project mount, so a session
|
||||
# can also just write one down.
|
||||
local remote_url host
|
||||
remote_url="$("$DEJIMA_GUARD_GIT" ${config_overrides[@]+"${config_overrides[@]}"} \
|
||||
-C "$repo_dir" remote get-url --push "$remote" 2>/dev/null)"
|
||||
if [ -z "$remote_url" ]; then
|
||||
# A remote this cannot resolve is refused rather than passed through.
|
||||
# It is usually a typo, which git would reject in a moment anyway - but
|
||||
# it is also what a disagreement between this parser and git's looks
|
||||
# like, and the failure of that disagreement has to be a refusal rather
|
||||
# than a push nobody checked.
|
||||
DEJIMA_GUARD_REASON="cannot resolve the remote '$remote' to a URL, so there is nothing to check this push against. Name a remote that exists."
|
||||
return 1
|
||||
fi
|
||||
|
||||
host="$(printf '%s' "$remote_url" \
|
||||
| sed -E 's#^[a-zA-Z]+://([^/@]+@)?([^/:]+).*#\2#; s#^[^@/]+@([^:/]+)[:/].*#\1#')"
|
||||
|
||||
local allowed_host host_allowed=0
|
||||
for allowed_host in ${DEJIMA_PUSH_HOSTS:-}; do
|
||||
[ "$host" = "$allowed_host" ] && { host_allowed=1; break; }
|
||||
done
|
||||
if [ "$host_allowed" != 1 ]; then
|
||||
DEJIMA_GUARD_REASON="this sandbox may only push to ${DEJIMA_FORGE_NAME:-the configured forge}'s allowed host(s) (${DEJIMA_PUSH_HOSTS:-none allowed}), not '$host'."
|
||||
return 1
|
||||
fi
|
||||
|
||||
# --mirror puts every ref under refs/ on the remote AND deletes the ones
|
||||
# that are gone locally, so what it does to a protected branch cannot be
|
||||
# read off the command: a mirror from a checkout that has no `main` deletes
|
||||
# the remote's. Refused outright rather than resolved, because there is no
|
||||
# branch list that makes it safe.
|
||||
if [ "$push_mirror" = 1 ]; then
|
||||
DEJIMA_GUARD_REASON="--mirror writes every ref and deletes the remote refs this checkout does not have, so it cannot be checked against a branch policy. Push the branch you mean."
|
||||
return 1
|
||||
fi
|
||||
|
||||
# --all pushes every local branch, so every local branch is what the policy
|
||||
# has to be applied to. The refspec list is empty here - git refuses --all
|
||||
# with one - and reading only the current branch is what let `git push
|
||||
# --all origin` from a feature branch push `main` beside it.
|
||||
if [ "$push_all" = 1 ]; then
|
||||
local local_branch
|
||||
while IFS= read -r local_branch; do
|
||||
[ -n "$local_branch" ] || continue
|
||||
if _dejima_branch_blocked "$local_branch"; then
|
||||
DEJIMA_GUARD_REASON="--all pushes every local branch, and '$local_branch' is one of them. Direct pushes to '$local_branch' aren't allowed in the sandbox. Push a feature branch and open a PR/MR instead."
|
||||
return 1
|
||||
fi
|
||||
done < <("$DEJIMA_GUARD_GIT" -C "$repo_dir" for-each-ref \
|
||||
--format='%(refname:short)' refs/heads/ 2>/dev/null)
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Each refspec's destination. `src:dst` names it outright, a bare name is
|
||||
# both, and `HEAD` or no refspec at all means the current branch - `git
|
||||
# push origin` and `git push origin HEAD` both push it.
|
||||
if [ "${#refspecs[@]}" -eq 0 ]; then
|
||||
refspecs=(HEAD)
|
||||
fi
|
||||
local refspec
|
||||
for refspec in "${refspecs[@]}"; do
|
||||
# One leading `+` is the force marker and belongs to the refspec rather
|
||||
# than to the branch, so it comes off before the destination is read -
|
||||
# `git push origin +main` names main, and the force spelling is the
|
||||
# destructive one to be wrong about.
|
||||
refspec="${refspec#+}"
|
||||
case "$refspec" in
|
||||
*:*) branch="${refspec##*:}" ;;
|
||||
HEAD) branch="$("$DEJIMA_GUARD_GIT" -C "$repo_dir" branch --show-current 2>/dev/null)" ;;
|
||||
*) branch="$refspec" ;;
|
||||
esac
|
||||
# A destination that does not start with refs/ is matched against the
|
||||
# remote's refs by git's rev-parse rules, so `heads/main` and `main`
|
||||
# both name refs/heads/main. Stripping only the full prefix left
|
||||
# `feature:heads/main` naming a branch no policy entry matched.
|
||||
branch="${branch#refs/}"
|
||||
branch="${branch#heads/}"
|
||||
if _dejima_branch_blocked "$branch"; then
|
||||
DEJIMA_GUARD_REASON="direct pushes to '$branch' aren't allowed in the sandbox. Push a feature branch and open a PR/MR instead."
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# DEJIMA_BLOCKED_BRANCHES fails OPEN: empty means no branch is protected, which
|
||||
# is correct for a workflow that genuinely has no branch policy. Unlike
|
||||
# DEJIMA_PUSH_HOSTS above, which fails CLOSED - empty means no host is allowed,
|
||||
# so every push is refused, and that is how a no-forge or non-pushing workflow
|
||||
# becomes enforced rather than merely described.
|
||||
_dejima_branch_blocked() {
|
||||
local blocked
|
||||
[ -n "${1:-}" ] || return 1
|
||||
for blocked in ${DEJIMA_BLOCKED_BRANCHES:-}; do
|
||||
[ "$1" = "$blocked" ] && return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# --- the forge CLI ----------------------------------------------------------
|
||||
|
||||
# dejima_argv_has <word>... - true when those words appear consecutively among
|
||||
# the non-flag arguments of the command being checked. _dejima_plain must be
|
||||
# set first, by dejima_forge_verdict.
|
||||
#
|
||||
# Anywhere in that run rather than at the start of it, because a flag's value
|
||||
# is indistinguishable from a subcommand here: in `gh --repo o/r pr merge 7`
|
||||
# the first non-flag argument is `o/r`, and requiring the match to begin there
|
||||
# let the most ordinary way to name a PR from outside its checkout walk
|
||||
# straight past the block. Knowing which flags take a value would mean knowing
|
||||
# each CLI, which is the coupling this guard exists to avoid.
|
||||
#
|
||||
# The cost is a command that happens to pass the words as consecutive values -
|
||||
# `--title pr --body merge` - matching. For the merge block that is a loud
|
||||
# refusal against a merge going through unnoticed; for a disclosure it is a
|
||||
# line of text on something an agent did write anyway. Both are the direction
|
||||
# to be wrong in.
|
||||
dejima_argv_has() {
|
||||
local want=("$@") n="${#_dejima_plain[@]}" m="$#" i=0 k
|
||||
[ "$m" -gt 0 ] || return 1
|
||||
while [ "$((i + m))" -le "$n" ]; do
|
||||
k=0
|
||||
while [ "$k" -lt "$m" ] && [ "${_dejima_plain[$((i + k))]}" = "${want[$k]}" ]; do
|
||||
k=$((k + 1))
|
||||
done
|
||||
[ "$k" -lt "$m" ] || return 0
|
||||
i=$((i + 1))
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# dejima_forge_plain <argv...> - fills _dejima_plain with the non-flag
|
||||
# arguments, which is what every rule below compares against.
|
||||
dejima_forge_plain() {
|
||||
local arg
|
||||
_dejima_plain=()
|
||||
for arg in "$@"; do
|
||||
case "$arg" in -*) continue ;; esac
|
||||
_dejima_plain+=("$arg")
|
||||
done
|
||||
}
|
||||
|
||||
# dejima_forge_commands - every CLI this policy applies to, one per line: the
|
||||
# one whose merge a workflow blocks, and every one that writes a body the
|
||||
# session authored. More than one, because a forge can have a CLI per artifact
|
||||
# rather than one with subcommands, and because a forge with nothing blocked
|
||||
# can still have something to disclose.
|
||||
#
|
||||
# docker/entrypoint.sh links the shadow over exactly this set, and
|
||||
# hooks/merge-guard.sh checks a command against it before applying anything -
|
||||
# without that the hook would apply gh's --repo rule to any command carrying
|
||||
# that flag, which the shadow cannot do because it only exists for these.
|
||||
dejima_forge_commands() {
|
||||
[ -z "${DEJIMA_MERGE_BLOCK:-}" ] || printf '%s\n' "${DEJIMA_MERGE_BLOCK%% *}"
|
||||
# "<kind> <cli> <subcommand>...", so the CLI is the second word.
|
||||
[ -z "${DEJIMA_DISCLOSE:-}" ] || printf '%s\n' "$DEJIMA_DISCLOSE" |
|
||||
while read -r _ _cli _; do
|
||||
[ -z "$_cli" ] || printf '%s\n' "$_cli"
|
||||
done
|
||||
}
|
||||
|
||||
# dejima_forge_verdict <command name> <argv...>
|
||||
#
|
||||
# The command name is argv[0] as the caller saw it - the shadow's own basename,
|
||||
# or the first word of a hook's segment. Every policy here is keyed to it,
|
||||
# because a block naming a different command than the one being run is not this
|
||||
# invocation's business.
|
||||
dejima_forge_verdict() {
|
||||
local self="$1"; shift
|
||||
DEJIMA_GUARD_REASON=""
|
||||
dejima_forge_plain "$@"
|
||||
|
||||
if [ -n "${DEJIMA_MERGE_BLOCK:-}" ]; then
|
||||
local -a blocked
|
||||
read -ra blocked <<< "$DEJIMA_MERGE_BLOCK"
|
||||
if [ "${#blocked[@]}" -ge 1 ] && [ "${blocked[0]}" = "$self" ]; then
|
||||
if [ "${#blocked[@]}" -eq 1 ]; then
|
||||
# The whole command is blocked, not one of its subcommands.
|
||||
DEJIMA_GUARD_REASON="'$self' is not available in the sandbox."
|
||||
return 1
|
||||
fi
|
||||
if dejima_argv_has "${blocked[@]:1}"; then
|
||||
DEJIMA_GUARD_REASON="merging is a human decision in the sandbox - open a PR/MR, then the operator merges. Report its URL and stop."
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
_dejima_forge_repo_verdict "$@" || return 1
|
||||
return 0
|
||||
}
|
||||
|
||||
# The repository the session was launched against.
|
||||
#
|
||||
# A forge whose CLI can be aimed elsewhere declares how (repo_flags, repo_env,
|
||||
# repo_paths). The gitea CLIs declare none, because they resolve the repo from
|
||||
# origin and refuse another host - which leaves nothing here to compare, not
|
||||
# that they are confined to one repository: origin is session-writable. `gh`
|
||||
# declares all three, so on that path the
|
||||
# most ordinary way to name a PR from outside its checkout is also the way to
|
||||
# file an issue on a repository the session has nothing to do with (#187).
|
||||
#
|
||||
# Refused rather than skipped when the launch resolved no repository at all: a
|
||||
# workspace with no origin has nothing for --repo to legitimately name, and
|
||||
# "cannot confirm" is not a reason to let it through.
|
||||
_dejima_forge_repo_verdict() {
|
||||
[ -n "${DEJIMA_FORGE_REPO_FLAGS:-}" ] || [ -n "${DEJIMA_FORGE_REPO_ENV:-}" ] \
|
||||
|| [ -n "${DEJIMA_FORGE_REPO_PATHS:-}" ] || return 0
|
||||
|
||||
if [ -n "${DEJIMA_FORGE_REPO_ENV:-}" ]; then
|
||||
local env_value="${!DEJIMA_FORGE_REPO_ENV:-}"
|
||||
if [ -n "$env_value" ] && ! _dejima_same_repo "$env_value"; then
|
||||
_dejima_refuse_repo "$DEJIMA_FORGE_REPO_ENV" "$env_value"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -n "${DEJIMA_FORGE_REPO_FLAGS:-}" ]; then
|
||||
local -a repo_flags args_left
|
||||
read -ra repo_flags <<< "$DEJIMA_FORGE_REPO_FLAGS"
|
||||
args_left=("$@")
|
||||
local arg flag value letter rest
|
||||
while [ "${#args_left[@]}" -gt 0 ]; do
|
||||
arg="${args_left[0]}"
|
||||
args_left=("${args_left[@]:1}")
|
||||
for flag in "${repo_flags[@]}"; do
|
||||
value=""
|
||||
case "$arg" in
|
||||
"$flag")
|
||||
# The value is the next argument, and its absence is
|
||||
# the CLI's error to report rather than this one's.
|
||||
[ "${#args_left[@]}" -gt 0 ] || continue
|
||||
value="${args_left[0]}"
|
||||
;;
|
||||
"$flag"=*) value="${arg#*=}" ;;
|
||||
*)
|
||||
# One-dash flags only; a two-dash flag carries its
|
||||
# value with `=` or as the next argument, both above.
|
||||
case "$flag" in
|
||||
-?) ;;
|
||||
*) continue ;;
|
||||
esac
|
||||
# Short flags cluster, so the letter can sit anywhere
|
||||
# in one: `-Rowner/name`, `-wR owner/name` and
|
||||
# `-wRowner/name` all name a repository and none of
|
||||
# them is `-R` as its own argument (#243). Split at the
|
||||
# letter the way getopt does - what follows it in the
|
||||
# same argument is the value, and nothing following it
|
||||
# means the next argument is.
|
||||
letter="${flag#-}"
|
||||
# A cluster, not a value that happens to start with a
|
||||
# dash: one dash, then the letter somewhere in a run of
|
||||
# letters. Without the second check, `--search foo-R/x`
|
||||
# style arguments would be mined for flag letters.
|
||||
case "$arg" in -[!-]*) ;; *) continue ;; esac
|
||||
rest="${arg#-}"
|
||||
case "$rest" in *"$letter"*) ;; *) continue ;; esac
|
||||
case "${rest%%"$letter"*}" in *[!A-Za-z]*) continue ;; esac
|
||||
value="${rest#*"$letter"}"
|
||||
if [ -z "$value" ]; then
|
||||
[ "${#args_left[@]}" -gt 0 ] || continue
|
||||
value="${args_left[0]}"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
[ -n "$value" ] || continue
|
||||
if ! _dejima_same_repo "$value"; then
|
||||
_dejima_refuse_repo "$flag" "$value"
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
done
|
||||
fi
|
||||
|
||||
_dejima_forge_path_verdict "$@" || return 1
|
||||
return 0
|
||||
}
|
||||
|
||||
# A repository named in a path rather than in a flag - `gh api
|
||||
# repos/<owner>/<name>/issues` reaches any repository the token does, without a
|
||||
# --repo anywhere in it (#243).
|
||||
#
|
||||
# Only when the declared subcommand is argv's first word. The alternative is
|
||||
# inspecting every positional that contains two slashes, which would refuse
|
||||
# `gh issue create --title repos/a/b` - and a false refusal costs an honest
|
||||
# agent a working command, which is the wrong way for a fast-fail to be wrong.
|
||||
#
|
||||
# Under that subcommand the rule is inverted: rather than looking for a path
|
||||
# naming the wrong repository, it requires one naming the right one. `gh api`
|
||||
# reaches every endpoint the token does, and an endpoint that is not under the
|
||||
# declared segment - `user/repos`, `/orgs/<org>/repos`, `graphql` - names no
|
||||
# repository for this to compare, so there was nothing to refuse and nothing
|
||||
# was (#365). Telling an endpoint apart from a header value or a field value
|
||||
# would mean knowing which of the CLI's flags take a separate value, which is
|
||||
# the coupling this guard exists to avoid - so the question asked is whether a
|
||||
# target it CAN read is present, which needs no such knowledge.
|
||||
#
|
||||
# graphql counts as one, because dejima's own github-pr reads review threads
|
||||
# through it and the API has no other route to them. A mutation does not:
|
||||
# `gh api graphql -f query='mutation{mergePullRequest(...)}'` is what an agent
|
||||
# reaches for once `gh pr merge` refuses, and no repository scoping can be read
|
||||
# out of a GraphQL document. Queries through it can only read, which the
|
||||
# read-only token could do anyway.
|
||||
_dejima_forge_path_verdict() {
|
||||
[ -n "${DEJIMA_FORGE_REPO_PATHS:-}" ] && [ "$#" -gt 0 ] || return 0
|
||||
|
||||
local -a repo_paths
|
||||
read -ra repo_paths <<< "$DEJIMA_FORGE_REPO_PATHS"
|
||||
local spec segment arg path owner name scopable
|
||||
for spec in "${repo_paths[@]}"; do
|
||||
[ "$1" = "${spec%%:*}" ] || continue
|
||||
segment="${spec#*:}"
|
||||
scopable=0
|
||||
for arg in "$@"; do
|
||||
# A GraphQL document whose operation is a mutation. Matched
|
||||
# anywhere in the argument, because the document arrives as the
|
||||
# value of a field flag - `-f query=mutation{...}` - rather than as
|
||||
# an argument of its own. Matched before the brace test below,
|
||||
# which a document would otherwise satisfy.
|
||||
#
|
||||
# Over EVERY argument, including the dash-prefixed ones, because a
|
||||
# flag can carry its value attached: `--field=query=mutation{...}`
|
||||
# and `-Fquery=mutation{...}` put the document inside one word that
|
||||
# the scoping loop below skips. This check needs no flag table to
|
||||
# read it, which is what lets it look at all of them - unlike the
|
||||
# repository scoping, which does.
|
||||
#
|
||||
# Both boundaries are anything that cannot continue an
|
||||
# identifier, rather than whitespace and a brace. GraphQL treats
|
||||
# commas and comments as ignored tokens, so `mutation,{...}` and
|
||||
# `mutation#c` followed by the body are valid documents, and a
|
||||
# document carrying more than one operation puts `}` immediately
|
||||
# before the second one. Each of those sat just outside a narrower
|
||||
# boundary. The class excludes `_` on both sides, so a field called
|
||||
# foo_mutation is not a mutation.
|
||||
if [[ "$arg" =~ (^|[^A-Za-z0-9_])mutation([^A-Za-z0-9_]|$) ]]; then
|
||||
DEJIMA_GUARD_REASON="a GraphQL mutation reaches any repository and any write the token allows, and no repository scoping can be read out of it. Use the subcommands, and leave a merge to the operator."
|
||||
return 1
|
||||
fi
|
||||
# A body this cannot read is a body it cannot tell a query from a
|
||||
# mutation in. Two spellings put one out of reach: a field value
|
||||
# read from a file, and --input, which takes the whole request body
|
||||
# from a file or from stdin. Refused under this subcommand only;
|
||||
# the document is one argument, so passing it inline is the fix.
|
||||
case "$arg" in
|
||||
*=@*|--input|--input=*)
|
||||
DEJIMA_GUARD_REASON="'$1' takes its body from a file or from stdin, so what it sends is not in the command for this to read. Pass the document inline."
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
# Everything from here needs to know which argument is the
|
||||
# endpoint, so the flags go.
|
||||
case "$arg" in -*) continue ;; esac
|
||||
[ "$arg" = graphql ] && { scopable=1; continue; }
|
||||
# gh substitutes {owner} and {repo} from the checkout, so a path
|
||||
# carrying them names this repository by construction and comparing
|
||||
# the literal braces would refuse it.
|
||||
case "$arg" in *'{'*) scopable=1; continue ;; esac
|
||||
path="${arg#/}"
|
||||
case "$path" in "$segment"/*/*) ;; *) continue ;; esac
|
||||
path="${path#"$segment"/}"
|
||||
owner="${path%%/*}"
|
||||
path="${path#*/}"
|
||||
name="${path%%/*}"
|
||||
[ -n "$owner" ] && [ -n "$name" ] || continue
|
||||
if ! _dejima_same_repo "$owner/$name"; then
|
||||
_dejima_refuse_repo "the path '$arg'" "$owner/$name"
|
||||
return 1
|
||||
fi
|
||||
scopable=1
|
||||
done
|
||||
if [ "$scopable" = 0 ]; then
|
||||
DEJIMA_GUARD_REASON="'$1' names no endpoint under '$segment/', so there is nothing here saying which repository this reaches. Name the endpoint as '$segment/${DEJIMA_FORGE_REPO:-<owner>/<name>}/...', or use the subcommands."
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
return 0
|
||||
}
|
||||
|
||||
_dejima_refuse_repo() {
|
||||
DEJIMA_GUARD_REASON="this session was launched against ${DEJIMA_FORGE_REPO:-no repository}, and $1 names '$2'. Act on the repository the sandbox mounts."
|
||||
}
|
||||
|
||||
# Case-insensitively: forges treat an owner and a repository name as
|
||||
# case-preserving but not case-sensitive, and a refusal over capitalisation
|
||||
# would be a false one.
|
||||
_dejima_same_repo() {
|
||||
[ -n "${DEJIMA_FORGE_REPO:-}" ] && [ "${1,,}" = "${DEJIMA_FORGE_REPO,,}" ]
|
||||
}
|
||||
@@ -1,502 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Default-deny egress firewall for the sandbox, allowlisting only GitHub's
|
||||
# published ranges, the fixed/extra domains below, and the Docker host
|
||||
# itself. The overall shape (ipset + iptables, GitHub ranges via api.github.com/meta,
|
||||
# allowlist domains resolved via dig) follows the same idea sketched in
|
||||
# Anthropic's public reference dev container
|
||||
# (https://github.com/anthropics/claude-code/tree/main/.devcontainer), but
|
||||
# everything here - IPv6 blocking, ulogd2 verdict logging, the GitHub
|
||||
# push/pull split, GH_TOKEN auth, and the extra-domains allowlist - is this
|
||||
# project's own.
|
||||
set -Eeuo pipefail
|
||||
IFS=$'\n\t'
|
||||
|
||||
# Nothing here may die quietly. Under `set -e` a failure aborts with no output
|
||||
# and the container simply never appears, which reads as the sandbox being
|
||||
# broken rather than one command failing. Three CI failures went to two
|
||||
# instances of exactly that (#59), neither leaving a line to go on.
|
||||
#
|
||||
# This is the floor; the explicit `exit 1` paths below say more. -E extends it
|
||||
# into functions and subshells, where it would not fire at all.
|
||||
#
|
||||
# A failure inside a command substitution reports twice, from the subshell and
|
||||
# from the containing statement. Worth the noise: for `x=$(a | b)` the first
|
||||
# line is the only thing saying whether it was `a` or `b`.
|
||||
trap 'status=$?; echo "ERROR: init-firewall.sh aborted at line $LINENO (exit $status): $BASH_COMMAND" >&2' ERR
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck source=fixed-domains.sh
|
||||
source "$SCRIPT_DIR/fixed-domains.sh"
|
||||
|
||||
# Docker's embedded-DNS NAT rules live in the same table we're about to
|
||||
# flush - snapshot them first so they can be reinstated afterward.
|
||||
docker_dns_nat_rules=$(iptables-save -t nat | grep "127\.0\.0\.11" || true)
|
||||
|
||||
# Reset to a known-empty state before laying down the real ruleset.
|
||||
iptables -F
|
||||
iptables -X
|
||||
iptables -t nat -F
|
||||
iptables -t nat -X
|
||||
iptables -t mangle -F
|
||||
iptables -t mangle -X
|
||||
ipset destroy allowed-domains 2>/dev/null || true
|
||||
ipset destroy github-domains 2>/dev/null || true
|
||||
|
||||
# Block IPv6 outright - there's no IPv6 equivalent of the allowlist below, so
|
||||
# without this, any host with IPv6 egress enabled would let a container
|
||||
# bypass the entire IPv4-based firewall via an AAAA-resolved address. Only
|
||||
# tolerate ip6tables being entirely unusable (IPv6 disabled at the kernel
|
||||
# level, e.g. no ip6_tables module) - once it's confirmed to work at all, a
|
||||
# failure partway through must fail the whole script (set -e, no more
|
||||
# blanket `|| true`) rather than silently leaving IPv6 wide open.
|
||||
#
|
||||
# The `else` below used to log that the family was presumably off at the
|
||||
# kernel level, and continue. That inferred a fact about the WORLD from a fact
|
||||
# about a TOOL, and the two are not the same condition: a container whose kernel
|
||||
# lacks ip6_tables, on a host with IPv6 enabled, took that branch, logged a
|
||||
# reassuring line, and ran with IPv6 live and unfiltered. Nothing failed,
|
||||
# because the only thing that had happened was a check that could not tell.
|
||||
#
|
||||
# So the branch asks the kernel instead. /proc/net/if_inet6 lists every IPv6
|
||||
# address on every interface, and it is the netns's own view - present with
|
||||
# `::1` alone when the stack is up and nothing is reachable, absent entirely
|
||||
# when IPv6 is compiled out.
|
||||
#
|
||||
# Loopback does not count. Refusing to start over `::1` would refuse every
|
||||
# container that has no IPv6 connectivity at all, which is a false refusal
|
||||
# for the common case. What matters is an address on an interface that leaves
|
||||
# the box.
|
||||
_ipv6_off_box() {
|
||||
[ -e /proc/net/if_inet6 ] || return 1
|
||||
# address ifindex prefixlen scope flags devname
|
||||
awk '$6 != "lo" { found = 1 } END { exit !found }' /proc/net/if_inet6
|
||||
}
|
||||
|
||||
if ip6tables -L >/dev/null 2>&1; then
|
||||
ip6tables -F
|
||||
ip6tables -X
|
||||
ip6tables -P INPUT DROP
|
||||
ip6tables -P OUTPUT DROP
|
||||
ip6tables -P FORWARD DROP
|
||||
elif _ipv6_off_box; then
|
||||
echo "ip6tables is unusable here, and IPv6 is live on an interface that" >&2
|
||||
echo "leaves this container. Turning the family off in this network" >&2
|
||||
echo "namespace, since it cannot be filtered." >&2
|
||||
# Written to /proc directly rather than through sysctl(8), which is one
|
||||
# more thing that has to be in the image for the firewall to hold. Per
|
||||
# interface as well as `all`, because which of those flushes existing
|
||||
# addresses has varied across kernels.
|
||||
for _conf in /proc/sys/net/ipv6/conf/*/disable_ipv6; do
|
||||
[ -w "$_conf" ] || continue
|
||||
echo 1 > "$_conf" 2>/dev/null || true
|
||||
done
|
||||
if _ipv6_off_box; then
|
||||
echo "" >&2
|
||||
echo "IPv6 is still up and there is no way to filter it from here, so" >&2
|
||||
echo "every rule below would be bypassable by an AAAA-resolved address." >&2
|
||||
echo "Refusing to start rather than reporting a boundary that is not" >&2
|
||||
echo "there. Either give the container a usable ip6tables (the" >&2
|
||||
echo "ip6_tables module on the host), or disable IPv6 for its network." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "IPv6 is off in this namespace."
|
||||
else
|
||||
echo "ip6tables unusable, and no IPv6 address outside loopback - nothing to filter"
|
||||
fi
|
||||
|
||||
# Reinsert just the rules captured above - nothing else Docker had set up in
|
||||
# nat comes back, so the reset above stays meaningful.
|
||||
if [ -n "$docker_dns_nat_rules" ]; then
|
||||
echo "Reinstating captured Docker DNS rules"
|
||||
iptables -t nat -N DOCKER_OUTPUT 2>/dev/null || true
|
||||
iptables -t nat -N DOCKER_POSTROUTING 2>/dev/null || true
|
||||
echo "$docker_dns_nat_rules" | xargs -L 1 iptables -t nat
|
||||
else
|
||||
echo "No Docker DNS rules were present to restore"
|
||||
fi
|
||||
|
||||
# Allow localhost. Every DNS query this container makes starts here: to
|
||||
# dnsmasq on 127.0.0.1, and from there to Docker's embedded resolver on
|
||||
# 127.0.0.11 (see resolv.conf). What this rule does NOT cover is the hop
|
||||
# after that, where the embedded resolver forwards an external name to the
|
||||
# host's nameservers - see the owner-matched rule further down, which is
|
||||
# there because that hop can leave this namespace. Still no "UDP/53 to
|
||||
# anywhere" rule, which would be an unrestricted-destination exfil channel.
|
||||
iptables -A INPUT -i lo -j ACCEPT
|
||||
iptables -A OUTPUT -o lo -j ACCEPT
|
||||
|
||||
# Create ipsets with CIDR support. GitHub's ranges get their own set,
|
||||
# restricted below to port 443 only: the sandbox is meant to be able to read
|
||||
# from GitHub (clone/fetch/gh over HTTPS) but never push there, and blocking
|
||||
# SSH (port 22) to it entirely means that's not just policy but physically
|
||||
# enforced - there's no transport left for the forwarded ssh-agent's real
|
||||
# key to authenticate a push with, regardless of what a hook does or
|
||||
# doesn't catch.
|
||||
ipset create allowed-domains hash:net
|
||||
ipset create github-domains hash:net
|
||||
|
||||
# Pull GitHub's published IP ranges so GitHub traffic can be allowed without
|
||||
# opening up the entire internet.
|
||||
echo "Looking up GitHub's IP ranges..."
|
||||
# Authenticated whenever GH_TOKEN is set: unauthenticated /meta is capped at
|
||||
# 60 requests per hour per source IP, shared across every container start and
|
||||
# everything else on the host, so a session that starts sandboxes in a loop
|
||||
# exhausts it and every subsequent launch fails here. Same read-only token
|
||||
# `gh` already gets (see docker-compose.yml), sent to the API it belongs to.
|
||||
gh_auth=()
|
||||
if [ -n "${GH_TOKEN:-}" ]; then
|
||||
gh_auth=(-H "Authorization: Bearer $GH_TOKEN")
|
||||
fi
|
||||
|
||||
# Bounded, retried, and reported. All three were missing, and the combination
|
||||
# was worse than any of them alone (#59).
|
||||
#
|
||||
# `curl -s` with no --max-time has no deadline at all - it waits as long as the
|
||||
# kernel keeps retrying the SYN. And under `set -euo pipefail` at the top of
|
||||
# this file, a curl that exits non-zero takes the whole script down ON THE
|
||||
# ASSIGNMENT, so every diagnostic below it is unreachable in exactly the case
|
||||
# that needs one: the container dies having printed nothing after the line
|
||||
# above, and the operator - or CI - sees only "the container never became
|
||||
# ready". The `|| status=$?` is what keeps the failure ours to report.
|
||||
#
|
||||
# Retried because both failure modes here are transient. A blip costs a few
|
||||
# seconds instead of the launch; and a burst of container starts can trip
|
||||
# GitHub's secondary rate limit, which answers 403 with a JSON body rather
|
||||
# than a transport error - so the retry has to wrap the response check too,
|
||||
# not just the transfer. curl's own --retry would not cover that case.
|
||||
fetch_github_ranges() {
|
||||
local attempt status body
|
||||
for attempt in 1 2 3; do
|
||||
status=0
|
||||
# -S so curl's own reason ("Operation timed out after ...") reaches
|
||||
# stderr and therefore `docker logs`; -s alone would swallow it.
|
||||
body=$(curl -sS --connect-timeout 5 --max-time 10 \
|
||||
${gh_auth[@]+"${gh_auth[@]}"} https://api.github.com/meta) || status=$?
|
||||
if [ "$status" -ne 0 ]; then
|
||||
echo " attempt $attempt/3: curl exited $status" >&2
|
||||
elif [ -z "$body" ]; then
|
||||
echo " attempt $attempt/3: empty response" >&2
|
||||
elif ! printf '%s' "$body" | jq -e '.web and .api and .git' >/dev/null 2>&1; then
|
||||
# An API error - rate limiting, a bad token - comes back as a JSON
|
||||
# body carrying a `message` and no ranges. Surface it rather than
|
||||
# reporting the response as malformed, which points at the wrong
|
||||
# thing entirely.
|
||||
echo " attempt $attempt/3: $(printf '%s' "$body" \
|
||||
| jq -re '.message' 2>/dev/null || echo 'response carried no ranges')" >&2
|
||||
else
|
||||
printf '%s' "$body"
|
||||
return 0
|
||||
fi
|
||||
# `if`, not `[ ... ] && sleep`: the AND-list's status on the last
|
||||
# attempt is 1, which set -e would take as the script failing here
|
||||
# rather than falling through to the message below.
|
||||
if [ "$attempt" -lt 3 ]; then
|
||||
sleep $((attempt * 2))
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
if ! gh_ranges=$(fetch_github_ranges); then
|
||||
echo "ERROR: could not fetch GitHub's IP ranges from api.github.com/meta." >&2
|
||||
echo "Tried three times over ~36s; the reason for each attempt is above." >&2
|
||||
echo "The sandbox cannot start without them - the firewall would deny every" >&2
|
||||
echo "GitHub range, so git, gh and the API would all fail once inside." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Loading GitHub's ranges into the ipset..."
|
||||
while read -r cidr; do
|
||||
if [[ ! "$cidr" =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}/[0-9]{1,2}$ ]]; then
|
||||
echo "ERROR: GitHub meta returned a malformed CIDR range: $cidr"
|
||||
exit 1
|
||||
fi
|
||||
echo " + $cidr"
|
||||
# -exist: aggregate -q collapses overlapping ranges within each of
|
||||
# web/api/git, but not necessarily across all three combined - the same
|
||||
# crash this already caused once on the allowed-domains ipset (a
|
||||
# duplicate add errors, and set -e turns that into the whole firewall,
|
||||
# and thus the whole container, failing to start).
|
||||
ipset add github-domains "$cidr" -exist
|
||||
done < <(echo "$gh_ranges" | jq -r '(.web + .api + .git)[]' | aggregate -q)
|
||||
|
||||
# Resolve and add other allowed domains (shared with dns-proxy.sh's name
|
||||
# allowlist via fixed-domains.sh, so the two can't drift apart)
|
||||
# Same treatment as the GitHub fetch above, and for the same reason: this loop
|
||||
# was `ips=$(dig ... | awk ...)`, which under `set -o pipefail` dies on the
|
||||
# ASSIGNMENT when dig exits non-zero - silently, before the "returned nothing"
|
||||
# check below can run. It aborted a CI container twice, at a different domain
|
||||
# each time, with the log simply stopping mid-loop (#59).
|
||||
#
|
||||
# Retried because a lookup here is the first traffic through a dnsmasq that
|
||||
# entrypoint.sh started moments ago, and because a CDN's resolver being briefly
|
||||
# unavailable should cost a second, not the container. +time/+tries bound each
|
||||
# attempt: dig's defaults are 5s x 3 tries, so a single unanswered lookup could
|
||||
# spend 15s of a startup budget on its own.
|
||||
resolve_domain() {
|
||||
local domain="$1" attempt status answer ips
|
||||
for attempt in 1 2 3; do
|
||||
status=0
|
||||
answer=$(dig +noall +answer +time=3 +tries=1 A "$domain" 2>&1) || status=$?
|
||||
if [ "$status" -ne 0 ]; then
|
||||
echo " attempt $attempt/3: dig exited $status${answer:+ - $answer}" >&2
|
||||
else
|
||||
ips=$(printf '%s\n' "$answer" | awk '$4 == "A" {print $5}')
|
||||
if [ -n "$ips" ]; then
|
||||
printf '%s\n' "$ips"
|
||||
return 0
|
||||
fi
|
||||
# A CNAME-only answer, an empty one, or a rebind rejection. Printing
|
||||
# what did come back separates "no such name" from "dnsmasq refused
|
||||
# this answer", which look identical from an empty A list.
|
||||
echo " attempt $attempt/3: no A records${answer:+ - $answer}" >&2
|
||||
fi
|
||||
if [ "$attempt" -lt 3 ]; then
|
||||
sleep "$attempt"
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
for domain in "${FIXED_DOMAINS[@]}"; do
|
||||
echo "Looking up $domain..."
|
||||
if ! ips=$(resolve_domain "$domain"); then
|
||||
echo "ERROR: DNS lookup for $domain failed - each attempt is above." >&2
|
||||
echo "The firewall cannot allowlist a domain it cannot resolve, and the" >&2
|
||||
echo "sandbox would start with that domain unreachable, so this is fatal." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
while read -r ip; do
|
||||
if [[ ! "$ip" =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]; then
|
||||
echo "ERROR: $domain resolved to something that isn't an IPv4 address: $ip"
|
||||
exit 1
|
||||
fi
|
||||
echo " + $ip ($domain)"
|
||||
# -exist: two domains can resolve to the same IP (e.g. pypi.org and
|
||||
# files.pythonhosted.org share Fastly edge IPs), and ipset otherwise
|
||||
# errors on a duplicate add, which -e/set -e turns into the whole
|
||||
# firewall (and thus the container) failing to start.
|
||||
ipset add allowed-domains "$ip" -exist
|
||||
done < <(echo "$ips")
|
||||
done
|
||||
|
||||
# Resolve and add caller-supplied extra domains (e.g. a Gitea host), if any.
|
||||
# Unlike the fixed domains above, failures here are non-fatal: a project may
|
||||
# be run without any Gitea config set, or a host may be transiently
|
||||
# unresolvable, and that shouldn't prevent the firewall from coming up.
|
||||
if [ -n "${EXTRA_ALLOWED_DOMAINS:-}" ]; then
|
||||
# The script-wide IFS above drops plain space to tighten word splitting
|
||||
# elsewhere, but EXTRA_ALLOWED_DOMAINS is a space-separated list by
|
||||
# construction (entrypoint.sh joins DEJIMA_FORGE_HOSTS/LOKI_URL host/
|
||||
# ALLOWED_DOMAINS with spaces) - restore it just for this
|
||||
# loop, or a multi-entry value silently collapses into one bogus token.
|
||||
IFS=' '
|
||||
for domain in $EXTRA_ALLOWED_DOMAINS; do
|
||||
# A literal IP (e.g. a monitoring host given as an IP, not a
|
||||
# hostname) needs no resolution - add it directly.
|
||||
if [[ "$domain" =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]; then
|
||||
echo "Adding $domain (extra, literal IP)"
|
||||
ipset add allowed-domains "$domain" -exist
|
||||
continue
|
||||
fi
|
||||
echo "Resolving $domain (extra)..."
|
||||
# Through resolve_domain like the fixed domains, which is what makes
|
||||
# the comment above true. `ips=$(dig ... | awk ...)` did not merely
|
||||
# fail to warn on a failing dig - under `set -o pipefail` it took the
|
||||
# whole script down on the assignment, so "non-fatal" held for a
|
||||
# domain that resolved to nothing and not for one whose lookup errored.
|
||||
if ! ips=$(resolve_domain "$domain"); then
|
||||
echo "WARNING: Failed to resolve extra domain $domain, skipping"
|
||||
continue
|
||||
fi
|
||||
|
||||
# Not always allowed-domains: that set carries no port restriction,
|
||||
# so a GitHub address landing in it is reachable on 22 as well and the
|
||||
# read-only guarantee is gone. See ipset_for_domain.
|
||||
target_set=$(ipset_for_domain "$domain")
|
||||
while read -r ip; do
|
||||
if [[ ! "$ip" =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]; then
|
||||
echo "WARNING: Invalid IP from DNS for $domain: $ip, skipping"
|
||||
continue
|
||||
fi
|
||||
echo "Adding $ip for $domain (-> $target_set)"
|
||||
ipset add "$target_set" "$ip" 2>/dev/null || true
|
||||
done < <(echo "$ips")
|
||||
done
|
||||
fi
|
||||
|
||||
# The Docker host's IP, taken from the container's default route - needed
|
||||
# below so the sandbox can still reach services running on the host itself.
|
||||
HOST_IP=$(ip -4 route show default | awk '{print $3; exit}')
|
||||
if [ -z "$HOST_IP" ]; then
|
||||
echo "ERROR: Couldn't determine the Docker host's IP from the default route"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Docker host IP: $HOST_IP"
|
||||
|
||||
# Only the host itself, not its whole subnet - other machines on the same
|
||||
# LAN/VPC have no reason to be reachable from this container.
|
||||
iptables -A INPUT -s "$HOST_IP" -j ACCEPT
|
||||
iptables -A OUTPUT -d "$HOST_IP" -j NFLOG --nflog-group 1 --nflog-prefix "allow-host"
|
||||
iptables -A OUTPUT -d "$HOST_IP" -j ACCEPT
|
||||
|
||||
# Set default policies to DROP first
|
||||
iptables -P INPUT DROP
|
||||
iptables -P FORWARD DROP
|
||||
iptables -P OUTPUT DROP
|
||||
|
||||
# First allow established connections for already approved traffic
|
||||
iptables -A INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
|
||||
iptables -A OUTPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
|
||||
|
||||
# Docker's embedded resolver forwards external names to the host's
|
||||
# nameservers, and on some versions does it from inside THIS namespace. Those
|
||||
# queries then traverse the chain below and match nothing - the host's
|
||||
# resolver is neither in an ipset nor the bridge gateway - so they hit the
|
||||
# catch-all REJECT and every lookup SERVFAILs. Only the first hop stays on
|
||||
# loopback. Two hosts with byte-identical /etc/resolv.conf behaved
|
||||
# differently, so this is version-dependent and cannot be assumed either way.
|
||||
#
|
||||
# --uid-owner 0 keeps this from being the hole the DNS allowlist closes: the
|
||||
# forwarding socket is dockerd's (root), the session is `node` with no sudo,
|
||||
# so it still cannot address a resolver directly. This newly permits DNS only
|
||||
# from processes already root in a NET_ADMIN container, which could flush the
|
||||
# ruleset anyway.
|
||||
#
|
||||
# Guarded, not asserted: xt_owner is a module, and a kernel without it should
|
||||
# leave a container that says why (the DNS self-check below) rather than one
|
||||
# that aborts here.
|
||||
if iptables -A OUTPUT -p udp --dport 53 -m owner --uid-owner 0 -j ACCEPT 2>/dev/null; then
|
||||
iptables -A OUTPUT -p tcp --dport 53 -m owner --uid-owner 0 -j ACCEPT 2>/dev/null \
|
||||
|| echo "WARNING: could not add the TCP/53 rule for root-owned sockets - large answers may fail" >&2
|
||||
else
|
||||
echo "WARNING: the iptables owner match is unavailable, so Docker's embedded" >&2
|
||||
echo "resolver cannot forward out of this namespace. If DNS works anyway this" >&2
|
||||
echo "host forwards elsewhere and nothing is wrong; the self-check below says which." >&2
|
||||
fi
|
||||
|
||||
# Then allow only specific outbound traffic to allowed domains. This isn't
|
||||
# port-restricted, so it also covers git-over-ssh (port 22) to the
|
||||
# configured forge's hosts, which are already in the ipset above - no
|
||||
# separate "TCP/22 to anywhere" rule needed (that would allow SSH, or any
|
||||
# raw TCP posing as it, to any host on the internet).
|
||||
#
|
||||
# Each terminal ACCEPT/REJECT decision below is also tagged with NFLOG so
|
||||
# ulogd2 can capture every new outbound connection attempt and its verdict
|
||||
# (log-shipper.sh ships these to Loki alongside dns-proxy.sh's query log).
|
||||
# Only new connections reach these rules at all - the ESTABLISHED,RELATED
|
||||
# rule above already short-circuits the rest of a connection's packets, so
|
||||
# this logs once per attempt, not once per packet.
|
||||
iptables -A OUTPUT -m set --match-set allowed-domains dst -j NFLOG --nflog-group 1 --nflog-prefix "allow"
|
||||
iptables -A OUTPUT -m set --match-set allowed-domains dst -j ACCEPT
|
||||
|
||||
# GitHub gets port 443 always - read access (clone/fetch/gh over HTTPS).
|
||||
# Port 22 (needed for SSH push) stays closed unless the caller explicitly
|
||||
# opted in via ALLOW_GITHUB_PUSH=1 (see bin/dejima and
|
||||
# git-push-guard.sh, which independently also has to allow the host before a
|
||||
# push actually gets through) - default is push there stays physically
|
||||
# unreachable, not just discouraged.
|
||||
iptables -A OUTPUT -p tcp --dport 443 -m set --match-set github-domains dst -j NFLOG --nflog-group 1 --nflog-prefix "allow-gh"
|
||||
iptables -A OUTPUT -p tcp --dport 443 -m set --match-set github-domains dst -j ACCEPT
|
||||
|
||||
if [ "${ALLOW_GITHUB_PUSH:-0}" = "1" ]; then
|
||||
echo "ALLOW_GITHUB_PUSH=1: opening port 22 to GitHub for SSH push"
|
||||
iptables -A OUTPUT -p tcp --dport 22 -m set --match-set github-domains dst -j NFLOG --nflog-group 1 --nflog-prefix "allow-gh-push"
|
||||
iptables -A OUTPUT -p tcp --dport 22 -m set --match-set github-domains dst -j ACCEPT
|
||||
fi
|
||||
|
||||
# Explicitly REJECT all other outbound traffic for immediate feedback
|
||||
iptables -A OUTPUT -j NFLOG --nflog-group 2 --nflog-prefix "reject"
|
||||
iptables -A OUTPUT -j REJECT --reject-with icmp-admin-prohibited
|
||||
|
||||
echo "Firewall rules applied. Self-checking..."
|
||||
|
||||
# DNS before either connectivity check, because both of those resolve a name
|
||||
# before they connect and neither can tell you that resolution is what failed.
|
||||
# The negative control is the trap: with DNS dead it fails for the wrong
|
||||
# reason and prints "example.com is unreachable, as it should be", so a
|
||||
# container with no working DNS at all reports a passing check and then dies
|
||||
# on the positive control naming a host rather than the cause. Reading that
|
||||
# output backwards took an afternoon; it should take one line.
|
||||
if ! dns_probe=$(dig +short +time=3 +tries=2 A api.github.com 2>&1) || [ -z "$dns_probe" ]; then
|
||||
echo "ERROR: self-check failed - DNS does not resolve inside the sandbox." >&2
|
||||
echo >&2
|
||||
echo "The path is: this container -> dnsmasq on 127.0.0.1 (dns-proxy.sh) ->" >&2
|
||||
echo "Docker's embedded resolver on 127.0.0.11 -> the host's nameservers." >&2
|
||||
echo "Only the first two hops are on loopback. If Docker forwards the last" >&2
|
||||
echo "one from inside this namespace, it traverses the OUTPUT chain, matches" >&2
|
||||
echo "nothing, and is rejected - which reads as SERVFAIL, not as a firewall." >&2
|
||||
echo >&2
|
||||
echo " dig said: ${dns_probe:-(empty answer)}" >&2
|
||||
echo " packets taken by the catch-all OUTPUT reject so far:" >&2
|
||||
iptables -L OUTPUT -vn 2>/dev/null \
|
||||
| awk '/icmp-admin-prohibited/ {print " " $1 " packets, " $2 " bytes"}' >&2
|
||||
echo " dnsmasq's last words:" >&2
|
||||
tail -5 /var/log/dnsmasq.log 2>/dev/null | sed 's/^/ /' >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "self-check ok - DNS resolves an allowlisted name"
|
||||
|
||||
# --max-time as well as --connect-timeout on both self-checks: a connect that
|
||||
# succeeds and then stalls is not covered by --connect-timeout, and while these
|
||||
# two sit inside `if` (so set -e cannot take the script down the way it could
|
||||
# above), a stalled transfer here still hangs the container's startup with no
|
||||
# deadline of its own.
|
||||
if curl --connect-timeout 5 --max-time 15 https://example.com >/dev/null 2>&1; then
|
||||
echo "ERROR: self-check failed - a non-allowlisted host (example.com) was reachable"
|
||||
exit 1
|
||||
else
|
||||
echo "self-check ok - example.com is unreachable, as it should be"
|
||||
fi
|
||||
|
||||
# Confirm the allowlist itself actually works, not just that the default-deny does.
|
||||
#
|
||||
# Retried, unlike the negative control above, because the two ask opposite
|
||||
# questions. This one asserts something CAN be reached, and a transient DNS
|
||||
# answer or a CDN edge rotating mid-boot answers it wrongly in the direction
|
||||
# that kills the container - which is how it presented, one container failing
|
||||
# here while a dozen others in the same CI run came up fine. The example.com
|
||||
# check must NOT be retried: there a single success is the real answer, and
|
||||
# retrying would only be asking until it says what we want.
|
||||
github_reachable=0
|
||||
for attempt in 1 2 3; do
|
||||
if curl --connect-timeout 5 --max-time 15 https://api.github.com/zen >/dev/null 2>&1; then
|
||||
github_reachable=1
|
||||
break
|
||||
fi
|
||||
echo " attempt $attempt/3: api.github.com not reachable yet" >&2
|
||||
# `if`, not `[ ... ] && sleep` - see fetch_github_ranges above for what
|
||||
# that costs on the last attempt under set -e.
|
||||
if [ "$attempt" -lt 3 ]; then
|
||||
sleep $((attempt * 2))
|
||||
fi
|
||||
done
|
||||
if [ "$github_reachable" -ne 1 ]; then
|
||||
echo "ERROR: self-check failed - api.github.com should be reachable but wasn't"
|
||||
exit 1
|
||||
else
|
||||
echo "self-check ok - api.github.com is reachable, as expected"
|
||||
fi
|
||||
|
||||
# Verify GitHub SSH (port 22) matches whichever mode was requested - this is
|
||||
# what makes GitHub actually read-only by default (or actually push-capable
|
||||
# when ALLOW_GITHUB_PUSH=1), not just discouraged either way. A mismatch here
|
||||
# means the firewall rules above and this check drifted apart - fail loudly
|
||||
# rather than silently running in the wrong mode.
|
||||
if [ "${ALLOW_GITHUB_PUSH:-0}" = "1" ]; then
|
||||
if timeout 5 bash -c "cat < /dev/null > /dev/tcp/github.com/22" 2>/dev/null; then
|
||||
echo "self-check ok - github.com:22 is reachable, as expected with ALLOW_GITHUB_PUSH=1"
|
||||
else
|
||||
echo "ERROR: self-check failed - github.com:22 should be reachable under ALLOW_GITHUB_PUSH=1 but wasn't"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
if timeout 5 bash -c "cat < /dev/null > /dev/tcp/github.com/22" 2>/dev/null; then
|
||||
echo "ERROR: self-check failed - github.com:22 was reachable without ALLOW_GITHUB_PUSH=1"
|
||||
exit 1
|
||||
else
|
||||
echo "self-check ok - github.com:22 is unreachable, as it should be"
|
||||
fi
|
||||
fi
|
||||
+27
-10
@@ -1,10 +1,14 @@
|
||||
#!/bin/bash
|
||||
# Tails dns-proxy.sh's dnsmasq query log and ulogd2's NFLOG-derived JSON
|
||||
# output, shipping them to Loki as raw log streams. Lets DNS lookups and
|
||||
# firewall allow/reject decisions from inside the sandbox show up alongside
|
||||
# the rest of the Grafana/Loki setup, labeled by job so they're easy to
|
||||
# filter. No-op if LOKI_URL isn't set - logging is a bonus on top of the
|
||||
# firewall/DNS-proxy enforcement, not required for either to work.
|
||||
# Tails the two records egress-policy produces - one JSON object per name
|
||||
# lookup, one per connection attempt - and ships them to Loki as raw log
|
||||
# streams. Lets lookups and allow/reject decisions from inside the sandbox show
|
||||
# up alongside the rest of the Grafana/Loki setup, labeled by job so they're
|
||||
# easy to filter. No-op if LOKI_URL isn't set - logging is a bonus on top of
|
||||
# the enforcement, not required for it to work.
|
||||
#
|
||||
# Both files are already one JSON object per line, so nothing here parses them.
|
||||
# A refused lookup is the record that matters most and never becomes a
|
||||
# connection, which is why there are two streams rather than one.
|
||||
#
|
||||
# Batches rather than pushing one line at a time: a burst of activity (e.g.
|
||||
# npm install resolving/connecting to dozens of hosts in quick succession)
|
||||
@@ -48,6 +52,19 @@ ship_stream() {
|
||||
last_flush=$(date +%s)
|
||||
}
|
||||
|
||||
# tail's stderr is discarded below because -F is meant to wait for a file
|
||||
# that does not exist yet, and complains once per second while it does.
|
||||
# That also swallows a permission error, which is not transient and which
|
||||
# looks identical from the outside: a stream that ships nothing, which is
|
||||
# also what a quiet session looks like.
|
||||
#
|
||||
# This process holds no capabilities (see entrypoint.sh), so it reads these
|
||||
# two files on their ordinary permissions and nothing else. Say so once
|
||||
# when that is not enough, rather than shipping an empty stream.
|
||||
if [ -e "$file" ] && [ ! -r "$file" ]; then
|
||||
echo "WARNING: $file is not readable by $(id -un) - the $job stream will ship nothing." >&2
|
||||
fi
|
||||
|
||||
last_flush=$(date +%s)
|
||||
tail -F -n0 "$file" 2>/dev/null | while true; do
|
||||
if IFS= read -r -t "$BATCH_MAX_SECONDS" line; then
|
||||
@@ -61,8 +78,8 @@ ship_stream() {
|
||||
done
|
||||
}
|
||||
|
||||
# Only the logs that exist in this container: dnsmasq's queries and ulogd2's
|
||||
# output. Session transcripts are shipped host-side, by
|
||||
# Only the logs that exist in this container: the name lookups and the
|
||||
# connection attempts. Session transcripts are shipped host-side, by
|
||||
# bin/dejima-conversation-ship, because an oversight log of a session must
|
||||
# not run in a process that session can reach.
|
||||
|
||||
@@ -70,8 +87,8 @@ if [ "${LOG_SHIPPER_LIB_ONLY:-0}" = "1" ]; then
|
||||
return 0 2>/dev/null || exit 0
|
||||
fi
|
||||
|
||||
ship_stream "dejima-dns" /var/log/dnsmasq.log &
|
||||
ship_stream "dejima-net" /var/log/ulogd.json &
|
||||
ship_stream "dejima-dns" /var/log/egress-policy/lookups.ndjson &
|
||||
ship_stream "dejima-net" /var/log/egress-policy/connections.ndjson &
|
||||
|
||||
|
||||
wait
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
# Interactive shell configuration for the sandbox user.
|
||||
#
|
||||
# Loaded from /etc/bash.bashrc, so it applies to every interactive bash, and
|
||||
# again from the sandbox user's ~/.bashrc, so it wins over the Debian skeleton
|
||||
# rc file that would otherwise reset PS1 and the history limits after us.
|
||||
# Sourcing it twice is harmless: everything below is an assignment or an
|
||||
# idempotent binding.
|
||||
#
|
||||
# Not a shell framework fetched at build time. This image is a security
|
||||
# boundary, and a prompt is not worth an unpinned third party in the build - so
|
||||
# what a framework would have configured is written out here instead, where it
|
||||
# can be read.
|
||||
|
||||
case $- in *i*) ;; *) return 0 ;; esac
|
||||
|
||||
# The sandbox user's shell only, even though /etc/bash.bashrc sources this for
|
||||
# every interactive bash in the container - root's included, which is what an
|
||||
# operator attaching to debug a session gets.
|
||||
#
|
||||
# Root must not take its history file from the volume below. That directory
|
||||
# belongs to the sandbox user, and `history -a` follows a symlink, so a session
|
||||
# that replaces .bash_history with one pointed elsewhere gets whatever root
|
||||
# types appended to a file of its choosing, as uid 0. In the other direction it
|
||||
# reads back anything the operator typed, including a command carrying a token.
|
||||
#
|
||||
# Bailing out of the whole file rather than guarding the HISTFILE assignment:
|
||||
# nothing here is written for root, and a guard on one setting would have to be
|
||||
# remembered by whoever adds the next one.
|
||||
if [ "$(id -u)" -eq 0 ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Debian's /etc/profile rewrites PATH from scratch for login shells: it drops
|
||||
# the global Node tool directory the image put in the environment, and it leaves
|
||||
# out the sbin directories, where the network tools a session is expected to use
|
||||
# (ip, nft, dnsmasq) all live. Put them back, without duplicating an
|
||||
# entry when this file is sourced twice.
|
||||
# Appended, for the reason the Dockerfile gives: the harness wrapper in
|
||||
# /usr/local/bin has to keep shadowing the npm-installed binary it wraps.
|
||||
case ":$PATH:" in
|
||||
*":/opt/node-tools/bin:"*) ;;
|
||||
*) PATH="$PATH:/opt/node-tools/bin" ;;
|
||||
esac
|
||||
for __dir in /usr/local/sbin /usr/sbin /sbin; do
|
||||
case ":$PATH:" in
|
||||
*":$__dir:"*) ;;
|
||||
*) PATH="$PATH:$__dir" ;;
|
||||
esac
|
||||
done
|
||||
unset __dir
|
||||
|
||||
# History on the volume docker-compose.yml mounts, so tomorrow's session sees
|
||||
# what was typed today. If the mount is missing or not writable by this user,
|
||||
# fall back to the home directory rather than making every prompt fail to save.
|
||||
if [ -w /var/opt/shell-history ]; then
|
||||
HISTFILE=/var/opt/shell-history/.bash_history
|
||||
else
|
||||
HISTFILE="$HOME/.bash_history"
|
||||
fi
|
||||
HISTSIZE=50000
|
||||
HISTFILESIZE=500000
|
||||
HISTCONTROL=ignoreboth
|
||||
HISTTIMEFORMAT='%F %T '
|
||||
shopt -s histappend cmdhist checkwinsize
|
||||
|
||||
# Append each command to $HISTFILE as it is entered. Without this, bash holds
|
||||
# the whole session in memory and writes on a clean exit only - and a sandbox
|
||||
# session is killed far more often than it exits.
|
||||
PROMPT_COMMAND='history -a'
|
||||
|
||||
if ! declare -F _completion_loader >/dev/null 2>&1; then
|
||||
[ -r /usr/share/bash-completion/bash_completion ] && . /usr/share/bash-completion/bash_completion
|
||||
fi
|
||||
|
||||
# Git state in the prompt comes from git-sh-prompt, which ships inside the git
|
||||
# package itself, so it is present whenever git is.
|
||||
if ! declare -F __git_ps1 >/dev/null 2>&1 && [ -r /usr/lib/git-core/git-sh-prompt ]; then
|
||||
. /usr/lib/git-core/git-sh-prompt
|
||||
fi
|
||||
GIT_PS1_SHOWDIRTYSTATE=1
|
||||
GIT_PS1_SHOWSTASHSTATE=1
|
||||
GIT_PS1_SHOWUNTRACKEDFILES=1
|
||||
GIT_PS1_SHOWUPSTREAM=auto
|
||||
if declare -F __git_ps1 >/dev/null 2>&1; then
|
||||
PS1='\[\e[1;32m\]\u@\h\[\e[0m\]:\[\e[1;34m\]\w\[\e[0;33m\]$(__git_ps1 " (%s)")\[\e[0m\]\$ '
|
||||
else
|
||||
PS1='\[\e[1;32m\]\u@\h\[\e[0m\]:\[\e[1;34m\]\w\[\e[0m\]\$ '
|
||||
fi
|
||||
|
||||
# Fuzzy search over history and over paths, written here rather than sourced
|
||||
# from whatever path the fzf package happens to keep its example bindings in -
|
||||
# that path has moved between releases, and a key binding that silently stops
|
||||
# existing after a distribution upgrade is worse than twenty lines of rc file.
|
||||
if command -v fzf >/dev/null 2>&1; then
|
||||
export FZF_DEFAULT_OPTS='--height=40% --layout=reverse --border --info=inline'
|
||||
|
||||
# Ctrl-R: search the history, most recent first, duplicates dropped. The sed
|
||||
# removes the entry number and the HISTTIMEFORMAT stamp that the history
|
||||
# builtin prints ahead of each command.
|
||||
__fzf_history_widget() {
|
||||
local picked
|
||||
picked=$(history |
|
||||
sed -E 's/^ *[0-9]+ +([0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2} )?//' |
|
||||
tac |
|
||||
awk '!seen[$0]++' |
|
||||
fzf --prompt='history> ' --query="$READLINE_LINE") || return 0
|
||||
READLINE_LINE=$picked
|
||||
READLINE_POINT=${#READLINE_LINE}
|
||||
}
|
||||
|
||||
# Ctrl-T: insert one or more paths from below the current directory at the
|
||||
# cursor. fd rather than find, so a checkout's ignored build output does not
|
||||
# bury the file being looked for.
|
||||
__fzf_path_widget() {
|
||||
local picked
|
||||
picked=$(fd --hidden --exclude .git --strip-cwd-prefix 2>/dev/null |
|
||||
fzf --prompt='path> ' --multi |
|
||||
tr '\n' ' ') || return 0
|
||||
picked=${picked% }
|
||||
[ -n "$picked" ] || return 0
|
||||
READLINE_LINE=${READLINE_LINE:0:READLINE_POINT}$picked${READLINE_LINE:READLINE_POINT}
|
||||
READLINE_POINT=$((READLINE_POINT + ${#picked}))
|
||||
}
|
||||
|
||||
bind -x '"\C-r": __fzf_history_widget'
|
||||
bind -x '"\C-t": __fzf_path_widget'
|
||||
fi
|
||||
@@ -0,0 +1,75 @@
|
||||
#!/bin/sh
|
||||
# Remove every setuid bit, setgid bit and file capability in the image, then
|
||||
# prove none is left.
|
||||
#
|
||||
# The session user has no sudo and no capabilities, and entrypoint.sh drops to
|
||||
# it through gosu, which lowers privilege rather than raising it. A setuid
|
||||
# binary or a file capability is the one shape an escalation out of that could
|
||||
# take, so the image must contain neither - and no_new_privs, which the
|
||||
# container also sets, is a second lock on the same door rather than a reason to
|
||||
# skip this one.
|
||||
#
|
||||
# Nothing here needs its bits. su, mount, passwd, chsh, ssh-keysign and
|
||||
# unix_chkpwd are of no use to an unattended non-root session that authenticates
|
||||
# to nothing, and each is a well-worn step in an escape.
|
||||
#
|
||||
# The one with a visible consequence is utempter, setgid utmp, which terminal
|
||||
# multiplexers call to record a login. Its failure is not fatal to them - tmux
|
||||
# carries on without the record - and the cost is that `who` and `w` inside the
|
||||
# container stop listing sessions. Worth saying because that looks like a fault
|
||||
# rather than a decision.
|
||||
#
|
||||
# Run as the LAST instruction of a complete image, after every stage that
|
||||
# installs anything: the base image's own final stage, and again at the end of
|
||||
# a derived per-project layer, which installs apt packages of its own after the
|
||||
# base image is finished (bin/dejima-project-lib.sh). Run earlier it would only
|
||||
# cover what existed at that point, and a package installed afterwards could
|
||||
# reintroduce a privileged binary with nothing to notice.
|
||||
#
|
||||
# The verification is why this is a build step rather than a package list:
|
||||
# adding a dependency that ships a setuid binary fails the build instead of
|
||||
# quietly widening the image.
|
||||
#
|
||||
# `--check` runs the verification alone, changing nothing. It exists so a test
|
||||
# against a running container asserts the property by running this code rather
|
||||
# than by restating it - in particular the root list below, which is the part
|
||||
# most easily got wrong.
|
||||
set -eu
|
||||
|
||||
check_only=false
|
||||
if [ "${1:-}" = "--check" ]; then
|
||||
check_only=true
|
||||
elif [ "$#" -gt 0 ]; then
|
||||
echo "usage: ${0##*/} [--check]" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# Real filesystems only. /proc, /sys and /dev are mounted during a build and
|
||||
# have nothing to strip; -xdev keeps find out of them, and getcap has no
|
||||
# equivalent flag, so it is pointed at the directories that can hold binaries.
|
||||
roots=""
|
||||
for d in /bin /boot /etc /home /lib /lib32 /lib64 /libx32 /opt /root /sbin /srv /usr /var; do
|
||||
[ -d "$d" ] && roots="$roots $d"
|
||||
done
|
||||
|
||||
if ! $check_only; then
|
||||
# shellcheck disable=SC2086
|
||||
find $roots -xdev -type f -perm /6000 -exec chmod -s {} +
|
||||
|
||||
# getcap prints "<path> <capabilities>"; strip the capabilities from each
|
||||
# file it names. A failure here is left to fail the script: a capability
|
||||
# that cannot be removed is exactly the thing this exists to catch.
|
||||
# shellcheck disable=SC2086
|
||||
getcap -r $roots 2>/dev/null | cut -d' ' -f1 | while read -r f; do
|
||||
setcap -r "$f"
|
||||
done
|
||||
fi
|
||||
|
||||
# shellcheck disable=SC2086
|
||||
remaining="$(find $roots -xdev -type f -perm /6000; getcap -r $roots 2>/dev/null)"
|
||||
if [ -n "$remaining" ]; then
|
||||
printf 'privileged bits remain:\n%s\n' "$remaining" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf 'no setuid, setgid or file-capability binaries remain\n'
|
||||
@@ -1,31 +0,0 @@
|
||||
# Consumes the NFLOG-tagged packets from init-firewall.sh's OUTPUT rules
|
||||
# (group 1: allowed connections, group 2: rejected ones) and writes each as
|
||||
# a JSON line that log-shipper.sh tails and forwards to Loki. Static config
|
||||
# (no per-run domain/env dependence, unlike dns-proxy.sh's generated
|
||||
# dnsmasq config), so this is copied into the image as-is.
|
||||
[global]
|
||||
logfile="/var/log/ulogd.log"
|
||||
loglevel=3
|
||||
|
||||
plugin="/usr/lib/x86_64-linux-gnu/ulogd/ulogd_inppkt_NFLOG.so"
|
||||
plugin="/usr/lib/x86_64-linux-gnu/ulogd/ulogd_raw2packet_BASE.so"
|
||||
plugin="/usr/lib/x86_64-linux-gnu/ulogd/ulogd_filter_IP2STR.so"
|
||||
plugin="/usr/lib/x86_64-linux-gnu/ulogd/ulogd_output_JSON.so"
|
||||
|
||||
stack=log1:NFLOG,base1:BASE,ip2str1:IP2STR,json1:JSON
|
||||
stack=log2:NFLOG,base1:BASE,ip2str1:IP2STR,json1:JSON
|
||||
|
||||
# Allowed connections (matches init-firewall.sh's --nflog-group 1 rules)
|
||||
[log1]
|
||||
group=1
|
||||
numeric_label=1
|
||||
|
||||
# Rejected connections (matches init-firewall.sh's --nflog-group 2 rule)
|
||||
[log2]
|
||||
group=2
|
||||
numeric_label=0
|
||||
|
||||
[json1]
|
||||
sync=1
|
||||
file="/var/log/ulogd.json"
|
||||
boolean_label=1
|
||||
+23
-7
@@ -365,9 +365,11 @@ concurrent jobs share a broadcast domain and can reach each other's service
|
||||
containers. Check that before raising it.
|
||||
|
||||
The DNS and firewall shape is the same one dejima uses inside its own
|
||||
container (`docker/dns-proxy.sh` and `docker/init-firewall.sh`), applied one
|
||||
layer out: allowlist the *names* and let the answers populate the set of
|
||||
allowed *addresses*.
|
||||
container (`docker/egress-policy`), applied one layer out: allowlist the
|
||||
*names* and let the answers populate the set of allowed *addresses*. The
|
||||
toolchain differs - the hardener is written against `iptables` and `ipset`,
|
||||
dejima's own policy against `nftables` - but the arrangement is the same, and
|
||||
so is what it is worth.
|
||||
|
||||
`dnsmasq` binds the bridge gateway. With `no-resolv` and one `server=` line
|
||||
per allowed name, every other name gets `REFUSED`, not resolved-then-blocked,
|
||||
@@ -462,6 +464,16 @@ prefer credentials that cannot do anything else: a token that raises an
|
||||
anonymous API rate limit should be a fine-grained token with no permissions
|
||||
and no repository access, not a general-purpose one.
|
||||
|
||||
**A per-run fetch, not a per-container one.** The privileged job starts
|
||||
twenty-seven container pairs, and each filter container asks the forge for its
|
||||
published address ranges before it brings the policy up. Done once per
|
||||
container that is a burst, and GitHub answers a burst on `/meta` with a `403`
|
||||
from its secondary rate limit - which is not the hourly ceiling a token raises,
|
||||
so a valid credential does not prevent it (#357). The job fetches once into the
|
||||
directory it already shares with those containers, and the suite points every
|
||||
filter at the result; a container falls back to fetching for itself when the
|
||||
file is absent, which is what happens outside CI.
|
||||
|
||||
**The cache server.** `act_runner` enables it by default, and it binds on the
|
||||
runner host specifically so that job containers can reach it. That is an
|
||||
inbound service exposed to untrusted code. Turn it off where it is not
|
||||
@@ -521,7 +533,11 @@ can still add a workflow of its own.
|
||||
daemon, the answer is a host you are willing to lose, not a smaller grant.
|
||||
- **Before Gitea 1.27, the job token stays permissive.** Branch protection
|
||||
bounds where it can write; it does not take the token away.
|
||||
- **None of this is checked by anything.** The rest of dejima's security
|
||||
properties have tests behind them. A runner's configuration lives on a
|
||||
machine this repository has no access to, so it is on you to re-check it
|
||||
after upgrades and rebuilds.
|
||||
- **Only part of this is checked, and only from inside a job.**
|
||||
`test/ci-runner.bats` runs on both tiers and asserts what a job can see from
|
||||
where it stands: which tier it landed on, that the confined one has no docker
|
||||
socket and the privileged one does, and that the egress rules are in front of
|
||||
the confined job. Everything outside a job - the runner's own configuration
|
||||
file, its labels, what else shares the machine - lives somewhere this
|
||||
repository has no access to, so it is on you to re-check that after upgrades
|
||||
and rebuilds.
|
||||
|
||||
+114
-30
@@ -70,6 +70,7 @@ which `[forge.<name>]`.
|
||||
| `command` | *(the selected harness's own)* | container's main process (e.g. `nvim`), also settable per launch with `--cmd`. Overriding it changes what dejima launches, not which harness is wrapped |
|
||||
| `tools` | unset | optional tools to put in the image, colon-separated; also `.dejima/tools` in a project (approved first) and `--include` per launch. None by default. See [docs/tools.md](tools.md) |
|
||||
| `firewall` | `on` | egress firewall; `off` disables it (debugging only), also settable per launch with `--no-firewall` |
|
||||
| `internal_domains` | unset | approved domains that really do resolve inside your own network, colon-separated. The policy refuses an answer for a permitted name that points at a private address, because an answer decides what the allowlist contains and comes from outside; this exempts the ones you know are internal. It exempts only - a name here is not reachable unless it is also approved, and naming one that is not stops the launch saying so. The configured forge, the Loki host and a self-served backend's `base_url` host are permitted and exempt already, so this is for destinations dejima does not configure itself. `dejima-allow` reads this same key when adding a domain to a running session |
|
||||
| `share_dir` | `~/.local/share/dejima/shared` | host-visible read/write scratch dir. One directory per project underneath it, and only that project's is mounted at `~/shared`. `dejima-share` is the host side of it |
|
||||
| `known_hosts` | `~/.ssh/known_hosts` | known-hosts file used for git-over-ssh |
|
||||
| `update_check` | `on` | at startup, say when this checkout is behind the branch it tracks. One `ls-remote` to your own remote, at most hourly, never fetching; `off` makes dejima contact nothing of its own accord. The answer is cached in `~/.local/share/dejima/update-check`, which is safe to delete |
|
||||
@@ -156,14 +157,15 @@ you selected, the second is push access when GitHub *is* your forge.
|
||||
| `[forge.gitea] api` | **required** for `forge = gitea` | `https://<gitea host>/api/v1` |
|
||||
| `[forge.gitea] token_file` | `~/.config/gitea/token` | path to the Gitea/Forgejo API token (the file, not the token itself) |
|
||||
| `[forge.github] push_token_file` | unset | path to a **write-capable** PAT, used only when `[core] forge = github`: deliberately separate from `[github] token_file`; see "GitHub push" in [docs/security.md](security.md) |
|
||||
| `[github] token_file` | `~/.config/gh/sandbox-token` | path to a read-only PAT for private GitHub repos / `gh`. Read whichever forge is selected, and it should stay read-only |
|
||||
| `[github] token_file` | `~/.config/gh/sandbox-token` | path to a read-only PAT for private GitHub repos / `gh`, and it should stay read-only. Read for every session EXCEPT when `[core] forge = github` and `push_token_file` is set: both fill `GH_TOKEN`, the forge's wins, and the launch says so. Which token can push is the half that matters |
|
||||
|
||||
### `[mounts]` - extra host paths
|
||||
|
||||
| Key | Default | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `readonly` | unset | extra host paths (colon-separated) a personal config needs, mounted read-only at their own path |
|
||||
| `read_write` | unset | host paths (colon-separated) mounted **read-write** at their own path: for directories meant to outlive the session; refused if they overlap anything dejima needs out of a session's reach |
|
||||
| `read_write` | unset | host paths (colon-separated) mounted **read-write** at their own path: for directories meant to outlive the session and be shared by every project; refused if they overlap anything dejima needs out of a session's reach |
|
||||
| `read_write_per_project` | unset | the same, mounted at the path named but backed by `<path>/projects/<slug>` - one directory per project rather than one for the host. For anything a session writes to be read later as background, where another project reading it would be a channel rather than a convenience |
|
||||
| `nvim_config` | unset | nvim config dir, used when `[core] command = nvim` |
|
||||
|
||||
### `[audit]` - the exit-time audit
|
||||
@@ -241,11 +243,28 @@ for them.
|
||||
A project can declare what it needs in two committed files:
|
||||
|
||||
```
|
||||
.dejima/allowed-domains # one domain per line
|
||||
.dejima/allowed-domains # one domain per line, fully qualified
|
||||
.dejima/dependencies # <apt|npm|pip|cargo>:<name> per line, or a
|
||||
# tarball:<name>@<sha256>:<url> release archive
|
||||
```
|
||||
|
||||
A domain has to name at least two labels. A permitted name covers everything
|
||||
under it, because the sandbox's resolver is configured per name and matches the
|
||||
name and its subdomains, with no exact-name form to use instead - so a bare
|
||||
label would grant its whole suffix, and `org` would ask for every `.org` name.
|
||||
The same floor applies to every key whose value becomes a permitted
|
||||
destination: `[core] internal_domains`, a forge's `ssh_host` and `api`, a
|
||||
backend's `base_url`, and `[logging] loki_url`. A value that is not a host name of at least two labels is
|
||||
refused when the entry is declared and again at launch, naming the key it came
|
||||
from - which includes a value that reached the launcher through a URL and lost
|
||||
its host on the way, such as a `loki_url` carrying userinfo.
|
||||
|
||||
An approval granted before this rule existed is still read rather than called
|
||||
corrupt, so an upgrade does not present as tampering; the launch stops instead,
|
||||
naming the entry. There is no `remove` verb, so clearing one means editing the
|
||||
store by hand - `~/.config/dejima/allowed-domains` for a global approval, or
|
||||
`~/.config/dejima/projects/<slug>/allowed-domains` for a project's.
|
||||
|
||||
**Declaring grants nothing.** The project directory is bind-mounted
|
||||
read-write, so a session could write these files; they are read only to
|
||||
compare against reality and suggest a command. Approvals live outside the
|
||||
@@ -258,6 +277,7 @@ dejima-project status ~/myproject # declared vs approved vs missing
|
||||
dejima-project approve ~/myproject # review and approve the declared set
|
||||
dejima-allow --project ~/myproject crates.io
|
||||
dejima-allow --global crates.io # applies to every project
|
||||
dejima-allow --container <id> dl.example.com files.example.org # several
|
||||
dejima-dep add --project ~/myproject cargo:cargo-nextest
|
||||
dejima-dep add --global cargo:cargo-nextest
|
||||
```
|
||||
@@ -265,7 +285,19 @@ dejima-dep add --global cargo:cargo-nextest
|
||||
Two of those tools also write the declaration, so an approval you make by
|
||||
hand does not stay invisible to everyone else on the project:
|
||||
`dejima-allow --project` appends to `.dejima/allowed-domains`, and
|
||||
`dejima-dep add --project` appends to `.dejima/dependencies`. Both write the
|
||||
`dejima-dep add --project` appends to `.dejima/dependencies`.
|
||||
|
||||
`dejima-allow` takes up to twenty domains at once, which is what a toolchain
|
||||
fetching from several hosts actually needs. One goes through as a single
|
||||
decision you already made by typing it; more than one is printed back to you
|
||||
one per line and confirmed before anything is added, defaulting to no. The cap
|
||||
and the confirmation are the same point: a list is where an unwanted name rides
|
||||
along unread, the agent that suggested the command is what might have put it
|
||||
there, and neither mechanism helps if the list is too long to finish reading.
|
||||
Every name is validated before any of them is shown, so what you are confirming
|
||||
is what will be added and no entry can carry an escape sequence that rewrites
|
||||
the lines above it. Confirming needs a terminal, so a script does them one at a
|
||||
time. Both write the
|
||||
approval first - that is the part that grants - and report separately on the
|
||||
declaration, so a project you can't write to still gets you the approval.
|
||||
`--global` writes no declaration at all: there is no project to declare in.
|
||||
@@ -785,8 +817,15 @@ ssh_key = ~/.ssh/dejima_ed25519
|
||||
`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](security.md) for the full mount rationale). Setting it means
|
||||
a live forwarded agent is no longer required at all.
|
||||
[security notes](security.md) for the full mount rationale).
|
||||
|
||||
Setting it means a forwarded agent is not merely unnecessary but **not
|
||||
mounted**: your own `SSH_AUTH_SOCK` is not passed to the container at all, so
|
||||
nothing in a session can sign with your personal identities by naming the
|
||||
socket directly. That is the point of choosing a dedicated key, and it is worth
|
||||
knowing before you choose one - a session that also needed the forwarded agent
|
||||
for something else (a second remote, a private submodule over ssh) no longer
|
||||
has it. Leave this unset to keep the agent.
|
||||
|
||||
## Using a different editor/application
|
||||
|
||||
@@ -842,15 +881,41 @@ session is meant to write to and whose contents should outlive the container:
|
||||
|
||||
```
|
||||
[mounts]
|
||||
read_write = ~/specs
|
||||
read_write = ~/.cache/shared-toolchain
|
||||
```
|
||||
|
||||
Both lists mount at the host path they name, so a convention that says
|
||||
`[mounts] read_write_per_project` mounts at the same path, but backs it with
|
||||
one directory per project instead of the whole thing:
|
||||
|
||||
```
|
||||
[mounts]
|
||||
read_write_per_project = ~/specs
|
||||
```
|
||||
|
||||
The container sees `~/specs` either way. Behind it, `read_write` puts the
|
||||
directory itself - every session's contents, including other projects' - and
|
||||
`read_write_per_project` puts `~/specs/projects/<slug>`, keyed the same way
|
||||
`[core] share_dir` and the audit store are.
|
||||
|
||||
**Which to reach for is decided by one question: should another project's
|
||||
session read what this one leaves there?** For a build cache or a directory of
|
||||
scripts, yes, and `read_write` is right. For design docs, scratch notes,
|
||||
anything an agent writes to be read later as background - no, and that is what
|
||||
`read_write_per_project` is for. A spec is written to be read as context, so a
|
||||
shared one is a channel from any project into every other.
|
||||
|
||||
All three lists mount at the host path they name, so a convention that says
|
||||
"specs live in `~/specs`" keeps working inside the container without being
|
||||
rewritten. That is the difference from `[core] share_dir`, which is
|
||||
mounted at `~/shared` regardless of where it lives on the host because its job
|
||||
is to be a well-known handoff point.
|
||||
|
||||
dejima creates the per-project directory; it never creates the one you named,
|
||||
so a path that is not there is refused as the typo it is. Naming a
|
||||
`read_write_per_project` root's `projects` directory in either of the other
|
||||
two lists is refused as well - handing that over is handing over every
|
||||
project's.
|
||||
|
||||
An entry is **refused, not warned about**, if it overlaps anything dejima
|
||||
relies on being out of a session's reach: the approval store, either audit
|
||||
store, the epitaph store, every project's share, `~/.local/bin`, dejima's own
|
||||
@@ -910,11 +975,18 @@ dejima takes that one character back for the host while a session runs
|
||||
(`bin/dejima-tty-suspend`). Ctrl-C and Ctrl-\ are deliberately left alone and
|
||||
still reach the session, which is how a harness expects to be interrupted.
|
||||
|
||||
Suspending is not detaching. A suspended session is stopped, so it makes no
|
||||
progress until you resume it, and it belongs to the terminal you started it
|
||||
from. For a session that keeps working while you are away, or that you want to
|
||||
pick up from somewhere else, run dejima inside tmux and use
|
||||
[`dejima-attach`](remote-access.md).
|
||||
Suspending is not pausing, and the difference is worth knowing before you reach
|
||||
for it. What stops is the host side: `dejima` itself, the container run, the
|
||||
credential proxy and the conversation shipper, which share one process group so
|
||||
the kernel stops and continues them together. The container is not in that
|
||||
group. It keeps running under the container runtime, with its mounts, its
|
||||
firewall grants and its forge token, and keeps making requests while the
|
||||
terminal looks stopped - see
|
||||
[what dejima does not sandbox](security.md#what-dejima-does-not-sandbox).
|
||||
|
||||
Suspending is not detaching either: the job belongs to the terminal you started
|
||||
it from. For a session you want to pick up from somewhere else, run dejima
|
||||
inside tmux and use [`dejima-attach`](remote-access.md).
|
||||
|
||||
## Shared scratch space
|
||||
|
||||
@@ -958,13 +1030,14 @@ Treat it as a handoff point, not as general-purpose storage. If your own
|
||||
project instructions tell agents to write somewhere at a fixed host path -
|
||||
design docs, scratch notes, anything meant to outlive the session but stay out
|
||||
of the project checkout - mount that directory instead, with
|
||||
`[mounts] read_write`. It arrives at its own absolute path, so a convention
|
||||
that says "put specs in `~/specs`" keeps working inside the container without
|
||||
being rewritten to point at `~/shared`.
|
||||
`[mounts] read_write_per_project`. It arrives at its own absolute path, so a
|
||||
convention that says "put specs in `~/specs`" keeps working inside the
|
||||
container without being rewritten to point at `~/shared` - and, like `~/shared`
|
||||
itself, what a session finds there is this project's and not every project's.
|
||||
|
||||
`~/shared`, the project mount, and anything in `[mounts] read_write` are the
|
||||
only writable host paths in the container. Everything else a session writes
|
||||
goes away with it.
|
||||
`~/shared`, the project mount, and anything in `[mounts] read_write` or
|
||||
`[mounts] read_write_per_project` are the only writable host paths in the
|
||||
container. Everything else a session writes goes away with it.
|
||||
|
||||
**Clear it out periodically.** The per-project split bounds which sessions can
|
||||
read what you left, and bounds nothing about how long. A project's share
|
||||
@@ -1102,22 +1175,33 @@ without the reason it is normally off (see
|
||||
[docs/security.md](security.md#github-push-blocked-by-default-optional-opt-in)),
|
||||
so the reason is supplied where it will be read.
|
||||
|
||||
`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
|
||||
`main`/`master` even there. Like the other hooks, this is a fast-fail guard
|
||||
against an overeager agent, not a hard security boundary - a session has full
|
||||
shell access and could in principle edit `.git/config` to route around it.
|
||||
`git-push-guard.sh` (`PreToolUse`/`Bash`, also `DEJIMA`-gated) blocks any `git
|
||||
push` whose resolved remote is not one of the configured forge's hosts, and any
|
||||
push straight to a branch the workflow protects. Neither list is named here:
|
||||
both are resolved host-side from the selected forge and workflow, so this is
|
||||
the same rule whichever forge is in use. Like the other hooks, this is a
|
||||
fast-fail guard against an overeager agent, not a hard security boundary - a
|
||||
session has full shell access and could in principle edit `.git/config` to
|
||||
route around it.
|
||||
GitHub push is blocked by default too (see
|
||||
[GitHub push](security.md#github-push-blocked-by-default-optional-opt-in) in
|
||||
docs/security.md) unless `--allow-github-push` is given. For a real guarantee
|
||||
against pushes to main/master, use branch protection on the remote itself.
|
||||
|
||||
`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
|
||||
Both this hook and the PATH shadow beside it read the decision out of
|
||||
`docker/guard-lib.sh`, which is the only copy of the rules. A push that the
|
||||
library cannot read is refused rather than passed through: an unresolvable
|
||||
remote, `--mirror`, and `--git-dir` or `--work-tree`, each of which names a
|
||||
repository other than the one the remote would be resolved in.
|
||||
|
||||
`merge-guard.sh` (`PreToolUse`/`Bash`, `DEJIMA`-gated) applies the configured
|
||||
forge's policy inside the sandbox: the blocked merge command, and a command
|
||||
aimed at a repository the session was not launched against. 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`
|
||||
|
||||
+67
-54
@@ -12,19 +12,23 @@ run them.
|
||||
| --- | --- |
|
||||
| `docker/Dockerfile` | Base image: dev tools, nvim, Playwright, the sandbox user. Carries **no harness** - the selected one installs into a layer above it, from its manifest's `install` |
|
||||
| `docker/install-forge-bins.sh` | Installs each forge's CLIs into the image from its manifest's `install` list, and fails the build on a file that is shipped but not declared, or declared but not shipped |
|
||||
| `docker/fixed-domains.sh` | Shared domain list sourced by both `init-firewall.sh` and `dns-proxy.sh` |
|
||||
| `docker/dns-proxy.sh` | Starts the DNS-allowlisting `dnsmasq`, repoints `resolv.conf` at it |
|
||||
| `docker/init-firewall.sh` | IPv4/IPv6 egress allowlist firewall, run at container start |
|
||||
| `docker/ulogd.conf` | `ulogd2` config: turns `init-firewall.sh`'s NFLOG-tagged packets into JSON |
|
||||
| `docker/log-shipper.sh` | Tails the dnsmasq/ulogd2 logs and ships them to the configured Loki endpoint (`[logging] loki_url`). Container-side, because those files only exist there |
|
||||
| `docker/entrypoint.sh` | Runs as root: DNS proxy, firewall, logging, drops to the sandbox user via `gosu`, then execs the session's command |
|
||||
| `docker/fixed-domains.sh` | Composes the permitted set by class, shared by `firewall-up.sh` in the container and `dejima-allow` on the host so the two cannot disagree about which class a name belongs to |
|
||||
| `docker/egress-policy` | The egress policy itself: packet filter, restricted resolver, connection log, and the self-checks that must pass before a session starts. Also `permit`, which adds a destination to a running session |
|
||||
| `docker/dns-lookup-log` | Turns the resolver's query log into one JSON record per name lookup, with its outcome |
|
||||
| `docker/cidr-collapse` | Collapses a CIDR list into the smallest equivalent one, validating every entry |
|
||||
| `docker/firewall-up.sh` | Turns a launch's configuration into the permitted set `egress-policy` is given, then starts it. What a namespace needs, rather than what a container does |
|
||||
| `docker/filter-entrypoint.sh` | PID 1 of the filter container: runs `firewall-up.sh`, signals readiness, then holds the namespace open for the sandbox to join |
|
||||
| `docker/caps-lib.sh` | `drop_caps` and the `CAP_SETPCAP` precondition, sourced by both entrypoints so the two cannot drift |
|
||||
| `docker/log-shipper.sh` | Tails the two records `egress-policy` produces - name lookups from `dns-lookup-log`, connection verdicts from ulogd2 - and ships them to the configured Loki endpoint (`[logging] loki_url`). Container-side, because those files only exist there |
|
||||
| `docker/entrypoint.sh` | PID 1 of the session container: runs as root to prepare the session's own state, points `resolv.conf` at the namespace's resolver, then execs the session's command as the sandbox user with an empty capability bounding set. The egress control is the filter container's to bring up, not this one's |
|
||||
| `docker/harness-wrapper.sh` | Shadows the selected harness's command on `PATH` to add its no-confirm flags and dejima's settings layer. Named by no harness; `entrypoint.sh` links it into place |
|
||||
| `docker/docker-compose.yml` | Wires up mounts/env; invoked via `bin/dejima`, not directly |
|
||||
| `hooks/sandbox-context.sh` | `SessionStart` hook: injects sandbox context/workflow reminders, and any `.dejima.md` the project ships |
|
||||
| `.dejima.md` | Project-level agent instructions, harness-agnostic: `CLAUDE.md`'s role under a neutral name. dejima loads a project's into the session context; this one is dejima's own |
|
||||
| `hooks/git-push-guard.sh` | `PreToolUse` hook: blocks pushes outside the allowed host(s)/branches |
|
||||
| `hooks/git-push-guard.sh` | `PreToolUse` hook: blocks pushes outside the allowed host(s)/branches. Turns the reported command into argv and asks `docker/guard-lib.sh` |
|
||||
| `hooks/deny-dangerous-bash.sh` | `PreToolUse` hook: blocks obviously destructive Bash commands |
|
||||
| `hooks/merge-guard.sh` | `PreToolUse` hook: blocks the configured forge's merge command (`DEJIMA_MERGE_BLOCK`) in the sandbox: merging is a human decision |
|
||||
| `hooks/merge-guard.sh` | `PreToolUse` hook: applies the forge policy - the blocked merge command (`DEJIMA_MERGE_BLOCK`), and a command aimed at another repository - to a reported command. Named for the merge block, which is the rule it was created for |
|
||||
| `docker/guard-lib.sh` | The push and merge policy itself, read out of argv. The two hooks above and the two PATH shadows all call it, so a bypass is fixed once rather than twice |
|
||||
| `hooks/dejima-notify.sh` | Drops an empty `waiting`/`done` marker for a remote-enabled session. Registered as a `Stop` hook only; `waiting` is there for a harness that can say it without having stopped - see [docs/remote-access.md](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.). `dejima-audit show` / `dejima-audit ack` default to the enclosing repo |
|
||||
@@ -46,7 +50,6 @@ run them.
|
||||
| `bin/dejima-epitaph` | Host-side: keeps the summary a session leaves when its context is compacted away, in a store nothing mounts. `save` runs from `bin/dejima`'s exit trap; `list`/`search`/`show` are the operator's, and only ever read |
|
||||
| `bin/dejima-harness-lib.sh` | Loads the selected harness's plugin and provides the `emit_*` functions it declares through; the guarded-store refusal runs in `emit_mount`. Not on PATH |
|
||||
| `bin/harness/<name>.sh` | One per supported harness: what dejima does for that harness that no manifest value could state. Trusted computing base, sourced by `bin/dejima` |
|
||||
| `bin/dejima-color-lib.sh` | The colour and terminal-detection helpers the CLIs share. Not on PATH |
|
||||
| `bin/dejima-slug-lib.sh` | The `~/.claude/projects` slug scheme and which slugs belong to a workspace; sourced by `bin/dejima` (what to mount) and `bin/dejima-epitaph` (whose transcripts to keep), so the two cannot disagree. Not on PATH |
|
||||
| `bin/dejima-ttyd` | Launches `ttyd` on loopback in front of `dejima-attach` |
|
||||
| `systemd/*.service` | User units for `dejima-ttyd` and `dejima-notify-watch`; ship disabled, installed by hand |
|
||||
@@ -76,18 +79,21 @@ the security invariants the README states, not a substitute for reading it,
|
||||
but a way to actually catch a future change silently breaking one of them
|
||||
instead of finding out the hard way.
|
||||
|
||||
`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` 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 the session's command, so the suite can attach
|
||||
to a fully-initialized container instead of reimplementing its own copy of
|
||||
the setup sequence), and asserts against it: IPv6 blocked, DNS/IP allowlist
|
||||
enforcement in both directions, GitHub read-only, no working `sudo`/
|
||||
`iptables`/`init-firewall.sh` as `node`, capabilities actually stripped
|
||||
(not just absent from a `docker exec` that doesn't have them to begin
|
||||
with), `ulogd2` unprivileged, and the dynamic-ipset CDN-rotation fix.
|
||||
`static.bats` greps the repo's own files - no sudo in the `Dockerfile`, no
|
||||
`docker.sock` or host-credential mounts, the `~/.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 the session's command, so the suite can attach to a fully-initialized
|
||||
container instead of reimplementing its own copy of the setup sequence), and
|
||||
asserts against it: IPv6 refused outright with no permit path in the chain and
|
||||
no AAAA from the resolver, name and address enforcement in both directions,
|
||||
inbound and
|
||||
forwarded traffic dropped, GitHub reachable on 443 and not on 22, no working
|
||||
`sudo` and no way to change the packet filter as `node`, capabilities actually
|
||||
stripped (not just absent from a `docker exec` that doesn't have them to begin
|
||||
with), both log producers unprivileged, and the address-set CDN-rotation fix.
|
||||
|
||||
`project.bats`, `remote.bats` and `audit.bats` need no Docker either.
|
||||
`project.bats` covers the
|
||||
@@ -100,29 +106,31 @@ 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.
|
||||
|
||||
`config.bats`, `providers.bats` and `proxy.bats` round out the suites that
|
||||
need no Docker either: `config.bats` covers `~/.config/dejima/config`
|
||||
parsing and `install.sh`'s legacy-variable warnings (stubbing `docker` itself,
|
||||
since the fast CI job's container deliberately has no daemon), `providers.bats`
|
||||
covers the forge/workflow/harness manifest contract documented in
|
||||
[docs/providers.md](providers.md), and `proxy.bats` covers the credential
|
||||
proxy's own request handling. `conversation.bats` runs
|
||||
`bin/dejima-conversation-ship` against a real transcript and a real HTTP
|
||||
endpoint standing in for Loki, and asserts on what arrives: that a session's
|
||||
lines ship, that a torn line waits until it is whole, that a sibling project
|
||||
whose directory name merely extends this one is not swept up with it, and
|
||||
that neither the in-memory batch nor the unread tail is lost to the kill that
|
||||
ends a session.
|
||||
`config.bats`, `providers.bats`, `proxy.bats`, `launch.bats` and `share.bats`
|
||||
round out the suites that need no Docker either: `config.bats` covers
|
||||
`~/.config/dejima/config` parsing and `install.sh`'s legacy-variable warnings
|
||||
(stubbing `docker` itself, since the fast CI job's container deliberately has
|
||||
no daemon), `providers.bats` covers the forge/workflow/harness manifest
|
||||
contract documented in [docs/providers.md](providers.md), `proxy.bats` covers
|
||||
the credential proxy's own request handling, `launch.bats` the command line a
|
||||
launch builds, and `share.bats` the shared scratch directory's own layout.
|
||||
`conversation.bats` runs `bin/dejima-conversation-ship` against a real
|
||||
transcript and a real HTTP endpoint standing in for Loki, and asserts on what
|
||||
arrives: that a session's lines ship, that a torn line waits until it is whole,
|
||||
that a sibling project whose directory name merely extends this one is not
|
||||
swept up with it, and that neither the in-memory batch nor the unread tail is
|
||||
lost to the kill that ends a session.
|
||||
|
||||
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
|
||||
reaches nothing at all. That pair is the ttyd front door's whole containment
|
||||
argument, so run it before trusting a front door you just set up.
|
||||
Docker host: `runtime.bats` proves that a service on the host is unreachable
|
||||
from inside a container - bound to loopback, and bound to the bridge gateway
|
||||
on a port the policy was not told to open. A second container, given that one
|
||||
port, reaches it; that is the positive control, and it is what stops the
|
||||
others from passing in an environment where the container reaches nothing at
|
||||
all. That set 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
|
||||
Every mutation in `runtime.bats` (firewall rules, address-set 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
|
||||
derived from bats' own per-run temp directory, not anything guessable or
|
||||
@@ -172,10 +180,12 @@ subset has.
|
||||
|
||||
`runtime.bats` is the only suite that needs a Docker daemon: it is the one
|
||||
that builds the image and boots real containers to assert against.
|
||||
Everything else, `audit.bats`, `ci-runner.bats`, `config.bats`,
|
||||
`conversation.bats`, `project.bats`, `providers.bats`, `proxy.bats`,
|
||||
`remote.bats` and `static.bats`, runs anywhere a shell does, including inside
|
||||
a dejima sandbox itself, which has no Docker daemon by design.
|
||||
`ci-runner.bats` needs something rarer - a real CI runner of either tier - and
|
||||
skips itself anywhere else. Everything in between runs anywhere a shell does,
|
||||
including inside a dejima sandbox itself, which has no Docker daemon by design.
|
||||
`test/run-confined.sh` discovers that set rather than naming it, so a suite
|
||||
added under `test/` is in the lane the day it lands - which is also why no list
|
||||
of them is kept here.
|
||||
|
||||
`.gitea/workflows/tests.yml` also runs `shellcheck` over every shell script in
|
||||
the repo, as its own job. `test/shellcheck.sh` holds the file list, discovered
|
||||
@@ -193,9 +203,10 @@ obscurely partway through; the file says why each is needed.
|
||||
## Adding a new harness
|
||||
|
||||
`[core] harness` picks a directory under `harnesses/`, and that harness's
|
||||
manifest declares eleven keys (see [providers.md](providers.md)). The
|
||||
inventory below is everything dejima does that is shaped by *which* harness is
|
||||
selected: which parts are the manifest's, which are the plugin's, and
|
||||
manifest declares what dejima needs to know about it - every key is in
|
||||
[providers.md](providers.md). The inventory below is everything dejima does
|
||||
that is shaped by *which* harness is selected: which parts are the manifest's,
|
||||
which are the plugin's, and
|
||||
which are still written against Claude Code by name. Taken from a pass over
|
||||
the tree, so each row names the file rather than describing a sketch - and a
|
||||
row that says "in the plugin" is one a second harness supplies rather than one
|
||||
@@ -239,12 +250,14 @@ R2 was a hard gate until the guards stopped needing it. Two of the three it
|
||||
hosted - the push guard and the merge guard - are now PATH shadows over `git`
|
||||
and the forge CLI (`docker/git-guard.sh`, `docker/forge-guard.sh`), which hold
|
||||
whatever the harness can or cannot intercept, and read argv rather than
|
||||
regex-matching a reconstructed command string. That left one guard depending
|
||||
on interception, and it is the one this repository already describes as "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" (docs/security.md). A gate that costs a harness its support should be
|
||||
guarding more than that.
|
||||
regex-matching a reconstructed command string. The hooks beside them read argv
|
||||
too: they split the reported command into words and ask the same library
|
||||
(`docker/guard-lib.sh`), so the overlap cannot become a disagreement. That
|
||||
left one guard depending on interception, and it is the one this repository
|
||||
already describes as "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" (docs/security.md). A gate that costs a harness its
|
||||
support should be guarding more than that.
|
||||
|
||||
Declaring one absent is a real decision with a visible cost, not a
|
||||
formality. It is there so a harness can be supported honestly at a smaller
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
#
|
||||
# So two things carry this feature's entire security, and both live here:
|
||||
#
|
||||
# 1. The listen address. docker/init-firewall.sh:187 accepts container
|
||||
# 1. The listen address. docker/egress-policy permits 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
|
||||
|
||||
+19
-10
@@ -127,8 +127,8 @@ operator can set, resolved the same way as any other config key
|
||||
| `capabilities` | `?list:identifier` | (empty) | What this forge can do - `pr`, `issues`, etc. A workflow's own `requires` is checked against this set at launch; a workflow asking for a capability this forge lacks is refused. |
|
||||
| `pr_command` | `?command` | (empty) | The PR-management command name the forge's briefing prose and `env_export`/`merge_block` refer to. |
|
||||
| `issue_command` | `?command` | (empty) | The issue-management command name, same idea. |
|
||||
| `merge_block` | `?command` | (empty) | The command prefix refused inside the sandbox - only when the workflow's `block_merge = on`. Enforced by `docker/forge-guard.sh`, which shadows the named CLI on PATH and compares argv, so it holds for a harness that cannot intercept a tool call; `hooks/merge-guard.sh` applies the same rule to a command string, for the harnesses that can. Matched as a run of consecutive non-flag arguments, so a flag before the subcommand does not slip past it. |
|
||||
| `repo_flags` | `?list:flag` | (empty) | The option names this forge's CLI accepts for naming a repository other than the one the session was launched against - `--repo -R` for `gh`. `docker/forge-guard.sh` refuses a value that is not that repository, in every spelling of the flag itself: `--repo x`, `--repo=x`, `-R x`, `-Rx`, and the letter inside a short-flag cluster (`-wR x`, `-wRx`). Fast-fail, not a boundary, for the same reason `merge_block` is not one: the session holds the token. Leave it empty for a CLI that resolves the repo itself and refuses another host, as the gitea ones do - a forge is not required to be aimable. |
|
||||
| `merge_block` | `?command` | (empty) | The command prefix refused inside the sandbox - only when the workflow's `block_merge = on`. Enforced by `docker/forge-guard.sh`, which shadows the named CLI on PATH and compares argv, so it holds for a harness that cannot intercept a tool call; `hooks/merge-guard.sh` applies the same rule to a reported command for the harnesses that can, through the same library, so the two cannot disagree. Matched as a run of consecutive non-flag arguments, so a flag before the subcommand does not slip past it. |
|
||||
| `repo_flags` | `?list:flag` | (empty) | The option names this forge's CLI accepts for naming a repository other than the one the session was launched against - `--repo -R` for `gh`. `docker/forge-guard.sh` refuses a value that is not that repository, in every spelling of the flag itself: `--repo x`, `--repo=x`, `-R x`, `-Rx`, and the letter inside a short-flag cluster (`-wR x`, `-wRx`). Fast-fail, not a boundary, for the same reason `merge_block` is not one: the session holds the token. Leave it empty for a CLI that resolves the repo itself and refuses another host, as the gitea ones do - a forge is not required to be aimable. **Enforced only for a CLI that also appears in `merge_block` or `disclose`:** those two are what name the commands the shadow is linked over and the hook checks against, so a forge declaring this and neither of them would get the rule applied to nothing. |
|
||||
| `identity_keys` | `?list:identifier` | (empty) | Which of this forge's own `keys` name an **account** rather than the forge itself - the credential paths, which differ per session, where an API URL does not. One declaration, two rules: a key named here is one an `[identity.<name>]` section may set, and one a project may not declare in its own `.dejima/`. Core keeps no list of its own, so a forge whose credential is spelled differently needs no change here. The identity inherits the type from this forge's `keys` entry, not a second copy of it. |
|
||||
| `repo_env` | `?identifier` | (empty) | The environment variable this forge's CLI reads for the same thing - `GH_REPO`. Declared separately because it takes no argument and would otherwise be the way around every flag above. |
|
||||
| `repo_paths` | `?list:repopath` | (empty) | A repository this forge's CLI names in a path rather than in a flag, as `<subcommand>:<first segment>` - `api:repos` for `gh api repos/<owner>/<name>/...`. Scoped to a subcommand on purpose: comparing every positional argument that contains two slashes would refuse an issue title that happens to look like one, and a false refusal costs an honest agent a working command. Paths carrying `gh`'s own `{owner}`/`{repo}` placeholders are left alone, since those resolve to this repository by construction. |
|
||||
@@ -138,7 +138,7 @@ operator can set, resolved the same way as any other config key
|
||||
| `host_keys` | `?list:identifier` | (empty) | Config keys (from `keys`) that hold a literal hostname - unioned by `forge_hosts` into the firewall allowlist and the push guard's allowed hosts. |
|
||||
| `url_keys` | `?list:identifier` | (empty) | Config keys that hold a URL - `forge_hosts` strips scheme and path and unions the bare host in the same way as `host_keys`. |
|
||||
| `fixed_hosts` | `?list:hostname` | (empty) | Hostnames this forge always needs reachable, independent of any config value (`github` uses this - `github.com`, `ssh.github.com`, `api.github.com` are the same three hosts for every operator, so there's nothing to configure and nothing to get wrong). |
|
||||
| `hosts_allowlisted_by_core` | `bool` | `off` | `on` means this forge's hosts are ones core already allowlists in its own right - GitHub's published ranges, which every session can read from over 443 whatever forge is selected. Core then keeps them out of `DEJIMA_FORGE_HOSTS` (the extra-domains path, which resolves a name into the port-unrestricted `allowed-domains` ipset and exempts it from DNS-rebind protection - right for an internal self-hosted host, a silent widening for a public one) and, when the workflow pushes, opens core's own GitHub push grant to them instead: port 22 to those same ranges, logged and verified by the firewall's startup self-check. They still reach `DEJIMA_PUSH_HOSTS`, so `git-push-guard.sh` permits the push. Name-free in core, but GitHub-specific in effect: this key means "core already allowlists these hosts in its own right", which today is only true of GitHub's ranges, so a future forge whose hosts are not among those must leave it `off` - setting it `on` for anything else grants no firewall allowance at all plus an irrelevant GitHub push grant. |
|
||||
| `hosts_allowlisted_by_core` | `bool` | `off` | `on` means this forge's hosts are ones core already allowlists in its own right - GitHub's published ranges, which every session can read from over 443 whatever forge is selected. Core then keeps them out of `DEJIMA_FORGE_HOSTS` (the open-class path, which permits a name on every port and exempts it from the check that refuses an answer pointing inside a private network - right for an internal self-hosted host, a silent widening for a public one) and, when the workflow pushes, opens core's own GitHub push grant to them instead: port 22 to those same ranges, logged like every other allowed connection. The startup self-check asserts the refusal when the opt-in is off, and skips that check when it is on, so a break in the opt-in plumbing shows up as a failed push rather than a container that will not start. They still reach `DEJIMA_PUSH_HOSTS`, so `git-push-guard.sh` permits the push. Name-free in core, but GitHub-specific in effect: this key means "core already allowlists these hosts in its own right", which today is only true of GitHub's ranges, so a future forge whose hosts are not among those must leave it `off` - setting it `on` for anything else grants no firewall allowance at all plus an irrelevant GitHub push grant. |
|
||||
| `install` | `?list:installspec` | (empty) | The CLIs this forge puts on PATH in the image - the only manifest key naming something that actually gets copied and executed. Either a path under the forge's own directory, or `system:<name>` for a command the image already provides from a package. The list is the gate, not a description of one: `docker/install-forge-bins.sh` installs exactly what is declared, and fails the build when a declared file is missing, when a declared `system:` command is not in the image, and when a forge ships a file under its `bin/` that it does not declare. Every command a forge names anywhere - `pr_command`, `issue_command`, `merge_block`, `disclose` - has to appear here, because a CLI that is not behind the shadow is one the guards cannot reach: the entrypoint refuses to start rather than leave a block or a disclosure looking enforced. |
|
||||
| `env_export` | `?list:export` | (empty) | `NAME=key` (the config key's resolved value) or `NAME=@key` (the *contents* of the file the config key names) pairs - the environment variables `bin/dejima` passes into the container for this forge. The `@` form is how a token reaches the container without its path being mistaken for the secret. |
|
||||
|
||||
@@ -231,13 +231,14 @@ A workflow's `briefing.md` is plain Markdown plus two marker forms that
|
||||
| `image_env` | `?list:envpair` | (empty) | `NAME=VALUE` pairs set in this harness's image layer, for anything it wants in the environment of every session of its own. Deliberately narrow - no whitespace, nothing shell-active - because each becomes an `ENV` line in a generated Dockerfile. |
|
||||
| `command` | `identifier` | `claude` | The container's main process for this harness, and the name the wrapper shadows on `PATH`. `[core] command` defaults to it. |
|
||||
| `command_path` | `?path` | (empty) | Where the real binary lives, behind the shadow. |
|
||||
| `api_domains` | `?list:hostname` | (empty) | Hostnames this harness's own machinery must reach - its update host, its telemetry endpoint. They join the *fixed* allowlist rather than the extra one, which `dns-proxy.sh` exempts from stop-dns-rebind and `init-firewall.sh` treats as non-fatal when it fails to resolve. |
|
||||
| `api_domains` | `?list:hostname` | (empty) | Hostnames this harness's own machinery must reach - its update host, its telemetry endpoint. They join the built-in part of the permitted set rather than the operator's, and are not exempted from the check that refuses an answer pointing inside a private network - a harness's endpoints are public names, and exempting a public name is what turns one influenced answer into egress to an internal host. |
|
||||
| `api_styles` | `?list:identifier` | (empty) | Which backend `api_style`s this harness has a client for. `claude-code` speaks `anthropic`; `cline` speaks both, which is what makes it the backend-agnostic one. Empty means unchecked. |
|
||||
| `guards` | `?list:identifier` | (empty) | Which of dejima's guard rails this harness can host: `bash-deny`, `push-guard`, `merge-guard`, `notify`. A name outside that set is refused rather than treated as an unknown guard, since a typo would otherwise turn a guard off silently. The ones a harness does not declare are named at launch - see "What a harness must be able to do" in [development.md](development.md). |
|
||||
| `assumed_backend` | `?provider:backend` | (empty) | Which backend this harness talked to before `[harness.<name>] backend` existed. Read only when that key is unset, and it writes into the operator's config rather than being consulted at launch - the one key here that does. |
|
||||
| `briefing_args` | `?args` | (empty) | The flag that carries the briefing to a harness taking it on the command line - for Claude Code, `--append-system-prompt`. `dejima-harness-wrapper` reads the rendered briefing and passes it as a single value, so every invocation carries it and not only the entrypoint's. Set, the session-start hook stops carrying the briefing and reduces to the session id, which is the one thing that does not exist until the harness starts. |
|
||||
| `briefing_path` | `?homepath` | (empty) | Where this harness reads its briefing from, relative to `$HOME`, for a harness that has no session-start hook to be handed it through. Set, `entrypoint.sh` renders the briefing into that path root-owned and read-only before the session starts, and refuses to start it at all if that fails. Empty for a harness that takes the briefing on the command line (`briefing_args`, which is how Claude Code gets it) or that can be handed it through a session-start hook. |
|
||||
| `no_confirm_args` | `?template` | (empty) | The flags that make the harness run without confirming each tool call. Required in practice: dejima refuses to launch a harness that names none, because unattended operation is the premise the container is built on (R1 in [development.md](development.md)). |
|
||||
| `settings_args` | `?template` | (empty) | How this harness is told to read dejima's own settings layer, mounted at `/etc/dejima-settings.json`. Empty for a harness that takes its overrides some other way. |
|
||||
| `no_confirm_args` | `?args` | (empty) | The flags that make the harness run without confirming each tool call. Required in practice: dejima refuses to launch a harness that names none, because unattended operation is the premise the container is built on (R1 in [development.md](development.md)). |
|
||||
| `settings_args` | `?args` | (empty) | How this harness is told to read dejima's own settings layer, mounted at `/etc/dejima-settings.json`. Empty for a harness that takes its overrides some other way. |
|
||||
| `keys` | `?list:keyspec` | (empty) | This harness's own `[harness.<name>]` config schema - same grammar as a forge's `keys`. |
|
||||
| `resume_command` | `?template` | (empty) | Prose template (may use `{{session_id}}`) shown at the end of the briefing for resuming this exact session on the host. Substitution is not recursive and the `template` type has no `=`, so a resume command that needs a second placeholder or an environment variable goes in the harness's briefing fragment instead, where both resolve. |
|
||||
| `transcript_update` | `enum:append\|rewrite` | `append` | How this harness's transcript files change, which decides how they can be read incrementally. `append` means the file only grows, one record per line, so the conversation shipper reads forward from a byte offset and consumes only whole lines. `rewrite` means the file is replaced wholesale, where an offset would land in the middle of a document - it is re-read when it changes and the records already shipped are skipped by count. Declared rather than sniffed, because append semantics against a rewritten file ship garbage. |
|
||||
@@ -247,7 +248,7 @@ Worked example, `harnesses/claude-code/harness.conf`:
|
||||
|
||||
```
|
||||
command = claude
|
||||
command_path = /usr/local/share/npm-global/bin/claude
|
||||
command_path = /opt/node-tools/bin/claude
|
||||
guards = bash-deny push-guard merge-guard notify
|
||||
no_confirm_args = --dangerously-skip-permissions
|
||||
settings_args = --settings /etc/dejima-settings.json
|
||||
@@ -675,8 +676,13 @@ knowing about because they are what a second forge costs:
|
||||
as such, and a count of what was hidden. Everything else `gitea-pr` does has
|
||||
a `gh` subcommand already.
|
||||
- **`gh` can be aimed past the sandboxed repository, and is stopped.** The
|
||||
gitea CLIs refuse an origin that is not on the configured host, so they can
|
||||
only act on the repo the sandbox was launched against. `gh` takes `--repo`
|
||||
gitea CLIs resolve the repository from `origin` and refuse an origin that is
|
||||
not on the configured host, so there is no flag or variable to compare and
|
||||
nothing for this guard to declare. That is a narrower confinement than it
|
||||
sounds: `origin` lives in `.git/config`, inside the read-write project mount,
|
||||
and the gitea token is account-wide - so a session that rewrites `origin` to
|
||||
another repository **on the same host** reaches it. What bounds that is the
|
||||
token's scope, as it is for `gh`. `gh` takes `--repo`
|
||||
on nearly every subcommand, reads `GH_REPO`, and names repositories in `gh
|
||||
api` paths, so this forge declares all three (`repo_flags`, `repo_env`,
|
||||
`repo_paths`) and `docker/forge-guard.sh` refuses a value that is not the
|
||||
@@ -751,7 +757,10 @@ never needs port 22 at all. It opens it through core's existing GitHub push
|
||||
grant (`ALLOW_GITHUB_PUSH`, the same one `--allow-github-push` uses), which
|
||||
its `hosts_allowlisted_by_core = on` is what asks for: port 22 to GitHub's
|
||||
published ranges and nothing else, logged like every other allowed
|
||||
connection and verified by the firewall's own startup self-check. The
|
||||
connection. The startup self-check asserts that port 22 is refused when the
|
||||
opt-in is off, and skips that check when it is on, so a break in the opt-in
|
||||
plumbing shows up as a failed push rather than a container that will not
|
||||
start. The
|
||||
port restriction is an IPv4 statement; what makes port 22 shut *at all*
|
||||
without this grant is that the container blocks IPv6 as well, so an
|
||||
allowlist naming one address family is not the whole of it. Only when
|
||||
|
||||
+15
-10
@@ -52,13 +52,17 @@ running.
|
||||
|
||||
## The bind address is the one thing to get right
|
||||
|
||||
`docker/init-firewall.sh` deliberately ACCEPTs container OUTPUT to the
|
||||
container's default-route address - the Docker bridge gateway, which is your
|
||||
host. That is the one host address a sandbox can reach at all. Combine it
|
||||
with the fact above (a tmux client is a host shell) and the consequence is
|
||||
direct: **a front door the sandbox can reach is a compromised session's own
|
||||
The session's default-route address - the Docker bridge gateway - is your
|
||||
host, and it is the one host address a sandbox can route to at all. Combine
|
||||
that with the fact above (a tmux client is a host shell) and the consequence
|
||||
is direct: **a front door the sandbox can reach is a compromised session's own
|
||||
escape hatch.**
|
||||
|
||||
`docker/egress-policy` opens no port on that address unless told to, so a
|
||||
front door bound to it is refused by the packet filter as well. Do not spend
|
||||
that: the clamp is the second layer, and binding the proxy correctly is the
|
||||
first.
|
||||
|
||||
So:
|
||||
|
||||
- `ttyd` is bound to `127.0.0.1` by `dejima-ttyd`, hardcoded, not
|
||||
@@ -901,10 +905,11 @@ invisibly, and on the same `XDG_RUNTIME_DIR` as the gotcha above.
|
||||
|
||||
**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, so a session filling `/run/dejima-notify` degrades your
|
||||
whole login session - D-Bus, the systemd user manager - not just dejima. A
|
||||
session can already fill the workspace mount, so this is an increment rather
|
||||
than a new exposure, but a RAM-backed filesystem fails harder and sooner.
|
||||
physical memory, so a session filling `${XDG_RUNTIME_DIR}/dejima/notify`
|
||||
degrades your whole login session - D-Bus, the systemd user manager - not just
|
||||
dejima. A session can already fill the workspace mount, so this is an increment
|
||||
rather than a new exposure, but a RAM-backed filesystem fails harder and
|
||||
sooner.
|
||||
There is deliberately no quota here; `rm -rf` on that run's marker directory (or
|
||||
logging out, which discards the tmpfs) is the recovery.
|
||||
|
||||
@@ -1014,7 +1019,7 @@ 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.
|
||||
parses those rows itself and `dejima-attach --help` calls them scriptable.
|
||||
|
||||
Three states, from a registry `dejima` writes on launch and removes on
|
||||
exit:
|
||||
|
||||
@@ -12,7 +12,7 @@ August, turned up two things worth having found sooner than that:
|
||||
a `.google.com` suffix while the resolver read `attacker-host`. dejima's
|
||||
matcher refused that shape and every neighbouring one, for structural reasons
|
||||
now recorded beside it - it matches whole domains rather than suffixes, and
|
||||
the decision and the dial are bound by the ipset being filled from the
|
||||
the decision and the dial are bound by the address set being filled from the
|
||||
answer the resolver returned. One laxity turned up and was fixed.
|
||||
- **The closest comparable project ships the opposite default.** Trail of Bits'
|
||||
Claude Code devcontainer lists network under "not sandboxed" and has full
|
||||
@@ -156,11 +156,11 @@ both distinctions vanish within a week of writing:
|
||||
|
||||
A bypass from the literature is a hypothesis about *their* implementation until
|
||||
it is run against this one. dejima differs in ways that plausibly change the
|
||||
answer: enforcement is `iptables` and a DNS proxy rather than an in-process
|
||||
proxy, the matcher is an anchored shell regex rather than a string-suffix test,
|
||||
the allowlist matches whole domains rather than suffixes, and the ipset is
|
||||
populated from the answer the resolver returned rather than from a second parse
|
||||
of a string.
|
||||
answer: enforcement is a packet filter and a restricting resolver rather than an
|
||||
in-process proxy, the matcher is an anchored shell regex rather than a
|
||||
string-suffix test, the allowlist matches whole domains rather than suffixes,
|
||||
and the address sets are populated from the answer the resolver returned rather
|
||||
than from a second parse of a string.
|
||||
|
||||
Each entry states a prediction that can fail.
|
||||
|
||||
@@ -170,8 +170,8 @@ Each entry states a prediction that can fail.
|
||||
has since been fixed.*
|
||||
- **A published SOCKS-style parser differential, run end to end** rather than
|
||||
against the validator alone. Prediction: no path, because nothing in dejima
|
||||
speaks SOCKS and the decision and the dial are bound by the ipset. Not yet
|
||||
speaks SOCKS and the decision and the dial are bound by the address set. Not yet
|
||||
run.
|
||||
- **Redirect chains to a host outside the allowlist.** Prediction: fail at the
|
||||
ipset, because dejima does not mediate HTTP and the connection is judged by
|
||||
address. Not yet run.
|
||||
packet filter, because dejima does not mediate HTTP and the connection is
|
||||
judged by address. Not yet run.
|
||||
|
||||
+284
-84
@@ -87,7 +87,7 @@ last is what remains once something is.
|
||||
|
||||
| Layer | Reached by | Radius without | Bounded by | Radius with |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Agent, not yet hostile | ordinary operation | the workspace, its history, every mounted path, and the forge, at full speed | the session briefing and `.dejima.md`; the `PreToolUse` hooks | obvious destruction and out-of-remote pushes fail fast, and merging stays a human decision |
|
||||
| Agent, not yet hostile | ordinary operation | the workspace, its history, every mounted path, and the forge, at full speed | the session briefing and `.dejima.md`; the command guards | obvious destruction and out-of-remote pushes fail fast, and merging stays a human decision |
|
||||
| Container | ordinary operation | the operator's home directory, every credential in it, and the open internet | dejima | the workspace, allowlisted domains, and the forge token's scope |
|
||||
| Devbox VM, unprivileged | container escape | everything the account can read, and unrestricted egress to the internet and the LAN | `dejima-devbox-hardener` | the same VM, with LAN reach cut to what is declared; internet egress stays open, deliberately |
|
||||
| Devbox VM, root | escape plus local escalation, or misconfiguration | the whole VM, the same egress, and the model API credential | `dejima-devbox-hardener`, enforced outside the guest | the whole VM and no further: root cannot reach the rule set that holds the LAN boundary |
|
||||
@@ -176,8 +176,8 @@ the sandbox at it instead of at you:
|
||||
- `[forge.gitea] token_file`: that account's token, rather than one issued
|
||||
under yours.
|
||||
- `[core] ssh_key`: a deploy key belonging to it, rather than your forwarded
|
||||
agent. See "Headless git push without agent forwarding" in
|
||||
[docs/configuration.md](configuration.md).
|
||||
agent - which is then not mounted at all. See "Headless git push without
|
||||
agent forwarding" in [docs/configuration.md](configuration.md).
|
||||
|
||||
With the two identities separated, the same settings stop being decorative:
|
||||
|
||||
@@ -202,8 +202,7 @@ collaborator on the repositories it works on. Write is the ceiling here, not
|
||||
a floor: an account that owns a repository, or holds admin on it, can merge
|
||||
its own work and edit the settings in the table above, so every row of that
|
||||
table goes back to being decorative. `write:repository` and `write:issue` are
|
||||
the scopes its token needs (README, "Gitea/Forgejo PR workflow"), and nothing
|
||||
about that changes for a collaborator.
|
||||
the scopes its token needs, and nothing about that changes for a collaborator.
|
||||
|
||||
One consequence presents as a misconfiguration, and the obvious fix is the
|
||||
wrong one. Parts of Gitea's API are gated on *ownership* rather than on
|
||||
@@ -271,7 +270,7 @@ In that configuration the provider directories are in the exit-time audit's
|
||||
TCB list, alongside `bin/`, `hooks/`, `docker/`, `test/` and `tools/`. They
|
||||
hold no code and nothing executes them, but what they hold decides what the
|
||||
next launch permits: a forge's `fixed_hosts` reaches `DEJIMA_FORGE_HOSTS`,
|
||||
which `entrypoint.sh` joins into the domains `init-firewall.sh` allowlists, so
|
||||
which `firewall-up.sh` joins into the permitted set the egress policy is given, so
|
||||
a line added to a manifest widens the egress boundary on the next run.
|
||||
`tools/*/tool.conf` is the same class, and is read by the host rather than the
|
||||
container: it names a tarball URL, the sha256 checked against it and the
|
||||
@@ -782,7 +781,22 @@ reach the model - which reads as a dejima fault rather than a missing
|
||||
prerequisite. Sign in on the host first; see the README's
|
||||
[Sign in on the host first](../README.md#sign-in-on-the-host-first).
|
||||
Refreshing an expired credential is the same: on the host, not from inside a
|
||||
session, where the credential deliberately never goes.
|
||||
session, where the credential deliberately never goes. A session that outlives
|
||||
its credential - one left open overnight is the ordinary way - meets a 502 from
|
||||
the proxy naming that as the remedy, and the same line is the last thing the
|
||||
proxy logs, so the exit audit repeats it on the host terminal.
|
||||
|
||||
**What the proxy may tell a session about a credential failure.** Anything it
|
||||
learned from the provider stays on the host: a response body, a status line, an
|
||||
exception. What crosses into the container is text this repository wrote, and
|
||||
only when it names something the operator has to do - "log in again on the
|
||||
host" is useful to an agent that would otherwise report itself broken, and
|
||||
discloses nothing the session did not already know. The distinction is the
|
||||
credential proxy's `CredentialNeedsOperator`, which carries the two separately
|
||||
so neither can be sent by accident. The proxy's own log is bound by the same
|
||||
rule - it keeps provider statuses and error-body prefixes for the exit audit -
|
||||
so it lives outside the socket directory, which is the one proxy path the
|
||||
session mounts.
|
||||
|
||||
### The refreshing OAuth path, in detail
|
||||
|
||||
@@ -999,6 +1013,9 @@ reasoning below is what both are built on.
|
||||
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.
|
||||
Setting it also withdraws the forwarded agent: `SSH_AUTH_SOCK` is not passed
|
||||
to the container, so the two paths are alternatives rather than both being
|
||||
open at once.
|
||||
That scoping is a convention, not something enforced here: nothing checks
|
||||
what `[core] ssh_key` actually points at, so pointing it at your real
|
||||
`~/.ssh/id_ed25519`/`id_rsa` instead of a dedicated key would load your
|
||||
@@ -1017,15 +1034,54 @@ reasoning below is what both are built on.
|
||||
SSH-agent/token story documented above. Keep credentials out of your
|
||||
global gitconfig, or expect them to be reachable from inside the
|
||||
sandbox.
|
||||
- **The egress filter is not in the container the session runs in.** A launch
|
||||
starts two: a filter container that owns the network namespace and installs
|
||||
the packet filter, and the sandbox, which joins that namespace with
|
||||
`network_mode: container:` rather than getting one of its own. Capabilities
|
||||
are per process rather than per namespace, so the sandbox cannot alter the
|
||||
filter bounding it even at uid 0 - measured, not reasoned: `nft` returns
|
||||
`Operation not permitted` to a joiner that lacks `NET_ADMIN`.
|
||||
|
||||
Two things follow. The namespace has its rules before anything is created in
|
||||
it, so there is no window in which a session holds an interface and no
|
||||
filter; `bin/dejima` waits for the filter container to say it is up and
|
||||
refuses the launch otherwise. And killing the filter container leaves the
|
||||
sandbox holding loopback alone - no network at all, rather than an unfiltered
|
||||
one.
|
||||
|
||||
Nothing a session runs ever executes in the filter container. It holds the
|
||||
privilege; the one beside it holds the session. Each launch gets a network of
|
||||
its own, so concurrent sessions are separated at the link layer as well.
|
||||
|
||||
A launch removes both on the way out, and reconciles at the start against
|
||||
whatever a `SIGKILL` or a restarted host left behind - a filter container
|
||||
with no session in its namespace, older than the window in which a pair is
|
||||
still being built. What that reclaims is address space rather than safety: a
|
||||
leaked filter container filters a namespace nothing is in, and Docker's
|
||||
address pool is finite enough that exhausting it fails a later launch with an
|
||||
error naming neither cause nor remedy.
|
||||
- The sandbox's own user (matching your host user; see "Config parity" in
|
||||
the README) has no `sudo`/setuid path back to root once the container is up:
|
||||
`entrypoint.sh` runs as root, sets up the firewall directly, then drops to
|
||||
that user via `gosu` before execing the configured command (`[core] command`,
|
||||
or the selected harness's own if that is unset). A compromised session can't
|
||||
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.
|
||||
`entrypoint.sh` runs as root, prepares the session's own state, then drops to
|
||||
that user before execing the configured command (`[core] command`, or the
|
||||
selected harness's own if that is unset). A compromised session can't
|
||||
re-run the egress policy with a wider allowlist to open its own
|
||||
exfiltration path. The script is in reach - it is the same image, and
|
||||
`/usr/local/sbin` is on that user's PATH - and reaching it is not the
|
||||
question: it refuses outright as a non-root caller, and the capability it
|
||||
would need to change anything belongs to the container beside this one.
|
||||
Verified both ways, in `runtime.bats`: the session user cannot run it, and
|
||||
root in the session container cannot either.
|
||||
- **The session's capability bounding set is empty**, not merely its effective
|
||||
one. The distinction is the whole of it: an empty effective set is what
|
||||
changing uid gives you anyway, while the bounding set is what a process that
|
||||
reached uid 0 in that tree would recover its capabilities from. So the two
|
||||
claims above - no path back to root, and no capability if there were one -
|
||||
hold separately rather than one resting on the other. The drop and the uid
|
||||
change happen in one `capsh` process because they have to: the bounding set
|
||||
can only be reduced while `CAP_SETPCAP` is still held, and changing uid first
|
||||
gives it away. The same applies to `log-shipper.sh`, the one root process
|
||||
that outlives the entrypoint.
|
||||
- A forge credential reaches the container as `-e NAME`, never
|
||||
`-e NAME=value`. The value comes from the launcher's own environment, so it
|
||||
sits in `/proc/<pid>/environ` (mode 0400, owner only) rather than in
|
||||
@@ -1033,17 +1089,45 @@ reasoning below is what both are built on.
|
||||
anyone. `docker inspect` still shows the resolved value to whoever can
|
||||
reach the daemon - that is daemon access rather than any process at all,
|
||||
and it is not something the launcher can change.
|
||||
- The container runs with `no-new-privileges` and an explicit capability
|
||||
floor - `cap_drop: ALL` plus the ones it actually uses: what the
|
||||
entrypoint's root phase needs, and `KILL`. `KILL` is there for
|
||||
`dejima-allow --container`, not for the entrypoint - adding a domain to a
|
||||
running session signals dnsmasq, and dnsmasq runs as its own user. The list
|
||||
itself is in `docker/docker-compose.yml`, which is where it stays correct. The first means a setuid binary or a file capability cannot raise
|
||||
- **Each container runs with `no-new-privileges` and a capability floor of its
|
||||
own**, `cap_drop: ALL` plus what that container actually uses. The two lists
|
||||
are the arrangement stated as configuration:
|
||||
|
||||
The session container declares **no network capability at all** - not
|
||||
`NET_ADMIN`, not `NET_RAW`. It is inside a namespace the filter container
|
||||
owns and has already installed the rules in, and capabilities are per
|
||||
process, so uid 0 in there is refused by the kernel rather than by policy.
|
||||
What it keeps is what its root phase does before the session starts: write
|
||||
and chown files, and drop privilege once. `SETPCAP` is there to spend rather
|
||||
than to use - it is what lets the entrypoint take the rest of the list away
|
||||
again before execing the session, and since Linux 4.3 it grants nothing a
|
||||
process does not already hold.
|
||||
|
||||
The filter container holds the network capabilities. The session's list is in
|
||||
`docker/docker-compose.yml` and the filter's is in `bin/dejima`, each beside
|
||||
the container it describes.
|
||||
|
||||
`KILL` is on both lists, for unrelated reasons, and it is the one worth
|
||||
reading twice. The filter container needs it because adding a domain to a
|
||||
running session signals dnsmasq, which runs as its own user. The session
|
||||
container needs it because `init: true` makes PID 1 an init that forwards
|
||||
signals to the session - and the entrypoint changes uid in place before
|
||||
exec'ing it, so that is uid 0 signalling a process whose real and saved uids
|
||||
are the sandbox user's. Without the capability every forwarded signal is
|
||||
`EPERM`, tini treats a failed forward as fatal, and an ordinary terminal
|
||||
resize ends the session.
|
||||
|
||||
`no-new-privileges` means a setuid binary or a file capability cannot raise
|
||||
privileges on exec, which is the shape any escalation from the session user
|
||||
would have to take; the second means the capabilities nothing here uses were
|
||||
never in the bounding set to be regained. Both are asserted against a running
|
||||
container rather than against the compose file, and the capability list the
|
||||
test uses is read out of that file so the two cannot drift.
|
||||
would have to take. The floor means the capabilities nothing uses were never
|
||||
in the bounding set to be regained. Both are asserted against running
|
||||
containers rather than against the files, and both lists the tests use are
|
||||
read out of those files so neither can drift.
|
||||
|
||||
What a floor describes is a container's **root phase**. A launch runs with
|
||||
`init: true`, so PID 1 is the init that reaps for the session and it holds
|
||||
that container's floor for as long as the container lives; the session is its
|
||||
child and holds nothing.
|
||||
- The approval store (`${DEJIMA_CONFIG_DIR:-$HOME/.config/dejima}`,
|
||||
see
|
||||
[Global and per-project domains and dependencies](../README.md#global-and-per-project-domains-and-dependencies)
|
||||
@@ -1147,12 +1231,14 @@ the window this closes.
|
||||
|
||||
## Network egress firewall
|
||||
|
||||
On start, the container runs an allowlist-only `iptables`/`ipset` firewall
|
||||
(`init-firewall.sh`), matching the reference dev container. Allowed by
|
||||
default:
|
||||
On start, the filter container establishes an allowlist-only egress policy
|
||||
(`docker/egress-policy`): one `nftables` inet table with a closed default and
|
||||
everything the session may reach expressed as named sets. Allowed by default:
|
||||
|
||||
- DNS resolution via a local proxy (see "DNS is allowlisted too")
|
||||
- the Docker host itself (not its whole subnet)
|
||||
- nothing on the Docker host. Its address is a named destination so that
|
||||
reaching for it is counted and refused rather than falling off the end of the
|
||||
chain, but no port on it is open (see "The host entry" below)
|
||||
- GitHub's published IP ranges, port 443 only (see "GitHub push" below)
|
||||
- `registry.npmjs.org`, plus whatever the selected harness declares as its own
|
||||
(`platform.claude.com` for Claude Code; Cline declares none, so a session
|
||||
@@ -1186,33 +1272,116 @@ Everything else is rejected. Run `dejima-allow --global <domain>`
|
||||
`crates.io` for a Rust project), or `dejima --no-firewall` to disable the
|
||||
firewall entirely for one launch (debugging only).
|
||||
|
||||
IPv6 is blocked outright (`ip6tables` DROP on INPUT/OUTPUT/FORWARD, `dnsmasq`
|
||||
strips AAAA answers): there's no IPv6 equivalent of the allowlist above, so
|
||||
leaving it open would bypass the whole firewall on any host with IPv6
|
||||
egress.
|
||||
Inbound and forwarded traffic default to drop as well, in both families, with
|
||||
narrow exceptions for loopback, replies to connections the container itself
|
||||
started, and the container's own host. Nothing in either container is meant to
|
||||
be reachable from the bridge - the resolver and the credential forwarder both
|
||||
bind loopback - and a session process that binds every interface is binding one
|
||||
by accident. A launch puts each pair on a network of its own, so there is
|
||||
rarely anything else on that bridge to reach it, but that is a property of the
|
||||
launcher; this is the filter's own answer to the same question. Dropped rather
|
||||
than refused, which is the opposite of the outbound decision and deliberate: a
|
||||
refusal is a courtesy to a caller that is supposed to be there.
|
||||
|
||||
Where `ip6tables` cannot be used at all, the firewall does not conclude that
|
||||
IPv6 is therefore absent - it asks the kernel, and treats an address on any
|
||||
interface other than loopback as the family being live. It then turns IPv6
|
||||
off in the container's own network namespace, and **refuses to start** if it
|
||||
cannot. A tool that will not run and a family that is not there are different
|
||||
conditions, and reporting the second on evidence of the first is how a
|
||||
boundary comes to be described rather than held.
|
||||
IPv6 is refused outright. Not filtered alongside IPv4, not allowlisted - a
|
||||
sandbox has no IPv6 egress at all. An allowlist naming one address family says
|
||||
nothing about the other, so every restriction above would be worth only as much
|
||||
as the v6 route to the same host.
|
||||
|
||||
It is said twice, pointing the same way. The first rule of the egress chain
|
||||
refuses the whole family, above even the established and loopback passes, since
|
||||
a rule below either is one a v6 packet can get past - loopback carries `::1`,
|
||||
and a flow permitted once stays permitted. And the resolver hands out no AAAA
|
||||
at all, so a permitted name never produces an address that could become such a
|
||||
packet. Either alone would hold; both is the posture.
|
||||
|
||||
One table is what makes this cheaper to hold than it used to be, rather than a
|
||||
reason to hold something weaker. The firewall this replaced had to decide
|
||||
whether the family was even present before it could block it, and the first
|
||||
version of that read "the tool will not run" as "the family is not there" - so
|
||||
it ran with IPv6 live and unfiltered, and said nothing. There is nothing left
|
||||
to infer: the refusal loads in the same transaction as everything else, or none
|
||||
of it loads. A tool that will not run and a family that is not there are
|
||||
different conditions, and reporting the second on evidence of the first is how
|
||||
a boundary comes to be described rather than held.
|
||||
|
||||
The startup self-check also resolves every permitted name and reads each answer
|
||||
back out of the address set its class names, refusing to start if one is
|
||||
missing. A name that resolves and whose address is not where the ports are
|
||||
keyed to would be a session that reaches a configured destination only by
|
||||
accident, or not at all - and the class an entry carries, the resolver line
|
||||
built from it, and the set the rules use are three separate statements that can
|
||||
each be right about a different one. A name that returns no address is reported
|
||||
rather than fatal: that is the world being unavailable, not the policy
|
||||
disagreeing with itself.
|
||||
|
||||
The startup self-check asserts the shape of the ruleset, not a connection that
|
||||
fails: a v6 connection fails identically on a host with no v6 route, so a probe
|
||||
alone would pass whether the family is refused or merely absent.
|
||||
|
||||
If your Gitea host only resolves via a LAN-local DNS server, make sure the
|
||||
Docker host itself can resolve it: containers inherit the host's resolver.
|
||||
|
||||
### The host entry
|
||||
|
||||
Every other destination in the policy is there because something declared it.
|
||||
The host entry is not: it is the gateway of the container's default route,
|
||||
derived from the routing table. Docker publishes container ports on that
|
||||
address, so permitting it on every port would reach the host's sshd, anything
|
||||
bound to `0.0.0.0`, and every published port of every other container on the
|
||||
machine - including dejima's own remote-access front door, which is a host
|
||||
shell behind a tmux prefix key.
|
||||
|
||||
So it opens no ports. The address is still named in the ruleset, with its own
|
||||
refusal rather than a fall-through to the end of the chain, so that a session
|
||||
reaching for the host shows up as a counted refusal instead of an anonymous
|
||||
one.
|
||||
|
||||
Nothing in a launch needs it open. A host-resident forge, a host-resident Loki
|
||||
and a self-served model endpoint are each reached through their own entry - by
|
||||
name, or as a literal address - and each lands in a set of its own with its own
|
||||
rules. Configuring one of those is how a host service becomes reachable.
|
||||
|
||||
For anything else, `EGRESS_HOST_PORTS` on the filter container takes TCP ports
|
||||
and low-high ranges, comma or space separated; `EGRESS_HOST_PORTS=any` restores
|
||||
the unclamped behaviour explicitly, and `EGRESS_HOST_ADDR=none` removes the
|
||||
entry altogether. A value that is not a port stops the container rather than
|
||||
becoming a rule nobody meant.
|
||||
|
||||
### DNS is allowlisted too
|
||||
|
||||
Resolution itself is restricted, not just the IP connections made
|
||||
afterward: `dns-proxy.sh` runs a local `dnsmasq` with no fallback resolver,
|
||||
and `/etc/resolv.conf` is repointed at it. Only names on the same allowlist
|
||||
above (plus `github.com`/`githubusercontent.com`/`githubassets.com`, needed
|
||||
since GitHub itself is allowlisted by IP range; see `fixed-domains.sh`) get
|
||||
forwarded to Docker's real resolver; anything else gets no upstream
|
||||
configured at all and is refused outright. Without this, a compromised
|
||||
session could exfiltrate data by encoding it into query names to an
|
||||
attacker-controlled domain, even with no direct connection ever possible.
|
||||
afterward: the policy runs a local `dnsmasq` with no fallback resolver, and
|
||||
`/etc/resolv.conf` in both containers is repointed at it. Only permitted names
|
||||
are forwarded to Docker's real resolver; everything else gets NXDOMAIN.
|
||||
Without this, a compromised session could exfiltrate data by encoding it into
|
||||
query names to an attacker-controlled domain, even with no direct connection
|
||||
ever possible.
|
||||
|
||||
Two rules make that hold rather than merely intend it. Reaching a resolver
|
||||
outside the namespace is restricted by uid: the forwarder, running as an
|
||||
account the image creates for that and nothing else, and root, because the
|
||||
container runtime's own embedded resolver forwards onwards from inside this
|
||||
namespace on some versions and every lookup fails without it. The session user
|
||||
is neither - uid 1000, no sudo, no setuid binary, no capability to change uid -
|
||||
so the only resolver it can address is the restricting one, which is what
|
||||
closes the query-name channel. Root in the session container would be a way
|
||||
around it; that is the same posture the firewall this replaced had, and
|
||||
narrowing it further needs a discriminator finer than uid, which the packet
|
||||
filter cannot express between two containers sharing one namespace. And an answer for
|
||||
a permitted name that points at a private, loopback or link-local address is
|
||||
refused, because an answer decides what the allowlist contains and an answer
|
||||
comes from outside: one influenced reply for a public name would otherwise be
|
||||
egress to a host on your own network. That refusal is stated twice, once in
|
||||
the resolver and once independently in the packet filter, so it does not rest
|
||||
on one component being configured correctly. Destinations you configured
|
||||
yourself - the forge, Loki, and anything in `[core] internal_domains` - are
|
||||
exempt, because you chose their addresses and there is nothing for an answer
|
||||
to decide.
|
||||
|
||||
Every lookup is recorded, with its outcome and - for a refusal - its reason.
|
||||
A refused lookup never becomes a connection, so it would otherwise leave no
|
||||
trace at all, and it is the query itself that carries data out.
|
||||
|
||||
### Adding a domain to a running session
|
||||
|
||||
@@ -1225,14 +1394,21 @@ dejima-allow crates.io # this running session only
|
||||
dejima-allow --container <id> crates.io # a specific session, if more than one is running
|
||||
```
|
||||
|
||||
It first adds the domain to the running dnsmasq's allowlist (appends a
|
||||
`server=` line to its servers-file and sends `SIGHUP` to reload just that
|
||||
file - no restart), then resolves it through the now-unblocked container
|
||||
resolver and adds the IP(s) to the running firewall's `ipset`, all via
|
||||
`docker exec -u root`. It finishes by checking the domain is actually
|
||||
reachable from inside that same container and prints a definitive verdict:
|
||||
It runs `egress-policy permit` in the filter container via `docker exec -u
|
||||
root`, which records the entry, regenerates the resolver's configuration and
|
||||
restarts it (under a second, losing only its cache), then proves the change:
|
||||
it resolves the name through the policy's own resolver and confirms the
|
||||
address reached the set its class uses. If any of that fails it puts the
|
||||
permitted set back the way it was, so there is no half-added name that
|
||||
resolves and is then refused. It finishes by checking the domain is actually
|
||||
reachable from inside the session container and prints a definitive verdict:
|
||||
exit code 2 if it isn't.
|
||||
|
||||
The class comes from the same file the container composes its own lists from,
|
||||
so a name under a forge suffix is added to the forge class and stays confined
|
||||
to 443 - adding a domain cannot widen how a destination is reachable, only
|
||||
whether.
|
||||
|
||||
`--global` and `--project` are the opposite of this: they record an approval
|
||||
for *future* sessions and deliberately do not touch a running container, even
|
||||
together - see
|
||||
@@ -1256,23 +1432,33 @@ sandbox, so there is no need for it to guess one and risk "successfully"
|
||||
updating someone else's session instead.
|
||||
|
||||
A domain allowed this way stays allowed for the rest of the session even
|
||||
if its CDN later rotates edge IPs: `dns-proxy.sh` tops up the firewall's
|
||||
`ipset` from every DNS answer it serves, not just the one resolved at
|
||||
allow-time, since only allowlisted names get an upstream to answer from in
|
||||
the first place. GitHub's own domains sit outside this - their existing
|
||||
port-443-only `ipset` entries still win by dnsmasq's longest-match rule -
|
||||
so that restriction is unaffected.
|
||||
if its CDN later rotates edge IPs: the resolver writes every answer it serves
|
||||
into the address set its entry's class names, not just the one resolved at
|
||||
allow-time, since only permitted names get an upstream to answer from in the
|
||||
first place. GitHub's own domains keep their port-443 restriction through it:
|
||||
what confines them is which class the name is in, and `dejima-allow` reads that
|
||||
from the same file the container composes its own lists from. The resolver's
|
||||
longest match decides which set an answer is written into; the ruleset's order
|
||||
decides which ports that set is reachable on, with the forge class's
|
||||
permit-and-refuse pair ahead of the open class. Adding a domain can widen what
|
||||
a session reaches, never how.
|
||||
|
||||
One gap remains: allowing an *internal* hostname (one that resolves to an
|
||||
RFC1918 address) mid-session doesn't work. dnsmasq's `stop-dns-rebind`
|
||||
rejects private answers for any domain not exempted at startup, and that
|
||||
exemption list can only be extended by restarting dnsmasq. `dejima-allow`
|
||||
re-asks the container's upstream resolver when a name fails to resolve, so it
|
||||
names this case and the relaunch that fixes it rather than reporting the host
|
||||
as unresolvable and sending you to look at a DNS server that is working.
|
||||
Internal hosts named before the container starts (`[forge.gitea] ssh_host`,
|
||||
`api`, `[logging] loki_url`, and any globally or per-project approved domain)
|
||||
are already exempted and unaffected by this.
|
||||
An *internal* hostname - one that resolves to an address inside your own
|
||||
network - is the one case that needs saying twice. The policy refuses such an
|
||||
answer for a permitted name, because an answer decides what the allowlist
|
||||
contains and comes from outside, so one influenced reply for a public name
|
||||
would be egress to a host on your network. Destinations dejima resolves itself
|
||||
(`[forge.gitea] ssh_host`, `api`, `[logging] loki_url`) are exempt, because you
|
||||
configured their addresses and there is nothing for an answer to decide. An
|
||||
approved domain is not: `.dejima/allowed-domains` is reviewed with the project
|
||||
and expected to carry public names.
|
||||
|
||||
So an internal host you approve also goes in `[core] internal_domains`. Both
|
||||
halves read that one key: a launch exempts those entries and nothing else, and
|
||||
`dejima-allow` passes the same exemption only for a name that is in it. Adding
|
||||
such a name to a running session works when it is listed and is refused when it
|
||||
is not, with the refusal naming the key - rather than the two commands having
|
||||
different ideas and the disagreement surfacing as a failed launch later.
|
||||
|
||||
The session briefing tells the agent to ask for this by name when a task is
|
||||
blocked on a single missing domain, rather than stopping the whole session
|
||||
@@ -1363,19 +1549,26 @@ execution, and the audit reports the config change it took.
|
||||
|
||||
## Network request logging
|
||||
|
||||
If `[logging] loki_url` is set (see Prerequisites in the README), every DNS query and every new
|
||||
If `[logging] loki_url` is set (see [docs/configuration.md](configuration.md)),
|
||||
every DNS query and every new
|
||||
outbound connection attempt - allowed or rejected - is shipped to that Loki
|
||||
endpoint. Two sources, both tailed and pushed by `log-shipper.sh`:
|
||||
|
||||
- `dns-proxy.sh`'s dnsmasq logs every query and whether it was forwarded
|
||||
(allowed) or refused (not on the allowlist) - labeled `job=dejima-dns`.
|
||||
- `init-firewall.sh` tags its terminal ACCEPT/REJECT rules with `NFLOG`
|
||||
(group 1 for allowed, group 2 for rejected), which `ulogd2`
|
||||
(`ulogd.conf`) turns into JSON lines with source/dest IP, port, and an
|
||||
`action` field - labeled `job=dejima-net`. Only new connection
|
||||
attempts are logged, not every packet: the firewall's existing
|
||||
ESTABLISHED,RELATED rule short-circuits the rest of a connection before it
|
||||
ever reaches the tagged rules.
|
||||
- every name lookup is recorded as one JSON object, with its outcome and, for
|
||||
a refusal, its reason: not permitted at all, permitted but answered with an
|
||||
address inside a private network, or permitted and no such name. The second
|
||||
carries a severity, because it is someone trying to steer the policy rather
|
||||
than a name nobody was allowed to ask for. Labeled `job=dejima-dns`. A
|
||||
refused lookup never becomes a connection, so this is the only record it
|
||||
leaves - and it is the query itself that carries data out.
|
||||
- the policy tags its terminal permit/refuse rules with `NFLOG`, on two groups
|
||||
rather than one prefix to parse, which `ulogd2` turns into JSON lines with
|
||||
source/dest IP, port, and an `action` field - labeled `job=dejima-net`. Its
|
||||
configuration is generated per launch alongside the ruleset, so the group
|
||||
numbers cannot drift from the rules that write to them. Only new connection
|
||||
attempts are logged, not every packet: the chain's ESTABLISHED,RELATED rule
|
||||
short-circuits the rest of a connection before it ever reaches the tagged
|
||||
rules.
|
||||
|
||||
This is visibility on top of the firewall/DNS-proxy enforcement above, not a
|
||||
substitute for it - nothing about logging changes what's actually allowed to
|
||||
@@ -1438,7 +1631,7 @@ Useful filters on top of those:
|
||||
|
||||
```logql
|
||||
# DNS lookups that got refused (not on the allowlist)
|
||||
{job="dejima-dns"} |= "REFUSED"
|
||||
{job="dejima-dns"} | json | outcome="refused"
|
||||
|
||||
# Rejected connections only (parses ulogd2's JSON, filters on its action field)
|
||||
{job="dejima-net"} | json | action="blocked"
|
||||
@@ -1558,8 +1751,15 @@ config-file path that opens the same port, but not
|
||||
silently: `hosts_allowlisted_by_core` is what asks for it, explicitly, in a
|
||||
manifest, and only when the selected workflow actually pushes. What it opens
|
||||
is the same GitHub ranges the read-only path already allows on port 443, now
|
||||
also on port 22, logged like every other allowed connection and verified by
|
||||
the firewall's own startup self-check.
|
||||
also on port 22, logged like every other allowed connection.
|
||||
|
||||
The startup self-check covers the default. With the opt-in off it asserts port
|
||||
22 is refused and stops the container if it is not; with the opt-in on it skips
|
||||
that one check, and says so in the log. Everything else it asserts still
|
||||
runs. So a break in the opt-in plumbing
|
||||
surfaces as a failed `git push` rather than as a container that refuses to
|
||||
start. `test/runtime.bats` asserts the open direction against a real container
|
||||
pair, which is where that property is covered.
|
||||
|
||||
`[github] token_file` must point at a **read-only** fine-grained PAT
|
||||
regardless of this setting. `gh auth setup-git` (run at container start if
|
||||
@@ -1592,9 +1792,9 @@ token there would make HTTPS push possible independent of
|
||||
every change, and `runtime.bats` asserts against a real running container
|
||||
rather than a mock: IPv6 blocked, a non-allowlisted domain refused and a
|
||||
non-allowlisted destination unreachable, `node` unable to re-run
|
||||
`init-firewall.sh`, no working `sudo`. Coverage is not completeness: the
|
||||
the egress policy, no working `sudo`. Coverage is not completeness: the
|
||||
reasoning here is judgement, nothing tests it, and a property nobody wrote
|
||||
a test for can regress quietly. A missing `ipset` dedup flag broke firewall
|
||||
a test for can regress quietly. A missing address-set dedup flag broke firewall
|
||||
startup twice before anyone caught it; there is a test for that now, which
|
||||
is the pattern to expect - each of these exists because something was got
|
||||
wrong first.
|
||||
|
||||
@@ -110,7 +110,7 @@ if nothing is arranged against it, the last is what remains once something is.
|
||||
|
||||
| Layer | Reached by | Radius without | Bounded by | Radius with |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Agent, not yet hostile | ordinary operation | the workspace, its history, every mounted path, and the forge, at full speed | the session briefing and `.dejima.md`; the `PreToolUse` hooks | obvious destruction and out-of-remote pushes fail fast, and merging stays a human decision |
|
||||
| Agent, not yet hostile | ordinary operation | the workspace, its history, every mounted path, and the forge, at full speed | the session briefing and `.dejima.md`; the command guards | obvious destruction and out-of-remote pushes fail fast, and merging stays a human decision |
|
||||
| Container | ordinary operation | the operator's home directory, every credential in it, and the open internet | dejima | the workspace, allowlisted domains, and the forge token's scope |
|
||||
| Devbox VM, unprivileged | container escape | everything the account can read, and unrestricted egress to the internet and the LAN | `dejima-devbox-hardener` | the same VM, with LAN reach cut to what is declared; internet egress stays open, deliberately |
|
||||
| Devbox VM, root | escape plus local escalation, or misconfiguration | the whole VM, the same egress, and the model API credential | `dejima-devbox-hardener`, enforced outside the guest | the whole VM and no further: root cannot reach the rule set that holds the LAN boundary |
|
||||
|
||||
@@ -44,8 +44,9 @@ function usage() {
|
||||
echo "Timestamps are stripped unless --timestamps is given."
|
||||
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)."
|
||||
"your shell profile), and a token: GITEA_TOKEN if it is set, otherwise" \
|
||||
"the contents of $GITEA_TOKEN_FILE. Inside a dejima sandbox the" \
|
||||
"variable is the arrangement - the file is deliberately not there."
|
||||
}
|
||||
|
||||
function require_config() {
|
||||
@@ -66,10 +67,26 @@ function require_token() {
|
||||
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
|
||||
if [ -n "${DEJIMA:-}" ]; then
|
||||
# In a sandbox the token arrives as GITEA_TOKEN, read host-side from
|
||||
# whatever the forge's token_file names. Writing the file suggested
|
||||
# below would satisfy this check and nothing else: the container is
|
||||
# disposable, and the operator's real credential is not in it. Say
|
||||
# what has to change instead, which is outside.
|
||||
echo "No Gitea token in this sandbox. It arrives as GITEA_TOKEN," \
|
||||
"read on the host from the token_file the configured forge" \
|
||||
"names - so there is nothing to create in here, and writing" \
|
||||
"$GITEA_TOKEN_FILE would not help." >&2
|
||||
echo "" >&2
|
||||
echo "Most likely this session has no forge configured, or the" \
|
||||
"configured one is not gitea. Ask the operator to check" \
|
||||
"[core] forge and [forge.<name>] token_file." >&2
|
||||
else
|
||||
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." >&2
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -28,8 +28,9 @@ function usage() {
|
||||
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)."
|
||||
"your shell profile), and a token: GITEA_TOKEN if it is set, otherwise" \
|
||||
"the contents of $GITEA_TOKEN_FILE. Inside a dejima sandbox the" \
|
||||
"variable is the arrangement - the file is deliberately not there."
|
||||
}
|
||||
|
||||
function require_config() {
|
||||
@@ -50,10 +51,26 @@ function require_token() {
|
||||
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
|
||||
if [ -n "${DEJIMA:-}" ]; then
|
||||
# In a sandbox the token arrives as GITEA_TOKEN, read host-side from
|
||||
# whatever the forge's token_file names. Writing the file suggested
|
||||
# below would satisfy this check and nothing else: the container is
|
||||
# disposable, and the operator's real credential is not in it. Say
|
||||
# what has to change instead, which is outside.
|
||||
echo "No Gitea token in this sandbox. It arrives as GITEA_TOKEN," \
|
||||
"read on the host from the token_file the configured forge" \
|
||||
"names - so there is nothing to create in here, and writing" \
|
||||
"$GITEA_TOKEN_FILE would not help." >&2
|
||||
echo "" >&2
|
||||
echo "Most likely this session has no forge configured, or the" \
|
||||
"configured one is not gitea. Ask the operator to check" \
|
||||
"[core] forge and [forge.<name>] token_file." >&2
|
||||
else
|
||||
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." >&2
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -25,8 +25,9 @@ function usage() {
|
||||
echo "the two is not given is left as it is."
|
||||
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)."
|
||||
"your shell profile), and a token: GITEA_TOKEN if it is set, otherwise" \
|
||||
"the contents of $GITEA_TOKEN_FILE. Inside a dejima sandbox the" \
|
||||
"variable is the arrangement - the file is deliberately not there."
|
||||
}
|
||||
|
||||
function require_config() {
|
||||
@@ -47,10 +48,26 @@ function require_token() {
|
||||
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
|
||||
if [ -n "${DEJIMA:-}" ]; then
|
||||
# In a sandbox the token arrives as GITEA_TOKEN, read host-side from
|
||||
# whatever the forge's token_file names. Writing the file suggested
|
||||
# below would satisfy this check and nothing else: the container is
|
||||
# disposable, and the operator's real credential is not in it. Say
|
||||
# what has to change instead, which is outside.
|
||||
echo "No Gitea token in this sandbox. It arrives as GITEA_TOKEN," \
|
||||
"read on the host from the token_file the configured forge" \
|
||||
"names - so there is nothing to create in here, and writing" \
|
||||
"$GITEA_TOKEN_FILE would not help." >&2
|
||||
echo "" >&2
|
||||
echo "Most likely this session has no forge configured, or the" \
|
||||
"configured one is not gitea. Ask the operator to check" \
|
||||
"[core] forge and [forge.<name>] token_file." >&2
|
||||
else
|
||||
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." >&2
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -15,11 +15,11 @@
|
||||
#
|
||||
# hosts_allowlisted_by_core is what keeps those three hosts on the path core
|
||||
# already has for them (the GitHub ranges, port 443, plus port 22 under the
|
||||
# push grant) instead of the extra-domains path a self-hosted forge host
|
||||
# takes. Without it they would land in the port-unrestricted allowed-domains
|
||||
# ipset AND lose DNS-rebind protection - a silent widening of exactly the
|
||||
# posture the line above describes - and the firewall's own startup
|
||||
# self-check would refuse to bring the container up at all.
|
||||
# push grant) instead of the open-class path a self-hosted forge host takes.
|
||||
# Without it they would be permitted on every port AND exempted from the check
|
||||
# that refuses an answer pointing inside a private network - a silent widening
|
||||
# of exactly the posture the line above describes - and the policy's own
|
||||
# startup self-check would refuse to bring the container up at all.
|
||||
capabilities = pr issues
|
||||
pr_command = gh pr
|
||||
issue_command = gh issue
|
||||
|
||||
@@ -3,7 +3,7 @@ image_dirs = .claude .claude/projects .claude/file-history
|
||||
image_files = .claude/.credentials.json
|
||||
image_env = CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1
|
||||
command = claude
|
||||
command_path = /usr/local/share/npm-global/bin/claude
|
||||
command_path = /opt/node-tools/bin/claude
|
||||
api_domains = platform.claude.com
|
||||
guards = bash-deny push-guard merge-guard notify
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ image_dirs = .cline .cline/data .cline/hooks .agents .agents/skills .age
|
||||
image_env = DO_NOT_TRACK=1 DISABLE_TELEMETRY=1 CLINE_NO_AUTO_UPDATE=1
|
||||
|
||||
command = cline
|
||||
command_path = /usr/local/share/npm-global/bin/cline
|
||||
command_path = /opt/node-tools/bin/cline
|
||||
|
||||
# Nothing is fetched at startup: the whole assessment ran against a loopback
|
||||
# endpoint with the firewall up, so there is no catalogue or feature-flag host
|
||||
@@ -35,9 +35,12 @@ api_domains =
|
||||
|
||||
# None. Cline registers no hook that can refuse a tool call, so bash-deny has
|
||||
# nowhere to live, and it has no session lifecycle signal dejima can turn into
|
||||
# a notification. push-guard and merge-guard are not listed because no harness
|
||||
# hosts them any more - they are PATH shadows and hold regardless. Launch
|
||||
# names what this costs; see harness_report_guards.
|
||||
# a notification.
|
||||
#
|
||||
# push-guard and merge-guard are not listed because this harness cannot host
|
||||
# them, not because nothing does - claude-code declares both, and the PATH
|
||||
# shadows cover a harness that does not. Declaring none here costs the hook
|
||||
# layer, not the guard. Launch names the cost; see harness_report_guards.
|
||||
guards =
|
||||
|
||||
# Both, which is what makes this harness the backend-agnostic one: it has a
|
||||
|
||||
@@ -35,11 +35,16 @@ session_id="${1:-}"
|
||||
resume_id="${session_id:-<your session id>}"
|
||||
project_dir="${2:-$PWD}"
|
||||
|
||||
# /etc/hostname in a docker container is the container's own ID, which
|
||||
# docker exec accepts directly - substituting it into the briefing below
|
||||
# means the agent states its real container instead of guessing (a wrong
|
||||
# guess that matches *another* running sandbox makes dejima-allow
|
||||
# "succeed" against the wrong container).
|
||||
# The id of this launch, read from the only place a session can see one.
|
||||
#
|
||||
# Not this container's own id, and worth being exact about: Docker names a
|
||||
# container that joined another's network namespace after the container it
|
||||
# joined, so what a session reads here is its FILTER container's id. That is
|
||||
# still the right thing to put in the briefing - `dejima-allow` takes either
|
||||
# half of the pair and derives the other - and it is what the agent has, so
|
||||
# substituting it means stating a real id rather than guessing one. A wrong
|
||||
# guess that matched another running sandbox would make dejima-allow "succeed"
|
||||
# against somebody else's session.
|
||||
container_id=$(cat /etc/hostname 2>/dev/null)
|
||||
container_id="${container_id:-<container-id>}"
|
||||
|
||||
@@ -192,9 +197,18 @@ if [ -f "$decl_dir/allowed-domains" ]; then
|
||||
# verbatim into the agent's context under a heading that reads as the
|
||||
# project speaking.
|
||||
[[ "$dom" =~ ^[a-zA-Z0-9]([a-zA-Z0-9-]{0,62}\.)+[a-zA-Z0-9-]{1,63}$ ]] || continue
|
||||
# Every valid domain has a dot, a BRE metachar that would match any
|
||||
# char - escape it so "a.b" can't false-positive against "aXb".
|
||||
grep -q "^server=/${dom//./\\.}/" /etc/dnsmasq-sandbox-servers.conf 2>/dev/null && continue
|
||||
# Asked of the resolver rather than read out of its configuration.
|
||||
# That configuration is in the FILTER container, which owns this
|
||||
# namespace's egress control and which the session cannot read - so a
|
||||
# probe that grepped it found nothing here and reported every declared
|
||||
# domain as missing, including ones the session could plainly reach.
|
||||
#
|
||||
# Resolution is the right question anyway: the resolver answers for
|
||||
# permitted names and returns NXDOMAIN for everything else, so "it
|
||||
# resolves" and "it is permitted" are the same set, asked from where
|
||||
# the answer actually matters. Bounded, because this runs while an
|
||||
# agent waits for its briefing.
|
||||
[ -n "$(dig +short +time=2 +tries=1 "$dom" 2>/dev/null)" ] && continue
|
||||
missing_domains_n=$((missing_domains_n + 1))
|
||||
[ "$missing_domains_n" -le "$MAX_LISTED" ] && missing_domains="$missing_domains $dom"
|
||||
done < <(read_declared_capped "$decl_dir/allowed-domains")
|
||||
@@ -207,7 +221,7 @@ if [ -f "$decl_dir/allowed-domains" ]; then
|
||||
Tell the user to run, outside the sandbox:
|
||||
dejima-project approve \"$project_dir\"
|
||||
(or, for one domain right now:
|
||||
dejima-allow --container $container_id <domain>)"
|
||||
dejima-allow --container $container_id <domain>...)"
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
+35
-155
@@ -3,19 +3,19 @@
|
||||
# DEJIMA_PUSH_HOSTS, and never directly to a branch listed in
|
||||
# DEJIMA_BLOCKED_BRANCHES - both resolved host-side by bin/dejima from the
|
||||
# configured forge and workflow (see dejima-provider-lib.sh), not hardcoded
|
||||
# to any one forge. This is a fast-fail guard against an overeager agent,
|
||||
# not a hard security boundary - a sandbox session has full shell access
|
||||
# and could in principle edit .git/config or re-export these variables to
|
||||
# route around it; that is true today and is not a regression, and the
|
||||
# firewall (see init-firewall.sh) is the actual boundary. No-op outside the
|
||||
# sandbox (DEJIMA unset), since this restriction doesn't apply to normal
|
||||
# host usage.
|
||||
# to any one forge.
|
||||
#
|
||||
# DEJIMA_PUSH_HOSTS fails CLOSED: empty means no host is allowed, so every
|
||||
# push is refused. That is deliberate - it is how a no-forge or
|
||||
# non-pushing workflow becomes enforced rather than merely described.
|
||||
# DEJIMA_BLOCKED_BRANCHES fails OPEN: empty means no branch is protected,
|
||||
# which is correct for a workflow that genuinely has no branch policy.
|
||||
# The rules themselves are docker/guard-lib.sh, which docker/git-guard.sh
|
||||
# applies to the same push as a PATH shadow. This file's whole job is to turn
|
||||
# the command string a harness reports into the argv that library reads, so
|
||||
# that the two cannot reach different verdicts - which they had already started
|
||||
# to do.
|
||||
#
|
||||
# A fast-fail guard against an overeager agent, not a hard security boundary: a
|
||||
# sandbox session has full shell access and could in principle edit .git/config
|
||||
# or re-export these variables to route around it, and the firewall (see
|
||||
# docker/egress-policy) is the actual boundary. No-op outside the sandbox
|
||||
# (DEJIMA unset), since this restriction doesn't apply to normal host usage.
|
||||
|
||||
if [ -z "${DEJIMA:-}" ]; then
|
||||
exit 0
|
||||
@@ -25,151 +25,31 @@ input=$(cat)
|
||||
command=$(echo "$input" | jq -r '.tool_input.command // empty')
|
||||
cwd=$(echo "$input" | jq -r '.cwd // empty')
|
||||
|
||||
# Only care about `git push` invocations - found by walking tokens rather than
|
||||
# by matching a shape.
|
||||
# shellcheck source=../docker/guard-lib.sh
|
||||
source "${DEJIMA_GUARD_LIB:-/usr/local/lib/dejima/guard-lib.sh}"
|
||||
|
||||
# A push can be the second half of a compound command, and only a segment whose
|
||||
# first word is git is a git invocation. Every segment that is one gets the same
|
||||
# verdict function the shadow calls, on the words after `git`, so git's own
|
||||
# options do not need anticipating here: they are argv either way.
|
||||
#
|
||||
# The pattern this replaced recognised `git push` and `git -C <dir> push` and
|
||||
# nothing else, so every other spelling of git's own options - `git -c k=v
|
||||
# push`, `git --git-dir=... push`, `git --no-pager push` - was not a push as
|
||||
# far as this hook was concerned, and it exited 0 before any check ran. The
|
||||
# PATH shadow in docker/git-guard.sh walks argv and caught them regardless,
|
||||
# which is why this was a quiet gap in the earlier warning rather than an open
|
||||
# one.
|
||||
#
|
||||
# Split on the shell separators first, because a push can be the second half
|
||||
# of a compound command, and only the segment that STARTS with git is a git
|
||||
# invocation - "echo push" is not.
|
||||
push_seen=0
|
||||
segment=""
|
||||
IFS=$'\n' read -rd '' -a _segments < <(
|
||||
printf '%s' "$command" | sed 's/&&/\n/g; s/||/\n/g; s/;/\n/g; s/|/\n/g'
|
||||
printf '\0')
|
||||
for seg in ${_segments[@]+"${_segments[@]}"}; do
|
||||
read -ra _t <<< "$seg"
|
||||
[ "${#_t[@]}" -gt 0 ] || continue
|
||||
[ "${_t[0]}" = git ] || continue
|
||||
_i=1
|
||||
while [ "$_i" -lt "${#_t[@]}" ]; do
|
||||
case "${_t[$_i]}" in
|
||||
-c|-C|--exec-path|--git-dir|--work-tree|--namespace|--super-prefix)
|
||||
_i=$((_i + 1)) ;;
|
||||
-*) ;;
|
||||
push) push_seen=1; segment="$seg"; break ;;
|
||||
*) break ;;
|
||||
esac
|
||||
_i=$((_i + 1))
|
||||
done
|
||||
[ "$push_seen" -eq 1 ] && break
|
||||
done
|
||||
[ "$push_seen" -eq 1 ] || exit 0
|
||||
# Everything below reads the git invocation, not the whole command line.
|
||||
command="$segment"
|
||||
|
||||
repo_dir="$cwd"
|
||||
if [[ "$command" =~ git[[:space:]]+-C[[:space:]]+([^[:space:]]+) ]]; then
|
||||
repo_dir="${BASH_REMATCH[1]}"
|
||||
fi
|
||||
|
||||
# One pass over the whole command, the way the shadow walks argv: skip git's
|
||||
# own options until the first token that IS the subcommand, then take the
|
||||
# first non-flag token as the remote and the second as the refspec.
|
||||
#
|
||||
# Splitting on the first occurrence of the substring "push" is what this
|
||||
# replaced, and it split inside `remote.origin.pushurl=...`, a branch named
|
||||
# push-fix, and anything else carrying the word. What came out was a nonsense
|
||||
# remote, which then took the unresolvable path below.
|
||||
#
|
||||
# Word-splitting rather than a full shell parse, so quoting edge cases are not
|
||||
# handled - the same pragmatic tradeoff as the sibling
|
||||
# deny-dangerous-bash.sh denylist, and the reason the unresolvable case
|
||||
# refuses rather than guesses.
|
||||
#
|
||||
# `-c key=value` is kept rather than dropped: git resolves where a push goes
|
||||
# from the config the push runs under, so asking without these answers for a
|
||||
# different configuration. `git -c remote.origin.pushurl=... push origin
|
||||
# branch` is that case.
|
||||
config_overrides=()
|
||||
positional=()
|
||||
read -ra tokens <<< "$command"
|
||||
t=0
|
||||
seen_push=0
|
||||
while [ "$t" -lt "${#tokens[@]}" ]; do
|
||||
tok="${tokens[$t]}"
|
||||
if [ "$seen_push" -eq 0 ]; then
|
||||
case "$tok" in
|
||||
-c) t=$((t + 1))
|
||||
[ "$t" -lt "${#tokens[@]}" ] && config_overrides+=(-c "${tokens[$t]}") ;;
|
||||
-C|--exec-path|--git-dir|--work-tree|--namespace|--super-prefix)
|
||||
t=$((t + 1)) ;;
|
||||
push) seen_push=1 ;;
|
||||
*) ;;
|
||||
esac
|
||||
else
|
||||
case "$tok" in
|
||||
-*) ;;
|
||||
*) positional+=("$tok") ;;
|
||||
esac
|
||||
fi
|
||||
t=$((t + 1))
|
||||
done
|
||||
|
||||
remote="origin"
|
||||
refspec=""
|
||||
if [ "${#positional[@]}" -ge 1 ]; then
|
||||
remote="${positional[0]}"
|
||||
fi
|
||||
if [ "${#positional[@]}" -ge 2 ]; then
|
||||
refspec="${positional[1]}"
|
||||
fi
|
||||
|
||||
# --push: the URL the push will actually use. remote.<name>.pushurl wins over
|
||||
# remote.<name>.url when it is set, and reading the latter checks a host the
|
||||
# push is not going to. See docker/git-guard.sh, which asks the same question.
|
||||
remote_url=$(git ${config_overrides[@]+"${config_overrides[@]}"} \
|
||||
-C "$repo_dir" remote get-url --push "$remote" 2>/dev/null)
|
||||
if [ -z "$remote_url" ]; then
|
||||
# Refused, not passed through. A remote that does not resolve is usually a
|
||||
# typo git would reject in a moment anyway - but it is also what a
|
||||
# disagreement between this parser and git's own looks like, and the
|
||||
# failure of that disagreement has to be a refusal rather than a push
|
||||
# nobody checked. docker/git-guard.sh takes the same direction; the two
|
||||
# answering differently was the bug.
|
||||
echo "Blocked by git-push-guard.sh: cannot resolve the remote '$remote' to a URL, so there is nothing to check this push against. Name a remote that exists." >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
host=$(echo "$remote_url" | sed -E 's#^[a-zA-Z]+://([^/@]+@)?([^/:]+).*#\2#; s#^[^@/]+@([^:/]+)[:/].*#\1#')
|
||||
|
||||
host_allowed=0
|
||||
for allowed_host in ${DEJIMA_PUSH_HOSTS:-}; do
|
||||
if [ "$host" = "$allowed_host" ]; then
|
||||
host_allowed=1
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$host_allowed" != 1 ]; then
|
||||
echo "Blocked by git-push-guard.sh: this sandbox may only push to ${DEJIMA_FORGE_NAME:-the configured forge}'s allowed host(s) (${DEJIMA_PUSH_HOSTS:-none allowed}), not '$host'." >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# Resolve the target branch: a src:dst refspec's dst, a bare branch name, or
|
||||
# (if no refspec was given at all, or it's HEAD) the current branch - `git
|
||||
# push origin` and `git push origin HEAD` both push the current branch.
|
||||
if [[ "$refspec" == *:* ]]; then
|
||||
branch="${refspec#*:}"
|
||||
elif [ -n "$refspec" ] && [ "$refspec" != "HEAD" ]; then
|
||||
branch="$refspec"
|
||||
else
|
||||
branch=$(git -C "$repo_dir" branch --show-current 2>/dev/null)
|
||||
fi
|
||||
branch="${branch#refs/heads/}"
|
||||
|
||||
for blocked_branch in ${DEJIMA_BLOCKED_BRANCHES:-}; do
|
||||
if [ "$branch" = "$blocked_branch" ]; then
|
||||
echo "Blocked by git-push-guard.sh: direct pushes to '$branch' aren't allowed in the sandbox. Push a feature branch and open a PR/MR instead." >&2
|
||||
# What a segment's FIRST word has to be is still git. A `FOO=bar git push`
|
||||
# prefix, or `env git push`, is a git invocation this does not see - the PATH
|
||||
# shadow does, which is the division of labour the pair exists for rather than
|
||||
# a gap in it.
|
||||
# Basenamed, like hooks/merge-guard.sh and like the shadow, which reads its own
|
||||
# $0. `/usr/bin/git push origin main` is the one spelling that reaches neither
|
||||
# layer otherwise - the absolute path steps over the PATH shadow, and comparing
|
||||
# the whole word meant this hook did not see a git invocation at all. It is
|
||||
# also the form the library's own header names as the reason both layers exist.
|
||||
while IFS= read -r segment; do
|
||||
read -ra tokens <<< "$segment"
|
||||
[ "${#tokens[@]}" -gt 0 ] || continue
|
||||
[ "$(basename -- "${tokens[0]}")" = git ] || continue
|
||||
if ! dejima_push_verdict "$cwd" "${tokens[@]:1}"; then
|
||||
echo "Blocked by git-push-guard.sh: $DEJIMA_GUARD_REASON" >&2
|
||||
exit 2
|
||||
fi
|
||||
done
|
||||
done < <(dejima_guard_segments "$command")
|
||||
|
||||
exit 0
|
||||
|
||||
+36
-22
@@ -1,10 +1,15 @@
|
||||
#!/usr/bin/env bash
|
||||
# PreToolUse hook: inside dejima, block a merge command matching
|
||||
# DEJIMA_MERGE_BLOCK - a literal command prefix resolved host-side by
|
||||
# bin/dejima from the configured forge and workflow, never hardcoded to one
|
||||
# forge. What lands on the default branch is an operator decision, as it
|
||||
# already is for `git push`; this is the API-side counterpart to
|
||||
# git-push-guard.sh, since a merge reaches the branch without a push.
|
||||
# PreToolUse hook: inside dejima, apply the forge policy to a forge CLI call -
|
||||
# the merge a workflow blocks, and a command aimed at a repository the session
|
||||
# was not launched against. What lands on the default branch is an operator
|
||||
# decision, as it already is for `git push`; this is the API-side counterpart
|
||||
# to git-push-guard.sh, since a merge reaches the branch without a push.
|
||||
#
|
||||
# The rules are docker/guard-lib.sh, which docker/forge-guard.sh applies to the
|
||||
# same command as a PATH shadow. This file's job is to turn the command string
|
||||
# a harness reports into the argv that library reads, so the two cannot reach
|
||||
# different verdicts. Named for the merge block because that is the rule it was
|
||||
# created for; the rest came with the shared policy.
|
||||
#
|
||||
# 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 line is
|
||||
@@ -16,32 +21,41 @@
|
||||
# shell access and the forge token, so it could call the merge endpoint with
|
||||
# curl. It stops the mistake, not the attack.
|
||||
#
|
||||
# Fails OPEN - empty means nothing is blocked, so a workflow with no
|
||||
# merge-block policy genuinely has none rather than the hook guessing.
|
||||
# Fails OPEN - a forge that declares no policy has none blocked, rather than
|
||||
# the hook guessing.
|
||||
|
||||
if [ -z "${DEJIMA:-}" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ -z "${DEJIMA_MERGE_BLOCK:-}" ]; then
|
||||
if [ -z "${DEJIMA_MERGE_BLOCK:-}" ] && [ -z "${DEJIMA_DISCLOSE:-}" ] &&
|
||||
[ -z "${DEJIMA_FORGE_REPO_FLAGS:-}" ] && [ -z "${DEJIMA_FORGE_REPO_ENV:-}" ] &&
|
||||
[ -z "${DEJIMA_FORGE_REPO_PATHS:-}" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
command=$(jq -r '.tool_input.command // empty')
|
||||
|
||||
# DEJIMA_MERGE_BLOCK is a literal command prefix from the forge manifest,
|
||||
# not a regex - escape its regex metacharacters before matching so a value
|
||||
# like ".*" matches only that literal text, never "everything".
|
||||
escaped_block=$(printf '%s' "$DEJIMA_MERGE_BLOCK" | sed -E 's/[][(){}.*+?^$\\|]/\\&/g')
|
||||
# shellcheck source=../docker/guard-lib.sh
|
||||
source "${DEJIMA_GUARD_LIB:-/usr/local/lib/dejima/guard-lib.sh}"
|
||||
|
||||
# The leading class includes '/' so a path-qualified call is caught too -
|
||||
# the Dockerfile installs each forge's CLI under /usr/local/bin, and that's
|
||||
# what an agent reaching past a shadowed PATH would type. Word-splitting
|
||||
# rather than a full shell parse, the same pragmatic tradeoff the sibling
|
||||
# hook makes.
|
||||
if [[ "$command" =~ (^|[[:space:]&\|\;/])$escaped_block([[:space:]]|$) ]]; then
|
||||
echo "Blocked by merge-guard.sh: merging is a human decision in the sandbox - open a PR/MR, then the operator merges. Report its URL and stop." >&2
|
||||
exit 2
|
||||
fi
|
||||
forge_commands="$(dejima_forge_commands | sort -u)"
|
||||
[ -n "$forge_commands" ] || exit 0
|
||||
|
||||
# Basenamed, so a path-qualified call is checked too - the Dockerfile installs
|
||||
# each forge's CLI under /usr/local/bin, and the real one sits behind the
|
||||
# shadow at a fixed path, which is what an agent reaching past a shadowed PATH
|
||||
# would type. It is also what the shadow itself compares, since that reads its
|
||||
# own basename.
|
||||
while IFS= read -r segment; do
|
||||
read -ra tokens <<< "$segment"
|
||||
[ "${#tokens[@]}" -gt 0 ] || continue
|
||||
self="$(basename -- "${tokens[0]}")"
|
||||
grep -qxF -- "$self" <<< "$forge_commands" || continue
|
||||
if ! dejima_forge_verdict "$self" "${tokens[@]:1}"; then
|
||||
echo "Blocked by merge-guard.sh: $DEJIMA_GUARD_REASON" >&2
|
||||
exit 2
|
||||
fi
|
||||
done < <(dejima_guard_segments "$command")
|
||||
|
||||
exit 0
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
# SessionStart hook: when running inside the dejima Docker container
|
||||
# (see https://github.com/anthropics/claude-code/tree/main/.devcontainer for
|
||||
# the reference this is based on, and this repo's own README for the actual
|
||||
# setup), hand Claude Code the session briefing.
|
||||
# SessionStart hook: when running inside the dejima Docker container, hand
|
||||
# Claude Code the session briefing. The container is dejima's own - the README
|
||||
# describes what it is and docs/security.md what it bounds.
|
||||
#
|
||||
# Everything that composes that briefing lives in dejima-briefing-render.sh,
|
||||
# baked into the image and shared with every harness. What is left here is the
|
||||
|
||||
+3
-1
@@ -140,7 +140,9 @@ else
|
||||
# only what the operator has actually decided.
|
||||
{
|
||||
printf '# dejima configuration. See README.md.\n'
|
||||
printf '# Every key below is shown at its default; uncomment to change one.\n'
|
||||
printf '# This file holds what you have decided, not every key there is.\n'
|
||||
printf '# defaults.conf beside it is the complete reference, rewritten by\n'
|
||||
printf '# every ./install.sh - do not edit that one.\n'
|
||||
printf '#\n'
|
||||
printf '# [core] harness, forge and workflow are written out uncommented, so\n'
|
||||
printf '# this file states what it selects rather than inheriting a future\n'
|
||||
|
||||
+1
-1
@@ -2112,7 +2112,7 @@ EOF
|
||||
|
||||
@test "TCB: a manifest hidden from git is a CRITICAL finding" {
|
||||
# A forge's fixed_hosts reaches DEJIMA_FORGE_HOSTS, which entrypoint.sh
|
||||
# joins into extra_domains and init-firewall.sh allowlists - so a line
|
||||
# joins into the permitted set the egress policy is given - so a line
|
||||
# added to a manifest widens the egress allowlist on the next launch.
|
||||
# Manifests are data and are never executed, which is why they were not
|
||||
# in the TCB list; what they are is a decision about what the next launch
|
||||
|
||||
+4
-10
@@ -77,16 +77,10 @@ PY
|
||||
[ -e /var/run/docker.sock ]
|
||||
}
|
||||
|
||||
@test "the two runners are not the same runner" {
|
||||
# Whatever else is true, exactly one of them gets the daemon. If both jobs
|
||||
# report the same answer here, the labels are not routing to two different
|
||||
# runners and the tiering exists only on paper.
|
||||
if [ "$DEJIMA_CI_TIER" = confined ]; then
|
||||
[ ! -e /var/run/docker.sock ]
|
||||
else
|
||||
[ -e /var/run/docker.sock ]
|
||||
fi
|
||||
}
|
||||
# The two directions above are each one job's answer: the confined runner must
|
||||
# not have the docker socket and the privileged one must. That the two jobs land
|
||||
# on DIFFERENT runners does not follow from either, and a suite cannot see it -
|
||||
# it runs in one job. Comparing the two jobs' results is the workflow's to do.
|
||||
|
||||
# --- egress, for the tier the rules actually apply to ------------------------
|
||||
#
|
||||
|
||||
+362
-17
@@ -63,6 +63,46 @@ EOF
|
||||
[ "$status" -ne 0 ]
|
||||
}
|
||||
|
||||
@test "core.internal_domains parses as a list of hostnames, and rejects what is not one" {
|
||||
# The key gates whether a security check applies to a name, so a typo in it
|
||||
# must not be read as a value. `?list:hostname` is what makes the whole
|
||||
# file refuse rather than the entry being skipped.
|
||||
run lib config_type core.internal_domains
|
||||
[ "$output" = "?list:hostname" ]
|
||||
|
||||
write_config '[core]' 'internal_domains = mirror.example:logs.example'
|
||||
run lib config_get core internal_domains
|
||||
[ "$status" -eq 0 ]
|
||||
[ "$output" = "mirror.example:logs.example" ]
|
||||
|
||||
# A colon-separated list, so the launch path's `tr ':' ' '` gets two
|
||||
# entries rather than one nonsense destination.
|
||||
local -a entries=()
|
||||
IFS=':' read -ra entries <<< "$output"
|
||||
[ "${#entries[@]}" -eq 2 ] \
|
||||
|| { echo "the list did not split into two entries: ${entries[*]}"; return 1; }
|
||||
|
||||
# And it comes back with no trailing newline, which every consumer that
|
||||
# reads it line by line has to allow for: a `while read` over a
|
||||
# single-entry list gets EOF on the only line and never runs its body.
|
||||
# bin/dejima-allow was written without that guard and silently treated
|
||||
# every declared name as undeclared.
|
||||
run bash -c 'source "$1"; printf "[%s]" "$(config_get core internal_domains)"' _ "$LIB"
|
||||
[ "$output" = "[mirror.example:logs.example]" ]
|
||||
|
||||
write_config '[core]' 'internal_domains = not a hostname'
|
||||
run lib config_get core internal_domains
|
||||
[ "$status" -ne 0 ] \
|
||||
|| { echo "a malformed entry was accepted: $output"; return 1; }
|
||||
|
||||
# Unset is the default and is not an error - most hosts have no internal
|
||||
# destination to declare.
|
||||
write_config '[core]' 'firewall = on'
|
||||
run lib config_get core internal_domains
|
||||
[ "$status" -eq 0 ]
|
||||
[ -z "$output" ]
|
||||
}
|
||||
|
||||
@test "config_type and config_default split the spec" {
|
||||
run lib config_type audit.keep
|
||||
[ "$output" = "int:1:1000" ]
|
||||
@@ -751,7 +791,7 @@ write_defaults() { printf '%s\n' "$@" > "$CONFIG_DIR/defaults.conf"; }
|
||||
|
||||
_identity_fixture() {
|
||||
ID_DIR="$(mktemp -d)"
|
||||
: > "$ID_DIR/default-key"; : > "$ID_DIR/work-key"; : > "$ID_DIR/oss-key"
|
||||
printf "k\n" | tee "$ID_DIR/default-key" "$ID_DIR/work-key" > "$ID_DIR/oss-key"
|
||||
: > "$ID_DIR/default-token"; : > "$ID_DIR/work-token"
|
||||
write_config '[core]' 'forge = gitea' "ssh_key = $ID_DIR/default-key" \
|
||||
'[forge.gitea]' 'ssh_host = git.example' 'api = https://git.example/api/v1' \
|
||||
@@ -969,7 +1009,7 @@ _identity_fixture() {
|
||||
mkdir -p "$FAKE_HOME/.claude"
|
||||
echo '{}' > "$FAKE_HOME/.claude/settings.json"
|
||||
key="$FAKE_HOME/sandbox-key"
|
||||
: > "$key"
|
||||
printf "not-a-real-key\n" > "$key"
|
||||
chmod 600 "$key"
|
||||
# forge = none, workflow = local-only: provider_validate_selection runs
|
||||
# ahead of this check, and the default gitea/worktree-pr pair is refused
|
||||
@@ -1245,7 +1285,7 @@ _identity_fixture() {
|
||||
mkdir -p "$FAKE_HOME/.claude"
|
||||
echo '{}' > "$FAKE_HOME/.claude/settings.json"
|
||||
key="$FAKE_HOME/sandbox-key"
|
||||
: > "$key"
|
||||
printf "not-a-real-key\n" > "$key"
|
||||
chmod 600 "$key"
|
||||
write_config '[core]' "ssh_key = $key" 'forge = none' 'workflow = local-only'
|
||||
workspace="$(mktemp -d)"
|
||||
@@ -1275,7 +1315,7 @@ EOF
|
||||
mkdir -p "$FAKE_HOME/.claude"
|
||||
echo '{}' > "$FAKE_HOME/.claude/settings.json"
|
||||
key="$FAKE_HOME/sandbox-key"
|
||||
: > "$key"
|
||||
printf "not-a-real-key\n" > "$key"
|
||||
chmod 600 "$key"
|
||||
write_config '[core]' "ssh_key = $key" \
|
||||
'[forge.gitea]' 'ssh_host = git.example' 'api = https://api.example/v1' \
|
||||
@@ -1394,8 +1434,8 @@ EOF
|
||||
|
||||
@test "the github forge's hosts reach the push guard, never the extra-domains allowlist" {
|
||||
# entrypoint.sh folds DEJIMA_FORGE_HOSTS into EXTRA_ALLOWED_DOMAINS,
|
||||
# which init-firewall.sh resolves into the port-UNRESTRICTED
|
||||
# allowed-domains ipset and dns-proxy.sh exempts from stop-dns-rebind.
|
||||
# which firewall-up.sh puts in the port-unrestricted open class and
|
||||
# exempts from the check that refuses an answer pointing inside.
|
||||
# Right for a self-hosted forge host, a silent widening for GitHub's
|
||||
# public names - and the firewall's own startup self-check refuses to
|
||||
# bring the container up when github.com:22 is reachable without the
|
||||
@@ -1409,7 +1449,7 @@ EOF
|
||||
# dedicated key file is the headless form of that, and keeps this test
|
||||
# independent of whether the host running it has an agent.
|
||||
local key="$FAKE_HOME/sandbox-key"
|
||||
: > "$key"
|
||||
printf "not-a-real-key\n" > "$key"
|
||||
chmod 600 "$key"
|
||||
write_config '[core]' 'forge = github' 'workflow = worktree-pr' \
|
||||
"ssh_key = $key" \
|
||||
@@ -1466,7 +1506,7 @@ EOF
|
||||
mkdir -p "$FAKE_HOME/.claude"
|
||||
echo '{}' > "$FAKE_HOME/.claude/settings.json"
|
||||
local key="$FAKE_HOME/sandbox-key"
|
||||
: > "$key"
|
||||
printf "not-a-real-key\n" > "$key"
|
||||
chmod 600 "$key"
|
||||
write_config '[core]' 'forge = gitea' 'workflow = branch-push' \
|
||||
"ssh_key = $key" \
|
||||
@@ -1490,7 +1530,7 @@ EOF
|
||||
mkdir -p "$FAKE_HOME/.claude"
|
||||
echo '{}' > "$FAKE_HOME/.claude/settings.json"
|
||||
key="$FAKE_HOME/sandbox-key"
|
||||
: > "$key"
|
||||
printf "not-a-real-key\n" > "$key"
|
||||
chmod 600 "$key"
|
||||
write_config '[core]' "ssh_key = $key" 'forge = none' 'workflow = local-only' \
|
||||
'[logging]' 'loki_url = https://loki.example.invalid'
|
||||
@@ -1667,7 +1707,7 @@ _rw_setup() {
|
||||
mkdir -p "$RW_HOME/.claude"
|
||||
echo '{}' > "$RW_HOME/.claude/settings.json"
|
||||
RW_KEY="$RW_HOME/sandbox-key"
|
||||
: > "$RW_KEY"
|
||||
printf "not-a-real-key\n" > "$RW_KEY"
|
||||
chmod 600 "$RW_KEY"
|
||||
}
|
||||
|
||||
@@ -1791,6 +1831,118 @@ _kill_agent() { [ -n "${RW_AGENT_PID:-}" ] && kill "$RW_AGENT_PID" 2>/dev/null;
|
||||
rm -rf "$workspace"
|
||||
}
|
||||
|
||||
# A launch that really reaches `docker compose`, with a pushing workflow, so the
|
||||
# rule that blanks the agent socket for a workflow that never pushes is not the
|
||||
# one doing the work. Its own fixture rather than _rw_setup's: this needs a
|
||||
# forge configured, and stdin closed because a full launch opens /dev/tty,
|
||||
# which under bats is not the terminal it expects.
|
||||
#
|
||||
# The stub records its own environment rather than its arguments, because the
|
||||
# volume line resolves ${SSH_AUTH_SOCK:-/dev/null} inside docker-compose - so
|
||||
# the environment handed to it IS whether the agent is mounted. Into a file,
|
||||
# because the launch prints plenty of its own and the assertion is about one
|
||||
# value.
|
||||
#
|
||||
# Leaves AGENT_LOG and AGENT_OUT for the caller to read on failure.
|
||||
_agent_launch() { # <ssh_key or "">
|
||||
local key="$1" h c s w
|
||||
# The suite exports DEJIMA_CONFIG_DIR, which is where a launch under it
|
||||
# reads its config from - a config written under $HOME would not be found.
|
||||
h="$(mktemp -d)"; c="$CONFIG_DIR"
|
||||
printf 'tok\n' > "$c/token"
|
||||
{
|
||||
printf '[core]\nharness = claude-code\nfirewall = on\nupdate_check = off\n'
|
||||
printf 'forge = gitea\nworkflow = worktree-pr\n'
|
||||
printf '[harness.claude-code]\nbackend = anthropic\n'
|
||||
printf '[forge.gitea]\nssh_host = git.example\napi = https://api.example\n'
|
||||
# The forge credential moves under the identity when there is one: an
|
||||
# identity that leaves one behind is a session acting under whichever
|
||||
# account owns it, which the launch refuses outright.
|
||||
if [ -n "$key" ]; then
|
||||
printf '[identity.default]\nssh_key = %s\ntoken_file = %s/token\n' "$key" "$c"
|
||||
else
|
||||
printf 'token_file = %s/token\n' "$c"
|
||||
fi
|
||||
} > "$c/config"
|
||||
AGENT_LOG="$(mktemp)"
|
||||
s="$(mktemp -d)"
|
||||
cat > "$s/docker" <<'STUB'
|
||||
#!/bin/sh
|
||||
printf 'SSH_AUTH_SOCK=[%s]\n' "${SSH_AUTH_SOCK-unset}" >> "$AGENT_SOCK_LOG"
|
||||
exit 0
|
||||
STUB
|
||||
chmod +x "$s/docker"
|
||||
w="$(mktemp -d)"; git -C "$w" init -q
|
||||
# `|| true` because a launch here ends however the stubbed session ended,
|
||||
# and this is not a test about its exit status.
|
||||
AGENT_OUT="$(HOME="$h" PATH="$s:$PATH" SSH_AUTH_SOCK="$AGENT_SOCK" \
|
||||
AGENT_SOCK_LOG="$AGENT_LOG" \
|
||||
bash "$REPO_ROOT/bin/dejima" "$w" </dev/null 2>&1 || true)"
|
||||
rm -rf "$h" "$s" "$w"
|
||||
}
|
||||
|
||||
# A real agent, because bin/dejima tests the socket with `[ -S ]` and refuses a
|
||||
# path that is not one.
|
||||
_real_agent() {
|
||||
eval "$(ssh-agent -s)" >/dev/null
|
||||
AGENT_SOCK="$SSH_AUTH_SOCK"
|
||||
AGENT_PID="$SSH_AGENT_PID"
|
||||
}
|
||||
|
||||
@test "a configured ssh_key means the operator's agent is not in the container at all" {
|
||||
# entrypoint.sh loads that key into an agent of its own and points
|
||||
# SSH_AUTH_SOCK at it, so the forwarded one was already unused - but it
|
||||
# stayed mounted at the path docker-compose.yml names, where
|
||||
# `SSH_AUTH_SOCK=/ssh-agent ssh ...` signs with the operator's personal
|
||||
# identities. Choosing a dedicated key has to mean the personal agent is
|
||||
# not in the container, not that it is merely not the default.
|
||||
local key
|
||||
_real_agent
|
||||
key="$(mktemp)"; printf 'not-a-real-key\n' > "$key"; chmod 600 "$key"
|
||||
|
||||
_agent_launch "$key"
|
||||
kill "$AGENT_PID" 2>/dev/null || true
|
||||
rm -f "$key"
|
||||
[ -s "$AGENT_LOG" ] \
|
||||
|| { echo "the launch never reached docker:"; echo "$AGENT_OUT" | tail -15; return 1; }
|
||||
[ "$(sort -u "$AGENT_LOG")" = "SSH_AUTH_SOCK=[]" ] \
|
||||
|| { echo "the forwarded agent still reached compose:"; sort -u "$AGENT_LOG"; return 1; }
|
||||
}
|
||||
|
||||
@test "without a configured ssh_key the forwarded agent still reaches the container" {
|
||||
# The positive control for the test above, and the reason it means
|
||||
# anything: without it, a launch that simply never set the variable would
|
||||
# satisfy that assertion too.
|
||||
_real_agent
|
||||
|
||||
_agent_launch ""
|
||||
local want="SSH_AUTH_SOCK=[$AGENT_SOCK]"
|
||||
kill "$AGENT_PID" 2>/dev/null || true
|
||||
[ -s "$AGENT_LOG" ] \
|
||||
|| { echo "the launch never reached docker:"; echo "$AGENT_OUT" | tail -15; return 1; }
|
||||
[ "$(sort -u "$AGENT_LOG")" = "$want" ] \
|
||||
|| { echo "the forwarded agent did not reach compose:"; sort -u "$AGENT_LOG"; return 1; }
|
||||
}
|
||||
|
||||
@test "an empty ssh_key file is not a key, and a pushing launch says so" {
|
||||
# It passes the is-a-file check and loads nothing in the container, so it
|
||||
# cannot be what stops the forwarded agent being mounted. Treated as no key
|
||||
# at all: harmless for a workflow that never pushes - which is what most of
|
||||
# this suite's fixtures are, with an empty file standing in for a path -
|
||||
# and named for one that does.
|
||||
# A pushing workflow with an empty key and no agent: the one case where it
|
||||
# costs anything, and the message names the file rather than only saying no
|
||||
# key is set. The with-an-agent direction is its own test above.
|
||||
AGENT_SOCK=""
|
||||
local key; key="$(mktemp)"; chmod 600 "$key"
|
||||
_agent_launch "$key"
|
||||
rm -f "$key"
|
||||
[[ "$AGENT_OUT" == *"names an empty file"* ]] \
|
||||
|| { echo "an empty key went unmentioned:"; echo "$AGENT_OUT" | tail -12; return 1; }
|
||||
[ ! -s "$AGENT_LOG" ] \
|
||||
|| { echo "the launch reached docker with no credential at all"; return 1; }
|
||||
}
|
||||
|
||||
# The positive control for the test above: with no key configured the
|
||||
# forwarded agent really is the only way to push, and an empty one is worth
|
||||
# saying so before it fails deep into a session.
|
||||
@@ -2221,6 +2373,139 @@ EOF
|
||||
rm -rf "$workspace" "$RW_HOME"
|
||||
}
|
||||
|
||||
@test "a read_write_per_project entry mounts this project's slice at the path it names" {
|
||||
_rw_setup
|
||||
pp="$(mktemp -d)"
|
||||
workspace="$(mktemp -d)"
|
||||
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only' \
|
||||
'[mounts]' "read_write_per_project = $pp"
|
||||
_rw_launch "$workspace"
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
|
||||
# The container gets the path the operator named; the host gives it one
|
||||
# directory of that path, keyed the same way share_dir and the audit store
|
||||
# are. Both halves asserted, because either alone is satisfied by the
|
||||
# arrangement this replaces.
|
||||
slug="$(printf '%s' "$workspace" | sed 's/[^a-zA-Z0-9]/-/g')"
|
||||
grep -qxF "$pp/projects/$slug:$pp" "$docker_log" \
|
||||
|| { echo "not mounted as a per-project slice:"; grep "$pp" "$docker_log"; return 1; }
|
||||
run grep -qxF "$pp:$pp" "$docker_log"
|
||||
[ "$status" -ne 0 ] \
|
||||
|| { echo "the whole directory was handed over as well"; return 1; }
|
||||
[ -d "$pp/projects/$slug" ] || { echo "the slice was never created"; return 1; }
|
||||
rm -rf "$pp" "$workspace" "$RW_HOME" "$docker_log"
|
||||
}
|
||||
|
||||
@test "two projects get different slices of one read_write_per_project entry" {
|
||||
# The whole point, and not deducible from one launch: the same declared
|
||||
# directory has to give two workspaces two directories, or it is a shared
|
||||
# pile with extra steps.
|
||||
_rw_setup
|
||||
pp="$(mktemp -d)"
|
||||
ws_a="$(mktemp -d)"; ws_b="$(mktemp -d)"
|
||||
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only' \
|
||||
'[mounts]' "read_write_per_project = $pp"
|
||||
_rw_launch "$ws_a"; [ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
a_log="$docker_log"
|
||||
_rw_launch "$ws_b"; [ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
|
||||
a="$(grep -oE "$pp/projects/[^:]+" "$a_log" | head -1)"
|
||||
b="$(grep -oE "$pp/projects/[^:]+" "$docker_log" | head -1)"
|
||||
[ -n "$a" ] && [ -n "$b" ] || { echo "no slice mounted for one of them"; return 1; }
|
||||
[ "$a" != "$b" ] \
|
||||
|| { echo "both projects were given the same directory: $a"; return 1; }
|
||||
rm -rf "$pp" "$ws_a" "$ws_b" "$RW_HOME" "$a_log" "$docker_log"
|
||||
}
|
||||
|
||||
@test "read_write is refused when it names a read_write_per_project root" {
|
||||
# The share guard's reason, one directory over: handing over the root that
|
||||
# holds every project's slice puts back the pile the slicing removed.
|
||||
_rw_setup
|
||||
pp="$(mktemp -d)"
|
||||
workspace="$(mktemp -d)"
|
||||
mkdir -p "$pp/projects"
|
||||
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only' \
|
||||
'[mounts]' "read_write = $pp/projects" "read_write_per_project = $pp"
|
||||
_rw_launch "$workspace"
|
||||
[ "$status" -ne 0 ] || { echo "the pile was handed back: $output"; return 1; }
|
||||
[[ "$output" == *"holds one directory per project"* ]] || { echo "$output"; return 1; }
|
||||
rm -rf "$pp" "$workspace" "$RW_HOME" "$docker_log"
|
||||
}
|
||||
|
||||
@test "readonly is refused when it names a read_write_per_project root" {
|
||||
_rw_setup
|
||||
pp="$(mktemp -d)"
|
||||
workspace="$(mktemp -d)"
|
||||
mkdir -p "$pp/projects"
|
||||
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only' \
|
||||
'[mounts]' "readonly = $pp/projects" "read_write_per_project = $pp"
|
||||
_rw_launch "$workspace"
|
||||
[ "$status" -ne 0 ] || { echo "every project's slice was readable: $output"; return 1; }
|
||||
rm -rf "$pp" "$workspace" "$RW_HOME" "$docker_log"
|
||||
}
|
||||
|
||||
@test "a read_write_per_project entry overlapping a protected path is refused" {
|
||||
# Slicing does not make a guarded store safe to hand over - it only makes
|
||||
# less of it reachable, which is not the property the guard is about.
|
||||
_rw_setup
|
||||
workspace="$(mktemp -d)"
|
||||
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only' \
|
||||
'[mounts]' "read_write_per_project = $CONFIG_DIR"
|
||||
_rw_launch "$workspace"
|
||||
[ "$status" -ne 0 ] || { echo "the approval store was mounted: $output"; return 1; }
|
||||
[[ "$output" == *"only how much of it there is"* ]] || { echo "$output"; return 1; }
|
||||
rm -rf "$workspace" "$RW_HOME" "$docker_log"
|
||||
}
|
||||
|
||||
@test "the same path in read_write and read_write_per_project is refused" {
|
||||
# Not by a duplicate check: the two mean different things, so naming a path
|
||||
# in both makes it an ancestor of its own per-project root, and the refusal
|
||||
# that fires is the one about handing over every project's directory. That
|
||||
# is the right answer, and it says which list the path belongs in.
|
||||
_rw_setup
|
||||
pp="$(mktemp -d)"
|
||||
workspace="$(mktemp -d)"
|
||||
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only' \
|
||||
'[mounts]' "read_write = $pp" "read_write_per_project = $pp"
|
||||
_rw_launch "$workspace"
|
||||
[ "$status" -ne 0 ] || { echo "$output"; return 1; }
|
||||
[[ "$output" == *"holds one directory per project"* ]] || { echo "$output"; return 1; }
|
||||
[[ "$output" == *"one list or the other and not in both"* ]] || { echo "$output"; return 1; }
|
||||
rm -rf "$pp" "$workspace" "$RW_HOME" "$docker_log"
|
||||
}
|
||||
|
||||
@test "a read_write_per_project entry that is not an existing directory is refused" {
|
||||
# The slice is dejima's to create; the root is not. A path that is not
|
||||
# there is a typo, and creating it would turn the typo into a directory.
|
||||
_rw_setup
|
||||
workspace="$(mktemp -d)"
|
||||
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only' \
|
||||
'[mounts]' "read_write_per_project = /nonexistent/specs"
|
||||
_rw_launch "$workspace"
|
||||
[ "$status" -ne 0 ]
|
||||
[[ "$output" == *"not an existing directory"* ]] || { echo "$output"; return 1; }
|
||||
[ ! -d /nonexistent/specs ]
|
||||
rm -rf "$workspace" "$RW_HOME" "$docker_log"
|
||||
}
|
||||
|
||||
@test "--print-briefing names a per-project mount as this project's own" {
|
||||
# A separate sentence from the read_write one, because the read_write
|
||||
# sentence is not true of these: a session told "these host directories at
|
||||
# their own paths" would believe the whole directory is behind the mount.
|
||||
pp="$(mktemp -d)"
|
||||
rw="$(mktemp -d)"
|
||||
write_config '[core]' 'forge = none' 'workflow = local-only' \
|
||||
'[mounts]' "read_write = $rw" "read_write_per_project = $pp"
|
||||
run "$REPO_ROOT/bin/dejima" --print-briefing
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
[[ "$output" == *"at their own paths"*"$rw"* ]] || { echo "$output"; return 1; }
|
||||
[[ "$output" == *"THIS project's own directory"*"$pp"* ]] || { echo "$output"; return 1; }
|
||||
# And composing a briefing leaves nothing behind: the slice belongs to a
|
||||
# launch, not to a diagnostic that prints one.
|
||||
[ ! -d "$pp/projects" ] || { echo "--print-briefing created the slice"; return 1; }
|
||||
rm -rf "$pp" "$rw"
|
||||
}
|
||||
|
||||
@test "a path in both read_write and readonly is refused rather than resolved by mount order" {
|
||||
_rw_setup
|
||||
d="$(mktemp -d)"
|
||||
@@ -3027,10 +3312,30 @@ _upd_cache() { printf '%s/.local/share/dejima/update-check' "$HOME"; }
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *"origin/trunk"* ]] \
|
||||
|| { echo "does not name what it is behind: $output"; return 1; }
|
||||
# The path, because the checkout that is behind is not necessarily the
|
||||
# The paths, because the checkout that is behind is not necessarily the
|
||||
# directory the operator is standing in when they read this.
|
||||
[[ "$output" == *"git -C $UPD_CHECKOUT pull"* ]] \
|
||||
|| { echo "does not say how to update: $output"; return 1; }
|
||||
# install.sh too, because pulling does not re-link what a new release
|
||||
# adds on PATH.
|
||||
[[ "$output" == *"$UPD_CHECKOUT/install.sh"* ]] \
|
||||
|| { echo "does not say to re-run install.sh: $output"; return 1; }
|
||||
}
|
||||
|
||||
@test "the notice is set off by a blank line either side" {
|
||||
_upd_fixture
|
||||
_upd_commit "$UPD_ORIGIN" second
|
||||
# Read from the file the tool wrote: bats' run strips the blank lines at
|
||||
# either end of what it captures, and those are the thing being asserted -
|
||||
# they are what keeps the launch's startup lines and image build from
|
||||
# reading as part of the notice.
|
||||
local out="$UPD_DIR/notice"
|
||||
"$UPD_CHECKOUT/bin/dejima-update-check" >"$out" 2>&1
|
||||
grep -q "commits this checkout" "$out" || { cat "$out"; return 1; }
|
||||
[ -z "$(head -n 1 "$out")" ] \
|
||||
|| { echo "no blank line before it: $(cat "$out")"; return 1; }
|
||||
[ -z "$(tail -n 1 "$out")" ] \
|
||||
|| { echo "no blank line after it: $(cat "$out")"; return 1; }
|
||||
}
|
||||
|
||||
@test "the commit count is given only when the commits are actually here" {
|
||||
@@ -3276,7 +3581,7 @@ EOF
|
||||
# The unnamed default gets a name, which is what lets it carry a git name
|
||||
# and a forge credential beside the key rather than those coming from
|
||||
# three different places.
|
||||
ID_DIR="$(mktemp -d)"; : > "$ID_DIR/default-key"; : > "$ID_DIR/tok"
|
||||
ID_DIR="$(mktemp -d)"; printf "k\n" | tee "$ID_DIR/default-key" > "$ID_DIR/tok"
|
||||
write_config '[core]' 'forge = gitea' \
|
||||
'[forge.gitea]' 'ssh_host = git.example' 'api = https://git.example/api/v1' \
|
||||
'[identity.default]' "ssh_key = $ID_DIR/default-key" \
|
||||
@@ -3291,7 +3596,7 @@ EOF
|
||||
@test "[core] ssh_key alongside [identity.default] is refused, not ranked" {
|
||||
# Two spellings of one thing. Which key a session pushed with is not
|
||||
# something to work out from precedence rules.
|
||||
ID_DIR="$(mktemp -d)"; : > "$ID_DIR/old-key"; : > "$ID_DIR/new-key"
|
||||
ID_DIR="$(mktemp -d)"; printf "k\n" | tee "$ID_DIR/old-key" > "$ID_DIR/new-key"
|
||||
write_config '[core]' 'forge = none' "ssh_key = $ID_DIR/old-key" \
|
||||
'[identity.default]' "ssh_key = $ID_DIR/new-key"
|
||||
run "$REPO_ROOT/bin/dejima" --print-config
|
||||
@@ -3304,7 +3609,7 @@ EOF
|
||||
@test "[core] ssh_key on its own still works, and says what replaced it" {
|
||||
# Deprecated, not removed: it is how every config written before
|
||||
# identities existed says this.
|
||||
ID_DIR="$(mktemp -d)"; : > "$ID_DIR/old-key"
|
||||
ID_DIR="$(mktemp -d)"; printf "not-a-real-key\n" > "$ID_DIR/old-key"
|
||||
write_config '[core]' 'forge = none' "ssh_key = $ID_DIR/old-key"
|
||||
run "$REPO_ROOT/bin/dejima" --print-config
|
||||
[ "$status" -eq 0 ] || { echo "a pre-identity config stopped working: $output"; return 1; }
|
||||
@@ -3315,7 +3620,7 @@ EOF
|
||||
}
|
||||
|
||||
@test "a named identity still beats [identity.default]" {
|
||||
ID_DIR="$(mktemp -d)"; : > "$ID_DIR/default-key"; : > "$ID_DIR/bot-key"
|
||||
ID_DIR="$(mktemp -d)"; printf "not-a-real-key\n" > "$ID_DIR/default-key"; printf "not-a-real-key\n" > "$ID_DIR/bot-key"
|
||||
write_config '[core]' 'forge = none' \
|
||||
'[identity.default]' "ssh_key = $ID_DIR/default-key" \
|
||||
'[identity.bot]' "ssh_key = $ID_DIR/bot-key"
|
||||
@@ -3330,7 +3635,7 @@ EOF
|
||||
# from the refusal - adding [identity.default] would stop it starting.
|
||||
# Finding that out on the launch that breaks is worse than being told now,
|
||||
# so the warning is about the config rather than about this launch.
|
||||
ID_DIR="$(mktemp -d)"; : > "$ID_DIR/old-key"; : > "$ID_DIR/bot-key"
|
||||
ID_DIR="$(mktemp -d)"; printf "not-a-real-key\n" > "$ID_DIR/old-key"; printf "not-a-real-key\n" > "$ID_DIR/bot-key"
|
||||
write_config '[core]' 'forge = none' "ssh_key = $ID_DIR/old-key" \
|
||||
'identity = bot' '[identity.bot]' "ssh_key = $ID_DIR/bot-key"
|
||||
run "$REPO_ROOT/bin/dejima" --print-config
|
||||
@@ -3351,7 +3656,7 @@ EOF
|
||||
# The other half of a deprecation warning: it has to stop. One that fires
|
||||
# after the migration is one an operator learns to ignore, which is how
|
||||
# the next warning gets missed too.
|
||||
ID_DIR="$(mktemp -d)"; : > "$ID_DIR/key"
|
||||
ID_DIR="$(mktemp -d)"; printf "not-a-real-key\n" > "$ID_DIR/key"
|
||||
write_config '[core]' 'forge = none' \
|
||||
'[identity.default]' "ssh_key = $ID_DIR/key" 'user_name = dejima agent'
|
||||
run "$REPO_ROOT/bin/dejima" --print-config
|
||||
@@ -3916,6 +4221,46 @@ _scan_staged_has_token() {
|
||||
rm -rf "$workspace" "$SCAN_STUB" "$SCAN_STAGED"
|
||||
}
|
||||
|
||||
@test "a scrub finds the value again whatever the key is spelled like" {
|
||||
# Deleting by document order made each later index stale: the second secret
|
||||
# survived and an innocent value went instead. The staged file is mounted
|
||||
# into the container, so the operator answers "keep it out" and it goes in.
|
||||
#
|
||||
# Driven against the program's own functions rather than a reimplementation
|
||||
# of the ordering, which is the thing under test.
|
||||
local scan="$REPO_ROOT/bin/dejima-credential-scan"
|
||||
run python3 - "$scan" <<'PYEOF'
|
||||
import json, sys
|
||||
mod = {}
|
||||
exec(compile(open(sys.argv[1]).read(), "scan", "exec"), mod)
|
||||
doc = {"mcpServers": {"x": {"args": ["sk-SECRET-ONE-aaaaaaaaaaaaaaaaaaaa",
|
||||
"sk-SECRET-TWO-bbbbbbbbbbbbbbbbbbbb",
|
||||
"--harmless-flag"]}}}
|
||||
def scrub(doc, want):
|
||||
pairs = list(mod["walk_json"](json.loads(json.dumps(doc))))
|
||||
drop = [parts for p, v, parts in pairs if p in want]
|
||||
out = json.loads(json.dumps(doc))
|
||||
mod["delete_json_components"](out, drop)
|
||||
return out
|
||||
|
||||
print(json.dumps(scrub(doc, {"mcpServers.x.args[0]",
|
||||
"mcpServers.x.args[1]"})["mcpServers"]["x"]["args"]))
|
||||
# A key that spells an index, and a key carrying a dot: both used to be
|
||||
# re-parsed as traversals, deleting a bystander or raising out of the scrub.
|
||||
print(json.dumps(scrub({"x[0]": "sk-SECRET-key", "x": ["innocent"]}, {"x[0]"})))
|
||||
print(json.dumps(scrub({"a.b": "sk-SECRET-dotted"}, {"a.b"})))
|
||||
PYEOF
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
[[ "$output" != *SECRET* ]] \
|
||||
|| { echo "a secret survived the scrub: $output"; return 1; }
|
||||
[[ "$output" == *harmless-flag* ]] \
|
||||
|| { echo "the scrub removed a value that was not a secret: $output"; return 1; }
|
||||
# No line raised: a KeyError here aborts the scrub and bin/dejima then
|
||||
# copies the file in unscrubbed.
|
||||
[ "${#lines[@]}" -eq 3 ] \
|
||||
|| { echo "the scrub did not complete for every shape: $output"; return 1; }
|
||||
}
|
||||
|
||||
@test "a standing scrub written by hand is honoured, and holds no value" {
|
||||
# Not offered at the prompt, but available to someone who has read what it
|
||||
# does - and it is the only way a scrubbed field survives an unattended
|
||||
|
||||
Vendored
+23
-2
@@ -16,8 +16,10 @@ import json
|
||||
import threading
|
||||
import time
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
STATE = {"oauth_hits": 0, "oauth_last": {}, "returned_401": False}
|
||||
STATE = {"oauth_hits": 0, "oauth_last": {}, "returned_401": False, "oauth_reject": False,
|
||||
"oauth_reject_say": None}
|
||||
LOCK = threading.Lock()
|
||||
|
||||
|
||||
@@ -50,11 +52,30 @@ class Handler(BaseHTTPRequestHandler):
|
||||
}).encode())
|
||||
|
||||
def _handle(self):
|
||||
if self.path == "/v1/oauth/token":
|
||||
if self.path.startswith("/oauth-reject"):
|
||||
# Make the next refresh fail the way a finished refresh token does.
|
||||
# Set rather than always-on, so the ordinary refresh tests keep the
|
||||
# same endpoint. ?say= replaces the error_description, for tests
|
||||
# about upstream-chosen text landing in the proxy's log.
|
||||
with LOCK:
|
||||
STATE["oauth_reject"] = True
|
||||
STATE["oauth_reject_say"] = \
|
||||
parse_qs(urlsplit(self.path).query).get("say", [None])[0]
|
||||
self._send(200, b"ok", "text/plain")
|
||||
elif self.path == "/v1/oauth/token":
|
||||
req = json.loads(self.rfile.read(int(self.headers.get("Content-Length", 0))))
|
||||
with LOCK:
|
||||
STATE["oauth_hits"] += 1
|
||||
STATE["oauth_last"] = req
|
||||
reject = STATE.get("oauth_reject")
|
||||
say = STATE.get("oauth_reject_say")
|
||||
if reject:
|
||||
# Verbatim from a real overnight expiry.
|
||||
self._send(400, json.dumps({
|
||||
"error": "invalid_grant",
|
||||
"error_description": say or "Refresh token expired",
|
||||
}).encode())
|
||||
return
|
||||
self._send(200, json.dumps({
|
||||
"access_token": "refreshed-access-token",
|
||||
"refresh_token": "rotated-refresh-token",
|
||||
|
||||
@@ -0,0 +1,506 @@
|
||||
#!/usr/bin/env bats
|
||||
# How a launch is arranged: the containers it starts, in what order, on what
|
||||
# network, and what it takes down again.
|
||||
#
|
||||
# The subject is bin/dejima's side of the filter container - the extra
|
||||
# container per launch that owns the network namespace and the packet filter,
|
||||
# which the sandbox joins and has no capability to alter. Whether the filter
|
||||
# actually filters is runtime.bats's question and needs a real Docker; whether
|
||||
# the launcher builds the arrangement that puts one there is this file's, and
|
||||
# needs only a stub.
|
||||
#
|
||||
# Everything here runs against a `docker` that records its argv and exits 0, so
|
||||
# a launch completes without a daemon. That makes the ORDER of the calls, and
|
||||
# the flags on each, the thing being asserted.
|
||||
|
||||
setup() {
|
||||
REPO_ROOT="$BATS_TEST_DIRNAME/.."
|
||||
SANDBOX_DIR="$REPO_ROOT/docker"
|
||||
LIB="$REPO_ROOT/bin/dejima-project-lib.sh"
|
||||
CONFIG_DIR="$(mktemp -d)"
|
||||
RUNTIME_DIR="$(mktemp -d)"
|
||||
export DEJIMA_CONFIG_DIR="$CONFIG_DIR"
|
||||
export XDG_RUNTIME_DIR="$RUNTIME_DIR"
|
||||
}
|
||||
|
||||
teardown() {
|
||||
rm -rf "$CONFIG_DIR" "$RUNTIME_DIR" ${LAUNCH_DIR:+"$LAUNCH_DIR"} ${PROJ:+"$PROJ"}
|
||||
}
|
||||
|
||||
# A `docker` that logs one line per invocation and succeeds at everything,
|
||||
# including the readiness probe. Tests that need a failure override it.
|
||||
stub_launch() {
|
||||
LAUNCH_DIR="$(mktemp -d)"
|
||||
mkdir -p "$LAUNCH_DIR/home/.claude"
|
||||
echo '{}' > "$LAUNCH_DIR/home/.claude/settings.json"
|
||||
cat > "$LAUNCH_DIR/docker" <<'EOF'
|
||||
#!/bin/bash
|
||||
echo "$*" >> "${SANDBOX_TEST_DOCKER_LOG:-/dev/null}"
|
||||
# `docker run -e NAME` carries the value in the environment rather than on the
|
||||
# command line, so the log above cannot show whether a flag had anything behind
|
||||
# it. Record what this process inherited, which is the same thing the flag
|
||||
# would forward. GH_TOKEN is deliberately not among them: it is a real
|
||||
# credential on a developer's machine and there is no reason to write one to
|
||||
# disk to test an unrelated variable.
|
||||
case "$*" in
|
||||
"run -d --init"*)
|
||||
env | grep -E '^(DEJIMA_|ALLOWED_DOMAINS=|ENABLE_FIREWALL=|ALLOW_GITHUB_PUSH=|LOKI_URL=|SANDBOX_)' \
|
||||
> "${SANDBOX_TEST_FILTER_ENV:-/dev/null}" || true
|
||||
;;
|
||||
esac
|
||||
exit 0
|
||||
EOF
|
||||
chmod +x "$LAUNCH_DIR/docker"
|
||||
printf "not-a-real-key\n" > "$LAUNCH_DIR/sandbox-key"
|
||||
chmod 600 "$LAUNCH_DIR/sandbox-key"
|
||||
PROJ="$(mktemp -d)"
|
||||
}
|
||||
|
||||
launch() {
|
||||
local conf
|
||||
conf="$(bash -c 'source "$1"; config_path' _ "$LIB")"
|
||||
mkdir -p "$(dirname "$conf")"
|
||||
printf '[core]\nharness = claude-code\nupdate_check = off\nssh_key = %s\n' \
|
||||
"$LAUNCH_DIR/sandbox-key" >> "$conf"
|
||||
HOME="$LAUNCH_DIR/home" PATH="$LAUNCH_DIR:$PATH" \
|
||||
SANDBOX_TEST_DOCKER_LOG="$LAUNCH_DIR/docker-log" \
|
||||
SANDBOX_TEST_FILTER_ENV="$LAUNCH_DIR/filter-env" \
|
||||
SSH_AUTH_SOCK= ALLOWED_DOMAINS= \
|
||||
"${DEJIMA_BIN:-$REPO_ROOT/bin/dejima}" "$@"
|
||||
}
|
||||
|
||||
log_line() { grep -- "$1" "$LAUNCH_DIR/docker-log" | tail -1; }
|
||||
# 1-indexed position of the last line matching a pattern, for ordering claims.
|
||||
log_pos() { grep -n -- "$1" "$LAUNCH_DIR/docker-log" | tail -1 | cut -d: -f1; }
|
||||
|
||||
@test "a launch creates a network of its own and a filter container on it" {
|
||||
stub_launch
|
||||
launch "$PROJ"
|
||||
|
||||
local net; net="$(log_line 'network create')"
|
||||
[ -n "$net" ] || { echo "no network was created"; return 1; }
|
||||
[[ "$net" =~ dejima-run-[0-9a-f]{16} ]] \
|
||||
|| { echo "the network is not named for this run: $net"; return 1; }
|
||||
[[ "$net" == *"--label dejima.run="* ]] \
|
||||
|| { echo "the network carries no run label, so nothing can reconcile it: $net"; return 1; }
|
||||
|
||||
local run; run="$(log_line 'run -d --init')"
|
||||
[ -n "$run" ] || { echo "no filter container was started"; return 1; }
|
||||
[[ "$run" =~ --name\ dejima-filter-[0-9a-f]{16} ]] \
|
||||
|| { echo "the filter container is not named for this run: $run"; return 1; }
|
||||
[[ "$run" == *"--entrypoint /usr/local/bin/filter-entrypoint.sh"* ]] \
|
||||
|| { echo "the filter container does not run the filter entrypoint: $run"; return 1; }
|
||||
|
||||
# One network per launch, and the filter container on that one - not on
|
||||
# whatever bridge Docker would have picked. The restricted resolver forwards to the
|
||||
# embedded resolver at 127.0.0.11, which only exists on a user-defined
|
||||
# network, so this is load-bearing rather than tidiness.
|
||||
local id; id="$(sed -E 's/.*dejima-run-([0-9a-f]{16}).*/\1/' <<< "$net")"
|
||||
[[ "$run" == *"--network dejima-run-$id"* ]] \
|
||||
|| { echo "the filter container is not on this launch's network: $run"; return 1; }
|
||||
}
|
||||
|
||||
@test "the sandbox joins the filter container's namespace rather than getting one" {
|
||||
stub_launch
|
||||
launch "$PROJ"
|
||||
|
||||
# The compose file is what carries it, from an environment variable the
|
||||
# launcher exports - so both halves are asserted. Either alone passes on an
|
||||
# arrangement where the session still has a network of its own.
|
||||
grep -qE '^\s*network_mode: \$\{SANDBOX_NETNS_CONTAINER' "$SANDBOX_DIR/docker-compose.yml" \
|
||||
|| { echo "the compose file does not join a namespace"; return 1; }
|
||||
|
||||
# And what it falls back to when nothing supplies one. Only a `docker
|
||||
# compose` invocation by hand reaches this, but a default naming a real
|
||||
# network is a sandbox that comes up working with unfiltered egress, and
|
||||
# nothing about it looks wrong. `none` fails the same case visibly.
|
||||
grep -qE '^\s*network_mode: \$\{SANDBOX_NETNS_CONTAINER:-none\}\s*$' \
|
||||
"$SANDBOX_DIR/docker-compose.yml" \
|
||||
|| { echo "the network_mode default is not fail-closed"; return 1; }
|
||||
|
||||
local run; run="$(log_line 'run -d --init')"
|
||||
local id; id="$(sed -E 's/.*--name dejima-filter-([0-9a-f]{16}).*/\1/' <<< "$run")"
|
||||
[ -n "$id" ]
|
||||
run grep -c "SANDBOX_NETNS_CONTAINER" "$REPO_ROOT/bin/dejima"
|
||||
[ "$status" -eq 0 ]
|
||||
grep -q 'export SANDBOX_NETNS_CONTAINER="container:\$SANDBOX_FILTER_CONTAINER"' \
|
||||
"$REPO_ROOT/bin/dejima" \
|
||||
|| { echo "the launcher does not point the sandbox at the filter container"; return 1; }
|
||||
}
|
||||
|
||||
@test "the filter container comes up before the session, not beside it" {
|
||||
# The ordering IS the control. A namespace that gets its rules after
|
||||
# something is already running in it has a window where a session holds an
|
||||
# interface and no filter, which is what this arrangement exists to remove
|
||||
# by construction rather than to bound by argument.
|
||||
stub_launch
|
||||
launch "$PROJ"
|
||||
|
||||
local net_at filter_at ready_at session_at
|
||||
net_at="$(log_pos 'network create')"
|
||||
filter_at="$(log_pos 'run -d --init')"
|
||||
ready_at="$(log_pos 'dejima-firewall-ready')"
|
||||
session_at="$(log_pos ' run --rm ')"
|
||||
[ -n "$net_at" ] && [ -n "$filter_at" ] && [ -n "$ready_at" ] && [ -n "$session_at" ] \
|
||||
|| { echo "one of the four steps never happened"; cat "$LAUNCH_DIR/docker-log"; return 1; }
|
||||
|
||||
[ "$net_at" -lt "$filter_at" ] \
|
||||
|| { echo "the filter container started before its network existed"; return 1; }
|
||||
[ "$filter_at" -lt "$ready_at" ] \
|
||||
|| { echo "readiness was probed before the filter container was started"; return 1; }
|
||||
[ "$ready_at" -lt "$session_at" ] \
|
||||
|| { echo "the session started before the filter was confirmed up"; return 1; }
|
||||
}
|
||||
|
||||
@test "a filter container that never comes up refuses the launch" {
|
||||
# The failure this is really about is the quiet one: a launcher that waited,
|
||||
# gave up, and started the session anyway would produce a working sandbox
|
||||
# with no egress control, and nothing downstream would notice.
|
||||
stub_launch
|
||||
cat > "$LAUNCH_DIR/docker" <<'EOF'
|
||||
#!/bin/bash
|
||||
echo "$*" >> "${SANDBOX_TEST_DOCKER_LOG:-/dev/null}"
|
||||
case "$*" in
|
||||
# The marker never appears, and the container is reported gone - which is
|
||||
# what a firewall that refused to start leaves behind.
|
||||
*dejima-firewall-ready*) exit 1 ;;
|
||||
*inspect*State.Running*) echo false; exit 0 ;;
|
||||
*logs*) echo "[egress-policy] FAILED: registry.npmjs.org did not resolve"; exit 0 ;;
|
||||
esac
|
||||
exit 0
|
||||
EOF
|
||||
chmod +x "$LAUNCH_DIR/docker"
|
||||
|
||||
run launch "$PROJ"
|
||||
[ "$status" -ne 0 ] \
|
||||
|| { echo "the launch succeeded with no egress control"; return 1; }
|
||||
[[ "$output" == *"did not come up"* ]] \
|
||||
|| { echo "it refused without saying why: $output"; return 1; }
|
||||
# And it hands over what the filter container said, or the operator is left
|
||||
# with a refusal and nowhere to look.
|
||||
[[ "$output" == *"registry.npmjs.org"* ]] \
|
||||
|| { echo "the refusal does not carry the filter container's own output: $output"; return 1; }
|
||||
|
||||
run grep -c -- ' run --rm ' "$LAUNCH_DIR/docker-log"
|
||||
[ "$output" = "0" ] \
|
||||
|| { echo "the session was started anyway"; return 1; }
|
||||
}
|
||||
|
||||
@test "the launch takes its own container and network down again" {
|
||||
stub_launch
|
||||
launch "$PROJ"
|
||||
|
||||
local run id
|
||||
run="$(log_line 'run -d --init')"
|
||||
id="$(sed -E 's/.*--name dejima-filter-([0-9a-f]{16}).*/\1/' <<< "$run")"
|
||||
[ -n "$id" ]
|
||||
|
||||
grep -q -- "rm -f dejima-filter-$id" "$LAUNCH_DIR/docker-log" \
|
||||
|| { echo "the filter container was left running"; return 1; }
|
||||
grep -q -- "network rm dejima-run-$id" "$LAUNCH_DIR/docker-log" \
|
||||
|| { echo "the network was left behind"; return 1; }
|
||||
|
||||
# After the session, not before it. Docker permits removing a namespace
|
||||
# owner from under a live joiner, so this order is the launcher's to keep.
|
||||
local session_at rm_at
|
||||
session_at="$(log_pos ' run --rm ')"
|
||||
rm_at="$(log_pos "rm -f dejima-filter-$id")"
|
||||
[ "$session_at" -lt "$rm_at" ] \
|
||||
|| { echo "the filter container was removed before the session ran"; return 1; }
|
||||
}
|
||||
|
||||
@test "the filter container runs the base image, never a project's derived one" {
|
||||
# It is the privileged half of the pair. A project's declared dependencies
|
||||
# are installed into the derived image for the session to use, and there is
|
||||
# no reason for any of them to be present in the container holding
|
||||
# NET_ADMIN.
|
||||
stub_launch
|
||||
launch "$PROJ"
|
||||
|
||||
local run; run="$(log_line 'run -d --init')"
|
||||
[[ "$run" =~ localhost/dejima:[^[:space:]-]+$ ]] \
|
||||
|| { echo "the filter container's image is not the plain base tag: $run"; return 1; }
|
||||
}
|
||||
|
||||
@test "the filter container declares the network capabilities and drops the rest" {
|
||||
stub_launch
|
||||
launch "$PROJ"
|
||||
|
||||
local run; run="$(log_line 'run -d --init')"
|
||||
[[ "$run" == *"--cap-drop ALL"* ]] \
|
||||
|| { echo "the filter container keeps Docker's default set: $run"; return 1; }
|
||||
[[ "$run" == *"--security-opt no-new-privileges:true"* ]] \
|
||||
|| { echo "no-new-privileges is missing from the filter container: $run"; return 1; }
|
||||
local cap
|
||||
for cap in NET_ADMIN NET_RAW KILL SETPCAP; do
|
||||
[[ "$run" == *"--cap-add $cap"* ]] \
|
||||
|| { echo "the filter container cannot do its job without $cap: $run"; return 1; }
|
||||
done
|
||||
}
|
||||
|
||||
|
||||
@test "every variable the firewall path reads is passed to the filter container" {
|
||||
# The drift guard this arrangement needs most. The sandbox gets its
|
||||
# environment from docker-compose.yml; the filter container is started with
|
||||
# `docker run` and gets nothing that is not named on that command line.
|
||||
#
|
||||
# So a firewall script that starts reading a new variable does not fail - it
|
||||
# reads an empty one, and permits less than it was told to, which presents
|
||||
# as a domain the operator approved being unreachable rather than as
|
||||
# anything about this. Derived from the scripts rather than listed here, so
|
||||
# the test cannot drift from them either.
|
||||
stub_launch
|
||||
launch "$PROJ"
|
||||
local run; run="$(log_line 'run -d --init')"
|
||||
[ -n "$run" ]
|
||||
|
||||
# A name these files ORIGINATE is their own working value, not something
|
||||
# the launcher supplies, so only names they read without originating have
|
||||
# to be passed in. Derived rather than listed, because a hand-kept
|
||||
# exclusion list is the same drift this test exists to catch, one level up.
|
||||
#
|
||||
# `X="${X:-}"` does not count as originating X: it reads the launcher's
|
||||
# value and supplies a default for its absence, which is the shape every
|
||||
# variable here is consumed with. Counting it would have quietly dropped
|
||||
# LOKI_URL out of the required set.
|
||||
local scripts=("$SANDBOX_DIR/firewall-up.sh" "$SANDBOX_DIR/egress-policy"
|
||||
"$SANDBOX_DIR/fixed-domains.sh" "$SANDBOX_DIR/log-shipper.sh")
|
||||
# The awk program is held in a variable rather than written inline: it
|
||||
# contains a double quote, and bash mis-nests one inside a single-quoted
|
||||
# program inside "$( )" - the whole file then fails to parse, as a syntax
|
||||
# error pointing at an unrelated line much further down.
|
||||
local prog assigned
|
||||
prog=$(cat <<'AWK'
|
||||
match($0, /^[[:space:]]*(export |readonly |local )?[A-Z][A-Z0-9_]*\+?=/) {
|
||||
decl = substr($0, RSTART, RLENGTH)
|
||||
rest = substr($0, RSTART + RLENGTH)
|
||||
gsub(/^[[:space:]]*(export|readonly|local)[[:space:]]+/, "", decl)
|
||||
sub(/\+?=$/, "", decl)
|
||||
if (rest ~ ("[$]{?" decl)) next
|
||||
print decl
|
||||
}
|
||||
|
||||
AWK
|
||||
)
|
||||
assigned="$(cat "${scripts[@]}" | awk "$prog" | sort -u)"
|
||||
local v missing=""
|
||||
while read -r v; do
|
||||
[ -n "$v" ] || continue
|
||||
grep -qxF "$v" <<< "$assigned" && continue
|
||||
[[ "$run" == *"-e $v"* ]] || missing="$missing $v"
|
||||
done < <(cat "${scripts[@]}" \
|
||||
| grep -oE '\$\{?(ALLOW[A-Z_]*|DEJIMA_[A-Z_]*|ENABLE_[A-Z_]*|GH_TOKEN|LOKI_URL|SANDBOX_[A-Z_]*)' \
|
||||
| tr -d '${' | sort -u \
|
||||
| grep -vE '^(SANDBOX_TEST.*)$')
|
||||
|
||||
[ -z "$missing" ] \
|
||||
|| { echo "the firewall path reads these, and the filter container is not given them:$missing"; return 1; }
|
||||
|
||||
# And the other half of the same failure, which the flag alone cannot show.
|
||||
# `docker run -e NAME` takes the value from the launcher's environment, so
|
||||
# a name that is assigned but never exported passes an empty one - the
|
||||
# filter container then permits less than it was told to, and what an
|
||||
# operator sees is a domain they approved being unreachable, with nothing
|
||||
# pointing here.
|
||||
#
|
||||
# Not hypothetical: `export DEJIMA_FORGE_HOSTS` is a line that had to be
|
||||
# added by hand when the filter container was introduced, because until
|
||||
# then that variable had only ever needed to reach the sandbox as
|
||||
# `-e NAME=value`, which carries its own value.
|
||||
#
|
||||
# Continuations are flattened first, because the exports here run to
|
||||
# several lines; and `export` is matched anywhere on a line rather than at
|
||||
# its start, because two of these are exported after a `;` on the line that
|
||||
# assigns them.
|
||||
local exported unexported=""
|
||||
exported="$(sed -z 's/\\\n[[:space:]]*/ /g' "$REPO_ROOT/bin/dejima" \
|
||||
| grep -oE 'export [A-Za-z_][A-Za-z0-9_ ]*')"
|
||||
[ -n "$exported" ] || { echo "found no exports in bin/dejima at all"; return 1; }
|
||||
|
||||
while read -r v; do
|
||||
[ -n "$v" ] || continue
|
||||
grep -qE "(^|[[:space:]])$v([[:space:]]|\$)" <<< "$exported" \
|
||||
|| unexported="$unexported $v"
|
||||
done < <(grep -oE '^\s+-e [A-Z_]+' "$REPO_ROOT/bin/dejima" | awk '{print $2}' | sort -u)
|
||||
|
||||
[ -z "$unexported" ] \
|
||||
|| { echo "passed as -e NAME but never exported, so the value is empty:$unexported"; return 1; }
|
||||
}
|
||||
@test "a declared internal domain reaches the filter container as a value, not just a name" {
|
||||
# The test above proves the flag is on the command line. `docker run -e
|
||||
# NAME` takes the value from the launcher's environment, so a flag with
|
||||
# nothing behind it passes an empty string - and this key decides whether a
|
||||
# security check applies to a name, so an empty one silently applies it to
|
||||
# every name the operator declared internal.
|
||||
#
|
||||
# Colon-separated in the file, space-separated in the environment, which is
|
||||
# what firewall-up.sh splits on.
|
||||
stub_launch
|
||||
local conf
|
||||
conf="$(bash -c 'source "$1"; config_path' _ "$LIB")"
|
||||
mkdir -p "$(dirname "$conf")"
|
||||
printf '[core]\ninternal_domains = mirror.example:logs.example\n' >> "$conf"
|
||||
launch "$PROJ"
|
||||
|
||||
local run; run="$(log_line 'run -d --init')"
|
||||
[ -n "$run" ]
|
||||
[[ "$run" == *"-e DEJIMA_INTERNAL_DOMAINS"* ]] \
|
||||
|| { echo "the flag is not on the filter container's command line"; return 1; }
|
||||
|
||||
# The value itself, read out of the environment the launcher handed over.
|
||||
# The stub records it, because `-e NAME` puts the value nowhere the command
|
||||
# line can show it - which is the whole reason this can go wrong quietly.
|
||||
local seen; seen="$(cat "$LAUNCH_DIR/filter-env" 2>/dev/null || true)"
|
||||
[[ "$seen" == *"DEJIMA_INTERNAL_DOMAINS=mirror.example logs.example"* ]] \
|
||||
|| { echo "the filter container was given: '$seen'"; return 1; }
|
||||
}
|
||||
|
||||
# A `docker` that reports one filter container and one network, with whatever
|
||||
# age, joiner and identity a test needs, and records every removal.
|
||||
#
|
||||
# Each of these tests is about a branch that decides NOT to remove something,
|
||||
# and a reconciler that removed nothing at all would pass every one of them -
|
||||
# so the first test here is the one that proves the mechanism fires.
|
||||
stub_orphan() { # <age-seconds> <joined-by|"">
|
||||
local age="$1" joiner="$2"
|
||||
stub_launch
|
||||
# Each query answers in the format Docker really answers it in, and the two
|
||||
# differ: `Created` is a string field on a container and a time.Time on a
|
||||
# network, so a template prints the second through String() and appends a
|
||||
# timezone NAME. One format for both is what let the network half of the
|
||||
# reconciler pass a test while reaping nothing.
|
||||
cat > "$LAUNCH_DIR/docker" <<EOF
|
||||
#!/bin/bash
|
||||
echo "\$*" >> "\${SANDBOX_TEST_DOCKER_LOG:-/dev/null}"
|
||||
epoch=\$(( \$(date +%s) - $age ))
|
||||
made=\$(date -u -d "@\$epoch" +%Y-%m-%dT%H:%M:%S.000000000Z)
|
||||
made_go=\$(date -u -d "@\$epoch" '+%Y-%m-%d %H:%M:%S.000000000 +0000 UTC')
|
||||
case "\$*" in
|
||||
# The running containers, and what namespace each is in.
|
||||
"ps -q") [ -n "$joiner" ] && echo j0 ; exit 0 ;;
|
||||
*HostConfig.NetworkMode*) [ -n "$joiner" ] && echo "container:$joiner" ; exit 0 ;;
|
||||
# The candidates.
|
||||
*"ps -aq --filter label=dejima.filter"*) echo orphan0 ; exit 0 ;;
|
||||
*"{{.Id}} {{.Name}} {{.Created}}"*) echo "orphan0 /dejima-filter-old \$made" ; exit 0 ;;
|
||||
*"network ls -q --filter label=dejima.run"*) echo net0 ; exit 0 ;;
|
||||
*"{{.Name}} {{.Created}}"*) echo "dejima-run-old \$made_go" ; exit 0 ;;
|
||||
esac
|
||||
exit 0
|
||||
EOF
|
||||
chmod +x "$LAUNCH_DIR/docker"
|
||||
}
|
||||
|
||||
@test "a filter container nothing is joined to, left over from before, is removed" {
|
||||
stub_orphan 6000 ""
|
||||
launch "$PROJ"
|
||||
grep -q -- 'rm -f orphan0' "$LAUNCH_DIR/docker-log" \
|
||||
|| { echo "the orphaned filter container was left running"; return 1; }
|
||||
grep -q -- 'network rm dejima-run-old' "$LAUNCH_DIR/docker-log" \
|
||||
|| { echo "its network was left behind"; return 1; }
|
||||
}
|
||||
|
||||
@test "the reconcile says what it removed, so a leak is not silently routine" {
|
||||
stub_orphan 6000 ""
|
||||
run launch "$PROJ"
|
||||
[[ "$output" == *"left behind"* ]] \
|
||||
|| { echo "an orphan was removed with nothing said about it: $output"; return 1; }
|
||||
}
|
||||
|
||||
@test "a filter container with a session still in its namespace is left alone" {
|
||||
# The one that matters on a host running several sessions: reaping this is
|
||||
# taking the network out from under somebody's running agent.
|
||||
stub_orphan 6000 orphan0
|
||||
launch "$PROJ"
|
||||
run grep -c -- 'rm -f orphan0' "$LAUNCH_DIR/docker-log"
|
||||
[ "$output" = "0" ] \
|
||||
|| { echo "a filter container with a live session was removed"; return 1; }
|
||||
}
|
||||
|
||||
@test "a filter container younger than the grace is left alone" {
|
||||
# A pair being built right now by a concurrent launch: between `docker run`
|
||||
# for the filter and `docker compose run` for the session, a healthy filter
|
||||
# container has no joiner. Age is what tells that apart from abandonment.
|
||||
stub_orphan 5 ""
|
||||
launch "$PROJ"
|
||||
run grep -c -- 'rm -f orphan0' "$LAUNCH_DIR/docker-log"
|
||||
[ "$output" = "0" ] \
|
||||
|| { echo "a filter container still being paired was removed"; return 1; }
|
||||
run grep -c -- 'network rm dejima-run-old' "$LAUNCH_DIR/docker-log"
|
||||
[ "$output" = "0" ] \
|
||||
|| { echo "a network still being attached to was removed"; return 1; }
|
||||
}
|
||||
|
||||
@test "a docker that cannot answer the reconcile does not fail the launch" {
|
||||
# It runs before the launch's own network and container, so an error here
|
||||
# would refuse a session over tidying up. The launch that follows reports
|
||||
# its own failures.
|
||||
stub_launch
|
||||
cat > "$LAUNCH_DIR/docker" <<'EOF'
|
||||
#!/bin/bash
|
||||
echo "$*" >> "${SANDBOX_TEST_DOCKER_LOG:-/dev/null}"
|
||||
case "$*" in
|
||||
ps*|*inspect*|*network\ ls*) echo "Cannot connect to the Docker daemon" >&2; exit 1 ;;
|
||||
esac
|
||||
exit 0
|
||||
EOF
|
||||
chmod +x "$LAUNCH_DIR/docker"
|
||||
run launch "$PROJ"
|
||||
[ "$status" -eq 0 ] \
|
||||
|| { echo "a failing reconcile refused the launch: $output"; return 1; }
|
||||
grep -q -- ' run --rm ' "$LAUNCH_DIR/docker-log" \
|
||||
|| { echo "the session never started"; return 1; }
|
||||
}
|
||||
|
||||
@test "a single-label host in a firewall-facing key stops the launch, naming the key" {
|
||||
# The policy refuses a dotless permitted entry at container start. Meeting
|
||||
# that as a sandbox that will not boot, on a value every check on the way in
|
||||
# accepted, is the failure this check exists to move: it happens here, on
|
||||
# the host, before anything starts, and says which key it came from.
|
||||
stub_launch
|
||||
local conf
|
||||
conf="$(bash -c 'source "$1"; config_path' _ "$LIB")"
|
||||
mkdir -p "$(dirname "$conf")"
|
||||
printf '[logging]\nloki_url = http://loki:3100\n' >> "$conf"
|
||||
|
||||
run launch "$PROJ"
|
||||
[ "$status" -ne 0 ] || { echo "the launch proceeded with a dotless host"; return 1; }
|
||||
[[ "$output" == *"loki_url"* ]] \
|
||||
|| { echo "the error does not name the key it came from: $output"; return 1; }
|
||||
[[ "$output" == *"at least two"* && "$output" == *labels* ]] \
|
||||
|| { echo "the error does not say what the rule is: $output"; return 1; }
|
||||
|
||||
# And nothing was started, rather than started and then torn down.
|
||||
[ ! -s "$LAUNCH_DIR/docker-log" ] || {
|
||||
echo "docker was invoked before the check refused:"; cat "$LAUNCH_DIR/docker-log"; return 1; }
|
||||
}
|
||||
|
||||
@test "a derived host that is not a host name is refused, not only a dotless one" {
|
||||
# The check exists for a derivation that went wrong, and a URL slip does not
|
||||
# always leave a bare label: userinfo and a trailing dot both survive into
|
||||
# the derived name, and both have a dot in them. Counting dots would pass
|
||||
# them here and the container would refuse them at boot instead.
|
||||
stub_launch
|
||||
local conf
|
||||
conf="$(bash -c 'source "$1"; config_path' _ "$LIB")"
|
||||
mkdir -p "$(dirname "$conf")"
|
||||
printf '[logging]\nloki_url = http://user@loki.example.com:3100\n' >> "$conf"
|
||||
|
||||
run launch "$PROJ"
|
||||
[ "$status" -ne 0 ] || { echo "a userinfo URL launched"; return 1; }
|
||||
[[ "$output" == *"user@loki.example.com"* ]] \
|
||||
|| { echo "the error does not show the derived host: $output"; return 1; }
|
||||
[ ! -s "$LAUNCH_DIR/docker-log" ] || {
|
||||
echo "docker was invoked before the check refused"; return 1; }
|
||||
}
|
||||
|
||||
@test "a fully qualified host in the same key launches" {
|
||||
# The control for the test above: same key, same shape, one dot more.
|
||||
stub_launch
|
||||
local conf
|
||||
conf="$(bash -c 'source "$1"; config_path' _ "$LIB")"
|
||||
mkdir -p "$(dirname "$conf")"
|
||||
printf '[logging]\nloki_url = http://loki.example.com:3100\n' >> "$conf"
|
||||
|
||||
run launch "$PROJ"
|
||||
[ "$status" -eq 0 ] || { echo "a fully qualified loki_url was refused: $output"; return 1; }
|
||||
}
|
||||
+220
-22
@@ -31,6 +31,11 @@ if [ "\$1" = ps ]; then
|
||||
exit 0
|
||||
fi
|
||||
case "\$*" in
|
||||
# A session container shares a filter container's network namespace, and
|
||||
# that is where the resolver and the packet filter are. dejima-allow derives the
|
||||
# one from the other rather than being told, so a stub that answers this
|
||||
# with nothing is a session with no filter to add a domain to.
|
||||
*inspect*NetworkMode*) echo "container:dejima-test-filter"; exit 0 ;;
|
||||
*dig*) echo 10.0.0.1; exit 0 ;;
|
||||
*curl*) exit $2 ;;
|
||||
*) exit 0 ;;
|
||||
@@ -79,7 +84,7 @@ done
|
||||
exit 0
|
||||
EOF
|
||||
chmod +x "$LAUNCH_DIR/docker"
|
||||
: > "$LAUNCH_DIR/sandbox-key"
|
||||
printf "not-a-real-key\n" > "$LAUNCH_DIR/sandbox-key"
|
||||
chmod 600 "$LAUNCH_DIR/sandbox-key"
|
||||
}
|
||||
|
||||
@@ -121,7 +126,7 @@ dockerfile_for() {
|
||||
|
||||
@test "valid_domain refuses the hostname shapes a resolver would read differently" {
|
||||
# The matcher is a trust boundary: what it accepts becomes a dnsmasq
|
||||
# `server=` line and an ipset entry. The published bypass in this space
|
||||
# `server=` line and an entry in the packet filter's address sets. The published bypass in this space
|
||||
# came from an egress control accepting a string its resolver then read as
|
||||
# a different host, so the shapes below are the ones worth asserting on
|
||||
# rather than only "does it reject evil.com".
|
||||
@@ -148,6 +153,67 @@ github.com'; do
|
||||
done
|
||||
}
|
||||
|
||||
@test "a domain a project may ask to reach needs two labels, though a host name does not" {
|
||||
# A permitted name covers everything under it: the resolver is configured
|
||||
# per name and matches the name and its subdomains, with no exact-name form
|
||||
# to use instead. So a bare label grants its whole suffix, and `org` asks
|
||||
# for every .org name. This floor is what keeps one entry from being that
|
||||
# large - and it is also why the two validators differ, since valid_domain
|
||||
# gates host-side consumers where a single label is ordinary.
|
||||
local one
|
||||
for one in loki gitbox org com localhost a; do
|
||||
run bash -c 'source "$1"; valid_domain "$2"' _ "$LIB" "$one"
|
||||
[ "$status" -eq 0 ] \
|
||||
|| { echo "premise wrong: valid_domain already refuses '$one'"; return 1; }
|
||||
run bash -c 'source "$1"; valid_permitted_domain "$2"' _ "$LIB" "$one"
|
||||
[ "$status" -ne 0 ] \
|
||||
|| { echo "'$one' was accepted as a domain a project may reach"; return 1; }
|
||||
done
|
||||
|
||||
local ok
|
||||
for ok in crates.io static.crates.io llm-backend.example.com a.b; do
|
||||
run bash -c 'source "$1"; valid_permitted_domain "$2"' _ "$LIB" "$ok"
|
||||
[ "$status" -eq 0 ] || { echo "refused a fully qualified name: $ok"; return 1; }
|
||||
done
|
||||
|
||||
# And it still refuses everything valid_domain does, rather than only
|
||||
# counting dots.
|
||||
local bad
|
||||
for bad in 'a..b.com' '-bad.com' 'a_b.com' '' 'github.com.'; do
|
||||
run bash -c 'source "$1"; valid_permitted_domain "$2"' _ "$LIB" "$bad"
|
||||
[ "$status" -ne 0 ] || { echo "accepted: $(printf %q "$bad")"; return 1; }
|
||||
done
|
||||
|
||||
# Granting is where it applies. Reading deliberately does not - see the
|
||||
# test below.
|
||||
run bash -c 'source "$1"; approve_entry "$2" allowed-domains loki' _ "$LIB" "$BATS_TEST_TMPDIR"
|
||||
[ "$status" -ne 0 ] || { echo "a single-label domain was granted"; return 1; }
|
||||
[[ "$output" == *"two labels"* ]] \
|
||||
|| { echo "the refusal does not say why: $output"; return 1; }
|
||||
run bash -c 'source "$1"; approve_entry "$2" allowed-domains ok.example.com' _ "$LIB" "$BATS_TEST_TMPDIR"
|
||||
[ "$status" -eq 0 ] || { echo "a fully qualified domain was refused: $output"; return 1; }
|
||||
}
|
||||
|
||||
@test "an approval granted before the two-label rule is still read, not called corruption" {
|
||||
# approved_entries validates on every read, and bin/dejima assigns from it
|
||||
# under set -e on every launch. Applying a rule that tightened afterwards
|
||||
# there would turn an approval that was valid when it was made into the
|
||||
# store's tampering message - on every launch, in every project, for an
|
||||
# operator whose only mistake was upgrading. The last release accepted a
|
||||
# single-label domain, so that store is the ordinary one.
|
||||
printf 'crates.io\nloki\n' > "$BATS_TEST_TMPDIR/allowed-domains"
|
||||
run bash -c 'source "$1"; approved_entries "$2" allowed-domains' _ "$LIB" "$BATS_TEST_TMPDIR"
|
||||
[ "$status" -eq 0 ] \
|
||||
|| { echo "a pre-existing approval now fails the read: $output"; return 1; }
|
||||
[[ "$output" == *loki* ]] \
|
||||
|| { echo "the entry was dropped rather than read: $output"; return 1; }
|
||||
|
||||
# The shape check that guards against real tampering still fires.
|
||||
printf 'crates.io\na..b.com\n' > "$BATS_TEST_TMPDIR/allowed-domains"
|
||||
run bash -c 'source "$1"; approved_entries "$2" allowed-domains' _ "$LIB" "$BATS_TEST_TMPDIR"
|
||||
[ "$status" -ne 0 ] || { echo "a malformed entry was read as valid"; return 1; }
|
||||
}
|
||||
|
||||
@test "valid_domain accepts real domains and rejects shell-active input" {
|
||||
run bash -c 'source "$1"; valid_domain "crates.io"' _ "$LIB"
|
||||
[ "$status" -eq 0 ]
|
||||
@@ -651,6 +717,66 @@ EOF
|
||||
grep -qx 'crates.io' "$(store_of "$PROJ")/allowed-domains"
|
||||
}
|
||||
|
||||
|
||||
@test "dejima-allow takes the filter container's id, which is what a session reads" {
|
||||
# A session asked for its own container id runs `cat /etc/hostname`, and
|
||||
# Docker names a container that joined another's network namespace after
|
||||
# the container it joined - so the id an agent hands its operator is the
|
||||
# FILTER container's. That id has to work, or the one command every
|
||||
# briefing tells an agent to ask for fails on the id the briefing gave it.
|
||||
STUB_DIR="$(mktemp -d)"
|
||||
cat > "$STUB_DIR/docker" <<'EOF'
|
||||
#!/bin/bash
|
||||
echo "$*" >> "${DOCKER_CALL_LOG:-/dev/null}"
|
||||
case "$*" in
|
||||
# Named container is a filter: it owns a network rather than joining one.
|
||||
*inspect*NetworkMode*c1*) echo "dejima-run-abcd" ;;
|
||||
*inspect*'{{.Id}}'*c1*) echo "filterid" ;;
|
||||
# And one running container is in its namespace.
|
||||
"ps -q") echo sessionid ;;
|
||||
*'{{.Id}} {{.HostConfig.NetworkMode}}'*) echo "sessionid container:filterid" ;;
|
||||
esac
|
||||
exit 0
|
||||
EOF
|
||||
chmod +x "$STUB_DIR/docker"
|
||||
PATH="$STUB_DIR:$PATH" DOCKER_CALL_LOG="$STUB_DIR/calls" \
|
||||
run "$REPO_ROOT/bin/dejima-allow" --container c1 crates.io
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
|
||||
# Asserted on the calls rather than the messages: which container each
|
||||
# half runs in is the whole point, and a message could name either while
|
||||
# the exec went to the wrong one.
|
||||
grep -q 'exec -u root c1 /usr/local/sbin/egress-policy permit crates.io' "$STUB_DIR/calls" \
|
||||
|| { echo "the permitted-set change did not go to the filter container:"; cat "$STUB_DIR/calls"; return 1; }
|
||||
grep -q 'exec sessionid curl' "$STUB_DIR/calls" \
|
||||
|| { echo "the probe did not run in the session container:"; cat "$STUB_DIR/calls"; return 1; }
|
||||
rm -rf "$STUB_DIR"
|
||||
}
|
||||
|
||||
@test "dejima-allow refuses a container that is neither half of a pair" {
|
||||
# A launch predating the split has a namespace of its own and no filter
|
||||
# container in it. The useful answer is what to do instead, not a docker
|
||||
# exec that fails somewhere inside a heredoc.
|
||||
STUB_DIR="$(mktemp -d)"
|
||||
cat > "$STUB_DIR/docker" <<'EOF'
|
||||
#!/bin/bash
|
||||
case "$*" in
|
||||
*inspect*NetworkMode*) echo "bridge"; exit 0 ;;
|
||||
*inspect*'{{.Id}}'*) echo "loneid"; exit 0 ;;
|
||||
"ps -q") exit 0 ;;
|
||||
esac
|
||||
exit 0
|
||||
EOF
|
||||
chmod +x "$STUB_DIR/docker"
|
||||
PATH="$STUB_DIR:$PATH" run "$REPO_ROOT/bin/dejima-allow" --container c1 crates.io
|
||||
[ "$status" -ne 0 ]
|
||||
[[ "$output" == *"neither a running session nor the filter container"* ]] \
|
||||
|| { echo "$output"; return 1; }
|
||||
[[ "$output" == *"dejima-allow --project crates.io"* ]] \
|
||||
|| { echo "the refusal does not say what to do instead: $output"; return 1; }
|
||||
rm -rf "$STUB_DIR"
|
||||
}
|
||||
|
||||
@test "dejima-allow --global persists without needing a container" {
|
||||
run "$REPO_ROOT/bin/dejima-allow" --global example.com
|
||||
[ "$status" -eq 0 ]
|
||||
@@ -695,54 +821,126 @@ EOF
|
||||
[[ "$output" == *"still NOT reachable"* ]]
|
||||
grep -qx 'crates.io' "$CONFIG_DIR/allowed-domains"
|
||||
}
|
||||
|
||||
# A fake `docker` whose in-container dig answers only when asked upstream
|
||||
# directly (@127.0.0.11), which is what an internally-routed name looks like
|
||||
# from here: dnsmasq drops the private answer, the upstream still has it.
|
||||
# A fake `docker` whose filter container refuses to permit the name, the way
|
||||
# egress-policy does when the answer points inside a private network and
|
||||
# nothing exempted the entry. $2 of the exec is the entry; --internal-ok on the
|
||||
# command line is what makes it succeed, which is exactly the distinction the
|
||||
# two tests below are about.
|
||||
stub_docker_rebind() {
|
||||
STUB_DIR="$(mktemp -d)"
|
||||
cat > "$STUB_DIR/docker" <<'EOF'
|
||||
#!/bin/bash
|
||||
echo "$*" >> "${DOCKER_CALL_LOG:-/dev/null}"
|
||||
case "$*" in
|
||||
*dig*@127.0.0.11*) echo 10.0.0.1 ;;
|
||||
*inspect*NetworkMode*) echo "container:dejima-test-filter"; exit 0 ;;
|
||||
*egress-policy\ permit*--internal-ok*)
|
||||
echo "[egress-policy] internal.example.com permitted (open class, internal-ok): 10.0.0.1"
|
||||
exit 0 ;;
|
||||
*egress-policy\ permit*)
|
||||
echo "[egress-policy] FAILED: internal.example.com did not resolve after being permitted (an answer pointing inside a private network is refused unless --internal-ok was given); nothing was changed" >&2
|
||||
exit 1 ;;
|
||||
esac
|
||||
exit 0
|
||||
EOF
|
||||
chmod +x "$STUB_DIR/docker"
|
||||
}
|
||||
|
||||
@test "dejima-allow names the relaunch for an internally-routed domain" {
|
||||
# "Failed to resolve" sent people looking at their DNS server, which is
|
||||
# working - the container's rebind protection is what dropped the answer,
|
||||
# and no live patch can lift it.
|
||||
|
||||
@test "dejima-allow names the approval that would let an internal name through" {
|
||||
# A name that resolves inside the operator's own network is refused unless
|
||||
# the entry was exempted, and a session-only grant deliberately is not:
|
||||
# exempting an unreviewed name is how one influenced answer becomes egress
|
||||
# to an internal host. So the useful answer is the approval that carries
|
||||
# the exemption, not "failed to resolve" - which sent people looking at
|
||||
# their DNS server, which was working.
|
||||
stub_docker_rebind
|
||||
run env PATH="$STUB_DIR:$PATH" \
|
||||
"$REPO_ROOT/bin/dejima-allow" --container c1 internal.example.com
|
||||
[ "$status" -eq 1 ]
|
||||
[[ "$output" == *"private address (10.0.0.1)"* ]]
|
||||
[[ "$output" == *"resolves inside your own network"* ]]
|
||||
[[ "$output" == *"dejima-allow --global internal.example.com"* ]]
|
||||
# And the container's own reason survives, because it names the mechanism.
|
||||
[[ "$output" == *"internal-ok"* ]]
|
||||
}
|
||||
|
||||
@test "dejima-allow points an internally-routed domain at the approval it just recorded" {
|
||||
|
||||
|
||||
@test "a name declared internal is added to a running session, without a relaunch" {
|
||||
# This used to be impossible: the exemption could only be written at
|
||||
# startup, so an internally-routed name meant restarting the session. The
|
||||
# permitted set is now regenerated in place, so a name the operator has
|
||||
# declared internal can be added to the session already running.
|
||||
printf '[core]\ninternal_domains = internal.example.com\n' > "$CONFIG_DIR/config"
|
||||
stub_docker_rebind
|
||||
run env PATH="$STUB_DIR:$PATH" \
|
||||
run env PATH="$STUB_DIR:$PATH" DOCKER_CALL_LOG="$STUB_DIR/calls" \
|
||||
"$REPO_ROOT/bin/dejima-allow" --global --container c1 internal.example.com
|
||||
[ "$status" -eq 1 ]
|
||||
[[ "$output" == *"approval recorded above already does this"* ]]
|
||||
[[ "$output" == *"Relaunch the session"* ]]
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
grep -qx 'internal.example.com' "$CONFIG_DIR/allowed-domains"
|
||||
grep -q 'egress-policy permit internal.example.com.*--internal-ok' "$STUB_DIR/calls" \
|
||||
|| { echo "the declaration did not carry the exemption:"; cat "$STUB_DIR/calls"; return 1; }
|
||||
[[ "$output" != *"Relaunch"* ]] \
|
||||
|| { echo "still telling the operator to relaunch:"; echo "$output"; return 1; }
|
||||
}
|
||||
|
||||
@test "dejima-allow still reports a domain that resolves nowhere as unresolvable" {
|
||||
# The rebind branch above must not swallow the ordinary failure: a typo'd
|
||||
# or dead name gets no answer from the upstream either.
|
||||
@test "an approval alone does not exempt a name from the answer check" {
|
||||
# The rule a launch applies, applied here too. An approval says a
|
||||
# destination may be reached; it does not say an answer for it may point
|
||||
# inside the operator's network, and a project's declared domains are
|
||||
# reviewed as public names. Exempting one on the strength of an approval is
|
||||
# what the two halves used to disagree about - this side handed out the
|
||||
# exemption, a launch withheld it, and the operator found out when the next
|
||||
# launch refused to start on a name that had just worked.
|
||||
stub_docker_rebind
|
||||
run env PATH="$STUB_DIR:$PATH" DOCKER_CALL_LOG="$STUB_DIR/calls" \
|
||||
"$REPO_ROOT/bin/dejima-allow" --global --container c1 internal.example.com
|
||||
[ "$status" -ne 0 ] || { echo "an approval exempted a name by itself"; return 1; }
|
||||
! grep -q 'internal-ok' "$STUB_DIR/calls" \
|
||||
|| { echo "the exemption was sent without a declaration:"; cat "$STUB_DIR/calls"; return 1; }
|
||||
# The approval is still recorded - it is a decision about reachability and
|
||||
# this command does not get to discard it over a separate question.
|
||||
grep -qx 'internal.example.com' "$CONFIG_DIR/allowed-domains"
|
||||
# And the refusal names the key that fixes it, not the variable the
|
||||
# container reads.
|
||||
[[ "$output" == *"internal_domains = internal.example.com"* ]] \
|
||||
|| { echo "the refusal does not name the remedy:"; echo "$output"; return 1; }
|
||||
}
|
||||
|
||||
@test "a session-only grant is never exempted from the answer check" {
|
||||
# The other half of the test above, and the one that matters: without an
|
||||
# approval there is no exemption, because exempting a name nobody reviewed
|
||||
# is what turns one attacker-influenced answer into egress to an internal
|
||||
# host.
|
||||
stub_docker_rebind
|
||||
run env PATH="$STUB_DIR:$PATH" DOCKER_CALL_LOG="$STUB_DIR/calls" \
|
||||
"$REPO_ROOT/bin/dejima-allow" --container c1 crates.io
|
||||
grep -q 'egress-policy permit crates.io' "$STUB_DIR/calls" \
|
||||
|| { echo "no permit call at all:"; cat "$STUB_DIR/calls"; return 1; }
|
||||
! grep -q 'internal-ok' "$STUB_DIR/calls" \
|
||||
|| { echo "a session-only grant was exempted:"; cat "$STUB_DIR/calls"; return 1; }
|
||||
}
|
||||
|
||||
|
||||
@test "dejima-allow reports a domain that resolves nowhere, and says nothing changed" {
|
||||
# The refusal branch above must not swallow the ordinary failure: a typo'd
|
||||
# or dead name gets no answer either, and the operator needs to know the
|
||||
# permitted set was left alone rather than half-changed.
|
||||
STUB_DIR="$(mktemp -d)"
|
||||
printf '#!/bin/bash\nexit 0\n' > "$STUB_DIR/docker"
|
||||
cat > "$STUB_DIR/docker" <<'EOF'
|
||||
#!/bin/bash
|
||||
case "$*" in
|
||||
*inspect*NetworkMode*) echo "container:dejima-test-filter"; exit 0 ;;
|
||||
*egress-policy\ permit*)
|
||||
echo "[egress-policy] FAILED: nowhere.example.com did not resolve after being permitted; nothing was changed" >&2
|
||||
exit 1 ;;
|
||||
esac
|
||||
exit 0
|
||||
EOF
|
||||
chmod +x "$STUB_DIR/docker"
|
||||
run env PATH="$STUB_DIR:$PATH" \
|
||||
"$REPO_ROOT/bin/dejima-allow" --container c1 nowhere.example.com
|
||||
[ "$status" -eq 1 ]
|
||||
[[ "$output" == *"Failed to resolve nowhere.example.com"* ]]
|
||||
[[ "$output" == *"nowhere.example.com did not resolve"* ]]
|
||||
[[ "$output" == *"left as it was"* ]]
|
||||
}
|
||||
|
||||
@test "dejima-project approve persists every domain even when a docker call would have failed" {
|
||||
|
||||
+56
-1
@@ -215,6 +215,33 @@ assert_remedy_forges_exist() {
|
||||
[ "$status" -ne 0 ]
|
||||
}
|
||||
|
||||
@test "the briefing tells an agent how a refusal differs from a hang" {
|
||||
# The policy rejects rather than drops, for the reason its own chain
|
||||
# comment gives: silently dropping makes every blocked destination look
|
||||
# like a hang. The briefing described both as failing to connect, which
|
||||
# throws that signal away - and two agents in one day reported a host
|
||||
# firewall on the far side as dejima blocking a domain, because from
|
||||
# inside a sandbox the two are indistinguishable except by timing.
|
||||
local lib="$REPO_ROOT/bin/dejima-provider-lib.sh"
|
||||
grep -q 'refused at once rather than' "$lib" \
|
||||
|| { echo "the briefing no longer says a refusal is immediate"; return 1; }
|
||||
grep -q 'HANGS is not this allowlist' "$lib" \
|
||||
|| { echo "the briefing no longer tells an agent what a hang means"; return 1; }
|
||||
|
||||
# And the property it describes is still true of the policy, so the advice
|
||||
# cannot outlive the behaviour it rests on.
|
||||
local chain
|
||||
chain=$(sed -n '/^ chain refuse {/,/^ }/p' "$REPO_ROOT/docker/egress-policy")
|
||||
[ -n "$chain" ] || { echo "no refuse chain in the policy"; return 1; }
|
||||
grep -q 'reject with tcp reset' <<< "$chain" \
|
||||
|| { echo "the policy no longer rejects TCP, so a block would hang"; return 1; }
|
||||
grep -q 'reject with icmpx' <<< "$chain" \
|
||||
|| { echo "the policy no longer rejects non-TCP"; return 1; }
|
||||
grep -qE '(^|[^a-z])drop([^a-z]|$)' <<< "$chain" \
|
||||
&& { echo "the refuse chain drops as well as rejects: $chain"; return 1; }
|
||||
true
|
||||
}
|
||||
|
||||
@test "gitea's CLIs moved out of bin/ and into its forge" {
|
||||
[ ! -e "$REPO_ROOT/bin/gitea-pr" ]
|
||||
[ ! -e "$REPO_ROOT/bin/gitea-issue" ]
|
||||
@@ -222,6 +249,34 @@ assert_remedy_forges_exist() {
|
||||
[ -x "$REPO_ROOT/forges/gitea/bin/gitea-issue" ]
|
||||
}
|
||||
|
||||
@test "a sandbox missing its forge token is not told to create one in the container" {
|
||||
# The token reaches a sandbox as GITEA_TOKEN, read host-side from whatever
|
||||
# the forge's token_file names. The file these tools fall back to is
|
||||
# deliberately absent in the container, so a message naming it as the remedy
|
||||
# sends an agent to write a credential into a disposable filesystem - or, if
|
||||
# it cannot, to report that it is unable to work when nothing is wrong with
|
||||
# the arrangement. That happened.
|
||||
# One subcommand per tool that actually reaches the token check.
|
||||
local spec t sub
|
||||
for spec in gitea-pr:create gitea-issue:create gitea-actions:runs; do
|
||||
t=${spec%%:*}; sub=${spec#*:}
|
||||
run env -u GITEA_TOKEN DEJIMA=1 GITEA_SSH_HOST=x GITEA_API=http://x/api/v1 \
|
||||
HOME="$BATS_TEST_TMPDIR/nohome" bash "$REPO_ROOT/forges/gitea/bin/$t" "$sub"
|
||||
[ "$status" -ne 0 ] || { echo "$t proceeded with no token"; return 1; }
|
||||
[[ "$output" == *"arrives as GITEA_TOKEN"* ]] \
|
||||
|| { echo "$t does not say where the token comes from: $output"; return 1; }
|
||||
[[ "$output" != *"save it to"* ]] \
|
||||
|| { echo "$t still tells a sandbox to save a token file: $output"; return 1; }
|
||||
done
|
||||
|
||||
# And the host-side message is unchanged, where writing the file IS the
|
||||
# remedy and nothing exports the variable.
|
||||
run env -u GITEA_TOKEN -u DEJIMA GITEA_SSH_HOST=x GITEA_API=http://x/api/v1 \
|
||||
HOME="$BATS_TEST_TMPDIR/nohome" bash "$REPO_ROOT/forges/gitea/bin/gitea-pr" create
|
||||
[[ "$output" == *"save it to"* ]] \
|
||||
|| { echo "the host-side remedy is gone: $output"; return 1; }
|
||||
}
|
||||
|
||||
@test "a workflow requiring a capability its forge lacks is refused" {
|
||||
FAKE_ROOT="$(mktemp -d)"
|
||||
mkdir -p "$FAKE_ROOT/forges/bare" "$FAKE_ROOT/workflows/needy" "$FAKE_ROOT/harnesses/h"
|
||||
@@ -1115,7 +1170,7 @@ assert_remedy_forges_exist() {
|
||||
# ("the github forge's hosts never reach the extra-domains allowlist").
|
||||
# Those three hosts are public SaaS names already covered by core's
|
||||
# GitHub ranges: sending them down the extra-domains path would put them
|
||||
# in the port-unrestricted allowed-domains ipset AND exempt them from
|
||||
# in the port-unrestricted open class AND exempt them from
|
||||
# dnsmasq's stop-dns-rebind, and the firewall's startup self-check
|
||||
# ("github.com:22 was reachable without ALLOW_GITHUB_PUSH=1") would then
|
||||
# refuse to bring the container up at all.
|
||||
|
||||
+221
-11
@@ -292,17 +292,18 @@ teardown() {
|
||||
|
||||
@test "forwarder bridges TCP to the proxy's unix socket" {
|
||||
start_proxy
|
||||
SANDBOX_CREDENTIAL_PROXY_SOCKET="$SOCK" SANDBOX_CREDENTIAL_PROXY_PORT=18399 \
|
||||
local fport; fport="$(free_port)"
|
||||
SANDBOX_CREDENTIAL_PROXY_SOCKET="$SOCK" SANDBOX_CREDENTIAL_PROXY_PORT="$fport" \
|
||||
SANDBOX_CREDENTIAL_TRANSPORT=loopback \
|
||||
node "$REPO_ROOT/docker/credential-proxy-forwarder.js" &
|
||||
FWD_PID=$!
|
||||
ok=""
|
||||
for _ in $(seq 1 50); do
|
||||
if curl -s -o /dev/null http://127.0.0.1:18399/echo; then ok=1; break; fi
|
||||
if curl -s -o /dev/null http://127.0.0.1:$fport/echo; then ok=1; break; fi
|
||||
sleep 0.1
|
||||
done
|
||||
[ -n "$ok" ]
|
||||
run curl -s -H "Authorization: Bearer $PLACEHOLDER" -d '{}' http://127.0.0.1:18399/v1/messages
|
||||
run curl -s -H "Authorization: Bearer $PLACEHOLDER" -d '{}' http://127.0.0.1:$fport/v1/messages
|
||||
kill "$FWD_PID" 2>/dev/null || true
|
||||
[ "$(jq -r '.headers.authorization' <<<"$output")" = "Bearer real-access-token" ]
|
||||
}
|
||||
@@ -353,6 +354,20 @@ PYEOF
|
||||
[[ "$output" != *"501"* ]]
|
||||
}
|
||||
|
||||
# The forwarder is given a port rather than asking for one, so these tests
|
||||
# have to name a number. Asking the kernel for a free one and handing that over
|
||||
# leaves a window where something else could take it; naming a constant leaves
|
||||
# the test failing on any machine already using it, which is the precondition
|
||||
# these tests were reading from the environment instead of stating. The window
|
||||
# is the smaller of the two, and the suites run on a developer's machine.
|
||||
free_port() {
|
||||
python3 -c 'import socket
|
||||
s = socket.socket()
|
||||
s.bind(("127.0.0.1", 0))
|
||||
print(s.getsockname()[1])
|
||||
s.close()'
|
||||
}
|
||||
|
||||
# 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
|
||||
@@ -382,13 +397,14 @@ make_cert() {
|
||||
@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
|
||||
local fport; fport="$(free_port)"
|
||||
start_forwarder tls "$fport"
|
||||
# --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 '{}'
|
||||
--resolve "api.anthropic.com:$fport:127.0.0.1" \
|
||||
-X POST "https://api.anthropic.com:$fport/v1/messages" -d '{}'
|
||||
kill "$FWD_PID" 2>/dev/null || true
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *'"authorization": "Bearer real-access-token"'* ]]
|
||||
@@ -403,8 +419,9 @@ make_cert() {
|
||||
# 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 \
|
||||
local fport; fport="$(free_port)"
|
||||
start_forwarder tls "$fport"
|
||||
run openssl s_client -connect "127.0.0.1:$fport" -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"* ]]
|
||||
@@ -412,8 +429,9 @@ make_cert() {
|
||||
|
||||
@test "forwarder loopback mode serves plaintext http on the port it is given" {
|
||||
start_proxy
|
||||
start_forwarder loopback 18445
|
||||
run curl -s -X POST "http://127.0.0.1:18445/v1/messages" -d '{}'
|
||||
local fport; fport="$(free_port)"
|
||||
start_forwarder loopback "$fport"
|
||||
run curl -s -X POST "http://127.0.0.1:$fport/v1/messages" -d '{}'
|
||||
kill "$FWD_PID" 2>/dev/null || true
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *'"authorization": "Bearer real-access-token"'* ]]
|
||||
@@ -437,7 +455,7 @@ make_cert() {
|
||||
# today, so a fall-through lands on an unused port if that ever changes.
|
||||
run env -u SANDBOX_CREDENTIAL_TLS_CERT -u SANDBOX_CREDENTIAL_TLS_KEY \
|
||||
SANDBOX_CREDENTIAL_PROXY_SOCKET="$SOCK" SANDBOX_CREDENTIAL_TRANSPORT=tls \
|
||||
SANDBOX_CREDENTIAL_PROXY_PORT=18398 \
|
||||
SANDBOX_CREDENTIAL_PROXY_PORT="$(free_port)" \
|
||||
node "$REPO_ROOT/docker/credential-proxy-forwarder.js"
|
||||
[ "$status" -ne 0 ]
|
||||
[[ "$output" == *"SANDBOX_CREDENTIAL_TLS_CERT"* ]]
|
||||
@@ -816,3 +834,195 @@ print(raw.decode().strip())
|
||||
[[ "$output" == *"path=/v1/"* ]] && [[ "$output" == *"red"* ]] \
|
||||
|| { echo "$output"; return 1; }
|
||||
}
|
||||
|
||||
@test "a refresh the provider rejects names the login that fixes it, in the session" {
|
||||
# What an overnight session meets in the morning: the access token has
|
||||
# expired, the refresh token with it, and the provider answers the refresh
|
||||
# with invalid_grant. That is not transient and no amount of retrying or
|
||||
# waiting improves it - the operator has to log in on the host.
|
||||
#
|
||||
# The session is where the failure is met and the host terminal is behind a
|
||||
# window the agent cannot read, so "see host terminal" costs a trip to find
|
||||
# one sentence. This message is written in the proxy rather than taken from
|
||||
# the provider's response, which is what makes it safe to send.
|
||||
write_creds -60
|
||||
start_proxy
|
||||
curl -s "http://127.0.0.1:$PORT/oauth-reject" >/dev/null
|
||||
|
||||
run pcurl -o /dev/null -w '%{http_code}' -X POST --data '{}' http://x/v1/messages
|
||||
[ "$output" = 502 ] || { echo "status was $output"; return 1; }
|
||||
|
||||
run pcurl -X POST --data '{}' http://x/v1/messages
|
||||
[[ "$output" == *"log in again"* ]] \
|
||||
|| { echo "the session was not told how to fix it: $output"; return 1; }
|
||||
[[ "$output" == *"on the host"* ]] \
|
||||
|| { echo "the session was not told where to fix it: $output"; return 1; }
|
||||
|
||||
# The provider's own bytes stay on the host, which is the rule this is
|
||||
# working within rather than around.
|
||||
[[ "$output" != *"invalid_grant"* ]] \
|
||||
|| { echo "an upstream response body reached the session: $output"; return 1; }
|
||||
grep -q 'invalid_grant' "$TD/stderr" \
|
||||
|| { echo "the host log lost the detail:"; cat "$TD/stderr"; return 1; }
|
||||
|
||||
# And the host log carries the remedy as well as the detail. The exit audit
|
||||
# prints the last line of this file when a session ends, so an operator who
|
||||
# was away while it failed reads it there - which is where this was found
|
||||
# wanting: it showed the provider's error code and nothing to do about it.
|
||||
# The LAST line specifically. bin/dejima's exit audit prints that one when a
|
||||
# session ends, so it is what an operator who was away while it failed reads
|
||||
# without going looking - and it was the provider's error code with no
|
||||
# remedy in it, which is what sent one looking in the first place.
|
||||
run tail -n 1 "$TD/stderr"
|
||||
[[ "$output" == *"log in again"* ]] \
|
||||
|| { echo "the last host line is not the actionable one: $output"; return 1; }
|
||||
[[ "$output" != *"HTTP 400"* ]] \
|
||||
|| { echo "the last host line still leads with the provider's answer: $output"; return 1; }
|
||||
}
|
||||
|
||||
# The exit audit in bin/dejima reads this log. Extracted and driven here rather
|
||||
# than restated, and fed logs this proxy actually wrote, because the two live in
|
||||
# different files and the classification only works if they agree about what a
|
||||
# line looks like.
|
||||
run_exit_audit() { # <proxy log>
|
||||
C_WARN='' C_RESET='' SANDBOX_CREDENTIAL_PROXY_LOG="$1" bash -c '
|
||||
C_WARN=""; C_RESET=""
|
||||
SANDBOX_CREDENTIAL_PROXY_LOG="$1"
|
||||
'"$(sed -n '/_audit_counts="\$(awk/,/unset _audit_counts _audit_last/p' \
|
||||
"$REPO_ROOT/bin/dejima")"'
|
||||
' _ "$1" 2>&1
|
||||
}
|
||||
|
||||
@test "the exit audit does not report a credential failure as the API answering" {
|
||||
# An overnight expiry logs no upstream status, because no request was made -
|
||||
# the credential could not be loaded. Announcing "the model API answered
|
||||
# with an error" there sends an operator to check the provider, and the
|
||||
# retry caveat invites them to hope it succeeded anyway. Neither is true,
|
||||
# and the line the proxy leaves last is an instruction rather than a status.
|
||||
write_creds -60
|
||||
start_proxy
|
||||
curl -s "http://127.0.0.1:$PORT/oauth-reject" >/dev/null
|
||||
pcurl -o /dev/null -X POST --data '{}' http://x/v1/messages || true
|
||||
|
||||
run run_exit_audit "$TD/stderr"
|
||||
[[ "$output" != *"model API answered with an error"* ]] \
|
||||
|| { echo "reported as the API answering: $output"; return 1; }
|
||||
[[ "$output" != *"may still have succeeded"* ]] \
|
||||
|| { echo "invited a retry that cannot succeed: $output"; return 1; }
|
||||
[[ "$output" == *"could not authenticate"* ]] \
|
||||
|| { echo "did not say what happened: $output"; return 1; }
|
||||
[[ "$output" == *"log in again"* ]] \
|
||||
|| { echo "did not carry the remedy: $output"; return 1; }
|
||||
}
|
||||
|
||||
@test "an upstream that answered with errors is still reported as one" {
|
||||
# The control: the audit's original purpose has to survive the split.
|
||||
start_proxy
|
||||
pcurl -o /dev/null "http://localhost/status-429"
|
||||
pcurl -o /dev/null "http://localhost/status-429"
|
||||
|
||||
run run_exit_audit "$TD/stderr"
|
||||
[[ "$output" == *"model API answered with an error"* ]] \
|
||||
|| { echo "an upstream error stopped being reported as one: $output"; return 1; }
|
||||
[[ "$output" == *"2 x HTTP 429"* ]] \
|
||||
|| { echo "the grouping by status is gone: $output"; return 1; }
|
||||
[[ "$output" == *"may still have succeeded"* ]] \
|
||||
|| { echo "the retry caveat is gone from the case it is about: $output"; return 1; }
|
||||
}
|
||||
|
||||
@test "a request that never reached the upstream is reported as neither case" {
|
||||
# `upstream request failed` carries no status - nothing answered - and no
|
||||
# credential failed either. Classified by "no status lines anywhere", it
|
||||
# read as an authentication failure, sending an operator to log in again
|
||||
# over a connection problem.
|
||||
start_proxy --upstream "http://127.0.0.1:1"
|
||||
pcurl -o /dev/null -X POST --data '{}' http://x/v1/messages || true
|
||||
|
||||
run run_exit_audit "$TD/stderr"
|
||||
[[ "$output" != *"could not authenticate"* ]] \
|
||||
|| { echo "a connection failure read as an authentication failure: $output"; return 1; }
|
||||
[[ "$output" != *"model API answered with an error"* ]] \
|
||||
|| { echo "reported as the API answering: $output"; return 1; }
|
||||
[[ "$output" == *"credential proxy reported a problem"* ]] \
|
||||
|| { echo "the failure went unreported: $output"; return 1; }
|
||||
[[ "$output" == *"upstream request failed"* ]] \
|
||||
|| { echo "the line itself is missing: $output"; return 1; }
|
||||
}
|
||||
|
||||
@test "upstream text saying 'answered 503' cannot fabricate a status count" {
|
||||
# The refresh failure's detail line carries up to 200 bytes of the token
|
||||
# endpoint's response body - text the upstream chose. Counted wherever
|
||||
# `answered N` appeared, that body minted "1 x HTTP 503" for a session
|
||||
# that made no request, and brought the retry caveat with it. The count
|
||||
# is anchored to the proxy's own `upstream answered N for` lines.
|
||||
write_creds -60
|
||||
start_proxy
|
||||
curl -s "http://127.0.0.1:$PORT/oauth-reject?say=the+upstream+answered+503+today" >/dev/null
|
||||
pcurl -o /dev/null -X POST --data '{}' http://x/v1/messages || true
|
||||
|
||||
run run_exit_audit "$TD/stderr"
|
||||
[[ "$output" != *"model API answered with an error"* ]] \
|
||||
|| { echo "an upstream string was counted as an API answer: $output"; return 1; }
|
||||
[[ "$output" != *"HTTP 503"* ]] \
|
||||
|| { echo "a status count was minted from body text: $output"; return 1; }
|
||||
[[ "$output" == *"could not authenticate"* ]] \
|
||||
|| { echo "the real failure went unreported: $output"; return 1; }
|
||||
[[ "$output" == *"answered 503 today"* ]] \
|
||||
|| { echo "the detail line is not shown: $output"; return 1; }
|
||||
}
|
||||
|
||||
@test "a negative Content-Length is refused rather than read to EOF" {
|
||||
# The cap exists because this runs on the host as the operator's own user.
|
||||
# int() accepts a negative, a negative passes a `> MAX` test, and a buffered
|
||||
# reader takes read(-1) as "to EOF" - so `Content-Length: -1` read an
|
||||
# unbounded body into host RAM past the cap. A non-numeric value raised out
|
||||
# past the types handle_error knows and put a traceback on the terminal.
|
||||
start_proxy
|
||||
|
||||
run curl -s -o /dev/null -w '%{http_code}' --unix-socket "$SOCK" \
|
||||
-H "Authorization: Bearer $PLACEHOLDER" -H 'Content-Length: -1' \
|
||||
-X POST --data 'x' http://x/v1/messages
|
||||
[ "$output" = 413 ] \
|
||||
|| { echo "a negative Content-Length was not refused: $output"; return 1; }
|
||||
|
||||
# A value that is not a number is left alone: it still raises, because the
|
||||
# test above for narrow error suppression uses exactly that fault to prove
|
||||
# the suppression is specific to a client going away. This fix is about the
|
||||
# value that passes the cap and then reads to EOF, not about input tidiness.
|
||||
|
||||
# The log says what arrived, which is what tells a hostile negative length
|
||||
# apart from a genuinely oversized upload.
|
||||
grep -q 'Content-Length of -1' "$TD/stderr" \
|
||||
|| { echo "the refusal log does not name the offending length:"; cat "$TD/stderr"; return 1; }
|
||||
|
||||
# And an ordinary request still works, so the check did not close the door.
|
||||
run pcurl -o /dev/null -w '%{http_code}' -X POST --data '{}' http://x/v1/messages
|
||||
[ "$output" = 200 ] || { echo "a normal request was refused: $output"; return 1; }
|
||||
}
|
||||
|
||||
@test "a negative chunk size is refused too, not only Content-Length" {
|
||||
# The two paths share one helper today. A refactor that inlines the chunked
|
||||
# branch - int(readline().split(b";")[0], 16) - puts the unbounded read back
|
||||
# with nothing failing, so the chunked shape is asserted in its own right.
|
||||
start_proxy
|
||||
python3 - "$SOCK" <<'PYEOF'
|
||||
import socket, sys
|
||||
s = socket.socket(socket.AF_UNIX)
|
||||
s.connect(sys.argv[1])
|
||||
s.sendall(b"POST /v1/messages HTTP/1.1\r\nHost: proxy\r\n"
|
||||
b"Authorization: Bearer placeholder\r\n"
|
||||
b"Transfer-Encoding: chunked\r\n\r\n-1\r\n")
|
||||
s.settimeout(5)
|
||||
try:
|
||||
got = s.recv(200)
|
||||
except Exception:
|
||||
got = b"<no answer>"
|
||||
print(got.split(b"\r\n")[0].decode("latin-1"))
|
||||
s.close()
|
||||
PYEOF
|
||||
run cat "$TD/stderr"
|
||||
[[ "$output" == *"chunk size of -1"* ]] \
|
||||
|| { echo "a negative chunk size was not refused as one: $output"; return 1; }
|
||||
[[ "$output" != *"Traceback"* ]] \
|
||||
|| { echo "it raised instead of being refused: $output"; return 1; }
|
||||
}
|
||||
|
||||
+25
-7
@@ -143,6 +143,22 @@ touches '^tools/' &&
|
||||
touches '^\.dejima/allowed-domains$' &&
|
||||
manual "A domain here is read from main by the CI hardener and widens every
|
||||
future CI job on that runner. Say in the PR what it is for."
|
||||
# The boundary itself had no pattern at all, so a release that rewrote the
|
||||
# egress policy, the capability floor or the credential path was reported as
|
||||
# touching nothing that needs a manual check - over exactly the things this
|
||||
# project exists to hold.
|
||||
touches '^(docker/egress-policy|docker/firewall-up|docker/fixed-domains|docker/dns-lookup-log|docker/cidr-collapse|bin/dejima-allow)' &&
|
||||
manual "Reach an allowed destination and a refused one from inside a real
|
||||
session, and check a refusal is immediate rather than a hang. The suites
|
||||
drive the policy; only a session drives it with a real resolver."
|
||||
touches '^(docker/caps-lib|docker/docker-compose)' &&
|
||||
manual "Check the capability set of a running session against the floor the
|
||||
compose file declares. A container that starts says nothing about what
|
||||
it kept."
|
||||
touches '^(bin/dejima-credential-proxy|docker/credential-proxy-forwarder|backends/|bin/dejima-credential-scan)' &&
|
||||
manual "Run a session against a real model through the proxy, and let a
|
||||
credential expire or be refused. The failure paths are what the suites
|
||||
reach least."
|
||||
[ "$printed" = 1 ] || echo " (none - this release touches nothing with a manual check)"
|
||||
|
||||
note "Required before this merges"
|
||||
@@ -158,9 +174,11 @@ echo " launch on the other configured harness to confirm it starts."
|
||||
# turns the obvious rollback into a second outage, so it is worth knowing
|
||||
# before rather than after.
|
||||
note "Config keys this release adds (a rollback refuses a config using them)"
|
||||
# Both sides through git, because this reports on $base...$head and the working
|
||||
# tree is neither of them once a head is named explicitly.
|
||||
added="$(comm -13 \
|
||||
<(git show "$base:bin/dejima-config-lib.sh" 2>/dev/null | grep -oE "^[a-z-]+\\.[a-z_]+" | sort -u) \
|
||||
<(grep -oE "^[a-z-]+\\.[a-z_]+" bin/dejima-config-lib.sh | sort -u))"
|
||||
<(git show "$head:bin/dejima-config-lib.sh" 2>/dev/null | grep -oE "^[a-z-]+\\.[a-z_]+" | sort -u))"
|
||||
# Providers too, and this is the sharper half: a [harness.<name>] section for
|
||||
# a harness the older dejima does not ship is an unknown SECTION, which fails
|
||||
# validation exactly as an unknown key does. So adopting a new provider is
|
||||
@@ -193,12 +211,12 @@ new_providers=""
|
||||
for pair in $DEJIMA_RELEASE_AXES; do
|
||||
axis="${pair%%:*}"
|
||||
one="${pair#*:}"
|
||||
[ -d "$axis" ] || continue
|
||||
git cat-file -e "$head:$axis" 2>/dev/null || continue
|
||||
while IFS= read -r name; do
|
||||
[ -n "$name" ] || continue
|
||||
git cat-file -e "$base:$axis/$name/$one.conf" 2>/dev/null ||
|
||||
new_providers="$new_providers $one.$name"
|
||||
done < <(find "$axis" -mindepth 1 -maxdepth 1 -type d -printf '%f\n' | sort)
|
||||
done < <(git ls-tree -d --name-only "$head:$axis" 2>/dev/null | sort)
|
||||
done
|
||||
|
||||
# And the keys a provider DECLARES, which are config keys an operator writes
|
||||
@@ -210,18 +228,18 @@ new_keys=""
|
||||
for pair in $DEJIMA_RELEASE_AXES; do
|
||||
axis="${pair%%:*}"
|
||||
one="${pair#*:}"
|
||||
[ -d "$axis" ] || continue
|
||||
git cat-file -e "$head:$axis" 2>/dev/null || continue
|
||||
while IFS= read -r name; do
|
||||
[ -n "$name" ] || continue
|
||||
now="$(sed -n 's/^keys *= *//p' "$axis/$name/$one.conf" 2>/dev/null \
|
||||
| tr ' ' '\n' | sed 's/:.*//' | sort -u)"
|
||||
now="$(git show "$head:$axis/$name/$one.conf" 2>/dev/null \
|
||||
| sed -n 's/^keys *= *//p' | tr ' ' '\n' | sed 's/:.*//' | sort -u)"
|
||||
was="$(git show "$base:$axis/$name/$one.conf" 2>/dev/null \
|
||||
| sed -n 's/^keys *= *//p' | tr ' ' '\n' | sed 's/:.*//' | sort -u)"
|
||||
while IFS= read -r k; do
|
||||
[ -n "$k" ] || continue
|
||||
new_keys="$new_keys [$one.$name]|$k"
|
||||
done < <(comm -13 <(printf '%s\n' "$was") <(printf '%s\n' "$now"))
|
||||
done < <(find "$axis" -mindepth 1 -maxdepth 1 -type d -printf '%f\n' | sort)
|
||||
done < <(git ls-tree -d --name-only "$head:$axis" 2>/dev/null | sort)
|
||||
done
|
||||
|
||||
if [ -n "$added" ] || [ -n "$new_providers" ] || [ -n "$new_keys" ]; then
|
||||
|
||||
+49
-9
@@ -163,7 +163,7 @@ done
|
||||
exit 0
|
||||
EOF
|
||||
chmod +x "$LAUNCH_DIR/docker"
|
||||
: > "$LAUNCH_DIR/sandbox-key"
|
||||
printf "not-a-real-key\n" > "$LAUNCH_DIR/sandbox-key"
|
||||
chmod 600 "$LAUNCH_DIR/sandbox-key"
|
||||
PROJ="$(mktemp -d)"
|
||||
}
|
||||
@@ -444,11 +444,21 @@ EOF
|
||||
write_remote_config
|
||||
# The EXIT trap removes the entry, so snapshot it from inside the run:
|
||||
# the stub stands in for the container, and runs while it still exists.
|
||||
#
|
||||
# Matched on the session's own compose run rather than on every docker
|
||||
# call. A launch makes several - a build, this launch's filter container,
|
||||
# and two removals from the exit trap - and snapshotting on each meant the
|
||||
# last one won. Those two removals run after the entry is gone, so what
|
||||
# this read was an empty directory.
|
||||
cat > "$LAUNCH_DIR/docker" <<EOF
|
||||
#!/bin/bash
|
||||
echo "\$*" >> "\${SANDBOX_TEST_DOCKER_LOG:-/dev/null}"
|
||||
rm -rf "$LAUNCH_DIR/sessions-snapshot"
|
||||
cp -r "$RUNTIME_DIR/dejima/sessions" "$LAUNCH_DIR/sessions-snapshot" 2>/dev/null || true
|
||||
case "\$*" in
|
||||
*" run --rm "*)
|
||||
rm -rf "$LAUNCH_DIR/sessions-snapshot"
|
||||
cp -r "$RUNTIME_DIR/dejima/sessions" "$LAUNCH_DIR/sessions-snapshot" 2>/dev/null || true
|
||||
;;
|
||||
esac
|
||||
exit 0
|
||||
EOF
|
||||
chmod +x "$LAUNCH_DIR/docker"
|
||||
@@ -1993,15 +2003,16 @@ EOF
|
||||
}
|
||||
ttyd_log() { cat "$STUB_DIR/ttyd-argv" 2>/dev/null; }
|
||||
|
||||
# Reports no listener, whatever the host is actually running. Opt-in, and
|
||||
# deliberately not part of stub_ttyd: the port-in-use refusal has its own test
|
||||
# below, which binds a real socket and needs the real ss to see it.
|
||||
# Reports no listener, whatever the host is actually running. Called beside
|
||||
# stub_ttyd by every ttyd test but one, and deliberately not folded into it:
|
||||
# the port-in-use refusal binds a real socket and needs the real ss to see it.
|
||||
#
|
||||
# For every OTHER ttyd test the port being free is a precondition, not the
|
||||
# subject - and one that was being inherited from the machine rather than
|
||||
# stated. The default-port case asserts against 7681, which is exactly the
|
||||
# port a real dejima-ttyd front door holds, so it failed on any host running
|
||||
# the feature: the machine a developer is most likely to run this suite on.
|
||||
# The rest name 12345, which is free on most machines and on none reliably.
|
||||
# Same shape as #56 - a test reading its environment instead of setting it.
|
||||
stub_ss_no_listeners() {
|
||||
STUB_DIR="${STUB_DIR:-$(mktemp -d)}"
|
||||
@@ -2077,6 +2088,8 @@ unit_exec_start() {
|
||||
@test "dejima-ttyd refuses to start when something else already holds its port" {
|
||||
command -v ss >/dev/null 2>&1 || skip "ss is not available"
|
||||
[ -x "$REPO_ROOT/bin/dejima-ttyd" ]
|
||||
# The one ttyd test with no stub_ss_no_listeners beside stub_ttyd: ss
|
||||
# seeing the socket bound below is the subject here, not a precondition.
|
||||
stub_ttyd
|
||||
|
||||
# The distro's ttyd package ships an enabled service that binds every
|
||||
@@ -2133,6 +2146,7 @@ PY
|
||||
@test "dejima-ttyd refuses to start when remote access is unconfigured, before ttyd is reached" {
|
||||
[ -x "$REPO_ROOT/bin/dejima-ttyd" ]
|
||||
stub_ttyd
|
||||
stub_ss_no_listeners
|
||||
run "$REPO_ROOT/bin/dejima-ttyd"
|
||||
[ "$status" -ne 0 ]
|
||||
[[ "$output" == *"not configured"* ]]
|
||||
@@ -2244,6 +2258,7 @@ PY
|
||||
[ -x "$REPO_ROOT/bin/dejima-ttyd" ]
|
||||
write_remote_config 'ttyd_port = 99999'
|
||||
stub_ttyd
|
||||
stub_ss_no_listeners
|
||||
run "$REPO_ROOT/bin/dejima-ttyd"
|
||||
[ "$status" -ne 0 ]
|
||||
# Named, so this cannot pass because ttyd was missing or the config was.
|
||||
@@ -2260,6 +2275,7 @@ PY
|
||||
# currently happens to agree with it.
|
||||
write_remote_config 'ttyd_port = 12345'
|
||||
stub_ttyd
|
||||
stub_ss_no_listeners
|
||||
run "$REPO_ROOT/bin/dejima-ttyd"
|
||||
[ "$status" -eq 0 ]
|
||||
assert_ttyd_argv 12345
|
||||
@@ -2289,6 +2305,7 @@ PY
|
||||
local bad
|
||||
for bad in 0 7 73 999 abc 1e2 -20; do
|
||||
stub_ttyd
|
||||
stub_ss_no_listeners
|
||||
: > "$STUB_DIR/ttyd-argv"
|
||||
write_remote_config 'ttyd_port = 12345' "ttyd_font_size = $bad"
|
||||
run "$REPO_ROOT/bin/dejima-ttyd"
|
||||
@@ -2309,6 +2326,7 @@ PY
|
||||
# moment either default moves.
|
||||
write_remote_config 'ttyd_port = 12345'
|
||||
stub_ttyd
|
||||
stub_ss_no_listeners
|
||||
run "$REPO_ROOT/bin/dejima-ttyd"
|
||||
[ "$status" -eq 0 ]
|
||||
assert_ttyd_argv 12345
|
||||
@@ -2337,6 +2355,7 @@ PY
|
||||
# a host-wide "off" written here would be a different thing from silence.
|
||||
write_remote_config 'ttyd_port = 12345'
|
||||
stub_ttyd
|
||||
stub_ss_no_listeners
|
||||
run "$REPO_ROOT/bin/dejima-ttyd"
|
||||
[ "$status" -eq 0 ]
|
||||
run grep -c 'buttonColors' "$STUB_DIR/ttyd-argv"
|
||||
@@ -2368,6 +2387,7 @@ PY
|
||||
# would be wrong for whichever half of the clients it did not match.
|
||||
write_remote_config 'ttyd_port = 12345'
|
||||
stub_ttyd
|
||||
stub_ss_no_listeners
|
||||
run "$REPO_ROOT/bin/dejima-ttyd"
|
||||
[ "$status" -eq 0 ]
|
||||
run grep -c 'focusOnTap' "$STUB_DIR/ttyd-argv"
|
||||
@@ -2403,6 +2423,7 @@ PY
|
||||
local bad
|
||||
for bad in b M-b C-ab C- ctrl-a 2 'C-\' ; do
|
||||
stub_ttyd
|
||||
stub_ss_no_listeners
|
||||
: > "$STUB_DIR/ttyd-argv"
|
||||
write_remote_config 'ttyd_port = 12345' "ttyd_tmux_prefix = $bad"
|
||||
run "$REPO_ROOT/bin/dejima-ttyd"
|
||||
@@ -2432,6 +2453,7 @@ PY
|
||||
"$REPO_ROOT/bin/dejima-provider-lib.sh" "$fake/bin/"
|
||||
write_remote_config 'ttyd_port = 12345'
|
||||
stub_ttyd
|
||||
stub_ss_no_listeners
|
||||
run "$fake/bin/dejima-ttyd"
|
||||
[ "$status" -ne 0 ]
|
||||
[[ "$output" == *"$fake/web/index.html"* ]]
|
||||
@@ -2449,7 +2471,7 @@ PY
|
||||
local web="$REPO_ROOT/web"
|
||||
[ -f "$web/index.html" ]
|
||||
[ -f "$web/sources.sha256" ]
|
||||
run bash -c "cd '$web' && sha256sum src/* package-lock.json | LC_ALL=C sort -k2"
|
||||
run bash -c "cd '$web' && sha256sum build.mjs src/* package-lock.json | LC_ALL=C sort -k2"
|
||||
[ "$status" -eq 0 ]
|
||||
[ "$output" = "$(cat "$web/sources.sha256")" ] || {
|
||||
echo "web/sources.sha256 is stale. Rebuild it:" >&2
|
||||
@@ -2487,10 +2509,26 @@ PY
|
||||
# restricts outbound traffic to an allowlist, so a CDN reference would not
|
||||
# resolve even from a browser outside it if the operator is on the same
|
||||
# restricted network.
|
||||
#
|
||||
# A `data:` URI is exempt and only that: it carries its bytes in the
|
||||
# attribute, so it fetches nothing and neither reason above applies to it.
|
||||
# The favicon is one. Matched per attribute rather than per line, so a data
|
||||
# URI on a line cannot carry a real reference past this beside it.
|
||||
local page="$REPO_ROOT/web/index.html"
|
||||
[ -f "$page" ]
|
||||
run grep -nEi '<(script|link|img|iframe)[^>]+(src|href)=' "$page"
|
||||
run bash -c "grep -oEi '<(script|link|img|iframe)[^>]+(src|href)=\"[^\"]*\"' '$page' \
|
||||
| grep -vEi '(src|href)=\"data:'"
|
||||
[ "$status" -ne 0 ] || { echo "the page references an external asset: $output" >&2; false; }
|
||||
|
||||
# And the exemption is not a hole: anything that is not a data URI still
|
||||
# fails, checked against a copy rather than trusted.
|
||||
local probe="$BATS_TEST_TMPDIR/probe.html"
|
||||
sed 's|<link rel="icon"|<link rel="stylesheet" href="https://cdn.example.com/x.css"><link rel="icon"|' \
|
||||
"$page" > "$probe"
|
||||
run bash -c "grep -oEi '<(script|link|img|iframe)[^>]+(src|href)=\"[^\"]*\"' '$probe' \
|
||||
| grep -vEi '(src|href)=\"data:'"
|
||||
[ "$status" -eq 0 ] \
|
||||
|| { echo "the check no longer catches a real external reference" >&2; false; }
|
||||
}
|
||||
|
||||
@test "the terminal page's own unit tests pass" {
|
||||
@@ -2515,6 +2553,7 @@ PY
|
||||
[ -x "$REPO_ROOT/bin/dejima-ttyd" ]
|
||||
write_remote_config 'ttyd_port = 12345'
|
||||
stub_ttyd
|
||||
stub_ss_no_listeners
|
||||
run "$REPO_ROOT/bin/dejima-ttyd"
|
||||
[ "$status" -eq 0 ]
|
||||
# Executed, not grepped: the flag has to reach ttyd's argv.
|
||||
@@ -2538,7 +2577,7 @@ PY
|
||||
}
|
||||
|
||||
@test "nothing shipped by the front doors binds a routable address" {
|
||||
# Load-bearing, not stylistic. docker/init-firewall.sh:187 accepts
|
||||
# Load-bearing, not stylistic. The egress policy permits traffic to
|
||||
# container OUTPUT to the default-route address, which is the Docker
|
||||
# bridge gateway - the host - and attaching tmux gives a host shell. A
|
||||
# listener on any address the container can route to is the sandbox's own
|
||||
@@ -2918,6 +2957,7 @@ nginx_blocks() {
|
||||
printf '[remote-access]\nenabled = on\nbase_url = https://d.example\nttyd_port = 12345\n' \
|
||||
> "$FAKE_HOME/.config/dejima/config"
|
||||
stub_ttyd
|
||||
stub_ss_no_listeners
|
||||
|
||||
local exec_start
|
||||
exec_start="$(unit_exec_start dejima-ttyd.service "$FAKE_HOME")"
|
||||
|
||||
+75
-25
@@ -37,43 +37,82 @@ PRIVILEGED=" runtime ci-runner "
|
||||
# is a path nobody has decided about, and guessing "none" there is how a change
|
||||
# ships having been checked by nothing.
|
||||
suites_for() {
|
||||
# Every suite that READS the path, not the one that owns it. static.bats
|
||||
# asserts invariants over the whole tree and config.bats sources most of
|
||||
# the libraries, so both appear far more often than a by-area reading
|
||||
# suggests - and leaving them out is what made the --changed lane report an
|
||||
# edit as green from a selection that never loaded the file.
|
||||
#
|
||||
# The map is checked against the suites mechanically; see the test named
|
||||
# for it in static.bats. Over-selecting costs a suite run, under-selecting
|
||||
# costs a missed regression, so a rule here errs wide.
|
||||
case "$1" in
|
||||
# Read by nearly every other suite, so a change to one is the whole set.
|
||||
bin/dejima|bin/dejima-config-lib.sh) echo ALL ;;
|
||||
|
||||
bin/dejima-audit) echo audit ;;
|
||||
bin/dejima-update-check) echo config ;;
|
||||
bin/dejima-epitaph) echo config ;;
|
||||
bin/dejima-audit) echo 'audit config proxy remote static' ;;
|
||||
bin/dejima-update-check) echo 'config project remote static' ;;
|
||||
bin/dejima-epitaph) echo 'config static' ;;
|
||||
bin/dejima-tty-suspend) echo static ;;
|
||||
bin/dejima-color-lib.sh) echo 'remote project' ;;
|
||||
bin/dejima-tui-lib.sh) echo 'remote project' ;;
|
||||
bin/dejima-conversation-ship) echo conversation ;;
|
||||
bin/dejima-share) echo share ;;
|
||||
bin/dejima-color-lib.sh) echo 'remote project config static' ;;
|
||||
bin/dejima-tui-lib.sh) echo 'remote project static' ;;
|
||||
bin/dejima-conversation-ship) echo 'conversation static' ;;
|
||||
bin/dejima-share) echo 'share static' ;;
|
||||
# The slug scheme keys the transcript mounts, the epitaph and audit
|
||||
# stores, and the share layout, so a change to it is not one area's.
|
||||
bin/dejima-slug-lib.sh) echo 'share conversation audit static' ;;
|
||||
bin/dejima-credential-proxy|docker/credential-proxy-forwarder.js) echo proxy ;;
|
||||
bin/dejima-project-lib.sh|bin/dejima-project|bin/dejima-dep|bin/dejima-allow) echo project ;;
|
||||
bin/dejima-attach|bin/dejima-ttyd|bin/dejima-notify-watch) echo remote ;;
|
||||
web/*|systemd/*) echo remote ;;
|
||||
bin/dejima-provider-lib.sh|bin/dejima-harness-lib.sh) echo providers ;;
|
||||
bin/harness/*) echo providers ;;
|
||||
forges/*|workflows/*|harnesses/*|tools/*) echo providers ;;
|
||||
install.sh) echo config ;;
|
||||
bin/dejima-credential-proxy|docker/credential-proxy-forwarder.js) echo 'proxy static' ;;
|
||||
bin/dejima-credential-scan*) echo 'config static' ;;
|
||||
bin/dejima-project-lib.sh|bin/dejima-project|bin/dejima-dep) \
|
||||
echo 'project config launch providers remote static' ;;
|
||||
bin/dejima-allow) echo 'project static config' ;;
|
||||
bin/dejima-attach) echo 'remote config project static' ;;
|
||||
bin/dejima-ttyd|bin/dejima-notify-watch) echo 'remote config static' ;;
|
||||
web/*|systemd/*) echo 'remote static' ;;
|
||||
bin/dejima-provider-lib.sh) echo 'providers config remote share static' ;;
|
||||
bin/dejima-harness-lib.sh) echo 'providers config remote static' ;;
|
||||
bin/harness/*) echo 'providers remote static' ;;
|
||||
# A manifest is read by more than the suite that owns the schema: the
|
||||
# audit reads a workflow's briefing, conversation.bats sed-injects a
|
||||
# harness.conf, and config.bats copies the whole set in.
|
||||
forges/*|workflows/*|harnesses/*|tools/*) \
|
||||
echo 'providers static audit config conversation' ;;
|
||||
install.sh) echo 'config audit providers remote static' ;;
|
||||
|
||||
# static.bats asserts on files it does not own, so a shipped doc or a
|
||||
# docker/ file needs it as well as whatever else the change touched -
|
||||
# which for docker/ is most things, since the entrypoint and compose
|
||||
# file are read by nearly every suite that builds a launch.
|
||||
docker/*) echo ALL ;;
|
||||
hooks/*) echo 'static config providers project' ;;
|
||||
docs/*|README.md|*.md) echo static ;;
|
||||
hooks/*) echo 'static config providers project remote' ;;
|
||||
.gitea/*|.shellcheckrc) echo static ;;
|
||||
.dejima/*) echo 'static config project providers' ;;
|
||||
|
||||
test/*.bats) local n="${1##*/}"; echo "${n%.bats}" ;;
|
||||
# A suite file is read by static.bats as well as run by bats: it
|
||||
# asserts invariants over the text of the others, so an edit to one
|
||||
# that only selected itself was reported green by a lane that never
|
||||
# loaded the assertions about it.
|
||||
test/*.bats) local n="${1##*/}"; echo "${n%.bats} static" ;;
|
||||
# Fixtures are executed by the suites that consume them, not merely
|
||||
# read: fake-anthropic.py is the upstream in proxy.bats (and in
|
||||
# runtime.bats, which no confined selection can carry), and
|
||||
# answer-prompt.py drives config.bats' prompts.
|
||||
test/fixtures/*) echo 'proxy static config' ;;
|
||||
test/*) echo static ;;
|
||||
|
||||
# Last of the specific rules, not before them: a .md under test/ or
|
||||
# .dejima/ belongs to whatever reads that directory, and this pattern
|
||||
# would otherwise answer for it. The docs themselves are asserted
|
||||
# against by more than static.bats - the README's axis lists, the
|
||||
# provider tables, the nginx example and the remote-access page each
|
||||
# have a suite checking the code agrees with them.
|
||||
#
|
||||
# So a doc-only edit runs more than static alone. That is the cost of
|
||||
# the lane answering for what reads a file, and it is the safe
|
||||
# direction: narrowing this back would report a doc change as green
|
||||
# from suites that assert against it.
|
||||
docs/*|README.md|*.md) echo 'static config providers remote' ;;
|
||||
|
||||
*) echo ALL ;;
|
||||
esac
|
||||
}
|
||||
@@ -246,14 +285,21 @@ wait
|
||||
# suite that fails to run at all - a syntax error, or duplicate test names,
|
||||
# which bats refuses outright - exits nonzero having run nothing, and reporting
|
||||
# that as "0 failures" is how a green board comes to mean nothing.
|
||||
#
|
||||
# Skips are counted and shown separately because bats emits one as `ok N ...
|
||||
# # skip`, so a test that decided not to run is indistinguishable from one that
|
||||
# passed. Several here skip on host state - an empty /opt/dejima, a missing
|
||||
# tool - so the column is the only thing that says how much of the suite the
|
||||
# number above actually covers.
|
||||
failed=0
|
||||
printf '%-16s %6s %8s\n' suite ran failed
|
||||
printf '%-16s %6s %8s %8s\n' suite ran skipped failed
|
||||
for s in "${selected[@]}"; do
|
||||
ran="$(grep -cE '^(ok|not ok)' "$out/$s")"
|
||||
emitted="$(grep -cE '^(ok|not ok)' "$out/$s")"
|
||||
n_skipped="$(grep -cE '^ok [0-9]+ .* # skip' "$out/$s")"
|
||||
bad="$(grep -cE '^not ok' "$out/$s")"
|
||||
status="$(cat "$out/$s.status" 2>/dev/null || echo 1)"
|
||||
printf '%-16s %6s %8s' "$s" "$ran" "$bad"
|
||||
if [ "$ran" -eq 0 ]; then
|
||||
printf '%-16s %6s %8s %8s' "$s" "$(( emitted - n_skipped ))" "$n_skipped" "$bad"
|
||||
if [ "$emitted" -eq 0 ]; then
|
||||
printf ' <- ran nothing\n'
|
||||
failed=1
|
||||
elif [ "$bad" -gt 0 ] || [ "$status" -ne 0 ]; then
|
||||
@@ -264,14 +310,18 @@ for s in "${selected[@]}"; do
|
||||
fi
|
||||
done
|
||||
|
||||
for s in "${selected[@]}"; do
|
||||
grep -hE '^ok [0-9]+ .* # skip' "$out/$s" | sed -E "s/^ok [0-9]+ / $s: /"
|
||||
done | { grep . && echo " (skipped, so the counts above do not cover them)"; } || true
|
||||
|
||||
if [ "$failed" -ne 0 ]; then
|
||||
echo
|
||||
for s in "${selected[@]}"; do
|
||||
ran="$(grep -cE '^(ok|not ok)' "$out/$s")"
|
||||
emitted="$(grep -cE '^(ok|not ok)' "$out/$s")"
|
||||
bad="$(grep -cE '^not ok' "$out/$s")"
|
||||
{ [ "$bad" -gt 0 ] || [ "$ran" -eq 0 ]; } || continue
|
||||
{ [ "$bad" -gt 0 ] || [ "$emitted" -eq 0 ]; } || continue
|
||||
echo "--- $s"
|
||||
if [ "$ran" -eq 0 ]; then
|
||||
if [ "$emitted" -eq 0 ]; then
|
||||
head -20 "$out/$s"
|
||||
else
|
||||
grep -E '^not ok' -A 20 "$out/$s"
|
||||
|
||||
+1129
-360
File diff suppressed because it is too large
Load Diff
+24
-7
@@ -17,7 +17,12 @@ list_scripts() {
|
||||
cd "$root"
|
||||
{
|
||||
ls bin/dejima bin/dejima-* bin/harness/*.sh 2>/dev/null || true
|
||||
ls hooks/*.sh docker/*.sh install.sh 2>/dev/null || true
|
||||
# docker/* rather than docker/*.sh: the scripts installed into the
|
||||
# image as commands carry no extension, and the shebang filter below
|
||||
# is what separates them from the Dockerfile, the compose file and the
|
||||
# Python and Lua that live here too. Missing one of them would leave
|
||||
# the egress policy itself unchecked.
|
||||
ls hooks/*.sh docker/* install.sh 2>/dev/null || true
|
||||
ls forges/*/bin/* 2>/dev/null || true
|
||||
# test/, for the reason .dejima.md gives for being careful what goes
|
||||
# there: CI checks the commit out on a host and runs it, so a helper
|
||||
@@ -27,12 +32,24 @@ list_scripts() {
|
||||
ls test/*.sh 2>/dev/null || true
|
||||
} | sort -u | while read -r f; do
|
||||
[ -f "$f" ] || continue
|
||||
# Harness plugins are sourced, not executed, so they carry no shebang
|
||||
# and the discovery below would drop them - silently, which is the
|
||||
# worst version of that. They are in the trusted computing base, so
|
||||
# they are included by name and the -s bash below tells shellcheck
|
||||
# what they are.
|
||||
case "$f" in bin/harness/*.sh) printf '%s\n' "$f"; continue ;; esac
|
||||
# Sourced fragments are not executed, so they carry no shebang and the
|
||||
# discovery below would drop them - silently, which is the worst
|
||||
# version of that. Harness plugins are one kind; docker/ has two more,
|
||||
# and they are not incidental: caps-lib.sh holds the drop that decides
|
||||
# what a session's capability set is, and fixed-domains.sh is the seam
|
||||
# keeping the permitted set's classes from drifting between the
|
||||
# container that enforces them and the host command that adds to them.
|
||||
#
|
||||
# Matched by pattern rather than by name, because a list is a thing to
|
||||
# forget to add to - and this one was forgotten anyway: bin/*-lib.sh
|
||||
# went unchecked, which is dejima-harness-lib.sh, the code deciding
|
||||
# which host paths may be mounted where. Everything under docker/ is in
|
||||
# the trusted computing base; the -s bash below tells shellcheck what
|
||||
# these are.
|
||||
case "$f" in
|
||||
bin/harness/*.sh|docker/*.sh|bin/*-lib.sh)
|
||||
printf '%s\n' "$f"; continue ;;
|
||||
esac
|
||||
head -1 "$f" | grep -qE '^#!.*(bash|sh)' && printf '%s\n' "$f"
|
||||
done
|
||||
}
|
||||
|
||||
+3186
-450
File diff suppressed because it is too large
Load Diff
+21
-2
@@ -10,7 +10,7 @@
|
||||
//
|
||||
// Usage: npm ci && node build.mjs
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import { readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
@@ -41,8 +41,15 @@ const CLIENT_SOURCES = [
|
||||
// needed, since the first alone passes on a commit that rebuilds the manifest
|
||||
// and leaves the page behind.
|
||||
const MANIFEST_SOURCES = [
|
||||
// The builder itself. Without it, editing this file - the escaping, the fill
|
||||
// order, which sources get inlined - produces a different page with a
|
||||
// byte-identical manifest and stamp, so the staleness check passes on a page
|
||||
// nobody rebuilt. web/README.md defends committing the artifact precisely so
|
||||
// nobody has to run the build, which is what makes that check load-bearing.
|
||||
'build.mjs',
|
||||
'package-lock.json',
|
||||
'src/config.js',
|
||||
'src/favicon.png',
|
||||
'src/gestures.js',
|
||||
'src/keyboard.js',
|
||||
'src/keys.js',
|
||||
@@ -58,7 +65,18 @@ const MANIFEST_SOURCES = [
|
||||
'src/viewport.js'
|
||||
];
|
||||
|
||||
// The order above is hand-written because it is load-bearing, so the list
|
||||
// cannot be a glob. A src/*.js omitted from it is still hashed into the
|
||||
// manifest and still fails no test - it is simply never inlined, and the page
|
||||
// is missing a module. Reading the directory catches that at build time.
|
||||
const uninlined = readdirSync(join(here, 'src'))
|
||||
.filter((f) => f.endsWith('.js') && !CLIENT_SOURCES.includes(`src/${f}`));
|
||||
if (uninlined.length) {
|
||||
throw new Error(`src/*.js not in CLIENT_SOURCES, so never inlined: ${uninlined.join(', ')}`);
|
||||
}
|
||||
|
||||
const read = (relative) => readFileSync(join(here, relative), 'utf8');
|
||||
const readBytes = (relative) => readFileSync(join(here, relative));
|
||||
const sha256 = (text) => createHash('sha256').update(text).digest('hex');
|
||||
|
||||
// A literal </script> inside an inlined script would end the element early.
|
||||
@@ -82,8 +100,9 @@ html = fill(html, 'PAGE_CSS', read('src/page.css'));
|
||||
html = fill(html, 'XTERM_JS', escapeForScript(read('node_modules/@xterm/xterm/lib/xterm.js')));
|
||||
html = fill(html, 'ADDON_FIT_JS', escapeForScript(read('node_modules/@xterm/addon-fit/lib/addon-fit.js')));
|
||||
html = fill(html, 'CLIENT_JS', escapeForScript(client));
|
||||
html = fill(html, 'FAVICON_B64', readBytes('src/favicon.png').toString('base64'));
|
||||
|
||||
const manifest = MANIFEST_SOURCES.map((file) => `${sha256(read(file))} ${file}\n`).join('');
|
||||
const manifest = MANIFEST_SOURCES.map((file) => `${sha256(readBytes(file))} ${file}\n`).join('');
|
||||
html = html.replace(
|
||||
'<!--BUILD_STAMP-->',
|
||||
() =>
|
||||
|
||||
+5
-1
@@ -10,7 +10,11 @@
|
||||
<meta name="color-scheme" content="dark">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<title>dejima</title>
|
||||
<!-- Generated by web/build.mjs - do not edit. Sources: f8a5205278ef77485e319cdefff6cbeeb027f201ab27266b02703a9eb570c608 -->
|
||||
<!-- Inlined like everything else on this page: ttyd serves one file and answers
|
||||
no path a /favicon.ico could be fetched from, so a link to one would 404 and
|
||||
the tab would fall back to the generic icon. -->
|
||||
<link rel="icon" type="image/png" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGUAAABlCAYAAABUfC3PAAAACXBIWXMAAAsSAAALEgHS3X78AAAK9UlEQVR4nO2db0wb5x3Hv6BAa2w0ggcjJlHsOHYkYgs8oIFIThMsUTlCJJsEFW/CgjJpaFsi7VWFtBfTVNZXUYJU5Q1K11ckIK0KQ0JDcrTWVU0FFCMcpJxLMQoYhOuEyf+K3XV74dxxZ5/xnX1nH+E+b+D85+6xPzzP7+653++hBAACxNRlyEgBd42xa7ckQEx9BuB6sVsjAwDYBaArhSxESlQBaCotditk0pGlSBBZigSRpUgQWYoEkaVIEFmKBJGlSBBZigSRpUgQWYoEkaVIkGPFbgBXvGt+hCMxuJdXAQBbO6+wvfOS9bUqpQIGnQYAcFangUqlgMWkL1hb80WSUrxrfriXV+Fd8+Pb7zbhXfPz3odz1pP2WF1tNQxnNDDoNGgy6yUrqiRATP2v2I0gJSx6Vlm/TDEx6DSwtpmkJOlK0aR41/yYdszBOfss4zBEp1KtRKW6AupTVShXlFHbbOzFEgi+2AUAbBEBxvZBqJQKWNtMsF44D4tZD5VSwe9DCUNhpXAVUV5RBo2xBuqTVcmfr0XkyxYRwPcbuwi++A+2iABCwciBr7fbWmG9cB7WNlPex+aB+FLCkRimHfMYn3QeKELbpMEJYw0loxCEglFsEQH4iQB8S5uIRxOsr1MpFbDbWtDbfQl1tcfFbpZ4UhY9q5h2zGPaMcf6fHlFGbSN9dA2aaAx1gjSE/JliwjAt+SHz+3P2IssZj3sHa2w21rEaobwUqYd85iY/CLjGZO2SQNtYz2M7aeFPKzgbBEBPHetZ+xBdbXVuGprQU+3VejYI5yUacc8Ho7NsA5RlWolTLazONeulUSP4AvhWgfh8sFPBNKeUykV6O22wm5rFWpoy1/KxKQTD8dmEI7E0p7TNmlg7jDghLEmn0NIhlAwioWplYy9x25rxe1b3fn2nNylZOoZZKxo7mrIeMp62InHElh2eEG41tNiD9lz8hjW+EtZ9Kxi+N5jVhnmDgPMNsOhHKJyhXCtY2FqhVXOQF8nerqtfHfJXcr2ziuMjD5Ju+I+qjJSySTHoNPgj7+9xme2gJuUTHHDbDOguavhSMtIZfmpFwtTK2kxp6fbioG+Ti5DWnYp0455DN9/xHhMY6zBu/2tb2zMyJd4LIGFqRUsO7yMx+22VgzdeT/b269knSVO7R2dgxehbdTwbeeRolxRhvaeRhjbT2PmgYsa0tjOUNnIepMrdd5H7h3cUZ+swl4sTm1bL3CbQ8sqpa72OEPMsuPbHJp3NCFc61RsIefPuMDpdrC9Y39nhMuHeIx94k6GCeHyUb/zmSvjJMXaZkJdbTW1/Zx2MBl2QsEoY1qmt/sS5/dyTpy4SjPtkYewrHie7p95Wcx6XvNinKXYba3U76FgBFssk3MySeKxBGM0sXe0ZnwtG5yl1NUeZ4h57lrndaCjhM/tzynAk/DK+6LvXA74mVmmDV0qpQLTjnle7+clxWLSywE/C8GNXUaSxvbOSwzffwR73585Z+rwzpDspc16ygE/nUzXceFIDCOjk5z2wVsKfQiTAz6TeCwB39Imtf3BL3/AjXN71Pb2zktOQxlvKcnAJQd8NugBvl75E24Y9/CB5Qdc1+1PtTwcm8m6n5wSvOWAzw49wF/X7X8nv6L9ziXxMCcpqQE/dYr6KJIa4Om947O1/ftNXBL7ci6FoAd8Qh7CGAHedjKBeuVPAIBQogSOzX0p9HnETOQsJTXg+5b4Z8a/KaQGePrQ5dgoQyheAiCZK8alp+RcCkEGfDIDknD5CnLzK7ixi3g0gVAwmjGLsbyiDD9/nfpaiPSm5y4f4/bvZqQUm5FSzO0cw8eet6jHr3K8ss+rPqWn20pJSaZ6RgW9CUbP9U0ds/lQqVZCfepnouUqp16vffTN2/jom7fTXkc/az2IvKQYdMkCHDJFlXD50NzVkM8uEdzYBeFaPzCfly+hYCQ5xLqT7aTnMefbu7lk7wPJP2CuM8V5V3L1dF+iEisI13pOUshZVcK1zrk3vFP7I1pr/8v63GYkGVzJsTzteNEECJcPhMuH8ooynGvXwtRhyKmX06/TLGY9rBdMVIVBMoac552tn7cUa9t5qEYVCEdiVMDn+teXLQ0USH5Qi0mPJrMet4ceUI//3rSH1tof2febcsZz+9Y1AMlEwsXlVUYCQzyazHZcdnh5p9nGYwnG3cWbfZ2wmPS5JOAxyFsKWf3EJ+BvEQEsP/VSw0kqyWoqU1I4LU/K2maiJvX+8GUF/vFemDr1JAklSjD0tYLqJSqlgvqSyJ/OWQ+cXz+Dc9bDEORzJ8sg1KeqYO4wZK0MoE/I1tVWC1aeJ0ghKteAv0UEsDC1wpq9TpYWHJS9frOvk5ISipfg1/9S4YYxjnde95jNSCk+9ryFzcj+mf5AX2fafqxtJljbTAjf6oZz9lla6UbwxS7+/ekcFqZW0NzVkFEOPcD35tk76AhWCjFw5y71wZq7Ghix5SAZfItw2JIDM8Ex+Q3AwUVOlWplmpwtIoB/3v18v11jfxWqTkXY+hTyyyqvKMNv7l7LKoMcg/ninPVg+P7jA5PbBvo6cZOll2Rje+cVHo7NHChH26TBV+NLVDzhI58DwkkJR2LouTVMfVHlFWUZazjstpa8x1+ylnLaMUf1UCFrE7d3XmF88gtMO+bT5Kd+tpHhQSHLvYUtrxu+/zhjjaPd1oqBvk7BCzkXPavUWZnFrMfIh4OC7j8ciWFi0onxSSdrz6yrrcbE6JCQh8yeS8wHesAnEUtGoVApFbj5us6ETY6QAZ5EUCkGnQY93Va4l1dx9kz9oZaRSqoc56wHdb+oFqVKWPC1WcgLtTcVUk4uJxFckZeWkiCyFAkiS5EgshQJIon1vrgQjsSodcHoq+J5v/MzTlEt5uRFHLk63lmdBoYz9YfpLLB4631xwbvmx5ezHjhnPTmtjkeHvLdhMekLvVQUX6QnJRyJsc7cCkmuN58KhHSkZJvOAJITgieMNahUV0Dz+kaUirZCXpy2At73G7sIB6MIvthlnRAlkeCMgzSkZFt0R9tYT8nIhXgskVxoze3PeJeTx+IDYlNcKd41P/5271HaMEUuLWJs1wpeIh6PJeBz+zOupzJ05/1ix5ziSZl2zGNk9AmjdxR6nRffkh+u8aU0OUXuNcWRwjbFX8x1XtjWUzHoNBgZHiyGmMJKCUdiuD30gDFclVeU4b3fXSz6Qm2hYBSffzrHOClQKRUYGR6kVgMvEIWTwiZEY6xB5+BFSa2C5JpYYlQRFEHMlYJMs7AJMbZr0fWndyUlBADaexpxuX8/vZSt7WJTECnD9x+nCbncL9oSsnljbD+dJmbow79zXoUoX0SX8snYDKMqVupCSFLFbO+8ZGRoiomoUhY9q4wav8MihCRVjHfNj5HRJ6IfVzQpZJcnUZ+qwsXeRrEOJxrG9tMwtmup7YlJJxY9q6IeUzQpI6OTjBywy/0tkgvqXLnc3wL1qf2aluF7j0U9nihSkimg+xeHzV0NBfunAmJBH3a3d17iEw6l17kiihR6gzXGGpg7DGIcpqCoT1Yx8qMPms3OF8FTjMgaEJJQMIopWiL0YWaPtl4AeatBjFQjwaVMTDoZ22Rp25vIuEhSBB++CjxPVFQMZ8T5rKLMfYl9yigVDDqNGLPIwiZ4k0jkv8AdWuS8LwkiS5EgshQJIkuRILIUCSJLkSCyFAkiS5EgshQJIkuRILIUCVIKwFfsRsgw2D0G4C8A+ovdEhkAgLvG2OX+Pzk3fgySn1OHAAAAAElFTkSuQmCC">
|
||||
<!-- Generated by web/build.mjs - do not edit. Sources: 0cc964e9a08f82488c268b4e95b19542a42c5ada3b927e7fac62f7acc46bde94 -->
|
||||
<style>
|
||||
/**
|
||||
* Copyright (c) 2014 The xterm.js authors. All rights reserved.
|
||||
|
||||
+3
-1
@@ -1,5 +1,7 @@
|
||||
63b33d784dd94f6df18f63043a5f3734a306e1ea01d7ddbc285d53c1b7435cf2 build.mjs
|
||||
f6f6611d0b3eacc39d10add9c4bbc56d4b3a70a47f022351a1c6eab68cd66e71 package-lock.json
|
||||
613d28096b1bed66f347590663f5f0136fb7f61a4578066fa2ed9e788ff9567a src/config.js
|
||||
18a303a9b2bb95504e25e954c82e542da96702cf39a3499e9c9fd50e5160f39f src/favicon.png
|
||||
f690fd9d35975bc977e3d323595a2a4aa1fca6ed50f213602f08adec3f5b2c92 src/gestures.js
|
||||
e75dc9d5e311a3d9bf9dc373b651ad506afbd7a7cc7ad54aac6fdad94276b0a4 src/keyboard.js
|
||||
2a070f63702b95b0c62963ec6b9ec4fa2b0b89b7553a8a889d68cdf3506f9ce3 src/keys.js
|
||||
@@ -10,6 +12,6 @@ cb5e33c0fa117176d9073bd9019f78b65bfe5535833bf1765ee542c700d67f1f src/osc52.js
|
||||
94229c981f6287162d6ef774f71ef41d941494595f76f16bb742811ca32a767f src/page.css
|
||||
e02823ce9d5d57615a0a8c0c7b91611932202758048b7eadabe4f1054aaf1a9f src/protocol.js
|
||||
aac82e88c6a65617818c6589091cbb8c3bab50e82e98aabf8b583cbb3dfda7e7 src/sgr.js
|
||||
dab361cf748e6d530099f79ccd3f407fcc493f81dd4afeda227376a8c9e9b81f src/template.html
|
||||
34e34a8d258b8a7d2afde03287681b99d839994936c398ba5c7165a3ed7aec93 src/template.html
|
||||
3c8e5520b694693126097d9fe17f89ff23ba15adb8532521c647907edba20215 src/toolbar.js
|
||||
864405fe11e5456bdb98ea8c841cb04346d5a38aabec897101defa9612d89443 src/viewport.js
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.8 KiB |
@@ -10,6 +10,10 @@
|
||||
<meta name="color-scheme" content="dark">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<title>dejima</title>
|
||||
<!-- Inlined like everything else on this page: ttyd serves one file and answers
|
||||
no path a /favicon.ico could be fetched from, so a link to one would 404 and
|
||||
the tab would fall back to the generic icon. -->
|
||||
<link rel="icon" type="image/png" href="data:image/png;base64,/*FAVICON_B64*/">
|
||||
<!--BUILD_STAMP-->
|
||||
<style>
|
||||
/*XTERM_CSS*/
|
||||
|
||||
Reference in New Issue
Block a user