Compare commits

...
41 Commits
Author SHA1 Message Date
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
27 changed files with 2575 additions and 360 deletions
+11
View File
@@ -13,6 +13,17 @@ socket, by design. Everything else runs anywhere, which is what the CI job names
mean: `sandbox` for the suites that need only a shell, `host-docker` for the one
that does not.
## `bats` is a declared dependency here, not part of the image
It is in `.dejima/dependencies`, like any other project's test runner, rather
than baked into the base image for every project that never runs bats. So on a
sandbox whose image predates that approval, `bats` is simply absent — the fix
is the ordinary one the briefing describes (`dejima-project approve`, then
relaunch so the derived image is rebuilt), not installing it in-session.
If it is missing and you cannot get a rebuild, say the suites were not run
rather than reporting a change as tested.
## Run the suite for what you changed, not all of them
The suites are independent and cover different areas. Running all of them for
+6
View File
@@ -1 +1,7 @@
docs.gitea.com
deb.debian.org
download.docker.com
github.com
api.github.com
objects.githubusercontent.com
release-assets.githubusercontent.com
+1
View File
@@ -1 +1,2 @@
apt:tmux
apt:bats
+304 -13
View File
@@ -17,8 +17,34 @@
# covering every unrelated area at once.
name: tests
# pull_request_target, not pull_request, and the difference is the whole
# security posture of this file.
#
# Under `pull_request` the workflow is read from the HEAD of the pull request -
# so the branch under test decides what runs and, critically, `runs-on:`. A
# branch can point its jobs at any runner its repository can reach, which
# means every control the runner provides (no Docker socket, deny-by-default
# egress) is opt-out by whoever wrote the branch.
#
# Under `pull_request_target` Gitea reads the workflow from the BASE branch,
# and explicitly discards any pull_request_target workflow found on the head
# commit - so a branch can neither edit this file's copy nor introduce its
# own. What runs here, and where, is whatever `main` says.
#
# The cost is that the checkout no longer defaults to the code under test: the
# run's ref is the base branch. Every job that means to test the branch has to
# say so, which is why the checkouts below carry an explicit ref. A bare
# checkout here would test `main` on every pull request and pass regardless of
# the change - green checks that mean nothing.
#
# This does NOT make the repository safe on its own. A branch can still add a
# workflow of its own with `on: pull_request`, and that runs from the head
# with whatever `runs-on:` it likes. The only thing stopping that is which
# runners the repository can reach at all - see docs/ci.md, "A label is not a
# boundary". What this buys is that the intended CI is trustworthy, and that
# escaping it requires adding a file rather than editing one.
on:
pull_request:
pull_request_target:
# No `concurrency:` block. Gitea does not implement one - the word appears
# nowhere in its actions models or services as of 1.25 - so writing it here
@@ -29,7 +55,21 @@ on:
jobs:
sandbox:
runs-on: ubuntu-latest
# A label carried only by a runner configured for this - see ci/ and
# docs/ci.md. Not a generic one, even though these suites genuinely do run
# anywhere a shell does, because "runs anywhere" is a statement about what
# the job needs and not about what it should be handed.
#
# Whether a job container gets the Docker socket is a property of the
# runner's config.yaml (container.docker_host), not of anything this file
# can ask for or refuse. So a job that needs nothing still receives
# whatever its runner grants everything it runs, and naming a specific
# label is the only way this file gets a say in which runner that is.
#
# If no runner carries this label, these jobs queue. That is the intended
# failure: it is a better one than a green run on a host that grants
# everything.
runs-on: dejima-ci
strategy:
# One suite per job. fail-fast off because these cover unrelated areas -
# cancelling the other six on the first failure would hide whether a
@@ -51,11 +91,65 @@ jobs:
# needs to run at all, and apt-get is what everything below needs.
image: node:20-bookworm
steps:
# Before apt, because apt's failure for this says "Unable to locate
# package bats" and nothing about why. On a runner behind the egress
# allowlist a name that does not resolve is the resolver or the
# allowlist, not the package - and ci-runner.bats, which would say so
# precisely, cannot run yet because installing it is what just failed.
#
# Uses only what the image already has: getent is libc, and the
# resolver's address is in resolv.conf.
# Before apt, because apt's failure for this says "Unable to locate
# package bats" and nothing about why. On a runner behind the egress
# allowlist a name that does not resolve is the resolver or the
# allowlist, not the package - and ci-runner.bats, which would say so
# precisely, cannot run yet because installing it is what just failed.
#
# Retried rather than decided on one attempt: a resolver can be a moment
# behind a container's start, and apt itself retries, so failing a whole
# job on a single lookup would be stricter than the step it is
# protecting. Six attempts over ~15s, then a diagnosis.
#
# Uses only what the image already has: getent is libc.
- name: Check the job network can resolve before installing anything
run: |
for attempt in 1 2 3 4 5 6; do
if getent hosts deb.debian.org >/dev/null 2>&1; then
[ "$attempt" -eq 1 ] || echo "resolved on attempt $attempt"
exit 0
fi
sleep 3
done
echo "deb.debian.org did not resolve after 6 attempts, so this job" >&2
echo "cannot install anything." >&2
echo "" >&2
echo "This job's resolver:" >&2
sed -n 's/^nameserver/ nameserver/p' /etc/resolv.conf >&2
echo "" >&2
echo "If other jobs in this same run resolved fine, the runners are" >&2
echo "configured differently: check that EVERY runner answering this" >&2
echo "label has --dns=<gateway> in container.options and the same" >&2
echo "container.network. A runner missing it fails only the jobs that" >&2
echo "happen to land on it, which looks intermittent." >&2
echo "" >&2
echo "If every job failed, check on the runner host, in order:" >&2
echo " dejima-ci-allowlist show - a server= line for this name?" >&2
echo " systemctl status dnsmasq - running?" >&2
echo " iptables -S DEJIMA-CI-IN - port 53 allowed from the bridge?" >&2
echo " journalctl -u dnsmasq -n 50 - does the query arrive, and is it" >&2
echo " forwarded without a reply?" >&2
exit 1
- name: Install test dependencies
run: |
apt-get update
apt-get install -y --no-install-recommends bats jq python3 tmux ca-certificates
# The code under test, named explicitly - see the header. Without `ref`
# this checks out the base branch and the suite passes without ever
# seeing the change.
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
# As an ordinary user, not root. One test leaves a directory mode 500 and
# asserts that the CLI's rm -rf refuses it and says so; root walks
# through that on CAP_DAC_OVERRIDE and the test fails for a reason that
@@ -72,18 +166,20 @@ jobs:
su node -c "TERM=xterm-256color bats test/${{ matrix.suite }}.bats"
host-docker:
# Not ubuntu-latest, unlike the job above. This one only works on a runner
# configured for it - host network namespace, the Docker socket, and the
# shared volume below - and none of that is anything the workflow can ask
# for. On a generic runner it fails the preflight check outright.
# A different label from the job above, and the only one in this file that
# asks for anything privileged. This job needs the host network namespace,
# the Docker socket and the shared volume below, none of which a workflow
# can request - they are granted by the runner or not at all, so on a
# runner without them it fails the preflight check outright.
#
# ubuntu-latest is a label every runner carries, so which one picked this
# up was a coin flip, and a re-run that happened to land on an unconfigured
# runner failed on a repo that had not changed. A label only the configured
# runner has makes that unrepresentable rather than merely diagnosable: no
# runner matching it means this job queues instead of running somewhere it
# cannot work. The sandbox job stays on ubuntu-latest, because it genuinely
# does run anywhere.
# A label carried by exactly one configured runner makes landing somewhere
# unusable unrepresentable rather than merely diagnosable: no runner
# matching it means this queues instead of running somewhere it cannot
# work. A generic label would make which runner picked it up a coin flip.
#
# The runner behind this label can be taken by anything it runs (see
# ci/act-runner-runtime.example.yaml), so it belongs on a host that exists
# for that and holds nothing else.
runs-on: dejima-runtime
container:
image: node:20-bookworm
@@ -117,6 +213,55 @@ jobs:
# nothing to do with what the test asserts. That test is the containment
# argument for the ttyd front door, so it is not one to skip in CI.
steps:
# Before apt, because apt's failure for this says "Unable to locate
# package bats" and nothing about why. On a runner behind the egress
# allowlist a name that does not resolve is the resolver or the
# allowlist, not the package - and ci-runner.bats, which would say so
# precisely, cannot run yet because installing it is what just failed.
#
# Uses only what the image already has: getent is libc, and the
# resolver's address is in resolv.conf.
# Before apt, because apt's failure for this says "Unable to locate
# package bats" and nothing about why. On a runner behind the egress
# allowlist a name that does not resolve is the resolver or the
# allowlist, not the package - and ci-runner.bats, which would say so
# precisely, cannot run yet because installing it is what just failed.
#
# Retried rather than decided on one attempt: a resolver can be a moment
# behind a container's start, and apt itself retries, so failing a whole
# job on a single lookup would be stricter than the step it is
# protecting. Six attempts over ~15s, then a diagnosis.
#
# Uses only what the image already has: getent is libc.
- name: Check the job network can resolve before installing anything
run: |
for attempt in 1 2 3 4 5 6; do
if getent hosts deb.debian.org >/dev/null 2>&1; then
[ "$attempt" -eq 1 ] || echo "resolved on attempt $attempt"
exit 0
fi
sleep 3
done
echo "deb.debian.org did not resolve after 6 attempts, so this job" >&2
echo "cannot install anything." >&2
echo "" >&2
echo "This job's resolver:" >&2
sed -n 's/^nameserver/ nameserver/p' /etc/resolv.conf >&2
echo "" >&2
echo "If other jobs in this same run resolved fine, the runners are" >&2
echo "configured differently: check that EVERY runner answering this" >&2
echo "label has --dns=<gateway> in container.options and the same" >&2
echo "container.network. A runner missing it fails only the jobs that" >&2
echo "happen to land on it, which looks intermittent." >&2
echo "" >&2
echo "If every job failed, check on the runner host, in order:" >&2
echo " dejima-ci-allowlist show - a server= line for this name?" >&2
echo " systemctl status dnsmasq - running?" >&2
echo " iptables -S DEJIMA-CI-IN - port 53 allowed from the bridge?" >&2
echo " journalctl -u dnsmasq -n 50 - does the query arrive, and is it" >&2
echo " forwarded without a reply?" >&2
exit 1
- name: Install test dependencies
run: |
apt-get update
@@ -239,6 +384,8 @@ jobs:
echo "GitHub ranges lookup is authenticated - ceiling is $limit requests/hour."
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
# The build dominates this job, and inside setup_file it is a `docker
# build -q` that prints nothing at all - so tens of minutes of ordinary
@@ -291,3 +438,147 @@ jobs:
# own BATS_*_TMPDIR on paths that mean the same thing on both sides
# of the socket, which is all any of those mounts need.
TMPDIR: /tmp/dejima-ci
# The runner's own configuration, asserted from inside a job on it. Separate
# jobs rather than steps on the two above, because the point is that these
# are two different runners and either can be wrong on its own - one red
# check per tier says which.
#
# These fail on how the RUNNER is configured, not on anything in a pull
# request. A red check here means the machine needs attention; it does not
# mean the branch does.
runner-env-sandbox:
runs-on: dejima-ci
name: runner-env (sandbox)
container:
image: node:20-bookworm
steps:
# Before apt, because apt's failure for this says "Unable to locate
# package bats" and nothing about why. On a runner behind the egress
# allowlist a name that does not resolve is the resolver or the
# allowlist, not the package - and ci-runner.bats, which would say so
# precisely, cannot run yet because installing it is what just failed.
#
# Uses only what the image already has: getent is libc, and the
# resolver's address is in resolv.conf.
# Before apt, because apt's failure for this says "Unable to locate
# package bats" and nothing about why. On a runner behind the egress
# allowlist a name that does not resolve is the resolver or the
# allowlist, not the package - and ci-runner.bats, which would say so
# precisely, cannot run yet because installing it is what just failed.
#
# Retried rather than decided on one attempt: a resolver can be a moment
# behind a container's start, and apt itself retries, so failing a whole
# job on a single lookup would be stricter than the step it is
# protecting. Six attempts over ~15s, then a diagnosis.
#
# Uses only what the image already has: getent is libc.
- name: Check the job network can resolve before installing anything
run: |
for attempt in 1 2 3 4 5 6; do
if getent hosts deb.debian.org >/dev/null 2>&1; then
[ "$attempt" -eq 1 ] || echo "resolved on attempt $attempt"
exit 0
fi
sleep 3
done
echo "deb.debian.org did not resolve after 6 attempts, so this job" >&2
echo "cannot install anything." >&2
echo "" >&2
echo "This job's resolver:" >&2
sed -n 's/^nameserver/ nameserver/p' /etc/resolv.conf >&2
echo "" >&2
echo "If other jobs in this same run resolved fine, the runners are" >&2
echo "configured differently: check that EVERY runner answering this" >&2
echo "label has --dns=<gateway> in container.options and the same" >&2
echo "container.network. A runner missing it fails only the jobs that" >&2
echo "happen to land on it, which looks intermittent." >&2
echo "" >&2
echo "If every job failed, check on the runner host, in order:" >&2
echo " dejima-ci-allowlist show - a server= line for this name?" >&2
echo " systemctl status dnsmasq - running?" >&2
echo " iptables -S DEJIMA-CI-IN - port 53 allowed from the bridge?" >&2
echo " journalctl -u dnsmasq -n 50 - does the query arrive, and is it" >&2
echo " forwarded without a reply?" >&2
exit 1
- name: Install test dependencies
run: |
apt-get update
apt-get install -y --no-install-recommends bats python3 ca-certificates
# No `ref:` here, deliberately - unlike the suites above. These assert on
# the RUNNER, not on the change, so the assertions should come from the
# base branch rather than from the branch they are meant to hold for.
# Under pull_request_target that is what a bare checkout gives, which
# means a branch can neither weaken these tests nor delete them.
- uses: actions/checkout@v4
- run: bats test/ci-runner.bats
env:
# Declared, not detected: landing on the wrong runner should fail
# rather than quietly select a different set of assertions.
DEJIMA_CI_TIER: sandbox
runner-env-host-docker:
runs-on: dejima-runtime
name: runner-env (host-docker)
container:
image: node:20-bookworm
steps:
# Before apt, because apt's failure for this says "Unable to locate
# package bats" and nothing about why. On a runner behind the egress
# allowlist a name that does not resolve is the resolver or the
# allowlist, not the package - and ci-runner.bats, which would say so
# precisely, cannot run yet because installing it is what just failed.
#
# Uses only what the image already has: getent is libc, and the
# resolver's address is in resolv.conf.
# Before apt, because apt's failure for this says "Unable to locate
# package bats" and nothing about why. On a runner behind the egress
# allowlist a name that does not resolve is the resolver or the
# allowlist, not the package - and ci-runner.bats, which would say so
# precisely, cannot run yet because installing it is what just failed.
#
# Retried rather than decided on one attempt: a resolver can be a moment
# behind a container's start, and apt itself retries, so failing a whole
# job on a single lookup would be stricter than the step it is
# protecting. Six attempts over ~15s, then a diagnosis.
#
# Uses only what the image already has: getent is libc.
- name: Check the job network can resolve before installing anything
run: |
for attempt in 1 2 3 4 5 6; do
if getent hosts deb.debian.org >/dev/null 2>&1; then
[ "$attempt" -eq 1 ] || echo "resolved on attempt $attempt"
exit 0
fi
sleep 3
done
echo "deb.debian.org did not resolve after 6 attempts, so this job" >&2
echo "cannot install anything." >&2
echo "" >&2
echo "This job's resolver:" >&2
sed -n 's/^nameserver/ nameserver/p' /etc/resolv.conf >&2
echo "" >&2
echo "If other jobs in this same run resolved fine, the runners are" >&2
echo "configured differently: check that EVERY runner answering this" >&2
echo "label has --dns=<gateway> in container.options and the same" >&2
echo "container.network. A runner missing it fails only the jobs that" >&2
echo "happen to land on it, which looks intermittent." >&2
echo "" >&2
echo "If every job failed, check on the runner host, in order:" >&2
echo " dejima-ci-allowlist show - a server= line for this name?" >&2
echo " systemctl status dnsmasq - running?" >&2
echo " iptables -S DEJIMA-CI-IN - port 53 allowed from the bridge?" >&2
echo " journalctl -u dnsmasq -n 50 - does the query arrive, and is it" >&2
echo " forwarded without a reply?" >&2
exit 1
- name: Install test dependencies
run: |
apt-get update
apt-get install -y --no-install-recommends bats python3 ca-certificates
# Base branch, same as the job above and for the same reason.
- uses: actions/checkout@v4
- run: bats test/ci-runner.bats
env:
DEJIMA_CI_TIER: host-docker
+75 -39
View File
@@ -23,6 +23,14 @@ Around that core is most of what running an agent unattended actually takes:
open pull requests and issues against Gitea or Forgejo and pull review
feedback back in, on a token scoped for exactly that. Merging stays a
human decision, made outside the sandbox.
- **The CI exposure, stated up front.** Push access is code execution on
whatever host your CI runs on: a session opens a pull request, the workflow
fires, and the runner executes that commit before you have read a line of
it. The container bounds what a session reaches directly — not what it can
arrange to have run elsewhere. That work happens on the runner:
[docs/ci.md](docs/ci.md) for what it takes, and
[dejima-ci-hardener](https://gitbox.branderson.io/branderson/dejima-ci-hardener)
for a tool that does it.
- **Logging.** Every DNS lookup and rejected connection, shipped to Loki —
and, opt-in, the session transcript alongside them.
- **Remote access, opt-in and off by default.** Attach to a live session
@@ -410,39 +418,20 @@ By default the container's entry point is bare `claude` (always running with
`docker/claude-wrapper.sh`), so a fresh clone of this repo works with no
editor setup at all.
Typical loop: point it at a project, ask Claude to implement something and
open a PR (`gitea-pr` is on `PATH`); from the host, `gitea-pr comments <PR#>`
to pull review feedback, feed it back in, repeat. Merging is yours — see
`merge-guard.sh` below. `gitea-issue` is on `PATH` too, for the work
that gets deferred rather than done: `list`, `view`, `create`, `comment`,
`close` and `reopen`, so a problem found mid-session becomes a ticket instead
of a footnote on an unrelated PR.
Typical loop: point it at a project, ask for a change, and let the session
work. It commits to a feature branch, pushes it, and opens a pull request —
then it stops. You review and merge that PR yourself, wherever you normally
read them. Merging is the one step the sandbox is not allowed to take, and
`merge-guard.sh` blocks it from the inside.
`gitea-actions` closes the other half of that loop. A sandbox cannot run the
tests that need a Docker daemon, so CI is where they run, and without a way
to read a run the only route from a red build back into the session is the
operator pasting the log in by hand. `gitea-actions runs`, `jobs <run>` and
`logs [run]` do that instead — with no run given, the newest one for `HEAD`,
and by default only the jobs that failed. It reads and never writes.
That boundary is the shape of the loop rather than a limit on it: every
session ends at something you read before it lands, and nothing reaches your
default branch without you.
Because the session stops at the PR and you merge afterwards, the sandbox
context tells Claude to put `Fixes #N` in the PR body rather than to close
issues by hand once they land — the merge is the last step in that sequence
that still happens, and it happens when the agent is gone. `Refs #N` is the
deliberate opposite, for work that touches a ticket without finishing it.
Both take `--body-file FILE` (or `-` for stdin) as well as `--body`. Prefer it
for anything with an apostrophe, a backtick or a code fence in it — a body
passed on a command line has to survive shell quoting first.
Inside the sandbox there's a second reason, and it argues for a real file over
a heredoc. `deny-dangerous-bash.sh` matches patterns anywhere in the Bash
command string, and it has no way to tell an instruction from prose that
merely mentions one — so a PR or issue body that discusses restarting a host,
or quotes a destructive command it is warning about, gets the whole call
blocked. Writing the body to a file first keeps that text out of the command
string entirely. A heredoc doesn't: the body is still part of the command the
hook reads, so it solves the quoting problem but not this one.
How far the session goes is the `[core] workflow` setting — `worktree-pr`
pushes and opens the PR described above, `branch-push` stops at the push, and
`local-only` commits and pushes nowhere. See
[Provider axes](#provider-axes-forge-workflow-harness).
### Using a different editor/application
@@ -812,9 +801,45 @@ A project can declare what it needs in two committed files:
```
.dejima/allowed-domains # one domain per line
.dejima/dependencies # <apt|npm|pip|cargo>:<name> per line
.dejima/dependencies # <apt|npm|pip|cargo>:<name> per line, or a
# tarball:<name>@<sha256>:<url> release archive
```
### Tarball dependencies
For a toolchain no package ecosystem ships in a version you can use — a
current Go, say, where Debian's is several releases behind — a dependency can
name a release archive instead:
```sh
dejima-dep add --global tarball:go@<sha256>:https://go.dev/dl/go1.25.0.linux-amd64.tar.gz
```
It unpacks with `--strip-components=1` into `/opt/dejima/<name>`, and
`/opt/dejima/<name>/bin` goes on `PATH`. That one convention covers the usual
shapes without the spec needing layout options: a Go archive has everything
under `go/`, an nvim release under `nvim-linux-<arch>/`, and stripping one
level puts `bin/` in the same place for both. Compression is detected from the
archive, so `.tar.gz`, `.tar.xz` and `.tar.zst` all work.
**The digest is mandatory**, and it is checked before anything is unpacked.
Without one you would be approving a *name* whose contents the far end can
change afterwards, which isn't a decision anyone can make once. With one, what
you approved is a specific archive and nothing else can take its place. Two
consequences worth knowing:
- **A tarball dependency names one architecture**, because a digest does.
Unlike a package name, it can't resolve per-host, so a mixed-architecture
fleet needs a spec per project rather than one global one.
- **Upgrading is an approval, not a version bump.** A new release is a new
digest, so it goes through `dejima-dep add` and `dejima-project approve`
again — which is the point, not friction to route around.
The URL's character set is deliberately narrower than URLs allow: the spec is
interpolated into a `RUN` line that runs as root at build time, so anything
shell-active is refused outright rather than quoted around, and only `https`
is accepted.
**Declaring grants nothing.** The project directory is bind-mounted
read-write, so a compromised session could write these files - they are read
only to compare against reality and suggest a command. Approvals live
@@ -1049,14 +1074,24 @@ already run, and it does not take a modified workflow file to get there: a
workflow that runs a test suite runs every file in it.
The sandbox bounds what a session can reach directly. It does not bound what
it can arrange to have run elsewhere. Read this one before you point a
sandbox at a project whose CI you administer - and if you would rather not
take it on, `[core] forge = none` with `[core] workflow = local-only` declines
the push grant and the code execution with it.
it can arrange to have run elsewhere.
**→ [docs/ci.md](docs/ci.md)** - why no approval gate is offered, hardening a
runner, and making CI's egress allowlist a version-controlled artifact read
from `main`.
**This holds for projects with no CI, too** - a branch can add the first
workflow, so what matters is which runners the repository can *reach*, not
what it runs today. That is a property of the forge rather than the project,
and it is worth one question per project before you start. On hosted runners
the answer is reassuring; with self-hosted runners in reach it is the thing to
look at. And if you would rather not take it on at all,
`[core] forge = none` with `[core] workflow = local-only` declines the push
grant and the code execution with it.
**→ [docs/ci.md](docs/ci.md)** - the *why*: no approval gate is offered and
what that leaves, hardening a runner, and making CI's egress allowlist a
version-controlled artifact read from `main`.
**→ [dejima-ci-hardener](https://gitbox.branderson.io/branderson/dejima-ci-hardener)** - the *how*: a self-contained tool that
hardens a CI runner, installed on the CI box and nowhere near a dejima host.
Nothing in this repository needs to be copied onto a runner.
## Security notes
@@ -1155,6 +1190,7 @@ likely to collide with a real container you have running.
| `systemd/*.service` | User units for `dejima-ttyd` and `dejima-notify-watch`; ship disabled, installed by hand |
| `docs/providers.md` | The manifest contract for the forge/workflow/harness axes, key by key, with the shipped manifests as worked examples |
| `docs/ci.md` | What push access grants a session on your CI host, and how to bound it - runner hardening, and an egress allowlist read from `main` |
| `ci/` | Worked runner, resolver and firewall examples for the setup `docs/ci.md` describes; installed by nothing, and belongs on a CI machine rather than a dejima one |
| `docs/nginx-dejima.conf.example` | Reverse-proxy template for the web front door; deliberately not loadable as written |
`bin/dejima` is the entry point — it resolves the project path,
+61 -10
View File
@@ -21,6 +21,11 @@ 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"
# 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
@@ -1095,6 +1100,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.
@@ -1254,16 +1278,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
+12 -2
View File
@@ -20,6 +20,12 @@ set -euo pipefail
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"
usage() {
echo "Usage: dejima-allow [--global] [--project [path]] [--container <id>] <domain>" >&2
@@ -133,9 +139,13 @@ if [ "$session_wanted" -eq 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)."
+230 -44
View File
@@ -10,6 +10,11 @@ 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"
# 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:-}"
@@ -562,6 +567,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
@@ -915,16 +942,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 +987,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 +1134,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:$') )
@@ -1259,12 +1317,15 @@ 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
@@ -1278,13 +1339,13 @@ case "$cmd" in
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."
@@ -1294,12 +1355,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
@@ -1317,6 +1380,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")" \
@@ -1339,6 +1403,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
@@ -1399,22 +1473,51 @@ case "$cmd" in
# 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=""
#
# $lbl and $surface are for DISPLAY only: the fixed label this
# arm matched, and the workspace-named thing inside the body that
# the finding is about. Splitting them is what lets the surface
# be coloured without colouring the audit's own words around it,
# and they are recomputed here 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
@@ -1424,29 +1527,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."
@@ -1476,12 +1634,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
@@ -1490,12 +1658,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
}
+4 -2
View File
@@ -11,7 +11,9 @@
# 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
@@ -85,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"
+84 -5
View File
@@ -38,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>
@@ -414,6 +456,40 @@ unapprove_entry() {
mv "$tmp" "$store/$kind"
}
# One RUN per tarball dependency, as root, before the drop to the sandbox
# user. Each unpacks into its own directory under /opt/dejima and puts that
# directory's bin/ on PATH.
#
# --strip-components=1 is what makes a single convention cover the real cases:
# a Go archive has everything under go/, an nvim release under
# nvim-linux-<arch>/, and stripping one level puts bin/ in the same place for
# both. So the spec needs no destination or layout fields, and there is
# nothing per-dependency for an operator to get wrong.
#
# The digest is checked BEFORE unpacking. A tar that is extracted first and
# verified after has already written whatever it contained.
#
# -xaf rather than -xzf: compression is detected from the archive, so xz and
# zstd releases work without the spec having to say which it is.
_emit_tarball_layers() {
local spec rest name sha url
for spec in "$@"; do
[ -n "$spec" ] || continue
rest="${spec#tarball:}"
name="${rest%%@*}"
rest="${rest#"$name"@}"
sha="${rest%%:*}"
url="${rest#"$sha":}"
printf 'RUN mkdir -p /opt/dejima/%s && \\\n' "$name"
printf ' wget --tries=3 --timeout=30 --waitretry=5 --retry-connrefused \\\n'
printf ' -O /tmp/%s.tar "%s" && \\\n' "$name" "$url"
printf ' echo "%s /tmp/%s.tar" | sha256sum -c - && \\\n' "$sha" "$name"
printf ' tar -C /opt/dejima/%s --strip-components=1 -xaf /tmp/%s.tar && \\\n' "$name" "$name"
printf ' rm /tmp/%s.tar\n' "$name"
printf 'ENV PATH=$PATH:/opt/dejima/%s/bin\n' "$name"
done
}
# Prints a Dockerfile that layers approved packages onto the shared base
# image. Only validated tokens are interpolated (approved_entries refuses
# anything else), so a RUN line here can't become a root-context injection.
@@ -424,6 +500,7 @@ unapprove_entry() {
# init-firewall.sh locks egress down.
generate_derived_dockerfile() {
local base="$1" store="$2" entries apt npm pip cargo spec name
local -a tarballs=()
entries="$(approved_entries "$store" dependencies)" \
|| { echo "refusing to build from a corrupt dependency list in $store" >&2; return 1; }
@@ -432,10 +509,11 @@ generate_derived_dockerfile() {
[ -n "$spec" ] || continue
name="${spec#*:}"
case "$spec" in
apt:*) apt="$apt $name" ;;
npm:*) npm="$npm $name" ;;
pip:*) pip="$pip $name" ;;
cargo:*) cargo="$cargo $name" ;;
apt:*) apt="$apt $name" ;;
npm:*) npm="$npm $name" ;;
pip:*) pip="$pip $name" ;;
cargo:*) cargo="$cargo $name" ;;
tarball:*) tarballs+=("$spec") ;;
esac
done <<< "$entries"
@@ -443,6 +521,7 @@ generate_derived_dockerfile() {
printf 'USER root\n'
[ -n "$apt" ] && printf 'RUN apt-get update && apt-get install -y --no-install-recommends%s \\\n && apt-get clean && rm -rf /var/lib/apt/lists/*\n' "$apt"
[ -n "$pip" ] && printf 'RUN pip install --break-system-packages%s\n' "$pip"
_emit_tarball_layers "${tarballs[@]+"${tarballs[@]}"}"
printf 'USER ${SANDBOX_USER}\n'
[ -n "$npm" ] && printf 'RUN npm install -g%s\n' "$npm"
if [ -n "$cargo" ]; then
+17 -43
View File
@@ -2,7 +2,14 @@
# container (https://github.com/anthropics/claude-code/tree/main/.devcontainer),
# substantially extended and reworked for this project's own sandboxing model
# (parametrized SANDBOX_USER, DNS proxy + ulogd2 logging, gosu-based privilege
# drop, nvim/Go/Playwright/tree-sitter toolchain, gitea-pr helper, etc).
# drop, nvim/Playwright/tree-sitter toolchain, gitea-pr helper, etc).
#
# What belongs in here is what the sandbox itself needs, plus the editor and
# browser tooling that has no route through the dependency mechanism. Language
# toolchains and per-project utilities do have one - `dejima-dep`, including
# tarball: for a release archive - so they are declared by the projects that
# want them rather than carried by every sandbox on the host. Anything added
# below is added for every project, forever, with no review anywhere.
FROM node:22
ARG TZ
@@ -30,7 +37,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
procps \
fzf \
zsh \
man-db \
unzip \
gnupg2 \
gh \
@@ -45,28 +51,15 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
ulogd2 \
ulogd2-json \
nano \
vim \
ripgrep \
fd-find \
build-essential \
python3 \
python3-pip \
python3-venv \
xz-utils \
bats \
cowsay \
fortune-mod \
fortunes-min \
&& apt-get clean && rm -rf /var/lib/apt/lists/* \
&& ln -s "$(command -v fdfind)" /usr/local/bin/fd
# cowsay/cowthink/fortune install to /usr/games on Debian, which isn't on
# PATH by default - nvim start-screen plugins commonly shell out to them by
# bare name for a dashboard header. Set via ENV (not a profile.d script) so it's
# picked up by every process unconditionally, including nvim's non-login
# subprocess spawns, which wouldn't source /etc/profile.d.
ENV PATH=/usr/games:$PATH
# Set `DEVCONTAINER` environment variable to help with orientation
ENV DEVCONTAINER=true
@@ -97,13 +90,6 @@ RUN useradd -r -s /usr/sbin/nologin ulogd
# of re-running all of it. That single ordering was most of the cost of the
# override rebuild: it was 1169s of a 1379s test run, for a difference that
# amounts to one useradd.
ARG GIT_DELTA_VERSION=0.18.2
RUN ARCH=$(dpkg --print-architecture) && \
wget --tries=3 --timeout=30 --waitretry=5 --retry-connrefused \
"https://github.com/dandavison/delta/releases/download/${GIT_DELTA_VERSION}/git-delta_${GIT_DELTA_VERSION}_${ARCH}.deb" && \
dpkg -i "git-delta_${GIT_DELTA_VERSION}_${ARCH}.deb" && \
rm "git-delta_${GIT_DELTA_VERSION}_${ARCH}.deb"
# gosu lets entrypoint.sh (root) drop to the node user for the actual
# session after setting up the firewall, without leaving any setuid/sudo
# escalation path available to node afterward. Verified against the
@@ -130,27 +116,15 @@ RUN asset_url=$(curl -fsSL --retry 3 --retry-delay 2 --retry-all-errors --max-ti
tar -C /usr/local --strip-components=1 -xzf nvim.tar.gz && \
rm nvim.tar.gz
# Install the latest stable Go toolchain. go.dev's own release JSON gives
# both the download URL and a SHA256 per file, verified below since this
# runs as root - same pattern as gosu above. `go build`/`go mod download`
# need proxy.golang.org and sum.golang.org reachable at runtime, and those are
# NOT built into the allowlist - a Go project approves them itself, one
# `dejima-allow --global <domain>` each, or a .dejima/allowed-domains line.
# Shipping the toolchain is not the same decision as granting every sandbox on
# the host reach to its module proxy.
RUN ARCH=$(dpkg --print-architecture) && \
release_json=$(curl -fsSL --retry 3 --retry-delay 2 --retry-all-errors --max-time 60 \
'https://go.dev/dl/?mode=json') && \
filename=$(echo "$release_json" | jq -r --arg arch "$ARCH" \
'[.[] | select(.stable == true)][0].files[] | select(.os == "linux" and .arch == $arch and .kind == "archive") | .filename') && \
sha256=$(echo "$release_json" | jq -r --arg arch "$ARCH" \
'[.[] | select(.stable == true)][0].files[] | select(.os == "linux" and .arch == $arch and .kind == "archive") | .sha256') && \
wget --tries=3 --timeout=30 --waitretry=5 --retry-connrefused \
-O go.tar.gz "https://go.dev/dl/${filename}" && \
echo "${sha256} go.tar.gz" | sha256sum -c - && \
tar -C /usr/local -xzf go.tar.gz && \
rm go.tar.gz
ENV PATH=$PATH:/usr/local/go/bin
# No Go toolchain here. A Go project declares one:
#
# dejima-dep add --global tarball:go@<sha256>:https://go.dev/dl/go<ver>.linux-amd64.tar.gz
#
# and gets a pinned toolchain in the version it actually wants, rather than
# whatever this image was built with. Shipping a language runtime is not the
# same decision as every sandbox on the host carrying one - and the module
# proxy it needs (proxy.golang.org, sum.golang.org) is an approval too, for
# the same reason.
# System libraries Chromium needs (fonts, libnss, libatk, etc.) for
# Playwright, installed here since it needs apt/root - the actual
+35
View File
@@ -252,6 +252,41 @@ fi
[ -n "${ANTHROPIC_BASE_URL:-}" ] || unset ANTHROPIC_BASE_URL
[ -n "${ANTHROPIC_AUTH_TOKEN:-}" ] || unset ANTHROPIC_AUTH_TOKEN
# Remote access is opt-in and off by default, so this whole block is skipped
# for the sessions that never asked for it: DEJIMA_NOTIFY_DIR is set only by
# bin/dejima's remote_enabled branch. An unset value is the ordinary case and
# says nothing.
#
# Set-but-unusable is different, and it is worth a line at startup because of
# how it otherwise presents. The marker directory is a bind mount whose source
# bin/dejima creates per launch and removes on exit, so restarting the
# container - `docker start`, or a restart policy - rather than relaunching
# `dejima` leaves the source gone. Docker's answer to a missing bind source is
# not to refuse: it creates one, root-owned. The directory then exists, so the
# notify hook's own `[ -d ]` guard passes and every notification fails with
# EACCES instead; the session is also absent from `dejima-attach`, because its
# registry entry went with the same directory. Three unrelated-looking symptoms
# and no obvious moment they started.
#
# Reported, not repaired. Chowning this would restore notifications while the
# session stayed missing from the registry - which bin/dejima writes host-side
# and never mounts here - so a session would look fixed while half of the
# feature was still broken. Relaunching is the actual remedy, and it is one the
# message can name.
if [ -n "${DEJIMA_NOTIFY_DIR:-}" ]; then
if [ ! -d "$DEJIMA_NOTIFY_DIR" ]; then
echo "WARNING: remote access is enabled, but $DEJIMA_NOTIFY_DIR is not a directory." >&2
echo "Notifications will not be delivered for this session." >&2
elif ! gosu "$SANDBOX_USER" test -w "$DEJIMA_NOTIFY_DIR"; then
echo "WARNING: remote access is enabled, but $DEJIMA_NOTIFY_DIR is not writable by" >&2
echo "$SANDBOX_USER - so notifications will fail, and this session will not appear in" >&2
echo "dejima-attach. This is what a container that was RESTARTED rather than" >&2
echo "relaunched looks like: bin/dejima creates the marker directory per launch and" >&2
echo "removes it on exit, and Docker recreated the missing bind source as root." >&2
echo "Stop this container and start a new session with 'dejima' to restore both." >&2
fi
fi
# Test harness hook (test/): everything above this point is the real
# startup sequence - DNS proxy, firewall, IPv6 block, logging,
# privilege-drop prep. Skip execing $SANDBOX_CMD so the suite can `docker
+30
View File
@@ -46,3 +46,33 @@ GITHUB_DNS_SUFFIXES=(
"githubusercontent.com"
"githubassets.com"
)
# Which ipset an allowlisted name's addresses belong in.
#
# GitHub's are restricted to port 443 by init-firewall.sh, and that
# restriction is the read-only guarantee - there is no transport left for the
# forwarded ssh-agent's key to push with. allowed-domains carries no port
# restriction at all, so an address that reaches it is reachable on 22 as
# well, and a GitHub address that reaches it silently undoes the whole
# arrangement.
#
# dns-proxy.sh gets this right for free: dnsmasq applies the longest matching
# ipset= line, so GitHub names go to github-domains however they were
# allowlisted. Every other path has to ask, and two did not - the
# extra-domains loop in init-firewall.sh and the live add in bin/dejima-allow
# both added unconditionally to allowed-domains. A project with github.com in
# its .dejima/allowed-domains therefore opened port 22 to whichever address
# GitHub returned at container start, for as long as that answer stayed
# cached. It presented as an intermittent startup failure, because the
# port-22 self-check re-resolves the name and only sees the hole when the
# second answer matches the first.
ipset_for_domain() {
local domain="$1" suffix
for suffix in "${GITHUB_DNS_SUFFIXES[@]}"; do
if [ "$domain" = "$suffix" ] || [[ "$domain" == *".$suffix" ]]; then
printf 'github-domains'
return 0
fi
done
printf 'allowed-domains'
}
+74 -6
View File
@@ -76,10 +76,13 @@ else
echo "No Docker DNS rules were present to restore"
fi
# Allow localhost. DNS resolution goes entirely through Docker's embedded
# resolver at 127.0.0.11 (see resolv.conf), which stays on loopback, so this
# also covers DNS - no need for a separate "UDP/53 to anywhere" rule, which
# would just be an unrestricted-destination exfil channel.
# Allow localhost. Every DNS query this container makes starts here: to
# dnsmasq on 127.0.0.1, and from there to Docker's embedded resolver on
# 127.0.0.11 (see resolv.conf). What this rule does NOT cover is the hop
# after that, where the embedded resolver forwards an external name to the
# host's nameservers - see the owner-matched rule further down, which is
# there because that hop can leave this namespace. Still no "UDP/53 to
# anywhere" rule, which would be an unrestricted-destination exfil channel.
iptables -A INPUT -i lo -j ACCEPT
iptables -A OUTPUT -o lo -j ACCEPT
@@ -269,13 +272,17 @@ if [ -n "${EXTRA_ALLOWED_DOMAINS:-}" ]; then
continue
fi
# Not always allowed-domains: that set carries no port restriction,
# so a GitHub address landing in it is reachable on 22 as well and the
# read-only guarantee is gone. See ipset_for_domain.
target_set=$(ipset_for_domain "$domain")
while read -r ip; do
if [[ ! "$ip" =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]; then
echo "WARNING: Invalid IP from DNS for $domain: $ip, skipping"
continue
fi
echo "Adding $ip for $domain"
ipset add allowed-domains "$ip" 2>/dev/null || true
echo "Adding $ip for $domain (-> $target_set)"
ipset add "$target_set" "$ip" 2>/dev/null || true
done < <(echo "$ips")
done
fi
@@ -305,6 +312,40 @@ iptables -P OUTPUT DROP
iptables -A INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
iptables -A OUTPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
# Docker's embedded resolver does not answer external names itself - it
# forwards them to the host's nameservers, and on current Docker it does that
# from inside THIS network namespace. Those queries therefore traverse the
# chain below like any other packet, and nothing here can match them: the
# host's resolver is neither in an ipset nor the bridge gateway. They reach
# the catch-all REJECT, every lookup comes back SERVFAIL, and the container
# dies in a self-check that says only that a host was unreachable.
#
# The comment on the loopback rule above used to claim DNS "stays on
# loopback, so this also covers DNS". Only the first hop does. Two hosts with
# byte-identical /etc/resolv.conf behaved differently here, so this is not a
# property of any host's configuration and cannot be assumed either way.
#
# --uid-owner 0 is what keeps this from being the hole the DNS allowlist
# exists to close. The forwarding socket belongs to dockerd, which is root;
# the session runs as `node` and there is no sudo in the image, so a
# compromised session still cannot address a resolver directly and still has
# no path to a name dns-proxy.sh has not allowed. What this does newly permit
# is DNS from processes that are already root in a container holding
# NET_ADMIN - which could simply flush this ruleset instead, so it grants
# nothing that was not already available.
#
# Guarded rather than asserted: xt_owner is a module, and a kernel without it
# should leave a container that says why (the DNS self-check below), not one
# that aborts here with an iptables usage error.
if iptables -A OUTPUT -p udp --dport 53 -m owner --uid-owner 0 -j ACCEPT 2>/dev/null; then
iptables -A OUTPUT -p tcp --dport 53 -m owner --uid-owner 0 -j ACCEPT 2>/dev/null \
|| echo "WARNING: could not add the TCP/53 rule for root-owned sockets - large answers may fail" >&2
else
echo "WARNING: the iptables owner match is unavailable, so Docker's embedded" >&2
echo "resolver cannot forward out of this namespace. If DNS works anyway this" >&2
echo "host forwards elsewhere and nothing is wrong; the self-check below says which." >&2
fi
# Then allow only specific outbound traffic to allowed domains. This isn't
# port-restricted, so it also covers git-over-ssh (port 22) to the
# configured forge's hosts, which are already in the ipset above - no
@@ -340,6 +381,33 @@ iptables -A OUTPUT -j NFLOG --nflog-group 2 --nflog-prefix "reject"
iptables -A OUTPUT -j REJECT --reject-with icmp-admin-prohibited
echo "Firewall rules applied. Self-checking..."
# DNS before either connectivity check, because both of those resolve a name
# before they connect and neither can tell you that resolution is what failed.
# The negative control is the trap: with DNS dead it fails for the wrong
# reason and prints "example.com is unreachable, as it should be", so a
# container with no working DNS at all reports a passing check and then dies
# on the positive control naming a host rather than the cause. Reading that
# output backwards took an afternoon; it should take one line.
if ! dns_probe=$(dig +short +time=3 +tries=2 A api.github.com 2>&1) || [ -z "$dns_probe" ]; then
echo "ERROR: self-check failed - DNS does not resolve inside the sandbox." >&2
echo >&2
echo "The path is: this container -> dnsmasq on 127.0.0.1 (dns-proxy.sh) ->" >&2
echo "Docker's embedded resolver on 127.0.0.11 -> the host's nameservers." >&2
echo "Only the first two hops are on loopback. If Docker forwards the last" >&2
echo "one from inside this namespace, it traverses the OUTPUT chain, matches" >&2
echo "nothing, and is rejected - which reads as SERVFAIL, not as a firewall." >&2
echo >&2
echo " dig said: ${dns_probe:-(empty answer)}" >&2
echo " packets taken by the catch-all OUTPUT reject so far:" >&2
iptables -L OUTPUT -vn 2>/dev/null \
| awk '/icmp-admin-prohibited/ {print " " $1 " packets, " $2 " bytes"}' >&2
echo " dnsmasq's last words:" >&2
tail -5 /var/log/dnsmasq.log 2>/dev/null | sed 's/^/ /' >&2
exit 1
fi
echo "self-check ok - DNS resolves an allowlisted name"
# --max-time as well as --connect-timeout on both self-checks: a connect that
# succeeds and then stalls is not covered by --connect-timeout, and while these
# two sit inside `if` (so set -e cannot take the script down the way it could
+216 -63
View File
@@ -6,6 +6,10 @@
CI runs.** That is the whole of it, and everything below is either why it
cannot be gated or what to do about it instead.
> **This document is the why.** For the how, [dejima-ci-hardener](https://gitbox.branderson.io/branderson/dejima-ci-hardener)
> is a self-contained tool that does it — installed on the CI box, never on a
> dejima host, and it does not assume you have read this.
The chain needs no operator action at any step. A session writes code; the
push guard permits a feature branch, which is what it exists to allow; the
session opens a pull request, which its briefing tells it to do without asking
@@ -24,6 +28,15 @@ 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,
@@ -85,6 +98,20 @@ this the untrusted party can edit the thing that is supposed to constrain it.
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
@@ -98,10 +125,13 @@ that is what it does on every pull request.
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 scope**, not instance scope, so an
unrelated repository cannot schedule work onto a runner you hardened for
this one. Gitea's own FAQ describes gitea.com doing this for its public
runners.
- **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
@@ -141,20 +171,162 @@ 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. Jobs then cannot land anywhere else — and if no runner
carries the label yet, they queue rather than running somewhere that grants
everything, which is a better failure than a green run on the wrong host.
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.
On capabilities: `--cap-drop=ALL` is the reflex and it breaks `apt-get`, which
needs `CHOWN`, `DAC_OVERRIDE`, `FOWNER`, `SETUID` and `SETGID` to unpack a
package. Drop what the job demonstrably does not need instead of dropping
everything and discovering the list by failure, and pair whatever you drop
with `--security-opt=no-new-privileges`, which costs nothing and does not
depend on getting the list right.
### A label is not a boundary
That last paragraph describes routing, not containment, and the difference
matters enough to state on its own.
**`runs-on:` is in the workflow file, and the workflow file is in the branch.**
A branch can name any label it likes. So a dedicated label keeps *honest* jobs
on the runner you prepared; it does nothing against a branch that would rather
run somewhere else. A session that changes `runs-on: dejima-ci` to
`runs-on: ubuntu-latest`, pushes, and opens a pull request has stepped outside
every control on this page — no allowlist, no socket restriction, no firewall
— provided some runner answering `ubuntu-latest` will take the job.
Whether one will is a property of **where your runners are registered**, which
is the actual boundary:
| Registered at | Which repositories it serves |
| --- | --- |
| Instance | **every repository on the instance** |
| Organization | that organization's repositories |
| Repository | that repository |
Gitea has no per-repository runner ACL — its FAQ says so directly, and points
at registration scope as the control instead: gitea.com registers its runners
for one organization "so our runners will not execute jobs from other
repositories."
So the property to hold onto is this: **a repository's CI security posture is
that of the weakest runner it can reach, not the one its workflow names.** An
instance-level general-purpose runner is reachable from every repository on
that instance, including any that takes agent-authored branches.
### Pinning the workflow with `pull_request_target`
Half of this is fixable, and worth fixing: **`runs-on` does not have to come
from the branch.**
Under `on: pull_request` the workflow is read from the head of the pull
request, so the branch decides what runs and where. Under
`on: pull_request_target` Gitea reads it from the **base branch**, and
explicitly discards any `pull_request_target` workflow found on the head
commit — a branch can neither edit the base copy nor introduce its own. What
runs, and on which runner, is whatever `main` says.
**Adopting it takes two merges, not one.** The trigger only takes effect once
it is on the base branch, because that is where it is read from — and a
`pull_request_target` workflow on a pull request's head is explicitly ignored.
So the change that introduces it cannot be tested by it: with only
`pull_request_target` on the branch and only `pull_request` on `main`, the
head's copy is skipped, the base has nothing to contribute, and **no checks
run at all** — which looks like a broken workflow rather than a bootstrap
problem.
Carry both triggers through the first merge, then remove `pull_request` in a
follow-up. The first change is tested by its own copy from the head, as
today; the follow-up is tested by the copy now on `main`. In between, pull
requests get both an untrusted run from the head and a trusted one from the
base — noisy, and for the untrusted half no worse than the status quo, so
keep the window short.
Two more consequences to handle rather than discover:
- **The checkout no longer defaults to the code under test.** The run's ref is
the base branch, so a bare `actions/checkout` fetches `main` and the suite
passes without ever seeing the change. Jobs that test the branch must name
it: `ref: ${{ github.event.pull_request.head.sha }}`. Jobs that assert on
the *runner* should keep the bare checkout, so their assertions come from
`main` and a branch can neither weaken nor delete them.
- **The job runs with the base repository's token and secrets**, because the
forge treats it as trusted. On a forge where a same-repo `pull_request` run
already gets a permissive token this changes nothing, but it is why the
pattern carries warnings elsewhere: give such a run no secret you would not
hand the branch.
This does not make the repository safe on its own. A branch can still add a
workflow of its own with `on: pull_request`, which runs from the head with
whatever `runs-on` it likes. What it buys is that the *intended* CI is
trustworthy, and that escaping it means adding a file rather than editing
one — a much louder diff.
### Closing the rest: what a repository can reach
The rule underneath everything here:
> **Every runner reachable from a repository that receives agent-authored
> branches must be one you would accept an attacker running code on.**
Note what that does *not* say. It says nothing about what the repository's
workflows currently do, because a branch can add the first one. **A project
with no CI at all is still exposed**, on a forge where its branches can reach
a runner — and that is the case people do not expect, because there is nothing
in the repository to look at.
So before pointing a sandbox at a project, ask one question: *what runners can
this repository's branches reach?*
**If the answer is "hosted runners only"** — GitHub-hosted and nothing
self-hosted attached — this is the mildest case, and it is worth saying so
rather than alarming people. The runner is disposable and is not your machine,
so "execution on a host you own" largely evaporates: there is no LAN behind
it and nothing of yours on it. What remains is the third item, credentials:
give pull-request runs no secrets they do not need and a read-only token
(`permissions: contents: read`), and do not attach a self-hosted runner to
that repository.
**If self-hosted runners are reachable**, that is the sharp case, and there are
three ways to hold the rule — none of which require moving or renaming
anything.
**1. Turn CI off for that repository, if it does not use CI.** Disabling the
Actions unit means no workflow runs for that repository at all, so no runner is
reachable and a branch cannot add the first workflow. Changing it back needs
*admin* on the repository — Gitea's API puts repository settings behind
`reqAdmin()` — so a bot account with Write cannot undo it. For a project that
was not using CI anyway this costs nothing and closes the vector outright. It
is the first thing to reach for and the most commonly overlooked.
**2. Scope the dangerous runner down, rather than moving the repository out.**
The instinct is to move agent-developed repositories somewhere with only
hardened runners. For a public project that means breaking its name, its
links and its ownership, which nobody should be asked to do.
Invert it. A runner can belong to an **owner** — a user as well as an
organization — and then serves that owner's repositories; Gitea's runner model
names the three cases: global (`OwnerID` and `RepoID` both zero), org/user
level (`OwnerID` set), repository level (`RepoID` set). So:
- Make the *hardened* runner the owner-level default, so every repository gets
it.
- Register the *permissive* runner — the one with the Docker socket, or with
unrestricted egress — at **repository** scope, for only the repositories
that genuinely need it.
Most repositories need a runner that can run tests, not one that can build
images. Scoping the dangerous capability to the few that need it is usually a
handful of registrations, and nothing moves or is renamed.
**3. Do not grant push.** `[core] forge = none` with
`[core] workflow = local-only`. The session commits locally and stops; you
review the diff and push it yourself. No branch, no workflow, no runner, and
the vector is closed completely rather than bounded. The cost is one push
after the review you were going to do anyway — and it is the only option here
that does not depend on getting a forge's configuration right.
If none of the three is available for a given project, that is worth knowing
plainly: pointing a sandbox at it means accepting that a compromised session
can run code on whatever that repository can reach.
## Egress
@@ -185,76 +357,49 @@ 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 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.
```sh
docker network create \
--opt com.docker.network.bridge.name=dejima-ci0 \
--subnet 203.0.113.0/24 \
dejima-ci
```
(That subnet is a documentation range, per RFC 5737. Substitute a private
range that does not collide with anything you route to.)
stable interface to match on. Give it a **private** subnet: `stop-dns-rebind`
below rejects answers in the private ranges, so a reserved-but-not-private
bridge subnet is one that check silently does not cover.
With `runner.capacity: 1` one job occupies that network at a time. Above one,
concurrent jobs share a broadcast domain and can reach each other's service
containers, which is worth knowing before raising it.
### DNS and the firewall
The 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*.
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.
a resolver cron chasing it. `rebind-domain-ok` exempts a self-hosted forge,
which legitimately resolves to a private address.
```
no-resolv
server=/deb.debian.org/198.51.100.53
ipset=/#/ci-allow
rebind-domain-ok=/gitea.example.com/
```
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.
`rebind-domain-ok` matters for a self-hosted forge: `stop-dns-rebind` rejects
private answers, and an internal forge legitimately has one.
Point the daemon at that resolver so containers inherit it:
```json
{ "dns": ["203.0.113.1"] }
```
Then filter, remembering both chains:
```sh
# Job egress: everything forwarded off the bridge passes here first.
iptables -I DOCKER-USER -i dejima-ci0 -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
iptables -A DOCKER-USER -i dejima-ci0 -m set --match-set ci-allow dst -p tcp -m multiport --dports 80,443 -j ACCEPT
iptables -A DOCKER-USER -i dejima-ci0 -j REJECT
# The resolver lives on the host, so it is INPUT, not FORWARD.
iptables -A INPUT -i dejima-ci0 -p udp --dport 53 -j ACCEPT
iptables -A INPUT -i dejima-ci0 -p tcp --dport 53 -j ACCEPT
iptables -A INPUT -i dejima-ci0 -j REJECT
```
Block IPv6 outright unless you have built the same allowlist for it. An
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 allowlist comes from `main`, not from the checkout
**The working configuration for all of this is in [`ci/`](../ci/), and
[`ci/README.md`](../ci/README.md) is the step-by-step.** It is deliberately not
reproduced here: two copies of a firewall rule drift, and the one in `ci/` is
the one with the ordering constraints and the substitution notes attached.
## The allowlist comes from `main`, not from the checkout
A dejima project already carries a version-controlled statement of where it is
allowed to reach: `.dejima/allowed-domains`. Use it — read from
@@ -280,6 +425,10 @@ 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:
@@ -361,6 +510,10 @@ can still add a workflow of its own.
- **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
+90
View File
@@ -47,6 +47,96 @@ push grant declines the code execution with it — `[core] forge = none` with
This applies to any project developed in a dejima sandbox, not only to this
repository. **→ [docs/ci.md](ci.md)** for the reasoning and the setup.
## To your forge, the sandbox is you
By default a session authenticates with your credentials: your SSH key through
the forwarded agent, and a token generated under your account. A push, a pull
request, a comment or a merge made from inside the sandbox is therefore
**indistinguishable at the forge from one you made yourself** — not similar to
it, identical.
The consequence is worth stating flatly, because it is easy to configure
around without noticing: **every control your forge offers that keys on
identity is a guard rather than a boundary.** Branch protection's merge
whitelist, its push whitelist, required approvals, and the audit log all
answer the question "who did this", and there is only one answer available.
Putting yourself on a merge whitelist puts the sandbox on it too.
Three specific consequences:
- **Required approvals are not merely weak here, they are unusable.** A forge
will not let a pull request's author approve it, and the sandbox opens pull
requests as you — so requiring one approval means nobody can give it.
- **The forge's audit trail cannot attribute.** "You pushed this" and "a
session pushed this" are the same recorded event. This is the same shape as
the exit-time audit's time-window caveat above: a record of what happened,
not of who caused it.
- **It compounds the SSH agent's reach.** The agent already signs for any repo
your key can reach (below); the identity is what makes the resulting action
land as yours everywhere it reaches.
### Give the sandbox its own identity
This is configuration, not a missing feature. Create a dedicated forge account
with **write** access to the repositories a session should work on, and point
the sandbox at it instead of at you:
- `[forge.gitea] token_file` — that account's token, rather than one issued
under yours.
- `[core] ssh_key` — a deploy key belonging to it, rather than your forwarded
agent. See "Headless git push without agent forwarding" in the README.
With the two identities separated, the same settings stop being decorative:
| Setting | What it becomes |
| --- | --- |
| Merge whitelist: you | The session genuinely cannot merge |
| Push whitelist on `main`: you, or nobody | The session pushes feature branches and nothing else |
| Require approvals ≥ 1 | Usable — the session authors, you approve |
That last row is the one that turns "the operator merges, the agent does not"
from a convention into something the forge enforces.
**Verify which credential a push actually uses.** Nothing here enforces one:
`[core] ssh_key` is a second, independent credential path (below), so if your
own agent is *also* forwarded, a push may still be signed by your personal
key and the separate identity becomes decorative for exactly the operation it
was meant to constrain. Make a test push and read what the forge attributes
it to, rather than assuming.
**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 is worth knowing before you meet it, because it 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 — which
cannot work, since no scope grants ownership, and whose only real remedy is
handing the sandbox your own token. That 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
+110 -43
View File
@@ -10,6 +10,24 @@
# Its reason to exist is a sandboxed agent's: CI is where the tests that
# cannot run in the sandbox actually run, and without this the only way to
# see why one failed is for the operator to paste the log in by hand.
#
# Nothing here touches /repos/{owner}/{repo}/actions/runs, or the job and
# log endpoints reached through it, however natural they look. Gitea gates
# that group behind reqOwner(): a token belonging to a dedicated non-owner
# account - which is what a sandbox should be given - is refused with "user
# should be the owner of the repo", and no scope grants ownership, so
# widening the token cannot fix it. The three sources used instead need only
# read access to the repo:
#
# 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}"
@@ -99,57 +117,81 @@ function gitea_owner_repo() {
echo "$owner_repo"
}
# The API addresses runs by an internal id; every URL a human has shows the
# per-repo run number instead, and the two are unrelated. Everything below
# takes the number and resolves it here, against one page of the run list.
#
# With no number, HEAD's commit: a run list is not what someone who just
# pushed is asking for. Matched on head_sha rather than on branch because a
# pull_request run reports no branch at all - its ref is refs/pull/N/head.
# 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
api_or_die "list runs" "${GITEA_API}/repos/${owner}/${repo}/actions/runs?page=1&limit=$RUN_SCAN"
fetch_tasks
if [ -n "$want" ]; then
run_id=$(echo "$json" | jq -r --argjson n "$want" \
'first(.workflow_runs[] | select(.run_number == $n) | .id) // empty')
if [ -z "$run_id" ]; then
echo "No run numbered $want in the last $RUN_SCAN runs (raise --limit, or see 'gitea-actions runs')" >&2
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_id=$(echo "$json" | jq -r --arg sha "$sha" \
'first(.workflow_runs[] | select(.head_sha == $sha) | .id) // empty')
if [ -z "$run_id" ]; then
echo "No run for HEAD (${sha:0:8}) in the last $RUN_SCAN runs - unpushed, or still queued?" >&2
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
run_number=$(echo "$json" | jq -r --argjson id "$run_id" \
'first(.workflow_runs[] | select(.id == $id) | .run_number)')
}
# Sets `jobs_json` for the resolved run. The job index a URL shows is the
# tail of the job's html_url, not a field of its own, and the list does not
# arrive in that order.
# 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}/actions/runs/${run_id}/jobs"
jobs_json=$(echo "$json" | jq '[.jobs[] | {
index: (.html_url | split("/") | last | tonumber),
id, name,
result: (if .status == "completed" then .conclusion else .status end)
}] | sort_by(.index)')
"${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 id="$1" name="$2" result="$3"
local index="$1" name="$2" result="$3"
echo "=== $name ($result) ==="
api_or_die "fetch logs for job $name" \
"${GITEA_API}/repos/${owner}/${repo}/actions/jobs/${id}/logs"
"${GITEA_WEB}/${owner}/${repo}/actions/runs/${run_number}/jobs/${index}/logs"
if [ "$KEEP_TIMESTAMPS" = 1 ]; then
echo "$json"
else
@@ -161,7 +203,7 @@ function print_job_log() {
echo
}
RUN_SCAN=50
TASK_SCAN=600
KEEP_TIMESTAMPS=0
if [ "$#" -lt 1 ]; then
@@ -179,6 +221,15 @@ case "$subcommand" in
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
@@ -195,14 +246,28 @@ case "$subcommand" in
*) echo "Unknown option: $1" >&2; usage; exit 1 ;;
esac
done
api_or_die "list runs" "${GITEA_API}/repos/${owner}/${repo}/actions/runs?page=1&limit=$limit"
echo "$json" | jq -r '.workflow_runs[] | [
(.run_number | tostring),
(if .status == "completed" then .conclusion else .status end),
.event,
(.head_sha[0:8]),
.display_title
] | @tsv' | column -t -s $'\t'
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=""
@@ -212,7 +277,9 @@ case "$subcommand" in
fetch_jobs
echo "run $run_number:"
echo "$jobs_json" | jq -r '.[] | [(.index | tostring), .result, .name] | @tsv' |
column -t -s $'\t'
while IFS=$'\t' read -r index result name; do
printf '%-3s %-11s %s\n' "$index" "$result" "$name"
done
;;
logs)
run_arg=""
@@ -237,7 +304,7 @@ case "$subcommand" in
if [[ "$job_want" =~ ^[0-9]+$ ]]; then
selected=$(echo "$jobs_json" | jq --argjson i "$job_want" '[.[] | select(.index == $i)]')
else
selected=$(echo "$jobs_json" | jq --arg n "$job_want" '[.[] | select(.name | contains($n))]')
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
@@ -254,8 +321,8 @@ case "$subcommand" in
fi
fi
while IFS=$'\t' read -r id name result; do
print_job_log "$id" "$name" "$result"
done < <(echo "$selected" | jq -r '.[] | [(.id | tostring), .name, .result] | @tsv')
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"
+106 -75
View File
@@ -5,12 +5,16 @@ 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 edit PR_NUMBER [--title TITLE] [--body BODY | --body-file FILE]"
echo " gitea-pr comments PR_NUMBER"
echo " gitea-pr merge PR_NUMBER"
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 +57,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 +107,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 +153,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 +168,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 +186,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,18 +197,44 @@ ${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)
@@ -187,39 +245,20 @@ ${body}"
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')
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
api_or_die "fetch comments for review ${review_id}" \
"${GITEA_API}/repos/${owner}/${repo}/pulls/${pr_number}/reviews/${review_id}/comments"
echo "$json" | jq -r '.[] | "[\(.user.login) @ \(.path):\(.line // "?")]\n\(.body)\n"'
done
;;
@@ -230,17 +269,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
+15
View File
@@ -133,6 +133,21 @@ if [ -f "$decl_dir/dependencies" ]; then
# a leading dash (e.g. "--allow-unauthenticated") must not validate
# as a name, or this hook would echo into the agent's context an
# entry the project's own approve/status commands treat as rejected.
#
# A tarball spec carries a digest and a URL rather than a bare name,
# so it is matched separately and answered by whether its directory
# is there. Reporting one as missing has to print the whole spec, and
# that is deliberate: the digest is the part that makes the operator's
# approval mean something, so a truncated form would be asking them to
# approve something they cannot see.
if [[ "$spec" == tarball:* ]]; then
name="${spec#tarball:}"; name="${name%%@*}"
[[ "$name" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]] || continue
[ -d "/opt/dejima/$name" ] && continue
missing_deps_n=$((missing_deps_n + 1))
[ "$missing_deps_n" -le "$MAX_LISTED" ] && missing_deps="$missing_deps $spec"
continue
fi
[[ "$spec" =~ ^(apt|npm|pip|cargo):[A-Za-z0-9][A-Za-z0-9._+-]*$ ]] || continue
kind="${spec%%:*}"
name="${spec#*:}"
+133 -10
View File
@@ -557,7 +557,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 +726,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 +854,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 +1101,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"
}
+173
View File
@@ -0,0 +1,173 @@
#!/usr/bin/env bats
#
# Asserts on the RUNNER, not on this repository. Every other suite here tests
# dejima's code; this one tests the machine executing it, because the controls
# in docs/ci.md live in a runner's config.yaml and a host's firewall - set up
# by dejima-ci-hardener, on a machine this repository never touches, so no
# amount of reviewing this repository can check them.
#
# It exists because both failures that matter look exactly like success from
# inside a job. Nothing in a job's log mentions whether the Docker socket was
# mounted into it, and a job that can reach an off-list resolver still passes
# every check that only inspects DOCKER-USER. Neither shows up as a red run;
# they show up as nothing at all.
#
# DEJIMA_CI_TIER says which runner this is supposed to be, set by the workflow
# rather than sniffed, so landing on the wrong runner is a failure rather than
# a different set of assertions quietly being chosen. Unset means this is not
# a CI runner and there is nothing here to assert.
#
# Its values are the names of the jobs whose environment they verify -
# `sandbox` and `host-docker` - rather than a separate vocabulary. A reader
# looking at a red `runner-env (host-docker)` should not have to work out
# which job that corresponds to.
setup() {
[ -n "${DEJIMA_CI_TIER:-}" ] || skip "not on a CI runner (DEJIMA_CI_TIER unset)"
case "$DEJIMA_CI_TIER" in
sandbox|host-docker) ;;
*) echo "unknown DEJIMA_CI_TIER: $DEJIMA_CI_TIER" >&2; return 1 ;;
esac
}
# An off-list destination that would answer if it were reachable. This matters
# more than it looks: pointing these at an address that never answers - a
# documentation range, say - would pass in an environment with no network at
# all, which is the exact failure this suite exists to detect.
OFF_LIST_IP=1.1.1.1
# The positive control's destination. Overridable because it has to be a name
# THIS runner allows, and an allowlist is per-operator - hardcoding one would
# fail on a runner that is correctly configured for a different set.
ALLOWED_HOST=${DEJIMA_CI_ALLOWED_HOST:-deb.debian.org}
connects() { # connects HOST PORT -> 0 if a TCP connection completes
python3 - "$1" "$2" <<'PY'
import socket, sys
try:
socket.create_connection((sys.argv[1], int(sys.argv[2])), timeout=6).close()
except OSError:
sys.exit(1)
PY
}
# --- the grant that decides everything --------------------------------------
@test "the sandbox jobs are not handed the Docker daemon" {
[ "$DEJIMA_CI_TIER" = sandbox ] || skip "asserted for the sandbox runner"
# act_runner mounts /var/run/docker.sock into every job container unless
# container.docker_host is "-". An unset value is the default, and the
# default mounts - so this is the assertion that a runner was actually
# configured rather than merely intended.
[ ! -e /var/run/docker.sock ] || {
echo "this runner grants job containers the Docker socket, which is" >&2
echo "root-equivalent on the runner host. Set container.docker_host: \"-\"" >&2
echo "in its config.yaml - see dejima-ci-hardener." >&2
false
}
}
@test "the host-docker job does have the daemon it is scheduled for" {
[ "$DEJIMA_CI_TIER" = host-docker ] || skip "asserted for the host-docker runner"
# The positive control for the label routing. Without it, a host-docker
# job that landed on a hardened runner would fail later and obscurely, and
# a sandbox job that landed on this one would be caught only by the test
# above - one direction of a two-direction problem.
[ -e /var/run/docker.sock ]
}
@test "the two runners are not the same runner" {
# Whatever else is true, exactly one of them gets the daemon. If both jobs
# report the same answer here, the labels are not routing to two different
# runners and the tiering exists only on paper.
if [ "$DEJIMA_CI_TIER" = sandbox ]; then
[ ! -e /var/run/docker.sock ]
else
[ -e /var/run/docker.sock ]
fi
}
# --- egress, for the tier the rules actually apply to ------------------------
#
# Deliberately scoped to the sandbox runner. host-docker's job runs in the
# HOST network namespace - that is the grant they exist for - so the bridge
# rules dejima-ci-hardener installs do not sit in front of them at all. Their
# egress is the host's own, which this repository's examples do not define.
# Asserting an allowlist there would test nothing and pass anyway, which is
# the failure mode this whole suite is about.
@test "an allowlisted destination is reachable" {
[ "$DEJIMA_CI_TIER" = sandbox ] || skip "bridge rules do not apply in the host netns"
# The positive control for everything below. Without it, a runner with no
# network at all passes every negative assertion in this file.
run getent hosts "$ALLOWED_HOST"
[ "$status" -eq 0 ] || {
echo "$ALLOWED_HOST did not resolve, so the negative checks below" >&2
echo "would pass for the wrong reason. Check the resolver and the" >&2
echo "allowlist rendered from .dejima/allowed-domains on main - or set" >&2
echo "DEJIMA_CI_ALLOWED_HOST to a name this runner does allow." >&2
false
}
run connects "$ALLOWED_HOST" 80
[ "$status" -eq 0 ]
}
@test "a name that is on no allowlist does not resolve" {
[ "$DEJIMA_CI_TIER" = sandbox ] || skip "bridge rules do not apply in the host netns"
# .invalid is reserved by RFC 2606 precisely so that it never resolves
# anywhere - but here it has to be REFUSED by a resolver that answers for
# allowlisted names, which the positive control above establishes.
run getent hosts example.invalid
[ "$status" -ne 0 ]
}
@test "a job cannot reach a resolver of its own choosing" {
[ "$DEJIMA_CI_TIER" = sandbox ] || skip "bridge rules do not apply in the host netns"
# The check most easily left out, and the one that decides whether the DNS
# allowlist means anything: a job that can query any resolver it likes can
# resolve anything it likes, whatever dnsmasq is configured with. Traffic
# to the resolver on the bridge gateway is addressed to the host, so it is
# an INPUT packet and never passes through DOCKER-USER - filtering only
# DOCKER-USER leaves this open.
run connects "$OFF_LIST_IP" 53
[ "$status" -ne 0 ] || {
echo "reached $OFF_LIST_IP:53, so this job can use any resolver it likes and" >&2
echo "the DNS allowlist is decorative. The INPUT rules are missing -" >&2
echo "re-run dejima-ci-firewall on the runner host." >&2
false
}
}
@test "a destination that is on no allowlist is unreachable" {
[ "$DEJIMA_CI_TIER" = sandbox ] || skip "bridge rules do not apply in the host netns"
# An IP literal rather than a name, so this tests the firewall rather than
# the resolver. Reaching it by name would fail at DNS first and prove
# nothing about DOCKER-USER.
run connects "$OFF_LIST_IP" 443
[ "$status" -ne 0 ] || {
echo "reached $OFF_LIST_IP:443, so this job has unrestricted egress." >&2
echo "" >&2
echo "If the DNS checks above passed, the resolver is fine and the packet" >&2
echo "filter is not - which usually means DOCKER-USER is empty. Docker" >&2
echo "recreates its own chains on every daemon start, so a 'systemctl" >&2
echo "restart docker' drops these rules and nothing looks different" >&2
echo "afterwards. Check with: iptables -S DOCKER-USER" >&2
echo "" >&2
echo "dejima-ci-hardener's dejima-ci-firewall.service reapplies them on" >&2
echo "every Docker start, which is the fix rather than a one-off re-run." >&2
false
}
}
@test "IPv6 egress is not an unfiltered way around all of the above" {
[ "$DEJIMA_CI_TIER" = sandbox ] || skip "bridge rules do not apply in the host netns"
# An address family with no allowlist is not a smaller hole than the one
# the rules above close; on a runner with IPv6 egress it is the same hole.
#
# Unlike the checks above, this one passes for two different reasons - v6
# rejected, or v6 never configured - and both are acceptable outcomes, so
# it is not given a positive control. What it rules out is the third case:
# a runner with working IPv6 egress and rules written only for v4.
run connects 2606:4700:4700::1111 443
[ "$status" -ne 0 ]
}
+89
View File
@@ -136,6 +136,95 @@ dockerfile_for() {
done
}
TARBALL_SHA=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
@test "valid_dep_spec accepts a well-formed tarball spec" {
for spec in \
"tarball:go@$TARBALL_SHA:https://go.dev/dl/go1.25.0.linux-amd64.tar.gz" \
"tarball:nvim@$TARBALL_SHA:https://github.com/neovim/neovim/releases/download/v0.11.0/nvim-linux-x86_64.tar.gz" \
"tarball:a@$TARBALL_SHA:https://example.com/x?v=1.2%2B3"; do
run bash -c 'source "$1"; valid_dep_spec "$2"' _ "$LIB" "$spec"
[ "$status" -eq 0 ]
done
}
# The URL and the name are interpolated into a root RUN line in the generated
# Dockerfile, so this is the boundary that keeps a declaration from becoming a
# build-time root shell. The digest cases matter for a different reason: a
# spec without one would be an approval of a name whose contents the far end
# can change afterwards, which is not something an operator can agree to once.
@test "valid_dep_spec rejects a tarball spec that is shell-active, unpinned or not https" {
for spec in \
"tarball:go@$TARBALL_SHA:https://x.dev/\$(id).tar.gz" \
"tarball:go@$TARBALL_SHA:https://x.dev/a;id.tar.gz" \
"tarball:go@$TARBALL_SHA:https://x.dev/a\`id\`.tar.gz" \
"tarball:go@$TARBALL_SHA:https://x.dev/a b.tar.gz" \
"tarball:go@$TARBALL_SHA:https://x.dev/a'\''.tar.gz" \
"tarball:go@$TARBALL_SHA:https://x.dev/a\\\\b.tar.gz" \
"tarball:go@$TARBALL_SHA:https://x.dev/a|id" \
"tarball:go@$TARBALL_SHA:http://x.dev/a.tar.gz" \
"tarball:go@$TARBALL_SHA:ftp://x.dev/a.tar.gz" \
"tarball:go@$TARBALL_SHA:/etc/passwd" \
"tarball:go@deadbeef:https://x.dev/a.tar.gz" \
"tarball:go@${TARBALL_SHA}Z:https://x.dev/a.tar.gz" \
"tarball:go@:https://x.dev/a.tar.gz" \
"tarball:go:https://x.dev/a.tar.gz" \
"tarball:go@$TARBALL_SHA" \
"tarball:@$TARBALL_SHA:https://x.dev/a.tar.gz" \
"tarball:-go@$TARBALL_SHA:https://x.dev/a.tar.gz"; do
run bash -c 'source "$1"; valid_dep_spec "$2"' _ "$LIB" "$spec"
[ "$status" -ne 0 ] || { echo "accepted: $spec" >&2; false; }
done
}
# A name becomes a directory under /opt/dejima, so a traversal token in it
# would place the unpacked archive somewhere else entirely.
@test "valid_dep_spec rejects a tarball name that walks out of its directory" {
for name in .. . ../evil a/b /abs; do
run bash -c 'source "$1"; valid_dep_spec "$2"' _ "$LIB" \
"tarball:$name@$TARBALL_SHA:https://x.dev/a.tar.gz"
[ "$status" -ne 0 ] || { echo "accepted name: $name" >&2; false; }
done
}
@test "generate_derived_dockerfile verifies a tarball's digest before unpacking it" {
store=$(bash -c 'source "$1"; ensure_project_store "$2"' _ "$LIB" "$PROJ")
printf 'tarball:go@%s:https://go.dev/dl/go1.25.0.linux-amd64.tar.gz\n' "$TARBALL_SHA" > "$store/dependencies"
run bash -c 'source "$1"; generate_derived_dockerfile base:tag "$2"' _ "$LIB" "$store"
[ "$status" -eq 0 ]
[[ "$output" == *"https://go.dev/dl/go1.25.0.linux-amd64.tar.gz"* ]]
[[ "$output" == *"$TARBALL_SHA /tmp/go.tar"* ]]
[[ "$output" == *"/opt/dejima/go"* ]]
[[ "$output" == *"ENV PATH=\$PATH:/opt/dejima/go/bin"* ]]
# An archive extracted first and verified after has already written
# whatever it contained, so the ordering is the property, not the presence
# of a checksum line anywhere in the file.
sha_line=$(grep -n 'sha256sum -c' <<< "$output" | head -1 | cut -d: -f1)
tar_line=$(grep -n 'tar -C /opt/dejima/go' <<< "$output" | head -1 | cut -d: -f1)
[ "$sha_line" -lt "$tar_line" ]
}
@test "generate_derived_dockerfile unpacks tarballs as root, before the drop to the sandbox user" {
store=$(bash -c 'source "$1"; ensure_project_store "$2"' _ "$LIB" "$PROJ")
printf 'tarball:go@%s:https://go.dev/dl/go.tar.gz\nnpm:typescript\n' "$TARBALL_SHA" > "$store/dependencies"
run bash -c 'source "$1"; generate_derived_dockerfile base:tag "$2"' _ "$LIB" "$store"
[ "$status" -eq 0 ]
tar_line=$(grep -n 'tar -C /opt/dejima/go' <<< "$output" | head -1 | cut -d: -f1)
drop_line=$(grep -n 'USER \${SANDBOX_USER}' <<< "$output" | head -1 | cut -d: -f1)
[ "$tar_line" -lt "$drop_line" ]
[[ "$(grep -E '^(USER|ENTRYPOINT)' <<< "$output" | tail -2)" == "USER root
ENTRYPOINT [\"/usr/local/bin/entrypoint.sh\"]" ]]
}
@test "generate_derived_dockerfile emits no tarball layer when none is approved" {
store=$(bash -c 'source "$1"; ensure_project_store "$2"' _ "$LIB" "$PROJ")
printf 'apt:libpq-dev\n' > "$store/dependencies"
run bash -c 'source "$1"; generate_derived_dockerfile base:tag "$2"' _ "$LIB" "$store"
[ "$status" -eq 0 ]
[[ "$output" != *"/opt/dejima"* ]]
}
@test "read_declared skips comments/blanks and marks invalid entries rejected" {
cat > "$PROJ/.dejima/dependencies" <<'EOF'
# project deps
+168
View File
@@ -85,6 +85,16 @@ wait_ready() {
if docker exec "$container" pgrep -x -f 'sleep infinity' >/dev/null 2>&1; then
return 0
fi
# A container that has already exited is never going to become ready,
# and polling it for the full two minutes turns a startup failure - an
# aborted entrypoint, say - into a wait long enough to read as a hang.
# The reason is in its logs, which are printed either way; this only
# decides how long it takes to see them.
if [ "$(docker inspect -f '{{.State.Running}}' "$container" 2>/dev/null)" = false ]; then
echo "container $container exited during startup - log:" >&2
settled_logs "$container" >&2
return 1
fi
sleep 1
done
echo "container $container never reached the post-entrypoint sleep - startup log:" >&2
@@ -197,6 +207,7 @@ teardown() {
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
@@ -210,6 +221,8 @@ teardown() {
docker rm -f "dejima-test-tls-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
docker rm -f "dejima-test-nohosts-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
docker rm -f "dejima-test-badtransport-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
docker rm -f "dejima-test-notifydir-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
docker rm -f "dejima-test-nonotify-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
}
dexec() {
@@ -358,6 +371,26 @@ dexec_node() {
[ -n "$output" ]
}
@test "the session user cannot address a resolver directly, so the DNS allowlist is not bypassable" {
# init-firewall.sh opens UDP/TCP 53 to any destination for root-owned
# sockets, because that is what Docker's embedded resolver forwards
# external names from and without it nothing in here resolves at all.
# This is the assertion that the opening stops there. `node` is the
# session user and there is no sudo in the image, so if it could reach a
# resolver of its own choosing it could carry data out in the query name
# itself - the exact thing dns-proxy.sh exists to prevent, and something
# no allowlist of *names* can catch once the *resolver* is attacker-chosen.
#
# 1.1.1.1 rather than an unrouted address: it answers if it is reachable,
# so a pass here means the packet was stopped and not that the probe was
# aimed at nothing.
run dexec_node dig +short registry.npmjs.org
[ -n "$output" ]
run dexec_node dig +time=2 +tries=1 @1.1.1.1 definitely-not-allowed.example.org
[ "$status" -ne 0 ]
}
@test "api.anthropic.com resolves in proxy-off mode (kill switch / API-key auth)" {
# api.anthropic.com is left off the allowlist when the credential proxy is
# on, so a bypass of the /etc/hosts pin that resolves the name by DNS gets
@@ -458,6 +491,54 @@ dexec_node() {
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
@@ -961,6 +1042,18 @@ EOF
local store; store="$(mktemp -d)"
local df; df="$(mktemp)"
printf 'apt:cowsay\n' > "$store/dependencies"
# The positive control, and it is load-bearing: this test proves the
# derived layer installed cowsay by finding cowsay installed. If the base
# image already carried it, the assertion below would pass with
# generate_derived_dockerfile doing nothing at all. Checked here rather
# than assumed, because "the base image gained a package" is a change
# nobody would connect to this file.
run docker run --rm --entrypoint dpkg-query "$IMAGE" -W -f='${Status}' cowsay
[[ "$output" != *"install ok installed"* ]] || {
echo "cowsay is in the base image, so this test cannot detect a broken derived build" >&2
false
}
bash -c 'source "$1"; generate_derived_dockerfile "$2" "$3"' \
_ "$BATS_TEST_DIRNAME/../bin/dejima-project-lib.sh" "$IMAGE" "$store" > "$df"
docker build -q -t "$derived" -f "$df" "$ctx" >&2
@@ -1250,3 +1343,78 @@ EOF
[[ "$logs" == *"'tls'"* ]] || dump "the refusal did not name the valid values"
[[ "$logs" == *"'loopback'"* ]] || dump "the refusal did not name the valid values"
}
# The three-symptom failure entrypoint.sh's marker-directory check exists to
# name: notifications stop, the session vanishes from dejima-attach, and the
# notify hook reports EACCES - with no obvious moment they started.
#
# It is Docker's answer to a missing bind source that produces it. bin/dejima
# creates the marker directory per launch and removes it on exit, so a
# container that is RESTARTED rather than relaunched has no source left, and
# Docker creates one root-owned rather than refusing. The directory then
# exists, so the hook's own `[ -d ]` guard passes and the write fails instead.
@test "a marker directory the sandbox user cannot write is reported at startup" {
# Its own container, not container_name() - that one is created in
# setup_file and used by every dexec test in this file. Starting a second
# container under that name conflicts, and removing it at the end of this
# body would take the shared one with it.
local container="dejima-test-notifydir-$(basename "$BATS_FILE_TMPDIR")"
local host_dir; host_dir="$(mktemp -d)"
# Unwritable by the sandbox user, which is the property the check keys on.
# Built here rather than by removing a bind source, so the test does not
# depend on Docker's fallback behaviour to set up a test OF the
# consequences of that behaviour.
#
# chmod rather than mounting :ro. A read-only mount would make the
# "not repaired" assertion below vacuous - root could not chown it even
# if entrypoint.sh tried. Mode 555 leaves it repairable by root inside the
# container and still unwritable by the session user, so that assertion
# means something.
chmod 555 "$host_dir"
docker run -d --name "$container" \
--cap-add=NET_ADMIN --cap-add=NET_RAW \
--network "$(network_name)" \
-e SANDBOX_TEST_MODE=1 \
-e GH_TOKEN="${GH_TOKEN:-}" \
-e DEJIMA_NOTIFY_DIR=/run/dejima-notify \
-v "$host_dir:/run/dejima-notify" \
--entrypoint /usr/local/bin/entrypoint.sh \
"$IMAGE" >/dev/null
wait_ready "$container"
run settled_logs "$container"
[[ "$output" == *"not writable by"* ]]
[[ "$output" == *"RESTARTED rather than"* ]]
# Reported, not repaired: chowning would restore notifications while the
# session stayed absent from a registry this container never sees.
#
# Asserted as "still not writable by the session user" rather than "still
# owned by root": ownership depends on who ran the suite, since mktemp -d
# follows the caller, while writability is the property the check reads
# and the one repairing it would change.
run docker exec -u node "$container" test -w /run/dejima-notify
[ "$status" -ne 0 ]
rm -rf "$host_dir"
}
# Remote access is opt-in and off by default. The check above must say nothing
# at all for the sessions that never asked for it, which is every session by
# default - a warning about an unconfigured optional feature is noise on every
# start, and noise is what stops warnings being read.
@test "no marker-directory warning when remote access is not enabled" {
local container="dejima-test-nonotify-$(basename "$BATS_FILE_TMPDIR")"
docker run -d --name "$container" \
--cap-add=NET_ADMIN --cap-add=NET_RAW \
--network "$(network_name)" \
-e SANDBOX_TEST_MODE=1 \
-e GH_TOKEN="${GH_TOKEN:-}" \
--entrypoint /usr/local/bin/entrypoint.sh \
"$IMAGE" >/dev/null
wait_ready "$container"
run settled_logs "$container"
[[ "$output" != *"dejima-notify"* ]]
[[ "$output" != *"remote access is enabled"* ]]
}
+437 -2
View File
@@ -663,6 +663,19 @@ EOF
grep -q "not the issue's URL" "$REPO_ROOT/workflows/worktree-pr/briefing.md"
}
@test "the worktree-pr workflow tells the agent not to hard-wrap bodies, and how to fix one" {
# The forge renders a single newline as a line break, so a body wrapped
# at a column arrives broken every ten words - worst on a phone, where
# the screen wraps the line and the stray newline wraps it again. The
# tools deliberately do not reflow anything (#79), which puts the whole
# of this on the briefing.
grep -q 'hard-wrap' "$REPO_ROOT/workflows/worktree-pr/briefing.md"
grep -q 'newline as a line break' "$REPO_ROOT/workflows/worktree-pr/briefing.md"
# And that revising the file a body came from sends nothing by itself -
# the other half of #79, where edits were reported but never made.
grep -q 'edit <N>' "$REPO_ROOT/workflows/worktree-pr/briefing.md"
}
@test "entrypoint runs as root and drops to node via gosu, not a baked-in USER node" {
# The whole privilege-drop model depends on entrypoint.sh itself starting
# as root (to set up the firewall) - a stray `USER node` before
@@ -813,6 +826,112 @@ EOF
done
}
# Runs a gitea CLI against a stub `curl` that records the request instead of
# making one, from a throwaway repo whose origin looks like the configured
# Gitea host. Every one of these tools reaches the network through a single
# `curl -K -` call, so this is their whole outbound surface.
#
# The stub answers the way gitea_api's callers parse: the body, then the
# status on its own line (which is what `-w '\n%{http_code}'` appends).
gitea_cli() {
local cli="$1"; shift
local d="$BATS_TEST_TMPDIR/cli"
if [ ! -d "$d" ]; then
mkdir -p "$d/stub" "$d/repo"
cat > "$d/stub/curl" <<'STUB'
#!/bin/bash
prev=""
payload=""
for a in "$@"; do
if [ "$prev" = "-d" ]; then
payload="$a"
printf '%s' "$a" > "$STUB_PAYLOAD"
fi
prev="$a"
done
cat >/dev/null
# A request carrying a body is a write, and its callers read one object
# back; the reads these tools make before writing are list endpoints, whose
# callers iterate. Answering both with the same shape would fail on the jq
# rather than on anything under test.
if [ -n "$payload" ]; then
echo '{"html_url": "https://forge.invalid/x/y/pulls/1"}'
else
echo '[]'
fi
echo 200
STUB
chmod +x "$d/stub/curl"
git -C "$d/repo" init -q -b feature
# Identity passed per-command rather than inherited: a CI container
# has no git config, and `commit` there exits 128 with "Author
# identity unknown" - a precondition this suite was taking from
# whoever ran it. The commit exists only so create's
# `rev-parse --abbrev-ref HEAD` has a branch to report.
git -C "$d/repo" -c user.name=t -c user.email=t@invalid \
commit -q --allow-empty -m seed
git -C "$d/repo" remote add origin git@forge.invalid:owner/name.git
fi
: > "$d/payload"
( cd "$d/repo" && env PATH="$d/stub:$PATH" \
GITEA_SSH_HOST=forge.invalid GITEA_API=https://forge.invalid/api/v1 \
GITEA_TOKEN=stub-token STUB_PAYLOAD="$d/payload" \
"$REPO_ROOT/forges/gitea/bin/$cli" "$@" )
}
# What was in the -d of the last gitea_cli call.
cli_payload() { cat "$BATS_TEST_TMPDIR/cli/payload"; }
@test "an edit sends only the fields it was given, so a title edit cannot blank a body" {
# The half of #79 that was silent: an agent that said it had updated a
# description had not, because there was no way to. Sending a fixed
# {title, body} pair instead would have traded that for a worse bug -
# every title-only edit wiping the description.
local cli
for cli in gitea-pr gitea-issue; do
gitea_cli "$cli" edit 1 --title "a new title"
[ "$(cli_payload | jq -r '.title')" = "a new title" ]
[ "$(cli_payload | jq 'has("body")')" = "false" ]
gitea_cli "$cli" edit 1 --body "a new body"
[ "$(cli_payload | jq 'has("title")')" = "false" ]
# The provenance marker create writes survives an edit rather than
# being shed by it.
[[ "$(cli_payload | jq -r '.body')" == *"LLM agent"* ]]
[[ "$(cli_payload | jq -r '.body')" == *"a new body"* ]]
done
}
@test "an edit with neither a title nor a body is refused rather than sent empty" {
local cli
for cli in gitea-pr gitea-issue; do
run gitea_cli "$cli" edit 1
[ "$status" -ne 0 ]
[[ "$output" == *"requires --title"* ]]
# Nothing reached the network to be refused by the far end instead.
[ ! -s "$BATS_TEST_TMPDIR/cli/payload" ]
done
}
@test "create still sends head, base, title and body after sharing a parser with edit" {
# create's own argument parsing moved into the function edit uses, so
# that the two cannot disagree about --body vs --body-file. This is the
# side of that move with no test of its own before now.
gitea_cli gitea-pr create --title "a title" --body "a body" --base trunk
[ "$(cli_payload | jq -r '.head')" = "feature" ]
[ "$(cli_payload | jq -r '.base')" = "trunk" ]
[ "$(cli_payload | jq -r '.title')" = "a title" ]
[[ "$(cli_payload | jq -r '.body')" == *"a body"* ]]
}
@test "an edit takes its body from a file, the same way create does" {
# The flag that matters inside the sandbox: a body written to a file
# never enters the Bash command string deny-dangerous-bash.sh reads.
echo "body out of a file" > "$BATS_TEST_TMPDIR/b.md"
gitea_cli gitea-pr edit 1 --body-file "$BATS_TEST_TMPDIR/b.md"
[[ "$(cli_payload | jq -r '.body')" == *"body out of a file"* ]]
}
@test "gitea-issue list asks for issues only, never pull requests" {
# Gitea's issues endpoint returns PRs too - a PR is an issue with a
# branch attached - so without type=issues every open PR shows up as an
@@ -829,6 +948,37 @@ EOF
[ "$status" -ne 0 ]
}
@test "gitea-actions asks for nothing that needs to own the repo" {
# Gitea gates /repos/{o}/{r}/actions/runs - and the job and log endpoints
# reached through it - behind reqOwner(). A sandbox should hold a token
# for a dedicated account that is a collaborator and nothing more, and
# that account is refused there with "user should be the owner of the
# repo". No scope grants ownership, so a reader who hits this reaches for
# a wider token, which is the wrong direction and the reason this is
# pinned rather than left to the comment explaining it.
#
# Anchored on GITEA_API, not on the path: the log route is a web URL
# under the same-looking /actions/runs/N/jobs/M prefix, and it is fine.
local f="$REPO_ROOT/forges/gitea/bin/gitea-actions"
run grep -nE '\$\{?GITEA_API\}?[^"]*actions/(runs|jobs)' "$f"
[ "$status" -ne 0 ]
# The three it may use instead, each reachable with plain read access.
grep -q 'actions/tasks' "$f"
grep -q 'commits/.*statuses' "$f"
grep -q 'GITEA_WEB' "$f"
}
@test "the gitea CLIs format their own tables, rather than shelling out to column" {
# util-linux's `column` is not in the sandbox image, and it is the last
# command in the pipeline - so a missing one fails with 127 after the
# request has already succeeded, which reads as an API problem rather
# than a missing tool. bin/dejima-audit and bin/dejima-attach pad with
# printf for the same reason; this keeps the forge CLIs consistent with
# them.
run grep -rn 'column -t' "$REPO_ROOT/forges/"
[ "$status" -ne 0 ]
}
@test "gitea-issue is installed into the image alongside gitea-pr" {
grep -q 'COPY forges/' "$SANDBOX_DIR/Dockerfile"
# install -m 755, not a plain cp, so the executable bit surviving isn't
@@ -1490,6 +1640,80 @@ this line is not json'
grep -q 'dejima-project approve' "$REPO_ROOT/hooks/sandbox-context.sh"
}
# The hook answers "is this installed?" per kind, and a tarball has no
# package manager to ask - it is there if its directory is. A tarball spec
# falling through to the package-name regex would be silently skipped, which
# reads to the agent as "declared and present" for something that is neither.
@test "sandbox-context reports a declared tarball dependency that is not unpacked" {
local ws; ws="$(mktemp -d)"
local sha=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
mkdir -p "$ws/.dejima"
printf 'tarball:definitely-not-here@%s:https://example.com/x.tar.gz\n' "$sha" > "$ws/.dejima/dependencies"
local out
out=$(printf '{"session_id":"s1","cwd":"%s"}' "$ws" | DEJIMA=1 bash "$REPO_ROOT/hooks/sandbox-context.sh")
rm -rf "$ws"
[[ "$out" == *"definitely-not-here"* ]]
# The digest reaches the agent intact: it is what the operator is being
# asked to approve, so a truncated form would be asking them to agree to
# something they cannot see.
[[ "$out" == *"$sha"* ]]
}
# The positive half of the test above. It needs a tarball dependency actually
# unpacked in the image, which only exists once a project has approved one -
# so on an image with none it skips rather than silently asserting nothing.
# A visible skip is the honest form here; a test that arranged its own
# /opt/dejima entry would be checking that mkdir works.
#
# The branch itself is pinned by the source-level test below, which holds
# regardless of what this image happens to contain.
@test "sandbox-context does not report a tarball dependency that is unpacked" {
local present=""
for d in /opt/dejima/*/; do [ -d "$d" ] && present="$(basename "$d")" && break; done
[ -n "$present" ] || skip "no unpacked tarball dependency in this image"
local ws; ws="$(mktemp -d)"
local sha=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
mkdir -p "$ws/.dejima"
printf 'tarball:%s@%s:https://example.com/x.tar.gz\n' "$present" "$sha" > "$ws/.dejima/dependencies"
local out
out=$(printf '{"session_id":"s1","cwd":"%s"}' "$ws" | DEJIMA=1 bash "$REPO_ROOT/hooks/sandbox-context.sh")
rm -rf "$ws"
[[ "$out" != *"$present"* ]]
}
# runtime.bats proves the derived-image dependency mechanism works by
# declaring apt:cowsay and then finding cowsay installed. That only proves
# anything while the base image does not ship it - otherwise the assertion
# passes with the mechanism entirely broken. runtime.bats checks this for
# itself too, but only where Docker exists; this catches it in the suite that
# runs everywhere, at the point the package list changes.
@test "the base image ships none of the packages used as dependency fixtures" {
for pkg in $(grep -oE "apt:[a-z0-9.+-]+" "$REPO_ROOT/test/runtime.bats" | cut -d: -f2 | sort -u); do
run grep -nE "^ +$pkg \\\\$" "$SANDBOX_DIR/Dockerfile"
[ "$status" -ne 0 ] || {
echo "$pkg is a dependency-test fixture and is also in the base image" >&2
false
}
done
}
# The image ships the sandbox's own machinery and the tooling with no route
# through dejima-dep. A language toolchain has a route - tarball: - so one
# appearing here means every project carries it with nobody having asked.
@test "no language toolchain is installed into the base image" {
run grep -nE '^(RUN|ENV).*(golang|/usr/local/go|rustup|\.cargo/bin|pyenv|rbenv|sdkman)' \
"$SANDBOX_DIR/Dockerfile"
[ "$status" -ne 0 ] || {
echo "declare it with dejima-dep instead:" >&2
echo "$output" >&2
false
}
}
@test "sandbox-context decides a tarball is present by its unpack directory" {
grep -q '\[ -d "/opt/dejima/\$name" \] && continue' "$REPO_ROOT/hooks/sandbox-context.sh"
}
@test "sandbox-context reports nothing when no project declarations exist" {
local ws; ws="$(mktemp -d)"
local out
@@ -1640,11 +1864,156 @@ EOF
# from the branch the operator controls. A future edit that softens this into
# "read .dejima/allowed-domains" without saying from where would leave the
# document describing a control that doesn't control anything.
# runs-on lives in the workflow file, which lives in the branch, so a
# dedicated label routes honest jobs and stops nothing else. An earlier version
# of this document said jobs "cannot land anywhere else", which is true only of
# a branch that is not trying - the exact distinction the rest of the document
# is built on. Pinned so it cannot quietly come back.
# The counterintuitive half of the advisory, and the one a reader is most
# likely to skip: a project with no CI is exposed too, because a branch adds
# the first workflow. If that goes missing, the document reads as being about
# people who already run CI, which is the smaller group.
@test "docs/ci.md says a project with no CI is exposed too" {
grep -q 'applies to projects with no CI at all' "$REPO_ROOT/docs/ci.md"
grep -q 'no workflows is not therefore safe' "$REPO_ROOT/docs/ci.md"
grep -qi 'holds for projects with no CI' "$REPO_ROOT/README.md"
}
# Moving a public project to another owner breaks its name, its links and its
# ownership. It must not read as the recommended answer, and the alternatives
# that require moving nothing have to be present.
@test "docs/ci.md offers answers that do not require moving a repository" {
local f="$REPO_ROOT/docs/ci.md"
grep -q 'Scope the dangerous runner down, rather than moving' "$f"
grep -q 'Turn CI off for that repository' "$f"
grep -q 'Do not grant push' "$f"
}
@test "docs/ci.md does not present the runner label as a boundary" {
grep -q 'A label is not a boundary' "$REPO_ROOT/docs/ci.md"
grep -q 'weakest runner it can reach' "$REPO_ROOT/docs/ci.md"
run grep -n 'cannot land anywhere else' "$REPO_ROOT/docs/ci.md"
[ "$status" -ne 0 ] || { echo "$output" >&2; false; }
}
# The shared-identity point is what decides whether an operator's branch
# protection does anything, and it is the kind of thing that reads as a
# digression to a later editor. Pinned in both places, and pinned together
# with the separation that fixes it - stating the problem without the remedy
# would leave a reader with nothing to do about it.
@test "docs/security.md documents that the sandbox authenticates as the operator" {
grep -q 'the sandbox is you' "$REPO_ROOT/docs/security.md"
grep -qi 'guard rather than a boundary' "$REPO_ROOT/docs/security.md"
grep -q 'forge.gitea. token_file' "$REPO_ROOT/docs/security.md"
grep -q 'core. ssh_key' "$REPO_ROOT/docs/security.md"
}
@test "docs/ci.md's branch-protection prerequisite links the shared-identity section" {
grep -q 'security.md#to-your-forge-the-sandbox-is-you' "$REPO_ROOT/docs/ci.md"
}
@test "docs/ci.md says the CI allowlist is read from main, not from the checkout" {
grep -q 'origin/main' "$REPO_ROOT/docs/ci.md"
grep -qi 'never from the checkout' "$REPO_ROOT/docs/ci.md"
}
# --- the labels this repo's own workflow asks for ---
#
# Whether a job container gets the Docker socket is settled by the runner's
# config.yaml - which lives in dejima-ci-hardener's territory, not this
# repository's - so the only lever here is which runner its jobs may land on.
# A generic label gives that lever away, which is what these pin.
# pull_request_target reads this workflow from the base branch, which is what
# pins runs-on out of a branch's reach. Reverting to pull_request hands that
# back, silently.
@test "the workflow is read from the base branch, not the branch under test" {
local wf="$REPO_ROOT/.gitea/workflows/tests.yml"
grep -qE '^ pull_request_target:' "$wf"
# pull_request may still be present, but only while the transition is
# explicitly in progress - it takes an untrusted run from the head with
# branch-controlled runs-on, which is the thing being removed. Requiring
# the marker means the day it is dropped, this fails until the trigger
# goes with it, rather than the pair quietly becoming permanent.
if grep -qE '^ pull_request:' "$wf"; then
grep -q 'TRANSITIONAL' "$wf" || {
echo "pull_request is still enabled without the transitional note -" >&2
echo "it hands runs-on back to the branch. Remove the trigger." >&2
false
}
fi
}
# The trap in that conversion: the run's ref is the BASE branch, so a bare
# checkout tests main and passes regardless of the change. Every suite that
# means to test the branch has to name the head explicitly - and a green run
# is exactly what it looks like when one does not.
@test "every suite that tests the branch checks out the branch" {
local wf="$REPO_ROOT/.gitea/workflows/tests.yml"
# Both branch-testing jobs pin the head sha; the runner-env jobs must not,
# since their assertions should come from the base branch.
[ "$(grep -c 'pull_request.head.sha' "$wf")" -eq 2 ]
[ "$(grep -c 'uses: actions/checkout@v4' "$wf")" -eq 4 ]
}
@test "no workflow job runs on a generic runner label" {
run grep -nE '^\s*runs-on:\s*(ubuntu|windows|macos|self-hosted)' \
"$REPO_ROOT"/.gitea/workflows/*.yml
[ "$status" -ne 0 ] || {
echo "a generic label lets this job land on a runner nobody hardened:" >&2
echo "$output" >&2
false
}
}
@test "every workflow job runs on a dejima-specific label" {
while IFS= read -r label; do
[[ "$label" == dejima-* ]] || {
echo "unexpected runs-on label: $label" >&2
false
}
done < <(grep -hE '^\s*runs-on:' "$REPO_ROOT"/.gitea/workflows/*.yml \
| sed -E 's/^\s*runs-on:\s*//')
}
# Remote access is opt-in and off by default, so the marker-directory check
# must be inside the "was it configured at all" guard. Outside it, every
# ordinary session gets a warning about a feature it never asked for - and a
# warning that fires on every start is one nobody reads.
@test "the marker-directory check only runs when remote access is configured" {
local f="$REPO_ROOT/docker/entrypoint.sh"
grep -q 'if \[ -n "${DEJIMA_NOTIFY_DIR:-}" \]; then' "$f"
# The check's own output must sit inside that guard, not before it.
guard=$(grep -n 'if \[ -n "${DEJIMA_NOTIFY_DIR:-}" \]; then' "$f" | head -1 | cut -d: -f1)
warn=$(grep -n 'remote access is enabled, but' "$f" | head -1 | cut -d: -f1)
[ "$guard" -lt "$warn" ]
}
# Chowning it would restore notifications while the session stayed absent from
# dejima-attach, because the registry entry is written host-side and never
# mounted here - a session that looked fixed with half the feature still
# broken. The remedy is relaunching, which the message names.
@test "the marker-directory check reports rather than repairs" {
local f="$REPO_ROOT/docker/entrypoint.sh"
run grep -nE 'chown[^|]*DEJIMA_NOTIFY_DIR|chmod[^|]*DEJIMA_NOTIFY_DIR' "$f"
[ "$status" -ne 0 ] || { echo "$output" >&2; false; }
grep -q "start a new session with 'dejima'" "$f"
}
# bin/dejima warns and continues rather than refusing. The neighbouring
# state-dir check does refuse, but that one is about a symlink handing a
# session an attacker-chosen path; this one is only about an optional feature
# working, and blocking a launch over undeliverable notifications costs more
# than it saves.
@test "bin/dejima warns about an unusable marker directory without refusing" {
local line
line=$(grep -n 'is not a directory this user owns and can write' "$REPO_ROOT/bin/dejima" | head -1 | cut -d: -f1)
[ -n "$line" ]
run sed -n "$((line)),$((line + 6))p" "$REPO_ROOT/bin/dejima"
[[ "$output" != *"exit 1"* ]]
}
@test "sandbox-context.sh no longer mentions ALLOWED_DOMAINS" {
run grep -n 'ALLOWED_DOMAINS' "$REPO_ROOT/hooks/sandbox-context.sh"
[ "$status" -ne 0 ]
@@ -1676,6 +2045,10 @@ SCANNED_PATHS=(README.md docs docker bin forges workflows harnesses hooks instal
}
}
# The exemption above is only defensible while it is the address the docs
# actually recommend. If the recommendation changes and this does not, the
# exemption starts covering an address nothing points at - a hole rather than
# a carve-out.
@test "no hostname under a private-network suffix is used as a documentation example" {
run grep -rnIE '[A-Za-z0-9-]+\.(localdomain|lan|internal|home\.arpa)\b' \
"${SCANNED_PATHS[@]/#/$REPO_ROOT/}"
@@ -1773,6 +2146,55 @@ startup_warning_slug() { printf '%s' "$DEJIMA_T/ws" | sed 's/[^a-zA-Z0-9]/-/g';
grep -q ' run --rm ' "$DEJIMA_T/docker-log"
}
# Through a real pty, because the [a]/[c]/[q] prompt only exists when stdin
# is a terminal - the same reason test/remote.bats drives its picker with
# `script` rather than a pipe.
startup_warning_launch_tty() {
local keys="$1"
printf '%s' "$keys" | HOME="$DEJIMA_T/home" PATH="$DEJIMA_T:$PATH" \
DEJIMA_CONFIG_DIR="$DEJIMA_T/config" \
SANDBOX_TEST_DOCKER_LOG="$DEJIMA_T/docker-log" \
SSH_AUTH_SOCK= ALLOWED_DOMAINS= TERM=xterm-256color \
script -qec "'$REPO_ROOT/bin/dejima' '$DEJIMA_T/ws'" /dev/null
}
@test "the startup warning offers to review the findings there and then" {
# Without this the operator is told to run a command in another terminal,
# at the one moment when nothing is waiting on them: the container has
# not started yet (#65).
startup_warning_setup
mkdir -p "$DEJIMA_T/audit/$(startup_warning_slug)"
: > "$DEJIMA_T/audit/$(startup_warning_slug)/2020-01-01-000000-CRITICAL.txt"
run startup_warning_launch_tty 'a
q
'
[[ "$output" == *"[a]"* ]]
[[ "$output" == *"review now"* ]]
# 'a' ran the audit review in-process, rather than only naming it.
[[ "$output" == *"dejima audit:"* ]]
}
@test "the startup warning colours the tier and the keystrokes on a terminal" {
# The complaint is that the warning is one undifferentiated block: the
# word that says how bad it is, the run ids, and the keys to press all
# read the same (#65).
startup_warning_setup
mkdir -p "$DEJIMA_T/audit/$(startup_warning_slug)"
: > "$DEJIMA_T/audit/$(startup_warning_slug)/2020-01-01-000000-CRITICAL.txt"
run startup_warning_launch_tty 'c
'
[[ "$output" == *$'\033'* ]]
}
@test "the startup warning emits no colour when it is not going to a terminal" {
startup_warning_setup
mkdir -p "$DEJIMA_T/audit/$(startup_warning_slug)"
: > "$DEJIMA_T/audit/$(startup_warning_slug)/2020-01-01-000000-CRITICAL.txt"
run startup_warning_launch
[[ "$output" == *"unacknowledged CRITICAL"* ]]
[[ "$output" != *$'\033'* ]]
}
@test "audit.strict = on refuses to start over unacknowledged CRITICAL history when stdin is not a tty" {
startup_warning_setup
printf '[audit]\nstrict = on\n' >> "$DEJIMA_T/config/config"
@@ -2084,15 +2506,21 @@ startup_warning_slug() { printf '%s' "$DEJIMA_T/ws" | sed 's/[^a-zA-Z0-9]/-/g';
[ "$status" -ne 0 ]
}
@test "every sandbox suite is in the CI matrix, and runtime.bats is not" {
@test "every suite is run by CI, and the two that need their own job have one" {
# Derived from the directory rather than a list kept here, so adding a
# test file fails this until the workflow is told about it. A suite nobody
# wired into CI runs nowhere, and nothing else would say so.
#
# Two suites are not matrix entries because they cannot be. runtime.bats
# needs a Docker daemon; ci-runner.bats asserts on the runner itself and
# has to run once per tier rather than once per suite. Both are checked
# below for having a job of their own instead - "not in the matrix" must
# not be a way to be in neither.
local wf="$REPO_ROOT/.gitea/workflows/tests.yml"
local f base missing=""
for f in "$REPO_ROOT"/test/*.bats; do
base="$(basename "$f" .bats)"
[ "$base" = "runtime" ] && continue
case "$base" in runtime|ci-runner) continue ;; esac
grep -qE "^ *- *$base\$" "$wf" || missing="$missing $base"
done
[ -z "$missing" ] || {
@@ -2105,6 +2533,13 @@ startup_warning_slug() { printf '%s' "$DEJIMA_T/ws" | sed 's/[^a-zA-Z0-9]/-/g';
# fail every time rather than testing anything.
! grep -qE '^ *- *runtime$' "$wf"
grep -q 'test/runtime.bats' "$wf"
# ci-runner.bats likewise, and once per tier: a single job would leave one
# of the two runners unasserted, which is the thing it exists to catch.
! grep -qE '^ *- *ci-runner$' "$wf"
grep -q 'test/ci-runner.bats' "$wf"
grep -q 'DEJIMA_CI_TIER: sandbox' "$wf"
grep -q 'DEJIMA_CI_TIER: host-docker' "$wf"
}
@test "the agent briefing teaches the project scope, not just global" {
+16 -2
View File
@@ -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.