Compare commits

...
Author SHA1 Message Date
branderson f679e5d59f Count a later word in the merge guard only when it names a path
That is where the two layers divide: a bare `gh` anywhere in a segment is
reached by executing what PATH resolves, so the shadow sees it, while a path
names the real binary and the shadow never runs. Checking every word regardless
also blocked `echo gh pr merge 3`, which the agreement test names as the one
case the two layers must differ on.
2026-10-13 21:38:46 -07:00
branderson d41483e073 Record what the release review changed, and what it left
The merge-guard and gh api fixes restore what the entry already claims, so
neither is an operator-visible change against the previous main; --config-env
and the empty-key warning are. The merge endpoint goes under what this release
does not do, with its ticket.
2026-10-13 21:21:18 -07:00
branderson 6bf63c8325 Carry git's --config-env into the push guard's resolution
It sets config the way `-c` does, with the value read from the environment,
so a pushurl set through it has to reach the URL this resolves. The separated
spelling also takes the next word: left unrecognised, `remote.origin.pushurl=VAR`
was read as the subcommand, and a subcommand that is not `push` ends the
verdict having checked neither the host allowlist nor the blocked branch.

It is the last of git's global options that takes a separate value and was
neither skipped in pairs nor refused.
2026-10-13 21:19:40 -07:00
branderson 0936ac80ac Say when an empty ssh_key leaves the operator's agent signing
A key file that exists but is empty loads nothing, so the withdrawal that a
configured key triggers does not fire and the forwarded agent stays mounted.
With a pushing workflow and an agent running, every other signal agrees with
the arrangement the operator asked for: the key is configured, the launch
starts, pushes work - and they work because the personal identities are doing
them.
2026-10-13 21:17:42 -07:00
branderson f35556ee0b Hold the gh api brace exemption to an endpoint's {owner}/{repo}
gh substitutes those two from the checkout, so a path carrying them names this
repository by construction. Any other brace says nothing about what the call
reaches, and the exemption accepted one anywhere in argv: `-f name={x}` or
`--template '{{.login}}'` asserted the whole command was repository-scoped,
which left `gh api orgs/other/repos -X POST` and `gh api user` reachable.
2026-10-13 21:14:39 -07:00
branderson 5f595de0ab Read every word of a segment in the merge guard, not the first
A forge CLI named by absolute path behind a prefix word - `env`, `nohup`,
`xargs`, `timeout` - reaches the real binary without passing the PATH shadow.
A check on the segment's first word leaves that combination with no layer
looking at it, so `env /usr/local/lib/dejima/forge/gh pr merge 7` was allowed.

The test asserted the path-qualified form but not a prefixed one, which is why
it kept passing.
2026-10-13 21:09:26 -07:00
branderson 31e7b869c3 Date the release 2026.10.13
The heading has to match the tag on main, and the tag is the day the merge
lands. Nothing derives one from the other, so they are set by hand together.
2026-10-13 21:01:03 -07:00
branderson 9acc280e03 List the changelog in the README's docs table
The changelog exists to be found by an operator deciding whether to update,
and the README table is where someone looks first.
2026-10-13 20:58:43 -07:00
branderson 55046cf863 Merge pull request 'Release blockers: N1-N6 and the two older Highs from the development to main pass' (#399) from release-blockers into development
Reviewed-on: #399
Reviewed-by: branderson <brad@branderson.io>
2026-10-13 20:38:36 -07:00
branderson a88c994954 Cut the story from the same-day release note
Three sentences on which format was tried first, which test refused it, and
why the paragraph was written the way it was. One fact survives that: four
dot-separated numbers beginning with a year and a month read as a private
IPv4 address, which is why the suffix is a letter.
2026-10-13 17:30:14 -07:00
branderson 0ffdfa6da2 Correct what the changelog says this release does to an existing config
Three things an operator has to act on were missing from "what an existing
config stops being able to do", each new since main and each visible to a
session that worked before:

- a configured ssh_key now withdraws the forwarded agent from the container
  entirely, so a session that used the key for the forge and the agent for a
  second remote loses the second half;
- an empty ssh_key file counts as no key, so a pushing launch with one and no
  agent is refused where it used to start;
- under the github forge, gh api is held to this repository, which an agent
  using it for anything else will notice.

Two entries were wrong rather than incomplete. #386 did not fail every
launch: it failed when the ranges came from a cache file, which is how CI
supplies them, and an ordinary launch's URL always has a host. #391 is about
what dejima gives you without the sister projects around it, not without a
forge.

The clamp covering only the bridge gateway moves to a "Known limitations"
section. It is a thing this release does not do rather than something that
stops working, and mixing the two makes the list an operator must act on
longer than it is. #379 and #385 are test-suite reliability and fold into one
line: the changelog's own rule is that entries name what an operator would
notice.

And one clause in the threat model, which this release's own clamp change
made checkable: no port on the host is open *unless you named one*.
2026-10-12 23:21:33 -07:00
branderson 2bee0e8b70 Have --version use the version library, and name a same-day release
Two review nits.

--version reimplemented dejima_version inline because the library was not
loaded at that point. It is pure functions with no config and nothing behind
it, so sourcing just that one costs nothing and leaves one implementation
rather than a copy that can drift from what the banner and the update notice
print.

And docs/release.md says what a second release on one day is called, which
the format otherwise had no answer for: a letter, 2026.10.12b. `sort -V`
orders it after the bare date, which is what the update notice compares with.

A letter rather than a fourth dotted number, which was the first choice: four
dot-separated numbers starting with a year and a month read as a private IPv4
address, and the static test that keeps those out of the documentation
refused the example. Writing that collision down tripped the same test, which
is why the paragraph describes the shape instead of showing it.
2026-10-12 23:21:33 -07:00
branderson a822e19fd0 Run the mount guard over the second seed list instead of arguing it away
The pull request's case for the second load was that what a plugin adds
there is a path dejima made this run. True of the one plugin that does it,
and not a property of the mechanism: harness_seed runs again in full, so a
plugin deriving a seed from state prepare_config changed would reach the
mount assembly on that argument alone.

So the rule is applied again, over a list rebuilt after the hook and still
before anything is mounted. Two loops over lists that are already built,
which is cheaper than the argument it replaces.

Driven at the function, since no shipped plugin exercises it - with the
adjacency of the call asserted separately, because the behavioural half
would not notice the call being deleted. The first version of that assertion
used a two-line `grep -F` pattern, which reads as two alternatives and
matched either line, so it passed with the call removed.
2026-10-12 23:21:15 -07:00
branderson 449ba3023d Stop throwing away the stage directory's path before cleanup reads it
My own bug from two commits ago, and the comment on it and the changelog
entry were both false as merged. The block that initialises trap-owned names
to empty runs AFTER the mktemp in straight-line order, so it replaced the
real path with "" and cleanup() removed nothing. Every launch, on every
harness, left a dejima-harness-stage directory in TMPDIR - 372 of them on
this machine from the test runs alone.

The line is gone rather than moved. SANDBOX_SETTINGS_FILE and
SANDBOX_CLAUDE_JSON_FILE are mktemp'd in the same region and deliberately
not in that block, for the reason that applies here too: they are bound
above the trap, so there is no window in which cleanup() could see an
inherited value, and resetting them afterwards is the only way to break
them.

Nothing caught it because nothing looked after exit. A launch test does now,
with its own TMPDIR, for both harnesses - cline is the one that writes a file
into it, so a leak there carries content rather than being an empty
directory - and for the two staging paths that were never broken, which is
what this one should have looked like.
2026-10-12 23:21:14 -07:00
branderson 3cf12910f3 Configure an isolated cline session from the staging directory
Fixes #393, so --isolated is supported on every shipped harness and the
release does not have to carry an exception.

Cline reads its provider configuration from a JSON file inside its own data
directory, and dejima wrote that file onto the host and let the state mount
carry it in. An isolated launch has no state mount - so the file went to a
host directory keyed on the container-local working directory, shared by
every isolated cline launch, and was then not mounted. Cline started with no
model and fell back to its own hosted endpoint, which the firewall was never
asked to allow.

It is written into the per-run staging directory now and seeded in, so
entrypoint.sh installs a copy the session owns. Two things follow. It arrives
without a state mount, which is what makes the mode work for this harness.
And nothing the session writes to it reaches the host, which is what the
comment above that function already wanted - "a repointing cannot persist
into the next session" - and which writing into a mounted directory only
approximated by rewriting the file each launch.

An ordinary launch ends up where it did: that one has a state mount, so the
seeded copy lands inside it.

The existing cline tests read the file out of the state directory, which is
no longer where it is; their fixture passes the staging directory core now
passes and reads it from there. The test that asserted the refusal asserts
the configuration arrives and the host state directory is untouched, with an
ordinary launch beside it so neither can pass by nothing being mounted.
2026-10-12 19:24:44 -07:00
branderson 54f0dac269 Give the hook that produces per-run files somewhere to put them
harness_prepare_config exists to produce what a launch needs and throws away
afterwards, and it had exactly one place to write: the single settings-override
file core passes it. A harness whose configuration is a file inside its own
state directory had nowhere, so it wrote onto the host and relied on the state
mount to carry it in.

So it gets a per-run directory as well, made beside that file and removed by
cleanup() with every other staging path.

And core loads the seed list twice. The early load is what the mount guard
runs against - a guard after the mount it would refuse is not a guard - and
the second, after prepare_config, is what the mount assembly reads: a hook
whose job is to make a file has to run before the list that mounts it. The
loader resets rather than appends, so a plugin emitting the same entries in
both passes gets them once. What a plugin adds in the second pass is a path
dejima made this run rather than one an operator could have pointed a mount
at, which is why the guard's early pass is still the whole guard.
2026-10-12 19:24:44 -07:00
branderson 075fcd1def Test the version, the update notice and the changelog's own claims
One commit for three because the tests are one block in test/static.bats,
and the alternative was scattering them across the file so the history could
have them separately.

What they hold. The release comes from git and not from a file, driven over
every state a checkout can be in - untagged, on a tag, past a tag, a
non-release tag present, and no git at all - because "unreleased" versus a
date versus a date+N are three different things to tell an operator. The
notice names both releases and the changelog, reads the remote's tags through
the same hardened invocation as the branch tip, and asks for them only when
there is news. The changelog says which version is supported and points at
the binding statement, and has an Unreleased heading to write the next entry
into.

There is deliberately no sweep for words like "backport". The first version
of one failed on the changelog's own sentence saying there are no backports:
a pattern cannot tell a promise from its denial, and a test that matches the
shape of prose rather than its claim is one that gets edited until it passes.
2026-10-12 17:14:58 -07:00
branderson d111bf0aed Write down what each release changed, and how to cut one
CHANGELOG.md, newest first, for one question: should I update, and what will
I be getting. Entries name what an operator would notice; the pull requests
carry the detail and are linked.

The risk a changelog introduces is the expectation that comes with it - a
list of releases reads as a list of things one may be running - so the file
says at the top that no release here is supported and points at SECURITY.md,
which is where that is binding. SECURITY.md's own "Supported versions"
section now says where to see what changed, since that is the question
someone arrives there with.

The first entry is this release: 119 commits, PRs #360-#391 and #399. It has
a section for what an existing config stops being able to do, because two
things in this one refuse an arrangement that worked and one of them - a
host-resident Loki configured by name - fails silently. The clamp covering
only the bridge gateway is named there too, with the issue.

`## Unreleased` is where the next entry goes before its date is known, since
the date is the version and cannot be written before the merge is made.
docs/release.md §6 is the two steps that produce a release - tag `main`,
rename the heading - both easy to forget because nothing fails without them.
2026-10-12 17:14:58 -07:00
branderson 6aacfbd8dd Name the release an outdated checkout is missing, and the changelog
The update notice said a branch has commits this checkout does not, which
answers "is there something newer" and nothing else. An operator deciding
whether to update wants to know how far behind they are and what they would
be getting - which is what releases are for, so the notice names both sides
and points at CHANGELOG.md rather than leaving the file to be discovered.

Both sides even when they name the same release: a checkout on 2026.10.12
whose remote is also on 2026.10.12 is behind by commits not yet released, and
saying that is more honest than naming one version.

The remote's newest release comes from its tags, through the same hardened
invocation as the branch tip and for the same reason - the checkout's own git
config must not name a command this runs. Release-shaped tags only, and asked
for only when there is something to report, so an up-to-date checkout still
pays one request rather than two. The cached answer grew a third field; a
cache written before it existed reads as an unknown release, which is what a
failed lookup also gives.
2026-10-12 17:14:58 -07:00
branderson 53a448bc43 Say which release a checkout is on
A release is a date - 2026.10.12 - and the git tag of that name on `main` is
where it lives. Nothing in the tree records it, which is the point: a VERSION
file says what was released and goes on saying it while a checkout is pulled
past that release, and the one moment the answer matters is the moment it
would be wrong. `git describe` cannot drift from history, and it tells the
truth about a checkout between releases: 2026.10.12+37 is on no release.

A date rather than a version number because the useful fact about a release
is how old it is, and because MAJOR.MINOR would be a promise about
compatibility and patch lines that SECURITY.md explicitly does not make. The
tip of main is the only supported version; a tag is a record of what shipped.

Two surfaces: `dejima --version`, answerable with no config read for the same
reason --help is, and the release beside the wordmark on the startup banner,
where an operator is already looking. The --help line for it is earned by
covering both flags on one.

Four states, each a different thing to tell someone: the release, the release
plus a distance, `unreleased` for a checkout with no release tag yet, and
`unknown` for no git at all. Only tags that look like a release count, so a
marker pushed for something else cannot become the answer.

Tests for this and the two commits after it arrive together, two commits
along: all three sit in one block of test/static.bats, and splitting them
would mean scattering them across the file for the sake of the history.
2026-10-12 17:14:58 -07:00
branderson 6bf9a1b60f Decide which host candidates are the host in the filter, not the launcher
The round-1 fix compared a configured destination's address against
EGRESS_HOST_ADDR in the launcher. That looked equivalent to deciding
residency and was not: the variable is the operator's override for when the
policy's own derivation from the default route is wrong, so it is normally
unset - and with it unset nothing was ever derived.

Which means the clamp going first, which is the fix this branch exists for,
refused a host-resident Loki or self-served model at the gateway address on
any ordinary install. The model fails loudly, because the session cannot
reach it. The Loki case fails silently: the shipper is best-effort, so
network logging just stops.

Split the decision instead, because neither process knows both halves. The
launcher offers candidates it is sure of - an explicit port on a literal
address - as EGRESS_HOST_CANDIDATES. The policy, which works the host's
address out from its default route, keeps the port of each candidate whose
address is the host's. Neither resolves a name, which is what both sides were
already refusing to do.

The folding runs after discovery and before validation, so a candidate's
port is held to the same check as an operator's rather than trusted for
having crossed a process boundary - with its own refusal naming the variable
it came from.

What is still a behaviour change on upgrade: a host-resident service
configured by NAME needs EGRESS_HOST_PORTS set by hand, since a name is a
destination neither process will resolve to decide what to open. It fails
closed. docs/release.md now asks the release pull request to name that class
of change, because failing closed is quiet, and an observability path is the
worst case.

Also the review's nit: the summary line is why nothing can reach a
host-resident service, the session and the shipper beside it.
2026-10-12 01:57:33 -07:00
branderson cbdac96133 Drive the credential-mount check through a launch, not over the source
The P1 test greps the flattened `elif` for five substrings, which is better
than the line-number comparison it replaced and would still pass on a
restructuring that tested the same words in a different combination.

So it is driven now, the way the release pass reproduced it: a backend whose
credential kind is none - cline with a self-served endpoint, since no API
claude-code speaks has one - with a real-looking credential sitting on the
host, and the docker argv asserted to carry no mount of it. With the
arrangement the mount IS for asserted the other way, so the test cannot pass
by nothing ever being mounted.

The grep stays. It says which conditions the branch tests, which argv
cannot.
2026-10-11 18:57:02 -07:00
branderson 0eb418266c Derive a host port only where the launcher can be sure of the host
The N1 fix introduced this, and it is the sharper half of what that fix was
about. The derivation added the port of every configured destination
"whether or not it turns out to be host-resident" - so a remote Loki at
https://loki.example.com, which has no port of its own, added 443 and opened
the host's own 443 to every session. On a box following the reverse-proxy
arrangement in docs/remote-access.md that is the proxy in front of the web
terminal: the thing the clamp exists to keep out of reach, opened by the
change that was meant to close it.

Three conditions now, and each is load-bearing because what the list opens
is a port on the host rather than on the destination: an explicit `:port`,
a literal address rather than a name, and that address being the one
EGRESS_HOST_ADDR names. Outside that nothing is derived, so a host-resident
service configured by name needs EGRESS_HOST_PORTS set by hand - which the
docs now say, where they claimed the launcher handled it.

Three URL shapes also produced a value that is not a port, which the policy
then refused by naming a variable the operator never set: userinfo whose
password starts with a digit, a query with no path, and a bracketed IPv6
address. Userinfo, query and fragment are stripped before the split, an
unbracketed IPv6 address is read as having no port because every colon in
it is the address, and a port has to be all digits.
2026-10-11 18:57:02 -07:00
branderson a53936b10c Fix the documentation the release pass found wrong
Section 4 of the pass, each one a claim the code contradicts.

Security properties stated too strongly. `[identity] ssh_key` is not a
section that exists - it is `[identity.<name>]` or the deprecated
`[core] ssh_key`. The isolated table said the forwarded agent is "not
mounted" flatly, where it is withdrawn when the workflow does not push OR a
usable key is configured - so the docs' own example, a pushing workflow on a
host with no key, forwards it. "The table above is the whole of what
crosses" was false: LOKI_URL still reaches the session, and the model
credential follows its own rules whatever the mode. And
`a session sees its own project's history and no other's` is true of the
per-project state and not of the four shared read-write directories this
release's own comment calls shared (#380).

Claims about the code's mechanism. The README listed "the Docker host" as
allowed, where it now opens the ports a host-resident service was
configured on and no others. providers.md described merge-guard.sh by a
regex mechanism it no longer uses, and said an empty DEJIMA_MERGE_BLOCK
makes it inert - it stays active for the disclosure and for repository
scoping, neither of which is the workflow's to turn off.
EGRESS_HOST_PORTS is set in the launching environment, not "on the filter
container", and the launcher now contributes to it.
configuration.md claimed nothing on the command line turns a setting back
on, which is true of the `--no-*` family and not of `--ro`, `--rw`,
`--include`, `--allow-github-push` or a `--forge` the config did not name.

Things that were simply missing. `--isolated` was not in the README at all,
and the refusal of a project path was undocumented. An empty `ssh_key` file
counting as no key is how a launch keeps the setting and forwards an agent
instead, which nothing said.

Smaller: a sentence orphaned by the multi-domain insertion now names its
two subjects, ci.md loses a container count that drifts, and
development.md's merge-guard row describes what the hook does rather than
what it was named for.
2026-10-11 17:30:14 -07:00
branderson 505c6f0623 Test the host clamp's ordering and the ports the launcher derives
The two tests for the host-port clamp change earlier in this branch. They
are a commit of their own rather than part of it because the hunk selection
that built these commits left them behind, and moving them back would mean
rewriting six commits that are otherwise correct.

What they assert: that the clamp is emitted before the open class and after
the forge clamp, which is what decides the matter since nft takes the first
match - and that the launcher derives a port from each configured
host-resident URL, with the derivation itself driven over the shapes it has
to read. A wrong port is the same as no port once the clamp is first.
2026-10-11 17:30:14 -07:00
branderson 7776aa3298 Remove an isolated session's share when it is still empty
From the release pass's lows. The share is made before most of the launch's
refusals, so a refused isolated launch left an empty directory under the
isolated root for an operator to find and wonder about - and a session that
handed nothing back left one too, in a root that grows by one per launch
with nothing to prune it (#389).

`rmdir` in the exit trap, which only removes an empty directory, so the
check is the removal. A project's share is untouched: that one is expected
to outlive its sessions.

The two tests that asserted the directory still exists afterwards now assert
the opposite, and one of them drives a stub that writes into the share -
which is what shows the launch creates and mounts it rather than only
computing a path, the thing the old assertion was really standing in for.

Refs #389
2026-10-11 17:29:44 -07:00
branderson 2a9606d873 Refuse --isolated written after the project path
From the release pass's lows, fixed here rather than ticketed because of
which way it fails. The flag loop stops at the first positional, so
`dejima ~/proj --isolated` passed the flag to the harness and mounted the
project read-write: the operator asked for the one thing that silently did
not happen, and nothing in the output said so.

Refused unless `--` says the rest is deliberately the harness's, which is
the one reading where passing it through is what was meant.
2026-10-11 17:29:43 -07:00
branderson 885423a0a8 Warn about every guarded store inside the workspace, not three of six
P2 from the release pass, reframed. The finding was that the workspace mount
bypasses the guarded-store refusal. It does - and that is a deliberate
decision with three tests pinning it: warn_if_inside_workspace tells the
operator, with each store's own consequence, rather than refusing a launch
against a directory that happens to contain one. Refusing would make
`dejima ~` impossible on a host whose config lives under it, which is a
policy change for the operator to make rather than a release fix.

What was not deliberate is which stores it covered. Three were warned about
and six were guarded, so a path dejima refuses to mount through [mounts] or
--ro was mounted without a word when it sat inside the project: the epitaph
store, every project's share root, and the isolated share root this release
added. Each has its own consequence and now says it - the epitaph one is the
sharpest, since that store exists to not be readable by a later session.

A static test holds the two lists in step by name, in both directions, so
the next store added to one is added to the other. The generic refusal text
said "even read-only" for a mount that is read-write by definition; it says
"mounted at all", which is true of every caller.

Whether the workspace should be refused rather than warned about is a real
question and not this one: #398.

Refs #398
2026-10-11 17:29:31 -07:00
branderson bec542ca48 Mount the real Claude credential only when it is this backend's own
P1 from the release pass, older than this release and worth fixing before it
because docs/security.md says the opposite.

The proxy branch requires a credential kind other than none, the proxy on,
and the credential usable. Everything else fell through to
`elif [ -f ~/.claude/.credentials.json ]`, which mounted the host file
read-write. That covers a backend needing no credential at all - a model you
serve yourself, the arrangement the docs call the one with nothing in the
container to steal - and any backend whose own credential file is elsewhere
and missing.

The branch names every condition that makes this the documented downgrade:
there is a backend, its credential is not none, the proxy is off for it, and
its credential file IS this file. The last is what distinguishes "the
operator turned the proxy off for the credential in this file" from "the
proxy branch did not take, so mount whatever is lying there".

The unusable-with-proxy-on case cannot reach it either way: that is already
a refusal, by design, a few lines above.
2026-10-11 17:27:43 -07:00
branderson 402fac5d5d Refuse an isolated cline launch rather than writing its config to the host
N4 from the release pass. harness_prepare_config writes providers.json into
the state directory, and the session reads it through the state mount - which
--isolated skips, because that hook also creates a slug directory on the
host. So an isolated cline launch wrote a file to the host, keyed on the
container-local working directory and shared by every isolated cline launch,
did not mount it, and started Cline with no model configured. That last part
is the failure providers.json's own updatedAt comment describes: Cline falls
back to its hosted endpoint, which the firewall was never asked to allow.

Refused in the plugin, before anything is created, because the harness is
what knows it has no way in. #393 is the way in - a per-run mount, the same
shape as the settings override.

Refs #393
2026-10-11 17:27:43 -07:00
branderson 2d462ea53c Make every reader of an isolated launch's settings give the same answer
N3 and N6 from the release pass, together because both are the same block
and splitting it is what a reader would have to undo to see either.

N3: `_override_set core.forge` is true for any `--forge`, `none` included -
so `dejima --isolated --forge none`, the launch that said most clearly that
it wanted no forge access, was the one handed core's GitHub credential. The
GitHub token now follows the resolved forge's capabilities instead: a forge
that can do nothing has no use for a credential, which is the actual rule,
and it names no forge, which core may not.

N6: the banner worked the epitaph out from [epitaph] enabled alone and
printed "epitaph on" a few lines above the isolated notice saying it was off
- and nothing was being kept either way, because the save was gated on the
mode instead. --print-config had the same split, for both keys. Both are
overrides now, where the flag is read, so the banner, --print-config, the
epitaph's save and the shipper's start all read one answer. The two mode
checks those last two carried are gone. The audit keeps its own, having no
key to override.

The test called "reaches no forge unless the same command names one" never
named a forge - only a workflow. It covers both, in both orders, with the
other setting asserted to stay at the mode's default.
2026-10-11 17:27:23 -07:00
branderson 8bea036701 Narrow the projects map on the proxy-off path too, not just the first one
N2 from the release pass. With the proxy off for a backend whose credential
IS ~/.claude.json, the staged copy was re-seeded with `cp` so the credential
would survive - and that put back the whole `projects` map #381 narrowed:
every project's path, a sample of each one's filenames, and what each had
cost. It ran after the credential scan had read the narrowed copy, so
nothing ever looked at what came back.

The narrowing is one function with the credential as a parameter now, so
there is no second expression to forget. The two call sites differ in that
one respect and nothing else.

docs/security.md said the copy was "seeded from the real file" with only the
credential removed, in two places. It describes both halves now, including
that this path keeps the credential and narrows the map exactly as the
other does.
2026-10-11 17:27:02 -07:00
branderson 8b9c94bfc5 Clamp the host's own address before the rules that would widen it
N1 from the release pass. The clamp said the right thing and never saw the
packets: the open class permits on every port, and the host's address lands
in it by several ordinary routes - a host-resident LOKI_URL, a self-served
model's base_url, `dejima-allow <gateway>`. The permit was emitted first, so
the host was open on every port it publishes, sshd and the remote-access
front door included, while docs/security.md said configuring one host
service is how that service becomes reachable.

Moved ahead of the open class and still behind the forge clamp, so a
host-resident forge is read as a forge rather than as the host.

On its own that would refuse what the clamp is for. EGRESS_HOST_PORTS has no
config key - it comes from the environment the operator launches from - so a
host-resident Loki or self-served model worked only because the open permit
answered first. The launcher now adds the port of each host-resident
destination it configured, which is what makes "that service, not the whole
host" true rather than aspirational. A port is added whether or not the
destination turns out to be host-resident, because deciding that means
resolving a name and the launcher does not.

One bug found writing the derivation, worth knowing: in a single `local`, a
later initialiser does not see an earlier name from the same statement, so
every URL read as its scheme's default port. Declared separately now.
2026-10-11 17:26:29 -07:00
branderson 43ac721355 Merge pull request 'Say what dejima on its own gives you' (#391) from standalone-posture into development
Reviewed-on: #391
Reviewed-by: branderson <brad@branderson.io>
2026-10-11 03:32:00 -07:00
branderson 60d0b8ec97 Merge pull request 'Run a session with no past and no future: dejima --isolated' (#390) from isolated-mode into development
Reviewed-on: #390
Reviewed-by: branderson <brad@branderson.io>
2026-10-11 03:29:21 -07:00
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 dd9fc844df Link the security notes' laptop caveat to what still holds there
Refs #387
2026-10-10 20:21:03 -07:00
branderson b41097c253 Point the README's stack table at the standalone answer
Refs #387
2026-10-10 20:07:14 -07:00
branderson f9f7a3f60f Say what dejima on its own gives you, and what to do about the rest
Refs #387
2026-10-10 20:07:09 -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 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
35 changed files with 3436 additions and 172 deletions
+5 -1
View File
@@ -171,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,
+166
View File
@@ -0,0 +1,166 @@
# Changelog
[← dejima README](README.md)
What each release changed, newest first, for one question: **should I update,
and what will I be getting.** `dejima --version` says which release the
checkout you are running is on.
A release is a date - `2026.10.13` - and the git tag of that name on `main` is
where it lives. A date rather than a version number because the useful fact
about a release is how old it is, and because `MAJOR.MINOR` would be a promise
about compatibility and patch lines that this project does not make.
**No release here is supported.** The tip of `main` is the only supported
version, and that is not a formality: there are no maintenance branches, no
backports and no security releases for older tags. A tag is a record of what
shipped so you can tell what you are missing - not a version to stay on. See
[SECURITY.md](SECURITY.md).
Entries name what an operator would notice. The full detail is in the pull
requests, which are linked.
## Unreleased
Nothing yet. The release pull request that merges `development` into `main`
replaces this heading with the date it merges, and tags `main` with the same
date.
## 2026.10.13
The first release with a version number. Everything before it is `unreleased`,
and a checkout from before this tag reports itself that way.
144 commits, PRs #360 to #399.
### New
- **`dejima --isolated`** runs a session with nothing before it and nothing
after it: no project mounted, no memory or transcript across either
boundary, no forge credential unless the same command names a forge, and a
`~/shared` made fresh for that session alone. For a disposable experiment,
or anything that should not be able to read what an earlier session did.
Supported on every shipped harness.
([#390](https://gitbox.branderson.io/dejima/dejima/pulls/390),
[docs](docs/configuration.md#isolated-sessions))
- **`dejima-allow` takes up to twenty domains at once**, printed back one per
line and confirmed before any is added. One domain is unchanged.
([#383](https://gitbox.branderson.io/dejima/dejima/pulls/383))
- **`dejima --version`**, a version on the startup banner, and an update
notice that names the release you would be getting rather than only a commit
count.
### Tightened
Each of these refuses or withholds something a previous release allowed.
- **The host entry is clamped to the ports it was told to open**, and the
clamp is now matched *before* the wider rules. Configuring one host-resident
service opened every port the host publishes, including sshd and the
remote-access front door.
([#371](https://gitbox.branderson.io/dejima/dejima/pulls/371),
[#399](https://gitbox.branderson.io/dejima/dejima/pulls/399))
- **The staged `~/.claude.json` carries the launching project's entry and no
other.** A session was handed the name of every project on the host, a
sample of each one's filenames, and what each had cost.
([#381](https://gitbox.branderson.io/dejima/dejima/pulls/381))
- **The real model credential is mounted only when it is this backend's own
credential and the proxy is off for it.** A backend needing no credential at
all - a model you serve yourself - was handed the real subscription login.
([#399](https://gitbox.branderson.io/dejima/dejima/pulls/399))
- **git's `--config-env` no longer walks past the push guard.** It sets a
pushurl the way `-c` does, and in its separated spelling the guard read its
value as the subcommand - so a push checked against neither the host
allowlist nor the blocked branch.
([#399](https://gitbox.branderson.io/dejima/dejima/pulls/399))
- **The push and merge guards are one policy, read out of argv**, closing
several spellings that walked past them.
([#372](https://gitbox.branderson.io/dejima/dejima/pulls/372),
[#374](https://gitbox.branderson.io/dejima/dejima/pulls/374))
- **Every store dejima keeps out of a session's reach is warned about** when
it sits inside the mounted project, not three of the six.
([#399](https://gitbox.branderson.io/dejima/dejima/pulls/399))
### What an existing config stops being able to do
Read this part before updating. All of these fail closed, and the first fails
quietly.
- **A host-resident Loki or self-served model configured by NAME needs
`EGRESS_HOST_PORTS` set to its port.** The clamp is matched before the open
class now, so an address permitted by name no longer reaches the host on
every port. A literal address with an explicit port is handled for you. The
model fails loudly - the session cannot reach it - but **the log shipper
fails silently**: network logging simply stops. The policy's own summary
line, `host <addr> on ports none`, is where that shows up.
- **A configured `ssh_key` now withdraws your forwarded ssh-agent from the
container entirely.** Choosing a dedicated key has to mean your personal
identities are not in there, rather than merely not the default. If a
session used the key for the forge and the agent for anything else - a
second remote, a private submodule - the second half stops working.
([#375](https://gitbox.branderson.io/dejima/dejima/pulls/375))
- **An empty `ssh_key` file now counts as no key at all.** A pushing workflow
with an empty key file and no agent is refused where it used to start and
fail at the push. With an agent running it starts, and now says that the
forwarded agent is still signing - which is what configuring a key is meant
to prevent.
([#375](https://gitbox.branderson.io/dejima/dejima/pulls/375))
- **Under the github forge, `gh api` is held to this repository.** A call
naming no endpoint under `repos/<this repo>/` is refused - `gh api user`,
`rate_limit`, an org endpoint - as are graphql mutations and reading a body
from a file (`-F x=@file`, `--input`). An agent that used `gh api` for
anything outside the repository will notice.
([#372](https://gitbox.branderson.io/dejima/dejima/pulls/372),
[#373](https://gitbox.branderson.io/dejima/dejima/pulls/373))
### Known limitations
Not changes - things this release does not do, worth knowing before relying
on the ones above.
- **The host clamp covers the bridge gateway only.** The host's other
addresses - its `docker0` address when the run network is a different
bridge, and its LAN address - are not in it, so a service configured at the
host's LAN IP is still permitted on every port by the open class.
([#401](https://gitbox.branderson.io/dejima/dejima/issues/401))
- **The merge block reads subcommands, not API endpoints.** `gh api -X PUT
repos/<owner>/<name>/pulls/<n>/merge` names this repository, so the scoping
check passes and nothing else sees a merge in it.
([#402](https://gitbox.branderson.io/dejima/dejima/issues/402))
### Fixed
- The egress policy's resolver checks had no name to check when the forge
ranges came from a cache file rather than fetched, which is how CI supplies
them - so every container in a CI run failed to come up. An ordinary
launch, whose ranges URL always has a host, was unaffected.
([#386](https://gitbox.branderson.io/dejima/dejima/pulls/386))
- GitHub's `/meta` ranges are fetched once per CI run rather than once per
container, and a rate limit is waited out instead of failing the run.
([#378](https://gitbox.branderson.io/dejima/dejima/pulls/378))
- Test-suite reliability: a self-check retry that could not see its own
failure, deadlines that scale with what a run asked of the machine, and a
teardown race that failed CI intermittently.
([#379](https://gitbox.branderson.io/dejima/dejima/pulls/379),
[#385](https://gitbox.branderson.io/dejima/dejima/pulls/385))
- A harness's per-run configuration is seeded from a staging directory that
goes away with the launch, rather than written onto the host where it will
live. For cline that is what lets `--isolated` work at all; for an ordinary
launch the end state is unchanged.
([#393](https://gitbox.branderson.io/dejima/dejima/issues/393))
### Documentation
Two review passes over the whole tree corrected documents that stated the
opposite of the code - including what Ctrl-Z does, what the sandbox tells a
session about a hung connection, and what dejima gives you on its own, without
any of the sister projects around it.
([#370](https://gitbox.branderson.io/dejima/dejima/pulls/370),
[#377](https://gitbox.branderson.io/dejima/dejima/pulls/377),
[#391](https://gitbox.branderson.io/dejima/dejima/pulls/391))
### Rollback
This release adds no config key, so the previous `main` accepts any config
this one accepts. Rolling back restores the behaviour the "stops being able
to" section describes, including the host entry being reachable on every port.
+24 -1
View File
@@ -151,6 +151,9 @@ what each one buys, what goes wrong without it, and where each one's boundary
ends. None of them is required to run dejima; what they change is what the
layer below the container costs, and whether anyone finds out.
**→ [Using dejima on its own](docs/threat-model.md#using-dejima-on-its-own)** -
what holds without them, what does not, and what to do about it.
**→ [Why a container, and not a microVM?](docs/threat-model.md#why-a-container-and-not-a-microvm)** -
the most common question about this design, and why the effort went into the
layers below the container rather than into a harder one.
@@ -247,6 +250,7 @@ Applications, scopes `write:repository` and `write:issue`) into
```sh
dejima # sandbox the current directory
dejima ~/repos/some-project
dejima --isolated # no project at all - see below
```
Point it at a project, ask for a change, and let the session work. What happens
@@ -295,6 +299,23 @@ 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.
### A session with no project
`dejima --isolated` is the other direction: nothing of your machine is
mounted, no memory or transcript crosses either boundary, no forge credential
unless the same command names a forge, and the one thing that outlives the
session is a `~/shared` made fresh for it. Run it from anywhere - the
directory you are in is not used, and naming one is refused rather than
ignored, so a launch cannot look like it is working on a project it cannot
see.
It narrows what a session can read of the host and what credential it carries.
It is not a stronger sandbox: the firewall, the credential boundary and the
capability drops are what they are for every other session.
**→ [docs/configuration.md](docs/configuration.md#isolated-sessions)** - the
table of what differs, and what the mode gives up.
**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
@@ -409,7 +430,8 @@ broken tool. IPv6 is refused outright rather than allowlisted: a sandbox has no
IPv6 egress at all, and one table states that in the same transaction as
everything else, so there is no moment where the family is partly filtered.
The allowlist covers what the sandbox needs to function - DNS, the Docker host,
The allowlist covers what the sandbox needs to function - DNS, the Docker host
on the ports a host-resident service was configured on and no others,
GitHub on port 443 for read access whichever forge is selected, whatever the
selected forge needs, the harness's own update endpoints, and the selected
backend's API endpoint when the credential proxy is off - plus whatever a
@@ -703,6 +725,7 @@ and how to run them.
| [ci.md](docs/ci.md) | What push access grants on your CI host, and how to bound it |
| [remote-access.md](docs/remote-access.md) | Picking and verifying a bind address, and the SSH and web front doors |
| [development.md](docs/development.md) | The file map, the test suites, running them |
| [CHANGELOG.md](CHANGELOG.md) | What each release changed, for deciding whether to update |
| [SECURITY.md](SECURITY.md) | What dejima claims, where to report a vulnerability, and what is promised in return |
| [ATTRIBUTION.md](ATTRIBUTION.md) | Who holds the dejima marks and what may be done with them |
+4
View File
@@ -75,6 +75,10 @@ Whoever deploys this is responsible for the result.
The tip of `main`, and nothing else. There are no maintenance branches, no
backports and no security releases for older tags or commits.
Releases are tagged by date so you can tell what you are running -
`dejima --version` - and [CHANGELOG.md](CHANGELOG.md) says what each one
changed. A tag is a record of what shipped, not a version anyone may stay on.
## No warranty
The software is provided without warranty of any kind. See sections 15 and 16
+652 -50
View File
@@ -20,6 +20,28 @@ set -euo pipefail
# so a mistyped invocation still fails a script. That split is the whole of
# what #64 asked for: bare invocation keeps its terse line, --help explains.
case "${1:-}" in
--version)
# Before any config is read, like --help: the moment someone asks
# which release they are on is often the moment something else is
# wrong. The version library and nothing else - it is pure functions
# with no config and no other library behind it, so there is one
# implementation of this rather than an inline copy that can drift
# from the one the banner and the update notice use.
_v_dir="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
# shellcheck source=dejima-version-lib.sh
source "$_v_dir/dejima-version-lib.sh"
_v_root="$(cd "$_v_dir/.." && pwd)"
_v="$(dejima_version "$_v_root")"
case "$_v" in
unknown) echo "dejima unknown (not a git checkout)" ;;
unreleased) echo "dejima unreleased (no release tag in this checkout)" ;;
*+*) echo "dejima $_v (${_v#*+} commits past ${_v%%+*})" ;;
*) echo "dejima $_v" ;;
esac
echo ""
echo "What each release changed: $_v_root/CHANGELOG.md"
echo "Only the tip of main is supported - see SECURITY.md."
exit 0 ;;
-h|--help)
cat <<'DEJIMA_HELP'
dejima - run an agent harness in a container that cannot reach your machine
@@ -50,12 +72,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
-h, --help this message; --version prints this release
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
@@ -80,6 +102,8 @@ source "$SCRIPT_DIR/dejima-color-lib.sh"
source "$SCRIPT_DIR/dejima-slug-lib.sh"
# shellcheck source=dejima-harness-lib.sh
source "$SCRIPT_DIR/dejima-harness-lib.sh"
# shellcheck source=dejima-version-lib.sh
source "$SCRIPT_DIR/dejima-version-lib.sh"
# fd 2: everything this script says for itself goes to stderr, so the
# session's own stdout stays exactly what the container wrote.
color_init 2
@@ -147,6 +171,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 +273,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 +307,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 +340,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.
#
# And the two things that are off because nothing would read what they
# produce: an epitaph and a conversation log are both read out of a
# transcript on the host, and this mode mounts none. Set as overrides rather
# than checked at each of the four places that would otherwise ask - the
# banner, --print-config, the shipper's start and the epitaph's save - so
# that every one of them gives the same answer.
if [ "$DEJIMA_ISOLATED" = 1 ]; then
_override_set core.forge || _set_override core.forge none
_override_set core.workflow || _set_override core.workflow local-only
_override_set epitaph.enabled || _set_override epitaph.enabled off
_override_set logging.conversation || _set_override logging.conversation off
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 +669,55 @@ 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
# The flag loop stops at the first positional, so anything after the path
# is the harness's - including `--isolated`, which was then passed
# through as a harness argument while the project was mounted read-write.
# An operator typing `dejima ~/proj --isolated` has asked for the one
# thing that silently did not happen, so it is refused unless `--` says
# the rest is deliberately not dejima's.
if [ "$DEJIMA_ARGS_ONLY" = 0 ]; then
for _arg in "$@"; do
[ "$_arg" = "--" ] && break
[ "$_arg" = "--isolated" ] || continue
echo "--isolated after the project directory would be passed to the" >&2
echo "harness, and this launch would mount $WORKSPACE_DIR read-write." >&2
echo "" >&2
echo "Put it before the path, or rather: --isolated takes no path at" >&2
echo "all. If the harness is meant to receive it, separate it with --." >&2
exit 1
done
unset _arg
fi
WORKSPACE_DIR="$(cd "$WORKSPACE_DIR" && pwd)"
if [ "${1:-}" = "--" ]; then
shift
fi
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 +725,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 +811,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[@]}"})"
@@ -716,6 +907,34 @@ _harness_host_config() {
}
_harness_host_config
# The read-write mounts against the harness's host paths, once more, for a
# list rebuilt after harness_prepare_config. The pass that runs with the rest
# of the mount validation is the guard; this one holds the same rule over
# entries that did not exist when it ran.
#
# Only the read-write lists: a read-only mount over a harness's own config is
# refused on its own path further up, and that list does not change here.
_refuse_rw_over_harness_host_config() {
local rw rw_real guarded guarded_real
HARNESS_HOST_CONFIG=()
_harness_host_config
for rw in ${RW_MOUNT_PATHS[@]+"${RW_MOUNT_PATHS[@]}"}; do
rw_real="$(readlink -m "$rw")"
for guarded in ${HARNESS_HOST_CONFIG[@]+"${HARNESS_HOST_CONFIG[@]}"}; do
guarded_real="$(readlink -m "$guarded")"
_paths_overlap "$rw_real" "$guarded_real" || continue
echo "Refusing writable mount: $rw" >&2
echo "It overlaps $guarded_real, which the selected harness reads on the" >&2
echo "host - so a session could rewrite what the next one runs with." >&2
echo "" >&2
echo "This path became the harness's only once its per-run configuration" >&2
echo "was staged, which is why it is refused here rather than with the" >&2
echo "other mounts." >&2
exit 1
done
done
}
# Read-write host mounts. Distinct from [core] share_dir, which is mounted at
# ~/shared - a well-known handoff point the agent is told about by name -
# while these are mounted at their own absolute path, so a path that works on
@@ -778,7 +997,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
@@ -818,13 +1038,13 @@ _refuse_guarded_mount() {
_paths_overlap "$real" "$guarded_real" || continue
echo "Refusing $kind: $path" >&2
echo "It overlaps $guarded_real, which dejima needs out of a session's reach." >&2
echo "Even read-only, that hands the session every project's approvals, the" >&2
echo "record of what earlier sessions were caught doing, or every project's" >&2
echo "handoff files." >&2
echo "Mounted at all, that hands the session every project's approvals, the" >&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=()
@@ -1150,6 +1370,24 @@ warn_if_inside_workspace "audit report store" "$AUDIT_DIR" \
warn_if_inside_workspace "audit baseline store" "$BASELINE_DIR" \
"a session can write its own acknowledgements, suppressing the" \
"findings it would otherwise produce here from every later report."
# The three above were the whole list, and the guarded-mount refusal further
# down covers six. So a path dejima refuses to mount through [mounts] or --ro
# was mounted without a word when it happened to sit inside the workspace -
# not a judgement about which stores matter, just three that were written and
# three that were not. A static test now holds the two lists in step.
warn_if_inside_workspace "epitaph store" "$(config_get epitaph dir)" \
"it holds each earlier session's own account of what it worked out, so" \
"a session can read what every previous one in this project concluded" \
"- the cross-session channel the never-mounted store exists to close -" \
"and can delete them."
warn_if_inside_workspace "share directories of every project" \
"$(share_projects_root "$(config_get core share_dir)")" \
"every project's handoff files can be read and rewritten, which is the" \
"pile the per-project split removed."
warn_if_inside_workspace "share directories of every isolated session" \
"$(isolated_share_root "$(config_get core share_dir)")" \
"the one thing an isolated session leaves behind is readable here, for" \
"every isolated session this host has run."
SANDBOX_SSH_KEY="$(config_get core ssh_key)"
if [ -n "$SANDBOX_SSH_KEY" ] && [ ! -f "$SANDBOX_SSH_KEY" ]; then
@@ -1203,6 +1441,20 @@ if [ "$DEJIMA_WORKFLOW_PUSH" = "on" ] && [ "$have_forwarded_agent" = 0 ] && [ "$
exit 1
fi
# The same empty file with an agent running is not fatal, and is the case a
# dedicated key is chosen to avoid: no key is loaded, so SSH_AUTH_SOCK is not
# blanked below, so the operator's own agent is mounted and signs for the whole
# unattended session. Said out loud, because every other signal an operator has
# - the key is configured, the launch starts, pushes work - agrees with the
# arrangement they asked for.
if [ -n "$SANDBOX_SSH_KEY" ] && [ "$have_usable_key" = 0 ] \
&& [ "$have_forwarded_agent" = 1 ] && [ "$DEJIMA_WORKFLOW_PUSH" = "on" ]; then
echo "Warning: [core] ssh_key names an empty file, so there is no key to load:" \
"$SANDBOX_SSH_KEY" >&2
echo "Your forwarded ssh-agent stays mounted and will sign for this session," \
"which is what configuring a dedicated key is meant to prevent." >&2
fi
# A valid forwarded socket with zero identities loaded forwards fine (the
# container sees a live, working agent) but is useless for git-over-ssh -
# warn now rather than have that surface confusingly deep into a session as
@@ -1248,16 +1500,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
@@ -1286,8 +1555,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
@@ -1304,7 +1579,27 @@ done
# sandboxing back on (#47) - and that a plugin refusing here stops the launch
# rather than starting a session the harness would misread.
SANDBOX_SETTINGS_FILE="$(mktemp "${TMPDIR:-/tmp}/dejima-settings.XXXXXX.json")"
harness_hook harness_prepare_config "$SANDBOX_SETTINGS_FILE" || exit 1
# And a directory for whatever else that hook has to produce per run. The
# settings override is one file and had one path; a harness whose
# configuration is several files, or whose configuration must not persist
# between launches, needs somewhere to put them that goes away with the run.
# Removed by cleanup(), like every other per-run staging path.
SANDBOX_HARNESS_STAGE_DIR="$(mktemp -d "${TMPDIR:-/tmp}/dejima-harness-stage.XXXXXX")"
harness_hook harness_prepare_config "$SANDBOX_SETTINGS_FILE" "$SANDBOX_HARNESS_STAGE_DIR" || exit 1
# The seed list again, now that the hook above has had its chance to add to
# it: a hook whose job is to make a file has to run before the list that
# mounts it. The load further up is what the mount guard ran against - a
# guard after the mount it would refuse is not a guard - and nothing is
# mounted yet here either, so the same rule is applied again below over
# whatever this pass added.
#
# Re-checked rather than argued about. What a plugin adds here is, today, a
# path dejima made this run and nothing an operator could have pointed a
# mount at - but `harness_seed` runs again in full, so a plugin deriving a
# seed from state prepare_config changed would reach the mount assembly on
# that argument alone. The check is two loops over lists already built.
harness_seed_load
_refuse_rw_over_harness_host_config
# ~/.claude.json mixes live app state with config (mcpServers) that cannot be
# split at the mount level, and read-only is not an option: Claude Code does
@@ -1332,15 +1627,49 @@ 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.
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
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
#
# 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.
#
# One function rather than one expression, because there are two call sites:
# this one, and the re-seed further down for a backend whose credential IS
# this file and whose proxy is off. That one differs in a single respect -
# the credential stays - and it reached for `cp`, which put every other
# project's entry back and did it after the credential scan had already read
# the narrowed copy. The difference is a parameter now, so the narrowing
# cannot be lost by taking the other path.
_stage_claude_json() { # <keep-credential: true|false> <destination>
local keep="$1" dest="$2"
if [ ! -f "$HOME/.claude.json" ]; then
echo '{}' > "$dest"
return 0
fi
else
echo '{}' > "$SANDBOX_CLAUDE_JSON_FILE"
# 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.
jq --arg proj "$WORKSPACE_DIR" --argjson keep "$keep" '
(if $keep then . else del(.primaryApiKey) end)
| if (.projects | type) == "object" then
.projects |= with_entries(
select(.key == $proj or (.key | startswith($proj + "/"))))
else . end
' "$HOME/.claude.json" > "$dest"
}
SANDBOX_CLAUDE_JSON_FILE="$(mktemp "${TMPDIR:-/tmp}/dejima-claude-json.XXXXXX.json")"
if ! _stage_claude_json false "$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
fi
SANDBOX_AUDIT_STATE="$(mktemp "${TMPDIR:-/tmp}/dejima-audit.XXXXXX")"
@@ -1387,6 +1716,15 @@ SANDBOX_FAKE_CREDENTIALS_FILE=""
# 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_HARNESS_STAGE_DIR is deliberately absent, like SANDBOX_SETTINGS_FILE
# and SANDBOX_CLAUDE_JSON_FILE: all three are mktemp'd further up, above the
# trap, so there is no window for cleanup() to see an inherited value - and
# resetting them here would run AFTER the assignment and throw the real path
# away. That is what happened to the stage directory, which then leaked once
# per launch while both the comment on it and the changelog said otherwise.
SANDBOX_NVIM_LOCKFILE=""
SANDBOX_DERIVED_DOCKERFILE=""
SANDBOX_DERIVED_CONTEXT=""
@@ -1569,7 +1907,7 @@ cleanup() {
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:-}" "${SANDBOX_CREDENTIAL_PROXY_LOG:-}" || true
rm -rf "${SANDBOX_GITCONFIG_DIR:-}" "${SANDBOX_CREDENTIAL_PROXY_DIR:-}" "${SANDBOX_DERIVED_CONTEXT:-}" || true
rm -rf "${SANDBOX_GITCONFIG_DIR:-}" "${SANDBOX_CREDENTIAL_PROXY_DIR:-}" "${SANDBOX_DERIVED_CONTEXT:-}" "${SANDBOX_HARNESS_STAGE_DIR:-}" || true
rm -f "${DEJIMA_SESSION_FILE:-}" || true
# dejima-notify-watch keys its debounce stamps by run id. Removed here,
# with the registry entry, because this is the only exit both of them are
@@ -1608,6 +1946,29 @@ 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
# And this session's share if it is still empty. `rmdir` rather than a
# test, because only an empty directory can be removed that way and the
# check is then the removal. It covers both a session that handed nothing
# back and a launch refused after the directory was made - every refusal
# that can happen after it, which is most of them, otherwise left one
# behind for an operator to find and wonder about. An ordinary project's
# share is not touched: that one is expected to outlive its sessions.
if [ "$DEJIMA_ISOLATED" = 1 ] && [ -n "${SANDBOX_SHARE_DIR:-}" ]; then
rmdir "$SANDBOX_SHARE_DIR" 2>/dev/null || 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
@@ -1626,6 +1987,12 @@ 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.
#
# An isolated launch reaches this with the key already overridden off, up
# where the flag is read: an epitaph is taken from a transcript on the
# host and that mode mounts none, so there is nothing to keep. Decided
# there rather than here so that the banner and --print-config say the
# same thing this does.
if [ "$(config_get_bool epitaph enabled)" = "1" ]; then
DEJIMA_EPITAPH_NO_SHIP="$NO_LOG" \
"$SCRIPT_DIR/dejima-epitaph" save "$WORKSPACE_DIR" || true
@@ -1644,7 +2011,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
@@ -1713,6 +2087,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.
@@ -1875,6 +2265,36 @@ 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 this session has a forge to reach, which is the operator saying it is
# meant to. Then nothing here is narrowed and the selection decides, exactly
# as for any other launch.
#
# Read off the RESOLVED forge's capabilities rather than off whether a --forge
# flag was passed. `--forge` naming the no-capability forge is an operator
# asking for no forge, and it was counted as naming one - so the launch that
# said most clearly that it wanted no forge access was the one that got the
# credential. Capabilities rather than the forge's name, because core may not
# name a forge, and because "a forge that can do nothing has no use for a
# credential" is the actual rule.
SANDBOX_GH_TOKEN="$GH_TOKEN"
if [ "$DEJIMA_ISOLATED" = 1 ] \
&& [ -z "$(manifest_get forge "$DEJIMA_FORGE_NAME" capabilities)" ]; 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
@@ -2307,13 +2727,20 @@ if [ -n "$DEJIMA_BACKEND_NAME" ]; then
#
# Re-seeded rather than never stripped, so the safe form is what every
# other path gets and this is one explicit step away from it.
#
# Through the same narrowing, with only the credential kept. It was a
# plain `cp`, which also put back the `projects` map #381 narrowed - every
# other project's path, filenames and costs - and did it after the
# credential scan had read the narrowed copy, so nothing ever looked at
# what came back.
if [ "$DEJIMA_BACKEND_CREDENTIAL_KIND" != none ] \
&& [ -n "$DEJIMA_BACKEND_CREDENTIAL_FIELD" ] \
&& ! backend_proxy_enabled "$DEJIMA_BACKEND_NAME" \
&& [ -f "$HOME/.claude.json" ] \
&& [ "$(readlink -m "$DEJIMA_BACKEND_CREDENTIAL_FILE")" \
= "$(readlink -m "$HOME/.claude.json")" ]; then
cp "$HOME/.claude.json" "$SANDBOX_CLAUDE_JSON_FILE"
_stage_claude_json true "$SANDBOX_CLAUDE_JSON_FILE" \
|| { echo "$HOME/.claude.json stopped being readable as JSON." >&2; exit 1; }
echo "[backend.$DEJIMA_BACKEND_NAME] proxy is off, so this session gets your real" >&2
echo "$DEJIMA_BACKEND_CREDENTIAL_FIELD from $HOME/.claude.json." >&2
fi
@@ -2505,6 +2932,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
@@ -2647,7 +3087,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
@@ -2945,11 +3393,28 @@ if [ -n "$DEJIMA_BACKEND_NAME" ] \
# which the EXIT trap removes.
extra_mounts+=(-v "$SANDBOX_FAKE_CREDENTIALS_FILE:$HOME/.claude/.credentials.json")
fi
elif [ -f "$HOME/.claude/.credentials.json" ]; then
elif [ -n "$DEJIMA_BACKEND_NAME" ] \
&& [ "$DEJIMA_BACKEND_CREDENTIAL_KIND" != none ] \
&& ! backend_proxy_enabled "$DEJIMA_BACKEND_NAME" \
&& [ -f "$HOME/.claude/.credentials.json" ] \
&& [ "$(readlink -m "$DEJIMA_BACKEND_CREDENTIAL_FILE")" \
= "$(readlink -m "$HOME/.claude/.credentials.json")" ]; then
# No proxy: the session authenticates with the real credential, so it has
# to be mounted in. Read-write because Claude Code refreshes it in place.
# This is the documented downgrade, not an oversight - see the kill-switch
# message above and docs/security.md.
#
# Each condition names a way this stops being that downgrade and becomes a
# credential handed to a session with no use for it. The branch above is
# the proxy engaging; everything else used to fall through to here, which
# meant a backend needing no credential at all - a model you serve
# yourself, the arrangement docs/security.md calls the one with nothing in
# the container to steal - mounted the real subscription login anyway. So
# did a backend whose own credential file is somewhere else and missing.
#
# The file is compared, not just the kind: this mount is for the case
# where THIS backend's credential IS this file, and the operator turned
# the proxy off for it.
extra_mounts+=(-v "$HOME/.claude/.credentials.json:$HOME/.claude/.credentials.json")
fi
export SANDBOX_ANTHROPIC_BASE_URL SANDBOX_ANTHROPIC_AUTH_TOKEN SANDBOX_CREDENTIAL_PROXY_SOCKET
@@ -3070,7 +3535,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)"
@@ -3157,8 +3632,21 @@ 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.
printf '\n %sdejima%s %s%s%s' \
"$C_BADGE" "$C_RESET" "$C_KEY" "$(basename -- "$WORKSPACE_DIR")" "$C_RESET" >&2
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
# The release this checkout is on, beside the wordmark: "which version am
# I running" is a question an operator should not have to ask, and a
# launch is where they are already looking. Dim, because it is context
# rather than a choice this launch made.
printf '\n %sdejima%s %s%s%s %s%s%s' \
"$C_BADGE" "$C_RESET" "$C_KEY" "$name" "$C_RESET" \
"$C_DIM" "$(dejima_version "$REPO_ROOT")" "$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)}" \
@@ -3171,6 +3659,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
@@ -3225,7 +3728,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
@@ -3377,10 +3884,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.
@@ -3576,10 +4107,74 @@ 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.
# EGRESS_HOST_ADDR comes from the operator's own environment and travels
# through untouched. Marked exported anyway, so that `-e NAME` carries a value
# rather than an empty string on a shell where it was set without being
# exported.
#
# EGRESS_HOST_CANDIDATES is the other half of the clamp, and it is a list of
# candidates rather than of ports because only the filter can decide.
#
# The clamp holds the host's own address to the ports it was told, ahead of
# every wider rule - so a service dejima pointed at the host needs its port
# there to be reachable at all. The launcher knows which destinations it
# configured; it does not know the host's address, which the policy works out
# from the default route inside the container. EGRESS_HOST_ADDR is the
# operator's override for when that derivation is wrong, so it is normally
# unset: comparing against it here meant nothing was ever derived on an
# ordinary install, and a host-resident Loki at the gateway address stopped
# being reachable the moment the clamp went first.
#
# So the launcher passes what it is sure of - a literal address with an
# explicit port - and the policy keeps the port of each candidate whose
# address is the host's. Nothing resolves a name on either side.
#
# A scheme default is not an explicit port: a remote https URL has none, and
# adding 443 for it opened the host's own 443 - which on a box following
# docs/remote-access.md is the reverse proxy in front of the web terminal.
_egress_host_candidate() { # <url> -> addr:port, or nothing
local rest hostport host port
rest="${1#*://}"
# Path, query and fragment, in that order: a URL may carry a query with no
# path at all, and `?x=1` came through as part of the port.
rest="${rest%%/*}"
rest="${rest%%\?*}"
rest="${rest%%#*}"
# Userinfo. A password beginning with a digit read as the port.
hostport="${rest##*@}"
case "$hostport" in
# Bracketed IPv6 with a port, then without. The unbracketed form has
# no port by definition - every colon in it is part of the address -
# which is why `[fe80::1]/` came out as `1]`.
\[*\]:*) host="${hostport%]:*}]"; port="${hostport##*]:}" ;;
\[*\]) return 0 ;;
*:*) host="${hostport%:*}"; port="${hostport##*:}" ;;
*) return 0 ;;
esac
case "$port" in ""|*[!0-9]*) return 0 ;; esac
# A literal IPv4 address and nothing else. A name is a destination the
# launcher cannot place, and the policy compares against an IPv4 address
# because the host entry is one.
case "$host" in
*[!0-9.]*|"") return 0 ;;
esac
printf '%s:%s' "$host" "$port"
}
EGRESS_HOST_CANDIDATES=""
for _hc_url in "$LOKI_URL" \
"$([ -n "$DEJIMA_BACKEND_NAME" ] \
&& config_type "backend.$DEJIMA_BACKEND_NAME.base_url" >/dev/null 2>&1 \
&& config_get "backend.$DEJIMA_BACKEND_NAME" base_url)"; do
[ -n "$_hc_url" ] || continue
_hc="$(_egress_host_candidate "$_hc_url")"
[ -n "$_hc" ] || continue
case " $EGRESS_HOST_CANDIDATES " in
*" $_hc "*) continue ;;
esac
EGRESS_HOST_CANDIDATES="${EGRESS_HOST_CANDIDATES:+$EGRESS_HOST_CANDIDATES }$_hc"
done
unset _hc_url _hc
export EGRESS_HOST_CANDIDATES
export EGRESS_HOST_ADDR EGRESS_HOST_PORTS
filter_env=(
@@ -3598,6 +4193,7 @@ filter_env=(
# could use.
-e EGRESS_HOST_ADDR
-e EGRESS_HOST_PORTS
-e EGRESS_HOST_CANDIDATES # host-resident destinations this launch configured
-e SANDBOX_HARNESS_DOMAINS
-e SANDBOX_BACKEND_PROXIED_DOMAIN
-e SANDBOX_CREDENTIAL_PROXY_SOCKET
@@ -3688,6 +4284,12 @@ 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.
#
# An isolated launch reaches this with the key already overridden off, for
# the reason the epitaph is: the shipper tails a transcript that is on the
# host because the harness's state directory is mounted, and that mode mounts
# none. One override up where the flag is read, rather than a check here that
# the banner would then contradict.
if [ "$(config_get_bool logging conversation)" = "1" ] &&
[ -n "$(config_get logging loki_url)" ]; then
"$SCRIPT_DIR/dejima-conversation-ship" "$WORKSPACE_DIR" &
+8
View File
@@ -495,6 +495,14 @@ _emit_seed_record() {
DEJIMA_SEED+=("$rel"$'\t'"$src")
}
# Called twice by core, and the second call is not a mistake. The first is
# early, with the other two list loads, because the mount guard has to see
# every host path a plugin names before anything is mounted. The second is
# after harness_prepare_config, whose job is to PRODUCE per-run files - a
# hook that makes a file has to run before the list that mounts it.
#
# So this resets rather than appends: the second call rebuilds the list, and a
# plugin that emits the same entries twice gets them once.
harness_seed_load() {
DEJIMA_SEED=()
harness_hook harness_seed
+116 -18
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
@@ -1726,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
}
@@ -1742,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
@@ -1986,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:-}" ;;
@@ -2161,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
@@ -2221,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.
#
+49 -2
View File
@@ -58,6 +58,8 @@ esac
source "$(dirname "$(readlink -f "$0")")/dejima-provider-lib.sh"
# shellcheck source=dejima-color-lib.sh
source "$(dirname "$(readlink -f "$0")")/dejima-color-lib.sh"
# shellcheck source=dejima-version-lib.sh
source "$(dirname "$(readlink -f "$0")")/dejima-version-lib.sh"
REPO_ROOT="$(cd "$(dirname "$(readlink -f "$0")")/.." && pwd)"
@@ -110,6 +112,25 @@ _remote_tip() {
printf '%s' "${out%%$'\t'*}"
}
# The remote's newest release tag, or nothing. Same hardened invocation as
# _remote_tip and for the same reasons; `--tags` with a pattern so a tag that
# is not a release cannot become the answer.
#
# Newest by sort order, which for a date is newest in time - the whole reason
# a release is a date rather than a number somebody increments.
_remote_release() {
local url="$1" out timeout=()
command -v timeout >/dev/null 2>&1 && timeout=(timeout 5)
out="$(env -u GIT_DIR -u GIT_WORK_TREE "${timeout[@]}" \
git -C / \
-c protocol.allow=never \
-c protocol.https.allow=always \
-c protocol.ssh.allow=always \
-c protocol.file.allow=always \
ls-remote --tags --refs "$url" '[0-9]*' 2>/dev/null)" || return 1
printf '%s' "$(printf '%s\n' "$out" | sed -n 's#.*refs/tags/##p' | sort -V | tail -1)"
}
# stderr dropped rather than shown: a config this cannot read is one bin/dejima
# has already refused to start on, and a version check is not the place anyone
# should first hear about it.
@@ -138,11 +159,15 @@ _usable_url "$url" || exit 0
cache="$(_cache_file)"
tip=""
remote_release=""
if [ -f "$cache" ]; then
read -r c_branch c_tip < "$cache" 2>/dev/null || true
# Three fields now; a cache written before the third existed reads as an
# unknown release, which is the same answer a failed lookup gives.
read -r c_branch c_tip c_release < "$cache" 2>/dev/null || true
c_age=$(( $(date +%s) - $(stat -c %Y "$cache" 2>/dev/null || echo 0) ))
if [ "${c_branch:-}" = "$upstream" ] && [ "$c_age" -lt "$CHECK_INTERVAL" ]; then
tip="${c_tip:-}"
case "${c_release:-}" in ""|"-") remote_release="" ;; *) remote_release="$c_release" ;; esac
cached=1
fi
fi
@@ -151,8 +176,14 @@ if [ -z "${cached:-}" ]; then
# A failed lookup is recorded, not just skipped. Without that, a checkout
# with no route to its remote pays the timeout on every single launch.
tip="$(_remote_tip "$url" "$upstream")" || tip=""
# Only when there is something to report: a checkout that is up to date
# exits below without printing, and asking for the tags as well would be
# a second request per launch for an answer nobody reads.
if [ -n "$tip" ] && [ "$tip" != "$head_sha" ]; then
remote_release="$(_remote_release "$url")" || remote_release=""
fi
mkdir -p "$(dirname "$cache")" 2>/dev/null \
&& printf '%s %s\n' "$upstream" "${tip:--}" > "$cache" 2>/dev/null
&& printf '%s %s %s\n' "$upstream" "${tip:--}" "${remote_release:--}" > "$cache" 2>/dev/null
fi
# "-" is a recorded failure; empty is a branch the remote does not have.
@@ -192,6 +223,22 @@ else
printf ' %sdejima: %s/%s has commits this checkout does not.%s\n' \
"$C_WARN" "$_remote_shown" "$_branch_shown" "$C_RESET" >&2
fi
# Which release, when the remote has tags to say so. A commit count answers
# "is there something newer" and nothing else; a release answers "how far
# behind am I, and what would I be getting" - which is the question, and the
# reason the changelog is named right here rather than left to be found.
#
# Both sides printed even when they name the same release: a checkout on
# 2026.10.12 whose remote is also on 2026.10.12 is behind by commits not yet
# released, and saying that is more honest than naming one version.
if [ -n "$remote_release" ]; then
_local_release="$(dejima_version "$REPO_ROOT")"
printf ' %s is on %s%s%s; this checkout is on %s%s%s.\n' \
"$_remote_shown" "$C_KEY" "$(sanitize "$remote_release")" "$C_RESET" \
"$C_KEY" "$_local_release" "$C_RESET" >&2
printf ' What each release changed: %s%s/CHANGELOG.md%s\n' \
"$C_KEY" "$REPO_ROOT" "$C_RESET" >&2
fi
# install.sh is safe to re-run, and a release can change what it links on PATH,
# so pulling is only half the update. Both paths absolute because the operator
# reading this is not necessarily standing in this checkout.
+49
View File
@@ -0,0 +1,49 @@
# Shared library: which release a checkout is on. Sourced by bin/dejima (for
# --version and the startup banner) and bin/dejima-update-check (to say what
# the remote has that this does not). Not on PATH and not executable.
#
# A release is a date - 2026.10.12 - and the git tag on `main` is where it
# lives. Nothing in the tree records it, which is the point: a VERSION file
# says what was released and goes on saying it while a checkout is pulled
# past that release, and the one moment the answer matters is the moment it
# would be wrong. `git describe` cannot drift from history.
#
# A date rather than a version number because the question an operator has is
# how old theirs is, and because MAJOR.MINOR would be a promise about
# compatibility and patch lines that SECURITY.md explicitly does not make:
# the tip of `main` is the only supported version, and a tag is a record of
# what shipped rather than something to stay on.
# The release this checkout is on, as one word.
#
# 2026.10.12 exactly that release
# 2026.10.12+37 thirty-seven commits past it, so on no release
# unreleased a git checkout with no release tag yet
# unknown no git at all, e.g. an unpacked tarball
#
# A second release on one day is 2026.10.12b - see docs/release.md - which
# needs nothing here: the match below accepts it and sort -V orders it after
# the bare date.
#
# Local-only git, so none of the transport configuration dejima-update-check
# has to work around is in the path here.
dejima_version() { # <repo root>
local root="$1" tag n
git -C "$root" rev-parse --git-dir >/dev/null 2>&1 || { printf 'unknown'; return 0; }
# Only tags that look like a release. A tag someone pushed for anything
# else must not become the answer to "which release is this".
tag="$(git -C "$root" describe --tags --abbrev=0 --match '[0-9]*' 2>/dev/null)" || tag=""
[ -n "$tag" ] || { printf 'unreleased'; return 0; }
n="$(git -C "$root" rev-list --count "$tag..HEAD" 2>/dev/null)" || n=0
case "${n:-0}" in
0|"") printf '%s' "$tag" ;;
*) printf '%s+%s' "$tag" "$n" ;;
esac
}
# The release part alone, for comparing two of them. `2026.10.12+37` and
# `2026.10.12` are the same release; what differs is whether the checkout
# stopped there.
dejima_version_release() { # <version>
printf '%s' "${1%%+*}"
}
+55 -13
View File
@@ -133,18 +133,23 @@ _cline_host_allowlisted() {
#
# $1 is the override file core owns for harnesses that take one on the command
# line. Cline does not - it reads a JSON file at a fixed place inside its data
# directory - so that argument is unused here and the file is written into the
# state directory instead, before it is mounted. Returning nonzero stops the
# directory - so that argument is unused here. $2 is the per-run staging
# directory, which is where this writes instead. Returning nonzero stops the
# launch.
#
# Rewritten every launch on purpose. A session can edit this file (it is inside
# a read-write mount, and Cline rewrites it itself on its own schedule anyway),
# so it is not a control while a session is running - the firewall is what
# bounds where a repointed endpoint could reach. What rewriting buys is that a
# repointing cannot persist into the next session.
# Written per run and seeded in, not written where it will live. A session can
# edit the installed copy - Cline rewrites it on its own schedule anyway - so
# it is not a control while a session is running; the firewall is what bounds
# where a repointed endpoint could reach. What this buys is that a repointing
# cannot persist into the next session, which writing into the mounted state
# directory only approximated by rewriting the file each launch.
harness_prepare_config() {
local state model settings
state="$(_cline_state_dir "$WORKSPACE_DIR")" || {
local model settings
# Checked, not used: this function no longer writes into the state
# directory, but a launch with no state_dir has nowhere to keep Cline's
# state at all and the resume command in the briefing has no path to
# name. Refusing here is where an operator can still act on it.
_cline_state_dir "$WORKSPACE_DIR" >/dev/null || {
echo "harness.cline: state_dir is not set, so there is nowhere to keep" >&2
echo "this project's Cline state. Set it in [harness.cline]." >&2
return 1
@@ -260,11 +265,34 @@ harness_prepare_config() {
return 1
fi
settings="$state/settings"
# Into the per-run staging directory core hands this hook, not into the
# state directory on the host. Two things follow, and the second is why
# --isolated works for this harness at all:
#
# The file is seeded rather than mounted, so entrypoint.sh installs a copy
# the session owns and nothing Cline writes to it reaches the host. That
# is what the comment above this function wanted - a repointing cannot
# persist into the next session - and writing into the mounted state
# directory only approximated it by rewriting the file each launch.
#
# And it no longer needs the state mount to arrive. An isolated launch
# mounts no host state, which used to mean this file was written to a host
# directory keyed on the container-local working directory and then not
# mounted: Cline started with no model and fell back to its own hosted
# endpoint, which the firewall was never asked to allow (#393).
_CLINE_PROVIDERS_FILE=""
settings="${2:-}"
[ -n "$settings" ] || {
echo "harness.cline: core passed no staging directory, so there is" >&2
echo "nowhere to put this launch's provider configuration." >&2
return 1
}
settings="$settings/cline-providers"
mkdir -p "$settings" || return 1
# 0700: the placeholder is not a secret, but this directory is where a real
# one would land if anything ever wrote one here by mistake.
chmod 700 "$state" 2>/dev/null || true
# 0700 on the staging directory, for the reason the state directory had
# it: the placeholder is not a secret, but this is where a real one would
# land if anything ever wrote one here by mistake.
chmod 700 "$settings" 2>/dev/null || true
# Cline DISCARDS a provider entry with no updatedAt and falls back to its
# own hosted endpoint, silently - the run reports provider "cline" and a
@@ -324,6 +352,10 @@ harness_prepare_config() {
fi || { rm -f "$tmp"; return 1; }
chmod 600 "$tmp"
mv "$tmp" "$settings/providers.json"
# Read by harness_seed below, which runs again after this hook for exactly
# this: a hook whose job is to produce a per-run file has to run before
# the list that mounts it.
_CLINE_PROVIDERS_FILE="$settings/providers.json"
}
# The per-project state this harness keeps on the host.
@@ -413,6 +445,16 @@ harness_staged_config() {
# servers, which are commands cline executes: a session that could write the
# host's copy would be choosing what runs there next time.
harness_seed() {
# This launch's provider configuration, which harness_prepare_config
# wrote into the staging directory. Seeded rather than mounted so the
# session owns the copy it edits and nothing it writes reaches the host -
# and so it arrives without the state mount, which an isolated launch
# does not have (#393).
#
# Empty until that hook has run. Core loads this list twice for exactly
# that reason, and the first pass simply has nothing to emit here.
[ -z "${_CLINE_PROVIDERS_FILE:-}" ] \
|| emit_seed_from "$_CLINE_PROVIDERS_FILE" ".cline/data/settings/providers.json"
emit_seed_from "$HOME/.cline/settings.json" ".cline/settings.json"
emit_seed_from "$HOME/.cline/cline_mcp_settings.json" ".cline/cline_mcp_settings.json"
}
+8
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
+33 -2
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
@@ -319,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
@@ -329,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:
+63 -6
View File
@@ -131,6 +131,7 @@ HOST_ADDR=${EGRESS_HOST_ADDR:-}
# 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:-}
HOST_CANDIDATES=${EGRESS_HOST_CANDIDATES:-}
# 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.
@@ -557,6 +558,40 @@ emit_host_clamp() {
# 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.
# The ports of destinations the launcher configured AT the host's own address.
#
# The launcher knows which destinations this launch configured and cannot know
# the host's address; this process works that out from the default route a few
# lines below. So it passes candidates - a literal address with an explicit
# port, each one it is sure of - and the decision is made here, where both
# halves are known. Nothing resolves a name on either side: a candidate whose
# address is not the host's is simply not the host's.
#
# Without this the clamp, which is matched before every wider rule, answers
# `ports none` for a host-resident Loki or self-served model at the gateway
# address - working before only because the open-class permit answered first.
fold_host_candidates() { # <host address>...
local cand addr port a
[ -n "$HOST_CANDIDATES" ] || return 0
# `any` is already wider than anything a candidate could add.
[ "$HOST_PORTS" = any ] && return 0
for cand in $HOST_CANDIDATES; do
addr=${cand%:*}
port=${cand##*:}
case "$port" in
""|*[!0-9]*) die "EGRESS_HOST_CANDIDATES entry '$cand' has no TCP port" ;;
esac
for a in "$@"; do
[ "$addr" = "$a" ] || continue
case ",${HOST_PORTS}," in
*",$port,"*) ;;
*) HOST_PORTS="${HOST_PORTS:+$HOST_PORTS,}$port" ;;
esac
break
done
done
}
validate_host_ports() {
local p
[ "$HOST_PORTS" = any ] && return 0
@@ -771,15 +806,26 @@ EOF
cat <<EOF
# The container's own host, as a single address, on the ports it was told
# to open and no others.
#
# Before the open class, not after it, because this is the narrower rule
# and the first match wins. The host's address reaches literal_v4 by
# several ordinary routes - a host-resident LOKI_URL, a self-served
# model's base_url, \`dejima-allow <gateway>\` - and from there the open
# permit below answered for it first, on every port, including sshd and
# the remote-access front door. Configuring one host service opens that
# service, which is what docs/security.md says it does.
EOF
emit_host_clamp host_v4
cat <<EOF
# The open class: names the launcher permitted and addresses it supplied,
# on any port. DNS to them is refused above along with everyone else's.
ip daddr @oname_v4 goto permit
ip daddr @literal_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
@@ -1418,7 +1464,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"
@@ -1481,8 +1532,14 @@ cmd_apply() {
discover_upstreams
local host_addrs forge_host
validate_host_ports
# Discover, then fold the launcher's candidates in, then validate the whole
# list: the candidates' ports become part of what the clamp permits, so they
# are held to the same check rather than trusted for having crossed a
# process boundary.
host_addrs=$(discover_host_addr)
# shellcheck disable=SC2086
fold_host_candidates $host_addrs
validate_host_ports
forge_host=$(host_of_url "$FORGE_META_URL")
build_entries "$forge_host"
+23 -3
View File
@@ -100,6 +100,20 @@ dejima_push_verdict() { # <repo-dir> <git argv...>
--git-dir=*|--work-tree=*)
repo_override=1
;;
# `-c` with the value read from the environment instead of argv,
# so it sets a pushurl the same way and has to be carried into the
# resolution below the same way. In the separated spelling it also
# takes the next word, which an unrecognised option would leave to
# be read as the subcommand - and a subcommand that is not `push`
# ends this function having checked nothing.
--config-env)
i=$((i + 1))
[ "$i" -lt "${#argv[@]}" ] \
&& config_overrides+=(--config-env "${argv[$i]}")
;;
--config-env=*)
config_overrides+=("${argv[$i]}")
;;
--exec-path|--namespace|--super-prefix)
i=$((i + 1))
;;
@@ -526,11 +540,17 @@ _dejima_forge_path_verdict() {
# endpoint, so the flags go.
case "$arg" in -*) continue ;; esac
[ "$arg" = graphql ] && { scopable=1; continue; }
path="${arg#/}"
# 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#/}"
# the literal braces would refuse it. Those two tokens only, and
# only in an endpoint under the segment: any other brace says
# nothing about which repository the call reaches, and a value
# carrying one - `-f name={x}`, `--template '{{.login}}'` - would
# otherwise be enough to assert the whole command is scoped.
case "$path" in
"$segment"/*'{owner}'*|"$segment"/*'{repo}'*) scopable=1; continue ;;
esac
case "$path" in "$segment"/*/*) ;; *) continue ;; esac
path="${path#"$segment"/}"
owner="${path%%/*}"
+2 -2
View File
@@ -464,8 +464,8 @@ 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
**A per-run fetch, not a per-container one.** The privileged job starts a
container pair per test, 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,
+112 -3
View File
@@ -55,6 +55,19 @@ 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.** None of those 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. `--isolated` is the far end of the same idea, narrowing several at once:
see [Isolated sessions](#isolated-sessions).
The rule is about the `--no-*` family, not about every flag. `--ro`, `--rw`,
`--include`, `--allow-github-push` and a `--forge` naming a forge the config
did not each widen what one launch can reach, which is what they are for - and
each is visible in the command that used it, where a config file's answer is
not.
### `[core]` - what this sandbox runs
The three selections come first because everything below depends on them: the
@@ -137,7 +150,7 @@ required: an identity supplies what it names and no more.
| Key | Default | Purpose |
| --- | --- | --- |
| `ssh_key` | unset | the SSH key this identity pushes with |
| `ssh_key` | unset | the SSH key this identity pushes with. An empty file counts as no key at all rather than a refusal, so pointing this at one is how a launch keeps the setting and forwards your agent instead |
| `user_name` | unset | `user.name` for commits made in the session |
| `user_email` | unset | `user.email` for commits made in the session |
| `token_file` | *(the forge's own default)* | this identity's Gitea/Forgejo API token, overriding `[forge.gitea] token_file`. Read only when `[core] forge = gitea` |
@@ -297,8 +310,10 @@ 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
time.
`dejima-allow --project` and `dejima-dep add --project` 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.
@@ -1054,6 +1069,100 @@ 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
```
Naming a directory is **refused**, not ignored: `dejima --isolated ~/proj`
stops rather than starting a session that looks like it is working on a
project it cannot see. Harness arguments after `--` still pass through.
| | 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, unless you name a pushing workflow and have no key |
| `[identity.<name>] ssh_key` | mounted | mounted |
| conversation log, epitaph | as configured | off |
Every shipped harness supports it. A harness whose model configuration has to
be a file inside its own state directory gets it from a staging directory
that goes away with the launch, rather than from the state mount this mode
does not have.
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.
Two rows surprise people. `[github] token_file` is withheld even though it is
not a forge setting. And an `[identity.<name>] 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).
The agent row has a condition worth reading twice: it is withdrawn when the
workflow does not push **or** a usable key is configured. So the second
command above - a pushing workflow, on a host with no `ssh_key` - does forward
your agent into an isolated session.
The table is what this mode changes, not everything that crosses. `LOKI_URL`
still reaches the session, and the model credential follows the rules in
[docs/security.md](security.md#the-model-api-credential-never-enters-the-container)
whatever the mode.
**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
+3 -1
View File
@@ -27,7 +27,7 @@ run them.
| `.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. 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: 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 |
| `hooks/merge-guard.sh` | `PreToolUse` hook: applies the forge policy - the blocked merge command (`DEJIMA_MERGE_BLOCK`), the agent-written disclosure, and a command aimed at another repository - to a reported command |
| `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 |
@@ -51,6 +51,7 @@ run them.
| `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-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-version-lib.sh` | Which release a checkout is on, from `git describe` rather than from any file in the tree - so it cannot claim a release a checkout has been pulled past. Sourced by `bin/dejima` (`--version`, the banner) and `bin/dejima-update-check` (what the remote has that this does not). Not on PATH |
| `bin/dejima-ttyd` | Launches `ttyd` on loopback in front of `dejima-attach` |
| `systemd/*.service` | User units for `dejima-ttyd` and `dejima-notify-watch`; ship disabled, installed by hand |
| `docs/providers.md` | The manifest contract for the forge/workflow/harness axes, key by key, with the shipped manifests as worked examples |
@@ -58,6 +59,7 @@ run them.
| `docs/threat-model.md` | What is assumed hostile, and where each layer's compromise stops. Carries the blast-radius table `docs/security.md` also shows; a static test keeps the two identical |
| `docs/research-review.md` | When to check dejima's reasoning against the field's, the standing questions that are the agenda, and the queue of bypasses not yet run against this implementation |
| `docs/nginx-dejima.conf.example` | Reverse-proxy template for the web front door; deliberately not loadable as written |
| `CHANGELOG.md` | What each release changed, for one question: should I update. A release is a date and the tag of that name on `main` is the only record of it - see `docs/release.md` §6 |
`bin/dejima` is the entry point: it resolves the project path,
checks for a live `ssh-agent`, reads `GITEA_TOKEN`/`GH_TOKEN` from the token
+19 -6
View File
@@ -438,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
@@ -509,9 +518,10 @@ itself:
`GITEA_SSH_HOST`, `GITEA_TOKEN`).
`hooks/git-push-guard.sh` reads `DEJIMA_PUSH_HOSTS` and
`DEJIMA_BLOCKED_BRANCHES`; `hooks/merge-guard.sh` reads `DEJIMA_MERGE_BLOCK` as a
regex-escaped literal command prefix. Two different failure directions,
both deliberate:
`DEJIMA_BLOCKED_BRANCHES`; `hooks/merge-guard.sh` reads `DEJIMA_MERGE_BLOCK` as
the merge subcommand a workflow blocks, matched against the parsed argv rather
than against the command string. Two different failure directions, both
deliberate:
- `DEJIMA_PUSH_HOSTS` fails **closed** - empty means no host is allowed, so
every push is refused. A no-forge or non-pushing workflow becomes
@@ -519,8 +529,11 @@ both deliberate:
- `DEJIMA_BLOCKED_BRANCHES` fails **open** - empty means no branch is
protected, which is correct for a workflow with genuinely no branch
policy.
- `DEJIMA_MERGE_BLOCK` fails **open** the same way - empty makes
`merge-guard.sh` inert rather than conditionally installed.
- `DEJIMA_MERGE_BLOCK` fails **open** for the merge it names - empty blocks no
merge, which is correct for a workflow with no merge policy. The guard
itself stays active either way: it also adds the agent-written disclosure
and holds a command to the repository the session is for, and neither of
those is the workflow's to turn off.
## Validation
+42
View File
@@ -118,3 +118,45 @@ looks like the safety net is not one. Name the keys. A queued privileged job, a
manual check that was skipped, a forge that has still never been launched -
those are the things a reader of `main` is entitled to know, and the release
PR is where they are still true and still remembered.
Worth its own line too: **what an existing config stops being able to do.** A
release that tightens a boundary can refuse an arrangement that worked, with
nothing in the operator's config changed - and the ones that fail closed are
the ones to name, because failing closed is quiet. An observability path is
the worst case: a log shipper that can no longer reach its endpoint stops
shipping and nothing else happens. Name the arrangement, what it now needs,
and where the refusal shows up.
## 6. The version, and the entry in the changelog
A release is a date, and the tag of that name on `main` is the only record of
it. Two steps, both at merge time, both easy to forget because nothing fails
without them:
```sh
git tag 2026.10.12 <the merge commit on main>
git push origin 2026.10.12
```
and in the same pull request, rename `CHANGELOG.md`'s `## Unreleased` heading
to that date and open a fresh empty one above it. The date is the day it
merges, so it cannot be written before the merge is made - which is why the
heading waits rather than being guessed.
**There is nothing else to bump.** `dejima --version` reads `git describe`, so
the tag is the version: no file records it, and none can disagree with it. A
checkout pulled past a release reports `2026.10.12+37`, which is the honest
answer for a checkout on no release.
**A second release on the same day gets a letter** - `2026.10.12b`, then `c`.
Rare, and the format otherwise has no answer: the date is the whole name.
`sort -V` orders it after the bare date, which is what the update notice
compares with, so nothing else has to know about the shape. A letter rather
than a fourth dotted number, because four dot-separated numbers beginning
with a year and a month read as a private IPv4 address.
What an entry is for is one question - should I update, and what will I be
getting - so it names what an operator would notice rather than what changed.
The pull requests carry the detail and are linked from it. A tag is a record
of what shipped, not a version anyone may stay on; `CHANGELOG.md` says so at
the top, and `SECURITY.md` is where that is binding.
+101 -9
View File
@@ -34,7 +34,9 @@ than the thing inferred from silence.
to [dejima-devbox-hardener](https://gitbox.branderson.io/dejima/dejima-devbox-hardener).
Run on a laptop with no VM layer, the row below the container is the
operator's own machine, and dejima has nothing to say about it. See
[Which deployment this describes](threat-model.md#which-deployment-this-describes).
[Which deployment this describes](threat-model.md#which-deployment-this-describes),
and [Using dejima on its own](threat-model.md#using-dejima-on-its-own) for
what still holds there and what to do about the rest.
- **Anything finer than a session.** The unit of isolation is one container for
one session. An MCP server, a hook, or a tool the session runs inherits the
session's whole reach - dejima reports that a session *added* an MCP server
@@ -321,6 +323,52 @@ 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 usually not mounted; a configured key always is.**
The agent is withdrawn when the workflow does not push **or** a usable key is
configured, and `--isolated` defaults to a workflow that does not push - so
the ordinary isolated launch forwards no agent. Name a pushing workflow on a
host with no `[identity.<name>] ssh_key`, which the example above does, and it
is forwarded like any other pushing launch. The flag does not decide this; the
workflow and the key do.
A configured 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
@@ -507,6 +555,13 @@ record, so there is no slice to take - dejima gives each workspace a data
directory of its own instead. Both reach the same place: a session sees its
own project's history and no other's.
That is about the per-project state each harness keeps. It is not true of
`~/.claude/backups`, `tasks`, `plans` and `sessions`, which are mounted
read-write and shared by every project's sessions - what a session writes
there, another project's session can read. See
[#380](https://gitbox.branderson.io/dejima/dejima/issues/380), which has what
was observed and why only the first of the four was free to fix.
### Claude Code: a slice of your own tree
- `~/.claude/projects` (session transcripts and per-project memory) is
@@ -725,6 +780,17 @@ reads it host-side per request instead. Without it the proxy would
withhold one file while dejima copied another, on every launch and whatever
backend was selected.
**The same staging narrows the `projects` map to the launching project.** That
map keys per-project state by absolute path, so copying all of it told a
session the name of every project on the host, a sample of each one's
filenames, and what each had cost - from inside a container whose claim is
that it cannot learn what you do elsewhere. The launching project's path and
anything under it are kept, because a session's working directory is routinely
a worktree inside it. Both halves run on the one path that stages this file,
including the re-seed for a backend whose credential IS this file and whose
proxy is off: that one keeps the credential and narrows the map exactly as the
other does.
**The residual the first two share is use-while-running.** A session that has
been turned can spend your quota, ask the model anything, and read the answers
- through the proxy, without ever holding the credential. That is inherent to
@@ -981,8 +1047,10 @@ reasoning below is what both are built on.
failing cleanly (confirmed against a real read-only copy of this file -
upstream bug: [anthropics/claude-code#29162](https://github.com/anthropics/claude-code/issues/29162)).
Instead, `dejima` generates a throwaway copy of the file at
launch (seeded from the real file, same "generate a sandbox-specific
file on the host" pattern as the generated settings override layer)
launch (seeded from the real file with the credential dropped and the
`projects` map narrowed to this project - see "staged, not mounted" above -
the same "generate a sandbox-specific file on the host" pattern as the
generated settings override layer)
and mounts *that* read-write in its place; the copy is discarded,
unmodified in the real file, when the session ends. A compromised
session can add whatever it wants to `mcpServers` (or anything else in
@@ -1340,13 +1408,37 @@ 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.
rules. Configuring one of those is how a host service becomes reachable, and
it is that service that becomes reachable rather than the host: the clamp is
matched **before** the wider class rules, so an address that is both the
host's and an open-class entry's is still held to these ports.
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.
`EGRESS_HOST_PORTS`, set in the environment you launch from, 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.
**A destination you pointed at the host's own address gets its port added for
you.** The two processes split that decision because neither knows both
halves. The launcher knows which destinations this launch configured - a
`[logging] loki_url`, a backend's `base_url` - and offers the ones it is sure
of: an explicit `:port` on a literal address. The filter, which works the
host's address out from its default route, keeps the port of each candidate
whose address is the host's. Neither resolves a name.
What that leaves out is deliberate in both directions. A scheme default is
not an explicit port, because a remote `https://loki.example.com` has none
and adding 443 for it would open the host's own 443 - which on a box
following
[Behind another reverse proxy](remote-access.md#behind-another-reverse-proxy)
is the proxy in front of the web terminal. And **a host-resident service
configured by name needs its port set here by hand**, because a name is a
destination neither process will resolve to decide what to open.
The policy's summary line is where a missing port shows up: `host <addr> on
ports none` is why nothing can reach it - the session, and the log shipper
that runs beside it.
### DNS is allowlisted too
+77
View File
@@ -244,6 +244,8 @@ and no answer at all where it does not.
So `dejima-devbox-hardener` is load-bearing rather than optional-and-nice.
On a laptop, dejima is a hardened container and the honest description of what
is under it is "your home directory, and dejima has nothing to say about it".
[Using dejima on its own](#using-dejima-on-its-own) is what that leaves you
with, and what to do about it.
**Load-bearing for what, exactly**, because the field's tiering invites a
reading that project does not accept and has never claimed. A per-workload VM
@@ -263,6 +265,81 @@ repository's behalf.
**What this repository can do about that is say so**, which is the whole of
this section. Nothing in dejima can bound its own host.
## Using dejima on its own
Most installs are this: dejima on a laptop or a workstation, and none of the
sister projects. Everything above about the rows below the container is true,
and it is about one threat, the least likely one on the list.
### What holds
The container row, in full. Nothing in it depends on another project.
- **Disk:** the workspace, the mounts you declare, and this project's own slice
of harness state. Not your home directory, not `~/.ssh`, not your other
projects ([security.md](security.md#one-projects-session-cannot-reach-anothers)).
- **Network:** deny-by-default egress. No port on the host is open unless you
named one, and a permitted name that answers with a private address is
refused unless it is a destination you configured, so the LAN is out of
reach unless you put it there
([security.md](security.md#network-egress-firewall)).
- **The model credential** stays on the host behind the credential proxy
([security.md](security.md#the-model-api-credential-never-enters-the-container)).
- **The forge:** whatever the token and key you hand the session can reach.
- **Afterwards:** the exit-time audit reports changes to the host-side files a
session could use to run code later, before your next `git` in that repo
([security.md](security.md#the-exit-time-audit)).
Against [the ranked list](#ranked-by-what-is-actually-likely): items 1, 2 and 4
are the container row's, and are handled the same with or without the stack.
Item 3 depends on where your CI runs, not on your machine. Item 5 is a person
deciding, with or without the stack. Item 6, escape, is the one the
devbox-side projects exist for.
### What does not
- **An escape lands on your machine, as you.** Your home directory, your
network position, your credentials. Nothing here bounds that.
- **Pushing is code execution wherever your CI runs**, if the project has CI
([security.md](security.md#ci-push-access-is-code-execution-elsewhere)).
- **Nothing watches the machine between sessions.** The audit covers the
surfaces it lists, for the window the session ran.
### Measured caution
None of these needs another project.
1. **Keep the kernel and the container runtime patched.** An escape needs an
unpatched host or a working zero-day, and the first is the one you control.
2. **Point repos at dejima that you would run yourself.** That is
[an assumption](#what-this-tool-assumes) the rest rests on.
3. **Give the sandbox its own identity**, with tokens scoped to the
repositories it works on, so the forge's reach is what you granted rather
than all of yours ([security.md](security.md#give-the-sandbox-its-own-identity)).
4. **Decide about CI before granting push.** Hosted CI, a runner you are
willing to lose, or no push at all (`[core] forge = none` with
`[core] workflow = local-only`). Not a self-hosted runner with your LAN
behind it ([ci.md](ci.md)).
5. **Treat what a session hands you as its writing:** a command it asks you to
run, a domain or dependency it asks you to approve, and every file in
`share_dir` or a read-write mount. See
[what mounting it costs you](remote-access.md#what-mounting-it-costs-you)
before syncing those anywhere a desktop will open them.
6. **Read the audit before your next `git` in that repo.** `[audit] strict`
refuses a headless launch while CRITICAL findings are unacknowledged.
7. **Leave the credential proxy on.** `proxy = off` is the one arrangement that
puts the real model credential in the container.
### When the rest is worth adding
- **dejima-devbox-hardener**, when your machine sits on a network with things
you would mind an escape reaching. It enforces from outside a VM, so the
first step is moving dejima into one.
- **dejima-ci-hardener, or hosted CI**, as soon as sessions push to a project
whose CI you run yourself.
- **dejima-guard**, when sessions run unattended long enough that "would I
notice?" matters, and you have a second machine to run it from.
## Why a container, and not a microVM
The most frequent question about this project.
+22 -7
View File
@@ -47,15 +47,30 @@ forge_commands="$(dejima_forge_commands | sort -u)"
# 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.
#
# A later word counts only when it names a path, which is what divides the two
# layers: a bare `gh` anywhere in a segment is the shadow's, because reaching
# it means executing what PATH resolves, while a path names the real binary
# directly and the shadow never runs. `env /usr/local/lib/dejima/forge/gh pr
# merge 7` is both at once - a prefix word hiding the first position, and a
# path stepping over the shadow - so without the path case it belonged to
# neither. `echo gh pr merge 3` stays allowed, and must: it names the CLI
# without running it, and the shadow it would run is not being bypassed.
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
for i in "${!tokens[@]}"; do
case "$i:${tokens[$i]}" in
0:*) ;;
*:*/*) ;;
*) continue ;;
esac
self="$(basename -- "${tokens[$i]}")"
grep -qxF -- "$self" <<< "$forge_commands" || continue
if ! dejima_forge_verdict "$self" "${tokens[@]:$((i + 1))}"; then
echo "Blocked by merge-guard.sh: $DEJIMA_GUARD_REASON" >&2
exit 2
fi
done
done < <(dejima_guard_segments "$command")
exit 0
+228 -6
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
@@ -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"
@@ -1943,6 +1962,27 @@ _real_agent() {
|| { echo "the launch reached docker with no credential at all"; return 1; }
}
@test "an empty ssh_key file with an agent running says the agent is still signing" {
# The configuration nothing else would tell an operator about: the key is
# configured, the launch starts, and pushes work - because the personal
# agent is doing them, which is what a dedicated key is chosen to avoid.
# No key is loaded, so the withdrawal above does not fire.
local key
_real_agent
key="$(mktemp)"; chmod 600 "$key"
_agent_launch "$key"
kill "$AGENT_PID" 2>/dev/null || true
rm -f "$key"
[[ "$AGENT_OUT" == *"names an empty file"* ]] \
|| { echo "an empty key went unmentioned:"; echo "$AGENT_OUT" | tail -12; return 1; }
[[ "$AGENT_OUT" == *"will sign for this session"* ]] \
|| { echo "the consequence went unnamed:"; echo "$AGENT_OUT" | tail -12; return 1; }
# And it is not a false alarm: the agent really does reach compose here.
[ "$(sort -u "$AGENT_LOG")" = "SSH_AUTH_SOCK=[$AGENT_SOCK]" ] \
|| { echo "the warning does not match what reached compose:"; sort -u "$AGENT_LOG"; 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.
@@ -3895,6 +3935,188 @@ 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 "the proxy-off re-seed narrows the projects map too" {
# With the proxy off for a backend whose credential IS ~/.claude.json,
# the staged copy is re-seeded so the key survives - and that was a plain
# `cp`, which put every other project's entry back. Worse, it ran after
# the credential scan had read the narrowed copy, so nothing ever looked
# at what came back.
_rw_setup
workspace="$(mktemp -d)"
cat > "$RW_HOME/.claude.json" <<JSON
{"primaryApiKey":"sk-ant-api03-REAL-KEY-FROM-THE-HOST",
"projects":{
"$workspace":{"exampleFiles":["mine.py"],"lastCost":1},
"/home/someone/else/their-project":{"exampleFiles":["theirs.py"]}},
"numStartups":7}
JSON
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only' \
'harness = claude-code' '[harness.claude-code]' \
'backend = anthropic-api-key' '[backend.anthropic-api-key]' \
"credential_file = $RW_HOME/.claude.json" 'proxy = off'
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 "$output"; return 1; }
[ -s "$staged" ] || { echo "no staged ~/.claude.json was mounted"; return 1; }
# The credential is the point of this path, so it stays.
[ "$(jq -r '.primaryApiKey' "$staged")" = sk-ant-api03-REAL-KEY-FROM-THE-HOST ] \
|| { echo "proxy = off did not pass the credential through"; return 1; }
# And nothing else came back with it.
[ "$(jq -r '.projects | length' "$staged")" = 1 ] \
|| { echo "the re-seed restored other projects: $(jq -c '.projects|keys' "$staged")"
return 1; }
grep -q 'theirs.py' "$staged" \
&& { echo "another project's filenames reached the container"; return 1; }
[ "$(jq -r '.numStartups' "$staged")" = 7 ] || { echo "lost app state"; return 1; }
rm -rf "$workspace" "$stub" "$staged"
}
@test "a backend needing no credential mounts no credential" {
# P1, driven rather than grepped. The mount sat under
# `elif [ -f ~/.claude/.credentials.json ]`, which fired for anything the
# proxy branch did not take - including a backend whose credential kind
# is none, the self-served arrangement docs/security.md calls the one with
# nothing in the container to steal. The real subscription login was
# mounted read-write into a session that had no use for it.
#
# The grep over the branch's conditions is in static.bats and would still
# pass on a restructuring that tested the same words differently, which is
# why this one reads the argv a launch actually produced.
_rw_setup
workspace="$(mktemp -d)"
# A real-looking credential on the host, which is what there is to leak.
printf '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-REAL","refreshToken":"r","expiresAt":1}}\n' \
> "$RW_HOME/.claude/.credentials.json"
chmod 600 "$RW_HOME/.claude/.credentials.json"
# cline rather than claude-code: a credential kind of none means a backend
# the harness reaches directly, and claude-code speaks no API that has
# one. This is the pairing the release pass reproduced it with.
local cline_state; cline_state="$(mktemp -d)"
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only' \
'harness = cline' '[harness.cline]' \
"state_dir = $cline_state" 'model = m' 'backend = self-hosted' \
'[backend.self-hosted]' 'base_url = http://127.0.0.1:11434'
_rw_launch "$workspace"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
grep -qF "$RW_HOME/.claude/.credentials.json:$RW_HOME/.claude/.credentials.json" \
"$docker_log" \
&& { echo "a self-served backend was handed the real credential"; return 1; }
rm -rf "$cline_state"
# And the arrangement the mount IS for still gets it: this backend's
# credential is that file, and the proxy is off for it.
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only' \
'harness = claude-code' '[harness.claude-code]' \
'backend = anthropic' '[backend.anthropic]' 'proxy = off'
_rw_launch "$workspace"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
grep -qF "$RW_HOME/.claude/.credentials.json:$RW_HOME/.claude/.credentials.json" \
"$docker_log" \
|| { echo "proxy = off no longer mounts the credential it is for"; return 1; }
rm -rf "$workspace"
}
@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,
+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():
+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
+606 -1
View File
@@ -61,6 +61,11 @@ launch_setup() {
cat > "$LAUNCH_DIR/docker" <<'EOF'
#!/bin/bash
echo "$*" >> "${SANDBOX_TEST_DOCKER_LOG:-/dev/null}"
# Stands in for a session that handed something back, for the one test about
# an isolated share surviving. Written where the launch says it mounted it.
if [ -n "${LAUNCH_SHARE_MARKER:-}" ] && [ -n "${SANDBOX_SHARE_DIR:-}" ]; then
: > "$SANDBOX_SHARE_DIR/handed-back" 2>/dev/null || true
fi
# For a real `docker build` (never `docker compose ... build`, whose $1 is
# "compose"), also log the generated Dockerfile's own content, tagged by -t -
# argv alone can't tell whether the Dockerfile's FROM line actually points at
@@ -88,6 +93,11 @@ EOF
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 +120,8 @@ 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= \
LAUNCH_SHARE_MARKER="${LAUNCH_SHARE_MARKER:-}" \
"$REPO_ROOT/bin/dejima" "$@"
}
@@ -1310,6 +1321,569 @@ 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
# Neither survives, because neither was written to: an empty isolated
# share is removed in the exit trap. That covers a session that handed
# nothing back and a launch refused after the directory was made, which
# is most refusals - each used to leave one for an operator to find.
[ ! -d "$first" ] && [ ! -d "$second" ] \
|| { echo "an empty isolated share was left behind"; return 1; }
# And one that WAS written to stays, which is what the directory is for -
# and is also what shows the launch creates and mounts it rather than
# just computing a path.
LAUNCH_SHARE_MARKER=1 run launch --isolated
[ "$status" -eq 0 ]
local used; used="$(launched_env SANDBOX_SHARE_DIR)"
[ -f "$used/handed-back" ] \
|| { echo "the share directory the launch mounted does not exist: $used"; return 1; }
rm -rf "$used"
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; }
# Both halves of the name: a forge named on the command line, and a
# workflow, in either order relative to the flag. It only ever named a
# workflow before, so the forge half of what it is called went untested.
local args
for args in "--isolated --forge gitea" "--forge gitea --isolated"; do
# shellcheck disable=SC2086
run launch $args --print-config
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
grep -qx 'core.forge=gitea' <<< "$output" \
|| { echo "'$args' did not keep the forge the operator named"; return 1; }
# And the workflow still defaults, since only the forge was named.
grep -qx 'core.workflow=local-only' <<< "$output" \
|| { echo "'$args' changed the workflow as well"; return 1; }
done
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; }
grep -qx 'core.forge=none' <<< "$output" \
|| { echo "'$args' changed the forge as well"; 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; }
# `--forge` naming the forge with no capabilities is an operator asking
# for no forge, and it counted as naming one - so the launch that said
# most clearly that it wanted no forge access was the one that got the
# credential.
run launch --isolated --forge none
[ "$status" -eq 0 ]
[ -z "$(launched_env SANDBOX_GH_TOKEN)" ] \
|| { echo "--isolated --forge none was handed core's GitHub token"; 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 says nothing is kept, and every reader agrees" {
# The banner worked the epitaph out from [epitaph] enabled alone and said
# "epitaph on" a few lines above the isolated notice saying it was off -
# and nothing was being kept either way, because the save was gated on
# the mode instead. --print-config had the same split for both keys.
#
# Both are overridden off where the flag is read now, so this asserts the
# thing every reader is reading rather than one reader's output.
launch_setup
printf '[epitaph]\nenabled = on\n[logging]\nconversation = on\nloki_url = http://loki.invalid:3100\n' \
>> "$(bash -c 'source "$1"; config_path' _ "$LIB")"
run launch --isolated --print-config
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
grep -qx 'epitaph.enabled=off' <<< "$output" \
|| { echo "--print-config says the epitaph is on for an isolated launch"; return 1; }
grep -qx 'logging.conversation=off' <<< "$output" \
|| { echo "--print-config says the conversation log is on"; return 1; }
# And on, as configured, without the flag - so the two lines above are
# about --isolated rather than about the keys having stopped working.
run launch --print-config
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
grep -qx 'epitaph.enabled=on' <<< "$output" \
|| { echo "an ordinary launch lost its epitaph setting"; return 1; }
grep -qx 'logging.conversation=on' <<< "$output" \
|| { echo "an ordinary launch lost its conversation setting"; return 1; }
}
@test "an isolated cline launch is configured, and writes nothing to the host" {
# Cline's model configuration reaches the container through the state
# mount, which this mode does not have - so it was written to a host
# directory keyed on the container-local working directory, shared by
# every isolated cline launch, and then not mounted. Cline started with no
# model and fell back to its own hosted endpoint, which the firewall was
# never asked to allow.
#
# It is seeded from the per-run staging directory now, so it arrives
# without a state mount and the host state directory is untouched.
launch_setup
local state; state="$(mktemp -d)"
printf '[harness.cline]\nstate_dir = %s\nmodel = m\nbackend = self-hosted\n[backend.self-hosted]\nbase_url = http://127.0.0.1:11434\n' \
"$state" >> "$(bash -c 'source "$1"; config_path' _ "$LIB")"
# --harness rather than a config line: launch() appends its own
# `harness = claude-code` after whatever a test writes, and a repeated
# key takes the last value.
run launch --isolated --harness cline
[ "$status" -eq 0 ] || { echo "$output"; rm -rf "$state"; return 1; }
# The configuration arrives, as a seed rather than a mount - so the
# session owns the copy it edits and nothing it writes reaches the host.
local log; log="$(docker_log)"
grep -qE -- "-v [^ ]*/providers\.json:/etc/dejima-seed/\.cline/data/settings/providers\.json:ro" \
<<< "$log" \
|| { echo "cline got no provider configuration"; rm -rf "$state"; return 1; }
# From a path that goes away with the run, not from the state directory.
grep -qE -- "-v $state[^ ]*:/.*providers\.json" <<< "$log" \
&& { echo "it was mounted out of the host state directory"; rm -rf "$state"; return 1; }
# And the state directory itself is neither mounted nor written.
grep -qF -- "-v $state" <<< "$log" \
&& { echo "an isolated launch mounted the host state directory"; rm -rf "$state"; return 1; }
[ -z "$(ls -A "$state")" ] \
|| { echo "it wrote to the host: $(ls -A "$state")"; rm -rf "$state"; return 1; }
rm -rf "$state"
return 0
}
@test "a launch leaves no per-run staging directory behind" {
# The staging directory is created with mktemp -d above the trap and
# removed by cleanup(), and for one release it was neither: the block
# that initialises trap-owned names to empty runs AFTER the mktemp in
# straight-line order, so it threw the real path away and cleanup()
# removed "". Every launch on every harness left one in TMPDIR.
#
# Nothing caught it because nothing looked after exit. This does, with its
# own TMPDIR so it sees only what this launch made - and for both
# harnesses, since the one that puts a file in there is not the default.
launch_setup
local t; t="$(mktemp -d)"
TMPDIR="$t" run launch "$PROJ"
[ "$status" -eq 0 ] || { echo "$output"; rm -rf "$t"; return 1; }
local left; left="$(find "$t" -maxdepth 1 -name 'dejima-harness-stage.*' -print)"
[ -z "$left" ] \
|| { echo "left behind: $left"; rm -rf "$t"; return 1; }
# cline is the harness that writes into it, so it is the one where a
# leak carries content rather than being an empty directory.
local state; state="$(mktemp -d)"
printf '[harness.cline]\nstate_dir = %s\nmodel = m\nbackend = self-hosted\n[backend.self-hosted]\nbase_url = http://127.0.0.1:11434\n' \
"$state" >> "$(bash -c 'source "$1"; config_path' _ "$LIB")"
TMPDIR="$t" run launch --harness cline "$PROJ"
[ "$status" -eq 0 ] || { echo "$output"; rm -rf "$t" "$state"; return 1; }
left="$(find "$t" -maxdepth 1 -name 'dejima-harness-stage.*' -print)"
[ -z "$left" ] \
|| { echo "cline left behind: $left ($(find "$left" -type f | head -3))"
rm -rf "$t" "$state"; return 1; }
# And the other staging paths, which were never broken and are what this
# one should have looked like.
left="$(find "$t" -maxdepth 1 \( -name 'dejima-settings.*' -o -name 'dejima-claude-json.*' \) -print)"
[ -z "$left" ] || { echo "also left behind: $left"; rm -rf "$t" "$state"; return 1; }
rm -rf "$t" "$state"
}
@test "an ordinary cline launch still gets its provider configuration" {
# The same seed, which for a launch that HAS a state mount lands inside
# it - so the end state is what it was before, written by the entrypoint
# rather than by the launcher onto the host.
launch_setup
local state; state="$(mktemp -d)"
printf '[harness.cline]\nstate_dir = %s\nmodel = m\nbackend = self-hosted\n[backend.self-hosted]\nbase_url = http://127.0.0.1:11434\n' \
"$state" >> "$(bash -c 'source "$1"; config_path' _ "$LIB")"
run launch --harness cline "$PROJ"
[ "$status" -eq 0 ] || { echo "$output"; rm -rf "$state"; return 1; }
local log; log="$(docker_log)"
grep -qE -- "-v [^ ]*/providers\.json:/etc/dejima-seed/\.cline/data/settings/providers\.json:ro" \
<<< "$log" \
|| { echo "cline got no provider configuration"; rm -rf "$state"; return 1; }
# And this one does mount the state directory, which is what makes the
# seeded copy land in it.
grep -qF -- "-v $state" <<< "$log" \
|| { echo "an ordinary launch did not mount the state directory"; rm -rf "$state"; return 1; }
rm -rf "$state"
return 0
}
@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; }
# And the other order, which is the one that went wrong quietly: the flag
# loop stops at the first positional, so `--isolated` after a path was
# passed to the harness while the project was mounted read-write - the
# operator asking for isolation got the opposite with no sign of it.
run launch "$PROJ" --isolated
[ "$status" -ne 0 ] \
|| { echo "--isolated after the path was accepted and mounted $PROJ"; return 1; }
[[ "$output" == *"would be passed to the"* ]] \
|| { echo "the refusal does not say what would have happened: $output"; return 1; }
# Still passed through when -- says it is the harness's.
run launch "$PROJ" -- --isolated
[ "$status" -eq 0 ] \
|| { echo "-- did not hand --isolated to the harness: $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
@@ -1383,6 +1957,37 @@ ENTRYPOINT [\"/usr/local/bin/entrypoint.sh\"]" ]]
[[ "$output" == *"approve its own domains and dependencies"* ]]
}
@test "the launcher warns about the epitaph and share stores inside the workspace too" {
# Three stores were warned about and six were guarded, so a path dejima
# refuses to mount through [mounts] or --ro was mounted without a word
# when it happened to sit inside the project. Each consequence differs,
# which is why each store says its own.
launch_setup
local conf; conf="$(bash -c 'source "$1"; config_path' _ "$LIB")"
printf '[epitaph]\ndir = %s\n[core]\nshare_dir = %s\n' \
"$PROJ/.epitaphs" "$PROJ/.share" >> "$conf"
run launch "$PROJ"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[[ "$output" == *"the epitaph store"* ]] \
|| { echo "no warning for the epitaph store: $output"; return 1; }
[[ "$output" == *"what every previous one in this project concluded"* ]] \
|| { echo "the epitaph warning does not name its own consequence"; return 1; }
[[ "$output" == *"share directories of every project"* ]] \
|| { echo "no warning for the per-project share root"; return 1; }
[[ "$output" == *"share directories of every isolated session"* ]] \
|| { echo "no warning for the isolated share root"; return 1; }
# And none of them for a project that contains none of these, so the
# three above are about the overlap rather than printed always.
local other; other="$(mktemp -d)"
run launch "$other"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[[ "$output" != *"writable from inside the sandbox"* ]] \
|| { echo "a clean project was warned about: $output"; return 1; }
rm -rf "$other"
}
@test "the launcher warns when the audit report or baseline store sits inside the mounted workspace" {
launch_setup
run launch "$PROJ"
+45 -1
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
+10 -1
View File
@@ -60,7 +60,7 @@ suites_for() {
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-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) \
@@ -247,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' \
+72
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
@@ -1479,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" {
@@ -2670,6 +2701,47 @@ EOF
[[ "$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
+36
View File
@@ -150,6 +150,42 @@ 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
# Not asserted to still exist: an isolated share that was never written
# to is removed in the exit trap, so a stub that runs no session leaves
# none. That it was created and mounted is project.bats' test, which has
# a stub that writes into it.
[ ! -d "$(mounted_share)" ] \
|| { echo "an empty isolated share was left behind"; 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
+722 -25
View File
@@ -1649,7 +1649,13 @@ _cline_cfg() {
# $1 is the config dir, $2 the workspace. Prints whatever the plugin said and
# returns its status.
#
# The staging directory is $1/stage, because that is the second argument core
# passes and because it is where the plugin writes this launch's provider
# configuration now - it used to write into the state directory, which an
# isolated launch does not mount. The tests below read it out of there.
_cline_prepare() {
mkdir -p "$1/stage"
DEJIMA_CONFIG_DIR="$1" bash -c '
source "$2/bin/dejima-provider-lib.sh"
source "$2/bin/dejima-project-lib.sh"
@@ -1657,7 +1663,7 @@ _cline_prepare() {
source "$2/bin/dejima-slug-lib.sh"
harness_plugin_load cline
WORKSPACE_DIR="$1"
harness_prepare_config /dev/null' _ "$2" "$REPO_ROOT"
harness_prepare_config /dev/null "$3"' _ "$2" "$REPO_ROOT" "$1/stage"
}
@test "cline refuses to launch with no model configured" {
@@ -1695,7 +1701,7 @@ _cline_prepare() {
# workspace slug, and spelling that out here would be a second copy of
# ws_slug that could agree with nothing.
local written
written="$(find "$d/state" -name providers.json -print -quit)"
written="$(find "$d/stage" -name providers.json -print -quit)"
[ -n "$written" ] || { echo "no providers.json written for $id"; return 1; }
local wrote
wrote="$(jq -r '.providers."openai-compatible".settings.model' "$written")"
@@ -1819,7 +1825,7 @@ _cline_prepare() {
run _cline_prepare "$cfg" "$ws"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
local f
f="$(find "$cfg/state" -name providers.json | head -1)"
f="$(find "$cfg/stage" -name providers.json | head -1)"
[ -n "$f" ] || { echo "no providers.json was written"; return 1; }
run jq -r '.providers.anthropic.settings.apiKey' "$f"
[[ "$output" == *placeholder* ]] || { echo "the key is not a placeholder: $output"; return 1; }
@@ -1843,7 +1849,7 @@ _cline_prepare() {
ws="$BATS_TEST_TMPDIR/ws-ts"
run _cline_prepare "$cfg" "$ws"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
f="$(find "$cfg/state" -name providers.json | head -1)"
f="$(find "$cfg/stage" -name providers.json | head -1)"
[ -n "$f" ] || { echo "no providers.json"; return 1; }
run jq -r '.providers.anthropic.updatedAt // "missing"' "$f"
[ "$output" != "missing" ] || { echo "no updatedAt: cline will discard this"; return 1; }
@@ -1859,7 +1865,7 @@ _cline_prepare() {
ws="$BATS_TEST_TMPDIR/ws-ts2"
run _cline_prepare "$cfg" "$ws"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
f="$(find "$cfg/state" -name providers.json | head -1)"
f="$(find "$cfg/stage" -name providers.json | head -1)"
run jq -r '.providers."openai-compatible".updatedAt // "missing"' "$f"
[ "$output" != "missing" ] || { echo "no updatedAt on the local branch"; return 1; }
}
@@ -1877,13 +1883,13 @@ _cline_prepare() {
cfg="$(_cline_cfg 'model = m' 'backend = anthropic' 'context_window = 15000')"
run _cline_prepare "$cfg" "$BATS_TEST_TMPDIR/ws-cw"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
f="$(find "$cfg/state" -name providers.json | head -1)"
f="$(find "$cfg/stage" -name providers.json | head -1)"
run jq -r '.providers.anthropic.settings.contextWindow' "$f"
[ "$output" = "15000" ] || { echo "not written as a number: $output"; return 1; }
cfg="$(_cline_cfg 'model = m' 'backend = anthropic')"
run _cline_prepare "$cfg" "$BATS_TEST_TMPDIR/ws-cw2"
f="$(find "$cfg/state" -name providers.json | head -1)"
f="$(find "$cfg/stage" -name providers.json | head -1)"
run jq -r '.providers.anthropic.settings.contextWindow // "absent"' "$f"
[ "$output" = "absent" ] || { echo "invented a context window: $output"; return 1; }
}
@@ -2236,6 +2242,70 @@ _reference_has_key() { # <doc> <section> <leaf>
done
}
@test "a bounded wait is lengthened by what the run asked the machine for" {
# The suites wait on things that take as long as the machine makes them
# take, and each wait has a deadline. One that is right on an idle box is
# wrong under the gate, and raising it until both pass means picking a
# number that is wrong in one of them - which is how a fixture's idle bound
# went from sixty seconds to two hundred and forty (#295).
local ls="$REPO_ROOT/test/fixtures/loadscale.py"
[ -f "$ls" ] || { echo "the shared scale is gone"; return 1; }
# Both runners say what they asked for, or the multiplier is always 1 and
# this does nothing. Derived on each side from the concurrency it chose,
# not a constant either of them picked.
grep -qF 'export DEJIMA_TEST_CONCURRENCY=$(( ${#selected[@]} * jobs ))' \
"$REPO_ROOT/test/run-confined.sh" \
|| { echo "the gate does not say how much concurrency it asked for"; return 1; }
grep -qF 'DEJIMA_TEST_CONCURRENCY=$(nproc) bats --jobs' \
"$REPO_ROOT/.gitea/workflows/tests.yml" \
|| { echo "the CI job does not say how much concurrency it asked for"; return 1; }
# And every fixture that waits on one uses it, rather than carrying a bound
# chosen to survive the gate.
# The import, not the word: both files mention loadscale in a comment
# explaining why their bounds are what they are, so a grep for the name
# passes on a file that stopped using it.
local f
for f in tty-suspend-harness.py answer-prompt.py; do
grep -qE '^(import loadscale|from loadscale import)' "$REPO_ROOT/test/fixtures/$f" \
|| { echo "$f has bounded waits that do not scale with the run"; return 1; }
done
# Driven, because the shape is easy to get right and the arithmetic is what
# matters. Never below 1, so an idle run and one with nothing set behave
# exactly as they do today; capped, because a wrong value here turns a test
# that should fail in seconds into one that hangs for the whole job.
local cores; cores="$(nproc)"
run env -u DEJIMA_TEST_CONCURRENCY python3 -c "
import sys; sys.path.insert(0, '$REPO_ROOT/test/fixtures')
import loadscale; print(loadscale.deadline(10.0))"
[ "$status" -eq 0 ] && [ "$output" = "10.0" ] \
|| { echo "an unset variable changed a deadline: $output"; return 1; }
run env DEJIMA_TEST_CONCURRENCY=$(( cores * 2 )) python3 -c "
import sys; sys.path.insert(0, '$REPO_ROOT/test/fixtures')
import loadscale; print(loadscale.deadline(10.0))"
[ "$output" = "20.0" ] \
|| { echo "twice the cores did not double a deadline: $output"; return 1; }
run env DEJIMA_TEST_CONCURRENCY=$(( cores / 2 )) python3 -c "
import sys; sys.path.insert(0, '$REPO_ROOT/test/fixtures')
import loadscale; print(loadscale.deadline(10.0))"
[ "$output" = "10.0" ] \
|| { echo "an under-subscribed run shortened a deadline: $output"; return 1; }
local junk
for junk in 99999999 -5 "" notanumber; do
run env DEJIMA_TEST_CONCURRENCY="$junk" python3 -c "
import sys; sys.path.insert(0, '$REPO_ROOT/test/fixtures')
import loadscale; print(loadscale.scale())"
[ "$status" -eq 0 ] || { echo "'$junk' made the scale raise: $output"; return 1; }
awk -v s="$output" 'BEGIN { exit !(s >= 1.0 && s <= 4.0) }' \
|| { echo "'$junk' produced a scale of $output"; return 1; }
done
}
@test "a changed path selects every suite that reads it, not just the one that owns it" {
# .dejima.md says nothing can tell that a rule is too narrow. A human
# cannot; this can. For each mapped path, every suite whose text names its
@@ -3004,6 +3074,296 @@ _classify() { # <value> -> prints addr|name, or exits non-zero on a refusal
grep -q '.worktrees/' "$REPO_ROOT/bin/dejima-provider-lib.sh"
}
# A briefing is the one thing a session cannot check for itself, so the four
# sentences an isolated launch makes false are worth a test each rather than a
# count. A session that believes its transcript outlives the container leaves
# work for a resume that cannot happen; one that believes ~/shared is shared
# with every session against this project treats a handoff as readable by
# people who will never see it.
@test "the isolated briefing does not claim a mount, a memory or a resume" {
local iso ord
ord="$(lib briefing_compose gitea worktree-pr claude-code)" || { echo "$ord"; return 1; }
iso="$(DEJIMA_ISOLATED=1 bash -c \
'source "$1"; briefing_compose gitea worktree-pr claude-code' _ \
"$REPO_ROOT/bin/dejima-provider-lib.sh" 2>&1)" || { echo "$iso"; return 1; }
# Each claim is asserted absent from the isolated briefing AND present in
# the ordinary one. Either half alone passes on prose that was reworded
# out of both.
local claim
for claim in \
'bind-mounted read-write and is the real' \
'every session against this project shares it' \
"this session's transcript outlives the container" \
'The project directory is mounted whole' \
'The worktree MUST live under the mounted project'
do
grep -qF "$claim" <<< "$ord" \
|| { echo "the ordinary briefing no longer says: $claim"; return 1; }
grep -qF "$claim" <<< "$iso" \
&& { echo "the isolated briefing still says: $claim"; return 1; }
done
# And what it says instead, because dropping the sentences without
# replacing them leaves a session to assume the usual arrangement.
for claim in \
'Nothing of the user'"'"'s machine is mounted here' \
'made fresh for this session' \
'This session cannot be resumed' \
'There is no project directory'
do
grep -qF "$claim" <<< "$iso" \
|| { echo "the isolated briefing does not say: $claim"; return 1; }
done
# The harness fragment is what promises the resume, and it is replaced
# rather than dropped - so the resume command must not survive either.
grep -qF 'claude --resume' <<< "$ord" \
|| { echo "the ordinary briefing no longer carries the resume command"; return 1; }
grep -qF 'claude --resume' <<< "$iso" \
&& { echo "the isolated briefing offers a resume that cannot work"; return 1; }
return 0
}
@test "the mode a briefing is composed for cannot be set from the environment alone" {
# DEJIMA_ISOLATED reaching the composer from the environment is how the
# test above drives it, which is exactly why bin/dejima must bind it
# before anything reads it: an inherited value would otherwise decide
# what a session is told about a launch that is not isolated at all.
grep -q '^DEJIMA_ISOLATED=0$' "$REPO_ROOT/bin/dejima" \
|| { echo "bin/dejima does not bind DEJIMA_ISOLATED before use"; return 1; }
# And the flag is the only thing that turns it on, which is the whole of
# "a CLI flag and nothing else".
local sets
sets="$(grep -c 'DEJIMA_ISOLATED=1' "$REPO_ROOT/bin/dejima")"
[ "$sets" = 1 ] \
|| { echo "bin/dejima sets DEJIMA_ISOLATED=1 in $sets places, not just the flag"; return 1; }
grep -q -- '--isolated) DEJIMA_ISOLATED=1; shift ;;' "$REPO_ROOT/bin/dejima" \
|| { echo "the flag is not what sets it"; return 1; }
}
@test "the host-side features that read a transcript are not started for an isolated launch" {
# Three things key on a workspace that an isolated launch has no host
# side of: the epitaph, the conversation shipper and the audit. Each
# would otherwise do its work against /dejima/work - a path that is not
# on the host at all - and report nothing, which is the shape of a
# feature that looks present and is not.
#
# Two of the three are off because the flag overrides their config key,
# which is what makes the banner and --print-config agree with what
# actually happens - they said "epitaph on" while nothing was kept. So
# what is asserted here differs by feature: the overrides for those two,
# and the mode check for the audit, which has no key to override.
local src="$REPO_ROOT/bin/dejima" flat key call
# Continuations flattened, so a guard split across lines still reads as
# one condition.
flat="$(sed -z 's/\\\n[[:space:]]*/ /g' "$src")"
for key in epitaph.enabled logging.conversation; do
grep -qF "_set_override $key off" <<< "$flat" \
|| { echo "an isolated launch does not turn $key off"; return 1; }
done
# Under the flag and nowhere else: an unconditional override would turn
# the feature off for every launch.
awk '
/^if \[ "\$DEJIMA_ISOLATED" = 1 \]; then/ { inblock = 1; next }
inblock && /^fi/ { inblock = 0 }
inblock && /_set_override (epitaph\.enabled|logging\.conversation) off/ { n++ }
END { exit !(n == 2) }
' <<< "$flat" \
|| { echo "the two overrides are not both inside the isolated block"; return 1; }
# And each feature still reads the key, so the override is what decides.
grep -qF 'config_get_bool epitaph enabled' <<< "$flat" \
|| { echo "the epitaph no longer reads its own key"; return 1; }
grep -qF 'config_get_bool logging conversation' <<< "$flat" \
|| { echo "the shipper no longer reads its own key"; return 1; }
# The audit has no config key to override, so it is gated on the mode.
for call in 'dejima-audit" snapshot' 'dejima-audit" compare'; do
grep -qF "$call" <<< "$flat" \
|| { echo "bin/dejima no longer calls: $call"; return 1; }
awk -v call="$call" '
index($0, "DEJIMA_ISOLATED") { guard = NR }
index($0, call) { if (guard && NR - guard <= 8) found = 1 }
END { exit !found }
' <<< "$flat" \
|| { echo "no DEJIMA_ISOLATED guard precedes: $call"; return 1; }
done
return 0
}
@test "a seed staged after the mount guard is still refused over a writable mount" {
# The seed list is loaded twice: once with the other list loads, where the
# mount guard runs, and once after harness_prepare_config, because a hook
# whose job is to make a file has to run before the list that mounts it.
#
# The second pass could have rested on an argument - what a plugin adds
# there is a path dejima made this run - and that is true of the one
# plugin that does it today and not a property of the mechanism. So the
# rule is applied again, and this is the refusal being driven: nothing in
# the tree exercises it, because no shipped plugin derives a seed from
# state the hook changed.
# Wired in, first: the behaviour below is driven against the function, so
# nothing there would notice the call being deleted. It runs immediately
# after the second load and before anything is mounted.
local flat
flat="$(sed -z 's/\\\n[[:space:]]*/ /g' "$REPO_ROOT/bin/dejima")"
# awk rather than a two-line grep pattern: `grep -F` reads one of those
# as two alternatives and matches either line, so the check passed with
# the call deleted. What has to hold is the adjacency.
awk '
prev == "harness_seed_load" && $0 == "_refuse_rw_over_harness_host_config" { found = 1 }
{ prev = $0 }
END { exit !found }
' <<< "$flat" \
|| { echo "no seed load is followed by the guard"; return 1; }
local out
run bash -c '
set -Eeuo pipefail
src="'"$SANDBOX_DIR"'/../bin"
eval "$(sed -n "/^_paths_overlap()/,/^}/p" "$src/dejima-harness-lib.sh")"
eval "$(sed -n "/^_refuse_rw_over_harness_host_config()/,/^}/p" "$src/dejima")"
# Stubbed, because what is under test is the refusal rather than how
# the list is assembled - which has its own tests further up.
# Captured first, under a name the function does not declare local:
# inside it, $2 is the function'"'"'s own argument, and `guarded` is one
# of its locals - which under set -u is unset rather than ours.
t_guarded="$2"
_harness_host_config() { HARNESS_HOST_CONFIG+=("$t_guarded"); }
RW_MOUNT_PATHS=("$1")
_refuse_rw_over_harness_host_config
echo ALLOWED' _ /tmp/dejima-t-rw /tmp/dejima-t-rw/inside/config.json
[ "$status" -ne 0 ] \
|| { echo "a writable mount over a staged seed was allowed: $output"; return 1; }
[[ "$output" == *"Refusing writable mount"* ]] \
|| { echo "the refusal does not say what it refused: $output"; return 1; }
[[ "$output" == *"once its per-run configuration"* ]] \
|| { echo "the refusal does not say why it is late: $output"; return 1; }
# And a path that does not overlap is left alone, so the refusal is about
# the overlap rather than about there being any seed at all.
run bash -c '
set -Eeuo pipefail
src="'"$SANDBOX_DIR"'/../bin"
eval "$(sed -n "/^_paths_overlap()/,/^}/p" "$src/dejima-harness-lib.sh")"
eval "$(sed -n "/^_refuse_rw_over_harness_host_config()/,/^}/p" "$src/dejima")"
# Captured first, under a name the function does not declare local:
# inside it, $2 is the function'"'"'s own argument, and `guarded` is one
# of its locals - which under set -u is unset rather than ours.
t_guarded="$2"
_harness_host_config() { HARNESS_HOST_CONFIG+=("$t_guarded"); }
RW_MOUNT_PATHS=("$1")
_refuse_rw_over_harness_host_config
echo ALLOWED' _ /tmp/dejima-t-rw /tmp/dejima-t-elsewhere/config.json
[ "$status" -eq 0 ] && [ "$output" = ALLOWED ] \
|| { echo "an unrelated writable mount was refused: $output"; return 1; }
}
@test "every store dejima refuses to mount is one it warns about inside the workspace" {
# Two lists that have to say the same thing. GUARDED_STORES is what a
# [mounts] entry or --ro/--rw is refused for naming;
# warn_if_inside_workspace is what an operator hears when the same path is
# inside the project being mounted instead. The workspace mount itself is
# not refused - that is a deliberate decision with its own tests - so the
# warning is the whole of what a session's own project gets.
#
# They drifted: three stores were warned about and six were guarded, so a
# path dejima would not mount through one route was mounted silently
# through the widest one.
#
# Checked by name in both directions rather than against a list kept here,
# which would need the same updating the code does.
local src="$REPO_ROOT/bin/dejima" flat guarded warned missing="" n
flat="$(sed -z 's/\\\n[[:space:]]*/ /g' "$src")"
guarded="$(sed -n 's/^[[:space:]]*GUARDED_STORES=(\(.*\))[[:space:]]*$/\1/p' <<< "$flat" \
| grep . | head -1)"
[ -n "$guarded" ] || { echo "GUARDED_STORES could not be read"; return 1; }
warned="$(grep '^warn_if_inside_workspace ' <<< "$flat")"
n="$(grep -c '^warn_if_inside_workspace ' <<< "$flat")"
[ "$n" -ge 6 ] || { echo "only $n stores are warned about"; return 1; }
# Every name the guarded list resolves a store through has to appear in a
# warning. Names rather than whole expressions, because two of the
# entries nest a command substitution inside another and no single
# tokenising of that is worth trusting.
local name
while IFS= read -r name; do
[ -n "$name" ] || continue
grep -qF -- "$name" <<< "$warned" \
|| missing="$missing $name"
done < <(grep -oE '\$\(([a-z_]+)|\$([A-Z_]+)' <<< "$guarded" \
| sed 's/^\$(\?//' | sort -u)
[ -z "$missing" ] \
|| { echo "guarded but never warned about:$missing"; return 1; }
# And the other direction: a warning for something the mount guard does
# not cover would be the two lists disagreeing the other way.
while IFS= read -r name; do
[ -n "$name" ] || continue
grep -qF -- "$name" <<< "$guarded" \
|| missing="$missing $name"
done < <(sed -n 's/^warn_if_inside_workspace "[^"]*"[[:space:]]*"\?\$(\?\([A-Za-z_]*\).*/\1/p' <<< "$flat" \
| grep . | sort -u)
[ -z "$missing" ] \
|| { echo "warned about but not guarded:$missing"; return 1; }
}
@test "the compose file's volumes can each be replaced for one launch" {
local compose="$SANDBOX_DIR/docker-compose.yml"
# The working directory is the one entry with no usable default: an
# isolated launch must mount no host path there, and a compose list entry
# cannot be empty. So bin/dejima always supplies the whole spec, and the
# file fails loudly rather than mounting something unintended.
grep -q '\${SANDBOX_WORKSPACE_MOUNT:?' "$compose" \
|| { echo "the workspace mount is not a single required variable"; return 1; }
grep -qE '^\s*-\s*\$\{WORKSPACE_DIR[:?]' "$compose" \
&& { echo "the workspace is still mounted from WORKSPACE_DIR directly"; return 1; }
# Every volume that outlives a container is named through a variable, so a
# launch can be given its own - and every default has to be the name
# Docker would have chosen without one, or a release silently swaps a
# volume for a fresh empty one and orphans what was in it.
#
# That name is `<compose project>_<key>`, so the check derives it from the
# file rather than listing the four. A default that merely looks right -
# the key alone, which is most of it - is the mistake this is here for,
# and it is one a list of expected strings would have been updated to
# match.
local project
project="$(sed -nE 's/^name:[[:space:]]*([a-z0-9_-]+)[[:space:]]*$/\1/p' "$compose")"
[ -n "$project" ] || { echo "the compose file sets no project name"; return 1; }
local checked=0 key default
while IFS=$'\t' read -r key default; do
checked=$((checked + 1))
[ "$default" = "${project}_${key}" ] \
|| { echo "volume $key defaults to '$default', not '${project}_${key}'"
echo "- so a launch that sets nothing gets a different volume"
return 1; }
done < <(awk '
/^volumes:/ { in_block = 1; next }
in_block && /^[^[:space:]]/ { in_block = 0 }
in_block && /^ [a-z0-9_-]+:[[:space:]]*$/ {
key = $1; sub(/:$/, "", key); next
}
in_block && /^ name:/ && key != "" {
# name: ${VAR:-default}
if (match($0, /:-[^}]+\}/)) {
d = substr($0, RSTART + 2, RLENGTH - 3)
print key "\t" d
}
key = ""
}
' "$compose")
# A loop over nothing passes. Four volumes today, and a fifth would have
# to be read by this rather than bypass it.
[ "$checked" -ge 4 ] \
|| { echo "only $checked volume default(s) were checked"; return 1; }
return 0
}
@test "the worktree-pr workflow tells the agent to close issues via the PR body" {
# The session ends at the PR and the merge happens later, without the
# agent - so anything it defers to "after the merge" is something it
@@ -4356,6 +4716,20 @@ _guard_repo() {
run _git_guard "$repo" -c remote.origin.pushurl=git@evil.invalid:o/r.git push origin feature
[[ "$output" == *"may only push to"* ]] \
|| { echo "$output"; return 1; }
# --config-env is the same thing with the value read from the environment,
# in both spellings. The separated one also takes the next word, so an
# option this did not know would have left `remote.origin.pushurl=PUSHURL`
# to be read as the subcommand - and anything that is not `push` is a
# command the guard has nothing to say about.
PUSHURL=git@evil.invalid:o/r.git run _git_guard "$repo" \
--config-env remote.origin.pushurl=PUSHURL push origin feature
[[ "$output" == *"may only push to"* ]] \
|| { echo "separated --config-env: $output"; return 1; }
PUSHURL=git@evil.invalid:o/r.git run _git_guard "$repo" \
--config-env=remote.origin.pushurl=PUSHURL push origin feature
[[ "$output" == *"may only push to"* ]] \
|| { echo "attached --config-env: $output"; return 1; }
}
@test "the hook sees a push however git's own options are spelled" {
@@ -4369,6 +4743,7 @@ _guard_repo() {
for cmd in "git --no-pager push origin main" \
"git -c core.pager=cat push origin main" \
"git --git-dir=$repo/.git push origin main" \
"git --config-env http.extraHeader=HDR push origin main" \
"ls && git push origin main" \
"/usr/bin/git push origin main" \
"/opt/elsewhere/git push origin main"; do
@@ -4675,8 +5050,11 @@ _repo_guard_shim() {
DEJIMA_FORGE_REPO=own/thing
DEJIMA_FORGE_REPO_PATHS="api:repos")
local form
# The last one carries a brace, which is only an exemption for {owner} and
# {repo}: another repository's path is still that path with a placeholder
# in the query string.
for form in "repos/other/thing/issues" "/repos/other/thing/issues" \
"repos/other/thing"; do
"repos/other/thing" "repos/other/thing/issues?per_page={n}"; do
run env "${env_common[@]}" "$d/gh" api "$form"
assert_forge_refused "Blocked by dejima" "gh api $form" || return 1
done
@@ -4686,7 +5064,12 @@ _repo_guard_shim() {
# reached every endpoint the token did, with a write among them (#365).
# Under the declared subcommand the question is inverted: a target this can
# read has to be present.
for form in "user/repos" "/orgs/someone/repos" "-X POST /user/repos" "rate_limit"; do
# The brace exemption below is for {owner}/{repo} in an endpoint. A brace
# anywhere else - a field value, a --template - says nothing about which
# repository the call reaches, so it must not stand in for a target.
for form in "user/repos" "/orgs/someone/repos" "-X POST /user/repos" "rate_limit" \
"user/repos -f x={y}" "-X POST /orgs/someone/repos -f name={x}" \
"user --template {{.login}}"; do
# shellcheck disable=SC2086
run env "${env_common[@]}" "$d/gh" api $form
assert_forge_refused "names no endpoint under" "gh api $form" || return 1
@@ -5164,8 +5547,15 @@ comment forge-cli issue comment"
# Blocked in the sandbox, including path-qualified - /usr/local/bin is
# where the image puts it, so a bare-name-only pattern would miss the
# form an agent reaching past PATH would type.
#
# Including behind a prefix word. An absolute path to the real CLI steps
# over the PATH shadow, so if the hook only read the segment's first word
# that combination would have no layer looking at it.
local cmd
for cmd in 'gitea-pr merge 24' '/usr/local/bin/gitea-pr merge 24' 'cd /tmp && gitea-pr merge 24'; do
for cmd in 'gitea-pr merge 24' '/usr/local/bin/gitea-pr merge 24' 'cd /tmp && gitea-pr merge 24' \
'env /usr/local/lib/dejima/forge/gitea-pr merge 24' \
'nohup /usr/local/lib/dejima/forge/gitea-pr merge 24' \
'timeout 5 /usr/local/lib/dejima/forge/gitea-pr merge 24'; do
payload=$(jq -n --arg c "$cmd" '{tool_input: {command: $c}}')
run bash -c "printf '%s' \"\$1\" | DEJIMA=1 DEJIMA_MERGE_BLOCK='gitea-pr merge' bash '$hook'" _ "$payload"
[ "$status" -eq 2 ]
@@ -5481,19 +5871,54 @@ registered_commands() {
|| { echo "the reachability check is not retried"; return 1; }
# The negatives: not.
local line
local line seen=0
while IFS= read -r line; do
seen=$((seen + 1))
case "$line" in
*retry_positive*) echo "a negative control is retried: $line"; return 1 ;;
esac
done < <(grep -E 'verify_refused|verify_port_refused|verify_ipv6_refused|PROBE_NAME\)' <<< "$v")
# Counted, because a loop over nothing passes: renaming those helpers would
# leave this asserting that none of zero lines retries.
[ "$seen" -ge 3 ] \
|| { echo "found $seen negative controls in verify(), so this checked almost nothing"; return 1; }
# And the lookup has to report emptiness, or retrying it is a no-op: it
# returns 0 whether or not it found an address otherwise.
local r
r=$(sed -n '/^resolve_one()/,/^}/p' "$p")
[ "$(grep -c 'return 1' <<< "$r")" -ge 1 ] \
|| { echo "resolve_one cannot signal 'no address', so retrying it does nothing"; return 1; }
# And the lookup has to report BOTH ways of having no address, or retrying
# it is a no-op for one of them. Driven rather than grepped: `return 1`
# appears in that function for the empty-answer case, so its presence was
# satisfied while a failing dig still returned 0 (#345).
local case_name
for case_name in failed empty; do
run bash -c '
set -u
d="$2/digstub"; mkdir -p "$d"
if [ "$3" = failed ]; then
printf "#!/bin/bash\nexit 9\n" > "$d/dig"
else
printf "#!/bin/bash\nexit 0\n" > "$d/dig"
fi
chmod +x "$d/dig"
PATH="$d:$PATH"
eval "$(sed -n "/^resolve_one()/,/^}/p" "$1")"
resolve_one some.example
' _ "$p" "$BATS_TEST_TMPDIR" "$case_name"
[ "$status" -ne 0 ] \
|| { echo "resolve_one reported success for a $case_name lookup, so retrying it does nothing"; return 1; }
done
# And it still succeeds when there IS an address, or the retry would spin
# on every name and the boot would fail on all of them.
run bash -c '
set -u
d="$2/digok"; mkdir -p "$d"
printf "#!/bin/bash\necho 192.0.2.7\n" > "$d/dig"
chmod +x "$d/dig"
PATH="$d:$PATH"
eval "$(sed -n "/^resolve_one()/,/^}/p" "$1")"
resolve_one some.example
' _ "$p" "$BATS_TEST_TMPDIR"
[ "$status" -eq 0 ] || { echo "resolve_one failed on a good answer: $output"; return 1; }
[ "$output" = 192.0.2.7 ] || { echo "resolve_one did not print the address: $output"; return 1; }
}
@test "a retried check reports the status the command actually returned" {
@@ -5949,11 +6374,26 @@ registered_commands() {
# proxy-disabled branch.
run grep -c 'extra_mounts+=(-v "\$HOME/\.claude/\.credentials\.json:\$HOME/\.claude/\.credentials\.json")' "$REPO_ROOT/bin/dejima"
[ "$output" = "1" ]
local mount_line branch_line
mount_line=$(grep -n 'extra_mounts+=(-v "\$HOME/\.claude/\.credentials\.json' "$REPO_ROOT/bin/dejima" | cut -d: -f1)
branch_line=$(grep -n 'elif \[ -f "\$HOME/\.claude/\.credentials\.json" \]; then' "$REPO_ROOT/bin/dejima" | cut -d: -f1)
[ -n "$branch_line" ]
[ "$branch_line" -lt "$mount_line" ]
# And the branch it sits under names every condition that makes this the
# documented downgrade rather than a credential handed to a session with
# no use for it. It was `elif [ -f <that file> ]`, which fired for
# anything the proxy branch did not take - including a backend needing no
# credential at all, the arrangement docs/security.md calls the one with
# nothing in the container to steal.
local flat branch
flat="$(sed -z 's/\\\n[[:space:]]*/ /g' "$REPO_ROOT/bin/dejima")"
branch="$(grep -E '^elif .*credentials\.json' <<< "$flat")"
[ -n "$branch" ] || { echo "the mount is not under an elif that tests it"; return 1; }
local need
for need in '! backend_proxy_enabled' 'DEJIMA_BACKEND_CREDENTIAL_KIND" != none' \
'DEJIMA_BACKEND_CREDENTIAL_FILE' '-n "$DEJIMA_BACKEND_NAME"'; do
grep -qF -- "$need" <<< "$branch" \
|| { echo "the credential mount's branch does not test: $need"; return 1; }
done
# The file comparison is what makes it THIS backend's credential rather
# than any backend with the proxy off.
grep -qF -- 'readlink -m "$DEJIMA_BACKEND_CREDENTIAL_FILE"' <<< "$branch" \
|| { echo "the branch does not compare the backend's own credential file"; return 1; }
}
@test "the container gets an empty credentials file of its own" {
@@ -9436,7 +9876,8 @@ _emit_mount_at() {
# And the other direction for the shared libraries, which are the entries
# most easily forgotten because nothing puts them on PATH.
for f in bin/dejima-config-lib.sh bin/dejima-color-lib.sh bin/dejima-provider-lib.sh \
bin/dejima-harness-lib.sh bin/dejima-tui-lib.sh docker/install-forge-bins.sh; do
bin/dejima-harness-lib.sh bin/dejima-tui-lib.sh bin/dejima-version-lib.sh \
docker/install-forge-bins.sh; do
grep -q "\`$f\`" "$doc" || { echo "$f is shipped but not in the file list"; return 1; }
done
}
@@ -9963,9 +10404,156 @@ _plain_output() { printf '%s' "$output" | sed 's/\x1b\[[0-9;]*m//g'; }
[[ "$(_plain_output)" == *"harness claude-code"* ]] \
&& [[ "$(_plain_output)" == *"workflow local-only"* ]] \
|| { echo "the banner names no values: $output"; return 1; }
# The release, beside the wordmark. "which version am I running" is a
# question an operator should not have to go and ask, and this is where
# they are already looking. A checkout with no release tag says so rather
# than saying nothing, which would read as a banner that lost a field.
# Any of the three states the version library defines, because which one
# it is depends on the tree this suite is run from - a clone with tags, a
# clone without, or an unpacked tree with no .git at all. A test that
# accepted only one of them would be asserting something about the
# checkout rather than about the banner.
[[ "$(_plain_output)" =~ (unreleased|unknown|[0-9]{4}\.[0-9]{2}\.[0-9]{2}) ]] \
|| { echo "the banner does not name the release: $output"; return 1; }
rm -rf "$DEJIMA_T"
}
@test "the update notice names the release and where to read what changed" {
# A commit count answers "is there something newer" and nothing else. An
# operator deciding whether to update wants to know how far behind they
# are and what they would be getting, which is the whole reason for
# having releases at all - so the notice names both sides and the
# changelog, rather than leaving the file to be discovered.
local u="$REPO_ROOT/bin/dejima-update-check"
grep -q 'CHANGELOG.md' "$u" \
|| { echo "the update notice does not name the changelog"; return 1; }
grep -q 'dejima_version "$REPO_ROOT"' "$u" \
|| { echo "the notice does not say which release this checkout is on"; return 1; }
# Only release-shaped tags, so a tag pushed for anything else cannot
# become the answer to "what is the newest release".
grep -qF "ls-remote --tags --refs" "$u" \
|| { echo "the remote's releases are not read from its tags"; return 1; }
# Through the same hardened invocation as the branch tip: the checkout's
# own git config must not name a command this runs, which is the whole
# argument at the top of that file.
local n
n="$(grep -c 'protocol.allow=never' "$u")"
[ "$n" = 2 ] \
|| { echo "$n of the two remote lookups pin the allowed protocols"; return 1; }
# And the tags are asked for only when there is something to report, so an
# up-to-date checkout pays one request rather than two.
local flat
flat="$(sed -z 's/\\\n[[:space:]]*/ /g' "$u")"
grep -q 'if \[ -n "$tip" \] && \[ "$tip" != "$head_sha" \]; then' <<< "$flat" \
|| { echo "the tag lookup is not conditional on there being news"; return 1; }
}
@test "the changelog says no release is supported, and has somewhere to write the next" {
# The risk a changelog introduces is the expectation that comes with it:
# a list of releases reads as a list of things one may be running, and
# SECURITY.md says the tip of main is the only supported version. So the
# file has to say so itself, where someone picking a version would be
# looking.
local c="$REPO_ROOT/CHANGELOG.md"
[ -f "$c" ] || { echo "there is no changelog"; return 1; }
grep -qi 'tip of .main. is the only supported' "$c" \
|| { echo "the changelog does not say which version is supported"; return 1; }
grep -q 'SECURITY.md' "$c" \
|| { echo "the changelog does not point at the binding statement"; return 1; }
# Deliberately no sweep for words like "backport" or "maintenance
# branch". The first version of this test had one, and it failed on the
# changelog's own sentence saying there are no backports - a pattern
# cannot tell a promise from its denial, and a test that matches the shape
# of prose rather than its claim is one that gets edited until it passes.
# The two assertions above are the claim; a reader is the only check on
# the rest.
# Somewhere for the next release's entry to go before its date is known.
# Without it an entry gets written under a guessed date, and the date is
# the version.
grep -q '^## Unreleased$' "$c" \
|| { echo "the changelog has no Unreleased heading to write into"; return 1; }
# And at least one real release, named as a date, so the scheme is in the
# file rather than only in the process document.
grep -qE '^## [0-9]{4}\.[0-9]{2}\.[0-9]{2}$' "$c" \
|| { echo "the changelog names no release as a date"; return 1; }
# The release process has to name both steps, because nothing fails when
# they are forgotten: the tag is the version, and the heading is the entry.
grep -q 'git tag' "$REPO_ROOT/docs/release.md" \
|| { echo "docs/release.md does not say to tag the release"; return 1; }
grep -q 'Unreleased' "$REPO_ROOT/docs/release.md" \
|| { echo "docs/release.md does not say to rename the heading"; return 1; }
}
@test "dejima --version says which release this is, and what is not supported" {
# Answerable without a config, like --help: the moment someone asks which
# release they are on is often the moment something else is wrong.
run env DEJIMA_CONFIG_DIR=/nonexistent "$REPO_ROOT/bin/dejima" --version
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[[ "$output" == dejima\ * ]] \
|| { echo "--version does not name the program: $output"; return 1; }
# One of the three states, for the reason the banner test gives: which it
# is depends on the tree this runs from, and it has to say which rather
# than printing nothing.
[[ "$output" =~ (unreleased|unknown|[0-9]{4}\.[0-9]{2}\.[0-9]{2}) ]] \
|| { echo "--version names no release: $output"; return 1; }
# The two things an operator reading this needs next.
[[ "$output" == *CHANGELOG.md* ]] \
|| { echo "--version does not say where the changes are: $output"; return 1; }
[[ "$output" == *"tip of main"* ]] \
|| { echo "--version does not say what is supported: $output"; return 1; }
}
@test "the release a checkout is on is read from git, not from a file in the tree" {
# A VERSION file says what was released and goes on saying it while a
# checkout is pulled past that release - and the one moment the answer
# matters is the moment it would be wrong. So there is no such file, and
# the library asks git.
[ ! -e "$REPO_ROOT/VERSION" ] \
|| { echo "a VERSION file is back, and it can disagree with the tags"; return 1; }
grep -q 'describe --tags' "$REPO_ROOT/bin/dejima-version-lib.sh" \
|| { echo "the version library does not read the tags"; return 1; }
# Driven over every state a checkout can be in, in a throwaway repository:
# the answers are what --version, the banner and the update notice all
# print, and "unreleased" versus a date versus a date+N are three
# different things to tell an operator.
local t; t="$(mktemp -d)"
git -C "$t" init -q .
git -C "$t" config user.email t@example.invalid
git -C "$t" config user.name t
_ver() {
run bash -c 'source "$1"; dejima_version "$2"' _ \
"$REPO_ROOT/bin/dejima-version-lib.sh" "$t"
}
_ver; [ "$output" = unreleased ] \
|| { echo "an untagged checkout reports '$output'"; rm -rf "$t"; return 1; }
echo one > "$t/a"; git -C "$t" add a; git -C "$t" commit -qm one
git -C "$t" tag 2026.10.12
_ver; [ "$output" = 2026.10.12 ] \
|| { echo "on the tag it reports '$output'"; rm -rf "$t"; return 1; }
echo two > "$t/b"; git -C "$t" add b; git -C "$t" commit -qm two
_ver; [ "$output" = 2026.10.12+1 ] \
|| { echo "one commit past the tag it reports '$output'"; rm -rf "$t"; return 1; }
# A tag that is not a release must not become the answer.
git -C "$t" tag some-marker
_ver; [ "$output" = 2026.10.12+1 ] \
|| { echo "a non-release tag became the version: '$output'"; rm -rf "$t"; return 1; }
# And the release part of that, which is what two checkouts are compared on.
run bash -c 'source "$1"; dejima_version_release "2026.10.12+1"' _ \
"$REPO_ROOT/bin/dejima-version-lib.sh"
[ "$output" = 2026.10.12 ] || { echo "the release part is '$output'"; rm -rf "$t"; return 1; }
rm -rf "$t"
# No git at all, which is an unpacked tarball rather than a clone.
local plain; plain="$(mktemp -d)"
run bash -c 'source "$1"; dejima_version "$2"' _ \
"$REPO_ROOT/bin/dejima-version-lib.sh" "$plain"
[ "$output" = unknown ] || { echo "a non-checkout reports '$output'"; }
rmdir "$plain"
[ "$output" = unknown ]
}
@test "the startup banner is off when the config says so" {
# A banner is exactly the thing that gets in the way when you launch
# dejima forty times in an afternoon, so it has a switch.
@@ -10173,12 +10761,16 @@ _plain_output() { printf '%s' "$output" | sed 's/\x1b\[[0-9;]*m//g'; }
}
# The providers.json cline is actually given, for one config. Prints it as JSON.
#
# Out of the staging directory, not the state directory: this file is written
# per run and seeded into the container now, which is what lets an isolated
# launch - one with no state mount at all - be configured.
_cline_providers_json() {
local cfg="$1" out
_cline_prepare "$cfg" "$BATS_TEST_TMPDIR/ws" >/dev/null 2>&1 || return 1
out="$(ls -d "$(sed -n 's/^state_dir *= *//p' "$cfg/config")"/*/settings 2>/dev/null | head -1)"
out="$(find "$cfg/stage" -name providers.json -print -quit 2>/dev/null)"
[ -n "$out" ] || return 1
cat "$out/providers.json"
cat "$out"
}
@test "cline's provider type follows the provider's api_style, not a second setting" {
@@ -10646,6 +11238,111 @@ _host_clamp() { # _host_clamp <EGRESS_HOST_PORTS value>
[[ "$output" != *permit* ]]
}
@test "the host entry's clamp is matched before the rules that would widen it" {
# The clamp said the right thing and never saw the packets. The open
# class permits on every port, and the host's own address lands in it by
# several ordinary routes - a host-resident LOKI_URL, a self-served
# model's base_url, `dejima-allow <gateway>` - so the permit answered
# first and the host was open on every port, sshd and the remote-access
# front door included.
#
# Asserted on the order the emitters are called in, which is what decides
# it: nft takes the first match.
local policy="$SANDBOX_DIR/egress-policy" host_at open_at forge_at
host_at="$(grep -n '^ emit_host_clamp host_v4' "$policy" | cut -d: -f1)"
open_at="$(grep -n 'ip daddr @literal_v4 goto permit' "$policy" | cut -d: -f1)"
forge_at="$(grep -n '^ emit_forge_clamp ip forge_v4' "$policy" | cut -d: -f1)"
[ -n "$host_at" ] && [ -n "$open_at" ] && [ -n "$forge_at" ] \
|| { echo "the rule emitters could not be found"; return 1; }
[ "$host_at" -lt "$open_at" ] \
|| { echo "the open class permits the host's address before the clamp sees it"
return 1; }
# And still after the forge clamp, so a host-resident forge is read as a
# forge rather than as the host.
[ "$forge_at" -lt "$host_at" ] \
|| { echo "the host clamp now answers for a host-resident forge"; return 1; }
}
@test "the launcher offers host candidates and the filter decides which are the host" {
# Split across the two processes because neither knows both halves. The
# launcher knows which destinations this launch configured and cannot know
# the host's address; the filter works that out from the default route.
#
# Comparing in the launcher against EGRESS_HOST_ADDR looked equivalent and
# was not: that variable is the operator's override for a wrong
# derivation, so it is normally unset, and nothing was ever derived - a
# host-resident Loki at the gateway address stopped being reachable the
# moment the clamp went first.
local src="$REPO_ROOT/bin/dejima" flat
flat="$(sed -z 's/\\\n[[:space:]]*/ /g' "$src")"
grep -qF '_egress_host_candidate' <<< "$flat" \
|| { echo "nothing offers a host candidate"; return 1; }
grep -qF -- '-e EGRESS_HOST_CANDIDATES' <<< "$flat" \
|| { echo "the candidates are not passed to the filter container"; return 1; }
# The launcher's half: a literal address with an explicit port, or
# nothing. A wrong answer is either a port nobody opened on the host or a
# service nobody can reach.
_cand() {
run bash -c '
eval "$(sed -n "/^_egress_host_candidate()/,/^}/p" "'"$REPO_ROOT"'/bin/dejima")"
printf "[%s]" "$(_egress_host_candidate "$1")"' _ "$1"
}
_cand "http://172.17.0.1:3100"
[ "$output" = "[172.17.0.1:3100]" ] || { echo "the gateway candidate was not offered: $output"; return 1; }
# Userinfo and a query do not become part of it: a password starting with
# a digit, and a query with no path, each read as the port.
_cand "http://u:9secret@172.17.0.1:3100/v1"
[ "$output" = "[172.17.0.1:3100]" ] || { echo "userinfo reached the port: $output"; return 1; }
_cand "http://172.17.0.1:3100?x=1"
[ "$output" = "[172.17.0.1:3100]" ] || { echo "a query reached the port: $output"; return 1; }
local url
while IFS= read -r url; do
[ -n "$url" ] || continue
_cand "$url"
[ "$output" = "[]" ] || { echo "offered $output for $url"; return 1; }
done <<CASES
https://loki.example.com/loki/api/v1/push
https://172.17.0.1/x
http://loki.internal:3100
http://172.17.0.1
http://[fe80::1]/
http://[fe80::1]:3100/
CASES
# The filter's half: a candidate's port is kept only if its address is one
# the host was discovered at.
_fold() { # _fold <HOST_PORTS> <HOST_CANDIDATES> <addr>...
local ports="$1" cands="$2"; shift 2
run env HOST_PORTS_IN="$ports" HOST_CANDIDATES_IN="$cands" bash -c '
set -Eeuo pipefail
HOST_PORTS=$HOST_PORTS_IN
HOST_CANDIDATES=$HOST_CANDIDATES_IN
die() { printf "FAILED: %s\n" "$*" >&2; exit 1; }
eval "$(sed -n "/^fold_host_candidates()/,/^}/p" "'"$SANDBOX_DIR"'/egress-policy")"
fold_host_candidates "$@"
printf "[%s]" "$HOST_PORTS"' _ "$@"
}
_fold "" "172.17.0.1:3100" 172.17.0.1
[ "$output" = "[3100]" ] || { echo "the host's own candidate was dropped: $output"; return 1; }
_fold "" "172.17.0.1:3100" 192.168.9.9
[ "$output" = "[]" ] || { echo "a candidate that is not the host was kept: $output"; return 1; }
# The operator's own value is extended, not replaced, and a repeat does
# not double.
_fold "22000" "172.17.0.1:3100 10.0.0.5:8080" 172.17.0.1 10.0.0.5
[ "$output" = "[22000,3100,8080]" ] || { echo "the combined list came out as $output"; return 1; }
_fold "3100" "172.17.0.1:3100" 172.17.0.1
[ "$output" = "[3100]" ] || { echo "a port already present was added again: $output"; return 1; }
# `any` is wider than anything a candidate could add.
_fold "any" "172.17.0.1:3100" 172.17.0.1
[ "$output" = "[any]" ] || { echo "any was narrowed to $output"; return 1; }
# And nothing at all when the host has no entry.
_fold "" "172.17.0.1:3100"
[ "$output" = "[]" ] || { echo "a candidate was kept with no host address: $output"; return 1; }
return 0
}
@test "the host entry permits the ports it was given, and refuses the rest" {
_host_clamp "3100"
[ "$status" -eq 0 ]
-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,