Compare commits

...
Author SHA1 Message Date
branderson 5a80e6f76f firewall: keep GitHub port-restricted however it was allowlisted
tests / sandbox (proxy) (pull_request_target) Successful in 2m2s
tests / runner-env (sandbox) (pull_request_target) Successful in 1m24s
tests / sandbox (remote) (pull_request_target) Successful in 4m38s
tests / sandbox (config) (pull_request_target) Successful in 5m20s
tests / sandbox (static) (pull_request_target) Successful in 2m43s
tests / sandbox (providers) (pull_request_target) Successful in 3m55s
tests / sandbox (project) (pull_request_target) Successful in 5m15s
tests / sandbox (audit) (pull_request_target) Successful in 7m25s
tests / runner-env (host-docker) (pull_request_target) Successful in 1m22s
tests / host-docker (pull_request_target) Successful in 5m4s
GitHub's addresses live in github-domains, which init-firewall.sh opens on
443 and nothing else. That restriction is the 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.

Two paths put addresses somewhere else. init-firewall.sh's extra-domains loop
and bin/dejima-allow's live add both wrote unconditionally into
allowed-domains, which carries no port restriction at all. So a project with
github.com in its .dejima/allowed-domains - this one has, and so does any
project whose CI runner needs GitHub, since the hardener reads that same file
- opened port 22 to whichever address GitHub returned at container start.

dns-proxy.sh already got this right and says so in a comment: dnsmasq applies
the longest matching ipset= line, so GitHub names reach github-domains no
matter which list allowlisted them. The other two paths never asked.
ipset_for_domain now answers for all three, and lives in fixed-domains.sh for
the reason that file exists - so the answer cannot drift between the places
that need it.

How it presented is worth recording, because it is nearly the opposite of
what it was. The port-22 self-check resolves github.com a second time, at the
end of startup. When GitHub returns the same address the check sees the open
port and kills the container; when the name has rotated in between, the
second address is only in github-domains and the check passes. So a security
hole surfaced as an intermittent boot failure, on a commit that had nothing
to do with it - #88 was suspected and reverted-in-preparation on exactly that
evidence. The self-check earned its keep here; what it could not do was say
which of the two answers it was looking at.

.dejima/allowed-domains keeps its GitHub entries. They are redundant for the
sandbox, which allowlists GitHub by published range and by DNS suffix
already, but dejima-ci-hardener reads the same file to build the CI runner's
egress allowlist, where they are not redundant at all.

The test asserts the address, not the symptom: github.com resolved through
the container's own dnsmasq is in github-domains, is not in allowed-domains,
and port 22 does not connect. That holds whichever way the second lookup
falls, which the self-check alone does not.
2026-08-06 09:37:13 -07:00
branderson 415564f00f Merge pull request 'firewall: stop assuming the embedded resolver's forward stays on loopback' (#88) from dns-forward-egress into main
Reviewed-on: branderson/dejima#88
Reviewed-by: branderson <brad@branderson.io>
2026-08-06 02:31:01 -07:00
branderson 8cc9e29ab3 firewall: stop assuming the embedded resolver's forward stays on loopback
tests / runner-env (sandbox) (pull_request_target) Successful in 1m27s
tests / sandbox (proxy) (pull_request_target) Successful in 2m5s
tests / sandbox (static) (pull_request_target) Successful in 2m56s
tests / sandbox (remote) (pull_request_target) Successful in 4m34s
tests / sandbox (project) (pull_request_target) Successful in 5m10s
tests / sandbox (audit) (pull_request_target) Successful in 7m16s
tests / sandbox (providers) (pull_request_target) Successful in 3m53s
tests / sandbox (config) (pull_request_target) Successful in 5m14s
tests / host-docker (pull_request_target) Successful in 4m54s
tests / runner-env (host-docker) (pull_request_target) Successful in 1m21s
The loopback rule carried the claim that DNS "goes entirely through Docker's
embedded resolver at 127.0.0.11, which stays on loopback, so this also covers
DNS". Only the first two hops do. 127.0.0.11 does not answer external names
itself - it forwards them to the host's nameservers, and on Docker 26.1.5
that forward leaves the container's network namespace, so it traverses OUTPUT
like anything else. Nothing there can match it: the host's resolver is in
neither ipset and is not the bridge gateway. It reaches the catch-all REJECT,
every lookup returns SERVFAIL, and the container dies in a self-check that
names a host rather than the cause.

Measured, not deduced: on the failing host the OUTPUT catch-all had taken ten
packets of 646 bytes - DNS query size, one per SERVFAIL in dnsmasq's log -
while every ACCEPT rule including the bridge gateway's sat at zero.

This is not a property of any host's configuration. Two machines with
byte-identical /etc/resolv.conf behaved differently; what differed was the
engine, 29.7.1 forwarding somewhere this ruleset never sees and 26.1.5 not.
Debian's docker.io is 26.x, so the obvious way to install Docker on the
distribution this project's own runner runs is the way that breaks.

UDP/53 and TCP/53 are therefore opened, restricted to root-owned sockets by
the owner match. That restriction is the whole design, not a detail: the
forwarding socket belongs to dockerd, which is root, while the session runs
as `node` and the image ships no sudo - so a compromised session still cannot
address a resolver of its own choosing, and the DNS allowlist still holds
against the attack it exists for, which is data carried out in a query name
to a resolver no allowlist of names can vet. What the rule newly permits is
DNS from processes that are already root in a container holding NET_ADMIN,
and those can flush this ruleset outright instead.

Guarded rather than asserted, since xt_owner is a module: a kernel without it
should produce a container that explains itself, not one that aborts on an
iptables usage error.

The self-check gains a DNS probe ahead of both connectivity checks. It is
what should have made this a one-line failure. With DNS dead the negative
control fails for the wrong reason and prints "example.com is unreachable, as
it should be", so the run reports a passing check and then dies on the
positive control - which said only that a host was unreachable, having sent
curl's actual complaint to /dev/null. The new probe names the hop, prints
dig's answer, the packets the catch-all took, and dnsmasq's last lines.

runtime.bats gains the assertion that makes the opening safe rather than
merely convenient: `node` resolves an allowlisted name, and `node` cannot
reach 1.1.1.1 directly. A real resolver, so that a pass means the packet was
stopped rather than that the probe was aimed at nothing.
2026-08-06 02:19:50 -07:00
branderson 3fbd6ad75f Merge pull request 'Keep one copy of the pull_request_target rationale' (#87) from wf-comment into main
Reviewed-on: branderson/dejima#87
Reviewed-by: branderson <brad@branderson.io>
2026-08-05 22:49:17 -07:00
branderson 5dc158f3a1 Merge pull request 'Document that the sandbox authenticates as the operator' (#80) from sandbox-identity into main
Reviewed-on: branderson/dejima#80
2026-08-05 22:48:52 -07:00
branderson 531779106e ci: keep one copy of the pull_request_target rationale
tests / sandbox (proxy) (pull_request_target) Successful in 2m2s
tests / sandbox (providers) (pull_request_target) Successful in 4m16s
tests / runner-env (sandbox) (pull_request_target) Successful in 1m28s
tests / sandbox (remote) (pull_request_target) Successful in 5m2s
tests / runner-env (host-docker) (pull_request_target) Successful in 1m22s
tests / sandbox (static) (pull_request_target) Successful in 3m0s
tests / sandbox (project) (pull_request_target) Successful in 5m52s
tests / sandbox (config) (pull_request_target) Successful in 5m59s
tests / sandbox (audit) (pull_request_target) Successful in 8m14s
tests / host-docker (pull_request_target) Failing after 7m46s
Removing the transitional trigger re-added the explanation next to the copy
already above it, so the file opened with the same twenty-five lines twice.
The fuller of the two is kept - it names the controls a branch would be opting
out of, which is the part that makes the paragraph worth reading.
2026-08-05 22:08:05 -07:00
branderson 14bb297dbc Merge branch 'main' into sandbox-identity
tests / runner-env (sandbox) (pull_request_target) Successful in 1m30s
tests / sandbox (proxy) (pull_request_target) Successful in 2m16s
tests / sandbox (static) (pull_request_target) Successful in 3m6s
tests / sandbox (providers) (pull_request_target) Successful in 4m18s
tests / sandbox (project) (pull_request_target) Successful in 5m52s
tests / host-docker (pull_request_target) Successful in 6m13s
tests / sandbox (remote) (pull_request_target) Successful in 5m9s
tests / sandbox (config) (pull_request_target) Successful in 6m1s
tests / runner-env (host-docker) (pull_request_target) Successful in 1m26s
tests / sandbox (audit) (pull_request_target) Successful in 8m52s
2026-08-05 22:03:44 -07:00
branderson 1cb1be5ee8 Merge pull request 'Say so when the marker directory is there but unwritable' (#85) from notify-dir-check into main
Reviewed-on: branderson/dejima#85
Reviewed-by: branderson <brad@branderson.io>
2026-08-05 22:03:19 -07:00
branderson 799ffa0770 docs: the sandbox authenticates as the operator, so identity controls are guards
tests / sandbox (proxy) (pull_request_target) Successful in 2m38s
tests / sandbox (providers) (pull_request_target) Successful in 4m24s
tests / runner-env (sandbox) (pull_request_target) Successful in 1m28s
tests / sandbox (project) (pull_request_target) Successful in 6m4s
tests / sandbox (static) (pull_request_target) Successful in 2m56s
tests / sandbox (config) (pull_request_target) Successful in 6m9s
tests / sandbox (remote) (pull_request_target) Successful in 5m6s
tests / host-docker (pull_request_target) Successful in 5m40s
tests / sandbox (audit) (pull_request_target) Successful in 8m31s
tests / runner-env (host-docker) (pull_request_target) Successful in 1m35s
A session pushes with the operator's forwarded SSH key and a token issued under
their account, so at the forge a push, a pull request, a comment or a merge
made from inside the sandbox is not similar to one the operator made - it is
the same event. Every forge control that answers "who did this" therefore
admits the sandbox exactly as it admits the operator, and there is no way to
configure around that while the identity is shared.

That matters most where someone would reasonably believe they had fixed
something. Putting yourself on a branch's merge whitelist puts the session on
it too. Required approvals are not merely weak but unusable, since a forge
will not let a pull request's author approve it and the session opens them as
you. And the forge's own audit log cannot attribute - the same shape as the
exit-time audit's time-window caveat, which this section points at rather than
restating.

The remedy is configuration rather than a missing feature, and it ships
alongside the problem so a reader is not left with nothing to do: a dedicated
forge account with write access, named by [forge.gitea] token_file and
[core] ssh_key, at which point the merge whitelist, the push whitelist and an
approval requirement all start enforcing what they appear to.

Two limits are stated rather than left to be discovered. Nothing enforces
which credential a push actually uses - [core] ssh_key is an independent path,
so a forwarded agent may still sign, making the separation decorative for the
one operation it was meant to constrain, which is worth a test push rather
than an assumption. And a separate identity does nothing for CI: a
write-capable account still pushes feature branches, which is what makes CI
execute code, and the token a forge injects into a job is a third principal
subject to none of those settings.

docs/ci.md's branch-protection prerequisite links here, because that section
told an operator to protect main without saying that on a default dejima setup
branch protection does not distinguish them from the session they are
protecting against.

static.bats pins both halves in security.md - the problem and the separation
that fixes it - and the link from ci.md. The anchor was checked against the
heading a renderer would derive, since a dead cross-document anchor is silent
in both files.
2026-08-05 21:57:19 -07:00
branderson bb2c1f218f test: give the marker-directory tests their own containers
tests / sandbox (config) (pull_request_target) Successful in 6m1s
tests / runner-env (host-docker) (pull_request_target) Successful in 1m26s
tests / sandbox (audit) (pull_request_target) Successful in 9m3s
tests / sandbox (proxy) (pull_request_target) Successful in 2m14s
tests / runner-env (sandbox) (pull_request_target) Successful in 1m26s
tests / sandbox (static) (pull_request_target) Successful in 3m6s
tests / sandbox (providers) (pull_request_target) Successful in 4m13s
tests / sandbox (remote) (pull_request_target) Successful in 5m3s
tests / sandbox (project) (pull_request_target) Successful in 5m55s
tests / host-docker (pull_request_target) Successful in 6m39s
They called container_name(), which is this file's SHARED container - created
in setup_file and used by every dexec test in the run. Starting a second
container under that name is the conflict CI reported, and the `docker rm -f`
at the end of each body would have destroyed the shared one had the name not
collided first. The failure was the only thing that stopped it.

Tests that start their own container here use a distinct prefix and register
it in teardown, which is unconditional; cleanup at the end of a body is not,
because bats runs bodies under errexit and a failed assertion abandons the
rest. Both now do that.

Two things in the first test would also have let it pass for the wrong reason.

It mounted the directory read-only to make it unwritable, which makes the
"reported, not repaired" assertion vacuous - root could not have chowned it
even if entrypoint.sh tried. Mode 555 on an ordinary mount leaves it
repairable from inside the container and still unwritable by the session user,
so that assertion tests something.

And it asserted the directory was still owned by root, which depends on who
ran the suite: mktemp -d follows the caller, so a non-root run would have
failed on ownership having nothing to do with the check. It now asserts what
the check actually reads and what repairing it would change - that the session
user still cannot write there.
2026-08-05 21:52:50 -07:00
branderson 4e6261561e remote: say so when the marker directory is there but unwritable
Restarting a sandbox container rather than relaunching it silently breaks
remote access, in a way that presents as three unrelated symptoms with no
obvious moment they started: notifications stop arriving, the session is
missing from dejima-attach, and the notify hook reports EACCES.

bin/dejima creates the marker directory per launch and removes it on exit, so
a restarted container has no bind source left. Docker's answer to a missing
bind source is not to refuse - it creates one, root-owned. The directory then
exists, which is all the notify hook's own `[ -d ]` guard asks, so the guard
passes and the write fails instead of the hook exiting quietly the way it does
when remote access simply is not configured. The session's registry entry went
with the same directory, which is the half that explains dejima-attach.

entrypoint.sh now checks it at startup, because that is the only place that
runs on a container restart - bin/dejima is not involved in one, so a check
there would have missed this entirely. A launcher-side check is added too, for
the different case of a stale root-owned leaf that mkdir -p happily succeeds
against, but it is the container-side one that catches the reported failure.

Reported, not repaired. Chowning the directory would restore notifications
while the session stayed absent from the registry - which bin/dejima writes
host-side and never mounts into the container - leaving a session that looks
fixed with half the feature still broken. The message names relaunching
instead.

Both checks are inside the "was this configured at all" guard, and there is a
test for that rather than only for the warning: remote access is opt-in and off
by default, so for almost every session this must say nothing. A warning about
an unconfigured optional feature would fire on every start, and a warning that
fires on every start is one nobody reads.

bin/dejima warns and continues rather than refusing. The state-dir check just
above it does refuse, but that one is about a symlink handing a session an
attacker-chosen path; this is only about an optional feature working, and
blocking a launch over undeliverable notifications costs more than it saves.

runtime.bats covers both directions against real containers - an unwritable
marker directory is reported and left alone, and an ordinary session says
nothing. static.bats pins the guard's placement, that nothing chowns the
directory, and that the launcher does not exit.
2026-08-05 21:52:50 -07:00
branderson 56c1ce90c3 Merge pull request 'Drop the transitional pull_request trigger' (#86) from trigger-followup into main
Reviewed-on: branderson/dejima#86
Reviewed-by: branderson <brad@branderson.io>
2026-08-05 21:33:10 -07:00
branderson 0bc660b8d1 Merge branch 'main' into trigger-followup
tests / sandbox (proxy) (pull_request_target) Successful in 2m7s
tests / runner-env (sandbox) (pull_request_target) Successful in 1m28s
tests / sandbox (providers) (pull_request_target) Successful in 4m36s
tests / sandbox (static) (pull_request_target) Successful in 3m3s
tests / sandbox (config) (pull_request_target) Successful in 6m30s
tests / sandbox (project) (pull_request_target) Successful in 6m6s
tests / sandbox (remote) (pull_request_target) Successful in 4m55s
tests / sandbox (audit) (pull_request_target) Successful in 8m57s
tests / runner-env (host-docker) (pull_request_target) Successful in 1m24s
tests / host-docker (pull_request_target) Successful in 5m53s
2026-08-05 21:18:45 -07:00
branderson cbcce5e225 Merge pull request 'CI: disclose the risk, verify our own runners, point at dejima-ci-hardener' (#84) from ci-final into main
Reviewed-on: branderson/dejima#84
Reviewed-by: branderson <brad@branderson.io>
2026-08-05 21:18:20 -07:00
branderson 761feeedae ci: retry the resolver preflight, and name the intermittent cause
tests / sandbox (static) (pull_request) Successful in 3m8s
tests / sandbox (project) (pull_request) Successful in 5m37s
tests / sandbox (config) (pull_request) Successful in 5m44s
tests / host-docker (pull_request) Successful in 5m21s
tests / runner-env (host-docker) (pull_request) Successful in 1m25s
tests / runner-env (sandbox) (pull_request) Successful in 1m27s
tests / sandbox (proxy) (pull_request) Successful in 2m19s
tests / sandbox (providers) (pull_request) Successful in 4m17s
tests / sandbox (remote) (pull_request) Successful in 5m5s
tests / sandbox (audit) (pull_request) Successful in 8m5s
Deciding on one lookup was stricter than the step it protects: apt retries,
and a resolver can be a moment behind a container's start, so a single blip
failed a whole job. Six attempts over about fifteen seconds now, and it says
which attempt succeeded when it was not the first - a slow resolver is worth
knowing about even when it works.

The diagnosis also splits on the shape of the failure, because the two mean
different things and only one of them is about this host. Jobs failing while
others in the same run pass is not a resolver fault at all: it is runners
answering the same label with different configuration, so the jobs that land
on one without --dns fail and the rest do not. That reads as intermittent and
sends people looking at the network. Every job failing is the resolver, and
the checks for that are listed separately, ending at the dnsmasq log - which
is what distinguishes a query that never arrives from one forwarded to an
upstream that never answers.
2026-08-05 20:53:47 -07:00
branderson b663d51678 ci: drop the transitional pull_request trigger
The pair existed so the change introducing pull_request_target could be tested
by its own copy from the head - pull_request_target only takes effect once it
is on the base branch, so that change could not be covered by it.

That is now true, so pull_request comes out. Leaving it would keep taking an
untrusted run from the head on every pull request, with runs-on chosen by the
branch, which is the thing pull_request_target exists to prevent.

This change is itself covered by the copy already on main, which is the point
of the two-step: the first merge makes the trigger real, the second removes
the one it replaced, and neither lands without checks.
2026-08-05 20:49:53 -07:00
branderson 14fde86f68 ci: say why a job cannot resolve, before apt says something else
tests / sandbox (proxy) (pull_request) Successful in 2m16s
tests / runner-env (sandbox) (pull_request) Successful in 1m29s
tests / sandbox (static) (pull_request) Successful in 3m10s
tests / sandbox (remote) (pull_request) Successful in 5m5s
tests / host-docker (pull_request) Successful in 5m50s
tests / runner-env (host-docker) (pull_request) Successful in 1m24s
tests / sandbox (providers) (pull_request) Successful in 4m18s
tests / sandbox (project) (pull_request) Successful in 5m44s
tests / sandbox (config) (pull_request) Successful in 5m46s
tests / sandbox (audit) (pull_request) Successful in 7m59s
A job on a runner behind the egress allowlist fails at `apt-get update` with
"Temporary failure resolving" and then "Unable to locate package bats". None
of that is about apt, and none of it names the resolver or the allowlist -
which is where the fault actually is.

The suite that would say so precisely, ci-runner.bats, cannot run: installing
it is the step that just failed. So the diagnosis has to come before the
install, from what the image already has - getent is libc, and the resolver's
address is in resolv.conf.

The step now names the resolver the job was given and lists what to check in
order, including the case that produces exactly this symptom and looks like
nothing else: an empty allowlist. With no-resolv and no server= lines there is
no upstream for any name, so every lookup is refused and every job dies in apt
regardless of what it was going to install.
2026-08-05 19:54:48 -07:00
branderson 76f61e77c0 ci: carry both triggers through the first merge
tests / sandbox (config) (pull_request) Failing after 4m22s
tests / sandbox (project) (pull_request) Failing after 4m21s
tests / sandbox (proxy) (pull_request) Failing after 4m22s
tests / sandbox (providers) (pull_request) Failing after 4m22s
tests / sandbox (remote) (pull_request) Failing after 4m21s
tests / host-docker (pull_request) Has been cancelled
tests / sandbox (static) (pull_request) Failing after 4m22s
tests / runner-env (sandbox) (pull_request) Failing after 4m21s
tests / runner-env (host-docker) (pull_request) Has been cancelled
tests / sandbox (audit) (pull_request) Failing after 4m43s
pull_request_target is read from the base branch, and Gitea explicitly ignores
a pull_request_target workflow found on a pull request's head. So the change
that introduces it cannot be tested by it: with only pull_request_target here
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 reads as a broken
workflow rather than as the bootstrap problem it is.

Keeping pull_request alongside it means this change is tested by its own copy
from the head, the way every pull request is today. Once merged, main carries
pull_request_target and a one-line follow-up removes pull_request - and that
follow-up is covered by the copy on main, so CI is continuous across both.

Between the two merges pull requests get both runs: untrusted from the head,
trusted from the base. That is noisy, and for the untrusted half it is the
status quo rather than a regression, so the window wants to be short.

The static guard now allows the pair only while the transitional note is
present, so dropping the note without dropping the trigger fails rather than
letting a temporary state become permanent. docs/ci.md records the two-merge
adoption, since anyone following this hits the same silence.
2026-08-05 19:45:27 -07:00
branderson 49d904bed7 docs: make the CI advisory answerable without moving a repository
The advisory had drifted into being about hardening a runner you already
operate, with "move the repositories somewhere with only hardened runners" as
the way to close what hardening cannot. For a public project that means
breaking its name, its links and its ownership, which is not a thing to ask
of anyone - and it left users whose projects have no CI believing none of this
applied to them.

Both are now addressed by asking a different question first: not "how do I
harden my runner" but "what runners can this repository's branches reach". The
rule is stated once - every reachable runner must be one you would accept an
attacker running code on - and everything else follows from it.

That framing makes the no-CI case visible, which is the one nobody expects. A
repository with no workflows is not safe by virtue of having none, because a
branch adds the first one; the exposure follows from reachability, which is a
property of the forge rather than of the project. Said in the README too,
since that is as far as many readers get.

It also separates the mild case from the sharp one instead of alarming
everybody equally. On hosted runners the machine is disposable and is not
yours, so the "execution on a host you own" item largely evaporates and what
remains is credentials - worth saying plainly rather than leaving a reader to
assume the worst applies to them.

For the sharp case there are now three answers that move nothing:

Disable the Actions unit where a project does not use CI. No workflow runs at
all, so nothing is reachable and a branch cannot add the first one. Re-enabling
needs admin - Gitea puts repository settings behind reqAdmin() - so a bot
account with Write cannot undo it. Free where CI was not being used, and the
most commonly overlooked.

Scope the dangerous runner down instead of moving the repository out. A runner
can belong to an owner, a user as well as an organization; make the hardened
one the owner-level default and register the permissive one at repository
scope for the few repositories that genuinely need a socket. Most repositories
need to run tests, not build images.

Or decline the push grant, which closes the vector rather than bounding it and
is the only answer that does not depend on getting a forge's configuration
right.

static.bats pins the no-CI claim in both files and that the three
move-nothing answers are present, since losing any of them returns the
document to advice a public project cannot take.
2026-08-05 19:28:05 -07:00
branderson 10a52c2dde ci: read this workflow from main, not from the branch under test
Under `on: pull_request` the workflow is read from the head of the pull
request, so the branch decides what runs and - the part that matters - where.
A branch can point its jobs at any runner its repository can reach, which
makes every control the runner provides opt-out by whoever wrote the branch.
The dedicated label was routing, not containment.

`pull_request_target` reads it from the base branch instead. Gitea does this
explicitly: it resolves the base commit, detects workflows there, and takes
only the pull_request_target ones - while discarding any pull_request_target
workflow found on the head commit. So a branch can neither edit this file's
copy nor introduce its own, and runs-on is whatever main says.

The conversion has a trap, and it fails green. The run's ref becomes the base
branch, so a bare checkout fetches main and every suite passes without seeing
the change. Both suites that test the branch now name the head sha explicitly.

The runner-environment jobs keep the bare checkout, deliberately and now with
a comment saying so: they assert on the runner rather than on the change, so
their assertions should come from the base branch - which also means a branch
can neither weaken nor delete them.

This does not make the repository safe by itself, and docs/ci.md says so where
it explains why: a branch can still ADD a workflow with on: pull_request, and
that runs from the head with any runs-on it likes. Only which runners the
repository can reach stops that. What this buys is that the intended CI is
trustworthy, and that escaping it requires adding a file rather than editing
one - a far louder diff.

Recorded alongside it, because it changes what that remaining fix costs: a
runner can belong to an OWNER, a user as well as an organization, and then
serves all of that owner's repositories. Gitea's runner model names the three
scopes directly. So an instance with sixty personal repositories and two
general-purpose runners needs two re-registrations at user scope rather than
sixty at repository scope, with the repositories that receive agent-authored
branches living under a different owner whose reachable runners are all
hardened.

static.bats pins the trigger, that pull_request does not return, and the
checkout split - two jobs on the head sha, four checkouts in total, so a
bare checkout added to a branch-testing job fails here rather than passing
green in CI.
2026-08-05 19:13:43 -07:00
branderson b474acac0a docs: a runner label routes jobs, it does not contain them
tests / sandbox (audit) (pull_request) Failing after 4m20s
tests / sandbox (project) (pull_request) Failing after 4m21s
tests / sandbox (config) (pull_request) Failing after 4m21s
tests / sandbox (providers) (pull_request) Failing after 4m21s
tests / sandbox (proxy) (pull_request) Failing after 4m21s
tests / sandbox (static) (pull_request) Failing after 4m21s
tests / runner-env (sandbox) (pull_request) Failing after 4m20s
tests / sandbox (remote) (pull_request) Failing after 4m21s
tests / host-docker (pull_request) Has been cancelled
tests / runner-env (host-docker) (pull_request) Has been cancelled
This document said that naming a dedicated label meant jobs "cannot land
anywhere else". That is true of a branch that is not trying, and false of one
that is - which is the distinction the rest of the document is built on, so it
was the wrong claim to make here of all places.

runs-on is in the workflow file and the workflow file is in the branch. A
session can change runs-on: dejima-ci to runs-on: ubuntu-latest, push, and
open a pull request; if any runner answering that label will take the job, it
has stepped outside every control described here - no allowlist, no socket
restriction, no firewall - without touching anything else.

Whether one will is a property of where runners are registered, which is the
actual boundary. Gitea's own FAQ is explicit that there is no per-repository
runner ACL and that registration scope is the control instead: an
instance-level runner serves every repository on the instance, and gitea.com
registers its own for a single organization so they will not run other
repositories' jobs.

So the property worth holding is that a repository's CI posture is that of the
weakest runner it can reach, not the one its workflow names. The runner-host
section framed registration scope as keeping other repositories off your
hardened runner; the converse - keeping your repository off nobody's hardened
runner - is the direction that bites, and is now stated first.

Also recorded: the runner-environment tests do not close this either, since
they are jobs in the same file and a branch redirecting runs-on can drop them
in the same commit. They catch a misconfigured runner, not a redirected job.
What survives the branch is the run log, which records which runner took each
job, and a required status check, which stops such a pull request being
merged. Both are after the fact, which is this document's whole point.

static.bats pins the correction, including that the old phrasing does not
return.
2026-08-05 17:29:57 -07:00
branderson 1c4dfc39e4 docs: rewrap a line the link substitution ran together
tests / runner-env (host-docker) (pull_request) Waiting to run
tests / host-docker (pull_request) Failing after 1m14s
tests / sandbox (providers) (pull_request) Has been cancelled
tests / sandbox (proxy) (pull_request) Has been cancelled
tests / sandbox (config) (pull_request) Has been cancelled
tests / sandbox (project) (pull_request) Failing after 14m46s
tests / sandbox (remote) (pull_request) Has been cancelled
tests / sandbox (static) (pull_request) Has been cancelled
tests / runner-env (sandbox) (pull_request) Has been cancelled
tests / sandbox (audit) (pull_request) Has been cancelled
2026-08-05 17:17:55 -07:00
branderson ca6d9805b2 docs: use the real dejima-ci-hardener URL
tests / sandbox (static) (pull_request) Has been cancelled
tests / host-docker (pull_request) Failing after 1m15s
tests / sandbox (audit) (pull_request) Has been cancelled
tests / sandbox (config) (pull_request) Has been cancelled
tests / runner-env (sandbox) (pull_request) Has been cancelled
tests / runner-env (host-docker) (pull_request) Failing after 1m13s
tests / sandbox (project) (pull_request) Has been cancelled
tests / sandbox (providers) (pull_request) Has been cancelled
tests / sandbox (proxy) (pull_request) Has been cancelled
tests / sandbox (remote) (pull_request) Has been cancelled
The placeholder was in place while that repository was private.
2026-08-05 17:16:20 -07:00
branderson aa7fe3c2d3 ci: disclose the risk, verify our own runners, and point at dejima-ci-hardener
tests / sandbox (config) (pull_request) Failing after 1m5s
tests / sandbox (project) (pull_request) Failing after 1m6s
tests / sandbox (audit) (pull_request) Failing after 1m5s
tests / sandbox (providers) (pull_request) Failing after 1m5s
tests / sandbox (proxy) (pull_request) Failing after 1m6s
tests / sandbox (remote) (pull_request) Failing after 1m6s
tests / sandbox (static) (pull_request) Failing after 1m6s
tests / runner-env (sandbox) (pull_request) Failing after 1m5s
tests / host-docker (pull_request) Failing after 1m23s
tests / runner-env (host-docker) (pull_request) Failing after 1m22s
Replaces the earlier attempt to ship a CI hardening bundle from this
repository. That approach required copying files out of a dejima checkout onto
a CI box, which is backwards: a runner is a machine whose purpose is executing
untrusted code, and nothing about dejima belongs on it. dejima-ci-hardener is
self-contained and owns the how; this repository keeps the disclosure and
links out.

What stays here is what only this repository can say or check.

docs/ci.md keeps the threat model: what a run adds over a sandbox - execution
on a host you own, egress from a network position you own, credentials the run
is handed - and why no approval gate is offered, since every candidate is
reachable from the branch, covered by a credential the session already holds,
or not a gate at all. It keeps the reasoning behind the controls, including
that the allowlist has to come from the branch the operator controls rather
than the checkout. It no longer carries the configuration: two copies of a
firewall rule drift, and one of them had already needed the same fix twice.

test/ci-runner.bats stays because it is this repository's CI asserting its own
runners, which is nobody else's business to run. Both failures it covers 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. Two
jobs, one per runner, with the tier declared by the workflow rather than
sniffed, and every negative check behind a positive control - an allowlisted
destination must resolve and connect first, or a runner with no network at all
passes the lot.

The sandbox matrix moves to the dejima-ci label so those jobs cannot land on a
runner that grants everything, and host-docker keeps dejima-runtime.

runtime.bats stops polling a container that has already exited, so an aborted
entrypoint surfaces at once rather than after the full two-minute wait.

With ci/ gone, static.bats loses the twelve guards over its files - they move
with what they guarded - and the leak scanner returns to its strict form,
since the private-subnet example that needed a carve-out went with them.
2026-08-05 17:01:40 -07:00
branderson 65c0410181 Expand allowed-domains 2026-08-05 15:35:14 -07:00
branderson b88b26e6dd Merge pull request 'Keep the base image to the sandbox's own needs' (#78) from image-trim into main
Reviewed-on: branderson/dejima#78
2026-08-05 09:08:18 -07:00
branderson c4b3168e31 image: keep the base image to the sandbox's own needs
tests / sandbox (proxy) (pull_request) Successful in 2m7s
tests / sandbox (static) (pull_request) Successful in 2m46s
tests / sandbox (providers) (pull_request) Successful in 4m5s
tests / sandbox (remote) (pull_request) Successful in 4m55s
tests / sandbox (project) (pull_request) Successful in 5m29s
tests / sandbox (config) (pull_request) Successful in 5m35s
tests / sandbox (audit) (pull_request) Successful in 7m46s
tests / host-docker (pull_request) Successful in 15m30s
Everything in the base image is carried by every sandbox on the host, for
every project, with no review anywhere - the same property that made the
built-in domain allowlist worth shortening. What belongs in it is what the
sandbox itself needs, plus tooling with no route through the dependency
mechanism. Language toolchains and per-project utilities have a route, so they
are declared by the projects that want them.

Out: the Go toolchain, git-delta, vim, man-db, python3-venv, and
cowsay/fortune with the /usr/games PATH entry that existed only to reach them.
bats moves to this repo's own .dejima/dependencies, which is where a project's
test runner belongs.

Go is the one with a real cost, and it is why the tarball dependency type came
first. A Go project now declares

  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 wants, rather than whatever the
image happened to be built with. That is better than what it replaces, not a
consolation: the previous build tracked latest-stable, so two images built a
week apart could ship different Go.

Removing cowsay repairs a test rather than only shrinking a layer.
runtime.bats proves the derived-image dependency mechanism works by declaring
apt:cowsay and then finding cowsay installed - which, with cowsay in the base
image, passed whether or not generate_derived_dockerfile did anything at all.
It now checks the base image does NOT ship it before building the derived one,
so the control cannot be silently defeated again, and static.bats derives the
same check from runtime.bats' own fixtures so it is caught at the point the
package list changes rather than only where Docker exists.

static.bats also refuses a language toolchain in the Dockerfile outright.
Both guards were confirmed to fail when violated, not merely to pass now.

Playwright, nvim, tree-sitter-cli, build-essential and the zsh layer all stay:
Chromium cannot be expressed as a dependency at all (nothing runs `npx
playwright install`), and the editor and shell are documented features with
tests behind them.
2026-08-05 02:21:01 -07:00
branderson d86c0560d0 Merge pull request 'Add a digest-pinned tarball dependency type' (#77) from tarball-deps into main
Reviewed-on: branderson/dejima#77
2026-08-05 02:09:50 -07:00
branderson 1ca2ca4466 Merge pull request 'Keep the built-in allowlist to what the sandbox itself needs' (#76) from allowlist-trim into main
Reviewed-on: branderson/dejima#76
2026-08-05 02:04:34 -07:00
branderson 98d63c0d44 deps: add a tarball dependency type, digest-pinned
tests / sandbox (proxy) (pull_request) Successful in 2m8s
tests / sandbox (static) (pull_request) Successful in 3m1s
tests / sandbox (providers) (pull_request) Successful in 4m18s
tests / sandbox (remote) (pull_request) Successful in 5m2s
tests / sandbox (project) (pull_request) Successful in 5m40s
tests / sandbox (config) (pull_request) Successful in 5m45s
tests / host-docker (pull_request) Successful in 7m45s
tests / sandbox (audit) (pull_request) Successful in 8m4s
Some toolchains are not usefully available from any package ecosystem the
dependency mechanism speaks. Debian 12's golang-go is several releases behind
current, so `apt:golang-go` is not a route to a Go a modern project can build
with - and today the only alternative is baking the toolchain into the base
image for every project whether it wants one or not.

tarball:<name>@<sha256>:<url> is that route. The archive unpacks with
--strip-components=1 into /opt/dejima/<name>, and /opt/dejima/<name>/bin joins
PATH. One convention covers the shapes that actually occur - 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 to get wrong.
Compression is detected from the archive rather than declared, so .tar.gz,
.tar.xz and .tar.zst all work.

The digest is mandatory, and that is what makes this an approval at all.
Approving a bare URL would be approving a name whose contents the far end can
change afterwards, which is not a decision anyone can make once; with a digest,
what was approved is one specific archive. The generated build verifies it
before unpacking, because a tar extracted first and checked afterwards has
already written whatever it contained - there is a test on that ordering
rather than on the presence of a checksum line.

Validation is stricter than the URL grammar allows, for the same reason the
existing spec regex is stricter than package names allow: this string is
interpolated into a RUN line that runs as root at build time. Everything
shell-active is refused outright rather than quoted around, https only, and
the name is refused if it could walk out of /opt/dejima.

sandbox-context.sh matches tarball specs separately, since a tarball has no
package manager to ask - it is present if its directory is. Without that
branch a tarball spec would fall through the package-name regex and be
skipped, which reads to the agent as declared-and-present for something that
is neither. A reported spec keeps its digest in full: that is the part the
operator is being asked to approve, so an abbreviated form would be asking
them to agree to something they cannot see.

Two properties documented rather than smoothed over: a tarball dependency
names one architecture, because a digest does, so a mixed-architecture fleet
needs a per-project spec rather than one global one; and upgrading is an
approval rather than a version bump, since a new release is a new digest.

The positive-branch hook test skips on an image with no tarball dependency
unpacked, and says so, rather than arranging its own directory and thereby
checking that mkdir works. The branch it covers is pinned at source level
alongside it, which holds regardless.
2026-08-05 01:02:58 -07:00
branderson 5c30839ff8 firewall: keep the built-in allowlist to what the sandbox itself needs
tests / sandbox (proxy) (pull_request) Successful in 2m5s
tests / sandbox (static) (pull_request) Successful in 2m47s
tests / sandbox (providers) (pull_request) Successful in 4m6s
tests / sandbox (remote) (pull_request) Successful in 4m51s
tests / sandbox (project) (pull_request) Successful in 5m24s
tests / sandbox (config) (pull_request) Successful in 5m31s
tests / sandbox (audit) (pull_request) Successful in 7m40s
tests / host-docker (pull_request) Successful in 5m31s
The built-in list grants to every sandbox on the host, for every project, with
no review anywhere. That makes it the one place where "this seems reasonable"
compounds: each entry is individually defensible and the total is a standing
grant nobody agreed to per project.

Five entries were tooling rather than sandbox machinery - gitlab.com, pypi.org,
files.pythonhosted.org, proxy.golang.org and sum.golang.org, which between them
serve mason's Python LSP downloads and the Go module proxy. What remains is
what the sandbox cannot run without: registry.npmjs.org and platform.claude.com
for the harness and its updates, release-assets.githubusercontent.com for the
binaries baked-in tooling fetches, and api.anthropic.com under its existing
proxy-off condition.

release-assets.githubusercontent.com is the arguable one and it stays. It is
the same editor-tooling bucket by origin, but it is also where an ordinary npm
postinstall gets its prebuilt binary, so removing it breaks a much wider class
of routine work than the other four.

Nothing is lost, only moved to where it gets reviewed: `dejima-allow --global
<domain>` restores any of these permanently, and a project that needs one says
so in .dejima/allowed-domains, which is read with the project. That route now
matters more than it did - a hardened CI runner reads its egress allowlist
from that same file on main (docs/ci.md), so a name granted by being built in
is a name that never appears in any diff.

The Dockerfile still ships the Go toolchain and says plainly that shipping it
is not the same decision as granting every sandbox reach to its module proxy.

runtime.bats's ipset-refresh test moves off pypi.org onto registry.npmjs.org:
it needs a name that is built in, since that is the property it exercises.
static.bats gains a guard that no language package host is built in, listing
crates.io and rubygems.org alongside the removed five - the failure mode here
is growth by reasonable-looking addition, and that is what it is there to
catch.
2026-08-04 23:58:00 -07:00
branderson d417f31def Merge pull request 'Document what push access grants a session on your CI host' (#74) from ci-hardening-docs into main
Reviewed-on: branderson/dejima#74
2026-08-04 23:04:21 -07:00
branderson a23d8145a5 docs: disclose that push access is CI code execution, and how to bound it
tests / sandbox (proxy) (pull_request) Successful in 2m5s
tests / sandbox (providers) (pull_request) Successful in 3m56s
tests / sandbox (remote) (pull_request) Successful in 4m44s
tests / sandbox (project) (pull_request) Successful in 5m17s
tests / sandbox (config) (pull_request) Successful in 5m21s
tests / host-docker (pull_request) Successful in 5m49s
tests / sandbox (audit) (pull_request) Successful in 7m31s
tests / sandbox (static) (pull_request) Successful in 8m5s
A session cannot reach outside its allowlist, but it can push a branch and
open a PR - and that makes CI run what it wrote, on a host, with no firewall
and no human in between. It does not take an edited workflow file: a workflow
that runs a test suite runs every file in it. The operator-merges gate sits
strictly downstream of that, so by the time anyone reads the diff, it has run.

Nothing here claimed this was handled; the docs were simply silent on it. This
adds the disclosure in all three places a reader could reasonably start from -
README, docs/security.md, and .dejima.md for an agent working on this repo -
and a full runbook in docs/ci.md.

The runbook deliberately offers no approval gate. Every version of one is
reachable from the branch, covered by a credential the session already holds,
or not a gate at all: the trigger and the workflow file are in the tree, a
forge token that can open a PR generally covers the dispatch endpoint, a
stopped runner just means the job lands in someone else's pool, and the forge's
own approval settings apply to fork PRs rather than to same-repo branches. So
it bounds what a run can do instead: a runner host you are willing to lose,
job containers with no Docker socket, deny-by-default egress, and no secret a
run does not need.

The part worth generalising is the egress allowlist. A dejima project already
carries a version-controlled statement of where it may reach, so CI reads
.dejima/allowed-domains from origin/main over the forge API - never from the
checkout, which belongs to the branch. Widening what CI can reach becomes a
diff on main: reviewed, attributable and revertible, in the same file and by
the same act that widens what a session may reach.

Also documented, because they are easy to miss rather than obscure: act_runner
mounts the daemon socket into every job container unless container.docker_host
is "-", and that is a property of the runner rather than of the job; filtering
only DOCKER-USER leaves a job free to use any resolver it likes, because the
resolver on a bridge gateway is INPUT and not FORWARD; and Gitea ignores
`permissions:` before 1.27, which is what makes branch protection on main
non-optional rather than merely advisable.

Where none of it is available - CI you do not administer, a shared pool - the
document says so plainly and points at declining the grant instead:
[core] forge = none with [core] workflow = local-only.

static.bats pins the disclosure in all three files, that docs/ci.md exists and
is linked from both, and that it still says the allowlist comes from main
rather than from the checkout.
2026-08-04 22:08:06 -07:00
branderson d2447fdf24 Add initial allowed domains and dependencies 2026-08-04 21:09:47 -07:00
branderson 1dd2356540 Merge pull request 'Fix dejima-allow and dejima-project, and teach the agent briefing the project scope' (#73) from allow-project-cli into main
Reviewed-on: branderson/dejima#73
2026-08-04 19:13:12 -07:00
branderson acd385f303 Disclose an intermediate symlink in --project's path, don't refuse it
tests / sandbox (proxy) (pull_request) Successful in 2m14s
tests / sandbox (providers) (pull_request) Successful in 2m15s
tests / sandbox (static) (pull_request) Successful in 2m59s
tests / sandbox (config) (pull_request) Successful in 4m20s
tests / sandbox (remote) (pull_request) Successful in 5m22s
tests / host-docker (pull_request) Successful in 5m37s
tests / sandbox (project) (pull_request) Successful in 6m12s
tests / sandbox (audit) (pull_request) Successful in 7m30s
cd/pwd resolve --project's path logically, so a symlinked component's
name survives in $project, but declare_entry's makedirs/open resolve
the same path physically - the declaration can land somewhere other
than what $project names, including inside another project's existing
.dejima. A legitimate symlinked checkout looks identical to a planted
one from here, and only the operator can tell them apart, so this
discloses the mismatch (naming the physical target) rather than
refusing on it, which would break the ordinary case with no way
forward. The approval path is deliberately left resolved logically -
resolving it physically too would move the approval itself into the
symlink's target, turning a declaration-only redirect into an actual
cross-project grant.
2026-08-04 18:46:04 -07:00
Brad Anderson 72c23ceea6 Bound the declaration read-back instead of slurping it
tests / sandbox (proxy) (pull_request) Successful in 2m3s
tests / sandbox (config) (pull_request) Successful in 3m58s
tests / sandbox (remote) (pull_request) Successful in 4m59s
tests / host-docker (pull_request) Successful in 5m15s
tests / sandbox (providers) (pull_request) Successful in 2m9s
tests / sandbox (static) (pull_request) Successful in 2m47s
tests / sandbox (project) (pull_request) Successful in 5m43s
tests / sandbox (audit) (pull_request) Successful in 7m0s
The atomic rewrite replaced `grep -qxF`, which streams the duplicate check
in constant memory, with a plain fh.read() of the whole file. That file is
the session-writable one this guard exists for, and a session can make it
arbitrarily large for nothing:

    truncate -s 8G .dejima/allowed-domains    # sparse, du reports 0

Then it asks the user to run the command the briefing tells it to ask for,
and the host reads 8 GiB into a variable. Measured with the read contained
by ulimit -v: a raw MemoryError traceback. Uncontained it is the OOM
killer, aimed at whatever else the machine is running. It failed closed -
no wrong write, the approval was already recorded - so this was
availability rather than privilege, but it was the one dimension of the
new code with no bound at all, and a traceback is the wrong register for
a file every other refusal here explains.

The read now stops at LIMIT + 1 bytes and refuses above LIMIT, inside the
same fd. LIMIT + 1 rather than an fstat size check because the file can
grow between the fstat and the read. 1 MiB is orders of magnitude above
any real declaration - DEJIMA_MAX_DECLARED entries of a domain's 253
bytes is tens of KiB - and that constant is passed in from the shell
rather than duplicated, so the two cannot drift.

The test plants the 8G sparse file and runs the CLI under ulimit -v, so a
regression fails the test rather than the machine it runs on; a
just-under-the-cap neighbour proves the cap is a ceiling on a hostile
file and not a new way to refuse a large real one.

Also narrows the README's "cannot swap either path" to the two leaves the
session actually controls - an intermediate component of an
operator-supplied --project path is still resolved through, as the
residuals already say.
2026-08-04 15:42:33 -07:00
Brad Anderson 9530da13d0 Make the declaration write atomic instead of checked
declare_entry guarded the one host-side write that lands on the
session-writable bind mount with a check-then-write: test for a symlink,
then mkdir -p, then test the file, then touch/grep/append. That closes
the pre-planted case and nothing else - a session running right now can
rename across any of those windows.

The old comment bounded that with "the documented moment to run these
CLIs is with nothing running". True for dejima-dep, which needs an image
rebuild anyway; false for dejima-allow --project, because the briefing
this branch added tells the agent to have the user run it from a
terminal outside the sandbox - mid-session, at a moment the session
itself chose, since it is the one asking for the command. Winning the
race is a host-privileged append of one validated token to an
attacker-chosen path: allowed-domains widens the egress firewall for
every project, dependencies is installed as root at image build where
there is no egress firewall.

Measured against a swapper: the old guard appended to the victim 25
times in 400 attempts on the file path and twice in 300 on the
directory.

So there is no check now - the guard is the open. python3 (already a
hard dependency) opens .dejima O_RDONLY|O_DIRECTORY|O_NOFOLLOW, then the
declaration file O_NOFOLLOW|O_CREAT|O_APPEND relative to that directory
fd, so neither path can be swapped for the other's sake, and reads the
duplicate check back through the same fd. O_NONBLOCK plus an S_ISREG
check keep a planted FIFO from wedging the CLI. Same signature, same
validate-first, same idempotence, still never calls exit, and both CLIs
still print the approval before calling it.

Two tests swap each path under a live writer and assert the victim is
never written, with a refusal count so a swapper that never landed
cannot pass vacuously. Dropping O_NOFOLLOW fails all five existing
symlink tests and both new ones; dropping O_NONBLOCK/S_ISREG hangs the
FIFO test out to timeout.

The mitigation sentence is corrected in the comment and the README
rather than left claiming a bound that no longer applies.
2026-08-04 15:02:05 -07:00
branderson 70f01a8361 Merge pull request 'gitea: add a CLI for reading CI runs and job logs' (#71) from gitea-actions into main
Reviewed-on: branderson/dejima#71
2026-08-04 14:19:38 -07:00
branderson b95a742b8d Merge pull request 'providers: ship the github and none forges, the branch-push and local-only workflows, and the validation Phase 2 deferred (Phase 3 + Phase 4 remnant)' (#69) from new-providers into main
Reviewed-on: branderson/dejima#69
2026-08-04 14:18:54 -07:00
branderson d6f4db0e0d Merge pull request 'install: register the merge guard, which the pre-rename entry was standing in for' (#70) from hook-registration into main
Reviewed-on: branderson/dejima#70
2026-08-04 14:17:44 -07:00
branderson 16104760dd test: wait for the client before reading the window an attach selects
tests / sandbox (proxy) (pull_request) Successful in 2m11s
tests / sandbox (static) (pull_request) Successful in 2m42s
tests / sandbox (config) (pull_request) Successful in 5m6s
tests / host-docker (pull_request) Successful in 5m37s
tests / sandbox (audit) (pull_request) Successful in 7m16s
tests / sandbox (providers) (pull_request) Successful in 3m48s
tests / sandbox (remote) (pull_request) Successful in 4m31s
tests / sandbox (project) (pull_request) Successful in 4m35s
dejima-attach creates the grouped session detached, checks it holds the
pane, selects the window, and only then attaches. await_group waited on
has-session, which goes true at the first of those four steps - so a caller
that went on to assert the window index was reading it two steps early. It
held locally and failed on a loaded CI runner, with the group assertion on
the line before it passing: the group is set at creation, the window is not.

Waits for the client instead, the same way the earlier attach test does, and
for the same reason. 12 consecutive runs of both affected tests, no failures.

Also retries the api.github.com self-check in init-firewall.sh. Not from this
branch - it fails on main the same way, and did here in one derived-image
container while a dozen others in the same run came up fine. A transient DNS
answer or a CDN edge rotating mid-boot answers "can the allowlist reach
anything" wrongly in the direction that kills the container. Bounded and
reported like the ranges fetch above it. Its opposite number, the
example.com check, is deliberately left un-retried: a single success there is
the real answer, and retrying would be asking until it says what we want.
2026-08-04 14:01:45 -07:00
branderson b3d32574a5 gitea: add a CLI for reading CI runs and job logs
tests / sandbox (proxy) (pull_request) Successful in 2m1s
tests / sandbox (providers) (pull_request) Successful in 2m3s
tests / sandbox (static) (pull_request) Successful in 2m38s
tests / sandbox (config) (pull_request) Successful in 3m52s
tests / sandbox (remote) (pull_request) Successful in 4m57s
tests / sandbox (project) (pull_request) Successful in 5m17s
tests / host-docker (pull_request) Successful in 5m22s
tests / sandbox (audit) (pull_request) Successful in 7m4s
A sandbox has no Docker daemon, so the half of the suite that boots real
containers only ever runs in CI - and until now the only way to see why one
of those failed was for the operator to read the run in a browser and paste
it in. The session that wrote the code could not see what it did.

A third self-contained twin rather than a subcommand of gitea-pr: runs are
repo-scoped, not PR-scoped (a push to main has runs and no PR), and the
existing pair already establishes one file per Gitea domain with a
duplicated preamble pinned by static.bats.

Two things about the API are worth knowing and neither is discoverable:
runs are addressed by an internal id that no URL shows, and `limit` on the
run list is ignored unless `page` is passed with it. Both are handled here
so no caller meets them.

With no run given it resolves HEAD's commit - matched on head_sha, since a
pull_request run reports no branch at all - and prints only the jobs that
failed. It reads and never writes, which static.bats pins: it authenticates
with the same write-scoped token as gitea-pr.
2026-08-04 13:21:32 -07:00
branderson 6b8a3fad6e install: register the merge guard, which the pre-rename entry was standing in for
tests / sandbox (proxy) (pull_request) Successful in 2m3s
tests / sandbox (providers) (pull_request) Successful in 2m4s
tests / sandbox (static) (pull_request) Successful in 2m46s
tests / sandbox (config) (pull_request) Successful in 4m4s
tests / sandbox (remote) (pull_request) Successful in 5m3s
tests / host-docker (pull_request) Successful in 5m14s
tests / sandbox (project) (pull_request) Successful in 5m19s
tests / sandbox (audit) (pull_request) Successful in 7m2s
register_hook's dedupe tested the existing commands with test(), an
unanchored regex. The gitea-merge-guard.sh -> merge-guard.sh rename left a
registration whose command string contains "merge-guard.sh", so every run
since reported "Skipping: merge-guard.sh already registered" and added
nothing. The guard that blocks the forge's merge command inside the sandbox
was registered nowhere, on any host that had installed before the rename.

The stale entry also names a hooks/ path the rename deleted, so Claude Code
reported a missing hook command on every session start - the visible half of
a failure whose other half was silent.

Both fixed: the dedupe matches the hook's name at the end of the command
rather than anywhere in it, and the pre-rename registration is dropped
alongside the pre-rename symlink, on the same condition (only when the path
it names is gone, so an operator's own file at that name is left alone).

Tested by running install.sh against a throwaway HOME rather than by
grepping it - the call this missed was present in the source the whole time.
2026-08-04 13:07:50 -07:00
branderson 63084ccd87 Merge remote-tracking branch 'origin/main' into new-providers
tests / sandbox (proxy) (pull_request) Successful in 2m2s
tests / sandbox (static) (pull_request) Successful in 2m38s
tests / sandbox (providers) (pull_request) Successful in 3m49s
tests / sandbox (remote) (pull_request) Failing after 4m31s
tests / sandbox (project) (pull_request) Successful in 4m40s
tests / sandbox (config) (pull_request) Successful in 5m15s
tests / sandbox (audit) (pull_request) Successful in 7m28s
tests / host-docker (pull_request) Failing after 7m42s
2026-08-04 12:26:16 -07:00
branderson 03c2d3abd9 test: wait for the startup line this asserts on, instead of reading the log once
The json-file log driver's writes lag the process that produced them - the
same short read settled_logs() was written for. It landed here as a CI
failure in which the container booted, port 22 was reachable, and the
rebind assertion held: everything the test guards passed, and only the
grep for the line announcing the branch that had demonstrably been taken
came back empty.
2026-08-04 12:26:07 -07:00
branderson 47832effc2 providers: ship the non-pushing pair as the default, and say so on upgrade
tests / sandbox (proxy) (pull_request) Successful in 2m6s
tests / sandbox (static) (pull_request) Successful in 2m39s
tests / sandbox (providers) (pull_request) Successful in 3m49s
tests / sandbox (project) (pull_request) Successful in 4m40s
tests / sandbox (remote) (pull_request) Successful in 4m46s
tests / sandbox (config) (pull_request) Successful in 5m15s
tests / host-docker (pull_request) Failing after 5m43s
tests / sandbox (audit) (pull_request) Successful in 7m27s
The shipped defaults were core.forge = gitea and core.workflow = worktree-pr
- a PUSHING pair. Rule 3 in provider_validate_selection refuses a workflow
that pushes when its forge resolves to no host, so every config without a
filled-in [forge.gitea] was refused at launch, including the one install.sh
writes. A fresh install could not start; 26 launcher tests in project.bats
and remote.bats failed for exactly that reason.

The rule is right. The defaults were the thing asserting a setup no fresh
install has, so the defaults change: forge = none, workflow = local-only.
Pushing is opted into. All 26 failures resolve with no launcher fixture
touched.

This is a behaviour change for an existing operator with no explicit
[core] forge / workflow line - their sandbox stops pushing - so it is
reported rather than silent:

- install.sh prints a notice when an existing config parses but does not set
  both keys explicitly, naming the old and new values and the two lines to
  write to keep pushing. Same stance as the remote.conf "no longer read"
  notice: reported, never rewritten.
- The starter config now writes forge and workflow uncommented, so a config
  states the selection it makes rather than inheriting a future default.
- install.sh's legacy seed path is repaired: _config_extra_keys enumerates
  only the selected forge's keys, so with `none` selected [forge.gitea]
  vanished from the starter config and GITEA_SSH_HOST/GITEA_API had nowhere
  to land. An operator with those exported was pushing to Gitea, so
  install.sh selects gitea + worktree-pr for them explicitly.
- README and docs/providers.md restate the defaults, with the reasoning and
  a migration callout.

providers.bats pins the property that broke: the shipped defaults must be a
non-pushing pair that validates with no forge configured, driven off the
schema rather than literal names. config.bats' embedded-newline token test
was relying on the old default to select the forge whose token_file it
poisons, and now names gitea explicitly - its subject genuinely needs one.
2026-08-04 12:10:12 -07:00
branderson 59f35e0396 Refuse a special file at the declaration path, not just a symlink
Two defects the previous commit introduced, both on lines it added.

The symlink guard in declare_entry only ruled out symlinks, and the
re-offer notice added to `dejima-dep remove --project` grepped the
declaration unconditionally. Both read or write a path the sandboxed
session owns, so a FIFO planted there blocks touch, the duplicate-check
grep and the append itself for as long as the session declines to open
the far end - wedging, for the full timeout, the very commands the
session briefing tells the user to run. Same class of thing the rest of
this guard exists to close, so it is closed by the same idea: the file
test widens from "not a symlink" to "regular file or absent", and the
notice's grep is gated on -f. A FIFO named .dejima needs nothing, since
mkdir -p already refuses to create over one.

Also drops a false clause from dejima-project's collector comment, which
listed a symlinked declaration path among the ways an approval can fail.
It cannot: both CLIs deliberately swallow a declare_entry failure and
exit 0, because the approval is what grants and is already written by
then. The comment now says so, rather than claiming the opposite.
2026-08-04 03:49:23 -07:00
branderson 4d1911c970 Refuse to write a declaration through a symlink, and fix the review's rest
The branch made the host-side CLIs write into <project>/.dejima, which is on
the read-write bind mount the sandboxed session controls. declare_entry had
no symlink guard, so a session could plant one and then ask the user - via
the briefing this branch added, which hands over the exact `dejima-allow
--project` command - to run the CLI that follows it. Three shapes, all
reproduced against the real binaries: .dejima/allowed-domains linked at the
global store turned a project approval into a machine-wide one while the
output claimed the project; linked at the global dependency list it wrote a
domain where only specs are valid, tripping approved_entries' refusal and
blocking every project's image build; and .dejima linked at any directory
worked outright, since mkdir -p follows one. declare_entry now refuses a
symlinked directory or file, explains why in terms of what the link could
reach, and says in a comment that this is a check and not a lock - a live
session can still win the race, which is why the documented moment to run
these is with nothing running.

The rest of the review, in the same pass:

- dejima-project's dep loop had no collector, so under set -e the first
  failing dependency aborted the script: the deps after it and every domain
  went unattempted, with no summary - making the README's "keeps going"
  claim false for dependencies. It now collects and reports like the domain
  loop, and "Restart the sandbox" is gated on something having landed.
- approve exited 0 when every approval had failed, so `approve && dejima`
  walked into a session with none of them. It now exits non-zero.
- dejima-dep swallowed --container, a flag it inherited from the shared
  parser and has no concept of, giving a caller who meant one session a
  persistent project grant instead. It refuses it.
- dejima-dep remove --project leaves the declaration in place by design, but
  the next approve then re-offers the entry; it now says so.
- README: the reference section still described .dejima as written only by
  the project or session. It now names the CLIs that write declarations,
  keeps "a declaration grants nothing", and explains the symlink refusal.
- DEJIMA_ALLOW_SKIP_SESSION was consulted by nothing (every caller set a
  scope flag, so session_wanted was already 0) and static.bats documented a
  call that does not exist. Deleted; the static test now pins its absence.
- Dropped a tautological alternative in the --project-path parser test.
2026-08-04 03:00:07 -07:00
branderson 0f5727e206 providers: fix residual findings from the final review
- README.md: the --allow-github-push section still claimed no config-file
  path could grant a push path, contradicted since 9d82c7b let
  [core] forge = github do exactly that when the workflow pushes; rewrite
  to state both truths and align with docs/security.md.
- docs/providers.md: qualify the shipped-providers table's "opens
  ssh.github.com:22" with "when the selected workflow pushes", matching
  the prose below it; also document hosts_allowlisted_by_core's
  GitHub-specific trap for future forge authors.
- bin/dejima-provider-lib.sh: comment the same trap where the manifest key
  is declared.
- test/providers.bats: assert the no-host remedy names a forge that
  actually exists (provider_list forge) instead of pinning the literal
  text "forge = github", which would go stale silently if that forge were
  renamed or removed.
- bin/dejima: skip _warn_forge_env_collisions when config_get core forge
  already failed, so an invalid forge value reports one error instead of
  two; add a regression test.
- test/static.bats: declare the missing local `body`.
2026-08-04 01:54:13 -07:00
branderson cee8eb0214 docs: fix false container-disambiguation claim about scope flags, pin it 2026-08-04 01:25:39 -07:00
branderson 9d82c7b569 providers: fix the final review's five must-fix findings
C1: `forge = github` could not boot a container with the firewall on. Its
fixed_hosts reached DEJIMA_FORGE_HOSTS, which entrypoint.sh folds into
EXTRA_ALLOWED_DOMAINS - the path that resolves a name into the
port-unrestricted allowed-domains ipset and exempts it from
stop-dns-rebind. Three public GitHub names therefore got all-port egress
and lost rebind protection, and init-firewall.sh's own self-check then
found github.com:22 reachable without ALLOW_GITHUB_PUSH=1 and killed the
container before the agent started.

New forge manifest key hosts_allowlisted_by_core (bool, default off), set
by forges/github/forge.conf and read generically by core: its hosts stay
out of DEJIMA_FORGE_HOSTS, still fill DEJIMA_PUSH_HOSTS so the push guard
permits the push, and when the workflow pushes, port 22 opens through
core's own grant (ALLOW_GITHUB_PUSH) - the documented, logged,
self-checked path. Core branches on the manifest value, never on a name.

Also:
- the "core names no forge" test now builds its pattern from
  provider_list forge, so every shipped forge name is covered, matching a
  name in literal position (quoted, bracketed, forge.-prefixed, case arm)
  alongside the original gitea/github.com prose scan. The two core sites
  it was blind to are gone: the GH_TOKEN collision warning is derived
  from the selected forge's env_export, and the briefing's
  network-posture sentence from hosts_allowlisted_by_core plus the
  workflow's push.
- workflows/worktree-pr/briefing.md no longer tells a github-forge
  session to push to Gitea and never to GitHub: "the remote",
  {{blocked_branches}} instead of a hardcoded main/master, and "the
  forge" instead of Gitea.
- rules 5 and 6 require a required key to resolve to a non-empty value,
  not merely to be written. install.sh writes every key commented out, so
  uncommenting one yields `api =` - set, and worth nothing, with every
  forge call failing inside the container instead.
- the README no longer says a fresh install is enough to run dejima (it
  is refused: gitea + worktree-pr with no host), nor that
  --allow-github-push replaces selecting a forge; rule 3's remedy offers
  forge = github; the network-egress and security.md passages the C1 fix
  falsified are corrected.

Regression tests for each, including a runtime.bats case that boots with
the github forge's resolved environment and asserts the container comes
up with port 22 in the documented posture (needs Docker; not run here).
2026-08-04 01:00:57 -07:00
branderson f0e3ccc8b8 docs: correct README claims about --global/--project patching a running session 2026-08-04 00:51:08 -07:00
branderson 5c299e0a5f context, docs: name the project scope and the new argument order 2026-08-04 00:21:34 -07:00
branderson 27ab4287c3 project: fix review findings in the status/approve rework
- Delete "approve visits every domain even when one fails to be
  approved": it stayed green with the set -e collector deleted, so it
  provided no coverage. The pre-existing "attempts every domain even
  when persisting one fails" test is the real guard (confirmed by
  removing the collector and observing which test actually fails).
- report()'s undeclared-approvals pass never added to `seen`, so an
  entry approved in both stores and declared in neither printed twice.
- The "Restart the sandbox..." trailer printed even when every domain
  failed, directly under "NOT approved ... nothing was recorded". Now
  gated on something having actually been approved (a pending dep, or
  at least one domain that didn't fail).
2026-08-03 23:37:01 -07:00
branderson 982be2e391 audit: name the writable mounts a session could reach 2026-08-03 23:20:46 -07:00
branderson 0f4fc7ec3c Merge pull request 'docs: lead remote access with what it is and what it costs, and diagram it' (#67) from docs-remote-access-diagram into main
Reviewed-on: branderson/dejima#67
2026-08-03 23:11:30 -07:00
branderson 7d93b33f06 providers: fix round 2 - drop forge names from core comments, fix audit-startup fixture
Two static.bats regressions from fix round 1:

- "core names no forge": my two fix-round comments used gitea as a worked
  example, violating the rule this branch exists to establish (core names
  no forge, in code or prose). Rewritten to make the same point generically
  (a required key with a non-empty schema default already has a working
  value; a non-pushing workflow never invokes the forge at all) without
  naming one.
- The four bin/dejima audit-startup tests share a fixture
  (startup_warning_setup) that wrote a config with no forge/workflow
  override - the default gitea/worktree-pr pair, unconfigured, which rule 3
  now refuses before bin/dejima ever reaches the audit-startup path those
  tests are about. Added forge = none / workflow = local-only to the shared
  fixture, the same fix already applied across test/config.bats.
2026-08-03 23:01:56 -07:00
branderson 440705721a project: show approvals in status, stop approve chasing sessions
status only ever iterated declarations, so a domain approved with
dejima-allow --project (which has no declaration behind it) was
invisible - the reported bug in issue #61. status now unions
declarations with actual approvals via a new report() function, tagging
each entry with the store it came from. classify() keeps its exact
existing contract for approve.

approve's domain loop keeps its set -e loop-abort collector (a bare
dejima-allow call would still let the first failure silently skip every
remaining domain - dejima-allow --project can fail on its own even with
no container involved), but no longer claims a domain was "approved, but
not applied to a running session" when nothing was actually recorded.
2026-08-03 22:57:37 -07:00
branderson cd6b911114 docs: fix two more stale forge-count references (review round 1)
The requires-key table row and docs/security.md still described a single
shipped forge and a warning-not-refusal, both stale since the earlier
providers.md rewrite in this task.
2026-08-03 22:48:38 -07:00
branderson d27bec6643 docs: document the shipped providers and the rules now enforced 2026-08-03 22:42:11 -07:00
branderson 80d425c62e allow, dep: don't let a declaration failure hide an already-recorded approval
approve_entry runs first and ungated under set -e, so a declare_entry
failure (e.g. an unwritable project checkout) aborted the script before
either confirmation echo printed - even though the approval, which is what
actually grants, had already been written. Print the approval confirmation
first, then guard declare_entry so its failure can't abort the script and
says plainly that the approval stands.
2026-08-03 22:13:45 -07:00
branderson 9802328321 providers: fix round 1 - default-valued keys, push-gated rule 5, honest none message
Three operator rulings on Task 7's review:

- A requires key with a non-empty schema default (gitea's token_file,
  defaulting to ~/.config/gitea/token) can never be genuinely missing, so
  it no longer counts toward set_n/unset_n. Without this, the README's
  documented gitea setup (ssh_host + api only, token saved at the default
  path) was refused as "partly configured" at every launch.
- Rule 5 is now gated on push = on, matching the single-required-key check
  it sits next to. A non-pushing workflow never invokes the forge, so
  setting only ssh_host (e.g. so the firewall allows fetches) is a
  legitimate configuration, not a typo.
- Rule 3's message for a forge with no keys at all (forge = none) no
  longer tells the operator to "Configure [forge.none]" or "set forge =
  none" when the forge already is none - it names the only real fix
  (a non-pushing workflow) instead.

Also: reuse the already-computed $push instead of re-deriving it in a
subshell, and fix a stale "warning" comment in test/config.bats.
2026-08-03 22:08:40 -07:00
branderson b9de98081c allow, dep: record the project's declaration, not just its approval 2026-08-03 21:52:39 -07:00
branderson ebb32a9234 test: cover the no-path form of dejima-dep's --global/--project guard
The existing mutual-exclusion test only used an explicit --project path,
under which dj_project is non-empty regardless of whether the check keys
on dj_project_set or on dj_project itself - so it couldn't catch a
regression to the latter. The no-path form is the one where only
dj_project_set can tell --project apart from its absence.
2026-08-03 21:24:39 -07:00
branderson 6d0f603b95 providers: refuse an unconfigured or half-configured pushing forge
Turn provider_validate_selection's two deferred rules into refusals now
that forge = none (Task 1) gives a refused operator a correct alternative
to point at: rule 3 (a pushing workflow needs a forge that resolves to a
host) and rule 5 (a partly configured forge is a typo, not a choice).

Closes a gap rule 3 alone leaves open: github pins fixed_hosts
independent of config, so forge_hosts is never empty regardless of
push_token_file, and github's single-key requires can never look
"partly" configured either - a wholly unconfigured github pushing forge
would sail past both rules otherwise. Added a third check, scoped like
rule 3 to a pushing workflow, that refuses when none of a forge's
requires keys are set.

Updated bin/dejima's now-stale comment describing the two rules as
deferred, and every test (in providers.bats and config.bats) that
launched with the default gitea/worktree-pr pair unconfigured, or a
gitea forge only partly configured - switched to forge = none +
workflow = local-only where the test wasn't actually about forge
selection, or to a fully configured gitea/github where it was.
2026-08-03 21:11:41 -07:00
branderson 1a6177a0e0 dep: flags first, spec last, sharing allow's parser
Also fix dejima-project's internal dejima-dep call, which still used
the old order that this removes.
2026-08-03 20:57:30 -07:00
branderson 623d565ebb providers: refuse an autonomy the selected forge cannot support 2026-08-03 20:52:33 -07:00
branderson ea49f6a0db forges: stop github's collision warning lying, stop it clobbering the read-only token
Fix round 1 on code review of the github forge (task 5):

- forge_env_exports now skips an env_export pair whose resolved value is
  empty instead of emitting NAME= - selecting forge = github with
  push_token_file unset was overriding docker-compose.yml's own read-only
  GH_TOKEN with an explicit empty one via -e, silently voiding the baseline
  GitHub read access every session gets regardless of forge.
- The collision warning now also checks that push_token_file is actually
  set before claiming GH_TOKEN comes from it - previously it only checked
  the read-only token_file, so it could fire (with a false claim) even when
  push_token_file was never configured.
2026-08-03 20:42:44 -07:00
branderson de38870e1f forges: add github, pushing with its own write-capable token 2026-08-03 20:20:47 -07:00
branderson b2d7964faf allow: fix review findings - restore loop-abort guard, stop false approval claim
dejima-project approve's set -e loop-abort guard (the || collector around
each dejima-allow call) had lost its only cover: the docker-failure
induction that used to exercise it is exactly what Task 2 removes. Add a
real induction (an unwritable project store) and a test that fails without
the collector.

dejima-allow --project can now fail with no Docker involved at all, so a
nonzero exit from it means the approval was refused, not merely "not
applied to a running session" - fix dejima-project's message to say so
instead of falsely claiming next-launch effect for something that was
never recorded.

Also: reorder the one old-order dejima-allow invocation left in
test/static.bats, and fix two comments (bin/dejima-allow,
test/static.bats) that still described DEJIMA_ALLOW_SKIP_SESSION as
covering --project persistence, which session_wanted now does on its own.
2026-08-03 20:13:22 -07:00
branderson 2f2722a9f2 workflows: add local-only, which pushes nothing 2026-08-03 19:41:04 -07:00
branderson f48e5827bb workflows: add branch-push, which stops at the branch 2026-08-03 19:35:22 -07:00
branderson ba4cf05179 allow: flags first, and keep scope flags away from containers
dejima-allow now takes [--global] [--project [path]] [--container <id>]
<domain>, using the shared parse_scope_args grammar from dejima-project-lib.sh
instead of its own ad hoc loop. The old <domain> [--global] [--project <path>]
order is rejected outright, not accepted alongside the new one.

--project/--global are approvals for future sessions, not a claim about a
container running right now, so an invocation using only those flags never
touches docker at all - the issue's headline complaint. --container is the
explicit opt-in to also patching a live session.

bin/dejima-project's own internal call had to move to the new order too,
since the old one is gone; its "no running session" comment was updated to
match (it can no longer fail there without --container).
2026-08-03 19:32:00 -07:00
branderson f4bfc78631 briefing: state the network posture the selected forge actually gives 2026-08-03 19:28:03 -07:00
branderson e216708cc8 forges: trim none's manifest comment to the one surprising fact 2026-08-03 19:20:49 -07:00
branderson 13c04748f0 forges: add none, for a sandbox that pushes nowhere 2026-08-03 19:00:54 -07:00
branderson 6ce77e28ab lib: share the scope-argument parser between the CLIs 2026-08-03 18:44:56 -07:00
39 changed files with 5274 additions and 467 deletions
+25
View File
@@ -13,6 +13,17 @@ socket, by design. Everything else runs anywhere, which is what the CI job names
mean: `sandbox` for the suites that need only a shell, `host-docker` for the one
that does not.
## `bats` is a declared dependency here, not part of the image
It is in `.dejima/dependencies`, like any other project's test runner, rather
than baked into the base image for every project that never runs bats. So on a
sandbox whose image predates that approval, `bats` is simply absent — the fix
is the ordinary one the briefing describes (`dejima-project approve`, then
relaunch so the derived image is rebuilt), not installing it in-session.
If it is missing and you cannot get a rebuild, say the suites were not run
rather than reporting a change as tested.
## Run the suite for what you changed, not all of them
The suites are independent and cover different areas. Running all of them for
@@ -55,6 +66,20 @@ credentials cross the container boundary, that runtime run is the only thing
exercising it end to end. Worth asking the operator to run it on the host rather
than waiting on the runner.
## Pushing a branch here runs your code outside the sandbox
The moment you push, CI checks that commit out on a host and executes it — and
because the workflow runs `bats test/<suite>.bats`, every file in `test/` is
code that runs there, not just the workflow file. Nothing about that is under
the sandbox's firewall, and no one reads the diff first.
Practically: a helper you add to a test file gets executed on a machine outside
this container. Write test code you would be comfortable running there, and if
a change would have CI reach a new host, that belongs in
`.dejima/allowed-domains` in the same pull request — on this project the CI
allowlist is read from `main`, so an undeclared destination fails rather than
connects. See [docs/ci.md](docs/ci.md).
## A test that reads its environment will pass here and fail on your machine
Twice now a test has stated a precondition by leaving something unset, which
+7
View File
@@ -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
+2
View File
@@ -0,0 +1,2 @@
apt:tmux
apt:bats
+304 -13
View File
@@ -17,8 +17,34 @@
# covering every unrelated area at once.
name: tests
# pull_request_target, not pull_request, and the difference is the whole
# security posture of this file.
#
# Under `pull_request` the workflow is read from the HEAD of the pull request -
# so the branch under test decides what runs and, critically, `runs-on:`. A
# branch can point its jobs at any runner its repository can reach, which
# means every control the runner provides (no Docker socket, deny-by-default
# egress) is opt-out by whoever wrote the branch.
#
# Under `pull_request_target` Gitea reads the workflow from the BASE branch,
# and explicitly discards any pull_request_target workflow found on the head
# commit - so a branch can neither edit this file's copy nor introduce its
# own. What runs here, and where, is whatever `main` says.
#
# The cost is that the checkout no longer defaults to the code under test: the
# run's ref is the base branch. Every job that means to test the branch has to
# say so, which is why the checkouts below carry an explicit ref. A bare
# checkout here would test `main` on every pull request and pass regardless of
# the change - green checks that mean nothing.
#
# This does NOT make the repository safe on its own. A branch can still add a
# workflow of its own with `on: pull_request`, and that runs from the head
# with whatever `runs-on:` it likes. The only thing stopping that is which
# runners the repository can reach at all - see docs/ci.md, "A label is not a
# boundary". What this buys is that the intended CI is trustworthy, and that
# escaping it requires adding a file rather than editing one.
on:
pull_request:
pull_request_target:
# No `concurrency:` block. Gitea does not implement one - the word appears
# nowhere in its actions models or services as of 1.25 - so writing it here
@@ -29,7 +55,21 @@ on:
jobs:
sandbox:
runs-on: ubuntu-latest
# A label carried only by a runner configured for this - see ci/ and
# docs/ci.md. Not a generic one, even though these suites genuinely do run
# anywhere a shell does, because "runs anywhere" is a statement about what
# the job needs and not about what it should be handed.
#
# Whether a job container gets the Docker socket is a property of the
# runner's config.yaml (container.docker_host), not of anything this file
# can ask for or refuse. So a job that needs nothing still receives
# whatever its runner grants everything it runs, and naming a specific
# label is the only way this file gets a say in which runner that is.
#
# If no runner carries this label, these jobs queue. That is the intended
# failure: it is a better one than a green run on a host that grants
# everything.
runs-on: dejima-ci
strategy:
# One suite per job. fail-fast off because these cover unrelated areas -
# cancelling the other six on the first failure would hide whether a
@@ -51,11 +91,65 @@ jobs:
# needs to run at all, and apt-get is what everything below needs.
image: node:20-bookworm
steps:
# Before apt, because apt's failure for this says "Unable to locate
# package bats" and nothing about why. On a runner behind the egress
# allowlist a name that does not resolve is the resolver or the
# allowlist, not the package - and ci-runner.bats, which would say so
# precisely, cannot run yet because installing it is what just failed.
#
# Uses only what the image already has: getent is libc, and the
# resolver's address is in resolv.conf.
# Before apt, because apt's failure for this says "Unable to locate
# package bats" and nothing about why. On a runner behind the egress
# allowlist a name that does not resolve is the resolver or the
# allowlist, not the package - and ci-runner.bats, which would say so
# precisely, cannot run yet because installing it is what just failed.
#
# Retried rather than decided on one attempt: a resolver can be a moment
# behind a container's start, and apt itself retries, so failing a whole
# job on a single lookup would be stricter than the step it is
# protecting. Six attempts over ~15s, then a diagnosis.
#
# Uses only what the image already has: getent is libc.
- name: Check the job network can resolve before installing anything
run: |
for attempt in 1 2 3 4 5 6; do
if getent hosts deb.debian.org >/dev/null 2>&1; then
[ "$attempt" -eq 1 ] || echo "resolved on attempt $attempt"
exit 0
fi
sleep 3
done
echo "deb.debian.org did not resolve after 6 attempts, so this job" >&2
echo "cannot install anything." >&2
echo "" >&2
echo "This job's resolver:" >&2
sed -n 's/^nameserver/ nameserver/p' /etc/resolv.conf >&2
echo "" >&2
echo "If other jobs in this same run resolved fine, the runners are" >&2
echo "configured differently: check that EVERY runner answering this" >&2
echo "label has --dns=<gateway> in container.options and the same" >&2
echo "container.network. A runner missing it fails only the jobs that" >&2
echo "happen to land on it, which looks intermittent." >&2
echo "" >&2
echo "If every job failed, check on the runner host, in order:" >&2
echo " dejima-ci-allowlist show - a server= line for this name?" >&2
echo " systemctl status dnsmasq - running?" >&2
echo " iptables -S DEJIMA-CI-IN - port 53 allowed from the bridge?" >&2
echo " journalctl -u dnsmasq -n 50 - does the query arrive, and is it" >&2
echo " forwarded without a reply?" >&2
exit 1
- name: Install test dependencies
run: |
apt-get update
apt-get install -y --no-install-recommends bats jq python3 tmux ca-certificates
# The code under test, named explicitly - see the header. Without `ref`
# this checks out the base branch and the suite passes without ever
# seeing the change.
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
# As an ordinary user, not root. One test leaves a directory mode 500 and
# asserts that the CLI's rm -rf refuses it and says so; root walks
# through that on CAP_DAC_OVERRIDE and the test fails for a reason that
@@ -72,18 +166,20 @@ jobs:
su node -c "TERM=xterm-256color bats test/${{ matrix.suite }}.bats"
host-docker:
# Not ubuntu-latest, unlike the job above. This one only works on a runner
# configured for it - host network namespace, the Docker socket, and the
# shared volume below - and none of that is anything the workflow can ask
# for. On a generic runner it fails the preflight check outright.
# A different label from the job above, and the only one in this file that
# asks for anything privileged. This job needs the host network namespace,
# the Docker socket and the shared volume below, none of which a workflow
# can request - they are granted by the runner or not at all, so on a
# runner without them it fails the preflight check outright.
#
# ubuntu-latest is a label every runner carries, so which one picked this
# up was a coin flip, and a re-run that happened to land on an unconfigured
# runner failed on a repo that had not changed. A label only the configured
# runner has makes that unrepresentable rather than merely diagnosable: no
# runner matching it means this job queues instead of running somewhere it
# cannot work. The sandbox job stays on ubuntu-latest, because it genuinely
# does run anywhere.
# A label carried by exactly one configured runner makes landing somewhere
# unusable unrepresentable rather than merely diagnosable: no runner
# matching it means this queues instead of running somewhere it cannot
# work. A generic label would make which runner picked it up a coin flip.
#
# The runner behind this label can be taken by anything it runs (see
# ci/act-runner-runtime.example.yaml), so it belongs on a host that exists
# for that and holds nothing else.
runs-on: dejima-runtime
container:
image: node:20-bookworm
@@ -117,6 +213,55 @@ jobs:
# nothing to do with what the test asserts. That test is the containment
# argument for the ttyd front door, so it is not one to skip in CI.
steps:
# Before apt, because apt's failure for this says "Unable to locate
# package bats" and nothing about why. On a runner behind the egress
# allowlist a name that does not resolve is the resolver or the
# allowlist, not the package - and ci-runner.bats, which would say so
# precisely, cannot run yet because installing it is what just failed.
#
# Uses only what the image already has: getent is libc, and the
# resolver's address is in resolv.conf.
# Before apt, because apt's failure for this says "Unable to locate
# package bats" and nothing about why. On a runner behind the egress
# allowlist a name that does not resolve is the resolver or the
# allowlist, not the package - and ci-runner.bats, which would say so
# precisely, cannot run yet because installing it is what just failed.
#
# Retried rather than decided on one attempt: a resolver can be a moment
# behind a container's start, and apt itself retries, so failing a whole
# job on a single lookup would be stricter than the step it is
# protecting. Six attempts over ~15s, then a diagnosis.
#
# Uses only what the image already has: getent is libc.
- name: Check the job network can resolve before installing anything
run: |
for attempt in 1 2 3 4 5 6; do
if getent hosts deb.debian.org >/dev/null 2>&1; then
[ "$attempt" -eq 1 ] || echo "resolved on attempt $attempt"
exit 0
fi
sleep 3
done
echo "deb.debian.org did not resolve after 6 attempts, so this job" >&2
echo "cannot install anything." >&2
echo "" >&2
echo "This job's resolver:" >&2
sed -n 's/^nameserver/ nameserver/p' /etc/resolv.conf >&2
echo "" >&2
echo "If other jobs in this same run resolved fine, the runners are" >&2
echo "configured differently: check that EVERY runner answering this" >&2
echo "label has --dns=<gateway> in container.options and the same" >&2
echo "container.network. A runner missing it fails only the jobs that" >&2
echo "happen to land on it, which looks intermittent." >&2
echo "" >&2
echo "If every job failed, check on the runner host, in order:" >&2
echo " dejima-ci-allowlist show - a server= line for this name?" >&2
echo " systemctl status dnsmasq - running?" >&2
echo " iptables -S DEJIMA-CI-IN - port 53 allowed from the bridge?" >&2
echo " journalctl -u dnsmasq -n 50 - does the query arrive, and is it" >&2
echo " forwarded without a reply?" >&2
exit 1
- name: Install test dependencies
run: |
apt-get update
@@ -239,6 +384,8 @@ jobs:
echo "GitHub ranges lookup is authenticated - ceiling is $limit requests/hour."
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
# The build dominates this job, and inside setup_file it is a `docker
# build -q` that prints nothing at all - so tens of minutes of ordinary
@@ -291,3 +438,147 @@ jobs:
# own BATS_*_TMPDIR on paths that mean the same thing on both sides
# of the socket, which is all any of those mounts need.
TMPDIR: /tmp/dejima-ci
# The runner's own configuration, asserted from inside a job on it. Separate
# jobs rather than steps on the two above, because the point is that these
# are two different runners and either can be wrong on its own - one red
# check per tier says which.
#
# These fail on how the RUNNER is configured, not on anything in a pull
# request. A red check here means the machine needs attention; it does not
# mean the branch does.
runner-env-sandbox:
runs-on: dejima-ci
name: runner-env (sandbox)
container:
image: node:20-bookworm
steps:
# Before apt, because apt's failure for this says "Unable to locate
# package bats" and nothing about why. On a runner behind the egress
# allowlist a name that does not resolve is the resolver or the
# allowlist, not the package - and ci-runner.bats, which would say so
# precisely, cannot run yet because installing it is what just failed.
#
# Uses only what the image already has: getent is libc, and the
# resolver's address is in resolv.conf.
# Before apt, because apt's failure for this says "Unable to locate
# package bats" and nothing about why. On a runner behind the egress
# allowlist a name that does not resolve is the resolver or the
# allowlist, not the package - and ci-runner.bats, which would say so
# precisely, cannot run yet because installing it is what just failed.
#
# Retried rather than decided on one attempt: a resolver can be a moment
# behind a container's start, and apt itself retries, so failing a whole
# job on a single lookup would be stricter than the step it is
# protecting. Six attempts over ~15s, then a diagnosis.
#
# Uses only what the image already has: getent is libc.
- name: Check the job network can resolve before installing anything
run: |
for attempt in 1 2 3 4 5 6; do
if getent hosts deb.debian.org >/dev/null 2>&1; then
[ "$attempt" -eq 1 ] || echo "resolved on attempt $attempt"
exit 0
fi
sleep 3
done
echo "deb.debian.org did not resolve after 6 attempts, so this job" >&2
echo "cannot install anything." >&2
echo "" >&2
echo "This job's resolver:" >&2
sed -n 's/^nameserver/ nameserver/p' /etc/resolv.conf >&2
echo "" >&2
echo "If other jobs in this same run resolved fine, the runners are" >&2
echo "configured differently: check that EVERY runner answering this" >&2
echo "label has --dns=<gateway> in container.options and the same" >&2
echo "container.network. A runner missing it fails only the jobs that" >&2
echo "happen to land on it, which looks intermittent." >&2
echo "" >&2
echo "If every job failed, check on the runner host, in order:" >&2
echo " dejima-ci-allowlist show - a server= line for this name?" >&2
echo " systemctl status dnsmasq - running?" >&2
echo " iptables -S DEJIMA-CI-IN - port 53 allowed from the bridge?" >&2
echo " journalctl -u dnsmasq -n 50 - does the query arrive, and is it" >&2
echo " forwarded without a reply?" >&2
exit 1
- name: Install test dependencies
run: |
apt-get update
apt-get install -y --no-install-recommends bats python3 ca-certificates
# No `ref:` here, deliberately - unlike the suites above. These assert on
# the RUNNER, not on the change, so the assertions should come from the
# base branch rather than from the branch they are meant to hold for.
# Under pull_request_target that is what a bare checkout gives, which
# means a branch can neither weaken these tests nor delete them.
- uses: actions/checkout@v4
- run: bats test/ci-runner.bats
env:
# Declared, not detected: landing on the wrong runner should fail
# rather than quietly select a different set of assertions.
DEJIMA_CI_TIER: sandbox
runner-env-host-docker:
runs-on: dejima-runtime
name: runner-env (host-docker)
container:
image: node:20-bookworm
steps:
# Before apt, because apt's failure for this says "Unable to locate
# package bats" and nothing about why. On a runner behind the egress
# allowlist a name that does not resolve is the resolver or the
# allowlist, not the package - and ci-runner.bats, which would say so
# precisely, cannot run yet because installing it is what just failed.
#
# Uses only what the image already has: getent is libc, and the
# resolver's address is in resolv.conf.
# Before apt, because apt's failure for this says "Unable to locate
# package bats" and nothing about why. On a runner behind the egress
# allowlist a name that does not resolve is the resolver or the
# allowlist, not the package - and ci-runner.bats, which would say so
# precisely, cannot run yet because installing it is what just failed.
#
# Retried rather than decided on one attempt: a resolver can be a moment
# behind a container's start, and apt itself retries, so failing a whole
# job on a single lookup would be stricter than the step it is
# protecting. Six attempts over ~15s, then a diagnosis.
#
# Uses only what the image already has: getent is libc.
- name: Check the job network can resolve before installing anything
run: |
for attempt in 1 2 3 4 5 6; do
if getent hosts deb.debian.org >/dev/null 2>&1; then
[ "$attempt" -eq 1 ] || echo "resolved on attempt $attempt"
exit 0
fi
sleep 3
done
echo "deb.debian.org did not resolve after 6 attempts, so this job" >&2
echo "cannot install anything." >&2
echo "" >&2
echo "This job's resolver:" >&2
sed -n 's/^nameserver/ nameserver/p' /etc/resolv.conf >&2
echo "" >&2
echo "If other jobs in this same run resolved fine, the runners are" >&2
echo "configured differently: check that EVERY runner answering this" >&2
echo "label has --dns=<gateway> in container.options and the same" >&2
echo "container.network. A runner missing it fails only the jobs that" >&2
echo "happen to land on it, which looks intermittent." >&2
echo "" >&2
echo "If every job failed, check on the runner host, in order:" >&2
echo " dejima-ci-allowlist show - a server= line for this name?" >&2
echo " systemctl status dnsmasq - running?" >&2
echo " iptables -S DEJIMA-CI-IN - port 53 allowed from the bridge?" >&2
echo " journalctl -u dnsmasq -n 50 - does the query arrive, and is it" >&2
echo " forwarded without a reply?" >&2
exit 1
- name: Install test dependencies
run: |
apt-get update
apt-get install -y --no-install-recommends bats python3 ca-certificates
# Base branch, same as the job above and for the same reason.
- uses: actions/checkout@v4
- run: bats test/ci-runner.bats
env:
DEJIMA_CI_TIER: host-docker
+292 -66
View File
@@ -117,8 +117,8 @@ see the whole resolved schema, real values included.
| Key | Default | Purpose |
| --- | --- | --- |
| `[core] harness` | `claude-code` | agent harness the container runs |
| `[core] forge` | `gitea` | forge integration (`gitea-pr`/`gitea-issue`) |
| `[core] workflow` | `worktree-pr` | workflow `sandbox-context.sh` tells the agent to follow |
| `[core] forge` | `none` | forge integration — `gitea` (`gitea-pr`/`gitea-issue`), `github` (`gh`, opens `ssh.github.com:22` for push), or `none` (no forge at all) |
| `[core] workflow` | `local-only` | workflow `sandbox-context.sh` tells the agent to follow — `worktree-pr` (push + open a PR), `branch-push` (push, stop at the branch), or `local-only` (commit locally, push nothing) |
| `[core] command` | `claude` | container's main process (e.g. `nvim`) — also settable per launch with `--cmd` |
| `[core] firewall` | `on` | egress firewall; `off` disables it (debugging only) — also settable per launch with `--no-firewall` |
| `[core] share_dir` | `~/.local/share/dejima/shared` | host-visible read/write scratch dir, mounted at `~/shared` |
@@ -129,6 +129,7 @@ see the whole resolved schema, real values included.
| `[forge.gitea] ssh_host` | unset | Gitea/Forgejo SSH hostname — skip if you only push to GitHub |
| `[forge.gitea] api` | unset | `https://<gitea host>/api/v1` — skip if you only push to GitHub |
| `[forge.gitea] token_file` | `~/.config/gitea/token` | path to the Gitea/Forgejo API token (the file, not the token itself) |
| `[forge.github] push_token_file` | unset | path to a **write-capable** PAT, used only when `[core] forge = github` — deliberately separate from `[github] token_file` below; see "GitHub push" |
| `[github] token_file` | `~/.config/gh/sandbox-token` | path to a read-only PAT for private GitHub repos / `gh` |
| `[mounts] readonly` | unset | extra host paths (colon-separated) a personal config needs, mounted read-only at their own path |
| `[mounts] read_write` | unset | host paths (colon-separated) mounted **read-write** at their own path — for directories meant to outlive the session; refused if they overlap anything dejima needs out of a session's reach |
@@ -148,24 +149,50 @@ see the whole resolved schema, real values included.
| `[remote-access] ttyd_font_size` | unset | `ttyd` terminal font size |
| `[remote-access] debounce` | `60` | seconds between repeat notifications for the same session |
**Gitea/Forgejo is the default PR workflow** — `gitea-pr`, and the only push
target `git-push-guard.sh` allows by default. If you don't use Gitea/Forgejo,
skip `[forge.gitea]` entirely and use `dejima --allow-github-push` instead to
push straight to GitHub (see "GitHub push: blocked by default, optional
opt-in" below) — the two aren't both required, pick whichever matches your
remote. [Forgejo](https://forgejo.org/) works unmodified: it's a Gitea fork
exposing a compatible `/api/v1` surface, so pointing `[forge.gitea] api` at a
Forgejo instance just works.
**No forge is the default** — `[core] forge = none` with
`[core] workflow = local-only`: the sandbox commits locally and pushes
nowhere. A fresh install has no forge credentials, so defaulting to a pushing
pair would be claiming a setup that isn't there — and a pushing workflow whose
forge resolves to no host is refused at launch, which would make an
out-of-the-box `dejima` refuse to start. Pushing is something you opt into.
> **Changed default (was `gitea` + `worktree-pr`).** If your
> `~/.config/dejima/config` has no explicit `[core] forge` / `[core] workflow`
> lines, it used to resolve to `gitea` + `worktree-pr` and now resolves to
> `none` + `local-only` — your sandbox will stop pushing and stop opening PRs.
> To keep the old behaviour, write both lines out under `[core]`:
> `forge = gitea` and `workflow = worktree-pr` (and fill in `[forge.gitea]`,
> which that pair has always required). `./install.sh` prints this notice
> when it finds a config in that state.
To push, set `[core] workflow` to `worktree-pr` (push + open a PR) or
`branch-push` (push, stop at the branch), and pick the forge to push to:
`[core] forge = gitea` for a Gitea/Forgejo instance (fill in `[forge.gitea]`
per "Gitea/Forgejo PR workflow" below), or `[core] forge = github` to push and
open PRs through `gh` (see "GitHub push" below for the posture change that
opens, and why its `push_token_file` is a separate credential from the
read-only `[github] token_file`). `dejima --allow-github-push` is a
per-launch *addition* to whichever forge is selected, not a substitute for
selecting one: a pushing workflow is still refused at launch unless its forge
resolves to a host and its required keys are filled in, so the flag alone
will not get an unconfigured `[forge.gitea]` past validation. [Forgejo](https://forgejo.org/) works unmodified: it's a Gitea
fork exposing a compatible `/api/v1` surface, so pointing `[forge.gitea] api`
at a Forgejo instance just works. See
[docs/providers.md](docs/providers.md#shipped-providers) for every shipped
forge/workflow and what each is for.
## Provider axes: forge, workflow, harness
The forge integration, the push/PR workflow, and the agent harness aren't
hardcoded into dejima — each is a directory under `forges/`, `workflows/` or
`harnesses/`, selected by `[core] forge`, `[core] workflow` and
`[core] harness` respectively. Ships today: `forges/gitea`,
`workflows/worktree-pr`, `harnesses/claude-code` — which is why the rest of
this document reads as if Gitea/Forgejo and a worktree-and-PR workflow are
the only option; right now, they are the only one that ships.
`[core] harness` respectively. Ships today: `forges/gitea`, `forges/github`,
`forges/none`, `workflows/worktree-pr`, `workflows/branch-push`,
`workflows/local-only`, `harnesses/claude-code`. The defaults are
`forges/none` and `workflows/local-only` — a fresh install commits locally
and pushes nowhere; the rest of this document spends most of its words on
Gitea/Forgejo and worktree-and-PR because that's the setup with the most to
configure, not because it's what you get out of the box.
A provider is **data**, never sourced or executed: its manifest (`forge.conf`,
`workflow.conf`, `harness.conf`) is parsed with the same discipline as the
@@ -183,7 +210,8 @@ matter of adding a directory rather than editing this repo's own code.
values into the container — the hostnames a forge needs reachable, which of
them pushes are allowed to, which branches are protected, and the literal
merge command to block — plus the forge's own environment variables (a
Gitea/Forgejo API token, in the shipped forge's case). The session briefing
Gitea/Forgejo API token for `gitea`, a write-capable PAT for `github`,
nothing for `none`). The session briefing
itself (the text `hooks/sandbox-context.sh` injects at `SessionStart`) is
composed host-side too, from a fixed core fragment no provider can override
plus the selected workflow's and harness's own prose, then mounted
@@ -217,11 +245,18 @@ sandbox escape if you get it wrong.
→ [Prerequisites](#prerequisites),
[Headless git push without agent forwarding](#headless-git-push-without-agent-forwarding)
That is enough to run `dejima`.
That is enough to run `dejima`. The starter config `install.sh` writes has
every key commented out at its default, which means `[core] forge = none` and
`[core] workflow = local-only`: the agent works in the sandbox and commits
locally, and nothing is pushed anywhere. To have it push, pick one of the two
forge sections below and set `[core] workflow` to `worktree-pr` or
`branch-push` — a pushing workflow is refused at launch unless its forge
resolves to somewhere to push to, so the forge is not optional once you
choose one of them.
### Gitea/Forgejo PR workflow
Skip this if you only push to GitHub.
Skip this if you use `forge = github` or `forge = none` instead (below).
1. Set `[forge.gitea] ssh_host` (the SSH hostname) and `[forge.gitea] api`
(`https://<gitea host>/api/v1`) in `~/.config/dejima/config`.
@@ -229,6 +264,34 @@ Skip this if you only push to GitHub.
`write:issue` — and save it to `~/.config/gitea/token`, `chmod 600`.
→ [Prerequisites](#prerequisites)
### GitHub forge (opt-in alternative)
Set `[core] forge = github` instead of the above to push and open PRs
against GitHub directly through `gh`, rather than a Gitea/Forgejo instance.
This is a persistent, per-config choice — different from the per-launch
`--allow-github-push` flag (see "GitHub push" below), and it opens
`ssh.github.com:22` for push every time this forge is selected with a
workflow that pushes, not just for one launch. It opens it through the same
grant that flag uses, so the posture is the one documented there and nothing
else about GitHub's treatment changes.
1. Generate a **write-capable** PAT, separate from the read-only
`[github] token_file` used for read access — promoting the read-only
token here would defeat the point of scoping it read-only.
2. Save it to a file and set `[forge.github] push_token_file` to that path.
dejima warns at startup if both `[github] token_file` and
`[forge.github] push_token_file` are set and non-empty: `gh` only reads one
token variable, and the write-capable one wins for that launch.
### Pushing nowhere
If this sandbox should never push or open a PR — read-only work, exploring a
codebase, anything where you'll take the diff out by hand — set
`[core] forge = none` and `[core] workflow = local-only`. Nothing to
configure for either; the agent commits locally and stops there, and
`git-push-guard.sh` refuses every push regardless of what else is set.
### Per-project domains and dependencies
Nothing to set up in advance; do this per project, as it turns out to need
@@ -237,14 +300,22 @@ something:
```sh
dejima-project status ~/myproject # declared vs approved vs missing
dejima-project approve ~/myproject # approve the declared set
dejima-allow crates.io --project ~/myproject
dejima-dep add cargo:cargo-nextest --global
dejima-allow --project ~/myproject crates.io
dejima-dep add --global cargo:cargo-nextest
```
Domains apply at the next launch, and `dejima-allow` also patches a running
session. Dependencies need an image rebuild, so they always land on the next
one. →
[Global and per-project domains and dependencies](#global-and-per-project-domains-and-dependencies),
`dejima-allow` with no scope flag patches a running session; `--project` or
`--global` instead records an approval for future sessions, and domains
apply at their next launch. Dependencies need an image rebuild, so they
always land on the next one.
`--project` records two things: the approval in `~/.config/dejima` (which is
what actually grants) and a declaration in the project's own
`.dejima/allowed-domains` (which is committed, so the rest of the project gets
it too). `--global` writes only the approval — there is no project to declare
in.
→ [Global and per-project domains and dependencies](#global-and-per-project-domains-and-dependencies),
[Adding a domain to a running session](#adding-a-domain-to-a-running-session)
### Log shipping
@@ -347,6 +418,13 @@ that gets deferred rather than done: `list`, `view`, `create`, `comment`,
`close` and `reopen`, so a problem found mid-session becomes a ticket instead
of a footnote on an unrelated PR.
`gitea-actions` closes the other half of that loop. A sandbox cannot run the
tests that need a Docker daemon, so CI is where they run, and without a way
to read a run the only route from a red build back into the session is the
operator pasting the log in by hand. `gitea-actions runs`, `jobs <run>` and
`logs [run]` do that instead — with no run given, the newest one for `HEAD`,
and by default only the jobs that failed. It reads and never writes.
Because the session stops at the PR and you merge afterwards, the sandbox
context tells Claude to put `Fixes #N` in the PR body rather than to close
issues by hand once they land — the merge is the last step in that sequence
@@ -609,15 +687,32 @@ default:
- DNS resolution via a local proxy (see "DNS is allowlisted too")
- the Docker host itself (not its whole subnet)
- GitHub's published IP ranges, port 443 only (see "GitHub push" below)
- `registry.npmjs.org`, `pypi.org`, `files.pythonhosted.org` (npm and
mason's Python-based LSP tools), `release-assets.githubusercontent.com`
(GitHub release assets, a separate CDN from github.com's own IPs),
`proxy.golang.org`, `sum.golang.org` (`go` module proxy/checksums);
`api.anthropic.com` when the credential proxy is off (kill switch or API-key auth)
- whatever `[forge.gitea] ssh_host`/`api` resolve to — not port-restricted, so
git-over-ssh to Gitea works without a blanket "port 22 to anywhere" rule
- `registry.npmjs.org` and `platform.claude.com` (the harness and its
updates), `release-assets.githubusercontent.com` (GitHub release assets, a
separate CDN from github.com's own IPs, and where a good deal of tooling
fetches its binaries); `api.anthropic.com` when the credential proxy is off
(kill switch or API-key auth)
Everything else is rejected. Run `dejima-allow <domain> --global`
That is the whole built-in list, and it is meant to stay that way: it covers
what the sandbox itself cannot run without, not what a project might want.
Language package hosts are approved rather than assumed — `pypi.org`,
`proxy.golang.org`, `crates.io` and the rest are one `dejima-allow --global`
or one reviewed `.dejima/allowed-domains` line away. The reason to keep the
built-in list short is that everything on it is reachable from every sandbox
on the host whether or not a project has any use for it, and a name granted
by being built in is a name nobody ever reviewed
- whatever the selected forge needs reachable (`[forge.gitea] ssh_host`/`api`
by default) — not port-restricted, so git-over-ssh to the forge works
without a blanket "port 22 to anywhere" rule, and exempted from DNS-rebind
protection because a self-hosted forge host legitimately resolves to an
internal address. `[core] forge = github` is deliberately *not* added here:
its three hostnames are public names already covered by the GitHub entry
above, so they stay on that entry — 443 by default, plus port 22 when the
selected workflow pushes, exactly as `--allow-github-push` opens it per
launch — rather than picking up all-port egress and losing rebind
protection on the way
Everything else is rejected. Run `dejima-allow --global <domain>`
(or `--project <path>` for one project) to allow more permanently (e.g.
`crates.io` for a Rust project), or `dejima --no-firewall` to disable the
firewall entirely for one launch (debugging only).
@@ -649,8 +744,8 @@ Approved domains only take effect at container start. For a domain a
sandbox — it has no privilege to do this itself):
```sh
dejima-allow crates.io # this session only
dejima-allow crates.io --global # + persist for every project
dejima-allow crates.io # this running session only
dejima-allow --container <id> crates.io # a specific session, if more than one is running
```
It first adds the domain to the running dnsmasq's allowlist (appends a
@@ -659,17 +754,21 @@ file - no restart), then resolves it through the now-unblocked container
resolver and adds the IP(s) to the running firewall's `ipset`, all via
`docker exec -u root`. It finishes by checking the domain is actually
reachable from inside that same container and prints a definitive verdict
— exit code 2 if it isn't. That check is a report on the live session only,
not a gate on approval: `--global`/`--project` persistence is recorded
first and unconditionally, since the domain being approved may legitimately
not answer on 443 (an internal service, an SSH-only host) and the natural
moment to add a global approval is precisely when nothing is running to
probe in the first place. (`--project` persistence likewise does not need a
running container at all — see below.) `--global` writes the domain to the
global approval store for every future session; `--global` and `--project`
are not mutually exclusive here - give both together and each persists to
its own store. If more than one dejima container is running, pass
`--container <name>` to disambiguate - `docker ps` finds it, or use the
— exit code 2 if it isn't.
`--global` and `--project` are the opposite of this: they record an approval
for *future* sessions and deliberately do not touch a running container, even
together - see
[Global and per-project domains and dependencies](#global-and-per-project-domains-and-dependencies)
below, since the domain being approved may legitimately not answer on 443 (an
internal service, an SSH-only host) and the natural moment to add an approval
is precisely when nothing is running to probe in the first place. Pass
`--container <id>` alongside a scope flag to do both in one call:
`dejima-allow --global --container <id> crates.io` records the approval and
patches that session immediately, in that order, so the approval is never
lost to a live-session failure. If more than one dejima container is
running, the bare form above refuses and asks you to disambiguate with
`--container <name>` - `docker ps` finds it, or use the
container's own ID, which `sandbox-context.sh` already hands the agent in
its startup context (and which is always available inside the sandbox as
`cat /etc/hostname`), so there's no need for it to guess one and risk
@@ -713,12 +812,48 @@ A project can declare what it needs in two committed files:
```
.dejima/allowed-domains # one domain per line
.dejima/dependencies # <apt|npm|pip|cargo>:<name> per line
.dejima/dependencies # <apt|npm|pip|cargo>:<name> per line, or a
# tarball:<name>@<sha256>:<url> release archive
```
### Tarball dependencies
For a toolchain no package ecosystem ships in a version you can use — a
current Go, say, where Debian's is several releases behind — a dependency can
name a release archive instead:
```sh
dejima-dep add --global tarball:go@<sha256>:https://go.dev/dl/go1.25.0.linux-amd64.tar.gz
```
It unpacks with `--strip-components=1` into `/opt/dejima/<name>`, and
`/opt/dejima/<name>/bin` goes on `PATH`. That one convention covers the usual
shapes without the spec needing layout options: a Go archive has everything
under `go/`, an nvim release under `nvim-linux-<arch>/`, and stripping one
level puts `bin/` in the same place for both. Compression is detected from the
archive, so `.tar.gz`, `.tar.xz` and `.tar.zst` all work.
**The digest is mandatory**, and it is checked before anything is unpacked.
Without one you would be approving a *name* whose contents the far end can
change afterwards, which isn't a decision anyone can make once. With one, what
you approved is a specific archive and nothing else can take its place. Two
consequences worth knowing:
- **A tarball dependency names one architecture**, because a digest does.
Unlike a package name, it can't resolve per-host, so a mixed-architecture
fleet needs a spec per project rather than one global one.
- **Upgrading is an approval, not a version bump.** A new release is a new
digest, so it goes through `dejima-dep add` and `dejima-project approve`
again — which is the point, not friction to route around.
The URL's character set is deliberately narrower than URLs allow: the spec is
interpolated into a `RUN` line that runs as root at build time, so anything
shell-active is refused outright rather than quoted around, and only `https`
is accepted.
**Declaring grants nothing.** The project directory is bind-mounted
read-write, so a compromised session could write these files - they are only
ever read to compare against reality and suggest a command. Approvals live
read-write, so a compromised session could write these files - they are read
only to compare against reality and suggest a command. Approvals live
outside the container, under `~/.config/dejima/` by default
(`$DEJIMA_CONFIG_DIR` relocates the whole tree) - global at its
root, per project under `projects/<slug>/` - and are written only by tools
@@ -727,34 +862,77 @@ you run on the host:
```sh
dejima-project status ~/myproject # declared vs approved vs missing
dejima-project approve ~/myproject # review and approve the declared set
dejima-allow crates.io --project ~/myproject
dejima-allow crates.io --global # applies to every project
dejima-dep add cargo:cargo-nextest --project ~/myproject
dejima-dep add cargo:cargo-nextest --global
dejima-allow --project ~/myproject crates.io
dejima-allow --global crates.io # applies to every project
dejima-dep add --project ~/myproject cargo:cargo-nextest
dejima-dep add --global cargo:cargo-nextest
```
Two of those tools also write the declaration, so an approval you make by
hand does not stay invisible to everyone else on the project:
`dejima-allow --project` appends to `.dejima/allowed-domains`, and
`dejima-dep add --project` appends to `.dejima/dependencies`. Both write the
approval first — that is the part that grants — and report separately on the
declaration, so a project you can't write to still gets you the approval.
`--global` writes no declaration at all: there is no project to declare in.
The declaration those two write still grants nothing; it is the same
untrusted file the section above describes, and writing to it does not
promote it. That it is session-writable is exactly why both tools refuse to
write a `.dejima` directory or a `.dejima/<kind>` file that is a **symlink**:
a compromised session can plant one and then ask you to run the command that
follows it, which would aim a host-side append at your approval store or at
any other file you can write. The refusal is the `open` itself rather than a
test before it — `O_NOFOLLOW` on both, with the file opened relative to the
already-opened directory — so a session that is still running while you type
the command cannot swap `.dejima` or the declaration file out from under the
write. (Those two leaves are what the session controls. A symlink further up
a `--project` path you supply yourself is still resolved through.) Expect the
session to be live: the agent briefing tells the agent to ask you to run
`dejima-allow --project` from a terminal outside the sandbox. The same write
reads the file back to skip an already-declared entry, and that read is
capped — a session can make its own `.dejima/<kind>` arbitrarily large for
free, and an oversized one is refused rather than loaded into your memory.
Neither writer has an inverse for the declaration. `dejima-dep remove
--project` withdraws the approval and deliberately leaves the declaration
alone (it says so, since `dejima-project approve` will otherwise offer the
entry again); `dejima-allow --project` has no `remove` at all. Taking
something back from the project itself means editing `.dejima/` and
committing that.
`dejima-project status`/`approve` classify a declared entry against
the union of global and project approvals, so something already approved
globally shows as approved, not pending. Unlike `dejima-allow`,
`dejima-dep`'s `--global` and `--project` are mutually exclusive -
an entry belongs to exactly one scope.
an entry belongs to exactly one scope. `dejima-dep` has no `--container`
scope either, and refuses the flag rather than ignoring it: a dependency
needs an image rebuild, so there is nothing to patch in a live session.
Approved domains join the allowlist at the next launch (or immediately, for
`dejima-allow`, which also updates the running session). Approved
dependencies are baked into a derived image built `FROM` the shared base -
first `dejima:<username>-global`, if the global store has any
Approved domains join the allowlist at the next launch. `dejima-allow` can
also patch a running session immediately, but only when it is run bare or
with `--container` - approving here (`--project`/`--global`, with no
`--container`) never touches one; see
[Adding a domain to a running session](#adding-a-domain-to-a-running-session).
Approved dependencies are baked into a derived image built `FROM` the shared
base - first `dejima:<username>-global`, if the global store has any
dependency approved, then `dejima:<username>-<slug>` on top of that,
if the project store does - so they need an image rebuild: they take effect
on the **next** launch.
Because of that rebuild, the usual moment to approve is *after* leaving the
sandbox — so persistence (`--global` or `--project`) never requires a running
container, and is never gated on the reachability probe `dejima-allow`
runs against a live session. Recording the approval is your decision;
patching a live session and verifying the domain answers on 443 are
best-effort on top of it, and `dejima-project approve` reports at the
end which domains it could not apply to a running session rather than
stopping at the first one.
container.
`dejima-project approve` never attempts to patch a running session: it only
ever calls `dejima-allow` without `--container`, so recording the approval
is the only thing it does. If an individual approval can't be recorded — an
unwritable store, a slug collision, a corrupt approval list — it reports
that entry as not approved and keeps going, rather than stopping at the
first failure; that holds for dependencies and domains alike, so one bad
entry can't cost you the rest of the set. It then exits non-zero if anything
failed, so `dejima-project approve ~/myproject && dejima` won't launch a
session missing the approvals you just confirmed.
A session whose project declares something it doesn't have is told so in its
startup context, with the exact command to hand you. Project approvals are
@@ -831,6 +1009,16 @@ pretty-printed by default, no extra parsing setup needed.
## GitHub push: blocked by default, optional opt-in
This section is about `--allow-github-push`, a per-launch flag that adds
GitHub as a push destination on top of whichever forge is selected, without
changing that selection. It adds a destination; it does not stand in for a
forge, so the launch-time rules for the selected forge/workflow pair still
apply exactly as they would without it. If you
want GitHub push and PRs as a standing choice instead, set
`[core] forge = github` — see "GitHub forge (opt-in alternative)" above; it
also opens `ssh.github.com:22`, but persistently rather than per launch, and
uses its own `[forge.github] push_token_file` rather than a loaded SSH key.
The sandbox can always read from GitHub (clone, fetch, `gh` API calls). By
**default** it can never push there — enforced at the network layer, not
just by `git-push-guard.sh`: GitHub's IP ranges are only allowlisted on port
@@ -853,9 +1041,15 @@ forwarding doesn't distinguish push from pull. Use a dedicated,
**repo-scoped GitHub deploy key** via `[core] ssh_key` instead whenever
`--allow-github-push` is set — that's what actually limits the blast radius.
`--allow-github-push` only changes the firewall/hook's *host* check; it adds
no credential-loading path of its own. There is deliberately no config-file
equivalent for this flag — an exported or saved value must not silently
grant a push path, so it's a per-launch decision only.
no credential-loading path of its own. The flag itself has no config-file
equivalent — an exported or saved value must not silently grant a push path,
so *this* opt-in stays a per-launch decision only. Selecting `[core] forge =
github` (see above) is a config-file path that opens the same port, but not
silently: `hosts_allowlisted_by_core` is what asks for it, explicitly, in a
manifest, and only when the selected workflow actually pushes. What it opens
is the same GitHub ranges the read-only path already allows on port 443, now
also on port 22, logged like every other allowed connection and verified by
the firewall's own startup self-check.
`[github] token_file` must point at a **read-only** fine-grained PAT
regardless of this setting. `gh auth setup-git` (run at container start if
@@ -880,6 +1074,36 @@ network, and that deserves more room than a section.
bind address and verifying it, the SSH and web front doors, reading and typing
on a phone, running behind another reverse proxy, notifications, and attaching.
## CI: push access is code execution elsewhere
If the project has CI, **granting a session push access grants it code
execution on whatever host that CI runs on** - and nothing in the chain needs
you. The push guard permits a feature branch, the briefing tells the agent to
open a pull request without asking, `on: pull_request` fires, and the runner
executes that commit. Merging is a decision you make *after* the code has
already run, and it does not take a modified workflow file to get there: a
workflow that runs a test suite runs every file in it.
The sandbox bounds what a session can reach directly. It does not bound what
it can arrange to have run elsewhere.
**This holds for projects with no CI, too** - a branch can add the first
workflow, so what matters is which runners the repository can *reach*, not
what it runs today. That is a property of the forge rather than the project,
and it is worth one question per project before you start. On hosted runners
the answer is reassuring; with self-hosted runners in reach it is the thing to
look at. And if you would rather not take it on at all,
`[core] forge = none` with `[core] workflow = local-only` declines the push
grant and the code execution with it.
**→ [docs/ci.md](docs/ci.md)** - the *why*: no approval gate is offered and
what that leaves, hardening a runner, and making CI's egress allowlist a
version-controlled artifact read from `main`.
**→ [dejima-ci-hardener](https://gitbox.branderson.io/branderson/dejima-ci-hardener)** - the *how*: a self-contained tool that
hardens a CI runner, installed on the CI box and nowhere near a dejima host.
Nothing in this repository needs to be copied onto a runner.
## Security notes
What this sandbox does and does not protect you from - the containment
@@ -969,13 +1193,15 @@ likely to collide with a real container you have running.
| `bin/dejima-dep` | Review and approve project dependencies declared in `.dejima/dependencies` |
| `bin/dejima-provider-lib.sh` | Parses forge/workflow/harness manifests and composes the session briefing; not on PATH — see [docs/providers.md](docs/providers.md) |
| `forges/`, `workflows/`, `harnesses/` | Provider directories for the three axes selected by `[core] forge`/`workflow`/`harness` — data, never sourced or executed; see [docs/providers.md](docs/providers.md) |
| `forges/gitea/bin/` | The gitea forge's own CLIs, baked into the image so the sandbox can act on them: `gitea-pr` (create a PR, read its comments, merge it) and `gitea-issue` (list, view, create, comment on, close and reopen issues) |
| `forges/gitea/bin/` | The gitea forge's own CLIs, baked into the image so the sandbox can act on them: `gitea-pr` (create a PR, read its comments, merge it), `gitea-issue` (list, view, create, comment on, close and reopen issues) and `gitea-actions` (read CI runs and job logs) |
| `bin/dejima-notify-watch` | Host-side watcher: turns markers into ntfy pushes composed entirely from host data |
| `bin/dejima-project` | Review and approve project domains and dependencies from the host |
| `bin/dejima-project-lib.sh` | Shared library sourced by `bin/dejima-dep`, `bin/dejima-project`, `bin/dejima` and the remote-access scripts; not on PATH |
| `bin/dejima-ttyd` | Launches `ttyd` on loopback in front of `dejima-attach` |
| `systemd/*.service` | User units for `dejima-ttyd` and `dejima-notify-watch`; ship disabled, installed by hand |
| `docs/providers.md` | The manifest contract for the forge/workflow/harness axes, key by key, with the shipped manifests as worked examples |
| `docs/ci.md` | What push access grants a session on your CI host, and how to bound it - runner hardening, and an egress allowlist read from `main` |
| `ci/` | Worked runner, resolver and firewall examples for the setup `docs/ci.md` describes; installed by nothing, and belongs on a CI machine rather than a dejima one |
| `docs/nginx-dejima.conf.example` | Reverse-proxy template for the web front door; deliberately not loadable as written |
`bin/dejima` is the entry point — it resolves the project path,
+125 -21
View File
@@ -157,6 +157,62 @@ if ! config_exists; then
exit 1
fi
# A selected forge's env_export can name a variable core also fills from its
# own config. Today that is GH_TOKEN, which core sets from the read-only
# credential at [github] token_file - one every session gets regardless of
# which forge is selected - and which a forge exporting the same name takes over
# for the launch, since a single variable can only carry one token. Said out
# loud rather than left as two configured credentials with one silently
# ignored: an operator who set both is entitled to know which is in effect. A
# warning, not a refusal - the forge's value winning is exactly what
# selecting that forge asked for.
#
# Driven off the manifest's env_export rather than off the forge's name: the
# collision is a property of the variable a forge exports, identical for any
# forge that exports it, and core has no business knowing which forge that is
# today. Checked here, ahead of --print-config's own exit below (like
# _warn_legacy_env above it) rather than down by the GH_TOKEN block further
# down that --print-config never reaches, so --print-config reports it too.
#
# Both files, not just core's: a forge key resolving to nothing is the
# "wholly unconfigured" case forge_env_exports deliberately emits no pair for
# (see its own comment), leaving core's own value in effect - so claiming the
# forge's key won would be false in exactly that state. The warning only
# means something once both are actually set.
_warn_forge_env_collisions() {
local forge="$1" pair name ref core core_name core_section core_leaf
local core_file forge_file
for pair in $(manifest_get forge "$forge" env_export); do
name="${pair%%=*}"
ref="${pair#*=}"
# Only the "@" form (the CONTENTS of the file a config key names) can
# carry one of core's own credentials.
[ "${ref:0:1}" = "@" ] || continue
# NAME=<core section> <core key>, for every variable core fills from
# the config file itself.
for core in "GH_TOKEN=github token_file"; do
core_name="${core%%=*}"
[ "$name" = "$core_name" ] || continue
core_section="${core#*=}"
core_leaf="${core_section##* }"
core_section="${core_section%% *}"
core_file="$(config_get "$core_section" "$core_leaf")"
forge_file="$(config_get "forge.$forge" "${ref:1}")"
[ -f "$core_file" ] && [ -s "$core_file" ] || continue
[ -f "$forge_file" ] && [ -s "$forge_file" ] || continue
echo "Note: [$core_section] $core_leaf is not used while [core] forge = $forge." >&2
echo "$name comes from [forge.$forge] ${ref:1} for this launch." >&2
done
done
}
# Guarded on config_get succeeding: an invalid [core] forge already made
# config_get print the real error (via config_validate) and return empty,
# so feeding that empty value on to _warn_forge_env_collisions would only
# add a second, spurious "invalid forge name:" line on top of it.
if _core_forge="$(config_get core forge)"; then
_warn_forge_env_collisions "$_core_forge"
fi
# --print-config resolves the whole precedence chain and exits, before any
# workspace argument is required and before anything is built or mounted.
# It exists for two reasons: it answers "one config file now - what is
@@ -173,14 +229,8 @@ if [ "$PRINT_CONFIG" = 1 ]; then
fi
# The selected forge/workflow/harness combination, resolved and validated
# before anything is built or mounted. provider_validate_selection enforces
# rules 1, 2 and 6 as refusals and rule 5 (a partly-configured forge) as a
# warning - see its definition. Rule 3 ("a pushing workflow needs a forge
# that resolves to a host") is deliberately NOT enforced here: it is
# deferred to Phase 3, which adds `forge = none` as the honest way to say
# "no forge" - enforcing it now would refuse the supported "I don't use a
# forge" configuration that git-push-guard.sh already handles at runtime
# (see README "Skip this if you only push to GitHub").
# before anything is built or mounted - see provider_validate_selection's own
# definition for the full set of rules it enforces, all as refusals.
DEJIMA_FORGE_NAME="$(config_get core forge)"
DEJIMA_WORKFLOW_NAME="$(config_get core workflow)"
DEJIMA_HARNESS_NAME="$(config_get core harness)"
@@ -299,19 +349,50 @@ if [ "$PRINT_BRIEFING" = 1 ]; then
exit 0
fi
if ! DEJIMA_FORGE_HOSTS="$(forge_hosts "$DEJIMA_FORGE_NAME")"; then
if ! _forge_all_hosts="$(forge_hosts "$DEJIMA_FORGE_NAME")"; then
exit 1
fi
DEJIMA_WORKFLOW_PUSH="$(manifest_get workflow "$DEJIMA_WORKFLOW_NAME" push)"
# A forge declaring hosts_allowlisted_by_core says its hosts are ones core
# already allowlists in its own right - the GitHub ranges every session can
# read from over 443. Those must NOT reach DEJIMA_FORGE_HOSTS, which
# entrypoint.sh folds into EXTRA_ALLOWED_DOMAINS: that path resolves a name
# into the port-UNRESTRICTED allowed-domains ipset and exempts it from
# dnsmasq's stop-dns-rebind, both of which are deliberate for an internal,
# self-hosted forge host and both of which are a widening for a public one.
# It would also make init-firewall.sh's startup self-check ("port 22 was
# reachable without the push grant") fail, killing the container outright.
#
# They still reach DEJIMA_PUSH_HOSTS: git-push-guard.sh's allowlist is a
# separate question from which names the firewall resolves, and a push to a
# host the guard doesn't know is refused inside the container regardless of
# how the packets would have been routed.
ALLOW_CORE_PUSH_GRANT="$ALLOW_GITHUB_PUSH_FLAG"
if [ "$(manifest_get forge "$DEJIMA_FORGE_NAME" hosts_allowlisted_by_core)" = "on" ]; then
DEJIMA_FORGE_HOSTS=""
# And pushing to them therefore goes through core's own push grant
# (ALLOW_GITHUB_PUSH below), which opens port 22 to the same ranges the
# read-only path already allows on 443 - logged, self-checked, and the
# documented way that port opens. Only when the workflow actually pushes:
# a non-pushing workflow has no reason to open it.
if [ "$DEJIMA_WORKFLOW_PUSH" = "on" ]; then
ALLOW_CORE_PUSH_GRANT=1
fi
else
DEJIMA_FORGE_HOSTS="$_forge_all_hosts"
fi
if [ "$DEJIMA_WORKFLOW_PUSH" = "on" ]; then
DEJIMA_PUSH_HOSTS="$DEJIMA_FORGE_HOSTS"
DEJIMA_PUSH_HOSTS="$_forge_all_hosts"
else
DEJIMA_PUSH_HOSTS=""
fi
# --allow-github-push's actual effect: git-push-guard.sh only ever checks
# DEJIMA_PUSH_HOSTS, so granting it a github.com push means adding these
# hosts to that list, not just exporting ALLOW_GITHUB_PUSH (which nothing
# reads anymore now that the guard is forge/workflow-driven).
# reads anymore now that the guard is forge/workflow-driven). Keyed to the
# flag alone, not to ALLOW_CORE_PUSH_GRANT: a forge whose own hosts these
# are has already contributed them above, and adding them twice would leave
# the guard's list carrying each name two or three times over.
if [ "$ALLOW_GITHUB_PUSH_FLAG" = 1 ]; then
DEJIMA_PUSH_HOSTS="${DEJIMA_PUSH_HOSTS:+$DEJIMA_PUSH_HOSTS }github.com ssh.github.com"
fi
@@ -607,7 +688,7 @@ cleanup() {
# STATE is dropped from the rm -f above and freed only after compare has
# read it, so this function's own cleanup can't race the audit that
# needs it - and its removal is guarded too, so it stays truly last.
"$SCRIPT_DIR/dejima-audit" compare "$WORKSPACE_DIR" "$SANDBOX_AUDIT_STATE" || true
DEJIMA_RW_MOUNTS="$DEJIMA_RW_MOUNTS" "$SCRIPT_DIR/dejima-audit" compare "$WORKSPACE_DIR" "$SANDBOX_AUDIT_STATE" || true
rm -f "$SANDBOX_AUDIT_STATE" || true
}
trap cleanup EXIT
@@ -652,18 +733,19 @@ export SANDBOX_SSH_KEY_FILE="$SANDBOX_SSH_KEY"
export SSH_KNOWN_HOSTS="$(config_get core known_hosts)"
export GH_TOKEN="${GH_TOKEN:-}"
export ALLOWED_DOMAINS="${ALLOWED_DOMAINS:-}"
# --allow-github-push is the only way to grant this - same principle as
# ALLOWED_DOMAINS above: this opens a push path, so an exported value already
# sitting in the operator's shell profile must not be able to silently grant
# it, with nothing recording that it did.
if [ "$ALLOW_GITHUB_PUSH_FLAG" = 1 ]; then
# The --allow-github-push flag and a selected forge whose hosts_allowlisted_by_core
# says these are its own push hosts (see ALLOW_CORE_PUSH_GRANT above) are the
# only two ways to grant this - same principle as ALLOWED_DOMAINS above: this
# opens a push path, so an exported value already sitting in the operator's
# shell profile must not be able to silently grant it, with nothing recording
# that it did. Both ways in are recorded: one in the command that used the
# flag, the other in the config file that selected the forge.
if [ "$ALLOW_CORE_PUSH_GRANT" = 1 ]; then
export ALLOW_GITHUB_PUSH=1
else
# Plain assignment, not "${ALLOW_GITHUB_PUSH:-0}" - same reason
# ALLOWED_DOMAINS above is plain. This opens a push path, so an exported
# value in a shell profile must not be able to grant it silently; the
# --allow-github-push flag is the only way in, and it is recorded in the
# command that used it.
# value in a shell profile must not be able to grant it silently.
export ALLOW_GITHUB_PUSH=0
fi
export TZ="${TZ:-$(readlink -f /etc/localtime 2>/dev/null | sed 's#.*/zoneinfo/##')}"
@@ -810,7 +892,10 @@ fi
# DEJIMA_RW_MOUNTS is deliberately not forwarded into the container as an
# environment variable: hooks/sandbox-context.sh no longer reads it (the
# writable-mounts sentence reaches the session through the composed briefing
# instead), so passing it would just be a value nothing inside consumes.
# instead), so passing it would just be a value nothing inside consumes. It
# IS forwarded the other direction, host-side, to the exit-time dejima-audit
# invocation in cleanup() below - that report's header names these paths so
# an operator reading any finding knows what else the session could reach.
for path in ${RW_MOUNT_PATHS[@]+"${RW_MOUNT_PATHS[@]}"}; do
extra_mounts+=(-v "$path:$path")
done
@@ -1010,6 +1095,25 @@ if remote_enabled; then
DEJIMA_NOTIFY_DIR="$DEJIMA_STATE_DIR/notify/$DEJIMA_RUN_ID"
mkdir -p -m 700 "$DEJIMA_NOTIFY_DIR"
# Verify what was just created, because everything downstream assumes it.
# A leaf left behind root-owned by an earlier container restart (see
# entrypoint.sh) is the shape this catches: mkdir -p succeeds against an
# existing directory regardless of who owns it, so without this the mount
# goes ahead and the failure surfaces later as EACCES in a hook.
#
# A warning rather than a refusal. Remote access is opt-in, and the rest
# of the session works without it - blocking a launch over undeliverable
# notifications would cost more than it saves. The neighbouring state-dir
# check does refuse, but that one is about a symlink handing a session an
# attacker-chosen path; this one is only about the feature working.
if [ ! -O "$DEJIMA_NOTIFY_DIR" ] || [ ! -w "$DEJIMA_NOTIFY_DIR" ]; then
echo "warning: $DEJIMA_NOTIFY_DIR is not a directory this user owns and can write." >&2
echo "Remote access is enabled, so notifications and dejima-attach will not work for" >&2
echo "this session. A leftover root-owned directory here is usually Docker's doing," >&2
echo "from a container that was restarted rather than relaunched; removing it and" >&2
echo "starting again clears it." >&2
fi
# $TMUX_PANE is set by tmux in the launching shell. Empty when the
# sandbox wasn't started from tmux: the session is still registered, just
# listed as not-attachable, rather than silently omitted.
+64 -46
View File
@@ -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)."
+18
View File
@@ -1193,6 +1193,15 @@ case "$cmd" in
echo "Changes below happened while this session ran - not necessarily because"
echo "of it. A concurrent dejima/Claude Code session, or your own edits during"
echo "the run, land in this same diff; see docs/security.md."
# Named, never hashed. Writing to a writable mount is the entire
# point of configuring one, so a finding per write would fire on
# every legitimate use and train the reader to skim - the exact
# failure the audit's severity tiers exist to avoid. What the
# operator needs instead is the blast radius: reading any finding
# below, they should know what else this session could reach.
if [ -n "${DEJIMA_RW_MOUNTS:-}" ]; then
echo "also writable by this session, not hashed: $(sanitize "$DEJIMA_RW_MOUNTS")"
fi
[ "${#crit[@]}" -gt 0 ] && printf 'CRITICAL %s\n' "${crit[@]}"
[ "${#warn[@]}" -gt 0 ] && printf 'WARNING %s\n' "${warn[@]}"
[ "${#info[@]}" -gt 0 ] && printf 'INFO %s\n' "${info[@]}"
@@ -1256,6 +1265,15 @@ case "$cmd" in
echo " dejima: host-persistence surfaces changed in"
echo " $(sanitize "$ws") while this session ran"
echo "----------------------------------------------------------------"
# Named, never hashed. Writing to a writable mount is the entire
# point of configuring one, so a finding per write would fire on
# every legitimate use and train the reader to skim - the exact
# failure the audit's severity tiers exist to avoid. What the
# operator needs instead is the blast radius: reading any finding
# below, they should know what else this session could reach.
if [ -n "${DEJIMA_RW_MOUNTS:-}" ]; then
echo " also writable by this session, not hashed: $(sanitize "$DEJIMA_RW_MOUNTS")"
fi
if [ "${#info[@]}" -gt 0 ]; then
echo " ${#info[@]} routine file change(s) - not listed"
fi
+2 -2
View File
@@ -46,8 +46,8 @@
_config_table() {
cat <<'EOF'
core.harness provider:harness claude-code
core.forge provider:forge gitea
core.workflow provider:workflow worktree-pr
core.forge provider:forge none
core.workflow provider:workflow local-only
core.command identifier claude
core.firewall bool on
core.share_dir path ~/.local/share/dejima/shared
+68 -23
View File
@@ -5,12 +5,15 @@
# grant itself one - the same split bin/dejima-allow uses for
# domains.
#
# Usage: dejima-dep <add|remove|list> [<spec>] [--global | --project <path>]
# dejima-dep add cargo:cargo-nextest --project ~/myproject
# dejima-dep add cargo:cargo-nextest --global
# Usage: dejima-dep <add|remove|list> [--global | --project [path]] [<spec>]
# dejima-dep add --project ~/myproject cargo:cargo-nextest
# dejima-dep add --project cargo:cargo-nextest # the project in $PWD
# dejima-dep add --global cargo:cargo-nextest
# dejima-dep list
#
# A spec is <apt|npm|pip|cargo>:<name>. The dependencies actually installed
# A spec is <apt|npm|pip|cargo>:<name>, or tarball:<name>@<sha256>:<url> for a
# release archive no package ecosystem ships in a usable version (a current Go
# toolchain, say). The dependencies actually installed
# for a project are the union of its global and project approvals.
set -euo pipefail
@@ -23,31 +26,44 @@ spec=""
project=""
global=0
while [ "$#" -gt 0 ]; do
case "$1" in
--global) global=1; shift ;;
--project) project="$2"; shift 2 ;;
-*) echo "Unknown option: $1" >&2; exit 1 ;;
*)
if [ -z "$action" ]; then action="$1"
elif [ -z "$spec" ]; then spec="$1"
else echo "Unexpected argument: $1" >&2; exit 1
fi
shift
;;
esac
done
usage() {
echo "Usage: dejima-dep <add|remove|list> [<spec>] [--global | --project <path>]" >&2
echo "Usage: dejima-dep <add|remove|list> [--global | --project [path]] [<spec>]" >&2
exit 1
}
[ -n "$action" ] || usage
# The action is read first so the required trailing count is known before
# parsing the rest: add/remove need a spec after any --project path, list does
# not - and that count is exactly what decides whether a token after --project
# is the path or the spec.
case "${1:-}" in
add|remove) action="$1"; shift; required=1 ;;
list) action="$1"; shift; required=0 ;;
*) usage ;;
esac
parse_scope_args "$required" "$@" || usage
case "${#dj_positional[@]}" in
0) spec="" ;;
1) spec="${dj_positional[0]}" ;;
*) usage ;;
esac
global="$dj_global"
[ "$dj_project_set" -eq 0 ] || project="${dj_project:-$PWD}"
# The shared parser knows --container because dejima-allow needs it. Before
# that parser existed this CLI answered "Unknown option" here, and silently
# accepting the flag now would be worse than the duplication it replaced: a
# caller who believes they scoped an approval to one session would get project
# scope - a persistent grant - and a zero exit telling them they were right.
if [ -n "$dj_container" ]; then
echo "dejima-dep has no --container scope: a dependency needs an image" >&2
echo "rebuild, so there is nothing to patch in a live session." >&2
exit 1
fi
# An entry belongs to exactly one scope; silently picking one would misplace
# an approval.
if [ "$global" -eq 1 ] && [ -n "$project" ]; then
if [ "$global" -eq 1 ] && [ "$dj_project_set" -eq 1 ]; then
echo "--global and --project are mutually exclusive." >&2
exit 1
fi
@@ -56,6 +72,7 @@ fi
# put a misleading $PWD-derived path in error messages.
if [ "$global" -eq 0 ]; then
project="$(cd "${project:-$PWD}" && pwd)"
disclose_project_symlink "$project"
fi
case "$action" in
@@ -70,7 +87,7 @@ case "$action" in
;;
add)
[ -n "$spec" ] || usage
valid_dep_spec "$spec" || { echo "Not a valid dependency spec: $spec" >&2; echo "Expected <apt|npm|pip|cargo>:<name>" >&2; exit 1; }
valid_dep_spec "$spec" || { echo "Not a valid dependency spec: $spec" >&2; echo "Expected <apt|npm|pip|cargo>:<name>, or tarball:<name>@<sha256>:<url>" >&2; exit 1; }
if [ "$global" -eq 1 ]; then
store="$(ensure_global_store)"
approve_entry "$store" dependencies "$spec"
@@ -79,6 +96,18 @@ case "$action" in
store="$(ensure_project_store "$project")"
approve_entry "$store" dependencies "$spec"
echo "Approved $spec for $project."
# Printed only after the approval above, and guarded rather than
# left to set -e: the approval is what grants, and it is already
# written by this point. Letting a declare_entry failure abort
# here would exit before this echo ever ran, leaving the operator
# thinking nothing happened when a spec had, in fact, just been
# approved.
if declare_entry "$project" dependencies "$spec"; then
echo "Declared it in $project/.dejima/dependencies - commit that to share it."
else
echo "The approval above IS recorded; only the project's own .dejima" >&2
echo "declaration could not be written." >&2
fi
fi
# Unlike dejima-allow, this cannot touch a running session:
# it needs an image rebuild. Say so, or an agent will keep insisting
@@ -101,6 +130,22 @@ case "$action" in
[ -n "$store" ] || { echo "No approved config for $project." >&2; exit 1; }
unapprove_entry "$store" dependencies "$spec"
echo "Removed $spec from $project (takes effect on the next launch)."
# `add --project` writes a declaration; this deliberately does not
# take one back, since withdrawing one machine's approval is not
# the same decision as editing a file the whole team shares. That
# asymmetry used to be invisible and self-undoing: the entry goes
# back to `pending`, the session briefing reports it missing, and
# the next `dejima-project approve` re-approves it - so say where
# the durable copy is rather than letting the removal look final.
# -f before the grep, not a bare grep: this reads a path the
# sandboxed session owns, and a FIFO planted there would hang the
# command outright.
if [ -f "$project/.dejima/dependencies" ] &&
grep -qxF "$spec" "$project/.dejima/dependencies" 2>/dev/null; then
echo "$project/.dejima/dependencies still declares it - this command does"
echo "not edit the project's committed declaration, so dejima-project"
echo "approve will offer it again. Edit that file to drop it for good."
fi
fi
;;
*) usage ;;
+124 -21
View File
@@ -20,17 +20,39 @@ project="$(cd "$project" && pwd)"
decl_dir="$project/.dejima"
store="$(resolve_project_store "$project")"
approved_of() {
local kind="$1"
approved_entries "$(config_root)" "$kind" || return 1
# Approved entries for one kind, tagged with the store they came from, so
# status can say where a grant actually lives rather than merging both into an
# anonymous list.
#
# approved_entries's exit status is captured from a plain command
# substitution, not from a `while read < <(...)` piped straight into the tag
# loop - a corrupt store that makes approved_entries fail before printing
# anything leaves that pattern's while loop with zero iterations, and a while
# loop that never ran its body reports success regardless of what the command
# feeding it did, which would turn a corrupt-store failure into a silent
# "nothing approved" here.
approved_tagged() {
local kind="$1" entries e
entries="$(approved_entries "$(config_root)" "$kind")" || return 1
while IFS= read -r e; do
[ -n "$e" ] || continue
printf 'global %s\n' "$e"
done <<< "$entries"
[ -n "$store" ] || return 0
approved_entries "$store" "$kind"
entries="$(approved_entries "$store" "$kind")" || return 1
while IFS= read -r e; do
[ -n "$e" ] || continue
printf 'project %s\n' "$e"
done <<< "$entries"
return 0
}
# Prints "<state> <entry>" per declared entry: approved / pending / rejected.
# Used by `approve`, which acts on pending entries only and has no use for the
# approved-but-undeclared ones `report` (below) exists to surface.
classify() {
local kind="$1" validator="$2" approved state entry
approved="$(approved_of "$kind")" || return 1
approved="$(approved_tagged "$kind" | cut -d' ' -f2-)" || return 1
while read -r state entry; do
case "$state" in
rejected) printf 'rejected %s\n' "$entry" ;;
@@ -47,16 +69,62 @@ classify() {
return 0
}
# The status view: the union of what the project declares and what is
# actually approved. Declarations alone were the bug (issue #61) - an
# approval recorded with `dejima-allow --project` has no declaration behind
# it and was therefore invisible here, which reads as "the command did
# nothing".
#
# Entries are already validated (read_declared/approved_entries refuse
# anything else), so the plain string comparisons below are enough - no
# entry can contain a character that would make one domain/spec look like
# another.
report() {
local kind="$1" validator="$2" approved state entry src tag e seen=""
approved="$(approved_tagged "$kind")" || return 1
while read -r state entry; do
case "$state" in
rejected) printf ' rejected %s\n' "$entry" ;;
truncated) printf ' truncated %s entries declared; only the first %s considered\n' \
"$entry" "$DEJIMA_MAX_DECLARED" ;;
ok)
src=""
while read -r tag e; do
[ "$e" = "$entry" ] || continue
src="$tag"
break
done <<< "$approved"
if [ -n "$src" ]; then
printf ' approved %s (%s)\n' "$entry" "$src"
seen="$seen$entry"$'\n'
else
printf ' pending %s (declared, not approved)\n' "$entry"
fi
;;
esac
done < <(read_declared "$decl_dir/$kind" "$validator")
# Approvals with no declaration behind them - the previously invisible case.
# seen accumulates here too, or an entry approved in both stores (global
# and project) and undeclared in either would print once per store.
while read -r tag e; do
[ -n "$e" ] || continue
grep -qxF "$e" <<< "$seen" && continue
printf ' approved %s (%s, not declared)\n' "$e" "$tag"
seen="$seen$e"$'\n'
done <<< "$approved"
return 0
}
case "$action" in
status)
echo "Project: $project"
echo "Approvals: ${store:-<none yet>}"
echo "Project approvals: ${store:-<none yet>}"
echo "Global approvals: $(config_root)"
echo
echo "dependencies:"
classify dependencies valid_dep_spec | sed 's/^/ /'
report dependencies valid_dep_spec
echo "allowed-domains:"
classify allowed-domains valid_domain | sed 's/^/ /'
report allowed-domains valid_domain
;;
approve)
pending_deps=$(classify dependencies valid_dep_spec | sed -n 's/^pending //p')
@@ -89,23 +157,58 @@ case "$action" in
y|Y) ;;
*) echo "Nothing approved."; exit 0 ;;
esac
# Both loops collect instead of letting set -e abort. Neither CLI is
# called with --container, so neither touches a running container and
# there is no live patch to succeed or fail here - but each can still
# fail on its own (an unwritable store, a slug collision, a corrupt
# approval list), and a bare call would abort the whole script on the
# first failure, silently skipping every remaining entry - the deps
# after it AND all the domains - with no summary at all. A failed
# declaration is deliberately NOT in that list: both CLIs swallow one
# and still exit 0, because the approval is what grants and it is
# already written by then. What a failure MEANS is the
# other half: the approval itself did not happen, so neither report
# below may repeat the old "approved, but not applied to a running
# session" line, which would call an entry approved when nothing was
# recorded.
dep_failed=""
dep_approved=0
for spec in $pending_deps; do
"$SCRIPT_DIR/dejima-dep" add "$spec" --project "$project"
if "$SCRIPT_DIR/dejima-dep" add --project "$project" "$spec"; then
dep_approved=1
else
dep_failed="$dep_failed $spec"
fi
done
# dejima-allow records the approval before it touches a
# running session, so a nonzero exit here means only that the live
# patch or its reachability probe didn't succeed - a declared host
# that doesn't serve HTTPS on 443, say. Under set -e that used to
# abort the loop and silently skip every remaining domain, so collect
# and report instead.
unapplied=""
failed=""
dom_approved=0
for dom in $pending_doms; do
"$SCRIPT_DIR/dejima-allow" "$dom" --project "$project" || unapplied="$unapplied $dom"
if "$SCRIPT_DIR/dejima-allow" --project "$project" "$dom"; then
dom_approved=1
else
failed="$failed $dom"
fi
done
if [ -n "$unapplied" ]; then
echo
echo "Approved for $project, but not applied to a running session:$unapplied" >&2
echo "They take effect at the next launch either way." >&2
echo
if [ -n "$dep_failed" ]; then
echo "NOT approved for $project (dejima-dep failed):$dep_failed" >&2
echo "Fix the cause and re-run; nothing was recorded for those." >&2
fi
if [ -n "$failed" ]; then
echo "NOT approved for $project (dejima-allow failed):$failed" >&2
echo "Fix the cause and re-run; nothing was recorded for those." >&2
fi
# Only when something actually landed: a run that failed every entry
# must not follow "NOT approved" with a line that reads as success.
if [ "$dep_approved" -eq 1 ] || [ "$dom_approved" -eq 1 ]; then
echo "Restart the sandbox for the approved entries to take effect."
fi
# `dejima-project approve ~/p && dejima` is the obvious way to chain
# these, and exiting 0 after recording nothing would walk that chain
# straight into a session with none of the approvals the user just
# confirmed.
if [ -n "$dep_failed" ] || [ -n "$failed" ]; then
exit 1
fi
;;
*)
+307 -6
View File
@@ -9,7 +9,10 @@
# compromised session can write these. They grant nothing; they are read
# only to compare against reality and suggest a command for the user to
# run. Parsed, never sourced - executing a file on a session-writable
# mount is a host-shell persistence path.
# mount is a host-shell persistence path. declare_entry also WRITES one,
# which is why it refuses to follow a symlink: a host-side write onto a
# session-writable path is the one direction this split does not otherwise
# cover.
#
# ~/.config/dejima/{allowed-domains,dependencies} - a GLOBAL
# APPROVAL, applying to every project.
@@ -35,7 +38,49 @@ DEJIMA_MAX_DECLARED=200
# generated RUN line as an option instead, quietly weakening apt/pip/npm/
# cargo's own signature/lockfile checks.
valid_dep_spec() {
[[ "$1" =~ ^(apt|npm|pip|cargo):[A-Za-z0-9][A-Za-z0-9._+-]*$ ]]
[[ "$1" =~ ^(apt|npm|pip|cargo):[A-Za-z0-9][A-Za-z0-9._+-]*$ ]] && return 0
valid_tarball_spec "$1"
}
# tarball:<name>@<sha256>:<url> - a release archive unpacked into the image,
# for a toolchain no package ecosystem ships in a usable version.
#
# The digest is mandatory and is what makes this an approval at all. Without
# it the operator would be approving a *name* whose contents the other end can
# change afterwards, which is not a decision anyone can make once; with it,
# what was approved is a specific archive and nothing else can take its place.
# The generated build verifies it before unpacking, not after.
#
# The character classes are narrower than the URL grammar allows, for the same
# reason as above: this string is interpolated into a Dockerfile RUN line that
# runs as root. Everything shell-active is excluded rather than quoted around
# - no $, backtick, quote, backslash, whitespace, or ;|&<>() - so there is no
# quoting to get wrong later. https only: the digest already provides
# integrity, but there is no reason to also accept a plaintext fetch.
DEJIMA_MAX_TARBALL_URL=512
valid_tarball_spec() {
local spec="$1" rest name sha url
[[ "$spec" == tarball:* ]] || return 1
rest="${spec#tarball:}"
name="${rest%%@*}"
[[ "$name" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]] || return 1
# Becomes a directory under /opt/dejima; a name that walks out of it, or
# that is a relative-path token, is not a name.
case "$name" in .|..) return 1 ;; esac
rest="${rest#"$name"}"
[[ "$rest" == @* ]] || return 1
rest="${rest#@}"
sha="${rest%%:*}"
[[ "$sha" =~ ^[a-f0-9]{64}$ ]] || return 1
url="${rest#"$sha"}"
[[ "$url" == :* ]] || return 1
url="${url#:}"
[ ${#url} -le "$DEJIMA_MAX_TARBALL_URL" ] || return 1
[[ "$url" =~ ^https://[A-Za-z0-9._~/?=+%-]+$ ]] || return 1
}
# read_declared <file> <validator-fn>
@@ -67,6 +112,71 @@ read_declared() {
return 0
}
# Shared argument parsing for dejima-allow and dejima-dep, so the two CLIs
# cannot drift on what --project means.
#
# The hard part is that --project's path is OPTIONAL, which makes
# `--project X` ambiguous on its own: X could be the path, or the required
# positional. Deciding by "is X the last token" is wrong for `dejima-dep list
# --project ~/p`, which has no trailing positional to protect and would
# silently resolve to the current directory instead - the scope quietly
# becoming one the caller did not name. So the decision is made against how
# many positionals the command actually requires after the flag:
#
# --project takes the next token as its path only if doing so still leaves
# the command its required trailing positional(s).
#
# Required counts are per-command constants declared by the caller, not
# inferred here.
#
# Sets dj_global, dj_project, dj_project_set, dj_container and the
# dj_positional array. dj_project_set exists because "--project with no path"
# (the current directory) and "no --project at all" are different scopes and
# an empty dj_project cannot tell them apart.
parse_scope_args() {
local required="$1"; shift
local -a args=("$@")
local n="${#args[@]}" i=0 k tok cand after
dj_global=0; dj_project=""; dj_project_set=0; dj_container=""
dj_positional=()
while [ "$i" -lt "$n" ]; do
tok="${args[$i]}"
case "$tok" in
--global) dj_global=1; i=$((i + 1)) ;;
--container)
cand="${args[$((i + 1))]:-}"
if [ -z "$cand" ]; then
echo "--container needs a container name or id." >&2
return 1
fi
dj_container="$cand"; i=$((i + 2)) ;;
--project)
dj_project_set=1; i=$((i + 1))
cand="${args[$i]:-}"
case "$cand" in ''|-*) continue ;; esac
# Count the positionals strictly after the candidate, skipping
# --container's value - counting that as a positional would
# make the candidate look safe to consume when it is not.
after=0; k=$((i + 1))
while [ "$k" -lt "$n" ]; do
case "${args[$k]}" in
--container) k=$((k + 2)) ;;
-*) k=$((k + 1)) ;;
*) after=$((after + 1)); k=$((k + 1)) ;;
esac
done
if [ "$after" -ge "$required" ]; then
dj_project="$cand"; i=$((i + 1))
fi ;;
-*) echo "Unknown option: $tok" >&2; return 1 ;;
*) dj_positional+=("$tok"); i=$((i + 1)) ;;
esac
done
return 0
}
# The global store is config_root() itself, not a subdirectory of it - the
# two filenames below can never collide with projects/<slug>, since a slug
# is a sanitized absolute path and always begins with "-".
@@ -83,6 +193,32 @@ project_slug() {
printf '%s' "$1" | sed 's/[^a-zA-Z0-9-]/-/g'
}
# $project is `cd <path> && pwd`'s LOGICAL resolution: a symlinked path
# component's name survives in the string. declare_entry's makedirs/open, and
# every other plain filesystem call, resolve the same path PHYSICALLY - so
# when a component is a symlink, "$project/.dejima" and the directory that
# actually gets created or opened are not the same place.
#
# A legitimate symlinked checkout (a worktree, a bind mount, a symlinked home
# directory) looks exactly like this too, and this function cannot tell the
# two apart - only the operator running the command can, so it discloses the
# mismatch rather than refusing on it. Refusing would break the ordinary case
# outright, with no way forward.
#
# Deliberately not used to change how $project itself is resolved: doing the
# approval lookup/write physically as well would move the *approval* to
# wherever the symlink targets, turning a declaration-only redirect into an
# actual cross-project grant - strictly worse than the mismatch this discloses.
disclose_project_symlink() {
local logical="$1" physical
physical="$(cd -P "$logical" && pwd -P)"
if [ "$physical" != "$logical" ]; then
echo "Note: $logical is reached through a symlink; it resolves to" >&2
echo "$physical, and that is where its .dejima declaration actually" >&2
echo "lives - not $logical." >&2
fi
}
# The store a writer must use: whatever a reader would resolve to, created
# for this exact path only when nothing above already covers it. Creating one
# per path unconditionally would be wrong in the workflow inheritance exists
@@ -116,6 +252,8 @@ ensure_project_store() {
if [ "$owner" != "$path" ]; then
echo "Note: recorded under the existing approvals for $owner, which" >&2
echo "$path inherits - so it applies to $owner and everything under it." >&2
echo "Any declaration this call writes still goes to $path/.dejima -" >&2
echo "the approval and the declaration do not have to share a directory." >&2
fi
printf '%s' "$existing"
return 0
@@ -184,6 +322,132 @@ approve_entry() {
grep -qxF "$entry" "$store/$kind" || printf '%s\n' "$entry" >> "$store/$kind"
}
# Record an entry in the project's OWN declaration - the committed, shared
# .dejima/<kind> file. This grants nothing (see this file's header: a
# declaration is parsed, never sourced, and lives on a session-writable
# mount), which is exactly why it is safe to write from the host. What it buys
# is visibility: without it, a domain approved with --project is invisible to
# dejima-project status, to hooks/sandbox-context.sh, and to everyone else on
# the project.
#
# Validated with the same dispatch approve_entry uses, so the two can never
# disagree about what a well-formed entry is.
declare_entry() {
local project="$1" kind="$2" entry="$3" validator
validator="$(_approved_validator "$kind")" || return 1
"$validator" "$entry" || { echo "Invalid $kind entry: $entry" >&2; return 1; }
# This is the one write in this file that lands on the session-writable
# bind mount, so it is the one that must not follow a symlink. A
# compromised session can plant one and then ask the user - through the
# startup briefing, which tells it to hand over exactly this command - to
# run the CLI that trips it. Following it would aim a host-privileged
# append anywhere the user can write: at ~/.config/dejima/allowed-domains,
# silently turning a project approval into a global one; at
# ~/.config/dejima/dependencies, where the entry is installed as root at
# image build, on the one code path with no egress firewall in front of it;
# or at any host file at all.
#
# A test would not be enough. The briefing tells the agent to have the user
# run `dejima-allow --project` from a terminal outside the sandbox, so the
# session is normally still LIVE while this runs - and since it is the one
# asking for the command, it picks the moment and can spin a rename loop
# across any window between a check and the write. So there is no check:
# the guard is the open itself. O_NOFOLLOW makes a symlink at either path
# fail the open outright, the directory is opened O_DIRECTORY and the file
# is opened relative to that fd, so neither can be swapped for the other's
# sake afterwards, and the whole read-then-append happens through that one
# fd. A session that wins the swap gets an open of the object it replaced,
# not a redirected write.
#
# bash cannot open a path that way, so this is python3 - a hard dependency
# already (install.sh refuses to install without it). O_NONBLOCK and the
# S_ISREG check cover the non-symlink shapes: a FIFO planted here would
# otherwise block a host CLI the user was told to run for as long as the
# session declines to open the other end.
python3 - "$project/.dejima" "$kind" "$entry" "$DEJIMA_MAX_DECLARED" <<'PY'
import errno, os, stat, sys
directory, name, entry = sys.argv[1], sys.argv[2], sys.argv[3]
MAX_DECLARED = int(sys.argv[4])
# A ceiling on the read-back below, not on what a project may declare. The
# largest legitimate file is MAX_DECLARED entries of a domain's 253 bytes plus
# comments - tens of KiB - so this is orders of magnitude clear of anything
# real, and exists only to stop a session-owned file from being unbounded.
LIMIT = 1 << 20
path = os.path.join(directory, name)
why = (
"A project's .dejima is writable by the sandboxed session, so a link or",
"special file planted here would aim this host-side append at the approval",
"store, or at any other file you can write.",
"Inspect and remove it, then re-run.",
)
def refuse(*lines):
for line in lines:
sys.stderr.write(line + "\n")
raise SystemExit(1)
try:
os.makedirs(directory, 0o755)
except FileExistsError:
pass
except OSError as exc:
refuse("Could not create %s: %s" % (directory, exc.strerror))
try:
dirfd = os.open(directory, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
except OSError as exc:
if exc.errno in (errno.ELOOP, errno.ENOTDIR):
refuse("Refusing to write through %s: it is a symlink, or not a directory at all." % directory, *why)
refuse("Could not open %s: %s" % (directory, exc.strerror))
try:
fd = os.open(
name,
os.O_RDWR | os.O_CREAT | os.O_APPEND | os.O_NOFOLLOW | os.O_NONBLOCK,
0o644,
dir_fd=dirfd,
)
except OSError as exc:
if exc.errno in (errno.ELOOP, errno.ENXIO):
refuse("Refusing to write through %s: it is a symlink or other special file." % path, *why)
refuse("Could not write %s: %s" % (path, exc.strerror))
finally:
os.close(dirfd)
# Before any buffered wrapper touches it: a FIFO is opened without blocking
# thanks to O_NONBLOCK, but it is not seekable, and refusing it is the point.
if not stat.S_ISREG(os.fstat(fd).st_mode):
os.close(fd)
refuse("Refusing to write to %s: it is not a regular file." % path, *why)
with os.fdopen(fd, "r+b") as fh:
# Bounded, because the duplicate check has to read back a file the session
# owns, and `truncate -s 8G` makes an arbitrarily large one at zero disk
# cost. Reading LIMIT + 1 rather than trusting fstat's size: the file can
# grow between the two, and this way the ceiling holds whatever it does.
body = fh.read(LIMIT + 1)
if len(body) > LIMIT:
refuse(
"Refusing to read %s back: it is larger than %d bytes." % (path, LIMIT),
"A project's .dejima is writable by the sandboxed session, and this",
"command has to read the file back to skip an entry already declared -",
"so an oversized one exhausts this machine's memory instead of",
"misdirecting the write. A real declaration is a few hundred short",
"lines; read_declared stops reading entries at %d." % MAX_DECLARED,
"Inspect and truncate it, then re-run.",
)
line = entry.encode()
if line not in body.split(b"\n"):
# O_APPEND, so this lands at the end whatever else has written since.
# The leading newline is for a declaration the session left unterminated
# - without it the append would extend that last line instead.
fh.write((b"" if not body or body.endswith(b"\n") else b"\n") + line + b"\n")
PY
}
unapprove_entry() {
local store="$1" kind="$2" entry="$3" tmp
[ -f "$store/$kind" ] || return 0
@@ -192,6 +456,40 @@ unapprove_entry() {
mv "$tmp" "$store/$kind"
}
# One RUN per tarball dependency, as root, before the drop to the sandbox
# user. Each unpacks into its own directory under /opt/dejima and puts that
# directory's bin/ on PATH.
#
# --strip-components=1 is what makes a single convention cover the real cases:
# a Go archive has everything under go/, an nvim release under
# nvim-linux-<arch>/, and stripping one level puts bin/ in the same place for
# both. So the spec needs no destination or layout fields, and there is
# nothing per-dependency for an operator to get wrong.
#
# The digest is checked BEFORE unpacking. A tar that is extracted first and
# verified after has already written whatever it contained.
#
# -xaf rather than -xzf: compression is detected from the archive, so xz and
# zstd releases work without the spec having to say which it is.
_emit_tarball_layers() {
local spec rest name sha url
for spec in "$@"; do
[ -n "$spec" ] || continue
rest="${spec#tarball:}"
name="${rest%%@*}"
rest="${rest#"$name"@}"
sha="${rest%%:*}"
url="${rest#"$sha":}"
printf 'RUN mkdir -p /opt/dejima/%s && \\\n' "$name"
printf ' wget --tries=3 --timeout=30 --waitretry=5 --retry-connrefused \\\n'
printf ' -O /tmp/%s.tar "%s" && \\\n' "$name" "$url"
printf ' echo "%s /tmp/%s.tar" | sha256sum -c - && \\\n' "$sha" "$name"
printf ' tar -C /opt/dejima/%s --strip-components=1 -xaf /tmp/%s.tar && \\\n' "$name" "$name"
printf ' rm /tmp/%s.tar\n' "$name"
printf 'ENV PATH=$PATH:/opt/dejima/%s/bin\n' "$name"
done
}
# Prints a Dockerfile that layers approved packages onto the shared base
# image. Only validated tokens are interpolated (approved_entries refuses
# anything else), so a RUN line here can't become a root-context injection.
@@ -202,6 +500,7 @@ unapprove_entry() {
# init-firewall.sh locks egress down.
generate_derived_dockerfile() {
local base="$1" store="$2" entries apt npm pip cargo spec name
local -a tarballs=()
entries="$(approved_entries "$store" dependencies)" \
|| { echo "refusing to build from a corrupt dependency list in $store" >&2; return 1; }
@@ -210,10 +509,11 @@ generate_derived_dockerfile() {
[ -n "$spec" ] || continue
name="${spec#*:}"
case "$spec" in
apt:*) apt="$apt $name" ;;
npm:*) npm="$npm $name" ;;
pip:*) pip="$pip $name" ;;
cargo:*) cargo="$cargo $name" ;;
apt:*) apt="$apt $name" ;;
npm:*) npm="$npm $name" ;;
pip:*) pip="$pip $name" ;;
cargo:*) cargo="$cargo $name" ;;
tarball:*) tarballs+=("$spec") ;;
esac
done <<< "$entries"
@@ -221,6 +521,7 @@ generate_derived_dockerfile() {
printf 'USER root\n'
[ -n "$apt" ] && printf 'RUN apt-get update && apt-get install -y --no-install-recommends%s \\\n && apt-get clean && rm -rf /var/lib/apt/lists/*\n' "$apt"
[ -n "$pip" ] && printf 'RUN pip install --break-system-packages%s\n' "$pip"
_emit_tarball_layers "${tarballs[@]+"${tarballs[@]}"}"
printf 'USER ${SANDBOX_USER}\n'
[ -n "$npm" ] && printf 'RUN npm install -g%s\n' "$npm"
if [ -n "$cargo" ]; then
+158 -30
View File
@@ -107,6 +107,13 @@ provider_list() {
# default. Types are the config file's own, so one validator set covers both.
_manifest_table() {
case "$1" in
# hosts_allowlisted_by_core (below) means "core already allowlists
# these hosts in its own right" - today that is true only of
# GitHub's published ranges (see bin/dejima's ALLOW_GITHUB_PUSH
# wiring). A forge whose hosts are not among those must leave it
# off: turning it on grants no firewall allowance at all (its hosts
# never reach DEJIMA_FORGE_HOSTS) plus an irrelevant GitHub push
# grant instead.
forge) cat <<'EOF'
capabilities ?list:identifier
pr_command ?command
@@ -117,6 +124,7 @@ requires ?list:identifier
host_keys ?list:identifier
url_keys ?list:identifier
fixed_hosts ?list:hostname
hosts_allowlisted_by_core bool off
install ?list:relpath
env_export ?list:export
EOF
@@ -474,38 +482,107 @@ provider_validate_selection() {
fi
done
# Rule 3 ("a pushing workflow needs a forge that resolves to a host") is
# deliberately NOT checked here. A forge's own host-bearing keys may have
# no schema default and install.sh writes them commented out, so a fresh
# install would be refused by this rule today - while it launches fine
# as-is, with git-push-guard.sh blocking pushes at runtime (see README
# "Skip this if you only push to GitHub"). An unconfigured forge is
# supported, not broken, until Phase 3 adds `forge = none` as the honest
# way to say so - only then does this rule have a correct alternative to
# point an operator at, and it becomes a refusal alongside rule 5 below.
# Autonomy names where the agent stops without asking, so it is a claim
# about what the selected forge can actually do. Checked here rather than
# left to the briefing: a mismatch discovered by the agent mid-task is a
# confusing failure, and this one is knowable at launch.
local autonomy push
autonomy="$(manifest_get workflow "$workflow" autonomy)" || return 1
push="$(manifest_get workflow "$workflow" push)" || return 1
case "$autonomy" in
open-pr)
if ! _set_contains "$caps" pr; then
echo "workflow '$workflow' sets autonomy = open-pr, which needs a forge" >&2
echo "offering the 'pr' capability - '$forge' offers: ${caps:-none}." >&2
return 1
fi
;;
push-branch)
if [ "$push" != "on" ]; then
echo "workflow '$workflow' sets autonomy = push-branch but push = off." >&2
echo "It cannot stop at a push it never makes." >&2
return 1
fi
;;
esac
# config_is_set, not config_get: the question is "did the operator set
# this key", not "does it have a value" - a key can have a schema
# default, so it always has a value, and testing the value would make a
# wholly unconfigured forge (nothing in [forge.<name>] at all) look
# partly configured.
# Rule 3: a workflow that pushes needs a forge that resolves to somewhere
# to push to. Deferred through Phase 2 because refusing an unconfigured
# forge had no correct alternative to offer - `forge = none` now exists,
# so an operator who genuinely pushes nowhere has an honest way to say it
# and this stops being a refusal with no remedy.
if [ "$push" = "on" ] && [ -z "$(forge_hosts "$forge")" ]; then
if [ -z "$(manifest_get forge "$forge" keys)" ]; then
# A forge with no keys at all has nothing to configure - "set
# [forge.$forge]" would be a fix that does not exist, so the only
# honest remedies are another forge or another workflow.
echo "workflow '$workflow' pushes, but forge '$forge' has no host to push" >&2
echo "to and nothing to configure. Select a forge that has one ([core]" >&2
echo "forge = github needs no host configured), or a workflow that does" >&2
echo "not push (workflow = local-only)." >&2
else
echo "workflow '$workflow' pushes, but forge '$forge' resolves to no host." >&2
echo "Configure [forge.$forge], or [core] forge = github to push to GitHub" >&2
echo "instead, or [core] forge = none and a workflow that does not push" >&2
echo "(workflow = local-only) if this sandbox pushes nowhere." >&2
fi
return 1
fi
# Set AND non-empty. config_is_set alone answers "did the operator write
# this key", which is not the same question: install.sh writes every key
# commented out at its default, so uncommenting one and not filling it in
# yields `api =` - written, and worth nothing. Every call the forge makes
# then fails inside the container, which is precisely what these two
# rules exist to catch at launch instead.
#
# Testing the value is only safe because of the config_default skip
# immediately below: a key with a working schema default always resolves
# to something, so it is excluded before the emptiness test can read a
# wholly unconfigured forge (nothing in [forge.<name>] at all) as partly
# configured. A key with no default resolves to empty exactly when it is
# unset or blank, which are the same thing to everything downstream.
set_n=0 unset_n=0 missing=""
for key in $(manifest_get forge "$forge" requires); do
if config_is_set "forge.$forge" "$key"; then
[ -z "$(config_default "forge.$forge.$key")" ] || continue
if config_is_set "forge.$forge" "$key" && [ -n "$(config_get "forge.$forge" "$key")" ]; then
set_n=$((set_n + 1))
else
unset_n=$((unset_n + 1))
missing="${missing:+$missing }$key"
fi
done
# A warning, not a refusal (rule 5), and only when the forge is PARTLY
# configured. Wholly unconfigured is the same supported "I don't use
# this forge" case rule 3 above defers to Phase 3 for, and must stay
# silent; a partial configuration is almost certainly a typo, and saying
# so costs nothing. Becomes a refusal in Phase 3 alongside rule 3.
if [ "$set_n" -gt 0 ] && [ "$unset_n" -gt 0 ]; then
echo "Warning: forge '$forge' is partly configured - [forge.$forge] is" >&2
echo "missing: $missing. Pushes and PRs will fail until it is set." >&2
# Rule 5: a partly configured forge is a typo, not a choice. Refused rather
# than warned now that `forge = none` exists: previously the only way to
# say "I don't use a forge" was to leave one half-filled, so refusing would
# have broken a supported state. Still scoped to PARTIAL configuration -
# nothing set at all is rule 3's case above, and carries its own message -
# and, like rule 3, only when the workflow actually pushes: a non-pushing
# workflow never invokes the forge at all, so a partially filled-in forge
# section (e.g. only a host key, set so the firewall allows fetches) is
# untidy rather than broken - not a typo worth refusing.
if [ "$push" = "on" ] && [ "$set_n" -gt 0 ] && [ "$unset_n" -gt 0 ]; then
echo "forge '$forge' is partly configured - [forge.$forge] is missing:" >&2
echo "$missing. Set them, or set [core] forge = none if this sandbox" >&2
echo "pushes nowhere." >&2
return 1
fi
# Rule 3 only catches a wholly unconfigured forge by way of forge_hosts
# resolving empty - but a forge like github pins fixed_hosts independent
# of any config (the same three hosts for every operator), so forge_hosts
# is never empty regardless of whether push_token_file is set. A forge
# with exactly one `requires` key can also never be "partly" configured
# (rule 5 above): it is either wholly set or wholly unset, so a wholly
# unset single-key forge slips past both rules unless this closes it.
# Scoped the same way rule 3 is - only when the workflow actually pushes,
# and never for a forge with no `requires` at all.
if [ "$push" = "on" ] && [ "$set_n" -eq 0 ] && [ "$unset_n" -gt 0 ]; then
echo "workflow '$workflow' pushes, but forge '$forge' has none of its" >&2
echo "required settings configured - [forge.$forge] is missing: $missing." >&2
echo "Set them, or set [core] forge = none and a workflow that does not" >&2
echo "push (workflow = local-only) if this sandbox pushes nowhere." >&2
return 1
fi
return 0
}
@@ -558,6 +635,18 @@ forge_env_exports() {
else
value="$(config_get "forge.$forge" "$ref")" || return 1
fi
# An unresolved (empty) value is skipped rather than emitted as
# `NAME=`. Harmless for a forge whose variables docker-compose.yml
# never declares in its own `environment:` section - an absent -e
# flag and an empty one are the same "not set" to a container either
# way, and every such variable's consumer already treats unset and
# empty identically. Not harmless for GH_TOKEN specifically: compose
# DOES declare it there, carrying an independent read-only value
# every session gets regardless of which forge is selected. `-e
# GH_TOKEN=` on `docker compose run` overrides that entry - emitting
# it here whenever a forge's own env_export resolves it empty would
# silently replace a configured read-only token with nothing.
[ -n "$value" ] || continue
printf '%s=%s\n' "$name" "$value"
done
}
@@ -588,7 +677,7 @@ BRIEFING_FRAGMENT_MAX=16384
# is a hard refusal (_briefing_substitute below), not a literal passthrough -
# an unrecognized {{key}} reaching an agent's context verbatim is exactly the
# kind of silent drift this whole function exists to prevent.
_BRIEFING_WHITELIST="pr_command issue_command forge_name forge_ssh_host blocked_branches container_id project_dir workspace_dir session_id resume_command writable_mounts"
_BRIEFING_WHITELIST="pr_command issue_command forge_name forge_ssh_host blocked_branches container_id project_dir workspace_dir session_id resume_command writable_mounts network_allowlist"
_briefing_whitelisted() {
_set_contains "$_BRIEFING_WHITELIST" "$1"
@@ -616,7 +705,7 @@ What this means:
packages, anything outside the mounted paths, and the container itself go
away when this run ends.
- Outbound network is restricted to an allowlist (Anthropic's API, the npm
registry, GitHub read-only, the configured {{forge_name}} host{{forge_ssh_host}}, plus anything
registry, {{network_allowlist}}, plus anything
approved via dejima-allow). Requests to anything else will simply
fail to connect. That restriction is not only a limit on your reach: it
is also what bounds where a prompt injection or a compromised dependency
@@ -625,11 +714,18 @@ What this means:
allowlist is load-bearing for you too, not just for the host - working
around it would give up that protection, not merely lift the one limit
it appears to. If a task needs one more domain and nothing else, that's
a quick fix, not a reason to stop: tell the user to run
`bin/dejima-allow <domain> --container {{container_id}}` (add
`--global` to persist it past this session) from a terminal outside the
sandbox, then continue. Quote that --container value exactly as written
here - it is this container's real ID, not something to infer.
a quick fix, not a reason to stop. Tell the user to run, from a terminal
outside the sandbox, whichever of these matches why the domain is needed:
bin/dejima-allow --container {{container_id}} <domain> # just this session
bin/dejima-allow --project <path> <domain> # this project, always
bin/dejima-allow --global <domain> # every project
Pick deliberately rather than asking for all three: a domain this project
needs is --project, not --global - a global grant widens every other
project's firewall too, which is rarely what one project's dependency
calls for. --project also records the domain in the project's own
.dejima/allowed-domains, so it is shared with everyone working on it
rather than living on one machine. Quote that --container value exactly as
written here - it is this container's real ID, not something to infer.
- Stay within the task you were given. Since there are no permission
prompts here, be more conservative than usual about anything destructive,
irreversible, or outside the project directory.
@@ -784,6 +880,38 @@ _briefing_resolve_key() {
fi
[ -z "$host" ] || printf ' (%s)' "$host"
;;
# What the session may actually reach, phrased for the selected forge
# rather than assembled from two placeholders. Materially different
# postures, and the agent acts on this sentence: GitHub is read-only
# by default; a forge with hosts of its own adds them; a forge whose
# hosts are the ones core already allowlists (hosts_allowlisted_by_core
# - the same manifest key bin/dejima reads to open the push grant) has
# no separate host to name, and when the workflow pushes, calling that
# access read-only would be false in the direction that matters.
#
# Driven off that key rather than off the forge's name: it is the one
# thing that actually decides the posture, so anything else here would
# be core guessing at what a manifest already states.
network_allowlist)
local hosts=""
if [ "$(manifest_get forge "$forge" hosts_allowlisted_by_core)" = "on" ]; then
if [ "$(manifest_get workflow "$workflow" push)" = "on" ]; then
printf '%s' "GitHub, which this session can also push to"
else
printf '%s' "GitHub read-only"
fi
else
if [ -n "$(manifest_get forge "$forge" host_keys)$(manifest_get forge "$forge" url_keys)$(manifest_get forge "$forge" fixed_hosts)" ]; then
hosts=", the configured $forge host"
if config_exists 2>/dev/null; then
local h
h="$(config_get "forge.$forge" ssh_host 2>/dev/null)" || h=""
[ -z "$h" ] || hosts="$hosts ($h)"
fi
fi
printf '%s' "GitHub read-only$hosts"
fi
;;
blocked_branches) manifest_get workflow "$workflow" blocked_branches ;;
project_dir) printf '%s' "${WORKSPACE_DIR:-}" ;;
workspace_dir) printf '%s' "${WORKSPACE_DIR:-}" ;;
+17 -40
View File
@@ -2,7 +2,14 @@
# container (https://github.com/anthropics/claude-code/tree/main/.devcontainer),
# substantially extended and reworked for this project's own sandboxing model
# (parametrized SANDBOX_USER, DNS proxy + ulogd2 logging, gosu-based privilege
# drop, nvim/Go/Playwright/tree-sitter toolchain, gitea-pr helper, etc).
# drop, nvim/Playwright/tree-sitter toolchain, gitea-pr helper, etc).
#
# What belongs in here is what the sandbox itself needs, plus the editor and
# browser tooling that has no route through the dependency mechanism. Language
# toolchains and per-project utilities do have one - `dejima-dep`, including
# tarball: for a release archive - so they are declared by the projects that
# want them rather than carried by every sandbox on the host. Anything added
# below is added for every project, forever, with no review anywhere.
FROM node:22
ARG TZ
@@ -30,7 +37,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
procps \
fzf \
zsh \
man-db \
unzip \
gnupg2 \
gh \
@@ -45,28 +51,15 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
ulogd2 \
ulogd2-json \
nano \
vim \
ripgrep \
fd-find \
build-essential \
python3 \
python3-pip \
python3-venv \
xz-utils \
bats \
cowsay \
fortune-mod \
fortunes-min \
&& apt-get clean && rm -rf /var/lib/apt/lists/* \
&& ln -s "$(command -v fdfind)" /usr/local/bin/fd
# cowsay/cowthink/fortune install to /usr/games on Debian, which isn't on
# PATH by default - nvim start-screen plugins commonly shell out to them by
# bare name for a dashboard header. Set via ENV (not a profile.d script) so it's
# picked up by every process unconditionally, including nvim's non-login
# subprocess spawns, which wouldn't source /etc/profile.d.
ENV PATH=/usr/games:$PATH
# Set `DEVCONTAINER` environment variable to help with orientation
ENV DEVCONTAINER=true
@@ -97,13 +90,6 @@ RUN useradd -r -s /usr/sbin/nologin ulogd
# of re-running all of it. That single ordering was most of the cost of the
# override rebuild: it was 1169s of a 1379s test run, for a difference that
# amounts to one useradd.
ARG GIT_DELTA_VERSION=0.18.2
RUN ARCH=$(dpkg --print-architecture) && \
wget --tries=3 --timeout=30 --waitretry=5 --retry-connrefused \
"https://github.com/dandavison/delta/releases/download/${GIT_DELTA_VERSION}/git-delta_${GIT_DELTA_VERSION}_${ARCH}.deb" && \
dpkg -i "git-delta_${GIT_DELTA_VERSION}_${ARCH}.deb" && \
rm "git-delta_${GIT_DELTA_VERSION}_${ARCH}.deb"
# gosu lets entrypoint.sh (root) drop to the node user for the actual
# session after setting up the firewall, without leaving any setuid/sudo
# escalation path available to node afterward. Verified against the
@@ -130,24 +116,15 @@ RUN asset_url=$(curl -fsSL --retry 3 --retry-delay 2 --retry-all-errors --max-ti
tar -C /usr/local --strip-components=1 -xzf nvim.tar.gz && \
rm nvim.tar.gz
# Install the latest stable Go toolchain. go.dev's own release JSON gives
# both the download URL and a SHA256 per file, verified below since this
# runs as root - same pattern as gosu above. `go build`/`go mod download`
# need proxy.golang.org and sum.golang.org reachable at runtime - see
# fixed-domains.sh.
RUN ARCH=$(dpkg --print-architecture) && \
release_json=$(curl -fsSL --retry 3 --retry-delay 2 --retry-all-errors --max-time 60 \
'https://go.dev/dl/?mode=json') && \
filename=$(echo "$release_json" | jq -r --arg arch "$ARCH" \
'[.[] | select(.stable == true)][0].files[] | select(.os == "linux" and .arch == $arch and .kind == "archive") | .filename') && \
sha256=$(echo "$release_json" | jq -r --arg arch "$ARCH" \
'[.[] | select(.stable == true)][0].files[] | select(.os == "linux" and .arch == $arch and .kind == "archive") | .sha256') && \
wget --tries=3 --timeout=30 --waitretry=5 --retry-connrefused \
-O go.tar.gz "https://go.dev/dl/${filename}" && \
echo "${sha256} go.tar.gz" | sha256sum -c - && \
tar -C /usr/local -xzf go.tar.gz && \
rm go.tar.gz
ENV PATH=$PATH:/usr/local/go/bin
# No Go toolchain here. A Go project declares one:
#
# dejima-dep add --global tarball:go@<sha256>:https://go.dev/dl/go<ver>.linux-amd64.tar.gz
#
# and gets a pinned toolchain in the version it actually wants, rather than
# whatever this image was built with. Shipping a language runtime is not the
# same decision as every sandbox on the host carrying one - and the module
# proxy it needs (proxy.golang.org, sum.golang.org) is an approval too, for
# the same reason.
# System libraries Chromium needs (fonts, libnss, libatk, etc.) for
# Playwright, installed here since it needs apt/root - the actual
+35
View File
@@ -252,6 +252,41 @@ fi
[ -n "${ANTHROPIC_BASE_URL:-}" ] || unset ANTHROPIC_BASE_URL
[ -n "${ANTHROPIC_AUTH_TOKEN:-}" ] || unset ANTHROPIC_AUTH_TOKEN
# Remote access is opt-in and off by default, so this whole block is skipped
# for the sessions that never asked for it: DEJIMA_NOTIFY_DIR is set only by
# bin/dejima's remote_enabled branch. An unset value is the ordinary case and
# says nothing.
#
# Set-but-unusable is different, and it is worth a line at startup because of
# how it otherwise presents. The marker directory is a bind mount whose source
# bin/dejima creates per launch and removes on exit, so restarting the
# container - `docker start`, or a restart policy - rather than relaunching
# `dejima` leaves the source gone. Docker's answer to a missing bind source is
# not to refuse: it creates one, root-owned. The directory then exists, so the
# notify hook's own `[ -d ]` guard passes and every notification fails with
# EACCES instead; the session is also absent from `dejima-attach`, because its
# registry entry went with the same directory. Three unrelated-looking symptoms
# and no obvious moment they started.
#
# Reported, not repaired. Chowning this would restore notifications while the
# session stayed missing from the registry - which bin/dejima writes host-side
# and never mounts here - so a session would look fixed while half of the
# feature was still broken. Relaunching is the actual remedy, and it is one the
# message can name.
if [ -n "${DEJIMA_NOTIFY_DIR:-}" ]; then
if [ ! -d "$DEJIMA_NOTIFY_DIR" ]; then
echo "WARNING: remote access is enabled, but $DEJIMA_NOTIFY_DIR is not a directory." >&2
echo "Notifications will not be delivered for this session." >&2
elif ! gosu "$SANDBOX_USER" test -w "$DEJIMA_NOTIFY_DIR"; then
echo "WARNING: remote access is enabled, but $DEJIMA_NOTIFY_DIR is not writable by" >&2
echo "$SANDBOX_USER - so notifications will fail, and this session will not appear in" >&2
echo "dejima-attach. This is what a container that was RESTARTED rather than" >&2
echo "relaunched looks like: bin/dejima creates the marker directory per launch and" >&2
echo "removes it on exit, and Docker recreated the missing bind source as root." >&2
echo "Stop this container and start a new session with 'dejima' to restore both." >&2
fi
fi
# Test harness hook (test/): everything above this point is the real
# startup sequence - DNS proxy, firewall, IPv6 block, logging,
# privilege-drop prep. Skip execing $SANDBOX_CMD so the suite can `docker
+43 -5
View File
@@ -1,14 +1,22 @@
# Sourced by both init-firewall.sh (IP-based ipset allowlist) and
# dns-proxy.sh (name-based DNS allowlist) so the two can't drift apart.
#
# What belongs here is what the sandbox's own machinery cannot run without -
# the harness and its updates, and the release CDN the tools baked into the
# image fetch binaries from. Nothing else.
#
# Language ecosystems and editor tooling deliberately do not: a Python, Go or
# Rust project reaches its own package host through an approval, either
# `dejima-allow --global` once or a `.dejima/allowed-domains` line reviewed
# with the project. The distinction is not tidiness. Every name here is
# reachable from every sandbox on the host whether or not the project has any
# use for it, and - since a project's declaration is also what a hardened CI
# runner reads its allowlist from (docs/ci.md) - a name that is granted by
# being built in is a name nobody ever reviewed.
FIXED_DOMAINS=(
"registry.npmjs.org"
"platform.claude.com"
"gitlab.com"
"pypi.org"
"files.pythonhosted.org"
"release-assets.githubusercontent.com"
"proxy.golang.org"
"sum.golang.org"
)
# api.anthropic.com is allowlisted only when this session has no credential
@@ -38,3 +46,33 @@ GITHUB_DNS_SUFFIXES=(
"githubusercontent.com"
"githubassets.com"
)
# Which ipset an allowlisted name's addresses belong in.
#
# GitHub's are restricted to port 443 by init-firewall.sh, and that
# restriction is the read-only guarantee - there is no transport left for the
# forwarded ssh-agent's key to push with. allowed-domains carries no port
# restriction at all, so an address that reaches it is reachable on 22 as
# well, and a GitHub address that reaches it silently undoes the whole
# arrangement.
#
# dns-proxy.sh gets this right for free: dnsmasq applies the longest matching
# ipset= line, so GitHub names go to github-domains however they were
# allowlisted. Every other path has to ask, and two did not - the
# extra-domains loop in init-firewall.sh and the live add in bin/dejima-allow
# both added unconditionally to allowed-domains. A project with github.com in
# its .dejima/allowed-domains therefore opened port 22 to whichever address
# GitHub returned at container start, for as long as that answer stayed
# cached. It presented as an intermittent startup failure, because the
# port-22 self-check re-resolves the name and only sees the hole when the
# second answer matches the first.
ipset_for_domain() {
local domain="$1" suffix
for suffix in "${GITHUB_DNS_SUFFIXES[@]}"; do
if [ "$domain" = "$suffix" ] || [[ "$domain" == *".$suffix" ]]; then
printf 'github-domains'
return 0
fi
done
printf 'allowed-domains'
}
+96 -7
View File
@@ -76,10 +76,13 @@ else
echo "No Docker DNS rules were present to restore"
fi
# Allow localhost. DNS resolution goes entirely through Docker's embedded
# resolver at 127.0.0.11 (see resolv.conf), which stays on loopback, so this
# also covers DNS - no need for a separate "UDP/53 to anywhere" rule, which
# would just be an unrestricted-destination exfil channel.
# Allow localhost. Every DNS query this container makes starts here: to
# dnsmasq on 127.0.0.1, and from there to Docker's embedded resolver on
# 127.0.0.11 (see resolv.conf). What this rule does NOT cover is the hop
# after that, where the embedded resolver forwards an external name to the
# host's nameservers - see the owner-matched rule further down, which is
# there because that hop can leave this namespace. Still no "UDP/53 to
# anywhere" rule, which would be an unrestricted-destination exfil channel.
iptables -A INPUT -i lo -j ACCEPT
iptables -A OUTPUT -o lo -j ACCEPT
@@ -269,13 +272,17 @@ if [ -n "${EXTRA_ALLOWED_DOMAINS:-}" ]; then
continue
fi
# Not always allowed-domains: that set carries no port restriction,
# so a GitHub address landing in it is reachable on 22 as well and the
# read-only guarantee is gone. See ipset_for_domain.
target_set=$(ipset_for_domain "$domain")
while read -r ip; do
if [[ ! "$ip" =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]; then
echo "WARNING: Invalid IP from DNS for $domain: $ip, skipping"
continue
fi
echo "Adding $ip for $domain"
ipset add allowed-domains "$ip" 2>/dev/null || true
echo "Adding $ip for $domain (-> $target_set)"
ipset add "$target_set" "$ip" 2>/dev/null || true
done < <(echo "$ips")
done
fi
@@ -305,6 +312,40 @@ iptables -P OUTPUT DROP
iptables -A INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
iptables -A OUTPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
# Docker's embedded resolver does not answer external names itself - it
# forwards them to the host's nameservers, and on current Docker it does that
# from inside THIS network namespace. Those queries therefore traverse the
# chain below like any other packet, and nothing here can match them: the
# host's resolver is neither in an ipset nor the bridge gateway. They reach
# the catch-all REJECT, every lookup comes back SERVFAIL, and the container
# dies in a self-check that says only that a host was unreachable.
#
# The comment on the loopback rule above used to claim DNS "stays on
# loopback, so this also covers DNS". Only the first hop does. Two hosts with
# byte-identical /etc/resolv.conf behaved differently here, so this is not a
# property of any host's configuration and cannot be assumed either way.
#
# --uid-owner 0 is what keeps this from being the hole the DNS allowlist
# exists to close. The forwarding socket belongs to dockerd, which is root;
# the session runs as `node` and there is no sudo in the image, so a
# compromised session still cannot address a resolver directly and still has
# no path to a name dns-proxy.sh has not allowed. What this does newly permit
# is DNS from processes that are already root in a container holding
# NET_ADMIN - which could simply flush this ruleset instead, so it grants
# nothing that was not already available.
#
# Guarded rather than asserted: xt_owner is a module, and a kernel without it
# should leave a container that says why (the DNS self-check below), not one
# that aborts here with an iptables usage error.
if iptables -A OUTPUT -p udp --dport 53 -m owner --uid-owner 0 -j ACCEPT 2>/dev/null; then
iptables -A OUTPUT -p tcp --dport 53 -m owner --uid-owner 0 -j ACCEPT 2>/dev/null \
|| echo "WARNING: could not add the TCP/53 rule for root-owned sockets - large answers may fail" >&2
else
echo "WARNING: the iptables owner match is unavailable, so Docker's embedded" >&2
echo "resolver cannot forward out of this namespace. If DNS works anyway this" >&2
echo "host forwards elsewhere and nothing is wrong; the self-check below says which." >&2
fi
# Then allow only specific outbound traffic to allowed domains. This isn't
# port-restricted, so it also covers git-over-ssh (port 22) to the
# configured forge's hosts, which are already in the ipset above - no
@@ -340,6 +381,33 @@ iptables -A OUTPUT -j NFLOG --nflog-group 2 --nflog-prefix "reject"
iptables -A OUTPUT -j REJECT --reject-with icmp-admin-prohibited
echo "Firewall rules applied. Self-checking..."
# DNS before either connectivity check, because both of those resolve a name
# before they connect and neither can tell you that resolution is what failed.
# The negative control is the trap: with DNS dead it fails for the wrong
# reason and prints "example.com is unreachable, as it should be", so a
# container with no working DNS at all reports a passing check and then dies
# on the positive control naming a host rather than the cause. Reading that
# output backwards took an afternoon; it should take one line.
if ! dns_probe=$(dig +short +time=3 +tries=2 A api.github.com 2>&1) || [ -z "$dns_probe" ]; then
echo "ERROR: self-check failed - DNS does not resolve inside the sandbox." >&2
echo >&2
echo "The path is: this container -> dnsmasq on 127.0.0.1 (dns-proxy.sh) ->" >&2
echo "Docker's embedded resolver on 127.0.0.11 -> the host's nameservers." >&2
echo "Only the first two hops are on loopback. If Docker forwards the last" >&2
echo "one from inside this namespace, it traverses the OUTPUT chain, matches" >&2
echo "nothing, and is rejected - which reads as SERVFAIL, not as a firewall." >&2
echo >&2
echo " dig said: ${dns_probe:-(empty answer)}" >&2
echo " packets taken by the catch-all OUTPUT reject so far:" >&2
iptables -L OUTPUT -vn 2>/dev/null \
| awk '/icmp-admin-prohibited/ {print " " $1 " packets, " $2 " bytes"}' >&2
echo " dnsmasq's last words:" >&2
tail -5 /var/log/dnsmasq.log 2>/dev/null | sed 's/^/ /' >&2
exit 1
fi
echo "self-check ok - DNS resolves an allowlisted name"
# --max-time as well as --connect-timeout on both self-checks: a connect that
# succeeds and then stalls is not covered by --connect-timeout, and while these
# two sit inside `if` (so set -e cannot take the script down the way it could
@@ -353,7 +421,28 @@ else
fi
# Confirm the allowlist itself actually works, not just that the default-deny does.
if ! curl --connect-timeout 5 --max-time 15 https://api.github.com/zen >/dev/null 2>&1; then
#
# Retried, unlike the negative control above, because the two ask opposite
# questions. This one asserts something CAN be reached, and a transient DNS
# answer or a CDN edge rotating mid-boot answers it wrongly in the direction
# that kills the container - which is how it presented, one container failing
# here while a dozen others in the same CI run came up fine. The example.com
# check must NOT be retried: there a single success is the real answer, and
# retrying would only be asking until it says what we want.
github_reachable=0
for attempt in 1 2 3; do
if curl --connect-timeout 5 --max-time 15 https://api.github.com/zen >/dev/null 2>&1; then
github_reachable=1
break
fi
echo " attempt $attempt/3: api.github.com not reachable yet" >&2
# `if`, not `[ ... ] && sleep` - see fetch_github_ranges above for what
# that costs on the last attempt under set -e.
if [ "$attempt" -lt 3 ]; then
sleep $((attempt * 2))
fi
done
if [ "$github_reachable" -ne 1 ]; then
echo "ERROR: self-check failed - api.github.com should be reachable but wasn't"
exit 1
else
+533
View File
@@ -0,0 +1,533 @@
# CI, and what push access grants
[← dejima README](../README.md)
**Granting a dejima session push access grants it code execution wherever your
CI runs.** That is the whole of it, and everything below is either why it
cannot be gated or what to do about it instead.
> **This document is the why.** For the how, [dejima-ci-hardener](https://gitbox.branderson.io/branderson/dejima-ci-hardener)
> is a self-contained tool that does it — installed on the CI box, never on a
> dejima host, and it does not assume you have read this.
The chain needs no operator action at any step. A session writes code; the
push guard permits a feature branch, which is what it exists to allow; the
session opens a pull request, which its briefing tells it to do without asking
first; `on: pull_request` fires and the runner checks out that commit and runs
it. The code does not have to be a modified workflow file to be executed — a
workflow that runs a test suite is already running every file in it, and a
plausible-looking helper in a test file is a much quieter carrier than an
edited workflow, which shows up in a diff.
The gate this sandbox otherwise relies on — you merge, the agent does not —
sits strictly downstream of that. By the time anyone reads the diff, the code
has run.
This applies to any project developed in a dejima sandbox, not only to dejima
itself. The sandbox bounds what a session can reach directly. It does not
bound what it can arrange to have run elsewhere, and CI is the shortest path
elsewhere.
**And it applies to projects with no CI at all.** A branch can add the first
workflow, so what matters is not what a repository's workflows do today but
which runners its branches can reach — which is a property of the forge, not
of the project. If you read one section of this document before pointing a
sandbox at something, make it
[what a repository can reach](#closing-the-rest-what-a-repository-can-reach);
it is a question to answer per project, and for a project on hosted runners
the answer is reassuring.
## What a run actually adds
The code CI executes is code the session already had. Relative to the sandbox,
a run adds three things, and every control in this document maps onto one of
them:
1. **Execution on a host you own** — outside the container, outside the
allowlist, with whatever that host can reach.
2. **Egress from a network position you own** — no dejima firewall, no DNS
proxy, ordinary networking.
3. **Credentials the run is handed** — the secrets you configured, plus the
token the forge injects into every job whether the workflow asks for one or
not.
Keep those three in view even if none of the specific configuration below
applies to you. They are what changes when a session gains push access, and
they survive translation to CI that looks nothing like this.
## Why this document offers no approval gate
The obvious answer is to require a human to approve a run. Each version of
that turns out to be reachable from the branch, from a credential the session
already holds, or to be no gate at all:
- **Changing the trigger** — `workflow_dispatch` only, or `push` to `main`
only. The workflow file lives in the tree the session controls, and on a
`pull_request` run it is read from the head of the pull request.
- **Triggering by hand.** A forge token that can open a pull request generally
also covers the dispatch endpoint. Gitea's `write:repository` does.
- **Leaving the runner stopped until you want a run.** Any shared pool has
spare capacity, so a job that cannot land on your runner lands on someone
else's. It also asks you to do something before every run, which is a
recommendation that gets abandoned rather than followed.
- **The forge's own approval settings.** Gitea's approval requirement and
read-only token apply to pull requests *from forks*. A dejima session pushes
a branch to the same repository, which is precisely the case they do not
cover.
- **Filtering egress from inside the workflow.** That is a step in a file the
branch can edit.
Running unattended from a plan through to a pull request is most of what a
sandbox like this is for. A design that reinserts a manual step per run gives
that up and, in practice, gets routed around. So nothing here tries to stop
the run. Everything here bounds what the run can do.
## Before anything else: protect the branch
Every control below that is worth anything roots its trust in `main` — most
directly the egress allowlist, which is read from there. A job token with
write access to the repository can push to an unprotected branch, so without
this the untrusted party can edit the thing that is supposed to constrain it.
- Protect `main`: no direct push, pull request required.
- **Verify** the job token cannot push to it, rather than assuming branch
protection covers a token the forge issues itself.
- Check whether a job token can *merge* a pull request on your instance. If it
can, both the merge guard and the you-merge-not-the-agent gate are reachable
from CI, and you should know that either way.
This costs nothing and it is the single highest-value item in this document.
**Branch protection needs a second identity to protect anything.** By default
a session authenticates as you, so a merge whitelist, a push whitelist and an
approval requirement all admit the sandbox exactly as they admit you — and
required approvals are unusable outright, since a forge will not let a pull
request's author approve it and the session opens them as you. Giving the
sandbox its own forge account is what turns those settings from decoration
into enforcement, and it is configuration rather than a missing feature:
[docs/security.md](security.md#to-your-forge-the-sandbox-is-you).
Note that it does not help with the rest of this document. A write-capable
account still pushes feature branches, which is what makes CI run code, and
the token the forge injects into a job is a third principal subject to none of
those settings.
## The runner host
Treat it as what it is: a machine that exists to run untrusted code, because
that is what it does on every pull request.
- **Nothing else on it.** No other service, no other project's data.
- **No route to the LAN or to a management network.** A runner that can reach
your internal services is a runner that hands them to whatever it executes.
- **No credentials beyond its own registration token.** Registration tokens
are worth rotating on a schedule; anyone holding one can register a runner
and start receiving jobs, including their secrets.
- **Rebuilt from a snapshot on a schedule**, so that persistence on it has a
bounded lifetime rather than an indefinite one.
- **Register runners at repository or organization scope**, not instance
scope. This matters in both directions, and the second is the one that bites:
an unrelated repository cannot schedule work onto a runner you hardened for
this one, *and* this repository cannot schedule work onto a runner nobody
hardened. An instance-level runner is reachable from every repository on the
instance — see "A label is not a boundary" below. Gitea's own FAQ describes
gitea.com doing this for its public runners.
Two runners on one VM give you label tiering, not host separation: a job that
cannot get the Docker socket still shares a kernel and a network with a runner
that can. If you only have one machine, that is a real reduction and worth
doing — just do not read it as isolation between the tiers.
## Job containers
The single most consequential setting is one that is invisible from inside a
job. `act_runner` mounts the Docker daemon's socket into every job container
it starts unless `container.docker_host` is set to `"-"`; its own generated
configuration says so:
> If it's `"-"`, act_runner will find an available docker host automatically,
> but the docker host won't be mounted to the job containers and service
> containers.
Access to that socket is root-equivalent on the runner host — `docker run
-v /:/host` ends the argument. Nothing in a job's log mentions the socket
being there, so this is not something a passing look at a green run will
surface. Check it directly, per runner:
```sh
grep -A12 '^container:' /path/to/act_runner/config.yaml
```
An unset `docker_host` is the default, and the default mounts.
Note also that whether a job gets the socket is a property of **the runner's**
configuration, not of the job. A job asking for nothing unusual still receives
whatever the runner grants everything it runs. So the useful question is not
"does this job need Docker" but "does every job this runner can pick up
deserve the socket".
For a runner that does not need it, the settings worth being explicit about
rather than leaving at their defaults are `docker_host: "-"`, an empty
`valid_volumes` (the default, but worth stating so an edit has to be
deliberate), `privileged: false`, a fixed `network` (below), `capacity: 1`,
`cache.enabled: false`, and a label nothing else carries.
dejima-ci-hardener ships both configs, with the reasoning inline.
**Prefer a new runner over reconfiguring an existing one.** A general-purpose
runner often exists precisely to build images, and taking its socket away
breaks it. Register a second runner with a label of its own, and name that
label in the workflow — and if no runner carries the label yet, the job queues
rather than running somewhere unprepared, which is a better failure than a
green run on the wrong host.
### A label is not a boundary
That last paragraph describes routing, not containment, and the difference
matters enough to state on its own.
**`runs-on:` is in the workflow file, and the workflow file is in the branch.**
A branch can name any label it likes. So a dedicated label keeps *honest* jobs
on the runner you prepared; it does nothing against a branch that would rather
run somewhere else. A session that changes `runs-on: dejima-ci` to
`runs-on: ubuntu-latest`, pushes, and opens a pull request has stepped outside
every control on this page — no allowlist, no socket restriction, no firewall
— provided some runner answering `ubuntu-latest` will take the job.
Whether one will is a property of **where your runners are registered**, which
is the actual boundary:
| Registered at | Which repositories it serves |
| --- | --- |
| Instance | **every repository on the instance** |
| Organization | that organization's repositories |
| Repository | that repository |
Gitea has no per-repository runner ACL — its FAQ says so directly, and points
at registration scope as the control instead: gitea.com registers its runners
for one organization "so our runners will not execute jobs from other
repositories."
So the property to hold onto is this: **a repository's CI security posture is
that of the weakest runner it can reach, not the one its workflow names.** An
instance-level general-purpose runner is reachable from every repository on
that instance, including any that takes agent-authored branches.
### Pinning the workflow with `pull_request_target`
Half of this is fixable, and worth fixing: **`runs-on` does not have to come
from the branch.**
Under `on: pull_request` the workflow is read from the head of the pull
request, so the branch decides what runs and where. Under
`on: pull_request_target` Gitea reads it from the **base branch**, and
explicitly discards any `pull_request_target` workflow found on the head
commit — a branch can neither edit the base copy nor introduce its own. What
runs, and on which runner, is whatever `main` says.
**Adopting it takes two merges, not one.** The trigger only takes effect once
it is on the base branch, because that is where it is read from — and a
`pull_request_target` workflow on a pull request's head is explicitly ignored.
So the change that introduces it cannot be tested by it: with only
`pull_request_target` on the branch and only `pull_request` on `main`, the
head's copy is skipped, the base has nothing to contribute, and **no checks
run at all** — which looks like a broken workflow rather than a bootstrap
problem.
Carry both triggers through the first merge, then remove `pull_request` in a
follow-up. The first change is tested by its own copy from the head, as
today; the follow-up is tested by the copy now on `main`. In between, pull
requests get both an untrusted run from the head and a trusted one from the
base — noisy, and for the untrusted half no worse than the status quo, so
keep the window short.
Two more consequences to handle rather than discover:
- **The checkout no longer defaults to the code under test.** The run's ref is
the base branch, so a bare `actions/checkout` fetches `main` and the suite
passes without ever seeing the change. Jobs that test the branch must name
it: `ref: ${{ github.event.pull_request.head.sha }}`. Jobs that assert on
the *runner* should keep the bare checkout, so their assertions come from
`main` and a branch can neither weaken nor delete them.
- **The job runs with the base repository's token and secrets**, because the
forge treats it as trusted. On a forge where a same-repo `pull_request` run
already gets a permissive token this changes nothing, but it is why the
pattern carries warnings elsewhere: give such a run no secret you would not
hand the branch.
This does not make the repository safe on its own. A branch can still add a
workflow of its own with `on: pull_request`, which runs from the head with
whatever `runs-on` it likes. What it buys is that the *intended* CI is
trustworthy, and that escaping it means adding a file rather than editing
one — a much louder diff.
### Closing the rest: what a repository can reach
The rule underneath everything here:
> **Every runner reachable from a repository that receives agent-authored
> branches must be one you would accept an attacker running code on.**
Note what that does *not* say. It says nothing about what the repository's
workflows currently do, because a branch can add the first one. **A project
with no CI at all is still exposed**, on a forge where its branches can reach
a runner — and that is the case people do not expect, because there is nothing
in the repository to look at.
So before pointing a sandbox at a project, ask one question: *what runners can
this repository's branches reach?*
**If the answer is "hosted runners only"** — GitHub-hosted and nothing
self-hosted attached — this is the mildest case, and it is worth saying so
rather than alarming people. The runner is disposable and is not your machine,
so "execution on a host you own" largely evaporates: there is no LAN behind
it and nothing of yours on it. What remains is the third item, credentials:
give pull-request runs no secrets they do not need and a read-only token
(`permissions: contents: read`), and do not attach a self-hosted runner to
that repository.
**If self-hosted runners are reachable**, that is the sharp case, and there are
three ways to hold the rule — none of which require moving or renaming
anything.
**1. Turn CI off for that repository, if it does not use CI.** Disabling the
Actions unit means no workflow runs for that repository at all, so no runner is
reachable and a branch cannot add the first workflow. Changing it back needs
*admin* on the repository — Gitea's API puts repository settings behind
`reqAdmin()` — so a bot account with Write cannot undo it. For a project that
was not using CI anyway this costs nothing and closes the vector outright. It
is the first thing to reach for and the most commonly overlooked.
**2. Scope the dangerous runner down, rather than moving the repository out.**
The instinct is to move agent-developed repositories somewhere with only
hardened runners. For a public project that means breaking its name, its
links and its ownership, which nobody should be asked to do.
Invert it. A runner can belong to an **owner** — a user as well as an
organization — and then serves that owner's repositories; Gitea's runner model
names the three cases: global (`OwnerID` and `RepoID` both zero), org/user
level (`OwnerID` set), repository level (`RepoID` set). So:
- Make the *hardened* runner the owner-level default, so every repository gets
it.
- Register the *permissive* runner — the one with the Docker socket, or with
unrestricted egress — at **repository** scope, for only the repositories
that genuinely need it.
Most repositories need a runner that can run tests, not one that can build
images. Scoping the dangerous capability to the few that need it is usually a
handful of registrations, and nothing moves or is renamed.
**3. Do not grant push.** `[core] forge = none` with
`[core] workflow = local-only`. The session commits locally and stops; you
review the diff and push it yourself. No branch, no workflow, no runner, and
the vector is closed completely rather than bounded. The cost is one push
after the review you were going to do anyway — and it is the only option here
that does not depend on getting a forge's configuration right.
If none of the three is available for a given project, that is worth knowing
plainly: pointing a sandbox at it means accepting that a compromised session
can run code on whatever that repository can reach.
## Egress
The goal is that a job cannot reach somewhere new — cannot fetch a next stage,
cannot post a repository to a paste service, cannot probe an internal address
— without a human having agreed to that destination first.
### Four network positions, only one of which is the job
Getting these confused produces either a broken build or a firewall that
protects nothing:
| Traffic | Whose namespace | Filtered in |
| --- | --- | --- |
| Job steps: `apt-get`, the checkout's fetch | the job container | `DOCKER-USER` (a `FORWARD` hook) |
| Job → the resolver on its bridge gateway | job container → the host itself | `INPUT` |
| Pulling the job's own image | the Docker daemon, on the host | the host's `OUTPUT` |
| `docker build` steps, where a job has a daemon | the daemon's own bridge | its own policy |
**Filtering only `DOCKER-USER` leaves the DNS allowlist decorative.** Traffic
to a resolver on the bridge gateway is addressed to the host, so it is an
`INPUT` packet and never passes through `DOCKER-USER` at all — and a job that
can reach any resolver it likes can resolve anything it likes. `INPUT` needs
its own pair of rules: accept port 53 from the bridge, drop the rest.
The upside of the split is that a job's own needs are usually small, because
image pulls and action fetches happen from the host rather than from the job.
For a suite that installs a few packages and checks out a repository, the job
needs the forge and a package mirror, and nothing else.
### A fixed network for jobs, a resolver, and both firewall chains
Point `container.network` at a network you created, with a named bridge, so
that the interface name, subnet and gateway are stable enough to write rules
against. Left empty, `act_runner` creates a network per job and there is no
stable interface to match on. Give it a **private** subnet: `stop-dns-rebind`
below rejects answers in the private ranges, so a reserved-but-not-private
bridge subnet is one that check silently does not cover.
With `runner.capacity: 1` one job occupies that network at a time. Above one,
concurrent jobs share a broadcast domain and can reach each other's service
containers, which is worth knowing before raising it.
The DNS and firewall shape is the same one dejima uses inside its own
container (`docker/dns-proxy.sh` and `docker/init-firewall.sh`), applied one
layer out: allowlist the *names* and let the answers populate the set of
allowed *addresses*.
`dnsmasq` binds the bridge gateway. With `no-resolv` and one `server=` line
per allowed name, every other name gets `REFUSED` — not resolved-then-blocked,
refused, which closes off encoding data into query names to a domain you never
allowed. `ipset=/#/ci-allow` adds every answer it serves to the firewall's
set, so a destination that rotates its edge addresses stays reachable without
a resolver cron chasing it. `rebind-domain-ok` exempts a self-hosted forge,
which legitimately resolves to a private address.
The runner pins its job containers at that resolver - per-runner rather than
daemon-wide, because a daemon-wide setting also reaches containers a
privileged job creates over the Docker socket, which have firewalls of their
own and no route to it. Then two chains filter: `DOCKER-USER` for everything
the job forwards off the bridge, and `INPUT` for the resolver itself, because
the gateway address belongs to the host.
Block IPv6 outright unless you have built the same allowlist for it — an
address family with no allowlist is not a smaller hole than the one you just
closed.
**The working configuration for all of this is in [`ci/`](../ci/), and
[`ci/README.md`](../ci/README.md) is the step-by-step.** It is deliberately not
reproduced here: two copies of a firewall rule drift, and the one in `ci/` is
the one with the ordering constraints and the substitution notes attached.
## The allowlist comes from `main`, not from the checkout
A dejima project already carries a version-controlled statement of where it is
allowed to reach: `.dejima/allowed-domains`. Use it — read from
**`origin/main`**, over the forge's API, by the runner host.
Never from the checkout. The checkout is the branch's, and a branch that can
write its own allowlist has no allowlist. `main` is yours: it got there
through a pull request you merged.
```sh
curl -fsS -H "Authorization: token $FORGE_TOKEN" \
"https://gitea.example.com/api/v1/repos/$OWNER/$REPO/raw/.dejima/allowed-domains?ref=main"
```
The consequence is the point of the whole arrangement: **widening what CI can
reach is a diff on `main`.** It is reviewed, it is attributable, it is
revertible, and it happens in the same file and by the same act that widens
what a session can reach. A prompt injection that needs one more destination
has to get that destination past you in a pull request first.
Refresh on a timer, render the `server=` lines, reload `dnsmasq`. Fail closed:
a fetch that errors should leave the previous allowlist in place, never fall
back to allowing everything — an allowlist builder is exactly the kind of
script whose failure mode is easy to get backwards, because the broken version
looks like a working one right up until it matters.
dejima-ci-hardener's `dejima-ci-allowlist` is that, including the distinction
that costs you here: a repository with no declaration is a 404 and ordinary,
while a 401 or a 500 means this run does not know what the allowlist is and
must not render one.
Two things to be deliberate about:
- **The base set should be narrower than dejima's built-ins, not equal to
them.** A domain judged reasonable for a sandbox was judged as reachable
from inside a firewalled container. On a runner the same name is reachable
from a host on your network. `api.anthropic.com`, for one, has no business
resolving on a runner.
- **One file now grants in two places.** Merging a domain into
`.dejima/allowed-domains` grants it to sessions *and* to CI. That is the
price of a single reviewable grant instead of two lists that drift apart,
and it is worth saying out loud when you review one.
You can shrink what the host itself needs, too: `runner.github_mirror` and
Gitea's `DEFAULT_ACTIONS_URL` both redirect `uses:` action fetches away from
github.com.
## Credentials a run is handed
**The job token.** Every job gets one, asked for or not. On Gitea 1.27 and
later you can set the repository's default to restricted mode (Repository →
Settings → Actions → General): read-only on `code`, `releases` and
`packages`, nothing else. The property that makes it worth having is that
workflow-level `permissions:` is *clamped* by that maximum, so a branch
raising its own cannot exceed it — it is one of the very few controls here
that the branch genuinely cannot touch.
Before 1.27, `permissions:` is ignored and the token is permissive. There is
no supported way to narrow it, which is what makes branch protection above
non-optional rather than merely advisable, and it is a concrete reason to
upgrade.
**Secrets.** A secret exposed to a pull-request-triggered run is a secret
exposed to whatever that branch contains. Give a run only what it needs, and
prefer credentials that cannot do anything else — a token that raises an
anonymous API rate limit should be a fine-grained token with no permissions
and no repository access, not a general-purpose one.
**The cache server.** `act_runner` enables it by default, and it binds on the
runner host specifically so that job containers can reach it. That is an
inbound service exposed to untrusted code. Turn it off where it is not
earning its place.
## CI you do not administer
On hosted CI — GitHub Actions, a shared pool, someone else's infrastructure —
**there is no reliable pre-execution gate for a same-repository branch**, and
the egress section above is unavailable to you: every candidate gate lives in
a file the branch can edit, and the network the job runs on is not yours to
program. That is a limit, not an oversight, and it is better known than
guessed at.
What still applies:
- **A hosted runner gives you the runner-host section for free.** It is
disposable and it is already off your network. The dangerous case is a
*self-hosted shared pool*, where a job can land on a machine nobody hardened
— and where the socket-mounting default above is nobody's decision, just the
default.
- **The credentials section applies everywhere.** `permissions: contents:
read` where it is honoured, no secrets on pull-request runs, and no tunnel
or credential that lets a runner reach your own infrastructure.
- **Branch protection applies everywhere** and costs nothing.
- **So does declining the grant.** If you cannot bound what CI does with push
access, do not give push access: `[core] forge = none` with
`[core] workflow = local-only` is a supported configuration in which the
session commits locally and stops. The push grant *is* the CI
code-execution grant; declining the first declines the second.
Gitea 1.27's **scoped workflows** are worth knowing about here: a workflow
maintained in a central repository the sandbox cannot push to, registerable
instance-wide, so the workflow file itself sits outside any agent branch's
reach. Use it — but it is not a gate. Scoped workflows run *alongside* a
consuming repository's own workflows rather than replacing them, so a branch
can still add a workflow of its own.
## What this does not stop
- **Code still runs.** Everything here bounds *where* and *with what*, not
*whether*. There is no point in the flow at which a human reads the diff
before execution.
- **A repository with no workflows is not therefore safe.** A branch can add
one, so the exposure follows from which runners the repository can reach,
not from what it currently runs. Disabling the Actions unit is what makes
"this project has no CI" into a property rather than a coincidence.
- **An allowed destination is still an exfiltration channel** through its own
request surface — a query string, a lookup, an issue body. Domain
allowlisting closes off reaching somewhere *new*; it cannot inspect what is
sent somewhere already trusted. That is a structural property of
allowlist-only filtering, and it is the same caveat
[docs/security.md](security.md) states for the sandbox itself.
- **The repository is not protected by any of this**, and does not need to be.
A session that can push already has the code; CI does not newly expose it.
- **A job on a runner that legitimately needs the Docker socket owns that
host** for as long as it takes to rebuild it. Where a job genuinely needs a
daemon, the answer is a host you are willing to lose, not a smaller grant.
- **Before Gitea 1.27, the job token stays permissive.** Branch protection
bounds where it can write; it does not take the token away.
- **None of this is checked by anything.** The rest of dejima's security
properties have tests behind them. A runner's configuration lives on a
machine this repository has no access to, so it is on you to re-check it
after upgrades and rebuilds.
+99 -37
View File
@@ -10,10 +10,12 @@ hardcoded into core. Each is a directory under `forges/`, `workflows/` or
| Axis | Directory | Selector | Ships |
| --- | --- | --- | --- |
| forge | `forges/` | `[core] forge` | `forges/gitea` |
| workflow | `workflows/` | `[core] workflow` | `workflows/worktree-pr` |
| forge | `forges/` | `[core] forge` | `forges/gitea`, `forges/github`, `forges/none` |
| workflow | `workflows/` | `[core] workflow` | `workflows/worktree-pr`, `workflows/branch-push`, `workflows/local-only` |
| harness | `harnesses/` | `[core] harness` | `harnesses/claude-code` |
See [Shipped providers](#shipped-providers) below for what each one is for.
**A provider is data.** Nothing in dejima sources, `eval`s or executes a
manifest or a briefing fragment - a manifest is parsed with exactly the
discipline the config file itself gets (unknown key is fatal, every value is
@@ -118,10 +120,11 @@ operator can set, resolved the same way as any other config key
| `issue_command` | `?command` | (empty) | The issue-management command name, same idea. |
| `merge_block` | `?command` | (empty) | The literal command prefix `hooks/merge-guard.sh` refuses inside the sandbox (e.g. `gitea-pr merge`) - only when the workflow's `block_merge = on`. |
| `keys` | `?list:keyspec` | (empty) | This forge's own `[forge.<name>]` config schema - see above. |
| `requires` | `?list:identifier` | (empty) | Which of this forge's own config keys an operator is expected to set for it to actually work (used only for the partial-configuration warning below, not a launch-time gate today). |
| `requires` | `?list:identifier` | (empty) | Which of this forge's own config keys an operator is expected to set for it to actually work - checked at launch by two refusal rules (partly-configured, and none-configured-with-a-fixed-host) when the workflow pushes; see [Validation](#validation). A key with a non-empty schema default never counts as missing for either check. |
| `host_keys` | `?list:identifier` | (empty) | Config keys (from `keys`) that hold a literal hostname - unioned by `forge_hosts` into the firewall allowlist and the push guard's allowed hosts. |
| `url_keys` | `?list:identifier` | (empty) | Config keys that hold a URL - `forge_hosts` strips scheme and path and unions the bare host in the same way as `host_keys`. |
| `fixed_hosts` | `?list:hostname` | (empty) | Hostnames this forge always needs reachable, independent of any config value (none of the shipped forges use this). |
| `fixed_hosts` | `?list:hostname` | (empty) | Hostnames this forge always needs reachable, independent of any config value (`github` uses this - `github.com`, `ssh.github.com`, `api.github.com` are the same three hosts for every operator, so there's nothing to configure and nothing to get wrong). |
| `hosts_allowlisted_by_core` | `bool` | `off` | `on` means this forge's hosts are ones core already allowlists in its own right - GitHub's published ranges, which every session can read from over 443 whatever forge is selected. Core then keeps them out of `DEJIMA_FORGE_HOSTS` (the extra-domains path, which resolves a name into the port-unrestricted `allowed-domains` ipset and exempts it from DNS-rebind protection - right for an internal self-hosted host, a silent widening for a public one) and, when the workflow pushes, opens core's own GitHub push grant to them instead: port 22 to those same ranges, logged and verified by the firewall's startup self-check. They still reach `DEJIMA_PUSH_HOSTS`, so `git-push-guard.sh` permits the push. Name-free in core, but GitHub-specific in effect: this key means "core already allowlists these hosts in its own right", which today is only true of GitHub's ranges, so a future forge whose hosts are not among those must leave it `off` - setting it `on` for anything else grants no firewall allowance at all plus an irrelevant GitHub push grant. |
| `install` | `?list:relpath` | (empty) | Files under this forge's own directory that ship into the image - the only manifest key naming something that actually gets copied and executed. |
| `env_export` | `?list:export` | (empty) | `NAME=key` (the config key's resolved value) or `NAME=@key` (the *contents* of the file the config key names) pairs - the environment variables `bin/dejima` passes into the container for this forge. The `@` form is how a token reaches the container without its path being mistaken for the secret. |
@@ -136,7 +139,7 @@ keys = ssh_host:?hostname api:?url token_file:path=~/.config/gitea/toke
requires = ssh_host api token_file
host_keys = ssh_host
url_keys = api
install = <its two CLIs, listed relative to its own bin/ directory>
install = <its CLIs, listed relative to its own bin/ directory>
env_export = GITEA_API=api GITEA_SSH_HOST=ssh_host GITEA_TOKEN=@token_file
```
@@ -146,9 +149,9 @@ prose refers to `gitea-pr`/`gitea-issue`; `hooks/merge-guard.sh` blocks
config keys (`ssh_host`, `api`, `token_file`) from `keys`; an operator is
expected to set all three (`requires`); `ssh_host` is a literal hostname and
`api` is a URL, both of which end up in the firewall allowlist and the push
guard's host list; `install` names the two executables under its own `bin/`
subdirectory (`gitea-pr` and `gitea-issue`), which is what the Dockerfile
copies into the image; and the container gets `GITEA_API`, `GITEA_SSH_HOST`,
guard's host list; `install` names the executables under its own `bin/`
subdirectory (`gitea-pr`, `gitea-issue` and `gitea-actions`), which is what
the Dockerfile copies into the image; and the container gets `GITEA_API`, `GITEA_SSH_HOST`,
and `GITEA_TOKEN` (the *contents* of the file at `token_file`, not its path)
as environment variables.
@@ -265,11 +268,14 @@ see.
Only resolved values, never an unresolved manifest or a provider directory
itself:
- `DEJIMA_FORGE_HOSTS` - every hostname the selected forge needs reachable
(`host_keys`, the host part of `url_keys`, plus `fixed_hosts`), unioned by
`forge_hosts`.
- `DEJIMA_PUSH_HOSTS` - `DEJIMA_FORGE_HOSTS` when the workflow's `push = on`,
otherwise empty.
- `DEJIMA_FORGE_HOSTS` - every hostname the selected forge needs *added to
the firewall's allowlist* (`host_keys`, the host part of `url_keys`, plus
`fixed_hosts`), unioned by `forge_hosts`. Empty for a forge declaring
`hosts_allowlisted_by_core` - core already allows those, and adding them
here would widen them to every port and drop their DNS-rebind protection.
- `DEJIMA_PUSH_HOSTS` - every hostname `forge_hosts` resolves (including a
`hosts_allowlisted_by_core` forge's, which the entry above omits) when the
workflow's `push = on`, otherwise empty.
- `DEJIMA_BLOCKED_BRANCHES` - the workflow's `blocked_branches`.
- `DEJIMA_MERGE_BLOCK` - the forge's `merge_block`, but only when the
workflow's `block_merge = on`; otherwise empty.
@@ -291,33 +297,89 @@ both deliberate:
- `DEJIMA_MERGE_BLOCK` fails **open** the same way - empty makes
`merge-guard.sh` inert rather than conditionally installed.
## Validation, and what's deliberately deferred to Phase 3
## Validation
`provider_validate_selection` checks the selected forge/workflow/harness at
every launch: each manifest must validate, and a workflow's `requires` must
be a subset of the forge's `capabilities` - a workflow asking for a
capability the forge doesn't have is refused outright.
every launch:
Two more rules are **planned but not enforced yet**, and a fresh install
relies on that:
- Each manifest must validate.
- A workflow's `requires` must be a subset of the forge's `capabilities` - a
workflow asking for a capability the forge doesn't have is refused
outright.
- The workflow's `autonomy` must be something the forge can actually back up:
`autonomy = open-pr` needs a forge offering the `pr` capability; `autonomy
= push-branch` needs `push = on` (it can't stop at a push it never makes).
A mismatch is refused at launch rather than discovered by the agent
mid-task.
- A pushing workflow (`push = on`) needs a forge that resolves to somewhere
to push to. A wholly unconfigured forge with nothing to configure at all
(`forge = none`) is refused, pointing at a forge that needs no host
configured (`forge = github`) or a workflow that does not push; a forge
that has config keys but none of them resolve to a host is refused with
"configure it, or select `forge = github`, or `forge = none` with a
workflow that does not push".
- A forge whose `requires` keys are **partly** set - some configured, some
not (a key written but left empty, as `install.sh`'s commented-out starter
config makes easy, counts as not set: it resolves to nothing, and every
call the forge makes with it would fail inside the container) - is refused
as a likely typo, but only when the workflow actually
pushes: a non-pushing workflow never invokes the forge, so e.g. gitea's
`ssh_host` alone (set only so the firewall allows fetches) is a legitimate
configuration, not a typo. A wholly unconfigured forge is never flagged by
this rule - that's the case above.
- A pushing workflow whose forge has **none** of its required settings
configured is refused too, even when `fixed_hosts` means the "resolves to
no host" rule above never fires. This is what catches `github`: its single
`requires` key (`push_token_file`) means "partly configured" (the rule
above) can never trigger for it either - there's nothing to be partial
between - so without this rule a pushing workflow could select `github`
with no `push_token_file` set and launch anyway, `fixed_hosts` masking the
fact that nothing was actually configured.
- A pushing workflow whose forge resolves to no host at all is not refused.
Gitea's `ssh_host`/`api` have no schema default, and `install.sh` writes
them commented out, so a fresh install would be refused by this rule if it
existed today - while it launches fine as-is, with `git-push-guard.sh`
blocking every push at runtime (fail-closed, see above). An unconfigured
forge is a supported state ("skip this if you only push to GitHub" in the
README), not a bug, until Phase 3 adds `forge = none` as the honest way to
say so.
- A forge's `requires` keys being unset produces a **warning**, not a
refusal, and only when the forge is *partially* configured (some of
`requires` set, some not) - a wholly unconfigured forge stays silent, the
same supported case as above. A partial configuration is almost certainly
a typo, so flagging it costs nothing even before Phase 3 turns this into a
refusal alongside the rule above.
Two qualifications worth stating explicitly for the "partly configured" rule:
it only applies when the workflow's `push = on` (a non-pushing workflow never
touches the forge, so an incomplete `[forge.<name>]` section is harmless),
and a required key with a non-empty schema default - gitea's `token_file`,
defaulting to `~/.config/gitea/token` - never counts as missing, since its
resolved value is already correct even when the operator never wrote it.
## Providers not yet built
## Shipped providers
`forges/github`, `forges/none`, `workflows/branch-push` and
`workflows/local-only` are Phase 3 candidates. They do not exist in this
repo today - nothing selects them, and no manifest for them ships.
| axis | provider | notes |
| --- | --- | --- |
| forge | `gitea` | PRs and issues via `gitea-pr`/`gitea-issue`, shipped into the image |
| forge | `github` | PRs and issues via `gh`; needs its own write-capable `push_token_file`; opens `ssh.github.com:22` when the selected workflow pushes |
| forge | `none` | **default** - no capabilities, no host; pair with `local-only` |
| workflow | `worktree-pr` | worktree → push → PR, stops at the PR |
| workflow | `branch-push` | worktree → push, stops at the branch |
| workflow | `local-only` | **default** - commits locally, pushes nothing |
| harness | `claude-code` | **default**, and the only harness today |
**The shipped defaults are deliberately the non-pushing pair**
(`forge = none`, `workflow = local-only`). A fresh install has no forge host
and no forge credential, so any default naming a real forge would be an
assertion about the operator's setup that nothing has established - and,
because a pushing workflow whose forge resolves to no host is refused (see
[Validation](#validation)), such a default would make an unconfigured
`dejima` refuse to launch at all. The defaults instead say the one thing
that is true of every fresh install: this sandbox pushes nowhere yet.
Pushing is opted into by naming both a pushing workflow and a forge that
resolves.
**Selecting `forge = github` is a real posture change, not just a config
choice.** It opens `ssh.github.com:22` for push - the read-only GitHub access
every session gets regardless (clone, fetch, `gh` API calls over port 443)
never needs port 22 at all. It opens it through core's existing GitHub push
grant (`ALLOW_GITHUB_PUSH`, the same one `--allow-github-push` uses), which
its `hosts_allowlisted_by_core = on` is what asks for: port 22 to GitHub's
published ranges and nothing else, logged like every other allowed
connection and verified by the firewall's own startup self-check. Only when
the selected workflow actually pushes - `forge = github` with
`workflow = local-only` leaves port 22 shut. And `[forge.github] push_token_file` is
deliberately a *separate*, write-capable token from `[github] token_file`
(a read-only fine-grained PAT used for read access independent of which
forge is selected): promoting the read-only token to push duty would defeat
the point of scoping it read-only in the first place. Because `gh` only ever
reads one token variable, dejima warns at startup when both files are
present and non-empty - the write-capable `push_token_file` wins for that
launch, and the read-only `token_file` is not used while `forge = github`.
+111 -3
View File
@@ -10,6 +10,111 @@ at repositories you already trust. Everything below is about how bad a
compromised session *inside* that trust boundary can get, not a claim that
one can't happen.
## CI: push access is code execution elsewhere
The controls in this document bound what a session can reach *directly*. They
do not bound what it can arrange to have run somewhere else, and if the
project has CI, **granting a session push access grants it code execution on
whatever host that CI runs on.** No operator action is needed anywhere in the
chain: the push guard permits a feature branch, the briefing tells the agent
to open a pull request without asking, `on: pull_request` fires, and the
runner checks out that commit and executes it. The you-merge-not-the-agent
gate sits strictly downstream — by the time anyone reads the diff, it has
already run.
Nor does the code have to be a modified workflow file. A workflow that runs a
test suite runs every file in it, so a plausible-looking helper in a test file
is executed just as surely, and far more quietly, than an edited workflow
would be.
Relative to the sandbox, a run adds three things: **execution on a host you
own**, outside the container and outside the allowlist; **egress from a
network position you own**, with no firewall and no DNS proxy in front of it;
and **whatever credentials the run is handed**, including the token the forge
injects into every job whether the workflow asks for one or not.
There is deliberately no approval gate offered for this. Every version of one
— changing the trigger, dispatching by hand, leaving the runner stopped, the
forge's own fork-PR approvals, filtering egress from inside the workflow — is
reachable from the branch, covered by a credential the session already holds,
or not a gate at all. The mitigations are environmental instead: a runner host
you are willing to lose, job containers with no Docker socket, deny-by-default
egress whose allowlist is read from `main` rather than from the branch, and no
secret a run does not need. And where none of that is available, declining the
push grant declines the code execution with it — `[core] forge = none` with
`[core] workflow = local-only`.
This applies to any project developed in a dejima sandbox, not only to this
repository. **→ [docs/ci.md](ci.md)** for the reasoning and the setup.
## To your forge, the sandbox is you
By default a session authenticates with your credentials: your SSH key through
the forwarded agent, and a token generated under your account. A push, a pull
request, a comment or a merge made from inside the sandbox is therefore
**indistinguishable at the forge from one you made yourself** — not similar to
it, identical.
The consequence is worth stating flatly, because it is easy to configure
around without noticing: **every control your forge offers that keys on
identity is a guard rather than a boundary.** Branch protection's merge
whitelist, its push whitelist, required approvals, and the audit log all
answer the question "who did this", and there is only one answer available.
Putting yourself on a merge whitelist puts the sandbox on it too.
Three specific consequences:
- **Required approvals are not merely weak here, they are unusable.** A forge
will not let a pull request's author approve it, and the sandbox opens pull
requests as you — so requiring one approval means nobody can give it.
- **The forge's audit trail cannot attribute.** "You pushed this" and "a
session pushed this" are the same recorded event. This is the same shape as
the exit-time audit's time-window caveat above: a record of what happened,
not of who caused it.
- **It compounds the SSH agent's reach.** The agent already signs for any repo
your key can reach (below); the identity is what makes the resulting action
land as yours everywhere it reaches.
### Give the sandbox its own identity
This is configuration, not a missing feature. Create a dedicated forge account
with **write** access to the repositories a session should work on, and point
the sandbox at it instead of at you:
- `[forge.gitea] token_file` — that account's token, rather than one issued
under yours.
- `[core] ssh_key` — a deploy key belonging to it, rather than your forwarded
agent. See "Headless git push without agent forwarding" in the README.
With the two identities separated, the same settings stop being decorative:
| Setting | What it becomes |
| --- | --- |
| Merge whitelist: you | The session genuinely cannot merge |
| Push whitelist on `main`: you, or nobody | The session pushes feature branches and nothing else |
| Require approvals ≥ 1 | Usable — the session authors, you approve |
That last row is the one that turns "the operator merges, the agent does not"
from a convention into something the forge enforces.
**Verify which credential a push actually uses.** Nothing here enforces one:
`[core] ssh_key` is a second, independent credential path (below), so if your
own agent is *also* forwarded, a push may still be signed by your personal
key and the separate identity becomes decorative for exactly the operation it
was meant to constrain. Make a test push and read what the forge attributes
it to, rather than assuming.
Two things a separate identity does **not** do:
- It does not reduce what a compromised session can do inside the container,
or to the mounted project. It changes what that session can do *to your
forge*, and who the forge thinks did it.
- It does not touch CI. A write-capable account can still push a feature
branch, and pushing a branch is what makes CI execute code — see the
section above. It also has no bearing on the token the forge injects into a
CI job, which is a third principal again, subject to none of the settings in
that table. **→ [docs/ci.md](ci.md)**
## Provider trust
Forges, workflows and harnesses (`forges/`, `workflows/`, `harnesses/`) are
@@ -21,7 +126,8 @@ code. `bin/dejima` resolves the selected forge/workflow/harness **on the
host** and passes only the resolved results into the container - which
hosts pushes are allowed to, which branches are protected, the literal merge
command to block, plus the forge's own environment variables (a Gitea API
token, in the shipped forge's case) - never a manifest or a provider
token for `gitea`, a write-capable PAT for `github`, nothing for `none`) -
never a manifest or a provider
directory itself. The one thing a provider ships that does run is a forge's
`bin/`, copied into the image at build time as an ordinary program on
`PATH`, no different from any other CLI baked into it.
@@ -477,8 +583,10 @@ hands to whichever session starts against it. See
rather than repo-scoped, so a compromised session can still reach (and
push to) other repos on that host you have access to. This doesn't extend
to GitHub by default: port 22 there is blocked unless `--allow-github-push`
is given, so the forwarded agent has no transport to use for it at all
otherwise (see "GitHub push: blocked by default, optional opt-in" above).
is given, or `[core] forge = github` is selected with a workflow that
pushes (which opens the same grant), so the forwarded agent otherwise has
no transport to use for it at all (see "GitHub push: blocked by default,
optional opt-in" above).
- `[core] ssh_key` (see "Headless git push without agent forwarding") is a
second, independent credential-loading path with the same "signs for any
Gitea repo it has access to" shape as the forwarded agent above - the
+261
View File
@@ -0,0 +1,261 @@
#!/bin/bash
# Read Gitea Actions runs and job logs from a shell - the CI-side counterpart
# to gitea-pr and gitea-issue, and deliberately their twin rather than their
# sibling: all three are copied into the sandbox image as single
# self-contained files, and gitea-pr also lives in a dotfiles checkout, so a
# shared library would be a third thing to keep in step across three places.
# The duplicated config/token/curl preamble is pinned in all three by
# test/static.bats instead.
#
# Its reason to exist is a sandboxed agent's: CI is where the tests that
# cannot run in the sandbox actually run, and without this the only way to
# see why one failed is for the operator to paste the log in by hand.
set -euo pipefail
GITEA_TOKEN_FILE="${GITEA_TOKEN_FILE:-$HOME/.config/gitea/token}"
function usage() {
echo "Usage: gitea-actions runs [--limit N]"
echo " gitea-actions jobs [RUN]"
echo " gitea-actions logs [RUN] [--job NAME|INDEX] [--all] [--timestamps]"
echo ""
echo "RUN is the number in a run's web URL (.../actions/runs/52). Left out,"
echo "it means the newest run for HEAD's commit - after a push, that is"
echo "almost always the one being asked about."
echo ""
echo "'logs' prints the failed jobs of a run; --all prints every job, and"
echo "--job picks one by name (a substring is enough) or by index."
echo "Timestamps are stripped unless --timestamps is given."
echo ""
echo "Requires GITEA_SSH_HOST and GITEA_API set in the environment (e.g. in" \
"your shell profile), and a token at $GITEA_TOKEN_FILE (or GITEA_TOKEN" \
"set for this invocation only)."
}
function require_config() {
if [ -z "${GITEA_SSH_HOST:-}" ]; then
echo "GITEA_SSH_HOST is not set. Add 'export GITEA_SSH_HOST=<gitea ssh hostname>'" \
"(e.g. gitea.example.com) to ~/.profile.local" >&2
exit 1
fi
if [ -z "${GITEA_API:-}" ]; then
echo "GITEA_API is not set. Add 'export GITEA_API=https://<gitea host>/api/v1'" \
"(e.g. https://gitea.example.com/api/v1) to ~/.profile.local" >&2
exit 1
fi
}
function require_token() {
if [ -z "${GITEA_TOKEN:-}" ] && [ -f "$GITEA_TOKEN_FILE" ]; then
GITEA_TOKEN=$(<"$GITEA_TOKEN_FILE")
fi
if [ -z "${GITEA_TOKEN:-}" ]; then
echo "No Gitea token found. Generate one in the Gitea web UI" \
"(Settings > Applications, scopes write:repository + write:issue)" \
"and save it to $GITEA_TOKEN_FILE (chmod 600), or set GITEA_TOKEN" \
"in the environment for this invocation only." >&2
exit 1
fi
}
# Feeds the auth header to curl as a config on stdin rather than as an
# argument. Command lines are world-readable through /proc, so `-H
# "Authorization: token $GITEA_TOKEN"` exposes the token to every other
# process on the host for as long as the request runs - and to anything that
# scrapes `ps` output, including the agents this repo exists to sandbox.
# printf is a bash builtin, so the token never lands in another argv.
function gitea_api() {
printf 'header = "Authorization: token %s"\n' "$GITEA_TOKEN" |
curl -sS -w '\n%{http_code}' -K - "$@"
}
# Splits gitea_api's combined output into the globals `json` and `http_code`
# and fails with the body on any non-2xx.
function api_or_die() {
local what="$1"; shift
local response
response=$(gitea_api "$@")
http_code=$(echo "$response" | tail -1)
json=$(echo "$response" | sed '$d')
if [ "$http_code" -lt 200 ] || [ "$http_code" -ge 300 ]; then
echo "Failed to $what (HTTP $http_code):" >&2
echo "$json" >&2
exit 1
fi
}
function gitea_owner_repo() {
local remote_url host owner_repo
remote_url=$(git remote get-url origin 2>/dev/null) || {
echo "No 'origin' remote found in this repository" >&2
exit 1
}
host=$(echo "$remote_url" | sed -E 's#^[^@]+@([^:]+):.*#\1#')
if [ "$host" != "$GITEA_SSH_HOST" ]; then
echo "origin remote ($remote_url) is not on $GITEA_SSH_HOST; gitea-actions only supports this host" >&2
exit 1
fi
owner_repo=$(echo "$remote_url" | sed -E 's#^[^@]+@[^:]+:##; s#\.git$##')
echo "$owner_repo"
}
# The API addresses runs by an internal id; every URL a human has shows the
# per-repo run number instead, and the two are unrelated. Everything below
# takes the number and resolves it here, against one page of the run list.
#
# With no number, HEAD's commit: a run list is not what someone who just
# pushed is asking for. Matched on head_sha rather than on branch because a
# pull_request run reports no branch at all - its ref is refs/pull/N/head.
function resolve_run() {
local want="${1:-}" sha
api_or_die "list runs" "${GITEA_API}/repos/${owner}/${repo}/actions/runs?page=1&limit=$RUN_SCAN"
if [ -n "$want" ]; then
run_id=$(echo "$json" | jq -r --argjson n "$want" \
'first(.workflow_runs[] | select(.run_number == $n) | .id) // empty')
if [ -z "$run_id" ]; then
echo "No run numbered $want in the last $RUN_SCAN runs (raise --limit, or see 'gitea-actions runs')" >&2
exit 1
fi
else
sha=$(git rev-parse HEAD 2>/dev/null) || {
echo "Not in a git repository, so there is no HEAD to look up - pass a run number" >&2
exit 1
}
run_id=$(echo "$json" | jq -r --arg sha "$sha" \
'first(.workflow_runs[] | select(.head_sha == $sha) | .id) // empty')
if [ -z "$run_id" ]; then
echo "No run for HEAD (${sha:0:8}) in the last $RUN_SCAN runs - unpushed, or still queued?" >&2
exit 1
fi
fi
run_number=$(echo "$json" | jq -r --argjson id "$run_id" \
'first(.workflow_runs[] | select(.id == $id) | .run_number)')
}
# Sets `jobs_json` for the resolved run. The job index a URL shows is the
# tail of the job's html_url, not a field of its own, and the list does not
# arrive in that order.
function fetch_jobs() {
api_or_die "list jobs for run $run_number" \
"${GITEA_API}/repos/${owner}/${repo}/actions/runs/${run_id}/jobs"
jobs_json=$(echo "$json" | jq '[.jobs[] | {
index: (.html_url | split("/") | last | tonumber),
id, name,
result: (if .status == "completed" then .conclusion else .status end)
}] | sort_by(.index)')
}
function print_job_log() {
local id="$1" name="$2" result="$3"
echo "=== $name ($result) ==="
api_or_die "fetch logs for job $name" \
"${GITEA_API}/repos/${owner}/${repo}/actions/jobs/${id}/logs"
if [ "$KEEP_TIMESTAMPS" = 1 ]; then
echo "$json"
else
# Every line carries an RFC3339 stamp from the runner. Useful for
# timing a hang, pure noise for reading a failure, and this output
# is usually going into an agent's context.
echo "$json" | sed -E 's/^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9:.]+Z //'
fi
echo
}
RUN_SCAN=50
KEEP_TIMESTAMPS=0
if [ "$#" -lt 1 ]; then
usage
exit 1
fi
subcommand="$1"
shift
case "$subcommand" in
runs|jobs|logs)
require_config
require_token
owner_repo=$(gitea_owner_repo)
owner="${owner_repo%%/*}"
repo="${owner_repo##*/}"
;;
*)
usage
exit 1
;;
esac
case "$subcommand" in
runs)
limit=20
while [ "$#" -gt 0 ]; do
case "$1" in
--limit) limit="$2"; shift 2 ;;
*) echo "Unknown option: $1" >&2; usage; exit 1 ;;
esac
done
api_or_die "list runs" "${GITEA_API}/repos/${owner}/${repo}/actions/runs?page=1&limit=$limit"
echo "$json" | jq -r '.workflow_runs[] | [
(.run_number | tostring),
(if .status == "completed" then .conclusion else .status end),
.event,
(.head_sha[0:8]),
.display_title
] | @tsv' | column -t -s $'\t'
;;
jobs)
run_arg=""
if [[ "${1:-}" =~ ^[0-9]+$ ]]; then run_arg="$1"; shift; fi
if [ "$#" -gt 0 ]; then echo "Unknown option: $1" >&2; usage; exit 1; fi
resolve_run "$run_arg"
fetch_jobs
echo "run $run_number:"
echo "$jobs_json" | jq -r '.[] | [(.index | tostring), .result, .name] | @tsv' |
column -t -s $'\t'
;;
logs)
run_arg=""
job_want=""
want_all=0
if [[ "${1:-}" =~ ^[0-9]+$ ]]; then run_arg="$1"; shift; fi
while [ "$#" -gt 0 ]; do
case "$1" in
--job) job_want="$2"; shift 2 ;;
--all) want_all=1; shift ;;
--timestamps) KEEP_TIMESTAMPS=1; shift ;;
*) echo "Unknown option: $1" >&2; usage; exit 1 ;;
esac
done
resolve_run "$run_arg"
fetch_jobs
if [ -n "$job_want" ]; then
# An index is exact; anything else matches the name loosely, so
# `--job host-docker` finds "host-docker" and `--job static`
# finds "sandbox (static)".
if [[ "$job_want" =~ ^[0-9]+$ ]]; then
selected=$(echo "$jobs_json" | jq --argjson i "$job_want" '[.[] | select(.index == $i)]')
else
selected=$(echo "$jobs_json" | jq --arg n "$job_want" '[.[] | select(.name | contains($n))]')
fi
if [ "$(echo "$selected" | jq 'length')" -eq 0 ]; then
echo "No job matching '$job_want' in run $run_number:" >&2
echo "$jobs_json" | jq -r '.[] | " \(.index) \(.name)"' >&2
exit 1
fi
elif [ "$want_all" = 1 ]; then
selected="$jobs_json"
else
selected=$(echo "$jobs_json" | jq '[.[] | select(.result != "success")]')
if [ "$(echo "$selected" | jq 'length')" -eq 0 ]; then
echo "run $run_number: all $(echo "$jobs_json" | jq 'length') jobs succeeded (--all for their logs anyway)" >&2
exit 0
fi
fi
while IFS=$'\t' read -r id name result; do
print_job_log "$id" "$name" "$result"
done < <(echo "$selected" | jq -r '.[] | [(.id | tostring), .name, .result] | @tsv')
;;
esac
+1 -1
View File
@@ -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
+32
View 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
+5
View 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 =
+16 -1
View File
@@ -133,6 +133,21 @@ if [ -f "$decl_dir/dependencies" ]; then
# a leading dash (e.g. "--allow-unauthenticated") must not validate
# as a name, or this hook would echo into the agent's context an
# entry the project's own approve/status commands treat as rejected.
#
# A tarball spec carries a digest and a URL rather than a bare name,
# so it is matched separately and answered by whether its directory
# is there. Reporting one as missing has to print the whole spec, and
# that is deliberate: the digest is the part that makes the operator's
# approval mean something, so a truncated form would be asking them to
# approve something they cannot see.
if [[ "$spec" == tarball:* ]]; then
name="${spec#tarball:}"; name="${name%%@*}"
[[ "$name" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]] || continue
[ -d "/opt/dejima/$name" ] && continue
missing_deps_n=$((missing_deps_n + 1))
[ "$missing_deps_n" -le "$MAX_LISTED" ] && missing_deps="$missing_deps $spec"
continue
fi
[[ "$spec" =~ ^(apt|npm|pip|cargo):[A-Za-z0-9][A-Za-z0-9._+-]*$ ]] || continue
kind="${spec%%:*}"
name="${spec#*:}"
@@ -182,7 +197,7 @@ if [ -f "$decl_dir/allowed-domains" ]; then
Tell the user to run, outside the sandbox:
dejima-project approve \"$project_dir\"
(or, for one domain right now:
bin/dejima-allow <domain> --container $container_id)"
bin/dejima-allow --container $container_id <domain>)"
fi
fi
+83 -4
View File
@@ -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
+20
View File
@@ -1710,3 +1710,23 @@ sidecar() { local r; r="$(find "$AUDITDIR" -name '*.txt' | sort | tail -1)"; r="
run bash -c "printf 'q\n' | '$AUDIT' ack '$WS'"
[ -f "$sc/$(key_of '.git/hooks/pre-commit').diff" ]
}
@test "the report header names the writable mounts a session could reach" {
mkdir -p "$WS/.git/hooks"; printf 'a\n' > "$WS/.git/hooks/pre-commit"
snap
printf 'curl evil\n' > "$WS/.git/hooks/pre-commit"
run env DEJIMA_RW_MOUNTS="/srv/specs /srv/notes" "$AUDIT" compare "$WS" "$STATE"
[[ "$output" == *"/srv/specs"* ]]
[[ "$output" == *"/srv/notes"* ]]
# Named as reachable-but-unhashed, not reported as a finding: writing to
# one is the point of configuring it, so it must not read as an alert.
[[ "$output" != *"! /srv/specs"* ]]
}
@test "the report says nothing about writable mounts when there are none" {
mkdir -p "$WS/.git/hooks"; printf 'a\n' > "$WS/.git/hooks/pre-commit"
snap
printf 'curl evil\n' > "$WS/.git/hooks/pre-commit"
run "$AUDIT" compare "$WS" "$STATE"
[[ "$output" != *"writable"* ]]
}
+173
View File
@@ -0,0 +1,173 @@
#!/usr/bin/env bats
#
# Asserts on the RUNNER, not on this repository. Every other suite here tests
# dejima's code; this one tests the machine executing it, because the controls
# in docs/ci.md live in a runner's config.yaml and a host's firewall - set up
# by dejima-ci-hardener, on a machine this repository never touches, so no
# amount of reviewing this repository can check them.
#
# It exists because both failures that matter look exactly like success from
# inside a job. Nothing in a job's log mentions whether the Docker socket was
# mounted into it, and a job that can reach an off-list resolver still passes
# every check that only inspects DOCKER-USER. Neither shows up as a red run;
# they show up as nothing at all.
#
# DEJIMA_CI_TIER says which runner this is supposed to be, set by the workflow
# rather than sniffed, so landing on the wrong runner is a failure rather than
# a different set of assertions quietly being chosen. Unset means this is not
# a CI runner and there is nothing here to assert.
#
# Its values are the names of the jobs whose environment they verify -
# `sandbox` and `host-docker` - rather than a separate vocabulary. A reader
# looking at a red `runner-env (host-docker)` should not have to work out
# which job that corresponds to.
setup() {
[ -n "${DEJIMA_CI_TIER:-}" ] || skip "not on a CI runner (DEJIMA_CI_TIER unset)"
case "$DEJIMA_CI_TIER" in
sandbox|host-docker) ;;
*) echo "unknown DEJIMA_CI_TIER: $DEJIMA_CI_TIER" >&2; return 1 ;;
esac
}
# An off-list destination that would answer if it were reachable. This matters
# more than it looks: pointing these at an address that never answers - a
# documentation range, say - would pass in an environment with no network at
# all, which is the exact failure this suite exists to detect.
OFF_LIST_IP=1.1.1.1
# The positive control's destination. Overridable because it has to be a name
# THIS runner allows, and an allowlist is per-operator - hardcoding one would
# fail on a runner that is correctly configured for a different set.
ALLOWED_HOST=${DEJIMA_CI_ALLOWED_HOST:-deb.debian.org}
connects() { # connects HOST PORT -> 0 if a TCP connection completes
python3 - "$1" "$2" <<'PY'
import socket, sys
try:
socket.create_connection((sys.argv[1], int(sys.argv[2])), timeout=6).close()
except OSError:
sys.exit(1)
PY
}
# --- the grant that decides everything --------------------------------------
@test "the sandbox jobs are not handed the Docker daemon" {
[ "$DEJIMA_CI_TIER" = sandbox ] || skip "asserted for the sandbox runner"
# act_runner mounts /var/run/docker.sock into every job container unless
# container.docker_host is "-". An unset value is the default, and the
# default mounts - so this is the assertion that a runner was actually
# configured rather than merely intended.
[ ! -e /var/run/docker.sock ] || {
echo "this runner grants job containers the Docker socket, which is" >&2
echo "root-equivalent on the runner host. Set container.docker_host: \"-\"" >&2
echo "in its config.yaml - see dejima-ci-hardener." >&2
false
}
}
@test "the host-docker job does have the daemon it is scheduled for" {
[ "$DEJIMA_CI_TIER" = host-docker ] || skip "asserted for the host-docker runner"
# The positive control for the label routing. Without it, a host-docker
# job that landed on a hardened runner would fail later and obscurely, and
# a sandbox job that landed on this one would be caught only by the test
# above - one direction of a two-direction problem.
[ -e /var/run/docker.sock ]
}
@test "the two runners are not the same runner" {
# Whatever else is true, exactly one of them gets the daemon. If both jobs
# report the same answer here, the labels are not routing to two different
# runners and the tiering exists only on paper.
if [ "$DEJIMA_CI_TIER" = sandbox ]; then
[ ! -e /var/run/docker.sock ]
else
[ -e /var/run/docker.sock ]
fi
}
# --- egress, for the tier the rules actually apply to ------------------------
#
# Deliberately scoped to the sandbox runner. host-docker's job runs in the
# HOST network namespace - that is the grant they exist for - so the bridge
# rules dejima-ci-hardener installs do not sit in front of them at all. Their
# egress is the host's own, which this repository's examples do not define.
# Asserting an allowlist there would test nothing and pass anyway, which is
# the failure mode this whole suite is about.
@test "an allowlisted destination is reachable" {
[ "$DEJIMA_CI_TIER" = sandbox ] || skip "bridge rules do not apply in the host netns"
# The positive control for everything below. Without it, a runner with no
# network at all passes every negative assertion in this file.
run getent hosts "$ALLOWED_HOST"
[ "$status" -eq 0 ] || {
echo "$ALLOWED_HOST did not resolve, so the negative checks below" >&2
echo "would pass for the wrong reason. Check the resolver and the" >&2
echo "allowlist rendered from .dejima/allowed-domains on main - or set" >&2
echo "DEJIMA_CI_ALLOWED_HOST to a name this runner does allow." >&2
false
}
run connects "$ALLOWED_HOST" 80
[ "$status" -eq 0 ]
}
@test "a name that is on no allowlist does not resolve" {
[ "$DEJIMA_CI_TIER" = sandbox ] || skip "bridge rules do not apply in the host netns"
# .invalid is reserved by RFC 2606 precisely so that it never resolves
# anywhere - but here it has to be REFUSED by a resolver that answers for
# allowlisted names, which the positive control above establishes.
run getent hosts example.invalid
[ "$status" -ne 0 ]
}
@test "a job cannot reach a resolver of its own choosing" {
[ "$DEJIMA_CI_TIER" = sandbox ] || skip "bridge rules do not apply in the host netns"
# The check most easily left out, and the one that decides whether the DNS
# allowlist means anything: a job that can query any resolver it likes can
# resolve anything it likes, whatever dnsmasq is configured with. Traffic
# to the resolver on the bridge gateway is addressed to the host, so it is
# an INPUT packet and never passes through DOCKER-USER - filtering only
# DOCKER-USER leaves this open.
run connects "$OFF_LIST_IP" 53
[ "$status" -ne 0 ] || {
echo "reached $OFF_LIST_IP:53, so this job can use any resolver it likes and" >&2
echo "the DNS allowlist is decorative. The INPUT rules are missing -" >&2
echo "re-run dejima-ci-firewall on the runner host." >&2
false
}
}
@test "a destination that is on no allowlist is unreachable" {
[ "$DEJIMA_CI_TIER" = sandbox ] || skip "bridge rules do not apply in the host netns"
# An IP literal rather than a name, so this tests the firewall rather than
# the resolver. Reaching it by name would fail at DNS first and prove
# nothing about DOCKER-USER.
run connects "$OFF_LIST_IP" 443
[ "$status" -ne 0 ] || {
echo "reached $OFF_LIST_IP:443, so this job has unrestricted egress." >&2
echo "" >&2
echo "If the DNS checks above passed, the resolver is fine and the packet" >&2
echo "filter is not - which usually means DOCKER-USER is empty. Docker" >&2
echo "recreates its own chains on every daemon start, so a 'systemctl" >&2
echo "restart docker' drops these rules and nothing looks different" >&2
echo "afterwards. Check with: iptables -S DOCKER-USER" >&2
echo "" >&2
echo "dejima-ci-hardener's dejima-ci-firewall.service reapplies them on" >&2
echo "every Docker start, which is the fix rather than a one-off re-run." >&2
false
}
}
@test "IPv6 egress is not an unfiltered way around all of the above" {
[ "$DEJIMA_CI_TIER" = sandbox ] || skip "bridge rules do not apply in the host netns"
# An address family with no allowlist is not a smaller hole than the one
# the rules above close; on a runner with IPv6 egress it is the same hole.
#
# Unlike the checks above, this one passes for two different reasons - v6
# rejected, or v6 never configured - and both are acceptable outcomes, so
# it is not given a positive control. What it rules out is the third case:
# a runner with working IPv6 egress and rules written only for v4.
run connects 2606:4700:4700::1111 443
[ "$status" -ne 0 ]
}
+243 -16
View File
@@ -464,6 +464,18 @@ write_config() {
[[ "$output" == *"invalid value"* ]]
}
@test "an invalid [core] forge reports one error, not two" {
# config_get already prints "invalid value for [core] forge ..." and
# returns empty; the collision-warning pass right after it used to feed
# that empty value on to manifest_get, which failed a second time with
# its own "invalid forge name:" line - noise on top of the real error.
write_config '[core]' 'forge = not-a-real-forge'
run "$REPO_ROOT/bin/dejima" --print-config
[ "$status" -ne 0 ]
[[ "$output" == *"invalid value for [core] forge"* ]]
[[ "$output" != *"invalid forge name"* ]]
}
@test "--print-config does not need Docker or a workspace argument" {
write_config '[core]' 'firewall = on'
PATH="/nonexistent:$PATH" run "$REPO_ROOT/bin/dejima" --print-config
@@ -484,7 +496,12 @@ write_config() {
key="$FAKE_HOME/sandbox-key"
: > "$key"
chmod 600 "$key"
write_config '[core]' "ssh_key = $key" 'command = --evil'
# forge = none, workflow = local-only: provider_validate_selection runs
# ahead of this check, and the default gitea/worktree-pr pair is refused
# unconfigured (see providers.bats) - this test is about core.command,
# not about forge selection, so it sidesteps that with the honest
# "no forge, no push" pair instead of configuring one just to get past it.
write_config '[core]' "ssh_key = $key" 'command = --evil' 'forge = none' 'workflow = local-only'
workspace="$(mktemp -d)"
HOME="$FAKE_HOME" SSH_AUTH_SOCK= run "$REPO_ROOT/bin/dejima" "$workspace"
[ "$status" -ne 0 ]
@@ -673,7 +690,7 @@ write_config() {
key="$FAKE_HOME/sandbox-key"
: > "$key"
chmod 600 "$key"
write_config '[core]' "ssh_key = $key"
write_config '[core]' "ssh_key = $key" 'forge = none' 'workflow = local-only'
workspace="$(mktemp -d)"
STUB_DIR="$(mktemp -d)"
cat > "$STUB_DIR/docker" <<'EOF'
@@ -704,7 +721,8 @@ EOF
: > "$key"
chmod 600 "$key"
write_config '[core]' "ssh_key = $key" \
'[forge.gitea]' 'token_file = /nonexistent/gitea-token'
'[forge.gitea]' 'ssh_host = git.example' 'api = https://api.example/v1' \
'token_file = /nonexistent/gitea-token'
workspace="$(mktemp -d)"
STUB_DIR="$(mktemp -d)"
cat > "$STUB_DIR/docker" <<'EOF'
@@ -734,6 +752,182 @@ EOF
[[ "$output" == *"set the path, not the token"* ]]
}
@test "selecting the github forge warns that [github] token_file is unused" {
local tok="$CONFIG_DIR/push-token"
printf 'ghp_example\n' > "$tok"
local ro="$CONFIG_DIR/ro-token"
printf 'ghp_readonly\n' > "$ro"
write_config '[core]' 'forge = github' 'workflow = branch-push' \
'[github]' "token_file = $ro" \
'[forge.github]' "push_token_file = $tok"
run "$REPO_ROOT/bin/dejima" --print-config
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[[ "$output" == *"[github] token_file is not used"* ]]
[[ "$output" == *"push_token_file"* ]]
}
@test "the github token_file warning is silent for any other forge" {
write_config '[core]' 'forge = gitea' '[github]' "token_file = $CONFIG_DIR/ro"
run "$REPO_ROOT/bin/dejima" --print-config
[ "$status" -eq 0 ]
[[ "$output" != *"is not used"* ]]
}
@test "the github token_file warning is silent when push_token_file is not also set" {
# The warning claims GH_TOKEN comes from push_token_file - false unless
# that is actually configured too. Wholly unconfigured (push_token_file
# unset) is the same silent case provider_validate_selection's own
# partial-config refusal defers to, not something to say out loud here.
local ro="$CONFIG_DIR/ro-token"
printf 'ghp_readonly\n' > "$ro"
write_config '[core]' 'forge = github' "[github]" "token_file = $ro"
run "$REPO_ROOT/bin/dejima" --print-config
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[[ "$output" != *"is not used"* ]]
}
@test "the github forge with no push_token_file leaves the read-only GH_TOKEN alone at launch" {
# Finding 1 regression: forge_env_exports used to emit `GH_TOKEN=` (empty)
# whenever push_token_file was unset, and `-e GH_TOKEN=` on `docker
# compose run` overrides docker-compose.yml's own `environment: GH_TOKEN:
# ${GH_TOKEN:-}` entry - the one carrying the independent read-only
# [github] token_file value every session is supposed to get regardless
# of forge. Asserted on the actual argument list handed to `docker`, not
# on a warning, so this catches the container-facing symptom directly.
FAKE_HOME="$(mktemp -d)"
mkdir -p "$FAKE_HOME/.claude"
echo '{}' > "$FAKE_HOME/.claude/settings.json"
local ro="$CONFIG_DIR/ro-token"
printf 'ghp_readonly\n' > "$ro"
write_config '[core]' 'forge = github' 'workflow = local-only' \
'[github]' "token_file = $ro"
workspace="$(mktemp -d)"
STUB_DIR="$(mktemp -d)"
cat > "$STUB_DIR/docker" <<'EOF'
#!/bin/sh
for a in "$@"; do printf '%s\n' "$a" >> "$SANDBOX_TEST_DOCKER_LOG"; done
exit 0
EOF
chmod +x "$STUB_DIR/docker"
docker_log="$(mktemp)"
HOME="$FAKE_HOME" PATH="$STUB_DIR:$PATH" SSH_AUTH_SOCK= \
SANDBOX_TEST_DOCKER_LOG="$docker_log" \
run "$REPO_ROOT/bin/dejima" "$workspace"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
! grep -qxF 'GH_TOKEN=' "$docker_log" \
|| { echo "GH_TOKEN= reached docker, clobbering the read-only token"; cat "$docker_log"; return 1; }
rm -rf "$FAKE_HOME" "$workspace" "$docker_log"
}
# Records both the environment `docker` is invoked with and its full
# argument list - the two places a resolved DEJIMA_*/ALLOW_* value can reach
# the container, and the only observable evidence of the firewall posture a
# launch actually asks for without booting one (test/runtime.bats has the
# other half, and needs Docker).
stub_docker_log() {
STUB_DIR="$(mktemp -d)"
cat > "$STUB_DIR/docker" <<'EOF'
#!/bin/sh
echo "ALLOW_GITHUB_PUSH=$ALLOW_GITHUB_PUSH" >> "$SANDBOX_TEST_DOCKER_LOG"
for a in "$@"; do printf '%s\n' "$a" >> "$SANDBOX_TEST_DOCKER_LOG"; done
exit 0
EOF
chmod +x "$STUB_DIR/docker"
}
@test "the github forge's hosts reach the push guard, never the extra-domains allowlist" {
# entrypoint.sh folds DEJIMA_FORGE_HOSTS into EXTRA_ALLOWED_DOMAINS,
# which init-firewall.sh resolves into the port-UNRESTRICTED
# allowed-domains ipset and dns-proxy.sh exempts from stop-dns-rebind.
# Right for a self-hosted forge host, a silent widening for GitHub's
# public names - and the firewall's own startup self-check refuses to
# bring the container up when github.com:22 is reachable without the
# push grant, so this also used to be a container that never started.
FAKE_HOME="$(mktemp -d)"
mkdir -p "$FAKE_HOME/.claude"
echo '{}' > "$FAKE_HOME/.claude/settings.json"
local tok="$CONFIG_DIR/push-token"
printf 'ghp_example\n' > "$tok"
# A pushing workflow refuses to launch without a push credential; a
# dedicated key file is the headless form of that, and keeps this test
# independent of whether the host running it has an agent.
local key="$FAKE_HOME/sandbox-key"
: > "$key"
chmod 600 "$key"
write_config '[core]' 'forge = github' 'workflow = worktree-pr' \
"ssh_key = $key" \
'[forge.github]' "push_token_file = $tok"
workspace="$(mktemp -d)"
stub_docker_log
docker_log="$(mktemp)"
HOME="$FAKE_HOME" PATH="$STUB_DIR:$PATH" SSH_AUTH_SOCK= \
SANDBOX_TEST_DOCKER_LOG="$docker_log" \
run "$REPO_ROOT/bin/dejima" "$workspace"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
# Nothing of this forge's in the extra-domains list at all.
grep -qxF 'DEJIMA_FORGE_HOSTS=' "$docker_log" \
|| { echo "DEJIMA_FORGE_HOSTS was not empty:"; grep '^DEJIMA_FORGE_HOSTS' "$docker_log"; return 1; }
# But the guard still knows them, or the push it is told to make is
# refused inside the container.
grep -qxF 'DEJIMA_PUSH_HOSTS=github.com ssh.github.com api.github.com' "$docker_log" \
|| { echo "push hosts wrong:"; grep '^DEJIMA_PUSH_HOSTS' "$docker_log"; return 1; }
# And port 22 opens through the documented, logged, self-checked path.
grep -qxF 'ALLOW_GITHUB_PUSH=1' "$docker_log" \
|| { echo "the push grant was not opened"; return 1; }
rm -rf "$FAKE_HOME" "$workspace" "$docker_log"
}
@test "the github forge with a non-pushing workflow leaves port 22 shut" {
# The grant is tied to the workflow actually pushing, not to the forge
# being selected - local-only never pushes, so there is nothing to open.
FAKE_HOME="$(mktemp -d)"
mkdir -p "$FAKE_HOME/.claude"
echo '{}' > "$FAKE_HOME/.claude/settings.json"
local tok="$CONFIG_DIR/push-token"
printf 'ghp_example\n' > "$tok"
write_config '[core]' 'forge = github' 'workflow = local-only' \
'[forge.github]' "push_token_file = $tok"
workspace="$(mktemp -d)"
stub_docker_log
docker_log="$(mktemp)"
HOME="$FAKE_HOME" PATH="$STUB_DIR:$PATH" SSH_AUTH_SOCK= \
SANDBOX_TEST_DOCKER_LOG="$docker_log" \
run "$REPO_ROOT/bin/dejima" "$workspace"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
grep -qxF 'ALLOW_GITHUB_PUSH=0' "$docker_log" \
|| { echo "the push grant was opened for a non-pushing workflow"; return 1; }
grep -qxF 'DEJIMA_PUSH_HOSTS=' "$docker_log"
grep -qxF 'DEJIMA_FORGE_HOSTS=' "$docker_log"
rm -rf "$FAKE_HOME" "$workspace" "$docker_log"
}
@test "a self-hosted forge's host still takes the extra-domains path" {
# The other side of the same manifest key: a forge that does NOT declare
# hosts_allowlisted_by_core keeps the behaviour that path exists for -
# its host resolved into the allowlist, and no GitHub push grant.
FAKE_HOME="$(mktemp -d)"
mkdir -p "$FAKE_HOME/.claude"
echo '{}' > "$FAKE_HOME/.claude/settings.json"
local key="$FAKE_HOME/sandbox-key"
: > "$key"
chmod 600 "$key"
write_config '[core]' 'forge = gitea' 'workflow = branch-push' \
"ssh_key = $key" \
'[forge.gitea]' 'ssh_host = git.example' 'api = https://api.example/v1' \
'token_file = /dev/null'
workspace="$(mktemp -d)"
stub_docker_log
docker_log="$(mktemp)"
HOME="$FAKE_HOME" PATH="$STUB_DIR:$PATH" SSH_AUTH_SOCK= \
SANDBOX_TEST_DOCKER_LOG="$docker_log" \
run "$REPO_ROOT/bin/dejima" "$workspace"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
grep -qxF 'DEJIMA_FORGE_HOSTS=git.example api.example' "$docker_log" \
|| { echo "forge hosts wrong:"; grep '^DEJIMA_FORGE_HOSTS' "$docker_log"; return 1; }
grep -qxF 'ALLOW_GITHUB_PUSH=0' "$docker_log"
rm -rf "$FAKE_HOME" "$workspace" "$docker_log"
}
@test "logging.loki_url reaches the launch environment" {
FAKE_HOME="$(mktemp -d)"
mkdir -p "$FAKE_HOME/.claude"
@@ -741,7 +935,7 @@ EOF
key="$FAKE_HOME/sandbox-key"
: > "$key"
chmod 600 "$key"
write_config '[core]' "ssh_key = $key" \
write_config '[core]' "ssh_key = $key" 'forge = none' 'workflow = local-only' \
'[logging]' 'loki_url = https://loki.example.invalid'
workspace="$(mktemp -d)"
STUB_DIR="$(mktemp -d)"
@@ -801,6 +995,24 @@ run_install() {
[ -f "$CONFIG_DIR/remote.conf" ]
}
@test "install.sh reports the changed forge/workflow defaults to a config that relies on them" {
# The defaults moved from gitea + worktree-pr to none + local-only, which
# silently stops an existing sandbox pushing. Same stance as the
# remote.conf notice above: a config whose meaning changed under the
# operator gets told so, with the lines that restore the old behaviour.
write_config '[core]' 'firewall = on'
run_install
[[ "$output" == *"forge = gitea"* ]]
[[ "$output" == *"workflow = worktree-pr"* ]]
[[ "$output" == *"local-only"* ]]
}
@test "install.sh stays quiet about the defaults once forge and workflow are explicit" {
write_config '[core]' 'forge = none' 'workflow = local-only'
run_install
[[ "$output" != *"have changed"* ]]
}
@test "the README documents the config file, not the retired variables" {
# The retired variables must be gone from the setup instructions...
run grep -nE '^\| `(SANDBOX_CMD|SANDBOX_EXTRA_MOUNTS|SANDBOX_NVIM_CONFIG|SANDBOX_SHARE_DIR|SANDBOX_CREDENTIAL_PROXY|LOKI_URL|ENABLE_FIREWALL|GITEA_SSH_HOST|GITEA_API|GITEA_TOKEN)`' \
@@ -932,7 +1144,7 @@ EOF
rw="$(mktemp -d)"
ro="$(mktemp -d)"
workspace="$(mktemp -d)"
write_config '[core]' "ssh_key = $RW_KEY" \
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only' \
'[mounts]' "read_write = $rw" "readonly = $ro"
_rw_launch "$workspace"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
@@ -957,7 +1169,12 @@ EOF
# though an actual session was told correctly. briefing_compose now
# reads the same DEJIMA_RW_MOUNTS a real launch would.
rw="$(mktemp -d)"
write_config '[mounts]' "read_write = $rw"
# forge = none, workflow = local-only: --print-briefing now runs after
# provider_validate_selection too, and the default gitea/worktree-pr pair
# is refused unconfigured - this test is about the mounts sentence, not
# forge selection.
write_config '[core]' 'forge = none' 'workflow = local-only' \
'[mounts]' "read_write = $rw"
run "$REPO_ROOT/bin/dejima" --print-briefing
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[[ "$output" == *"outlive the container too: $rw"* ]]
@@ -972,7 +1189,8 @@ EOF
# rule never runs, which would pass this test for the wrong reason.
mkdir -p "$CONFIG_DIR" "$RW_HOME/.claude" "$RW_HOME/.local/bin"
for guarded in "$CONFIG_DIR" "$RW_HOME/.claude" "$RW_HOME/.local/bin" "$REPO_ROOT"; do
write_config '[core]' "ssh_key = $RW_KEY" '[mounts]' "read_write = $guarded"
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only' \
'[mounts]' "read_write = $guarded"
_rw_launch "$workspace"
[ "$status" -ne 0 ] || { echo "accepted a writable mount over $guarded"; return 1; }
[[ "$output" == *"Refusing"* ]] \
@@ -980,13 +1198,15 @@ EOF
done
# The workspace is already mounted read-write; naming it again is a config
# error worth surfacing, not a harmless duplicate.
write_config '[core]' "ssh_key = $RW_KEY" '[mounts]' "read_write = $workspace"
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only' \
'[mounts]' "read_write = $workspace"
_rw_launch "$workspace"
[ "$status" -ne 0 ]
[[ "$output" == *"Refusing"* ]]
# A bare home directory contains several guarded paths, so the same
# both-directions rule catches it without needing an entry of its own.
write_config '[core]' "ssh_key = $RW_KEY" '[mounts]' "read_write = $RW_HOME"
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only' \
'[mounts]' "read_write = $RW_HOME"
_rw_launch "$workspace"
[ "$status" -ne 0 ]
[[ "$output" == *"Refusing"* ]]
@@ -1003,6 +1223,7 @@ EOF
workspace="$(mktemp -d)"
mkdir -p "$RW_HOME/data"
write_config '[core]' "ssh_key = $RW_KEY" "share_dir = $RW_HOME/share" \
'forge = none' 'workflow = local-only' \
'[audit]' "dir = $RW_HOME/data/store/audit" \
'[mounts]' "read_write = $RW_HOME/data"
[ ! -e "$RW_HOME/data/store" ]
@@ -1022,7 +1243,7 @@ EOF
# overlap rule under test never runs.
mkdir -p "$store/audit"
for key in dir baselines_dir; do
write_config '[core]' "ssh_key = $RW_KEY" \
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only' \
'[audit]' "$key = $store/audit" \
'[mounts]' "read_write = $store/audit"
_rw_launch "$workspace"
@@ -1038,7 +1259,7 @@ EOF
_rw_setup
workspace="$(mktemp -d)"
outer="$(mktemp -d)"
write_config '[core]' "ssh_key = $RW_KEY" \
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only' \
'[audit]' "dir = $outer/nested/audit" \
'[mounts]' "read_write = $outer"
_rw_launch "$workspace"
@@ -1055,7 +1276,7 @@ EOF
workspace="$(mktemp -d)"
for spelling in "/" "/." "//"; do
d="$(mktemp -d)"
write_config '[core]' "ssh_key = $RW_KEY" \
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only' \
'[mounts]' "readonly = $d" "read_write = ${d}${spelling}"
_rw_launch "$workspace"
[ "$status" -ne 0 ] || { echo "both lists accepted '$d' vs '${d}${spelling}'"; return 1; }
@@ -1070,7 +1291,8 @@ EOF
_rw_setup
d="$(mktemp -d)"
workspace="$(mktemp -d)"
write_config '[core]' "ssh_key = $RW_KEY" '[mounts]' "read_write = $d" "readonly = $d"
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only' \
'[mounts]' "read_write = $d" "readonly = $d"
_rw_launch "$workspace"
[ "$status" -ne 0 ]
[[ "$output" == *"both name the same"* ]]
@@ -1080,7 +1302,8 @@ EOF
@test "a read_write entry that is not an existing directory is refused" {
_rw_setup
workspace="$(mktemp -d)"
write_config '[core]' "ssh_key = $RW_KEY" '[mounts]' 'read_write = /nonexistent/nowhere'
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only' \
'[mounts]' 'read_write = /nonexistent/nowhere'
_rw_launch "$workspace"
[ "$status" -ne 0 ]
[[ "$output" == *"not an existing directory"* ]]
@@ -1110,8 +1333,12 @@ EOF
# exercises the real launch path to verify the refusal actually stops it.
_rw_setup
workspace="$(mktemp -d)"
# Configure gitea forge with a valid config plus a token file with a newline.
printf '[core]\nfirewall = on\nssh_key = %s\n[forge.gitea]\nssh_host = git.example\napi = https://api.example\ntoken_file = %s/token\n' \
# Configure gitea forge with a valid config plus a token file with a
# newline. forge/workflow are named explicitly because this test is about
# a gitea token reaching the container: the shipped defaults select no
# forge at all, so leaving them implicit would mean nothing ever reads
# token_file and the refusal under test could never fire.
printf '[core]\nfirewall = on\nssh_key = %s\nforge = gitea\nworkflow = worktree-pr\n[forge.gitea]\nssh_host = git.example\napi = https://api.example\ntoken_file = %s/token\n' \
"$RW_KEY" "$CONFIG_DIR" > "$CONFIG_DIR/config"
printf 'real-secret\ngarbage\n' > "$CONFIG_DIR/token"
STUB_DIR="$(mktemp -d)"
+846 -61
View File
File diff suppressed because it is too large Load Diff
+382 -26
View File
@@ -12,6 +12,21 @@ teardown() { rm -rf "$CONFIG_DIR" ${FAKE_ROOT:+"$FAKE_ROOT"}; }
lib() { bash -c 'source "$1"; shift; "$@"' _ "$LIB" "$@"; }
# Checks the CLAIM a remedy message makes rather than pinning its wording:
# every "forge = <name>" it names must be a forge that actually exists. A
# literal like "forge = github" would still pass this after github was
# renamed or removed, silently advising a value that no longer works.
assert_remedy_forges_exist() {
local text="$1" real name found=0
real="$(lib provider_list forge)"
while IFS= read -r name; do
[ -n "$name" ] || continue
found=1
grep -qxF "$name" <<< "$real" || { echo "remedy names nonexistent forge: $name"; return 1; }
done < <(grep -oE 'forge = [a-z0-9-]+' <<< "$text" | awk '{print $3}')
[ "$found" -eq 1 ] || { echo "remedy names no forge at all"; return 1; }
}
@test "every shipped manifest validates" {
for axis in forge workflow harness; do
while IFS= read -r name; do
@@ -172,54 +187,124 @@ lib() { bash -c 'source "$1"; shift; "$@"' _ "$LIB" "$@"; }
[[ "$output" == *"needy"* ]]
}
@test "a pushing workflow on a forge with no hosts is not refused - rule 3 is deferred to Phase 3" {
@test "a pushing workflow on a forge with no hosts is refused" {
# gitea's ssh_host/api have no schema default and install.sh writes them
# commented out, so refusing this would refuse a fresh install - see the
# comment above provider_validate_selection's push check. Phase 3 adds
# `forge = none` and this becomes a refusal then.
# commented out, so this is what an operator who selects a pushing
# workflow without configuring the forge hits - `forge = none` (the
# shipped default) is the honest way to say "no forge" instead.
FAKE_ROOT="$(mktemp -d)"
mkdir -p "$FAKE_ROOT/forges/hostless" "$FAKE_ROOT/workflows/pusher" "$FAKE_ROOT/harnesses/h"
printf 'capabilities =\n' > "$FAKE_ROOT/forges/hostless/forge.conf"
# keys is non-empty - this forge IS configurable, just not configured -
# unlike `none`, which has no keys at all and gets its own message below.
printf 'capabilities =\nkeys = ssh_host:?hostname\n' > "$FAKE_ROOT/forges/hostless/forge.conf"
printf 'requires =\npush = on\n' > "$FAKE_ROOT/workflows/pusher/workflow.conf"
printf 'command = claude\n' > "$FAKE_ROOT/harnesses/h/harness.conf"
run env DEJIMA_PROVIDER_ROOT="$FAKE_ROOT" \
bash -c 'source "$1"; provider_validate_selection hostless pusher h' _ "$LIB"
[ "$status" -ne 0 ]
[[ "$output" == *"forge = none"* ]]
}
@test "a pushing workflow against a forge with no host is refused, and names none" {
# gitea with nothing configured resolves to no host at all.
printf '[core]\nfirewall = on\n' > "$CONFIG_DIR/config"
run lib provider_validate_selection gitea worktree-pr claude-code
[ "$status" -ne 0 ]
[[ "$output" == *"forge = none"* ]]
}
@test "a partly configured forge is refused, not merely warned about" {
# token_file is not named here (and would not need to be, to make this
# partial): its schema default (~/.config/gitea/token) means it can
# never be genuinely missing, so it never enters set_n/unset_n at all -
# see "the shipped selection validates with the documented setup" below.
printf '[forge.gitea]\nssh_host = git.example\n' > "$CONFIG_DIR/config"
run lib provider_validate_selection gitea worktree-pr claude-code
[ "$status" -ne 0 ]
[[ "$output" == *"api"* ]]
}
@test "the shipped defaults are a non-pushing pair that validates with no forge configured" {
# A fresh install must launch. The defaults were gitea + worktree-pr,
# which is a PUSHING pair, and rule 3 refuses a pushing workflow whose
# forge resolves to no host - so an unedited config was refused at every
# launch until a forge was configured. Driven off the schema rather than
# the literal names, so a future default that reintroduces the
# contradiction fails here, next to the rule it contradicts, instead of
# in twenty-odd launcher tests that never mention forges.
printf '[core]\nfirewall = on\n' > "$CONFIG_DIR/config"
local forge workflow harness
forge="$(lib config_default core.forge)"
workflow="$(lib config_default core.workflow)"
harness="$(lib config_default core.harness)"
[ "$(lib manifest_get workflow "$workflow" push)" = "off" ] \
|| { echo "default workflow '$workflow' pushes - a fresh install has no forge to push to"; return 1; }
run lib provider_validate_selection "$forge" "$workflow" "$harness"
[ "$status" -eq 0 ] || { echo "the shipped defaults do not validate unconfigured: $output"; return 1; }
}
@test "a non-pushing workflow against an unconfigured forge is still fine" {
# local-only pushes nothing, so having no host is not a contradiction.
printf '[core]\nfirewall = on\n' > "$CONFIG_DIR/config"
run lib provider_validate_selection none local-only claude-code
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
}
@test "the shipped selection validates" {
# provider_validate_selection runs after bin/dejima's own config_exists
# gate in real use, so a config file is always present by then - gitea's
# requires (ssh_host, api) need real values here for the same reason.
@test "the shipped selection validates with the documented setup" {
# This is the regression guard for the README/install.sh setup: an
# operator is told to set only ssh_host and api and save the token at
# token_file's schema default path (~/.config/gitea/token), leaving
# token_file itself unset in the config. A key with a non-empty schema
# default can never be genuinely missing - its resolved value is already
# correct - so it must not make this look partly configured and refused.
printf '[forge.gitea]\nssh_host = git.example\napi = https://api.example/v1\n' \
> "$CONFIG_DIR/config"
run lib provider_validate_selection gitea worktree-pr claude-code
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
}
@test "a wholly unconfigured forge produces no warning and still validates" {
# The supported "I don't use a forge" case (rule 3's comment, README
# "Skip this if you only push to GitHub") - a test asserting only the
# warning below would pass against a version that refused this instead.
# gitea's requires includes token_file, which has a schema default
# (~/.config/gitea/token) and so ALWAYS resolves to a non-empty value
# via config_get, even though nothing here sets it - this must still
# count as "unset" (config_is_set, not config_get) or a config that sets
# none of gitea's keys would look partly configured and warn.
@test "a wholly unconfigured forge still validates against a non-pushing workflow" {
# The supported "I don't use a forge" case is now scoped to a workflow
# that doesn't push (local-only) - a pushing one is rule 3's refusal
# above. Rules 3 and 5 (and the single-required-key check) are all
# gated on push = on, so local-only never reaches any of them regardless
# of what is or isn't configured.
printf '[core]\nfirewall = on\n' > "$CONFIG_DIR/config"
run lib config_default forge.gitea.token_file
[ -n "$output" ] || { echo "test premise broken: token_file has no default"; return 1; }
run lib provider_validate_selection gitea worktree-pr claude-code
run lib provider_validate_selection gitea local-only claude-code
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[[ "$output" != *"Warning"* ]] || { echo "unexpected warning: $output"; return 1; }
}
@test "a partially configured forge warns but still validates" {
@test "a partially configured forge still validates against a non-pushing workflow" {
# Rule 5 is gated on push = on: local-only never invokes the forge, so
# setting only ssh_host (e.g. so the firewall allows fetches) is a
# legitimate configuration, not a typo to refuse.
printf '[forge.gitea]\nssh_host = git.example\n' > "$CONFIG_DIR/config"
run lib provider_validate_selection gitea worktree-pr claude-code
run lib provider_validate_selection gitea local-only claude-code
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[[ "$output" == *"partly configured"* ]]
[[ "$output" == *"api"* ]]
}
@test "a pushing workflow against forge = none names the workflow, not [forge.none]" {
# none has no keys at all, so "Configure [forge.none]" would be a fix
# that does not exist - the only honest remedy is the workflow.
printf '[core]\nfirewall = on\n' > "$CONFIG_DIR/config"
run lib provider_validate_selection none branch-push claude-code
[ "$status" -ne 0 ]
[[ "$output" == *"nothing to configure"* ]]
[[ "$output" == *"workflow = local-only"* ]]
[[ "$output" != *"[forge.none]"* ]]
}
@test "a pushing workflow against a single-required-key forge with nothing set is refused" {
# github's fixed_hosts (github.com et al) are pinned independent of
# config, so forge_hosts is never empty and rule 3 alone would let a
# wholly unconfigured github sail through. github's `requires` is also
# exactly one key, so it can never be "partly" configured (rule 5's own
# condition) - it is either wholly set or wholly unset. This is the gap
# between the two rules that only a dedicated check closes.
printf '[core]\nfirewall = on\nforge = github\nworkflow = branch-push\n' > "$CONFIG_DIR/config"
run lib provider_validate_selection github branch-push claude-code
[ "$status" -ne 0 ]
[[ "$output" == *"push_token_file"* ]]
}
@test "forge_hosts unions literal host keys, URL-derived hosts and fixed hosts" {
@@ -404,6 +489,29 @@ lib() { bash -c 'source "$1"; shift; "$@"' _ "$LIB" "$@"; }
[ "$output" = "0" ]
}
@test "the none forge declares no capabilities, keys or hosts" {
run lib manifest_validate forge none
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[ -z "$(lib manifest_get forge none capabilities)" ]
[ -z "$(lib manifest_get forge none keys)" ]
[ -z "$(lib manifest_get forge none requires)" ]
# No host sources at all - this is what makes it the honest "I have no
# forge" answer rather than a forge that merely isn't configured yet.
[ -z "$(lib forge_hosts none)" ]
[ -z "$(lib forge_env_exports none)" ]
}
@test "the none forge ships no CLIs into the image" {
[ -z "$(lib manifest_get forge none install)" ]
[ ! -d "$REPO_ROOT/forges/none/bin" ]
}
@test "a workflow needing a capability is refused against the none forge" {
run lib provider_validate_selection none worktree-pr claude-code
[ "$status" -ne 0 ]
[[ "$output" == *"requires the 'pr' capability"* ]]
}
@test "docs/providers.md documents every manifest key" {
for axis in forge workflow harness; do
while IFS=$'\t' read -r key _rest; do
@@ -414,8 +522,256 @@ lib() { bash -c 'source "$1"; shift; "$@"' _ "$LIB" "$@"; }
done
}
@test "docs/providers.md documents every shipped provider" {
local doc="$REPO_ROOT/docs/providers.md" axis name
for axis in forge workflow harness; do
while IFS= read -r name; do
[ -n "$name" ] || continue
grep -q "$name" "$doc" || { echo "docs/providers.md never mentions $axis '$name'"; return 1; }
done < <(lib provider_list "$axis")
done
}
@test "no doc still says the new providers are unbuilt" {
! grep -qi 'not yet built\|Phase 3 candidates\|deferred to Phase 3' "$REPO_ROOT/docs/providers.md"
}
@test "the github forge's firewall posture change is documented" {
grep -q 'ssh.github.com' "$REPO_ROOT/docs/providers.md"
grep -qi 'push' "$REPO_ROOT/docs/providers.md"
}
@test "no doc still points at the old gitea CLI paths" {
run grep -rn 'bin/gitea-pr\|bin/gitea-issue' "$REPO_ROOT/README.md" \
"$REPO_ROOT/docs" "$REPO_ROOT/install.sh"
[ "$status" -ne 0 ] || { echo "$output"; return 1; }
}
@test "the briefing names a real forge host, and says nothing about one for none" {
printf '[forge.gitea]\nssh_host = git.example\n' > "$CONFIG_DIR/config"
run lib briefing_compose gitea worktree-pr claude-code
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[[ "$output" == *"GitHub read-only, the configured gitea host (git.example), plus anything"* ]]
# workflows/local-only (Task 4) is what `forge = none` is meant to pair
# with - none's capabilities are empty, and local-only requires none, so
# this composes the real shipped trio rather than a throwaway workflow.
run lib briefing_compose none local-only claude-code
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
# No dangling article, no "none" read as a forge name.
[[ "$output" == *"GitHub read-only, plus anything"* ]]
[[ "$output" != *"configured none host"* ]]
}
@test "branch-push pushes and blocks main, but attempts no PR" {
run lib manifest_validate workflow branch-push
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[ "$(lib manifest_get workflow branch-push push)" = "on" ]
[ "$(lib manifest_get workflow branch-push blocked_branches)" = "main master" ]
[ "$(lib manifest_get workflow branch-push block_merge)" = "off" ]
[ "$(lib manifest_get workflow branch-push requires_worktree)" = "on" ]
[ "$(lib manifest_get workflow branch-push autonomy)" = "push-branch" ]
# Requires no capability, so it is usable against a forge that offers
# none - but it still pushes, so unlike a non-pushing workflow it cannot
# pair with forge = none itself (rule 3: a pushing workflow needs a forge
# that resolves to a host, and none never does). Exercised here against
# gitea, fully configured, for a host without exercising any capability.
printf '[forge.gitea]\nssh_host = git.example\napi = https://api.example/v1\ntoken_file = /dev/null\n' \
> "$CONFIG_DIR/config"
run lib provider_validate_selection gitea branch-push claude-code
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
}
@test "the branch-push briefing tells the agent to stop at the push" {
run lib briefing_compose gitea branch-push claude-code
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[[ "$output" == *"Push that branch"* ]]
# It must not instruct opening a PR - that is worktree-pr's job, and this
# workflow's whole point is that it stops earlier.
[[ "$output" != *"create"*"PR"* ]] || { echo "branch-push briefing mentions opening a PR"; return 1; }
}
@test "local-only pushes nothing and blocks no branches" {
run lib manifest_validate workflow local-only
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[ "$(lib manifest_get workflow local-only push)" = "off" ]
[ "$(lib manifest_get workflow local-only block_merge)" = "off" ]
[ "$(lib manifest_get workflow local-only requires_worktree)" = "off" ]
[ "$(lib manifest_get workflow local-only autonomy)" = "commit" ]
# blocked_branches is empty BECAUSE push is off - there is no push to
# block. This pins the pair together: turning push on later without
# revisiting this would silently allow a push to main.
[ -z "$(lib manifest_get workflow local-only blocked_branches)" ]
}
@test "none + local-only is a valid, fully unconfigured selection" {
run lib provider_validate_selection none local-only claude-code
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[ -z "$output" ]
}
@test "the github forge pushes with its own token, not the read-only one" {
run lib manifest_validate forge github
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[ "$(lib manifest_get forge github capabilities)" = "pr issues" ]
[ "$(lib manifest_get forge github pr_command)" = "gh pr" ]
[ "$(lib manifest_get forge github merge_block)" = "gh pr merge" ]
# The write-capable token is its own key. Reusing [github] token_file
# would promote a deliberately read-only PAT to a pushing credential.
[ "$(lib manifest_get forge github requires)" = "push_token_file" ]
[[ "$(lib manifest_get forge github env_export)" == *"GH_TOKEN=@push_token_file"* ]]
# Ships no CLI: gh is already in the image.
[ -z "$(lib manifest_get forge github install)" ]
}
@test "the briefing does not call GitHub read-only when the forge can push to it" {
# The other half of Task 2's network_allowlist resolver, tested here
# because this is where the github forge starts existing.
printf '[forge.github]\npush_token_file = /dev/null\n' > "$CONFIG_DIR/config"
run lib briefing_compose github branch-push claude-code
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[[ "$output" != *"GitHub read-only"* ]]
[[ "$output" == *"GitHub, which this session can also push to"* ]]
}
@test "the github forge contributes its hosts without any config" {
hosts="$(lib forge_hosts github)"
for h in github.com ssh.github.com api.github.com; do
[[ " $hosts " == *" $h "* ]] || { echo "missing $h in: $hosts"; return 1; }
done
}
@test "the github forge routes its hosts through core's allowlist, not the extra-domains one" {
# The manifest half of the launcher behaviour asserted in test/config.bats
# ("the github forge's hosts never reach the extra-domains allowlist").
# Those three hosts are public SaaS names already covered by core's
# GitHub ranges: sending them down the extra-domains path would put them
# in the port-unrestricted allowed-domains ipset AND exempt them from
# dnsmasq's stop-dns-rebind, and the firewall's startup self-check
# ("github.com:22 was reachable without ALLOW_GITHUB_PUSH=1") would then
# refuse to bring the container up at all.
[ "$(lib manifest_get forge github hosts_allowlisted_by_core)" = "on" ]
# Still resolved by forge_hosts, which is what keeps rule 3 (a pushing
# workflow needs a forge with a host) satisfied and what fills
# DEJIMA_PUSH_HOSTS so git-push-guard.sh permits the push.
[ -n "$(lib forge_hosts github)" ]
# Off unless a manifest says otherwise: a self-hosted forge's host is
# exactly the internal address the extra-domains path exists for.
[ "$(lib manifest_get forge gitea hosts_allowlisted_by_core)" = "off" ]
[ "$(lib manifest_get forge none hosts_allowlisted_by_core)" = "off" ]
}
@test "the network-posture sentence follows the manifest, not the forge's name" {
# Same key, read for the briefing: with a pushing workflow the session
# really can push to GitHub, and with a non-pushing one it really cannot
# (bin/dejima only opens the push grant when the workflow pushes), so the
# sentence has to say two different things for the same forge.
printf '[forge.github]\npush_token_file = /dev/null\n' > "$CONFIG_DIR/config"
run lib briefing_compose github local-only claude-code
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[[ "$output" == *"GitHub read-only"* ]]
[[ "$output" != *"can also push to"* ]]
# And no dangling "the configured github host" - core allowlists those,
# there is nothing for an operator to have configured.
[[ "$output" != *"configured github host"* ]]
}
@test "a required key that is set but empty counts as missing" {
# install.sh writes every key commented out at its default, so
# uncommenting one and not filling it in yields `api =` - set, and worth
# nothing. config_is_set alone called that configured, and the launch
# went ahead with every gitea call failing inside the container instead.
printf '[forge.gitea]\nssh_host = git.example\napi =\n' > "$CONFIG_DIR/config"
run lib provider_validate_selection gitea worktree-pr claude-code
[ "$status" -ne 0 ]
[[ "$output" == *"api"* ]]
}
@test "a single required key that is set but empty is refused too" {
# The same hole in the other rule: github's one `requires` key can never
# be "partly" configured, so an empty push_token_file would otherwise
# sail past both this rule and rule 3 (fixed_hosts means it always
# resolves to a host) and launch with no push credential at all.
printf '[core]\nforge = github\n[forge.github]\npush_token_file =\n' > "$CONFIG_DIR/config"
run lib provider_validate_selection github branch-push claude-code
[ "$status" -ne 0 ]
[[ "$output" == *"push_token_file"* ]]
}
@test "the no-host refusal offers the forge that needs no host configured" {
# The remedy has to include the one an operator who wants to push would
# actually pick - pointing only at `forge = none` + local-only answers
# the opposite question. Asserted here as the claim (every forge the
# remedy names is real), not the literal wording - see
# assert_remedy_forges_exist.
printf '[core]\nfirewall = on\n' > "$CONFIG_DIR/config"
run lib provider_validate_selection gitea worktree-pr claude-code
[ "$status" -ne 0 ]
assert_remedy_forges_exist "$output"
run lib provider_validate_selection none branch-push claude-code
[ "$status" -ne 0 ]
assert_remedy_forges_exist "$output"
}
@test "an unset push_token_file yields no GH_TOKEN pair at all, not an empty one" {
# docker-compose.yml declares GH_TOKEN itself, carrying the independent
# read-only [github] token_file value. `-e GH_TOKEN=` (the pair
# forge_env_exports used to emit for an unresolved value) overrides that
# entry to empty on `docker compose run` - wholly unconfigured must stay
# silent here, exactly like it already does for provider_validate_selection's
# own partial-config warning, or selecting this forge with no push token
# yet silently voids the baseline read-only access every session gets.
printf '[core]\nforge = github\n' > "$CONFIG_DIR/config"
run lib forge_env_exports github
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[ -z "$output" ]
}
@test "a configured push_token_file still yields a real GH_TOKEN pair" {
tok="$CONFIG_DIR/push-token"
printf 'ghp_example\n' > "$tok"
printf '[forge.github]\npush_token_file = %s\n' "$tok" > "$CONFIG_DIR/config"
run lib forge_env_exports github
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[ "$output" = "GH_TOKEN=ghp_example" ]
}
@test "open-pr autonomy is refused against a forge with no pr capability" {
FAKE_ROOT="$(mktemp -d)"
mkdir -p "$FAKE_ROOT/forges/nopr" "$FAKE_ROOT/workflows/wants-pr" "$FAKE_ROOT/harnesses/h"
printf 'capabilities =\n' > "$FAKE_ROOT/forges/nopr/forge.conf"
# requires is empty, so rule 1 passes and only the autonomy rule can catch this.
printf 'requires =\npush = on\nautonomy = open-pr\n' > "$FAKE_ROOT/workflows/wants-pr/workflow.conf"
printf 'command = claude\n' > "$FAKE_ROOT/harnesses/h/harness.conf"
run env DEJIMA_PROVIDER_ROOT="$FAKE_ROOT" \
bash -c 'source "$1"; provider_validate_selection nopr wants-pr h' _ "$LIB"
[ "$status" -ne 0 ]
[[ "$output" == *"open-pr"* ]]
}
@test "push-branch autonomy is refused when the workflow does not push" {
FAKE_ROOT="$(mktemp -d)"
mkdir -p "$FAKE_ROOT/forges/f" "$FAKE_ROOT/workflows/contradiction" "$FAKE_ROOT/harnesses/h"
printf 'capabilities =\n' > "$FAKE_ROOT/forges/f/forge.conf"
printf 'requires =\npush = off\nautonomy = push-branch\n' > "$FAKE_ROOT/workflows/contradiction/workflow.conf"
printf 'command = claude\n' > "$FAKE_ROOT/harnesses/h/harness.conf"
run env DEJIMA_PROVIDER_ROOT="$FAKE_ROOT" \
bash -c 'source "$1"; provider_validate_selection f contradiction h' _ "$LIB"
[ "$status" -ne 0 ]
[[ "$output" == *"push-branch"* ]]
}
@test "every shipped workflow's autonomy is supported by a forge that ships" {
# worktree-pr/open-pr and branch-push both push, so both now need a real
# host (rule 3) - gitea, fully configured. local-only never pushes, so
# none (nothing configured at all) stays the honest pairing for it.
printf '[forge.gitea]\nssh_host = git.example\napi = https://api.example/v1\ntoken_file = /dev/null\n' \
> "$CONFIG_DIR/config"
run lib provider_validate_selection gitea worktree-pr claude-code
[ "$status" -eq 0 ] || { echo "gitea+worktree-pr: $output"; return 1; }
run lib provider_validate_selection gitea branch-push claude-code
[ "$status" -eq 0 ] || { echo "gitea+branch-push: $output"; return 1; }
printf '[core]\nfirewall = on\n' > "$CONFIG_DIR/config"
run lib provider_validate_selection none local-only claude-code
[ "$status" -eq 0 ] || { echo "none+local-only: $output"; return 1; }
}
+10 -2
View File
@@ -880,10 +880,18 @@ EOF
# Waits for the grouped session an attach should have created, then prints
# "<name> <group>" for it - empty group means UNGROUPED, i.e. a fresh session
# tmux conjured out of a name rather than a clone of the sandbox's own.
#
# Waits for the CLIENT rather than the session, like the earlier attach test
# does. dejima-attach creates the session detached, checks it holds the pane,
# selects the window and only then attaches - so has-session goes true while
# the session is still on whatever window the group started on. A caller that
# goes on to assert the window index reads it inside that gap. That is how
# this failed in CI, with the group assertion on the line before it passing:
# the group is set at creation, the window is not.
await_group() {
local i
for i in $(seq 50); do
if tmux has-session -t "=dejima-$1" 2>/dev/null; then break; fi
for i in $(seq 100); do
[ "$(tmux list-clients -F '#{client_session}' 2>/dev/null)" = "dejima-$1" ] && break
sleep 0.1
done
tmux list-sessions -F '#{session_name} #{session_group}' | grep "^dejima-$1 " || true
+229 -2
View File
@@ -85,6 +85,16 @@ wait_ready() {
if docker exec "$container" pgrep -x -f 'sleep infinity' >/dev/null 2>&1; then
return 0
fi
# A container that has already exited is never going to become ready,
# and polling it for the full two minutes turns a startup failure - an
# aborted entrypoint, say - into a wait long enough to read as a hang.
# The reason is in its logs, which are printed either way; this only
# decides how long it takes to see them.
if [ "$(docker inspect -f '{{.State.Running}}' "$container" 2>/dev/null)" = false ]; then
echo "container $container exited during startup - log:" >&2
settled_logs "$container" >&2
return 1
fi
sleep 1
done
echo "container $container never reached the post-entrypoint sleep - startup log:" >&2
@@ -196,6 +206,8 @@ teardown() {
docker rmi "dejima-test-override:$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
docker rm -f "dejima-test-sshkey-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
docker rm -f "dejima-test-ghpush-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
docker rm -f "dejima-test-ghforge-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
docker rm -f "dejima-test-ghextra-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
docker rm -f "dejima-test-claudejson-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
docker rm -f "dejima-test-rebind-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
docker rm -f "dejima-test-overlays-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
@@ -209,6 +221,8 @@ teardown() {
docker rm -f "dejima-test-tls-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
docker rm -f "dejima-test-nohosts-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
docker rm -f "dejima-test-badtransport-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
docker rm -f "dejima-test-notifydir-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
docker rm -f "dejima-test-nonotify-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
}
dexec() {
@@ -357,6 +371,26 @@ dexec_node() {
[ -n "$output" ]
}
@test "the session user cannot address a resolver directly, so the DNS allowlist is not bypassable" {
# init-firewall.sh opens UDP/TCP 53 to any destination for root-owned
# sockets, because that is what Docker's embedded resolver forwards
# external names from and without it nothing in here resolves at all.
# This is the assertion that the opening stops there. `node` is the
# session user and there is no sudo in the image, so if it could reach a
# resolver of its own choosing it could carry data out in the query name
# itself - the exact thing dns-proxy.sh exists to prevent, and something
# no allowlist of *names* can catch once the *resolver* is attacker-chosen.
#
# 1.1.1.1 rather than an unrouted address: it answers if it is reachable,
# so a pass here means the packet was stopped and not that the probe was
# aimed at nothing.
run dexec_node dig +short registry.npmjs.org
[ -n "$output" ]
run dexec_node dig +time=2 +tries=1 @1.1.1.1 definitely-not-allowed.example.org
[ "$status" -ne 0 ]
}
@test "api.anthropic.com resolves in proxy-off mode (kill switch / API-key auth)" {
# api.anthropic.com is left off the allowlist when the credential proxy is
# on, so a bypass of the /etc/hosts pin that resolves the name by DNS gets
@@ -402,6 +436,109 @@ dexec_node() {
docker rm -f "$test_container" >/dev/null 2>&1 || true
}
@test "a github-forge launch boots, and its port 22 comes from the push grant" {
# The container half of test/config.bats' "the github forge's hosts reach
# the push guard, never the extra-domains allowlist" - the environment
# asserted there, handed to a real container here. Selecting `forge =
# github` with a pushing workflow resolves to exactly this: no
# DEJIMA_FORGE_HOSTS (core already allowlists GitHub's ranges) and the
# push grant open.
#
# It has to be a boot test, because the failure it guards against was the
# container not starting at all: with those three hostnames in
# DEJIMA_FORGE_HOSTS, init-firewall.sh resolves them into the
# port-unrestricted allowed-domains ipset, its own startup self-check
# finds github.com:22 reachable without ALLOW_GITHUB_PUSH=1, and
# entrypoint.sh (set -Eeuo pipefail) dies before the agent ever runs.
local test_container="dejima-test-ghforge-$(basename "$BATS_FILE_TMPDIR")"
docker run -d --name "$test_container" \
--cap-add=NET_ADMIN --cap-add=NET_RAW \
--network "$(network_name)" \
-e SANDBOX_TEST_MODE=1 \
-e GH_TOKEN="${GH_TOKEN:-}" \
-e DEJIMA_FORGE_HOSTS= \
-e DEJIMA_PUSH_HOSTS="github.com ssh.github.com api.github.com" \
-e ALLOW_GITHUB_PUSH=1 \
--entrypoint /usr/local/bin/entrypoint.sh \
"$IMAGE" >/dev/null
# The container coming up at all is half the assertion.
wait_ready "$test_container"
# Port 22 open, and open through the path that says so out loud.
run docker exec "$test_container" timeout 5 bash -c "cat < /dev/null > /dev/tcp/github.com/22"
[ "$status" -eq 0 ]
# Polled rather than read once, for the reason settled_logs documents: the
# json-file driver's writes lag the process that produced them, so a single
# read taken here can stop short of a line printed seconds earlier. That is
# how this failed in CI - with the port-22 assertion directly above passing,
# ie. with the branch that prints this line demonstrably taken. Waiting on
# the line cannot mask its absence: one never printed still fails, later.
local want="ALLOW_GITHUB_PUSH=1: opening port 22" logs=""
for _ in $(seq 1 40); do
logs=$(docker logs "$test_container" 2>&1)
case "$logs" in *"$want"*) break ;; esac
sleep 0.25
done
[[ "$logs" == *"$want"* ]]
# And the other half of the widening this avoids: GitHub's names keep
# dnsmasq's rebind protection, which the extra-domains path would have
# exempted them from.
run docker exec "$test_container" grep 'rebind-domain-ok=/github.com/' /etc/dnsmasq-sandbox.conf
[ "$status" -ne 0 ]
docker rm -f "$test_container" >/dev/null 2>&1 || true
}
@test "a GitHub name declared as an extra domain stays port-restricted, so push stays unreachable" {
# .dejima/allowed-domains is read with the project and is meant to widen
# what a session can reach. It must not widen how. GitHub's addresses are
# confined to 443 by init-firewall.sh, and that is the entire read-only
# guarantee - with 22 shut there is no transport left for the forwarded
# ssh-agent's key to push with, whatever a hook does or does not catch.
#
# allowed-domains carries no port restriction, so before ipset_for_domain
# this path put GitHub's address there and opened 22. It surfaced as an
# intermittent startup failure rather than as a silent hole only because
# the port-22 self-check re-resolves the name: it catches the hole when
# GitHub's second answer matches the first, and misses it when the name
# has rotated in between. This test does not depend on that coin flip.
local test_container="dejima-test-ghextra-$(basename "$BATS_FILE_TMPDIR")"
docker run -d --name "$test_container" \
--cap-add=NET_ADMIN --cap-add=NET_RAW \
--network "$(network_name)" \
-e SANDBOX_TEST_MODE=1 \
-e GH_TOKEN="${GH_TOKEN:-}" \
-e DEJIMA_FORGE_HOSTS= \
-e ALLOWED_DOMAINS="github.com api.github.com" \
--entrypoint /usr/local/bin/entrypoint.sh \
"$IMAGE" >/dev/null
# Coming up at all is part of the assertion: the port-22 self-check runs
# during startup, and with the address in the wrong set it fails the boot.
wait_ready "$test_container"
# Resolved inside the container, through the same dnsmasq that answered
# during startup - so this asks about the address actually allowlisted,
# not one a fresh lookup from elsewhere happened to return.
local ip
ip=$(docker exec "$test_container" dig +short A github.com | head -1)
[ -n "$ip" ]
run docker exec "$test_container" ipset test github-domains "$ip"
[ "$status" -eq 0 ]
run docker exec "$test_container" ipset test allowed-domains "$ip"
[ "$status" -ne 0 ]
# And the property those two sets exist to produce.
run docker exec "$test_container" timeout 5 bash -c "cat < /dev/null > /dev/tcp/github.com/22"
[ "$status" -ne 0 ]
docker rm -f "$test_container" >/dev/null 2>&1 || true
}
@test "a configured forge host is exempted from stop-dns-rebind, other allowed domains are not" {
# Passed as DEJIMA_FORGE_HOSTS, not GITEA_SSH_HOST: the container no longer
# derives hostnames from any one forge's variables - bin/dejima resolves
@@ -594,7 +731,10 @@ dexec_node() {
}
@test "the ipset stays in sync with fresh (non-cached) DNS answers, not just the boot-time snapshot" {
ip=$(dexec dig +short pypi.org | head -1)
# Any built-in domain that resolves to a CDN address will do; this one is
# here because it is built in, so keep it on a name from fixed-domains.sh
# rather than one a project happens to have approved.
ip=$(dexec dig +short registry.npmjs.org | head -1)
[ -n "$ip" ]
dexec ipset del allowed-domains "$ip"
run dexec ipset test allowed-domains "$ip"
@@ -605,7 +745,7 @@ dexec_node() {
# forward triggers dnsmasq's --ipset= tracking (see dns-proxy.sh).
dexec bash -c 'kill -HUP "$(cat /var/run/dnsmasq-sandbox.pid)"'
sleep 0.5
dexec dig +short pypi.org >/dev/null
dexec dig +short registry.npmjs.org >/dev/null
sleep 0.5
run dexec ipset test allowed-domains "$ip"
[ "$status" -eq 0 ]
@@ -902,6 +1042,18 @@ EOF
local store; store="$(mktemp -d)"
local df; df="$(mktemp)"
printf 'apt:cowsay\n' > "$store/dependencies"
# The positive control, and it is load-bearing: this test proves the
# derived layer installed cowsay by finding cowsay installed. If the base
# image already carried it, the assertion below would pass with
# generate_derived_dockerfile doing nothing at all. Checked here rather
# than assumed, because "the base image gained a package" is a change
# nobody would connect to this file.
run docker run --rm --entrypoint dpkg-query "$IMAGE" -W -f='${Status}' cowsay
[[ "$output" != *"install ok installed"* ]] || {
echo "cowsay is in the base image, so this test cannot detect a broken derived build" >&2
false
}
bash -c 'source "$1"; generate_derived_dockerfile "$2" "$3"' \
_ "$BATS_TEST_DIRNAME/../bin/dejima-project-lib.sh" "$IMAGE" "$store" > "$df"
docker build -q -t "$derived" -f "$df" "$ctx" >&2
@@ -1191,3 +1343,78 @@ EOF
[[ "$logs" == *"'tls'"* ]] || dump "the refusal did not name the valid values"
[[ "$logs" == *"'loopback'"* ]] || dump "the refusal did not name the valid values"
}
# The three-symptom failure entrypoint.sh's marker-directory check exists to
# name: notifications stop, the session vanishes from dejima-attach, and the
# notify hook reports EACCES - with no obvious moment they started.
#
# It is Docker's answer to a missing bind source that produces it. bin/dejima
# creates the marker directory per launch and removes it on exit, so a
# container that is RESTARTED rather than relaunched has no source left, and
# Docker creates one root-owned rather than refusing. The directory then
# exists, so the hook's own `[ -d ]` guard passes and the write fails instead.
@test "a marker directory the sandbox user cannot write is reported at startup" {
# Its own container, not container_name() - that one is created in
# setup_file and used by every dexec test in this file. Starting a second
# container under that name conflicts, and removing it at the end of this
# body would take the shared one with it.
local container="dejima-test-notifydir-$(basename "$BATS_FILE_TMPDIR")"
local host_dir; host_dir="$(mktemp -d)"
# Unwritable by the sandbox user, which is the property the check keys on.
# Built here rather than by removing a bind source, so the test does not
# depend on Docker's fallback behaviour to set up a test OF the
# consequences of that behaviour.
#
# chmod rather than mounting :ro. A read-only mount would make the
# "not repaired" assertion below vacuous - root could not chown it even
# if entrypoint.sh tried. Mode 555 leaves it repairable by root inside the
# container and still unwritable by the session user, so that assertion
# means something.
chmod 555 "$host_dir"
docker run -d --name "$container" \
--cap-add=NET_ADMIN --cap-add=NET_RAW \
--network "$(network_name)" \
-e SANDBOX_TEST_MODE=1 \
-e GH_TOKEN="${GH_TOKEN:-}" \
-e DEJIMA_NOTIFY_DIR=/run/dejima-notify \
-v "$host_dir:/run/dejima-notify" \
--entrypoint /usr/local/bin/entrypoint.sh \
"$IMAGE" >/dev/null
wait_ready "$container"
run settled_logs "$container"
[[ "$output" == *"not writable by"* ]]
[[ "$output" == *"RESTARTED rather than"* ]]
# Reported, not repaired: chowning would restore notifications while the
# session stayed absent from a registry this container never sees.
#
# Asserted as "still not writable by the session user" rather than "still
# owned by root": ownership depends on who ran the suite, since mktemp -d
# follows the caller, while writability is the property the check reads
# and the one repairing it would change.
run docker exec -u node "$container" test -w /run/dejima-notify
[ "$status" -ne 0 ]
rm -rf "$host_dir"
}
# Remote access is opt-in and off by default. The check above must say nothing
# at all for the sessions that never asked for it, which is every session by
# default - a warning about an unconfigured optional feature is noise on every
# start, and noise is what stops warnings being read.
@test "no marker-directory warning when remote access is not enabled" {
local container="dejima-test-nonotify-$(basename "$BATS_FILE_TMPDIR")"
docker run -d --name "$container" \
--cap-add=NET_ADMIN --cap-add=NET_RAW \
--network "$(network_name)" \
-e SANDBOX_TEST_MODE=1 \
-e GH_TOKEN="${GH_TOKEN:-}" \
--entrypoint /usr/local/bin/entrypoint.sh \
"$IMAGE" >/dev/null
wait_ready "$container"
run settled_logs "$container"
[[ "$output" != *"dejima-notify"* ]]
[[ "$output" != *"remote access is enabled"* ]]
}
+496 -30
View File
@@ -699,7 +699,7 @@ EOF
# reach a real running dejima session (and mutate its live
# firewall) even if the machine running this test happens to have one -
# it must fail at "container not found", not at validation.
run "$REPO_ROOT/bin/dejima-allow" 'crates.io' --container dejima-test-suite-nonexistent-target
run "$REPO_ROOT/bin/dejima-allow" --container dejima-test-suite-nonexistent-target 'crates.io'
[ "$status" -ne 0 ]
[[ "$output" != *"Not a valid domain"* ]]
}
@@ -781,17 +781,17 @@ EOF
rm -rf "$d"
}
@test "neither gitea CLI passes the token as a command-line argument" {
@test "no gitea CLI passes the token as a command-line argument" {
# /proc makes argv world-readable, so a token in a curl invocation is
# visible to every other process for the life of the request - and this
# runs alongside agents that read process listings.
#
# Both files, because they are deliberate copies of each other rather
# Every file, because they are deliberate copies of each other rather
# than a shared library (see forges/gitea/bin/gitea-issue's header for
# why), and a duplicated security property is exactly the kind that
# drifts.
local f
for f in forges/gitea/bin/gitea-pr forges/gitea/bin/gitea-issue; do
for f in forges/gitea/bin/gitea-pr forges/gitea/bin/gitea-issue forges/gitea/bin/gitea-actions; do
run grep -n 'curl.*Authorization: token' "$REPO_ROOT/$f"
[ "$status" -ne 0 ]
grep -q 'curl -sS -w .*-K -' "$REPO_ROOT/$f"
@@ -820,6 +820,15 @@ EOF
grep -q 'type=issues' "$REPO_ROOT/forges/gitea/bin/gitea-issue"
}
@test "gitea-actions only reads - it issues no request that could change anything" {
# It authenticates with the same write-scoped token as gitea-pr, so
# "it only fetches runs and logs" has to be a property of the file and
# not merely of what its subcommands are named.
run grep -nE '\-X (POST|PUT|PATCH|DELETE)|--data|[^a-z-]-d ' \
"$REPO_ROOT/forges/gitea/bin/gitea-actions"
[ "$status" -ne 0 ]
}
@test "gitea-issue is installed into the image alongside gitea-pr" {
grep -q 'COPY forges/' "$SANDBOX_DIR/Dockerfile"
# install -m 755, not a plain cp, so the executable bit surviving isn't
@@ -865,9 +874,58 @@ EOF
grep -q 'register_hook PreToolUse Bash merge-guard.sh' "$REPO_ROOT/install.sh"
}
@test "install.sh cleans up the stale pre-rename gitea-merge-guard.sh hook symlink" {
grep -q 'gitea-merge-guard.sh' "$REPO_ROOT/install.sh"
grep -q 'dangling' "$REPO_ROOT/install.sh"
# Runs install.sh against a throwaway HOME, with a stub `docker` on PATH so
# its tool check passes somewhere without one (this suite is the no-Docker
# half). Everything the script writes - hook symlinks, ~/.local/bin,
# settings.json, the starter config - lands under that HOME.
run_install() {
local home="$1"
mkdir -p "$home/stub"
printf '#!/bin/sh\nexit 0\n' > "$home/stub/docker"
chmod +x "$home/stub/docker"
env HOME="$home" PATH="$home/stub:$PATH" DEJIMA_CONFIG_DIR="$home/config" \
bash "$REPO_ROOT/install.sh"
}
# Every registered hook command, one per line.
registered_commands() {
jq -r '[.hooks[]?[]?.hooks[]?.command // empty] | .[]' "$1/.claude/settings.json"
}
@test "install.sh replaces the pre-rename gitea-merge-guard.sh registration rather than reading it as one" {
# Asserted by running it, not by grepping it: the bug this covers was a
# register_hook call that was present in the source and did nothing. Its
# dedupe matched the pre-rename command string as a substring, so the
# merge guard was registered nowhere while install.sh reported success,
# and every session start reported the missing hook the stale entry
# still named.
local home="$BATS_TEST_TMPDIR/h"
mkdir -p "$home/.claude/hooks"
ln -s "$REPO_ROOT/hooks/gone.sh" "$home/.claude/hooks/gitea-merge-guard.sh"
jq -n '{hooks: {PreToolUse: [{matcher: "Bash", hooks: [{type: "command",
command: "$HOME/.claude/hooks/gitea-merge-guard.sh"}]}]}}' \
> "$home/.claude/settings.json"
run_install "$home"
run registered_commands "$home"
[[ "$output" != *"gitea-merge-guard.sh"* ]]
[ "$(registered_commands "$home" | grep -c '/merge-guard\.sh$')" -eq 1 ]
[ ! -e "$home/.claude/hooks/gitea-merge-guard.sh" ]
}
@test "install.sh registers each hook exactly once across repeated runs" {
# The other direction of the same dedupe: an exact match must still
# match. A re-run after a git pull is the common case, and duplicate
# registrations would run every guard twice.
local home="$BATS_TEST_TMPDIR/h2" cmd
run_install "$home"
run_install "$home"
while read -r cmd; do
[ "$(registered_commands "$home" | grep -cxF "$cmd")" -eq 1 ]
done < <(registered_commands "$home")
[ "$(registered_commands "$home" | wc -l)" -eq 4 ]
}
@test "the push guard names no forge, and blocks everything when no host is allowed" {
@@ -1297,14 +1355,25 @@ this line is not json'
[ "$status" -ne 0 ]
}
@test "dejima-allow's session-skip escape hatch is test-only, never a silent no-op path" {
# DEJIMA_ALLOW_SKIP_SESSION exists so test/project.bats can
# exercise --project persistence without a live container. It must never
# be reachable from the documented interface.
run grep -c 'DEJIMA_ALLOW_SKIP_SESSION' "$REPO_ROOT/bin/dejima-allow"
[ "$output" -eq 1 ]
run grep 'DEJIMA_ALLOW_SKIP_SESSION' "$REPO_ROOT/README.md"
@test "dejima-allow has no env-triggered path that skips the live session" {
# DEJIMA_ALLOW_SKIP_SESSION used to exist as a test-only escape hatch, and
# by the end was consulted by nothing: every caller passed --project or
# --global, which set session_wanted=0 on their own. An environment
# variable that turns a firewall CLI into a silent no-op is worth keeping
# out on purpose, not just deleting once - which is what this pins.
# Matched on the read and the write, not the bare name: both files below
# still name it in a comment explaining why it is gone, and a grep that
# tripped on its own rationale would have to be deleted to pass.
run grep -n '\${\?DEJIMA_ALLOW_SKIP_SESSION' "$REPO_ROOT/bin/dejima-allow"
[ "$status" -ne 0 ]
run grep -rn --exclude=static.bats 'DEJIMA_ALLOW_SKIP_SESSION=' \
"$REPO_ROOT/test" "$REPO_ROOT/README.md"
[ "$status" -ne 0 ]
}
@test "dejima-allow's usage puts the domain last" {
grep -q 'Usage: dejima-allow \[--global\] \[--project \[path\]\] \[--container <id>\] <domain>' \
"$REPO_ROOT/bin/dejima-allow"
}
@test "bin/dejima merges approved project domains into ALLOWED_DOMAINS" {
@@ -1421,6 +1490,80 @@ this line is not json'
grep -q 'dejima-project approve' "$REPO_ROOT/hooks/sandbox-context.sh"
}
# The hook answers "is this installed?" per kind, and a tarball has no
# package manager to ask - it is there if its directory is. A tarball spec
# falling through to the package-name regex would be silently skipped, which
# reads to the agent as "declared and present" for something that is neither.
@test "sandbox-context reports a declared tarball dependency that is not unpacked" {
local ws; ws="$(mktemp -d)"
local sha=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
mkdir -p "$ws/.dejima"
printf 'tarball:definitely-not-here@%s:https://example.com/x.tar.gz\n' "$sha" > "$ws/.dejima/dependencies"
local out
out=$(printf '{"session_id":"s1","cwd":"%s"}' "$ws" | DEJIMA=1 bash "$REPO_ROOT/hooks/sandbox-context.sh")
rm -rf "$ws"
[[ "$out" == *"definitely-not-here"* ]]
# The digest reaches the agent intact: it is what the operator is being
# asked to approve, so a truncated form would be asking them to agree to
# something they cannot see.
[[ "$out" == *"$sha"* ]]
}
# The positive half of the test above. It needs a tarball dependency actually
# unpacked in the image, which only exists once a project has approved one -
# so on an image with none it skips rather than silently asserting nothing.
# A visible skip is the honest form here; a test that arranged its own
# /opt/dejima entry would be checking that mkdir works.
#
# The branch itself is pinned by the source-level test below, which holds
# regardless of what this image happens to contain.
@test "sandbox-context does not report a tarball dependency that is unpacked" {
local present=""
for d in /opt/dejima/*/; do [ -d "$d" ] && present="$(basename "$d")" && break; done
[ -n "$present" ] || skip "no unpacked tarball dependency in this image"
local ws; ws="$(mktemp -d)"
local sha=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
mkdir -p "$ws/.dejima"
printf 'tarball:%s@%s:https://example.com/x.tar.gz\n' "$present" "$sha" > "$ws/.dejima/dependencies"
local out
out=$(printf '{"session_id":"s1","cwd":"%s"}' "$ws" | DEJIMA=1 bash "$REPO_ROOT/hooks/sandbox-context.sh")
rm -rf "$ws"
[[ "$out" != *"$present"* ]]
}
# runtime.bats proves the derived-image dependency mechanism works by
# declaring apt:cowsay and then finding cowsay installed. That only proves
# anything while the base image does not ship it - otherwise the assertion
# passes with the mechanism entirely broken. runtime.bats checks this for
# itself too, but only where Docker exists; this catches it in the suite that
# runs everywhere, at the point the package list changes.
@test "the base image ships none of the packages used as dependency fixtures" {
for pkg in $(grep -oE "apt:[a-z0-9.+-]+" "$REPO_ROOT/test/runtime.bats" | cut -d: -f2 | sort -u); do
run grep -nE "^ +$pkg \\\\$" "$SANDBOX_DIR/Dockerfile"
[ "$status" -ne 0 ] || {
echo "$pkg is a dependency-test fixture and is also in the base image" >&2
false
}
done
}
# The image ships the sandbox's own machinery and the tooling with no route
# through dejima-dep. A language toolchain has a route - tarball: - so one
# appearing here means every project carries it with nobody having asked.
@test "no language toolchain is installed into the base image" {
run grep -nE '^(RUN|ENV).*(golang|/usr/local/go|rustup|\.cargo/bin|pyenv|rbenv|sdkman)' \
"$SANDBOX_DIR/Dockerfile"
[ "$status" -ne 0 ] || {
echo "declare it with dejima-dep instead:" >&2
echo "$output" >&2
false
}
}
@test "sandbox-context decides a tarball is present by its unpack directory" {
grep -q '\[ -d "/opt/dejima/\$name" \] && continue' "$REPO_ROOT/hooks/sandbox-context.sh"
}
@test "sandbox-context reports nothing when no project declarations exist" {
local ws; ws="$(mktemp -d)"
local out
@@ -1532,7 +1675,7 @@ EOF
}
@test "README documents dejima-dep --global" {
grep -q 'dejima-dep add cargo:cargo-nextest --global' "$REPO_ROOT/README.md"
grep -q 'dejima-dep add --global cargo:cargo-nextest' "$REPO_ROOT/README.md"
}
@test "README documents that the exit-time audit's findings are not attributed to the session" {
@@ -1550,6 +1693,177 @@ EOF
grep -q 'directory shared by' "$REPO_ROOT/docs/security.md"
}
# The CI disclosure is the one part of issue #72 that helps a reader whatever
# else is or isn't mitigated on their runners, so it is pinned in all three
# places rather than left to survive a future edit on its own. Presence-only
# greps, same caveat as the two above.
@test "README and docs/security.md both disclose that push access is CI code execution" {
for f in README.md docs/security.md; do
grep -q 'push access is code execution elsewhere' "$REPO_ROOT/$f"
grep -qi 'forge = none' "$REPO_ROOT/$f"
done
}
@test "docs/ci.md exists and is linked from the README and docs/security.md" {
[ -f "$REPO_ROOT/docs/ci.md" ]
grep -q 'docs/ci\.md' "$REPO_ROOT/README.md"
grep -q '(ci\.md)' "$REPO_ROOT/docs/security.md"
}
# The whole allowlist argument in docs/ci.md rests on reading the declaration
# from the branch the operator controls. A future edit that softens this into
# "read .dejima/allowed-domains" without saying from where would leave the
# document describing a control that doesn't control anything.
# runs-on lives in the workflow file, which lives in the branch, so a
# dedicated label routes honest jobs and stops nothing else. An earlier version
# of this document said jobs "cannot land anywhere else", which is true only of
# a branch that is not trying - the exact distinction the rest of the document
# is built on. Pinned so it cannot quietly come back.
# The counterintuitive half of the advisory, and the one a reader is most
# likely to skip: a project with no CI is exposed too, because a branch adds
# the first workflow. If that goes missing, the document reads as being about
# people who already run CI, which is the smaller group.
@test "docs/ci.md says a project with no CI is exposed too" {
grep -q 'applies to projects with no CI at all' "$REPO_ROOT/docs/ci.md"
grep -q 'no workflows is not therefore safe' "$REPO_ROOT/docs/ci.md"
grep -qi 'holds for projects with no CI' "$REPO_ROOT/README.md"
}
# Moving a public project to another owner breaks its name, its links and its
# ownership. It must not read as the recommended answer, and the alternatives
# that require moving nothing have to be present.
@test "docs/ci.md offers answers that do not require moving a repository" {
local f="$REPO_ROOT/docs/ci.md"
grep -q 'Scope the dangerous runner down, rather than moving' "$f"
grep -q 'Turn CI off for that repository' "$f"
grep -q 'Do not grant push' "$f"
}
@test "docs/ci.md does not present the runner label as a boundary" {
grep -q 'A label is not a boundary' "$REPO_ROOT/docs/ci.md"
grep -q 'weakest runner it can reach' "$REPO_ROOT/docs/ci.md"
run grep -n 'cannot land anywhere else' "$REPO_ROOT/docs/ci.md"
[ "$status" -ne 0 ] || { echo "$output" >&2; false; }
}
# The shared-identity point is what decides whether an operator's branch
# protection does anything, and it is the kind of thing that reads as a
# digression to a later editor. Pinned in both places, and pinned together
# with the separation that fixes it - stating the problem without the remedy
# would leave a reader with nothing to do about it.
@test "docs/security.md documents that the sandbox authenticates as the operator" {
grep -q 'the sandbox is you' "$REPO_ROOT/docs/security.md"
grep -qi 'guard rather than a boundary' "$REPO_ROOT/docs/security.md"
grep -q 'forge.gitea. token_file' "$REPO_ROOT/docs/security.md"
grep -q 'core. ssh_key' "$REPO_ROOT/docs/security.md"
}
@test "docs/ci.md's branch-protection prerequisite links the shared-identity section" {
grep -q 'security.md#to-your-forge-the-sandbox-is-you' "$REPO_ROOT/docs/ci.md"
}
@test "docs/ci.md says the CI allowlist is read from main, not from the checkout" {
grep -q 'origin/main' "$REPO_ROOT/docs/ci.md"
grep -qi 'never from the checkout' "$REPO_ROOT/docs/ci.md"
}
# --- the labels this repo's own workflow asks for ---
#
# Whether a job container gets the Docker socket is settled by the runner's
# config.yaml - which lives in dejima-ci-hardener's territory, not this
# repository's - so the only lever here is which runner its jobs may land on.
# A generic label gives that lever away, which is what these pin.
# pull_request_target reads this workflow from the base branch, which is what
# pins runs-on out of a branch's reach. Reverting to pull_request hands that
# back, silently.
@test "the workflow is read from the base branch, not the branch under test" {
local wf="$REPO_ROOT/.gitea/workflows/tests.yml"
grep -qE '^ pull_request_target:' "$wf"
# pull_request may still be present, but only while the transition is
# explicitly in progress - it takes an untrusted run from the head with
# branch-controlled runs-on, which is the thing being removed. Requiring
# the marker means the day it is dropped, this fails until the trigger
# goes with it, rather than the pair quietly becoming permanent.
if grep -qE '^ pull_request:' "$wf"; then
grep -q 'TRANSITIONAL' "$wf" || {
echo "pull_request is still enabled without the transitional note -" >&2
echo "it hands runs-on back to the branch. Remove the trigger." >&2
false
}
fi
}
# The trap in that conversion: the run's ref is the BASE branch, so a bare
# checkout tests main and passes regardless of the change. Every suite that
# means to test the branch has to name the head explicitly - and a green run
# is exactly what it looks like when one does not.
@test "every suite that tests the branch checks out the branch" {
local wf="$REPO_ROOT/.gitea/workflows/tests.yml"
# Both branch-testing jobs pin the head sha; the runner-env jobs must not,
# since their assertions should come from the base branch.
[ "$(grep -c 'pull_request.head.sha' "$wf")" -eq 2 ]
[ "$(grep -c 'uses: actions/checkout@v4' "$wf")" -eq 4 ]
}
@test "no workflow job runs on a generic runner label" {
run grep -nE '^\s*runs-on:\s*(ubuntu|windows|macos|self-hosted)' \
"$REPO_ROOT"/.gitea/workflows/*.yml
[ "$status" -ne 0 ] || {
echo "a generic label lets this job land on a runner nobody hardened:" >&2
echo "$output" >&2
false
}
}
@test "every workflow job runs on a dejima-specific label" {
while IFS= read -r label; do
[[ "$label" == dejima-* ]] || {
echo "unexpected runs-on label: $label" >&2
false
}
done < <(grep -hE '^\s*runs-on:' "$REPO_ROOT"/.gitea/workflows/*.yml \
| sed -E 's/^\s*runs-on:\s*//')
}
# Remote access is opt-in and off by default, so the marker-directory check
# must be inside the "was it configured at all" guard. Outside it, every
# ordinary session gets a warning about a feature it never asked for - and a
# warning that fires on every start is one nobody reads.
@test "the marker-directory check only runs when remote access is configured" {
local f="$REPO_ROOT/docker/entrypoint.sh"
grep -q 'if \[ -n "${DEJIMA_NOTIFY_DIR:-}" \]; then' "$f"
# The check's own output must sit inside that guard, not before it.
guard=$(grep -n 'if \[ -n "${DEJIMA_NOTIFY_DIR:-}" \]; then' "$f" | head -1 | cut -d: -f1)
warn=$(grep -n 'remote access is enabled, but' "$f" | head -1 | cut -d: -f1)
[ "$guard" -lt "$warn" ]
}
# Chowning it would restore notifications while the session stayed absent from
# dejima-attach, because the registry entry is written host-side and never
# mounted here - a session that looked fixed with half the feature still
# broken. The remedy is relaunching, which the message names.
@test "the marker-directory check reports rather than repairs" {
local f="$REPO_ROOT/docker/entrypoint.sh"
run grep -nE 'chown[^|]*DEJIMA_NOTIFY_DIR|chmod[^|]*DEJIMA_NOTIFY_DIR' "$f"
[ "$status" -ne 0 ] || { echo "$output" >&2; false; }
grep -q "start a new session with 'dejima'" "$f"
}
# bin/dejima warns and continues rather than refusing. The neighbouring
# state-dir check does refuse, but that one is about a symlink handing a
# session an attacker-chosen path; this one is only about an optional feature
# working, and blocking a launch over undeliverable notifications costs more
# than it saves.
@test "bin/dejima warns about an unusable marker directory without refusing" {
local line
line=$(grep -n 'is not a directory this user owns and can write' "$REPO_ROOT/bin/dejima" | head -1 | cut -d: -f1)
[ -n "$line" ]
run sed -n "$((line)),$((line + 6))p" "$REPO_ROOT/bin/dejima"
[[ "$output" != *"exit 1"* ]]
}
@test "sandbox-context.sh no longer mentions ALLOWED_DOMAINS" {
run grep -n 'ALLOWED_DOMAINS' "$REPO_ROOT/hooks/sandbox-context.sh"
[ "$status" -ne 0 ]
@@ -1581,6 +1895,10 @@ SCANNED_PATHS=(README.md docs docker bin forges workflows harnesses hooks instal
}
}
# The exemption above is only defensible while it is the address the docs
# actually recommend. If the recommendation changes and this does not, the
# exemption starts covering an address nothing points at - a hole rather than
# a carve-out.
@test "no hostname under a private-network suffix is used as a documentation example" {
run grep -rnIE '[A-Za-z0-9-]+\.(localdomain|lan|internal|home\.arpa)\b' \
"${SCANNED_PATHS[@]/#/$REPO_ROOT/}"
@@ -1630,9 +1948,17 @@ EOF
chmod +x "$DEJIMA_T/docker"
: > "$DEJIMA_T/sandbox-key"
chmod 600 "$DEJIMA_T/sandbox-key"
# forge = none, workflow = local-only: provider_validate_selection runs
# ahead of the audit-startup path these tests are actually about, and the
# default gitea/worktree-pr pair is refused unconfigured (see
# providers.bats) - this fixture sidesteps that with the honest
# "no forge, no push" pair rather than configuring a forge just to get
# past validation.
cat > "$DEJIMA_T/config/config" <<EOF
[core]
ssh_key = $DEJIMA_T/sandbox-key
forge = none
workflow = local-only
[audit]
dir = $DEJIMA_T/audit
EOF
@@ -1786,6 +2112,27 @@ startup_warning_slug() { printf '%s' "$DEJIMA_T/ws" | sed 's/[^a-zA-Z0-9]/-/g';
grep -q 'SANDBOX_ANTHROPIC_AUTH_TOKEN="sandbox-credential-proxy-placeholder"' "$REPO_ROOT/bin/dejima"
}
# The built-in list grants to every sandbox on the host, for every project,
# with no review anywhere - so what belongs in it is what the sandbox itself
# cannot run without. A language package host reaching it is how the list
# grows one reasonable-looking entry at a time until "allowed by default"
# covers most of the internet a build would want.
#
# The route for these is an approval: `dejima-allow --global`, or a reviewed
# .dejima/allowed-domains line. Adding one back here means arguing that the
# sandbox itself needs it.
@test "no language package host is built into the allowlist" {
run bash -c "SANDBOX_ANTHROPIC_PROXY_SOCKET=/tmp/p.sock; source '$SANDBOX_DIR/fixed-domains.sh'; printf '%s\n' \"\${FIXED_DOMAINS[@]}\""
[ "$status" -eq 0 ]
for d in pypi.org files.pythonhosted.org proxy.golang.org sum.golang.org \
crates.io static.crates.io rubygems.org gitlab.com; do
! grep -qx "$d" <<< "$output" || {
echo "$d is built in; it should be an approval instead" >&2
false
}
done
}
@test "api.anthropic.com is in the fixed domains only when the proxy is off" {
# Not unconditionally allowlisted: with the proxy on, the session reaches
# the API through the forwarder rather than by resolving this name, so
@@ -1836,25 +2183,61 @@ startup_warning_slug() { printf '%s' "$DEJIMA_T/ws" | sed 's/[^a-zA-Z0-9]/-/g';
# prose leak would both defeat the point, so each file gets its own
# narrow, justified exclusion for the specific lines that are not a
# forge coupling - everything else in scope must still match nothing.
local f matches fail=0
#
# Two scans, because they have different subjects:
#
# (1) `gitea` and `github.com` as substrings, case-insensitively. This is
# the original scan, and it is what catches a PROSE leak ("push to
# the Gitea remote") as much as a coupling.
#
# (2) Every name `provider_list forge` reports, in literal position.
# Derived from the shipped directories rather than written out here,
# so a forge is covered the day it lands - which is exactly what
# scan 1 missed: `github` became a shipped FORGE name, and a bare
# `github` matches neither of scan 1's alternatives, so core could
# (and did) compare a forge name to a literal with this test green.
#
# "Literal position" means quoted, backticked, bracketed as a config
# section, prefixed `forge.`, under `forges/`, or a `case` arm -
# every shape a lookup or comparison actually takes here. It is not a
# bare-word scan because two shipped names cannot survive one: `none`
# is an ordinary English word this repo's prose uses constantly, and
# `github` is also the name of a CORE feature that predates the forge
# axis (GitHub read-only: `[github] token_file`, the `github.com`
# credential helper, `--allow-github-push`). Operator-facing remedy
# text naming a config VALUE (`set [core] forge = none`, `or [core]
# forge = github`) is deliberately outside these shapes too: a rule
# that refuses a combination has to be able to say which one works,
# and "pick a different forge" without saying which is a worse
# message, not a purer one.
local f matches fail=0 name literals="" body
while IFS= read -r name; do
[ -n "$name" ] || continue
# The last alternative is a `case` arm - anchored to the start of a
# line (optionally after other arms' `|`) so it is the shape bash
# actually uses, not any prose that happens to close a parenthesis
# after the word.
literals="${literals:+$literals|}[\"'\`]$name[\"'\`]|\\[$name\\]|forge\\.$name|forges/$name|^[[:space:]]*[(|]?$name\\)|\\|$name\\)"
done < <(bash -c 'source "$1"; provider_list forge' _ "$REPO_ROOT/bin/dejima-provider-lib.sh")
[ -n "$literals" ] || { echo "no shipped forges to build the pattern from"; return 1; }
for f in "$REPO_ROOT/bin/dejima" "$REPO_ROOT/bin/dejima-config-lib.sh" \
"$REPO_ROOT/bin/dejima-provider-lib.sh" "$REPO_ROOT/docker/entrypoint.sh" \
"$REPO_ROOT/docker/docker-compose.yml" "$REPO_ROOT/hooks/git-push-guard.sh" \
"$REPO_ROOT/hooks/merge-guard.sh" "$REPO_ROOT/hooks/sandbox-context.sh"; do
matches="$(grep -inE 'gitea|github\.com' "$f" || true)"
# _warn_legacy_env's table names the OLD environment variables
# (GITEA_SSH_HOST, GITEA_API, ...) dejima read directly before the
# manifest schema replaced them - a one-time migration aid for an
# operator's existing shell profile. Those names only ever meant
# gitea (nothing else was ever wired that way) and do not change when
# a forge is added or removed, so excluding the function body - the
# same range test/config.bats isolates by name for its own "every
# retired variable" test - is exempting history, not a live coupling.
# A no-op for every other file here: none of them define it.
body="$(sed '/^_warn_legacy_env()/,/^}/d' "$f")"
matches="$(grep -inE 'gitea|github\.com' <<< "$body" || true)"
matches="$matches"$'\n'"$(grep -nE "$literals" <<< "$body" || true)"
case "$f" in
*/bin/dejima)
# _warn_legacy_env's table names the OLD environment
# variables (GITEA_SSH_HOST, GITEA_API, ...) dejima read
# directly before the manifest schema replaced them - a
# one-time migration aid for an operator's existing shell
# profile. Those names only ever meant gitea (nothing else
# was ever wired that way) and do not change when a forge is
# added or removed, so excluding the function body - the same
# range test/config.bats isolates by name for its own
# "every retired variable" test - is exempting history, not a
# live coupling.
matches="$(sed '/^_warn_legacy_env()/,/^}/d' "$f" | grep -inE 'gitea|github\.com' || true)"
# A citation of an external issue URL, the same category as
# hooks/sandbox-context.sh:3 below.
matches="$(grep -vE 'claude-code/issues/29162' <<< "$matches" || true)"
@@ -1864,6 +2247,13 @@ startup_warning_slug() { printf '%s' "$DEJIMA_T/ws" | sed 's/[^a-zA-Z0-9]/-/g';
# docker/entrypoint.sh and docker-compose.yml already get
# below, not a forge coupling.
matches="$(grep -vE 'github\.com' <<< "$matches" || true)"
# `[github] token_file` and the pairing that resolves it: the
# CORE config section holding the read-only GitHub credential
# every session gets whatever forge is selected. Same feature
# as the exception above, in its config-file spelling - the
# section predates the forge axis and would still exist with
# every forge directory deleted.
matches="$(grep -vE '\[github\] token_file|GH_TOKEN=github token_file' <<< "$matches" || true)"
;;
*/bin/dejima-config-lib.sh)
# core.forge's schema DEFAULT - real data (today there is
@@ -1889,6 +2279,9 @@ startup_warning_slug() { printf '%s' "$DEJIMA_T/ws" | sed 's/[^a-zA-Z0-9]/-/g';
matches="$(grep -vE 'anthropics/claude-code/tree' <<< "$matches" || true)"
;;
esac
# The two scans are concatenated with a newline, so an empty one
# leaves a blank line rather than nothing at all.
matches="$(grep -v '^$' <<< "$matches" || true)"
if [ -n "$matches" ]; then
echo "$f still names a forge:"
echo "$matches"
@@ -1914,15 +2307,21 @@ startup_warning_slug() { printf '%s' "$DEJIMA_T/ws" | sed 's/[^a-zA-Z0-9]/-/g';
[ "$status" -ne 0 ]
}
@test "every sandbox suite is in the CI matrix, and runtime.bats is not" {
@test "every suite is run by CI, and the two that need their own job have one" {
# Derived from the directory rather than a list kept here, so adding a
# test file fails this until the workflow is told about it. A suite nobody
# wired into CI runs nowhere, and nothing else would say so.
#
# Two suites are not matrix entries because they cannot be. runtime.bats
# needs a Docker daemon; ci-runner.bats asserts on the runner itself and
# has to run once per tier rather than once per suite. Both are checked
# below for having a job of their own instead - "not in the matrix" must
# not be a way to be in neither.
local wf="$REPO_ROOT/.gitea/workflows/tests.yml"
local f base missing=""
for f in "$REPO_ROOT"/test/*.bats; do
base="$(basename "$f" .bats)"
[ "$base" = "runtime" ] && continue
case "$base" in runtime|ci-runner) continue ;; esac
grep -qE "^ *- *$base\$" "$wf" || missing="$missing $base"
done
[ -z "$missing" ] || {
@@ -1935,4 +2334,71 @@ startup_warning_slug() { printf '%s' "$DEJIMA_T/ws" | sed 's/[^a-zA-Z0-9]/-/g';
# fail every time rather than testing anything.
! grep -qE '^ *- *runtime$' "$wf"
grep -q 'test/runtime.bats' "$wf"
# ci-runner.bats likewise, and once per tier: a single job would leave one
# of the two runners unasserted, which is the thing it exists to catch.
! grep -qE '^ *- *ci-runner$' "$wf"
grep -q 'test/ci-runner.bats' "$wf"
grep -q 'DEJIMA_CI_TIER: sandbox' "$wf"
grep -q 'DEJIMA_CI_TIER: host-docker' "$wf"
}
@test "the agent briefing teaches the project scope, not just global" {
# The observed failure: an agent asked for a project-specific domain to be
# added container-wide and globally, because --project was never named.
grep -q -- 'dejima-allow --project' "$REPO_ROOT/bin/dejima-provider-lib.sh"
}
@test "the agent briefing and the context hook use the new argument order" {
# A briefing that hands the operator a command the CLI now rejects is worse
# than one that says nothing.
run grep -nE 'dejima-allow [a-z<][^ ]* +--' \
"$REPO_ROOT/bin/dejima-provider-lib.sh" "$REPO_ROOT/hooks/sandbox-context.sh"
[ "$status" -ne 0 ]
}
@test "the briefing's container placeholder is the template form, not a shell variable" {
# _briefing_core_preamble is a quoted heredoc rendered by placeholder
# substitution; a literal $container_id would reach the agent unexpanded.
run grep -n 'dejima-allow --container \$container_id' "$REPO_ROOT/bin/dejima-provider-lib.sh"
[ "$status" -ne 0 ]
grep -q 'dejima-allow --container {{container_id}}' "$REPO_ROOT/bin/dejima-provider-lib.sh"
}
@test "README documents dejima-allow with flags before the domain" {
grep -q 'dejima-allow --global crates.io' "$REPO_ROOT/README.md"
run grep -nE 'dejima-allow [a-z][^ ]* +--(global|project)' "$REPO_ROOT/README.md"
[ "$status" -ne 0 ]
}
@test "README documents dejima-dep with flags before the spec" {
grep -q 'dejima-dep add --global cargo:cargo-nextest' "$REPO_ROOT/README.md"
run grep -nE 'dejima-dep (add|remove) [a-z][^ ]* +--(global|project)' "$REPO_ROOT/README.md"
[ "$status" -ne 0 ]
}
@test "README does not claim --global/--project patch a running session" {
# A scope-flag-only call sets session_wanted=0 and never touches docker -
# see bin/dejima-allow. Claiming otherwise sends an operator to expect a
# live update they will not get.
run grep -nE 'which also (updates|patches) the running session' "$REPO_ROOT/README.md"
[ "$status" -ne 0 ]
grep -q 'deliberately do not touch a running container' "$REPO_ROOT/README.md"
}
@test "README does not claim dejima-project approve reports live-session failures" {
# approve only ever calls dejima-allow without --container (see
# bin/dejima-project's approve case), so a failure there is a persistence
# failure, never a live-session one.
run grep -n 'could not apply to a running session' "$REPO_ROOT/README.md"
[ "$status" -ne 0 ]
grep -q 'never attempts to patch a running session' "$REPO_ROOT/README.md"
}
@test "README does not attribute container disambiguation to a scope flag" {
# A --project/--global-only call sets session_wanted=0 and never reaches
# the multiple-containers ambiguity check at all - only the bare form
# (or bare + --container) does. Only the bare form can ever refuse on it.
run grep -n 'scope-flag) form\|scope-flag form' "$REPO_ROOT/README.md"
[ "$status" -ne 0 ]
}
+16
View File
@@ -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.
+9
View File
@@ -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
+9
View File
@@ -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.
+9
View File
@@ -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 -4
View File
@@ -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