Compare commits
51
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
247dbf73dd | ||
|
|
d7d808425c | ||
|
|
5b8f6e71be | ||
|
|
ef8cc12d9f | ||
|
|
d5b9830631 | ||
|
|
bd0f5ddab6 | ||
|
|
d4a45cc3f7 | ||
|
|
54962bb165 | ||
|
|
1efcc42294 | ||
|
|
13e8c39d0f | ||
|
|
1ba13df4df | ||
|
|
779a644e63 | ||
|
|
799dd3b6eb | ||
|
|
26bc014b3f | ||
|
|
0eafdf1d78 | ||
|
|
1f92e7a8aa | ||
|
|
c5c23682af | ||
|
|
3825d23074 | ||
|
|
1140ef056d | ||
|
|
3b1a7098d9 | ||
|
|
8cfd763b79 | ||
|
|
a63c964df9 | ||
|
|
fa1d67018b | ||
|
|
bd21eb5db0 | ||
|
|
bc05990e33 | ||
|
|
e837634c1f | ||
|
|
283527fc6d | ||
|
|
efb92cb703 | ||
|
|
77a298264c | ||
|
|
d31c707217 | ||
|
|
0e09060555 | ||
|
|
e7f5a9b541 | ||
|
|
54b00d73a6 | ||
|
|
ecb886f658 | ||
|
|
aa11bdd27b | ||
|
|
32841b4a2e | ||
|
|
760af9946f | ||
|
|
b7f641594b | ||
|
|
cd8ac7be0f | ||
|
|
8438daa07e | ||
|
|
631b73ae5f | ||
|
|
d0052161ef | ||
|
|
8a26412716 | ||
|
|
0a323ed230 | ||
|
|
db989b3277 | ||
|
|
3a6acf102a | ||
|
|
18cff7eece | ||
|
|
da0920d13f | ||
|
|
e31c651a5e | ||
|
|
7079fca1cd | ||
|
|
4dfa6e3927 |
+68
@@ -123,3 +123,71 @@ the code was being worked on:
|
||||
Both are fixed by stating the precondition (`env -u`, a stubbed `ss`) rather
|
||||
than assuming it. When a test fails only in one environment, suspect this before
|
||||
suspecting the code — and when writing one, set what you depend on.
|
||||
|
||||
## What lands on `main` here is a trust anchor, not just a release
|
||||
|
||||
`docs/responsible-use.md` is this project's own policy, and two of its points
|
||||
bind while you are working *on* dejima rather than merely with it.
|
||||
|
||||
**`.dejima/allowed-domains` is read from `origin/main` by
|
||||
dejima-ci-hardener**, which treats whatever it finds there as already
|
||||
reviewed - that is how a CI runner decides what a job may reach. Adding a
|
||||
domain to that file is not a convenience for one branch; it widens what every
|
||||
future CI job on that runner can talk to, from the moment it merges. Say what
|
||||
a domain is for in the pull request, and expect the question.
|
||||
|
||||
**Security here is not a later pass.** This repository's product is an
|
||||
argument that a boundary holds. A change that quietly weakens one - a mount
|
||||
that widens, a check that moves after the thing it guards, a test that stops
|
||||
asserting what its name claims - is a defect in the product, not a rough edge
|
||||
to tidy up afterwards. If you notice one while doing something else, say so
|
||||
rather than leaving it for a pass that may not come.
|
||||
|
||||
## Write the comment that prevents the bug, not the one that shows your work
|
||||
|
||||
The prose here gets a compression pass periodically, and the same things get
|
||||
cut every time. Save that pass the work:
|
||||
|
||||
- **Keep** the issue number, the failure mode, and why a check is shaped the
|
||||
way it is. Someone has to be able to not reintroduce the bug.
|
||||
- **Cut how you found it.** Which CI run failed, how many attempts it took,
|
||||
what you suspected first: that is your story, not the reader's.
|
||||
- **Cut the second and third consequence.** State the first. A reader who
|
||||
needs the chain can follow it.
|
||||
- **Cut anything the code directly below already says.**
|
||||
- **Cut meta-commentary** - "the useful way to read this is", "what follows
|
||||
is why". Say the thing instead.
|
||||
- **No numbers that drift.** Test counts, directory sizes, how many files a
|
||||
sweep found: wrong within a month, and nothing checks them. This applies to
|
||||
pull request descriptions too, which are read once and then stop being
|
||||
true.
|
||||
- **One place.** An edge case belongs in `docs/`, not also in the README -
|
||||
the README is what someone needs on first contact.
|
||||
|
||||
This is not an argument for thinner documentation: the note on voice in the
|
||||
README prefers thorough over polished, deliberately. It is an argument
|
||||
against padding. A paragraph carrying one fact should be one sentence.
|
||||
|
||||
The blocks that survive every pass are the ones where the argument *is* the
|
||||
content: the `O_NOFOLLOW` open in `dejima-project-lib.sh`, the id-derivation
|
||||
anchoring in `dejima-audit`'s `ack`, `--check-origin` in `dejima-ttyd`.
|
||||
Length is not the problem; length carrying no fact is.
|
||||
|
||||
## Target `development`, not `main`
|
||||
|
||||
Branches here are opened against `development`, which merges to `main`
|
||||
periodically. `main` stays releasable while work lands quickly; nothing else
|
||||
about the workflow changes.
|
||||
|
||||
`gitea-pr` defaults its base to the repository's default branch, so say so:
|
||||
|
||||
```sh
|
||||
gitea-pr create --base development --title ... --body-file -
|
||||
```
|
||||
|
||||
Forgetting is the likely mistake, and it is recoverable - a pull request's
|
||||
base can be changed after the fact, in the web UI or through the API. Check
|
||||
what you opened rather than assuming.
|
||||
|
||||
Rebase onto `origin/development` too, for the same reason: rebasing onto
|
||||
`main` puts anything already merged to `development` back in your diff.
|
||||
|
||||
+23
-10
@@ -28,9 +28,20 @@ listed here are covered too.
|
||||
|
||||
## Terms
|
||||
|
||||
The dejima name, logo and wordmark are trademarks of Brad Anderson. All rights
|
||||
are reserved. They are not licensed under the GPL; GPL-3.0 §7(e) permits
|
||||
declining to grant trademark rights.
|
||||
The dejima name, logo and wordmark are trademarks of Brad Anderson **as they
|
||||
identify this software**. A trademark attaches to a mark as used for
|
||||
particular goods and services, never to a word in the abstract, and the claim
|
||||
here goes no further than that.
|
||||
|
||||
Dejima is a real place - the island in Nagasaki harbour this project is named
|
||||
after, and rebuilt there today. Nothing in this file claims the word itself,
|
||||
its historical or geographic sense, or any use unconnected with this
|
||||
software. Writing about the island, naming something after it, or using the
|
||||
name in any way that is not about this project is outside these terms and
|
||||
always was.
|
||||
|
||||
The marks are not licensed under the GPL; GPL-3.0 §7(e) permits declining to
|
||||
grant trademark rights. All rights not granted below are reserved.
|
||||
|
||||
**You may, without asking:**
|
||||
|
||||
@@ -40,14 +51,16 @@ declining to grant trademark rights.
|
||||
|
||||
**You may not, without written permission:**
|
||||
|
||||
- modify them: recolour, redraw, crop, animate, or fold them into another mark;
|
||||
- use them in your own work, or as the mark of any other project, product or
|
||||
organisation;
|
||||
- use them so as to suggest that dejima endorses, sponsors or produced your
|
||||
work.
|
||||
- modify the artwork: recolour, redraw, crop, animate, or fold it into
|
||||
another mark;
|
||||
- use the artwork in your own work, or as the mark of any other project,
|
||||
product or organisation;
|
||||
- use the name for software in a way likely to be taken as referring to this
|
||||
project - which is the only sense in which the name is claimed at all;
|
||||
- use any of them so as to suggest that dejima endorses, sponsors or produced
|
||||
your work.
|
||||
|
||||
A fork that remains a copy of dejima may keep the files. A fork that diverges
|
||||
into a project of its own must replace them with a mark of its own.
|
||||
|
||||
Copyright in the image files is reserved. All rights not granted above are
|
||||
withheld.
|
||||
Copyright in the image files is reserved.
|
||||
|
||||
@@ -14,6 +14,12 @@ different, **fork it and make the change in your fork**. Nothing here depends
|
||||
on an upstream maintainer accepting a patch. The fork is yours to run, and
|
||||
running it is the point.
|
||||
|
||||
## Branches
|
||||
|
||||
Work is opened against `development`, which merges to `main` periodically.
|
||||
`main` is what a reader should expect to be releasable; `development` is
|
||||
where it happens first.
|
||||
|
||||
## Expect it to move
|
||||
|
||||
Significant change is coming in the near future. Config keys, interfaces, the
|
||||
|
||||
@@ -24,8 +24,8 @@
|
||||
|
||||
|
||||
|
||||
**Agents work best unsupervised. Bound the blast radius instead, and assume
|
||||
the agent is hostile.**
|
||||
**Agents work best without you babysitting them. Bound the blast radius, let them
|
||||
work, and assume they are hostile.**
|
||||
|
||||
An agent with permission prompts turned off gets a lot done. It can also be
|
||||
turned against you mid-session, by a prompt injection or a poisoned
|
||||
@@ -33,18 +33,19 @@ dependency, and it will not announce that it has been. dejima is built for
|
||||
that case: the agent works without asking, and what it can reach, take and
|
||||
break was decided before it started.
|
||||
|
||||
Runs a coding agent (Claude Code by default, or whatever command you point
|
||||
it at) inside a firewalled Docker container with
|
||||
`--dangerously-skip-permissions` (no permission prompts, all tool use
|
||||
auto-accepted), against a project bind-mounted from the host. The agent gets
|
||||
to work without stopping to ask; the container is what makes that trade a
|
||||
reasonable one.
|
||||
dejima is a **sandboxed development environment for unsupervised agentic
|
||||
work.** It runs a coding agent (Claude Code, or whatever command you point it
|
||||
at) inside a firewalled Docker container with `--dangerously-skip-permissions`
|
||||
(no permission prompts, all tool use auto-accepted), against a project
|
||||
bind-mounted from the host. The agent gets to work without stopping to ask;
|
||||
the container is what makes that trade a reasonable one.
|
||||
|
||||
**One maintainer, no support.** Nothing about this is promised: not a reply,
|
||||
not a fix, not a release. [SECURITY.md](SECURITY.md) has the detail, and is
|
||||
where to report a vulnerability rather than in a public issue.
|
||||
A container on its own is only confinement. What makes this a development
|
||||
environment is the rest: a forge workflow the agent drives itself, project
|
||||
context written for the agent rather than for you, and enough of a trail left
|
||||
behind that you can walk away from a session and still pick up what it did.
|
||||
|
||||
Around that core is most of what running an agent unattended takes:
|
||||
**What bounds it:**
|
||||
|
||||
- **An egress allowlist.** DNS and IP, IPv4 and IPv6. Anything not on the
|
||||
list does not connect, and the agent is told so.
|
||||
@@ -56,40 +57,81 @@ Around that core is most of what running an agent unattended takes:
|
||||
and dependencies it needs in `.dejima/`. Declaring is not granting: the
|
||||
approval is a separate decision you make on the host, by hand, and the
|
||||
approval store is never mounted where a session could reach it.
|
||||
- **A PR and issue workflow.** `gitea-pr` and `gitea-issue` let a session
|
||||
open pull requests and issues against Gitea or Forgejo and pull review
|
||||
feedback back in, on a token scoped for exactly that. Merging stays a
|
||||
human decision, made outside the sandbox.
|
||||
- **One project's session cannot reach another's.** `~/.claude` is not
|
||||
mounted whole. A session gets its own project's transcripts, memory and
|
||||
edit history and nothing else, so it cannot read what you did elsewhere or
|
||||
write into the memory a future session in another project will believe.
|
||||
- **CI hardening, because push access is code execution.** A session opens a
|
||||
pull request, the workflow fires, and your runner executes that commit
|
||||
before you've read a line of it. The container bounds what a session reaches
|
||||
directly, not what it can arrange to have run elsewhere. So this one is
|
||||
work on the runner: [docs/ci.md](docs/ci.md) for what it takes, and
|
||||
directly, not what it can arrange to have run elsewhere. Preventing this
|
||||
requires hardening your CI runner: [docs/ci.md](docs/ci.md) for what it
|
||||
takes, and
|
||||
[dejima-ci-hardener](https://gitbox.branderson.io/dejima/dejima-ci-hardener)
|
||||
for a tool that does it.
|
||||
- **Logging.** Every DNS lookup and rejected connection is shipped to Loki,
|
||||
along with the session transcript if you opt in.
|
||||
- **Remote access, opt-in and off by default.** Attach to a live session
|
||||
from a terminal or a browser, with a push notification when it wants you.
|
||||
- **Guard rails inside the session.** Hooks refuse obviously destructive
|
||||
commands, pushes to anywhere but the allowed remote, and merging a PR from
|
||||
inside the sandbox. These are
|
||||
fast-fail guards against an overeager agent, not a boundary against a
|
||||
hostile one: the container is the boundary.
|
||||
- **Config parity with your host.** `CLAUDE.md`, settings, hooks, plugins
|
||||
and git identity carry over; uid, home directory and project path match on
|
||||
both sides, so a session's transcript is readable from the host afterward.
|
||||
- **An exit-time audit** of the host surfaces a session could have written
|
||||
to, so a change to your git config or `~/.claude` gets surfaced.
|
||||
|
||||
Originally inspired by
|
||||
[Anthropic's reference dev container](https://github.com/anthropics/claude-code/tree/main/.devcontainer),
|
||||
reworked to run via `docker compose` directly and grown well past it since.
|
||||
**What it gives an agent to work with:**
|
||||
|
||||
- **A PR and issue workflow the agent drives itself.** `gitea-pr` and
|
||||
`gitea-issue` let a session open pull requests and issues against Gitea or
|
||||
Forgejo and pull review feedback back in, on a token scoped for exactly
|
||||
that. Merging stays a human decision, made outside the sandbox.
|
||||
- **Project context written for the agent.** A project ships a `.dejima.md`
|
||||
saying how work is done in it - `CLAUDE.md`'s job under a harness-agnostic
|
||||
name - and dejima loads it alongside a briefing on where the session is,
|
||||
what it can reach, and why the limits are there. An agent that knows it is
|
||||
in a sandbox asks for a domain instead of working around the firewall.
|
||||
- **Config parity with your host.** `CLAUDE.md`, settings, hooks, plugins
|
||||
and git identity carry over; uid, home directory and project path match on
|
||||
both sides, so a session's transcript is readable from the host afterward.
|
||||
- **Remote access, opt-in and off by default.** Attach to a live session
|
||||
from a terminal or a browser, with a push notification when it wants you.
|
||||
- **What a long session worked out, kept.** Each time the agent compacts, the
|
||||
summary it writes is saved host-side, into a store no container mounts. It
|
||||
is the only durable account of a session's reasoning, and it otherwise goes
|
||||
when the container does.
|
||||
- **Logging, off unless you turn it on.** Point `[logging] loki_url` at your
|
||||
own Loki instance and every DNS lookup and rejected connection is shipped
|
||||
there, with the session transcript too if you separately opt in. Nothing
|
||||
leaves the machine otherwise, and there is no endpoint of ours anywhere in
|
||||
this.
|
||||
|
||||
**Early development. Not ready to be depended on.** Nothing here has been
|
||||
through outside review, an external audit, or adversarial testing by anyone
|
||||
but the person who wrote it. Every security property this describes is a
|
||||
claim awaiting scrutiny, not one that has survived it. Do not put this
|
||||
anywhere that being wrong about it would matter.
|
||||
|
||||
**One maintainer, no support.** Nothing about this is promised: not a reply,
|
||||
not a fix, not a release. [SECURITY.md](SECURITY.md) has the detail, and is
|
||||
where to report a vulnerability rather than in a public issue.
|
||||
|
||||
This repo only holds the tooling; it's not where any project lives. Point
|
||||
it at a project on disk; that project is bind-mounted into the container at
|
||||
runtime.
|
||||
|
||||
A note from the developer about the voice in this repository:
|
||||
|
||||
> dejima is developed in dejima, and that means the words are written rapidly
|
||||
> by AI agents. That applies to this README, the docs, the commit messages,
|
||||
> the pull requests, and the code comments. I periodically take passes at
|
||||
> smoothing out the AI voice in the docs, but it's inefficient to do with
|
||||
> every pull request. I'm prioritizing thorough documentation over human
|
||||
> voice. If you feel something sounds painfully "AI", it is.
|
||||
>
|
||||
> The artwork (logo, wordmark, etc.) is a different story. It's made by a
|
||||
> human, and no AI tools were used to create it. See
|
||||
> [ATTRIBUTION.md](ATTRIBUTION.md).
|
||||
|
||||
[](https://gitbox.branderson.io/dejima/dejima)
|
||||
|
||||
## The name
|
||||
|
||||
[Dejima](https://en.wikipedia.org/wiki/Dejima) was a small artificial island
|
||||
@@ -221,7 +263,8 @@ every key, its default, and what it is for.
|
||||
The forge integration, the push/PR workflow, and the agent harness aren't
|
||||
hardcoded into dejima: each is a directory under `forges/`, `workflows/` or
|
||||
`harnesses/`, selected independently by `[core] forge`, `[core] workflow` and
|
||||
`[core] harness`.
|
||||
`[core] harness`. `harness` has no default and must be set, so a later
|
||||
release cannot change which one you get.
|
||||
|
||||
Ships today:
|
||||
|
||||
@@ -348,12 +391,22 @@ the guards in full, and why they fail open.
|
||||
### Configuring the session
|
||||
|
||||
Running a different editor or application than the default (`--cmd`,
|
||||
`[core] command`, `[mounts] nvim_config`), and mounting extra writable host
|
||||
directories a session should have access to (`[mounts] read_write`), are
|
||||
both config-file changes rather than anything you set up per launch.
|
||||
`[core] command`, `[mounts] nvim_config`) is a config-file change, as are the
|
||||
extra host directories a session should reach: `[mounts] readonly` and
|
||||
`[mounts] read_write`, mounted at their own path.
|
||||
|
||||
**→ [docs/configuration.md](docs/configuration.md)** - both, including which
|
||||
host paths a writable mount is refused over and why.
|
||||
For a one-off, `--extra-readonly PATH` and `--extra-read-write PATH` (short
|
||||
forms `--ro` and `--rw`, both repeatable) add a path for a single launch, for
|
||||
things like reading another checkout while migrating code out of it. They
|
||||
append to those same lists, so a path a configured mount would be refused
|
||||
over is refused here too.
|
||||
|
||||
```sh
|
||||
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.
|
||||
|
||||
## Config parity with your host Claude Code setup
|
||||
|
||||
@@ -488,6 +541,14 @@ sources, opt-in conversation logging, and viewing both in Grafana.
|
||||
|
||||
## GitHub push: blocked by default, optional opt-in
|
||||
|
||||
A session that is not being used to push to a forge should not be able to
|
||||
push to it. Your configured forge is the one you chose; GitHub is where
|
||||
everything else lives - every dependency a session reads, every repository it
|
||||
clones to understand an error. The case this prevents is an eager agent, not
|
||||
a compromised one: an unsolicited pull request on a stranger's project, in
|
||||
your name, added to a queue its maintainers are already drowning in. See
|
||||
[docs/responsible-use.md](docs/responsible-use.md) for what to do instead.
|
||||
|
||||
The sandbox can always read from GitHub - clone, fetch, `gh` API calls. By
|
||||
default it can never push there: GitHub's IP ranges are only allowlisted on
|
||||
port 443, so port 22 is physically unreachable, not merely discouraged by a
|
||||
@@ -554,7 +615,7 @@ finding is something to read and clean up before your next `git` or `claude`
|
||||
in that repo. Its diff is also time-windowed: it reports that a path changed
|
||||
while the session was running, not that the session changed it.
|
||||
|
||||
`dejima-audit ack` walks the findings and shows you each one before asking.
|
||||
`dejima-audit show` indexes what is waiting; `dejima-audit ack` walks it.
|
||||
Approving records the exact content, so any later change re-fires; ignoring
|
||||
records the surface, so that path is fine whatever it later contains; and
|
||||
skipping decides nothing, which means you are asked again next session.
|
||||
@@ -611,3 +672,7 @@ Copyright (C) 2026 Brad Anderson
|
||||
The dejima wordmark and logo are not code and are not covered by that licence.
|
||||
They are trademarks; who holds them and what may be done with them is stated
|
||||
once for the organisation, in [ATTRIBUTION.md](ATTRIBUTION.md).
|
||||
|
||||
The egress firewall's original shape came from
|
||||
[Anthropic's reference dev container](https://github.com/anthropics/claude-code/tree/main/.devcontainer).
|
||||
`docker/init-firewall.sh` records what it kept and what it did not.
|
||||
|
||||
+12
@@ -43,6 +43,18 @@ Nothing is asked in return either. There is no embargo period and no
|
||||
coordinated disclosure window. Disclose on whatever schedule you think is
|
||||
right.
|
||||
|
||||
## This is not proven software
|
||||
|
||||
These projects are in early development. Nothing in them has been through
|
||||
outside review, an external audit, or adversarial testing by anyone other
|
||||
than the people who wrote them. Where a design argument is stated it is one
|
||||
person's reasoning; where a test asserts something it asserts what that
|
||||
person thought to check.
|
||||
|
||||
Treat every security property described in any of these repositories as a
|
||||
claim awaiting scrutiny rather than one that has survived it, and do not
|
||||
deploy them where being wrong would matter.
|
||||
|
||||
## There is no support
|
||||
|
||||
These are personal projects with one maintainer.
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="122" height="20" role="img"
|
||||
aria-label="Developed in dejima">
|
||||
<title>Developed in dejima</title>
|
||||
<linearGradient id="g" x2="0" y2="100%">
|
||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||
<stop offset="1" stop-opacity=".1"/>
|
||||
</linearGradient>
|
||||
<clipPath id="r"><rect width="122" height="20" rx="3" fill="#fff"/></clipPath>
|
||||
<g clip-path="url(#r)">
|
||||
<rect width="79" height="20" fill="#282828"/>
|
||||
<rect x="79" width="43" height="20" fill="#8ec07c"/>
|
||||
<rect width="122" height="20" fill="url(#g)"/>
|
||||
</g>
|
||||
<g font-family="Verdana,DejaVu Sans,Geneva,sans-serif" font-size="11"
|
||||
text-anchor="middle">
|
||||
<text x="39.5" y="15" fill="#010101" fill-opacity=".3">Developed in</text>
|
||||
<text x="39.5" y="14" fill="#ebdbb2">Developed in</text>
|
||||
<text x="100.5" y="15" fill="#010101" fill-opacity=".3">dejima</text>
|
||||
<text x="100.5" y="14" fill="#282828">dejima</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 969 B |
+258
-121
@@ -61,6 +61,65 @@ _need_value() {
|
||||
}
|
||||
}
|
||||
|
||||
# Per-launch additions to [mounts] readonly and [mounts] read_write, for the
|
||||
# one-off cases: reading another checkout while migrating code out of it,
|
||||
# writing to a scratch directory for one session. Repeatable, one path each.
|
||||
#
|
||||
# These append to the config's own lists rather than carrying their own mount
|
||||
# code, which is the whole design. Everything that already applies to a
|
||||
# configured mount then applies to a flag automatically and with nothing
|
||||
# duplicated: the existence check, the refusal when a path names the approval
|
||||
# store or the audit stores, the refusal when the same path appears in both
|
||||
# lists, and the read-write paths the session briefing and the audit report
|
||||
# header name. A flag is a way to say where, not a way around any of that.
|
||||
EXTRA_RO_FLAGS=()
|
||||
EXTRA_RW_FLAGS=()
|
||||
FLAG_MOUNTS=""
|
||||
_add_mount_flag() {
|
||||
local flag="$1" value="$2"
|
||||
# Both lists are colon-separated, so a path containing one cannot be
|
||||
# represented in them at all - it would silently split into two mounts
|
||||
# that each fail the existence check. Say so instead.
|
||||
case "$value" in
|
||||
*:*) echo "$flag path must not contain a colon: $value" >&2; exit 1 ;;
|
||||
esac
|
||||
# Same leading-~ expansion the config's list:path type does, for the
|
||||
# quoted case the shell leaves alone.
|
||||
value="${value/#\~/$HOME}"
|
||||
# Relative paths are worth accepting here and meaningless in a config
|
||||
# file, which has no working directory to be relative to. Made absolute
|
||||
# by prefixing rather than by readlink -f: resolving would mount the
|
||||
# symlink's target under a path the operator never typed.
|
||||
case "$value" in /*) ;; *) value="$PWD/$value" ;; esac
|
||||
case "$flag" in
|
||||
--extra-readonly) EXTRA_RO_FLAGS+=("$value") ;;
|
||||
--extra-read-write) EXTRA_RW_FLAGS+=("$value") ;;
|
||||
esac
|
||||
# Recorded here, at parse time, and not where the lists are assembled:
|
||||
# that happens inside a command substitution, whose subshell would throw
|
||||
# the assignment away and leave every flag reported as a config entry.
|
||||
FLAG_MOUNTS="${FLAG_MOUNTS:+$FLAG_MOUNTS:}$value"
|
||||
}
|
||||
_append_mounts() {
|
||||
local list="$1"; shift
|
||||
local p
|
||||
for p in "$@"; do
|
||||
list="${list:+$list:}$p"
|
||||
done
|
||||
printf '%s' "$list"
|
||||
}
|
||||
# Which of the two the operator actually wrote, for the refusals below.
|
||||
# Without this a mistyped --ro is reported as a "[mounts] readonly entry",
|
||||
# sending someone to a config file that does not mention the path at all.
|
||||
|
||||
_mount_label() {
|
||||
local path="$1" from_config="$2" from_flag="$3"
|
||||
case ":$FLAG_MOUNTS:" in
|
||||
*":$path:"*) printf '%s' "$from_flag" ;;
|
||||
*) printf '%s' "$from_config" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
PRINT_CONFIG=0
|
||||
PRINT_BRIEFING=0
|
||||
ALLOW_GITHUB_PUSH_FLAG=0
|
||||
@@ -74,6 +133,12 @@ while [ $# -gt 0 ]; do
|
||||
--cmd) _need_value --cmd "${2:-}"; _set_override core.command "$2"; shift 2 ;;
|
||||
--forge) _need_value --forge "${2:-}"; _set_override core.forge "$2"; shift 2 ;;
|
||||
--workflow) _need_value --workflow "${2:-}"; _set_override core.workflow "$2"; shift 2 ;;
|
||||
--extra-readonly|--ro)
|
||||
_need_value --extra-readonly "${2:-}"
|
||||
_add_mount_flag --extra-readonly "$2"; shift 2 ;;
|
||||
--extra-read-write|--rw)
|
||||
_need_value --extra-read-write "${2:-}"
|
||||
_add_mount_flag --extra-read-write "$2"; shift 2 ;;
|
||||
--*) echo "unknown option: $1" >&2; exit 1 ;;
|
||||
*) break ;;
|
||||
esac
|
||||
@@ -164,28 +229,18 @@ if ! config_exists; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# A selected forge's env_export can name a variable core also fills from its
|
||||
# own config. Today that is GH_TOKEN, which core sets from the read-only
|
||||
# credential at [github] token_file - one every session gets regardless of
|
||||
# which forge is selected - and which a forge exporting the same name takes over
|
||||
# for the launch, since a single variable can only carry one token. Said out
|
||||
# loud rather than left as two configured credentials with one silently
|
||||
# ignored: an operator who set both is entitled to know which is in effect. A
|
||||
# warning, not a refusal - the forge's value winning is exactly what
|
||||
# selecting that forge asked for.
|
||||
# A forge's env_export can name a variable core also fills - today GH_TOKEN,
|
||||
# which core sets from [github] token_file for every session regardless of
|
||||
# forge. One variable carries one token, so the forge's wins. Warned rather
|
||||
# than left silent: an operator who configured both is entitled to know which
|
||||
# is in effect. Not a refusal, since the forge winning is what selecting it
|
||||
# asked for.
|
||||
#
|
||||
# Driven off the manifest's env_export rather than off the forge's name: the
|
||||
# collision is a property of the variable a forge exports, identical for any
|
||||
# forge that exports it, and core has no business knowing which forge that is
|
||||
# today. Checked here, ahead of --print-config's own exit below (like
|
||||
# _warn_legacy_env above it) rather than down by the GH_TOKEN block further
|
||||
# down that --print-config never reaches, so --print-config reports it too.
|
||||
#
|
||||
# Both files, not just core's: a forge key resolving to nothing is the
|
||||
# "wholly unconfigured" case forge_env_exports deliberately emits no pair for
|
||||
# (see its own comment), leaving core's own value in effect - so claiming the
|
||||
# forge's key won would be false in exactly that state. The warning only
|
||||
# means something once both are actually set.
|
||||
# Driven off env_export rather than the forge's name - the collision belongs
|
||||
# to the variable, not to any particular forge. Checked here, ahead of
|
||||
# --print-config's exit, so that reports it too. Both values must actually be
|
||||
# set: a forge key resolving to nothing leaves core's in effect, and claiming
|
||||
# the forge won would be false.
|
||||
_warn_forge_env_collisions() {
|
||||
local forge="$1" pair name ref core core_name core_section core_leaf
|
||||
local core_file forge_file
|
||||
@@ -241,8 +296,35 @@ fi
|
||||
DEJIMA_FORGE_NAME="$(config_get core forge)"
|
||||
DEJIMA_WORKFLOW_NAME="$(config_get core workflow)"
|
||||
DEJIMA_HARNESS_NAME="$(config_get core harness)"
|
||||
# No default: claude-code was one, and a release changing it would move an
|
||||
# operator who never chose to a different harness on upgrade.
|
||||
if [ -z "$DEJIMA_HARNESS_NAME" ]; then
|
||||
echo "[core] harness is not set in $(config_path)." >&2
|
||||
echo "There is no default, so a later release cannot change your choice." >&2
|
||||
echo "" >&2
|
||||
echo " [core]" >&2
|
||||
echo " harness = claude-code" >&2
|
||||
echo "" >&2
|
||||
echo "Available: $(provider_list harness | tr '\n' ' ')" >&2
|
||||
exit 1
|
||||
fi
|
||||
provider_validate_selection "$DEJIMA_FORGE_NAME" "$DEJIMA_WORKFLOW_NAME" "$DEJIMA_HARNESS_NAME" || exit 1
|
||||
|
||||
# Whether GitHub is a push destination this session, which the briefing tells
|
||||
# the agent about. Two ways in: the per-launch flag, or a forge whose hosts
|
||||
# core allowlists (github) under a workflow that pushes. Derived from the
|
||||
# manifests directly rather than from DEJIMA_WORKFLOW_PUSH, which is resolved
|
||||
# further down - this has to be settled before --print-briefing exits, or that
|
||||
# would render a briefing a real launch does not.
|
||||
DEJIMA_GITHUB_PUSH=off
|
||||
if [ "$ALLOW_GITHUB_PUSH_FLAG" = 1 ]; then
|
||||
DEJIMA_GITHUB_PUSH=on
|
||||
elif [ "$(manifest_get forge "$DEJIMA_FORGE_NAME" hosts_allowlisted_by_core)" = "on" ] \
|
||||
&& [ "$(manifest_get workflow "$DEJIMA_WORKFLOW_NAME" push)" = "on" ]; then
|
||||
DEJIMA_GITHUB_PUSH=on
|
||||
fi
|
||||
export DEJIMA_GITHUB_PUSH
|
||||
|
||||
# Resolved here, ahead of --print-briefing below, because briefing_compose
|
||||
# needs DEJIMA_RW_MOUNTS (the {{writable_mounts}} placeholder) to render the
|
||||
# same sentence a real launch renders - see the read_write mounts block this
|
||||
@@ -268,6 +350,8 @@ WORKSPACE_DIR="$(cd "$WORKSPACE_DIR" && pwd)"
|
||||
AUDIT_DIR="$(config_get audit dir)"
|
||||
BASELINE_DIR="$(config_get audit baselines_dir)"
|
||||
SANDBOX_EXTRA_MOUNTS="$(config_get mounts readonly)"
|
||||
SANDBOX_EXTRA_MOUNTS="$(_append_mounts "$SANDBOX_EXTRA_MOUNTS" \
|
||||
${EXTRA_RO_FLAGS[@]+"${EXTRA_RO_FLAGS[@]}"})"
|
||||
|
||||
# Read-write host mounts. Distinct from [core] share_dir, which is mounted at
|
||||
# ~/shared - a well-known handoff point the agent is told about by name -
|
||||
@@ -288,7 +372,41 @@ _paths_overlap() {
|
||||
case "$b/" in "$a"/*) return 0 ;; esac
|
||||
return 1
|
||||
}
|
||||
|
||||
# The stores that decide what a session may do next, and the record of what
|
||||
# it did. Read-only is not enough to make mounting them harmless: the
|
||||
# approval store discloses every project's approved domains and
|
||||
# dependencies, and the audit baselines say which findings have already been
|
||||
# waved through. Refusing beats mounting and hoping the mode holds (#15).
|
||||
GUARDED_STORES=()
|
||||
_guarded_stores() {
|
||||
[ ${#GUARDED_STORES[@]} -eq 0 ] || return 0
|
||||
GUARDED_STORES=("$(config_root)" "$AUDIT_DIR" "$BASELINE_DIR")
|
||||
}
|
||||
|
||||
# Refuse $2 if it overlaps any of the paths in $3.., in either direction: an
|
||||
# ancestor of a store exposes it just as surely as the store itself.
|
||||
_refuse_guarded_mount() {
|
||||
local kind="$1" path="$2"; shift 2
|
||||
local real guarded guarded_real
|
||||
# readlink -m rather than -f, same reason as the read_write guard below:
|
||||
# -f gives nothing for a path that does not exist yet, and an empty
|
||||
# result compares equal to nothing and would sail through.
|
||||
real="$(readlink -m "$path")"
|
||||
for guarded in "$@"; do
|
||||
[ -n "$guarded" ] || continue
|
||||
guarded_real="$(readlink -m "$guarded")"
|
||||
_paths_overlap "$real" "$guarded_real" || continue
|
||||
echo "Refusing $kind: $path" >&2
|
||||
echo "It overlaps $guarded_real, which dejima needs out of a session's reach." >&2
|
||||
echo "Even read-only, that hands the session every project's approvals or the" >&2
|
||||
echo "record of what earlier sessions were caught doing." >&2
|
||||
exit 1
|
||||
done
|
||||
}
|
||||
SANDBOX_RW_MOUNTS="$(config_get mounts read_write)"
|
||||
SANDBOX_RW_MOUNTS="$(_append_mounts "$SANDBOX_RW_MOUNTS" \
|
||||
${EXTRA_RW_FLAGS[@]+"${EXTRA_RW_FLAGS[@]}"})"
|
||||
RW_MOUNT_PATHS=()
|
||||
DEJIMA_RW_MOUNTS=""
|
||||
if [ -n "$SANDBOX_RW_MOUNTS" ]; then
|
||||
@@ -296,7 +414,7 @@ if [ -n "$SANDBOX_RW_MOUNTS" ]; then
|
||||
for path in "${rw_paths[@]}"; do
|
||||
[ -n "$path" ] || continue
|
||||
if [ ! -d "$path" ]; then
|
||||
echo "[mounts] read_write entry is not an existing directory: $path" >&2
|
||||
echo "$(_mount_label "$path" "[mounts] read_write entry" "--extra-read-write path") is not an existing directory: $path" >&2
|
||||
exit 1
|
||||
fi
|
||||
# readlink -m, not -f: -f yields nothing unless at most the final
|
||||
@@ -313,7 +431,7 @@ if [ -n "$SANDBOX_RW_MOUNTS" ]; then
|
||||
"$HOME/.claude" "$HOME/.local/bin" "$REPO_ROOT" "$WORKSPACE_DIR"; do
|
||||
guarded_real="$(readlink -m "$guarded")"
|
||||
_paths_overlap "$rw_real" "$guarded_real" || continue
|
||||
echo "Refusing [mounts] read_write entry: $path" >&2
|
||||
echo "Refusing $(_mount_label "$path" "[mounts] read_write entry" "--extra-read-write path"): $path" >&2
|
||||
echo "It overlaps $guarded_real, which dejima needs out of a session's reach." >&2
|
||||
echo "A writable mount there would let a session approve its own domains and" >&2
|
||||
echo "dependencies, delete the audit findings it is named in, or plant code" >&2
|
||||
@@ -510,12 +628,20 @@ fi
|
||||
# A valid forwarded socket with zero identities loaded forwards fine (the
|
||||
# container sees a live, working agent) but is useless for git-over-ssh -
|
||||
# warn now rather than have that surface confusingly deep into a session as
|
||||
# a failed push. Non-fatal: a task might not need git-over-ssh at all, and
|
||||
# SANDBOX_SSH_KEY (if set) provides its own identity independently of this.
|
||||
if [ "$have_forwarded_agent" = 1 ] && ! ssh-add -l >/dev/null 2>&1; then
|
||||
# a failed push. Non-fatal: a task might not need git-over-ssh at all.
|
||||
#
|
||||
# Silent when [core] ssh_key is set, because then the premise is false: the
|
||||
# key is loaded into the container's own agent and pushes work without the
|
||||
# forwarded one carrying anything. It is also the better arrangement of the
|
||||
# two - a dedicated identity that works unattended, rather than the
|
||||
# operator's own - so warning about the fallback being empty, while the
|
||||
# recommended path is configured and working, tells someone who took the
|
||||
# advice that they got it wrong.
|
||||
if [ "$have_forwarded_agent" = 1 ] && [ -z "$SANDBOX_SSH_KEY" ] \
|
||||
&& ! ssh-add -l >/dev/null 2>&1; then
|
||||
echo "Warning: ssh-agent has no identities loaded (ssh-add -l shows none)." >&2
|
||||
echo "git-over-ssh (e.g. pushing to the configured forge) will fail until you ssh-add a key," >&2
|
||||
echo "unless [core] ssh_key is also set." >&2
|
||||
echo "or set [core] ssh_key in $(config_path) to a dedicated key - see docs/configuration.md." >&2
|
||||
fi
|
||||
|
||||
# Deliberately not `gh auth token` / gh's own hosts.yml: a normal `gh
|
||||
@@ -572,47 +698,29 @@ if ! settings_error="$(jq . "$HOME/.claude/settings.json" 2>&1 >/dev/null)"; the
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Claude Code's own internal per-command sandboxing (~/.claude/settings.json's
|
||||
# "sandbox" block) is redundant here - it can't even function inside this
|
||||
# container (bwrap can't create a namespace, and getting it to would need
|
||||
# privilege escalation approaching --privileged, undoing this container's own
|
||||
# hardening), and this container is already a real, working sandbox on its
|
||||
# own. A session can't be trusted to turn it off itself, so the override is
|
||||
# generated here, on the host, and reaches Claude Code as an extra settings
|
||||
# layer: docker-compose.yml mounts this file read-only at a container-local
|
||||
# path and claude-wrapper.sh passes it as --settings, which loads above
|
||||
# ~/.claude/settings.json in the precedence order. Everything else in the
|
||||
# operator's real settings (hooks, permissions, plugins) still applies from
|
||||
# there, so this file holds only what the sandbox overrides.
|
||||
# Claude Code's own per-command sandboxing is redundant here and cannot work
|
||||
# anyway - bwrap can't create a namespace without privileges that would undo
|
||||
# this container's hardening - and a session can't be trusted to turn it off
|
||||
# itself. So the override is generated host-side and reaches Claude Code as
|
||||
# an extra --settings layer above ~/.claude/settings.json, which holds only
|
||||
# what the sandbox overrides; the operator's hooks, permissions and plugins
|
||||
# still apply from there.
|
||||
#
|
||||
# The earlier arrangement - a full modified copy of the operator's settings,
|
||||
# mounted over ~/.claude/settings.json - detached itself whenever the host
|
||||
# rewrote the real file, because that mount point sits inside the read-write
|
||||
# ~/.claude mount and a rename unlinks the dentry the mount is pinned to. The
|
||||
# session then silently fell back to the operator's settings, with sandboxing
|
||||
# back on and the notify hooks below gone. Mounting outside ~/.claude removes
|
||||
# that: nothing on the host can rename over a container-local path. See #47.
|
||||
# It is mounted at a container-local path, not over ~/.claude/settings.json.
|
||||
# That earlier arrangement detached whenever the host rewrote the real file
|
||||
# (a rename unlinks the dentry the mount is pinned to), silently restoring
|
||||
# the operator's settings with sandboxing back on. See #47.
|
||||
SANDBOX_SETTINGS_FILE="$(mktemp "${TMPDIR:-/tmp}/dejima-settings.XXXXXX.json")"
|
||||
jq -n '{sandbox: {enabled: false}}' > "$SANDBOX_SETTINGS_FILE"
|
||||
|
||||
# ~/.claude.json mixes live app state (project registry, usage stats,
|
||||
# caches) with config (mcpServers) that can't be split at the mount level -
|
||||
# unlike settings.json above, it can't just be made read-only either:
|
||||
# Claude Code does an atomic write-temp-then-rename against this exact file
|
||||
# during normal startup (before login even completes), and a read-only
|
||||
# file makes that hang instead of failing cleanly (confirmed against a
|
||||
# real read-only copy of this file - see
|
||||
# https://github.com/anthropics/claude-code/issues/29162). Generate a
|
||||
# throwaway copy instead, seeded from the real file, and mount *that*
|
||||
# read-write in its place - same "generate a sandbox-specific file on the
|
||||
# host" pattern as SANDBOX_SETTINGS_FILE above, just solving a different
|
||||
# problem: nothing a compromised session writes here (mcpServers or
|
||||
# anything else) ever reaches the real file, since the copy is discarded,
|
||||
# not merged back, when this session ends. This also means concurrent
|
||||
# sandbox sessions never contend over the same file - each gets its own
|
||||
# copy. See docs/configuration.md "Config parity with your host Claude Code
|
||||
# setup" for exactly what that means
|
||||
# doesn't carry over from a sandboxed session.
|
||||
# ~/.claude.json mixes live app state with config (mcpServers) that cannot be
|
||||
# split at the mount level, and read-only is not an option: Claude Code does
|
||||
# an atomic write-and-rename against it during startup, and a read-only file
|
||||
# makes that HANG rather than fail cleanly (anthropics/claude-code#29162).
|
||||
# So it gets a throwaway copy, seeded from the real file and discarded rather
|
||||
# than merged back - nothing a session writes here reaches the host, and
|
||||
# concurrent sessions never contend. See docs/configuration.md, "Config
|
||||
# parity with your host Claude Code setup".
|
||||
SANDBOX_CLAUDE_JSON_FILE="$(mktemp "${TMPDIR:-/tmp}/dejima-claude-json.XXXXXX.json")"
|
||||
if [ -f "$HOME/.claude.json" ]; then
|
||||
cp "$HOME/.claude.json" "$SANDBOX_CLAUDE_JSON_FILE"
|
||||
@@ -697,6 +805,10 @@ cleanup() {
|
||||
# read it, so this function's own cleanup can't race the audit that
|
||||
# needs it - and its removal is guarded too, so it stays truly last.
|
||||
DEJIMA_RW_MOUNTS="$DEJIMA_RW_MOUNTS" "$SCRIPT_DIR/dejima-audit" compare "$WORKSPACE_DIR" "$SANDBOX_AUDIT_STATE" || true
|
||||
# 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
|
||||
# failure here must not replace the container's exit status.
|
||||
"$SCRIPT_DIR/dejima-epitaph" save "$WORKSPACE_DIR" || true
|
||||
rm -f "$SANDBOX_AUDIT_STATE" || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
@@ -841,6 +953,13 @@ for mounted_dir in hooks skills commands agents output-styles; do
|
||||
case " $mounted_dirs " in
|
||||
*" $real_dir "*) continue ;;
|
||||
esac
|
||||
# The target is discovered, not configured, so nothing has vetted it
|
||||
# (#15). A hook symlinked into the approval store would mount it
|
||||
# here, read-only and silently. Refusing rather than skipping: a
|
||||
# skipped mount leaves the symlink dangling inside the container and
|
||||
# the hook failing for a reason nothing states.
|
||||
_guarded_stores
|
||||
_refuse_guarded_mount "~/.claude symlink target" "$real_dir" "${GUARDED_STORES[@]}"
|
||||
mounted_dirs="$mounted_dirs $real_dir"
|
||||
extra_mounts+=(-v "$real_dir:$real_dir:ro")
|
||||
done
|
||||
@@ -885,9 +1004,15 @@ if [ -n "$SANDBOX_EXTRA_MOUNTS" ]; then
|
||||
for path in "${extra_paths[@]}"; do
|
||||
[ -n "$path" ] || continue
|
||||
if [ ! -e "$path" ]; then
|
||||
echo "[mounts] readonly entry does not exist: $path" >&2
|
||||
echo "$(_mount_label "$path" "[mounts] readonly entry" "--extra-readonly path") does not exist: $path" >&2
|
||||
exit 1
|
||||
fi
|
||||
# [mounts] read_write has been guarded since it was added; this one
|
||||
# never was, so `readonly = ~/.config/dejima` mounted the approval
|
||||
# store into the container and nothing said a word (#15).
|
||||
_guarded_stores
|
||||
_refuse_guarded_mount "$(_mount_label "$path" "[mounts] readonly entry" "--extra-readonly path")" \
|
||||
"$path" "${GUARDED_STORES[@]}"
|
||||
extra_mounts+=(-v "$path:$path:ro")
|
||||
done
|
||||
fi
|
||||
@@ -929,24 +1054,14 @@ if [ -d "$WORKSPACE_DIR/.git/hooks" ]; then
|
||||
extra_mounts+=(-v "$WORKSPACE_DIR/.git/hooks:$WORKSPACE_DIR/.git/hooks:ro")
|
||||
fi
|
||||
|
||||
# ~/.claude/projects, one slug at a time rather than the whole tree.
|
||||
#
|
||||
# The tree holds every project's transcripts and agent memory. Memory is
|
||||
# loaded into context at session start, so a write into another project's
|
||||
# slug is text a future session there reads as established background - and
|
||||
# the mount alone was enough for that, no escape and no credential (#104).
|
||||
# Mounting per slug leaves the others absent rather than present-but-
|
||||
# unwritable, which is the state Claude Code already handles on a first run.
|
||||
#
|
||||
# The directory itself stays container-local, so a slug that is not mounted
|
||||
# is still writable during the session and simply does not survive it. That
|
||||
# is what lets Claude Code create a slug it does not find, which it does
|
||||
# whenever a session runs against a path with no history yet.
|
||||
#
|
||||
# Only slugs that already exist on the host are mounted, apart from the
|
||||
# workspace's own. A checkout with forty worktrees would otherwise have forty
|
||||
# empty directories created under ~/.claude/projects on its first launch, all
|
||||
# of them litter for the worktrees no session has ever run in.
|
||||
# ~/.claude/projects, one slug at a time: the tree is every project's
|
||||
# transcripts and memory, and memory is loaded into context at session start,
|
||||
# so a write into another slug is background a future session there believes
|
||||
# (#104). projects/ itself stays container-local, so an unmounted slug is
|
||||
# still writable and just does not survive - which is how Claude Code creates
|
||||
# a slug it does not find. Existing slugs only, apart from the workspace's
|
||||
# own, so a checkout full of worktrees does not litter the host with empty
|
||||
# directories for worktrees nothing has run in.
|
||||
mkdir -p "$HOME/.claude/projects/$(ws_slug "$WORKSPACE_DIR")"
|
||||
while IFS= read -r _slug; do
|
||||
[ -n "$_slug" ] || continue
|
||||
@@ -954,36 +1069,35 @@ while IFS= read -r _slug; do
|
||||
extra_mounts+=(-v "$HOME/.claude/projects/$_slug:$HOME/.claude/projects/$_slug")
|
||||
done < <(workspace_slugs_sorted "$WORKSPACE_DIR")
|
||||
|
||||
# ~/.claude/statusline.sh is the same class of file as the config staged at
|
||||
# /etc/dejima-claude by docker-compose.yml: settings.json's statusLine.command
|
||||
# points at it, and Claude Code executes it on the host. Staged the same way,
|
||||
# for entrypoint.sh to copy into place, rather than mounted at its real path.
|
||||
# ~/.claude/file-history, same shape, sharper reason: `<session>/<hash>@v1`
|
||||
# is a verbatim copy of an edited file, so the tree is other projects' source
|
||||
# rather than a record of it, and it was writable - tampering there puts
|
||||
# chosen content into a later restore. Keyed by session id, so there is no
|
||||
# slug to match on; workspace_session_ids resolves it through the transcript
|
||||
# names. The starting session's id cannot be known yet, so its directory is
|
||||
# container-local: undo history for it is lost if it is resumed on the host.
|
||||
mkdir -p "$HOME/.claude/file-history"
|
||||
while IFS= read -r _sid; do
|
||||
[ -n "$_sid" ] || continue
|
||||
[ -d "$HOME/.claude/file-history/$_sid" ] || continue
|
||||
extra_mounts+=(-v "$HOME/.claude/file-history/$_sid:$HOME/.claude/file-history/$_sid")
|
||||
done < <(workspace_session_ids "$HOME/.claude/projects" "$WORKSPACE_DIR")
|
||||
|
||||
# statusline.sh is executed on the host by Claude Code (settings.json's
|
||||
# statusLine.command), so it is staged for entrypoint.sh to copy rather than
|
||||
# mounted at its real path - same as the config in docker-compose.yml.
|
||||
#
|
||||
# It used to be mounted read-only at $HOME/.claude/statusline.sh, and that
|
||||
# protected only the plain-regular-file case - confirmed against a live
|
||||
# container, not just reasoned about. When statusline.sh was itself a symlink
|
||||
# (the common case - typically into a dotfiles checkout), the read-write
|
||||
# ~/.claude mount was established first, so the destination already had that
|
||||
# symlink sitting on it, and bind-mount destination resolution (runc's
|
||||
# SecureJoin) followed it: the :ro mount landed on the target directory
|
||||
# instead of the symlink dentry, which stayed writable and replaceable. There
|
||||
# was no mount-based fix while ~/.claude was mounted whole, and the
|
||||
# compensating control was detection after the fact (bin/dejima-audit hashes
|
||||
# the symlink's target string). Staging closes it outright: Docker resolves
|
||||
# the symlink when it sets up the staging mount, so entrypoint.sh copies the
|
||||
# real content to a path nothing on the host can reach.
|
||||
# A :ro mount there protected only the plain-file case, confirmed against a
|
||||
# live container. When statusline.sh is itself a symlink (the common case),
|
||||
# bind-mount destination resolution follows it, so the mount lands on the
|
||||
# target and the link stays replaceable. Staging closes that: Docker resolves
|
||||
# the symlink setting up the staging mount, and the copy lands where the host
|
||||
# cannot reach it.
|
||||
#
|
||||
# Conditional on existence because not every host has one, and mounting a
|
||||
# nonexistent source would make Docker create a root-owned directory at the
|
||||
# target instead - worse than the gap this closes.
|
||||
#
|
||||
# A *dangling* symlink (target doesn't exist, e.g. a dotfiles checkout that
|
||||
# isn't present) is neither "exists" nor safely mountable, so it gets its own
|
||||
# branch that refuses to launch rather than either silently skipping the
|
||||
# mount (reopens the gap plain -e had) or attempting it anyway (Docker can't
|
||||
# bind-mount a dangling symlink - it fails deep in mount setup with an opaque
|
||||
# "mkdir ...: file exists", since the symlink already occupies that path,
|
||||
# with nothing connecting that error back to the statusline for the user).
|
||||
# Conditional on existence - mounting a missing source makes Docker create a
|
||||
# root-owned directory at the target. A dangling symlink is neither
|
||||
# mountable nor "exists", so it refuses rather than skipping the mount
|
||||
# (reopening the gap) or failing later with an opaque "mkdir: file exists".
|
||||
if [ -L "$HOME/.claude/statusline.sh" ] && [ ! -e "$HOME/.claude/statusline.sh" ]; then
|
||||
echo "$HOME/.claude/statusline.sh is a symlink to a target that doesn't" >&2
|
||||
echo "exist. Docker can't bind-mount a dangling symlink, and skipping the" >&2
|
||||
@@ -994,16 +1108,12 @@ elif [ -e "$HOME/.claude/statusline.sh" ]; then
|
||||
extra_mounts+=(-v "$HOME/.claude/statusline.sh:/etc/dejima-claude/statusline.sh:ro")
|
||||
fi
|
||||
|
||||
# Keep the real Anthropic OAuth credential out of the container entirely:
|
||||
# run a host-side proxy that injects it per-request (see
|
||||
# bin/dejima-anthropic-proxy and docs/security.md). Nothing needs masking
|
||||
# any more - ~/.claude isn't mounted as a whole, so the real credential (and
|
||||
# the proxy's .credentials-proxy-tmp staging directory beside it) simply
|
||||
# never crosses into the container. That masking is what #50 was: both masks
|
||||
# were single-file/-directory mounts nested in the read-write ~/.claude
|
||||
# mount, and the proxy's own write-back renamed the credential out from
|
||||
# under them, detaching the mask and exposing the real token from the first
|
||||
# refresh on.
|
||||
# The real Anthropic OAuth credential never enters the container: a host-side
|
||||
# proxy injects it per request (bin/dejima-anthropic-proxy, docs/security.md).
|
||||
# Nothing needs masking now that ~/.claude isn't mounted whole - and masking
|
||||
# is what #50 was, a single-file mount nested in the read-write ~/.claude
|
||||
# that the proxy's own write-back renamed out from under, exposing the real
|
||||
# token on the first refresh.
|
||||
#
|
||||
# How the container then reaches the proxy depends on the transport. In the
|
||||
# default tls one, entrypoint.sh points api.anthropic.com at an in-container
|
||||
@@ -1233,6 +1343,17 @@ if remote_enabled; then
|
||||
mv "$SANDBOX_REMOTE_SETTINGS_TMP" "$SANDBOX_SETTINGS_FILE"
|
||||
fi
|
||||
|
||||
# Everything from here to the container's own first output is silent: the
|
||||
# audit snapshot, an image build that prints nothing when every layer is
|
||||
# cached, and Docker creating the container with a mount per allowed path.
|
||||
# Long enough to read as hung, so say what is happening first. A first run,
|
||||
# or one after a Dockerfile change, builds the image and takes minutes rather
|
||||
# than seconds - the line names that, since it is the case where someone is
|
||||
# most likely to give up and interrupt.
|
||||
printf '%sdejima%s is starting, please wait...\n' "$C_KEY" "$C_RESET" >&2
|
||||
printf '%sA first run builds the container image, which takes several minutes.%s\n' \
|
||||
"$C_DIM" "$C_RESET" >&2
|
||||
|
||||
# Snapshot host-persistence surfaces so the post-run audit can report
|
||||
# anything that changed while this session ran (advisory - see bin/dejima-audit).
|
||||
"$SCRIPT_DIR/dejima-audit" snapshot "$WORKSPACE_DIR" "$SANDBOX_AUDIT_STATE" || true
|
||||
@@ -1286,6 +1407,22 @@ if [ -n "$PROJECT_STORE" ] && [ -s "$PROJECT_STORE/dependencies" ]; then
|
||||
fi
|
||||
export SANDBOX_IMAGE
|
||||
|
||||
# nvim's data, state and cache are Docker volumes rather than host paths, so
|
||||
# nothing a session installs reaches the host's own nvim. They did outlive
|
||||
# the container under one fixed name each, though, shared by every project
|
||||
# on the machine: a session that drops a file under `lazy/`, or replaces a
|
||||
# binary under `mason/bin/`, is writing something a later session in an
|
||||
# unrelated project executes the next time nvim runs in the sandbox. That is
|
||||
# the persistence this repository closes everywhere else, so it is scoped
|
||||
# the same way the derived image tag is - per project.
|
||||
#
|
||||
# The prefix is not decoration: project_slug maps a leading `/` to `-`, and
|
||||
# a Docker volume name may not start with one.
|
||||
_nvim_volume_slug="$(project_slug "$WORKSPACE_DIR")"
|
||||
export SANDBOX_NVIM_DATA="dejima_nvim-data${_nvim_volume_slug}"
|
||||
export SANDBOX_NVIM_STATE="dejima_nvim-state${_nvim_volume_slug}"
|
||||
export SANDBOX_NVIM_CACHE="dejima_nvim-cache${_nvim_volume_slug}"
|
||||
|
||||
# Not exec'd (the EXIT trap cleans up the generated temp files) and not a
|
||||
# bare command under set -e (a nonzero/interrupted container run - e.g.
|
||||
# Ctrl-C returning 130 - would otherwise abort the script here, skipping the
|
||||
|
||||
+30
-47
@@ -24,23 +24,17 @@ fi
|
||||
STATE_DIR="$(remote_state_dir)"
|
||||
SESSIONS_DIR="$STATE_DIR/sessions"
|
||||
|
||||
# gruvbox dark. Every value below is upstream's, tagged with the name of the
|
||||
# color.table row it came from:
|
||||
# gruvbox dark, every value upstream's and tagged with its color.table row:
|
||||
# https://github.com/gruvbox-community/gruvbox-contrib/blob/master/color.table
|
||||
# Carrying the name and not just the hex is the point - a palette this widely
|
||||
# known is one almost nobody has memorised to six digits, so a wrong value is
|
||||
# only catchable by reading if there is something to check it against.
|
||||
# The names are carried so a wrong hex is catchable by reading.
|
||||
#
|
||||
# Truecolor only, and deliberately not paired with a 256-colour table: a
|
||||
# terminal without it - or tmux without `Tc` - approximates 38;2 to its own
|
||||
# nearest colour rather than printing the escape, so the fallback already
|
||||
# exists one layer down and a second palette here would only duplicate it.
|
||||
# Truecolor only: a terminal without it, or tmux without `Tc`, approximates
|
||||
# 38;2 to its nearest colour rather than printing the escape, so the fallback
|
||||
# already exists one layer down.
|
||||
#
|
||||
# Everything below is empty unless stdout is a terminal that says it can show
|
||||
# colour, which is what keeps --list clean: it is a machine interface (ttyd's
|
||||
# --url-arg feeds run ids back in, and the README documents the rows as
|
||||
# scriptable), and it is also how the bats suite reads this script, so its
|
||||
# assertions see the same bare words either way.
|
||||
# Empty unless stdout is a colour-capable terminal, which keeps --list a
|
||||
# machine interface (ttyd feeds run ids back in) and is also how the bats
|
||||
# suite reads this script.
|
||||
if [ -t 1 ] && [ -z "${NO_COLOR:-}" ] && [ "${TERM:-dumb}" != dumb ]; then
|
||||
C_OFF=$'\033[0m'; C_BOLD=$'\033[1m'
|
||||
C_BADGE=$'\033[38;2;254;128;25m' # bright_orange #fe8019, the wordmark
|
||||
@@ -95,22 +89,16 @@ state_color() {
|
||||
# How long after its own started= a registered session may still have no
|
||||
# container, before the prune in list_sessions treats that as "gone".
|
||||
#
|
||||
# Sized against the step that actually holds a launch up: bin/dejima writes
|
||||
# the registry entry, then rewrites the settings copy, snapshots the
|
||||
# workspace for the audit, runs an UNCONDITIONAL `docker compose build
|
||||
# claude`, optionally builds up to two derived images on top of it, and only
|
||||
# then reaches `docker compose run`. A cold base build is the Dockerfile's
|
||||
# apt and npm layers fetched over the network - minutes, not seconds - and
|
||||
# the derived builds add to it, while a warm run reaches the container in
|
||||
# seconds and never comes near this window.
|
||||
# Sized against what holds a launch up: the registry entry is written before
|
||||
# an unconditional `docker compose build`, plus up to two derived image
|
||||
# builds, and a cold base build fetches apt and npm layers - minutes. A warm
|
||||
# run never comes near this window.
|
||||
#
|
||||
# The two errors are not symmetric, which is why this is generous. Too long
|
||||
# only leaves a session that really did die listed until the window passes,
|
||||
# and the next listing prunes it. Too short deletes the registry entry of a
|
||||
# session that is about to start: bin/dejima never rewrites that entry, so
|
||||
# the session is permanently absent from both front doors, and the marker
|
||||
# directory deleted with it is the source of a bind mount the imminent
|
||||
# `docker compose run` declares.
|
||||
# Generous because the errors are not symmetric. Too long leaves a dead
|
||||
# session listed until the window passes. Too short deletes the entry of a
|
||||
# session about to start: bin/dejima never rewrites it, so the session is
|
||||
# permanently absent from both front doors, and the marker directory removed
|
||||
# with it is a bind source the imminent `compose run` declares.
|
||||
STARTUP_GRACE=900
|
||||
|
||||
# Parsed, never sourced: this file is host-written, but treating state files
|
||||
@@ -281,26 +269,21 @@ attach() {
|
||||
exit 1
|
||||
fi
|
||||
group="dejima-$run_id"
|
||||
# Both values come from the same live pane, in one round trip. Taking the
|
||||
# session name from the recorded label instead would follow a name rather
|
||||
# than the sandbox: an operator renaming sessions is enough to point it at
|
||||
# an unrelated session that has since taken the old name.
|
||||
# Both values from the same live pane, one round trip. Taking the session
|
||||
# name from the recorded label would follow a name rather than the
|
||||
# sandbox, and a rename is enough to point it at an unrelated session.
|
||||
#
|
||||
# The name is a starting point, never a guarantee. It is frequently
|
||||
# ANOTHER remote client's throwaway group (tmux reports whichever session
|
||||
# holding the window is most recent, and every grouped session here is
|
||||
# newer than the operator's own), and those carry destroy-unattached - so
|
||||
# the base can be made to stop existing on demand by anyone holding a
|
||||
# second connection. That is why the session created from it is verified
|
||||
# below instead of trusted: `new-session -t` does not fail on a name that
|
||||
# matches nothing, it silently creates a fresh UNGROUPED session, which
|
||||
# for a remote client is a plain host shell with no sandbox in it.
|
||||
# The name is a starting point, not a guarantee: it is often another
|
||||
# remote client's throwaway group, and those carry destroy-unattached, so
|
||||
# anyone with a second connection can make the base stop existing. Hence
|
||||
# the verification below - `new-session -t` does not fail on a name
|
||||
# matching nothing, it silently creates an UNGROUPED session, which for a
|
||||
# remote client is a plain host shell with no sandbox in it.
|
||||
#
|
||||
# display-message prints nothing and still exits 0 for a target that no
|
||||
# longer exists, so an empty result means the pane went away between the
|
||||
# check above and here. That case is caught by the verification below too;
|
||||
# bailing early just avoids creating a junk session to immediately kill,
|
||||
# and says what actually happened.
|
||||
# display-message prints nothing and exits 0 for a vanished target, so an
|
||||
# empty result means the pane went away since the check above. The
|
||||
# verification catches that too; bailing early just avoids creating a
|
||||
# junk session and says what happened.
|
||||
target="$(tmux display-message -p -t "$entry_pane" '#{session_name}:#{window_index}' 2>/dev/null || true)"
|
||||
# Session names may contain ':', window indexes can't.
|
||||
base="${target%:*}"
|
||||
|
||||
+235
-138
@@ -73,6 +73,24 @@ AUDIT_KEEP="$(config_get audit keep)"
|
||||
# startup warning may well reach for `show` first, and showing them a
|
||||
# different report than the one being warned about sends them looking for a
|
||||
# finding that is not in it.
|
||||
# How many reports are waiting, and how many of those are CRITICAL. `ack`
|
||||
# walks one report at a time by design - each is a separate session's worth of
|
||||
# findings - but saying nothing about the rest is what made clearing a backlog
|
||||
# feel like the tool was stuck on one.
|
||||
# A report's stable identity: its basename without the -CRITICAL marker, which
|
||||
# `ack` removes once every CRITICAL in it has been dealt with.
|
||||
report_key() {
|
||||
local b; b="$(basename "$1")"
|
||||
printf '%s' "${b%-CRITICAL.txt}.txt" | sed 's/\.txt\.txt$/.txt/'
|
||||
}
|
||||
|
||||
count_reports() {
|
||||
local dir="$1"
|
||||
printf '%s %s\n' \
|
||||
"$(find "$dir" -maxdepth 1 -name '*.txt' 2>/dev/null | wc -l)" \
|
||||
"$(find "$dir" -maxdepth 1 -name '*-CRITICAL.txt' 2>/dev/null | wc -l)"
|
||||
}
|
||||
|
||||
select_report() {
|
||||
local dir="$1" r
|
||||
r="$(find "$dir" -maxdepth 1 -name '*-CRITICAL.txt' 2>/dev/null | sort | head -1)"
|
||||
@@ -87,19 +105,16 @@ BASELINE_DIR="$(config_get audit baselines_dir)"
|
||||
# should mean that repo. bin/dejima always passes an explicit path, so this
|
||||
# only ever fires for a hand-run show/ack.
|
||||
#
|
||||
# Candidates in order, first one that already HAS reports wins - existence of
|
||||
# a report directory is the only evidence available about which path
|
||||
# bin/dejima was actually invoked with, and the three candidates can
|
||||
# genuinely differ:
|
||||
# 1. the enclosing repo's toplevel (the ordinary case, and from a subdir);
|
||||
# 2. the MAIN worktree root, when cwd is a linked worktree - the sandbox's
|
||||
# own .worktrees/<branch> workflow keys reports to the parent repo;
|
||||
# 3. $PWD, which covers a non-git directory and the case where the path
|
||||
# bin/dejima was given (logical, via `cd && pwd`) differs from git's
|
||||
# physical toplevel because of a symlinked path component.
|
||||
# Falling back to candidate 1 when none of them has reports is purely so the
|
||||
# "no reports for X" message can name a concrete path: a wrong guess must be
|
||||
# visible rather than silent.
|
||||
# Candidates in order, first one that already HAS reports wins - a report
|
||||
# directory is the only evidence of which path bin/dejima was invoked with,
|
||||
# and the three can genuinely differ:
|
||||
# 1. the enclosing repo's toplevel (ordinary case, and from a subdir);
|
||||
# 2. the MAIN worktree root, when cwd is a linked worktree - the
|
||||
# .worktrees/<branch> workflow keys reports to the parent repo;
|
||||
# 3. $PWD, for a non-git directory, or when the logical path bin/dejima was
|
||||
# given differs from git's physical toplevel through a symlink.
|
||||
# Falling back to 1 when none has reports is so "no reports for X" can name a
|
||||
# concrete path: a wrong guess must be visible rather than silent.
|
||||
resolve_ws() {
|
||||
local given="$1" top common main c
|
||||
if [ -n "$given" ]; then
|
||||
@@ -572,6 +587,11 @@ baseline_suppresses() {
|
||||
}
|
||||
|
||||
# Whether `ack` has already walked this report to its end. Recorded in the
|
||||
# Recorded against the report's name with any -CRITICAL suffix stripped,
|
||||
# because a report that sheds that suffix later in the same run would
|
||||
# otherwise carry a marker naming a file that no longer exists - and re-prompt
|
||||
# forever, which is the failure the marker exists to prevent.
|
||||
#
|
||||
# baseline file, alongside the approvals, as `reviewed <report-basename>`:
|
||||
# the file is already the per-workspace record of what the operator has
|
||||
# decided, already parsed line-by-line, and already tolerant of verbs it does
|
||||
@@ -658,37 +678,26 @@ sanitize() {
|
||||
| LC_ALL=C sed $'s/\xc2[\x80-\x9f]//g; s/\xe2\x80[\xaa-\xae]//g; s/\xe2\x81[\xa6-\xa9]//g'
|
||||
}
|
||||
|
||||
# sanitize() is lossy, so it must never be what an ID passes through.
|
||||
# sanitize() is lossy, so an ID must never pass through it. `ack` derives the
|
||||
# baseline id from the report text and the next `compare` matches it against
|
||||
# the RAW bytes on disk, so a stripped id cannot round-trip: the surface
|
||||
# becomes permanently unsuppressible and the startup warning fires forever.
|
||||
#
|
||||
# `ack` derives the baseline id it records from the report's text, and the
|
||||
# next `compare` matches that id against the RAW bytes on disk. An id
|
||||
# containing anything sanitize() strips therefore could not round-trip at
|
||||
# all: the surface stayed permanently unsuppressible and its report
|
||||
# permanently unclearable, so bin/dejima's startup warning fired forever -
|
||||
# the alert fatigue this whole feature exists to end, reached from the other
|
||||
# side. Ids are encoded instead, and the encoded form is canonical
|
||||
# everywhere it is written down: in the report AND in the baseline file.
|
||||
# Ids are encoded instead, canonically, in the report AND the baseline file.
|
||||
# An id sanitize() leaves alone is written verbatim (every ordinary one, so
|
||||
# existing baselines are unchanged); anything else is hex-encoded behind a
|
||||
# marker, which is ASCII and reverses exactly.
|
||||
#
|
||||
# An id that survives sanitize() unchanged is already display-safe and is
|
||||
# written verbatim - that is every ordinary id, so reports and existing
|
||||
# baseline files are byte-for-byte what they were. Anything else is
|
||||
# hex-encoded behind a marker, which is ASCII by construction and reverses
|
||||
# exactly.
|
||||
# An id that merely LOOKS encoded is encoded too, so the marker's presence
|
||||
# always means this encoder put it there. Otherwise a workspace could present
|
||||
# an id `ack` decodes into one of the attacker's choosing - baselining
|
||||
# something other than the finding while counting it acknowledged. Only the
|
||||
# START matters, and no monitored path begins with the marker, but a git
|
||||
# config section name is free text the session can write.
|
||||
#
|
||||
# An id that merely LOOKS encoded is encoded too, so that decoding stays
|
||||
# unambiguous: the marker's presence always means this encoder put it there.
|
||||
# Without that, a workspace could present an id that `ack` decoded into one
|
||||
# of the attacker's choosing - baselining something other than the finding
|
||||
# while still counting it acknowledged, the same shape as the id-derivation
|
||||
# hijacks the anchored case arms in `ack` guard against. Only the START of
|
||||
# an id is significant, and no monitored path can begin with the marker
|
||||
# (they are all rooted at .claude/.vscode/.git/.envrc/.mcp.json), but a git
|
||||
# config section name is free text in a file the session can write and the
|
||||
# audit parses that file directly rather than through git.
|
||||
#
|
||||
# The encoded form is also the honest rendering. sanitize() DELETES those
|
||||
# bytes, so a planted ".git/hooks/pre<U+202E>commit" displayed as
|
||||
# "precommit" sends an operator grepping for a name no file on disk has.
|
||||
# Encoding is also the honest rendering: sanitize() DELETES those bytes, so a
|
||||
# planted ".git/hooks/pre<U+202E>commit" shown as "precommit" sends an
|
||||
# operator grepping for a name no file has.
|
||||
HEX_MARK='{hex}'
|
||||
enc_id() {
|
||||
local v="$1"
|
||||
@@ -791,6 +800,22 @@ hash_paths() {
|
||||
} | sort
|
||||
}
|
||||
|
||||
# Whether `git status` would show a change to $2 inside repo $1. One call per
|
||||
# candidate path rather than one listing compared as a set: the candidate list
|
||||
# is what changed this session, which is small, and --porcelain on a path is
|
||||
# cheap. A path git does not know about at all (never added, and ignored)
|
||||
# prints nothing, which is exactly the invisible case.
|
||||
#
|
||||
# A repo git refuses to read (not a repository, or a dubious-ownership
|
||||
# refusal) makes this return false, so every change is reported rather than
|
||||
# silently downgraded - failing towards the noisier answer, not the quieter
|
||||
# one.
|
||||
git_shows_change() {
|
||||
local repo="$1" path="$2" out
|
||||
out="$(git -C "$repo" status --porcelain --untracked-files=all -- "$path" 2>/dev/null)" || return 1
|
||||
[ -n "$out" ]
|
||||
}
|
||||
|
||||
tcb_snapshot() {
|
||||
# encoded-path<TAB>sha for the sandbox's own trusted computing base, but
|
||||
# only when $ws IS the sandbox's repo (self-mount) - see AUDIT_REPO_ROOT.
|
||||
@@ -1025,20 +1050,6 @@ render_finding() {
|
||||
return 0
|
||||
}
|
||||
|
||||
projects_snapshot() {
|
||||
# slug<TAB>maxmtime for each immediate subdir of PROJECTS_DIR, sorted by
|
||||
# slug; maxmtime is 0 if the slug dir has no files.
|
||||
local dir="$1" d slug mtime
|
||||
[ -d "$dir" ] || return 0
|
||||
for d in "$dir"/*/; do
|
||||
[ -d "$d" ] || continue
|
||||
slug="$(basename "$d")"
|
||||
mtime="$(find "$d" -type f -printf '%T@\n' 2>/dev/null | sort -n | tail -1)"
|
||||
mtime="${mtime%%.*}"
|
||||
printf '%s\t%s\n' "$slug" "${mtime:-0}"
|
||||
done | sort
|
||||
}
|
||||
|
||||
statusline_snapshot() {
|
||||
# path<TAB>sha for $HOME/.claude/statusline.sh, or nothing if this host
|
||||
# has none - see STATUSLINE_PATH above for why this one path is
|
||||
@@ -1064,7 +1075,11 @@ case "$cmd" in
|
||||
{ echo "### staging $STAGE_DIR";
|
||||
echo "### gitconfig"; git_config_snapshot "$ws";
|
||||
echo "### paths"; hash_paths "$ws";
|
||||
echo "### projects"; projects_snapshot "$PROJECTS_DIR";
|
||||
# Empty since the cross-project finding was retired, and kept
|
||||
# rather than dropped: it is a range boundary for the paths section
|
||||
# above, and removing it would make a state file written by one
|
||||
# version unparseable by the other across an upgrade mid-session.
|
||||
echo "### projects";
|
||||
echo "### tcb"; tcb_snapshot "$ws";
|
||||
echo "### statusline"; statusline_snapshot "$STATUSLINE_PATH"; } > "$state" 2>/dev/null || true
|
||||
exit 0
|
||||
@@ -1103,7 +1118,7 @@ case "$cmd" in
|
||||
|
||||
new="$(mktemp)"; { echo "### gitconfig"; git_config_snapshot "$ws";
|
||||
echo "### paths"; hash_paths "$ws";
|
||||
echo "### projects"; projects_snapshot "$PROJECTS_DIR";
|
||||
echo "### projects";
|
||||
echo "### tcb"; tcb_snapshot "$ws";
|
||||
echo "### statusline"; statusline_snapshot "$STATUSLINE_PATH"; } > "$new" 2>/dev/null || {
|
||||
echo "dejima audit: could not complete, skipping." >&2; rm -f "$new"; exit 0; }
|
||||
@@ -1194,38 +1209,55 @@ case "$cmd" in
|
||||
<(sed -n '/^### paths/,/^### projects/p' "$new" | grep -v '^### ') \
|
||||
| sed 's/^\t//' | cut -f1 | sort -u )
|
||||
|
||||
# projects diffs: a write to another project's ~/.claude/projects
|
||||
# slug during this session (transcript/memory-poisoning surface).
|
||||
# "self" = the workspace's own slug or a live worktree nested under
|
||||
# it, exactly the set bin/dejima mounts. This used to be a
|
||||
# "$current_slug"-* glob, which also matched any sibling project whose
|
||||
# directory name extended this one's - so on a host with `proj` and
|
||||
# `proj-ci` side by side, writes to `proj-ci` were silently exempt.
|
||||
self_slugs="$(workspace_slugs_sorted "$ws")"
|
||||
while IFS= read -r line; do
|
||||
slug="${line%%$'\t'*}"
|
||||
if printf '%s\n' "$self_slugs" | grep -qxF -- "$slug"; then
|
||||
continue
|
||||
fi
|
||||
warn+=("CROSS-PROJECT history/memory write: $(sanitize "$slug")")
|
||||
done < <(comm -13 <(sed -n '/^### projects/,/^### tcb/p' "$state" | grep -v '^### ') \
|
||||
<(sed -n '/^### projects/,/^### tcb/p' "$new" | grep -v '^### ') )
|
||||
# There is deliberately no cross-project finding here any more. It
|
||||
# reported a write to another project's ~/.claude/projects slug, and
|
||||
# a session could once make one: the whole tree was mounted. Since
|
||||
# #104 it gets its own slugs and nothing else, so the only writes this
|
||||
# could still see are other host or dejima sessions doing their own
|
||||
# legitimate work - and with several sessions running it reported all
|
||||
# of them, on every report, drowning the findings that mattered.
|
||||
#
|
||||
# Detection that cannot observe the thing it was built for, and fires
|
||||
# constantly on the thing it was not, is worse than no detection: it
|
||||
# trains the operator to skim. The mount is the control now, and
|
||||
# static.bats pins it.
|
||||
|
||||
# tcb diffs: any change under the sandbox's own trusted computing
|
||||
# base, only populated when this session's workspace IS the
|
||||
# sandbox's own repo (see tcb_snapshot) - empty/no-op otherwise.
|
||||
# Bounded at ### statusline (not $p, the last section now) so this
|
||||
# doesn't also swallow that section's lines.
|
||||
# Split on whether git can see the change, because that decides
|
||||
# whether this finding is telling the operator anything.
|
||||
#
|
||||
# tcb_snapshot only populates when the workspace IS dejima's own repo,
|
||||
# so every change here is also a change to a git working tree the
|
||||
# operator is about to read in `git status` and in the pull request.
|
||||
# Reporting each one as a CRITICAL to acknowledge duplicated a
|
||||
# stronger control and made ordinary development unusable.
|
||||
#
|
||||
# What git cannot show is the case worth keeping: a file listed in
|
||||
# .gitignore or .git/info/exclude, or marked assume-unchanged or
|
||||
# skip-worktree, is live code that runs on the next launch and appears
|
||||
# nowhere in `git status`. Nothing but this hash sees it, so it stays
|
||||
# a CRITICAL finding.
|
||||
tcb_visible=0
|
||||
while IFS= read -r line; do
|
||||
# Encoded on arrival, same as the path loop above.
|
||||
rline="$(dec_id "$line")"
|
||||
tid="tcb:$rline"; tsha=""; [ -e "$ws/$rline" ] && tsha="$(hash_entry "$ws/$rline")"
|
||||
baseline_suppresses "$tid" "$tsha" && continue
|
||||
crit+=("TCB (sandbox self) modified: $line")
|
||||
if git_shows_change "$ws" "$rline"; then
|
||||
tcb_visible=$((tcb_visible + 1))
|
||||
continue
|
||||
fi
|
||||
crit+=("TCB (sandbox self) modified, INVISIBLE to git: $line")
|
||||
finding_ids+=("$tid")
|
||||
done < <(comm -3 <(sed -n '/^### tcb/,/^### statusline/p' "$state" | grep -v '^### ') \
|
||||
<(sed -n '/^### tcb/,/^### statusline/p' "$new" | grep -v '^### ') \
|
||||
| sed 's/^\t//' | cut -f1 | sort -u )
|
||||
if [ "$tcb_visible" -gt 0 ]; then
|
||||
info+=("$tcb_visible change(s) under the sandbox's own bin/, hooks/, docker/, test/ - this workspace IS dejima; review them with git")
|
||||
fi
|
||||
|
||||
# statusline diff: ~/.claude/statusline.sh added/changed/removed/
|
||||
# repointed - see STATUSLINE_PATH above for why this gets its own
|
||||
@@ -1376,15 +1408,41 @@ case "$cmd" in
|
||||
exit 0
|
||||
;;
|
||||
show)
|
||||
color_init 1
|
||||
report_dir="$AUDIT_DIR/$(ws_slug "$ws")"
|
||||
report="$(select_report "$report_dir")" \
|
||||
|| { echo "dejima audit: no reports for $ws" >&2; exit 0; }
|
||||
# Which report, on stderr so stdout stays the report content alone:
|
||||
# `show` no longer necessarily prints the newest one, and an operator
|
||||
# cross-referencing the run timestamps in the startup warning needs to
|
||||
# know which of them they are looking at.
|
||||
echo "dejima audit: $report" >&2
|
||||
cat "$report"
|
||||
# Default is an index, not a dump. `cat` of one report was a wall of
|
||||
# text that answered neither question an operator actually has - how
|
||||
# much is waiting, and which of it still needs a decision - and it
|
||||
# printed findings already acknowledged, so the wall did not even
|
||||
# shrink as they worked through it.
|
||||
if [ "${2:-}" = --full ] || [ "${3:-}" = --full ] || [ "${4:-}" = --full ]; then
|
||||
echo "dejima audit: $report" >&2
|
||||
cat "$report"
|
||||
exit 0
|
||||
fi
|
||||
read -r n_all n_crit <<< "$(count_reports "$report_dir")"
|
||||
printf '%s report(s) for %s' "$n_all" "$ws"
|
||||
[ "$n_crit" -gt 0 ] && printf ', %s%s with unacknowledged CRITICAL%s' \
|
||||
"$C_CRIT" "$n_crit" "$C_RESET"
|
||||
printf '\n\n'
|
||||
# Newest last, so the one `ack` will offer first is the one nearest
|
||||
# the prompt - and mark it, since select_report prefers CRITICAL over
|
||||
# recency and "the newest" is the wrong guess.
|
||||
r=""; line=""; pending=""
|
||||
while IFS= read -r r; do
|
||||
pending=$(grep -cE '^(CRITICAL|WARNING|INFO)' "$r" 2>/dev/null || true)
|
||||
line="$(printf ' %-44s %s finding(s)' "$(basename "$r")" "${pending:-0}")"
|
||||
case "$r" in
|
||||
*-CRITICAL.txt) printf '%s%s%s' "$C_CRIT" "$line" "$C_RESET" ;;
|
||||
*) printf '%s' "$line" ;;
|
||||
esac
|
||||
[ "$r" = "$report" ] && printf ' %s<- ack starts here%s' "$C_KEY" "$C_RESET"
|
||||
printf '\n'
|
||||
done < <(find "$report_dir" -maxdepth 1 -name '*.txt' 2>/dev/null | sort)
|
||||
printf '\n%sdejima-audit ack%s walks them; %sdejima-audit show --full%s prints one.\n' \
|
||||
"$C_KEY" "$C_RESET" "$C_KEY" "$C_RESET"
|
||||
exit 0
|
||||
;;
|
||||
ack)
|
||||
@@ -1393,6 +1451,17 @@ case "$cmd" in
|
||||
bf="$BASELINE_DIR/$(ws_slug "$ws")"
|
||||
report="$(select_report "$report_dir")" \
|
||||
|| { echo "dejima audit: no reports for $ws" >&2; exit 0; }
|
||||
read -r n_all n_crit <<< "$(count_reports "$report_dir")"
|
||||
if [ "$n_all" -gt 1 ]; then
|
||||
printf '%s reports waiting' "$n_all"
|
||||
[ "$n_crit" -gt 0 ] && printf ', %s%s with unacknowledged CRITICAL%s' \
|
||||
"$C_CRIT" "$n_crit" "$C_RESET"
|
||||
printf '.\n\n'
|
||||
fi
|
||||
# One report per pass, every report in one invocation. Walking one and
|
||||
# exiting meant clearing a backlog took as many runs as there were
|
||||
# reports, with nothing on screen to say another was behind it.
|
||||
while :; do
|
||||
sc="$(sidecar_dir "$report")"
|
||||
# Removed only at the end, and only for findings actually
|
||||
# acknowledged - an abort must leave the operator exactly where they
|
||||
@@ -1411,6 +1480,10 @@ case "$cmd" in
|
||||
# (or a non-path CRITICAL, which can never be "approved" - see
|
||||
# ackable below) must keep its suffix and stay eviction-protected.
|
||||
crit_seen=0; crit_acked=0
|
||||
# Set when a finding was cleared because its surface no longer exists.
|
||||
# Like unackable_shown, it means the report has been dealt with as far
|
||||
# as it can be, so it earns a `reviewed` marker.
|
||||
removed_cleared=0
|
||||
# Findings passed over because the baseline already covers them, or
|
||||
# because this report has been walked before - counted so a run that
|
||||
# displays nothing can say why instead of looking broken.
|
||||
@@ -1420,7 +1493,7 @@ case "$cmd" in
|
||||
# end. Together they decide if a `reviewed` line gets written below.
|
||||
unackable_shown=0
|
||||
report_reviewed=0
|
||||
if baseline_reviewed "$(basename "$report")"; then report_reviewed=1; fi
|
||||
if baseline_reviewed "$(report_key "$report")"; then report_reviewed=1; fi
|
||||
# The loop below reads report lines on its own stdin (`< "$report"`),
|
||||
# so operator keystrokes are read from fd 3 - a copy of the script's
|
||||
# original stdin taken before that redirection - rather than
|
||||
@@ -1448,47 +1521,28 @@ case "$cmd" in
|
||||
*) continue ;;
|
||||
esac
|
||||
[ "$tier" = CRITICAL ] && crit_seen=$((crit_seen + 1))
|
||||
# Derive the namespaced id baseline_suppresses() will later
|
||||
# compare against (see id_severity/current_sha_for_id above) -
|
||||
# one case arm per finding-producing loop in `compare`. Every
|
||||
# pattern is anchored to the fixed label text `compare` itself
|
||||
# emits at the START of body, never matched as a substring
|
||||
# anywhere in it: a workspace-controlled value (a git config
|
||||
# value, a path) can contain text like "workspace file: " or
|
||||
# "TCB (sandbox self) modified: " and reach $body unmodified, so
|
||||
# an unanchored match could be hijacked into deriving the wrong
|
||||
# (attacker-chosen) id for the wrong namespace - approving it
|
||||
# would then baseline an id that can never match, un-baselining
|
||||
# the real finding while still counting it as acknowledged. A
|
||||
# finding whose body matches none of these (currently only
|
||||
# CROSS-PROJECT) has no id to record, so it's shown but not
|
||||
# offered a/s.
|
||||
# Derive the namespaced id baseline_suppresses() compares
|
||||
# against - one arm per finding-producing loop in `compare`.
|
||||
#
|
||||
# The id itself must then be recovered with a shortest-prefix
|
||||
# strip of that same fixed label (`${body#LABEL}`), never
|
||||
# `${body##*: }`: the latter strips up to the LAST ": " in the
|
||||
# whole string, and a workspace-controlled path can itself
|
||||
# contain a literal ": " (e.g. ".git/hooks/pre-commit: decoy"),
|
||||
# which would truncate the derived id to everything after that
|
||||
# planted separator - the case match being anchored doesn't
|
||||
# protect the extraction that follows it.
|
||||
# Both steps must resist workspace-controlled text in $body. The
|
||||
# patterns anchor to the fixed label at the START of body: a git
|
||||
# config value or path can contain "workspace file: ", so an
|
||||
# unanchored match derives an attacker-chosen id in the wrong
|
||||
# namespace, and approving it baselines an id that never matches
|
||||
# while counting the real finding as acknowledged. The id is then
|
||||
# recovered with `${body#LABEL}`, never `${body##*: }` - the
|
||||
# latter strips to the LAST ": ", and a path can contain one.
|
||||
# A body matching no arm (currently only CROSS-PROJECT) gets no
|
||||
# id: shown, but not offered a/s.
|
||||
#
|
||||
# Two forms come out of this, because the two consumers need
|
||||
# different ones (see enc_id): $id is the ENCODED id exactly as
|
||||
# the report carries it, and is what gets written to the
|
||||
# baseline file, which stores that form. $rid is the same id
|
||||
# decoded back to the raw bytes on disk, for current_sha_for_id
|
||||
# to stat and hash and for id_severity to classify. Only the
|
||||
# id-bearing portion is decoded - never the namespace prefix
|
||||
# this code adds itself.
|
||||
#
|
||||
# $lbl and $surface are for DISPLAY only: the fixed label this
|
||||
# arm matched, and the workspace-named thing inside the body that
|
||||
# the finding is about. Splitting them is what lets the surface
|
||||
# be coloured without colouring the audit's own words around it,
|
||||
# and they are recomputed here rather than derived from $id
|
||||
# because a gitconfig body carries `key=value` while its id is
|
||||
# the key alone.
|
||||
# $id is the ENCODED form the report and baseline file carry;
|
||||
# $rid is it decoded, for current_sha_for_id to stat and hash and
|
||||
# id_severity to classify. Only the id-bearing portion decodes,
|
||||
# never the namespace prefix added here. $lbl and $surface are
|
||||
# display-only, split so the surface can be coloured without the
|
||||
# audit's own words around it, and recomputed rather than derived
|
||||
# from $id because a gitconfig body carries `key=value` while its
|
||||
# id is the key alone.
|
||||
ackable=0; id=""; rid=""; lbl=""; surface=""
|
||||
case "$body" in
|
||||
"HOST-CODE-EXECUTION workspace file: "*)
|
||||
@@ -1522,9 +1576,9 @@ case "$cmd" in
|
||||
esl="${body#HOST-CODE-EXECUTION statusline.sh changed (runs UNSANDBOXED on the host via settings.json statusLine.command): }"
|
||||
surface="$esl"
|
||||
id="statusline:$esl"; rid="statusline:$(dec_id "$esl")" ;;
|
||||
"TCB (sandbox self) modified: "*)
|
||||
ackable=1; lbl="TCB (sandbox self) modified: "
|
||||
etcb="${body#TCB (sandbox self) modified: }"
|
||||
"TCB (sandbox self) modified, INVISIBLE to git: "*)
|
||||
ackable=1; lbl="TCB (sandbox self) modified, INVISIBLE to git: "
|
||||
etcb="${body#TCB (sandbox self) modified, INVISIBLE to git: }"
|
||||
surface="$etcb"
|
||||
id="tcb:$etcb"; rid="tcb:$(dec_id "$etcb")" ;;
|
||||
*) ackable=0 ;;
|
||||
@@ -1553,6 +1607,18 @@ case "$cmd" in
|
||||
if [ "$tier" = CRITICAL ]; then crit_acked=$((crit_acked + 1)); fi
|
||||
continue
|
||||
fi
|
||||
# A finding cleared on an earlier pass because its surface was
|
||||
# gone. Nothing was recorded for it - there is no content to pin -
|
||||
# so baseline_suppresses cannot cover it, and it is ackable, so
|
||||
# the un-ackable branch below does not either. Without this it
|
||||
# re-presents on every run: the same forever-loop the clear was
|
||||
# meant to end, one step further along.
|
||||
if [ "$ackable" -eq 1 ] && [ "$report_reviewed" -eq 1 ] \
|
||||
&& [ -z "$(current_sha_for_id "$rid")" ]; then
|
||||
already=$((already + 1))
|
||||
if [ "$tier" = CRITICAL ]; then crit_acked=$((crit_acked + 1)); fi
|
||||
continue
|
||||
fi
|
||||
# An un-ackable finding in a report already walked to the end.
|
||||
# There is nothing to record for one, so without this it would
|
||||
# re-prompt on every run forever, gating the exit from `ack` on a
|
||||
@@ -1619,18 +1685,25 @@ case "$cmd" in
|
||||
else
|
||||
sha="$(current_sha_for_id "$rid")"
|
||||
if [ -z "$sha" ]; then
|
||||
# Nothing left to pin the approval to: current_sha_for_id
|
||||
# returning empty means $id no longer exists (deleted
|
||||
# between compare and ack, or - for gitconfig - the key
|
||||
# was unset). Writing `safe <id> ` anyway would record a
|
||||
# baseline entry baseline_suppresses() can never match
|
||||
# (its own empty-sha guard rejects it as malformed), so
|
||||
# the surface would stay permanently unsuppressible -
|
||||
# while still advancing crit_acked and letting a CRITICAL
|
||||
# report shed its -CRITICAL.txt protection. Refuse
|
||||
# instead: record nothing, and leave the report's
|
||||
# protection exactly as it was.
|
||||
echo " cannot approve: $id no longer exists"
|
||||
# The surface is gone - deleted since compare, or a
|
||||
# gitconfig key unset. There is nothing to pin an
|
||||
# approval to, and `safe <id> ` with an empty sha would
|
||||
# record a baseline line baseline_suppresses() can never
|
||||
# match, leaving the surface permanently unsuppressible.
|
||||
#
|
||||
# So nothing is recorded - but the finding is CLEARED,
|
||||
# because a surface that does not exist does not run.
|
||||
# Refusing outright is what made one deleted file pin
|
||||
# the audit forever: crit_acked never reached crit_seen,
|
||||
# the report kept -CRITICAL.txt, and select_report picks
|
||||
# CRITICAL first, so every other report stayed hidden
|
||||
# behind a finding that could not be resolved by any
|
||||
# means. If the surface comes back, it comes back as a
|
||||
# new finding, which is the honest outcome.
|
||||
echo " ${C_OK}cleared${C_RESET}: $(sanitize "$id") no longer exists"
|
||||
removed_cleared=1
|
||||
[ "$tier" = CRITICAL ] && crit_acked=$((crit_acked + 1))
|
||||
sc_done+=("$rid")
|
||||
else
|
||||
pending+=("safe $id $sha")
|
||||
sc_done+=("$rid")
|
||||
@@ -1651,8 +1724,9 @@ case "$cmd" in
|
||||
# Written once the report has been walked to its end, and only when
|
||||
# it actually showed something that cannot be acknowledged - there is
|
||||
# nothing else to stop those from re-prompting forever.
|
||||
if [ "$unackable_shown" -eq 1 ] && [ "$report_reviewed" -eq 0 ]; then
|
||||
pending+=("reviewed $(basename "$report")")
|
||||
if { [ "$unackable_shown" -eq 1 ] || [ "$removed_cleared" -eq 1 ]; } \
|
||||
&& [ "$report_reviewed" -eq 0 ]; then
|
||||
pending+=("reviewed $(report_key "$report")")
|
||||
fi
|
||||
if [ "${#pending[@]}" -gt 0 ]; then
|
||||
mkdir -p "$BASELINE_DIR"
|
||||
@@ -1690,10 +1764,33 @@ case "$cmd" in
|
||||
else
|
||||
echo "dejima audit: nothing recorded."
|
||||
fi
|
||||
|
||||
# Next report, if there is one. Asked rather than assumed: an operator
|
||||
# who has decided enough for now should not have to abort out of a
|
||||
# report they have not read to stop.
|
||||
next_report="$(select_report "$report_dir")" || next_report=""
|
||||
if [ -n "$next_report" ] && [ "$next_report" != "$report" ]; then
|
||||
read -r n_all n_crit <<< "$(count_reports "$report_dir")"
|
||||
printf '\n%s report(s) still waiting' "$n_all"
|
||||
[ "$n_crit" -gt 0 ] && printf ', %s%s with unacknowledged CRITICAL%s' \
|
||||
"$C_CRIT" "$n_crit" "$C_RESET"
|
||||
printf '. Continue? [%sy%s/%sn%s] ' "$C_KEY" "$C_RESET" "$C_KEY" "$C_RESET"
|
||||
# fd 3, the same duplicate of the original stdin the finding
|
||||
# prompt reads: the report is this loop's stdin, and /dev/tty is
|
||||
# not the caller's input when `ack` is driven by a pipe.
|
||||
{ read -r answer <&3; } 2>/dev/null || answer=n
|
||||
printf '\n'
|
||||
case "$answer" in
|
||||
y|Y|"") report="$next_report"; sc="$(sidecar_dir "$report")"; continue ;;
|
||||
esac
|
||||
fi
|
||||
break
|
||||
done
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "usage: dejima-audit {snapshot|compare|show|ack} [workspace] [state-file]" >&2
|
||||
echo " show [--full] index of waiting reports; --full prints one" >&2
|
||||
echo " workspace defaults to the enclosing git repository, else the current directory" >&2
|
||||
exit 2
|
||||
;;
|
||||
|
||||
+16
-19
@@ -22,30 +22,24 @@
|
||||
# Schema
|
||||
#
|
||||
# Three TAB-separated columns: key, type, default. One source of truth - both
|
||||
# _config_spec and _config_keys read this, so a key cannot exist for lookup
|
||||
# but be missing from enumeration (which is what --print-config walks).
|
||||
# _config_spec and _config_keys read it, so a key cannot be lookup-able but
|
||||
# missing from --print-config's enumeration.
|
||||
#
|
||||
# A "?" prefix on the type means the empty string is a legal value. Without
|
||||
# it, empty is rejected: the distinction is between a setting that is
|
||||
# meaningfully absent (ssh_key, loki_url) and one that must always have a
|
||||
# A "?" type prefix means empty is legal, distinguishing a setting that is
|
||||
# meaningfully absent (ssh_key, loki_url) from one that must always have a
|
||||
# value (firewall, share_dir).
|
||||
#
|
||||
# The three axis keys are `provider:<axis>` - validated by looking the name
|
||||
# up under the provider root (see _config_extra_type_valid below and
|
||||
# dejima-provider-lib.sh) rather than a fixed enum, so adding a provider is
|
||||
# adding a directory, not editing this schema.
|
||||
#
|
||||
# [forge.<name>] and [harness.<name>] keys are absent from this table for the
|
||||
# same reason: enumerating a forge's own keys here would mean a new forge
|
||||
# needs a core edit, which is exactly what the provider axes are for.
|
||||
# Those two sections are resolved instead through the _config_extra_*
|
||||
# functions below, stubbed here and overridden by dejima-provider-lib.sh once
|
||||
# it is sourced, against the type+default each provider's own `keys` manifest
|
||||
# entry declares (see _provider_keyspec_parse there).
|
||||
# The axis keys are `provider:<axis>`, validated against the provider root
|
||||
# rather than a fixed enum, so adding a provider is adding a directory.
|
||||
# [forge.<name>] and [harness.<name>] keys are absent here for the same
|
||||
# reason - enumerating them would make a new forge need a core edit. They
|
||||
# resolve through the _config_extra_* functions below, stubbed here and
|
||||
# overridden by dejima-provider-lib.sh, against each provider's own `keys`
|
||||
# manifest entry.
|
||||
# ---------------------------------------------------------------------------
|
||||
_config_table() {
|
||||
cat <<'EOF'
|
||||
core.harness provider:harness claude-code
|
||||
core.harness provider:harness
|
||||
core.forge provider:forge none
|
||||
core.workflow provider:workflow local-only
|
||||
core.command identifier claude
|
||||
@@ -61,6 +55,9 @@ credentials.proxy bool on
|
||||
credentials.transport enum:tls|loopback tls
|
||||
logging.loki_url ?url
|
||||
logging.conversation bool off
|
||||
epitaph.enabled bool on
|
||||
epitaph.dir path ~/.local/share/dejima/epitaphs
|
||||
epitaph.loki bool off
|
||||
audit.dir path ~/.local/share/dejima/audit
|
||||
audit.baselines_dir path ~/.local/share/dejima/baselines
|
||||
audit.keep int:1:1000 10
|
||||
@@ -313,7 +310,7 @@ _config_extra_section_known() { return 1; }
|
||||
|
||||
_config_section_known() {
|
||||
case "$1" in
|
||||
core|github|mounts|credentials|logging|audit|remote-access) return 0 ;;
|
||||
core|github|mounts|credentials|logging|audit|remote-access|epitaph) return 0 ;;
|
||||
*) _config_extra_section_known "$1" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
Executable
+164
@@ -0,0 +1,164 @@
|
||||
#!/bin/bash
|
||||
# Epitaph: what a session leaves behind when its context dies.
|
||||
#
|
||||
# Claude Code writes a summary each time a session is compacted. It is the
|
||||
# only durable account of what a long session worked out, and it goes with
|
||||
# the container. This keeps it.
|
||||
#
|
||||
# Runs entirely on the host, from bin/dejima's exit trap. Nothing in the
|
||||
# container writes here and the store is never mounted, so a session cannot
|
||||
# read what earlier sessions concluded or edit what this one is about to
|
||||
# record - the same arrangement as the audit store, and for the same reason
|
||||
# (#104: anything a session writes and a later one reads is a channel into
|
||||
# that later session's premises).
|
||||
#
|
||||
# The transcript IS session-writable, so a summary is only as honest as the
|
||||
# file it came from. This preserves what was recorded, it does not attest to
|
||||
# it.
|
||||
#
|
||||
# Usage: dejima-epitaph save <workspace> [transcripts-dir]
|
||||
set -uo pipefail
|
||||
|
||||
# shellcheck source=dejima-provider-lib.sh
|
||||
source "$(dirname "$(readlink -f "$0")")/dejima-provider-lib.sh"
|
||||
# shellcheck source=dejima-slug-lib.sh
|
||||
source "$(dirname "$(readlink -f "$0")")/dejima-slug-lib.sh"
|
||||
config_validate || exit 1
|
||||
|
||||
cmd="${1:-}"; ws_arg="${2:-}"; transcripts="${3:-}"
|
||||
|
||||
[ "$(config_get epitaph enabled)" = "on" ] || exit 0
|
||||
STORE="$(config_get epitaph dir)"
|
||||
[ -n "$transcripts" ] || transcripts="$(config_get harness.claude-code projects_dir)"
|
||||
|
||||
ws="$ws_arg"
|
||||
[ -n "$ws" ] || ws="$(pwd)"
|
||||
ws="$(cd "$ws" 2>/dev/null && pwd)" || exit 0
|
||||
|
||||
case "$cmd" in
|
||||
save) ;;
|
||||
*) echo "usage: dejima-epitaph save <workspace> [transcripts-dir]" >&2; exit 2 ;;
|
||||
esac
|
||||
|
||||
# One file per summary, named for the moment it was written, so a directory
|
||||
# listing is a timeline. Keyed on the summary record's own uuid: `save` runs
|
||||
# on every exit, and a session compacted three times has three epitaphs, two
|
||||
# of which an earlier run already wrote.
|
||||
mkdir -p "$STORE" 2>/dev/null || exit 0
|
||||
chmod 700 "$STORE" 2>/dev/null || true
|
||||
|
||||
saved=0
|
||||
while IFS= read -r slug; do
|
||||
[ -n "$slug" ] || continue
|
||||
dir="$transcripts/$slug"
|
||||
[ -d "$dir" ] || continue
|
||||
out="$STORE/$slug"
|
||||
mkdir -p "$out" 2>/dev/null || continue
|
||||
for f in "$dir"/*.jsonl; do
|
||||
[ -f "$f" ] || continue
|
||||
# python3 is already a hard dependency (install.sh refuses without
|
||||
# it), and this is line-delimited JSON with embedded newlines in the
|
||||
# payload - not something to take apart with sed.
|
||||
n="$(python3 - "$f" "$out" <<'PY'
|
||||
import json, os, sys
|
||||
|
||||
path, out = sys.argv[1], sys.argv[2]
|
||||
pending = None # metadata from the compact_boundary that precedes a summary
|
||||
written = 0
|
||||
|
||||
for line in open(path, errors="replace"):
|
||||
line = line.strip()
|
||||
if not line or not line.startswith("{"):
|
||||
continue
|
||||
try:
|
||||
rec = json.loads(line)
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
if rec.get("subtype") == "compact_boundary":
|
||||
pending = rec.get("compactMetadata") or {}
|
||||
continue
|
||||
|
||||
if not rec.get("isCompactSummary"):
|
||||
continue
|
||||
|
||||
body = rec.get("message", {}).get("content")
|
||||
if isinstance(body, list):
|
||||
body = "\n".join(p.get("text", "") for p in body if isinstance(p, dict))
|
||||
if not isinstance(body, str) or not body.strip():
|
||||
pending = None
|
||||
continue
|
||||
|
||||
uid = rec.get("uuid") or ""
|
||||
stamp = (rec.get("timestamp") or "").replace(":", "").replace("-", "")[:15]
|
||||
name = "%s-%s.md" % (stamp or "unknown", uid[:8] or "nouuid")
|
||||
dest = os.path.join(out, name)
|
||||
if os.path.exists(dest):
|
||||
pending = None
|
||||
continue
|
||||
|
||||
meta = pending or {}
|
||||
head = [
|
||||
"# Epitaph",
|
||||
"",
|
||||
"- session: %s" % (rec.get("sessionId") or "unknown"),
|
||||
"- at: %s" % (rec.get("timestamp") or "unknown"),
|
||||
"- trigger: %s" % (meta.get("trigger") or "unknown"),
|
||||
]
|
||||
if meta.get("preTokens") is not None:
|
||||
head.append("- tokens: %s before, %s after"
|
||||
% (meta.get("preTokens"), meta.get("postTokens")))
|
||||
head += [
|
||||
"",
|
||||
"Written by the session and preserved verbatim. The transcript it came",
|
||||
"from is writable from inside the container, so this records what was",
|
||||
"there rather than vouching for it.",
|
||||
"", "---", "",
|
||||
]
|
||||
tmp = dest + ".part"
|
||||
with open(tmp, "w") as fh:
|
||||
fh.write("\n".join(head) + body.rstrip() + "\n")
|
||||
os.chmod(tmp, 0o600)
|
||||
os.rename(tmp, dest)
|
||||
written += 1
|
||||
pending = None
|
||||
|
||||
print(written)
|
||||
PY
|
||||
)" || n=0
|
||||
saved=$((saved + ${n:-0}))
|
||||
done
|
||||
done < <(workspace_slugs_sorted "$ws")
|
||||
|
||||
[ "$saved" -gt 0 ] || exit 0
|
||||
echo "dejima: wrote $saved epitaph(s) to $STORE" >&2
|
||||
|
||||
# Optional, and off by default: the store is the durable copy, this is for
|
||||
# operators who already centralise session logs. Host-side rather than
|
||||
# through the container's log-shipper, so the same "never in the session's
|
||||
# reach" property holds for the shipping path too.
|
||||
[ "$(config_get epitaph loki)" = "on" ] || exit 0
|
||||
loki="$(config_get logging loki_url)"
|
||||
[ -n "$loki" ] || exit 0
|
||||
while IFS= read -r slug; do
|
||||
[ -n "$slug" ] || continue
|
||||
for f in "$STORE/$slug"/*.md; do
|
||||
[ -f "$f" ] && [ ! -f "$f.shipped" ] || continue
|
||||
if python3 - "$loki" "$slug" "$f" <<'PY'
|
||||
import json, sys, time, urllib.request
|
||||
|
||||
loki, slug, path = sys.argv[1], sys.argv[2], sys.argv[3]
|
||||
body = json.dumps({"streams": [{
|
||||
"stream": {"job": "dejima-epitaph", "project": slug},
|
||||
"values": [[str(time.time_ns()), open(path, errors="replace").read()]],
|
||||
}]}).encode()
|
||||
req = urllib.request.Request(loki.rstrip("/") + "/loki/api/v1/push", data=body,
|
||||
headers={"Content-Type": "application/json"})
|
||||
urllib.request.urlopen(req, timeout=10).read()
|
||||
PY
|
||||
then
|
||||
: > "$f.shipped"
|
||||
fi
|
||||
done
|
||||
done < <(workspace_slugs_sorted "$ws")
|
||||
exit 0
|
||||
+14
-20
@@ -7,30 +7,24 @@
|
||||
# (no argument) watch until killed - what the systemd unit runs
|
||||
# --once one scan, then exit
|
||||
#
|
||||
# This script is the trust boundary. Everything under the marker root is
|
||||
# written by a sandboxed session that may be compromised, and the device it
|
||||
# ultimately reaches - the operator's phone - is outside the sandbox. The
|
||||
# in-container hook's own enum constrains nothing here: a session owns the
|
||||
# contents of its bind-mounted marker directory and can create any name it
|
||||
# likes in it, hook or no hook. The guarantee rests entirely on this file:
|
||||
# This script is the trust boundary: a possibly-compromised session writes
|
||||
# the markers, and the phone they reach is outside the sandbox. The
|
||||
# in-container hook's enum constrains nothing here - a session owns its
|
||||
# marker directory and can create any name it likes. The guarantee is here:
|
||||
#
|
||||
# - the marker NAME is the only thing consulted, and only by iterating a
|
||||
# literal enum, so a name outside it is never stat'd, read, or removed
|
||||
# - marker CONTENT is never read, so there is nothing to inject
|
||||
# - names are never interpolated into a command
|
||||
# - `rm -f` unlinks a symlink instead of following it, so a marker aimed at
|
||||
# a host file removes the link and leaves the file alone
|
||||
# - only the marker NAME is consulted, by iterating a literal enum, so a
|
||||
# name outside it is never stat'd, read, or removed
|
||||
# - marker CONTENT is never read, and names never reach a command
|
||||
# - `rm -f` unlinks a symlink rather than following it
|
||||
# - the click URL and every ntfy header are composed from host-side data
|
||||
#
|
||||
# That last point is what the enum is really for. ntfy's own headers include
|
||||
# X-Actions (which can make the phone issue an arbitrary HTTP request or fire
|
||||
# an Android intent), X-Attach and X-Icon (which fetch URLs), X-Email and
|
||||
# X-Call. A single attacker-controlled header here would turn the phone into
|
||||
# a confused deputy inside the operator's network.
|
||||
# The last point is what the enum is for: ntfy's X-Actions can make the phone
|
||||
# issue an HTTP request or fire an Android intent, X-Attach and X-Icon fetch
|
||||
# URLs. One attacker-controlled header makes the phone a confused deputy
|
||||
# inside the operator's network.
|
||||
#
|
||||
# One scan covers every run directory, so nothing a single session can do to
|
||||
# its own markers may abort it: that would let one compromised sandbox silence
|
||||
# every other session's notifications.
|
||||
# One scan covers every run directory, so nothing a session does to its own
|
||||
# markers can abort it and silence every other session's notifications.
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
|
||||
|
||||
+29
-41
@@ -115,24 +115,20 @@ read_declared() {
|
||||
# Shared argument parsing for dejima-allow and dejima-dep, so the two CLIs
|
||||
# cannot drift on what --project means.
|
||||
#
|
||||
# The hard part is that --project's path is OPTIONAL, which makes
|
||||
# `--project X` ambiguous on its own: X could be the path, or the required
|
||||
# positional. Deciding by "is X the last token" is wrong for `dejima-dep list
|
||||
# --project ~/p`, which has no trailing positional to protect and would
|
||||
# silently resolve to the current directory instead - the scope quietly
|
||||
# becoming one the caller did not name. So the decision is made against how
|
||||
# many positionals the command actually requires after the flag:
|
||||
# --project's path is OPTIONAL, so `--project X` is ambiguous: X could be the
|
||||
# path or the required positional. Deciding by "is X the last token" breaks
|
||||
# `dejima-dep list --project ~/p`, which has no trailing positional and would
|
||||
# silently scope to the current directory instead. So:
|
||||
#
|
||||
# --project takes the next token as its path only if doing so still leaves
|
||||
# the command its required trailing positional(s).
|
||||
# --project takes the next token as its path only if that still leaves the
|
||||
# command its required trailing positional(s).
|
||||
#
|
||||
# Required counts are per-command constants declared by the caller, not
|
||||
# inferred here.
|
||||
# Required counts are per-command constants the caller declares.
|
||||
#
|
||||
# Sets dj_global, dj_project, dj_project_set, dj_container and the
|
||||
# dj_positional array. dj_project_set exists because "--project with no path"
|
||||
# (the current directory) and "no --project at all" are different scopes and
|
||||
# an empty dj_project cannot tell them apart.
|
||||
# Sets dj_global, dj_project, dj_project_set, dj_container and dj_positional.
|
||||
# dj_project_set exists because "--project with no path" (the current
|
||||
# directory) and "no --project at all" are different scopes that an empty
|
||||
# dj_project cannot distinguish.
|
||||
parse_scope_args() {
|
||||
local required="$1"; shift
|
||||
local -a args=("$@")
|
||||
@@ -336,34 +332,26 @@ declare_entry() {
|
||||
local project="$1" kind="$2" entry="$3" validator
|
||||
validator="$(_approved_validator "$kind")" || return 1
|
||||
"$validator" "$entry" || { echo "Invalid $kind entry: $entry" >&2; return 1; }
|
||||
# This is the one write in this file that lands on the session-writable
|
||||
# bind mount, so it is the one that must not follow a symlink. A
|
||||
# compromised session can plant one and then ask the user - through the
|
||||
# startup briefing, which tells it to hand over exactly this command - to
|
||||
# run the CLI that trips it. Following it would aim a host-privileged
|
||||
# append anywhere the user can write: at ~/.config/dejima/allowed-domains,
|
||||
# silently turning a project approval into a global one; at
|
||||
# ~/.config/dejima/dependencies, where the entry is installed as root at
|
||||
# image build, on the one code path with no egress firewall in front of it;
|
||||
# or at any host file at all.
|
||||
# The one write here that lands on the session-writable bind mount, so
|
||||
# the one that must not follow a symlink. A session can plant one and
|
||||
# then ask the user - via the briefing, which hands over exactly this
|
||||
# command - to run the CLI that trips it, aiming a host-privileged
|
||||
# append at ~/.config/dejima/allowed-domains (project approval becomes
|
||||
# global) or dependencies (installed as root at image build, the one
|
||||
# path with no firewall in front of it).
|
||||
#
|
||||
# A test would not be enough. 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 while this runs - and since it is the one
|
||||
# asking for the command, it picks the moment and can spin a rename loop
|
||||
# across any window between a check and the write. So there is no check:
|
||||
# the guard is the open itself. O_NOFOLLOW makes a symlink at either path
|
||||
# fail the open outright, the directory is opened O_DIRECTORY and the file
|
||||
# is opened relative to that fd, so neither can be swapped for the other's
|
||||
# sake afterwards, and the whole read-then-append happens through that one
|
||||
# fd. A session that wins the swap gets an open of the object it replaced,
|
||||
# not a redirected write.
|
||||
# Checking first is not enough: the session is normally still live when
|
||||
# the user runs this, and it chose the moment, so it can spin a rename
|
||||
# loop across any check-to-write window. The guard is the open itself.
|
||||
# O_NOFOLLOW fails outright on a symlink, the directory is opened
|
||||
# O_DIRECTORY and the file relative to that fd, and the whole
|
||||
# read-then-append goes through it - so winning the swap gets an open of
|
||||
# the replaced object, not a redirected write.
|
||||
#
|
||||
# bash cannot open a path that way, so this is python3 - a hard dependency
|
||||
# already (install.sh refuses to install without it). O_NONBLOCK and the
|
||||
# S_ISREG check cover the non-symlink shapes: a FIFO planted here would
|
||||
# otherwise block a host CLI the user was told to run for as long as the
|
||||
# session declines to open the other end.
|
||||
# bash cannot open a path that way, hence python3 (already a hard
|
||||
# dependency). O_NONBLOCK and the S_ISREG check cover the non-symlink
|
||||
# shapes: a planted FIFO would otherwise hang the host CLI for as long as
|
||||
# the session declined to open the other end.
|
||||
python3 - "$project/.dejima" "$kind" "$entry" "$DEJIMA_MAX_DECLARED" <<'PY'
|
||||
import errno, os, stat, sys
|
||||
|
||||
|
||||
+53
-21
@@ -329,28 +329,17 @@ manifest_get() {
|
||||
# directory whose manifest declares its own keys, not editing core.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# config_validate re-parses the WHOLE config file on every config_get call
|
||||
# (see its own comment - "on the order of thirty reads" per launch), and each
|
||||
# pass through a [forge.<name>]/[harness.<name>] line calls back into here.
|
||||
# Going through manifest_get/manifest_validate here would mean thirty-odd
|
||||
# full-manifest validations (every field, not just `keys`) for every key in
|
||||
# those sections - so this reads and validates only the `keys` field itself,
|
||||
# a plain line-scan plus the one type check _provider_valid_value already
|
||||
# does for it, skipping fields (pr_command, capabilities, env_export, ...)
|
||||
# that this lookup never uses. The manifest's OTHER fields still get their
|
||||
# full manifest_validate elsewhere before anything consequential reads them
|
||||
# (provider_validate_selection, forge_hosts, forge_env_exports,
|
||||
# briefing_compose) - this function only ever feeds a config schema type
|
||||
# check, never a forge action.
|
||||
# config_validate re-parses the whole config on every config_get (~thirty per
|
||||
# launch), and each [forge.<name>] line calls back here. Going through
|
||||
# manifest_validate would mean that many full-manifest validations, so this
|
||||
# line-scans the `keys` field alone. Every other field still gets a full
|
||||
# manifest_validate before anything consequential reads it - this feeds a
|
||||
# config schema type check, never a forge action.
|
||||
#
|
||||
# Cached per axis+name for the rest of THIS command substitution: a
|
||||
# manifest cannot change mid-launch, so re-deriving it for every key in the
|
||||
# same section within one config_validate pass is pure waste. The cache
|
||||
# itself does not survive past this subshell (bash forks a new one per
|
||||
# config_get call), so it only dedupes the N keys of one section against
|
||||
# each other, not across a launch's ~thirty reads - a cheap, safe partial
|
||||
# win rather than a cross-process cache this file has no story for
|
||||
# invalidating.
|
||||
# Cached per axis+name for this command substitution only. bash forks a new
|
||||
# subshell per config_get, so it dedupes the keys of one section against each
|
||||
# other rather than across a launch: a cheap partial win, not a cross-process
|
||||
# cache this file has no way to invalidate.
|
||||
declare -gA _PROVIDER_KEYS_CACHE
|
||||
_provider_keys_cached() {
|
||||
local axis="$1" name="$2" cache_key="$axis:$name"
|
||||
@@ -371,6 +360,10 @@ _provider_keys_cached() {
|
||||
# here is a per-line fork thirty times over.
|
||||
_provider_keys_field() {
|
||||
local axis="$1" name="$2" dir file line key value="" seen=0 type n=0
|
||||
# An unselected axis contributes no keys - a different question from
|
||||
# whether a selection is valid. Without this, --print-config prints
|
||||
# "invalid harness name:" and fails at the thing it is for.
|
||||
[ -n "$name" ] || return 1
|
||||
dir="$(provider_dir "$axis" "$name")" || return 1
|
||||
file="$dir/$(_provider_manifest_name "$axis")"
|
||||
[ -f "$file" ] || return 1
|
||||
@@ -726,6 +719,14 @@ What this means:
|
||||
.dejima/allowed-domains, so it is shared with everyone working on it
|
||||
rather than living on one machine. Quote that --container value exactly as
|
||||
written here - it is this container's real ID, not something to infer.
|
||||
And a domain can be an action rather than a source: a deploy hook, a
|
||||
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.
|
||||
- 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.
|
||||
@@ -750,6 +751,33 @@ _briefing_core_worktree() {
|
||||
`.worktrees/` to `.git/info/exclude` (not `.gitignore` - this is
|
||||
sandbox-local, not something to commit) so it doesn't clutter `git
|
||||
status` for the user's real checkout.
|
||||
- A worktree isolates the working tree, NOT `.git/config`, which is shared
|
||||
with the parent checkout. A bare `git config <key> <value>` run from
|
||||
inside a worktree writes to the user's real repository config, not to
|
||||
anything scoped to your branch. Use `git -C <path> config` against a
|
||||
throwaway repo when setting config for a test or a repro. This matters
|
||||
beyond a stray `user.name`: `core.hooksPath`, `credential.helper` and
|
||||
`!`-aliases all execute on the host, and the same unscoped command
|
||||
reaches them.
|
||||
BRIEFING_EOF
|
||||
}
|
||||
|
||||
# Only when GitHub is a push destination this session. The default is off for
|
||||
# a reason the agent cannot infer from having the ability: docs/security.md,
|
||||
# "GitHub push". An agent that finds a real bug in a dependency and helpfully
|
||||
# opens a pull request on it has not made a mistake about permissions - it
|
||||
# has done something the operator was supposed to decide, and added to a queue
|
||||
# maintainers are already drowning in. Saying so here is cheaper than
|
||||
# discovering it afterwards, and the flag is exactly when it becomes possible.
|
||||
_briefing_core_github_push() {
|
||||
cat <<'BRIEFING_EOF'
|
||||
- GitHub push is enabled for this session, for the repository you were
|
||||
pointed at and nothing else. Do NOT push a branch, open a pull request, or
|
||||
file an issue on any other project, however good the change would be. An
|
||||
unsolicited pull request is a cost imposed on maintainers who did not ask
|
||||
for it, not a contribution, and it arrives under the operator's name. If
|
||||
you find a real bug somewhere else, say so in your reply and let them
|
||||
decide what to do about it.
|
||||
BRIEFING_EOF
|
||||
}
|
||||
|
||||
@@ -1013,6 +1041,10 @@ briefing_compose() {
|
||||
text+=$'\n\n'
|
||||
text+="$workflow_frag"
|
||||
text+=$'\n\n'
|
||||
if [ "${DEJIMA_GITHUB_PUSH:-off}" = "on" ]; then
|
||||
text+="$(_briefing_core_github_push)"
|
||||
text+=$'\n\n'
|
||||
fi
|
||||
text+="$(_briefing_core_closing_a)"
|
||||
text+=$'\n\n'
|
||||
text+="$harness_frag"
|
||||
|
||||
@@ -51,3 +51,31 @@ workspace_slugs() {
|
||||
|
||||
# Every slug in workspace_slugs, sorted and deduplicated.
|
||||
workspace_slugs_sorted() { workspace_slugs "$1" | sort -u; }
|
||||
|
||||
# The Claude Code session ids belonging to workspace $2, one per line, taken
|
||||
# from the transcript filenames under $1 (a ~/.claude/projects directory).
|
||||
#
|
||||
# ~/.claude/file-history is keyed by session id rather than by project, so
|
||||
# there is no slug in its own paths to scope it by. The transcript is what
|
||||
# ties the two together: projects/<slug>/<id>.jsonl and file-history/<id>
|
||||
# name the same session, so the slugs this workspace owns select the ids it
|
||||
# owns. A session id that has no transcript belongs to no project this can
|
||||
# see, and is left out.
|
||||
workspace_session_ids() {
|
||||
local projects_dir="$1" ws="$2" slug f id
|
||||
[ -d "$projects_dir" ] || return 0
|
||||
while IFS= read -r slug; do
|
||||
[ -n "$slug" ] || continue
|
||||
[ -d "$projects_dir/$slug" ] || continue
|
||||
for f in "$projects_dir/$slug"/*.jsonl; do
|
||||
[ -f "$f" ] || continue
|
||||
id="$(basename "$f" .jsonl)"
|
||||
# Only a real session id, never whatever else a directory might
|
||||
# hold: this value becomes a mount path.
|
||||
case "$id" in
|
||||
*[!a-fA-F0-9-]*) continue ;;
|
||||
esac
|
||||
[ -n "$id" ] && printf '%s\n' "$id"
|
||||
done
|
||||
done < <(workspace_slugs_sorted "$ws") | sort -u
|
||||
}
|
||||
|
||||
+18
-28
@@ -5,37 +5,27 @@
|
||||
# systemd/dejima-ttyd.service.
|
||||
# See docs/remote-access.md.
|
||||
#
|
||||
# --interface 127.0.0.1 is a security requirement, not a preference.
|
||||
# docker/init-firewall.sh:187 accepts container OUTPUT to the default-route
|
||||
# address - the Docker bridge gateway, i.e. the host - and attaching tmux
|
||||
# gives a HOST shell (the prefix key opens a new window outside the
|
||||
# container). A listener on any address the container can route to would hand
|
||||
# a compromised session its own escape hatch. Loopback is not one of those
|
||||
# addresses: the container has no route to the host's 127.0.0.1. Put nginx in
|
||||
# front, bound to one specific LAN address - see
|
||||
# docs/nginx-dejima.conf.example.
|
||||
# --interface 127.0.0.1 is a requirement, not a preference. init-firewall.sh
|
||||
# accepts container OUTPUT to the bridge gateway (the host), and attaching
|
||||
# tmux gives a HOST shell - the prefix key opens a window outside the
|
||||
# container. A listener on any address the container can route to is a
|
||||
# compromised session's escape hatch; loopback is not one of them. Put nginx
|
||||
# in front, bound to one LAN address - docs/nginx-dejima.conf.example.
|
||||
#
|
||||
# ttyd is given no credential of its own, so the reverse proxy is the only
|
||||
# thing authenticating a request, and the loopback bind is what makes that
|
||||
# safe rather than careless: nothing but a process on this host can open the
|
||||
# socket at all. --writable is what makes the terminal a terminal rather than
|
||||
# a viewer.
|
||||
# ttyd carries no credential of its own, so the proxy is the only thing
|
||||
# authenticating and the loopback bind is what makes that safe. --writable
|
||||
# makes it a terminal rather than a viewer.
|
||||
#
|
||||
# --check-origin is the other half of that, and it is not optional. Without
|
||||
# it ttyd does no origin validation at all (it only calls check_host_origin
|
||||
# when the flag is set), and browsers do not apply the same-origin policy to
|
||||
# WebSockets: any page the operator's browser loads could open
|
||||
# wss://<front-door>/ws, have the browser attach its cached credential for
|
||||
# that origin, and be handed this host shell. Proxy authentication cannot
|
||||
# stop that - it is satisfied by the victim's own browser. ttyd compares the
|
||||
# Origin authority against the Host header, which is why the nginx example
|
||||
# passes Host through unchanged; the two go together.
|
||||
# --check-origin is not optional: without it ttyd does no origin validation
|
||||
# at all, and browsers do not apply same-origin to WebSockets, so any page
|
||||
# the operator loads could open wss://<front-door>/ws with the browser
|
||||
# attaching its cached credential and be handed this shell. Proxy auth cannot
|
||||
# stop that - the victim's own browser satisfies it. ttyd compares Origin
|
||||
# against Host, which is why the nginx example passes Host through unchanged.
|
||||
#
|
||||
# --url-arg lets the client pass ?arg=<run-id>, which is what the notification
|
||||
# deep link uses. It reaches dejima-attach as argv, never as a shell word, and
|
||||
# anything there that is not --list or the empty string (a bare `?arg=`, which
|
||||
# lands on the picker) has to match the run-id shape before it is used as a
|
||||
# path component or anything else.
|
||||
# --url-arg carries ?arg=<run-id> for the notification deep link. It reaches
|
||||
# dejima-attach as argv, never a shell word, and anything but --list or empty
|
||||
# must match the run-id shape before it is used as a path component.
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
|
||||
|
||||
+15
-23
@@ -77,19 +77,15 @@ ENV DEJIMA=1
|
||||
# this user, so it doesn't need to own anything itself.
|
||||
RUN useradd -r -s /usr/sbin/nologin ulogd
|
||||
|
||||
# Every fetch from here down retries, and none is single-shot. What matters
|
||||
# isn't the odds of a particular download failing, it's the odds of any of
|
||||
# them failing - and without retries a single blip anywhere throws away the
|
||||
# whole build.
|
||||
# Every fetch from here down retries. What matters is not the odds of one
|
||||
# download failing but of ANY of them failing, and without retries a single
|
||||
# blip discards the whole build.
|
||||
#
|
||||
# These steps deliberately sit *above* the SANDBOX_* ARGs, not below. Nothing
|
||||
# between here and `USER` needs the sandbox user to exist yet - they all
|
||||
# install system-wide as root - so keeping them ahead of the ARGs means a
|
||||
# build that overrides one (test/runtime.bats does, and so does any host
|
||||
# whose uid isn't 1000) still reuses every layer down to this point instead
|
||||
# of re-running all of it. That single ordering was most of the cost of the
|
||||
# override rebuild: it was 1169s of a 1379s test run, for a difference that
|
||||
# amounts to one useradd.
|
||||
# These steps sit ABOVE the SANDBOX_* ARGs on purpose: nothing here needs the
|
||||
# sandbox user, so a build overriding a uid (runtime.bats, and any host not
|
||||
# on 1000) still reuses every layer to this point. That ordering alone was
|
||||
# most of the cost of an override rebuild, for a difference amounting to one
|
||||
# useradd.
|
||||
# gosu lets entrypoint.sh (root) drop to the node user for the actual
|
||||
# session after setting up the firewall, without leaving any setuid/sudo
|
||||
# escalation path available to node afterward. Verified against the
|
||||
@@ -189,7 +185,7 @@ RUN mkdir /commandhistory && \
|
||||
# longer mounted itself, only individual slug directories inside it, so
|
||||
# without this Docker creates it root-owned while setting those up and the
|
||||
# session cannot write a slug of its own.
|
||||
RUN mkdir -p $SANDBOX_HOME/.ssh $SANDBOX_HOME/.claude $SANDBOX_HOME/.claude/projects && \
|
||||
RUN mkdir -p $SANDBOX_HOME/.ssh $SANDBOX_HOME/.claude $SANDBOX_HOME/.claude/projects $SANDBOX_HOME/.claude/file-history && \
|
||||
chown -R $SANDBOX_USER:$SANDBOX_USER $SANDBOX_HOME/.ssh $SANDBOX_HOME/.claude && \
|
||||
chmod 700 $SANDBOX_HOME/.ssh $SANDBOX_HOME/.claude
|
||||
|
||||
@@ -259,16 +255,12 @@ RUN npm install -g tree-sitter-cli@0.24.7
|
||||
# matching Chromium build into node's own cache, both at build time so a
|
||||
# session never needs network access for either.
|
||||
#
|
||||
# Chromium's own internal process sandbox is questionable here: it launches
|
||||
# without --no-sandbox and without error, but its setuid sandbox helper
|
||||
# (chrome_sandbox) isn't actually setuid-root in this image, and this host
|
||||
# has the same unprivileged-userns restriction that broke bubblewrap - so
|
||||
# its namespace-based isolation almost certainly isn't active either, it's
|
||||
# just not hard-failing the way bubblewrap did. Not treated as a real
|
||||
# security boundary either way: this container is already the boundary that
|
||||
# matters, and a compromised session already has full `node`-level bash
|
||||
# access, which a Chromium exploit couldn't exceed regardless of whichever
|
||||
# of its own internal sandbox layers happen to be active.
|
||||
# Chromium's own process sandbox is probably not active here: it launches
|
||||
# without error, but chrome_sandbox is not setuid-root in this image and the
|
||||
# unprivileged-userns restriction that broke bubblewrap applies, so it just
|
||||
# fails softer than bubblewrap did. Not treated as a boundary either way -
|
||||
# the container is the boundary, and a session already has node-level shell
|
||||
# access that a Chromium exploit could not exceed.
|
||||
RUN npm install -g playwright@${PLAYWRIGHT_VERSION} && \
|
||||
npx playwright install chromium
|
||||
|
||||
|
||||
+28
-31
@@ -111,39 +111,24 @@ services:
|
||||
# (based on cwd) lines up with the host, letting a session started
|
||||
# here be resumed on the host with `claude --resume`.
|
||||
- ${WORKSPACE_DIR:?set WORKSPACE_DIR to the project directory to mount}:${WORKSPACE_DIR}
|
||||
# ~/.claude is deliberately not mounted as a whole. It used to be,
|
||||
# read-write, with the config inside it layered back read-only on top.
|
||||
# Every one of those overlays was a mount point nested in a
|
||||
# host-writable mount, and a single-file bind mount is pinned to the
|
||||
# dentry at that path: a host-side rename of the underlying file - a
|
||||
# sync daemon, or Claude Code's own atomic writes - unlinks it, the
|
||||
# mount silently detaches, and the path falls through to the real,
|
||||
# writable host file (#47, #48). The same thing happened to the mask
|
||||
# over .credentials.json, handing the sandbox the operator's real
|
||||
# OAuth token on the first refresh (#50).
|
||||
# ~/.claude is enumerated, never mounted whole. A single-file bind
|
||||
# mount is pinned to the dentry at its path, so a host-side rename -
|
||||
# a sync daemon, or Claude Code's own atomic writes - unlinks it, the
|
||||
# mount detaches silently, and the path falls through to the real
|
||||
# writable host file (#47, #48; #50 did exactly that to the
|
||||
# .credentials.json mask). Anything not listed here is
|
||||
# container-local, which is the safer default but means new state that
|
||||
# should persist has to be added deliberately.
|
||||
#
|
||||
# So the contents are enumerated instead. Anything not listed below is
|
||||
# container-local: writable during the session, gone with the
|
||||
# container. That is the safer default - a config file a future Claude
|
||||
# Code version adds simply doesn't persist, instead of sitting
|
||||
# unprotected on a writable mount - but it does mean new state that
|
||||
# *should* persist has to be added here deliberately.
|
||||
# Read-write below, and all directories: a directory mount can't
|
||||
# detach that way, because a rename replaces an entry within it rather
|
||||
# than the mount point itself.
|
||||
#
|
||||
# Runtime state that has to survive the session, read-write. All
|
||||
# directories, which is what makes them safe: a directory bind mount
|
||||
# can't detach the way a file one does, because a rename replaces an
|
||||
# entry *within* the mounted directory rather than the mount point.
|
||||
# projects/ is deliberately absent here. It holds every project's
|
||||
# transcripts and agent memory, and mounting the tree gave a session
|
||||
# write access to all of it - enough on its own to plant text a future
|
||||
# session in another project reads as established background (#104).
|
||||
# bin/dejima mounts the launching workspace's own slug and its live
|
||||
# worktree children instead, one -v each, so the rest are absent rather
|
||||
# than merely unwritable. The directory holding them stays
|
||||
# container-local, which is what lets Claude Code create a slug it does
|
||||
# not find. See bin/dejima-slug-lib.sh for which slugs those are and
|
||||
# why the set comes from real worktree paths.
|
||||
- ${HOME}/.claude/file-history:${HOME}/.claude/file-history
|
||||
# projects/ and file-history/ are absent on purpose - the trees are
|
||||
# every project's memory and every project's file contents, and a
|
||||
# write into either reaches sessions elsewhere (#104). bin/dejima
|
||||
# mounts this workspace's own entries, one -v each; see
|
||||
# bin/dejima-slug-lib.sh for how that set is chosen.
|
||||
- ${HOME}/.claude/backups:${HOME}/.claude/backups
|
||||
- ${HOME}/.claude/tasks:${HOME}/.claude/tasks
|
||||
- ${HOME}/.claude/plans:${HOME}/.claude/plans
|
||||
@@ -239,6 +224,18 @@ services:
|
||||
|
||||
volumes:
|
||||
claude-code-bashhistory:
|
||||
# Named explicitly so bin/dejima can give each project its own set. These
|
||||
# persist across containers by design (a plugin install should not have to
|
||||
# happen every launch), which without the split meant one project's session
|
||||
# could leave a Lua file or a mason/bin binary that another project's
|
||||
# session executes the next time nvim runs. The service still refers to the
|
||||
# static keys; only the volume Docker actually creates varies.
|
||||
#
|
||||
# The defaults reproduce the previous names exactly, so a `docker compose`
|
||||
# invocation that bypasses bin/dejima behaves as it did.
|
||||
nvim-data:
|
||||
name: ${SANDBOX_NVIM_DATA:-dejima_nvim-data}
|
||||
nvim-state:
|
||||
name: ${SANDBOX_NVIM_STATE:-dejima_nvim-state}
|
||||
nvim-cache:
|
||||
name: ${SANDBOX_NVIM_CACHE:-dejima_nvim-cache}
|
||||
|
||||
+11
-20
@@ -252,27 +252,18 @@ fi
|
||||
[ -n "${ANTHROPIC_BASE_URL:-}" ] || unset ANTHROPIC_BASE_URL
|
||||
[ -n "${ANTHROPIC_AUTH_TOKEN:-}" ] || unset ANTHROPIC_AUTH_TOKEN
|
||||
|
||||
# Remote access is opt-in and off by default, so this whole block is skipped
|
||||
# for the sessions that never asked for it: DEJIMA_NOTIFY_DIR is set only by
|
||||
# bin/dejima's remote_enabled branch. An unset value is the ordinary case and
|
||||
# says nothing.
|
||||
# Unset is the ordinary case (remote access is opt-in) and says nothing.
|
||||
# Set-but-unusable is worth a startup line because of how it presents: the
|
||||
# marker directory's source is created per launch and removed on exit, so
|
||||
# restarting the container rather than relaunching `dejima` leaves it gone,
|
||||
# and Docker answers a missing bind source by creating one root-owned. The
|
||||
# hook's `[ -d ]` guard then passes and every notification fails EACCES,
|
||||
# while the session is also missing from `dejima-attach` because its registry
|
||||
# entry went with the same directory - three unrelated-looking symptoms.
|
||||
#
|
||||
# Set-but-unusable is different, and it is worth a line at startup because of
|
||||
# how it otherwise presents. The marker directory is a bind mount whose source
|
||||
# bin/dejima creates per launch and removes on exit, so restarting the
|
||||
# container - `docker start`, or a restart policy - rather than relaunching
|
||||
# `dejima` leaves the source gone. Docker's answer to a missing bind source is
|
||||
# not to refuse: it creates one, root-owned. The directory then exists, so the
|
||||
# notify hook's own `[ -d ]` guard passes and every notification fails with
|
||||
# EACCES instead; the session is also absent from `dejima-attach`, because its
|
||||
# registry entry went with the same directory. Three unrelated-looking symptoms
|
||||
# and no obvious moment they started.
|
||||
#
|
||||
# Reported, not repaired. Chowning this would restore notifications while the
|
||||
# session stayed missing from the registry - which bin/dejima writes host-side
|
||||
# and never mounts here - so a session would look fixed while half of the
|
||||
# feature was still broken. Relaunching is the actual remedy, and it is one the
|
||||
# message can name.
|
||||
# Reported, not repaired: chowning would restore notifications while the
|
||||
# session stayed absent from the registry, looking fixed with half the
|
||||
# feature broken. Relaunching is the remedy, and the message can say so.
|
||||
if [ -n "${DEJIMA_NOTIFY_DIR:-}" ]; then
|
||||
if [ ! -d "$DEJIMA_NOTIFY_DIR" ]; then
|
||||
echo "WARNING: remote access is enabled, but $DEJIMA_NOTIFY_DIR is not a directory." >&2
|
||||
|
||||
+10
-15
@@ -50,22 +50,17 @@ GITHUB_DNS_SUFFIXES=(
|
||||
# Which ipset an allowlisted name's addresses belong in.
|
||||
#
|
||||
# GitHub's are restricted to port 443 by init-firewall.sh, and that
|
||||
# restriction is the read-only guarantee - there is no transport left for the
|
||||
# forwarded ssh-agent's key to push with. allowed-domains carries no port
|
||||
# restriction at all, so an address that reaches it is reachable on 22 as
|
||||
# well, and a GitHub address that reaches it silently undoes the whole
|
||||
# arrangement.
|
||||
# restriction IS the read-only guarantee: no transport is left for a
|
||||
# forwarded agent key to push with. allowed-domains has no port restriction,
|
||||
# so a GitHub address landing there silently undoes the arrangement.
|
||||
#
|
||||
# dns-proxy.sh gets this right for free: dnsmasq applies the longest matching
|
||||
# ipset= line, so GitHub names go to github-domains however they were
|
||||
# allowlisted. Every other path has to ask, and two did not - the
|
||||
# extra-domains loop in init-firewall.sh and the live add in bin/dejima-allow
|
||||
# both added unconditionally to allowed-domains. A project with github.com in
|
||||
# its .dejima/allowed-domains therefore opened port 22 to whichever address
|
||||
# GitHub returned at container start, for as long as that answer stayed
|
||||
# cached. It presented as an intermittent startup failure, because the
|
||||
# port-22 self-check re-resolves the name and only sees the hole when the
|
||||
# second answer matches the first.
|
||||
# dnsmasq applies the longest matching ipset= line, so dns-proxy.sh gets this
|
||||
# right for free. Every other path has to ask, and two did not - the
|
||||
# extra-domains loop and bin/dejima-allow's live add both appended
|
||||
# unconditionally. A project declaring github.com therefore opened port 22 to
|
||||
# whatever address was cached at container start, presenting as an
|
||||
# intermittent startup failure because the self-check re-resolves and only
|
||||
# sees the hole when the second answer matches the first.
|
||||
ipset_for_domain() {
|
||||
local domain="$1" suffix
|
||||
for suffix in "${GITHUB_DNS_SUFFIXES[@]}"; do
|
||||
|
||||
+24
-38
@@ -11,23 +11,17 @@
|
||||
set -Eeuo pipefail
|
||||
IFS=$'\n\t'
|
||||
|
||||
# Nothing in here may die quietly. Under `set -e` a failing command aborts the
|
||||
# script with no output at all, and this script's failures are invisible in the
|
||||
# worst possible way: the container simply never appears, which reads as the
|
||||
# sandbox being broken rather than as one command in one loop having failed.
|
||||
# Three CI failures were spent on two different instances of exactly that (#59)
|
||||
# - one at the GitHub ranges fetch, one in the domain loop below - and neither
|
||||
# left a single line to go on.
|
||||
# Nothing here may die quietly. Under `set -e` a failure aborts with no output
|
||||
# and the container simply never appears, which reads as the sandbox being
|
||||
# broken rather than one command failing. Three CI failures went to two
|
||||
# instances of exactly that (#59), neither leaving a line to go on.
|
||||
#
|
||||
# The explicit `exit 1` paths below say more than this can and are worth
|
||||
# keeping; this is the floor under everything else. -E on the line above is
|
||||
# what extends it into functions and subshells, where it would otherwise not
|
||||
# fire at all.
|
||||
# This is the floor; the explicit `exit 1` paths below say more. -E extends it
|
||||
# into functions and subshells, where it would not fire at all.
|
||||
#
|
||||
# A failure inside a command substitution reports twice - once from the
|
||||
# subshell, naming the command that actually failed, and once from the
|
||||
# statement containing it. That is worth the noise: for `x=$(a | b)` the first
|
||||
# line is the only thing that says whether it was `a` or `b`.
|
||||
# A failure inside a command substitution reports twice, from the subshell and
|
||||
# from the containing statement. Worth the noise: for `x=$(a | b)` the first
|
||||
# line is the only thing saying whether it was `a` or `b`.
|
||||
trap 'status=$?; echo "ERROR: init-firewall.sh aborted at line $LINENO (exit $status): $BASH_COMMAND" >&2' ERR
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
@@ -312,31 +306,23 @@ iptables -P OUTPUT DROP
|
||||
iptables -A INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
|
||||
iptables -A OUTPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
|
||||
|
||||
# Docker's embedded resolver does not answer external names itself - it
|
||||
# forwards them to the host's nameservers, and on current Docker it does that
|
||||
# from inside THIS network namespace. Those queries therefore traverse the
|
||||
# chain below like any other packet, and nothing here can match them: the
|
||||
# host's resolver is neither in an ipset nor the bridge gateway. They reach
|
||||
# the catch-all REJECT, every lookup comes back SERVFAIL, and the container
|
||||
# dies in a self-check that says only that a host was unreachable.
|
||||
# Docker's embedded resolver forwards external names to the host's
|
||||
# nameservers, and on some versions does it from inside THIS namespace. Those
|
||||
# queries then traverse the chain below and match nothing - the host's
|
||||
# resolver is neither in an ipset nor the bridge gateway - so they hit the
|
||||
# catch-all REJECT and every lookup SERVFAILs. Only the first hop stays on
|
||||
# loopback. Two hosts with byte-identical /etc/resolv.conf behaved
|
||||
# differently, so this is version-dependent and cannot be assumed either way.
|
||||
#
|
||||
# The comment on the loopback rule above used to claim DNS "stays on
|
||||
# loopback, so this also covers DNS". Only the first hop does. Two hosts with
|
||||
# byte-identical /etc/resolv.conf behaved differently here, so this is not a
|
||||
# property of any host's configuration and cannot be assumed either way.
|
||||
# --uid-owner 0 keeps this from being the hole the DNS allowlist closes: the
|
||||
# forwarding socket is dockerd's (root), the session is `node` with no sudo,
|
||||
# so it still cannot address a resolver directly. This newly permits DNS only
|
||||
# from processes already root in a NET_ADMIN container, which could flush the
|
||||
# ruleset anyway.
|
||||
#
|
||||
# --uid-owner 0 is what keeps this from being the hole the DNS allowlist
|
||||
# exists to close. The forwarding socket belongs to dockerd, which is root;
|
||||
# the session runs as `node` and there is no sudo in the image, so a
|
||||
# compromised session still cannot address a resolver directly and still has
|
||||
# no path to a name dns-proxy.sh has not allowed. What this does newly permit
|
||||
# is DNS from processes that are already root in a container holding
|
||||
# NET_ADMIN - which could simply flush this ruleset instead, so it grants
|
||||
# nothing that was not already available.
|
||||
#
|
||||
# Guarded rather than asserted: xt_owner is a module, and a kernel without it
|
||||
# should leave a container that says why (the DNS self-check below), not one
|
||||
# that aborts here with an iptables usage error.
|
||||
# Guarded, not asserted: xt_owner is a module, and a kernel without it should
|
||||
# leave a container that says why (the DNS self-check below) rather than one
|
||||
# that aborts here.
|
||||
if iptables -A OUTPUT -p udp --dport 53 -m owner --uid-owner 0 -j ACCEPT 2>/dev/null; then
|
||||
iptables -A OUTPUT -p tcp --dport 53 -m owner --uid-owner 0 -j ACCEPT 2>/dev/null \
|
||||
|| echo "WARNING: could not add the TCP/53 rule for root-owned sockets - large answers may fail" >&2
|
||||
|
||||
+24
-30
@@ -267,45 +267,39 @@ The rule underneath everything here:
|
||||
> **Every runner reachable from a repository that receives agent-authored
|
||||
> branches must be one you would accept an attacker running code on.**
|
||||
|
||||
Note what that does *not* say. It says nothing about what the repository's
|
||||
workflows currently do, because a branch can add the first one. **A project
|
||||
with no CI at all is still exposed**, on a forge where its branches can reach
|
||||
a runner, and that is the case people do not expect, because there is nothing
|
||||
in the repository to look at.
|
||||
Note what that does *not* say: nothing about what the workflows currently do,
|
||||
because a branch can add the first one. **A project with no CI at all is still
|
||||
exposed** on a forge where its branches can reach a runner - the case people
|
||||
do not expect, because there is nothing in the repository to look at.
|
||||
|
||||
So before pointing a sandbox at a project, ask one question: *what runners can
|
||||
So ask one question before pointing a sandbox at a project: *what runners can
|
||||
this repository's branches reach?*
|
||||
|
||||
**If the answer is "hosted runners only"** (GitHub-hosted and nothing
|
||||
self-hosted attached), this is the mildest case, and it is worth saying so
|
||||
rather than alarming people. The runner is disposable and is not your machine,
|
||||
so "execution on a host you own" largely evaporates: there is no LAN behind
|
||||
it and nothing of yours on it. What remains is the third item, credentials:
|
||||
give pull-request runs no secrets they do not need and a read-only token
|
||||
(`permissions: contents: read`), and do not attach a self-hosted runner to
|
||||
that repository.
|
||||
**"Hosted runners only"** is the mildest case. The runner is disposable and is
|
||||
not your machine, so "execution on a host you own" largely evaporates - no LAN
|
||||
behind it, nothing of yours on it. What remains is credentials: give
|
||||
pull-request runs no secrets they do not need and a read-only token
|
||||
(`permissions: contents: read`), and attach no self-hosted runner.
|
||||
|
||||
**If self-hosted runners are reachable**, that is the sharp case, and there are
|
||||
three ways to hold the rule, none of which require moving or renaming
|
||||
anything.
|
||||
**If self-hosted runners are reachable**, that is the sharp case. Three ways
|
||||
to hold the rule, none of which require moving or renaming anything.
|
||||
|
||||
**1. Turn CI off for that repository, if it does not use CI.** Disabling the
|
||||
Actions unit means no workflow runs for that repository at all, so no runner is
|
||||
reachable and a branch cannot add the first workflow. Changing it back needs
|
||||
*admin* on the repository (Gitea's API puts repository settings behind
|
||||
`reqAdmin()`), so a bot account with Write cannot undo it. For a project that
|
||||
was not using CI anyway this costs nothing and closes the vector outright. It
|
||||
is the first thing to reach for and the most commonly overlooked.
|
||||
**1. Turn CI off for that repository, if it does not use CI.** No workflow
|
||||
runs at all, so no runner is reachable and a branch cannot add the first
|
||||
workflow. Turning it back on needs *admin* (Gitea puts repository settings
|
||||
behind `reqAdmin()`), so a bot account with Write cannot undo it. For a
|
||||
project not using CI this costs nothing, and it is the most commonly
|
||||
overlooked option.
|
||||
|
||||
**2. Scope the dangerous runner down, rather than moving the repository out.**
|
||||
The instinct is to move agent-developed repositories somewhere with only
|
||||
hardened runners. For a public project that means breaking its name, its
|
||||
links and its ownership, which nobody should be asked to do.
|
||||
hardened runners, which for a public project breaks its name, links and
|
||||
ownership.
|
||||
|
||||
Invert it. A runner can belong to an **owner** (a user as well as an
|
||||
organization) and then serves that owner's repositories; Gitea's runner model
|
||||
names the three cases: global (`OwnerID` and `RepoID` both zero), org/user
|
||||
level (`OwnerID` set), repository level (`RepoID` set). So:
|
||||
Invert it. A runner can belong to an **owner** - a user as well as an
|
||||
organization - and then serves that owner's repositories. Gitea's model has
|
||||
three cases: global (`OwnerID` and `RepoID` zero), org/user (`OwnerID` set),
|
||||
repository (`RepoID` set). So:
|
||||
|
||||
- Make the *hardened* runner the owner-level default, so every repository gets
|
||||
it.
|
||||
|
||||
+69
-24
@@ -18,7 +18,7 @@ what a given launch will actually use.
|
||||
|
||||
| Key | Default | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `[core] harness` | `claude-code` | agent harness the container runs |
|
||||
| `[core] harness` | *(none - must be set)* | agent harness the container runs; refused if unset, so a later default cannot change your selection |
|
||||
| `[core] forge` | `none` | forge integration: `gitea` (`gitea-pr`/`gitea-issue`), `github` (`gh`, opens `ssh.github.com:22` for push), or `none` (no forge at all) |
|
||||
| `[core] workflow` | `local-only` | workflow `sandbox-context.sh` tells the agent to follow: `worktree-pr` (push + open a PR), `branch-push` (push, stop at the branch), or `local-only` (commit locally, push nothing) |
|
||||
| `[core] command` | `claude` | container's main process (e.g. `nvim`), also settable per launch with `--cmd` |
|
||||
@@ -39,6 +39,9 @@ what a given launch will actually use.
|
||||
| `[credentials] proxy` | `on` | host-side credential proxy; `off` mounts the real OAuth credential into the container directly, as before this feature |
|
||||
| `[credentials] transport` | `tls` | set to `loopback` to fall back to a plaintext forwarder with an explicit `ANTHROPIC_BASE_URL`, keeping the credential out of the container but reinstating the base-URL fingerprint: for when certificate pinning breaks the default TLS path (see [security notes](docs/security.md)) |
|
||||
| `[logging] loki_url` | unset | ship DNS/firewall logs to a Loki endpoint |
|
||||
| `[epitaph] enabled` | `on` | keep the summary Claude Code writes when a session compacts |
|
||||
| `[epitaph] dir` | `~/.local/share/dejima/epitaphs` | where they are kept; never mounted into a container |
|
||||
| `[epitaph] loki` | `off` | also push each epitaph to `[logging] loki_url`, host-side |
|
||||
| `[logging] conversation` | `off` | opt-in: also ship session transcripts to Loki (needs `loki_url`); see "Conversation logging (opt-in)" in docs/security.md |
|
||||
| `[audit] dir` | `~/.local/share/dejima/audit` | where exit-time audit findings are stored |
|
||||
| `[audit] baselines_dir` | `~/.local/share/dejima/baselines` | where audit baselines are stored |
|
||||
@@ -93,32 +96,27 @@ name a release archive instead:
|
||||
dejima-dep add --global tarball:go@<sha256>:https://go.dev/dl/go1.25.0.linux-amd64.tar.gz
|
||||
```
|
||||
|
||||
To get the digest, fetch the archive on the host and hash it. Do not take a
|
||||
digest from the same page that serves the download unless you are willing to
|
||||
trust that page for both:
|
||||
Fetch the archive on the host and hash it. Do not take the digest from the
|
||||
same page that serves the download unless you trust that page for both:
|
||||
|
||||
```sh
|
||||
curl -fLO https://go.dev/dl/go1.25.0.linux-amd64.tar.gz
|
||||
sha256sum go1.25.0.linux-amd64.tar.gz
|
||||
```
|
||||
|
||||
Many projects publish a checksums file or sign their releases. Where they do,
|
||||
verifying against that is worth more than hashing whatever arrived, because it
|
||||
tells you the archive is the one the project published rather than the one
|
||||
your connection received.
|
||||
Where a project publishes a checksums file or signs its releases, verifying
|
||||
against that beats hashing whatever arrived: it says the archive is the one
|
||||
the project published, not the one your connection received.
|
||||
|
||||
It unpacks with `--strip-components=1` into `/opt/dejima/<name>`, and
|
||||
`/opt/dejima/<name>/bin` goes on `PATH`. That one convention covers the usual
|
||||
shapes without the spec needing layout options: a Go archive has everything
|
||||
under `go/`, an nvim release under `nvim-linux-<arch>/`, and stripping one
|
||||
level puts `bin/` in the same place for both. Compression is detected from the
|
||||
archive, so `.tar.gz`, `.tar.xz` and `.tar.zst` all work.
|
||||
`/opt/dejima/<name>/bin` goes on `PATH` - one convention covering the usual
|
||||
shapes without layout options, since a Go archive is under `go/` and an nvim
|
||||
release under `nvim-linux-<arch>/`. Compression is detected, so `.tar.gz`,
|
||||
`.tar.xz` and `.tar.zst` all work.
|
||||
|
||||
**The digest is mandatory**, and it is checked before anything is unpacked.
|
||||
Without one you would be approving a *name* whose contents the far end can
|
||||
change afterwards, which isn't a decision anyone can make once. With one, what
|
||||
you approved is a specific archive and nothing else can take its place. That
|
||||
has two consequences:
|
||||
**The digest is mandatory** and is checked before anything is unpacked.
|
||||
Without one you would approve a *name* whose contents the far end can change
|
||||
afterwards. Two consequences:
|
||||
|
||||
- **A tarball dependency locks the project to one architecture**, because a
|
||||
digest does. A package name resolves per-host and a digest cannot: it names
|
||||
@@ -136,12 +134,11 @@ shell-active is refused outright rather than quoted around, and only `https`
|
||||
is accepted.
|
||||
|
||||
**Declaring grants nothing.** The project directory is bind-mounted
|
||||
read-write, so a compromised session could write these files - they are read
|
||||
only to compare against reality and suggest a command. Approvals live
|
||||
outside the container, under `~/.config/dejima/` by default
|
||||
(`$DEJIMA_CONFIG_DIR` relocates the whole tree) - global at its
|
||||
root, per project under `projects/<slug>/` - and are written only by tools
|
||||
you run on the host:
|
||||
read-write, so a session could write these files; they are read only to
|
||||
compare against reality and suggest a command. Approvals live outside the
|
||||
container under `~/.config/dejima/` (`$DEJIMA_CONFIG_DIR` relocates the
|
||||
tree) - global at its root, per project under `projects/<slug>/` - written
|
||||
only by tools you run on the host:
|
||||
|
||||
```sh
|
||||
dejima-project status ~/myproject # declared vs approved vs missing
|
||||
@@ -329,6 +326,21 @@ nvim_config = ~/.config/nvim
|
||||
Set both in `~/.config/dejima/config` if you use this mode regularly, or pass
|
||||
`--cmd nvim` per launch once `nvim_config` is set.
|
||||
|
||||
The lockfile is layered on as a **copy**, not as your file: `bin/dejima`
|
||||
mounts a `mktemp` duplicate and deletes it afterwards, so a plugin sync
|
||||
inside a session cannot rewrite the host's `lazy-lock.json`.
|
||||
|
||||
`~/.local/share/nvim` (plugin source and `mason/` binaries), `~/.local/state/
|
||||
nvim` and `~/.cache/nvim` are **Docker volumes, not host directories**. Your
|
||||
own nvim data is never mounted, so nothing a session installs can reach the
|
||||
editor you run outside the container. They do persist across containers,
|
||||
which is what stops every launch from reinstalling plugins - so they are
|
||||
named per project. Under one shared name, a session that wrote a Lua file
|
||||
under `lazy/`, or replaced a binary under `mason/bin/`, would leave something
|
||||
the next session in an *unrelated* project executed. Nothing there is
|
||||
protection against a session tampering with its own project's plugins, which
|
||||
it can do freely and which only ever runs inside the sandbox.
|
||||
|
||||
If your config references something outside that directory by absolute host
|
||||
path (a helper script in a dotfiles `bin/`, say), bring it in with
|
||||
`[mounts] readonly` (colon-separated, mounted read-only at the same path):
|
||||
@@ -366,6 +378,31 @@ resolving symlinks, so `read_write = ~` is caught too.
|
||||
Sessions are told which directories these are, so "everything else is
|
||||
disposable" stays true in the briefing rather than becoming a trap.
|
||||
|
||||
### One-off mounts for a single launch
|
||||
|
||||
`--extra-readonly PATH` and `--extra-read-write PATH` add a path for one run
|
||||
without editing the config. `--ro` and `--rw` are the short forms. Both are
|
||||
repeatable, one path per occurrence:
|
||||
|
||||
```sh
|
||||
dejima --ro ~/other-project ~/myproject # read while migrating code out
|
||||
dejima --rw ~/scratch --ro ~/reference ~/myproject
|
||||
```
|
||||
|
||||
They **append** to the config's lists rather than replacing them, and they
|
||||
are the same lists, so everything above applies unchanged: the existence
|
||||
check, the refusal when a path overlaps something dejima keeps out of a
|
||||
session's reach, the refusal when one path appears in both lists, and the
|
||||
briefing sentence naming what a session can write. A flag says *where*, not
|
||||
*whether*, and there is no flag that waives a refusal.
|
||||
|
||||
Two differences from the config form, both because a command line is not a
|
||||
file. A relative path is accepted and made absolute against the working
|
||||
directory, which a config file has no equivalent of. And a path containing a
|
||||
colon is refused outright: both lists are colon-separated, so such a path
|
||||
cannot be represented in one at all and would otherwise split into two
|
||||
mounts that each fail the existence check.
|
||||
|
||||
## Shared scratch space
|
||||
|
||||
`~/shared` is a read-write bind mount of `[core] share_dir` (default
|
||||
@@ -449,6 +486,14 @@ since the same session can be resumed unrestricted on the host via
|
||||
`claude --resume`. No-op outside the container. `deny-dangerous-bash.sh`
|
||||
(`PreToolUse`) also applies here, since `~/.claude/hooks` is mounted in.
|
||||
|
||||
When GitHub is a push destination this session - `--allow-github-push`, or
|
||||
`forge = github` under a pushing workflow - the briefing also tells the
|
||||
session not to push branches or open pull requests on any project other than
|
||||
the one it was pointed at. Lifting the default gives an agent the ability
|
||||
without the reason it is normally off (see
|
||||
[docs/security.md](security.md#github-push-blocked-by-default-optional-opt-in)),
|
||||
so the reason is supplied where it will be read.
|
||||
|
||||
`git-push-guard.sh` (`PreToolUse`/`Bash`, also `DEJIMA`-gated) blocks
|
||||
any `git push` whose resolved remote isn't the Gitea host, and any push to
|
||||
`main`/`master` even there. Like the other hooks, this is a fast-fail guard
|
||||
|
||||
+18
-1
@@ -34,7 +34,8 @@ run them.
|
||||
| `bin/dejima-notify-watch` | Host-side watcher: turns markers into ntfy pushes composed entirely from host data |
|
||||
| `bin/dejima-project` | Review and approve project domains and dependencies from the host |
|
||||
| `bin/dejima-project-lib.sh` | Shared library sourced by `bin/dejima-dep`, `bin/dejima-project`, `bin/dejima` and the remote-access scripts; not on PATH |
|
||||
| `bin/dejima-slug-lib.sh` | The `~/.claude/projects` slug scheme and which slugs belong to a workspace; sourced by `bin/dejima` (what to mount) and `bin/dejima-audit` (what counts as cross-project), so the two cannot disagree. Not on PATH |
|
||||
| `bin/dejima-epitaph` | Host-side: keeps the summary a session leaves when its context is compacted away, in a store nothing mounts. Run from `bin/dejima`'s exit trap |
|
||||
| `bin/dejima-slug-lib.sh` | The `~/.claude/projects` slug scheme and which slugs belong to a workspace; sourced by `bin/dejima` (what to mount) and `bin/dejima-epitaph` (whose transcripts to keep), so the two cannot disagree. Not on PATH |
|
||||
| `bin/dejima-ttyd` | Launches `ttyd` on loopback in front of `dejima-attach` |
|
||||
| `systemd/*.service` | User units for `dejima-ttyd` and `dejima-notify-watch`; ship disabled, installed by hand |
|
||||
| `docs/providers.md` | The manifest contract for the forge/workflow/harness axes, key by key, with the shipped manifests as worked examples |
|
||||
@@ -111,6 +112,22 @@ socket withheld from the confined job, granted to the privileged job, and
|
||||
the egress each is allowed) - see [docs/ci.md](ci.md). It skips outright
|
||||
unless `DEJIMA_CI_TIER` is set, so it has nothing to say when run locally.
|
||||
|
||||
### Branches
|
||||
|
||||
Pull requests target `development`, which merges to `main` periodically, so
|
||||
`main` stays releasable while work lands quickly. `gitea-pr` defaults its
|
||||
base to the repository's default branch, so pass `--base development`.
|
||||
|
||||
CI needs no change for this: `.gitea/workflows/tests.yml` triggers on
|
||||
`pull_request_target` with no branch filter, so it already runs whatever the
|
||||
base is.
|
||||
|
||||
Nothing *enforces* the target. `git-push-guard.sh` blocks direct pushes to
|
||||
the branches a workflow names, and that list is workflow-wide rather than
|
||||
per-project - adding `development` would impose it on every dejima user,
|
||||
most of whom have no such branch. So this is a convention with a check
|
||||
attached: read what `gitea-pr` printed.
|
||||
|
||||
### Running the tests
|
||||
|
||||
```sh
|
||||
|
||||
+16
-25
@@ -318,30 +318,21 @@ every launch:
|
||||
that has config keys but none of them resolve to a host is refused with
|
||||
"configure it, or select `forge = github`, or `forge = none` with a
|
||||
workflow that does not push".
|
||||
- A forge whose `requires` keys are **partly** set - some configured, some
|
||||
not (a key written but left empty, as `install.sh`'s commented-out starter
|
||||
config makes easy, counts as not set: it resolves to nothing, and every
|
||||
call the forge makes with it would fail inside the container) - is refused
|
||||
as a likely typo, but only when the workflow actually
|
||||
pushes: a non-pushing workflow never invokes the forge, so e.g. gitea's
|
||||
`ssh_host` alone (set only so the firewall allows fetches) is a legitimate
|
||||
configuration, not a typo. A wholly unconfigured forge is never flagged by
|
||||
this rule - that's the case above.
|
||||
- A pushing workflow whose forge has **none** of its required settings
|
||||
configured is refused too, even when `fixed_hosts` means the "resolves to
|
||||
no host" rule above never fires. This is what catches `github`: its single
|
||||
`requires` key (`push_token_file`) means "partly configured" (the rule
|
||||
above) can never trigger for it either - there's nothing to be partial
|
||||
between - so without this rule a pushing workflow could select `github`
|
||||
with no `push_token_file` set and launch anyway, `fixed_hosts` masking the
|
||||
fact that nothing was actually configured.
|
||||
|
||||
Two qualifications worth stating explicitly for the "partly configured" rule:
|
||||
it only applies when the workflow's `push = on` (a non-pushing workflow never
|
||||
touches the forge, so an incomplete `[forge.<name>]` section is harmless),
|
||||
and a required key with a non-empty schema default - gitea's `token_file`,
|
||||
defaulting to `~/.config/gitea/token` - never counts as missing, since its
|
||||
resolved value is already correct even when the operator never wrote it.
|
||||
- A forge whose `requires` keys are **partly** set is refused as a likely
|
||||
typo, but only when the workflow pushes. A key written but left empty
|
||||
counts as not set - it resolves to nothing, and every call made with it
|
||||
would fail inside the container. A non-pushing workflow never invokes the
|
||||
forge, so gitea's `ssh_host` alone (set only so the firewall allows
|
||||
fetches) is legitimate. A required key with a non-empty schema default -
|
||||
gitea's `token_file` - never counts as missing, since its resolved value is
|
||||
correct even unwritten. A wholly unconfigured forge is the case above, not
|
||||
this one.
|
||||
- A pushing workflow whose forge has **none** of its required settings is
|
||||
refused too, even where `fixed_hosts` stops the "resolves to no host" rule
|
||||
firing. This catches `github`: with a single `requires` key there is
|
||||
nothing to be partial between, so without this rule a pushing workflow
|
||||
could select it with no `push_token_file` and launch anyway, `fixed_hosts`
|
||||
masking that nothing was configured.
|
||||
|
||||
## Shipped providers
|
||||
|
||||
@@ -353,7 +344,7 @@ resolved value is already correct even when the operator never wrote it.
|
||||
| workflow | `worktree-pr` | worktree → push → PR, stops at the PR |
|
||||
| workflow | `branch-push` | worktree → push, stops at the branch |
|
||||
| workflow | `local-only` | **default** - commits locally, pushes nothing |
|
||||
| harness | `claude-code` | **default**, and the only harness today |
|
||||
| harness | `claude-code` | the only harness today, and **not a default** - `[core] harness` must be set |
|
||||
|
||||
**The shipped defaults are deliberately the non-pushing pair**
|
||||
(`forge = none`, `workflow = local-only`). A fresh install has no forge host
|
||||
|
||||
+26
-33
@@ -700,16 +700,14 @@ what the outer proxy sends.
|
||||
|
||||
## Notifications
|
||||
|
||||
`dejima` registers `hooks/dejima-notify.sh` as a `Notification` and
|
||||
`Stop` hook for a remote-enabled session - into the throwaway settings
|
||||
override layer it already generates for that session, never into your real
|
||||
`~/.claude/settings.json`, which every sandbox and your host's own Claude
|
||||
Code read directly. Your own `Notification` and `Stop` hooks keep running
|
||||
alongside it: Claude Code merges hooks across settings layers rather than
|
||||
letting the higher one replace the lower. That layer is mounted read-only, so a
|
||||
session cannot unregister its own hook. The hook writes one empty file, named `waiting` or
|
||||
`done`, into this run's own bind-mounted marker directory. No content, no
|
||||
stdin, no network.
|
||||
`dejima` registers `hooks/dejima-notify.sh` as a `Notification` and `Stop`
|
||||
hook for a remote-enabled session, into the throwaway settings override layer
|
||||
it already generates - never into your real `~/.claude/settings.json`. Your
|
||||
own hooks keep running alongside it, since Claude Code merges hooks across
|
||||
layers rather than replacing. The layer is mounted read-only, so a session
|
||||
cannot unregister its own hook. The hook writes one empty file named
|
||||
`waiting` or `done` into this run's marker directory: no content, no stdin,
|
||||
no network.
|
||||
|
||||
On the host, `dejima-notify-watch` turns those markers into ntfy pushes.
|
||||
Same installation shape as above:
|
||||
@@ -726,14 +724,13 @@ loop plus a five-second sleep). Repeat markers for the same session and
|
||||
event inside 60 seconds are debounced (`[remote-access] debounce` overrides
|
||||
that).
|
||||
|
||||
One environment gotcha, because it fails silently: the watcher and
|
||||
`dejima` both resolve the marker root as `${XDG_RUNTIME_DIR:-/tmp}/dejima`,
|
||||
so they only meet when they agree on `XDG_RUNTIME_DIR`. systemd sets it for
|
||||
user units and a login shell gets it from pam_systemd, but a `dejima`
|
||||
run from a context that has neither - a bare `su`, a cron job, a tmux server
|
||||
started before login - writes its markers under `/tmp/dejima` while the unit
|
||||
watches `/run/user/<uid>/dejima`, and no push is ever sent, with nothing
|
||||
logged to say so. `systemctl --user show-environment` is where to check.
|
||||
One gotcha, because it fails silently: both sides resolve the marker root as
|
||||
`${XDG_RUNTIME_DIR:-/tmp}/dejima`, so they only meet when they agree on
|
||||
`XDG_RUNTIME_DIR`. systemd sets it for user units and a login shell gets it
|
||||
from pam_systemd, but a run from a context with neither - a bare `su`, cron,
|
||||
a tmux server started before login - writes to `/tmp/dejima` while the unit
|
||||
watches `/run/user/<uid>/dejima`. No push is sent and nothing is logged.
|
||||
Check with `systemctl --user show-environment`.
|
||||
|
||||
The same directory is why you probably want lingering on:
|
||||
|
||||
@@ -741,23 +738,19 @@ The same directory is why you probably want lingering on:
|
||||
loginctl enable-linger "$USER"
|
||||
```
|
||||
|
||||
Both units here are *user* units. Without lingering they are stopped at
|
||||
logout and `/run/user/<uid>` is destroyed with the session, taking the
|
||||
session registry and every marker with it - so a sandbox left running in a
|
||||
detached tmux session has no front door and sends no notification until you
|
||||
log in again. It fails closed rather than open (nothing is left listening
|
||||
unguarded), but it fails invisibly, and it is the same `XDG_RUNTIME_DIR`
|
||||
that the gotcha above is about.
|
||||
Both are *user* units. Without lingering they stop at logout and
|
||||
`/run/user/<uid>` is destroyed with the session, taking the registry and
|
||||
every marker with it - so a sandbox left in a detached tmux session has no
|
||||
front door and sends nothing until you log in again. It fails closed, but
|
||||
invisibly, and on the same `XDG_RUNTIME_DIR` as the gotcha above.
|
||||
|
||||
**The marker directory is a write channel into your runtime tmpfs**, and
|
||||
nothing bounds it. `/run/user/<uid>` is RAM-backed and sized at a fraction
|
||||
of physical memory, and each session gets its own directory under it
|
||||
bind-mounted read-write, so a session that writes into `/run/dejima-notify`
|
||||
until it is full degrades your whole login session - D-Bus, the systemd user
|
||||
manager - and not just dejima. A session can already fill the workspace
|
||||
mount, so this is an increment rather than a new kind of exposure, but a
|
||||
RAM-backed filesystem fails harder and sooner than a disk. There is
|
||||
deliberately no quota here; `rm -rf` on that run's marker directory (or
|
||||
nothing bounds it. `/run/user/<uid>` is RAM-backed and sized at a fraction of
|
||||
physical memory, so a session filling `/run/dejima-notify` degrades your
|
||||
whole login session - D-Bus, the systemd user manager - not just dejima. A
|
||||
session can already fill the workspace mount, so this is an increment rather
|
||||
than a new exposure, but a RAM-backed filesystem fails harder and sooner.
|
||||
There is deliberately no quota here; `rm -rf` on that run's marker directory (or
|
||||
logging out, which discards the tmpfs) is the recovery.
|
||||
|
||||
**What a notification contains.** A title of `dejima: <project> needs input`
|
||||
|
||||
+225
-160
@@ -10,6 +10,12 @@ at repositories you already trust. Everything below is about how bad a
|
||||
compromised session *inside* that trust boundary can get, not a claim that
|
||||
one can't happen.
|
||||
|
||||
**And none of it has been reviewed by anyone else.** This is early
|
||||
development: the reasoning here is one person's, the tests assert what that
|
||||
person thought to check, and no outside audit or adversarial testing has
|
||||
happened. Read what follows as claims awaiting scrutiny rather than claims
|
||||
that have survived it. See [SECURITY.md](../SECURITY.md).
|
||||
|
||||
## CI: push access is code execution elsewhere
|
||||
|
||||
The controls in this document bound what a session can reach *directly*. They
|
||||
@@ -212,27 +218,34 @@ hands to whichever session starts against it. See
|
||||
appears in `.git/config` directly), `!`-shell aliases - plus content
|
||||
changes under the workspace's `.claude/`, `.mcp.json`, `.envrc`,
|
||||
`.vscode/`, and `.git/hooks/` (symlinks included - a repointed symlink is
|
||||
hashed by its target, not just its content), plus cross-project writes
|
||||
under `~/.claude/projects` (see below). When the mounted workspace *is*
|
||||
this repo's own sandbox itself (see the trusted-computing-base note
|
||||
further below), it also diffs content hashes of the sandbox's own trusted
|
||||
computing base - `bin/`, `hooks/`, `docker/`, `install.sh`,
|
||||
`test/` - under that workspace. It's **advisory only**: it fires *after*
|
||||
the session has already ended, so a report of a planted `core.hooksPath`
|
||||
or hook file is something to read and clean up *before* the next `git`
|
||||
or `claude` in that repo, not something that was blocked. It's silent when nothing
|
||||
changed, and never affects the session's own exit code even if it can't
|
||||
complete. It doesn't cover `[core] share_dir` (see
|
||||
[Shared scratch space](configuration.md#shared-scratch-space)) - that
|
||||
hand-off path stays unaudited - and its coverage is a
|
||||
fixed list of known Claude Code config surfaces, not a general integrity
|
||||
check, so a future config surface this list hasn't been taught about yet
|
||||
would go unreported. **Its diff is time-windowed, not session-attributed:**
|
||||
a finding means one of these paths changed while the session was running,
|
||||
not that the session made the change. A concurrent dejima or Claude Code
|
||||
session, another host process, or you editing the same files by hand
|
||||
during the run all land in the same diff and get reported identically -
|
||||
the tool has no way to tell them apart.
|
||||
hashed by its target, not just its content). When the mounted workspace is
|
||||
this repo itself, it also hashes the sandbox's own trusted computing base -
|
||||
`bin/`, `hooks/`, `docker/`, `install.sh`, `test/`.
|
||||
|
||||
Worth knowing about that first surface, because the sandbox's own workflow
|
||||
makes it easy to get wrong: **a git worktree isolates the working tree, not
|
||||
`.git/config`.** Config is shared with the parent checkout, so a bare
|
||||
`git config <key> <value>` inside a worktree writes the real repository's
|
||||
config, including any exec key above. The briefing now says so, and gives
|
||||
`git -C <path> config`. A guard against an unaware agent, not a boundary:
|
||||
`.git/config` is in the read-write project mount and cannot be read-only
|
||||
without breaking `git push -u`, which writes `branch.<name>.merge` on every
|
||||
push. Detection covers the rest, and did - this was found by the audit
|
||||
reporting a `.git/config` change.
|
||||
|
||||
**Advisory only.** It fires after the session ended, so a planted
|
||||
`core.hooksPath` is something to clean up before the next `git` or
|
||||
`claude` in that repo, not something that was blocked. Silent when nothing
|
||||
changed, and never affects the session's exit code. It does not cover
|
||||
`[core] share_dir` (see
|
||||
[Shared scratch space](configuration.md#shared-scratch-space)), and its
|
||||
coverage is a fixed list rather than a general integrity check, so a config
|
||||
surface it has not been taught about goes unreported.
|
||||
|
||||
**Its diff is time-windowed, not session-attributed:** a finding means a
|
||||
path changed while the session ran, not that the session changed it. A
|
||||
concurrent session, another host process, or your own editing all land in
|
||||
the same diff, reported identically.
|
||||
- **Approving and ignoring are different decisions, and the difference is
|
||||
what you are promising about next time.** Approving records the exact
|
||||
content: `safe <id> <sha>`, so this file with these bytes is fine and any
|
||||
@@ -246,18 +259,22 @@ hands to whichever session starts against it. See
|
||||
[a] approve this exact content [i] ignore this key from now on [s] skip (ask again next time) [q] quit
|
||||
```
|
||||
|
||||
Skipping is the third option on purpose: a finding you have not decided
|
||||
about is asked again next session, which is the right default for anything
|
||||
you are unsure of. A WARNING-tier git config change is curatable the same
|
||||
way, which matters for keys some tools rewrite on every run: without a
|
||||
decision recorded against one, it re-fires every session and pushes real
|
||||
findings out of the capped list.
|
||||
A finding whose file has since been deleted is **cleared** rather than
|
||||
approved: there is no content left to pin an approval to, and a surface
|
||||
that does not exist does not run. Nothing is recorded, so if it comes back
|
||||
it comes back as a new finding. Refusing instead - the old behaviour - left
|
||||
the report unresolvable, and because `ack` opens CRITICAL reports first it
|
||||
hid every other report behind that one.
|
||||
|
||||
Severity is not negotiable by either verb: an exec-capable git config key or
|
||||
a shell alias is classified CRITICAL whatever the baseline says, and an
|
||||
`ignore` entry is refused against a CRITICAL surface when the baseline is
|
||||
read, not just when it is written. So neither decision can quietly downgrade
|
||||
something that matters.
|
||||
Skipping asks again next session, the right default when you are unsure.
|
||||
It matters for keys some tools rewrite on every run: with no decision
|
||||
recorded, one re-fires every session and pushes real findings out of the
|
||||
capped list.
|
||||
|
||||
Severity is not negotiable by either verb. An exec-capable key or shell
|
||||
alias is CRITICAL whatever the baseline says, and an `ignore` entry is
|
||||
refused against a CRITICAL surface when the baseline is read, not only when
|
||||
written - so neither decision can quietly downgrade something that matters.
|
||||
|
||||
- `dejima-audit ack` shows what it is asking you to approve. For a surface
|
||||
that changed during the session it prints a unified diff of the
|
||||
@@ -271,21 +288,19 @@ hands to whichever session starts against it. See
|
||||
live on disk); a removed one stores nothing at all, because a deletion
|
||||
diff *is* the whole former file.
|
||||
|
||||
Both the staging directory and the retained diffs live under
|
||||
`$AUDIT_DIR` (`~/.local/share/dejima/audit` by default), never under
|
||||
`/tmp`. That is the boundary the whole scheme rests on:
|
||||
`docker-compose.yml` mounts nothing covering it, so the container cannot
|
||||
read the retained content of earlier runs - `test/static.bats` pins that
|
||||
as a regression guard. If the store is configured *inside* the mounted
|
||||
workspace it becomes readable and deletable from within the sandbox, so
|
||||
in that case no content is retained at all - only a note saying why. A
|
||||
session killed with `SIGKILL` (no EXIT trap runs, so no `compare`)
|
||||
leaves its staging directory behind; the next run against that
|
||||
workspace reaps it, identifying dead owners by pid and process start
|
||||
time recorded in an `.owner` file, so a concurrent session against the
|
||||
same workspace is never mistaken for dead and disturbed. A directory
|
||||
whose owner record is missing or unreadable falls back to a 24-hour age
|
||||
check instead of guessing liveness.
|
||||
Staging and retained diffs live under `$AUDIT_DIR`
|
||||
(`~/.local/share/dejima/audit`), never `/tmp`. That is the boundary the
|
||||
scheme rests on: nothing in `docker-compose.yml` covers it, so the
|
||||
container cannot read earlier runs' retained content, and `static.bats`
|
||||
pins that. A store configured *inside* the workspace is readable and
|
||||
deletable from the sandbox, so nothing is retained in that case - only a
|
||||
note saying why.
|
||||
|
||||
A `SIGKILL`ed session runs no EXIT trap and leaves its staging directory;
|
||||
the next run against that workspace reaps it, identifying dead owners by
|
||||
pid and process start time from an `.owner` file so a concurrent session is
|
||||
never mistaken for dead. A missing or unreadable owner record falls back to
|
||||
a 24-hour age check rather than guessing.
|
||||
|
||||
Content is withheld from the store in several further cases, and in
|
||||
every one `ack` prints the reason in place of the diff - so an absent
|
||||
@@ -300,73 +315,59 @@ hands to whichever session starts against it. See
|
||||
existed before the session but was too large to have been staged (over
|
||||
1 MiB) withholds with "no pre-session copy", not a false claim that it
|
||||
is new. The *current* side is bounded by that same 1 MiB cap, and - like
|
||||
the staged side - the size is checked *before* any copy is taken: a file
|
||||
the session grew past it withholds with "change too large to retain"
|
||||
rather than being copied through `$TMPDIR` first. That ordering is the
|
||||
point, not the cap: `compare` runs from the exit trap on every session
|
||||
exit and the size is the session's own choice, so copying first would
|
||||
let a session turn "I filled my own workspace" into that many bytes
|
||||
written to `$TMPDIR` - frequently a tmpfs, i.e. host RAM - at exit, and
|
||||
into a whole copy of the file left sitting there if that exit was a
|
||||
`SIGKILL`. A generated diff is withheld if it is binary, over 200 lines
|
||||
the staged side - checked *before* any copy is taken: a file the session
|
||||
grew past it withholds with "change too large to retain" rather than
|
||||
being copied through `$TMPDIR` first. The ordering is the point, not the
|
||||
cap. `compare` runs from the exit trap on every exit and the size is the
|
||||
session's own choice, so copying first would turn "I filled my own
|
||||
workspace" into that many bytes written to `$TMPDIR` - often a tmpfs, so
|
||||
host RAM - and into a whole copy left there if the exit was a `SIGKILL`.
|
||||
A generated diff is withheld if it is binary, over 200 lines
|
||||
or 64 KiB (and is bounded to that as it is generated, for the same
|
||||
reason), or matches a best-effort credential screen - vendor prefixes
|
||||
including `sk-ant-`, `sk-proj-`, `ghp_`, `gho_`, `github_pat_`,
|
||||
`glpat-`, Slack's `xox[baprs]-`, `AIza`, `AKIA`, `_authToken`,
|
||||
`Authorization: Bearer`, and PEM private-key headers, matched
|
||||
case-insensitively (deliberately: the one high-value surface this is
|
||||
the only net for, `.claude/settings.json`'s `env` block, conventionally
|
||||
holds its keys upper-case). That screen is still explicitly best-effort
|
||||
and has real false negatives - the denylist above is the reliable half
|
||||
of the defence, this is the second net. A git config key holding more
|
||||
than one value on either side of the session is not compared at all:
|
||||
git resolves such a key to its *last* value in *file* order, and the
|
||||
audit does not resolve it - it parses the config files and sorts, so
|
||||
lexicographically-last is not the value git will run, and the
|
||||
pre-session side is every value the key had rather than one. Comparing
|
||||
anyway produced a diff over values git does not use: with two
|
||||
pre-session values it read as a deletion that never happened, while the
|
||||
value the session actually appended - the one git now executes -
|
||||
appeared nowhere in it. That case withholds with "this key has multiple
|
||||
values and git uses the last", and must be compared by hand;
|
||||
`include.path` is legitimately multi-valued, so this is an ordinary
|
||||
shape, not only a planted one. A generated diff can also still come out
|
||||
empty - before and after compare equal even though the finding was
|
||||
reported as a change - when the config file's *text* changed while the
|
||||
parsed value did not, e.g. a value that was re-quoted; that case
|
||||
withholds too, saying plainly that the comparison came out empty rather
|
||||
than silently showing nothing, since an empty diff for a reported
|
||||
change would otherwise read as a bug.
|
||||
case-insensitively, since `.claude/settings.json`'s `env` block - the one
|
||||
high-value surface this is the only net for - conventionally holds its keys
|
||||
upper-case. The screen is best-effort with real false negatives: the
|
||||
denylist above is the reliable half.
|
||||
|
||||
A per-report **retention budget** (`RETAIN_BUDGET`, 1 MiB) also bounds
|
||||
the total diff bytes one report may keep. Once it is spent, remaining
|
||||
findings in that report withhold with a note that the budget ran out,
|
||||
and both the report and the exit-time stderr summary add an alarm line:
|
||||
that many changed host-persistence surfaces in one session is treated
|
||||
as a signal in its own right, not merely a limit worth raising - an
|
||||
ordinary session changes few enough of these surfaces that the budget
|
||||
is never in practice a constraint. `diffs = off` in the config's
|
||||
`[audit]` section disables retention (and staging) entirely.
|
||||
A git config key with more than one value on either side is not compared.
|
||||
Git resolves such a key to its last value in FILE order; the audit parses
|
||||
and sorts, so lexicographically-last is not what git runs, and the
|
||||
pre-session side is every value the key had. Comparing anyway produced a
|
||||
diff over values git does not use - two pre-session values read as a
|
||||
deletion that never happened, while the appended value git now executes
|
||||
appeared nowhere. It withholds instead, and must be compared by hand.
|
||||
`include.path` is legitimately multi-valued, so this is an ordinary shape.
|
||||
|
||||
Two residual limits sit on top of the credential screen's false
|
||||
negatives above. The prompt shows at most 40 lines of a diff or
|
||||
of new content before truncating, naming the file so you can read the
|
||||
rest - a large planted change is truncated exactly where you would most
|
||||
want it whole. And the `SIGKILL` case above is a real exposure window,
|
||||
not just a cleanup mechanic: until the next run against that workspace
|
||||
reaps it, the staging directory sits on disk holding whole pre-session
|
||||
file copies (not diffs) of every monitored surface, for as long as the
|
||||
workspace goes unused.
|
||||
- Report findings name a path or config key that a session may itself have
|
||||
chosen, so those names reach your terminal as untrusted text. One that
|
||||
contains an ASCII control character, a C1 control, or a Unicode bidi
|
||||
override - which between them can scroll, blank, or visually reorder what
|
||||
you read, and can split a name across the audit's own records - is
|
||||
written hex-encoded behind a `{hex}` marker instead, and decodes with
|
||||
`printf '%b' "$(sed 's/../\\x&/g' <<< HEX)"`. Ordinary names are printed
|
||||
as-is, so the marker appearing at all is itself worth a second look.
|
||||
The encoded form is what `dejima-audit ack` records in the baseline, so
|
||||
what you acknowledge is the file that is actually on disk rather than a
|
||||
A diff can also come out empty when the config file's TEXT changed but the
|
||||
parsed value did not, e.g. a re-quoted value. That withholds too, saying so
|
||||
rather than showing nothing - an empty diff for a reported change reads as
|
||||
a bug.
|
||||
|
||||
A per-report **retention budget** (`RETAIN_BUDGET`, 1 MiB) bounds the total
|
||||
diff bytes one report keeps. Once spent, the rest withhold and both the
|
||||
report and the stderr summary add an alarm line: that many changed
|
||||
host-persistence surfaces in one session is a signal in itself, not a limit
|
||||
worth raising. `diffs = off` in `[audit]` disables retention and staging.
|
||||
|
||||
Two residual limits on top of the screen's false negatives. The prompt
|
||||
truncates at 40 lines, naming the file so you can read the rest - a large
|
||||
planted change is cut exactly where you would want it whole. And the
|
||||
`SIGKILL` case is an exposure window, not just a cleanup mechanic: until
|
||||
the next run reaps it, the staging directory holds whole pre-session file
|
||||
copies of every monitored surface, for as long as the workspace goes
|
||||
unused.
|
||||
- Findings name a path or config key the session may have chosen, so they
|
||||
reach your terminal as untrusted text. One containing an ASCII or C1
|
||||
control or a Unicode bidi override - which can scroll, blank or reorder
|
||||
what you read, and split a name across the audit's own records - is written
|
||||
hex-encoded behind a `{hex}` marker and decodes with
|
||||
`printf '%b' "$(sed 's/../\\x&/g' <<< HEX)"`. Ordinary names print as-is,
|
||||
so the marker appearing is itself worth a look. The encoded form is what
|
||||
`ack` records, so what you acknowledge is the file on disk rather than a
|
||||
readable approximation of its name.
|
||||
- `~/.claude/projects` (session transcripts and per-project memory) is
|
||||
**not** mounted as a tree. A session gets the workspace's own slug
|
||||
@@ -391,20 +392,44 @@ hands to whichever session starts against it. See
|
||||
first-run case it already handles, and it is why an absent sibling does
|
||||
not risk the hang that a blocked write to `~/.claude.json` caused.
|
||||
|
||||
Cross-project writes are still **detected** as well. The exit-time audit
|
||||
runs host-side over the whole tree, so narrowing the mount does not
|
||||
narrow it; it now covers what the mount cannot reach, which is
|
||||
concurrent *host* sessions. Residual: the workspace's own transcripts
|
||||
and memory stay fully writable throughout, since Claude Code needs that
|
||||
for host-side `claude --resume` to work; and detection keys off each
|
||||
slug's max file mtime, so an edit to a file that isn't already that
|
||||
project's newest doesn't advance the max and goes unflagged. The
|
||||
time-window caveat above applies most sharply here, because on the host
|
||||
`~/.claude/projects` is a single
|
||||
directory shared by *every* Claude Code session, not scoped to the one
|
||||
being audited - so an entirely innocent concurrent session working on
|
||||
some unrelated project can still produce a `CROSS-PROJECT` finding that
|
||||
reads like poisoning and isn't.
|
||||
Residual: the workspace's own transcripts and memory stay fully writable
|
||||
throughout, since Claude Code needs that for host-side `claude --resume`.
|
||||
|
||||
There is no longer a `CROSS-PROJECT` finding for this. It existed when the
|
||||
whole tree was mounted and a session really could write another project's
|
||||
slug. Now that it cannot, the only writes the audit could still see there
|
||||
are other sessions doing their own work - and on a host running several at
|
||||
once it reported all of them, on every report. Detection that cannot
|
||||
observe what it was built for, and fires constantly on what it was not,
|
||||
trains an operator to skim. The mount is the control, and `static.bats`
|
||||
pins it.
|
||||
- `~/.claude/file-history` (Claude Code's edit-undo record) is **not**
|
||||
mounted as a tree either, and the reason is sharper than for `projects/`.
|
||||
Each `<session>/<hash>@v1` is a **verbatim copy of an edited file**, so the
|
||||
tree is other projects' source rather than a record about it. Read access
|
||||
alone is the leak; it was read-write on top of that, and tampering with
|
||||
another project's undo record puts chosen content into a later restore.
|
||||
|
||||
It is keyed by session id, so unlike `projects/` there is no slug in its
|
||||
own paths to scope it by. The transcript names supply it: `projects/<slug>/
|
||||
<id>.jsonl` and `file-history/<id>` name the same session, so the slugs the
|
||||
workspace owns select the session ids it owns
|
||||
(`workspace_session_ids` in `bin/dejima-slug-lib.sh`). A directory whose
|
||||
session has no transcript belongs to no project dejima can identify, and is
|
||||
left out.
|
||||
|
||||
The session about to start has an id nothing can know in advance, so its
|
||||
own directory is created container-local and does not survive the run.
|
||||
That costs undo history for that session if it is later resumed on the
|
||||
host, and it is the reason this cannot be scoped as completely as
|
||||
`projects/`, where the workspace's own slug is known at launch.
|
||||
- Epitaphs - the summary a session leaves when its context is compacted away -
|
||||
are kept host-side in `[epitaph] dir`, and that directory is never mounted. A session can neither read what earlier
|
||||
sessions concluded nor edit what this one is about to record, which is the
|
||||
same arrangement as the audit store and for the same reason. The transcript
|
||||
they are extracted from *is* session-writable, so a summary records what was
|
||||
there rather than vouching for it - and nothing loads one back into a later
|
||||
session, deliberately: that would be the channel #104 closed, rebuilt.
|
||||
- Allowing egress to a domain at all creates a channel for exfiltrating data
|
||||
through *that service's own request surface* (query strings, lookups,
|
||||
etc.), not just to a literal attacker-controlled destination. Domain-level
|
||||
@@ -418,51 +443,45 @@ hands to whichever session starts against it. See
|
||||
similar. It shares the host kernel, so a container-escape vulnerability
|
||||
would defeat every other control described here at once.
|
||||
- Most of what this document asserts is re-checked by the `test/` suite on
|
||||
every change - over 300 bats tests across six files, and `runtime.bats`
|
||||
builds the image and asserts against a real running container rather than
|
||||
a mock: IPv6 blocked, a non-allowlisted domain refused by the DNS proxy
|
||||
and a non-allowlisted destination unreachable, `node` unable to re-run
|
||||
`init-firewall.sh` to widen the allowlist, no working `sudo`. Coverage is
|
||||
not the same as completeness, though. The reasoning in this document is
|
||||
judgement rather than assertions, nothing tests it, and a property nobody
|
||||
thought to write a test for can still regress quietly. A missing `ipset`
|
||||
dedup flag broke firewall startup entirely - twice, on two different
|
||||
ipsets - before anyone caught it; there is a test asserting every
|
||||
`ipset add` is duplicate-safe now, which is the pattern to expect. Each
|
||||
of these tests exists because something was got wrong first.
|
||||
every change, and `runtime.bats` asserts against a real running container
|
||||
rather than a mock: IPv6 blocked, a non-allowlisted domain refused and a
|
||||
non-allowlisted destination unreachable, `node` unable to re-run
|
||||
`init-firewall.sh`, no working `sudo`. Coverage is not completeness: the
|
||||
reasoning here is judgement, nothing tests it, and a property nobody wrote
|
||||
a test for can regress quietly. A missing `ipset` dedup flag broke firewall
|
||||
startup twice before anyone caught it; there is a test for that now, which
|
||||
is the pattern to expect - each of these exists because something was got
|
||||
wrong first.
|
||||
- The Anthropic OAuth credential (`~/.claude/.credentials.json`) never
|
||||
enters the container. It isn't mounted: `~/.claude` is not shared as a
|
||||
whole (see the mount inventory below), so there is no path from
|
||||
inside the container to that file. `dejima` starts a host-side proxy
|
||||
(`bin/dejima-anthropic-proxy`) on a per-session unix socket and mounts a
|
||||
fake credential in place of the real one - the two secrets are
|
||||
placeholders, while `scopes`/`subscriptionType`/`rateLimitTier`/`clientId`
|
||||
are copied from the real file so the session's own UI stays accurate. Its
|
||||
expiry is set a year out rather than mirrored, deliberately: a near-term
|
||||
one would make Claude Code refresh on its own, with a refresh token that
|
||||
isn't real, and log the session out. Inside the container,
|
||||
fake credential in place of the real one - the secrets are placeholders,
|
||||
while `scopes`/`subscriptionType`/`rateLimitTier`/`clientId` are copied so
|
||||
the session's UI stays accurate. Its expiry is a year out rather than
|
||||
mirrored: a near-term one would make Claude Code refresh on its own with a
|
||||
refresh token that isn't real, logging the session out. Inside the
|
||||
container,
|
||||
`entrypoint.sh` points `api.anthropic.com` at a loopback TLS terminator
|
||||
(`anthropic-proxy-forwarder.js`) whose certificate is generated at startup
|
||||
and trusted only there, and which pipes raw bytes into the proxy's socket.
|
||||
The proxy injects the real access token per request and owns OAuth refresh
|
||||
- rotated tokens are written back to the real host file atomically, so
|
||||
host-side sessions stay logged in. Claude Code itself is configured
|
||||
exactly as it would be on the host: no `ANTHROPIC_BASE_URL`, no
|
||||
`ANTHROPIC_AUTH_TOKEN`, nothing that marks the session as unusual to the
|
||||
client. The proxy then forwards the client's header block verbatim -
|
||||
same names, same casing, same order, repeats intact, and no
|
||||
`Accept-Encoding` it didn't send - changing only the `Authorization`
|
||||
value, and `anthropic-beta` if the OAuth value isn't already there. Left
|
||||
to itself, Python's `http.client` would have rewritten all of that into
|
||||
its own house style and moved the fingerprint from the client to the
|
||||
wire. What it cannot disguise is that the connection is made by CPython's
|
||||
`ssl` rather than by Node: the TLS handshake (JA3/JA4) and the absence of
|
||||
connection reuse still distinguish it at that layer. What remains is
|
||||
session-scoped: a
|
||||
compromised session can still *use* the model API as you while it runs
|
||||
(that's inherent to the sandbox working at all), but it can no longer read
|
||||
a durable credential and exfiltrate it for offline use via the other
|
||||
allowed egress destinations (npm, GitHub, your Gitea host). Caveats:
|
||||
exactly as on the host: no `ANTHROPIC_BASE_URL`, no
|
||||
`ANTHROPIC_AUTH_TOKEN`, nothing marking the session as unusual. The proxy
|
||||
forwards the client's header block verbatim - same names, casing, order and
|
||||
repeats, and no `Accept-Encoding` it didn't send - changing only
|
||||
`Authorization`, and `anthropic-beta` if the OAuth value isn't there. Left
|
||||
alone, Python's `http.client` would rewrite all of that into its own style
|
||||
and move the fingerprint from the client to the wire. It cannot disguise
|
||||
that the connection is CPython's `ssl` rather than Node: the TLS handshake
|
||||
(JA3/JA4) and the absence of connection reuse still distinguish it.
|
||||
What remains is session-scoped: a compromised session can still *use* the
|
||||
API as you while it runs, which is inherent to the sandbox working at all,
|
||||
but it cannot read a durable credential and exfiltrate it for offline use.
|
||||
Caveats:
|
||||
`/login` inside the container doesn't work while the proxy is on; the
|
||||
refresh flow mirrors a Claude Code internal and may break if upstream
|
||||
changes it, in which case the proxy fails loudly (launch abort, 502s on
|
||||
@@ -516,13 +535,21 @@ hands to whichever session starts against it. See
|
||||
are enumerated in `docker-compose.yml`, in three classes, and anything not
|
||||
listed is container-local: writable during the session and gone with the
|
||||
container.
|
||||
- **Read-write, because it has to survive the session**: `projects/`,
|
||||
`file-history/`, `backups/`, `tasks/`, `plans/`, `sessions/`. All
|
||||
directories, which is what makes them safe (see the detachment note
|
||||
below).
|
||||
- **Read-write, because it has to survive the session**: `backups/`,
|
||||
`tasks/`, `plans/`, `sessions/`, plus the entries of `projects/` and
|
||||
`file-history/` that belong to this workspace. All directories, which is
|
||||
what makes them safe (see the detachment note below). The two trees are
|
||||
not mounted whole: see the `projects/` note further down, and the
|
||||
`file-history/` note below it.
|
||||
- **Read-only**: `plugins/`, `hooks/`, `skills/`, `commands/`, `agents/`,
|
||||
`output-styles/`, plus the symlink-target directories `dejima`
|
||||
discovers at launch. Hooks execute automatically with no separate
|
||||
discovers at launch. Those targets are discovered rather than
|
||||
configured, so they are checked against the approval store and the
|
||||
audit stores and refused if they overlap either - a hook symlinked into
|
||||
the approval store used to mount it, read-only and silently, and
|
||||
read-only is no protection when the content is every project's
|
||||
approvals. The same check now covers `[mounts] readonly`, which had
|
||||
none at all. Hooks execute automatically with no separate
|
||||
approval step, skills can ship executable scripts, and
|
||||
commands/agents/output-styles are prompt text loaded into a later host
|
||||
session, so without this a compromised session could plant a backdoored
|
||||
@@ -707,6 +734,18 @@ hands to whichever session starts against it. See
|
||||
changes takes effect the *next* time `dejima` itself runs,
|
||||
not the current one.
|
||||
|
||||
The report splits on whether git can see the change. tcb_snapshot only
|
||||
populates when the workspace IS dejima's own repo, so an ordinary change
|
||||
there is already in the working tree you are about to read in `git status`
|
||||
and in the pull request - reporting each one as a CRITICAL to acknowledge
|
||||
duplicated a stronger control and made developing dejima itself unusable.
|
||||
Those are counted in one INFO line instead.
|
||||
|
||||
A change git *cannot* see stays a CRITICAL finding, because nothing else
|
||||
would catch it: a file in `.gitignore` or `.git/info/exclude`, or one
|
||||
marked `assume-unchanged` or `skip-worktree`, is live code that runs on the
|
||||
next launch and appears nowhere in `git status`.
|
||||
|
||||
### Declaration files are session-writable
|
||||
|
||||
A project's `.dejima/` declaration files sit inside the project mount, which
|
||||
@@ -970,6 +1009,32 @@ want GitHub push and PRs as a standing choice instead, set
|
||||
`ssh.github.com:22`, but persistently rather than per launch, and uses its
|
||||
own `[forge.github] push_token_file` rather than a loaded SSH key.
|
||||
|
||||
**Why the default is off.** The principle is narrower than CI and holds
|
||||
whether or not CI exists anywhere: *a session that is not being used to push
|
||||
to a forge should not be able to push to it.* Your configured forge is the
|
||||
one you chose to push to. GitHub is where everything else lives - every
|
||||
dependency a session reads, every repository it clones to understand an
|
||||
error - so that is where the rule bites by default.
|
||||
|
||||
The case is an eager agent, not a compromised one: it finds a real bug in an
|
||||
unrelated open-source project and pushes a fix branch to somebody else's
|
||||
repository. That this is not an attack does not make it harmless. Maintainers
|
||||
are already triaging a flood of unsolicited AI-generated pull requests, and
|
||||
one more arriving in your name, unreviewed, is a cost imposed on someone who
|
||||
did not ask for it. The block exists so that it cannot happen, not so that
|
||||
you get to approve it first.
|
||||
|
||||
[Don't point it at repositories you don't own](responsible-use.md#dont-point-it-at-repositories-you-dont-own)
|
||||
is the rule this enforces, and gives the fork-and-review workflow to use
|
||||
instead: work against your own fork, review the result, and open the upstream
|
||||
pull request by hand if you still want to.
|
||||
|
||||
Closing the CI chain follows from this - a push is code execution on whatever
|
||||
runners that repository can reach, per the section above - but it is not the
|
||||
reason for it. Reading it the other way round gets the order backwards, and
|
||||
makes the default look like a CI workaround to be turned off once CI is
|
||||
handled.
|
||||
|
||||
The sandbox can always read from GitHub (clone, fetch, `gh` API calls). By
|
||||
**default** it can never push there: enforced at the network layer, not
|
||||
just by `git-push-guard.sh`. GitHub's IP ranges are only allowlisted on port
|
||||
|
||||
@@ -1,23 +1,19 @@
|
||||
#!/bin/bash
|
||||
# Read Gitea Actions runs and job logs from a shell - the CI-side counterpart
|
||||
# to gitea-pr and gitea-issue, and deliberately their twin rather than their
|
||||
# sibling: all three are copied into the sandbox image as single
|
||||
# self-contained files, and gitea-pr also lives in a dotfiles checkout, so a
|
||||
# shared library would be a third thing to keep in step across three places.
|
||||
# The duplicated config/token/curl preamble is pinned in all three by
|
||||
# test/static.bats instead.
|
||||
# to gitea-pr and gitea-issue. Self-contained like both of them rather than
|
||||
# sharing a library: all three are copied into the image as single files, so
|
||||
# a library would be another thing to keep in step. test/static.bats pins the
|
||||
# duplicated config/token/curl preamble instead.
|
||||
#
|
||||
# Its reason to exist is a sandboxed agent's: CI is where the tests that
|
||||
# cannot run in the sandbox actually run, and without this the only way to
|
||||
# see why one failed is for the operator to paste the log in by hand.
|
||||
# It exists for the agent: CI is where the tests the sandbox cannot run
|
||||
# actually run, and without this the only way to see a failure is for the
|
||||
# operator to paste the log in.
|
||||
#
|
||||
# Nothing here touches /repos/{owner}/{repo}/actions/runs, or the job and
|
||||
# log endpoints reached through it, however natural they look. Gitea gates
|
||||
# that group behind reqOwner(): a token belonging to a dedicated non-owner
|
||||
# account - which is what a sandbox should be given - is refused with "user
|
||||
# should be the owner of the repo", and no scope grants ownership, so
|
||||
# widening the token cannot fix it. The three sources used instead need only
|
||||
# read access to the repo:
|
||||
# Nothing here touches /repos/{owner}/{repo}/actions/runs or the endpoints
|
||||
# reached through it, however natural they look. Gitea gates that group
|
||||
# behind reqOwner(), so a dedicated non-owner account - what a sandbox should
|
||||
# have - is refused, and no scope grants ownership. The three sources used
|
||||
# instead need only read access:
|
||||
#
|
||||
# actions/tasks one record per job, carrying its run number,
|
||||
# head_sha, event and result
|
||||
|
||||
+15
-22
@@ -1,30 +1,23 @@
|
||||
#!/usr/bin/env bash
|
||||
# PreToolUse hook: inside dejima, block a merge command matching
|
||||
# DEJIMA_MERGE_BLOCK - a literal command prefix (the forge's PR command
|
||||
# followed by its merge subcommand) resolved host-side by bin/dejima from
|
||||
# the configured forge and workflow
|
||||
# (see dejima-provider-lib.sh), not hardcoded to any one forge. The sandbox
|
||||
# workflow is feature branch -> open a PR/MR -> a human merges; what lands
|
||||
# on the default branch is an operator decision, exactly as it already is
|
||||
# for `git push` (see git-push-guard.sh, whose no-direct-push-to-a-blocked-
|
||||
# branch rule this is the API-side counterpart to - a merge reaches the
|
||||
# branch without a push, so that guard never sees it).
|
||||
# DEJIMA_MERGE_BLOCK - a literal command prefix resolved host-side by
|
||||
# bin/dejima from the configured forge and workflow, never hardcoded to one
|
||||
# forge. What lands on the default branch is an operator decision, as it
|
||||
# already is for `git push`; this is the API-side counterpart to
|
||||
# git-push-guard.sh, since a merge reaches the branch without a push.
|
||||
#
|
||||
# Deliberately not conditional on intent. A hook is handed a command, not a
|
||||
# conversation, so it cannot tell an instructed merge from an unprompted one;
|
||||
# the only honest place to draw the line is the context, and the context that
|
||||
# needs the line is this one - headless unattended runs under
|
||||
# --dangerously-skip-permissions, where nobody is watching the turn happen.
|
||||
# On the host the hook is a no-op and an operator can still ask for a merge.
|
||||
# Not conditional on intent: a hook is handed a command, not a conversation,
|
||||
# so it cannot tell an instructed merge from an unprompted one. The line is
|
||||
# drawn by context instead, and the context that needs it is this one -
|
||||
# unattended runs under --dangerously-skip-permissions. On the host it is a
|
||||
# no-op.
|
||||
#
|
||||
# Like its sibling this is a fast-fail guard against an overeager agent, not
|
||||
# a hard security boundary: a sandbox session has full shell access and the
|
||||
# forge token in its environment, so it could call the merge endpoint with
|
||||
# curl directly. It stops the mistake, not the attack.
|
||||
# A fast-fail guard against an overeager agent, not a boundary: a session has
|
||||
# shell access and the forge token, so it could call the merge endpoint with
|
||||
# curl. It stops the mistake, not the attack.
|
||||
#
|
||||
# DEJIMA_MERGE_BLOCK fails OPEN: empty means no command is blocked, so the
|
||||
# hook is inert rather than conditionally installed - a workflow with no
|
||||
# merge-block policy (or a forge with nothing to block) genuinely has none.
|
||||
# Fails OPEN - empty means nothing is blocked, so a workflow with no
|
||||
# merge-block policy genuinely has none rather than the hook guessing.
|
||||
|
||||
if [ -z "${DEJIMA:-}" ]; then
|
||||
exit 0
|
||||
|
||||
+14
-19
@@ -66,29 +66,24 @@ fi
|
||||
requirements=""
|
||||
decl_dir="$project_dir/.dejima"
|
||||
|
||||
# Everything below reads attacker-controllable text on its way into an
|
||||
# agent's context window, so all three costs it can impose are bounded:
|
||||
# Everything below reads attacker-controllable text on its way into a context
|
||||
# window, so each cost it can impose is bounded:
|
||||
#
|
||||
# MAX_DECLARED entries considered - the same cap read_declared() applies
|
||||
# host-side, so this reports on the same window
|
||||
# `dejima-project approve` will act on.
|
||||
# host-side, so this reports the window `approve` acts on.
|
||||
# MAX_ENTRY_LEN characters per entry.
|
||||
# MAX_LISTED entries named; the rest are counted only. Without it, 200
|
||||
# entries of 200 chars is 40 KB of attacker-chosen tokens per
|
||||
# section, and a large enough $requirements pushed the jq call
|
||||
# past MAX_ARG_STRLEN and emitted nothing - costing every
|
||||
# later session its whole briefing.
|
||||
#
|
||||
# Within that window the report is a strict subset of what `approve` accepts,
|
||||
# never a superset: it never names something approve would reject, but it can
|
||||
# stay silent about something approve would take - a dotless domain (the
|
||||
# regex below requires a label separator, deliberately, so a bare
|
||||
# IGNORE-PREVIOUS-INSTRUCTIONS can't be echoed into the context) and a valid
|
||||
# domain longer than MAX_ENTRY_LEN are both approvable host-side and both
|
||||
# silently skipped here. Fail-closed in the direction that matters: the worst
|
||||
# case is a missing line of advice, not an unapproved entry presented as fine.
|
||||
# MAX_LISTED entries actually named in the report; the rest are only
|
||||
# counted. Without it 200 entries of 200 chars would be 40 KB
|
||||
# of attacker-chosen tokens per section, and a big enough
|
||||
# $requirements used to push the jq call at the end past
|
||||
# Linux's 128 KiB MAX_ARG_STRLEN and emit nothing at all -
|
||||
# silently costing every later session in the project its
|
||||
# entire briefing.
|
||||
# The report is a strict subset of what `approve` accepts, never a superset:
|
||||
# it can stay silent about something approvable (a dotless domain, which the
|
||||
# regex refuses so a bare IGNORE-PREVIOUS-INSTRUCTIONS cannot be echoed into
|
||||
# context; or one longer than MAX_ENTRY_LEN) but never names something
|
||||
# `approve` would reject. The worst case is a missing line of advice, not an
|
||||
# unapproved entry presented as fine.
|
||||
MAX_DECLARED=200
|
||||
MAX_ENTRY_LEN=200
|
||||
MAX_LISTED=20
|
||||
|
||||
+5
-3
@@ -182,6 +182,7 @@ link_bin dejima-project
|
||||
# the startup warning about unacknowledged CRITICAL findings tells the
|
||||
# operator to run `dejima-audit ack`, which is only true once it is on PATH.
|
||||
link_bin dejima-audit
|
||||
link_bin dejima-epitaph
|
||||
|
||||
# Remote access (opt-in, see docs/remote-access.md). All three are inert
|
||||
# without [remote-access] enabled = on - they refuse to run at all - so
|
||||
@@ -235,9 +236,9 @@ else
|
||||
printf '# dejima configuration. See README.md.\n'
|
||||
printf '# Every key below is shown at its default; uncomment to change one.\n'
|
||||
printf '#\n'
|
||||
printf '# [core] forge and [core] workflow are the exception: they are written\n'
|
||||
printf '# out uncommented, so this file states what it selects rather than\n'
|
||||
printf '# inheriting whatever a future release defaults to.\n'
|
||||
printf '# [core] harness, forge and workflow are written out uncommented, so\n'
|
||||
printf '# this file states what it selects rather than inheriting a future\n'
|
||||
printf '# default. harness has none at all - dejima refuses to start unset.\n'
|
||||
printf '#\n'
|
||||
printf '# Those two decide whether this sandbox pushes at all. The shipped\n'
|
||||
printf '# default is forge = none with workflow = local-only: the agent\n'
|
||||
@@ -261,6 +262,7 @@ else
|
||||
case "$_key" in
|
||||
forge.gitea.ssh_host) _seed="${GITEA_SSH_HOST:-}" ;;
|
||||
forge.gitea.api) _seed="${GITEA_API:-}" ;;
|
||||
core.harness) _seed="claude-code" ;;
|
||||
core.forge) _seed="$_starter_forge" ;;
|
||||
core.workflow) _seed="$_starter_workflow" ;;
|
||||
core.command) _seed="${SANDBOX_CMD:-}" ;;
|
||||
|
||||
+136
-98
@@ -143,16 +143,24 @@ cmp_() { "$AUDIT" compare "$WS" "$STATE" 2>&1; }
|
||||
[ "$count" -eq 1 ]
|
||||
}
|
||||
|
||||
@test "flags a cross-project write under ~/.claude/projects" {
|
||||
# The cross-project finding is gone, and these are its replacement: writes
|
||||
# under ~/.claude/projects must produce NOTHING now, whichever slug they land
|
||||
# in. It reported a poisoning route a session could once take - the whole
|
||||
# tree was mounted - and since #104 a session gets its own slugs only, so
|
||||
# every remaining write it could see belongs to some other session doing its
|
||||
# own work. With several sessions running it reported all of them on every
|
||||
# report.
|
||||
@test "a write to another project's slug is not reported" {
|
||||
mkdir -p "$PROJDIR/-some-other-project"
|
||||
echo x > "$PROJDIR/-some-other-project/file1"
|
||||
snap
|
||||
echo y > "$PROJDIR/-some-other-project/file2"; touch -d '+1 hour' "$PROJDIR/-some-other-project/file2"
|
||||
run cmp_
|
||||
[[ "$output" == *"CROSS-PROJECT history/memory write: -some-other-project"* ]]
|
||||
[[ "$output" != *"CROSS-PROJECT"* ]]
|
||||
[[ "$output" != *"-some-other-project"* ]]
|
||||
}
|
||||
|
||||
@test "does not flag the current project's own projects slug" {
|
||||
@test "a write to this project's own slug is not reported either" {
|
||||
mkdir -p "$PROJDIR/$CURRENT_SLUG"
|
||||
echo x > "$PROJDIR/$CURRENT_SLUG/file1"
|
||||
snap
|
||||
@@ -161,34 +169,6 @@ cmp_() { "$AUDIT" compare "$WS" "$STATE" 2>&1; }
|
||||
[[ "$output" != *"CROSS-PROJECT"* ]]
|
||||
}
|
||||
|
||||
@test "does not flag a worktree child's projects slug" {
|
||||
# A real worktree, not just a directory named like one. "Self" is derived
|
||||
# from live worktree paths now, so the fixture has to produce one.
|
||||
git -C "$WS" commit -q --allow-empty -m base
|
||||
git -C "$WS" worktree add -q "$WS/.worktrees/featurex" -b featurex
|
||||
mkdir -p "$PROJDIR/${CURRENT_SLUG}--worktrees-featurex"
|
||||
echo x > "$PROJDIR/${CURRENT_SLUG}--worktrees-featurex/file1"
|
||||
snap
|
||||
echo y > "$PROJDIR/${CURRENT_SLUG}--worktrees-featurex/file2"; touch -d '+1 hour' "$PROJDIR/${CURRENT_SLUG}--worktrees-featurex/file2"
|
||||
run cmp_
|
||||
[[ "$output" != *"CROSS-PROJECT"* ]]
|
||||
}
|
||||
|
||||
# The regression guard for #104's sibling case. The old test was a
|
||||
# "$CURRENT_SLUG"-* glob, which this slug matches: a project checked out
|
||||
# beside this one, whose directory name merely extends it, was silently
|
||||
# exempt from cross-project reporting. `proj` and `proj-ci-hardener` side by
|
||||
# side is an ordinary layout, not a contrived one.
|
||||
@test "flags a sibling project whose directory name extends this one's" {
|
||||
mkdir -p "$PROJDIR/${CURRENT_SLUG}-ci-hardener"
|
||||
echo x > "$PROJDIR/${CURRENT_SLUG}-ci-hardener/file1"
|
||||
snap
|
||||
echo y > "$PROJDIR/${CURRENT_SLUG}-ci-hardener/file2"
|
||||
touch -d '+1 hour' "$PROJDIR/${CURRENT_SLUG}-ci-hardener/file2"
|
||||
run cmp_
|
||||
[[ "$output" == *"CROSS-PROJECT"* ]]
|
||||
}
|
||||
|
||||
@test "silent when no projects changed" {
|
||||
mkdir -p "$PROJDIR/-some-other-project"
|
||||
echo x > "$PROJDIR/-some-other-project/file1"
|
||||
@@ -230,7 +210,11 @@ cmp_() { "$AUDIT" compare "$WS" "$STATE" 2>&1; }
|
||||
[[ "$output" == *".claude/evil"* ]]
|
||||
}
|
||||
|
||||
@test "TCB: flags a change under bin/ when workspace is the audit's own repo root" {
|
||||
@test "TCB: a change git can see is reported as info, not as a CRITICAL to ack" {
|
||||
# tcb_snapshot only populates when the workspace IS dejima's own repo, so
|
||||
# every change here is also in the git working tree the operator is about
|
||||
# to read. One CRITICAL per file duplicated `git status` and made
|
||||
# developing dejima itself unusable.
|
||||
mkdir -p "$WS/bin"
|
||||
echo x > "$WS/bin/x"
|
||||
echo x > "$WS/install.sh"
|
||||
@@ -238,7 +222,27 @@ cmp_() { "$AUDIT" compare "$WS" "$STATE" 2>&1; }
|
||||
snap
|
||||
echo changed > "$WS/bin/x"
|
||||
run cmp_
|
||||
[[ "$output" == *"TCB (sandbox self) modified: bin/x"* ]]
|
||||
[[ "$output" != *"TCB (sandbox self) modified"* ]] # nothing to acknowledge
|
||||
# Recorded as INFO in the report rather than shouted on stderr: the
|
||||
# startup warning already said the TCB is writable this session.
|
||||
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
|
||||
run bash -c "cat '$AUDITDIR/$slug'/*.txt"
|
||||
[[ "$output" == *"this workspace IS dejima"* ]]
|
||||
}
|
||||
|
||||
@test "TCB: a change git CANNOT see is still a CRITICAL finding" {
|
||||
# The case that justifies keeping the check at all. A file listed in
|
||||
# .git/info/exclude is live code that runs on the next launch and appears
|
||||
# nowhere in `git status`, so nothing but this hash sees it.
|
||||
mkdir -p "$WS/bin" "$WS/.git/info"
|
||||
echo x > "$WS/bin/hidden"
|
||||
echo x > "$WS/install.sh"
|
||||
echo 'bin/hidden' > "$WS/.git/info/exclude"
|
||||
export DEJIMA_AUDIT_REPO_ROOT="$(readlink -f "$WS")"
|
||||
snap
|
||||
echo changed > "$WS/bin/hidden"
|
||||
run cmp_
|
||||
[[ "$output" == *"TCB (sandbox self) modified, INVISIBLE to git: bin/hidden"* ]]
|
||||
}
|
||||
|
||||
@test "TCB: does not flag changes when the workspace is not the audit's own repo root" {
|
||||
@@ -611,14 +615,56 @@ cmp_() { "$AUDIT" compare "$WS" "$STATE" 2>&1; }
|
||||
[ "$result" -ne 124 ]
|
||||
}
|
||||
|
||||
@test "show prints the only report there is, and names it" {
|
||||
# One `ack` run used to walk one report and exit, saying nothing about the
|
||||
# rest, so clearing a backlog took as many runs as there were reports and
|
||||
# looked like the tool was stuck on the first one.
|
||||
@test "ack walks every report in one run when told to continue" {
|
||||
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
|
||||
snap; echo 'export X=1' > "$WS/.envrc"; cmp_ >/dev/null 2>&1
|
||||
snap; mkdir -p "$WS/.claude"; echo '{}' > "$WS/.claude/settings.json"; cmp_ >/dev/null 2>&1
|
||||
[ "$(find "$AUDITDIR/$slug" -name '*.txt' | wc -l)" -eq 2 ]
|
||||
# approve, continue, approve
|
||||
run bash -c "printf 'a\ny\na\n' | '$AUDIT' ack '$WS'"
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *"2 reports waiting"* ]]
|
||||
[[ "$output" == *".envrc"* ]]
|
||||
[[ "$output" == *"settings.json"* ]] # the second report, same run
|
||||
}
|
||||
|
||||
@test "ack stops at one report when told not to continue" {
|
||||
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
|
||||
snap; echo 'export X=1' > "$WS/.envrc"; cmp_ >/dev/null 2>&1
|
||||
snap; mkdir -p "$WS/.claude"; echo '{}' > "$WS/.claude/settings.json"; cmp_ >/dev/null 2>&1
|
||||
run bash -c "printf 'a\nn\n' | '$AUDIT' ack '$WS'"
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *"still waiting"* ]] # said what is left behind
|
||||
[[ "$output" != *"settings.json"* ]] # and did not open it
|
||||
}
|
||||
|
||||
@test "show indexes the reports rather than dumping one" {
|
||||
# It used to `cat` a single report: a wall of text that answered neither
|
||||
# question an operator has - how much is waiting, and what still needs a
|
||||
# decision - and included findings already acknowledged, so it did not
|
||||
# even shrink as they worked through it.
|
||||
snap
|
||||
echo 'export X=1' > "$WS/.envrc"
|
||||
cmp_ >/dev/null 2>&1
|
||||
run bash -c "'$AUDIT' show '$WS' 2>&1"
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *"1 report(s)"* ]]
|
||||
[[ "$output" == *"-CRITICAL.txt"* ]] # names it
|
||||
[[ "$output" == *"ack starts here"* ]] # and which one ack will open
|
||||
[[ "$output" != *".envrc"* ]] # without the contents
|
||||
}
|
||||
|
||||
@test "show --full prints the report ack would open" {
|
||||
snap
|
||||
echo 'export X=1' > "$WS/.envrc"
|
||||
cmp_ >/dev/null 2>&1
|
||||
run bash -c "'$AUDIT' show '$WS' --full 2>&1"
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *".envrc"* ]]
|
||||
[[ "$output" == *"-CRITICAL.txt"* ]] # said which report it printed
|
||||
[[ "$output" == *"-CRITICAL.txt"* ]]
|
||||
}
|
||||
|
||||
@test "show and ack agree on which report they mean" {
|
||||
@@ -638,19 +684,20 @@ WARNING changed workspace file: .vscode/settings.json
|
||||
REPORT
|
||||
run "$AUDIT" show "$WS"
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *".envrc"* ]] # the stale CRITICAL report...
|
||||
[[ "$output" == *"-CRITICAL.txt"* ]] # the stale CRITICAL report...
|
||||
[[ "$output" != *"settings.json"* ]] # ...not the newer clean one
|
||||
}
|
||||
|
||||
@test "show falls back to the newest report when no CRITICAL one is pending" {
|
||||
@test "show marks the newest report as the one ack will open, absent a CRITICAL" {
|
||||
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
|
||||
report_dir="$AUDITDIR/$slug"; mkdir -p "$report_dir"
|
||||
echo 'older' > "$report_dir/2020-01-01-000000.txt"
|
||||
echo 'newer' > "$report_dir/2026-01-01-000001.txt"
|
||||
run "$AUDIT" show "$WS"
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *"newer"* ]]
|
||||
[[ "$output" != *"older"* ]]
|
||||
[[ "$output" == *"2 report(s)"* ]] # both listed, not just one
|
||||
[[ "$output" == *"2020-01-01-000000.txt"* ]]
|
||||
[[ "$output" == *"2026-01-01-000001.txt"*"ack starts here"* ]]
|
||||
}
|
||||
|
||||
@test "ack presents a stale unacknowledged CRITICAL report before a newer clean one" {
|
||||
@@ -791,39 +838,32 @@ REPORT
|
||||
[[ "$output" != *"-CRITICAL.txt"* ]]
|
||||
}
|
||||
|
||||
@test "a finding that cannot be acknowledged is presented once, not on every run" {
|
||||
# There is nothing to record for one, so without a marker it re-prompted
|
||||
# forever and `ack` could never reach a state of having nothing to ask.
|
||||
@test "a cleared finding is presented once, not on every run" {
|
||||
# There is nothing to record for a surface that no longer exists, so
|
||||
# without a `reviewed` marker the report re-prompted forever and `ack`
|
||||
# could never reach a state of having nothing to ask.
|
||||
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
|
||||
mkdir -p "$PROJDIR/-some-other-project"
|
||||
echo x > "$PROJDIR/-some-other-project/file1"
|
||||
snap
|
||||
echo y > "$PROJDIR/-some-other-project/file2"
|
||||
touch -d '+1 hour' "$PROJDIR/-some-other-project/file2"
|
||||
cmp_ >/dev/null 2>&1
|
||||
run bash -c "printf 's\n' | '$AUDIT' ack '$WS'"
|
||||
[[ "$output" == *"CROSS-PROJECT"* ]]
|
||||
snap; echo 'export X=1' > "$WS/.envrc"; cmp_ >/dev/null 2>&1
|
||||
rm -f "$WS/.envrc"
|
||||
run bash -c "printf 'a\n' | '$AUDIT' ack '$WS'"
|
||||
[[ "$output" == *".envrc"* ]]
|
||||
grep -q '^reviewed ' "$BASEDIR/$slug"
|
||||
|
||||
run bash -c "printf 's\n' | '$AUDIT' ack '$WS'"
|
||||
[[ "$output" != *"CROSS-PROJECT"* ]]
|
||||
run bash -c "printf 'a\n' | '$AUDIT' ack '$WS'"
|
||||
[[ "$output" != *".envrc"* ]]
|
||||
}
|
||||
|
||||
@test "quitting a report does not mark it reviewed" {
|
||||
# The marker means "walked to the end". An abort discards everything
|
||||
# else, and must discard this too or one q would silence the report.
|
||||
# The marker means "walked to the end". An abort discards everything else,
|
||||
# and must discard this too or one q would silence the report.
|
||||
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
|
||||
mkdir -p "$PROJDIR/-some-other-project"
|
||||
echo x > "$PROJDIR/-some-other-project/file1"
|
||||
snap
|
||||
echo y > "$PROJDIR/-some-other-project/file2"
|
||||
touch -d '+1 hour' "$PROJDIR/-some-other-project/file2"
|
||||
cmp_ >/dev/null 2>&1
|
||||
snap; echo 'export X=1' > "$WS/.envrc"; cmp_ >/dev/null 2>&1
|
||||
rm -f "$WS/.envrc"
|
||||
run bash -c "printf 'q\n' | '$AUDIT' ack '$WS'"
|
||||
! grep -q '^reviewed ' "$BASEDIR/$slug" 2>/dev/null
|
||||
|
||||
run bash -c "printf 's\n' | '$AUDIT' ack '$WS'"
|
||||
[[ "$output" == *"CROSS-PROJECT"* ]]
|
||||
run bash -c "printf 'a\n' | '$AUDIT' ack '$WS'"
|
||||
[[ "$output" == *".envrc"* ]]
|
||||
}
|
||||
|
||||
@test "a WARNING git config finding can be acknowledged, both ways (#45)" {
|
||||
@@ -873,24 +913,6 @@ REPORT
|
||||
[[ "$output" != *"-CRITICAL.txt"* ]]
|
||||
}
|
||||
|
||||
@test "ack does not write an unmatchable entry for a finding with no id" {
|
||||
# Was written against a changed git config, which #45 made ackable. The
|
||||
# invariant is about findings that have no id to record at all, so it now
|
||||
# uses the one class that still has none: a cross-project write, whose
|
||||
# subject is another project's slug rather than anything this audit can
|
||||
# hash and pin.
|
||||
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
|
||||
mkdir -p "$PROJDIR/-some-other-project"
|
||||
echo x > "$PROJDIR/-some-other-project/file1"
|
||||
snap
|
||||
echo y > "$PROJDIR/-some-other-project/file2"
|
||||
touch -d '+1 hour' "$PROJDIR/-some-other-project/file2"
|
||||
cmp_ >/dev/null 2>&1
|
||||
run bash -c "printf 'a\n' | '$AUDIT' ack '$WS'"
|
||||
[[ "$output" == *"cannot"* ]]
|
||||
! grep -qE '^(safe|spam) ' "$BASEDIR/$slug" 2>/dev/null
|
||||
}
|
||||
|
||||
@test "an ack-approved finding is suppressed on the next compare (id round-trips through the baseline)" {
|
||||
snap; echo 'export X=1' > "$WS/.envrc"; cmp_ >/dev/null 2>&1
|
||||
run bash -c "printf 'a\n' | '$AUDIT' ack '$WS'"
|
||||
@@ -919,24 +941,37 @@ REPORT
|
||||
# so the report must stay eviction-protected
|
||||
}
|
||||
|
||||
@test "ack refuses to approve a finding whose path no longer exists, and the CRITICAL suffix survives" {
|
||||
# A since-deleted path has no content left to pin a `safe` approval to -
|
||||
# writing one anyway (the old behaviour) produced a baseline entry
|
||||
# baseline_suppresses() can never match (its own empty-sha guard rejects
|
||||
# it as malformed), so the surface stayed permanently unsuppressible -
|
||||
# while `a` still advanced crit_acked and let the report shed its
|
||||
# -CRITICAL.txt protection for good. Refusing outright keeps both the
|
||||
# baseline and the report's protection honest.
|
||||
@test "a finding whose path no longer exists is cleared, not refused forever" {
|
||||
# Refusing outright kept the baseline honest - there is no content left to
|
||||
# pin a `safe` entry to - but it also meant crit_acked never reached
|
||||
# crit_seen, so the report kept -CRITICAL.txt. select_report prefers
|
||||
# CRITICAL, so one deleted file pinned `ack` to a report that no decision
|
||||
# could resolve and hid every other report behind it, permanently.
|
||||
#
|
||||
# Still nothing written to the baseline: an entry with an empty sha can
|
||||
# never match. The finding is simply cleared, because a surface that does
|
||||
# not exist does not run. If it comes back, it comes back as new.
|
||||
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
|
||||
snap; echo 'export X=1' > "$WS/.envrc"; cmp_ >/dev/null 2>&1
|
||||
rm -f "$WS/.envrc" # gone by the time ack runs
|
||||
run bash -c "printf 'a\n' | '$AUDIT' ack '$WS'"
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *"cannot approve: .envrc no longer exists"* ]]
|
||||
[[ "$output" == *"nothing recorded"* ]]
|
||||
[ ! -f "$BASEDIR/$slug" ] # nothing written to the baseline at all
|
||||
[[ "$output" == *"cleared"* ]]
|
||||
[[ "$output" == *".envrc"* ]]
|
||||
! grep -qE '^(safe|spam) ' "$BASEDIR/$slug" 2>/dev/null # nothing unmatchable recorded
|
||||
grep -q '^reviewed ' "$BASEDIR/$slug" # but the report is done with
|
||||
run bash -c "ls '$AUDITDIR/$slug'"
|
||||
[[ "$output" == *"-CRITICAL.txt"* ]] # unacknowledged CRITICAL keeps its protection
|
||||
[[ "$output" != *"-CRITICAL.txt"* ]] # and no longer blocks the queue
|
||||
}
|
||||
|
||||
# The other half: clearing is not a way to launder a finding whose surface is
|
||||
# still there. Only an absent one can be cleared.
|
||||
@test "a finding whose path still exists is approved against its content, not cleared" {
|
||||
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
|
||||
snap; echo 'export X=1' > "$WS/.envrc"; cmp_ >/dev/null 2>&1
|
||||
run bash -c "printf 'a\n' | '$AUDIT' ack '$WS'"
|
||||
[[ "$output" != *"cleared"* ]]
|
||||
grep -q '^safe .envrc ' "$BASEDIR/$slug"
|
||||
}
|
||||
|
||||
@test "a workspace path containing its own ': ' cannot hijack ack's id derivation" {
|
||||
@@ -1079,7 +1114,10 @@ REPORT
|
||||
|
||||
@test "ack records a safe tcb entry whose id round-trips and suppresses the next compare" {
|
||||
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
|
||||
mkdir -p "$WS/bin"; echo x > "$WS/bin/x"; echo x > "$WS/install.sh"
|
||||
# Hidden from git, which is the only shape that is still a finding.
|
||||
mkdir -p "$WS/bin" "$WS/.git/info"
|
||||
echo x > "$WS/bin/x"; echo x > "$WS/install.sh"
|
||||
echo 'bin/x' > "$WS/.git/info/exclude"
|
||||
export DEJIMA_AUDIT_REPO_ROOT="$(readlink -f "$WS")"
|
||||
snap
|
||||
echo changed > "$WS/bin/x"
|
||||
@@ -1137,7 +1175,7 @@ REPORT
|
||||
cmp_ >/dev/null
|
||||
run bash -c "cd '$WS' && '$AUDIT' show 2>/dev/null"
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *".envrc"* ]]
|
||||
[[ "$output" == *"report(s) for $WS"* ]]
|
||||
}
|
||||
|
||||
@test "show finds the workspace when run from a subdirectory" {
|
||||
@@ -1147,7 +1185,7 @@ REPORT
|
||||
cmp_ >/dev/null
|
||||
run bash -c "cd '$WS/src/deep' && '$AUDIT' show 2>/dev/null"
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *".envrc"* ]]
|
||||
[[ "$output" == *"report(s) for $WS"* ]]
|
||||
}
|
||||
|
||||
@test "show finds the parent repo when run from a linked worktree" {
|
||||
@@ -1162,7 +1200,7 @@ REPORT
|
||||
cmp_ >/dev/null
|
||||
run bash -c "cd '$WS/.worktrees/wt' && '$AUDIT' show 2>/dev/null"
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *".envrc"* ]]
|
||||
[[ "$output" == *"report(s) for $WS"* ]]
|
||||
}
|
||||
|
||||
@test "show finds the parent repo's report even after the worktree itself was snapshotted" {
|
||||
@@ -1177,7 +1215,7 @@ REPORT
|
||||
"$AUDIT" snapshot "$WS/.worktrees/wt" "$(mktemp)"
|
||||
run bash -c "cd '$WS/.worktrees/wt' && '$AUDIT' show 2>/dev/null"
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *".envrc"* ]]
|
||||
[[ "$output" == *"report(s) for $WS"* ]]
|
||||
}
|
||||
|
||||
@test "show names the path it resolved when that workspace has no reports" {
|
||||
@@ -1198,7 +1236,7 @@ REPORT
|
||||
other="$(mktemp -d)"; git -C "$other" init -q
|
||||
run bash -c "cd '$other' && '$AUDIT' show '$WS' 2>/dev/null"
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *".envrc"* ]]
|
||||
[[ "$output" == *"report(s) for $WS"* ]]
|
||||
rm -rf "$other"
|
||||
}
|
||||
|
||||
|
||||
+363
-19
@@ -188,6 +188,16 @@ EOF
|
||||
|
||||
write_config() {
|
||||
printf '%s\n' "$@" > "$CONFIG_DIR/config"
|
||||
# A launch is refused without a harness, so every fixture needs one. Its
|
||||
# own [core] section, which is legal and resolves the same; a test that
|
||||
# means to omit it writes the file directly.
|
||||
# A key assignment, not the word anywhere in the arguments: a [mounts]
|
||||
# path containing "harness" would otherwise suppress the seeding.
|
||||
local _a _has=0
|
||||
for _a in "$@"; do
|
||||
[[ "$_a" =~ ^[[:space:]]*harness[[:space:]]*= ]] && _has=1
|
||||
done
|
||||
[ "$_has" -eq 1 ] || printf '[core]\nharness = claude-code\n' >> "$CONFIG_DIR/config"
|
||||
}
|
||||
|
||||
@test "config_get returns the schema default when the key is absent" {
|
||||
@@ -1045,24 +1055,16 @@ run_install() {
|
||||
|
||||
local name out hit=0
|
||||
|
||||
# docs/ and systemd/ are pure operator-facing prose - nothing there is
|
||||
# live shell plumbing, so any mention of a retired name is either the
|
||||
# deliberate "no longer read" notice (install.sh's remote.conf one lives
|
||||
# outside these two directories, but the exclusion is kept here too in
|
||||
# case a future doc adds one), a stale instruction, or one of the two
|
||||
# cases below that are neither:
|
||||
# docs/ and systemd/ are operator-facing prose, no live plumbing, so a
|
||||
# retired name there is either the deliberate "no longer read" notice, a
|
||||
# stale instruction, or one of two cases that are neither:
|
||||
#
|
||||
# - A manifest-field line (`env_export = GITEA_API=api ...`): this is
|
||||
# docs/providers.md quoting a forge manifest's `env_export` value
|
||||
# verbatim, which *declares* a container-side environment variable -
|
||||
# the exact thing env_export is for - not an instruction to an operator
|
||||
# to set one. `GITEA_API` and friends are retired only as host-side,
|
||||
# operator-set variables (what _warn_legacy_env's table is about); they
|
||||
# are not retired as the container-side names env_export produces, and
|
||||
# docs/providers.md documenting that mapping is not a regression.
|
||||
# - A backtick-quoted mention (`` `GITEA_API` ``): prose naming a
|
||||
# variable - "the container gets `GITEA_API`" - reads a value, it
|
||||
# doesn't tell anyone to set one.
|
||||
# - A manifest-field line (`env_export = GITEA_API=api ...`), which is
|
||||
# providers.md quoting a manifest verbatim. These names are retired as
|
||||
# host-side operator-set variables, not as the container-side names
|
||||
# env_export produces.
|
||||
# - A backtick-quoted mention - prose naming a variable reads a value, it
|
||||
# does not tell anyone to set one.
|
||||
while IFS= read -r name; do
|
||||
out="$(grep -rnE "\b${name}\b" "$REPO_ROOT/docs" "$REPO_ROOT/systemd" 2>/dev/null \
|
||||
| grep -v 'no longer read' \
|
||||
@@ -1134,11 +1136,273 @@ exit 0
|
||||
EOF
|
||||
chmod +x "$STUB_DIR/docker"
|
||||
docker_log="$(mktemp)"
|
||||
HOME="$RW_HOME" PATH="$STUB_DIR:$PATH" SSH_AUTH_SOCK= \
|
||||
HOME="$RW_HOME" PATH="$STUB_DIR:$PATH" SSH_AUTH_SOCK="${RW_AGENT_SOCK-}" \
|
||||
SANDBOX_TEST_DOCKER_LOG="$docker_log" \
|
||||
run "$REPO_ROOT/bin/dejima" "$1"
|
||||
}
|
||||
|
||||
# A real agent, carrying nothing. `ssh-add -l` against it exits nonzero,
|
||||
# which is the condition the "no identities loaded" warning keys on, and a
|
||||
# handmade socket file would not reproduce it.
|
||||
_empty_agent() {
|
||||
eval "$(ssh-agent -s)" >/dev/null
|
||||
RW_AGENT_SOCK="$SSH_AUTH_SOCK"
|
||||
RW_AGENT_PID="$SSH_AGENT_PID"
|
||||
}
|
||||
_kill_agent() { [ -n "${RW_AGENT_PID:-}" ] && kill "$RW_AGENT_PID" 2>/dev/null; return 0; }
|
||||
|
||||
# The forwarded agent is the fallback; [core] ssh_key is the arrangement the
|
||||
# docs recommend, because it is a dedicated identity and works unattended.
|
||||
# So an empty agent is only worth mentioning when nothing else can push -
|
||||
# otherwise the warning fires at exactly the operators who took the advice.
|
||||
# ~/.claude/file-history is keyed by session id, so nothing in its own paths
|
||||
# says which project a directory belongs to. The transcript names are what
|
||||
# resolve it, and this is the assertion that the resolution is right: a
|
||||
# session of this workspace is mounted, one belonging to another project is
|
||||
# not, and neither is one whose slug merely extends this workspace's - the
|
||||
# sibling case that #104 turned on.
|
||||
@test "file-history is mounted for this workspace's sessions and no others" {
|
||||
_rw_setup
|
||||
workspace="$(mktemp -d)"
|
||||
local slug sib own foreign sibling
|
||||
slug="$(printf '%s' "$workspace" | sed 's/[^a-zA-Z0-9]/-/g')"
|
||||
sib="$slug-extra"
|
||||
own=11111111-1111-1111-1111-111111111111
|
||||
foreign=22222222-2222-2222-2222-222222222222
|
||||
sibling=33333333-3333-3333-3333-333333333333
|
||||
mkdir -p "$RW_HOME/.claude/projects/$slug" \
|
||||
"$RW_HOME/.claude/projects/-some-other-project" \
|
||||
"$RW_HOME/.claude/projects/$sib"
|
||||
: > "$RW_HOME/.claude/projects/$slug/$own.jsonl"
|
||||
: > "$RW_HOME/.claude/projects/-some-other-project/$foreign.jsonl"
|
||||
: > "$RW_HOME/.claude/projects/$sib/$sibling.jsonl"
|
||||
mkdir -p "$RW_HOME/.claude/file-history/$own" \
|
||||
"$RW_HOME/.claude/file-history/$foreign" \
|
||||
"$RW_HOME/.claude/file-history/$sibling"
|
||||
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only'
|
||||
_rw_launch "$workspace"
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
grep -qF "file-history/$own" "$docker_log" \
|
||||
|| { echo "own session not mounted:"; grep file-history "$docker_log"; return 1; }
|
||||
run grep -F "file-history/$foreign" "$docker_log"
|
||||
[ "$status" -ne 0 ] || { echo "another project's file-history was mounted"; return 1; }
|
||||
run grep -F "file-history/$sibling" "$docker_log"
|
||||
[ "$status" -ne 0 ] || { echo "a sibling project's file-history was mounted"; return 1; }
|
||||
rm -rf "$workspace"
|
||||
}
|
||||
|
||||
# No default on purpose: a release changing it would move an operator who
|
||||
# never chose to a different harness on upgrade.
|
||||
@test "a config with no [core] harness is refused, and says what to add" {
|
||||
_rw_setup
|
||||
workspace="$(mktemp -d)"
|
||||
# Written directly, not through write_config, which seeds a harness for
|
||||
# every other fixture precisely because a launch needs one.
|
||||
printf '[core]\nssh_key = %s\nforge = none\nworkflow = local-only\n' \
|
||||
"$RW_KEY" > "$CONFIG_DIR/config"
|
||||
_rw_launch "$workspace"
|
||||
[ "$status" -ne 0 ] || { echo "launched with no harness selected:"; echo "$output"; return 1; }
|
||||
[[ "$output" == *"[core] harness is not set"* ]]
|
||||
[[ "$output" == *"harness = claude-code"* ]] # the line to add
|
||||
[[ "$output" == *"Available:"*"claude-code"* ]]
|
||||
rm -rf "$workspace"
|
||||
}
|
||||
|
||||
@test "the harness schema carries no default to fall back to" {
|
||||
# Directly again: write_config seeds one, which is the whole point of it.
|
||||
printf '[core]\nforge = none\nworkflow = local-only\n' > "$CONFIG_DIR/config"
|
||||
run lib config_get core harness
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
[ "$output" = "" ]
|
||||
}
|
||||
|
||||
# --print-config is what an operator reaches for to find out what is unset, so
|
||||
# it has to survive the thing being unset.
|
||||
@test "--print-config still works when no harness is selected" {
|
||||
printf '[core]\nforge = none\nworkflow = local-only\n' > "$CONFIG_DIR/config"
|
||||
run "$REPO_ROOT/bin/dejima" --print-config
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
[[ "$output" == *"core.harness="* ]]
|
||||
[[ "$output" != *"invalid harness name"* ]]
|
||||
}
|
||||
|
||||
@test "an empty ssh-agent is not warned about when [core] ssh_key is set" {
|
||||
_rw_setup
|
||||
workspace="$(mktemp -d)"
|
||||
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only'
|
||||
_empty_agent
|
||||
_rw_launch "$workspace"
|
||||
_kill_agent
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
[[ "$output" != *"no identities loaded"* ]] || { echo "$output"; return 1; }
|
||||
rm -rf "$workspace"
|
||||
}
|
||||
|
||||
# The positive control for the test above: with no key configured the
|
||||
# forwarded agent really is the only way to push, and an empty one is worth
|
||||
# saying so before it fails deep into a session.
|
||||
# --extra-readonly / --extra-read-write append to the config's own lists
|
||||
# rather than carrying their own mount code, so these assert the mount lands
|
||||
# and then that every refusal the configured form gets applies to the flag
|
||||
# too. If they ever stop sharing a path, the guard tests below pass while the
|
||||
# flags quietly stop being covered by them.
|
||||
_rw_launch_flags() {
|
||||
local ws="$1"; shift
|
||||
STUB_DIR="$(mktemp -d)"
|
||||
cat > "$STUB_DIR/docker" <<'EOF'
|
||||
#!/bin/sh
|
||||
for a in "$@"; do printf '%s\n' "$a" >> "$SANDBOX_TEST_DOCKER_LOG"; done
|
||||
exit 0
|
||||
EOF
|
||||
chmod +x "$STUB_DIR/docker"
|
||||
docker_log="$(mktemp)"
|
||||
HOME="$RW_HOME" PATH="$STUB_DIR:$PATH" SSH_AUTH_SOCK= \
|
||||
SANDBOX_TEST_DOCKER_LOG="$docker_log" \
|
||||
run "$REPO_ROOT/bin/dejima" "$@" "$ws"
|
||||
}
|
||||
|
||||
@test "--extra-readonly and --extra-read-write mount for one launch" {
|
||||
_rw_setup
|
||||
workspace="$(mktemp -d)"; ro="$(mktemp -d)"; rw="$(mktemp -d)"
|
||||
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only'
|
||||
_rw_launch_flags "$workspace" --extra-readonly "$ro" --extra-read-write "$rw"
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
grep -qxF "$ro:$ro:ro" "$docker_log" || { cat "$docker_log"; return 1; }
|
||||
grep -qxF "$rw:$rw" "$docker_log" || { cat "$docker_log"; return 1; }
|
||||
rm -rf "$workspace" "$ro" "$rw"
|
||||
}
|
||||
|
||||
@test "the short forms are the same flags, and both are repeatable" {
|
||||
_rw_setup
|
||||
workspace="$(mktemp -d)"; a="$(mktemp -d)"; b="$(mktemp -d)"
|
||||
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only'
|
||||
_rw_launch_flags "$workspace" --ro "$a" --ro "$b"
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
grep -qxF "$a:$a:ro" "$docker_log" || { cat "$docker_log"; return 1; }
|
||||
grep -qxF "$b:$b:ro" "$docker_log" || { cat "$docker_log"; return 1; }
|
||||
rm -rf "$workspace" "$a" "$b"
|
||||
}
|
||||
|
||||
# The flags add to the configured lists; they do not replace them. A launch
|
||||
# that dropped the config's own entries would still pass the two tests above.
|
||||
@test "a flag adds to [mounts] readonly rather than replacing it" {
|
||||
_rw_setup
|
||||
workspace="$(mktemp -d)"; configured="$(mktemp -d)"; extra="$(mktemp -d)"
|
||||
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only' \
|
||||
'[mounts]' "readonly = $configured"
|
||||
_rw_launch_flags "$workspace" --ro "$extra"
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
grep -qxF "$configured:$configured:ro" "$docker_log" || { cat "$docker_log"; return 1; }
|
||||
grep -qxF "$extra:$extra:ro" "$docker_log" || { cat "$docker_log"; return 1; }
|
||||
rm -rf "$workspace" "$configured" "$extra"
|
||||
}
|
||||
|
||||
# The point of appending to the same list: a flag is a way to say where, not
|
||||
# a way past the refusals. Both directions, since the two lists are guarded
|
||||
# by different code.
|
||||
@test "--extra-readonly is refused when it names the approval store" {
|
||||
_rw_setup
|
||||
workspace="$(mktemp -d)"
|
||||
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only'
|
||||
_rw_launch_flags "$workspace" --ro "$CONFIG_DIR"
|
||||
[ "$status" -ne 0 ] || { echo "launched anyway:"; echo "$output"; return 1; }
|
||||
[[ "$output" == *"Refusing --extra-readonly path"* ]] || { echo "$output"; return 1; }
|
||||
rm -rf "$workspace"
|
||||
}
|
||||
|
||||
@test "--extra-read-write is refused when it names the approval store" {
|
||||
_rw_setup
|
||||
workspace="$(mktemp -d)"
|
||||
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only'
|
||||
_rw_launch_flags "$workspace" --rw "$CONFIG_DIR"
|
||||
[ "$status" -ne 0 ] || { echo "launched anyway:"; echo "$output"; return 1; }
|
||||
[[ "$output" == *"Refusing --extra-read-write path"* ]] || { echo "$output"; return 1; }
|
||||
rm -rf "$workspace"
|
||||
}
|
||||
|
||||
# A refusal that named "[mounts] readonly" would send someone to a config
|
||||
# file that does not mention the path.
|
||||
@test "a rejected flag path is reported as a flag, not as a config entry" {
|
||||
_rw_setup
|
||||
workspace="$(mktemp -d)"
|
||||
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only'
|
||||
_rw_launch_flags "$workspace" --ro /nonexistent-flag-path
|
||||
[ "$status" -ne 0 ]
|
||||
[[ "$output" == *"--extra-readonly path does not exist"* ]] || { echo "$output"; return 1; }
|
||||
rm -rf "$workspace"
|
||||
}
|
||||
|
||||
# Both lists are colon-separated, so such a path cannot be represented in one
|
||||
# and would split into two mounts that each fail the existence check.
|
||||
@test "a flag path containing a colon is refused" {
|
||||
_rw_setup
|
||||
workspace="$(mktemp -d)"
|
||||
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only'
|
||||
_rw_launch_flags "$workspace" --ro /tmp/has:colon
|
||||
[ "$status" -ne 0 ]
|
||||
[[ "$output" == *"must not contain a colon"* ]] || { echo "$output"; return 1; }
|
||||
rm -rf "$workspace"
|
||||
}
|
||||
|
||||
# #15. Both of these reach `extra_mounts+=(-v ...)` with a path computed at
|
||||
# run time, so the static grep for a literal $PROJECT_STORE / config_root
|
||||
# cannot see either one - it passed throughout. [mounts] read_write has been
|
||||
# guarded since it was added; these two were not.
|
||||
@test "[mounts] readonly is refused when it names the approval store" {
|
||||
_rw_setup
|
||||
workspace="$(mktemp -d)"
|
||||
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only' \
|
||||
'[mounts]' "readonly = $CONFIG_DIR"
|
||||
_rw_launch "$workspace"
|
||||
[ "$status" -ne 0 ] || { echo "launched anyway:"; echo "$output"; return 1; }
|
||||
[[ "$output" == *"Refusing [mounts] readonly entry"* ]] || { echo "$output"; return 1; }
|
||||
rm -rf "$workspace"
|
||||
}
|
||||
|
||||
# The target is discovered from a symlink rather than configured, so nothing
|
||||
# had vetted it: a hook pointing into the approval store mounted the store.
|
||||
@test "a ~/.claude symlink is refused when its target sits in the approval store" {
|
||||
_rw_setup
|
||||
workspace="$(mktemp -d)"
|
||||
mkdir -p "$RW_HOME/.claude/hooks" "$CONFIG_DIR/sub"
|
||||
: > "$CONFIG_DIR/sub/hook.sh"
|
||||
ln -s "$CONFIG_DIR/sub/hook.sh" "$RW_HOME/.claude/hooks/hook.sh"
|
||||
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only'
|
||||
_rw_launch "$workspace"
|
||||
[ "$status" -ne 0 ] || { echo "launched anyway:"; echo "$output"; return 1; }
|
||||
[[ "$output" == *"Refusing ~/.claude symlink target"* ]] || { echo "$output"; return 1; }
|
||||
rm -rf "$workspace"
|
||||
}
|
||||
|
||||
# The ordinary case the symlink scan exists for: this repo's own hooks/ are
|
||||
# symlinked into ~/.claude and must still be mounted. Without this, refusing
|
||||
# everything would pass the two tests above.
|
||||
@test "a ~/.claude symlink to an ordinary directory is still mounted" {
|
||||
_rw_setup
|
||||
workspace="$(mktemp -d)"
|
||||
hookdir="$(mktemp -d)"
|
||||
: > "$hookdir/hook.sh"
|
||||
mkdir -p "$RW_HOME/.claude/hooks"
|
||||
ln -s "$hookdir/hook.sh" "$RW_HOME/.claude/hooks/hook.sh"
|
||||
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only'
|
||||
_rw_launch "$workspace"
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
grep -qxF "$hookdir:$hookdir:ro" "$docker_log" \
|
||||
|| { echo "hook target not mounted:"; cat "$docker_log"; return 1; }
|
||||
rm -rf "$workspace" "$hookdir"
|
||||
}
|
||||
|
||||
@test "an empty ssh-agent is warned about when [core] ssh_key is not set" {
|
||||
_rw_setup
|
||||
workspace="$(mktemp -d)"
|
||||
write_config '[core]' 'forge = none' 'workflow = local-only'
|
||||
_empty_agent
|
||||
_rw_launch "$workspace"
|
||||
_kill_agent
|
||||
[[ "$output" == *"no identities loaded"* ]] || { echo "$output"; return 1; }
|
||||
rm -rf "$workspace"
|
||||
}
|
||||
|
||||
@test "a read_write entry is mounted without :ro, unlike a readonly one" {
|
||||
_rw_setup
|
||||
rw="$(mktemp -d)"
|
||||
@@ -1338,7 +1602,7 @@ EOF
|
||||
# a gitea token reaching the container: the shipped defaults select no
|
||||
# forge at all, so leaving them implicit would mean nothing ever reads
|
||||
# token_file and the refusal under test could never fire.
|
||||
printf '[core]\nfirewall = on\nssh_key = %s\nforge = gitea\nworkflow = worktree-pr\n[forge.gitea]\nssh_host = git.example\napi = https://api.example\ntoken_file = %s/token\n' \
|
||||
printf '[core]\nharness = claude-code\nfirewall = on\nssh_key = %s\nforge = gitea\nworkflow = worktree-pr\n[forge.gitea]\nssh_host = git.example\napi = https://api.example\ntoken_file = %s/token\n' \
|
||||
"$RW_KEY" "$CONFIG_DIR" > "$CONFIG_DIR/config"
|
||||
printf 'real-secret\ngarbage\n' > "$CONFIG_DIR/token"
|
||||
STUB_DIR="$(mktemp -d)"
|
||||
@@ -1357,3 +1621,83 @@ EOF
|
||||
|| { echo "docker was invoked despite the refusal"; return 1; }
|
||||
rm -rf "$workspace" "$RW_HOME" "$STUB_DIR"
|
||||
}
|
||||
|
||||
# --- epitaph: what a session leaves when its context dies ---------------------------------------------------
|
||||
#
|
||||
# What a long session worked out is in the summary Claude Code writes when it
|
||||
# compacts, and that goes away with the container. The store is host-side and
|
||||
# never mounted, so a later session can neither read what an earlier one
|
||||
# concluded nor edit what this one is about to record (#104).
|
||||
|
||||
_epitaph_fixture() {
|
||||
EPI_STORE="$(mktemp -d)"
|
||||
EPI_TR="$(mktemp -d)"
|
||||
EPI_WS="$(mktemp -d)"; git -C "$EPI_WS" init -q
|
||||
EPI_SLUG="$(printf '%s' "$EPI_WS" | sed 's/[^a-zA-Z0-9]/-/g')"
|
||||
mkdir -p "$EPI_TR/$EPI_SLUG"
|
||||
write_config '[epitaph]' "dir = $EPI_STORE"
|
||||
}
|
||||
|
||||
# One compact_boundary carrying the metadata, then the summary itself.
|
||||
_epitaph_transcript() {
|
||||
local f="$EPI_TR/$EPI_SLUG/$1.jsonl" text="${2:-worked out X}" uid="${3:-aaaabbbb}"
|
||||
{
|
||||
printf '{"type":"system","subtype":"compact_boundary","compactMetadata":{"trigger":"manual","preTokens":900,"postTokens":90}}\n'
|
||||
printf '{"type":"user","isCompactSummary":true,"uuid":"%s","sessionId":"s-1","timestamp":"2026-01-02T03:04:05.000Z","message":{"content":"%s"}}\n' "$uid" "$text"
|
||||
} > "$f"
|
||||
}
|
||||
|
||||
@test "an epitaph is written out of the container's reach" {
|
||||
_epitaph_fixture
|
||||
_epitaph_transcript s1 "the thing this session worked out"
|
||||
run "$REPO_ROOT/bin/dejima-epitaph" save "$EPI_WS" "$EPI_TR"
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
local saved; saved="$(find "$EPI_STORE" -name '*.md')"
|
||||
[ -n "$saved" ]
|
||||
grep -q 'the thing this session worked out' "$saved"
|
||||
grep -q 'trigger: manual' "$saved"
|
||||
grep -q '900 before, 90 after' "$saved"
|
||||
rm -rf "$EPI_STORE" "$EPI_TR" "$EPI_WS"
|
||||
}
|
||||
|
||||
@test "saving twice does not duplicate an epitaph already kept" {
|
||||
# `save` runs on every exit, and a session compacted three times has two
|
||||
# epitaphs an earlier run already wrote.
|
||||
_epitaph_fixture
|
||||
_epitaph_transcript s1 "once only"
|
||||
"$REPO_ROOT/bin/dejima-epitaph" save "$EPI_WS" "$EPI_TR" >/dev/null 2>&1
|
||||
"$REPO_ROOT/bin/dejima-epitaph" save "$EPI_WS" "$EPI_TR" >/dev/null 2>&1
|
||||
[ "$(find "$EPI_STORE" -name '*.md' | wc -l)" -eq 1 ]
|
||||
rm -rf "$EPI_STORE" "$EPI_TR" "$EPI_WS"
|
||||
}
|
||||
|
||||
@test "another project's epitaphs are not collected" {
|
||||
_epitaph_fixture
|
||||
_epitaph_transcript s1 "mine"
|
||||
mkdir -p "$EPI_TR/-some-other-project"
|
||||
printf '{"type":"user","isCompactSummary":true,"uuid":"cccc","sessionId":"s-2","timestamp":"2026-01-02T03:04:05.000Z","message":{"content":"theirs"}}\n' \
|
||||
> "$EPI_TR/-some-other-project/s2.jsonl"
|
||||
"$REPO_ROOT/bin/dejima-epitaph" save "$EPI_WS" "$EPI_TR" >/dev/null 2>&1
|
||||
run grep -rl 'theirs' "$EPI_STORE"
|
||||
[ "$status" -ne 0 ] || { echo "collected another project's summary"; return 1; }
|
||||
grep -rq 'mine' "$EPI_STORE"
|
||||
rm -rf "$EPI_STORE" "$EPI_TR" "$EPI_WS"
|
||||
}
|
||||
|
||||
@test "no epitaph is written when [epitaph] enabled is off" {
|
||||
_epitaph_fixture
|
||||
write_config '[epitaph]' "dir = $EPI_STORE" 'enabled = off'
|
||||
_epitaph_transcript s1 "should not be kept"
|
||||
"$REPO_ROOT/bin/dejima-epitaph" save "$EPI_WS" "$EPI_TR" >/dev/null 2>&1
|
||||
[ -z "$(find "$EPI_STORE" -name '*.md' 2>/dev/null)" ]
|
||||
rm -rf "$EPI_STORE" "$EPI_TR" "$EPI_WS"
|
||||
}
|
||||
|
||||
@test "the epitaph store is not mounted into the container" {
|
||||
# The whole point: a session must not be able to read what earlier
|
||||
# sessions concluded, or edit what this one is about to record.
|
||||
run grep -n 'epitaph' "$REPO_ROOT/docker/docker-compose.yml"
|
||||
[ "$status" -ne 0 ] || { echo "$output"; return 1; }
|
||||
run grep -nE 'extra_mounts\+=.*epitaph' "$REPO_ROOT/bin/dejima"
|
||||
[ "$status" -ne 0 ] || { echo "$output"; return 1; }
|
||||
}
|
||||
|
||||
+39
-2
@@ -92,7 +92,8 @@ launch() {
|
||||
local conf
|
||||
conf="$(bash -c 'source "$1"; config_path' _ "$LIB")"
|
||||
mkdir -p "$(dirname "$conf")"
|
||||
printf '[core]\nssh_key = %s\n' "$LAUNCH_DIR/sandbox-key" >> "$conf"
|
||||
# No default, so a config that launches must select one.
|
||||
printf '[core]\nharness = claude-code\nssh_key = %s\n' "$LAUNCH_DIR/sandbox-key" >> "$conf"
|
||||
HOME="$LAUNCH_DIR/home" PATH="$LAUNCH_DIR:$PATH" \
|
||||
SANDBOX_TEST_RUN_ENV="$LAUNCH_ENV" \
|
||||
SANDBOX_TEST_DOCKER_LOG="$LAUNCH_DIR/docker-log" \
|
||||
@@ -817,7 +818,7 @@ ENTRYPOINT [\"/usr/local/bin/entrypoint.sh\"]" ]]
|
||||
# no domains at all, or by the env prefix below never reaching the child.
|
||||
printf 'good.example.com\n' > "$CONFIG_DIR/allowed-domains"
|
||||
launch_setup
|
||||
printf '[core]\nssh_key = %s\n' "$LAUNCH_DIR/sandbox-key" >> "$CONFIG_DIR/config"
|
||||
printf '[core]\nharness = claude-code\nssh_key = %s\n' "$LAUNCH_DIR/sandbox-key" >> "$CONFIG_DIR/config"
|
||||
HOME="$LAUNCH_DIR/home" PATH="$LAUNCH_DIR:$PATH" \
|
||||
SANDBOX_TEST_RUN_ENV="$LAUNCH_ENV" \
|
||||
SSH_AUTH_SOCK= ALLOWED_DOMAINS=evil.example \
|
||||
@@ -908,6 +909,42 @@ ENTRYPOINT [\"/usr/local/bin/entrypoint.sh\"]" ]]
|
||||
[ "$(launched_env SANDBOX_IMAGE)" = "dejima:$(id -un)-$slug" ]
|
||||
}
|
||||
|
||||
# nvim's data/state/cache are Docker volumes, not host paths, so nothing a
|
||||
# session installs reaches the host's own nvim. They do outlive the container
|
||||
# though, and under one fixed name each they were shared by every project on
|
||||
# the machine: a Lua file dropped under lazy/, or a binary replaced under
|
||||
# mason/bin/, is executed by whatever project's session next runs nvim in the
|
||||
# sandbox. Scoped per project, the same way the derived image tag is.
|
||||
@test "the nvim volumes are per project, not one set shared by every project" {
|
||||
launch_setup
|
||||
run launch "$PROJ"
|
||||
[ "$status" -eq 0 ]
|
||||
slug="$(bash -c 'source "$1"; project_slug "$2"' _ "$LIB" "$PROJ")"
|
||||
[ "$(launched_env SANDBOX_NVIM_DATA)" = "dejima_nvim-data$slug" ]
|
||||
[ "$(launched_env SANDBOX_NVIM_STATE)" = "dejima_nvim-state$slug" ]
|
||||
[ "$(launched_env SANDBOX_NVIM_CACHE)" = "dejima_nvim-cache$slug" ]
|
||||
|
||||
local other; other="$(mktemp -d)"
|
||||
run launch "$other"
|
||||
[ "$status" -eq 0 ]
|
||||
[ "$(launched_env SANDBOX_NVIM_DATA)" != "dejima_nvim-data$slug" ]
|
||||
rm -rf "$other"
|
||||
}
|
||||
|
||||
# project_slug turns a leading / into -, and a Docker volume name may not
|
||||
# start with one: without the prefix every launch would fail at `compose run`
|
||||
# rather than at anything that names the cause.
|
||||
@test "the derived nvim volume names are valid Docker volume names" {
|
||||
launch_setup
|
||||
run launch "$PROJ"
|
||||
[ "$status" -eq 0 ]
|
||||
local v
|
||||
for v in SANDBOX_NVIM_DATA SANDBOX_NVIM_STATE SANDBOX_NVIM_CACHE; do
|
||||
[[ "$(launched_env "$v")" =~ ^[a-zA-Z0-9][a-zA-Z0-9_.-]*$ ]] \
|
||||
|| { echo "$v=$(launched_env "$v") is not a valid volume name"; return 1; }
|
||||
done
|
||||
}
|
||||
|
||||
@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
|
||||
|
||||
+76
-1
@@ -236,7 +236,8 @@ assert_remedy_forges_exist() {
|
||||
local forge workflow harness
|
||||
forge="$(lib config_default core.forge)"
|
||||
workflow="$(lib config_default core.workflow)"
|
||||
harness="$(lib config_default core.harness)"
|
||||
# No default to read: the shipped pair under test is forge/workflow.
|
||||
harness=claude-code
|
||||
[ "$(lib manifest_get workflow "$workflow" push)" = "off" ] \
|
||||
|| { echo "default workflow '$workflow' pushes - a fresh install has no forge to push to"; return 1; }
|
||||
run lib provider_validate_selection "$forge" "$workflow" "$harness"
|
||||
@@ -591,6 +592,80 @@ assert_remedy_forges_exist() {
|
||||
[[ "$output" != *"create"*"PR"* ]] || { echo "branch-push briefing mentions opening a PR"; return 1; }
|
||||
}
|
||||
|
||||
# #32: an implementer ran a bare `git config user.name ...` inside a
|
||||
# worktree and wrote the parent checkout's real config, misattributing three
|
||||
# commits. A worktree isolates the working tree, not `.git/config`, and the
|
||||
# briefing told the agent to use worktrees while saying nothing about that -
|
||||
# which reads as more isolation than is on offer. The same unscoped command
|
||||
# reaches core.hooksPath and credential.helper, which execute on the host.
|
||||
#
|
||||
# In the core paragraph rather than a workflow's own prose, so a workflow can
|
||||
# opt into worktrees but cannot ship them without this.
|
||||
@test "every worktree briefing says a worktree does not isolate .git/config" {
|
||||
local wf
|
||||
for wf in worktree-pr branch-push; do
|
||||
[ "$(lib manifest_get workflow "$wf" requires_worktree)" = "on" ]
|
||||
run lib briefing_compose gitea "$wf" claude-code
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
[[ "$output" == *".git/config"* ]] \
|
||||
|| { echo "$wf briefing does not mention .git/config"; return 1; }
|
||||
[[ "$output" == *"git -C"* ]] \
|
||||
|| { echo "$wf briefing does not give the scoped form"; return 1; }
|
||||
done
|
||||
}
|
||||
|
||||
# The negative half: a workflow that does not use worktrees has no reason to
|
||||
# carry the paragraph, and its presence there would mean the splice is
|
||||
# unconditional rather than opt-in.
|
||||
@test "a workflow without worktrees does not carry the worktree paragraph" {
|
||||
[ "$(lib manifest_get workflow local-only requires_worktree)" = "off" ]
|
||||
run lib briefing_compose none local-only claude-code
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
[[ "$output" != *"does not isolate"* ]]
|
||||
[[ "$output" != *"worktree MUST live"* ]]
|
||||
}
|
||||
|
||||
# The default that blocks GitHub push exists to stop an eager agent pushing to
|
||||
# somebody else's project. When the operator lifts it, the agent gains the
|
||||
# ability without the reason, so the briefing supplies the reason.
|
||||
@test "the briefing warns against pushing elsewhere when GitHub push is on" {
|
||||
DEJIMA_GITHUB_PUSH=on run lib briefing_compose gitea worktree-pr claude-code
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
[[ "$output" == *"GitHub push is enabled for this session"* ]]
|
||||
[[ "$output" == *"pointed at and nothing else"* ]]
|
||||
[[ "$output" == *"not a contribution"* ]]
|
||||
# and it says what to do instead of nothing at all
|
||||
[[ "$output" == *"say so in your reply"* ]]
|
||||
}
|
||||
|
||||
# Two points from docs/responsible-use.md that an agent can act on, as
|
||||
# opposed to the majority which are the operator's to follow. Kept short on
|
||||
# purpose: this text is in every session's context, for every user.
|
||||
@test "the briefing names the two responsible-use points an agent can act on" {
|
||||
run lib briefing_compose gitea worktree-pr claude-code
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
# a domain that acts on the world rather than serving data
|
||||
[[ "$output" == *"action rather than a source"* ]]
|
||||
[[ "$output" == *"let the operator decide"* ]]
|
||||
# and things in the working tree that are not code
|
||||
[[ "$output" == *"not"*"code"* ]]
|
||||
[[ "$output" == *".env"* ]]
|
||||
}
|
||||
|
||||
@test "the briefing says nothing about GitHub push when it is off" {
|
||||
run lib briefing_compose gitea worktree-pr claude-code
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
[[ "$output" != *"GitHub push is enabled"* ]]
|
||||
}
|
||||
|
||||
@test "bin/dejima turns the GitHub push briefing on for both routes to it" {
|
||||
# --allow-github-push, and forge = github under a pushing workflow.
|
||||
grep -q 'DEJIMA_GITHUB_PUSH=on' "$REPO_ROOT/bin/dejima"
|
||||
grep -q 'ALLOW_GITHUB_PUSH_FLAG" = 1 \]; then' "$REPO_ROOT/bin/dejima"
|
||||
grep -q 'hosts_allowlisted_by_core" ) \?= "on"\|hosts_allowlisted_by_core)" = "on"' "$REPO_ROOT/bin/dejima"
|
||||
grep -q 'export DEJIMA_GITHUB_PUSH' "$REPO_ROOT/bin/dejima"
|
||||
}
|
||||
|
||||
@test "local-only pushes nothing and blocks no branches" {
|
||||
run lib manifest_validate workflow local-only
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
|
||||
+9
-14
@@ -409,21 +409,16 @@ make_cert() {
|
||||
# shell *function* persists in the calling shell rather than scoping to
|
||||
# the call, so it would leak into every later test in this file.
|
||||
#
|
||||
# The -u pair is what makes this test about the code rather than about
|
||||
# where it runs (#56). "No cert configured" was expressed by not setting
|
||||
# the variables, which only holds where nothing else set them -
|
||||
# entrypoint.sh:211 exports both into every tls-transport session, so a
|
||||
# suite run from inside a dejima container inherited real cert paths, the
|
||||
# guard below never fired, and execution fell through to the listen. What
|
||||
# failed then was an EADDRINUSE against the session's own forwarder on
|
||||
# :443, reported as this assertion failing - the code was fine and the
|
||||
# test was reading its own environment. Unsetting them states the
|
||||
# precondition instead of assuming it.
|
||||
# The -u pair makes this about the code rather than where it runs (#56).
|
||||
# "No cert configured" used to be expressed by not setting the variables,
|
||||
# which only holds where nothing else did - entrypoint.sh exports both
|
||||
# into every tls-transport session, so a suite run inside a dejima
|
||||
# container inherited real paths, the guard never fired, and the
|
||||
# fall-through hit EADDRINUSE on :443 and was reported as this assertion
|
||||
# failing. Unsetting states the precondition instead of assuming it.
|
||||
#
|
||||
# The port override is belt-and-braces: the cert check runs before the
|
||||
# listen today, so a passing test never binds anything, but if that order
|
||||
# ever changes the fall-through lands on an unused port rather than on
|
||||
# whatever holds :443.
|
||||
# The port override is belt-and-braces: the cert check precedes the listen
|
||||
# today, so a fall-through lands on an unused port if that ever changes.
|
||||
run env -u SANDBOX_ANTHROPIC_TLS_CERT -u SANDBOX_ANTHROPIC_TLS_KEY \
|
||||
SANDBOX_ANTHROPIC_PROXY_SOCKET="$SOCK" SANDBOX_ANTHROPIC_TRANSPORT=tls \
|
||||
SANDBOX_ANTHROPIC_PROXY_PORT=18398 \
|
||||
|
||||
+74
-26
@@ -5,6 +5,25 @@
|
||||
# compromised session writes into - so most of what's here is about what it
|
||||
# refuses to do with that input.
|
||||
|
||||
# No test in this file may modify the repository's own CLIs. Two of them need
|
||||
# an exit-time audit that fails, and they get it from a temp shadow of bin/
|
||||
# (see shadow_repo) rather than by overwriting the real one. This is what
|
||||
# proves it stayed that way even if a test aborts partway through, and it is
|
||||
# checked against a hash rather than against `git status` so that it holds
|
||||
# while someone has uncommitted edits to that file.
|
||||
setup_file() {
|
||||
REMOTE_AUDIT_SUM="$(sha256sum "$BATS_TEST_DIRNAME/../bin/dejima-audit" | cut -d' ' -f1)"
|
||||
export REMOTE_AUDIT_SUM
|
||||
}
|
||||
teardown_file() {
|
||||
local now
|
||||
now="$(sha256sum "$BATS_TEST_DIRNAME/../bin/dejima-audit" | cut -d' ' -f1)"
|
||||
[ "$now" = "$REMOTE_AUDIT_SUM" ] || {
|
||||
echo "test/remote.bats modified bin/dejima-audit in the repository" >&2
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
setup() {
|
||||
REPO_ROOT="$BATS_TEST_DIRNAME/.."
|
||||
LIB="$REPO_ROOT/bin/dejima-project-lib.sh"
|
||||
@@ -19,15 +38,6 @@ teardown() {
|
||||
if [ -n "${TMUX_DIR:-}" ]; then
|
||||
TMUX_TMPDIR="$TMUX_DIR" tmux kill-server 2>/dev/null || true
|
||||
fi
|
||||
# Safety net for tests that temporarily swap out the real bin/dejima-
|
||||
# audit: each such test restores it itself right after `run`, before any
|
||||
# assertion that could short-circuit the test body, but this catches the
|
||||
# case anyway if something in between still managed to abort first.
|
||||
if [ -n "${AUDIT_BACKUP:-}" ] && [ -f "$AUDIT_BACKUP" ]; then
|
||||
cp "$AUDIT_BACKUP" "$REPO_ROOT/bin/dejima-audit"
|
||||
chmod +x "$REPO_ROOT/bin/dejima-audit"
|
||||
rm -f "$AUDIT_BACKUP"
|
||||
fi
|
||||
rm -rf "$CONFIG_DIR" "$RUNTIME_DIR" ${STUB_DIR:+"$STUB_DIR"} ${LAUNCH_DIR:+"$LAUNCH_DIR"} \
|
||||
${TMUX_DIR:+"$TMUX_DIR"} ${FAKE_HOME:+"$FAKE_HOME"}
|
||||
}
|
||||
@@ -70,6 +80,54 @@ write_remote_config() {
|
||||
[ "$output" = "$RUNTIME_DIR/dejima" ]
|
||||
}
|
||||
|
||||
# A throwaway copy of the repository whose bin/dejima-audit a test may
|
||||
# replace or delete. Everything else is a symlink to the real thing, so this
|
||||
# costs one directory of links.
|
||||
#
|
||||
# bin/ is copied rather than linked, and both reasons matter. bin/dejima
|
||||
# resolves its own location with `readlink -f`, so a symlinked copy would
|
||||
# point SCRIPT_DIR back at the repository and the stub would never be the one
|
||||
# it calls. And a symlink is actively dangerous here: `cat > $SHADOW/bin/
|
||||
# dejima-audit` follows it and truncates the real script, which is precisely
|
||||
# what this helper exists to stop. Copying removes the whole class rather
|
||||
# than one instance of it.
|
||||
#
|
||||
# Two tests here need an audit binary that fails, and they used to get one by
|
||||
# overwriting `$REPO_ROOT/bin/dejima-audit` and restoring it afterwards. That
|
||||
# left the working tree holding a two-line stub for the length of the test,
|
||||
# which is a poor thing to do to a security tool: a run killed partway, or
|
||||
# two runs of this file at once, restores the stub permanently instead of the
|
||||
# script, and a suite that passes while silently disabling the exit-time
|
||||
# audit is the exact failure this file exists to prevent. It also, reasonably
|
||||
# enough, reads like an attack to anything watching the repository for
|
||||
# changes.
|
||||
shadow_repo() {
|
||||
local root entry
|
||||
root="$(cd "$REPO_ROOT" && pwd)"
|
||||
SHADOW="$BATS_TEST_TMPDIR/shadow"
|
||||
mkdir -p "$SHADOW"
|
||||
for entry in "$root"/*; do
|
||||
[ "$(basename "$entry")" = bin ] && continue
|
||||
ln -sfn "$entry" "$SHADOW/$(basename "$entry")"
|
||||
done
|
||||
cp -a "$root/bin" "$SHADOW/bin"
|
||||
DEJIMA_BIN="$SHADOW/bin/dejima"
|
||||
}
|
||||
|
||||
# Stands in for the real audit and does nothing but fail. Self-describing
|
||||
# because a file watcher can surface it mid-test, and a bare `exit 3` sitting
|
||||
# where a security tool belongs is alarming without this comment on it.
|
||||
shadow_failing_audit() {
|
||||
cat > "$SHADOW/bin/dejima-audit" <<'EOF'
|
||||
#!/bin/bash
|
||||
# Test fixture for test/remote.bats, living in that test's temp directory
|
||||
# and never in the repository. Fails on purpose: bin/dejima must still
|
||||
# report the container's exit code and not this one.
|
||||
exit 3
|
||||
EOF
|
||||
chmod +x "$SHADOW/bin/dejima-audit"
|
||||
}
|
||||
|
||||
# A fake `docker` that records its argv, so bin/dejima's launch path can be
|
||||
# exercised with no daemon. Same approach as test/project.bats.
|
||||
stub_launch() {
|
||||
@@ -119,12 +177,13 @@ launch() {
|
||||
local conf
|
||||
conf="$(bash -c 'source "$1"; config_path' _ "$LIB")"
|
||||
mkdir -p "$(dirname "$conf")"
|
||||
printf '[core]\nssh_key = %s\n' "$LAUNCH_DIR/sandbox-key" >> "$conf"
|
||||
# No default, so a config that launches must select one.
|
||||
printf '[core]\nharness = claude-code\nssh_key = %s\n' "$LAUNCH_DIR/sandbox-key" >> "$conf"
|
||||
HOME="$LAUNCH_DIR/home" PATH="$LAUNCH_DIR:$PATH" \
|
||||
SANDBOX_TEST_DOCKER_LOG="$LAUNCH_DIR/docker-log" \
|
||||
SANDBOX_TEST_SETTINGS_COPY="$LAUNCH_DIR/settings-copy.json" \
|
||||
SSH_AUTH_SOCK= ALLOWED_DOMAINS= \
|
||||
"$REPO_ROOT/bin/dejima" "$@"
|
||||
"${DEJIMA_BIN:-$REPO_ROOT/bin/dejima}" "$@"
|
||||
}
|
||||
|
||||
run_line() { grep -- ' run --rm ' "$LAUNCH_DIR/docker-log" | tail -1; }
|
||||
@@ -280,15 +339,9 @@ done
|
||||
exit 0
|
||||
EOF
|
||||
chmod +x "$LAUNCH_DIR/docker"
|
||||
AUDIT_REAL="$REPO_ROOT/bin/dejima-audit"
|
||||
AUDIT_BACKUP="$(mktemp)"
|
||||
cp "$AUDIT_REAL" "$AUDIT_BACKUP"
|
||||
printf '#!/bin/bash\nexit 3\n' > "$AUDIT_REAL"
|
||||
chmod +x "$AUDIT_REAL"
|
||||
shadow_repo
|
||||
shadow_failing_audit
|
||||
run launch "$PROJ"
|
||||
cp "$AUDIT_BACKUP" "$AUDIT_REAL"
|
||||
chmod +x "$AUDIT_REAL"
|
||||
rm -f "$AUDIT_BACKUP"
|
||||
[ "$status" -eq 5 ]
|
||||
rm -rf "$PROJ"
|
||||
}
|
||||
@@ -309,14 +362,9 @@ done
|
||||
exit 0
|
||||
EOF
|
||||
chmod +x "$LAUNCH_DIR/docker"
|
||||
AUDIT_REAL="$REPO_ROOT/bin/dejima-audit"
|
||||
AUDIT_BACKUP="$(mktemp)"
|
||||
cp "$AUDIT_REAL" "$AUDIT_BACKUP"
|
||||
rm -f "$AUDIT_REAL"
|
||||
shadow_repo
|
||||
rm -f "$SHADOW/bin/dejima-audit"
|
||||
run launch "$PROJ"
|
||||
cp "$AUDIT_BACKUP" "$AUDIT_REAL"
|
||||
chmod +x "$AUDIT_REAL"
|
||||
rm -f "$AUDIT_BACKUP"
|
||||
[ "$status" -eq 5 ]
|
||||
rm -rf "$PROJ"
|
||||
}
|
||||
|
||||
+65
-36
@@ -29,42 +29,31 @@ IMAGE=dejima:latest
|
||||
container_name() { echo "dejima-test-$(basename "$BATS_FILE_TMPDIR")"; }
|
||||
network_name() { echo "dejima-test-net-$(basename "$BATS_FILE_TMPDIR")"; }
|
||||
|
||||
# Every container this file starts must go on network_name(), never the
|
||||
# default bridge: dns-proxy.sh forwards allowlisted names to Docker's
|
||||
# embedded resolver at 127.0.0.11, which only exists on a user-defined
|
||||
# network (compose gives the real sandbox one). On the default bridge the
|
||||
# container gets the host's nameservers instead, dnsmasq has nowhere to
|
||||
# forward, and startup dies in dns-proxy.sh - see its own check for this.
|
||||
# Every container here must go on network_name(), never the default bridge:
|
||||
# dns-proxy.sh forwards to Docker's embedded resolver at 127.0.0.11, which
|
||||
# only exists on a user-defined network. On the default bridge dnsmasq has
|
||||
# nowhere to forward and startup dies in dns-proxy.sh.
|
||||
#
|
||||
# Readiness is the *final* `sleep infinity` (entrypoint.sh's
|
||||
# SANDBOX_TEST_MODE exec), matched exactly. A bare `pgrep -x sleep` also
|
||||
# matches the short-lived sleeps of the startup sequence itself
|
||||
# (dns-proxy.sh's dig-retry loop, entrypoint.sh's forwarder-bind wait), and
|
||||
# a test that proceeds on one of those asserts against a container whose
|
||||
# firewall hasn't been installed yet - which silently turns egress
|
||||
# assertions into vacuous passes, since everything is still reachable at
|
||||
# that point. Waiting for `sleep infinity` means the whole startup sequence
|
||||
# (DNS proxy, firewall, ipsets, logging, forwarder bind) has completed.
|
||||
# Readiness is the FINAL `sleep infinity` (entrypoint.sh's SANDBOX_TEST_MODE
|
||||
# exec), matched exactly. A bare `pgrep -x sleep` also matches the startup
|
||||
# sequence's own short sleeps, and proceeding on one of those asserts against
|
||||
# a container with no firewall yet - turning egress assertions into vacuous
|
||||
# passes, since everything is still reachable.
|
||||
#
|
||||
# 120 rather than 60: init-firewall.sh's GitHub ranges fetch now retries, and a
|
||||
# run that loses two attempts before succeeding spends ~26s of the budget on
|
||||
# that call alone (#59) - which on a runner that has just spent three minutes
|
||||
# unpacking an image is enough to turn a recovery into a spurious failure. A
|
||||
# longer budget only delays reporting a genuinely stuck startup; it cannot hide
|
||||
# one, since nothing here passes on a timeout.
|
||||
# `docker wait` returns the moment the container exits, but the json-file log
|
||||
# driver writes asynchronously - so reading the log right then can return one
|
||||
# that stops mid-stream, with everything after some flush boundary missing.
|
||||
# 120s rather than 60: the GitHub ranges fetch retries, and losing two
|
||||
# attempts costs ~26s (#59), which on a runner that just spent minutes
|
||||
# unpacking an image turns a recovery into a spurious failure. A longer
|
||||
# budget only delays reporting a stuck startup; nothing here passes on a
|
||||
# timeout.
|
||||
# `docker wait` returns on exit, but the json-file log driver writes
|
||||
# asynchronously, so reading right then can return a log that stops
|
||||
# mid-stream. That presented as a container "aborting" partway through
|
||||
# init-firewall.sh's domain loop at a different domain each run, and survived
|
||||
# three fixes before the giveaway: no message from an ERR trap that cannot be
|
||||
# bypassed. Nothing had aborted; the read was short.
|
||||
#
|
||||
# That is not hypothetical. It presented as a container "aborting" partway
|
||||
# through init-firewall.sh's domain loop, at a slightly different domain each
|
||||
# run, and survived three separate fixes to that loop before the giveaway: the
|
||||
# log ended with no message from an ERR trap that cannot be bypassed and no
|
||||
# output from a retry that prints on every path. Nothing had aborted. The read
|
||||
# was short.
|
||||
#
|
||||
# Two identical consecutive reads means the driver has caught up. Deliberately
|
||||
# content-neutral - waiting for an expected string would make the wait pass or
|
||||
# Two identical consecutive reads means the driver has caught up.
|
||||
# Content-neutral on purpose - waiting for an expected string would pass or
|
||||
# fail on the very thing the caller is about to assert.
|
||||
settled_logs() {
|
||||
local container="$1" current previous=""
|
||||
@@ -408,8 +397,30 @@ dexec_node() {
|
||||
}
|
||||
|
||||
@test "GitHub is reachable on port 443" {
|
||||
run dexec curl --connect-timeout 5 -s -o /dev/null -w "%{http_code}" https://api.github.com/zen
|
||||
[ "$status" -eq 0 ]
|
||||
# Retried, and only because this is a POSITIVE assertion: more attempts
|
||||
# can only turn a pass into a pass. The unreachability assertions below
|
||||
# are never retried - there, a retry could hide a hole that opens once.
|
||||
#
|
||||
# It failed once on CI with nothing to go on: no curl exit code, no HTTP
|
||||
# status, no way to tell DNS from the ipset from the far end. Everything
|
||||
# else in that run said the firewall was right - the port-22 push grant
|
||||
# reached GitHub, so github-domains held real addresses - which left one
|
||||
# five-second connect against a live third-party service as the only
|
||||
# explanation, and no evidence to confirm it with. Hence the diagnostics
|
||||
# as much as the retries.
|
||||
local attempt code rc=0
|
||||
for attempt in 1 2 3; do
|
||||
code="$(dexec curl --connect-timeout 5 -s -o /dev/null \
|
||||
-w '%{http_code}' https://api.github.com/zen 2>&1)" && return 0
|
||||
rc=$?
|
||||
sleep 2
|
||||
done
|
||||
echo "api.github.com:443 unreachable after 3 attempts." >&2
|
||||
echo "last curl exit: $rc, last output: '$code'" >&2
|
||||
echo "exit 6 is DNS, 7 or 28 is the route or the firewall; an HTTP code" >&2
|
||||
echo "with a non-zero exit means the connection worked. Check with" >&2
|
||||
echo "'ipset list github-domains' inside the container." >&2
|
||||
false
|
||||
}
|
||||
|
||||
@test "GitHub is NOT reachable on port 22 (push transport is physically absent)" {
|
||||
@@ -607,9 +618,12 @@ dexec_node() {
|
||||
@test "only the mounted project slugs exist in the container, and an unmounted one is still creatable" {
|
||||
local test_container="dejima-test-projects-$(basename "$BATS_FILE_TMPDIR")"
|
||||
local src; src="$(mktemp -d)"
|
||||
mkdir -p "$src/own" "$src/sibling"
|
||||
mkdir -p "$src/own" "$src/sibling" "$src/fh-own"
|
||||
echo host > "$src/own/existing.jsonl"
|
||||
echo secret > "$src/sibling/other.jsonl"
|
||||
# file-history holds verbatim copies of edited files, so an unmounted
|
||||
# session directory being absent is the whole point of scoping it.
|
||||
echo 'other project source' > "$src/fh-own/aaaa@v1"
|
||||
chmod -R 777 "$src"
|
||||
docker run -d --name "$test_container" \
|
||||
--cap-add=NET_ADMIN --cap-add=NET_RAW \
|
||||
@@ -617,6 +631,7 @@ dexec_node() {
|
||||
-e SANDBOX_TEST_MODE=1 \
|
||||
-e GH_TOKEN="${GH_TOKEN:-}" \
|
||||
-v "$src/own:/home/node/.claude/projects/own" \
|
||||
-v "$src/fh-own:/home/node/.claude/file-history/own-session" \
|
||||
--entrypoint /usr/local/bin/entrypoint.sh \
|
||||
"$IMAGE" >/dev/null
|
||||
wait_ready "$test_container"
|
||||
@@ -639,6 +654,20 @@ dexec_node() {
|
||||
bash -c 'echo written > /home/node/.claude/projects/own/new.jsonl'
|
||||
[ "$(cat "$src/own/new.jsonl")" = written ]
|
||||
|
||||
# Same three properties for file-history. The middle one is what the
|
||||
# image's pre-created directory buys: without it Docker creates
|
||||
# file-history/ root-owned while setting up the mount nested in it, and
|
||||
# the session cannot record an undo entry for its own id at all.
|
||||
run docker exec -u node "$test_container" ls /home/node/.claude/file-history
|
||||
[[ "$output" != *other-session* ]]
|
||||
run docker exec -u node "$test_container" \
|
||||
bash -c 'mkdir -p /home/node/.claude/file-history/fresh-session && echo x > /home/node/.claude/file-history/fresh-session/b@v1'
|
||||
[ "$status" -eq 0 ]
|
||||
[ ! -e "$src/fresh-session" ]
|
||||
docker exec -u node "$test_container" \
|
||||
bash -c 'echo written > /home/node/.claude/file-history/own-session/c@v1'
|
||||
[ "$(cat "$src/fh-own/c@v1")" = written ]
|
||||
|
||||
docker rm -f "$test_container" >/dev/null 2>&1 || true
|
||||
rm -rf "$src"
|
||||
}
|
||||
|
||||
+188
-43
@@ -449,10 +449,9 @@ EOF
|
||||
# Directories, which is what makes them safe: a directory bind mount
|
||||
# can't detach the way a file one does, because a rename replaces an
|
||||
# entry *within* the mounted directory rather than the mount point.
|
||||
# projects/ is not in this list: it is mounted per slug by bin/dejima,
|
||||
# see the tests below. The rest is the edit-undo record and the task/plan
|
||||
# state skills write.
|
||||
grep -qE '^\s*-\s*\$\{HOME\}/\.claude/file-history:\$\{HOME\}/\.claude/file-history\s*$' "$SANDBOX_DIR/docker-compose.yml"
|
||||
# projects/ and file-history/ are not in this list: both are mounted an
|
||||
# entry at a time by bin/dejima, see the tests below. The rest is the
|
||||
# task/plan state skills write and the ~/.claude.json backups.
|
||||
grep -qE '^\s*-\s*\$\{HOME\}/\.claude/backups:\$\{HOME\}/\.claude/backups\s*$' "$SANDBOX_DIR/docker-compose.yml"
|
||||
grep -qE '^\s*-\s*\$\{HOME\}/\.claude/tasks:\$\{HOME\}/\.claude/tasks\s*$' "$SANDBOX_DIR/docker-compose.yml"
|
||||
grep -qE '^\s*-\s*\$\{HOME\}/\.claude/plans:\$\{HOME\}/\.claude/plans\s*$' "$SANDBOX_DIR/docker-compose.yml"
|
||||
@@ -464,6 +463,16 @@ EOF
|
||||
# enough to plant text a future session in another project reads as
|
||||
# background (#104). This is the assertion that the tree itself never comes
|
||||
# back; the per-slug mounts that replace it are covered below.
|
||||
# The service still refers to the static keys; only the volume Docker
|
||||
# actually creates varies, so a hardcoded name here would silently put every
|
||||
# project back on one shared set.
|
||||
@test "the nvim volumes take their names from the environment" {
|
||||
grep -qE '^\s*name:\s*\$\{SANDBOX_NVIM_DATA:-' "$SANDBOX_DIR/docker-compose.yml"
|
||||
grep -qE '^\s*name:\s*\$\{SANDBOX_NVIM_STATE:-' "$SANDBOX_DIR/docker-compose.yml"
|
||||
grep -qE '^\s*name:\s*\$\{SANDBOX_NVIM_CACHE:-' "$SANDBOX_DIR/docker-compose.yml"
|
||||
grep -q 'export SANDBOX_NVIM_DATA=' "$REPO_ROOT/bin/dejima"
|
||||
}
|
||||
|
||||
@test "docker-compose.yml never mounts the whole ~/.claude/projects tree" {
|
||||
run grep -nE '^\s*-\s*\$\{HOME\}/\.claude/projects:' "$SANDBOX_DIR/docker-compose.yml"
|
||||
[ "$status" -ne 0 ] || {
|
||||
@@ -487,6 +496,30 @@ EOF
|
||||
grep -qE '^RUN mkdir -p .*\$SANDBOX_HOME/\.claude/projects' "$SANDBOX_DIR/Dockerfile"
|
||||
}
|
||||
|
||||
# Each file-history/<session>/<hash>@v1 is a verbatim copy of an edited file,
|
||||
# so mounting the tree hands a session every project's source outright -
|
||||
# worse than the transcripts, which are a record about the work rather than
|
||||
# the work. Writable too, so tampering with another project's undo record
|
||||
# puts chosen content into a later restore there.
|
||||
@test "docker-compose.yml never mounts the whole ~/.claude/file-history tree" {
|
||||
run grep -nE '^\s*-\s*\$\{HOME\}/\.claude/file-history:' "$SANDBOX_DIR/docker-compose.yml"
|
||||
[ "$status" -ne 0 ] || {
|
||||
echo "the file-history tree is mounted whole again:" >&2
|
||||
echo "$output" >&2
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
@test "bin/dejima mounts ~/.claude/file-history one session at a time" {
|
||||
grep -q 'workspace_session_ids "$HOME/.claude/projects" "$WORKSPACE_DIR"' "$REPO_ROOT/bin/dejima"
|
||||
grep -q 'extra_mounts+=(-v "$HOME/.claude/file-history/$_sid:$HOME/.claude/file-history/$_sid")' \
|
||||
"$REPO_ROOT/bin/dejima"
|
||||
}
|
||||
|
||||
@test "the image pre-creates ~/.claude/file-history so the session mounts have a writable parent" {
|
||||
grep -qE '\$SANDBOX_HOME/\.claude/file-history' "$SANDBOX_DIR/Dockerfile"
|
||||
}
|
||||
|
||||
# One definition of "this workspace's slugs", shared by the mount and the
|
||||
# audit. If they drift, a slug the mount exposes but the audit calls foreign
|
||||
# fires a finding every session, and a slug the audit calls its own but the
|
||||
@@ -1718,6 +1751,13 @@ this line is not json'
|
||||
[ "$status" -ne 0 ]
|
||||
run grep -nE '^\s*extra_mounts\+=.*(PROJECT_STORE|config_root)' "$REPO_ROOT/bin/dejima"
|
||||
[ "$status" -ne 0 ]
|
||||
# Both greps are literal, so neither can see a mount whose path is
|
||||
# computed at run time - and two of those reached the store (#15): a
|
||||
# [mounts] readonly entry naming it, and a ~/.claude symlink pointing
|
||||
# into it. What refuses them is asserted by running the launcher, in
|
||||
# config.bats ("refused when it names the approval store"). This test
|
||||
# is the cheap half and does not stand in for that one.
|
||||
grep -q '_refuse_guarded_mount' "$REPO_ROOT/bin/dejima"
|
||||
}
|
||||
|
||||
@test "sandbox-context escapes a declared domain's dots before matching the servers file" {
|
||||
@@ -1949,12 +1989,15 @@ EOF
|
||||
grep -q 'not a verdict on who caused them' "$REPO_ROOT/README.md"
|
||||
}
|
||||
|
||||
@test "docs/security.md documents the ~/.claude/projects cross-session false-positive case" {
|
||||
# Presence-only grep, same caveat as above: confirms the specific
|
||||
# ~/.claude/projects wording landed, not that the surrounding
|
||||
# reasoning is sound.
|
||||
grep -q 'CROSS-PROJECT. finding' "$REPO_ROOT/docs/security.md"
|
||||
grep -q 'directory shared by' "$REPO_ROOT/docs/security.md"
|
||||
# Was: that the cross-session false-positive case is documented. The finding
|
||||
# itself is gone - a session can no longer write another project's slug, so
|
||||
# every one it could still see belonged to some other session - and what
|
||||
# needs documenting now is its absence, so that removing detection stays a
|
||||
# stated decision rather than something a reader has to infer from silence.
|
||||
@test "docs/security.md says why there is no cross-project finding" {
|
||||
grep -q 'no longer a .CROSS-PROJECT. finding' "$REPO_ROOT/docs/security.md"
|
||||
run grep -n 'CROSS-PROJECT history/memory write' "$REPO_ROOT/bin/dejima-audit"
|
||||
[ "$status" -ne 0 ] || { echo "the finding is back but the doc says it is gone" >&2; false; }
|
||||
}
|
||||
|
||||
# The CI disclosure is the one part of issue #72 that helps a reader whatever
|
||||
@@ -1987,6 +2030,97 @@ EOF
|
||||
# likely to skip: a project with no CI is exposed too, because a branch adds
|
||||
# the first workflow. If that goes missing, the document reads as being about
|
||||
# people who already run CI, which is the smaller group.
|
||||
# Who wrote the prose. The repository argues at length in a voice that is not
|
||||
# its maintainer's, and saying so is the difference between disclosure and
|
||||
# letting a reader assume. Pinned because it is the kind of line that goes
|
||||
# quietly in a tidy-up, and its absence is not visible from anything else.
|
||||
@test "the README frames dejima as a development environment, not only a sandbox" {
|
||||
grep -q 'sandboxed development environment' "$REPO_ROOT/README.md"
|
||||
# The half that is not containment has to be visible as such. A reader
|
||||
# who is shown only the boundary learns what dejima stops and not what
|
||||
# it is for.
|
||||
grep -q 'What it gives an agent to work with' "$REPO_ROOT/README.md"
|
||||
}
|
||||
|
||||
@test "the README does not promise a default harness" {
|
||||
# `[core] harness` has had no default since it became required - a launch
|
||||
# is refused without it - but the opening paragraph still read "Claude
|
||||
# Code by default", which is the promise that change exists to not make.
|
||||
run grep -n 'Claude Code by default' "$REPO_ROOT/README.md"
|
||||
[ "$status" -ne 0 ] || { echo "$output"; return 1; }
|
||||
}
|
||||
|
||||
@test "the README says the prose is written by agents" {
|
||||
grep -q 'A note from the developer' "$REPO_ROOT/README.md"
|
||||
grep -q 'the words are written rapidly' "$REPO_ROOT/README.md"
|
||||
# the contrast that makes both claims credible: ATTRIBUTION.md says the
|
||||
# artwork is the other way round, and the README points at it
|
||||
grep -q 'no AI tools were used to create it' "$REPO_ROOT/README.md"
|
||||
grep -q 'No AI tools were used' "$REPO_ROOT/ATTRIBUTION.md"
|
||||
}
|
||||
|
||||
# The maturity disclaimer, pinned in all three places a reader can land. A
|
||||
# project that argues at length for its own security properties is exactly the
|
||||
# one that has to say those properties are unreviewed - and it is the first
|
||||
# thing that gets quietly dropped as a project starts to feel established.
|
||||
@test "every entry point says this is unreviewed early development" {
|
||||
local f
|
||||
for f in README.md SECURITY.md docs/security.md; do
|
||||
grep -q 'reviewed by anyone else\|outside review' "$REPO_ROOT/$f" || {
|
||||
echo "$f does not say the work is unreviewed" >&2
|
||||
false
|
||||
}
|
||||
done
|
||||
grep -q 'Not ready to be depended on' "$REPO_ROOT/README.md"
|
||||
grep -q 'not proven software' "$REPO_ROOT/SECURITY.md"
|
||||
}
|
||||
|
||||
# The GitHub push default was documented as mechanism only - port 443, port 22
|
||||
# unreachable - and a reader who needed the reason inferred the wrong one: that
|
||||
# it is a containment measure against a hostile session. It is not. The
|
||||
# repositories on the other side are other people's, and the failure it
|
||||
# prevents takes no compromise, only an agent being helpful at a stranger's
|
||||
# expense. Pinned in both places a reader lands, because getting this wrong
|
||||
# leads someone to turn the flag on believing it costs them nothing.
|
||||
@test "the GitHub push default is documented with its reason, not just its mechanism" {
|
||||
# Matched on a fragment rather than the whole sentence: it wraps
|
||||
# differently in each file, and a line-based grep cannot span that.
|
||||
local f
|
||||
for f in README.md docs/security.md; do
|
||||
grep -q 'not being used to push' "$REPO_ROOT/$f" || {
|
||||
echo "$f gives the mechanism without the principle" >&2
|
||||
false
|
||||
}
|
||||
done
|
||||
# the eager-agent case, which is what makes the principle concrete - and
|
||||
# that it is not softened into a well-meaning near-miss. The unsolicited
|
||||
# pull request is the thing being prevented, not a good deed with bad
|
||||
# timing; docs/responsible-use.md is where the cost to maintainers is set
|
||||
# out, and this must not read as disagreeing with it.
|
||||
grep -q 'an eager agent, not a compromised one' "$REPO_ROOT/docs/security.md"
|
||||
grep -q 'does not make it harmless' "$REPO_ROOT/docs/security.md"
|
||||
# and the ordering a reader got backwards: CI follows from the principle
|
||||
# rather than being the reason for it
|
||||
grep -q 'not the$\|reason for it' "$REPO_ROOT/docs/security.md"
|
||||
grep -q 'dont-point-it-at-repositories-you-dont-own' "$REPO_ROOT/docs/security.md"
|
||||
}
|
||||
|
||||
# The branch convention is only a convention - nothing refuses a pull request
|
||||
# aimed at main - so the three places an agent or a contributor looks have to
|
||||
# agree, or the one that is stale becomes the instruction someone follows.
|
||||
@test "the development-branch convention is stated where it is looked for" {
|
||||
local f
|
||||
for f in .dejima.md CONTRIBUTING.md docs/development.md; do
|
||||
grep -q 'development' "$REPO_ROOT/$f" || {
|
||||
echo "$f does not mention the development branch" >&2
|
||||
false
|
||||
}
|
||||
done
|
||||
# and the one thing that is easy to get wrong: gitea-pr's default base
|
||||
grep -q -- '--base development' "$REPO_ROOT/.dejima.md"
|
||||
grep -q -- '--base development' "$REPO_ROOT/docs/development.md"
|
||||
}
|
||||
|
||||
@test "docs/ci.md says a project with no CI is exposed too" {
|
||||
grep -q 'applies to projects with no CI at all' "$REPO_ROOT/docs/ci.md"
|
||||
grep -q 'no workflows is not therefore safe' "$REPO_ROOT/docs/ci.md"
|
||||
@@ -2064,10 +2198,12 @@ EOF
|
||||
|
||||
# The wordmark is referenced by a fixed filename on purpose - see the comment
|
||||
# above the tag - which means a rename is caught here or not at all.
|
||||
# Markdown image syntax as well as <img>, and svg as well as png: the badge
|
||||
# is referenced the markdown way and is the case this exists for.
|
||||
@test "every image the README points at is committed" {
|
||||
local src missing=0
|
||||
for src in $(grep -ohE 'src="[^"]+\.png"' "$REPO_ROOT/README.md" \
|
||||
| sed 's/^src="//; s/"$//'); do
|
||||
for src in $(grep -ohE 'src="[^"]+\.(png|svg)"|\]\([^)]+\.(png|svg)\)' "$REPO_ROOT/README.md" \
|
||||
| sed 's/^src="//; s/"$//; s/^](//; s/)$//'); do
|
||||
if [ ! -f "$REPO_ROOT/$src" ]; then
|
||||
echo "README points at $src, which does not exist" >&2
|
||||
missing=1
|
||||
@@ -2265,6 +2401,7 @@ EOF
|
||||
# past validation.
|
||||
cat > "$DEJIMA_T/config/config" <<EOF
|
||||
[core]
|
||||
harness = claude-code
|
||||
ssh_key = $DEJIMA_T/sandbox-key
|
||||
forge = none
|
||||
workflow = local-only
|
||||
@@ -2535,39 +2672,26 @@ q
|
||||
}
|
||||
|
||||
@test "core names no forge" {
|
||||
# The single assertion that keeps this separation from decaying: without
|
||||
# it, the first convenient edit puts a forge name back into core. A
|
||||
# whole-file skip or a substring loose enough to stop catching a real
|
||||
# prose leak would both defeat the point, so each file gets its own
|
||||
# narrow, justified exclusion for the specific lines that are not a
|
||||
# forge coupling - everything else in scope must still match nothing.
|
||||
# What keeps core free of forge names. Each file gets a narrow, justified
|
||||
# exclusion rather than a whole-file skip; everything else must match
|
||||
# nothing. Two scans, different subjects:
|
||||
#
|
||||
# Two scans, because they have different subjects:
|
||||
# (1) `gitea` and `github.com` as case-insensitive substrings, which
|
||||
# catches a PROSE leak ("push to the Gitea remote") as much as a
|
||||
# coupling.
|
||||
# (2) Every name `provider_list forge` reports, in literal position -
|
||||
# derived from the shipped directories, so a new forge is covered the
|
||||
# day it lands. Scan 1 missed exactly that: bare `github` matches
|
||||
# neither alternative, so core could compare a forge name with this
|
||||
# test green.
|
||||
#
|
||||
# (1) `gitea` and `github.com` as substrings, case-insensitively. This is
|
||||
# the original scan, and it is what catches a PROSE leak ("push to
|
||||
# the Gitea remote") as much as a coupling.
|
||||
#
|
||||
# (2) Every name `provider_list forge` reports, in literal position.
|
||||
# Derived from the shipped directories rather than written out here,
|
||||
# so a forge is covered the day it lands - which is exactly what
|
||||
# scan 1 missed: `github` became a shipped FORGE name, and a bare
|
||||
# `github` matches neither of scan 1's alternatives, so core could
|
||||
# (and did) compare a forge name to a literal with this test green.
|
||||
#
|
||||
# "Literal position" means quoted, backticked, bracketed as a config
|
||||
# section, prefixed `forge.`, under `forges/`, or a `case` arm -
|
||||
# every shape a lookup or comparison actually takes here. It is not a
|
||||
# bare-word scan because two shipped names cannot survive one: `none`
|
||||
# is an ordinary English word this repo's prose uses constantly, and
|
||||
# `github` is also the name of a CORE feature that predates the forge
|
||||
# axis (GitHub read-only: `[github] token_file`, the `github.com`
|
||||
# credential helper, `--allow-github-push`). Operator-facing remedy
|
||||
# text naming a config VALUE (`set [core] forge = none`, `or [core]
|
||||
# forge = github`) is deliberately outside these shapes too: a rule
|
||||
# that refuses a combination has to be able to say which one works,
|
||||
# and "pick a different forge" without saying which is a worse
|
||||
# message, not a purer one.
|
||||
# "Literal position" means quoted, backticked, a config section,
|
||||
# `forge.`-prefixed, under `forges/`, or a case arm - not a bare-word
|
||||
# scan, because `none` is an ordinary English word and `github` also
|
||||
# names a core feature predating the forge axis (`[github]
|
||||
# token_file`, `--allow-github-push`). Remedy text naming a config
|
||||
# VALUE is outside these shapes too: a rule that refuses a
|
||||
# combination has to say which one works.
|
||||
local f matches fail=0 name literals="" body
|
||||
while IFS= read -r name; do
|
||||
[ -n "$name" ] || continue
|
||||
@@ -2701,6 +2825,27 @@ q
|
||||
grep -q 'DEJIMA_CI_TIER: privileged' "$wf"
|
||||
}
|
||||
|
||||
# test/remote.bats used to stand in a failing exit-time audit by overwriting
|
||||
# `$REPO_ROOT/bin/dejima-audit` and restoring it afterwards. The restore does
|
||||
# not happen if the run is killed partway, and two runs of that file at once
|
||||
# restore each other's stub, so a suite could pass while leaving the real
|
||||
# audit disabled - and a file watcher looking at the tree during the window
|
||||
# sees a security tool replaced by a two-line `exit 3`, which reads exactly
|
||||
# like an attack.
|
||||
#
|
||||
# Tests get a throwaway copy to mutate instead. Redirects and removals are
|
||||
# what actually went wrong, so those are what this pins; a test that finds
|
||||
# some third way to write into the checkout is not covered.
|
||||
@test "no test suite writes into the repository it is testing" {
|
||||
run grep -rnE '(>>?|rm +(-[a-zA-Z]+ +)*)[[:space:]]*"?\$\{?(REPO_ROOT|BATS_TEST_DIRNAME)' \
|
||||
"$REPO_ROOT"/test/*.bats
|
||||
[ "$status" -ne 0 ] || {
|
||||
echo "a test writes into the checkout; give it a copy instead:" >&2
|
||||
echo "$output" >&2
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
@test "the agent briefing teaches the project scope, not just global" {
|
||||
# The observed failure: an agent asked for a project-specific domain to be
|
||||
# added container-wide and globally, because --project was never named.
|
||||
|
||||
Reference in New Issue
Block a user