Compare commits

...
Author SHA1 Message Date
branderson bc05990e33 Fix five README and ATTRIBUTION points, and compress three more doc sections
tests / runner-env (confined) (pull_request_target) Successful in 1m39s
tests / confined (proxy) (pull_request_target) Successful in 2m11s
tests / confined (static) (pull_request_target) Successful in 3m24s
tests / confined (providers) (pull_request_target) Successful in 3m52s
tests / confined (remote) (pull_request_target) Successful in 4m28s
tests / confined (project) (pull_request_target) Successful in 5m27s
tests / confined (config) (pull_request_target) Successful in 6m24s
tests / runner-env (privileged) (pull_request_target) Successful in 1m37s
tests / confined (audit) (pull_request_target) Successful in 7m27s
tests / privileged (pull_request_target) Successful in 5m36s
README:
- The Loki bullet read as though shipping were the default. It is off unless
  [logging] loki_url points at an instance of your own, and there is no
  endpoint of ours in it - which a reader who does not know Loki cannot be
  expected to assume.
- "So this one is work on the runner" -> "Preventing this requires hardening
  your CI runner".
- New bullet for the ~/.claude isolation. A session gets its own project's
  transcripts, memory and edit history and nothing else. That is a property
  users should be told about rather than a fix buried in security.md.
- "One maintainer, no support" moves below the feature list. It interrupted
  the run from what dejima is to what it ships, and reads better once the
  reader knows what is on offer.

ATTRIBUTION.md scopes the trademark claim to the software. dejima is a real
island in Nagasaki harbour, rebuilt and standing today, and a file that reads
as claiming the name of a real place is both wrong and rude. A mark attaches
to particular goods and services, never to a word in the abstract, so the
terms now say so and disclaim the historical and geographic senses outright.
The artwork restrictions stay absolute; the name restriction is about being
taken for this project, which is the only sense in which a name can be
claimed.

Also compressed: tarball dependencies, ci.md's runner-reachability section,
and providers.md's validation rules. Net -314 lines overall.
2026-08-10 20:29:00 -07:00
branderson e837634c1f docs: compress the audit, credential-proxy and notification sections
Same rule as the comment passes. The exit-time audit section was half of
security.md and had grown a second explanation of itself alongside the first:
the enumerated exec keys, the withholding rules and the multi-value git config
case all stay, since each names a decision someone would otherwise undo. The
retelling around them went.

The test-suite bullet no longer counts tests. "Over 300 bats tests across six
files" was wrong within a week of being written and drifts every time a suite
is added, which is the argument against numbers in prose that nothing checks.

Rebased onto main, taking the README tagline edit and #112's worktree
paragraph, which landed in a bullet this pass rewrote. Both are kept - the
worktree text is compressed to match the surrounding register rather than
dropped.
2026-08-10 20:28:33 -07:00
branderson 283527fc6d docs: compress the exit-time audit section 2026-08-10 20:28:33 -07:00
branderson efb92cb703 Compress another fourteen comment blocks
Second pass, same rule: the fact that stops a regression stays, the retelling
goes. Net -272 across 21 files now.

Mostly file headers this time, which had drifted into explaining the design
twice - once to justify it and once to describe it. dejima-ttyd's flag notes,
merge-guard's, gitea-actions', the config schema's, and the two Dockerfile
blocks all say the same things in half the lines.

The load-bearing detail is untouched: --check-origin's WebSocket
same-origin argument, reqOwner() being unfixable by widening a token, the
port-443 restriction being the read-only guarantee, and the ERR trap's reason
for existing all read the same.
2026-08-10 20:28:32 -07:00
branderson 77a298264c Compress the longest comment blocks, keep what they were for
Net -195 lines across 13 files. Every comment here still says the thing that
stops the next person reinstating the bug: the issue number, the failure mode,
the reason a check is written the way it is. What went is the narrative around
it - the retelling of how a bug was found, consequence chains spelled out to
their third step, restatements of what the code above already shows.

Nothing was cut because it was long. The blocks that survive at length are the
ones where the reasoning is the content: the O_NOFOLLOW open in
dejima-project-lib.sh, the id-derivation anchoring in dejima-audit, the
enumerated ~/.claude mounts.

Two operator-environment details are gone: a directory size and checkout count
from the machine this was written on, in bin/dejima and docs/security.md. They
were useful for judging severity in a handoff and have no business in a public
repository - the same distinction dejima-ci-hardener's addresses.bats exists
to keep.
2026-08-10 20:28:32 -07:00
branderson d31c707217 Merge pull request 'Say plainly that this is unreviewed early development' (#117) from maturity-disclaimer into main
Reviewed-on: #117
Reviewed-by: branderson <brad@branderson.io>
2026-08-10 20:17:19 -07:00
branderson 0e09060555 Say plainly that this is unreviewed early development
tests / confined (proxy) (pull_request_target) Successful in 2m10s
tests / runner-env (confined) (pull_request_target) Successful in 1m38s
tests / confined (static) (pull_request_target) Successful in 3m35s
tests / confined (providers) (pull_request_target) Successful in 4m2s
tests / confined (remote) (pull_request_target) Successful in 4m38s
tests / privileged (pull_request_target) Successful in 5m15s
tests / confined (project) (pull_request_target) Successful in 5m45s
tests / runner-env (privileged) (pull_request_target) Successful in 1m39s
tests / confined (config) (pull_request_target) Successful in 6m53s
tests / confined (audit) (pull_request_target) Successful in 7m56s
The no-support notice said nothing was promised, which reads as a statement
about responsiveness rather than about maturity. Someone could take it at
face value and still conclude the software itself is finished.

It is not. Nothing here has been through outside review, an external audit,
or adversarial testing by anyone but its author. The reasoning is one
person's and the tests assert what that person thought to check, which is a
weaker thing than it can look like from a repository that argues at length
for its own security properties.

In all three places a reader lands: the README opening, above the no-support
block; SECURITY.md, as its own section ahead of the support and warranty
terms, since that file is canonical for the organisation; and the top of
docs/security.md, which is a thousand lines of confident claims and the place
over-confidence is most likely to take hold.

Pinned by a test. This is the first line that gets quietly dropped as a
project starts to feel established, and dropping it should take a deliberate
edit to a test rather than an oversight.
2026-08-10 19:51:38 -07:00
branderson e7f5a9b541 Merge pull request 'Say that dejima is starting, and wear the badge' (#114) from launch-progress into main
Reviewed-on: #114
Reviewed-by: branderson <brad@branderson.io>
2026-08-10 18:28:26 -07:00
branderson 54b00d73a6 Use the wordmark's own green in the badge
tests / confined (proxy) (pull_request_target) Successful in 2m7s
tests / runner-env (confined) (pull_request_target) Successful in 1m37s
tests / confined (providers) (pull_request_target) Successful in 3m46s
tests / confined (project) (pull_request_target) Successful in 5m10s
tests / confined (config) (pull_request_target) Successful in 6m16s
tests / confined (audit) (pull_request_target) Successful in 7m24s
tests / runner-env (privileged) (pull_request_target) Successful in 1m38s
tests / confined (static) (pull_request_target) Successful in 3m27s
tests / confined (remote) (pull_request_target) Successful in 4m29s
tests / privileged (pull_request_target) Successful in 5m19s
#98971a was gruvbox's faded green, chosen from the palette rather than from
the mark. Sampling the wordmark gives #8ec07c for the island tile, so the two
now agree. Dark text on it is 7.0:1 rather than 4.8:1.
2026-08-10 14:52:43 -07:00
branderson ecb886f658 Move the badge to the end of the opening, and point it at dejima
tests / confined (remote) (pull_request_target) Failing after 1m18s
tests / runner-env (confined) (pull_request_target) Successful in 1m35s
tests / confined (proxy) (pull_request_target) Successful in 2m7s
tests / confined (static) (pull_request_target) Successful in 3m19s
tests / confined (providers) (pull_request_target) Successful in 3m44s
tests / privileged (pull_request_target) Successful in 5m3s
tests / confined (project) (pull_request_target) Successful in 5m9s
tests / runner-env (privileged) (pull_request_target) Successful in 1m34s
tests / confined (config) (pull_request_target) Successful in 6m3s
tests / confined (audit) (pull_request_target) Successful in 7m6s
It sat under the wordmark, where two marks stacked read as branding rather
than as a statement about the project. Last thing before "The name" puts it
after the reader knows what dejima is, which is where the badge means
something.

The link goes to the project, not the badge repository - what a reader wants
from it is dejima, and that is what the badge repo already tells everyone
else to link to.
2026-08-10 14:51:15 -07:00
branderson aa11bdd27b Say that dejima is starting, and wear the badge
tests / confined (proxy) (pull_request_target) Successful in 2m13s
tests / confined (static) (pull_request_target) Successful in 3m35s
tests / confined (providers) (pull_request_target) Successful in 4m6s
tests / confined (remote) (pull_request_target) Successful in 4m42s
tests / runner-env (privileged) (pull_request_target) Successful in 1m36s
tests / confined (project) (pull_request_target) Successful in 5m44s
tests / confined (config) (pull_request_target) Successful in 6m47s
tests / confined (audit) (pull_request_target) Successful in 7m48s
tests / runner-env (confined) (pull_request_target) Successful in 1m40s
tests / privileged (pull_request_target) Successful in 5m13s
Everything between the command and the container's first output is silent -
the audit snapshot, an image build that prints nothing when every layer is
cached, and Docker creating the container with a mount per allowed path. Long
enough to read as hung. Two lines to stderr now say what is happening, and
name the first-run case specifically, since building the image takes minutes
and is when someone is most likely to interrupt.

Placed after --print-config and --print-briefing exit, so machine-readable
output stays machine-readable on both streams.

The badge is dejima's own, as the first project to carry it. The SVG is
copied into assets/ rather than hotlinked or fetched from shields.io: a badge
in a README should not make every reader of it fetch from a third party, and
that goes double for a project whose argument is about not trusting reach you
did not choose.

test/static.bats' image check now covers markdown image syntax and svg, not
just <img> and png - the badge is referenced the markdown way, and would have
sat outside the check that exists for exactly this.
2026-08-10 14:41:34 -07:00
branderson 32841b4a2e Update README.md 2026-08-10 00:21:23 -07:00
branderson 760af9946f Merge pull request 'Tell the session a worktree does not isolate .git/config' (#112) from worktree-config-briefing into main
Reviewed-on: #112
Reviewed-by: branderson <brad@branderson.io>
2026-08-10 00:03:44 -07:00
branderson b7f641594b Tell the session a worktree does not isolate .git/config
tests / runner-env (confined) (pull_request_target) Successful in 1m39s
tests / confined (proxy) (pull_request_target) Successful in 2m8s
tests / confined (providers) (pull_request_target) Successful in 4m13s
tests / confined (remote) (pull_request_target) Successful in 4m51s
tests / confined (project) (pull_request_target) Successful in 5m59s
tests / confined (config) (pull_request_target) Successful in 7m2s
tests / confined (audit) (pull_request_target) Successful in 8m2s
tests / confined (static) (pull_request_target) Successful in 3m45s
tests / privileged (pull_request_target) Successful in 5m25s
tests / runner-env (privileged) (pull_request_target) Successful in 1m37s
A worktree isolates the working tree; `.git/config` is shared with the parent
checkout. So a bare `git config <key> <value>` run inside a worktree writes
the user's real repository config. That happened: an implementer set
`user.name` while reproducing a fixture by hand and misattributed three
commits, noticed only because a later subagent read an odd author off
`git log`.

The briefing tells a session to work in a worktree, and says nothing about
what a worktree does not cover, which reads as more isolation than is on
offer. It now says both, and gives the scoped form.

In `_briefing_core_worktree` rather than a workflow's own prose, so a
workflow can opt into worktrees but cannot ship them without this - same
reasoning as the containment paragraph already there.

A guard, not a boundary, and the doc says so. `.git/config` sits in the
read-write project mount and cannot be made read-only without breaking
`git push -u`, which writes `branch.<name>.merge` on every push. Detection is
what covers a session that does not care: the audit already watches that file
for `core.hooksPath`, `credential.helper` and the other exec keys the same
unscoped command reaches, and is how this was found.

Closes #32
2026-08-09 23:43:44 -07:00
branderson cd8ac7be0f Merge pull request 'Give each project its own nvim volumes' (#111) from nvim-volumes-per-project into main
Reviewed-on: #111
Reviewed-by: branderson <brad@branderson.io>
2026-08-09 23:41:42 -07:00
branderson 8438daa07e Give each project its own nvim volumes
tests / confined (proxy) (pull_request_target) Successful in 2m8s
tests / runner-env (confined) (pull_request_target) Successful in 1m37s
tests / confined (static) (pull_request_target) Successful in 3m38s
tests / confined (providers) (pull_request_target) Successful in 3m56s
tests / runner-env (privileged) (pull_request_target) Successful in 1m39s
tests / confined (audit) (pull_request_target) Successful in 8m10s
tests / confined (remote) (pull_request_target) Successful in 4m47s
tests / privileged (pull_request_target) Successful in 5m22s
tests / confined (project) (pull_request_target) Successful in 5m56s
tests / confined (config) (pull_request_target) Successful in 6m14s
The nvim data, state and cache directories are Docker volumes rather than
host paths, so nothing a session installs has ever reached the host's own
nvim. They do outlive the container, which is the point - a plugin install
should not happen on every launch - but they carried one fixed name each and
so were shared by every project on the machine.

That made them a persistence channel between sandboxes. A session writes a
Lua file under `lazy/`, or replaces a binary under `mason/bin/`, and the next
session to run nvim in the sandbox executes it, whatever project that session
is working on. `mason/bin` is the sharper half: those are invoked directly,
with no load path to arrange and no plugin to enable. It is bounded by the
container - a payload there runs in another sandbox, not on the host - but
cross-project persistence is what this repository closes everywhere else.

Scoped per project, the same way the derived image tag already is. The
service still refers to the static compose keys; only the volume Docker
creates varies, and the defaults reproduce the previous names so a bare
`docker compose` behaves as it did.

The `dejima_nvim-*` prefix is load-bearing rather than cosmetic: project_slug
maps a leading `/` to `-`, and a Docker volume name may not start with one.
Without it every launch would fail inside `compose run` rather than anywhere
that names the cause, so there is a test for it.
2026-08-09 23:27:38 -07:00
branderson 631b73ae5f Merge pull request 'Add --extra-readonly and --extra-read-write for one-off mounts' (#109) from extra-mount-flags into main
Reviewed-on: #109
Reviewed-by: branderson <brad@branderson.io>
2026-08-09 23:20:43 -07:00
branderson d0052161ef Merge pull request 'Mount ~/.claude/file-history one session at a time, not the whole tree' (#110) from file-history-scope into main
Reviewed-on: #110
Reviewed-by: branderson <brad@branderson.io>
2026-08-09 23:19:17 -07:00
branderson 8a26412716 Add --extra-readonly and --extra-read-write for one-off mounts
tests / confined (proxy) (pull_request_target) Successful in 2m10s
tests / runner-env (confined) (pull_request_target) Successful in 1m39s
tests / confined (static) (pull_request_target) Successful in 3m40s
tests / confined (providers) (pull_request_target) Successful in 3m59s
tests / confined (remote) (pull_request_target) Successful in 4m50s
tests / privileged (pull_request_target) Successful in 5m24s
tests / confined (project) (pull_request_target) Successful in 5m27s
tests / confined (config) (pull_request_target) Successful in 6m52s
tests / runner-env (privileged) (pull_request_target) Successful in 1m37s
tests / confined (audit) (pull_request_target) Successful in 8m4s
Reading another checkout while migrating code out of it, or writing to a
scratch directory for one session, meant editing the config file and then
remembering to edit it back. These do it per launch: --extra-readonly PATH
and --extra-read-write PATH, short forms --ro and --rw, both repeatable.

They append to [mounts] readonly and [mounts] read_write rather than carrying
their own mount code, which is the design rather than an implementation
detail. Everything that already applies to a configured mount then applies to
a flag with nothing duplicated and no second path to keep in step: the
existence check, the refusal when a path overlaps the approval store or the
audit stores, the refusal when one path appears in both lists, and the
read-write paths named in the session briefing and the audit report header.
A flag says where, not whether, and no flag waives a refusal.

Two differences from the config form, both because a command line is not a
file. A relative path is accepted and made absolute against the working
directory - by prefixing rather than by readlink -f, so a symlinked path is
mounted where the operator typed it rather than at its target. And a path
containing a colon is refused: the lists are colon-separated, so it cannot be
represented in one and would otherwise split into two mounts that each fail
the existence check.

The refusals name the flag rather than the config key when a flag supplied
the path, since "[mounts] readonly entry" sends someone to a file that does
not mention it. That record is kept at parse time: the lists are assembled
inside a command substitution, whose subshell would discard it and report
every flag as a config entry.
2026-08-09 23:01:20 -07:00
branderson 0a323ed230 Mount ~/.claude/file-history one session at a time, not the whole tree
tests / confined (proxy) (pull_request_target) Successful in 2m13s
tests / runner-env (confined) (pull_request_target) Successful in 1m37s
tests / confined (providers) (pull_request_target) Successful in 3m57s
tests / confined (remote) (pull_request_target) Successful in 4m52s
tests / confined (config) (pull_request_target) Successful in 6m7s
tests / privileged (pull_request_target) Successful in 9m21s
tests / confined (static) (pull_request_target) Successful in 3m40s
tests / confined (project) (pull_request_target) Successful in 5m33s
tests / confined (audit) (pull_request_target) Successful in 8m18s
tests / runner-env (privileged) (pull_request_target) Successful in 1m40s
The tree was bind-mounted read-write in full, and each
`<session>/<hash>@v1` in it is a verbatim copy of an edited file. So it is
other projects' source rather than a record about it, which makes the read
side worse than the transcripts #104 covered: on the host this was developed
on, 47M across ten checkouts, including a dotfiles repo. The write side puts
chosen content into a later restore in another project.

A launch now gets the sessions belonging to its own workspace and nothing
else - 11 directories here instead of 50.

Keyed by session id, so unlike projects/ there is no slug in these paths to
scope by. The transcripts supply it: projects/<slug>/<id>.jsonl and
file-history/<id> name the same session, so the slugs a workspace owns select
the session ids it owns. That routes through workspace_slugs_sorted, so the
sibling case is excluded here for free - a naive count of "dejima's" history
says 12, and one of those belongs to dejima-ci-hardener.

The session about to start has an id nothing can know in advance, so its own
directory is container-local and does not survive the run. That costs undo
history for that session if it is later resumed on the host. It is the one
respect in which this cannot be scoped as tightly as projects/, where the
workspace's own slug is known at launch, and it is why the image pre-creates
file-history/: without it Docker creates the parent root-owned while setting
up the mounts nested in it, and the session cannot record its own undo entry
at all.

The static test asserting that compose still mounts the tree, and the
Dockerfile mkdir test whose regex is anchored to the RUN line, both had to
move with it.
2026-08-09 22:50:05 -07:00
branderson db989b3277 Merge pull request 'Refuse a read-only mount that would expose the approval store' (#108) from approval-store-mount-guard into main
Reviewed-on: #108
Reviewed-by: branderson <brad@branderson.io>
2026-08-09 21:52:15 -07:00
branderson 3a6acf102a Refuse a read-only mount that would expose the approval store
tests / confined (proxy) (pull_request_target) Successful in 2m13s
tests / runner-env (confined) (pull_request_target) Successful in 1m35s
tests / confined (static) (pull_request_target) Successful in 3m32s
tests / confined (providers) (pull_request_target) Successful in 3m48s
tests / confined (remote) (pull_request_target) Successful in 4m37s
tests / confined (project) (pull_request_target) Successful in 5m16s
tests / runner-env (privileged) (pull_request_target) Successful in 1m38s
tests / confined (audit) (pull_request_target) Successful in 7m51s
tests / privileged (pull_request_target) Successful in 5m13s
tests / confined (config) (pull_request_target) Successful in 5m59s
Two mount paths reached `extra_mounts+=(-v ...)` with a path computed at run
time, and neither was checked: a `[mounts] readonly` entry naming the store,
and a ~/.claude symlink whose target directory sits inside it. Reproduced
directly against main - `readonly = <approval store>` produces
`-v <store>:<store>:ro` and the launch exits 0 with nothing said.

The static test guarding "the approval store is never mounted into the
container" greps bin/dejima for a literal $PROJECT_STORE or config_root, so
it could not see either one and passed throughout. That is what #15 named.

Read-only is not enough to make those mounts harmless. The approval store
discloses every project's approved domains and dependencies, and the audit
baselines say which findings were already waved through.

`[mounts] read_write` has been guarded since it was added. The guard is now a
function, and the two unguarded paths call it with the stores alone - not
read_write's wider list, which also covers $REPO_ROOT and the workspace,
because mounting those read-only is ordinary and expected.

The half of #15 that is already closed: the symlink is no longer
session-plantable. It required ~/.claude to be mounted read-write, and the
enumerated-mounts rework made the five scanned directories read-only.
Verified from inside a running container - every one of them refuses a
symlink with "Read-only file system", and ~/.claude itself is container-local.
So what is left is an operator's own symlink, and the invariant.

Closes #15
2026-08-09 21:41:58 -07:00
branderson 18cff7eece Merge pull request 'Move the devcontainer credit to License, and quieten the ssh-agent warning' (#107) from lineage-ssh-warning into main
Reviewed-on: #107
Reviewed-by: branderson <brad@branderson.io>
2026-08-09 21:12:32 -07:00
branderson da0920d13f Merge pull request 'Mount ~/.claude/projects one slug at a time, not the whole tree' (#105) from projects-slug-mount into main
Reviewed-on: #105
Reviewed-by: branderson <brad@branderson.io>
2026-08-09 21:11:57 -07:00
branderson e31c651a5e Move the devcontainer credit to License, and quieten the ssh-agent warning
tests / confined (proxy) (pull_request_target) Successful in 2m11s
tests / runner-env (confined) (pull_request_target) Successful in 1m38s
tests / confined (static) (pull_request_target) Successful in 3m40s
tests / confined (providers) (pull_request_target) Successful in 3m56s
tests / confined (remote) (pull_request_target) Successful in 4m50s
tests / privileged (pull_request_target) Successful in 5m15s
tests / confined (project) (pull_request_target) Successful in 5m27s
tests / runner-env (privileged) (pull_request_target) Successful in 1m39s
tests / confined (config) (pull_request_target) Successful in 5m56s
tests / confined (audit) (pull_request_target) Successful in 8m4s
The credit moves out of the opening and into the License section, naming the
part it applies to. docker/init-firewall.sh, docker/Dockerfile,
hooks/sandbox-context.sh and docs/security.md carry it at the point of use
and are unchanged.

bin/dejima warned that ssh-agent had no identities loaded even when
[core] ssh_key was set, and told the operator the push would fail "unless
[core] ssh_key is also set" while it was. With a key configured the premise
does not hold: the key is loaded into the container's own agent and pushes
work without the forwarded one carrying anything. The warning is now silent
in that case, and its last line points at the key rather than treating it as
an afterthought.

Two tests in config.bats, driving a real ssh-agent with nothing added to it,
since a handmade socket does not reproduce the `ssh-add -l` failure the
warning keys on. The first fails against the previous behaviour.
2026-08-09 20:03:46 -07:00
branderson 7079fca1cd Merge pull request 'Stop the remote tests from overwriting the real dejima-audit' (#106) from test-audit-stub into main
Reviewed-on: #106
Reviewed-by: branderson <brad@branderson.io>
2026-08-09 19:54:23 -07:00
branderson 4dfa6e3927 Stop the remote tests from overwriting the real dejima-audit
tests / runner-env (confined) (pull_request_target) Successful in 1m39s
tests / confined (proxy) (pull_request_target) Successful in 2m11s
tests / confined (static) (pull_request_target) Successful in 3m38s
tests / confined (providers) (pull_request_target) Successful in 3m58s
tests / confined (remote) (pull_request_target) Successful in 4m50s
tests / confined (project) (pull_request_target) Successful in 5m29s
tests / confined (config) (pull_request_target) Successful in 5m35s
tests / confined (audit) (pull_request_target) Successful in 8m5s
tests / privileged (pull_request_target) Successful in 9m19s
tests / runner-env (privileged) (pull_request_target) Successful in 1m38s
Two tests need an exit-time audit that fails, and they got one by writing a
two-line `exit 3` stub over `$REPO_ROOT/bin/dejima-audit` and restoring it
from a backup afterwards. Three things wrong with that:

A run killed partway never reaches the restore, so the checkout is left
holding the stub. Two runs of this file at once are worse: the second backs
up the first's stub as the "real" script and restores that, permanently. A
suite that passes while silently disabling the exit-time audit is the exact
failure this file exists to prevent.

And it reads like an attack. A file watcher that samples the tree during the
window sees a security tool replaced by `#!/bin/bash` / `exit 3`, with no
indication anywhere in the bytes that a test put it there. That is worth
avoiding on its own: a real compromise should not have somewhere innocent to
hide, and an alarm that fires on ordinary test runs is an alarm that stops
being read.

Tests now mutate a throwaway copy under $BATS_TEST_TMPDIR instead. bin/ is
copied rather than symlinked, which is load-bearing twice over: bin/dejima
resolves its own location with readlink -f, so a symlink would send
SCRIPT_DIR back to the repository; and `cat >` through a symlink truncates
the target, so linking would have written the real script anyway. The first
draft of this change did link, and the new teardown_file guard caught it
overwriting bin/dejima-audit on the first run - which is the argument for
having the guard.

The stub also says what it is now, so the alarming version of it explains
itself if anything ever does surface it.

Two guards, because the fixture is not the only way to get this wrong:
teardown_file hashes bin/dejima-audit around the whole file, and a static
test refuses any redirect or removal aimed at $REPO_ROOT or
$BATS_TEST_DIRNAME from any suite.
2026-08-09 19:37:26 -07:00
branderson b7880c1084 Mount ~/.claude/projects one slug at a time, not the whole tree
tests / runner-env (confined) (pull_request_target) Successful in 1m33s
tests / confined (proxy) (pull_request_target) Successful in 2m23s
tests / confined (static) (pull_request_target) Successful in 3m50s
tests / confined (providers) (pull_request_target) Successful in 4m9s
tests / confined (remote) (pull_request_target) Successful in 4m59s
tests / confined (project) (pull_request_target) Successful in 5m39s
tests / confined (config) (pull_request_target) Successful in 5m43s
tests / confined (audit) (pull_request_target) Successful in 8m14s
tests / privileged (pull_request_target) Successful in 12m32s
tests / runner-env (privileged) (pull_request_target) Successful in 1m25s
The tree holds every project's transcripts and agent memory, mounted
read-write. Memory is loaded into context at session start, so a write into
another project's memory/ and its MEMORY.md index is text a future session
there reads as established background rather than as input. No container
escape and no forge credential were needed for that; the mount was enough.

A launch now gets the workspace's own slug plus one per live git worktree
nested under it, and nothing else. On the host this repo was developed on
that is 17 directories instead of 71. Sibling projects are absent rather than
present-but-unwritable, which is the first-run state Claude Code already
handles - the risk that a read-only projects/ would hang it, the way a
blocked ~/.claude.json write did, does not arise, because projects/ itself
stays container-local and writable.

The set comes from real worktree paths, not from the slug string, and that is
load-bearing. A slug cannot tell a child from a sibling: /w/proj/wt1 and
/w/proj-wt1 produce the same one. The audit's existing "$slug"-* glob
therefore treated any project checked out beside this one whose directory
name merely extends it as part of it - on this host, a session in dejima had
dejima-ci-hardener's writes silently exempted from cross-project reporting.
Sharing that glob with the mount would have carried the hole straight into
it, so both now call one function in bin/dejima-slug-lib.sh. Hosts with
sibling-named projects will see CROSS-PROJECT findings they were not getting
before; those are real writes, not new false positives.

Detection is unaffected: dejima-audit runs host-side over the whole tree, so
narrowing the mount does not narrow what it can see. It now covers what the
mount cannot reach, which is concurrent host sessions.

The runtime assertion could not be run here - this sandbox has no Docker
daemon - so the privileged CI job is the first thing to execute it.

Closes #104
2026-08-09 13:53:22 -07:00
branderson d7900f1b03 Merge pull request 'Put the wordmark on the README, publish the org-wide policy files, and comb the docs' (#103) from branding into main
Reviewed-on: #103
Reviewed-by: branderson <brad@branderson.io>
2026-08-09 03:12:00 -07:00
branderson d234e473de Indent the wordmark and open up the gap under the rule
tests / confined (proxy) (pull_request_target) Successful in 1m56s
tests / runner-env (confined) (pull_request_target) Successful in 1m24s
tests / confined (providers) (pull_request_target) Successful in 3m29s
tests / confined (config) (pull_request_target) Successful in 4m51s
tests / confined (project) (pull_request_target) Successful in 4m51s
tests / confined (static) (pull_request_target) Successful in 3m22s
tests / confined (remote) (pull_request_target) Successful in 4m27s
tests / privileged (pull_request_target) Successful in 4m50s
tests / confined (audit) (pull_request_target) Successful in 7m19s
tests / runner-env (privileged) (pull_request_target) Successful in 1m24s
A leading &nbsp; on the same line as the tag puts the image inside a
paragraph and indents it by one space. hspace would be the obvious way to do
that and is the wrong one: it survives Gitea sanitizing and is then ignored by
every browser that dropped HTML4 presentation attributes, so it reads as
working in the source and does nothing on the page.
2026-08-09 01:43:34 -07:00
branderson 96c8906351 Space the wordmark above, rule it off below
tests / runner-env (confined) (pull_request_target) Successful in 1m24s
tests / confined (proxy) (pull_request_target) Successful in 1m59s
tests / confined (static) (pull_request_target) Successful in 3m23s
tests / confined (providers) (pull_request_target) Successful in 3m39s
tests / confined (remote) (pull_request_target) Successful in 4m23s
tests / privileged (pull_request_target) Successful in 4m49s
tests / confined (project) (pull_request_target) Successful in 4m52s
tests / confined (config) (pull_request_target) Successful in 4m58s
tests / runner-env (privileged) (pull_request_target) Successful in 1m23s
tests / confined (audit) (pull_request_target) Successful in 7m19s
The blank line moves to above the tag, and an <hr> separates the wordmark
from the tagline underneath it.
2026-08-09 01:41:49 -07:00
branderson 2c75ef0661 Use a character, not a break, for the wordmark spacing
tests / confined (proxy) (pull_request_target) Successful in 2m0s
tests / confined (providers) (pull_request_target) Successful in 3m49s
tests / runner-env (confined) (pull_request_target) Successful in 1m26s
tests / privileged (pull_request_target) Successful in 5m4s
tests / confined (project) (pull_request_target) Successful in 5m20s
tests / confined (config) (pull_request_target) Successful in 5m23s
tests / runner-env (privileged) (pull_request_target) Successful in 1m25s
tests / confined (static) (pull_request_target) Successful in 3m32s
tests / confined (audit) (pull_request_target) Successful in 7m50s
tests / confined (remote) (pull_request_target) Successful in 4m41s
The <br> did not render. A break at the end of a block ends a line that
nothing follows, and browsers give that no height, so the tagline stayed
where it was. &nbsp; survives Gitea sanitizing as a real U+00A0, which means
the paragraph holding it has content and occupies a line. Verified against
the instance markdown endpoint: the rendered output is <p>\xa0</p>, not a
collapsing space.
2026-08-09 01:34:25 -07:00
branderson 9a0c56f9a7 Give the wordmark room above the tagline
tests / confined (proxy) (pull_request_target) Successful in 1m58s
tests / runner-env (confined) (pull_request_target) Successful in 1m25s
tests / confined (static) (pull_request_target) Successful in 3m28s
tests / confined (providers) (pull_request_target) Successful in 3m45s
tests / confined (remote) (pull_request_target) Successful in 4m36s
tests / privileged (pull_request_target) Successful in 5m5s
tests / confined (project) (pull_request_target) Successful in 5m16s
tests / confined (config) (pull_request_target) Successful in 5m20s
tests / runner-env (privileged) (pull_request_target) Successful in 1m25s
tests / confined (audit) (pull_request_target) Successful in 7m50s
An image alone between two blank lines is not wrapped in a paragraph, so it
has no bottom margin and the tagline sits right under it. `style` is not the
fix: Gitea's sanitizer strips it from the tag and from any wrapper, verified
against the instance's own markdown endpoint, and `vspace` survives
sanitizing only to be ignored by every browser that dropped HTML4 presentation
attributes. A `<br>` renders on both Gitea and GitHub, so that is what this
uses, with the reasoning in the comment above the tag.
2026-08-09 01:29:37 -07:00
branderson ab8e26437c Comb the docs for the tics that mark them as agent-written
tests / confined (proxy) (pull_request_target) Successful in 2m2s
tests / confined (providers) (pull_request_target) Successful in 3m51s
tests / runner-env (confined) (pull_request_target) Successful in 1m26s
tests / confined (static) (pull_request_target) Successful in 3m31s
tests / confined (remote) (pull_request_target) Successful in 4m41s
tests / privileged (pull_request_target) Successful in 5m10s
tests / confined (project) (pull_request_target) Successful in 5m20s
tests / confined (config) (pull_request_target) Successful in 5m25s
tests / runner-env (privileged) (pull_request_target) Successful in 1m25s
tests / confined (audit) (pull_request_target) Successful in 7m56s
The prose leaned on a handful of constructions hard enough to be a tell,
mostly the "X, not Y" antithesis as a closing beat and the meta-commentary
that tells a reader how to read the sentence they just read. Where the
contrast carries information, and in a security doc it usually does, it
stays; where it was rhythm, it goes. "worth knowing" went from nine uses to
none, "which is the point" and "the whole point" from three to none, and the
filler senses of "actually", "simply" and "precisely" are gone while the
load-bearing ones (nominal versus real) are untouched.

No claim changes. The agent-voice section is deliberately untouched, since
its whole premise is that it was written by one.

Provider axes is now three bulleted lists rather than a run-on sentence
naming seven directories, and ATTRIBUTION.md credits gruvbox for the palette.
2026-08-09 00:44:43 -07:00
branderson 034913205a Put the wordmark on the README, and publish the org-wide policy files
The README points at one wordmark file unconditionally rather than switching
on `prefers-color-scheme`, which reports the browser's setting and never the
site's. A forge that renders dark whichever way the browser is set would then
show dark ink on a dark page, silently, to a reader whose browser prefers
light. Both files ship, so a mirror on a light-themed host changes one line
where someone can see whether it worked. The suffix names the mode a file is
for, not the colour of its ink, so `-darkmode` is the pale one.

ATTRIBUTION.md and SECURITY.md are canonical for the whole organisation and
live here because this is the repository the others link back to. Keeping one
copy is the point: the terms cannot drift into two divergent statements of
who holds the marks, and a reporter cannot find two different addresses to
send a vulnerability to. dejima-ci-hardener links to both by absolute URL, so
a rename here is a 404 in a repository whose tests cannot see this one, which
is why static.bats pins the two filenames.

The README's opening now says there is one maintainer and no support, where
somebody deciding whether to deploy this sees it before investing effort.
2026-08-08 23:41:45 -07:00
branderson 9cb6b7b432 Merge pull request 'docs: point at the dejima organization' (#102) from org-move into main
Reviewed-on: #102
Reviewed-by: branderson <brad@branderson.io>
2026-08-08 20:34:08 -07:00
branderson 32589c0603 Merge branch 'main' into org-move
tests / runner-env (confined) (pull_request_target) Successful in 1m42s
tests / confined (proxy) (pull_request_target) Successful in 2m5s
tests / confined (static) (pull_request_target) Successful in 3m42s
tests / confined (providers) (pull_request_target) Successful in 3m55s
tests / confined (remote) (pull_request_target) Successful in 4m50s
tests / confined (project) (pull_request_target) Successful in 5m29s
tests / confined (config) (pull_request_target) Successful in 5m33s
tests / confined (audit) (pull_request_target) Successful in 8m2s
tests / privileged (pull_request_target) Successful in 11m41s
tests / runner-env (privileged) (pull_request_target) Successful in 1m25s
2026-08-08 20:19:04 -07:00
branderson 3a72114770 Merge pull request 'ci: one vocabulary for the two runner tiers' (#101) from tier-vocabulary into main
Reviewed-on: #101
Reviewed-by: branderson <brad@branderson.io>
2026-08-08 20:17:54 -07:00
branderson e69d35a8a4 docs: point at the dejima organization
tests / runner-env (sandbox) (pull_request_target) Waiting to run
tests / runner-env (host-docker) (pull_request_target) Waiting to run
tests / sandbox (remote) (pull_request_target) Waiting to run
tests / sandbox (static) (pull_request_target) Waiting to run
tests / host-docker (pull_request_target) Waiting to run
tests / sandbox (audit) (pull_request_target) Has been cancelled
tests / sandbox (config) (pull_request_target) Has been cancelled
tests / sandbox (project) (pull_request_target) Has been cancelled
tests / sandbox (providers) (pull_request_target) Has been cancelled
tests / sandbox (proxy) (pull_request_target) Has been cancelled
Both repositories moved out of the personal namespace and into a `dejima`
organization, so every link into either one is now one path segment wrong:
four to dejima-ci-hardener and one to this repository's own issue #97.

Gitea redirects an old path, so none of these were broken. They were merely
lying about where the thing lives, which is the kind of stale that survives
indefinitely because nothing fails.

Only the organization segment changes. The host stays `gitbox.branderson.io`,
which is the name a reader resolves; the git remote uses the internal
`gitbox.bradina.localdomain` and is not a documentation concern.

Nothing in `bin/`, `test/` or the workflow hardcoded the old owner, so this is
documentation only. The forge CLIs read owner and repo out of the git remote
rather than from anything committed here, which is why moving a repository
does not require a code change to keep them working.
2026-08-08 19:17:44 -07:00
branderson 07e21f86b1 ci: one vocabulary for the two runner tiers
tests / sandbox (proxy) (pull_request_target) Successful in 2m2s
tests / runner-env (sandbox) (pull_request_target) Successful in 1m26s
tests / sandbox (static) (pull_request_target) Successful in 3m29s
tests / sandbox (providers) (pull_request_target) Successful in 3m59s
tests / sandbox (remote) (pull_request_target) Successful in 4m41s
tests / host-docker (pull_request_target) Successful in 4m51s
tests / runner-env (host-docker) (pull_request_target) Successful in 1m24s
tests / sandbox (project) (pull_request_target) Successful in 5m18s
tests / sandbox (config) (pull_request_target) Successful in 5m21s
tests / sandbox (audit) (pull_request_target) Successful in 8m1s
Two tiers were carrying three sets of names. dejima-ci-hardener called them
confined and privileged, the runner labels called them dejima-ci and
dejima-runtime, and the workflow and tests called them sandbox and host-docker.
Someone reading a red `runner-env (host-docker)` and then opening the runbook
to fix it had to map three ways before they could act.

Now: `dejima-confined` and `dejima-privileged` as labels, `confined` and
`privileged` as job names and DEJIMA_CI_TIER values. The hardener's runner
generator derives its default label from the role it was given
(`dejima-$ROLE`), so accepting that default produces exactly the strings this
file names, and neither side is typed from memory.

Deliberately not a substitution. `sandbox` in this repository overwhelmingly
means the dejima container, not a CI tier: git-push-guard.sh, providers.md,
security.md, responsible-use.md and most of .dejima.md all use it that way and
are untouched. Only the tier sense moved, and test/ci-runner.bats no longer
contains the word at all, which is right for a file that is entirely about
tiers.

`/tmp/dejima-ci` stays as it is. It is a path the privileged job bind-mounts,
not a label, and renaming it would need the runner's filesystem to change in
the same breath.

## This does not take effect until the runners are relabelled

`runs-on:` matches labels a registered runner actually carries, and under
pull_request_target the workflow is read from main. So from the merge until
both runners come up as dejima-confined and dejima-privileged, jobs queue
rather than fail, and Gitea has no concurrency: to clear them. That downtime
is accepted: nobody else ships here, and dejima-ci-hardener cannot put two
labels on one box without hand-editing a generated runner, which would defeat
the point of generating it.

Note also that this file cannot test itself. pull_request_target reads the
workflow from the base branch, so this change is exercised for the first time
after it lands. static.bats asserting on DEJIMA_CI_TIER's values is what
catches a half-done rename before then, and it is updated here.
2026-08-07 16:40:13 -07:00
branderson 57ad800c34 Merge pull request 'README: say what dejima is for, and what it does not guarantee' (#96) from readme-hierarchy into main
Reviewed-on: branderson/dejima#96
Reviewed-by: branderson <brad@branderson.io>
2026-08-07 16:11:55 -07:00
branderson c441116c3b docs: answer the second review round
tests / sandbox (proxy) (pull_request_target) Successful in 2m0s
tests / sandbox (providers) (pull_request_target) Successful in 3m49s
tests / runner-env (sandbox) (pull_request_target) Successful in 1m24s
tests / sandbox (static) (pull_request_target) Successful in 3m19s
tests / sandbox (remote) (pull_request_target) Successful in 4m36s
tests / host-docker (pull_request_target) Successful in 4m52s
tests / runner-env (host-docker) (pull_request_target) Successful in 1m21s
tests / sandbox (project) (pull_request_target) Successful in 5m11s
tests / sandbox (config) (pull_request_target) Successful in 5m16s
tests / sandbox (audit) (pull_request_target) Successful in 7m57s
- A git push credential is not a prerequisite. The default workflow is
  `local-only`, which pushes nowhere, so nothing about getting dejima running
  needs one. It moves to Setup, next to choosing a pushing workflow, which is
  the point at which it becomes required. The ordering it gained last round
  survives the move: a key of the session's own first, a forwarded agent as
  the fallback with the cost of authenticating as you stated.

- The loop section says dejima itself is developed under `worktree-pr`, so
  this repository is that workflow in use rather than a description of one.

- configuration.md drops "expect the session to be live". A reader
  configuring a project has no reason to hold that; it is dejima's
  implementation that has to assume it, which is why the reasoning lives in
  security.md, where it stays.

- The Grafana section covers the conversation stream, which had queries for
  DNS and connections only. Selecting it, filtering by role, by tool, and by
  session, plus why `session` is a field rather than a label, and what is
  deliberately absent: tool results and thinking blocks are never shipped, so
  a query shows that a session ran a command and never what came back.
2026-08-07 15:51:31 -07:00
branderson 97bada30d3 docs: answer the review on responsible-use.md
- "Code you couldn't should wait for containment you trust more" made this a
  question about trusting dejima. It is not. There is no containment that
  fully prevents exfiltration: a determined attacker with code execution and
  an outbound path will find one, and an allowlist narrows paths rather than
  closing them. So the question is whether you can accept the risk of the
  contents leaving, and if you cannot, the answer is not to hand them over.

- "Auditing the directory takes an extra minute" was wrong about the cost and
  wrong about the method. Reading a working tree by eye is not a minute's
  work and succeeds every time until the once it matters. Point dejima at a
  fresh clone: it contains what is in version control and nothing else, so
  the whole class of stray uncommitted files goes away rather than being
  searched for.

- The allowlist section buried its own rule in a distinction. It now opens
  with it: for every allowlist entry you give an agent, consider what an
  attacker could do with it. The worked examples follow as the reason the
  answer varies.

- `main` is named as "(or `master`, or whatever your default branch is
  called)", so this does not read as dejima assuming one branch name.

- "Safe and responsible are not the same thing" overlapped the final point
  about dejima's security saying nothing about your software's. This one is
  about the merge specifically: what makes a default branch different is that
  other things read it and believe it. The general code-quality argument
  belongs to the other point and is left there.

- The retrofit point goes further, because the important half was missing.
  Security is not something a future agent can add either. Early
  architectural decisions close off the fixes for whole classes of problem,
  and a later pass can find a missing bounds check but cannot move a boundary
  the system was built on top of. dejima makes writing a lot of code fast,
  which makes writing a lot of insecure code fast if nobody is holding that
  concern, and holding it is the reader's job at every step rather than a
  phase to schedule.
2026-08-07 15:51:31 -07:00
branderson 81123c6d05 docs: answer the review on configuration.md and security.md
configuration.md:

- "least specific first" said the order without saying what the order does.
  The layers add rather than override, so a narrower layer can only widen
  what a broader one permits and the result is a union. Read the old wording
  cold and you would guess the opposite.

- The historical justification for `forge = none` being the default is gone,
  along with the changed-default blockquote. Nobody needs the argument for
  why we do not ship a broken default, and install.sh covers the migration
  for the handful of configs it could apply to.

- Tarball digests: how to get one. Fetch and hash on the host, and prefer a
  published checksums file, because that tells you the archive is the one the
  project shipped rather than the one your connection produced.

- "a mixed-architecture fleet needs a spec per project" implied a feature
  that does not exist. A tarball dependency locks the project to one
  architecture, full stop, with #97 filed for per-architecture specs.

- The symlink paragraph was a vulnerability deep-dive in a configuration doc.
  What a reader here needs is that both writers refuse a symlink and that the
  refusal is the open itself. The attack and the O_NOFOLLOW reasoning move to
  security.md under a heading of their own.

- "Config parity with your host Claude Code setup" becomes "...with your host
  agent harness setup", with Claude Code as a subheading, so adding a second
  harness does not mean rewriting the heading every link targets. Headless
  git push is promoted back to a top-level section: it was nested under
  Claude Code by that change, and it is harness-agnostic.

- Claude Code is named only where it is actually specific. The .dejima.md
  passage keeps it, because that passage exists to explain why a
  harness-neutral name is needed.

- The Dockerfile caveat pointed at editing the base image. That is what
  dejima-dep is for, and a dependency added through it is declared, approved
  and survives a rebuild.

- Headless git push led with running unattended. The reason that matters is
  identity: a forwarded agent authenticates as you. Uptime is the lesser,
  second reason now.

- ~/shared says to clear it out periodically, for the same reason as the
  README.

security.md: the WARNING git-config tier is described by what it does rather
than by what it used to fail to do.
2026-08-07 15:51:31 -07:00
branderson c2023ff402 docs: add CONTRIBUTING.md, and answer the review on the README
CONTRIBUTING.md says the thing that is true: this is developed on a private
forge that allows neither account registration nor anonymous issues, so
there is currently no way for anyone outside to file either. That will
change. Until it does, fork it and change your fork. It also warns that a
fork taken today diverges quickly, which is the part worth knowing before
building on one.

README, from review:

- "Check whether they want it first" had no antecedent for "they". It names
  the project, and points at dejima's own CONTRIBUTING now that there is one.

- The push credential listed agent forwarding first. That is backwards: a
  forwarded agent authenticates the session as you, which is the arrangement
  docs/security.md argues against two sections earlier. `[core] ssh_key` with
  an identity of its own leads, forwarding is the fallback, and the cost of
  the fallback is stated rather than left to be discovered.

- The "typical loop" was one workflow, and not the default one. A reader
  would have concluded that pushing and opening a PR is how dejima works. It
  now leads with the part every workflow shares and names what each does
  after that.

- The merge-guard paragraph asserted the hooks do not stop a hostile session,
  unconditionally. That only holds if you gave the session a token that can
  merge. Stated conditionally, with a token scoped below merge named as a
  control alongside branch protection.

- "Every session ends at something you read before it lands" was still
  workflow-shaped: under local-only there is no pull request to read. Replaced
  with what is true of all three, that none of them puts work on your default
  branch.

- The session briefing was described as telling the agent to push and open a
  pull request. It carries whichever workflow's instructions were selected.

- ~/shared says to clear it out between sessions. It persists by design, so
  anything forgotten there is handed to every session after it.

- The approval sentence takes the reviewer's wording: approval is a separate
  act you do yourself on the host, by hand, and approvals are stored outside
  the reach of the session.
2026-08-07 15:51:31 -07:00
branderson 9374eb5757 docs: lead with the premise, not with a virtue
The first line was "Unsupervised agentic development you can take
responsibility for." Responsibility is something this project argues for, in
a section that exists precisely because nothing in the software enforces it.
dejima works fine for careless use. A first line implying otherwise claims a
property the tool does not have, which is the same overclaiming the merge
guard paragraph was just corrected for.

What dejima is actually built on is narrower and more distinctive: let the
agent implement unsupervised, and assume it is hostile while it does. Not
fallible, hostile. It can be turned mid-session by a prompt injection or a
poisoned dependency and will not announce that it has been, so the design
question is never whether to trust it, only what it can reach when it should
not be trusted.

The paragraph under it followed the old framing and described mistakes, which
undersells the premise, so it now describes the case the container is
actually for.
2026-08-07 15:51:31 -07:00
branderson 5c1717ebc4 docs: pick up what #95 changed about acknowledging findings
PR #95 left the docs alone deliberately, with this rewrite in flight, and
listed what the rewrite should pick up. This is that.

Approve and ignore were doing different things and the prompt made them look
identical, which is the confusion #95 fixed in the tool. Saying it in prose
is the other half: approving records the exact content, so any later change
re-fires; ignoring records the surface, so that key is fine whatever it later
contains. Approve a hook you have read. Ignore a key some tool rewrites every
run. Skipping decides nothing and asks again, which is the right default for
anything you are unsure about. The keymap is quoted so the doc and the prompt
agree.

A WARNING-tier git config change is now curatable, which the baseline
discussion here predates. It used to be the one class of finding with no id
to record a decision against, so keys that get rewritten every run re-fired
forever and pushed real findings out of the capped list.

Also states what neither verb can do: severity is decided by what the surface
is, an exec key or a shell alias is CRITICAL whatever the baseline says, and
an ignore entry is refused against a CRITICAL surface when the baseline is
read rather than only when it is written. A curation mechanism that could
quietly downgrade something is worse than none, so the limit is worth stating
where the mechanism is.

The audit discussion had no heading of its own, sitting unmarked inside a
five-hundred-line section, so nothing could link to it. It has one now.

Two other things ride along. The note that `--allow-github-push` is a
per-launch addition rather than a substitute for selecting a forge, preserved
from the README's Prerequisites tail. And three em-dashes this branch's rebase
onto main brought back into this file, cleared to match the rest of it.
2026-08-07 15:51:31 -07:00
branderson 4ba9a0499e docs: leave prerequisites as prerequisites, and move the schema
Prerequisites ran to about a hundred lines, most of it a thirty-two row
config table, and it sat between the responsible-use section and Setup. A
reader who wanted "what do I do" had to scroll the entire schema to reach it,
and the schema is the one thing nobody reads top to bottom: they arrive at it
looking for one key.

What is actually required stays: Docker, python3 for the credential proxy,
and a git push credential. Then a sentence saying everything else is optional
and lives in one file, and a link. Twenty-two lines, and Setup is next.

The table moves to a Configuration reference section in docs/configuration.md,
which is where someone editing their config already is. The reasoning for why
no forge is the default, and the changed-default migration notice, go with it.

The trailing "to push, set [core] workflow" paragraph is deleted rather than
moved: Setup's Core block already says all of it. Forgejo working unmodified
is still in the README. That `--allow-github-push` is a per-launch addition
rather than a substitute for selecting a forge is preserved separately, in
docs/security.md next to the posture it modifies.

Moving the table also moved the only mention of `[audit]` out of the README,
which test/config.bats caught. The test is right, and not merely about a
string: the feature list advertises an exit-time audit, and the README would
no longer have said what it watches, that it is advisory rather than a block,
or what `[audit] strict` gates. It gets a section, which is also where the
approve-versus-ignore distinction belongs in short form.
2026-08-07 15:51:31 -07:00
branderson 3b03545c3a docs: say what the loop guarantees, now that the guard does not 2026-08-07 15:51:31 -07:00
branderson f19f89c13a test: pin bin/dejima's docs/configuration.md and docs/security.md references
bin/dejima names docs/configuration.md in user-facing diagnostics, and
bin/dejima, bin/dejima-anthropic-proxy and bin/dejima-audit all name
docs/security.md the same way. Before the docs restructure these named the
README, which cannot be renamed; now they name files that can be, and a
rename would silently send an operator nowhere. Follow the shape of the
existing docs/remote-access.md path test in test/remote.bats: read the
referenced path out of the script rather than restating it, generalized to
walk every docs/*.md reference actually present rather than hardcoding the
two names again.
2026-08-07 15:51:31 -07:00
branderson fc9ab6768b docs: fix dead cross-references in docs/security.md left by the move
Four references pointed at headings that used to be in the same document
before content moved into README.md/docs/configuration.md/docs/providers.md
during the restructure: a deleted README section, two headings that now
live in docs/configuration.md, and one that lives in the README, wrongly
pointed at with 'above'. Turn each into a real markdown link to where the
target actually is, and clean up the double-semicolon left by the em-dash
removal in the GitHub-forge reference.
2026-08-07 15:51:31 -07:00
branderson dddeefcd21 docs: restore qualifications the restructure dropped from the README
The merge-guard claim, the GitHub-forge push cost, the writable-host-surface
answer, and the GitHub-push HTTPS exception all had their qualifying detail
moved into docs/ while the unqualified claim stayed in the README. Restore
each qualification in the README's own voice, next to the claim it modifies,
and link to where the fuller detail now lives. Also anchor the two
docs/security.md and docs/configuration.md links that already had a correct
anchored form available elsewhere in the README but weren't using it, and
correct the GitHub-forge Setup row to agree with docs/providers.md and to
point at the section that actually has the steps.
2026-08-07 15:51:31 -07:00
branderson 40e6c7ba8b docs: a file map of files that are here 2026-08-07 15:51:31 -07:00
branderson 599fc6e3eb docs: fix dead cross-references left by the README restructure
security.md pointed at a README subsection that became a Setup table
row; link the providers.md section that actually documents selecting
the GitHub forge instead. development.md pointed at a README section
that moved into security.md; link the heading it moved to. ci.md
pointed at a ci/ directory that does not exist in this repository;
point at dejima-ci-hardener, which is where that configuration
actually lives and which the rest of the file already links.
2026-08-07 15:51:31 -07:00
branderson 7ed9790b6c docs: semicolons only where a sentence follows 2026-08-07 15:51:31 -07:00
branderson d50343869f docs: replace em-dashes with punctuation that reads as written
Each em-dash was repunctuated by the job it was doing (colon for an
explanation, semicolon or a split sentence for two independent
clauses, parentheses or a comma pair for an aside, a plain comma for
a contrastive tail), not swapped for a single character throughout.
One sentence in README.md's Logging bullet was rewritten rather than
repunctuated, because the em-dash there was covering a fragment with
no verb of its own.

README.md ends at 5, docs/ci.md, docs/security.md and
docs/remote-access.md at 0. All 5 remaining are inside "An agent's
perspective on working inside", which is explicitly exempt and left
untouched. That is one more than the four this task's brief expected
there: direct measurement shows the section holds five, not four (one
line carries two). Per the exemption rule against editing so much as a
character of that section's punctuation, none of the five were
touched; the sweep of everything outside the exempt zone is still
clean.
2026-08-07 15:51:31 -07:00
branderson 70b2a1e392 docs: say what each option costs, not just that it exists 2026-08-07 15:51:30 -07:00
branderson cd6ad04145 docs: cut setup to what everyone needs, and index the rest 2026-08-07 15:51:30 -07:00
branderson 38ee9ee246 docs: say what the firewall actually allows, and point the right way 2026-08-07 15:51:30 -07:00
branderson e2ea1a8afb docs: fold the egress firewall and logging into the security doc
Moves the Network egress firewall, Network request logging, and GitHub
push sections (with their subsections) from README.md into
docs/security.md, after the existing Provider trust section, leaving
load-bearing stubs behind for the two security-guarantee sections and
an operational summary for logging. Repoints every anchor into the
moved headings (README's own Setup section, docs/configuration.md,
docker-compose.yml's comment) at their new home, and restates the
"deliberately do not touch a running container" guarantee in the
firewall stub since test/static.bats checks for it in README.md
itself.
2026-08-07 15:51:30 -07:00
branderson 313250f7c4 docs: name what the container does, not variables nobody sets 2026-08-07 15:51:30 -07:00
branderson fa106cd8ff docs: move day-to-day configuration out of the README 2026-08-07 15:51:30 -07:00
branderson 37e8609db3 docs: say how many suites there are by not saying 2026-08-07 15:51:30 -07:00
branderson c108e58992 docs: move the file map and suite detail into a development doc 2026-08-07 15:51:30 -07:00
branderson cdf0e78525 docs: the reasoning behind each responsible-use rule 2026-08-07 15:51:30 -07:00
branderson f72e4292d5 docs: keep one CI bullet, in the voice of its neighbours 2026-08-07 15:51:30 -07:00
branderson 42088f9c07 docs: say what dejima is for, and how to use it responsibly 2026-08-07 15:51:30 -07:00
branderson 6e969c636f Merge pull request 'gitea-pr: hide review threads that are already resolved' (#99) from gitea-pr-resolved into main
Reviewed-on: branderson/dejima#99
Reviewed-by: branderson <brad@branderson.io>
2026-08-07 15:49:06 -07:00
branderson 73e1a84414 gitea-pr: hide review threads that are already resolved
tests / sandbox (proxy) (pull_request_target) Successful in 2m0s
tests / sandbox (providers) (pull_request_target) Successful in 3m50s
tests / runner-env (sandbox) (pull_request_target) Successful in 1m24s
tests / sandbox (static) (pull_request_target) Successful in 3m20s
tests / sandbox (remote) (pull_request_target) Successful in 4m34s
tests / host-docker (pull_request_target) Successful in 4m47s
tests / runner-env (host-docker) (pull_request_target) Successful in 1m20s
tests / sandbox (config) (pull_request_target) Successful in 5m13s
tests / sandbox (audit) (pull_request_target) Successful in 7m39s
tests / sandbox (project) (pull_request_target) Successful in 5m8s
`gitea-pr comments` printed every inline comment a pull request had ever
received, resolved or not, in one flat list. On a PR reviewed in several
rounds that is mostly noise: the reader has to work out which comments are
outstanding by recognising which passages have already been rewritten.

The deciding field is `resolver`. A resolved comment carries the user who
resolved it; an open one carries null. Neither the review's `state`
(COMMENT vs REQUEST_CHANGES) nor whether comments arrived grouped under one
review says anything about it - both describe how the reviewer chose to
submit, and reading either as a resolution signal works until someone files
differently.

The catch, and the reason this is not a one-line filter: **Gitea stamps only
a thread's root comment.** A reply inside a closed thread comes back with
`resolver: null`, so judging each comment alone reports a thread you already
dealt with as still open. That happened while reading review feedback on #96:
a closed-and-refiled thread's reply presented as outstanding, and the passage
it pointed at had already been rewritten.

So a thread is every comment sharing a path and a diff position, and it is
resolved if any of them carries a resolver. Unresolved threads print by
default; `--all` includes the rest, marked. When anything is hidden it says
how much, because a list that silently shrank is worse than a long one.

## Also

.dejima.md gains the batching rule this session earned. Gitea does not
implement `concurrency:`, which the workflow file already records with a test,
so a second push to an open PR queues behind the first rather than cancelling
it. Pushing commit-by-commit while iterating queued enough runs to saturate
this project's forge: nginx could not open connections to Gitea, the requests
timing out were the runners' own UpdateLog and UpdateTask calls, and the
instance was down long enough to trip uptime alerts. Commit freely, push once.
The same section covers forge API reads, since `comments` costs one request
per review.

## Testing

Two tests, both confirmed to fail against the previous `gitea-pr` and at the
right assertions: the resolved-thread one on the reply leaking through, the
`--all` one on the missing marker. The shared curl stub now returns three
comments, two of which are one thread whose reply carries no resolver, so the
case that caused this is what the suite exercises.

static 204, providers 65. Both green.
2026-08-07 15:20:32 -07:00
branderson 0aef363910 Merge pull request 'gitea-pr: show the diff a review comment was left against' (#98) from gitea-pr-hunks into main
Reviewed-on: branderson/dejima#98
Reviewed-by: branderson <brad@branderson.io>
2026-08-07 14:19:28 -07:00
branderson 2222aef7d1 gitea-pr: show the diff a review comment was left against
tests / sandbox (proxy) (pull_request_target) Successful in 2m5s
tests / runner-env (sandbox) (pull_request_target) Successful in 1m28s
tests / sandbox (static) (pull_request_target) Successful in 3m33s
tests / sandbox (providers) (pull_request_target) Successful in 3m59s
tests / sandbox (remote) (pull_request_target) Successful in 4m41s
tests / host-docker (pull_request_target) Successful in 4m55s
tests / runner-env (host-docker) (pull_request_target) Successful in 1m23s
tests / sandbox (project) (pull_request_target) Successful in 5m19s
tests / sandbox (config) (pull_request_target) Successful in 5m25s
tests / sandbox (audit) (pull_request_target) Successful in 8m1s
`gitea-pr comments` rendered every inline review comment as `path:?`. Not
sometimes: always. The jq asked for `.line`, and Gitea's review-comment
objects have no `line` field, so `// "?"` was the only branch that could ever
be taken.

The effect is not cosmetic. A reviewer writing "this is confusing" or "let's
reword this" is pointing at a passage they can see and the reader cannot. All
that survived the fetch was the file. On a review of any size, several
comments land on the same file, and some of them say "this" about a phrase
that occurs more than once, so acting on the review means guessing which
passage was meant and sometimes guessing wrong. That happened on #96, on a
comment about historical content in a file with two candidate passages, and
cost a round trip to resolve.

The objects do carry what is needed: `diff_hunk`, which is the exact text the
comment was attached to, and `position`, the offset into the diff. Both are
printed now, with the comment marked off from the quoted code so the two
cannot be read as one block.

The hunk makes the output longer, and that is the right trade: scrolling
costs nothing, and guessing wrong costs a round trip through a human.

## Testing

The shared curl stub answered every read with `[]`, which meant `comments`
had no coverage at all: the reviews list came back empty, so the per-review
comment fetch never ran and the rendering under test was never reached. The
stub now answers the two review endpoints with shaped data and everything
else as before.

One test, and it was confirmed to fail against the previous `gitea-pr`,
at the assertion for the hunk text rather than incidentally.

static 202 and providers 65, both green.
2026-08-07 14:08:32 -07:00
branderson b71b0c1dda Merge pull request 'audit: stop re-asking about findings already decided, make the WARNING git-config tier ackable, and colour the review' (#95) from audit-ux into main
Reviewed-on: branderson/dejima#95
Reviewed-by: branderson <brad@branderson.io>
2026-08-06 23:39:48 -07:00
branderson e7e6a57dc3 audit: stop re-asking about findings already decided, and colour the review
tests / sandbox (proxy) (pull_request_target) Successful in 2m7s
tests / runner-env (sandbox) (pull_request_target) Successful in 1m26s
tests / sandbox (static) (pull_request_target) Successful in 3m32s
tests / runner-env (host-docker) (pull_request_target) Successful in 1m23s
tests / sandbox (project) (pull_request_target) Successful in 5m24s
tests / sandbox (providers) (pull_request_target) Successful in 4m0s
tests / sandbox (remote) (pull_request_target) Successful in 4m44s
tests / host-docker (pull_request_target) Successful in 4m59s
tests / sandbox (config) (pull_request_target) Successful in 5m28s
tests / sandbox (audit) (pull_request_target) Successful in 8m17s
Closes #65 and #45.

The headline bug was not what it looked like. Approvals were being written
correctly the whole time - `ack` simply never consulted the baseline when
deciding what to SHOW, so every later run re-presented the same CRITICALs
and appended a duplicate entry for each. It reads from the outside as
"exact approvals are not being recorded".

Two reports can also list the same finding, and approving it in the first
left the second permanently -CRITICAL.txt: bin/dejima then warned about a
report whose every finding had been dealt with, forever. The rename now runs
on a pass that records nothing new, so a report covered entirely by earlier
decisions stops counting as unacknowledged.

#45: a WARNING-tier git config change could not be acknowledged at all - no
`ack` arm derived an id for it, and neither branch of `compare` that emits
one consulted the baseline. It was the only class of finding with no way to
curate it, so keys some tools rewrite on every run re-fired every session
and pushed real WARNINGs out of the capped list. Safe by construction rather
than by care taken here: id_severity still calls an exec key or any alias
CRITICAL whatever wrote the entry, and baseline_suppresses still refuses a
`spam` entry against a CRITICAL surface at read time.

Findings that cannot be acknowledged at all - now only a cross-project write
- are presented once per report rather than on every run. There is nothing
to record for one, so a `reviewed <report>` line marks the report walked.
Only an un-ackable finding is governed by it; skip still means "ask again"
for everything else, which is the whole difference between skip and ignore.
Quitting does not mark it, since the report was not walked.

The keys: skip is [s], and the permanent option is [i]. Both prompts now say
what they do to future findings, because the difference between them was
invisible - approve pins this exact content and re-fires on any other,
ignore blankets the surface whatever it later contains, and the WARNING
prompt used to offer a bare "approve" that read as the same thing. The noun
follows the surface, so a git config key is not called a file.

Colour, from a small lib both bin/dejima and bin/dejima-audit source so the
launcher's warning and the review it sends you to agree about what red
means: severity on the tier, the file or key picked out of the label around
it, dim on the audit's own framing so it stops running together with the
content between it, and green/red on a retained diff. Off unless the stream
being written to is a terminal, and off for NO_COLOR and TERM=dumb - reports
and piped output are read back by machines, this suite included.

And the launcher's warning offers [a] to review right there. It already knew
the findings were unacknowledged and then asked the operator to go and run a
command in another terminal, at the one moment when nothing was waiting on
them: the container has not started yet. It loops, because `ack` walks one
report per run.

Tests: 7 new in audit.bats and 3 in static.bats, the four load-bearing ones
confirmed to fail against the previous dejima-audit. Suites green - audit
148, static 202, config 94, project 132, providers 64.

Docs are deliberately untouched: a README/docs rewrite is in flight, and
this belongs in that rather than in the version being replaced.
2026-08-06 20:39:54 -07:00
branderson d648c7743a Merge pull request 'gitea-actions: read CI through endpoints a non-owner token can reach' (#93) from gitea-actions-scope into main
Reviewed-on: branderson/dejima#93
Reviewed-by: branderson <brad@branderson.io>
2026-08-06 18:40:20 -07:00
branderson 00555eadd7 Merge pull request 'gitea: add edit to the PR and issue CLIs, brief the agent on body formatting, and make README Usage human-facing' (#92) from gitea-tools into main
Reviewed-on: branderson/dejima#92
Reviewed-by: branderson <brad@branderson.io>
2026-08-06 18:40:01 -07:00
branderson da045fa77f docs: say that the sandbox's account needs write and not ownership
tests / sandbox (proxy) (pull_request_target) Successful in 2m3s
tests / sandbox (static) (pull_request_target) Successful in 2m47s
tests / sandbox (providers) (pull_request_target) Successful in 3m53s
tests / runner-env (sandbox) (pull_request_target) Successful in 1m22s
tests / sandbox (remote) (pull_request_target) Successful in 4m36s
tests / host-docker (pull_request_target) Successful in 4m50s
tests / runner-env (host-docker) (pull_request_target) Successful in 1m22s
tests / sandbox (project) (pull_request_target) Successful in 5m14s
tests / sandbox (config) (pull_request_target) Successful in 5m20s
tests / sandbox (audit) (pull_request_target) Successful in 7m22s
"Give the sandbox its own identity" said write access without saying that
write is the ceiling. An account that owns the repo, or holds admin on it,
can merge its own work and edit the branch protections in that section's own
table - so every row goes back to being decorative, which is the failure the
section exists to prevent.

And the consequence a reader will otherwise meet as a puzzle: parts of
Gitea's API are gated on ownership rather than permission, and answer a
collaborator with "user should be the owner of the repo" whatever scopes its
token has. That reads as a scope error, and the reflex - widen the token -
cannot work, while the thing that would work is handing the sandbox an owner
token and undoing the section. Named here with what gitea-actions does
instead, so the dead end is documented rather than rediscovered.
2026-08-06 12:36:24 -07:00
branderson e241ff9a31 gitea-actions: read CI through endpoints a non-owner token can reach
tests / runner-env (sandbox) (pull_request_target) Successful in 1m35s
tests / sandbox (proxy) (pull_request_target) Successful in 2m13s
tests / sandbox (static) (pull_request_target) Successful in 3m1s
tests / sandbox (providers) (pull_request_target) Successful in 4m1s
tests / sandbox (remote) (pull_request_target) Successful in 4m44s
tests / host-docker (pull_request_target) Successful in 4m49s
tests / runner-env (host-docker) (pull_request_target) Successful in 1m21s
tests / sandbox (project) (pull_request_target) Successful in 5m19s
tests / sandbox (audit) (pull_request_target) Successful in 7m25s
tests / sandbox (config) (pull_request_target) Successful in 5m24s
The sandbox holds a token for a dedicated account with push access and
nothing more, which is the point of having one. Gitea gates
/repos/{owner}/{repo}/actions/runs behind reqOwner(), so that account got
"user should be the owner of the repo" and every subcommand failed at the
first call. It is an ownership check, not a scope check - widening the token
cannot fix it, and the only thing that would is handing the sandbox an owner
token, which is the opposite of the point.

Three sources replace it, all of them plain repo reads:

  actions/tasks            one record per job, carrying run number,
                           head_sha, event and result
  commits/<sha>/statuses   the same jobs as statuses, and the only place a
                           job's index is visible to a non-owner
  the web log route        the run view's own endpoint, same token header

The job index is what addresses a log and is not a field anywhere - only the
tail of a status's target_url. Statuses are written twice per job and
filtered here by run number as well as commit, since a re-run puts a second
run's statuses on the same one.

Also drops `column`, which is not in the sandbox image: being last in the
pipeline, a missing one fails with 127 after the request has succeeded, so
`runs` and `jobs` have been broken in the shipped image while looking like
an API problem. Padded with printf like dejima-audit and dejima-attach.

Verified against the live forge with the dejima-agent token that could not
run any of this before: runs, jobs, logs by default/--job name/--job index,
and both not-found paths.
2026-08-06 12:22:48 -07:00
branderson 5fa4be3cac test: give the stub repo its own git identity instead of the runner's
tests / sandbox (proxy) (pull_request_target) Successful in 2m6s
tests / runner-env (sandbox) (pull_request_target) Successful in 1m25s
tests / sandbox (static) (pull_request_target) Successful in 2m58s
tests / sandbox (providers) (pull_request_target) Successful in 3m57s
tests / sandbox (remote) (pull_request_target) Successful in 4m37s
tests / host-docker (pull_request_target) Successful in 4m38s
tests / runner-env (host-docker) (pull_request_target) Successful in 1m21s
tests / sandbox (project) (pull_request_target) Successful in 5m14s
tests / sandbox (config) (pull_request_target) Successful in 5m17s
tests / sandbox (audit) (pull_request_target) Successful in 7m22s
The new CLI tests build a throwaway repo and commit into it so that
create's `rev-parse --abbrev-ref HEAD` has a branch to report. `git commit`
needs an author, and a CI job container has no git config at all - it exited
128 with "Author identity unknown" and took three of the four tests with it,
while every local run passed on whoever happened to be configured.

Identity is now passed per-command, so nothing about the environment is
assumed. Verified by running the whole suite with HOME emptied and both
GIT_CONFIG_GLOBAL and GIT_CONFIG_SYSTEM pointed at /dev/null: 197 tests,
no failures, where the same run reproduced all three CI failures before.
2026-08-06 12:08:38 -07:00
branderson 8122267f2b docs: make Usage about using dejima, not about the CLIs the session uses
tests / sandbox (proxy) (pull_request_target) Successful in 2m3s
tests / runner-env (sandbox) (pull_request_target) Successful in 1m26s
tests / sandbox (providers) (pull_request_target) Successful in 3m48s
tests / sandbox (remote) (pull_request_target) Successful in 4m28s
tests / sandbox (audit) (pull_request_target) Successful in 7m7s
tests / sandbox (static) (pull_request_target) Failing after 2m51s
tests / host-docker (pull_request_target) Successful in 4m49s
tests / runner-env (host-docker) (pull_request_target) Successful in 1m21s
tests / sandbox (project) (pull_request_target) Successful in 5m6s
tests / sandbox (config) (pull_request_target) Successful in 5m9s
The section had grown into agent instructions: which gitea-* subcommands
exist, why --body-file beats a heredoc, how deny-dangerous-bash.sh reads a
command string, when to write Fixes #N. None of that is something a person
running dejima does or needs - the agent gets it from the session briefing,
which is where it already lives.

What is left is the loop a human is actually in: point it at a project, ask
for a change, review and merge the PR it opens. Plus the one thing that
governs how far a session goes, which is the workflow setting.
2026-08-06 11:53:16 -07:00
branderson 0e29236dff gitea: let an agent edit a PR or issue body, and tell it how to write one
Two halves of #79.

An agent that said it had updated a description had not, because there was
no way to: create was write-once and rewriting the file the body came from
sends nothing. Both CLIs gain `edit N [--title] [--body|--body-file]`, which
sends only the fields it was given - a fixed {title, body} pair would have
traded a silent no-op for a worse bug, every title-only edit wiping the
description. An edited body keeps create's provenance marker rather than
shedding it.

The hard-wrapping half is left to the briefing, deliberately. A tool that
reflows prose has to guess which newlines the author meant, and it will be
wrong about trailers, addresses and one-line-per-item text; these stay dumb
and pass the body through as written. The worktree-pr fragment now says not
to wrap, why (the forge renders a newline as a break, worst on a phone),
which breaks to keep, and that revising the source file sends nothing.

gitea-pr adopts gitea-issue's api_or_die and parse_body_args rather than
growing a fifth hand-rolled response check and a second copy of the body
parser. That is what makes create and edit agree about --body vs
--body-file, and it leaves the two files the twins they are documented to be.

First behavioural tests for any of these CLIs, against a stub curl: what
lands in the request, not what the source greps like. They cover create too,
which had none before its parser moved.
2026-08-06 11:53:16 -07:00
branderson aec22e32b7 Merge pull request 'firewall: keep GitHub port-restricted however it was allowlisted' (#90) from extra-domains-github-split into main
Reviewed-on: branderson/dejima#90
Reviewed-by: branderson <brad@branderson.io>
2026-08-06 11:10:04 -07:00
branderson 5a2d460075 Merge pull request 'docs: name the CI exposure in the opening list' (#91) from readme-ci-bullet into main
Reviewed-on: branderson/dejima#91
Reviewed-by: branderson <brad@branderson.io>
2026-08-06 10:15:44 -07:00
branderson 1e65852f2c docs: name the CI exposure in the opening list
tests / runner-env (sandbox) (pull_request_target) Successful in 1m30s
tests / sandbox (proxy) (pull_request_target) Successful in 2m6s
tests / sandbox (providers) (pull_request_target) Successful in 3m54s
tests / host-docker (pull_request_target) Successful in 4m39s
tests / sandbox (project) (pull_request_target) Successful in 5m15s
tests / sandbox (static) (pull_request_target) Successful in 2m54s
tests / sandbox (remote) (pull_request_target) Successful in 4m40s
tests / runner-env (host-docker) (pull_request_target) Successful in 1m21s
tests / sandbox (config) (pull_request_target) Successful in 5m20s
tests / sandbox (audit) (pull_request_target) Successful in 7m19s
The list of what running an agent unattended takes is where a reader decides
whether this is a serious tool, and it ran to nine bullets without mentioning
the one exposure the container cannot close. CI came ~1000 lines later, which
is after the point where someone has already pointed dejima at a project and
given a session push access.

The bullet is deliberately the odd one out. Every other entry is something
dejima provides; this one is something it does not, and says so - the
container bounds what a session reaches directly, not what it can arrange to
have run elsewhere. Both destinations get named there rather than deferred:
docs/ci.md for the reasoning, dejima-ci-hardener for the tool.

Placed directly after the PR and issue workflow, which is where push access
is introduced and therefore where the consequence belongs.
2026-08-06 10:03:14 -07:00
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
60 changed files with 9231 additions and 2038 deletions
+58 -5
View File
@@ -10,9 +10,20 @@ told) and `README.md`.
`test/runtime.bats` builds the image and boots real containers, so it needs a
working Docker. A dejima sandbox does not have one — the container has no Docker
socket, by design. Everything else runs anywhere, which is what the CI job names
mean: `sandbox` for the suites that need only a shell, `host-docker` for the one
mean: `confined` for the suites that need only a shell, `privileged` for the one
that does not.
## `bats` is a declared dependency here, not part of the image
It is in `.dejima/dependencies`, like any other project's test runner, rather
than baked into the base image for every project that never runs bats. So on a
sandbox whose image predates that approval, `bats` is simply absent — the fix
is the ordinary one the briefing describes (`dejima-project approve`, then
relaunch so the derived image is rebuilt), not installing it in-session.
If it is missing and you cannot get a rebuild, say the suites were not run
rather than reporting a change as tested.
## Run the suite for what you changed, not all of them
The suites are independent and cover different areas. Running all of them for
@@ -37,13 +48,13 @@ a change to a shipped doc or a `docker/` file usually needs `static.bats` *as
well as* whatever else it touched — that suite asserts on files it does not own.
**CI runs `test/runtime.bats` on every pull request**, as a separate
`host-docker` job in `.gitea/workflows/tests.yml`. So being unable to run it
`privileged` job in `.gitea/workflows/tests.yml`. So being unable to run it
locally is not a reason to stop, to hedge, or to leave a change unfinished. Run
the sandbox suites, open the PR, and say plainly in the PR body that the
host-docker half was not run locally.
the confined suites, open the PR, and say plainly in the PR body that the
privileged half was not run locally.
**But do not read "CI covers it" as "it passed."** That job is pinned to
`runs-on: dejima-runtime`, a label only the one configured self-hosted runner
`runs-on: dejima-privileged`, a label only the one configured self-hosted runner
carries — it needs the host network namespace, the Docker socket, and a shared
volume, none of which a workflow can request. If that runner is offline the job
**queues instead of failing**, and the PR sits with the runtime half neither
@@ -55,6 +66,48 @@ credentials cross the container boundary, that runtime run is the only thing
exercising it end to end. Worth asking the operator to run it on the host rather
than waiting on the runner.
## Pushing a branch here runs your code outside the sandbox
The moment you push, CI checks that commit out on a host and executes it — and
because the workflow runs `bats test/<suite>.bats`, every file in `test/` is
code that runs there, not just the workflow file. Nothing about that is under
the sandbox's firewall, and no one reads the diff first.
Practically: a helper you add to a test file gets executed on a machine outside
this container. Write test code you would be comfortable running there, and if
a change would have CI reach a new host, that belongs in
`.dejima/allowed-domains` in the same pull request — on this project the CI
allowlist is read from `main`, so an undeclared destination fails rather than
connects. See [docs/ci.md](docs/ci.md).
## Batch your pushes, because runs queue rather than supersede
Every push to an open pull request starts a full workflow run, and Gitea does
not implement `concurrency:` (see the comment at the top of
`.gitea/workflows/tests.yml`, which records the test). A second push does not
cancel the first: it queues behind it. So four pushes in ten minutes is four
full runs, and the load keeps arriving long after you have stopped pushing,
because the backlog is still draining.
That is not hypothetical here. A session pushing commit-by-commit while
iterating on a pull request queued enough runs to saturate this project's
forge: nginx stopped being able to open connections to Gitea at all, and the
requests that timed out were the runners' own `UpdateLog` and `UpdateTask`
calls plus the operator's browser. The forge went down for several minutes and
tripped uptime alerts.
So: **finish a change, then push once.** Commit as often as you like locally;
that costs nothing. What costs something is every `git push`. If you have three
commits to make, make them and push at the end, rather than pushing after each.
The same applies to forge API reads. `gitea-pr comments` fetches one request
per review on the pull request, so on a heavily reviewed one that is dozens of
requests per invocation. Run it when you need the answer, not repeatedly while
iterating, and never in a loop.
If a push does supersede a run you no longer care about, cancel the stale one
by hand in the web UI. Nothing does it for you.
## A test that reads its environment will pass here and fail on your machine
Twice now a test has stated a precondition by leaving something unset, which
+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
+311 -19
View File
@@ -8,17 +8,44 @@
# and boots real containers, so it needs a Docker daemon the sandbox
# deliberately does not have. That constraint is what a contributor has to
# reason about when deciding what they can run before pushing, so the jobs are
# named for it.
# named for the runner tier they need: `confined` for the ones a shell can
# run anywhere, `privileged` for the one that needs a daemon.
#
# The sandbox half is a matrix over its files rather than one invocation. The
# The confined half is a matrix over its files rather than one invocation. The
# files are independent, so running them together only serialised them: wall
# clock was their sum rather than the slowest one. It also means a failure
# names the suite that failed in the job list, instead of one red check
# covering every unrelated area at once.
name: tests
# pull_request_target, not pull_request, and the difference is the whole
# security posture of this file.
#
# Under `pull_request` the workflow is read from the HEAD of the pull request -
# so the branch under test decides what runs and, critically, `runs-on:`. A
# branch can point its jobs at any runner its repository can reach, which
# means every control the runner provides (no Docker socket, deny-by-default
# egress) is opt-out by whoever wrote the branch.
#
# Under `pull_request_target` Gitea reads the workflow from the BASE branch,
# and explicitly discards any pull_request_target workflow found on the head
# commit - so a branch can neither edit this file's copy nor introduce its
# own. What runs here, and where, is whatever `main` says.
#
# The cost is that the checkout no longer defaults to the code under test: the
# run's ref is the base branch. Every job that means to test the branch has to
# say so, which is why the checkouts below carry an explicit ref. A bare
# checkout here would test `main` on every pull request and pass regardless of
# the change - green checks that mean nothing.
#
# This does NOT make the repository safe on its own. A branch can still add a
# workflow of its own with `on: pull_request`, and that runs from the head
# with whatever `runs-on:` it likes. The only thing stopping that is which
# runners the repository can reach at all - see docs/ci.md, "A label is not a
# boundary". What this buys is that the intended CI is trustworthy, and that
# escaping it requires adding a file rather than editing one.
on:
pull_request:
pull_request_target:
# No `concurrency:` block. Gitea does not implement one - the word appears
# nowhere in its actions models or services as of 1.25 - so writing it here
@@ -28,8 +55,22 @@ on:
# supersede, and cancel the stale run by hand if it is in the way.
jobs:
sandbox:
runs-on: ubuntu-latest
confined:
# A label carried only by a runner configured for this - see ci/ and
# docs/ci.md. Not a generic one, even though these suites genuinely do run
# anywhere a shell does, because "runs anywhere" is a statement about what
# the job needs and not about what it should be handed.
#
# Whether a job container gets the Docker socket is a property of the
# runner's config.yaml (container.docker_host), not of anything this file
# can ask for or refuse. So a job that needs nothing still receives
# whatever its runner grants everything it runs, and naming a specific
# label is the only way this file gets a say in which runner that is.
#
# If no runner carries this label, these jobs queue. That is the intended
# failure: it is a better one than a green run on a host that grants
# everything.
runs-on: dejima-confined
strategy:
# One suite per job. fail-fast off because these cover unrelated areas -
# cancelling the other six on the first failure would hide whether a
@@ -45,17 +86,71 @@ jobs:
- proxy
- config
- providers
name: sandbox (${{ matrix.suite }})
name: confined (${{ matrix.suite }})
container:
# Debian bookworm plus a node binary - node is what actions/checkout
# needs to run at all, and apt-get is what everything below needs.
image: node:20-bookworm
steps:
# Before apt, because apt's failure for this says "Unable to locate
# package bats" and nothing about why. On a runner behind the egress
# allowlist a name that does not resolve is the resolver or the
# allowlist, not the package - and ci-runner.bats, which would say so
# precisely, cannot run yet because installing it is what just failed.
#
# Uses only what the image already has: getent is libc, and the
# resolver's address is in resolv.conf.
# Before apt, because apt's failure for this says "Unable to locate
# package bats" and nothing about why. On a runner behind the egress
# allowlist a name that does not resolve is the resolver or the
# allowlist, not the package - and ci-runner.bats, which would say so
# precisely, cannot run yet because installing it is what just failed.
#
# Retried rather than decided on one attempt: a resolver can be a moment
# behind a container's start, and apt itself retries, so failing a whole
# job on a single lookup would be stricter than the step it is
# protecting. Six attempts over ~15s, then a diagnosis.
#
# Uses only what the image already has: getent is libc.
- name: Check the job network can resolve before installing anything
run: |
for attempt in 1 2 3 4 5 6; do
if getent hosts deb.debian.org >/dev/null 2>&1; then
[ "$attempt" -eq 1 ] || echo "resolved on attempt $attempt"
exit 0
fi
sleep 3
done
echo "deb.debian.org did not resolve after 6 attempts, so this job" >&2
echo "cannot install anything." >&2
echo "" >&2
echo "This job's resolver:" >&2
sed -n 's/^nameserver/ nameserver/p' /etc/resolv.conf >&2
echo "" >&2
echo "If other jobs in this same run resolved fine, the runners are" >&2
echo "configured differently: check that EVERY runner answering this" >&2
echo "label has --dns=<gateway> in container.options and the same" >&2
echo "container.network. A runner missing it fails only the jobs that" >&2
echo "happen to land on it, which looks intermittent." >&2
echo "" >&2
echo "If every job failed, check on the runner host, in order:" >&2
echo " dejima-ci-allowlist show - a server= line for this name?" >&2
echo " systemctl status dnsmasq - running?" >&2
echo " iptables -S DEJIMA-CI-IN - port 53 allowed from the bridge?" >&2
echo " journalctl -u dnsmasq -n 50 - does the query arrive, and is it" >&2
echo " forwarded without a reply?" >&2
exit 1
- name: Install test dependencies
run: |
apt-get update
apt-get install -y --no-install-recommends bats jq python3 tmux ca-certificates
# The code under test, named explicitly - see the header. Without `ref`
# this checks out the base branch and the suite passes without ever
# seeing the change.
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
# As an ordinary user, not root. One test leaves a directory mode 500 and
# asserts that the CLI's rm -rf refuses it and says so; root walks
# through that on CAP_DAC_OVERRIDE and the test fails for a reason that
@@ -71,20 +166,22 @@ jobs:
chown -R node:node .
su node -c "TERM=xterm-256color bats test/${{ matrix.suite }}.bats"
host-docker:
# Not ubuntu-latest, unlike the job above. This one only works on a runner
# configured for it - host network namespace, the Docker socket, and the
# shared volume below - and none of that is anything the workflow can ask
# for. On a generic runner it fails the preflight check outright.
privileged:
# A different label from the job above, and the only one in this file that
# asks for anything privileged. This job needs the host network namespace,
# the Docker socket and the shared volume below, none of which a workflow
# can request - they are granted by the runner or not at all, so on a
# runner without them it fails the preflight check outright.
#
# ubuntu-latest is a label every runner carries, so which one picked this
# up was a coin flip, and a re-run that happened to land on an unconfigured
# runner failed on a repo that had not changed. A label only the configured
# runner has makes that unrepresentable rather than merely diagnosable: no
# runner matching it means this job queues instead of running somewhere it
# cannot work. The sandbox job stays on ubuntu-latest, because it genuinely
# does run anywhere.
runs-on: dejima-runtime
# A label carried by exactly one configured runner makes landing somewhere
# unusable unrepresentable rather than merely diagnosable: no runner
# matching it means this queues instead of running somewhere it cannot
# work. A generic label would make which runner picked it up a coin flip.
#
# The runner behind this label can be taken by anything it runs (see
# ci/act-runner-runtime.example.yaml), so it belongs on a host that exists
# for that and holds nothing else.
runs-on: dejima-privileged
container:
image: node:20-bookworm
# One volume, and it is load-bearing - see TMPDIR on the bats step for
@@ -117,6 +214,55 @@ jobs:
# nothing to do with what the test asserts. That test is the containment
# argument for the ttyd front door, so it is not one to skip in CI.
steps:
# Before apt, because apt's failure for this says "Unable to locate
# package bats" and nothing about why. On a runner behind the egress
# allowlist a name that does not resolve is the resolver or the
# allowlist, not the package - and ci-runner.bats, which would say so
# precisely, cannot run yet because installing it is what just failed.
#
# Uses only what the image already has: getent is libc, and the
# resolver's address is in resolv.conf.
# Before apt, because apt's failure for this says "Unable to locate
# package bats" and nothing about why. On a runner behind the egress
# allowlist a name that does not resolve is the resolver or the
# allowlist, not the package - and ci-runner.bats, which would say so
# precisely, cannot run yet because installing it is what just failed.
#
# Retried rather than decided on one attempt: a resolver can be a moment
# behind a container's start, and apt itself retries, so failing a whole
# job on a single lookup would be stricter than the step it is
# protecting. Six attempts over ~15s, then a diagnosis.
#
# Uses only what the image already has: getent is libc.
- name: Check the job network can resolve before installing anything
run: |
for attempt in 1 2 3 4 5 6; do
if getent hosts deb.debian.org >/dev/null 2>&1; then
[ "$attempt" -eq 1 ] || echo "resolved on attempt $attempt"
exit 0
fi
sleep 3
done
echo "deb.debian.org did not resolve after 6 attempts, so this job" >&2
echo "cannot install anything." >&2
echo "" >&2
echo "This job's resolver:" >&2
sed -n 's/^nameserver/ nameserver/p' /etc/resolv.conf >&2
echo "" >&2
echo "If other jobs in this same run resolved fine, the runners are" >&2
echo "configured differently: check that EVERY runner answering this" >&2
echo "label has --dns=<gateway> in container.options and the same" >&2
echo "container.network. A runner missing it fails only the jobs that" >&2
echo "happen to land on it, which looks intermittent." >&2
echo "" >&2
echo "If every job failed, check on the runner host, in order:" >&2
echo " dejima-ci-allowlist show - a server= line for this name?" >&2
echo " systemctl status dnsmasq - running?" >&2
echo " iptables -S DEJIMA-CI-IN - port 53 allowed from the bridge?" >&2
echo " journalctl -u dnsmasq -n 50 - does the query arrive, and is it" >&2
echo " forwarded without a reply?" >&2
exit 1
- name: Install test dependencies
run: |
apt-get update
@@ -239,6 +385,8 @@ jobs:
echo "GitHub ranges lookup is authenticated - ceiling is $limit requests/hour."
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
# The build dominates this job, and inside setup_file it is a `docker
# build -q` that prints nothing at all - so tens of minutes of ordinary
@@ -291,3 +439,147 @@ jobs:
# own BATS_*_TMPDIR on paths that mean the same thing on both sides
# of the socket, which is all any of those mounts need.
TMPDIR: /tmp/dejima-ci
# The runner's own configuration, asserted from inside a job on it. Separate
# jobs rather than steps on the two above, because the point is that these
# are two different runners and either can be wrong on its own - one red
# check per tier says which.
#
# These fail on how the RUNNER is configured, not on anything in a pull
# request. A red check here means the machine needs attention; it does not
# mean the branch does.
runner-env-confined:
runs-on: dejima-confined
name: runner-env (confined)
container:
image: node:20-bookworm
steps:
# Before apt, because apt's failure for this says "Unable to locate
# package bats" and nothing about why. On a runner behind the egress
# allowlist a name that does not resolve is the resolver or the
# allowlist, not the package - and ci-runner.bats, which would say so
# precisely, cannot run yet because installing it is what just failed.
#
# Uses only what the image already has: getent is libc, and the
# resolver's address is in resolv.conf.
# Before apt, because apt's failure for this says "Unable to locate
# package bats" and nothing about why. On a runner behind the egress
# allowlist a name that does not resolve is the resolver or the
# allowlist, not the package - and ci-runner.bats, which would say so
# precisely, cannot run yet because installing it is what just failed.
#
# Retried rather than decided on one attempt: a resolver can be a moment
# behind a container's start, and apt itself retries, so failing a whole
# job on a single lookup would be stricter than the step it is
# protecting. Six attempts over ~15s, then a diagnosis.
#
# Uses only what the image already has: getent is libc.
- name: Check the job network can resolve before installing anything
run: |
for attempt in 1 2 3 4 5 6; do
if getent hosts deb.debian.org >/dev/null 2>&1; then
[ "$attempt" -eq 1 ] || echo "resolved on attempt $attempt"
exit 0
fi
sleep 3
done
echo "deb.debian.org did not resolve after 6 attempts, so this job" >&2
echo "cannot install anything." >&2
echo "" >&2
echo "This job's resolver:" >&2
sed -n 's/^nameserver/ nameserver/p' /etc/resolv.conf >&2
echo "" >&2
echo "If other jobs in this same run resolved fine, the runners are" >&2
echo "configured differently: check that EVERY runner answering this" >&2
echo "label has --dns=<gateway> in container.options and the same" >&2
echo "container.network. A runner missing it fails only the jobs that" >&2
echo "happen to land on it, which looks intermittent." >&2
echo "" >&2
echo "If every job failed, check on the runner host, in order:" >&2
echo " dejima-ci-allowlist show - a server= line for this name?" >&2
echo " systemctl status dnsmasq - running?" >&2
echo " iptables -S DEJIMA-CI-IN - port 53 allowed from the bridge?" >&2
echo " journalctl -u dnsmasq -n 50 - does the query arrive, and is it" >&2
echo " forwarded without a reply?" >&2
exit 1
- name: Install test dependencies
run: |
apt-get update
apt-get install -y --no-install-recommends bats python3 ca-certificates
# No `ref:` here, deliberately - unlike the suites above. These assert on
# the RUNNER, not on the change, so the assertions should come from the
# base branch rather than from the branch they are meant to hold for.
# Under pull_request_target that is what a bare checkout gives, which
# means a branch can neither weaken these tests nor delete them.
- uses: actions/checkout@v4
- run: bats test/ci-runner.bats
env:
# Declared, not detected: landing on the wrong runner should fail
# rather than quietly select a different set of assertions.
DEJIMA_CI_TIER: confined
runner-env-privileged:
runs-on: dejima-privileged
name: runner-env (privileged)
container:
image: node:20-bookworm
steps:
# Before apt, because apt's failure for this says "Unable to locate
# package bats" and nothing about why. On a runner behind the egress
# allowlist a name that does not resolve is the resolver or the
# allowlist, not the package - and ci-runner.bats, which would say so
# precisely, cannot run yet because installing it is what just failed.
#
# Uses only what the image already has: getent is libc, and the
# resolver's address is in resolv.conf.
# Before apt, because apt's failure for this says "Unable to locate
# package bats" and nothing about why. On a runner behind the egress
# allowlist a name that does not resolve is the resolver or the
# allowlist, not the package - and ci-runner.bats, which would say so
# precisely, cannot run yet because installing it is what just failed.
#
# Retried rather than decided on one attempt: a resolver can be a moment
# behind a container's start, and apt itself retries, so failing a whole
# job on a single lookup would be stricter than the step it is
# protecting. Six attempts over ~15s, then a diagnosis.
#
# Uses only what the image already has: getent is libc.
- name: Check the job network can resolve before installing anything
run: |
for attempt in 1 2 3 4 5 6; do
if getent hosts deb.debian.org >/dev/null 2>&1; then
[ "$attempt" -eq 1 ] || echo "resolved on attempt $attempt"
exit 0
fi
sleep 3
done
echo "deb.debian.org did not resolve after 6 attempts, so this job" >&2
echo "cannot install anything." >&2
echo "" >&2
echo "This job's resolver:" >&2
sed -n 's/^nameserver/ nameserver/p' /etc/resolv.conf >&2
echo "" >&2
echo "If other jobs in this same run resolved fine, the runners are" >&2
echo "configured differently: check that EVERY runner answering this" >&2
echo "label has --dns=<gateway> in container.options and the same" >&2
echo "container.network. A runner missing it fails only the jobs that" >&2
echo "happen to land on it, which looks intermittent." >&2
echo "" >&2
echo "If every job failed, check on the runner host, in order:" >&2
echo " dejima-ci-allowlist show - a server= line for this name?" >&2
echo " systemctl status dnsmasq - running?" >&2
echo " iptables -S DEJIMA-CI-IN - port 53 allowed from the bridge?" >&2
echo " journalctl -u dnsmasq -n 50 - does the query arrive, and is it" >&2
echo " forwarded without a reply?" >&2
exit 1
- name: Install test dependencies
run: |
apt-get update
apt-get install -y --no-install-recommends bats python3 ca-certificates
# Base branch, same as the job above and for the same reason.
- uses: actions/checkout@v4
- run: bats test/ci-runner.bats
env:
DEJIMA_CI_TIER: privileged
+66
View File
@@ -0,0 +1,66 @@
# Attribution
The code in the dejima repositories is GPL-3.0-or-later; see `LICENSE`. The
artwork is not code and is not covered by it.
This file is the canonical statement for the dejima marks. Other repositories
link here rather than restating the terms.
## Authorship
All logos, assets, and artwork are human-made. No AI tools were used in their
creation. The artist is anonymous.
The palette is based on [gruvbox](https://github.com/morhetz/gruvbox) by Pavel
Pertsev, which is MIT-licensed. The colour values are gruvbox's; the marks
drawn with them are not.
## What is covered
Both marks, in every form:
- the logo: the filled tiles and the outline variants, in both light-mode and
dark-mode renderings, at any resolution, and the favicon;
- the wordmark: the solid and outline renderings, again in both modes.
The terms attach to the marks rather than to this list, so renderings not
listed here are covered too.
## Terms
The dejima name, logo and wordmark are trademarks of Brad Anderson **as they
identify this software**. A trademark attaches to a mark as used for
particular goods and services, never to a word in the abstract, and the claim
here goes no further than that.
Dejima is a real place - the island in Nagasaki harbour this project is named
after, and rebuilt there today. Nothing in this file claims the word itself,
its historical or geographic sense, or any use unconnected with this
software. Writing about the island, naming something after it, or using the
name in any way that is not about this project is outside these terms and
always was.
The marks are not licensed under the GPL; GPL-3.0 §7(e) permits declining to
grant trademark rights. All rights not granted below are reserved.
**You may, without asking:**
- reproduce them unmodified to refer to dejima;
- redistribute them unmodified as part of an unmodified copy of a repository
that ships them.
**You may not, without written permission:**
- modify the artwork: recolour, redraw, crop, animate, or fold it into
another mark;
- use the artwork in your own work, or as the mark of any other project,
product or organisation;
- use the name for software in a way likely to be taken as referring to this
project - which is the only sense in which the name is claimed at all;
- use any of them so as to suggest that dejima endorses, sponsors or produced
your work.
A fork that remains a copy of dejima may keep the files. A fork that diverges
into a project of its own must replace them with a mark of its own.
Copyright in the image files is reserved.
+33
View File
@@ -0,0 +1,33 @@
# Contributing
[← dejima README](README.md)
dejima is under active, rapid development, and it is developed on a private
forge. That forge does not allow account registration, and it does not accept
anonymous issues or pull requests. So there is currently no way for you to
file either, and that is a property of where this lives rather than a comment
on the contribution.
That will change: this project will provide a way to submit issues and pull
requests. Until it does, if you are using dejima and need it to do something
different, **fork it and make the change in your fork**. Nothing here depends
on an upstream maintainer accepting a patch. The fork is yours to run, and
running it is the point.
## Expect it to move
Significant change is coming in the near future. Config keys, interfaces, the
layout of the docs, and the boundaries between the CLIs are all still
settling. A fork taken today will diverge from upstream quickly.
If you do fork, pinning to a commit you have read is more useful here than
tracking a branch that is being rewritten under you.
## If you are running an agent against this repository
`docs/responsible-use.md` applies to dejima itself as much as to anything else
you point it at, and one rule in it is load-bearing here: what reaches `main`
becomes the baseline for everything after it. dejima-ci-hardener reads
`.dejima/allowed-domains` from `origin/main` and treats what it finds there as
reviewed, so a change to that file is a change to what a CI runner will let
through.
+412 -769
View File
File diff suppressed because it is too large Load Diff
+77
View File
@@ -0,0 +1,77 @@
# Security policy
This is the security policy for every repository in the dejima organisation.
Individual repositories state what is specific to them and link here for the
rest.
## What dejima claims
It bounds what an agent session can reach. It does not make an agent safe to
run.
A session runs in a container with no permission prompts, one bind-mounted
project directory, a deny-by-default egress allowlist covering DNS as well as
IP, and no copy of the credentials it authenticates with. That allowlist
bounds where a prompt injection or a compromised dependency can send code and
where it can fetch its next instruction. It does not prevent either one.
Nothing here holds against a container escape or a kernel bug. The in-session
hooks are fast-fail guards, not a boundary. The exit-time audit runs after the
session has ended and attributes nothing to it. And none of it speaks to
whether the code a session wrote is correct, secure, or fit to merge.
[docs/security.md](docs/security.md) states the threat model and covers each
of these in detail.
## Reporting a vulnerability
Email **dejima@branderson.io**. Please do not open a public issue for a
vulnerability; the tracker is public, and a report there is a disclosure.
Useful things to include:
1. what an attacker gets;
2. the steps to reproduce it;
3. the repository and the commit you tested.
## What you can expect in return
Nothing is promised: no reply, no acknowledgement, no triage, no fix, and no
timeline for any of them. Reports are read when there is time to read them.
Nothing is asked in return either. There is no embargo period and no
coordinated disclosure window. Disclose on whatever schedule you think is
right.
## This is not proven software
These projects are in early development. Nothing in them has been through
outside review, an external audit, or adversarial testing by anyone other
than the people who wrote them. Where a design argument is stated it is one
person's reasoning; where a test asserts something it asserts what that
person thought to check.
Treat every security property described in any of these repositories as a
claim awaiting scrutiny rather than one that has survived it, and do not
deploy them where being wrong would matter.
## There is no support
These are personal projects with one maintainer.
There is no paid version, no support contract, no service level, no sponsorship
attached to them, and no commercial relationship with anyone who uses them.
Questions may go unanswered. Issues may go unread. Nothing here is developed or
supplied in the course of a commercial activity.
Whoever deploys this is responsible for the result.
## Supported versions
The tip of `main`, and nothing else. There are no maintenance branches, no
backports and no security releases for older tags or commits.
## No warranty
The software is provided without warranty of any kind. See sections 15 and 16
of each repository's `LICENSE`.
+19
View File
@@ -0,0 +1,19 @@
# assets
The dejima wordmark, in the two versions the README needs: `-darkmode` for a
page that renders dark, `-lightmode` for one that renders light. The suffix
names the mode a file is for, not the colour of its ink, so `-darkmode` is the
pale one.
The README points at `-darkmode` unconditionally. A mirror on a light-themed
host changes that one line; the comment above the tag says why it is not
conditional.
**These are trademarks and are not covered by this repository's GPL licence.**
Who holds them, and what may be done with them, is stated once for the whole
organisation, in this repository:
[ATTRIBUTION.md](../ATTRIBUTION.md)
Other repositories in the organisation link to that file by absolute URL, so
it does not move or get renamed without their links breaking with it.
+21
View File
@@ -0,0 +1,21 @@
<svg xmlns="http://www.w3.org/2000/svg" width="122" height="20" role="img"
aria-label="Developed in dejima">
<title>Developed in dejima</title>
<linearGradient id="g" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
</linearGradient>
<clipPath id="r"><rect width="122" height="20" rx="3" fill="#fff"/></clipPath>
<g clip-path="url(#r)">
<rect width="79" height="20" fill="#282828"/>
<rect x="79" width="43" height="20" fill="#8ec07c"/>
<rect width="122" height="20" fill="url(#g)"/>
</g>
<g font-family="Verdana,DejaVu Sans,Geneva,sans-serif" font-size="11"
text-anchor="middle">
<text x="39.5" y="15" fill="#010101" fill-opacity=".3">Developed in</text>
<text x="39.5" y="14" fill="#ebdbb2">Developed in</text>
<text x="100.5" y="15" fill="#010101" fill-opacity=".3">dejima</text>
<text x="100.5" y="14" fill="#282828">dejima</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 969 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

+386 -116
View File
@@ -21,6 +21,13 @@ COMPOSE_FILE="$REPO_ROOT/docker/docker-compose.yml"
source "$SCRIPT_DIR/dejima-project-lib.sh"
# shellcheck source=dejima-provider-lib.sh
source "$SCRIPT_DIR/dejima-provider-lib.sh"
# shellcheck source=dejima-color-lib.sh
source "$SCRIPT_DIR/dejima-color-lib.sh"
# shellcheck source=dejima-slug-lib.sh
source "$SCRIPT_DIR/dejima-slug-lib.sh"
# fd 2: everything this script says for itself goes to stderr, so the
# session's own stdout stays exactly what the container wrote.
color_init 2
# Flags come first, then the optional workspace path, then "--" and
# everything after that reaches the container command untouched - including a
@@ -54,6 +61,65 @@ _need_value() {
}
}
# Per-launch additions to [mounts] readonly and [mounts] read_write, for the
# one-off cases: reading another checkout while migrating code out of it,
# writing to a scratch directory for one session. Repeatable, one path each.
#
# These append to the config's own lists rather than carrying their own mount
# code, which is the whole design. Everything that already applies to a
# configured mount then applies to a flag automatically and with nothing
# duplicated: the existence check, the refusal when a path names the approval
# store or the audit stores, the refusal when the same path appears in both
# lists, and the read-write paths the session briefing and the audit report
# header name. A flag is a way to say where, not a way around any of that.
EXTRA_RO_FLAGS=()
EXTRA_RW_FLAGS=()
FLAG_MOUNTS=""
_add_mount_flag() {
local flag="$1" value="$2"
# Both lists are colon-separated, so a path containing one cannot be
# represented in them at all - it would silently split into two mounts
# that each fail the existence check. Say so instead.
case "$value" in
*:*) echo "$flag path must not contain a colon: $value" >&2; exit 1 ;;
esac
# Same leading-~ expansion the config's list:path type does, for the
# quoted case the shell leaves alone.
value="${value/#\~/$HOME}"
# Relative paths are worth accepting here and meaningless in a config
# file, which has no working directory to be relative to. Made absolute
# by prefixing rather than by readlink -f: resolving would mount the
# symlink's target under a path the operator never typed.
case "$value" in /*) ;; *) value="$PWD/$value" ;; esac
case "$flag" in
--extra-readonly) EXTRA_RO_FLAGS+=("$value") ;;
--extra-read-write) EXTRA_RW_FLAGS+=("$value") ;;
esac
# Recorded here, at parse time, and not where the lists are assembled:
# that happens inside a command substitution, whose subshell would throw
# the assignment away and leave every flag reported as a config entry.
FLAG_MOUNTS="${FLAG_MOUNTS:+$FLAG_MOUNTS:}$value"
}
_append_mounts() {
local list="$1"; shift
local p
for p in "$@"; do
list="${list:+$list:}$p"
done
printf '%s' "$list"
}
# Which of the two the operator actually wrote, for the refusals below.
# Without this a mistyped --ro is reported as a "[mounts] readonly entry",
# sending someone to a config file that does not mention the path at all.
_mount_label() {
local path="$1" from_config="$2" from_flag="$3"
case ":$FLAG_MOUNTS:" in
*":$path:"*) printf '%s' "$from_flag" ;;
*) printf '%s' "$from_config" ;;
esac
}
PRINT_CONFIG=0
PRINT_BRIEFING=0
ALLOW_GITHUB_PUSH_FLAG=0
@@ -67,6 +133,12 @@ while [ $# -gt 0 ]; do
--cmd) _need_value --cmd "${2:-}"; _set_override core.command "$2"; shift 2 ;;
--forge) _need_value --forge "${2:-}"; _set_override core.forge "$2"; shift 2 ;;
--workflow) _need_value --workflow "${2:-}"; _set_override core.workflow "$2"; shift 2 ;;
--extra-readonly|--ro)
_need_value --extra-readonly "${2:-}"
_add_mount_flag --extra-readonly "$2"; shift 2 ;;
--extra-read-write|--rw)
_need_value --extra-read-write "${2:-}"
_add_mount_flag --extra-read-write "$2"; shift 2 ;;
--*) echo "unknown option: $1" >&2; exit 1 ;;
*) break ;;
esac
@@ -157,6 +229,52 @@ if ! config_exists; then
exit 1
fi
# A forge's env_export can name a variable core also fills - today GH_TOKEN,
# which core sets from [github] token_file for every session regardless of
# forge. One variable carries one token, so the forge's wins. Warned rather
# than left silent: an operator who configured both is entitled to know which
# is in effect. Not a refusal, since the forge winning is what selecting it
# asked for.
#
# Driven off env_export rather than the forge's name - the collision belongs
# to the variable, not to any particular forge. Checked here, ahead of
# --print-config's exit, so that reports it too. Both values must actually be
# set: a forge key resolving to nothing leaves core's in effect, and claiming
# the forge won would be false.
_warn_forge_env_collisions() {
local forge="$1" pair name ref core core_name core_section core_leaf
local core_file forge_file
for pair in $(manifest_get forge "$forge" env_export); do
name="${pair%%=*}"
ref="${pair#*=}"
# Only the "@" form (the CONTENTS of the file a config key names) can
# carry one of core's own credentials.
[ "${ref:0:1}" = "@" ] || continue
# NAME=<core section> <core key>, for every variable core fills from
# the config file itself.
for core in "GH_TOKEN=github token_file"; do
core_name="${core%%=*}"
[ "$name" = "$core_name" ] || continue
core_section="${core#*=}"
core_leaf="${core_section##* }"
core_section="${core_section%% *}"
core_file="$(config_get "$core_section" "$core_leaf")"
forge_file="$(config_get "forge.$forge" "${ref:1}")"
[ -f "$core_file" ] && [ -s "$core_file" ] || continue
[ -f "$forge_file" ] && [ -s "$forge_file" ] || continue
echo "Note: [$core_section] $core_leaf is not used while [core] forge = $forge." >&2
echo "$name comes from [forge.$forge] ${ref:1} for this launch." >&2
done
done
}
# Guarded on config_get succeeding: an invalid [core] forge already made
# config_get print the real error (via config_validate) and return empty,
# so feeding that empty value on to _warn_forge_env_collisions would only
# add a second, spurious "invalid forge name:" line on top of it.
if _core_forge="$(config_get core forge)"; then
_warn_forge_env_collisions "$_core_forge"
fi
# --print-config resolves the whole precedence chain and exits, before any
# workspace argument is required and before anything is built or mounted.
# It exists for two reasons: it answers "one config file now - what is
@@ -173,14 +291,8 @@ if [ "$PRINT_CONFIG" = 1 ]; then
fi
# The selected forge/workflow/harness combination, resolved and validated
# before anything is built or mounted. provider_validate_selection enforces
# rules 1, 2 and 6 as refusals and rule 5 (a partly-configured forge) as a
# warning - see its definition. Rule 3 ("a pushing workflow needs a forge
# that resolves to a host") is deliberately NOT enforced here: it is
# deferred to Phase 3, which adds `forge = none` as the honest way to say
# "no forge" - enforcing it now would refuse the supported "I don't use a
# forge" configuration that git-push-guard.sh already handles at runtime
# (see README "Skip this if you only push to GitHub").
# before anything is built or mounted - see provider_validate_selection's own
# definition for the full set of rules it enforces, all as refusals.
DEJIMA_FORGE_NAME="$(config_get core forge)"
DEJIMA_WORKFLOW_NAME="$(config_get core workflow)"
DEJIMA_HARNESS_NAME="$(config_get core harness)"
@@ -211,6 +323,8 @@ WORKSPACE_DIR="$(cd "$WORKSPACE_DIR" && pwd)"
AUDIT_DIR="$(config_get audit dir)"
BASELINE_DIR="$(config_get audit baselines_dir)"
SANDBOX_EXTRA_MOUNTS="$(config_get mounts readonly)"
SANDBOX_EXTRA_MOUNTS="$(_append_mounts "$SANDBOX_EXTRA_MOUNTS" \
${EXTRA_RO_FLAGS[@]+"${EXTRA_RO_FLAGS[@]}"})"
# Read-write host mounts. Distinct from [core] share_dir, which is mounted at
# ~/shared - a well-known handoff point the agent is told about by name -
@@ -231,7 +345,41 @@ _paths_overlap() {
case "$b/" in "$a"/*) return 0 ;; esac
return 1
}
# The stores that decide what a session may do next, and the record of what
# it did. Read-only is not enough to make mounting them harmless: the
# approval store discloses every project's approved domains and
# dependencies, and the audit baselines say which findings have already been
# waved through. Refusing beats mounting and hoping the mode holds (#15).
GUARDED_STORES=()
_guarded_stores() {
[ ${#GUARDED_STORES[@]} -eq 0 ] || return 0
GUARDED_STORES=("$(config_root)" "$AUDIT_DIR" "$BASELINE_DIR")
}
# Refuse $2 if it overlaps any of the paths in $3.., in either direction: an
# ancestor of a store exposes it just as surely as the store itself.
_refuse_guarded_mount() {
local kind="$1" path="$2"; shift 2
local real guarded guarded_real
# readlink -m rather than -f, same reason as the read_write guard below:
# -f gives nothing for a path that does not exist yet, and an empty
# result compares equal to nothing and would sail through.
real="$(readlink -m "$path")"
for guarded in "$@"; do
[ -n "$guarded" ] || continue
guarded_real="$(readlink -m "$guarded")"
_paths_overlap "$real" "$guarded_real" || continue
echo "Refusing $kind: $path" >&2
echo "It overlaps $guarded_real, which dejima needs out of a session's reach." >&2
echo "Even read-only, that hands the session every project's approvals or the" >&2
echo "record of what earlier sessions were caught doing." >&2
exit 1
done
}
SANDBOX_RW_MOUNTS="$(config_get mounts read_write)"
SANDBOX_RW_MOUNTS="$(_append_mounts "$SANDBOX_RW_MOUNTS" \
${EXTRA_RW_FLAGS[@]+"${EXTRA_RW_FLAGS[@]}"})"
RW_MOUNT_PATHS=()
DEJIMA_RW_MOUNTS=""
if [ -n "$SANDBOX_RW_MOUNTS" ]; then
@@ -239,7 +387,7 @@ if [ -n "$SANDBOX_RW_MOUNTS" ]; then
for path in "${rw_paths[@]}"; do
[ -n "$path" ] || continue
if [ ! -d "$path" ]; then
echo "[mounts] read_write entry is not an existing directory: $path" >&2
echo "$(_mount_label "$path" "[mounts] read_write entry" "--extra-read-write path") is not an existing directory: $path" >&2
exit 1
fi
# readlink -m, not -f: -f yields nothing unless at most the final
@@ -256,7 +404,7 @@ if [ -n "$SANDBOX_RW_MOUNTS" ]; then
"$HOME/.claude" "$HOME/.local/bin" "$REPO_ROOT" "$WORKSPACE_DIR"; do
guarded_real="$(readlink -m "$guarded")"
_paths_overlap "$rw_real" "$guarded_real" || continue
echo "Refusing [mounts] read_write entry: $path" >&2
echo "Refusing $(_mount_label "$path" "[mounts] read_write entry" "--extra-read-write path"): $path" >&2
echo "It overlaps $guarded_real, which dejima needs out of a session's reach." >&2
echo "A writable mount there would let a session approve its own domains and" >&2
echo "dependencies, delete the audit findings it is named in, or plant code" >&2
@@ -299,19 +447,50 @@ if [ "$PRINT_BRIEFING" = 1 ]; then
exit 0
fi
if ! DEJIMA_FORGE_HOSTS="$(forge_hosts "$DEJIMA_FORGE_NAME")"; then
if ! _forge_all_hosts="$(forge_hosts "$DEJIMA_FORGE_NAME")"; then
exit 1
fi
DEJIMA_WORKFLOW_PUSH="$(manifest_get workflow "$DEJIMA_WORKFLOW_NAME" push)"
# A forge declaring hosts_allowlisted_by_core says its hosts are ones core
# already allowlists in its own right - the GitHub ranges every session can
# read from over 443. Those must NOT reach DEJIMA_FORGE_HOSTS, which
# entrypoint.sh folds into EXTRA_ALLOWED_DOMAINS: that path resolves a name
# into the port-UNRESTRICTED allowed-domains ipset and exempts it from
# dnsmasq's stop-dns-rebind, both of which are deliberate for an internal,
# self-hosted forge host and both of which are a widening for a public one.
# It would also make init-firewall.sh's startup self-check ("port 22 was
# reachable without the push grant") fail, killing the container outright.
#
# They still reach DEJIMA_PUSH_HOSTS: git-push-guard.sh's allowlist is a
# separate question from which names the firewall resolves, and a push to a
# host the guard doesn't know is refused inside the container regardless of
# how the packets would have been routed.
ALLOW_CORE_PUSH_GRANT="$ALLOW_GITHUB_PUSH_FLAG"
if [ "$(manifest_get forge "$DEJIMA_FORGE_NAME" hosts_allowlisted_by_core)" = "on" ]; then
DEJIMA_FORGE_HOSTS=""
# And pushing to them therefore goes through core's own push grant
# (ALLOW_GITHUB_PUSH below), which opens port 22 to the same ranges the
# read-only path already allows on 443 - logged, self-checked, and the
# documented way that port opens. Only when the workflow actually pushes:
# a non-pushing workflow has no reason to open it.
if [ "$DEJIMA_WORKFLOW_PUSH" = "on" ]; then
ALLOW_CORE_PUSH_GRANT=1
fi
else
DEJIMA_FORGE_HOSTS="$_forge_all_hosts"
fi
if [ "$DEJIMA_WORKFLOW_PUSH" = "on" ]; then
DEJIMA_PUSH_HOSTS="$DEJIMA_FORGE_HOSTS"
DEJIMA_PUSH_HOSTS="$_forge_all_hosts"
else
DEJIMA_PUSH_HOSTS=""
fi
# --allow-github-push's actual effect: git-push-guard.sh only ever checks
# DEJIMA_PUSH_HOSTS, so granting it a github.com push means adding these
# hosts to that list, not just exporting ALLOW_GITHUB_PUSH (which nothing
# reads anymore now that the guard is forge/workflow-driven).
# reads anymore now that the guard is forge/workflow-driven). Keyed to the
# flag alone, not to ALLOW_CORE_PUSH_GRANT: a forge whose own hosts these
# are has already contributed them above, and adding them twice would leave
# the guard's list carrying each name two or three times over.
if [ "$ALLOW_GITHUB_PUSH_FLAG" = 1 ]; then
DEJIMA_PUSH_HOSTS="${DEJIMA_PUSH_HOSTS:+$DEJIMA_PUSH_HOSTS }github.com ssh.github.com"
fi
@@ -390,7 +569,7 @@ if [ -n "$SANDBOX_SSH_KEY" ]; then
echo "Recommended: chmod 600 $SANDBOX_SSH_KEY" >&2
fi
# SANDBOX_SSH_KEY is meant to be a dedicated, low-privilege deploy key
# (see README "Headless git push"), not your real identity - it stays
# (see docs/configuration.md "Headless git push"), not your real identity - it stays
# loaded in an in-container agent for the whole session, unattended.
# Nothing enforces that convention, so at least catch the common case
# of pointing it at a default personal keyfile by mistake.
@@ -399,7 +578,7 @@ if [ -n "$SANDBOX_SSH_KEY" ]; then
echo "Warning: $SANDBOX_SSH_KEY looks like a default personal SSH key," \
"not a dedicated one." >&2
echo "It will be loaded into the sandbox for the whole unattended session -" \
"see README 'Headless git push'." >&2
"see docs/configuration.md." >&2
;;
esac
fi
@@ -415,19 +594,27 @@ fi
if [ "$DEJIMA_WORKFLOW_PUSH" = "on" ] && [ "$have_forwarded_agent" = 0 ] && [ -z "$SANDBOX_SSH_KEY" ]; then
echo "SSH_AUTH_SOCK is not set or not a valid socket, and [core] ssh_key is not set." >&2
echo "Either start ssh-agent and run ssh-add, or set [core] ssh_key in $(config_path) to a" >&2
echo "dedicated key for headless use - see README 'Headless git push' section." >&2
echo "dedicated key for headless use - see docs/configuration.md." >&2
exit 1
fi
# A valid forwarded socket with zero identities loaded forwards fine (the
# container sees a live, working agent) but is useless for git-over-ssh -
# warn now rather than have that surface confusingly deep into a session as
# a failed push. Non-fatal: a task might not need git-over-ssh at all, and
# SANDBOX_SSH_KEY (if set) provides its own identity independently of this.
if [ "$have_forwarded_agent" = 1 ] && ! ssh-add -l >/dev/null 2>&1; then
# a failed push. Non-fatal: a task might not need git-over-ssh at all.
#
# Silent when [core] ssh_key is set, because then the premise is false: the
# key is loaded into the container's own agent and pushes work without the
# forwarded one carrying anything. It is also the better arrangement of the
# two - a dedicated identity that works unattended, rather than the
# operator's own - so warning about the fallback being empty, while the
# recommended path is configured and working, tells someone who took the
# advice that they got it wrong.
if [ "$have_forwarded_agent" = 1 ] && [ -z "$SANDBOX_SSH_KEY" ] \
&& ! ssh-add -l >/dev/null 2>&1; then
echo "Warning: ssh-agent has no identities loaded (ssh-add -l shows none)." >&2
echo "git-over-ssh (e.g. pushing to the configured forge) will fail until you ssh-add a key," >&2
echo "unless [core] ssh_key is also set." >&2
echo "or set [core] ssh_key in $(config_path) to a dedicated key - see docs/configuration.md." >&2
fi
# Deliberately not `gh auth token` / gh's own hosts.yml: a normal `gh
@@ -484,46 +671,29 @@ if ! settings_error="$(jq . "$HOME/.claude/settings.json" 2>&1 >/dev/null)"; the
exit 1
fi
# Claude Code's own internal per-command sandboxing (~/.claude/settings.json's
# "sandbox" block) is redundant here - it can't even function inside this
# container (bwrap can't create a namespace, and getting it to would need
# privilege escalation approaching --privileged, undoing this container's own
# hardening), and this container is already a real, working sandbox on its
# own. A session can't be trusted to turn it off itself, so the override is
# generated here, on the host, and reaches Claude Code as an extra settings
# layer: docker-compose.yml mounts this file read-only at a container-local
# path and claude-wrapper.sh passes it as --settings, which loads above
# ~/.claude/settings.json in the precedence order. Everything else in the
# operator's real settings (hooks, permissions, plugins) still applies from
# there, so this file holds only what the sandbox overrides.
# Claude Code's own per-command sandboxing is redundant here and cannot work
# anyway - bwrap can't create a namespace without privileges that would undo
# this container's hardening - and a session can't be trusted to turn it off
# itself. So the override is generated host-side and reaches Claude Code as
# an extra --settings layer above ~/.claude/settings.json, which holds only
# what the sandbox overrides; the operator's hooks, permissions and plugins
# still apply from there.
#
# The earlier arrangement - a full modified copy of the operator's settings,
# mounted over ~/.claude/settings.json - detached itself whenever the host
# rewrote the real file, because that mount point sits inside the read-write
# ~/.claude mount and a rename unlinks the dentry the mount is pinned to. The
# session then silently fell back to the operator's settings, with sandboxing
# back on and the notify hooks below gone. Mounting outside ~/.claude removes
# that: nothing on the host can rename over a container-local path. See #47.
# It is mounted at a container-local path, not over ~/.claude/settings.json.
# That earlier arrangement detached whenever the host rewrote the real file
# (a rename unlinks the dentry the mount is pinned to), silently restoring
# the operator's settings with sandboxing back on. See #47.
SANDBOX_SETTINGS_FILE="$(mktemp "${TMPDIR:-/tmp}/dejima-settings.XXXXXX.json")"
jq -n '{sandbox: {enabled: false}}' > "$SANDBOX_SETTINGS_FILE"
# ~/.claude.json mixes live app state (project registry, usage stats,
# caches) with config (mcpServers) that can't be split at the mount level -
# unlike settings.json above, it can't just be made read-only either:
# Claude Code does an atomic write-temp-then-rename against this exact file
# during normal startup (before login even completes), and a read-only
# file makes that hang instead of failing cleanly (confirmed against a
# real read-only copy of this file - see
# https://github.com/anthropics/claude-code/issues/29162). Generate a
# throwaway copy instead, seeded from the real file, and mount *that*
# read-write in its place - same "generate a sandbox-specific file on the
# host" pattern as SANDBOX_SETTINGS_FILE above, just solving a different
# problem: nothing a compromised session writes here (mcpServers or
# anything else) ever reaches the real file, since the copy is discarded,
# not merged back, when this session ends. This also means concurrent
# sandbox sessions never contend over the same file - each gets its own
# copy. See README "Throwaway ~/.claude.json" for exactly what that means
# doesn't carry over from a sandboxed session.
# ~/.claude.json mixes live app state with config (mcpServers) that cannot be
# split at the mount level, and read-only is not an option: Claude Code does
# an atomic write-and-rename against it during startup, and a read-only file
# makes that HANG rather than fail cleanly (anthropics/claude-code#29162).
# So it gets a throwaway copy, seeded from the real file and discarded rather
# than merged back - nothing a session writes here reaches the host, and
# concurrent sessions never contend. See docs/configuration.md, "Config
# parity with your host Claude Code setup".
SANDBOX_CLAUDE_JSON_FILE="$(mktemp "${TMPDIR:-/tmp}/dejima-claude-json.XXXXXX.json")"
if [ -f "$HOME/.claude.json" ]; then
cp "$HOME/.claude.json" "$SANDBOX_CLAUDE_JSON_FILE"
@@ -607,7 +777,7 @@ cleanup() {
# STATE is dropped from the rm -f above and freed only after compare has
# read it, so this function's own cleanup can't race the audit that
# needs it - and its removal is guarded too, so it stays truly last.
"$SCRIPT_DIR/dejima-audit" compare "$WORKSPACE_DIR" "$SANDBOX_AUDIT_STATE" || true
DEJIMA_RW_MOUNTS="$DEJIMA_RW_MOUNTS" "$SCRIPT_DIR/dejima-audit" compare "$WORKSPACE_DIR" "$SANDBOX_AUDIT_STATE" || true
rm -f "$SANDBOX_AUDIT_STATE" || true
}
trap cleanup EXIT
@@ -652,18 +822,19 @@ export SANDBOX_SSH_KEY_FILE="$SANDBOX_SSH_KEY"
export SSH_KNOWN_HOSTS="$(config_get core known_hosts)"
export GH_TOKEN="${GH_TOKEN:-}"
export ALLOWED_DOMAINS="${ALLOWED_DOMAINS:-}"
# --allow-github-push is the only way to grant this - same principle as
# ALLOWED_DOMAINS above: this opens a push path, so an exported value already
# sitting in the operator's shell profile must not be able to silently grant
# it, with nothing recording that it did.
if [ "$ALLOW_GITHUB_PUSH_FLAG" = 1 ]; then
# The --allow-github-push flag and a selected forge whose hosts_allowlisted_by_core
# says these are its own push hosts (see ALLOW_CORE_PUSH_GRANT above) are the
# only two ways to grant this - same principle as ALLOWED_DOMAINS above: this
# opens a push path, so an exported value already sitting in the operator's
# shell profile must not be able to silently grant it, with nothing recording
# that it did. Both ways in are recorded: one in the command that used the
# flag, the other in the config file that selected the forge.
if [ "$ALLOW_CORE_PUSH_GRANT" = 1 ]; then
export ALLOW_GITHUB_PUSH=1
else
# Plain assignment, not "${ALLOW_GITHUB_PUSH:-0}" - same reason
# ALLOWED_DOMAINS above is plain. This opens a push path, so an exported
# value in a shell profile must not be able to grant it silently; the
# --allow-github-push flag is the only way in, and it is recorded in the
# command that used it.
# value in a shell profile must not be able to grant it silently.
export ALLOW_GITHUB_PUSH=0
fi
export TZ="${TZ:-$(readlink -f /etc/localtime 2>/dev/null | sed 's#.*/zoneinfo/##')}"
@@ -751,6 +922,13 @@ for mounted_dir in hooks skills commands agents output-styles; do
case " $mounted_dirs " in
*" $real_dir "*) continue ;;
esac
# The target is discovered, not configured, so nothing has vetted it
# (#15). A hook symlinked into the approval store would mount it
# here, read-only and silently. Refusing rather than skipping: a
# skipped mount leaves the symlink dangling inside the container and
# the hook failing for a reason nothing states.
_guarded_stores
_refuse_guarded_mount "~/.claude symlink target" "$real_dir" "${GUARDED_STORES[@]}"
mounted_dirs="$mounted_dirs $real_dir"
extra_mounts+=(-v "$real_dir:$real_dir:ro")
done
@@ -795,9 +973,15 @@ if [ -n "$SANDBOX_EXTRA_MOUNTS" ]; then
for path in "${extra_paths[@]}"; do
[ -n "$path" ] || continue
if [ ! -e "$path" ]; then
echo "[mounts] readonly entry does not exist: $path" >&2
echo "$(_mount_label "$path" "[mounts] readonly entry" "--extra-readonly path") does not exist: $path" >&2
exit 1
fi
# [mounts] read_write has been guarded since it was added; this one
# never was, so `readonly = ~/.config/dejima` mounted the approval
# store into the container and nothing said a word (#15).
_guarded_stores
_refuse_guarded_mount "$(_mount_label "$path" "[mounts] readonly entry" "--extra-readonly path")" \
"$path" "${GUARDED_STORES[@]}"
extra_mounts+=(-v "$path:$path:ro")
done
fi
@@ -810,7 +994,10 @@ fi
# DEJIMA_RW_MOUNTS is deliberately not forwarded into the container as an
# environment variable: hooks/sandbox-context.sh no longer reads it (the
# writable-mounts sentence reaches the session through the composed briefing
# instead), so passing it would just be a value nothing inside consumes.
# instead), so passing it would just be a value nothing inside consumes. It
# IS forwarded the other direction, host-side, to the exit-time dejima-audit
# invocation in cleanup() below - that report's header names these paths so
# an operator reading any finding knows what else the session could reach.
for path in ${RW_MOUNT_PATHS[@]+"${RW_MOUNT_PATHS[@]}"}; do
extra_mounts+=(-v "$path:$path")
done
@@ -836,36 +1023,50 @@ if [ -d "$WORKSPACE_DIR/.git/hooks" ]; then
extra_mounts+=(-v "$WORKSPACE_DIR/.git/hooks:$WORKSPACE_DIR/.git/hooks:ro")
fi
# ~/.claude/statusline.sh is the same class of file as the config staged at
# /etc/dejima-claude by docker-compose.yml: settings.json's statusLine.command
# points at it, and Claude Code executes it on the host. Staged the same way,
# for entrypoint.sh to copy into place, rather than mounted at its real path.
# ~/.claude/projects, one slug at a time: the tree is every project's
# transcripts and memory, and memory is loaded into context at session start,
# so a write into another slug is background a future session there believes
# (#104). projects/ itself stays container-local, so an unmounted slug is
# still writable and just does not survive - which is how Claude Code creates
# a slug it does not find. Existing slugs only, apart from the workspace's
# own, so a checkout full of worktrees does not litter the host with empty
# directories for worktrees nothing has run in.
mkdir -p "$HOME/.claude/projects/$(ws_slug "$WORKSPACE_DIR")"
while IFS= read -r _slug; do
[ -n "$_slug" ] || continue
[ -d "$HOME/.claude/projects/$_slug" ] || continue
extra_mounts+=(-v "$HOME/.claude/projects/$_slug:$HOME/.claude/projects/$_slug")
done < <(workspace_slugs_sorted "$WORKSPACE_DIR")
# ~/.claude/file-history, same shape, sharper reason: `<session>/<hash>@v1`
# is a verbatim copy of an edited file, so the tree is other projects' source
# rather than a record of it, and it was writable - tampering there puts
# chosen content into a later restore. Keyed by session id, so there is no
# slug to match on; workspace_session_ids resolves it through the transcript
# names. The starting session's id cannot be known yet, so its directory is
# container-local: undo history for it is lost if it is resumed on the host.
mkdir -p "$HOME/.claude/file-history"
while IFS= read -r _sid; do
[ -n "$_sid" ] || continue
[ -d "$HOME/.claude/file-history/$_sid" ] || continue
extra_mounts+=(-v "$HOME/.claude/file-history/$_sid:$HOME/.claude/file-history/$_sid")
done < <(workspace_session_ids "$HOME/.claude/projects" "$WORKSPACE_DIR")
# statusline.sh is executed on the host by Claude Code (settings.json's
# statusLine.command), so it is staged for entrypoint.sh to copy rather than
# mounted at its real path - same as the config in docker-compose.yml.
#
# It used to be mounted read-only at $HOME/.claude/statusline.sh, and that
# protected only the plain-regular-file case - confirmed against a live
# container, not just reasoned about. When statusline.sh was itself a symlink
# (the common case - typically into a dotfiles checkout), the read-write
# ~/.claude mount was established first, so the destination already had that
# symlink sitting on it, and bind-mount destination resolution (runc's
# SecureJoin) followed it: the :ro mount landed on the target directory
# instead of the symlink dentry, which stayed writable and replaceable. There
# was no mount-based fix while ~/.claude was mounted whole, and the
# compensating control was detection after the fact (bin/dejima-audit hashes
# the symlink's target string). Staging closes it outright: Docker resolves
# the symlink when it sets up the staging mount, so entrypoint.sh copies the
# real content to a path nothing on the host can reach.
# A :ro mount there protected only the plain-file case, confirmed against a
# live container. When statusline.sh is itself a symlink (the common case),
# bind-mount destination resolution follows it, so the mount lands on the
# target and the link stays replaceable. Staging closes that: Docker resolves
# the symlink setting up the staging mount, and the copy lands where the host
# cannot reach it.
#
# Conditional on existence because not every host has one, and mounting a
# nonexistent source would make Docker create a root-owned directory at the
# target instead - worse than the gap this closes.
#
# A *dangling* symlink (target doesn't exist, e.g. a dotfiles checkout that
# isn't present) is neither "exists" nor safely mountable, so it gets its own
# branch that refuses to launch rather than either silently skipping the
# mount (reopens the gap plain -e had) or attempting it anyway (Docker can't
# bind-mount a dangling symlink - it fails deep in mount setup with an opaque
# "mkdir ...: file exists", since the symlink already occupies that path,
# with nothing connecting that error back to the statusline for the user).
# Conditional on existence - mounting a missing source makes Docker create a
# root-owned directory at the target. A dangling symlink is neither
# mountable nor "exists", so it refuses rather than skipping the mount
# (reopening the gap) or failing later with an opaque "mkdir: file exists".
if [ -L "$HOME/.claude/statusline.sh" ] && [ ! -e "$HOME/.claude/statusline.sh" ]; then
echo "$HOME/.claude/statusline.sh is a symlink to a target that doesn't" >&2
echo "exist. Docker can't bind-mount a dangling symlink, and skipping the" >&2
@@ -876,16 +1077,12 @@ elif [ -e "$HOME/.claude/statusline.sh" ]; then
extra_mounts+=(-v "$HOME/.claude/statusline.sh:/etc/dejima-claude/statusline.sh:ro")
fi
# Keep the real Anthropic OAuth credential out of the container entirely:
# run a host-side proxy that injects it per-request (see
# bin/dejima-anthropic-proxy and docs/security.md). Nothing needs masking
# any more - ~/.claude isn't mounted as a whole, so the real credential (and
# the proxy's .credentials-proxy-tmp staging directory beside it) simply
# never crosses into the container. That masking is what #50 was: both masks
# were single-file/-directory mounts nested in the read-write ~/.claude
# mount, and the proxy's own write-back renamed the credential out from
# under them, detaching the mask and exposing the real token from the first
# refresh on.
# The real Anthropic OAuth credential never enters the container: a host-side
# proxy injects it per request (bin/dejima-anthropic-proxy, docs/security.md).
# Nothing needs masking now that ~/.claude isn't mounted whole - and masking
# is what #50 was, a single-file mount nested in the read-write ~/.claude
# that the proxy's own write-back renamed out from under, exposing the real
# token on the first refresh.
#
# How the container then reaches the proxy depends on the transport. In the
# default tls one, entrypoint.sh points api.anthropic.com at an in-container
@@ -1010,6 +1207,25 @@ if remote_enabled; then
DEJIMA_NOTIFY_DIR="$DEJIMA_STATE_DIR/notify/$DEJIMA_RUN_ID"
mkdir -p -m 700 "$DEJIMA_NOTIFY_DIR"
# Verify what was just created, because everything downstream assumes it.
# A leaf left behind root-owned by an earlier container restart (see
# entrypoint.sh) is the shape this catches: mkdir -p succeeds against an
# existing directory regardless of who owns it, so without this the mount
# goes ahead and the failure surfaces later as EACCES in a hook.
#
# A warning rather than a refusal. Remote access is opt-in, and the rest
# of the session works without it - blocking a launch over undeliverable
# notifications would cost more than it saves. The neighbouring state-dir
# check does refuse, but that one is about a symlink handing a session an
# attacker-chosen path; this one is only about the feature working.
if [ ! -O "$DEJIMA_NOTIFY_DIR" ] || [ ! -w "$DEJIMA_NOTIFY_DIR" ]; then
echo "warning: $DEJIMA_NOTIFY_DIR is not a directory this user owns and can write." >&2
echo "Remote access is enabled, so notifications and dejima-attach will not work for" >&2
echo "this session. A leftover root-owned directory here is usually Docker's doing," >&2
echo "from a container that was restarted rather than relaunched; removing it and" >&2
echo "starting again clears it." >&2
fi
# $TMUX_PANE is set by tmux in the launching shell. Empty when the
# sandbox wasn't started from tmux: the session is still registered, just
# listed as not-attachable, rather than silently omitted.
@@ -1096,6 +1312,17 @@ if remote_enabled; then
mv "$SANDBOX_REMOTE_SETTINGS_TMP" "$SANDBOX_SETTINGS_FILE"
fi
# Everything from here to the container's own first output is silent: the
# audit snapshot, an image build that prints nothing when every layer is
# cached, and Docker creating the container with a mount per allowed path.
# Long enough to read as hung, so say what is happening first. A first run,
# or one after a Dockerfile change, builds the image and takes minutes rather
# than seconds - the line names that, since it is the case where someone is
# most likely to give up and interrupt.
printf '%sdejima%s is starting, please wait...\n' "$C_KEY" "$C_RESET" >&2
printf '%sA first run builds the container image, which takes several minutes.%s\n' \
"$C_DIM" "$C_RESET" >&2
# Snapshot host-persistence surfaces so the post-run audit can report
# anything that changed while this session ran (advisory - see bin/dejima-audit).
"$SCRIPT_DIR/dejima-audit" snapshot "$WORKSPACE_DIR" "$SANDBOX_AUDIT_STATE" || true
@@ -1149,6 +1376,22 @@ if [ -n "$PROJECT_STORE" ] && [ -s "$PROJECT_STORE/dependencies" ]; then
fi
export SANDBOX_IMAGE
# nvim's data, state and cache are Docker volumes rather than host paths, so
# nothing a session installs reaches the host's own nvim. They did outlive
# the container under one fixed name each, though, shared by every project
# on the machine: a session that drops a file under `lazy/`, or replaces a
# binary under `mason/bin/`, is writing something a later session in an
# unrelated project executes the next time nvim runs in the sandbox. That is
# the persistence this repository closes everywhere else, so it is scoped
# the same way the derived image tag is - per project.
#
# The prefix is not decoration: project_slug maps a leading `/` to `-`, and
# a Docker volume name may not start with one.
_nvim_volume_slug="$(project_slug "$WORKSPACE_DIR")"
export SANDBOX_NVIM_DATA="dejima_nvim-data${_nvim_volume_slug}"
export SANDBOX_NVIM_STATE="dejima_nvim-state${_nvim_volume_slug}"
export SANDBOX_NVIM_CACHE="dejima_nvim-cache${_nvim_volume_slug}"
# Not exec'd (the EXIT trap cleans up the generated temp files) and not a
# bare command under set -e (a nonzero/interrupted container run - e.g.
# Ctrl-C returning 130 - would otherwise abort the script here, skipping the
@@ -1160,7 +1403,7 @@ export SANDBOX_IMAGE
# because exit-time output can be ignored exactly once, permanently. This is
# host-side and stays host-side: it must never be routed into the session's
# context, which would make it a cross-session persistence channel.
audit_slug="$(printf '%s' "$WORKSPACE_DIR" | sed 's/[^a-zA-Z0-9]/-/g')"
audit_slug="$(ws_slug "$WORKSPACE_DIR")"
audit_reports="$AUDIT_DIR/$audit_slug"
# `|| true`: a workspace with no history yet (the common case - nothing has
# ever run against it, or every past finding is already acknowledged) has no
@@ -1169,16 +1412,43 @@ audit_reports="$AUDIT_DIR/$audit_slug"
# every ordinary launch.
pending_crit="$(find "$audit_reports" -maxdepth 1 -name '*-CRITICAL.txt' 2>/dev/null | sort)" || true
if [ -n "$pending_crit" ]; then
n="$(printf '%s\n' "$pending_crit" | wc -l)"
echo "" >&2
echo " dejima: $n unacknowledged CRITICAL finding(s) in this workspace" >&2
printf '%s\n' "$pending_crit" | sed 's#.*/##; s#-CRITICAL\.txt$##; s#^# from run #' >&2
echo " Review with: dejima-audit ack \"$WORKSPACE_DIR\"" >&2
if [ -t 0 ]; then
printf ' [c] continue [q] quit: ' >&2
read -r reply || reply=q
case "$reply" in q|Q) echo "aborted." >&2; exit 1 ;; esac
elif [ "$(config_get_bool audit strict)" = "1" ]; then
# Re-read after an inline `ack`, so approving everything here clears the
# warning instead of asking again about reports that no longer carry a
# CRITICAL. Looping rather than asking once: `ack` walks ONE report per
# run (see select_report), so a workspace with several needs several
# passes, and sending the operator back to their shell between each is
# the friction this option exists to remove (#65).
while [ -n "$pending_crit" ]; do
n="$(printf '%s\n' "$pending_crit" | wc -l)"
echo "" >&2
printf ' dejima: %s%s unacknowledged %sCRITICAL%s finding(s) in this workspace\n' \
"$C_CRIT" "$n" "$C_CRIT" "$C_RESET" >&2
printf '%s\n' "$pending_crit" \
| sed "s#.*/##; s#-CRITICAL\.txt\$##; s#^# from run ${C_INFO}#; s#\$#${C_RESET}#" >&2
printf ' Review with: %sdejima-audit ack "%s"%s\n' \
"$C_KEY" "$WORKSPACE_DIR" "$C_RESET" >&2
if [ -t 0 ]; then
printf ' %s[a]%s review now %s[c]%s continue %s[q]%s quit: ' \
"$C_KEY" "$C_RESET" "$C_KEY" "$C_RESET" "$C_KEY" "$C_RESET" >&2
read -r reply || reply=q
case "$reply" in
q|Q) echo "aborted." >&2; exit 1 ;;
a|A)
# Run it here rather than telling the operator to run it
# in another terminal: this is the moment they are
# looking at the finding, and the container has not
# started, so nothing is waiting on the answer.
"$SCRIPT_DIR/dejima-audit" ack "$WORKSPACE_DIR" || true
pending_crit="$(find "$audit_reports" -maxdepth 1 -name '*-CRITICAL.txt' 2>/dev/null | sort)" || true
continue
;;
esac
fi
break
done
fi
if [ -n "$pending_crit" ] && [ ! -t 0 ]; then
if [ "$(config_get_bool audit strict)" = "1" ]; then
echo " audit.strict is on and stdin is not a tty - refusing to start." >&2
exit 1
else
+64 -46
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)."
+30 -47
View File
@@ -24,23 +24,17 @@ fi
STATE_DIR="$(remote_state_dir)"
SESSIONS_DIR="$STATE_DIR/sessions"
# gruvbox dark. Every value below is upstream's, tagged with the name of the
# color.table row it came from:
# gruvbox dark, every value upstream's and tagged with its color.table row:
# https://github.com/gruvbox-community/gruvbox-contrib/blob/master/color.table
# Carrying the name and not just the hex is the point - a palette this widely
# known is one almost nobody has memorised to six digits, so a wrong value is
# only catchable by reading if there is something to check it against.
# The names are carried so a wrong hex is catchable by reading.
#
# Truecolor only, and deliberately not paired with a 256-colour table: a
# terminal without it - or tmux without `Tc` - approximates 38;2 to its own
# nearest colour rather than printing the escape, so the fallback already
# exists one layer down and a second palette here would only duplicate it.
# Truecolor only: a terminal without it, or tmux without `Tc`, approximates
# 38;2 to its nearest colour rather than printing the escape, so the fallback
# already exists one layer down.
#
# Everything below is empty unless stdout is a terminal that says it can show
# colour, which is what keeps --list clean: it is a machine interface (ttyd's
# --url-arg feeds run ids back in, and the README documents the rows as
# scriptable), and it is also how the bats suite reads this script, so its
# assertions see the same bare words either way.
# Empty unless stdout is a colour-capable terminal, which keeps --list a
# machine interface (ttyd feeds run ids back in) and is also how the bats
# suite reads this script.
if [ -t 1 ] && [ -z "${NO_COLOR:-}" ] && [ "${TERM:-dumb}" != dumb ]; then
C_OFF=$'\033[0m'; C_BOLD=$'\033[1m'
C_BADGE=$'\033[38;2;254;128;25m' # bright_orange #fe8019, the wordmark
@@ -95,22 +89,16 @@ state_color() {
# How long after its own started= a registered session may still have no
# container, before the prune in list_sessions treats that as "gone".
#
# Sized against the step that actually holds a launch up: bin/dejima writes
# the registry entry, then rewrites the settings copy, snapshots the
# workspace for the audit, runs an UNCONDITIONAL `docker compose build
# claude`, optionally builds up to two derived images on top of it, and only
# then reaches `docker compose run`. A cold base build is the Dockerfile's
# apt and npm layers fetched over the network - minutes, not seconds - and
# the derived builds add to it, while a warm run reaches the container in
# seconds and never comes near this window.
# Sized against what holds a launch up: the registry entry is written before
# an unconditional `docker compose build`, plus up to two derived image
# builds, and a cold base build fetches apt and npm layers - minutes. A warm
# run never comes near this window.
#
# The two errors are not symmetric, which is why this is generous. Too long
# only leaves a session that really did die listed until the window passes,
# and the next listing prunes it. Too short deletes the registry entry of a
# session that is about to start: bin/dejima never rewrites that entry, so
# the session is permanently absent from both front doors, and the marker
# directory deleted with it is the source of a bind mount the imminent
# `docker compose run` declares.
# Generous because the errors are not symmetric. Too long leaves a dead
# session listed until the window passes. Too short deletes the entry of a
# session about to start: bin/dejima never rewrites it, so the session is
# permanently absent from both front doors, and the marker directory removed
# with it is a bind source the imminent `compose run` declares.
STARTUP_GRACE=900
# Parsed, never sourced: this file is host-written, but treating state files
@@ -281,26 +269,21 @@ attach() {
exit 1
fi
group="dejima-$run_id"
# Both values come from the same live pane, in one round trip. Taking the
# session name from the recorded label instead would follow a name rather
# than the sandbox: an operator renaming sessions is enough to point it at
# an unrelated session that has since taken the old name.
# Both values from the same live pane, one round trip. Taking the session
# name from the recorded label would follow a name rather than the
# sandbox, and a rename is enough to point it at an unrelated session.
#
# The name is a starting point, never a guarantee. It is frequently
# ANOTHER remote client's throwaway group (tmux reports whichever session
# holding the window is most recent, and every grouped session here is
# newer than the operator's own), and those carry destroy-unattached - so
# the base can be made to stop existing on demand by anyone holding a
# second connection. That is why the session created from it is verified
# below instead of trusted: `new-session -t` does not fail on a name that
# matches nothing, it silently creates a fresh UNGROUPED session, which
# for a remote client is a plain host shell with no sandbox in it.
# The name is a starting point, not a guarantee: it is often another
# remote client's throwaway group, and those carry destroy-unattached, so
# anyone with a second connection can make the base stop existing. Hence
# the verification below - `new-session -t` does not fail on a name
# matching nothing, it silently creates an UNGROUPED session, which for a
# remote client is a plain host shell with no sandbox in it.
#
# display-message prints nothing and still exits 0 for a target that no
# longer exists, so an empty result means the pane went away between the
# check above and here. That case is caught by the verification below too;
# bailing early just avoids creating a junk session to immediately kill,
# and says what actually happened.
# display-message prints nothing and exits 0 for a vanished target, so an
# empty result means the pane went away since the check above. The
# verification catches that too; bailing early just avoids creating a
# junk session and says what happened.
target="$(tmux display-message -p -t "$entry_pane" '#{session_name}:#{window_index}' 2>/dev/null || true)"
# Session names may contain ':', window indexes can't.
base="${target%:*}"
+301 -122
View File
@@ -10,6 +10,13 @@ set -uo pipefail
# shellcheck source=dejima-provider-lib.sh
source "$(dirname "$(readlink -f "$0")")/dejima-provider-lib.sh"
# shellcheck source=dejima-color-lib.sh
source "$(dirname "$(readlink -f "$0")")/dejima-color-lib.sh"
# shellcheck source=dejima-slug-lib.sh
source "$(dirname "$(readlink -f "$0")")/dejima-slug-lib.sh"
# Defined empty here so every interpolation below is safe before a surface
# picks its stream; compare (stderr) and ack (stdout) each re-init for theirs.
color_init 1
config_validate || exit 1
cmd="${1:-}"; ws_arg="${2:-}"; state="${3:-}"
@@ -48,7 +55,9 @@ AUDIT_DIR="$(config_get audit dir)"
# suggesting a restart is an entirely ordinary thing to suggest.
AUDIT_KEEP="$(config_get audit keep)"
ws_slug() { printf '%s' "$1" | sed 's/[^a-zA-Z0-9]/-/g'; }
# ws_slug and workspace_slugs come from dejima-slug-lib.sh, shared with
# bin/dejima so the set of slugs it mounts and the set treated as this
# workspace's own here cannot drift apart.
# The one report both `show` and `ack` operate on: the OLDEST unacknowledged
# CRITICAL report if there is one, else the newest report overall.
@@ -78,19 +87,16 @@ BASELINE_DIR="$(config_get audit baselines_dir)"
# should mean that repo. bin/dejima always passes an explicit path, so this
# only ever fires for a hand-run show/ack.
#
# Candidates in order, first one that already HAS reports wins - existence of
# a report directory is the only evidence available about which path
# bin/dejima was actually invoked with, and the three candidates can
# genuinely differ:
# 1. the enclosing repo's toplevel (the ordinary case, and from a subdir);
# 2. the MAIN worktree root, when cwd is a linked worktree - the sandbox's
# own .worktrees/<branch> workflow keys reports to the parent repo;
# 3. $PWD, which covers a non-git directory and the case where the path
# bin/dejima was given (logical, via `cd && pwd`) differs from git's
# physical toplevel because of a symlinked path component.
# Falling back to candidate 1 when none of them has reports is purely so the
# "no reports for X" message can name a concrete path: a wrong guess must be
# visible rather than silent.
# Candidates in order, first one that already HAS reports wins - a report
# directory is the only evidence of which path bin/dejima was invoked with,
# and the three can genuinely differ:
# 1. the enclosing repo's toplevel (ordinary case, and from a subdir);
# 2. the MAIN worktree root, when cwd is a linked worktree - the
# .worktrees/<branch> workflow keys reports to the parent repo;
# 3. $PWD, for a non-git directory, or when the logical path bin/dejima was
# given differs from git's physical toplevel through a symlink.
# Falling back to 1 when none has reports is so "no reports for X" can name a
# concrete path: a wrong guess must be visible rather than silent.
resolve_ws() {
local given="$1" top common main c
if [ -n "$given" ]; then
@@ -562,6 +568,28 @@ baseline_suppresses() {
return 1
}
# Whether `ack` has already walked this report to its end. Recorded in the
# baseline file, alongside the approvals, as `reviewed <report-basename>`:
# the file is already the per-workspace record of what the operator has
# decided, already parsed line-by-line, and already tolerant of verbs it does
# not know (baseline_suppresses skips them), so a report written by an older
# version reads exactly as it did.
#
# Only findings that CANNOT be acknowledged are governed by this. An ackable
# one is governed by its own entry, so `skip` still means "ask me again" for
# it, which is the whole difference between skip and ignore.
baseline_reviewed() {
local want="$1" bf verb rest
bf="$BASELINE_DIR/$(ws_slug "$ws")"
[ -f "$bf" ] || return 1
while read -r verb rest; do
case "$verb" in
reviewed) [ "$rest" = "$want" ] && return 0 ;;
esac
done < "$bf"
return 1
}
# git config keys whose values run a command on the next git invocation, or
# (include.path/includeif.*.path) indirect to another config file that could
# set one - key names are case-insensitive in git, so match on a lowercased
@@ -627,37 +655,26 @@ sanitize() {
| LC_ALL=C sed $'s/\xc2[\x80-\x9f]//g; s/\xe2\x80[\xaa-\xae]//g; s/\xe2\x81[\xa6-\xa9]//g'
}
# sanitize() is lossy, so it must never be what an ID passes through.
# sanitize() is lossy, so an ID must never pass through it. `ack` derives the
# baseline id from the report text and the next `compare` matches it against
# the RAW bytes on disk, so a stripped id cannot round-trip: the surface
# becomes permanently unsuppressible and the startup warning fires forever.
#
# `ack` derives the baseline id it records from the report's text, and the
# next `compare` matches that id against the RAW bytes on disk. An id
# containing anything sanitize() strips therefore could not round-trip at
# all: the surface stayed permanently unsuppressible and its report
# permanently unclearable, so bin/dejima's startup warning fired forever -
# the alert fatigue this whole feature exists to end, reached from the other
# side. Ids are encoded instead, and the encoded form is canonical
# everywhere it is written down: in the report AND in the baseline file.
# Ids are encoded instead, canonically, in the report AND the baseline file.
# An id sanitize() leaves alone is written verbatim (every ordinary one, so
# existing baselines are unchanged); anything else is hex-encoded behind a
# marker, which is ASCII and reverses exactly.
#
# An id that survives sanitize() unchanged is already display-safe and is
# written verbatim - that is every ordinary id, so reports and existing
# baseline files are byte-for-byte what they were. Anything else is
# hex-encoded behind a marker, which is ASCII by construction and reverses
# exactly.
# An id that merely LOOKS encoded is encoded too, so the marker's presence
# always means this encoder put it there. Otherwise a workspace could present
# an id `ack` decodes into one of the attacker's choosing - baselining
# something other than the finding while counting it acknowledged. Only the
# START matters, and no monitored path begins with the marker, but a git
# config section name is free text the session can write.
#
# An id that merely LOOKS encoded is encoded too, so that decoding stays
# unambiguous: the marker's presence always means this encoder put it there.
# Without that, a workspace could present an id that `ack` decoded into one
# of the attacker's choosing - baselining something other than the finding
# while still counting it acknowledged, the same shape as the id-derivation
# hijacks the anchored case arms in `ack` guard against. Only the START of
# an id is significant, and no monitored path can begin with the marker
# (they are all rooted at .claude/.vscode/.git/.envrc/.mcp.json), but a git
# config section name is free text in a file the session can write and the
# audit parses that file directly rather than through git.
#
# The encoded form is also the honest rendering. sanitize() DELETES those
# bytes, so a planted ".git/hooks/pre<U+202E>commit" displayed as
# "precommit" sends an operator grepping for a name no file on disk has.
# Encoding is also the honest rendering: sanitize() DELETES those bytes, so a
# planted ".git/hooks/pre<U+202E>commit" shown as "precommit" sends an
# operator grepping for a name no file has.
HEX_MARK='{hex}'
enc_id() {
local v="$1"
@@ -915,16 +932,27 @@ gitconfig_count_for_key() {
# it cannot be mistaken for the audit's own output, and capped. Sanitizing
# line by line rather than whole-file keeps a planted NUL or newline run from
# collapsing separate lines into one.
# $2 = "diff" to colour +/- lines. Colour is applied to the audit's own
# rendering of a line, never mixed into the line's text: the content here is
# workspace-controlled, and sanitize() has already removed the bytes that
# could forge one of these sequences.
show_capped() {
local f="$1" n=0 total line
local f="$1" mode="${2:-plain}" n=0 total line clean
total="$(wc -l < "$f" 2>/dev/null)" || total=0
while IFS= read -r line; do
n=$((n + 1))
[ "$n" -le "$DISPLAY_LINE_CAP" ] || break
printf ' %s\n' "$(sanitize "$line")"
clean="$(sanitize "$line")"
if [ "$mode" = diff ]; then
case "$clean" in
+*) printf ' %s%s%s\n' "$C_ADD" "$clean" "$C_RESET"; continue ;;
-*) printf ' %s%s%s\n' "$C_DEL" "$clean" "$C_RESET"; continue ;;
esac
fi
printf ' %s\n' "$clean"
done < "$f"
if [ "$total" -gt "$DISPLAY_LINE_CAP" ]; then
printf ' ... +%s more line(s)\n' "$(( total - DISPLAY_LINE_CAP ))"
printf ' %s... +%s more line(s)%s\n' "$C_DIM" "$(( total - DISPLAY_LINE_CAP ))" "$C_RESET"
fi
}
@@ -949,22 +977,27 @@ render_finding() {
local id="$1" sc="$2" key tmp
key="$(id_key "$id")"
# Dim on the audit's own framing, plain on the content between it: the
# complaint this answers is that the two ran together in one colour with
# nothing to say where the tool stopped speaking and the workspace
# started (#65).
if [ -n "$sc" ] && [ -f "$sc/$key.diff" ]; then
echo " --- what changed while the session ran ---"
show_capped "$sc/$key.diff"
printf ' %s--- what changed while the session ran ---%s\n' "$C_DIM" "$C_RESET"
show_capped "$sc/$key.diff" diff
source_hint "$id"
return 0
fi
if [ -n "$sc" ] && [ -f "$sc/$key.removed" ]; then
echo " --- this surface was removed; its pre-session content was not retained ---"
printf ' %s--- this surface was removed; its pre-session content was not retained ---%s\n' "$C_DIM" "$C_RESET"
return 0
fi
if [ -n "$sc" ] && [ -f "$sc/$key.withheld" ]; then
echo " --- no diff retained: $(sanitize "$(head -n1 "$sc/$key.withheld" 2>/dev/null)") ---"
printf ' %s--- no diff retained: %s ---%s\n' "$C_DIM" \
"$(sanitize "$(head -n1 "$sc/$key.withheld" 2>/dev/null)")" "$C_RESET"
elif [ -n "$sc" ] && [ -f "$sc/$key.new" ]; then
echo " --- new surface; full current content ---"
printf ' %s--- new surface; full current content ---%s\n' "$C_DIM" "$C_RESET"
else
echo " --- no pre-session record; current content ---"
printf ' %s--- no pre-session record; current content ---%s\n' "$C_DIM" "$C_RESET"
fi
tmp="$(mktemp)" || return 0
@@ -1091,14 +1124,29 @@ case "$cmd" in
baseline_suppresses "$gid" "$gsha" && continue
crit+=("COMMAND-EXECUTION git alias: $body")
finding_ids+=("gitconfig:$key") ;;
*) warn+=("changed git config: $body") ;;
*) gid="gitconfig:$key"; gsha="$(hash_value "$(unquote_value "${line#*=}")")"
baseline_suppresses "$gid" "$gsha" && continue
warn+=("changed git config: $body")
finding_ids+=("gitconfig:$key") ;;
esac
else
# No id recorded, for the same reason CROSS-PROJECT gets none:
# `ack` has no arm for a plain "changed git config" line, so a
# diff retained for one would be content at rest that nothing
# ever reads. The report body already carries the new value.
# Suppressible and id-bearing like the CRITICAL arms above.
# Both were previously neither, which left this the one tier
# of finding with no way to curate it: keys some tools
# rewrite on every run (branch.<name>.merge on each push,
# submodule.*) re-fired every session and pushed real
# WARNINGs out of the capped list (#45).
#
# Safe by construction rather than by care taken here:
# id_severity already calls an exec key or any alias CRITICAL
# whatever wrote the entry, and baseline_suppresses refuses a
# `spam` entry against a CRITICAL surface at READ time - so a
# key that later joins is_exec_key stops being suppressed by
# an existing entry, with nothing to revisit.
gid="gitconfig:$key"; gsha="$(hash_value "$(unquote_value "${line#*=}")")"
baseline_suppresses "$gid" "$gsha" && continue
warn+=("changed git config: $body")
finding_ids+=("gitconfig:$key")
fi
done < <(comm -13 <(sed -n '/^### gitconfig/,/^### paths/p' "$state" | grep -vE '^### |^worktree:$') \
<(sed -n '/^### gitconfig/,/^### paths/p' "$new" | grep -vE '^### |^worktree:$') )
@@ -1134,13 +1182,17 @@ case "$cmd" in
# projects diffs: a write to another project's ~/.claude/projects
# slug during this session (transcript/memory-poisoning surface).
# "self" = current workspace's own slug or a worktree child of it.
current_slug="$(ws_slug "$ws")"
# "self" = the workspace's own slug or a live worktree nested under
# it, exactly the set bin/dejima mounts. This used to be a
# "$current_slug"-* glob, which also matched any sibling project whose
# directory name extended this one's - so on a host with `proj` and
# `proj-ci` side by side, writes to `proj-ci` were silently exempt.
self_slugs="$(workspace_slugs_sorted "$ws")"
while IFS= read -r line; do
slug="${line%%$'\t'*}"
case "$slug" in
"$current_slug"|"$current_slug"-*) continue ;;
esac
if printf '%s\n' "$self_slugs" | grep -qxF -- "$slug"; then
continue
fi
warn+=("CROSS-PROJECT history/memory write: $(sanitize "$slug")")
done < <(comm -13 <(sed -n '/^### projects/,/^### tcb/p' "$state" | grep -v '^### ') \
<(sed -n '/^### projects/,/^### tcb/p' "$new" | grep -v '^### ') )
@@ -1193,6 +1245,15 @@ case "$cmd" in
echo "Changes below happened while this session ran - not necessarily because"
echo "of it. A concurrent dejima/Claude Code session, or your own edits during"
echo "the run, land in this same diff; see docs/security.md."
# Named, never hashed. Writing to a writable mount is the entire
# point of configuring one, so a finding per write would fire on
# every legitimate use and train the reader to skim - the exact
# failure the audit's severity tiers exist to avoid. What the
# operator needs instead is the blast radius: reading any finding
# below, they should know what else this session could reach.
if [ -n "${DEJIMA_RW_MOUNTS:-}" ]; then
echo "also writable by this session, not hashed: $(sanitize "$DEJIMA_RW_MOUNTS")"
fi
[ "${#crit[@]}" -gt 0 ] && printf 'CRITICAL %s\n' "${crit[@]}"
[ "${#warn[@]}" -gt 0 ] && printf 'WARNING %s\n' "${warn[@]}"
[ "${#info[@]}" -gt 0 ] && printf 'INFO %s\n' "${info[@]}"
@@ -1250,23 +1311,35 @@ case "$cmd" in
# Ascending severity: the operator reads bottom-up from their shell
# prompt, so whatever prints LAST is what actually gets seen. INFO
# collapses to a count - it exists to be counted, not read.
# stderr is this block's stream, so the palette is decided for that
# one: redirecting the summary to a file must not put escapes in it.
color_init 2
{
echo ""
echo "================================================================"
printf '%s================================================================%s\n' "$C_DIM" "$C_RESET"
echo " dejima: host-persistence surfaces changed in"
echo " $(sanitize "$ws") while this session ran"
echo "----------------------------------------------------------------"
printf ' %s%s%s while this session ran\n' "$C_PATH" "$(sanitize "$ws")" "$C_RESET"
printf '%s----------------------------------------------------------------%s\n' "$C_DIM" "$C_RESET"
# Named, never hashed. Writing to a writable mount is the entire
# point of configuring one, so a finding per write would fire on
# every legitimate use and train the reader to skim - the exact
# failure the audit's severity tiers exist to avoid. What the
# operator needs instead is the blast radius: reading any finding
# below, they should know what else this session could reach.
if [ -n "${DEJIMA_RW_MOUNTS:-}" ]; then
echo " also writable by this session, not hashed: $(sanitize "$DEJIMA_RW_MOUNTS")"
fi
if [ "${#info[@]}" -gt 0 ]; then
echo " ${#info[@]} routine file change(s) - not listed"
fi
if [ "${#warn[@]}" -gt 0 ]; then
printf ' - %s\n' "${warn[@]:0:$WARN_CAP}"
printf " $C_WARN-$C_RESET %s\n" "${warn[@]:0:$WARN_CAP}"
if [ "${#warn[@]}" -gt "$WARN_CAP" ]; then
echo " + $(( ${#warn[@]} - WARN_CAP )) further change(s) not shown."
fi
fi
if [ "${#crit[@]}" -gt 0 ]; then
printf ' ! %s\n' "${crit[@]:0:$CRIT_CAP}"
printf " $C_CRIT!$C_RESET %s\n" "${crit[@]:0:$CRIT_CAP}"
if [ "${#crit[@]}" -gt "$CRIT_CAP" ]; then
echo " !! $(( ${#crit[@]} - CRIT_CAP )) FURTHER CRITICAL FINDINGS NOT SHOWN."
echo " !! ${#crit[@]} critical findings in one session is ABNORMAL."
@@ -1276,12 +1349,14 @@ case "$cmd" in
# room to keep evidence is a finding about the session, not a
# note about the store.
[ "${#budget_note[@]}" -gt 0 ] && printf '%s\n' "${budget_note[@]}"
echo "----------------------------------------------------------------"
printf '%s----------------------------------------------------------------%s\n' "$C_DIM" "$C_RESET"
if [ "${#crit[@]}" -gt 0 ]; then
echo " Review BEFORE the next 'git' or 'claude' in that repo."
echo " dejima: ${#crit[@]} CRITICAL - code that will run on your host."
printf ' dejima: %s%s CRITICAL%s - code that will run on your host.\n' \
"$C_CRIT" "${#crit[@]}" "$C_RESET"
else
echo " dejima: no critical findings; ${#warn[@]} change(s) to review."
printf ' dejima: %sno critical findings%s; %s change(s) to review.\n' \
"$C_OK" "$C_RESET" "${#warn[@]}"
fi
} >&2
exit 0
@@ -1299,6 +1374,7 @@ case "$cmd" in
exit 0
;;
ack)
color_init 1
report_dir="$AUDIT_DIR/$(ws_slug "$ws")"
bf="$BASELINE_DIR/$(ws_slug "$ws")"
report="$(select_report "$report_dir")" \
@@ -1321,6 +1397,16 @@ case "$cmd" in
# (or a non-path CRITICAL, which can never be "approved" - see
# ackable below) must keep its suffix and stay eviction-protected.
crit_seen=0; crit_acked=0
# Findings passed over because the baseline already covers them, or
# because this report has been walked before - counted so a run that
# displays nothing can say why instead of looking broken.
already=0
# Whether this report carries any finding that cannot be
# acknowledged, and whether a previous run already walked it to the
# end. Together they decide if a `reviewed` line gets written below.
unackable_shown=0
report_reviewed=0
if baseline_reviewed "$(basename "$report")"; then report_reviewed=1; fi
# The loop below reads report lines on its own stdin (`< "$report"`),
# so operator keystrokes are read from fd 3 - a copy of the script's
# original stdin taken before that redirection - rather than
@@ -1348,55 +1434,65 @@ case "$cmd" in
*) continue ;;
esac
[ "$tier" = CRITICAL ] && crit_seen=$((crit_seen + 1))
# Derive the namespaced id baseline_suppresses() will later
# compare against (see id_severity/current_sha_for_id above) -
# one case arm per finding-producing loop in `compare`. Every
# pattern is anchored to the fixed label text `compare` itself
# emits at the START of body, never matched as a substring
# anywhere in it: a workspace-controlled value (a git config
# value, a path) can contain text like "workspace file: " or
# "TCB (sandbox self) modified: " and reach $body unmodified, so
# an unanchored match could be hijacked into deriving the wrong
# (attacker-chosen) id for the wrong namespace - approving it
# would then baseline an id that can never match, un-baselining
# the real finding while still counting it as acknowledged. A
# finding whose body matches none of these (currently only
# CROSS-PROJECT) has no id to record, so it's shown but not
# offered a/s.
# Derive the namespaced id baseline_suppresses() compares
# against - one arm per finding-producing loop in `compare`.
#
# The id itself must then be recovered with a shortest-prefix
# strip of that same fixed label (`${body#LABEL}`), never
# `${body##*: }`: the latter strips up to the LAST ": " in the
# whole string, and a workspace-controlled path can itself
# contain a literal ": " (e.g. ".git/hooks/pre-commit: decoy"),
# which would truncate the derived id to everything after that
# planted separator - the case match being anchored doesn't
# protect the extraction that follows it.
# Both steps must resist workspace-controlled text in $body. The
# patterns anchor to the fixed label at the START of body: a git
# config value or path can contain "workspace file: ", so an
# unanchored match derives an attacker-chosen id in the wrong
# namespace, and approving it baselines an id that never matches
# while counting the real finding as acknowledged. The id is then
# recovered with `${body#LABEL}`, never `${body##*: }` - the
# latter strips to the LAST ": ", and a path can contain one.
# A body matching no arm (currently only CROSS-PROJECT) gets no
# id: shown, but not offered a/s.
#
# Two forms come out of this, because the two consumers need
# different ones (see enc_id): $id is the ENCODED id exactly as
# the report carries it, and is what gets written to the
# baseline file, which stores that form. $rid is the same id
# decoded back to the raw bytes on disk, for current_sha_for_id
# to stat and hash and for id_severity to classify. Only the
# id-bearing portion is decoded - never the namespace prefix
# this code adds itself.
ackable=0; id=""; rid=""
# $id is the ENCODED form the report and baseline file carry;
# $rid is it decoded, for current_sha_for_id to stat and hash and
# id_severity to classify. Only the id-bearing portion decodes,
# never the namespace prefix added here. $lbl and $surface are
# display-only, split so the surface can be coloured without the
# audit's own words around it, and recomputed rather than derived
# from $id because a gitconfig body carries `key=value` while its
# id is the key alone.
ackable=0; id=""; rid=""; lbl=""; surface=""
case "$body" in
"HOST-CODE-EXECUTION workspace file: "*)
ackable=1; id="${body#HOST-CODE-EXECUTION workspace file: }"
rid="$(dec_id "$id")" ;;
ackable=1; lbl="HOST-CODE-EXECUTION workspace file: "
id="${body#HOST-CODE-EXECUTION workspace file: }"
surface="$id"; rid="$(dec_id "$id")" ;;
"changed workspace file: "*)
ackable=1; id="${body#changed workspace file: }"
rid="$(dec_id "$id")" ;;
"COMMAND-EXECUTION git config: "*|"COMMAND-EXECUTION git alias: "*)
ackable=1; cfgline="${body#*: }"; ekey="${cfgline%%=*}"
ackable=1; lbl="changed workspace file: "
id="${body#changed workspace file: }"
surface="$id"; rid="$(dec_id "$id")" ;;
"COMMAND-EXECUTION git config: "*)
ackable=1; lbl="COMMAND-EXECUTION git config: "
cfgline="${body#COMMAND-EXECUTION git config: }"
ekey="${cfgline%%=*}"; surface="$ekey"
id="gitconfig:$ekey"; rid="gitconfig:$(dec_id "$ekey")" ;;
"COMMAND-EXECUTION git alias: "*)
ackable=1; lbl="COMMAND-EXECUTION git alias: "
cfgline="${body#COMMAND-EXECUTION git alias: }"
ekey="${cfgline%%=*}"; surface="$ekey"
id="gitconfig:$ekey"; rid="gitconfig:$(dec_id "$ekey")" ;;
# The WARNING form of the two above. Its own arm rather than
# another alternative on theirs, because the label it strips
# differs and the strip must be of that exact label (#45).
"changed git config: "*)
ackable=1; lbl="changed git config: "
cfgline="${body#changed git config: }"
ekey="${cfgline%%=*}"; surface="$ekey"
id="gitconfig:$ekey"; rid="gitconfig:$(dec_id "$ekey")" ;;
"HOST-CODE-EXECUTION statusline.sh changed (runs UNSANDBOXED on the host via settings.json statusLine.command): "*)
ackable=1; esl="${body#HOST-CODE-EXECUTION statusline.sh changed (runs UNSANDBOXED on the host via settings.json statusLine.command): }"
ackable=1; lbl="HOST-CODE-EXECUTION statusline.sh changed (runs UNSANDBOXED on the host via settings.json statusLine.command): "
esl="${body#HOST-CODE-EXECUTION statusline.sh changed (runs UNSANDBOXED on the host via settings.json statusLine.command): }"
surface="$esl"
id="statusline:$esl"; rid="statusline:$(dec_id "$esl")" ;;
"TCB (sandbox self) modified: "*)
ackable=1; etcb="${body#TCB (sandbox self) modified: }"
ackable=1; lbl="TCB (sandbox self) modified: "
etcb="${body#TCB (sandbox self) modified: }"
surface="$etcb"
id="tcb:$etcb"; rid="tcb:$(dec_id "$etcb")" ;;
*) ackable=0 ;;
esac
@@ -1406,29 +1502,84 @@ case "$cmd" in
# must only ever advance when a real, non-empty id was derived.
[ -n "$id" ] || ackable=0
[ -n "$rid" ] || ackable=0
# Already decided: the baseline holds an approval pinning this
# exact content, or an ignore for the surface outright. `compare`
# filters these out when it WRITES a report, but a report written
# before that entry existed still lists them - and two reports
# can list the same finding, so approving it in the first left
# the second re-presenting it on every later run, with the
# approval sitting in the baseline the whole time. That is what
# made repeated `ack` runs show the same CRITICALs forever (#65).
#
# Counted as covered rather than merely hidden: a CRITICAL whose
# content is already approved must let its report shed
# -CRITICAL.txt, or the startup warning nags about a report whose
# every finding has been dealt with.
if [ "$ackable" -eq 1 ] && baseline_suppresses "$rid" "$(current_sha_for_id "$rid")"; then
already=$((already + 1))
if [ "$tier" = CRITICAL ]; then crit_acked=$((crit_acked + 1)); fi
continue
fi
# An un-ackable finding in a report already walked to the end.
# There is nothing to record for one, so without this it would
# re-prompt on every run forever, gating the exit from `ack` on a
# keystroke that can never change anything (#65).
if [ "$ackable" -ne 1 ] && [ "$report_reviewed" -eq 1 ]; then
already=$((already + 1))
continue
fi
[ "$ackable" -eq 1 ] || unackable_shown=1
echo ""
echo " [$tier] $body"
[ "$ackable" -eq 1 ] && render_finding "$rid" "$sc"
if [ "$ackable" -ne 1 ]; then
printf ' [c] skip [q] quit (this finding cannot be acknowledged): '
elif [ "$tier" = CRITICAL ]; then
printf ' [a] approve this exact content [c] skip [q] quit: '
# The tier carries the colour, and the surface the finding is
# about is picked out from the label around it - those are the
# two things an operator scans a wall of these for.
if [ -n "$surface" ]; then
tail_rest="${body#"$lbl$surface"}"
printf ' %s[%s]%s %s%s%s%s%s\n' "$(tier_color "$tier")" "$tier" "$C_RESET" \
"$lbl" "$C_PATH" "$surface" "$C_RESET" "$tail_rest"
else
printf ' [a] approve [s] always ignore [c] skip [q] quit: '
printf ' %s[%s]%s %s\n' "$(tier_color "$tier")" "$tier" "$C_RESET" "$body"
fi
[ "$ackable" -eq 1 ] && render_finding "$rid" "$sc"
# Each option says what it does to FUTURE findings, because that
# is the whole difference between the two that record something
# and it was not visible: approve pins this exact content and
# re-fires on any other, ignore blankets the surface whatever it
# later contains. The WARNING prompt used to say a bare
# "approve", which read as the same thing as "always ignore".
#
# "file" is wrong for a git config key, and naming the wrong kind
# of thing in the sentence that offers a permanent mute is worth
# one case.
case "$rid" in
gitconfig:*) ign_noun="key" ;;
*) ign_noun="file" ;;
esac
k_a="$C_KEY[a]$C_RESET"; k_i="$C_KEY[i]$C_RESET"
k_s="$C_KEY[s]$C_RESET"; k_q="$C_KEY[q]$C_RESET"
if [ "$ackable" -ne 1 ]; then
printf ' %s skip %s quit (this finding cannot be acknowledged): ' "$k_s" "$k_q"
elif [ "$tier" = CRITICAL ]; then
printf ' %s approve this exact content %s skip (ask again next time) %s quit: ' \
"$k_a" "$k_s" "$k_q"
else
printf ' %s approve this exact content %s ignore this %s from now on %s skip (ask again next time) %s quit: ' \
"$k_a" "$k_i" "$ign_noun" "$k_s" "$k_q"
fi
{ read -r key <&3; } 2>/dev/null || { aborted=1; break; }
case "$key" in
q|$'\e'|'') aborted=1; break ;;
s)
i)
if [ "$ackable" -ne 1 ]; then
echo " cannot acknowledge: this finding cannot be acknowledged."
elif [ "$tier" = CRITICAL ]; then
echo " cannot 'always ignore' a CRITICAL surface - approve the exact content or skip."
echo " cannot ignore a CRITICAL surface from now on - approve the exact content or skip."
else
pending+=("spam $id")
sc_done+=("$rid")
fi
;;
s) ;;
a)
if [ "$ackable" -ne 1 ]; then
echo " cannot acknowledge: this finding cannot be acknowledged."
@@ -1458,12 +1609,22 @@ case "$cmd" in
esac
done < "$report"
if [ "$aborted" -eq 1 ] || [ "${#pending[@]}" -eq 0 ]; then
# An abort discards everything, the reviewed marker included: the
# operator did not finish the report, so it has not been reviewed.
if [ "$aborted" -eq 1 ]; then
echo ""; echo "dejima audit: nothing recorded."
exit 0
fi
mkdir -p "$BASELINE_DIR"
printf '%s\n' "${pending[@]}" >> "$bf"
# Written once the report has been walked to its end, and only when
# it actually showed something that cannot be acknowledged - there is
# nothing else to stop those from re-prompting forever.
if [ "$unackable_shown" -eq 1 ] && [ "$report_reviewed" -eq 0 ]; then
pending+=("reviewed $(basename "$report")")
fi
if [ "${#pending[@]}" -gt 0 ]; then
mkdir -p "$BASELINE_DIR"
printf '%s\n' "${pending[@]}" >> "$bf"
fi
for rid in ${sc_done[@]+"${sc_done[@]}"}; do
rm -f "$sc/$(id_key "$rid")".diff "$sc/$(id_key "$rid")".new \
"$sc/$(id_key "$rid")".removed "$sc/$(id_key "$rid")".withheld
@@ -1472,12 +1633,30 @@ case "$cmd" in
# Only rename off -CRITICAL.txt (making the report evictable by
# rotation) when every CRITICAL finding this report carried was
# actually approved - not just when something was approved.
# Reached even when this run recorded nothing new: a report whose
# CRITICALs were all approved through some EARLIER report listing the
# same finding is fully dealt with, and has to stop being counted as
# unacknowledged or the startup warning nags about it forever.
if [ "$crit_seen" -gt 0 ] && [ "$crit_acked" -eq "$crit_seen" ]; then
case "$report" in
*-CRITICAL.txt) mv "$report" "${report%-CRITICAL.txt}.txt" ;;
esac
fi
echo ""; echo "dejima audit: recorded ${#pending[@]} acknowledgement(s) in $bf"
echo ""
# Distinguish the three ways a run can end with nothing new, because
# "nothing recorded" for all of them is what made an already-handled
# report look like a broken tool.
acks=0
for p in ${pending[@]+"${pending[@]}"}; do
case "$p" in reviewed\ *) ;; *) acks=$((acks + 1)) ;; esac
done
if [ "$acks" -gt 0 ]; then
echo "dejima audit: recorded $acks acknowledgement(s) in $bf"
elif [ "$already" -gt 0 ]; then
echo "dejima audit: nothing new - $already finding(s) already dealt with."
else
echo "dejima audit: nothing recorded."
fi
exit 0
;;
*)
+47
View File
@@ -0,0 +1,47 @@
#!/bin/bash
# Terminal colour, shared by bin/dejima and bin/dejima-audit.
#
# Shared rather than duplicated because the two are one surface to the
# operator: the launcher's "unacknowledged CRITICAL" warning and the audit
# review it sends them to must agree about what red means, or the colour is
# decoration instead of information.
#
# Nothing here decides WHAT to highlight - that belongs with the text. It
# decides only whether colour is safe to emit at all, and provides the names.
#
# Off unless the stream being written to is a terminal, so a redirected
# report or a piped test never receives escape sequences: the audit's output
# is read back by tests and, in the reports it writes, by future runs.
# NO_COLOR (any value, https://no-color.org) and TERM=dumb both turn it off
# as well.
#
# The variables are always DEFINED, empty when colour is off, so callers
# interpolate them unconditionally and no call site needs an if.
color_init() {
local fd="${1:-1}"
C_RESET=""; C_CRIT=""; C_WARN=""; C_INFO=""; C_PATH=""
C_KEY=""; C_DIM=""; C_ADD=""; C_DEL=""; C_OK=""
[ -t "$fd" ] || return 0
[ -z "${NO_COLOR+x}" ] || return 0
case "${TERM:-}" in ''|dumb) return 0 ;; esac
C_RESET=$'\033[0m'
C_CRIT=$'\033[1;31m' # bold red - runs unsandboxed host code
C_WARN=$'\033[33m' # yellow
C_INFO=$'\033[36m' # cyan
C_PATH=$'\033[1;36m' # bold cyan - the file or key a finding is about
C_KEY=$'\033[1m' # bold - the keystroke an operator has to press
C_DIM=$'\033[2m' # dim - the audit's own framing around content
C_ADD=$'\033[32m' # green - a diff's added lines
C_DEL=$'\033[31m' # red - a diff's removed lines
C_OK=$'\033[32m' # green
}
# The colour for a tier name, so the mapping lives in one place rather than
# at each site that prints one.
tier_color() {
case "$1" in
CRITICAL) printf '%s' "$C_CRIT" ;;
WARNING) printf '%s' "$C_WARN" ;;
*) printf '%s' "$C_INFO" ;;
esac
}
+13 -19
View File
@@ -22,32 +22,26 @@
# Schema
#
# Three TAB-separated columns: key, type, default. One source of truth - both
# _config_spec and _config_keys read this, so a key cannot exist for lookup
# but be missing from enumeration (which is what --print-config walks).
# _config_spec and _config_keys read it, so a key cannot be lookup-able but
# missing from --print-config's enumeration.
#
# A "?" prefix on the type means the empty string is a legal value. Without
# it, empty is rejected: the distinction is between a setting that is
# meaningfully absent (ssh_key, loki_url) and one that must always have a
# A "?" type prefix means empty is legal, distinguishing a setting that is
# meaningfully absent (ssh_key, loki_url) from one that must always have a
# value (firewall, share_dir).
#
# The three axis keys are `provider:<axis>` - validated by looking the name
# up under the provider root (see _config_extra_type_valid below and
# dejima-provider-lib.sh) rather than a fixed enum, so adding a provider is
# adding a directory, not editing this schema.
#
# [forge.<name>] and [harness.<name>] keys are absent from this table for the
# same reason: enumerating a forge's own keys here would mean a new forge
# needs a core edit, which is exactly what the provider axes are for.
# Those two sections are resolved instead through the _config_extra_*
# functions below, stubbed here and overridden by dejima-provider-lib.sh once
# it is sourced, against the type+default each provider's own `keys` manifest
# entry declares (see _provider_keyspec_parse there).
# The axis keys are `provider:<axis>`, validated against the provider root
# rather than a fixed enum, so adding a provider is adding a directory.
# [forge.<name>] and [harness.<name>] keys are absent here for the same
# reason - enumerating them would make a new forge need a core edit. They
# resolve through the _config_extra_* functions below, stubbed here and
# overridden by dejima-provider-lib.sh, against each provider's own `keys`
# manifest entry.
# ---------------------------------------------------------------------------
_config_table() {
cat <<'EOF'
core.harness provider:harness claude-code
core.forge provider:forge gitea
core.workflow provider:workflow worktree-pr
core.forge provider:forge none
core.workflow provider:workflow local-only
core.command identifier claude
core.firewall bool on
core.share_dir path ~/.local/share/dejima/shared
+68 -23
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 ;;
+14 -20
View File
@@ -7,30 +7,24 @@
# (no argument) watch until killed - what the systemd unit runs
# --once one scan, then exit
#
# This script is the trust boundary. Everything under the marker root is
# written by a sandboxed session that may be compromised, and the device it
# ultimately reaches - the operator's phone - is outside the sandbox. The
# in-container hook's own enum constrains nothing here: a session owns the
# contents of its bind-mounted marker directory and can create any name it
# likes in it, hook or no hook. The guarantee rests entirely on this file:
# This script is the trust boundary: a possibly-compromised session writes
# the markers, and the phone they reach is outside the sandbox. The
# in-container hook's enum constrains nothing here - a session owns its
# marker directory and can create any name it likes. The guarantee is here:
#
# - the marker NAME is the only thing consulted, and only by iterating a
# literal enum, so a name outside it is never stat'd, read, or removed
# - marker CONTENT is never read, so there is nothing to inject
# - names are never interpolated into a command
# - `rm -f` unlinks a symlink instead of following it, so a marker aimed at
# a host file removes the link and leaves the file alone
# - only the marker NAME is consulted, by iterating a literal enum, so a
# name outside it is never stat'd, read, or removed
# - marker CONTENT is never read, and names never reach a command
# - `rm -f` unlinks a symlink rather than following it
# - the click URL and every ntfy header are composed from host-side data
#
# That last point is what the enum is really for. ntfy's own headers include
# X-Actions (which can make the phone issue an arbitrary HTTP request or fire
# an Android intent), X-Attach and X-Icon (which fetch URLs), X-Email and
# X-Call. A single attacker-controlled header here would turn the phone into
# a confused deputy inside the operator's network.
# The last point is what the enum is for: ntfy's X-Actions can make the phone
# issue an HTTP request or fire an Android intent, X-Attach and X-Icon fetch
# URLs. One attacker-controlled header makes the phone a confused deputy
# inside the operator's network.
#
# One scan covers every run directory, so nothing a single session can do to
# its own markers may abort it: that would let one compromised sandbox silence
# every other session's notifications.
# One scan covers every run directory, so nothing a session does to its own
# markers can abort it and silence every other session's notifications.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
+124 -21
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
;;
*)
+295 -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,67 @@ read_declared() {
return 0
}
# Shared argument parsing for dejima-allow and dejima-dep, so the two CLIs
# cannot drift on what --project means.
#
# --project's path is OPTIONAL, so `--project X` is ambiguous: X could be the
# path or the required positional. Deciding by "is X the last token" breaks
# `dejima-dep list --project ~/p`, which has no trailing positional and would
# silently scope to the current directory instead. So:
#
# --project takes the next token as its path only if that still leaves the
# command its required trailing positional(s).
#
# Required counts are per-command constants the caller declares.
#
# Sets dj_global, dj_project, dj_project_set, dj_container and dj_positional.
# dj_project_set exists because "--project with no path" (the current
# directory) and "no --project at all" are different scopes that an empty
# dj_project cannot distinguish.
parse_scope_args() {
local required="$1"; shift
local -a args=("$@")
local n="${#args[@]}" i=0 k tok cand after
dj_global=0; dj_project=""; dj_project_set=0; dj_container=""
dj_positional=()
while [ "$i" -lt "$n" ]; do
tok="${args[$i]}"
case "$tok" in
--global) dj_global=1; i=$((i + 1)) ;;
--container)
cand="${args[$((i + 1))]:-}"
if [ -z "$cand" ]; then
echo "--container needs a container name or id." >&2
return 1
fi
dj_container="$cand"; i=$((i + 2)) ;;
--project)
dj_project_set=1; i=$((i + 1))
cand="${args[$i]:-}"
case "$cand" in ''|-*) continue ;; esac
# Count the positionals strictly after the candidate, skipping
# --container's value - counting that as a positional would
# make the candidate look safe to consume when it is not.
after=0; k=$((i + 1))
while [ "$k" -lt "$n" ]; do
case "${args[$k]}" in
--container) k=$((k + 2)) ;;
-*) k=$((k + 1)) ;;
*) after=$((after + 1)); k=$((k + 1)) ;;
esac
done
if [ "$after" -ge "$required" ]; then
dj_project="$cand"; i=$((i + 1))
fi ;;
-*) echo "Unknown option: $tok" >&2; return 1 ;;
*) dj_positional+=("$tok"); i=$((i + 1)) ;;
esac
done
return 0
}
# The global store is config_root() itself, not a subdirectory of it - the
# two filenames below can never collide with projects/<slug>, since a slug
# is a sanitized absolute path and always begins with "-".
@@ -83,6 +189,32 @@ project_slug() {
printf '%s' "$1" | sed 's/[^a-zA-Z0-9-]/-/g'
}
# $project is `cd <path> && pwd`'s LOGICAL resolution: a symlinked path
# component's name survives in the string. declare_entry's makedirs/open, and
# every other plain filesystem call, resolve the same path PHYSICALLY - so
# when a component is a symlink, "$project/.dejima" and the directory that
# actually gets created or opened are not the same place.
#
# A legitimate symlinked checkout (a worktree, a bind mount, a symlinked home
# directory) looks exactly like this too, and this function cannot tell the
# two apart - only the operator running the command can, so it discloses the
# mismatch rather than refusing on it. Refusing would break the ordinary case
# outright, with no way forward.
#
# Deliberately not used to change how $project itself is resolved: doing the
# approval lookup/write physically as well would move the *approval* to
# wherever the symlink targets, turning a declaration-only redirect into an
# actual cross-project grant - strictly worse than the mismatch this discloses.
disclose_project_symlink() {
local logical="$1" physical
physical="$(cd -P "$logical" && pwd -P)"
if [ "$physical" != "$logical" ]; then
echo "Note: $logical is reached through a symlink; it resolves to" >&2
echo "$physical, and that is where its .dejima declaration actually" >&2
echo "lives - not $logical." >&2
fi
}
# The store a writer must use: whatever a reader would resolve to, created
# for this exact path only when nothing above already covers it. Creating one
# per path unconditionally would be wrong in the workflow inheritance exists
@@ -116,6 +248,8 @@ ensure_project_store() {
if [ "$owner" != "$path" ]; then
echo "Note: recorded under the existing approvals for $owner, which" >&2
echo "$path inherits - so it applies to $owner and everything under it." >&2
echo "Any declaration this call writes still goes to $path/.dejima -" >&2
echo "the approval and the declaration do not have to share a directory." >&2
fi
printf '%s' "$existing"
return 0
@@ -184,6 +318,124 @@ approve_entry() {
grep -qxF "$entry" "$store/$kind" || printf '%s\n' "$entry" >> "$store/$kind"
}
# Record an entry in the project's OWN declaration - the committed, shared
# .dejima/<kind> file. This grants nothing (see this file's header: a
# declaration is parsed, never sourced, and lives on a session-writable
# mount), which is exactly why it is safe to write from the host. What it buys
# is visibility: without it, a domain approved with --project is invisible to
# dejima-project status, to hooks/sandbox-context.sh, and to everyone else on
# the project.
#
# Validated with the same dispatch approve_entry uses, so the two can never
# disagree about what a well-formed entry is.
declare_entry() {
local project="$1" kind="$2" entry="$3" validator
validator="$(_approved_validator "$kind")" || return 1
"$validator" "$entry" || { echo "Invalid $kind entry: $entry" >&2; return 1; }
# The one write here that lands on the session-writable bind mount, so
# the one that must not follow a symlink. A session can plant one and
# then ask the user - via the briefing, which hands over exactly this
# command - to run the CLI that trips it, aiming a host-privileged
# append at ~/.config/dejima/allowed-domains (project approval becomes
# global) or dependencies (installed as root at image build, the one
# path with no firewall in front of it).
#
# Checking first is not enough: the session is normally still live when
# the user runs this, and it chose the moment, so it can spin a rename
# loop across any check-to-write window. The guard is the open itself.
# O_NOFOLLOW fails outright on a symlink, the directory is opened
# O_DIRECTORY and the file relative to that fd, and the whole
# read-then-append goes through it - so winning the swap gets an open of
# the replaced object, not a redirected write.
#
# bash cannot open a path that way, hence python3 (already a hard
# dependency). O_NONBLOCK and the S_ISREG check cover the non-symlink
# shapes: a planted FIFO would otherwise hang the host CLI for as long as
# the session declined to open the other end.
python3 - "$project/.dejima" "$kind" "$entry" "$DEJIMA_MAX_DECLARED" <<'PY'
import errno, os, stat, sys
directory, name, entry = sys.argv[1], sys.argv[2], sys.argv[3]
MAX_DECLARED = int(sys.argv[4])
# A ceiling on the read-back below, not on what a project may declare. The
# largest legitimate file is MAX_DECLARED entries of a domain's 253 bytes plus
# comments - tens of KiB - so this is orders of magnitude clear of anything
# real, and exists only to stop a session-owned file from being unbounded.
LIMIT = 1 << 20
path = os.path.join(directory, name)
why = (
"A project's .dejima is writable by the sandboxed session, so a link or",
"special file planted here would aim this host-side append at the approval",
"store, or at any other file you can write.",
"Inspect and remove it, then re-run.",
)
def refuse(*lines):
for line in lines:
sys.stderr.write(line + "\n")
raise SystemExit(1)
try:
os.makedirs(directory, 0o755)
except FileExistsError:
pass
except OSError as exc:
refuse("Could not create %s: %s" % (directory, exc.strerror))
try:
dirfd = os.open(directory, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
except OSError as exc:
if exc.errno in (errno.ELOOP, errno.ENOTDIR):
refuse("Refusing to write through %s: it is a symlink, or not a directory at all." % directory, *why)
refuse("Could not open %s: %s" % (directory, exc.strerror))
try:
fd = os.open(
name,
os.O_RDWR | os.O_CREAT | os.O_APPEND | os.O_NOFOLLOW | os.O_NONBLOCK,
0o644,
dir_fd=dirfd,
)
except OSError as exc:
if exc.errno in (errno.ELOOP, errno.ENXIO):
refuse("Refusing to write through %s: it is a symlink or other special file." % path, *why)
refuse("Could not write %s: %s" % (path, exc.strerror))
finally:
os.close(dirfd)
# Before any buffered wrapper touches it: a FIFO is opened without blocking
# thanks to O_NONBLOCK, but it is not seekable, and refusing it is the point.
if not stat.S_ISREG(os.fstat(fd).st_mode):
os.close(fd)
refuse("Refusing to write to %s: it is not a regular file." % path, *why)
with os.fdopen(fd, "r+b") as fh:
# Bounded, because the duplicate check has to read back a file the session
# owns, and `truncate -s 8G` makes an arbitrarily large one at zero disk
# cost. Reading LIMIT + 1 rather than trusting fstat's size: the file can
# grow between the two, and this way the ceiling holds whatever it does.
body = fh.read(LIMIT + 1)
if len(body) > LIMIT:
refuse(
"Refusing to read %s back: it is larger than %d bytes." % (path, LIMIT),
"A project's .dejima is writable by the sandboxed session, and this",
"command has to read the file back to skip an entry already declared -",
"so an oversized one exhausts this machine's memory instead of",
"misdirecting the write. A real declaration is a few hundred short",
"lines; read_declared stops reading entries at %d." % MAX_DECLARED,
"Inspect and truncate it, then re-run.",
)
line = entry.encode()
if line not in body.split(b"\n"):
# O_APPEND, so this lands at the end whatever else has written since.
# The leading newline is for a declaration the session left unterminated
# - without it the append would extend that last line instead.
fh.write((b"" if not body or body.endswith(b"\n") else b"\n") + line + b"\n")
PY
}
unapprove_entry() {
local store="$1" kind="$2" entry="$3" tmp
[ -f "$store/$kind" ] || return 0
@@ -192,6 +444,40 @@ unapprove_entry() {
mv "$tmp" "$store/$kind"
}
# One RUN per tarball dependency, as root, before the drop to the sandbox
# user. Each unpacks into its own directory under /opt/dejima and puts that
# directory's bin/ on PATH.
#
# --strip-components=1 is what makes a single convention cover the real cases:
# a Go archive has everything under go/, an nvim release under
# nvim-linux-<arch>/, and stripping one level puts bin/ in the same place for
# both. So the spec needs no destination or layout fields, and there is
# nothing per-dependency for an operator to get wrong.
#
# The digest is checked BEFORE unpacking. A tar that is extracted first and
# verified after has already written whatever it contained.
#
# -xaf rather than -xzf: compression is detected from the archive, so xz and
# zstd releases work without the spec having to say which it is.
_emit_tarball_layers() {
local spec rest name sha url
for spec in "$@"; do
[ -n "$spec" ] || continue
rest="${spec#tarball:}"
name="${rest%%@*}"
rest="${rest#"$name"@}"
sha="${rest%%:*}"
url="${rest#"$sha":}"
printf 'RUN mkdir -p /opt/dejima/%s && \\\n' "$name"
printf ' wget --tries=3 --timeout=30 --waitretry=5 --retry-connrefused \\\n'
printf ' -O /tmp/%s.tar "%s" && \\\n' "$name" "$url"
printf ' echo "%s /tmp/%s.tar" | sha256sum -c - && \\\n' "$sha" "$name"
printf ' tar -C /opt/dejima/%s --strip-components=1 -xaf /tmp/%s.tar && \\\n' "$name" "$name"
printf ' rm /tmp/%s.tar\n' "$name"
printf 'ENV PATH=$PATH:/opt/dejima/%s/bin\n' "$name"
done
}
# Prints a Dockerfile that layers approved packages onto the shared base
# image. Only validated tokens are interpolated (approved_entries refuses
# anything else), so a RUN line here can't become a root-context injection.
@@ -202,6 +488,7 @@ unapprove_entry() {
# init-firewall.sh locks egress down.
generate_derived_dockerfile() {
local base="$1" store="$2" entries apt npm pip cargo spec name
local -a tarballs=()
entries="$(approved_entries "$store" dependencies)" \
|| { echo "refusing to build from a corrupt dependency list in $store" >&2; return 1; }
@@ -210,10 +497,11 @@ generate_derived_dockerfile() {
[ -n "$spec" ] || continue
name="${spec#*:}"
case "$spec" in
apt:*) apt="$apt $name" ;;
npm:*) npm="$npm $name" ;;
pip:*) pip="$pip $name" ;;
cargo:*) cargo="$cargo $name" ;;
apt:*) apt="$apt $name" ;;
npm:*) npm="$npm $name" ;;
pip:*) pip="$pip $name" ;;
cargo:*) cargo="$cargo $name" ;;
tarball:*) tarballs+=("$spec") ;;
esac
done <<< "$entries"
@@ -221,6 +509,7 @@ generate_derived_dockerfile() {
printf 'USER root\n'
[ -n "$apt" ] && printf 'RUN apt-get update && apt-get install -y --no-install-recommends%s \\\n && apt-get clean && rm -rf /var/lib/apt/lists/*\n' "$apt"
[ -n "$pip" ] && printf 'RUN pip install --break-system-packages%s\n' "$pip"
_emit_tarball_layers "${tarballs[@]+"${tarballs[@]}"}"
printf 'USER ${SANDBOX_USER}\n'
[ -n "$npm" ] && printf 'RUN npm install -g%s\n' "$npm"
if [ -n "$cargo" ]; then
+176 -51
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
@@ -321,28 +329,17 @@ manifest_get() {
# directory whose manifest declares its own keys, not editing core.
# ---------------------------------------------------------------------------
# config_validate re-parses the WHOLE config file on every config_get call
# (see its own comment - "on the order of thirty reads" per launch), and each
# pass through a [forge.<name>]/[harness.<name>] line calls back into here.
# Going through manifest_get/manifest_validate here would mean thirty-odd
# full-manifest validations (every field, not just `keys`) for every key in
# those sections - so this reads and validates only the `keys` field itself,
# a plain line-scan plus the one type check _provider_valid_value already
# does for it, skipping fields (pr_command, capabilities, env_export, ...)
# that this lookup never uses. The manifest's OTHER fields still get their
# full manifest_validate elsewhere before anything consequential reads them
# (provider_validate_selection, forge_hosts, forge_env_exports,
# briefing_compose) - this function only ever feeds a config schema type
# check, never a forge action.
# config_validate re-parses the whole config on every config_get (~thirty per
# launch), and each [forge.<name>] line calls back here. Going through
# manifest_validate would mean that many full-manifest validations, so this
# line-scans the `keys` field alone. Every other field still gets a full
# manifest_validate before anything consequential reads it - this feeds a
# config schema type check, never a forge action.
#
# Cached per axis+name for the rest of THIS command substitution: a
# manifest cannot change mid-launch, so re-deriving it for every key in the
# same section within one config_validate pass is pure waste. The cache
# itself does not survive past this subshell (bash forks a new one per
# config_get call), so it only dedupes the N keys of one section against
# each other, not across a launch's ~thirty reads - a cheap, safe partial
# win rather than a cross-process cache this file has no story for
# invalidating.
# Cached per axis+name for this command substitution only. bash forks a new
# subshell per config_get, so it dedupes the keys of one section against each
# other rather than across a launch: a cheap partial win, not a cross-process
# cache this file has no way to invalidate.
declare -gA _PROVIDER_KEYS_CACHE
_provider_keys_cached() {
local axis="$1" name="$2" cache_key="$axis:$name"
@@ -474,38 +471,107 @@ provider_validate_selection() {
fi
done
# Rule 3 ("a pushing workflow needs a forge that resolves to a host") is
# deliberately NOT checked here. A forge's own host-bearing keys may have
# no schema default and install.sh writes them commented out, so a fresh
# install would be refused by this rule today - while it launches fine
# as-is, with git-push-guard.sh blocking pushes at runtime (see README
# "Skip this if you only push to GitHub"). An unconfigured forge is
# supported, not broken, until Phase 3 adds `forge = none` as the honest
# way to say so - only then does this rule have a correct alternative to
# point an operator at, and it becomes a refusal alongside rule 5 below.
# Autonomy names where the agent stops without asking, so it is a claim
# about what the selected forge can actually do. Checked here rather than
# left to the briefing: a mismatch discovered by the agent mid-task is a
# confusing failure, and this one is knowable at launch.
local autonomy push
autonomy="$(manifest_get workflow "$workflow" autonomy)" || return 1
push="$(manifest_get workflow "$workflow" push)" || return 1
case "$autonomy" in
open-pr)
if ! _set_contains "$caps" pr; then
echo "workflow '$workflow' sets autonomy = open-pr, which needs a forge" >&2
echo "offering the 'pr' capability - '$forge' offers: ${caps:-none}." >&2
return 1
fi
;;
push-branch)
if [ "$push" != "on" ]; then
echo "workflow '$workflow' sets autonomy = push-branch but push = off." >&2
echo "It cannot stop at a push it never makes." >&2
return 1
fi
;;
esac
# config_is_set, not config_get: the question is "did the operator set
# this key", not "does it have a value" - a key can have a schema
# default, so it always has a value, and testing the value would make a
# wholly unconfigured forge (nothing in [forge.<name>] at all) look
# partly configured.
# Rule 3: a workflow that pushes needs a forge that resolves to somewhere
# to push to. Deferred through Phase 2 because refusing an unconfigured
# forge had no correct alternative to offer - `forge = none` now exists,
# so an operator who genuinely pushes nowhere has an honest way to say it
# and this stops being a refusal with no remedy.
if [ "$push" = "on" ] && [ -z "$(forge_hosts "$forge")" ]; then
if [ -z "$(manifest_get forge "$forge" keys)" ]; then
# A forge with no keys at all has nothing to configure - "set
# [forge.$forge]" would be a fix that does not exist, so the only
# honest remedies are another forge or another workflow.
echo "workflow '$workflow' pushes, but forge '$forge' has no host to push" >&2
echo "to and nothing to configure. Select a forge that has one ([core]" >&2
echo "forge = github needs no host configured), or a workflow that does" >&2
echo "not push (workflow = local-only)." >&2
else
echo "workflow '$workflow' pushes, but forge '$forge' resolves to no host." >&2
echo "Configure [forge.$forge], or [core] forge = github to push to GitHub" >&2
echo "instead, or [core] forge = none and a workflow that does not push" >&2
echo "(workflow = local-only) if this sandbox pushes nowhere." >&2
fi
return 1
fi
# Set AND non-empty. config_is_set alone answers "did the operator write
# this key", which is not the same question: install.sh writes every key
# commented out at its default, so uncommenting one and not filling it in
# yields `api =` - written, and worth nothing. Every call the forge makes
# then fails inside the container, which is precisely what these two
# rules exist to catch at launch instead.
#
# Testing the value is only safe because of the config_default skip
# immediately below: a key with a working schema default always resolves
# to something, so it is excluded before the emptiness test can read a
# wholly unconfigured forge (nothing in [forge.<name>] at all) as partly
# configured. A key with no default resolves to empty exactly when it is
# unset or blank, which are the same thing to everything downstream.
set_n=0 unset_n=0 missing=""
for key in $(manifest_get forge "$forge" requires); do
if config_is_set "forge.$forge" "$key"; then
[ -z "$(config_default "forge.$forge.$key")" ] || continue
if config_is_set "forge.$forge" "$key" && [ -n "$(config_get "forge.$forge" "$key")" ]; then
set_n=$((set_n + 1))
else
unset_n=$((unset_n + 1))
missing="${missing:+$missing }$key"
fi
done
# A warning, not a refusal (rule 5), and only when the forge is PARTLY
# configured. Wholly unconfigured is the same supported "I don't use
# this forge" case rule 3 above defers to Phase 3 for, and must stay
# silent; a partial configuration is almost certainly a typo, and saying
# so costs nothing. Becomes a refusal in Phase 3 alongside rule 3.
if [ "$set_n" -gt 0 ] && [ "$unset_n" -gt 0 ]; then
echo "Warning: forge '$forge' is partly configured - [forge.$forge] is" >&2
echo "missing: $missing. Pushes and PRs will fail until it is set." >&2
# Rule 5: a partly configured forge is a typo, not a choice. Refused rather
# than warned now that `forge = none` exists: previously the only way to
# say "I don't use a forge" was to leave one half-filled, so refusing would
# have broken a supported state. Still scoped to PARTIAL configuration -
# nothing set at all is rule 3's case above, and carries its own message -
# and, like rule 3, only when the workflow actually pushes: a non-pushing
# workflow never invokes the forge at all, so a partially filled-in forge
# section (e.g. only a host key, set so the firewall allows fetches) is
# untidy rather than broken - not a typo worth refusing.
if [ "$push" = "on" ] && [ "$set_n" -gt 0 ] && [ "$unset_n" -gt 0 ]; then
echo "forge '$forge' is partly configured - [forge.$forge] is missing:" >&2
echo "$missing. Set them, or set [core] forge = none if this sandbox" >&2
echo "pushes nowhere." >&2
return 1
fi
# Rule 3 only catches a wholly unconfigured forge by way of forge_hosts
# resolving empty - but a forge like github pins fixed_hosts independent
# of any config (the same three hosts for every operator), so forge_hosts
# is never empty regardless of whether push_token_file is set. A forge
# with exactly one `requires` key can also never be "partly" configured
# (rule 5 above): it is either wholly set or wholly unset, so a wholly
# unset single-key forge slips past both rules unless this closes it.
# Scoped the same way rule 3 is - only when the workflow actually pushes,
# and never for a forge with no `requires` at all.
if [ "$push" = "on" ] && [ "$set_n" -eq 0 ] && [ "$unset_n" -gt 0 ]; then
echo "workflow '$workflow' pushes, but forge '$forge' has none of its" >&2
echo "required settings configured - [forge.$forge] is missing: $missing." >&2
echo "Set them, or set [core] forge = none and a workflow that does not" >&2
echo "push (workflow = local-only) if this sandbox pushes nowhere." >&2
return 1
fi
return 0
}
@@ -558,6 +624,18 @@ forge_env_exports() {
else
value="$(config_get "forge.$forge" "$ref")" || return 1
fi
# An unresolved (empty) value is skipped rather than emitted as
# `NAME=`. Harmless for a forge whose variables docker-compose.yml
# never declares in its own `environment:` section - an absent -e
# flag and an empty one are the same "not set" to a container either
# way, and every such variable's consumer already treats unset and
# empty identically. Not harmless for GH_TOKEN specifically: compose
# DOES declare it there, carrying an independent read-only value
# every session gets regardless of which forge is selected. `-e
# GH_TOKEN=` on `docker compose run` overrides that entry - emitting
# it here whenever a forge's own env_export resolves it empty would
# silently replace a configured read-only token with nothing.
[ -n "$value" ] || continue
printf '%s=%s\n' "$name" "$value"
done
}
@@ -588,7 +666,7 @@ BRIEFING_FRAGMENT_MAX=16384
# is a hard refusal (_briefing_substitute below), not a literal passthrough -
# an unrecognized {{key}} reaching an agent's context verbatim is exactly the
# kind of silent drift this whole function exists to prevent.
_BRIEFING_WHITELIST="pr_command issue_command forge_name forge_ssh_host blocked_branches container_id project_dir workspace_dir session_id resume_command writable_mounts"
_BRIEFING_WHITELIST="pr_command issue_command forge_name forge_ssh_host blocked_branches container_id project_dir workspace_dir session_id resume_command writable_mounts network_allowlist"
_briefing_whitelisted() {
_set_contains "$_BRIEFING_WHITELIST" "$1"
@@ -616,7 +694,7 @@ What this means:
packages, anything outside the mounted paths, and the container itself go
away when this run ends.
- Outbound network is restricted to an allowlist (Anthropic's API, the npm
registry, GitHub read-only, the configured {{forge_name}} host{{forge_ssh_host}}, plus anything
registry, {{network_allowlist}}, plus anything
approved via dejima-allow). Requests to anything else will simply
fail to connect. That restriction is not only a limit on your reach: it
is also what bounds where a prompt injection or a compromised dependency
@@ -625,11 +703,18 @@ What this means:
allowlist is load-bearing for you too, not just for the host - working
around it would give up that protection, not merely lift the one limit
it appears to. If a task needs one more domain and nothing else, that's
a quick fix, not a reason to stop: tell the user to run
`bin/dejima-allow <domain> --container {{container_id}}` (add
`--global` to persist it past this session) from a terminal outside the
sandbox, then continue. Quote that --container value exactly as written
here - it is this container's real ID, not something to infer.
a quick fix, not a reason to stop. Tell the user to run, from a terminal
outside the sandbox, whichever of these matches why the domain is needed:
bin/dejima-allow --container {{container_id}} <domain> # just this session
bin/dejima-allow --project <path> <domain> # this project, always
bin/dejima-allow --global <domain> # every project
Pick deliberately rather than asking for all three: a domain this project
needs is --project, not --global - a global grant widens every other
project's firewall too, which is rarely what one project's dependency
calls for. --project also records the domain in the project's own
.dejima/allowed-domains, so it is shared with everyone working on it
rather than living on one machine. Quote that --container value exactly as
written here - it is this container's real ID, not something to infer.
- Stay within the task you were given. Since there are no permission
prompts here, be more conservative than usual about anything destructive,
irreversible, or outside the project directory.
@@ -654,6 +739,14 @@ _briefing_core_worktree() {
`.worktrees/` to `.git/info/exclude` (not `.gitignore` - this is
sandbox-local, not something to commit) so it doesn't clutter `git
status` for the user's real checkout.
- A worktree isolates the working tree, NOT `.git/config`, which is shared
with the parent checkout. A bare `git config <key> <value>` run from
inside a worktree writes to the user's real repository config, not to
anything scoped to your branch. Use `git -C <path> config` against a
throwaway repo when setting config for a test or a repro. This matters
beyond a stray `user.name`: `core.hooksPath`, `credential.helper` and
`!`-aliases all execute on the host, and the same unscoped command
reaches them.
BRIEFING_EOF
}
@@ -784,6 +877,38 @@ _briefing_resolve_key() {
fi
[ -z "$host" ] || printf ' (%s)' "$host"
;;
# What the session may actually reach, phrased for the selected forge
# rather than assembled from two placeholders. Materially different
# postures, and the agent acts on this sentence: GitHub is read-only
# by default; a forge with hosts of its own adds them; a forge whose
# hosts are the ones core already allowlists (hosts_allowlisted_by_core
# - the same manifest key bin/dejima reads to open the push grant) has
# no separate host to name, and when the workflow pushes, calling that
# access read-only would be false in the direction that matters.
#
# Driven off that key rather than off the forge's name: it is the one
# thing that actually decides the posture, so anything else here would
# be core guessing at what a manifest already states.
network_allowlist)
local hosts=""
if [ "$(manifest_get forge "$forge" hosts_allowlisted_by_core)" = "on" ]; then
if [ "$(manifest_get workflow "$workflow" push)" = "on" ]; then
printf '%s' "GitHub, which this session can also push to"
else
printf '%s' "GitHub read-only"
fi
else
if [ -n "$(manifest_get forge "$forge" host_keys)$(manifest_get forge "$forge" url_keys)$(manifest_get forge "$forge" fixed_hosts)" ]; then
hosts=", the configured $forge host"
if config_exists 2>/dev/null; then
local h
h="$(config_get "forge.$forge" ssh_host 2>/dev/null)" || h=""
[ -z "$h" ] || hosts="$hosts ($h)"
fi
fi
printf '%s' "GitHub read-only$hosts"
fi
;;
blocked_branches) manifest_get workflow "$workflow" blocked_branches ;;
project_dir) printf '%s' "${WORKSPACE_DIR:-}" ;;
workspace_dir) printf '%s' "${WORKSPACE_DIR:-}" ;;
+81
View File
@@ -0,0 +1,81 @@
# Shared library: the ~/.claude/projects slug scheme, and which slugs belong
# to a given workspace. Sourced by bin/dejima (to decide what to mount) and
# bin/dejima-audit (to decide what counts as a cross-project write). Not on
# PATH and not executable.
#
# One definition, because the two callers must not disagree. A slug the mount
# exposes but the audit treats as foreign produces a finding on every ordinary
# session; a slug the audit treats as the workspace's own but the mount does
# not expose is a hole that nothing reports.
# Claude Code's own project key: the absolute cwd with every non-alphanumeric
# replaced by a dash. Matches docker/log-shipper.sh's Loki key and
# dejima-project-lib.sh's project_slug, which cannot source this file (one
# runs inside the container, the other is a separate library).
ws_slug() { printf '%s' "$1" | sed 's/[^a-zA-Z0-9]/-/g'; }
# The slugs belonging to $1: its own, plus one per live git worktree nested
# underneath it. One per line, sorted, no duplicates.
#
# Derived from real worktree paths rather than from the slug string, and that
# is the whole point of the function. Slugs cannot distinguish a child from a
# sibling: `/w/proj/wt1` and `/w/proj-wt1` both slug to `-w-proj-wt1`, so a
# `$slug-*` glob matches any sibling project whose directory name merely
# extends this one's - on a host with `proj` and `proj-ci` checked out side by
# side, `proj-ci` reads as part of `proj`. A path test cannot make that
# mistake, because a sibling is not under the workspace directory.
#
# Listing from a worktree names the main checkout and its siblings too. Those
# are not under $1, so they drop out here: launching against a worktree
# exposes that worktree and nothing else, which is correct.
workspace_slugs() {
local ws="$1" wt
ws="${ws%/}"
ws_slug "$ws"
printf '\n'
# A path containing a newline splits into two lines that name no real
# directory, so both fail the -d test below rather than being parsed as
# some other worktree. git has no unambiguous non-`-z` output here.
git -C "$ws" worktree list --porcelain 2>/dev/null \
| sed -n 's/^worktree //p' \
| while IFS= read -r wt; do
case "$wt" in
"$ws"/*) ;;
*) continue ;;
esac
[ -d "$wt" ] || continue
ws_slug "$wt"
printf '\n'
done
}
# Every slug in workspace_slugs, sorted and deduplicated.
workspace_slugs_sorted() { workspace_slugs "$1" | sort -u; }
# The Claude Code session ids belonging to workspace $2, one per line, taken
# from the transcript filenames under $1 (a ~/.claude/projects directory).
#
# ~/.claude/file-history is keyed by session id rather than by project, so
# there is no slug in its own paths to scope it by. The transcript is what
# ties the two together: projects/<slug>/<id>.jsonl and file-history/<id>
# name the same session, so the slugs this workspace owns select the ids it
# owns. A session id that has no transcript belongs to no project this can
# see, and is left out.
workspace_session_ids() {
local projects_dir="$1" ws="$2" slug f id
[ -d "$projects_dir" ] || return 0
while IFS= read -r slug; do
[ -n "$slug" ] || continue
[ -d "$projects_dir/$slug" ] || continue
for f in "$projects_dir/$slug"/*.jsonl; do
[ -f "$f" ] || continue
id="$(basename "$f" .jsonl)"
# Only a real session id, never whatever else a directory might
# hold: this value becomes a mount path.
case "$id" in
*[!a-fA-F0-9-]*) continue ;;
esac
[ -n "$id" ] && printf '%s\n' "$id"
done
done < <(workspace_slugs_sorted "$ws") | sort -u
}
+18 -28
View File
@@ -5,37 +5,27 @@
# systemd/dejima-ttyd.service.
# See docs/remote-access.md.
#
# --interface 127.0.0.1 is a security requirement, not a preference.
# docker/init-firewall.sh:187 accepts container OUTPUT to the default-route
# address - the Docker bridge gateway, i.e. the host - and attaching tmux
# gives a HOST shell (the prefix key opens a new window outside the
# container). A listener on any address the container can route to would hand
# a compromised session its own escape hatch. Loopback is not one of those
# addresses: the container has no route to the host's 127.0.0.1. Put nginx in
# front, bound to one specific LAN address - see
# docs/nginx-dejima.conf.example.
# --interface 127.0.0.1 is a requirement, not a preference. init-firewall.sh
# accepts container OUTPUT to the bridge gateway (the host), and attaching
# tmux gives a HOST shell - the prefix key opens a window outside the
# container. A listener on any address the container can route to is a
# compromised session's escape hatch; loopback is not one of them. Put nginx
# in front, bound to one LAN address - docs/nginx-dejima.conf.example.
#
# ttyd is given no credential of its own, so the reverse proxy is the only
# thing authenticating a request, and the loopback bind is what makes that
# safe rather than careless: nothing but a process on this host can open the
# socket at all. --writable is what makes the terminal a terminal rather than
# a viewer.
# ttyd carries no credential of its own, so the proxy is the only thing
# authenticating and the loopback bind is what makes that safe. --writable
# makes it a terminal rather than a viewer.
#
# --check-origin is the other half of that, and it is not optional. Without
# it ttyd does no origin validation at all (it only calls check_host_origin
# when the flag is set), and browsers do not apply the same-origin policy to
# WebSockets: any page the operator's browser loads could open
# wss://<front-door>/ws, have the browser attach its cached credential for
# that origin, and be handed this host shell. Proxy authentication cannot
# stop that - it is satisfied by the victim's own browser. ttyd compares the
# Origin authority against the Host header, which is why the nginx example
# passes Host through unchanged; the two go together.
# --check-origin is not optional: without it ttyd does no origin validation
# at all, and browsers do not apply same-origin to WebSockets, so any page
# the operator loads could open wss://<front-door>/ws with the browser
# attaching its cached credential and be handed this shell. Proxy auth cannot
# stop that - the victim's own browser satisfies it. ttyd compares Origin
# against Host, which is why the nginx example passes Host through unchanged.
#
# --url-arg lets the client pass ?arg=<run-id>, which is what the notification
# deep link uses. It reaches dejima-attach as argv, never as a shell word, and
# anything there that is not --list or the empty string (a bare `?arg=`, which
# lands on the picker) has to match the run-id shape before it is used as a
# path component or anything else.
# --url-arg carries ?arg=<run-id> for the notification deep link. It reaches
# dejima-attach as argv, never a shell word, and anything but --list or empty
# must match the run-id shape before it is used as a path component.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
+36 -63
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
@@ -84,26 +77,15 @@ ENV DEJIMA=1
# this user, so it doesn't need to own anything itself.
RUN useradd -r -s /usr/sbin/nologin ulogd
# Every fetch from here down retries, and none is single-shot. What matters
# isn't the odds of a particular download failing, it's the odds of any of
# them failing - and without retries a single blip anywhere throws away the
# whole build.
# Every fetch from here down retries. What matters is not the odds of one
# download failing but of ANY of them failing, and without retries a single
# blip discards the whole build.
#
# These steps deliberately sit *above* the SANDBOX_* ARGs, not below. Nothing
# between here and `USER` needs the sandbox user to exist yet - they all
# install system-wide as root - so keeping them ahead of the ARGs means a
# build that overrides one (test/runtime.bats does, and so does any host
# whose uid isn't 1000) still reuses every layer down to this point instead
# of re-running all of it. That single ordering was most of the cost of the
# override rebuild: it was 1169s of a 1379s test run, for a difference that
# amounts to one useradd.
ARG GIT_DELTA_VERSION=0.18.2
RUN ARCH=$(dpkg --print-architecture) && \
wget --tries=3 --timeout=30 --waitretry=5 --retry-connrefused \
"https://github.com/dandavison/delta/releases/download/${GIT_DELTA_VERSION}/git-delta_${GIT_DELTA_VERSION}_${ARCH}.deb" && \
dpkg -i "git-delta_${GIT_DELTA_VERSION}_${ARCH}.deb" && \
rm "git-delta_${GIT_DELTA_VERSION}_${ARCH}.deb"
# These steps sit ABOVE the SANDBOX_* ARGs on purpose: nothing here needs the
# sandbox user, so a build overriding a uid (runtime.bats, and any host not
# on 1000) still reuses every layer to this point. That ordering alone was
# most of the cost of an override rebuild, for a difference amounting to one
# useradd.
# gosu lets entrypoint.sh (root) drop to the node user for the actual
# session after setting up the firewall, without leaving any setuid/sudo
# escalation path available to node afterward. Verified against the
@@ -130,24 +112,15 @@ RUN asset_url=$(curl -fsSL --retry 3 --retry-delay 2 --retry-all-errors --max-ti
tar -C /usr/local --strip-components=1 -xzf nvim.tar.gz && \
rm nvim.tar.gz
# Install the latest stable Go toolchain. go.dev's own release JSON gives
# both the download URL and a SHA256 per file, verified below since this
# runs as root - same pattern as gosu above. `go build`/`go mod download`
# need proxy.golang.org and sum.golang.org reachable at runtime - see
# fixed-domains.sh.
RUN ARCH=$(dpkg --print-architecture) && \
release_json=$(curl -fsSL --retry 3 --retry-delay 2 --retry-all-errors --max-time 60 \
'https://go.dev/dl/?mode=json') && \
filename=$(echo "$release_json" | jq -r --arg arch "$ARCH" \
'[.[] | select(.stable == true)][0].files[] | select(.os == "linux" and .arch == $arch and .kind == "archive") | .filename') && \
sha256=$(echo "$release_json" | jq -r --arg arch "$ARCH" \
'[.[] | select(.stable == true)][0].files[] | select(.os == "linux" and .arch == $arch and .kind == "archive") | .sha256') && \
wget --tries=3 --timeout=30 --waitretry=5 --retry-connrefused \
-O go.tar.gz "https://go.dev/dl/${filename}" && \
echo "${sha256} go.tar.gz" | sha256sum -c - && \
tar -C /usr/local -xzf go.tar.gz && \
rm go.tar.gz
ENV PATH=$PATH:/usr/local/go/bin
# No Go toolchain here. A Go project declares one:
#
# dejima-dep add --global tarball:go@<sha256>:https://go.dev/dl/go<ver>.linux-amd64.tar.gz
#
# and gets a pinned toolchain in the version it actually wants, rather than
# whatever this image was built with. Shipping a language runtime is not the
# same decision as every sandbox on the host carrying one - and the module
# proxy it needs (proxy.golang.org, sum.golang.org) is an approval too, for
# the same reason.
# System libraries Chromium needs (fonts, libnss, libatk, etc.) for
# Playwright, installed here since it needs apt/root - the actual
@@ -208,7 +181,11 @@ RUN mkdir /commandhistory && \
# from the host, and Docker creates the parents of a mount point root-owned
# if they don't already exist - which would leave the sandbox user unable to
# write the caches and history it keeps directly in ~/.claude.
RUN mkdir -p $SANDBOX_HOME/.ssh $SANDBOX_HOME/.claude && \
# projects/ is in that list for the same reason one level down: it is no
# longer mounted itself, only individual slug directories inside it, so
# without this Docker creates it root-owned while setting those up and the
# session cannot write a slug of its own.
RUN mkdir -p $SANDBOX_HOME/.ssh $SANDBOX_HOME/.claude $SANDBOX_HOME/.claude/projects $SANDBOX_HOME/.claude/file-history && \
chown -R $SANDBOX_USER:$SANDBOX_USER $SANDBOX_HOME/.ssh $SANDBOX_HOME/.claude && \
chmod 700 $SANDBOX_HOME/.ssh $SANDBOX_HOME/.claude
@@ -278,16 +255,12 @@ RUN npm install -g tree-sitter-cli@0.24.7
# matching Chromium build into node's own cache, both at build time so a
# session never needs network access for either.
#
# Chromium's own internal process sandbox is questionable here: it launches
# without --no-sandbox and without error, but its setuid sandbox helper
# (chrome_sandbox) isn't actually setuid-root in this image, and this host
# has the same unprivileged-userns restriction that broke bubblewrap - so
# its namespace-based isolation almost certainly isn't active either, it's
# just not hard-failing the way bubblewrap did. Not treated as a real
# security boundary either way: this container is already the boundary that
# matters, and a compromised session already has full `node`-level bash
# access, which a Chromium exploit couldn't exceed regardless of whichever
# of its own internal sandbox layers happen to be active.
# Chromium's own process sandbox is probably not active here: it launches
# without error, but chrome_sandbox is not setuid-root in this image and the
# unprivileged-userns restriction that broke bubblewrap applies, so it just
# fails softer than bubblewrap did. Not treated as a boundary either way -
# the container is the boundary, and a session already has node-level shell
# access that a Chromium exploit could not exceed.
RUN npm install -g playwright@${PLAYWRIGHT_VERSION} && \
npx playwright install chromium
+29 -26
View File
@@ -82,7 +82,7 @@ services:
# Opt-in (default off): also ship session prompts, assistant
# responses, and tool invocations to Loki as the
# dejima-conversation job. Content only leaves the container
# if LOKI_URL is also set. See README "Network request logging".
# if LOKI_URL is also set. See docs/security.md "Network request logging".
SANDBOX_LOG_CONVERSATION: ${SANDBOX_LOG_CONVERSATION:-0}
SSH_AUTH_SOCK: /ssh-agent
ENABLE_FIREWALL: ${ENABLE_FIREWALL:-1}
@@ -111,33 +111,24 @@ services:
# (based on cwd) lines up with the host, letting a session started
# here be resumed on the host with `claude --resume`.
- ${WORKSPACE_DIR:?set WORKSPACE_DIR to the project directory to mount}:${WORKSPACE_DIR}
# ~/.claude is deliberately not mounted as a whole. It used to be,
# read-write, with the config inside it layered back read-only on top.
# Every one of those overlays was a mount point nested in a
# host-writable mount, and a single-file bind mount is pinned to the
# dentry at that path: a host-side rename of the underlying file - a
# sync daemon, or Claude Code's own atomic writes - unlinks it, the
# mount silently detaches, and the path falls through to the real,
# writable host file (#47, #48). The same thing happened to the mask
# over .credentials.json, handing the sandbox the operator's real
# OAuth token on the first refresh (#50).
# ~/.claude is enumerated, never mounted whole. A single-file bind
# mount is pinned to the dentry at its path, so a host-side rename -
# a sync daemon, or Claude Code's own atomic writes - unlinks it, the
# mount detaches silently, and the path falls through to the real
# writable host file (#47, #48; #50 did exactly that to the
# .credentials.json mask). Anything not listed here is
# container-local, which is the safer default but means new state that
# should persist has to be added deliberately.
#
# So the contents are enumerated instead. Anything not listed below is
# container-local: writable during the session, gone with the
# container. That is the safer default - a config file a future Claude
# Code version adds simply doesn't persist, instead of sitting
# unprotected on a writable mount - but it does mean new state that
# *should* persist has to be added here deliberately.
# Read-write below, and all directories: a directory mount can't
# detach that way, because a rename replaces an entry within it rather
# than the mount point itself.
#
# Runtime state that has to survive the session, read-write. All
# directories, which is what makes them safe: a directory bind mount
# can't detach the way a file one does, because a rename replaces an
# entry *within* the mounted directory rather than the mount point.
# projects/ is what host-side `claude --resume` reads (and what
# log-shipper.sh tails); the rest is Claude Code's edit-undo record
# and the task/plan state skills write.
- ${HOME}/.claude/projects:${HOME}/.claude/projects
- ${HOME}/.claude/file-history:${HOME}/.claude/file-history
# projects/ and file-history/ are absent on purpose - the trees are
# every project's memory and every project's file contents, and a
# write into either reaches sessions elsewhere (#104). bin/dejima
# mounts this workspace's own entries, one -v each; see
# bin/dejima-slug-lib.sh for how that set is chosen.
- ${HOME}/.claude/backups:${HOME}/.claude/backups
- ${HOME}/.claude/tasks:${HOME}/.claude/tasks
- ${HOME}/.claude/plans:${HOME}/.claude/plans
@@ -233,6 +224,18 @@ services:
volumes:
claude-code-bashhistory:
# Named explicitly so bin/dejima can give each project its own set. These
# persist across containers by design (a plugin install should not have to
# happen every launch), which without the split meant one project's session
# could leave a Lua file or a mason/bin binary that another project's
# session executes the next time nvim runs. The service still refers to the
# static keys; only the volume Docker actually creates varies.
#
# The defaults reproduce the previous names exactly, so a `docker compose`
# invocation that bypasses bin/dejima behaves as it did.
nvim-data:
name: ${SANDBOX_NVIM_DATA:-dejima_nvim-data}
nvim-state:
name: ${SANDBOX_NVIM_STATE:-dejima_nvim-state}
nvim-cache:
name: ${SANDBOX_NVIM_CACHE:-dejima_nvim-cache}
+26
View File
@@ -252,6 +252,32 @@ fi
[ -n "${ANTHROPIC_BASE_URL:-}" ] || unset ANTHROPIC_BASE_URL
[ -n "${ANTHROPIC_AUTH_TOKEN:-}" ] || unset ANTHROPIC_AUTH_TOKEN
# Unset is the ordinary case (remote access is opt-in) and says nothing.
# Set-but-unusable is worth a startup line because of how it presents: the
# marker directory's source is created per launch and removed on exit, so
# restarting the container rather than relaunching `dejima` leaves it gone,
# and Docker answers a missing bind source by creating one root-owned. The
# hook's `[ -d ]` guard then passes and every notification fails EACCES,
# while the session is also missing from `dejima-attach` because its registry
# entry went with the same directory - three unrelated-looking symptoms.
#
# Reported, not repaired: chowning would restore notifications while the
# session stayed absent from the registry, looking fixed with half the
# feature broken. Relaunching is the remedy, and the message can say so.
if [ -n "${DEJIMA_NOTIFY_DIR:-}" ]; then
if [ ! -d "$DEJIMA_NOTIFY_DIR" ]; then
echo "WARNING: remote access is enabled, but $DEJIMA_NOTIFY_DIR is not a directory." >&2
echo "Notifications will not be delivered for this session." >&2
elif ! gosu "$SANDBOX_USER" test -w "$DEJIMA_NOTIFY_DIR"; then
echo "WARNING: remote access is enabled, but $DEJIMA_NOTIFY_DIR is not writable by" >&2
echo "$SANDBOX_USER - so notifications will fail, and this session will not appear in" >&2
echo "dejima-attach. This is what a container that was RESTARTED rather than" >&2
echo "relaunched looks like: bin/dejima creates the marker directory per launch and" >&2
echo "removes it on exit, and Docker recreated the missing bind source as root." >&2
echo "Stop this container and start a new session with 'dejima' to restore both." >&2
fi
fi
# Test harness hook (test/): everything above this point is the real
# startup sequence - DNS proxy, firewall, IPv6 block, logging,
# privilege-drop prep. Skip execing $SANDBOX_CMD so the suite can `docker
+38 -5
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,28 @@ GITHUB_DNS_SUFFIXES=(
"githubusercontent.com"
"githubassets.com"
)
# Which ipset an allowlisted name's addresses belong in.
#
# GitHub's are restricted to port 443 by init-firewall.sh, and that
# restriction IS the read-only guarantee: no transport is left for a
# forwarded agent key to push with. allowed-domains has no port restriction,
# so a GitHub address landing there silently undoes the arrangement.
#
# dnsmasq applies the longest matching ipset= line, so dns-proxy.sh gets this
# right for free. Every other path has to ask, and two did not - the
# extra-domains loop and bin/dejima-allow's live add both appended
# unconditionally. A project declaring github.com therefore opened port 22 to
# whatever address was cached at container start, presenting as an
# intermittent startup failure because the self-check re-resolves and only
# sees the hole when the second answer matches the first.
ipset_for_domain() {
local domain="$1" suffix
for suffix in "${GITHUB_DNS_SUFFIXES[@]}"; do
if [ "$domain" = "$suffix" ] || [[ "$domain" == *".$suffix" ]]; then
printf 'github-domains'
return 0
fi
done
printf 'allowed-domains'
}
+97 -22
View File
@@ -11,23 +11,17 @@
set -Eeuo pipefail
IFS=$'\n\t'
# Nothing in here may die quietly. Under `set -e` a failing command aborts the
# script with no output at all, and this script's failures are invisible in the
# worst possible way: the container simply never appears, which reads as the
# sandbox being broken rather than as one command in one loop having failed.
# Three CI failures were spent on two different instances of exactly that (#59)
# - one at the GitHub ranges fetch, one in the domain loop below - and neither
# left a single line to go on.
# Nothing here may die quietly. Under `set -e` a failure aborts with no output
# and the container simply never appears, which reads as the sandbox being
# broken rather than one command failing. Three CI failures went to two
# instances of exactly that (#59), neither leaving a line to go on.
#
# The explicit `exit 1` paths below say more than this can and are worth
# keeping; this is the floor under everything else. -E on the line above is
# what extends it into functions and subshells, where it would otherwise not
# fire at all.
# This is the floor; the explicit `exit 1` paths below say more. -E extends it
# into functions and subshells, where it would not fire at all.
#
# A failure inside a command substitution reports twice - once from the
# subshell, naming the command that actually failed, and once from the
# statement containing it. That is worth the noise: for `x=$(a | b)` the first
# line is the only thing that says whether it was `a` or `b`.
# A failure inside a command substitution reports twice, from the subshell and
# from the containing statement. Worth the noise: for `x=$(a | b)` the first
# line is the only thing saying whether it was `a` or `b`.
trap 'status=$?; echo "ERROR: init-firewall.sh aborted at line $LINENO (exit $status): $BASH_COMMAND" >&2' ERR
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
@@ -76,10 +70,13 @@ else
echo "No Docker DNS rules were present to restore"
fi
# Allow localhost. DNS resolution goes entirely through Docker's embedded
# resolver at 127.0.0.11 (see resolv.conf), which stays on loopback, so this
# also covers DNS - no need for a separate "UDP/53 to anywhere" rule, which
# would just be an unrestricted-destination exfil channel.
# Allow localhost. Every DNS query this container makes starts here: to
# dnsmasq on 127.0.0.1, and from there to Docker's embedded resolver on
# 127.0.0.11 (see resolv.conf). What this rule does NOT cover is the hop
# after that, where the embedded resolver forwards an external name to the
# host's nameservers - see the owner-matched rule further down, which is
# there because that hop can leave this namespace. Still no "UDP/53 to
# anywhere" rule, which would be an unrestricted-destination exfil channel.
iptables -A INPUT -i lo -j ACCEPT
iptables -A OUTPUT -o lo -j ACCEPT
@@ -269,13 +266,17 @@ if [ -n "${EXTRA_ALLOWED_DOMAINS:-}" ]; then
continue
fi
# Not always allowed-domains: that set carries no port restriction,
# so a GitHub address landing in it is reachable on 22 as well and the
# read-only guarantee is gone. See ipset_for_domain.
target_set=$(ipset_for_domain "$domain")
while read -r ip; do
if [[ ! "$ip" =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]; then
echo "WARNING: Invalid IP from DNS for $domain: $ip, skipping"
continue
fi
echo "Adding $ip for $domain"
ipset add allowed-domains "$ip" 2>/dev/null || true
echo "Adding $ip for $domain (-> $target_set)"
ipset add "$target_set" "$ip" 2>/dev/null || true
done < <(echo "$ips")
done
fi
@@ -305,6 +306,32 @@ iptables -P OUTPUT DROP
iptables -A INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
iptables -A OUTPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
# Docker's embedded resolver forwards external names to the host's
# nameservers, and on some versions does it from inside THIS namespace. Those
# queries then traverse the chain below and match nothing - the host's
# resolver is neither in an ipset nor the bridge gateway - so they hit the
# catch-all REJECT and every lookup SERVFAILs. Only the first hop stays on
# loopback. Two hosts with byte-identical /etc/resolv.conf behaved
# differently, so this is version-dependent and cannot be assumed either way.
#
# --uid-owner 0 keeps this from being the hole the DNS allowlist closes: the
# forwarding socket is dockerd's (root), the session is `node` with no sudo,
# so it still cannot address a resolver directly. This newly permits DNS only
# from processes already root in a NET_ADMIN container, which could flush the
# ruleset anyway.
#
# Guarded, not asserted: xt_owner is a module, and a kernel without it should
# leave a container that says why (the DNS self-check below) rather than one
# that aborts here.
if iptables -A OUTPUT -p udp --dport 53 -m owner --uid-owner 0 -j ACCEPT 2>/dev/null; then
iptables -A OUTPUT -p tcp --dport 53 -m owner --uid-owner 0 -j ACCEPT 2>/dev/null \
|| echo "WARNING: could not add the TCP/53 rule for root-owned sockets - large answers may fail" >&2
else
echo "WARNING: the iptables owner match is unavailable, so Docker's embedded" >&2
echo "resolver cannot forward out of this namespace. If DNS works anyway this" >&2
echo "host forwards elsewhere and nothing is wrong; the self-check below says which." >&2
fi
# Then allow only specific outbound traffic to allowed domains. This isn't
# port-restricted, so it also covers git-over-ssh (port 22) to the
# configured forge's hosts, which are already in the ipset above - no
@@ -340,6 +367,33 @@ iptables -A OUTPUT -j NFLOG --nflog-group 2 --nflog-prefix "reject"
iptables -A OUTPUT -j REJECT --reject-with icmp-admin-prohibited
echo "Firewall rules applied. Self-checking..."
# DNS before either connectivity check, because both of those resolve a name
# before they connect and neither can tell you that resolution is what failed.
# The negative control is the trap: with DNS dead it fails for the wrong
# reason and prints "example.com is unreachable, as it should be", so a
# container with no working DNS at all reports a passing check and then dies
# on the positive control naming a host rather than the cause. Reading that
# output backwards took an afternoon; it should take one line.
if ! dns_probe=$(dig +short +time=3 +tries=2 A api.github.com 2>&1) || [ -z "$dns_probe" ]; then
echo "ERROR: self-check failed - DNS does not resolve inside the sandbox." >&2
echo >&2
echo "The path is: this container -> dnsmasq on 127.0.0.1 (dns-proxy.sh) ->" >&2
echo "Docker's embedded resolver on 127.0.0.11 -> the host's nameservers." >&2
echo "Only the first two hops are on loopback. If Docker forwards the last" >&2
echo "one from inside this namespace, it traverses the OUTPUT chain, matches" >&2
echo "nothing, and is rejected - which reads as SERVFAIL, not as a firewall." >&2
echo >&2
echo " dig said: ${dns_probe:-(empty answer)}" >&2
echo " packets taken by the catch-all OUTPUT reject so far:" >&2
iptables -L OUTPUT -vn 2>/dev/null \
| awk '/icmp-admin-prohibited/ {print " " $1 " packets, " $2 " bytes"}' >&2
echo " dnsmasq's last words:" >&2
tail -5 /var/log/dnsmasq.log 2>/dev/null | sed 's/^/ /' >&2
exit 1
fi
echo "self-check ok - DNS resolves an allowlisted name"
# --max-time as well as --connect-timeout on both self-checks: a connect that
# succeeds and then stalls is not covered by --connect-timeout, and while these
# two sit inside `if` (so set -e cannot take the script down the way it could
@@ -353,7 +407,28 @@ else
fi
# Confirm the allowlist itself actually works, not just that the default-deny does.
if ! curl --connect-timeout 5 --max-time 15 https://api.github.com/zen >/dev/null 2>&1; then
#
# Retried, unlike the negative control above, because the two ask opposite
# questions. This one asserts something CAN be reached, and a transient DNS
# answer or a CDN edge rotating mid-boot answers it wrongly in the direction
# that kills the container - which is how it presented, one container failing
# here while a dozen others in the same CI run came up fine. The example.com
# check must NOT be retried: there a single success is the real answer, and
# retrying would only be asking until it says what we want.
github_reachable=0
for attempt in 1 2 3; do
if curl --connect-timeout 5 --max-time 15 https://api.github.com/zen >/dev/null 2>&1; then
github_reachable=1
break
fi
echo " attempt $attempt/3: api.github.com not reachable yet" >&2
# `if`, not `[ ... ] && sleep` - see fetch_github_ranges above for what
# that costs on the last attempt under set -e.
if [ "$attempt" -lt 3 ]; then
sleep $((attempt * 2))
fi
done
if [ "$github_reachable" -ne 1 ]; then
echo "ERROR: self-check failed - api.github.com should be reachable but wasn't"
exit 1
else
+527
View File
@@ -0,0 +1,527 @@
# CI, and what push access grants
[← dejima README](../README.md)
**Granting a dejima session push access grants it code execution wherever your
CI runs.** That is the whole of it, and everything below is either why it
cannot be gated or what to do about it instead.
> **This document is the why.** For the how, [dejima-ci-hardener](https://gitbox.branderson.io/dejima/dejima-ci-hardener)
> is a self-contained tool that does it, installed on the CI box, never on a
> dejima host, and it does not assume you have read this.
The chain needs no operator action at any step. A session writes code; the
push guard permits a feature branch, which is what it exists to allow; the
session opens a pull request, which its briefing tells it to do without asking
first; `on: pull_request` fires and the runner checks out that commit and runs
it. The code does not have to be a modified workflow file to be executed: a
workflow that runs a test suite is already running every file in it, and a
plausible-looking helper in a test file is a much quieter carrier than an
edited workflow, which shows up in a diff.
The gate this sandbox otherwise relies on (you merge, the agent does not)
sits strictly downstream of that. By the time anyone reads the diff, the code
has run.
This applies to any project developed in a dejima sandbox, not only to dejima
itself. The sandbox bounds what a session can reach directly. It does not
bound what it can arrange to have run elsewhere, and CI is the shortest path
elsewhere.
**And it applies to projects with no CI at all.** A branch can add the first
workflow, so what matters is not what a repository's workflows do today but
which runners its branches can reach, which is a property of the forge, not
of the project. If you read one section of this document before pointing a
sandbox at something, make it
[what a repository can reach](#closing-the-rest-what-a-repository-can-reach);
it is a question to answer per project, and for a project on hosted runners
the answer is reassuring.
## What a run actually adds
The code CI executes is code the session already had. Relative to the sandbox,
a run adds three things, and every control in this document maps onto one of
them:
1. **Execution on a host you own**: outside the container, outside the
allowlist, with whatever that host can reach.
2. **Egress from a network position you own**: no dejima firewall, no DNS
proxy, ordinary networking.
3. **Credentials the run is handed**: the secrets you configured, plus the
token the forge injects into every job whether the workflow asks for one or
not.
Keep those three in view even if none of the specific configuration below
applies to you. They are what changes when a session gains push access, and
they survive translation to CI that looks nothing like this.
## Why this document offers no approval gate
The obvious answer is to require a human to approve a run. Each version of
that turns out to be reachable from the branch, from a credential the session
already holds, or to be no gate at all:
- **Changing the trigger**: `workflow_dispatch` only, or `push` to `main`
only. The workflow file lives in the tree the session controls, and on a
`pull_request` run it is read from the head of the pull request.
- **Triggering by hand.** A forge token that can open a pull request generally
also covers the dispatch endpoint. Gitea's `write:repository` does.
- **Leaving the runner stopped until you want a run.** Any shared pool has
spare capacity, so a job that cannot land on your runner lands on someone
else's. It also asks you to do something before every run, which is a
recommendation that gets abandoned rather than followed.
- **The forge's own approval settings.** Gitea's approval requirement and
read-only token apply to pull requests *from forks*. A dejima session pushes
a branch to the same repository, which is precisely the case they do not
cover.
- **Filtering egress from inside the workflow.** That is a step in a file the
branch can edit.
Running unattended from a plan through to a pull request is most of what a
sandbox like this is for. A design that reinserts a manual step per run gives
that up and, in practice, gets routed around. So nothing here tries to stop
the run. Everything here bounds what the run can do.
## Before anything else: protect the branch
Every control below that is worth anything roots its trust in `main`: most
directly the egress allowlist, which is read from there. A job token with
write access to the repository can push to an unprotected branch, so without
this the untrusted party can edit the thing that is supposed to constrain it.
- Protect `main`: no direct push, pull request required.
- **Verify** the job token cannot push to it, rather than assuming branch
protection covers a token the forge issues itself.
- Check whether a job token can *merge* a pull request on your instance. If it
can, both the merge guard and the you-merge-not-the-agent gate are reachable
from CI, and you should know that either way.
This costs nothing and it is the single highest-value item in this document.
**Branch protection needs a second identity to protect anything.** By default
a session authenticates as you, so a merge whitelist, a push whitelist and an
approval requirement all admit the sandbox exactly as they admit you, and
required approvals are unusable outright, since a forge will not let a pull
request's author approve it and the session opens them as you. Giving the
sandbox its own forge account is what turns those settings from decoration
into enforcement, and it is configuration rather than a missing feature:
[docs/security.md](security.md#to-your-forge-the-sandbox-is-you).
Note that it does not help with the rest of this document. A write-capable
account still pushes feature branches, which is what makes CI run code, and
the token the forge injects into a job is a third principal subject to none of
those settings.
## The runner host
Treat it as what it is: a machine that exists to run untrusted code, because
that is what it does on every pull request.
- **Nothing else on it.** No other service, no other project's data.
- **No route to the LAN or to a management network.** A runner that can reach
your internal services is a runner that hands them to whatever it executes.
- **No credentials beyond its own registration token.** Registration tokens
are worth rotating on a schedule; anyone holding one can register a runner
and start receiving jobs, including their secrets.
- **Rebuilt from a snapshot on a schedule**, so that persistence on it has a
bounded lifetime rather than an indefinite one.
- **Register runners at repository or organization scope**, not instance
scope. This matters in both directions, and the second is the one that bites:
an unrelated repository cannot schedule work onto a runner you hardened for
this one, *and* this repository cannot schedule work onto a runner nobody
hardened. An instance-level runner is reachable from every repository on the
instance; see "A label is not a boundary" below. Gitea's own FAQ describes
gitea.com doing this for its public runners.
Two runners on one VM give you label tiering, not host separation: a job that
cannot get the Docker socket still shares a kernel and a network with a runner
that can. If you only have one machine, that is a real reduction and worth
doing; just do not read it as isolation between the tiers.
## Job containers
The single most consequential setting is one that is invisible from inside a
job. `act_runner` mounts the Docker daemon's socket into every job container
it starts unless `container.docker_host` is set to `"-"`; its own generated
configuration says so:
> If it's `"-"`, act_runner will find an available docker host automatically,
> but the docker host won't be mounted to the job containers and service
> containers.
Access to that socket is root-equivalent on the runner host: `docker run
-v /:/host` ends the argument. Nothing in a job's log mentions the socket
being there, so this is not something a passing look at a green run will
surface. Check it directly, per runner:
```sh
grep -A12 '^container:' /path/to/act_runner/config.yaml
```
An unset `docker_host` is the default, and the default mounts.
Note also that whether a job gets the socket is a property of **the runner's**
configuration, not of the job. A job asking for nothing unusual still receives
whatever the runner grants everything it runs. So the useful question is not
"does this job need Docker" but "does every job this runner can pick up
deserve the socket".
For a runner that does not need it, the settings worth being explicit about
rather than leaving at their defaults are `docker_host: "-"`, an empty
`valid_volumes` (the default, but worth stating so an edit has to be
deliberate), `privileged: false`, a fixed `network` (below), `capacity: 1`,
`cache.enabled: false`, and a label nothing else carries.
dejima-ci-hardener ships both configs, with the reasoning inline.
**Prefer a new runner over reconfiguring an existing one.** A general-purpose
runner often exists precisely to build images, and taking its socket away
breaks it. Register a second runner with a label of its own, and name that
label in the workflow, and if no runner carries the label yet, the job queues
rather than running somewhere unprepared, which is a better failure than a
green run on the wrong host.
### A label is not a boundary
That last paragraph describes routing, not containment, and the difference
matters enough to state on its own.
**`runs-on:` is in the workflow file, and the workflow file is in the branch.**
A branch can name any label it likes. So a dedicated label keeps *honest* jobs
on the runner you prepared; it does nothing against a branch that would rather
run somewhere else. A session that changes `runs-on: dejima-confined` to
`runs-on: ubuntu-latest`, pushes, and opens a pull request has stepped outside
every control on this page (no allowlist, no socket restriction, no firewall),
provided some runner answering `ubuntu-latest` will take the job.
Whether one will is a property of **where your runners are registered**, which
is the actual boundary:
| Registered at | Which repositories it serves |
| --- | --- |
| Instance | **every repository on the instance** |
| Organization | that organization's repositories |
| Repository | that repository |
Gitea has no per-repository runner ACL; its FAQ says so directly, and points
at registration scope as the control instead: gitea.com registers its runners
for one organization "so our runners will not execute jobs from other
repositories."
So the property to hold onto is this: **a repository's CI security posture is
that of the weakest runner it can reach, not the one its workflow names.** An
instance-level general-purpose runner is reachable from every repository on
that instance, including any that takes agent-authored branches.
### Pinning the workflow with `pull_request_target`
Half of this is fixable, and worth fixing: **`runs-on` does not have to come
from the branch.**
Under `on: pull_request` the workflow is read from the head of the pull
request, so the branch decides what runs and where. Under
`on: pull_request_target` Gitea reads it from the **base branch**, and
explicitly discards any `pull_request_target` workflow found on the head
commit: a branch can neither edit the base copy nor introduce its own. What
runs, and on which runner, is whatever `main` says.
**Adopting it takes two merges, not one.** The trigger only takes effect once
it is on the base branch, because that is where it is read from, and a
`pull_request_target` workflow on a pull request's head is explicitly ignored.
So the change that introduces it cannot be tested by it: with only
`pull_request_target` on the branch and only `pull_request` on `main`, the
head's copy is skipped, the base has nothing to contribute, and **no checks
run at all**, which looks like a broken workflow rather than a bootstrap
problem.
Carry both triggers through the first merge, then remove `pull_request` in a
follow-up. The first change is tested by its own copy from the head, as
today; the follow-up is tested by the copy now on `main`. In between, pull
requests get both an untrusted run from the head and a trusted one from the
base, noisy, and for the untrusted half no worse than the status quo, so
keep the window short.
Two more consequences to handle rather than discover:
- **The checkout no longer defaults to the code under test.** The run's ref is
the base branch, so a bare `actions/checkout` fetches `main` and the suite
passes without ever seeing the change. Jobs that test the branch must name
it: `ref: ${{ github.event.pull_request.head.sha }}`. Jobs that assert on
the *runner* should keep the bare checkout, so their assertions come from
`main` and a branch can neither weaken nor delete them.
- **The job runs with the base repository's token and secrets**, because the
forge treats it as trusted. On a forge where a same-repo `pull_request` run
already gets a permissive token this changes nothing, but it is why the
pattern carries warnings elsewhere: give such a run no secret you would not
hand the branch.
This does not make the repository safe on its own. A branch can still add a
workflow of its own with `on: pull_request`, which runs from the head with
whatever `runs-on` it likes. What it buys is that the *intended* CI is
trustworthy, and that escaping it means adding a file rather than editing
one: a much louder diff.
### Closing the rest: what a repository can reach
The rule underneath everything here:
> **Every runner reachable from a repository that receives agent-authored
> branches must be one you would accept an attacker running code on.**
Note what that does *not* say: nothing about what the workflows currently do,
because a branch can add the first one. **A project with no CI at all is still
exposed** on a forge where its branches can reach a runner - the case people
do not expect, because there is nothing in the repository to look at.
So ask one question before pointing a sandbox at a project: *what runners can
this repository's branches reach?*
**"Hosted runners only"** is the mildest case. The runner is disposable and is
not your machine, so "execution on a host you own" largely evaporates - no LAN
behind it, nothing of yours on it. What remains is credentials: give
pull-request runs no secrets they do not need and a read-only token
(`permissions: contents: read`), and attach no self-hosted runner.
**If self-hosted runners are reachable**, that is the sharp case. Three ways
to hold the rule, none of which require moving or renaming anything.
**1. Turn CI off for that repository, if it does not use CI.** No workflow
runs at all, so no runner is reachable and a branch cannot add the first
workflow. Turning it back on needs *admin* (Gitea puts repository settings
behind `reqAdmin()`), so a bot account with Write cannot undo it. For a
project not using CI this costs nothing, and it is the most commonly
overlooked option.
**2. Scope the dangerous runner down, rather than moving the repository out.**
The instinct is to move agent-developed repositories somewhere with only
hardened runners, which for a public project breaks its name, links and
ownership.
Invert it. A runner can belong to an **owner** - a user as well as an
organization - and then serves that owner's repositories. Gitea's model has
three cases: global (`OwnerID` and `RepoID` zero), org/user (`OwnerID` set),
repository (`RepoID` set). So:
- Make the *hardened* runner the owner-level default, so every repository gets
it.
- Register the *permissive* runner (the one with the Docker socket, or with
unrestricted egress) at **repository** scope, for only the repositories
that genuinely need it.
Most repositories need a runner that can run tests, not one that can build
images. Scoping the dangerous capability to the few that need it is usually a
handful of registrations, and nothing moves or is renamed.
**3. Do not grant push.** `[core] forge = none` with
`[core] workflow = local-only`. The session commits locally and stops; you
review the diff and push it yourself. No branch, no workflow, no runner, and
the vector is closed completely rather than bounded. The cost is one push
after the review you were going to do anyway, and it is the only option here
that does not depend on getting a forge's configuration right.
If none of the three is available for a given project, say so plainly:
pointing a sandbox at it means accepting that a compromised session can run
code on whatever that repository can reach.
## Egress
The goal is that a job cannot reach somewhere new (cannot fetch a next stage,
cannot post a repository to a paste service, cannot probe an internal address)
without a human having agreed to that destination first.
### Four network positions, only one of which is the job
Getting these confused produces either a broken build or a firewall that
protects nothing:
| Traffic | Whose namespace | Filtered in |
| --- | --- | --- |
| Job steps: `apt-get`, the checkout's fetch | the job container | `DOCKER-USER` (a `FORWARD` hook) |
| Job → the resolver on its bridge gateway | job container → the host itself | `INPUT` |
| Pulling the job's own image | the Docker daemon, on the host | the host's `OUTPUT` |
| `docker build` steps, where a job has a daemon | the daemon's own bridge | its own policy |
**Filtering only `DOCKER-USER` leaves the DNS allowlist decorative.** Traffic
to a resolver on the bridge gateway is addressed to the host, so it is an
`INPUT` packet and never passes through `DOCKER-USER` at all, and a job that
can reach any resolver it likes can resolve anything it likes. `INPUT` needs
its own pair of rules: accept port 53 from the bridge, drop the rest.
The upside of the split is that a job's own needs are usually small, because
image pulls and action fetches happen from the host rather than from the job.
For a suite that installs a few packages and checks out a repository, the job
needs the forge and a package mirror, and nothing else.
### A fixed network for jobs, a resolver, and both firewall chains
Point `container.network` at a network you created, with a named bridge, so
that the interface name, subnet and gateway are stable enough to write rules
against. Left empty, `act_runner` creates a network per job and there is no
stable interface to match on. Give it a **private** subnet: `stop-dns-rebind`
below rejects answers in the private ranges, so a reserved-but-not-private
bridge subnet is one that check silently does not cover.
With `runner.capacity: 1` one job occupies that network at a time. Above one,
concurrent jobs share a broadcast domain and can reach each other's service
containers. Check that before raising it.
The DNS and firewall shape is the same one dejima uses inside its own
container (`docker/dns-proxy.sh` and `docker/init-firewall.sh`), applied one
layer out: allowlist the *names* and let the answers populate the set of
allowed *addresses*.
`dnsmasq` binds the bridge gateway. With `no-resolv` and one `server=` line
per allowed name, every other name gets `REFUSED`, not resolved-then-blocked,
refused, which closes off encoding data into query names to a domain you never
allowed. `ipset=/#/ci-allow` adds every answer it serves to the firewall's
set, so a destination that rotates its edge addresses stays reachable without
a resolver cron chasing it. `rebind-domain-ok` exempts a self-hosted forge,
which legitimately resolves to a private address.
The runner pins its job containers at that resolver - per-runner rather than
daemon-wide, because a daemon-wide setting also reaches containers a
privileged job creates over the Docker socket, which have firewalls of their
own and no route to it. Then two chains filter: `DOCKER-USER` for everything
the job forwards off the bridge, and `INPUT` for the resolver itself, because
the gateway address belongs to the host.
Block IPv6 outright unless you have built the same allowlist for it: an
address family with no allowlist is not a smaller hole than the one you just
closed.
**The working configuration for all of this is in
[dejima-ci-hardener](https://gitbox.branderson.io/dejima/dejima-ci-hardener),
with the ordering constraints and substitution notes attached.** It is
deliberately not reproduced here: two copies of a firewall rule drift, and
dejima-ci-hardener is the one that stays runnable.
## The allowlist comes from `main`, not from the checkout
A dejima project already carries a version-controlled statement of where it is
allowed to reach: `.dejima/allowed-domains`. Use it: read from
**`origin/main`**, over the forge's API, by the runner host.
Never from the checkout. The checkout is the branch's, and a branch that can
write its own allowlist has no allowlist. `main` is yours: it got there
through a pull request you merged.
```sh
curl -fsS -H "Authorization: token $FORGE_TOKEN" \
"https://gitea.example.com/api/v1/repos/$OWNER/$REPO/raw/.dejima/allowed-domains?ref=main"
```
The consequence is the point of the whole arrangement: **widening what CI can
reach is a diff on `main`.** It is reviewed, it is attributable, it is
revertible, and it happens in the same file and by the same act that widens
what a session can reach. A prompt injection that needs one more destination
has to get that destination past you in a pull request first.
Refresh on a timer, render the `server=` lines, reload `dnsmasq`. Fail closed:
a fetch that errors should leave the previous allowlist in place, never fall
back to allowing everything: an allowlist builder is exactly the kind of
script whose failure mode is easy to get backwards, because the broken version
looks like a working one right up until it matters.
dejima-ci-hardener's `dejima-ci-allowlist` is that, including the distinction
that costs you here: a repository with no declaration is a 404 and ordinary,
while a 401 or a 500 means this run does not know what the allowlist is and
must not render one.
Two things to be deliberate about:
- **The base set should be narrower than dejima's built-ins, not equal to
them.** A domain judged reasonable for a sandbox was judged as reachable
from inside a firewalled container. On a runner the same name is reachable
from a host on your network. `api.anthropic.com`, for one, has no business
resolving on a runner.
- **One file now grants in two places.** Merging a domain into
`.dejima/allowed-domains` grants it to sessions *and* to CI. That is the
price of a single reviewable grant instead of two lists that drift apart,
and it is worth saying out loud when you review one.
You can shrink what the host itself needs, too: `runner.github_mirror` and
Gitea's `DEFAULT_ACTIONS_URL` both redirect `uses:` action fetches away from
github.com.
## Credentials a run is handed
**The job token.** Every job gets one, asked for or not. On Gitea 1.27 and
later you can set the repository's default to restricted mode (Repository →
Settings → Actions → General): read-only on `code`, `releases` and
`packages`, nothing else. The property that makes it worth having is that
workflow-level `permissions:` is *clamped* by that maximum, so a branch
raising its own cannot exceed it: it is one of the very few controls here
that the branch genuinely cannot touch.
Before 1.27, `permissions:` is ignored and the token is permissive. There is
no supported way to narrow it, which is what makes branch protection above
non-optional rather than merely advisable, and it is a concrete reason to
upgrade.
**Secrets.** A secret exposed to a pull-request-triggered run is a secret
exposed to whatever that branch contains. Give a run only what it needs, and
prefer credentials that cannot do anything else: a token that raises an
anonymous API rate limit should be a fine-grained token with no permissions
and no repository access, not a general-purpose one.
**The cache server.** `act_runner` enables it by default, and it binds on the
runner host specifically so that job containers can reach it. That is an
inbound service exposed to untrusted code. Turn it off where it is not
earning its place.
## CI you do not administer
On hosted CI (GitHub Actions, a shared pool, someone else's infrastructure),
**there is no reliable pre-execution gate for a same-repository branch**, and
the egress section above is unavailable to you: every candidate gate lives in
a file the branch can edit, and the network the job runs on is not yours to
program. That is a limit, not an oversight, and it is better known than
guessed at.
What still applies:
- **A hosted runner gives you the runner-host section for free.** It is
disposable and it is already off your network. The dangerous case is a
*self-hosted shared pool*, where a job can land on a machine nobody hardened,
and where the socket-mounting default above is nobody's decision, just the
default.
- **The credentials section applies everywhere.** `permissions: contents:
read` where it is honoured, no secrets on pull-request runs, and no tunnel
or credential that lets a runner reach your own infrastructure.
- **Branch protection applies everywhere** and costs nothing.
- **So does declining the grant.** If you cannot bound what CI does with push
access, do not give push access: `[core] forge = none` with
`[core] workflow = local-only` is a supported configuration in which the
session commits locally and stops. The push grant *is* the CI
code-execution grant; declining the first declines the second.
Gitea 1.27's **scoped workflows** apply here: a workflow maintained in a
central repository the sandbox cannot push to, registerable instance-wide,
so the workflow file itself sits outside any agent branch's reach. Use it, but it is not a gate. Scoped workflows run *alongside* a
consuming repository's own workflows rather than replacing them, so a branch
can still add a workflow of its own.
## What this does not stop
- **Code still runs.** Everything here bounds *where* and *with what*, not
*whether*. There is no point in the flow at which a human reads the diff
before execution.
- **A repository with no workflows is not therefore safe.** A branch can add
one, so the exposure follows from which runners the repository can reach,
not from what it currently runs. Disabling the Actions unit is what makes
"this project has no CI" into a property rather than a coincidence.
- **An allowed destination is still an exfiltration channel** through its own
request surface: a query string, a lookup, an issue body. Domain
allowlisting closes off reaching somewhere *new*; it cannot inspect what is
sent somewhere already trusted. That is a structural property of
allowlist-only filtering, and it is the same caveat
[docs/security.md](security.md) states for the sandbox itself.
- **The repository is not protected by any of this**, and does not need to be.
A session that can push already has the code; CI does not newly expose it.
- **A job on a runner that legitimately needs the Docker socket owns that
host** for as long as it takes to rebuild it. Where a job genuinely needs a
daemon, the answer is a host you are willing to lose, not a smaller grant.
- **Before Gitea 1.27, the job token stays permissive.** Branch protection
bounds where it can write; it does not take the token away.
- **None of this is checked by anything.** The rest of dejima's security
properties have tests behind them. A runner's configuration lives on a
machine this repository has no access to, so it is on you to re-check it
after upgrades and rebuilds.
+512
View File
@@ -0,0 +1,512 @@
# Configuration
[← dejima README](../README.md)
Day-to-day configuration once dejima is installed and running: what a
project declares versus what you approve, config parity with your host agent
harness setup, a different editor, writable host directories, shared
scratch space, frontend testing, and what the sandbox knows about itself.
## Configuration reference
Every key dejima reads, its default, and what it is for. All of it lives in
one file, `~/.config/dejima/config`. `./install.sh` writes a starter copy with
every key present and commented out at its default, so nothing here has to be
set to get a working sandbox: uncomment what you need. `dejima --print-config`
prints the resolved schema with real values, which is the fastest way to see
what a given launch will actually use.
| Key | Default | Purpose |
| --- | --- | --- |
| `[core] harness` | `claude-code` | agent harness the container runs |
| `[core] forge` | `none` | forge integration: `gitea` (`gitea-pr`/`gitea-issue`), `github` (`gh`, opens `ssh.github.com:22` for push), or `none` (no forge at all) |
| `[core] workflow` | `local-only` | workflow `sandbox-context.sh` tells the agent to follow: `worktree-pr` (push + open a PR), `branch-push` (push, stop at the branch), or `local-only` (commit locally, push nothing) |
| `[core] command` | `claude` | container's main process (e.g. `nvim`), also settable per launch with `--cmd` |
| `[core] firewall` | `on` | egress firewall; `off` disables it (debugging only), also settable per launch with `--no-firewall` |
| `[core] share_dir` | `~/.local/share/dejima/shared` | host-visible read/write scratch dir, mounted at `~/shared` |
| `[core] ssh_key` | unset | dedicated deploy key for headless/unattended git push |
| `[core] known_hosts` | `~/.ssh/known_hosts` | known-hosts file used for git-over-ssh |
| `[harness.claude-code] projects_dir` | `~/.claude/projects` | where Claude Code session transcripts live |
| `[harness.claude-code] statusline_path` | `~/.claude/statusline.sh` | statusline script path |
| `[forge.gitea] ssh_host` | unset | Gitea/Forgejo SSH hostname: skip if you only push to GitHub |
| `[forge.gitea] api` | unset | `https://<gitea host>/api/v1`: skip if you only push to GitHub |
| `[forge.gitea] token_file` | `~/.config/gitea/token` | path to the Gitea/Forgejo API token (the file, not the token itself) |
| `[forge.github] push_token_file` | unset | path to a **write-capable** PAT, used only when `[core] forge = github`: deliberately separate from `[github] token_file` below; see "GitHub push" in docs/security.md |
| `[github] token_file` | `~/.config/gh/sandbox-token` | path to a read-only PAT for private GitHub repos / `gh` |
| `[mounts] readonly` | unset | extra host paths (colon-separated) a personal config needs, mounted read-only at their own path |
| `[mounts] read_write` | unset | host paths (colon-separated) mounted **read-write** at their own path: for directories meant to outlive the session; refused if they overlap anything dejima needs out of a session's reach |
| `[mounts] nvim_config` | unset | nvim config dir, used when `[core] command = nvim` |
| `[credentials] proxy` | `on` | host-side credential proxy; `off` mounts the real OAuth credential into the container directly, as before this feature |
| `[credentials] transport` | `tls` | set to `loopback` to fall back to a plaintext forwarder with an explicit `ANTHROPIC_BASE_URL`, keeping the credential out of the container but reinstating the base-URL fingerprint: for when certificate pinning breaks the default TLS path (see [security notes](docs/security.md)) |
| `[logging] loki_url` | unset | ship DNS/firewall logs to a Loki endpoint |
| `[logging] conversation` | `off` | opt-in: also ship session transcripts to Loki (needs `loki_url`); see "Conversation logging (opt-in)" in docs/security.md |
| `[audit] dir` | `~/.local/share/dejima/audit` | where exit-time audit findings are stored |
| `[audit] baselines_dir` | `~/.local/share/dejima/baselines` | where audit baselines are stored |
| `[audit] keep` | `10` | how many past findings/baselines to retain |
| `[audit] strict` | `off` | refuse a headless (non-tty) launch over unacknowledged CRITICAL findings |
| `[remote-access] enabled` | `off` | the switch: nothing in "Remote access" below runs until this is `on` |
| `[remote-access] ntfy_url` | unset | ntfy topic URL for session notifications |
| `[remote-access] base_url` | unset | public URL of the web front door, for notification links |
| `[remote-access] ttyd_port` | `7681` | port `ttyd` listens on |
| `[remote-access] ttyd_font_size` | unset | `ttyd` terminal font size |
| `[remote-access] debounce` | `60` | seconds between repeat notifications for the same session |
**No forge is the default**: `[core] forge = none` with
`[core] workflow = local-only`, so the sandbox works locally and pushes
nowhere. Every other workflow needs forge credentials, which is what you are
opting into when you pick one.
## Global and per-project domains and dependencies
Allowed domains and dependencies each stack through their own layers. The
layers **add** to each other, so a narrower layer can only widen what a
broader one already permits: nothing at the project layer can withdraw a
built-in domain, and nothing at the container layer can withdraw a project
one. Ordering them least specific first is how they are listed below and how
they are applied, and it means the set a session gets is the union of every
layer that applies to it, never an override of one by another.
| Config | Layers, least specific first |
| --- | --- |
| Allowed domains | built-in → global → project → container (one session) |
| Dependencies | base image → global → project |
Dependencies have no container-only scope: installing one always needs an
image rebuild, so there's no `dejima-allow`-style live-session patch
for them.
A project can declare what it needs in two committed files:
```
.dejima/allowed-domains # one domain per line
.dejima/dependencies # <apt|npm|pip|cargo>:<name> per line, or a
# tarball:<name>@<sha256>:<url> release archive
```
### Tarball dependencies
For a toolchain no package ecosystem ships in a version you can use - a
current Go, say, where Debian's is several releases behind - a dependency can
name a release archive instead:
```sh
dejima-dep add --global tarball:go@<sha256>:https://go.dev/dl/go1.25.0.linux-amd64.tar.gz
```
Fetch the archive on the host and hash it. Do not take the digest from the
same page that serves the download unless you trust that page for both:
```sh
curl -fLO https://go.dev/dl/go1.25.0.linux-amd64.tar.gz
sha256sum go1.25.0.linux-amd64.tar.gz
```
Where a project publishes a checksums file or signs its releases, verifying
against that beats hashing whatever arrived: it says the archive is the one
the project published, not the one your connection received.
It unpacks with `--strip-components=1` into `/opt/dejima/<name>`, and
`/opt/dejima/<name>/bin` goes on `PATH` - one convention covering the usual
shapes without layout options, since a Go archive is under `go/` and an nvim
release under `nvim-linux-<arch>/`. Compression is detected, so `.tar.gz`,
`.tar.xz` and `.tar.zst` all work.
**The digest is mandatory** and is checked before anything is unpacked.
Without one you would approve a *name* whose contents the far end can change
afterwards. Two consequences:
- **A tarball dependency locks the project to one architecture**, because a
digest does. A package name resolves per-host and a digest cannot: it names
one archive, built for one architecture. There is no per-architecture
tarball spec today, so a project that declares one is a project that builds
on machines of that architecture and no others. Track that at
[#97](https://gitbox.branderson.io/dejima/dejima/issues/97).
- **Upgrading is an approval, not a version bump.** A new release is a new
digest, so it goes through `dejima-dep add` and `dejima-project approve`
again. That is the mechanism working, not friction to route around.
The URL's character set is deliberately narrower than URLs allow: the spec is
interpolated into a `RUN` line that runs as root at build time, so anything
shell-active is refused outright rather than quoted around, and only `https`
is accepted.
**Declaring grants nothing.** The project directory is bind-mounted
read-write, so a session could write these files; they are read only to
compare against reality and suggest a command. Approvals live outside the
container under `~/.config/dejima/` (`$DEJIMA_CONFIG_DIR` relocates the
tree) - global at its root, per project under `projects/<slug>/` - written
only by tools you run on the host:
```sh
dejima-project status ~/myproject # declared vs approved vs missing
dejima-project approve ~/myproject # review and approve the declared set
dejima-allow --project ~/myproject crates.io
dejima-allow --global crates.io # applies to every project
dejima-dep add --project ~/myproject cargo:cargo-nextest
dejima-dep add --global cargo:cargo-nextest
```
Two of those tools also write the declaration, so an approval you make by
hand does not stay invisible to everyone else on the project:
`dejima-allow --project` appends to `.dejima/allowed-domains`, and
`dejima-dep add --project` appends to `.dejima/dependencies`. Both write the
approval first - that is the part that grants - and report separately on the
declaration, so a project you can't write to still gets you the approval.
`--global` writes no declaration at all: there is no project to declare in.
The declaration those two write still grants nothing; it is the same
untrusted file the section above describes, and writing to it does not
promote it. That it is session-writable is exactly why both tools refuse to
write a `.dejima` directory or a `.dejima/<kind>` file that is a **symlink**.
The refusal is the `open` itself rather than a check before it, so it holds
even against a session that is still running while you type the command. See
[Declaration files are session-writable](security.md#declaration-files-are-session-writable)
for what that defends against and how. The same write
reads the file back to skip an already-declared entry, and that read is
capped - a session can make its own `.dejima/<kind>` arbitrarily large for
free, and an oversized one is refused rather than loaded into your memory.
Neither writer has an inverse for the declaration. `dejima-dep remove
--project` withdraws the approval and deliberately leaves the declaration
alone (it says so, since `dejima-project approve` will otherwise offer the
entry again); `dejima-allow --project` has no `remove` at all. Taking
something back from the project itself means editing `.dejima/` and
committing that.
`dejima-project status`/`approve` classify a declared entry against
the union of global and project approvals, so something already approved
globally shows as approved, not pending. Unlike `dejima-allow`,
`dejima-dep`'s `--global` and `--project` are mutually exclusive -
an entry belongs to exactly one scope. `dejima-dep` has no `--container`
scope either, and refuses the flag rather than ignoring it: a dependency
needs an image rebuild, so there is nothing to patch in a live session.
Approved domains join the allowlist at the next launch. `dejima-allow` can
also patch a running session immediately, but only when it is run bare or
with `--container` - approving here (`--project`/`--global`, with no
`--container`) never touches one; see
[Adding a domain to a running session](security.md#adding-a-domain-to-a-running-session).
Approved dependencies are baked into a derived image built `FROM` the shared
base - first `dejima:<username>-global`, if the global store has any
dependency approved, then `dejima:<username>-<slug>` on top of that,
if the project store does - so they need an image rebuild: they take effect
on the **next** launch.
Because of that rebuild, the usual moment to approve is *after* leaving the
sandbox - so persistence (`--global` or `--project`) never requires a running
container.
`dejima-project approve` never attempts to patch a running session: it only
ever calls `dejima-allow` without `--container`, so recording the approval
is the only thing it does. If an individual approval can't be recorded - an
unwritable store, a slug collision, a corrupt approval list - it reports
that entry as not approved and keeps going, rather than stopping at the
first failure; that holds for dependencies and domains alike, so one bad
entry can't cost you the rest of the set. It then exits non-zero if anything
failed, so `dejima-project approve ~/myproject && dejima` won't launch a
session missing the approvals you just confirmed.
A session whose project declares something it doesn't have is told so in its
startup context, with the exact command to hand you. Project approvals are
inherited by subdirectories, so a git worktree under the project gets its
parent's; global approvals apply everywhere regardless.
## Config parity with your host agent harness setup
What carries over from your host into the container, and what deliberately
does not. The mechanism is the same whichever harness `[core] harness`
selects; the specific paths below belong to the one harness shipped today.
### Claude Code
Global `CLAUDE.md`, settings, hooks, skills, and plugins all carry over
automatically, and your existing login stays effective too - no separate
first-run login, though the credential itself never enters the container
(see [security notes](security.md) for the host-side proxy that makes
that work).
`~/.claude` itself is *not* shared as a whole: its contents are enumerated
in `docker-compose.yml`, config files are copied in rather than mounted,
and anything not listed is container-local - writable during the session,
gone with the container. Session transcripts, plans, and the edit-undo
record are on the list, so `claude --resume` on the host still works; a
credential, cache, or newly-added config file is not. The
[security notes](security.md) explain the split and why single-file
mounts couldn't be trusted here. (`~/.claude.json` is handled differently
again - a throwaway per-session copy, not the real file.)
The project directory is mounted at the *same absolute path* inside the
container as on the host, so Claude Code's session-history keying lines up
between host and container: a session's transcript is visible on the host
afterward, and `claude --resume <session-id>` should find it there. (Whether
a bare `claude --resume` with no ID picks it as the "most recent" session
depends on `~/.claude.json`'s own bookkeeping, which - per the throwaway-copy
design above - doesn't carry over from a sandboxed session.)
The container's own user matches yours exactly - username, uid, gid, and
home directory (see `Dockerfile`) - so `$HOME`-relative paths (dotfiles,
Claude Code's plugin bookkeeping) resolve the same on both sides. Defaults to
`node`/1000/1000/`/home/node` if you build the image directly instead of via
`dejima`. Images are tagged per-user (`dejima:<username>`)
so concurrent builds on a shared machine don't clobber each other.
Your global `~/.gitconfig` also carries over (as a throwaway copy, so commits
made inside the sandbox get the right identity, but nothing written to it
during a session reaches your real file). Falls back to an empty config if
you don't have one.
Caveat: MCP servers or hooks that shell out to host-specific binaries (a
browser, a language runtime not installed here) will not work until that
tooling is installed in the image. Add it with `dejima-dep` rather than by
editing the base `Dockerfile`: a dependency added that way is declared,
approved and layered like any other, and it survives a rebuild. Editing the
Dockerfile directly puts it outside that record.
## Headless git push without agent forwarding
**The reason to do this is identity, not uptime.** A forwarded agent
authenticates the session **as you**: every commit and every pull request it
produces is yours, and for as long as it runs it can reach whatever that key
can reach. A key of its own, belonging to an account that is not you, gives
the session an identity you can scope, revoke and read in a log separately
from your own. See
[To your forge, the sandbox is you](security.md#to-your-forge-the-sandbox-is-you)
for why that matters more than it first appears.
Running unattended is the second reason and the lesser one. Agent forwarding
ties SSH access to whichever client is currently connected, so it dies when
that client disconnects and takes git-over-ssh with it for anything still
running headless.
Either way, generate a dedicated, purpose-built keypair:
```sh
ssh-keygen -t ed25519 -N '' -f ~/.ssh/dejima_ed25519
```
Passphrase-less is deliberate - this is meant to be a low-privilege
credential living only inside the container, not your personal identity. Add
the public half as a Gitea deploy key scoped to just the repos that need
push access, then set it in the config:
```
[core]
ssh_key = ~/.ssh/dejima_ed25519
```
`entrypoint.sh` loads it into a container-local `ssh-agent` at startup and
deletes the plaintext copy immediately after - the sandbox user itself never
has a path to the raw key, only the agent socket (see
[security notes](security.md) for the full mount rationale). Setting
`[core] ssh_key` means a live forwarded agent is no longer required at all.
## Using a different editor/application
```sh
dejima --cmd nvim ~/repos/some-project
```
Runs unconfigured unless you also set `[mounts] nvim_config` in the config to
a real config directory on the host (bind-mounted read-only at
`~/.config/nvim` - lazy.nvim's lockfile is the one exception, layered
read-write on top so plugin syncs don't fail on a read-only filesystem;
nothing else in the config is writable):
```
[core]
command = nvim
[mounts]
nvim_config = ~/.config/nvim
```
Set both in `~/.config/dejima/config` if you use this mode regularly, or pass
`--cmd nvim` per launch once `nvim_config` is set.
The lockfile is layered on as a **copy**, not as your file: `bin/dejima`
mounts a `mktemp` duplicate and deletes it afterwards, so a plugin sync
inside a session cannot rewrite the host's `lazy-lock.json`.
`~/.local/share/nvim` (plugin source and `mason/` binaries), `~/.local/state/
nvim` and `~/.cache/nvim` are **Docker volumes, not host directories**. Your
own nvim data is never mounted, so nothing a session installs can reach the
editor you run outside the container. They do persist across containers,
which is what stops every launch from reinstalling plugins - so they are
named per project. Under one shared name, a session that wrote a Lua file
under `lazy/`, or replaced a binary under `mason/bin/`, would leave something
the next session in an *unrelated* project executed. Nothing there is
protection against a session tampering with its own project's plugins, which
it can do freely and which only ever runs inside the sandbox.
If your config references something outside that directory by absolute host
path (a helper script in a dotfiles `bin/`, say), bring it in with
`[mounts] readonly` (colon-separated, mounted read-only at the same path):
```
[mounts]
readonly = ~/dotfiles/bin
```
## Writable host directories
`[mounts] read_write` is the same thing without `:ro` - for a directory a
session is meant to write to and whose contents should outlive the container:
```
[mounts]
read_write = ~/specs
```
Both lists mount at the host path they name, so a convention that says
"specs live in `~/specs`" keeps working inside the container without being
rewritten. That is the difference from `[core] share_dir`, which is
mounted at `~/shared` regardless of where it lives on the host because its job
is to be a well-known handoff point.
An entry is **refused, not warned about**, if it overlaps anything dejima
relies on being out of a session's reach: the approval store, either audit
store, `~/.claude`, `~/.local/bin`, dejima's own checkout, or the project mount
(already read-write, so listing it again is a mistake worth surfacing). A
writable mount over any of those would let a session approve its own domains,
delete the audit findings naming it, or plant code that runs unsandboxed on the
host next time you start an agent. Overlap is checked in both directions after
resolving symlinks, so `read_write = ~` is caught too.
Sessions are told which directories these are, so "everything else is
disposable" stays true in the briefing rather than becoming a trap.
### One-off mounts for a single launch
`--extra-readonly PATH` and `--extra-read-write PATH` add a path for one run
without editing the config. `--ro` and `--rw` are the short forms. Both are
repeatable, one path per occurrence:
```sh
dejima --ro ~/other-project ~/myproject # read while migrating code out
dejima --rw ~/scratch --ro ~/reference ~/myproject
```
They **append** to the config's lists rather than replacing them, and they
are the same lists, so everything above applies unchanged: the existence
check, the refusal when a path overlaps something dejima keeps out of a
session's reach, the refusal when one path appears in both lists, and the
briefing sentence naming what a session can write. A flag says *where*, not
*whether*, and there is no flag that waives a refusal.
Two differences from the config form, both because a command line is not a
file. A relative path is accepted and made absolute against the working
directory, which a config file has no equivalent of. And a path containing a
colon is refused outright: both lists are colon-separated, so such a path
cannot be represented in one at all and would otherwise split into two
mounts that each fail the existence check.
## Shared scratch space
`~/shared` is a read-write bind mount of `[core] share_dir` (default
`~/.local/share/dejima/shared`, created automatically) - separate
from the project mount, for passing files (screenshots, temp scripts) in and
out of a session without touching the actual checkout. Persists across
sessions since it's a real directory, not a container volume.
Treat it as a handoff point, not as general-purpose storage. If your own
project instructions tell agents to write somewhere at a fixed host path -
design docs, scratch notes, anything meant to outlive the session but stay out
of the project checkout - mount that directory instead, with
`[mounts] read_write`. It arrives at its own absolute path, so a convention
that says "put specs in `~/specs`" keeps working inside the container without
being rewritten to point at `~/shared`.
`~/shared`, the project mount, and anything in `[mounts] read_write` are the
only writable host paths in the container. Everything else a session writes
goes away with it.
**Clear it out periodically.** It persists across sessions by design, which
means anything you left in it is readable by every session after the one you
left it for. A credential dropped in there to get one job done, and then
forgotten, is a credential you have handed to every session since.
## Testing frontend changes
Playwright (Chromium) is pre-installed at image build time, browser
included - `require("playwright")` works from any script, no network access
or per-project `npm install playwright` needed.
`localhost`/loopback traffic never goes through the egress firewall, so a
project's own dev server needs no firewall changes. A real external site
needs to be on the allowlist like any other network use
(`dejima-allow <domain>` for a running session, add
`--global`/`--project <path>` to persist it) - the firewall applies to the
browser's traffic exactly like `curl`/`git`.
If a project pins its own `playwright`/`@playwright/test` version, its
`npm install` may try to download a different Chromium build from
Playwright's CDN, which isn't allowlisted by default - allow it the same way
as any other one-off domain need.
Chromium's own process sandbox isn't actually active in this image (its
setuid helper isn't setuid here) - not a concern, since the container itself
is already the boundary that matters, and a compromised session already has
full shell access as the container's own user regardless.
## Sandbox self-awareness
### Project instructions: `.dejima.md`
`.dejima.md` at a project's root is that project's agent-instruction file -
the same role `CLAUDE.md` plays for Claude Code - under a name that doesn't
name a harness. dejima loads it into the session's opening context the way the
harness would load its own, so a project's instructions apply whichever agent
is running.
The reason for a second name: Claude Code finds a `CLAUDE.md` by itself, and
nothing else does. As dejima grows the ability to run agents that aren't Claude
Code, a project that wants to say "here is how to work on me" needs one file
that reaches all of them - so dejima delivers it rather than leaving each
harness to go looking for its own.
Loaded verbatim, capped at 16 KB with any truncation noted in the text. It
carries no authority over the sandbox itself: the file sits on the read-write
workspace mount, so nothing in it widens the firewall, relaxes a guard, or
authorises a push, and the text delivered around it says so.
This repo ships one, describing which half of its own test suite a sandboxed
session can run.
### The session briefing
A `SessionStart` hook (`~/.claude/hooks/sandbox-context.sh`) injects a
reminder when `DEJIMA` is set: the session is running in a
disposable, firewalled container and should stay within the task at hand -
if something genuinely needs to break out (broader network, host changes,
GUI/hardware), stop and ask rather than trying to work around the firewall,
since the same session can be resumed unrestricted on the host via
`claude --resume`. No-op outside the container. `deny-dangerous-bash.sh`
(`PreToolUse`) also applies here, since `~/.claude/hooks` is mounted in.
`git-push-guard.sh` (`PreToolUse`/`Bash`, also `DEJIMA`-gated) blocks
any `git push` whose resolved remote isn't the Gitea host, and any push to
`main`/`master` even there. Like the other hooks, this is a fast-fail guard
against an overeager agent, not a hard security boundary - a session has full
shell access and could in principle edit `.git/config` to route around it.
GitHub push is blocked by default too (see
[GitHub push](security.md#github-push-blocked-by-default-optional-opt-in) in
docs/security.md) unless `--allow-github-push` is given. For a real guarantee
against pushes to main/master, use branch protection on the remote itself.
`merge-guard.sh` (`PreToolUse`/`Bash`, `DEJIMA`-gated) blocks
`gitea-pr merge` inside the sandbox. It's the API-side counterpart to the rule
above: a merge puts a branch on `main` without ever performing a push, so
`git-push-guard.sh` never sees it. A hook is handed a command rather than a
conversation, so it can't tell an instructed merge from an unprompted one -
which is why it's scoped to the sandbox rather than applied everywhere. On the
host it does nothing, and you can still ask an agent to merge. The same
fast-fail caveat applies with more force than usual here: the session has the
Gitea token in its environment and could call the merge endpoint with `curl`
directly. Branch protection on the remote is the real guarantee.
`sandbox-context.sh` also tells the agent the actual workflow to follow: do
new work in a `git worktree` under the mounted project (e.g.
`.worktrees/<branch>`, never `../<branch>` or elsewhere under `$HOME` - only
the bind-mounted path is backed by real host storage, so a worktree created
outside it silently vanishes with the container), then push the feature
branch and run `gitea-pr create`.
+133
View File
@@ -0,0 +1,133 @@
# Development
[← dejima README](../README.md)
The file-by-file map of this repository, and the detail behind its test
suites: what each one covers, whether it needs a Docker daemon, and how to
run them.
## Files
| File | Purpose |
| --- | --- |
| `docker/Dockerfile` | Base image, dev tools, nvim, Claude Code + wrapper, `gitea-pr`/`gitea-issue` |
| `docker/fixed-domains.sh` | Shared domain list sourced by both `init-firewall.sh` and `dns-proxy.sh` |
| `docker/dns-proxy.sh` | Starts the DNS-allowlisting `dnsmasq`, repoints `resolv.conf` at it |
| `docker/init-firewall.sh` | IPv4/IPv6 egress allowlist firewall, run at container start |
| `docker/ulogd.conf` | `ulogd2` config: turns `init-firewall.sh`'s NFLOG-tagged packets into JSON |
| `docker/log-shipper.sh` | Tails the dnsmasq/ulogd2 logs and ships them to the configured Loki endpoint (`[logging] loki_url`); also ships session transcripts when conversation logging (`[logging] conversation`) is on |
| `docker/entrypoint.sh` | Runs as root: DNS proxy, firewall, logging, drops to the sandbox user via `gosu`, then execs the session's command |
| `docker/claude-wrapper.sh` | Shadows `claude` on `PATH` to always add `--dangerously-skip-permissions` |
| `docker/docker-compose.yml` | Wires up mounts/env; invoked via `bin/dejima`, not directly |
| `hooks/sandbox-context.sh` | `SessionStart` hook: injects sandbox context/workflow reminders, and any `.dejima.md` the project ships |
| `.dejima.md` | Project-level agent instructions, harness-agnostic: `CLAUDE.md`'s role under a neutral name. dejima loads a project's into the session context; this one is dejima's own |
| `hooks/git-push-guard.sh` | `PreToolUse` hook: blocks pushes outside the allowed host(s)/branches |
| `hooks/deny-dangerous-bash.sh` | `PreToolUse` hook: blocks obviously destructive Bash commands |
| `hooks/merge-guard.sh` | `PreToolUse` hook: blocks the configured forge's merge command (`DEJIMA_MERGE_BLOCK`) in the sandbox: merging is a human decision |
| `hooks/dejima-notify.sh` | `Notification`/`Stop` hook: drops an empty `waiting`/`done` marker for a remote-enabled session - see [docs/remote-access.md](remote-access.md) |
| `bin/dejima-attach` | Lists live sessions and attaches to one; the shared core behind both remote front doors |
| `bin/dejima-audit` | Advisory exit-time diff of host-persistence surfaces (git config, `.claude/`, etc.). `dejima-audit show` / `dejima-audit ack` default to the enclosing repo |
| `bin/dejima-dep` | Review and approve project dependencies declared in `.dejima/dependencies` |
| `bin/dejima-provider-lib.sh` | Parses forge/workflow/harness manifests and composes the session briefing; not on PATH: see [docs/providers.md](providers.md) |
| `forges/`, `workflows/`, `harnesses/` | Provider directories for the three axes selected by `[core] forge`/`workflow`/`harness` (data, never sourced or executed); see [docs/providers.md](providers.md) |
| `forges/gitea/bin/` | The gitea forge's own CLIs, baked into the image so the sandbox can act on them: `gitea-pr` (create a PR, read its comments, merge it), `gitea-issue` (list, view, create, comment on, close and reopen issues) and `gitea-actions` (read CI runs and job logs) |
| `bin/dejima-notify-watch` | Host-side watcher: turns markers into ntfy pushes composed entirely from host data |
| `bin/dejima-project` | Review and approve project domains and dependencies from the host |
| `bin/dejima-project-lib.sh` | Shared library sourced by `bin/dejima-dep`, `bin/dejima-project`, `bin/dejima` and the remote-access scripts; not on PATH |
| `bin/dejima-slug-lib.sh` | The `~/.claude/projects` slug scheme and which slugs belong to a workspace; sourced by `bin/dejima` (what to mount) and `bin/dejima-audit` (what counts as cross-project), so the two cannot disagree. Not on PATH |
| `bin/dejima-ttyd` | Launches `ttyd` on loopback in front of `dejima-attach` |
| `systemd/*.service` | User units for `dejima-ttyd` and `dejima-notify-watch`; ship disabled, installed by hand |
| `docs/providers.md` | The manifest contract for the forge/workflow/harness axes, key by key, with the shipped manifests as worked examples |
| `docs/ci.md` | What push access grants a session on your CI host, and how to bound it - runner hardening, and an egress allowlist read from `main` |
| `docs/nginx-dejima.conf.example` | Reverse-proxy template for the web front door; deliberately not loadable as written |
`bin/dejima` is the entry point: it resolves the project path,
checks for a live `ssh-agent`, reads `GITEA_TOKEN`/`GH_TOKEN` from the token
files named in the config, discovers which host directories need mounting for symlinks under
`~/.claude` (`hooks`, `skills`, `commands`, `agents`, `output-styles`, and
top-level entries like `statusline.sh`) to resolve, and runs
`docker compose run`. `bin/dejima-allow`
adds a domain to a running session's firewall from the host - see
[Adding a domain to a running session](security.md#adding-a-domain-to-a-running-session).
`install.sh` installs the hooks into
`~/.claude/hooks` and registers them in `~/.claude/settings.json`.
## Tests
`test/` has a [bats](https://github.com/bats-core/bats-core) suite covering
the security invariants the README states, not a substitute for reading it,
but a way to actually catch a future change silently breaking one of them
instead of finding out the hard way.
`static.bats` greps the repo's own files - no sudo in the `Dockerfile`, every
`ipset add` is duplicate-safe, no `docker.sock`/host-credential mounts, the
`~/.claude` mount inventory is exactly what it should be, `dejima-allow`
rejects a malicious domain argument. `runtime.bats` builds the image, boots
a real container running the actual `entrypoint.sh` (a `SANDBOX_TEST_MODE`
env var makes it stop just short of execing the session's command, so the suite can attach
to a fully-initialized container instead of reimplementing its own copy of
the setup sequence), and asserts against it: IPv6 blocked, DNS/IP allowlist
enforcement in both directions, GitHub read-only, no working `sudo`/
`iptables`/`init-firewall.sh` as `node`, capabilities actually stripped
(not just absent from a `docker exec` that doesn't have them to begin
with), `ulogd2` unprivileged, and the dynamic-ipset CDN-rotation fix.
`project.bats`, `remote.bats` and `audit.bats` need no Docker either.
`project.bats` covers the
declaration/approval split - what `.dejima/` may ask for, what only the host
can grant, and that the approval store is never mounted. `remote.bats`
covers everything in [docs/remote-access.md](remote-access.md):
the `[remote-access]` config section, the session registry, and the properties the
feature's security rests on - that the
notification path carries no sandbox-controlled byte, and that no marker
name outside `waiting`/`done` is ever acted on. `audit.bats` covers
`bin/dejima-audit`'s own diffing.
`config.bats`, `providers.bats` and `proxy.bats` round out the suites that
need no Docker either: `config.bats` covers `~/.config/dejima/config`
parsing and `install.sh`'s legacy-variable warnings (stubbing `docker` itself,
since the fast CI job's container deliberately has no daemon), `providers.bats`
covers the forge/workflow/harness manifest contract documented in
[docs/providers.md](providers.md), and `proxy.bats` covers the credential
proxy's own request handling.
One assertion is worth calling out because it can only run for real on a
Docker host: `runtime.bats` proves that a service bound to the host's
loopback is unreachable from inside a container while one bound to the
bridge gateway *is* reachable. The second half is a positive control - it is
what stops the first from passing in an environment where the container
reaches nothing at all. That pair is the ttyd front door's whole containment
argument, so run it before trusting a front door you just set up.
Every mutation in `runtime.bats` (firewall rules, `ipset` entries) happens
inside the test container's own network namespace, same as any other
container - none of it touches the host. Container/network names are
derived from bats' own per-run temp directory, not anything guessable or
likely to collide with a real container you have running.
`ci-runner.bats` is different in kind from the others: it doesn't test
this repository at all, it asserts on the CI runner executing it (the Docker
socket withheld from the confined job, granted to the privileged job, and
the egress each is allowed) - see [docs/ci.md](ci.md). It skips outright
unless `DEJIMA_CI_TIER` is set, so it has nothing to say when run locally.
### Running the tests
```sh
bats test/ # everything
bats test/audit.bats # one suite - the suites are independent, see .dejima.md
SKIP_BUILD=1 bats test/runtime.bats # the one suite needing Docker; reuse the current image
```
`runtime.bats` is the only suite that needs a Docker daemon: it is the one
that builds the image and boots real containers to assert against.
Everything else, `audit.bats`, `ci-runner.bats`, `config.bats`,
`project.bats`, `providers.bats`, `proxy.bats`, `remote.bats` and
`static.bats`, runs anywhere a shell does, including inside a dejima sandbox
itself, which has no Docker daemon by design.
`.gitea/workflows/tests.yml` runs all of it on every pull request and on every
push to one, `runtime.bats` included. That job needs two things from the
runner that an ordinary one doesn't (the host's Docker socket and the host
network namespace), and it checks for both up front rather than failing
obscurely partway through; the file says why each is needed.
+90 -37
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,80 @@ both deliberate:
- `DEJIMA_MERGE_BLOCK` fails **open** the same way - empty makes
`merge-guard.sh` inert rather than conditionally installed.
## Validation, and what's deliberately deferred to Phase 3
## Validation
`provider_validate_selection` checks the selected forge/workflow/harness at
every launch: each manifest must validate, and a workflow's `requires` must
be a subset of the forge's `capabilities` - a workflow asking for a
capability the forge doesn't have is refused outright.
every launch:
Two more rules are **planned but not enforced yet**, and a fresh install
relies on that:
- Each manifest must validate.
- A workflow's `requires` must be a subset of the forge's `capabilities` - a
workflow asking for a capability the forge doesn't have is refused
outright.
- The workflow's `autonomy` must be something the forge can actually back up:
`autonomy = open-pr` needs a forge offering the `pr` capability; `autonomy
= push-branch` needs `push = on` (it can't stop at a push it never makes).
A mismatch is refused at launch rather than discovered by the agent
mid-task.
- A pushing workflow (`push = on`) needs a forge that resolves to somewhere
to push to. A wholly unconfigured forge with nothing to configure at all
(`forge = none`) is refused, pointing at a forge that needs no host
configured (`forge = github`) or a workflow that does not push; a forge
that has config keys but none of them resolve to a host is refused with
"configure it, or select `forge = github`, or `forge = none` with a
workflow that does not push".
- A forge whose `requires` keys are **partly** set is refused as a likely
typo, but only when the workflow pushes. A key written but left empty
counts as not set - it resolves to nothing, and every call made with it
would fail inside the container. A non-pushing workflow never invokes the
forge, so gitea's `ssh_host` alone (set only so the firewall allows
fetches) is legitimate. A required key with a non-empty schema default -
gitea's `token_file` - never counts as missing, since its resolved value is
correct even unwritten. A wholly unconfigured forge is the case above, not
this one.
- A pushing workflow whose forge has **none** of its required settings is
refused too, even where `fixed_hosts` stops the "resolves to no host" rule
firing. This catches `github`: with a single `requires` key there is
nothing to be partial between, so without this rule a pushing workflow
could select it with no `push_token_file` and launch anyway, `fixed_hosts`
masking that nothing was configured.
- A pushing workflow whose forge resolves to no host at all is not refused.
Gitea's `ssh_host`/`api` have no schema default, and `install.sh` writes
them commented out, so a fresh install would be refused by this rule if it
existed today - while it launches fine as-is, with `git-push-guard.sh`
blocking every push at runtime (fail-closed, see above). An unconfigured
forge is a supported state ("skip this if you only push to GitHub" in the
README), not a bug, until Phase 3 adds `forge = none` as the honest way to
say so.
- A forge's `requires` keys being unset produces a **warning**, not a
refusal, and only when the forge is *partially* configured (some of
`requires` set, some not) - a wholly unconfigured forge stays silent, the
same supported case as above. A partial configuration is almost certainly
a typo, so flagging it costs nothing even before Phase 3 turns this into a
refusal alongside the rule above.
## Shipped providers
## Providers not yet built
| axis | provider | notes |
| --- | --- | --- |
| forge | `gitea` | PRs and issues via `gitea-pr`/`gitea-issue`, shipped into the image |
| forge | `github` | PRs and issues via `gh`; needs its own write-capable `push_token_file`; opens `ssh.github.com:22` when the selected workflow pushes |
| forge | `none` | **default** - no capabilities, no host; pair with `local-only` |
| workflow | `worktree-pr` | worktree → push → PR, stops at the PR |
| workflow | `branch-push` | worktree → push, stops at the branch |
| workflow | `local-only` | **default** - commits locally, pushes nothing |
| harness | `claude-code` | **default**, and the only harness today |
`forges/github`, `forges/none`, `workflows/branch-push` and
`workflows/local-only` are Phase 3 candidates. They do not exist in this
repo today - nothing selects them, and no manifest for them ships.
**The shipped defaults are deliberately the non-pushing pair**
(`forge = none`, `workflow = local-only`). A fresh install has no forge host
and no forge credential, so any default naming a real forge would be an
assertion about the operator's setup that nothing has established - and,
because a pushing workflow whose forge resolves to no host is refused (see
[Validation](#validation)), such a default would make an unconfigured
`dejima` refuse to launch at all. The defaults instead say the one thing
that is true of every fresh install: this sandbox pushes nowhere yet.
Pushing is opted into by naming both a pushing workflow and a forge that
resolves.
**Selecting `forge = github` is a real posture change, not just a config
choice.** It opens `ssh.github.com:22` for push - the read-only GitHub access
every session gets regardless (clone, fetch, `gh` API calls over port 443)
never needs port 22 at all. It opens it through core's existing GitHub push
grant (`ALLOW_GITHUB_PUSH`, the same one `--allow-github-push` uses), which
its `hosts_allowlisted_by_core = on` is what asks for: port 22 to GitHub's
published ranges and nothing else, logged like every other allowed
connection and verified by the firewall's own startup self-check. Only when
the selected workflow actually pushes - `forge = github` with
`workflow = local-only` leaves port 22 shut. And `[forge.github] push_token_file` is
deliberately a *separate*, write-capable token from `[github] token_file`
(a read-only fine-grained PAT used for read access independent of which
forge is selected): promoting the read-only token to push duty would defeat
the point of scoping it read-only in the first place. Because `gh` only ever
reads one token variable, dejima warns at startup when both files are
present and non-empty - the write-capable `push_token_file` wins for that
launch, and the read-only `token_file` is not used while `forge = github`.
+32 -39
View File
@@ -44,7 +44,7 @@ What it does **not** add is a way *out* of a sandbox. Configured as
described below, a compromised session cannot reach `ttyd`, cannot reach
your proxy, and gains nothing from the fact that your phone can: it has a
route to exactly one host address, and neither front door is on it. That
property is worth stating precisely because it is the one a plausible-looking
property needs stating exactly, because it is the one a plausible-looking
mistake inverts - a proxy bound to `0.0.0.0` or to a bridge address is a
front door opening *inward*, and that is a full sandbox escape rather than a
degradation. [Verifying it](#verifying-it) is two commands, and worth
@@ -69,7 +69,7 @@ approvals):
scheme; a value without one is refused rather than guessed at, because
`curl` reads a leading `-K/path` as an *option* that takes the request's URL
from that file instead. Values are restricted to
`A-Za-z0-9:/._~?=%@+-` — no whitespace and nothing shell-active, since these
`A-Za-z0-9:/._~?=%@+-`: no whitespace and nothing shell-active, since these
end up in an HTTP header and in a URL handed to a phone.
**A malformed config is a hard failure, not a warning.** The whole file is
@@ -304,7 +304,7 @@ also what catches a distro `ttyd.service` still running on that port, which
is why this is worth doing even if you are confident about the proxy. `test/runtime.bats`
asserts this same containment property against a live container (with a
positive control on the same path, so a passing result can't come from the
container simply reaching nothing), and it passes on a real Docker host.
container reaching nothing), and it passes on a real Docker host.
## SSH front door
@@ -442,7 +442,7 @@ and forgotten. What matters in it:
the one you have - just make sure it doesn't fall through to this vhost.
- **TLS.** HTTP Basic sends the password on every request, and the terminal
itself carries whatever the session is working on.
- **`proxy_set_header Host $http_host;`** — `$http_host`, not `$host`.
- **`proxy_set_header Host $http_host;`**: `$http_host`, not `$host`.
`--check-origin` compares the client's `Origin` against the `Host` header,
and nginx's `$host` has the port stripped while `ttyd` keeps the port in
what it compares unless it is 80 or 443. On a vhost listening anywhere
@@ -700,16 +700,14 @@ what the outer proxy sends.
## Notifications
`dejima` registers `hooks/dejima-notify.sh` as a `Notification` and
`Stop` hook for a remote-enabled session - into the throwaway settings
override layer it already generates for that session, never into your real
`~/.claude/settings.json`, which every sandbox and your host's own Claude
Code read directly. Your own `Notification` and `Stop` hooks keep running
alongside it: Claude Code merges hooks across settings layers rather than
letting the higher one replace the lower. That layer is mounted read-only, so a
session cannot unregister its own hook. The hook writes one empty file, named `waiting` or
`done`, into this run's own bind-mounted marker directory. No content, no
stdin, no network.
`dejima` registers `hooks/dejima-notify.sh` as a `Notification` and `Stop`
hook for a remote-enabled session, into the throwaway settings override layer
it already generates - never into your real `~/.claude/settings.json`. Your
own hooks keep running alongside it, since Claude Code merges hooks across
layers rather than replacing. The layer is mounted read-only, so a session
cannot unregister its own hook. The hook writes one empty file named
`waiting` or `done` into this run's marker directory: no content, no stdin,
no network.
On the host, `dejima-notify-watch` turns those markers into ntfy pushes.
Same installation shape as above:
@@ -726,14 +724,13 @@ loop plus a five-second sleep). Repeat markers for the same session and
event inside 60 seconds are debounced (`[remote-access] debounce` overrides
that).
One environment gotcha, because it fails silently: the watcher and
`dejima` both resolve the marker root as `${XDG_RUNTIME_DIR:-/tmp}/dejima`,
so they only meet when they agree on `XDG_RUNTIME_DIR`. systemd sets it for
user units and a login shell gets it from pam_systemd, but a `dejima`
run from a context that has neither - a bare `su`, a cron job, a tmux server
started before login - writes its markers under `/tmp/dejima` while the unit
watches `/run/user/<uid>/dejima`, and no push is ever sent, with nothing
logged to say so. `systemctl --user show-environment` is where to check.
One gotcha, because it fails silently: both sides resolve the marker root as
`${XDG_RUNTIME_DIR:-/tmp}/dejima`, so they only meet when they agree on
`XDG_RUNTIME_DIR`. systemd sets it for user units and a login shell gets it
from pam_systemd, but a run from a context with neither - a bare `su`, cron,
a tmux server started before login - writes to `/tmp/dejima` while the unit
watches `/run/user/<uid>/dejima`. No push is sent and nothing is logged.
Check with `systemctl --user show-environment`.
The same directory is why you probably want lingering on:
@@ -741,23 +738,19 @@ The same directory is why you probably want lingering on:
loginctl enable-linger "$USER"
```
Both units here are *user* units. Without lingering they are stopped at
logout and `/run/user/<uid>` is destroyed with the session, taking the
session registry and every marker with it - so a sandbox left running in a
detached tmux session has no front door and sends no notification until you
log in again. It fails closed rather than open (nothing is left listening
unguarded), but it fails invisibly, and it is the same `XDG_RUNTIME_DIR`
that the gotcha above is about.
Both are *user* units. Without lingering they stop at logout and
`/run/user/<uid>` is destroyed with the session, taking the registry and
every marker with it - so a sandbox left in a detached tmux session has no
front door and sends nothing until you log in again. It fails closed, but
invisibly, and on the same `XDG_RUNTIME_DIR` as the gotcha above.
**The marker directory is a write channel into your runtime tmpfs**, and
nothing bounds it. `/run/user/<uid>` is RAM-backed and sized at a fraction
of physical memory, and each session gets its own directory under it
bind-mounted read-write, so a session that writes into `/run/dejima-notify`
until it is full degrades your whole login session - D-Bus, the systemd user
manager - and not just dejima. A session can already fill the workspace
mount, so this is an increment rather than a new kind of exposure, but a
RAM-backed filesystem fails harder and sooner than a disk. There is
deliberately no quota here; `rm -rf` on that run's marker directory (or
nothing bounds it. `/run/user/<uid>` is RAM-backed and sized at a fraction of
physical memory, so a session filling `/run/dejima-notify` degrades your
whole login session - D-Bus, the systemd user manager - not just dejima. A
session can already fill the workspace mount, so this is an increment rather
than a new exposure, but a RAM-backed filesystem fails harder and sooner.
There is deliberately no quota here; `rm -rf` on that run's marker directory (or
logging out, which discards the tmpfs) is the recovery.
**What a notification contains.** A title of `dejima: <project> needs input`
@@ -801,8 +794,8 @@ the next one is worth checking.
`grep -c dejima-notify /etc/dejima-settings.json` from inside should be 2.
That file is passed to Claude Code as `--settings` by `claude-wrapper.sh`;
if it is missing entirely, this container was not started by `dejima`.
3. **Does the marker reach the host?** This is the one worth knowing, because
it splits the chain cleanly in half. From inside the sandbox:
3. **Does the marker reach the host?** This is the one to check first: it
splits the chain cleanly in half. From inside the sandbox:
```sh
: > "$DEJIMA_NOTIFY_DIR/waiting" && sleep 2 && ls "$DEJIMA_NOTIFY_DIR"
+215
View File
@@ -0,0 +1,215 @@
# Using this responsibly
[← dejima README](../README.md)
The README states each of these ten rules in a sentence or two. This is the
part that didn't fit: why each one matters, what goes wrong without it, and
the detail behind the three the README could only gesture at.
## Check whether they want it first.
"They didn't ask for this" is a consent problem, not a quality problem. A
project that has said it doesn't accept AI-assisted contributions has said so
for reasons that have nothing to do with whether a given patch is good: review
load, licensing questions, a maintainer who simply doesn't want to referee
that argument on every pull request. A well-written, well-tested change
doesn't override a stated no, any more than good manners override a locked
door. Read CONTRIBUTING (or the equivalent policy file, or the pinned issue,
wherever a project keeps it) before you start work, because finding out after
means the work is already done and now has nowhere to go.
## Don't point it at repositories you don't own.
The concrete workflow, step by step:
1. Fork the project on its forge.
2. Point `bin/dejima` at your fork: run it from a local clone whose `origin`
is your fork, not the upstream repository.
3. Let the session do its work there. With a pushing workflow configured, it
opens branches, and pull requests, against your fork, not upstream.
4. Review the agent's branch yourself, the same way you'd review anyone
else's diff before merging it.
5. Merge it to your fork's own `main`.
6. Open the upstream pull request by hand from there, once you would have
been glad to have written it yourself.
The reason for the extra hop is what happens without it. An auto-push
workflow aimed straight at someone else's repository puts a session's raw
output directly into that project's pull request queue, unreviewed by
anyone, the moment it opens. Maintainers already spend real time triaging a
flood of low-effort AI-generated pull requests; a Godot maintainer's account
of what that actually costs is worth reading in full:
<https://bsky.app/profile/akien.bsky.social/post/3meyerixvhs2p>. Forking and
reviewing first doesn't change what the session is capable of writing. It
changes who reads the output before anyone else has to: you, not a stranger
who owns the project.
## Don't point it at proprietary code.
dejima narrows the paths code has to leave through; it doesn't close them,
and "point it only at code you could survive losing" is the practical test
for that gap. Ask it plainly before you start a session, not after: if this
checkout's contents ended up somewhere they shouldn't, would that be a bad
week or the end of the business? Code you could survive losing may be worth
the convenience of unattended agentic work. Code you couldn't shouldn't go in
at all.
That second one is not a judgement about how good the containment is, and it
does not get better with a version bump. There is no containment that fully
prevents exfiltration: a determined attacker with code execution and any
outbound path will find a way. So the question is not whether you trust
dejima enough, but whether you can accept the risk of the contents leaving.
If you cannot, do not hand them to an agent that could send them.
## Look at what else is in the directory.
The bind mount doesn't know which files you meant to hand over. It exposes
the whole checkout, and everything a working tree accumulates over its life
sits there with the same reachability as the source files you actually care
about: a `.env` a teammate left uncommitted, a database dump pulled down for
local debugging and never deleted, a credential baked into a fixture file,
an old key still sitting in the history of a branch that got checked out.
None of that is code in any sense that matters to the agent; all of it is
just bytes in a directory the session can read and any tool call it makes
can touch.
Auditing a working tree by eye is not a minute's work, and it is the kind of
check that succeeds every time until the once it matters. **Point dejima at a
fresh clone instead.** A clone contains what is in version control and
nothing else, so the whole class goes away rather than being searched for. If
a directory might have ignored files in it worth protecting, that is the
directory not to hand over.
## An allowlist entry is a grant, not a convenience.
For every allowlist entry you give a dejima agent, consider what an attacker
could do with it. The answer varies enormously between entries that look
similar, and what it turns on is what happens if the call is wrong, whether
wrong means a prompt-injected instruction or an ordinary agent mistake.
A package registry or a docs site can be wrong without being expensive. A
session that fetches the wrong package version, or scrapes a stale or
misleading page, produces bad input to more code, code that still has to
pass through your own review before any of it ships. The mistake is
contained by the same diff you were always going to read.
A deploy webhook, a production database API, and a payment provider are a
different shape entirely. A single call against any of them is the action,
not a step toward one you'll review later: the deployment already ran, the
row is already written or gone, the charge already happened. There is no
diff downstream that catches it, because nothing about it passes through
your repository at all. Allowlisting one of these gives an unattended process
standing authority to act on the world, the same authority you would think
twice about handing a person.
Ask, before adding an entry: if this call happened by mistake, is the result
something I fix in the next commit, or something I have to explain to
someone?
## Safe and responsible are not the same thing.
"Safe" is a property of the sandbox: what a session inside it can reach.
"Responsible" is a property of what you let out of it. A session can be
contained perfectly and still produce something that should not be merged,
and the sandbox has no opinion either way.
This point is about the merge, not about code quality in general. What makes
`main` (or `master`, or whatever your default branch is called) different
from any other branch is that other things read it and believe it. It is
worth being precise about why, rather than saying "the released version" in
some vaguer sense. In this repository, dejima-ci-hardener resolves the
CI-side egress
allowlist by reading `.dejima/allowed-domains` from `origin/main`, never
from a branch or a local checkout, and treats whatever it finds there as
already reviewed. That trust is load-bearing: the only way content lands on
`main` is through a pull request you merged, so reading from `main` is how
the tool avoids asking a branch what it's allowed to do. So merging onto
`main` ships it to users and feeds a trust anchor that other tooling, this
one and potentially future ones, is entitled to rely on without re-checking.
Keeping insecure or broken code off `main` is what makes that reliance
justified, and that job is yours; nothing about running the agent in a
sandbox does it for you.
## Give it its own identity, then own what you merge.
The reason to separate identities is that forge-level controls key on who
authenticated, not on what changed. By default the sandbox authenticates as
you, so every control your forge offers that answers "who did this" (a merge
whitelist, a push whitelist, required approvals) admits the session exactly
as it admits you; a required approval becomes unusable outright, since a
forge won't let a pull request's own author approve it, and the session
opens pull requests as you. Giving the sandbox a dedicated forge account
(see [docs/security.md](security.md#to-your-forge-the-sandbox-is-you) for
how) is what turns those settings from decoration back into something that
actually holds a session to a smaller set of permissions than yours.
Once the identity is separate, every commit and pull request the session
produces is visibly labeled as the agent's own work, not yours, in your
project's history. That visibility makes your responsibility legible without
shrinking it. Anyone reading your `main` later sees your name on the merge
commit regardless of whose name is on the authored one, and will hold you to
it accordingly.
## Understand what you ship.
The risk here is habituation, not a single lapse. dejima makes iterating
cheap enough that reading every diff starts to feel like the slow part of
the loop, and skipping it works fine, repeatedly, until the one time it
doesn't. A reviewer who reads every change catches most of what's wrong with
it. A reviewer who only reads the ones that felt risky catches none of the
ones that didn't feel risky, which is the shape a subtle bug, or a
prompt-injected change dressed up as something ordinary, takes. Nothing
about a faster loop changes what engineering judgment is for.
## This is not a blastproof box.
Read this rule as a statement about what kind of failure to plan for.
dejima's containment is Linux namespaces and a small capability set around
the session, the same kind of isolation a container gives any process, not a
virtual machine boundary; it shares the host kernel with everything else on
the machine.
That buys real things (see [docs/security.md](security.md) for the specific
list: no working `sudo`, a deny-by-default firewall, read-only mounts over
the surfaces that matter most), and every one of them assumes the container
itself stays intact. A determined attacker, or a single container-escape
vulnerability, doesn't erode that protection gradually; it removes every
layer described in that document at once, because they all sit on the same
kernel. So the assumption to hold is not that the walls will probably hold.
It is that if this container is ever compromised at the kernel level,
everything reachable from this host account should be treated as compromised
with it, which is a very different thing to plan around than an ordinary bug.
## dejima's security says nothing about your software's.
These are different axes, not different degrees of the same thing. dejima's
threat model is about the session: what a process running inside the
sandbox can reach, exfiltrate, or execute while it runs. None of that has
anything to say about the software that session writes once it's on disk.
dejima can contain a session perfectly and that session can still write a
SQL injection, hand-roll a broken authentication check, hardcode a secret,
or pull in a compromised dependency, because those are properties of the
code itself, decided at the moment it's written, not something a sandbox
sitting around the process that wrote it can retrofit afterward. If what got
built inside dejima turns out to be insecure, containment was never the
control that was supposed to catch it.
**It is not something a future agent can retrofit either.** "We will do a
security pass later" was already a weak plan before agents made it cheap to
say. Early architectural decisions
close off the fixes for whole classes of problem: where trust boundaries sit,
what is authenticated and where, what data crosses which process, what a
single compromised component reaches. A later pass can find a missing bounds
check. It cannot move a boundary you built a system on top of. And insecure
implementation decisions sit in software for years without announcing
themselves, which is exactly why they are not found by a pass that goes
looking once.
So security has to be considered at every point in the work, not scheduled
as a phase, and dejima will not help you do that. It makes writing a lot of
code fast, which makes writing a lot of insecure code fast if nobody is
holding that concern. That responsibility is yours at every step, and it is
the one thing here no tooling in this repository is aiming at.
The same practices you'd apply to code from any other source, review,
linting, dependency scanning, tests aimed at the failure modes you care
about, still have to happen here. dejima does not stand in for any of them.
+662 -174
View File
@@ -10,6 +10,140 @@ at repositories you already trust. Everything below is about how bad a
compromised session *inside* that trust boundary can get, not a claim that
one can't happen.
**And none of it has been reviewed by anyone else.** This is early
development: the reasoning here is one person's, the tests assert what that
person thought to check, and no outside audit or adversarial testing has
happened. Read what follows as claims awaiting scrutiny rather than claims
that have survived it. See [SECURITY.md](../SECURITY.md).
## CI: push access is code execution elsewhere
The controls in this document bound what a session can reach *directly*. They
do not bound what it can arrange to have run somewhere else, and if the
project has CI, **granting a session push access grants it code execution on
whatever host that CI runs on.** No operator action is needed anywhere in the
chain: the push guard permits a feature branch, the briefing tells the agent
to open a pull request without asking, `on: pull_request` fires, and the
runner checks out that commit and executes it. The you-merge-not-the-agent
gate sits strictly downstream: by the time anyone reads the diff, it has
already run.
Nor does the code have to be a modified workflow file. A workflow that runs a
test suite runs every file in it, so a plausible-looking helper in a test file
is executed just as surely, and far more quietly, than an edited workflow
would be.
Relative to the sandbox, a run adds three things: **execution on a host you
own**, outside the container and outside the allowlist; **egress from a
network position you own**, with no firewall and no DNS proxy in front of it;
and **whatever credentials the run is handed**, including the token the forge
injects into every job whether the workflow asks for one or not.
There is deliberately no approval gate offered for this. Every version of one
(changing the trigger, dispatching by hand, leaving the runner stopped, the
forge's own fork-PR approvals, filtering egress from inside the workflow) is
reachable from the branch, covered by a credential the session already holds,
or not a gate at all. The mitigations are environmental instead: a runner host
you are willing to lose, job containers with no Docker socket, deny-by-default
egress whose allowlist is read from `main` rather than from the branch, and no
secret a run does not need. And where none of that is available, declining the
push grant declines the code execution with it: `[core] forge = none` with
`[core] workflow = local-only`.
This applies to any project developed in a dejima sandbox, not only to this
repository. **→ [docs/ci.md](ci.md)** for the reasoning and the setup.
## To your forge, the sandbox is you
By default a session authenticates with your credentials: your SSH key through
the forwarded agent, and a token generated under your account. A push, a pull
request, a comment or a merge made from inside the sandbox is therefore
**indistinguishable at the forge from one you made yourself**, not similar to
it, identical.
The consequence is worth stating flatly, because it is easy to configure
around without noticing: **every control your forge offers that keys on
identity is a guard rather than a boundary.** Branch protection's merge
whitelist, its push whitelist, required approvals, and the audit log all
answer the question "who did this", and there is only one answer available.
Putting yourself on a merge whitelist puts the sandbox on it too.
Three specific consequences:
- **Required approvals are not merely weak here, they are unusable.** A forge
will not let a pull request's author approve it, and the sandbox opens pull
requests as you, so requiring one approval means nobody can give it.
- **The forge's audit trail cannot attribute.** "You pushed this" and "a
session pushed this" are the same recorded event. This is the same shape as
the exit-time audit's time-window caveat above: a record of what happened,
not of who caused it.
- **It compounds the SSH agent's reach.** The agent already signs for any repo
your key can reach (below); the identity is what makes the resulting action
land as yours everywhere it reaches.
### Give the sandbox its own identity
This is configuration, not a missing feature. Create a dedicated forge account
with **write** access to the repositories a session should work on, and point
the sandbox at it instead of at you:
- `[forge.gitea] token_file`: that account's token, rather than one issued
under yours.
- `[core] ssh_key`: a deploy key belonging to it, rather than your forwarded
agent. See "Headless git push without agent forwarding" in
[docs/configuration.md](configuration.md).
With the two identities separated, the same settings stop being decorative:
| Setting | What it becomes |
| --- | --- |
| Merge whitelist: you | The session genuinely cannot merge |
| Push whitelist on `main`: you, or nobody | The session pushes feature branches and nothing else |
| Require approvals ≥ 1 | Usable: the session authors, you approve |
That last row is the one that turns "the operator merges, the agent does not"
from a convention into something the forge enforces.
**Verify which credential a push actually uses.** Nothing here enforces one:
`[core] ssh_key` is a second, independent credential path (below), so if your
own agent is *also* forwarded, a push may still be signed by your personal
key and the separate identity becomes decorative for exactly the operation it
was meant to constrain. Make a test push and read what the forge attributes
it to, rather than assuming.
**Write access, and specifically not ownership.** Add the account as a
collaborator on the repositories it works on. Write is the ceiling here, not
a floor: an account that owns a repository, or holds admin on it, can merge
its own work and edit the settings in the table above, so every row of that
table goes back to being decorative. `write:repository` and `write:issue` are
the scopes its token needs (README, "Gitea/Forgejo PR workflow"), and nothing
about that changes for a collaborator.
One consequence presents as a misconfiguration, and the obvious fix is the
wrong one. Parts of Gitea's API are gated on *ownership* rather than on
permission: `GET
/repos/{owner}/{repo}/actions/runs` answers a non-owner with `"user should be
the owner of the repo"` no matter which scopes the token carries. Read that
way it looks like a scope error, and the reflex is to widen the token. That
cannot work, since no scope grants ownership, and its only real remedy is
handing the sandbox your own token, which would undo this whole section.
`gitea-actions` therefore reads CI through `actions/tasks`, the commit's
statuses and the run view's log route, none of which are owner-gated. If some
future need lands on an owner-only endpoint, leaving that capability
unavailable is the better trade than promoting the account.
Two things a separate identity does **not** do:
- It does not reduce what a compromised session can do inside the container,
or to the mounted project. It changes what that session can do *to your
forge*, and who the forge thinks did it.
- It does not touch CI. A write-capable account can still push a feature
branch, and pushing a branch is what makes CI execute code; see the
section above. It also has no bearing on the token the forge injects into a
CI job, which is a third principal again, subject to none of the settings in
that table. **→ [docs/ci.md](ci.md)**
## Provider trust
Forges, workflows and harnesses (`forges/`, `workflows/`, `harnesses/`) are
@@ -21,7 +155,8 @@ code. `bin/dejima` resolves the selected forge/workflow/harness **on the
host** and passes only the resolved results into the container - which
hosts pushes are allowed to, which branches are protected, the literal merge
command to block, plus the forge's own environment variables (a Gitea API
token, in the shipped forge's case) - never a manifest or a provider
token for `gitea`, a write-capable PAT for `github`, nothing for `none`) -
never a manifest or a provider
directory itself. The one thing a provider ships that does run is a forge's
`bin/`, copied into the image at build time as an ordinary program on
`PATH`, no different from any other CLI baked into it.
@@ -69,6 +204,8 @@ hands to whichever session starts against it. See
overlaid (they're config, not fixed paths a static mount can cover) and
remain fully writable; the exit-time audit below reports changes to
either, but only after the session ends - it doesn't stop them.
### The exit-time audit
- `bin/dejima-audit` snapshots host-persistence surfaces before each
run and diffs them after the container exits: `.git/config` (and worktree
`config.worktree`) keys that run a command on the next `git` -
@@ -82,25 +219,57 @@ hands to whichever session starts against it. See
changes under the workspace's `.claude/`, `.mcp.json`, `.envrc`,
`.vscode/`, and `.git/hooks/` (symlinks included - a repointed symlink is
hashed by its target, not just its content), plus cross-project writes
under `~/.claude/projects` (see below). When the mounted workspace *is*
this repo's own sandbox itself (see the trusted-computing-base note
further below), it also diffs content hashes of the sandbox's own trusted
computing base - `bin/`, `hooks/`, `docker/`, `install.sh`,
`test/` - under that workspace. It's **advisory only**: it fires *after*
the session has already ended, so a report of a planted `core.hooksPath`
or hook file is something to read and clean up *before* the next `git`
or `claude` in that repo, not something that was blocked. It's silent when nothing
changed, and never affects the session's own exit code even if it can't
complete. It doesn't cover `[core] share_dir` (see "Shared scratch
space") - that hand-off path stays unaudited - and its coverage is a
fixed list of known Claude Code config surfaces, not a general integrity
check, so a future config surface this list hasn't been taught about yet
would go unreported. **Its diff is time-windowed, not session-attributed:**
a finding means one of these paths changed while the session was running,
not that the session made the change. A concurrent dejima or Claude Code
session, another host process, or you editing the same files by hand
during the run all land in the same diff and get reported identically -
the tool has no way to tell them apart.
under `~/.claude/projects` (see below). When the mounted workspace is this
repo itself, it also hashes the sandbox's own trusted computing base -
`bin/`, `hooks/`, `docker/`, `install.sh`, `test/`.
Worth knowing about that first surface, because the sandbox's own workflow
makes it easy to get wrong: **a git worktree isolates the working tree, not
`.git/config`.** Config is shared with the parent checkout, so a bare
`git config <key> <value>` inside a worktree writes the real repository's
config, including any exec key above. The briefing now says so, and gives
`git -C <path> config`. A guard against an unaware agent, not a boundary:
`.git/config` is in the read-write project mount and cannot be read-only
without breaking `git push -u`, which writes `branch.<name>.merge` on every
push. Detection covers the rest, and did - this was found by the audit
reporting a `.git/config` change.
**Advisory only.** It fires after the session ended, so a planted
`core.hooksPath` is something to clean up before the next `git` or
`claude` in that repo, not something that was blocked. Silent when nothing
changed, and never affects the session's exit code. It does not cover
`[core] share_dir` (see
[Shared scratch space](configuration.md#shared-scratch-space)), and its
coverage is a fixed list rather than a general integrity check, so a config
surface it has not been taught about goes unreported.
**Its diff is time-windowed, not session-attributed:** a finding means a
path changed while the session ran, not that the session changed it. A
concurrent session, another host process, or your own editing all land in
the same diff, reported identically.
- **Approving and ignoring are different decisions, and the difference is
what you are promising about next time.** Approving records the exact
content: `safe <id> <sha>`, so this file with these bytes is fine and any
other content re-fires. Ignoring records the surface: `spam <id>`, so this
path or key is fine whatever it later contains. Approve a hook you have
read; ignore a key some tool rewrites on every run and which you have
decided you will never care about. The review offers both, and says which
is which rather than leaving you to infer it:
```
[a] approve this exact content [i] ignore this key from now on [s] skip (ask again next time) [q] quit
```
Skipping asks again next session, the right default when you are unsure.
It matters for keys some tools rewrite on every run: with no decision
recorded, one re-fires every session and pushes real findings out of the
capped list.
Severity is not negotiable by either verb. An exec-capable key or shell
alias is CRITICAL whatever the baseline says, and an `ignore` entry is
refused against a CRITICAL surface when the baseline is read, not only when
written - so neither decision can quietly downgrade something that matters.
- `dejima-audit ack` shows what it is asking you to approve. For a surface
that changed during the session it prints a unified diff of the
pre-session content against the current content; for one created during
@@ -113,21 +282,19 @@ hands to whichever session starts against it. See
live on disk); a removed one stores nothing at all, because a deletion
diff *is* the whole former file.
Both the staging directory and the retained diffs live under
`$AUDIT_DIR` (`~/.local/share/dejima/audit` by default), never under
`/tmp`. That is the boundary the whole scheme rests on:
`docker-compose.yml` mounts nothing covering it, so the container cannot
read the retained content of earlier runs - `test/static.bats` pins that
as a regression guard. If the store is configured *inside* the mounted
workspace it becomes readable and deletable from within the sandbox, so
in that case no content is retained at all - only a note saying why. A
session killed with `SIGKILL` (no EXIT trap runs, so no `compare`)
leaves its staging directory behind; the next run against that
workspace reaps it, identifying dead owners by pid and process start
time recorded in an `.owner` file, so a concurrent session against the
same workspace is never mistaken for dead and disturbed. A directory
whose owner record is missing or unreadable falls back to a 24-hour age
check instead of guessing liveness.
Staging and retained diffs live under `$AUDIT_DIR`
(`~/.local/share/dejima/audit`), never `/tmp`. That is the boundary the
scheme rests on: nothing in `docker-compose.yml` covers it, so the
container cannot read earlier runs' retained content, and `static.bats`
pins that. A store configured *inside* the workspace is readable and
deletable from the sandbox, so nothing is retained in that case - only a
note saying why.
A `SIGKILL`ed session runs no EXIT trap and leaves its staging directory;
the next run against that workspace reaps it, identifying dead owners by
pid and process start time from an `.owner` file so a concurrent session is
never mistaken for dead. A missing or unreadable owner record falls back to
a 24-hour age check rather than guessing.
Content is withheld from the store in several further cases, and in
every one `ack` prints the reason in place of the diff - so an absent
@@ -142,101 +309,114 @@ hands to whichever session starts against it. See
existed before the session but was too large to have been staged (over
1 MiB) withholds with "no pre-session copy", not a false claim that it
is new. The *current* side is bounded by that same 1 MiB cap, and - like
the staged side - the size is checked *before* any copy is taken: a file
the session grew past it withholds with "change too large to retain"
rather than being copied through `$TMPDIR` first. That ordering is the
point, not the cap: `compare` runs from the exit trap on every session
exit and the size is the session's own choice, so copying first would
let a session turn "I filled my own workspace" into that many bytes
written to `$TMPDIR` - frequently a tmpfs, i.e. host RAM - at exit, and
into a whole copy of the file left sitting there if that exit was a
`SIGKILL`. A generated diff is withheld if it is binary, over 200 lines
the staged side - checked *before* any copy is taken: a file the session
grew past it withholds with "change too large to retain" rather than
being copied through `$TMPDIR` first. The ordering is the point, not the
cap. `compare` runs from the exit trap on every exit and the size is the
session's own choice, so copying first would turn "I filled my own
workspace" into that many bytes written to `$TMPDIR` - often a tmpfs, so
host RAM - and into a whole copy left there if the exit was a `SIGKILL`.
A generated diff is withheld if it is binary, over 200 lines
or 64 KiB (and is bounded to that as it is generated, for the same
reason), or matches a best-effort credential screen - vendor prefixes
including `sk-ant-`, `sk-proj-`, `ghp_`, `gho_`, `github_pat_`,
`glpat-`, Slack's `xox[baprs]-`, `AIza`, `AKIA`, `_authToken`,
`Authorization: Bearer`, and PEM private-key headers, matched
case-insensitively (deliberately: the one high-value surface this is
the only net for, `.claude/settings.json`'s `env` block, conventionally
holds its keys upper-case). That screen is still explicitly best-effort
and has real false negatives - the denylist above is the reliable half
of the defence, this is the second net. A git config key holding more
than one value on either side of the session is not compared at all:
git resolves such a key to its *last* value in *file* order, and the
audit does not resolve it - it parses the config files and sorts, so
lexicographically-last is not the value git will run, and the
pre-session side is every value the key had rather than one. Comparing
anyway produced a diff over values git does not use: with two
pre-session values it read as a deletion that never happened, while the
value the session actually appended - the one git now executes -
appeared nowhere in it. That case withholds with "this key has multiple
values and git uses the last", and must be compared by hand;
`include.path` is legitimately multi-valued, so this is an ordinary
shape, not only a planted one. A generated diff can also still come out
empty - before and after compare equal even though the finding was
reported as a change - when the config file's *text* changed while the
parsed value did not, e.g. a value that was re-quoted; that case
withholds too, saying plainly that the comparison came out empty rather
than silently showing nothing, since an empty diff for a reported
change would otherwise read as a bug.
case-insensitively, since `.claude/settings.json`'s `env` block - the one
high-value surface this is the only net for - conventionally holds its keys
upper-case. The screen is best-effort with real false negatives: the
denylist above is the reliable half.
A per-report **retention budget** (`RETAIN_BUDGET`, 1 MiB) also bounds
the total diff bytes one report may keep. Once it is spent, remaining
findings in that report withhold with a note that the budget ran out,
and both the report and the exit-time stderr summary add an alarm line:
that many changed host-persistence surfaces in one session is treated
as a signal in its own right, not merely a limit worth raising - an
ordinary session changes few enough of these surfaces that the budget
is never in practice a constraint. `diffs = off` in the config's
`[audit]` section disables retention (and staging) entirely.
A git config key with more than one value on either side is not compared.
Git resolves such a key to its last value in FILE order; the audit parses
and sorts, so lexicographically-last is not what git runs, and the
pre-session side is every value the key had. Comparing anyway produced a
diff over values git does not use - two pre-session values read as a
deletion that never happened, while the appended value git now executes
appeared nowhere. It withholds instead, and must be compared by hand.
`include.path` is legitimately multi-valued, so this is an ordinary shape.
Two residual limits worth knowing on top of the credential screen's
false negatives above. The prompt shows at most 40 lines of a diff or
of new content before truncating, naming the file so you can read the
rest - a large planted change is truncated exactly where you would most
want it whole. And the `SIGKILL` case above is a real exposure window,
not just a cleanup mechanic: until the next run against that workspace
reaps it, the staging directory sits on disk holding whole pre-session
file copies (not diffs) of every monitored surface, for as long as the
workspace goes unused.
- Report findings name a path or config key that a session may itself have
chosen, so those names reach your terminal as untrusted text. One that
contains an ASCII control character, a C1 control, or a Unicode bidi
override - which between them can scroll, blank, or visually reorder what
you read, and can split a name across the audit's own records - is
written hex-encoded behind a `{hex}` marker instead, and decodes with
`printf '%b' "$(sed 's/../\\x&/g' <<< HEX)"`. Ordinary names are printed
as-is, so the marker appearing at all is itself worth a second look.
The encoded form is what `dejima-audit ack` records in the baseline, so
what you acknowledge is the file that is actually on disk rather than a
A diff can also come out empty when the config file's TEXT changed but the
parsed value did not, e.g. a re-quoted value. That withholds too, saying so
rather than showing nothing - an empty diff for a reported change reads as
a bug.
A per-report **retention budget** (`RETAIN_BUDGET`, 1 MiB) bounds the total
diff bytes one report keeps. Once spent, the rest withhold and both the
report and the stderr summary add an alarm line: that many changed
host-persistence surfaces in one session is a signal in itself, not a limit
worth raising. `diffs = off` in `[audit]` disables retention and staging.
Two residual limits on top of the screen's false negatives. The prompt
truncates at 40 lines, naming the file so you can read the rest - a large
planted change is cut exactly where you would want it whole. And the
`SIGKILL` case is an exposure window, not just a cleanup mechanic: until
the next run reaps it, the staging directory holds whole pre-session file
copies of every monitored surface, for as long as the workspace goes
unused.
- Findings name a path or config key the session may have chosen, so they
reach your terminal as untrusted text. One containing an ASCII or C1
control or a Unicode bidi override - which can scroll, blank or reorder
what you read, and split a name across the audit's own records - is written
hex-encoded behind a `{hex}` marker and decodes with
`printf '%b' "$(sed 's/../\\x&/g' <<< HEX)"`. Ordinary names print as-is,
so the marker appearing is itself worth a look. The encoded form is what
`ack` records, so what you acknowledge is the file on disk rather than a
readable approximation of its name.
- `~/.claude/projects` (session transcripts and per-project memory) is
**not** mounted read-only, unlike the code-carrying `~/.claude`
directories - deliberately. Scoping a read-only mount to "the
current project's slug"
would break the sandbox's own worktree workflow: a session running in
`WORKSPACE/.worktrees/<branch>` writes to a *different* projects slug
than the parent repo, so a slug-scoped overlay would either block that
legitimate write or have to be widened until it stopped protecting
anything. Instead, cross-project writes are **detected, not prevented**:
the exit-time audit above flags any write during the session to another
project's `~/.claude/projects/<slug>` directory, excluding the current
project's own slug and its worktree children. That's after-the-fact
reporting of transcript/memory poisoning in *other* projects, not a
block on it happening. Residual even within that: the current project's
own transcripts/memory stay fully writable throughout, since Claude Code
needs that for host-side `claude --resume` to work; and detection keys
off each slug's max file mtime, so an edit to a file that isn't already
that project's newest doesn't advance the max and goes unflagged. A
read-only mount for `projects/` remains a possible future option if
Claude Code is confirmed to fail cleanly (not hang, as `~/.claude.json`
did) when a write to it is blocked. This is also the sharpest case of
the time-window caveat above: `~/.claude/projects` is a single
directory shared by *every* Claude Code session on the host, not scoped
to the one being audited, so an entirely innocent concurrent session
working on some unrelated project can produce a `CROSS-PROJECT` finding
here that reads like the audited session poisoned another project's
history - it didn't, it just happened to be running at the same time.
**not** mounted as a tree. A session gets the workspace's own slug
directory plus one for each live git worktree nested under it,
read-write, and nothing else. Every other project's slug is *absent*
from the container rather than present-but-unwritable. Agent memory is
loaded into context at session start, so a write into another project's
`memory/` is text a future session there reads as established background
rather than as input - and until #104 the mount alone was enough to do
that, with no escape and no credential involved.
The set is derived from real worktree paths
(`bin/dejima-slug-lib.sh`), not from the slug string, because a slug
cannot tell a child from a sibling: `/w/proj/wt1` and `/w/proj-wt1`
produce the same one. A `<slug>-*` glob therefore also matches any
project checked out beside this one whose directory name merely extends
it, which is an ordinary layout rather than a contrived one.
`projects/` itself stays container-local, so a slug that is not mounted
is still writable during the session and just does not survive it. That
is what lets Claude Code create a slug it does not find, which is the
first-run case it already handles, and it is why an absent sibling does
not risk the hang that a blocked write to `~/.claude.json` caused.
Cross-project writes are still **detected**: the audit runs host-side over
the whole tree, so narrowing the mount does not narrow it, and it now
covers what the mount cannot reach - concurrent *host* sessions. Residual:
the workspace's own transcripts and memory stay writable, since
`claude --resume` needs that; and detection keys off each slug's max file
mtime, so an edit to a file that is not already the newest goes unflagged.
The time-window caveat applies most sharply here, because on the host
`~/.claude/projects` is a single
directory shared by *every* Claude Code session - so an innocent concurrent
session elsewhere can produce a `CROSS-PROJECT` finding that reads like
poisoning and isn't.
- `~/.claude/file-history` (Claude Code's edit-undo record) is **not**
mounted as a tree either, and the reason is sharper than for `projects/`.
Each `<session>/<hash>@v1` is a **verbatim copy of an edited file**, so the
tree is other projects' source rather than a record about it. Read access
alone is the leak; it was read-write on top of that, and tampering with
another project's undo record puts chosen content into a later restore.
It is keyed by session id, so unlike `projects/` there is no slug in its
own paths to scope it by. The transcript names supply it: `projects/<slug>/
<id>.jsonl` and `file-history/<id>` name the same session, so the slugs the
workspace owns select the session ids it owns
(`workspace_session_ids` in `bin/dejima-slug-lib.sh`). A directory whose
session has no transcript belongs to no project dejima can identify, and is
left out.
The session about to start has an id nothing can know in advance, so its
own directory is created container-local and does not survive the run.
That costs undo history for that session if it is later resumed on the
host, and it is the reason this cannot be scoped as completely as
`projects/`, where the workspace's own slug is known at launch.
- Allowing egress to a domain at all creates a channel for exfiltrating data
through *that service's own request surface* (query strings, lookups,
etc.), not just to a literal attacker-controlled destination. Domain-level
@@ -250,51 +430,45 @@ hands to whichever session starts against it. See
similar. It shares the host kernel, so a container-escape vulnerability
would defeat every other control described here at once.
- Most of what this document asserts is re-checked by the `test/` suite on
every change - over 300 bats tests across six files, and `runtime.bats`
builds the image and asserts against a real running container rather than
a mock: IPv6 blocked, a non-allowlisted domain refused by the DNS proxy
and a non-allowlisted destination unreachable, `node` unable to re-run
`init-firewall.sh` to widen the allowlist, no working `sudo`. Coverage is
not the same as completeness, though. The reasoning in this document is
judgement rather than assertions, nothing tests it, and a property nobody
thought to write a test for can still regress quietly. A missing `ipset`
dedup flag broke firewall startup entirely - twice, on two different
ipsets - before anyone caught it; there is a test asserting every
`ipset add` is duplicate-safe now, which is the pattern to expect. Each
of these tests exists because something was got wrong first.
every change, and `runtime.bats` asserts against a real running container
rather than a mock: IPv6 blocked, a non-allowlisted domain refused and a
non-allowlisted destination unreachable, `node` unable to re-run
`init-firewall.sh`, no working `sudo`. Coverage is not completeness: the
reasoning here is judgement, nothing tests it, and a property nobody wrote
a test for can regress quietly. A missing `ipset` dedup flag broke firewall
startup twice before anyone caught it; there is a test for that now, which
is the pattern to expect - each of these exists because something was got
wrong first.
- The Anthropic OAuth credential (`~/.claude/.credentials.json`) never
enters the container. It simply isn't mounted: `~/.claude` is not shared
as a whole (see the mount inventory below), so there is no path from
enters the container. It isn't mounted: `~/.claude` is not shared as a
whole (see the mount inventory below), so there is no path from
inside the container to that file. `dejima` starts a host-side proxy
(`bin/dejima-anthropic-proxy`) on a per-session unix socket and mounts a
fake credential in place of the real one - the two secrets are
placeholders, while `scopes`/`subscriptionType`/`rateLimitTier`/`clientId`
are copied from the real file so the session's own UI stays accurate. Its
expiry is set a year out rather than mirrored, deliberately: a near-term
one would make Claude Code refresh on its own, with a refresh token that
isn't real, and log the session out. Inside the container,
fake credential in place of the real one - the secrets are placeholders,
while `scopes`/`subscriptionType`/`rateLimitTier`/`clientId` are copied so
the session's UI stays accurate. Its expiry is a year out rather than
mirrored: a near-term one would make Claude Code refresh on its own with a
refresh token that isn't real, logging the session out. Inside the
container,
`entrypoint.sh` points `api.anthropic.com` at a loopback TLS terminator
(`anthropic-proxy-forwarder.js`) whose certificate is generated at startup
and trusted only there, and which pipes raw bytes into the proxy's socket.
The proxy injects the real access token per request and owns OAuth refresh
- rotated tokens are written back to the real host file atomically, so
host-side sessions stay logged in. Claude Code itself is configured
exactly as it would be on the host: no `ANTHROPIC_BASE_URL`, no
`ANTHROPIC_AUTH_TOKEN`, nothing that marks the session as unusual to the
client. The proxy then forwards the client's header block verbatim -
same names, same casing, same order, repeats intact, and no
`Accept-Encoding` it didn't send - changing only the `Authorization`
value, and `anthropic-beta` if the OAuth value isn't already there. Left
to itself, Python's `http.client` would have rewritten all of that into
its own house style and moved the fingerprint from the client to the
wire. What it cannot disguise is that the connection is made by CPython's
`ssl` rather than by Node: the TLS handshake (JA3/JA4) and the absence of
connection reuse still distinguish it at that layer. What remains is
session-scoped: a
compromised session can still *use* the model API as you while it runs
(that's inherent to the sandbox working at all), but it can no longer read
a durable credential and exfiltrate it for offline use via the other
allowed egress destinations (npm, GitHub, your Gitea host). Caveats:
exactly as on the host: no `ANTHROPIC_BASE_URL`, no
`ANTHROPIC_AUTH_TOKEN`, nothing marking the session as unusual. The proxy
forwards the client's header block verbatim - same names, casing, order and
repeats, and no `Accept-Encoding` it didn't send - changing only
`Authorization`, and `anthropic-beta` if the OAuth value isn't there. Left
alone, Python's `http.client` would rewrite all of that into its own style
and move the fingerprint from the client to the wire. It cannot disguise
that the connection is CPython's `ssl` rather than Node: the TLS handshake
(JA3/JA4) and the absence of connection reuse still distinguish it.
What remains is session-scoped: a compromised session can still *use* the
API as you while it runs, which is inherent to the sandbox working at all,
but it cannot read a durable credential and exfiltrate it for offline use.
Caveats:
`/login` inside the container doesn't work while the proxy is on; the
refresh flow mirrors a Claude Code internal and may break if upstream
changes it, in which case the proxy fails loudly (launch abort, 502s on
@@ -348,13 +522,21 @@ hands to whichever session starts against it. See
are enumerated in `docker-compose.yml`, in three classes, and anything not
listed is container-local: writable during the session and gone with the
container.
- **Read-write, because it has to survive the session**: `projects/`,
`file-history/`, `backups/`, `tasks/`, `plans/`, `sessions/`. All
directories, which is what makes them safe (see the detachment note
below).
- **Read-write, because it has to survive the session**: `backups/`,
`tasks/`, `plans/`, `sessions/`, plus the entries of `projects/` and
`file-history/` that belong to this workspace. All directories, which is
what makes them safe (see the detachment note below). The two trees are
not mounted whole: see the `projects/` note further down, and the
`file-history/` note below it.
- **Read-only**: `plugins/`, `hooks/`, `skills/`, `commands/`, `agents/`,
`output-styles/`, plus the symlink-target directories `dejima`
discovers at launch. Hooks execute automatically with no separate
discovers at launch. Those targets are discovered rather than
configured, so they are checked against the approval store and the
audit stores and refused if they overlap either - a hook symlinked into
the approval store used to mount it, read-only and silently, and
read-only is no protection when the content is every project's
approvals. The same check now covers `[mounts] readonly`, which had
none at all. Hooks execute automatically with no separate
approval step, skills can ship executable scripts, and
commands/agents/output-styles are prompt text loaded into a later host
session, so without this a compromised session could plant a backdoored
@@ -373,8 +555,7 @@ hands to whichever session starts against it. See
`statusline.sh` are mounted read-only at `/etc/dejima-claude/` and
installed into `~/.claude` by `entrypoint.sh` as root-owned, unwritable
files. A session can still unlink and replace them - `~/.claude` belongs
to it - but only its own copy, which was the whole point of making them
read-only.
to it - but only its own copy, which is why they are read-only.
The reason for the split is that a **single-file bind mount silently
detaches**. It is pinned to the dentry at its mount point, so when the
@@ -463,8 +644,7 @@ hands to whichever session starts against it. See
sandboxed session (e.g. a token refresh) doesn't carry back out. Worth
re-checking if a future Claude Code version starts relying on that for
normal operation.
- DNS resolution itself is allowlisted (see "DNS is allowlisted too" in the
README),
- DNS resolution itself is allowlisted (see "DNS is allowlisted too" below),
not just the IP connections made afterward, closing off exfiltration via
encoding data into query names to an attacker-controlled domain - verified
a non-allowlisted domain gets refused outright (`status: REFUSED`) rather
@@ -477,10 +657,13 @@ hands to whichever session starts against it. See
rather than repo-scoped, so a compromised session can still reach (and
push to) other repos on that host you have access to. This doesn't extend
to GitHub by default: port 22 there is blocked unless `--allow-github-push`
is given, so the forwarded agent has no transport to use for it at all
otherwise (see "GitHub push: blocked by default, optional opt-in" above).
- `[core] ssh_key` (see "Headless git push without agent forwarding") is a
second, independent credential-loading path with the same "signs for any
is given, or `[core] forge = github` is selected with a workflow that
pushes (which opens the same grant), so the forwarded agent otherwise has
no transport to use for it at all (see "GitHub push: blocked by default,
optional opt-in" below).
- `[core] ssh_key` (see [Headless git push without agent
forwarding](configuration.md#headless-git-push-without-agent-forwarding))
is a second, independent credential-loading path with the same "signs for any
Gitea repo it has access to" shape as the forwarded agent above - the
mitigation is that it's meant to be a dedicated deploy key scoped to only
the repos that actually need push access, not your personal identity.
@@ -502,7 +685,7 @@ hands to whichever session starts against it. See
SSH-agent/token story documented above. Keep credentials out of your
global gitconfig, or expect them to be reachable from inside the
sandbox.
- The sandbox's own user (matching your host user — see "Config parity" in
- The sandbox's own user (matching your host user; see "Config parity" in
the README) has no `sudo`/setuid path back to root once the container is up:
`entrypoint.sh` runs as root, sets up the firewall directly, then drops to
that user via `gosu` before execing the configured command (`[core] command`,
@@ -512,7 +695,9 @@ hands to whichever session starts against it. See
cleared from the effective/permitted sets on the `gosu` drop, and that
user gets a permission error running `iptables` directly.
- The approval store (`${DEJIMA_CONFIG_DIR:-$HOME/.config/dejima}`,
see "Global and per-project domains and dependencies" above) lives outside
see
[Global and per-project domains and dependencies](../README.md#global-and-per-project-domains-and-dependencies)
in the README) lives outside
this repo, and outside every project mount, by default - unlike a
project's own `.dejima/` declaration files, it's never
bind-mounted into the container, so a compromised
@@ -522,8 +707,8 @@ hands to whichever session starts against it. See
`bin/dejima-project-lib.sh`), never sourced or executed, and are
written only by the CLIs (`dejima-allow`, `dejima-dep`,
`dejima-project`) a human runs on the host. `dejima`
warns at launch whenever the resolved store path — default or overridden
via `DEJIMA_CONFIG_DIR` — lands under the mounted workspace,
warns at launch whenever the resolved store path (default or overridden
via `DEJIMA_CONFIG_DIR`) lands under the mounted workspace,
rather than silently trusting it.
- When the mounted workspace *is* this repo itself, `dejima`
warns at launch that the repo's own trusted computing base - `bin/`,
@@ -535,3 +720,306 @@ hands to whichever session starts against it. See
session can still edit those files during the run, and whatever it
changes takes effect the *next* time `dejima` itself runs,
not the current one.
### Declaration files are session-writable
A project's `.dejima/` declaration files sit inside the project mount, which
is bind-mounted read-write. A compromised session can write them, and that is
fine by design: declaring grants nothing, and the approval store the session
cannot reach is what a declaration has to pass through.
What a session *could* try is to make the host-side write land somewhere else.
Plant `.dejima`, or `.dejima/<kind>`, as a symlink pointing at your approval
store or any other file you can write, then get you to run the
`dejima-allow --project` or `dejima-dep add` that appends to it. The append
happens on the host, as you, so it inherits your write access rather than the
container's.
Both writers refuse a symlink at either of those two leaves. The refusal is
the `open` itself and not a check before it: `O_NOFOLLOW` on both, with the
file opened relative to the already-opened directory, so a session still
running while you type the command cannot swap either one out from under the
write. Those two leaves are what a session controls. A symlink further up a
`--project` path you supplied yourself is still resolved through, because
that part of the path is your claim about where the project is, not the
session's.
Expect the session to be live when you run these: the agent briefing tells it
to ask you to run them from a terminal outside the sandbox, which is exactly
the window this closes.
## Network egress firewall
On start, the container runs an allowlist-only `iptables`/`ipset` firewall
(`init-firewall.sh`), matching the reference dev container. Allowed by
default:
- DNS resolution via a local proxy (see "DNS is allowlisted too")
- the Docker host itself (not its whole subnet)
- GitHub's published IP ranges, port 443 only (see "GitHub push" below)
- `registry.npmjs.org` and `platform.claude.com` (the harness and its
updates), `release-assets.githubusercontent.com` (GitHub release assets, a
separate CDN from github.com's own IPs, and where a good deal of tooling
fetches its binaries); `api.anthropic.com` when the credential proxy is off
(kill switch or API-key auth)
That is the whole built-in list, and it is meant to stay that way: it covers
what the sandbox itself cannot run without, not what a project might want.
Language package hosts are approved rather than assumed: `pypi.org`,
`proxy.golang.org`, `crates.io` and the rest are one `dejima-allow --global`
or one reviewed `.dejima/allowed-domains` line away. The reason to keep the
built-in list short is that everything on it is reachable from every sandbox
on the host whether or not a project has any use for it, and a name granted
by being built in is a name nobody ever reviewed
- whatever the selected forge needs reachable (`[forge.gitea] ssh_host`/`api`
by default): not port-restricted, so git-over-ssh to the forge works
without a blanket "port 22 to anywhere" rule, and exempted from DNS-rebind
protection because a self-hosted forge host legitimately resolves to an
internal address. `[core] forge = github` is deliberately *not* added here:
its three hostnames are public names already covered by the GitHub entry
above, so they stay on that entry: 443 by default, plus port 22 when the
selected workflow pushes, exactly as `--allow-github-push` opens it per
launch, rather than picking up all-port egress and losing rebind
protection on the way
Everything else is rejected. Run `dejima-allow --global <domain>`
(or `--project <path>` for one project) to allow more permanently (e.g.
`crates.io` for a Rust project), or `dejima --no-firewall` to disable the
firewall entirely for one launch (debugging only).
IPv6 is blocked outright (`ip6tables` DROP on INPUT/OUTPUT/FORWARD, `dnsmasq`
strips AAAA answers): there's no IPv6 equivalent of the allowlist above, so
leaving it open would bypass the whole firewall on any host with IPv6
egress.
If your Gitea host only resolves via a LAN-local DNS server, make sure the
Docker host itself can resolve it: containers inherit the host's resolver.
### DNS is allowlisted too
Resolution itself is restricted, not just the IP connections made
afterward: `dns-proxy.sh` runs a local `dnsmasq` with no fallback resolver,
and `/etc/resolv.conf` is repointed at it. Only names on the same allowlist
above (plus `github.com`/`githubusercontent.com`/`githubassets.com`, needed
since GitHub itself is allowlisted by IP range; see `fixed-domains.sh`) get
forwarded to Docker's real resolver; anything else gets no upstream
configured at all and is refused outright. Without this, a compromised
session could exfiltrate data by encoding it into query names to an
attacker-controlled domain, even with no direct connection ever possible.
### Adding a domain to a running session
Approved domains only take effect at container start. For a domain a
*running* session needs right now, run from the host (not inside the
sandbox: it has no privilege to do this itself):
```sh
dejima-allow crates.io # this running session only
dejima-allow --container <id> crates.io # a specific session, if more than one is running
```
It first adds the domain to the running dnsmasq's allowlist (appends a
`server=` line to its servers-file and sends `SIGHUP` to reload just that
file - no restart), then resolves it through the now-unblocked container
resolver and adds the IP(s) to the running firewall's `ipset`, all via
`docker exec -u root`. It finishes by checking the domain is actually
reachable from inside that same container and prints a definitive verdict:
exit code 2 if it isn't.
`--global` and `--project` are the opposite of this: they record an approval
for *future* sessions and deliberately do not touch a running container, even
together - see
[Global and per-project domains and dependencies](configuration.md#global-and-per-project-domains-and-dependencies)
in docs/configuration.md, since the domain being approved may legitimately
not answer on 443 (an
internal service, an SSH-only host) and the natural moment to add an approval
is precisely when nothing is running to probe in the first place. Pass
`--container <id>` alongside a scope flag to do both in one call:
`dejima-allow --global --container <id> crates.io` records the approval and
patches that session immediately, in that order, so the approval is never
lost to a live-session failure. If more than one dejima container is
running, the bare form above refuses and asks you to disambiguate with
`--container <name>` - `docker ps` finds it, or use the
container's own ID, which `sandbox-context.sh` already hands the agent in
its startup context (and which is always available inside the sandbox as
`cat /etc/hostname`), so there's no need for it to guess one and risk
"successfully" updating someone else's session instead.
A domain allowed this way stays allowed for the rest of the session even
if its CDN later rotates edge IPs: `dns-proxy.sh` tops up the firewall's
`ipset` from every DNS answer it serves, not just the one resolved at
allow-time, since only allowlisted names get an upstream to answer from in
the first place. GitHub's own domains sit outside this - their existing
port-443-only `ipset` entries still win by dnsmasq's longest-match rule -
so that restriction is unaffected.
One gap remains: allowing an *internal* hostname (one that resolves to an
RFC1918 address) mid-session doesn't work. dnsmasq's `stop-dns-rebind`
rejects private answers for any domain not exempted at startup, and that
exemption list can only be extended by restarting dnsmasq. Internal hosts
named before the container starts (`[forge.gitea] ssh_host`, `api`,
`[logging] loki_url`, and any globally or per-project approved domain) are
already exempted and unaffected by this.
The sandbox-context.sh `SessionStart` hook tells the agent to ask for this
by name when a task is blocked on a single missing domain, rather than
stopping the whole session over it.
## Network request logging
If `[logging] loki_url` is set (see Prerequisites in the README), every DNS query and every new
outbound connection attempt - allowed or rejected - is shipped to that Loki
endpoint. Two sources, both tailed and pushed by `log-shipper.sh`:
- `dns-proxy.sh`'s dnsmasq logs every query and whether it was forwarded
(allowed) or refused (not on the allowlist) - labeled `job=dejima-dns`.
- `init-firewall.sh` tags its terminal ACCEPT/REJECT rules with `NFLOG`
(group 1 for allowed, group 2 for rejected), which `ulogd2`
(`ulogd.conf`) turns into JSON lines with source/dest IP, port, and an
`action` field - labeled `job=dejima-net`. Only new connection
attempts are logged, not every packet: the firewall's existing
ESTABLISHED,RELATED rule short-circuits the rest of a connection before it
ever reaches the tagged rules.
This is visibility on top of the firewall/DNS-proxy enforcement above, not a
substitute for it - nothing about logging changes what's actually allowed to
resolve or connect.
### Conversation logging (opt-in)
Set `[logging] conversation = on` (with `loki_url` configured) to also ship
session content to Loki as the `dejima-conversation` job: your
prompts, the agent's text responses, and tool invocations (tool name +
input - never tool outputs). Lines are JSON with the session ID inside
(`{session, role, text}` / `{session, role: "tool", tool, input}`).
Off by default - conversation content is a different sensitivity class
than DNS names and firewall verdicts, so it never leaves the container
unless you opt in. Transcripts of every session under the mounted
workspace (including worktrees and subagent sessions) are shipped while
the sandbox runs.
### Viewing logs in Grafana
Nothing sandbox-specific needed on the Grafana side beyond a Loki data
source pointed at wherever `[logging] loki_url` resolves to (Connections → Data
sources → Loki, if one isn't already configured against that instance) -
`log-shipper.sh` pushes directly to Loki's HTTP API, there's no Prometheus
scrape target or exporter involved.
In Grafana's Explore view (or a dashboard panel with a Loki query), each
stream is selectable by its `job` label:
```logql
{job="dejima-dns"} # every DNS query + verdict
{job="dejima-net"} # every connection attempt, as JSON
{job="dejima-conversation"} # the session itself, if opted in
```
Useful filters on top of those:
```logql
# DNS lookups that got refused (not on the allowlist)
{job="dejima-dns"} |= "REFUSED"
# Rejected connections only (parses ulogd2's JSON, filters on its action field)
{job="dejima-net"} | json | action="blocked"
# All three together, e.g. to watch a live session across sources
{source="dejima"}
```
The conversation stream, when `[logging] conversation` is on, is one JSON
object per line carrying `session`, `role`, and either `text` or a `tool` name
with its `input`:
```logql
# Just what you asked for, without the replies
{job="dejima-conversation"} | json | role="user"
# Every shell command a session ran, and with what arguments
{job="dejima-conversation"} | json | tool="Bash"
# One session end to end, id taken from a line you already found
{job="dejima-conversation"} | json | session="0198e2c1-..."
# Anything a session said about a subject, across every session
{job="dejima-conversation"} |= "allowed-domains"
```
`session` is deliberately a field inside the line rather than a Loki label:
one label per session would multiply the stream cardinality by the number of
sessions you have ever run, which is how a Loki instance stops being usable.
Filter on it with `| json | session=` as above rather than expecting a
selector.
Two things are absent by design. **Tool results are never shipped**, only
invocations, so a query will show you that a session ran `Bash` with certain
arguments and never what came back. **Thinking blocks are never shipped either.** Both dominate the volume
and neither was what opting in asked for. Long values are clipped, so a very
large prompt or tool input ends in `...[truncated]` rather than being dropped.
`{job="dejima-net"}` lines are one full ulogd2 JSON object per
connection attempt (`| json` in a query unpacks fields like `dest_ip`,
`dest_port`, `action`) - Loki's Explore view shows these expandable/
pretty-printed by default, no extra parsing setup needed.
## GitHub push: blocked by default, optional opt-in
`dejima --allow-github-push` is a per-launch *addition* to whichever forge is
selected, not a substitute for selecting one. A pushing workflow is still
refused at launch unless its forge resolves to a host with its required keys
filled in, so the flag alone will not get an unconfigured `[forge.gitea]` past
validation.
This section is about `--allow-github-push`, a per-launch flag that adds
GitHub as a push destination on top of whichever forge is selected, without
changing that selection. It adds a destination; it does not stand in for a
forge, so the launch-time rules for the selected forge/workflow pair still
apply exactly as they would without it. If you
want GitHub push and PRs as a standing choice instead, set
`[core] forge = github` (see
[Shipped providers](providers.md#shipped-providers)): it also opens
`ssh.github.com:22`, but persistently rather than per launch, and uses its
own `[forge.github] push_token_file` rather than a loaded SSH key.
The sandbox can always read from GitHub (clone, fetch, `gh` API calls). By
**default** it can never push there: enforced at the network layer, not
just by `git-push-guard.sh`. GitHub's IP ranges are only allowlisted on port
443. Port 22 is unreachable, so SSH-based push has no transport at all,
regardless of what any hook does or doesn't catch.
**Opt-in, not hardcoded**: pass `--allow-github-push` to open port 22 to
GitHub and let `git-push-guard.sh` accept `github.com`/`ssh.github.com` as a
push destination (the no-direct-to-main rule still applies):
```sh
dejima --allow-github-push ~/repos/some-project
```
**Read this before turning it on.** Whether a push actually succeeds depends
entirely on what SSH key is loaded into the agent. A forwarded personal
agent with your normal GitHub key would let a compromised session push to
**any** repo that key can write to, not just the one you intended: agent
forwarding doesn't distinguish push from pull. Use a dedicated,
**repo-scoped GitHub deploy key** via `[core] ssh_key` instead whenever
`--allow-github-push` is set: that's what actually limits the blast radius.
`--allow-github-push` only changes the firewall/hook's *host* check; it adds
no credential-loading path of its own. The flag itself has no config-file
equivalent: an exported or saved value must not silently grant a push path,
so *this* opt-in stays a per-launch decision only. Selecting `[core] forge =
github` (see [Shipped providers](providers.md#shipped-providers)) is a
config-file path that opens the same port, but not
silently: `hosts_allowlisted_by_core` is what asks for it, explicitly, in a
manifest, and only when the selected workflow actually pushes. What it opens
is the same GitHub ranges the read-only path already allows on port 443, now
also on port 22, logged like every other allowed connection and verified by
the firewall's own startup self-check.
`[github] token_file` must point at a **read-only** fine-grained PAT
regardless of this setting. `gh auth setup-git` (run at container start if
that file exists) points git's github.com HTTPS credential helper at `gh`,
which only ever holds whatever token that file provides: a write-scoped
token there would make HTTPS push possible independent of
`--allow-github-push`, so don't use one.
+324
View File
@@ -0,0 +1,324 @@
#!/bin/bash
# Read Gitea Actions runs and job logs from a shell - the CI-side counterpart
# to gitea-pr and gitea-issue. Self-contained like both of them rather than
# sharing a library: all three are copied into the image as single files, so
# a library would be another thing to keep in step. test/static.bats pins the
# duplicated config/token/curl preamble instead.
#
# It exists for the agent: CI is where the tests the sandbox cannot run
# actually run, and without this the only way to see a failure is for the
# operator to paste the log in.
#
# Nothing here touches /repos/{owner}/{repo}/actions/runs or the endpoints
# reached through it, however natural they look. Gitea gates that group
# behind reqOwner(), so a dedicated non-owner account - what a sandbox should
# have - is refused, and no scope grants ownership. The three sources used
# instead need only read access:
#
# actions/tasks one record per job, carrying its run number,
# head_sha, event and result
# commits/<sha>/statuses the same jobs as commit statuses, and the only
# place a job's index is visible
# the web log route the run view's own endpoint, which takes the
# same token header as the API
#
# The job index matters because it is what addresses a log, and it is not a
# field anywhere - only the tail of a status's target_url.
set -euo pipefail
GITEA_TOKEN_FILE="${GITEA_TOKEN_FILE:-$HOME/.config/gitea/token}"
function usage() {
echo "Usage: gitea-actions runs [--limit N]"
echo " gitea-actions jobs [RUN]"
echo " gitea-actions logs [RUN] [--job NAME|INDEX] [--all] [--timestamps]"
echo ""
echo "RUN is the number in a run's web URL (.../actions/runs/52). Left out,"
echo "it means the newest run for HEAD's commit - after a push, that is"
echo "almost always the one being asked about."
echo ""
echo "'logs' prints the failed jobs of a run; --all prints every job, and"
echo "--job picks one by name (a substring is enough) or by index."
echo "Timestamps are stripped unless --timestamps is given."
echo ""
echo "Requires GITEA_SSH_HOST and GITEA_API set in the environment (e.g. in" \
"your shell profile), and a token at $GITEA_TOKEN_FILE (or GITEA_TOKEN" \
"set for this invocation only)."
}
function require_config() {
if [ -z "${GITEA_SSH_HOST:-}" ]; then
echo "GITEA_SSH_HOST is not set. Add 'export GITEA_SSH_HOST=<gitea ssh hostname>'" \
"(e.g. gitea.example.com) to ~/.profile.local" >&2
exit 1
fi
if [ -z "${GITEA_API:-}" ]; then
echo "GITEA_API is not set. Add 'export GITEA_API=https://<gitea host>/api/v1'" \
"(e.g. https://gitea.example.com/api/v1) to ~/.profile.local" >&2
exit 1
fi
}
function require_token() {
if [ -z "${GITEA_TOKEN:-}" ] && [ -f "$GITEA_TOKEN_FILE" ]; then
GITEA_TOKEN=$(<"$GITEA_TOKEN_FILE")
fi
if [ -z "${GITEA_TOKEN:-}" ]; then
echo "No Gitea token found. Generate one in the Gitea web UI" \
"(Settings > Applications, scopes write:repository + write:issue)" \
"and save it to $GITEA_TOKEN_FILE (chmod 600), or set GITEA_TOKEN" \
"in the environment for this invocation only." >&2
exit 1
fi
}
# Feeds the auth header to curl as a config on stdin rather than as an
# argument. Command lines are world-readable through /proc, so `-H
# "Authorization: token $GITEA_TOKEN"` exposes the token to every other
# process on the host for as long as the request runs - and to anything that
# scrapes `ps` output, including the agents this repo exists to sandbox.
# printf is a bash builtin, so the token never lands in another argv.
function gitea_api() {
printf 'header = "Authorization: token %s"\n' "$GITEA_TOKEN" |
curl -sS -w '\n%{http_code}' -K - "$@"
}
# Splits gitea_api's combined output into the globals `json` and `http_code`
# and fails with the body on any non-2xx.
function api_or_die() {
local what="$1"; shift
local response
response=$(gitea_api "$@")
http_code=$(echo "$response" | tail -1)
json=$(echo "$response" | sed '$d')
if [ "$http_code" -lt 200 ] || [ "$http_code" -ge 300 ]; then
echo "Failed to $what (HTTP $http_code):" >&2
echo "$json" >&2
exit 1
fi
}
function gitea_owner_repo() {
local remote_url host owner_repo
remote_url=$(git remote get-url origin 2>/dev/null) || {
echo "No 'origin' remote found in this repository" >&2
exit 1
}
host=$(echo "$remote_url" | sed -E 's#^[^@]+@([^:]+):.*#\1#')
if [ "$host" != "$GITEA_SSH_HOST" ]; then
echo "origin remote ($remote_url) is not on $GITEA_SSH_HOST; gitea-actions only supports this host" >&2
exit 1
fi
owner_repo=$(echo "$remote_url" | sed -E 's#^[^@]+@[^:]+:##; s#\.git$##')
echo "$owner_repo"
}
# Sets `tasks_json`. The list pages by TASK - one record per job - so a run
# costs as many records as it has jobs, and `limit` is ignored unless `page`
# comes with it. Taken as one page, sized for a few dozen runs of a ten-job
# workflow; a lookup that falls off the end says which it was.
function fetch_tasks() {
api_or_die "list runs" \
"${GITEA_API}/repos/${owner}/${repo}/actions/tasks?page=1&limit=$TASK_SCAN"
tasks_json="$json"
}
# Sets `run_number` and `run_sha`. With no number, HEAD's commit: a run list
# is not what someone who just pushed is asking for. Matched on head_sha
# rather than on branch because a pull_request run reports no branch at all -
# its ref is refs/pull/N/head.
function resolve_run() {
local want="${1:-}" sha
fetch_tasks
if [ -n "$want" ]; then
run_sha=$(echo "$tasks_json" | jq -r --argjson n "$want" \
'first(.workflow_runs[] | select(.run_number == $n) | .head_sha) // empty')
if [ -z "$run_sha" ]; then
echo "No run numbered $want in the last $TASK_SCAN job records (see 'gitea-actions runs')" >&2
exit 1
fi
run_number="$want"
else
sha=$(git rev-parse HEAD 2>/dev/null) || {
echo "Not in a git repository, so there is no HEAD to look up - pass a run number" >&2
exit 1
}
run_number=$(echo "$tasks_json" | jq -r --arg sha "$sha" \
'first(.workflow_runs[] | select(.head_sha == $sha) | .run_number) // empty')
if [ -z "$run_number" ]; then
echo "No run for HEAD (${sha:0:8}) in the last $TASK_SCAN job records - unpushed, or still queued?" >&2
exit 1
fi
run_sha="$sha"
fi
}
# Sets `jobs_json` for the resolved run, from the commit's statuses - the one
# place a job's index is visible to a token that does not own the repo.
#
# A status is written more than once per job (queued, then finished) and the
# list arrives newest first, so the newest per context wins. `context` is
# "<workflow> / <job> (<event>)"; the job name is pulled out of it for
# display, and matching keeps the whole string so either form works. Filtered
# by run number as well as by commit, since a re-run puts a second run's
# statuses on the same commit.
function fetch_jobs() {
api_or_die "list jobs for run $run_number" \
"${GITEA_API}/repos/${owner}/${repo}/commits/${run_sha}/statuses?page=1&limit=100"
jobs_json=$(echo "$json" | jq --arg rn "$run_number" '
[.[] | select(.target_url | test("/actions/runs/" + $rn + "/jobs/[0-9]+$"))]
| group_by(.context) | map(first)
| map({
index: (.target_url | split("/") | last | tonumber),
name: (.context | sub("^[^/]+/ *"; "") | sub(" *\\([^()]*\\)$"; "")),
context,
result: .status
})
| sort_by(.index)')
if [ "$(echo "$jobs_json" | jq 'length')" -eq 0 ]; then
echo "Run $run_number (${run_sha:0:8}) reports no jobs." >&2
echo "Gitea publishes a run's jobs as statuses on its commit, and this" >&2
echo "commit has none for that run - so there is nothing to address a log by." >&2
exit 1
fi
}
function print_job_log() {
local index="$1" name="$2" result="$3"
echo "=== $name ($result) ==="
api_or_die "fetch logs for job $name" \
"${GITEA_WEB}/${owner}/${repo}/actions/runs/${run_number}/jobs/${index}/logs"
if [ "$KEEP_TIMESTAMPS" = 1 ]; then
echo "$json"
else
# Every line carries an RFC3339 stamp from the runner. Useful for
# timing a hang, pure noise for reading a failure, and this output
# is usually going into an agent's context.
echo "$json" | sed -E 's/^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9:.]+Z //'
fi
echo
}
TASK_SCAN=600
KEEP_TIMESTAMPS=0
if [ "$#" -lt 1 ]; then
usage
exit 1
fi
subcommand="$1"
shift
case "$subcommand" in
runs|jobs|logs)
require_config
require_token
owner_repo=$(gitea_owner_repo)
owner="${owner_repo%%/*}"
repo="${owner_repo##*/}"
# Logs come from the run view's own endpoint rather than the API, so
# the web root is needed as well. Derived from GITEA_API because that
# is the only address the caller configures.
GITEA_WEB="${GITEA_API%/api/v1}"
if [ "$GITEA_WEB" = "$GITEA_API" ]; then
echo "GITEA_API ($GITEA_API) does not end in /api/v1, so the web root" >&2
echo "cannot be derived from it - logs are read from a non-API route." >&2
exit 1
fi
;;
*)
usage
exit 1
;;
esac
case "$subcommand" in
runs)
limit=20
while [ "$#" -gt 0 ]; do
case "$1" in
--limit) limit="$2"; shift 2 ;;
*) echo "Unknown option: $1" >&2; usage; exit 1 ;;
esac
done
fetch_tasks
# One record per job, so a run appears once for each job it has.
# Folded here into a row per run, whose result is the worst of them:
# a run with one failed job is a failed run, and reporting it as
# seven successes and a failure is not what the caller asked.
echo "$tasks_json" | jq -r --argjson limit "$limit" '
[.workflow_runs[]] | group_by(.run_number)
| map({
run: .[0].run_number,
event: .[0].event,
sha: (.[0].head_sha[0:8]),
title: .[0].display_title,
result: (if any(.[]; .status == "failure") then "failure"
elif any(.[]; .status == "cancelled") then "cancelled"
elif any(.[]; .status != "success") then "in_progress"
else "success" end)
})
| sort_by(.run) | reverse | .[0:$limit] | .[]
| [(.run | tostring), .result, .event, .sha, .title] | @tsv' |
while IFS=$'\t' read -r run result event sha title; do
printf '%-5s %-11s %-20s %-8s %s\n' "$run" "$result" "$event" "$sha" "$title"
done
;;
jobs)
run_arg=""
if [[ "${1:-}" =~ ^[0-9]+$ ]]; then run_arg="$1"; shift; fi
if [ "$#" -gt 0 ]; then echo "Unknown option: $1" >&2; usage; exit 1; fi
resolve_run "$run_arg"
fetch_jobs
echo "run $run_number:"
echo "$jobs_json" | jq -r '.[] | [(.index | tostring), .result, .name] | @tsv' |
while IFS=$'\t' read -r index result name; do
printf '%-3s %-11s %s\n' "$index" "$result" "$name"
done
;;
logs)
run_arg=""
job_want=""
want_all=0
if [[ "${1:-}" =~ ^[0-9]+$ ]]; then run_arg="$1"; shift; fi
while [ "$#" -gt 0 ]; do
case "$1" in
--job) job_want="$2"; shift 2 ;;
--all) want_all=1; shift ;;
--timestamps) KEEP_TIMESTAMPS=1; shift ;;
*) echo "Unknown option: $1" >&2; usage; exit 1 ;;
esac
done
resolve_run "$run_arg"
fetch_jobs
if [ -n "$job_want" ]; then
# An index is exact; anything else matches the name loosely, so
# `--job host-docker` finds "host-docker" and `--job static`
# finds "sandbox (static)".
if [[ "$job_want" =~ ^[0-9]+$ ]]; then
selected=$(echo "$jobs_json" | jq --argjson i "$job_want" '[.[] | select(.index == $i)]')
else
selected=$(echo "$jobs_json" | jq --arg n "$job_want" '[.[] | select(.context | contains($n))]')
fi
if [ "$(echo "$selected" | jq 'length')" -eq 0 ]; then
echo "No job matching '$job_want' in run $run_number:" >&2
echo "$jobs_json" | jq -r '.[] | " \(.index) \(.name)"' >&2
exit 1
fi
elif [ "$want_all" = 1 ]; then
selected="$jobs_json"
else
selected=$(echo "$jobs_json" | jq '[.[] | select(.result != "success")]')
if [ "$(echo "$selected" | jq 'length')" -eq 0 ]; then
echo "run $run_number: all $(echo "$jobs_json" | jq 'length') jobs succeeded (--all for their logs anyway)" >&2
exit 0
fi
fi
while IFS=$'\t' read -r index name result; do
print_job_log "$index" "$name" "$result"
done < <(echo "$selected" | jq -r '.[] | [(.index | tostring), .name, .result] | @tsv')
;;
esac
+31 -1
View File
@@ -13,6 +13,7 @@ function usage() {
echo "Usage: gitea-issue list [--state open|closed|all] [--limit N]"
echo " gitea-issue view NUMBER"
echo " gitea-issue create --title TITLE [--body BODY | --body-file FILE]"
echo " gitea-issue edit NUMBER [--title TITLE] [--body BODY | --body-file FILE]"
echo " gitea-issue comment NUMBER (--body BODY | --body-file FILE)"
echo " gitea-issue close NUMBER"
echo " gitea-issue reopen NUMBER"
@@ -153,7 +154,7 @@ subcommand="$1"
shift
case "$subcommand" in
list|view|create|comment|close|reopen)
list|view|create|edit|comment|close|reopen)
require_config
require_token
owner_repo=$(gitea_owner_repo)
@@ -223,6 +224,35 @@ ${body}"
'{title: $title, body: $body}')"
echo "$json" | jq -r '.html_url'
;;
edit)
require_number "${1:-}"
number="$1"
shift
parse_body_args "$@"
if [ -z "$parsed_title" ] && [ -z "$body" ]; then
echo "gitea-issue edit requires --title, --body or --body-file" >&2
exit 1
fi
# Only the fields actually given are sent: an omitted key leaves that
# field alone, so editing a title cannot blank a body by accident.
payload='{}'
if [ -n "$parsed_title" ]; then
payload=$(echo "$payload" | jq --arg t "$parsed_title" '. + {title: $t}')
fi
if [ -n "$body" ]; then
# The same marker create writes. It describes where the body came
# from rather than the event that wrote it, so an edited body
# keeps the disclosure instead of quietly shedding it.
payload=$(echo "$payload" | jq --arg b "**Filed by LLM agent**
$body" '. + {body: $b}')
fi
api_or_die "edit issue #${number}" -X PATCH "${issues_api}/${number}" \
-H "Content-Type: application/json" -d "$payload"
echo "$json" | jq -r '.html_url'
;;
comment)
require_number "${1:-}"
number="$1"
+164 -78
View File
@@ -5,12 +5,19 @@ GITEA_TOKEN_FILE="${GITEA_TOKEN_FILE:-$HOME/.config/gitea/token}"
function usage() {
echo "Usage: gitea-pr create [--title TITLE] [--body BODY | --body-file FILE] [--base BRANCH]"
echo " gitea-pr comments PR_NUMBER"
echo " gitea-pr edit PR_NUMBER [--title TITLE] [--body BODY | --body-file FILE]"
echo " gitea-pr comments PR_NUMBER [--all]"
echo " gitea-pr merge PR_NUMBER"
echo ""
echo "'comments' shows unresolved review threads by default. --all includes"
echo "the ones already resolved, marked as such."
echo ""
echo "--body-file reads the body from FILE, or from stdin when FILE is '-'."
echo "Prefer it over --body for anything with quotes, backticks or newlines."
echo ""
echo "'edit' replaces the title and/or body of an existing PR. Whichever of"
echo "the two is not given is left as it is."
echo ""
echo "Requires GITEA_SSH_HOST and GITEA_API set in the environment (e.g. in" \
"your shell profile), and a token at $GITEA_TOKEN_FILE (or GITEA_TOKEN" \
"set for this invocation only)."
@@ -53,6 +60,23 @@ function gitea_api() {
curl -sS -w '\n%{http_code}' -K - "$@"
}
# Splits gitea_api's combined output into the globals `json` and `http_code`
# and fails with the body on any non-2xx. Copied from gitea-issue, which grew
# it first for the same reason: every call site here did this by hand, and a
# fifth hand-rolled copy is where one of them quietly stops checking.
function api_or_die() {
local what="$1"; shift
local response
response=$(gitea_api "$@")
http_code=$(echo "$response" | tail -1)
json=$(echo "$response" | sed '$d')
if [ "$http_code" -lt 200 ] || [ "$http_code" -ge 300 ]; then
echo "Failed to $what (HTTP $http_code):" >&2
echo "$json" >&2
exit 1
fi
}
# Reads a body from a file, or from stdin for '-'. The point is to keep prose
# out of argv: a body composed on a command line has to survive shell quoting,
# and the things that break it - an apostrophe, a backtick, a $ - are exactly
@@ -86,6 +110,43 @@ function gitea_owner_repo() {
echo "$owner_repo"
}
function require_number() {
if ! [[ "${1:-}" =~ ^[0-9]+$ ]]; then
echo "Expected a PR number, got '${1:-}'" >&2
exit 1
fi
}
# Sets `body` from --body/--body-file, `parsed_title` from --title and
# `parsed_base` from --base, rejecting --body and --body-file together.
# Shared by create and edit so the two cannot drift into disagreeing about
# which one wins - the same reason gitea-issue shares one between its own
# two body-taking subcommands.
function parse_body_args() {
body=""
local body_file=""
parsed_title=""
parsed_base=""
while [ "$#" -gt 0 ]; do
case "$1" in
--title) parsed_title="$2"; shift 2 ;;
--body) body="$2"; shift 2 ;;
--body-file) body_file="$2"; shift 2 ;;
--base) parsed_base="$2"; shift 2 ;;
*) echo "Unknown option: $1" >&2; usage; exit 1 ;;
esac
done
# Rejected rather than ranked: silently preferring one would make a caller
# that passed both ship a body it didn't mean to.
if [ -n "$body" ] && [ -n "$body_file" ]; then
echo "Pass --body or --body-file, not both" >&2
exit 1
fi
if [ -n "$body_file" ]; then
body="$(read_body_file "$body_file")"
fi
}
if [ "$#" -lt 1 ]; then
usage
exit 1
@@ -95,7 +156,7 @@ subcommand="$1"
shift
case "$subcommand" in
create|comments|merge)
create|edit|comments|merge)
require_config
require_token
owner_repo=$(gitea_owner_repo)
@@ -110,28 +171,9 @@ esac
case "$subcommand" in
create)
title=""
body=""
body_file=""
base=""
while [ "$#" -gt 0 ]; do
case "$1" in
--title) title="$2"; shift 2 ;;
--body) body="$2"; shift 2 ;;
--body-file) body_file="$2"; shift 2 ;;
--base) base="$2"; shift 2 ;;
*) echo "Unknown option: $1" >&2; usage; exit 1 ;;
esac
done
# Rejected rather than ranked: silently preferring one would make a
# caller that passed both ship a body it didn't mean to.
if [ -n "$body" ] && [ -n "$body_file" ]; then
echo "Pass --body or --body-file, not both" >&2
exit 1
fi
if [ -n "$body_file" ]; then
body="$(read_body_file "$body_file")"
fi
parse_body_args "$@"
title="$parsed_title"
base="$parsed_base"
body="**Summary generated by LLM agent**
${body}"
@@ -147,14 +189,7 @@ ${body}"
base="${base:-main}"
fi
response=$(gitea_api "${GITEA_API}/repos/${owner}/${repo}/pulls?state=open")
http_code=$(echo "$response" | tail -1)
json=$(echo "$response" | sed '$d')
if [ "$http_code" -lt 200 ] || [ "$http_code" -ge 300 ]; then
echo "Failed to check for an existing PR (HTTP $http_code):" >&2
echo "$json" >&2
exit 1
fi
api_or_die "check for an existing PR" "${GITEA_API}/repos/${owner}/${repo}/pulls?state=open"
existing_url=$(echo "$json" | jq -r --arg head "$current_branch" --arg base "$base" \
'.[] | select(.head.ref == $head and .base.ref == $base) | .html_url' | head -1)
if [ -n "$existing_url" ]; then
@@ -165,63 +200,122 @@ ${body}"
title="$current_branch"
fi
response=$(gitea_api -X POST "${GITEA_API}/repos/${owner}/${repo}/pulls" \
api_or_die "create PR" -X POST "${GITEA_API}/repos/${owner}/${repo}/pulls" \
-H "Content-Type: application/json" \
-d "$(jq -n --arg head "$current_branch" --arg base "$base" --arg title "$title" --arg body "$body" \
'{head: $head, base: $base, title: $title, body: $body}')")
http_code=$(echo "$response" | tail -1)
json=$(echo "$response" | sed '$d')
if [ "$http_code" -lt 200 ] || [ "$http_code" -ge 300 ]; then
echo "Failed to create PR (HTTP $http_code):" >&2
echo "$json" >&2
'{head: $head, base: $base, title: $title, body: $body}')"
echo "$json" | jq -r '.html_url'
;;
edit)
require_number "${1:-}"
pr_number="$1"
shift
parse_body_args "$@"
if [ -n "$parsed_base" ]; then
echo "gitea-pr edit cannot change the base branch" >&2
exit 1
fi
if [ -z "$parsed_title" ] && [ -z "$body" ]; then
echo "gitea-pr edit requires --title, --body or --body-file" >&2
exit 1
fi
# Only the fields actually given are sent: an omitted key leaves that
# field alone, so editing a title cannot blank a body by accident.
payload='{}'
if [ -n "$parsed_title" ]; then
payload=$(echo "$payload" | jq --arg t "$parsed_title" '. + {title: $t}')
fi
if [ -n "$body" ]; then
# The same marker create writes. It describes where the body came
# from rather than the event that wrote it, so an edited body
# keeps the disclosure instead of quietly shedding it.
payload=$(echo "$payload" | jq --arg b "**Summary generated by LLM agent**
$body" '. + {body: $b}')
fi
api_or_die "edit PR #${pr_number}" -X PATCH \
"${GITEA_API}/repos/${owner}/${repo}/pulls/${pr_number}" \
-H "Content-Type: application/json" -d "$payload"
echo "$json" | jq -r '.html_url'
;;
comments)
pr_number="${1:-}"
shift || true
show_all=0
while [ $# -gt 0 ]; do
case "$1" in
--all) show_all=1 ;;
*) echo "Unknown argument: $1" >&2
echo "Usage: gitea-pr comments PR_NUMBER [--all]" >&2
exit 1 ;;
esac
shift
done
if [ -z "$pr_number" ]; then
echo "Usage: gitea-pr comments PR_NUMBER" >&2
echo "Usage: gitea-pr comments PR_NUMBER [--all]" >&2
exit 1
fi
echo "=== Issue comments ==="
response=$(gitea_api "${GITEA_API}/repos/${owner}/${repo}/issues/${pr_number}/comments")
http_code=$(echo "$response" | tail -1)
json=$(echo "$response" | sed '$d')
if [ "$http_code" -lt 200 ] || [ "$http_code" -ge 300 ]; then
echo "Failed to fetch issue comments (HTTP $http_code):" >&2
echo "$json" >&2
exit 1
fi
api_or_die "fetch issue comments" \
"${GITEA_API}/repos/${owner}/${repo}/issues/${pr_number}/comments"
echo "$json" | jq -r '.[] | "[\(.user.login) @ \(.created_at)]\n\(.body)\n"'
echo "=== Reviews ==="
response=$(gitea_api "${GITEA_API}/repos/${owner}/${repo}/pulls/${pr_number}/reviews")
http_code=$(echo "$response" | tail -1)
reviews_json=$(echo "$response" | sed '$d')
if [ "$http_code" -lt 200 ] || [ "$http_code" -ge 300 ]; then
echo "Failed to fetch reviews (HTTP $http_code):" >&2
echo "$reviews_json" >&2
exit 1
fi
api_or_die "fetch reviews" "${GITEA_API}/repos/${owner}/${repo}/pulls/${pr_number}/reviews"
reviews_json="$json"
echo "$reviews_json" | jq -r '.[] | select(.body != "") | "[\(.user.login) @ \(.submitted_at) — \(.state)]\n\(.body)\n"'
echo "=== Inline review comments ==="
review_ids=$(echo "$reviews_json" | jq -r '.[] | select(.comments_count > 0) | .id')
all_comments='[]'
for review_id in $review_ids; do
response=$(gitea_api \
"${GITEA_API}/repos/${owner}/${repo}/pulls/${pr_number}/reviews/${review_id}/comments")
http_code=$(echo "$response" | tail -1)
json=$(echo "$response" | sed '$d')
if [ "$http_code" -lt 200 ] || [ "$http_code" -ge 300 ]; then
echo "Failed to fetch comments for review ${review_id} (HTTP $http_code):" >&2
echo "$json" >&2
exit 1
fi
echo "$json" | jq -r '.[] | "[\(.user.login) @ \(.path):\(.line // "?")]\n\(.body)\n"'
api_or_die "fetch comments for review ${review_id}" \
"${GITEA_API}/repos/${owner}/${repo}/pulls/${pr_number}/reviews/${review_id}/comments"
# The hunk, not just the path. Gitea's review-comment objects carry
# no `line` field at all - the old `.line // "?"` could only ever
# print `?` - but they do carry `diff_hunk`, which is the exact
# text the comment was left against. Without it a reviewer's
# "this is confusing" arrives with nothing to attach it to, and
# whoever acts on the review has to guess which passage was meant.
# `position` is the index into the diff rather than a file line, so
# it is printed as a diff offset and not as something to jump to.
all_comments=$(printf '%s\n%s' "$all_comments" "$json" | jq -s 'add')
done
# A thread is every comment sharing a path and a diff position, and it
# is resolved if ANY of them carries a resolver. Gitea stamps only a
# thread's root when you resolve it, so a reply inside a closed thread
# comes back with `resolver: null` and reads as open if each comment is
# judged alone. That is not hypothetical: it is how a closed-and-refiled
# thread on #96 presented as still needing an answer.
#
# Note what is NOT used here. A review's `state` (COMMENT vs
# REQUEST_CHANGES) and whether comments arrived grouped under one review
# or singly both describe how the reviewer submitted, not whether
# anything is outstanding. Reading either as a resolution signal works
# until a reviewer changes how they file, and then it silently stops.
threads=$(printf '%s' "$all_comments" | jq '
group_by(.path + "@" + (.position | tostring))
| map({resolved: any(.[]; .resolver != null), comments: .})')
resolved_count=$(printf '%s' "$threads" | jq '[.[] | select(.resolved)] | length')
if [ "$show_all" -eq 1 ]; then
visible="$threads"
else
visible=$(printf '%s' "$threads" | jq '[.[] | select(.resolved | not)]')
fi
printf '%s' "$visible" | jq -r '.[]
| .resolved as $r
| .comments[]
| "[\(.user.login) @ \(.path) @@\(.position // "?")\(if $r then " (resolved)" else "" end)]\n\(.diff_hunk // "(no hunk recorded)")\n--- comment ---\n\(.body)\n"'
if [ "$show_all" -ne 1 ] && [ "$resolved_count" -gt 0 ]; then
echo "($resolved_count resolved thread(s) hidden. Re-run with --all to include them.)"
fi
;;
merge)
pr_number="${1:-}"
@@ -230,17 +324,9 @@ ${body}"
exit 1
fi
response=$(gitea_api -X POST "${GITEA_API}/repos/${owner}/${repo}/pulls/${pr_number}/merge" \
-H "Content-Type: application/json" \
-d '{"Do": "merge"}')
http_code=$(echo "$response" | tail -1)
json=$(echo "$response" | sed '$d')
if [ "$http_code" -lt 200 ] || [ "$http_code" -ge 300 ]; then
echo "Failed to merge PR #${pr_number} (HTTP $http_code):" >&2
echo "$json" >&2
exit 1
fi
api_or_die "merge PR #${pr_number}" -X POST \
"${GITEA_API}/repos/${owner}/${repo}/pulls/${pr_number}/merge" \
-H "Content-Type: application/json" -d '{"Do": "merge"}'
echo "Merged PR #${pr_number}"
;;
esac
+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 =
+15 -22
View File
@@ -1,30 +1,23 @@
#!/usr/bin/env bash
# PreToolUse hook: inside dejima, block a merge command matching
# DEJIMA_MERGE_BLOCK - a literal command prefix (the forge's PR command
# followed by its merge subcommand) resolved host-side by bin/dejima from
# the configured forge and workflow
# (see dejima-provider-lib.sh), not hardcoded to any one forge. The sandbox
# workflow is feature branch -> open a PR/MR -> a human merges; what lands
# on the default branch is an operator decision, exactly as it already is
# for `git push` (see git-push-guard.sh, whose no-direct-push-to-a-blocked-
# branch rule this is the API-side counterpart to - a merge reaches the
# branch without a push, so that guard never sees it).
# DEJIMA_MERGE_BLOCK - a literal command prefix resolved host-side by
# bin/dejima from the configured forge and workflow, never hardcoded to one
# forge. What lands on the default branch is an operator decision, as it
# already is for `git push`; this is the API-side counterpart to
# git-push-guard.sh, since a merge reaches the branch without a push.
#
# Deliberately not conditional on intent. A hook is handed a command, not a
# conversation, so it cannot tell an instructed merge from an unprompted one;
# the only honest place to draw the line is the context, and the context that
# needs the line is this one - headless unattended runs under
# --dangerously-skip-permissions, where nobody is watching the turn happen.
# On the host the hook is a no-op and an operator can still ask for a merge.
# Not conditional on intent: a hook is handed a command, not a conversation,
# so it cannot tell an instructed merge from an unprompted one. The line is
# drawn by context instead, and the context that needs it is this one -
# unattended runs under --dangerously-skip-permissions. On the host it is a
# no-op.
#
# Like its sibling this is a fast-fail guard against an overeager agent, not
# a hard security boundary: a sandbox session has full shell access and the
# forge token in its environment, so it could call the merge endpoint with
# curl directly. It stops the mistake, not the attack.
# A fast-fail guard against an overeager agent, not a boundary: a session has
# shell access and the forge token, so it could call the merge endpoint with
# curl. It stops the mistake, not the attack.
#
# DEJIMA_MERGE_BLOCK fails OPEN: empty means no command is blocked, so the
# hook is inert rather than conditionally installed - a workflow with no
# merge-block policy (or a forge with nothing to block) genuinely has none.
# Fails OPEN - empty means nothing is blocked, so a workflow with no
# merge-block policy genuinely has none rather than the hook guessing.
if [ -z "${DEJIMA:-}" ]; then
exit 0
+30 -20
View File
@@ -66,29 +66,24 @@ fi
requirements=""
decl_dir="$project_dir/.dejima"
# Everything below reads attacker-controllable text on its way into an
# agent's context window, so all three costs it can impose are bounded:
# Everything below reads attacker-controllable text on its way into a context
# window, so each cost it can impose is bounded:
#
# MAX_DECLARED entries considered - the same cap read_declared() applies
# host-side, so this reports on the same window
# `dejima-project approve` will act on.
# host-side, so this reports the window `approve` acts on.
# MAX_ENTRY_LEN characters per entry.
# MAX_LISTED entries named; the rest are counted only. Without it, 200
# entries of 200 chars is 40 KB of attacker-chosen tokens per
# section, and a large enough $requirements pushed the jq call
# past MAX_ARG_STRLEN and emitted nothing - costing every
# later session its whole briefing.
#
# Within that window the report is a strict subset of what `approve` accepts,
# never a superset: it never names something approve would reject, but it can
# stay silent about something approve would take - a dotless domain (the
# regex below requires a label separator, deliberately, so a bare
# IGNORE-PREVIOUS-INSTRUCTIONS can't be echoed into the context) and a valid
# domain longer than MAX_ENTRY_LEN are both approvable host-side and both
# silently skipped here. Fail-closed in the direction that matters: the worst
# case is a missing line of advice, not an unapproved entry presented as fine.
# MAX_LISTED entries actually named in the report; the rest are only
# counted. Without it 200 entries of 200 chars would be 40 KB
# of attacker-chosen tokens per section, and a big enough
# $requirements used to push the jq call at the end past
# Linux's 128 KiB MAX_ARG_STRLEN and emit nothing at all -
# silently costing every later session in the project its
# entire briefing.
# The report is a strict subset of what `approve` accepts, never a superset:
# it can stay silent about something approvable (a dotless domain, which the
# regex refuses so a bare IGNORE-PREVIOUS-INSTRUCTIONS cannot be echoed into
# context; or one longer than MAX_ENTRY_LEN) but never names something
# `approve` would reject. The worst case is a missing line of advice, not an
# unapproved entry presented as fine.
MAX_DECLARED=200
MAX_ENTRY_LEN=200
MAX_LISTED=20
@@ -133,6 +128,21 @@ if [ -f "$decl_dir/dependencies" ]; then
# a leading dash (e.g. "--allow-unauthenticated") must not validate
# as a name, or this hook would echo into the agent's context an
# entry the project's own approve/status commands treat as rejected.
#
# A tarball spec carries a digest and a URL rather than a bare name,
# so it is matched separately and answered by whether its directory
# is there. Reporting one as missing has to print the whole spec, and
# that is deliberate: the digest is the part that makes the operator's
# approval mean something, so a truncated form would be asking them to
# approve something they cannot see.
if [[ "$spec" == tarball:* ]]; then
name="${spec#tarball:}"; name="${name%%@*}"
[[ "$name" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]] || continue
[ -d "/opt/dejima/$name" ] && continue
missing_deps_n=$((missing_deps_n + 1))
[ "$missing_deps_n" -le "$MAX_LISTED" ] && missing_deps="$missing_deps $spec"
continue
fi
[[ "$spec" =~ ^(apt|npm|pip|cargo):[A-Za-z0-9][A-Za-z0-9._+-]*$ ]] || continue
kind="${spec%%:*}"
name="${spec#*:}"
@@ -182,7 +192,7 @@ if [ -f "$decl_dir/allowed-domains" ]; then
Tell the user to run, outside the sandbox:
dejima-project approve \"$project_dir\"
(or, for one domain right now:
bin/dejima-allow <domain> --container $container_id)"
bin/dejima-allow --container $container_id <domain>)"
fi
fi
+83 -4
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
+172 -10
View File
@@ -162,6 +162,10 @@ cmp_() { "$AUDIT" compare "$WS" "$STATE" 2>&1; }
}
@test "does not flag a worktree child's projects slug" {
# A real worktree, not just a directory named like one. "Self" is derived
# from live worktree paths now, so the fixture has to produce one.
git -C "$WS" commit -q --allow-empty -m base
git -C "$WS" worktree add -q "$WS/.worktrees/featurex" -b featurex
mkdir -p "$PROJDIR/${CURRENT_SLUG}--worktrees-featurex"
echo x > "$PROJDIR/${CURRENT_SLUG}--worktrees-featurex/file1"
snap
@@ -170,6 +174,21 @@ cmp_() { "$AUDIT" compare "$WS" "$STATE" 2>&1; }
[[ "$output" != *"CROSS-PROJECT"* ]]
}
# The regression guard for #104's sibling case. The old test was a
# "$CURRENT_SLUG"-* glob, which this slug matches: a project checked out
# beside this one, whose directory name merely extends it, was silently
# exempt from cross-project reporting. `proj` and `proj-ci-hardener` side by
# side is an ordinary layout, not a contrived one.
@test "flags a sibling project whose directory name extends this one's" {
mkdir -p "$PROJDIR/${CURRENT_SLUG}-ci-hardener"
echo x > "$PROJDIR/${CURRENT_SLUG}-ci-hardener/file1"
snap
echo y > "$PROJDIR/${CURRENT_SLUG}-ci-hardener/file2"
touch -d '+1 hour' "$PROJDIR/${CURRENT_SLUG}-ci-hardener/file2"
run cmp_
[[ "$output" == *"CROSS-PROJECT"* ]]
}
@test "silent when no projects changed" {
mkdir -p "$PROJDIR/-some-other-project"
echo x > "$PROJDIR/-some-other-project/file1"
@@ -557,7 +576,10 @@ cmp_() { "$AUDIT" compare "$WS" "$STATE" 2>&1; }
printf 'keep = 10\n' >> "$CONFIG_DIR/config"
snap; git -C "$WS" config user.name changed
cmp_ >/dev/null 2>&1
run bash -c "ls '$AUDITDIR/$slug' | wc -l"
# *.txt, not every entry: a finding with retained content also leaves a
# <report>.d sidecar beside it, which rotation counts as part of the
# report it belongs to rather than as another report.
run bash -c "ls '$AUDITDIR/$slug'/*.txt | wc -l"
[ "$output" -le 10 ]
}
@@ -723,15 +745,125 @@ REPORT
echo 'export X=1' > "$WS/.envrc"
mkdir -p "$WS/.vscode"; echo '{}' > "$WS/.vscode/settings.json"
cmp_ >/dev/null 2>&1
# 's' on the CRITICAL .envrc finding, then 'a' on the WARNING one that
# 'i' on the CRITICAL .envrc finding, then 'a' on the WARNING one that
# follows it - so $BASEDIR/$slug is guaranteed to exist afterward and the
# negative grep below can't pass vacuously against a missing file.
run bash -c "printf 's\na\n' | '$AUDIT' ack '$WS'"
[[ "$output" == *"cannot 'always ignore' a CRITICAL surface - approve the exact content or skip."* ]]
run bash -c "printf 'i\na\n' | '$AUDIT' ack '$WS'"
[[ "$output" == *"cannot ignore a CRITICAL surface from now on"* ]]
[ -f "$BASEDIR/$slug" ]
! grep -q '^spam \.envrc' "$BASEDIR/$slug"
}
@test "a finding the baseline already covers is not presented again" {
# #65's headline bug. The approval was always written correctly; `ack`
# just never consulted the baseline when deciding what to SHOW, so every
# later run re-presented the same CRITICAL and appended a duplicate
# entry. Reported as "approvals are not being recorded", which is what it
# looks like from the outside.
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
snap; echo 'export X=1' > "$WS/.envrc"; cmp_ >/dev/null 2>&1
run bash -c "printf 'a\n' | '$AUDIT' ack '$WS'"
[ "$(grep -c '^safe .envrc' "$BASEDIR/$slug")" -eq 1 ]
run bash -c "printf 'a\n' | '$AUDIT' ack '$WS'"
[[ "$output" != *".envrc"* ]]
[[ "$output" == *"already dealt with"* ]]
# And no second entry for the same decision.
[ "$(grep -c '^safe .envrc' "$BASEDIR/$slug")" -eq 1 ]
}
@test "a report whose CRITICALs were approved through an earlier report stops nagging" {
# Two reports can list the same finding. Approving it in the first left
# the second permanently -CRITICAL.txt, so bin/dejima warned about a
# report whose every finding had been dealt with, and `ack` re-presented
# it forever. The rename now runs on a pass that records nothing new.
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
snap; echo 'export X=1' > "$WS/.envrc"; cmp_ >/dev/null 2>&1
# A second report listing the same, still-unapproved finding.
cp "$AUDITDIR/$slug"/*-CRITICAL.txt "$AUDITDIR/$slug/2020-01-01-000000-CRITICAL.txt"
run bash -c "printf 'a\n' | '$AUDIT' ack '$WS'"
[[ "$output" == *"recorded 1 acknowledgement"* ]]
# The remaining report is walked with nothing left to decide, and sheds
# its suffix rather than staying unacknowledged forever.
run bash -c "printf 'a\n' | '$AUDIT' ack '$WS'"
[[ "$output" == *"already dealt with"* ]]
run bash -c "ls '$AUDITDIR/$slug'"
[[ "$output" != *"-CRITICAL.txt"* ]]
}
@test "a finding that cannot be acknowledged is presented once, not on every run" {
# There is nothing to record for one, so without a marker it re-prompted
# forever and `ack` could never reach a state of having nothing to ask.
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
mkdir -p "$PROJDIR/-some-other-project"
echo x > "$PROJDIR/-some-other-project/file1"
snap
echo y > "$PROJDIR/-some-other-project/file2"
touch -d '+1 hour' "$PROJDIR/-some-other-project/file2"
cmp_ >/dev/null 2>&1
run bash -c "printf 's\n' | '$AUDIT' ack '$WS'"
[[ "$output" == *"CROSS-PROJECT"* ]]
grep -q '^reviewed ' "$BASEDIR/$slug"
run bash -c "printf 's\n' | '$AUDIT' ack '$WS'"
[[ "$output" != *"CROSS-PROJECT"* ]]
}
@test "quitting a report does not mark it reviewed" {
# The marker means "walked to the end". An abort discards everything
# else, and must discard this too or one q would silence the report.
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
mkdir -p "$PROJDIR/-some-other-project"
echo x > "$PROJDIR/-some-other-project/file1"
snap
echo y > "$PROJDIR/-some-other-project/file2"
touch -d '+1 hour' "$PROJDIR/-some-other-project/file2"
cmp_ >/dev/null 2>&1
run bash -c "printf 'q\n' | '$AUDIT' ack '$WS'"
! grep -q '^reviewed ' "$BASEDIR/$slug" 2>/dev/null
run bash -c "printf 's\n' | '$AUDIT' ack '$WS'"
[[ "$output" == *"CROSS-PROJECT"* ]]
}
@test "a WARNING git config finding can be acknowledged, both ways (#45)" {
# Neither `ack` nor `compare` handled this tier: no arm derived an id for
# it, and the two branches that emit it never consulted the baseline. It
# was the one class of finding with no way to curate it at all.
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
snap; git -C "$WS" config user.name changed-in-session; cmp_ >/dev/null 2>&1
run bash -c "printf 'i\n' | '$AUDIT' ack '$WS'"
grep -q '^spam gitconfig:user.name' "$BASEDIR/$slug"
# ...and the surface stays quiet whatever it changes to next, which is
# the point for a key some tool rewrites on every run.
snap; git -C "$WS" config user.name changed-again
run cmp_
[[ "$output" != *"user.name"* ]]
}
@test "an approved WARNING git config value re-fires when the value changes" {
# The other verb: `safe` pins the exact value, so a different one is a
# new decision rather than covered by the old one.
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
snap; git -C "$WS" config user.name first; cmp_ >/dev/null 2>&1
run bash -c "printf 'a\n' | '$AUDIT' ack '$WS'"
grep -q '^safe gitconfig:user.name ' "$BASEDIR/$slug"
snap; git -C "$WS" config user.name second
run cmp_
[[ "$output" == *"changed git config: user.name=second"* ]]
}
@test "ack emits no colour when its output is not a terminal" {
# Reports and piped output are read back by machines - the audit's own
# next run, and every test here. An escape sequence in either is a bug,
# so the palette is decided from the stream rather than from a flag.
snap; echo 'export X=1' > "$WS/.envrc"; cmp_ >/dev/null 2>&1
run bash -c "printf 'a\n' | '$AUDIT' ack '$WS'"
[[ "$output" != *$'\033'* ]]
run cmp_
[[ "$output" != *$'\033'* ]]
}
@test "ack records a safe entry and clears the CRITICAL report marker" {
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
snap; echo 'export X=1' > "$WS/.envrc"; cmp_ >/dev/null 2>&1
@@ -741,12 +873,22 @@ REPORT
[[ "$output" != *"-CRITICAL.txt"* ]]
}
@test "ack does not write an unmatchable entry for a non-path finding" {
@test "ack does not write an unmatchable entry for a finding with no id" {
# Was written against a changed git config, which #45 made ackable. The
# invariant is about findings that have no id to record at all, so it now
# uses the one class that still has none: a cross-project write, whose
# subject is another project's slug rather than anything this audit can
# hash and pin.
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
snap; git -C "$WS" config user.name changed; cmp_ >/dev/null 2>&1
mkdir -p "$PROJDIR/-some-other-project"
echo x > "$PROJDIR/-some-other-project/file1"
snap
echo y > "$PROJDIR/-some-other-project/file2"
touch -d '+1 hour' "$PROJDIR/-some-other-project/file2"
cmp_ >/dev/null 2>&1
run bash -c "printf 'a\n' | '$AUDIT' ack '$WS'"
[[ "$output" == *"cannot"* ]]
[ ! -f "$BASEDIR/$slug" ]
! grep -qE '^(safe|spam) ' "$BASEDIR/$slug" 2>/dev/null
}
@test "an ack-approved finding is suppressed on the next compare (id round-trips through the baseline)" {
@@ -978,11 +1120,11 @@ REPORT
git -C "$WS" config core.hooksPath /tmp/evil
mkdir -p "$WS/.vscode"; echo '{}' > "$WS/.vscode/settings.json"
cmp_ >/dev/null 2>&1
# 's' on the CRITICAL gitconfig finding, then 'a' on the WARNING one that
# 'i' on the CRITICAL gitconfig finding, then 'a' on the WARNING one that
# follows it - so $BASEDIR/$slug is guaranteed to exist afterward and the
# negative grep below can't pass vacuously against a missing file.
run bash -c "printf 's\na\n' | '$AUDIT' ack '$WS'"
[[ "$output" == *"cannot 'always ignore' a CRITICAL surface - approve the exact content or skip."* ]]
run bash -c "printf 'i\na\n' | '$AUDIT' ack '$WS'"
[[ "$output" == *"cannot ignore a CRITICAL surface from now on"* ]]
[ -f "$BASEDIR/$slug" ]
! grep -q '^spam gitconfig:core.hooksPath' "$BASEDIR/$slug"
}
@@ -1710,3 +1852,23 @@ sidecar() { local r; r="$(find "$AUDITDIR" -name '*.txt' | sort | tail -1)"; r="
run bash -c "printf 'q\n' | '$AUDIT' ack '$WS'"
[ -f "$sc/$(key_of '.git/hooks/pre-commit').diff" ]
}
@test "the report header names the writable mounts a session could reach" {
mkdir -p "$WS/.git/hooks"; printf 'a\n' > "$WS/.git/hooks/pre-commit"
snap
printf 'curl evil\n' > "$WS/.git/hooks/pre-commit"
run env DEJIMA_RW_MOUNTS="/srv/specs /srv/notes" "$AUDIT" compare "$WS" "$STATE"
[[ "$output" == *"/srv/specs"* ]]
[[ "$output" == *"/srv/notes"* ]]
# Named as reachable-but-unhashed, not reported as a finding: writing to
# one is the point of configuring it, so it must not read as an alert.
[[ "$output" != *"! /srv/specs"* ]]
}
@test "the report says nothing about writable mounts when there are none" {
mkdir -p "$WS/.git/hooks"; printf 'a\n' > "$WS/.git/hooks/pre-commit"
snap
printf 'curl evil\n' > "$WS/.git/hooks/pre-commit"
run "$AUDIT" compare "$WS" "$STATE"
[[ "$output" != *"writable"* ]]
}
+174
View File
@@ -0,0 +1,174 @@
#!/usr/bin/env bats
#
# Asserts on the RUNNER, not on this repository. Every other suite here tests
# dejima's code; this one tests the machine executing it, because the controls
# in docs/ci.md live in a runner's config.yaml and a host's firewall - set up
# by dejima-ci-hardener, on a machine this repository never touches, so no
# amount of reviewing this repository can check them.
#
# It exists because both failures that matter look exactly like success from
# inside a job. Nothing in a job's log mentions whether the Docker socket was
# mounted into it, and a job that can reach an off-list resolver still passes
# every check that only inspects DOCKER-USER. Neither shows up as a red run;
# they show up as nothing at all.
#
# DEJIMA_CI_TIER says which runner this is supposed to be, set by the workflow
# rather than sniffed, so landing on the wrong runner is a failure rather than
# a different set of assertions quietly being chosen. Unset means this is not
# a CI runner and there is nothing here to assert.
#
# Its values are the names of the jobs whose environment they verify -
# `confined` and `privileged` - rather than a separate vocabulary. Those are
# the same words the runner labels and dejima-ci-hardener's roles use, so a
# reader looking at a red `runner-env (privileged)` should not have to work out
# which job that corresponds to.
setup() {
[ -n "${DEJIMA_CI_TIER:-}" ] || skip "not on a CI runner (DEJIMA_CI_TIER unset)"
case "$DEJIMA_CI_TIER" in
confined|privileged) ;;
*) echo "unknown DEJIMA_CI_TIER: $DEJIMA_CI_TIER" >&2; return 1 ;;
esac
}
# An off-list destination that would answer if it were reachable. This matters
# more than it looks: pointing these at an address that never answers - a
# documentation range, say - would pass in an environment with no network at
# all, which is the exact failure this suite exists to detect.
OFF_LIST_IP=1.1.1.1
# The positive control's destination. Overridable because it has to be a name
# THIS runner allows, and an allowlist is per-operator - hardcoding one would
# fail on a runner that is correctly configured for a different set.
ALLOWED_HOST=${DEJIMA_CI_ALLOWED_HOST:-deb.debian.org}
connects() { # connects HOST PORT -> 0 if a TCP connection completes
python3 - "$1" "$2" <<'PY'
import socket, sys
try:
socket.create_connection((sys.argv[1], int(sys.argv[2])), timeout=6).close()
except OSError:
sys.exit(1)
PY
}
# --- the grant that decides everything --------------------------------------
@test "the confined jobs are not handed the Docker daemon" {
[ "$DEJIMA_CI_TIER" = confined ] || skip "asserted for the confined runner"
# act_runner mounts /var/run/docker.sock into every job container unless
# container.docker_host is "-". An unset value is the default, and the
# default mounts - so this is the assertion that a runner was actually
# configured rather than merely intended.
[ ! -e /var/run/docker.sock ] || {
echo "this runner grants job containers the Docker socket, which is" >&2
echo "root-equivalent on the runner host. Set container.docker_host: \"-\"" >&2
echo "in its config.yaml - see dejima-ci-hardener." >&2
false
}
}
@test "the privileged job does have the daemon it is scheduled for" {
[ "$DEJIMA_CI_TIER" = privileged ] || skip "asserted for the privileged runner"
# The positive control for the label routing. Without it, a privileged
# job that landed on a hardened runner would fail later and obscurely, and
# a confined job that landed on this one would be caught only by the test
# above - one direction of a two-direction problem.
[ -e /var/run/docker.sock ]
}
@test "the two runners are not the same runner" {
# Whatever else is true, exactly one of them gets the daemon. If both jobs
# report the same answer here, the labels are not routing to two different
# runners and the tiering exists only on paper.
if [ "$DEJIMA_CI_TIER" = confined ]; then
[ ! -e /var/run/docker.sock ]
else
[ -e /var/run/docker.sock ]
fi
}
# --- egress, for the tier the rules actually apply to ------------------------
#
# Deliberately scoped to the confined runner. The privileged job runs in the
# HOST network namespace - that is the grant they exist for - so the bridge
# rules dejima-ci-hardener installs do not sit in front of them at all. Their
# egress is the host's own, which this repository's examples do not define.
# Asserting an allowlist there would test nothing and pass anyway, which is
# the failure mode this whole suite is about.
@test "an allowlisted destination is reachable" {
[ "$DEJIMA_CI_TIER" = confined ] || skip "bridge rules do not apply in the host netns"
# The positive control for everything below. Without it, a runner with no
# network at all passes every negative assertion in this file.
run getent hosts "$ALLOWED_HOST"
[ "$status" -eq 0 ] || {
echo "$ALLOWED_HOST did not resolve, so the negative checks below" >&2
echo "would pass for the wrong reason. Check the resolver and the" >&2
echo "allowlist rendered from .dejima/allowed-domains on main - or set" >&2
echo "DEJIMA_CI_ALLOWED_HOST to a name this runner does allow." >&2
false
}
run connects "$ALLOWED_HOST" 80
[ "$status" -eq 0 ]
}
@test "a name that is on no allowlist does not resolve" {
[ "$DEJIMA_CI_TIER" = confined ] || skip "bridge rules do not apply in the host netns"
# .invalid is reserved by RFC 2606 precisely so that it never resolves
# anywhere - but here it has to be REFUSED by a resolver that answers for
# allowlisted names, which the positive control above establishes.
run getent hosts example.invalid
[ "$status" -ne 0 ]
}
@test "a job cannot reach a resolver of its own choosing" {
[ "$DEJIMA_CI_TIER" = confined ] || skip "bridge rules do not apply in the host netns"
# The check most easily left out, and the one that decides whether the DNS
# allowlist means anything: a job that can query any resolver it likes can
# resolve anything it likes, whatever dnsmasq is configured with. Traffic
# to the resolver on the bridge gateway is addressed to the host, so it is
# an INPUT packet and never passes through DOCKER-USER - filtering only
# DOCKER-USER leaves this open.
run connects "$OFF_LIST_IP" 53
[ "$status" -ne 0 ] || {
echo "reached $OFF_LIST_IP:53, so this job can use any resolver it likes and" >&2
echo "the DNS allowlist is decorative. The INPUT rules are missing -" >&2
echo "re-run dejima-ci-firewall on the runner host." >&2
false
}
}
@test "a destination that is on no allowlist is unreachable" {
[ "$DEJIMA_CI_TIER" = confined ] || skip "bridge rules do not apply in the host netns"
# An IP literal rather than a name, so this tests the firewall rather than
# the resolver. Reaching it by name would fail at DNS first and prove
# nothing about DOCKER-USER.
run connects "$OFF_LIST_IP" 443
[ "$status" -ne 0 ] || {
echo "reached $OFF_LIST_IP:443, so this job has unrestricted egress." >&2
echo "" >&2
echo "If the DNS checks above passed, the resolver is fine and the packet" >&2
echo "filter is not - which usually means DOCKER-USER is empty. Docker" >&2
echo "recreates its own chains on every daemon start, so a 'systemctl" >&2
echo "restart docker' drops these rules and nothing looks different" >&2
echo "afterwards. Check with: iptables -S DOCKER-USER" >&2
echo "" >&2
echo "dejima-ci-hardener's dejima-ci-firewall.service reapplies them on" >&2
echo "every Docker start, which is the fix rather than a one-off re-run." >&2
false
}
}
@test "IPv6 egress is not an unfiltered way around all of the above" {
[ "$DEJIMA_CI_TIER" = confined ] || skip "bridge rules do not apply in the host netns"
# An address family with no allowlist is not a smaller hole than the one
# the rules above close; on a runner with IPv6 egress it is the same hole.
#
# Unlike the checks above, this one passes for two different reasons - v6
# rejected, or v6 never configured - and both are acceptable outcomes, so
# it is not given a positive control. What it rules out is the third case:
# a runner with working IPv6 egress and rules written only for v4.
run connects 2606:4700:4700::1111 443
[ "$status" -ne 0 ]
}
+480 -34
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)`' \
@@ -833,24 +1045,16 @@ run_install() {
local name out hit=0
# docs/ and systemd/ are pure operator-facing prose - nothing there is
# live shell plumbing, so any mention of a retired name is either the
# deliberate "no longer read" notice (install.sh's remote.conf one lives
# outside these two directories, but the exclusion is kept here too in
# case a future doc adds one), a stale instruction, or one of the two
# cases below that are neither:
# docs/ and systemd/ are operator-facing prose, no live plumbing, so a
# retired name there is either the deliberate "no longer read" notice, a
# stale instruction, or one of two cases that are neither:
#
# - A manifest-field line (`env_export = GITEA_API=api ...`): this is
# docs/providers.md quoting a forge manifest's `env_export` value
# verbatim, which *declares* a container-side environment variable -
# the exact thing env_export is for - not an instruction to an operator
# to set one. `GITEA_API` and friends are retired only as host-side,
# operator-set variables (what _warn_legacy_env's table is about); they
# are not retired as the container-side names env_export produces, and
# docs/providers.md documenting that mapping is not a regression.
# - A backtick-quoted mention (`` `GITEA_API` ``): prose naming a
# variable - "the container gets `GITEA_API`" - reads a value, it
# doesn't tell anyone to set one.
# - A manifest-field line (`env_export = GITEA_API=api ...`), which is
# providers.md quoting a manifest verbatim. These names are retired as
# host-side operator-set variables, not as the container-side names
# env_export produces.
# - A backtick-quoted mention - prose naming a variable reads a value, it
# does not tell anyone to set one.
while IFS= read -r name; do
out="$(grep -rnE "\b${name}\b" "$REPO_ROOT/docs" "$REPO_ROOT/systemd" 2>/dev/null \
| grep -v 'no longer read' \
@@ -922,17 +1126,244 @@ exit 0
EOF
chmod +x "$STUB_DIR/docker"
docker_log="$(mktemp)"
HOME="$RW_HOME" PATH="$STUB_DIR:$PATH" SSH_AUTH_SOCK= \
HOME="$RW_HOME" PATH="$STUB_DIR:$PATH" SSH_AUTH_SOCK="${RW_AGENT_SOCK-}" \
SANDBOX_TEST_DOCKER_LOG="$docker_log" \
run "$REPO_ROOT/bin/dejima" "$1"
}
# A real agent, carrying nothing. `ssh-add -l` against it exits nonzero,
# which is the condition the "no identities loaded" warning keys on, and a
# handmade socket file would not reproduce it.
_empty_agent() {
eval "$(ssh-agent -s)" >/dev/null
RW_AGENT_SOCK="$SSH_AUTH_SOCK"
RW_AGENT_PID="$SSH_AGENT_PID"
}
_kill_agent() { [ -n "${RW_AGENT_PID:-}" ] && kill "$RW_AGENT_PID" 2>/dev/null; return 0; }
# The forwarded agent is the fallback; [core] ssh_key is the arrangement the
# docs recommend, because it is a dedicated identity and works unattended.
# So an empty agent is only worth mentioning when nothing else can push -
# otherwise the warning fires at exactly the operators who took the advice.
# ~/.claude/file-history is keyed by session id, so nothing in its own paths
# says which project a directory belongs to. The transcript names are what
# resolve it, and this is the assertion that the resolution is right: a
# session of this workspace is mounted, one belonging to another project is
# not, and neither is one whose slug merely extends this workspace's - the
# sibling case that #104 turned on.
@test "file-history is mounted for this workspace's sessions and no others" {
_rw_setup
workspace="$(mktemp -d)"
local slug sib own foreign sibling
slug="$(printf '%s' "$workspace" | sed 's/[^a-zA-Z0-9]/-/g')"
sib="$slug-extra"
own=11111111-1111-1111-1111-111111111111
foreign=22222222-2222-2222-2222-222222222222
sibling=33333333-3333-3333-3333-333333333333
mkdir -p "$RW_HOME/.claude/projects/$slug" \
"$RW_HOME/.claude/projects/-some-other-project" \
"$RW_HOME/.claude/projects/$sib"
: > "$RW_HOME/.claude/projects/$slug/$own.jsonl"
: > "$RW_HOME/.claude/projects/-some-other-project/$foreign.jsonl"
: > "$RW_HOME/.claude/projects/$sib/$sibling.jsonl"
mkdir -p "$RW_HOME/.claude/file-history/$own" \
"$RW_HOME/.claude/file-history/$foreign" \
"$RW_HOME/.claude/file-history/$sibling"
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only'
_rw_launch "$workspace"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
grep -qF "file-history/$own" "$docker_log" \
|| { echo "own session not mounted:"; grep file-history "$docker_log"; return 1; }
run grep -F "file-history/$foreign" "$docker_log"
[ "$status" -ne 0 ] || { echo "another project's file-history was mounted"; return 1; }
run grep -F "file-history/$sibling" "$docker_log"
[ "$status" -ne 0 ] || { echo "a sibling project's file-history was mounted"; return 1; }
rm -rf "$workspace"
}
@test "an empty ssh-agent is not warned about when [core] ssh_key is set" {
_rw_setup
workspace="$(mktemp -d)"
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only'
_empty_agent
_rw_launch "$workspace"
_kill_agent
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[[ "$output" != *"no identities loaded"* ]] || { echo "$output"; return 1; }
rm -rf "$workspace"
}
# The positive control for the test above: with no key configured the
# forwarded agent really is the only way to push, and an empty one is worth
# saying so before it fails deep into a session.
# --extra-readonly / --extra-read-write append to the config's own lists
# rather than carrying their own mount code, so these assert the mount lands
# and then that every refusal the configured form gets applies to the flag
# too. If they ever stop sharing a path, the guard tests below pass while the
# flags quietly stop being covered by them.
_rw_launch_flags() {
local ws="$1"; shift
STUB_DIR="$(mktemp -d)"
cat > "$STUB_DIR/docker" <<'EOF'
#!/bin/sh
for a in "$@"; do printf '%s\n' "$a" >> "$SANDBOX_TEST_DOCKER_LOG"; done
exit 0
EOF
chmod +x "$STUB_DIR/docker"
docker_log="$(mktemp)"
HOME="$RW_HOME" PATH="$STUB_DIR:$PATH" SSH_AUTH_SOCK= \
SANDBOX_TEST_DOCKER_LOG="$docker_log" \
run "$REPO_ROOT/bin/dejima" "$@" "$ws"
}
@test "--extra-readonly and --extra-read-write mount for one launch" {
_rw_setup
workspace="$(mktemp -d)"; ro="$(mktemp -d)"; rw="$(mktemp -d)"
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only'
_rw_launch_flags "$workspace" --extra-readonly "$ro" --extra-read-write "$rw"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
grep -qxF "$ro:$ro:ro" "$docker_log" || { cat "$docker_log"; return 1; }
grep -qxF "$rw:$rw" "$docker_log" || { cat "$docker_log"; return 1; }
rm -rf "$workspace" "$ro" "$rw"
}
@test "the short forms are the same flags, and both are repeatable" {
_rw_setup
workspace="$(mktemp -d)"; a="$(mktemp -d)"; b="$(mktemp -d)"
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only'
_rw_launch_flags "$workspace" --ro "$a" --ro "$b"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
grep -qxF "$a:$a:ro" "$docker_log" || { cat "$docker_log"; return 1; }
grep -qxF "$b:$b:ro" "$docker_log" || { cat "$docker_log"; return 1; }
rm -rf "$workspace" "$a" "$b"
}
# The flags add to the configured lists; they do not replace them. A launch
# that dropped the config's own entries would still pass the two tests above.
@test "a flag adds to [mounts] readonly rather than replacing it" {
_rw_setup
workspace="$(mktemp -d)"; configured="$(mktemp -d)"; extra="$(mktemp -d)"
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only' \
'[mounts]' "readonly = $configured"
_rw_launch_flags "$workspace" --ro "$extra"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
grep -qxF "$configured:$configured:ro" "$docker_log" || { cat "$docker_log"; return 1; }
grep -qxF "$extra:$extra:ro" "$docker_log" || { cat "$docker_log"; return 1; }
rm -rf "$workspace" "$configured" "$extra"
}
# The point of appending to the same list: a flag is a way to say where, not
# a way past the refusals. Both directions, since the two lists are guarded
# by different code.
@test "--extra-readonly is refused when it names the approval store" {
_rw_setup
workspace="$(mktemp -d)"
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only'
_rw_launch_flags "$workspace" --ro "$CONFIG_DIR"
[ "$status" -ne 0 ] || { echo "launched anyway:"; echo "$output"; return 1; }
[[ "$output" == *"Refusing --extra-readonly path"* ]] || { echo "$output"; return 1; }
rm -rf "$workspace"
}
@test "--extra-read-write is refused when it names the approval store" {
_rw_setup
workspace="$(mktemp -d)"
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only'
_rw_launch_flags "$workspace" --rw "$CONFIG_DIR"
[ "$status" -ne 0 ] || { echo "launched anyway:"; echo "$output"; return 1; }
[[ "$output" == *"Refusing --extra-read-write path"* ]] || { echo "$output"; return 1; }
rm -rf "$workspace"
}
# A refusal that named "[mounts] readonly" would send someone to a config
# file that does not mention the path.
@test "a rejected flag path is reported as a flag, not as a config entry" {
_rw_setup
workspace="$(mktemp -d)"
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only'
_rw_launch_flags "$workspace" --ro /nonexistent-flag-path
[ "$status" -ne 0 ]
[[ "$output" == *"--extra-readonly path does not exist"* ]] || { echo "$output"; return 1; }
rm -rf "$workspace"
}
# Both lists are colon-separated, so such a path cannot be represented in one
# and would split into two mounts that each fail the existence check.
@test "a flag path containing a colon is refused" {
_rw_setup
workspace="$(mktemp -d)"
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only'
_rw_launch_flags "$workspace" --ro /tmp/has:colon
[ "$status" -ne 0 ]
[[ "$output" == *"must not contain a colon"* ]] || { echo "$output"; return 1; }
rm -rf "$workspace"
}
# #15. Both of these reach `extra_mounts+=(-v ...)` with a path computed at
# run time, so the static grep for a literal $PROJECT_STORE / config_root
# cannot see either one - it passed throughout. [mounts] read_write has been
# guarded since it was added; these two were not.
@test "[mounts] readonly is refused when it names the approval store" {
_rw_setup
workspace="$(mktemp -d)"
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only' \
'[mounts]' "readonly = $CONFIG_DIR"
_rw_launch "$workspace"
[ "$status" -ne 0 ] || { echo "launched anyway:"; echo "$output"; return 1; }
[[ "$output" == *"Refusing [mounts] readonly entry"* ]] || { echo "$output"; return 1; }
rm -rf "$workspace"
}
# The target is discovered from a symlink rather than configured, so nothing
# had vetted it: a hook pointing into the approval store mounted the store.
@test "a ~/.claude symlink is refused when its target sits in the approval store" {
_rw_setup
workspace="$(mktemp -d)"
mkdir -p "$RW_HOME/.claude/hooks" "$CONFIG_DIR/sub"
: > "$CONFIG_DIR/sub/hook.sh"
ln -s "$CONFIG_DIR/sub/hook.sh" "$RW_HOME/.claude/hooks/hook.sh"
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only'
_rw_launch "$workspace"
[ "$status" -ne 0 ] || { echo "launched anyway:"; echo "$output"; return 1; }
[[ "$output" == *"Refusing ~/.claude symlink target"* ]] || { echo "$output"; return 1; }
rm -rf "$workspace"
}
# The ordinary case the symlink scan exists for: this repo's own hooks/ are
# symlinked into ~/.claude and must still be mounted. Without this, refusing
# everything would pass the two tests above.
@test "a ~/.claude symlink to an ordinary directory is still mounted" {
_rw_setup
workspace="$(mktemp -d)"
hookdir="$(mktemp -d)"
: > "$hookdir/hook.sh"
mkdir -p "$RW_HOME/.claude/hooks"
ln -s "$hookdir/hook.sh" "$RW_HOME/.claude/hooks/hook.sh"
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only'
_rw_launch "$workspace"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
grep -qxF "$hookdir:$hookdir:ro" "$docker_log" \
|| { echo "hook target not mounted:"; cat "$docker_log"; return 1; }
rm -rf "$workspace" "$hookdir"
}
@test "an empty ssh-agent is warned about when [core] ssh_key is not set" {
_rw_setup
workspace="$(mktemp -d)"
write_config '[core]' 'forge = none' 'workflow = local-only'
_empty_agent
_rw_launch "$workspace"
_kill_agent
[[ "$output" == *"no identities loaded"* ]] || { echo "$output"; return 1; }
rm -rf "$workspace"
}
@test "a read_write entry is mounted without :ro, unlike a readonly one" {
_rw_setup
rw="$(mktemp -d)"
ro="$(mktemp -d)"
workspace="$(mktemp -d)"
write_config '[core]' "ssh_key = $RW_KEY" \
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only' \
'[mounts]' "read_write = $rw" "readonly = $ro"
_rw_launch "$workspace"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
@@ -957,7 +1388,12 @@ EOF
# though an actual session was told correctly. briefing_compose now
# reads the same DEJIMA_RW_MOUNTS a real launch would.
rw="$(mktemp -d)"
write_config '[mounts]' "read_write = $rw"
# forge = none, workflow = local-only: --print-briefing now runs after
# provider_validate_selection too, and the default gitea/worktree-pr pair
# is refused unconfigured - this test is about the mounts sentence, not
# forge selection.
write_config '[core]' 'forge = none' 'workflow = local-only' \
'[mounts]' "read_write = $rw"
run "$REPO_ROOT/bin/dejima" --print-briefing
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[[ "$output" == *"outlive the container too: $rw"* ]]
@@ -972,7 +1408,8 @@ EOF
# rule never runs, which would pass this test for the wrong reason.
mkdir -p "$CONFIG_DIR" "$RW_HOME/.claude" "$RW_HOME/.local/bin"
for guarded in "$CONFIG_DIR" "$RW_HOME/.claude" "$RW_HOME/.local/bin" "$REPO_ROOT"; do
write_config '[core]' "ssh_key = $RW_KEY" '[mounts]' "read_write = $guarded"
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only' \
'[mounts]' "read_write = $guarded"
_rw_launch "$workspace"
[ "$status" -ne 0 ] || { echo "accepted a writable mount over $guarded"; return 1; }
[[ "$output" == *"Refusing"* ]] \
@@ -980,13 +1417,15 @@ EOF
done
# The workspace is already mounted read-write; naming it again is a config
# error worth surfacing, not a harmless duplicate.
write_config '[core]' "ssh_key = $RW_KEY" '[mounts]' "read_write = $workspace"
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only' \
'[mounts]' "read_write = $workspace"
_rw_launch "$workspace"
[ "$status" -ne 0 ]
[[ "$output" == *"Refusing"* ]]
# A bare home directory contains several guarded paths, so the same
# both-directions rule catches it without needing an entry of its own.
write_config '[core]' "ssh_key = $RW_KEY" '[mounts]' "read_write = $RW_HOME"
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only' \
'[mounts]' "read_write = $RW_HOME"
_rw_launch "$workspace"
[ "$status" -ne 0 ]
[[ "$output" == *"Refusing"* ]]
@@ -1003,6 +1442,7 @@ EOF
workspace="$(mktemp -d)"
mkdir -p "$RW_HOME/data"
write_config '[core]' "ssh_key = $RW_KEY" "share_dir = $RW_HOME/share" \
'forge = none' 'workflow = local-only' \
'[audit]' "dir = $RW_HOME/data/store/audit" \
'[mounts]' "read_write = $RW_HOME/data"
[ ! -e "$RW_HOME/data/store" ]
@@ -1022,7 +1462,7 @@ EOF
# overlap rule under test never runs.
mkdir -p "$store/audit"
for key in dir baselines_dir; do
write_config '[core]' "ssh_key = $RW_KEY" \
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only' \
'[audit]' "$key = $store/audit" \
'[mounts]' "read_write = $store/audit"
_rw_launch "$workspace"
@@ -1038,7 +1478,7 @@ EOF
_rw_setup
workspace="$(mktemp -d)"
outer="$(mktemp -d)"
write_config '[core]' "ssh_key = $RW_KEY" \
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only' \
'[audit]' "dir = $outer/nested/audit" \
'[mounts]' "read_write = $outer"
_rw_launch "$workspace"
@@ -1055,7 +1495,7 @@ EOF
workspace="$(mktemp -d)"
for spelling in "/" "/." "//"; do
d="$(mktemp -d)"
write_config '[core]' "ssh_key = $RW_KEY" \
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only' \
'[mounts]' "readonly = $d" "read_write = ${d}${spelling}"
_rw_launch "$workspace"
[ "$status" -ne 0 ] || { echo "both lists accepted '$d' vs '${d}${spelling}'"; return 1; }
@@ -1070,7 +1510,8 @@ EOF
_rw_setup
d="$(mktemp -d)"
workspace="$(mktemp -d)"
write_config '[core]' "ssh_key = $RW_KEY" '[mounts]' "read_write = $d" "readonly = $d"
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only' \
'[mounts]' "read_write = $d" "readonly = $d"
_rw_launch "$workspace"
[ "$status" -ne 0 ]
[[ "$output" == *"both name the same"* ]]
@@ -1080,7 +1521,8 @@ EOF
@test "a read_write entry that is not an existing directory is refused" {
_rw_setup
workspace="$(mktemp -d)"
write_config '[core]' "ssh_key = $RW_KEY" '[mounts]' 'read_write = /nonexistent/nowhere'
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only' \
'[mounts]' 'read_write = /nonexistent/nowhere'
_rw_launch "$workspace"
[ "$status" -ne 0 ]
[[ "$output" == *"not an existing directory"* ]]
@@ -1110,8 +1552,12 @@ EOF
# exercises the real launch path to verify the refusal actually stops it.
_rw_setup
workspace="$(mktemp -d)"
# Configure gitea forge with a valid config plus a token file with a newline.
printf '[core]\nfirewall = on\nssh_key = %s\n[forge.gitea]\nssh_host = git.example\napi = https://api.example\ntoken_file = %s/token\n' \
# Configure gitea forge with a valid config plus a token file with a
# newline. forge/workflow are named explicitly because this test is about
# a gitea token reaching the container: the shipped defaults select no
# forge at all, so leaving them implicit would mean nothing ever reads
# token_file and the refusal under test could never fire.
printf '[core]\nfirewall = on\nssh_key = %s\nforge = gitea\nworkflow = worktree-pr\n[forge.gitea]\nssh_host = git.example\napi = https://api.example\ntoken_file = %s/token\n' \
"$RW_KEY" "$CONFIG_DIR" > "$CONFIG_DIR/config"
printf 'real-secret\ngarbage\n' > "$CONFIG_DIR/token"
STUB_DIR="$(mktemp -d)"
+882 -61
View File
File diff suppressed because it is too large Load Diff
+415 -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,289 @@ lib() { bash -c 'source "$1"; shift; "$@"' _ "$LIB" "$@"; }
done
}
@test "docs/providers.md documents every shipped provider" {
local doc="$REPO_ROOT/docs/providers.md" axis name
for axis in forge workflow harness; do
while IFS= read -r name; do
[ -n "$name" ] || continue
grep -q "$name" "$doc" || { echo "docs/providers.md never mentions $axis '$name'"; return 1; }
done < <(lib provider_list "$axis")
done
}
@test "no doc still says the new providers are unbuilt" {
! grep -qi 'not yet built\|Phase 3 candidates\|deferred to Phase 3' "$REPO_ROOT/docs/providers.md"
}
@test "the github forge's firewall posture change is documented" {
grep -q 'ssh.github.com' "$REPO_ROOT/docs/providers.md"
grep -qi 'push' "$REPO_ROOT/docs/providers.md"
}
@test "no doc still points at the old gitea CLI paths" {
run grep -rn 'bin/gitea-pr\|bin/gitea-issue' "$REPO_ROOT/README.md" \
"$REPO_ROOT/docs" "$REPO_ROOT/install.sh"
[ "$status" -ne 0 ] || { echo "$output"; return 1; }
}
@test "the briefing names a real forge host, and says nothing about one for none" {
printf '[forge.gitea]\nssh_host = git.example\n' > "$CONFIG_DIR/config"
run lib briefing_compose gitea worktree-pr claude-code
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[[ "$output" == *"GitHub read-only, the configured gitea host (git.example), plus anything"* ]]
# workflows/local-only (Task 4) is what `forge = none` is meant to pair
# with - none's capabilities are empty, and local-only requires none, so
# this composes the real shipped trio rather than a throwaway workflow.
run lib briefing_compose none local-only claude-code
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
# No dangling article, no "none" read as a forge name.
[[ "$output" == *"GitHub read-only, plus anything"* ]]
[[ "$output" != *"configured none host"* ]]
}
@test "branch-push pushes and blocks main, but attempts no PR" {
run lib manifest_validate workflow branch-push
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[ "$(lib manifest_get workflow branch-push push)" = "on" ]
[ "$(lib manifest_get workflow branch-push blocked_branches)" = "main master" ]
[ "$(lib manifest_get workflow branch-push block_merge)" = "off" ]
[ "$(lib manifest_get workflow branch-push requires_worktree)" = "on" ]
[ "$(lib manifest_get workflow branch-push autonomy)" = "push-branch" ]
# Requires no capability, so it is usable against a forge that offers
# none - but it still pushes, so unlike a non-pushing workflow it cannot
# pair with forge = none itself (rule 3: a pushing workflow needs a forge
# that resolves to a host, and none never does). Exercised here against
# gitea, fully configured, for a host without exercising any capability.
printf '[forge.gitea]\nssh_host = git.example\napi = https://api.example/v1\ntoken_file = /dev/null\n' \
> "$CONFIG_DIR/config"
run lib provider_validate_selection gitea branch-push claude-code
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
}
@test "the branch-push briefing tells the agent to stop at the push" {
run lib briefing_compose gitea branch-push claude-code
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[[ "$output" == *"Push that branch"* ]]
# It must not instruct opening a PR - that is worktree-pr's job, and this
# workflow's whole point is that it stops earlier.
[[ "$output" != *"create"*"PR"* ]] || { echo "branch-push briefing mentions opening a PR"; return 1; }
}
# #32: an implementer ran a bare `git config user.name ...` inside a
# worktree and wrote the parent checkout's real config, misattributing three
# commits. A worktree isolates the working tree, not `.git/config`, and the
# briefing told the agent to use worktrees while saying nothing about that -
# which reads as more isolation than is on offer. The same unscoped command
# reaches core.hooksPath and credential.helper, which execute on the host.
#
# In the core paragraph rather than a workflow's own prose, so a workflow can
# opt into worktrees but cannot ship them without this.
@test "every worktree briefing says a worktree does not isolate .git/config" {
local wf
for wf in worktree-pr branch-push; do
[ "$(lib manifest_get workflow "$wf" requires_worktree)" = "on" ]
run lib briefing_compose gitea "$wf" claude-code
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[[ "$output" == *".git/config"* ]] \
|| { echo "$wf briefing does not mention .git/config"; return 1; }
[[ "$output" == *"git -C"* ]] \
|| { echo "$wf briefing does not give the scoped form"; return 1; }
done
}
# The negative half: a workflow that does not use worktrees has no reason to
# carry the paragraph, and its presence there would mean the splice is
# unconditional rather than opt-in.
@test "a workflow without worktrees does not carry the worktree paragraph" {
[ "$(lib manifest_get workflow local-only requires_worktree)" = "off" ]
run lib briefing_compose none local-only claude-code
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[[ "$output" != *"does not isolate"* ]]
[[ "$output" != *"worktree MUST live"* ]]
}
@test "local-only pushes nothing and blocks no branches" {
run lib manifest_validate workflow local-only
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[ "$(lib manifest_get workflow local-only push)" = "off" ]
[ "$(lib manifest_get workflow local-only block_merge)" = "off" ]
[ "$(lib manifest_get workflow local-only requires_worktree)" = "off" ]
[ "$(lib manifest_get workflow local-only autonomy)" = "commit" ]
# blocked_branches is empty BECAUSE push is off - there is no push to
# block. This pins the pair together: turning push on later without
# revisiting this would silently allow a push to main.
[ -z "$(lib manifest_get workflow local-only blocked_branches)" ]
}
@test "none + local-only is a valid, fully unconfigured selection" {
run lib provider_validate_selection none local-only claude-code
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[ -z "$output" ]
}
@test "the github forge pushes with its own token, not the read-only one" {
run lib manifest_validate forge github
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[ "$(lib manifest_get forge github capabilities)" = "pr issues" ]
[ "$(lib manifest_get forge github pr_command)" = "gh pr" ]
[ "$(lib manifest_get forge github merge_block)" = "gh pr merge" ]
# The write-capable token is its own key. Reusing [github] token_file
# would promote a deliberately read-only PAT to a pushing credential.
[ "$(lib manifest_get forge github requires)" = "push_token_file" ]
[[ "$(lib manifest_get forge github env_export)" == *"GH_TOKEN=@push_token_file"* ]]
# Ships no CLI: gh is already in the image.
[ -z "$(lib manifest_get forge github install)" ]
}
@test "the briefing does not call GitHub read-only when the forge can push to it" {
# The other half of Task 2's network_allowlist resolver, tested here
# because this is where the github forge starts existing.
printf '[forge.github]\npush_token_file = /dev/null\n' > "$CONFIG_DIR/config"
run lib briefing_compose github branch-push claude-code
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[[ "$output" != *"GitHub read-only"* ]]
[[ "$output" == *"GitHub, which this session can also push to"* ]]
}
@test "the github forge contributes its hosts without any config" {
hosts="$(lib forge_hosts github)"
for h in github.com ssh.github.com api.github.com; do
[[ " $hosts " == *" $h "* ]] || { echo "missing $h in: $hosts"; return 1; }
done
}
@test "the github forge routes its hosts through core's allowlist, not the extra-domains one" {
# The manifest half of the launcher behaviour asserted in test/config.bats
# ("the github forge's hosts never reach the extra-domains allowlist").
# Those three hosts are public SaaS names already covered by core's
# GitHub ranges: sending them down the extra-domains path would put them
# in the port-unrestricted allowed-domains ipset AND exempt them from
# dnsmasq's stop-dns-rebind, and the firewall's startup self-check
# ("github.com:22 was reachable without ALLOW_GITHUB_PUSH=1") would then
# refuse to bring the container up at all.
[ "$(lib manifest_get forge github hosts_allowlisted_by_core)" = "on" ]
# Still resolved by forge_hosts, which is what keeps rule 3 (a pushing
# workflow needs a forge with a host) satisfied and what fills
# DEJIMA_PUSH_HOSTS so git-push-guard.sh permits the push.
[ -n "$(lib forge_hosts github)" ]
# Off unless a manifest says otherwise: a self-hosted forge's host is
# exactly the internal address the extra-domains path exists for.
[ "$(lib manifest_get forge gitea hosts_allowlisted_by_core)" = "off" ]
[ "$(lib manifest_get forge none hosts_allowlisted_by_core)" = "off" ]
}
@test "the network-posture sentence follows the manifest, not the forge's name" {
# Same key, read for the briefing: with a pushing workflow the session
# really can push to GitHub, and with a non-pushing one it really cannot
# (bin/dejima only opens the push grant when the workflow pushes), so the
# sentence has to say two different things for the same forge.
printf '[forge.github]\npush_token_file = /dev/null\n' > "$CONFIG_DIR/config"
run lib briefing_compose github local-only claude-code
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[[ "$output" == *"GitHub read-only"* ]]
[[ "$output" != *"can also push to"* ]]
# And no dangling "the configured github host" - core allowlists those,
# there is nothing for an operator to have configured.
[[ "$output" != *"configured github host"* ]]
}
@test "a required key that is set but empty counts as missing" {
# install.sh writes every key commented out at its default, so
# uncommenting one and not filling it in yields `api =` - set, and worth
# nothing. config_is_set alone called that configured, and the launch
# went ahead with every gitea call failing inside the container instead.
printf '[forge.gitea]\nssh_host = git.example\napi =\n' > "$CONFIG_DIR/config"
run lib provider_validate_selection gitea worktree-pr claude-code
[ "$status" -ne 0 ]
[[ "$output" == *"api"* ]]
}
@test "a single required key that is set but empty is refused too" {
# The same hole in the other rule: github's one `requires` key can never
# be "partly" configured, so an empty push_token_file would otherwise
# sail past both this rule and rule 3 (fixed_hosts means it always
# resolves to a host) and launch with no push credential at all.
printf '[core]\nforge = github\n[forge.github]\npush_token_file =\n' > "$CONFIG_DIR/config"
run lib provider_validate_selection github branch-push claude-code
[ "$status" -ne 0 ]
[[ "$output" == *"push_token_file"* ]]
}
@test "the no-host refusal offers the forge that needs no host configured" {
# The remedy has to include the one an operator who wants to push would
# actually pick - pointing only at `forge = none` + local-only answers
# the opposite question. Asserted here as the claim (every forge the
# remedy names is real), not the literal wording - see
# assert_remedy_forges_exist.
printf '[core]\nfirewall = on\n' > "$CONFIG_DIR/config"
run lib provider_validate_selection gitea worktree-pr claude-code
[ "$status" -ne 0 ]
assert_remedy_forges_exist "$output"
run lib provider_validate_selection none branch-push claude-code
[ "$status" -ne 0 ]
assert_remedy_forges_exist "$output"
}
@test "an unset push_token_file yields no GH_TOKEN pair at all, not an empty one" {
# docker-compose.yml declares GH_TOKEN itself, carrying the independent
# read-only [github] token_file value. `-e GH_TOKEN=` (the pair
# forge_env_exports used to emit for an unresolved value) overrides that
# entry to empty on `docker compose run` - wholly unconfigured must stay
# silent here, exactly like it already does for provider_validate_selection's
# own partial-config warning, or selecting this forge with no push token
# yet silently voids the baseline read-only access every session gets.
printf '[core]\nforge = github\n' > "$CONFIG_DIR/config"
run lib forge_env_exports github
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[ -z "$output" ]
}
@test "a configured push_token_file still yields a real GH_TOKEN pair" {
tok="$CONFIG_DIR/push-token"
printf 'ghp_example\n' > "$tok"
printf '[forge.github]\npush_token_file = %s\n' "$tok" > "$CONFIG_DIR/config"
run lib forge_env_exports github
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[ "$output" = "GH_TOKEN=ghp_example" ]
}
@test "open-pr autonomy is refused against a forge with no pr capability" {
FAKE_ROOT="$(mktemp -d)"
mkdir -p "$FAKE_ROOT/forges/nopr" "$FAKE_ROOT/workflows/wants-pr" "$FAKE_ROOT/harnesses/h"
printf 'capabilities =\n' > "$FAKE_ROOT/forges/nopr/forge.conf"
# requires is empty, so rule 1 passes and only the autonomy rule can catch this.
printf 'requires =\npush = on\nautonomy = open-pr\n' > "$FAKE_ROOT/workflows/wants-pr/workflow.conf"
printf 'command = claude\n' > "$FAKE_ROOT/harnesses/h/harness.conf"
run env DEJIMA_PROVIDER_ROOT="$FAKE_ROOT" \
bash -c 'source "$1"; provider_validate_selection nopr wants-pr h' _ "$LIB"
[ "$status" -ne 0 ]
[[ "$output" == *"open-pr"* ]]
}
@test "push-branch autonomy is refused when the workflow does not push" {
FAKE_ROOT="$(mktemp -d)"
mkdir -p "$FAKE_ROOT/forges/f" "$FAKE_ROOT/workflows/contradiction" "$FAKE_ROOT/harnesses/h"
printf 'capabilities =\n' > "$FAKE_ROOT/forges/f/forge.conf"
printf 'requires =\npush = off\nautonomy = push-branch\n' > "$FAKE_ROOT/workflows/contradiction/workflow.conf"
printf 'command = claude\n' > "$FAKE_ROOT/harnesses/h/harness.conf"
run env DEJIMA_PROVIDER_ROOT="$FAKE_ROOT" \
bash -c 'source "$1"; provider_validate_selection f contradiction h' _ "$LIB"
[ "$status" -ne 0 ]
[[ "$output" == *"push-branch"* ]]
}
@test "every shipped workflow's autonomy is supported by a forge that ships" {
# worktree-pr/open-pr and branch-push both push, so both now need a real
# host (rule 3) - gitea, fully configured. local-only never pushes, so
# none (nothing configured at all) stays the honest pairing for it.
printf '[forge.gitea]\nssh_host = git.example\napi = https://api.example/v1\ntoken_file = /dev/null\n' \
> "$CONFIG_DIR/config"
run lib provider_validate_selection gitea worktree-pr claude-code
[ "$status" -eq 0 ] || { echo "gitea+worktree-pr: $output"; return 1; }
run lib provider_validate_selection gitea branch-push claude-code
[ "$status" -eq 0 ] || { echo "gitea+branch-push: $output"; return 1; }
printf '[core]\nfirewall = on\n' > "$CONFIG_DIR/config"
run lib provider_validate_selection none local-only claude-code
[ "$status" -eq 0 ] || { echo "none+local-only: $output"; return 1; }
}
+9 -14
View File
@@ -409,21 +409,16 @@ make_cert() {
# shell *function* persists in the calling shell rather than scoping to
# the call, so it would leak into every later test in this file.
#
# The -u pair is what makes this test about the code rather than about
# where it runs (#56). "No cert configured" was expressed by not setting
# the variables, which only holds where nothing else set them -
# entrypoint.sh:211 exports both into every tls-transport session, so a
# suite run from inside a dejima container inherited real cert paths, the
# guard below never fired, and execution fell through to the listen. What
# failed then was an EADDRINUSE against the session's own forwarder on
# :443, reported as this assertion failing - the code was fine and the
# test was reading its own environment. Unsetting them states the
# precondition instead of assuming it.
# The -u pair makes this about the code rather than where it runs (#56).
# "No cert configured" used to be expressed by not setting the variables,
# which only holds where nothing else did - entrypoint.sh exports both
# into every tls-transport session, so a suite run inside a dejima
# container inherited real paths, the guard never fired, and the
# fall-through hit EADDRINUSE on :443 and was reported as this assertion
# failing. Unsetting states the precondition instead of assuming it.
#
# The port override is belt-and-braces: the cert check runs before the
# listen today, so a passing test never binds anything, but if that order
# ever changes the fall-through lands on an unused port rather than on
# whatever holds :443.
# The port override is belt-and-braces: the cert check precedes the listen
# today, so a fall-through lands on an unused port if that ever changes.
run env -u SANDBOX_ANTHROPIC_TLS_CERT -u SANDBOX_ANTHROPIC_TLS_KEY \
SANDBOX_ANTHROPIC_PROXY_SOCKET="$SOCK" SANDBOX_ANTHROPIC_TRANSPORT=tls \
SANDBOX_ANTHROPIC_PROXY_PORT=18398 \
+82 -27
View File
@@ -5,6 +5,25 @@
# compromised session writes into - so most of what's here is about what it
# refuses to do with that input.
# No test in this file may modify the repository's own CLIs. Two of them need
# an exit-time audit that fails, and they get it from a temp shadow of bin/
# (see shadow_repo) rather than by overwriting the real one. This is what
# proves it stayed that way even if a test aborts partway through, and it is
# checked against a hash rather than against `git status` so that it holds
# while someone has uncommitted edits to that file.
setup_file() {
REMOTE_AUDIT_SUM="$(sha256sum "$BATS_TEST_DIRNAME/../bin/dejima-audit" | cut -d' ' -f1)"
export REMOTE_AUDIT_SUM
}
teardown_file() {
local now
now="$(sha256sum "$BATS_TEST_DIRNAME/../bin/dejima-audit" | cut -d' ' -f1)"
[ "$now" = "$REMOTE_AUDIT_SUM" ] || {
echo "test/remote.bats modified bin/dejima-audit in the repository" >&2
return 1
}
}
setup() {
REPO_ROOT="$BATS_TEST_DIRNAME/.."
LIB="$REPO_ROOT/bin/dejima-project-lib.sh"
@@ -19,15 +38,6 @@ teardown() {
if [ -n "${TMUX_DIR:-}" ]; then
TMUX_TMPDIR="$TMUX_DIR" tmux kill-server 2>/dev/null || true
fi
# Safety net for tests that temporarily swap out the real bin/dejima-
# audit: each such test restores it itself right after `run`, before any
# assertion that could short-circuit the test body, but this catches the
# case anyway if something in between still managed to abort first.
if [ -n "${AUDIT_BACKUP:-}" ] && [ -f "$AUDIT_BACKUP" ]; then
cp "$AUDIT_BACKUP" "$REPO_ROOT/bin/dejima-audit"
chmod +x "$REPO_ROOT/bin/dejima-audit"
rm -f "$AUDIT_BACKUP"
fi
rm -rf "$CONFIG_DIR" "$RUNTIME_DIR" ${STUB_DIR:+"$STUB_DIR"} ${LAUNCH_DIR:+"$LAUNCH_DIR"} \
${TMUX_DIR:+"$TMUX_DIR"} ${FAKE_HOME:+"$FAKE_HOME"}
}
@@ -70,6 +80,54 @@ write_remote_config() {
[ "$output" = "$RUNTIME_DIR/dejima" ]
}
# A throwaway copy of the repository whose bin/dejima-audit a test may
# replace or delete. Everything else is a symlink to the real thing, so this
# costs one directory of links.
#
# bin/ is copied rather than linked, and both reasons matter. bin/dejima
# resolves its own location with `readlink -f`, so a symlinked copy would
# point SCRIPT_DIR back at the repository and the stub would never be the one
# it calls. And a symlink is actively dangerous here: `cat > $SHADOW/bin/
# dejima-audit` follows it and truncates the real script, which is precisely
# what this helper exists to stop. Copying removes the whole class rather
# than one instance of it.
#
# Two tests here need an audit binary that fails, and they used to get one by
# overwriting `$REPO_ROOT/bin/dejima-audit` and restoring it afterwards. That
# left the working tree holding a two-line stub for the length of the test,
# which is a poor thing to do to a security tool: a run killed partway, or
# two runs of this file at once, restores the stub permanently instead of the
# script, and a suite that passes while silently disabling the exit-time
# audit is the exact failure this file exists to prevent. It also, reasonably
# enough, reads like an attack to anything watching the repository for
# changes.
shadow_repo() {
local root entry
root="$(cd "$REPO_ROOT" && pwd)"
SHADOW="$BATS_TEST_TMPDIR/shadow"
mkdir -p "$SHADOW"
for entry in "$root"/*; do
[ "$(basename "$entry")" = bin ] && continue
ln -sfn "$entry" "$SHADOW/$(basename "$entry")"
done
cp -a "$root/bin" "$SHADOW/bin"
DEJIMA_BIN="$SHADOW/bin/dejima"
}
# Stands in for the real audit and does nothing but fail. Self-describing
# because a file watcher can surface it mid-test, and a bare `exit 3` sitting
# where a security tool belongs is alarming without this comment on it.
shadow_failing_audit() {
cat > "$SHADOW/bin/dejima-audit" <<'EOF'
#!/bin/bash
# Test fixture for test/remote.bats, living in that test's temp directory
# and never in the repository. Fails on purpose: bin/dejima must still
# report the container's exit code and not this one.
exit 3
EOF
chmod +x "$SHADOW/bin/dejima-audit"
}
# A fake `docker` that records its argv, so bin/dejima's launch path can be
# exercised with no daemon. Same approach as test/project.bats.
stub_launch() {
@@ -124,7 +182,7 @@ launch() {
SANDBOX_TEST_DOCKER_LOG="$LAUNCH_DIR/docker-log" \
SANDBOX_TEST_SETTINGS_COPY="$LAUNCH_DIR/settings-copy.json" \
SSH_AUTH_SOCK= ALLOWED_DOMAINS= \
"$REPO_ROOT/bin/dejima" "$@"
"${DEJIMA_BIN:-$REPO_ROOT/bin/dejima}" "$@"
}
run_line() { grep -- ' run --rm ' "$LAUNCH_DIR/docker-log" | tail -1; }
@@ -280,15 +338,9 @@ done
exit 0
EOF
chmod +x "$LAUNCH_DIR/docker"
AUDIT_REAL="$REPO_ROOT/bin/dejima-audit"
AUDIT_BACKUP="$(mktemp)"
cp "$AUDIT_REAL" "$AUDIT_BACKUP"
printf '#!/bin/bash\nexit 3\n' > "$AUDIT_REAL"
chmod +x "$AUDIT_REAL"
shadow_repo
shadow_failing_audit
run launch "$PROJ"
cp "$AUDIT_BACKUP" "$AUDIT_REAL"
chmod +x "$AUDIT_REAL"
rm -f "$AUDIT_BACKUP"
[ "$status" -eq 5 ]
rm -rf "$PROJ"
}
@@ -309,14 +361,9 @@ done
exit 0
EOF
chmod +x "$LAUNCH_DIR/docker"
AUDIT_REAL="$REPO_ROOT/bin/dejima-audit"
AUDIT_BACKUP="$(mktemp)"
cp "$AUDIT_REAL" "$AUDIT_BACKUP"
rm -f "$AUDIT_REAL"
shadow_repo
rm -f "$SHADOW/bin/dejima-audit"
run launch "$PROJ"
cp "$AUDIT_BACKUP" "$AUDIT_REAL"
chmod +x "$AUDIT_REAL"
rm -f "$AUDIT_BACKUP"
[ "$status" -eq 5 ]
rm -rf "$PROJ"
}
@@ -880,10 +927,18 @@ EOF
# Waits for the grouped session an attach should have created, then prints
# "<name> <group>" for it - empty group means UNGROUPED, i.e. a fresh session
# tmux conjured out of a name rather than a clone of the sandbox's own.
#
# Waits for the CLIENT rather than the session, like the earlier attach test
# does. dejima-attach creates the session detached, checks it holds the pane,
# selects the window and only then attaches - so has-session goes true while
# the session is still on whatever window the group started on. A caller that
# goes on to assert the window index reads it inside that gap. That is how
# this failed in CI, with the group assertion on the line before it passing:
# the group is set at creation, the window is not.
await_group() {
local i
for i in $(seq 50); do
if tmux has-session -t "=dejima-$1" 2>/dev/null; then break; fi
for i in $(seq 100); do
[ "$(tmux list-clients -F '#{client_session}' 2>/dev/null)" = "dejima-$1" ] && break
sleep 0.1
done
tmux list-sessions -F '#{session_name} #{session_group}' | grep "^dejima-$1 " || true
+318 -35
View File
@@ -29,42 +29,31 @@ IMAGE=dejima:latest
container_name() { echo "dejima-test-$(basename "$BATS_FILE_TMPDIR")"; }
network_name() { echo "dejima-test-net-$(basename "$BATS_FILE_TMPDIR")"; }
# Every container this file starts must go on network_name(), never the
# default bridge: dns-proxy.sh forwards allowlisted names to Docker's
# embedded resolver at 127.0.0.11, which only exists on a user-defined
# network (compose gives the real sandbox one). On the default bridge the
# container gets the host's nameservers instead, dnsmasq has nowhere to
# forward, and startup dies in dns-proxy.sh - see its own check for this.
# Every container here must go on network_name(), never the default bridge:
# dns-proxy.sh forwards to Docker's embedded resolver at 127.0.0.11, which
# only exists on a user-defined network. On the default bridge dnsmasq has
# nowhere to forward and startup dies in dns-proxy.sh.
#
# Readiness is the *final* `sleep infinity` (entrypoint.sh's
# SANDBOX_TEST_MODE exec), matched exactly. A bare `pgrep -x sleep` also
# matches the short-lived sleeps of the startup sequence itself
# (dns-proxy.sh's dig-retry loop, entrypoint.sh's forwarder-bind wait), and
# a test that proceeds on one of those asserts against a container whose
# firewall hasn't been installed yet - which silently turns egress
# assertions into vacuous passes, since everything is still reachable at
# that point. Waiting for `sleep infinity` means the whole startup sequence
# (DNS proxy, firewall, ipsets, logging, forwarder bind) has completed.
# Readiness is the FINAL `sleep infinity` (entrypoint.sh's SANDBOX_TEST_MODE
# exec), matched exactly. A bare `pgrep -x sleep` also matches the startup
# sequence's own short sleeps, and proceeding on one of those asserts against
# a container with no firewall yet - turning egress assertions into vacuous
# passes, since everything is still reachable.
#
# 120 rather than 60: init-firewall.sh's GitHub ranges fetch now retries, and a
# run that loses two attempts before succeeding spends ~26s of the budget on
# that call alone (#59) - which on a runner that has just spent three minutes
# unpacking an image is enough to turn a recovery into a spurious failure. A
# longer budget only delays reporting a genuinely stuck startup; it cannot hide
# one, since nothing here passes on a timeout.
# `docker wait` returns the moment the container exits, but the json-file log
# driver writes asynchronously - so reading the log right then can return one
# that stops mid-stream, with everything after some flush boundary missing.
# 120s rather than 60: the GitHub ranges fetch retries, and losing two
# attempts costs ~26s (#59), which on a runner that just spent minutes
# unpacking an image turns a recovery into a spurious failure. A longer
# budget only delays reporting a stuck startup; nothing here passes on a
# timeout.
# `docker wait` returns on exit, but the json-file log driver writes
# asynchronously, so reading right then can return a log that stops
# mid-stream. That presented as a container "aborting" partway through
# init-firewall.sh's domain loop at a different domain each run, and survived
# three fixes before the giveaway: no message from an ERR trap that cannot be
# bypassed. Nothing had aborted; the read was short.
#
# That is not hypothetical. It presented as a container "aborting" partway
# through init-firewall.sh's domain loop, at a slightly different domain each
# run, and survived three separate fixes to that loop before the giveaway: the
# log ended with no message from an ERR trap that cannot be bypassed and no
# output from a retry that prints on every path. Nothing had aborted. The read
# was short.
#
# Two identical consecutive reads means the driver has caught up. Deliberately
# content-neutral - waiting for an expected string would make the wait pass or
# Two identical consecutive reads means the driver has caught up.
# Content-neutral on purpose - waiting for an expected string would pass or
# fail on the very thing the caller is about to assert.
settled_logs() {
local container="$1" current previous=""
@@ -85,6 +74,16 @@ wait_ready() {
if docker exec "$container" pgrep -x -f 'sleep infinity' >/dev/null 2>&1; then
return 0
fi
# A container that has already exited is never going to become ready,
# and polling it for the full two minutes turns a startup failure - an
# aborted entrypoint, say - into a wait long enough to read as a hang.
# The reason is in its logs, which are printed either way; this only
# decides how long it takes to see them.
if [ "$(docker inspect -f '{{.State.Running}}' "$container" 2>/dev/null)" = false ]; then
echo "container $container exited during startup - log:" >&2
settled_logs "$container" >&2
return 1
fi
sleep 1
done
echo "container $container never reached the post-entrypoint sleep - startup log:" >&2
@@ -196,6 +195,8 @@ teardown() {
docker rmi "dejima-test-override:$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
docker rm -f "dejima-test-sshkey-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
docker rm -f "dejima-test-ghpush-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
docker rm -f "dejima-test-ghforge-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
docker rm -f "dejima-test-ghextra-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
docker rm -f "dejima-test-claudejson-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
docker rm -f "dejima-test-rebind-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
docker rm -f "dejima-test-overlays-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
@@ -209,6 +210,8 @@ teardown() {
docker rm -f "dejima-test-tls-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
docker rm -f "dejima-test-nohosts-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
docker rm -f "dejima-test-badtransport-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
docker rm -f "dejima-test-notifydir-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
docker rm -f "dejima-test-nonotify-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
}
dexec() {
@@ -357,6 +360,26 @@ dexec_node() {
[ -n "$output" ]
}
@test "the session user cannot address a resolver directly, so the DNS allowlist is not bypassable" {
# init-firewall.sh opens UDP/TCP 53 to any destination for root-owned
# sockets, because that is what Docker's embedded resolver forwards
# external names from and without it nothing in here resolves at all.
# This is the assertion that the opening stops there. `node` is the
# session user and there is no sudo in the image, so if it could reach a
# resolver of its own choosing it could carry data out in the query name
# itself - the exact thing dns-proxy.sh exists to prevent, and something
# no allowlist of *names* can catch once the *resolver* is attacker-chosen.
#
# 1.1.1.1 rather than an unrouted address: it answers if it is reachable,
# so a pass here means the packet was stopped and not that the probe was
# aimed at nothing.
run dexec_node dig +short registry.npmjs.org
[ -n "$output" ]
run dexec_node dig +time=2 +tries=1 @1.1.1.1 definitely-not-allowed.example.org
[ "$status" -ne 0 ]
}
@test "api.anthropic.com resolves in proxy-off mode (kill switch / API-key auth)" {
# api.anthropic.com is left off the allowlist when the credential proxy is
# on, so a bypass of the /etc/hosts pin that resolves the name by DNS gets
@@ -402,6 +425,109 @@ dexec_node() {
docker rm -f "$test_container" >/dev/null 2>&1 || true
}
@test "a github-forge launch boots, and its port 22 comes from the push grant" {
# The container half of test/config.bats' "the github forge's hosts reach
# the push guard, never the extra-domains allowlist" - the environment
# asserted there, handed to a real container here. Selecting `forge =
# github` with a pushing workflow resolves to exactly this: no
# DEJIMA_FORGE_HOSTS (core already allowlists GitHub's ranges) and the
# push grant open.
#
# It has to be a boot test, because the failure it guards against was the
# container not starting at all: with those three hostnames in
# DEJIMA_FORGE_HOSTS, init-firewall.sh resolves them into the
# port-unrestricted allowed-domains ipset, its own startup self-check
# finds github.com:22 reachable without ALLOW_GITHUB_PUSH=1, and
# entrypoint.sh (set -Eeuo pipefail) dies before the agent ever runs.
local test_container="dejima-test-ghforge-$(basename "$BATS_FILE_TMPDIR")"
docker run -d --name "$test_container" \
--cap-add=NET_ADMIN --cap-add=NET_RAW \
--network "$(network_name)" \
-e SANDBOX_TEST_MODE=1 \
-e GH_TOKEN="${GH_TOKEN:-}" \
-e DEJIMA_FORGE_HOSTS= \
-e DEJIMA_PUSH_HOSTS="github.com ssh.github.com api.github.com" \
-e ALLOW_GITHUB_PUSH=1 \
--entrypoint /usr/local/bin/entrypoint.sh \
"$IMAGE" >/dev/null
# The container coming up at all is half the assertion.
wait_ready "$test_container"
# Port 22 open, and open through the path that says so out loud.
run docker exec "$test_container" timeout 5 bash -c "cat < /dev/null > /dev/tcp/github.com/22"
[ "$status" -eq 0 ]
# Polled rather than read once, for the reason settled_logs documents: the
# json-file driver's writes lag the process that produced them, so a single
# read taken here can stop short of a line printed seconds earlier. That is
# how this failed in CI - with the port-22 assertion directly above passing,
# ie. with the branch that prints this line demonstrably taken. Waiting on
# the line cannot mask its absence: one never printed still fails, later.
local want="ALLOW_GITHUB_PUSH=1: opening port 22" logs=""
for _ in $(seq 1 40); do
logs=$(docker logs "$test_container" 2>&1)
case "$logs" in *"$want"*) break ;; esac
sleep 0.25
done
[[ "$logs" == *"$want"* ]]
# And the other half of the widening this avoids: GitHub's names keep
# dnsmasq's rebind protection, which the extra-domains path would have
# exempted them from.
run docker exec "$test_container" grep 'rebind-domain-ok=/github.com/' /etc/dnsmasq-sandbox.conf
[ "$status" -ne 0 ]
docker rm -f "$test_container" >/dev/null 2>&1 || true
}
@test "a GitHub name declared as an extra domain stays port-restricted, so push stays unreachable" {
# .dejima/allowed-domains is read with the project and is meant to widen
# what a session can reach. It must not widen how. GitHub's addresses are
# confined to 443 by init-firewall.sh, and that is the entire read-only
# guarantee - with 22 shut there is no transport left for the forwarded
# ssh-agent's key to push with, whatever a hook does or does not catch.
#
# allowed-domains carries no port restriction, so before ipset_for_domain
# this path put GitHub's address there and opened 22. It surfaced as an
# intermittent startup failure rather than as a silent hole only because
# the port-22 self-check re-resolves the name: it catches the hole when
# GitHub's second answer matches the first, and misses it when the name
# has rotated in between. This test does not depend on that coin flip.
local test_container="dejima-test-ghextra-$(basename "$BATS_FILE_TMPDIR")"
docker run -d --name "$test_container" \
--cap-add=NET_ADMIN --cap-add=NET_RAW \
--network "$(network_name)" \
-e SANDBOX_TEST_MODE=1 \
-e GH_TOKEN="${GH_TOKEN:-}" \
-e DEJIMA_FORGE_HOSTS= \
-e ALLOWED_DOMAINS="github.com api.github.com" \
--entrypoint /usr/local/bin/entrypoint.sh \
"$IMAGE" >/dev/null
# Coming up at all is part of the assertion: the port-22 self-check runs
# during startup, and with the address in the wrong set it fails the boot.
wait_ready "$test_container"
# Resolved inside the container, through the same dnsmasq that answered
# during startup - so this asks about the address actually allowlisted,
# not one a fresh lookup from elsewhere happened to return.
local ip
ip=$(docker exec "$test_container" dig +short A github.com | head -1)
[ -n "$ip" ]
run docker exec "$test_container" ipset test github-domains "$ip"
[ "$status" -eq 0 ]
run docker exec "$test_container" ipset test allowed-domains "$ip"
[ "$status" -ne 0 ]
# And the property those two sets exist to produce.
run docker exec "$test_container" timeout 5 bash -c "cat < /dev/null > /dev/tcp/github.com/22"
[ "$status" -ne 0 ]
docker rm -f "$test_container" >/dev/null 2>&1 || true
}
@test "a configured forge host is exempted from stop-dns-rebind, other allowed domains are not" {
# Passed as DEJIMA_FORGE_HOSTS, not GITEA_SSH_HOST: the container no longer
# derives hostnames from any one forge's variables - bin/dejima resolves
@@ -457,6 +583,73 @@ dexec_node() {
rm -rf "$src"
}
# #104: the whole ~/.claude/projects tree used to be mounted read-write, so a
# session could write another project's transcripts and agent memory. Memory
# is loaded into context at session start, which makes that a write into a
# future session's premises rather than its input.
#
# Three properties, and the second is the one that decides whether scoping is
# viable at all: a slug that is absent has to be creatable, because that is
# what Claude Code does on a first run against a path with no history. If the
# parent were read-only instead, this is where it would hang the way a
# blocked ~/.claude.json write did.
@test "only the mounted project slugs exist in the container, and an unmounted one is still creatable" {
local test_container="dejima-test-projects-$(basename "$BATS_FILE_TMPDIR")"
local src; src="$(mktemp -d)"
mkdir -p "$src/own" "$src/sibling" "$src/fh-own"
echo host > "$src/own/existing.jsonl"
echo secret > "$src/sibling/other.jsonl"
# file-history holds verbatim copies of edited files, so an unmounted
# session directory being absent is the whole point of scoping it.
echo 'other project source' > "$src/fh-own/aaaa@v1"
chmod -R 777 "$src"
docker run -d --name "$test_container" \
--cap-add=NET_ADMIN --cap-add=NET_RAW \
--network "$(network_name)" \
-e SANDBOX_TEST_MODE=1 \
-e GH_TOKEN="${GH_TOKEN:-}" \
-v "$src/own:/home/node/.claude/projects/own" \
-v "$src/fh-own:/home/node/.claude/file-history/own-session" \
--entrypoint /usr/local/bin/entrypoint.sh \
"$IMAGE" >/dev/null
wait_ready "$test_container"
# the sibling project is absent, not merely unwritable
run docker exec -u node "$test_container" test -e /home/node/.claude/projects/sibling
[ "$status" -ne 0 ]
run docker exec -u node "$test_container" ls /home/node/.claude/projects
[[ "$output" != *sibling* ]]
# a slug with no history yet can still be created, and stays in the
# container: it is not on the host afterwards
run docker exec -u node "$test_container" \
bash -c 'mkdir -p /home/node/.claude/projects/fresh && echo x > /home/node/.claude/projects/fresh/a.jsonl'
[ "$status" -eq 0 ]
[ ! -e "$src/fresh" ]
# and the mounted slug is the one that does reach the host
docker exec -u node "$test_container" \
bash -c 'echo written > /home/node/.claude/projects/own/new.jsonl'
[ "$(cat "$src/own/new.jsonl")" = written ]
# Same three properties for file-history. The middle one is what the
# image's pre-created directory buys: without it Docker creates
# file-history/ root-owned while setting up the mount nested in it, and
# the session cannot record an undo entry for its own id at all.
run docker exec -u node "$test_container" ls /home/node/.claude/file-history
[[ "$output" != *other-session* ]]
run docker exec -u node "$test_container" \
bash -c 'mkdir -p /home/node/.claude/file-history/fresh-session && echo x > /home/node/.claude/file-history/fresh-session/b@v1'
[ "$status" -eq 0 ]
[ ! -e "$src/fresh-session" ]
docker exec -u node "$test_container" \
bash -c 'echo written > /home/node/.claude/file-history/own-session/c@v1'
[ "$(cat "$src/fh-own/c@v1")" = written ]
docker rm -f "$test_container" >/dev/null 2>&1 || true
rm -rf "$src"
}
@test "a staged statusline.sh is copied in root-owned, and replacing it never reaches the host" {
# Companion to the overlay-dirs test above, but for the config
# bin/dejima stages at /etc/dejima-claude for entrypoint.sh to copy into
@@ -594,7 +787,10 @@ dexec_node() {
}
@test "the ipset stays in sync with fresh (non-cached) DNS answers, not just the boot-time snapshot" {
ip=$(dexec dig +short pypi.org | head -1)
# Any built-in domain that resolves to a CDN address will do; this one is
# here because it is built in, so keep it on a name from fixed-domains.sh
# rather than one a project happens to have approved.
ip=$(dexec dig +short registry.npmjs.org | head -1)
[ -n "$ip" ]
dexec ipset del allowed-domains "$ip"
run dexec ipset test allowed-domains "$ip"
@@ -605,7 +801,7 @@ dexec_node() {
# forward triggers dnsmasq's --ipset= tracking (see dns-proxy.sh).
dexec bash -c 'kill -HUP "$(cat /var/run/dnsmasq-sandbox.pid)"'
sleep 0.5
dexec dig +short pypi.org >/dev/null
dexec dig +short registry.npmjs.org >/dev/null
sleep 0.5
run dexec ipset test allowed-domains "$ip"
[ "$status" -eq 0 ]
@@ -902,6 +1098,18 @@ EOF
local store; store="$(mktemp -d)"
local df; df="$(mktemp)"
printf 'apt:cowsay\n' > "$store/dependencies"
# The positive control, and it is load-bearing: this test proves the
# derived layer installed cowsay by finding cowsay installed. If the base
# image already carried it, the assertion below would pass with
# generate_derived_dockerfile doing nothing at all. Checked here rather
# than assumed, because "the base image gained a package" is a change
# nobody would connect to this file.
run docker run --rm --entrypoint dpkg-query "$IMAGE" -W -f='${Status}' cowsay
[[ "$output" != *"install ok installed"* ]] || {
echo "cowsay is in the base image, so this test cannot detect a broken derived build" >&2
false
}
bash -c 'source "$1"; generate_derived_dockerfile "$2" "$3"' \
_ "$BATS_TEST_DIRNAME/../bin/dejima-project-lib.sh" "$IMAGE" "$store" > "$df"
docker build -q -t "$derived" -f "$df" "$ctx" >&2
@@ -1191,3 +1399,78 @@ EOF
[[ "$logs" == *"'tls'"* ]] || dump "the refusal did not name the valid values"
[[ "$logs" == *"'loopback'"* ]] || dump "the refusal did not name the valid values"
}
# The three-symptom failure entrypoint.sh's marker-directory check exists to
# name: notifications stop, the session vanishes from dejima-attach, and the
# notify hook reports EACCES - with no obvious moment they started.
#
# It is Docker's answer to a missing bind source that produces it. bin/dejima
# creates the marker directory per launch and removes it on exit, so a
# container that is RESTARTED rather than relaunched has no source left, and
# Docker creates one root-owned rather than refusing. The directory then
# exists, so the hook's own `[ -d ]` guard passes and the write fails instead.
@test "a marker directory the sandbox user cannot write is reported at startup" {
# Its own container, not container_name() - that one is created in
# setup_file and used by every dexec test in this file. Starting a second
# container under that name conflicts, and removing it at the end of this
# body would take the shared one with it.
local container="dejima-test-notifydir-$(basename "$BATS_FILE_TMPDIR")"
local host_dir; host_dir="$(mktemp -d)"
# Unwritable by the sandbox user, which is the property the check keys on.
# Built here rather than by removing a bind source, so the test does not
# depend on Docker's fallback behaviour to set up a test OF the
# consequences of that behaviour.
#
# chmod rather than mounting :ro. A read-only mount would make the
# "not repaired" assertion below vacuous - root could not chown it even
# if entrypoint.sh tried. Mode 555 leaves it repairable by root inside the
# container and still unwritable by the session user, so that assertion
# means something.
chmod 555 "$host_dir"
docker run -d --name "$container" \
--cap-add=NET_ADMIN --cap-add=NET_RAW \
--network "$(network_name)" \
-e SANDBOX_TEST_MODE=1 \
-e GH_TOKEN="${GH_TOKEN:-}" \
-e DEJIMA_NOTIFY_DIR=/run/dejima-notify \
-v "$host_dir:/run/dejima-notify" \
--entrypoint /usr/local/bin/entrypoint.sh \
"$IMAGE" >/dev/null
wait_ready "$container"
run settled_logs "$container"
[[ "$output" == *"not writable by"* ]]
[[ "$output" == *"RESTARTED rather than"* ]]
# Reported, not repaired: chowning would restore notifications while the
# session stayed absent from a registry this container never sees.
#
# Asserted as "still not writable by the session user" rather than "still
# owned by root": ownership depends on who ran the suite, since mktemp -d
# follows the caller, while writability is the property the check reads
# and the one repairing it would change.
run docker exec -u node "$container" test -w /run/dejima-notify
[ "$status" -ne 0 ]
rm -rf "$host_dir"
}
# Remote access is opt-in and off by default. The check above must say nothing
# at all for the sessions that never asked for it, which is every session by
# default - a warning about an unconfigured optional feature is noise on every
# start, and noise is what stops warnings being read.
@test "no marker-directory warning when remote access is not enabled" {
local container="dejima-test-nonotify-$(basename "$BATS_FILE_TMPDIR")"
docker run -d --name "$container" \
--cap-add=NET_ADMIN --cap-add=NET_RAW \
--network "$(network_name)" \
-e SANDBOX_TEST_MODE=1 \
-e GH_TOKEN="${GH_TOKEN:-}" \
--entrypoint /usr/local/bin/entrypoint.sh \
"$IMAGE" >/dev/null
wait_ready "$container"
run settled_logs "$container"
[[ "$output" != *"dejima-notify"* ]]
[[ "$output" != *"remote access is enabled"* ]]
}
+931 -41
View File
File diff suppressed because it is too large Load Diff
+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
+20 -6
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
@@ -44,5 +44,19 @@ Git workflow - do this even though nothing stops you from doing otherwise:
crash, a restart, or a destructive command it is warning about will get
the call blocked, which looks like a broken tool rather than what it is.
Writing the file first keeps the prose out of the command entirely.
`{{issue_command}}` (list/view/create/comment/close/reopen) takes the same
flag, for recording a problem you found but aren't fixing.
`{{issue_command}}` (list/view/create/edit/comment/close/reopen) takes the
same flag, for recording a problem you found but aren't fixing.
- Do not hard-wrap prose in those bodies. Write each paragraph as one long
line and separate paragraphs with a blank line. The forge renders a single
newline as a line break, so text you wrapped at a column arrives with a
break every ten words: it wraps early on a desktop, and on a phone the
screen breaks the line first and your newline breaks it again a few words
later, which is much harder to read than either alone. Keep the breaks
that carry structure - list items, table rows, headings, and anything
inside a code fence - and drop the ones that were only there to keep the
source tidy. The tools send the body through exactly as written; nothing
reflows it for you.
- To revise a body after the fact, use `{{pr_command}} edit <N> --body-file`
(or `{{issue_command}} edit <N>`). Rewriting the file you built the body
from changes nothing on its own - the forge has its own copy from the
moment you created it, so an edit has to be sent.