Compare commits
46
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
b7880c1084 | ||
|
|
d7900f1b03 | ||
|
|
d234e473de | ||
|
|
96c8906351 | ||
|
|
2c75ef0661 | ||
|
|
9a0c56f9a7 | ||
|
|
ab8e26437c | ||
|
|
034913205a | ||
|
|
9cb6b7b432 |
+19
@@ -123,3 +123,22 @@ 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.
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
# Attribution
|
||||
|
||||
The code in the dejima repositories is GPL-3.0-or-later; see `LICENSE`. The
|
||||
artwork is not code and is not covered by it.
|
||||
|
||||
This file is the canonical statement for the dejima marks. Other repositories
|
||||
link here rather than restating the terms.
|
||||
|
||||
## Authorship
|
||||
|
||||
All logos, assets, and artwork are human-made. No AI tools were used in their
|
||||
creation. The artist is anonymous.
|
||||
|
||||
The palette is based on [gruvbox](https://github.com/morhetz/gruvbox) by Pavel
|
||||
Pertsev, which is MIT-licensed. The colour values are gruvbox's; the marks
|
||||
drawn with them are not.
|
||||
|
||||
## What is covered
|
||||
|
||||
Both marks, in every form:
|
||||
|
||||
- the logo: the filled tiles and the outline variants, in both light-mode and
|
||||
dark-mode renderings, at any resolution, and the favicon;
|
||||
- the wordmark: the solid and outline renderings, again in both modes.
|
||||
|
||||
The terms attach to the marks rather than to this list, so renderings not
|
||||
listed here are covered too.
|
||||
|
||||
## Terms
|
||||
|
||||
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:**
|
||||
|
||||
- reproduce them unmodified to refer to dejima;
|
||||
- redistribute them unmodified as part of an unmodified copy of a repository
|
||||
that ships them.
|
||||
|
||||
**You may not, without written permission:**
|
||||
|
||||
- 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.
|
||||
+2
-3
@@ -20,9 +20,8 @@ Significant change is coming in the near future. Config keys, interfaces, the
|
||||
layout of the docs, and the boundaries between the CLIs are all still
|
||||
settling. A fork taken today will diverge from upstream quickly.
|
||||
|
||||
That is worth knowing before you build anything on top of one. If you do fork,
|
||||
pinning to a commit you have read is more useful here than tracking a branch
|
||||
that is being rewritten under you.
|
||||
If you do fork, pinning to a commit you have read is more useful here than
|
||||
tracking a branch that is being rewritten under you.
|
||||
|
||||
## If you are running an agent against this repository
|
||||
|
||||
|
||||
@@ -1,7 +1,31 @@
|
||||
# dejima
|
||||
<!-- Fixed, not preference-aware, and that is the decision rather than an
|
||||
oversight. `prefers-color-scheme` reports the browser's setting, never the
|
||||
site's, and this is read on a forge that renders dark whichever way the
|
||||
browser is set - so anything conditional shows dark ink on a dark page to
|
||||
a reader whose browser prefers light, and does it silently.
|
||||
The suffix names the mode a file is FOR, not the colour of its ink:
|
||||
-darkmode is the pale wordmark that belongs on a dark page. A mirror on a
|
||||
light-themed host swaps the filename below for dejima-wordmark-lightmode.png,
|
||||
which is committed alongside for exactly that. One line, on the mirror,
|
||||
where someone can see whether it worked.
|
||||
Every here is spacing, and it has to be a character rather than CSS
|
||||
or a presentation attribute. Gitea's sanitizer strips `style` from the tag
|
||||
and from any wrapper, `hspace`/`vspace` survive sanitizing only to be
|
||||
ignored by browsers that dropped HTML4 presentation attributes, and a bare
|
||||
<br> ends a line nothing follows, which browsers collapse to no height.
|
||||
survives as a real U+00A0: on its own line it fills a paragraph
|
||||
that would otherwise be empty, and immediately before the tag it sits
|
||||
inline and indents the image by one space. -->
|
||||
|
||||
|
||||
**Agents work best unsupervised. Bound the blast radius instead, and assume
|
||||
the agent is hostile.**
|
||||
<img src="assets/dejima-wordmark-darkmode.png" alt="dejima" height="72">
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
**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
|
||||
@@ -13,13 +37,13 @@ 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 a
|
||||
reasonable trade rather than a reckless one.
|
||||
to work without stopping to ask; the container is what makes that trade a
|
||||
reasonable one.
|
||||
|
||||
Around that core is most of what running an agent unattended actually takes:
|
||||
Around that core is most of what running an agent unattended takes:
|
||||
|
||||
- **An egress allowlist.** DNS and IP, IPv4 and IPv6. Anything not on the
|
||||
list does not connect, and the agent is told so rather than left to guess.
|
||||
list does not connect, and the agent is told so.
|
||||
- **Credentials that stay on the host.** Your Anthropic OAuth credential
|
||||
never enters the container: a host-side proxy holds it and the container
|
||||
gets a placeholder. Your SSH key doesn't either; the container sees an
|
||||
@@ -28,6 +52,10 @@ Around that core is most of what running an agent unattended actually 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.
|
||||
- **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.
|
||||
- **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
|
||||
@@ -35,12 +63,16 @@ Around that core is most of what running an agent unattended actually takes:
|
||||
- **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.
|
||||
- **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.
|
||||
- **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
|
||||
@@ -52,17 +84,37 @@ Around that core is most of what running an agent unattended actually takes:
|
||||
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 instead of
|
||||
just happening.
|
||||
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.
|
||||
**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
|
||||
@@ -112,8 +164,8 @@ road with no barrier.
|
||||
|
||||
## Using this responsibly
|
||||
|
||||
dejima bounds what a session can reach. It has nothing to say about whether
|
||||
what that session wrote should exist. That part is yours.
|
||||
dejima bounds what a session can reach. Whether what that session wrote
|
||||
should exist is your call.
|
||||
|
||||
**Check whether the project wants it first.** A growing number of projects
|
||||
state that they do not accept AI-assisted contributions. Fork-and-review is
|
||||
@@ -194,10 +246,16 @@ 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`. Ships today: `forges/gitea`, `forges/github`, `forges/none`,
|
||||
`workflows/worktree-pr`, `workflows/branch-push`, `workflows/local-only`,
|
||||
`harnesses/claude-code`. The defaults are `forges/none` and
|
||||
`workflows/local-only`: a fresh install commits locally and pushes nowhere.
|
||||
`[core] harness`.
|
||||
|
||||
Ships today:
|
||||
|
||||
- **Forges:** `none`, `gitea`, `github`
|
||||
- **Workflows:** `local-only`, `branch-push`, `worktree-pr`
|
||||
- **Harnesses:** `claude-code`
|
||||
|
||||
The defaults are `forge = none` and `workflow = local-only`: a fresh install
|
||||
commits locally and pushes nowhere.
|
||||
|
||||
See [docs/providers.md](docs/providers.md) for the manifest contract, key by
|
||||
key, and the shipped providers as worked examples.
|
||||
@@ -229,8 +287,8 @@ to `worktree-pr` or `branch-push`: a pushing workflow is refused at launch
|
||||
unless its forge resolves to somewhere to push to, so the forge is not
|
||||
optional once you choose one of them.
|
||||
|
||||
A pushing workflow also needs a git push credential, and which one you give it
|
||||
is a decision worth making deliberately rather than by default:
|
||||
A pushing workflow also needs a git push credential. Which one you give it
|
||||
matters:
|
||||
|
||||
- **`[core] ssh_key`, a key of the session's own**, belonging to an account
|
||||
that is not you. This is the one to set up. It gives the sandbox an
|
||||
@@ -306,8 +364,8 @@ merge, are what actually stop a merge from happening without you.
|
||||
What holds across all three is where they stop: none of them puts work on
|
||||
your default branch. `local-only` never leaves the machine, and the pushing
|
||||
workflows stop at a branch or at a pull request. The last step is yours in
|
||||
every case, which is a property of how the workflows are arranged rather than
|
||||
something enforced against a session that does not want to cooperate.
|
||||
every case. That comes from how the workflows are arranged, not from anything
|
||||
enforced against a session that does not want to cooperate.
|
||||
|
||||
**→ [docs/configuration.md](docs/configuration.md#the-session-briefing)** -
|
||||
the guards in full, and why they fail open.
|
||||
@@ -315,12 +373,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
|
||||
|
||||
@@ -344,8 +412,7 @@ the project mount, for passing files in and out of a session without
|
||||
touching the actual checkout. It persists across sessions since it's a real
|
||||
directory, not a container volume - but treat it as a handoff point, not
|
||||
general-purpose storage. **Clear it out between sessions.** Anything left
|
||||
there is readable by the next session, and by every session after that, which
|
||||
turns a file you forgot into something you handed over.
|
||||
there is readable by the next session, and by every session after that.
|
||||
|
||||
`~/shared`, the project mount, and anything in `[mounts] read_write` are the
|
||||
only writable host paths in the container. Everything else a session writes
|
||||
@@ -456,6 +523,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
|
||||
@@ -519,9 +594,8 @@ you what changed.
|
||||
|
||||
It is **advisory, not a block**. It fires after the session has ended, so a
|
||||
finding is something to read and clean up before your next `git` or `claude`
|
||||
in that repo, not something that was prevented. And its diff is time-windowed
|
||||
rather than session-attributed: it reports that a path changed while the
|
||||
session was running, not that the session changed it.
|
||||
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.
|
||||
Approving records the exact content, so any later change re-fires; ignoring
|
||||
@@ -551,7 +625,7 @@ credentials never enter the container, that a push to anywhere but the
|
||||
allowed remote is refused. Hundreds of tests across suites covering
|
||||
static analysis, host-side behaviour, and containers started for real.
|
||||
|
||||
They are the reason claims here are stated flatly rather than hedged.
|
||||
They are why the claims here are stated flatly.
|
||||
|
||||
→ **[docs/development.md](docs/development.md)**: the suites, what each
|
||||
covers, and how to run them.
|
||||
@@ -567,6 +641,8 @@ covers, and how to run them.
|
||||
| [ci.md](docs/ci.md) | What push access grants on your CI host, and how to bound it |
|
||||
| [remote-access.md](docs/remote-access.md) | Picking and verifying a bind address, and the SSH and web front doors |
|
||||
| [development.md](docs/development.md) | The file map, the test suites, running them |
|
||||
| [SECURITY.md](SECURITY.md) | What dejima claims, where to report a vulnerability, and what is promised in return |
|
||||
| [ATTRIBUTION.md](ATTRIBUTION.md) | Who holds the dejima marks and what may be done with them |
|
||||
|
||||
## License
|
||||
|
||||
@@ -574,3 +650,11 @@ This project is licensed under the GNU General Public License v3.0 or later
|
||||
(GPL-3.0-or-later); see [LICENSE](LICENSE) for the full text.
|
||||
|
||||
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.
|
||||
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
# Security policy
|
||||
|
||||
This is the security policy for every repository in the dejima organisation.
|
||||
Individual repositories state what is specific to them and link here for the
|
||||
rest.
|
||||
|
||||
## What dejima claims
|
||||
|
||||
It bounds what an agent session can reach. It does not make an agent safe to
|
||||
run.
|
||||
|
||||
A session runs in a container with no permission prompts, one bind-mounted
|
||||
project directory, a deny-by-default egress allowlist covering DNS as well as
|
||||
IP, and no copy of the credentials it authenticates with. That allowlist
|
||||
bounds where a prompt injection or a compromised dependency can send code and
|
||||
where it can fetch its next instruction. It does not prevent either one.
|
||||
|
||||
Nothing here holds against a container escape or a kernel bug. The in-session
|
||||
hooks are fast-fail guards, not a boundary. The exit-time audit runs after the
|
||||
session has ended and attributes nothing to it. And none of it speaks to
|
||||
whether the code a session wrote is correct, secure, or fit to merge.
|
||||
|
||||
[docs/security.md](docs/security.md) states the threat model and covers each
|
||||
of these in detail.
|
||||
|
||||
## Reporting a vulnerability
|
||||
|
||||
Email **dejima@branderson.io**. Please do not open a public issue for a
|
||||
vulnerability; the tracker is public, and a report there is a disclosure.
|
||||
|
||||
Useful things to include:
|
||||
|
||||
1. what an attacker gets;
|
||||
2. the steps to reproduce it;
|
||||
3. the repository and the commit you tested.
|
||||
|
||||
## What you can expect in return
|
||||
|
||||
Nothing is promised: no reply, no acknowledgement, no triage, no fix, and no
|
||||
timeline for any of them. Reports are read when there is time to read them.
|
||||
|
||||
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.
|
||||
|
||||
There is no paid version, no support contract, no service level, no sponsorship
|
||||
attached to them, and no commercial relationship with anyone who uses them.
|
||||
Questions may go unanswered. Issues may go unread. Nothing here is developed or
|
||||
supplied in the course of a commercial activity.
|
||||
|
||||
Whoever deploys this is responsible for the result.
|
||||
|
||||
## Supported versions
|
||||
|
||||
The tip of `main`, and nothing else. There are no maintenance branches, no
|
||||
backports and no security releases for older tags or commits.
|
||||
|
||||
## No warranty
|
||||
|
||||
The software is provided without warranty of any kind. See sections 15 and 16
|
||||
of each repository's `LICENSE`.
|
||||
@@ -0,0 +1,19 @@
|
||||
# assets
|
||||
|
||||
The dejima wordmark, in the two versions the README needs: `-darkmode` for a
|
||||
page that renders dark, `-lightmode` for one that renders light. The suffix
|
||||
names the mode a file is for, not the colour of its ink, so `-darkmode` is the
|
||||
pale one.
|
||||
|
||||
The README points at `-darkmode` unconditionally. A mirror on a light-themed
|
||||
host changes that one line; the comment above the tag says why it is not
|
||||
conditional.
|
||||
|
||||
**These are trademarks and are not covered by this repository's GPL licence.**
|
||||
Who holds them, and what may be done with them, is stated once for the whole
|
||||
organisation, in this repository:
|
||||
|
||||
[ATTRIBUTION.md](../ATTRIBUTION.md)
|
||||
|
||||
Other repositories in the organisation link to that file by absolute URL, so
|
||||
it does not move or get renamed without their links breaking with it.
|
||||
@@ -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 |
Binary file not shown.
|
After Width: | Height: | Size: 47 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 47 KiB |
+252
-104
@@ -23,6 +23,8 @@ source "$SCRIPT_DIR/dejima-project-lib.sh"
|
||||
source "$SCRIPT_DIR/dejima-provider-lib.sh"
|
||||
# shellcheck source=dejima-color-lib.sh
|
||||
source "$SCRIPT_DIR/dejima-color-lib.sh"
|
||||
# shellcheck source=dejima-slug-lib.sh
|
||||
source "$SCRIPT_DIR/dejima-slug-lib.sh"
|
||||
# fd 2: everything this script says for itself goes to stderr, so the
|
||||
# session's own stdout stays exactly what the container wrote.
|
||||
color_init 2
|
||||
@@ -59,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
|
||||
@@ -72,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
|
||||
@@ -162,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,6 +298,21 @@ DEJIMA_WORKFLOW_NAME="$(config_get core workflow)"
|
||||
DEJIMA_HARNESS_NAME="$(config_get core harness)"
|
||||
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
|
||||
@@ -266,6 +338,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 -
|
||||
@@ -286,7 +360,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
|
||||
@@ -294,7 +402,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
|
||||
@@ -311,7 +419,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
|
||||
@@ -508,12 +616,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
|
||||
@@ -570,47 +686,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"
|
||||
@@ -839,6 +937,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
|
||||
@@ -883,9 +988,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
|
||||
@@ -927,36 +1038,50 @@ if [ -d "$WORKSPACE_DIR/.git/hooks" ]; then
|
||||
extra_mounts+=(-v "$WORKSPACE_DIR/.git/hooks:$WORKSPACE_DIR/.git/hooks:ro")
|
||||
fi
|
||||
|
||||
# ~/.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/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
|
||||
[ -d "$HOME/.claude/projects/$_slug" ] || continue
|
||||
extra_mounts+=(-v "$HOME/.claude/projects/$_slug:$HOME/.claude/projects/$_slug")
|
||||
done < <(workspace_slugs_sorted "$WORKSPACE_DIR")
|
||||
|
||||
# ~/.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
|
||||
@@ -967,16 +1092,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
|
||||
@@ -1206,6 +1327,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
|
||||
@@ -1259,6 +1391,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
|
||||
@@ -1270,7 +1418,7 @@ export SANDBOX_IMAGE
|
||||
# because exit-time output can be ignored exactly once, permanently. This is
|
||||
# host-side and stays host-side: it must never be routed into the session's
|
||||
# context, which would make it a cross-session persistence channel.
|
||||
audit_slug="$(printf '%s' "$WORKSPACE_DIR" | sed 's/[^a-zA-Z0-9]/-/g')"
|
||||
audit_slug="$(ws_slug "$WORKSPACE_DIR")"
|
||||
audit_reports="$AUDIT_DIR/$audit_slug"
|
||||
# `|| true`: a workspace with no history yet (the common case - nothing has
|
||||
# ever run against it, or every past finding is already acknowledged) has no
|
||||
|
||||
+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%:*}"
|
||||
|
||||
+61
-86
@@ -12,6 +12,8 @@ set -uo pipefail
|
||||
source "$(dirname "$(readlink -f "$0")")/dejima-provider-lib.sh"
|
||||
# shellcheck source=dejima-color-lib.sh
|
||||
source "$(dirname "$(readlink -f "$0")")/dejima-color-lib.sh"
|
||||
# shellcheck source=dejima-slug-lib.sh
|
||||
source "$(dirname "$(readlink -f "$0")")/dejima-slug-lib.sh"
|
||||
# Defined empty here so every interpolation below is safe before a surface
|
||||
# picks its stream; compare (stderr) and ack (stdout) each re-init for theirs.
|
||||
color_init 1
|
||||
@@ -53,7 +55,9 @@ AUDIT_DIR="$(config_get audit dir)"
|
||||
# suggesting a restart is an entirely ordinary thing to suggest.
|
||||
AUDIT_KEEP="$(config_get audit keep)"
|
||||
|
||||
ws_slug() { printf '%s' "$1" | sed 's/[^a-zA-Z0-9]/-/g'; }
|
||||
# ws_slug and workspace_slugs come from dejima-slug-lib.sh, shared with
|
||||
# bin/dejima so the set of slugs it mounts and the set treated as this
|
||||
# workspace's own here cannot drift apart.
|
||||
|
||||
# The one report both `show` and `ack` operate on: the OLDEST unacknowledged
|
||||
# CRITICAL report if there is one, else the newest report overall.
|
||||
@@ -83,19 +87,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
|
||||
@@ -654,37 +655,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"
|
||||
@@ -1192,13 +1182,17 @@ case "$cmd" in
|
||||
|
||||
# projects diffs: a write to another project's ~/.claude/projects
|
||||
# slug during this session (transcript/memory-poisoning surface).
|
||||
# "self" = current workspace's own slug or a worktree child of it.
|
||||
current_slug="$(ws_slug "$ws")"
|
||||
# "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'*}"
|
||||
case "$slug" in
|
||||
"$current_slug"|"$current_slug"-*) continue ;;
|
||||
esac
|
||||
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 '^### ') )
|
||||
@@ -1440,47 +1434,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: "*)
|
||||
|
||||
+11
-17
@@ -22,26 +22,20 @@
|
||||
# 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'
|
||||
|
||||
+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
|
||||
|
||||
|
||||
+49
-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"
|
||||
@@ -726,6 +715,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 +747,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 +1037,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"
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
# Shared library: the ~/.claude/projects slug scheme, and which slugs belong
|
||||
# to a given workspace. Sourced by bin/dejima (to decide what to mount) and
|
||||
# bin/dejima-audit (to decide what counts as a cross-project write). Not on
|
||||
# PATH and not executable.
|
||||
#
|
||||
# One definition, because the two callers must not disagree. A slug the mount
|
||||
# exposes but the audit treats as foreign produces a finding on every ordinary
|
||||
# session; a slug the audit treats as the workspace's own but the mount does
|
||||
# not expose is a hole that nothing reports.
|
||||
|
||||
# Claude Code's own project key: the absolute cwd with every non-alphanumeric
|
||||
# replaced by a dash. Matches docker/log-shipper.sh's Loki key and
|
||||
# dejima-project-lib.sh's project_slug, which cannot source this file (one
|
||||
# runs inside the container, the other is a separate library).
|
||||
ws_slug() { printf '%s' "$1" | sed 's/[^a-zA-Z0-9]/-/g'; }
|
||||
|
||||
# The slugs belonging to $1: its own, plus one per live git worktree nested
|
||||
# underneath it. One per line, sorted, no duplicates.
|
||||
#
|
||||
# Derived from real worktree paths rather than from the slug string, and that
|
||||
# is the whole point of the function. Slugs cannot distinguish a child from a
|
||||
# sibling: `/w/proj/wt1` and `/w/proj-wt1` both slug to `-w-proj-wt1`, so a
|
||||
# `$slug-*` glob matches any sibling project whose directory name merely
|
||||
# extends this one's - on a host with `proj` and `proj-ci` checked out side by
|
||||
# side, `proj-ci` reads as part of `proj`. A path test cannot make that
|
||||
# mistake, because a sibling is not under the workspace directory.
|
||||
#
|
||||
# Listing from a worktree names the main checkout and its siblings too. Those
|
||||
# are not under $1, so they drop out here: launching against a worktree
|
||||
# exposes that worktree and nothing else, which is correct.
|
||||
workspace_slugs() {
|
||||
local ws="$1" wt
|
||||
ws="${ws%/}"
|
||||
ws_slug "$ws"
|
||||
printf '\n'
|
||||
# A path containing a newline splits into two lines that name no real
|
||||
# directory, so both fail the -d test below rather than being parsed as
|
||||
# some other worktree. git has no unambiguous non-`-z` output here.
|
||||
git -C "$ws" worktree list --porcelain 2>/dev/null \
|
||||
| sed -n 's/^worktree //p' \
|
||||
| while IFS= read -r wt; do
|
||||
case "$wt" in
|
||||
"$ws"/*) ;;
|
||||
*) continue ;;
|
||||
esac
|
||||
[ -d "$wt" ] || continue
|
||||
ws_slug "$wt"
|
||||
printf '\n'
|
||||
done
|
||||
}
|
||||
|
||||
# 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)"
|
||||
|
||||
+19
-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
|
||||
@@ -185,7 +181,11 @@ RUN mkdir /commandhistory && \
|
||||
# from the host, and Docker creates the parents of a mount point root-owned
|
||||
# if they don't already exist - which would leave the sandbox user unable to
|
||||
# write the caches and history it keeps directly in ~/.claude.
|
||||
RUN mkdir -p $SANDBOX_HOME/.ssh $SANDBOX_HOME/.claude && \
|
||||
# projects/ is in that list for the same reason one level down: it is no
|
||||
# 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 $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
|
||||
|
||||
@@ -255,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
-25
@@ -111,33 +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 what host-side `claude --resume` reads (and what
|
||||
# log-shipper.sh tails); the rest is Claude Code's edit-undo record
|
||||
# and the task/plan state skills write.
|
||||
- ${HOME}/.claude/projects:${HOME}/.claude/projects
|
||||
- ${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
|
||||
@@ -233,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
|
||||
|
||||
+31
-38
@@ -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.
|
||||
@@ -324,9 +318,9 @@ the vector is closed completely rather than bounded. The cost is one push
|
||||
after the review you were going to do anyway, and it is the only option here
|
||||
that does not depend on getting a forge's configuration right.
|
||||
|
||||
If none of the three is available for a given project, that is worth knowing
|
||||
plainly: pointing a sandbox at it means accepting that a compromised session
|
||||
can run code on whatever that repository can reach.
|
||||
If none of the three is available for a given project, say so plainly:
|
||||
pointing a sandbox at it means accepting that a compromised session can run
|
||||
code on whatever that repository can reach.
|
||||
|
||||
## Egress
|
||||
|
||||
@@ -368,7 +362,7 @@ bridge subnet is one that check silently does not cover.
|
||||
|
||||
With `runner.capacity: 1` one job occupies that network at a time. Above one,
|
||||
concurrent jobs share a broadcast domain and can reach each other's service
|
||||
containers, which is worth knowing before raising it.
|
||||
containers. Check that before raising it.
|
||||
|
||||
The DNS and firewall shape is the same one dejima uses inside its own
|
||||
container (`docker/dns-proxy.sh` and `docker/init-firewall.sh`), applied one
|
||||
@@ -499,10 +493,9 @@ What still applies:
|
||||
session commits locally and stops. The push grant *is* the CI
|
||||
code-execution grant; declining the first declines the second.
|
||||
|
||||
Gitea 1.27's **scoped workflows** are worth knowing about here: a workflow
|
||||
maintained in a central repository the sandbox cannot push to, registerable
|
||||
instance-wide, so the workflow file itself sits outside any agent branch's
|
||||
reach. Use it, but it is not a gate. Scoped workflows run *alongside* a
|
||||
Gitea 1.27's **scoped workflows** apply here: a workflow maintained in a
|
||||
central repository the sandbox cannot push to, registerable instance-wide,
|
||||
so the workflow file itself sits outside any agent branch's reach. Use it, but it is not a gate. Scoped workflows run *alongside* a
|
||||
consuming repository's own workflows rather than replacing them, so a branch
|
||||
can still add a workflow of its own.
|
||||
|
||||
|
||||
+69
-27
@@ -93,32 +93,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. Two
|
||||
consequences worth knowing:
|
||||
**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
|
||||
@@ -128,7 +123,7 @@ consequences worth knowing:
|
||||
[#97](https://gitbox.branderson.io/dejima/dejima/issues/97).
|
||||
- **Upgrading is an approval, not a version bump.** A new release is a new
|
||||
digest, so it goes through `dejima-dep add` and `dejima-project approve`
|
||||
again - which is the point, not friction to route around.
|
||||
again. That is the mechanism working, not friction to route around.
|
||||
|
||||
The URL's character set is deliberately narrower than URLs allow: the spec is
|
||||
interpolated into a `RUN` line that runs as root at build time, so anything
|
||||
@@ -136,12 +131,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 +323,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):
|
||||
@@ -348,9 +357,9 @@ session is meant to write to and whose contents should outlive the container:
|
||||
read_write = ~/specs
|
||||
```
|
||||
|
||||
Both lists mount at the host path they name, which is the point: a convention
|
||||
that says "specs live in `~/specs`" keeps working inside the container without
|
||||
being rewritten. That is the difference from `[core] share_dir`, which is
|
||||
Both lists mount at the host path they name, so a convention that says
|
||||
"specs live in `~/specs`" keeps working inside the container without being
|
||||
rewritten. That is the difference from `[core] share_dir`, which is
|
||||
mounted at `~/shared` regardless of where it lives on the host because its job
|
||||
is to be a well-known handoff point.
|
||||
|
||||
@@ -366,6 +375,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 +483,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
|
||||
|
||||
+2
-1
@@ -34,6 +34,7 @@ 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-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 |
|
||||
@@ -95,7 +96,7 @@ Docker host: `runtime.bats` proves that a service bound to the host's
|
||||
loopback is unreachable from inside a container while one bound to the
|
||||
bridge gateway *is* reachable. The second half is a positive control - it is
|
||||
what stops the first from passing in an environment where the container
|
||||
simply reaches nothing. That pair is the ttyd front door's whole containment
|
||||
reaches nothing at all. That pair is the ttyd front door's whole containment
|
||||
argument, so run it before trusting a front door you just set up.
|
||||
|
||||
Every mutation in `runtime.bats` (firewall rules, `ipset` entries) happens
|
||||
|
||||
+15
-24
@@ -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
|
||||
|
||||
|
||||
+30
-37
@@ -44,7 +44,7 @@ What it does **not** add is a way *out* of a sandbox. Configured as
|
||||
described below, a compromised session cannot reach `ttyd`, cannot reach
|
||||
your proxy, and gains nothing from the fact that your phone can: it has a
|
||||
route to exactly one host address, and neither front door is on it. That
|
||||
property is worth stating precisely because it is the one a plausible-looking
|
||||
property needs stating exactly, because it is the one a plausible-looking
|
||||
mistake inverts - a proxy bound to `0.0.0.0` or to a bridge address is a
|
||||
front door opening *inward*, and that is a full sandbox escape rather than a
|
||||
degradation. [Verifying it](#verifying-it) is two commands, and worth
|
||||
@@ -304,7 +304,7 @@ also what catches a distro `ttyd.service` still running on that port, which
|
||||
is why this is worth doing even if you are confident about the proxy. `test/runtime.bats`
|
||||
asserts this same containment property against a live container (with a
|
||||
positive control on the same path, so a passing result can't come from the
|
||||
container simply reaching nothing), and it passes on a real Docker host.
|
||||
container reaching nothing), and it passes on a real Docker host.
|
||||
|
||||
## SSH front door
|
||||
|
||||
@@ -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`
|
||||
@@ -801,8 +794,8 @@ the next one is worth checking.
|
||||
`grep -c dejima-notify /etc/dejima-settings.json` from inside should be 2.
|
||||
That file is passed to Claude Code as `--settings` by `claude-wrapper.sh`;
|
||||
if it is missing entirely, this container was not started by `dejima`.
|
||||
3. **Does the marker reach the host?** This is the one worth knowing, because
|
||||
it splits the chain cleanly in half. From inside the sandbox:
|
||||
3. **Does the marker reach the host?** This is the one to check first: it
|
||||
splits the chain cleanly in half. From inside the sandbox:
|
||||
|
||||
```sh
|
||||
: > "$DEJIMA_NOTIFY_DIR/waiting" && sleep 2 && ls "$DEJIMA_NOTIFY_DIR"
|
||||
|
||||
+37
-43
@@ -57,10 +57,9 @@ at all.
|
||||
That second one is not a judgement about how good the containment is, and it
|
||||
does not get better with a version bump. There is no containment that fully
|
||||
prevents exfiltration: a determined attacker with code execution and any
|
||||
outbound path will find a way, and the allowlist narrows the paths rather
|
||||
than closing them. So the question is never whether you trust dejima enough.
|
||||
It is whether you can accept the risk of the contents leaving. If you cannot,
|
||||
do not hand them to an agent that could send them.
|
||||
outbound path will find a way. So the question is not whether you trust
|
||||
dejima enough, but whether you can accept the risk of the contents leaving.
|
||||
If you cannot, do not hand them to an agent that could send them.
|
||||
|
||||
## Look at what else is in the directory.
|
||||
|
||||
@@ -84,11 +83,9 @@ directory not to hand over.
|
||||
## An allowlist entry is a grant, not a convenience.
|
||||
|
||||
For every allowlist entry you give a dejima agent, consider what an attacker
|
||||
could do with it. That is the whole rule. What follows is why the answer
|
||||
varies so much between entries that look similar.
|
||||
|
||||
The variable is what happens if the call is wrong, whether wrong means a
|
||||
prompt-injected instruction or an ordinary agent mistake.
|
||||
could do with it. The answer varies enormously between entries that look
|
||||
similar, and what it turns on is what happens if the call is wrong, whether
|
||||
wrong means a prompt-injected instruction or an ordinary agent mistake.
|
||||
|
||||
A package registry or a docs site can be wrong without being expensive. A
|
||||
session that fetches the wrong package version, or scrapes a stale or
|
||||
@@ -101,10 +98,9 @@ different shape entirely. A single call against any of them is the action,
|
||||
not a step toward one you'll review later: the deployment already ran, the
|
||||
row is already written or gone, the charge already happened. There is no
|
||||
diff downstream that catches it, because nothing about it passes through
|
||||
your repository at all. Allowlisting one of these isn't giving the session a
|
||||
faster way to fetch data; it's giving an unattended process standing
|
||||
authority to act on the world, unsupervised, the same authority you'd think
|
||||
twice about handing an unsupervised person.
|
||||
your repository at all. Allowlisting one of these gives an unattended process
|
||||
standing authority to act on the world, the same authority you would think
|
||||
twice about handing a person.
|
||||
|
||||
Ask, before adding an entry: if this call happened by mistake, is the result
|
||||
something I fix in the next commit, or something I have to explain to
|
||||
@@ -125,15 +121,14 @@ some vaguer sense. In this repository, dejima-ci-hardener resolves the
|
||||
CI-side egress
|
||||
allowlist by reading `.dejima/allowed-domains` from `origin/main`, never
|
||||
from a branch or a local checkout, and treats whatever it finds there as
|
||||
already reviewed. That trust isn't a policy statement, it's load-bearing:
|
||||
the only way content lands on `main` is through a pull request you merged,
|
||||
so reading from `main` is how the tool avoids asking a branch what it's
|
||||
allowed to do. Merging something onto `main` doesn't just ship it to users.
|
||||
It feeds a trust anchor that other tooling, this one and potentially future
|
||||
ones, is entitled to rely on without re-checking. Keeping insecure or broken
|
||||
code off `main` is what makes that reliance justified in the first place,
|
||||
and that job is yours; nothing about running the agent in a sandbox does it
|
||||
for you.
|
||||
already reviewed. That trust is load-bearing: the only way content lands on
|
||||
`main` is through a pull request you merged, so reading from `main` is how
|
||||
the tool avoids asking a branch what it's allowed to do. So merging onto
|
||||
`main` ships it to users and feeds a trust anchor that other tooling, this
|
||||
one and potentially future ones, is entitled to rely on without re-checking.
|
||||
Keeping insecure or broken code off `main` is what makes that reliance
|
||||
justified, and that job is yours; nothing about running the agent in a
|
||||
sandbox does it for you.
|
||||
|
||||
## Give it its own identity, then own what you merge.
|
||||
|
||||
@@ -150,10 +145,10 @@ actually holds a session to a smaller set of permissions than yours.
|
||||
|
||||
Once the identity is separate, every commit and pull request the session
|
||||
produces is visibly labeled as the agent's own work, not yours, in your
|
||||
project's history. That visibility doesn't shrink your responsibility for
|
||||
it; it just makes it legible. Anyone reading your `main` later sees your
|
||||
name on the merge commit regardless of whose name is on the authored one,
|
||||
and will hold you to it accordingly.
|
||||
project's history. That visibility makes your responsibility legible without
|
||||
shrinking it. Anyone reading your `main` later sees your name on the merge
|
||||
commit regardless of whose name is on the authored one, and will hold you to
|
||||
it accordingly.
|
||||
|
||||
## Understand what you ship.
|
||||
|
||||
@@ -162,27 +157,27 @@ cheap enough that reading every diff starts to feel like the slow part of
|
||||
the loop, and skipping it works fine, repeatedly, until the one time it
|
||||
doesn't. A reviewer who reads every change catches most of what's wrong with
|
||||
it. A reviewer who only reads the ones that felt risky catches none of the
|
||||
ones that didn't feel risky, which is precisely the shape a subtle bug, or a
|
||||
ones that didn't feel risky, which is the shape a subtle bug, or a
|
||||
prompt-injected change dressed up as something ordinary, takes. Nothing
|
||||
about a faster loop changes what engineering judgment is for.
|
||||
|
||||
## This is not a blastproof box.
|
||||
|
||||
The useful way to read this rule is as a statement about what kind of
|
||||
failure to plan for, not as a disclaimer to skim past. dejima's containment
|
||||
is Linux namespaces and a small capability set around the session, the same
|
||||
kind of isolation a container gives any process, not a virtual machine
|
||||
boundary; it shares the host kernel with everything else on the machine.
|
||||
Read this rule as a statement about what kind of failure to plan for.
|
||||
dejima's containment is Linux namespaces and a small capability set around
|
||||
the session, the same kind of isolation a container gives any process, not a
|
||||
virtual machine boundary; it shares the host kernel with everything else on
|
||||
the machine.
|
||||
That buys real things (see [docs/security.md](security.md) for the specific
|
||||
list: no working `sudo`, a deny-by-default firewall, read-only mounts over
|
||||
the surfaces that matter most), and every one of them assumes the container
|
||||
itself stays intact. A determined attacker, or a single container-escape
|
||||
vulnerability, doesn't erode that protection gradually; it removes every
|
||||
layer described in that document at once, because they all sit on the same
|
||||
kernel. The operating assumption to hold isn't "the walls will probably
|
||||
hold." It's "if this container is ever compromised at the kernel level,
|
||||
treat everything reachable from this host account as compromised with it,"
|
||||
which is a very different thing to plan around than an ordinary bug.
|
||||
kernel. So the assumption to hold is not that the walls will probably hold.
|
||||
It is that if this container is ever compromised at the kernel level,
|
||||
everything reachable from this host account should be treated as compromised
|
||||
with it, which is a very different thing to plan around than an ordinary bug.
|
||||
|
||||
## dejima's security says nothing about your software's.
|
||||
|
||||
@@ -195,12 +190,12 @@ SQL injection, hand-roll a broken authentication check, hardcode a secret,
|
||||
or pull in a compromised dependency, because those are properties of the
|
||||
code itself, decided at the moment it's written, not something a sandbox
|
||||
sitting around the process that wrote it can retrofit afterward. If what got
|
||||
built inside dejima turns out to be insecure, that isn't a containment
|
||||
failure; containment was never the control that was supposed to catch it.
|
||||
built inside dejima turns out to be insecure, containment was never the
|
||||
control that was supposed to catch it.
|
||||
|
||||
**It is not something a future agent can retrofit either**, and that is the
|
||||
more useful half of this. "We will do a security pass later" was already a
|
||||
weak plan before agents made it cheap to say. Early architectural decisions
|
||||
**It is not something a future agent can retrofit either.** "We will do a
|
||||
security pass later" was already a weak plan before agents made it cheap to
|
||||
say. Early architectural decisions
|
||||
close off the fixes for whole classes of problem: where trust boundaries sit,
|
||||
what is authenticated and where, what data crosses which process, what a
|
||||
single compromised component reaches. A later pass can find a missing bounds
|
||||
@@ -217,5 +212,4 @@ the one thing here no tooling in this repository is aiming at.
|
||||
|
||||
The same practices you'd apply to code from any other source, review,
|
||||
linting, dependency scanning, tests aimed at the failure modes you care
|
||||
about, still have to happen here. dejima doesn't stand in for any of them;
|
||||
it operates on a different axis entirely.
|
||||
about, still have to happen here. dejima does not stand in for any of them.
|
||||
|
||||
+230
-182
@@ -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
|
||||
@@ -113,9 +119,9 @@ table goes back to being decorative. `write:repository` and `write:issue` are
|
||||
the scopes its token needs (README, "Gitea/Forgejo PR workflow"), and nothing
|
||||
about that changes for a collaborator.
|
||||
|
||||
One consequence is worth knowing before you meet it, because it presents as a
|
||||
misconfiguration and the obvious fix is the wrong one. Parts of Gitea's API
|
||||
are gated on *ownership* rather than on permission: `GET
|
||||
One consequence presents as a misconfiguration, and the obvious fix is the
|
||||
wrong one. Parts of Gitea's API are gated on *ownership* rather than on
|
||||
permission: `GET
|
||||
/repos/{owner}/{repo}/actions/runs` answers a non-owner with `"user should be
|
||||
the owner of the repo"` no matter which scopes the token carries. Read that
|
||||
way it looks like a scope error, and the reflex is to widen the token. That
|
||||
@@ -213,26 +219,34 @@ hands to whichever session starts against it. See
|
||||
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.
|
||||
under `~/.claude/projects` (see below). 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 +260,15 @@ 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.
|
||||
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 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.
|
||||
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 +282,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,101 +309,114 @@ 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 worth knowing 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 read-only, unlike the code-carrying `~/.claude`
|
||||
directories - deliberately. Scoping a read-only mount to "the
|
||||
current project's slug"
|
||||
would break the sandbox's own worktree workflow: a session running in
|
||||
`WORKSPACE/.worktrees/<branch>` writes to a *different* projects slug
|
||||
than the parent repo, so a slug-scoped overlay would either block that
|
||||
legitimate write or have to be widened until it stopped protecting
|
||||
anything. Instead, cross-project writes are **detected, not prevented**:
|
||||
the exit-time audit above flags any write during the session to another
|
||||
project's `~/.claude/projects/<slug>` directory, excluding the current
|
||||
project's own slug and its worktree children. That's after-the-fact
|
||||
reporting of transcript/memory poisoning in *other* projects, not a
|
||||
block on it happening. Residual even within that: the current project's
|
||||
own transcripts/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. A
|
||||
read-only mount for `projects/` remains a possible future option if
|
||||
Claude Code is confirmed to fail cleanly (not hang, as `~/.claude.json`
|
||||
did) when a write to it is blocked. This is also the sharpest case of
|
||||
the time-window caveat above: `~/.claude/projects` is a single
|
||||
directory shared by *every* Claude Code session on the host, not scoped
|
||||
to the one being audited, so an entirely innocent concurrent session
|
||||
working on some unrelated project can produce a `CROSS-PROJECT` finding
|
||||
here that reads like the audited session poisoned another project's
|
||||
history - it didn't, it just happened to be running at the same time.
|
||||
**not** mounted as a tree. A session gets the workspace's own slug
|
||||
directory plus one for each live git worktree nested under it,
|
||||
read-write, and nothing else. Every other project's slug is *absent*
|
||||
from the container rather than present-but-unwritable. Agent memory is
|
||||
loaded into context at session start, so a write into another project's
|
||||
`memory/` is text a future session there reads as established background
|
||||
rather than as input - and until #104 the mount alone was enough to do
|
||||
that, with no escape and no credential involved.
|
||||
|
||||
The set is derived from real worktree paths
|
||||
(`bin/dejima-slug-lib.sh`), not from the slug string, because a slug
|
||||
cannot tell a child from a sibling: `/w/proj/wt1` and `/w/proj-wt1`
|
||||
produce the same one. A `<slug>-*` glob therefore also matches any
|
||||
project checked out beside this one whose directory name merely extends
|
||||
it, which is an ordinary layout rather than a contrived one.
|
||||
|
||||
`projects/` itself stays container-local, so a slug that is not mounted
|
||||
is still writable during the session and just does not survive it. That
|
||||
is what lets Claude Code create a slug it does not find, which is the
|
||||
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**: the audit runs host-side over
|
||||
the whole tree, so narrowing the mount does not narrow it, and it now
|
||||
covers what the mount cannot reach - concurrent *host* sessions. Residual:
|
||||
the workspace's own transcripts and memory stay writable, since
|
||||
`claude --resume` needs that; and detection keys off each slug's max file
|
||||
mtime, so an edit to a file that is not already the newest goes unflagged.
|
||||
The time-window caveat applies most sharply here, because on the host
|
||||
`~/.claude/projects` is a single
|
||||
directory shared by *every* Claude Code session - so an innocent concurrent
|
||||
session elsewhere can produce a `CROSS-PROJECT` finding that reads like
|
||||
poisoning and isn't.
|
||||
- `~/.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.
|
||||
- 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
|
||||
@@ -408,51 +430,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 simply isn't mounted: `~/.claude` is not shared
|
||||
as a whole (see the mount inventory below), so there is no path from
|
||||
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
|
||||
@@ -506,13 +522,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
|
||||
@@ -531,8 +555,7 @@ hands to whichever session starts against it. See
|
||||
`statusline.sh` are mounted read-only at `/etc/dejima-claude/` and
|
||||
installed into `~/.claude` by `entrypoint.sh` as root-owned, unwritable
|
||||
files. A session can still unlink and replace them - `~/.claude` belongs
|
||||
to it - but only its own copy, which was the whole point of making them
|
||||
read-only.
|
||||
to it - but only its own copy, which is why they are read-only.
|
||||
|
||||
The reason for the split is that a **single-file bind mount silently
|
||||
detaches**. It is pinned to the dentry at its mount point, so when the
|
||||
@@ -930,10 +953,9 @@ sessions you have ever run, which is how a Loki instance stops being usable.
|
||||
Filter on it with `| json | session=` as above rather than expecting a
|
||||
selector.
|
||||
|
||||
Two things are absent by design and worth knowing before you go looking for
|
||||
them. **Tool results are never shipped**, only invocations, so a query will
|
||||
show you that a session ran `Bash` with certain arguments and never what came
|
||||
back. **Thinking blocks are never shipped either.** Both dominate the volume
|
||||
Two things are absent by design. **Tool results are never shipped**, only
|
||||
invocations, so a query will show you that a session ran `Bash` with certain
|
||||
arguments and never what came back. **Thinking blocks are never shipped either.** Both dominate the volume
|
||||
and neither was what opting in asked for. Long values are clipped, so a very
|
||||
large prompt or tool input ends in `...[truncated]` rather than being dropped.
|
||||
|
||||
@@ -962,6 +984,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
|
||||
|
||||
@@ -162,6 +162,10 @@ cmp_() { "$AUDIT" compare "$WS" "$STATE" 2>&1; }
|
||||
}
|
||||
|
||||
@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
|
||||
@@ -170,6 +174,21 @@ cmp_() { "$AUDIT" compare "$WS" "$STATE" 2>&1; }
|
||||
[[ "$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"
|
||||
|
||||
+237
-18
@@ -1045,24 +1045,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 +1126,238 @@ 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"
|
||||
}
|
||||
|
||||
@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)"
|
||||
|
||||
@@ -908,6 +908,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
|
||||
|
||||
@@ -591,6 +591,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 \
|
||||
|
||||
+72
-25
@@ -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() {
|
||||
@@ -124,7 +182,7 @@ launch() {
|
||||
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 +338,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 +361,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"
|
||||
}
|
||||
|
||||
+113
-35
@@ -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)" {
|
||||
@@ -594,6 +605,73 @@ dexec_node() {
|
||||
rm -rf "$src"
|
||||
}
|
||||
|
||||
# #104: the whole ~/.claude/projects tree used to be mounted read-write, so a
|
||||
# session could write another project's transcripts and agent memory. Memory
|
||||
# is loaded into context at session start, which makes that a write into a
|
||||
# future session's premises rather than its input.
|
||||
#
|
||||
# Three properties, and the second is the one that decides whether scoping is
|
||||
# viable at all: a slug that is absent has to be creatable, because that is
|
||||
# what Claude Code does on a first run against a path with no history. If the
|
||||
# parent were read-only instead, this is where it would hang the way a
|
||||
# blocked ~/.claude.json write did.
|
||||
@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" "$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 \
|
||||
--network "$(network_name)" \
|
||||
-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"
|
||||
|
||||
# the sibling project is absent, not merely unwritable
|
||||
run docker exec -u node "$test_container" test -e /home/node/.claude/projects/sibling
|
||||
[ "$status" -ne 0 ]
|
||||
run docker exec -u node "$test_container" ls /home/node/.claude/projects
|
||||
[[ "$output" != *sibling* ]]
|
||||
|
||||
# a slug with no history yet can still be created, and stays in the
|
||||
# container: it is not on the host afterwards
|
||||
run docker exec -u node "$test_container" \
|
||||
bash -c 'mkdir -p /home/node/.claude/projects/fresh && echo x > /home/node/.claude/projects/fresh/a.jsonl'
|
||||
[ "$status" -eq 0 ]
|
||||
[ ! -e "$src/fresh" ]
|
||||
|
||||
# and the mounted slug is the one that does reach the host
|
||||
docker exec -u node "$test_container" \
|
||||
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"
|
||||
}
|
||||
|
||||
@test "a staged statusline.sh is copied in root-owned, and replacing it never reaches the host" {
|
||||
# Companion to the overlay-dirs test above, but for the config
|
||||
# bin/dejima stages at /etc/dejima-claude for entrypoint.sh to copy into
|
||||
|
||||
+212
-36
@@ -449,16 +449,93 @@ 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 what host-side `claude --resume` reads; the rest is the
|
||||
# edit-undo record and the task/plan state skills write.
|
||||
grep -qE '^\s*-\s*\$\{HOME\}/\.claude/projects:\$\{HOME\}/\.claude/projects\s*$' "$SANDBOX_DIR/docker-compose.yml"
|
||||
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"
|
||||
grep -qE '^\s*-\s*\$\{HOME\}/\.claude/sessions:\$\{HOME\}/\.claude/sessions\s*$' "$SANDBOX_DIR/docker-compose.yml"
|
||||
}
|
||||
|
||||
# The whole tree carries every project's transcripts and agent memory, and
|
||||
# memory is loaded into context at session start - so write access to it is
|
||||
# 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 ] || {
|
||||
echo "the projects tree is mounted whole again:" >&2
|
||||
echo "$output" >&2
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
@test "bin/dejima mounts ~/.claude/projects one slug at a time" {
|
||||
grep -q 'workspace_slugs_sorted "$WORKSPACE_DIR"' "$REPO_ROOT/bin/dejima"
|
||||
grep -q 'extra_mounts+=(-v "$HOME/.claude/projects/$_slug:$HOME/.claude/projects/$_slug")' \
|
||||
"$REPO_ROOT/bin/dejima"
|
||||
}
|
||||
|
||||
# Docker creates the parents of a mount point root-owned when they do not
|
||||
# already exist. projects/ is now such a parent, so without this the session
|
||||
# could not create a slug of its own - which is exactly what it must do when
|
||||
# the workspace has no history yet.
|
||||
@test "the image pre-creates ~/.claude/projects so the slug mounts have a writable parent" {
|
||||
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
|
||||
# mount does not expose is a hole nothing reports.
|
||||
@test "bin/dejima and bin/dejima-audit share one slug definition" {
|
||||
grep -q 'source "$SCRIPT_DIR/dejima-slug-lib.sh"' "$REPO_ROOT/bin/dejima"
|
||||
grep -q 'dejima-slug-lib.sh"$' "$REPO_ROOT/bin/dejima-audit"
|
||||
# and neither keeps a private copy of the sed scheme
|
||||
run grep -n "s/\[^a-zA-Z0-9\]/-/g" "$REPO_ROOT/bin/dejima" "$REPO_ROOT/bin/dejima-audit"
|
||||
[ "$status" -ne 0 ] || {
|
||||
echo "an inline copy of the slug scheme is back:" >&2
|
||||
echo "$output" >&2
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
@test "~/.claude config files are staged for copying, not mounted at their real paths" {
|
||||
# A copy can't be detached by a host-side rename at all. entrypoint.sh
|
||||
# installs them root-owned and read-only; see its comment and the mount
|
||||
@@ -1674,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" {
|
||||
@@ -1943,6 +2027,65 @@ 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 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"
|
||||
}
|
||||
|
||||
@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"
|
||||
@@ -2009,6 +2152,31 @@ EOF
|
||||
[ "$missing" -eq 0 ]
|
||||
}
|
||||
|
||||
# Other repositories in the organisation link to these two by absolute URL
|
||||
# rather than keeping copies, so the terms cannot drift into two divergent
|
||||
# statements. A rename here is a 404 in a repository whose tests cannot see
|
||||
# this one.
|
||||
@test "the org-wide policy files other repositories link to keep their names" {
|
||||
[ -f "$REPO_ROOT/SECURITY.md" ]
|
||||
[ -f "$REPO_ROOT/ATTRIBUTION.md" ]
|
||||
}
|
||||
|
||||
# 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|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
|
||||
fi
|
||||
done
|
||||
[ "$missing" -eq 0 ]
|
||||
}
|
||||
|
||||
# --- the labels this repo's own workflow asks for ---
|
||||
#
|
||||
# Whether a job container gets the Docker socket is settled by the runner's
|
||||
@@ -2125,7 +2293,7 @@ EOF
|
||||
# This catches address- and hostname-shaped leaks only. A personal domain
|
||||
# that looks like any other public one is not mechanically distinguishable
|
||||
# and still rests on review.
|
||||
SCANNED_PATHS=(README.md docs docker bin forges workflows harnesses hooks install.sh systemd .gitea)
|
||||
SCANNED_PATHS=(README.md SECURITY.md ATTRIBUTION.md docs docker bin forges workflows harnesses hooks install.sh systemd .gitea)
|
||||
|
||||
@test "no private or link-local address is used as a documentation example" {
|
||||
run grep -rnIE '\b(10|127\.0\.0\.[2-9]|169\.254|192\.168|172\.(1[6-9]|2[0-9]|3[01]))\.[0-9]{1,3}\.[0-9]{1,3}\b' \
|
||||
@@ -2468,39 +2636,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
|
||||
@@ -2634,6 +2789,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