Compare commits

...
Author SHA1 Message Date
branderson f0513208a1 Stop inviting the isolated mode to be used for untrusted work
The pointer to security.md said it was worth reading "before using this for
anything you would not run against your own checkout", which reads as an
offer the threat model does not make: its stated premise is that the
project is one you already trust, and nothing about this flag changes that.
It narrows what a session can read of the host and what credential it
carries, which is not the same claim.
2026-10-11 01:01:49 -07:00
branderson a6e1de5f30 Keep the credential argument in one place
The explanation of what crosses into an isolated session and why was in
both documents, nearly verbatim. security.md keeps it: why a forge token
is the widest thing a session holds, why core's GitHub credential counts
as one, why the agent and the key get opposite answers, and why this is a
narrower blast radius rather than a stronger sandbox.

configuration.md keeps the table, the two commands, and the two rows that
surprise people - the GitHub token being withheld though it is not a forge
setting, and the key being mounted though the agent is not - then links to
the argument. The division is that configuration.md owns what the mode
does and how to ask for it, security.md owns what boundary it moves.

One duplication stays, in the other direction: security.md says in one
sentence that the conversation log, the epitaph and the audit are off,
where configuration.md has the full version and the issue number. Each
page carries its own argument whole and one sentence of the other's.
2026-10-11 00:34:56 -07:00
branderson 60d6736e37 Narrow the comment above the isolated forge default to the key
It said the ssh key or the forwarded agent is untouched, which was true
when it was written and stopped being true in the same commit: the
workflow this sets pushes nothing, and a workflow that never pushes
already gets no agent socket. Only the key is untouched, and #392 is where
that is being decided.
2026-10-11 00:34:56 -07:00
branderson b36e7c935f Withhold the GitHub token from an isolated session, and get the agent right
Two halves of one question - what credential an isolated launch carries -
which the round-1 change answered for the forge's own token and got wrong
for the other two.

Core fills GH_TOKEN from [github] token_file for every session whatever
forge is selected, so narrowing the forge did not narrow it. Read-only
across an operator's private repositories is still account-wide reach, so
an isolated launch that named no forge no longer gets it. The filter
container still does: that token authenticates dejima's own fetch of
GitHub's address ranges, a request about the allowlist made where no
session can reach it, so the session reads a separate variable. Gated on
whether the command named a forge, captured before the mode's own default
answers for it.

The docs had the ssh agent backwards. A workflow that never pushes gets no
agent socket and local-only is one, so an isolated launch forwards no
agent; a configured key crosses whatever the workflow. They now state the
pair, with a table row each.

The test for it was worse than incomplete. project.bats' launch helper
hardcodes SSH_AUTH_SOCK empty, so no launch it drives could have forwarded
an agent and asserting its absence proved nothing - and a configured key
withdraws the agent independently of the workflow, so even with a socket
the fixture would have shown the right answer for the wrong reason. The
helper now takes LAUNCH_SSH_AUTH_SOCK for the one test about that path,
and that test empties the key file so the push rule is the only thing left
that can withhold the socket, with a pushing workflow as the positive
control.

Withholding the key as well, for any launch whose workflow never pushes,
is #392: wider than this flag and not free.

Refs #392
2026-10-11 00:03:58 -07:00
branderson ebf3b1dfe1 Name the runtime test's second volume so teardown can sweep it
Purpose before the run suffix, like test_container_name: teardown's
filter is anchored on the suffix, so a name ending in anything else is one
it cannot reach if the test is interrupted between creating the volume and
removing it.
2026-10-11 00:03:33 -07:00
branderson 84ed923f85 Reach no forge from an isolated launch unless the command names one
The flag narrows, and the forge credential is the widest thing a session
gets: a token is commonly account-wide rather than per repository, so a
session holding one can read and write everything its operator can.
Carrying that by default into the mode meant for a session nobody is
watching had the host narrowed to nothing and the forge left at full
reach.

So --isolated sets forge to none and workflow to local-only for that
launch. Naming either on the same command line is how an operator asks
for a session that clones and opens a pull request, which is a real use
of this mode. The defaults are applied after the argument loop rather
than inside it, so the two orders agree.

The ssh key and a forwarded agent are untouched. That requirement belongs
to the launch rather than to this flag, so what this removes is the forge
token and the instruction to push, not the ability to reach a host over
ssh. Both documents say so, and a test exists to fail if it stops being
true rather than letting the claim go stale.

The briefing's closing paragraph said work leaves "pushed or left in
~/shared", which is loose when the default workflow pushes nothing.

Also here, because they sit inside the same hunk: the history-volume
assertion in project.bats now says "sets nothing" rather than naming a
volume, which is the previous commit's reasoning applied to the test that
had the same weakness.

And two things the review raised that are not this: the remote-access
registry writes `workspace=isolated`, so `dejima-attach --list` stops
showing `work` - dejima's own scratch path - for every isolated session;
and the launch notice names the forge and workflow it resolved, since
both are now part of what the mode is.
2026-10-10 22:06:41 -07:00
branderson 770e8778be Give the isolated working directory a named volume, reaped like the rest
It was an anonymous volume, asked for with a colonless mount spec, which
left the reaping to whatever `compose run --rm` does with one - a question
the runtime test could not answer, because it exercised plain `docker
run`. The guarantee never depended on it: an anonymous volume is as
unreachable as a named one. What did depend on it is that the volumes do
not pile up, once per launch, and this is the one holding everything the
session cloned or wrote.

So it is a volume declared beside the other four, named for the run and
added to the set cleanup() removes. One rule for all five, and nothing
left resting on an unverified property of compose.

The runtime test now asserts what a confined suite genuinely cannot: that
a volume at that path is writable by the session user, since it is seeded
from the image and a wrong owner would break a session on its first
write, and that a second session's starts empty. It no longer compares
`docker volume ls | wc -l` before and after, which another job on a shared
runner creating a volume in between would have failed. runtime.bats'
teardown sweeps this suite's volumes too, for the run interrupted between
making one and removing it.
2026-10-10 22:06:21 -07:00
branderson ba3615ec4b Keep the shell-history volume pointing at the volume it already was
My own regression from the commit that made these names variable. The
compose project is `dejima` and the key is `dejima-bashhistory`, so an
entry with no `name:` gets `dejima_dejima-bashhistory` - and the default I
wrote was the key alone, a different volume. Every ordinary launch after
this branch merged would have started from an empty history with the real
one orphaned.

The test is rewritten rather than corrected. Pinning the right string
leaves a test that gets edited to match whatever the file says, which is
what the old one did and why it passed. It now derives the expected name
from the compose project and each volume's key, and checks every declared
volume, so a default that merely looks plausible fails and a volume added
later cannot skip the check.
2026-10-10 22:06:06 -07:00
branderson 83f9fcfe7f Document isolated sessions, and where a flag's reasoning lives
A section in configuration.md with the table of what differs, why the
launch directory decides nothing, and what the mode gives up. Two notes
beyond the mechanics: that there is no config key in any spelling, and
that this is a narrower blast radius rather than a stronger sandbox - the
firewall, the credential boundary and the capability drops are unchanged,
and a container escape is exactly as serious.

security.md gets the short version beside the section on the mounted
project being writable, which is the class this removes rather than
bounds.

providers.md lists the briefing placeholder whitelist, which a test holds
to the code, so the two new ones are named there with what they are for.

The narrowing-flags paragraph cut from `dejima --help` lands here in full,
which is what earned the line the flag needed.
2026-10-10 20:26:39 -07:00
branderson a687d1793b Test what an isolated launch hands the container
Driven through the real launcher against a stubbed docker, because every
claim here is about what `compose run` is given and a grep over the source
passes just as well on a line that no longer takes effect.

The one about an attack rather than about tidiness is that a launch from
inside an approved project gets neither its domains nor its dependency
layer, with an ordinary launch from the same directory asserted to get
both - so it cannot pass on a fixture whose approvals were never recorded.

Two independent things in bin/dejima hold that, and the test fails only
when both are reversed. Worth saying because a mutation run looks like a
miss otherwise: reversing either alone still passes, which is what defence
in depth looks like from the outside.

Seventeen other mutations each fail the test named for them. The runtime
one covers the single assumption a confined suite cannot decide - that
Docker reads a colonless mount spec as an anonymous volume, writable by
the session user, with no host path behind it.
2026-10-10 20:26:38 -07:00
branderson 537ab56b04 Stop telling an isolated session things that are not true of it
Four claims in the core preamble an isolated launch makes false: that the
workspace is the real project, that ~/shared is shared with every session
against it, that the harness keeps state on the host, and that the work is
recoverable there afterwards. A session acts on these - one that believes
its transcript outlives the container leaves work for a resume that cannot
happen.

The two paragraphs carrying them become placeholders with a variant each,
which is the mechanism the preamble already uses, so the ordinary briefing
comes out byte-identical apart from two reworded clauses. The harness
fragment, which is what promises the resume, is replaced rather than
dropped: a session told nothing would assume the usual arrangement.

The worktree block's whole rationale is that the bind-mounted project is
the only path backed by host storage, which inverts here. Both worktree
workflows also stated its first bullet identically in their own fragments,
leaving one argument in three places and only two of them substitutable -
it is core's now, so a workflow says where the block goes and nothing
about what it says.

"outside the project directory" became "outside your working directory",
which is true in both modes rather than vacuous in one.
2026-10-10 20:26:25 -07:00
branderson a72ea3dcda Turn off what reads a transcript, and say so at launch
The epitaph, the conversation shipper and the exit-time audit all key on a
workspace that an isolated launch has no host side of. Each would
otherwise run against /dejima/work, find nothing, and look like a feature
that is present.

So all three are skipped, and the launch prints what it is, what is off,
and where its share directory is - whatever [ui] banner says and whether
or not stderr is a terminal, unlike the banner. The share path is the only
route back to what the session hands over, and a log that is quietly empty
is worse than one the operator knows they are not getting.

Making either of the first two work in this mode is #388.

Refs #388
2026-10-10 20:26:25 -07:00
branderson 006a2319e7 Give an isolated launch a share directory of its own
The one thing that outlives such a session, so it has to be a real host
directory - and it must never be the one another session had.

A sibling of the per-project root rather than a slug inside it, because
an isolated session has no project to be keyed by. mktemp -d rather than
a name built from a timestamp: two launches in the same second would
otherwise be handed one directory, which is the single thing this layout
cannot do.

The root joins the guarded stores, so no ordinary launch can name it in
[mounts] readonly and be handed every isolated session's handoff files -
the pile the per-project split removed. The loose-entry notice learns
about it too, or it would report dejima's own directory as something left
behind by hand at every launch.
2026-10-10 20:26:02 -07:00
branderson c9064053ab Mount none of the host's writable state for an isolated launch
What crosses in, and what outlives the container.

The harness state hook is not called. It is what mounts the transcripts
and the per-project memory, and it creates the directory for the
workspace's own slug before mounting it - so skipping the call is also
what stops an isolated launch leaving a directory behind.

Config directories are filtered on the mode the harness already declares.
The read-only ones are the operator's hooks, skills and commands, which no
session can write; the writable ones are shared state every project's
sessions append to (#380). They are not pre-created either, since a
directory nothing mounts needs no mount source.

Both nvim's volumes and the shell history are named for the run. Every
isolated launch has the same working directory, so a slug would give them
all one set - and the history volume is shared by every project even when
the nvim ones are not, which makes it the one surface where an isolated
session would read what was typed in any session before it. What keeps
them unreachable is the name carrying this run's id; the removal in
cleanup() is tidiness on top of that.

The [mounts] lists are dropped with a line naming them, rather than
refused: they are a standing preference about ordinary work, and refusing
would mean an operator with one could never use this flag.

Refs #380
2026-10-10 20:25:50 -07:00
branderson 57317e14ec Add --isolated, a launch with no project behind it
The flag, the working directory it runs in, and the one thing that makes
the directory it was started from irrelevant.

/dejima/work is container-local and outside $HOME on purpose: a few
host-side reads take this path, and ~/work is a path an operator might
plausibly have. The compose file takes the whole mount spec from one
variable because an isolated launch needs one with no colon in it - an
anonymous volume - and a list entry cannot be left empty.

The approval store is not resolved. resolve_project_store walks upwards
from the path it is given, so a launch from $HOME on a machine whose
dotfiles were once approved as a project would otherwise be handed that
project's allowed domains and its dependency layer.

--ro and --rw are refused rather than dropped: a path typed into this
command cannot be quietly ignored. A project path is refused for the same
reason, since nothing afterwards would contradict it.

The --help line is earned rather than taken: the page was at its budget,
so the two-line note on narrowing flags became one and moved to
docs/configuration.md in full.
2026-10-10 20:25:34 -07:00
branderson af6913ebbe Merge pull request 'Wait for what teardown killed before removing its directories' (#385) from remote-teardown into development
Reviewed-on: #385
Reviewed-by: branderson <brad@branderson.io>
2026-10-09 23:13:49 -07:00
branderson e2a7dcd4dd Merge pull request 'Stage only the launching project's entry in ~/.claude.json' (#381) from claude-json-scope into development
Reviewed-on: #381
Reviewed-by: branderson <brad@branderson.io>
2026-10-09 23:13:10 -07:00
branderson a6585dd050 Merge pull request 'Take several domains at once in dejima-allow, capped and confirmed' (#383) from allow-multi into development
Reviewed-on: #383
Reviewed-by: branderson <brad@branderson.io>
2026-10-09 23:08:25 -07:00
branderson b142c31b22 Merge pull request 'The self-check retry that missed its own failure, and deadlines that scale with the load' (#379) from reliability into development
Reviewed-on: #379
Reviewed-by: branderson <brad@branderson.io>
2026-10-09 22:49:53 -07:00
branderson c5b3c5d222 Wait for what teardown killed before removing its directories
remote.bats failed intermittently on `rm: cannot remove ...: Directory not
empty` in its own teardown - a red check on a test that had passed. Seen in CI
on #374 and again locally on a branch touching neither this file nor
dejima-attach, which is what said it was the teardown rather than the test.

`kill` sends a signal; it does not wait. The attach client these tests
background - and the script(1) and tmux processes under it - takes a moment to
go, and `tmux kill-server` likewise returns once the server has been told. In
that window `rm -rf` lists a directory, deletes what it saw, and something
still exiting creates another entry before the rmdir.

So teardown waits for each thing to be gone rather than sleeping or hoping: the
client until its pid stops answering, the server until it stops answering
has-session. The removal then retries as a backstop for anything else that
writes on its way out, and reports what is left rather than failing - a leaked
temp directory costs disk, where a red check on a passing test costs a re-run
and some trust in the suite.

Three consecutive full-gate runs clean, with no occurrence of the message. That
is evidence rather than proof for a race, and the suite's own history says so:
#295 called three clean runs "rarer, not impossible".
2026-10-09 21:51:45 -07:00
branderson 3d2e3d2699 Stage only the launching project's entry in ~/.claude.json
That file keys per-project state by absolute path, and dejima copied the whole
map in. So a session was told the name of every project on the host, a sample
of each one's filenames (exampleFiles), what each had cost and when each last
ran - from inside a sandbox whose whole claim is that it cannot learn what you
do elsewhere (#380).

Narrowed for the reason #104 narrowed the projects MOUNT: the whole map is
every project's. The project's own path and anything under it are kept, because
a session's cwd is routinely a worktree inside the project and Claude Code keys
that separately. Compared as strings, which is meaningful here rather than a
guess about layout: both sides of this mount agree on the path by construction.

A `projects` key holding something other than an object is left alone rather
than narrowed. Narrowing it would error, and the staging treats a jq failure as
fatal - so an odd but parseable file would have become a launch that will not
start, over a key with nothing in it to narrow.

The other three directories #380 names are untouched: scoping backups/, tasks/,
plans/ and sessions/ is a behaviour change for the host side, and this one is
not.
2026-10-09 21:51:45 -07:00
branderson 2d8c86ea44 Take several domains at once, up to twenty, confirmed before any is added
An agent that needs a toolchain's hosts asks for them together, and this took
one domain per command - so the operator ran it four times for four hosts, or
the agent asked four times.

Up to twenty now. One still goes through as it always did: that is a decision
the operator made by typing it, and a prompt on every ordinary use is a prompt
nobody reads. More than one is printed back one per line and confirmed first,
defaulting to no.

The cap and the confirmation are the same point. A list is where an unwanted
name rides along unread, the agent that suggested the command is what might
have put it there, and neither mechanism helps if the list is too long to
finish reading - so twenty, which is enough for the real case and short enough
to take in at once.

Every name is validated before any of them is shown, and one bad entry refuses
the whole command. The list being confirmed has to be the list that gets added,
or the operator approves names and a prefix of them lands; and the printout can
only be read if no entry carries a carriage return or an escape sequence that
rewrites the lines above it, which the pattern already forbids. All the invalid
ones are named, because fixing a list of ten should not mean running it ten
times to find the second mistake.

Confirming needs a terminal, so a script does them one at a time - which is
what it did before and still does for one. Repeats collapse, case-folded, so
the count the operator reads is honest.

$domain is only set inside the per-domain loops now, so the container picker's
prompt takes a label for the whole request instead. Under set -u that one
reference was fatal rather than merely wrong, which is how it was found.

The help page stayed within its thirty lines by earning them: the picker is
described once under the scope that finds it rather than twice, and two
paragraphs that both said a scope flag is an approval for later became one.
2026-10-09 21:51:45 -07:00
branderson 503addfc8b Scale a bounded wait by the concurrency the run asked for
The suites wait on things that take as long as the machine makes them take - a
pty reaching raw mode, a shipper polling, a prompt being answered - and each
wait has a deadline. One that is right on an idle box is wrong under the gate,
which runs every suite at once, and raising it until both pass means picking a
number that is wrong in one of them. #296 reduced that by budgeting the
concurrency; it did not remove it, and the same mistake arrived in a new
fixture three days later, where an idle bound went from sixty seconds to two
hundred and forty.

So each runner says how much concurrency it asked the machine for -
run-confined.sh the budget it divided among the suites, the CI job what it
passes bats - and the fixtures multiply their bounds by how oversubscribed that
makes it. Neither number is a guess about load: it is what that runner asked
for.

Nothing gets shorter than it is today. The multiplier floors at 1, so an idle
run and a run with the variable unset behave exactly as before, and it is
capped because a wrong value here turns a test that should fail in seconds into
one that hangs for the length of the job.
2026-10-09 21:51:45 -07:00
branderson a279460318 Make two tests look at what they are named for
"The permitted set cannot be amended from the session container" checked only
the exit status, so an amendment that landed and then failed on something later
would have passed it - non-zero, with the destination permitted. It now re-reads
what a permit for a name actually writes: the entries file and the resolver
configuration. Not the address sets, which only ever hold addresses, so
grepping those for a hostname is a check that cannot fail.

The negative-control loop in the self-check test iterated the output of a grep,
so renaming those helpers would have left it asserting that none of zero lines
is retried. It counts them.
2026-10-09 21:51:45 -07:00
branderson 76a5c5202e Retry the lookup failure the retry was written for
resolve_one returned 0 when dig itself failed, so retry_positive saw an answer
on the first attempt, printed no retry line, and the caller died on an empty
result. A resolver a moment behind the container is exactly that case - dig
fails rather than answering empty - so the transient the retry exists to absorb
was the one it did not cover (#345).

It returns non-zero for both ways of having no address now. Negative checks
read it the same way either way: they want non-zero, and a lookup that did not
come back is not a name that resolved.

The test guarding this was satisfied by the `return 1` already in that function
for the empty-answer case, so it passed while the property was false. It drives
resolve_one against a stubbed dig instead - failing, empty, and answering - and
the third case matters as much as the first two: a lookup that reported failure
on a good answer would spin the retry on every name and fail the boot on all of
them.
2026-10-09 21:51:45 -07:00
branderson 60a319b3ba Merge pull request 'Give the resolver checks a permitted name when the meta URL has no host' (#386) from egress-subject into development
Reviewed-on: #386
Reviewed-by: branderson <brad@branderson.io>
2026-10-09 21:49:44 -07:00
branderson c0bc131fd4 Read the workflow for the sh -n sweep without PyYAML
The sweep added in #382 parsed .gitea/workflows/tests.yml with PyYAML,
which is not in the test image: in CI the test died with
ModuleNotFoundError having checked nothing, and failed confined (static).

It is an indentation scan over the two shapes the file uses now, with
nothing imported from outside the standard library. Bodies come from the
file as written rather than from the copy keys are normalised in, so a
shell line beginning with a dash is not rewritten on its way to sh -n,
and a job setting defaults: is refused rather than guessed at.

A hand-rolled scan that meets a shape it was not written for finds
nothing and reports no failures, so the count it read is checked against
a plain grep of the run: keys in the file.
2026-10-09 21:43:48 -07:00
branderson 00a2df3b0c Give the resolver checks a permitted name when the meta URL has no host
tests / confined (audit) (pull_request_target) Successful in 3m55s
tests / confined (config) (pull_request_target) Successful in 5m21s
tests / confined (conversation) (pull_request_target) Successful in 1m17s
tests / confined (launch) (pull_request_target) Successful in 2m32s
tests / confined (project) (pull_request_target) Successful in 3m37s
tests / confined (providers) (pull_request_target) Successful in 2m16s
tests / confined (proxy) (pull_request_target) Successful in 1m29s
tests / confined (remote) (pull_request_target) Successful in 3m9s
tests / confined (share) (pull_request_target) Successful in 2m17s
tests / confined (shellcheck) (pull_request_target) Successful in 1m35s
tests / runner-env (confined) (pull_request_target) Successful in 1m5s
tests / confined (static) (pull_request_target) Failing after 2m45s
tests / privileged (pull_request_target) Successful in 7m53s
tests / runner-env (privileged) (pull_request_target) Successful in 1m2s
The cached-ranges fixture points EGRESS_FORGE_META_URL at a file:// path, and a
file URL has no host - so checks 3 and 4, which resolve "the meta URL's host",
were handed an empty string. Check 3 asked the resolver to look up "" and
failed the container's boot on it, which took down every privileged run as soon
as the cache started working.

The property those checks state is about the policy: that a permitted name
resolves through the local resolver, and that its address lands in that name's
own set. The meta URL's host was an instance of such a name, not the point of
it - so the subject falls back to a permitted forge-class name, which keeps
checks 3, 4 and 6 doing exactly what they claim. Forge-class specifically,
because check 4 looks the answer up in the forge class's own set and check 6
asserts port 22 is refused on it.

Nothing at all to check is a refusal naming that, rather than a lookup of "".

Check 5 is skipped for a file source, and says so in the log. It asserts that a
permitted destination is reachable THROUGH the policy, and a local file is not
one: reading it again would pass while proving nothing. That is a real
reduction on the runs that use the cache, so it is a line an operator can see
rather than a silent pass - restoring it means choosing a permitted destination
cheap enough to request once per container, which is a separate decision.
2026-10-09 20:54:18 -07:00
branderson e7769bb3d6 Merge pull request 'Write the ranges-cache step in the shell the runner actually gives it' (#382) from ci-sh-fix into development
Reviewed-on: #382
Reviewed-by: branderson <brad@branderson.io>
2026-10-09 18:25:43 -07:00
branderson e346ad3ae2 Write the ranges-cache step in the shell the runner actually gives it
tests / privileged (pull_request_target) Failing after 1m7s
tests / runner-env (privileged) (pull_request_target) Successful in 58s
tests / confined (conversation) (pull_request_target) Successful in 1m52s
tests / confined (proxy) (pull_request_target) Successful in 2m6s
tests / confined (providers) (pull_request_target) Successful in 3m7s
tests / confined (launch) (pull_request_target) Successful in 3m29s
tests / confined (share) (pull_request_target) Successful in 2m43s
tests / runner-env (confined) (pull_request_target) Successful in 1m11s
tests / confined (shellcheck) (pull_request_target) Successful in 1m41s
tests / confined (remote) (pull_request_target) Successful in 4m16s
tests / confined (project) (pull_request_target) Successful in 4m47s
tests / confined (audit) (pull_request_target) Successful in 5m11s
tests / confined (static) (pull_request_target) Failing after 4m12s
tests / confined (config) (pull_request_target) Successful in 6m34s
The step used a bash array. The runner's default shell is `sh -e`, where that
is a syntax error - so the whole step died before any of it ran, and the
privileged job failed on every pull request from the moment #378 merged.

Rewritten as POSIX sh: two curl branches rather than one with a built-up
argument list, and the ranges read once into a variable so the success test and
the count are the same answer. Driven under real dash in all four paths - a
good fetch, a failed fetch, a 200 carrying a rate-limit message instead of
ranges, and no token - with the cache written only in the first.

The sweep that would have caught it goes in beside it: every run: block in the
workflow that does not name its own shell has to parse under `sh -n`. A YAML
parse says only that the string is a string, and nothing local runs the
workflow, so this reached CI green from a machine where the gate passed.
2026-10-09 17:44:17 -07:00
branderson 3f5dae88b5 Merge pull request 'Fetch the forge ranges once per run, and wait out a rate limit when one happens' (#378) from meta-cache into development
Reviewed-on: #378
Reviewed-by: branderson <brad@branderson.io>
2026-10-09 14:15:22 -07:00
branderson 5569e8d2fe Put these tests beside their relatives, not at the end of the file
tests / confined (conversation) (pull_request_target) Successful in 1m52s
tests / confined (proxy) (pull_request_target) Successful in 1m58s
tests / confined (providers) (pull_request_target) Successful in 2m45s
tests / confined (launch) (pull_request_target) Successful in 3m7s
tests / confined (share) (pull_request_target) Successful in 2m17s
tests / runner-env (confined) (pull_request_target) Successful in 1m5s
tests / confined (remote) (pull_request_target) Successful in 3m40s
tests / confined (shellcheck) (pull_request_target) Successful in 1m34s
tests / confined (project) (pull_request_target) Successful in 4m6s
tests / confined (audit) (pull_request_target) Successful in 4m28s
tests / confined (static) (pull_request_target) Successful in 3m34s
tests / confined (config) (pull_request_target) Successful in 5m51s
tests / privileged (pull_request_target) Successful in 12m11s
tests / runner-env (privileged) (pull_request_target) Successful in 1m0s
Appending to test/static.bats is why four branches in a row have had to resolve
a conflict that was only ever about position. The fetch tests go with the rest
of the fetch tests, and the one that reads the workflow file goes next to the
other one that reads it - which is a better home for both and one nothing else
is competing for.
2026-10-09 13:38:36 -07:00
branderson e900c51e20 Say in docs/ci.md why the ranges are fetched once per run
The secondary rate limit is the part that surprises: it is not the hourly
ceiling a token raises, so a valid credential does not prevent it, and the
failure arrives as one container failing to boot rather than as anything about
the API. It sits beside the note on the token, which is where a reader meets
the endpoint.
2026-10-09 13:37:29 -07:00
branderson e91b5771f6 Fetch the forge ranges once per CI run instead of once per container
test/runtime.bats starts twenty-seven container pairs, and every filter fetched
GitHub's /meta for itself - roughly twenty-seven requests in five minutes. That
is well inside the hourly ceiling a token buys, and exactly the burst the
endpoint's SECONDARY limit answers with a 403, which takes one container's boot
down and the job with it (#357).

The privileged job now fetches once into the directory it already shares with
the containers it starts, and runtime.bats points every filter at the result.
One request cannot be a burst.

Best-effort on both sides. The job writes the file only once it parses and
carries ranges, because a rate-limit body is a 200 with a message and no ranges
and a cached one would fail every container instead of one; and the suite falls
back to per-container fetches when the file is absent, which is what happens
outside CI. The worst case is the behaviour this replaces, and each side says
which of the two the run is getting.

egress-policy refuses a file:// source unless SANDBOX_TEST_MODE is set. Those
ranges decide which addresses the forge class reaches, so a launch that could
name its own source could name its own ranges - and that variable is set by
this suite and by nothing in bin/dejima. The mount is read-only and goes to the
filter alone; the session half has no business holding a path that names them.
2026-10-09 13:37:29 -07:00
branderson 3b17604178 Wait as long as a rate limit asks, and say which failure it was
Five attempts over twenty seconds is short for a limit measured in tens of
seconds: the whole sequence could sit inside one window and exhaust itself
without ever reaching the other side (#357).

A rate limit is now recognised from the status - 403 or 429 - or from the body,
because the endpoint will report one with a message and no ranges over a 200.
Those back off at attempt*15 rather than attempt*2, so three attempts cover 45
seconds instead of six. A connection failure keeps the short backoff, because no
wait fixes it.

Retry-After beats both, being the only number here that is not an estimate, and
only in its numeric form: the header may carry an HTTP date instead, and parsing
one to compare against a clock is a wrong answer away from a sleep nobody chose.
A cap stops a value nobody expected turning a boot into a hang.

And the message names the failure. An HTTP error from this endpoint carries a
body saying why - "API rate limit exceeded" rather than "curl exit 0, http 403"
- which was being discarded in favour of curl's stderr, where there is nothing.
curl's own message is what is left when no response carried a body.
2026-10-09 13:37:29 -07:00
branderson 6511a0537c Merge pull request 'Ctrl-Z does not pause the agent, and eleven other documented facts that had drifted' (#377) from review-docs into development
Reviewed-on: #377
Reviewed-by: branderson <brad@branderson.io>
2026-10-09 13:34:20 -07:00
branderson 023efb3171 Merge pull request 'The suite map's blind spot, a test that only skipped, and three cleanups' (#376) from review-tests into development
Reviewed-on: #376
Reviewed-by: branderson <brad@branderson.io>
2026-10-08 17:01:28 -07:00
branderson 23462a3c71 Merge pull request 'The host ssh-agent, a half-landed permit, and four things around them' (#375) from review-sec into development
Reviewed-on: #375
Reviewed-by: branderson <brad@branderson.io>
2026-10-08 16:54:42 -07:00
branderson 2ddcf964c3 Start the log shipper again, and catch the shape that stopped it
tests / confined (audit) (pull_request_target) Successful in 3m57s
tests / confined (config) (pull_request_target) Successful in 5m20s
tests / confined (conversation) (pull_request_target) Successful in 1m18s
tests / confined (launch) (pull_request_target) Successful in 2m31s
tests / confined (project) (pull_request_target) Successful in 3m36s
tests / confined (providers) (pull_request_target) Successful in 2m15s
tests / confined (proxy) (pull_request_target) Successful in 1m27s
tests / confined (remote) (pull_request_target) Successful in 3m8s
tests / confined (share) (pull_request_target) Successful in 2m14s
tests / confined (static) (pull_request_target) Successful in 3m32s
tests / confined (shellcheck) (pull_request_target) Successful in 1m34s
tests / runner-env (confined) (pull_request_target) Successful in 1m6s
tests / runner-env (privileged) (pull_request_target) Successful in 1m0s
tests / privileged (pull_request_target) Successful in 6m46s
`env -u GH_TOKEN -u EGRESS_FORGE_META_AUTH drop_caps ...` cannot run: drop_caps
is a shell function from caps-lib.sh and env can only exec a binary, so the line
was exit 127. The `&` kept that from set -e, so the filter container came up
reporting ready with no shipper and Loki logging went dead without a word.

The prefix is gone. What covers the shipper is order, which is what the test now
asserts: both names are out of this script's environment before the line that
starts it, the raw token before the policy run and the header straight after it.

And the error class has its own test, because the one I wrote only grepped the
start line for the prefix's text - it would have passed on a line that could not
execute. It flattens continuations first, since the prefix was written across two
lines and a per-line grep did not see it, and drops comments after that, or the
line explaining why there is no prefix matches itself. Both spellings verified
to fail it.

runtime.bats already asserts the shipper is running when LOKI_URL is set, which
is the check that would have caught this in the privileged job.
2026-10-08 00:24:05 -07:00
branderson faeafa7f2d Merge pull request 'Three forms that walked past the push and api guards' (#374) from guard-boundary into development
Reviewed-on: #374
Reviewed-by: branderson <brad@branderson.io>
2026-10-08 00:18:26 -07:00
branderson 2cc6057c88 Reflow the two paragraphs this branch left long
tests / confined (audit) (pull_request_target) Successful in 4m53s
tests / confined (config) (pull_request_target) Successful in 6m29s
tests / confined (conversation) (pull_request_target) Successful in 1m21s
tests / confined (launch) (pull_request_target) Successful in 2m36s
tests / confined (project) (pull_request_target) Successful in 4m1s
tests / confined (providers) (pull_request_target) Successful in 2m21s
tests / confined (proxy) (pull_request_target) Successful in 1m35s
tests / confined (remote) (pull_request_target) Successful in 3m0s
tests / confined (share) (pull_request_target) Successful in 2m16s
tests / confined (static) (pull_request_target) Successful in 3m19s
tests / confined (shellcheck) (pull_request_target) Successful in 1m36s
tests / runner-env (confined) (pull_request_target) Successful in 1m1s
tests / runner-env (privileged) (pull_request_target) Successful in 59s
tests / privileged (pull_request_target) Successful in 6m47s
Both were edited in place without rewrapping, so a line in each ran past the
column the rest of the file keeps to. The link stays on one line rather than
being wrapped mid-label.
2026-10-07 23:15:05 -07:00
branderson 1d8725f089 Move the CI-install test beside the other one that reads the workflow
tests / confined (audit) (pull_request_target) Successful in 4m48s
tests / confined (config) (pull_request_target) Successful in 9m30s
tests / confined (conversation) (pull_request_target) Successful in 1m21s
tests / confined (launch) (pull_request_target) Successful in 2m43s
tests / confined (project) (pull_request_target) Successful in 4m2s
tests / confined (providers) (pull_request_target) Successful in 2m23s
tests / confined (proxy) (pull_request_target) Successful in 1m29s
tests / confined (remote) (pull_request_target) Successful in 3m5s
tests / confined (share) (pull_request_target) Successful in 2m13s
tests / confined (static) (pull_request_target) Successful in 4m55s
tests / confined (shellcheck) (pull_request_target) Successful in 1m38s
tests / runner-env (confined) (pull_request_target) Successful in 1m6s
tests / runner-env (privileged) (pull_request_target) Successful in 57s
tests / privileged (pull_request_target) Successful in 6m43s
Three branches appended to the end of test/static.bats, so whichever merged
last had to resolve a conflict that was only ever about position. This one
belongs next to the test that reads the same workflow file for the suite matrix,
which is both a better home and one nothing else is competing for.

Also drops a count that drifts from the suite map's docs rule, and a line in the
comment-stripping block that described doing it the other way.
2026-10-07 23:14:26 -07:00
branderson aa40e0c455 State what the spellings have in common, not what one used to do
tests / confined (conversation) (pull_request_target) Successful in 1m12s
tests / confined (proxy) (pull_request_target) Successful in 1m27s
tests / confined (providers) (pull_request_target) Successful in 2m11s
tests / confined (launch) (pull_request_target) Successful in 2m31s
tests / confined (share) (pull_request_target) Successful in 2m16s
tests / runner-env (confined) (pull_request_target) Successful in 1m7s
tests / confined (remote) (pull_request_target) Successful in 3m8s
tests / confined (shellcheck) (pull_request_target) Successful in 1m37s
tests / confined (project) (pull_request_target) Successful in 3m44s
tests / confined (audit) (pull_request_target) Successful in 4m31s
tests / confined (static) (pull_request_target) Successful in 4m49s
tests / privileged (pull_request_target) Successful in 6m18s
tests / runner-env (privileged) (pull_request_target) Successful in 58s
tests / confined (config) (pull_request_target) Successful in 9m33s
The comment above the refused forms explained the `+` case by what the guard
used to compare. What a reader needs is why every form in the list names the
same branch, which is the two rules the loop exists to cover.
2026-10-07 23:11:57 -07:00
branderson d180581293 Keep the credential from the one daemon the policy does not start
tests / confined (conversation) (pull_request_target) Successful in 1m20s
tests / confined (providers) (pull_request_target) Failing after 42s
tests / confined (proxy) (pull_request_target) Successful in 1m33s
tests / confined (launch) (pull_request_target) Successful in 2m36s
tests / confined (shellcheck) (pull_request_target) Successful in 1m35s
tests / confined (share) (pull_request_target) Successful in 2m14s
tests / confined (audit) (pull_request_target) Successful in 4m46s
tests / confined (remote) (pull_request_target) Successful in 3m14s
tests / confined (project) (pull_request_target) Successful in 3m58s
tests / runner-env (confined) (pull_request_target) Successful in 1m5s
tests / confined (static) (pull_request_target) Successful in 4m50s
tests / confined (config) (pull_request_target) Successful in 9m50s
tests / privileged (pull_request_target) Failing after 6m37s
tests / runner-env (privileged) (pull_request_target) Successful in 57s
log-shipper.sh is started by firewall-up.sh rather than by egress-policy, so
NO_CREDENTIAL never reached it. The unset above it took the raw token away and
left EGRESS_FORGE_META_AUTH exported - and that header carries the same token
in another wrapper, so the shipper held the credential after all.

It now starts under the same `env -u`, and the header is dropped as soon as the
policy run that needed it returns. The raw token still goes before that run,
which is why the test asserts one on each side of it.
2026-10-07 23:11:27 -07:00
branderson f61795381d Stop the README's Ctrl-Z line contradicting the sentence after it
tests / confined (audit) (pull_request_target) Successful in 4m48s
tests / confined (config) (pull_request_target) Successful in 8m56s
tests / confined (conversation) (pull_request_target) Successful in 1m20s
tests / confined (launch) (pull_request_target) Successful in 2m35s
tests / confined (project) (pull_request_target) Successful in 4m0s
tests / confined (providers) (pull_request_target) Successful in 2m18s
tests / confined (proxy) (pull_request_target) Successful in 1m30s
tests / confined (remote) (pull_request_target) Successful in 2m58s
tests / confined (share) (pull_request_target) Successful in 2m12s
tests / confined (shellcheck) (pull_request_target) Successful in 1m36s
tests / privileged (pull_request_target) Successful in 6m31s
tests / runner-env (confined) (pull_request_target) Successful in 1m7s
tests / runner-env (privileged) (pull_request_target) Successful in 1m0s
tests / confined (static) (pull_request_target) Successful in 2m39s
"Backgrounds all of dejima" and "the container keeps running" cannot both be
read as written. The host side is what backgrounds, which is also the
distinction the paragraph exists to draw.

And docs/configuration.md said "this script", which was lifted from
dejima-tty-suspend's own header and has no referent in a document.
2026-10-07 20:45:06 -07:00
branderson 805d9a2c35 Say what the two runner assertions do and do not establish
tests / confined (conversation) (pull_request_target) Successful in 1m18s
tests / confined (launch) (pull_request_target) Successful in 2m27s
tests / confined (project) (pull_request_target) Successful in 3m54s
tests / confined (providers) (pull_request_target) Successful in 2m16s
tests / confined (proxy) (pull_request_target) Successful in 1m33s
tests / confined (remote) (pull_request_target) Successful in 3m15s
tests / confined (share) (pull_request_target) Successful in 2m13s
tests / confined (static) (pull_request_target) Successful in 3m24s
tests / confined (shellcheck) (pull_request_target) Successful in 1m33s
tests / runner-env (confined) (pull_request_target) Successful in 1m0s
tests / runner-env (privileged) (pull_request_target) Successful in 1m2s
tests / confined (audit) (pull_request_target) Successful in 4m33s
tests / confined (config) (pull_request_target) Successful in 5m58s
tests / privileged (pull_request_target) Successful in 6m42s
The comment left where a test used to be described the test. What a reader
needs is the property: each assertion is one job's answer, that the two jobs
land on different runners follows from neither, and a suite cannot see it
because it runs in one job.

The suite map's docs rule gains the same kind of note - a doc-only edit now
runs four suites rather than one, which is the cost of answering for what reads
a file and the direction to err in.
2026-10-07 20:44:48 -07:00
branderson 5884930592 Strip each suite's comments once instead of once per tracked file
The widened walk did a grep pass over every suite for every tracked file, which
took about three times as long as the narrow version it replaced. .dejima.md
warns what a slow test does to a bounded deadline under load, and static.bats
is the long pole in both lanes.

The comment-stripped copies are made once, before the loop. About 14s against
about 32s, for the same answers - verified by reverting the test/*.bats rule and
watching it name the two suites that miss static.

The header described the behaviour before the comment exclusion went in, so it
now says what counts as reading a file is defined below and is narrower than
mentioning it.
2026-10-07 20:44:48 -07:00
branderson 452d49394f State the --repo rule as a property rather than as a correction
tests / confined (audit) (pull_request_target) Successful in 4m31s
tests / confined (config) (pull_request_target) Successful in 9m24s
tests / confined (conversation) (pull_request_target) Successful in 1m12s
tests / confined (launch) (pull_request_target) Successful in 2m29s
tests / confined (project) (pull_request_target) Successful in 3m42s
tests / confined (providers) (pull_request_target) Successful in 2m12s
tests / confined (proxy) (pull_request_target) Successful in 1m28s
tests / confined (share) (pull_request_target) Successful in 2m12s
tests / confined (static) (pull_request_target) Successful in 4m43s
tests / confined (shellcheck) (pull_request_target) Successful in 1m31s
tests / privileged (pull_request_target) Successful in 6m44s
tests / runner-env (confined) (pull_request_target) Successful in 1m4s
tests / runner-env (privileged) (pull_request_target) Successful in 57s
tests / confined (remote) (pull_request_target) Successful in 1m46s
The comment explained git's precedence and then described the reading that had
it backwards. A reader debugging this line needs the rule and what it decides
about an invocation, which is what it says now.
2026-10-07 20:43:00 -07:00
branderson 1cda465898 Read a destination the way git resolves it, not only its full spelling
A refspec destination that does not start with refs/ is matched against the
remote's refs by git's rev-parse rules, so `heads/main` and `main` both name
refs/heads/main. Only the full prefix was stripped, which left
`feature:heads/main` naming a branch no policy entry matched - and
`+feature:heads/main` with it.

Both prefixes come off now, in that order. Every strip has to leave an
unprotected destination alone, which the test asserts for each of them: a
stripping that refuses is not a policy that refuses.
2026-10-07 20:42:59 -07:00
branderson cacb74fb28 Refuse a graphql call whose body is not in the command
--input takes the whole request body from a file, or from stdin with `-`, so
the document is not in argv at any spelling: not as a field value the mutation
check can read, and not in the `=@` shape the file-indirection check catches.
The positional graphql beside it still counted as a readable target, which made
this the most direct spelling left on the path #365 closed for inline bodies.

Refused under the declared subcommand only, in both the separate-value and
attached forms. Outside it, --input is a flag this has nothing to say about,
which the test asserts - otherwise the refusal is about the word rather than
about the body.
2026-10-07 20:42:42 -07:00
branderson 4a9e789ba1 Drop a test call that asserted nothing, and two comments that narrate history
tests / confined (conversation) (pull_request_target) Successful in 1m19s
tests / confined (launch) (pull_request_target) Successful in 2m33s
tests / confined (proxy) (pull_request_target) Successful in 1m33s
tests / confined (providers) (pull_request_target) Successful in 2m17s
tests / confined (shellcheck) (pull_request_target) Successful in 1m34s
tests / confined (audit) (pull_request_target) Successful in 4m41s
tests / confined (project) (pull_request_target) Successful in 3m54s
tests / confined (share) (pull_request_target) Successful in 2m10s
tests / runner-env (confined) (pull_request_target) Successful in 1m3s
tests / confined (remote) (pull_request_target) Successful in 3m1s
tests / confined (static) (pull_request_target) Successful in 4m52s
tests / confined (config) (pull_request_target) Successful in 9m36s
tests / privileged (pull_request_target) Successful in 6m32s
tests / runner-env (privileged) (pull_request_target) Successful in 58s
The empty-key test ran a launch with no key and never looked at the result; the
direction it was standing in for is its own test above it.

permit_address's comment said what the code used to leave standing. It now
states the property - the two adds are not one transaction - and says why the
unconditional delete is the safe direction when the element came from
somewhere else: the record has already rolled back, so a narrower live set
agrees with what `entries` reports.
2026-10-07 20:39:33 -07:00
branderson 9b96d5781b Document that a configured key withdraws the forwarded agent
docs/configuration.md said a key means a forwarded agent "is no longer required
at all", which is now an understatement: it is not mounted. An operator who
also relied on the agent inside the session - a second remote, a private
submodule over ssh - loses it, and that belongs where they choose the key
rather than in a commit message.

docs/security.md says the same in the two places it describes the key as an
alternative to the agent, because "alternative" now means the other one is
closed.
2026-10-07 20:39:33 -07:00
branderson bac3d45814 Scrub the credential where each daemon starts, not upstream of one call path
The unset in firewall-up.sh only covered `apply`. bin/dejima starts the filter
container with -e GH_TOKEN, so the token is in that container's configured
environment - and `dejima-allow` reaches the policy through `docker exec`,
which gets that environment. permit_name and rollback_entries both restart the
resolver, so the first approval of a name put the credential back into dnsmasq.
The ordering test could not see it, because it only read the apply path.

All three daemon starts now carry `env -u GH_TOKEN -u EGRESS_FORGE_META_AUTH`,
so the property holds wherever they were reached from and is visible on the
line that starts them. The post-fetch unset stays, but is no longer the only
thing standing there.

The test asserts the start lines rather than their order, which is what makes
it independent of the call path, and counts them so a daemon dropped from the
sweep is not a daemon that passed it.
2026-10-07 20:39:33 -07:00
branderson fa29e2ac00 Correct eleven documented facts that had drifted from the code
tests / confined (conversation) (pull_request_target) Successful in 1m11s
tests / confined (proxy) (pull_request_target) Successful in 1m24s
tests / confined (providers) (pull_request_target) Successful in 2m11s
tests / confined (share) (pull_request_target) Successful in 2m13s
tests / runner-env (confined) (pull_request_target) Successful in 1m5s
tests / confined (shellcheck) (pull_request_target) Successful in 1m33s
tests / confined (static) (pull_request_target) Successful in 3m31s
tests / confined (launch) (pull_request_target) Successful in 2m27s
tests / confined (remote) (pull_request_target) Successful in 3m5s
tests / confined (project) (pull_request_target) Successful in 3m32s
tests / confined (audit) (pull_request_target) Successful in 3m53s
tests / confined (config) (pull_request_target) Successful in 5m13s
tests / privileged (pull_request_target) Successful in 6m7s
tests / runner-env (privileged) (pull_request_target) Successful in 1m2s
Each was verified against what ships rather than taken from the report:

docs/development.md said a harness manifest declares eleven keys; the schema
has eighteen. The count is gone rather than corrected, since nothing checks it
and it was wrong within a release. The same file's no-Docker suite list omitted
launch.bats and share.bats and put ci-runner.bats among the suites that run
anywhere, when it needs a real runner of either tier; the list now says
run-confined.sh discovers the set, which is why one is not kept there. It also
listed bin/dejima-color-lib.sh twice, with two different descriptions.

docs/configuration.md described the push guard as refusing a remote that is not
the Gitea host. It reads DEJIMA_PUSH_HOSTS and DEJIMA_BLOCKED_BRANCHES, both
resolved from the selected forge and workflow, so it is the same rule whichever
forge is in use.

docs/ci.md said none of the runner posture is checked by anything.
ci-runner.bats checks what a job can see from where it stands - which tier it
landed on, the docker socket in one tier and not the other, the egress rules in
front of the confined job. What it cannot see is the runner's own configuration
file, and that is now what the bullet says.

docs/security.md cited a README section that no longer exists for the forge
token's scopes, and pointed Loki readers at the README's Prerequisites, which
say nothing about logging.

install.sh wrote a config header describing the every-key-commented format it
retired - twelve lines below a comment explaining that defaults.conf is the
complete reference now and this file holds only what the operator decided.

README.md omitted jq from its prerequisites, which install.sh hard-requires and
exits over.

docs/remote-access.md named /run/dejima-notify as the marker root, while its
own diagram twenty lines up has it right: ${XDG_RUNTIME_DIR}/dejima/notify. It
also attributed a "scriptable" claim to the README; it lives in
dejima-attach's own help.

The audit's time-window caveat was stated twice in consecutive sentences in the
README; the one that stays is the one static.bats pins.

The report also called the README's GitHub-push rationale a duplicate of
docs/security.md's, under the "one place" rule. It is not, and the test named
for it says why: the README has to name both properties the port-22 claim rests
on, because the claim stays readable only if a reader can see what would have
to stop being true. Left as it was.
2026-10-07 19:36:31 -07:00
branderson b3f8252c73 Stop two documents saying Ctrl-Z pauses the agent
bin/dejima-tty-suspend's own header says what happens: everything host-side is
in one process group - the script, the container run, the credential proxy, the
conversation shipper - so the kernel stops and continues all of it together.
The container is not in that group. It keeps running, with its mounts, its
firewall grants and its forge token.

docs/security.md says so. The README said "it makes no progress while
suspended" and docs/configuration.md said "a suspended session is stopped, so
it makes no progress until you resume it" - and the operator most likely to
reach for Ctrl-Z is one uneasy about what the agent is doing, which is the
reader those two sentences were worst for.
2026-10-07 19:21:01 -07:00
branderson 720b91a569 Say the resolver-check rationale once per step rather than twice
tests / confined (conversation) (pull_request_target) Successful in 1m16s
tests / confined (proxy) (pull_request_target) Successful in 1m27s
tests / confined (providers) (pull_request_target) Successful in 2m14s
tests / confined (launch) (pull_request_target) Successful in 2m35s
tests / confined (share) (pull_request_target) Successful in 2m15s
tests / runner-env (confined) (pull_request_target) Successful in 1m7s
tests / confined (remote) (pull_request_target) Successful in 3m10s
tests / confined (shellcheck) (pull_request_target) Successful in 1m34s
tests / confined (project) (pull_request_target) Successful in 3m38s
tests / confined (audit) (pull_request_target) Successful in 3m58s
tests / confined (static) (pull_request_target) Successful in 3m40s
tests / confined (config) (pull_request_target) Successful in 5m21s
tests / privileged (pull_request_target) Successful in 6m37s
tests / runner-env (privileged) (pull_request_target) Successful in 56s
The paragraph explaining why the lookup runs before apt was pasted twice inside
each of the four steps that do it. The second copy carried the retry reasoning
and the first carried one detail the second lacked, so the kept paragraph is the
longer one with that detail folded in.
2026-10-07 19:01:46 -07:00
branderson 3086bb884e Stop a test claiming a property one job cannot see
"The two runners are not the same runner" ran in one job, so the branch it took
was whichever of the two assertions above it already applies - one job's answer
restated, under a name claiming both. Comparing the two jobs' results is the
workflow's job, not a suite's, and the comment left in its place says so.

Both directions stay covered, one per job: the confined runner must not have the
docker socket and the privileged one must.
2026-10-07 19:01:46 -07:00
branderson 02f5dd8b19 Make the release pass read its head argument instead of the working tree
The rollback scan diffed $base against the working tree while reporting on
$base...$head, so an explicit head reported on whatever happened to be checked
out. Driven against two refs with base equal to head, the old form named five
providers as added; the new one correctly names none.

All three sites now read the head side through git: the config schema with
`git show`, and the provider directories with `git ls-tree` rather than `find`.
2026-10-07 19:01:46 -07:00
branderson 990ab94a40 Install the tool the port-collision test needs, so it stops skipping in CI
remote.bats skips the dejima-ttyd port-collision refusal without `ss`, and the
confined job installed no iproute2 - so the one check guarding a reverse-proxy
port takeover was a permanent `ok # skip` on every run, which is the same as
not having it.

The test pins both halves: the install line in that job, and that remote.bats
still keys on the tool - otherwise this is an install nothing needs.
2026-10-07 18:50:44 -07:00
branderson 940f632447 Let the suite map's own test see the paths it was rotting under
The meta-test iterated bin/, hooks/, web/, systemd/ and install.sh, so the map
could go narrow under test/, forges/, harnesses/, workflows/, tools/, .dejima/
and docs/ with nothing watching. It now walks every tracked file, and the three
gaps that were there are closed:

A change to test/runtime.bats selected zero confined suites, while static.bats
carries invariants over that file's text - so the --changed lane exited green
having run nothing. A suite file is read as well as run, and now selects static
beside itself.

test/fixtures/* selected proxy and static, but config.bats executes
answer-prompt.py to drive its prompts.

forges/, workflows/ and harnesses/ selected providers and static, while the
audit reads a workflow's briefing, conversation.bats sed-injects a harness.conf
and config.bats copies the whole set in.

The docs rule also moved below the test/ and .dejima/ rules, where it belongs:
a .md under either of those is read by whatever reads that directory, and the
pattern answered for it first.

The test itself now ignores comment lines when asking which suites read a file.
Suites cite each other in prose constantly, and counting those pushed every
rule towards selecting everything - which is a different failure from the one
this guards. Over-selection is still the safe direction; this is about what
"reads it" means.
2026-10-07 18:49:53 -07:00
branderson c8fd2dc605 Treat an empty ssh_key file as no key, rather than refusing it
tests / confined (conversation) (pull_request_target) Successful in 1m12s
tests / confined (proxy) (pull_request_target) Successful in 1m26s
tests / confined (providers) (pull_request_target) Successful in 2m13s
tests / confined (launch) (pull_request_target) Successful in 2m33s
tests / confined (share) (pull_request_target) Successful in 2m17s
tests / runner-env (confined) (pull_request_target) Successful in 1m7s
tests / confined (remote) (pull_request_target) Successful in 3m9s
tests / confined (shellcheck) (pull_request_target) Successful in 1m34s
tests / confined (project) (pull_request_target) Successful in 3m36s
tests / confined (audit) (pull_request_target) Successful in 3m56s
tests / confined (static) (pull_request_target) Successful in 3m32s
tests / confined (config) (pull_request_target) Successful in 5m23s
tests / privileged (pull_request_target) Successful in 6m42s
tests / runner-env (privileged) (pull_request_target) Successful in 56s
Refusing it outright was the first shape of the commit above and it broke most
of the suite: a great many fixtures use an empty file as a stand-in for a path,
which is a configuration dejima used to wave through.

An empty file is not a key - it loads nothing in the container - so it cannot be
what withdraws the forwarded agent either. It now counts as no key at all, which
costs nothing for a workflow that never pushes and lands in the refusal that
already exists for having neither, with the file named.

The fixtures write a byte into those files, because what they were describing
is a path to a key and an empty one no longer is.
2026-10-07 18:44:40 -07:00
branderson ba2158e0e8 Say what the gitea CLIs confine, and what the push hook does not see
"The gitea CLIs refuse an origin that is not on the configured host, so they
can only act on the repo the sandbox was launched against" overstates it by one
step. origin lives in .git/config, inside the read-write project mount, and the
gitea token is account-wide - so a session that rewrites origin reaches another
repository on the same host. What bounds that is the token's scope, as it is
for gh.

And the push hook's comment claimed git's own options were the only thing a
spelling could vary. A segment's first word still has to be git, so a
`FOO=bar git push` prefix is a push this does not see - the PATH shadow does,
which is the division of labour the pair exists for rather than a gap in it.
2026-10-07 18:44:33 -07:00
branderson 51c0a8bc65 Render the briefing as the session user, and test the pty tty(1) reports on
The renderer reads the project's own .dejima.md and asks the package managers
what is installed, with its cwd inside the workspace a session can write.
Nothing about that needs root - and root got the answer wrong too: the
dependency layers install npm and cargo packages as the sandbox user, into that
user's HOME, so root's cargo home held none of them and every declared cargo
dependency was reported missing. hooks/sandbox-context.sh already reaches the
renderer as that user, since a hook runs inside the session; this is the other
route agreeing with it. The file it writes stays root-owned and read-only,
which is the half that must not be deprivileged.

The pty chown guarded on stdout and then asked `tty`, which reports on stdin.
With one attached and the other not, the path came back empty and the chown
silently did nothing - the nvim clipboard fallback failing with nothing to
point at.
2026-10-07 18:05:24 -07:00
branderson d418de83df Scrub the forge credential before the daemons that inherit it start
The published-ranges fetch is the only thing in the firewall path that needs
GH_TOKEN, and the header derived from it. Everything started after it - the
restricted resolver, the two log readers, the shipper - parses content the
session's own traffic decides, and inherits the environment of the process that
started it.

Both are unset as soon as the request that uses them is made. The test asserts
the ORDER rather than the presence of the unset, because an unset after the
daemons start is the same as none at all - and it also pins that the header is
still there for the fetch, so the scrub cannot pass by breaking the request.
2026-10-07 18:02:43 -07:00
branderson 225abccece Take back the any-port element when a forge-class permit half-lands
permit_address puts a forge-class address in literal_v4 and then in forge_v4.
If the second add failed, the entries file rolled back and the command died -
but the first element stayed, so the address the forge class says is HTTPS-only
was permitted on every port for the rest of the session, and neither `entries`
nor `status` showed it, because the record of it had been undone.

The element goes back too. If that removal itself fails there is nothing left to
try, so it says so and names the restart rather than reporting a narrowing that
did not happen.

Driven with a stub nft, since the real one needs a namespace to act on: the
half-landed case, the whole-success case which must undo nothing, and the
open-class case where there is no second set and so nothing to take back.
2026-10-07 17:58:32 -07:00
branderson 43081852ea Unmount the operator's ssh-agent when a dedicated key is configured
The forwarded agent was mounted whenever the workflow pushes, and entrypoint.sh
merely re-pointed SSH_AUTH_SOCK at a fresh agent holding the sandbox key. The
host agent stayed connectable at the path docker-compose.yml names, so
`SSH_AUTH_SOCK=/ssh-agent ssh ...` signed with the operator's personal
identities for the whole session.

Choosing a dedicated key has to mean the personal agent is not in the container,
not that it is merely not the default. bin/dejima now blanks SSH_AUTH_SOCK when
a key is configured, the same way it does for a workflow that never pushes, so
${SSH_AUTH_SOCK:-/dev/null} mounts nothing.

An empty key file is refused rather than accepted: it passes the is-a-file check,
loads nothing in the container, and is now what stops the agent being mounted -
the three together would be a pushing session with no credential at all.

Both the key spelling and [identity.default] ssh_key are covered, since
config_get resolves the identity's value for that key.
2026-10-07 17:57:28 -07:00
branderson 4adfe9b36c Read a GraphQL document that arrives attached to its flag
tests / confined (conversation) (pull_request_target) Successful in 1m13s
tests / confined (proxy) (pull_request_target) Successful in 1m24s
tests / confined (providers) (pull_request_target) Successful in 2m13s
tests / confined (launch) (pull_request_target) Successful in 2m31s
tests / confined (share) (pull_request_target) Successful in 2m15s
tests / runner-env (confined) (pull_request_target) Successful in 1m5s
tests / confined (remote) (pull_request_target) Successful in 3m7s
tests / confined (shellcheck) (pull_request_target) Successful in 1m33s
tests / confined (project) (pull_request_target) Successful in 3m36s
tests / confined (audit) (pull_request_target) Successful in 3m58s
tests / confined (static) (pull_request_target) Successful in 3m32s
tests / confined (config) (pull_request_target) Successful in 5m19s
tests / privileged (pull_request_target) Successful in 6m50s
tests / runner-env (privileged) (pull_request_target) Successful in 1m1s
The mutation check and the file-indirection check ran after the loop had
skipped every dash-prefixed argument, so a flag carrying its value attached put
the document somewhere neither looked: `--field=query=mutation{...}` and
`-Fquery=mutation{...}` passed, while the positional `graphql` beside them
still counted as a readable target. With the github forge the push token is
write-capable, so that is the escalation the inline spelling was closed against.

Both checks now run over every argument. Neither needs to know which of the
CLI's flags take a separate value, which is what lets them look at all of them
- unlike the repository scoping below, which does, and which still sees only
the positionals.
2026-10-07 17:23:36 -07:00
branderson 12a222cace Let a positional remote beat --repo, the way git documents it
git: "this option is equivalent to the <repository> argument. If both are
specified, the command-line argument takes precedence." This read it the other
way round, so `git push --repo=origin other-remote feature` resolved and
approved origin's URL, treated other-remote as a refspec, and let git push to a
host nothing had checked against DEJIMA_PUSH_HOSTS.

The library's own header says a disagreement between this parser and git's has
to fail as a refusal. This one failed as an unchecked push - the test for it
shows ssh reaching the unvetted host when the precedence is put back.

`git push --repo=origin main` is a push to a remote called "main" under that
rule, which is how git reads it too, so it is now refused as an unresolvable
remote rather than by the branch policy.
2026-10-07 17:21:33 -07:00
branderson af0fce3326 Read the force marker as part of the refspec, not of the branch
`git push origin +main` put the destination through the branch policy as
"+main", which matched no entry, so the one spelling that escaped was the
destructive one. `+refs/heads/main` escaped the same way, since the prefix also
stopped the refs/heads/ strip from landing.

One leading `+` now comes off before the destination is read. A `+` on a branch
nothing protects is still an ordinary push, and the test says so - otherwise the
strip could be doing the refusing rather than the policy.
2026-10-07 17:19:20 -07:00
branderson 2217d6d3c3 Merge pull request 'Close the two gaps a review found in the guards, and rewrap what was written to a forge's rules' (#373) from review-1007 into development
Reviewed-on: #373
Reviewed-by: branderson <brad@branderson.io>
2026-10-07 16:51:38 -07:00
branderson 300097dcae Make the mutation boundary symmetric
tests / confined (conversation) (pull_request_target) Successful in 1m13s
tests / confined (proxy) (pull_request_target) Successful in 1m26s
tests / confined (providers) (pull_request_target) Successful in 2m12s
tests / confined (launch) (pull_request_target) Successful in 2m27s
tests / confined (share) (pull_request_target) Successful in 2m12s
tests / runner-env (confined) (pull_request_target) Successful in 1m5s
tests / confined (remote) (pull_request_target) Successful in 3m5s
tests / confined (shellcheck) (pull_request_target) Successful in 1m33s
tests / privileged (pull_request_target) Successful in 6m37s
tests / confined (project) (pull_request_target) Successful in 3m33s
tests / confined (audit) (pull_request_target) Successful in 3m52s
tests / confined (static) (pull_request_target) Successful in 3m29s
tests / confined (config) (pull_request_target) Successful in 5m12s
tests / runner-env (privileged) (pull_request_target) Successful in 57s
The right side still required whitespace or a brace, and GraphQL treats commas
and comments as ignored tokens - so `mutation,{mergePullRequest}` and
`mutation#c` followed by the body on the next line are valid documents that sat
just outside it. The bare word with nothing after it did too.

Both boundaries are now anything that cannot continue an identifier. The class
excludes `_` on both sides, so a field called foo_mutation is still not a
mutation, and the three honest queries in the test still pass - a check that
refused one would cost an agent the only route the API has to review threads.
2026-10-07 10:05:21 -07:00
branderson 30a2a5c288 Cut the second and third consequence from two comments
tests / confined (conversation) (pull_request_target) Successful in 1m15s
tests / confined (proxy) (pull_request_target) Successful in 1m26s
tests / confined (providers) (pull_request_target) Successful in 2m10s
tests / confined (remote) (pull_request_target) Successful in 3m5s
tests / confined (shellcheck) (pull_request_target) Successful in 1m33s
tests / confined (project) (pull_request_target) Successful in 3m29s
tests / confined (audit) (pull_request_target) Successful in 3m47s
tests / confined (static) (pull_request_target) Successful in 3m24s
tests / confined (config) (pull_request_target) Successful in 5m8s
tests / privileged (pull_request_target) Successful in 6m49s
tests / confined (launch) (pull_request_target) Successful in 2m27s
tests / confined (share) (pull_request_target) Successful in 2m10s
tests / runner-env (confined) (pull_request_target) Successful in 1m4s
tests / runner-env (privileged) (pull_request_target) Successful in 57s
Both state a real reason and then keep going. The entrypoint's shadow comment
spent three sentences where the first carries the bug, and the harness.conf one
restated the entrypoint's fail-closed story when the harness-local fact is
that it cannot host the guards, not that nothing does.

Measured against this repo's own standard for comments, which asks for the
first consequence and leaves the chain to a reader who needs it.
2026-10-07 09:23:34 -07:00
branderson ab67534887 Rewrap the prose that was written to a forge body's rules
The host-entry section went in as four single lines of 236 to 487 characters,
in a file hard-wrapped at eighty. One-long-line-per-paragraph is for pull
request and issue bodies, where the forge turns a source newline into a visible
break; a repository document has no such reader.

Also pins the precondition behind the repository rules: they are applied to the
commands merge_block and disclose name, because those are the only keys that
name a CLI - so a forge declaring repo_flags and neither of them would get the
rule applied to nothing, silently, there being no command for it to be absent
from. Stated in providers.md for a third-party manifest, and asserted over the
shipped forges so the statement cannot quietly stop being true.
2026-10-07 09:22:15 -07:00
branderson bb4f06657f Read a mutation that is not the document's first operation
The left boundary was whitespace or `=`, so a GraphQL document carrying more
than one operation - `query A{x}mutation B{mergePullRequest(...)}` - put `}`
immediately before the mutation and walked past the check. Whether a given CLI
can select that second operation is beside the point: the boundary should not
be the thing deciding.

It is now anything that cannot continue an identifier, which still excludes
`_`, so a field named foo_mutation is not read as a mutation. Both halves are
tested, because a check that refused an honest query would cost an agent the
one route the API has to review threads.
2026-10-07 09:20:20 -07:00
branderson 6d77293880 Let the push hook see the one spelling that reaches neither layer
`/usr/bin/git push origin main` stepped over the PATH shadow by naming the
absolute path, and the hook compared the whole first word, so it did not see a
git invocation at all. Both layers allowed it.

That form is the example guard-lib.sh's own header gives as the reason the two
layers exist, and the identical spelling was already basenamed and tested for
the forge CLI beside it. The hook now basenames too, as the shadow does by
reading its own $0.

A basename is not a suffix match: `mygit push` and `git-annex push` stay
commands this has nothing to say about, and the test says so.
2026-10-07 09:18:56 -07:00
branderson c7521c7b6d Merge pull request 'One copy of the push and merge policy, and the forms that walked past it' (#372) from guards-365 into development
Reviewed-on: #372
Reviewed-by: branderson <brad@branderson.io>
2026-10-06 23:13:49 -07:00
branderson 3474c96f95 Prove in a container that the shadow can load its policy
tests / confined (conversation) (pull_request_target) Successful in 1m17s
tests / confined (proxy) (pull_request_target) Successful in 1m26s
tests / confined (providers) (pull_request_target) Successful in 2m12s
tests / confined (launch) (pull_request_target) Successful in 2m29s
tests / confined (share) (pull_request_target) Successful in 2m10s
tests / runner-env (confined) (pull_request_target) Successful in 1m4s
tests / confined (remote) (pull_request_target) Successful in 3m4s
tests / confined (shellcheck) (pull_request_target) Successful in 1m31s
tests / confined (project) (pull_request_target) Successful in 3m32s
tests / confined (audit) (pull_request_target) Successful in 3m52s
tests / confined (static) (pull_request_target) Successful in 3m52s
tests / confined (config) (pull_request_target) Successful in 5m9s
tests / privileged (pull_request_target) Successful in 6m50s
tests / runner-env (privileged) (pull_request_target) Successful in 1m0s
Every git command in a session now goes through a shadow that sources a file
the Dockerfile copies in, so a COPY line that stopped matching the path would
break git outright in every session - and the confined suites would stay green,
because they point the guards at the checkout rather than the image.

The file-wide pair sets no DEJIMA_PUSH_HOSTS, which fails closed, so any push
is refused and the refusal itself is the evidence the policy loaded. The
pass-through half is asserted beside it: everything that is not a push has to
reach real git, or the shadow is a broken git rather than a guarded one.
2026-10-06 22:39:31 -07:00
branderson 4c81605df6 Say that the hooks and the shadows read the same decision
Three documents described the hooks as regex-matching a command string while
the shadows compared argv, which is what made the overlap read as two
implementations rather than two entry points. development.md's table gains the
library; configuration.md says which pushes are refused for being unreadable
rather than checked, since that is the part an operator meets as a refusal.

merge-guard.sh is also no longer only about merges, so configuration.md says
what it applies.
2026-10-06 22:38:42 -07:00
branderson 07d863d6dc Close the push and api forms that walked past the guards
Four bypasses, all of them a parser resolving exactly one thing and checking
that (#365).

`--all` and `--mirror` were opaque flags, and only the first positional after
the remote was read. So `git push --all origin` from a feature branch pushed
main, and `git push origin feature main` checked feature alone. Every
positional after the remote is now a refspec and every one is checked; --all
is checked against every local branch, since that is what it pushes. --mirror
is refused outright: it writes every ref AND deletes the remote refs this
checkout does not have, so a mirror from a repository with no main is how the
remote's main disappears, and no branch list makes that readable off the
command.

`--git-dir` and `--work-tree` were consumed and ignored, so a push carrying
one resolved the remote in the mounted project, found it allowed, and then ran
against a repository whose origin points somewhere else. Refused for a push -
and only for a push, since they say nothing about any other git command.
Honouring them would mean reimplementing how they combine with -C and with
each other.

`gh api` was scoped only when the endpoint sat under the declared segment, so
`user/repos`, `/orgs/<org>/repos` and `graphql` named no repository for the
guard to compare and nothing was refused. Under that subcommand the question
is now inverted: a target the guard CAN read has to be present. Telling an
endpoint apart from a header or field value would mean knowing which of the
CLI's flags take a separate value, which is the coupling this guard exists to
avoid - asking whether a readable target is there needs no such knowledge.

graphql stays readable, because dejima's own github-pr reads review threads
through it and the API has no other route to them, and a query can only read
what the token already could. A mutation does not: it is what an agent reaches
for once `gh pr merge` refuses, and no repository scoping can be read out of a
GraphQL document. A field value read from a file is refused under the same
subcommand, since it keeps the document out of the command entirely.
2026-10-06 22:34:31 -07:00
branderson 2d3a8ef32a Put the push and merge policy in one place both layers read
Each rule existed twice: as a PATH shadow over the command, and as a
PreToolUse hook over the command string a harness reports. The overlap is
deliberate - a hook sees `/usr/bin/git push`, which walks past the shadow, and
a shadow sees a push from an editor integration or a script, which the harness
never reports - so neither can be dropped. What they cannot do is disagree,
and they had: the shadow skipped `-o` and its value and read `--repo`, the
hook did neither, so `git push -o x origin main` was fine to one and a hard
refusal from the other for the same invocation in the same session.

docker/guard-lib.sh now holds the decision and all four call it. Each hook's
job is to turn a command string into the argv the library reads, which is also
how the hooks inherit the shadows' indifference to spacing and quoting - and
why merge-guard.sh no longer escapes regex metacharacters out of a block
pattern, since there is no regex left to inject into.

The library takes the stricter reading wherever the two differed: `HEAD`
resolves to the current branch, `--repo` names the remote, and a flag's value
is skipped rather than read as a positional.

entrypoint.sh derives the set of CLIs it links the shadow over from the same
function the hook checks a command against, so what is shadowed and what is
checked cannot drift apart either.
2026-10-06 22:32:04 -07:00
branderson f0cf3b98f7 Merge pull request 'Finish the check gaps, and clamp the host entry to the ports it was told to open' (#371) from checks-364 into development
Reviewed-on: #371
Reviewed-by: branderson <brad@branderson.io>
2026-10-06 21:55:33 -07:00
branderson 513187cef3 Merge pull request 'Correct the documents that stated the opposite of the code' (#370) from docs-contradictions into development
Reviewed-on: #370
Reviewed-by: branderson <brad@branderson.io>
2026-10-06 21:45:59 -07:00
branderson 4caf9d8d76 Keep the harness explanation out of the opening pitch
tests / confined (conversation) (pull_request_target) Successful in 1m13s
tests / confined (proxy) (pull_request_target) Successful in 1m27s
tests / confined (providers) (pull_request_target) Successful in 2m10s
tests / confined (launch) (pull_request_target) Successful in 2m26s
tests / confined (share) (pull_request_target) Successful in 2m8s
tests / runner-env (confined) (pull_request_target) Successful in 1m4s
tests / confined (remote) (pull_request_target) Successful in 3m4s
tests / confined (shellcheck) (pull_request_target) Successful in 1m29s
tests / confined (project) (pull_request_target) Successful in 3m30s
tests / confined (audit) (pull_request_target) Successful in 3m47s
tests / confined (static) (pull_request_target) Successful in 3m19s
tests / confined (config) (pull_request_target) Successful in 5m8s
tests / privileged (pull_request_target) Successful in 6m5s
tests / runner-env (privileged) (pull_request_target) Successful in 1m0s
The README's first paragraphs are the shortest attention budget in the project,
and a sentence there grew into three explaining which harness install.sh seeds.
That belongs in the install steps, where a reader meets the file and can be
surprised by it, so step 4 now says the starter config states claude-code and
which line to change.

The budget test stopped at the pitch, so the paragraph immediately after it -
the same real estate - had none. It now covers everything before "What bounds
it:", which is where the opening ends. Verified by putting the long version
back: 189 words against a budget of 160.
2026-10-06 21:24:58 -07:00
branderson bad60b2cbe Give the connection log a permitted destination that is not the host
tests / confined (conversation) (pull_request_target) Successful in 1m11s
tests / confined (proxy) (pull_request_target) Successful in 1m23s
tests / confined (providers) (pull_request_target) Successful in 2m6s
tests / confined (launch) (pull_request_target) Successful in 2m26s
tests / confined (share) (pull_request_target) Successful in 2m9s
tests / runner-env (confined) (pull_request_target) Successful in 1m4s
tests / confined (remote) (pull_request_target) Successful in 3m0s
tests / confined (shellcheck) (pull_request_target) Successful in 1m30s
tests / confined (project) (pull_request_target) Successful in 3m27s
tests / confined (audit) (pull_request_target) Successful in 3m48s
tests / confined (static) (pull_request_target) Successful in 3m20s
tests / confined (config) (pull_request_target) Successful in 5m7s
tests / privileged (pull_request_target) Successful in 6m27s
tests / runner-env (privileged) (pull_request_target) Successful in 1m0s
The log test used the bridge gateway on port 9 for its permitted half, which
worked because the host entry was open on every port - so the clamp turned its
allow record into a refusal and the test failed in CI.

It needs a destination nothing has to answer on, since the allow record is
written when the first packet is accepted rather than when a service replies,
and waiting for a reply would make this a test of the network. A documentation
address permitted as a literal is that: in @literal_v4, on any port, routed
nowhere. Permitted live rather than declared at launch, so the file-wide pair
keeps the permitted set a real launch has.
2026-10-06 17:51:14 -07:00
branderson b906f33ab5 Export the two host-entry variables the launcher passes through
tests / confined (conversation) (pull_request_target) Successful in 1m20s
tests / confined (proxy) (pull_request_target) Successful in 1m26s
tests / confined (providers) (pull_request_target) Successful in 2m10s
tests / confined (launch) (pull_request_target) Successful in 2m25s
tests / confined (share) (pull_request_target) Successful in 2m9s
tests / runner-env (confined) (pull_request_target) Successful in 1m4s
tests / confined (remote) (pull_request_target) Successful in 3m4s
tests / confined (shellcheck) (pull_request_target) Successful in 1m31s
tests / confined (project) (pull_request_target) Successful in 3m28s
tests / confined (audit) (pull_request_target) Successful in 3m48s
tests / confined (static) (pull_request_target) Successful in 3m18s
tests / confined (config) (pull_request_target) Successful in 5m10s
tests / privileged (pull_request_target) Failing after 7m3s
tests / runner-env (privileged) (pull_request_target) Successful in 1m2s
Every other name in filter_env is derived here, so it is exported where it is
assigned. These two come from the operator's own environment, and `docker run
-e NAME` reads the launcher's environment rather than its shell variables - so
a value set without being exported would reach the filter container as nothing
at all, and the clamp's escape hatch would silently do nothing.
2026-10-06 14:59:42 -07:00
branderson 2f921a0d6a Correct the two documents that describe the host as reachable
development.md described the runtime assertion as proving a gateway-bound
service reachable, which is the behaviour the clamp removes; the positive
control it explains now comes from a second container given one port.

remote-access.md said the policy deliberately permits traffic to the
default-route address. The address is still the one a sandbox can route to,
and that is what makes a front door bound to it an escape hatch - but no port
on it is open now, so the filter refuses such a door too. Said as the second
layer rather than the first, because binding the proxy correctly is still the
thing to get right.
2026-10-06 14:57:37 -07:00
branderson 79e18b916a Prove the host clamp holds against a real listener
The test asserted that a host service bound to the bridge gateway was
reachable, which is the behaviour the clamp removes. It now asserts both
halves: the file-wide pair, which names no ports, cannot reach a listener on
the gateway address, and a pair given that one port can reach it and not a
second listener on the same address at another port.

The loopback half stays - the clamp does not replace it, since a host service
bound to 127.0.0.1 is not on the gateway address at all - and the positive
control stays for the reason it was written: without one, a nonzero status is
equally satisfied by docker exec failing or bash missing from the image.
2026-10-06 14:46:56 -07:00
branderson ba2fb5b434 Describe the host entry as what it reaches
The allowlist said "the Docker host itself (not its whole subnet)", which is
accurate about addresses and said nothing about ports - so a reader checking
whether the boundary held would not have learned that every port the host
published was reachable, and now would not learn that none is.

Its own section, because the reason it is clamped is the reason it exists: it
is the one destination nobody declared.
2026-10-06 14:46:56 -07:00
branderson 03b96b2da8 Clamp the host entry to the ports it was told to open
Every other destination in the policy is there because something declared it.
The host entry is not: it is the gateway of the container's default route,
derived from the routing table, and it was permitted on every port. Docker
publishes container ports on that address, so a session reached the host's
sshd, anything bound to 0.0.0.0, and every published port of every other
container on the machine - including dejima's own remote-access front door,
which is a host shell behind a tmux prefix key (#363).

It now opens none by default. Nothing in a launch needs it: a host-resident
forge, a host-resident Loki and a self-served model endpoint are each reached
through their own entry, by name or as a literal address, and each lands in a
set of its own. EGRESS_HOST_PORTS takes TCP ports and low-high ranges for
anything else, `any` restores the unclamped rule explicitly, and a value that
is not a port stops the container rather than becoming a rule nobody meant.

The address stays named, with its own refusal rather than a fall-through to the
end of the chain, so reaching for the host is a counted refusal.

bin/dejima now passes EGRESS_HOST_PORTS and EGRESS_HOST_ADDR to the filter
container. Neither has a config key and both default inside egress-policy, so
without that the clamp would have no escape hatch an operator could reach.
2026-10-06 14:46:47 -07:00
branderson f8726b2ea9 Ask the kernel for the forwarder's port instead of naming five
proxy.bats named 18398, 18399, 18443, 18444 and 18445, while run-confined.sh
states that the suites binding a socket ask for a free one. The forwarder takes
its port as configuration rather than asking for one itself, so a test has to
supply a number - but it can supply a number the kernel just said was free.

That leaves a window where something else takes the port between the probe and
the bind. It is the smaller of the two failure modes: a constant fails on every
run on a machine already using it, and these suites run on developer machines.
2026-10-06 14:38:35 -07:00
branderson 1d91d07d5c State the free port the ttyd tests were reading from the machine
stub_ss_no_listeners existed and said in its own comment that it was the
precondition for every other ttyd test; one test called it. Eleven others wrote
ttyd_port = 12345 and asserted success, so they failed on any host holding that
port - the same shape as #56, and as the default-port case that produced this
helper.

It is now called beside every stub_ttyd but one. That one is the port-in-use
refusal, which binds a real socket and needs the real ss to see it; the
exclusion is stated there rather than left to be rediscovered.

Verified by holding 127.0.0.1:12345 and running the suite: green with the
stubs, six failures without them.
2026-10-06 14:38:35 -07:00
branderson 172988a67c Test the only validation standing between a forge's document and the allowlist
egress-policy pipes the fetched ranges and the operator's own forge addresses
through cidr-collapse and loads whatever comes back, so both halves of it are
load-bearing: the collapse keeps the interval sets small, and the validation is
the only thing that looks at what the published document actually contained.
Nothing exercised either.

Six cases, over documentation addresses: the merge, the refusal to over-merge
into addresses the forge never published, per-family output with v4 first,
stdin with both separators, a bare address and a host-bits range, and empty
input exiting clean so egress-policy's own empty-list message is reached. The
refusal case checks that nothing usable reaches stdout, since a caller
substituting partial output would load a short allowlist and look fine.

Verified by mutation: swallowing the ValueError instead of exiting fails the
refusal case, and collapsing both families together fails the merge case.
2026-10-06 14:34:55 -07:00
branderson a7d7ec9dda Stop the gate counting a skipped test as one that ran
bats emits a skip as `ok N <name> # skip <reason>`, so the summary counted a
test that declined to run as a test that passed, and named it nowhere. Several
suites skip on host state - an empty /opt/dejima, a missing ss or node - so the
number in the ran column was the only thing an operator had, and it was
overstating how much of the suite the run covered.

Skips now have their own column and their names are printed under the table.
The ran column is what executed. "ran nothing" still keys off the TAP lines
emitted, so a suite bats refused outright is still caught.
2026-10-06 14:33:44 -07:00
branderson 6d6e40d243 Make an absence assertion mean absent rather than unreadable
grep exits 2 for a file it could not read, which satisfies `-ne 0` exactly as
well as "the pattern is not there". Eighty-nine assertions in this suite are
the dangerous-thing-is-absent ones - no sudo in the Dockerfile, no docker
socket in compose, no ~/.ssh mount - so a file renamed or moved out from under
them would turn all eighty-nine green having checked nothing.

`-eq 1` is the status that means the file was read and the pattern was not in
it. All 503 tests still pass, so every one of these does read a real file
today; pointing one at a path that does not exist now fails it.

The new test walks the line after each `run grep` with awk rather than
grepping this file for the shape, which would match its own text.
2026-10-06 14:25:30 -07:00
branderson eb5b34f3be Refuse to build a page that silently drops a source
tests / confined (config) (pull_request_target) Successful in 5m55s
tests / confined (conversation) (pull_request_target) Successful in 1m20s
tests / confined (launch) (pull_request_target) Successful in 2m31s
tests / confined (project) (pull_request_target) Successful in 3m43s
tests / confined (providers) (pull_request_target) Successful in 2m15s
tests / confined (proxy) (pull_request_target) Successful in 1m34s
tests / confined (remote) (pull_request_target) Successful in 2m49s
tests / confined (share) (pull_request_target) Successful in 2m0s
tests / confined (static) (pull_request_target) Successful in 3m13s
tests / confined (shellcheck) (pull_request_target) Successful in 1m31s
tests / privileged (pull_request_target) Successful in 6m27s
tests / runner-env (confined) (pull_request_target) Successful in 1m4s
tests / runner-env (privileged) (pull_request_target) Successful in 56s
tests / confined (audit) (pull_request_target) Successful in 1m57s
A src/*.js omitted from CLIENT_SOURCES is hashed into the manifest and fails
no test - the staleness check and the Sources stamp both agree with the tree.
It is simply never inlined, so the page ships missing a module.

The list cannot become a glob, because its order is load-bearing: each file
attaches to the shared DEJIMA object and later ones parse through earlier
ones. So the build reads src/ and refuses when the directory holds a module
the list does not. Verified by removing osc52.js from the list.
2026-10-06 14:10:44 -07:00
branderson 9329c722cd Name the briefing and the guards by what they are, not by one route
The README said a SessionStart hook briefs the session. Neither shipped
harness takes that route: claude-code gets the briefing as a system prompt
through briefing_args, cline reads the file entrypoint.sh writes root-owned.
The hook is the fallback for a harness doing neither, which is a detail for
docs/configuration.md rather than the page someone reads first.

The blast-radius row - identical in security.md and threat-model.md, and
pinned identical by a test - credited the PreToolUse hooks for bounding an
agent that is not yet hostile, in a table that is otherwise harness-agnostic.
The push and merge guards are PATH shadows and hold for every harness; only
the denylist is a hook. The row's own radius column already claims both.
2026-10-06 14:10:38 -07:00
branderson 978480c73d Hash the builder into its own manifest
tests / confined (conversation) (pull_request_target) Successful in 1m42s
tests / confined (proxy) (pull_request_target) Successful in 1m51s
tests / confined (providers) (pull_request_target) Successful in 2m40s
tests / confined (launch) (pull_request_target) Successful in 2m57s
tests / confined (share) (pull_request_target) Successful in 2m13s
tests / runner-env (confined) (pull_request_target) Successful in 1m5s
tests / confined (remote) (pull_request_target) Successful in 3m33s
tests / confined (shellcheck) (pull_request_target) Successful in 1m32s
tests / confined (project) (pull_request_target) Successful in 4m7s
tests / confined (audit) (pull_request_target) Successful in 4m58s
tests / confined (static) (pull_request_target) Successful in 4m34s
tests / confined (config) (pull_request_target) Successful in 8m15s
tests / privileged (pull_request_target) Successful in 11m34s
tests / runner-env (privileged) (pull_request_target) Successful in 1m1s
web/build.mjs was not among the sources it stamps, so editing it - the
escaping, the fill order, which files get inlined - produced a different page
with a byte-identical manifest and stamp. The staleness check passed on a
page nobody had rebuilt, and web/README.md defends committing the artifact
precisely so nobody has to run the build, which is what makes that check the
only thing standing there.

The test hashes the same set, so the two cannot disagree about what counts as
a source. Verified by editing the builder and leaving the page alone, which
now fails.
2026-10-06 13:54:49 -07:00
branderson 4f0fbff39c Stop a comment claiming no harness hosts the guards, beside one that does
claude-code declares four. The comment sat next to cline's decision to declare
none and gave the reason as "no harness hosts them any more" - which is the
reasoning that made the missing git shadow a hole rather than a cost, since
the shadow was not installed under the default workflow either.

It now says what is true: this harness cannot host them, the PATH shadows
cover a harness that does not, and those shadows are installed whatever the
workflow says - so declaring none here costs the hook layer, not the guard.
2026-10-06 13:54:49 -07:00
branderson 34ae5c4044 Correct three documents that stated the opposite of the code
Two harness keys were typed `?template` where the schema says `?args`, and
the code's own comment explains the distinction exists because calling them
template "promised a substitution that does not happen" - re-promised to
exactly the audience writing a manifest against that table. assumed_backend
was in the prose and missing from the table of every key, and it is the one
key there that writes into the operator's config.

[github] token_file was documented as read whichever forge is selected. Both
it and the github forge's push_token_file fill GH_TOKEN, the forge's wins,
and the launch says so - which is the half that decides which token can push.
providers.md already had it right.

README said neither harness is a default without saying install.sh writes a
starter config selecting one. Both are true: dejima has no default, which is
why the generated file has to state a choice. Left as it was, a reader is
surprised by their own config.
2026-10-06 13:54:49 -07:00
branderson c400ff3ad0 Merge pull request 'The checks everything else leans on: shellcheck coverage, release pass, suite map' (#369) from review-checks into development
Reviewed-on: #369
Reviewed-by: branderson <brad@branderson.io>
2026-10-06 13:46:32 -07:00
branderson 57c76aa573 Select every suite that reads a changed path, not the one that owns it
tests / confined (conversation) (pull_request_target) Successful in 1m8s
tests / confined (proxy) (pull_request_target) Successful in 1m19s
tests / confined (providers) (pull_request_target) Successful in 2m6s
tests / confined (launch) (pull_request_target) Successful in 2m20s
tests / confined (share) (pull_request_target) Successful in 2m2s
tests / runner-env (confined) (pull_request_target) Successful in 58s
tests / confined (remote) (pull_request_target) Successful in 2m56s
tests / confined (shellcheck) (pull_request_target) Successful in 1m23s
tests / confined (project) (pull_request_target) Successful in 3m22s
tests / confined (audit) (pull_request_target) Successful in 3m42s
tests / confined (static) (pull_request_target) Successful in 3m10s
tests / confined (config) (pull_request_target) Successful in 5m3s
tests / privileged (pull_request_target) Successful in 6m9s
tests / runner-env (privileged) (pull_request_target) Successful in 55s
bin/dejima-provider-lib.sh selected `providers` alone while config.bats sets
LIB= to that very file; bin/dejima-harness-lib.sh selected a suite that does
not mention it at all. Twenty rules were narrower than the suites that read
them, and static.bats - which asserts over the whole tree - was missing from
nearly all of them.

The --changed lane is what runs between edits, so a narrow rule reports an
edit as green from a selection that never loaded the file.

.dejima.md says nothing can tell that a rule is too narrow. A reader cannot;
a test can: for each mapped path, every suite whose text names it has to be in
that rule's answer. Over-selecting costs a suite run and under-selecting costs
a regression, so the rules now err wide.

The check matches on a basename, and on the full path where the tree holds
more than one file by that name - without which web/README.md inherits every
mention of the root one.
2026-09-25 17:19:06 -07:00
branderson d188eb20be Name a manual check for the boundary in the release pass
Its patterns covered the launcher, the forges, the guards and the web page,
and nothing in the egress policy, the capability floor or the credential
path. A release that rewrote the firewall was therefore reported as touching
nothing that needs a manual check - over exactly the things this project
exists to hold, in the one place that says what nothing has verified.

Three patterns, each naming what only a session can show: a refusal that is
immediate rather than a hang, a capability set read from a running container,
and the credential paths the suites reach least.

The test drives each boundary path against the script's own patterns, so a
pattern edited to stop matching is caught rather than the list here drifting.
2026-09-25 17:19:06 -07:00
branderson 88779bd656 Shellcheck the two libraries the shebang filter dropped
The list matches a shebang, with named exceptions for the sourced fragments
that carry none - and that exception was written as a pattern rather than a
roll of names precisely so nobody would have to remember to extend it. It was
still short by one directory: bin/*-lib.sh, which is dejima-harness-lib.sh,
the code deciding which host paths may be mounted where.

47 files became 49. The one finding it surfaced is SC2015 on an `A && B || C`
that is correct - there is no then-branch and the block exits - so it is
rewritten as the `if` it means rather than suppressed.

The test derives what must be covered from the tree rather than from a list,
by reading each file's shebang, so a fragment added in a new place fails this
instead of being skipped in silence.
2026-09-25 17:19:06 -07:00
branderson 7535f4795b Merge pull request 'Three HIGH findings from the full-tree review' (#368) from report-high into development
Reviewed-on: #368
Reviewed-by: branderson <brad@branderson.io>
2026-09-25 16:57:49 -07:00
branderson bd01875c7f Say what length arrived, end the connection, and test the chunked half
tests / confined (conversation) (pull_request_target) Successful in 1m7s
tests / confined (proxy) (pull_request_target) Successful in 1m20s
tests / confined (providers) (pull_request_target) Successful in 2m10s
tests / confined (launch) (pull_request_target) Successful in 2m31s
tests / confined (share) (pull_request_target) Successful in 2m12s
tests / runner-env (confined) (pull_request_target) Successful in 59s
tests / confined (remote) (pull_request_target) Successful in 3m8s
tests / confined (shellcheck) (pull_request_target) Successful in 1m27s
tests / confined (project) (pull_request_target) Successful in 3m34s
tests / confined (audit) (pull_request_target) Successful in 3m53s
tests / confined (static) (pull_request_target) Successful in 3m19s
tests / confined (config) (pull_request_target) Successful in 5m15s
tests / privileged (pull_request_target) Successful in 6m10s
tests / runner-env (privileged) (pull_request_target) Successful in 54s
Three small things around the body cap.

The refusal log named the cap rather than the value, so `Content-Length: -1`
and a genuinely oversized upload read identically - while the comment on the
413 path promises the log says what came in.

The 413 left the connection open with the refused body still queued, so the
next parse took those bytes as a request line and a keep-alive client got
garbage 400s instead of one clean refusal.

And only the Content-Length path was tested. The two share a helper today, so
a refactor that inlines the chunked branch would restore the unbounded read
with nothing failing; the chunked shape now has its own case.
2026-09-25 16:38:28 -07:00
branderson d8263120e3 Find a scrubbed value by its components, not by re-reading its path
The display path is ambiguous and always was: a dict key spelled `x[0]` and
element 0 of a sibling list `x` produce the same string, and so does a key
containing a dot. Re-parsing it to find the value again deleted the bystander
and left the secret in the file for the first, and raised KeyError out of the
scrub for the second - after which bin/dejima copies the file in unscrubbed.

That spelling cannot change: finding_id hashes it, and the hash is what a
standing answer in credential-scan.decisions is keyed by. So walk_json now
yields the components it built the path from, and everything that has to find
the value again takes those. The string stays what an operator reads.

The string-parsing deleter is gone rather than left unused, so a future caller
cannot find its way back onto the ambiguous path.

One thing this does not fix: when two locations genuinely share a display path
they share a finding id, so scrubbing removes both. That is the fail-safe
direction - the secret goes - and disambiguating the string would orphan every
recorded decision.
2026-09-25 16:38:28 -07:00
branderson c302aaae96 Test the shadow against re-gating in any spelling, and the real guard
The regression test matched an `if` carrying one of the two lists, so re-gating
written as `[ -n "${DEJIMA_PUSH_HOSTS:-}" ] && ln -sf ...` passed it while
putting the bug straight back - no shadow under the shipped default workflow.
It now requires the link to sit at the top level of the file, carry no test of
its own, and open no conditional in the block it lives in. All three forms were
run against it.

The empty-host refusal was proven by a copy of the guard's loop written in the
test, which is the shape this repository keeps getting caught by: it passes
when a fail-open short-circuit is added above the loop, and fails when the loop
is rewritten harmlessly. It now runs git-guard.sh against a real repository
with the list empty, and checks a permitted host is not refused for the same
reason - so the refusal is the empty list rather than a broken guard.
2026-09-25 16:38:27 -07:00
branderson a899d60d59 Delete scrubbed JSON paths in an order that does not shift the rest
tests / confined (conversation) (pull_request_target) Successful in 1m37s
tests / confined (proxy) (pull_request_target) Successful in 1m45s
tests / confined (providers) (pull_request_target) Successful in 2m32s
tests / confined (launch) (pull_request_target) Successful in 2m50s
tests / confined (share) (pull_request_target) Successful in 2m3s
tests / runner-env (confined) (pull_request_target) Successful in 56s
tests / confined (remote) (pull_request_target) Successful in 3m22s
tests / confined (shellcheck) (pull_request_target) Successful in 1m23s
tests / confined (project) (pull_request_target) Successful in 3m49s
tests / confined (audit) (pull_request_target) Successful in 4m11s
tests / confined (static) (pull_request_target) Successful in 3m8s
tests / confined (config) (pull_request_target) Successful in 5m32s
tests / privileged (pull_request_target) Successful in 12m27s
tests / runner-env (privileged) (pull_request_target) Successful in 54s
del cur[i] moves every later element of a list down one, so applying findings
in document order deleted the wrong ones from the second onwards. Two secrets
in one array left the second secret in the file and removed an innocent value
instead - and the staged file is mounted into the container, so the operator
answers "keep it out" and it goes in silently.

Reachable from anything that carries several token-shaped strings in one list,
which an mcpServers args array is.

Deepest path and highest index first makes each deletion independent of the
ones after it.
2026-09-23 17:27:31 -07:00
branderson 4425d83d88 Refuse a negative Content-Length instead of reading to EOF
int() accepts a negative, a negative passes the `> MAX_BODY_BYTES` test, and a
buffered reader takes read(-1) as "until EOF" - so `Content-Length: -1` walked
past the cap and read an unbounded body into host RAM. The cap's own comment
says it is there because this runs on the host as the operator's user.

The same check now covers chunk sizes, which had the same shape.

A value that is not a number is deliberately left to raise. The suppression
around handle_error is meant to be specific to the client going away, and the
test that proves it uses a non-hex chunk length as the genuine fault that must
still surface - refusing it cleanly here would remove that canary. The bug was
the value that passes the cap and then reads to EOF, not untidy input.
2026-09-23 17:27:31 -07:00
branderson c73fb4702b Install the git shadow always, because an empty host list refuses
DEJIMA_PUSH_HOSTS fails closed: empty means no host may be pushed to, and the
hook's header says that is how a no-forge or non-pushing workflow becomes
enforced rather than merely described. The entrypoint linked the shadow only
when that list or the blocked-branch list was non-empty, so the guard was
absent in exactly the case it exists for.

The shipped default workflow is local-only, which sets push off and no blocked
branches, so that was the ordinary launch rather than a corner. With
claude-code the PreToolUse hook still covered it. cline declares no guards, on
the stated reasoning that the PATH shadows hold regardless - so a cline
local-only session had no push guard of any kind, while the launch report told
the operator the only gap was a full-path git invocation.

This reverses a deliberate decision, and the test that pinned it now carries
the reversal and the reason rather than being deleted. Leaving PATH as the
image left it is right for a shadow with nothing to enforce; the forge shadow
stays conditional for that reason, since an unset merge block genuinely blocks
nothing. A fail-closed list is the opposite case.
2026-09-23 17:27:31 -07:00
branderson 896773bfc5 Merge pull request 'Give the remote access page a favicon' (#362) from web-favicon into development
Reviewed-on: #362
Reviewed-by: branderson <brad@branderson.io>
2026-09-17 18:04:38 -07:00
branderson 8b579fdd59 Merge pull request 'Tell a session that a hang is not the allowlist' (#360) from briefing-refusal into development
Reviewed-on: #360
Reviewed-by: branderson <brad@branderson.io>
2026-09-17 18:04:03 -07:00
branderson f44fe47657 Give the remote access page a favicon
tests / confined (conversation) (pull_request_target) Successful in 1m6s
tests / confined (proxy) (pull_request_target) Successful in 1m15s
tests / confined (providers) (pull_request_target) Successful in 1m59s
tests / confined (launch) (pull_request_target) Successful in 2m16s
tests / confined (share) (pull_request_target) Successful in 1m56s
tests / runner-env (confined) (pull_request_target) Successful in 52s
tests / confined (remote) (pull_request_target) Successful in 2m48s
tests / confined (shellcheck) (pull_request_target) Successful in 1m18s
tests / confined (project) (pull_request_target) Successful in 3m16s
tests / confined (audit) (pull_request_target) Successful in 3m37s
tests / confined (static) (pull_request_target) Successful in 3m0s
tests / confined (config) (pull_request_target) Successful in 4m57s
tests / privileged (pull_request_target) Successful in 6m9s
tests / runner-env (privileged) (pull_request_target) Successful in 49s
Inlined as a data URI, like everything else on it: ttyd serves one file and
answers no path a /favicon.ico could be fetched from, so a link to one would
404 and the tab would keep the generic icon.

build.mjs hashes its manifest from bytes rather than from a utf8 read, so a
binary source can sit in the list beside the text ones. Every existing hash
is unchanged by that - hashing a utf8 string hashes exactly those bytes -
which is checked rather than assumed: only template.html's entry moved, and
only because this commit edits it.

"the terminal page fetches nothing at page load" refused the new link, and
its reasons - ttyd serves one file, the sandbox restricts outbound traffic -
are both about fetching. A data URI carries its bytes in the attribute and
fetches nothing, so it is exempt and nothing else is. The match is now per
attribute rather than per line, so a data URI cannot carry a real reference
past the check beside it, and the test builds a copy with a real external
reference to prove the exemption did not open a hole.
2026-09-17 16:54:38 -07:00
branderson 378ad85d8e Tell a session that a hang is not the allowlist
tests / confined (conversation) (pull_request_target) Successful in 1m4s
tests / confined (proxy) (pull_request_target) Successful in 1m12s
tests / confined (providers) (pull_request_target) Successful in 2m0s
tests / confined (launch) (pull_request_target) Successful in 2m16s
tests / confined (share) (pull_request_target) Successful in 2m1s
tests / runner-env (confined) (pull_request_target) Successful in 53s
tests / confined (remote) (pull_request_target) Successful in 2m51s
tests / confined (shellcheck) (pull_request_target) Successful in 1m20s
tests / confined (project) (pull_request_target) Successful in 3m19s
tests / confined (audit) (pull_request_target) Successful in 3m40s
tests / confined (static) (pull_request_target) Successful in 3m22s
tests / confined (config) (pull_request_target) Successful in 4m58s
tests / privileged (pull_request_target) Successful in 6m16s
tests / runner-env (privileged) (pull_request_target) Successful in 51s
The policy rejects rather than drops, and its own chain comment gives the
reason: silently dropping would make every blocked destination look like a
hang. The briefing then described both as failing to connect, which throws
that signal away.

Two agents reported the same non-problem in one day as a result - a service
on the operator's network reachable only on a port their own firewall
exposes, met as a timeout, reported as dejima blocking an allowlisted
domain. From inside a sandbox the two are indistinguishable except by
timing: a refusal here is immediate, and a timeout is something past the
boundary.

The test pins the advice to the behaviour it rests on, so a policy that
started dropping would fail the test that teaches the distinction rather
than leaving a briefing that quietly became wrong.
2026-09-17 12:03:43 -07:00
branderson 6522ad56e0 Merge pull request 'Finish the release's documentation review, and two things it found in code' (#359) from release-followups into development
tests / confined (conversation) (pull_request_target) Successful in 1m4s
tests / confined (proxy) (pull_request_target) Successful in 1m13s
tests / confined (providers) (pull_request_target) Successful in 1m58s
tests / confined (launch) (pull_request_target) Successful in 2m16s
tests / confined (share) (pull_request_target) Successful in 1m57s
tests / runner-env (confined) (pull_request_target) Successful in 52s
tests / confined (remote) (pull_request_target) Successful in 2m50s
tests / confined (shellcheck) (pull_request_target) Successful in 1m18s
tests / confined (project) (pull_request_target) Successful in 3m15s
tests / confined (audit) (pull_request_target) Successful in 3m34s
tests / confined (static) (pull_request_target) Successful in 3m1s
tests / confined (config) (pull_request_target) Successful in 4m50s
tests / privileged (pull_request_target) Successful in 5m57s
tests / runner-env (privileged) (pull_request_target) Successful in 47s
Reviewed-on: #359
Reviewed-by: branderson <brad@branderson.io>
2026-09-17 01:29:05 -07:00
branderson b91b8f5fb3 Map fixtures and dejima-allow to the suites that actually exercise them
tests / confined (config) (pull_request_target) Successful in 4m28s
tests / confined (conversation) (pull_request_target) Successful in 1m11s
tests / confined (launch) (pull_request_target) Successful in 2m11s
tests / confined (project) (pull_request_target) Successful in 2m54s
tests / confined (providers) (pull_request_target) Successful in 1m59s
tests / confined (proxy) (pull_request_target) Successful in 1m19s
tests / confined (remote) (pull_request_target) Successful in 2m35s
tests / confined (share) (pull_request_target) Successful in 1m22s
tests / confined (static) (pull_request_target) Successful in 2m30s
tests / confined (shellcheck) (pull_request_target) Successful in 1m12s
tests / privileged (pull_request_target) Successful in 6m44s
tests / runner-env (confined) (pull_request_target) Successful in 51s
tests / runner-env (privileged) (pull_request_target) Successful in 48s
tests / confined (audit) (pull_request_target) Successful in 1m49s
test/fixtures/fake-anthropic.py is executed as proxy.bats' upstream, but
test/* mapped every non-bats test file to static alone, so a fixture-only
change ran a suite that never touches it and skipped the one that does.
bin/dejima-allow likewise: static.bats asserts on its class wiring, and the
project-only rule skipped the suite holding that guard.

runtime.bats consumes the fixture too, but no confined selection can carry
it - that half stays CI's.
2026-09-16 17:21:51 -07:00
branderson e9e1c2bc52 Walk the whole image for file capabilities, not a union of named roots
Setuid gets a whole-filesystem find in the derived-image test; capabilities
were covered only inside the sweeper's own roots plus the four directories it
skips, so one at a top-level path nobody enumerated was asserted by nothing.
-xdev keeps the walk to the image itself - /proc, /sys and the bind mounts
are their own filesystems - and a missing getcap prints a marker rather than
an empty result that reads as clean.

Not run here: test/runtime.bats needs Docker. CI's privileged job is what
exercises it.
2026-09-16 17:21:01 -07:00
branderson cb793edead Assert the connection log's verdict values, not the field's presence
The verdict rides on which NFLOG group a rule named, and nothing asserted
either value - the two groups swapped would have logged every refusal as
"allowed" with the suite green, and a supervisor reading the log would
believe it. The refused probe now has to be recorded as blocked, and a
permitted attempt - the bridge gateway, which is in @host_v4 whether or not
the host answers on the port - has to be recorded as allowed, closing the
half the comment promised and the test never drove.

Not run here: this is test/runtime.bats, which needs Docker. CI's privileged
job is what exercises it.
2026-09-16 17:19:57 -07:00
branderson 832c4a4f6d Fold case in record_entries' exemption too, the fourth site of four
df7a744 fixed the comparison in build_entries; the exemption decision one
function up still compared INTERNAL_OK to the entry verbatim, on case and on
a trailing dot. A pair spelled differently passed the launcher's folded check
and build_entries' folded check, was recorded strict here, and the boot then
refused the name's internal answer - telling the operator to configure the
exemption they had already configured.

The existing test now spells the two sides apart in case and dot both, and
asserts the exemption that was recorded rather than only that the boot
proceeded.
2026-09-16 17:17:58 -07:00
branderson 0daae02833 Anchor the exit audit to the lines the proxy writes, not phrases in them
The classifier asked whether 'answered N' appeared anywhere and called every
log without one a credential failure. Both halves were wrong. The log carries
text chosen elsewhere - an upstream error-body prefix, an exception echoing
the session's request path - so a token endpoint whose 400 body said
'answered 503' minted a status count and the retry caveat for a session that
made no request. And a log whose only lines were connection failures was
reported as the session being unable to authenticate, sending an operator to
log in again over a network problem.

Three sections now, each on its own evidence: statuses counted from the
proxy's anchored 'upstream answered N for' lines, the credential section from
its 'cannot load|refresh credentials:' lines plus the remedy the proxy writes
after them, and anything else shown under a header that claims neither. A
session that earned both gets both.

The two new cases are driven like the existing pair, against logs the proxy
really wrote - the fixture's oauth-reject gained ?say= so a test can choose
the upstream's text.
2026-09-16 17:16:03 -07:00
branderson a88393f769 Keep the credential proxy's log out of the directory the session mounts
docs/security.md's new rule says what the proxy learned from the provider
stays on the host - and the log holds exactly that, statuses and error-body
prefixes, while living in the socket directory that is bind-mounted read-only
into the session. The session user's uid is the host user's, so the 0644 log
inside the 0700 mount was readable from inside, including token-endpoint
error bodies the container-facing message deliberately withholds.

The log is its own mktemp file now, removed by the exit trap, and the trap
never inherits its name from the environment - same rule as the other names
cleanup() removes but does not create.
2026-09-16 17:11:33 -07:00
branderson b8c34bb98c Make the privilege-sweep test require the sweep, not the spelling
tests / confined (conversation) (pull_request_target) Successful in 1m8s
tests / confined (proxy) (pull_request_target) Successful in 1m14s
tests / confined (providers) (pull_request_target) Successful in 1m57s
tests / confined (launch) (pull_request_target) Successful in 2m14s
tests / confined (share) (pull_request_target) Successful in 1m53s
tests / runner-env (confined) (pull_request_target) Successful in 51s
tests / confined (remote) (pull_request_target) Successful in 2m47s
tests / confined (shellcheck) (pull_request_target) Successful in 1m16s
tests / confined (project) (pull_request_target) Successful in 3m13s
tests / confined (audit) (pull_request_target) Successful in 3m33s
tests / confined (static) (pull_request_target) Successful in 2m57s
tests / confined (config) (pull_request_target) Successful in 4m50s
tests / privileged (pull_request_target) Successful in 6m38s
tests / runner-env (privileged) (pull_request_target) Successful in 48s
Both halves were substring greps, and the sweeper's own path appears in the
line that chmods it. So a Dockerfile whose last instruction only copies or
chmods the sweeper satisfied the test, and so did a derived layer whose
invocation had been commented out - the sweep could be disabled with the test
green, leaving Debian's setuid set in the image with no_new_privs as the only
lock, which the sweeper's header declines to rely on.

It now asks whether the sweeper is invoked as a command, and reads the
derived Dockerfile the generator produces rather than grepping for a string
in the generator, since the order relative to the layer's own installs is the
whole property.

My first attempt at this was the same defect again - a tighter regex that
still matched `chmod +x <path>`. Three mutations, all of which had passed:
the base invocation removed, the derived one commented out, and the derived
one moved above its installs.

The runtime check also gained the directories the sweeper deliberately does
not walk. --check covers the sweeper's own roots, which is right for proving
the sweep worked and leaves nothing asserting that the image ships no file
capability outside them; the whole-filesystem getcap this replaced did cover
them.
2026-09-16 01:20:40 -07:00
branderson df7a74481c Fold case where the policy checks internal_domains, the third site of three
dejima_class_for and dejima_mark_internal fold; build_entries compared
verbatim. So a pair the launcher had already accepted was refused one layer
down, with a message saying the name was permitted by nothing - a correct
configuration that will not start, over a spelling difference DNS does not
recognise.
2026-09-16 01:17:54 -07:00
branderson 337b2bce04 Route every entry into the open class through the class rule, not just one
fixed-domains.sh said the forge class wins wherever the two overlap. Only the
approved-domains loop asked. The built-in open list carries
release-assets.githubusercontent.com, which sits under a built-in forge
suffix, so with the shipped defaults it kept the open class and took a
resolver line more specific than the suffix entry - feeding the set with no
port restriction.

The 443 clamp then rests on the forge's published ranges covering that
address, which is the one-layer-where-there-are-two that firewall-up.sh's own
comment warns about. The ranges do cover it today. The last release did not
depend on that: with no line of its own, the name fell to the suffix entry
and was restricted.

Every route into the open class is normalised now, so a harness or backend
domain under a forge suffix cannot arrive the same way. The comment states
the mechanism rather than asserting the outcome.

The test drives the composition with nothing else set, because the shipped
defaults are the configuration every operator has before declaring anything,
and that was the case nothing covered.
2026-09-16 01:17:05 -07:00
branderson d8845206c4 Finish the release's documentation review, and two things it found in code
tests / confined (conversation) (pull_request_target) Successful in 1m0s
tests / confined (proxy) (pull_request_target) Successful in 1m13s
tests / confined (providers) (pull_request_target) Successful in 2m8s
tests / confined (launch) (pull_request_target) Successful in 2m23s
tests / confined (share) (pull_request_target) Successful in 2m14s
tests / runner-env (confined) (pull_request_target) Successful in 1m0s
tests / confined (remote) (pull_request_target) Successful in 3m13s
tests / confined (shellcheck) (pull_request_target) Successful in 1m26s
tests / confined (project) (pull_request_target) Successful in 3m46s
tests / confined (audit) (pull_request_target) Successful in 4m14s
tests / confined (static) (pull_request_target) Successful in 3m47s
tests / confined (config) (pull_request_target) Successful in 5m43s
tests / privileged (pull_request_target) Successful in 6m22s
tests / runner-env (privileged) (pull_request_target) Successful in 50s
The documentation pass that ran mid-release checked what existed then. Four
pull requests landed after it. These are what they left behind.

The exit audit announced every proxy log as "The model API answered with an
error this session", counted statuses out of it, and closed by saying a
retried one may still have succeeded. A credential that could not be loaded
produces no status line at all, because no request was made - so that case
printed the header, no counts, and an invitation to hope. All three are false
for exactly the failure the credential work routes there deliberately. It now
says what happened and prints the one line that says what to do.

permit_name still spelled out its own set name, which is the fourth place
deciding which set a class feeds and the one set_for_class's comment claimed
did not exist. A live add looking in a different set from the one being
filled reports a permitted name as unreachable, or the reverse. The static
test now refuses any spelling of a set name outside that helper, rather than
naming the three sites it knew about.

Documentation: the rule the credential proxy's comment cites - what may cross
into a session and what may not - was not written anywhere, and the change
that made a repo-authored remedy cross needed it to be. The boot check that
now refuses a name whose address is missing from its class's set was
undocumented, as was the widening of the launch check from counting dots to
asking for a host name. And an approval granted before the two-label rule now
stops a launch with no remove verb to clear it, so the file to edit is named.

The audit's two paths are driven against logs this proxy really wrote, rather
than lines written in the test, because the classification only works if the
two files agree about what a line looks like.
2026-09-16 00:23:48 -07:00
branderson c0935a52c3 Merge pull request 'Name the login that fixes an expired credential, where it is met' (#358) from cred-expiry into development
tests / confined (conversation) (pull_request_target) Successful in 1m6s
tests / confined (proxy) (pull_request_target) Successful in 1m9s
tests / confined (providers) (pull_request_target) Successful in 1m59s
tests / confined (launch) (pull_request_target) Successful in 2m15s
tests / confined (share) (pull_request_target) Successful in 1m58s
tests / runner-env (confined) (pull_request_target) Successful in 53s
tests / confined (remote) (pull_request_target) Successful in 2m49s
tests / confined (shellcheck) (pull_request_target) Successful in 1m19s
tests / confined (project) (pull_request_target) Successful in 3m16s
tests / confined (audit) (pull_request_target) Successful in 3m36s
tests / confined (static) (pull_request_target) Successful in 3m2s
tests / confined (config) (pull_request_target) Successful in 4m49s
tests / privileged (pull_request_target) Successful in 5m40s
tests / runner-env (privileged) (pull_request_target) Successful in 47s
Reviewed-on: #358
Reviewed-by: branderson <brad@branderson.io>
2026-09-15 23:29:49 -07:00
54 changed files with 5724 additions and 1173 deletions
+70 -38
View File
@@ -95,14 +95,6 @@ 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
@@ -114,7 +106,8 @@ jobs:
# 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.
# Uses only what the image already has: getent is libc, and the
# resolver's address is in resolv.conf.
- name: Check the job network can resolve before installing anything
run: |
for attempt in 1 2 3 4 5 6; do
@@ -147,7 +140,11 @@ jobs:
- name: Install test dependencies
run: |
apt-get update
apt-get install -y --no-install-recommends bats jq python3 tmux ca-certificates
# iproute2 for `ss`: the one test asserting dejima-ttyd refuses to
# start when something already holds its port skips without it, and a
# permanent skip on the check guarding a reverse-proxy port takeover
# is the same as not having it.
apt-get install -y --no-install-recommends bats jq python3 tmux ca-certificates iproute2
# 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.
@@ -174,7 +171,11 @@ jobs:
# otherwise refuses --jobs outright when GNU parallel is absent.
- run: |
chown -R node:node .
su node -c "TERM=xterm-256color bats --jobs $(nproc) --no-parallelize-across-files test/${{ matrix.suite }}.bats"
# DEJIMA_TEST_CONCURRENCY is what this job asks the machine for, which
# the fixtures that wait on a bounded deadline multiply their bounds
# by - see test/fixtures/loadscale.py. One suite per job here, so it
# is the --jobs value and nothing more.
su node -c "TERM=xterm-256color DEJIMA_TEST_CONCURRENCY=$(nproc) bats --jobs $(nproc) --no-parallelize-across-files test/${{ matrix.suite }}.bats"
shellcheck:
# Its own job rather than a matrix entry: it installs a different package,
@@ -251,14 +252,6 @@ 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
@@ -270,7 +263,8 @@ jobs:
# 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.
# Uses only what the image already has: getent is libc, and the
# resolver's address is in resolv.conf.
- name: Check the job network can resolve before installing anything
run: |
for attempt in 1 2 3 4 5 6; do
@@ -442,6 +436,58 @@ jobs:
fi
echo "GitHub ranges lookup is authenticated - ceiling is $limit requests/hour."
# One fetch for the whole run, into the directory the job already shares
# with the containers it starts. test/runtime.bats starts twenty-seven
# container pairs and each filter fetched the ranges for itself, which is
# roughly twenty-seven requests in five minutes - comfortably inside the
# 5000/hour a token allows, and exactly the burst GitHub's SECONDARY limit
# answers with a 403 (#357). One request cannot be a burst.
#
# Best-effort: a failure here leaves no file, and runtime.bats falls back
# to each container fetching for itself. That is the behaviour this
# replaces, so the worst case is the status quo rather than a red job -
# and the step says which of the two the run is getting.
- name: Fetch the GitHub ranges once for the whole run
env:
GH_TOKEN: ${{ secrets.GH_META_TOKEN }}
run: |
# POSIX sh, like every other run: block here - the runner's default
# shell is `sh -e`, where an array is a syntax error and the step dies
# before any of this runs. Two curl branches rather than one with a
# built-up argument list, for the same reason.
mkdir -p /tmp/dejima-ci
out=/tmp/dejima-ci/github-meta.json
rm -f "$out" "$out.part"
fetched=0
if [ -n "${GH_TOKEN:-}" ]; then
if curl -fsS --max-time 30 -H "Authorization: Bearer $GH_TOKEN" \
https://api.github.com/meta -o "$out.part" 2>/dev/null; then
fetched=1
fi
else
if curl -fsS --max-time 30 \
https://api.github.com/meta -o "$out.part" 2>/dev/null; then
fetched=1
fi
fi
# Written only once it parses AND carries ranges, because a rate-limit
# body is a 200 with a message and no ranges - and a cached one would
# then fail every container in the run instead of one.
ranges=""
if [ "$fetched" = 1 ]; then
ranges="$(jq -r '[.git[]?, .api[]?, .web[]?] | unique | .[]' < "$out.part" 2>/dev/null || true)"
fi
if [ -n "$ranges" ]; then
mv "$out.part" "$out"
echo "Cached GitHub ranges for this run: $(printf '%s\n' "$ranges" | wc -l) CIDRs."
else
rm -f "$out.part"
echo "Could not cache the ranges, so each container will fetch its own."
echo "That is the pre-#357 behaviour, not a new failure - but it is what"
echo "trips the secondary limit, so a 403 in a container boot below is"
echo "explained by this line."
fi
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
@@ -536,14 +582,6 @@ jobs:
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
@@ -555,7 +593,8 @@ jobs:
# 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.
# Uses only what the image already has: getent is libc, and the
# resolver's address is in resolv.conf.
- name: Check the job network can resolve before installing anything
run: |
for attempt in 1 2 3 4 5 6; do
@@ -607,14 +646,6 @@ jobs:
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
@@ -626,7 +657,8 @@ jobs:
# 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.
# Uses only what the image already has: getent is libc, and the
# resolver's address is in resolv.conf.
- name: Check the job network can resolve before installing anything
run: |
for attempt in 1 2 3 4 5 6; do
+12 -10
View File
@@ -160,8 +160,8 @@ layers below the container rather than into a harder one.
This repo only holds the tooling; no project lives here. Point it at a project
on disk and that project is bind-mounted into the container at runtime.
**Prerequisites:** Docker with the `docker compose` v2 plugin, and `python3`
(stdlib only, no packages) for the host-side credential proxy.
**Prerequisites:** Docker with the `docker compose` v2 plugin, `jq`, and
`python3` (stdlib only, no packages) for the host-side credential proxy.
Only the first block below is required. The rest are independent, so take the
ones you want.
@@ -184,7 +184,9 @@ ones you want.
config.
3. **Put `~/.local/bin` on your `PATH`** if it isn't already.
4. **Set `[core] harness`.** It has no default and a launch is refused without
it, which is what stops a later release changing your selection for you.
it, which is what stops a later release changing your selection for you. The
starter config states `claude-code`, because a file with no default to fall
back on has to name a choice; change that line for Cline.
That is enough to run `dejima`, and no push credential is needed to get this
far. Everything else runs at its default, which means `[core] forge = none` and
@@ -293,9 +295,10 @@ dejima --ro ~/other-project ~/myproject
**→ [docs/configuration.md](docs/configuration.md#one-off-mounts-for-a-single-launch)** -
both forms, and which host paths a mount is refused over and why.
**Ctrl-Z backgrounds all of dejima**, and `fg` brings back a working session.
It makes no progress while suspended, though, so for one that should keep
working while you are away, run dejima inside tmux and use
**Ctrl-Z backgrounds dejima's host side**, and `fg` brings back a working
session. It gets your terminal back rather than pausing the agent - the
container keeps running - so for a session you want to leave working and pick
up elsewhere, run dejima inside tmux and use
[`dejima-attach`](docs/remote-access.md).
→ [why that takes doing](docs/configuration.md#suspending-a-session)
@@ -503,7 +506,7 @@ at a fixed host path.
`.dejima.md` at a project's root is that project's agent-instruction file, the
harness-agnostic equivalent of `CLAUDE.md`, loaded into the session's context
with no authority over the sandbox itself. A `SessionStart` hook also briefs the
with no authority over the sandbox itself. dejima also briefs the
session on where it is running and on the instructions belonging to whichever
workflow you selected, so a session under `local-only` is told something
different from one under `worktree-pr`.
@@ -562,9 +565,8 @@ After the container exits it diffs them and tells you what changed.
It is **advisory, not a block**, firing after the session has ended, so a
finding is something to clean up before your next `git` command or session in
that repo. Its diff is time-windowed: a finding says a path changed while the
session was running, not that the session changed it. Findings are a time
window, not a verdict on who caused them.
that repo. Its findings are a time window, not a verdict on who caused them:
one says a path changed while the session was running.
`dejima-audit show` indexes what is waiting and `dejima-audit ack` walks it, one
finding at a time. `[audit] strict` refuses a headless launch while CRITICAL
+483 -50
View File
@@ -50,12 +50,12 @@ Options (each overrides the config file for one launch only):
--no-epitaph keep nothing if this session compacts
--no-firewall disable the egress firewall entirely (debugging only)
--no-pause do not wait for a keypress before the image build
--isolated no project, no memory, no forge; keeps only ~/shared
--print-config print every key as it resolves, and exit
--print-briefing print the briefing the agent will get, and exit
-h, --help this message
Every --no-* flag only ever narrows. Nothing here turns a setting back on: a
config that says off is a decision, not a default to override in passing.
Every --no-* flag only ever narrows; a config that says off is a decision.
Config lives in ~/.config/dejima/config; [core] harness must be set there.
DEJIMA_HELP
@@ -147,6 +147,19 @@ _set_override() {
done
FLAG_OVERRIDES=(${kept[@]+"${kept[@]}"} "$key=$value")
}
# Whether a flag has already set $1, for the defaults --isolated applies after
# the loop. Flag order on the command line is arbitrary, so this is asked
# rather than inferred from when a flag ran: `--isolated --forge <name>` and
# `--forge <name> --isolated` have to mean the same thing.
_override_set() {
local key="$1" entry
for entry in ${FLAG_OVERRIDES[@]+"${FLAG_OVERRIDES[@]}"}; do
[ "${entry%%=*}" = "$key" ] || continue
return 0
done
return 1
}
_need_value() {
[ -n "${2:-}" ] && [ "${2:0:2}" != "--" ] || {
echo "$1 requires a value" >&2
@@ -236,9 +249,24 @@ NO_LOG=0
# Launch-scoped, like NO_LOG: initialised here so an inherited environment
# variable of the same name cannot decide it.
NO_PAUSE=0
# A session with nothing before it and nothing after it: no project mounted,
# no memory, no transcript, and a ~/shared of its own that no other session
# has written to or will read. What survives it is that directory and the
# branches it pushed.
#
# A flag and no config key, deliberately. Every other narrowing here has a
# [section] key behind it because a machine can sensibly prefer it always;
# this one changes what the session IS rather than how much it may do, and a
# file that silently decided it would make the next launch's answer to "where
# is my work" depend on a line nobody reread.
DEJIMA_ISOLATED=0
# Whether the operator wrote `--`, so an isolated launch can tell a project
# path it must refuse from harness arguments it must pass through. Both arrive
# here as positionals once the loop breaks.
DEJIMA_ARGS_ONLY=0
while [ $# -gt 0 ]; do
case "$1" in
--) shift; break ;;
--) DEJIMA_ARGS_ONLY=1; shift; break ;;
--print-config) PRINT_CONFIG=1; shift ;;
--print-briefing) PRINT_BRIEFING=1; shift ;;
--no-firewall) _set_override core.firewall off; shift ;;
@@ -255,6 +283,7 @@ while [ $# -gt 0 ]; do
# reach its prompt, and does not want that recorded as a preference
# for the launch they run by hand tomorrow.
--no-pause) NO_PAUSE=1; shift ;;
--isolated) DEJIMA_ISOLATED=1; shift ;;
--no-log) _set_override logging.loki_url ""
_set_override logging.conversation off
NO_LOG=1; shift ;;
@@ -287,6 +316,60 @@ while [ $# -gt 0 ]; do
esac
done
# An isolated launch reaches no forge and is not told to push, unless this
# command says otherwise.
#
# The flag narrows, like every other one here, and the forge credential is the
# widest thing a session gets: a forge token is commonly account-wide rather
# than per repository, so a session holding one can read and write everything
# the operator can. Carrying that by default into the mode whose purpose is a
# session you are not watching is the wrong way round - the host is narrowed
# to nothing and the forge is left at full reach.
#
# Applied after the loop rather than in it, so `--isolated --forge <name>` and
# `--forge <name> --isolated` agree: naming either on the same command line is
# how an operator asks for a session that clones and opens a pull request,
# which is a real use of this mode and not something to make impossible.
#
# A configured ssh key is untouched, because an isolated launch is not where
# that requirement lives - so a session with no forge still holds a key that
# can reach a host over ssh (#392). The forwarded agent is not in the same
# position: the workflow this sets pushes nothing, and a workflow that never
# pushes already gets no agent socket.
#
# Whether the operator named a forge, asked before the default below answers
# for them: it decides the GitHub credential further down as well, and after
# this block "is there an override" is true either way.
DEJIMA_FORGE_NAMED=0
if [ "$DEJIMA_ISOLATED" = 1 ]; then
if _override_set core.forge; then
DEJIMA_FORGE_NAMED=1
else
_set_override core.forge none
fi
_override_set core.workflow || _set_override core.workflow local-only
fi
# Refused rather than dropped. A configured mount is a standing preference and
# an isolated launch simply does not apply it, with a line saying so; --ro and
# --rw are the operator typing a path in this command, and the only two honest
# answers to "mount this, in a session that mounts nothing" are to refuse or
# to contradict them.
if [ "$DEJIMA_ISOLATED" = 1 ] && [ -n "$FLAG_MOUNTS" ]; then
echo "--isolated mounts nothing of the host, so it cannot take --ro or --rw:" >&2
# One per line, read rather than word-split: a path may contain a space,
# and the list is colon-separated precisely because it may not contain one
# of those.
while IFS= read -r _m; do
[ -n "$_m" ] || continue
printf ' %s\n' "$_m" >&2
done < <(printf '%s\n' "$FLAG_MOUNTS" | tr ':' '\n')
echo "" >&2
echo "Hand the session what it needs through ~/shared, which is the one" >&2
echo "directory an isolated session shares with you." >&2
exit 1
fi
# Wraps config_get so a flag wins over the file for the rest of this run.
# Defined here rather than in the library because an override is a property
# of one invocation of this launcher, not of the config file.
@@ -562,18 +645,35 @@ export DEJIMA_GITHUB_PUSH
# AUDIT_DIR, BASELINE_DIR and SANDBOX_EXTRA_MOUNTS are pure config reads (or,
# for WORKSPACE_DIR, a default-to-pwd with an existence check) - no side
# effects a print-and-exit needs to avoid.
WORKSPACE_DIR="${1:-$(pwd)}"
if [ ! -d "$WORKSPACE_DIR" ]; then
echo "Not a directory: $WORKSPACE_DIR" >&2
exit 1
fi
shift || true
if [ "$DEJIMA_ISOLATED" = 1 ]; then
# Refused rather than ignored. A path here would be read as the project
# this session is for, and nothing afterwards would contradict that - the
# directory simply would not be there. The operator may be anywhere;
# saying so is the point of the flag.
if [ "$DEJIMA_ARGS_ONLY" = 0 ] && [ $# -gt 0 ]; then
echo "--isolated takes no project directory, and would not mount $1." >&2
echo "Run it from anywhere: the working directory is the container's own." >&2
exit 1
fi
# Container-local, and outside $HOME on purpose - a handful of host-side
# reads below take this path (an origin URL for the forge sentence, the
# self-hosting check), and a path an operator might plausibly have would
# have them read a real directory of theirs. Created in the image.
WORKSPACE_DIR=/dejima/work
else
WORKSPACE_DIR="${1:-$(pwd)}"
if [ ! -d "$WORKSPACE_DIR" ]; then
echo "Not a directory: $WORKSPACE_DIR" >&2
exit 1
fi
shift || true
if [ "${1:-}" = "--" ]; then
shift
fi
if [ "${1:-}" = "--" ]; then
shift
fi
WORKSPACE_DIR="$(cd "$WORKSPACE_DIR" && pwd)"
WORKSPACE_DIR="$(cd "$WORKSPACE_DIR" && pwd)"
fi
# Resolved here, with the workspace, rather than beside the other approval
# reads further down: --print-briefing composes and exits well before those,
@@ -581,7 +681,29 @@ WORKSPACE_DIR="$(cd "$WORKSPACE_DIR" && pwd)"
# they decide. That is not hypothetical - the same ordering already caught
# DEJIMA_RW_MOUNTS, whose sentence went missing from --print-briefing while
# real sessions were told correctly.
PROJECT_STORE="$(resolve_project_store "$WORKSPACE_DIR")"
#
# Left empty for an isolated launch, and NOT resolved first and discarded:
# resolve_project_store walks upwards from the path it is given, so a launch
# from $HOME on a host whose dotfiles were once approved as a project would
# find that project's store - and with it the domains it may reach and the
# dependencies baked into its image. A directory the operator happens to be
# standing in decides nothing here, which is most of what the flag means.
#
# Every project-scoped read below is already guarded on this being non-empty
# - the corpus paragraph, the approved tools, the allowed domains, the
# dependency layer - so one empty assignment covers all four.
#
# It is not the only thing covering them, and that is deliberate rather than
# redundant. The working directory above is container-local, so even a
# resolver call here would be asking about /dejima/work rather than about
# wherever the operator was standing. Either of the two closes the leak on
# its own, and they answer different questions: that one is about where the
# session runs, this one about what it is entitled to. A test that reverses
# both is in project.bats, because reversing either alone proves nothing.
PROJECT_STORE=""
if [ "$DEJIMA_ISOLATED" = 0 ]; then
PROJECT_STORE="$(resolve_project_store "$WORKSPACE_DIR")"
fi
# Whether this project has been approved to keep its history as evidence, and
# so gets the commit-granularity paragraph in its briefing.
@@ -645,11 +767,36 @@ unset _tools_wanted _tool
AUDIT_DIR="$(config_get audit dir)"
BASELINE_DIR="$(config_get audit baselines_dir)"
# A [mounts] list as this launch should see it: every entry is a host path
# that outlives the session, so an isolated launch gets none of them.
#
# Dropped rather than refused, unlike --ro and --rw. These are a standing
# preference about ordinary work, written once and inherited by every launch;
# refusing them would mean an operator with a [mounts] readonly line could
# never use this flag at all. The notice below is what keeps that from being
# silent, because a directory that is simply absent inside the container is
# the least informative way to find out.
_mounts_list() {
[ "$DEJIMA_ISOLATED" = 0 ] || return 0
config_get mounts "$1"
}
if [ "$DEJIMA_ISOLATED" = 1 ]; then
_dropped=""
for _key in readonly read_write read_write_per_project; do
[ -n "$(config_get mounts "$_key")" ] || continue
_dropped="${_dropped:+$_dropped, }[mounts] $_key"
done
[ -z "$_dropped" ] \
|| echo "Isolated: $_dropped not applied - nothing of the host is mounted." >&2
unset _dropped _key
fi
# Read before either guard runs, because what this list declares becomes
# something the other two lists are refused for naming - see PP_PROJECT_ROOTS.
SANDBOX_RW_PP_MOUNTS="$(config_get mounts read_write_per_project)"
SANDBOX_RW_PP_MOUNTS="$(_mounts_list read_write_per_project)"
SANDBOX_EXTRA_MOUNTS="$(config_get mounts readonly)"
SANDBOX_EXTRA_MOUNTS="$(_mounts_list readonly)"
SANDBOX_EXTRA_MOUNTS="$(_append_mounts "$SANDBOX_EXTRA_MOUNTS" \
${EXTRA_RO_FLAGS[@]+"${EXTRA_RO_FLAGS[@]}"})"
@@ -778,7 +925,8 @@ _guarded_stores() {
[ ${#GUARDED_STORES[@]} -eq 0 ] || return 0
GUARDED_STORES=("$(config_root)" "$AUDIT_DIR" "$BASELINE_DIR" \
"$(config_get epitaph dir)" \
"$(share_projects_root "$(config_get core share_dir)")")
"$(share_projects_root "$(config_get core share_dir)")" \
"$(isolated_share_root "$(config_get core share_dir)")")
}
# The `projects` subdirectory of every read_write_per_project root: the
@@ -819,12 +967,12 @@ _refuse_guarded_mount() {
echo "Refusing $kind: $path" >&2
echo "It overlaps $guarded_real, which dejima needs out of a session's reach." >&2
echo "Even read-only, that hands the session every project's approvals, the" >&2
echo "record of what earlier sessions were caught doing, or every project's" >&2
echo "handoff files." >&2
echo "record of what earlier sessions were caught doing, or the handoff files" >&2
echo "of every project and every isolated session." >&2
exit 1
done
}
SANDBOX_RW_MOUNTS="$(config_get mounts read_write)"
SANDBOX_RW_MOUNTS="$(_mounts_list read_write)"
SANDBOX_RW_MOUNTS="$(_append_mounts "$SANDBOX_RW_MOUNTS" \
${EXTRA_RW_FLAGS[@]+"${EXTRA_RW_FLAGS[@]}"})"
RW_MOUNT_PATHS=()
@@ -1156,6 +1304,14 @@ if [ -n "$SANDBOX_SSH_KEY" ] && [ ! -f "$SANDBOX_SSH_KEY" ]; then
echo "[core] ssh_key is set but not a file: $SANDBOX_SSH_KEY" >&2
exit 1
fi
# An empty file is not a key: it passes the check above and loads nothing in the
# container. Treated as no key at all rather than refused, because the only
# launch it costs anything is one that pushes - and that case is Rule 4's
# below, which already says what to do about having neither.
have_usable_key=1
if [ -z "$SANDBOX_SSH_KEY" ] || [ ! -s "$SANDBOX_SSH_KEY" ]; then
have_usable_key=0
fi
if [ -n "$SANDBOX_SSH_KEY" ]; then
key_perms=$(stat -c '%a' "$SANDBOX_SSH_KEY")
if [ "${key_perms: -2}" != "00" ]; then
@@ -1185,7 +1341,10 @@ fi
# Rule 4: a workflow that never pushes has no use for either - skip the
# requirement entirely rather than force an operator doing local-only work
# to set up an agent or a deploy key for nothing.
if [ "$DEJIMA_WORKFLOW_PUSH" = "on" ] && [ "$have_forwarded_agent" = 0 ] && [ -z "$SANDBOX_SSH_KEY" ]; then
if [ "$DEJIMA_WORKFLOW_PUSH" = "on" ] && [ "$have_forwarded_agent" = 0 ] && [ "$have_usable_key" = 0 ]; then
if [ -n "$SANDBOX_SSH_KEY" ]; then
echo "[core] ssh_key names an empty file, so there is no key to load: $SANDBOX_SSH_KEY" >&2
fi
echo "SSH_AUTH_SOCK is not set or not a valid socket, and [core] ssh_key is not set." >&2
echo "Either start ssh-agent and run ssh-add, or set [core] ssh_key in $(config_path) to a" >&2
echo "dedicated key for headless use - see docs/configuration.md." >&2
@@ -1237,16 +1396,33 @@ fi
# boundary ~/.claude is already sliced per project to hold. What the container
# sees is still ~/shared, so nothing an agent has been told changes; what it
# cannot see any more is another project's.
#
# An isolated launch gets one of its own instead, under a sibling root. Still
# a real host directory, because handing a file back to the operator is the
# one thing that mode keeps - but never the directory any other session had,
# so it carries nothing in and nothing it is given reaches a later session.
# The operator is told the path below, since there is no project name to find
# it under.
SANDBOX_SHARE_ROOT="$(config_get core share_dir)"
SANDBOX_SHARE_DIR="$(share_dir_for "$SANDBOX_SHARE_ROOT" "$WORKSPACE_DIR")"
if [ "$DEJIMA_ISOLATED" = 1 ]; then
SANDBOX_SHARE_DIR="$(isolated_share_make "$SANDBOX_SHARE_ROOT")" || {
echo "Could not make an isolated share directory under $SANDBOX_SHARE_ROOT" >&2
exit 1
}
else
SANDBOX_SHARE_DIR="$(share_dir_for "$SANDBOX_SHARE_ROOT" "$WORKSPACE_DIR")"
fi
mkdir -p "$SANDBOX_SHARE_DIR"
# Files an operator left in share_dir before the split are still there and are
# no longer mounted anywhere. Said every launch while that is true, because
# the alternative is a file that was handed to a session yesterday silently
# not arriving today.
# Both roots dejima writes are excluded, not just the per-project one: the
# question this answers is "did something other than dejima put this here",
# and an isolated session's share is dejima's.
share_loose="$(find "$SANDBOX_SHARE_ROOT" -mindepth 1 -maxdepth 1 \
! -name projects -printf '%f\n' 2>/dev/null | sort)" || true
! -name projects ! -name isolated -printf '%f\n' 2>/dev/null | sort)" || true
if [ -n "$share_loose" ]; then
echo "Note: $SANDBOX_SHARE_ROOT holds entries no session can see any more," >&2
echo " because ~/shared is now this project's own subdirectory of it:" >&2
@@ -1275,8 +1451,14 @@ unset share_loose _entry
# Which directories those are is the harness's (harness_config_dirs); that
# they must exist before the mount is set up is dejima's. Already loaded, up
# where the read_write refusal needed them.
#
# Only the ones that will be mounted, which for an isolated launch is the
# read-only half - see the mount loop further down, which filters the same
# way. Creating the others would have an isolated launch leave four empty
# directories on a host it is not supposed to write to at all.
for _rec in ${DEJIMA_CONFIG_DIRS[@]+"${DEJIMA_CONFIG_DIRS[@]}"}; do
IFS=$'\t' read -r _cdir _cmode _cscan <<< "$_rec"
[ "$DEJIMA_ISOLATED" = 0 ] || [ "$_cmode" = ro ] || continue
mkdir -p "$_cdir"
done
@@ -1321,9 +1503,31 @@ harness_hook harness_prepare_config "$SANDBOX_SETTINGS_FILE" || exit 1
# A file jq cannot parse is a refusal, not a fallback to cp. Falling back
# would restore the exact leak this exists to close, at the moment least
# likely to be noticed.
#
# The `projects` map is narrowed to the launching project at the same time, for
# the reason #104 narrowed the projects MOUNT: the whole map is every project's.
# It keys per-project state by absolute path, so a copy of all of it tells a
# session the names of every project on the host, a sample of each one's
# filenames (`exampleFiles`), and what each has cost (#380).
#
# The project's own path and anything under it, because a session's cwd is
# routinely a worktree inside the project and Claude Code keys that separately.
# Paths are compared as strings: both sides of this mount agree on them by
# construction - same uid, same home, same project path - which is what makes
# the comparison meaningful rather than a guess about layout.
SANDBOX_CLAUDE_JSON_FILE="$(mktemp "${TMPDIR:-/tmp}/dejima-claude-json.XXXXXX.json")"
if [ -f "$HOME/.claude.json" ]; then
if ! jq 'del(.primaryApiKey)' "$HOME/.claude.json" > "$SANDBOX_CLAUDE_JSON_FILE"; then
# The type is checked rather than the key's presence: `projects` holding
# something other than an object is not a shape Claude Code writes, and
# narrowing it would error - turning a parseable file into a refused launch
# over a key with nothing in it to narrow.
if ! jq --arg proj "$WORKSPACE_DIR" '
del(.primaryApiKey)
| if (.projects | type) == "object" then
.projects |= with_entries(
select(.key == $proj or (.key | startswith($proj + "/"))))
else . end
' "$HOME/.claude.json" > "$SANDBOX_CLAUDE_JSON_FILE"; then
echo "$HOME/.claude.json is not readable as JSON, so dejima cannot strip the" >&2
echo "credential it may hold before copying it into the container." >&2
exit 1
@@ -1369,12 +1573,16 @@ DEJIMA_STATE_DIR=""
# different statement: what the trap must never inherit.
SANDBOX_BRIEFING_FILE=""
SANDBOX_CREDENTIAL_PROXY_DIR=""
SANDBOX_CREDENTIAL_PROXY_LOG=""
SANDBOX_FAKE_CREDENTIALS_FILE=""
# The same rule, for two names cleanup() hands to `docker rm` rather than to
# `rm`. An inherited value there removes a container or a network belonging to
# something else on this host, which is a worse thing to get wrong than a file.
SANDBOX_FILTER_CONTAINER=""
SANDBOX_RUN_NETWORK=""
# Same rule again, and `docker volume rm` is the third thing in this trap that
# destroys something named rather than something mktemp'd.
SANDBOX_ISOLATED_VOLUMES=""
SANDBOX_NVIM_LOCKFILE=""
SANDBOX_DERIVED_DOCKERFILE=""
SANDBOX_DERIVED_CONTEXT=""
@@ -1487,28 +1695,76 @@ cleanup() {
# retries turns one condition into several identical lines - three
# 429s four seconds apart was the first real instance - and a list of
# them says no more than the count does.
printf '\n%sThe model API answered with an error this session:%s\n' \
"${C_WARN:-}" "${C_RESET:-}" >&2
#
# match() on the whole line rather than a loop over fields. A loop
# needs an index, and an awk index dereferenced inside single quotes
# is indistinguishable to the test that every name cleanup() uses is
# bound before the EXIT trap can fire - it reads one and reports an
# unbound name. That test is right to be strict, so this stays out of
# its way rather than asking it to parse quoting.
awk '{ if (match($0, /answered [0-9]+/))
n[substr($0, RSTART + 9, RLENGTH - 9)]++ }
#
# Anchored to the whole line the proxy writes - its prefix, then
# `upstream answered N for` or `cannot load|refresh credentials:`,
# the spellings in bin/dejima-credential-proxy - not to a phrase
# appearing anywhere. The log also carries text chosen elsewhere: an
# upstream error-body prefix, an exception echoing the session's
# request path. Classified on a floating match, a token endpoint
# whose error body said `answered 503` fabricated a status count
# here, and a session whose only lines were connection failures was
# reported as unable to authenticate. Each section prints on its own
# evidence, and a session can have earned both.
_audit_counts="$(awk '{ if (match($0, /^[0-9:]* dejima-credential-proxy: upstream answered [0-9]+ for /)) {
match($0, / answered [0-9]+ for /)
n[substr($0, RSTART + 10, RLENGTH - 15)]++ } }
END { for (s in n) printf " %d x HTTP %s\n", n[s], s }' \
"$SANDBOX_CREDENTIAL_PROXY_LOG" >&2
printf ' most recent: %s\n' \
"$(tail -n 1 "$SANDBOX_CREDENTIAL_PROXY_LOG" \
| sed 's/^[0-9:]* dejima-credential-proxy: //')" >&2
# Said because the count above cannot distinguish them: a harness that
# retried a 429 or a 5xx may well have got its answer in the end, so
# this is not on its own a session that failed.
printf ' A retried status may still have succeeded; this is what the API said,\n' >&2
printf ' not a verdict on the session.\n' >&2
"$SANDBOX_CREDENTIAL_PROXY_LOG")"
_audit_api_last="$(awk '{ if (match($0, /^[0-9:]* dejima-credential-proxy: upstream answered [0-9]+ for /))
line = $0 }
END { print line }' "$SANDBOX_CREDENTIAL_PROXY_LOG" \
| sed 's/^[0-9:]* dejima-credential-proxy: //')"
# The `cannot ...` line names what failed; the line the proxy writes
# right after it is the remedy, and is the one an operator can act on.
_audit_cred="$(awk '
{ if (match($0, /^[0-9:]* dejima-credential-proxy: cannot (load|refresh) credentials: /)) {
cred = $0; want = NR + 1; remedy = "" }
else if (NR == want) remedy = $0 }
END { if (cred != "") { print cred; if (remedy != "") print remedy } }' \
"$SANDBOX_CREDENTIAL_PROXY_LOG" \
| sed 's/^[0-9:]* dejima-credential-proxy: //; s/^/ /')"
_audit_last="$(tail -n 1 "$SANDBOX_CREDENTIAL_PROXY_LOG" \
| sed 's/^[0-9:]* dejima-credential-proxy: //')"
if [ -n "$_audit_counts" ]; then
printf '\n%sThe model API answered with an error this session:%s\n' \
"${C_WARN:-}" "${C_RESET:-}" >&2
printf '%s\n' "$_audit_counts" >&2
printf ' most recent: %s\n' "$_audit_api_last" >&2
# Said because the count above cannot distinguish them: a harness
# that retried a 429 or a 5xx may well have got its answer in the
# end, so this is not on its own a session that failed.
printf ' A retried status may still have succeeded; this is what the API said,\n' >&2
printf ' not a verdict on the session.\n' >&2
fi
if [ -n "$_audit_cred" ]; then
# The proxy logged a failure the API never answered - it could not
# load or refresh the credential, and no request was made. Saying
# "the model API answered with an error" there sends an operator to
# check the provider's availability, and the retry caveat above it
# invites them to hope it succeeded anyway. Neither is true.
printf '\n%sThe session could not authenticate to the model API:%s\n' \
"${C_WARN:-}" "${C_RESET:-}" >&2
printf '%s\n' "$_audit_cred" >&2
fi
if [ -z "$_audit_counts" ] && [ -z "$_audit_cred" ]; then
# Something else: a request that never reached the upstream, a
# body the proxy refused. Neither an API answer nor a credential
# failure, so this claims neither and shows the line instead.
printf '\n%sThe credential proxy reported a problem this session:%s\n' \
"${C_WARN:-}" "${C_RESET:-}" >&2
printf ' %s\n' "$_audit_last" >&2
fi
unset _audit_counts _audit_last _audit_cred _audit_api_last
fi
rm -f "$SANDBOX_SETTINGS_FILE" "$SANDBOX_CLAUDE_JSON_FILE" "${SANDBOX_NVIM_LOCKFILE:-}" "${SANDBOX_FAKE_CREDENTIALS_FILE:-}" "${SANDBOX_DERIVED_DOCKERFILE:-}" "${SANDBOX_BRIEFING_FILE:-}" || true
rm -f "$SANDBOX_SETTINGS_FILE" "$SANDBOX_CLAUDE_JSON_FILE" "${SANDBOX_NVIM_LOCKFILE:-}" "${SANDBOX_FAKE_CREDENTIALS_FILE:-}" "${SANDBOX_DERIVED_DOCKERFILE:-}" "${SANDBOX_BRIEFING_FILE:-}" "${SANDBOX_CREDENTIAL_PROXY_LOG:-}" || true
rm -rf "${SANDBOX_GITCONFIG_DIR:-}" "${SANDBOX_CREDENTIAL_PROXY_DIR:-}" "${SANDBOX_DERIVED_CONTEXT:-}" || true
rm -f "${DEJIMA_SESSION_FILE:-}" || true
# dejima-notify-watch keys its debounce stamps by run id. Removed here,
@@ -1548,6 +1804,19 @@ cleanup() {
if [ -n "${SANDBOX_RUN_NETWORK:-}" ]; then
docker network rm "$SANDBOX_RUN_NETWORK" >/dev/null 2>&1 || true
fi
# The five volumes an isolated launch named for itself.
#
# What keeps them out of every other session is the name rather than this
# line: it carries this run's id and nothing else computes it, so a daemon
# that has gone away leaves volumes nobody can ask for. What this line is
# for is that they do not accumulate - one set per launch, and the
# working-directory volume among them holds everything the session cloned
# or wrote, with nothing else on the host to prune it.
if [ -n "${SANDBOX_ISOLATED_VOLUMES:-}" ]; then
# Deliberately unquoted - one name per word, all of them dejima's own.
# shellcheck disable=SC2086
docker volume rm $SANDBOX_ISOLATED_VOLUMES >/dev/null 2>&1 || true
fi
exit_step "session ended - keeping what it compacted away"
# Keep whatever the session compacted away, into a store nothing mounts.
# `|| true` for the same reason every other line in this trap has it: a
@@ -1566,7 +1835,13 @@ cleanup() {
# only ever suppresses, so a stale export makes epitaph quieter and never
# louder, which is what makes it safe to honour from the environment at
# all.
if [ "$(config_get_bool epitaph enabled)" = "1" ]; then
#
# Nothing to keep for an isolated launch: an epitaph is read out of the
# transcript on the host, and that mode mounts none - so this would write
# an empty record keyed to a working directory that will never exist
# again. Said at launch rather than inferred from an empty store, and the
# same goes for the conversation shipper further down.
if [ "$DEJIMA_ISOLATED" = 0 ] && [ "$(config_get_bool epitaph enabled)" = "1" ]; then
DEJIMA_EPITAPH_NO_SHIP="$NO_LOG" \
"$SCRIPT_DIR/dejima-epitaph" save "$WORKSPACE_DIR" || true
fi
@@ -1584,7 +1859,14 @@ cleanup() {
# HOST the session could have written, and for a per-project mount that is
# the slice rather than the root it sits in. Naming the root would claim a
# blast radius the per-project split exists to have narrowed.
DEJIMA_RW_MOUNTS="$DEJIMA_RW_SOURCES" "$SCRIPT_DIR/dejima-audit" compare "$WORKSPACE_DIR" "$SANDBOX_AUDIT_STATE" || true
#
# Not run for an isolated launch, which has no host-side surface for the
# audit to be about: its subject is the workspace's own files, and there
# is no workspace. The one thing such a session can write is ~/shared,
# and this never hashed that in any mode.
if [ "$DEJIMA_ISOLATED" = 0 ]; then
DEJIMA_RW_MOUNTS="$DEJIMA_RW_SOURCES" "$SCRIPT_DIR/dejima-audit" compare "$WORKSPACE_DIR" "$SANDBOX_AUDIT_STATE" || true
fi
rm -f "$SANDBOX_AUDIT_STATE" || true
}
trap cleanup EXIT
@@ -1653,6 +1935,22 @@ done
unset _git_field _git_value
export WORKSPACE_DIR
# The whole mount spec for the working directory, because the compose file
# cannot build it from a source and a target: an isolated launch mounts no
# host path there at all, and that is a spec with no colon in it rather than
# an empty one.
#
# An isolated launch names the compose file's isolated-work volume, which
# gets this run's own name below and is removed with the other four. A bare
# target would have asked for an anonymous volume instead, which is just as
# unreachable by another session but leaves the reaping to whatever `compose
# run --rm` does with one - and an unreaped workspace volume holds everything
# the session cloned, once per launch, with nothing to prune it.
if [ "$DEJIMA_ISOLATED" = 1 ]; then
export SANDBOX_WORKSPACE_MOUNT="isolated-work:$WORKSPACE_DIR"
else
export SANDBOX_WORKSPACE_MOUNT="$WORKSPACE_DIR:$WORKSPACE_DIR"
fi
# The image's user is built to match this uid/gid/username exactly (see
# Dockerfile) - this is what makes $HOME-based absolute paths (dotfiles,
# ~/.claude's plugin bookkeeping) resolve identically inside the container.
@@ -1804,10 +2102,38 @@ export SANDBOX_GITCONFIG_DIR
# forwarded either - left empty here, docker-compose.yml's
# ${SSH_AUTH_SOCK:-/dev/null} mounts nothing.
[ "$DEJIMA_WORKFLOW_PUSH" = "on" ] || SSH_AUTH_SOCK=""
# And neither does a launch that configured its own key. entrypoint.sh loads
# that key into an agent of its own and points SSH_AUTH_SOCK at it, so the
# forwarded one was already unused - but it stayed mounted at the path
# docker-compose.yml names, where `SSH_AUTH_SOCK=/ssh-agent ssh ...` signs with
# the operator's personal identities. Choosing a dedicated key has to mean the
# personal agent is not in the container, not that it is merely not the default.
[ "$have_usable_key" = 0 ] || SSH_AUTH_SOCK=""
export SSH_AUTH_SOCK
export SANDBOX_SSH_KEY_FILE="$SANDBOX_SSH_KEY"
SSH_KNOWN_HOSTS="$(config_get core known_hosts)"; export SSH_KNOWN_HOSTS
export GH_TOKEN="${GH_TOKEN:-}"
# What the SESSION gets, which is not the same question as what the filter
# container gets. GH_TOKEN above goes to the filter, where it authenticates
# the GitHub ranges fetch - a request dejima makes about its own allowlist,
# not something the session can reach - so that one crosses whatever the
# session is trusted with.
#
# This is core's read-only GitHub credential, filled from [github] token_file
# for every session whatever forge is selected. Read-only across an
# operator's private repositories is still account-wide reach, so an isolated
# launch does not get it: the mode's claim is that it carries no forge
# credential, and that claim has to cover the one core fills in as well as
# the one a forge declares.
#
# Unless the command named a forge, which is the operator saying this session
# is meant to reach one. Then nothing here is narrowed and the selection
# decides, exactly as for any other launch.
SANDBOX_GH_TOKEN="$GH_TOKEN"
if [ "$DEJIMA_ISOLATED" = 1 ] && [ "$DEJIMA_FORGE_NAMED" = 0 ]; then
SANDBOX_GH_TOKEN=""
fi
export SANDBOX_GH_TOKEN
export ALLOWED_DOMAINS="${ALLOWED_DOMAINS:-}"
# The --allow-github-push flag and a selected forge whose hosts_allowlisted_by_core
# says these are its own push hosts (see ALLOW_CORE_PUSH_GRANT above) are the
@@ -2438,6 +2764,19 @@ extra_mounts+=(-e "DEJIMA_FORGE_REPO_ENV=$DEJIMA_FORGE_REPO_ENV")
mounted_dirs=""
for _rec in ${DEJIMA_CONFIG_DIRS[@]+"${DEJIMA_CONFIG_DIRS[@]}"}; do
IFS=$'\t' read -r dir mounted_mode mounted_scan <<< "$_rec"
# An isolated launch takes the read-only ones and none of the writable
# ones. The split is already in the declaration, so this filters on what
# the harness said rather than on a list of names kept here.
#
# The two halves answer differently. A read-only directory is the
# OPERATOR's - their hooks, skills and commands - and a session cannot
# write to it, so nothing it holds came from an earlier session and
# nothing this one does will reach a later one. The writable ones are
# shared state every project's sessions append to (#380), which is the
# channel this mode exists to close.
if [ "$DEJIMA_ISOLATED" = 1 ] && [ "$mounted_mode" != ro ]; then
continue
fi
emit_mount "$dir" "$mounted_mode"
[ "$mounted_scan" = scan ] || continue
[ -d "$dir" ] || continue
@@ -2580,7 +2919,15 @@ fi
# harness plugin, which declares each mount through emit_mount. The guarded-
# store refusal runs there, at the point of declaration, rather than being
# something each plugin has to remember. See bin/dejima-harness-lib.sh.
harness_hook harness_state_mounts "$WORKSPACE_DIR"
#
# Not called at all for an isolated launch, which is both halves of what that
# mode needs here: this hook is what mounts the transcripts and the memory
# under ~/.claude/projects, and it CREATES the directory for the workspace's
# own slug before mounting it. Skipping the call is therefore also what stops
# an isolated launch leaving a directory on the host.
if [ "$DEJIMA_ISOLATED" = 0 ]; then
harness_hook harness_state_mounts "$WORKSPACE_DIR"
fi
# statusline.sh is executed on the host by Claude Code (settings.json's
# statusLine.command), so it is staged for entrypoint.sh to copy rather than
@@ -2755,10 +3102,15 @@ if [ -n "$DEJIMA_BACKEND_NAME" ] \
&& _credential_usable "$DEJIMA_BACKEND_CREDENTIAL_KIND" "$DEJIMA_BACKEND_CREDENTIAL_FILE" "$DEJIMA_BACKEND_CREDENTIAL_FIELD"; then
SANDBOX_CREDENTIAL_PROXY_DIR="$(mktemp -d "${TMPDIR:-/tmp}/dejima-credential-proxy.XXXXXX")"
SANDBOX_CREDENTIAL_PROXY_SOCKET="$SANDBOX_CREDENTIAL_PROXY_DIR/proxy.sock"
# Outside SANDBOX_CREDENTIAL_PROXY_DIR, which is bind-mounted into the
# session below so the socket is reachable. The log holds what the proxy
# learned from the provider - statuses, error-body prefixes - and
# docs/security.md says that stays on the host, so it must not share the
# one proxy path the session can read.
SANDBOX_CREDENTIAL_PROXY_LOG="$(mktemp "${TMPDIR:-/tmp}/dejima-credential-proxy-log.XXXXXX")"
# Every value comes from backends/<name>/backend.conf. Nothing about a
# backend is written here, which is what makes adding one a directory
# rather than an edit to this file.
SANDBOX_CREDENTIAL_PROXY_LOG="$SANDBOX_CREDENTIAL_PROXY_DIR/proxy.log"
_proxy_args=(
--socket "$SANDBOX_CREDENTIAL_PROXY_SOCKET"
--log "$SANDBOX_CREDENTIAL_PROXY_LOG"
@@ -2998,7 +3350,17 @@ if remote_enabled; then
# exists, so it covers the whole build, and it stops being true at exactly
# the moment the EXIT trap below stops being able to run.
{
printf 'workspace=%s\n' "$WORKSPACE_DIR"
# `dejima-attach --list` shows this column as a basename, which for an
# isolated launch would be "work" - the name of dejima's own scratch
# path rather than anything about the session. Same substitution the
# startup banner makes, and for the same reason. What tells two
# concurrent isolated sessions apart is the run id beside it, which
# that listing already prints.
if [ "$DEJIMA_ISOLATED" = 1 ]; then
printf 'workspace=isolated\n'
else
printf 'workspace=%s\n' "$WORKSPACE_DIR"
fi
printf 'pane=%s\n' "${TMUX_PANE:-}"
printf 'label=%s\n' "$dejima_tmux_label"
printf 'started=%s\n' "$(date +%s)"
@@ -3085,8 +3447,16 @@ startup_banner() {
# C_BADGE, the same name the pickers set the mark in. Two surfaces
# spelling the wordmark two different colours is the drift a shared
# palette exists to stop.
local name
# The working directory's basename would be "work" here, which names
# dejima's own scratch path rather than anything about the launch.
if [ "$DEJIMA_ISOLATED" = 1 ]; then
name="isolated"
else
name="$(basename -- "$WORKSPACE_DIR")"
fi
printf '\n %sdejima%s %s%s%s' \
"$C_BADGE" "$C_RESET" "$C_KEY" "$(basename -- "$WORKSPACE_DIR")" "$C_RESET" >&2
"$C_BADGE" "$C_RESET" "$C_KEY" "$name" "$C_RESET" >&2
[ -z "$branch" ] || printf ' %s%s%s %s' "$C_DIM" "$GLYPH_SEP" "$C_RESET" "$branch" >&2
printf '\n %sharness%s %s %sforge%s %s %sworkflow%s %s\n' \
"$C_DIM" "$C_RESET" "$DEJIMA_HARNESS_NAME${model:+ ($model)}" \
@@ -3099,6 +3469,21 @@ startup_banner() {
}
startup_banner
# Printed whatever [ui] banner says and whether or not stderr is a terminal,
# unlike the banner above: the share path is the only route back to anything
# this session hands over, and the two features it does without are ones an
# operator would otherwise go looking for afterwards and find empty.
if [ "$DEJIMA_ISOLATED" = 1 ]; then
printf '\n %sisolated%s no project mounted, no memory, no transcript;\n' \
"${C_KEY:-}" "${C_RESET:-}" >&2
printf ' conversation log and epitaph off - both read one\n' >&2
printf ' %sforge%s %s %sworkflow%s %s\n' \
"${C_DIM:-}" "${C_RESET:-}" "$DEJIMA_FORGE_NAME" \
"${C_DIM:-}" "${C_RESET:-}" "$DEJIMA_WORKFLOW_NAME" >&2
printf ' %sshared%s %s\n' "${C_DIM:-}" "${C_RESET:-}" "$SANDBOX_SHARE_DIR" >&2
printf ' the only thing that outlives this session\n' >&2
fi
printf '\n%sdejima%s is starting, please wait...\n' "$C_KEY" "$C_RESET" >&2
printf '%sA first run builds the container image, which takes several minutes.%s\n' \
"$C_DIM" "$C_RESET" >&2
@@ -3153,7 +3538,11 @@ fi
# Snapshot host-persistence surfaces so the post-run audit can report
# anything that changed while this session ran (advisory - see bin/dejima-audit).
"$SCRIPT_DIR/dejima-audit" snapshot "$WORKSPACE_DIR" "$SANDBOX_AUDIT_STATE" || true
# Paired with the compare in cleanup(), which states why an isolated launch
# does neither.
if [ "$DEJIMA_ISOLATED" = 0 ]; then
"$SCRIPT_DIR/dejima-audit" snapshot "$WORKSPACE_DIR" "$SANDBOX_AUDIT_STATE" || true
fi
# When the sandbox is pointed at its own repo, its TCB (bin/, hooks/,
# docker/, install.sh, test/) is mounted writable - warn, and the audit
@@ -3305,10 +3694,34 @@ export SANDBOX_IMAGE
#
# The prefix is not decoration: project_slug maps a leading `/` to `-`, and
# a Docker volume name may not start with one.
_nvim_volume_slug="$(project_slug "$WORKSPACE_DIR")"
export SANDBOX_NVIM_DATA="dejima_nvim-data${_nvim_volume_slug}"
export SANDBOX_NVIM_STATE="dejima_nvim-state${_nvim_volume_slug}"
export SANDBOX_NVIM_CACHE="dejima_nvim-cache${_nvim_volume_slug}"
#
# Per RUN rather than per project for an isolated launch, along with the shell
# history - which is shared by every project even when these are not, so it is
# otherwise the one surface where an isolated session reads what was typed in
# any session before it. Every isolated launch has the same working
# directory, so a slug would give them all the same four volumes and put back
# exactly the thing described above.
#
# The run id is what makes them unreachable: no other launch computes that
# name, so no other session can ask for these volumes whether or not the
# removal in cleanup() gets to run.
if [ "$DEJIMA_ISOLATED" = 1 ]; then
export SANDBOX_NVIM_DATA="dejima_isolated-nvim-data-$DEJIMA_RUN_ID"
export SANDBOX_NVIM_STATE="dejima_isolated-nvim-state-$DEJIMA_RUN_ID"
export SANDBOX_NVIM_CACHE="dejima_isolated-nvim-cache-$DEJIMA_RUN_ID"
export SANDBOX_BASH_HISTORY="dejima_isolated-bashhistory-$DEJIMA_RUN_ID"
# The working directory, which is a volume rather than a host path here -
# see SANDBOX_WORKSPACE_MOUNT above. It is the one of the five that holds
# whatever the session actually did, so it is the one that most needs
# removing rather than merely being unreachable.
export SANDBOX_ISOLATED_WORK="dejima_isolated-work-$DEJIMA_RUN_ID"
SANDBOX_ISOLATED_VOLUMES="$SANDBOX_NVIM_DATA $SANDBOX_NVIM_STATE $SANDBOX_NVIM_CACHE $SANDBOX_BASH_HISTORY $SANDBOX_ISOLATED_WORK"
else
_nvim_volume_slug="$(project_slug "$WORKSPACE_DIR")"
export SANDBOX_NVIM_DATA="dejima_nvim-data${_nvim_volume_slug}"
export SANDBOX_NVIM_STATE="dejima_nvim-state${_nvim_volume_slug}"
export SANDBOX_NVIM_CACHE="dejima_nvim-cache${_nvim_volume_slug}"
fi
# Not exec'd (the EXIT trap cleans up the generated temp files) and not a
# bare command under set -e (a nonzero/interrupted container run - e.g.
@@ -3504,6 +3917,12 @@ docker network create --label "dejima.run=$DEJIMA_RUN_ID" "$SANDBOX_RUN_NETWORK"
# docker-compose.yml declares for the sandbox. A static test reads the same
# names back out of the firewall scripts, so a script that starts reading a new
# one fails rather than silently allowlisting less than it was told to.
# The only two names here this launcher does not derive: they come from the
# operator's own environment and travel through untouched. Marked exported
# anyway, so that `-e NAME` carries a value rather than an empty string on a
# shell where they were set without being exported.
export EGRESS_HOST_ADDR EGRESS_HOST_PORTS
filter_env=(
-e ENABLE_FIREWALL # whether to install rules at all
-e ALLOWED_DOMAINS # the operator's approved names
@@ -3513,6 +3932,13 @@ filter_env=(
-e LOKI_URL # its host is allowlisted, and the shipper needs it
-e DEJIMA_INTERNAL_DOMAINS # approved names that really do resolve inside
-e DEJIMA_BACKEND_HOST # a self-served model's host, from its base_url
# The host entry: which address stands for "this container's host", and
# which of its TCP ports open. Both default inside egress-policy and
# neither has a config key, so passing them is what makes them reachable
# at all - without this the clamp would have no escape hatch an operator
# could use.
-e EGRESS_HOST_ADDR
-e EGRESS_HOST_PORTS
-e SANDBOX_HARNESS_DOMAINS
-e SANDBOX_BACKEND_PROXIED_DOMAIN
-e SANDBOX_CREDENTIAL_PROXY_SOCKET
@@ -3603,7 +4029,14 @@ compose_status=0
# Gated here as well as inside the shipper, which checks the same two keys for
# itself: --no-conversation and --no-log are overrides held in this process,
# and the shipper re-reads the config rather than inheriting them.
if [ "$(config_get_bool logging conversation)" = "1" ] &&
#
# And not at all for an isolated launch. The shipper tails a transcript that
# is on the host because the harness's state directory is mounted, and that
# mode mounts none - so what it would ship is nothing, from a file that is
# never created. Said at launch instead, because a log that is quietly empty
# is worse than one the operator knows they are not getting.
if [ "$DEJIMA_ISOLATED" = 0 ] &&
[ "$(config_get_bool logging conversation)" = "1" ] &&
[ -n "$(config_get logging loki_url)" ]; then
"$SCRIPT_DIR/dejima-conversation-ship" "$WORKSPACE_DIR" &
SANDBOX_CONVERSATION_SHIP_PID=$!
+221 -103
View File
@@ -6,11 +6,12 @@
# actually needs, it should tell you the exact command to run here rather
# than try to work around the firewall.
#
# Usage: dejima-allow [--global] [--project [path]] [--container <id>] <domain>
# Usage: dejima-allow [--global] [--project [path]] [--container <id>] <domain>...
# dejima-allow crates.io # this running session only
# dejima-allow --global crates.io # persist for every project
# dejima-allow --project crates.io # persist for the project in $PWD
# dejima-allow --project ~/myproj crates.io # persist for one named project
# dejima-allow dl.example.com files.example.org # several, confirmed first
#
# --project and --global are approvals for future sessions and deliberately do
# not touch a running container: a scope flag means "record this", not "patch
@@ -25,32 +26,32 @@ case "${1:-}" in
cat <<'DEJIMA_ALLOW_HELP'
dejima-allow - let a sandbox reach a domain the egress firewall blocks
Usage: dejima-allow [--global] [--project [path]] [--container <id>] <domain>
Usage: dejima-allow [--global] [--project [path]] [--container <id>] <domain>...
Up to twenty. More than one is confirmed first, defaulting to no, which
needs a terminal - so a script does them one at a time.
Scope, which decides who the grant is for and how long it lasts:
(none) the running container, right now, until it exits. Finds it
if exactly one is running. With several, it offers them to
pick from on a terminal, and lists them and stops anywhere
else - a prompt is no use to a script.
if exactly one is running. With several it offers them to
pick from on a terminal, by project and by the number the
dejima-attach picker gives each; anywhere else it lists
them and stops, a prompt being no use to a script.
--container <id> the same, for a named container. Use this when a session
told you its own id rather than letting anything guess.
With several running, they are listed by project and by
the number the dejima-attach picker gives each one. On a
terminal it offers them to pick from instead.
--project [path] every future session in one project. Also writes the domain
to that project's .dejima/allowed-domains, so it is shared
with everyone working on it. Path defaults to $PWD.
--global every future session in every project on this host.
They combine. --project and --global deliberately do NOT touch a session that
is already running: they are approvals for future ones. Pass --container as
well to do both at once.
They combine, and --project/--global deliberately do NOT touch a running
session: they are approvals for future ones, so pass --container to do both.
Prefer --project to --global - a domain one project needs is that project's,
and a global grant widens every other project's firewall too.
Prefer --project to --global. A domain one project needs is that project's, and
a global grant widens every other project's firewall too.
With --container, the domain is verified reachable afterwards and a warning is
With --container, each domain is verified reachable afterwards and a warning is
printed if it is not, so a typo or a blocked port does not look like success.
One that fails stops there naming what landed; a scope flag has them all by
then regardless.
See docs/configuration.md; docs/security.md explains what the allowlist bounds.
DEJIMA_ALLOW_HELP
@@ -73,34 +74,138 @@ source "$SCRIPT_DIR/../docker/fixed-domains.sh"
source "$SCRIPT_DIR/dejima-tui-lib.sh"
usage() {
echo "Usage: dejima-allow [--global] [--project [path]] [--container <id>] <domain>" >&2
echo "Usage: dejima-allow [--global] [--project [path]] [--container <id>] <domain>..." >&2
exit 1
}
# 1: the domain is the required trailing positional, which is what tells
# parse_scope_args whether a token after --project is a path or the domain.
# The most domains one command will take. A cap rather than no limit, because
# the confirmation below only works if the operator reads the list, and a list
# long enough to scroll is one nobody reads to the end of - which is where
# something would be hidden. Twenty is enough for the real case (a toolchain
# that fetches from several hosts) and short enough to take in at once.
readonly DEJIMA_ALLOW_MAX=20
# 1: at least one trailing positional, which is what tells parse_scope_args
# whether a token after --project is a path or a domain. It is a minimum, not
# an exact count - several domains may follow.
parse_scope_args 1 "$@" || usage
[ "${#dj_positional[@]}" -eq 1 ] || usage
domain="${dj_positional[0]}"
[ "${#dj_positional[@]}" -ge 1 ] || usage
domains=("${dj_positional[@]}")
global="$dj_global"
project=""
if [ "$dj_project_set" -eq 1 ]; then
# parse_scope_args consumes a token after --project as the path whenever
# enough positionals follow it, which with several domains is any of them.
# A path that is not a directory and does read as a domain was the first
# domain: `dejima-allow --project a.com b.com` means both of those, in the
# project in $PWD. A directory wins, so a project whose name happens to
# look like a hostname still works.
if [ -n "$dj_project" ] && [ ! -d "$dj_project" ] \
&& [[ "$dj_project" =~ ^[a-zA-Z0-9]([a-zA-Z0-9-]{0,62}\.)*[a-zA-Z0-9-]{1,63}$ ]]; then
domains=("$dj_project" "${domains[@]}")
dj_project=""
fi
project="$(cd "${dj_project:-$PWD}" && pwd)"
disclose_project_symlink "$project"
fi
container="$dj_container"
# Validated up front, not just passed as a positional arg below: this also
# has to be a well-formed hostname to be a valid dnsmasq server=/domain/...
# line (e.g. no embedded "/"), on top of ruling out shell metacharacters. A
# sandboxed agent can suggest this exact command to the user to copy-paste
# (see sandbox-context.sh), so a prompt-injected agent handing back a
# malicious "domain" is a real path here, not just a theoretical one.
if [[ ! "$domain" =~ ^[a-zA-Z0-9]([a-zA-Z0-9-]{0,62}\.)*[a-zA-Z0-9-]{1,63}$ ]]; then
echo "Not a valid domain: $domain" >&2
# Repeats dropped, order kept. A list with the same name twice asks the
# operator to approve it twice and makes the count they are reading wrong.
_seen=""
_unique=()
for _d in "${domains[@]}"; do
case "$_seen" in *"|${_d,,}|"*) continue ;; esac
_seen="$_seen|${_d,,}|"
_unique+=("$_d")
done
domains=("${_unique[@]}")
# What to call this request where it is described as a whole, rather than once
# per name. Anything outside the per-domain loops below has no $domain to use,
# and under set -u reaching for one is fatal rather than merely wrong - which is
# how the container picker's own prompt took this script down.
if [ "${#domains[@]}" -eq 1 ]; then
domain_label="${domains[0]}"
else
domain_label="${#domains[@]} domains"
fi
if [ "${#domains[@]}" -gt "$DEJIMA_ALLOW_MAX" ]; then
echo "That is ${#domains[@]} domains, and this takes at most $DEJIMA_ALLOW_MAX at once." >&2
echo "" >&2
echo "The limit is there because the confirmation below is only worth anything if" >&2
echo "you read the whole list. Split it up, and read each part." >&2
exit 1
fi
# Every domain validated BEFORE any of them is shown or recorded, and the whole
# command refused if any fails. Two reasons beyond the obvious one.
#
# The list the operator confirms has to be the list that gets added: validating
# as we went would ask them to approve names, then add a prefix of them and
# fail. And the confirmation rests on the list being readable as printed - this
# pattern allows letters, digits, hyphens and dots and nothing else, so no
# entry can carry a carriage return or an escape sequence that rewrites the
# lines above it on the way past.
#
# It also has to be a well-formed hostname to be a valid dnsmasq
# server=/domain/... line (e.g. no embedded "/"), on top of ruling out shell
# metacharacters. A sandboxed agent can suggest this exact command for the
# operator to copy-paste (see sandbox-context.sh), so a prompt-injected agent
# handing back a malicious "domain" is a real path here, not a theoretical one.
#
# All of them reported, not the first: an operator fixing a typo in a list of
# ten should not have to run it ten times to find the second one.
_bad=()
for _d in "${domains[@]}"; do
[[ "$_d" =~ ^[a-zA-Z0-9]([a-zA-Z0-9-]{0,62}\.)*[a-zA-Z0-9-]{1,63}$ ]] || _bad+=("$_d")
done
if [ "${#_bad[@]}" -gt 0 ]; then
if [ "${#_bad[@]}" -eq 1 ]; then
echo "Not a valid domain: ${_bad[0]}" >&2
else
echo "Not valid domains:" >&2
printf ' %s\n' "${_bad[@]}" >&2
fi
exit 1
fi
# One domain goes through as it always has. More than one is confirmed first,
# because a list is where something unwanted can ride along unread - and the
# agent that suggested the command is exactly what might have put it there.
#
# Default no, and the list on stdout one per line so nothing is off the right
# edge of a narrow terminal.
#
# No terminal means no confirmation is possible, so it refuses rather than
# assuming either answer. A script that wants several does them one at a time,
# which is the behaviour this had before and still has for one.
if [ "${#domains[@]}" -gt 1 ]; then
echo "About to allow ${#domains[@]} domains:"
printf ' %s\n' "${domains[@]}"
echo ""
_where=()
[ -n "$project" ] && _where+=("every future session in $project")
[ "$global" -eq 1 ] && _where+=("every future session in every project")
[ -n "$container" ] && _where+=("the running session $container")
[ "${#_where[@]}" -gt 0 ] || _where=("the running session")
printf 'This grants them to: %s\n' "$(printf '%s; ' "${_where[@]}" | sed 's/; $//')"
echo ""
if [ ! -t 0 ]; then
echo "Several domains at once needs a terminal to confirm on, and this is not one." >&2
echo "Run them one at a time, which needs no confirmation." >&2
exit 1
fi
printf 'Add all %s? [y/N] ' "${#domains[@]}"
read -r _reply || _reply=""
case "$_reply" in
y|Y|yes|YES|Yes) ;;
*) echo "Nothing was added."; exit 1 ;;
esac
echo ""
fi
# Recorded first, and unconditionally: --project/--global are the user's
# approval decision for future sessions, not a claim about this one. Gating
# either on a live container would break the documented workflow outright -
@@ -111,6 +216,7 @@ fi
# given together, and each persists to its own store.
if [ -n "$project" ]; then
store="$(ensure_project_store "$project")"
for domain in "${domains[@]}"; do
approve_entry "$store" allowed-domains "$domain"
echo "Added $domain to the approved domains for $project (applies to every future session in it)."
# Printed only after the approval above, and guarded rather than left to
@@ -124,11 +230,14 @@ if [ -n "$project" ]; then
echo "The approval above IS recorded and applies to every future session in it;" >&2
echo "only the project's own .dejima declaration could not be written." >&2
fi
done
fi
if [ "$global" -eq 1 ]; then
store="$(ensure_global_store)"
approve_entry "$store" allowed-domains "$domain"
echo "Added $domain to the global approved domains (applies to every future session)."
for domain in "${domains[@]}"; do
approve_entry "$store" allowed-domains "$domain"
echo "Added $domain to the global approved domains (applies to every future session)."
done
fi
# A scope flag means "record this approval", not "patch what is running now" -
@@ -225,7 +334,7 @@ pick_container() {
"$C_LINK" "$CT_W_NAME" "${CT_NAMES[$i]}" "$C_OFF" \
"$C_MUTED" "${CT_ATTACH[$i]}" "$C_OFF"
done
tui_pick "${#CT_IDS[@]}" "allow $domain in"
tui_pick "${#CT_IDS[@]}" "allow $domain_label in"
container="${CT_NAMES[$((TUI_CHOICE - 1))]}"
}
@@ -313,7 +422,7 @@ if [ "$session_wanted" -eq 1 ]; then
echo "inside it - either half of the pair is accepted here. If the session has" >&2
echo "already exited, record the domain for the next one instead:" >&2
echo "" >&2
echo " dejima-allow --project $domain" >&2
echo " dejima-allow --project ${domains[*]}" >&2
exit 1
fi
filter="$container"
@@ -348,83 +457,92 @@ if [ "$session_wanted" -eq 1 ]; then
# on the same name - with the failed launch as the operator's first hint
# that the two commands had different ideas.
#
# $domain reaches the container as an argument rather than inside the
# command string: it is validated above, and this keeps it that way if that
# validation is ever loosened.
# Each domain reaches the container as an argument rather than inside the
# command string: they are validated above, and this keeps it that way if
# that validation is ever loosened.
#
# Guarded on the file existing: config_get says so on stderr when it does
# not, which is right for a launch and noise for this, where a host with no
# configuration at all is an ordinary case and the answer is simply "not
# listed". A config that exists and is malformed still reports itself.
internal=0
if [ -f "$(config_path)" ]; then
# `|| [ -n "$_d" ]`, because config_get prints its value with no
# trailing newline - so a single-entry list is one line read never
# returns, and the loop body would not run at all.
# Case-folded, like the class decision below and dejima_mark_internal:
# a live add that misses the exemption the operator configured leaves
# the name without it until the next launch, which then refuses to
# start on the same mismatch.
while IFS= read -r _d || [ -n "$_d" ]; do
[ "${_d,,}" = "${domain,,}" ] && internal=1
done < <(config_get core internal_domains | tr ':' '\n')
fi
permit_args=(permit "$domain")
[ "$(dejima_class_for "$domain")" = forge ] && permit_args+=(--forge)
[ "$internal" -eq 1 ] && permit_args+=(--internal-ok)
echo "Permitting $domain in $filter, this session's egress control..."
if ! docker exec -u root "$filter" /usr/local/sbin/egress-policy "${permit_args[@]}"; then
echo "" >&2
echo "The permitted set was left as it was - the reason is above." >&2
if [ "$internal" -eq 0 ]; then
echo "" >&2
echo "If $domain resolves inside your own network, say so in the config and" >&2
echo "relaunch - an answer pointing inside is refused for every name that is" >&2
echo "not listed there, whether or not it is approved:" >&2
echo "" >&2
echo " [core]" >&2
echo " internal_domains = $domain" >&2
echo "" >&2
echo "It still needs an approval to be reachable at all:" >&2
echo "" >&2
echo " dejima-allow --global $domain" >&2
# One domain at a time, because each is its own change to the running
# policy: the class it belongs to, whether the operator declared it
# internal, and whether it then answers. A failure stops the loop rather
# than carrying on, so what did and did not land is the order printed
# above - and the names after it are still recorded under any scope flag
# given, which happened before this and is not undone by a live add
# failing.
for domain in "${domains[@]}"; do
internal=0
if [ -f "$(config_path)" ]; then
# `|| [ -n "$_d" ]`, because config_get prints its value with no
# trailing newline - so a single-entry list is one line read never
# returns, and the loop body would not run at all.
# Case-folded, like the class decision below and dejima_mark_internal:
# a live add that misses the exemption the operator configured leaves
# the name without it until the next launch, which then refuses to
# start on the same mismatch.
while IFS= read -r _d || [ -n "$_d" ]; do
[ "${_d,,}" = "${domain,,}" ] && internal=1
done < <(config_get core internal_domains | tr ':' '\n')
fi
exit 1
fi
echo "$domain allowed for the running session ($container)."
permit_args=(permit "$domain")
[ "$(dejima_class_for "$domain")" = forge ] && permit_args+=(--forge)
[ "$internal" -eq 1 ] && permit_args+=(--internal-ok)
# TCP-level truth check from inside the very container that was just
# updated - this script's job is the firewall, so "TCP connect succeeded"
# is the claim to verify. curl exit 6 (resolve failed), 7 (connect
# refused/blocked) and 28 (connect or overall timeout) mean still
# unreachable; anything else (0, or a TLS/HTTP-layer code like 35/52) means
# the connection itself went through the firewall fine. --max-time bounds
# the whole request, not just the connect phase - without it, a server that
# accepts the TCP connection and then stalls (never sends TLS/HTTP bytes)
# would hang this script indefinitely; curl maps that case to the same
# exit 28 the case below already handles, so no new branch is needed. This
# is a report on this run, not a gate on either scope's approval: both
# --project and --global are already recorded above, deliberately - a
# declared host may legitimately not answer on 443 (an internal service,
# an SSH-only host, an apex that only serves a subdomain), and the natural
# moment to add a global approval is precisely when nothing is running to
# probe in the first place. The exit 2 below still means what it says:
# the live patch to this session did not verify.
echo "Verifying $domain is reachable from $container..."
rc=0
docker exec "$container" curl -sI --connect-timeout 5 --max-time 10 "https://$domain/" >/dev/null 2>&1 || rc=$?
case "$rc" in
6|7|28)
echo "WARNING: $domain is still NOT reachable from $container (curl exit $rc)." >&2
echo "If the service doesn't listen on 443 this check can fail spuriously;" >&2
echo "otherwise the allow did not take effect - re-check the container ID." >&2
exit 2
;;
*)
echo "Verified: $domain is reachable from $container."
;;
esac
echo "Permitting $domain in $filter, this session's egress control..."
if ! docker exec -u root "$filter" /usr/local/sbin/egress-policy "${permit_args[@]}"; then
echo "" >&2
echo "The permitted set was left as it was - the reason is above." >&2
if [ "$internal" -eq 0 ]; then
echo "" >&2
echo "If $domain resolves inside your own network, say so in the config and" >&2
echo "relaunch - an answer pointing inside is refused for every name that is" >&2
echo "not listed there, whether or not it is approved:" >&2
echo "" >&2
echo " [core]" >&2
echo " internal_domains = $domain" >&2
echo "" >&2
echo "It still needs an approval to be reachable at all:" >&2
echo "" >&2
echo " dejima-allow --global $domain" >&2
fi
exit 1
fi
echo "$domain allowed for the running session ($container)."
# TCP-level truth check from inside the very container that was just
# updated - this script's job is the firewall, so "TCP connect succeeded"
# is the claim to verify. curl exit 6 (resolve failed), 7 (connect
# refused/blocked) and 28 (connect or overall timeout) mean still
# unreachable; anything else (0, or a TLS/HTTP-layer code like 35/52) means
# the connection itself went through the firewall fine. --max-time bounds
# the whole request, not just the connect phase - without it, a server that
# accepts the TCP connection and then stalls (never sends TLS/HTTP bytes)
# would hang this script indefinitely; curl maps that case to the same
# exit 28 the case below already handles, so no new branch is needed. This
# is a report on this run, not a gate on either scope's approval: both
# --project and --global are already recorded above, deliberately - a
# declared host may legitimately not answer on 443 (an internal service,
# an SSH-only host, an apex that only serves a subdomain), and the natural
# moment to add a global approval is precisely when nothing is running to
# probe in the first place. The exit 2 below still means what it says:
# the live patch to this session did not verify.
echo "Verifying $domain is reachable from $container..."
rc=0
docker exec "$container" curl -sI --connect-timeout 5 --max-time 10 "https://$domain/" >/dev/null 2>&1 || rc=$?
case "$rc" in
6|7|28)
echo "WARNING: $domain is still NOT reachable from $container (curl exit $rc)." >&2
echo "If the service doesn't listen on 443 this check can fail spuriously;" >&2
echo "otherwise the allow did not take effect - re-check the container ID." >&2
exit 2
;;
*)
echo "Verified: $domain is reachable from $container."
;;
esac
done
fi
+38 -7
View File
@@ -400,29 +400,54 @@ class Handler(BaseHTTPRequestHandler):
# forwarded upstream is a worse outcome than a rejected one.
MAX_BODY_BYTES = 32 * 1024 * 1024
def _length(self, raw, what):
"""A byte count from the wire, or BodyTooLarge.
`int()` alone accepts a negative, and a negative passes a `> MAX` test
and then means "read to EOF" to a buffered reader - so the cap above,
whose whole reason is that this runs on the host as the operator, was
bypassed by sending `Content-Length: -1`.
A value that is not a number still raises out of here, deliberately: the
suppression around handle_error is meant to be specific to the client
going away, and a malformed chunk length is the fault the test for that
uses to prove it. Refusing it cleanly here would remove the canary and
trade noise for blindness.
"""
n = int(raw, 16) if what == "chunk size" else int(raw)
if n < 0 or n > self.MAX_BODY_BYTES:
# The offending value, not the cap. The host log said "refused a
# request body: 33554432 bytes" whatever arrived, so a hostile -1
# and a genuinely oversized upload read identically - and the
# comment on the 413 path promises the log says what came in.
raise BodyTooLarge("%s of %d, cap %d"
% (what, n, self.MAX_BODY_BYTES))
return n
def _read_body(self):
if self.headers.get("Transfer-Encoding", "").lower() == "chunked":
chunks = []
total = 0
while True:
size = int(self.rfile.readline().split(b";")[0], 16)
size = self._length(self.rfile.readline().split(b";")[0], "chunk size")
if size == 0:
self.rfile.readline()
return b"".join(chunks)
total += size
if total > self.MAX_BODY_BYTES:
raise BodyTooLarge("%d bytes" % self.MAX_BODY_BYTES)
raise BodyTooLarge("chunked body of %d or more, cap %d"
% (total, self.MAX_BODY_BYTES))
chunks.append(self.rfile.read(size))
self.rfile.read(2)
length = int(self.headers.get("Content-Length", 0))
if length > self.MAX_BODY_BYTES:
raise BodyTooLarge("%d bytes" % self.MAX_BODY_BYTES)
length = self._length(self.headers.get("Content-Length", 0), "Content-Length")
return self.rfile.read(length)
def _fail(self, status, message, log_message=None):
# log_message (host stderr) may contain upstream response bytes or
# exception detail; message (sent to the container in the response
# body) never should - see docs/security.md.
# exception detail; message (sent to the container in the response body)
# never should. The one thing that does cross is text written in this
# file naming something the operator must do - see CredentialNeedsOperator,
# and docs/security.md for why that is not an exception to the rule.
self.log_error("%s", log_message if log_message is not None else message)
body = json.dumps(
{"type": "error", "error": {"type": "proxy_error", "message": message}}
@@ -519,6 +544,12 @@ class Handler(BaseHTTPRequestHandler):
# 413 rather than a traceback and a 500: the session gets a status
# that says what to do about it, and the host log says what came
# in. Nothing is forwarded upstream.
#
# And the connection ends here. The body was refused before it was
# read, so its bytes are still queued; left open, the next parse
# takes them as a request line and answers a keep-alive client with
# garbage 400s instead of one clean refusal.
self.close_connection = True
return self._fail(413, "credential proxy: request body too large",
log_message="refused a request body: %s" % e)
try:
+45 -21
View File
@@ -160,17 +160,31 @@ def classify(path, value):
return (None, None)
def walk_json(doc, prefix=""):
def walk_json(doc, prefix="", parts=()):
"""Yields (display path, value, components).
The display path is what an operator reads and what finding_id hashes, so
its spelling is fixed - it is recorded in the decisions file and a change
would orphan every standing answer. It is also ambiguous: a dict key of
`x[0]` and element 0 of a list `x` produce the same string, and so do a key
containing a dot and the traversal a dot denotes.
The components are the same walk as a tuple, which is not ambiguous. Anything
that has to FIND the value again uses those; re-parsing the string deleted
the wrong element, and for a dotted key raised KeyError out of the scrub,
leaving the file to be copied unscrubbed.
"""
if isinstance(doc, dict):
for k, v in doc.items():
yield from walk_json(v, "%s.%s" % (prefix, k) if prefix else str(k))
yield from walk_json(v, "%s.%s" % (prefix, k) if prefix else str(k),
parts + (("key", k),))
elif isinstance(doc, list):
# Indexed, so a decision recorded against one element does not silently
# cover a different one later.
for i, v in enumerate(doc):
yield from walk_json(v, "%s[%d]" % (prefix, i))
yield from walk_json(v, "%s[%d]" % (prefix, i), parts + (("idx", i),))
else:
yield (prefix, doc)
yield (prefix, doc, parts)
def ini_logical_lines(text):
@@ -227,17 +241,28 @@ def walk_ini(text):
yield (key, value)
def delete_json_path(doc, path):
# Mirrors walk_json's construction exactly, including list indices.
parts = re.findall(r"[^.\[\]]+|\[\d+\]", path)
cur = doc
for part in parts[:-1]:
cur = cur[int(part[1:-1])] if part.startswith("[") else cur[part]
last = parts[-1]
if last.startswith("["):
del cur[int(last[1:-1])]
else:
del cur[last]
def components_sort_key(parts):
"""Orders paths so a list element is deleted after any later sibling.
`del cur[i]` shifts every later index down, so applying findings in document
order deletes the wrong elements from the second onwards: two secrets in one
array left the second one in the file and removed an innocent value instead,
and the file is then mounted into the container - the operator answers "keep
it out" and it goes in. Deepest and highest index first makes each deletion
independent of the ones after it.
"""
return [(1, p[1]) if p[0] == "idx" else (0, p[1]) for p in parts]
def delete_json_components(doc, drop):
"""Every component path, in an order under which no deletion invalidates
another: deepest and highest index first, so a list deletion cannot shift an
element that is still to be removed."""
for parts in sorted(drop, key=components_sort_key, reverse=True):
cur = doc
for kind, item in parts[:-1]:
cur = cur[item]
del cur[parts[-1][1]]
def drop_ini_paths(text, paths):
@@ -311,21 +336,20 @@ def main():
print("%s is not readable as JSON" % args.label, file=sys.stderr)
return 2
else:
pairs = list(walk_ini(text))
pairs = [(p, v, ()) for p, v in walk_ini(text)]
if args.scrub:
wanted = set(load_list(args.scrub))
drop = [path for path, value in pairs
drop = [(path, parts) for path, value, parts in pairs
if finding_id(args.label, path) in wanted]
if not drop:
return 0
if args.format == "json":
doc = json.loads(text)
for path in drop:
delete_json_path(doc, path)
delete_json_components(doc, [parts for _path, parts in drop])
out = json.dumps(doc, indent=2) + "\n"
else:
out = drop_ini_paths(text, drop)
out = drop_ini_paths(text, [p for p, _parts in drop])
with open(args.file, "w") as f:
f.write(out)
return 0
@@ -350,7 +374,7 @@ def main():
return True
return False
for path, value in pairs:
for path, value, _parts in pairs:
fid = finding_id(args.label, path)
if fid in decided or judged_safe("%s\t%s" % (args.label, path)):
continue
+2 -2
View File
@@ -308,10 +308,10 @@ emit_host_exec() {
echo "Harness plugin emitted a host-exec leaf or label containing a tab or newline" >&2
exit 1 ;;
esac
[ -n "$leaf" ] && [ -n "$label" ] || {
if [ -z "$leaf" ] || [ -z "$label" ]; then
echo "Harness plugin emitted a host-exec surface with no leaf or label" >&2
exit 1
}
fi
DEJIMA_HOST_EXEC+=("$ns"$'\t'"$leaf"$'\t'"$path"$'\t'"$label")
}
+124 -20
View File
@@ -1661,7 +1661,7 @@ BRIEFING_FRAGMENT_MAX=16384
# is a hard refusal (_briefing_substitute below), not a literal passthrough -
# an unrecognized {{key}} reaching an agent's context verbatim is exactly the
# kind of silent drift this whole function exists to prevent.
_BRIEFING_WHITELIST="pr_command issue_command forge_name forge_ssh_host blocked_branches container_id project_dir workspace_dir session_id resume_command writable_mounts network_allowlist no_confirm_args harness_state_dir"
_BRIEFING_WHITELIST="pr_command issue_command forge_name forge_ssh_host blocked_branches container_id project_dir workspace_dir session_id resume_command writable_mounts network_allowlist no_confirm_args harness_state_dir workspace_posture project_posture"
_briefing_whitelisted() {
_set_contains "$_BRIEFING_WHITELIST" "$1"
@@ -1671,14 +1671,30 @@ _briefing_whitelisted() {
# allowlist is load-bearing for the agent too. Lives here, not in any
# provider directory - see the module-level comment. A quoted heredoc, so
# backticks and $ signs are literal and need no escaping.
_briefing_core_preamble() {
cat <<'BRIEFING_EOF'
You are running inside the dejima Docker container, not directly on
the user's machine, with
{{no_confirm_args}} active (no tool-use confirmations).
What this means:
- The workspace directory is bind-mounted read-write and is the real
# What the session is told about where it is: which of the host it can see,
# what it may hand back, and what survives it.
#
# Two variants rather than one paragraph with caveats, because the ordinary
# one states four things an isolated launch makes false - that the workspace
# is the real project, that ~/shared is shared with every other session
# against it, that the harness keeps state on the host, and that the work is
# recoverable there afterwards. A session acts on these: one that believes
# its transcript outlives the container leaves things for a resume that
# cannot happen.
#
# Continuation lines carry the two spaces the preamble's bullet list uses,
# since this is substituted into one.
_briefing_workspace_posture() {
if [ "${DEJIMA_ISOLATED:-0}" = 1 ]; then
printf '%s' "Nothing of the user's machine is mounted here and this session has no
past: no project checkout, no memory, and no transcript of an earlier
run. Your working directory is the container's own and goes with it, as
does anything you clone or write into it. ~/shared is the single
exception - a real host directory, made fresh for this session, which no
earlier session wrote to and no later one will read. Put there whatever
the user should still have afterwards."
else
printf '%s' "The workspace directory is bind-mounted read-write and is the real
project. ~/shared is also a real, persistent host directory (this
project's own, and not part of its checkout) - use it to hand the user a
file (a screenshot, a temp script) without touching the project checkout,
@@ -1687,8 +1703,37 @@ What this means:
as readable by all of them. Your harness keeps its own state on the host too,
so this session's transcript outlives the container - the work is
recoverable on the host with the command at the end of this
briefing.{{writable_mounts}} Everything else is disposable: installed system
packages, anything outside the mounted paths, and the container itself go
briefing."
fi
}
# The same split for what the session should expect to FIND. The ordinary
# text is about a mounted checkout holding things that are not code; an
# isolated session has no checkout to be careful with, and the useful
# instruction is the opposite one - ask rather than look.
_briefing_project_posture() {
if [ "${DEJIMA_ISOLATED:-0}" = 1 ]; then
printf '%s' "There is no project directory, so nothing you find in this container
came from the user. If a task needs a file of theirs, say what you need
rather than going looking for it: ~/shared is the only way they can hand
you one."
else
printf '%s' "The project directory is mounted whole, so it can hold things that are not
code: a .env, a database dump, a key someone left in the tree. Say so if
you find one, and do not open, copy or send it anywhere unless the task
actually requires it."
fi
}
_briefing_core_preamble() {
cat <<'BRIEFING_EOF'
You are running inside the dejima Docker container, not directly on
the user's machine, with
{{no_confirm_args}} active (no tool-use confirmations).
What this means:
- {{workspace_posture}}{{writable_mounts}} Everything else is disposable: installed system
packages, anything the mounts do not cover, and the container itself go
away when this run ends.
- A headless browser is installed: playwright, with chromium, importable
from any directory in either module system. If you change something with a
@@ -1696,8 +1741,10 @@ What this means:
and leave the screenshot in ~/shared.
- Outbound network is restricted to an allowlist (Anthropic's API, the npm
registry, {{network_allowlist}}, plus anything
approved via dejima-allow). Requests to anything else will simply
fail to connect. That restriction is not only a limit on your reach: it
approved via dejima-allow). Anything else is refused at once rather than
left to time out, so a connection that HANGS is not this allowlist - it is
something past the sandbox, and from in here the timing is the only thing
that tells the two apart. That restriction is not only a limit on your reach: it
is also what bounds where a prompt injection or a compromised dependency
in this session could send the user's code or fetch its next instruction
from. You cannot audit yourself for that from the inside, so the
@@ -1709,6 +1756,10 @@ What this means:
dejima-allow --container {{container_id}} <domain> # just this session
dejima-allow --project <path> <domain> # this project, always
dejima-allow --global <domain> # every project
Several domains can go on one line, up to twenty, and more than one is
printed back for the operator to confirm before anything is added. So ask
for the whole set a task needs in one command rather than one at a time -
but ask only for what it needs, because the list is read.
Pick deliberately rather than asking for all three: a domain this project
needs is --project, not --global - a global grant widens every other
project's firewall too, which is rarely what one project's dependency
@@ -1720,13 +1771,10 @@ What this means:
payment or production API acts on the world the moment it is called, with
no diff for anyone to review afterwards. Do not ask for one of those to
finish a task - say what you need and let the operator decide.
- The project directory is mounted whole, so it can hold things that are not
code: a .env, a database dump, a key someone left in the tree. Say so if
you find one, and do not open, copy or send it anywhere unless the task
actually requires it.
- {{project_posture}}
- Stay within the task you were given. Since there are no permission
prompts here, be more conservative than usual about anything destructive,
irreversible, or outside the project directory.
irreversible, or outside your working directory.
BRIEFING_EOF
}
@@ -1736,8 +1784,36 @@ BRIEFING_EOF
# workflow can only opt into it (requires_worktree = on), never edit or omit
# it. _briefing_resolve_worktree splices this into the workflow fragment at
# its `<!-- core: worktree -->` marker.
_briefing_core_worktree() {
# Where a branch's working tree must live, and why.
#
# The ordinary rule rests on one fact - that the bind-mounted project
# directory is the only path backed by host storage - and an isolated launch
# has no such path, so every clause of it would be advice about a checkout
# that is not there. What stays true is the question the rule answers: where
# work can be put such that it is not thrown away.
#
# Both worktree workflows stated the first bullet identically in their own
# fragments, which left the same argument in three places and only two of
# them substitutable. It is core's now, so a workflow declaring
# requires_worktree says where the block goes and nothing about what it says.
_briefing_core_worktree_isolated() {
cat <<'BRIEFING_EOF'
- A worktree here is just a directory, and nothing in this container is the
user's checkout: clone what you need into your working directory and work
there. The rule about keeping a worktree under the mounted project does
not apply, because there is no mounted project, and a `git config` in
here reaches no repository of theirs. What leaves this container is what
you push and what you leave in ~/shared.
BRIEFING_EOF
}
_briefing_core_worktree() {
[ "${DEJIMA_ISOLATED:-0}" = 0 ] || { _briefing_core_worktree_isolated; return 0; }
cat <<'BRIEFING_EOF'
- Do new work in a git worktree for the feature branch, not by switching
branches in place in the mounted checkout - that's the user's actual
working copy, and changing its branch out from under them is disruptive
even if you switch it back.
- The worktree MUST live under the mounted project directory itself, e.g.
`git worktree add .worktrees/<branch> -b <branch>` run from the project
root - never `../<branch>` or any other path outside it, including
@@ -1980,6 +2056,10 @@ _briefing_resolve_key() {
printf '%s' "GitHub read-only$hosts"
fi
;;
# Both of these differ by whether this is an isolated launch, and the
# prose for each case lives with the preamble it is substituted into.
workspace_posture) _briefing_workspace_posture ;;
project_posture) _briefing_project_posture ;;
blocked_branches) manifest_get workflow "$workflow" blocked_branches ;;
project_dir) printf '%s' "${WORKSPACE_DIR:-}" ;;
workspace_dir) printf '%s' "${WORKSPACE_DIR:-}" ;;
@@ -2155,6 +2235,18 @@ _briefing_core_tools() {
printf '%s' "$out"
}
# What closes an isolated briefing, in place of the harness's own sentence
# about resuming on the host.
_briefing_core_isolated_close() {
cat <<'BRIEFING_EOF'
This session cannot be resumed. Its transcript is inside the container and
goes when the container does, so anything worth keeping has to leave before
you finish: into ~/shared, and into a pushed branch where this session has a
forge to push to at all. Say what you left there, rather than assuming the
user can read it back out of the session afterwards.
BRIEFING_EOF
}
briefing_compose() {
local forge="$1" workflow="$2" harness="$3"
local caps autonomy workflow_frag harness_frag text
@@ -2215,7 +2307,19 @@ briefing_compose() {
# business resolving.
text+="$(_briefing_core_closing_a)"
text+=$'\n\n'
text+="$harness_frag"
# The harness fragment is about resuming this session on the host, which
# for an isolated launch is the one thing it cannot do: the state that
# resume reads is the state this mode does not mount. Replaced rather
# than dropped, because a session told nothing would reasonably assume
# the usual arrangement and leave work for a resume that never comes.
#
# The fragment is still composed and validated above, so a harness whose
# own briefing is broken is refused in this mode too.
if [ "${DEJIMA_ISOLATED:-0}" = 1 ]; then
text+="$(_briefing_core_isolated_close)"
else
text+="$harness_frag"
fi
_briefing_substitute "$text" "$forge" "$workflow" "$harness"
}
+21
View File
@@ -40,6 +40,27 @@ share_projects_root() { projects_root "$1"; }
# The host directory mounted at ~/shared for a launch against $2.
share_dir_for() { project_dir_for "$1" "$2"; }
# Where an isolated launch's ~/shared lives: a sibling of the per-project
# root, holding one directory per SESSION rather than one per project.
#
# A sibling rather than a slug inside it because an isolated session has no
# project to be keyed by, and the one thing this layout must never do is hand
# a second session the directory a first one used. A name is not enough to
# promise that, which is why the directory below is made rather than
# computed.
isolated_share_root() { printf '%s/isolated' "${1%/}"; }
# Makes this session's share directory under that root and prints its path.
# Creating is the point: `mktemp -d` is what makes "no other session has this
# directory" true for two launches in the same second, where a name built from
# a timestamp would silently hand both of them one directory.
isolated_share_make() {
local root
root="$(isolated_share_root "$1")"
mkdir -p "$root" || return 1
mktemp -d "$root/$(date -u +%Y%m%d-%H%M%S)-XXXXXX"
}
# The slugs belonging to $1: its own, plus one per live git worktree nested
# underneath it. One per line, sorted, no duplicates.
#
+11
View File
@@ -230,6 +230,14 @@ RUN mkdir -p $SANDBOX_HOME/.ssh && \
chown $SANDBOX_USER:$SANDBOX_USER $SANDBOX_HOME/.ssh && \
chmod 700 $SANDBOX_HOME/.ssh
# The working directory an isolated session gets instead of a project mount.
# Outside $SANDBOX_HOME on purpose: bin/dejima still reads this path on the
# HOST for a few things (an origin URL, a self-hosting check), so a path the
# operator might plausibly have - ~/work - would have those read a real
# directory of theirs. Nothing on a host is at /dejima.
RUN mkdir -p /dejima/work && \
chown -R $SANDBOX_USER:$SANDBOX_USER /dejima
# Pre-create the nvim data/state/cache dirs owned by the sandbox user,
# since they're mounted as named volumes in docker-compose.yml: Docker
# seeds a fresh named volume from whatever already exists at the mount
@@ -324,6 +332,9 @@ COPY docker/harness-wrapper.sh /usr/local/bin/dejima-harness-wrapper
# nothing until something puts them on PATH.
COPY docker/git-guard.sh /usr/local/lib/dejima/git-guard.sh
COPY docker/forge-guard.sh /usr/local/lib/dejima/forge-guard.sh
# The policy both of those apply, and that the two hooks beside them apply to
# the same command from the other side. One copy, so a bypass is fixed once.
COPY docker/guard-lib.sh /usr/local/lib/dejima/guard-lib.sh
# The briefing renderer, which every harness needs and no harness owns. Baked
# in rather than reached through the ~/.claude/hooks mount, because that mount
# is Claude Code's configuration: sandbox-context.sh calls this, and so does
+37 -3
View File
@@ -157,7 +157,13 @@ services:
# no write scopes). Used by `gh` and, via `gh auth setup-git`, by git
# itself for github.com over HTTPS - see the firewall/Dockerfile for
# why that's the only path GitHub is reachable through at all.
GH_TOKEN: ${GH_TOKEN:-}
#
# bin/dejima's SANDBOX_ prefixed copy rather than GH_TOKEN itself,
# because the filter container needs that one for the authenticated
# ranges fetch whatever the session is trusted with, and an isolated
# launch is trusted with less. Empty is an ordinary state here: it is
# what a host that configured no such token has always had.
GH_TOKEN: ${SANDBOX_GH_TOKEN-}
# Points git at the gitconfig copy mounted below instead of the
# default ~/.gitconfig - see that mount's comment for why it can't
# just live at ~/.gitconfig directly.
@@ -225,7 +231,16 @@ services:
# fixed /workspace) so Claude Code's project/session-history keying
# (based on cwd) lines up with the host, letting a session started
# here be resumed on the host with `claude --resume`.
- ${WORKSPACE_DIR:?set WORKSPACE_DIR to the project directory to mount}:${WORKSPACE_DIR}
#
# One variable holding the whole spec rather than a source and a target,
# because `--isolated` has to mount no host path here at all and an
# entry in this list cannot be left empty. There it names the
# isolated-work volume below, which bin/dejima gives this run's own name
# and removes afterwards - the same rule as the other four, rather than
# an anonymous volume whose reaping depends on what `compose run --rm`
# does with one. bin/dejima always sets it: ordinarily to the project
# directory at its own path, for the keying reason above.
- ${SANDBOX_WORKSPACE_MOUNT:?set by bin/dejima - the project directory, or a container-local path for --isolated}
# ~/.claude is enumerated, never mounted whole. A single-file bind
# mount is pinned to the dentry at its path, so a host-side rename -
# a sync daemon, or Claude Code's own atomic writes - unlinks it, the
@@ -289,7 +304,10 @@ services:
- nvim-state:${HOME}/.local/state/nvim
- nvim-cache:${HOME}/.cache/nvim
# Required only if SANDBOX_SSH_KEY isn't set - bin/dejima
# itself enforces that at least one of the two is present.
# itself enforces that at least one of the two is present, and blanks
# SSH_AUTH_SOCK when the key is the one chosen, so this mounts nothing
# then. A forwarded agent left here signs with the operator's personal
# identities for anything in the container that names this path.
- ${SSH_AUTH_SOCK:-/dev/null}:/ssh-agent
- ${SSH_KNOWN_HOSTS:-/dev/null}:${HOME}/.ssh/known_hosts:ro
# Dedicated, purpose-built key for headless git push - see README
@@ -316,7 +334,18 @@ services:
volumes:
# Shell history, shared by every harness - it is the container's shell that
# writes it, not the agent.
#
# Named like the three below so bin/dejima can give an `--isolated` launch
# one of its own: this volume is shared by every project, so the default is
# a session reading what the last one typed anywhere on the host.
#
# The default repeats the project name, which is not a typo. An entry with
# no `name:` gets `<compose project>_<key>`, and the project here is
# `dejima` while the key is `dejima-bashhistory` - so anything shorter is a
# DIFFERENT volume, and every ordinary launch would start from an empty
# history with the real one orphaned.
dejima-bashhistory:
name: ${SANDBOX_BASH_HISTORY:-dejima_dejima-bashhistory}
# Named explicitly so bin/dejima can give each project its own set. These
# persist across containers by design (a plugin install should not have to
# happen every launch), which without the split meant one project's session
@@ -326,6 +355,11 @@ volumes:
#
# The defaults reproduce the previous names exactly, so a `docker compose`
# invocation that bypasses bin/dejima behaves as it did.
# The working directory of an `--isolated` launch, which mounts no host
# path there. Referenced only when the workspace mount above names it, so
# an ordinary launch neither uses nor creates it.
isolated-work:
name: ${SANDBOX_ISOLATED_WORK:-dejima_isolated-work}
nvim-data:
name: ${SANDBOX_NVIM_DATA:-dejima_nvim-data}
nvim-state:
+250 -32
View File
@@ -126,6 +126,11 @@ INTERNAL_OK=${EGRESS_INTERNAL_OK:-}
INTERNAL_OK_HINT=${EGRESS_INTERNAL_OK_HINT:-EGRESS_INTERNAL_OK}
FORGE_SSH=${EGRESS_FORGE_SSH:-0}
HOST_ADDR=${EGRESS_HOST_ADDR:-}
# Which TCP ports the host entry opens. Empty - the default - opens none, so
# the address is a known destination that is refused rather than an implicit
# grant over everything the host publishes. `any` restores the unclamped rule,
# explicitly. See the clamp below for why none is the right default.
HOST_PORTS=${EGRESS_HOST_PORTS:-}
# Self-check probes. example.com is reserved by RFC 2606 for documentation and
# resolves globally, which is what makes it a usable negative: if it does not
# resolve here, the resolver is restricting names rather than simply broken.
@@ -138,6 +143,9 @@ PROBE_AAAA_NAME=${EGRESS_PROBE_AAAA_NAME:-registry.npmjs.org}
PROBE_ADDR4=${EGRESS_PROBE_ADDR4:-192.0.2.1}
FETCH_ATTEMPTS=${EGRESS_FETCH_ATTEMPTS:-5}
FETCH_TIMEOUT=${EGRESS_FETCH_TIMEOUT:-20}
# The ceiling on one wait between attempts, so a Retry-After nobody expected
# cannot turn a boot into an indefinite hang.
FETCH_MAX_BACKOFF=${EGRESS_FETCH_MAX_BACKOFF:-60}
say() { printf '[egress-policy] %s\n' "$*" >&2; }
die() { printf '[egress-policy] FAILED: %s\n' "$*" >&2; exit 1; }
@@ -224,15 +232,23 @@ classify_entry() {
}
record_entries() {
local class=$1 raw=$2 value kind exemption
local class=$1 raw=$2 value kind exemption ok_name
for value in ${raw//,/ }; do
value=${value%.}
[ -n "$value" ] || continue
kind=$(classify_entry "$value")
exemption=strict
case " ${INTERNAL_OK//,/ } " in
*" $value "*) exemption=internal-ok ;;
esac
# Case-folded and dot-stripped on both sides, like build_entries below and
# dejima_class_for/dejima_mark_internal on the launcher's side. Compared
# verbatim, a pair the launcher had accepted was recorded strict here, and
# the boot then refused the name's internal answer while telling the
# operator to configure the exemption they had already configured.
for ok_name in ${INTERNAL_OK//,/ }; do
ok_name=${ok_name%.}
if [ "${ok_name,,}" = "${value,,}" ]; then
exemption=internal-ok
fi
done
if [ "$exemption" = internal-ok ] && [ "$kind" = addr ]; then
# Harmless, but say so: the check it exempts is a check on answers, and
# an address the operator typed is not an answer.
@@ -272,7 +288,13 @@ build_entries() {
local value
for value in ${INTERNAL_OK//,/ }; do
[ -n "$value" ] || continue
listed=$(awk -v v="${value%.}" '$4 == v' "$ENTRIES_FILE")
# Case-folded on both sides, like dejima_class_for and dejima_mark_internal
# on the launcher's side of this. DNS names are case-insensitive and the
# resolver matches them so, and comparing verbatim here refused a launch
# whose configuration was correct - the launcher's own check had already
# passed on the same pair, so the refusal arrived from the wrong layer with
# a message saying the name was permitted by nothing.
listed=$(awk -v v="${value%.}" 'BEGIN { v = tolower(v) } tolower($4) == v' "$ENTRIES_FILE")
[ -n "$listed" ] || missing+=("$value")
done
[ ${#missing[@]} -eq 0 ] \
@@ -343,8 +365,20 @@ host_of_url() {
# through the policy. The session container has not been started at this point -
# the launcher waits for the readiness file - so the window is not one the agent
# can be in.
# The seconds the endpoint asked us to wait, if it asked. Header names are
# case-insensitive, and the value is either a count of seconds or an HTTP date
# - only the numeric form is honoured, because a date needs parsing to compare
# against a clock and a wrong answer there is a sleep nobody chose.
retry_after_from() {
local v
v=$(tr -d '\r' < "$1" 2>/dev/null \
| awk -F': *' 'tolower($1) == "retry-after" { print $2; exit }') || return 1
case "$v" in ''|*[!0-9]*) return 1 ;; esac
printf '%s' "$v"
}
fetch_forge_ranges() {
local out=$1 attempt rc http err
local out=$1 attempt rc http err retry wait limited
# Authenticated when the launcher supplied a token. The endpoint this
# defaults to is rate-limited per source address, unauthenticated, across
# everything on the host - so a host that starts sandboxes in a loop exhausts
@@ -353,15 +387,38 @@ fetch_forge_ranges() {
# the launcher's to choose.
local -a auth=()
[ -n "$FORGE_META_AUTH" ] && auth=(-H "$FORGE_META_AUTH")
# A file:// source is a test fixture and nothing else. test/runtime.bats
# starts twenty-seven container pairs in one run, each of which fetched the
# ranges for itself - which is what trips the endpoint's secondary limit
# (#357) - so the suite fetches once and points every pair at the result.
#
# Refused outside a test container, because the ranges decide which addresses
# the forge class reaches: a launch that could name its own source could name
# its own ranges. SANDBOX_TEST_MODE is set by that suite and by nothing in
# bin/dejima, so there is no launch this is reachable from.
case "$FORGE_META_URL" in
file://*)
[ "${SANDBOX_TEST_MODE:-0}" = 1 ] \
|| die "EGRESS_FORGE_META_URL names a file:// source, which is a test fixture only. The forge's address ranges decide what the forge class reaches, so they are not read from the filesystem in a real launch."
;;
esac
for ((attempt = 1; attempt <= FETCH_ATTEMPTS; attempt++)); do
rc=0
: > "$out.hdr"
http=$(curl --silent --show-error --location \
--max-time "$FETCH_TIMEOUT" \
--write-out '%{http_code}' \
--dump-header "$out.hdr" \
--output "$out.part" \
${auth[@]+"${auth[@]}"} \
"$FORGE_META_URL" 2>"$out.err") || rc=$?
if [ "$rc" -eq 0 ] && [ "$http" = 200 ]; then
# curl reports no status for a scheme that has none, so what counts as a
# good transfer is the scheme's own answer rather than 200 always.
local want_http=200
case "$FORGE_META_URL" in file://*) want_http=000 ;; esac
if [ "$rc" -eq 0 ] && [ "$http" = "$want_http" ]; then
# Whether the body carries ranges is part of whether the fetch worked,
# so it is decided here rather than after the loop. A rate limit is the
# reason: it answers with a message and no ranges, sometimes over a 200,
@@ -393,12 +450,39 @@ fetch_forge_ranges() {
# reason at all - which is the diagnostic this whole path exists to give.
err=$(jq -re '.message' < "$out.part" 2>/dev/null || true)
[ -n "$err" ] || err="response carried no ranges"
say "attempt $attempt/$FETCH_ATTEMPTS to fetch $FORGE_META_URL failed: $err"
say "attempt $attempt/$FETCH_ATTEMPTS to fetch $FORGE_META_URL failed: http $http, $err"
else
err=$(tr '\n' ' ' < "$out.err")
# The body first, because an HTTP failure from this endpoint carries one
# and curl's stderr does not: "API rate limit exceeded" names the cause,
# where "curl exit 0, http 403" names only the symptom. curl's own
# message is what is left when there was no response to carry a body.
err=$(jq -re '.message' < "$out.part" 2>/dev/null || true)
[ -n "$err" ] || err=$(tr '\n' ' ' < "$out.err")
say "attempt $attempt/$FETCH_ATTEMPTS to fetch $FORGE_META_URL failed: curl exit $rc, http ${http:-none}${err:+, }$err"
fi
[ "$attempt" -lt "$FETCH_ATTEMPTS" ] && sleep $((attempt * 2))
# A rate limit is the one failure here that clears by waiting, and the only
# one worth waiting long for. GitHub reports a secondary limit as 403 with
# a Retry-After, and a primary one as 403 or 429 - but it will also answer
# 200 with a message and no ranges, so the body counts as evidence too.
limited=0
case "$http" in 403|429) limited=1 ;; esac
case "${err,,}" in *"rate limit"*|*"abuse"*|*"too many requests"*) limited=1 ;; esac
# attempt*2 spends twenty seconds over five tries, and a secondary limit is
# measured in tens of seconds - so the whole sequence could sit inside one
# window and exhaust itself without ever reaching the other side (#357).
wait=$((attempt * 2))
[ "$limited" -eq 1 ] && wait=$((attempt * 15))
# What the endpoint asked for beats what this guessed, which is the only
# number here that is not an estimate.
retry=$(retry_after_from "$out.hdr") && wait=$retry
[ "$wait" -le "$FETCH_MAX_BACKOFF" ] || wait=$FETCH_MAX_BACKOFF
if [ "$attempt" -lt "$FETCH_ATTEMPTS" ]; then
say "waiting ${wait}s before attempt $((attempt + 1))${retry:+ (Retry-After: ${retry}s)}"
sleep "$wait"
fi
retry=""
done
die "could not fetch the forge address ranges after $FETCH_ATTEMPTS attempts; the reason for each is above. The sandbox cannot start without them - the policy would refuse every forge range, so git, gh and the API would all fail once inside."
}
@@ -435,6 +519,56 @@ emit_forge_clamp() {
printf ' %s daddr @%s goto refuse\n' "$family" "$set"
}
# The host entry is the one destination nobody declared: it is the gateway of
# the default route, derived rather than configured. Docker publishes container
# ports on that address, so permitting it on every port reached the host's sshd,
# anything bound to 0.0.0.0, and every published port of every other container
# on the machine - including the remote-access front door dejima itself ships,
# which is a host shell behind a tmux prefix key (#363).
#
# Nothing in a launch needs it. A host-resident forge, Loki or self-served model
# is reached through its own entry, by name or as a literal address, and either
# way lands in a set of its own. So the default here is no ports at all, and the
# address stays a named destination that is refused rather than one that falls
# through to the end of the chain - the counter says the host was reached for,
# which an operator debugging a clamp needs to see.
#
# TCP only: nothing here has ever permitted UDP to the host, and DNS to it is
# refused above with everyone else's.
emit_host_clamp() {
local set=$1 p list=""
if [ "$HOST_PORTS" = any ]; then
printf ' ip daddr @%s goto permit\n' "$set"
return
fi
# Rebuilt element by element rather than by substituting the separators: the
# value may be comma-separated, space-separated or both, and a substitution
# turns ", " into an empty element that nft rejects.
for p in ${HOST_PORTS//,/ }; do
list="${list:+$list, }$p"
done
if [ -n "$list" ]; then
printf ' ip daddr @%s tcp dport { %s } goto permit\n' "$set" "$list"
fi
printf ' ip daddr @%s goto refuse\n' "$set"
}
# A typo stops the container rather than becoming a rule that permits the wrong
# port or a ruleset nft refuses after the fact, which is the same reasoning as
# classify_entry's: a destination that looks configured and is silently refused
# is the worst of the three.
validate_host_ports() {
local p
[ "$HOST_PORTS" = any ] && return 0
for p in ${HOST_PORTS//,/ }; do
[[ $p =~ ^[0-9]+(-[0-9]+)?$ ]] \
|| die "EGRESS_HOST_PORTS entry '$p' is not a TCP port or a low-high range (or the whole value 'any')"
local lo=${p%%-*} hi=${p##*-}
{ [ "$lo" -ge 1 ] && [ "$hi" -le 65535 ] && [ "$lo" -le "$hi" ]; } \
|| die "EGRESS_HOST_PORTS entry '$p' is not within 1-65535, low first"
done
}
# Rules are generated into one file and loaded with `nft -f`, which is a single
# transaction: the namespace is never left half-filtered. The table is replaced
# rather than the ruleset flushed - the runtime has its own rules in this
@@ -642,8 +776,12 @@ EOF
ip daddr @oname_v4 goto permit
ip daddr @literal_v4 goto permit
# The container's own host, as a single address.
ip daddr @host_v4 goto permit
# The container's own host, as a single address, on the ports it was told
# to open and no others.
EOF
emit_host_clamp host_v4
cat <<EOF
# Anything else: logged, then refused. The chain policy is drop as a
# backstop, but nothing should ever reach it.
@@ -757,9 +895,21 @@ EOF
fi
}
# The forge credential, removed from a daemon's environment at the point it
# starts rather than somewhere upstream of it. All three of these parse content
# the session's own traffic decides, and all three are reachable by more than
# one call path: `dejima-allow` runs `egress-policy permit` through `docker
# exec`, which gets the filter container's configured environment, and both
# permit_name and rollback_entries restart the resolver. An unset earlier in
# one path says nothing about the others; this says it where it has to hold.
#
# `env -u` rather than an unset, so the scrub is visible on the line that
# starts the daemon and cannot be separated from it by an edit elsewhere.
readonly NO_CREDENTIAL=(env -u GH_TOKEN -u EGRESS_FORGE_META_AUTH)
start_resolver() {
stop_daemon "$RESOLVER_PID" dnsmasq
dnsmasq -C "$RESOLVER_CONF" || die "the resolver did not start"
"${NO_CREDENTIAL[@]}" dnsmasq -C "$RESOLVER_CONF" || die "the resolver did not start"
wait_for_daemon dnsmasq
# Waiting for the process is not waiting for the resolver. dnsmasq
@@ -848,7 +998,7 @@ start_connection_log() {
stop_daemon "$ULOGD_PID" ulogd
id -u "$LOGGER_USER" >/dev/null 2>&1 \
|| die "the image did not create the $LOGGER_USER account, which the connection logger drops to"
ulogd -d -u "$LOGGER_USER" -c "$ULOGD_CONF" -p "$ULOGD_PID" \
"${NO_CREDENTIAL[@]}" ulogd -d -u "$LOGGER_USER" -c "$ULOGD_CONF" -p "$ULOGD_PID" \
|| die "the connection logger did not start"
wait_for_daemon ulogd
}
@@ -871,7 +1021,7 @@ start_lookup_log() {
# resolver's log - and that is the last thing in this container that should
# be running with privilege. It opens two files and then only reads and
# writes them, so the drop costs it nothing.
nohup gosu "$LOGGER_USER" "$LOOKUP_LOGGER" --source "$RESOLVER_LOG" --output "$LOOKUP_LOG" \
nohup "${NO_CREDENTIAL[@]}" gosu "$LOGGER_USER" "$LOOKUP_LOGGER" --source "$RESOLVER_LOG" --output "$LOOKUP_LOG" \
>/dev/null 2>>"$LOGDIR/lookup-log.err" &
echo $! > "$LOOKUP_PID"
sleep 0.3
@@ -939,8 +1089,31 @@ retry_positive() {
# Ordered so that a failure names its own cause: the positive checks come
# first, because "a blocked destination was refused" means nothing until
# something that should work has been shown to work.
# Which permitted name the resolver checks are about.
#
# Normally the meta URL's host, which is a permitted forge-class name by
# construction. A file:// source has no host at all - that is the suite's
# cached-ranges fixture (#357) - and handing an empty name to check 3 asked the
# resolver to look up "" and failed the boot on it.
#
# The fallback is a permitted forge-class name, because the property those
# checks state is about the POLICY: that the resolver resolves a permitted name
# and files its address in that name's own set. Where the ranges were read from
# is a different question, and check 5's.
#
# sed rather than head: a producer whose pipe closes early takes SIGPIPE, and
# pipefail makes that the pipeline's status - see the note on grep -q above.
verify_subject() {
local host=$1
if [ -n "$host" ]; then
printf '%s' "$host"
return 0
fi
entries_of forge name | sed -n '1p'
}
verify() {
local forge_host=$1 rc answer set_name
local forge_host=$1 rc answer set_name subject
pgrep -x dnsmasq >/dev/null || die "check 1: the resolver is not running"
pgrep -x ulogd >/dev/null || die "check 1: the connection logger is not running"
@@ -954,9 +1127,13 @@ verify() {
[ "$running_as" = "$RESOLVER_USER" ] \
|| die "check 2: the resolver is running as '${running_as:-unknown}', not $RESOLVER_USER, so the rule permitting it to reach an upstream resolver is keyed to the wrong account"
answer=$(retry_positive 3 resolve_one "$forge_host") || true
subject=$(verify_subject "$forge_host")
[ -n "$subject" ] \
|| die "check 3: there is no permitted forge-class name to check the resolver against, and no host in $FORGE_META_URL to fall back on"
answer=$(retry_positive 3 resolve_one "$subject") || true
[ -n "$answer" ] \
|| die "check 3: the permitted name $forge_host did not resolve through the local resolver - name resolution is broken, not restricted.$(why_no_answer "$forge_host")"
|| die "check 3: the permitted name $subject did not resolve through the local resolver - name resolution is broken, not restricted.$(why_no_answer "$subject")"
# The resolver is also what puts a permitted name's addresses into the
# allow-list, and which set it uses is the name's class. Checking membership
@@ -970,13 +1147,25 @@ verify() {
# two cannot look in different sets.
set_name=$(set_for_class forge)
nft get element inet "$TABLE" "$set_name" "{ $answer }" >/dev/null 2>&1 \
|| die "check 4: $forge_host resolved to $answer but the resolver did not add it to $set_name - permitted names would resolve and then be refused"
|| die "check 4: $subject resolved to $answer but the resolver did not add it to $set_name - permitted names would resolve and then be refused"
rc=0
retry_positive 3 curl --silent --show-error --output /dev/null \
--connect-timeout 5 --max-time 20 "$FORGE_META_URL" || rc=$?
[ "$rc" -eq 0 ] \
|| die "check 5: the permitted destination $FORGE_META_URL is not reachable through the policy (curl exit $rc)"
# Skipped for a file:// source, and said out loud. This check asserts that a
# permitted destination is reachable THROUGH the policy, and a local file is
# not one - reaching it would pass while proving nothing. That is a real
# reduction on the runs using the cached ranges, which is why it is a line in
# the log rather than a silent pass.
case "$FORGE_META_URL" in
file://*)
say "check 5 skipped: the ranges came from a file, so reading it again says nothing about what the policy permits"
;;
*)
rc=0
retry_positive 3 curl --silent --show-error --output /dev/null \
--connect-timeout 5 --max-time 20 "$FORGE_META_URL" || rc=$?
[ "$rc" -eq 0 ] \
|| die "check 5: the permitted destination $FORGE_META_URL is not reachable through the policy (curl exit $rc)"
;;
esac
# The same address, on the port the opt-in gates. With the opt-in off it must
# be refused even though the address was reached by name, which is the whole
@@ -1229,7 +1418,12 @@ resolve_one() {
local answers line
# A only: the resolver is configured to answer no AAAA at all, so asking for
# one spends a query to be told nothing.
answers=$(dig +short +time=3 +tries=2 @127.0.0.1 "$1" A 2>/dev/null) || return 0
# Non-zero when dig itself failed, not zero: that is a lookup which did not
# come back - a resolver a moment behind the container is exactly it - and so
# the one case the positive checks' retry exists to absorb. Returning 0 there
# reported success with no address, which retry_positive takes as an answer
# and the caller then dies on (#345).
answers=$(dig +short +time=3 +tries=2 @127.0.0.1 "$1" A 2>/dev/null) || return 1
while IFS= read -r line; do
[[ "$line" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]] || continue
printf '%s\n' "$line"
@@ -1292,6 +1486,7 @@ cmd_apply() {
discover_upstreams
local host_addrs forge_host
validate_host_ports
host_addrs=$(discover_host_addr)
forge_host=$(host_of_url "$FORGE_META_URL")
@@ -1306,6 +1501,11 @@ cmd_apply() {
local meta="$RUNDIR/forge-meta.json"
fetch_forge_ranges "$meta"
# The one request that needed the credential has been made, so it stops here
# for the rest of this process too. Each daemon start scrubs its own
# environment as well (NO_CREDENTIAL), because `permit` reaches them without
# ever coming through here.
unset FORGE_META_AUTH EGRESS_FORGE_META_AUTH
local ranges forge4
ranges=$(jq -r "$FORGE_META_FILTER" < "$meta" | grep -v '^null$' | grep -v '^$') \
@@ -1325,7 +1525,7 @@ cmd_apply() {
resolver_uid=$(id -u "$RESOLVER_USER")
apply_ruleset "$forge4" "$host_addrs" "$resolver_uid"
say "policy applied: $(printf '%s\n' "$ranges" | wc -l) forge ranges, host $(printf '%s ' $host_addrs), forge ssh=$FORGE_SSH"
say "policy applied: $(printf '%s\n' "$ranges" | wc -l) forge ranges, host $(printf '%s ' $host_addrs) on ports ${HOST_PORTS:-none}, forge ssh=$FORGE_SSH"
write_log_config
start_connection_log
@@ -1412,13 +1612,26 @@ rollback_entries() {
# An address needs no resolution and no restart: it goes straight into the sets
# its class uses. Both sets are interval sets, so a prefix works as well as a
# single address.
# A forge-class address belongs in both sets, and the adds are not one
# transaction: if the second fails, the first has to come back out. Otherwise
# the address the forge class says is HTTPS-only is left in literal_v4, which
# permits every port, while the entries file rolls back - so nothing reports a
# grant that is live for the rest of the session.
#
# Deleting it is unconditional, which can take back an element that was in that
# set from somewhere else. That direction is safe: the refusal already rolled
# the record back, so a narrower live set agrees with what `entries` says.
permit_address() {
local class=$1 value=$2
nft add element inet "$TABLE" literal_v4 "{ $value }" \
|| { mv "$ENTRIES_FILE.prev" "$ENTRIES_FILE"; die "could not add $value to the allow-list"; }
if [ "$class" = forge ]; then
nft add element inet "$TABLE" forge_v4 "{ $value }" \
|| { mv "$ENTRIES_FILE.prev" "$ENTRIES_FILE"; die "could not add $value to the forge class"; }
nft add element inet "$TABLE" forge_v4 "{ $value }" || {
nft delete element inet "$TABLE" literal_v4 "{ $value }" 2>/dev/null \
|| say "WARNING: $value is still in the any-port allow-list and could not be removed; restart the session"
mv "$ENTRIES_FILE.prev" "$ENTRIES_FILE"
die "could not add $value to the forge class"
}
fi
say "$value permitted ($class class, direct)"
}
@@ -1429,7 +1642,7 @@ permit_address() {
# up in the set its class uses. A name that resolved but never reached the
# allow-list would fail later as a mystery, so it is undone here instead.
permit_name() {
local class=$1 name=$2 set_prefix addr addrs ok='' mark
local class=$1 name=$2 set_name addr addrs ok='' mark
write_resolver_config
mark=$(wc -c < "$RESOLVER_LOG")
@@ -1449,13 +1662,18 @@ permit_name() {
die "$name returned no address - the name may not exist, or its upstream may be unreachable. Nothing was changed."
fi
[ "$class" = forge ] && set_prefix=fname || set_prefix=oname
# Through the same helper as the config that fills the set and the check that
# reads it back at boot. This was the fourth place naming a set for a class,
# and the one the helper's own comment claimed did not exist - a live add
# looking in a different set from the one being filled would report a name as
# unreachable while it was permitted, or the reverse.
set_name=$(set_for_class "$class")
for addr in $addrs; do
nft get element inet "$TABLE" "${set_prefix}_v4" "{ $addr }" >/dev/null 2>&1 && ok=yes
nft get element inet "$TABLE" "$set_name" "{ $addr }" >/dev/null 2>&1 && ok=yes
done
if [ -z "$ok" ]; then
rollback_entries
die "$name resolved to [$(printf '%s ' $addrs)] but none of those addresses reached the ${set_prefix} sets; nothing was changed"
die "$name resolved to [$(printf '%s ' $addrs)] but none of those addresses reached $set_name; nothing was changed"
fi
say "$name permitted ($class class, $exemption): $(printf '%s ' $addrs)"
}
+29 -25
View File
@@ -44,7 +44,10 @@ fi
# involved), leaving the tty root-owned. nvim's clipboard fallback needs to
# write to it directly as the sandbox user, so fix the ownership up here,
# before dropping privileges.
if [ -t 1 ]; then
# Both halves read stdin, because that is what `tty` reports on. Testing stdout
# instead meant a container with one attached and the other not produced an
# empty path and a chown that silently did nothing.
if [ -t 0 ]; then
chown "$SANDBOX_USER" "$(tty)" 2>/dev/null || true
fi
@@ -70,6 +73,9 @@ fi
# Mounted at /root (not under $SANDBOX_HOME) so only this root-run block
# can ever read it - copied out, loaded into the agent, and the copy
# deleted, all before dropping to the sandbox user below.
# The forwarded agent is not mounted when this key is configured (bin/dejima
# blanks SSH_AUTH_SOCK), so this is the only agent in the container rather than
# the preferred one of two.
SANDBOX_SSH_KEY_SRC="/root/.ssh-sandbox-key-src"
if [ -s "$SANDBOX_SSH_KEY_SRC" ]; then
install -o "$SANDBOX_USER" -g "$SANDBOX_USER" -m 600 "$SANDBOX_SSH_KEY_SRC" "$SANDBOX_HOME/.ssh/sandbox-key"
@@ -90,31 +96,25 @@ fi
# Linked here rather than COPYed into place at build time because both depend
# on values that are only known per launch: which hosts this forge may be
# pushed to, and which subcommand this workflow blocks.
if [ -n "${DEJIMA_PUSH_HOSTS:-}${DEJIMA_BLOCKED_BRANCHES:-}" ]; then
# /usr/local/bin precedes /usr/bin, so this shadows git without moving it
# and the guard execs /usr/bin/git directly.
ln -sf /usr/local/lib/dejima/git-guard.sh /usr/local/bin/git
fi
# Every CLI the shadow has something to do about: the one whose merge a
# workflow blocks, and every one that writes a body the session authored. More
# than one, because a forge can have a CLI per artifact rather than one with
# subcommands, and because a forge with nothing blocked can still have
# something to disclose.
# The git shadow is installed unconditionally, because DEJIMA_PUSH_HOSTS fails
# CLOSED: empty means no host may be pushed to, which is how a no-forge or
# non-pushing workflow becomes enforced rather than described. Gating the
# shadow on a non-empty value would leave it absent in exactly that case - and
# local-only, the shipped default, is exactly that case.
#
# The first word of each record names the CLI. The real one is already behind
# the symlink this replaces (see install-forge-bins.sh), so the guard has a
# fixed path to exec.
_shadow_clis() {
[ -z "${DEJIMA_MERGE_BLOCK:-}" ] || printf '%s\n' "${DEJIMA_MERGE_BLOCK%% *}"
# "<kind> <cli> <subcommand>...", so the CLI is the second word.
[ -z "${DEJIMA_DISCLOSE:-}" ] || printf '%s\n' "$DEJIMA_DISCLOSE" |
while read -r _ _cli _; do
[ -z "$_cli" ] || printf '%s\n' "$_cli"
done
}
# /usr/local/bin precedes /usr/bin, so this shadows git without moving it and
# the guard execs /usr/bin/git directly.
ln -sf /usr/local/lib/dejima/git-guard.sh /usr/local/bin/git
_shadow_cli_list="$(_shadow_clis | sort -u)"
# Every CLI the shadow has something to do about. Derived by the same function
# hooks/merge-guard.sh uses to decide whether a command is one of these, so the
# set the shadow is linked over and the set the hook checks against cannot
# disagree. The real CLI is already behind the symlink this replaces (see
# install-forge-bins.sh), so the guard has a fixed path to exec.
# shellcheck source=guard-lib.sh
source "${DEJIMA_GUARD_LIB:-/usr/local/lib/dejima/guard-lib.sh}"
_shadow_cli_list="$(dejima_forge_commands | sort -u)"
if [ -n "$_shadow_cli_list" ]; then
while read -r _cli; do
[ -n "$_cli" ] || continue
@@ -315,7 +315,11 @@ if [ -n "${SANDBOX_HARNESS_BRIEFING_ARGS:-}" ] || [ -n "${SANDBOX_HARNESS_BRIEFI
mkdir -p /etc/dejima
# Written and renamed, so a harness starting while this is written reads
# either the whole briefing or no file at all.
if ! /usr/local/lib/dejima/dejima-briefing-render.sh "" "$PWD" \
# As the sandbox user, not as root. It reads the project's own .dejima.md
# and asks the package managers what is installed, with its cwd inside the
# workspace the session can write - and the file it produces is written by
# the redirect out here, which root still owns.
if ! gosu "$SANDBOX_USER" /usr/local/lib/dejima/dejima-briefing-render.sh "" "$PWD" \
> "$DEJIMA_BRIEFING_RENDERED.part"; then
rm -f "$DEJIMA_BRIEFING_RENDERED.part"
echo "entrypoint: could not render the session briefing. Refusing to" >&2
+35 -2
View File
@@ -101,8 +101,28 @@ fi
_join() { local IFS=' '; echo "${*:-}"; }
EGRESS_FORGE_NAMES="$(_join ${DEJIMA_FORGE_CLASS[@]+"${DEJIMA_FORGE_CLASS[@]}"})"
EGRESS_ALLOWED_NAMES="$(_join ${DEJIMA_OPEN_CLASS[@]+"${DEJIMA_OPEN_CLASS[@]}"})"
# The forge class wins wherever the two overlap, applied here rather than
# asserted in a comment. Every route into the open class is normalised, not just
# the approved-domains loop: the built-in list carries
# release-assets.githubusercontent.com, which is under a forge suffix, and a
# harness or backend domain under one would arrive the same way. Left alone, such
# a name gets a resolver line more specific than the suffix entry, feeding the
# open-class set - and the port restriction then rests on the forge's published
# ranges alone, which is one layer where the design has two.
_forge_extra=""
_open_kept=""
for _domain in ${DEJIMA_OPEN_CLASS[@]+"${DEJIMA_OPEN_CLASS[@]}"}; do
if [ "$(dejima_class_for "$_domain")" = forge ]; then
_forge_extra="$_forge_extra $_domain"
else
_open_kept="$_open_kept $_domain"
fi
done
# shellcheck disable=SC2086
EGRESS_FORGE_NAMES="$(_join ${DEJIMA_FORGE_CLASS[@]+"${DEJIMA_FORGE_CLASS[@]}"} $_forge_extra)"
# shellcheck disable=SC2086
EGRESS_ALLOWED_NAMES="$(_join $_open_kept)"
EGRESS_INTERNAL_OK="$(_join ${DEJIMA_INTERNAL_CLASS[@]+"${DEJIMA_INTERNAL_CLASS[@]}"})"
# What the policy should call that list when it tells an operator to add
# something to it. It knows the variable it reads; only dejima knows the
@@ -116,8 +136,17 @@ export EGRESS_FORGE_SSH="${ALLOW_GITHUB_PUSH:-0}"
# was available. Same read-only token `gh` already gets (docker-compose.yml),
# sent to the API it belongs to and nowhere else.
[ -n "${GH_TOKEN:-}" ] && export EGRESS_FORGE_META_AUTH="Authorization: Bearer $GH_TOKEN"
# The raw token has no other use here, and the header is the only thing the
# policy needs it for. Both are a credential anything started below has no
# reason to hold: they parse content the session's own traffic decides.
unset GH_TOKEN
/usr/local/sbin/egress-policy apply
# The header carries the token in another wrapper, so it goes the moment the
# request that needed it is made. egress-policy scrubs its own daemons at the
# line that starts each one; nothing started below this point can hold either
# half, which is how the shipper is covered.
unset EGRESS_FORGE_META_AUTH
# Best-effort visibility on top of the enforcement above, not part of it -
# log-shipper.sh no-ops if LOKI_URL isn't set. It stays root, deliberately:
@@ -134,4 +163,8 @@ export EGRESS_FORGE_SSH="${ALLOW_GITHUB_PUSH:-0}"
# It outlives this script, and reparents to the container's init when this one
# exits - which is where a daemon watching a session belongs. Started from the
# entrypoint, it was a child of the very session process it exists to watch.
# No `env -u` prefix here, unlike the daemons egress-policy starts: drop_caps
# is a shell function, and env can only exec a binary - so a prefix turns this
# line into exit 127, which the `&` hides from set -e. The two unsets above
# already ran, which is what this one relies on instead.
drop_caps -- -c 'exec "$@"' dejima /usr/local/bin/log-shipper.sh &
+7 -5
View File
@@ -58,11 +58,13 @@ DEJIMA_OPEN_CLASS=(
DEJIMA_INTERNAL_CLASS=()
# release-assets.githubusercontent.com is under a forge-class suffix, and the
# forge class wins wherever the two overlap - by design, not by accident. It is
# fetched over HTTPS like everything else under that suffix, so the narrower
# class costs it nothing and the alternative would be a name that quietly
# widened the forge.
# release-assets.githubusercontent.com is under a forge-class suffix, so it ends
# up in the forge class: firewall-up.sh routes every open-class entry through
# dejima_class_for, this list included, rather than only the ones a project
# declared. It is fetched over HTTPS like everything else under that suffix, so
# the narrower class costs it nothing, and the alternative is a name that
# quietly widens the forge - which is what it did until the normalisation
# covered this list too.
# Split on spaces explicitly, not on whatever IFS the caller happens to have,
# so every consumer gets the same list from the same value. A harness declaring
+25 -187
View File
@@ -3,21 +3,22 @@
# one subcommand a workflow blocks is refused, a command aimed at another
# repository is refused, and a body the session authored says so.
#
# The first rule is the one hooks/merge-guard.sh applies - a merge is the
# operator's decision, so the sandbox opens a PR and stops - moved off the
# harness's hook protocol and onto the command. It works for a harness that
# cannot intercept a tool call, and it compares argv rather than
# regex-matching a command string, so quoting, spacing and pipelines stop
# mattering.
# The first two are docker/guard-lib.sh, which hooks/merge-guard.sh applies to
# the same command from the other side. Here they sit on the command itself: it
# works for a harness that cannot intercept a tool call, and it compares argv
# rather than regex-matching a command string, so quoting, spacing and
# pipelines stop mattering.
#
# The second is here for the same reason it is not in the forge CLIs: dejima
# writes two of them and not the third, so a disclosure living in the tools is
# one that depends on which forge an operator picked. This is the one place
# every forge passes through.
# The third is this layer's alone, because a hook is handed a command string
# and cannot rewrite the body an argument points at. It is here rather than in
# the forge CLIs for the same reason the repository rule is: dejima writes two
# of them and not the third, so a disclosure living in the tools is one that
# depends on which forge an operator picked. This is the one place every forge
# passes through.
#
# Fast-fail, not a boundary, exactly as the hook was: what actually decides
# whether a merge is possible is the scope of the token the forge issued. This
# stops an eager agent doing it by accident.
# Fast-fail, not a boundary: what actually decides whether a merge is possible
# is the scope of the token the forge issued. This stops an eager agent doing
# it by accident.
#
# DEJIMA_MERGE_BLOCK the full command prefix to refuse, e.g. "gitea-pr
# merge". Its first word is the command this shadows;
@@ -51,180 +52,12 @@ if [ -z "${DEJIMA:-}" ] ||
exec "$real" "$@"
fi
# The words of a command prefix, minus the CLI name, appear as a run of
# consecutive non-flag arguments. Flags are dropped first, so `gitea-pr
# --verbose merge 7` is still a merge.
#
# Anywhere in that run rather than at the start of it, because a flag's value
# is indistinguishable from a subcommand here: in `gh --repo o/r pr merge 7`
# the first non-flag argument is `o/r`, and requiring the match to begin there
# let the most ordinary way to name a PR from outside its checkout walk
# straight past the block. Knowing which flags take a value would mean knowing
# each CLI, which is the coupling this guard exists to avoid.
#
# The cost is a command that happens to pass the words as consecutive values -
# `--title pr --body merge` - matching. For the merge block that is a loud
# refusal against a merge going through unnoticed; for a disclosure it is a
# line of text on something an agent did write anyway. Both are the direction
# to be wrong in.
plain=()
for arg in "$@"; do
case "$arg" in -*) continue ;; esac
plain+=("$arg")
done
# shellcheck source=guard-lib.sh
source "${DEJIMA_GUARD_LIB:-/usr/local/lib/dejima/guard-lib.sh}"
# argv_has <word>... - true when those words appear consecutively in `plain`.
argv_has() {
local want=("$@") n="${#plain[@]}" m="$#" i=0 k
[ "$m" -gt 0 ] || return 1
while [ "$((i + m))" -le "$n" ]; do
k=0
while [ "$k" -lt "$m" ] && [ "${plain[$((i + k))]}" = "${want[$k]}" ]; do
k=$((k + 1))
done
[ "$k" -lt "$m" ] || return 0
i=$((i + 1))
done
return 1
}
# --- the merge block --------------------------------------------------------
if [ -n "${DEJIMA_MERGE_BLOCK:-}" ]; then
read -ra blocked <<< "$DEJIMA_MERGE_BLOCK"
# The first word names the command; only the rest is a subcommand sequence
# to compare against argv. A block naming a different command than the one
# being run is not this invocation's business.
if [ "${#blocked[@]}" -ge 1 ] && [ "${blocked[0]}" = "$self" ]; then
if [ "${#blocked[@]}" -eq 1 ]; then
# The whole command is blocked, not one of its subcommands.
echo "Blocked by dejima's forge guard: '$self' is not available in the sandbox." >&2
exit 1
fi
if argv_has "${blocked[@]:1}"; then
echo "Blocked by dejima's forge guard: merging is a human decision in the sandbox - open a PR/MR, then the operator merges. Report its URL and stop." >&2
exit 1
fi
fi
fi
# --- the repository the session was launched against ------------------------
#
# A forge whose CLI can be aimed elsewhere declares how (repo_flags,
# repo_env). The gitea CLIs declare neither, because they resolve the repo
# from origin and refuse another host; `gh` declares both, so on that path the
# most ordinary way to name a PR from outside its checkout is also the way to
# file an issue on a repository the session has nothing to do with (#187).
#
# Fast-fail, not a boundary, for the same reason the merge block is not one:
# the session holds the token and could call the API directly. What bounds
# this is the scope of push_token_file.
#
# Refused rather than skipped when the launch resolved no repository at all: a
# workspace with no origin has nothing for --repo to legitimately name, and
# "cannot confirm" is not a reason to let it through.
if [ -n "${DEJIMA_FORGE_REPO_FLAGS:-}" ] || [ -n "${DEJIMA_FORGE_REPO_ENV:-}" ] ||
[ -n "${DEJIMA_FORGE_REPO_PATHS:-}" ]; then
refuse_repo() {
echo "Blocked by dejima's forge guard: this session was launched against ${DEJIMA_FORGE_REPO:-no repository}, and $1 names '$2'. Act on the repository the sandbox mounts." >&2
exit 1
}
# Case-insensitively: forges treat an owner and a repository name as
# case-preserving but not case-sensitive, and a refusal over capitalisation
# would be a false one.
same_repo() {
[ -n "${DEJIMA_FORGE_REPO:-}" ] && [ "${1,,}" = "${DEJIMA_FORGE_REPO,,}" ]
}
if [ -n "${DEJIMA_FORGE_REPO_ENV:-}" ]; then
env_value="${!DEJIMA_FORGE_REPO_ENV:-}"
if [ -n "$env_value" ] && ! same_repo "$env_value"; then
refuse_repo "$DEJIMA_FORGE_REPO_ENV" "$env_value"
fi
fi
if [ -n "${DEJIMA_FORGE_REPO_FLAGS:-}" ]; then
read -ra repo_flags <<< "$DEJIMA_FORGE_REPO_FLAGS"
args_left=("$@")
while [ "${#args_left[@]}" -gt 0 ]; do
arg="${args_left[0]}"
args_left=("${args_left[@]:1}")
for flag in "${repo_flags[@]}"; do
value=""
case "$arg" in
"$flag")
# The value is the next argument, and its absence is
# the CLI's error to report rather than this one's.
[ "${#args_left[@]}" -gt 0 ] || continue
value="${args_left[0]}"
;;
"$flag"=*) value="${arg#*=}" ;;
*)
# One-dash flags only; a two-dash flag carries its
# value with `=` or as the next argument, both above.
case "$flag" in
-?) ;;
*) continue ;;
esac
# Short flags cluster, so the letter can sit anywhere
# in one: `-Rowner/name`, `-wR owner/name` and
# `-wRowner/name` all name a repository and none of
# them is `-R` as its own argument (#243). Split at the
# letter the way getopt does - what follows it in the
# same argument is the value, and nothing following it
# means the next argument is.
letter="${flag#-}"
# A cluster, not a value that happens to start with a
# dash: one dash, then the letter somewhere in a run of
# letters. Without the second check, `--search foo-R/x`
# style arguments would be mined for flag letters.
case "$arg" in -[!-]*) ;; *) continue ;; esac
rest="${arg#-}"
case "$rest" in *"$letter"*) ;; *) continue ;; esac
case "${rest%%"$letter"*}" in *[!A-Za-z]*) continue ;; esac
value="${rest#*"$letter"}"
if [ -z "$value" ]; then
[ "${#args_left[@]}" -gt 0 ] || continue
value="${args_left[0]}"
fi
;;
esac
[ -n "$value" ] || continue
same_repo "$value" || refuse_repo "$flag" "$value"
done
done
fi
# A repository named in a path rather than in a flag - `gh api
# repos/<owner>/<name>/issues` reaches any repository the token does,
# without a --repo anywhere in it (#243).
#
# Only when the declared subcommand is argv's first word. The alternative
# is inspecting every positional that contains two slashes, which would
# refuse `gh issue create --title repos/a/b` - and a false refusal costs an
# honest agent a working command, which is the wrong way for a fast-fail to
# be wrong.
if [ -n "${DEJIMA_FORGE_REPO_PATHS:-}" ] && [ "$#" -gt 0 ]; then
read -ra repo_paths <<< "$DEJIMA_FORGE_REPO_PATHS"
for spec in "${repo_paths[@]}"; do
[ "$1" = "${spec%%:*}" ] || continue
segment="${spec#*:}"
for arg in "$@"; do
case "$arg" in -*) continue ;; esac
# gh substitutes {owner} and {repo} from the checkout, so a
# path carrying them names this repository by construction and
# comparing the literal braces would refuse it.
case "$arg" in *'{'*) continue ;; esac
path="${arg#/}"
case "$path" in "$segment"/*/*) ;; *) continue ;; esac
path="${path#"$segment"/}"
owner="${path%%/*}"
path="${path#*/}"
name="${path%%/*}"
[ -n "$owner" ] && [ -n "$name" ] || continue
same_repo "$owner/$name" \
|| refuse_repo "the path '$arg'" "$owner/$name"
done
done
fi
if ! dejima_forge_verdict "$self" "$@"; then
echo "Blocked by dejima's forge guard: $DEJIMA_GUARD_REASON" >&2
exit 1
fi
# --- the authorship disclosure ---------------------------------------------
@@ -242,13 +75,18 @@ marker_for() {
esac
}
# The non-flag arguments the records below are compared against. Set here
# rather than relied on from the verdict above, so this section says what it
# depends on.
dejima_forge_plain "$@"
kind=""
while read -r record_kind record_rest; do
[ -n "$record_kind" ] || continue
read -ra words <<< "$record_rest"
[ "${#words[@]}" -ge 1 ] || continue
[ "${words[0]}" = "$self" ] || continue
if argv_has "${words[@]:1}"; then
if dejima_argv_has "${words[@]:1}"; then
kind="$record_kind"
break
fi
+17 -129
View File
@@ -1,20 +1,20 @@
#!/bin/bash
# Shadows git on PATH so `git push` is checked whatever ran it.
#
# The rules are the ones hooks/git-push-guard.sh applies - push only to the
# forge's own hosts, never straight to a blocked branch - moved off the
# harness's hook protocol and onto the command itself. Two things follow from
# that. It works for a harness with no way to intercept a tool call, which is
# most of them. And it reads the decision out of argv rather than out of a
# regex over a reconstructed command string, so `git push`, `git -c k=v
# push` and a push buried in a pipeline are all just argv here.
# The rules are in docker/guard-lib.sh, which hooks/git-push-guard.sh applies
# to the same push from the other side - push only to the forge's own hosts,
# never straight to a blocked branch. Here they sit on the command itself,
# which means two things. It works for a harness with no way to intercept a
# tool call, which is most of them. And it reads the decision out of argv
# rather than out of a regex over a reconstructed command string, so
# `git push`, `git -c k=v push` and a push buried in a pipeline are all just
# argv here.
#
# It is a fast-fail guard, not a boundary, and the distinction is the same one
# docs/security.md draws about the hook it replaces: a session that means to
# get round it can call /usr/bin/git directly, exactly as it could have used a
# tool the hook did not cover. What stops a push reaching somewhere it should
# not is the firewall, and what stops it being accepted is the forge. This
# stops an eager agent doing the obvious thing by accident.
# docs/security.md draws about the hook beside it: a session that means to get
# round it can call /usr/bin/git directly, exactly as it could have used a tool
# the hook did not cover. What stops a push reaching somewhere it should not is
# the firewall, and what stops it being accepted is the forge.
#
# Everything it needs is already in the container's environment, resolved on
# the host from the forge and workflow manifests (see bin/dejima):
@@ -32,125 +32,13 @@ if [ -z "${DEJIMA:-}" ]; then
exec "$REAL_GIT" "$@"
fi
# Find the subcommand, and -C if it is there. git's own global options come
# before the subcommand and several take a value, so the ones that do are
# skipped in pairs rather than assumed to be attached.
repo_dir="$PWD"
subcommand=""
argv=("$@")
# Every `-c key=value` this push carries, kept rather than only skipped. The
# question below is where the push will go, and git answers that from the
# config the push runs under - so resolving it without these would answer for
# a different configuration than the one about to be used. A one-command
# `-c remote.origin.pushurl=...` is exactly that case.
config_overrides=()
i=0
while [ "$i" -lt "${#argv[@]}" ]; do
case "${argv[$i]}" in
-C)
i=$((i + 1))
[ "$i" -lt "${#argv[@]}" ] && repo_dir="${argv[$i]}"
;;
-c)
i=$((i + 1))
[ "$i" -lt "${#argv[@]}" ] \
&& config_overrides+=(-c "${argv[$i]}")
;;
--exec-path|--git-dir|--work-tree|--namespace|--super-prefix)
i=$((i + 1))
;;
-*)
;;
*)
subcommand="${argv[$i]}"
break
;;
esac
i=$((i + 1))
done
DEJIMA_GUARD_GIT="$REAL_GIT"
# shellcheck source=guard-lib.sh
source "${DEJIMA_GUARD_LIB:-/usr/local/lib/dejima/guard-lib.sh}"
[ "$subcommand" = "push" ] || exec "$REAL_GIT" "$@"
# What comes after `push`: the first two non-flag words are the remote and the
# refspec. Flags that take a value are skipped in pairs for the same reason as
# above - `git push --repo origin main` must not read "origin" as the remote.
# --repo names the remote in git's own legacy form and wins over a positional,
# so it is read rather than skipped: reading it as an opaque flag would leave
# `git push --repo=origin main` looking like a push to a remote called "main",
# which resolves to no URL - and passing that through unchecked is a push to
# origin/main that nothing looked at.
remote=""
refspec=""
i=$((i + 1))
while [ "$i" -lt "${#argv[@]}" ]; do
case "${argv[$i]}" in
--repo=*)
remote="${argv[$i]#--repo=}"
;;
--repo)
i=$((i + 1))
[ "$i" -lt "${#argv[@]}" ] && remote="${argv[$i]}"
;;
-o|--push-option|--receive-pack|--exec)
i=$((i + 1))
;;
-*)
;;
*)
if [ -z "$remote" ]; then
remote="${argv[$i]}"
elif [ -z "$refspec" ]; then
refspec="${argv[$i]}"
fi
;;
esac
i=$((i + 1))
done
[ -n "$remote" ] || remote=origin
# --push, because that is the URL the push will use. Without it this reads
# remote.<name>.url while git pushes to remote.<name>.pushurl when one is set,
# so a pushurl naming any other host is checked against the fetch URL and
# allowed. It needs no evasion to reach: `git -c remote.origin.pushurl=...
# push origin branch` sets one for a single command, and .git/config is in the
# read-write project mount, so a session can also just write one down.
remote_url="$("$REAL_GIT" ${config_overrides[@]+"${config_overrides[@]}"} \
-C "$repo_dir" remote get-url --push "$remote" 2>/dev/null)"
# A remote this cannot resolve is refused rather than passed through. It is
# usually a typo, which git would reject in a moment anyway - but it is also
# what a disagreement between this parser and git's looks like, and the
# failure of that disagreement has to be a refusal rather than a push nobody
# checked.
if [ -z "$remote_url" ]; then
echo "Blocked by dejima's git guard: cannot resolve the remote '$remote' to a URL," >&2
echo "so there is nothing to check this push against. Name a remote that exists." >&2
if ! dejima_push_verdict "$PWD" "$@"; then
echo "Blocked by dejima's git guard: $DEJIMA_GUARD_REASON" >&2
exit 1
fi
host="$(printf '%s' "$remote_url" \
| sed -E 's#^[a-zA-Z]+://([^/@]+@)?([^/:]+).*#\2#; s#^[^@/]+@([^:/]+)[:/].*#\1#')"
host_allowed=0
for allowed_host in ${DEJIMA_PUSH_HOSTS:-}; do
[ "$host" = "$allowed_host" ] && { host_allowed=1; break; }
done
if [ "$host_allowed" != 1 ]; then
echo "Blocked by dejima's git guard: this sandbox may only push to ${DEJIMA_FORGE_NAME:-the configured forge}'s allowed host(s) (${DEJIMA_PUSH_HOSTS:-none allowed}), not '$host'." >&2
exit 1
fi
if [ -n "$refspec" ]; then
branch="${refspec##*:}"
else
branch="$("$REAL_GIT" -C "$repo_dir" branch --show-current 2>/dev/null)"
fi
branch="${branch#refs/heads/}"
for blocked_branch in ${DEJIMA_BLOCKED_BRANCHES:-}; do
if [ "$branch" = "$blocked_branch" ]; then
echo "Blocked by dejima's git guard: direct pushes to '$branch' aren't allowed in the sandbox. Push a feature branch and open a PR/MR instead." >&2
exit 1
fi
done
exec "$REAL_GIT" "$@"
+563
View File
@@ -0,0 +1,563 @@
#!/bin/bash
# The push and merge policy, once, for the two layers that apply it.
#
# Each rule used to exist twice: as a PATH shadow over the command
# (docker/git-guard.sh, docker/forge-guard.sh) and as a PreToolUse hook over
# the command string a harness reports (hooks/git-push-guard.sh,
# hooks/merge-guard.sh). The two overlap deliberately - a hook sees
# `/usr/bin/git push`, which walks past the shadow, and a shadow sees a push
# from an editor integration or a script, which the harness never reports - so
# neither can be dropped. What they cannot do is disagree, and they had already
# drifted: `git push -o x origin main` was fine to one and a hard refusal from
# the other, for the same invocation in the same session.
#
# So the decision lives here and both call it. A bypass is then fixed once.
#
# Every function reads argv, never a command string. A hook holds a string, so
# it splits it into segments and words first (dejima_guard_segments) and hands
# the words over - which is also why the hooks inherit the shadows' indifference
# to spacing, quoting and pipelines.
#
# None of this is a boundary, and the distinction is the one docs/security.md
# draws: a session that means to get round these can call /usr/bin/git or the
# forge API directly. What stops a push reaching somewhere it should not is the
# firewall, and what stops it being accepted is the forge's token scope. These
# stop an eager agent doing the obvious thing by accident.
#
# Sourced, so nothing here exits: a refusal is a return of 1 with the reason in
# DEJIMA_GUARD_REASON. The caller supplies its own prefix, because an operator
# reading a refusal needs to know which layer spoke.
# The real git, never the shadow. The shadow passes a non-push through, so
# calling it would work - but it would also mean every question this asks git
# goes through a script that is asking git questions.
DEJIMA_GUARD_GIT="${DEJIMA_GUARD_GIT:-/usr/bin/git}"
DEJIMA_GUARD_REASON=""
# --- splitting a command string into git/forge invocations -------------------
# dejima_guard_segments <command string>
#
# One segment per line. A guarded command can be the second half of a compound
# one, and only a segment whose first word is the command in question is that
# command - `echo push` is not a push.
#
# Word-splitting rather than a full shell parse, so quoting edge cases are not
# handled. That is the same pragmatic tradeoff as the sibling
# deny-dangerous-bash.sh denylist, and the reason every rule below refuses
# rather than guesses when it cannot read what it is looking at.
# The trailing newline is load-bearing: without it `read` returns false on the
# last segment even though it read one, so a command with no separators in it -
# every ordinary push - was never looked at.
dejima_guard_segments() {
printf '%s\n' "$1" | sed 's/&&/\n/g; s/||/\n/g; s/;/\n/g; s/|/\n/g'
}
# --- git push ---------------------------------------------------------------
# Where a push will go and which branches it will write, read out of argv the
# way git reads it. Sets DEJIMA_GUARD_PUSH=1 when the argv is a push at all.
#
# git's own global options come before the subcommand and several take a
# separate value, so those are skipped in pairs rather than assumed attached.
#
# `-c key=value` is kept rather than skipped: git resolves where a push goes
# from the config the push runs under, so asking without these answers for a
# different configuration than the one about to be used. A one-command
# `-c remote.origin.pushurl=...` is exactly that case.
#
# --git-dir and --work-tree are REFUSED rather than skipped or honoured. Both
# used to be consumed and ignored, so `git --git-dir /tmp/x/.git push origin
# main` resolved `origin` in the mounted project, found it allowed, and then
# ran against a repository whose origin points somewhere else. Honouring them
# would mean reimplementing how they combine with -C and with each other;
# refusing is the direction the unresolvable-remote case below already takes,
# and a session with a second repository to push from can cd into it or use -C.
dejima_push_verdict() { # <repo-dir> <git argv...>
local repo_dir="$1"; shift
local argv=("$@") i=0 subcommand="" branch
local -a config_overrides=() refspecs=()
local remote="" repo_flag="" push_all=0 push_mirror=0 repo_override=0
DEJIMA_GUARD_REASON=""
DEJIMA_GUARD_PUSH=0
while [ "$i" -lt "${#argv[@]}" ]; do
case "${argv[$i]}" in
-C)
i=$((i + 1))
[ "$i" -lt "${#argv[@]}" ] && repo_dir="${argv[$i]}"
;;
-c)
i=$((i + 1))
[ "$i" -lt "${#argv[@]}" ] \
&& config_overrides+=(-c "${argv[$i]}")
;;
--git-dir|--work-tree)
repo_override=1
i=$((i + 1))
;;
--git-dir=*|--work-tree=*)
repo_override=1
;;
--exec-path|--namespace|--super-prefix)
i=$((i + 1))
;;
-*)
;;
*)
subcommand="${argv[$i]}"
break
;;
esac
i=$((i + 1))
done
[ "$subcommand" = push ] || return 0
DEJIMA_GUARD_PUSH=1
# Noted while parsing and refused here, so that only a push is refused:
# these say nothing about any other git command, and a guard that refused
# `git --git-dir X status` would be refusing the flag rather than the push.
if [ "$repo_override" = 1 ]; then
DEJIMA_GUARD_REASON="a push carrying --git-dir or --work-tree cannot be checked, because the repository it writes from is not the one this would resolve the remote in. Run it from that repository, or name it with -C."
return 1
fi
# What comes after `push`. --repo names the remote in git's own legacy form
# and is read rather than treated as an opaque flag, because skipping it
# would leave `git push --repo=origin main` looking like a bare push and
# resolve a remote the command never named.
#
# It LOSES to a positional, which is what git documents: "this option is
# equivalent to the <repository> argument. If both are specified, the
# command-line argument takes precedence." So in `git push --repo=origin
# other-remote feature` the remote is other-remote, and checking origin
# instead would approve a host this push never goes to.
#
# Every positional after the remote is a refspec, not just the first.
# `git push origin feature main` checked `feature` alone and let `main`
# through beside it.
i=$((i + 1))
while [ "$i" -lt "${#argv[@]}" ]; do
case "${argv[$i]}" in
--repo=*) repo_flag="${argv[$i]#--repo=}" ;;
--repo)
i=$((i + 1))
[ "$i" -lt "${#argv[@]}" ] && repo_flag="${argv[$i]}"
;;
--all) push_all=1 ;;
--mirror) push_mirror=1 ;;
-o|--push-option|--receive-pack|--exec)
i=$((i + 1))
;;
-*) ;;
*)
if [ -z "$remote" ]; then
remote="${argv[$i]}"
else
refspecs+=("${argv[$i]}")
fi
;;
esac
i=$((i + 1))
done
[ -n "$remote" ] || remote="$repo_flag"
[ -n "$remote" ] || remote=origin
# --push, because that is the URL the push will use. Without it this reads
# remote.<name>.url while git pushes to remote.<name>.pushurl when one is
# set, so a pushurl naming any other host is checked against the fetch URL
# and allowed. It needs no evasion to reach: `git -c
# remote.origin.pushurl=... push origin branch` sets one for a single
# command, and .git/config is in the read-write project mount, so a session
# can also just write one down.
local remote_url host
remote_url="$("$DEJIMA_GUARD_GIT" ${config_overrides[@]+"${config_overrides[@]}"} \
-C "$repo_dir" remote get-url --push "$remote" 2>/dev/null)"
if [ -z "$remote_url" ]; then
# A remote this cannot resolve is refused rather than passed through.
# It is usually a typo, which git would reject in a moment anyway - but
# it is also what a disagreement between this parser and git's looks
# like, and the failure of that disagreement has to be a refusal rather
# than a push nobody checked.
DEJIMA_GUARD_REASON="cannot resolve the remote '$remote' to a URL, so there is nothing to check this push against. Name a remote that exists."
return 1
fi
host="$(printf '%s' "$remote_url" \
| sed -E 's#^[a-zA-Z]+://([^/@]+@)?([^/:]+).*#\2#; s#^[^@/]+@([^:/]+)[:/].*#\1#')"
local allowed_host host_allowed=0
for allowed_host in ${DEJIMA_PUSH_HOSTS:-}; do
[ "$host" = "$allowed_host" ] && { host_allowed=1; break; }
done
if [ "$host_allowed" != 1 ]; then
DEJIMA_GUARD_REASON="this sandbox may only push to ${DEJIMA_FORGE_NAME:-the configured forge}'s allowed host(s) (${DEJIMA_PUSH_HOSTS:-none allowed}), not '$host'."
return 1
fi
# --mirror puts every ref under refs/ on the remote AND deletes the ones
# that are gone locally, so what it does to a protected branch cannot be
# read off the command: a mirror from a checkout that has no `main` deletes
# the remote's. Refused outright rather than resolved, because there is no
# branch list that makes it safe.
if [ "$push_mirror" = 1 ]; then
DEJIMA_GUARD_REASON="--mirror writes every ref and deletes the remote refs this checkout does not have, so it cannot be checked against a branch policy. Push the branch you mean."
return 1
fi
# --all pushes every local branch, so every local branch is what the policy
# has to be applied to. The refspec list is empty here - git refuses --all
# with one - and reading only the current branch is what let `git push
# --all origin` from a feature branch push `main` beside it.
if [ "$push_all" = 1 ]; then
local local_branch
while IFS= read -r local_branch; do
[ -n "$local_branch" ] || continue
if _dejima_branch_blocked "$local_branch"; then
DEJIMA_GUARD_REASON="--all pushes every local branch, and '$local_branch' is one of them. Direct pushes to '$local_branch' aren't allowed in the sandbox. Push a feature branch and open a PR/MR instead."
return 1
fi
done < <("$DEJIMA_GUARD_GIT" -C "$repo_dir" for-each-ref \
--format='%(refname:short)' refs/heads/ 2>/dev/null)
return 0
fi
# Each refspec's destination. `src:dst` names it outright, a bare name is
# both, and `HEAD` or no refspec at all means the current branch - `git
# push origin` and `git push origin HEAD` both push it.
if [ "${#refspecs[@]}" -eq 0 ]; then
refspecs=(HEAD)
fi
local refspec
for refspec in "${refspecs[@]}"; do
# One leading `+` is the force marker and belongs to the refspec rather
# than to the branch, so it comes off before the destination is read -
# `git push origin +main` names main, and the force spelling is the
# destructive one to be wrong about.
refspec="${refspec#+}"
case "$refspec" in
*:*) branch="${refspec##*:}" ;;
HEAD) branch="$("$DEJIMA_GUARD_GIT" -C "$repo_dir" branch --show-current 2>/dev/null)" ;;
*) branch="$refspec" ;;
esac
# A destination that does not start with refs/ is matched against the
# remote's refs by git's rev-parse rules, so `heads/main` and `main`
# both name refs/heads/main. Stripping only the full prefix left
# `feature:heads/main` naming a branch no policy entry matched.
branch="${branch#refs/}"
branch="${branch#heads/}"
if _dejima_branch_blocked "$branch"; then
DEJIMA_GUARD_REASON="direct pushes to '$branch' aren't allowed in the sandbox. Push a feature branch and open a PR/MR instead."
return 1
fi
done
return 0
}
# DEJIMA_BLOCKED_BRANCHES fails OPEN: empty means no branch is protected, which
# is correct for a workflow that genuinely has no branch policy. Unlike
# DEJIMA_PUSH_HOSTS above, which fails CLOSED - empty means no host is allowed,
# so every push is refused, and that is how a no-forge or non-pushing workflow
# becomes enforced rather than merely described.
_dejima_branch_blocked() {
local blocked
[ -n "${1:-}" ] || return 1
for blocked in ${DEJIMA_BLOCKED_BRANCHES:-}; do
[ "$1" = "$blocked" ] && return 0
done
return 1
}
# --- the forge CLI ----------------------------------------------------------
# dejima_argv_has <word>... - true when those words appear consecutively among
# the non-flag arguments of the command being checked. _dejima_plain must be
# set first, by dejima_forge_verdict.
#
# Anywhere in that run rather than at the start of it, because a flag's value
# is indistinguishable from a subcommand here: in `gh --repo o/r pr merge 7`
# the first non-flag argument is `o/r`, and requiring the match to begin there
# let the most ordinary way to name a PR from outside its checkout walk
# straight past the block. Knowing which flags take a value would mean knowing
# each CLI, which is the coupling this guard exists to avoid.
#
# The cost is a command that happens to pass the words as consecutive values -
# `--title pr --body merge` - matching. For the merge block that is a loud
# refusal against a merge going through unnoticed; for a disclosure it is a
# line of text on something an agent did write anyway. Both are the direction
# to be wrong in.
dejima_argv_has() {
local want=("$@") n="${#_dejima_plain[@]}" m="$#" i=0 k
[ "$m" -gt 0 ] || return 1
while [ "$((i + m))" -le "$n" ]; do
k=0
while [ "$k" -lt "$m" ] && [ "${_dejima_plain[$((i + k))]}" = "${want[$k]}" ]; do
k=$((k + 1))
done
[ "$k" -lt "$m" ] || return 0
i=$((i + 1))
done
return 1
}
# dejima_forge_plain <argv...> - fills _dejima_plain with the non-flag
# arguments, which is what every rule below compares against.
dejima_forge_plain() {
local arg
_dejima_plain=()
for arg in "$@"; do
case "$arg" in -*) continue ;; esac
_dejima_plain+=("$arg")
done
}
# dejima_forge_commands - every CLI this policy applies to, one per line: the
# one whose merge a workflow blocks, and every one that writes a body the
# session authored. More than one, because a forge can have a CLI per artifact
# rather than one with subcommands, and because a forge with nothing blocked
# can still have something to disclose.
#
# docker/entrypoint.sh links the shadow over exactly this set, and
# hooks/merge-guard.sh checks a command against it before applying anything -
# without that the hook would apply gh's --repo rule to any command carrying
# that flag, which the shadow cannot do because it only exists for these.
dejima_forge_commands() {
[ -z "${DEJIMA_MERGE_BLOCK:-}" ] || printf '%s\n' "${DEJIMA_MERGE_BLOCK%% *}"
# "<kind> <cli> <subcommand>...", so the CLI is the second word.
[ -z "${DEJIMA_DISCLOSE:-}" ] || printf '%s\n' "$DEJIMA_DISCLOSE" |
while read -r _ _cli _; do
[ -z "$_cli" ] || printf '%s\n' "$_cli"
done
}
# dejima_forge_verdict <command name> <argv...>
#
# The command name is argv[0] as the caller saw it - the shadow's own basename,
# or the first word of a hook's segment. Every policy here is keyed to it,
# because a block naming a different command than the one being run is not this
# invocation's business.
dejima_forge_verdict() {
local self="$1"; shift
DEJIMA_GUARD_REASON=""
dejima_forge_plain "$@"
if [ -n "${DEJIMA_MERGE_BLOCK:-}" ]; then
local -a blocked
read -ra blocked <<< "$DEJIMA_MERGE_BLOCK"
if [ "${#blocked[@]}" -ge 1 ] && [ "${blocked[0]}" = "$self" ]; then
if [ "${#blocked[@]}" -eq 1 ]; then
# The whole command is blocked, not one of its subcommands.
DEJIMA_GUARD_REASON="'$self' is not available in the sandbox."
return 1
fi
if dejima_argv_has "${blocked[@]:1}"; then
DEJIMA_GUARD_REASON="merging is a human decision in the sandbox - open a PR/MR, then the operator merges. Report its URL and stop."
return 1
fi
fi
fi
_dejima_forge_repo_verdict "$@" || return 1
return 0
}
# The repository the session was launched against.
#
# A forge whose CLI can be aimed elsewhere declares how (repo_flags, repo_env,
# repo_paths). The gitea CLIs declare none, because they resolve the repo from
# origin and refuse another host - which leaves nothing here to compare, not
# that they are confined to one repository: origin is session-writable. `gh`
# declares all three, so on that path the
# most ordinary way to name a PR from outside its checkout is also the way to
# file an issue on a repository the session has nothing to do with (#187).
#
# Refused rather than skipped when the launch resolved no repository at all: a
# workspace with no origin has nothing for --repo to legitimately name, and
# "cannot confirm" is not a reason to let it through.
_dejima_forge_repo_verdict() {
[ -n "${DEJIMA_FORGE_REPO_FLAGS:-}" ] || [ -n "${DEJIMA_FORGE_REPO_ENV:-}" ] \
|| [ -n "${DEJIMA_FORGE_REPO_PATHS:-}" ] || return 0
if [ -n "${DEJIMA_FORGE_REPO_ENV:-}" ]; then
local env_value="${!DEJIMA_FORGE_REPO_ENV:-}"
if [ -n "$env_value" ] && ! _dejima_same_repo "$env_value"; then
_dejima_refuse_repo "$DEJIMA_FORGE_REPO_ENV" "$env_value"
return 1
fi
fi
if [ -n "${DEJIMA_FORGE_REPO_FLAGS:-}" ]; then
local -a repo_flags args_left
read -ra repo_flags <<< "$DEJIMA_FORGE_REPO_FLAGS"
args_left=("$@")
local arg flag value letter rest
while [ "${#args_left[@]}" -gt 0 ]; do
arg="${args_left[0]}"
args_left=("${args_left[@]:1}")
for flag in "${repo_flags[@]}"; do
value=""
case "$arg" in
"$flag")
# The value is the next argument, and its absence is
# the CLI's error to report rather than this one's.
[ "${#args_left[@]}" -gt 0 ] || continue
value="${args_left[0]}"
;;
"$flag"=*) value="${arg#*=}" ;;
*)
# One-dash flags only; a two-dash flag carries its
# value with `=` or as the next argument, both above.
case "$flag" in
-?) ;;
*) continue ;;
esac
# Short flags cluster, so the letter can sit anywhere
# in one: `-Rowner/name`, `-wR owner/name` and
# `-wRowner/name` all name a repository and none of
# them is `-R` as its own argument (#243). Split at the
# letter the way getopt does - what follows it in the
# same argument is the value, and nothing following it
# means the next argument is.
letter="${flag#-}"
# A cluster, not a value that happens to start with a
# dash: one dash, then the letter somewhere in a run of
# letters. Without the second check, `--search foo-R/x`
# style arguments would be mined for flag letters.
case "$arg" in -[!-]*) ;; *) continue ;; esac
rest="${arg#-}"
case "$rest" in *"$letter"*) ;; *) continue ;; esac
case "${rest%%"$letter"*}" in *[!A-Za-z]*) continue ;; esac
value="${rest#*"$letter"}"
if [ -z "$value" ]; then
[ "${#args_left[@]}" -gt 0 ] || continue
value="${args_left[0]}"
fi
;;
esac
[ -n "$value" ] || continue
if ! _dejima_same_repo "$value"; then
_dejima_refuse_repo "$flag" "$value"
return 1
fi
done
done
fi
_dejima_forge_path_verdict "$@" || return 1
return 0
}
# A repository named in a path rather than in a flag - `gh api
# repos/<owner>/<name>/issues` reaches any repository the token does, without a
# --repo anywhere in it (#243).
#
# Only when the declared subcommand is argv's first word. The alternative is
# inspecting every positional that contains two slashes, which would refuse
# `gh issue create --title repos/a/b` - and a false refusal costs an honest
# agent a working command, which is the wrong way for a fast-fail to be wrong.
#
# Under that subcommand the rule is inverted: rather than looking for a path
# naming the wrong repository, it requires one naming the right one. `gh api`
# reaches every endpoint the token does, and an endpoint that is not under the
# declared segment - `user/repos`, `/orgs/<org>/repos`, `graphql` - names no
# repository for this to compare, so there was nothing to refuse and nothing
# was (#365). Telling an endpoint apart from a header value or a field value
# would mean knowing which of the CLI's flags take a separate value, which is
# the coupling this guard exists to avoid - so the question asked is whether a
# target it CAN read is present, which needs no such knowledge.
#
# graphql counts as one, because dejima's own github-pr reads review threads
# through it and the API has no other route to them. A mutation does not:
# `gh api graphql -f query='mutation{mergePullRequest(...)}'` is what an agent
# reaches for once `gh pr merge` refuses, and no repository scoping can be read
# out of a GraphQL document. Queries through it can only read, which the
# read-only token could do anyway.
_dejima_forge_path_verdict() {
[ -n "${DEJIMA_FORGE_REPO_PATHS:-}" ] && [ "$#" -gt 0 ] || return 0
local -a repo_paths
read -ra repo_paths <<< "$DEJIMA_FORGE_REPO_PATHS"
local spec segment arg path owner name scopable
for spec in "${repo_paths[@]}"; do
[ "$1" = "${spec%%:*}" ] || continue
segment="${spec#*:}"
scopable=0
for arg in "$@"; do
# A GraphQL document whose operation is a mutation. Matched
# anywhere in the argument, because the document arrives as the
# value of a field flag - `-f query=mutation{...}` - rather than as
# an argument of its own. Matched before the brace test below,
# which a document would otherwise satisfy.
#
# Over EVERY argument, including the dash-prefixed ones, because a
# flag can carry its value attached: `--field=query=mutation{...}`
# and `-Fquery=mutation{...}` put the document inside one word that
# the scoping loop below skips. This check needs no flag table to
# read it, which is what lets it look at all of them - unlike the
# repository scoping, which does.
#
# Both boundaries are anything that cannot continue an
# identifier, rather than whitespace and a brace. GraphQL treats
# commas and comments as ignored tokens, so `mutation,{...}` and
# `mutation#c` followed by the body are valid documents, and a
# document carrying more than one operation puts `}` immediately
# before the second one. Each of those sat just outside a narrower
# boundary. The class excludes `_` on both sides, so a field called
# foo_mutation is not a mutation.
if [[ "$arg" =~ (^|[^A-Za-z0-9_])mutation([^A-Za-z0-9_]|$) ]]; then
DEJIMA_GUARD_REASON="a GraphQL mutation reaches any repository and any write the token allows, and no repository scoping can be read out of it. Use the subcommands, and leave a merge to the operator."
return 1
fi
# A body this cannot read is a body it cannot tell a query from a
# mutation in. Two spellings put one out of reach: a field value
# read from a file, and --input, which takes the whole request body
# from a file or from stdin. Refused under this subcommand only;
# the document is one argument, so passing it inline is the fix.
case "$arg" in
*=@*|--input|--input=*)
DEJIMA_GUARD_REASON="'$1' takes its body from a file or from stdin, so what it sends is not in the command for this to read. Pass the document inline."
return 1
;;
esac
# Everything from here needs to know which argument is the
# endpoint, so the flags go.
case "$arg" in -*) continue ;; esac
[ "$arg" = graphql ] && { scopable=1; continue; }
# gh substitutes {owner} and {repo} from the checkout, so a path
# carrying them names this repository by construction and comparing
# the literal braces would refuse it.
case "$arg" in *'{'*) scopable=1; continue ;; esac
path="${arg#/}"
case "$path" in "$segment"/*/*) ;; *) continue ;; esac
path="${path#"$segment"/}"
owner="${path%%/*}"
path="${path#*/}"
name="${path%%/*}"
[ -n "$owner" ] && [ -n "$name" ] || continue
if ! _dejima_same_repo "$owner/$name"; then
_dejima_refuse_repo "the path '$arg'" "$owner/$name"
return 1
fi
scopable=1
done
if [ "$scopable" = 0 ]; then
DEJIMA_GUARD_REASON="'$1' names no endpoint under '$segment/', so there is nothing here saying which repository this reaches. Name the endpoint as '$segment/${DEJIMA_FORGE_REPO:-<owner>/<name>}/...', or use the subcommands."
return 1
fi
done
return 0
}
_dejima_refuse_repo() {
DEJIMA_GUARD_REASON="this session was launched against ${DEJIMA_FORGE_REPO:-no repository}, and $1 names '$2'. Act on the repository the sandbox mounts."
}
# Case-insensitively: forges treat an owner and a repository name as
# case-preserving but not case-sensitive, and a refusal over capitalisation
# would be a false one.
_dejima_same_repo() {
[ -n "${DEJIMA_FORGE_REPO:-}" ] && [ "${1,,}" = "${DEJIMA_FORGE_REPO,,}" ]
}
+18 -4
View File
@@ -464,6 +464,16 @@ prefer credentials that cannot do anything else: a token that raises an
anonymous API rate limit should be a fine-grained token with no permissions
and no repository access, not a general-purpose one.
**A per-run fetch, not a per-container one.** The privileged job starts
twenty-seven container pairs, and each filter container asks the forge for its
published address ranges before it brings the policy up. Done once per
container that is a burst, and GitHub answers a burst on `/meta` with a `403`
from its secondary rate limit - which is not the hourly ceiling a token raises,
so a valid credential does not prevent it (#357). The job fetches once into the
directory it already shares with those containers, and the suite points every
filter at the result; a container falls back to fetching for itself when the
file is absent, which is what happens outside CI.
**The cache server.** `act_runner` enables it by default, and it binds on the
runner host specifically so that job containers can reach it. That is an
inbound service exposed to untrusted code. Turn it off where it is not
@@ -523,7 +533,11 @@ can still add a workflow of its own.
daemon, the answer is a host you are willing to lose, not a smaller grant.
- **Before Gitea 1.27, the job token stays permissive.** Branch protection
bounds where it can write; it does not take the token away.
- **None of this is checked by anything.** The rest of dejima's security
properties have tests behind them. A runner's configuration lives on a
machine this repository has no access to, so it is on you to re-check it
after upgrades and rebuilds.
- **Only part of this is checked, and only from inside a job.**
`test/ci-runner.bats` runs on both tiers and asserts what a job can see from
where it stands: which tier it landed on, that the confined one has no docker
socket and the privileged one does, and that the egress rules are in front of
the confined job. Everything outside a job - the runner's own configuration
file, its labels, what else shares the machine - lives somewhere this
repository has no access to, so it is on you to re-check that after upgrades
and rebuilds.
+151 -22
View File
@@ -55,6 +55,14 @@ Every tool takes `--help`, which describes its own arguments and what they do.
That is the fastest answer to "what does this flag mean"; this page is for the
config file behind them.
**A launch flag overrides the file for that launch only, and every `--no-*`
flag only ever narrows.** Nothing dejima accepts on the command line turns a
setting back on. A config that says `off` is a decision somebody made in a
file they can reread, and a flag that could undo one in passing would make the
file's answer the weaker of the two - so the flags that exist are the ones that
take something away. `--isolated` is the far end of that: see
[Isolated sessions](#isolated-sessions).
### `[core]` - what this sandbox runs
The three selections come first because everything below depends on them: the
@@ -157,7 +165,7 @@ you selected, the second is push access when GitHub *is* your forge.
| `[forge.gitea] api` | **required** for `forge = gitea` | `https://<gitea host>/api/v1` |
| `[forge.gitea] token_file` | `~/.config/gitea/token` | path to the Gitea/Forgejo API token (the file, not the token itself) |
| `[forge.github] push_token_file` | unset | path to a **write-capable** PAT, used only when `[core] forge = github`: deliberately separate from `[github] token_file`; see "GitHub push" in [docs/security.md](security.md) |
| `[github] token_file` | `~/.config/gh/sandbox-token` | path to a read-only PAT for private GitHub repos / `gh`. Read whichever forge is selected, and it should stay read-only |
| `[github] token_file` | `~/.config/gh/sandbox-token` | path to a read-only PAT for private GitHub repos / `gh`, and it should stay read-only. Read for every session EXCEPT when `[core] forge = github` and `push_token_file` is set: both fill `GH_TOKEN`, the forge's wins, and the launch says so. Which token can push is the half that matters |
### `[mounts]` - extra host paths
@@ -254,8 +262,16 @@ name and its subdomains, with no exact-name form to use instead - so a bare
label would grant its whole suffix, and `org` would ask for every `.org` name.
The same floor applies to every key whose value becomes a permitted
destination: `[core] internal_domains`, a forge's `ssh_host` and `api`, a
backend's `base_url`, and `[logging] loki_url`. A single-label value is refused
when the entry is declared and again at launch, naming the key it came from.
backend's `base_url`, and `[logging] loki_url`. A value that is not a host name of at least two labels is
refused when the entry is declared and again at launch, naming the key it came
from - which includes a value that reached the launcher through a URL and lost
its host on the way, such as a `loki_url` carrying userinfo.
An approval granted before this rule existed is still read rather than called
corrupt, so an upgrade does not present as tampering; the launch stops instead,
naming the entry. There is no `remove` verb, so clearing one means editing the
store by hand - `~/.config/dejima/allowed-domains` for a global approval, or
`~/.config/dejima/projects/<slug>/allowed-domains` for a project's.
**Declaring grants nothing.** The project directory is bind-mounted
read-write, so a session could write these files; they are read only to
@@ -269,6 +285,7 @@ dejima-project status ~/myproject # declared vs approved vs missing
dejima-project approve ~/myproject # review and approve the declared set
dejima-allow --project ~/myproject crates.io
dejima-allow --global crates.io # applies to every project
dejima-allow --container <id> dl.example.com files.example.org # several
dejima-dep add --project ~/myproject cargo:cargo-nextest
dejima-dep add --global cargo:cargo-nextest
```
@@ -276,7 +293,19 @@ dejima-dep add --global cargo:cargo-nextest
Two of those tools also write the declaration, so an approval you make by
hand does not stay invisible to everyone else on the project:
`dejima-allow --project` appends to `.dejima/allowed-domains`, and
`dejima-dep add --project` appends to `.dejima/dependencies`. Both write the
`dejima-dep add --project` appends to `.dejima/dependencies`.
`dejima-allow` takes up to twenty domains at once, which is what a toolchain
fetching from several hosts actually needs. One goes through as a single
decision you already made by typing it; more than one is printed back to you
one per line and confirmed before anything is added, defaulting to no. The cap
and the confirmation are the same point: a list is where an unwanted name rides
along unread, the agent that suggested the command is what might have put it
there, and neither mechanism helps if the list is too long to finish reading.
Every name is validated before any of them is shown, so what you are confirming
is what will be added and no entry can carry an escape sequence that rewrites
the lines above it. Confirming needs a terminal, so a script does them one at a
time. Both write the
approval first - that is the part that grants - and report separately on the
declaration, so a project you can't write to still gets you the approval.
`--global` writes no declaration at all: there is no project to declare in.
@@ -796,8 +825,15 @@ ssh_key = ~/.ssh/dejima_ed25519
`entrypoint.sh` loads it into a container-local `ssh-agent` at startup and
deletes the plaintext copy immediately after - the sandbox user itself never
has a path to the raw key, only the agent socket (see
[security notes](security.md) for the full mount rationale). Setting it means
a live forwarded agent is no longer required at all.
[security notes](security.md) for the full mount rationale).
Setting it means a forwarded agent is not merely unnecessary but **not
mounted**: your own `SSH_AUTH_SOCK` is not passed to the container at all, so
nothing in a session can sign with your personal identities by naming the
socket directly. That is the point of choosing a dedicated key, and it is worth
knowing before you choose one - a session that also needed the forwarded agent
for something else (a second remote, a private submodule over ssh) no longer
has it. Leave this unset to keep the agent.
## Using a different editor/application
@@ -947,11 +983,18 @@ dejima takes that one character back for the host while a session runs
(`bin/dejima-tty-suspend`). Ctrl-C and Ctrl-\ are deliberately left alone and
still reach the session, which is how a harness expects to be interrupted.
Suspending is not detaching. A suspended session is stopped, so it makes no
progress until you resume it, and it belongs to the terminal you started it
from. For a session that keeps working while you are away, or that you want to
pick up from somewhere else, run dejima inside tmux and use
[`dejima-attach`](remote-access.md).
Suspending is not pausing, and the difference is worth knowing before you reach
for it. What stops is the host side: `dejima` itself, the container run, the
credential proxy and the conversation shipper, which share one process group so
the kernel stops and continues them together. The container is not in that
group. It keeps running under the container runtime, with its mounts, its
firewall grants and its forge token, and keeps making requests while the
terminal looks stopped - see
[what dejima does not sandbox](security.md#what-dejima-does-not-sandbox).
Suspending is not detaching either: the job belongs to the terminal you started
it from. For a session you want to pick up from somewhere else, run dejima
inside tmux and use [`dejima-attach`](remote-access.md).
## Shared scratch space
@@ -1019,6 +1062,81 @@ that is true, rather than moving an operator's files on its own.
`dejima-share adopt <project>` moves them into one project's share after
showing what it will move and asking, and it never overwrites.
## Isolated sessions
`dejima --isolated` runs a session with nothing before it and nothing after it.
No project is mounted, no memory or transcript crosses either boundary, and the
one thing that outlives it is a `~/shared` of its own.
```sh
dejima --isolated # from anywhere - the directory you are in is not used
```
| | ordinary launch | `--isolated` |
|---|---|---|
| working directory | the project, read-write at its own path | `/dejima/work`, container-local |
| `~/shared` | this project's, shared by every session against it | made fresh, never reused |
| memory and transcripts | `~/.claude/projects/<slug>`, mounted | none |
| shell history, nvim state | volumes that outlive the container | this run's own, removed with it |
| domains, dependencies, tools | the global set plus this project's | the global set only |
| `[mounts]` lists | applied | dropped, and named at launch |
| forge, workflow | as configured | `none` and `local-only`, unless named |
| forge token, `[github] token_file` | both cross | neither, unless a forge is named |
| forwarded ssh agent | mounted when the workflow pushes | not mounted |
| `[identity] ssh_key` | mounted | mounted |
| conversation log, epitaph | as configured | off |
Your own `~/.claude` hooks, skills, commands, agents and output styles are
still mounted read-only, as is `[mounts] nvim_config`. Those are yours, no
session can write them, and a session without them is a session without the
tools you built for it.
**The directory you launch from decides nothing.** An approval store is found
by walking up from the launch directory, so a home directory that was once
approved as a project would otherwise hand an anonymous session that project's
allowed domains and its dependency layer - which is the whole reason to be able
to run this from a fresh terminal in `$HOME` without thinking about it.
**There is no config key, in any spelling.** A mode this strong is a decision
to make in the command, where it is visible, rather than a line in a file that
every later launch inherits. For the same reason `--ro` and `--rw` are refused
rather than dropped: a path typed into this command cannot be quietly ignored,
where a `[mounts]` list written months ago can be - with a line saying which.
**What it gives up.** The conversation log and the epitaph both read a
transcript on the host, and this mode mounts none, so both are off and the
launch says so rather than leaving you to find an empty record later. The
exit-time audit is skipped for the same reason: its subject is the project's
own files. If you want either back in this mode, that is
[#388](https://gitbox.branderson.io/dejima/dejima/issues/388) - both are your
record rather than the agent's, and neither ever reaches a future session.
**No forge, unless you name one on the same command.** `--isolated` sets
`[core] forge` to `none` and `[core] workflow` to `local-only` for that launch,
and withholds `[github] token_file` with them. Naming either is how you ask for
a session that clones and opens a pull request, which is a real use of this
mode:
```sh
dejima --isolated # no forge, pushes nothing
dejima --isolated --forge gitea --workflow worktree-pr # clones, pushes, opens a PR
```
Order does not matter: `--forge gitea --isolated` means the same thing, and
naming a forge brings the credentials back with it.
The table above is the whole of what crosses. Two rows surprise people:
`[github] token_file` is withheld even though it is not a forge setting, and
an `[identity] ssh_key` is mounted even though the forwarded agent is not -
so a session with no forge still holds a key that can reach a host over ssh.
Withholding that key too, for any launch whose workflow never pushes, is
[#392](https://gitbox.branderson.io/dejima/dejima/issues/392).
**Why each of those is the way it is**, and why this is a narrower blast
radius rather than a stronger sandbox, is in
[docs/security.md](security.md#a-session-with-no-project-mounted). Worth
reading before relying on this mode.
## Reading what a session left behind
When a session compacts, the summary it writes is the only durable account of
@@ -1140,22 +1258,33 @@ without the reason it is normally off (see
[docs/security.md](security.md#github-push-blocked-by-default-optional-opt-in)),
so the reason is supplied where it will be read.
`git-push-guard.sh` (`PreToolUse`/`Bash`, also `DEJIMA`-gated) blocks
any `git push` whose resolved remote isn't the Gitea host, and any push to
`main`/`master` even there. Like the other hooks, this is a fast-fail guard
against an overeager agent, not a hard security boundary - a session has full
shell access and could in principle edit `.git/config` to route around it.
`git-push-guard.sh` (`PreToolUse`/`Bash`, also `DEJIMA`-gated) blocks any `git
push` whose resolved remote is not one of the configured forge's hosts, and any
push straight to a branch the workflow protects. Neither list is named here:
both are resolved host-side from the selected forge and workflow, so this is
the same rule whichever forge is in use. Like the other hooks, this is a
fast-fail guard against an overeager agent, not a hard security boundary - a
session has full shell access and could in principle edit `.git/config` to
route around it.
GitHub push is blocked by default too (see
[GitHub push](security.md#github-push-blocked-by-default-optional-opt-in) in
docs/security.md) unless `--allow-github-push` is given. For a real guarantee
against pushes to main/master, use branch protection on the remote itself.
`merge-guard.sh` (`PreToolUse`/`Bash`, `DEJIMA`-gated) blocks
`gitea-pr merge` inside the sandbox. It's the API-side counterpart to the rule
above: a merge puts a branch on `main` without ever performing a push, so
`git-push-guard.sh` never sees it. A hook is handed a command rather than a
conversation, so it can't tell an instructed merge from an unprompted one -
which is why it's scoped to the sandbox rather than applied everywhere. On the
Both this hook and the PATH shadow beside it read the decision out of
`docker/guard-lib.sh`, which is the only copy of the rules. A push that the
library cannot read is refused rather than passed through: an unresolvable
remote, `--mirror`, and `--git-dir` or `--work-tree`, each of which names a
repository other than the one the remote would be resolved in.
`merge-guard.sh` (`PreToolUse`/`Bash`, `DEJIMA`-gated) applies the configured
forge's policy inside the sandbox: the blocked merge command, and a command
aimed at a repository the session was not launched against. It's the API-side
counterpart to the rule above: a merge puts a branch on `main` without ever
performing a push, so `git-push-guard.sh` never sees it. A hook is handed a
command rather than a conversation, so it can't tell an instructed merge from
an unprompted one - which is why it's scoped to the sandbox rather than
applied everywhere. On the
host it does nothing, and you can still ask an agent to merge. The same
fast-fail caveat applies with more force than usual here: the session has the
Gitea token in its environment and could call the merge endpoint with `curl`
+42 -35
View File
@@ -25,9 +25,10 @@ run them.
| `docker/docker-compose.yml` | Wires up mounts/env; invoked via `bin/dejima`, not directly |
| `hooks/sandbox-context.sh` | `SessionStart` hook: injects sandbox context/workflow reminders, and any `.dejima.md` the project ships |
| `.dejima.md` | Project-level agent instructions, harness-agnostic: `CLAUDE.md`'s role under a neutral name. dejima loads a project's into the session context; this one is dejima's own |
| `hooks/git-push-guard.sh` | `PreToolUse` hook: blocks pushes outside the allowed host(s)/branches |
| `hooks/git-push-guard.sh` | `PreToolUse` hook: blocks pushes outside the allowed host(s)/branches. Turns the reported command into argv and asks `docker/guard-lib.sh` |
| `hooks/deny-dangerous-bash.sh` | `PreToolUse` hook: blocks obviously destructive Bash commands |
| `hooks/merge-guard.sh` | `PreToolUse` hook: blocks the configured forge's merge command (`DEJIMA_MERGE_BLOCK`) in the sandbox: merging is a human decision |
| `hooks/merge-guard.sh` | `PreToolUse` hook: applies the forge policy - the blocked merge command (`DEJIMA_MERGE_BLOCK`), and a command aimed at another repository - to a reported command. Named for the merge block, which is the rule it was created for |
| `docker/guard-lib.sh` | The push and merge policy itself, read out of argv. The two hooks above and the two PATH shadows all call it, so a bypass is fixed once rather than twice |
| `hooks/dejima-notify.sh` | Drops an empty `waiting`/`done` marker for a remote-enabled session. Registered as a `Stop` hook only; `waiting` is there for a harness that can say it without having stopped - see [docs/remote-access.md](remote-access.md) |
| `bin/dejima-attach` | Lists live sessions and attaches to one; the shared core behind both remote front doors |
| `bin/dejima-audit` | Advisory exit-time diff of host-persistence surfaces (git config, `.claude/`, etc.). `dejima-audit show` / `dejima-audit ack` default to the enclosing repo |
@@ -49,7 +50,6 @@ run them.
| `bin/dejima-epitaph` | Host-side: keeps the summary a session leaves when its context is compacted away, in a store nothing mounts. `save` runs from `bin/dejima`'s exit trap; `list`/`search`/`show` are the operator's, and only ever read |
| `bin/dejima-harness-lib.sh` | Loads the selected harness's plugin and provides the `emit_*` functions it declares through; the guarded-store refusal runs in `emit_mount`. Not on PATH |
| `bin/harness/<name>.sh` | One per supported harness: what dejima does for that harness that no manifest value could state. Trusted computing base, sourced by `bin/dejima` |
| `bin/dejima-color-lib.sh` | The colour and terminal-detection helpers the CLIs share. Not on PATH |
| `bin/dejima-slug-lib.sh` | The `~/.claude/projects` slug scheme and which slugs belong to a workspace; sourced by `bin/dejima` (what to mount) and `bin/dejima-epitaph` (whose transcripts to keep), so the two cannot disagree. Not on PATH |
| `bin/dejima-ttyd` | Launches `ttyd` on loopback in front of `dejima-attach` |
| `systemd/*.service` | User units for `dejima-ttyd` and `dejima-notify-watch`; ship disabled, installed by hand |
@@ -106,27 +106,29 @@ notification path carries no sandbox-controlled byte, and that no marker
name outside `waiting`/`done` is ever acted on. `audit.bats` covers
`bin/dejima-audit`'s own diffing.
`config.bats`, `providers.bats` and `proxy.bats` round out the suites that
need no Docker either: `config.bats` covers `~/.config/dejima/config`
parsing and `install.sh`'s legacy-variable warnings (stubbing `docker` itself,
since the fast CI job's container deliberately has no daemon), `providers.bats`
covers the forge/workflow/harness manifest contract documented in
[docs/providers.md](providers.md), and `proxy.bats` covers the credential
proxy's own request handling. `conversation.bats` runs
`bin/dejima-conversation-ship` against a real transcript and a real HTTP
endpoint standing in for Loki, and asserts on what arrives: that a session's
lines ship, that a torn line waits until it is whole, that a sibling project
whose directory name merely extends this one is not swept up with it, and
that neither the in-memory batch nor the unread tail is lost to the kill that
ends a session.
`config.bats`, `providers.bats`, `proxy.bats`, `launch.bats` and `share.bats`
round out the suites that need no Docker either: `config.bats` covers
`~/.config/dejima/config` parsing and `install.sh`'s legacy-variable warnings
(stubbing `docker` itself, since the fast CI job's container deliberately has
no daemon), `providers.bats` covers the forge/workflow/harness manifest
contract documented in [docs/providers.md](providers.md), `proxy.bats` covers
the credential proxy's own request handling, `launch.bats` the command line a
launch builds, and `share.bats` the shared scratch directory's own layout.
`conversation.bats` runs `bin/dejima-conversation-ship` against a real
transcript and a real HTTP endpoint standing in for Loki, and asserts on what
arrives: that a session's lines ship, that a torn line waits until it is whole,
that a sibling project whose directory name merely extends this one is not
swept up with it, and that neither the in-memory batch nor the unread tail is
lost to the kill that ends a session.
One assertion is worth calling out because it can only run for real on a
Docker host: `runtime.bats` proves that a service bound to the host's
loopback is unreachable from inside a container while one bound to the
bridge gateway *is* reachable. The second half is a positive control - it is
what stops the first from passing in an environment where the container
reaches nothing at all. That pair is the ttyd front door's whole containment
argument, so run it before trusting a front door you just set up.
Docker host: `runtime.bats` proves that a service on the host is unreachable
from inside a container - bound to loopback, and bound to the bridge gateway
on a port the policy was not told to open. A second container, given that one
port, reaches it; that is the positive control, and it is what stops the
others from passing in an environment where the container reaches nothing at
all. That set is the ttyd front door's whole containment argument, so run it
before trusting a front door you just set up.
Every mutation in `runtime.bats` (firewall rules, address-set entries) happens
inside the test container's own network namespace, same as any other
@@ -178,10 +180,12 @@ subset has.
`runtime.bats` is the only suite that needs a Docker daemon: it is the one
that builds the image and boots real containers to assert against.
Everything else, `audit.bats`, `ci-runner.bats`, `config.bats`,
`conversation.bats`, `project.bats`, `providers.bats`, `proxy.bats`,
`remote.bats` and `static.bats`, runs anywhere a shell does, including inside
a dejima sandbox itself, which has no Docker daemon by design.
`ci-runner.bats` needs something rarer - a real CI runner of either tier - and
skips itself anywhere else. Everything in between runs anywhere a shell does,
including inside a dejima sandbox itself, which has no Docker daemon by design.
`test/run-confined.sh` discovers that set rather than naming it, so a suite
added under `test/` is in the lane the day it lands - which is also why no list
of them is kept here.
`.gitea/workflows/tests.yml` also runs `shellcheck` over every shell script in
the repo, as its own job. `test/shellcheck.sh` holds the file list, discovered
@@ -199,9 +203,10 @@ obscurely partway through; the file says why each is needed.
## Adding a new harness
`[core] harness` picks a directory under `harnesses/`, and that harness's
manifest declares eleven keys (see [providers.md](providers.md)). The
inventory below is everything dejima does that is shaped by *which* harness is
selected: which parts are the manifest's, which are the plugin's, and
manifest declares what dejima needs to know about it - every key is in
[providers.md](providers.md). The inventory below is everything dejima does
that is shaped by *which* harness is selected: which parts are the manifest's,
which are the plugin's, and
which are still written against Claude Code by name. Taken from a pass over
the tree, so each row names the file rather than describing a sketch - and a
row that says "in the plugin" is one a second harness supplies rather than one
@@ -245,12 +250,14 @@ R2 was a hard gate until the guards stopped needing it. Two of the three it
hosted - the push guard and the merge guard - are now PATH shadows over `git`
and the forge CLI (`docker/git-guard.sh`, `docker/forge-guard.sh`), which hold
whatever the harness can or cannot intercept, and read argv rather than
regex-matching a reconstructed command string. That left one guard depending
on interception, and it is the one this repository already describes as "a
fast-fail guard against an overeager agent doing something obviously
destructive, not a boundary against one that's actively trying to get around
them" (docs/security.md). A gate that costs a harness its support should be
guarding more than that.
regex-matching a reconstructed command string. The hooks beside them read argv
too: they split the reported command into words and ask the same library
(`docker/guard-lib.sh`), so the overlap cannot become a disagreement. That
left one guard depending on interception, and it is the one this repository
already describes as "a fast-fail guard against an overeager agent doing
something obviously destructive, not a boundary against one that's actively
trying to get around them" (docs/security.md). A gate that costs a harness its
support should be guarding more than that.
Declaring one absent is a real decision with a visible cost, not a
formality. It is there so a harness can be supported honestly at a smaller
+22 -7
View File
@@ -127,8 +127,8 @@ operator can set, resolved the same way as any other config key
| `capabilities` | `?list:identifier` | (empty) | What this forge can do - `pr`, `issues`, etc. A workflow's own `requires` is checked against this set at launch; a workflow asking for a capability this forge lacks is refused. |
| `pr_command` | `?command` | (empty) | The PR-management command name the forge's briefing prose and `env_export`/`merge_block` refer to. |
| `issue_command` | `?command` | (empty) | The issue-management command name, same idea. |
| `merge_block` | `?command` | (empty) | The command prefix refused inside the sandbox - only when the workflow's `block_merge = on`. Enforced by `docker/forge-guard.sh`, which shadows the named CLI on PATH and compares argv, so it holds for a harness that cannot intercept a tool call; `hooks/merge-guard.sh` applies the same rule to a command string, for the harnesses that can. Matched as a run of consecutive non-flag arguments, so a flag before the subcommand does not slip past it. |
| `repo_flags` | `?list:flag` | (empty) | The option names this forge's CLI accepts for naming a repository other than the one the session was launched against - `--repo -R` for `gh`. `docker/forge-guard.sh` refuses a value that is not that repository, in every spelling of the flag itself: `--repo x`, `--repo=x`, `-R x`, `-Rx`, and the letter inside a short-flag cluster (`-wR x`, `-wRx`). Fast-fail, not a boundary, for the same reason `merge_block` is not one: the session holds the token. Leave it empty for a CLI that resolves the repo itself and refuses another host, as the gitea ones do - a forge is not required to be aimable. |
| `merge_block` | `?command` | (empty) | The command prefix refused inside the sandbox - only when the workflow's `block_merge = on`. Enforced by `docker/forge-guard.sh`, which shadows the named CLI on PATH and compares argv, so it holds for a harness that cannot intercept a tool call; `hooks/merge-guard.sh` applies the same rule to a reported command for the harnesses that can, through the same library, so the two cannot disagree. Matched as a run of consecutive non-flag arguments, so a flag before the subcommand does not slip past it. |
| `repo_flags` | `?list:flag` | (empty) | The option names this forge's CLI accepts for naming a repository other than the one the session was launched against - `--repo -R` for `gh`. `docker/forge-guard.sh` refuses a value that is not that repository, in every spelling of the flag itself: `--repo x`, `--repo=x`, `-R x`, `-Rx`, and the letter inside a short-flag cluster (`-wR x`, `-wRx`). Fast-fail, not a boundary, for the same reason `merge_block` is not one: the session holds the token. Leave it empty for a CLI that resolves the repo itself and refuses another host, as the gitea ones do - a forge is not required to be aimable. **Enforced only for a CLI that also appears in `merge_block` or `disclose`:** those two are what name the commands the shadow is linked over and the hook checks against, so a forge declaring this and neither of them would get the rule applied to nothing. |
| `identity_keys` | `?list:identifier` | (empty) | Which of this forge's own `keys` name an **account** rather than the forge itself - the credential paths, which differ per session, where an API URL does not. One declaration, two rules: a key named here is one an `[identity.<name>]` section may set, and one a project may not declare in its own `.dejima/`. Core keeps no list of its own, so a forge whose credential is spelled differently needs no change here. The identity inherits the type from this forge's `keys` entry, not a second copy of it. |
| `repo_env` | `?identifier` | (empty) | The environment variable this forge's CLI reads for the same thing - `GH_REPO`. Declared separately because it takes no argument and would otherwise be the way around every flag above. |
| `repo_paths` | `?list:repopath` | (empty) | A repository this forge's CLI names in a path rather than in a flag, as `<subcommand>:<first segment>` - `api:repos` for `gh api repos/<owner>/<name>/...`. Scoped to a subcommand on purpose: comparing every positional argument that contains two slashes would refuse an issue title that happens to look like one, and a false refusal costs an honest agent a working command. Paths carrying `gh`'s own `{owner}`/`{repo}` placeholders are left alone, since those resolve to this repository by construction. |
@@ -234,10 +234,11 @@ A workflow's `briefing.md` is plain Markdown plus two marker forms that
| `api_domains` | `?list:hostname` | (empty) | Hostnames this harness's own machinery must reach - its update host, its telemetry endpoint. They join the built-in part of the permitted set rather than the operator's, and are not exempted from the check that refuses an answer pointing inside a private network - a harness's endpoints are public names, and exempting a public name is what turns one influenced answer into egress to an internal host. |
| `api_styles` | `?list:identifier` | (empty) | Which backend `api_style`s this harness has a client for. `claude-code` speaks `anthropic`; `cline` speaks both, which is what makes it the backend-agnostic one. Empty means unchecked. |
| `guards` | `?list:identifier` | (empty) | Which of dejima's guard rails this harness can host: `bash-deny`, `push-guard`, `merge-guard`, `notify`. A name outside that set is refused rather than treated as an unknown guard, since a typo would otherwise turn a guard off silently. The ones a harness does not declare are named at launch - see "What a harness must be able to do" in [development.md](development.md). |
| `assumed_backend` | `?provider:backend` | (empty) | Which backend this harness talked to before `[harness.<name>] backend` existed. Read only when that key is unset, and it writes into the operator's config rather than being consulted at launch - the one key here that does. |
| `briefing_args` | `?args` | (empty) | The flag that carries the briefing to a harness taking it on the command line - for Claude Code, `--append-system-prompt`. `dejima-harness-wrapper` reads the rendered briefing and passes it as a single value, so every invocation carries it and not only the entrypoint's. Set, the session-start hook stops carrying the briefing and reduces to the session id, which is the one thing that does not exist until the harness starts. |
| `briefing_path` | `?homepath` | (empty) | Where this harness reads its briefing from, relative to `$HOME`, for a harness that has no session-start hook to be handed it through. Set, `entrypoint.sh` renders the briefing into that path root-owned and read-only before the session starts, and refuses to start it at all if that fails. Empty for a harness that takes the briefing on the command line (`briefing_args`, which is how Claude Code gets it) or that can be handed it through a session-start hook. |
| `no_confirm_args` | `?template` | (empty) | The flags that make the harness run without confirming each tool call. Required in practice: dejima refuses to launch a harness that names none, because unattended operation is the premise the container is built on (R1 in [development.md](development.md)). |
| `settings_args` | `?template` | (empty) | How this harness is told to read dejima's own settings layer, mounted at `/etc/dejima-settings.json`. Empty for a harness that takes its overrides some other way. |
| `no_confirm_args` | `?args` | (empty) | The flags that make the harness run without confirming each tool call. Required in practice: dejima refuses to launch a harness that names none, because unattended operation is the premise the container is built on (R1 in [development.md](development.md)). |
| `settings_args` | `?args` | (empty) | How this harness is told to read dejima's own settings layer, mounted at `/etc/dejima-settings.json`. Empty for a harness that takes its overrides some other way. |
| `keys` | `?list:keyspec` | (empty) | This harness's own `[harness.<name>]` config schema - same grammar as a forge's `keys`. |
| `resume_command` | `?template` | (empty) | Prose template (may use `{{session_id}}`) shown at the end of the briefing for resuming this exact session on the host. Substitution is not recursive and the `template` type has no `=`, so a resume command that needs a second placeholder or an environment variable goes in the harness's briefing fragment instead, where both resolve. |
| `transcript_update` | `enum:append\|rewrite` | `append` | How this harness's transcript files change, which decides how they can be read incrementally. `append` means the file only grows, one record per line, so the conversation shipper reads forward from a byte offset and consumes only whole lines. `rewrite` means the file is replaced wholesale, where an offset would land in the middle of a document - it is re-read when it changes and the records already shipped are skipped by count. Declared rather than sniffed, because append semantics against a rewritten file ship garbage. |
@@ -437,12 +438,21 @@ Every `{{placeholder}}` in the assembled text must be on a fixed whitelist
(`pr_command`, `issue_command`, `forge_name`, `forge_ssh_host`,
`blocked_branches`, `container_id`, `project_dir`, `workspace_dir`,
`session_id`, `resume_command`, `writable_mounts`, `network_allowlist`,
`no_confirm_args`, `harness_state_dir`) - an unrecognized one is a
`no_confirm_args`, `harness_state_dir`, `workspace_posture`,
`project_posture`) - an unrecognized one is a
hard refusal, not a literal pass-through. `{{session_id}}` and
`{{container_id}}` are deliberately left as `@@SESSION_ID@@`/
`@@CONTAINER_ID@@` markers instead of being resolved here, since neither is
known until the container is actually running.
`workspace_posture` and `project_posture` are what
[`--isolated`](configuration.md#isolated-sessions) changes. The preamble's
claims about the mount, about `~/shared` being shared with every session
against this project, and about the transcript outliving the container are
substituted rather than written inline, because an isolated launch makes all
three false - and a session that believes them leaves work for a resume that
cannot happen.
### How the briefing reaches the session
**A session-start hook is a size-limited channel, which is why it is not the
@@ -675,8 +685,13 @@ knowing about because they are what a second forge costs:
as such, and a count of what was hidden. Everything else `gitea-pr` does has
a `gh` subcommand already.
- **`gh` can be aimed past the sandboxed repository, and is stopped.** The
gitea CLIs refuse an origin that is not on the configured host, so they can
only act on the repo the sandbox was launched against. `gh` takes `--repo`
gitea CLIs resolve the repository from `origin` and refuse an origin that is
not on the configured host, so there is no flag or variable to compare and
nothing for this guard to declare. That is a narrower confinement than it
sounds: `origin` lives in `.git/config`, inside the read-write project mount,
and the gitea token is account-wide - so a session that rewrites `origin` to
another repository **on the same host** reaches it. What bounds that is the
token's scope, as it is for `gh`. `gh` takes `--repo`
on nearly every subcommand, reads `GH_REPO`, and names repositories in `gh
api` paths, so this forge declares all three (`repo_flags`, `repo_env`,
`repo_paths`) and `docker/forge-guard.sh` refuses a value that is not the
+16 -10
View File
@@ -52,11 +52,16 @@ running.
## The bind address is the one thing to get right
`docker/egress-policy` deliberately permits traffic to the session's
default-route address - the Docker bridge gateway, which is your host. That is
the one host address a sandbox can reach at all. Combine it with the fact above
(a tmux client is a host shell) and the consequence is direct: **a front door
the sandbox can reach is a compromised session's own escape hatch.**
The session's default-route address - the Docker bridge gateway - is your
host, and it is the one host address a sandbox can route to at all. Combine
that with the fact above (a tmux client is a host shell) and the consequence
is direct: **a front door the sandbox can reach is a compromised session's own
escape hatch.**
`docker/egress-policy` opens no port on that address unless told to, so a
front door bound to it is refused by the packet filter as well. Do not spend
that: the clamp is the second layer, and binding the proxy correctly is the
first.
So:
@@ -900,10 +905,11 @@ invisibly, and on the same `XDG_RUNTIME_DIR` as the gotcha above.
**The marker directory is a write channel into your runtime tmpfs**, and
nothing bounds it. `/run/user/<uid>` is RAM-backed and sized at a fraction of
physical memory, so a session filling `/run/dejima-notify` degrades your
whole login session - D-Bus, the systemd user manager - not just dejima. A
session can already fill the workspace mount, so this is an increment rather
than a new exposure, but a RAM-backed filesystem fails harder and sooner.
physical memory, so a session filling `${XDG_RUNTIME_DIR}/dejima/notify`
degrades your whole login session - D-Bus, the systemd user manager - not just
dejima. A session can already fill the workspace mount, so this is an increment
rather than a new exposure, but a RAM-backed filesystem fails harder and
sooner.
There is deliberately no quota here; `rm -rf` on that run's marker directory (or
logging out, which discards the tmpfs) is the recovery.
@@ -1013,7 +1019,7 @@ name, the session's age, its state, and the container id.
The picker is coloured (gruvbox) when stdout is a terminal that says it can
show it; `NO_COLOR=1` or a `dumb` `$TERM` turns that off, along with the
box-drawing glyphs. `--list` is never coloured, terminal or not - the picker
parses those rows itself and the README calls them scriptable.
parses those rows itself and `dejima-attach --help` calls them scriptable.
Three states, from a registry `dejima` writes on launch and removes on
exit:
+105 -8
View File
@@ -87,7 +87,7 @@ last is what remains once something is.
| Layer | Reached by | Radius without | Bounded by | Radius with |
| --- | --- | --- | --- | --- |
| Agent, not yet hostile | ordinary operation | the workspace, its history, every mounted path, and the forge, at full speed | the session briefing and `.dejima.md`; the `PreToolUse` hooks | obvious destruction and out-of-remote pushes fail fast, and merging stays a human decision |
| Agent, not yet hostile | ordinary operation | the workspace, its history, every mounted path, and the forge, at full speed | the session briefing and `.dejima.md`; the command guards | obvious destruction and out-of-remote pushes fail fast, and merging stays a human decision |
| Container | ordinary operation | the operator's home directory, every credential in it, and the open internet | dejima | the workspace, allowlisted domains, and the forge token's scope |
| Devbox VM, unprivileged | container escape | everything the account can read, and unrestricted egress to the internet and the LAN | `dejima-devbox-hardener` | the same VM, with LAN reach cut to what is declared; internet egress stays open, deliberately |
| Devbox VM, root | escape plus local escalation, or misconfiguration | the whole VM, the same egress, and the model API credential | `dejima-devbox-hardener`, enforced outside the guest | the whole VM and no further: root cannot reach the rule set that holds the LAN boundary |
@@ -176,8 +176,8 @@ the sandbox at it instead of at you:
- `[forge.gitea] token_file`: that account's token, rather than one issued
under yours.
- `[core] ssh_key`: a deploy key belonging to it, rather than your forwarded
agent. See "Headless git push without agent forwarding" in
[docs/configuration.md](configuration.md).
agent - which is then not mounted at all. See "Headless git push without
agent forwarding" in [docs/configuration.md](configuration.md).
With the two identities separated, the same settings stop being decorative:
@@ -202,8 +202,7 @@ 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.
the scopes its token needs, and nothing about that changes for a collaborator.
One consequence presents as a misconfiguration, and the obvious fix is the
wrong one. Parts of Gitea's API are gated on *ownership* rather than on
@@ -322,6 +321,47 @@ can make; it is one the audit reports. See
overlaid (they're config, not fixed paths a static mount can cover) and
remain fully writable; the exit-time audit below reports changes to
either, but only after the session ends - it doesn't stop them.
### A session with no project mounted
`dejima --isolated` removes this whole class rather than bounding it: there is
no mounted project to be written, no `.git` to plant a hook in, and no
harness state directory on the host for a session to leave anything in. Its
working directory is container-local and its `~/shared` is made fresh for that
session alone.
**It narrows the forge credential too**, which is the widest thing a session
holds: a Gitea token is account-wide (see
[To your forge, the sandbox is you](#to-your-forge-the-sandbox-is-you)), so an
ordinary session can read and write every repository its operator can.
`--isolated` sets `forge` to `none` and `workflow` to `local-only` for that
launch, and blanks the session's copy of `[github] token_file` - core's
read-only GitHub credential, which crosses for every ordinary session
whatever forge is selected, and which is read-only across the operator's
*private* repositories rather than scoped to one. So no forge credential of
either kind reaches an isolated session, and nothing tells it to push.
Naming a forge on the same command line brings that back: it is the operator
saying this session is meant to reach one, and nothing is narrowed from there.
The filter container keeps the GitHub token either way, because that is what
authenticates dejima's own fetch of GitHub's address ranges - a request about
the allowlist, made outside the session's reach.
**The forwarded agent is not mounted; a configured key is.** A workflow that
never pushes gets no agent socket, and `local-only` is one - so the personal
identities in the operator's own agent stay outside the container. An
`[identity] ssh_key` is mounted and loaded whatever the workflow, so an
isolated session with no forge still holds a key that can reach a host over
ssh. Withholding that for any launch whose workflow never pushes is
[#392](https://gitbox.branderson.io/dejima/dejima/issues/392).
So: a narrower blast radius on the host and a narrower credential, not a
stronger sandbox. The firewall, the credential boundary and the capability
drops are unchanged, and a container escape is exactly as serious. The cost is
that the conversation log, the epitaph and the exit-time audit are all off,
because each reads host state this mode does not mount. See
[Isolated sessions](configuration.md#isolated-sessions).
## The exit-time audit
`bin/dejima-audit` snapshots host-persistence surfaces before each run and
@@ -782,7 +822,22 @@ reach the model - which reads as a dejima fault rather than a missing
prerequisite. Sign in on the host first; see the README's
[Sign in on the host first](../README.md#sign-in-on-the-host-first).
Refreshing an expired credential is the same: on the host, not from inside a
session, where the credential deliberately never goes.
session, where the credential deliberately never goes. A session that outlives
its credential - one left open overnight is the ordinary way - meets a 502 from
the proxy naming that as the remedy, and the same line is the last thing the
proxy logs, so the exit audit repeats it on the host terminal.
**What the proxy may tell a session about a credential failure.** Anything it
learned from the provider stays on the host: a response body, a status line, an
exception. What crosses into the container is text this repository wrote, and
only when it names something the operator has to do - "log in again on the
host" is useful to an agent that would otherwise report itself broken, and
discloses nothing the session did not already know. The distinction is the
credential proxy's `CredentialNeedsOperator`, which carries the two separately
so neither can be sent by accident. The proxy's own log is bound by the same
rule - it keeps provider statuses and error-body prefixes for the exit audit -
so it lives outside the socket directory, which is the one proxy path the
session mounts.
### The refreshing OAuth path, in detail
@@ -999,6 +1054,9 @@ reasoning below is what both are built on.
Gitea repo it has access to" shape as the forwarded agent above - the
mitigation is that it's meant to be a dedicated deploy key scoped to only
the repos that actually need push access, not your personal identity.
Setting it also withdraws the forwarded agent: `SSH_AUTH_SOCK` is not passed
to the container, so the two paths are alternatives rather than both being
open at once.
That scoping is a convention, not something enforced here: nothing checks
what `[core] ssh_key` actually points at, so pointing it at your real
`~/.ssh/id_ed25519`/`id_rsa` instead of a dedicated key would load your
@@ -1219,7 +1277,9 @@ On start, the filter container establishes an allowlist-only egress policy
everything the session may reach expressed as named sets. Allowed by default:
- DNS resolution via a local proxy (see "DNS is allowlisted too")
- the Docker host itself (not its whole subnet)
- nothing on the Docker host. Its address is a named destination so that
reaching for it is counted and refused rather than falling off the end of the
chain, but no port on it is open (see "The host entry" below)
- GitHub's published IP ranges, port 443 only (see "GitHub push" below)
- `registry.npmjs.org`, plus whatever the selected harness declares as its own
(`platform.claude.com` for Claude Code; Cline declares none, so a session
@@ -1286,6 +1346,16 @@ of it loads. A tool that will not run and a family that is not there are
different conditions, and reporting the second on evidence of the first is how
a boundary comes to be described rather than held.
The startup self-check also resolves every permitted name and reads each answer
back out of the address set its class names, refusing to start if one is
missing. A name that resolves and whose address is not where the ports are
keyed to would be a session that reaches a configured destination only by
accident, or not at all - and the class an entry carries, the resolver line
built from it, and the set the rules use are three separate statements that can
each be right about a different one. A name that returns no address is reported
rather than fatal: that is the world being unavailable, not the policy
disagreeing with itself.
The startup self-check asserts the shape of the ruleset, not a connection that
fails: a v6 connection fails identically on a host with no v6 route, so a probe
alone would pass whether the family is refused or merely absent.
@@ -1293,6 +1363,32 @@ alone would pass whether the family is refused or merely absent.
If your Gitea host only resolves via a LAN-local DNS server, make sure the
Docker host itself can resolve it: containers inherit the host's resolver.
### The host entry
Every other destination in the policy is there because something declared it.
The host entry is not: it is the gateway of the container's default route,
derived from the routing table. Docker publishes container ports on that
address, so permitting it on every port would reach the host's sshd, anything
bound to `0.0.0.0`, and every published port of every other container on the
machine - including dejima's own remote-access front door, which is a host
shell behind a tmux prefix key.
So it opens no ports. The address is still named in the ruleset, with its own
refusal rather than a fall-through to the end of the chain, so that a session
reaching for the host shows up as a counted refusal instead of an anonymous
one.
Nothing in a launch needs it open. A host-resident forge, a host-resident Loki
and a self-served model endpoint are each reached through their own entry - by
name, or as a literal address - and each lands in a set of its own with its own
rules. Configuring one of those is how a host service becomes reachable.
For anything else, `EGRESS_HOST_PORTS` on the filter container takes TCP ports
and low-high ranges, comma or space separated; `EGRESS_HOST_PORTS=any` restores
the unclamped behaviour explicitly, and `EGRESS_HOST_ADDR=none` removes the
entry altogether. A value that is not a port stops the container rather than
becoming a rule nobody meant.
### DNS is allowlisted too
Resolution itself is restricted, not just the IP connections made
@@ -1494,7 +1590,8 @@ execution, and the audit reports the config change it took.
## Network request logging
If `[logging] loki_url` is set (see Prerequisites in the README), every DNS query and every new
If `[logging] loki_url` is set (see [docs/configuration.md](configuration.md)),
every DNS query and every new
outbound connection attempt - allowed or rejected - is shipped to that Loki
endpoint. Two sources, both tailed and pushed by `log-shipper.sh`:
+1 -1
View File
@@ -110,7 +110,7 @@ if nothing is arranged against it, the last is what remains once something is.
| Layer | Reached by | Radius without | Bounded by | Radius with |
| --- | --- | --- | --- | --- |
| Agent, not yet hostile | ordinary operation | the workspace, its history, every mounted path, and the forge, at full speed | the session briefing and `.dejima.md`; the `PreToolUse` hooks | obvious destruction and out-of-remote pushes fail fast, and merging stays a human decision |
| Agent, not yet hostile | ordinary operation | the workspace, its history, every mounted path, and the forge, at full speed | the session briefing and `.dejima.md`; the command guards | obvious destruction and out-of-remote pushes fail fast, and merging stays a human decision |
| Container | ordinary operation | the operator's home directory, every credential in it, and the open internet | dejima | the workspace, allowlisted domains, and the forge token's scope |
| Devbox VM, unprivileged | container escape | everything the account can read, and unrestricted egress to the internet and the LAN | `dejima-devbox-hardener` | the same VM, with LAN reach cut to what is declared; internet egress stays open, deliberately |
| Devbox VM, root | escape plus local escalation, or misconfiguration | the whole VM, the same egress, and the model API credential | `dejima-devbox-hardener`, enforced outside the guest | the whole VM and no further: root cannot reach the rule set that holds the LAN boundary |
+6 -3
View File
@@ -35,9 +35,12 @@ api_domains =
# None. Cline registers no hook that can refuse a tool call, so bash-deny has
# nowhere to live, and it has no session lifecycle signal dejima can turn into
# a notification. push-guard and merge-guard are not listed because no harness
# hosts them any more - they are PATH shadows and hold regardless. Launch
# names what this costs; see harness_report_guards.
# a notification.
#
# push-guard and merge-guard are not listed because this harness cannot host
# them, not because nothing does - claude-code declares both, and the PATH
# shadows cover a harness that does not. Declaring none here costs the hook
# layer, not the guard. Launch names the cost; see harness_report_guards.
guards =
# Both, which is what makes this harness the backend-agnostic one: it has a
+1 -1
View File
@@ -221,7 +221,7 @@ if [ -f "$decl_dir/allowed-domains" ]; then
Tell the user to run, outside the sandbox:
dejima-project approve \"$project_dir\"
(or, for one domain right now:
dejima-allow --container $container_id <domain>)"
dejima-allow --container $container_id <domain>...)"
fi
fi
+35 -155
View File
@@ -3,19 +3,19 @@
# DEJIMA_PUSH_HOSTS, and never directly to a branch listed in
# DEJIMA_BLOCKED_BRANCHES - both resolved host-side by bin/dejima from the
# configured forge and workflow (see dejima-provider-lib.sh), not hardcoded
# to any one forge. This is a fast-fail guard against an overeager agent,
# not a hard security boundary - a sandbox session has full shell access
# and could in principle edit .git/config or re-export these variables to
# route around it; that is true today and is not a regression, and the
# firewall (see docker/egress-policy) is the actual boundary. No-op outside the
# sandbox (DEJIMA unset), since this restriction doesn't apply to normal
# host usage.
# to any one forge.
#
# DEJIMA_PUSH_HOSTS fails CLOSED: empty means no host is allowed, so every
# push is refused. That is deliberate - it is how a no-forge or
# non-pushing workflow becomes enforced rather than merely described.
# DEJIMA_BLOCKED_BRANCHES fails OPEN: empty means no branch is protected,
# which is correct for a workflow that genuinely has no branch policy.
# The rules themselves are docker/guard-lib.sh, which docker/git-guard.sh
# applies to the same push as a PATH shadow. This file's whole job is to turn
# the command string a harness reports into the argv that library reads, so
# that the two cannot reach different verdicts - which they had already started
# to do.
#
# A fast-fail guard against an overeager agent, not a hard security boundary: a
# sandbox session has full shell access and could in principle edit .git/config
# or re-export these variables to route around it, and the firewall (see
# docker/egress-policy) is the actual boundary. No-op outside the sandbox
# (DEJIMA unset), since this restriction doesn't apply to normal host usage.
if [ -z "${DEJIMA:-}" ]; then
exit 0
@@ -25,151 +25,31 @@ input=$(cat)
command=$(echo "$input" | jq -r '.tool_input.command // empty')
cwd=$(echo "$input" | jq -r '.cwd // empty')
# Only care about `git push` invocations - found by walking tokens rather than
# by matching a shape.
# shellcheck source=../docker/guard-lib.sh
source "${DEJIMA_GUARD_LIB:-/usr/local/lib/dejima/guard-lib.sh}"
# A push can be the second half of a compound command, and only a segment whose
# first word is git is a git invocation. Every segment that is one gets the same
# verdict function the shadow calls, on the words after `git`, so git's own
# options do not need anticipating here: they are argv either way.
#
# The pattern this replaced recognised `git push` and `git -C <dir> push` and
# nothing else, so every other spelling of git's own options - `git -c k=v
# push`, `git --git-dir=... push`, `git --no-pager push` - was not a push as
# far as this hook was concerned, and it exited 0 before any check ran. The
# PATH shadow in docker/git-guard.sh walks argv and caught them regardless,
# which is why this was a quiet gap in the earlier warning rather than an open
# one.
#
# Split on the shell separators first, because a push can be the second half
# of a compound command, and only the segment that STARTS with git is a git
# invocation - "echo push" is not.
push_seen=0
segment=""
IFS=$'\n' read -rd '' -a _segments < <(
printf '%s' "$command" | sed 's/&&/\n/g; s/||/\n/g; s/;/\n/g; s/|/\n/g'
printf '\0')
for seg in ${_segments[@]+"${_segments[@]}"}; do
read -ra _t <<< "$seg"
[ "${#_t[@]}" -gt 0 ] || continue
[ "${_t[0]}" = git ] || continue
_i=1
while [ "$_i" -lt "${#_t[@]}" ]; do
case "${_t[$_i]}" in
-c|-C|--exec-path|--git-dir|--work-tree|--namespace|--super-prefix)
_i=$((_i + 1)) ;;
-*) ;;
push) push_seen=1; segment="$seg"; break ;;
*) break ;;
esac
_i=$((_i + 1))
done
[ "$push_seen" -eq 1 ] && break
done
[ "$push_seen" -eq 1 ] || exit 0
# Everything below reads the git invocation, not the whole command line.
command="$segment"
repo_dir="$cwd"
if [[ "$command" =~ git[[:space:]]+-C[[:space:]]+([^[:space:]]+) ]]; then
repo_dir="${BASH_REMATCH[1]}"
fi
# One pass over the whole command, the way the shadow walks argv: skip git's
# own options until the first token that IS the subcommand, then take the
# first non-flag token as the remote and the second as the refspec.
#
# Splitting on the first occurrence of the substring "push" is what this
# replaced, and it split inside `remote.origin.pushurl=...`, a branch named
# push-fix, and anything else carrying the word. What came out was a nonsense
# remote, which then took the unresolvable path below.
#
# Word-splitting rather than a full shell parse, so quoting edge cases are not
# handled - the same pragmatic tradeoff as the sibling
# deny-dangerous-bash.sh denylist, and the reason the unresolvable case
# refuses rather than guesses.
#
# `-c key=value` is kept rather than dropped: git resolves where a push goes
# from the config the push runs under, so asking without these answers for a
# different configuration. `git -c remote.origin.pushurl=... push origin
# branch` is that case.
config_overrides=()
positional=()
read -ra tokens <<< "$command"
t=0
seen_push=0
while [ "$t" -lt "${#tokens[@]}" ]; do
tok="${tokens[$t]}"
if [ "$seen_push" -eq 0 ]; then
case "$tok" in
-c) t=$((t + 1))
[ "$t" -lt "${#tokens[@]}" ] && config_overrides+=(-c "${tokens[$t]}") ;;
-C|--exec-path|--git-dir|--work-tree|--namespace|--super-prefix)
t=$((t + 1)) ;;
push) seen_push=1 ;;
*) ;;
esac
else
case "$tok" in
-*) ;;
*) positional+=("$tok") ;;
esac
fi
t=$((t + 1))
done
remote="origin"
refspec=""
if [ "${#positional[@]}" -ge 1 ]; then
remote="${positional[0]}"
fi
if [ "${#positional[@]}" -ge 2 ]; then
refspec="${positional[1]}"
fi
# --push: the URL the push will actually use. remote.<name>.pushurl wins over
# remote.<name>.url when it is set, and reading the latter checks a host the
# push is not going to. See docker/git-guard.sh, which asks the same question.
remote_url=$(git ${config_overrides[@]+"${config_overrides[@]}"} \
-C "$repo_dir" remote get-url --push "$remote" 2>/dev/null)
if [ -z "$remote_url" ]; then
# Refused, not passed through. A remote that does not resolve is usually a
# typo git would reject in a moment anyway - but it is also what a
# disagreement between this parser and git's own looks like, and the
# failure of that disagreement has to be a refusal rather than a push
# nobody checked. docker/git-guard.sh takes the same direction; the two
# answering differently was the bug.
echo "Blocked by git-push-guard.sh: cannot resolve the remote '$remote' to a URL, so there is nothing to check this push against. Name a remote that exists." >&2
exit 2
fi
host=$(echo "$remote_url" | sed -E 's#^[a-zA-Z]+://([^/@]+@)?([^/:]+).*#\2#; s#^[^@/]+@([^:/]+)[:/].*#\1#')
host_allowed=0
for allowed_host in ${DEJIMA_PUSH_HOSTS:-}; do
if [ "$host" = "$allowed_host" ]; then
host_allowed=1
break
fi
done
if [ "$host_allowed" != 1 ]; then
echo "Blocked by git-push-guard.sh: this sandbox may only push to ${DEJIMA_FORGE_NAME:-the configured forge}'s allowed host(s) (${DEJIMA_PUSH_HOSTS:-none allowed}), not '$host'." >&2
exit 2
fi
# Resolve the target branch: a src:dst refspec's dst, a bare branch name, or
# (if no refspec was given at all, or it's HEAD) the current branch - `git
# push origin` and `git push origin HEAD` both push the current branch.
if [[ "$refspec" == *:* ]]; then
branch="${refspec#*:}"
elif [ -n "$refspec" ] && [ "$refspec" != "HEAD" ]; then
branch="$refspec"
else
branch=$(git -C "$repo_dir" branch --show-current 2>/dev/null)
fi
branch="${branch#refs/heads/}"
for blocked_branch in ${DEJIMA_BLOCKED_BRANCHES:-}; do
if [ "$branch" = "$blocked_branch" ]; then
echo "Blocked by git-push-guard.sh: direct pushes to '$branch' aren't allowed in the sandbox. Push a feature branch and open a PR/MR instead." >&2
# What a segment's FIRST word has to be is still git. A `FOO=bar git push`
# prefix, or `env git push`, is a git invocation this does not see - the PATH
# shadow does, which is the division of labour the pair exists for rather than
# a gap in it.
# Basenamed, like hooks/merge-guard.sh and like the shadow, which reads its own
# $0. `/usr/bin/git push origin main` is the one spelling that reaches neither
# layer otherwise - the absolute path steps over the PATH shadow, and comparing
# the whole word meant this hook did not see a git invocation at all. It is
# also the form the library's own header names as the reason both layers exist.
while IFS= read -r segment; do
read -ra tokens <<< "$segment"
[ "${#tokens[@]}" -gt 0 ] || continue
[ "$(basename -- "${tokens[0]}")" = git ] || continue
if ! dejima_push_verdict "$cwd" "${tokens[@]:1}"; then
echo "Blocked by git-push-guard.sh: $DEJIMA_GUARD_REASON" >&2
exit 2
fi
done
done < <(dejima_guard_segments "$command")
exit 0
+36 -22
View File
@@ -1,10 +1,15 @@
#!/usr/bin/env bash
# PreToolUse hook: inside dejima, block a merge command matching
# DEJIMA_MERGE_BLOCK - a literal command prefix resolved host-side by
# bin/dejima from the configured forge and workflow, never hardcoded to one
# forge. What lands on the default branch is an operator decision, as it
# already is for `git push`; this is the API-side counterpart to
# git-push-guard.sh, since a merge reaches the branch without a push.
# PreToolUse hook: inside dejima, apply the forge policy to a forge CLI call -
# the merge a workflow blocks, and a command aimed at a repository the session
# was not launched against. What lands on the default branch is an operator
# decision, as it already is for `git push`; this is the API-side counterpart
# to git-push-guard.sh, since a merge reaches the branch without a push.
#
# The rules are docker/guard-lib.sh, which docker/forge-guard.sh applies to the
# same command as a PATH shadow. This file's job is to turn the command string
# a harness reports into the argv that library reads, so the two cannot reach
# different verdicts. Named for the merge block because that is the rule it was
# created for; the rest came with the shared policy.
#
# Not conditional on intent: a hook is handed a command, not a conversation,
# so it cannot tell an instructed merge from an unprompted one. The line is
@@ -16,32 +21,41 @@
# shell access and the forge token, so it could call the merge endpoint with
# curl. It stops the mistake, not the attack.
#
# Fails OPEN - empty means nothing is blocked, so a workflow with no
# merge-block policy genuinely has none rather than the hook guessing.
# Fails OPEN - a forge that declares no policy has none blocked, rather than
# the hook guessing.
if [ -z "${DEJIMA:-}" ]; then
exit 0
fi
if [ -z "${DEJIMA_MERGE_BLOCK:-}" ]; then
if [ -z "${DEJIMA_MERGE_BLOCK:-}" ] && [ -z "${DEJIMA_DISCLOSE:-}" ] &&
[ -z "${DEJIMA_FORGE_REPO_FLAGS:-}" ] && [ -z "${DEJIMA_FORGE_REPO_ENV:-}" ] &&
[ -z "${DEJIMA_FORGE_REPO_PATHS:-}" ]; then
exit 0
fi
command=$(jq -r '.tool_input.command // empty')
# DEJIMA_MERGE_BLOCK is a literal command prefix from the forge manifest,
# not a regex - escape its regex metacharacters before matching so a value
# like ".*" matches only that literal text, never "everything".
escaped_block=$(printf '%s' "$DEJIMA_MERGE_BLOCK" | sed -E 's/[][(){}.*+?^$\\|]/\\&/g')
# shellcheck source=../docker/guard-lib.sh
source "${DEJIMA_GUARD_LIB:-/usr/local/lib/dejima/guard-lib.sh}"
# The leading class includes '/' so a path-qualified call is caught too -
# the Dockerfile installs each forge's CLI under /usr/local/bin, and that's
# what an agent reaching past a shadowed PATH would type. Word-splitting
# rather than a full shell parse, the same pragmatic tradeoff the sibling
# hook makes.
if [[ "$command" =~ (^|[[:space:]&\|\;/])$escaped_block([[:space:]]|$) ]]; then
echo "Blocked by merge-guard.sh: merging is a human decision in the sandbox - open a PR/MR, then the operator merges. Report its URL and stop." >&2
exit 2
fi
forge_commands="$(dejima_forge_commands | sort -u)"
[ -n "$forge_commands" ] || exit 0
# Basenamed, so a path-qualified call is checked too - the Dockerfile installs
# each forge's CLI under /usr/local/bin, and the real one sits behind the
# shadow at a fixed path, which is what an agent reaching past a shadowed PATH
# would type. It is also what the shadow itself compares, since that reads its
# own basename.
while IFS= read -r segment; do
read -ra tokens <<< "$segment"
[ "${#tokens[@]}" -gt 0 ] || continue
self="$(basename -- "${tokens[0]}")"
grep -qxF -- "$self" <<< "$forge_commands" || continue
if ! dejima_forge_verdict "$self" "${tokens[@]:1}"; then
echo "Blocked by merge-guard.sh: $DEJIMA_GUARD_REASON" >&2
exit 2
fi
done < <(dejima_guard_segments "$command")
exit 0
+3 -1
View File
@@ -140,7 +140,9 @@ else
# only what the operator has actually decided.
{
printf '# dejima configuration. See README.md.\n'
printf '# Every key below is shown at its default; uncomment to change one.\n'
printf '# This file holds what you have decided, not every key there is.\n'
printf '# defaults.conf beside it is the complete reference, rewritten by\n'
printf '# every ./install.sh - do not edit that one.\n'
printf '#\n'
printf '# [core] harness, forge and workflow are written out uncommented, so\n'
printf '# this file states what it selects rather than inheriting a future\n'
+4 -10
View File
@@ -77,16 +77,10 @@ PY
[ -e /var/run/docker.sock ]
}
@test "the two runners are not the same runner" {
# Whatever else is true, exactly one of them gets the daemon. If both jobs
# report the same answer here, the labels are not routing to two different
# runners and the tiering exists only on paper.
if [ "$DEJIMA_CI_TIER" = confined ]; then
[ ! -e /var/run/docker.sock ]
else
[ -e /var/run/docker.sock ]
fi
}
# The two directions above are each one job's answer: the confined runner must
# not have the docker socket and the privileged one must. That the two jobs land
# on DIFFERENT runners does not follow from either, and a suite cannot see it -
# it runs in one job. Comparing the two jobs' results is the workflow's to do.
# --- egress, for the tier the rules actually apply to ------------------------
#
+281 -20
View File
@@ -569,6 +569,23 @@ write_defaults() { printf '%s\n' "$@" > "$CONFIG_DIR/defaults.conf"; }
[[ "$output" == *"nonesuch"* ]]
}
@test "the isolated mode has no config key, in any plausible spelling" {
# --isolated is a flag and nothing else, on purpose: it changes what the
# session IS rather than how much it may do, and a file that decided it
# would make the next launch's answer to "where is my work" depend on a
# line nobody reread. The schema is what holds that - an unknown key is
# fatal, so every spelling someone might reach for has to stay unknown.
local key
for key in isolated isolate ephemeral no_workspace; do
write_config '[core]' "$key = on"
run lib config_validate
[ "$status" -ne 0 ] \
|| { echo "[core] $key was accepted, so the mode is settable from a file"; return 1; }
[[ "$output" == *"unknown key"* ]] || { echo "$output"; return 1; }
done
return 0
}
@test "an invalid value is fatal and names the key" {
write_config '[audit]' 'keep = 0'
run lib config_validate
@@ -791,7 +808,7 @@ write_defaults() { printf '%s\n' "$@" > "$CONFIG_DIR/defaults.conf"; }
_identity_fixture() {
ID_DIR="$(mktemp -d)"
: > "$ID_DIR/default-key"; : > "$ID_DIR/work-key"; : > "$ID_DIR/oss-key"
printf "k\n" | tee "$ID_DIR/default-key" "$ID_DIR/work-key" > "$ID_DIR/oss-key"
: > "$ID_DIR/default-token"; : > "$ID_DIR/work-token"
write_config '[core]' 'forge = gitea' "ssh_key = $ID_DIR/default-key" \
'[forge.gitea]' 'ssh_host = git.example' 'api = https://git.example/api/v1' \
@@ -1009,7 +1026,7 @@ _identity_fixture() {
mkdir -p "$FAKE_HOME/.claude"
echo '{}' > "$FAKE_HOME/.claude/settings.json"
key="$FAKE_HOME/sandbox-key"
: > "$key"
printf "not-a-real-key\n" > "$key"
chmod 600 "$key"
# forge = none, workflow = local-only: provider_validate_selection runs
# ahead of this check, and the default gitea/worktree-pr pair is refused
@@ -1285,7 +1302,7 @@ _identity_fixture() {
mkdir -p "$FAKE_HOME/.claude"
echo '{}' > "$FAKE_HOME/.claude/settings.json"
key="$FAKE_HOME/sandbox-key"
: > "$key"
printf "not-a-real-key\n" > "$key"
chmod 600 "$key"
write_config '[core]' "ssh_key = $key" 'forge = none' 'workflow = local-only'
workspace="$(mktemp -d)"
@@ -1315,7 +1332,7 @@ EOF
mkdir -p "$FAKE_HOME/.claude"
echo '{}' > "$FAKE_HOME/.claude/settings.json"
key="$FAKE_HOME/sandbox-key"
: > "$key"
printf "not-a-real-key\n" > "$key"
chmod 600 "$key"
write_config '[core]' "ssh_key = $key" \
'[forge.gitea]' 'ssh_host = git.example' 'api = https://api.example/v1' \
@@ -1385,12 +1402,14 @@ EOF
@test "the github forge with no push_token_file leaves the read-only GH_TOKEN alone at launch" {
# forge_env_exports must not emit `GH_TOKEN=` (empty) when
# push_token_file is unset: `-e GH_TOKEN=` on `docker
# compose run` overrides docker-compose.yml's own `environment: GH_TOKEN:
# ${GH_TOKEN:-}` entry - the one carrying the independent read-only
# [github] token_file value every session is supposed to get regardless
# of forge. Asserted on the actual argument list handed to `docker`, not
# on a warning, so this catches the container-facing symptom directly.
# push_token_file is unset: `-e GH_TOKEN=` on `docker compose run`
# overrides docker-compose.yml's own `environment: GH_TOKEN:` entry - the
# one carrying the independent read-only [github] token_file value every
# session is supposed to get regardless of forge. (That entry reads
# bin/dejima's SANDBOX_GH_TOKEN, which is the same value except for an
# isolated launch; the override is what matters here either way.)
# Asserted on the actual argument list handed to `docker`, not on a
# warning, so this catches the container-facing symptom directly.
FAKE_HOME="$(mktemp -d)"
mkdir -p "$FAKE_HOME/.claude"
echo '{}' > "$FAKE_HOME/.claude/settings.json"
@@ -1449,7 +1468,7 @@ EOF
# dedicated key file is the headless form of that, and keeps this test
# independent of whether the host running it has an agent.
local key="$FAKE_HOME/sandbox-key"
: > "$key"
printf "not-a-real-key\n" > "$key"
chmod 600 "$key"
write_config '[core]' 'forge = github' 'workflow = worktree-pr' \
"ssh_key = $key" \
@@ -1506,7 +1525,7 @@ EOF
mkdir -p "$FAKE_HOME/.claude"
echo '{}' > "$FAKE_HOME/.claude/settings.json"
local key="$FAKE_HOME/sandbox-key"
: > "$key"
printf "not-a-real-key\n" > "$key"
chmod 600 "$key"
write_config '[core]' 'forge = gitea' 'workflow = branch-push' \
"ssh_key = $key" \
@@ -1530,7 +1549,7 @@ EOF
mkdir -p "$FAKE_HOME/.claude"
echo '{}' > "$FAKE_HOME/.claude/settings.json"
key="$FAKE_HOME/sandbox-key"
: > "$key"
printf "not-a-real-key\n" > "$key"
chmod 600 "$key"
write_config '[core]' "ssh_key = $key" 'forge = none' 'workflow = local-only' \
'[logging]' 'loki_url = https://loki.example.invalid'
@@ -1707,7 +1726,7 @@ _rw_setup() {
mkdir -p "$RW_HOME/.claude"
echo '{}' > "$RW_HOME/.claude/settings.json"
RW_KEY="$RW_HOME/sandbox-key"
: > "$RW_KEY"
printf "not-a-real-key\n" > "$RW_KEY"
chmod 600 "$RW_KEY"
}
@@ -1831,6 +1850,118 @@ _kill_agent() { [ -n "${RW_AGENT_PID:-}" ] && kill "$RW_AGENT_PID" 2>/dev/null;
rm -rf "$workspace"
}
# A launch that really reaches `docker compose`, with a pushing workflow, so the
# rule that blanks the agent socket for a workflow that never pushes is not the
# one doing the work. Its own fixture rather than _rw_setup's: this needs a
# forge configured, and stdin closed because a full launch opens /dev/tty,
# which under bats is not the terminal it expects.
#
# The stub records its own environment rather than its arguments, because the
# volume line resolves ${SSH_AUTH_SOCK:-/dev/null} inside docker-compose - so
# the environment handed to it IS whether the agent is mounted. Into a file,
# because the launch prints plenty of its own and the assertion is about one
# value.
#
# Leaves AGENT_LOG and AGENT_OUT for the caller to read on failure.
_agent_launch() { # <ssh_key or "">
local key="$1" h c s w
# The suite exports DEJIMA_CONFIG_DIR, which is where a launch under it
# reads its config from - a config written under $HOME would not be found.
h="$(mktemp -d)"; c="$CONFIG_DIR"
printf 'tok\n' > "$c/token"
{
printf '[core]\nharness = claude-code\nfirewall = on\nupdate_check = off\n'
printf 'forge = gitea\nworkflow = worktree-pr\n'
printf '[harness.claude-code]\nbackend = anthropic\n'
printf '[forge.gitea]\nssh_host = git.example\napi = https://api.example\n'
# The forge credential moves under the identity when there is one: an
# identity that leaves one behind is a session acting under whichever
# account owns it, which the launch refuses outright.
if [ -n "$key" ]; then
printf '[identity.default]\nssh_key = %s\ntoken_file = %s/token\n' "$key" "$c"
else
printf 'token_file = %s/token\n' "$c"
fi
} > "$c/config"
AGENT_LOG="$(mktemp)"
s="$(mktemp -d)"
cat > "$s/docker" <<'STUB'
#!/bin/sh
printf 'SSH_AUTH_SOCK=[%s]\n' "${SSH_AUTH_SOCK-unset}" >> "$AGENT_SOCK_LOG"
exit 0
STUB
chmod +x "$s/docker"
w="$(mktemp -d)"; git -C "$w" init -q
# `|| true` because a launch here ends however the stubbed session ended,
# and this is not a test about its exit status.
AGENT_OUT="$(HOME="$h" PATH="$s:$PATH" SSH_AUTH_SOCK="$AGENT_SOCK" \
AGENT_SOCK_LOG="$AGENT_LOG" \
bash "$REPO_ROOT/bin/dejima" "$w" </dev/null 2>&1 || true)"
rm -rf "$h" "$s" "$w"
}
# A real agent, because bin/dejima tests the socket with `[ -S ]` and refuses a
# path that is not one.
_real_agent() {
eval "$(ssh-agent -s)" >/dev/null
AGENT_SOCK="$SSH_AUTH_SOCK"
AGENT_PID="$SSH_AGENT_PID"
}
@test "a configured ssh_key means the operator's agent is not in the container at all" {
# entrypoint.sh loads that key into an agent of its own and points
# SSH_AUTH_SOCK at it, so the forwarded one was already unused - but it
# stayed mounted at the path docker-compose.yml names, where
# `SSH_AUTH_SOCK=/ssh-agent ssh ...` signs with the operator's personal
# identities. Choosing a dedicated key has to mean the personal agent is
# not in the container, not that it is merely not the default.
local key
_real_agent
key="$(mktemp)"; printf 'not-a-real-key\n' > "$key"; chmod 600 "$key"
_agent_launch "$key"
kill "$AGENT_PID" 2>/dev/null || true
rm -f "$key"
[ -s "$AGENT_LOG" ] \
|| { echo "the launch never reached docker:"; echo "$AGENT_OUT" | tail -15; return 1; }
[ "$(sort -u "$AGENT_LOG")" = "SSH_AUTH_SOCK=[]" ] \
|| { echo "the forwarded agent still reached compose:"; sort -u "$AGENT_LOG"; return 1; }
}
@test "without a configured ssh_key the forwarded agent still reaches the container" {
# The positive control for the test above, and the reason it means
# anything: without it, a launch that simply never set the variable would
# satisfy that assertion too.
_real_agent
_agent_launch ""
local want="SSH_AUTH_SOCK=[$AGENT_SOCK]"
kill "$AGENT_PID" 2>/dev/null || true
[ -s "$AGENT_LOG" ] \
|| { echo "the launch never reached docker:"; echo "$AGENT_OUT" | tail -15; return 1; }
[ "$(sort -u "$AGENT_LOG")" = "$want" ] \
|| { echo "the forwarded agent did not reach compose:"; sort -u "$AGENT_LOG"; return 1; }
}
@test "an empty ssh_key file is not a key, and a pushing launch says so" {
# It passes the is-a-file check and loads nothing in the container, so it
# cannot be what stops the forwarded agent being mounted. Treated as no key
# at all: harmless for a workflow that never pushes - which is what most of
# this suite's fixtures are, with an empty file standing in for a path -
# and named for one that does.
# A pushing workflow with an empty key and no agent: the one case where it
# costs anything, and the message names the file rather than only saying no
# key is set. The with-an-agent direction is its own test above.
AGENT_SOCK=""
local key; key="$(mktemp)"; chmod 600 "$key"
_agent_launch "$key"
rm -f "$key"
[[ "$AGENT_OUT" == *"names an empty file"* ]] \
|| { echo "an empty key went unmentioned:"; echo "$AGENT_OUT" | tail -12; return 1; }
[ ! -s "$AGENT_LOG" ] \
|| { echo "the launch reached docker with no credential at all"; return 1; }
}
# The positive control for the test above: with no key configured the
# forwarded agent really is the only way to push, and an empty one is worth
# saying so before it fails deep into a session.
@@ -3469,7 +3600,7 @@ EOF
# The unnamed default gets a name, which is what lets it carry a git name
# and a forge credential beside the key rather than those coming from
# three different places.
ID_DIR="$(mktemp -d)"; : > "$ID_DIR/default-key"; : > "$ID_DIR/tok"
ID_DIR="$(mktemp -d)"; printf "k\n" | tee "$ID_DIR/default-key" > "$ID_DIR/tok"
write_config '[core]' 'forge = gitea' \
'[forge.gitea]' 'ssh_host = git.example' 'api = https://git.example/api/v1' \
'[identity.default]' "ssh_key = $ID_DIR/default-key" \
@@ -3484,7 +3615,7 @@ EOF
@test "[core] ssh_key alongside [identity.default] is refused, not ranked" {
# Two spellings of one thing. Which key a session pushed with is not
# something to work out from precedence rules.
ID_DIR="$(mktemp -d)"; : > "$ID_DIR/old-key"; : > "$ID_DIR/new-key"
ID_DIR="$(mktemp -d)"; printf "k\n" | tee "$ID_DIR/old-key" > "$ID_DIR/new-key"
write_config '[core]' 'forge = none' "ssh_key = $ID_DIR/old-key" \
'[identity.default]' "ssh_key = $ID_DIR/new-key"
run "$REPO_ROOT/bin/dejima" --print-config
@@ -3497,7 +3628,7 @@ EOF
@test "[core] ssh_key on its own still works, and says what replaced it" {
# Deprecated, not removed: it is how every config written before
# identities existed says this.
ID_DIR="$(mktemp -d)"; : > "$ID_DIR/old-key"
ID_DIR="$(mktemp -d)"; printf "not-a-real-key\n" > "$ID_DIR/old-key"
write_config '[core]' 'forge = none' "ssh_key = $ID_DIR/old-key"
run "$REPO_ROOT/bin/dejima" --print-config
[ "$status" -eq 0 ] || { echo "a pre-identity config stopped working: $output"; return 1; }
@@ -3508,7 +3639,7 @@ EOF
}
@test "a named identity still beats [identity.default]" {
ID_DIR="$(mktemp -d)"; : > "$ID_DIR/default-key"; : > "$ID_DIR/bot-key"
ID_DIR="$(mktemp -d)"; printf "not-a-real-key\n" > "$ID_DIR/default-key"; printf "not-a-real-key\n" > "$ID_DIR/bot-key"
write_config '[core]' 'forge = none' \
'[identity.default]' "ssh_key = $ID_DIR/default-key" \
'[identity.bot]' "ssh_key = $ID_DIR/bot-key"
@@ -3523,7 +3654,7 @@ EOF
# from the refusal - adding [identity.default] would stop it starting.
# Finding that out on the launch that breaks is worse than being told now,
# so the warning is about the config rather than about this launch.
ID_DIR="$(mktemp -d)"; : > "$ID_DIR/old-key"; : > "$ID_DIR/bot-key"
ID_DIR="$(mktemp -d)"; printf "not-a-real-key\n" > "$ID_DIR/old-key"; printf "not-a-real-key\n" > "$ID_DIR/bot-key"
write_config '[core]' 'forge = none' "ssh_key = $ID_DIR/old-key" \
'identity = bot' '[identity.bot]' "ssh_key = $ID_DIR/bot-key"
run "$REPO_ROOT/bin/dejima" --print-config
@@ -3544,7 +3675,7 @@ EOF
# The other half of a deprecation warning: it has to stop. One that fires
# after the migration is one an operator learns to ignore, which is how
# the next warning gets missed too.
ID_DIR="$(mktemp -d)"; : > "$ID_DIR/key"
ID_DIR="$(mktemp -d)"; printf "not-a-real-key\n" > "$ID_DIR/key"
write_config '[core]' 'forge = none' \
'[identity.default]' "ssh_key = $ID_DIR/key" 'user_name = dejima agent'
run "$REPO_ROOT/bin/dejima" --print-config
@@ -3783,6 +3914,96 @@ EOF
rm -rf "$workspace"
}
@test "the staged ~/.claude.json carries the launching project's entry and no other" {
# The projects map keys per-project state by absolute path, so a verbatim
# copy tells a session the name of every project on the host, a sample of
# each one's filenames (exampleFiles) and what each has cost. Narrowed for
# the reason #104 narrowed the projects MOUNT: the whole map is every
# project's (#380).
_rw_setup
workspace="$(mktemp -d)"
# A worktree under the project, because a session's cwd routinely is one and
# Claude Code keys that path separately - and a sibling whose path merely
# begins with the project's, which is the comparison a bare prefix test
# gets wrong.
#
# The other project's path is deliberately dull. Calling it anything
# credential-shaped - "secret" was the first attempt - makes the staged
# copy trip dejima's own credential scan, so the launch refuses and this
# test passes on a refusal rather than on the narrowing it is named for.
mkdir -p "$workspace/.worktrees/feature"
cat > "$RW_HOME/.claude.json" <<JSON
{"primaryApiKey":"sk-ant-api03-REAL-KEY-FROM-THE-HOST",
"projects":{
"$workspace":{"exampleFiles":["mine.py"],"lastCost":1},
"$workspace/.worktrees/feature":{"exampleFiles":["branch.py"]},
"${workspace}-sibling":{"exampleFiles":["sibling.py"]},
"/home/someone/else/their-project":{"exampleFiles":["theirs.py"],"lastCost":99}},
"numStartups":7}
JSON
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only' \
'harness = claude-code' '[harness.claude-code]' 'backend = anthropic'
local stub log staged
stub="$(mktemp -d)"; log="$(mktemp)"; staged="$(mktemp)"
cat > "$stub/docker" <<EOF
#!/bin/sh
[ -n "\$SANDBOX_CLAUDE_JSON_FILE" ] && cp "\$SANDBOX_CLAUDE_JSON_FILE" "$staged" 2>/dev/null
exit 0
EOF
chmod +x "$stub/docker"
HOME="$RW_HOME" PATH="$stub:$PATH" SSH_AUTH_SOCK= \
run "$REPO_ROOT/bin/dejima" "$workspace"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[ -s "$staged" ] || { echo "no staged ~/.claude.json was mounted"; return 1; }
# The project and anything under it.
[ "$(jq -r --arg p "$workspace" '.projects[$p].lastCost' "$staged")" = 1 ] \
|| { echo "the launching project's own entry is gone"; return 1; }
[ "$(jq -r --arg p "$workspace/.worktrees/feature" '.projects[$p].exampleFiles[0]' "$staged")" = branch.py ] \
|| { echo "a worktree inside the project was dropped"; return 1; }
# And nothing else, including the sibling a bare prefix test would keep.
[ "$(jq -r --arg p "${workspace}-sibling" '.projects | has($p)' "$staged")" = false ] \
|| { echo "a sibling path sharing the project's prefix was kept"; return 1; }
[ "$(jq -r '.projects | has("/home/someone/else/their-project")' "$staged")" = false ] \
|| { echo "another project reached the container"; return 1; }
[ "$(jq -r '.projects | length' "$staged")" = 2 ] \
|| { echo "wrong number of entries: $(jq -c '.projects | keys' "$staged")"; return 1; }
grep -q 'theirs.py' "$staged" \
&& { echo "another project's filenames reached the container"; return 1; }
# The rest of the file is why the copy exists, and the credential still goes.
[ "$(jq -r '.numStartups' "$staged")" = 7 ] || { echo "lost app state"; return 1; }
[ "$(jq -r '.primaryApiKey // "absent"' "$staged")" = absent ] \
|| { echo "the console API key reached the container"; return 1; }
rm -rf "$workspace" "$stub" "$log" "$staged"
}
@test "a projects key that is not a map does not refuse the launch" {
# Narrowing a `projects` that holds a string would error, and the staging
# treats a jq failure as fatal - so an odd but parseable file would become
# a launch that will not start, over a key with nothing in it to narrow.
_rw_setup
workspace="$(mktemp -d)"
printf '{"projects":"not-a-map","numStartups":3}\n' > "$RW_HOME/.claude.json"
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only' \
'harness = claude-code' '[harness.claude-code]' 'backend = anthropic'
local stub staged
stub="$(mktemp -d)"; staged="$(mktemp)"
cat > "$stub/docker" <<EOF
#!/bin/sh
[ -n "\$SANDBOX_CLAUDE_JSON_FILE" ] && cp "\$SANDBOX_CLAUDE_JSON_FILE" "$staged" 2>/dev/null
exit 0
EOF
chmod +x "$stub/docker"
HOME="$RW_HOME" PATH="$stub:$PATH" SSH_AUTH_SOCK= \
run "$REPO_ROOT/bin/dejima" "$workspace"
[ "$status" -eq 0 ] || { echo "an odd projects key refused the launch: $output"; return 1; }
[ "$(jq -r '.numStartups' "$staged")" = 3 ] || { echo "the copy lost its state"; return 1; }
rm -rf "$workspace" "$stub" "$staged"
}
@test "a console API key in ~/.claude.json does not reach the container" {
# Found on a real desktop launch. A Claude Code signed in with a console
# API token keeps the key as a top-level primaryApiKey in ~/.claude.json,
@@ -4109,6 +4330,46 @@ _scan_staged_has_token() {
rm -rf "$workspace" "$SCAN_STUB" "$SCAN_STAGED"
}
@test "a scrub finds the value again whatever the key is spelled like" {
# Deleting by document order made each later index stale: the second secret
# survived and an innocent value went instead. The staged file is mounted
# into the container, so the operator answers "keep it out" and it goes in.
#
# Driven against the program's own functions rather than a reimplementation
# of the ordering, which is the thing under test.
local scan="$REPO_ROOT/bin/dejima-credential-scan"
run python3 - "$scan" <<'PYEOF'
import json, sys
mod = {}
exec(compile(open(sys.argv[1]).read(), "scan", "exec"), mod)
doc = {"mcpServers": {"x": {"args": ["sk-SECRET-ONE-aaaaaaaaaaaaaaaaaaaa",
"sk-SECRET-TWO-bbbbbbbbbbbbbbbbbbbb",
"--harmless-flag"]}}}
def scrub(doc, want):
pairs = list(mod["walk_json"](json.loads(json.dumps(doc))))
drop = [parts for p, v, parts in pairs if p in want]
out = json.loads(json.dumps(doc))
mod["delete_json_components"](out, drop)
return out
print(json.dumps(scrub(doc, {"mcpServers.x.args[0]",
"mcpServers.x.args[1]"})["mcpServers"]["x"]["args"]))
# A key that spells an index, and a key carrying a dot: both used to be
# re-parsed as traversals, deleting a bystander or raising out of the scrub.
print(json.dumps(scrub({"x[0]": "sk-SECRET-key", "x": ["innocent"]}, {"x[0]"})))
print(json.dumps(scrub({"a.b": "sk-SECRET-dotted"}, {"a.b"})))
PYEOF
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[[ "$output" != *SECRET* ]] \
|| { echo "a secret survived the scrub: $output"; return 1; }
[[ "$output" == *harmless-flag* ]] \
|| { echo "the scrub removed a value that was not a secret: $output"; return 1; }
# No line raised: a KeyError here aborts the scrub and bin/dejima then
# copies the file in unscrubbed.
[ "${#lines[@]}" -eq 3 ] \
|| { echo "the scrub did not complete for every shape: $output"; return 1; }
}
@test "a standing scrub written by hand is honoured, and holds no value" {
# Not offered at the prompt, but available to someone who has read what it
# does - and it is the only way a scrubbed field survives an unattended
+9 -3
View File
@@ -21,6 +21,9 @@ import select
import sys
import time
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import loadscale # noqa: E402
# The default is the credential scan's prompt. --marker takes another, so a
# test for a different prompt does not need a second copy of this.
MARKER = "[s/a/o/r]"
@@ -33,9 +36,12 @@ AMBIENT = "Press Enter to continue"
# An IDLE timeout: how long to wait with no output before giving up. Generous
# because the thing it guards against is a command that never finishes, and
# any finite bound catches that - while a tight one just times the machine.
# Sixty seconds was enough on an idle box and not enough under the full gate,
# where every suite runs at once (#295).
TIMEOUT = 240.0
#
# Multiplied by how oversubscribed the run asked the machine to be, rather than
# raised again the next time it is not enough: this one went from sixty seconds
# to this because sixty was ample on an idle box and expired under the full
# gate, which is the shape of guess loadscale exists to stop (#295).
TIMEOUT = loadscale.deadline(240.0)
def main():
+10 -4
View File
@@ -16,8 +16,10 @@ import json
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, urlsplit
STATE = {"oauth_hits": 0, "oauth_last": {}, "returned_401": False, "oauth_reject": False}
STATE = {"oauth_hits": 0, "oauth_last": {}, "returned_401": False, "oauth_reject": False,
"oauth_reject_say": None}
LOCK = threading.Lock()
@@ -50,12 +52,15 @@ class Handler(BaseHTTPRequestHandler):
}).encode())
def _handle(self):
if self.path == "/oauth-reject":
if self.path.startswith("/oauth-reject"):
# Make the next refresh fail the way a finished refresh token does.
# Set rather than always-on, so the ordinary refresh tests keep the
# same endpoint.
# same endpoint. ?say= replaces the error_description, for tests
# about upstream-chosen text landing in the proxy's log.
with LOCK:
STATE["oauth_reject"] = True
STATE["oauth_reject_say"] = \
parse_qs(urlsplit(self.path).query).get("say", [None])[0]
self._send(200, b"ok", "text/plain")
elif self.path == "/v1/oauth/token":
req = json.loads(self.rfile.read(int(self.headers.get("Content-Length", 0))))
@@ -63,11 +68,12 @@ class Handler(BaseHTTPRequestHandler):
STATE["oauth_hits"] += 1
STATE["oauth_last"] = req
reject = STATE.get("oauth_reject")
say = STATE.get("oauth_reject_say")
if reject:
# Verbatim from a real overnight expiry.
self._send(400, json.dumps({
"error": "invalid_grant",
"error_description": "Refresh token expired",
"error_description": say or "Refresh token expired",
}).encode())
return
self._send(200, json.dumps({
+43
View File
@@ -0,0 +1,43 @@
"""How much longer a bounded wait should be given on a loaded machine.
The suites here wait on things that take as long as the machine makes them take
- a pty reaching raw mode, a shipper polling, a prompt being answered - and
each wait has a deadline. A deadline that is right on an idle box is wrong
under the gate, which runs every suite at once, and lengthening it until both
work means guessing a number that is generous in one place and useless in the
other (#295).
So the runner says how much concurrency it asked for, and the deadline is
multiplied by how oversubscribed that makes the machine. Nothing gets shorter
than it is today: the multiplier floors at 1, so an idle run and a run with no
variable set behave exactly as before.
Capped, because this is a multiplier on a timeout: a variable set wrongly, or a
machine reporting one core, would otherwise turn a test that should fail in
seconds into one that hangs for the length of the CI job.
DEJIMA_TEST_CONCURRENCY is how many tests the runner intends to have in flight
at once. test/run-confined.sh sets it to the budget it divided among the
suites; the CI workflow sets it to what it passes bats. Neither is a guess
about load - it is what that runner asked the machine for.
"""
import os
MAX_SCALE = 4.0
def scale():
"""The multiplier, >= 1.0 and <= MAX_SCALE."""
try:
want = int(os.environ.get("DEJIMA_TEST_CONCURRENCY", "0"))
except ValueError:
return 1.0
if want <= 0:
return 1.0
cores = os.cpu_count() or 1
return max(1.0, min(MAX_SCALE, want / float(cores)))
def deadline(seconds):
"""A bounded wait, lengthened for the concurrency this run asked for."""
return seconds * scale()
+11 -3
View File
@@ -25,6 +25,9 @@ import sys
import termios
import time
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from loadscale import deadline # noqa: E402
HELPER = os.path.abspath(sys.argv[1])
SHELL = sys.argv[2] if len(sys.argv) > 2 else "bash"
# Required, not defaulted. The fallback was "/tmp", and the three paths below
@@ -200,7 +203,7 @@ def pump(seconds):
def expect(text, timeout=8.0):
"""Wait for text to appear in output since the last call to forget()."""
end = time.time() + timeout
end = time.time() + deadline(timeout)
while time.time() < end:
if text in "".join(seen):
return True
@@ -238,7 +241,7 @@ def reclaimed():
def await_reclaim(timeout=10.0):
end = time.time() + timeout
end = time.time() + deadline(timeout)
while time.time() < end:
if reclaimed():
return True
@@ -259,8 +262,13 @@ def await_raw(timeout=15.0):
scheduling later. Sampling the flags right after fg was timing the machine,
and on a loaded one it read the shell's own canonical mode and reported it
as a failure to restore - the same shape as the reclaim wait above.
Every bound in this file is multiplied by how oversubscribed the run asked
the machine to be (loadscale), because lengthening them until both an idle
box and the full gate pass means picking a number that is wrong in one of
the two. They are unchanged when nothing says otherwise.
"""
end = time.time() + timeout
end = time.time() + deadline(timeout)
while time.time() < end:
if raw_restored():
return True
+1 -1
View File
@@ -51,7 +51,7 @@ esac
exit 0
EOF
chmod +x "$LAUNCH_DIR/docker"
: > "$LAUNCH_DIR/sandbox-key"
printf "not-a-real-key\n" > "$LAUNCH_DIR/sandbox-key"
chmod 600 "$LAUNCH_DIR/sandbox-key"
PROJ="$(mktemp -d)"
}
+393 -2
View File
@@ -84,10 +84,15 @@ done
exit 0
EOF
chmod +x "$LAUNCH_DIR/docker"
: > "$LAUNCH_DIR/sandbox-key"
printf "not-a-real-key\n" > "$LAUNCH_DIR/sandbox-key"
chmod 600 "$LAUNCH_DIR/sandbox-key"
}
# SSH_AUTH_SOCK is emptied rather than passed through, so a test never reads
# the agent of whoever is running the suite - an inherited one would make the
# forwarded-agent path depend on the machine. LAUNCH_SSH_AUTH_SOCK is how the
# one test about that path states its own precondition instead.
#
# ssh_key is written into whatever config file config_root() currently
# resolves to (DEJIMA_CONFIG_DIR, which a few tests point elsewhere) rather
# than always $CONFIG_DIR/config, and appended rather than written, so it
@@ -110,7 +115,7 @@ launch() {
SANDBOX_TEST_RUN_ENV="$LAUNCH_ENV" \
SANDBOX_TEST_DOCKER_LOG="$LAUNCH_DIR/docker-log" \
SANDBOX_TEST_DOCKERFILE_LOG="$LAUNCH_DIR/dockerfile-log" \
SSH_AUTH_SOCK= ALLOWED_DOMAINS= \
SSH_AUTH_SOCK="${LAUNCH_SSH_AUTH_SOCK-}" ALLOWED_DOMAINS= \
"$REPO_ROOT/bin/dejima" "$@"
}
@@ -1310,6 +1315,392 @@ ENTRYPOINT [\"/usr/local/bin/entrypoint.sh\"]" ]]
done
}
# --isolated: a session with nothing before it and nothing after it. The
# claim is one sentence - nothing of the host crosses in but the operator's
# own read-only config, and nothing survives but ~/shared - and these are the
# parts of it that a stubbed launch can decide.
#
# Driven through the real launcher rather than by grepping it, because every
# one of these is a property of what `compose run` is handed, and a grep over
# the source passes just as well on a line that no longer takes effect.
@test "an isolated launch mounts no host path for its working directory" {
launch_setup
run launch --isolated
[ "$status" -eq 0 ]
# The whole spec, because the compose file cannot build one: a source and
# a target would have to be two variables and neither can be empty. The
# source here names the compose file's own volume, so nothing of the host
# is behind the working directory.
local spec; spec="$(launched_env SANDBOX_WORKSPACE_MOUNT)"
[ "$spec" = "isolated-work:/dejima/work" ] \
|| { echo "the workspace mount is $spec, not the container-local volume"; return 1; }
[ "$(launched_env WORKSPACE_DIR)" = /dejima/work ]
# Whatever the source is, it must not be a path: a source starting with /
# is a bind mount, which is the one thing this mode cannot have here.
case "${spec%%:*}" in
/*) echo "the workspace mount names a host path: $spec"; return 1 ;;
esac
# That the volume it names is this run's own, and is removed afterwards,
# belongs to the test below with the other four.
# And the ordinary case still mounts the project at its own path, so the
# assertion above cannot pass by the variable having stopped working.
run launch "$PROJ"
[ "$status" -eq 0 ]
[ "$(launched_env SANDBOX_WORKSPACE_MOUNT)" = "$PROJ:$PROJ" ]
}
@test "an isolated launch takes nothing from the project it was started in" {
# The one in this file that is about an attack rather than about tidiness.
# resolve_project_store walks UPWARDS from the directory it is given, so
# an operator in $HOME on a machine whose dotfiles were once approved as a
# project would otherwise have handed an anonymous session that project's
# allowed domains and its dependency layer. The flag means the directory
# it was run from decides nothing.
#
# Two independent things in bin/dejima hold that - the working directory
# being container-local, and the approval store not being resolved - and
# this fails only when BOTH are reversed. Worth knowing when reading a
# mutation result: either one alone still passes, which is what defence
# in depth looks like from here rather than a test that misses.
"$REPO_ROOT/bin/dejima-allow" --project "$PROJ" example.invalid >/dev/null
"$REPO_ROOT/bin/dejima-dep" add --project "$PROJ" apt:libpq-dev >/dev/null
launch_setup
local slug; slug="$(bash -c 'source "$1"; project_slug "$2"' _ "$LIB" "$PROJ")"
# First that the fixture works at all: an ordinary launch from in there
# DOES get both. Without this the assertions below pass on a project
# whose approvals were never recorded.
cd "$PROJ"
run launch
cd - >/dev/null
[ "$status" -eq 0 ]
[[ "$(launched_env ALLOWED_DOMAINS)" == *example.invalid* ]] \
|| { echo "the fixture's project domains were never approved"; return 1; }
[ "$(launched_env SANDBOX_IMAGE)" = "localhost/dejima:$(id -un)-$slug" ] \
|| { echo "the fixture's project deps were never approved"; return 1; }
# And then that the isolated launch from the same directory gets neither.
# The log accumulates across launches in one fixture, so the build line
# asserted absent below would otherwise be found from the launch above.
: > "$LAUNCH_DIR/docker-log"
cd "$PROJ"
run launch --isolated
cd - >/dev/null
[ "$status" -eq 0 ]
[[ "$(launched_env ALLOWED_DOMAINS)" != *example.invalid* ]] \
|| { echo "an isolated launch inherited the cwd's approved domains"; return 1; }
[ "$(launched_env SANDBOX_IMAGE)" != "localhost/dejima:$(id -un)-$slug" ] \
|| { echo "an isolated launch inherited the cwd's dependency layer"; return 1; }
grep -q -- "-t localhost/dejima:$(id -un)-$slug -f" <(docker_log) \
&& { echo "an isolated launch built the cwd's dependency layer"; return 1; }
return 0
}
@test "an isolated launch mounts the operator's read-only config and none of the writable state" {
launch_setup
run launch --isolated
[ "$status" -eq 0 ]
local log; log="$(docker_log)"
# The operator's own hooks and skills: theirs, read-only, and not
# something an earlier session could have written.
grep -q -- "-v $LAUNCH_DIR/home/.claude/skills:" <<< "$log" \
|| { echo "the operator's skills are not mounted"; return 1; }
# The shared read-write state every project's sessions append to (#380),
# and the transcripts and memory under projects/.
local d
for d in backups tasks plans sessions projects file-history; do
grep -q -- "-v $LAUNCH_DIR/home/.claude/$d" <<< "$log" \
&& { echo "an isolated launch mounts ~/.claude/$d"; return 1; }
done
# Not mounted and also not CREATED: the pre-create loop exists so Docker
# does not make a root-owned mount source, and a directory nothing mounts
# needs no source.
for d in backups tasks plans sessions; do
[ ! -d "$LAUNCH_DIR/home/.claude/$d" ] \
|| { echo "an isolated launch created ~/.claude/$d on the host"; return 1; }
done
return 0
}
@test "an isolated launch gets a fresh share directory that no other session has had" {
local share; share="$(mktemp -d)"
launch_setup
printf '[core]\nshare_dir = %s\n' "$share" \
>> "$(bash -c 'source "$1"; config_path' _ "$LIB")"
run launch --isolated
[ "$status" -eq 0 ]
local first; first="$(launched_env SANDBOX_SHARE_DIR)"
run launch --isolated
[ "$status" -eq 0 ]
local second; second="$(launched_env SANDBOX_SHARE_DIR)"
[ -n "$first" ] && [ -n "$second" ] || { echo "no share directory was set"; return 1; }
[ "$first" != "$second" ] \
|| { echo "two isolated launches were handed the same directory: $first"; return 1; }
case "$first" in
"$share"/isolated/*) ;;
*) echo "the isolated share is not under the isolated root: $first"; return 1 ;;
esac
[ -d "$first" ] && [ -d "$second" ] || { echo "the share directories were not made"; return 1; }
# Made, and nothing else: the one host directory an isolated session
# writes starts empty, which is the whole of "nothing crosses in".
[ -z "$(ls -A "$first")" ] || { echo "the share directory is not empty"; return 1; }
rm -rf "$share"
}
@test "an isolated launch names its volumes for the run and removes them" {
launch_setup
run launch --isolated
[ "$status" -eq 0 ]
# Two are sharper than the rest. The shell history is shared by every
# project even when the nvim ones are not, so under its usual name an
# isolated session reads what was typed in any session before it; and the
# working directory is where everything the session did actually lives.
local v names=""
for v in SANDBOX_NVIM_DATA SANDBOX_NVIM_STATE SANDBOX_NVIM_CACHE SANDBOX_BASH_HISTORY SANDBOX_ISOLATED_WORK; do
names="$names $(launched_env "$v")"
[[ "$(launched_env "$v")" == dejima_isolated-* ]] \
|| { echo "$v is not this run's own: $(launched_env "$v")"; return 1; }
[[ "$(launched_env "$v")" =~ ^[a-zA-Z0-9][a-zA-Z0-9_.-]*$ ]] \
|| { echo "$v is not a valid Docker volume name"; return 1; }
done
grep -q "volume rm$names" <(docker_log) \
|| { echo "the run's volumes are not removed:$names"; cat "$LAUNCH_DIR/docker-log"; return 1; }
local before="$names"
run launch --isolated
[ "$status" -eq 0 ]
names=""
for v in SANDBOX_NVIM_DATA SANDBOX_NVIM_STATE SANDBOX_NVIM_CACHE SANDBOX_BASH_HISTORY SANDBOX_ISOLATED_WORK; do
names="$names $(launched_env "$v")"
done
[ "$before" != "$names" ] \
|| { echo "two isolated launches asked for the same volumes:$names"; return 1; }
# And an ordinary launch still gets the shared history volume, which is
# what makes a session started tomorrow see what was typed today.
#
# Asserted as "sets nothing", not as a name. What the volume is called
# then comes from the compose default, and whether THAT is the name Docker
# would have chosen on its own is a property of the compose file - checked
# there, in static.bats, against the project name rather than against a
# string repeated here. A name in this test would have been updated to
# match a wrong default rather than catching one.
run launch "$PROJ"
[ "$status" -eq 0 ]
[ -z "$(launched_env SANDBOX_BASH_HISTORY)" ] \
|| { echo "an ordinary launch renamed the history volume to $(launched_env SANDBOX_BASH_HISTORY)"
return 1; }
}
@test "an isolated launch reaches no forge unless the same command names one" {
# The forge credential is the widest thing a session holds - a Gitea token
# is account-wide - so the mode whose purpose is a session nobody is
# watching does not carry one by default. It narrows, like every other
# flag here.
#
# Both orders, because the defaults are applied after the argument loop
# rather than inside it: a version that set them in the loop would pass
# one of these two and fail the other.
launch_setup
local conf
conf="$(bash -c 'source "$1"; config_path' _ "$LIB")"
# With the section header: launch() appends its own [core] block after
# this, so a bare key here would sit outside any section and be refused.
printf '[core]\nforge = gitea\nworkflow = worktree-pr\n' >> "$conf"
run launch --isolated --print-config
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
grep -qx 'core.forge=none' <<< "$output" \
|| { echo "an isolated launch kept the configured forge"; return 1; }
grep -qx 'core.workflow=local-only' <<< "$output" \
|| { echo "an isolated launch kept a workflow that pushes"; return 1; }
local args
for args in "--isolated --workflow worktree-pr" "--workflow worktree-pr --isolated"; do
# shellcheck disable=SC2086
run launch $args --print-config
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
grep -qx 'core.workflow=worktree-pr' <<< "$output" \
|| { echo "'$args' did not keep the workflow the operator named"; return 1; }
done
# And the config file is untouched for a launch without the flag, so the
# assertions above are about --isolated rather than about the fixture.
run launch --print-config
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
grep -qx 'core.forge=gitea' <<< "$output" \
|| { echo "an ordinary launch lost the configured forge"; return 1; }
return 0
}
@test "an isolated launch mounts the configured ssh key and not the forwarded agent" {
# Opposite answers for the two, which is the whole reason this is one
# test rather than a line in another: a workflow that never pushes gets
# no agent socket, and --isolated defaults to one of those, while a
# configured key is exported whatever the workflow. Both docs state the
# pair, and a test that checked only the key - as this one first did -
# cannot catch the half that was written backwards.
#
# Withholding the key as well, for any launch that never pushes, is #392.
launch_setup
run launch --isolated
[ "$status" -eq 0 ]
[ "$(launched_env SANDBOX_SSH_KEY_FILE)" = "$LAUNCH_DIR/sandbox-key" ] \
|| { echo "the configured key did not cross: $(launched_env SANDBOX_SSH_KEY_FILE)"
echo "- if that is deliberate, the two docs saying it does need updating"
return 1; }
# Now the agent, which needs its own fixture for two reasons. The helper
# empties SSH_AUTH_SOCK so no test reads the suite runner's own agent, so
# a launch that never sets it could not have forwarded one whatever the
# code did - asserting its absence there proves nothing. And a configured
# key withdraws the agent too, independently of the workflow, so the key
# above has to go for this to be about the rule the docs name. An EMPTY
# key file is "not a key" by design, which is how that is arranged.
: > "$LAUNCH_DIR/sandbox-key"
# A real socket, because the launcher tests for one with -S. Binding
# creates the filesystem entry and leaves it there after the process
# exits, which is all this needs.
python3 -c 'import socket,sys
s = socket.socket(socket.AF_UNIX)
s.bind(sys.argv[1])' "$LAUNCH_DIR/agent.sock"
[ -S "$LAUNCH_DIR/agent.sock" ] || { echo "the fixture made no socket"; return 1; }
LAUNCH_SSH_AUTH_SOCK="$LAUNCH_DIR/agent.sock" run launch --isolated
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[ -z "$(launched_env SSH_AUTH_SOCK)" ] \
|| { echo "a workflow that pushes nothing still forwarded the agent"; return 1; }
# And the positive control. A pushing workflow needs a forge with a host
# to push to, so this names both - which is also the combination an
# operator asking for an isolated session that pushes would type.
printf 'ghp_not_a_real_push_token\n' > "$LAUNCH_DIR/gh-push-token"
chmod 600 "$LAUNCH_DIR/gh-push-token"
printf '[forge.github]\npush_token_file = %s\n' "$LAUNCH_DIR/gh-push-token" \
>> "$(bash -c 'source "$1"; config_path' _ "$LIB")"
LAUNCH_SSH_AUTH_SOCK="$LAUNCH_DIR/agent.sock" \
run launch --isolated --forge github --workflow branch-push
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[ "$(launched_env SSH_AUTH_SOCK)" = "$LAUNCH_DIR/agent.sock" ] \
|| { echo "naming a pushing workflow did not forward the agent"; return 1; }
}
@test "an isolated launch gets no GitHub token unless the command names a forge" {
# [github] token_file is not a forge setting, which is what makes it easy
# to miss: core fills it for every session whatever forge is selected. A
# read-only PAT is still read-only across the operator's private
# repositories, so the mode that carries no forge credential cannot carry
# this one either.
#
# The filter container keeps it, because that is what authenticates
# dejima's own fetch of GitHub's address ranges - a request about the
# allowlist, made where no session can reach it. So the two are separate
# variables now, and this asserts both halves: the session's is empty and
# the launcher's, which is what `-e GH_TOKEN` hands the filter, is not.
launch_setup
printf 'ghp_not_a_real_token\n' > "$LAUNCH_DIR/gh-token"
chmod 600 "$LAUNCH_DIR/gh-token"
printf '[github]\ntoken_file = %s\n' "$LAUNCH_DIR/gh-token" \
>> "$(bash -c 'source "$1"; config_path' _ "$LIB")"
run launch --isolated
[ "$status" -eq 0 ]
[ -z "$(launched_env SANDBOX_GH_TOKEN)" ] \
|| { echo "an isolated session was handed core's GitHub token"; return 1; }
[ -n "$(launched_env GH_TOKEN)" ] \
|| { echo "the filter container lost the token that authenticates its ranges fetch"
return 1; }
# Naming a forge is the operator saying this session is meant to reach
# one, and nothing is narrowed from there.
run launch --isolated --forge gitea
[ "$status" -eq 0 ]
[ -n "$(launched_env SANDBOX_GH_TOKEN)" ] \
|| { echo "a named forge did not restore the GitHub token"; return 1; }
# And an ordinary launch is untouched, including one whose config selects
# no forge - this narrows for the flag, not for every forge-less launch.
run launch --forge none --workflow local-only "$PROJ"
[ "$status" -eq 0 ]
[ -n "$(launched_env SANDBOX_GH_TOKEN)" ] \
|| { echo "an ordinary launch lost core's GitHub token"; return 1; }
}
@test "an isolated launch refuses a project path and the mount flags" {
launch_setup
# Each refusal names what it refused. A bare nonzero status would pass on
# a launch that failed for an unrelated reason.
run launch --isolated "$PROJ"
[ "$status" -ne 0 ] || { echo "a project path was accepted"; return 1; }
[[ "$output" == *"takes no project directory"* ]] \
|| { echo "the refusal does not say why: $output"; return 1; }
local flag
for flag in --ro --rw; do
run launch --isolated "$flag" /tmp
[ "$status" -ne 0 ] || { echo "$flag was accepted"; return 1; }
[[ "$output" == *"cannot take --ro or --rw"* ]] \
|| { echo "the $flag refusal does not say why: $output"; return 1; }
[[ "$output" == *"/tmp"* ]] \
|| { echo "the $flag refusal does not name the path: $output"; return 1; }
done
# Harness arguments after -- are not a project path, and refusing them
# would make the flag unusable with every launch that passes any.
run launch --isolated -- --version
[ "$status" -eq 0 ] \
|| { echo "harness arguments were read as a project path: $output"; return 1; }
}
@test "an isolated launch says what it is and what it does without" {
launch_setup
run launch --isolated
[ "$status" -eq 0 ]
# Printed whatever [ui] banner says and with stderr redirected, unlike
# the banner: the share path is the only route back to what the session
# hands over, and the two features it does without are ones an operator
# would otherwise go looking for afterwards and find empty.
[[ "$output" == *"no project mounted, no memory, no transcript"* ]] \
|| { echo "the launch does not say what this mode is: $output"; return 1; }
[[ "$output" == *"conversation log and epitaph off"* ]] \
|| { echo "the launch does not say what is off: $output"; return 1; }
[[ "$output" == *"$(launched_env SANDBOX_SHARE_DIR)"* ]] \
|| { echo "the launch does not name the share directory: $output"; return 1; }
}
@test "an isolated launch does not apply the configured mounts, and says so" {
local extra; extra="$(mktemp -d)"
launch_setup
printf '[mounts]\nreadonly = %s\n' "$extra" \
>> "$(bash -c 'source "$1"; config_path' _ "$LIB")"
run launch --isolated
[ "$status" -eq 0 ]
grep -q -- "-v $extra" <(docker_log) \
&& { echo "an isolated launch applied a configured mount"; return 1; }
[[ "$output" == *"[mounts] readonly not applied"* ]] \
|| { echo "the launch does not say the mount was dropped: $output"; return 1; }
# Dropped for an isolated launch only - the same config still mounts it
# for an ordinary one, so the assertion above is about the flag rather
# than about the list having stopped working.
run launch "$PROJ"
[ "$status" -eq 0 ]
grep -q -- "-v $extra" <(docker_log) \
|| { echo "an ordinary launch stopped applying [mounts] readonly"; return 1; }
rm -rf "$extra"
}
@test "the launcher builds and runs the global layer when only global deps are approved" {
"$REPO_ROOT/bin/dejima-dep" add --global apt:libpq-dev >/dev/null
launch_setup
+27
View File
@@ -215,6 +215,33 @@ assert_remedy_forges_exist() {
[ "$status" -ne 0 ]
}
@test "the briefing tells an agent how a refusal differs from a hang" {
# The policy rejects rather than drops, for the reason its own chain
# comment gives: silently dropping makes every blocked destination look
# like a hang. The briefing described both as failing to connect, which
# throws that signal away - and two agents in one day reported a host
# firewall on the far side as dejima blocking a domain, because from
# inside a sandbox the two are indistinguishable except by timing.
local lib="$REPO_ROOT/bin/dejima-provider-lib.sh"
grep -q 'refused at once rather than' "$lib" \
|| { echo "the briefing no longer says a refusal is immediate"; return 1; }
grep -q 'HANGS is not this allowlist' "$lib" \
|| { echo "the briefing no longer tells an agent what a hang means"; return 1; }
# And the property it describes is still true of the policy, so the advice
# cannot outlive the behaviour it rests on.
local chain
chain=$(sed -n '/^ chain refuse {/,/^ }/p' "$REPO_ROOT/docker/egress-policy")
[ -n "$chain" ] || { echo "no refuse chain in the policy"; return 1; }
grep -q 'reject with tcp reset' <<< "$chain" \
|| { echo "the policy no longer rejects TCP, so a block would hang"; return 1; }
grep -q 'reject with icmpx' <<< "$chain" \
|| { echo "the policy no longer rejects non-TCP"; return 1; }
grep -qE '(^|[^a-z])drop([^a-z]|$)' <<< "$chain" \
&& { echo "the refuse chain drops as well as rejects: $chain"; return 1; }
true
}
@test "gitea's CLIs moved out of bin/ and into its forge" {
[ ! -e "$REPO_ROOT/bin/gitea-pr" ]
[ ! -e "$REPO_ROOT/bin/gitea-issue" ]
+176 -11
View File
@@ -292,17 +292,18 @@ teardown() {
@test "forwarder bridges TCP to the proxy's unix socket" {
start_proxy
SANDBOX_CREDENTIAL_PROXY_SOCKET="$SOCK" SANDBOX_CREDENTIAL_PROXY_PORT=18399 \
local fport; fport="$(free_port)"
SANDBOX_CREDENTIAL_PROXY_SOCKET="$SOCK" SANDBOX_CREDENTIAL_PROXY_PORT="$fport" \
SANDBOX_CREDENTIAL_TRANSPORT=loopback \
node "$REPO_ROOT/docker/credential-proxy-forwarder.js" &
FWD_PID=$!
ok=""
for _ in $(seq 1 50); do
if curl -s -o /dev/null http://127.0.0.1:18399/echo; then ok=1; break; fi
if curl -s -o /dev/null http://127.0.0.1:$fport/echo; then ok=1; break; fi
sleep 0.1
done
[ -n "$ok" ]
run curl -s -H "Authorization: Bearer $PLACEHOLDER" -d '{}' http://127.0.0.1:18399/v1/messages
run curl -s -H "Authorization: Bearer $PLACEHOLDER" -d '{}' http://127.0.0.1:$fport/v1/messages
kill "$FWD_PID" 2>/dev/null || true
[ "$(jq -r '.headers.authorization' <<<"$output")" = "Bearer real-access-token" ]
}
@@ -353,6 +354,20 @@ PYEOF
[[ "$output" != *"501"* ]]
}
# The forwarder is given a port rather than asking for one, so these tests
# have to name a number. Asking the kernel for a free one and handing that over
# leaves a window where something else could take it; naming a constant leaves
# the test failing on any machine already using it, which is the precondition
# these tests were reading from the environment instead of stating. The window
# is the smaller of the two, and the suites run on a developer's machine.
free_port() {
python3 -c 'import socket
s = socket.socket()
s.bind(("127.0.0.1", 0))
print(s.getsockname()[1])
s.close()'
}
# The forwarder is the container-side half of the chain. In tls mode it
# terminates TLS for api.anthropic.com on loopback so Claude Code needs no
# ANTHROPIC_BASE_URL at all, then pipes raw bytes into the same unix socket
@@ -382,13 +397,14 @@ make_cert() {
@test "forwarder tls mode: https to api.anthropic.com reaches the proxy, which injects the real token" {
make_cert
start_proxy
start_forwarder tls 18443
local fport; fport="$(free_port)"
start_forwarder tls "$fport"
# --resolve is what stands in for the container's /etc/hosts entry: the
# name must really be api.anthropic.com for certificate verification to
# be a meaningful check, not a hostname the test picked to be easy.
run curl -s --cacert "$TD/cert.pem" \
--resolve "api.anthropic.com:18443:127.0.0.1" \
-X POST "https://api.anthropic.com:18443/v1/messages" -d '{}'
--resolve "api.anthropic.com:$fport:127.0.0.1" \
-X POST "https://api.anthropic.com:$fport/v1/messages" -d '{}'
kill "$FWD_PID" 2>/dev/null || true
[ "$status" -eq 0 ]
[[ "$output" == *'"authorization": "Bearer real-access-token"'* ]]
@@ -403,8 +419,9 @@ make_cert() {
# no HTTP awareness to catch it.
make_cert
start_proxy
start_forwarder tls 18444
run openssl s_client -connect 127.0.0.1:18444 -alpn h2,http/1.1 \
local fport; fport="$(free_port)"
start_forwarder tls "$fport"
run openssl s_client -connect "127.0.0.1:$fport" -alpn h2,http/1.1 \
-servername api.anthropic.com -CAfile "$TD/cert.pem" </dev/null
kill "$FWD_PID" 2>/dev/null || true
[[ "$output" == *"ALPN protocol: http/1.1"* ]]
@@ -412,8 +429,9 @@ make_cert() {
@test "forwarder loopback mode serves plaintext http on the port it is given" {
start_proxy
start_forwarder loopback 18445
run curl -s -X POST "http://127.0.0.1:18445/v1/messages" -d '{}'
local fport; fport="$(free_port)"
start_forwarder loopback "$fport"
run curl -s -X POST "http://127.0.0.1:$fport/v1/messages" -d '{}'
kill "$FWD_PID" 2>/dev/null || true
[ "$status" -eq 0 ]
[[ "$output" == *'"authorization": "Bearer real-access-token"'* ]]
@@ -437,7 +455,7 @@ make_cert() {
# today, so a fall-through lands on an unused port if that ever changes.
run env -u SANDBOX_CREDENTIAL_TLS_CERT -u SANDBOX_CREDENTIAL_TLS_KEY \
SANDBOX_CREDENTIAL_PROXY_SOCKET="$SOCK" SANDBOX_CREDENTIAL_TRANSPORT=tls \
SANDBOX_CREDENTIAL_PROXY_PORT=18398 \
SANDBOX_CREDENTIAL_PROXY_PORT="$(free_port)" \
node "$REPO_ROOT/docker/credential-proxy-forwarder.js"
[ "$status" -ne 0 ]
[[ "$output" == *"SANDBOX_CREDENTIAL_TLS_CERT"* ]]
@@ -861,3 +879,150 @@ print(raw.decode().strip())
[[ "$output" != *"HTTP 400"* ]] \
|| { echo "the last host line still leads with the provider's answer: $output"; return 1; }
}
# The exit audit in bin/dejima reads this log. Extracted and driven here rather
# than restated, and fed logs this proxy actually wrote, because the two live in
# different files and the classification only works if they agree about what a
# line looks like.
run_exit_audit() { # <proxy log>
C_WARN='' C_RESET='' SANDBOX_CREDENTIAL_PROXY_LOG="$1" bash -c '
C_WARN=""; C_RESET=""
SANDBOX_CREDENTIAL_PROXY_LOG="$1"
'"$(sed -n '/_audit_counts="\$(awk/,/unset _audit_counts _audit_last/p' \
"$REPO_ROOT/bin/dejima")"'
' _ "$1" 2>&1
}
@test "the exit audit does not report a credential failure as the API answering" {
# An overnight expiry logs no upstream status, because no request was made -
# the credential could not be loaded. Announcing "the model API answered
# with an error" there sends an operator to check the provider, and the
# retry caveat invites them to hope it succeeded anyway. Neither is true,
# and the line the proxy leaves last is an instruction rather than a status.
write_creds -60
start_proxy
curl -s "http://127.0.0.1:$PORT/oauth-reject" >/dev/null
pcurl -o /dev/null -X POST --data '{}' http://x/v1/messages || true
run run_exit_audit "$TD/stderr"
[[ "$output" != *"model API answered with an error"* ]] \
|| { echo "reported as the API answering: $output"; return 1; }
[[ "$output" != *"may still have succeeded"* ]] \
|| { echo "invited a retry that cannot succeed: $output"; return 1; }
[[ "$output" == *"could not authenticate"* ]] \
|| { echo "did not say what happened: $output"; return 1; }
[[ "$output" == *"log in again"* ]] \
|| { echo "did not carry the remedy: $output"; return 1; }
}
@test "an upstream that answered with errors is still reported as one" {
# The control: the audit's original purpose has to survive the split.
start_proxy
pcurl -o /dev/null "http://localhost/status-429"
pcurl -o /dev/null "http://localhost/status-429"
run run_exit_audit "$TD/stderr"
[[ "$output" == *"model API answered with an error"* ]] \
|| { echo "an upstream error stopped being reported as one: $output"; return 1; }
[[ "$output" == *"2 x HTTP 429"* ]] \
|| { echo "the grouping by status is gone: $output"; return 1; }
[[ "$output" == *"may still have succeeded"* ]] \
|| { echo "the retry caveat is gone from the case it is about: $output"; return 1; }
}
@test "a request that never reached the upstream is reported as neither case" {
# `upstream request failed` carries no status - nothing answered - and no
# credential failed either. Classified by "no status lines anywhere", it
# read as an authentication failure, sending an operator to log in again
# over a connection problem.
start_proxy --upstream "http://127.0.0.1:1"
pcurl -o /dev/null -X POST --data '{}' http://x/v1/messages || true
run run_exit_audit "$TD/stderr"
[[ "$output" != *"could not authenticate"* ]] \
|| { echo "a connection failure read as an authentication failure: $output"; return 1; }
[[ "$output" != *"model API answered with an error"* ]] \
|| { echo "reported as the API answering: $output"; return 1; }
[[ "$output" == *"credential proxy reported a problem"* ]] \
|| { echo "the failure went unreported: $output"; return 1; }
[[ "$output" == *"upstream request failed"* ]] \
|| { echo "the line itself is missing: $output"; return 1; }
}
@test "upstream text saying 'answered 503' cannot fabricate a status count" {
# The refresh failure's detail line carries up to 200 bytes of the token
# endpoint's response body - text the upstream chose. Counted wherever
# `answered N` appeared, that body minted "1 x HTTP 503" for a session
# that made no request, and brought the retry caveat with it. The count
# is anchored to the proxy's own `upstream answered N for` lines.
write_creds -60
start_proxy
curl -s "http://127.0.0.1:$PORT/oauth-reject?say=the+upstream+answered+503+today" >/dev/null
pcurl -o /dev/null -X POST --data '{}' http://x/v1/messages || true
run run_exit_audit "$TD/stderr"
[[ "$output" != *"model API answered with an error"* ]] \
|| { echo "an upstream string was counted as an API answer: $output"; return 1; }
[[ "$output" != *"HTTP 503"* ]] \
|| { echo "a status count was minted from body text: $output"; return 1; }
[[ "$output" == *"could not authenticate"* ]] \
|| { echo "the real failure went unreported: $output"; return 1; }
[[ "$output" == *"answered 503 today"* ]] \
|| { echo "the detail line is not shown: $output"; return 1; }
}
@test "a negative Content-Length is refused rather than read to EOF" {
# The cap exists because this runs on the host as the operator's own user.
# int() accepts a negative, a negative passes a `> MAX` test, and a buffered
# reader takes read(-1) as "to EOF" - so `Content-Length: -1` read an
# unbounded body into host RAM past the cap. A non-numeric value raised out
# past the types handle_error knows and put a traceback on the terminal.
start_proxy
run curl -s -o /dev/null -w '%{http_code}' --unix-socket "$SOCK" \
-H "Authorization: Bearer $PLACEHOLDER" -H 'Content-Length: -1' \
-X POST --data 'x' http://x/v1/messages
[ "$output" = 413 ] \
|| { echo "a negative Content-Length was not refused: $output"; return 1; }
# A value that is not a number is left alone: it still raises, because the
# test above for narrow error suppression uses exactly that fault to prove
# the suppression is specific to a client going away. This fix is about the
# value that passes the cap and then reads to EOF, not about input tidiness.
# The log says what arrived, which is what tells a hostile negative length
# apart from a genuinely oversized upload.
grep -q 'Content-Length of -1' "$TD/stderr" \
|| { echo "the refusal log does not name the offending length:"; cat "$TD/stderr"; return 1; }
# And an ordinary request still works, so the check did not close the door.
run pcurl -o /dev/null -w '%{http_code}' -X POST --data '{}' http://x/v1/messages
[ "$output" = 200 ] || { echo "a normal request was refused: $output"; return 1; }
}
@test "a negative chunk size is refused too, not only Content-Length" {
# The two paths share one helper today. A refactor that inlines the chunked
# branch - int(readline().split(b";")[0], 16) - puts the unbounded read back
# with nothing failing, so the chunked shape is asserted in its own right.
start_proxy
python3 - "$SOCK" <<'PYEOF'
import socket, sys
s = socket.socket(socket.AF_UNIX)
s.connect(sys.argv[1])
s.sendall(b"POST /v1/messages HTTP/1.1\r\nHost: proxy\r\n"
b"Authorization: Bearer placeholder\r\n"
b"Transfer-Encoding: chunked\r\n\r\n-1\r\n")
s.settimeout(5)
try:
got = s.recv(200)
except Exception:
got = b"<no answer>"
print(got.split(b"\r\n")[0].decode("latin-1"))
s.close()
PYEOF
run cat "$TD/stderr"
[[ "$output" == *"chunk size of -1"* ]] \
|| { echo "a negative chunk size was not refused as one: $output"; return 1; }
[[ "$output" != *"Traceback"* ]] \
|| { echo "it raised instead of being refused: $output"; return 1; }
}
+25 -7
View File
@@ -143,6 +143,22 @@ touches '^tools/' &&
touches '^\.dejima/allowed-domains$' &&
manual "A domain here is read from main by the CI hardener and widens every
future CI job on that runner. Say in the PR what it is for."
# The boundary itself had no pattern at all, so a release that rewrote the
# egress policy, the capability floor or the credential path was reported as
# touching nothing that needs a manual check - over exactly the things this
# project exists to hold.
touches '^(docker/egress-policy|docker/firewall-up|docker/fixed-domains|docker/dns-lookup-log|docker/cidr-collapse|bin/dejima-allow)' &&
manual "Reach an allowed destination and a refused one from inside a real
session, and check a refusal is immediate rather than a hang. The suites
drive the policy; only a session drives it with a real resolver."
touches '^(docker/caps-lib|docker/docker-compose)' &&
manual "Check the capability set of a running session against the floor the
compose file declares. A container that starts says nothing about what
it kept."
touches '^(bin/dejima-credential-proxy|docker/credential-proxy-forwarder|backends/|bin/dejima-credential-scan)' &&
manual "Run a session against a real model through the proxy, and let a
credential expire or be refused. The failure paths are what the suites
reach least."
[ "$printed" = 1 ] || echo " (none - this release touches nothing with a manual check)"
note "Required before this merges"
@@ -158,9 +174,11 @@ echo " launch on the other configured harness to confirm it starts."
# turns the obvious rollback into a second outage, so it is worth knowing
# before rather than after.
note "Config keys this release adds (a rollback refuses a config using them)"
# Both sides through git, because this reports on $base...$head and the working
# tree is neither of them once a head is named explicitly.
added="$(comm -13 \
<(git show "$base:bin/dejima-config-lib.sh" 2>/dev/null | grep -oE "^[a-z-]+\\.[a-z_]+" | sort -u) \
<(grep -oE "^[a-z-]+\\.[a-z_]+" bin/dejima-config-lib.sh | sort -u))"
<(git show "$head:bin/dejima-config-lib.sh" 2>/dev/null | grep -oE "^[a-z-]+\\.[a-z_]+" | sort -u))"
# Providers too, and this is the sharper half: a [harness.<name>] section for
# a harness the older dejima does not ship is an unknown SECTION, which fails
# validation exactly as an unknown key does. So adopting a new provider is
@@ -193,12 +211,12 @@ new_providers=""
for pair in $DEJIMA_RELEASE_AXES; do
axis="${pair%%:*}"
one="${pair#*:}"
[ -d "$axis" ] || continue
git cat-file -e "$head:$axis" 2>/dev/null || continue
while IFS= read -r name; do
[ -n "$name" ] || continue
git cat-file -e "$base:$axis/$name/$one.conf" 2>/dev/null ||
new_providers="$new_providers $one.$name"
done < <(find "$axis" -mindepth 1 -maxdepth 1 -type d -printf '%f\n' | sort)
done < <(git ls-tree -d --name-only "$head:$axis" 2>/dev/null | sort)
done
# And the keys a provider DECLARES, which are config keys an operator writes
@@ -210,18 +228,18 @@ new_keys=""
for pair in $DEJIMA_RELEASE_AXES; do
axis="${pair%%:*}"
one="${pair#*:}"
[ -d "$axis" ] || continue
git cat-file -e "$head:$axis" 2>/dev/null || continue
while IFS= read -r name; do
[ -n "$name" ] || continue
now="$(sed -n 's/^keys *= *//p' "$axis/$name/$one.conf" 2>/dev/null \
| tr ' ' '\n' | sed 's/:.*//' | sort -u)"
now="$(git show "$head:$axis/$name/$one.conf" 2>/dev/null \
| sed -n 's/^keys *= *//p' | tr ' ' '\n' | sed 's/:.*//' | sort -u)"
was="$(git show "$base:$axis/$name/$one.conf" 2>/dev/null \
| sed -n 's/^keys *= *//p' | tr ' ' '\n' | sed 's/:.*//' | sort -u)"
while IFS= read -r k; do
[ -n "$k" ] || continue
new_keys="$new_keys [$one.$name]|$k"
done < <(comm -13 <(printf '%s\n' "$was") <(printf '%s\n' "$now"))
done < <(find "$axis" -mindepth 1 -maxdepth 1 -type d -printf '%f\n' | sort)
done < <(git ls-tree -d --name-only "$head:$axis" 2>/dev/null | sort)
done
if [ -n "$added" ] || [ -n "$new_providers" ] || [ -n "$new_keys" ]; then
+81 -7
View File
@@ -33,15 +33,59 @@ setup() {
export XDG_RUNTIME_DIR="$RUNTIME_DIR"
}
teardown() {
# An attach client this test backgrounded. `kill` only sends the signal, and
# the client - plus the script(1) and tmux processes under it - takes a
# moment to go. Removing its directories in that window is what produced
# "rm: cannot remove ...: Directory not empty": rm lists a directory,
# deletes what it saw, and the still-dying client creates something else
# before the rmdir. So the wait is for the process to be gone, not a sleep.
if [ -n "${attach_pid:-}" ]; then
kill "$attach_pid" 2>/dev/null || true
_await_gone kill -0 "$attach_pid"
fi
# Only ever the private server start_tmux created (its own TMUX_TMPDIR),
# never the operator's.
if [ -n "${TMUX_DIR:-}" ]; then
TMUX_TMPDIR="$TMUX_DIR" tmux kill-server 2>/dev/null || true
# kill-server returns once the server has been told, and the server
# unlinks its socket on its own schedule.
_await_gone env TMUX_TMPDIR="$TMUX_DIR" tmux has-session
fi
rm -rf "$CONFIG_DIR" "$RUNTIME_DIR" ${STUB_DIR:+"$STUB_DIR"} ${LAUNCH_DIR:+"$LAUNCH_DIR"} \
_rm_settled "$CONFIG_DIR" "$RUNTIME_DIR" ${STUB_DIR:+"$STUB_DIR"} ${LAUNCH_DIR:+"$LAUNCH_DIR"} \
${TMUX_DIR:+"$TMUX_DIR"} ${FAKE_HOME:+"$FAKE_HOME"}
}
# Waits until the given command FAILS, which is how each of the things above
# reports that it is gone. Bounded, and silent on expiry: teardown's job is to
# clean up, and a cleanup that fails a passing test is the bug being fixed.
_await_gone() {
local i
for i in $(seq 1 50); do
"$@" >/dev/null 2>&1 || return 0
sleep 0.1
done
return 1
}
# rm -rf, retried. The waits above remove the cause; this is the backstop for
# anything else that writes into a temp directory as it exits, and it reports
# what is left rather than failing the test - a leaked temp directory costs
# disk, and a red check on a passing test costs a re-run and some trust in the
# suite.
_rm_settled() {
local d i
for d in "$@"; do
[ -n "$d" ] || continue
for i in 1 2 3 4 5; do
rm -rf "$d" 2>/dev/null
[ -e "$d" ] || break
sleep 0.2
done
[ ! -e "$d" ] \
|| echo "teardown: $d survived removal, holding: $(ls -A "$d" 2>/dev/null | tr '\n' ' ')" >&3
done
}
lib() { bash -c 'source "$1"; shift; "$@"' _ "$LIB" "$@"; }
# Writes a [remote-access] config with enabled = on and base_url set (so
@@ -163,7 +207,7 @@ done
exit 0
EOF
chmod +x "$LAUNCH_DIR/docker"
: > "$LAUNCH_DIR/sandbox-key"
printf "not-a-real-key\n" > "$LAUNCH_DIR/sandbox-key"
chmod 600 "$LAUNCH_DIR/sandbox-key"
PROJ="$(mktemp -d)"
}
@@ -2003,15 +2047,16 @@ EOF
}
ttyd_log() { cat "$STUB_DIR/ttyd-argv" 2>/dev/null; }
# Reports no listener, whatever the host is actually running. Opt-in, and
# deliberately not part of stub_ttyd: the port-in-use refusal has its own test
# below, which binds a real socket and needs the real ss to see it.
# Reports no listener, whatever the host is actually running. Called beside
# stub_ttyd by every ttyd test but one, and deliberately not folded into it:
# the port-in-use refusal binds a real socket and needs the real ss to see it.
#
# For every OTHER ttyd test the port being free is a precondition, not the
# subject - and one that was being inherited from the machine rather than
# stated. The default-port case asserts against 7681, which is exactly the
# port a real dejima-ttyd front door holds, so it failed on any host running
# the feature: the machine a developer is most likely to run this suite on.
# The rest name 12345, which is free on most machines and on none reliably.
# Same shape as #56 - a test reading its environment instead of setting it.
stub_ss_no_listeners() {
STUB_DIR="${STUB_DIR:-$(mktemp -d)}"
@@ -2087,6 +2132,8 @@ unit_exec_start() {
@test "dejima-ttyd refuses to start when something else already holds its port" {
command -v ss >/dev/null 2>&1 || skip "ss is not available"
[ -x "$REPO_ROOT/bin/dejima-ttyd" ]
# The one ttyd test with no stub_ss_no_listeners beside stub_ttyd: ss
# seeing the socket bound below is the subject here, not a precondition.
stub_ttyd
# The distro's ttyd package ships an enabled service that binds every
@@ -2143,6 +2190,7 @@ PY
@test "dejima-ttyd refuses to start when remote access is unconfigured, before ttyd is reached" {
[ -x "$REPO_ROOT/bin/dejima-ttyd" ]
stub_ttyd
stub_ss_no_listeners
run "$REPO_ROOT/bin/dejima-ttyd"
[ "$status" -ne 0 ]
[[ "$output" == *"not configured"* ]]
@@ -2254,6 +2302,7 @@ PY
[ -x "$REPO_ROOT/bin/dejima-ttyd" ]
write_remote_config 'ttyd_port = 99999'
stub_ttyd
stub_ss_no_listeners
run "$REPO_ROOT/bin/dejima-ttyd"
[ "$status" -ne 0 ]
# Named, so this cannot pass because ttyd was missing or the config was.
@@ -2270,6 +2319,7 @@ PY
# currently happens to agree with it.
write_remote_config 'ttyd_port = 12345'
stub_ttyd
stub_ss_no_listeners
run "$REPO_ROOT/bin/dejima-ttyd"
[ "$status" -eq 0 ]
assert_ttyd_argv 12345
@@ -2299,6 +2349,7 @@ PY
local bad
for bad in 0 7 73 999 abc 1e2 -20; do
stub_ttyd
stub_ss_no_listeners
: > "$STUB_DIR/ttyd-argv"
write_remote_config 'ttyd_port = 12345' "ttyd_font_size = $bad"
run "$REPO_ROOT/bin/dejima-ttyd"
@@ -2319,6 +2370,7 @@ PY
# moment either default moves.
write_remote_config 'ttyd_port = 12345'
stub_ttyd
stub_ss_no_listeners
run "$REPO_ROOT/bin/dejima-ttyd"
[ "$status" -eq 0 ]
assert_ttyd_argv 12345
@@ -2347,6 +2399,7 @@ PY
# a host-wide "off" written here would be a different thing from silence.
write_remote_config 'ttyd_port = 12345'
stub_ttyd
stub_ss_no_listeners
run "$REPO_ROOT/bin/dejima-ttyd"
[ "$status" -eq 0 ]
run grep -c 'buttonColors' "$STUB_DIR/ttyd-argv"
@@ -2378,6 +2431,7 @@ PY
# would be wrong for whichever half of the clients it did not match.
write_remote_config 'ttyd_port = 12345'
stub_ttyd
stub_ss_no_listeners
run "$REPO_ROOT/bin/dejima-ttyd"
[ "$status" -eq 0 ]
run grep -c 'focusOnTap' "$STUB_DIR/ttyd-argv"
@@ -2413,6 +2467,7 @@ PY
local bad
for bad in b M-b C-ab C- ctrl-a 2 'C-\' ; do
stub_ttyd
stub_ss_no_listeners
: > "$STUB_DIR/ttyd-argv"
write_remote_config 'ttyd_port = 12345' "ttyd_tmux_prefix = $bad"
run "$REPO_ROOT/bin/dejima-ttyd"
@@ -2442,6 +2497,7 @@ PY
"$REPO_ROOT/bin/dejima-provider-lib.sh" "$fake/bin/"
write_remote_config 'ttyd_port = 12345'
stub_ttyd
stub_ss_no_listeners
run "$fake/bin/dejima-ttyd"
[ "$status" -ne 0 ]
[[ "$output" == *"$fake/web/index.html"* ]]
@@ -2459,7 +2515,7 @@ PY
local web="$REPO_ROOT/web"
[ -f "$web/index.html" ]
[ -f "$web/sources.sha256" ]
run bash -c "cd '$web' && sha256sum src/* package-lock.json | LC_ALL=C sort -k2"
run bash -c "cd '$web' && sha256sum build.mjs src/* package-lock.json | LC_ALL=C sort -k2"
[ "$status" -eq 0 ]
[ "$output" = "$(cat "$web/sources.sha256")" ] || {
echo "web/sources.sha256 is stale. Rebuild it:" >&2
@@ -2497,10 +2553,26 @@ PY
# restricts outbound traffic to an allowlist, so a CDN reference would not
# resolve even from a browser outside it if the operator is on the same
# restricted network.
#
# A `data:` URI is exempt and only that: it carries its bytes in the
# attribute, so it fetches nothing and neither reason above applies to it.
# The favicon is one. Matched per attribute rather than per line, so a data
# URI on a line cannot carry a real reference past this beside it.
local page="$REPO_ROOT/web/index.html"
[ -f "$page" ]
run grep -nEi '<(script|link|img|iframe)[^>]+(src|href)=' "$page"
run bash -c "grep -oEi '<(script|link|img|iframe)[^>]+(src|href)=\"[^\"]*\"' '$page' \
| grep -vEi '(src|href)=\"data:'"
[ "$status" -ne 0 ] || { echo "the page references an external asset: $output" >&2; false; }
# And the exemption is not a hole: anything that is not a data URI still
# fails, checked against a copy rather than trusted.
local probe="$BATS_TEST_TMPDIR/probe.html"
sed 's|<link rel="icon"|<link rel="stylesheet" href="https://cdn.example.com/x.css"><link rel="icon"|' \
"$page" > "$probe"
run bash -c "grep -oEi '<(script|link|img|iframe)[^>]+(src|href)=\"[^\"]*\"' '$probe' \
| grep -vEi '(src|href)=\"data:'"
[ "$status" -eq 0 ] \
|| { echo "the check no longer catches a real external reference" >&2; false; }
}
@test "the terminal page's own unit tests pass" {
@@ -2525,6 +2597,7 @@ PY
[ -x "$REPO_ROOT/bin/dejima-ttyd" ]
write_remote_config 'ttyd_port = 12345'
stub_ttyd
stub_ss_no_listeners
run "$REPO_ROOT/bin/dejima-ttyd"
[ "$status" -eq 0 ]
# Executed, not grepped: the flag has to reach ttyd's argv.
@@ -2928,6 +3001,7 @@ nginx_blocks() {
printf '[remote-access]\nenabled = on\nbase_url = https://d.example\nttyd_port = 12345\n' \
> "$FAKE_HOME/.config/dejima/config"
stub_ttyd
stub_ss_no_listeners
local exec_start
exec_start="$(unit_exec_start dejima-ttyd.service "$FAKE_HOME")"
+85 -26
View File
@@ -37,43 +37,82 @@ PRIVILEGED=" runtime ci-runner "
# is a path nobody has decided about, and guessing "none" there is how a change
# ships having been checked by nothing.
suites_for() {
# Every suite that READS the path, not the one that owns it. static.bats
# asserts invariants over the whole tree and config.bats sources most of
# the libraries, so both appear far more often than a by-area reading
# suggests - and leaving them out is what made the --changed lane report an
# edit as green from a selection that never loaded the file.
#
# The map is checked against the suites mechanically; see the test named
# for it in static.bats. Over-selecting costs a suite run, under-selecting
# costs a missed regression, so a rule here errs wide.
case "$1" in
# Read by nearly every other suite, so a change to one is the whole set.
bin/dejima|bin/dejima-config-lib.sh) echo ALL ;;
bin/dejima-audit) echo audit ;;
bin/dejima-update-check) echo config ;;
bin/dejima-epitaph) echo config ;;
bin/dejima-audit) echo 'audit config proxy remote static' ;;
bin/dejima-update-check) echo 'config project remote static' ;;
bin/dejima-epitaph) echo 'config static' ;;
bin/dejima-tty-suspend) echo static ;;
bin/dejima-color-lib.sh) echo 'remote project' ;;
bin/dejima-tui-lib.sh) echo 'remote project' ;;
bin/dejima-conversation-ship) echo conversation ;;
bin/dejima-share) echo share ;;
bin/dejima-color-lib.sh) echo 'remote project config static' ;;
bin/dejima-tui-lib.sh) echo 'remote project static' ;;
bin/dejima-conversation-ship) echo 'conversation static' ;;
bin/dejima-share) echo 'share static' ;;
# The slug scheme keys the transcript mounts, the epitaph and audit
# stores, and the share layout, so a change to it is not one area's.
bin/dejima-slug-lib.sh) echo 'share conversation audit static' ;;
bin/dejima-credential-proxy|docker/credential-proxy-forwarder.js) echo proxy ;;
bin/dejima-project-lib.sh|bin/dejima-project|bin/dejima-dep|bin/dejima-allow) echo project ;;
bin/dejima-attach|bin/dejima-ttyd|bin/dejima-notify-watch) echo remote ;;
web/*|systemd/*) echo remote ;;
bin/dejima-provider-lib.sh|bin/dejima-harness-lib.sh) echo providers ;;
bin/harness/*) echo providers ;;
forges/*|workflows/*|harnesses/*|tools/*) echo providers ;;
install.sh) echo config ;;
bin/dejima-slug-lib.sh) echo 'share conversation audit project static' ;;
bin/dejima-credential-proxy|docker/credential-proxy-forwarder.js) echo 'proxy static' ;;
bin/dejima-credential-scan*) echo 'config static' ;;
bin/dejima-project-lib.sh|bin/dejima-project|bin/dejima-dep) \
echo 'project config launch providers remote static' ;;
bin/dejima-allow) echo 'project static config' ;;
bin/dejima-attach) echo 'remote config project static' ;;
bin/dejima-ttyd|bin/dejima-notify-watch) echo 'remote config static' ;;
web/*|systemd/*) echo 'remote static' ;;
bin/dejima-provider-lib.sh) echo 'providers config remote share static' ;;
bin/dejima-harness-lib.sh) echo 'providers config remote static' ;;
bin/harness/*) echo 'providers remote static' ;;
# A manifest is read by more than the suite that owns the schema: the
# audit reads a workflow's briefing, conversation.bats sed-injects a
# harness.conf, and config.bats copies the whole set in.
forges/*|workflows/*|harnesses/*|tools/*) \
echo 'providers static audit config conversation' ;;
install.sh) echo 'config audit providers remote static' ;;
# static.bats asserts on files it does not own, so a shipped doc or a
# docker/ file needs it as well as whatever else the change touched -
# which for docker/ is most things, since the entrypoint and compose
# file are read by nearly every suite that builds a launch.
docker/*) echo ALL ;;
hooks/*) echo 'static config providers project' ;;
docs/*|README.md|*.md) echo static ;;
hooks/*) echo 'static config providers project remote' ;;
.gitea/*|.shellcheckrc) echo static ;;
.dejima/*) echo 'static config project providers' ;;
test/*.bats) local n="${1##*/}"; echo "${n%.bats}" ;;
# A suite file is read by static.bats as well as run by bats: it
# asserts invariants over the text of the others, so an edit to one
# that only selected itself was reported green by a lane that never
# loaded the assertions about it.
test/*.bats) local n="${1##*/}"; echo "${n%.bats} static" ;;
# Fixtures are executed by the suites that consume them, not merely
# read: fake-anthropic.py is the upstream in proxy.bats (and in
# runtime.bats, which no confined selection can carry), and
# answer-prompt.py drives config.bats' prompts.
test/fixtures/*) echo 'proxy static config' ;;
test/*) echo static ;;
# Last of the specific rules, not before them: a .md under test/ or
# .dejima/ belongs to whatever reads that directory, and this pattern
# would otherwise answer for it. The docs themselves are asserted
# against by more than static.bats - the README's axis lists, the
# provider tables, the nginx example and the remote-access page each
# have a suite checking the code agrees with them.
#
# So a doc-only edit runs more than static alone. That is the cost of
# the lane answering for what reads a file, and it is the safe
# direction: narrowing this back would report a doc change as green
# from suites that assert against it.
docs/*|README.md|*.md) echo 'static config providers remote' ;;
*) echo ALL ;;
esac
}
@@ -208,6 +247,15 @@ fi
bats_flags=()
[ "$jobs" -gt 1 ] && bats_flags=(--jobs "$jobs" --no-parallelize-across-files)
# What this run asks the machine for, for the fixtures that wait on a bounded
# deadline. They multiply their bounds by how oversubscribed that makes it
# rather than carrying a number picked to survive the gate, which is a number
# that is then far too generous anywhere else (#295, test/fixtures/loadscale.py).
#
# Exported rather than passed, because it has to reach a python fixture several
# processes below bats.
export DEJIMA_TEST_CONCURRENCY=$(( ${#selected[@]} * jobs ))
{
if [ "$lane" = changed ]; then
printf 'lane: CHANGED - %d of %d confined suites, from %d changed path(s)\n' \
@@ -246,14 +294,21 @@ wait
# suite that fails to run at all - a syntax error, or duplicate test names,
# which bats refuses outright - exits nonzero having run nothing, and reporting
# that as "0 failures" is how a green board comes to mean nothing.
#
# Skips are counted and shown separately because bats emits one as `ok N ...
# # skip`, so a test that decided not to run is indistinguishable from one that
# passed. Several here skip on host state - an empty /opt/dejima, a missing
# tool - so the column is the only thing that says how much of the suite the
# number above actually covers.
failed=0
printf '%-16s %6s %8s\n' suite ran failed
printf '%-16s %6s %8s %8s\n' suite ran skipped failed
for s in "${selected[@]}"; do
ran="$(grep -cE '^(ok|not ok)' "$out/$s")"
emitted="$(grep -cE '^(ok|not ok)' "$out/$s")"
n_skipped="$(grep -cE '^ok [0-9]+ .* # skip' "$out/$s")"
bad="$(grep -cE '^not ok' "$out/$s")"
status="$(cat "$out/$s.status" 2>/dev/null || echo 1)"
printf '%-16s %6s %8s' "$s" "$ran" "$bad"
if [ "$ran" -eq 0 ]; then
printf '%-16s %6s %8s %8s' "$s" "$(( emitted - n_skipped ))" "$n_skipped" "$bad"
if [ "$emitted" -eq 0 ]; then
printf ' <- ran nothing\n'
failed=1
elif [ "$bad" -gt 0 ] || [ "$status" -ne 0 ]; then
@@ -264,14 +319,18 @@ for s in "${selected[@]}"; do
fi
done
for s in "${selected[@]}"; do
grep -hE '^ok [0-9]+ .* # skip' "$out/$s" | sed -E "s/^ok [0-9]+ / $s: /"
done | { grep . && echo " (skipped, so the counts above do not cover them)"; } || true
if [ "$failed" -ne 0 ]; then
echo
for s in "${selected[@]}"; do
ran="$(grep -cE '^(ok|not ok)' "$out/$s")"
emitted="$(grep -cE '^(ok|not ok)' "$out/$s")"
bad="$(grep -cE '^not ok' "$out/$s")"
{ [ "$bad" -gt 0 ] || [ "$ran" -eq 0 ]; } || continue
{ [ "$bad" -gt 0 ] || [ "$emitted" -eq 0 ]; } || continue
echo "--- $s"
if [ "$ran" -eq 0 ]; then
if [ "$emitted" -eq 0 ]; then
head -20 "$out/$s"
else
grep -E '^not ok' -A 20 "$out/$s"
+206 -29
View File
@@ -476,6 +476,11 @@ teardown() {
--format '{{.Names}}' 2>/dev/null)
docker images -q "localhost/dejima-test-*:$suffix" 2>/dev/null \
| xargs -r docker rmi -f >/dev/null 2>&1 || true
# Volumes too, for the same reason and in the same namespace: a test that
# makes one removes it itself, and this is what covers the run that was
# interrupted between the two.
docker volume ls -q --filter "name=^dejima-test-.*$suffix\$" 2>/dev/null \
| xargs -r docker volume rm >/dev/null 2>&1 || true
}
# The capability list bin/dejima gives the filter container, read out of the
@@ -518,17 +523,39 @@ start_pair() { # <purpose|""> <image> [docker run args...]
filter="$(test_container_name "$purpose-filter")"
fi
# The run's cached GitHub ranges, when the job fetched them. Each filter
# fetched its own, which is twenty-seven requests in about five minutes
# across this suite - inside the hourly ceiling a token buys, and exactly
# the burst GitHub's secondary limit answers with a 403 (#357).
#
# Read-only, and only ever this one file. egress-policy refuses a file://
# source unless SANDBOX_TEST_MODE is set, which start_pair's callers supply
# and no launch does.
#
# Absent when the job could not fetch, or when the suite is run by hand
# outside CI: every container then fetches for itself, which is the
# behaviour this replaces rather than a failure.
local -a meta=()
if [ -r /tmp/dejima-ci/github-meta.json ]; then
meta=(-v /tmp/dejima-ci/github-meta.json:/run/forge-meta-cache.json:ro
-e EGRESS_FORGE_META_URL=file:///run/forge-meta-cache.json
-e SANDBOX_TEST_MODE=1)
fi
local -a fcaps=() scaps=()
while IFS= read -r cap; do fcaps+=(--cap-add="$cap"); done < <(launcher_filter_cap_add)
while IFS= read -r cap; do scaps+=(--cap-add="$cap"); done < <(compose_cap_add)
[ "${#fcaps[@]}" -gt 0 ] || { echo "no --cap-add found in bin/dejima's filter run" >&2; return 1; }
[ "${#scaps[@]}" -gt 0 ] || { echo "no cap_add found in docker-compose.yml" >&2; return 1; }
# The cache goes to the FILTER only: it is the half that fetches the ranges,
# and the session container has no business holding a path that names them.
docker run -d --init --name "$filter" \
--cap-drop=ALL "${fcaps[@]}" \
--security-opt=no-new-privileges:true \
--sysctl net.ipv4.ip_unprivileged_port_start=0 \
--network "$(network_name)" \
${meta[@]+"${meta[@]}"} \
"$@" \
--entrypoint /usr/local/bin/filter-entrypoint.sh \
"$image" >/dev/null
@@ -1457,11 +1484,37 @@ session_pid() { # [container], defaulting to the file-wide one
# running session, and it is the operator's to run from the host. From
# inside the session it must do nothing - it is the same binary in the same
# image, and only the container it runs in differs.
# What the policy would have CHANGED is re-read after each refusal, not
# just the exit status checked: an amendment that landed and then failed on
# something later would exit non-zero with the destination permitted, which
# is the outcome this test is named for and the one a status cannot see.
#
# The entries file and the resolver configuration, because a permit for a
# NAME writes both - the address sets only ever hold addresses, so grepping
# those for a hostname is a check that cannot fail.
local before_entries before_conf after_entries after_conf
before_entries="$(dexec_filter cat /run/egress-policy/entries)"
before_conf="$(dexec_filter cat /run/egress-policy/resolver.conf)"
[ -n "$before_entries" ] && [ -n "$before_conf" ] \
|| { echo "could not read the policy's own record of what is permitted"; return 1; }
run dexec_node /usr/local/sbin/egress-policy permit evil.example.com
[ "$status" -ne 0 ]
run docker exec -u root "$(container_name)" /usr/local/sbin/egress-policy permit evil.example.com
[ "$status" -ne 0 ] \
|| { echo "root in the session container amended the permitted set"; return 1; }
after_entries="$(dexec_filter cat /run/egress-policy/entries)"
after_conf="$(dexec_filter cat /run/egress-policy/resolver.conf)"
[ "$after_entries" = "$before_entries" ] \
|| { echo "the entries file changed despite both refusals"; return 1; }
[ "$after_conf" = "$before_conf" ] \
|| { echo "the resolver configuration changed despite both refusals"; return 1; }
# Named directly as well, so this does not rest on two snapshots matching:
# an entry already present before the test would make them match anyway.
grep -q evil.example <<< "$after_entries$after_conf" \
&& { echo "evil.example.com reached the policy's record of what is permitted"; return 1; }
return 0
}
@test "node has no working sudo" {
@@ -1504,6 +1557,29 @@ session_pid() { # [container], defaulting to the file-wide one
# container.
run dexec /usr/local/sbin/strip-privilege-bits --check
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
# And once more outside those roots. --check walks the directories the
# sweeper walks, which deliberately exclude /tmp, /run, /mnt and /media -
# the sweeper has no reason to visit them, but an image that shipped
# something there would go unnoticed by a check scoped to its own sweep.
# Cheap because those directories are near-empty in an image.
run dexec sh -c 'find /tmp /run /mnt /media -xdev -type f -perm /6000 2>/dev/null; getcap -r /tmp /run /mnt /media 2>/dev/null'
[ -z "$output" ] \
|| { echo "privileged bits outside the swept roots:"; echo "$output"; return 1; }
# File capabilities over the whole image filesystem, not a directory
# union: a capability at a top-level path nobody enumerated - /data/bin
# in some future layer - is asserted by nothing else, while setuid gets
# the same whole-filesystem walk in the derived-image test below. -xdev
# keeps this to the image: /proc, /sys and every bind mount are their own
# filesystems, and a capability in the operator's mounted checkout is not
# the image's to answer for. A missing getcap prints a marker instead of
# nothing, because an empty result has to mean "none found", not "nothing
# asked".
run dexec sh -c 'command -v getcap >/dev/null || { echo getcap-missing; exit 1; }
find / -xdev -type f -print0 2>/dev/null | xargs -0 -r getcap 2>/dev/null || true'
[ -z "$output" ] \
|| { echo "a file capability survived somewhere in the image (or getcap is gone):"; echo "$output"; return 1; }
}
@test "the session shell is bash, configured by the image, with history appended" {
@@ -1795,20 +1871,36 @@ session_pid() { # [container], defaulting to the file-wide one
@test "every connection attempt is recorded with its verdict" {
# A refused destination and a permitted one both have to leave a record, or
# the log a supervisor reads proves nothing about what the session did.
# the log a supervisor reads proves nothing about what the session did. And
# each record's verdict has to be the right one, not merely present: the
# verdict rides on which NFLOG group the rule named, so the two groups
# swapped would log every refusal as "allowed" - and a check that only
# asked for the field would stay green while it did.
# The permitted half needs a destination nothing has to answer on: the allow
# record is written when the first packet is accepted, not when a service
# replies, and a test that waited for a reply would be testing the network.
# A documentation address permitted as a literal is exactly that - in
# @literal_v4, on any port, routed nowhere. Added live rather than declared
# at launch so the file-wide pair keeps the permitted set a real one has.
local allowed=198.51.100.1
run dexec_filter /usr/local/sbin/egress-policy permit "$allowed"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
dexec curl -s -o /dev/null --connect-timeout 5 --max-time 8 https://192.0.2.1/ 2>/dev/null || true
dexec curl -s -o /dev/null --connect-timeout 3 --max-time 5 "http://$allowed:9/" 2>/dev/null || true
local found="" i
for i in 1 2 3 4 5 6; do
if dexec_filter grep -q '192.0.2.1' /var/log/egress-policy/connections.ndjson 2>/dev/null; then
if dexec_filter grep -q '192.0.2.1' /var/log/egress-policy/connections.ndjson 2>/dev/null \
&& dexec_filter grep -F "$allowed" /var/log/egress-policy/connections.ndjson 2>/dev/null | grep -q '"allowed"'; then
found=yes; break
fi
sleep 0.5
done
[ -n "$found" ] \
|| { echo "a refused connection left no record"; dexec_filter tail -5 /var/log/egress-policy/connections.ndjson; return 1; }
|| { echo "a connection left no record with its verdict"; dexec_filter tail -5 /var/log/egress-policy/connections.ndjson; return 1; }
run dexec_filter sh -c "grep -F '192.0.2.1' /var/log/egress-policy/connections.ndjson | tail -1"
[[ "$output" == *'"action"'* ]] \
|| { echo "recorded without a verdict field: $output"; return 1; }
[[ "$output" == *'"blocked"'* ]] \
|| { echo "a refused connection was not recorded as blocked: $output"; return 1; }
}
@@ -2254,49 +2346,69 @@ s.serve_forever()' "$bind_addr" > "$port_file" 2>/dev/null &
timeout 5 bash -c "cat < /dev/null > /dev/tcp/$bind_addr/$HOST_LISTENER_PORT"
}
@test "a host service bound to loopback is unreachable from inside the container, one bound to the bridge gateway is reachable" {
@test "the host is unreachable on every port by default, and on exactly the ports EGRESS_HOST_PORTS names" {
# The regression test for the escape path bin/dejima-ttyd's
# --interface 127.0.0.1 exists to close. The egress policy deliberately
# ACCEPTs OUTPUT to the Docker bridge gateway, and a tmux client attached
# through ttyd is a host shell - the prefix key opens a window outside the
# container - so a ttyd the sandbox could reach would be a full escape.
# Prove the containment property holds rather than trusting the flag.
local container host_ip loopback_port gateway_port
# --interface 127.0.0.1 exists to close, now one layer stronger. The host
# entry used to be accepted on every port, so a ttyd bound to 0.0.0.0 - and
# a tmux client attached through ttyd is a host shell, the prefix key opens
# a window outside the container - was reachable from the session. It now
# opens only the ports it was told to (#363).
local container host_ip loopback_port named_port other_port
local test_container="$(test_container_name hostports)"
container="$(container_name)"
# Derived exactly as the egress policy derives the host address, so this is the one
# host address the container's OUTPUT chain accepts at all.
# Derived exactly as the egress policy derives the host address, so this is
# the one host address the container's OUTPUT chain names at all.
host_ip="$(docker exec "$container" sh -c "ip route | grep default | cut -d' ' -f3")"
[ -n "$host_ip" ]
start_host_listener 127.0.0.1
loopback_port="$HOST_LISTENER_PORT"
start_host_listener "$host_ip"
gateway_port="$HOST_LISTENER_PORT"
named_port="$HOST_LISTENER_PORT"
start_host_listener "$host_ip"
other_port="$HOST_LISTENER_PORT"
# Setup check, from the host's own vantage point: the loopback listener's
# port must answer on 127.0.0.1 only. bind(0) on two different addresses
# can legally return the same port number, and if it had, the container
# probe below would be hitting the gateway listener and failing for a
# reason that has nothing to do with containment.
# probes below would be hitting a gateway listener and failing for a reason
# that has nothing to do with containment.
run timeout 5 bash -c "cat < /dev/null > /dev/tcp/$host_ip/$loopback_port"
[ "$status" -ne 0 ]
# Positive control, and the reason the negative one means anything: same
# container, same docker exec, same timeout/bash /dev/tcp probe, same
# gateway address - only the port differs. Without it a `status -ne 0` is
# equally satisfied by docker exec failing (125), bash or timeout missing
# from the image (127), or a container that can reach nothing at all, none
# of which say anything about loopback.
run docker exec "$container" timeout 5 bash -c "cat < /dev/null > /dev/tcp/$host_ip/$gateway_port"
# The default, on the file-wide pair, which sets no EGRESS_HOST_PORTS: a
# real listener on the gateway address, and the session cannot reach it.
run docker exec "$container" timeout 5 bash -c "cat < /dev/null > /dev/tcp/$host_ip/$named_port"
[ "$status" -ne 0 ]
# A pair told to open that one port, and nothing else about it changed.
start_pair hostports "$SESSION_IMAGE" \
-e SANDBOX_TEST_MODE=1 \
-e EGRESS_HOST_PORTS="$named_port"
# Positive control, and the reason the negatives mean anything: without it
# a `status -ne 0` is equally satisfied by docker exec failing (125), bash
# or timeout missing from the image (127), or a container that can reach
# nothing at all, none of which say anything about the clamp.
run docker exec "$test_container" timeout 5 bash -c "cat < /dev/null > /dev/tcp/$host_ip/$named_port"
[ "$status" -eq 0 ]
# Deliberately not asserting the exact status. 1 (the host RSTs, since
# nothing is bound on the gateway address at this port) is what it should
# be, but a host that DROPs on its own docker bridge instead yields 124
# from timeout, and that is still containment.
run docker exec "$container" timeout 5 bash -c "cat < /dev/null > /dev/tcp/$host_ip/$loopback_port"
# The clamp itself: same container, same address, same kind of listener,
# only the port differs from the one above.
run docker exec "$test_container" timeout 5 bash -c "cat < /dev/null > /dev/tcp/$host_ip/$other_port"
[ "$status" -ne 0 ]
# And the original containment property, which the clamp does not replace:
# a host service bound to loopback is not on the gateway address at all.
#
# Deliberately not asserting the exact status on any of the negatives. 1
# (the host RSTs) is what a refusal should look like, but a drop yields 124
# from timeout, and that is still containment.
run docker exec "$test_container" timeout 5 bash -c "cat < /dev/null > /dev/tcp/$host_ip/$loopback_port"
[ "$status" -ne 0 ]
docker rm -f "$test_container" >/dev/null 2>&1 || true
}
@@ -2588,3 +2700,68 @@ EOF
[[ "$output" != *"dejima-notify"* ]]
[[ "$output" != *"remote access is enabled"* ]]
}
@test "an isolated launch's working directory is a volume the session user owns" {
# The one thing about --isolated that a confined test cannot decide: that
# the directory the launcher points a session at is usable. A volume at a
# path the image created is seeded from it, ownership included, and if
# that did not hold the session would start in a directory root owns and
# fail on its first write.
#
# The volume is named for the run by bin/dejima and removed in its exit
# trap, both of which the confined suite checks against the launcher. So
# this does not re-check the reaping, and deliberately does not count the
# host's volumes before and after: another job on a shared runner creating
# one between the two reads would fail a test about something else
# entirely.
local vol="dejima-test-work-$(basename "$BATS_RUN_TMPDIR")"
docker volume create "$vol" >/dev/null
run docker run --rm -v "$vol:/dejima/work" --entrypoint sh "$SESSION_IMAGE" -c \
'cd /dejima/work && touch marker && stat -c "%U" /dejima/work'
if [ "$status" -ne 0 ] || [ "$output" != node ]; then
docker volume rm "$vol" >/dev/null 2>&1 || true
echo "the working directory is not writable by the session user: $output"
return 1
fi
# And nothing of the host is behind it, which is the claim the mode rests
# on: a second volume at the same path starts empty rather than carrying
# what the first container wrote.
# Purpose before the suffix, like test_container_name: teardown's filter
# is anchored on the suffix, so a name ending in anything else is one it
# cannot sweep if this is interrupted between create and remove.
local other="dejima-test-work2-$(basename "$BATS_RUN_TMPDIR")"
docker volume create "$other" >/dev/null
run docker run --rm -v "$other:/dejima/work" --entrypoint sh "$SESSION_IMAGE" -c \
'ls -A /dejima/work'
local rc="$status" out="$output"
docker volume rm "$vol" "$other" >/dev/null 2>&1 || true
[ "$rc" -eq 0 ] || { echo "$out"; return 1; }
[ -z "$out" ] \
|| { echo "a second session's working directory was not empty: $out"; return 1; }
}
@test "the git shadow loads its policy in the image, and leaves other git commands alone" {
# The decision lives in a file the Dockerfile copies in
# (docker/guard-lib.sh), and every git command in a session goes through
# the shadow that sources it - so a COPY line that stopped matching the
# path would break git outright, in every session, and nothing in the
# confined suites would notice: they point the guards at the checkout.
#
# The file-wide pair sets no DEJIMA_PUSH_HOSTS, which fails closed, so any
# push is refused. Which refusal does not matter; that the guard is the one
# speaking does.
run dexec bash -c 'cd /tmp && git init -q -b feature r && cd r && git push origin feature'
[ "$status" -ne 0 ]
[[ "$output" == *"Blocked by dejima's git guard"* ]] \
|| { echo "the shadow did not reach its policy: $output"; return 1; }
[[ "$output" != *"No such file"* ]] \
|| { echo "the shadow could not load the policy file: $output"; return 1; }
# And the pass-through path: everything that is not a push has to reach
# real git, or the shadow is a broken git rather than a guarded one.
run dexec git --version
[ "$status" -eq 0 ]
[[ "$output" == git\ version* ]] || { echo "$output"; return 1; }
}
+32
View File
@@ -150,6 +150,38 @@ mounted_share() { sort -u "$SHARE_LOG" | grep -v '^$' | head -1; }
|| { echo "a clean share_dir warned about its own projects dir: $output"; return 1; }
}
@test "an isolated launch's share is its own, and the root is not reported as loose" {
write_config
# launch() takes a workspace, which --isolated refuses - so this one
# drives the launcher directly with the same stub.
STUB_DIR="$(mktemp -d)"
cat > "$STUB_DIR/docker" <<'EOF'
#!/bin/sh
printf '%s\n' "${SANDBOX_SHARE_DIR-}" >> "$SANDBOX_TEST_SHARE_LOG"
exit 0
EOF
chmod +x "$STUB_DIR/docker"
SHARE_LOG="$HOST/share-log"
: > "$SHARE_LOG"
HOME="$HOST/home" PATH="$STUB_DIR:$PATH" SSH_AUTH_SOCK= \
SANDBOX_TEST_SHARE_LOG="$SHARE_LOG" \
run "$REPO_ROOT/bin/dejima" --isolated
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
case "$(mounted_share)" in
"$SHARE"/isolated/*) ;;
*) echo "mounted '$(mounted_share)', not a directory under the isolated root"
return 1 ;;
esac
[ -d "$(mounted_share)" ] \
|| { echo "the launch did not create the directory it mounts"; return 1; }
# The root holding them is dejima's, like projects/ - so a launch must
# not go on telling the operator about it every time as something that
# was left behind by hand.
[[ "$output" != *"no session can see"* ]] \
|| { echo "the isolated root was reported as left behind: $output"; return 1; }
}
@test "dejima-share list names each project and flags what is loose" {
write_config
share path "$WS" > /dev/null
+10 -4
View File
@@ -40,10 +40,16 @@ list_scripts() {
# keeping the permitted set's classes from drifting between the
# container that enforces them and the host command that adds to them.
#
# Matched by directory rather than by name, because a list is a thing
# to forget to add to. Everything under docker/ is in the trusted
# computing base; the -s bash below tells shellcheck what these are.
case "$f" in bin/harness/*.sh|docker/*.sh) printf '%s\n' "$f"; continue ;; esac
# Matched by pattern rather than by name, because a list is a thing to
# forget to add to - and this one was forgotten anyway: bin/*-lib.sh
# went unchecked, which is dejima-harness-lib.sh, the code deciding
# which host paths may be mounted where. Everything under docker/ is in
# the trusted computing base; the -s bash below tells shellcheck what
# these are.
case "$f" in
bin/harness/*.sh|docker/*.sh|bin/*-lib.sh)
printf '%s\n' "$f"; continue ;;
esac
head -1 "$f" | grep -qE '^#!.*(bash|sh)' && printf '%s\n' "$f"
done
}
+1871 -131
View File
File diff suppressed because it is too large Load Diff
+21 -2
View File
@@ -10,7 +10,7 @@
//
// Usage: npm ci && node build.mjs
import { createHash } from 'node:crypto';
import { readFileSync, writeFileSync } from 'node:fs';
import { readdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -41,8 +41,15 @@ const CLIENT_SOURCES = [
// needed, since the first alone passes on a commit that rebuilds the manifest
// and leaves the page behind.
const MANIFEST_SOURCES = [
// The builder itself. Without it, editing this file - the escaping, the fill
// order, which sources get inlined - produces a different page with a
// byte-identical manifest and stamp, so the staleness check passes on a page
// nobody rebuilt. web/README.md defends committing the artifact precisely so
// nobody has to run the build, which is what makes that check load-bearing.
'build.mjs',
'package-lock.json',
'src/config.js',
'src/favicon.png',
'src/gestures.js',
'src/keyboard.js',
'src/keys.js',
@@ -58,7 +65,18 @@ const MANIFEST_SOURCES = [
'src/viewport.js'
];
// The order above is hand-written because it is load-bearing, so the list
// cannot be a glob. A src/*.js omitted from it is still hashed into the
// manifest and still fails no test - it is simply never inlined, and the page
// is missing a module. Reading the directory catches that at build time.
const uninlined = readdirSync(join(here, 'src'))
.filter((f) => f.endsWith('.js') && !CLIENT_SOURCES.includes(`src/${f}`));
if (uninlined.length) {
throw new Error(`src/*.js not in CLIENT_SOURCES, so never inlined: ${uninlined.join(', ')}`);
}
const read = (relative) => readFileSync(join(here, relative), 'utf8');
const readBytes = (relative) => readFileSync(join(here, relative));
const sha256 = (text) => createHash('sha256').update(text).digest('hex');
// A literal </script> inside an inlined script would end the element early.
@@ -82,8 +100,9 @@ html = fill(html, 'PAGE_CSS', read('src/page.css'));
html = fill(html, 'XTERM_JS', escapeForScript(read('node_modules/@xterm/xterm/lib/xterm.js')));
html = fill(html, 'ADDON_FIT_JS', escapeForScript(read('node_modules/@xterm/addon-fit/lib/addon-fit.js')));
html = fill(html, 'CLIENT_JS', escapeForScript(client));
html = fill(html, 'FAVICON_B64', readBytes('src/favicon.png').toString('base64'));
const manifest = MANIFEST_SOURCES.map((file) => `${sha256(read(file))} ${file}\n`).join('');
const manifest = MANIFEST_SOURCES.map((file) => `${sha256(readBytes(file))} ${file}\n`).join('');
html = html.replace(
'<!--BUILD_STAMP-->',
() =>
+5 -1
View File
@@ -10,7 +10,11 @@
<meta name="color-scheme" content="dark">
<meta name="mobile-web-app-capable" content="yes">
<title>dejima</title>
<!-- Generated by web/build.mjs - do not edit. Sources: f8a5205278ef77485e319cdefff6cbeeb027f201ab27266b02703a9eb570c608 -->
<!-- Inlined like everything else on this page: ttyd serves one file and answers
no path a /favicon.ico could be fetched from, so a link to one would 404 and
the tab would fall back to the generic icon. -->
<link rel="icon" type="image/png" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGUAAABlCAYAAABUfC3PAAAACXBIWXMAAAsSAAALEgHS3X78AAAK9UlEQVR4nO2db0wb5x3Hv6BAa2w0ggcjJlHsOHYkYgs8oIFIThMsUTlCJJsEFW/CgjJpaFsi7VWFtBfTVNZXUYJU5Q1K11ckIK0KQ0JDcrTWVU0FFCMcpJxLMQoYhOuEyf+K3XV74dxxZ5/xnX1nH+E+b+D85+6xPzzP7+653++hBAACxNRlyEgBd42xa7ckQEx9BuB6sVsjAwDYBaArhSxESlQBaCotditk0pGlSBBZigSRpUgQWYoEkaVIEFmKBJGlSBBZigSRpUgQWYoEkaVIkGPFbgBXvGt+hCMxuJdXAQBbO6+wvfOS9bUqpQIGnQYAcFangUqlgMWkL1hb80WSUrxrfriXV+Fd8+Pb7zbhXfPz3odz1pP2WF1tNQxnNDDoNGgy6yUrqiRATP2v2I0gJSx6Vlm/TDEx6DSwtpmkJOlK0aR41/yYdszBOfss4zBEp1KtRKW6AupTVShXlFHbbOzFEgi+2AUAbBEBxvZBqJQKWNtMsF44D4tZD5VSwe9DCUNhpXAVUV5RBo2xBuqTVcmfr0XkyxYRwPcbuwi++A+2iABCwciBr7fbWmG9cB7WNlPex+aB+FLCkRimHfMYn3QeKELbpMEJYw0loxCEglFsEQH4iQB8S5uIRxOsr1MpFbDbWtDbfQl1tcfFbpZ4UhY9q5h2zGPaMcf6fHlFGbSN9dA2aaAx1gjSE/JliwjAt+SHz+3P2IssZj3sHa2w21rEaobwUqYd85iY/CLjGZO2SQNtYz2M7aeFPKzgbBEBPHetZ+xBdbXVuGprQU+3VejYI5yUacc8Ho7NsA5RlWolTLazONeulUSP4AvhWgfh8sFPBNKeUykV6O22wm5rFWpoy1/KxKQTD8dmEI7E0p7TNmlg7jDghLEmn0NIhlAwioWplYy9x25rxe1b3fn2nNylZOoZZKxo7mrIeMp62InHElh2eEG41tNiD9lz8hjW+EtZ9Kxi+N5jVhnmDgPMNsOhHKJyhXCtY2FqhVXOQF8nerqtfHfJXcr2ziuMjD5Ju+I+qjJSySTHoNPgj7+9xme2gJuUTHHDbDOguavhSMtIZfmpFwtTK2kxp6fbioG+Ti5DWnYp0455DN9/xHhMY6zBu/2tb2zMyJd4LIGFqRUsO7yMx+22VgzdeT/b269knSVO7R2dgxehbdTwbeeRolxRhvaeRhjbT2PmgYsa0tjOUNnIepMrdd5H7h3cUZ+swl4sTm1bL3CbQ8sqpa72OEPMsuPbHJp3NCFc61RsIefPuMDpdrC9Y39nhMuHeIx94k6GCeHyUb/zmSvjJMXaZkJdbTW1/Zx2MBl2QsEoY1qmt/sS5/dyTpy4SjPtkYewrHie7p95Wcx6XvNinKXYba3U76FgBFssk3MySeKxBGM0sXe0ZnwtG5yl1NUeZ4h57lrndaCjhM/tzynAk/DK+6LvXA74mVmmDV0qpQLTjnle7+clxWLSywE/C8GNXUaSxvbOSwzffwR73585Z+rwzpDspc16ygE/nUzXceFIDCOjk5z2wVsKfQiTAz6TeCwB39Imtf3BL3/AjXN71Pb2zktOQxlvKcnAJQd8NugBvl75E24Y9/CB5Qdc1+1PtTwcm8m6n5wSvOWAzw49wF/X7X8nv6L9ziXxMCcpqQE/dYr6KJIa4Om947O1/ftNXBL7ci6FoAd8Qh7CGAHedjKBeuVPAIBQogSOzX0p9HnETOQsJTXg+5b4Z8a/KaQGePrQ5dgoQyheAiCZK8alp+RcCkEGfDIDknD5CnLzK7ixi3g0gVAwmjGLsbyiDD9/nfpaiPSm5y4f4/bvZqQUm5FSzO0cw8eet6jHr3K8ss+rPqWn20pJSaZ6RgW9CUbP9U0ds/lQqVZCfepnouUqp16vffTN2/jom7fTXkc/az2IvKQYdMkCHDJFlXD50NzVkM8uEdzYBeFaPzCfly+hYCQ5xLqT7aTnMefbu7lk7wPJP2CuM8V5V3L1dF+iEisI13pOUshZVcK1zrk3vFP7I1pr/8v63GYkGVzJsTzteNEECJcPhMuH8ooynGvXwtRhyKmX06/TLGY9rBdMVIVBMoac552tn7cUa9t5qEYVCEdiVMDn+teXLQ0USH5Qi0mPJrMet4ceUI//3rSH1tof2febcsZz+9Y1AMlEwsXlVUYCQzyazHZcdnh5p9nGYwnG3cWbfZ2wmPS5JOAxyFsKWf3EJ+BvEQEsP/VSw0kqyWoqU1I4LU/K2maiJvX+8GUF/vFemDr1JAklSjD0tYLqJSqlgvqSyJ/OWQ+cXz+Dc9bDEORzJ8sg1KeqYO4wZK0MoE/I1tVWC1aeJ0ghKteAv0UEsDC1wpq9TpYWHJS9frOvk5ISipfg1/9S4YYxjnde95jNSCk+9ryFzcj+mf5AX2fafqxtJljbTAjf6oZz9lla6UbwxS7+/ekcFqZW0NzVkFEOPcD35tk76AhWCjFw5y71wZq7Ghix5SAZfItw2JIDM8Ex+Q3AwUVOlWplmpwtIoB/3v18v11jfxWqTkXY+hTyyyqvKMNv7l7LKoMcg/ninPVg+P7jA5PbBvo6cZOll2Rje+cVHo7NHChH26TBV+NLVDzhI58DwkkJR2LouTVMfVHlFWUZazjstpa8x1+ylnLaMUf1UCFrE7d3XmF88gtMO+bT5Kd+tpHhQSHLvYUtrxu+/zhjjaPd1oqBvk7BCzkXPavUWZnFrMfIh4OC7j8ciWFi0onxSSdrz6yrrcbE6JCQh8yeS8wHesAnEUtGoVApFbj5us6ETY6QAZ5EUCkGnQY93Va4l1dx9kz9oZaRSqoc56wHdb+oFqVKWPC1WcgLtTcVUk4uJxFckZeWkiCyFAkiS5EgshQJIon1vrgQjsSodcHoq+J5v/MzTlEt5uRFHLk63lmdBoYz9YfpLLB4631xwbvmx5ezHjhnPTmtjkeHvLdhMekLvVQUX6QnJRyJsc7cCkmuN58KhHSkZJvOAJITgieMNahUV0Dz+kaUirZCXpy2At73G7sIB6MIvthlnRAlkeCMgzSkZFt0R9tYT8nIhXgskVxoze3PeJeTx+IDYlNcKd41P/5271HaMEUuLWJs1wpeIh6PJeBz+zOupzJ05/1ix5ziSZl2zGNk9AmjdxR6nRffkh+u8aU0OUXuNcWRwjbFX8x1XtjWUzHoNBgZHiyGmMJKCUdiuD30gDFclVeU4b3fXSz6Qm2hYBSffzrHOClQKRUYGR6kVgMvEIWTwiZEY6xB5+BFSa2C5JpYYlQRFEHMlYJMs7AJMbZr0fWndyUlBADaexpxuX8/vZSt7WJTECnD9x+nCbncL9oSsnljbD+dJmbow79zXoUoX0SX8snYDKMqVupCSFLFbO+8ZGRoiomoUhY9q4wav8MihCRVjHfNj5HRJ6IfVzQpZJcnUZ+qwsXeRrEOJxrG9tMwtmup7YlJJxY9q6IeUzQpI6OTjBywy/0tkgvqXLnc3wL1qf2aluF7j0U9nihSkimg+xeHzV0NBfunAmJBH3a3d17iEw6l17kiihR6gzXGGpg7DGIcpqCoT1Yx8qMPms3OF8FTjMgaEJJQMIopWiL0YWaPtl4AeatBjFQjwaVMTDoZ22Rp25vIuEhSBB++CjxPVFQMZ8T5rKLMfYl9yigVDDqNGLPIwiZ4k0jkv8AdWuS8LwkiS5EgshQJIkuRILIUCSJLkSCyFAkiS5EgshQJIkuRILIUCVIKwFfsRsgw2D0G4C8A+ovdEhkAgLvG2OX+Pzk3fgySn1OHAAAAAElFTkSuQmCC">
<!-- Generated by web/build.mjs - do not edit. Sources: 0cc964e9a08f82488c268b4e95b19542a42c5ada3b927e7fac62f7acc46bde94 -->
<style>
/**
* Copyright (c) 2014 The xterm.js authors. All rights reserved.
+3 -1
View File
@@ -1,5 +1,7 @@
63b33d784dd94f6df18f63043a5f3734a306e1ea01d7ddbc285d53c1b7435cf2 build.mjs
f6f6611d0b3eacc39d10add9c4bbc56d4b3a70a47f022351a1c6eab68cd66e71 package-lock.json
613d28096b1bed66f347590663f5f0136fb7f61a4578066fa2ed9e788ff9567a src/config.js
18a303a9b2bb95504e25e954c82e542da96702cf39a3499e9c9fd50e5160f39f src/favicon.png
f690fd9d35975bc977e3d323595a2a4aa1fca6ed50f213602f08adec3f5b2c92 src/gestures.js
e75dc9d5e311a3d9bf9dc373b651ad506afbd7a7cc7ad54aac6fdad94276b0a4 src/keyboard.js
2a070f63702b95b0c62963ec6b9ec4fa2b0b89b7553a8a889d68cdf3506f9ce3 src/keys.js
@@ -10,6 +12,6 @@ cb5e33c0fa117176d9073bd9019f78b65bfe5535833bf1765ee542c700d67f1f src/osc52.js
94229c981f6287162d6ef774f71ef41d941494595f76f16bb742811ca32a767f src/page.css
e02823ce9d5d57615a0a8c0c7b91611932202758048b7eadabe4f1054aaf1a9f src/protocol.js
aac82e88c6a65617818c6589091cbb8c3bab50e82e98aabf8b583cbb3dfda7e7 src/sgr.js
dab361cf748e6d530099f79ccd3f407fcc493f81dd4afeda227376a8c9e9b81f src/template.html
34e34a8d258b8a7d2afde03287681b99d839994936c398ba5c7165a3ed7aec93 src/template.html
3c8e5520b694693126097d9fe17f89ff23ba15adb8532521c647907edba20215 src/toolbar.js
864405fe11e5456bdb98ea8c841cb04346d5a38aabec897101defa9612d89443 src/viewport.js
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

+4
View File
@@ -10,6 +10,10 @@
<meta name="color-scheme" content="dark">
<meta name="mobile-web-app-capable" content="yes">
<title>dejima</title>
<!-- Inlined like everything else on this page: ttyd serves one file and answers
no path a /favicon.ico could be fetched from, so a link to one would 404 and
the tab would fall back to the generic icon. -->
<link rel="icon" type="image/png" href="data:image/png;base64,/*FAVICON_B64*/">
<!--BUILD_STAMP-->
<style>
/*XTERM_CSS*/
-4
View File
@@ -1,8 +1,4 @@
Git workflow - do this even though nothing stops you from doing otherwise:
- Do new work in a git worktree for the feature branch, not by switching
branches in place in the mounted checkout - that's the user's actual
working copy, and changing its branch out from under them is disruptive
even if you switch it back.
<!-- core: worktree -->
- Push that branch to the remote when the work is complete and its tests
pass. Do that WITHOUT asking first: this workflow's destination is known,
-4
View File
@@ -1,8 +1,4 @@
Git workflow - do this even though nothing stops you from doing otherwise:
- Do new work in a git worktree for the feature branch, not by switching
branches in place in the mounted checkout - that's the user's actual
working copy, and changing its branch out from under them is disruptive
even if you switch it back.
<!-- core: worktree -->
- Push that branch to the remote, then run `{{pr_command}} create` from
the worktree. Never push directly to {{blocked_branches}}, on any remote,