Fixes the five defects in #61, and closes a privilege-scope escalation that the third of them opened up.
The five asks
--project means project-level, not container-level. Container work now happens only when no scope flag was given, or --container was passed explicitly. --project/--global alone never consult Docker — no container listing, no "No running dejima container found", no reachability probe. --project --container <id> <domain> still does both, because both were asked for.
The old order is removed rather than deprecated; it fails loudly. dejima-dep is in scope by choice — two sibling CLIs driven by the same dejima-project approve and described in the same agent briefing should not disagree about what --project means.
The optional path needs one precise rule, because --project X is ambiguous on its own: --project consumes the next token as its path only if doing so still leaves the command its required trailing positional(s). The obvious simplification — "never consume the last token" — silently sends dejima-dep list --project ~/p to the current directory, because list has no trailing positional to protect. That case has its own test.
--project writes the declaration too. It records the approval under ~/.config/dejima (which grants) and a declaration in the project's own .dejima/<kind> (committed, grants nothing). Without the second, a domain approved with --project was invisible to dejima-project status, to the agent briefing, and to everyone else on the project. --global writes only the approval — there is no project to declare in.
dejima-project status shows approvals, not just declarations. It now lists the union, with each entry's source and a marker when an approval has no declaration behind it — the case that was invisible. The header labels each store rather than printing a bare path beside a project path.
The agent briefing names the project scope. This is the defect that generated the others' symptoms: the briefing offered only --container and --global, so an agent asked for a project-specific domain to be added container-wide and globally, and could not name the project-level command. It now gives all three with a decision rule, because a bare flag list would not have changed that choice.
Security fix, found by the whole-branch review
Making the CLIs write declarations inverted the trust direction on a path nobody guarded. <project>/.dejima/ is on a bind mount the sandboxed session can write, so a session could pre-plant a symlink there. Three reproductions against the real binaries:
.dejima/allowed-domains → the global approval store turned a --project approval into a global one, allowed in every project on the machine, while the output claimed it was declared in the project.
.dejima/allowed-domains → the global dependencies file corrupted it, so approved_entries' refuse-rather-than-proceed contract fired against the global store and bin/dejima could build a derived image for no project until the file was hand-edited.
.dejima → any directory gave an arbitrary host-file append, because mkdir -p follows a directory symlink.
The chain was closed by this branch: the new briefing tells the agent to have the user run dejima-allow --project, so a prompt-injected session could plant the symlink and request the command that trips it.
declare_entry now refuses to write when .dejima or .dejima/<kind> is a symlink or any non-regular file, and the refusal is the open, not a test before it. A check would not have been enough here: the briefing tells the agent to have the user run dejima-allow --project from a terminal outside the sandbox, so the session is normally still live — and it picks the moment, so it can spin a rename loop across any window between a check and the write. The write is therefore done through python3 (already a hard dependency): O_NOFOLLOW|O_DIRECTORY on .dejima, then O_NOFOLLOW|O_CREAT|O_APPEND on <kind>relative to that directory fd, with the duplicate check read back through the same fd. A swapped path fails the open instead of redirecting it, and neither path can be swapped for the other's sake afterwards. O_NONBLOCK plus an S_ISREG check keeps a planted FIFO from wedging the CLI, and the read-back is capped at 1 MiB — grep -qxF streamed in constant memory, so reading this session-owned file into a variable would have swapped the symlink for a host OOM (truncate -s 8G is sparse and costs the session nothing).
Both guards were run against a live swapper: the old check-then-write appended to the victim file within a few hundred attempts on both the file and the directory path; the new one never does, and the two tests that demonstrate it are in the suite.
Reads of .dejima/ deliberately have no symlink guard: following a link on a read only lets a session show itself its own files. The asymmetry is intentional, not an oversight.
Behaviour changes worth knowing
dejima-project approve exits non-zero when any approval failed. It previously exited 0 while printing that nothing was recorded, so approve && dejima walked into a session with none of the approvals. Relevant if you script it.
dejima-project approve no longer applies anything to a running session — it records approvals and reports persistence failures without stopping at the first one. The dependency half had no such collector and aborted the whole run under set -e; it does now.
dejima-dep rejects --container (exit 1) instead of silently ignoring it. Adopting the shared parser had given it a flag it has no concept of, so a user who thought they were scoping to a container silently got project scope.
DEJIMA_ALLOW_SKIP_SESSION is removed. The scope split made it dead — every use passed a scope flag that never reaches its guard. It was never a documented interface.
dejima-dep remove --project withdraws the approval but leaves the declaration. Editing a file the whole team shares is the project's editorial decision, not a side effect of one machine withdrawing its approval — but it means dejima-project approve will offer the entry again, so remove now says so. dejima-allow --project has no remove at all; the README notes the gap.
Known limitations, deliberately not fixed
An intermediate path component that is a session-planted symlink.--project <proj>/<subdir> where <subdir> is a symlink creates .dejima/ inside the link target. The leaf guards still fire, so the worst case is an attacker-chosen directory gaining one validated entry — no approval-store write, no append into an existing file. Not reachable through the briefing, which only ever emits the workspace root.
Hardlinks are followed. Not reachable from a session: hardlinks cannot cross the bind-mount boundary, the approval store is never mounted in, and bin/dejima refuses a read-write mount overlapping the config root.
A few tests are forward regression guards that pass against reverted code; each is paired with a discriminating neighbour, and the parser and container split are both proven empirically rather than by grep.
Testing
bats test/project.bats test/static.bats test/config.bats test/providers.bats → 409 passing, 0 failures (baseline on origin/main was 349). Whole fast suite including audit, proxy and remote → exit 0, no failures. runtime.bats not run (needs Docker). bash -n clean on every changed script; shellcheck was not available in this environment.
Each dimension of the declaration guard was mutation-checked rather than assumed: dropping O_NOFOLLOW fails all five symlink tests and both swap tests; dropping O_NONBLOCK/S_ISREG hangs the FIFO test out to timeout's 124; dropping the read cap fails the oversized-file test while its just-under-the-cap neighbour still passes.
Reviewed task by task with an adversarial pass after each, plus a whole-branch review. Defects those caught and fixed here rather than shipped, beyond the symlink escalation above:
A rewritten test silently dropped the set -e loop-abort guard for dejima-project approve; proven by mutation on two commits — deleting the collector left all eight approve tests green.
approve printed "Approved for project, but not applied to a running session" and exited 0 in a case where nothing was written at all.
A declare_entry failure aborted past an already-recorded approval, so the grant happened and the user saw only an error that read as "nothing happened".
while ... done < <(cmd) || return 1 swallowed the failure when cmd errored before printing, so status proceeded with exit 0 against a corrupt approval store — defeating a deliberate refuse-rather-than-proceed contract.
Creating the staging directory made resolve_ws-style detection treat any snapshotted workspace as having reports.
Three README claims describing removed behaviour, one of which contradicted what the previous commit had shipped.
**Summary generated by LLM agent**
Fixes the five defects in #61, and closes a privilege-scope escalation that the third of them opened up.
## The five asks
**`--project` means project-level, not container-level.** Container work now happens only when no scope flag was given, or `--container` was passed explicitly. `--project`/`--global` alone never consult Docker — no container listing, no "No running dejima container found", no reachability probe. `--project --container <id> <domain>` still does both, because both were asked for.
**Flags first, required positional last.**
```
dejima-allow [--global] [--project [path]] [--container <id>] <domain>
dejima-dep add|remove [--global] [--project [path]] <spec>
dejima-dep list [--global] [--project [path]]
```
The old order is removed rather than deprecated; it fails loudly. `dejima-dep` is in scope by choice — two sibling CLIs driven by the same `dejima-project approve` and described in the same agent briefing should not disagree about what `--project` means.
The optional path needs one precise rule, because `--project X` is ambiguous on its own: **`--project` consumes the next token as its path only if doing so still leaves the command its required trailing positional(s).** The obvious simplification — "never consume the last token" — silently sends `dejima-dep list --project ~/p` to the current directory, because `list` has no trailing positional to protect. That case has its own test.
**`--project` writes the declaration too.** It records the approval under `~/.config/dejima` (which grants) *and* a declaration in the project's own `.dejima/<kind>` (committed, grants nothing). Without the second, a domain approved with `--project` was invisible to `dejima-project status`, to the agent briefing, and to everyone else on the project. `--global` writes only the approval — there is no project to declare in.
**`dejima-project status` shows approvals, not just declarations.** It now lists the union, with each entry's source and a marker when an approval has no declaration behind it — the case that was invisible. The header labels each store rather than printing a bare path beside a project path.
**The agent briefing names the project scope.** This is the defect that generated the others' symptoms: the briefing offered only `--container` and `--global`, so an agent asked for a project-specific domain to be added container-wide and globally, and could not name the project-level command. It now gives all three with a decision rule, because a bare flag list would not have changed that choice.
## Security fix, found by the whole-branch review
Making the CLIs *write* declarations inverted the trust direction on a path nobody guarded. `<project>/.dejima/` is on a bind mount the sandboxed session can write, so a session could pre-plant a symlink there. Three reproductions against the real binaries:
- `.dejima/allowed-domains` → the global approval store turned a `--project` approval into a **global** one, allowed in every project on the machine, while the output claimed it was declared in the project.
- `.dejima/allowed-domains` → the global `dependencies` file corrupted it, so `approved_entries`' refuse-rather-than-proceed contract fired against the global store and `bin/dejima` could build a derived image for **no** project until the file was hand-edited.
- `.dejima` → any directory gave an arbitrary host-file append, because `mkdir -p` follows a directory symlink.
The chain was closed by this branch: the new briefing tells the agent to have the user run `dejima-allow --project`, so a prompt-injected session could plant the symlink *and* request the command that trips it.
`declare_entry` now refuses to write when `.dejima` or `.dejima/<kind>` is a symlink or any non-regular file, and **the refusal is the open, not a test before it**. A check would not have been enough here: the briefing tells the agent to have the user run `dejima-allow --project` from a terminal *outside* the sandbox, so the session is normally still live — and it picks the moment, so it can spin a rename loop across any window between a check and the write. The write is therefore done through `python3` (already a hard dependency): `O_NOFOLLOW|O_DIRECTORY` on `.dejima`, then `O_NOFOLLOW|O_CREAT|O_APPEND` on `<kind>` *relative to that directory fd*, with the duplicate check read back through the same fd. A swapped path fails the open instead of redirecting it, and neither path can be swapped for the other's sake afterwards. `O_NONBLOCK` plus an `S_ISREG` check keeps a planted FIFO from wedging the CLI, and the read-back is capped at 1 MiB — `grep -qxF` streamed in constant memory, so reading this session-owned file into a variable would have swapped the symlink for a host OOM (`truncate -s 8G` is sparse and costs the session nothing).
Both guards were run against a live swapper: the old check-then-write appended to the victim file within a few hundred attempts on both the file and the directory path; the new one never does, and the two tests that demonstrate it are in the suite.
Reads of `.dejima/` deliberately have no symlink guard: following a link on a read only lets a session show itself its own files. The asymmetry is intentional, not an oversight.
## Behaviour changes worth knowing
- **`dejima-project approve` exits non-zero** when any approval failed. It previously exited 0 while printing that nothing was recorded, so `approve && dejima` walked into a session with none of the approvals. Relevant if you script it.
- **`dejima-project approve` no longer applies anything to a running session** — it records approvals and reports persistence failures without stopping at the first one. The dependency half had no such collector and aborted the whole run under `set -e`; it does now.
- **`dejima-dep` rejects `--container`** (exit 1) instead of silently ignoring it. Adopting the shared parser had given it a flag it has no concept of, so a user who thought they were scoping to a container silently got project scope.
- **`DEJIMA_ALLOW_SKIP_SESSION` is removed.** The scope split made it dead — every use passed a scope flag that never reaches its guard. It was never a documented interface.
- **`dejima-dep remove --project` withdraws the approval but leaves the declaration.** Editing a file the whole team shares is the project's editorial decision, not a side effect of one machine withdrawing its approval — but it means `dejima-project approve` will offer the entry again, so `remove` now says so. `dejima-allow --project` has no remove at all; the README notes the gap.
## Known limitations, deliberately not fixed
- **An intermediate path component that is a session-planted symlink.** `--project <proj>/<subdir>` where `<subdir>` is a symlink creates `.dejima/` inside the link target. The leaf guards still fire, so the worst case is an attacker-chosen directory gaining one validated entry — no approval-store write, no append into an existing file. Not reachable through the briefing, which only ever emits the workspace root.
- **Hardlinks are followed.** Not reachable from a session: hardlinks cannot cross the bind-mount boundary, the approval store is never mounted in, and `bin/dejima` refuses a read-write mount overlapping the config root.
- A few tests are forward regression guards that pass against reverted code; each is paired with a discriminating neighbour, and the parser and container split are both proven empirically rather than by grep.
## Testing
`bats test/project.bats test/static.bats test/config.bats test/providers.bats` → **409 passing, 0 failures** (baseline on `origin/main` was 349). Whole fast suite including `audit`, `proxy` and `remote` → exit 0, no failures. `runtime.bats` not run (needs Docker). `bash -n` clean on every changed script; `shellcheck` was not available in this environment.
Each dimension of the declaration guard was mutation-checked rather than assumed: dropping `O_NOFOLLOW` fails all five symlink tests and both swap tests; dropping `O_NONBLOCK`/`S_ISREG` hangs the FIFO test out to `timeout`'s 124; dropping the read cap fails the oversized-file test while its just-under-the-cap neighbour still passes.
Reviewed task by task with an adversarial pass after each, plus a whole-branch review. Defects those caught and fixed here rather than shipped, beyond the symlink escalation above:
- A rewritten test silently dropped the `set -e` loop-abort guard for `dejima-project approve`; proven by mutation on two commits — deleting the collector left all eight approve tests green.
- `approve` printed "Approved for *project*, but not applied to a running session" and exited 0 in a case where nothing was written at all.
- A `declare_entry` failure aborted past an already-recorded approval, so the grant happened and the user saw only an error that read as "nothing happened".
- `while ... done < <(cmd) || return 1` swallowed the failure when `cmd` errored before printing, so `status` proceeded with exit 0 against a **corrupt approval store** — defeating a deliberate refuse-rather-than-proceed contract.
- Creating the staging directory made `resolve_ws`-style detection treat any snapshotted workspace as having reports.
- Three README claims describing removed behaviour, one of which contradicted what the previous commit had shipped.
Fixes #61
dejima-allow now takes [--global] [--project [path]] [--container <id>]
<domain>, using the shared parse_scope_args grammar from dejima-project-lib.sh
instead of its own ad hoc loop. The old <domain> [--global] [--project <path>]
order is rejected outright, not accepted alongside the new one.
--project/--global are approvals for future sessions, not a claim about a
container running right now, so an invocation using only those flags never
touches docker at all - the issue's headline complaint. --container is the
explicit opt-in to also patching a live session.
bin/dejima-project's own internal call had to move to the new order too,
since the old one is gone; its "no running session" comment was updated to
match (it can no longer fail there without --container).
dejima-project approve's set -e loop-abort guard (the || collector around
each dejima-allow call) had lost its only cover: the docker-failure
induction that used to exercise it is exactly what Task 2 removes. Add a
real induction (an unwritable project store) and a test that fails without
the collector.
dejima-allow --project can now fail with no Docker involved at all, so a
nonzero exit from it means the approval was refused, not merely "not
applied to a running session" - fix dejima-project's message to say so
instead of falsely claiming next-launch effect for something that was
never recorded.
Also: reorder the one old-order dejima-allow invocation left in
test/static.bats, and fix two comments (bin/dejima-allow,
test/static.bats) that still described DEJIMA_ALLOW_SKIP_SESSION as
covering --project persistence, which session_wanted now does on its own.
The existing mutual-exclusion test only used an explicit --project path,
under which dj_project is non-empty regardless of whether the check keys
on dj_project_set or on dj_project itself - so it couldn't catch a
regression to the latter. The no-path form is the one where only
dj_project_set can tell --project apart from its absence.
approve_entry runs first and ungated under set -e, so a declare_entry
failure (e.g. an unwritable project checkout) aborted the script before
either confirmation echo printed - even though the approval, which is what
actually grants, had already been written. Print the approval confirmation
first, then guard declare_entry so its failure can't abort the script and
says plainly that the approval stands.
status only ever iterated declarations, so a domain approved with
dejima-allow --project (which has no declaration behind it) was
invisible - the reported bug in issue #61. status now unions
declarations with actual approvals via a new report() function, tagging
each entry with the store it came from. classify() keeps its exact
existing contract for approve.
approve's domain loop keeps its set -e loop-abort collector (a bare
dejima-allow call would still let the first failure silently skip every
remaining domain - dejima-allow --project can fail on its own even with
no container involved), but no longer claims a domain was "approved, but
not applied to a running session" when nothing was actually recorded.
- Delete "approve visits every domain even when one fails to be
approved": it stayed green with the set -e collector deleted, so it
provided no coverage. The pre-existing "attempts every domain even
when persisting one fails" test is the real guard (confirmed by
removing the collector and observing which test actually fails).
- report()'s undeclared-approvals pass never added to `seen`, so an
entry approved in both stores and declared in neither printed twice.
- The "Restart the sandbox..." trailer printed even when every domain
failed, directly under "NOT approved ... nothing was recorded". Now
gated on something having actually been approved (a pending dep, or
at least one domain that didn't fail).
The branch made the host-side CLIs write into <project>/.dejima, which is on
the read-write bind mount the sandboxed session controls. declare_entry had
no symlink guard, so a session could plant one and then ask the user - via
the briefing this branch added, which hands over the exact `dejima-allow
--project` command - to run the CLI that follows it. Three shapes, all
reproduced against the real binaries: .dejima/allowed-domains linked at the
global store turned a project approval into a machine-wide one while the
output claimed the project; linked at the global dependency list it wrote a
domain where only specs are valid, tripping approved_entries' refusal and
blocking every project's image build; and .dejima linked at any directory
worked outright, since mkdir -p follows one. declare_entry now refuses a
symlinked directory or file, explains why in terms of what the link could
reach, and says in a comment that this is a check and not a lock - a live
session can still win the race, which is why the documented moment to run
these is with nothing running.
The rest of the review, in the same pass:
- dejima-project's dep loop had no collector, so under set -e the first
failing dependency aborted the script: the deps after it and every domain
went unattempted, with no summary - making the README's "keeps going"
claim false for dependencies. It now collects and reports like the domain
loop, and "Restart the sandbox" is gated on something having landed.
- approve exited 0 when every approval had failed, so `approve && dejima`
walked into a session with none of them. It now exits non-zero.
- dejima-dep swallowed --container, a flag it inherited from the shared
parser and has no concept of, giving a caller who meant one session a
persistent project grant instead. It refuses it.
- dejima-dep remove --project leaves the declaration in place by design, but
the next approve then re-offers the entry; it now says so.
- README: the reference section still described .dejima as written only by
the project or session. It now names the CLIs that write declarations,
keeps "a declaration grants nothing", and explains the symlink refusal.
- DEJIMA_ALLOW_SKIP_SESSION was consulted by nothing (every caller set a
scope flag, so session_wanted was already 0) and static.bats documented a
call that does not exist. Deleted; the static test now pins its absence.
- Dropped a tautological alternative in the --project-path parser test.
Two defects the previous commit introduced, both on lines it added.
The symlink guard in declare_entry only ruled out symlinks, and the
re-offer notice added to `dejima-dep remove --project` grepped the
declaration unconditionally. Both read or write a path the sandboxed
session owns, so a FIFO planted there blocks touch, the duplicate-check
grep and the append itself for as long as the session declines to open
the far end - wedging, for the full timeout, the very commands the
session briefing tells the user to run. Same class of thing the rest of
this guard exists to close, so it is closed by the same idea: the file
test widens from "not a symlink" to "regular file or absent", and the
notice's grep is gated on -f. A FIFO named .dejima needs nothing, since
mkdir -p already refuses to create over one.
Also drops a false clause from dejima-project's collector comment, which
listed a symlinked declaration path among the ways an approval can fail.
It cannot: both CLIs deliberately swallow a declare_entry failure and
exit 0, because the approval is what grants and is already written by
then. The comment now says so, rather than claiming the opposite.
declare_entry guarded the one host-side write that lands on the
session-writable bind mount with a check-then-write: test for a symlink,
then mkdir -p, then test the file, then touch/grep/append. That closes
the pre-planted case and nothing else - a session running right now can
rename across any of those windows.
The old comment bounded that with "the documented moment to run these
CLIs is with nothing running". True for dejima-dep, which needs an image
rebuild anyway; false for dejima-allow --project, because the briefing
this branch added tells the agent to have the user run it from a
terminal outside the sandbox - mid-session, at a moment the session
itself chose, since it is the one asking for the command. Winning the
race is a host-privileged append of one validated token to an
attacker-chosen path: allowed-domains widens the egress firewall for
every project, dependencies is installed as root at image build where
there is no egress firewall.
Measured against a swapper: the old guard appended to the victim 25
times in 400 attempts on the file path and twice in 300 on the
directory.
So there is no check now - the guard is the open. python3 (already a
hard dependency) opens .dejima O_RDONLY|O_DIRECTORY|O_NOFOLLOW, then the
declaration file O_NOFOLLOW|O_CREAT|O_APPEND relative to that directory
fd, so neither path can be swapped for the other's sake, and reads the
duplicate check back through the same fd. O_NONBLOCK plus an S_ISREG
check keep a planted FIFO from wedging the CLI. Same signature, same
validate-first, same idempotence, still never calls exit, and both CLIs
still print the approval before calling it.
Two tests swap each path under a live writer and assert the victim is
never written, with a refusal count so a swapper that never landed
cannot pass vacuously. Dropping O_NOFOLLOW fails all five existing
symlink tests and both new ones; dropping O_NONBLOCK/S_ISREG hangs the
FIFO test out to timeout.
The mitigation sentence is corrected in the comment and the README
rather than left claiming a bound that no longer applies.
The atomic rewrite replaced `grep -qxF`, which streams the duplicate check
in constant memory, with a plain fh.read() of the whole file. That file is
the session-writable one this guard exists for, and a session can make it
arbitrarily large for nothing:
truncate -s 8G .dejima/allowed-domains # sparse, du reports 0
Then it asks the user to run the command the briefing tells it to ask for,
and the host reads 8 GiB into a variable. Measured with the read contained
by ulimit -v: a raw MemoryError traceback. Uncontained it is the OOM
killer, aimed at whatever else the machine is running. It failed closed -
no wrong write, the approval was already recorded - so this was
availability rather than privilege, but it was the one dimension of the
new code with no bound at all, and a traceback is the wrong register for
a file every other refusal here explains.
The read now stops at LIMIT + 1 bytes and refuses above LIMIT, inside the
same fd. LIMIT + 1 rather than an fstat size check because the file can
grow between the fstat and the read. 1 MiB is orders of magnitude above
any real declaration - DEJIMA_MAX_DECLARED entries of a domain's 253
bytes is tens of KiB - and that constant is passed in from the shell
rather than duplicated, so the two cannot drift.
The test plants the 8G sparse file and runs the CLI under ulimit -v, so a
regression fails the test rather than the machine it runs on; a
just-under-the-cap neighbour proves the cap is a ceiling on a hostile
file and not a new way to refuse a large real one.
Also narrows the README's "cannot swap either path" to the two leaves the
session actually controls - an intermediate component of an
operator-supplied --project path is still resolved through, as the
residuals already say.
cd/pwd resolve --project's path logically, so a symlinked component's
name survives in $project, but declare_entry's makedirs/open resolve
the same path physically - the declaration can land somewhere other
than what $project names, including inside another project's existing
.dejima. A legitimate symlinked checkout looks identical to a planted
one from here, and only the operator can tell them apart, so this
discloses the mismatch (naming the physical target) rather than
refusing on it, which would break the ordinary case with no way
forward. The approval path is deliberately left resolved logically -
resolving it physically too would move the approval itself into the
symlink's target, turning a declaration-only redirect into an actual
cross-project grant.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Summary generated by LLM agent
Fixes the five defects in #61, and closes a privilege-scope escalation that the third of them opened up.
The five asks
--projectmeans project-level, not container-level. Container work now happens only when no scope flag was given, or--containerwas passed explicitly.--project/--globalalone never consult Docker — no container listing, no "No running dejima container found", no reachability probe.--project --container <id> <domain>still does both, because both were asked for.Flags first, required positional last.
The old order is removed rather than deprecated; it fails loudly.
dejima-depis in scope by choice — two sibling CLIs driven by the samedejima-project approveand described in the same agent briefing should not disagree about what--projectmeans.The optional path needs one precise rule, because
--project Xis ambiguous on its own:--projectconsumes the next token as its path only if doing so still leaves the command its required trailing positional(s). The obvious simplification — "never consume the last token" — silently sendsdejima-dep list --project ~/pto the current directory, becauselisthas no trailing positional to protect. That case has its own test.--projectwrites the declaration too. It records the approval under~/.config/dejima(which grants) and a declaration in the project's own.dejima/<kind>(committed, grants nothing). Without the second, a domain approved with--projectwas invisible todejima-project status, to the agent briefing, and to everyone else on the project.--globalwrites only the approval — there is no project to declare in.dejima-project statusshows approvals, not just declarations. It now lists the union, with each entry's source and a marker when an approval has no declaration behind it — the case that was invisible. The header labels each store rather than printing a bare path beside a project path.The agent briefing names the project scope. This is the defect that generated the others' symptoms: the briefing offered only
--containerand--global, so an agent asked for a project-specific domain to be added container-wide and globally, and could not name the project-level command. It now gives all three with a decision rule, because a bare flag list would not have changed that choice.Security fix, found by the whole-branch review
Making the CLIs write declarations inverted the trust direction on a path nobody guarded.
<project>/.dejima/is on a bind mount the sandboxed session can write, so a session could pre-plant a symlink there. Three reproductions against the real binaries:.dejima/allowed-domains→ the global approval store turned a--projectapproval into a global one, allowed in every project on the machine, while the output claimed it was declared in the project..dejima/allowed-domains→ the globaldependenciesfile corrupted it, soapproved_entries' refuse-rather-than-proceed contract fired against the global store andbin/dejimacould build a derived image for no project until the file was hand-edited..dejima→ any directory gave an arbitrary host-file append, becausemkdir -pfollows a directory symlink.The chain was closed by this branch: the new briefing tells the agent to have the user run
dejima-allow --project, so a prompt-injected session could plant the symlink and request the command that trips it.declare_entrynow refuses to write when.dejimaor.dejima/<kind>is a symlink or any non-regular file, and the refusal is the open, not a test before it. A check would not have been enough here: the briefing tells the agent to have the user rundejima-allow --projectfrom a terminal outside the sandbox, so the session is normally still live — and it picks the moment, so it can spin a rename loop across any window between a check and the write. The write is therefore done throughpython3(already a hard dependency):O_NOFOLLOW|O_DIRECTORYon.dejima, thenO_NOFOLLOW|O_CREAT|O_APPENDon<kind>relative to that directory fd, with the duplicate check read back through the same fd. A swapped path fails the open instead of redirecting it, and neither path can be swapped for the other's sake afterwards.O_NONBLOCKplus anS_ISREGcheck keeps a planted FIFO from wedging the CLI, and the read-back is capped at 1 MiB —grep -qxFstreamed in constant memory, so reading this session-owned file into a variable would have swapped the symlink for a host OOM (truncate -s 8Gis sparse and costs the session nothing).Both guards were run against a live swapper: the old check-then-write appended to the victim file within a few hundred attempts on both the file and the directory path; the new one never does, and the two tests that demonstrate it are in the suite.
Reads of
.dejima/deliberately have no symlink guard: following a link on a read only lets a session show itself its own files. The asymmetry is intentional, not an oversight.Behaviour changes worth knowing
dejima-project approveexits non-zero when any approval failed. It previously exited 0 while printing that nothing was recorded, soapprove && dejimawalked into a session with none of the approvals. Relevant if you script it.dejima-project approveno longer applies anything to a running session — it records approvals and reports persistence failures without stopping at the first one. The dependency half had no such collector and aborted the whole run underset -e; it does now.dejima-deprejects--container(exit 1) instead of silently ignoring it. Adopting the shared parser had given it a flag it has no concept of, so a user who thought they were scoping to a container silently got project scope.DEJIMA_ALLOW_SKIP_SESSIONis removed. The scope split made it dead — every use passed a scope flag that never reaches its guard. It was never a documented interface.dejima-dep remove --projectwithdraws the approval but leaves the declaration. Editing a file the whole team shares is the project's editorial decision, not a side effect of one machine withdrawing its approval — but it meansdejima-project approvewill offer the entry again, soremovenow says so.dejima-allow --projecthas no remove at all; the README notes the gap.Known limitations, deliberately not fixed
--project <proj>/<subdir>where<subdir>is a symlink creates.dejima/inside the link target. The leaf guards still fire, so the worst case is an attacker-chosen directory gaining one validated entry — no approval-store write, no append into an existing file. Not reachable through the briefing, which only ever emits the workspace root.bin/dejimarefuses a read-write mount overlapping the config root.Testing
bats test/project.bats test/static.bats test/config.bats test/providers.bats→ 409 passing, 0 failures (baseline onorigin/mainwas 349). Whole fast suite includingaudit,proxyandremote→ exit 0, no failures.runtime.batsnot run (needs Docker).bash -nclean on every changed script;shellcheckwas not available in this environment.Each dimension of the declaration guard was mutation-checked rather than assumed: dropping
O_NOFOLLOWfails all five symlink tests and both swap tests; droppingO_NONBLOCK/S_ISREGhangs the FIFO test out totimeout's 124; dropping the read cap fails the oversized-file test while its just-under-the-cap neighbour still passes.Reviewed task by task with an adversarial pass after each, plus a whole-branch review. Defects those caught and fixed here rather than shipped, beyond the symlink escalation above:
set -eloop-abort guard fordejima-project approve; proven by mutation on two commits — deleting the collector left all eight approve tests green.approveprinted "Approved for project, but not applied to a running session" and exited 0 in a case where nothing was written at all.declare_entryfailure aborted past an already-recorded approval, so the grant happened and the user saw only an error that read as "nothing happened".while ... done < <(cmd) || return 1swallowed the failure whencmderrored before printing, sostatusproceeded with exit 0 against a corrupt approval store — defeating a deliberate refuse-rather-than-proceed contract.resolve_ws-style detection treat any snapshotted workspace as having reports.Fixes #61
The atomic rewrite replaced `grep -qxF`, which streams the duplicate check in constant memory, with a plain fh.read() of the whole file. That file is the session-writable one this guard exists for, and a session can make it arbitrarily large for nothing: truncate -s 8G .dejima/allowed-domains # sparse, du reports 0 Then it asks the user to run the command the briefing tells it to ask for, and the host reads 8 GiB into a variable. Measured with the read contained by ulimit -v: a raw MemoryError traceback. Uncontained it is the OOM killer, aimed at whatever else the machine is running. It failed closed - no wrong write, the approval was already recorded - so this was availability rather than privilege, but it was the one dimension of the new code with no bound at all, and a traceback is the wrong register for a file every other refusal here explains. The read now stops at LIMIT + 1 bytes and refuses above LIMIT, inside the same fd. LIMIT + 1 rather than an fstat size check because the file can grow between the fstat and the read. 1 MiB is orders of magnitude above any real declaration - DEJIMA_MAX_DECLARED entries of a domain's 253 bytes is tens of KiB - and that constant is passed in from the shell rather than duplicated, so the two cannot drift. The test plants the 8G sparse file and runs the CLI under ulimit -v, so a regression fails the test rather than the machine it runs on; a just-under-the-cap neighbour proves the cap is a ceiling on a hostile file and not a new way to refuse a large real one. Also narrows the README's "cannot swap either path" to the two leaves the session actually controls - an intermediate component of an operator-supplied --project path is still resolved through, as the residuals already say.