Compare commits
161
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc05990e33 | ||
|
|
e837634c1f | ||
|
|
283527fc6d | ||
|
|
efb92cb703 | ||
|
|
77a298264c | ||
|
|
d31c707217 | ||
|
|
0e09060555 | ||
|
|
e7f5a9b541 | ||
|
|
54b00d73a6 | ||
|
|
ecb886f658 | ||
|
|
aa11bdd27b | ||
|
|
32841b4a2e | ||
|
|
760af9946f | ||
|
|
b7f641594b | ||
|
|
cd8ac7be0f | ||
|
|
8438daa07e | ||
|
|
631b73ae5f | ||
|
|
d0052161ef | ||
|
|
8a26412716 | ||
|
|
0a323ed230 | ||
|
|
db989b3277 | ||
|
|
3a6acf102a | ||
|
|
18cff7eece | ||
|
|
da0920d13f | ||
|
|
e31c651a5e | ||
|
|
7079fca1cd | ||
|
|
4dfa6e3927 | ||
|
|
b7880c1084 | ||
|
|
d7900f1b03 | ||
|
|
d234e473de | ||
|
|
96c8906351 | ||
|
|
2c75ef0661 | ||
|
|
9a0c56f9a7 | ||
|
|
ab8e26437c | ||
|
|
034913205a | ||
|
|
9cb6b7b432 | ||
|
|
32589c0603 | ||
|
|
3a72114770 | ||
|
|
e69d35a8a4 | ||
|
|
07e21f86b1 | ||
|
|
57ad800c34 | ||
|
|
c441116c3b | ||
|
|
97bada30d3 | ||
|
|
81123c6d05 | ||
|
|
c2023ff402 | ||
|
|
9374eb5757 | ||
|
|
5c1717ebc4 | ||
|
|
4ba9a0499e | ||
|
|
3b03545c3a | ||
|
|
f19f89c13a | ||
|
|
fc9ab6768b | ||
|
|
dddeefcd21 | ||
|
|
40e6c7ba8b | ||
|
|
599fc6e3eb | ||
|
|
7ed9790b6c | ||
|
|
d50343869f | ||
|
|
70b2a1e392 | ||
|
|
cd6ad04145 | ||
|
|
38ee9ee246 | ||
|
|
e2ea1a8afb | ||
|
|
313250f7c4 | ||
|
|
fa106cd8ff | ||
|
|
37e8609db3 | ||
|
|
c108e58992 | ||
|
|
cdf0e78525 | ||
|
|
f72e4292d5 | ||
|
|
42088f9c07 | ||
|
|
6e969c636f | ||
|
|
73e1a84414 | ||
|
|
0aef363910 | ||
|
|
2222aef7d1 | ||
|
|
b71b0c1dda | ||
|
|
e7e6a57dc3 | ||
|
|
d648c7743a | ||
|
|
00555eadd7 | ||
|
|
da045fa77f | ||
|
|
e241ff9a31 | ||
|
|
5fa4be3cac | ||
|
|
8122267f2b | ||
|
|
0e29236dff | ||
|
|
aec22e32b7 | ||
|
|
5a2d460075 | ||
|
|
1e65852f2c | ||
|
|
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 |
+58
-5
@@ -10,9 +10,20 @@ told) and `README.md`.
|
||||
`test/runtime.bats` builds the image and boots real containers, so it needs a
|
||||
working Docker. A dejima sandbox does not have one — the container has no Docker
|
||||
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
|
||||
mean: `confined` for the suites that need only a shell, `privileged` 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
|
||||
@@ -37,13 +48,13 @@ a change to a shipped doc or a `docker/` file usually needs `static.bats` *as
|
||||
well as* whatever else it touched — that suite asserts on files it does not own.
|
||||
|
||||
**CI runs `test/runtime.bats` on every pull request**, as a separate
|
||||
`host-docker` job in `.gitea/workflows/tests.yml`. So being unable to run it
|
||||
`privileged` job in `.gitea/workflows/tests.yml`. So being unable to run it
|
||||
locally is not a reason to stop, to hedge, or to leave a change unfinished. Run
|
||||
the sandbox suites, open the PR, and say plainly in the PR body that the
|
||||
host-docker half was not run locally.
|
||||
the confined suites, open the PR, and say plainly in the PR body that the
|
||||
privileged half was not run locally.
|
||||
|
||||
**But do not read "CI covers it" as "it passed."** That job is pinned to
|
||||
`runs-on: dejima-runtime`, a label only the one configured self-hosted runner
|
||||
`runs-on: dejima-privileged`, a label only the one configured self-hosted runner
|
||||
carries — it needs the host network namespace, the Docker socket, and a shared
|
||||
volume, none of which a workflow can request. If that runner is offline the job
|
||||
**queues instead of failing**, and the PR sits with the runtime half neither
|
||||
@@ -55,6 +66,48 @@ 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).
|
||||
|
||||
## Batch your pushes, because runs queue rather than supersede
|
||||
|
||||
Every push to an open pull request starts a full workflow run, and Gitea does
|
||||
not implement `concurrency:` (see the comment at the top of
|
||||
`.gitea/workflows/tests.yml`, which records the test). A second push does not
|
||||
cancel the first: it queues behind it. So four pushes in ten minutes is four
|
||||
full runs, and the load keeps arriving long after you have stopped pushing,
|
||||
because the backlog is still draining.
|
||||
|
||||
That is not hypothetical here. A session pushing commit-by-commit while
|
||||
iterating on a pull request queued enough runs to saturate this project's
|
||||
forge: nginx stopped being able to open connections to Gitea at all, and the
|
||||
requests that timed out were the runners' own `UpdateLog` and `UpdateTask`
|
||||
calls plus the operator's browser. The forge went down for several minutes and
|
||||
tripped uptime alerts.
|
||||
|
||||
So: **finish a change, then push once.** Commit as often as you like locally;
|
||||
that costs nothing. What costs something is every `git push`. If you have three
|
||||
commits to make, make them and push at the end, rather than pushing after each.
|
||||
|
||||
The same applies to forge API reads. `gitea-pr comments` fetches one request
|
||||
per review on the pull request, so on a heavily reviewed one that is dozens of
|
||||
requests per invocation. Run it when you need the answer, not repeatedly while
|
||||
iterating, and never in a loop.
|
||||
|
||||
If a push does supersede a run you no longer care about, cancel the stale one
|
||||
by hand in the web UI. Nothing does it for you.
|
||||
|
||||
## 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
|
||||
+311
-19
@@ -8,17 +8,44 @@
|
||||
# and boots real containers, so it needs a Docker daemon the sandbox
|
||||
# deliberately does not have. That constraint is what a contributor has to
|
||||
# reason about when deciding what they can run before pushing, so the jobs are
|
||||
# named for it.
|
||||
# named for the runner tier they need: `confined` for the ones a shell can
|
||||
# run anywhere, `privileged` for the one that needs a daemon.
|
||||
#
|
||||
# The sandbox half is a matrix over its files rather than one invocation. The
|
||||
# The confined half is a matrix over its files rather than one invocation. The
|
||||
# files are independent, so running them together only serialised them: wall
|
||||
# clock was their sum rather than the slowest one. It also means a failure
|
||||
# names the suite that failed in the job list, instead of one red check
|
||||
# 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
|
||||
@@ -28,8 +55,22 @@ on:
|
||||
# supersede, and cancel the stale run by hand if it is in the way.
|
||||
|
||||
jobs:
|
||||
sandbox:
|
||||
runs-on: ubuntu-latest
|
||||
confined:
|
||||
# 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-confined
|
||||
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
|
||||
@@ -45,17 +86,71 @@ jobs:
|
||||
- proxy
|
||||
- config
|
||||
- providers
|
||||
name: sandbox (${{ matrix.suite }})
|
||||
name: confined (${{ matrix.suite }})
|
||||
container:
|
||||
# Debian bookworm plus a node binary - node is what actions/checkout
|
||||
# needs to run at all, and apt-get is what everything below needs.
|
||||
image: node:20-bookworm
|
||||
steps:
|
||||
# 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
|
||||
@@ -71,20 +166,22 @@ jobs:
|
||||
chown -R node:node .
|
||||
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.
|
||||
privileged:
|
||||
# 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.
|
||||
runs-on: dejima-runtime
|
||||
# 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-privileged
|
||||
container:
|
||||
image: node:20-bookworm
|
||||
# One volume, and it is load-bearing - see TMPDIR on the bats step for
|
||||
@@ -117,6 +214,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 +385,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 +439,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-confined:
|
||||
runs-on: dejima-confined
|
||||
name: runner-env (confined)
|
||||
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: confined
|
||||
|
||||
runner-env-privileged:
|
||||
runs-on: dejima-privileged
|
||||
name: runner-env (privileged)
|
||||
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: privileged
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
# Attribution
|
||||
|
||||
The code in the dejima repositories is GPL-3.0-or-later; see `LICENSE`. The
|
||||
artwork is not code and is not covered by it.
|
||||
|
||||
This file is the canonical statement for the dejima marks. Other repositories
|
||||
link here rather than restating the terms.
|
||||
|
||||
## Authorship
|
||||
|
||||
All logos, assets, and artwork are human-made. No AI tools were used in their
|
||||
creation. The artist is anonymous.
|
||||
|
||||
The palette is based on [gruvbox](https://github.com/morhetz/gruvbox) by Pavel
|
||||
Pertsev, which is MIT-licensed. The colour values are gruvbox's; the marks
|
||||
drawn with them are not.
|
||||
|
||||
## What is covered
|
||||
|
||||
Both marks, in every form:
|
||||
|
||||
- the logo: the filled tiles and the outline variants, in both light-mode and
|
||||
dark-mode renderings, at any resolution, and the favicon;
|
||||
- the wordmark: the solid and outline renderings, again in both modes.
|
||||
|
||||
The terms attach to the marks rather than to this list, so renderings not
|
||||
listed here are covered too.
|
||||
|
||||
## Terms
|
||||
|
||||
The dejima name, logo and wordmark are trademarks of Brad Anderson **as they
|
||||
identify this software**. A trademark attaches to a mark as used for
|
||||
particular goods and services, never to a word in the abstract, and the claim
|
||||
here goes no further than that.
|
||||
|
||||
Dejima is a real place - the island in Nagasaki harbour this project is named
|
||||
after, and rebuilt there today. Nothing in this file claims the word itself,
|
||||
its historical or geographic sense, or any use unconnected with this
|
||||
software. Writing about the island, naming something after it, or using the
|
||||
name in any way that is not about this project is outside these terms and
|
||||
always was.
|
||||
|
||||
The marks are not licensed under the GPL; GPL-3.0 §7(e) permits declining to
|
||||
grant trademark rights. All rights not granted below are reserved.
|
||||
|
||||
**You may, without asking:**
|
||||
|
||||
- reproduce them unmodified to refer to dejima;
|
||||
- redistribute them unmodified as part of an unmodified copy of a repository
|
||||
that ships them.
|
||||
|
||||
**You may not, without written permission:**
|
||||
|
||||
- modify the artwork: recolour, redraw, crop, animate, or fold it into
|
||||
another mark;
|
||||
- use the artwork in your own work, or as the mark of any other project,
|
||||
product or organisation;
|
||||
- use the name for software in a way likely to be taken as referring to this
|
||||
project - which is the only sense in which the name is claimed at all;
|
||||
- use any of them so as to suggest that dejima endorses, sponsors or produced
|
||||
your work.
|
||||
|
||||
A fork that remains a copy of dejima may keep the files. A fork that diverges
|
||||
into a project of its own must replace them with a mark of its own.
|
||||
|
||||
Copyright in the image files is reserved.
|
||||
@@ -0,0 +1,33 @@
|
||||
# Contributing
|
||||
|
||||
[← dejima README](README.md)
|
||||
|
||||
dejima is under active, rapid development, and it is developed on a private
|
||||
forge. That forge does not allow account registration, and it does not accept
|
||||
anonymous issues or pull requests. So there is currently no way for you to
|
||||
file either, and that is a property of where this lives rather than a comment
|
||||
on the contribution.
|
||||
|
||||
That will change: this project will provide a way to submit issues and pull
|
||||
requests. Until it does, if you are using dejima and need it to do something
|
||||
different, **fork it and make the change in your fork**. Nothing here depends
|
||||
on an upstream maintainer accepting a patch. The fork is yours to run, and
|
||||
running it is the point.
|
||||
|
||||
## Expect it to move
|
||||
|
||||
Significant change is coming in the near future. Config keys, interfaces, the
|
||||
layout of the docs, and the boundaries between the CLIs are all still
|
||||
settling. A fork taken today will diverge from upstream quickly.
|
||||
|
||||
If you do fork, pinning to a commit you have read is more useful here than
|
||||
tracking a branch that is being rewritten under you.
|
||||
|
||||
## If you are running an agent against this repository
|
||||
|
||||
`docs/responsible-use.md` applies to dejima itself as much as to anything else
|
||||
you point it at, and one rule in it is load-bearing here: what reaches `main`
|
||||
becomes the baseline for everything after it. dejima-ci-hardener reads
|
||||
`.dejima/allowed-domains` from `origin/main` and treats what it finds there as
|
||||
reviewed, so a change to that file is a change to what a CI runner will let
|
||||
through.
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
# Security policy
|
||||
|
||||
This is the security policy for every repository in the dejima organisation.
|
||||
Individual repositories state what is specific to them and link here for the
|
||||
rest.
|
||||
|
||||
## What dejima claims
|
||||
|
||||
It bounds what an agent session can reach. It does not make an agent safe to
|
||||
run.
|
||||
|
||||
A session runs in a container with no permission prompts, one bind-mounted
|
||||
project directory, a deny-by-default egress allowlist covering DNS as well as
|
||||
IP, and no copy of the credentials it authenticates with. That allowlist
|
||||
bounds where a prompt injection or a compromised dependency can send code and
|
||||
where it can fetch its next instruction. It does not prevent either one.
|
||||
|
||||
Nothing here holds against a container escape or a kernel bug. The in-session
|
||||
hooks are fast-fail guards, not a boundary. The exit-time audit runs after the
|
||||
session has ended and attributes nothing to it. And none of it speaks to
|
||||
whether the code a session wrote is correct, secure, or fit to merge.
|
||||
|
||||
[docs/security.md](docs/security.md) states the threat model and covers each
|
||||
of these in detail.
|
||||
|
||||
## Reporting a vulnerability
|
||||
|
||||
Email **dejima@branderson.io**. Please do not open a public issue for a
|
||||
vulnerability; the tracker is public, and a report there is a disclosure.
|
||||
|
||||
Useful things to include:
|
||||
|
||||
1. what an attacker gets;
|
||||
2. the steps to reproduce it;
|
||||
3. the repository and the commit you tested.
|
||||
|
||||
## What you can expect in return
|
||||
|
||||
Nothing is promised: no reply, no acknowledgement, no triage, no fix, and no
|
||||
timeline for any of them. Reports are read when there is time to read them.
|
||||
|
||||
Nothing is asked in return either. There is no embargo period and no
|
||||
coordinated disclosure window. Disclose on whatever schedule you think is
|
||||
right.
|
||||
|
||||
## This is not proven software
|
||||
|
||||
These projects are in early development. Nothing in them has been through
|
||||
outside review, an external audit, or adversarial testing by anyone other
|
||||
than the people who wrote them. Where a design argument is stated it is one
|
||||
person's reasoning; where a test asserts something it asserts what that
|
||||
person thought to check.
|
||||
|
||||
Treat every security property described in any of these repositories as a
|
||||
claim awaiting scrutiny rather than one that has survived it, and do not
|
||||
deploy them where being wrong would matter.
|
||||
|
||||
## There is no support
|
||||
|
||||
These are personal projects with one maintainer.
|
||||
|
||||
There is no paid version, no support contract, no service level, no sponsorship
|
||||
attached to them, and no commercial relationship with anyone who uses them.
|
||||
Questions may go unanswered. Issues may go unread. Nothing here is developed or
|
||||
supplied in the course of a commercial activity.
|
||||
|
||||
Whoever deploys this is responsible for the result.
|
||||
|
||||
## Supported versions
|
||||
|
||||
The tip of `main`, and nothing else. There are no maintenance branches, no
|
||||
backports and no security releases for older tags or commits.
|
||||
|
||||
## No warranty
|
||||
|
||||
The software is provided without warranty of any kind. See sections 15 and 16
|
||||
of each repository's `LICENSE`.
|
||||
@@ -0,0 +1,19 @@
|
||||
# assets
|
||||
|
||||
The dejima wordmark, in the two versions the README needs: `-darkmode` for a
|
||||
page that renders dark, `-lightmode` for one that renders light. The suffix
|
||||
names the mode a file is for, not the colour of its ink, so `-darkmode` is the
|
||||
pale one.
|
||||
|
||||
The README points at `-darkmode` unconditionally. A mirror on a light-themed
|
||||
host changes that one line; the comment above the tag says why it is not
|
||||
conditional.
|
||||
|
||||
**These are trademarks and are not covered by this repository's GPL licence.**
|
||||
Who holds them, and what may be done with them, is stated once for the whole
|
||||
organisation, in this repository:
|
||||
|
||||
[ATTRIBUTION.md](../ATTRIBUTION.md)
|
||||
|
||||
Other repositories in the organisation link to that file by absolute URL, so
|
||||
it does not move or get renamed without their links breaking with it.
|
||||
@@ -0,0 +1,21 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="122" height="20" role="img"
|
||||
aria-label="Developed in dejima">
|
||||
<title>Developed in dejima</title>
|
||||
<linearGradient id="g" x2="0" y2="100%">
|
||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||
<stop offset="1" stop-opacity=".1"/>
|
||||
</linearGradient>
|
||||
<clipPath id="r"><rect width="122" height="20" rx="3" fill="#fff"/></clipPath>
|
||||
<g clip-path="url(#r)">
|
||||
<rect width="79" height="20" fill="#282828"/>
|
||||
<rect x="79" width="43" height="20" fill="#8ec07c"/>
|
||||
<rect width="122" height="20" fill="url(#g)"/>
|
||||
</g>
|
||||
<g font-family="Verdana,DejaVu Sans,Geneva,sans-serif" font-size="11"
|
||||
text-anchor="middle">
|
||||
<text x="39.5" y="15" fill="#010101" fill-opacity=".3">Developed in</text>
|
||||
<text x="39.5" y="14" fill="#ebdbb2">Developed in</text>
|
||||
<text x="100.5" y="15" fill="#010101" fill-opacity=".3">dejima</text>
|
||||
<text x="100.5" y="14" fill="#282828">dejima</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 969 B |
Binary file not shown.
|
After Width: | Height: | Size: 47 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 47 KiB |
+386
-116
@@ -21,6 +21,13 @@ COMPOSE_FILE="$REPO_ROOT/docker/docker-compose.yml"
|
||||
source "$SCRIPT_DIR/dejima-project-lib.sh"
|
||||
# shellcheck source=dejima-provider-lib.sh
|
||||
source "$SCRIPT_DIR/dejima-provider-lib.sh"
|
||||
# shellcheck source=dejima-color-lib.sh
|
||||
source "$SCRIPT_DIR/dejima-color-lib.sh"
|
||||
# shellcheck source=dejima-slug-lib.sh
|
||||
source "$SCRIPT_DIR/dejima-slug-lib.sh"
|
||||
# fd 2: everything this script says for itself goes to stderr, so the
|
||||
# session's own stdout stays exactly what the container wrote.
|
||||
color_init 2
|
||||
|
||||
# Flags come first, then the optional workspace path, then "--" and
|
||||
# everything after that reaches the container command untouched - including a
|
||||
@@ -54,6 +61,65 @@ _need_value() {
|
||||
}
|
||||
}
|
||||
|
||||
# Per-launch additions to [mounts] readonly and [mounts] read_write, for the
|
||||
# one-off cases: reading another checkout while migrating code out of it,
|
||||
# writing to a scratch directory for one session. Repeatable, one path each.
|
||||
#
|
||||
# These append to the config's own lists rather than carrying their own mount
|
||||
# code, which is the whole design. Everything that already applies to a
|
||||
# configured mount then applies to a flag automatically and with nothing
|
||||
# duplicated: the existence check, the refusal when a path names the approval
|
||||
# store or the audit stores, the refusal when the same path appears in both
|
||||
# lists, and the read-write paths the session briefing and the audit report
|
||||
# header name. A flag is a way to say where, not a way around any of that.
|
||||
EXTRA_RO_FLAGS=()
|
||||
EXTRA_RW_FLAGS=()
|
||||
FLAG_MOUNTS=""
|
||||
_add_mount_flag() {
|
||||
local flag="$1" value="$2"
|
||||
# Both lists are colon-separated, so a path containing one cannot be
|
||||
# represented in them at all - it would silently split into two mounts
|
||||
# that each fail the existence check. Say so instead.
|
||||
case "$value" in
|
||||
*:*) echo "$flag path must not contain a colon: $value" >&2; exit 1 ;;
|
||||
esac
|
||||
# Same leading-~ expansion the config's list:path type does, for the
|
||||
# quoted case the shell leaves alone.
|
||||
value="${value/#\~/$HOME}"
|
||||
# Relative paths are worth accepting here and meaningless in a config
|
||||
# file, which has no working directory to be relative to. Made absolute
|
||||
# by prefixing rather than by readlink -f: resolving would mount the
|
||||
# symlink's target under a path the operator never typed.
|
||||
case "$value" in /*) ;; *) value="$PWD/$value" ;; esac
|
||||
case "$flag" in
|
||||
--extra-readonly) EXTRA_RO_FLAGS+=("$value") ;;
|
||||
--extra-read-write) EXTRA_RW_FLAGS+=("$value") ;;
|
||||
esac
|
||||
# Recorded here, at parse time, and not where the lists are assembled:
|
||||
# that happens inside a command substitution, whose subshell would throw
|
||||
# the assignment away and leave every flag reported as a config entry.
|
||||
FLAG_MOUNTS="${FLAG_MOUNTS:+$FLAG_MOUNTS:}$value"
|
||||
}
|
||||
_append_mounts() {
|
||||
local list="$1"; shift
|
||||
local p
|
||||
for p in "$@"; do
|
||||
list="${list:+$list:}$p"
|
||||
done
|
||||
printf '%s' "$list"
|
||||
}
|
||||
# Which of the two the operator actually wrote, for the refusals below.
|
||||
# Without this a mistyped --ro is reported as a "[mounts] readonly entry",
|
||||
# sending someone to a config file that does not mention the path at all.
|
||||
|
||||
_mount_label() {
|
||||
local path="$1" from_config="$2" from_flag="$3"
|
||||
case ":$FLAG_MOUNTS:" in
|
||||
*":$path:"*) printf '%s' "$from_flag" ;;
|
||||
*) printf '%s' "$from_config" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
PRINT_CONFIG=0
|
||||
PRINT_BRIEFING=0
|
||||
ALLOW_GITHUB_PUSH_FLAG=0
|
||||
@@ -67,6 +133,12 @@ while [ $# -gt 0 ]; do
|
||||
--cmd) _need_value --cmd "${2:-}"; _set_override core.command "$2"; shift 2 ;;
|
||||
--forge) _need_value --forge "${2:-}"; _set_override core.forge "$2"; shift 2 ;;
|
||||
--workflow) _need_value --workflow "${2:-}"; _set_override core.workflow "$2"; shift 2 ;;
|
||||
--extra-readonly|--ro)
|
||||
_need_value --extra-readonly "${2:-}"
|
||||
_add_mount_flag --extra-readonly "$2"; shift 2 ;;
|
||||
--extra-read-write|--rw)
|
||||
_need_value --extra-read-write "${2:-}"
|
||||
_add_mount_flag --extra-read-write "$2"; shift 2 ;;
|
||||
--*) echo "unknown option: $1" >&2; exit 1 ;;
|
||||
*) break ;;
|
||||
esac
|
||||
@@ -157,6 +229,52 @@ if ! config_exists; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# A forge's env_export can name a variable core also fills - today GH_TOKEN,
|
||||
# which core sets from [github] token_file for every session regardless of
|
||||
# forge. One variable carries one token, so the forge's wins. Warned rather
|
||||
# than left silent: an operator who configured both is entitled to know which
|
||||
# is in effect. Not a refusal, since the forge winning is what selecting it
|
||||
# asked for.
|
||||
#
|
||||
# Driven off env_export rather than the forge's name - the collision belongs
|
||||
# to the variable, not to any particular forge. Checked here, ahead of
|
||||
# --print-config's exit, so that reports it too. Both values must actually be
|
||||
# set: a forge key resolving to nothing leaves core's in effect, and claiming
|
||||
# the forge won would be false.
|
||||
_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 +291,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)"
|
||||
@@ -211,6 +323,8 @@ WORKSPACE_DIR="$(cd "$WORKSPACE_DIR" && pwd)"
|
||||
AUDIT_DIR="$(config_get audit dir)"
|
||||
BASELINE_DIR="$(config_get audit baselines_dir)"
|
||||
SANDBOX_EXTRA_MOUNTS="$(config_get mounts readonly)"
|
||||
SANDBOX_EXTRA_MOUNTS="$(_append_mounts "$SANDBOX_EXTRA_MOUNTS" \
|
||||
${EXTRA_RO_FLAGS[@]+"${EXTRA_RO_FLAGS[@]}"})"
|
||||
|
||||
# Read-write host mounts. Distinct from [core] share_dir, which is mounted at
|
||||
# ~/shared - a well-known handoff point the agent is told about by name -
|
||||
@@ -231,7 +345,41 @@ _paths_overlap() {
|
||||
case "$b/" in "$a"/*) return 0 ;; esac
|
||||
return 1
|
||||
}
|
||||
|
||||
# The stores that decide what a session may do next, and the record of what
|
||||
# it did. Read-only is not enough to make mounting them harmless: the
|
||||
# approval store discloses every project's approved domains and
|
||||
# dependencies, and the audit baselines say which findings have already been
|
||||
# waved through. Refusing beats mounting and hoping the mode holds (#15).
|
||||
GUARDED_STORES=()
|
||||
_guarded_stores() {
|
||||
[ ${#GUARDED_STORES[@]} -eq 0 ] || return 0
|
||||
GUARDED_STORES=("$(config_root)" "$AUDIT_DIR" "$BASELINE_DIR")
|
||||
}
|
||||
|
||||
# Refuse $2 if it overlaps any of the paths in $3.., in either direction: an
|
||||
# ancestor of a store exposes it just as surely as the store itself.
|
||||
_refuse_guarded_mount() {
|
||||
local kind="$1" path="$2"; shift 2
|
||||
local real guarded guarded_real
|
||||
# readlink -m rather than -f, same reason as the read_write guard below:
|
||||
# -f gives nothing for a path that does not exist yet, and an empty
|
||||
# result compares equal to nothing and would sail through.
|
||||
real="$(readlink -m "$path")"
|
||||
for guarded in "$@"; do
|
||||
[ -n "$guarded" ] || continue
|
||||
guarded_real="$(readlink -m "$guarded")"
|
||||
_paths_overlap "$real" "$guarded_real" || continue
|
||||
echo "Refusing $kind: $path" >&2
|
||||
echo "It overlaps $guarded_real, which dejima needs out of a session's reach." >&2
|
||||
echo "Even read-only, that hands the session every project's approvals or the" >&2
|
||||
echo "record of what earlier sessions were caught doing." >&2
|
||||
exit 1
|
||||
done
|
||||
}
|
||||
SANDBOX_RW_MOUNTS="$(config_get mounts read_write)"
|
||||
SANDBOX_RW_MOUNTS="$(_append_mounts "$SANDBOX_RW_MOUNTS" \
|
||||
${EXTRA_RW_FLAGS[@]+"${EXTRA_RW_FLAGS[@]}"})"
|
||||
RW_MOUNT_PATHS=()
|
||||
DEJIMA_RW_MOUNTS=""
|
||||
if [ -n "$SANDBOX_RW_MOUNTS" ]; then
|
||||
@@ -239,7 +387,7 @@ if [ -n "$SANDBOX_RW_MOUNTS" ]; then
|
||||
for path in "${rw_paths[@]}"; do
|
||||
[ -n "$path" ] || continue
|
||||
if [ ! -d "$path" ]; then
|
||||
echo "[mounts] read_write entry is not an existing directory: $path" >&2
|
||||
echo "$(_mount_label "$path" "[mounts] read_write entry" "--extra-read-write path") is not an existing directory: $path" >&2
|
||||
exit 1
|
||||
fi
|
||||
# readlink -m, not -f: -f yields nothing unless at most the final
|
||||
@@ -256,7 +404,7 @@ if [ -n "$SANDBOX_RW_MOUNTS" ]; then
|
||||
"$HOME/.claude" "$HOME/.local/bin" "$REPO_ROOT" "$WORKSPACE_DIR"; do
|
||||
guarded_real="$(readlink -m "$guarded")"
|
||||
_paths_overlap "$rw_real" "$guarded_real" || continue
|
||||
echo "Refusing [mounts] read_write entry: $path" >&2
|
||||
echo "Refusing $(_mount_label "$path" "[mounts] read_write entry" "--extra-read-write path"): $path" >&2
|
||||
echo "It overlaps $guarded_real, which dejima needs out of a session's reach." >&2
|
||||
echo "A writable mount there would let a session approve its own domains and" >&2
|
||||
echo "dependencies, delete the audit findings it is named in, or plant code" >&2
|
||||
@@ -299,19 +447,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
|
||||
@@ -390,7 +569,7 @@ if [ -n "$SANDBOX_SSH_KEY" ]; then
|
||||
echo "Recommended: chmod 600 $SANDBOX_SSH_KEY" >&2
|
||||
fi
|
||||
# SANDBOX_SSH_KEY is meant to be a dedicated, low-privilege deploy key
|
||||
# (see README "Headless git push"), not your real identity - it stays
|
||||
# (see docs/configuration.md "Headless git push"), not your real identity - it stays
|
||||
# loaded in an in-container agent for the whole session, unattended.
|
||||
# Nothing enforces that convention, so at least catch the common case
|
||||
# of pointing it at a default personal keyfile by mistake.
|
||||
@@ -399,7 +578,7 @@ if [ -n "$SANDBOX_SSH_KEY" ]; then
|
||||
echo "Warning: $SANDBOX_SSH_KEY looks like a default personal SSH key," \
|
||||
"not a dedicated one." >&2
|
||||
echo "It will be loaded into the sandbox for the whole unattended session -" \
|
||||
"see README 'Headless git push'." >&2
|
||||
"see docs/configuration.md." >&2
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
@@ -415,19 +594,27 @@ fi
|
||||
if [ "$DEJIMA_WORKFLOW_PUSH" = "on" ] && [ "$have_forwarded_agent" = 0 ] && [ -z "$SANDBOX_SSH_KEY" ]; then
|
||||
echo "SSH_AUTH_SOCK is not set or not a valid socket, and [core] ssh_key is not set." >&2
|
||||
echo "Either start ssh-agent and run ssh-add, or set [core] ssh_key in $(config_path) to a" >&2
|
||||
echo "dedicated key for headless use - see README 'Headless git push' section." >&2
|
||||
echo "dedicated key for headless use - see docs/configuration.md." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# A valid forwarded socket with zero identities loaded forwards fine (the
|
||||
# container sees a live, working agent) but is useless for git-over-ssh -
|
||||
# warn now rather than have that surface confusingly deep into a session as
|
||||
# a failed push. Non-fatal: a task might not need git-over-ssh at all, and
|
||||
# SANDBOX_SSH_KEY (if set) provides its own identity independently of this.
|
||||
if [ "$have_forwarded_agent" = 1 ] && ! ssh-add -l >/dev/null 2>&1; then
|
||||
# a failed push. Non-fatal: a task might not need git-over-ssh at all.
|
||||
#
|
||||
# Silent when [core] ssh_key is set, because then the premise is false: the
|
||||
# key is loaded into the container's own agent and pushes work without the
|
||||
# forwarded one carrying anything. It is also the better arrangement of the
|
||||
# two - a dedicated identity that works unattended, rather than the
|
||||
# operator's own - so warning about the fallback being empty, while the
|
||||
# recommended path is configured and working, tells someone who took the
|
||||
# advice that they got it wrong.
|
||||
if [ "$have_forwarded_agent" = 1 ] && [ -z "$SANDBOX_SSH_KEY" ] \
|
||||
&& ! ssh-add -l >/dev/null 2>&1; then
|
||||
echo "Warning: ssh-agent has no identities loaded (ssh-add -l shows none)." >&2
|
||||
echo "git-over-ssh (e.g. pushing to the configured forge) will fail until you ssh-add a key," >&2
|
||||
echo "unless [core] ssh_key is also set." >&2
|
||||
echo "or set [core] ssh_key in $(config_path) to a dedicated key - see docs/configuration.md." >&2
|
||||
fi
|
||||
|
||||
# Deliberately not `gh auth token` / gh's own hosts.yml: a normal `gh
|
||||
@@ -484,46 +671,29 @@ if ! settings_error="$(jq . "$HOME/.claude/settings.json" 2>&1 >/dev/null)"; the
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Claude Code's own internal per-command sandboxing (~/.claude/settings.json's
|
||||
# "sandbox" block) is redundant here - it can't even function inside this
|
||||
# container (bwrap can't create a namespace, and getting it to would need
|
||||
# privilege escalation approaching --privileged, undoing this container's own
|
||||
# hardening), and this container is already a real, working sandbox on its
|
||||
# own. A session can't be trusted to turn it off itself, so the override is
|
||||
# generated here, on the host, and reaches Claude Code as an extra settings
|
||||
# layer: docker-compose.yml mounts this file read-only at a container-local
|
||||
# path and claude-wrapper.sh passes it as --settings, which loads above
|
||||
# ~/.claude/settings.json in the precedence order. Everything else in the
|
||||
# operator's real settings (hooks, permissions, plugins) still applies from
|
||||
# there, so this file holds only what the sandbox overrides.
|
||||
# Claude Code's own per-command sandboxing is redundant here and cannot work
|
||||
# anyway - bwrap can't create a namespace without privileges that would undo
|
||||
# this container's hardening - and a session can't be trusted to turn it off
|
||||
# itself. So the override is generated host-side and reaches Claude Code as
|
||||
# an extra --settings layer above ~/.claude/settings.json, which holds only
|
||||
# what the sandbox overrides; the operator's hooks, permissions and plugins
|
||||
# still apply from there.
|
||||
#
|
||||
# The earlier arrangement - a full modified copy of the operator's settings,
|
||||
# mounted over ~/.claude/settings.json - detached itself whenever the host
|
||||
# rewrote the real file, because that mount point sits inside the read-write
|
||||
# ~/.claude mount and a rename unlinks the dentry the mount is pinned to. The
|
||||
# session then silently fell back to the operator's settings, with sandboxing
|
||||
# back on and the notify hooks below gone. Mounting outside ~/.claude removes
|
||||
# that: nothing on the host can rename over a container-local path. See #47.
|
||||
# It is mounted at a container-local path, not over ~/.claude/settings.json.
|
||||
# That earlier arrangement detached whenever the host rewrote the real file
|
||||
# (a rename unlinks the dentry the mount is pinned to), silently restoring
|
||||
# the operator's settings with sandboxing back on. See #47.
|
||||
SANDBOX_SETTINGS_FILE="$(mktemp "${TMPDIR:-/tmp}/dejima-settings.XXXXXX.json")"
|
||||
jq -n '{sandbox: {enabled: false}}' > "$SANDBOX_SETTINGS_FILE"
|
||||
|
||||
# ~/.claude.json mixes live app state (project registry, usage stats,
|
||||
# caches) with config (mcpServers) that can't be split at the mount level -
|
||||
# unlike settings.json above, it can't just be made read-only either:
|
||||
# Claude Code does an atomic write-temp-then-rename against this exact file
|
||||
# during normal startup (before login even completes), and a read-only
|
||||
# file makes that hang instead of failing cleanly (confirmed against a
|
||||
# real read-only copy of this file - see
|
||||
# https://github.com/anthropics/claude-code/issues/29162). Generate a
|
||||
# throwaway copy instead, seeded from the real file, and mount *that*
|
||||
# read-write in its place - same "generate a sandbox-specific file on the
|
||||
# host" pattern as SANDBOX_SETTINGS_FILE above, just solving a different
|
||||
# problem: nothing a compromised session writes here (mcpServers or
|
||||
# anything else) ever reaches the real file, since the copy is discarded,
|
||||
# not merged back, when this session ends. This also means concurrent
|
||||
# sandbox sessions never contend over the same file - each gets its own
|
||||
# copy. See README "Throwaway ~/.claude.json" for exactly what that means
|
||||
# doesn't carry over from a sandboxed session.
|
||||
# ~/.claude.json mixes live app state with config (mcpServers) that cannot be
|
||||
# split at the mount level, and read-only is not an option: Claude Code does
|
||||
# an atomic write-and-rename against it during startup, and a read-only file
|
||||
# makes that HANG rather than fail cleanly (anthropics/claude-code#29162).
|
||||
# So it gets a throwaway copy, seeded from the real file and discarded rather
|
||||
# than merged back - nothing a session writes here reaches the host, and
|
||||
# concurrent sessions never contend. See docs/configuration.md, "Config
|
||||
# parity with your host Claude Code setup".
|
||||
SANDBOX_CLAUDE_JSON_FILE="$(mktemp "${TMPDIR:-/tmp}/dejima-claude-json.XXXXXX.json")"
|
||||
if [ -f "$HOME/.claude.json" ]; then
|
||||
cp "$HOME/.claude.json" "$SANDBOX_CLAUDE_JSON_FILE"
|
||||
@@ -607,7 +777,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 +822,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/##')}"
|
||||
@@ -751,6 +922,13 @@ for mounted_dir in hooks skills commands agents output-styles; do
|
||||
case " $mounted_dirs " in
|
||||
*" $real_dir "*) continue ;;
|
||||
esac
|
||||
# The target is discovered, not configured, so nothing has vetted it
|
||||
# (#15). A hook symlinked into the approval store would mount it
|
||||
# here, read-only and silently. Refusing rather than skipping: a
|
||||
# skipped mount leaves the symlink dangling inside the container and
|
||||
# the hook failing for a reason nothing states.
|
||||
_guarded_stores
|
||||
_refuse_guarded_mount "~/.claude symlink target" "$real_dir" "${GUARDED_STORES[@]}"
|
||||
mounted_dirs="$mounted_dirs $real_dir"
|
||||
extra_mounts+=(-v "$real_dir:$real_dir:ro")
|
||||
done
|
||||
@@ -795,9 +973,15 @@ if [ -n "$SANDBOX_EXTRA_MOUNTS" ]; then
|
||||
for path in "${extra_paths[@]}"; do
|
||||
[ -n "$path" ] || continue
|
||||
if [ ! -e "$path" ]; then
|
||||
echo "[mounts] readonly entry does not exist: $path" >&2
|
||||
echo "$(_mount_label "$path" "[mounts] readonly entry" "--extra-readonly path") does not exist: $path" >&2
|
||||
exit 1
|
||||
fi
|
||||
# [mounts] read_write has been guarded since it was added; this one
|
||||
# never was, so `readonly = ~/.config/dejima` mounted the approval
|
||||
# store into the container and nothing said a word (#15).
|
||||
_guarded_stores
|
||||
_refuse_guarded_mount "$(_mount_label "$path" "[mounts] readonly entry" "--extra-readonly path")" \
|
||||
"$path" "${GUARDED_STORES[@]}"
|
||||
extra_mounts+=(-v "$path:$path:ro")
|
||||
done
|
||||
fi
|
||||
@@ -810,7 +994,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
|
||||
@@ -836,36 +1023,50 @@ if [ -d "$WORKSPACE_DIR/.git/hooks" ]; then
|
||||
extra_mounts+=(-v "$WORKSPACE_DIR/.git/hooks:$WORKSPACE_DIR/.git/hooks:ro")
|
||||
fi
|
||||
|
||||
# ~/.claude/statusline.sh is the same class of file as the config staged at
|
||||
# /etc/dejima-claude by docker-compose.yml: settings.json's statusLine.command
|
||||
# points at it, and Claude Code executes it on the host. Staged the same way,
|
||||
# for entrypoint.sh to copy into place, rather than mounted at its real path.
|
||||
# ~/.claude/projects, one slug at a time: the tree is every project's
|
||||
# transcripts and memory, and memory is loaded into context at session start,
|
||||
# so a write into another slug is background a future session there believes
|
||||
# (#104). projects/ itself stays container-local, so an unmounted slug is
|
||||
# still writable and just does not survive - which is how Claude Code creates
|
||||
# a slug it does not find. Existing slugs only, apart from the workspace's
|
||||
# own, so a checkout full of worktrees does not litter the host with empty
|
||||
# directories for worktrees nothing has run in.
|
||||
mkdir -p "$HOME/.claude/projects/$(ws_slug "$WORKSPACE_DIR")"
|
||||
while IFS= read -r _slug; do
|
||||
[ -n "$_slug" ] || continue
|
||||
[ -d "$HOME/.claude/projects/$_slug" ] || continue
|
||||
extra_mounts+=(-v "$HOME/.claude/projects/$_slug:$HOME/.claude/projects/$_slug")
|
||||
done < <(workspace_slugs_sorted "$WORKSPACE_DIR")
|
||||
|
||||
# ~/.claude/file-history, same shape, sharper reason: `<session>/<hash>@v1`
|
||||
# is a verbatim copy of an edited file, so the tree is other projects' source
|
||||
# rather than a record of it, and it was writable - tampering there puts
|
||||
# chosen content into a later restore. Keyed by session id, so there is no
|
||||
# slug to match on; workspace_session_ids resolves it through the transcript
|
||||
# names. The starting session's id cannot be known yet, so its directory is
|
||||
# container-local: undo history for it is lost if it is resumed on the host.
|
||||
mkdir -p "$HOME/.claude/file-history"
|
||||
while IFS= read -r _sid; do
|
||||
[ -n "$_sid" ] || continue
|
||||
[ -d "$HOME/.claude/file-history/$_sid" ] || continue
|
||||
extra_mounts+=(-v "$HOME/.claude/file-history/$_sid:$HOME/.claude/file-history/$_sid")
|
||||
done < <(workspace_session_ids "$HOME/.claude/projects" "$WORKSPACE_DIR")
|
||||
|
||||
# statusline.sh is executed on the host by Claude Code (settings.json's
|
||||
# statusLine.command), so it is staged for entrypoint.sh to copy rather than
|
||||
# mounted at its real path - same as the config in docker-compose.yml.
|
||||
#
|
||||
# It used to be mounted read-only at $HOME/.claude/statusline.sh, and that
|
||||
# protected only the plain-regular-file case - confirmed against a live
|
||||
# container, not just reasoned about. When statusline.sh was itself a symlink
|
||||
# (the common case - typically into a dotfiles checkout), the read-write
|
||||
# ~/.claude mount was established first, so the destination already had that
|
||||
# symlink sitting on it, and bind-mount destination resolution (runc's
|
||||
# SecureJoin) followed it: the :ro mount landed on the target directory
|
||||
# instead of the symlink dentry, which stayed writable and replaceable. There
|
||||
# was no mount-based fix while ~/.claude was mounted whole, and the
|
||||
# compensating control was detection after the fact (bin/dejima-audit hashes
|
||||
# the symlink's target string). Staging closes it outright: Docker resolves
|
||||
# the symlink when it sets up the staging mount, so entrypoint.sh copies the
|
||||
# real content to a path nothing on the host can reach.
|
||||
# A :ro mount there protected only the plain-file case, confirmed against a
|
||||
# live container. When statusline.sh is itself a symlink (the common case),
|
||||
# bind-mount destination resolution follows it, so the mount lands on the
|
||||
# target and the link stays replaceable. Staging closes that: Docker resolves
|
||||
# the symlink setting up the staging mount, and the copy lands where the host
|
||||
# cannot reach it.
|
||||
#
|
||||
# Conditional on existence because not every host has one, and mounting a
|
||||
# nonexistent source would make Docker create a root-owned directory at the
|
||||
# target instead - worse than the gap this closes.
|
||||
#
|
||||
# A *dangling* symlink (target doesn't exist, e.g. a dotfiles checkout that
|
||||
# isn't present) is neither "exists" nor safely mountable, so it gets its own
|
||||
# branch that refuses to launch rather than either silently skipping the
|
||||
# mount (reopens the gap plain -e had) or attempting it anyway (Docker can't
|
||||
# bind-mount a dangling symlink - it fails deep in mount setup with an opaque
|
||||
# "mkdir ...: file exists", since the symlink already occupies that path,
|
||||
# with nothing connecting that error back to the statusline for the user).
|
||||
# Conditional on existence - mounting a missing source makes Docker create a
|
||||
# root-owned directory at the target. A dangling symlink is neither
|
||||
# mountable nor "exists", so it refuses rather than skipping the mount
|
||||
# (reopening the gap) or failing later with an opaque "mkdir: file exists".
|
||||
if [ -L "$HOME/.claude/statusline.sh" ] && [ ! -e "$HOME/.claude/statusline.sh" ]; then
|
||||
echo "$HOME/.claude/statusline.sh is a symlink to a target that doesn't" >&2
|
||||
echo "exist. Docker can't bind-mount a dangling symlink, and skipping the" >&2
|
||||
@@ -876,16 +1077,12 @@ elif [ -e "$HOME/.claude/statusline.sh" ]; then
|
||||
extra_mounts+=(-v "$HOME/.claude/statusline.sh:/etc/dejima-claude/statusline.sh:ro")
|
||||
fi
|
||||
|
||||
# Keep the real Anthropic OAuth credential out of the container entirely:
|
||||
# run a host-side proxy that injects it per-request (see
|
||||
# bin/dejima-anthropic-proxy and docs/security.md). Nothing needs masking
|
||||
# any more - ~/.claude isn't mounted as a whole, so the real credential (and
|
||||
# the proxy's .credentials-proxy-tmp staging directory beside it) simply
|
||||
# never crosses into the container. That masking is what #50 was: both masks
|
||||
# were single-file/-directory mounts nested in the read-write ~/.claude
|
||||
# mount, and the proxy's own write-back renamed the credential out from
|
||||
# under them, detaching the mask and exposing the real token from the first
|
||||
# refresh on.
|
||||
# The real Anthropic OAuth credential never enters the container: a host-side
|
||||
# proxy injects it per request (bin/dejima-anthropic-proxy, docs/security.md).
|
||||
# Nothing needs masking now that ~/.claude isn't mounted whole - and masking
|
||||
# is what #50 was, a single-file mount nested in the read-write ~/.claude
|
||||
# that the proxy's own write-back renamed out from under, exposing the real
|
||||
# token on the first refresh.
|
||||
#
|
||||
# How the container then reaches the proxy depends on the transport. In the
|
||||
# default tls one, entrypoint.sh points api.anthropic.com at an in-container
|
||||
@@ -1010,6 +1207,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.
|
||||
@@ -1096,6 +1312,17 @@ if remote_enabled; then
|
||||
mv "$SANDBOX_REMOTE_SETTINGS_TMP" "$SANDBOX_SETTINGS_FILE"
|
||||
fi
|
||||
|
||||
# Everything from here to the container's own first output is silent: the
|
||||
# audit snapshot, an image build that prints nothing when every layer is
|
||||
# cached, and Docker creating the container with a mount per allowed path.
|
||||
# Long enough to read as hung, so say what is happening first. A first run,
|
||||
# or one after a Dockerfile change, builds the image and takes minutes rather
|
||||
# than seconds - the line names that, since it is the case where someone is
|
||||
# most likely to give up and interrupt.
|
||||
printf '%sdejima%s is starting, please wait...\n' "$C_KEY" "$C_RESET" >&2
|
||||
printf '%sA first run builds the container image, which takes several minutes.%s\n' \
|
||||
"$C_DIM" "$C_RESET" >&2
|
||||
|
||||
# Snapshot host-persistence surfaces so the post-run audit can report
|
||||
# anything that changed while this session ran (advisory - see bin/dejima-audit).
|
||||
"$SCRIPT_DIR/dejima-audit" snapshot "$WORKSPACE_DIR" "$SANDBOX_AUDIT_STATE" || true
|
||||
@@ -1149,6 +1376,22 @@ if [ -n "$PROJECT_STORE" ] && [ -s "$PROJECT_STORE/dependencies" ]; then
|
||||
fi
|
||||
export SANDBOX_IMAGE
|
||||
|
||||
# nvim's data, state and cache are Docker volumes rather than host paths, so
|
||||
# nothing a session installs reaches the host's own nvim. They did outlive
|
||||
# the container under one fixed name each, though, shared by every project
|
||||
# on the machine: a session that drops a file under `lazy/`, or replaces a
|
||||
# binary under `mason/bin/`, is writing something a later session in an
|
||||
# unrelated project executes the next time nvim runs in the sandbox. That is
|
||||
# the persistence this repository closes everywhere else, so it is scoped
|
||||
# the same way the derived image tag is - per project.
|
||||
#
|
||||
# The prefix is not decoration: project_slug maps a leading `/` to `-`, and
|
||||
# a Docker volume name may not start with one.
|
||||
_nvim_volume_slug="$(project_slug "$WORKSPACE_DIR")"
|
||||
export SANDBOX_NVIM_DATA="dejima_nvim-data${_nvim_volume_slug}"
|
||||
export SANDBOX_NVIM_STATE="dejima_nvim-state${_nvim_volume_slug}"
|
||||
export SANDBOX_NVIM_CACHE="dejima_nvim-cache${_nvim_volume_slug}"
|
||||
|
||||
# Not exec'd (the EXIT trap cleans up the generated temp files) and not a
|
||||
# bare command under set -e (a nonzero/interrupted container run - e.g.
|
||||
# Ctrl-C returning 130 - would otherwise abort the script here, skipping the
|
||||
@@ -1160,7 +1403,7 @@ export SANDBOX_IMAGE
|
||||
# because exit-time output can be ignored exactly once, permanently. This is
|
||||
# host-side and stays host-side: it must never be routed into the session's
|
||||
# context, which would make it a cross-session persistence channel.
|
||||
audit_slug="$(printf '%s' "$WORKSPACE_DIR" | sed 's/[^a-zA-Z0-9]/-/g')"
|
||||
audit_slug="$(ws_slug "$WORKSPACE_DIR")"
|
||||
audit_reports="$AUDIT_DIR/$audit_slug"
|
||||
# `|| true`: a workspace with no history yet (the common case - nothing has
|
||||
# ever run against it, or every past finding is already acknowledged) has no
|
||||
@@ -1169,16 +1412,43 @@ audit_reports="$AUDIT_DIR/$audit_slug"
|
||||
# every ordinary launch.
|
||||
pending_crit="$(find "$audit_reports" -maxdepth 1 -name '*-CRITICAL.txt' 2>/dev/null | sort)" || true
|
||||
if [ -n "$pending_crit" ]; then
|
||||
n="$(printf '%s\n' "$pending_crit" | wc -l)"
|
||||
echo "" >&2
|
||||
echo " dejima: $n unacknowledged CRITICAL finding(s) in this workspace" >&2
|
||||
printf '%s\n' "$pending_crit" | sed 's#.*/##; s#-CRITICAL\.txt$##; s#^# from run #' >&2
|
||||
echo " Review with: dejima-audit ack \"$WORKSPACE_DIR\"" >&2
|
||||
if [ -t 0 ]; then
|
||||
printf ' [c] continue [q] quit: ' >&2
|
||||
read -r reply || reply=q
|
||||
case "$reply" in q|Q) echo "aborted." >&2; exit 1 ;; esac
|
||||
elif [ "$(config_get_bool audit strict)" = "1" ]; then
|
||||
# Re-read after an inline `ack`, so approving everything here clears the
|
||||
# warning instead of asking again about reports that no longer carry a
|
||||
# CRITICAL. Looping rather than asking once: `ack` walks ONE report per
|
||||
# run (see select_report), so a workspace with several needs several
|
||||
# passes, and sending the operator back to their shell between each is
|
||||
# the friction this option exists to remove (#65).
|
||||
while [ -n "$pending_crit" ]; do
|
||||
n="$(printf '%s\n' "$pending_crit" | wc -l)"
|
||||
echo "" >&2
|
||||
printf ' dejima: %s%s unacknowledged %sCRITICAL%s finding(s) in this workspace\n' \
|
||||
"$C_CRIT" "$n" "$C_CRIT" "$C_RESET" >&2
|
||||
printf '%s\n' "$pending_crit" \
|
||||
| sed "s#.*/##; s#-CRITICAL\.txt\$##; s#^# from run ${C_INFO}#; s#\$#${C_RESET}#" >&2
|
||||
printf ' Review with: %sdejima-audit ack "%s"%s\n' \
|
||||
"$C_KEY" "$WORKSPACE_DIR" "$C_RESET" >&2
|
||||
if [ -t 0 ]; then
|
||||
printf ' %s[a]%s review now %s[c]%s continue %s[q]%s quit: ' \
|
||||
"$C_KEY" "$C_RESET" "$C_KEY" "$C_RESET" "$C_KEY" "$C_RESET" >&2
|
||||
read -r reply || reply=q
|
||||
case "$reply" in
|
||||
q|Q) echo "aborted." >&2; exit 1 ;;
|
||||
a|A)
|
||||
# Run it here rather than telling the operator to run it
|
||||
# in another terminal: this is the moment they are
|
||||
# looking at the finding, and the container has not
|
||||
# started, so nothing is waiting on the answer.
|
||||
"$SCRIPT_DIR/dejima-audit" ack "$WORKSPACE_DIR" || true
|
||||
pending_crit="$(find "$audit_reports" -maxdepth 1 -name '*-CRITICAL.txt' 2>/dev/null | sort)" || true
|
||||
continue
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
break
|
||||
done
|
||||
fi
|
||||
if [ -n "$pending_crit" ] && [ ! -t 0 ]; then
|
||||
if [ "$(config_get_bool audit strict)" = "1" ]; then
|
||||
echo " audit.strict is on and stdin is not a tty - refusing to start." >&2
|
||||
exit 1
|
||||
else
|
||||
|
||||
+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)."
|
||||
|
||||
+30
-47
@@ -24,23 +24,17 @@ fi
|
||||
STATE_DIR="$(remote_state_dir)"
|
||||
SESSIONS_DIR="$STATE_DIR/sessions"
|
||||
|
||||
# gruvbox dark. Every value below is upstream's, tagged with the name of the
|
||||
# color.table row it came from:
|
||||
# gruvbox dark, every value upstream's and tagged with its color.table row:
|
||||
# https://github.com/gruvbox-community/gruvbox-contrib/blob/master/color.table
|
||||
# Carrying the name and not just the hex is the point - a palette this widely
|
||||
# known is one almost nobody has memorised to six digits, so a wrong value is
|
||||
# only catchable by reading if there is something to check it against.
|
||||
# The names are carried so a wrong hex is catchable by reading.
|
||||
#
|
||||
# Truecolor only, and deliberately not paired with a 256-colour table: a
|
||||
# terminal without it - or tmux without `Tc` - approximates 38;2 to its own
|
||||
# nearest colour rather than printing the escape, so the fallback already
|
||||
# exists one layer down and a second palette here would only duplicate it.
|
||||
# Truecolor only: a terminal without it, or tmux without `Tc`, approximates
|
||||
# 38;2 to its nearest colour rather than printing the escape, so the fallback
|
||||
# already exists one layer down.
|
||||
#
|
||||
# Everything below is empty unless stdout is a terminal that says it can show
|
||||
# colour, which is what keeps --list clean: it is a machine interface (ttyd's
|
||||
# --url-arg feeds run ids back in, and the README documents the rows as
|
||||
# scriptable), and it is also how the bats suite reads this script, so its
|
||||
# assertions see the same bare words either way.
|
||||
# Empty unless stdout is a colour-capable terminal, which keeps --list a
|
||||
# machine interface (ttyd feeds run ids back in) and is also how the bats
|
||||
# suite reads this script.
|
||||
if [ -t 1 ] && [ -z "${NO_COLOR:-}" ] && [ "${TERM:-dumb}" != dumb ]; then
|
||||
C_OFF=$'\033[0m'; C_BOLD=$'\033[1m'
|
||||
C_BADGE=$'\033[38;2;254;128;25m' # bright_orange #fe8019, the wordmark
|
||||
@@ -95,22 +89,16 @@ state_color() {
|
||||
# How long after its own started= a registered session may still have no
|
||||
# container, before the prune in list_sessions treats that as "gone".
|
||||
#
|
||||
# Sized against the step that actually holds a launch up: bin/dejima writes
|
||||
# the registry entry, then rewrites the settings copy, snapshots the
|
||||
# workspace for the audit, runs an UNCONDITIONAL `docker compose build
|
||||
# claude`, optionally builds up to two derived images on top of it, and only
|
||||
# then reaches `docker compose run`. A cold base build is the Dockerfile's
|
||||
# apt and npm layers fetched over the network - minutes, not seconds - and
|
||||
# the derived builds add to it, while a warm run reaches the container in
|
||||
# seconds and never comes near this window.
|
||||
# Sized against what holds a launch up: the registry entry is written before
|
||||
# an unconditional `docker compose build`, plus up to two derived image
|
||||
# builds, and a cold base build fetches apt and npm layers - minutes. A warm
|
||||
# run never comes near this window.
|
||||
#
|
||||
# The two errors are not symmetric, which is why this is generous. Too long
|
||||
# only leaves a session that really did die listed until the window passes,
|
||||
# and the next listing prunes it. Too short deletes the registry entry of a
|
||||
# session that is about to start: bin/dejima never rewrites that entry, so
|
||||
# the session is permanently absent from both front doors, and the marker
|
||||
# directory deleted with it is the source of a bind mount the imminent
|
||||
# `docker compose run` declares.
|
||||
# Generous because the errors are not symmetric. Too long leaves a dead
|
||||
# session listed until the window passes. Too short deletes the entry of a
|
||||
# session about to start: bin/dejima never rewrites it, so the session is
|
||||
# permanently absent from both front doors, and the marker directory removed
|
||||
# with it is a bind source the imminent `compose run` declares.
|
||||
STARTUP_GRACE=900
|
||||
|
||||
# Parsed, never sourced: this file is host-written, but treating state files
|
||||
@@ -281,26 +269,21 @@ attach() {
|
||||
exit 1
|
||||
fi
|
||||
group="dejima-$run_id"
|
||||
# Both values come from the same live pane, in one round trip. Taking the
|
||||
# session name from the recorded label instead would follow a name rather
|
||||
# than the sandbox: an operator renaming sessions is enough to point it at
|
||||
# an unrelated session that has since taken the old name.
|
||||
# Both values from the same live pane, one round trip. Taking the session
|
||||
# name from the recorded label would follow a name rather than the
|
||||
# sandbox, and a rename is enough to point it at an unrelated session.
|
||||
#
|
||||
# The name is a starting point, never a guarantee. It is frequently
|
||||
# ANOTHER remote client's throwaway group (tmux reports whichever session
|
||||
# holding the window is most recent, and every grouped session here is
|
||||
# newer than the operator's own), and those carry destroy-unattached - so
|
||||
# the base can be made to stop existing on demand by anyone holding a
|
||||
# second connection. That is why the session created from it is verified
|
||||
# below instead of trusted: `new-session -t` does not fail on a name that
|
||||
# matches nothing, it silently creates a fresh UNGROUPED session, which
|
||||
# for a remote client is a plain host shell with no sandbox in it.
|
||||
# The name is a starting point, not a guarantee: it is often another
|
||||
# remote client's throwaway group, and those carry destroy-unattached, so
|
||||
# anyone with a second connection can make the base stop existing. Hence
|
||||
# the verification below - `new-session -t` does not fail on a name
|
||||
# matching nothing, it silently creates an UNGROUPED session, which for a
|
||||
# remote client is a plain host shell with no sandbox in it.
|
||||
#
|
||||
# display-message prints nothing and still exits 0 for a target that no
|
||||
# longer exists, so an empty result means the pane went away between the
|
||||
# check above and here. That case is caught by the verification below too;
|
||||
# bailing early just avoids creating a junk session to immediately kill,
|
||||
# and says what actually happened.
|
||||
# display-message prints nothing and exits 0 for a vanished target, so an
|
||||
# empty result means the pane went away since the check above. The
|
||||
# verification catches that too; bailing early just avoids creating a
|
||||
# junk session and says what happened.
|
||||
target="$(tmux display-message -p -t "$entry_pane" '#{session_name}:#{window_index}' 2>/dev/null || true)"
|
||||
# Session names may contain ':', window indexes can't.
|
||||
base="${target%:*}"
|
||||
|
||||
+301
-122
@@ -10,6 +10,13 @@ set -uo pipefail
|
||||
|
||||
# shellcheck source=dejima-provider-lib.sh
|
||||
source "$(dirname "$(readlink -f "$0")")/dejima-provider-lib.sh"
|
||||
# shellcheck source=dejima-color-lib.sh
|
||||
source "$(dirname "$(readlink -f "$0")")/dejima-color-lib.sh"
|
||||
# shellcheck source=dejima-slug-lib.sh
|
||||
source "$(dirname "$(readlink -f "$0")")/dejima-slug-lib.sh"
|
||||
# Defined empty here so every interpolation below is safe before a surface
|
||||
# picks its stream; compare (stderr) and ack (stdout) each re-init for theirs.
|
||||
color_init 1
|
||||
config_validate || exit 1
|
||||
|
||||
cmd="${1:-}"; ws_arg="${2:-}"; state="${3:-}"
|
||||
@@ -48,7 +55,9 @@ AUDIT_DIR="$(config_get audit dir)"
|
||||
# suggesting a restart is an entirely ordinary thing to suggest.
|
||||
AUDIT_KEEP="$(config_get audit keep)"
|
||||
|
||||
ws_slug() { printf '%s' "$1" | sed 's/[^a-zA-Z0-9]/-/g'; }
|
||||
# ws_slug and workspace_slugs come from dejima-slug-lib.sh, shared with
|
||||
# bin/dejima so the set of slugs it mounts and the set treated as this
|
||||
# workspace's own here cannot drift apart.
|
||||
|
||||
# The one report both `show` and `ack` operate on: the OLDEST unacknowledged
|
||||
# CRITICAL report if there is one, else the newest report overall.
|
||||
@@ -78,19 +87,16 @@ BASELINE_DIR="$(config_get audit baselines_dir)"
|
||||
# should mean that repo. bin/dejima always passes an explicit path, so this
|
||||
# only ever fires for a hand-run show/ack.
|
||||
#
|
||||
# Candidates in order, first one that already HAS reports wins - existence of
|
||||
# a report directory is the only evidence available about which path
|
||||
# bin/dejima was actually invoked with, and the three candidates can
|
||||
# genuinely differ:
|
||||
# 1. the enclosing repo's toplevel (the ordinary case, and from a subdir);
|
||||
# 2. the MAIN worktree root, when cwd is a linked worktree - the sandbox's
|
||||
# own .worktrees/<branch> workflow keys reports to the parent repo;
|
||||
# 3. $PWD, which covers a non-git directory and the case where the path
|
||||
# bin/dejima was given (logical, via `cd && pwd`) differs from git's
|
||||
# physical toplevel because of a symlinked path component.
|
||||
# Falling back to candidate 1 when none of them has reports is purely so the
|
||||
# "no reports for X" message can name a concrete path: a wrong guess must be
|
||||
# visible rather than silent.
|
||||
# Candidates in order, first one that already HAS reports wins - a report
|
||||
# directory is the only evidence of which path bin/dejima was invoked with,
|
||||
# and the three can genuinely differ:
|
||||
# 1. the enclosing repo's toplevel (ordinary case, and from a subdir);
|
||||
# 2. the MAIN worktree root, when cwd is a linked worktree - the
|
||||
# .worktrees/<branch> workflow keys reports to the parent repo;
|
||||
# 3. $PWD, for a non-git directory, or when the logical path bin/dejima was
|
||||
# given differs from git's physical toplevel through a symlink.
|
||||
# Falling back to 1 when none has reports is so "no reports for X" can name a
|
||||
# concrete path: a wrong guess must be visible rather than silent.
|
||||
resolve_ws() {
|
||||
local given="$1" top common main c
|
||||
if [ -n "$given" ]; then
|
||||
@@ -562,6 +568,28 @@ baseline_suppresses() {
|
||||
return 1
|
||||
}
|
||||
|
||||
# Whether `ack` has already walked this report to its end. Recorded in the
|
||||
# baseline file, alongside the approvals, as `reviewed <report-basename>`:
|
||||
# the file is already the per-workspace record of what the operator has
|
||||
# decided, already parsed line-by-line, and already tolerant of verbs it does
|
||||
# not know (baseline_suppresses skips them), so a report written by an older
|
||||
# version reads exactly as it did.
|
||||
#
|
||||
# Only findings that CANNOT be acknowledged are governed by this. An ackable
|
||||
# one is governed by its own entry, so `skip` still means "ask me again" for
|
||||
# it, which is the whole difference between skip and ignore.
|
||||
baseline_reviewed() {
|
||||
local want="$1" bf verb rest
|
||||
bf="$BASELINE_DIR/$(ws_slug "$ws")"
|
||||
[ -f "$bf" ] || return 1
|
||||
while read -r verb rest; do
|
||||
case "$verb" in
|
||||
reviewed) [ "$rest" = "$want" ] && return 0 ;;
|
||||
esac
|
||||
done < "$bf"
|
||||
return 1
|
||||
}
|
||||
|
||||
# git config keys whose values run a command on the next git invocation, or
|
||||
# (include.path/includeif.*.path) indirect to another config file that could
|
||||
# set one - key names are case-insensitive in git, so match on a lowercased
|
||||
@@ -627,37 +655,26 @@ sanitize() {
|
||||
| LC_ALL=C sed $'s/\xc2[\x80-\x9f]//g; s/\xe2\x80[\xaa-\xae]//g; s/\xe2\x81[\xa6-\xa9]//g'
|
||||
}
|
||||
|
||||
# sanitize() is lossy, so it must never be what an ID passes through.
|
||||
# sanitize() is lossy, so an ID must never pass through it. `ack` derives the
|
||||
# baseline id from the report text and the next `compare` matches it against
|
||||
# the RAW bytes on disk, so a stripped id cannot round-trip: the surface
|
||||
# becomes permanently unsuppressible and the startup warning fires forever.
|
||||
#
|
||||
# `ack` derives the baseline id it records from the report's text, and the
|
||||
# next `compare` matches that id against the RAW bytes on disk. An id
|
||||
# containing anything sanitize() strips therefore could not round-trip at
|
||||
# all: the surface stayed permanently unsuppressible and its report
|
||||
# permanently unclearable, so bin/dejima's startup warning fired forever -
|
||||
# the alert fatigue this whole feature exists to end, reached from the other
|
||||
# side. Ids are encoded instead, and the encoded form is canonical
|
||||
# everywhere it is written down: in the report AND in the baseline file.
|
||||
# Ids are encoded instead, canonically, in the report AND the baseline file.
|
||||
# An id sanitize() leaves alone is written verbatim (every ordinary one, so
|
||||
# existing baselines are unchanged); anything else is hex-encoded behind a
|
||||
# marker, which is ASCII and reverses exactly.
|
||||
#
|
||||
# An id that survives sanitize() unchanged is already display-safe and is
|
||||
# written verbatim - that is every ordinary id, so reports and existing
|
||||
# baseline files are byte-for-byte what they were. Anything else is
|
||||
# hex-encoded behind a marker, which is ASCII by construction and reverses
|
||||
# exactly.
|
||||
# An id that merely LOOKS encoded is encoded too, so the marker's presence
|
||||
# always means this encoder put it there. Otherwise a workspace could present
|
||||
# an id `ack` decodes into one of the attacker's choosing - baselining
|
||||
# something other than the finding while counting it acknowledged. Only the
|
||||
# START matters, and no monitored path begins with the marker, but a git
|
||||
# config section name is free text the session can write.
|
||||
#
|
||||
# An id that merely LOOKS encoded is encoded too, so that decoding stays
|
||||
# unambiguous: the marker's presence always means this encoder put it there.
|
||||
# Without that, a workspace could present an id that `ack` decoded into one
|
||||
# of the attacker's choosing - baselining something other than the finding
|
||||
# while still counting it acknowledged, the same shape as the id-derivation
|
||||
# hijacks the anchored case arms in `ack` guard against. Only the START of
|
||||
# an id is significant, and no monitored path can begin with the marker
|
||||
# (they are all rooted at .claude/.vscode/.git/.envrc/.mcp.json), but a git
|
||||
# config section name is free text in a file the session can write and the
|
||||
# audit parses that file directly rather than through git.
|
||||
#
|
||||
# The encoded form is also the honest rendering. sanitize() DELETES those
|
||||
# bytes, so a planted ".git/hooks/pre<U+202E>commit" displayed as
|
||||
# "precommit" sends an operator grepping for a name no file on disk has.
|
||||
# Encoding is also the honest rendering: sanitize() DELETES those bytes, so a
|
||||
# planted ".git/hooks/pre<U+202E>commit" shown as "precommit" sends an
|
||||
# operator grepping for a name no file has.
|
||||
HEX_MARK='{hex}'
|
||||
enc_id() {
|
||||
local v="$1"
|
||||
@@ -915,16 +932,27 @@ gitconfig_count_for_key() {
|
||||
# it cannot be mistaken for the audit's own output, and capped. Sanitizing
|
||||
# line by line rather than whole-file keeps a planted NUL or newline run from
|
||||
# collapsing separate lines into one.
|
||||
# $2 = "diff" to colour +/- lines. Colour is applied to the audit's own
|
||||
# rendering of a line, never mixed into the line's text: the content here is
|
||||
# workspace-controlled, and sanitize() has already removed the bytes that
|
||||
# could forge one of these sequences.
|
||||
show_capped() {
|
||||
local f="$1" n=0 total line
|
||||
local f="$1" mode="${2:-plain}" n=0 total line clean
|
||||
total="$(wc -l < "$f" 2>/dev/null)" || total=0
|
||||
while IFS= read -r line; do
|
||||
n=$((n + 1))
|
||||
[ "$n" -le "$DISPLAY_LINE_CAP" ] || break
|
||||
printf ' %s\n' "$(sanitize "$line")"
|
||||
clean="$(sanitize "$line")"
|
||||
if [ "$mode" = diff ]; then
|
||||
case "$clean" in
|
||||
+*) printf ' %s%s%s\n' "$C_ADD" "$clean" "$C_RESET"; continue ;;
|
||||
-*) printf ' %s%s%s\n' "$C_DEL" "$clean" "$C_RESET"; continue ;;
|
||||
esac
|
||||
fi
|
||||
printf ' %s\n' "$clean"
|
||||
done < "$f"
|
||||
if [ "$total" -gt "$DISPLAY_LINE_CAP" ]; then
|
||||
printf ' ... +%s more line(s)\n' "$(( total - DISPLAY_LINE_CAP ))"
|
||||
printf ' %s... +%s more line(s)%s\n' "$C_DIM" "$(( total - DISPLAY_LINE_CAP ))" "$C_RESET"
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -949,22 +977,27 @@ render_finding() {
|
||||
local id="$1" sc="$2" key tmp
|
||||
key="$(id_key "$id")"
|
||||
|
||||
# Dim on the audit's own framing, plain on the content between it: the
|
||||
# complaint this answers is that the two ran together in one colour with
|
||||
# nothing to say where the tool stopped speaking and the workspace
|
||||
# started (#65).
|
||||
if [ -n "$sc" ] && [ -f "$sc/$key.diff" ]; then
|
||||
echo " --- what changed while the session ran ---"
|
||||
show_capped "$sc/$key.diff"
|
||||
printf ' %s--- what changed while the session ran ---%s\n' "$C_DIM" "$C_RESET"
|
||||
show_capped "$sc/$key.diff" diff
|
||||
source_hint "$id"
|
||||
return 0
|
||||
fi
|
||||
if [ -n "$sc" ] && [ -f "$sc/$key.removed" ]; then
|
||||
echo " --- this surface was removed; its pre-session content was not retained ---"
|
||||
printf ' %s--- this surface was removed; its pre-session content was not retained ---%s\n' "$C_DIM" "$C_RESET"
|
||||
return 0
|
||||
fi
|
||||
if [ -n "$sc" ] && [ -f "$sc/$key.withheld" ]; then
|
||||
echo " --- no diff retained: $(sanitize "$(head -n1 "$sc/$key.withheld" 2>/dev/null)") ---"
|
||||
printf ' %s--- no diff retained: %s ---%s\n' "$C_DIM" \
|
||||
"$(sanitize "$(head -n1 "$sc/$key.withheld" 2>/dev/null)")" "$C_RESET"
|
||||
elif [ -n "$sc" ] && [ -f "$sc/$key.new" ]; then
|
||||
echo " --- new surface; full current content ---"
|
||||
printf ' %s--- new surface; full current content ---%s\n' "$C_DIM" "$C_RESET"
|
||||
else
|
||||
echo " --- no pre-session record; current content ---"
|
||||
printf ' %s--- no pre-session record; current content ---%s\n' "$C_DIM" "$C_RESET"
|
||||
fi
|
||||
|
||||
tmp="$(mktemp)" || return 0
|
||||
@@ -1091,14 +1124,29 @@ case "$cmd" in
|
||||
baseline_suppresses "$gid" "$gsha" && continue
|
||||
crit+=("COMMAND-EXECUTION git alias: $body")
|
||||
finding_ids+=("gitconfig:$key") ;;
|
||||
*) warn+=("changed git config: $body") ;;
|
||||
*) gid="gitconfig:$key"; gsha="$(hash_value "$(unquote_value "${line#*=}")")"
|
||||
baseline_suppresses "$gid" "$gsha" && continue
|
||||
warn+=("changed git config: $body")
|
||||
finding_ids+=("gitconfig:$key") ;;
|
||||
esac
|
||||
else
|
||||
# No id recorded, for the same reason CROSS-PROJECT gets none:
|
||||
# `ack` has no arm for a plain "changed git config" line, so a
|
||||
# diff retained for one would be content at rest that nothing
|
||||
# ever reads. The report body already carries the new value.
|
||||
# Suppressible and id-bearing like the CRITICAL arms above.
|
||||
# Both were previously neither, which left this the one tier
|
||||
# of finding with no way to curate it: keys some tools
|
||||
# rewrite on every run (branch.<name>.merge on each push,
|
||||
# submodule.*) re-fired every session and pushed real
|
||||
# WARNINGs out of the capped list (#45).
|
||||
#
|
||||
# Safe by construction rather than by care taken here:
|
||||
# id_severity already calls an exec key or any alias CRITICAL
|
||||
# whatever wrote the entry, and baseline_suppresses refuses a
|
||||
# `spam` entry against a CRITICAL surface at READ time - so a
|
||||
# key that later joins is_exec_key stops being suppressed by
|
||||
# an existing entry, with nothing to revisit.
|
||||
gid="gitconfig:$key"; gsha="$(hash_value "$(unquote_value "${line#*=}")")"
|
||||
baseline_suppresses "$gid" "$gsha" && continue
|
||||
warn+=("changed git config: $body")
|
||||
finding_ids+=("gitconfig:$key")
|
||||
fi
|
||||
done < <(comm -13 <(sed -n '/^### gitconfig/,/^### paths/p' "$state" | grep -vE '^### |^worktree:$') \
|
||||
<(sed -n '/^### gitconfig/,/^### paths/p' "$new" | grep -vE '^### |^worktree:$') )
|
||||
@@ -1134,13 +1182,17 @@ case "$cmd" in
|
||||
|
||||
# projects diffs: a write to another project's ~/.claude/projects
|
||||
# slug during this session (transcript/memory-poisoning surface).
|
||||
# "self" = current workspace's own slug or a worktree child of it.
|
||||
current_slug="$(ws_slug "$ws")"
|
||||
# "self" = the workspace's own slug or a live worktree nested under
|
||||
# it, exactly the set bin/dejima mounts. This used to be a
|
||||
# "$current_slug"-* glob, which also matched any sibling project whose
|
||||
# directory name extended this one's - so on a host with `proj` and
|
||||
# `proj-ci` side by side, writes to `proj-ci` were silently exempt.
|
||||
self_slugs="$(workspace_slugs_sorted "$ws")"
|
||||
while IFS= read -r line; do
|
||||
slug="${line%%$'\t'*}"
|
||||
case "$slug" in
|
||||
"$current_slug"|"$current_slug"-*) continue ;;
|
||||
esac
|
||||
if printf '%s\n' "$self_slugs" | grep -qxF -- "$slug"; then
|
||||
continue
|
||||
fi
|
||||
warn+=("CROSS-PROJECT history/memory write: $(sanitize "$slug")")
|
||||
done < <(comm -13 <(sed -n '/^### projects/,/^### tcb/p' "$state" | grep -v '^### ') \
|
||||
<(sed -n '/^### projects/,/^### tcb/p' "$new" | grep -v '^### ') )
|
||||
@@ -1193,6 +1245,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[@]}"
|
||||
@@ -1250,23 +1311,35 @@ case "$cmd" in
|
||||
# Ascending severity: the operator reads bottom-up from their shell
|
||||
# prompt, so whatever prints LAST is what actually gets seen. INFO
|
||||
# collapses to a count - it exists to be counted, not read.
|
||||
# stderr is this block's stream, so the palette is decided for that
|
||||
# one: redirecting the summary to a file must not put escapes in it.
|
||||
color_init 2
|
||||
{
|
||||
echo ""
|
||||
echo "================================================================"
|
||||
printf '%s================================================================%s\n' "$C_DIM" "$C_RESET"
|
||||
echo " dejima: host-persistence surfaces changed in"
|
||||
echo " $(sanitize "$ws") while this session ran"
|
||||
echo "----------------------------------------------------------------"
|
||||
printf ' %s%s%s while this session ran\n' "$C_PATH" "$(sanitize "$ws")" "$C_RESET"
|
||||
printf '%s----------------------------------------------------------------%s\n' "$C_DIM" "$C_RESET"
|
||||
# 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
|
||||
if [ "${#warn[@]}" -gt 0 ]; then
|
||||
printf ' - %s\n' "${warn[@]:0:$WARN_CAP}"
|
||||
printf " $C_WARN-$C_RESET %s\n" "${warn[@]:0:$WARN_CAP}"
|
||||
if [ "${#warn[@]}" -gt "$WARN_CAP" ]; then
|
||||
echo " + $(( ${#warn[@]} - WARN_CAP )) further change(s) not shown."
|
||||
fi
|
||||
fi
|
||||
if [ "${#crit[@]}" -gt 0 ]; then
|
||||
printf ' ! %s\n' "${crit[@]:0:$CRIT_CAP}"
|
||||
printf " $C_CRIT!$C_RESET %s\n" "${crit[@]:0:$CRIT_CAP}"
|
||||
if [ "${#crit[@]}" -gt "$CRIT_CAP" ]; then
|
||||
echo " !! $(( ${#crit[@]} - CRIT_CAP )) FURTHER CRITICAL FINDINGS NOT SHOWN."
|
||||
echo " !! ${#crit[@]} critical findings in one session is ABNORMAL."
|
||||
@@ -1276,12 +1349,14 @@ case "$cmd" in
|
||||
# room to keep evidence is a finding about the session, not a
|
||||
# note about the store.
|
||||
[ "${#budget_note[@]}" -gt 0 ] && printf '%s\n' "${budget_note[@]}"
|
||||
echo "----------------------------------------------------------------"
|
||||
printf '%s----------------------------------------------------------------%s\n' "$C_DIM" "$C_RESET"
|
||||
if [ "${#crit[@]}" -gt 0 ]; then
|
||||
echo " Review BEFORE the next 'git' or 'claude' in that repo."
|
||||
echo " dejima: ${#crit[@]} CRITICAL - code that will run on your host."
|
||||
printf ' dejima: %s%s CRITICAL%s - code that will run on your host.\n' \
|
||||
"$C_CRIT" "${#crit[@]}" "$C_RESET"
|
||||
else
|
||||
echo " dejima: no critical findings; ${#warn[@]} change(s) to review."
|
||||
printf ' dejima: %sno critical findings%s; %s change(s) to review.\n' \
|
||||
"$C_OK" "$C_RESET" "${#warn[@]}"
|
||||
fi
|
||||
} >&2
|
||||
exit 0
|
||||
@@ -1299,6 +1374,7 @@ case "$cmd" in
|
||||
exit 0
|
||||
;;
|
||||
ack)
|
||||
color_init 1
|
||||
report_dir="$AUDIT_DIR/$(ws_slug "$ws")"
|
||||
bf="$BASELINE_DIR/$(ws_slug "$ws")"
|
||||
report="$(select_report "$report_dir")" \
|
||||
@@ -1321,6 +1397,16 @@ case "$cmd" in
|
||||
# (or a non-path CRITICAL, which can never be "approved" - see
|
||||
# ackable below) must keep its suffix and stay eviction-protected.
|
||||
crit_seen=0; crit_acked=0
|
||||
# Findings passed over because the baseline already covers them, or
|
||||
# because this report has been walked before - counted so a run that
|
||||
# displays nothing can say why instead of looking broken.
|
||||
already=0
|
||||
# Whether this report carries any finding that cannot be
|
||||
# acknowledged, and whether a previous run already walked it to the
|
||||
# end. Together they decide if a `reviewed` line gets written below.
|
||||
unackable_shown=0
|
||||
report_reviewed=0
|
||||
if baseline_reviewed "$(basename "$report")"; then report_reviewed=1; fi
|
||||
# The loop below reads report lines on its own stdin (`< "$report"`),
|
||||
# so operator keystrokes are read from fd 3 - a copy of the script's
|
||||
# original stdin taken before that redirection - rather than
|
||||
@@ -1348,55 +1434,65 @@ case "$cmd" in
|
||||
*) continue ;;
|
||||
esac
|
||||
[ "$tier" = CRITICAL ] && crit_seen=$((crit_seen + 1))
|
||||
# Derive the namespaced id baseline_suppresses() will later
|
||||
# compare against (see id_severity/current_sha_for_id above) -
|
||||
# one case arm per finding-producing loop in `compare`. Every
|
||||
# pattern is anchored to the fixed label text `compare` itself
|
||||
# emits at the START of body, never matched as a substring
|
||||
# anywhere in it: a workspace-controlled value (a git config
|
||||
# value, a path) can contain text like "workspace file: " or
|
||||
# "TCB (sandbox self) modified: " and reach $body unmodified, so
|
||||
# an unanchored match could be hijacked into deriving the wrong
|
||||
# (attacker-chosen) id for the wrong namespace - approving it
|
||||
# would then baseline an id that can never match, un-baselining
|
||||
# the real finding while still counting it as acknowledged. A
|
||||
# finding whose body matches none of these (currently only
|
||||
# CROSS-PROJECT) has no id to record, so it's shown but not
|
||||
# offered a/s.
|
||||
# Derive the namespaced id baseline_suppresses() compares
|
||||
# against - one arm per finding-producing loop in `compare`.
|
||||
#
|
||||
# The id itself must then be recovered with a shortest-prefix
|
||||
# strip of that same fixed label (`${body#LABEL}`), never
|
||||
# `${body##*: }`: the latter strips up to the LAST ": " in the
|
||||
# whole string, and a workspace-controlled path can itself
|
||||
# contain a literal ": " (e.g. ".git/hooks/pre-commit: decoy"),
|
||||
# which would truncate the derived id to everything after that
|
||||
# planted separator - the case match being anchored doesn't
|
||||
# protect the extraction that follows it.
|
||||
# Both steps must resist workspace-controlled text in $body. The
|
||||
# patterns anchor to the fixed label at the START of body: a git
|
||||
# config value or path can contain "workspace file: ", so an
|
||||
# unanchored match derives an attacker-chosen id in the wrong
|
||||
# namespace, and approving it baselines an id that never matches
|
||||
# while counting the real finding as acknowledged. The id is then
|
||||
# recovered with `${body#LABEL}`, never `${body##*: }` - the
|
||||
# latter strips to the LAST ": ", and a path can contain one.
|
||||
# A body matching no arm (currently only CROSS-PROJECT) gets no
|
||||
# id: shown, but not offered a/s.
|
||||
#
|
||||
# Two forms come out of this, because the two consumers need
|
||||
# different ones (see enc_id): $id is the ENCODED id exactly as
|
||||
# the report carries it, and is what gets written to the
|
||||
# baseline file, which stores that form. $rid is the same id
|
||||
# decoded back to the raw bytes on disk, for current_sha_for_id
|
||||
# to stat and hash and for id_severity to classify. Only the
|
||||
# id-bearing portion is decoded - never the namespace prefix
|
||||
# this code adds itself.
|
||||
ackable=0; id=""; rid=""
|
||||
# $id is the ENCODED form the report and baseline file carry;
|
||||
# $rid is it decoded, for current_sha_for_id to stat and hash and
|
||||
# id_severity to classify. Only the id-bearing portion decodes,
|
||||
# never the namespace prefix added here. $lbl and $surface are
|
||||
# display-only, split so the surface can be coloured without the
|
||||
# audit's own words around it, and recomputed rather than derived
|
||||
# from $id because a gitconfig body carries `key=value` while its
|
||||
# id is the key alone.
|
||||
ackable=0; id=""; rid=""; lbl=""; surface=""
|
||||
case "$body" in
|
||||
"HOST-CODE-EXECUTION workspace file: "*)
|
||||
ackable=1; id="${body#HOST-CODE-EXECUTION workspace file: }"
|
||||
rid="$(dec_id "$id")" ;;
|
||||
ackable=1; lbl="HOST-CODE-EXECUTION workspace file: "
|
||||
id="${body#HOST-CODE-EXECUTION workspace file: }"
|
||||
surface="$id"; rid="$(dec_id "$id")" ;;
|
||||
"changed workspace file: "*)
|
||||
ackable=1; id="${body#changed workspace file: }"
|
||||
rid="$(dec_id "$id")" ;;
|
||||
"COMMAND-EXECUTION git config: "*|"COMMAND-EXECUTION git alias: "*)
|
||||
ackable=1; cfgline="${body#*: }"; ekey="${cfgline%%=*}"
|
||||
ackable=1; lbl="changed workspace file: "
|
||||
id="${body#changed workspace file: }"
|
||||
surface="$id"; rid="$(dec_id "$id")" ;;
|
||||
"COMMAND-EXECUTION git config: "*)
|
||||
ackable=1; lbl="COMMAND-EXECUTION git config: "
|
||||
cfgline="${body#COMMAND-EXECUTION git config: }"
|
||||
ekey="${cfgline%%=*}"; surface="$ekey"
|
||||
id="gitconfig:$ekey"; rid="gitconfig:$(dec_id "$ekey")" ;;
|
||||
"COMMAND-EXECUTION git alias: "*)
|
||||
ackable=1; lbl="COMMAND-EXECUTION git alias: "
|
||||
cfgline="${body#COMMAND-EXECUTION git alias: }"
|
||||
ekey="${cfgline%%=*}"; surface="$ekey"
|
||||
id="gitconfig:$ekey"; rid="gitconfig:$(dec_id "$ekey")" ;;
|
||||
# The WARNING form of the two above. Its own arm rather than
|
||||
# another alternative on theirs, because the label it strips
|
||||
# differs and the strip must be of that exact label (#45).
|
||||
"changed git config: "*)
|
||||
ackable=1; lbl="changed git config: "
|
||||
cfgline="${body#changed git config: }"
|
||||
ekey="${cfgline%%=*}"; surface="$ekey"
|
||||
id="gitconfig:$ekey"; rid="gitconfig:$(dec_id "$ekey")" ;;
|
||||
"HOST-CODE-EXECUTION statusline.sh changed (runs UNSANDBOXED on the host via settings.json statusLine.command): "*)
|
||||
ackable=1; esl="${body#HOST-CODE-EXECUTION statusline.sh changed (runs UNSANDBOXED on the host via settings.json statusLine.command): }"
|
||||
ackable=1; lbl="HOST-CODE-EXECUTION statusline.sh changed (runs UNSANDBOXED on the host via settings.json statusLine.command): "
|
||||
esl="${body#HOST-CODE-EXECUTION statusline.sh changed (runs UNSANDBOXED on the host via settings.json statusLine.command): }"
|
||||
surface="$esl"
|
||||
id="statusline:$esl"; rid="statusline:$(dec_id "$esl")" ;;
|
||||
"TCB (sandbox self) modified: "*)
|
||||
ackable=1; etcb="${body#TCB (sandbox self) modified: }"
|
||||
ackable=1; lbl="TCB (sandbox self) modified: "
|
||||
etcb="${body#TCB (sandbox self) modified: }"
|
||||
surface="$etcb"
|
||||
id="tcb:$etcb"; rid="tcb:$(dec_id "$etcb")" ;;
|
||||
*) ackable=0 ;;
|
||||
esac
|
||||
@@ -1406,29 +1502,84 @@ case "$cmd" in
|
||||
# must only ever advance when a real, non-empty id was derived.
|
||||
[ -n "$id" ] || ackable=0
|
||||
[ -n "$rid" ] || ackable=0
|
||||
# Already decided: the baseline holds an approval pinning this
|
||||
# exact content, or an ignore for the surface outright. `compare`
|
||||
# filters these out when it WRITES a report, but a report written
|
||||
# before that entry existed still lists them - and two reports
|
||||
# can list the same finding, so approving it in the first left
|
||||
# the second re-presenting it on every later run, with the
|
||||
# approval sitting in the baseline the whole time. That is what
|
||||
# made repeated `ack` runs show the same CRITICALs forever (#65).
|
||||
#
|
||||
# Counted as covered rather than merely hidden: a CRITICAL whose
|
||||
# content is already approved must let its report shed
|
||||
# -CRITICAL.txt, or the startup warning nags about a report whose
|
||||
# every finding has been dealt with.
|
||||
if [ "$ackable" -eq 1 ] && baseline_suppresses "$rid" "$(current_sha_for_id "$rid")"; then
|
||||
already=$((already + 1))
|
||||
if [ "$tier" = CRITICAL ]; then crit_acked=$((crit_acked + 1)); fi
|
||||
continue
|
||||
fi
|
||||
# An un-ackable finding in a report already walked to the end.
|
||||
# There is nothing to record for one, so without this it would
|
||||
# re-prompt on every run forever, gating the exit from `ack` on a
|
||||
# keystroke that can never change anything (#65).
|
||||
if [ "$ackable" -ne 1 ] && [ "$report_reviewed" -eq 1 ]; then
|
||||
already=$((already + 1))
|
||||
continue
|
||||
fi
|
||||
[ "$ackable" -eq 1 ] || unackable_shown=1
|
||||
echo ""
|
||||
echo " [$tier] $body"
|
||||
[ "$ackable" -eq 1 ] && render_finding "$rid" "$sc"
|
||||
if [ "$ackable" -ne 1 ]; then
|
||||
printf ' [c] skip [q] quit (this finding cannot be acknowledged): '
|
||||
elif [ "$tier" = CRITICAL ]; then
|
||||
printf ' [a] approve this exact content [c] skip [q] quit: '
|
||||
# The tier carries the colour, and the surface the finding is
|
||||
# about is picked out from the label around it - those are the
|
||||
# two things an operator scans a wall of these for.
|
||||
if [ -n "$surface" ]; then
|
||||
tail_rest="${body#"$lbl$surface"}"
|
||||
printf ' %s[%s]%s %s%s%s%s%s\n' "$(tier_color "$tier")" "$tier" "$C_RESET" \
|
||||
"$lbl" "$C_PATH" "$surface" "$C_RESET" "$tail_rest"
|
||||
else
|
||||
printf ' [a] approve [s] always ignore [c] skip [q] quit: '
|
||||
printf ' %s[%s]%s %s\n' "$(tier_color "$tier")" "$tier" "$C_RESET" "$body"
|
||||
fi
|
||||
[ "$ackable" -eq 1 ] && render_finding "$rid" "$sc"
|
||||
# Each option says what it does to FUTURE findings, because that
|
||||
# is the whole difference between the two that record something
|
||||
# and it was not visible: approve pins this exact content and
|
||||
# re-fires on any other, ignore blankets the surface whatever it
|
||||
# later contains. The WARNING prompt used to say a bare
|
||||
# "approve", which read as the same thing as "always ignore".
|
||||
#
|
||||
# "file" is wrong for a git config key, and naming the wrong kind
|
||||
# of thing in the sentence that offers a permanent mute is worth
|
||||
# one case.
|
||||
case "$rid" in
|
||||
gitconfig:*) ign_noun="key" ;;
|
||||
*) ign_noun="file" ;;
|
||||
esac
|
||||
k_a="$C_KEY[a]$C_RESET"; k_i="$C_KEY[i]$C_RESET"
|
||||
k_s="$C_KEY[s]$C_RESET"; k_q="$C_KEY[q]$C_RESET"
|
||||
if [ "$ackable" -ne 1 ]; then
|
||||
printf ' %s skip %s quit (this finding cannot be acknowledged): ' "$k_s" "$k_q"
|
||||
elif [ "$tier" = CRITICAL ]; then
|
||||
printf ' %s approve this exact content %s skip (ask again next time) %s quit: ' \
|
||||
"$k_a" "$k_s" "$k_q"
|
||||
else
|
||||
printf ' %s approve this exact content %s ignore this %s from now on %s skip (ask again next time) %s quit: ' \
|
||||
"$k_a" "$k_i" "$ign_noun" "$k_s" "$k_q"
|
||||
fi
|
||||
{ read -r key <&3; } 2>/dev/null || { aborted=1; break; }
|
||||
case "$key" in
|
||||
q|$'\e'|'') aborted=1; break ;;
|
||||
s)
|
||||
i)
|
||||
if [ "$ackable" -ne 1 ]; then
|
||||
echo " cannot acknowledge: this finding cannot be acknowledged."
|
||||
elif [ "$tier" = CRITICAL ]; then
|
||||
echo " cannot 'always ignore' a CRITICAL surface - approve the exact content or skip."
|
||||
echo " cannot ignore a CRITICAL surface from now on - approve the exact content or skip."
|
||||
else
|
||||
pending+=("spam $id")
|
||||
sc_done+=("$rid")
|
||||
fi
|
||||
;;
|
||||
s) ;;
|
||||
a)
|
||||
if [ "$ackable" -ne 1 ]; then
|
||||
echo " cannot acknowledge: this finding cannot be acknowledged."
|
||||
@@ -1458,12 +1609,22 @@ case "$cmd" in
|
||||
esac
|
||||
done < "$report"
|
||||
|
||||
if [ "$aborted" -eq 1 ] || [ "${#pending[@]}" -eq 0 ]; then
|
||||
# An abort discards everything, the reviewed marker included: the
|
||||
# operator did not finish the report, so it has not been reviewed.
|
||||
if [ "$aborted" -eq 1 ]; then
|
||||
echo ""; echo "dejima audit: nothing recorded."
|
||||
exit 0
|
||||
fi
|
||||
mkdir -p "$BASELINE_DIR"
|
||||
printf '%s\n' "${pending[@]}" >> "$bf"
|
||||
# Written once the report has been walked to its end, and only when
|
||||
# it actually showed something that cannot be acknowledged - there is
|
||||
# nothing else to stop those from re-prompting forever.
|
||||
if [ "$unackable_shown" -eq 1 ] && [ "$report_reviewed" -eq 0 ]; then
|
||||
pending+=("reviewed $(basename "$report")")
|
||||
fi
|
||||
if [ "${#pending[@]}" -gt 0 ]; then
|
||||
mkdir -p "$BASELINE_DIR"
|
||||
printf '%s\n' "${pending[@]}" >> "$bf"
|
||||
fi
|
||||
for rid in ${sc_done[@]+"${sc_done[@]}"}; do
|
||||
rm -f "$sc/$(id_key "$rid")".diff "$sc/$(id_key "$rid")".new \
|
||||
"$sc/$(id_key "$rid")".removed "$sc/$(id_key "$rid")".withheld
|
||||
@@ -1472,12 +1633,30 @@ case "$cmd" in
|
||||
# Only rename off -CRITICAL.txt (making the report evictable by
|
||||
# rotation) when every CRITICAL finding this report carried was
|
||||
# actually approved - not just when something was approved.
|
||||
# Reached even when this run recorded nothing new: a report whose
|
||||
# CRITICALs were all approved through some EARLIER report listing the
|
||||
# same finding is fully dealt with, and has to stop being counted as
|
||||
# unacknowledged or the startup warning nags about it forever.
|
||||
if [ "$crit_seen" -gt 0 ] && [ "$crit_acked" -eq "$crit_seen" ]; then
|
||||
case "$report" in
|
||||
*-CRITICAL.txt) mv "$report" "${report%-CRITICAL.txt}.txt" ;;
|
||||
esac
|
||||
fi
|
||||
echo ""; echo "dejima audit: recorded ${#pending[@]} acknowledgement(s) in $bf"
|
||||
echo ""
|
||||
# Distinguish the three ways a run can end with nothing new, because
|
||||
# "nothing recorded" for all of them is what made an already-handled
|
||||
# report look like a broken tool.
|
||||
acks=0
|
||||
for p in ${pending[@]+"${pending[@]}"}; do
|
||||
case "$p" in reviewed\ *) ;; *) acks=$((acks + 1)) ;; esac
|
||||
done
|
||||
if [ "$acks" -gt 0 ]; then
|
||||
echo "dejima audit: recorded $acks acknowledgement(s) in $bf"
|
||||
elif [ "$already" -gt 0 ]; then
|
||||
echo "dejima audit: nothing new - $already finding(s) already dealt with."
|
||||
else
|
||||
echo "dejima audit: nothing recorded."
|
||||
fi
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
#!/bin/bash
|
||||
# Terminal colour, shared by bin/dejima and bin/dejima-audit.
|
||||
#
|
||||
# Shared rather than duplicated because the two are one surface to the
|
||||
# operator: the launcher's "unacknowledged CRITICAL" warning and the audit
|
||||
# review it sends them to must agree about what red means, or the colour is
|
||||
# decoration instead of information.
|
||||
#
|
||||
# Nothing here decides WHAT to highlight - that belongs with the text. It
|
||||
# decides only whether colour is safe to emit at all, and provides the names.
|
||||
#
|
||||
# Off unless the stream being written to is a terminal, so a redirected
|
||||
# report or a piped test never receives escape sequences: the audit's output
|
||||
# is read back by tests and, in the reports it writes, by future runs.
|
||||
# NO_COLOR (any value, https://no-color.org) and TERM=dumb both turn it off
|
||||
# as well.
|
||||
#
|
||||
# The variables are always DEFINED, empty when colour is off, so callers
|
||||
# interpolate them unconditionally and no call site needs an if.
|
||||
color_init() {
|
||||
local fd="${1:-1}"
|
||||
C_RESET=""; C_CRIT=""; C_WARN=""; C_INFO=""; C_PATH=""
|
||||
C_KEY=""; C_DIM=""; C_ADD=""; C_DEL=""; C_OK=""
|
||||
[ -t "$fd" ] || return 0
|
||||
[ -z "${NO_COLOR+x}" ] || return 0
|
||||
case "${TERM:-}" in ''|dumb) return 0 ;; esac
|
||||
C_RESET=$'\033[0m'
|
||||
C_CRIT=$'\033[1;31m' # bold red - runs unsandboxed host code
|
||||
C_WARN=$'\033[33m' # yellow
|
||||
C_INFO=$'\033[36m' # cyan
|
||||
C_PATH=$'\033[1;36m' # bold cyan - the file or key a finding is about
|
||||
C_KEY=$'\033[1m' # bold - the keystroke an operator has to press
|
||||
C_DIM=$'\033[2m' # dim - the audit's own framing around content
|
||||
C_ADD=$'\033[32m' # green - a diff's added lines
|
||||
C_DEL=$'\033[31m' # red - a diff's removed lines
|
||||
C_OK=$'\033[32m' # green
|
||||
}
|
||||
|
||||
# The colour for a tier name, so the mapping lives in one place rather than
|
||||
# at each site that prints one.
|
||||
tier_color() {
|
||||
case "$1" in
|
||||
CRITICAL) printf '%s' "$C_CRIT" ;;
|
||||
WARNING) printf '%s' "$C_WARN" ;;
|
||||
*) printf '%s' "$C_INFO" ;;
|
||||
esac
|
||||
}
|
||||
+13
-19
@@ -22,32 +22,26 @@
|
||||
# Schema
|
||||
#
|
||||
# Three TAB-separated columns: key, type, default. One source of truth - both
|
||||
# _config_spec and _config_keys read this, so a key cannot exist for lookup
|
||||
# but be missing from enumeration (which is what --print-config walks).
|
||||
# _config_spec and _config_keys read it, so a key cannot be lookup-able but
|
||||
# missing from --print-config's enumeration.
|
||||
#
|
||||
# A "?" prefix on the type means the empty string is a legal value. Without
|
||||
# it, empty is rejected: the distinction is between a setting that is
|
||||
# meaningfully absent (ssh_key, loki_url) and one that must always have a
|
||||
# A "?" type prefix means empty is legal, distinguishing a setting that is
|
||||
# meaningfully absent (ssh_key, loki_url) from one that must always have a
|
||||
# value (firewall, share_dir).
|
||||
#
|
||||
# The three axis keys are `provider:<axis>` - validated by looking the name
|
||||
# up under the provider root (see _config_extra_type_valid below and
|
||||
# dejima-provider-lib.sh) rather than a fixed enum, so adding a provider is
|
||||
# adding a directory, not editing this schema.
|
||||
#
|
||||
# [forge.<name>] and [harness.<name>] keys are absent from this table for the
|
||||
# same reason: enumerating a forge's own keys here would mean a new forge
|
||||
# needs a core edit, which is exactly what the provider axes are for.
|
||||
# Those two sections are resolved instead through the _config_extra_*
|
||||
# functions below, stubbed here and overridden by dejima-provider-lib.sh once
|
||||
# it is sourced, against the type+default each provider's own `keys` manifest
|
||||
# entry declares (see _provider_keyspec_parse there).
|
||||
# The axis keys are `provider:<axis>`, validated against the provider root
|
||||
# rather than a fixed enum, so adding a provider is adding a directory.
|
||||
# [forge.<name>] and [harness.<name>] keys are absent here for the same
|
||||
# reason - enumerating them would make a new forge need a core edit. They
|
||||
# resolve through the _config_extra_* functions below, stubbed here and
|
||||
# overridden by dejima-provider-lib.sh, against each provider's own `keys`
|
||||
# manifest entry.
|
||||
# ---------------------------------------------------------------------------
|
||||
_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 ;;
|
||||
|
||||
+14
-20
@@ -7,30 +7,24 @@
|
||||
# (no argument) watch until killed - what the systemd unit runs
|
||||
# --once one scan, then exit
|
||||
#
|
||||
# This script is the trust boundary. Everything under the marker root is
|
||||
# written by a sandboxed session that may be compromised, and the device it
|
||||
# ultimately reaches - the operator's phone - is outside the sandbox. The
|
||||
# in-container hook's own enum constrains nothing here: a session owns the
|
||||
# contents of its bind-mounted marker directory and can create any name it
|
||||
# likes in it, hook or no hook. The guarantee rests entirely on this file:
|
||||
# This script is the trust boundary: a possibly-compromised session writes
|
||||
# the markers, and the phone they reach is outside the sandbox. The
|
||||
# in-container hook's enum constrains nothing here - a session owns its
|
||||
# marker directory and can create any name it likes. The guarantee is here:
|
||||
#
|
||||
# - the marker NAME is the only thing consulted, and only by iterating a
|
||||
# literal enum, so a name outside it is never stat'd, read, or removed
|
||||
# - marker CONTENT is never read, so there is nothing to inject
|
||||
# - names are never interpolated into a command
|
||||
# - `rm -f` unlinks a symlink instead of following it, so a marker aimed at
|
||||
# a host file removes the link and leaves the file alone
|
||||
# - only the marker NAME is consulted, by iterating a literal enum, so a
|
||||
# name outside it is never stat'd, read, or removed
|
||||
# - marker CONTENT is never read, and names never reach a command
|
||||
# - `rm -f` unlinks a symlink rather than following it
|
||||
# - the click URL and every ntfy header are composed from host-side data
|
||||
#
|
||||
# That last point is what the enum is really for. ntfy's own headers include
|
||||
# X-Actions (which can make the phone issue an arbitrary HTTP request or fire
|
||||
# an Android intent), X-Attach and X-Icon (which fetch URLs), X-Email and
|
||||
# X-Call. A single attacker-controlled header here would turn the phone into
|
||||
# a confused deputy inside the operator's network.
|
||||
# The last point is what the enum is for: ntfy's X-Actions can make the phone
|
||||
# issue an HTTP request or fire an Android intent, X-Attach and X-Icon fetch
|
||||
# URLs. One attacker-controlled header makes the phone a confused deputy
|
||||
# inside the operator's network.
|
||||
#
|
||||
# One scan covers every run directory, so nothing a single session can do to
|
||||
# its own markers may abort it: that would let one compromised sandbox silence
|
||||
# every other session's notifications.
|
||||
# One scan covers every run directory, so nothing a session does to its own
|
||||
# markers can abort it and silence every other session's notifications.
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
|
||||
|
||||
+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
|
||||
;;
|
||||
*)
|
||||
|
||||
+295
-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,67 @@ read_declared() {
|
||||
return 0
|
||||
}
|
||||
|
||||
# Shared argument parsing for dejima-allow and dejima-dep, so the two CLIs
|
||||
# cannot drift on what --project means.
|
||||
#
|
||||
# --project's path is OPTIONAL, so `--project X` is ambiguous: X could be the
|
||||
# path or the required positional. Deciding by "is X the last token" breaks
|
||||
# `dejima-dep list --project ~/p`, which has no trailing positional and would
|
||||
# silently scope to the current directory instead. So:
|
||||
#
|
||||
# --project takes the next token as its path only if that still leaves the
|
||||
# command its required trailing positional(s).
|
||||
#
|
||||
# Required counts are per-command constants the caller declares.
|
||||
#
|
||||
# Sets dj_global, dj_project, dj_project_set, dj_container and dj_positional.
|
||||
# dj_project_set exists because "--project with no path" (the current
|
||||
# directory) and "no --project at all" are different scopes that an empty
|
||||
# dj_project cannot distinguish.
|
||||
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 +189,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 +248,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 +318,124 @@ 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; }
|
||||
# The one write here that lands on the session-writable bind mount, so
|
||||
# the one that must not follow a symlink. A session can plant one and
|
||||
# then ask the user - via the briefing, which hands over exactly this
|
||||
# command - to run the CLI that trips it, aiming a host-privileged
|
||||
# append at ~/.config/dejima/allowed-domains (project approval becomes
|
||||
# global) or dependencies (installed as root at image build, the one
|
||||
# path with no firewall in front of it).
|
||||
#
|
||||
# Checking first is not enough: the session is normally still live when
|
||||
# the user runs this, and it chose the moment, so it can spin a rename
|
||||
# loop across any check-to-write window. The guard is the open itself.
|
||||
# O_NOFOLLOW fails outright on a symlink, the directory is opened
|
||||
# O_DIRECTORY and the file relative to that fd, and the whole
|
||||
# read-then-append goes through it - so winning the swap gets an open of
|
||||
# the replaced object, not a redirected write.
|
||||
#
|
||||
# bash cannot open a path that way, hence python3 (already a hard
|
||||
# dependency). O_NONBLOCK and the S_ISREG check cover the non-symlink
|
||||
# shapes: a planted FIFO would otherwise hang the host CLI for as long as
|
||||
# the session declined 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 +444,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 +488,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 +497,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 +509,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
|
||||
|
||||
+176
-51
@@ -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
|
||||
@@ -321,28 +329,17 @@ manifest_get() {
|
||||
# directory whose manifest declares its own keys, not editing core.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# config_validate re-parses the WHOLE config file on every config_get call
|
||||
# (see its own comment - "on the order of thirty reads" per launch), and each
|
||||
# pass through a [forge.<name>]/[harness.<name>] line calls back into here.
|
||||
# Going through manifest_get/manifest_validate here would mean thirty-odd
|
||||
# full-manifest validations (every field, not just `keys`) for every key in
|
||||
# those sections - so this reads and validates only the `keys` field itself,
|
||||
# a plain line-scan plus the one type check _provider_valid_value already
|
||||
# does for it, skipping fields (pr_command, capabilities, env_export, ...)
|
||||
# that this lookup never uses. The manifest's OTHER fields still get their
|
||||
# full manifest_validate elsewhere before anything consequential reads them
|
||||
# (provider_validate_selection, forge_hosts, forge_env_exports,
|
||||
# briefing_compose) - this function only ever feeds a config schema type
|
||||
# check, never a forge action.
|
||||
# config_validate re-parses the whole config on every config_get (~thirty per
|
||||
# launch), and each [forge.<name>] line calls back here. Going through
|
||||
# manifest_validate would mean that many full-manifest validations, so this
|
||||
# line-scans the `keys` field alone. Every other field still gets a full
|
||||
# manifest_validate before anything consequential reads it - this feeds a
|
||||
# config schema type check, never a forge action.
|
||||
#
|
||||
# Cached per axis+name for the rest of THIS command substitution: a
|
||||
# manifest cannot change mid-launch, so re-deriving it for every key in the
|
||||
# same section within one config_validate pass is pure waste. The cache
|
||||
# itself does not survive past this subshell (bash forks a new one per
|
||||
# config_get call), so it only dedupes the N keys of one section against
|
||||
# each other, not across a launch's ~thirty reads - a cheap, safe partial
|
||||
# win rather than a cross-process cache this file has no story for
|
||||
# invalidating.
|
||||
# Cached per axis+name for this command substitution only. bash forks a new
|
||||
# subshell per config_get, so it dedupes the keys of one section against each
|
||||
# other rather than across a launch: a cheap partial win, not a cross-process
|
||||
# cache this file has no way to invalidate.
|
||||
declare -gA _PROVIDER_KEYS_CACHE
|
||||
_provider_keys_cached() {
|
||||
local axis="$1" name="$2" cache_key="$axis:$name"
|
||||
@@ -474,38 +471,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 +624,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 +666,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 +694,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 +703,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.
|
||||
@@ -654,6 +739,14 @@ _briefing_core_worktree() {
|
||||
`.worktrees/` to `.git/info/exclude` (not `.gitignore` - this is
|
||||
sandbox-local, not something to commit) so it doesn't clutter `git
|
||||
status` for the user's real checkout.
|
||||
- A worktree isolates the working tree, NOT `.git/config`, which is shared
|
||||
with the parent checkout. A bare `git config <key> <value>` run from
|
||||
inside a worktree writes to the user's real repository config, not to
|
||||
anything scoped to your branch. Use `git -C <path> config` against a
|
||||
throwaway repo when setting config for a test or a repro. This matters
|
||||
beyond a stray `user.name`: `core.hooksPath`, `credential.helper` and
|
||||
`!`-aliases all execute on the host, and the same unscoped command
|
||||
reaches them.
|
||||
BRIEFING_EOF
|
||||
}
|
||||
|
||||
@@ -784,6 +877,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:-}" ;;
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
# Shared library: the ~/.claude/projects slug scheme, and which slugs belong
|
||||
# to a given workspace. Sourced by bin/dejima (to decide what to mount) and
|
||||
# bin/dejima-audit (to decide what counts as a cross-project write). Not on
|
||||
# PATH and not executable.
|
||||
#
|
||||
# One definition, because the two callers must not disagree. A slug the mount
|
||||
# exposes but the audit treats as foreign produces a finding on every ordinary
|
||||
# session; a slug the audit treats as the workspace's own but the mount does
|
||||
# not expose is a hole that nothing reports.
|
||||
|
||||
# Claude Code's own project key: the absolute cwd with every non-alphanumeric
|
||||
# replaced by a dash. Matches docker/log-shipper.sh's Loki key and
|
||||
# dejima-project-lib.sh's project_slug, which cannot source this file (one
|
||||
# runs inside the container, the other is a separate library).
|
||||
ws_slug() { printf '%s' "$1" | sed 's/[^a-zA-Z0-9]/-/g'; }
|
||||
|
||||
# The slugs belonging to $1: its own, plus one per live git worktree nested
|
||||
# underneath it. One per line, sorted, no duplicates.
|
||||
#
|
||||
# Derived from real worktree paths rather than from the slug string, and that
|
||||
# is the whole point of the function. Slugs cannot distinguish a child from a
|
||||
# sibling: `/w/proj/wt1` and `/w/proj-wt1` both slug to `-w-proj-wt1`, so a
|
||||
# `$slug-*` glob matches any sibling project whose directory name merely
|
||||
# extends this one's - on a host with `proj` and `proj-ci` checked out side by
|
||||
# side, `proj-ci` reads as part of `proj`. A path test cannot make that
|
||||
# mistake, because a sibling is not under the workspace directory.
|
||||
#
|
||||
# Listing from a worktree names the main checkout and its siblings too. Those
|
||||
# are not under $1, so they drop out here: launching against a worktree
|
||||
# exposes that worktree and nothing else, which is correct.
|
||||
workspace_slugs() {
|
||||
local ws="$1" wt
|
||||
ws="${ws%/}"
|
||||
ws_slug "$ws"
|
||||
printf '\n'
|
||||
# A path containing a newline splits into two lines that name no real
|
||||
# directory, so both fail the -d test below rather than being parsed as
|
||||
# some other worktree. git has no unambiguous non-`-z` output here.
|
||||
git -C "$ws" worktree list --porcelain 2>/dev/null \
|
||||
| sed -n 's/^worktree //p' \
|
||||
| while IFS= read -r wt; do
|
||||
case "$wt" in
|
||||
"$ws"/*) ;;
|
||||
*) continue ;;
|
||||
esac
|
||||
[ -d "$wt" ] || continue
|
||||
ws_slug "$wt"
|
||||
printf '\n'
|
||||
done
|
||||
}
|
||||
|
||||
# Every slug in workspace_slugs, sorted and deduplicated.
|
||||
workspace_slugs_sorted() { workspace_slugs "$1" | sort -u; }
|
||||
|
||||
# The Claude Code session ids belonging to workspace $2, one per line, taken
|
||||
# from the transcript filenames under $1 (a ~/.claude/projects directory).
|
||||
#
|
||||
# ~/.claude/file-history is keyed by session id rather than by project, so
|
||||
# there is no slug in its own paths to scope it by. The transcript is what
|
||||
# ties the two together: projects/<slug>/<id>.jsonl and file-history/<id>
|
||||
# name the same session, so the slugs this workspace owns select the ids it
|
||||
# owns. A session id that has no transcript belongs to no project this can
|
||||
# see, and is left out.
|
||||
workspace_session_ids() {
|
||||
local projects_dir="$1" ws="$2" slug f id
|
||||
[ -d "$projects_dir" ] || return 0
|
||||
while IFS= read -r slug; do
|
||||
[ -n "$slug" ] || continue
|
||||
[ -d "$projects_dir/$slug" ] || continue
|
||||
for f in "$projects_dir/$slug"/*.jsonl; do
|
||||
[ -f "$f" ] || continue
|
||||
id="$(basename "$f" .jsonl)"
|
||||
# Only a real session id, never whatever else a directory might
|
||||
# hold: this value becomes a mount path.
|
||||
case "$id" in
|
||||
*[!a-fA-F0-9-]*) continue ;;
|
||||
esac
|
||||
[ -n "$id" ] && printf '%s\n' "$id"
|
||||
done
|
||||
done < <(workspace_slugs_sorted "$ws") | sort -u
|
||||
}
|
||||
+18
-28
@@ -5,37 +5,27 @@
|
||||
# systemd/dejima-ttyd.service.
|
||||
# See docs/remote-access.md.
|
||||
#
|
||||
# --interface 127.0.0.1 is a security requirement, not a preference.
|
||||
# docker/init-firewall.sh:187 accepts container OUTPUT to the default-route
|
||||
# address - the Docker bridge gateway, i.e. the host - and attaching tmux
|
||||
# gives a HOST shell (the prefix key opens a new window outside the
|
||||
# container). A listener on any address the container can route to would hand
|
||||
# a compromised session its own escape hatch. Loopback is not one of those
|
||||
# addresses: the container has no route to the host's 127.0.0.1. Put nginx in
|
||||
# front, bound to one specific LAN address - see
|
||||
# docs/nginx-dejima.conf.example.
|
||||
# --interface 127.0.0.1 is a requirement, not a preference. init-firewall.sh
|
||||
# accepts container OUTPUT to the bridge gateway (the host), and attaching
|
||||
# tmux gives a HOST shell - the prefix key opens a window outside the
|
||||
# container. A listener on any address the container can route to is a
|
||||
# compromised session's escape hatch; loopback is not one of them. Put nginx
|
||||
# in front, bound to one LAN address - docs/nginx-dejima.conf.example.
|
||||
#
|
||||
# ttyd is given no credential of its own, so the reverse proxy is the only
|
||||
# thing authenticating a request, and the loopback bind is what makes that
|
||||
# safe rather than careless: nothing but a process on this host can open the
|
||||
# socket at all. --writable is what makes the terminal a terminal rather than
|
||||
# a viewer.
|
||||
# ttyd carries no credential of its own, so the proxy is the only thing
|
||||
# authenticating and the loopback bind is what makes that safe. --writable
|
||||
# makes it a terminal rather than a viewer.
|
||||
#
|
||||
# --check-origin is the other half of that, and it is not optional. Without
|
||||
# it ttyd does no origin validation at all (it only calls check_host_origin
|
||||
# when the flag is set), and browsers do not apply the same-origin policy to
|
||||
# WebSockets: any page the operator's browser loads could open
|
||||
# wss://<front-door>/ws, have the browser attach its cached credential for
|
||||
# that origin, and be handed this host shell. Proxy authentication cannot
|
||||
# stop that - it is satisfied by the victim's own browser. ttyd compares the
|
||||
# Origin authority against the Host header, which is why the nginx example
|
||||
# passes Host through unchanged; the two go together.
|
||||
# --check-origin is not optional: without it ttyd does no origin validation
|
||||
# at all, and browsers do not apply same-origin to WebSockets, so any page
|
||||
# the operator loads could open wss://<front-door>/ws with the browser
|
||||
# attaching its cached credential and be handed this shell. Proxy auth cannot
|
||||
# stop that - the victim's own browser satisfies it. ttyd compares Origin
|
||||
# against Host, which is why the nginx example passes Host through unchanged.
|
||||
#
|
||||
# --url-arg lets the client pass ?arg=<run-id>, which is what the notification
|
||||
# deep link uses. It reaches dejima-attach as argv, never as a shell word, and
|
||||
# anything there that is not --list or the empty string (a bare `?arg=`, which
|
||||
# lands on the picker) has to match the run-id shape before it is used as a
|
||||
# path component or anything else.
|
||||
# --url-arg carries ?arg=<run-id> for the notification deep link. It reaches
|
||||
# dejima-attach as argv, never a shell word, and anything but --list or empty
|
||||
# must match the run-id shape before it is used as a path component.
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
|
||||
|
||||
+36
-63
@@ -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
|
||||
|
||||
@@ -84,26 +77,15 @@ ENV DEJIMA=1
|
||||
# this user, so it doesn't need to own anything itself.
|
||||
RUN useradd -r -s /usr/sbin/nologin ulogd
|
||||
|
||||
# Every fetch from here down retries, and none is single-shot. What matters
|
||||
# isn't the odds of a particular download failing, it's the odds of any of
|
||||
# them failing - and without retries a single blip anywhere throws away the
|
||||
# whole build.
|
||||
# Every fetch from here down retries. What matters is not the odds of one
|
||||
# download failing but of ANY of them failing, and without retries a single
|
||||
# blip discards the whole build.
|
||||
#
|
||||
# These steps deliberately sit *above* the SANDBOX_* ARGs, not below. Nothing
|
||||
# between here and `USER` needs the sandbox user to exist yet - they all
|
||||
# install system-wide as root - so keeping them ahead of the ARGs means a
|
||||
# build that overrides one (test/runtime.bats does, and so does any host
|
||||
# whose uid isn't 1000) still reuses every layer down to this point instead
|
||||
# of re-running all of it. That single ordering was most of the cost of the
|
||||
# override rebuild: it was 1169s of a 1379s test run, for a difference that
|
||||
# amounts to one useradd.
|
||||
ARG GIT_DELTA_VERSION=0.18.2
|
||||
RUN ARCH=$(dpkg --print-architecture) && \
|
||||
wget --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"
|
||||
|
||||
# These steps sit ABOVE the SANDBOX_* ARGs on purpose: nothing here needs the
|
||||
# sandbox user, so a build overriding a uid (runtime.bats, and any host not
|
||||
# on 1000) still reuses every layer to this point. That ordering alone was
|
||||
# most of the cost of an override rebuild, for a difference amounting to one
|
||||
# useradd.
|
||||
# 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 +112,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
|
||||
@@ -208,7 +181,11 @@ RUN mkdir /commandhistory && \
|
||||
# from the host, and Docker creates the parents of a mount point root-owned
|
||||
# if they don't already exist - which would leave the sandbox user unable to
|
||||
# write the caches and history it keeps directly in ~/.claude.
|
||||
RUN mkdir -p $SANDBOX_HOME/.ssh $SANDBOX_HOME/.claude && \
|
||||
# projects/ is in that list for the same reason one level down: it is no
|
||||
# longer mounted itself, only individual slug directories inside it, so
|
||||
# without this Docker creates it root-owned while setting those up and the
|
||||
# session cannot write a slug of its own.
|
||||
RUN mkdir -p $SANDBOX_HOME/.ssh $SANDBOX_HOME/.claude $SANDBOX_HOME/.claude/projects $SANDBOX_HOME/.claude/file-history && \
|
||||
chown -R $SANDBOX_USER:$SANDBOX_USER $SANDBOX_HOME/.ssh $SANDBOX_HOME/.claude && \
|
||||
chmod 700 $SANDBOX_HOME/.ssh $SANDBOX_HOME/.claude
|
||||
|
||||
@@ -278,16 +255,12 @@ RUN npm install -g tree-sitter-cli@0.24.7
|
||||
# matching Chromium build into node's own cache, both at build time so a
|
||||
# session never needs network access for either.
|
||||
#
|
||||
# Chromium's own internal process sandbox is questionable here: it launches
|
||||
# without --no-sandbox and without error, but its setuid sandbox helper
|
||||
# (chrome_sandbox) isn't actually setuid-root in this image, and this host
|
||||
# has the same unprivileged-userns restriction that broke bubblewrap - so
|
||||
# its namespace-based isolation almost certainly isn't active either, it's
|
||||
# just not hard-failing the way bubblewrap did. Not treated as a real
|
||||
# security boundary either way: this container is already the boundary that
|
||||
# matters, and a compromised session already has full `node`-level bash
|
||||
# access, which a Chromium exploit couldn't exceed regardless of whichever
|
||||
# of its own internal sandbox layers happen to be active.
|
||||
# Chromium's own process sandbox is probably not active here: it launches
|
||||
# without error, but chrome_sandbox is not setuid-root in this image and the
|
||||
# unprivileged-userns restriction that broke bubblewrap applies, so it just
|
||||
# fails softer than bubblewrap did. Not treated as a boundary either way -
|
||||
# the container is the boundary, and a session already has node-level shell
|
||||
# access that a Chromium exploit could not exceed.
|
||||
RUN npm install -g playwright@${PLAYWRIGHT_VERSION} && \
|
||||
npx playwright install chromium
|
||||
|
||||
|
||||
+29
-26
@@ -82,7 +82,7 @@ services:
|
||||
# Opt-in (default off): also ship session prompts, assistant
|
||||
# responses, and tool invocations to Loki as the
|
||||
# dejima-conversation job. Content only leaves the container
|
||||
# if LOKI_URL is also set. See README "Network request logging".
|
||||
# if LOKI_URL is also set. See docs/security.md "Network request logging".
|
||||
SANDBOX_LOG_CONVERSATION: ${SANDBOX_LOG_CONVERSATION:-0}
|
||||
SSH_AUTH_SOCK: /ssh-agent
|
||||
ENABLE_FIREWALL: ${ENABLE_FIREWALL:-1}
|
||||
@@ -111,33 +111,24 @@ services:
|
||||
# (based on cwd) lines up with the host, letting a session started
|
||||
# here be resumed on the host with `claude --resume`.
|
||||
- ${WORKSPACE_DIR:?set WORKSPACE_DIR to the project directory to mount}:${WORKSPACE_DIR}
|
||||
# ~/.claude is deliberately not mounted as a whole. It used to be,
|
||||
# read-write, with the config inside it layered back read-only on top.
|
||||
# Every one of those overlays was a mount point nested in a
|
||||
# host-writable mount, and a single-file bind mount is pinned to the
|
||||
# dentry at that path: a host-side rename of the underlying file - a
|
||||
# sync daemon, or Claude Code's own atomic writes - unlinks it, the
|
||||
# mount silently detaches, and the path falls through to the real,
|
||||
# writable host file (#47, #48). The same thing happened to the mask
|
||||
# over .credentials.json, handing the sandbox the operator's real
|
||||
# OAuth token on the first refresh (#50).
|
||||
# ~/.claude is enumerated, never mounted whole. A single-file bind
|
||||
# mount is pinned to the dentry at its path, so a host-side rename -
|
||||
# a sync daemon, or Claude Code's own atomic writes - unlinks it, the
|
||||
# mount detaches silently, and the path falls through to the real
|
||||
# writable host file (#47, #48; #50 did exactly that to the
|
||||
# .credentials.json mask). Anything not listed here is
|
||||
# container-local, which is the safer default but means new state that
|
||||
# should persist has to be added deliberately.
|
||||
#
|
||||
# So the contents are enumerated instead. Anything not listed below is
|
||||
# container-local: writable during the session, gone with the
|
||||
# container. That is the safer default - a config file a future Claude
|
||||
# Code version adds simply doesn't persist, instead of sitting
|
||||
# unprotected on a writable mount - but it does mean new state that
|
||||
# *should* persist has to be added here deliberately.
|
||||
# Read-write below, and all directories: a directory mount can't
|
||||
# detach that way, because a rename replaces an entry within it rather
|
||||
# than the mount point itself.
|
||||
#
|
||||
# Runtime state that has to survive the session, read-write. All
|
||||
# directories, which is what makes them safe: a directory bind mount
|
||||
# can't detach the way a file one does, because a rename replaces an
|
||||
# entry *within* the mounted directory rather than the mount point.
|
||||
# projects/ is what host-side `claude --resume` reads (and what
|
||||
# log-shipper.sh tails); the rest is Claude Code's edit-undo record
|
||||
# and the task/plan state skills write.
|
||||
- ${HOME}/.claude/projects:${HOME}/.claude/projects
|
||||
- ${HOME}/.claude/file-history:${HOME}/.claude/file-history
|
||||
# projects/ and file-history/ are absent on purpose - the trees are
|
||||
# every project's memory and every project's file contents, and a
|
||||
# write into either reaches sessions elsewhere (#104). bin/dejima
|
||||
# mounts this workspace's own entries, one -v each; see
|
||||
# bin/dejima-slug-lib.sh for how that set is chosen.
|
||||
- ${HOME}/.claude/backups:${HOME}/.claude/backups
|
||||
- ${HOME}/.claude/tasks:${HOME}/.claude/tasks
|
||||
- ${HOME}/.claude/plans:${HOME}/.claude/plans
|
||||
@@ -233,6 +224,18 @@ services:
|
||||
|
||||
volumes:
|
||||
claude-code-bashhistory:
|
||||
# Named explicitly so bin/dejima can give each project its own set. These
|
||||
# persist across containers by design (a plugin install should not have to
|
||||
# happen every launch), which without the split meant one project's session
|
||||
# could leave a Lua file or a mason/bin binary that another project's
|
||||
# session executes the next time nvim runs. The service still refers to the
|
||||
# static keys; only the volume Docker actually creates varies.
|
||||
#
|
||||
# The defaults reproduce the previous names exactly, so a `docker compose`
|
||||
# invocation that bypasses bin/dejima behaves as it did.
|
||||
nvim-data:
|
||||
name: ${SANDBOX_NVIM_DATA:-dejima_nvim-data}
|
||||
nvim-state:
|
||||
name: ${SANDBOX_NVIM_STATE:-dejima_nvim-state}
|
||||
nvim-cache:
|
||||
name: ${SANDBOX_NVIM_CACHE:-dejima_nvim-cache}
|
||||
|
||||
@@ -252,6 +252,32 @@ fi
|
||||
[ -n "${ANTHROPIC_BASE_URL:-}" ] || unset ANTHROPIC_BASE_URL
|
||||
[ -n "${ANTHROPIC_AUTH_TOKEN:-}" ] || unset ANTHROPIC_AUTH_TOKEN
|
||||
|
||||
# Unset is the ordinary case (remote access is opt-in) and says nothing.
|
||||
# Set-but-unusable is worth a startup line because of how it presents: the
|
||||
# marker directory's source is created per launch and removed on exit, so
|
||||
# restarting the container rather than relaunching `dejima` leaves it gone,
|
||||
# and Docker answers a missing bind source by creating one root-owned. The
|
||||
# hook's `[ -d ]` guard then passes and every notification fails EACCES,
|
||||
# while the session is also missing from `dejima-attach` because its registry
|
||||
# entry went with the same directory - three unrelated-looking symptoms.
|
||||
#
|
||||
# Reported, not repaired: chowning would restore notifications while the
|
||||
# session stayed absent from the registry, looking fixed with half the
|
||||
# feature broken. Relaunching is the remedy, and the message can say so.
|
||||
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
|
||||
|
||||
+38
-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,28 @@ 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: no transport is left for a
|
||||
# forwarded agent key to push with. allowed-domains has no port restriction,
|
||||
# so a GitHub address landing there silently undoes the arrangement.
|
||||
#
|
||||
# dnsmasq applies the longest matching ipset= line, so dns-proxy.sh gets this
|
||||
# right for free. Every other path has to ask, and two did not - the
|
||||
# extra-domains loop and bin/dejima-allow's live add both appended
|
||||
# unconditionally. A project declaring github.com therefore opened port 22 to
|
||||
# whatever address was cached at container start, presenting as an
|
||||
# intermittent startup failure because the self-check re-resolves and only
|
||||
# sees the hole when the second answer matches the first.
|
||||
ipset_for_domain() {
|
||||
local domain="$1" suffix
|
||||
for suffix in "${GITHUB_DNS_SUFFIXES[@]}"; do
|
||||
if [ "$domain" = "$suffix" ] || [[ "$domain" == *".$suffix" ]]; then
|
||||
printf 'github-domains'
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
printf 'allowed-domains'
|
||||
}
|
||||
|
||||
+97
-22
@@ -11,23 +11,17 @@
|
||||
set -Eeuo pipefail
|
||||
IFS=$'\n\t'
|
||||
|
||||
# Nothing in here may die quietly. Under `set -e` a failing command aborts the
|
||||
# script with no output at all, and this script's failures are invisible in the
|
||||
# worst possible way: the container simply never appears, which reads as the
|
||||
# sandbox being broken rather than as one command in one loop having failed.
|
||||
# Three CI failures were spent on two different instances of exactly that (#59)
|
||||
# - one at the GitHub ranges fetch, one in the domain loop below - and neither
|
||||
# left a single line to go on.
|
||||
# Nothing here may die quietly. Under `set -e` a failure aborts with no output
|
||||
# and the container simply never appears, which reads as the sandbox being
|
||||
# broken rather than one command failing. Three CI failures went to two
|
||||
# instances of exactly that (#59), neither leaving a line to go on.
|
||||
#
|
||||
# The explicit `exit 1` paths below say more than this can and are worth
|
||||
# keeping; this is the floor under everything else. -E on the line above is
|
||||
# what extends it into functions and subshells, where it would otherwise not
|
||||
# fire at all.
|
||||
# This is the floor; the explicit `exit 1` paths below say more. -E extends it
|
||||
# into functions and subshells, where it would not fire at all.
|
||||
#
|
||||
# A failure inside a command substitution reports twice - once from the
|
||||
# subshell, naming the command that actually failed, and once from the
|
||||
# statement containing it. That is worth the noise: for `x=$(a | b)` the first
|
||||
# line is the only thing that says whether it was `a` or `b`.
|
||||
# A failure inside a command substitution reports twice, from the subshell and
|
||||
# from the containing statement. Worth the noise: for `x=$(a | b)` the first
|
||||
# line is the only thing saying whether it was `a` or `b`.
|
||||
trap 'status=$?; echo "ERROR: init-firewall.sh aborted at line $LINENO (exit $status): $BASH_COMMAND" >&2' ERR
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
@@ -76,10 +70,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 +266,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 +306,32 @@ 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 forwards external names to the host's
|
||||
# nameservers, and on some versions does it from inside THIS namespace. Those
|
||||
# queries then traverse the chain below and match nothing - the host's
|
||||
# resolver is neither in an ipset nor the bridge gateway - so they hit the
|
||||
# catch-all REJECT and every lookup SERVFAILs. Only the first hop stays on
|
||||
# loopback. Two hosts with byte-identical /etc/resolv.conf behaved
|
||||
# differently, so this is version-dependent and cannot be assumed either way.
|
||||
#
|
||||
# --uid-owner 0 keeps this from being the hole the DNS allowlist closes: the
|
||||
# forwarding socket is dockerd's (root), the session is `node` with no sudo,
|
||||
# so it still cannot address a resolver directly. This newly permits DNS only
|
||||
# from processes already root in a NET_ADMIN container, which could flush the
|
||||
# ruleset anyway.
|
||||
#
|
||||
# Guarded, not asserted: xt_owner is a module, and a kernel without it should
|
||||
# leave a container that says why (the DNS self-check below) rather than one
|
||||
# that aborts here.
|
||||
if iptables -A OUTPUT -p udp --dport 53 -m owner --uid-owner 0 -j ACCEPT 2>/dev/null; then
|
||||
iptables -A OUTPUT -p tcp --dport 53 -m owner --uid-owner 0 -j ACCEPT 2>/dev/null \
|
||||
|| echo "WARNING: could not add the TCP/53 rule for root-owned sockets - large answers may fail" >&2
|
||||
else
|
||||
echo "WARNING: the iptables owner match is unavailable, so Docker's embedded" >&2
|
||||
echo "resolver cannot forward out of this namespace. If DNS works anyway this" >&2
|
||||
echo "host forwards elsewhere and nothing is wrong; the self-check below says which." >&2
|
||||
fi
|
||||
|
||||
# Then allow only specific outbound traffic to allowed domains. This isn't
|
||||
# port-restricted, so it also covers git-over-ssh (port 22) to the
|
||||
# configured forge's hosts, which are already in the ipset above - no
|
||||
@@ -340,6 +367,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 +407,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
|
||||
|
||||
+527
@@ -0,0 +1,527 @@
|
||||
# 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/dejima/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-confined` 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: nothing about what the 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 - the case people
|
||||
do not expect, because there is nothing in the repository to look at.
|
||||
|
||||
So ask one question before pointing a sandbox at a project: *what runners can
|
||||
this repository's branches reach?*
|
||||
|
||||
**"Hosted runners only"** is the mildest case. The runner is disposable and is
|
||||
not your machine, so "execution on a host you own" largely evaporates - no LAN
|
||||
behind it, nothing of yours on it. What remains is credentials: give
|
||||
pull-request runs no secrets they do not need and a read-only token
|
||||
(`permissions: contents: read`), and attach no self-hosted runner.
|
||||
|
||||
**If self-hosted runners are reachable**, that is the sharp case. 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.** No workflow
|
||||
runs at all, so no runner is reachable and a branch cannot add the first
|
||||
workflow. Turning it back on needs *admin* (Gitea puts repository settings
|
||||
behind `reqAdmin()`), so a bot account with Write cannot undo it. For a
|
||||
project not using CI this costs nothing, and it is the most commonly
|
||||
overlooked option.
|
||||
|
||||
**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, which for a public project breaks its name, links and
|
||||
ownership.
|
||||
|
||||
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 model has
|
||||
three cases: global (`OwnerID` and `RepoID` zero), org/user (`OwnerID` set),
|
||||
repository (`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, say so 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. Check that before raising it.
|
||||
|
||||
The DNS and firewall shape is the same one dejima uses inside its own
|
||||
container (`docker/dns-proxy.sh` and `docker/init-firewall.sh`), applied one
|
||||
layer out: allowlist the *names* and let the answers populate the set of
|
||||
allowed *addresses*.
|
||||
|
||||
`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
|
||||
[dejima-ci-hardener](https://gitbox.branderson.io/dejima/dejima-ci-hardener),
|
||||
with the ordering constraints and substitution notes attached.** It is
|
||||
deliberately not reproduced here: two copies of a firewall rule drift, and
|
||||
dejima-ci-hardener is the one that stays runnable.
|
||||
|
||||
## 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** apply 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.
|
||||
@@ -0,0 +1,512 @@
|
||||
# Configuration
|
||||
|
||||
[← dejima README](../README.md)
|
||||
|
||||
Day-to-day configuration once dejima is installed and running: what a
|
||||
project declares versus what you approve, config parity with your host agent
|
||||
harness setup, a different editor, writable host directories, shared
|
||||
scratch space, frontend testing, and what the sandbox knows about itself.
|
||||
|
||||
## Configuration reference
|
||||
|
||||
Every key dejima reads, its default, and what it is for. All of it lives in
|
||||
one file, `~/.config/dejima/config`. `./install.sh` writes a starter copy with
|
||||
every key present and commented out at its default, so nothing here has to be
|
||||
set to get a working sandbox: uncomment what you need. `dejima --print-config`
|
||||
prints the resolved schema with real values, which is the fastest way to see
|
||||
what a given launch will actually use.
|
||||
|
||||
| Key | Default | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `[core] harness` | `claude-code` | agent harness the container runs |
|
||||
| `[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` |
|
||||
| `[core] ssh_key` | unset | dedicated deploy key for headless/unattended git push |
|
||||
| `[core] known_hosts` | `~/.ssh/known_hosts` | known-hosts file used for git-over-ssh |
|
||||
| `[harness.claude-code] projects_dir` | `~/.claude/projects` | where Claude Code session transcripts live |
|
||||
| `[harness.claude-code] statusline_path` | `~/.claude/statusline.sh` | statusline script path |
|
||||
| `[forge.gitea] ssh_host` | unset | Gitea/Forgejo SSH hostname: skip if you only push to GitHub |
|
||||
| `[forge.gitea] api` | unset | `https://<gitea host>/api/v1`: skip if you only push to GitHub |
|
||||
| `[forge.gitea] token_file` | `~/.config/gitea/token` | path to the Gitea/Forgejo API token (the file, not the token itself) |
|
||||
| `[forge.github] push_token_file` | unset | path to a **write-capable** PAT, used only when `[core] forge = github`: deliberately separate from `[github] token_file` below; see "GitHub push" in docs/security.md |
|
||||
| `[github] token_file` | `~/.config/gh/sandbox-token` | path to a read-only PAT for private GitHub repos / `gh` |
|
||||
| `[mounts] readonly` | unset | extra host paths (colon-separated) a personal config needs, mounted read-only at their own path |
|
||||
| `[mounts] read_write` | unset | host paths (colon-separated) mounted **read-write** at their own path: for directories meant to outlive the session; refused if they overlap anything dejima needs out of a session's reach |
|
||||
| `[mounts] nvim_config` | unset | nvim config dir, used when `[core] command = nvim` |
|
||||
| `[credentials] proxy` | `on` | host-side credential proxy; `off` mounts the real OAuth credential into the container directly, as before this feature |
|
||||
| `[credentials] transport` | `tls` | set to `loopback` to fall back to a plaintext forwarder with an explicit `ANTHROPIC_BASE_URL`, keeping the credential out of the container but reinstating the base-URL fingerprint: for when certificate pinning breaks the default TLS path (see [security notes](docs/security.md)) |
|
||||
| `[logging] loki_url` | unset | ship DNS/firewall logs to a Loki endpoint |
|
||||
| `[logging] conversation` | `off` | opt-in: also ship session transcripts to Loki (needs `loki_url`); see "Conversation logging (opt-in)" in docs/security.md |
|
||||
| `[audit] dir` | `~/.local/share/dejima/audit` | where exit-time audit findings are stored |
|
||||
| `[audit] baselines_dir` | `~/.local/share/dejima/baselines` | where audit baselines are stored |
|
||||
| `[audit] keep` | `10` | how many past findings/baselines to retain |
|
||||
| `[audit] strict` | `off` | refuse a headless (non-tty) launch over unacknowledged CRITICAL findings |
|
||||
| `[remote-access] enabled` | `off` | the switch: nothing in "Remote access" below runs until this is `on` |
|
||||
| `[remote-access] ntfy_url` | unset | ntfy topic URL for session notifications |
|
||||
| `[remote-access] base_url` | unset | public URL of the web front door, for notification links |
|
||||
| `[remote-access] ttyd_port` | `7681` | port `ttyd` listens on |
|
||||
| `[remote-access] ttyd_font_size` | unset | `ttyd` terminal font size |
|
||||
| `[remote-access] debounce` | `60` | seconds between repeat notifications for the same session |
|
||||
|
||||
**No forge is the default**: `[core] forge = none` with
|
||||
`[core] workflow = local-only`, so the sandbox works locally and pushes
|
||||
nowhere. Every other workflow needs forge credentials, which is what you are
|
||||
opting into when you pick one.
|
||||
|
||||
## Global and per-project domains and dependencies
|
||||
|
||||
Allowed domains and dependencies each stack through their own layers. The
|
||||
layers **add** to each other, so a narrower layer can only widen what a
|
||||
broader one already permits: nothing at the project layer can withdraw a
|
||||
built-in domain, and nothing at the container layer can withdraw a project
|
||||
one. Ordering them least specific first is how they are listed below and how
|
||||
they are applied, and it means the set a session gets is the union of every
|
||||
layer that applies to it, never an override of one by another.
|
||||
|
||||
| Config | Layers, least specific first |
|
||||
| --- | --- |
|
||||
| Allowed domains | built-in → global → project → container (one session) |
|
||||
| Dependencies | base image → global → project |
|
||||
|
||||
Dependencies have no container-only scope: installing one always needs an
|
||||
image rebuild, so there's no `dejima-allow`-style live-session patch
|
||||
for them.
|
||||
|
||||
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, 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
|
||||
```
|
||||
|
||||
Fetch the archive on the host and hash it. Do not take the digest from the
|
||||
same page that serves the download unless you trust that page for both:
|
||||
|
||||
```sh
|
||||
curl -fLO https://go.dev/dl/go1.25.0.linux-amd64.tar.gz
|
||||
sha256sum go1.25.0.linux-amd64.tar.gz
|
||||
```
|
||||
|
||||
Where a project publishes a checksums file or signs its releases, verifying
|
||||
against that beats hashing whatever arrived: it says the archive is the one
|
||||
the project published, not the one your connection received.
|
||||
|
||||
It unpacks with `--strip-components=1` into `/opt/dejima/<name>`, and
|
||||
`/opt/dejima/<name>/bin` goes on `PATH` - one convention covering the usual
|
||||
shapes without layout options, since a Go archive is under `go/` and an nvim
|
||||
release under `nvim-linux-<arch>/`. Compression is detected, so `.tar.gz`,
|
||||
`.tar.xz` and `.tar.zst` all work.
|
||||
|
||||
**The digest is mandatory** and is checked before anything is unpacked.
|
||||
Without one you would approve a *name* whose contents the far end can change
|
||||
afterwards. Two consequences:
|
||||
|
||||
- **A tarball dependency locks the project to one architecture**, because a
|
||||
digest does. A package name resolves per-host and a digest cannot: it names
|
||||
one archive, built for one architecture. There is no per-architecture
|
||||
tarball spec today, so a project that declares one is a project that builds
|
||||
on machines of that architecture and no others. Track that at
|
||||
[#97](https://gitbox.branderson.io/dejima/dejima/issues/97).
|
||||
- **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. That is the mechanism working, 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 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/` (`$DEJIMA_CONFIG_DIR` relocates the
|
||||
tree) - global at its root, per project under `projects/<slug>/` - written
|
||||
only by tools 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 --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**.
|
||||
The refusal is the `open` itself rather than a check before it, so it holds
|
||||
even against a session that is still running while you type the command. See
|
||||
[Declaration files are session-writable](security.md#declaration-files-are-session-writable)
|
||||
for what that defends against and how. 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. `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. `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](security.md#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.
|
||||
|
||||
`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
|
||||
inherited by subdirectories, so a git worktree under the project gets its
|
||||
parent's; global approvals apply everywhere regardless.
|
||||
|
||||
## Config parity with your host agent harness setup
|
||||
|
||||
What carries over from your host into the container, and what deliberately
|
||||
does not. The mechanism is the same whichever harness `[core] harness`
|
||||
selects; the specific paths below belong to the one harness shipped today.
|
||||
|
||||
### Claude Code
|
||||
|
||||
Global `CLAUDE.md`, settings, hooks, skills, and plugins all carry over
|
||||
automatically, and your existing login stays effective too - no separate
|
||||
first-run login, though the credential itself never enters the container
|
||||
(see [security notes](security.md) for the host-side proxy that makes
|
||||
that work).
|
||||
|
||||
`~/.claude` itself is *not* shared as a whole: its contents are enumerated
|
||||
in `docker-compose.yml`, config files are copied in rather than mounted,
|
||||
and anything not listed is container-local - writable during the session,
|
||||
gone with the container. Session transcripts, plans, and the edit-undo
|
||||
record are on the list, so `claude --resume` on the host still works; a
|
||||
credential, cache, or newly-added config file is not. The
|
||||
[security notes](security.md) explain the split and why single-file
|
||||
mounts couldn't be trusted here. (`~/.claude.json` is handled differently
|
||||
again - a throwaway per-session copy, not the real file.)
|
||||
|
||||
The project directory is mounted at the *same absolute path* inside the
|
||||
container as on the host, so Claude Code's session-history keying lines up
|
||||
between host and container: a session's transcript is visible on the host
|
||||
afterward, and `claude --resume <session-id>` should find it there. (Whether
|
||||
a bare `claude --resume` with no ID picks it as the "most recent" session
|
||||
depends on `~/.claude.json`'s own bookkeeping, which - per the throwaway-copy
|
||||
design above - doesn't carry over from a sandboxed session.)
|
||||
|
||||
The container's own user matches yours exactly - username, uid, gid, and
|
||||
home directory (see `Dockerfile`) - so `$HOME`-relative paths (dotfiles,
|
||||
Claude Code's plugin bookkeeping) resolve the same on both sides. Defaults to
|
||||
`node`/1000/1000/`/home/node` if you build the image directly instead of via
|
||||
`dejima`. Images are tagged per-user (`dejima:<username>`)
|
||||
so concurrent builds on a shared machine don't clobber each other.
|
||||
|
||||
Your global `~/.gitconfig` also carries over (as a throwaway copy, so commits
|
||||
made inside the sandbox get the right identity, but nothing written to it
|
||||
during a session reaches your real file). Falls back to an empty config if
|
||||
you don't have one.
|
||||
|
||||
Caveat: MCP servers or hooks that shell out to host-specific binaries (a
|
||||
browser, a language runtime not installed here) will not work until that
|
||||
tooling is installed in the image. Add it with `dejima-dep` rather than by
|
||||
editing the base `Dockerfile`: a dependency added that way is declared,
|
||||
approved and layered like any other, and it survives a rebuild. Editing the
|
||||
Dockerfile directly puts it outside that record.
|
||||
|
||||
## Headless git push without agent forwarding
|
||||
|
||||
**The reason to do this is identity, not uptime.** A forwarded agent
|
||||
authenticates the session **as you**: every commit and every pull request it
|
||||
produces is yours, and for as long as it runs it can reach whatever that key
|
||||
can reach. A key of its own, belonging to an account that is not you, gives
|
||||
the session an identity you can scope, revoke and read in a log separately
|
||||
from your own. See
|
||||
[To your forge, the sandbox is you](security.md#to-your-forge-the-sandbox-is-you)
|
||||
for why that matters more than it first appears.
|
||||
|
||||
Running unattended is the second reason and the lesser one. Agent forwarding
|
||||
ties SSH access to whichever client is currently connected, so it dies when
|
||||
that client disconnects and takes git-over-ssh with it for anything still
|
||||
running headless.
|
||||
|
||||
Either way, generate a dedicated, purpose-built keypair:
|
||||
|
||||
```sh
|
||||
ssh-keygen -t ed25519 -N '' -f ~/.ssh/dejima_ed25519
|
||||
```
|
||||
|
||||
Passphrase-less is deliberate - this is meant to be a low-privilege
|
||||
credential living only inside the container, not your personal identity. Add
|
||||
the public half as a Gitea deploy key scoped to just the repos that need
|
||||
push access, then set it in the config:
|
||||
|
||||
```
|
||||
[core]
|
||||
ssh_key = ~/.ssh/dejima_ed25519
|
||||
```
|
||||
|
||||
`entrypoint.sh` loads it into a container-local `ssh-agent` at startup and
|
||||
deletes the plaintext copy immediately after - the sandbox user itself never
|
||||
has a path to the raw key, only the agent socket (see
|
||||
[security notes](security.md) for the full mount rationale). Setting
|
||||
`[core] ssh_key` means a live forwarded agent is no longer required at all.
|
||||
|
||||
## Using a different editor/application
|
||||
|
||||
```sh
|
||||
dejima --cmd nvim ~/repos/some-project
|
||||
```
|
||||
|
||||
Runs unconfigured unless you also set `[mounts] nvim_config` in the config to
|
||||
a real config directory on the host (bind-mounted read-only at
|
||||
`~/.config/nvim` - lazy.nvim's lockfile is the one exception, layered
|
||||
read-write on top so plugin syncs don't fail on a read-only filesystem;
|
||||
nothing else in the config is writable):
|
||||
|
||||
```
|
||||
[core]
|
||||
command = nvim
|
||||
|
||||
[mounts]
|
||||
nvim_config = ~/.config/nvim
|
||||
```
|
||||
|
||||
Set both in `~/.config/dejima/config` if you use this mode regularly, or pass
|
||||
`--cmd nvim` per launch once `nvim_config` is set.
|
||||
|
||||
The lockfile is layered on as a **copy**, not as your file: `bin/dejima`
|
||||
mounts a `mktemp` duplicate and deletes it afterwards, so a plugin sync
|
||||
inside a session cannot rewrite the host's `lazy-lock.json`.
|
||||
|
||||
`~/.local/share/nvim` (plugin source and `mason/` binaries), `~/.local/state/
|
||||
nvim` and `~/.cache/nvim` are **Docker volumes, not host directories**. Your
|
||||
own nvim data is never mounted, so nothing a session installs can reach the
|
||||
editor you run outside the container. They do persist across containers,
|
||||
which is what stops every launch from reinstalling plugins - so they are
|
||||
named per project. Under one shared name, a session that wrote a Lua file
|
||||
under `lazy/`, or replaced a binary under `mason/bin/`, would leave something
|
||||
the next session in an *unrelated* project executed. Nothing there is
|
||||
protection against a session tampering with its own project's plugins, which
|
||||
it can do freely and which only ever runs inside the sandbox.
|
||||
|
||||
If your config references something outside that directory by absolute host
|
||||
path (a helper script in a dotfiles `bin/`, say), bring it in with
|
||||
`[mounts] readonly` (colon-separated, mounted read-only at the same path):
|
||||
|
||||
```
|
||||
[mounts]
|
||||
readonly = ~/dotfiles/bin
|
||||
```
|
||||
|
||||
## Writable host directories
|
||||
|
||||
`[mounts] read_write` is the same thing without `:ro` - for a directory a
|
||||
session is meant to write to and whose contents should outlive the container:
|
||||
|
||||
```
|
||||
[mounts]
|
||||
read_write = ~/specs
|
||||
```
|
||||
|
||||
Both lists mount at the host path they name, so a convention that says
|
||||
"specs live in `~/specs`" keeps working inside the container without being
|
||||
rewritten. That is the difference from `[core] share_dir`, which is
|
||||
mounted at `~/shared` regardless of where it lives on the host because its job
|
||||
is to be a well-known handoff point.
|
||||
|
||||
An entry is **refused, not warned about**, if it overlaps anything dejima
|
||||
relies on being out of a session's reach: the approval store, either audit
|
||||
store, `~/.claude`, `~/.local/bin`, dejima's own checkout, or the project mount
|
||||
(already read-write, so listing it again is a mistake worth surfacing). A
|
||||
writable mount over any of those would let a session approve its own domains,
|
||||
delete the audit findings naming it, or plant code that runs unsandboxed on the
|
||||
host next time you start an agent. Overlap is checked in both directions after
|
||||
resolving symlinks, so `read_write = ~` is caught too.
|
||||
|
||||
Sessions are told which directories these are, so "everything else is
|
||||
disposable" stays true in the briefing rather than becoming a trap.
|
||||
|
||||
### One-off mounts for a single launch
|
||||
|
||||
`--extra-readonly PATH` and `--extra-read-write PATH` add a path for one run
|
||||
without editing the config. `--ro` and `--rw` are the short forms. Both are
|
||||
repeatable, one path per occurrence:
|
||||
|
||||
```sh
|
||||
dejima --ro ~/other-project ~/myproject # read while migrating code out
|
||||
dejima --rw ~/scratch --ro ~/reference ~/myproject
|
||||
```
|
||||
|
||||
They **append** to the config's lists rather than replacing them, and they
|
||||
are the same lists, so everything above applies unchanged: the existence
|
||||
check, the refusal when a path overlaps something dejima keeps out of a
|
||||
session's reach, the refusal when one path appears in both lists, and the
|
||||
briefing sentence naming what a session can write. A flag says *where*, not
|
||||
*whether*, and there is no flag that waives a refusal.
|
||||
|
||||
Two differences from the config form, both because a command line is not a
|
||||
file. A relative path is accepted and made absolute against the working
|
||||
directory, which a config file has no equivalent of. And a path containing a
|
||||
colon is refused outright: both lists are colon-separated, so such a path
|
||||
cannot be represented in one at all and would otherwise split into two
|
||||
mounts that each fail the existence check.
|
||||
|
||||
## Shared scratch space
|
||||
|
||||
`~/shared` is a read-write bind mount of `[core] share_dir` (default
|
||||
`~/.local/share/dejima/shared`, created automatically) - separate
|
||||
from the project mount, for passing files (screenshots, temp scripts) in and
|
||||
out of a session without touching the actual checkout. Persists across
|
||||
sessions since it's a real directory, not a container volume.
|
||||
|
||||
Treat it as a handoff point, not as general-purpose storage. If your own
|
||||
project instructions tell agents to write somewhere at a fixed host path -
|
||||
design docs, scratch notes, anything meant to outlive the session but stay out
|
||||
of the project checkout - mount that directory instead, with
|
||||
`[mounts] read_write`. It arrives at its own absolute path, so a convention
|
||||
that says "put specs in `~/specs`" keeps working inside the container without
|
||||
being rewritten to point at `~/shared`.
|
||||
|
||||
`~/shared`, the project mount, and anything in `[mounts] read_write` are the
|
||||
only writable host paths in the container. Everything else a session writes
|
||||
goes away with it.
|
||||
|
||||
**Clear it out periodically.** It persists across sessions by design, which
|
||||
means anything you left in it is readable by every session after the one you
|
||||
left it for. A credential dropped in there to get one job done, and then
|
||||
forgotten, is a credential you have handed to every session since.
|
||||
|
||||
## Testing frontend changes
|
||||
|
||||
Playwright (Chromium) is pre-installed at image build time, browser
|
||||
included - `require("playwright")` works from any script, no network access
|
||||
or per-project `npm install playwright` needed.
|
||||
|
||||
`localhost`/loopback traffic never goes through the egress firewall, so a
|
||||
project's own dev server needs no firewall changes. A real external site
|
||||
needs to be on the allowlist like any other network use
|
||||
(`dejima-allow <domain>` for a running session, add
|
||||
`--global`/`--project <path>` to persist it) - the firewall applies to the
|
||||
browser's traffic exactly like `curl`/`git`.
|
||||
|
||||
If a project pins its own `playwright`/`@playwright/test` version, its
|
||||
`npm install` may try to download a different Chromium build from
|
||||
Playwright's CDN, which isn't allowlisted by default - allow it the same way
|
||||
as any other one-off domain need.
|
||||
|
||||
Chromium's own process sandbox isn't actually active in this image (its
|
||||
setuid helper isn't setuid here) - not a concern, since the container itself
|
||||
is already the boundary that matters, and a compromised session already has
|
||||
full shell access as the container's own user regardless.
|
||||
|
||||
## Sandbox self-awareness
|
||||
|
||||
### Project instructions: `.dejima.md`
|
||||
|
||||
`.dejima.md` at a project's root is that project's agent-instruction file -
|
||||
the same role `CLAUDE.md` plays for Claude Code - under a name that doesn't
|
||||
name a harness. dejima loads it into the session's opening context the way the
|
||||
harness would load its own, so a project's instructions apply whichever agent
|
||||
is running.
|
||||
|
||||
The reason for a second name: Claude Code finds a `CLAUDE.md` by itself, and
|
||||
nothing else does. As dejima grows the ability to run agents that aren't Claude
|
||||
Code, a project that wants to say "here is how to work on me" needs one file
|
||||
that reaches all of them - so dejima delivers it rather than leaving each
|
||||
harness to go looking for its own.
|
||||
|
||||
Loaded verbatim, capped at 16 KB with any truncation noted in the text. It
|
||||
carries no authority over the sandbox itself: the file sits on the read-write
|
||||
workspace mount, so nothing in it widens the firewall, relaxes a guard, or
|
||||
authorises a push, and the text delivered around it says so.
|
||||
|
||||
This repo ships one, describing which half of its own test suite a sandboxed
|
||||
session can run.
|
||||
|
||||
### The session briefing
|
||||
|
||||
A `SessionStart` hook (`~/.claude/hooks/sandbox-context.sh`) injects a
|
||||
reminder when `DEJIMA` is set: the session is running in a
|
||||
disposable, firewalled container and should stay within the task at hand -
|
||||
if something genuinely needs to break out (broader network, host changes,
|
||||
GUI/hardware), stop and ask rather than trying to work around the firewall,
|
||||
since the same session can be resumed unrestricted on the host via
|
||||
`claude --resume`. No-op outside the container. `deny-dangerous-bash.sh`
|
||||
(`PreToolUse`) also applies here, since `~/.claude/hooks` is mounted in.
|
||||
|
||||
`git-push-guard.sh` (`PreToolUse`/`Bash`, also `DEJIMA`-gated) blocks
|
||||
any `git push` whose resolved remote isn't the Gitea host, and any push to
|
||||
`main`/`master` even there. Like the other hooks, this is a fast-fail guard
|
||||
against an overeager agent, not a hard security boundary - a session has full
|
||||
shell access and could in principle edit `.git/config` to route around it.
|
||||
GitHub push is blocked by default too (see
|
||||
[GitHub push](security.md#github-push-blocked-by-default-optional-opt-in) in
|
||||
docs/security.md) unless `--allow-github-push` is given. For a real guarantee
|
||||
against pushes to main/master, use branch protection on the remote itself.
|
||||
|
||||
`merge-guard.sh` (`PreToolUse`/`Bash`, `DEJIMA`-gated) blocks
|
||||
`gitea-pr merge` inside the sandbox. It's the API-side counterpart to the rule
|
||||
above: a merge puts a branch on `main` without ever performing a push, so
|
||||
`git-push-guard.sh` never sees it. A hook is handed a command rather than a
|
||||
conversation, so it can't tell an instructed merge from an unprompted one -
|
||||
which is why it's scoped to the sandbox rather than applied everywhere. On the
|
||||
host it does nothing, and you can still ask an agent to merge. The same
|
||||
fast-fail caveat applies with more force than usual here: the session has the
|
||||
Gitea token in its environment and could call the merge endpoint with `curl`
|
||||
directly. Branch protection on the remote is the real guarantee.
|
||||
|
||||
`sandbox-context.sh` also tells the agent the actual workflow to follow: do
|
||||
new work in a `git worktree` under the mounted project (e.g.
|
||||
`.worktrees/<branch>`, never `../<branch>` or elsewhere under `$HOME` - only
|
||||
the bind-mounted path is backed by real host storage, so a worktree created
|
||||
outside it silently vanishes with the container), then push the feature
|
||||
branch and run `gitea-pr create`.
|
||||
@@ -0,0 +1,133 @@
|
||||
# Development
|
||||
|
||||
[← dejima README](../README.md)
|
||||
|
||||
The file-by-file map of this repository, and the detail behind its test
|
||||
suites: what each one covers, whether it needs a Docker daemon, and how to
|
||||
run them.
|
||||
|
||||
## Files
|
||||
|
||||
| File | Purpose |
|
||||
| --- | --- |
|
||||
| `docker/Dockerfile` | Base image, dev tools, nvim, Claude Code + wrapper, `gitea-pr`/`gitea-issue` |
|
||||
| `docker/fixed-domains.sh` | Shared domain list sourced by both `init-firewall.sh` and `dns-proxy.sh` |
|
||||
| `docker/dns-proxy.sh` | Starts the DNS-allowlisting `dnsmasq`, repoints `resolv.conf` at it |
|
||||
| `docker/init-firewall.sh` | IPv4/IPv6 egress allowlist firewall, run at container start |
|
||||
| `docker/ulogd.conf` | `ulogd2` config: turns `init-firewall.sh`'s NFLOG-tagged packets into JSON |
|
||||
| `docker/log-shipper.sh` | Tails the dnsmasq/ulogd2 logs and ships them to the configured Loki endpoint (`[logging] loki_url`); also ships session transcripts when conversation logging (`[logging] conversation`) is on |
|
||||
| `docker/entrypoint.sh` | Runs as root: DNS proxy, firewall, logging, drops to the sandbox user via `gosu`, then execs the session's command |
|
||||
| `docker/claude-wrapper.sh` | Shadows `claude` on `PATH` to always add `--dangerously-skip-permissions` |
|
||||
| `docker/docker-compose.yml` | Wires up mounts/env; invoked via `bin/dejima`, not directly |
|
||||
| `hooks/sandbox-context.sh` | `SessionStart` hook: injects sandbox context/workflow reminders, and any `.dejima.md` the project ships |
|
||||
| `.dejima.md` | Project-level agent instructions, harness-agnostic: `CLAUDE.md`'s role under a neutral name. dejima loads a project's into the session context; this one is dejima's own |
|
||||
| `hooks/git-push-guard.sh` | `PreToolUse` hook: blocks pushes outside the allowed host(s)/branches |
|
||||
| `hooks/deny-dangerous-bash.sh` | `PreToolUse` hook: blocks obviously destructive Bash commands |
|
||||
| `hooks/merge-guard.sh` | `PreToolUse` hook: blocks the configured forge's merge command (`DEJIMA_MERGE_BLOCK`) in the sandbox: merging is a human decision |
|
||||
| `hooks/dejima-notify.sh` | `Notification`/`Stop` hook: drops an empty `waiting`/`done` marker for a remote-enabled session - see [docs/remote-access.md](remote-access.md) |
|
||||
| `bin/dejima-attach` | Lists live sessions and attaches to one; the shared core behind both remote front doors |
|
||||
| `bin/dejima-audit` | Advisory exit-time diff of host-persistence surfaces (git config, `.claude/`, etc.). `dejima-audit show` / `dejima-audit ack` default to the enclosing repo |
|
||||
| `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](providers.md) |
|
||||
| `forges/`, `workflows/`, `harnesses/` | Provider directories for the three axes selected by `[core] forge`/`workflow`/`harness` (data, never sourced or executed); see [docs/providers.md](providers.md) |
|
||||
| `forges/gitea/bin/` | The gitea forge's own CLIs, baked into the image so the sandbox can act on them: `gitea-pr` (create a PR, read its comments, merge it), `gitea-issue` (list, view, create, comment on, close and reopen issues) and `gitea-actions` (read CI runs and job logs) |
|
||||
| `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-slug-lib.sh` | The `~/.claude/projects` slug scheme and which slugs belong to a workspace; sourced by `bin/dejima` (what to mount) and `bin/dejima-audit` (what counts as cross-project), so the two cannot disagree. Not on PATH |
|
||||
| `bin/dejima-ttyd` | Launches `ttyd` on loopback in front of `dejima-attach` |
|
||||
| `systemd/*.service` | User units for `dejima-ttyd` and `dejima-notify-watch`; ship disabled, installed by hand |
|
||||
| `docs/providers.md` | The manifest contract for the forge/workflow/harness axes, key by key, with the shipped manifests as worked examples |
|
||||
| `docs/ci.md` | What push access grants a session on your CI host, and how to bound it - runner hardening, and an egress allowlist read from `main` |
|
||||
| `docs/nginx-dejima.conf.example` | Reverse-proxy template for the web front door; deliberately not loadable as written |
|
||||
|
||||
`bin/dejima` is the entry point: it resolves the project path,
|
||||
checks for a live `ssh-agent`, reads `GITEA_TOKEN`/`GH_TOKEN` from the token
|
||||
files named in the config, discovers which host directories need mounting for symlinks under
|
||||
`~/.claude` (`hooks`, `skills`, `commands`, `agents`, `output-styles`, and
|
||||
top-level entries like `statusline.sh`) to resolve, and runs
|
||||
`docker compose run`. `bin/dejima-allow`
|
||||
adds a domain to a running session's firewall from the host - see
|
||||
[Adding a domain to a running session](security.md#adding-a-domain-to-a-running-session).
|
||||
`install.sh` installs the hooks into
|
||||
`~/.claude/hooks` and registers them in `~/.claude/settings.json`.
|
||||
|
||||
## Tests
|
||||
|
||||
`test/` has a [bats](https://github.com/bats-core/bats-core) suite covering
|
||||
the security invariants the README states, not a substitute for reading it,
|
||||
but a way to actually catch a future change silently breaking one of them
|
||||
instead of finding out the hard way.
|
||||
|
||||
`static.bats` greps the repo's own files - no sudo in the `Dockerfile`, every
|
||||
`ipset add` is duplicate-safe, no `docker.sock`/host-credential mounts, the
|
||||
`~/.claude` mount inventory is exactly what it should be, `dejima-allow`
|
||||
rejects a malicious domain argument. `runtime.bats` builds the image, boots
|
||||
a real container running the actual `entrypoint.sh` (a `SANDBOX_TEST_MODE`
|
||||
env var makes it stop just short of execing the session's command, so the suite can attach
|
||||
to a fully-initialized container instead of reimplementing its own copy of
|
||||
the setup sequence), and asserts against it: IPv6 blocked, DNS/IP allowlist
|
||||
enforcement in both directions, GitHub read-only, no working `sudo`/
|
||||
`iptables`/`init-firewall.sh` as `node`, capabilities actually stripped
|
||||
(not just absent from a `docker exec` that doesn't have them to begin
|
||||
with), `ulogd2` unprivileged, and the dynamic-ipset CDN-rotation fix.
|
||||
|
||||
`project.bats`, `remote.bats` and `audit.bats` need no Docker either.
|
||||
`project.bats` covers the
|
||||
declaration/approval split - what `.dejima/` may ask for, what only the host
|
||||
can grant, and that the approval store is never mounted. `remote.bats`
|
||||
covers everything in [docs/remote-access.md](remote-access.md):
|
||||
the `[remote-access]` config section, the session registry, and the properties the
|
||||
feature's security rests on - that the
|
||||
notification path carries no sandbox-controlled byte, and that no marker
|
||||
name outside `waiting`/`done` is ever acted on. `audit.bats` covers
|
||||
`bin/dejima-audit`'s own diffing.
|
||||
|
||||
`config.bats`, `providers.bats` and `proxy.bats` round out the suites that
|
||||
need no Docker either: `config.bats` covers `~/.config/dejima/config`
|
||||
parsing and `install.sh`'s legacy-variable warnings (stubbing `docker` itself,
|
||||
since the fast CI job's container deliberately has no daemon), `providers.bats`
|
||||
covers the forge/workflow/harness manifest contract documented in
|
||||
[docs/providers.md](providers.md), and `proxy.bats` covers the credential
|
||||
proxy's own request handling.
|
||||
|
||||
One assertion is worth calling out because it can only run for real on a
|
||||
Docker host: `runtime.bats` proves that a service bound to the host's
|
||||
loopback is unreachable from inside a container while one bound to the
|
||||
bridge gateway *is* reachable. The second half is a positive control - it is
|
||||
what stops the first from passing in an environment where the container
|
||||
reaches nothing at all. That pair is the ttyd front door's whole containment
|
||||
argument, so run it before trusting a front door you just set up.
|
||||
|
||||
Every mutation in `runtime.bats` (firewall rules, `ipset` entries) happens
|
||||
inside the test container's own network namespace, same as any other
|
||||
container - none of it touches the host. Container/network names are
|
||||
derived from bats' own per-run temp directory, not anything guessable or
|
||||
likely to collide with a real container you have running.
|
||||
|
||||
`ci-runner.bats` is different in kind from the others: it doesn't test
|
||||
this repository at all, it asserts on the CI runner executing it (the Docker
|
||||
socket withheld from the confined job, granted to the privileged job, and
|
||||
the egress each is allowed) - see [docs/ci.md](ci.md). It skips outright
|
||||
unless `DEJIMA_CI_TIER` is set, so it has nothing to say when run locally.
|
||||
|
||||
### Running the tests
|
||||
|
||||
```sh
|
||||
bats test/ # everything
|
||||
bats test/audit.bats # one suite - the suites are independent, see .dejima.md
|
||||
SKIP_BUILD=1 bats test/runtime.bats # the one suite needing Docker; reuse the current image
|
||||
```
|
||||
|
||||
`runtime.bats` is the only suite that needs a Docker daemon: it is the one
|
||||
that builds the image and boots real containers to assert against.
|
||||
Everything else, `audit.bats`, `ci-runner.bats`, `config.bats`,
|
||||
`project.bats`, `providers.bats`, `proxy.bats`, `remote.bats` and
|
||||
`static.bats`, runs anywhere a shell does, including inside a dejima sandbox
|
||||
itself, which has no Docker daemon by design.
|
||||
|
||||
`.gitea/workflows/tests.yml` runs all of it on every pull request and on every
|
||||
push to one, `runtime.bats` included. That job needs two things from the
|
||||
runner that an ordinary one doesn't (the host's Docker socket and the host
|
||||
network namespace), and it checks for both up front rather than failing
|
||||
obscurely partway through; the file says why each is needed.
|
||||
+90
-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,80 @@ 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 is refused as a likely
|
||||
typo, but only when the workflow pushes. A key written but left empty
|
||||
counts as not set - it resolves to nothing, and every call made with it
|
||||
would fail inside the container. A non-pushing workflow never invokes the
|
||||
forge, so gitea's `ssh_host` alone (set only so the firewall allows
|
||||
fetches) is legitimate. A required key with a non-empty schema default -
|
||||
gitea's `token_file` - never counts as missing, since its resolved value is
|
||||
correct even unwritten. A wholly unconfigured forge is the case above, not
|
||||
this one.
|
||||
- A pushing workflow whose forge has **none** of its required settings is
|
||||
refused too, even where `fixed_hosts` stops the "resolves to no host" rule
|
||||
firing. This catches `github`: with a single `requires` key there is
|
||||
nothing to be partial between, so without this rule a pushing workflow
|
||||
could select it with no `push_token_file` and launch anyway, `fixed_hosts`
|
||||
masking that nothing was 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.
|
||||
## Shipped providers
|
||||
|
||||
## Providers not yet built
|
||||
| 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 |
|
||||
|
||||
`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.
|
||||
**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`.
|
||||
|
||||
+32
-39
@@ -44,7 +44,7 @@ What it does **not** add is a way *out* of a sandbox. Configured as
|
||||
described below, a compromised session cannot reach `ttyd`, cannot reach
|
||||
your proxy, and gains nothing from the fact that your phone can: it has a
|
||||
route to exactly one host address, and neither front door is on it. That
|
||||
property is worth stating precisely because it is the one a plausible-looking
|
||||
property needs stating exactly, because it is the one a plausible-looking
|
||||
mistake inverts - a proxy bound to `0.0.0.0` or to a bridge address is a
|
||||
front door opening *inward*, and that is a full sandbox escape rather than a
|
||||
degradation. [Verifying it](#verifying-it) is two commands, and worth
|
||||
@@ -69,7 +69,7 @@ approvals):
|
||||
scheme; a value without one is refused rather than guessed at, because
|
||||
`curl` reads a leading `-K/path` as an *option* that takes the request's URL
|
||||
from that file instead. Values are restricted to
|
||||
`A-Za-z0-9:/._~?=%@+-` — no whitespace and nothing shell-active, since these
|
||||
`A-Za-z0-9:/._~?=%@+-`: no whitespace and nothing shell-active, since these
|
||||
end up in an HTTP header and in a URL handed to a phone.
|
||||
|
||||
**A malformed config is a hard failure, not a warning.** The whole file is
|
||||
@@ -304,7 +304,7 @@ also what catches a distro `ttyd.service` still running on that port, which
|
||||
is why this is worth doing even if you are confident about the proxy. `test/runtime.bats`
|
||||
asserts this same containment property against a live container (with a
|
||||
positive control on the same path, so a passing result can't come from the
|
||||
container simply reaching nothing), and it passes on a real Docker host.
|
||||
container reaching nothing), and it passes on a real Docker host.
|
||||
|
||||
## SSH front door
|
||||
|
||||
@@ -442,7 +442,7 @@ and forgotten. What matters in it:
|
||||
the one you have - just make sure it doesn't fall through to this vhost.
|
||||
- **TLS.** HTTP Basic sends the password on every request, and the terminal
|
||||
itself carries whatever the session is working on.
|
||||
- **`proxy_set_header Host $http_host;`** — `$http_host`, not `$host`.
|
||||
- **`proxy_set_header Host $http_host;`**: `$http_host`, not `$host`.
|
||||
`--check-origin` compares the client's `Origin` against the `Host` header,
|
||||
and nginx's `$host` has the port stripped while `ttyd` keeps the port in
|
||||
what it compares unless it is 80 or 443. On a vhost listening anywhere
|
||||
@@ -700,16 +700,14 @@ what the outer proxy sends.
|
||||
|
||||
## Notifications
|
||||
|
||||
`dejima` registers `hooks/dejima-notify.sh` as a `Notification` and
|
||||
`Stop` hook for a remote-enabled session - into the throwaway settings
|
||||
override layer it already generates for that session, never into your real
|
||||
`~/.claude/settings.json`, which every sandbox and your host's own Claude
|
||||
Code read directly. Your own `Notification` and `Stop` hooks keep running
|
||||
alongside it: Claude Code merges hooks across settings layers rather than
|
||||
letting the higher one replace the lower. That layer is mounted read-only, so a
|
||||
session cannot unregister its own hook. The hook writes one empty file, named `waiting` or
|
||||
`done`, into this run's own bind-mounted marker directory. No content, no
|
||||
stdin, no network.
|
||||
`dejima` registers `hooks/dejima-notify.sh` as a `Notification` and `Stop`
|
||||
hook for a remote-enabled session, into the throwaway settings override layer
|
||||
it already generates - never into your real `~/.claude/settings.json`. Your
|
||||
own hooks keep running alongside it, since Claude Code merges hooks across
|
||||
layers rather than replacing. The layer is mounted read-only, so a session
|
||||
cannot unregister its own hook. The hook writes one empty file named
|
||||
`waiting` or `done` into this run's marker directory: no content, no stdin,
|
||||
no network.
|
||||
|
||||
On the host, `dejima-notify-watch` turns those markers into ntfy pushes.
|
||||
Same installation shape as above:
|
||||
@@ -726,14 +724,13 @@ loop plus a five-second sleep). Repeat markers for the same session and
|
||||
event inside 60 seconds are debounced (`[remote-access] debounce` overrides
|
||||
that).
|
||||
|
||||
One environment gotcha, because it fails silently: the watcher and
|
||||
`dejima` both resolve the marker root as `${XDG_RUNTIME_DIR:-/tmp}/dejima`,
|
||||
so they only meet when they agree on `XDG_RUNTIME_DIR`. systemd sets it for
|
||||
user units and a login shell gets it from pam_systemd, but a `dejima`
|
||||
run from a context that has neither - a bare `su`, a cron job, a tmux server
|
||||
started before login - writes its markers under `/tmp/dejima` while the unit
|
||||
watches `/run/user/<uid>/dejima`, and no push is ever sent, with nothing
|
||||
logged to say so. `systemctl --user show-environment` is where to check.
|
||||
One gotcha, because it fails silently: both sides resolve the marker root as
|
||||
`${XDG_RUNTIME_DIR:-/tmp}/dejima`, so they only meet when they agree on
|
||||
`XDG_RUNTIME_DIR`. systemd sets it for user units and a login shell gets it
|
||||
from pam_systemd, but a run from a context with neither - a bare `su`, cron,
|
||||
a tmux server started before login - writes to `/tmp/dejima` while the unit
|
||||
watches `/run/user/<uid>/dejima`. No push is sent and nothing is logged.
|
||||
Check with `systemctl --user show-environment`.
|
||||
|
||||
The same directory is why you probably want lingering on:
|
||||
|
||||
@@ -741,23 +738,19 @@ The same directory is why you probably want lingering on:
|
||||
loginctl enable-linger "$USER"
|
||||
```
|
||||
|
||||
Both units here are *user* units. Without lingering they are stopped at
|
||||
logout and `/run/user/<uid>` is destroyed with the session, taking the
|
||||
session registry and every marker with it - so a sandbox left running in a
|
||||
detached tmux session has no front door and sends no notification until you
|
||||
log in again. It fails closed rather than open (nothing is left listening
|
||||
unguarded), but it fails invisibly, and it is the same `XDG_RUNTIME_DIR`
|
||||
that the gotcha above is about.
|
||||
Both are *user* units. Without lingering they stop at logout and
|
||||
`/run/user/<uid>` is destroyed with the session, taking the registry and
|
||||
every marker with it - so a sandbox left in a detached tmux session has no
|
||||
front door and sends nothing until you log in again. It fails closed, but
|
||||
invisibly, and on the same `XDG_RUNTIME_DIR` as the gotcha above.
|
||||
|
||||
**The marker directory is a write channel into your runtime tmpfs**, and
|
||||
nothing bounds it. `/run/user/<uid>` is RAM-backed and sized at a fraction
|
||||
of physical memory, and each session gets its own directory under it
|
||||
bind-mounted read-write, so a session that writes into `/run/dejima-notify`
|
||||
until it is full degrades your whole login session - D-Bus, the systemd user
|
||||
manager - and not just dejima. A session can already fill the workspace
|
||||
mount, so this is an increment rather than a new kind of exposure, but a
|
||||
RAM-backed filesystem fails harder and sooner than a disk. There is
|
||||
deliberately no quota here; `rm -rf` on that run's marker directory (or
|
||||
nothing bounds it. `/run/user/<uid>` is RAM-backed and sized at a fraction of
|
||||
physical memory, so a session filling `/run/dejima-notify` degrades your
|
||||
whole login session - D-Bus, the systemd user manager - not just dejima. A
|
||||
session can already fill the workspace mount, so this is an increment rather
|
||||
than a new exposure, but a RAM-backed filesystem fails harder and sooner.
|
||||
There is deliberately no quota here; `rm -rf` on that run's marker directory (or
|
||||
logging out, which discards the tmpfs) is the recovery.
|
||||
|
||||
**What a notification contains.** A title of `dejima: <project> needs input`
|
||||
@@ -801,8 +794,8 @@ the next one is worth checking.
|
||||
`grep -c dejima-notify /etc/dejima-settings.json` from inside should be 2.
|
||||
That file is passed to Claude Code as `--settings` by `claude-wrapper.sh`;
|
||||
if it is missing entirely, this container was not started by `dejima`.
|
||||
3. **Does the marker reach the host?** This is the one worth knowing, because
|
||||
it splits the chain cleanly in half. From inside the sandbox:
|
||||
3. **Does the marker reach the host?** This is the one to check first: it
|
||||
splits the chain cleanly in half. From inside the sandbox:
|
||||
|
||||
```sh
|
||||
: > "$DEJIMA_NOTIFY_DIR/waiting" && sleep 2 && ls "$DEJIMA_NOTIFY_DIR"
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
# Using this responsibly
|
||||
|
||||
[← dejima README](../README.md)
|
||||
|
||||
The README states each of these ten rules in a sentence or two. This is the
|
||||
part that didn't fit: why each one matters, what goes wrong without it, and
|
||||
the detail behind the three the README could only gesture at.
|
||||
|
||||
## Check whether they want it first.
|
||||
|
||||
"They didn't ask for this" is a consent problem, not a quality problem. A
|
||||
project that has said it doesn't accept AI-assisted contributions has said so
|
||||
for reasons that have nothing to do with whether a given patch is good: review
|
||||
load, licensing questions, a maintainer who simply doesn't want to referee
|
||||
that argument on every pull request. A well-written, well-tested change
|
||||
doesn't override a stated no, any more than good manners override a locked
|
||||
door. Read CONTRIBUTING (or the equivalent policy file, or the pinned issue,
|
||||
wherever a project keeps it) before you start work, because finding out after
|
||||
means the work is already done and now has nowhere to go.
|
||||
|
||||
## Don't point it at repositories you don't own.
|
||||
|
||||
The concrete workflow, step by step:
|
||||
|
||||
1. Fork the project on its forge.
|
||||
2. Point `bin/dejima` at your fork: run it from a local clone whose `origin`
|
||||
is your fork, not the upstream repository.
|
||||
3. Let the session do its work there. With a pushing workflow configured, it
|
||||
opens branches, and pull requests, against your fork, not upstream.
|
||||
4. Review the agent's branch yourself, the same way you'd review anyone
|
||||
else's diff before merging it.
|
||||
5. Merge it to your fork's own `main`.
|
||||
6. Open the upstream pull request by hand from there, once you would have
|
||||
been glad to have written it yourself.
|
||||
|
||||
The reason for the extra hop is what happens without it. An auto-push
|
||||
workflow aimed straight at someone else's repository puts a session's raw
|
||||
output directly into that project's pull request queue, unreviewed by
|
||||
anyone, the moment it opens. Maintainers already spend real time triaging a
|
||||
flood of low-effort AI-generated pull requests; a Godot maintainer's account
|
||||
of what that actually costs is worth reading in full:
|
||||
<https://bsky.app/profile/akien.bsky.social/post/3meyerixvhs2p>. Forking and
|
||||
reviewing first doesn't change what the session is capable of writing. It
|
||||
changes who reads the output before anyone else has to: you, not a stranger
|
||||
who owns the project.
|
||||
|
||||
## Don't point it at proprietary code.
|
||||
|
||||
dejima narrows the paths code has to leave through; it doesn't close them,
|
||||
and "point it only at code you could survive losing" is the practical test
|
||||
for that gap. Ask it plainly before you start a session, not after: if this
|
||||
checkout's contents ended up somewhere they shouldn't, would that be a bad
|
||||
week or the end of the business? Code you could survive losing may be worth
|
||||
the convenience of unattended agentic work. Code you couldn't shouldn't go in
|
||||
at all.
|
||||
|
||||
That second one is not a judgement about how good the containment is, and it
|
||||
does not get better with a version bump. There is no containment that fully
|
||||
prevents exfiltration: a determined attacker with code execution and any
|
||||
outbound path will find a way. So the question is not whether you trust
|
||||
dejima enough, but whether you can accept the risk of the contents leaving.
|
||||
If you cannot, do not hand them to an agent that could send them.
|
||||
|
||||
## Look at what else is in the directory.
|
||||
|
||||
The bind mount doesn't know which files you meant to hand over. It exposes
|
||||
the whole checkout, and everything a working tree accumulates over its life
|
||||
sits there with the same reachability as the source files you actually care
|
||||
about: a `.env` a teammate left uncommitted, a database dump pulled down for
|
||||
local debugging and never deleted, a credential baked into a fixture file,
|
||||
an old key still sitting in the history of a branch that got checked out.
|
||||
None of that is code in any sense that matters to the agent; all of it is
|
||||
just bytes in a directory the session can read and any tool call it makes
|
||||
can touch.
|
||||
|
||||
Auditing a working tree by eye is not a minute's work, and it is the kind of
|
||||
check that succeeds every time until the once it matters. **Point dejima at a
|
||||
fresh clone instead.** A clone contains what is in version control and
|
||||
nothing else, so the whole class goes away rather than being searched for. If
|
||||
a directory might have ignored files in it worth protecting, that is the
|
||||
directory not to hand over.
|
||||
|
||||
## An allowlist entry is a grant, not a convenience.
|
||||
|
||||
For every allowlist entry you give a dejima agent, consider what an attacker
|
||||
could do with it. The answer varies enormously between entries that look
|
||||
similar, and what it turns on is what happens if the call is wrong, whether
|
||||
wrong means a prompt-injected instruction or an ordinary agent mistake.
|
||||
|
||||
A package registry or a docs site can be wrong without being expensive. A
|
||||
session that fetches the wrong package version, or scrapes a stale or
|
||||
misleading page, produces bad input to more code, code that still has to
|
||||
pass through your own review before any of it ships. The mistake is
|
||||
contained by the same diff you were always going to read.
|
||||
|
||||
A deploy webhook, a production database API, and a payment provider are a
|
||||
different shape entirely. A single call against any of them is the action,
|
||||
not a step toward one you'll review later: the deployment already ran, the
|
||||
row is already written or gone, the charge already happened. There is no
|
||||
diff downstream that catches it, because nothing about it passes through
|
||||
your repository at all. Allowlisting one of these gives an unattended process
|
||||
standing authority to act on the world, the same authority you would think
|
||||
twice about handing a person.
|
||||
|
||||
Ask, before adding an entry: if this call happened by mistake, is the result
|
||||
something I fix in the next commit, or something I have to explain to
|
||||
someone?
|
||||
|
||||
## Safe and responsible are not the same thing.
|
||||
|
||||
"Safe" is a property of the sandbox: what a session inside it can reach.
|
||||
"Responsible" is a property of what you let out of it. A session can be
|
||||
contained perfectly and still produce something that should not be merged,
|
||||
and the sandbox has no opinion either way.
|
||||
|
||||
This point is about the merge, not about code quality in general. What makes
|
||||
`main` (or `master`, or whatever your default branch is called) different
|
||||
from any other branch is that other things read it and believe it. It is
|
||||
worth being precise about why, rather than saying "the released version" in
|
||||
some vaguer sense. In this repository, dejima-ci-hardener resolves the
|
||||
CI-side egress
|
||||
allowlist by reading `.dejima/allowed-domains` from `origin/main`, never
|
||||
from a branch or a local checkout, and treats whatever it finds there as
|
||||
already reviewed. That trust is load-bearing: the only way content lands on
|
||||
`main` is through a pull request you merged, so reading from `main` is how
|
||||
the tool avoids asking a branch what it's allowed to do. So merging onto
|
||||
`main` ships it to users and feeds a trust anchor that other tooling, this
|
||||
one and potentially future ones, is entitled to rely on without re-checking.
|
||||
Keeping insecure or broken code off `main` is what makes that reliance
|
||||
justified, and that job is yours; nothing about running the agent in a
|
||||
sandbox does it for you.
|
||||
|
||||
## Give it its own identity, then own what you merge.
|
||||
|
||||
The reason to separate identities is that forge-level controls key on who
|
||||
authenticated, not on what changed. By default the sandbox authenticates as
|
||||
you, so every control your forge offers that answers "who did this" (a merge
|
||||
whitelist, a push whitelist, required approvals) admits the session exactly
|
||||
as it admits you; a required approval becomes unusable outright, since a
|
||||
forge won't let a pull request's own author approve it, and the session
|
||||
opens pull requests as you. Giving the sandbox a dedicated forge account
|
||||
(see [docs/security.md](security.md#to-your-forge-the-sandbox-is-you) for
|
||||
how) is what turns those settings from decoration back into something that
|
||||
actually holds a session to a smaller set of permissions than yours.
|
||||
|
||||
Once the identity is separate, every commit and pull request the session
|
||||
produces is visibly labeled as the agent's own work, not yours, in your
|
||||
project's history. That visibility makes your responsibility legible without
|
||||
shrinking it. Anyone reading your `main` later sees your name on the merge
|
||||
commit regardless of whose name is on the authored one, and will hold you to
|
||||
it accordingly.
|
||||
|
||||
## Understand what you ship.
|
||||
|
||||
The risk here is habituation, not a single lapse. dejima makes iterating
|
||||
cheap enough that reading every diff starts to feel like the slow part of
|
||||
the loop, and skipping it works fine, repeatedly, until the one time it
|
||||
doesn't. A reviewer who reads every change catches most of what's wrong with
|
||||
it. A reviewer who only reads the ones that felt risky catches none of the
|
||||
ones that didn't feel risky, which is the shape a subtle bug, or a
|
||||
prompt-injected change dressed up as something ordinary, takes. Nothing
|
||||
about a faster loop changes what engineering judgment is for.
|
||||
|
||||
## This is not a blastproof box.
|
||||
|
||||
Read this rule as a statement about what kind of failure to plan for.
|
||||
dejima's containment is Linux namespaces and a small capability set around
|
||||
the session, the same kind of isolation a container gives any process, not a
|
||||
virtual machine boundary; it shares the host kernel with everything else on
|
||||
the machine.
|
||||
That buys real things (see [docs/security.md](security.md) for the specific
|
||||
list: no working `sudo`, a deny-by-default firewall, read-only mounts over
|
||||
the surfaces that matter most), and every one of them assumes the container
|
||||
itself stays intact. A determined attacker, or a single container-escape
|
||||
vulnerability, doesn't erode that protection gradually; it removes every
|
||||
layer described in that document at once, because they all sit on the same
|
||||
kernel. So the assumption to hold is not that the walls will probably hold.
|
||||
It is that if this container is ever compromised at the kernel level,
|
||||
everything reachable from this host account should be treated as compromised
|
||||
with it, which is a very different thing to plan around than an ordinary bug.
|
||||
|
||||
## dejima's security says nothing about your software's.
|
||||
|
||||
These are different axes, not different degrees of the same thing. dejima's
|
||||
threat model is about the session: what a process running inside the
|
||||
sandbox can reach, exfiltrate, or execute while it runs. None of that has
|
||||
anything to say about the software that session writes once it's on disk.
|
||||
dejima can contain a session perfectly and that session can still write a
|
||||
SQL injection, hand-roll a broken authentication check, hardcode a secret,
|
||||
or pull in a compromised dependency, because those are properties of the
|
||||
code itself, decided at the moment it's written, not something a sandbox
|
||||
sitting around the process that wrote it can retrofit afterward. If what got
|
||||
built inside dejima turns out to be insecure, containment was never the
|
||||
control that was supposed to catch it.
|
||||
|
||||
**It is not something a future agent can retrofit either.** "We will do a
|
||||
security pass later" was already a weak plan before agents made it cheap to
|
||||
say. Early architectural decisions
|
||||
close off the fixes for whole classes of problem: where trust boundaries sit,
|
||||
what is authenticated and where, what data crosses which process, what a
|
||||
single compromised component reaches. A later pass can find a missing bounds
|
||||
check. It cannot move a boundary you built a system on top of. And insecure
|
||||
implementation decisions sit in software for years without announcing
|
||||
themselves, which is exactly why they are not found by a pass that goes
|
||||
looking once.
|
||||
|
||||
So security has to be considered at every point in the work, not scheduled
|
||||
as a phase, and dejima will not help you do that. It makes writing a lot of
|
||||
code fast, which makes writing a lot of insecure code fast if nobody is
|
||||
holding that concern. That responsibility is yours at every step, and it is
|
||||
the one thing here no tooling in this repository is aiming at.
|
||||
|
||||
The same practices you'd apply to code from any other source, review,
|
||||
linting, dependency scanning, tests aimed at the failure modes you care
|
||||
about, still have to happen here. dejima does not stand in for any of them.
|
||||
+662
-174
@@ -10,6 +10,140 @@ 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.
|
||||
|
||||
**And none of it has been reviewed by anyone else.** This is early
|
||||
development: the reasoning here is one person's, the tests assert what that
|
||||
person thought to check, and no outside audit or adversarial testing has
|
||||
happened. Read what follows as claims awaiting scrutiny rather than claims
|
||||
that have survived it. See [SECURITY.md](../SECURITY.md).
|
||||
|
||||
## 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
|
||||
[docs/configuration.md](configuration.md).
|
||||
|
||||
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.
|
||||
|
||||
**Write access, and specifically not ownership.** Add the account as a
|
||||
collaborator on the repositories it works on. Write is the ceiling here, not
|
||||
a floor: an account that owns a repository, or holds admin on it, can merge
|
||||
its own work and edit the settings in the table above, so every row of that
|
||||
table goes back to being decorative. `write:repository` and `write:issue` are
|
||||
the scopes its token needs (README, "Gitea/Forgejo PR workflow"), and nothing
|
||||
about that changes for a collaborator.
|
||||
|
||||
One consequence presents as a misconfiguration, and the obvious fix is the
|
||||
wrong one. Parts of Gitea's API are gated on *ownership* rather than on
|
||||
permission: `GET
|
||||
/repos/{owner}/{repo}/actions/runs` answers a non-owner with `"user should be
|
||||
the owner of the repo"` no matter which scopes the token carries. Read that
|
||||
way it looks like a scope error, and the reflex is to widen the token. That
|
||||
cannot work, since no scope grants ownership, and its only real remedy is
|
||||
handing the sandbox your own token, which would undo this whole section.
|
||||
|
||||
`gitea-actions` therefore reads CI through `actions/tasks`, the commit's
|
||||
statuses and the run view's log route, none of which are owner-gated. If some
|
||||
future need lands on an owner-only endpoint, leaving that capability
|
||||
unavailable is the better trade than promoting the account.
|
||||
|
||||
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 +155,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.
|
||||
@@ -69,6 +204,8 @@ hands to whichever session starts against it. See
|
||||
overlaid (they're config, not fixed paths a static mount can cover) and
|
||||
remain fully writable; the exit-time audit below reports changes to
|
||||
either, but only after the session ends - it doesn't stop them.
|
||||
### The exit-time audit
|
||||
|
||||
- `bin/dejima-audit` snapshots host-persistence surfaces before each
|
||||
run and diffs them after the container exits: `.git/config` (and worktree
|
||||
`config.worktree`) keys that run a command on the next `git` -
|
||||
@@ -82,25 +219,57 @@ hands to whichever session starts against it. See
|
||||
changes under the workspace's `.claude/`, `.mcp.json`, `.envrc`,
|
||||
`.vscode/`, and `.git/hooks/` (symlinks included - a repointed symlink is
|
||||
hashed by its target, not just its content), plus cross-project writes
|
||||
under `~/.claude/projects` (see below). When the mounted workspace *is*
|
||||
this repo's own sandbox itself (see the trusted-computing-base note
|
||||
further below), it also diffs content hashes of the sandbox's own trusted
|
||||
computing base - `bin/`, `hooks/`, `docker/`, `install.sh`,
|
||||
`test/` - under that workspace. It's **advisory only**: it fires *after*
|
||||
the session has already ended, so a report of a planted `core.hooksPath`
|
||||
or hook file is something to read and clean up *before* the next `git`
|
||||
or `claude` in that repo, not something that was blocked. It's silent when nothing
|
||||
changed, and never affects the session's own exit code even if it can't
|
||||
complete. It doesn't cover `[core] share_dir` (see "Shared scratch
|
||||
space") - that hand-off path stays unaudited - and its coverage is a
|
||||
fixed list of known Claude Code config surfaces, not a general integrity
|
||||
check, so a future config surface this list hasn't been taught about yet
|
||||
would go unreported. **Its diff is time-windowed, not session-attributed:**
|
||||
a finding means one of these paths changed while the session was running,
|
||||
not that the session made the change. A concurrent dejima or Claude Code
|
||||
session, another host process, or you editing the same files by hand
|
||||
during the run all land in the same diff and get reported identically -
|
||||
the tool has no way to tell them apart.
|
||||
under `~/.claude/projects` (see below). When the mounted workspace is this
|
||||
repo itself, it also hashes the sandbox's own trusted computing base -
|
||||
`bin/`, `hooks/`, `docker/`, `install.sh`, `test/`.
|
||||
|
||||
Worth knowing about that first surface, because the sandbox's own workflow
|
||||
makes it easy to get wrong: **a git worktree isolates the working tree, not
|
||||
`.git/config`.** Config is shared with the parent checkout, so a bare
|
||||
`git config <key> <value>` inside a worktree writes the real repository's
|
||||
config, including any exec key above. The briefing now says so, and gives
|
||||
`git -C <path> config`. A guard against an unaware agent, not a boundary:
|
||||
`.git/config` is in the read-write project mount and cannot be read-only
|
||||
without breaking `git push -u`, which writes `branch.<name>.merge` on every
|
||||
push. Detection covers the rest, and did - this was found by the audit
|
||||
reporting a `.git/config` change.
|
||||
|
||||
**Advisory only.** It fires after the session ended, so a planted
|
||||
`core.hooksPath` is something to clean up before the next `git` or
|
||||
`claude` in that repo, not something that was blocked. Silent when nothing
|
||||
changed, and never affects the session's exit code. It does not cover
|
||||
`[core] share_dir` (see
|
||||
[Shared scratch space](configuration.md#shared-scratch-space)), and its
|
||||
coverage is a fixed list rather than a general integrity check, so a config
|
||||
surface it has not been taught about goes unreported.
|
||||
|
||||
**Its diff is time-windowed, not session-attributed:** a finding means a
|
||||
path changed while the session ran, not that the session changed it. A
|
||||
concurrent session, another host process, or your own editing all land in
|
||||
the same diff, reported identically.
|
||||
- **Approving and ignoring are different decisions, and the difference is
|
||||
what you are promising about next time.** Approving records the exact
|
||||
content: `safe <id> <sha>`, so this file with these bytes is fine and any
|
||||
other content re-fires. Ignoring records the surface: `spam <id>`, so this
|
||||
path or key is fine whatever it later contains. Approve a hook you have
|
||||
read; ignore a key some tool rewrites on every run and which you have
|
||||
decided you will never care about. The review offers both, and says which
|
||||
is which rather than leaving you to infer it:
|
||||
|
||||
```
|
||||
[a] approve this exact content [i] ignore this key from now on [s] skip (ask again next time) [q] quit
|
||||
```
|
||||
|
||||
Skipping asks again next session, the right default when you are unsure.
|
||||
It matters for keys some tools rewrite on every run: with no decision
|
||||
recorded, one re-fires every session and pushes real findings out of the
|
||||
capped list.
|
||||
|
||||
Severity is not negotiable by either verb. An exec-capable key or shell
|
||||
alias is CRITICAL whatever the baseline says, and an `ignore` entry is
|
||||
refused against a CRITICAL surface when the baseline is read, not only when
|
||||
written - so neither decision can quietly downgrade something that matters.
|
||||
|
||||
- `dejima-audit ack` shows what it is asking you to approve. For a surface
|
||||
that changed during the session it prints a unified diff of the
|
||||
pre-session content against the current content; for one created during
|
||||
@@ -113,21 +282,19 @@ hands to whichever session starts against it. See
|
||||
live on disk); a removed one stores nothing at all, because a deletion
|
||||
diff *is* the whole former file.
|
||||
|
||||
Both the staging directory and the retained diffs live under
|
||||
`$AUDIT_DIR` (`~/.local/share/dejima/audit` by default), never under
|
||||
`/tmp`. That is the boundary the whole scheme rests on:
|
||||
`docker-compose.yml` mounts nothing covering it, so the container cannot
|
||||
read the retained content of earlier runs - `test/static.bats` pins that
|
||||
as a regression guard. If the store is configured *inside* the mounted
|
||||
workspace it becomes readable and deletable from within the sandbox, so
|
||||
in that case no content is retained at all - only a note saying why. A
|
||||
session killed with `SIGKILL` (no EXIT trap runs, so no `compare`)
|
||||
leaves its staging directory behind; the next run against that
|
||||
workspace reaps it, identifying dead owners by pid and process start
|
||||
time recorded in an `.owner` file, so a concurrent session against the
|
||||
same workspace is never mistaken for dead and disturbed. A directory
|
||||
whose owner record is missing or unreadable falls back to a 24-hour age
|
||||
check instead of guessing liveness.
|
||||
Staging and retained diffs live under `$AUDIT_DIR`
|
||||
(`~/.local/share/dejima/audit`), never `/tmp`. That is the boundary the
|
||||
scheme rests on: nothing in `docker-compose.yml` covers it, so the
|
||||
container cannot read earlier runs' retained content, and `static.bats`
|
||||
pins that. A store configured *inside* the workspace is readable and
|
||||
deletable from the sandbox, so nothing is retained in that case - only a
|
||||
note saying why.
|
||||
|
||||
A `SIGKILL`ed session runs no EXIT trap and leaves its staging directory;
|
||||
the next run against that workspace reaps it, identifying dead owners by
|
||||
pid and process start time from an `.owner` file so a concurrent session is
|
||||
never mistaken for dead. A missing or unreadable owner record falls back to
|
||||
a 24-hour age check rather than guessing.
|
||||
|
||||
Content is withheld from the store in several further cases, and in
|
||||
every one `ack` prints the reason in place of the diff - so an absent
|
||||
@@ -142,101 +309,114 @@ hands to whichever session starts against it. See
|
||||
existed before the session but was too large to have been staged (over
|
||||
1 MiB) withholds with "no pre-session copy", not a false claim that it
|
||||
is new. The *current* side is bounded by that same 1 MiB cap, and - like
|
||||
the staged side - the size is checked *before* any copy is taken: a file
|
||||
the session grew past it withholds with "change too large to retain"
|
||||
rather than being copied through `$TMPDIR` first. That ordering is the
|
||||
point, not the cap: `compare` runs from the exit trap on every session
|
||||
exit and the size is the session's own choice, so copying first would
|
||||
let a session turn "I filled my own workspace" into that many bytes
|
||||
written to `$TMPDIR` - frequently a tmpfs, i.e. host RAM - at exit, and
|
||||
into a whole copy of the file left sitting there if that exit was a
|
||||
`SIGKILL`. A generated diff is withheld if it is binary, over 200 lines
|
||||
the staged side - checked *before* any copy is taken: a file the session
|
||||
grew past it withholds with "change too large to retain" rather than
|
||||
being copied through `$TMPDIR` first. The ordering is the point, not the
|
||||
cap. `compare` runs from the exit trap on every exit and the size is the
|
||||
session's own choice, so copying first would turn "I filled my own
|
||||
workspace" into that many bytes written to `$TMPDIR` - often a tmpfs, so
|
||||
host RAM - and into a whole copy left there if the exit was a `SIGKILL`.
|
||||
A generated diff is withheld if it is binary, over 200 lines
|
||||
or 64 KiB (and is bounded to that as it is generated, for the same
|
||||
reason), or matches a best-effort credential screen - vendor prefixes
|
||||
including `sk-ant-`, `sk-proj-`, `ghp_`, `gho_`, `github_pat_`,
|
||||
`glpat-`, Slack's `xox[baprs]-`, `AIza`, `AKIA`, `_authToken`,
|
||||
`Authorization: Bearer`, and PEM private-key headers, matched
|
||||
case-insensitively (deliberately: the one high-value surface this is
|
||||
the only net for, `.claude/settings.json`'s `env` block, conventionally
|
||||
holds its keys upper-case). That screen is still explicitly best-effort
|
||||
and has real false negatives - the denylist above is the reliable half
|
||||
of the defence, this is the second net. A git config key holding more
|
||||
than one value on either side of the session is not compared at all:
|
||||
git resolves such a key to its *last* value in *file* order, and the
|
||||
audit does not resolve it - it parses the config files and sorts, so
|
||||
lexicographically-last is not the value git will run, and the
|
||||
pre-session side is every value the key had rather than one. Comparing
|
||||
anyway produced a diff over values git does not use: with two
|
||||
pre-session values it read as a deletion that never happened, while the
|
||||
value the session actually appended - the one git now executes -
|
||||
appeared nowhere in it. That case withholds with "this key has multiple
|
||||
values and git uses the last", and must be compared by hand;
|
||||
`include.path` is legitimately multi-valued, so this is an ordinary
|
||||
shape, not only a planted one. A generated diff can also still come out
|
||||
empty - before and after compare equal even though the finding was
|
||||
reported as a change - when the config file's *text* changed while the
|
||||
parsed value did not, e.g. a value that was re-quoted; that case
|
||||
withholds too, saying plainly that the comparison came out empty rather
|
||||
than silently showing nothing, since an empty diff for a reported
|
||||
change would otherwise read as a bug.
|
||||
case-insensitively, since `.claude/settings.json`'s `env` block - the one
|
||||
high-value surface this is the only net for - conventionally holds its keys
|
||||
upper-case. The screen is best-effort with real false negatives: the
|
||||
denylist above is the reliable half.
|
||||
|
||||
A per-report **retention budget** (`RETAIN_BUDGET`, 1 MiB) also bounds
|
||||
the total diff bytes one report may keep. Once it is spent, remaining
|
||||
findings in that report withhold with a note that the budget ran out,
|
||||
and both the report and the exit-time stderr summary add an alarm line:
|
||||
that many changed host-persistence surfaces in one session is treated
|
||||
as a signal in its own right, not merely a limit worth raising - an
|
||||
ordinary session changes few enough of these surfaces that the budget
|
||||
is never in practice a constraint. `diffs = off` in the config's
|
||||
`[audit]` section disables retention (and staging) entirely.
|
||||
A git config key with more than one value on either side is not compared.
|
||||
Git resolves such a key to its last value in FILE order; the audit parses
|
||||
and sorts, so lexicographically-last is not what git runs, and the
|
||||
pre-session side is every value the key had. Comparing anyway produced a
|
||||
diff over values git does not use - two pre-session values read as a
|
||||
deletion that never happened, while the appended value git now executes
|
||||
appeared nowhere. It withholds instead, and must be compared by hand.
|
||||
`include.path` is legitimately multi-valued, so this is an ordinary shape.
|
||||
|
||||
Two residual limits worth knowing on top of the credential screen's
|
||||
false negatives above. The prompt shows at most 40 lines of a diff or
|
||||
of new content before truncating, naming the file so you can read the
|
||||
rest - a large planted change is truncated exactly where you would most
|
||||
want it whole. And the `SIGKILL` case above is a real exposure window,
|
||||
not just a cleanup mechanic: until the next run against that workspace
|
||||
reaps it, the staging directory sits on disk holding whole pre-session
|
||||
file copies (not diffs) of every monitored surface, for as long as the
|
||||
workspace goes unused.
|
||||
- Report findings name a path or config key that a session may itself have
|
||||
chosen, so those names reach your terminal as untrusted text. One that
|
||||
contains an ASCII control character, a C1 control, or a Unicode bidi
|
||||
override - which between them can scroll, blank, or visually reorder what
|
||||
you read, and can split a name across the audit's own records - is
|
||||
written hex-encoded behind a `{hex}` marker instead, and decodes with
|
||||
`printf '%b' "$(sed 's/../\\x&/g' <<< HEX)"`. Ordinary names are printed
|
||||
as-is, so the marker appearing at all is itself worth a second look.
|
||||
The encoded form is what `dejima-audit ack` records in the baseline, so
|
||||
what you acknowledge is the file that is actually on disk rather than a
|
||||
A diff can also come out empty when the config file's TEXT changed but the
|
||||
parsed value did not, e.g. a re-quoted value. That withholds too, saying so
|
||||
rather than showing nothing - an empty diff for a reported change reads as
|
||||
a bug.
|
||||
|
||||
A per-report **retention budget** (`RETAIN_BUDGET`, 1 MiB) bounds the total
|
||||
diff bytes one report keeps. Once spent, the rest withhold and both the
|
||||
report and the stderr summary add an alarm line: that many changed
|
||||
host-persistence surfaces in one session is a signal in itself, not a limit
|
||||
worth raising. `diffs = off` in `[audit]` disables retention and staging.
|
||||
|
||||
Two residual limits on top of the screen's false negatives. The prompt
|
||||
truncates at 40 lines, naming the file so you can read the rest - a large
|
||||
planted change is cut exactly where you would want it whole. And the
|
||||
`SIGKILL` case is an exposure window, not just a cleanup mechanic: until
|
||||
the next run reaps it, the staging directory holds whole pre-session file
|
||||
copies of every monitored surface, for as long as the workspace goes
|
||||
unused.
|
||||
- Findings name a path or config key the session may have chosen, so they
|
||||
reach your terminal as untrusted text. One containing an ASCII or C1
|
||||
control or a Unicode bidi override - which can scroll, blank or reorder
|
||||
what you read, and split a name across the audit's own records - is written
|
||||
hex-encoded behind a `{hex}` marker and decodes with
|
||||
`printf '%b' "$(sed 's/../\\x&/g' <<< HEX)"`. Ordinary names print as-is,
|
||||
so the marker appearing is itself worth a look. The encoded form is what
|
||||
`ack` records, so what you acknowledge is the file on disk rather than a
|
||||
readable approximation of its name.
|
||||
- `~/.claude/projects` (session transcripts and per-project memory) is
|
||||
**not** mounted read-only, unlike the code-carrying `~/.claude`
|
||||
directories - deliberately. Scoping a read-only mount to "the
|
||||
current project's slug"
|
||||
would break the sandbox's own worktree workflow: a session running in
|
||||
`WORKSPACE/.worktrees/<branch>` writes to a *different* projects slug
|
||||
than the parent repo, so a slug-scoped overlay would either block that
|
||||
legitimate write or have to be widened until it stopped protecting
|
||||
anything. Instead, cross-project writes are **detected, not prevented**:
|
||||
the exit-time audit above flags any write during the session to another
|
||||
project's `~/.claude/projects/<slug>` directory, excluding the current
|
||||
project's own slug and its worktree children. That's after-the-fact
|
||||
reporting of transcript/memory poisoning in *other* projects, not a
|
||||
block on it happening. Residual even within that: the current project's
|
||||
own transcripts/memory stay fully writable throughout, since Claude Code
|
||||
needs that for host-side `claude --resume` to work; and detection keys
|
||||
off each slug's max file mtime, so an edit to a file that isn't already
|
||||
that project's newest doesn't advance the max and goes unflagged. A
|
||||
read-only mount for `projects/` remains a possible future option if
|
||||
Claude Code is confirmed to fail cleanly (not hang, as `~/.claude.json`
|
||||
did) when a write to it is blocked. This is also the sharpest case of
|
||||
the time-window caveat above: `~/.claude/projects` is a single
|
||||
directory shared by *every* Claude Code session on the host, not scoped
|
||||
to the one being audited, so an entirely innocent concurrent session
|
||||
working on some unrelated project can produce a `CROSS-PROJECT` finding
|
||||
here that reads like the audited session poisoned another project's
|
||||
history - it didn't, it just happened to be running at the same time.
|
||||
**not** mounted as a tree. A session gets the workspace's own slug
|
||||
directory plus one for each live git worktree nested under it,
|
||||
read-write, and nothing else. Every other project's slug is *absent*
|
||||
from the container rather than present-but-unwritable. Agent memory is
|
||||
loaded into context at session start, so a write into another project's
|
||||
`memory/` is text a future session there reads as established background
|
||||
rather than as input - and until #104 the mount alone was enough to do
|
||||
that, with no escape and no credential involved.
|
||||
|
||||
The set is derived from real worktree paths
|
||||
(`bin/dejima-slug-lib.sh`), not from the slug string, because a slug
|
||||
cannot tell a child from a sibling: `/w/proj/wt1` and `/w/proj-wt1`
|
||||
produce the same one. A `<slug>-*` glob therefore also matches any
|
||||
project checked out beside this one whose directory name merely extends
|
||||
it, which is an ordinary layout rather than a contrived one.
|
||||
|
||||
`projects/` itself stays container-local, so a slug that is not mounted
|
||||
is still writable during the session and just does not survive it. That
|
||||
is what lets Claude Code create a slug it does not find, which is the
|
||||
first-run case it already handles, and it is why an absent sibling does
|
||||
not risk the hang that a blocked write to `~/.claude.json` caused.
|
||||
|
||||
Cross-project writes are still **detected**: the audit runs host-side over
|
||||
the whole tree, so narrowing the mount does not narrow it, and it now
|
||||
covers what the mount cannot reach - concurrent *host* sessions. Residual:
|
||||
the workspace's own transcripts and memory stay writable, since
|
||||
`claude --resume` needs that; and detection keys off each slug's max file
|
||||
mtime, so an edit to a file that is not already the newest goes unflagged.
|
||||
The time-window caveat applies most sharply here, because on the host
|
||||
`~/.claude/projects` is a single
|
||||
directory shared by *every* Claude Code session - so an innocent concurrent
|
||||
session elsewhere can produce a `CROSS-PROJECT` finding that reads like
|
||||
poisoning and isn't.
|
||||
- `~/.claude/file-history` (Claude Code's edit-undo record) is **not**
|
||||
mounted as a tree either, and the reason is sharper than for `projects/`.
|
||||
Each `<session>/<hash>@v1` is a **verbatim copy of an edited file**, so the
|
||||
tree is other projects' source rather than a record about it. Read access
|
||||
alone is the leak; it was read-write on top of that, and tampering with
|
||||
another project's undo record puts chosen content into a later restore.
|
||||
|
||||
It is keyed by session id, so unlike `projects/` there is no slug in its
|
||||
own paths to scope it by. The transcript names supply it: `projects/<slug>/
|
||||
<id>.jsonl` and `file-history/<id>` name the same session, so the slugs the
|
||||
workspace owns select the session ids it owns
|
||||
(`workspace_session_ids` in `bin/dejima-slug-lib.sh`). A directory whose
|
||||
session has no transcript belongs to no project dejima can identify, and is
|
||||
left out.
|
||||
|
||||
The session about to start has an id nothing can know in advance, so its
|
||||
own directory is created container-local and does not survive the run.
|
||||
That costs undo history for that session if it is later resumed on the
|
||||
host, and it is the reason this cannot be scoped as completely as
|
||||
`projects/`, where the workspace's own slug is known at launch.
|
||||
- Allowing egress to a domain at all creates a channel for exfiltrating data
|
||||
through *that service's own request surface* (query strings, lookups,
|
||||
etc.), not just to a literal attacker-controlled destination. Domain-level
|
||||
@@ -250,51 +430,45 @@ hands to whichever session starts against it. See
|
||||
similar. It shares the host kernel, so a container-escape vulnerability
|
||||
would defeat every other control described here at once.
|
||||
- Most of what this document asserts is re-checked by the `test/` suite on
|
||||
every change - over 300 bats tests across six files, and `runtime.bats`
|
||||
builds the image and asserts against a real running container rather than
|
||||
a mock: IPv6 blocked, a non-allowlisted domain refused by the DNS proxy
|
||||
and a non-allowlisted destination unreachable, `node` unable to re-run
|
||||
`init-firewall.sh` to widen the allowlist, no working `sudo`. Coverage is
|
||||
not the same as completeness, though. The reasoning in this document is
|
||||
judgement rather than assertions, nothing tests it, and a property nobody
|
||||
thought to write a test for can still regress quietly. A missing `ipset`
|
||||
dedup flag broke firewall startup entirely - twice, on two different
|
||||
ipsets - before anyone caught it; there is a test asserting every
|
||||
`ipset add` is duplicate-safe now, which is the pattern to expect. Each
|
||||
of these tests exists because something was got wrong first.
|
||||
every change, and `runtime.bats` asserts against a real running container
|
||||
rather than a mock: IPv6 blocked, a non-allowlisted domain refused and a
|
||||
non-allowlisted destination unreachable, `node` unable to re-run
|
||||
`init-firewall.sh`, no working `sudo`. Coverage is not completeness: the
|
||||
reasoning here is judgement, nothing tests it, and a property nobody wrote
|
||||
a test for can regress quietly. A missing `ipset` dedup flag broke firewall
|
||||
startup twice before anyone caught it; there is a test for that now, which
|
||||
is the pattern to expect - each of these exists because something was got
|
||||
wrong first.
|
||||
- The Anthropic OAuth credential (`~/.claude/.credentials.json`) never
|
||||
enters the container. It simply isn't mounted: `~/.claude` is not shared
|
||||
as a whole (see the mount inventory below), so there is no path from
|
||||
enters the container. It isn't mounted: `~/.claude` is not shared as a
|
||||
whole (see the mount inventory below), so there is no path from
|
||||
inside the container to that file. `dejima` starts a host-side proxy
|
||||
(`bin/dejima-anthropic-proxy`) on a per-session unix socket and mounts a
|
||||
fake credential in place of the real one - the two secrets are
|
||||
placeholders, while `scopes`/`subscriptionType`/`rateLimitTier`/`clientId`
|
||||
are copied from the real file so the session's own UI stays accurate. Its
|
||||
expiry is set a year out rather than mirrored, deliberately: a near-term
|
||||
one would make Claude Code refresh on its own, with a refresh token that
|
||||
isn't real, and log the session out. Inside the container,
|
||||
fake credential in place of the real one - the secrets are placeholders,
|
||||
while `scopes`/`subscriptionType`/`rateLimitTier`/`clientId` are copied so
|
||||
the session's UI stays accurate. Its expiry is a year out rather than
|
||||
mirrored: a near-term one would make Claude Code refresh on its own with a
|
||||
refresh token that isn't real, logging the session out. Inside the
|
||||
container,
|
||||
`entrypoint.sh` points `api.anthropic.com` at a loopback TLS terminator
|
||||
(`anthropic-proxy-forwarder.js`) whose certificate is generated at startup
|
||||
and trusted only there, and which pipes raw bytes into the proxy's socket.
|
||||
The proxy injects the real access token per request and owns OAuth refresh
|
||||
- rotated tokens are written back to the real host file atomically, so
|
||||
host-side sessions stay logged in. Claude Code itself is configured
|
||||
exactly as it would be on the host: no `ANTHROPIC_BASE_URL`, no
|
||||
`ANTHROPIC_AUTH_TOKEN`, nothing that marks the session as unusual to the
|
||||
client. The proxy then forwards the client's header block verbatim -
|
||||
same names, same casing, same order, repeats intact, and no
|
||||
`Accept-Encoding` it didn't send - changing only the `Authorization`
|
||||
value, and `anthropic-beta` if the OAuth value isn't already there. Left
|
||||
to itself, Python's `http.client` would have rewritten all of that into
|
||||
its own house style and moved the fingerprint from the client to the
|
||||
wire. What it cannot disguise is that the connection is made by CPython's
|
||||
`ssl` rather than by Node: the TLS handshake (JA3/JA4) and the absence of
|
||||
connection reuse still distinguish it at that layer. What remains is
|
||||
session-scoped: a
|
||||
compromised session can still *use* the model API as you while it runs
|
||||
(that's inherent to the sandbox working at all), but it can no longer read
|
||||
a durable credential and exfiltrate it for offline use via the other
|
||||
allowed egress destinations (npm, GitHub, your Gitea host). Caveats:
|
||||
exactly as on the host: no `ANTHROPIC_BASE_URL`, no
|
||||
`ANTHROPIC_AUTH_TOKEN`, nothing marking the session as unusual. The proxy
|
||||
forwards the client's header block verbatim - same names, casing, order and
|
||||
repeats, and no `Accept-Encoding` it didn't send - changing only
|
||||
`Authorization`, and `anthropic-beta` if the OAuth value isn't there. Left
|
||||
alone, Python's `http.client` would rewrite all of that into its own style
|
||||
and move the fingerprint from the client to the wire. It cannot disguise
|
||||
that the connection is CPython's `ssl` rather than Node: the TLS handshake
|
||||
(JA3/JA4) and the absence of connection reuse still distinguish it.
|
||||
What remains is session-scoped: a compromised session can still *use* the
|
||||
API as you while it runs, which is inherent to the sandbox working at all,
|
||||
but it cannot read a durable credential and exfiltrate it for offline use.
|
||||
Caveats:
|
||||
`/login` inside the container doesn't work while the proxy is on; the
|
||||
refresh flow mirrors a Claude Code internal and may break if upstream
|
||||
changes it, in which case the proxy fails loudly (launch abort, 502s on
|
||||
@@ -348,13 +522,21 @@ hands to whichever session starts against it. See
|
||||
are enumerated in `docker-compose.yml`, in three classes, and anything not
|
||||
listed is container-local: writable during the session and gone with the
|
||||
container.
|
||||
- **Read-write, because it has to survive the session**: `projects/`,
|
||||
`file-history/`, `backups/`, `tasks/`, `plans/`, `sessions/`. All
|
||||
directories, which is what makes them safe (see the detachment note
|
||||
below).
|
||||
- **Read-write, because it has to survive the session**: `backups/`,
|
||||
`tasks/`, `plans/`, `sessions/`, plus the entries of `projects/` and
|
||||
`file-history/` that belong to this workspace. All directories, which is
|
||||
what makes them safe (see the detachment note below). The two trees are
|
||||
not mounted whole: see the `projects/` note further down, and the
|
||||
`file-history/` note below it.
|
||||
- **Read-only**: `plugins/`, `hooks/`, `skills/`, `commands/`, `agents/`,
|
||||
`output-styles/`, plus the symlink-target directories `dejima`
|
||||
discovers at launch. Hooks execute automatically with no separate
|
||||
discovers at launch. Those targets are discovered rather than
|
||||
configured, so they are checked against the approval store and the
|
||||
audit stores and refused if they overlap either - a hook symlinked into
|
||||
the approval store used to mount it, read-only and silently, and
|
||||
read-only is no protection when the content is every project's
|
||||
approvals. The same check now covers `[mounts] readonly`, which had
|
||||
none at all. Hooks execute automatically with no separate
|
||||
approval step, skills can ship executable scripts, and
|
||||
commands/agents/output-styles are prompt text loaded into a later host
|
||||
session, so without this a compromised session could plant a backdoored
|
||||
@@ -373,8 +555,7 @@ hands to whichever session starts against it. See
|
||||
`statusline.sh` are mounted read-only at `/etc/dejima-claude/` and
|
||||
installed into `~/.claude` by `entrypoint.sh` as root-owned, unwritable
|
||||
files. A session can still unlink and replace them - `~/.claude` belongs
|
||||
to it - but only its own copy, which was the whole point of making them
|
||||
read-only.
|
||||
to it - but only its own copy, which is why they are read-only.
|
||||
|
||||
The reason for the split is that a **single-file bind mount silently
|
||||
detaches**. It is pinned to the dentry at its mount point, so when the
|
||||
@@ -463,8 +644,7 @@ hands to whichever session starts against it. See
|
||||
sandboxed session (e.g. a token refresh) doesn't carry back out. Worth
|
||||
re-checking if a future Claude Code version starts relying on that for
|
||||
normal operation.
|
||||
- DNS resolution itself is allowlisted (see "DNS is allowlisted too" in the
|
||||
README),
|
||||
- DNS resolution itself is allowlisted (see "DNS is allowlisted too" below),
|
||||
not just the IP connections made afterward, closing off exfiltration via
|
||||
encoding data into query names to an attacker-controlled domain - verified
|
||||
a non-allowlisted domain gets refused outright (`status: REFUSED`) rather
|
||||
@@ -477,10 +657,13 @@ 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).
|
||||
- `[core] ssh_key` (see "Headless git push without agent forwarding") is a
|
||||
second, independent credential-loading path with the same "signs for any
|
||||
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" below).
|
||||
- `[core] ssh_key` (see [Headless git push without agent
|
||||
forwarding](configuration.md#headless-git-push-without-agent-forwarding))
|
||||
is a second, independent credential-loading path with the same "signs for any
|
||||
Gitea repo it has access to" shape as the forwarded agent above - the
|
||||
mitigation is that it's meant to be a dedicated deploy key scoped to only
|
||||
the repos that actually need push access, not your personal identity.
|
||||
@@ -502,7 +685,7 @@ hands to whichever session starts against it. See
|
||||
SSH-agent/token story documented above. Keep credentials out of your
|
||||
global gitconfig, or expect them to be reachable from inside the
|
||||
sandbox.
|
||||
- The sandbox's own user (matching your host user — see "Config parity" in
|
||||
- The sandbox's own user (matching your host user; see "Config parity" in
|
||||
the README) has no `sudo`/setuid path back to root once the container is up:
|
||||
`entrypoint.sh` runs as root, sets up the firewall directly, then drops to
|
||||
that user via `gosu` before execing the configured command (`[core] command`,
|
||||
@@ -512,7 +695,9 @@ hands to whichever session starts against it. See
|
||||
cleared from the effective/permitted sets on the `gosu` drop, and that
|
||||
user gets a permission error running `iptables` directly.
|
||||
- The approval store (`${DEJIMA_CONFIG_DIR:-$HOME/.config/dejima}`,
|
||||
see "Global and per-project domains and dependencies" above) lives outside
|
||||
see
|
||||
[Global and per-project domains and dependencies](../README.md#global-and-per-project-domains-and-dependencies)
|
||||
in the README) lives outside
|
||||
this repo, and outside every project mount, by default - unlike a
|
||||
project's own `.dejima/` declaration files, it's never
|
||||
bind-mounted into the container, so a compromised
|
||||
@@ -522,8 +707,8 @@ hands to whichever session starts against it. See
|
||||
`bin/dejima-project-lib.sh`), never sourced or executed, and are
|
||||
written only by the CLIs (`dejima-allow`, `dejima-dep`,
|
||||
`dejima-project`) a human runs on the host. `dejima`
|
||||
warns at launch whenever the resolved store path — default or overridden
|
||||
via `DEJIMA_CONFIG_DIR` — lands under the mounted workspace,
|
||||
warns at launch whenever the resolved store path (default or overridden
|
||||
via `DEJIMA_CONFIG_DIR`) lands under the mounted workspace,
|
||||
rather than silently trusting it.
|
||||
- When the mounted workspace *is* this repo itself, `dejima`
|
||||
warns at launch that the repo's own trusted computing base - `bin/`,
|
||||
@@ -535,3 +720,306 @@ hands to whichever session starts against it. See
|
||||
session can still edit those files during the run, and whatever it
|
||||
changes takes effect the *next* time `dejima` itself runs,
|
||||
not the current one.
|
||||
|
||||
### Declaration files are session-writable
|
||||
|
||||
A project's `.dejima/` declaration files sit inside the project mount, which
|
||||
is bind-mounted read-write. A compromised session can write them, and that is
|
||||
fine by design: declaring grants nothing, and the approval store the session
|
||||
cannot reach is what a declaration has to pass through.
|
||||
|
||||
What a session *could* try is to make the host-side write land somewhere else.
|
||||
Plant `.dejima`, or `.dejima/<kind>`, as a symlink pointing at your approval
|
||||
store or any other file you can write, then get you to run the
|
||||
`dejima-allow --project` or `dejima-dep add` that appends to it. The append
|
||||
happens on the host, as you, so it inherits your write access rather than the
|
||||
container's.
|
||||
|
||||
Both writers refuse a symlink at either of those two leaves. The refusal is
|
||||
the `open` itself and not a check before it: `O_NOFOLLOW` on both, with the
|
||||
file opened relative to the already-opened directory, so a session still
|
||||
running while you type the command cannot swap either one out from under the
|
||||
write. Those two leaves are what a session controls. A symlink further up a
|
||||
`--project` path you supplied yourself is still resolved through, because
|
||||
that part of the path is your claim about where the project is, not the
|
||||
session's.
|
||||
|
||||
Expect the session to be live when you run these: the agent briefing tells it
|
||||
to ask you to run them from a terminal outside the sandbox, which is exactly
|
||||
the window this closes.
|
||||
|
||||
## Network egress firewall
|
||||
|
||||
On start, the container runs an allowlist-only `iptables`/`ipset` firewall
|
||||
(`init-firewall.sh`), matching the reference dev container. Allowed by
|
||||
default:
|
||||
|
||||
- DNS resolution via a local proxy (see "DNS is allowlisted too")
|
||||
- the Docker host itself (not its whole subnet)
|
||||
- GitHub's published IP ranges, port 443 only (see "GitHub push" below)
|
||||
- `registry.npmjs.org` and `platform.claude.com` (the harness and its
|
||||
updates), `release-assets.githubusercontent.com` (GitHub release assets, a
|
||||
separate CDN from github.com's own IPs, and where a good deal of tooling
|
||||
fetches its binaries); `api.anthropic.com` when the credential proxy is off
|
||||
(kill switch or API-key auth)
|
||||
|
||||
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).
|
||||
|
||||
IPv6 is blocked outright (`ip6tables` DROP on INPUT/OUTPUT/FORWARD, `dnsmasq`
|
||||
strips AAAA answers): there's no IPv6 equivalent of the allowlist above, so
|
||||
leaving it open would bypass the whole firewall on any host with IPv6
|
||||
egress.
|
||||
|
||||
If your Gitea host only resolves via a LAN-local DNS server, make sure the
|
||||
Docker host itself can resolve it: containers inherit the host's resolver.
|
||||
|
||||
### DNS is allowlisted too
|
||||
|
||||
Resolution itself is restricted, not just the IP connections made
|
||||
afterward: `dns-proxy.sh` runs a local `dnsmasq` with no fallback resolver,
|
||||
and `/etc/resolv.conf` is repointed at it. Only names on the same allowlist
|
||||
above (plus `github.com`/`githubusercontent.com`/`githubassets.com`, needed
|
||||
since GitHub itself is allowlisted by IP range; see `fixed-domains.sh`) get
|
||||
forwarded to Docker's real resolver; anything else gets no upstream
|
||||
configured at all and is refused outright. Without this, a compromised
|
||||
session could exfiltrate data by encoding it into query names to an
|
||||
attacker-controlled domain, even with no direct connection ever possible.
|
||||
|
||||
### Adding a domain to a running session
|
||||
|
||||
Approved domains only take effect at container start. For a domain a
|
||||
*running* session needs right now, run from the host (not inside the
|
||||
sandbox: it has no privilege to do this itself):
|
||||
|
||||
```sh
|
||||
dejima-allow crates.io # this running session only
|
||||
dejima-allow --container <id> crates.io # a specific session, if more than one is running
|
||||
```
|
||||
|
||||
It first adds the domain to the running dnsmasq's allowlist (appends a
|
||||
`server=` line to its servers-file and sends `SIGHUP` to reload just that
|
||||
file - no restart), then resolves it through the now-unblocked container
|
||||
resolver and adds the IP(s) to the running firewall's `ipset`, all via
|
||||
`docker exec -u root`. It finishes by checking the domain is actually
|
||||
reachable from inside that same container and prints a definitive verdict:
|
||||
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](configuration.md#global-and-per-project-domains-and-dependencies)
|
||||
in docs/configuration.md, 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
|
||||
"successfully" updating someone else's session instead.
|
||||
|
||||
A domain allowed this way stays allowed for the rest of the session even
|
||||
if its CDN later rotates edge IPs: `dns-proxy.sh` tops up the firewall's
|
||||
`ipset` from every DNS answer it serves, not just the one resolved at
|
||||
allow-time, since only allowlisted names get an upstream to answer from in
|
||||
the first place. GitHub's own domains sit outside this - their existing
|
||||
port-443-only `ipset` entries still win by dnsmasq's longest-match rule -
|
||||
so that restriction is unaffected.
|
||||
|
||||
One gap remains: allowing an *internal* hostname (one that resolves to an
|
||||
RFC1918 address) mid-session doesn't work. dnsmasq's `stop-dns-rebind`
|
||||
rejects private answers for any domain not exempted at startup, and that
|
||||
exemption list can only be extended by restarting dnsmasq. Internal hosts
|
||||
named before the container starts (`[forge.gitea] ssh_host`, `api`,
|
||||
`[logging] loki_url`, and any globally or per-project approved domain) are
|
||||
already exempted and unaffected by this.
|
||||
|
||||
The sandbox-context.sh `SessionStart` hook tells the agent to ask for this
|
||||
by name when a task is blocked on a single missing domain, rather than
|
||||
stopping the whole session over it.
|
||||
|
||||
## Network request logging
|
||||
|
||||
If `[logging] loki_url` is set (see Prerequisites in the README), every DNS query and every new
|
||||
outbound connection attempt - allowed or rejected - is shipped to that Loki
|
||||
endpoint. Two sources, both tailed and pushed by `log-shipper.sh`:
|
||||
|
||||
- `dns-proxy.sh`'s dnsmasq logs every query and whether it was forwarded
|
||||
(allowed) or refused (not on the allowlist) - labeled `job=dejima-dns`.
|
||||
- `init-firewall.sh` tags its terminal ACCEPT/REJECT rules with `NFLOG`
|
||||
(group 1 for allowed, group 2 for rejected), which `ulogd2`
|
||||
(`ulogd.conf`) turns into JSON lines with source/dest IP, port, and an
|
||||
`action` field - labeled `job=dejima-net`. Only new connection
|
||||
attempts are logged, not every packet: the firewall's existing
|
||||
ESTABLISHED,RELATED rule short-circuits the rest of a connection before it
|
||||
ever reaches the tagged rules.
|
||||
|
||||
This is visibility on top of the firewall/DNS-proxy enforcement above, not a
|
||||
substitute for it - nothing about logging changes what's actually allowed to
|
||||
resolve or connect.
|
||||
|
||||
### Conversation logging (opt-in)
|
||||
|
||||
Set `[logging] conversation = on` (with `loki_url` configured) to also ship
|
||||
session content to Loki as the `dejima-conversation` job: your
|
||||
prompts, the agent's text responses, and tool invocations (tool name +
|
||||
input - never tool outputs). Lines are JSON with the session ID inside
|
||||
(`{session, role, text}` / `{session, role: "tool", tool, input}`).
|
||||
|
||||
Off by default - conversation content is a different sensitivity class
|
||||
than DNS names and firewall verdicts, so it never leaves the container
|
||||
unless you opt in. Transcripts of every session under the mounted
|
||||
workspace (including worktrees and subagent sessions) are shipped while
|
||||
the sandbox runs.
|
||||
|
||||
### Viewing logs in Grafana
|
||||
|
||||
Nothing sandbox-specific needed on the Grafana side beyond a Loki data
|
||||
source pointed at wherever `[logging] loki_url` resolves to (Connections → Data
|
||||
sources → Loki, if one isn't already configured against that instance) -
|
||||
`log-shipper.sh` pushes directly to Loki's HTTP API, there's no Prometheus
|
||||
scrape target or exporter involved.
|
||||
|
||||
In Grafana's Explore view (or a dashboard panel with a Loki query), each
|
||||
stream is selectable by its `job` label:
|
||||
|
||||
```logql
|
||||
{job="dejima-dns"} # every DNS query + verdict
|
||||
{job="dejima-net"} # every connection attempt, as JSON
|
||||
{job="dejima-conversation"} # the session itself, if opted in
|
||||
```
|
||||
|
||||
Useful filters on top of those:
|
||||
|
||||
```logql
|
||||
# DNS lookups that got refused (not on the allowlist)
|
||||
{job="dejima-dns"} |= "REFUSED"
|
||||
|
||||
# Rejected connections only (parses ulogd2's JSON, filters on its action field)
|
||||
{job="dejima-net"} | json | action="blocked"
|
||||
|
||||
# All three together, e.g. to watch a live session across sources
|
||||
{source="dejima"}
|
||||
```
|
||||
|
||||
The conversation stream, when `[logging] conversation` is on, is one JSON
|
||||
object per line carrying `session`, `role`, and either `text` or a `tool` name
|
||||
with its `input`:
|
||||
|
||||
```logql
|
||||
# Just what you asked for, without the replies
|
||||
{job="dejima-conversation"} | json | role="user"
|
||||
|
||||
# Every shell command a session ran, and with what arguments
|
||||
{job="dejima-conversation"} | json | tool="Bash"
|
||||
|
||||
# One session end to end, id taken from a line you already found
|
||||
{job="dejima-conversation"} | json | session="0198e2c1-..."
|
||||
|
||||
# Anything a session said about a subject, across every session
|
||||
{job="dejima-conversation"} |= "allowed-domains"
|
||||
```
|
||||
|
||||
`session` is deliberately a field inside the line rather than a Loki label:
|
||||
one label per session would multiply the stream cardinality by the number of
|
||||
sessions you have ever run, which is how a Loki instance stops being usable.
|
||||
Filter on it with `| json | session=` as above rather than expecting a
|
||||
selector.
|
||||
|
||||
Two things are absent by design. **Tool results are never shipped**, only
|
||||
invocations, so a query will show you that a session ran `Bash` with certain
|
||||
arguments and never what came back. **Thinking blocks are never shipped either.** Both dominate the volume
|
||||
and neither was what opting in asked for. Long values are clipped, so a very
|
||||
large prompt or tool input ends in `...[truncated]` rather than being dropped.
|
||||
|
||||
`{job="dejima-net"}` lines are one full ulogd2 JSON object per
|
||||
connection attempt (`| json` in a query unpacks fields like `dest_ip`,
|
||||
`dest_port`, `action`) - Loki's Explore view shows these expandable/
|
||||
pretty-printed by default, no extra parsing setup needed.
|
||||
|
||||
|
||||
## GitHub push: blocked by default, optional opt-in
|
||||
|
||||
`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 with its required keys
|
||||
filled in, so the flag alone will not get an unconfigured `[forge.gitea]` past
|
||||
validation.
|
||||
|
||||
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
|
||||
[Shipped providers](providers.md#shipped-providers)): 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
|
||||
443. Port 22 is unreachable, so SSH-based push has no transport at all,
|
||||
regardless of what any hook does or doesn't catch.
|
||||
|
||||
**Opt-in, not hardcoded**: pass `--allow-github-push` to open port 22 to
|
||||
GitHub and let `git-push-guard.sh` accept `github.com`/`ssh.github.com` as a
|
||||
push destination (the no-direct-to-main rule still applies):
|
||||
|
||||
```sh
|
||||
dejima --allow-github-push ~/repos/some-project
|
||||
```
|
||||
|
||||
**Read this before turning it on.** Whether a push actually succeeds depends
|
||||
entirely on what SSH key is loaded into the agent. A forwarded personal
|
||||
agent with your normal GitHub key would let a compromised session push to
|
||||
**any** repo that key can write to, not just the one you intended: agent
|
||||
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. 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 [Shipped providers](providers.md#shipped-providers)) 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
|
||||
that file exists) points git's github.com HTTPS credential helper at `gh`,
|
||||
which only ever holds whatever token that file provides: a write-scoped
|
||||
token there would make HTTPS push possible independent of
|
||||
`--allow-github-push`, so don't use one.
|
||||
|
||||
Executable
+324
@@ -0,0 +1,324 @@
|
||||
#!/bin/bash
|
||||
# Read Gitea Actions runs and job logs from a shell - the CI-side counterpart
|
||||
# to gitea-pr and gitea-issue. Self-contained like both of them rather than
|
||||
# sharing a library: all three are copied into the image as single files, so
|
||||
# a library would be another thing to keep in step. test/static.bats pins the
|
||||
# duplicated config/token/curl preamble instead.
|
||||
#
|
||||
# It exists for the agent: CI is where the tests the sandbox cannot run
|
||||
# actually run, and without this the only way to see a failure is for the
|
||||
# operator to paste the log in.
|
||||
#
|
||||
# Nothing here touches /repos/{owner}/{repo}/actions/runs or the endpoints
|
||||
# reached through it, however natural they look. Gitea gates that group
|
||||
# behind reqOwner(), so a dedicated non-owner account - what a sandbox should
|
||||
# have - is refused, and no scope grants ownership. The three sources used
|
||||
# instead need only read access:
|
||||
#
|
||||
# actions/tasks one record per job, carrying its run number,
|
||||
# head_sha, event and result
|
||||
# commits/<sha>/statuses the same jobs as commit statuses, and the only
|
||||
# place a job's index is visible
|
||||
# the web log route the run view's own endpoint, which takes the
|
||||
# same token header as the API
|
||||
#
|
||||
# The job index matters because it is what addresses a log, and it is not a
|
||||
# field anywhere - only the tail of a status's target_url.
|
||||
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"
|
||||
}
|
||||
|
||||
# Sets `tasks_json`. The list pages by TASK - one record per job - so a run
|
||||
# costs as many records as it has jobs, and `limit` is ignored unless `page`
|
||||
# comes with it. Taken as one page, sized for a few dozen runs of a ten-job
|
||||
# workflow; a lookup that falls off the end says which it was.
|
||||
function fetch_tasks() {
|
||||
api_or_die "list runs" \
|
||||
"${GITEA_API}/repos/${owner}/${repo}/actions/tasks?page=1&limit=$TASK_SCAN"
|
||||
tasks_json="$json"
|
||||
}
|
||||
|
||||
# Sets `run_number` and `run_sha`. With no number, HEAD's commit: a run list
|
||||
# is not what someone who just pushed is asking for. Matched on head_sha
|
||||
# rather than on branch because a pull_request run reports no branch at all -
|
||||
# its ref is refs/pull/N/head.
|
||||
function resolve_run() {
|
||||
local want="${1:-}" sha
|
||||
fetch_tasks
|
||||
if [ -n "$want" ]; then
|
||||
run_sha=$(echo "$tasks_json" | jq -r --argjson n "$want" \
|
||||
'first(.workflow_runs[] | select(.run_number == $n) | .head_sha) // empty')
|
||||
if [ -z "$run_sha" ]; then
|
||||
echo "No run numbered $want in the last $TASK_SCAN job records (see 'gitea-actions runs')" >&2
|
||||
exit 1
|
||||
fi
|
||||
run_number="$want"
|
||||
else
|
||||
sha=$(git rev-parse HEAD 2>/dev/null) || {
|
||||
echo "Not in a git repository, so there is no HEAD to look up - pass a run number" >&2
|
||||
exit 1
|
||||
}
|
||||
run_number=$(echo "$tasks_json" | jq -r --arg sha "$sha" \
|
||||
'first(.workflow_runs[] | select(.head_sha == $sha) | .run_number) // empty')
|
||||
if [ -z "$run_number" ]; then
|
||||
echo "No run for HEAD (${sha:0:8}) in the last $TASK_SCAN job records - unpushed, or still queued?" >&2
|
||||
exit 1
|
||||
fi
|
||||
run_sha="$sha"
|
||||
fi
|
||||
}
|
||||
|
||||
# Sets `jobs_json` for the resolved run, from the commit's statuses - the one
|
||||
# place a job's index is visible to a token that does not own the repo.
|
||||
#
|
||||
# A status is written more than once per job (queued, then finished) and the
|
||||
# list arrives newest first, so the newest per context wins. `context` is
|
||||
# "<workflow> / <job> (<event>)"; the job name is pulled out of it for
|
||||
# display, and matching keeps the whole string so either form works. Filtered
|
||||
# by run number as well as by commit, since a re-run puts a second run's
|
||||
# statuses on the same commit.
|
||||
function fetch_jobs() {
|
||||
api_or_die "list jobs for run $run_number" \
|
||||
"${GITEA_API}/repos/${owner}/${repo}/commits/${run_sha}/statuses?page=1&limit=100"
|
||||
jobs_json=$(echo "$json" | jq --arg rn "$run_number" '
|
||||
[.[] | select(.target_url | test("/actions/runs/" + $rn + "/jobs/[0-9]+$"))]
|
||||
| group_by(.context) | map(first)
|
||||
| map({
|
||||
index: (.target_url | split("/") | last | tonumber),
|
||||
name: (.context | sub("^[^/]+/ *"; "") | sub(" *\\([^()]*\\)$"; "")),
|
||||
context,
|
||||
result: .status
|
||||
})
|
||||
| sort_by(.index)')
|
||||
if [ "$(echo "$jobs_json" | jq 'length')" -eq 0 ]; then
|
||||
echo "Run $run_number (${run_sha:0:8}) reports no jobs." >&2
|
||||
echo "Gitea publishes a run's jobs as statuses on its commit, and this" >&2
|
||||
echo "commit has none for that run - so there is nothing to address a log by." >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
function print_job_log() {
|
||||
local index="$1" name="$2" result="$3"
|
||||
echo "=== $name ($result) ==="
|
||||
api_or_die "fetch logs for job $name" \
|
||||
"${GITEA_WEB}/${owner}/${repo}/actions/runs/${run_number}/jobs/${index}/logs"
|
||||
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
|
||||
}
|
||||
|
||||
TASK_SCAN=600
|
||||
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##*/}"
|
||||
# Logs come from the run view's own endpoint rather than the API, so
|
||||
# the web root is needed as well. Derived from GITEA_API because that
|
||||
# is the only address the caller configures.
|
||||
GITEA_WEB="${GITEA_API%/api/v1}"
|
||||
if [ "$GITEA_WEB" = "$GITEA_API" ]; then
|
||||
echo "GITEA_API ($GITEA_API) does not end in /api/v1, so the web root" >&2
|
||||
echo "cannot be derived from it - logs are read from a non-API route." >&2
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
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
|
||||
fetch_tasks
|
||||
# One record per job, so a run appears once for each job it has.
|
||||
# Folded here into a row per run, whose result is the worst of them:
|
||||
# a run with one failed job is a failed run, and reporting it as
|
||||
# seven successes and a failure is not what the caller asked.
|
||||
echo "$tasks_json" | jq -r --argjson limit "$limit" '
|
||||
[.workflow_runs[]] | group_by(.run_number)
|
||||
| map({
|
||||
run: .[0].run_number,
|
||||
event: .[0].event,
|
||||
sha: (.[0].head_sha[0:8]),
|
||||
title: .[0].display_title,
|
||||
result: (if any(.[]; .status == "failure") then "failure"
|
||||
elif any(.[]; .status == "cancelled") then "cancelled"
|
||||
elif any(.[]; .status != "success") then "in_progress"
|
||||
else "success" end)
|
||||
})
|
||||
| sort_by(.run) | reverse | .[0:$limit] | .[]
|
||||
| [(.run | tostring), .result, .event, .sha, .title] | @tsv' |
|
||||
while IFS=$'\t' read -r run result event sha title; do
|
||||
printf '%-5s %-11s %-20s %-8s %s\n' "$run" "$result" "$event" "$sha" "$title"
|
||||
done
|
||||
;;
|
||||
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' |
|
||||
while IFS=$'\t' read -r index result name; do
|
||||
printf '%-3s %-11s %s\n' "$index" "$result" "$name"
|
||||
done
|
||||
;;
|
||||
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(.context | 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 index name result; do
|
||||
print_job_log "$index" "$name" "$result"
|
||||
done < <(echo "$selected" | jq -r '.[] | [(.index | tostring), .name, .result] | @tsv')
|
||||
;;
|
||||
esac
|
||||
@@ -13,6 +13,7 @@ function usage() {
|
||||
echo "Usage: gitea-issue list [--state open|closed|all] [--limit N]"
|
||||
echo " gitea-issue view NUMBER"
|
||||
echo " gitea-issue create --title TITLE [--body BODY | --body-file FILE]"
|
||||
echo " gitea-issue edit NUMBER [--title TITLE] [--body BODY | --body-file FILE]"
|
||||
echo " gitea-issue comment NUMBER (--body BODY | --body-file FILE)"
|
||||
echo " gitea-issue close NUMBER"
|
||||
echo " gitea-issue reopen NUMBER"
|
||||
@@ -153,7 +154,7 @@ subcommand="$1"
|
||||
shift
|
||||
|
||||
case "$subcommand" in
|
||||
list|view|create|comment|close|reopen)
|
||||
list|view|create|edit|comment|close|reopen)
|
||||
require_config
|
||||
require_token
|
||||
owner_repo=$(gitea_owner_repo)
|
||||
@@ -223,6 +224,35 @@ ${body}"
|
||||
'{title: $title, body: $body}')"
|
||||
echo "$json" | jq -r '.html_url'
|
||||
;;
|
||||
edit)
|
||||
require_number "${1:-}"
|
||||
number="$1"
|
||||
shift
|
||||
parse_body_args "$@"
|
||||
if [ -z "$parsed_title" ] && [ -z "$body" ]; then
|
||||
echo "gitea-issue edit requires --title, --body or --body-file" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Only the fields actually given are sent: an omitted key leaves that
|
||||
# field alone, so editing a title cannot blank a body by accident.
|
||||
payload='{}'
|
||||
if [ -n "$parsed_title" ]; then
|
||||
payload=$(echo "$payload" | jq --arg t "$parsed_title" '. + {title: $t}')
|
||||
fi
|
||||
if [ -n "$body" ]; then
|
||||
# The same marker create writes. It describes where the body came
|
||||
# from rather than the event that wrote it, so an edited body
|
||||
# keeps the disclosure instead of quietly shedding it.
|
||||
payload=$(echo "$payload" | jq --arg b "**Filed by LLM agent**
|
||||
|
||||
$body" '. + {body: $b}')
|
||||
fi
|
||||
|
||||
api_or_die "edit issue #${number}" -X PATCH "${issues_api}/${number}" \
|
||||
-H "Content-Type: application/json" -d "$payload"
|
||||
echo "$json" | jq -r '.html_url'
|
||||
;;
|
||||
comment)
|
||||
require_number "${1:-}"
|
||||
number="$1"
|
||||
|
||||
+164
-78
@@ -5,12 +5,19 @@ GITEA_TOKEN_FILE="${GITEA_TOKEN_FILE:-$HOME/.config/gitea/token}"
|
||||
|
||||
function usage() {
|
||||
echo "Usage: gitea-pr create [--title TITLE] [--body BODY | --body-file FILE] [--base BRANCH]"
|
||||
echo " gitea-pr comments PR_NUMBER"
|
||||
echo " gitea-pr edit PR_NUMBER [--title TITLE] [--body BODY | --body-file FILE]"
|
||||
echo " gitea-pr comments PR_NUMBER [--all]"
|
||||
echo " gitea-pr merge PR_NUMBER"
|
||||
echo ""
|
||||
echo "'comments' shows unresolved review threads by default. --all includes"
|
||||
echo "the ones already resolved, marked as such."
|
||||
echo ""
|
||||
echo "--body-file reads the body from FILE, or from stdin when FILE is '-'."
|
||||
echo "Prefer it over --body for anything with quotes, backticks or newlines."
|
||||
echo ""
|
||||
echo "'edit' replaces the title and/or body of an existing PR. Whichever of"
|
||||
echo "the two is not given is left as it is."
|
||||
echo ""
|
||||
echo "Requires GITEA_SSH_HOST and GITEA_API set in the environment (e.g. in" \
|
||||
"your shell profile), and a token at $GITEA_TOKEN_FILE (or GITEA_TOKEN" \
|
||||
"set for this invocation only)."
|
||||
@@ -53,6 +60,23 @@ function gitea_api() {
|
||||
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. Copied from gitea-issue, which grew
|
||||
# it first for the same reason: every call site here did this by hand, and a
|
||||
# fifth hand-rolled copy is where one of them quietly stops checking.
|
||||
function api_or_die() {
|
||||
local what="$1"; shift
|
||||
local response
|
||||
response=$(gitea_api "$@")
|
||||
http_code=$(echo "$response" | tail -1)
|
||||
json=$(echo "$response" | sed '$d')
|
||||
if [ "$http_code" -lt 200 ] || [ "$http_code" -ge 300 ]; then
|
||||
echo "Failed to $what (HTTP $http_code):" >&2
|
||||
echo "$json" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Reads a body from a file, or from stdin for '-'. The point is to keep prose
|
||||
# out of argv: a body composed on a command line has to survive shell quoting,
|
||||
# and the things that break it - an apostrophe, a backtick, a $ - are exactly
|
||||
@@ -86,6 +110,43 @@ function gitea_owner_repo() {
|
||||
echo "$owner_repo"
|
||||
}
|
||||
|
||||
function require_number() {
|
||||
if ! [[ "${1:-}" =~ ^[0-9]+$ ]]; then
|
||||
echo "Expected a PR number, got '${1:-}'" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Sets `body` from --body/--body-file, `parsed_title` from --title and
|
||||
# `parsed_base` from --base, rejecting --body and --body-file together.
|
||||
# Shared by create and edit so the two cannot drift into disagreeing about
|
||||
# which one wins - the same reason gitea-issue shares one between its own
|
||||
# two body-taking subcommands.
|
||||
function parse_body_args() {
|
||||
body=""
|
||||
local body_file=""
|
||||
parsed_title=""
|
||||
parsed_base=""
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--title) parsed_title="$2"; shift 2 ;;
|
||||
--body) body="$2"; shift 2 ;;
|
||||
--body-file) body_file="$2"; shift 2 ;;
|
||||
--base) parsed_base="$2"; shift 2 ;;
|
||||
*) echo "Unknown option: $1" >&2; usage; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
# Rejected rather than ranked: silently preferring one would make a caller
|
||||
# that passed both ship a body it didn't mean to.
|
||||
if [ -n "$body" ] && [ -n "$body_file" ]; then
|
||||
echo "Pass --body or --body-file, not both" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -n "$body_file" ]; then
|
||||
body="$(read_body_file "$body_file")"
|
||||
fi
|
||||
}
|
||||
|
||||
if [ "$#" -lt 1 ]; then
|
||||
usage
|
||||
exit 1
|
||||
@@ -95,7 +156,7 @@ subcommand="$1"
|
||||
shift
|
||||
|
||||
case "$subcommand" in
|
||||
create|comments|merge)
|
||||
create|edit|comments|merge)
|
||||
require_config
|
||||
require_token
|
||||
owner_repo=$(gitea_owner_repo)
|
||||
@@ -110,28 +171,9 @@ esac
|
||||
|
||||
case "$subcommand" in
|
||||
create)
|
||||
title=""
|
||||
body=""
|
||||
body_file=""
|
||||
base=""
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--title) title="$2"; shift 2 ;;
|
||||
--body) body="$2"; shift 2 ;;
|
||||
--body-file) body_file="$2"; shift 2 ;;
|
||||
--base) base="$2"; shift 2 ;;
|
||||
*) echo "Unknown option: $1" >&2; usage; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
# Rejected rather than ranked: silently preferring one would make a
|
||||
# caller that passed both ship a body it didn't mean to.
|
||||
if [ -n "$body" ] && [ -n "$body_file" ]; then
|
||||
echo "Pass --body or --body-file, not both" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -n "$body_file" ]; then
|
||||
body="$(read_body_file "$body_file")"
|
||||
fi
|
||||
parse_body_args "$@"
|
||||
title="$parsed_title"
|
||||
base="$parsed_base"
|
||||
body="**Summary generated by LLM agent**
|
||||
|
||||
${body}"
|
||||
@@ -147,14 +189,7 @@ ${body}"
|
||||
base="${base:-main}"
|
||||
fi
|
||||
|
||||
response=$(gitea_api "${GITEA_API}/repos/${owner}/${repo}/pulls?state=open")
|
||||
http_code=$(echo "$response" | tail -1)
|
||||
json=$(echo "$response" | sed '$d')
|
||||
if [ "$http_code" -lt 200 ] || [ "$http_code" -ge 300 ]; then
|
||||
echo "Failed to check for an existing PR (HTTP $http_code):" >&2
|
||||
echo "$json" >&2
|
||||
exit 1
|
||||
fi
|
||||
api_or_die "check for an existing PR" "${GITEA_API}/repos/${owner}/${repo}/pulls?state=open"
|
||||
existing_url=$(echo "$json" | jq -r --arg head "$current_branch" --arg base "$base" \
|
||||
'.[] | select(.head.ref == $head and .base.ref == $base) | .html_url' | head -1)
|
||||
if [ -n "$existing_url" ]; then
|
||||
@@ -165,63 +200,122 @@ ${body}"
|
||||
title="$current_branch"
|
||||
fi
|
||||
|
||||
response=$(gitea_api -X POST "${GITEA_API}/repos/${owner}/${repo}/pulls" \
|
||||
api_or_die "create PR" -X POST "${GITEA_API}/repos/${owner}/${repo}/pulls" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$(jq -n --arg head "$current_branch" --arg base "$base" --arg title "$title" --arg body "$body" \
|
||||
'{head: $head, base: $base, title: $title, body: $body}')")
|
||||
http_code=$(echo "$response" | tail -1)
|
||||
json=$(echo "$response" | sed '$d')
|
||||
|
||||
if [ "$http_code" -lt 200 ] || [ "$http_code" -ge 300 ]; then
|
||||
echo "Failed to create PR (HTTP $http_code):" >&2
|
||||
echo "$json" >&2
|
||||
'{head: $head, base: $base, title: $title, body: $body}')"
|
||||
echo "$json" | jq -r '.html_url'
|
||||
;;
|
||||
edit)
|
||||
require_number "${1:-}"
|
||||
pr_number="$1"
|
||||
shift
|
||||
parse_body_args "$@"
|
||||
if [ -n "$parsed_base" ]; then
|
||||
echo "gitea-pr edit cannot change the base branch" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "$parsed_title" ] && [ -z "$body" ]; then
|
||||
echo "gitea-pr edit requires --title, --body or --body-file" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Only the fields actually given are sent: an omitted key leaves that
|
||||
# field alone, so editing a title cannot blank a body by accident.
|
||||
payload='{}'
|
||||
if [ -n "$parsed_title" ]; then
|
||||
payload=$(echo "$payload" | jq --arg t "$parsed_title" '. + {title: $t}')
|
||||
fi
|
||||
if [ -n "$body" ]; then
|
||||
# The same marker create writes. It describes where the body came
|
||||
# from rather than the event that wrote it, so an edited body
|
||||
# keeps the disclosure instead of quietly shedding it.
|
||||
payload=$(echo "$payload" | jq --arg b "**Summary generated by LLM agent**
|
||||
|
||||
$body" '. + {body: $b}')
|
||||
fi
|
||||
|
||||
api_or_die "edit PR #${pr_number}" -X PATCH \
|
||||
"${GITEA_API}/repos/${owner}/${repo}/pulls/${pr_number}" \
|
||||
-H "Content-Type: application/json" -d "$payload"
|
||||
echo "$json" | jq -r '.html_url'
|
||||
;;
|
||||
comments)
|
||||
pr_number="${1:-}"
|
||||
shift || true
|
||||
show_all=0
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--all) show_all=1 ;;
|
||||
*) echo "Unknown argument: $1" >&2
|
||||
echo "Usage: gitea-pr comments PR_NUMBER [--all]" >&2
|
||||
exit 1 ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
if [ -z "$pr_number" ]; then
|
||||
echo "Usage: gitea-pr comments PR_NUMBER" >&2
|
||||
echo "Usage: gitea-pr comments PR_NUMBER [--all]" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "=== Issue comments ==="
|
||||
response=$(gitea_api "${GITEA_API}/repos/${owner}/${repo}/issues/${pr_number}/comments")
|
||||
http_code=$(echo "$response" | tail -1)
|
||||
json=$(echo "$response" | sed '$d')
|
||||
if [ "$http_code" -lt 200 ] || [ "$http_code" -ge 300 ]; then
|
||||
echo "Failed to fetch issue comments (HTTP $http_code):" >&2
|
||||
echo "$json" >&2
|
||||
exit 1
|
||||
fi
|
||||
api_or_die "fetch issue comments" \
|
||||
"${GITEA_API}/repos/${owner}/${repo}/issues/${pr_number}/comments"
|
||||
echo "$json" | jq -r '.[] | "[\(.user.login) @ \(.created_at)]\n\(.body)\n"'
|
||||
|
||||
echo "=== Reviews ==="
|
||||
response=$(gitea_api "${GITEA_API}/repos/${owner}/${repo}/pulls/${pr_number}/reviews")
|
||||
http_code=$(echo "$response" | tail -1)
|
||||
reviews_json=$(echo "$response" | sed '$d')
|
||||
if [ "$http_code" -lt 200 ] || [ "$http_code" -ge 300 ]; then
|
||||
echo "Failed to fetch reviews (HTTP $http_code):" >&2
|
||||
echo "$reviews_json" >&2
|
||||
exit 1
|
||||
fi
|
||||
api_or_die "fetch reviews" "${GITEA_API}/repos/${owner}/${repo}/pulls/${pr_number}/reviews"
|
||||
reviews_json="$json"
|
||||
echo "$reviews_json" | jq -r '.[] | select(.body != "") | "[\(.user.login) @ \(.submitted_at) — \(.state)]\n\(.body)\n"'
|
||||
|
||||
echo "=== Inline review comments ==="
|
||||
review_ids=$(echo "$reviews_json" | jq -r '.[] | select(.comments_count > 0) | .id')
|
||||
all_comments='[]'
|
||||
for review_id in $review_ids; do
|
||||
response=$(gitea_api \
|
||||
"${GITEA_API}/repos/${owner}/${repo}/pulls/${pr_number}/reviews/${review_id}/comments")
|
||||
http_code=$(echo "$response" | tail -1)
|
||||
json=$(echo "$response" | sed '$d')
|
||||
if [ "$http_code" -lt 200 ] || [ "$http_code" -ge 300 ]; then
|
||||
echo "Failed to fetch comments for review ${review_id} (HTTP $http_code):" >&2
|
||||
echo "$json" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "$json" | jq -r '.[] | "[\(.user.login) @ \(.path):\(.line // "?")]\n\(.body)\n"'
|
||||
api_or_die "fetch comments for review ${review_id}" \
|
||||
"${GITEA_API}/repos/${owner}/${repo}/pulls/${pr_number}/reviews/${review_id}/comments"
|
||||
# The hunk, not just the path. Gitea's review-comment objects carry
|
||||
# no `line` field at all - the old `.line // "?"` could only ever
|
||||
# print `?` - but they do carry `diff_hunk`, which is the exact
|
||||
# text the comment was left against. Without it a reviewer's
|
||||
# "this is confusing" arrives with nothing to attach it to, and
|
||||
# whoever acts on the review has to guess which passage was meant.
|
||||
# `position` is the index into the diff rather than a file line, so
|
||||
# it is printed as a diff offset and not as something to jump to.
|
||||
all_comments=$(printf '%s\n%s' "$all_comments" "$json" | jq -s 'add')
|
||||
done
|
||||
|
||||
# A thread is every comment sharing a path and a diff position, and it
|
||||
# is resolved if ANY of them carries a resolver. Gitea stamps only a
|
||||
# thread's root when you resolve it, so a reply inside a closed thread
|
||||
# comes back with `resolver: null` and reads as open if each comment is
|
||||
# judged alone. That is not hypothetical: it is how a closed-and-refiled
|
||||
# thread on #96 presented as still needing an answer.
|
||||
#
|
||||
# Note what is NOT used here. A review's `state` (COMMENT vs
|
||||
# REQUEST_CHANGES) and whether comments arrived grouped under one review
|
||||
# or singly both describe how the reviewer submitted, not whether
|
||||
# anything is outstanding. Reading either as a resolution signal works
|
||||
# until a reviewer changes how they file, and then it silently stops.
|
||||
threads=$(printf '%s' "$all_comments" | jq '
|
||||
group_by(.path + "@" + (.position | tostring))
|
||||
| map({resolved: any(.[]; .resolver != null), comments: .})')
|
||||
resolved_count=$(printf '%s' "$threads" | jq '[.[] | select(.resolved)] | length')
|
||||
|
||||
if [ "$show_all" -eq 1 ]; then
|
||||
visible="$threads"
|
||||
else
|
||||
visible=$(printf '%s' "$threads" | jq '[.[] | select(.resolved | not)]')
|
||||
fi
|
||||
|
||||
printf '%s' "$visible" | jq -r '.[]
|
||||
| .resolved as $r
|
||||
| .comments[]
|
||||
| "[\(.user.login) @ \(.path) @@\(.position // "?")\(if $r then " (resolved)" else "" end)]\n\(.diff_hunk // "(no hunk recorded)")\n--- comment ---\n\(.body)\n"'
|
||||
|
||||
if [ "$show_all" -ne 1 ] && [ "$resolved_count" -gt 0 ]; then
|
||||
echo "($resolved_count resolved thread(s) hidden. Re-run with --all to include them.)"
|
||||
fi
|
||||
;;
|
||||
merge)
|
||||
pr_number="${1:-}"
|
||||
@@ -230,17 +324,9 @@ ${body}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
response=$(gitea_api -X POST "${GITEA_API}/repos/${owner}/${repo}/pulls/${pr_number}/merge" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"Do": "merge"}')
|
||||
http_code=$(echo "$response" | tail -1)
|
||||
json=$(echo "$response" | sed '$d')
|
||||
|
||||
if [ "$http_code" -lt 200 ] || [ "$http_code" -ge 300 ]; then
|
||||
echo "Failed to merge PR #${pr_number} (HTTP $http_code):" >&2
|
||||
echo "$json" >&2
|
||||
exit 1
|
||||
fi
|
||||
api_or_die "merge PR #${pr_number}" -X POST \
|
||||
"${GITEA_API}/repos/${owner}/${repo}/pulls/${pr_number}/merge" \
|
||||
-H "Content-Type: application/json" -d '{"Do": "merge"}'
|
||||
echo "Merged PR #${pr_number}"
|
||||
;;
|
||||
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 =
|
||||
+15
-22
@@ -1,30 +1,23 @@
|
||||
#!/usr/bin/env bash
|
||||
# PreToolUse hook: inside dejima, block a merge command matching
|
||||
# DEJIMA_MERGE_BLOCK - a literal command prefix (the forge's PR command
|
||||
# followed by its merge subcommand) resolved host-side by bin/dejima from
|
||||
# the configured forge and workflow
|
||||
# (see dejima-provider-lib.sh), not hardcoded to any one forge. The sandbox
|
||||
# workflow is feature branch -> open a PR/MR -> a human merges; what lands
|
||||
# on the default branch is an operator decision, exactly as it already is
|
||||
# for `git push` (see git-push-guard.sh, whose no-direct-push-to-a-blocked-
|
||||
# branch rule this is the API-side counterpart to - a merge reaches the
|
||||
# branch without a push, so that guard never sees it).
|
||||
# DEJIMA_MERGE_BLOCK - a literal command prefix resolved host-side by
|
||||
# bin/dejima from the configured forge and workflow, never hardcoded to one
|
||||
# forge. What lands on the default branch is an operator decision, as it
|
||||
# already is for `git push`; this is the API-side counterpart to
|
||||
# git-push-guard.sh, since a merge reaches the branch without a push.
|
||||
#
|
||||
# Deliberately not conditional on intent. A hook is handed a command, not a
|
||||
# conversation, so it cannot tell an instructed merge from an unprompted one;
|
||||
# the only honest place to draw the line is the context, and the context that
|
||||
# needs the line is this one - headless unattended runs under
|
||||
# --dangerously-skip-permissions, where nobody is watching the turn happen.
|
||||
# On the host the hook is a no-op and an operator can still ask for a merge.
|
||||
# Not conditional on intent: a hook is handed a command, not a conversation,
|
||||
# so it cannot tell an instructed merge from an unprompted one. The line is
|
||||
# drawn by context instead, and the context that needs it is this one -
|
||||
# unattended runs under --dangerously-skip-permissions. On the host it is a
|
||||
# no-op.
|
||||
#
|
||||
# Like its sibling this is a fast-fail guard against an overeager agent, not
|
||||
# a hard security boundary: a sandbox session has full shell access and the
|
||||
# forge token in its environment, so it could call the merge endpoint with
|
||||
# curl directly. It stops the mistake, not the attack.
|
||||
# A fast-fail guard against an overeager agent, not a boundary: a session has
|
||||
# shell access and the forge token, so it could call the merge endpoint with
|
||||
# curl. It stops the mistake, not the attack.
|
||||
#
|
||||
# DEJIMA_MERGE_BLOCK fails OPEN: empty means no command is blocked, so the
|
||||
# hook is inert rather than conditionally installed - a workflow with no
|
||||
# merge-block policy (or a forge with nothing to block) genuinely has none.
|
||||
# Fails OPEN - empty means nothing is blocked, so a workflow with no
|
||||
# merge-block policy genuinely has none rather than the hook guessing.
|
||||
|
||||
if [ -z "${DEJIMA:-}" ]; then
|
||||
exit 0
|
||||
|
||||
+30
-20
@@ -66,29 +66,24 @@ fi
|
||||
requirements=""
|
||||
decl_dir="$project_dir/.dejima"
|
||||
|
||||
# Everything below reads attacker-controllable text on its way into an
|
||||
# agent's context window, so all three costs it can impose are bounded:
|
||||
# Everything below reads attacker-controllable text on its way into a context
|
||||
# window, so each cost it can impose is bounded:
|
||||
#
|
||||
# MAX_DECLARED entries considered - the same cap read_declared() applies
|
||||
# host-side, so this reports on the same window
|
||||
# `dejima-project approve` will act on.
|
||||
# host-side, so this reports the window `approve` acts on.
|
||||
# MAX_ENTRY_LEN characters per entry.
|
||||
# MAX_LISTED entries named; the rest are counted only. Without it, 200
|
||||
# entries of 200 chars is 40 KB of attacker-chosen tokens per
|
||||
# section, and a large enough $requirements pushed the jq call
|
||||
# past MAX_ARG_STRLEN and emitted nothing - costing every
|
||||
# later session its whole briefing.
|
||||
#
|
||||
# Within that window the report is a strict subset of what `approve` accepts,
|
||||
# never a superset: it never names something approve would reject, but it can
|
||||
# stay silent about something approve would take - a dotless domain (the
|
||||
# regex below requires a label separator, deliberately, so a bare
|
||||
# IGNORE-PREVIOUS-INSTRUCTIONS can't be echoed into the context) and a valid
|
||||
# domain longer than MAX_ENTRY_LEN are both approvable host-side and both
|
||||
# silently skipped here. Fail-closed in the direction that matters: the worst
|
||||
# case is a missing line of advice, not an unapproved entry presented as fine.
|
||||
# MAX_LISTED entries actually named in the report; the rest are only
|
||||
# counted. Without it 200 entries of 200 chars would be 40 KB
|
||||
# of attacker-chosen tokens per section, and a big enough
|
||||
# $requirements used to push the jq call at the end past
|
||||
# Linux's 128 KiB MAX_ARG_STRLEN and emit nothing at all -
|
||||
# silently costing every later session in the project its
|
||||
# entire briefing.
|
||||
# The report is a strict subset of what `approve` accepts, never a superset:
|
||||
# it can stay silent about something approvable (a dotless domain, which the
|
||||
# regex refuses so a bare IGNORE-PREVIOUS-INSTRUCTIONS cannot be echoed into
|
||||
# context; or one longer than MAX_ENTRY_LEN) but never names something
|
||||
# `approve` would reject. The worst case is a missing line of advice, not an
|
||||
# unapproved entry presented as fine.
|
||||
MAX_DECLARED=200
|
||||
MAX_ENTRY_LEN=200
|
||||
MAX_LISTED=20
|
||||
@@ -133,6 +128,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 +192,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
|
||||
|
||||
+172
-10
@@ -162,6 +162,10 @@ cmp_() { "$AUDIT" compare "$WS" "$STATE" 2>&1; }
|
||||
}
|
||||
|
||||
@test "does not flag a worktree child's projects slug" {
|
||||
# A real worktree, not just a directory named like one. "Self" is derived
|
||||
# from live worktree paths now, so the fixture has to produce one.
|
||||
git -C "$WS" commit -q --allow-empty -m base
|
||||
git -C "$WS" worktree add -q "$WS/.worktrees/featurex" -b featurex
|
||||
mkdir -p "$PROJDIR/${CURRENT_SLUG}--worktrees-featurex"
|
||||
echo x > "$PROJDIR/${CURRENT_SLUG}--worktrees-featurex/file1"
|
||||
snap
|
||||
@@ -170,6 +174,21 @@ cmp_() { "$AUDIT" compare "$WS" "$STATE" 2>&1; }
|
||||
[[ "$output" != *"CROSS-PROJECT"* ]]
|
||||
}
|
||||
|
||||
# The regression guard for #104's sibling case. The old test was a
|
||||
# "$CURRENT_SLUG"-* glob, which this slug matches: a project checked out
|
||||
# beside this one, whose directory name merely extends it, was silently
|
||||
# exempt from cross-project reporting. `proj` and `proj-ci-hardener` side by
|
||||
# side is an ordinary layout, not a contrived one.
|
||||
@test "flags a sibling project whose directory name extends this one's" {
|
||||
mkdir -p "$PROJDIR/${CURRENT_SLUG}-ci-hardener"
|
||||
echo x > "$PROJDIR/${CURRENT_SLUG}-ci-hardener/file1"
|
||||
snap
|
||||
echo y > "$PROJDIR/${CURRENT_SLUG}-ci-hardener/file2"
|
||||
touch -d '+1 hour' "$PROJDIR/${CURRENT_SLUG}-ci-hardener/file2"
|
||||
run cmp_
|
||||
[[ "$output" == *"CROSS-PROJECT"* ]]
|
||||
}
|
||||
|
||||
@test "silent when no projects changed" {
|
||||
mkdir -p "$PROJDIR/-some-other-project"
|
||||
echo x > "$PROJDIR/-some-other-project/file1"
|
||||
@@ -557,7 +576,10 @@ cmp_() { "$AUDIT" compare "$WS" "$STATE" 2>&1; }
|
||||
printf 'keep = 10\n' >> "$CONFIG_DIR/config"
|
||||
snap; git -C "$WS" config user.name changed
|
||||
cmp_ >/dev/null 2>&1
|
||||
run bash -c "ls '$AUDITDIR/$slug' | wc -l"
|
||||
# *.txt, not every entry: a finding with retained content also leaves a
|
||||
# <report>.d sidecar beside it, which rotation counts as part of the
|
||||
# report it belongs to rather than as another report.
|
||||
run bash -c "ls '$AUDITDIR/$slug'/*.txt | wc -l"
|
||||
[ "$output" -le 10 ]
|
||||
}
|
||||
|
||||
@@ -723,15 +745,125 @@ REPORT
|
||||
echo 'export X=1' > "$WS/.envrc"
|
||||
mkdir -p "$WS/.vscode"; echo '{}' > "$WS/.vscode/settings.json"
|
||||
cmp_ >/dev/null 2>&1
|
||||
# 's' on the CRITICAL .envrc finding, then 'a' on the WARNING one that
|
||||
# 'i' on the CRITICAL .envrc finding, then 'a' on the WARNING one that
|
||||
# follows it - so $BASEDIR/$slug is guaranteed to exist afterward and the
|
||||
# negative grep below can't pass vacuously against a missing file.
|
||||
run bash -c "printf 's\na\n' | '$AUDIT' ack '$WS'"
|
||||
[[ "$output" == *"cannot 'always ignore' a CRITICAL surface - approve the exact content or skip."* ]]
|
||||
run bash -c "printf 'i\na\n' | '$AUDIT' ack '$WS'"
|
||||
[[ "$output" == *"cannot ignore a CRITICAL surface from now on"* ]]
|
||||
[ -f "$BASEDIR/$slug" ]
|
||||
! grep -q '^spam \.envrc' "$BASEDIR/$slug"
|
||||
}
|
||||
|
||||
@test "a finding the baseline already covers is not presented again" {
|
||||
# #65's headline bug. The approval was always written correctly; `ack`
|
||||
# just never consulted the baseline when deciding what to SHOW, so every
|
||||
# later run re-presented the same CRITICAL and appended a duplicate
|
||||
# entry. Reported as "approvals are not being recorded", which is what it
|
||||
# looks like from the outside.
|
||||
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
|
||||
snap; echo 'export X=1' > "$WS/.envrc"; cmp_ >/dev/null 2>&1
|
||||
run bash -c "printf 'a\n' | '$AUDIT' ack '$WS'"
|
||||
[ "$(grep -c '^safe .envrc' "$BASEDIR/$slug")" -eq 1 ]
|
||||
|
||||
run bash -c "printf 'a\n' | '$AUDIT' ack '$WS'"
|
||||
[[ "$output" != *".envrc"* ]]
|
||||
[[ "$output" == *"already dealt with"* ]]
|
||||
# And no second entry for the same decision.
|
||||
[ "$(grep -c '^safe .envrc' "$BASEDIR/$slug")" -eq 1 ]
|
||||
}
|
||||
|
||||
@test "a report whose CRITICALs were approved through an earlier report stops nagging" {
|
||||
# Two reports can list the same finding. Approving it in the first left
|
||||
# the second permanently -CRITICAL.txt, so bin/dejima warned about a
|
||||
# report whose every finding had been dealt with, and `ack` re-presented
|
||||
# it forever. The rename now runs on a pass that records nothing new.
|
||||
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
|
||||
snap; echo 'export X=1' > "$WS/.envrc"; cmp_ >/dev/null 2>&1
|
||||
# A second report listing the same, still-unapproved finding.
|
||||
cp "$AUDITDIR/$slug"/*-CRITICAL.txt "$AUDITDIR/$slug/2020-01-01-000000-CRITICAL.txt"
|
||||
run bash -c "printf 'a\n' | '$AUDIT' ack '$WS'"
|
||||
[[ "$output" == *"recorded 1 acknowledgement"* ]]
|
||||
# The remaining report is walked with nothing left to decide, and sheds
|
||||
# its suffix rather than staying unacknowledged forever.
|
||||
run bash -c "printf 'a\n' | '$AUDIT' ack '$WS'"
|
||||
[[ "$output" == *"already dealt with"* ]]
|
||||
run bash -c "ls '$AUDITDIR/$slug'"
|
||||
[[ "$output" != *"-CRITICAL.txt"* ]]
|
||||
}
|
||||
|
||||
@test "a finding that cannot be acknowledged is presented once, not on every run" {
|
||||
# There is nothing to record for one, so without a marker it re-prompted
|
||||
# forever and `ack` could never reach a state of having nothing to ask.
|
||||
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
|
||||
mkdir -p "$PROJDIR/-some-other-project"
|
||||
echo x > "$PROJDIR/-some-other-project/file1"
|
||||
snap
|
||||
echo y > "$PROJDIR/-some-other-project/file2"
|
||||
touch -d '+1 hour' "$PROJDIR/-some-other-project/file2"
|
||||
cmp_ >/dev/null 2>&1
|
||||
run bash -c "printf 's\n' | '$AUDIT' ack '$WS'"
|
||||
[[ "$output" == *"CROSS-PROJECT"* ]]
|
||||
grep -q '^reviewed ' "$BASEDIR/$slug"
|
||||
|
||||
run bash -c "printf 's\n' | '$AUDIT' ack '$WS'"
|
||||
[[ "$output" != *"CROSS-PROJECT"* ]]
|
||||
}
|
||||
|
||||
@test "quitting a report does not mark it reviewed" {
|
||||
# The marker means "walked to the end". An abort discards everything
|
||||
# else, and must discard this too or one q would silence the report.
|
||||
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
|
||||
mkdir -p "$PROJDIR/-some-other-project"
|
||||
echo x > "$PROJDIR/-some-other-project/file1"
|
||||
snap
|
||||
echo y > "$PROJDIR/-some-other-project/file2"
|
||||
touch -d '+1 hour' "$PROJDIR/-some-other-project/file2"
|
||||
cmp_ >/dev/null 2>&1
|
||||
run bash -c "printf 'q\n' | '$AUDIT' ack '$WS'"
|
||||
! grep -q '^reviewed ' "$BASEDIR/$slug" 2>/dev/null
|
||||
|
||||
run bash -c "printf 's\n' | '$AUDIT' ack '$WS'"
|
||||
[[ "$output" == *"CROSS-PROJECT"* ]]
|
||||
}
|
||||
|
||||
@test "a WARNING git config finding can be acknowledged, both ways (#45)" {
|
||||
# Neither `ack` nor `compare` handled this tier: no arm derived an id for
|
||||
# it, and the two branches that emit it never consulted the baseline. It
|
||||
# was the one class of finding with no way to curate it at all.
|
||||
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
|
||||
snap; git -C "$WS" config user.name changed-in-session; cmp_ >/dev/null 2>&1
|
||||
run bash -c "printf 'i\n' | '$AUDIT' ack '$WS'"
|
||||
grep -q '^spam gitconfig:user.name' "$BASEDIR/$slug"
|
||||
# ...and the surface stays quiet whatever it changes to next, which is
|
||||
# the point for a key some tool rewrites on every run.
|
||||
snap; git -C "$WS" config user.name changed-again
|
||||
run cmp_
|
||||
[[ "$output" != *"user.name"* ]]
|
||||
}
|
||||
|
||||
@test "an approved WARNING git config value re-fires when the value changes" {
|
||||
# The other verb: `safe` pins the exact value, so a different one is a
|
||||
# new decision rather than covered by the old one.
|
||||
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
|
||||
snap; git -C "$WS" config user.name first; cmp_ >/dev/null 2>&1
|
||||
run bash -c "printf 'a\n' | '$AUDIT' ack '$WS'"
|
||||
grep -q '^safe gitconfig:user.name ' "$BASEDIR/$slug"
|
||||
snap; git -C "$WS" config user.name second
|
||||
run cmp_
|
||||
[[ "$output" == *"changed git config: user.name=second"* ]]
|
||||
}
|
||||
|
||||
@test "ack emits no colour when its output is not a terminal" {
|
||||
# Reports and piped output are read back by machines - the audit's own
|
||||
# next run, and every test here. An escape sequence in either is a bug,
|
||||
# so the palette is decided from the stream rather than from a flag.
|
||||
snap; echo 'export X=1' > "$WS/.envrc"; cmp_ >/dev/null 2>&1
|
||||
run bash -c "printf 'a\n' | '$AUDIT' ack '$WS'"
|
||||
[[ "$output" != *$'\033'* ]]
|
||||
run cmp_
|
||||
[[ "$output" != *$'\033'* ]]
|
||||
}
|
||||
|
||||
@test "ack records a safe entry and clears the CRITICAL report marker" {
|
||||
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
|
||||
snap; echo 'export X=1' > "$WS/.envrc"; cmp_ >/dev/null 2>&1
|
||||
@@ -741,12 +873,22 @@ REPORT
|
||||
[[ "$output" != *"-CRITICAL.txt"* ]]
|
||||
}
|
||||
|
||||
@test "ack does not write an unmatchable entry for a non-path finding" {
|
||||
@test "ack does not write an unmatchable entry for a finding with no id" {
|
||||
# Was written against a changed git config, which #45 made ackable. The
|
||||
# invariant is about findings that have no id to record at all, so it now
|
||||
# uses the one class that still has none: a cross-project write, whose
|
||||
# subject is another project's slug rather than anything this audit can
|
||||
# hash and pin.
|
||||
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
|
||||
snap; git -C "$WS" config user.name changed; cmp_ >/dev/null 2>&1
|
||||
mkdir -p "$PROJDIR/-some-other-project"
|
||||
echo x > "$PROJDIR/-some-other-project/file1"
|
||||
snap
|
||||
echo y > "$PROJDIR/-some-other-project/file2"
|
||||
touch -d '+1 hour' "$PROJDIR/-some-other-project/file2"
|
||||
cmp_ >/dev/null 2>&1
|
||||
run bash -c "printf 'a\n' | '$AUDIT' ack '$WS'"
|
||||
[[ "$output" == *"cannot"* ]]
|
||||
[ ! -f "$BASEDIR/$slug" ]
|
||||
! grep -qE '^(safe|spam) ' "$BASEDIR/$slug" 2>/dev/null
|
||||
}
|
||||
|
||||
@test "an ack-approved finding is suppressed on the next compare (id round-trips through the baseline)" {
|
||||
@@ -978,11 +1120,11 @@ REPORT
|
||||
git -C "$WS" config core.hooksPath /tmp/evil
|
||||
mkdir -p "$WS/.vscode"; echo '{}' > "$WS/.vscode/settings.json"
|
||||
cmp_ >/dev/null 2>&1
|
||||
# 's' on the CRITICAL gitconfig finding, then 'a' on the WARNING one that
|
||||
# 'i' on the CRITICAL gitconfig finding, then 'a' on the WARNING one that
|
||||
# follows it - so $BASEDIR/$slug is guaranteed to exist afterward and the
|
||||
# negative grep below can't pass vacuously against a missing file.
|
||||
run bash -c "printf 's\na\n' | '$AUDIT' ack '$WS'"
|
||||
[[ "$output" == *"cannot 'always ignore' a CRITICAL surface - approve the exact content or skip."* ]]
|
||||
run bash -c "printf 'i\na\n' | '$AUDIT' ack '$WS'"
|
||||
[[ "$output" == *"cannot ignore a CRITICAL surface from now on"* ]]
|
||||
[ -f "$BASEDIR/$slug" ]
|
||||
! grep -q '^spam gitconfig:core.hooksPath' "$BASEDIR/$slug"
|
||||
}
|
||||
@@ -1710,3 +1852,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,174 @@
|
||||
#!/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 -
|
||||
# `confined` and `privileged` - rather than a separate vocabulary. Those are
|
||||
# the same words the runner labels and dejima-ci-hardener's roles use, so a
|
||||
# reader looking at a red `runner-env (privileged)` 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
|
||||
confined|privileged) ;;
|
||||
*) 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 confined jobs are not handed the Docker daemon" {
|
||||
[ "$DEJIMA_CI_TIER" = confined ] || skip "asserted for the confined 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 privileged job does have the daemon it is scheduled for" {
|
||||
[ "$DEJIMA_CI_TIER" = privileged ] || skip "asserted for the privileged runner"
|
||||
# The positive control for the label routing. Without it, a privileged
|
||||
# job that landed on a hardened runner would fail later and obscurely, and
|
||||
# a confined 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" = confined ]; 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 confined runner. The privileged 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" = confined ] || 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" = confined ] || 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" = confined ] || 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" = confined ] || 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" = confined ] || 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 ]
|
||||
}
|
||||
+480
-34
@@ -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)`' \
|
||||
@@ -833,24 +1045,16 @@ run_install() {
|
||||
|
||||
local name out hit=0
|
||||
|
||||
# docs/ and systemd/ are pure operator-facing prose - nothing there is
|
||||
# live shell plumbing, so any mention of a retired name is either the
|
||||
# deliberate "no longer read" notice (install.sh's remote.conf one lives
|
||||
# outside these two directories, but the exclusion is kept here too in
|
||||
# case a future doc adds one), a stale instruction, or one of the two
|
||||
# cases below that are neither:
|
||||
# docs/ and systemd/ are operator-facing prose, no live plumbing, so a
|
||||
# retired name there is either the deliberate "no longer read" notice, a
|
||||
# stale instruction, or one of two cases that are neither:
|
||||
#
|
||||
# - A manifest-field line (`env_export = GITEA_API=api ...`): this is
|
||||
# docs/providers.md quoting a forge manifest's `env_export` value
|
||||
# verbatim, which *declares* a container-side environment variable -
|
||||
# the exact thing env_export is for - not an instruction to an operator
|
||||
# to set one. `GITEA_API` and friends are retired only as host-side,
|
||||
# operator-set variables (what _warn_legacy_env's table is about); they
|
||||
# are not retired as the container-side names env_export produces, and
|
||||
# docs/providers.md documenting that mapping is not a regression.
|
||||
# - A backtick-quoted mention (`` `GITEA_API` ``): prose naming a
|
||||
# variable - "the container gets `GITEA_API`" - reads a value, it
|
||||
# doesn't tell anyone to set one.
|
||||
# - A manifest-field line (`env_export = GITEA_API=api ...`), which is
|
||||
# providers.md quoting a manifest verbatim. These names are retired as
|
||||
# host-side operator-set variables, not as the container-side names
|
||||
# env_export produces.
|
||||
# - A backtick-quoted mention - prose naming a variable reads a value, it
|
||||
# does not tell anyone to set one.
|
||||
while IFS= read -r name; do
|
||||
out="$(grep -rnE "\b${name}\b" "$REPO_ROOT/docs" "$REPO_ROOT/systemd" 2>/dev/null \
|
||||
| grep -v 'no longer read' \
|
||||
@@ -922,17 +1126,244 @@ exit 0
|
||||
EOF
|
||||
chmod +x "$STUB_DIR/docker"
|
||||
docker_log="$(mktemp)"
|
||||
HOME="$RW_HOME" PATH="$STUB_DIR:$PATH" SSH_AUTH_SOCK= \
|
||||
HOME="$RW_HOME" PATH="$STUB_DIR:$PATH" SSH_AUTH_SOCK="${RW_AGENT_SOCK-}" \
|
||||
SANDBOX_TEST_DOCKER_LOG="$docker_log" \
|
||||
run "$REPO_ROOT/bin/dejima" "$1"
|
||||
}
|
||||
|
||||
# A real agent, carrying nothing. `ssh-add -l` against it exits nonzero,
|
||||
# which is the condition the "no identities loaded" warning keys on, and a
|
||||
# handmade socket file would not reproduce it.
|
||||
_empty_agent() {
|
||||
eval "$(ssh-agent -s)" >/dev/null
|
||||
RW_AGENT_SOCK="$SSH_AUTH_SOCK"
|
||||
RW_AGENT_PID="$SSH_AGENT_PID"
|
||||
}
|
||||
_kill_agent() { [ -n "${RW_AGENT_PID:-}" ] && kill "$RW_AGENT_PID" 2>/dev/null; return 0; }
|
||||
|
||||
# The forwarded agent is the fallback; [core] ssh_key is the arrangement the
|
||||
# docs recommend, because it is a dedicated identity and works unattended.
|
||||
# So an empty agent is only worth mentioning when nothing else can push -
|
||||
# otherwise the warning fires at exactly the operators who took the advice.
|
||||
# ~/.claude/file-history is keyed by session id, so nothing in its own paths
|
||||
# says which project a directory belongs to. The transcript names are what
|
||||
# resolve it, and this is the assertion that the resolution is right: a
|
||||
# session of this workspace is mounted, one belonging to another project is
|
||||
# not, and neither is one whose slug merely extends this workspace's - the
|
||||
# sibling case that #104 turned on.
|
||||
@test "file-history is mounted for this workspace's sessions and no others" {
|
||||
_rw_setup
|
||||
workspace="$(mktemp -d)"
|
||||
local slug sib own foreign sibling
|
||||
slug="$(printf '%s' "$workspace" | sed 's/[^a-zA-Z0-9]/-/g')"
|
||||
sib="$slug-extra"
|
||||
own=11111111-1111-1111-1111-111111111111
|
||||
foreign=22222222-2222-2222-2222-222222222222
|
||||
sibling=33333333-3333-3333-3333-333333333333
|
||||
mkdir -p "$RW_HOME/.claude/projects/$slug" \
|
||||
"$RW_HOME/.claude/projects/-some-other-project" \
|
||||
"$RW_HOME/.claude/projects/$sib"
|
||||
: > "$RW_HOME/.claude/projects/$slug/$own.jsonl"
|
||||
: > "$RW_HOME/.claude/projects/-some-other-project/$foreign.jsonl"
|
||||
: > "$RW_HOME/.claude/projects/$sib/$sibling.jsonl"
|
||||
mkdir -p "$RW_HOME/.claude/file-history/$own" \
|
||||
"$RW_HOME/.claude/file-history/$foreign" \
|
||||
"$RW_HOME/.claude/file-history/$sibling"
|
||||
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only'
|
||||
_rw_launch "$workspace"
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
grep -qF "file-history/$own" "$docker_log" \
|
||||
|| { echo "own session not mounted:"; grep file-history "$docker_log"; return 1; }
|
||||
run grep -F "file-history/$foreign" "$docker_log"
|
||||
[ "$status" -ne 0 ] || { echo "another project's file-history was mounted"; return 1; }
|
||||
run grep -F "file-history/$sibling" "$docker_log"
|
||||
[ "$status" -ne 0 ] || { echo "a sibling project's file-history was mounted"; return 1; }
|
||||
rm -rf "$workspace"
|
||||
}
|
||||
|
||||
@test "an empty ssh-agent is not warned about when [core] ssh_key is set" {
|
||||
_rw_setup
|
||||
workspace="$(mktemp -d)"
|
||||
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only'
|
||||
_empty_agent
|
||||
_rw_launch "$workspace"
|
||||
_kill_agent
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
[[ "$output" != *"no identities loaded"* ]] || { echo "$output"; return 1; }
|
||||
rm -rf "$workspace"
|
||||
}
|
||||
|
||||
# The positive control for the test above: with no key configured the
|
||||
# forwarded agent really is the only way to push, and an empty one is worth
|
||||
# saying so before it fails deep into a session.
|
||||
# --extra-readonly / --extra-read-write append to the config's own lists
|
||||
# rather than carrying their own mount code, so these assert the mount lands
|
||||
# and then that every refusal the configured form gets applies to the flag
|
||||
# too. If they ever stop sharing a path, the guard tests below pass while the
|
||||
# flags quietly stop being covered by them.
|
||||
_rw_launch_flags() {
|
||||
local ws="$1"; shift
|
||||
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="$RW_HOME" PATH="$STUB_DIR:$PATH" SSH_AUTH_SOCK= \
|
||||
SANDBOX_TEST_DOCKER_LOG="$docker_log" \
|
||||
run "$REPO_ROOT/bin/dejima" "$@" "$ws"
|
||||
}
|
||||
|
||||
@test "--extra-readonly and --extra-read-write mount for one launch" {
|
||||
_rw_setup
|
||||
workspace="$(mktemp -d)"; ro="$(mktemp -d)"; rw="$(mktemp -d)"
|
||||
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only'
|
||||
_rw_launch_flags "$workspace" --extra-readonly "$ro" --extra-read-write "$rw"
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
grep -qxF "$ro:$ro:ro" "$docker_log" || { cat "$docker_log"; return 1; }
|
||||
grep -qxF "$rw:$rw" "$docker_log" || { cat "$docker_log"; return 1; }
|
||||
rm -rf "$workspace" "$ro" "$rw"
|
||||
}
|
||||
|
||||
@test "the short forms are the same flags, and both are repeatable" {
|
||||
_rw_setup
|
||||
workspace="$(mktemp -d)"; a="$(mktemp -d)"; b="$(mktemp -d)"
|
||||
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only'
|
||||
_rw_launch_flags "$workspace" --ro "$a" --ro "$b"
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
grep -qxF "$a:$a:ro" "$docker_log" || { cat "$docker_log"; return 1; }
|
||||
grep -qxF "$b:$b:ro" "$docker_log" || { cat "$docker_log"; return 1; }
|
||||
rm -rf "$workspace" "$a" "$b"
|
||||
}
|
||||
|
||||
# The flags add to the configured lists; they do not replace them. A launch
|
||||
# that dropped the config's own entries would still pass the two tests above.
|
||||
@test "a flag adds to [mounts] readonly rather than replacing it" {
|
||||
_rw_setup
|
||||
workspace="$(mktemp -d)"; configured="$(mktemp -d)"; extra="$(mktemp -d)"
|
||||
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only' \
|
||||
'[mounts]' "readonly = $configured"
|
||||
_rw_launch_flags "$workspace" --ro "$extra"
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
grep -qxF "$configured:$configured:ro" "$docker_log" || { cat "$docker_log"; return 1; }
|
||||
grep -qxF "$extra:$extra:ro" "$docker_log" || { cat "$docker_log"; return 1; }
|
||||
rm -rf "$workspace" "$configured" "$extra"
|
||||
}
|
||||
|
||||
# The point of appending to the same list: a flag is a way to say where, not
|
||||
# a way past the refusals. Both directions, since the two lists are guarded
|
||||
# by different code.
|
||||
@test "--extra-readonly is refused when it names the approval store" {
|
||||
_rw_setup
|
||||
workspace="$(mktemp -d)"
|
||||
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only'
|
||||
_rw_launch_flags "$workspace" --ro "$CONFIG_DIR"
|
||||
[ "$status" -ne 0 ] || { echo "launched anyway:"; echo "$output"; return 1; }
|
||||
[[ "$output" == *"Refusing --extra-readonly path"* ]] || { echo "$output"; return 1; }
|
||||
rm -rf "$workspace"
|
||||
}
|
||||
|
||||
@test "--extra-read-write is refused when it names the approval store" {
|
||||
_rw_setup
|
||||
workspace="$(mktemp -d)"
|
||||
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only'
|
||||
_rw_launch_flags "$workspace" --rw "$CONFIG_DIR"
|
||||
[ "$status" -ne 0 ] || { echo "launched anyway:"; echo "$output"; return 1; }
|
||||
[[ "$output" == *"Refusing --extra-read-write path"* ]] || { echo "$output"; return 1; }
|
||||
rm -rf "$workspace"
|
||||
}
|
||||
|
||||
# A refusal that named "[mounts] readonly" would send someone to a config
|
||||
# file that does not mention the path.
|
||||
@test "a rejected flag path is reported as a flag, not as a config entry" {
|
||||
_rw_setup
|
||||
workspace="$(mktemp -d)"
|
||||
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only'
|
||||
_rw_launch_flags "$workspace" --ro /nonexistent-flag-path
|
||||
[ "$status" -ne 0 ]
|
||||
[[ "$output" == *"--extra-readonly path does not exist"* ]] || { echo "$output"; return 1; }
|
||||
rm -rf "$workspace"
|
||||
}
|
||||
|
||||
# Both lists are colon-separated, so such a path cannot be represented in one
|
||||
# and would split into two mounts that each fail the existence check.
|
||||
@test "a flag path containing a colon is refused" {
|
||||
_rw_setup
|
||||
workspace="$(mktemp -d)"
|
||||
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only'
|
||||
_rw_launch_flags "$workspace" --ro /tmp/has:colon
|
||||
[ "$status" -ne 0 ]
|
||||
[[ "$output" == *"must not contain a colon"* ]] || { echo "$output"; return 1; }
|
||||
rm -rf "$workspace"
|
||||
}
|
||||
|
||||
# #15. Both of these reach `extra_mounts+=(-v ...)` with a path computed at
|
||||
# run time, so the static grep for a literal $PROJECT_STORE / config_root
|
||||
# cannot see either one - it passed throughout. [mounts] read_write has been
|
||||
# guarded since it was added; these two were not.
|
||||
@test "[mounts] readonly is refused when it names the approval store" {
|
||||
_rw_setup
|
||||
workspace="$(mktemp -d)"
|
||||
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only' \
|
||||
'[mounts]' "readonly = $CONFIG_DIR"
|
||||
_rw_launch "$workspace"
|
||||
[ "$status" -ne 0 ] || { echo "launched anyway:"; echo "$output"; return 1; }
|
||||
[[ "$output" == *"Refusing [mounts] readonly entry"* ]] || { echo "$output"; return 1; }
|
||||
rm -rf "$workspace"
|
||||
}
|
||||
|
||||
# The target is discovered from a symlink rather than configured, so nothing
|
||||
# had vetted it: a hook pointing into the approval store mounted the store.
|
||||
@test "a ~/.claude symlink is refused when its target sits in the approval store" {
|
||||
_rw_setup
|
||||
workspace="$(mktemp -d)"
|
||||
mkdir -p "$RW_HOME/.claude/hooks" "$CONFIG_DIR/sub"
|
||||
: > "$CONFIG_DIR/sub/hook.sh"
|
||||
ln -s "$CONFIG_DIR/sub/hook.sh" "$RW_HOME/.claude/hooks/hook.sh"
|
||||
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only'
|
||||
_rw_launch "$workspace"
|
||||
[ "$status" -ne 0 ] || { echo "launched anyway:"; echo "$output"; return 1; }
|
||||
[[ "$output" == *"Refusing ~/.claude symlink target"* ]] || { echo "$output"; return 1; }
|
||||
rm -rf "$workspace"
|
||||
}
|
||||
|
||||
# The ordinary case the symlink scan exists for: this repo's own hooks/ are
|
||||
# symlinked into ~/.claude and must still be mounted. Without this, refusing
|
||||
# everything would pass the two tests above.
|
||||
@test "a ~/.claude symlink to an ordinary directory is still mounted" {
|
||||
_rw_setup
|
||||
workspace="$(mktemp -d)"
|
||||
hookdir="$(mktemp -d)"
|
||||
: > "$hookdir/hook.sh"
|
||||
mkdir -p "$RW_HOME/.claude/hooks"
|
||||
ln -s "$hookdir/hook.sh" "$RW_HOME/.claude/hooks/hook.sh"
|
||||
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only'
|
||||
_rw_launch "$workspace"
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
grep -qxF "$hookdir:$hookdir:ro" "$docker_log" \
|
||||
|| { echo "hook target not mounted:"; cat "$docker_log"; return 1; }
|
||||
rm -rf "$workspace" "$hookdir"
|
||||
}
|
||||
|
||||
@test "an empty ssh-agent is warned about when [core] ssh_key is not set" {
|
||||
_rw_setup
|
||||
workspace="$(mktemp -d)"
|
||||
write_config '[core]' 'forge = none' 'workflow = local-only'
|
||||
_empty_agent
|
||||
_rw_launch "$workspace"
|
||||
_kill_agent
|
||||
[[ "$output" == *"no identities loaded"* ]] || { echo "$output"; return 1; }
|
||||
rm -rf "$workspace"
|
||||
}
|
||||
|
||||
@test "a read_write entry is mounted without :ro, unlike a readonly one" {
|
||||
_rw_setup
|
||||
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 +1388,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 +1408,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 +1417,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 +1442,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 +1462,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 +1478,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 +1495,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 +1510,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 +1521,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 +1552,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)"
|
||||
|
||||
+882
-61
File diff suppressed because it is too large
Load Diff
+415
-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,289 @@ 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; }
|
||||
}
|
||||
|
||||
# #32: an implementer ran a bare `git config user.name ...` inside a
|
||||
# worktree and wrote the parent checkout's real config, misattributing three
|
||||
# commits. A worktree isolates the working tree, not `.git/config`, and the
|
||||
# briefing told the agent to use worktrees while saying nothing about that -
|
||||
# which reads as more isolation than is on offer. The same unscoped command
|
||||
# reaches core.hooksPath and credential.helper, which execute on the host.
|
||||
#
|
||||
# In the core paragraph rather than a workflow's own prose, so a workflow can
|
||||
# opt into worktrees but cannot ship them without this.
|
||||
@test "every worktree briefing says a worktree does not isolate .git/config" {
|
||||
local wf
|
||||
for wf in worktree-pr branch-push; do
|
||||
[ "$(lib manifest_get workflow "$wf" requires_worktree)" = "on" ]
|
||||
run lib briefing_compose gitea "$wf" claude-code
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
[[ "$output" == *".git/config"* ]] \
|
||||
|| { echo "$wf briefing does not mention .git/config"; return 1; }
|
||||
[[ "$output" == *"git -C"* ]] \
|
||||
|| { echo "$wf briefing does not give the scoped form"; return 1; }
|
||||
done
|
||||
}
|
||||
|
||||
# The negative half: a workflow that does not use worktrees has no reason to
|
||||
# carry the paragraph, and its presence there would mean the splice is
|
||||
# unconditional rather than opt-in.
|
||||
@test "a workflow without worktrees does not carry the worktree paragraph" {
|
||||
[ "$(lib manifest_get workflow local-only requires_worktree)" = "off" ]
|
||||
run lib briefing_compose none local-only claude-code
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
[[ "$output" != *"does not isolate"* ]]
|
||||
[[ "$output" != *"worktree MUST live"* ]]
|
||||
}
|
||||
|
||||
@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; }
|
||||
}
|
||||
|
||||
+9
-14
@@ -409,21 +409,16 @@ make_cert() {
|
||||
# shell *function* persists in the calling shell rather than scoping to
|
||||
# the call, so it would leak into every later test in this file.
|
||||
#
|
||||
# The -u pair is what makes this test about the code rather than about
|
||||
# where it runs (#56). "No cert configured" was expressed by not setting
|
||||
# the variables, which only holds where nothing else set them -
|
||||
# entrypoint.sh:211 exports both into every tls-transport session, so a
|
||||
# suite run from inside a dejima container inherited real cert paths, the
|
||||
# guard below never fired, and execution fell through to the listen. What
|
||||
# failed then was an EADDRINUSE against the session's own forwarder on
|
||||
# :443, reported as this assertion failing - the code was fine and the
|
||||
# test was reading its own environment. Unsetting them states the
|
||||
# precondition instead of assuming it.
|
||||
# The -u pair makes this about the code rather than where it runs (#56).
|
||||
# "No cert configured" used to be expressed by not setting the variables,
|
||||
# which only holds where nothing else did - entrypoint.sh exports both
|
||||
# into every tls-transport session, so a suite run inside a dejima
|
||||
# container inherited real paths, the guard never fired, and the
|
||||
# fall-through hit EADDRINUSE on :443 and was reported as this assertion
|
||||
# failing. Unsetting states the precondition instead of assuming it.
|
||||
#
|
||||
# The port override is belt-and-braces: the cert check runs before the
|
||||
# listen today, so a passing test never binds anything, but if that order
|
||||
# ever changes the fall-through lands on an unused port rather than on
|
||||
# whatever holds :443.
|
||||
# The port override is belt-and-braces: the cert check precedes the listen
|
||||
# today, so a fall-through lands on an unused port if that ever changes.
|
||||
run env -u SANDBOX_ANTHROPIC_TLS_CERT -u SANDBOX_ANTHROPIC_TLS_KEY \
|
||||
SANDBOX_ANTHROPIC_PROXY_SOCKET="$SOCK" SANDBOX_ANTHROPIC_TRANSPORT=tls \
|
||||
SANDBOX_ANTHROPIC_PROXY_PORT=18398 \
|
||||
|
||||
+82
-27
@@ -5,6 +5,25 @@
|
||||
# compromised session writes into - so most of what's here is about what it
|
||||
# refuses to do with that input.
|
||||
|
||||
# No test in this file may modify the repository's own CLIs. Two of them need
|
||||
# an exit-time audit that fails, and they get it from a temp shadow of bin/
|
||||
# (see shadow_repo) rather than by overwriting the real one. This is what
|
||||
# proves it stayed that way even if a test aborts partway through, and it is
|
||||
# checked against a hash rather than against `git status` so that it holds
|
||||
# while someone has uncommitted edits to that file.
|
||||
setup_file() {
|
||||
REMOTE_AUDIT_SUM="$(sha256sum "$BATS_TEST_DIRNAME/../bin/dejima-audit" | cut -d' ' -f1)"
|
||||
export REMOTE_AUDIT_SUM
|
||||
}
|
||||
teardown_file() {
|
||||
local now
|
||||
now="$(sha256sum "$BATS_TEST_DIRNAME/../bin/dejima-audit" | cut -d' ' -f1)"
|
||||
[ "$now" = "$REMOTE_AUDIT_SUM" ] || {
|
||||
echo "test/remote.bats modified bin/dejima-audit in the repository" >&2
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
setup() {
|
||||
REPO_ROOT="$BATS_TEST_DIRNAME/.."
|
||||
LIB="$REPO_ROOT/bin/dejima-project-lib.sh"
|
||||
@@ -19,15 +38,6 @@ teardown() {
|
||||
if [ -n "${TMUX_DIR:-}" ]; then
|
||||
TMUX_TMPDIR="$TMUX_DIR" tmux kill-server 2>/dev/null || true
|
||||
fi
|
||||
# Safety net for tests that temporarily swap out the real bin/dejima-
|
||||
# audit: each such test restores it itself right after `run`, before any
|
||||
# assertion that could short-circuit the test body, but this catches the
|
||||
# case anyway if something in between still managed to abort first.
|
||||
if [ -n "${AUDIT_BACKUP:-}" ] && [ -f "$AUDIT_BACKUP" ]; then
|
||||
cp "$AUDIT_BACKUP" "$REPO_ROOT/bin/dejima-audit"
|
||||
chmod +x "$REPO_ROOT/bin/dejima-audit"
|
||||
rm -f "$AUDIT_BACKUP"
|
||||
fi
|
||||
rm -rf "$CONFIG_DIR" "$RUNTIME_DIR" ${STUB_DIR:+"$STUB_DIR"} ${LAUNCH_DIR:+"$LAUNCH_DIR"} \
|
||||
${TMUX_DIR:+"$TMUX_DIR"} ${FAKE_HOME:+"$FAKE_HOME"}
|
||||
}
|
||||
@@ -70,6 +80,54 @@ write_remote_config() {
|
||||
[ "$output" = "$RUNTIME_DIR/dejima" ]
|
||||
}
|
||||
|
||||
# A throwaway copy of the repository whose bin/dejima-audit a test may
|
||||
# replace or delete. Everything else is a symlink to the real thing, so this
|
||||
# costs one directory of links.
|
||||
#
|
||||
# bin/ is copied rather than linked, and both reasons matter. bin/dejima
|
||||
# resolves its own location with `readlink -f`, so a symlinked copy would
|
||||
# point SCRIPT_DIR back at the repository and the stub would never be the one
|
||||
# it calls. And a symlink is actively dangerous here: `cat > $SHADOW/bin/
|
||||
# dejima-audit` follows it and truncates the real script, which is precisely
|
||||
# what this helper exists to stop. Copying removes the whole class rather
|
||||
# than one instance of it.
|
||||
#
|
||||
# Two tests here need an audit binary that fails, and they used to get one by
|
||||
# overwriting `$REPO_ROOT/bin/dejima-audit` and restoring it afterwards. That
|
||||
# left the working tree holding a two-line stub for the length of the test,
|
||||
# which is a poor thing to do to a security tool: a run killed partway, or
|
||||
# two runs of this file at once, restores the stub permanently instead of the
|
||||
# script, and a suite that passes while silently disabling the exit-time
|
||||
# audit is the exact failure this file exists to prevent. It also, reasonably
|
||||
# enough, reads like an attack to anything watching the repository for
|
||||
# changes.
|
||||
shadow_repo() {
|
||||
local root entry
|
||||
root="$(cd "$REPO_ROOT" && pwd)"
|
||||
SHADOW="$BATS_TEST_TMPDIR/shadow"
|
||||
mkdir -p "$SHADOW"
|
||||
for entry in "$root"/*; do
|
||||
[ "$(basename "$entry")" = bin ] && continue
|
||||
ln -sfn "$entry" "$SHADOW/$(basename "$entry")"
|
||||
done
|
||||
cp -a "$root/bin" "$SHADOW/bin"
|
||||
DEJIMA_BIN="$SHADOW/bin/dejima"
|
||||
}
|
||||
|
||||
# Stands in for the real audit and does nothing but fail. Self-describing
|
||||
# because a file watcher can surface it mid-test, and a bare `exit 3` sitting
|
||||
# where a security tool belongs is alarming without this comment on it.
|
||||
shadow_failing_audit() {
|
||||
cat > "$SHADOW/bin/dejima-audit" <<'EOF'
|
||||
#!/bin/bash
|
||||
# Test fixture for test/remote.bats, living in that test's temp directory
|
||||
# and never in the repository. Fails on purpose: bin/dejima must still
|
||||
# report the container's exit code and not this one.
|
||||
exit 3
|
||||
EOF
|
||||
chmod +x "$SHADOW/bin/dejima-audit"
|
||||
}
|
||||
|
||||
# A fake `docker` that records its argv, so bin/dejima's launch path can be
|
||||
# exercised with no daemon. Same approach as test/project.bats.
|
||||
stub_launch() {
|
||||
@@ -124,7 +182,7 @@ launch() {
|
||||
SANDBOX_TEST_DOCKER_LOG="$LAUNCH_DIR/docker-log" \
|
||||
SANDBOX_TEST_SETTINGS_COPY="$LAUNCH_DIR/settings-copy.json" \
|
||||
SSH_AUTH_SOCK= ALLOWED_DOMAINS= \
|
||||
"$REPO_ROOT/bin/dejima" "$@"
|
||||
"${DEJIMA_BIN:-$REPO_ROOT/bin/dejima}" "$@"
|
||||
}
|
||||
|
||||
run_line() { grep -- ' run --rm ' "$LAUNCH_DIR/docker-log" | tail -1; }
|
||||
@@ -280,15 +338,9 @@ done
|
||||
exit 0
|
||||
EOF
|
||||
chmod +x "$LAUNCH_DIR/docker"
|
||||
AUDIT_REAL="$REPO_ROOT/bin/dejima-audit"
|
||||
AUDIT_BACKUP="$(mktemp)"
|
||||
cp "$AUDIT_REAL" "$AUDIT_BACKUP"
|
||||
printf '#!/bin/bash\nexit 3\n' > "$AUDIT_REAL"
|
||||
chmod +x "$AUDIT_REAL"
|
||||
shadow_repo
|
||||
shadow_failing_audit
|
||||
run launch "$PROJ"
|
||||
cp "$AUDIT_BACKUP" "$AUDIT_REAL"
|
||||
chmod +x "$AUDIT_REAL"
|
||||
rm -f "$AUDIT_BACKUP"
|
||||
[ "$status" -eq 5 ]
|
||||
rm -rf "$PROJ"
|
||||
}
|
||||
@@ -309,14 +361,9 @@ done
|
||||
exit 0
|
||||
EOF
|
||||
chmod +x "$LAUNCH_DIR/docker"
|
||||
AUDIT_REAL="$REPO_ROOT/bin/dejima-audit"
|
||||
AUDIT_BACKUP="$(mktemp)"
|
||||
cp "$AUDIT_REAL" "$AUDIT_BACKUP"
|
||||
rm -f "$AUDIT_REAL"
|
||||
shadow_repo
|
||||
rm -f "$SHADOW/bin/dejima-audit"
|
||||
run launch "$PROJ"
|
||||
cp "$AUDIT_BACKUP" "$AUDIT_REAL"
|
||||
chmod +x "$AUDIT_REAL"
|
||||
rm -f "$AUDIT_BACKUP"
|
||||
[ "$status" -eq 5 ]
|
||||
rm -rf "$PROJ"
|
||||
}
|
||||
@@ -880,10 +927,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
|
||||
|
||||
+318
-35
@@ -29,42 +29,31 @@ IMAGE=dejima:latest
|
||||
container_name() { echo "dejima-test-$(basename "$BATS_FILE_TMPDIR")"; }
|
||||
network_name() { echo "dejima-test-net-$(basename "$BATS_FILE_TMPDIR")"; }
|
||||
|
||||
# Every container this file starts must go on network_name(), never the
|
||||
# default bridge: dns-proxy.sh forwards allowlisted names to Docker's
|
||||
# embedded resolver at 127.0.0.11, which only exists on a user-defined
|
||||
# network (compose gives the real sandbox one). On the default bridge the
|
||||
# container gets the host's nameservers instead, dnsmasq has nowhere to
|
||||
# forward, and startup dies in dns-proxy.sh - see its own check for this.
|
||||
# Every container here must go on network_name(), never the default bridge:
|
||||
# dns-proxy.sh forwards to Docker's embedded resolver at 127.0.0.11, which
|
||||
# only exists on a user-defined network. On the default bridge dnsmasq has
|
||||
# nowhere to forward and startup dies in dns-proxy.sh.
|
||||
#
|
||||
# Readiness is the *final* `sleep infinity` (entrypoint.sh's
|
||||
# SANDBOX_TEST_MODE exec), matched exactly. A bare `pgrep -x sleep` also
|
||||
# matches the short-lived sleeps of the startup sequence itself
|
||||
# (dns-proxy.sh's dig-retry loop, entrypoint.sh's forwarder-bind wait), and
|
||||
# a test that proceeds on one of those asserts against a container whose
|
||||
# firewall hasn't been installed yet - which silently turns egress
|
||||
# assertions into vacuous passes, since everything is still reachable at
|
||||
# that point. Waiting for `sleep infinity` means the whole startup sequence
|
||||
# (DNS proxy, firewall, ipsets, logging, forwarder bind) has completed.
|
||||
# Readiness is the FINAL `sleep infinity` (entrypoint.sh's SANDBOX_TEST_MODE
|
||||
# exec), matched exactly. A bare `pgrep -x sleep` also matches the startup
|
||||
# sequence's own short sleeps, and proceeding on one of those asserts against
|
||||
# a container with no firewall yet - turning egress assertions into vacuous
|
||||
# passes, since everything is still reachable.
|
||||
#
|
||||
# 120 rather than 60: init-firewall.sh's GitHub ranges fetch now retries, and a
|
||||
# run that loses two attempts before succeeding spends ~26s of the budget on
|
||||
# that call alone (#59) - which on a runner that has just spent three minutes
|
||||
# unpacking an image is enough to turn a recovery into a spurious failure. A
|
||||
# longer budget only delays reporting a genuinely stuck startup; it cannot hide
|
||||
# one, since nothing here passes on a timeout.
|
||||
# `docker wait` returns the moment the container exits, but the json-file log
|
||||
# driver writes asynchronously - so reading the log right then can return one
|
||||
# that stops mid-stream, with everything after some flush boundary missing.
|
||||
# 120s rather than 60: the GitHub ranges fetch retries, and losing two
|
||||
# attempts costs ~26s (#59), which on a runner that just spent minutes
|
||||
# unpacking an image turns a recovery into a spurious failure. A longer
|
||||
# budget only delays reporting a stuck startup; nothing here passes on a
|
||||
# timeout.
|
||||
# `docker wait` returns on exit, but the json-file log driver writes
|
||||
# asynchronously, so reading right then can return a log that stops
|
||||
# mid-stream. That presented as a container "aborting" partway through
|
||||
# init-firewall.sh's domain loop at a different domain each run, and survived
|
||||
# three fixes before the giveaway: no message from an ERR trap that cannot be
|
||||
# bypassed. Nothing had aborted; the read was short.
|
||||
#
|
||||
# That is not hypothetical. It presented as a container "aborting" partway
|
||||
# through init-firewall.sh's domain loop, at a slightly different domain each
|
||||
# run, and survived three separate fixes to that loop before the giveaway: the
|
||||
# log ended with no message from an ERR trap that cannot be bypassed and no
|
||||
# output from a retry that prints on every path. Nothing had aborted. The read
|
||||
# was short.
|
||||
#
|
||||
# Two identical consecutive reads means the driver has caught up. Deliberately
|
||||
# content-neutral - waiting for an expected string would make the wait pass or
|
||||
# Two identical consecutive reads means the driver has caught up.
|
||||
# Content-neutral on purpose - waiting for an expected string would pass or
|
||||
# fail on the very thing the caller is about to assert.
|
||||
settled_logs() {
|
||||
local container="$1" current previous=""
|
||||
@@ -85,6 +74,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 +195,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 +210,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 +360,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 +425,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
|
||||
@@ -457,6 +583,73 @@ dexec_node() {
|
||||
rm -rf "$src"
|
||||
}
|
||||
|
||||
# #104: the whole ~/.claude/projects tree used to be mounted read-write, so a
|
||||
# session could write another project's transcripts and agent memory. Memory
|
||||
# is loaded into context at session start, which makes that a write into a
|
||||
# future session's premises rather than its input.
|
||||
#
|
||||
# Three properties, and the second is the one that decides whether scoping is
|
||||
# viable at all: a slug that is absent has to be creatable, because that is
|
||||
# what Claude Code does on a first run against a path with no history. If the
|
||||
# parent were read-only instead, this is where it would hang the way a
|
||||
# blocked ~/.claude.json write did.
|
||||
@test "only the mounted project slugs exist in the container, and an unmounted one is still creatable" {
|
||||
local test_container="dejima-test-projects-$(basename "$BATS_FILE_TMPDIR")"
|
||||
local src; src="$(mktemp -d)"
|
||||
mkdir -p "$src/own" "$src/sibling" "$src/fh-own"
|
||||
echo host > "$src/own/existing.jsonl"
|
||||
echo secret > "$src/sibling/other.jsonl"
|
||||
# file-history holds verbatim copies of edited files, so an unmounted
|
||||
# session directory being absent is the whole point of scoping it.
|
||||
echo 'other project source' > "$src/fh-own/aaaa@v1"
|
||||
chmod -R 777 "$src"
|
||||
docker run -d --name "$test_container" \
|
||||
--cap-add=NET_ADMIN --cap-add=NET_RAW \
|
||||
--network "$(network_name)" \
|
||||
-e SANDBOX_TEST_MODE=1 \
|
||||
-e GH_TOKEN="${GH_TOKEN:-}" \
|
||||
-v "$src/own:/home/node/.claude/projects/own" \
|
||||
-v "$src/fh-own:/home/node/.claude/file-history/own-session" \
|
||||
--entrypoint /usr/local/bin/entrypoint.sh \
|
||||
"$IMAGE" >/dev/null
|
||||
wait_ready "$test_container"
|
||||
|
||||
# the sibling project is absent, not merely unwritable
|
||||
run docker exec -u node "$test_container" test -e /home/node/.claude/projects/sibling
|
||||
[ "$status" -ne 0 ]
|
||||
run docker exec -u node "$test_container" ls /home/node/.claude/projects
|
||||
[[ "$output" != *sibling* ]]
|
||||
|
||||
# a slug with no history yet can still be created, and stays in the
|
||||
# container: it is not on the host afterwards
|
||||
run docker exec -u node "$test_container" \
|
||||
bash -c 'mkdir -p /home/node/.claude/projects/fresh && echo x > /home/node/.claude/projects/fresh/a.jsonl'
|
||||
[ "$status" -eq 0 ]
|
||||
[ ! -e "$src/fresh" ]
|
||||
|
||||
# and the mounted slug is the one that does reach the host
|
||||
docker exec -u node "$test_container" \
|
||||
bash -c 'echo written > /home/node/.claude/projects/own/new.jsonl'
|
||||
[ "$(cat "$src/own/new.jsonl")" = written ]
|
||||
|
||||
# Same three properties for file-history. The middle one is what the
|
||||
# image's pre-created directory buys: without it Docker creates
|
||||
# file-history/ root-owned while setting up the mount nested in it, and
|
||||
# the session cannot record an undo entry for its own id at all.
|
||||
run docker exec -u node "$test_container" ls /home/node/.claude/file-history
|
||||
[[ "$output" != *other-session* ]]
|
||||
run docker exec -u node "$test_container" \
|
||||
bash -c 'mkdir -p /home/node/.claude/file-history/fresh-session && echo x > /home/node/.claude/file-history/fresh-session/b@v1'
|
||||
[ "$status" -eq 0 ]
|
||||
[ ! -e "$src/fresh-session" ]
|
||||
docker exec -u node "$test_container" \
|
||||
bash -c 'echo written > /home/node/.claude/file-history/own-session/c@v1'
|
||||
[ "$(cat "$src/fh-own/c@v1")" = written ]
|
||||
|
||||
docker rm -f "$test_container" >/dev/null 2>&1 || true
|
||||
rm -rf "$src"
|
||||
}
|
||||
|
||||
@test "a staged statusline.sh is copied in root-owned, and replacing it never reaches the host" {
|
||||
# Companion to the overlay-dirs test above, but for the config
|
||||
# bin/dejima stages at /etc/dejima-claude for entrypoint.sh to copy into
|
||||
@@ -594,7 +787,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 +801,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 +1098,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 +1399,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"* ]]
|
||||
}
|
||||
|
||||
+931
-41
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -44,5 +44,19 @@ Git workflow - do this even though nothing stops you from doing otherwise:
|
||||
crash, a restart, or a destructive command it is warning about will get
|
||||
the call blocked, which looks like a broken tool rather than what it is.
|
||||
Writing the file first keeps the prose out of the command entirely.
|
||||
`{{issue_command}}` (list/view/create/comment/close/reopen) takes the same
|
||||
flag, for recording a problem you found but aren't fixing.
|
||||
`{{issue_command}}` (list/view/create/edit/comment/close/reopen) takes the
|
||||
same flag, for recording a problem you found but aren't fixing.
|
||||
- Do not hard-wrap prose in those bodies. Write each paragraph as one long
|
||||
line and separate paragraphs with a blank line. The forge renders a single
|
||||
newline as a line break, so text you wrapped at a column arrives with a
|
||||
break every ten words: it wraps early on a desktop, and on a phone the
|
||||
screen breaks the line first and your newline breaks it again a few words
|
||||
later, which is much harder to read than either alone. Keep the breaks
|
||||
that carry structure - list items, table rows, headings, and anything
|
||||
inside a code fence - and drop the ones that were only there to keep the
|
||||
source tidy. The tools send the body through exactly as written; nothing
|
||||
reflows it for you.
|
||||
- To revise a body after the fact, use `{{pr_command}} edit <N> --body-file`
|
||||
(or `{{issue_command}} edit <N>`). Rewriting the file you built the body
|
||||
from changes nothing on its own - the forge has its own copy from the
|
||||
moment you created it, so an edit has to be sent.
|
||||
|
||||
Reference in New Issue
Block a user