Compare commits
78
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a80e6f76f | ||
|
|
415564f00f | ||
|
|
8cc9e29ab3 | ||
|
|
3fbd6ad75f | ||
|
|
5dc158f3a1 | ||
|
|
531779106e | ||
|
|
14bb297dbc | ||
|
|
1cb1be5ee8 | ||
|
|
799ffa0770 | ||
|
|
bb2c1f218f | ||
|
|
4e6261561e | ||
|
|
56c1ce90c3 | ||
|
|
0bc660b8d1 | ||
|
|
cbcce5e225 | ||
|
|
761feeedae | ||
|
|
b663d51678 | ||
|
|
14fde86f68 | ||
|
|
76f61e77c0 | ||
|
|
49d904bed7 | ||
|
|
10a52c2dde | ||
|
|
b474acac0a | ||
|
|
1c4dfc39e4 | ||
|
|
ca6d9805b2 | ||
|
|
aa7fe3c2d3 | ||
|
|
65c0410181 | ||
|
|
b88b26e6dd | ||
|
|
c4b3168e31 | ||
|
|
d86c0560d0 | ||
|
|
1ca2ca4466 | ||
|
|
98d63c0d44 | ||
|
|
5c30839ff8 | ||
|
|
d417f31def | ||
|
|
a23d8145a5 | ||
|
|
d2447fdf24 | ||
|
|
1dd2356540 | ||
|
|
acd385f303 | ||
|
|
72c23ceea6 | ||
|
|
9530da13d0 | ||
|
|
70f01a8361 | ||
|
|
b95a742b8d | ||
|
|
d6f4db0e0d | ||
|
|
16104760dd | ||
|
|
b3d32574a5 | ||
|
|
6b8a3fad6e | ||
|
|
63084ccd87 | ||
|
|
03c2d3abd9 | ||
|
|
47832effc2 | ||
|
|
59f35e0396 | ||
|
|
4d1911c970 | ||
|
|
0f5727e206 | ||
|
|
cee8eb0214 | ||
|
|
9d82c7b569 | ||
|
|
f0e3ccc8b8 | ||
|
|
5c299e0a5f | ||
|
|
27ab4287c3 | ||
|
|
982be2e391 | ||
|
|
0f4fc7ec3c | ||
|
|
7d93b33f06 | ||
|
|
440705721a | ||
|
|
cd6b911114 | ||
|
|
d27bec6643 | ||
|
|
80d425c62e | ||
|
|
9802328321 | ||
|
|
b9de98081c | ||
|
|
ebb32a9234 | ||
|
|
6d0f603b95 | ||
|
|
1a6177a0e0 | ||
|
|
623d565ebb | ||
|
|
ea49f6a0db | ||
|
|
de38870e1f | ||
|
|
b2d7964faf | ||
|
|
2f2722a9f2 | ||
|
|
f48e5827bb | ||
|
|
ba4cf05179 | ||
|
|
f4bfc78631 | ||
|
|
e216708cc8 | ||
|
|
13c04748f0 | ||
|
|
6ce77e28ab |
+25
@@ -13,6 +13,17 @@ socket, by design. Everything else runs anywhere, which is what the CI job names
|
||||
mean: `sandbox` for the suites that need only a shell, `host-docker` for the one
|
||||
that does not.
|
||||
|
||||
## `bats` is a declared dependency here, not part of the image
|
||||
|
||||
It is in `.dejima/dependencies`, like any other project's test runner, rather
|
||||
than baked into the base image for every project that never runs bats. So on a
|
||||
sandbox whose image predates that approval, `bats` is simply absent — the fix
|
||||
is the ordinary one the briefing describes (`dejima-project approve`, then
|
||||
relaunch so the derived image is rebuilt), not installing it in-session.
|
||||
|
||||
If it is missing and you cannot get a rebuild, say the suites were not run
|
||||
rather than reporting a change as tested.
|
||||
|
||||
## Run the suite for what you changed, not all of them
|
||||
|
||||
The suites are independent and cover different areas. Running all of them for
|
||||
@@ -55,6 +66,20 @@ credentials cross the container boundary, that runtime run is the only thing
|
||||
exercising it end to end. Worth asking the operator to run it on the host rather
|
||||
than waiting on the runner.
|
||||
|
||||
## Pushing a branch here runs your code outside the sandbox
|
||||
|
||||
The moment you push, CI checks that commit out on a host and executes it — and
|
||||
because the workflow runs `bats test/<suite>.bats`, every file in `test/` is
|
||||
code that runs there, not just the workflow file. Nothing about that is under
|
||||
the sandbox's firewall, and no one reads the diff first.
|
||||
|
||||
Practically: a helper you add to a test file gets executed on a machine outside
|
||||
this container. Write test code you would be comfortable running there, and if
|
||||
a change would have CI reach a new host, that belongs in
|
||||
`.dejima/allowed-domains` in the same pull request — on this project the CI
|
||||
allowlist is read from `main`, so an undeclared destination fails rather than
|
||||
connects. See [docs/ci.md](docs/ci.md).
|
||||
|
||||
## A test that reads its environment will pass here and fail on your machine
|
||||
|
||||
Twice now a test has stated a precondition by leaving something unset, which
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
docs.gitea.com
|
||||
deb.debian.org
|
||||
download.docker.com
|
||||
github.com
|
||||
api.github.com
|
||||
objects.githubusercontent.com
|
||||
release-assets.githubusercontent.com
|
||||
@@ -0,0 +1,2 @@
|
||||
apt:tmux
|
||||
apt:bats
|
||||
+304
-13
@@ -17,8 +17,34 @@
|
||||
# covering every unrelated area at once.
|
||||
name: tests
|
||||
|
||||
# pull_request_target, not pull_request, and the difference is the whole
|
||||
# security posture of this file.
|
||||
#
|
||||
# Under `pull_request` the workflow is read from the HEAD of the pull request -
|
||||
# so the branch under test decides what runs and, critically, `runs-on:`. A
|
||||
# branch can point its jobs at any runner its repository can reach, which
|
||||
# means every control the runner provides (no Docker socket, deny-by-default
|
||||
# egress) is opt-out by whoever wrote the branch.
|
||||
#
|
||||
# Under `pull_request_target` Gitea reads the workflow from the BASE branch,
|
||||
# and explicitly discards any pull_request_target workflow found on the head
|
||||
# commit - so a branch can neither edit this file's copy nor introduce its
|
||||
# own. What runs here, and where, is whatever `main` says.
|
||||
#
|
||||
# The cost is that the checkout no longer defaults to the code under test: the
|
||||
# run's ref is the base branch. Every job that means to test the branch has to
|
||||
# say so, which is why the checkouts below carry an explicit ref. A bare
|
||||
# checkout here would test `main` on every pull request and pass regardless of
|
||||
# the change - green checks that mean nothing.
|
||||
#
|
||||
# This does NOT make the repository safe on its own. A branch can still add a
|
||||
# workflow of its own with `on: pull_request`, and that runs from the head
|
||||
# with whatever `runs-on:` it likes. The only thing stopping that is which
|
||||
# runners the repository can reach at all - see docs/ci.md, "A label is not a
|
||||
# boundary". What this buys is that the intended CI is trustworthy, and that
|
||||
# escaping it requires adding a file rather than editing one.
|
||||
on:
|
||||
pull_request:
|
||||
pull_request_target:
|
||||
|
||||
# No `concurrency:` block. Gitea does not implement one - the word appears
|
||||
# nowhere in its actions models or services as of 1.25 - so writing it here
|
||||
@@ -29,7 +55,21 @@ on:
|
||||
|
||||
jobs:
|
||||
sandbox:
|
||||
runs-on: ubuntu-latest
|
||||
# A label carried only by a runner configured for this - see ci/ and
|
||||
# docs/ci.md. Not a generic one, even though these suites genuinely do run
|
||||
# anywhere a shell does, because "runs anywhere" is a statement about what
|
||||
# the job needs and not about what it should be handed.
|
||||
#
|
||||
# Whether a job container gets the Docker socket is a property of the
|
||||
# runner's config.yaml (container.docker_host), not of anything this file
|
||||
# can ask for or refuse. So a job that needs nothing still receives
|
||||
# whatever its runner grants everything it runs, and naming a specific
|
||||
# label is the only way this file gets a say in which runner that is.
|
||||
#
|
||||
# If no runner carries this label, these jobs queue. That is the intended
|
||||
# failure: it is a better one than a green run on a host that grants
|
||||
# everything.
|
||||
runs-on: dejima-ci
|
||||
strategy:
|
||||
# One suite per job. fail-fast off because these cover unrelated areas -
|
||||
# cancelling the other six on the first failure would hide whether a
|
||||
@@ -51,11 +91,65 @@ 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
|
||||
# allowlist, not the package - and ci-runner.bats, which would say so
|
||||
# precisely, cannot run yet because installing it is what just failed.
|
||||
#
|
||||
# Retried rather than decided on one attempt: a resolver can be a moment
|
||||
# behind a container's start, and apt itself retries, so failing a whole
|
||||
# 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.
|
||||
- name: Check the job network can resolve before installing anything
|
||||
run: |
|
||||
for attempt in 1 2 3 4 5 6; do
|
||||
if getent hosts deb.debian.org >/dev/null 2>&1; then
|
||||
[ "$attempt" -eq 1 ] || echo "resolved on attempt $attempt"
|
||||
exit 0
|
||||
fi
|
||||
sleep 3
|
||||
done
|
||||
echo "deb.debian.org did not resolve after 6 attempts, so this job" >&2
|
||||
echo "cannot install anything." >&2
|
||||
echo "" >&2
|
||||
echo "This job's resolver:" >&2
|
||||
sed -n 's/^nameserver/ nameserver/p' /etc/resolv.conf >&2
|
||||
echo "" >&2
|
||||
echo "If other jobs in this same run resolved fine, the runners are" >&2
|
||||
echo "configured differently: check that EVERY runner answering this" >&2
|
||||
echo "label has --dns=<gateway> in container.options and the same" >&2
|
||||
echo "container.network. A runner missing it fails only the jobs that" >&2
|
||||
echo "happen to land on it, which looks intermittent." >&2
|
||||
echo "" >&2
|
||||
echo "If every job failed, check on the runner host, in order:" >&2
|
||||
echo " dejima-ci-allowlist show - a server= line for this name?" >&2
|
||||
echo " systemctl status dnsmasq - running?" >&2
|
||||
echo " iptables -S DEJIMA-CI-IN - port 53 allowed from the bridge?" >&2
|
||||
echo " journalctl -u dnsmasq -n 50 - does the query arrive, and is it" >&2
|
||||
echo " forwarded without a reply?" >&2
|
||||
exit 1
|
||||
|
||||
- name: Install test dependencies
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends bats jq python3 tmux ca-certificates
|
||||
# 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.
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
# As an ordinary user, not root. One test leaves a directory mode 500 and
|
||||
# asserts that the CLI's rm -rf refuses it and says so; root walks
|
||||
# through that on CAP_DAC_OVERRIDE and the test fails for a reason that
|
||||
@@ -72,18 +166,20 @@ jobs:
|
||||
su node -c "TERM=xterm-256color bats test/${{ matrix.suite }}.bats"
|
||||
|
||||
host-docker:
|
||||
# Not ubuntu-latest, unlike the job above. This one only works on a runner
|
||||
# configured for it - host network namespace, the Docker socket, and the
|
||||
# shared volume below - and none of that is anything the workflow can ask
|
||||
# for. On a generic runner it fails the preflight check outright.
|
||||
# A different label from the job above, and the only one in this file that
|
||||
# asks for anything privileged. This job needs the host network namespace,
|
||||
# the Docker socket and the shared volume below, none of which a workflow
|
||||
# can request - they are granted by the runner or not at all, so on a
|
||||
# runner without them it fails the preflight check outright.
|
||||
#
|
||||
# ubuntu-latest is a label every runner carries, so which one picked this
|
||||
# up was a coin flip, and a re-run that happened to land on an unconfigured
|
||||
# runner failed on a repo that had not changed. A label only the configured
|
||||
# runner has makes that unrepresentable rather than merely diagnosable: no
|
||||
# runner matching it means this job queues instead of running somewhere it
|
||||
# cannot work. The sandbox job stays on ubuntu-latest, because it genuinely
|
||||
# does run anywhere.
|
||||
# A label carried by exactly one configured runner makes landing somewhere
|
||||
# unusable unrepresentable rather than merely diagnosable: no runner
|
||||
# matching it means this queues instead of running somewhere it cannot
|
||||
# work. A generic label would make which runner picked it up a coin flip.
|
||||
#
|
||||
# The runner behind this label can be taken by anything it runs (see
|
||||
# ci/act-runner-runtime.example.yaml), so it belongs on a host that exists
|
||||
# for that and holds nothing else.
|
||||
runs-on: dejima-runtime
|
||||
container:
|
||||
image: node:20-bookworm
|
||||
@@ -117,6 +213,55 @@ 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
|
||||
# allowlist, not the package - and ci-runner.bats, which would say so
|
||||
# precisely, cannot run yet because installing it is what just failed.
|
||||
#
|
||||
# Retried rather than decided on one attempt: a resolver can be a moment
|
||||
# behind a container's start, and apt itself retries, so failing a whole
|
||||
# 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.
|
||||
- name: Check the job network can resolve before installing anything
|
||||
run: |
|
||||
for attempt in 1 2 3 4 5 6; do
|
||||
if getent hosts deb.debian.org >/dev/null 2>&1; then
|
||||
[ "$attempt" -eq 1 ] || echo "resolved on attempt $attempt"
|
||||
exit 0
|
||||
fi
|
||||
sleep 3
|
||||
done
|
||||
echo "deb.debian.org did not resolve after 6 attempts, so this job" >&2
|
||||
echo "cannot install anything." >&2
|
||||
echo "" >&2
|
||||
echo "This job's resolver:" >&2
|
||||
sed -n 's/^nameserver/ nameserver/p' /etc/resolv.conf >&2
|
||||
echo "" >&2
|
||||
echo "If other jobs in this same run resolved fine, the runners are" >&2
|
||||
echo "configured differently: check that EVERY runner answering this" >&2
|
||||
echo "label has --dns=<gateway> in container.options and the same" >&2
|
||||
echo "container.network. A runner missing it fails only the jobs that" >&2
|
||||
echo "happen to land on it, which looks intermittent." >&2
|
||||
echo "" >&2
|
||||
echo "If every job failed, check on the runner host, in order:" >&2
|
||||
echo " dejima-ci-allowlist show - a server= line for this name?" >&2
|
||||
echo " systemctl status dnsmasq - running?" >&2
|
||||
echo " iptables -S DEJIMA-CI-IN - port 53 allowed from the bridge?" >&2
|
||||
echo " journalctl -u dnsmasq -n 50 - does the query arrive, and is it" >&2
|
||||
echo " forwarded without a reply?" >&2
|
||||
exit 1
|
||||
|
||||
- name: Install test dependencies
|
||||
run: |
|
||||
apt-get update
|
||||
@@ -239,6 +384,8 @@ jobs:
|
||||
echo "GitHub ranges lookup is authenticated - ceiling is $limit requests/hour."
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
# The build dominates this job, and inside setup_file it is a `docker
|
||||
# build -q` that prints nothing at all - so tens of minutes of ordinary
|
||||
@@ -291,3 +438,147 @@ jobs:
|
||||
# own BATS_*_TMPDIR on paths that mean the same thing on both sides
|
||||
# of the socket, which is all any of those mounts need.
|
||||
TMPDIR: /tmp/dejima-ci
|
||||
|
||||
# The runner's own configuration, asserted from inside a job on it. Separate
|
||||
# jobs rather than steps on the two above, because the point is that these
|
||||
# are two different runners and either can be wrong on its own - one red
|
||||
# check per tier says which.
|
||||
#
|
||||
# These fail on how the RUNNER is configured, not on anything in a pull
|
||||
# request. A red check here means the machine needs attention; it does not
|
||||
# mean the branch does.
|
||||
runner-env-sandbox:
|
||||
runs-on: dejima-ci
|
||||
name: runner-env (sandbox)
|
||||
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
|
||||
# allowlist, not the package - and ci-runner.bats, which would say so
|
||||
# precisely, cannot run yet because installing it is what just failed.
|
||||
#
|
||||
# Retried rather than decided on one attempt: a resolver can be a moment
|
||||
# behind a container's start, and apt itself retries, so failing a whole
|
||||
# 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.
|
||||
- name: Check the job network can resolve before installing anything
|
||||
run: |
|
||||
for attempt in 1 2 3 4 5 6; do
|
||||
if getent hosts deb.debian.org >/dev/null 2>&1; then
|
||||
[ "$attempt" -eq 1 ] || echo "resolved on attempt $attempt"
|
||||
exit 0
|
||||
fi
|
||||
sleep 3
|
||||
done
|
||||
echo "deb.debian.org did not resolve after 6 attempts, so this job" >&2
|
||||
echo "cannot install anything." >&2
|
||||
echo "" >&2
|
||||
echo "This job's resolver:" >&2
|
||||
sed -n 's/^nameserver/ nameserver/p' /etc/resolv.conf >&2
|
||||
echo "" >&2
|
||||
echo "If other jobs in this same run resolved fine, the runners are" >&2
|
||||
echo "configured differently: check that EVERY runner answering this" >&2
|
||||
echo "label has --dns=<gateway> in container.options and the same" >&2
|
||||
echo "container.network. A runner missing it fails only the jobs that" >&2
|
||||
echo "happen to land on it, which looks intermittent." >&2
|
||||
echo "" >&2
|
||||
echo "If every job failed, check on the runner host, in order:" >&2
|
||||
echo " dejima-ci-allowlist show - a server= line for this name?" >&2
|
||||
echo " systemctl status dnsmasq - running?" >&2
|
||||
echo " iptables -S DEJIMA-CI-IN - port 53 allowed from the bridge?" >&2
|
||||
echo " journalctl -u dnsmasq -n 50 - does the query arrive, and is it" >&2
|
||||
echo " forwarded without a reply?" >&2
|
||||
exit 1
|
||||
|
||||
- name: Install test dependencies
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends bats python3 ca-certificates
|
||||
# No `ref:` here, deliberately - unlike the suites above. These assert on
|
||||
# the RUNNER, not on the change, so the assertions should come from the
|
||||
# base branch rather than from the branch they are meant to hold for.
|
||||
# Under pull_request_target that is what a bare checkout gives, which
|
||||
# means a branch can neither weaken these tests nor delete them.
|
||||
- uses: actions/checkout@v4
|
||||
- run: bats test/ci-runner.bats
|
||||
env:
|
||||
# Declared, not detected: landing on the wrong runner should fail
|
||||
# rather than quietly select a different set of assertions.
|
||||
DEJIMA_CI_TIER: sandbox
|
||||
|
||||
runner-env-host-docker:
|
||||
runs-on: dejima-runtime
|
||||
name: runner-env (host-docker)
|
||||
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
|
||||
# allowlist, not the package - and ci-runner.bats, which would say so
|
||||
# precisely, cannot run yet because installing it is what just failed.
|
||||
#
|
||||
# Retried rather than decided on one attempt: a resolver can be a moment
|
||||
# behind a container's start, and apt itself retries, so failing a whole
|
||||
# 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.
|
||||
- name: Check the job network can resolve before installing anything
|
||||
run: |
|
||||
for attempt in 1 2 3 4 5 6; do
|
||||
if getent hosts deb.debian.org >/dev/null 2>&1; then
|
||||
[ "$attempt" -eq 1 ] || echo "resolved on attempt $attempt"
|
||||
exit 0
|
||||
fi
|
||||
sleep 3
|
||||
done
|
||||
echo "deb.debian.org did not resolve after 6 attempts, so this job" >&2
|
||||
echo "cannot install anything." >&2
|
||||
echo "" >&2
|
||||
echo "This job's resolver:" >&2
|
||||
sed -n 's/^nameserver/ nameserver/p' /etc/resolv.conf >&2
|
||||
echo "" >&2
|
||||
echo "If other jobs in this same run resolved fine, the runners are" >&2
|
||||
echo "configured differently: check that EVERY runner answering this" >&2
|
||||
echo "label has --dns=<gateway> in container.options and the same" >&2
|
||||
echo "container.network. A runner missing it fails only the jobs that" >&2
|
||||
echo "happen to land on it, which looks intermittent." >&2
|
||||
echo "" >&2
|
||||
echo "If every job failed, check on the runner host, in order:" >&2
|
||||
echo " dejima-ci-allowlist show - a server= line for this name?" >&2
|
||||
echo " systemctl status dnsmasq - running?" >&2
|
||||
echo " iptables -S DEJIMA-CI-IN - port 53 allowed from the bridge?" >&2
|
||||
echo " journalctl -u dnsmasq -n 50 - does the query arrive, and is it" >&2
|
||||
echo " forwarded without a reply?" >&2
|
||||
exit 1
|
||||
|
||||
- name: Install test dependencies
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends bats python3 ca-certificates
|
||||
# Base branch, same as the job above and for the same reason.
|
||||
- uses: actions/checkout@v4
|
||||
- run: bats test/ci-runner.bats
|
||||
env:
|
||||
DEJIMA_CI_TIER: host-docker
|
||||
|
||||
@@ -117,8 +117,8 @@ see the whole resolved schema, real values included.
|
||||
| Key | Default | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `[core] harness` | `claude-code` | agent harness the container runs |
|
||||
| `[core] forge` | `gitea` | forge integration (`gitea-pr`/`gitea-issue`) |
|
||||
| `[core] workflow` | `worktree-pr` | workflow `sandbox-context.sh` tells the agent to follow |
|
||||
| `[core] forge` | `none` | forge integration — `gitea` (`gitea-pr`/`gitea-issue`), `github` (`gh`, opens `ssh.github.com:22` for push), or `none` (no forge at all) |
|
||||
| `[core] workflow` | `local-only` | workflow `sandbox-context.sh` tells the agent to follow — `worktree-pr` (push + open a PR), `branch-push` (push, stop at the branch), or `local-only` (commit locally, push nothing) |
|
||||
| `[core] command` | `claude` | container's main process (e.g. `nvim`) — also settable per launch with `--cmd` |
|
||||
| `[core] firewall` | `on` | egress firewall; `off` disables it (debugging only) — also settable per launch with `--no-firewall` |
|
||||
| `[core] share_dir` | `~/.local/share/dejima/shared` | host-visible read/write scratch dir, mounted at `~/shared` |
|
||||
@@ -129,6 +129,7 @@ see the whole resolved schema, real values included.
|
||||
| `[forge.gitea] ssh_host` | unset | Gitea/Forgejo SSH hostname — skip if you only push to GitHub |
|
||||
| `[forge.gitea] api` | unset | `https://<gitea host>/api/v1` — skip if you only push to GitHub |
|
||||
| `[forge.gitea] token_file` | `~/.config/gitea/token` | path to the Gitea/Forgejo API token (the file, not the token itself) |
|
||||
| `[forge.github] push_token_file` | unset | path to a **write-capable** PAT, used only when `[core] forge = github` — deliberately separate from `[github] token_file` below; see "GitHub push" |
|
||||
| `[github] token_file` | `~/.config/gh/sandbox-token` | path to a read-only PAT for private GitHub repos / `gh` |
|
||||
| `[mounts] readonly` | unset | extra host paths (colon-separated) a personal config needs, mounted read-only at their own path |
|
||||
| `[mounts] read_write` | unset | host paths (colon-separated) mounted **read-write** at their own path — for directories meant to outlive the session; refused if they overlap anything dejima needs out of a session's reach |
|
||||
@@ -148,24 +149,50 @@ see the whole resolved schema, real values included.
|
||||
| `[remote-access] ttyd_font_size` | unset | `ttyd` terminal font size |
|
||||
| `[remote-access] debounce` | `60` | seconds between repeat notifications for the same session |
|
||||
|
||||
**Gitea/Forgejo is the default PR workflow** — `gitea-pr`, and the only push
|
||||
target `git-push-guard.sh` allows by default. If you don't use Gitea/Forgejo,
|
||||
skip `[forge.gitea]` entirely and use `dejima --allow-github-push` instead to
|
||||
push straight to GitHub (see "GitHub push: blocked by default, optional
|
||||
opt-in" below) — the two aren't both required, pick whichever matches your
|
||||
remote. [Forgejo](https://forgejo.org/) works unmodified: it's a Gitea fork
|
||||
exposing a compatible `/api/v1` surface, so pointing `[forge.gitea] api` at a
|
||||
Forgejo instance just works.
|
||||
**No forge is the default** — `[core] forge = none` with
|
||||
`[core] workflow = local-only`: the sandbox commits locally and pushes
|
||||
nowhere. A fresh install has no forge credentials, so defaulting to a pushing
|
||||
pair would be claiming a setup that isn't there — and a pushing workflow whose
|
||||
forge resolves to no host is refused at launch, which would make an
|
||||
out-of-the-box `dejima` refuse to start. Pushing is something you opt into.
|
||||
|
||||
> **Changed default (was `gitea` + `worktree-pr`).** If your
|
||||
> `~/.config/dejima/config` has no explicit `[core] forge` / `[core] workflow`
|
||||
> lines, it used to resolve to `gitea` + `worktree-pr` and now resolves to
|
||||
> `none` + `local-only` — your sandbox will stop pushing and stop opening PRs.
|
||||
> To keep the old behaviour, write both lines out under `[core]`:
|
||||
> `forge = gitea` and `workflow = worktree-pr` (and fill in `[forge.gitea]`,
|
||||
> which that pair has always required). `./install.sh` prints this notice
|
||||
> when it finds a config in that state.
|
||||
|
||||
To push, set `[core] workflow` to `worktree-pr` (push + open a PR) or
|
||||
`branch-push` (push, stop at the branch), and pick the forge to push to:
|
||||
`[core] forge = gitea` for a Gitea/Forgejo instance (fill in `[forge.gitea]`
|
||||
per "Gitea/Forgejo PR workflow" below), or `[core] forge = github` to push and
|
||||
open PRs through `gh` (see "GitHub push" below for the posture change that
|
||||
opens, and why its `push_token_file` is a separate credential from the
|
||||
read-only `[github] token_file`). `dejima --allow-github-push` is a
|
||||
per-launch *addition* to whichever forge is selected, not a substitute for
|
||||
selecting one: a pushing workflow is still refused at launch unless its forge
|
||||
resolves to a host and its required keys are filled in, so the flag alone
|
||||
will not get an unconfigured `[forge.gitea]` past validation. [Forgejo](https://forgejo.org/) works unmodified: it's a Gitea
|
||||
fork exposing a compatible `/api/v1` surface, so pointing `[forge.gitea] api`
|
||||
at a Forgejo instance just works. See
|
||||
[docs/providers.md](docs/providers.md#shipped-providers) for every shipped
|
||||
forge/workflow and what each is for.
|
||||
|
||||
## Provider axes: forge, workflow, harness
|
||||
|
||||
The forge integration, the push/PR workflow, and the agent harness aren't
|
||||
hardcoded into dejima — each is a directory under `forges/`, `workflows/` or
|
||||
`harnesses/`, selected by `[core] forge`, `[core] workflow` and
|
||||
`[core] harness` respectively. Ships today: `forges/gitea`,
|
||||
`workflows/worktree-pr`, `harnesses/claude-code` — which is why the rest of
|
||||
this document reads as if Gitea/Forgejo and a worktree-and-PR workflow are
|
||||
the only option; right now, they are the only one that ships.
|
||||
`[core] harness` respectively. Ships today: `forges/gitea`, `forges/github`,
|
||||
`forges/none`, `workflows/worktree-pr`, `workflows/branch-push`,
|
||||
`workflows/local-only`, `harnesses/claude-code`. The defaults are
|
||||
`forges/none` and `workflows/local-only` — a fresh install commits locally
|
||||
and pushes nowhere; the rest of this document spends most of its words on
|
||||
Gitea/Forgejo and worktree-and-PR because that's the setup with the most to
|
||||
configure, not because it's what you get out of the box.
|
||||
|
||||
A provider is **data**, never sourced or executed: its manifest (`forge.conf`,
|
||||
`workflow.conf`, `harness.conf`) is parsed with the same discipline as the
|
||||
@@ -183,7 +210,8 @@ matter of adding a directory rather than editing this repo's own code.
|
||||
values into the container — the hostnames a forge needs reachable, which of
|
||||
them pushes are allowed to, which branches are protected, and the literal
|
||||
merge command to block — plus the forge's own environment variables (a
|
||||
Gitea/Forgejo API token, in the shipped forge's case). The session briefing
|
||||
Gitea/Forgejo API token for `gitea`, a write-capable PAT for `github`,
|
||||
nothing for `none`). The session briefing
|
||||
itself (the text `hooks/sandbox-context.sh` injects at `SessionStart`) is
|
||||
composed host-side too, from a fixed core fragment no provider can override
|
||||
plus the selected workflow's and harness's own prose, then mounted
|
||||
@@ -217,11 +245,18 @@ sandbox escape if you get it wrong.
|
||||
→ [Prerequisites](#prerequisites),
|
||||
[Headless git push without agent forwarding](#headless-git-push-without-agent-forwarding)
|
||||
|
||||
That is enough to run `dejima`.
|
||||
That is enough to run `dejima`. The starter config `install.sh` writes has
|
||||
every key commented out at its default, which means `[core] forge = none` and
|
||||
`[core] workflow = local-only`: the agent works in the sandbox and commits
|
||||
locally, and nothing is pushed anywhere. To have it push, pick one of the two
|
||||
forge sections below and set `[core] workflow` to `worktree-pr` or
|
||||
`branch-push` — a pushing workflow is refused at launch unless its forge
|
||||
resolves to somewhere to push to, so the forge is not optional once you
|
||||
choose one of them.
|
||||
|
||||
### Gitea/Forgejo PR workflow
|
||||
|
||||
Skip this if you only push to GitHub.
|
||||
Skip this if you use `forge = github` or `forge = none` instead (below).
|
||||
|
||||
1. Set `[forge.gitea] ssh_host` (the SSH hostname) and `[forge.gitea] api`
|
||||
(`https://<gitea host>/api/v1`) in `~/.config/dejima/config`.
|
||||
@@ -229,6 +264,34 @@ Skip this if you only push to GitHub.
|
||||
`write:issue` — and save it to `~/.config/gitea/token`, `chmod 600`.
|
||||
→ [Prerequisites](#prerequisites)
|
||||
|
||||
### GitHub forge (opt-in alternative)
|
||||
|
||||
Set `[core] forge = github` instead of the above to push and open PRs
|
||||
against GitHub directly through `gh`, rather than a Gitea/Forgejo instance.
|
||||
This is a persistent, per-config choice — different from the per-launch
|
||||
`--allow-github-push` flag (see "GitHub push" below), and it opens
|
||||
`ssh.github.com:22` for push every time this forge is selected with a
|
||||
workflow that pushes, not just for one launch. It opens it through the same
|
||||
grant that flag uses, so the posture is the one documented there and nothing
|
||||
else about GitHub's treatment changes.
|
||||
|
||||
1. Generate a **write-capable** PAT, separate from the read-only
|
||||
`[github] token_file` used for read access — promoting the read-only
|
||||
token here would defeat the point of scoping it read-only.
|
||||
2. Save it to a file and set `[forge.github] push_token_file` to that path.
|
||||
|
||||
dejima warns at startup if both `[github] token_file` and
|
||||
`[forge.github] push_token_file` are set and non-empty: `gh` only reads one
|
||||
token variable, and the write-capable one wins for that launch.
|
||||
|
||||
### Pushing nowhere
|
||||
|
||||
If this sandbox should never push or open a PR — read-only work, exploring a
|
||||
codebase, anything where you'll take the diff out by hand — set
|
||||
`[core] forge = none` and `[core] workflow = local-only`. Nothing to
|
||||
configure for either; the agent commits locally and stops there, and
|
||||
`git-push-guard.sh` refuses every push regardless of what else is set.
|
||||
|
||||
### Per-project domains and dependencies
|
||||
|
||||
Nothing to set up in advance; do this per project, as it turns out to need
|
||||
@@ -237,14 +300,22 @@ something:
|
||||
```sh
|
||||
dejima-project status ~/myproject # declared vs approved vs missing
|
||||
dejima-project approve ~/myproject # approve the declared set
|
||||
dejima-allow crates.io --project ~/myproject
|
||||
dejima-dep add cargo:cargo-nextest --global
|
||||
dejima-allow --project ~/myproject crates.io
|
||||
dejima-dep add --global cargo:cargo-nextest
|
||||
```
|
||||
|
||||
Domains apply at the next launch, and `dejima-allow` also patches a running
|
||||
session. Dependencies need an image rebuild, so they always land on the next
|
||||
one. →
|
||||
[Global and per-project domains and dependencies](#global-and-per-project-domains-and-dependencies),
|
||||
`dejima-allow` with no scope flag patches a running session; `--project` or
|
||||
`--global` instead records an approval for future sessions, and domains
|
||||
apply at their next launch. Dependencies need an image rebuild, so they
|
||||
always land on the next one.
|
||||
|
||||
`--project` records two things: the approval in `~/.config/dejima` (which is
|
||||
what actually grants) and a declaration in the project's own
|
||||
`.dejima/allowed-domains` (which is committed, so the rest of the project gets
|
||||
it too). `--global` writes only the approval — there is no project to declare
|
||||
in.
|
||||
|
||||
→ [Global and per-project domains and dependencies](#global-and-per-project-domains-and-dependencies),
|
||||
[Adding a domain to a running session](#adding-a-domain-to-a-running-session)
|
||||
|
||||
### Log shipping
|
||||
@@ -347,6 +418,13 @@ that gets deferred rather than done: `list`, `view`, `create`, `comment`,
|
||||
`close` and `reopen`, so a problem found mid-session becomes a ticket instead
|
||||
of a footnote on an unrelated PR.
|
||||
|
||||
`gitea-actions` closes the other half of that loop. A sandbox cannot run the
|
||||
tests that need a Docker daemon, so CI is where they run, and without a way
|
||||
to read a run the only route from a red build back into the session is the
|
||||
operator pasting the log in by hand. `gitea-actions runs`, `jobs <run>` and
|
||||
`logs [run]` do that instead — with no run given, the newest one for `HEAD`,
|
||||
and by default only the jobs that failed. It reads and never writes.
|
||||
|
||||
Because the session stops at the PR and you merge afterwards, the sandbox
|
||||
context tells Claude to put `Fixes #N` in the PR body rather than to close
|
||||
issues by hand once they land — the merge is the last step in that sequence
|
||||
@@ -609,15 +687,32 @@ default:
|
||||
- DNS resolution via a local proxy (see "DNS is allowlisted too")
|
||||
- the Docker host itself (not its whole subnet)
|
||||
- GitHub's published IP ranges, port 443 only (see "GitHub push" below)
|
||||
- `registry.npmjs.org`, `pypi.org`, `files.pythonhosted.org` (npm and
|
||||
mason's Python-based LSP tools), `release-assets.githubusercontent.com`
|
||||
(GitHub release assets, a separate CDN from github.com's own IPs),
|
||||
`proxy.golang.org`, `sum.golang.org` (`go` module proxy/checksums);
|
||||
`api.anthropic.com` when the credential proxy is off (kill switch or API-key auth)
|
||||
- whatever `[forge.gitea] ssh_host`/`api` resolve to — not port-restricted, so
|
||||
git-over-ssh to Gitea works without a blanket "port 22 to anywhere" rule
|
||||
- `registry.npmjs.org` and `platform.claude.com` (the harness and its
|
||||
updates), `release-assets.githubusercontent.com` (GitHub release assets, a
|
||||
separate CDN from github.com's own IPs, and where a good deal of tooling
|
||||
fetches its binaries); `api.anthropic.com` when the credential proxy is off
|
||||
(kill switch or API-key auth)
|
||||
|
||||
Everything else is rejected. Run `dejima-allow <domain> --global`
|
||||
That is the whole built-in list, and it is meant to stay that way: it covers
|
||||
what the sandbox itself cannot run without, not what a project might want.
|
||||
Language package hosts are approved rather than assumed — `pypi.org`,
|
||||
`proxy.golang.org`, `crates.io` and the rest are one `dejima-allow --global`
|
||||
or one reviewed `.dejima/allowed-domains` line away. The reason to keep the
|
||||
built-in list short is that everything on it is reachable from every sandbox
|
||||
on the host whether or not a project has any use for it, and a name granted
|
||||
by being built in is a name nobody ever reviewed
|
||||
- whatever the selected forge needs reachable (`[forge.gitea] ssh_host`/`api`
|
||||
by default) — not port-restricted, so git-over-ssh to the forge works
|
||||
without a blanket "port 22 to anywhere" rule, and exempted from DNS-rebind
|
||||
protection because a self-hosted forge host legitimately resolves to an
|
||||
internal address. `[core] forge = github` is deliberately *not* added here:
|
||||
its three hostnames are public names already covered by the GitHub entry
|
||||
above, so they stay on that entry — 443 by default, plus port 22 when the
|
||||
selected workflow pushes, exactly as `--allow-github-push` opens it per
|
||||
launch — rather than picking up all-port egress and losing rebind
|
||||
protection on the way
|
||||
|
||||
Everything else is rejected. Run `dejima-allow --global <domain>`
|
||||
(or `--project <path>` for one project) to allow more permanently (e.g.
|
||||
`crates.io` for a Rust project), or `dejima --no-firewall` to disable the
|
||||
firewall entirely for one launch (debugging only).
|
||||
@@ -649,8 +744,8 @@ Approved domains only take effect at container start. For a domain a
|
||||
sandbox — it has no privilege to do this itself):
|
||||
|
||||
```sh
|
||||
dejima-allow crates.io # this session only
|
||||
dejima-allow crates.io --global # + persist for every project
|
||||
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
|
||||
@@ -659,17 +754,21 @@ 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
|
||||
— exit code 2 if it isn't. That check is a report on the live session only,
|
||||
not a gate on approval: `--global`/`--project` persistence is recorded
|
||||
first and unconditionally, since the domain being approved may legitimately
|
||||
not answer on 443 (an internal service, an SSH-only host) and the natural
|
||||
moment to add a global approval is precisely when nothing is running to
|
||||
probe in the first place. (`--project` persistence likewise does not need a
|
||||
running container at all — see below.) `--global` writes the domain to the
|
||||
global approval store for every future session; `--global` and `--project`
|
||||
are not mutually exclusive here - give both together and each persists to
|
||||
its own store. If more than one dejima container is running, pass
|
||||
`--container <name>` to disambiguate - `docker ps` finds it, or use the
|
||||
— exit code 2 if it isn't.
|
||||
|
||||
`--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
|
||||
[Global and per-project domains and dependencies](#global-and-per-project-domains-and-dependencies)
|
||||
below, since the domain being approved may legitimately not answer on 443 (an
|
||||
internal service, an SSH-only host) and the natural moment to add an approval
|
||||
is precisely when nothing is running to probe in the first place. Pass
|
||||
`--container <id>` alongside a scope flag to do both in one call:
|
||||
`dejima-allow --global --container <id> crates.io` records the approval and
|
||||
patches that session immediately, in that order, so the approval is never
|
||||
lost to a live-session failure. If more than one dejima container is
|
||||
running, the bare form above refuses and asks you to disambiguate with
|
||||
`--container <name>` - `docker ps` finds it, or use the
|
||||
container's own ID, which `sandbox-context.sh` already hands the agent in
|
||||
its startup context (and which is always available inside the sandbox as
|
||||
`cat /etc/hostname`), so there's no need for it to guess one and risk
|
||||
@@ -713,12 +812,48 @@ A project can declare what it needs in two committed files:
|
||||
|
||||
```
|
||||
.dejima/allowed-domains # one domain per line
|
||||
.dejima/dependencies # <apt|npm|pip|cargo>:<name> per line
|
||||
.dejima/dependencies # <apt|npm|pip|cargo>:<name> per line, or a
|
||||
# tarball:<name>@<sha256>:<url> release archive
|
||||
```
|
||||
|
||||
### Tarball dependencies
|
||||
|
||||
For a toolchain no package ecosystem ships in a version you can use — a
|
||||
current Go, say, where Debian's is several releases behind — a dependency can
|
||||
name a release archive instead:
|
||||
|
||||
```sh
|
||||
dejima-dep add --global tarball:go@<sha256>:https://go.dev/dl/go1.25.0.linux-amd64.tar.gz
|
||||
```
|
||||
|
||||
It unpacks with `--strip-components=1` into `/opt/dejima/<name>`, and
|
||||
`/opt/dejima/<name>/bin` goes on `PATH`. That one convention covers the usual
|
||||
shapes without the spec needing layout options: a Go archive has everything
|
||||
under `go/`, an nvim release under `nvim-linux-<arch>/`, and stripping one
|
||||
level puts `bin/` in the same place for both. Compression is detected from the
|
||||
archive, so `.tar.gz`, `.tar.xz` and `.tar.zst` all work.
|
||||
|
||||
**The digest is mandatory**, and it is checked before anything is unpacked.
|
||||
Without one you would be approving a *name* whose contents the far end can
|
||||
change afterwards, which isn't a decision anyone can make once. With one, what
|
||||
you approved is a specific archive and nothing else can take its place. Two
|
||||
consequences worth knowing:
|
||||
|
||||
- **A tarball dependency names one architecture**, because a digest does.
|
||||
Unlike a package name, it can't resolve per-host, so a mixed-architecture
|
||||
fleet needs a spec per project rather than one global one.
|
||||
- **Upgrading is an approval, not a version bump.** A new release is a new
|
||||
digest, so it goes through `dejima-dep add` and `dejima-project approve`
|
||||
again — which is the point, not friction to route around.
|
||||
|
||||
The URL's character set is deliberately narrower than URLs allow: the spec is
|
||||
interpolated into a `RUN` line that runs as root at build time, so anything
|
||||
shell-active is refused outright rather than quoted around, and only `https`
|
||||
is accepted.
|
||||
|
||||
**Declaring grants nothing.** The project directory is bind-mounted
|
||||
read-write, so a compromised session could write these files - they are only
|
||||
ever read to compare against reality and suggest a command. Approvals live
|
||||
read-write, so a compromised session could write these files - they are read
|
||||
only to compare against reality and suggest a command. Approvals live
|
||||
outside the container, under `~/.config/dejima/` by default
|
||||
(`$DEJIMA_CONFIG_DIR` relocates the whole tree) - global at its
|
||||
root, per project under `projects/<slug>/` - and are written only by tools
|
||||
@@ -727,34 +862,77 @@ you run on the host:
|
||||
```sh
|
||||
dejima-project status ~/myproject # declared vs approved vs missing
|
||||
dejima-project approve ~/myproject # review and approve the declared set
|
||||
dejima-allow crates.io --project ~/myproject
|
||||
dejima-allow crates.io --global # applies to every project
|
||||
dejima-dep add cargo:cargo-nextest --project ~/myproject
|
||||
dejima-dep add cargo:cargo-nextest --global
|
||||
dejima-allow --project ~/myproject crates.io
|
||||
dejima-allow --global crates.io # applies to every project
|
||||
dejima-dep add --project ~/myproject cargo:cargo-nextest
|
||||
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
|
||||
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.
|
||||
|
||||
The declaration those two write still grants nothing; it is the same
|
||||
untrusted file the section above describes, and writing to it does not
|
||||
promote it. That it is session-writable is exactly why both tools refuse to
|
||||
write a `.dejima` directory or a `.dejima/<kind>` file that is a **symlink**:
|
||||
a compromised session can plant one and then ask you to run the command that
|
||||
follows it, which would aim a host-side append at your approval store or at
|
||||
any other file you can write. The refusal is the `open` itself rather than a
|
||||
test before it — `O_NOFOLLOW` on both, with the file opened relative to the
|
||||
already-opened directory — so a session that is still running while you type
|
||||
the command cannot swap `.dejima` or the declaration file out from under the
|
||||
write. (Those two leaves are what the session controls. A symlink further up
|
||||
a `--project` path you supply yourself is still resolved through.) Expect the
|
||||
session to be live: the agent briefing tells the agent to ask you to run
|
||||
`dejima-allow --project` from a terminal outside the sandbox. The same write
|
||||
reads the file back to skip an already-declared entry, and that read is
|
||||
capped — a session can make its own `.dejima/<kind>` arbitrarily large for
|
||||
free, and an oversized one is refused rather than loaded into your memory.
|
||||
|
||||
Neither writer has an inverse for the declaration. `dejima-dep remove
|
||||
--project` withdraws the approval and deliberately leaves the declaration
|
||||
alone (it says so, since `dejima-project approve` will otherwise offer the
|
||||
entry again); `dejima-allow --project` has no `remove` at all. Taking
|
||||
something back from the project itself means editing `.dejima/` and
|
||||
committing that.
|
||||
|
||||
`dejima-project status`/`approve` classify a declared entry against
|
||||
the union of global and project approvals, so something already approved
|
||||
globally shows as approved, not pending. Unlike `dejima-allow`,
|
||||
`dejima-dep`'s `--global` and `--project` are mutually exclusive -
|
||||
an entry belongs to exactly one scope.
|
||||
an entry belongs to exactly one scope. `dejima-dep` has no `--container`
|
||||
scope either, and refuses the flag rather than ignoring it: a dependency
|
||||
needs an image rebuild, so there is nothing to patch in a live session.
|
||||
|
||||
Approved domains join the allowlist at the next launch (or immediately, for
|
||||
`dejima-allow`, which also updates the running session). Approved
|
||||
dependencies are baked into a derived image built `FROM` the shared base -
|
||||
first `dejima:<username>-global`, if the global store has any
|
||||
Approved domains join the allowlist at the next launch. `dejima-allow` can
|
||||
also patch a running session immediately, but only when it is run bare or
|
||||
with `--container` - approving here (`--project`/`--global`, with no
|
||||
`--container`) never touches one; see
|
||||
[Adding a domain to a running session](#adding-a-domain-to-a-running-session).
|
||||
Approved dependencies are baked into a derived image built `FROM` the shared
|
||||
base - first `dejima:<username>-global`, if the global store has any
|
||||
dependency approved, then `dejima:<username>-<slug>` on top of that,
|
||||
if the project store does - so they need an image rebuild: they take effect
|
||||
on the **next** launch.
|
||||
|
||||
Because of that rebuild, the usual moment to approve is *after* leaving the
|
||||
sandbox — so persistence (`--global` or `--project`) never requires a running
|
||||
container, and is never gated on the reachability probe `dejima-allow`
|
||||
runs against a live session. Recording the approval is your decision;
|
||||
patching a live session and verifying the domain answers on 443 are
|
||||
best-effort on top of it, and `dejima-project approve` reports at the
|
||||
end which domains it could not apply to a running session rather than
|
||||
stopping at the first one.
|
||||
container.
|
||||
|
||||
`dejima-project approve` never attempts to patch a running session: it only
|
||||
ever calls `dejima-allow` without `--container`, so recording the approval
|
||||
is the only thing it does. If an individual approval can't be recorded — an
|
||||
unwritable store, a slug collision, a corrupt approval list — it reports
|
||||
that entry as not approved and keeps going, rather than stopping at the
|
||||
first failure; that holds for dependencies and domains alike, so one bad
|
||||
entry can't cost you the rest of the set. It then exits non-zero if anything
|
||||
failed, so `dejima-project approve ~/myproject && dejima` won't launch a
|
||||
session missing the approvals you just confirmed.
|
||||
|
||||
A session whose project declares something it doesn't have is told so in its
|
||||
startup context, with the exact command to hand you. Project approvals are
|
||||
@@ -831,6 +1009,16 @@ pretty-printed by default, no extra parsing setup needed.
|
||||
|
||||
## GitHub push: blocked by default, optional opt-in
|
||||
|
||||
This section is about `--allow-github-push`, a per-launch flag that adds
|
||||
GitHub as a push destination on top of whichever forge is selected, without
|
||||
changing that selection. It adds a destination; it does not stand in for a
|
||||
forge, so the launch-time rules for the selected forge/workflow pair still
|
||||
apply exactly as they would without it. If you
|
||||
want GitHub push and PRs as a standing choice instead, set
|
||||
`[core] forge = github` — see "GitHub forge (opt-in alternative)" above; it
|
||||
also opens `ssh.github.com:22`, but persistently rather than per launch, and
|
||||
uses its own `[forge.github] push_token_file` rather than a loaded SSH key.
|
||||
|
||||
The sandbox can always read from GitHub (clone, fetch, `gh` API calls). By
|
||||
**default** it can never push there — enforced at the network layer, not
|
||||
just by `git-push-guard.sh`: GitHub's IP ranges are only allowlisted on port
|
||||
@@ -853,9 +1041,15 @@ forwarding doesn't distinguish push from pull. Use a dedicated,
|
||||
**repo-scoped GitHub deploy key** via `[core] ssh_key` instead whenever
|
||||
`--allow-github-push` is set — that's what actually limits the blast radius.
|
||||
`--allow-github-push` only changes the firewall/hook's *host* check; it adds
|
||||
no credential-loading path of its own. There is deliberately no config-file
|
||||
equivalent for this flag — an exported or saved value must not silently
|
||||
grant a push path, so it's a per-launch decision only.
|
||||
no credential-loading path of its own. The flag itself has no config-file
|
||||
equivalent — an exported or saved value must not silently grant a push path,
|
||||
so *this* opt-in stays a per-launch decision only. Selecting `[core] forge =
|
||||
github` (see above) is a 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.
|
||||
|
||||
`[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
|
||||
@@ -880,6 +1074,36 @@ network, and that deserves more room than a section.
|
||||
bind address and verifying it, the SSH and web front doors, reading and typing
|
||||
on a phone, running behind another reverse proxy, notifications, and attaching.
|
||||
|
||||
## CI: push access is code execution elsewhere
|
||||
|
||||
If the project has CI, **granting a session push access grants it code
|
||||
execution on whatever host that CI runs on** - and nothing in the chain needs
|
||||
you. The push guard permits a feature branch, the briefing tells the agent to
|
||||
open a pull request without asking, `on: pull_request` fires, and the runner
|
||||
executes that commit. Merging is a decision you make *after* the code has
|
||||
already run, and it does not take a modified workflow file to get there: a
|
||||
workflow that runs a test suite runs every file in it.
|
||||
|
||||
The sandbox bounds what a session can reach directly. It does not bound what
|
||||
it can arrange to have run elsewhere.
|
||||
|
||||
**This holds for projects with no CI, too** - a branch can add the first
|
||||
workflow, so what matters is which runners the repository can *reach*, not
|
||||
what it runs today. That is a property of the forge rather than the project,
|
||||
and it is worth one question per project before you start. On hosted runners
|
||||
the answer is reassuring; with self-hosted runners in reach it is the thing to
|
||||
look at. And if you would rather not take it on at all,
|
||||
`[core] forge = none` with `[core] workflow = local-only` declines the push
|
||||
grant and the code execution with it.
|
||||
|
||||
**→ [docs/ci.md](docs/ci.md)** - the *why*: no approval gate is offered and
|
||||
what that leaves, hardening a runner, and making CI's egress allowlist a
|
||||
version-controlled artifact read from `main`.
|
||||
|
||||
**→ [dejima-ci-hardener](https://gitbox.branderson.io/branderson/dejima-ci-hardener)** - the *how*: a self-contained tool that
|
||||
hardens a CI runner, installed on the CI box and nowhere near a dejima host.
|
||||
Nothing in this repository needs to be copied onto a runner.
|
||||
|
||||
## Security notes
|
||||
|
||||
What this sandbox does and does not protect you from - the containment
|
||||
@@ -969,13 +1193,15 @@ likely to collide with a real container you have running.
|
||||
| `bin/dejima-dep` | Review and approve project dependencies declared in `.dejima/dependencies` |
|
||||
| `bin/dejima-provider-lib.sh` | Parses forge/workflow/harness manifests and composes the session briefing; not on PATH — see [docs/providers.md](docs/providers.md) |
|
||||
| `forges/`, `workflows/`, `harnesses/` | Provider directories for the three axes selected by `[core] forge`/`workflow`/`harness` — data, never sourced or executed; see [docs/providers.md](docs/providers.md) |
|
||||
| `forges/gitea/bin/` | The gitea forge's own CLIs, baked into the image so the sandbox can act on them: `gitea-pr` (create a PR, read its comments, merge it) and `gitea-issue` (list, view, create, comment on, close and reopen issues) |
|
||||
| `forges/gitea/bin/` | The gitea forge's own CLIs, baked into the image so the sandbox can act on them: `gitea-pr` (create a PR, read its comments, merge it), `gitea-issue` (list, view, create, comment on, close and reopen issues) and `gitea-actions` (read CI runs and job logs) |
|
||||
| `bin/dejima-notify-watch` | Host-side watcher: turns markers into ntfy pushes composed entirely from host data |
|
||||
| `bin/dejima-project` | Review and approve project domains and dependencies from the host |
|
||||
| `bin/dejima-project-lib.sh` | Shared library sourced by `bin/dejima-dep`, `bin/dejima-project`, `bin/dejima` and the remote-access scripts; not on PATH |
|
||||
| `bin/dejima-ttyd` | Launches `ttyd` on loopback in front of `dejima-attach` |
|
||||
| `systemd/*.service` | User units for `dejima-ttyd` and `dejima-notify-watch`; ship disabled, installed by hand |
|
||||
| `docs/providers.md` | The manifest contract for the forge/workflow/harness axes, key by key, with the shipped manifests as worked examples |
|
||||
| `docs/ci.md` | What push access grants a session on your CI host, and how to bound it - runner hardening, and an egress allowlist read from `main` |
|
||||
| `ci/` | Worked runner, resolver and firewall examples for the setup `docs/ci.md` describes; installed by nothing, and belongs on a CI machine rather than a dejima one |
|
||||
| `docs/nginx-dejima.conf.example` | Reverse-proxy template for the web front door; deliberately not loadable as written |
|
||||
|
||||
`bin/dejima` is the entry point — it resolves the project path,
|
||||
|
||||
+125
-21
@@ -157,6 +157,62 @@ if ! config_exists; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# A selected forge's env_export can name a variable core also fills from its
|
||||
# own config. Today that is GH_TOKEN, which core sets from the read-only
|
||||
# credential at [github] token_file - one every session gets regardless of
|
||||
# which forge is selected - and which a forge exporting the same name takes over
|
||||
# for the launch, since a single variable can only carry one token. Said out
|
||||
# loud rather than left as two configured credentials with one silently
|
||||
# ignored: an operator who set both is entitled to know which is in effect. A
|
||||
# warning, not a refusal - the forge's value winning is exactly what
|
||||
# selecting that forge asked for.
|
||||
#
|
||||
# Driven off the manifest's env_export rather than off the forge's name: the
|
||||
# collision is a property of the variable a forge exports, identical for any
|
||||
# forge that exports it, and core has no business knowing which forge that is
|
||||
# today. Checked here, ahead of --print-config's own exit below (like
|
||||
# _warn_legacy_env above it) rather than down by the GH_TOKEN block further
|
||||
# down that --print-config never reaches, so --print-config reports it too.
|
||||
#
|
||||
# Both files, not just core's: a forge key resolving to nothing is the
|
||||
# "wholly unconfigured" case forge_env_exports deliberately emits no pair for
|
||||
# (see its own comment), leaving core's own value in effect - so claiming the
|
||||
# forge's key won would be false in exactly that state. The warning only
|
||||
# means something once both are actually set.
|
||||
_warn_forge_env_collisions() {
|
||||
local forge="$1" pair name ref core core_name core_section core_leaf
|
||||
local core_file forge_file
|
||||
for pair in $(manifest_get forge "$forge" env_export); do
|
||||
name="${pair%%=*}"
|
||||
ref="${pair#*=}"
|
||||
# Only the "@" form (the CONTENTS of the file a config key names) can
|
||||
# carry one of core's own credentials.
|
||||
[ "${ref:0:1}" = "@" ] || continue
|
||||
# NAME=<core section> <core key>, for every variable core fills from
|
||||
# the config file itself.
|
||||
for core in "GH_TOKEN=github token_file"; do
|
||||
core_name="${core%%=*}"
|
||||
[ "$name" = "$core_name" ] || continue
|
||||
core_section="${core#*=}"
|
||||
core_leaf="${core_section##* }"
|
||||
core_section="${core_section%% *}"
|
||||
core_file="$(config_get "$core_section" "$core_leaf")"
|
||||
forge_file="$(config_get "forge.$forge" "${ref:1}")"
|
||||
[ -f "$core_file" ] && [ -s "$core_file" ] || continue
|
||||
[ -f "$forge_file" ] && [ -s "$forge_file" ] || continue
|
||||
echo "Note: [$core_section] $core_leaf is not used while [core] forge = $forge." >&2
|
||||
echo "$name comes from [forge.$forge] ${ref:1} for this launch." >&2
|
||||
done
|
||||
done
|
||||
}
|
||||
# Guarded on config_get succeeding: an invalid [core] forge already made
|
||||
# config_get print the real error (via config_validate) and return empty,
|
||||
# so feeding that empty value on to _warn_forge_env_collisions would only
|
||||
# add a second, spurious "invalid forge name:" line on top of it.
|
||||
if _core_forge="$(config_get core forge)"; then
|
||||
_warn_forge_env_collisions "$_core_forge"
|
||||
fi
|
||||
|
||||
# --print-config resolves the whole precedence chain and exits, before any
|
||||
# workspace argument is required and before anything is built or mounted.
|
||||
# It exists for two reasons: it answers "one config file now - what is
|
||||
@@ -173,14 +229,8 @@ if [ "$PRINT_CONFIG" = 1 ]; then
|
||||
fi
|
||||
|
||||
# The selected forge/workflow/harness combination, resolved and validated
|
||||
# before anything is built or mounted. provider_validate_selection enforces
|
||||
# rules 1, 2 and 6 as refusals and rule 5 (a partly-configured forge) as a
|
||||
# warning - see its definition. Rule 3 ("a pushing workflow needs a forge
|
||||
# that resolves to a host") is deliberately NOT enforced here: it is
|
||||
# deferred to Phase 3, which adds `forge = none` as the honest way to say
|
||||
# "no forge" - enforcing it now would refuse the supported "I don't use a
|
||||
# forge" configuration that git-push-guard.sh already handles at runtime
|
||||
# (see README "Skip this if you only push to GitHub").
|
||||
# before anything is built or mounted - see provider_validate_selection's own
|
||||
# definition for the full set of rules it enforces, all as refusals.
|
||||
DEJIMA_FORGE_NAME="$(config_get core forge)"
|
||||
DEJIMA_WORKFLOW_NAME="$(config_get core workflow)"
|
||||
DEJIMA_HARNESS_NAME="$(config_get core harness)"
|
||||
@@ -299,19 +349,50 @@ if [ "$PRINT_BRIEFING" = 1 ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if ! DEJIMA_FORGE_HOSTS="$(forge_hosts "$DEJIMA_FORGE_NAME")"; then
|
||||
if ! _forge_all_hosts="$(forge_hosts "$DEJIMA_FORGE_NAME")"; then
|
||||
exit 1
|
||||
fi
|
||||
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.
|
||||
#
|
||||
# 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
|
||||
# host the guard doesn't know is refused inside the container regardless of
|
||||
# how the packets would have been routed.
|
||||
ALLOW_CORE_PUSH_GRANT="$ALLOW_GITHUB_PUSH_FLAG"
|
||||
if [ "$(manifest_get forge "$DEJIMA_FORGE_NAME" hosts_allowlisted_by_core)" = "on" ]; then
|
||||
DEJIMA_FORGE_HOSTS=""
|
||||
# And pushing to them therefore goes through core's own push grant
|
||||
# (ALLOW_GITHUB_PUSH below), which opens port 22 to the same ranges the
|
||||
# read-only path already allows on 443 - logged, self-checked, and the
|
||||
# documented way that port opens. Only when the workflow actually pushes:
|
||||
# a non-pushing workflow has no reason to open it.
|
||||
if [ "$DEJIMA_WORKFLOW_PUSH" = "on" ]; then
|
||||
ALLOW_CORE_PUSH_GRANT=1
|
||||
fi
|
||||
else
|
||||
DEJIMA_FORGE_HOSTS="$_forge_all_hosts"
|
||||
fi
|
||||
if [ "$DEJIMA_WORKFLOW_PUSH" = "on" ]; then
|
||||
DEJIMA_PUSH_HOSTS="$DEJIMA_FORGE_HOSTS"
|
||||
DEJIMA_PUSH_HOSTS="$_forge_all_hosts"
|
||||
else
|
||||
DEJIMA_PUSH_HOSTS=""
|
||||
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 (which nothing
|
||||
# reads anymore now that the guard is forge/workflow-driven).
|
||||
# reads anymore now that the guard is forge/workflow-driven). 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
|
||||
# the guard's list carrying each name two or three times over.
|
||||
if [ "$ALLOW_GITHUB_PUSH_FLAG" = 1 ]; then
|
||||
DEJIMA_PUSH_HOSTS="${DEJIMA_PUSH_HOSTS:+$DEJIMA_PUSH_HOSTS }github.com ssh.github.com"
|
||||
fi
|
||||
@@ -607,7 +688,7 @@ cleanup() {
|
||||
# STATE is dropped from the rm -f above and freed only after compare has
|
||||
# read it, so this function's own cleanup can't race the audit that
|
||||
# needs it - and its removal is guarded too, so it stays truly last.
|
||||
"$SCRIPT_DIR/dejima-audit" compare "$WORKSPACE_DIR" "$SANDBOX_AUDIT_STATE" || true
|
||||
DEJIMA_RW_MOUNTS="$DEJIMA_RW_MOUNTS" "$SCRIPT_DIR/dejima-audit" compare "$WORKSPACE_DIR" "$SANDBOX_AUDIT_STATE" || true
|
||||
rm -f "$SANDBOX_AUDIT_STATE" || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
@@ -652,18 +733,19 @@ export SANDBOX_SSH_KEY_FILE="$SANDBOX_SSH_KEY"
|
||||
export SSH_KNOWN_HOSTS="$(config_get core known_hosts)"
|
||||
export GH_TOKEN="${GH_TOKEN:-}"
|
||||
export ALLOWED_DOMAINS="${ALLOWED_DOMAINS:-}"
|
||||
# --allow-github-push is the only way to grant this - same principle as
|
||||
# ALLOWED_DOMAINS above: this opens a push path, so an exported value already
|
||||
# sitting in the operator's shell profile must not be able to silently grant
|
||||
# it, with nothing recording that it did.
|
||||
if [ "$ALLOW_GITHUB_PUSH_FLAG" = 1 ]; then
|
||||
# The --allow-github-push flag and a selected forge whose hosts_allowlisted_by_core
|
||||
# says these are its own push hosts (see ALLOW_CORE_PUSH_GRANT above) are the
|
||||
# only two ways to grant this - same principle as ALLOWED_DOMAINS above: this
|
||||
# opens a push path, so an exported value already sitting in the operator's
|
||||
# shell profile must not be able to silently grant it, with nothing recording
|
||||
# that it did. Both ways in are recorded: one in the command that used the
|
||||
# flag, the other in the config file that selected the forge.
|
||||
if [ "$ALLOW_CORE_PUSH_GRANT" = 1 ]; then
|
||||
export ALLOW_GITHUB_PUSH=1
|
||||
else
|
||||
# Plain assignment, not "${ALLOW_GITHUB_PUSH:-0}" - same reason
|
||||
# ALLOWED_DOMAINS above is plain. This opens a push path, so an exported
|
||||
# value in a shell profile must not be able to grant it silently; the
|
||||
# --allow-github-push flag is the only way in, and it is recorded in the
|
||||
# command that used it.
|
||||
# value in a shell profile must not be able to grant it silently.
|
||||
export ALLOW_GITHUB_PUSH=0
|
||||
fi
|
||||
export TZ="${TZ:-$(readlink -f /etc/localtime 2>/dev/null | sed 's#.*/zoneinfo/##')}"
|
||||
@@ -810,7 +892,10 @@ fi
|
||||
# DEJIMA_RW_MOUNTS is deliberately not forwarded into the container as an
|
||||
# environment variable: hooks/sandbox-context.sh no longer reads it (the
|
||||
# writable-mounts sentence reaches the session through the composed briefing
|
||||
# instead), so passing it would just be a value nothing inside consumes.
|
||||
# instead), so passing it would just be a value nothing inside consumes. It
|
||||
# IS forwarded the other direction, host-side, to the exit-time dejima-audit
|
||||
# invocation in cleanup() below - that report's header names these paths so
|
||||
# an operator reading any finding knows what else the session could reach.
|
||||
for path in ${RW_MOUNT_PATHS[@]+"${RW_MOUNT_PATHS[@]}"}; do
|
||||
extra_mounts+=(-v "$path:$path")
|
||||
done
|
||||
@@ -1010,6 +1095,25 @@ if remote_enabled; then
|
||||
DEJIMA_NOTIFY_DIR="$DEJIMA_STATE_DIR/notify/$DEJIMA_RUN_ID"
|
||||
mkdir -p -m 700 "$DEJIMA_NOTIFY_DIR"
|
||||
|
||||
# Verify what was just created, because everything downstream assumes it.
|
||||
# A leaf left behind root-owned by an earlier container restart (see
|
||||
# entrypoint.sh) is the shape this catches: mkdir -p succeeds against an
|
||||
# existing directory regardless of who owns it, so without this the mount
|
||||
# goes ahead and the failure surfaces later as EACCES in a hook.
|
||||
#
|
||||
# A warning rather than a refusal. Remote access is opt-in, and the rest
|
||||
# of the session works without it - blocking a launch over undeliverable
|
||||
# notifications would cost more than it saves. The neighbouring state-dir
|
||||
# check does refuse, but that one is about a symlink handing a session an
|
||||
# attacker-chosen path; this one is only about the feature working.
|
||||
if [ ! -O "$DEJIMA_NOTIFY_DIR" ] || [ ! -w "$DEJIMA_NOTIFY_DIR" ]; then
|
||||
echo "warning: $DEJIMA_NOTIFY_DIR is not a directory this user owns and can write." >&2
|
||||
echo "Remote access is enabled, so notifications and dejima-attach will not work for" >&2
|
||||
echo "this session. A leftover root-owned directory here is usually Docker's doing," >&2
|
||||
echo "from a container that was restarted rather than relaunched; removing it and" >&2
|
||||
echo "starting again clears it." >&2
|
||||
fi
|
||||
|
||||
# $TMUX_PANE is set by tmux in the launching shell. Empty when the
|
||||
# sandbox wasn't started from tmux: the session is still registered, just
|
||||
# listed as not-attachable, rather than silently omitted.
|
||||
|
||||
+64
-46
@@ -6,38 +6,44 @@
|
||||
# actually needs, it should tell you the exact command to run here rather
|
||||
# than try to work around the firewall.
|
||||
#
|
||||
# Usage: dejima-allow <domain> [--global] [--project <path>] [--container <name>]
|
||||
# dejima-allow crates.io # this running session only
|
||||
# dejima-allow crates.io --global # + persist for every project
|
||||
# dejima-allow crates.io --project ~/myproj # + persist for one project
|
||||
# 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
|
||||
#
|
||||
# --project and --global are approvals for future sessions and deliberately do
|
||||
# not touch a running container: a scope flag means "record this", not "patch
|
||||
# the thing running right now". Pass --container as well to do both.
|
||||
set -euo pipefail
|
||||
|
||||
domain=""
|
||||
global=0
|
||||
project=""
|
||||
container=""
|
||||
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.
|
||||
# shellcheck source=../docker/fixed-domains.sh
|
||||
source "$SCRIPT_DIR/../docker/fixed-domains.sh"
|
||||
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--global) global=1; shift ;;
|
||||
--project) project="$2"; shift 2 ;;
|
||||
--container) container="$2"; shift 2 ;;
|
||||
-*) echo "Unknown option: $1" >&2; exit 1 ;;
|
||||
*)
|
||||
if [ -n "$domain" ]; then
|
||||
echo "Usage: dejima-allow <domain> [--global] [--project <path>] [--container <name>]" >&2
|
||||
exit 1
|
||||
fi
|
||||
domain="$1"
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ -z "$domain" ]; then
|
||||
echo "Usage: dejima-allow <domain> [--global] [--project <path>] [--container <name>]" >&2
|
||||
usage() {
|
||||
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.
|
||||
parse_scope_args 1 "$@" || usage
|
||||
[ "${#dj_positional[@]}" -eq 1 ] || usage
|
||||
domain="${dj_positional[0]}"
|
||||
global="$dj_global"
|
||||
project=""
|
||||
if [ "$dj_project_set" -eq 1 ]; then
|
||||
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/...
|
||||
@@ -58,16 +64,21 @@ fi
|
||||
# probe below would refuse any declared domain that legitimately doesn't serve
|
||||
# HTTPS on 443. The two scopes are independent, not exclusive: both may be
|
||||
# given together, and each persists to its own store.
|
||||
if [ -n "$project" ] || [ "$global" -eq 1 ]; then
|
||||
SCRIPT_DIR="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
|
||||
# shellcheck source=dejima-project-lib.sh
|
||||
source "$SCRIPT_DIR/dejima-project-lib.sh"
|
||||
fi
|
||||
if [ -n "$project" ]; then
|
||||
project="$(cd "$project" && pwd)"
|
||||
store="$(ensure_project_store "$project")"
|
||||
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
|
||||
# set -e: the approval is what grants, and it is already written by this
|
||||
# point. Letting a declare_entry failure abort here would exit before this
|
||||
# echo ever ran, leaving the operator thinking nothing happened when a
|
||||
# domain had, in fact, just been approved for every future session.
|
||||
if declare_entry "$project" allowed-domains "$domain"; then
|
||||
echo "Declared it in $project/.dejima/allowed-domains - commit that to share it."
|
||||
else
|
||||
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
|
||||
fi
|
||||
if [ "$global" -eq 1 ]; then
|
||||
store="$(ensure_global_store)"
|
||||
@@ -75,22 +86,25 @@ if [ "$global" -eq 1 ]; then
|
||||
echo "Added $domain to the global approved domains (applies to every future session)."
|
||||
fi
|
||||
|
||||
# The live-session update below needs a running container to exec into.
|
||||
# Skipped only by the test suite (test/project.bats), which exercises
|
||||
# --project persistence on its own, without a container.
|
||||
if [ "${DEJIMA_ALLOW_SKIP_SESSION:-0}" != "1" ]; then
|
||||
# A scope flag means "record this approval", not "patch what is running now" -
|
||||
# so a --project/--global invocation never goes near Docker, and stops
|
||||
# reporting on containers the caller did not ask about. --container is the
|
||||
# explicit opt-in to doing both.
|
||||
#
|
||||
# This is the only thing that decides. A DEJIMA_ALLOW_SKIP_SESSION env var
|
||||
# used to sit alongside it as a test-only escape hatch; session_wanted made it
|
||||
# redundant, and a firewall CLI with an environment-triggered no-op path is
|
||||
# not something to keep for no reason.
|
||||
session_wanted=1
|
||||
if [ -n "$project" ] || [ "$global" -eq 1 ]; then
|
||||
[ -n "$container" ] || session_wanted=0
|
||||
fi
|
||||
if [ "$session_wanted" -eq 1 ]; then
|
||||
if [ -z "$container" ]; then
|
||||
containers=$(docker ps --filter "label=com.docker.compose.project=dejima" --filter "status=running" --format '{{.Names}}')
|
||||
count=$(echo "$containers" | grep -c . || true)
|
||||
if [ "$count" -eq 0 ]; then
|
||||
echo "No running dejima container found." >&2
|
||||
# With --project or --global (or both) there is nothing left to
|
||||
# do and nothing went wrong: the approval is already recorded and
|
||||
# applies from the next launch.
|
||||
if [ -n "$project" ] || [ "$global" -eq 1 ]; then
|
||||
echo "Nothing to patch for this session; $domain applies from the next launch." >&2
|
||||
exit 0
|
||||
fi
|
||||
exit 1
|
||||
elif [ "$count" -gt 1 ]; then
|
||||
echo "Multiple running dejima containers found; specify one with --container:" >&2
|
||||
@@ -125,9 +139,13 @@ if [ "${DEJIMA_ALLOW_SKIP_SESSION:-0}" != "1" ]; then
|
||||
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)"
|
||||
docker exec -u root "$container" ipset add allowed-domains "$ip" -exist
|
||||
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)."
|
||||
|
||||
@@ -1193,6 +1193,15 @@ case "$cmd" in
|
||||
echo "Changes below happened while this session ran - not necessarily because"
|
||||
echo "of it. A concurrent dejima/Claude Code session, or your own edits during"
|
||||
echo "the run, land in this same diff; see docs/security.md."
|
||||
# Named, never hashed. Writing to a writable mount is the entire
|
||||
# point of configuring one, so a finding per write would fire on
|
||||
# every legitimate use and train the reader to skim - the exact
|
||||
# failure the audit's severity tiers exist to avoid. What the
|
||||
# operator needs instead is the blast radius: reading any finding
|
||||
# below, they should know what else this session could reach.
|
||||
if [ -n "${DEJIMA_RW_MOUNTS:-}" ]; then
|
||||
echo "also writable by this session, not hashed: $(sanitize "$DEJIMA_RW_MOUNTS")"
|
||||
fi
|
||||
[ "${#crit[@]}" -gt 0 ] && printf 'CRITICAL %s\n' "${crit[@]}"
|
||||
[ "${#warn[@]}" -gt 0 ] && printf 'WARNING %s\n' "${warn[@]}"
|
||||
[ "${#info[@]}" -gt 0 ] && printf 'INFO %s\n' "${info[@]}"
|
||||
@@ -1256,6 +1265,15 @@ case "$cmd" in
|
||||
echo " dejima: host-persistence surfaces changed in"
|
||||
echo " $(sanitize "$ws") while this session ran"
|
||||
echo "----------------------------------------------------------------"
|
||||
# Named, never hashed. Writing to a writable mount is the entire
|
||||
# point of configuring one, so a finding per write would fire on
|
||||
# every legitimate use and train the reader to skim - the exact
|
||||
# failure the audit's severity tiers exist to avoid. What the
|
||||
# operator needs instead is the blast radius: reading any finding
|
||||
# below, they should know what else this session could reach.
|
||||
if [ -n "${DEJIMA_RW_MOUNTS:-}" ]; then
|
||||
echo " also writable by this session, not hashed: $(sanitize "$DEJIMA_RW_MOUNTS")"
|
||||
fi
|
||||
if [ "${#info[@]}" -gt 0 ]; then
|
||||
echo " ${#info[@]} routine file change(s) - not listed"
|
||||
fi
|
||||
|
||||
@@ -46,8 +46,8 @@
|
||||
_config_table() {
|
||||
cat <<'EOF'
|
||||
core.harness provider:harness claude-code
|
||||
core.forge provider:forge gitea
|
||||
core.workflow provider:workflow worktree-pr
|
||||
core.forge provider:forge none
|
||||
core.workflow provider:workflow local-only
|
||||
core.command identifier claude
|
||||
core.firewall bool on
|
||||
core.share_dir path ~/.local/share/dejima/shared
|
||||
|
||||
+68
-23
@@ -5,12 +5,15 @@
|
||||
# grant itself one - the same split bin/dejima-allow uses for
|
||||
# domains.
|
||||
#
|
||||
# Usage: dejima-dep <add|remove|list> [<spec>] [--global | --project <path>]
|
||||
# dejima-dep add cargo:cargo-nextest --project ~/myproject
|
||||
# dejima-dep add cargo:cargo-nextest --global
|
||||
# Usage: dejima-dep <add|remove|list> [--global | --project [path]] [<spec>]
|
||||
# dejima-dep add --project ~/myproject cargo:cargo-nextest
|
||||
# dejima-dep add --project cargo:cargo-nextest # the project in $PWD
|
||||
# dejima-dep add --global cargo:cargo-nextest
|
||||
# dejima-dep list
|
||||
#
|
||||
# A spec is <apt|npm|pip|cargo>:<name>. The dependencies actually installed
|
||||
# A spec is <apt|npm|pip|cargo>:<name>, or tarball:<name>@<sha256>:<url> for a
|
||||
# release archive no package ecosystem ships in a usable version (a current Go
|
||||
# toolchain, say). The dependencies actually installed
|
||||
# for a project are the union of its global and project approvals.
|
||||
set -euo pipefail
|
||||
|
||||
@@ -23,31 +26,44 @@ spec=""
|
||||
project=""
|
||||
global=0
|
||||
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--global) global=1; shift ;;
|
||||
--project) project="$2"; shift 2 ;;
|
||||
-*) echo "Unknown option: $1" >&2; exit 1 ;;
|
||||
*)
|
||||
if [ -z "$action" ]; then action="$1"
|
||||
elif [ -z "$spec" ]; then spec="$1"
|
||||
else echo "Unexpected argument: $1" >&2; exit 1
|
||||
fi
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
usage() {
|
||||
echo "Usage: dejima-dep <add|remove|list> [<spec>] [--global | --project <path>]" >&2
|
||||
echo "Usage: dejima-dep <add|remove|list> [--global | --project [path]] [<spec>]" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
[ -n "$action" ] || usage
|
||||
# The action is read first so the required trailing count is known before
|
||||
# parsing the rest: add/remove need a spec after any --project path, list does
|
||||
# not - and that count is exactly what decides whether a token after --project
|
||||
# is the path or the spec.
|
||||
case "${1:-}" in
|
||||
add|remove) action="$1"; shift; required=1 ;;
|
||||
list) action="$1"; shift; required=0 ;;
|
||||
*) usage ;;
|
||||
esac
|
||||
|
||||
parse_scope_args "$required" "$@" || usage
|
||||
case "${#dj_positional[@]}" in
|
||||
0) spec="" ;;
|
||||
1) spec="${dj_positional[0]}" ;;
|
||||
*) usage ;;
|
||||
esac
|
||||
global="$dj_global"
|
||||
[ "$dj_project_set" -eq 0 ] || project="${dj_project:-$PWD}"
|
||||
|
||||
# The shared parser knows --container because dejima-allow needs it. Before
|
||||
# that parser existed this CLI answered "Unknown option" here, and silently
|
||||
# accepting the flag now would be worse than the duplication it replaced: a
|
||||
# caller who believes they scoped an approval to one session would get project
|
||||
# scope - a persistent grant - and a zero exit telling them they were right.
|
||||
if [ -n "$dj_container" ]; then
|
||||
echo "dejima-dep has no --container scope: a dependency needs an image" >&2
|
||||
echo "rebuild, so there is nothing to patch in a live session." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# An entry belongs to exactly one scope; silently picking one would misplace
|
||||
# an approval.
|
||||
if [ "$global" -eq 1 ] && [ -n "$project" ]; then
|
||||
if [ "$global" -eq 1 ] && [ "$dj_project_set" -eq 1 ]; then
|
||||
echo "--global and --project are mutually exclusive." >&2
|
||||
exit 1
|
||||
fi
|
||||
@@ -56,6 +72,7 @@ fi
|
||||
# put a misleading $PWD-derived path in error messages.
|
||||
if [ "$global" -eq 0 ]; then
|
||||
project="$(cd "${project:-$PWD}" && pwd)"
|
||||
disclose_project_symlink "$project"
|
||||
fi
|
||||
|
||||
case "$action" in
|
||||
@@ -70,7 +87,7 @@ case "$action" in
|
||||
;;
|
||||
add)
|
||||
[ -n "$spec" ] || usage
|
||||
valid_dep_spec "$spec" || { echo "Not a valid dependency spec: $spec" >&2; echo "Expected <apt|npm|pip|cargo>:<name>" >&2; exit 1; }
|
||||
valid_dep_spec "$spec" || { echo "Not a valid dependency spec: $spec" >&2; echo "Expected <apt|npm|pip|cargo>:<name>, or tarball:<name>@<sha256>:<url>" >&2; exit 1; }
|
||||
if [ "$global" -eq 1 ]; then
|
||||
store="$(ensure_global_store)"
|
||||
approve_entry "$store" dependencies "$spec"
|
||||
@@ -79,6 +96,18 @@ case "$action" in
|
||||
store="$(ensure_project_store "$project")"
|
||||
approve_entry "$store" dependencies "$spec"
|
||||
echo "Approved $spec for $project."
|
||||
# Printed only after the approval above, and guarded rather than
|
||||
# left to set -e: the approval is what grants, and it is already
|
||||
# written by this point. Letting a declare_entry failure abort
|
||||
# here would exit before this echo ever ran, leaving the operator
|
||||
# thinking nothing happened when a spec had, in fact, just been
|
||||
# approved.
|
||||
if declare_entry "$project" dependencies "$spec"; then
|
||||
echo "Declared it in $project/.dejima/dependencies - commit that to share it."
|
||||
else
|
||||
echo "The approval above IS recorded; only the project's own .dejima" >&2
|
||||
echo "declaration could not be written." >&2
|
||||
fi
|
||||
fi
|
||||
# Unlike dejima-allow, this cannot touch a running session:
|
||||
# it needs an image rebuild. Say so, or an agent will keep insisting
|
||||
@@ -101,6 +130,22 @@ case "$action" in
|
||||
[ -n "$store" ] || { echo "No approved config for $project." >&2; exit 1; }
|
||||
unapprove_entry "$store" dependencies "$spec"
|
||||
echo "Removed $spec from $project (takes effect on the next launch)."
|
||||
# `add --project` writes a declaration; this deliberately does not
|
||||
# take one back, since withdrawing one machine's approval is not
|
||||
# the same decision as editing a file the whole team shares. That
|
||||
# asymmetry used to be invisible and self-undoing: the entry goes
|
||||
# back to `pending`, the session briefing reports it missing, and
|
||||
# the next `dejima-project approve` re-approves it - so say where
|
||||
# the durable copy is rather than letting the removal look final.
|
||||
# -f before the grep, not a bare grep: this reads a path the
|
||||
# sandboxed session owns, and a FIFO planted there would hang the
|
||||
# command outright.
|
||||
if [ -f "$project/.dejima/dependencies" ] &&
|
||||
grep -qxF "$spec" "$project/.dejima/dependencies" 2>/dev/null; then
|
||||
echo "$project/.dejima/dependencies still declares it - this command does"
|
||||
echo "not edit the project's committed declaration, so dejima-project"
|
||||
echo "approve will offer it again. Edit that file to drop it for good."
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
*) usage ;;
|
||||
|
||||
+124
-21
@@ -20,17 +20,39 @@ project="$(cd "$project" && pwd)"
|
||||
decl_dir="$project/.dejima"
|
||||
store="$(resolve_project_store "$project")"
|
||||
|
||||
approved_of() {
|
||||
local kind="$1"
|
||||
approved_entries "$(config_root)" "$kind" || return 1
|
||||
# Approved entries for one kind, tagged with the store they came from, so
|
||||
# status can say where a grant actually lives rather than merging both into an
|
||||
# anonymous list.
|
||||
#
|
||||
# approved_entries's exit status is captured from a plain command
|
||||
# substitution, not from a `while read < <(...)` piped straight into the tag
|
||||
# loop - a corrupt store that makes approved_entries fail before printing
|
||||
# anything leaves that pattern's while loop with zero iterations, and a while
|
||||
# loop that never ran its body reports success regardless of what the command
|
||||
# feeding it did, which would turn a corrupt-store failure into a silent
|
||||
# "nothing approved" here.
|
||||
approved_tagged() {
|
||||
local kind="$1" entries e
|
||||
entries="$(approved_entries "$(config_root)" "$kind")" || return 1
|
||||
while IFS= read -r e; do
|
||||
[ -n "$e" ] || continue
|
||||
printf 'global %s\n' "$e"
|
||||
done <<< "$entries"
|
||||
[ -n "$store" ] || return 0
|
||||
approved_entries "$store" "$kind"
|
||||
entries="$(approved_entries "$store" "$kind")" || return 1
|
||||
while IFS= read -r e; do
|
||||
[ -n "$e" ] || continue
|
||||
printf 'project %s\n' "$e"
|
||||
done <<< "$entries"
|
||||
return 0
|
||||
}
|
||||
|
||||
# Prints "<state> <entry>" per declared entry: approved / pending / rejected.
|
||||
# Used by `approve`, which acts on pending entries only and has no use for the
|
||||
# approved-but-undeclared ones `report` (below) exists to surface.
|
||||
classify() {
|
||||
local kind="$1" validator="$2" approved state entry
|
||||
approved="$(approved_of "$kind")" || return 1
|
||||
approved="$(approved_tagged "$kind" | cut -d' ' -f2-)" || return 1
|
||||
while read -r state entry; do
|
||||
case "$state" in
|
||||
rejected) printf 'rejected %s\n' "$entry" ;;
|
||||
@@ -47,16 +69,62 @@ classify() {
|
||||
return 0
|
||||
}
|
||||
|
||||
# The status view: the union of what the project declares and what is
|
||||
# actually approved. Declarations alone were the bug (issue #61) - an
|
||||
# approval recorded with `dejima-allow --project` has no declaration behind
|
||||
# it and was therefore invisible here, which reads as "the command did
|
||||
# nothing".
|
||||
#
|
||||
# Entries are already validated (read_declared/approved_entries refuse
|
||||
# anything else), so the plain string comparisons below are enough - no
|
||||
# entry can contain a character that would make one domain/spec look like
|
||||
# another.
|
||||
report() {
|
||||
local kind="$1" validator="$2" approved state entry src tag e seen=""
|
||||
approved="$(approved_tagged "$kind")" || return 1
|
||||
while read -r state entry; do
|
||||
case "$state" in
|
||||
rejected) printf ' rejected %s\n' "$entry" ;;
|
||||
truncated) printf ' truncated %s entries declared; only the first %s considered\n' \
|
||||
"$entry" "$DEJIMA_MAX_DECLARED" ;;
|
||||
ok)
|
||||
src=""
|
||||
while read -r tag e; do
|
||||
[ "$e" = "$entry" ] || continue
|
||||
src="$tag"
|
||||
break
|
||||
done <<< "$approved"
|
||||
if [ -n "$src" ]; then
|
||||
printf ' approved %s (%s)\n' "$entry" "$src"
|
||||
seen="$seen$entry"$'\n'
|
||||
else
|
||||
printf ' pending %s (declared, not approved)\n' "$entry"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
done < <(read_declared "$decl_dir/$kind" "$validator")
|
||||
# Approvals with no declaration behind them - the previously invisible case.
|
||||
# seen accumulates here too, or an entry approved in both stores (global
|
||||
# and project) and undeclared in either would print once per store.
|
||||
while read -r tag e; do
|
||||
[ -n "$e" ] || continue
|
||||
grep -qxF "$e" <<< "$seen" && continue
|
||||
printf ' approved %s (%s, not declared)\n' "$e" "$tag"
|
||||
seen="$seen$e"$'\n'
|
||||
done <<< "$approved"
|
||||
return 0
|
||||
}
|
||||
|
||||
case "$action" in
|
||||
status)
|
||||
echo "Project: $project"
|
||||
echo "Approvals: ${store:-<none yet>}"
|
||||
echo "Project approvals: ${store:-<none yet>}"
|
||||
echo "Global approvals: $(config_root)"
|
||||
echo
|
||||
echo "dependencies:"
|
||||
classify dependencies valid_dep_spec | sed 's/^/ /'
|
||||
report dependencies valid_dep_spec
|
||||
echo "allowed-domains:"
|
||||
classify allowed-domains valid_domain | sed 's/^/ /'
|
||||
report allowed-domains valid_domain
|
||||
;;
|
||||
approve)
|
||||
pending_deps=$(classify dependencies valid_dep_spec | sed -n 's/^pending //p')
|
||||
@@ -89,23 +157,58 @@ case "$action" in
|
||||
y|Y) ;;
|
||||
*) echo "Nothing approved."; exit 0 ;;
|
||||
esac
|
||||
# Both loops collect instead of letting set -e abort. Neither CLI is
|
||||
# called with --container, so neither touches a running container and
|
||||
# there is no live patch to succeed or fail here - but each can still
|
||||
# fail on its own (an unwritable store, a slug collision, a corrupt
|
||||
# approval list), and a bare call would abort the whole script on the
|
||||
# first failure, silently skipping every remaining entry - the deps
|
||||
# after it AND all the domains - with no summary at all. A failed
|
||||
# declaration is deliberately NOT in that list: both CLIs swallow one
|
||||
# and still exit 0, because the approval is what grants and it is
|
||||
# already written by then. What a failure MEANS is the
|
||||
# other half: the approval itself did not happen, so neither report
|
||||
# below may repeat the old "approved, but not applied to a running
|
||||
# session" line, which would call an entry approved when nothing was
|
||||
# recorded.
|
||||
dep_failed=""
|
||||
dep_approved=0
|
||||
for spec in $pending_deps; do
|
||||
"$SCRIPT_DIR/dejima-dep" add "$spec" --project "$project"
|
||||
if "$SCRIPT_DIR/dejima-dep" add --project "$project" "$spec"; then
|
||||
dep_approved=1
|
||||
else
|
||||
dep_failed="$dep_failed $spec"
|
||||
fi
|
||||
done
|
||||
# dejima-allow records the approval before it touches a
|
||||
# running session, so a nonzero exit here means only that the live
|
||||
# patch or its reachability probe didn't succeed - a declared host
|
||||
# that doesn't serve HTTPS on 443, say. Under set -e that used to
|
||||
# abort the loop and silently skip every remaining domain, so collect
|
||||
# and report instead.
|
||||
unapplied=""
|
||||
failed=""
|
||||
dom_approved=0
|
||||
for dom in $pending_doms; do
|
||||
"$SCRIPT_DIR/dejima-allow" "$dom" --project "$project" || unapplied="$unapplied $dom"
|
||||
if "$SCRIPT_DIR/dejima-allow" --project "$project" "$dom"; then
|
||||
dom_approved=1
|
||||
else
|
||||
failed="$failed $dom"
|
||||
fi
|
||||
done
|
||||
if [ -n "$unapplied" ]; then
|
||||
echo
|
||||
echo "Approved for $project, but not applied to a running session:$unapplied" >&2
|
||||
echo "They take effect at the next launch either way." >&2
|
||||
echo
|
||||
if [ -n "$dep_failed" ]; then
|
||||
echo "NOT approved for $project (dejima-dep failed):$dep_failed" >&2
|
||||
echo "Fix the cause and re-run; nothing was recorded for those." >&2
|
||||
fi
|
||||
if [ -n "$failed" ]; then
|
||||
echo "NOT approved for $project (dejima-allow failed):$failed" >&2
|
||||
echo "Fix the cause and re-run; nothing was recorded for those." >&2
|
||||
fi
|
||||
# Only when something actually landed: a run that failed every entry
|
||||
# must not follow "NOT approved" with a line that reads as success.
|
||||
if [ "$dep_approved" -eq 1 ] || [ "$dom_approved" -eq 1 ]; then
|
||||
echo "Restart the sandbox for the approved entries to take effect."
|
||||
fi
|
||||
# `dejima-project approve ~/p && dejima` is the obvious way to chain
|
||||
# these, and exiting 0 after recording nothing would walk that chain
|
||||
# straight into a session with none of the approvals the user just
|
||||
# confirmed.
|
||||
if [ -n "$dep_failed" ] || [ -n "$failed" ]; then
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
|
||||
+307
-6
@@ -9,7 +9,10 @@
|
||||
# compromised session can write these. They grant nothing; they are read
|
||||
# only to compare against reality and suggest a command for the user to
|
||||
# run. Parsed, never sourced - executing a file on a session-writable
|
||||
# mount is a host-shell persistence path.
|
||||
# mount is a host-shell persistence path. declare_entry also WRITES one,
|
||||
# which is why it refuses to follow a symlink: a host-side write onto a
|
||||
# session-writable path is the one direction this split does not otherwise
|
||||
# cover.
|
||||
#
|
||||
# ~/.config/dejima/{allowed-domains,dependencies} - a GLOBAL
|
||||
# APPROVAL, applying to every project.
|
||||
@@ -35,7 +38,49 @@ DEJIMA_MAX_DECLARED=200
|
||||
# generated RUN line as an option instead, quietly weakening apt/pip/npm/
|
||||
# cargo's own signature/lockfile checks.
|
||||
valid_dep_spec() {
|
||||
[[ "$1" =~ ^(apt|npm|pip|cargo):[A-Za-z0-9][A-Za-z0-9._+-]*$ ]]
|
||||
[[ "$1" =~ ^(apt|npm|pip|cargo):[A-Za-z0-9][A-Za-z0-9._+-]*$ ]] && return 0
|
||||
valid_tarball_spec "$1"
|
||||
}
|
||||
|
||||
# tarball:<name>@<sha256>:<url> - a release archive unpacked into the image,
|
||||
# for a toolchain no package ecosystem ships in a usable version.
|
||||
#
|
||||
# The digest is mandatory and is what makes this an approval at all. Without
|
||||
# it the operator would be approving a *name* whose contents the other end can
|
||||
# change afterwards, which is not a decision anyone can make once; with it,
|
||||
# what was approved is a specific archive and nothing else can take its place.
|
||||
# The generated build verifies it before unpacking, not after.
|
||||
#
|
||||
# The character classes are narrower than the URL grammar allows, for the same
|
||||
# reason as above: this string is interpolated into a Dockerfile RUN line that
|
||||
# runs as root. Everything shell-active is excluded rather than quoted around
|
||||
# - no $, backtick, quote, backslash, whitespace, or ;|&<>() - so there is no
|
||||
# quoting to get wrong later. https only: the digest already provides
|
||||
# integrity, but there is no reason to also accept a plaintext fetch.
|
||||
DEJIMA_MAX_TARBALL_URL=512
|
||||
valid_tarball_spec() {
|
||||
local spec="$1" rest name sha url
|
||||
[[ "$spec" == tarball:* ]] || return 1
|
||||
rest="${spec#tarball:}"
|
||||
|
||||
name="${rest%%@*}"
|
||||
[[ "$name" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]] || return 1
|
||||
# Becomes a directory under /opt/dejima; a name that walks out of it, or
|
||||
# that is a relative-path token, is not a name.
|
||||
case "$name" in .|..) return 1 ;; esac
|
||||
|
||||
rest="${rest#"$name"}"
|
||||
[[ "$rest" == @* ]] || return 1
|
||||
rest="${rest#@}"
|
||||
|
||||
sha="${rest%%:*}"
|
||||
[[ "$sha" =~ ^[a-f0-9]{64}$ ]] || return 1
|
||||
|
||||
url="${rest#"$sha"}"
|
||||
[[ "$url" == :* ]] || return 1
|
||||
url="${url#:}"
|
||||
[ ${#url} -le "$DEJIMA_MAX_TARBALL_URL" ] || return 1
|
||||
[[ "$url" =~ ^https://[A-Za-z0-9._~/?=+%-]+$ ]] || return 1
|
||||
}
|
||||
|
||||
# read_declared <file> <validator-fn>
|
||||
@@ -67,6 +112,71 @@ read_declared() {
|
||||
return 0
|
||||
}
|
||||
|
||||
# Shared argument parsing for dejima-allow and dejima-dep, so the two CLIs
|
||||
# cannot drift on what --project means.
|
||||
#
|
||||
# The hard part is that --project's path is OPTIONAL, which makes
|
||||
# `--project X` ambiguous on its own: X could be the path, or the required
|
||||
# positional. Deciding by "is X the last token" is wrong for `dejima-dep list
|
||||
# --project ~/p`, which has no trailing positional to protect and would
|
||||
# silently resolve to the current directory instead - the scope quietly
|
||||
# becoming one the caller did not name. So the decision is made against how
|
||||
# many positionals the command actually requires after the flag:
|
||||
#
|
||||
# --project takes the next token as its path only if doing so still leaves
|
||||
# the command its required trailing positional(s).
|
||||
#
|
||||
# Required counts are per-command constants declared by the caller, not
|
||||
# inferred here.
|
||||
#
|
||||
# Sets dj_global, dj_project, dj_project_set, dj_container and the
|
||||
# dj_positional array. dj_project_set exists because "--project with no path"
|
||||
# (the current directory) and "no --project at all" are different scopes and
|
||||
# an empty dj_project cannot tell them apart.
|
||||
parse_scope_args() {
|
||||
local required="$1"; shift
|
||||
local -a args=("$@")
|
||||
local n="${#args[@]}" i=0 k tok cand after
|
||||
|
||||
dj_global=0; dj_project=""; dj_project_set=0; dj_container=""
|
||||
dj_positional=()
|
||||
|
||||
while [ "$i" -lt "$n" ]; do
|
||||
tok="${args[$i]}"
|
||||
case "$tok" in
|
||||
--global) dj_global=1; i=$((i + 1)) ;;
|
||||
--container)
|
||||
cand="${args[$((i + 1))]:-}"
|
||||
if [ -z "$cand" ]; then
|
||||
echo "--container needs a container name or id." >&2
|
||||
return 1
|
||||
fi
|
||||
dj_container="$cand"; i=$((i + 2)) ;;
|
||||
--project)
|
||||
dj_project_set=1; i=$((i + 1))
|
||||
cand="${args[$i]:-}"
|
||||
case "$cand" in ''|-*) continue ;; esac
|
||||
# Count the positionals strictly after the candidate, skipping
|
||||
# --container's value - counting that as a positional would
|
||||
# make the candidate look safe to consume when it is not.
|
||||
after=0; k=$((i + 1))
|
||||
while [ "$k" -lt "$n" ]; do
|
||||
case "${args[$k]}" in
|
||||
--container) k=$((k + 2)) ;;
|
||||
-*) k=$((k + 1)) ;;
|
||||
*) after=$((after + 1)); k=$((k + 1)) ;;
|
||||
esac
|
||||
done
|
||||
if [ "$after" -ge "$required" ]; then
|
||||
dj_project="$cand"; i=$((i + 1))
|
||||
fi ;;
|
||||
-*) echo "Unknown option: $tok" >&2; return 1 ;;
|
||||
*) dj_positional+=("$tok"); i=$((i + 1)) ;;
|
||||
esac
|
||||
done
|
||||
return 0
|
||||
}
|
||||
|
||||
# The global store is config_root() itself, not a subdirectory of it - the
|
||||
# two filenames below can never collide with projects/<slug>, since a slug
|
||||
# is a sanitized absolute path and always begins with "-".
|
||||
@@ -83,6 +193,32 @@ project_slug() {
|
||||
printf '%s' "$1" | sed 's/[^a-zA-Z0-9-]/-/g'
|
||||
}
|
||||
|
||||
# $project is `cd <path> && pwd`'s LOGICAL resolution: a symlinked path
|
||||
# component's name survives in the string. declare_entry's makedirs/open, and
|
||||
# every other plain filesystem call, resolve the same path PHYSICALLY - so
|
||||
# when a component is a symlink, "$project/.dejima" and the directory that
|
||||
# actually gets created or opened are not the same place.
|
||||
#
|
||||
# A legitimate symlinked checkout (a worktree, a bind mount, a symlinked home
|
||||
# directory) looks exactly like this too, and this function cannot tell the
|
||||
# two apart - only the operator running the command can, so it discloses the
|
||||
# mismatch rather than refusing on it. Refusing would break the ordinary case
|
||||
# outright, with no way forward.
|
||||
#
|
||||
# Deliberately not used to change how $project itself is resolved: doing the
|
||||
# approval lookup/write physically as well would move the *approval* to
|
||||
# wherever the symlink targets, turning a declaration-only redirect into an
|
||||
# actual cross-project grant - strictly worse than the mismatch this discloses.
|
||||
disclose_project_symlink() {
|
||||
local logical="$1" physical
|
||||
physical="$(cd -P "$logical" && pwd -P)"
|
||||
if [ "$physical" != "$logical" ]; then
|
||||
echo "Note: $logical is reached through a symlink; it resolves to" >&2
|
||||
echo "$physical, and that is where its .dejima declaration actually" >&2
|
||||
echo "lives - not $logical." >&2
|
||||
fi
|
||||
}
|
||||
|
||||
# The store a writer must use: whatever a reader would resolve to, created
|
||||
# for this exact path only when nothing above already covers it. Creating one
|
||||
# per path unconditionally would be wrong in the workflow inheritance exists
|
||||
@@ -116,6 +252,8 @@ ensure_project_store() {
|
||||
if [ "$owner" != "$path" ]; then
|
||||
echo "Note: recorded under the existing approvals for $owner, which" >&2
|
||||
echo "$path inherits - so it applies to $owner and everything under it." >&2
|
||||
echo "Any declaration this call writes still goes to $path/.dejima -" >&2
|
||||
echo "the approval and the declaration do not have to share a directory." >&2
|
||||
fi
|
||||
printf '%s' "$existing"
|
||||
return 0
|
||||
@@ -184,6 +322,132 @@ approve_entry() {
|
||||
grep -qxF "$entry" "$store/$kind" || printf '%s\n' "$entry" >> "$store/$kind"
|
||||
}
|
||||
|
||||
# Record an entry in the project's OWN declaration - the committed, shared
|
||||
# .dejima/<kind> file. This grants nothing (see this file's header: a
|
||||
# declaration is parsed, never sourced, and lives on a session-writable
|
||||
# mount), which is exactly why it is safe to write from the host. What it buys
|
||||
# is visibility: without it, a domain approved with --project is invisible to
|
||||
# dejima-project status, to hooks/sandbox-context.sh, and to everyone else on
|
||||
# the project.
|
||||
#
|
||||
# Validated with the same dispatch approve_entry uses, so the two can never
|
||||
# disagree about what a well-formed entry is.
|
||||
declare_entry() {
|
||||
local project="$1" kind="$2" entry="$3" validator
|
||||
validator="$(_approved_validator "$kind")" || return 1
|
||||
"$validator" "$entry" || { echo "Invalid $kind entry: $entry" >&2; return 1; }
|
||||
# This is the one write in this file that lands on the session-writable
|
||||
# bind mount, so it is the one that must not follow a symlink. A
|
||||
# compromised session can plant one and then ask the user - through the
|
||||
# startup briefing, which tells it to hand over exactly this command - to
|
||||
# run the CLI that trips it. Following it would aim a host-privileged
|
||||
# append anywhere the user can write: at ~/.config/dejima/allowed-domains,
|
||||
# silently turning a project approval into a global one; at
|
||||
# ~/.config/dejima/dependencies, where the entry is installed as root at
|
||||
# image build, on the one code path with no egress firewall in front of it;
|
||||
# or at any host file at all.
|
||||
#
|
||||
# A test would not be enough. The briefing tells the agent to have the user
|
||||
# run `dejima-allow --project` from a terminal outside the sandbox, so the
|
||||
# session is normally still LIVE while this runs - and since it is the one
|
||||
# asking for the command, it picks the moment and can spin a rename loop
|
||||
# across any window between a check and the write. So there is no check:
|
||||
# the guard is the open itself. O_NOFOLLOW makes a symlink at either path
|
||||
# fail the open outright, the directory is opened O_DIRECTORY and the file
|
||||
# is opened relative to that fd, so neither can be swapped for the other's
|
||||
# sake afterwards, and the whole read-then-append happens through that one
|
||||
# fd. A session that wins the swap gets an open of the object it replaced,
|
||||
# not a redirected write.
|
||||
#
|
||||
# bash cannot open a path that way, so this is python3 - a hard dependency
|
||||
# already (install.sh refuses to install without it). O_NONBLOCK and the
|
||||
# S_ISREG check cover the non-symlink shapes: a FIFO planted here would
|
||||
# otherwise block a host CLI the user was told to run for as long as the
|
||||
# session declines to open the other end.
|
||||
python3 - "$project/.dejima" "$kind" "$entry" "$DEJIMA_MAX_DECLARED" <<'PY'
|
||||
import errno, os, stat, sys
|
||||
|
||||
directory, name, entry = sys.argv[1], sys.argv[2], sys.argv[3]
|
||||
MAX_DECLARED = int(sys.argv[4])
|
||||
# A ceiling on the read-back below, not on what a project may declare. The
|
||||
# largest legitimate file is MAX_DECLARED entries of a domain's 253 bytes plus
|
||||
# comments - tens of KiB - so this is orders of magnitude clear of anything
|
||||
# real, and exists only to stop a session-owned file from being unbounded.
|
||||
LIMIT = 1 << 20
|
||||
path = os.path.join(directory, name)
|
||||
why = (
|
||||
"A project's .dejima is writable by the sandboxed session, so a link or",
|
||||
"special file planted here would aim this host-side append at the approval",
|
||||
"store, or at any other file you can write.",
|
||||
"Inspect and remove it, then re-run.",
|
||||
)
|
||||
|
||||
|
||||
def refuse(*lines):
|
||||
for line in lines:
|
||||
sys.stderr.write(line + "\n")
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
try:
|
||||
os.makedirs(directory, 0o755)
|
||||
except FileExistsError:
|
||||
pass
|
||||
except OSError as exc:
|
||||
refuse("Could not create %s: %s" % (directory, exc.strerror))
|
||||
|
||||
try:
|
||||
dirfd = os.open(directory, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
|
||||
except OSError as exc:
|
||||
if exc.errno in (errno.ELOOP, errno.ENOTDIR):
|
||||
refuse("Refusing to write through %s: it is a symlink, or not a directory at all." % directory, *why)
|
||||
refuse("Could not open %s: %s" % (directory, exc.strerror))
|
||||
|
||||
try:
|
||||
fd = os.open(
|
||||
name,
|
||||
os.O_RDWR | os.O_CREAT | os.O_APPEND | os.O_NOFOLLOW | os.O_NONBLOCK,
|
||||
0o644,
|
||||
dir_fd=dirfd,
|
||||
)
|
||||
except OSError as exc:
|
||||
if exc.errno in (errno.ELOOP, errno.ENXIO):
|
||||
refuse("Refusing to write through %s: it is a symlink or other special file." % path, *why)
|
||||
refuse("Could not write %s: %s" % (path, exc.strerror))
|
||||
finally:
|
||||
os.close(dirfd)
|
||||
|
||||
# Before any buffered wrapper touches it: a FIFO is opened without blocking
|
||||
# thanks to O_NONBLOCK, but it is not seekable, and refusing it is the point.
|
||||
if not stat.S_ISREG(os.fstat(fd).st_mode):
|
||||
os.close(fd)
|
||||
refuse("Refusing to write to %s: it is not a regular file." % path, *why)
|
||||
|
||||
with os.fdopen(fd, "r+b") as fh:
|
||||
# Bounded, because the duplicate check has to read back a file the session
|
||||
# owns, and `truncate -s 8G` makes an arbitrarily large one at zero disk
|
||||
# cost. Reading LIMIT + 1 rather than trusting fstat's size: the file can
|
||||
# grow between the two, and this way the ceiling holds whatever it does.
|
||||
body = fh.read(LIMIT + 1)
|
||||
if len(body) > LIMIT:
|
||||
refuse(
|
||||
"Refusing to read %s back: it is larger than %d bytes." % (path, LIMIT),
|
||||
"A project's .dejima is writable by the sandboxed session, and this",
|
||||
"command has to read the file back to skip an entry already declared -",
|
||||
"so an oversized one exhausts this machine's memory instead of",
|
||||
"misdirecting the write. A real declaration is a few hundred short",
|
||||
"lines; read_declared stops reading entries at %d." % MAX_DECLARED,
|
||||
"Inspect and truncate it, then re-run.",
|
||||
)
|
||||
line = entry.encode()
|
||||
if line not in body.split(b"\n"):
|
||||
# O_APPEND, so this lands at the end whatever else has written since.
|
||||
# The leading newline is for a declaration the session left unterminated
|
||||
# - without it the append would extend that last line instead.
|
||||
fh.write((b"" if not body or body.endswith(b"\n") else b"\n") + line + b"\n")
|
||||
PY
|
||||
}
|
||||
|
||||
unapprove_entry() {
|
||||
local store="$1" kind="$2" entry="$3" tmp
|
||||
[ -f "$store/$kind" ] || return 0
|
||||
@@ -192,6 +456,40 @@ unapprove_entry() {
|
||||
mv "$tmp" "$store/$kind"
|
||||
}
|
||||
|
||||
# One RUN per tarball dependency, as root, before the drop to the sandbox
|
||||
# user. Each unpacks into its own directory under /opt/dejima and puts that
|
||||
# directory's bin/ on PATH.
|
||||
#
|
||||
# --strip-components=1 is what makes a single convention cover the real cases:
|
||||
# a Go archive has everything under go/, an nvim release under
|
||||
# nvim-linux-<arch>/, and stripping one level puts bin/ in the same place for
|
||||
# both. So the spec needs no destination or layout fields, and there is
|
||||
# nothing per-dependency for an operator to get wrong.
|
||||
#
|
||||
# The digest is checked BEFORE unpacking. A tar that is extracted first and
|
||||
# verified after has already written whatever it contained.
|
||||
#
|
||||
# -xaf rather than -xzf: compression is detected from the archive, so xz and
|
||||
# zstd releases work without the spec having to say which it is.
|
||||
_emit_tarball_layers() {
|
||||
local spec rest name sha url
|
||||
for spec in "$@"; do
|
||||
[ -n "$spec" ] || continue
|
||||
rest="${spec#tarball:}"
|
||||
name="${rest%%@*}"
|
||||
rest="${rest#"$name"@}"
|
||||
sha="${rest%%:*}"
|
||||
url="${rest#"$sha":}"
|
||||
printf 'RUN mkdir -p /opt/dejima/%s && \\\n' "$name"
|
||||
printf ' wget --tries=3 --timeout=30 --waitretry=5 --retry-connrefused \\\n'
|
||||
printf ' -O /tmp/%s.tar "%s" && \\\n' "$name" "$url"
|
||||
printf ' echo "%s /tmp/%s.tar" | sha256sum -c - && \\\n' "$sha" "$name"
|
||||
printf ' tar -C /opt/dejima/%s --strip-components=1 -xaf /tmp/%s.tar && \\\n' "$name" "$name"
|
||||
printf ' rm /tmp/%s.tar\n' "$name"
|
||||
printf 'ENV PATH=$PATH:/opt/dejima/%s/bin\n' "$name"
|
||||
done
|
||||
}
|
||||
|
||||
# Prints a Dockerfile that layers approved packages onto the shared base
|
||||
# image. Only validated tokens are interpolated (approved_entries refuses
|
||||
# anything else), so a RUN line here can't become a root-context injection.
|
||||
@@ -202,6 +500,7 @@ unapprove_entry() {
|
||||
# init-firewall.sh locks egress down.
|
||||
generate_derived_dockerfile() {
|
||||
local base="$1" store="$2" entries apt npm pip cargo spec name
|
||||
local -a tarballs=()
|
||||
entries="$(approved_entries "$store" dependencies)" \
|
||||
|| { echo "refusing to build from a corrupt dependency list in $store" >&2; return 1; }
|
||||
|
||||
@@ -210,10 +509,11 @@ generate_derived_dockerfile() {
|
||||
[ -n "$spec" ] || continue
|
||||
name="${spec#*:}"
|
||||
case "$spec" in
|
||||
apt:*) apt="$apt $name" ;;
|
||||
npm:*) npm="$npm $name" ;;
|
||||
pip:*) pip="$pip $name" ;;
|
||||
cargo:*) cargo="$cargo $name" ;;
|
||||
apt:*) apt="$apt $name" ;;
|
||||
npm:*) npm="$npm $name" ;;
|
||||
pip:*) pip="$pip $name" ;;
|
||||
cargo:*) cargo="$cargo $name" ;;
|
||||
tarball:*) tarballs+=("$spec") ;;
|
||||
esac
|
||||
done <<< "$entries"
|
||||
|
||||
@@ -221,6 +521,7 @@ generate_derived_dockerfile() {
|
||||
printf 'USER root\n'
|
||||
[ -n "$apt" ] && printf 'RUN apt-get update && apt-get install -y --no-install-recommends%s \\\n && apt-get clean && rm -rf /var/lib/apt/lists/*\n' "$apt"
|
||||
[ -n "$pip" ] && printf 'RUN pip install --break-system-packages%s\n' "$pip"
|
||||
_emit_tarball_layers "${tarballs[@]+"${tarballs[@]}"}"
|
||||
printf 'USER ${SANDBOX_USER}\n'
|
||||
[ -n "$npm" ] && printf 'RUN npm install -g%s\n' "$npm"
|
||||
if [ -n "$cargo" ]; then
|
||||
|
||||
+158
-30
@@ -107,6 +107,13 @@ provider_list() {
|
||||
# default. Types are the config file's own, so one validator set covers both.
|
||||
_manifest_table() {
|
||||
case "$1" in
|
||||
# hosts_allowlisted_by_core (below) means "core already allowlists
|
||||
# these hosts in its own right" - today that is true only of
|
||||
# GitHub's published ranges (see bin/dejima's ALLOW_GITHUB_PUSH
|
||||
# wiring). A forge whose hosts are not among those must leave it
|
||||
# off: turning it on grants no firewall allowance at all (its hosts
|
||||
# never reach DEJIMA_FORGE_HOSTS) plus an irrelevant GitHub push
|
||||
# grant instead.
|
||||
forge) cat <<'EOF'
|
||||
capabilities ?list:identifier
|
||||
pr_command ?command
|
||||
@@ -117,6 +124,7 @@ requires ?list:identifier
|
||||
host_keys ?list:identifier
|
||||
url_keys ?list:identifier
|
||||
fixed_hosts ?list:hostname
|
||||
hosts_allowlisted_by_core bool off
|
||||
install ?list:relpath
|
||||
env_export ?list:export
|
||||
EOF
|
||||
@@ -474,38 +482,107 @@ provider_validate_selection() {
|
||||
fi
|
||||
done
|
||||
|
||||
# Rule 3 ("a pushing workflow needs a forge that resolves to a host") is
|
||||
# deliberately NOT checked here. A forge's own host-bearing keys may have
|
||||
# no schema default and install.sh writes them commented out, so a fresh
|
||||
# install would be refused by this rule today - while it launches fine
|
||||
# as-is, with git-push-guard.sh blocking pushes at runtime (see README
|
||||
# "Skip this if you only push to GitHub"). An unconfigured forge is
|
||||
# supported, not broken, until Phase 3 adds `forge = none` as the honest
|
||||
# way to say so - only then does this rule have a correct alternative to
|
||||
# point an operator at, and it becomes a refusal alongside rule 5 below.
|
||||
# Autonomy names where the agent stops without asking, so it is a claim
|
||||
# about what the selected forge can actually do. Checked here rather than
|
||||
# left to the briefing: a mismatch discovered by the agent mid-task is a
|
||||
# confusing failure, and this one is knowable at launch.
|
||||
local autonomy push
|
||||
autonomy="$(manifest_get workflow "$workflow" autonomy)" || return 1
|
||||
push="$(manifest_get workflow "$workflow" push)" || return 1
|
||||
case "$autonomy" in
|
||||
open-pr)
|
||||
if ! _set_contains "$caps" pr; then
|
||||
echo "workflow '$workflow' sets autonomy = open-pr, which needs a forge" >&2
|
||||
echo "offering the 'pr' capability - '$forge' offers: ${caps:-none}." >&2
|
||||
return 1
|
||||
fi
|
||||
;;
|
||||
push-branch)
|
||||
if [ "$push" != "on" ]; then
|
||||
echo "workflow '$workflow' sets autonomy = push-branch but push = off." >&2
|
||||
echo "It cannot stop at a push it never makes." >&2
|
||||
return 1
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
# config_is_set, not config_get: the question is "did the operator set
|
||||
# this key", not "does it have a value" - a key can have a schema
|
||||
# default, so it always has a value, and testing the value would make a
|
||||
# wholly unconfigured forge (nothing in [forge.<name>] at all) look
|
||||
# partly configured.
|
||||
# Rule 3: a workflow that pushes needs a forge that resolves to somewhere
|
||||
# to push to. Deferred through Phase 2 because refusing an unconfigured
|
||||
# forge had no correct alternative to offer - `forge = none` now exists,
|
||||
# so an operator who genuinely pushes nowhere has an honest way to say it
|
||||
# and this stops being a refusal with no remedy.
|
||||
if [ "$push" = "on" ] && [ -z "$(forge_hosts "$forge")" ]; then
|
||||
if [ -z "$(manifest_get forge "$forge" keys)" ]; then
|
||||
# A forge with no keys at all has nothing to configure - "set
|
||||
# [forge.$forge]" would be a fix that does not exist, so the only
|
||||
# honest remedies are another forge or another workflow.
|
||||
echo "workflow '$workflow' pushes, but forge '$forge' has no host to push" >&2
|
||||
echo "to and nothing to configure. Select a forge that has one ([core]" >&2
|
||||
echo "forge = github needs no host configured), or a workflow that does" >&2
|
||||
echo "not push (workflow = local-only)." >&2
|
||||
else
|
||||
echo "workflow '$workflow' pushes, but forge '$forge' resolves to no host." >&2
|
||||
echo "Configure [forge.$forge], or [core] forge = github to push to GitHub" >&2
|
||||
echo "instead, or [core] forge = none and a workflow that does not push" >&2
|
||||
echo "(workflow = local-only) if this sandbox pushes nowhere." >&2
|
||||
fi
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Set AND non-empty. config_is_set alone answers "did the operator write
|
||||
# this key", which is not the same question: install.sh writes every key
|
||||
# commented out at its default, so uncommenting one and not filling it in
|
||||
# yields `api =` - written, and worth nothing. Every call the forge makes
|
||||
# then fails inside the container, which is precisely what these two
|
||||
# rules exist to catch at launch instead.
|
||||
#
|
||||
# Testing the value is only safe because of the config_default skip
|
||||
# immediately below: a key with a working schema default always resolves
|
||||
# to something, so it is excluded before the emptiness test can read a
|
||||
# wholly unconfigured forge (nothing in [forge.<name>] at all) as partly
|
||||
# configured. A key with no default resolves to empty exactly when it is
|
||||
# unset or blank, which are the same thing to everything downstream.
|
||||
set_n=0 unset_n=0 missing=""
|
||||
for key in $(manifest_get forge "$forge" requires); do
|
||||
if config_is_set "forge.$forge" "$key"; then
|
||||
[ -z "$(config_default "forge.$forge.$key")" ] || continue
|
||||
if config_is_set "forge.$forge" "$key" && [ -n "$(config_get "forge.$forge" "$key")" ]; then
|
||||
set_n=$((set_n + 1))
|
||||
else
|
||||
unset_n=$((unset_n + 1))
|
||||
missing="${missing:+$missing }$key"
|
||||
fi
|
||||
done
|
||||
# A warning, not a refusal (rule 5), and only when the forge is PARTLY
|
||||
# configured. Wholly unconfigured is the same supported "I don't use
|
||||
# this forge" case rule 3 above defers to Phase 3 for, and must stay
|
||||
# silent; a partial configuration is almost certainly a typo, and saying
|
||||
# so costs nothing. Becomes a refusal in Phase 3 alongside rule 3.
|
||||
if [ "$set_n" -gt 0 ] && [ "$unset_n" -gt 0 ]; then
|
||||
echo "Warning: forge '$forge' is partly configured - [forge.$forge] is" >&2
|
||||
echo "missing: $missing. Pushes and PRs will fail until it is set." >&2
|
||||
# Rule 5: a partly configured forge is a typo, not a choice. Refused rather
|
||||
# than warned now that `forge = none` exists: previously the only way to
|
||||
# say "I don't use a forge" was to leave one half-filled, so refusing would
|
||||
# have broken a supported state. Still scoped to PARTIAL configuration -
|
||||
# nothing set at all is rule 3's case above, and carries its own message -
|
||||
# and, like rule 3, only when the workflow actually pushes: a non-pushing
|
||||
# workflow never invokes the forge at all, so a partially filled-in forge
|
||||
# section (e.g. only a host key, set so the firewall allows fetches) is
|
||||
# untidy rather than broken - not a typo worth refusing.
|
||||
if [ "$push" = "on" ] && [ "$set_n" -gt 0 ] && [ "$unset_n" -gt 0 ]; then
|
||||
echo "forge '$forge' is partly configured - [forge.$forge] is missing:" >&2
|
||||
echo "$missing. Set them, or set [core] forge = none if this sandbox" >&2
|
||||
echo "pushes nowhere." >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Rule 3 only catches a wholly unconfigured forge by way of forge_hosts
|
||||
# resolving empty - but a forge like github pins fixed_hosts independent
|
||||
# of any config (the same three hosts for every operator), so forge_hosts
|
||||
# is never empty regardless of whether push_token_file is set. A forge
|
||||
# with exactly one `requires` key can also never be "partly" configured
|
||||
# (rule 5 above): it is either wholly set or wholly unset, so a wholly
|
||||
# unset single-key forge slips past both rules unless this closes it.
|
||||
# Scoped the same way rule 3 is - only when the workflow actually pushes,
|
||||
# and never for a forge with no `requires` at all.
|
||||
if [ "$push" = "on" ] && [ "$set_n" -eq 0 ] && [ "$unset_n" -gt 0 ]; then
|
||||
echo "workflow '$workflow' pushes, but forge '$forge' has none of its" >&2
|
||||
echo "required settings configured - [forge.$forge] is missing: $missing." >&2
|
||||
echo "Set them, or set [core] forge = none and a workflow that does not" >&2
|
||||
echo "push (workflow = local-only) if this sandbox pushes nowhere." >&2
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
@@ -558,6 +635,18 @@ forge_env_exports() {
|
||||
else
|
||||
value="$(config_get "forge.$forge" "$ref")" || return 1
|
||||
fi
|
||||
# An unresolved (empty) value is skipped rather than emitted as
|
||||
# `NAME=`. Harmless for a forge whose variables docker-compose.yml
|
||||
# never declares in its own `environment:` section - an absent -e
|
||||
# flag and an empty one are the same "not set" to a container either
|
||||
# way, and every such variable's consumer already treats unset and
|
||||
# empty identically. Not harmless for GH_TOKEN specifically: compose
|
||||
# DOES declare it there, carrying an independent read-only value
|
||||
# every session gets regardless of which forge is selected. `-e
|
||||
# GH_TOKEN=` on `docker compose run` overrides that entry - emitting
|
||||
# it here whenever a forge's own env_export resolves it empty would
|
||||
# silently replace a configured read-only token with nothing.
|
||||
[ -n "$value" ] || continue
|
||||
printf '%s=%s\n' "$name" "$value"
|
||||
done
|
||||
}
|
||||
@@ -588,7 +677,7 @@ BRIEFING_FRAGMENT_MAX=16384
|
||||
# is a hard refusal (_briefing_substitute below), not a literal passthrough -
|
||||
# an unrecognized {{key}} reaching an agent's context verbatim is exactly the
|
||||
# kind of silent drift this whole function exists to prevent.
|
||||
_BRIEFING_WHITELIST="pr_command issue_command forge_name forge_ssh_host blocked_branches container_id project_dir workspace_dir session_id resume_command writable_mounts"
|
||||
_BRIEFING_WHITELIST="pr_command issue_command forge_name forge_ssh_host blocked_branches container_id project_dir workspace_dir session_id resume_command writable_mounts network_allowlist"
|
||||
|
||||
_briefing_whitelisted() {
|
||||
_set_contains "$_BRIEFING_WHITELIST" "$1"
|
||||
@@ -616,7 +705,7 @@ What this means:
|
||||
packages, anything outside the mounted paths, and the container itself go
|
||||
away when this run ends.
|
||||
- Outbound network is restricted to an allowlist (Anthropic's API, the npm
|
||||
registry, GitHub read-only, the configured {{forge_name}} host{{forge_ssh_host}}, plus anything
|
||||
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
|
||||
is also what bounds where a prompt injection or a compromised dependency
|
||||
@@ -625,11 +714,18 @@ What this means:
|
||||
allowlist is load-bearing for you too, not just for the host - working
|
||||
around it would give up that protection, not merely lift the one limit
|
||||
it appears to. If a task needs one more domain and nothing else, that's
|
||||
a quick fix, not a reason to stop: tell the user to run
|
||||
`bin/dejima-allow <domain> --container {{container_id}}` (add
|
||||
`--global` to persist it past this session) from a terminal outside the
|
||||
sandbox, then continue. Quote that --container value exactly as written
|
||||
here - it is this container's real ID, not something to infer.
|
||||
a quick fix, not a reason to stop. Tell the user to run, from a terminal
|
||||
outside the sandbox, whichever of these matches why the domain is needed:
|
||||
bin/dejima-allow --container {{container_id}} <domain> # just this session
|
||||
bin/dejima-allow --project <path> <domain> # this project, always
|
||||
bin/dejima-allow --global <domain> # every project
|
||||
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
|
||||
calls for. --project also records the domain in the project's own
|
||||
.dejima/allowed-domains, so it is shared with everyone working on it
|
||||
rather than living on one machine. Quote that --container value exactly as
|
||||
written here - it is this container's real ID, not something to infer.
|
||||
- Stay within the task you were given. Since there are no permission
|
||||
prompts here, be more conservative than usual about anything destructive,
|
||||
irreversible, or outside the project directory.
|
||||
@@ -784,6 +880,38 @@ _briefing_resolve_key() {
|
||||
fi
|
||||
[ -z "$host" ] || printf ' (%s)' "$host"
|
||||
;;
|
||||
# What the session may actually reach, phrased for the selected forge
|
||||
# rather than assembled from two placeholders. Materially different
|
||||
# postures, and the agent acts on this sentence: GitHub is read-only
|
||||
# by default; a forge with hosts of its own adds them; a forge whose
|
||||
# hosts are the ones core already allowlists (hosts_allowlisted_by_core
|
||||
# - the same manifest key bin/dejima reads to open the push grant) has
|
||||
# no separate host to name, and when the workflow pushes, calling that
|
||||
# access read-only would be false in the direction that matters.
|
||||
#
|
||||
# Driven off that key rather than off the forge's name: it is the one
|
||||
# thing that actually decides the posture, so anything else here would
|
||||
# be core guessing at what a manifest already states.
|
||||
network_allowlist)
|
||||
local hosts=""
|
||||
if [ "$(manifest_get forge "$forge" hosts_allowlisted_by_core)" = "on" ]; then
|
||||
if [ "$(manifest_get workflow "$workflow" push)" = "on" ]; then
|
||||
printf '%s' "GitHub, which this session can also push to"
|
||||
else
|
||||
printf '%s' "GitHub read-only"
|
||||
fi
|
||||
else
|
||||
if [ -n "$(manifest_get forge "$forge" host_keys)$(manifest_get forge "$forge" url_keys)$(manifest_get forge "$forge" fixed_hosts)" ]; then
|
||||
hosts=", the configured $forge host"
|
||||
if config_exists 2>/dev/null; then
|
||||
local h
|
||||
h="$(config_get "forge.$forge" ssh_host 2>/dev/null)" || h=""
|
||||
[ -z "$h" ] || hosts="$hosts ($h)"
|
||||
fi
|
||||
fi
|
||||
printf '%s' "GitHub read-only$hosts"
|
||||
fi
|
||||
;;
|
||||
blocked_branches) manifest_get workflow "$workflow" blocked_branches ;;
|
||||
project_dir) printf '%s' "${WORKSPACE_DIR:-}" ;;
|
||||
workspace_dir) printf '%s' "${WORKSPACE_DIR:-}" ;;
|
||||
|
||||
+17
-40
@@ -2,7 +2,14 @@
|
||||
# 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/Go/Playwright/tree-sitter toolchain, gitea-pr helper, etc).
|
||||
# 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
|
||||
# tarball: for a release archive - so they are declared by the projects that
|
||||
# want them rather than carried by every sandbox on the host. Anything added
|
||||
# below is added for every project, forever, with no review anywhere.
|
||||
FROM node:22
|
||||
|
||||
ARG TZ
|
||||
@@ -30,7 +37,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
procps \
|
||||
fzf \
|
||||
zsh \
|
||||
man-db \
|
||||
unzip \
|
||||
gnupg2 \
|
||||
gh \
|
||||
@@ -45,28 +51,15 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ulogd2 \
|
||||
ulogd2-json \
|
||||
nano \
|
||||
vim \
|
||||
ripgrep \
|
||||
fd-find \
|
||||
build-essential \
|
||||
python3 \
|
||||
python3-pip \
|
||||
python3-venv \
|
||||
xz-utils \
|
||||
bats \
|
||||
cowsay \
|
||||
fortune-mod \
|
||||
fortunes-min \
|
||||
&& apt-get clean && rm -rf /var/lib/apt/lists/* \
|
||||
&& ln -s "$(command -v fdfind)" /usr/local/bin/fd
|
||||
|
||||
# cowsay/cowthink/fortune install to /usr/games on Debian, which isn't on
|
||||
# PATH by default - nvim start-screen plugins commonly shell out to them by
|
||||
# bare name for a dashboard header. Set via ENV (not a profile.d script) so it's
|
||||
# picked up by every process unconditionally, including nvim's non-login
|
||||
# subprocess spawns, which wouldn't source /etc/profile.d.
|
||||
ENV PATH=/usr/games:$PATH
|
||||
|
||||
# Set `DEVCONTAINER` environment variable to help with orientation
|
||||
ENV DEVCONTAINER=true
|
||||
|
||||
@@ -97,13 +90,6 @@ RUN useradd -r -s /usr/sbin/nologin ulogd
|
||||
# of re-running all of it. That single ordering was most of the cost of the
|
||||
# override rebuild: it was 1169s of a 1379s test run, for a difference that
|
||||
# amounts to one useradd.
|
||||
ARG GIT_DELTA_VERSION=0.18.2
|
||||
RUN ARCH=$(dpkg --print-architecture) && \
|
||||
wget --tries=3 --timeout=30 --waitretry=5 --retry-connrefused \
|
||||
"https://github.com/dandavison/delta/releases/download/${GIT_DELTA_VERSION}/git-delta_${GIT_DELTA_VERSION}_${ARCH}.deb" && \
|
||||
dpkg -i "git-delta_${GIT_DELTA_VERSION}_${ARCH}.deb" && \
|
||||
rm "git-delta_${GIT_DELTA_VERSION}_${ARCH}.deb"
|
||||
|
||||
# gosu lets entrypoint.sh (root) drop to the node user for the actual
|
||||
# session after setting up the firewall, without leaving any setuid/sudo
|
||||
# escalation path available to node afterward. Verified against the
|
||||
@@ -130,24 +116,15 @@ RUN asset_url=$(curl -fsSL --retry 3 --retry-delay 2 --retry-all-errors --max-ti
|
||||
tar -C /usr/local --strip-components=1 -xzf nvim.tar.gz && \
|
||||
rm nvim.tar.gz
|
||||
|
||||
# Install the latest stable Go toolchain. go.dev's own release JSON gives
|
||||
# both the download URL and a SHA256 per file, verified below since this
|
||||
# runs as root - same pattern as gosu above. `go build`/`go mod download`
|
||||
# need proxy.golang.org and sum.golang.org reachable at runtime - see
|
||||
# fixed-domains.sh.
|
||||
RUN ARCH=$(dpkg --print-architecture) && \
|
||||
release_json=$(curl -fsSL --retry 3 --retry-delay 2 --retry-all-errors --max-time 60 \
|
||||
'https://go.dev/dl/?mode=json') && \
|
||||
filename=$(echo "$release_json" | jq -r --arg arch "$ARCH" \
|
||||
'[.[] | select(.stable == true)][0].files[] | select(.os == "linux" and .arch == $arch and .kind == "archive") | .filename') && \
|
||||
sha256=$(echo "$release_json" | jq -r --arg arch "$ARCH" \
|
||||
'[.[] | select(.stable == true)][0].files[] | select(.os == "linux" and .arch == $arch and .kind == "archive") | .sha256') && \
|
||||
wget --tries=3 --timeout=30 --waitretry=5 --retry-connrefused \
|
||||
-O go.tar.gz "https://go.dev/dl/${filename}" && \
|
||||
echo "${sha256} go.tar.gz" | sha256sum -c - && \
|
||||
tar -C /usr/local -xzf go.tar.gz && \
|
||||
rm go.tar.gz
|
||||
ENV PATH=$PATH:/usr/local/go/bin
|
||||
# No Go toolchain here. A Go project declares one:
|
||||
#
|
||||
# dejima-dep add --global tarball:go@<sha256>:https://go.dev/dl/go<ver>.linux-amd64.tar.gz
|
||||
#
|
||||
# and gets a pinned toolchain in the version it actually wants, rather than
|
||||
# whatever this image was built with. Shipping a language runtime is not the
|
||||
# same decision as every sandbox on the host carrying one - and the module
|
||||
# proxy it needs (proxy.golang.org, sum.golang.org) is an approval too, for
|
||||
# the same reason.
|
||||
|
||||
# System libraries Chromium needs (fonts, libnss, libatk, etc.) for
|
||||
# Playwright, installed here since it needs apt/root - the actual
|
||||
|
||||
@@ -252,6 +252,41 @@ fi
|
||||
[ -n "${ANTHROPIC_BASE_URL:-}" ] || unset ANTHROPIC_BASE_URL
|
||||
[ -n "${ANTHROPIC_AUTH_TOKEN:-}" ] || unset ANTHROPIC_AUTH_TOKEN
|
||||
|
||||
# Remote access is opt-in and off by default, so this whole block is skipped
|
||||
# for the sessions that never asked for it: DEJIMA_NOTIFY_DIR is set only by
|
||||
# bin/dejima's remote_enabled branch. An unset value is the ordinary case and
|
||||
# says nothing.
|
||||
#
|
||||
# Set-but-unusable is different, and it is worth a line at startup because of
|
||||
# how it otherwise presents. The marker directory is a bind mount whose source
|
||||
# bin/dejima creates per launch and removes on exit, so restarting the
|
||||
# container - `docker start`, or a restart policy - rather than relaunching
|
||||
# `dejima` leaves the source gone. Docker's answer to a missing bind source is
|
||||
# not to refuse: it creates one, root-owned. The directory then exists, so the
|
||||
# notify hook's own `[ -d ]` guard passes and every notification fails with
|
||||
# EACCES instead; the session is also absent from `dejima-attach`, because its
|
||||
# registry entry went with the same directory. Three unrelated-looking symptoms
|
||||
# and no obvious moment they started.
|
||||
#
|
||||
# Reported, not repaired. Chowning this would restore notifications while the
|
||||
# session stayed missing from the registry - which bin/dejima writes host-side
|
||||
# and never mounts here - so a session would look fixed while half of the
|
||||
# feature was still broken. Relaunching is the actual remedy, and it is one the
|
||||
# message can name.
|
||||
if [ -n "${DEJIMA_NOTIFY_DIR:-}" ]; then
|
||||
if [ ! -d "$DEJIMA_NOTIFY_DIR" ]; then
|
||||
echo "WARNING: remote access is enabled, but $DEJIMA_NOTIFY_DIR is not a directory." >&2
|
||||
echo "Notifications will not be delivered for this session." >&2
|
||||
elif ! gosu "$SANDBOX_USER" test -w "$DEJIMA_NOTIFY_DIR"; then
|
||||
echo "WARNING: remote access is enabled, but $DEJIMA_NOTIFY_DIR is not writable by" >&2
|
||||
echo "$SANDBOX_USER - so notifications will fail, and this session will not appear in" >&2
|
||||
echo "dejima-attach. This is what a container that was RESTARTED rather than" >&2
|
||||
echo "relaunched looks like: bin/dejima creates the marker directory per launch and" >&2
|
||||
echo "removes it on exit, and Docker recreated the missing bind source as root." >&2
|
||||
echo "Stop this container and start a new session with 'dejima' to restore both." >&2
|
||||
fi
|
||||
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
|
||||
|
||||
+43
-5
@@ -1,14 +1,22 @@
|
||||
# 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.
|
||||
#
|
||||
# What belongs here is what the sandbox's own machinery cannot run without -
|
||||
# the harness and its updates, and the release CDN the tools baked into the
|
||||
# image fetch binaries from. Nothing else.
|
||||
#
|
||||
# Language ecosystems and editor tooling deliberately do not: a Python, Go or
|
||||
# Rust project reaches its own package host through an approval, either
|
||||
# `dejima-allow --global` once or a `.dejima/allowed-domains` line reviewed
|
||||
# with the project. The distinction is not tidiness. Every name here is
|
||||
# reachable from every sandbox on the host whether or not the project has any
|
||||
# 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"
|
||||
"platform.claude.com"
|
||||
"gitlab.com"
|
||||
"pypi.org"
|
||||
"files.pythonhosted.org"
|
||||
"release-assets.githubusercontent.com"
|
||||
"proxy.golang.org"
|
||||
"sum.golang.org"
|
||||
)
|
||||
|
||||
# api.anthropic.com is allowlisted only when this session has no credential
|
||||
@@ -38,3 +46,33 @@ GITHUB_DNS_SUFFIXES=(
|
||||
"githubusercontent.com"
|
||||
"githubassets.com"
|
||||
)
|
||||
|
||||
# Which ipset an allowlisted name's addresses belong in.
|
||||
#
|
||||
# GitHub's are restricted to port 443 by init-firewall.sh, and that
|
||||
# restriction is the read-only guarantee - there is no transport left for the
|
||||
# forwarded ssh-agent's key to push with. allowed-domains carries no port
|
||||
# restriction at all, so an address that reaches it is reachable on 22 as
|
||||
# well, and a GitHub address that reaches it silently undoes the whole
|
||||
# arrangement.
|
||||
#
|
||||
# dns-proxy.sh gets this right for free: dnsmasq applies the longest matching
|
||||
# ipset= line, so GitHub names go to github-domains however they were
|
||||
# allowlisted. Every other path has to ask, and two did not - the
|
||||
# extra-domains loop in init-firewall.sh and the live add in bin/dejima-allow
|
||||
# both added unconditionally to allowed-domains. A project with github.com in
|
||||
# its .dejima/allowed-domains therefore opened port 22 to whichever address
|
||||
# GitHub returned at container start, for as long as that answer stayed
|
||||
# cached. It presented as an intermittent startup failure, because the
|
||||
# port-22 self-check re-resolves the name 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'
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
printf 'allowed-domains'
|
||||
}
|
||||
|
||||
+96
-7
@@ -76,10 +76,13 @@ else
|
||||
echo "No Docker DNS rules were present to restore"
|
||||
fi
|
||||
|
||||
# Allow localhost. DNS resolution goes entirely through Docker's embedded
|
||||
# resolver at 127.0.0.11 (see resolv.conf), which stays on loopback, so this
|
||||
# also covers DNS - no need for a separate "UDP/53 to anywhere" rule, which
|
||||
# would just be an unrestricted-destination exfil channel.
|
||||
# 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
|
||||
|
||||
@@ -269,13 +272,17 @@ if [ -n "${EXTRA_ALLOWED_DOMAINS:-}" ]; then
|
||||
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"
|
||||
ipset add allowed-domains "$ip" 2>/dev/null || true
|
||||
echo "Adding $ip for $domain (-> $target_set)"
|
||||
ipset add "$target_set" "$ip" 2>/dev/null || true
|
||||
done < <(echo "$ips")
|
||||
done
|
||||
fi
|
||||
@@ -305,6 +312,40 @@ iptables -P OUTPUT DROP
|
||||
iptables -A INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
|
||||
iptables -A OUTPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
|
||||
|
||||
# Docker's embedded resolver does not answer external names itself - it
|
||||
# forwards them to the host's nameservers, and on current Docker it does that
|
||||
# from inside THIS network namespace. Those queries therefore traverse the
|
||||
# chain below like any other packet, and nothing here can match them: the
|
||||
# host's resolver is neither in an ipset nor the bridge gateway. They reach
|
||||
# the catch-all REJECT, every lookup comes back SERVFAIL, and the container
|
||||
# dies in a self-check that says only that a host was unreachable.
|
||||
#
|
||||
# The comment on the loopback rule above used to claim DNS "stays on
|
||||
# loopback, so this also covers DNS". Only the first hop does. Two hosts with
|
||||
# byte-identical /etc/resolv.conf behaved differently here, so this is not a
|
||||
# property of any host's configuration and cannot be assumed either way.
|
||||
#
|
||||
# --uid-owner 0 is what keeps this from being the hole the DNS allowlist
|
||||
# exists to close. The forwarding socket belongs to dockerd, which is root;
|
||||
# the session runs as `node` and there is no sudo in the image, so a
|
||||
# compromised session still cannot address a resolver directly and still has
|
||||
# no path to a name dns-proxy.sh has not allowed. What this does newly permit
|
||||
# is DNS from processes that are already root in a container holding
|
||||
# NET_ADMIN - which could simply flush this ruleset instead, so it grants
|
||||
# nothing that was not already available.
|
||||
#
|
||||
# Guarded rather than asserted: xt_owner is a module, and a kernel without it
|
||||
# should leave a container that says why (the DNS self-check below), not one
|
||||
# that aborts here with an iptables usage error.
|
||||
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
|
||||
@@ -340,6 +381,33 @@ 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
|
||||
@@ -353,7 +421,28 @@ else
|
||||
fi
|
||||
|
||||
# Confirm the allowlist itself actually works, not just that the default-deny does.
|
||||
if ! curl --connect-timeout 5 --max-time 15 https://api.github.com/zen >/dev/null 2>&1; then
|
||||
#
|
||||
# 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
|
||||
|
||||
+533
@@ -0,0 +1,533 @@
|
||||
# CI, and what push access grants
|
||||
|
||||
[← dejima README](../README.md)
|
||||
|
||||
**Granting a dejima session push access grants it code execution wherever your
|
||||
CI runs.** That is the whole of it, and everything below is either why it
|
||||
cannot be gated or what to do about it instead.
|
||||
|
||||
> **This document is the why.** For the how, [dejima-ci-hardener](https://gitbox.branderson.io/branderson/dejima-ci-hardener)
|
||||
> is a self-contained tool that does it — installed on the CI box, never on a
|
||||
> dejima host, and it does not assume you have read this.
|
||||
|
||||
The chain needs no operator action at any step. A session writes code; the
|
||||
push guard permits a feature branch, which is what it exists to allow; the
|
||||
session opens a pull request, which its briefing tells it to do without asking
|
||||
first; `on: pull_request` fires and the runner checks out that commit and runs
|
||||
it. The code does not have to be a modified workflow file to be executed — a
|
||||
workflow that runs a test suite is already running every file in it, and a
|
||||
plausible-looking helper in a test file is a much quieter carrier than an
|
||||
edited workflow, which shows up in a diff.
|
||||
|
||||
The gate this sandbox otherwise relies on — you merge, the agent does not —
|
||||
sits strictly downstream of that. By the time anyone reads the diff, the code
|
||||
has run.
|
||||
|
||||
This applies to any project developed in a dejima sandbox, not only to dejima
|
||||
itself. The sandbox bounds what a session can reach directly. It does not
|
||||
bound what it can arrange to have run elsewhere, and CI is the shortest path
|
||||
elsewhere.
|
||||
|
||||
**And it applies to projects with no CI at all.** A branch can add the first
|
||||
workflow, so what matters is not what a repository's workflows do today but
|
||||
which runners its branches can reach — which is a property of the forge, not
|
||||
of the project. If you read one section of this document before pointing a
|
||||
sandbox at something, make it
|
||||
[what a repository can reach](#closing-the-rest-what-a-repository-can-reach);
|
||||
it is a question to answer per project, and for a project on hosted runners
|
||||
the answer is reassuring.
|
||||
|
||||
## What a run actually adds
|
||||
|
||||
The code CI executes is code the session already had. Relative to the sandbox,
|
||||
a run adds three things, and every control in this document maps onto one of
|
||||
them:
|
||||
|
||||
1. **Execution on a host you own** — outside the container, outside the
|
||||
allowlist, with whatever that host can reach.
|
||||
2. **Egress from a network position you own** — no dejima firewall, no DNS
|
||||
proxy, ordinary networking.
|
||||
3. **Credentials the run is handed** — the secrets you configured, plus the
|
||||
token the forge injects into every job whether the workflow asks for one or
|
||||
not.
|
||||
|
||||
Keep those three in view even if none of the specific configuration below
|
||||
applies to you. They are what changes when a session gains push access, and
|
||||
they survive translation to CI that looks nothing like this.
|
||||
|
||||
## Why this document offers no approval gate
|
||||
|
||||
The obvious answer is to require a human to approve a run. Each version of
|
||||
that turns out to be reachable from the branch, from a credential the session
|
||||
already holds, or to be no gate at all:
|
||||
|
||||
- **Changing the trigger** — `workflow_dispatch` only, or `push` to `main`
|
||||
only. The workflow file lives in the tree the session controls, and on a
|
||||
`pull_request` run it is read from the head of the pull request.
|
||||
- **Triggering by hand.** A forge token that can open a pull request generally
|
||||
also covers the dispatch endpoint. Gitea's `write:repository` does.
|
||||
- **Leaving the runner stopped until you want a run.** Any shared pool has
|
||||
spare capacity, so a job that cannot land on your runner lands on someone
|
||||
else's. It also asks you to do something before every run, which is a
|
||||
recommendation that gets abandoned rather than followed.
|
||||
- **The forge's own approval settings.** Gitea's approval requirement and
|
||||
read-only token apply to pull requests *from forks*. A dejima session pushes
|
||||
a branch to the same repository, which is precisely the case they do not
|
||||
cover.
|
||||
- **Filtering egress from inside the workflow.** That is a step in a file the
|
||||
branch can edit.
|
||||
|
||||
Running unattended from a plan through to a pull request is most of what a
|
||||
sandbox like this is for. A design that reinserts a manual step per run gives
|
||||
that up and, in practice, gets routed around. So nothing here tries to stop
|
||||
the run. Everything here bounds what the run can do.
|
||||
|
||||
## Before anything else: protect the branch
|
||||
|
||||
Every control below that is worth anything roots its trust in `main` — most
|
||||
directly the egress allowlist, which is read from there. A job token with
|
||||
write access to the repository can push to an unprotected branch, so without
|
||||
this the untrusted party can edit the thing that is supposed to constrain it.
|
||||
|
||||
- Protect `main`: no direct push, pull request required.
|
||||
- **Verify** the job token cannot push to it, rather than assuming branch
|
||||
protection covers a token the forge issues itself.
|
||||
- Check whether a job token can *merge* a pull request on your instance. If it
|
||||
can, both the merge guard and the you-merge-not-the-agent gate are reachable
|
||||
from CI, and you should know that either way.
|
||||
|
||||
This costs nothing and it is the single highest-value item in this document.
|
||||
|
||||
**Branch protection needs a second identity to protect anything.** By default
|
||||
a session authenticates as you, so a merge whitelist, a push whitelist and an
|
||||
approval requirement all admit the sandbox exactly as they admit you — and
|
||||
required approvals are unusable outright, since a forge will not let a pull
|
||||
request's author approve it and the session opens them as you. Giving the
|
||||
sandbox its own forge account is what turns those settings from decoration
|
||||
into enforcement, and it is configuration rather than a missing feature:
|
||||
[docs/security.md](security.md#to-your-forge-the-sandbox-is-you).
|
||||
|
||||
Note that it does not help with the rest of this document. A write-capable
|
||||
account still pushes feature branches, which is what makes CI run code, and
|
||||
the token the forge injects into a job is a third principal subject to none of
|
||||
those settings.
|
||||
|
||||
## The runner host
|
||||
|
||||
Treat it as what it is: a machine that exists to run untrusted code, because
|
||||
that is what it does on every pull request.
|
||||
|
||||
- **Nothing else on it.** No other service, no other project's data.
|
||||
- **No route to the LAN or to a management network.** A runner that can reach
|
||||
your internal services is a runner that hands them to whatever it executes.
|
||||
- **No credentials beyond its own registration token.** Registration tokens
|
||||
are worth rotating on a schedule; anyone holding one can register a runner
|
||||
and start receiving jobs, including their secrets.
|
||||
- **Rebuilt from a snapshot on a schedule**, so that persistence on it has a
|
||||
bounded lifetime rather than an indefinite one.
|
||||
- **Register runners at repository or organization scope**, not instance
|
||||
scope. This matters in both directions, and the second is the one that bites:
|
||||
an unrelated repository cannot schedule work onto a runner you hardened for
|
||||
this one, *and* this repository cannot schedule work onto a runner nobody
|
||||
hardened. An instance-level runner is reachable from every repository on the
|
||||
instance — see "A label is not a boundary" below. Gitea's own FAQ describes
|
||||
gitea.com doing this for its public runners.
|
||||
|
||||
Two runners on one VM give you label tiering, not host separation: a job that
|
||||
cannot get the Docker socket still shares a kernel and a network with a runner
|
||||
that can. If you only have one machine, that is a real reduction and worth
|
||||
doing — just do not read it as isolation between the tiers.
|
||||
|
||||
## Job containers
|
||||
|
||||
The single most consequential setting is one that is invisible from inside a
|
||||
job. `act_runner` mounts the Docker daemon's socket into every job container
|
||||
it starts unless `container.docker_host` is set to `"-"`; its own generated
|
||||
configuration says so:
|
||||
|
||||
> If it's `"-"`, act_runner will find an available docker host automatically,
|
||||
> but the docker host won't be mounted to the job containers and service
|
||||
> containers.
|
||||
|
||||
Access to that socket is root-equivalent on the runner host — `docker run
|
||||
-v /:/host` ends the argument. Nothing in a job's log mentions the socket
|
||||
being there, so this is not something a passing look at a green run will
|
||||
surface. Check it directly, per runner:
|
||||
|
||||
```sh
|
||||
grep -A12 '^container:' /path/to/act_runner/config.yaml
|
||||
```
|
||||
|
||||
An unset `docker_host` is the default, and the default mounts.
|
||||
|
||||
Note also that whether a job gets the socket is a property of **the runner's**
|
||||
configuration, not of the job. A job asking for nothing unusual still receives
|
||||
whatever the runner grants everything it runs. So the useful question is not
|
||||
"does this job need Docker" but "does every job this runner can pick up
|
||||
deserve the socket".
|
||||
|
||||
For a runner that does not need it, the settings worth being explicit about
|
||||
rather than leaving at their defaults are `docker_host: "-"`, an empty
|
||||
`valid_volumes` (the default, but worth stating so an edit has to be
|
||||
deliberate), `privileged: false`, a fixed `network` (below), `capacity: 1`,
|
||||
`cache.enabled: false`, and a label nothing else carries.
|
||||
dejima-ci-hardener ships both configs, with the reasoning inline.
|
||||
|
||||
**Prefer a new runner over reconfiguring an existing one.** A general-purpose
|
||||
runner often exists precisely to build images, and taking its socket away
|
||||
breaks it. Register a second runner with a label of its own, and name that
|
||||
label in the workflow — and if no runner carries the label yet, the job queues
|
||||
rather than running somewhere unprepared, which is a better failure than a
|
||||
green run on the wrong host.
|
||||
|
||||
### A label is not a boundary
|
||||
|
||||
That last paragraph describes routing, not containment, and the difference
|
||||
matters enough to state on its own.
|
||||
|
||||
**`runs-on:` is in the workflow file, and the workflow file is in the branch.**
|
||||
A branch can name any label it likes. So a dedicated label keeps *honest* jobs
|
||||
on the runner you prepared; it does nothing against a branch that would rather
|
||||
run somewhere else. A session that changes `runs-on: dejima-ci` to
|
||||
`runs-on: ubuntu-latest`, pushes, and opens a pull request has stepped outside
|
||||
every control on this page — no allowlist, no socket restriction, no firewall
|
||||
— provided some runner answering `ubuntu-latest` will take the job.
|
||||
|
||||
Whether one will is a property of **where your runners are registered**, which
|
||||
is the actual boundary:
|
||||
|
||||
| Registered at | Which repositories it serves |
|
||||
| --- | --- |
|
||||
| Instance | **every repository on the instance** |
|
||||
| Organization | that organization's repositories |
|
||||
| Repository | that repository |
|
||||
|
||||
Gitea has no per-repository runner ACL — its FAQ says so directly, and points
|
||||
at registration scope as the control instead: gitea.com registers its runners
|
||||
for one organization "so our runners will not execute jobs from other
|
||||
repositories."
|
||||
|
||||
So the property to hold onto is this: **a repository's CI security posture is
|
||||
that of the weakest runner it can reach, not the one its workflow names.** An
|
||||
instance-level general-purpose runner is reachable from every repository on
|
||||
that instance, including any that takes agent-authored branches.
|
||||
|
||||
### Pinning the workflow with `pull_request_target`
|
||||
|
||||
Half of this is fixable, and worth fixing: **`runs-on` does not have to come
|
||||
from the branch.**
|
||||
|
||||
Under `on: pull_request` the workflow is read from the head of the pull
|
||||
request, so the branch decides what runs and where. Under
|
||||
`on: pull_request_target` Gitea reads it from the **base branch**, and
|
||||
explicitly discards any `pull_request_target` workflow found on the head
|
||||
commit — a branch can neither edit the base copy nor introduce its own. What
|
||||
runs, and on which runner, is whatever `main` says.
|
||||
|
||||
**Adopting it takes two merges, not one.** The trigger only takes effect once
|
||||
it is on the base branch, because that is where it is read from — and a
|
||||
`pull_request_target` workflow on a pull request's head is explicitly ignored.
|
||||
So the change that introduces it cannot be tested by it: with only
|
||||
`pull_request_target` on the branch and only `pull_request` on `main`, the
|
||||
head's copy is skipped, the base has nothing to contribute, and **no checks
|
||||
run at all** — which looks like a broken workflow rather than a bootstrap
|
||||
problem.
|
||||
|
||||
Carry both triggers through the first merge, then remove `pull_request` in a
|
||||
follow-up. The first change is tested by its own copy from the head, as
|
||||
today; the follow-up is tested by the copy now on `main`. In between, pull
|
||||
requests get both an untrusted run from the head and a trusted one from the
|
||||
base — noisy, and for the untrusted half no worse than the status quo, so
|
||||
keep the window short.
|
||||
|
||||
Two more consequences to handle rather than discover:
|
||||
|
||||
- **The checkout no longer defaults to the code under test.** The run's ref is
|
||||
the base branch, so a bare `actions/checkout` fetches `main` and the suite
|
||||
passes without ever seeing the change. Jobs that test the branch must name
|
||||
it: `ref: ${{ github.event.pull_request.head.sha }}`. Jobs that assert on
|
||||
the *runner* should keep the bare checkout, so their assertions come from
|
||||
`main` and a branch can neither weaken nor delete them.
|
||||
- **The job runs with the base repository's token and secrets**, because the
|
||||
forge treats it as trusted. On a forge where a same-repo `pull_request` run
|
||||
already gets a permissive token this changes nothing, but it is why the
|
||||
pattern carries warnings elsewhere: give such a run no secret you would not
|
||||
hand the branch.
|
||||
|
||||
This does not make the repository safe on its own. A branch can still add a
|
||||
workflow of its own with `on: pull_request`, which runs from the head with
|
||||
whatever `runs-on` it likes. What it buys is that the *intended* CI is
|
||||
trustworthy, and that escaping it means adding a file rather than editing
|
||||
one — a much louder diff.
|
||||
|
||||
### Closing the rest: what a repository can reach
|
||||
|
||||
The rule underneath everything here:
|
||||
|
||||
> **Every runner reachable from a repository that receives agent-authored
|
||||
> branches must be one you would accept an attacker running code on.**
|
||||
|
||||
Note what that does *not* say. It says nothing about what the repository's
|
||||
workflows currently do, because a branch can add the first one. **A project
|
||||
with no CI at all is still exposed**, on a forge where its branches can reach
|
||||
a runner — and that is the case people do not expect, because there is nothing
|
||||
in the repository to look at.
|
||||
|
||||
So before pointing a sandbox at a project, ask one question: *what runners can
|
||||
this repository's branches reach?*
|
||||
|
||||
**If the answer is "hosted runners only"** — GitHub-hosted and nothing
|
||||
self-hosted attached — this is the mildest case, and it is worth saying so
|
||||
rather than alarming people. The runner is disposable and is not your machine,
|
||||
so "execution on a host you own" largely evaporates: there is no LAN behind
|
||||
it and nothing of yours on it. What remains is the third item, credentials:
|
||||
give pull-request runs no secrets they do not need and a read-only token
|
||||
(`permissions: contents: read`), and do not attach a self-hosted runner to
|
||||
that repository.
|
||||
|
||||
**If self-hosted runners are reachable**, that is the sharp case, and there are
|
||||
three ways to hold the rule — none of which require moving or renaming
|
||||
anything.
|
||||
|
||||
**1. Turn CI off for that repository, if it does not use CI.** Disabling the
|
||||
Actions unit means no workflow runs for that repository at all, so no runner is
|
||||
reachable and a branch cannot add the first workflow. Changing it back needs
|
||||
*admin* on the repository — Gitea's API puts repository settings behind
|
||||
`reqAdmin()` — so a bot account with Write cannot undo it. For a project that
|
||||
was not using CI anyway this costs nothing and closes the vector outright. It
|
||||
is the first thing to reach for and the most commonly overlooked.
|
||||
|
||||
**2. Scope the dangerous runner down, rather than moving the repository out.**
|
||||
The instinct is to move agent-developed repositories somewhere with only
|
||||
hardened runners. For a public project that means breaking its name, its
|
||||
links and its ownership, which nobody should be asked to do.
|
||||
|
||||
Invert it. A runner can belong to an **owner** — a user as well as an
|
||||
organization — and then serves that owner's repositories; Gitea's runner model
|
||||
names the three cases: global (`OwnerID` and `RepoID` both zero), org/user
|
||||
level (`OwnerID` set), repository level (`RepoID` set). So:
|
||||
|
||||
- Make the *hardened* runner the owner-level default, so every repository gets
|
||||
it.
|
||||
- Register the *permissive* runner — the one with the Docker socket, or with
|
||||
unrestricted egress — at **repository** scope, for only the repositories
|
||||
that genuinely need it.
|
||||
|
||||
Most repositories need a runner that can run tests, not one that can build
|
||||
images. Scoping the dangerous capability to the few that need it is usually a
|
||||
handful of registrations, and nothing moves or is renamed.
|
||||
|
||||
**3. Do not grant push.** `[core] forge = none` with
|
||||
`[core] workflow = local-only`. The session commits locally and stops; you
|
||||
review the diff and push it yourself. No branch, no workflow, no runner, and
|
||||
the vector is closed completely rather than bounded. The cost is one push
|
||||
after the review you were going to do anyway — and it is the only option here
|
||||
that does not depend on getting a forge's configuration right.
|
||||
|
||||
If none of the three is available for a given project, that is worth knowing
|
||||
plainly: pointing a sandbox at it means accepting that a compromised session
|
||||
can run code on whatever that repository can reach.
|
||||
|
||||
## Egress
|
||||
|
||||
The goal is that a job cannot reach somewhere new — cannot fetch a next stage,
|
||||
cannot post a repository to a paste service, cannot probe an internal address
|
||||
— without a human having agreed to that destination first.
|
||||
|
||||
### Four network positions, only one of which is the job
|
||||
|
||||
Getting these confused produces either a broken build or a firewall that
|
||||
protects nothing:
|
||||
|
||||
| Traffic | Whose namespace | Filtered in |
|
||||
| --- | --- | --- |
|
||||
| Job steps: `apt-get`, the checkout's fetch | the job container | `DOCKER-USER` (a `FORWARD` hook) |
|
||||
| Job → the resolver on its bridge gateway | job container → the host itself | `INPUT` |
|
||||
| Pulling the job's own image | the Docker daemon, on the host | the host's `OUTPUT` |
|
||||
| `docker build` steps, where a job has a daemon | the daemon's own bridge | its own policy |
|
||||
|
||||
**Filtering only `DOCKER-USER` leaves the DNS allowlist decorative.** Traffic
|
||||
to a resolver on the bridge gateway is addressed to the host, so it is an
|
||||
`INPUT` packet and never passes through `DOCKER-USER` at all — and a job that
|
||||
can reach any resolver it likes can resolve anything it likes. `INPUT` needs
|
||||
its own pair of rules: accept port 53 from the bridge, drop the rest.
|
||||
|
||||
The upside of the split is that a job's own needs are usually small, because
|
||||
image pulls and action fetches happen from the host rather than from the job.
|
||||
For a suite that installs a few packages and checks out a repository, the job
|
||||
needs the forge and a package mirror, and nothing else.
|
||||
|
||||
### A fixed network for jobs, a resolver, and both firewall chains
|
||||
|
||||
Point `container.network` at a network you created, with a named bridge, so
|
||||
that the interface name, subnet and gateway are stable enough to write rules
|
||||
against. Left empty, `act_runner` creates a network per job and there is no
|
||||
stable interface to match on. Give it a **private** subnet: `stop-dns-rebind`
|
||||
below rejects answers in the private ranges, so a reserved-but-not-private
|
||||
bridge subnet is one that check silently does not cover.
|
||||
|
||||
With `runner.capacity: 1` one job occupies that network at a time. Above one,
|
||||
concurrent jobs share a broadcast domain and can reach each other's service
|
||||
containers, which is worth knowing 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*.
|
||||
|
||||
`dnsmasq` binds the bridge gateway. With `no-resolv` and one `server=` line
|
||||
per allowed name, every other name gets `REFUSED` — not resolved-then-blocked,
|
||||
refused, which closes off encoding data into query names to a domain you never
|
||||
allowed. `ipset=/#/ci-allow` adds every answer it serves to the firewall's
|
||||
set, so a destination that rotates its edge addresses stays reachable without
|
||||
a resolver cron chasing it. `rebind-domain-ok` exempts a self-hosted forge,
|
||||
which legitimately resolves to a private address.
|
||||
|
||||
The runner pins its job containers at that resolver - per-runner rather than
|
||||
daemon-wide, because a daemon-wide setting also reaches containers a
|
||||
privileged job creates over the Docker socket, which have firewalls of their
|
||||
own and no route to it. Then two chains filter: `DOCKER-USER` for everything
|
||||
the job forwards off the bridge, and `INPUT` for the resolver itself, because
|
||||
the gateway address belongs to the host.
|
||||
|
||||
Block IPv6 outright unless you have built the same allowlist for it — an
|
||||
address family with no allowlist is not a smaller hole than the one you just
|
||||
closed.
|
||||
|
||||
**The working configuration for all of this is in [`ci/`](../ci/), and
|
||||
[`ci/README.md`](../ci/README.md) is the step-by-step.** It is deliberately not
|
||||
reproduced here: two copies of a firewall rule drift, and the one in `ci/` is
|
||||
the one with the ordering constraints and the substitution notes attached.
|
||||
|
||||
## The allowlist comes from `main`, not from the checkout
|
||||
|
||||
A dejima project already carries a version-controlled statement of where it is
|
||||
allowed to reach: `.dejima/allowed-domains`. Use it — read from
|
||||
**`origin/main`**, over the forge's API, by the runner host.
|
||||
|
||||
Never from the checkout. The checkout is the branch's, and a branch that can
|
||||
write its own allowlist has no allowlist. `main` is yours: it got there
|
||||
through a pull request you merged.
|
||||
|
||||
```sh
|
||||
curl -fsS -H "Authorization: token $FORGE_TOKEN" \
|
||||
"https://gitea.example.com/api/v1/repos/$OWNER/$REPO/raw/.dejima/allowed-domains?ref=main"
|
||||
```
|
||||
|
||||
The consequence is the point of the whole arrangement: **widening what CI can
|
||||
reach is a diff on `main`.** It is reviewed, it is attributable, it is
|
||||
revertible, and it happens in the same file and by the same act that widens
|
||||
what a session can reach. A prompt injection that needs one more destination
|
||||
has to get that destination past you in a pull request first.
|
||||
|
||||
Refresh on a timer, render the `server=` lines, reload `dnsmasq`. Fail closed:
|
||||
a fetch that errors should leave the previous allowlist in place, never fall
|
||||
back to allowing everything — an allowlist builder is exactly the kind of
|
||||
script whose failure mode is easy to get backwards, because the broken version
|
||||
looks like a working one right up until it matters.
|
||||
dejima-ci-hardener's `dejima-ci-allowlist` is that, including the distinction
|
||||
that costs you here: a repository with no declaration is a 404 and ordinary,
|
||||
while a 401 or a 500 means this run does not know what the allowlist is and
|
||||
must not render one.
|
||||
|
||||
Two things to be deliberate about:
|
||||
|
||||
- **The base set should be narrower than dejima's built-ins, not equal to
|
||||
them.** A domain judged reasonable for a sandbox was judged as reachable
|
||||
from inside a firewalled container. On a runner the same name is reachable
|
||||
from a host on your network. `api.anthropic.com`, for one, has no business
|
||||
resolving on a runner.
|
||||
- **One file now grants in two places.** Merging a domain into
|
||||
`.dejima/allowed-domains` grants it to sessions *and* to CI. That is the
|
||||
price of a single reviewable grant instead of two lists that drift apart,
|
||||
and it is worth saying out loud when you review one.
|
||||
|
||||
You can shrink what the host itself needs, too: `runner.github_mirror` and
|
||||
Gitea's `DEFAULT_ACTIONS_URL` both redirect `uses:` action fetches away from
|
||||
github.com.
|
||||
|
||||
## Credentials a run is handed
|
||||
|
||||
**The job token.** Every job gets one, asked for or not. On Gitea 1.27 and
|
||||
later you can set the repository's default to restricted mode (Repository →
|
||||
Settings → Actions → General): read-only on `code`, `releases` and
|
||||
`packages`, nothing else. The property that makes it worth having is that
|
||||
workflow-level `permissions:` is *clamped* by that maximum, so a branch
|
||||
raising its own cannot exceed it — it is one of the very few controls here
|
||||
that the branch genuinely cannot touch.
|
||||
|
||||
Before 1.27, `permissions:` is ignored and the token is permissive. There is
|
||||
no supported way to narrow it, which is what makes branch protection above
|
||||
non-optional rather than merely advisable, and it is a concrete reason to
|
||||
upgrade.
|
||||
|
||||
**Secrets.** A secret exposed to a pull-request-triggered run is a secret
|
||||
exposed to whatever that branch contains. Give a run only what it needs, and
|
||||
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.
|
||||
|
||||
**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
|
||||
earning its place.
|
||||
|
||||
## CI you do not administer
|
||||
|
||||
On hosted CI — GitHub Actions, a shared pool, someone else's infrastructure —
|
||||
**there is no reliable pre-execution gate for a same-repository branch**, and
|
||||
the egress section above is unavailable to you: every candidate gate lives in
|
||||
a file the branch can edit, and the network the job runs on is not yours to
|
||||
program. That is a limit, not an oversight, and it is better known than
|
||||
guessed at.
|
||||
|
||||
What still applies:
|
||||
|
||||
- **A hosted runner gives you the runner-host section for free.** It is
|
||||
disposable and it is already off your network. The dangerous case is a
|
||||
*self-hosted shared pool*, where a job can land on a machine nobody hardened
|
||||
— and where the socket-mounting default above is nobody's decision, just the
|
||||
default.
|
||||
- **The credentials section applies everywhere.** `permissions: contents:
|
||||
read` where it is honoured, no secrets on pull-request runs, and no tunnel
|
||||
or credential that lets a runner reach your own infrastructure.
|
||||
- **Branch protection applies everywhere** and costs nothing.
|
||||
- **So does declining the grant.** If you cannot bound what CI does with push
|
||||
access, do not give push access: `[core] forge = none` with
|
||||
`[core] workflow = local-only` is a supported configuration in which the
|
||||
session commits locally and stops. The push grant *is* the CI
|
||||
code-execution grant; declining the first declines the second.
|
||||
|
||||
Gitea 1.27's **scoped workflows** are worth knowing about here: a workflow
|
||||
maintained in a central repository the sandbox cannot push to, registerable
|
||||
instance-wide, so the workflow file itself sits outside any agent branch's
|
||||
reach. Use it — but it is not a gate. Scoped workflows run *alongside* a
|
||||
consuming repository's own workflows rather than replacing them, so a branch
|
||||
can still add a workflow of its own.
|
||||
|
||||
## What this does not stop
|
||||
|
||||
- **Code still runs.** Everything here bounds *where* and *with what*, not
|
||||
*whether*. There is no point in the flow at which a human reads the diff
|
||||
before execution.
|
||||
- **A repository with no workflows is not therefore safe.** A branch can add
|
||||
one, so the exposure follows from which runners the repository can reach,
|
||||
not from what it currently runs. Disabling the Actions unit is what makes
|
||||
"this project has no CI" into a property rather than a coincidence.
|
||||
- **An allowed destination is still an exfiltration channel** through its own
|
||||
request surface — a query string, a lookup, an issue body. Domain
|
||||
allowlisting closes off reaching somewhere *new*; it cannot inspect what is
|
||||
sent somewhere already trusted. That is a structural property of
|
||||
allowlist-only filtering, and it is the same caveat
|
||||
[docs/security.md](security.md) states for the sandbox itself.
|
||||
- **The repository is not protected by any of this**, and does not need to be.
|
||||
A session that can push already has the code; CI does not newly expose it.
|
||||
- **A job on a runner that legitimately needs the Docker socket owns that
|
||||
host** for as long as it takes to rebuild it. Where a job genuinely needs a
|
||||
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.
|
||||
+99
-37
@@ -10,10 +10,12 @@ hardcoded into core. Each is a directory under `forges/`, `workflows/` or
|
||||
|
||||
| Axis | Directory | Selector | Ships |
|
||||
| --- | --- | --- | --- |
|
||||
| forge | `forges/` | `[core] forge` | `forges/gitea` |
|
||||
| workflow | `workflows/` | `[core] workflow` | `workflows/worktree-pr` |
|
||||
| forge | `forges/` | `[core] forge` | `forges/gitea`, `forges/github`, `forges/none` |
|
||||
| workflow | `workflows/` | `[core] workflow` | `workflows/worktree-pr`, `workflows/branch-push`, `workflows/local-only` |
|
||||
| harness | `harnesses/` | `[core] harness` | `harnesses/claude-code` |
|
||||
|
||||
See [Shipped providers](#shipped-providers) below for what each one is for.
|
||||
|
||||
**A provider is data.** Nothing in dejima sources, `eval`s or executes a
|
||||
manifest or a briefing fragment - a manifest is parsed with exactly the
|
||||
discipline the config file itself gets (unknown key is fatal, every value is
|
||||
@@ -118,10 +120,11 @@ operator can set, resolved the same way as any other config key
|
||||
| `issue_command` | `?command` | (empty) | The issue-management command name, same idea. |
|
||||
| `merge_block` | `?command` | (empty) | The literal command prefix `hooks/merge-guard.sh` refuses inside the sandbox (e.g. `gitea-pr merge`) - only when the workflow's `block_merge = on`. |
|
||||
| `keys` | `?list:keyspec` | (empty) | This forge's own `[forge.<name>]` config schema - see above. |
|
||||
| `requires` | `?list:identifier` | (empty) | Which of this forge's own config keys an operator is expected to set for it to actually work (used only for the partial-configuration warning below, not a launch-time gate today). |
|
||||
| `requires` | `?list:identifier` | (empty) | Which of this forge's own config keys an operator is expected to set for it to actually work - checked at launch by two refusal rules (partly-configured, and none-configured-with-a-fixed-host) when the workflow pushes; see [Validation](#validation). A key with a non-empty schema default never counts as missing for either check. |
|
||||
| `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 (none of the shipped forges use this). |
|
||||
| `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. |
|
||||
| `install` | `?list:relpath` | (empty) | Files under this forge's own directory that ship into the image - the only manifest key naming something that actually gets copied and executed. |
|
||||
| `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. |
|
||||
|
||||
@@ -136,7 +139,7 @@ keys = ssh_host:?hostname api:?url token_file:path=~/.config/gitea/toke
|
||||
requires = ssh_host api token_file
|
||||
host_keys = ssh_host
|
||||
url_keys = api
|
||||
install = <its two CLIs, listed relative to its own bin/ directory>
|
||||
install = <its CLIs, listed relative to its own bin/ directory>
|
||||
env_export = GITEA_API=api GITEA_SSH_HOST=ssh_host GITEA_TOKEN=@token_file
|
||||
```
|
||||
|
||||
@@ -146,9 +149,9 @@ prose refers to `gitea-pr`/`gitea-issue`; `hooks/merge-guard.sh` blocks
|
||||
config keys (`ssh_host`, `api`, `token_file`) from `keys`; an operator is
|
||||
expected to set all three (`requires`); `ssh_host` is a literal hostname and
|
||||
`api` is a URL, both of which end up in the firewall allowlist and the push
|
||||
guard's host list; `install` names the two executables under its own `bin/`
|
||||
subdirectory (`gitea-pr` and `gitea-issue`), which is what the Dockerfile
|
||||
copies into the image; and the container gets `GITEA_API`, `GITEA_SSH_HOST`,
|
||||
guard's host list; `install` names the executables under its own `bin/`
|
||||
subdirectory (`gitea-pr`, `gitea-issue` and `gitea-actions`), which is what
|
||||
the Dockerfile copies into the image; and the container gets `GITEA_API`, `GITEA_SSH_HOST`,
|
||||
and `GITEA_TOKEN` (the *contents* of the file at `token_file`, not its path)
|
||||
as environment variables.
|
||||
|
||||
@@ -265,11 +268,14 @@ see.
|
||||
Only resolved values, never an unresolved manifest or a provider directory
|
||||
itself:
|
||||
|
||||
- `DEJIMA_FORGE_HOSTS` - every hostname the selected forge needs reachable
|
||||
(`host_keys`, the host part of `url_keys`, plus `fixed_hosts`), unioned by
|
||||
`forge_hosts`.
|
||||
- `DEJIMA_PUSH_HOSTS` - `DEJIMA_FORGE_HOSTS` when the workflow's `push = on`,
|
||||
otherwise empty.
|
||||
- `DEJIMA_FORGE_HOSTS` - every hostname the selected forge needs *added to
|
||||
the firewall's allowlist* (`host_keys`, the host part of `url_keys`, plus
|
||||
`fixed_hosts`), unioned by `forge_hosts`. Empty for a forge declaring
|
||||
`hosts_allowlisted_by_core` - core already allows those, and adding them
|
||||
here would widen them to every port and drop their DNS-rebind protection.
|
||||
- `DEJIMA_PUSH_HOSTS` - every hostname `forge_hosts` resolves (including a
|
||||
`hosts_allowlisted_by_core` forge's, which the entry above omits) when the
|
||||
workflow's `push = on`, otherwise empty.
|
||||
- `DEJIMA_BLOCKED_BRANCHES` - the workflow's `blocked_branches`.
|
||||
- `DEJIMA_MERGE_BLOCK` - the forge's `merge_block`, but only when the
|
||||
workflow's `block_merge = on`; otherwise empty.
|
||||
@@ -291,33 +297,89 @@ both deliberate:
|
||||
- `DEJIMA_MERGE_BLOCK` fails **open** the same way - empty makes
|
||||
`merge-guard.sh` inert rather than conditionally installed.
|
||||
|
||||
## Validation, and what's deliberately deferred to Phase 3
|
||||
## Validation
|
||||
|
||||
`provider_validate_selection` checks the selected forge/workflow/harness at
|
||||
every launch: each manifest must validate, and a workflow's `requires` must
|
||||
be a subset of the forge's `capabilities` - a workflow asking for a
|
||||
capability the forge doesn't have is refused outright.
|
||||
every launch:
|
||||
|
||||
Two more rules are **planned but not enforced yet**, and a fresh install
|
||||
relies on that:
|
||||
- Each manifest must validate.
|
||||
- A workflow's `requires` must be a subset of the forge's `capabilities` - a
|
||||
workflow asking for a capability the forge doesn't have is refused
|
||||
outright.
|
||||
- The workflow's `autonomy` must be something the forge can actually back up:
|
||||
`autonomy = open-pr` needs a forge offering the `pr` capability; `autonomy
|
||||
= push-branch` needs `push = on` (it can't stop at a push it never makes).
|
||||
A mismatch is refused at launch rather than discovered by the agent
|
||||
mid-task.
|
||||
- A pushing workflow (`push = on`) needs a forge that resolves to somewhere
|
||||
to push to. A wholly unconfigured forge with nothing to configure at all
|
||||
(`forge = none`) is refused, pointing at a forge that needs no host
|
||||
configured (`forge = github`) or a workflow that does not push; a forge
|
||||
that has config keys but none of them resolve to a host is refused with
|
||||
"configure it, or select `forge = github`, or `forge = none` with a
|
||||
workflow that does not push".
|
||||
- A forge whose `requires` keys are **partly** set - some configured, some
|
||||
not (a key written but left empty, as `install.sh`'s commented-out starter
|
||||
config makes easy, counts as not set: it resolves to nothing, and every
|
||||
call the forge makes with it would fail inside the container) - is refused
|
||||
as a likely typo, but only when the workflow actually
|
||||
pushes: a non-pushing workflow never invokes the forge, so e.g. gitea's
|
||||
`ssh_host` alone (set only so the firewall allows fetches) is a legitimate
|
||||
configuration, not a typo. A wholly unconfigured forge is never flagged by
|
||||
this rule - that's the case above.
|
||||
- A pushing workflow whose forge has **none** of its required settings
|
||||
configured is refused too, even when `fixed_hosts` means the "resolves to
|
||||
no host" rule above never fires. This is what catches `github`: its single
|
||||
`requires` key (`push_token_file`) means "partly configured" (the rule
|
||||
above) can never trigger for it either - there's nothing to be partial
|
||||
between - so without this rule a pushing workflow could select `github`
|
||||
with no `push_token_file` set and launch anyway, `fixed_hosts` masking the
|
||||
fact that nothing was actually configured.
|
||||
|
||||
- A pushing workflow whose forge resolves to no host at all is not refused.
|
||||
Gitea's `ssh_host`/`api` have no schema default, and `install.sh` writes
|
||||
them commented out, so a fresh install would be refused by this rule if it
|
||||
existed today - while it launches fine as-is, with `git-push-guard.sh`
|
||||
blocking every push at runtime (fail-closed, see above). An unconfigured
|
||||
forge is a supported state ("skip this if you only push to GitHub" in the
|
||||
README), not a bug, until Phase 3 adds `forge = none` as the honest way to
|
||||
say so.
|
||||
- A forge's `requires` keys being unset produces a **warning**, not a
|
||||
refusal, and only when the forge is *partially* configured (some of
|
||||
`requires` set, some not) - a wholly unconfigured forge stays silent, the
|
||||
same supported case as above. A partial configuration is almost certainly
|
||||
a typo, so flagging it costs nothing even before Phase 3 turns this into a
|
||||
refusal alongside the rule above.
|
||||
Two qualifications worth stating explicitly for the "partly configured" rule:
|
||||
it only applies when the workflow's `push = on` (a non-pushing workflow never
|
||||
touches the forge, so an incomplete `[forge.<name>]` section is harmless),
|
||||
and a required key with a non-empty schema default - gitea's `token_file`,
|
||||
defaulting to `~/.config/gitea/token` - never counts as missing, since its
|
||||
resolved value is already correct even when the operator never wrote it.
|
||||
|
||||
## Providers not yet built
|
||||
## Shipped providers
|
||||
|
||||
`forges/github`, `forges/none`, `workflows/branch-push` and
|
||||
`workflows/local-only` are Phase 3 candidates. They do not exist in this
|
||||
repo today - nothing selects them, and no manifest for them ships.
|
||||
| axis | provider | notes |
|
||||
| --- | --- | --- |
|
||||
| forge | `gitea` | PRs and issues via `gitea-pr`/`gitea-issue`, shipped into the image |
|
||||
| forge | `github` | PRs and issues via `gh`; needs its own write-capable `push_token_file`; opens `ssh.github.com:22` when the selected workflow pushes |
|
||||
| forge | `none` | **default** - no capabilities, no host; pair with `local-only` |
|
||||
| workflow | `worktree-pr` | worktree → push → PR, stops at the PR |
|
||||
| workflow | `branch-push` | worktree → push, stops at the branch |
|
||||
| workflow | `local-only` | **default** - commits locally, pushes nothing |
|
||||
| harness | `claude-code` | **default**, and the only harness today |
|
||||
|
||||
**The shipped defaults are deliberately the non-pushing pair**
|
||||
(`forge = none`, `workflow = local-only`). A fresh install has no forge host
|
||||
and no forge credential, so any default naming a real forge would be an
|
||||
assertion about the operator's setup that nothing has established - and,
|
||||
because a pushing workflow whose forge resolves to no host is refused (see
|
||||
[Validation](#validation)), such a default would make an unconfigured
|
||||
`dejima` refuse to launch at all. The defaults instead say the one thing
|
||||
that is true of every fresh install: this sandbox pushes nowhere yet.
|
||||
Pushing is opted into by naming both a pushing workflow and a forge that
|
||||
resolves.
|
||||
|
||||
**Selecting `forge = github` is a real posture change, not just a config
|
||||
choice.** It opens `ssh.github.com:22` for push - the read-only GitHub access
|
||||
every session gets regardless (clone, fetch, `gh` API calls over port 443)
|
||||
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. Only when
|
||||
the selected workflow actually pushes - `forge = github` with
|
||||
`workflow = local-only` leaves port 22 shut. And `[forge.github] push_token_file` is
|
||||
deliberately a *separate*, write-capable token from `[github] token_file`
|
||||
(a read-only fine-grained PAT used for read access independent of which
|
||||
forge is selected): promoting the read-only token to push duty would defeat
|
||||
the point of scoping it read-only in the first place. Because `gh` only ever
|
||||
reads one token variable, dejima warns at startup when both files are
|
||||
present and non-empty - the write-capable `push_token_file` wins for that
|
||||
launch, and the read-only `token_file` is not used while `forge = github`.
|
||||
|
||||
+111
-3
@@ -10,6 +10,111 @@ at repositories you already trust. Everything below is about how bad a
|
||||
compromised session *inside* that trust boundary can get, not a claim that
|
||||
one can't happen.
|
||||
|
||||
## CI: push access is code execution elsewhere
|
||||
|
||||
The controls in this document bound what a session can reach *directly*. They
|
||||
do not bound what it can arrange to have run somewhere else, and if the
|
||||
project has CI, **granting a session push access grants it code execution on
|
||||
whatever host that CI runs on.** No operator action is needed anywhere in the
|
||||
chain: the push guard permits a feature branch, the briefing tells the agent
|
||||
to open a pull request without asking, `on: pull_request` fires, and the
|
||||
runner checks out that commit and executes it. The you-merge-not-the-agent
|
||||
gate sits strictly downstream — by the time anyone reads the diff, it has
|
||||
already run.
|
||||
|
||||
Nor does the code have to be a modified workflow file. A workflow that runs a
|
||||
test suite runs every file in it, so a plausible-looking helper in a test file
|
||||
is executed just as surely, and far more quietly, than an edited workflow
|
||||
would be.
|
||||
|
||||
Relative to the sandbox, a run adds three things: **execution on a host you
|
||||
own**, outside the container and outside the allowlist; **egress from a
|
||||
network position you own**, with no firewall and no DNS proxy in front of it;
|
||||
and **whatever credentials the run is handed**, including the token the forge
|
||||
injects into every job whether the workflow asks for one or not.
|
||||
|
||||
There is deliberately no approval gate offered for this. Every version of one
|
||||
— changing the trigger, dispatching by hand, leaving the runner stopped, the
|
||||
forge's own fork-PR approvals, filtering egress from inside the workflow — is
|
||||
reachable from the branch, covered by a credential the session already holds,
|
||||
or not a gate at all. The mitigations are environmental instead: a runner host
|
||||
you are willing to lose, job containers with no Docker socket, deny-by-default
|
||||
egress whose allowlist is read from `main` rather than from the branch, and no
|
||||
secret a run does not need. And where none of that is available, declining the
|
||||
push grant declines the code execution with it — `[core] forge = none` with
|
||||
`[core] workflow = local-only`.
|
||||
|
||||
This applies to any project developed in a dejima sandbox, not only to this
|
||||
repository. **→ [docs/ci.md](ci.md)** for the reasoning and the setup.
|
||||
|
||||
## To your forge, the sandbox is you
|
||||
|
||||
By default a session authenticates with your credentials: your SSH key through
|
||||
the forwarded agent, and a token generated under your account. A push, a pull
|
||||
request, a comment or a merge made from inside the sandbox is therefore
|
||||
**indistinguishable at the forge from one you made yourself** — not similar to
|
||||
it, identical.
|
||||
|
||||
The consequence is worth stating flatly, because it is easy to configure
|
||||
around without noticing: **every control your forge offers that keys on
|
||||
identity is a guard rather than a boundary.** Branch protection's merge
|
||||
whitelist, its push whitelist, required approvals, and the audit log all
|
||||
answer the question "who did this", and there is only one answer available.
|
||||
Putting yourself on a merge whitelist puts the sandbox on it too.
|
||||
|
||||
Three specific consequences:
|
||||
|
||||
- **Required approvals are not merely weak here, they are unusable.** A forge
|
||||
will not let a pull request's author approve it, and the sandbox opens pull
|
||||
requests as you — so requiring one approval means nobody can give it.
|
||||
- **The forge's audit trail cannot attribute.** "You pushed this" and "a
|
||||
session pushed this" are the same recorded event. This is the same shape as
|
||||
the exit-time audit's time-window caveat above: a record of what happened,
|
||||
not of who caused it.
|
||||
- **It compounds the SSH agent's reach.** The agent already signs for any repo
|
||||
your key can reach (below); the identity is what makes the resulting action
|
||||
land as yours everywhere it reaches.
|
||||
|
||||
### Give the sandbox its own identity
|
||||
|
||||
This is configuration, not a missing feature. Create a dedicated forge account
|
||||
with **write** access to the repositories a session should work on, and point
|
||||
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 the README.
|
||||
|
||||
With the two identities separated, the same settings stop being decorative:
|
||||
|
||||
| Setting | What it becomes |
|
||||
| --- | --- |
|
||||
| Merge whitelist: you | The session genuinely cannot merge |
|
||||
| Push whitelist on `main`: you, or nobody | The session pushes feature branches and nothing else |
|
||||
| Require approvals ≥ 1 | Usable — the session authors, you approve |
|
||||
|
||||
That last row is the one that turns "the operator merges, the agent does not"
|
||||
from a convention into something the forge enforces.
|
||||
|
||||
**Verify which credential a push actually uses.** Nothing here enforces one:
|
||||
`[core] ssh_key` is a second, independent credential path (below), so if your
|
||||
own agent is *also* forwarded, a push may still be signed by your personal
|
||||
key and the separate identity becomes decorative for exactly the operation it
|
||||
was meant to constrain. Make a test push and read what the forge attributes
|
||||
it to, rather than assuming.
|
||||
|
||||
Two things a separate identity does **not** do:
|
||||
|
||||
- It does not reduce what a compromised session can do inside the container,
|
||||
or to the mounted project. It changes what that session can do *to your
|
||||
forge*, and who the forge thinks did it.
|
||||
- It does not touch CI. A write-capable account can still push a feature
|
||||
branch, and pushing a branch is what makes CI execute code — see the
|
||||
section above. It also has no bearing on the token the forge injects into a
|
||||
CI job, which is a third principal again, subject to none of the settings in
|
||||
that table. **→ [docs/ci.md](ci.md)**
|
||||
|
||||
## Provider trust
|
||||
|
||||
Forges, workflows and harnesses (`forges/`, `workflows/`, `harnesses/`) are
|
||||
@@ -21,7 +126,8 @@ code. `bin/dejima` resolves the selected forge/workflow/harness **on the
|
||||
host** and passes only the resolved results into the container - which
|
||||
hosts pushes are allowed to, which branches are protected, the literal merge
|
||||
command to block, plus the forge's own environment variables (a Gitea API
|
||||
token, in the shipped forge's case) - never a manifest or a provider
|
||||
token for `gitea`, a write-capable PAT for `github`, nothing for `none`) -
|
||||
never a manifest or a provider
|
||||
directory itself. The one thing a provider ships that does run is a forge's
|
||||
`bin/`, copied into the image at build time as an ordinary program on
|
||||
`PATH`, no different from any other CLI baked into it.
|
||||
@@ -477,8 +583,10 @@ hands to whichever session starts against it. See
|
||||
rather than repo-scoped, so a compromised session can still reach (and
|
||||
push to) other repos on that host you have access to. This doesn't extend
|
||||
to GitHub by default: port 22 there is blocked unless `--allow-github-push`
|
||||
is given, so the forwarded agent has no transport to use for it at all
|
||||
otherwise (see "GitHub push: blocked by default, optional opt-in" above).
|
||||
is given, or `[core] forge = github` is selected with a workflow that
|
||||
pushes (which opens the same grant), so the forwarded agent otherwise has
|
||||
no transport to use for it at all (see "GitHub push: blocked by default,
|
||||
optional opt-in" above).
|
||||
- `[core] ssh_key` (see "Headless git push without agent forwarding") is a
|
||||
second, independent credential-loading path with the same "signs for any
|
||||
Gitea repo it has access to" shape as the forwarded agent above - the
|
||||
|
||||
Executable
+261
@@ -0,0 +1,261 @@
|
||||
#!/bin/bash
|
||||
# Read Gitea Actions runs and job logs from a shell - the CI-side counterpart
|
||||
# to gitea-pr and gitea-issue, and deliberately their twin rather than their
|
||||
# sibling: all three are copied into the sandbox image as single
|
||||
# self-contained files, and gitea-pr also lives in a dotfiles checkout, so a
|
||||
# shared library would be a third thing to keep in step across three places.
|
||||
# The duplicated config/token/curl preamble is pinned in all three by
|
||||
# test/static.bats instead.
|
||||
#
|
||||
# Its reason to exist is a sandboxed agent's: CI is where the tests that
|
||||
# cannot run in the sandbox actually run, and without this the only way to
|
||||
# see why one failed is for the operator to paste the log in by hand.
|
||||
set -euo pipefail
|
||||
|
||||
GITEA_TOKEN_FILE="${GITEA_TOKEN_FILE:-$HOME/.config/gitea/token}"
|
||||
|
||||
function usage() {
|
||||
echo "Usage: gitea-actions runs [--limit N]"
|
||||
echo " gitea-actions jobs [RUN]"
|
||||
echo " gitea-actions logs [RUN] [--job NAME|INDEX] [--all] [--timestamps]"
|
||||
echo ""
|
||||
echo "RUN is the number in a run's web URL (.../actions/runs/52). Left out,"
|
||||
echo "it means the newest run for HEAD's commit - after a push, that is"
|
||||
echo "almost always the one being asked about."
|
||||
echo ""
|
||||
echo "'logs' prints the failed jobs of a run; --all prints every job, and"
|
||||
echo "--job picks one by name (a substring is enough) or by index."
|
||||
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)."
|
||||
}
|
||||
|
||||
function require_config() {
|
||||
if [ -z "${GITEA_SSH_HOST:-}" ]; then
|
||||
echo "GITEA_SSH_HOST is not set. Add 'export GITEA_SSH_HOST=<gitea ssh hostname>'" \
|
||||
"(e.g. gitea.example.com) to ~/.profile.local" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "${GITEA_API:-}" ]; then
|
||||
echo "GITEA_API is not set. Add 'export GITEA_API=https://<gitea host>/api/v1'" \
|
||||
"(e.g. https://gitea.example.com/api/v1) to ~/.profile.local" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
function require_token() {
|
||||
if [ -z "${GITEA_TOKEN:-}" ] && [ -f "$GITEA_TOKEN_FILE" ]; then
|
||||
GITEA_TOKEN=$(<"$GITEA_TOKEN_FILE")
|
||||
fi
|
||||
if [ -z "${GITEA_TOKEN:-}" ]; then
|
||||
echo "No Gitea token found. Generate one in the Gitea web UI" \
|
||||
"(Settings > Applications, scopes write:repository + write:issue)" \
|
||||
"and save it to $GITEA_TOKEN_FILE (chmod 600), or set GITEA_TOKEN" \
|
||||
"in the environment for this invocation only." >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Feeds the auth header to curl as a config on stdin rather than as an
|
||||
# argument. Command lines are world-readable through /proc, so `-H
|
||||
# "Authorization: token $GITEA_TOKEN"` exposes the token to every other
|
||||
# process on the host for as long as the request runs - and to anything that
|
||||
# scrapes `ps` output, including the agents this repo exists to sandbox.
|
||||
# printf is a bash builtin, so the token never lands in another argv.
|
||||
function gitea_api() {
|
||||
printf 'header = "Authorization: token %s"\n' "$GITEA_TOKEN" |
|
||||
curl -sS -w '\n%{http_code}' -K - "$@"
|
||||
}
|
||||
|
||||
# Splits gitea_api's combined output into the globals `json` and `http_code`
|
||||
# and fails with the body on any non-2xx.
|
||||
function api_or_die() {
|
||||
local what="$1"; shift
|
||||
local response
|
||||
response=$(gitea_api "$@")
|
||||
http_code=$(echo "$response" | tail -1)
|
||||
json=$(echo "$response" | sed '$d')
|
||||
if [ "$http_code" -lt 200 ] || [ "$http_code" -ge 300 ]; then
|
||||
echo "Failed to $what (HTTP $http_code):" >&2
|
||||
echo "$json" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
function gitea_owner_repo() {
|
||||
local remote_url host owner_repo
|
||||
remote_url=$(git remote get-url origin 2>/dev/null) || {
|
||||
echo "No 'origin' remote found in this repository" >&2
|
||||
exit 1
|
||||
}
|
||||
host=$(echo "$remote_url" | sed -E 's#^[^@]+@([^:]+):.*#\1#')
|
||||
if [ "$host" != "$GITEA_SSH_HOST" ]; then
|
||||
echo "origin remote ($remote_url) is not on $GITEA_SSH_HOST; gitea-actions only supports this host" >&2
|
||||
exit 1
|
||||
fi
|
||||
owner_repo=$(echo "$remote_url" | sed -E 's#^[^@]+@[^:]+:##; s#\.git$##')
|
||||
echo "$owner_repo"
|
||||
}
|
||||
|
||||
# The API addresses runs by an internal id; every URL a human has shows the
|
||||
# per-repo run number instead, and the two are unrelated. Everything below
|
||||
# takes the number and resolves it here, against one page of the run list.
|
||||
#
|
||||
# With no number, HEAD's commit: a run list is not what someone who just
|
||||
# pushed is asking for. Matched on head_sha rather than on branch because a
|
||||
# pull_request run reports no branch at all - its ref is refs/pull/N/head.
|
||||
function resolve_run() {
|
||||
local want="${1:-}" sha
|
||||
api_or_die "list runs" "${GITEA_API}/repos/${owner}/${repo}/actions/runs?page=1&limit=$RUN_SCAN"
|
||||
if [ -n "$want" ]; then
|
||||
run_id=$(echo "$json" | jq -r --argjson n "$want" \
|
||||
'first(.workflow_runs[] | select(.run_number == $n) | .id) // empty')
|
||||
if [ -z "$run_id" ]; then
|
||||
echo "No run numbered $want in the last $RUN_SCAN runs (raise --limit, or see 'gitea-actions runs')" >&2
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
sha=$(git rev-parse HEAD 2>/dev/null) || {
|
||||
echo "Not in a git repository, so there is no HEAD to look up - pass a run number" >&2
|
||||
exit 1
|
||||
}
|
||||
run_id=$(echo "$json" | jq -r --arg sha "$sha" \
|
||||
'first(.workflow_runs[] | select(.head_sha == $sha) | .id) // empty')
|
||||
if [ -z "$run_id" ]; then
|
||||
echo "No run for HEAD (${sha:0:8}) in the last $RUN_SCAN runs - unpushed, or still queued?" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
run_number=$(echo "$json" | jq -r --argjson id "$run_id" \
|
||||
'first(.workflow_runs[] | select(.id == $id) | .run_number)')
|
||||
}
|
||||
|
||||
# Sets `jobs_json` for the resolved run. The job index a URL shows is the
|
||||
# tail of the job's html_url, not a field of its own, and the list does not
|
||||
# arrive in that order.
|
||||
function fetch_jobs() {
|
||||
api_or_die "list jobs for run $run_number" \
|
||||
"${GITEA_API}/repos/${owner}/${repo}/actions/runs/${run_id}/jobs"
|
||||
jobs_json=$(echo "$json" | jq '[.jobs[] | {
|
||||
index: (.html_url | split("/") | last | tonumber),
|
||||
id, name,
|
||||
result: (if .status == "completed" then .conclusion else .status end)
|
||||
}] | sort_by(.index)')
|
||||
}
|
||||
|
||||
function print_job_log() {
|
||||
local id="$1" name="$2" result="$3"
|
||||
echo "=== $name ($result) ==="
|
||||
api_or_die "fetch logs for job $name" \
|
||||
"${GITEA_API}/repos/${owner}/${repo}/actions/jobs/${id}/logs"
|
||||
if [ "$KEEP_TIMESTAMPS" = 1 ]; then
|
||||
echo "$json"
|
||||
else
|
||||
# Every line carries an RFC3339 stamp from the runner. Useful for
|
||||
# timing a hang, pure noise for reading a failure, and this output
|
||||
# is usually going into an agent's context.
|
||||
echo "$json" | sed -E 's/^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9:.]+Z //'
|
||||
fi
|
||||
echo
|
||||
}
|
||||
|
||||
RUN_SCAN=50
|
||||
KEEP_TIMESTAMPS=0
|
||||
|
||||
if [ "$#" -lt 1 ]; then
|
||||
usage
|
||||
exit 1
|
||||
fi
|
||||
|
||||
subcommand="$1"
|
||||
shift
|
||||
|
||||
case "$subcommand" in
|
||||
runs|jobs|logs)
|
||||
require_config
|
||||
require_token
|
||||
owner_repo=$(gitea_owner_repo)
|
||||
owner="${owner_repo%%/*}"
|
||||
repo="${owner_repo##*/}"
|
||||
;;
|
||||
*)
|
||||
usage
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
case "$subcommand" in
|
||||
runs)
|
||||
limit=20
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--limit) limit="$2"; shift 2 ;;
|
||||
*) echo "Unknown option: $1" >&2; usage; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
api_or_die "list runs" "${GITEA_API}/repos/${owner}/${repo}/actions/runs?page=1&limit=$limit"
|
||||
echo "$json" | jq -r '.workflow_runs[] | [
|
||||
(.run_number | tostring),
|
||||
(if .status == "completed" then .conclusion else .status end),
|
||||
.event,
|
||||
(.head_sha[0:8]),
|
||||
.display_title
|
||||
] | @tsv' | column -t -s $'\t'
|
||||
;;
|
||||
jobs)
|
||||
run_arg=""
|
||||
if [[ "${1:-}" =~ ^[0-9]+$ ]]; then run_arg="$1"; shift; fi
|
||||
if [ "$#" -gt 0 ]; then echo "Unknown option: $1" >&2; usage; exit 1; fi
|
||||
resolve_run "$run_arg"
|
||||
fetch_jobs
|
||||
echo "run $run_number:"
|
||||
echo "$jobs_json" | jq -r '.[] | [(.index | tostring), .result, .name] | @tsv' |
|
||||
column -t -s $'\t'
|
||||
;;
|
||||
logs)
|
||||
run_arg=""
|
||||
job_want=""
|
||||
want_all=0
|
||||
if [[ "${1:-}" =~ ^[0-9]+$ ]]; then run_arg="$1"; shift; fi
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--job) job_want="$2"; shift 2 ;;
|
||||
--all) want_all=1; shift ;;
|
||||
--timestamps) KEEP_TIMESTAMPS=1; shift ;;
|
||||
*) echo "Unknown option: $1" >&2; usage; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
resolve_run "$run_arg"
|
||||
fetch_jobs
|
||||
|
||||
if [ -n "$job_want" ]; then
|
||||
# An index is exact; anything else matches the name loosely, so
|
||||
# `--job host-docker` finds "host-docker" and `--job static`
|
||||
# finds "sandbox (static)".
|
||||
if [[ "$job_want" =~ ^[0-9]+$ ]]; then
|
||||
selected=$(echo "$jobs_json" | jq --argjson i "$job_want" '[.[] | select(.index == $i)]')
|
||||
else
|
||||
selected=$(echo "$jobs_json" | jq --arg n "$job_want" '[.[] | select(.name | contains($n))]')
|
||||
fi
|
||||
if [ "$(echo "$selected" | jq 'length')" -eq 0 ]; then
|
||||
echo "No job matching '$job_want' in run $run_number:" >&2
|
||||
echo "$jobs_json" | jq -r '.[] | " \(.index) \(.name)"' >&2
|
||||
exit 1
|
||||
fi
|
||||
elif [ "$want_all" = 1 ]; then
|
||||
selected="$jobs_json"
|
||||
else
|
||||
selected=$(echo "$jobs_json" | jq '[.[] | select(.result != "success")]')
|
||||
if [ "$(echo "$selected" | jq 'length')" -eq 0 ]; then
|
||||
echo "run $run_number: all $(echo "$jobs_json" | jq 'length') jobs succeeded (--all for their logs anyway)" >&2
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
while IFS=$'\t' read -r id name result; do
|
||||
print_job_log "$id" "$name" "$result"
|
||||
done < <(echo "$selected" | jq -r '.[] | [(.id | tostring), .name, .result] | @tsv')
|
||||
;;
|
||||
esac
|
||||
@@ -6,5 +6,5 @@ keys = ssh_host:?hostname api:?url token_file:path=~/.config/gitea/toke
|
||||
requires = ssh_host api token_file
|
||||
host_keys = ssh_host
|
||||
url_keys = api
|
||||
install = bin/gitea-pr bin/gitea-issue
|
||||
install = bin/gitea-pr bin/gitea-issue bin/gitea-actions
|
||||
env_export = GITEA_API=api GITEA_SSH_HOST=ssh_host GITEA_TOKEN=@token_file
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
# GitHub as the FORGE - meaning this sandbox pushes to it and opens PRs there.
|
||||
# Distinct from the read-only GitHub access every session gets regardless,
|
||||
# which is a core feature and stays independent of this selection.
|
||||
#
|
||||
# push_token_file is deliberately NOT [github] token_file. That one is a
|
||||
# dedicated read-only fine-grained PAT (see bin/dejima's comment on it):
|
||||
# promoting it here would turn a credential chosen for being unable to write
|
||||
# into the one this workflow pushes with. Selecting this forge asks for a
|
||||
# second, explicitly write-capable token.
|
||||
#
|
||||
# fixed_hosts rather than host_keys: these are the same three hosts for every
|
||||
# operator, so there is nothing to configure and nothing to get wrong. Note
|
||||
# that selecting this forge opens ssh.github.com:22 for push - a real change
|
||||
# in posture, documented in docs/providers.md.
|
||||
#
|
||||
# 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.
|
||||
capabilities = pr issues
|
||||
pr_command = gh pr
|
||||
issue_command = gh issue
|
||||
merge_block = gh pr merge
|
||||
keys = push_token_file:?path
|
||||
requires = push_token_file
|
||||
fixed_hosts = github.com ssh.github.com api.github.com
|
||||
hosts_allowlisted_by_core = on
|
||||
install =
|
||||
env_export = GH_TOKEN=@push_token_file
|
||||
@@ -0,0 +1,5 @@
|
||||
# Every field below is empty on purpose, not merely unset - this is the
|
||||
# forge with no capabilities, distinct from one that just isn't configured yet.
|
||||
capabilities =
|
||||
keys =
|
||||
requires =
|
||||
@@ -133,6 +133,21 @@ if [ -f "$decl_dir/dependencies" ]; then
|
||||
# a leading dash (e.g. "--allow-unauthenticated") must not validate
|
||||
# as a name, or this hook would echo into the agent's context an
|
||||
# entry the project's own approve/status commands treat as rejected.
|
||||
#
|
||||
# A tarball spec carries a digest and a URL rather than a bare name,
|
||||
# so it is matched separately and answered by whether its directory
|
||||
# is there. Reporting one as missing has to print the whole spec, and
|
||||
# that is deliberate: the digest is the part that makes the operator's
|
||||
# approval mean something, so a truncated form would be asking them to
|
||||
# approve something they cannot see.
|
||||
if [[ "$spec" == tarball:* ]]; then
|
||||
name="${spec#tarball:}"; name="${name%%@*}"
|
||||
[[ "$name" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]] || continue
|
||||
[ -d "/opt/dejima/$name" ] && continue
|
||||
missing_deps_n=$((missing_deps_n + 1))
|
||||
[ "$missing_deps_n" -le "$MAX_LISTED" ] && missing_deps="$missing_deps $spec"
|
||||
continue
|
||||
fi
|
||||
[[ "$spec" =~ ^(apt|npm|pip|cargo):[A-Za-z0-9][A-Za-z0-9._+-]*$ ]] || continue
|
||||
kind="${spec%%:*}"
|
||||
name="${spec#*:}"
|
||||
@@ -182,7 +197,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:
|
||||
bin/dejima-allow <domain> --container $container_id)"
|
||||
bin/dejima-allow --container $container_id <domain>)"
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
+83
-4
@@ -92,10 +92,34 @@ if [ ! -f "$SETTINGS" ]; then
|
||||
echo '{}' > "$SETTINGS"
|
||||
fi
|
||||
|
||||
# The registration that went with the symlink removed above. Two things
|
||||
# depend on it going: Claude Code reports a missing hook command on every
|
||||
# session start while it points at a path that no longer exists, and - until
|
||||
# the dedupe below stopped matching it - it also stood in for the
|
||||
# registration it was renamed from, so `register_hook merge-guard.sh`
|
||||
# skipped and the merge guard was registered nowhere at all. Dropped only
|
||||
# when the path it names is gone, so an operator's own file at that name
|
||||
# keeps its registration, matching the symlink check above.
|
||||
if [ ! -e "$old_merge_guard" ] && jq -e --arg s "/gitea-merge-guard.sh" \
|
||||
'[.hooks.PreToolUse[]?.hooks[]?.command // empty] | any(endswith($s))' \
|
||||
"$SETTINGS" >/dev/null 2>&1; then
|
||||
echo "Removing: stale pre-rename gitea-merge-guard.sh registration from $SETTINGS"
|
||||
settings_tmp=$(mktemp)
|
||||
jq --arg s "/gitea-merge-guard.sh" \
|
||||
'.hooks.PreToolUse = ((.hooks.PreToolUse // [])
|
||||
| map(.hooks = ((.hooks // []) | map(select((.command // "") | endswith($s) | not))))
|
||||
| map(select((.hooks | length) > 0)))' \
|
||||
"$SETTINGS" > "$settings_tmp" && mv "$settings_tmp" "$SETTINGS"
|
||||
fi
|
||||
|
||||
register_hook() {
|
||||
local event="$1" matcher="$2" script_name="$3" description="$4"
|
||||
if jq -e --arg s "$script_name" \
|
||||
"[.hooks.${event}[]?.hooks[]?.command // empty] | any(test(\$s))" \
|
||||
# endswith on the leading slash, not test(): test() is an unanchored
|
||||
# regex, so a hook whose name merely ends in another's - which is what a
|
||||
# rename like gitea-merge-guard.sh -> merge-guard.sh produces - read as
|
||||
# already registered, and the new hook was silently never added.
|
||||
if jq -e --arg s "/$script_name" \
|
||||
"[.hooks.${event}[]?.hooks[]?.command // empty] | any(endswith(\$s))" \
|
||||
"$SETTINGS" >/dev/null 2>&1; then
|
||||
echo "Skipping: $script_name already registered as a $event hook"
|
||||
return
|
||||
@@ -188,13 +212,41 @@ if config_exists; then
|
||||
else
|
||||
mkdir -p "$(config_root)"
|
||||
echo "Writing a starter config to $CONFIG_FILE"
|
||||
# An operator with GITEA_SSH_HOST/GITEA_API still exported was pushing to
|
||||
# a Gitea/Forgejo instance before the config file existed, so the starter
|
||||
# config keeps them pushing: forge and workflow are written out
|
||||
# explicitly rather than left at the (non-pushing) shipped defaults, and
|
||||
# gitea's own keys are enumerated so there is somewhere for the seeds to
|
||||
# land - _config_keys enumerates only the SELECTED forge's keys, and with
|
||||
# no config file yet that selection is the default, `none`.
|
||||
_starter_forge="$(config_default core.forge)"
|
||||
_starter_workflow="$(config_default core.workflow)"
|
||||
_starter_extra_keys=""
|
||||
if [ -n "${GITEA_SSH_HOST:-}" ] || [ -n "${GITEA_API:-}" ]; then
|
||||
_starter_forge=gitea
|
||||
_starter_workflow=worktree-pr
|
||||
_starter_extra_keys="$(_config_extra_keys_for forge gitea)"
|
||||
fi
|
||||
# Seeded from any legacy variables still exported, so the one-time
|
||||
# migration is a review rather than a retyping. Everything else is
|
||||
# written commented-out at its schema default, so the file doubles as
|
||||
# the reference for what can be set.
|
||||
{
|
||||
printf '# dejima configuration. See README.md.\n'
|
||||
printf '# Every key below is shown at its default; uncomment to change one.\n\n'
|
||||
printf '# Every key below is shown at its default; uncomment to change one.\n'
|
||||
printf '#\n'
|
||||
printf '# [core] forge and [core] workflow are the exception: they are written\n'
|
||||
printf '# out uncommented, so this file states what it selects rather than\n'
|
||||
printf '# inheriting whatever a future release defaults to.\n'
|
||||
printf '#\n'
|
||||
printf '# Those two decide whether this sandbox pushes at all. The shipped\n'
|
||||
printf '# default is forge = none with workflow = local-only: the agent\n'
|
||||
printf '# commits locally and pushes NOWHERE. To push, set workflow to\n'
|
||||
printf '# worktree-pr (push + open a PR) or branch-push, and set forge to\n'
|
||||
printf '# gitea (fill in [forge.gitea]) or github (fill in [forge.github]\n'
|
||||
printf '# push_token_file). A pushing workflow whose forge resolves to no\n'
|
||||
printf '# host is refused at launch, not launched with pushing quietly\n'
|
||||
printf '# broken.\n\n'
|
||||
_section=""
|
||||
while IFS= read -r _key; do
|
||||
[ -n "$_key" ] || continue
|
||||
@@ -209,6 +261,8 @@ else
|
||||
case "$_key" in
|
||||
forge.gitea.ssh_host) _seed="${GITEA_SSH_HOST:-}" ;;
|
||||
forge.gitea.api) _seed="${GITEA_API:-}" ;;
|
||||
core.forge) _seed="$_starter_forge" ;;
|
||||
core.workflow) _seed="$_starter_workflow" ;;
|
||||
core.command) _seed="${SANDBOX_CMD:-}" ;;
|
||||
core.ssh_key) _seed="${SANDBOX_SSH_KEY:-}" ;;
|
||||
mounts.readonly) _seed="${SANDBOX_EXTRA_MOUNTS:-}" ;;
|
||||
@@ -234,7 +288,7 @@ else
|
||||
else
|
||||
printf '# %s = %s\n' "$_leaf" "$_default"
|
||||
fi
|
||||
done < <(_config_keys)
|
||||
done < <(_config_keys; [ -z "$_starter_extra_keys" ] || printf '%s\n' "$_starter_extra_keys")
|
||||
} > "$CONFIG_FILE"
|
||||
chmod 600 "$CONFIG_FILE"
|
||||
fi
|
||||
@@ -245,6 +299,31 @@ if [ -f "$(config_root)/remote.conf" ]; then
|
||||
echo "[remote-access] section of $CONFIG_FILE. Left in place for you to remove."
|
||||
fi
|
||||
|
||||
# The [core] forge/workflow defaults changed from gitea+worktree-pr to
|
||||
# none+local-only, which silently stops an existing sandbox pushing if the
|
||||
# operator was relying on the defaults. Reported for the same reason the
|
||||
# remote.conf note above is: a config that means something different after an
|
||||
# upgrade than it did before must not change meaning quietly. Only for a
|
||||
# config that PARSES - config_is_set cannot distinguish "absent" from "the
|
||||
# file is broken", and a broken config already fails loudly at launch.
|
||||
if config_exists && config_validate 2>/dev/null; then
|
||||
if ! config_is_set core forge || ! config_is_set core workflow; then
|
||||
echo
|
||||
echo "Note: the defaults for [core] forge and [core] workflow have changed."
|
||||
echo "They were 'gitea' and 'worktree-pr'; they are now 'none' and 'local-only'."
|
||||
echo "$CONFIG_FILE does not set both of them explicitly, so this"
|
||||
echo "sandbox now commits locally and pushes NOWHERE where it used to push and"
|
||||
echo "open a Gitea/Forgejo PR."
|
||||
echo
|
||||
echo "To keep pushing, write both lines out under [core]:"
|
||||
echo " forge = gitea"
|
||||
echo " workflow = worktree-pr"
|
||||
echo "(with [forge.gitea] ssh_host and api filled in, which that pair has always"
|
||||
echo "required). To confirm the new behaviour instead, write 'forge = none' and"
|
||||
echo "'workflow = local-only' explicitly and this note stops appearing."
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "Done. Try: $BIN_DIR/dejima ~/some/project"
|
||||
echo "(or just 'dejima ~/some/project' once $BIN_DIR is on PATH)"
|
||||
echo
|
||||
|
||||
@@ -1710,3 +1710,23 @@ sidecar() { local r; r="$(find "$AUDITDIR" -name '*.txt' | sort | tail -1)"; r="
|
||||
run bash -c "printf 'q\n' | '$AUDIT' ack '$WS'"
|
||||
[ -f "$sc/$(key_of '.git/hooks/pre-commit').diff" ]
|
||||
}
|
||||
|
||||
@test "the report header names the writable mounts a session could reach" {
|
||||
mkdir -p "$WS/.git/hooks"; printf 'a\n' > "$WS/.git/hooks/pre-commit"
|
||||
snap
|
||||
printf 'curl evil\n' > "$WS/.git/hooks/pre-commit"
|
||||
run env DEJIMA_RW_MOUNTS="/srv/specs /srv/notes" "$AUDIT" compare "$WS" "$STATE"
|
||||
[[ "$output" == *"/srv/specs"* ]]
|
||||
[[ "$output" == *"/srv/notes"* ]]
|
||||
# Named as reachable-but-unhashed, not reported as a finding: writing to
|
||||
# one is the point of configuring it, so it must not read as an alert.
|
||||
[[ "$output" != *"! /srv/specs"* ]]
|
||||
}
|
||||
|
||||
@test "the report says nothing about writable mounts when there are none" {
|
||||
mkdir -p "$WS/.git/hooks"; printf 'a\n' > "$WS/.git/hooks/pre-commit"
|
||||
snap
|
||||
printf 'curl evil\n' > "$WS/.git/hooks/pre-commit"
|
||||
run "$AUDIT" compare "$WS" "$STATE"
|
||||
[[ "$output" != *"writable"* ]]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
#!/usr/bin/env bats
|
||||
#
|
||||
# Asserts on the RUNNER, not on this repository. Every other suite here tests
|
||||
# dejima's code; this one tests the machine executing it, because the controls
|
||||
# in docs/ci.md live in a runner's config.yaml and a host's firewall - set up
|
||||
# by dejima-ci-hardener, on a machine this repository never touches, so no
|
||||
# amount of reviewing this repository can check them.
|
||||
#
|
||||
# It exists because both failures that matter look exactly like success from
|
||||
# inside a job. Nothing in a job's log mentions whether the Docker socket was
|
||||
# mounted into it, and a job that can reach an off-list resolver still passes
|
||||
# every check that only inspects DOCKER-USER. Neither shows up as a red run;
|
||||
# they show up as nothing at all.
|
||||
#
|
||||
# DEJIMA_CI_TIER says which runner this is supposed to be, set by the workflow
|
||||
# rather than sniffed, so landing on the wrong runner is a failure rather than
|
||||
# a different set of assertions quietly being chosen. Unset means this is not
|
||||
# a CI runner and there is nothing here to assert.
|
||||
#
|
||||
# Its values are the names of the jobs whose environment they verify -
|
||||
# `sandbox` and `host-docker` - rather than a separate vocabulary. A reader
|
||||
# looking at a red `runner-env (host-docker)` should not have to work out
|
||||
# which job that corresponds to.
|
||||
|
||||
setup() {
|
||||
[ -n "${DEJIMA_CI_TIER:-}" ] || skip "not on a CI runner (DEJIMA_CI_TIER unset)"
|
||||
case "$DEJIMA_CI_TIER" in
|
||||
sandbox|host-docker) ;;
|
||||
*) echo "unknown DEJIMA_CI_TIER: $DEJIMA_CI_TIER" >&2; return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# An off-list destination that would answer if it were reachable. This matters
|
||||
# more than it looks: pointing these at an address that never answers - a
|
||||
# documentation range, say - would pass in an environment with no network at
|
||||
# all, which is the exact failure this suite exists to detect.
|
||||
OFF_LIST_IP=1.1.1.1
|
||||
|
||||
# The positive control's destination. Overridable because it has to be a name
|
||||
# THIS runner allows, and an allowlist is per-operator - hardcoding one would
|
||||
# fail on a runner that is correctly configured for a different set.
|
||||
ALLOWED_HOST=${DEJIMA_CI_ALLOWED_HOST:-deb.debian.org}
|
||||
|
||||
connects() { # connects HOST PORT -> 0 if a TCP connection completes
|
||||
python3 - "$1" "$2" <<'PY'
|
||||
import socket, sys
|
||||
try:
|
||||
socket.create_connection((sys.argv[1], int(sys.argv[2])), timeout=6).close()
|
||||
except OSError:
|
||||
sys.exit(1)
|
||||
PY
|
||||
}
|
||||
|
||||
# --- the grant that decides everything --------------------------------------
|
||||
|
||||
@test "the sandbox jobs are not handed the Docker daemon" {
|
||||
[ "$DEJIMA_CI_TIER" = sandbox ] || skip "asserted for the sandbox runner"
|
||||
# act_runner mounts /var/run/docker.sock into every job container unless
|
||||
# container.docker_host is "-". An unset value is the default, and the
|
||||
# default mounts - so this is the assertion that a runner was actually
|
||||
# configured rather than merely intended.
|
||||
[ ! -e /var/run/docker.sock ] || {
|
||||
echo "this runner grants job containers the Docker socket, which is" >&2
|
||||
echo "root-equivalent on the runner host. Set container.docker_host: \"-\"" >&2
|
||||
echo "in its config.yaml - see dejima-ci-hardener." >&2
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
@test "the host-docker job does have the daemon it is scheduled for" {
|
||||
[ "$DEJIMA_CI_TIER" = host-docker ] || skip "asserted for the host-docker runner"
|
||||
# The positive control for the label routing. Without it, a host-docker
|
||||
# job that landed on a hardened runner would fail later and obscurely, and
|
||||
# a sandbox job that landed on this one would be caught only by the test
|
||||
# above - one direction of a two-direction problem.
|
||||
[ -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" = sandbox ]; then
|
||||
[ ! -e /var/run/docker.sock ]
|
||||
else
|
||||
[ -e /var/run/docker.sock ]
|
||||
fi
|
||||
}
|
||||
|
||||
# --- egress, for the tier the rules actually apply to ------------------------
|
||||
#
|
||||
# Deliberately scoped to the sandbox runner. host-docker's job runs in the
|
||||
# HOST network namespace - that is the grant they exist for - so the bridge
|
||||
# rules dejima-ci-hardener installs do not sit in front of them at all. Their
|
||||
# egress is the host's own, which this repository's examples do not define.
|
||||
# Asserting an allowlist there would test nothing and pass anyway, which is
|
||||
# the failure mode this whole suite is about.
|
||||
|
||||
@test "an allowlisted destination is reachable" {
|
||||
[ "$DEJIMA_CI_TIER" = sandbox ] || skip "bridge rules do not apply in the host netns"
|
||||
# The positive control for everything below. Without it, a runner with no
|
||||
# network at all passes every negative assertion in this file.
|
||||
run getent hosts "$ALLOWED_HOST"
|
||||
[ "$status" -eq 0 ] || {
|
||||
echo "$ALLOWED_HOST did not resolve, so the negative checks below" >&2
|
||||
echo "would pass for the wrong reason. Check the resolver and the" >&2
|
||||
echo "allowlist rendered from .dejima/allowed-domains on main - or set" >&2
|
||||
echo "DEJIMA_CI_ALLOWED_HOST to a name this runner does allow." >&2
|
||||
false
|
||||
}
|
||||
run connects "$ALLOWED_HOST" 80
|
||||
[ "$status" -eq 0 ]
|
||||
}
|
||||
|
||||
@test "a name that is on no allowlist does not resolve" {
|
||||
[ "$DEJIMA_CI_TIER" = sandbox ] || skip "bridge rules do not apply in the host netns"
|
||||
# .invalid is reserved by RFC 2606 precisely so that it never resolves
|
||||
# anywhere - but here it has to be REFUSED by a resolver that answers for
|
||||
# allowlisted names, which the positive control above establishes.
|
||||
run getent hosts example.invalid
|
||||
[ "$status" -ne 0 ]
|
||||
}
|
||||
|
||||
@test "a job cannot reach a resolver of its own choosing" {
|
||||
[ "$DEJIMA_CI_TIER" = sandbox ] || skip "bridge rules do not apply in the host netns"
|
||||
# The check most easily left out, and the one that decides whether the DNS
|
||||
# allowlist means anything: a job that can query any resolver it likes can
|
||||
# resolve anything it likes, whatever dnsmasq is configured with. Traffic
|
||||
# to the resolver on the bridge gateway is addressed to the host, so it is
|
||||
# an INPUT packet and never passes through DOCKER-USER - filtering only
|
||||
# DOCKER-USER leaves this open.
|
||||
run connects "$OFF_LIST_IP" 53
|
||||
[ "$status" -ne 0 ] || {
|
||||
echo "reached $OFF_LIST_IP:53, so this job can use any resolver it likes and" >&2
|
||||
echo "the DNS allowlist is decorative. The INPUT rules are missing -" >&2
|
||||
echo "re-run dejima-ci-firewall on the runner host." >&2
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
@test "a destination that is on no allowlist is unreachable" {
|
||||
[ "$DEJIMA_CI_TIER" = sandbox ] || skip "bridge rules do not apply in the host netns"
|
||||
# An IP literal rather than a name, so this tests the firewall rather than
|
||||
# the resolver. Reaching it by name would fail at DNS first and prove
|
||||
# nothing about DOCKER-USER.
|
||||
run connects "$OFF_LIST_IP" 443
|
||||
[ "$status" -ne 0 ] || {
|
||||
echo "reached $OFF_LIST_IP:443, so this job has unrestricted egress." >&2
|
||||
echo "" >&2
|
||||
echo "If the DNS checks above passed, the resolver is fine and the packet" >&2
|
||||
echo "filter is not - which usually means DOCKER-USER is empty. Docker" >&2
|
||||
echo "recreates its own chains on every daemon start, so a 'systemctl" >&2
|
||||
echo "restart docker' drops these rules and nothing looks different" >&2
|
||||
echo "afterwards. Check with: iptables -S DOCKER-USER" >&2
|
||||
echo "" >&2
|
||||
echo "dejima-ci-hardener's dejima-ci-firewall.service reapplies them on" >&2
|
||||
echo "every Docker start, which is the fix rather than a one-off re-run." >&2
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
@test "IPv6 egress is not an unfiltered way around all of the above" {
|
||||
[ "$DEJIMA_CI_TIER" = sandbox ] || skip "bridge rules do not apply in the host netns"
|
||||
# An address family with no allowlist is not a smaller hole than the one
|
||||
# the rules above close; on a runner with IPv6 egress it is the same hole.
|
||||
#
|
||||
# Unlike the checks above, this one passes for two different reasons - v6
|
||||
# rejected, or v6 never configured - and both are acceptable outcomes, so
|
||||
# it is not given a positive control. What it rules out is the third case:
|
||||
# a runner with working IPv6 egress and rules written only for v4.
|
||||
run connects 2606:4700:4700::1111 443
|
||||
[ "$status" -ne 0 ]
|
||||
}
|
||||
+243
-16
@@ -464,6 +464,18 @@ write_config() {
|
||||
[[ "$output" == *"invalid value"* ]]
|
||||
}
|
||||
|
||||
@test "an invalid [core] forge reports one error, not two" {
|
||||
# config_get already prints "invalid value for [core] forge ..." and
|
||||
# returns empty; the collision-warning pass right after it used to feed
|
||||
# that empty value on to manifest_get, which failed a second time with
|
||||
# its own "invalid forge name:" line - noise on top of the real error.
|
||||
write_config '[core]' 'forge = not-a-real-forge'
|
||||
run "$REPO_ROOT/bin/dejima" --print-config
|
||||
[ "$status" -ne 0 ]
|
||||
[[ "$output" == *"invalid value for [core] forge"* ]]
|
||||
[[ "$output" != *"invalid forge name"* ]]
|
||||
}
|
||||
|
||||
@test "--print-config does not need Docker or a workspace argument" {
|
||||
write_config '[core]' 'firewall = on'
|
||||
PATH="/nonexistent:$PATH" run "$REPO_ROOT/bin/dejima" --print-config
|
||||
@@ -484,7 +496,12 @@ write_config() {
|
||||
key="$FAKE_HOME/sandbox-key"
|
||||
: > "$key"
|
||||
chmod 600 "$key"
|
||||
write_config '[core]' "ssh_key = $key" 'command = --evil'
|
||||
# forge = none, workflow = local-only: provider_validate_selection runs
|
||||
# ahead of this check, and the default gitea/worktree-pr pair is refused
|
||||
# unconfigured (see providers.bats) - this test is about core.command,
|
||||
# not about forge selection, so it sidesteps that with the honest
|
||||
# "no forge, no push" pair instead of configuring one just to get past it.
|
||||
write_config '[core]' "ssh_key = $key" 'command = --evil' 'forge = none' 'workflow = local-only'
|
||||
workspace="$(mktemp -d)"
|
||||
HOME="$FAKE_HOME" SSH_AUTH_SOCK= run "$REPO_ROOT/bin/dejima" "$workspace"
|
||||
[ "$status" -ne 0 ]
|
||||
@@ -673,7 +690,7 @@ write_config() {
|
||||
key="$FAKE_HOME/sandbox-key"
|
||||
: > "$key"
|
||||
chmod 600 "$key"
|
||||
write_config '[core]' "ssh_key = $key"
|
||||
write_config '[core]' "ssh_key = $key" 'forge = none' 'workflow = local-only'
|
||||
workspace="$(mktemp -d)"
|
||||
STUB_DIR="$(mktemp -d)"
|
||||
cat > "$STUB_DIR/docker" <<'EOF'
|
||||
@@ -704,7 +721,8 @@ EOF
|
||||
: > "$key"
|
||||
chmod 600 "$key"
|
||||
write_config '[core]' "ssh_key = $key" \
|
||||
'[forge.gitea]' 'token_file = /nonexistent/gitea-token'
|
||||
'[forge.gitea]' 'ssh_host = git.example' 'api = https://api.example/v1' \
|
||||
'token_file = /nonexistent/gitea-token'
|
||||
workspace="$(mktemp -d)"
|
||||
STUB_DIR="$(mktemp -d)"
|
||||
cat > "$STUB_DIR/docker" <<'EOF'
|
||||
@@ -734,6 +752,182 @@ EOF
|
||||
[[ "$output" == *"set the path, not the token"* ]]
|
||||
}
|
||||
|
||||
@test "selecting the github forge warns that [github] token_file is unused" {
|
||||
local tok="$CONFIG_DIR/push-token"
|
||||
printf 'ghp_example\n' > "$tok"
|
||||
local ro="$CONFIG_DIR/ro-token"
|
||||
printf 'ghp_readonly\n' > "$ro"
|
||||
write_config '[core]' 'forge = github' 'workflow = branch-push' \
|
||||
'[github]' "token_file = $ro" \
|
||||
'[forge.github]' "push_token_file = $tok"
|
||||
run "$REPO_ROOT/bin/dejima" --print-config
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
[[ "$output" == *"[github] token_file is not used"* ]]
|
||||
[[ "$output" == *"push_token_file"* ]]
|
||||
}
|
||||
|
||||
@test "the github token_file warning is silent for any other forge" {
|
||||
write_config '[core]' 'forge = gitea' '[github]' "token_file = $CONFIG_DIR/ro"
|
||||
run "$REPO_ROOT/bin/dejima" --print-config
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" != *"is not used"* ]]
|
||||
}
|
||||
|
||||
@test "the github token_file warning is silent when push_token_file is not also set" {
|
||||
# The warning claims GH_TOKEN comes from push_token_file - false unless
|
||||
# that is actually configured too. Wholly unconfigured (push_token_file
|
||||
# unset) is the same silent case provider_validate_selection's own
|
||||
# partial-config refusal defers to, not something to say out loud here.
|
||||
local ro="$CONFIG_DIR/ro-token"
|
||||
printf 'ghp_readonly\n' > "$ro"
|
||||
write_config '[core]' 'forge = github' "[github]" "token_file = $ro"
|
||||
run "$REPO_ROOT/bin/dejima" --print-config
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
[[ "$output" != *"is not used"* ]]
|
||||
}
|
||||
|
||||
@test "the github forge with no push_token_file leaves the read-only GH_TOKEN alone at launch" {
|
||||
# Finding 1 regression: forge_env_exports used to emit `GH_TOKEN=` (empty)
|
||||
# whenever push_token_file was unset, and `-e GH_TOKEN=` on `docker
|
||||
# compose run` overrides docker-compose.yml's own `environment: GH_TOKEN:
|
||||
# ${GH_TOKEN:-}` entry - the one carrying the independent read-only
|
||||
# [github] token_file value every session is supposed to get regardless
|
||||
# of forge. Asserted on the actual argument list handed to `docker`, not
|
||||
# on a warning, so this catches the container-facing symptom directly.
|
||||
FAKE_HOME="$(mktemp -d)"
|
||||
mkdir -p "$FAKE_HOME/.claude"
|
||||
echo '{}' > "$FAKE_HOME/.claude/settings.json"
|
||||
local ro="$CONFIG_DIR/ro-token"
|
||||
printf 'ghp_readonly\n' > "$ro"
|
||||
write_config '[core]' 'forge = github' 'workflow = local-only' \
|
||||
'[github]' "token_file = $ro"
|
||||
workspace="$(mktemp -d)"
|
||||
STUB_DIR="$(mktemp -d)"
|
||||
cat > "$STUB_DIR/docker" <<'EOF'
|
||||
#!/bin/sh
|
||||
for a in "$@"; do printf '%s\n' "$a" >> "$SANDBOX_TEST_DOCKER_LOG"; done
|
||||
exit 0
|
||||
EOF
|
||||
chmod +x "$STUB_DIR/docker"
|
||||
docker_log="$(mktemp)"
|
||||
HOME="$FAKE_HOME" PATH="$STUB_DIR:$PATH" SSH_AUTH_SOCK= \
|
||||
SANDBOX_TEST_DOCKER_LOG="$docker_log" \
|
||||
run "$REPO_ROOT/bin/dejima" "$workspace"
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
! grep -qxF 'GH_TOKEN=' "$docker_log" \
|
||||
|| { echo "GH_TOKEN= reached docker, clobbering the read-only token"; cat "$docker_log"; return 1; }
|
||||
rm -rf "$FAKE_HOME" "$workspace" "$docker_log"
|
||||
}
|
||||
|
||||
# Records both the environment `docker` is invoked with and its full
|
||||
# argument list - the two places a resolved DEJIMA_*/ALLOW_* value can reach
|
||||
# the container, and the only observable evidence of the firewall posture a
|
||||
# launch actually asks for without booting one (test/runtime.bats has the
|
||||
# other half, and needs Docker).
|
||||
stub_docker_log() {
|
||||
STUB_DIR="$(mktemp -d)"
|
||||
cat > "$STUB_DIR/docker" <<'EOF'
|
||||
#!/bin/sh
|
||||
echo "ALLOW_GITHUB_PUSH=$ALLOW_GITHUB_PUSH" >> "$SANDBOX_TEST_DOCKER_LOG"
|
||||
for a in "$@"; do printf '%s\n' "$a" >> "$SANDBOX_TEST_DOCKER_LOG"; done
|
||||
exit 0
|
||||
EOF
|
||||
chmod +x "$STUB_DIR/docker"
|
||||
}
|
||||
|
||||
@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.
|
||||
# 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
|
||||
# push grant, so this also used to be a container that never started.
|
||||
FAKE_HOME="$(mktemp -d)"
|
||||
mkdir -p "$FAKE_HOME/.claude"
|
||||
echo '{}' > "$FAKE_HOME/.claude/settings.json"
|
||||
local tok="$CONFIG_DIR/push-token"
|
||||
printf 'ghp_example\n' > "$tok"
|
||||
# A pushing workflow refuses to launch without a push credential; a
|
||||
# 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"
|
||||
chmod 600 "$key"
|
||||
write_config '[core]' 'forge = github' 'workflow = worktree-pr' \
|
||||
"ssh_key = $key" \
|
||||
'[forge.github]' "push_token_file = $tok"
|
||||
workspace="$(mktemp -d)"
|
||||
stub_docker_log
|
||||
docker_log="$(mktemp)"
|
||||
HOME="$FAKE_HOME" PATH="$STUB_DIR:$PATH" SSH_AUTH_SOCK= \
|
||||
SANDBOX_TEST_DOCKER_LOG="$docker_log" \
|
||||
run "$REPO_ROOT/bin/dejima" "$workspace"
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
# Nothing of this forge's in the extra-domains list at all.
|
||||
grep -qxF 'DEJIMA_FORGE_HOSTS=' "$docker_log" \
|
||||
|| { echo "DEJIMA_FORGE_HOSTS was not empty:"; grep '^DEJIMA_FORGE_HOSTS' "$docker_log"; return 1; }
|
||||
# But the guard still knows them, or the push it is told to make is
|
||||
# refused inside the container.
|
||||
grep -qxF 'DEJIMA_PUSH_HOSTS=github.com ssh.github.com api.github.com' "$docker_log" \
|
||||
|| { echo "push hosts wrong:"; grep '^DEJIMA_PUSH_HOSTS' "$docker_log"; return 1; }
|
||||
# And port 22 opens through the documented, logged, self-checked path.
|
||||
grep -qxF 'ALLOW_GITHUB_PUSH=1' "$docker_log" \
|
||||
|| { echo "the push grant was not opened"; return 1; }
|
||||
rm -rf "$FAKE_HOME" "$workspace" "$docker_log"
|
||||
}
|
||||
|
||||
@test "the github forge with a non-pushing workflow leaves port 22 shut" {
|
||||
# The grant is tied to the workflow actually pushing, not to the forge
|
||||
# being selected - local-only never pushes, so there is nothing to open.
|
||||
FAKE_HOME="$(mktemp -d)"
|
||||
mkdir -p "$FAKE_HOME/.claude"
|
||||
echo '{}' > "$FAKE_HOME/.claude/settings.json"
|
||||
local tok="$CONFIG_DIR/push-token"
|
||||
printf 'ghp_example\n' > "$tok"
|
||||
write_config '[core]' 'forge = github' 'workflow = local-only' \
|
||||
'[forge.github]' "push_token_file = $tok"
|
||||
workspace="$(mktemp -d)"
|
||||
stub_docker_log
|
||||
docker_log="$(mktemp)"
|
||||
HOME="$FAKE_HOME" PATH="$STUB_DIR:$PATH" SSH_AUTH_SOCK= \
|
||||
SANDBOX_TEST_DOCKER_LOG="$docker_log" \
|
||||
run "$REPO_ROOT/bin/dejima" "$workspace"
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
grep -qxF 'ALLOW_GITHUB_PUSH=0' "$docker_log" \
|
||||
|| { echo "the push grant was opened for a non-pushing workflow"; return 1; }
|
||||
grep -qxF 'DEJIMA_PUSH_HOSTS=' "$docker_log"
|
||||
grep -qxF 'DEJIMA_FORGE_HOSTS=' "$docker_log"
|
||||
rm -rf "$FAKE_HOME" "$workspace" "$docker_log"
|
||||
}
|
||||
|
||||
@test "a self-hosted forge's host still takes the extra-domains path" {
|
||||
# The other side of the same manifest key: a forge that does NOT declare
|
||||
# hosts_allowlisted_by_core keeps the behaviour that path exists for -
|
||||
# its host resolved into the allowlist, and no GitHub push grant.
|
||||
FAKE_HOME="$(mktemp -d)"
|
||||
mkdir -p "$FAKE_HOME/.claude"
|
||||
echo '{}' > "$FAKE_HOME/.claude/settings.json"
|
||||
local key="$FAKE_HOME/sandbox-key"
|
||||
: > "$key"
|
||||
chmod 600 "$key"
|
||||
write_config '[core]' 'forge = gitea' 'workflow = branch-push' \
|
||||
"ssh_key = $key" \
|
||||
'[forge.gitea]' 'ssh_host = git.example' 'api = https://api.example/v1' \
|
||||
'token_file = /dev/null'
|
||||
workspace="$(mktemp -d)"
|
||||
stub_docker_log
|
||||
docker_log="$(mktemp)"
|
||||
HOME="$FAKE_HOME" PATH="$STUB_DIR:$PATH" SSH_AUTH_SOCK= \
|
||||
SANDBOX_TEST_DOCKER_LOG="$docker_log" \
|
||||
run "$REPO_ROOT/bin/dejima" "$workspace"
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
grep -qxF 'DEJIMA_FORGE_HOSTS=git.example api.example' "$docker_log" \
|
||||
|| { echo "forge hosts wrong:"; grep '^DEJIMA_FORGE_HOSTS' "$docker_log"; return 1; }
|
||||
grep -qxF 'ALLOW_GITHUB_PUSH=0' "$docker_log"
|
||||
rm -rf "$FAKE_HOME" "$workspace" "$docker_log"
|
||||
}
|
||||
|
||||
@test "logging.loki_url reaches the launch environment" {
|
||||
FAKE_HOME="$(mktemp -d)"
|
||||
mkdir -p "$FAKE_HOME/.claude"
|
||||
@@ -741,7 +935,7 @@ EOF
|
||||
key="$FAKE_HOME/sandbox-key"
|
||||
: > "$key"
|
||||
chmod 600 "$key"
|
||||
write_config '[core]' "ssh_key = $key" \
|
||||
write_config '[core]' "ssh_key = $key" 'forge = none' 'workflow = local-only' \
|
||||
'[logging]' 'loki_url = https://loki.example.invalid'
|
||||
workspace="$(mktemp -d)"
|
||||
STUB_DIR="$(mktemp -d)"
|
||||
@@ -801,6 +995,24 @@ run_install() {
|
||||
[ -f "$CONFIG_DIR/remote.conf" ]
|
||||
}
|
||||
|
||||
@test "install.sh reports the changed forge/workflow defaults to a config that relies on them" {
|
||||
# The defaults moved from gitea + worktree-pr to none + local-only, which
|
||||
# silently stops an existing sandbox pushing. Same stance as the
|
||||
# remote.conf notice above: a config whose meaning changed under the
|
||||
# operator gets told so, with the lines that restore the old behaviour.
|
||||
write_config '[core]' 'firewall = on'
|
||||
run_install
|
||||
[[ "$output" == *"forge = gitea"* ]]
|
||||
[[ "$output" == *"workflow = worktree-pr"* ]]
|
||||
[[ "$output" == *"local-only"* ]]
|
||||
}
|
||||
|
||||
@test "install.sh stays quiet about the defaults once forge and workflow are explicit" {
|
||||
write_config '[core]' 'forge = none' 'workflow = local-only'
|
||||
run_install
|
||||
[[ "$output" != *"have changed"* ]]
|
||||
}
|
||||
|
||||
@test "the README documents the config file, not the retired variables" {
|
||||
# The retired variables must be gone from the setup instructions...
|
||||
run grep -nE '^\| `(SANDBOX_CMD|SANDBOX_EXTRA_MOUNTS|SANDBOX_NVIM_CONFIG|SANDBOX_SHARE_DIR|SANDBOX_CREDENTIAL_PROXY|LOKI_URL|ENABLE_FIREWALL|GITEA_SSH_HOST|GITEA_API|GITEA_TOKEN)`' \
|
||||
@@ -932,7 +1144,7 @@ EOF
|
||||
rw="$(mktemp -d)"
|
||||
ro="$(mktemp -d)"
|
||||
workspace="$(mktemp -d)"
|
||||
write_config '[core]' "ssh_key = $RW_KEY" \
|
||||
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only' \
|
||||
'[mounts]' "read_write = $rw" "readonly = $ro"
|
||||
_rw_launch "$workspace"
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
@@ -957,7 +1169,12 @@ EOF
|
||||
# though an actual session was told correctly. briefing_compose now
|
||||
# reads the same DEJIMA_RW_MOUNTS a real launch would.
|
||||
rw="$(mktemp -d)"
|
||||
write_config '[mounts]' "read_write = $rw"
|
||||
# forge = none, workflow = local-only: --print-briefing now runs after
|
||||
# provider_validate_selection too, and the default gitea/worktree-pr pair
|
||||
# is refused unconfigured - this test is about the mounts sentence, not
|
||||
# forge selection.
|
||||
write_config '[core]' 'forge = none' 'workflow = local-only' \
|
||||
'[mounts]' "read_write = $rw"
|
||||
run "$REPO_ROOT/bin/dejima" --print-briefing
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
[[ "$output" == *"outlive the container too: $rw"* ]]
|
||||
@@ -972,7 +1189,8 @@ EOF
|
||||
# rule never runs, which would pass this test for the wrong reason.
|
||||
mkdir -p "$CONFIG_DIR" "$RW_HOME/.claude" "$RW_HOME/.local/bin"
|
||||
for guarded in "$CONFIG_DIR" "$RW_HOME/.claude" "$RW_HOME/.local/bin" "$REPO_ROOT"; do
|
||||
write_config '[core]' "ssh_key = $RW_KEY" '[mounts]' "read_write = $guarded"
|
||||
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only' \
|
||||
'[mounts]' "read_write = $guarded"
|
||||
_rw_launch "$workspace"
|
||||
[ "$status" -ne 0 ] || { echo "accepted a writable mount over $guarded"; return 1; }
|
||||
[[ "$output" == *"Refusing"* ]] \
|
||||
@@ -980,13 +1198,15 @@ EOF
|
||||
done
|
||||
# The workspace is already mounted read-write; naming it again is a config
|
||||
# error worth surfacing, not a harmless duplicate.
|
||||
write_config '[core]' "ssh_key = $RW_KEY" '[mounts]' "read_write = $workspace"
|
||||
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only' \
|
||||
'[mounts]' "read_write = $workspace"
|
||||
_rw_launch "$workspace"
|
||||
[ "$status" -ne 0 ]
|
||||
[[ "$output" == *"Refusing"* ]]
|
||||
# A bare home directory contains several guarded paths, so the same
|
||||
# both-directions rule catches it without needing an entry of its own.
|
||||
write_config '[core]' "ssh_key = $RW_KEY" '[mounts]' "read_write = $RW_HOME"
|
||||
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only' \
|
||||
'[mounts]' "read_write = $RW_HOME"
|
||||
_rw_launch "$workspace"
|
||||
[ "$status" -ne 0 ]
|
||||
[[ "$output" == *"Refusing"* ]]
|
||||
@@ -1003,6 +1223,7 @@ EOF
|
||||
workspace="$(mktemp -d)"
|
||||
mkdir -p "$RW_HOME/data"
|
||||
write_config '[core]' "ssh_key = $RW_KEY" "share_dir = $RW_HOME/share" \
|
||||
'forge = none' 'workflow = local-only' \
|
||||
'[audit]' "dir = $RW_HOME/data/store/audit" \
|
||||
'[mounts]' "read_write = $RW_HOME/data"
|
||||
[ ! -e "$RW_HOME/data/store" ]
|
||||
@@ -1022,7 +1243,7 @@ EOF
|
||||
# overlap rule under test never runs.
|
||||
mkdir -p "$store/audit"
|
||||
for key in dir baselines_dir; do
|
||||
write_config '[core]' "ssh_key = $RW_KEY" \
|
||||
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only' \
|
||||
'[audit]' "$key = $store/audit" \
|
||||
'[mounts]' "read_write = $store/audit"
|
||||
_rw_launch "$workspace"
|
||||
@@ -1038,7 +1259,7 @@ EOF
|
||||
_rw_setup
|
||||
workspace="$(mktemp -d)"
|
||||
outer="$(mktemp -d)"
|
||||
write_config '[core]' "ssh_key = $RW_KEY" \
|
||||
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only' \
|
||||
'[audit]' "dir = $outer/nested/audit" \
|
||||
'[mounts]' "read_write = $outer"
|
||||
_rw_launch "$workspace"
|
||||
@@ -1055,7 +1276,7 @@ EOF
|
||||
workspace="$(mktemp -d)"
|
||||
for spelling in "/" "/." "//"; do
|
||||
d="$(mktemp -d)"
|
||||
write_config '[core]' "ssh_key = $RW_KEY" \
|
||||
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only' \
|
||||
'[mounts]' "readonly = $d" "read_write = ${d}${spelling}"
|
||||
_rw_launch "$workspace"
|
||||
[ "$status" -ne 0 ] || { echo "both lists accepted '$d' vs '${d}${spelling}'"; return 1; }
|
||||
@@ -1070,7 +1291,8 @@ EOF
|
||||
_rw_setup
|
||||
d="$(mktemp -d)"
|
||||
workspace="$(mktemp -d)"
|
||||
write_config '[core]' "ssh_key = $RW_KEY" '[mounts]' "read_write = $d" "readonly = $d"
|
||||
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only' \
|
||||
'[mounts]' "read_write = $d" "readonly = $d"
|
||||
_rw_launch "$workspace"
|
||||
[ "$status" -ne 0 ]
|
||||
[[ "$output" == *"both name the same"* ]]
|
||||
@@ -1080,7 +1302,8 @@ EOF
|
||||
@test "a read_write entry that is not an existing directory is refused" {
|
||||
_rw_setup
|
||||
workspace="$(mktemp -d)"
|
||||
write_config '[core]' "ssh_key = $RW_KEY" '[mounts]' 'read_write = /nonexistent/nowhere'
|
||||
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only' \
|
||||
'[mounts]' 'read_write = /nonexistent/nowhere'
|
||||
_rw_launch "$workspace"
|
||||
[ "$status" -ne 0 ]
|
||||
[[ "$output" == *"not an existing directory"* ]]
|
||||
@@ -1110,8 +1333,12 @@ EOF
|
||||
# exercises the real launch path to verify the refusal actually stops it.
|
||||
_rw_setup
|
||||
workspace="$(mktemp -d)"
|
||||
# Configure gitea forge with a valid config plus a token file with a newline.
|
||||
printf '[core]\nfirewall = on\nssh_key = %s\n[forge.gitea]\nssh_host = git.example\napi = https://api.example\ntoken_file = %s/token\n' \
|
||||
# Configure gitea forge with a valid config plus a token file with a
|
||||
# newline. forge/workflow are named explicitly because this test is about
|
||||
# a gitea token reaching the container: the shipped defaults select no
|
||||
# forge at all, so leaving them implicit would mean nothing ever reads
|
||||
# token_file and the refusal under test could never fire.
|
||||
printf '[core]\nfirewall = on\nssh_key = %s\nforge = gitea\nworkflow = worktree-pr\n[forge.gitea]\nssh_host = git.example\napi = https://api.example\ntoken_file = %s/token\n' \
|
||||
"$RW_KEY" "$CONFIG_DIR" > "$CONFIG_DIR/config"
|
||||
printf 'real-secret\ngarbage\n' > "$CONFIG_DIR/token"
|
||||
STUB_DIR="$(mktemp -d)"
|
||||
|
||||
+846
-61
File diff suppressed because it is too large
Load Diff
+382
-26
@@ -12,6 +12,21 @@ teardown() { rm -rf "$CONFIG_DIR" ${FAKE_ROOT:+"$FAKE_ROOT"}; }
|
||||
|
||||
lib() { bash -c 'source "$1"; shift; "$@"' _ "$LIB" "$@"; }
|
||||
|
||||
# Checks the CLAIM a remedy message makes rather than pinning its wording:
|
||||
# every "forge = <name>" it names must be a forge that actually exists. A
|
||||
# literal like "forge = github" would still pass this after github was
|
||||
# renamed or removed, silently advising a value that no longer works.
|
||||
assert_remedy_forges_exist() {
|
||||
local text="$1" real name found=0
|
||||
real="$(lib provider_list forge)"
|
||||
while IFS= read -r name; do
|
||||
[ -n "$name" ] || continue
|
||||
found=1
|
||||
grep -qxF "$name" <<< "$real" || { echo "remedy names nonexistent forge: $name"; return 1; }
|
||||
done < <(grep -oE 'forge = [a-z0-9-]+' <<< "$text" | awk '{print $3}')
|
||||
[ "$found" -eq 1 ] || { echo "remedy names no forge at all"; return 1; }
|
||||
}
|
||||
|
||||
@test "every shipped manifest validates" {
|
||||
for axis in forge workflow harness; do
|
||||
while IFS= read -r name; do
|
||||
@@ -172,54 +187,124 @@ lib() { bash -c 'source "$1"; shift; "$@"' _ "$LIB" "$@"; }
|
||||
[[ "$output" == *"needy"* ]]
|
||||
}
|
||||
|
||||
@test "a pushing workflow on a forge with no hosts is not refused - rule 3 is deferred to Phase 3" {
|
||||
@test "a pushing workflow on a forge with no hosts is refused" {
|
||||
# gitea's ssh_host/api have no schema default and install.sh writes them
|
||||
# commented out, so refusing this would refuse a fresh install - see the
|
||||
# comment above provider_validate_selection's push check. Phase 3 adds
|
||||
# `forge = none` and this becomes a refusal then.
|
||||
# commented out, so this is what an operator who selects a pushing
|
||||
# workflow without configuring the forge hits - `forge = none` (the
|
||||
# shipped default) is the honest way to say "no forge" instead.
|
||||
FAKE_ROOT="$(mktemp -d)"
|
||||
mkdir -p "$FAKE_ROOT/forges/hostless" "$FAKE_ROOT/workflows/pusher" "$FAKE_ROOT/harnesses/h"
|
||||
printf 'capabilities =\n' > "$FAKE_ROOT/forges/hostless/forge.conf"
|
||||
# keys is non-empty - this forge IS configurable, just not configured -
|
||||
# unlike `none`, which has no keys at all and gets its own message below.
|
||||
printf 'capabilities =\nkeys = ssh_host:?hostname\n' > "$FAKE_ROOT/forges/hostless/forge.conf"
|
||||
printf 'requires =\npush = on\n' > "$FAKE_ROOT/workflows/pusher/workflow.conf"
|
||||
printf 'command = claude\n' > "$FAKE_ROOT/harnesses/h/harness.conf"
|
||||
run env DEJIMA_PROVIDER_ROOT="$FAKE_ROOT" \
|
||||
bash -c 'source "$1"; provider_validate_selection hostless pusher h' _ "$LIB"
|
||||
[ "$status" -ne 0 ]
|
||||
[[ "$output" == *"forge = none"* ]]
|
||||
}
|
||||
|
||||
@test "a pushing workflow against a forge with no host is refused, and names none" {
|
||||
# gitea with nothing configured resolves to no host at all.
|
||||
printf '[core]\nfirewall = on\n' > "$CONFIG_DIR/config"
|
||||
run lib provider_validate_selection gitea worktree-pr claude-code
|
||||
[ "$status" -ne 0 ]
|
||||
[[ "$output" == *"forge = none"* ]]
|
||||
}
|
||||
|
||||
@test "a partly configured forge is refused, not merely warned about" {
|
||||
# token_file is not named here (and would not need to be, to make this
|
||||
# partial): its schema default (~/.config/gitea/token) means it can
|
||||
# never be genuinely missing, so it never enters set_n/unset_n at all -
|
||||
# see "the shipped selection validates with the documented setup" below.
|
||||
printf '[forge.gitea]\nssh_host = git.example\n' > "$CONFIG_DIR/config"
|
||||
run lib provider_validate_selection gitea worktree-pr claude-code
|
||||
[ "$status" -ne 0 ]
|
||||
[[ "$output" == *"api"* ]]
|
||||
}
|
||||
|
||||
@test "the shipped defaults are a non-pushing pair that validates with no forge configured" {
|
||||
# A fresh install must launch. The defaults were gitea + worktree-pr,
|
||||
# which is a PUSHING pair, and rule 3 refuses a pushing workflow whose
|
||||
# forge resolves to no host - so an unedited config was refused at every
|
||||
# launch until a forge was configured. Driven off the schema rather than
|
||||
# the literal names, so a future default that reintroduces the
|
||||
# contradiction fails here, next to the rule it contradicts, instead of
|
||||
# in twenty-odd launcher tests that never mention forges.
|
||||
printf '[core]\nfirewall = on\n' > "$CONFIG_DIR/config"
|
||||
local forge workflow harness
|
||||
forge="$(lib config_default core.forge)"
|
||||
workflow="$(lib config_default core.workflow)"
|
||||
harness="$(lib config_default core.harness)"
|
||||
[ "$(lib manifest_get workflow "$workflow" push)" = "off" ] \
|
||||
|| { echo "default workflow '$workflow' pushes - a fresh install has no forge to push to"; return 1; }
|
||||
run lib provider_validate_selection "$forge" "$workflow" "$harness"
|
||||
[ "$status" -eq 0 ] || { echo "the shipped defaults do not validate unconfigured: $output"; return 1; }
|
||||
}
|
||||
|
||||
@test "a non-pushing workflow against an unconfigured forge is still fine" {
|
||||
# local-only pushes nothing, so having no host is not a contradiction.
|
||||
printf '[core]\nfirewall = on\n' > "$CONFIG_DIR/config"
|
||||
run lib provider_validate_selection none local-only claude-code
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
}
|
||||
|
||||
@test "the shipped selection validates" {
|
||||
# provider_validate_selection runs after bin/dejima's own config_exists
|
||||
# gate in real use, so a config file is always present by then - gitea's
|
||||
# requires (ssh_host, api) need real values here for the same reason.
|
||||
@test "the shipped selection validates with the documented setup" {
|
||||
# This is the regression guard for the README/install.sh setup: an
|
||||
# operator is told to set only ssh_host and api and save the token at
|
||||
# token_file's schema default path (~/.config/gitea/token), leaving
|
||||
# token_file itself unset in the config. A key with a non-empty schema
|
||||
# default can never be genuinely missing - its resolved value is already
|
||||
# correct - so it must not make this look partly configured and refused.
|
||||
printf '[forge.gitea]\nssh_host = git.example\napi = https://api.example/v1\n' \
|
||||
> "$CONFIG_DIR/config"
|
||||
run lib provider_validate_selection gitea worktree-pr claude-code
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
}
|
||||
|
||||
@test "a wholly unconfigured forge produces no warning and still validates" {
|
||||
# The supported "I don't use a forge" case (rule 3's comment, README
|
||||
# "Skip this if you only push to GitHub") - a test asserting only the
|
||||
# warning below would pass against a version that refused this instead.
|
||||
# gitea's requires includes token_file, which has a schema default
|
||||
# (~/.config/gitea/token) and so ALWAYS resolves to a non-empty value
|
||||
# via config_get, even though nothing here sets it - this must still
|
||||
# count as "unset" (config_is_set, not config_get) or a config that sets
|
||||
# none of gitea's keys would look partly configured and warn.
|
||||
@test "a wholly unconfigured forge still validates against a non-pushing workflow" {
|
||||
# The supported "I don't use a forge" case is now scoped to a workflow
|
||||
# that doesn't push (local-only) - a pushing one is rule 3's refusal
|
||||
# above. Rules 3 and 5 (and the single-required-key check) are all
|
||||
# gated on push = on, so local-only never reaches any of them regardless
|
||||
# of what is or isn't configured.
|
||||
printf '[core]\nfirewall = on\n' > "$CONFIG_DIR/config"
|
||||
run lib config_default forge.gitea.token_file
|
||||
[ -n "$output" ] || { echo "test premise broken: token_file has no default"; return 1; }
|
||||
run lib provider_validate_selection gitea worktree-pr claude-code
|
||||
run lib provider_validate_selection gitea local-only claude-code
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
[[ "$output" != *"Warning"* ]] || { echo "unexpected warning: $output"; return 1; }
|
||||
}
|
||||
|
||||
@test "a partially configured forge warns but still validates" {
|
||||
@test "a partially configured forge still validates against a non-pushing workflow" {
|
||||
# Rule 5 is gated on push = on: local-only never invokes the forge, so
|
||||
# setting only ssh_host (e.g. so the firewall allows fetches) is a
|
||||
# legitimate configuration, not a typo to refuse.
|
||||
printf '[forge.gitea]\nssh_host = git.example\n' > "$CONFIG_DIR/config"
|
||||
run lib provider_validate_selection gitea worktree-pr claude-code
|
||||
run lib provider_validate_selection gitea local-only claude-code
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
[[ "$output" == *"partly configured"* ]]
|
||||
[[ "$output" == *"api"* ]]
|
||||
}
|
||||
|
||||
@test "a pushing workflow against forge = none names the workflow, not [forge.none]" {
|
||||
# none has no keys at all, so "Configure [forge.none]" would be a fix
|
||||
# that does not exist - the only honest remedy is the workflow.
|
||||
printf '[core]\nfirewall = on\n' > "$CONFIG_DIR/config"
|
||||
run lib provider_validate_selection none branch-push claude-code
|
||||
[ "$status" -ne 0 ]
|
||||
[[ "$output" == *"nothing to configure"* ]]
|
||||
[[ "$output" == *"workflow = local-only"* ]]
|
||||
[[ "$output" != *"[forge.none]"* ]]
|
||||
}
|
||||
|
||||
@test "a pushing workflow against a single-required-key forge with nothing set is refused" {
|
||||
# github's fixed_hosts (github.com et al) are pinned independent of
|
||||
# config, so forge_hosts is never empty and rule 3 alone would let a
|
||||
# wholly unconfigured github sail through. github's `requires` is also
|
||||
# exactly one key, so it can never be "partly" configured (rule 5's own
|
||||
# condition) - it is either wholly set or wholly unset. This is the gap
|
||||
# between the two rules that only a dedicated check closes.
|
||||
printf '[core]\nfirewall = on\nforge = github\nworkflow = branch-push\n' > "$CONFIG_DIR/config"
|
||||
run lib provider_validate_selection github branch-push claude-code
|
||||
[ "$status" -ne 0 ]
|
||||
[[ "$output" == *"push_token_file"* ]]
|
||||
}
|
||||
|
||||
@test "forge_hosts unions literal host keys, URL-derived hosts and fixed hosts" {
|
||||
@@ -404,6 +489,29 @@ lib() { bash -c 'source "$1"; shift; "$@"' _ "$LIB" "$@"; }
|
||||
[ "$output" = "0" ]
|
||||
}
|
||||
|
||||
@test "the none forge declares no capabilities, keys or hosts" {
|
||||
run lib manifest_validate forge none
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
[ -z "$(lib manifest_get forge none capabilities)" ]
|
||||
[ -z "$(lib manifest_get forge none keys)" ]
|
||||
[ -z "$(lib manifest_get forge none requires)" ]
|
||||
# No host sources at all - this is what makes it the honest "I have no
|
||||
# forge" answer rather than a forge that merely isn't configured yet.
|
||||
[ -z "$(lib forge_hosts none)" ]
|
||||
[ -z "$(lib forge_env_exports none)" ]
|
||||
}
|
||||
|
||||
@test "the none forge ships no CLIs into the image" {
|
||||
[ -z "$(lib manifest_get forge none install)" ]
|
||||
[ ! -d "$REPO_ROOT/forges/none/bin" ]
|
||||
}
|
||||
|
||||
@test "a workflow needing a capability is refused against the none forge" {
|
||||
run lib provider_validate_selection none worktree-pr claude-code
|
||||
[ "$status" -ne 0 ]
|
||||
[[ "$output" == *"requires the 'pr' capability"* ]]
|
||||
}
|
||||
|
||||
@test "docs/providers.md documents every manifest key" {
|
||||
for axis in forge workflow harness; do
|
||||
while IFS=$'\t' read -r key _rest; do
|
||||
@@ -414,8 +522,256 @@ lib() { bash -c 'source "$1"; shift; "$@"' _ "$LIB" "$@"; }
|
||||
done
|
||||
}
|
||||
|
||||
@test "docs/providers.md documents every shipped provider" {
|
||||
local doc="$REPO_ROOT/docs/providers.md" axis name
|
||||
for axis in forge workflow harness; do
|
||||
while IFS= read -r name; do
|
||||
[ -n "$name" ] || continue
|
||||
grep -q "$name" "$doc" || { echo "docs/providers.md never mentions $axis '$name'"; return 1; }
|
||||
done < <(lib provider_list "$axis")
|
||||
done
|
||||
}
|
||||
|
||||
@test "no doc still says the new providers are unbuilt" {
|
||||
! grep -qi 'not yet built\|Phase 3 candidates\|deferred to Phase 3' "$REPO_ROOT/docs/providers.md"
|
||||
}
|
||||
|
||||
@test "the github forge's firewall posture change is documented" {
|
||||
grep -q 'ssh.github.com' "$REPO_ROOT/docs/providers.md"
|
||||
grep -qi 'push' "$REPO_ROOT/docs/providers.md"
|
||||
}
|
||||
|
||||
@test "no doc still points at the old gitea CLI paths" {
|
||||
run grep -rn 'bin/gitea-pr\|bin/gitea-issue' "$REPO_ROOT/README.md" \
|
||||
"$REPO_ROOT/docs" "$REPO_ROOT/install.sh"
|
||||
[ "$status" -ne 0 ] || { echo "$output"; return 1; }
|
||||
}
|
||||
|
||||
@test "the briefing names a real forge host, and says nothing about one for none" {
|
||||
printf '[forge.gitea]\nssh_host = git.example\n' > "$CONFIG_DIR/config"
|
||||
run lib briefing_compose gitea worktree-pr claude-code
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
[[ "$output" == *"GitHub read-only, the configured gitea host (git.example), plus anything"* ]]
|
||||
|
||||
# workflows/local-only (Task 4) is what `forge = none` is meant to pair
|
||||
# with - none's capabilities are empty, and local-only requires none, so
|
||||
# this composes the real shipped trio rather than a throwaway workflow.
|
||||
run lib briefing_compose none local-only claude-code
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
# No dangling article, no "none" read as a forge name.
|
||||
[[ "$output" == *"GitHub read-only, plus anything"* ]]
|
||||
[[ "$output" != *"configured none host"* ]]
|
||||
}
|
||||
|
||||
@test "branch-push pushes and blocks main, but attempts no PR" {
|
||||
run lib manifest_validate workflow branch-push
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
[ "$(lib manifest_get workflow branch-push push)" = "on" ]
|
||||
[ "$(lib manifest_get workflow branch-push blocked_branches)" = "main master" ]
|
||||
[ "$(lib manifest_get workflow branch-push block_merge)" = "off" ]
|
||||
[ "$(lib manifest_get workflow branch-push requires_worktree)" = "on" ]
|
||||
[ "$(lib manifest_get workflow branch-push autonomy)" = "push-branch" ]
|
||||
# Requires no capability, so it is usable against a forge that offers
|
||||
# none - but it still pushes, so unlike a non-pushing workflow it cannot
|
||||
# pair with forge = none itself (rule 3: a pushing workflow needs a forge
|
||||
# that resolves to a host, and none never does). Exercised here against
|
||||
# gitea, fully configured, for a host without exercising any capability.
|
||||
printf '[forge.gitea]\nssh_host = git.example\napi = https://api.example/v1\ntoken_file = /dev/null\n' \
|
||||
> "$CONFIG_DIR/config"
|
||||
run lib provider_validate_selection gitea branch-push claude-code
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
}
|
||||
|
||||
@test "the branch-push briefing tells the agent to stop at the push" {
|
||||
run lib briefing_compose gitea branch-push claude-code
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
[[ "$output" == *"Push that branch"* ]]
|
||||
# It must not instruct opening a PR - that is worktree-pr's job, and this
|
||||
# workflow's whole point is that it stops earlier.
|
||||
[[ "$output" != *"create"*"PR"* ]] || { echo "branch-push briefing mentions opening a PR"; return 1; }
|
||||
}
|
||||
|
||||
@test "local-only pushes nothing and blocks no branches" {
|
||||
run lib manifest_validate workflow local-only
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
[ "$(lib manifest_get workflow local-only push)" = "off" ]
|
||||
[ "$(lib manifest_get workflow local-only block_merge)" = "off" ]
|
||||
[ "$(lib manifest_get workflow local-only requires_worktree)" = "off" ]
|
||||
[ "$(lib manifest_get workflow local-only autonomy)" = "commit" ]
|
||||
# blocked_branches is empty BECAUSE push is off - there is no push to
|
||||
# block. This pins the pair together: turning push on later without
|
||||
# revisiting this would silently allow a push to main.
|
||||
[ -z "$(lib manifest_get workflow local-only blocked_branches)" ]
|
||||
}
|
||||
|
||||
@test "none + local-only is a valid, fully unconfigured selection" {
|
||||
run lib provider_validate_selection none local-only claude-code
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
[ -z "$output" ]
|
||||
}
|
||||
|
||||
@test "the github forge pushes with its own token, not the read-only one" {
|
||||
run lib manifest_validate forge github
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
[ "$(lib manifest_get forge github capabilities)" = "pr issues" ]
|
||||
[ "$(lib manifest_get forge github pr_command)" = "gh pr" ]
|
||||
[ "$(lib manifest_get forge github merge_block)" = "gh pr merge" ]
|
||||
# The write-capable token is its own key. Reusing [github] token_file
|
||||
# would promote a deliberately read-only PAT to a pushing credential.
|
||||
[ "$(lib manifest_get forge github requires)" = "push_token_file" ]
|
||||
[[ "$(lib manifest_get forge github env_export)" == *"GH_TOKEN=@push_token_file"* ]]
|
||||
# Ships no CLI: gh is already in the image.
|
||||
[ -z "$(lib manifest_get forge github install)" ]
|
||||
}
|
||||
|
||||
@test "the briefing does not call GitHub read-only when the forge can push to it" {
|
||||
# The other half of Task 2's network_allowlist resolver, tested here
|
||||
# because this is where the github forge starts existing.
|
||||
printf '[forge.github]\npush_token_file = /dev/null\n' > "$CONFIG_DIR/config"
|
||||
run lib briefing_compose github branch-push claude-code
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
[[ "$output" != *"GitHub read-only"* ]]
|
||||
[[ "$output" == *"GitHub, which this session can also push to"* ]]
|
||||
}
|
||||
|
||||
@test "the github forge contributes its hosts without any config" {
|
||||
hosts="$(lib forge_hosts github)"
|
||||
for h in github.com ssh.github.com api.github.com; do
|
||||
[[ " $hosts " == *" $h "* ]] || { echo "missing $h in: $hosts"; return 1; }
|
||||
done
|
||||
}
|
||||
|
||||
@test "the github forge routes its hosts through core's allowlist, not the extra-domains one" {
|
||||
# The manifest half of the launcher behaviour asserted in test/config.bats
|
||||
# ("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
|
||||
# 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.
|
||||
[ "$(lib manifest_get forge github hosts_allowlisted_by_core)" = "on" ]
|
||||
# Still resolved by forge_hosts, which is what keeps rule 3 (a pushing
|
||||
# workflow needs a forge with a host) satisfied and what fills
|
||||
# DEJIMA_PUSH_HOSTS so git-push-guard.sh permits the push.
|
||||
[ -n "$(lib forge_hosts github)" ]
|
||||
# Off unless a manifest says otherwise: a self-hosted forge's host is
|
||||
# exactly the internal address the extra-domains path exists for.
|
||||
[ "$(lib manifest_get forge gitea hosts_allowlisted_by_core)" = "off" ]
|
||||
[ "$(lib manifest_get forge none hosts_allowlisted_by_core)" = "off" ]
|
||||
}
|
||||
|
||||
@test "the network-posture sentence follows the manifest, not the forge's name" {
|
||||
# Same key, read for the briefing: with a pushing workflow the session
|
||||
# really can push to GitHub, and with a non-pushing one it really cannot
|
||||
# (bin/dejima only opens the push grant when the workflow pushes), so the
|
||||
# sentence has to say two different things for the same forge.
|
||||
printf '[forge.github]\npush_token_file = /dev/null\n' > "$CONFIG_DIR/config"
|
||||
run lib briefing_compose github local-only claude-code
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
[[ "$output" == *"GitHub read-only"* ]]
|
||||
[[ "$output" != *"can also push to"* ]]
|
||||
# And no dangling "the configured github host" - core allowlists those,
|
||||
# there is nothing for an operator to have configured.
|
||||
[[ "$output" != *"configured github host"* ]]
|
||||
}
|
||||
|
||||
@test "a required key that is set but empty counts as missing" {
|
||||
# install.sh writes every key commented out at its default, so
|
||||
# uncommenting one and not filling it in yields `api =` - set, and worth
|
||||
# nothing. config_is_set alone called that configured, and the launch
|
||||
# went ahead with every gitea call failing inside the container instead.
|
||||
printf '[forge.gitea]\nssh_host = git.example\napi =\n' > "$CONFIG_DIR/config"
|
||||
run lib provider_validate_selection gitea worktree-pr claude-code
|
||||
[ "$status" -ne 0 ]
|
||||
[[ "$output" == *"api"* ]]
|
||||
}
|
||||
|
||||
@test "a single required key that is set but empty is refused too" {
|
||||
# The same hole in the other rule: github's one `requires` key can never
|
||||
# be "partly" configured, so an empty push_token_file would otherwise
|
||||
# sail past both this rule and rule 3 (fixed_hosts means it always
|
||||
# resolves to a host) and launch with no push credential at all.
|
||||
printf '[core]\nforge = github\n[forge.github]\npush_token_file =\n' > "$CONFIG_DIR/config"
|
||||
run lib provider_validate_selection github branch-push claude-code
|
||||
[ "$status" -ne 0 ]
|
||||
[[ "$output" == *"push_token_file"* ]]
|
||||
}
|
||||
|
||||
@test "the no-host refusal offers the forge that needs no host configured" {
|
||||
# The remedy has to include the one an operator who wants to push would
|
||||
# actually pick - pointing only at `forge = none` + local-only answers
|
||||
# the opposite question. Asserted here as the claim (every forge the
|
||||
# remedy names is real), not the literal wording - see
|
||||
# assert_remedy_forges_exist.
|
||||
printf '[core]\nfirewall = on\n' > "$CONFIG_DIR/config"
|
||||
run lib provider_validate_selection gitea worktree-pr claude-code
|
||||
[ "$status" -ne 0 ]
|
||||
assert_remedy_forges_exist "$output"
|
||||
run lib provider_validate_selection none branch-push claude-code
|
||||
[ "$status" -ne 0 ]
|
||||
assert_remedy_forges_exist "$output"
|
||||
}
|
||||
|
||||
@test "an unset push_token_file yields no GH_TOKEN pair at all, not an empty one" {
|
||||
# docker-compose.yml declares GH_TOKEN itself, carrying the independent
|
||||
# read-only [github] token_file value. `-e GH_TOKEN=` (the pair
|
||||
# forge_env_exports used to emit for an unresolved value) overrides that
|
||||
# entry to empty on `docker compose run` - wholly unconfigured must stay
|
||||
# silent here, exactly like it already does for provider_validate_selection's
|
||||
# own partial-config warning, or selecting this forge with no push token
|
||||
# yet silently voids the baseline read-only access every session gets.
|
||||
printf '[core]\nforge = github\n' > "$CONFIG_DIR/config"
|
||||
run lib forge_env_exports github
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
[ -z "$output" ]
|
||||
}
|
||||
|
||||
@test "a configured push_token_file still yields a real GH_TOKEN pair" {
|
||||
tok="$CONFIG_DIR/push-token"
|
||||
printf 'ghp_example\n' > "$tok"
|
||||
printf '[forge.github]\npush_token_file = %s\n' "$tok" > "$CONFIG_DIR/config"
|
||||
run lib forge_env_exports github
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
[ "$output" = "GH_TOKEN=ghp_example" ]
|
||||
}
|
||||
|
||||
@test "open-pr autonomy is refused against a forge with no pr capability" {
|
||||
FAKE_ROOT="$(mktemp -d)"
|
||||
mkdir -p "$FAKE_ROOT/forges/nopr" "$FAKE_ROOT/workflows/wants-pr" "$FAKE_ROOT/harnesses/h"
|
||||
printf 'capabilities =\n' > "$FAKE_ROOT/forges/nopr/forge.conf"
|
||||
# requires is empty, so rule 1 passes and only the autonomy rule can catch this.
|
||||
printf 'requires =\npush = on\nautonomy = open-pr\n' > "$FAKE_ROOT/workflows/wants-pr/workflow.conf"
|
||||
printf 'command = claude\n' > "$FAKE_ROOT/harnesses/h/harness.conf"
|
||||
run env DEJIMA_PROVIDER_ROOT="$FAKE_ROOT" \
|
||||
bash -c 'source "$1"; provider_validate_selection nopr wants-pr h' _ "$LIB"
|
||||
[ "$status" -ne 0 ]
|
||||
[[ "$output" == *"open-pr"* ]]
|
||||
}
|
||||
|
||||
@test "push-branch autonomy is refused when the workflow does not push" {
|
||||
FAKE_ROOT="$(mktemp -d)"
|
||||
mkdir -p "$FAKE_ROOT/forges/f" "$FAKE_ROOT/workflows/contradiction" "$FAKE_ROOT/harnesses/h"
|
||||
printf 'capabilities =\n' > "$FAKE_ROOT/forges/f/forge.conf"
|
||||
printf 'requires =\npush = off\nautonomy = push-branch\n' > "$FAKE_ROOT/workflows/contradiction/workflow.conf"
|
||||
printf 'command = claude\n' > "$FAKE_ROOT/harnesses/h/harness.conf"
|
||||
run env DEJIMA_PROVIDER_ROOT="$FAKE_ROOT" \
|
||||
bash -c 'source "$1"; provider_validate_selection f contradiction h' _ "$LIB"
|
||||
[ "$status" -ne 0 ]
|
||||
[[ "$output" == *"push-branch"* ]]
|
||||
}
|
||||
|
||||
@test "every shipped workflow's autonomy is supported by a forge that ships" {
|
||||
# worktree-pr/open-pr and branch-push both push, so both now need a real
|
||||
# host (rule 3) - gitea, fully configured. local-only never pushes, so
|
||||
# none (nothing configured at all) stays the honest pairing for it.
|
||||
printf '[forge.gitea]\nssh_host = git.example\napi = https://api.example/v1\ntoken_file = /dev/null\n' \
|
||||
> "$CONFIG_DIR/config"
|
||||
run lib provider_validate_selection gitea worktree-pr claude-code
|
||||
[ "$status" -eq 0 ] || { echo "gitea+worktree-pr: $output"; return 1; }
|
||||
run lib provider_validate_selection gitea branch-push claude-code
|
||||
[ "$status" -eq 0 ] || { echo "gitea+branch-push: $output"; return 1; }
|
||||
printf '[core]\nfirewall = on\n' > "$CONFIG_DIR/config"
|
||||
run lib provider_validate_selection none local-only claude-code
|
||||
[ "$status" -eq 0 ] || { echo "none+local-only: $output"; return 1; }
|
||||
}
|
||||
|
||||
+10
-2
@@ -880,10 +880,18 @@ EOF
|
||||
# Waits for the grouped session an attach should have created, then prints
|
||||
# "<name> <group>" for it - empty group means UNGROUPED, i.e. a fresh session
|
||||
# tmux conjured out of a name rather than a clone of the sandbox's own.
|
||||
#
|
||||
# Waits for the CLIENT rather than the session, like the earlier attach test
|
||||
# does. dejima-attach creates the session detached, checks it holds the pane,
|
||||
# selects the window and only then attaches - so has-session goes true while
|
||||
# the session is still on whatever window the group started on. A caller that
|
||||
# goes on to assert the window index reads it inside that gap. That is how
|
||||
# this failed in CI, with the group assertion on the line before it passing:
|
||||
# the group is set at creation, the window is not.
|
||||
await_group() {
|
||||
local i
|
||||
for i in $(seq 50); do
|
||||
if tmux has-session -t "=dejima-$1" 2>/dev/null; then break; fi
|
||||
for i in $(seq 100); do
|
||||
[ "$(tmux list-clients -F '#{client_session}' 2>/dev/null)" = "dejima-$1" ] && break
|
||||
sleep 0.1
|
||||
done
|
||||
tmux list-sessions -F '#{session_name} #{session_group}' | grep "^dejima-$1 " || true
|
||||
|
||||
+229
-2
@@ -85,6 +85,16 @@ wait_ready() {
|
||||
if docker exec "$container" pgrep -x -f 'sleep infinity' >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
# A container that has already exited is never going to become ready,
|
||||
# and polling it for the full two minutes turns a startup failure - an
|
||||
# aborted entrypoint, say - into a wait long enough to read as a hang.
|
||||
# The reason is in its logs, which are printed either way; this only
|
||||
# decides how long it takes to see them.
|
||||
if [ "$(docker inspect -f '{{.State.Running}}' "$container" 2>/dev/null)" = false ]; then
|
||||
echo "container $container exited during startup - log:" >&2
|
||||
settled_logs "$container" >&2
|
||||
return 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
echo "container $container never reached the post-entrypoint sleep - startup log:" >&2
|
||||
@@ -196,6 +206,8 @@ teardown() {
|
||||
docker rmi "dejima-test-override:$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
|
||||
docker rm -f "dejima-test-sshkey-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
|
||||
docker rm -f "dejima-test-ghpush-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
|
||||
docker rm -f "dejima-test-ghforge-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
|
||||
docker rm -f "dejima-test-ghextra-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
|
||||
docker rm -f "dejima-test-claudejson-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
|
||||
docker rm -f "dejima-test-rebind-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
|
||||
docker rm -f "dejima-test-overlays-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
|
||||
@@ -209,6 +221,8 @@ teardown() {
|
||||
docker rm -f "dejima-test-tls-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
|
||||
docker rm -f "dejima-test-nohosts-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
|
||||
docker rm -f "dejima-test-badtransport-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
|
||||
docker rm -f "dejima-test-notifydir-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
|
||||
docker rm -f "dejima-test-nonotify-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
dexec() {
|
||||
@@ -357,6 +371,26 @@ dexec_node() {
|
||||
[ -n "$output" ]
|
||||
}
|
||||
|
||||
@test "the session user cannot address a resolver directly, so the DNS allowlist is not bypassable" {
|
||||
# init-firewall.sh opens UDP/TCP 53 to any destination for root-owned
|
||||
# sockets, because that is what Docker's embedded resolver forwards
|
||||
# external names from and without it nothing in here resolves at all.
|
||||
# This is the assertion that the opening stops there. `node` is the
|
||||
# session user and there is no sudo in the image, so if it could reach a
|
||||
# resolver of its own choosing it could carry data out in the query name
|
||||
# itself - the exact thing dns-proxy.sh exists to prevent, and something
|
||||
# no allowlist of *names* can catch once the *resolver* is attacker-chosen.
|
||||
#
|
||||
# 1.1.1.1 rather than an unrouted address: it answers if it is reachable,
|
||||
# so a pass here means the packet was stopped and not that the probe was
|
||||
# aimed at nothing.
|
||||
run dexec_node dig +short registry.npmjs.org
|
||||
[ -n "$output" ]
|
||||
|
||||
run dexec_node dig +time=2 +tries=1 @1.1.1.1 definitely-not-allowed.example.org
|
||||
[ "$status" -ne 0 ]
|
||||
}
|
||||
|
||||
@test "api.anthropic.com resolves in proxy-off mode (kill switch / API-key auth)" {
|
||||
# api.anthropic.com is left off the allowlist when the credential proxy is
|
||||
# on, so a bypass of the /etc/hosts pin that resolves the name by DNS gets
|
||||
@@ -402,6 +436,109 @@ dexec_node() {
|
||||
docker rm -f "$test_container" >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
@test "a github-forge launch boots, and its port 22 comes from the push grant" {
|
||||
# The container half of test/config.bats' "the github forge's hosts reach
|
||||
# the push guard, never the extra-domains allowlist" - the environment
|
||||
# asserted there, handed to a real container here. Selecting `forge =
|
||||
# github` with a pushing workflow resolves to exactly this: no
|
||||
# DEJIMA_FORGE_HOSTS (core already allowlists GitHub's ranges) and the
|
||||
# push grant open.
|
||||
#
|
||||
# It has to be a boot test, because the failure it guards against was the
|
||||
# container not starting at all: with those three hostnames in
|
||||
# DEJIMA_FORGE_HOSTS, init-firewall.sh resolves them into the
|
||||
# port-unrestricted allowed-domains ipset, its own startup self-check
|
||||
# finds github.com:22 reachable without ALLOW_GITHUB_PUSH=1, and
|
||||
# entrypoint.sh (set -Eeuo pipefail) dies before the agent ever runs.
|
||||
local test_container="dejima-test-ghforge-$(basename "$BATS_FILE_TMPDIR")"
|
||||
docker run -d --name "$test_container" \
|
||||
--cap-add=NET_ADMIN --cap-add=NET_RAW \
|
||||
--network "$(network_name)" \
|
||||
-e SANDBOX_TEST_MODE=1 \
|
||||
-e GH_TOKEN="${GH_TOKEN:-}" \
|
||||
-e DEJIMA_FORGE_HOSTS= \
|
||||
-e DEJIMA_PUSH_HOSTS="github.com ssh.github.com api.github.com" \
|
||||
-e ALLOW_GITHUB_PUSH=1 \
|
||||
--entrypoint /usr/local/bin/entrypoint.sh \
|
||||
"$IMAGE" >/dev/null
|
||||
|
||||
# The container coming up at all is half the assertion.
|
||||
wait_ready "$test_container"
|
||||
|
||||
# Port 22 open, and open through the path that says so out loud.
|
||||
run docker exec "$test_container" timeout 5 bash -c "cat < /dev/null > /dev/tcp/github.com/22"
|
||||
[ "$status" -eq 0 ]
|
||||
# Polled rather than read once, for the reason settled_logs documents: the
|
||||
# json-file driver's writes lag the process that produced them, so a single
|
||||
# read taken here can stop short of a line printed seconds earlier. That is
|
||||
# how this failed in CI - with the port-22 assertion directly above passing,
|
||||
# ie. with the branch that prints this line demonstrably taken. Waiting on
|
||||
# the line cannot mask its absence: one never printed still fails, later.
|
||||
local want="ALLOW_GITHUB_PUSH=1: opening port 22" logs=""
|
||||
for _ in $(seq 1 40); do
|
||||
logs=$(docker logs "$test_container" 2>&1)
|
||||
case "$logs" in *"$want"*) break ;; esac
|
||||
sleep 0.25
|
||||
done
|
||||
[[ "$logs" == *"$want"* ]]
|
||||
|
||||
# And the other half of the widening this avoids: GitHub's names keep
|
||||
# dnsmasq's rebind protection, which the extra-domains path would have
|
||||
# exempted them from.
|
||||
run docker exec "$test_container" grep 'rebind-domain-ok=/github.com/' /etc/dnsmasq-sandbox.conf
|
||||
[ "$status" -ne 0 ]
|
||||
|
||||
docker rm -f "$test_container" >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
@test "a GitHub name declared as an extra domain stays port-restricted, so push stays unreachable" {
|
||||
# .dejima/allowed-domains is read with the project and is meant to widen
|
||||
# what a session can reach. It must not widen how. GitHub's addresses are
|
||||
# confined to 443 by init-firewall.sh, and that is the entire read-only
|
||||
# guarantee - with 22 shut there is no transport left for the forwarded
|
||||
# ssh-agent's key to push with, whatever a hook does or does not catch.
|
||||
#
|
||||
# allowed-domains carries no port restriction, so before ipset_for_domain
|
||||
# this path put GitHub's address there and opened 22. It surfaced as an
|
||||
# intermittent startup failure rather than as a silent hole only because
|
||||
# the port-22 self-check re-resolves the name: it catches the hole when
|
||||
# GitHub's second answer matches the first, and misses it when the name
|
||||
# has rotated in between. This test does not depend on that coin flip.
|
||||
local test_container="dejima-test-ghextra-$(basename "$BATS_FILE_TMPDIR")"
|
||||
docker run -d --name "$test_container" \
|
||||
--cap-add=NET_ADMIN --cap-add=NET_RAW \
|
||||
--network "$(network_name)" \
|
||||
-e SANDBOX_TEST_MODE=1 \
|
||||
-e GH_TOKEN="${GH_TOKEN:-}" \
|
||||
-e DEJIMA_FORGE_HOSTS= \
|
||||
-e ALLOWED_DOMAINS="github.com api.github.com" \
|
||||
--entrypoint /usr/local/bin/entrypoint.sh \
|
||||
"$IMAGE" >/dev/null
|
||||
|
||||
# Coming up at all is part of the assertion: the port-22 self-check runs
|
||||
# during startup, and with the address in the wrong set it fails the boot.
|
||||
wait_ready "$test_container"
|
||||
|
||||
# Resolved inside the container, through the same dnsmasq that answered
|
||||
# during startup - so this asks about the address actually allowlisted,
|
||||
# not one a fresh lookup from elsewhere happened to return.
|
||||
local ip
|
||||
ip=$(docker exec "$test_container" dig +short A github.com | head -1)
|
||||
[ -n "$ip" ]
|
||||
|
||||
run docker exec "$test_container" ipset test github-domains "$ip"
|
||||
[ "$status" -eq 0 ]
|
||||
|
||||
run docker exec "$test_container" ipset test allowed-domains "$ip"
|
||||
[ "$status" -ne 0 ]
|
||||
|
||||
# And the property those two sets exist to produce.
|
||||
run docker exec "$test_container" timeout 5 bash -c "cat < /dev/null > /dev/tcp/github.com/22"
|
||||
[ "$status" -ne 0 ]
|
||||
|
||||
docker rm -f "$test_container" >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
@test "a configured forge host is exempted from stop-dns-rebind, other allowed domains are not" {
|
||||
# Passed as DEJIMA_FORGE_HOSTS, not GITEA_SSH_HOST: the container no longer
|
||||
# derives hostnames from any one forge's variables - bin/dejima resolves
|
||||
@@ -594,7 +731,10 @@ dexec_node() {
|
||||
}
|
||||
|
||||
@test "the ipset stays in sync with fresh (non-cached) DNS answers, not just the boot-time snapshot" {
|
||||
ip=$(dexec dig +short pypi.org | head -1)
|
||||
# Any built-in domain that resolves to a CDN address will do; this one is
|
||||
# here because it is built in, so keep it on a name from fixed-domains.sh
|
||||
# rather than one a project happens to have approved.
|
||||
ip=$(dexec dig +short registry.npmjs.org | head -1)
|
||||
[ -n "$ip" ]
|
||||
dexec ipset del allowed-domains "$ip"
|
||||
run dexec ipset test allowed-domains "$ip"
|
||||
@@ -605,7 +745,7 @@ dexec_node() {
|
||||
# forward triggers dnsmasq's --ipset= tracking (see dns-proxy.sh).
|
||||
dexec bash -c 'kill -HUP "$(cat /var/run/dnsmasq-sandbox.pid)"'
|
||||
sleep 0.5
|
||||
dexec dig +short pypi.org >/dev/null
|
||||
dexec dig +short registry.npmjs.org >/dev/null
|
||||
sleep 0.5
|
||||
run dexec ipset test allowed-domains "$ip"
|
||||
[ "$status" -eq 0 ]
|
||||
@@ -902,6 +1042,18 @@ EOF
|
||||
local store; store="$(mktemp -d)"
|
||||
local df; df="$(mktemp)"
|
||||
printf 'apt:cowsay\n' > "$store/dependencies"
|
||||
|
||||
# The positive control, and it is load-bearing: this test proves the
|
||||
# derived layer installed cowsay by finding cowsay installed. If the base
|
||||
# image already carried it, the assertion below would pass with
|
||||
# generate_derived_dockerfile doing nothing at all. Checked here rather
|
||||
# than assumed, because "the base image gained a package" is a change
|
||||
# nobody would connect to this file.
|
||||
run docker run --rm --entrypoint dpkg-query "$IMAGE" -W -f='${Status}' cowsay
|
||||
[[ "$output" != *"install ok installed"* ]] || {
|
||||
echo "cowsay is in the base image, so this test cannot detect a broken derived build" >&2
|
||||
false
|
||||
}
|
||||
bash -c 'source "$1"; generate_derived_dockerfile "$2" "$3"' \
|
||||
_ "$BATS_TEST_DIRNAME/../bin/dejima-project-lib.sh" "$IMAGE" "$store" > "$df"
|
||||
docker build -q -t "$derived" -f "$df" "$ctx" >&2
|
||||
@@ -1191,3 +1343,78 @@ EOF
|
||||
[[ "$logs" == *"'tls'"* ]] || dump "the refusal did not name the valid values"
|
||||
[[ "$logs" == *"'loopback'"* ]] || dump "the refusal did not name the valid values"
|
||||
}
|
||||
|
||||
# The three-symptom failure entrypoint.sh's marker-directory check exists to
|
||||
# name: notifications stop, the session vanishes from dejima-attach, and the
|
||||
# notify hook reports EACCES - with no obvious moment they started.
|
||||
#
|
||||
# It is Docker's answer to a missing bind source that produces it. bin/dejima
|
||||
# creates the marker directory per launch and removes it on exit, so a
|
||||
# container that is RESTARTED rather than relaunched has no source left, and
|
||||
# Docker creates one root-owned rather than refusing. The directory then
|
||||
# exists, so the hook's own `[ -d ]` guard passes and the write fails instead.
|
||||
@test "a marker directory the sandbox user cannot write is reported at startup" {
|
||||
# Its own container, not container_name() - that one is created in
|
||||
# setup_file and used by every dexec test in this file. Starting a second
|
||||
# container under that name conflicts, and removing it at the end of this
|
||||
# body would take the shared one with it.
|
||||
local container="dejima-test-notifydir-$(basename "$BATS_FILE_TMPDIR")"
|
||||
local host_dir; host_dir="$(mktemp -d)"
|
||||
# Unwritable by the sandbox user, which is the property the check keys on.
|
||||
# Built here rather than by removing a bind source, so the test does not
|
||||
# depend on Docker's fallback behaviour to set up a test OF the
|
||||
# consequences of that behaviour.
|
||||
#
|
||||
# chmod rather than mounting :ro. A read-only mount would make the
|
||||
# "not repaired" assertion below vacuous - root could not chown it even
|
||||
# if entrypoint.sh tried. Mode 555 leaves it repairable by root inside the
|
||||
# container and still unwritable by the session user, so that assertion
|
||||
# means something.
|
||||
chmod 555 "$host_dir"
|
||||
|
||||
docker run -d --name "$container" \
|
||||
--cap-add=NET_ADMIN --cap-add=NET_RAW \
|
||||
--network "$(network_name)" \
|
||||
-e SANDBOX_TEST_MODE=1 \
|
||||
-e GH_TOKEN="${GH_TOKEN:-}" \
|
||||
-e DEJIMA_NOTIFY_DIR=/run/dejima-notify \
|
||||
-v "$host_dir:/run/dejima-notify" \
|
||||
--entrypoint /usr/local/bin/entrypoint.sh \
|
||||
"$IMAGE" >/dev/null
|
||||
wait_ready "$container"
|
||||
|
||||
run settled_logs "$container"
|
||||
[[ "$output" == *"not writable by"* ]]
|
||||
[[ "$output" == *"RESTARTED rather than"* ]]
|
||||
# Reported, not repaired: chowning would restore notifications while the
|
||||
# session stayed absent from a registry this container never sees.
|
||||
#
|
||||
# Asserted as "still not writable by the session user" rather than "still
|
||||
# owned by root": ownership depends on who ran the suite, since mktemp -d
|
||||
# follows the caller, while writability is the property the check reads
|
||||
# and the one repairing it would change.
|
||||
run docker exec -u node "$container" test -w /run/dejima-notify
|
||||
[ "$status" -ne 0 ]
|
||||
|
||||
rm -rf "$host_dir"
|
||||
}
|
||||
|
||||
# Remote access is opt-in and off by default. The check above must say nothing
|
||||
# at all for the sessions that never asked for it, which is every session by
|
||||
# default - a warning about an unconfigured optional feature is noise on every
|
||||
# start, and noise is what stops warnings being read.
|
||||
@test "no marker-directory warning when remote access is not enabled" {
|
||||
local container="dejima-test-nonotify-$(basename "$BATS_FILE_TMPDIR")"
|
||||
docker run -d --name "$container" \
|
||||
--cap-add=NET_ADMIN --cap-add=NET_RAW \
|
||||
--network "$(network_name)" \
|
||||
-e SANDBOX_TEST_MODE=1 \
|
||||
-e GH_TOKEN="${GH_TOKEN:-}" \
|
||||
--entrypoint /usr/local/bin/entrypoint.sh \
|
||||
"$IMAGE" >/dev/null
|
||||
wait_ready "$container"
|
||||
|
||||
run settled_logs "$container"
|
||||
[[ "$output" != *"dejima-notify"* ]]
|
||||
[[ "$output" != *"remote access is enabled"* ]]
|
||||
}
|
||||
|
||||
+496
-30
@@ -699,7 +699,7 @@ EOF
|
||||
# reach a real running dejima session (and mutate its live
|
||||
# firewall) even if the machine running this test happens to have one -
|
||||
# it must fail at "container not found", not at validation.
|
||||
run "$REPO_ROOT/bin/dejima-allow" 'crates.io' --container dejima-test-suite-nonexistent-target
|
||||
run "$REPO_ROOT/bin/dejima-allow" --container dejima-test-suite-nonexistent-target 'crates.io'
|
||||
[ "$status" -ne 0 ]
|
||||
[[ "$output" != *"Not a valid domain"* ]]
|
||||
}
|
||||
@@ -781,17 +781,17 @@ EOF
|
||||
rm -rf "$d"
|
||||
}
|
||||
|
||||
@test "neither gitea CLI passes the token as a command-line argument" {
|
||||
@test "no gitea CLI passes the token as a command-line argument" {
|
||||
# /proc makes argv world-readable, so a token in a curl invocation is
|
||||
# visible to every other process for the life of the request - and this
|
||||
# runs alongside agents that read process listings.
|
||||
#
|
||||
# Both files, because they are deliberate copies of each other rather
|
||||
# Every file, because they are deliberate copies of each other rather
|
||||
# than a shared library (see forges/gitea/bin/gitea-issue's header for
|
||||
# why), and a duplicated security property is exactly the kind that
|
||||
# drifts.
|
||||
local f
|
||||
for f in forges/gitea/bin/gitea-pr forges/gitea/bin/gitea-issue; do
|
||||
for f in forges/gitea/bin/gitea-pr forges/gitea/bin/gitea-issue forges/gitea/bin/gitea-actions; do
|
||||
run grep -n 'curl.*Authorization: token' "$REPO_ROOT/$f"
|
||||
[ "$status" -ne 0 ]
|
||||
grep -q 'curl -sS -w .*-K -' "$REPO_ROOT/$f"
|
||||
@@ -820,6 +820,15 @@ EOF
|
||||
grep -q 'type=issues' "$REPO_ROOT/forges/gitea/bin/gitea-issue"
|
||||
}
|
||||
|
||||
@test "gitea-actions only reads - it issues no request that could change anything" {
|
||||
# It authenticates with the same write-scoped token as gitea-pr, so
|
||||
# "it only fetches runs and logs" has to be a property of the file and
|
||||
# not merely of what its subcommands are named.
|
||||
run grep -nE '\-X (POST|PUT|PATCH|DELETE)|--data|[^a-z-]-d ' \
|
||||
"$REPO_ROOT/forges/gitea/bin/gitea-actions"
|
||||
[ "$status" -ne 0 ]
|
||||
}
|
||||
|
||||
@test "gitea-issue is installed into the image alongside gitea-pr" {
|
||||
grep -q 'COPY forges/' "$SANDBOX_DIR/Dockerfile"
|
||||
# install -m 755, not a plain cp, so the executable bit surviving isn't
|
||||
@@ -865,9 +874,58 @@ EOF
|
||||
grep -q 'register_hook PreToolUse Bash merge-guard.sh' "$REPO_ROOT/install.sh"
|
||||
}
|
||||
|
||||
@test "install.sh cleans up the stale pre-rename gitea-merge-guard.sh hook symlink" {
|
||||
grep -q 'gitea-merge-guard.sh' "$REPO_ROOT/install.sh"
|
||||
grep -q 'dangling' "$REPO_ROOT/install.sh"
|
||||
# Runs install.sh against a throwaway HOME, with a stub `docker` on PATH so
|
||||
# its tool check passes somewhere without one (this suite is the no-Docker
|
||||
# half). Everything the script writes - hook symlinks, ~/.local/bin,
|
||||
# settings.json, the starter config - lands under that HOME.
|
||||
run_install() {
|
||||
local home="$1"
|
||||
mkdir -p "$home/stub"
|
||||
printf '#!/bin/sh\nexit 0\n' > "$home/stub/docker"
|
||||
chmod +x "$home/stub/docker"
|
||||
env HOME="$home" PATH="$home/stub:$PATH" DEJIMA_CONFIG_DIR="$home/config" \
|
||||
bash "$REPO_ROOT/install.sh"
|
||||
}
|
||||
|
||||
# Every registered hook command, one per line.
|
||||
registered_commands() {
|
||||
jq -r '[.hooks[]?[]?.hooks[]?.command // empty] | .[]' "$1/.claude/settings.json"
|
||||
}
|
||||
|
||||
@test "install.sh replaces the pre-rename gitea-merge-guard.sh registration rather than reading it as one" {
|
||||
# Asserted by running it, not by grepping it: the bug this covers was a
|
||||
# register_hook call that was present in the source and did nothing. Its
|
||||
# dedupe matched the pre-rename command string as a substring, so the
|
||||
# merge guard was registered nowhere while install.sh reported success,
|
||||
# and every session start reported the missing hook the stale entry
|
||||
# still named.
|
||||
local home="$BATS_TEST_TMPDIR/h"
|
||||
mkdir -p "$home/.claude/hooks"
|
||||
ln -s "$REPO_ROOT/hooks/gone.sh" "$home/.claude/hooks/gitea-merge-guard.sh"
|
||||
jq -n '{hooks: {PreToolUse: [{matcher: "Bash", hooks: [{type: "command",
|
||||
command: "$HOME/.claude/hooks/gitea-merge-guard.sh"}]}]}}' \
|
||||
> "$home/.claude/settings.json"
|
||||
|
||||
run_install "$home"
|
||||
|
||||
run registered_commands "$home"
|
||||
[[ "$output" != *"gitea-merge-guard.sh"* ]]
|
||||
[ "$(registered_commands "$home" | grep -c '/merge-guard\.sh$')" -eq 1 ]
|
||||
[ ! -e "$home/.claude/hooks/gitea-merge-guard.sh" ]
|
||||
}
|
||||
|
||||
@test "install.sh registers each hook exactly once across repeated runs" {
|
||||
# The other direction of the same dedupe: an exact match must still
|
||||
# match. A re-run after a git pull is the common case, and duplicate
|
||||
# registrations would run every guard twice.
|
||||
local home="$BATS_TEST_TMPDIR/h2" cmd
|
||||
run_install "$home"
|
||||
run_install "$home"
|
||||
|
||||
while read -r cmd; do
|
||||
[ "$(registered_commands "$home" | grep -cxF "$cmd")" -eq 1 ]
|
||||
done < <(registered_commands "$home")
|
||||
[ "$(registered_commands "$home" | wc -l)" -eq 4 ]
|
||||
}
|
||||
|
||||
@test "the push guard names no forge, and blocks everything when no host is allowed" {
|
||||
@@ -1297,14 +1355,25 @@ this line is not json'
|
||||
[ "$status" -ne 0 ]
|
||||
}
|
||||
|
||||
@test "dejima-allow's session-skip escape hatch is test-only, never a silent no-op path" {
|
||||
# DEJIMA_ALLOW_SKIP_SESSION exists so test/project.bats can
|
||||
# exercise --project persistence without a live container. It must never
|
||||
# be reachable from the documented interface.
|
||||
run grep -c 'DEJIMA_ALLOW_SKIP_SESSION' "$REPO_ROOT/bin/dejima-allow"
|
||||
[ "$output" -eq 1 ]
|
||||
run grep 'DEJIMA_ALLOW_SKIP_SESSION' "$REPO_ROOT/README.md"
|
||||
@test "dejima-allow has no env-triggered path that skips the live session" {
|
||||
# DEJIMA_ALLOW_SKIP_SESSION used to exist as a test-only escape hatch, and
|
||||
# by the end was consulted by nothing: every caller passed --project or
|
||||
# --global, which set session_wanted=0 on their own. An environment
|
||||
# variable that turns a firewall CLI into a silent no-op is worth keeping
|
||||
# out on purpose, not just deleting once - which is what this pins.
|
||||
# Matched on the read and the write, not the bare name: both files below
|
||||
# still name it in a comment explaining why it is gone, and a grep that
|
||||
# tripped on its own rationale would have to be deleted to pass.
|
||||
run grep -n '\${\?DEJIMA_ALLOW_SKIP_SESSION' "$REPO_ROOT/bin/dejima-allow"
|
||||
[ "$status" -ne 0 ]
|
||||
run grep -rn --exclude=static.bats 'DEJIMA_ALLOW_SKIP_SESSION=' \
|
||||
"$REPO_ROOT/test" "$REPO_ROOT/README.md"
|
||||
[ "$status" -ne 0 ]
|
||||
}
|
||||
|
||||
@test "dejima-allow's usage puts the domain last" {
|
||||
grep -q 'Usage: dejima-allow \[--global\] \[--project \[path\]\] \[--container <id>\] <domain>' \
|
||||
"$REPO_ROOT/bin/dejima-allow"
|
||||
}
|
||||
|
||||
@test "bin/dejima merges approved project domains into ALLOWED_DOMAINS" {
|
||||
@@ -1421,6 +1490,80 @@ this line is not json'
|
||||
grep -q 'dejima-project approve' "$REPO_ROOT/hooks/sandbox-context.sh"
|
||||
}
|
||||
|
||||
# The hook answers "is this installed?" per kind, and a tarball has no
|
||||
# package manager to ask - it is there if its directory is. A tarball spec
|
||||
# falling through to the package-name regex would be silently skipped, which
|
||||
# reads to the agent as "declared and present" for something that is neither.
|
||||
@test "sandbox-context reports a declared tarball dependency that is not unpacked" {
|
||||
local ws; ws="$(mktemp -d)"
|
||||
local sha=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
|
||||
mkdir -p "$ws/.dejima"
|
||||
printf 'tarball:definitely-not-here@%s:https://example.com/x.tar.gz\n' "$sha" > "$ws/.dejima/dependencies"
|
||||
local out
|
||||
out=$(printf '{"session_id":"s1","cwd":"%s"}' "$ws" | DEJIMA=1 bash "$REPO_ROOT/hooks/sandbox-context.sh")
|
||||
rm -rf "$ws"
|
||||
[[ "$out" == *"definitely-not-here"* ]]
|
||||
# The digest reaches the agent intact: it is what the operator is being
|
||||
# asked to approve, so a truncated form would be asking them to agree to
|
||||
# something they cannot see.
|
||||
[[ "$out" == *"$sha"* ]]
|
||||
}
|
||||
|
||||
# The positive half of the test above. It needs a tarball dependency actually
|
||||
# unpacked in the image, which only exists once a project has approved one -
|
||||
# so on an image with none it skips rather than silently asserting nothing.
|
||||
# A visible skip is the honest form here; a test that arranged its own
|
||||
# /opt/dejima entry would be checking that mkdir works.
|
||||
#
|
||||
# The branch itself is pinned by the source-level test below, which holds
|
||||
# regardless of what this image happens to contain.
|
||||
@test "sandbox-context does not report a tarball dependency that is unpacked" {
|
||||
local present=""
|
||||
for d in /opt/dejima/*/; do [ -d "$d" ] && present="$(basename "$d")" && break; done
|
||||
[ -n "$present" ] || skip "no unpacked tarball dependency in this image"
|
||||
local ws; ws="$(mktemp -d)"
|
||||
local sha=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
|
||||
mkdir -p "$ws/.dejima"
|
||||
printf 'tarball:%s@%s:https://example.com/x.tar.gz\n' "$present" "$sha" > "$ws/.dejima/dependencies"
|
||||
local out
|
||||
out=$(printf '{"session_id":"s1","cwd":"%s"}' "$ws" | DEJIMA=1 bash "$REPO_ROOT/hooks/sandbox-context.sh")
|
||||
rm -rf "$ws"
|
||||
[[ "$out" != *"$present"* ]]
|
||||
}
|
||||
|
||||
# runtime.bats proves the derived-image dependency mechanism works by
|
||||
# declaring apt:cowsay and then finding cowsay installed. That only proves
|
||||
# anything while the base image does not ship it - otherwise the assertion
|
||||
# passes with the mechanism entirely broken. runtime.bats checks this for
|
||||
# itself too, but only where Docker exists; this catches it in the suite that
|
||||
# runs everywhere, at the point the package list changes.
|
||||
@test "the base image ships none of the packages used as dependency fixtures" {
|
||||
for pkg in $(grep -oE "apt:[a-z0-9.+-]+" "$REPO_ROOT/test/runtime.bats" | cut -d: -f2 | sort -u); do
|
||||
run grep -nE "^ +$pkg \\\\$" "$SANDBOX_DIR/Dockerfile"
|
||||
[ "$status" -ne 0 ] || {
|
||||
echo "$pkg is a dependency-test fixture and is also in the base image" >&2
|
||||
false
|
||||
}
|
||||
done
|
||||
}
|
||||
|
||||
# The image ships the sandbox's own machinery and the tooling with no route
|
||||
# through dejima-dep. A language toolchain has a route - tarball: - so one
|
||||
# appearing here means every project carries it with nobody having asked.
|
||||
@test "no language toolchain is installed into the base image" {
|
||||
run grep -nE '^(RUN|ENV).*(golang|/usr/local/go|rustup|\.cargo/bin|pyenv|rbenv|sdkman)' \
|
||||
"$SANDBOX_DIR/Dockerfile"
|
||||
[ "$status" -ne 0 ] || {
|
||||
echo "declare it with dejima-dep instead:" >&2
|
||||
echo "$output" >&2
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
@test "sandbox-context decides a tarball is present by its unpack directory" {
|
||||
grep -q '\[ -d "/opt/dejima/\$name" \] && continue' "$REPO_ROOT/hooks/sandbox-context.sh"
|
||||
}
|
||||
|
||||
@test "sandbox-context reports nothing when no project declarations exist" {
|
||||
local ws; ws="$(mktemp -d)"
|
||||
local out
|
||||
@@ -1532,7 +1675,7 @@ EOF
|
||||
}
|
||||
|
||||
@test "README documents dejima-dep --global" {
|
||||
grep -q 'dejima-dep add cargo:cargo-nextest --global' "$REPO_ROOT/README.md"
|
||||
grep -q 'dejima-dep add --global cargo:cargo-nextest' "$REPO_ROOT/README.md"
|
||||
}
|
||||
|
||||
@test "README documents that the exit-time audit's findings are not attributed to the session" {
|
||||
@@ -1550,6 +1693,177 @@ EOF
|
||||
grep -q 'directory shared by' "$REPO_ROOT/docs/security.md"
|
||||
}
|
||||
|
||||
# The CI disclosure is the one part of issue #72 that helps a reader whatever
|
||||
# else is or isn't mitigated on their runners, so it is pinned in all three
|
||||
# places rather than left to survive a future edit on its own. Presence-only
|
||||
# greps, same caveat as the two above.
|
||||
@test "README and docs/security.md both disclose that push access is CI code execution" {
|
||||
for f in README.md docs/security.md; do
|
||||
grep -q 'push access is code execution elsewhere' "$REPO_ROOT/$f"
|
||||
grep -qi 'forge = none' "$REPO_ROOT/$f"
|
||||
done
|
||||
}
|
||||
|
||||
@test "docs/ci.md exists and is linked from the README and docs/security.md" {
|
||||
[ -f "$REPO_ROOT/docs/ci.md" ]
|
||||
grep -q 'docs/ci\.md' "$REPO_ROOT/README.md"
|
||||
grep -q '(ci\.md)' "$REPO_ROOT/docs/security.md"
|
||||
}
|
||||
|
||||
# The whole allowlist argument in docs/ci.md rests on reading the declaration
|
||||
# from the branch the operator controls. A future edit that softens this into
|
||||
# "read .dejima/allowed-domains" without saying from where would leave the
|
||||
# document describing a control that doesn't control anything.
|
||||
# runs-on lives in the workflow file, which lives in the branch, so a
|
||||
# dedicated label routes honest jobs and stops nothing else. An earlier version
|
||||
# of this document said jobs "cannot land anywhere else", which is true only of
|
||||
# a branch that is not trying - the exact distinction the rest of the document
|
||||
# is built on. Pinned so it cannot quietly come back.
|
||||
# The counterintuitive half of the advisory, and the one a reader is most
|
||||
# likely to skip: a project with no CI is exposed too, because a branch adds
|
||||
# the first workflow. If that goes missing, the document reads as being about
|
||||
# people who already run CI, which is the smaller group.
|
||||
@test "docs/ci.md says a project with no CI is exposed too" {
|
||||
grep -q 'applies to projects with no CI at all' "$REPO_ROOT/docs/ci.md"
|
||||
grep -q 'no workflows is not therefore safe' "$REPO_ROOT/docs/ci.md"
|
||||
grep -qi 'holds for projects with no CI' "$REPO_ROOT/README.md"
|
||||
}
|
||||
|
||||
# Moving a public project to another owner breaks its name, its links and its
|
||||
# ownership. It must not read as the recommended answer, and the alternatives
|
||||
# that require moving nothing have to be present.
|
||||
@test "docs/ci.md offers answers that do not require moving a repository" {
|
||||
local f="$REPO_ROOT/docs/ci.md"
|
||||
grep -q 'Scope the dangerous runner down, rather than moving' "$f"
|
||||
grep -q 'Turn CI off for that repository' "$f"
|
||||
grep -q 'Do not grant push' "$f"
|
||||
}
|
||||
|
||||
@test "docs/ci.md does not present the runner label as a boundary" {
|
||||
grep -q 'A label is not a boundary' "$REPO_ROOT/docs/ci.md"
|
||||
grep -q 'weakest runner it can reach' "$REPO_ROOT/docs/ci.md"
|
||||
run grep -n 'cannot land anywhere else' "$REPO_ROOT/docs/ci.md"
|
||||
[ "$status" -ne 0 ] || { echo "$output" >&2; false; }
|
||||
}
|
||||
|
||||
# The shared-identity point is what decides whether an operator's branch
|
||||
# protection does anything, and it is the kind of thing that reads as a
|
||||
# digression to a later editor. Pinned in both places, and pinned together
|
||||
# with the separation that fixes it - stating the problem without the remedy
|
||||
# would leave a reader with nothing to do about it.
|
||||
@test "docs/security.md documents that the sandbox authenticates as the operator" {
|
||||
grep -q 'the sandbox is you' "$REPO_ROOT/docs/security.md"
|
||||
grep -qi 'guard rather than a boundary' "$REPO_ROOT/docs/security.md"
|
||||
grep -q 'forge.gitea. token_file' "$REPO_ROOT/docs/security.md"
|
||||
grep -q 'core. ssh_key' "$REPO_ROOT/docs/security.md"
|
||||
}
|
||||
|
||||
@test "docs/ci.md's branch-protection prerequisite links the shared-identity section" {
|
||||
grep -q 'security.md#to-your-forge-the-sandbox-is-you' "$REPO_ROOT/docs/ci.md"
|
||||
}
|
||||
|
||||
@test "docs/ci.md says the CI allowlist is read from main, not from the checkout" {
|
||||
grep -q 'origin/main' "$REPO_ROOT/docs/ci.md"
|
||||
grep -qi 'never from the checkout' "$REPO_ROOT/docs/ci.md"
|
||||
}
|
||||
|
||||
# --- the labels this repo's own workflow asks for ---
|
||||
#
|
||||
# Whether a job container gets the Docker socket is settled by the runner's
|
||||
# config.yaml - which lives in dejima-ci-hardener's territory, not this
|
||||
# repository's - so the only lever here is which runner its jobs may land on.
|
||||
# A generic label gives that lever away, which is what these pin.
|
||||
|
||||
# pull_request_target reads this workflow from the base branch, which is what
|
||||
# pins runs-on out of a branch's reach. Reverting to pull_request hands that
|
||||
# back, silently.
|
||||
@test "the workflow is read from the base branch, not the branch under test" {
|
||||
local wf="$REPO_ROOT/.gitea/workflows/tests.yml"
|
||||
grep -qE '^ pull_request_target:' "$wf"
|
||||
|
||||
# pull_request may still be present, but only while the transition is
|
||||
# explicitly in progress - it takes an untrusted run from the head with
|
||||
# branch-controlled runs-on, which is the thing being removed. Requiring
|
||||
# the marker means the day it is dropped, this fails until the trigger
|
||||
# goes with it, rather than the pair quietly becoming permanent.
|
||||
if grep -qE '^ pull_request:' "$wf"; then
|
||||
grep -q 'TRANSITIONAL' "$wf" || {
|
||||
echo "pull_request is still enabled without the transitional note -" >&2
|
||||
echo "it hands runs-on back to the branch. Remove the trigger." >&2
|
||||
false
|
||||
}
|
||||
fi
|
||||
}
|
||||
|
||||
# The trap in that conversion: the run's ref is the BASE branch, so a bare
|
||||
# checkout tests main and passes regardless of the change. Every suite that
|
||||
# means to test the branch has to name the head explicitly - and a green run
|
||||
# is exactly what it looks like when one does not.
|
||||
@test "every suite that tests the branch checks out the branch" {
|
||||
local wf="$REPO_ROOT/.gitea/workflows/tests.yml"
|
||||
# Both branch-testing jobs pin the head sha; the runner-env jobs must not,
|
||||
# since their assertions should come from the base branch.
|
||||
[ "$(grep -c 'pull_request.head.sha' "$wf")" -eq 2 ]
|
||||
[ "$(grep -c 'uses: actions/checkout@v4' "$wf")" -eq 4 ]
|
||||
}
|
||||
|
||||
@test "no workflow job runs on a generic runner label" {
|
||||
run grep -nE '^\s*runs-on:\s*(ubuntu|windows|macos|self-hosted)' \
|
||||
"$REPO_ROOT"/.gitea/workflows/*.yml
|
||||
[ "$status" -ne 0 ] || {
|
||||
echo "a generic label lets this job land on a runner nobody hardened:" >&2
|
||||
echo "$output" >&2
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
@test "every workflow job runs on a dejima-specific label" {
|
||||
while IFS= read -r label; do
|
||||
[[ "$label" == dejima-* ]] || {
|
||||
echo "unexpected runs-on label: $label" >&2
|
||||
false
|
||||
}
|
||||
done < <(grep -hE '^\s*runs-on:' "$REPO_ROOT"/.gitea/workflows/*.yml \
|
||||
| sed -E 's/^\s*runs-on:\s*//')
|
||||
}
|
||||
|
||||
# Remote access is opt-in and off by default, so the marker-directory check
|
||||
# must be inside the "was it configured at all" guard. Outside it, every
|
||||
# ordinary session gets a warning about a feature it never asked for - and a
|
||||
# warning that fires on every start is one nobody reads.
|
||||
@test "the marker-directory check only runs when remote access is configured" {
|
||||
local f="$REPO_ROOT/docker/entrypoint.sh"
|
||||
grep -q 'if \[ -n "${DEJIMA_NOTIFY_DIR:-}" \]; then' "$f"
|
||||
# The check's own output must sit inside that guard, not before it.
|
||||
guard=$(grep -n 'if \[ -n "${DEJIMA_NOTIFY_DIR:-}" \]; then' "$f" | head -1 | cut -d: -f1)
|
||||
warn=$(grep -n 'remote access is enabled, but' "$f" | head -1 | cut -d: -f1)
|
||||
[ "$guard" -lt "$warn" ]
|
||||
}
|
||||
|
||||
# Chowning it would restore notifications while the session stayed absent from
|
||||
# dejima-attach, because the registry entry is written host-side and never
|
||||
# mounted here - a session that looked fixed with half the feature still
|
||||
# broken. The remedy is relaunching, which the message names.
|
||||
@test "the marker-directory check reports rather than repairs" {
|
||||
local f="$REPO_ROOT/docker/entrypoint.sh"
|
||||
run grep -nE 'chown[^|]*DEJIMA_NOTIFY_DIR|chmod[^|]*DEJIMA_NOTIFY_DIR' "$f"
|
||||
[ "$status" -ne 0 ] || { echo "$output" >&2; false; }
|
||||
grep -q "start a new session with 'dejima'" "$f"
|
||||
}
|
||||
|
||||
# bin/dejima warns and continues rather than refusing. The neighbouring
|
||||
# state-dir check does refuse, but that one is about a symlink handing a
|
||||
# session an attacker-chosen path; this one is only about an optional feature
|
||||
# working, and blocking a launch over undeliverable notifications costs more
|
||||
# than it saves.
|
||||
@test "bin/dejima warns about an unusable marker directory without refusing" {
|
||||
local line
|
||||
line=$(grep -n 'is not a directory this user owns and can write' "$REPO_ROOT/bin/dejima" | head -1 | cut -d: -f1)
|
||||
[ -n "$line" ]
|
||||
run sed -n "$((line)),$((line + 6))p" "$REPO_ROOT/bin/dejima"
|
||||
[[ "$output" != *"exit 1"* ]]
|
||||
}
|
||||
|
||||
@test "sandbox-context.sh no longer mentions ALLOWED_DOMAINS" {
|
||||
run grep -n 'ALLOWED_DOMAINS' "$REPO_ROOT/hooks/sandbox-context.sh"
|
||||
[ "$status" -ne 0 ]
|
||||
@@ -1581,6 +1895,10 @@ SCANNED_PATHS=(README.md docs docker bin forges workflows harnesses hooks instal
|
||||
}
|
||||
}
|
||||
|
||||
# The exemption above is only defensible while it is the address the docs
|
||||
# actually recommend. If the recommendation changes and this does not, the
|
||||
# exemption starts covering an address nothing points at - a hole rather than
|
||||
# a carve-out.
|
||||
@test "no hostname under a private-network suffix is used as a documentation example" {
|
||||
run grep -rnIE '[A-Za-z0-9-]+\.(localdomain|lan|internal|home\.arpa)\b' \
|
||||
"${SCANNED_PATHS[@]/#/$REPO_ROOT/}"
|
||||
@@ -1630,9 +1948,17 @@ EOF
|
||||
chmod +x "$DEJIMA_T/docker"
|
||||
: > "$DEJIMA_T/sandbox-key"
|
||||
chmod 600 "$DEJIMA_T/sandbox-key"
|
||||
# forge = none, workflow = local-only: provider_validate_selection runs
|
||||
# ahead of the audit-startup path these tests are actually about, and the
|
||||
# default gitea/worktree-pr pair is refused unconfigured (see
|
||||
# providers.bats) - this fixture sidesteps that with the honest
|
||||
# "no forge, no push" pair rather than configuring a forge just to get
|
||||
# past validation.
|
||||
cat > "$DEJIMA_T/config/config" <<EOF
|
||||
[core]
|
||||
ssh_key = $DEJIMA_T/sandbox-key
|
||||
forge = none
|
||||
workflow = local-only
|
||||
[audit]
|
||||
dir = $DEJIMA_T/audit
|
||||
EOF
|
||||
@@ -1786,6 +2112,27 @@ startup_warning_slug() { printf '%s' "$DEJIMA_T/ws" | sed 's/[^a-zA-Z0-9]/-/g';
|
||||
grep -q 'SANDBOX_ANTHROPIC_AUTH_TOKEN="sandbox-credential-proxy-placeholder"' "$REPO_ROOT/bin/dejima"
|
||||
}
|
||||
|
||||
# The built-in list grants to every sandbox on the host, for every project,
|
||||
# with no review anywhere - so what belongs in it is what the sandbox itself
|
||||
# cannot run without. A language package host reaching it is how the list
|
||||
# grows one reasonable-looking entry at a time until "allowed by default"
|
||||
# covers most of the internet a build would want.
|
||||
#
|
||||
# The route for these is an approval: `dejima-allow --global`, or a reviewed
|
||||
# .dejima/allowed-domains line. Adding one back here means arguing that the
|
||||
# sandbox itself needs it.
|
||||
@test "no language package host is built into the allowlist" {
|
||||
run bash -c "SANDBOX_ANTHROPIC_PROXY_SOCKET=/tmp/p.sock; source '$SANDBOX_DIR/fixed-domains.sh'; printf '%s\n' \"\${FIXED_DOMAINS[@]}\""
|
||||
[ "$status" -eq 0 ]
|
||||
for d in pypi.org files.pythonhosted.org proxy.golang.org sum.golang.org \
|
||||
crates.io static.crates.io rubygems.org gitlab.com; do
|
||||
! grep -qx "$d" <<< "$output" || {
|
||||
echo "$d is built in; it should be an approval instead" >&2
|
||||
false
|
||||
}
|
||||
done
|
||||
}
|
||||
|
||||
@test "api.anthropic.com is in the fixed domains only when the proxy is off" {
|
||||
# Not unconditionally allowlisted: with the proxy on, the session reaches
|
||||
# the API through the forwarder rather than by resolving this name, so
|
||||
@@ -1836,25 +2183,61 @@ startup_warning_slug() { printf '%s' "$DEJIMA_T/ws" | sed 's/[^a-zA-Z0-9]/-/g';
|
||||
# prose leak would both defeat the point, so each file gets its own
|
||||
# narrow, justified exclusion for the specific lines that are not a
|
||||
# forge coupling - everything else in scope must still match nothing.
|
||||
local f matches fail=0
|
||||
#
|
||||
# Two scans, because they have different subjects:
|
||||
#
|
||||
# (1) `gitea` and `github.com` as substrings, case-insensitively. This is
|
||||
# the original scan, and it is what catches a PROSE leak ("push to
|
||||
# the Gitea remote") as much as a coupling.
|
||||
#
|
||||
# (2) Every name `provider_list forge` reports, in literal position.
|
||||
# Derived from the shipped directories rather than written out here,
|
||||
# so a forge is covered the day it lands - which is exactly what
|
||||
# scan 1 missed: `github` became a shipped FORGE name, and a bare
|
||||
# `github` matches neither of scan 1's alternatives, so core could
|
||||
# (and did) compare a forge name to a literal with this test green.
|
||||
#
|
||||
# "Literal position" means quoted, backticked, bracketed as a config
|
||||
# section, prefixed `forge.`, under `forges/`, or a `case` arm -
|
||||
# every shape a lookup or comparison actually takes here. It is not a
|
||||
# bare-word scan because two shipped names cannot survive one: `none`
|
||||
# is an ordinary English word this repo's prose uses constantly, and
|
||||
# `github` is also the name of a CORE feature that predates the forge
|
||||
# axis (GitHub read-only: `[github] token_file`, the `github.com`
|
||||
# credential helper, `--allow-github-push`). Operator-facing remedy
|
||||
# text naming a config VALUE (`set [core] forge = none`, `or [core]
|
||||
# forge = github`) is deliberately outside these shapes too: a rule
|
||||
# that refuses a combination has to be able to say which one works,
|
||||
# and "pick a different forge" without saying which is a worse
|
||||
# message, not a purer one.
|
||||
local f matches fail=0 name literals="" body
|
||||
while IFS= read -r name; do
|
||||
[ -n "$name" ] || continue
|
||||
# The last alternative is a `case` arm - anchored to the start of a
|
||||
# line (optionally after other arms' `|`) so it is the shape bash
|
||||
# actually uses, not any prose that happens to close a parenthesis
|
||||
# after the word.
|
||||
literals="${literals:+$literals|}[\"'\`]$name[\"'\`]|\\[$name\\]|forge\\.$name|forges/$name|^[[:space:]]*[(|]?$name\\)|\\|$name\\)"
|
||||
done < <(bash -c 'source "$1"; provider_list forge' _ "$REPO_ROOT/bin/dejima-provider-lib.sh")
|
||||
[ -n "$literals" ] || { echo "no shipped forges to build the pattern from"; return 1; }
|
||||
for f in "$REPO_ROOT/bin/dejima" "$REPO_ROOT/bin/dejima-config-lib.sh" \
|
||||
"$REPO_ROOT/bin/dejima-provider-lib.sh" "$REPO_ROOT/docker/entrypoint.sh" \
|
||||
"$REPO_ROOT/docker/docker-compose.yml" "$REPO_ROOT/hooks/git-push-guard.sh" \
|
||||
"$REPO_ROOT/hooks/merge-guard.sh" "$REPO_ROOT/hooks/sandbox-context.sh"; do
|
||||
matches="$(grep -inE 'gitea|github\.com' "$f" || true)"
|
||||
# _warn_legacy_env's table names the OLD environment variables
|
||||
# (GITEA_SSH_HOST, GITEA_API, ...) dejima read directly before the
|
||||
# manifest schema replaced them - a one-time migration aid for an
|
||||
# operator's existing shell profile. Those names only ever meant
|
||||
# gitea (nothing else was ever wired that way) and do not change when
|
||||
# a forge is added or removed, so excluding the function body - the
|
||||
# same range test/config.bats isolates by name for its own "every
|
||||
# retired variable" test - is exempting history, not a live coupling.
|
||||
# A no-op for every other file here: none of them define it.
|
||||
body="$(sed '/^_warn_legacy_env()/,/^}/d' "$f")"
|
||||
matches="$(grep -inE 'gitea|github\.com' <<< "$body" || true)"
|
||||
matches="$matches"$'\n'"$(grep -nE "$literals" <<< "$body" || true)"
|
||||
case "$f" in
|
||||
*/bin/dejima)
|
||||
# _warn_legacy_env's table names the OLD environment
|
||||
# variables (GITEA_SSH_HOST, GITEA_API, ...) dejima read
|
||||
# directly before the manifest schema replaced them - a
|
||||
# one-time migration aid for an operator's existing shell
|
||||
# profile. Those names only ever meant gitea (nothing else
|
||||
# was ever wired that way) and do not change when a forge is
|
||||
# added or removed, so excluding the function body - the same
|
||||
# range test/config.bats isolates by name for its own
|
||||
# "every retired variable" test - is exempting history, not a
|
||||
# live coupling.
|
||||
matches="$(sed '/^_warn_legacy_env()/,/^}/d' "$f" | grep -inE 'gitea|github\.com' || true)"
|
||||
# A citation of an external issue URL, the same category as
|
||||
# hooks/sandbox-context.sh:3 below.
|
||||
matches="$(grep -vE 'claude-code/issues/29162' <<< "$matches" || true)"
|
||||
@@ -1864,6 +2247,13 @@ startup_warning_slug() { printf '%s' "$DEJIMA_T/ws" | sed 's/[^a-zA-Z0-9]/-/g';
|
||||
# docker/entrypoint.sh and docker-compose.yml already get
|
||||
# below, not a forge coupling.
|
||||
matches="$(grep -vE 'github\.com' <<< "$matches" || true)"
|
||||
# `[github] token_file` and the pairing that resolves it: the
|
||||
# CORE config section holding the read-only GitHub credential
|
||||
# every session gets whatever forge is selected. Same feature
|
||||
# as the exception above, in its config-file spelling - the
|
||||
# section predates the forge axis and would still exist with
|
||||
# every forge directory deleted.
|
||||
matches="$(grep -vE '\[github\] token_file|GH_TOKEN=github token_file' <<< "$matches" || true)"
|
||||
;;
|
||||
*/bin/dejima-config-lib.sh)
|
||||
# core.forge's schema DEFAULT - real data (today there is
|
||||
@@ -1889,6 +2279,9 @@ startup_warning_slug() { printf '%s' "$DEJIMA_T/ws" | sed 's/[^a-zA-Z0-9]/-/g';
|
||||
matches="$(grep -vE 'anthropics/claude-code/tree' <<< "$matches" || true)"
|
||||
;;
|
||||
esac
|
||||
# The two scans are concatenated with a newline, so an empty one
|
||||
# leaves a blank line rather than nothing at all.
|
||||
matches="$(grep -v '^$' <<< "$matches" || true)"
|
||||
if [ -n "$matches" ]; then
|
||||
echo "$f still names a forge:"
|
||||
echo "$matches"
|
||||
@@ -1914,15 +2307,21 @@ startup_warning_slug() { printf '%s' "$DEJIMA_T/ws" | sed 's/[^a-zA-Z0-9]/-/g';
|
||||
[ "$status" -ne 0 ]
|
||||
}
|
||||
|
||||
@test "every sandbox suite is in the CI matrix, and runtime.bats is not" {
|
||||
@test "every suite is run by CI, and the two that need their own job have one" {
|
||||
# Derived from the directory rather than a list kept here, so adding a
|
||||
# test file fails this until the workflow is told about it. A suite nobody
|
||||
# wired into CI runs nowhere, and nothing else would say so.
|
||||
#
|
||||
# Two suites are not matrix entries because they cannot be. runtime.bats
|
||||
# needs a Docker daemon; ci-runner.bats asserts on the runner itself and
|
||||
# has to run once per tier rather than once per suite. Both are checked
|
||||
# below for having a job of their own instead - "not in the matrix" must
|
||||
# not be a way to be in neither.
|
||||
local wf="$REPO_ROOT/.gitea/workflows/tests.yml"
|
||||
local f base missing=""
|
||||
for f in "$REPO_ROOT"/test/*.bats; do
|
||||
base="$(basename "$f" .bats)"
|
||||
[ "$base" = "runtime" ] && continue
|
||||
case "$base" in runtime|ci-runner) continue ;; esac
|
||||
grep -qE "^ *- *$base\$" "$wf" || missing="$missing $base"
|
||||
done
|
||||
[ -z "$missing" ] || {
|
||||
@@ -1935,4 +2334,71 @@ startup_warning_slug() { printf '%s' "$DEJIMA_T/ws" | sed 's/[^a-zA-Z0-9]/-/g';
|
||||
# fail every time rather than testing anything.
|
||||
! grep -qE '^ *- *runtime$' "$wf"
|
||||
grep -q 'test/runtime.bats' "$wf"
|
||||
|
||||
# ci-runner.bats likewise, and once per tier: a single job would leave one
|
||||
# of the two runners unasserted, which is the thing it exists to catch.
|
||||
! grep -qE '^ *- *ci-runner$' "$wf"
|
||||
grep -q 'test/ci-runner.bats' "$wf"
|
||||
grep -q 'DEJIMA_CI_TIER: sandbox' "$wf"
|
||||
grep -q 'DEJIMA_CI_TIER: host-docker' "$wf"
|
||||
}
|
||||
|
||||
@test "the agent briefing teaches the project scope, not just global" {
|
||||
# The observed failure: an agent asked for a project-specific domain to be
|
||||
# added container-wide and globally, because --project was never named.
|
||||
grep -q -- 'dejima-allow --project' "$REPO_ROOT/bin/dejima-provider-lib.sh"
|
||||
}
|
||||
|
||||
@test "the agent briefing and the context hook use the new argument order" {
|
||||
# A briefing that hands the operator a command the CLI now rejects is worse
|
||||
# than one that says nothing.
|
||||
run grep -nE 'dejima-allow [a-z<][^ ]* +--' \
|
||||
"$REPO_ROOT/bin/dejima-provider-lib.sh" "$REPO_ROOT/hooks/sandbox-context.sh"
|
||||
[ "$status" -ne 0 ]
|
||||
}
|
||||
|
||||
@test "the briefing's container placeholder is the template form, not a shell variable" {
|
||||
# _briefing_core_preamble is a quoted heredoc rendered by placeholder
|
||||
# substitution; a literal $container_id would reach the agent unexpanded.
|
||||
run grep -n 'dejima-allow --container \$container_id' "$REPO_ROOT/bin/dejima-provider-lib.sh"
|
||||
[ "$status" -ne 0 ]
|
||||
grep -q 'dejima-allow --container {{container_id}}' "$REPO_ROOT/bin/dejima-provider-lib.sh"
|
||||
}
|
||||
|
||||
@test "README documents dejima-allow with flags before the domain" {
|
||||
grep -q 'dejima-allow --global crates.io' "$REPO_ROOT/README.md"
|
||||
run grep -nE 'dejima-allow [a-z][^ ]* +--(global|project)' "$REPO_ROOT/README.md"
|
||||
[ "$status" -ne 0 ]
|
||||
}
|
||||
|
||||
@test "README documents dejima-dep with flags before the spec" {
|
||||
grep -q 'dejima-dep add --global cargo:cargo-nextest' "$REPO_ROOT/README.md"
|
||||
run grep -nE 'dejima-dep (add|remove) [a-z][^ ]* +--(global|project)' "$REPO_ROOT/README.md"
|
||||
[ "$status" -ne 0 ]
|
||||
}
|
||||
|
||||
@test "README does not claim --global/--project patch a running session" {
|
||||
# A scope-flag-only call sets session_wanted=0 and never touches docker -
|
||||
# see bin/dejima-allow. Claiming otherwise sends an operator to expect a
|
||||
# live update they will not get.
|
||||
run grep -nE 'which also (updates|patches) the running session' "$REPO_ROOT/README.md"
|
||||
[ "$status" -ne 0 ]
|
||||
grep -q 'deliberately do not touch a running container' "$REPO_ROOT/README.md"
|
||||
}
|
||||
|
||||
@test "README does not claim dejima-project approve reports live-session failures" {
|
||||
# approve only ever calls dejima-allow without --container (see
|
||||
# bin/dejima-project's approve case), so a failure there is a persistence
|
||||
# failure, never a live-session one.
|
||||
run grep -n 'could not apply to a running session' "$REPO_ROOT/README.md"
|
||||
[ "$status" -ne 0 ]
|
||||
grep -q 'never attempts to patch a running session' "$REPO_ROOT/README.md"
|
||||
}
|
||||
|
||||
@test "README does not attribute container disambiguation to a scope flag" {
|
||||
# A --project/--global-only call sets session_wanted=0 and never reaches
|
||||
# the multiple-containers ambiguity check at all - only the bare form
|
||||
# (or bare + --container) does. Only the bare form can ever refuse on it.
|
||||
run grep -n 'scope-flag) form\|scope-flag form' "$REPO_ROOT/README.md"
|
||||
[ "$status" -ne 0 ]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
Git workflow - do this even though nothing stops you from doing otherwise:
|
||||
- Do new work in a git worktree for the feature branch, not by switching
|
||||
branches in place in the mounted checkout - that's the user's actual
|
||||
working copy, and changing its branch out from under them is disruptive
|
||||
even if you switch it back.
|
||||
<!-- core: worktree -->
|
||||
- Push that branch to the remote when the work is complete and its tests
|
||||
pass. Do that WITHOUT asking first: this workflow's destination is known,
|
||||
so asking makes the operator answer the same question every time. General
|
||||
branch-finishing advice from a skill cannot know that, and does not
|
||||
override it.
|
||||
- Stop at the push. Do not open a pull request, and do not merge anything -
|
||||
neither is part of this workflow. Report the branch name and stop.
|
||||
- Never push directly to {{blocked_branches}}, on any remote. A
|
||||
`git-push-guard.sh` hook enforces that as a backstop, but treat it as the
|
||||
workflow to follow rather than a filter to route around.
|
||||
@@ -0,0 +1,9 @@
|
||||
# block_merge is off despite this being a pushing workflow: nothing here ever
|
||||
# reaches a forge's PR interface, so there is no merge command to block.
|
||||
requires =
|
||||
push = on
|
||||
blocked_branches = main master
|
||||
block_merge = off
|
||||
requires_worktree = on
|
||||
autonomy = push-branch
|
||||
briefing = briefing.md
|
||||
@@ -0,0 +1,9 @@
|
||||
Git workflow - do this even though nothing stops you from doing otherwise:
|
||||
- Commit your work locally as you go. Nothing in this session pushes
|
||||
anywhere: there is no remote destination configured for it, and
|
||||
`git-push-guard.sh` blocks pushes as a backstop.
|
||||
- Do not open pull requests and do not merge. Neither is part of this
|
||||
workflow.
|
||||
- Report what you committed and stop. If the work needs to reach a remote,
|
||||
say so plainly rather than attempting it - that is the operator's call and
|
||||
needs a different workflow.
|
||||
@@ -0,0 +1,9 @@
|
||||
# blocked_branches is empty BECAUSE push is off. The two are coupled: turning
|
||||
# push on without filling this in would silently allow a push to main.
|
||||
requires =
|
||||
push = off
|
||||
blocked_branches =
|
||||
block_merge = off
|
||||
requires_worktree = off
|
||||
autonomy = commit
|
||||
briefing = briefing.md
|
||||
@@ -4,9 +4,9 @@ Git workflow - do this even though nothing stops you from doing otherwise:
|
||||
working copy, and changing its branch out from under them is disruptive
|
||||
even if you switch it back.
|
||||
<!-- core: worktree -->
|
||||
- Push that branch to the Gitea remote, then run `{{pr_command}} create` from
|
||||
the worktree. Never push to GitHub (read-only, and not reachable for
|
||||
push regardless), and never push directly to main/master, on any remote.
|
||||
- Push that branch to the remote, then run `{{pr_command}} create` from
|
||||
the worktree. Never push directly to {{blocked_branches}}, on any remote,
|
||||
and never to a host the preamble above didn't say this session can push to.
|
||||
A `git-push-guard.sh` hook enforces both as a backstop, but treat them
|
||||
as the actual workflow to follow, not just a filter to route around.
|
||||
Merging is the operator's call, not yours - a `merge-guard.sh`
|
||||
@@ -25,7 +25,7 @@ Git workflow - do this even though nothing stops you from doing otherwise:
|
||||
<!-- end -->
|
||||
<!-- requires: issues -->
|
||||
- Name the issues a PR resolves in its body: `Fixes #N` (`Closes` and
|
||||
`Resolves` work too) tells Gitea to close them when it merges. Use
|
||||
`Resolves` work too) tells the forge to close them when it merges. Use
|
||||
`Refs #N` for work that is partial or merely related, so those stay
|
||||
open. Prefer the keyword to closing anything yourself once it lands,
|
||||
because of the line above - you stop at the PR, and the merge happens
|
||||
|
||||
Reference in New Issue
Block a user