Compare commits
75
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
011675ab8b | ||
|
|
7581ac0351 | ||
|
|
02bda50b4d | ||
|
|
7174d1147f | ||
|
|
7274236a7c | ||
|
|
48bcd32c0e | ||
|
|
7695325c2e | ||
|
|
0707cfc1dd | ||
|
|
d49b9eaa08 | ||
|
|
821e15283a | ||
|
|
eedf855bc4 | ||
|
|
4df8ca1dfa | ||
|
|
0b8d1e5817 | ||
|
|
0ac60b7a59 | ||
|
|
34b0b1bec2 | ||
|
|
38045f87c8 | ||
|
|
d7d808425c | ||
|
|
5b8f6e71be | ||
|
|
ef8cc12d9f | ||
|
|
d5b9830631 | ||
|
|
bd0f5ddab6 | ||
|
|
d4a45cc3f7 | ||
|
|
54962bb165 | ||
|
|
1efcc42294 | ||
|
|
13e8c39d0f | ||
|
|
1ba13df4df | ||
|
|
779a644e63 | ||
|
|
799dd3b6eb | ||
|
|
26bc014b3f | ||
|
|
0eafdf1d78 | ||
|
|
1f92e7a8aa | ||
|
|
c5c23682af | ||
|
|
3825d23074 | ||
|
|
1140ef056d | ||
|
|
3b1a7098d9 | ||
|
|
8cfd763b79 | ||
|
|
a63c964df9 | ||
|
|
fa1d67018b | ||
|
|
bd21eb5db0 | ||
|
|
bc05990e33 | ||
|
|
e837634c1f | ||
|
|
283527fc6d | ||
|
|
efb92cb703 | ||
|
|
77a298264c | ||
|
|
d31c707217 | ||
|
|
0e09060555 | ||
|
|
e7f5a9b541 | ||
|
|
54b00d73a6 | ||
|
|
ecb886f658 | ||
|
|
aa11bdd27b | ||
|
|
32841b4a2e | ||
|
|
760af9946f | ||
|
|
b7f641594b | ||
|
|
cd8ac7be0f | ||
|
|
8438daa07e | ||
|
|
631b73ae5f | ||
|
|
d0052161ef | ||
|
|
8a26412716 | ||
|
|
0a323ed230 | ||
|
|
db989b3277 | ||
|
|
3a6acf102a | ||
|
|
18cff7eece | ||
|
|
da0920d13f | ||
|
|
e31c651a5e | ||
|
|
7079fca1cd | ||
|
|
4dfa6e3927 | ||
|
|
b7880c1084 | ||
|
|
d7900f1b03 | ||
|
|
d234e473de | ||
|
|
96c8906351 | ||
|
|
2c75ef0661 | ||
|
|
9a0c56f9a7 | ||
|
|
ab8e26437c | ||
|
|
034913205a | ||
|
|
9cb6b7b432 |
+68
@@ -123,3 +123,71 @@ the code was being worked on:
|
||||
Both are fixed by stating the precondition (`env -u`, a stubbed `ss`) rather
|
||||
than assuming it. When a test fails only in one environment, suspect this before
|
||||
suspecting the code — and when writing one, set what you depend on.
|
||||
|
||||
## What lands on `main` here is a trust anchor, not just a release
|
||||
|
||||
`docs/responsible-use.md` is this project's own policy, and two of its points
|
||||
bind while you are working *on* dejima rather than merely with it.
|
||||
|
||||
**`.dejima/allowed-domains` is read from `origin/main` by
|
||||
dejima-ci-hardener**, which treats whatever it finds there as already
|
||||
reviewed - that is how a CI runner decides what a job may reach. Adding a
|
||||
domain to that file is not a convenience for one branch; it widens what every
|
||||
future CI job on that runner can talk to, from the moment it merges. Say what
|
||||
a domain is for in the pull request, and expect the question.
|
||||
|
||||
**Security here is not a later pass.** This repository's product is an
|
||||
argument that a boundary holds. A change that quietly weakens one - a mount
|
||||
that widens, a check that moves after the thing it guards, a test that stops
|
||||
asserting what its name claims - is a defect in the product, not a rough edge
|
||||
to tidy up afterwards. If you notice one while doing something else, say so
|
||||
rather than leaving it for a pass that may not come.
|
||||
|
||||
## Write the comment that prevents the bug, not the one that shows your work
|
||||
|
||||
The prose here gets a compression pass periodically, and the same things get
|
||||
cut every time. Save that pass the work:
|
||||
|
||||
- **Keep** the issue number, the failure mode, and why a check is shaped the
|
||||
way it is. Someone has to be able to not reintroduce the bug.
|
||||
- **Cut how you found it.** Which CI run failed, how many attempts it took,
|
||||
what you suspected first: that is your story, not the reader's.
|
||||
- **Cut the second and third consequence.** State the first. A reader who
|
||||
needs the chain can follow it.
|
||||
- **Cut anything the code directly below already says.**
|
||||
- **Cut meta-commentary** - "the useful way to read this is", "what follows
|
||||
is why". Say the thing instead.
|
||||
- **No numbers that drift.** Test counts, directory sizes, how many files a
|
||||
sweep found: wrong within a month, and nothing checks them. This applies to
|
||||
pull request descriptions too, which are read once and then stop being
|
||||
true.
|
||||
- **One place.** An edge case belongs in `docs/`, not also in the README -
|
||||
the README is what someone needs on first contact.
|
||||
|
||||
This is not an argument for thinner documentation: the note on voice in the
|
||||
README prefers thorough over polished, deliberately. It is an argument
|
||||
against padding. A paragraph carrying one fact should be one sentence.
|
||||
|
||||
The blocks that survive every pass are the ones where the argument *is* the
|
||||
content: the `O_NOFOLLOW` open in `dejima-project-lib.sh`, the id-derivation
|
||||
anchoring in `dejima-audit`'s `ack`, `--check-origin` in `dejima-ttyd`.
|
||||
Length is not the problem; length carrying no fact is.
|
||||
|
||||
## Target `development`, not `main`
|
||||
|
||||
Branches here are opened against `development`, which merges to `main`
|
||||
periodically. `main` stays releasable while work lands quickly; nothing else
|
||||
about the workflow changes.
|
||||
|
||||
`gitea-pr` defaults its base to the repository's default branch, so say so:
|
||||
|
||||
```sh
|
||||
gitea-pr create --base development --title ... --body-file -
|
||||
```
|
||||
|
||||
Forgetting is the likely mistake, and it is recoverable - a pull request's
|
||||
base can be changed after the fact, in the web UI or through the API. Check
|
||||
what you opened rather than assuming.
|
||||
|
||||
Rebase onto `origin/development` too, for the same reason: rebasing onto
|
||||
`main` puts anything already merged to `development` back in your diff.
|
||||
|
||||
@@ -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.
|
||||
+8
-3
@@ -14,15 +14,20 @@ different, **fork it and make the change in your fork**. Nothing here depends
|
||||
on an upstream maintainer accepting a patch. The fork is yours to run, and
|
||||
running it is the point.
|
||||
|
||||
## Branches
|
||||
|
||||
Work is opened against `development`, which merges to `main` periodically.
|
||||
`main` is what a reader should expect to be releasable; `development` is
|
||||
where it happens first.
|
||||
|
||||
## Expect it to move
|
||||
|
||||
Significant change is coming in the near future. Config keys, interfaces, the
|
||||
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,17 @@ 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`. `harness` has no default and must be set, so a later
|
||||
release cannot change which one you get.
|
||||
|
||||
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 +288,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
|
||||
@@ -280,7 +339,7 @@ dejima ~/repos/some-project
|
||||
|
||||
By default the container's entry point is bare `claude` (always running with
|
||||
`--dangerously-skip-permissions`, regardless of how it's invoked; see
|
||||
`docker/claude-wrapper.sh`), so a fresh clone of this repo works with no
|
||||
`docker/harness-wrapper.sh`), so a fresh clone of this repo works with no
|
||||
editor setup at all.
|
||||
|
||||
The loop, in the part every workflow shares: point it at a project, ask for a
|
||||
@@ -306,8 +365,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 +374,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 +413,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 +524,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,11 +595,10 @@ 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.
|
||||
`dejima-audit show` indexes what is waiting; `dejima-audit ack` walks it.
|
||||
Approving records the exact content, so any later change re-fires; ignoring
|
||||
records the surface, so that path is fine whatever it later contains; and
|
||||
skipping decides nothing, which means you are asked again next session.
|
||||
@@ -551,7 +626,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 +642,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 +651,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 |
+345
-124
@@ -23,6 +23,10 @@ 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"
|
||||
# shellcheck source=dejima-harness-lib.sh
|
||||
source "$SCRIPT_DIR/dejima-harness-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 +63,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 +135,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 +231,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
|
||||
@@ -239,8 +298,42 @@ fi
|
||||
DEJIMA_FORGE_NAME="$(config_get core forge)"
|
||||
DEJIMA_WORKFLOW_NAME="$(config_get core workflow)"
|
||||
DEJIMA_HARNESS_NAME="$(config_get core harness)"
|
||||
# No default: claude-code was one, and a release changing it would move an
|
||||
# operator who never chose to a different harness on upgrade.
|
||||
if [ -z "$DEJIMA_HARNESS_NAME" ]; then
|
||||
echo "[core] harness is not set in $(config_path)." >&2
|
||||
echo "There is no default, so a later release cannot change your choice." >&2
|
||||
echo "" >&2
|
||||
echo " [core]" >&2
|
||||
echo " harness = claude-code" >&2
|
||||
echo "" >&2
|
||||
echo "Available: $(provider_list harness | tr '\n' ' ')" >&2
|
||||
exit 1
|
||||
fi
|
||||
provider_validate_selection "$DEJIMA_FORGE_NAME" "$DEJIMA_WORKFLOW_NAME" "$DEJIMA_HARNESS_NAME" || exit 1
|
||||
|
||||
# The selected harness's plugin: what dejima does for this harness that no
|
||||
# manifest value could state. Loaded once the selection is validated, and
|
||||
# before anything that calls a hook. A manifest without a plugin is refused
|
||||
# rather than treated as a lighter kind of support - see "Adding a new
|
||||
# harness" in docs/development.md.
|
||||
harness_plugin_load "$DEJIMA_HARNESS_NAME"
|
||||
|
||||
# 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 +359,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 +381,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 +423,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 +440,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 +637,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 +707,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"
|
||||
@@ -637,11 +756,32 @@ SANDBOX_AUDIT_STATE="$(mktemp "${TMPDIR:-/tmp}/dejima-audit.XXXXXX")"
|
||||
# and SANDBOX_DERIVED_CONTEXT is one of the `rm -rf` arguments.
|
||||
DEJIMA_NOTIFY_DIR=""
|
||||
DEJIMA_SESSION_FILE=""
|
||||
SANDBOX_REMOTE_SETTINGS_TMP=""
|
||||
SANDBOX_NVIM_LOCKFILE=""
|
||||
SANDBOX_DERIVED_DOCKERFILE=""
|
||||
SANDBOX_DERIVED_CONTEXT=""
|
||||
|
||||
# Names each host-side task in cleanup() as it starts. Between the container
|
||||
# exiting and the operator's shell prompt returning, dejima audits every
|
||||
# surface the session could have written to and reads back the transcripts it
|
||||
# left; on a real project that is seconds of nothing on screen, and a
|
||||
# terminal that prints nothing reads as one that has hung. The remedy an
|
||||
# operator reaches for then is Ctrl-C, which is precisely when the audit
|
||||
# must not be interrupted.
|
||||
#
|
||||
# Only once a snapshot exists, which is the same condition `dejima-audit
|
||||
# compare` uses to decide it has anything to do: an exit before the session
|
||||
# ever started is instant, and its last line is the error that caused it -
|
||||
# announcing work that is not happening would push that off the screen.
|
||||
#
|
||||
# stderr, like everything else this trap prints, and host-side only: none of
|
||||
# it is ever routed back into a session's context.
|
||||
exit_step() {
|
||||
# `return 0` rather than letting the test be the last command: a nonzero
|
||||
# status from anything in an EXIT trap replaces the script's own.
|
||||
[ -s "${SANDBOX_AUDIT_STATE:-}" ] && printf 'dejima: %s...\n' "$1" >&2
|
||||
return 0
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
# Every rm below is guarded with `|| true`: under set -e, a failing
|
||||
# command in an EXIT trap replaces the shell's own exit status, so none
|
||||
@@ -673,7 +813,7 @@ cleanup() {
|
||||
fi
|
||||
rm -f "$SANDBOX_SETTINGS_FILE" "$SANDBOX_CLAUDE_JSON_FILE" "${SANDBOX_NVIM_LOCKFILE:-}" "${SANDBOX_FAKE_CREDENTIALS_FILE:-}" "${SANDBOX_DERIVED_DOCKERFILE:-}" "${SANDBOX_BRIEFING_FILE:-}" || true
|
||||
rm -rf "${SANDBOX_GITCONFIG_DIR:-}" "${SANDBOX_ANTHROPIC_PROXY_DIR:-}" "${SANDBOX_DERIVED_CONTEXT:-}" || true
|
||||
rm -f "${DEJIMA_SESSION_FILE:-}" "${SANDBOX_REMOTE_SETTINGS_TMP:-}" || true
|
||||
rm -f "${DEJIMA_SESSION_FILE:-}" || true
|
||||
# Unlike the silent `|| true` guards above, this removal (and, before
|
||||
# the audit below was moved in, the last command of an EXIT trap) warns
|
||||
# out loud when it fails: the marker directory is bind-mounted
|
||||
@@ -685,6 +825,15 @@ cleanup() {
|
||||
# dejima-notify-watch's marker consumption.
|
||||
rm -rf "${DEJIMA_NOTIFY_DIR:-}" \
|
||||
|| echo "warning: could not remove the marker directory ${DEJIMA_NOTIFY_DIR:-}" >&2
|
||||
exit_step "session ended - keeping what it compacted away"
|
||||
# Keep whatever the session compacted away, into a store nothing mounts.
|
||||
# `|| true` for the same reason every other line in this trap has it: a
|
||||
# failure here must not replace the container's exit status.
|
||||
#
|
||||
# Ahead of the audit rather than after it, so the audit keeps the last
|
||||
# word (see below): this prints a line of its own whenever it keeps
|
||||
# something, and that line was landing under the verdict.
|
||||
"$SCRIPT_DIR/dejima-epitaph" save "$WORKSPACE_DIR" || true
|
||||
# Last writer, structurally: the operator reads bottom-up from their
|
||||
# shell prompt, so anything printed after the verdict pushes it out of
|
||||
# view. The marker-directory warning above is the one line a session can
|
||||
@@ -694,6 +843,7 @@ cleanup() {
|
||||
# STATE is dropped from the rm -f above and freed only after compare has
|
||||
# read it, so this function's own cleanup can't race the audit that
|
||||
# needs it - and its removal is guarded too, so it stays truly last.
|
||||
exit_step "checking what changed on the host while it ran"
|
||||
DEJIMA_RW_MOUNTS="$DEJIMA_RW_MOUNTS" "$SCRIPT_DIR/dejima-audit" compare "$WORKSPACE_DIR" "$SANDBOX_AUDIT_STATE" || true
|
||||
rm -f "$SANDBOX_AUDIT_STATE" || true
|
||||
}
|
||||
@@ -770,6 +920,42 @@ case "$SANDBOX_CMD" in
|
||||
;;
|
||||
esac
|
||||
export SANDBOX_CMD
|
||||
|
||||
# What the wrapper inside the container needs to shadow the harness's binary
|
||||
# and pin its flags, resolved here because the manifests are host-side data
|
||||
# the container never sees. Every value is the selected harness's own; none
|
||||
# of it is written down in the image.
|
||||
#
|
||||
# SANDBOX_HARNESS_COMMAND is the name to shadow, and is the manifest's rather
|
||||
# than [core] command: overriding the latter to `nvim` changes what dejima
|
||||
# launches, not which harness is wrapped, and an editor-driven session that
|
||||
# then runs the harness from a terminal must still get the flags.
|
||||
SANDBOX_HARNESS_COMMAND="$(manifest_get harness "$DEJIMA_HARNESS_NAME" command)"
|
||||
SANDBOX_HARNESS_REAL="$(manifest_get harness "$DEJIMA_HARNESS_NAME" command_path)"
|
||||
SANDBOX_HARNESS_ARGS="$(manifest_get harness "$DEJIMA_HARNESS_NAME" no_confirm_args)"
|
||||
# Named in full rather than templated: the settings layer's container-side
|
||||
# path is dejima's own fixed contract (docker-compose.yml mounts it there),
|
||||
# not a value that varies per run, and {{...}} in a manifest belongs to the
|
||||
# briefing composer's substitution rather than to a second mechanism that
|
||||
# merely looks like it.
|
||||
SANDBOX_HARNESS_SETTINGS="$(manifest_get harness "$DEJIMA_HARNESS_NAME" settings_args)"
|
||||
# Unattended operation is R1 of the capability bar, and a harness whose
|
||||
# manifest names no way to ask for it would run asking for confirmation on
|
||||
# every tool call, against a container built on the premise that it does not.
|
||||
# That fails silently and looks like the harness hanging, so refuse instead.
|
||||
if [ -z "$SANDBOX_HARNESS_ARGS" ]; then
|
||||
echo "Harness '$DEJIMA_HARNESS_NAME' declares no no_confirm_args." >&2
|
||||
echo "dejima runs unattended; a harness that stops to ask cannot be run that" >&2
|
||||
echo "way. See \"What a harness must be able to do\" in docs/development.md." >&2
|
||||
exit 1
|
||||
fi
|
||||
export SANDBOX_HARNESS_COMMAND SANDBOX_HARNESS_REAL SANDBOX_HARNESS_ARGS \
|
||||
SANDBOX_HARNESS_SETTINGS
|
||||
|
||||
# [core] command defaults to the selected harness's own command rather than
|
||||
# to a literal in core's schema, which is where `claude` used to sit.
|
||||
[ -n "$SANDBOX_CMD" ] || SANDBOX_CMD="$SANDBOX_HARNESS_COMMAND"
|
||||
export SANDBOX_CMD
|
||||
# docker-compose.yml/entrypoint.sh compare these against "1"/"0", not
|
||||
# "on"/"off" - config_get_bool is what produces that, config_get would pass
|
||||
# "on"/"off" straight through and (among other things) silently disable the
|
||||
@@ -839,6 +1025,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 +1076,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 +1126,30 @@ 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.
|
||||
# The state this harness keeps on the host, and which of it this workspace
|
||||
# is entitled to see. Harness-specific by nature - which directories exist,
|
||||
# and what decides that one of their entries belongs to this workspace, is a
|
||||
# fact about the harness rather than about dejima - so it lives in the
|
||||
# harness plugin, which declares each mount through emit_mount. The guarded-
|
||||
# store refusal runs there, at the point of declaration, rather than being
|
||||
# something each plugin has to remember. See bin/dejima-harness-lib.sh.
|
||||
harness_hook harness_state_mounts "$WORKSPACE_DIR"
|
||||
|
||||
# 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 +1160,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
|
||||
@@ -1186,26 +1375,42 @@ if remote_enabled; then
|
||||
# own ID.
|
||||
extra_mounts+=(-e "DEJIMA_RUN_ID=$DEJIMA_RUN_ID")
|
||||
|
||||
# The notify hook is registered per session, into the override layer from
|
||||
# above and nowhere else: registering it in the operator's real
|
||||
# settings.json would run it for every sandbox and for the host's own
|
||||
# Claude Code, which share that file, when it is only meaningful for a
|
||||
# session that has a marker directory. $SANDBOX_SETTINGS_FILE is
|
||||
# already a throwaway mktemp file, so this rewrite cannot reach the
|
||||
# operator's real settings.json; it is mounted read-only, so the
|
||||
# session cannot unregister itself either. Claude Code merges hooks
|
||||
# across settings layers rather than letting the higher one replace the
|
||||
# lower, so these are added to whatever the operator already has rather
|
||||
# than displacing it. $HOME is left unexpanded for Claude Code to resolve
|
||||
# at hook-run time - inside the container, not here - matching the
|
||||
# convention install.sh already uses.
|
||||
SANDBOX_REMOTE_SETTINGS_TMP="$(mktemp "${TMPDIR:-/tmp}/dejima-settings-remote.XXXXXX.json")"
|
||||
jq '.hooks.Notification = ((.hooks.Notification // []) + [{"matcher": "", "hooks": [{"type": "command", "command": "$HOME/.claude/hooks/dejima-notify.sh waiting"}]}])
|
||||
| .hooks.Stop = ((.hooks.Stop // []) + [{"matcher": "", "hooks": [{"type": "command", "command": "$HOME/.claude/hooks/dejima-notify.sh done"}]}])' \
|
||||
"$SANDBOX_SETTINGS_FILE" > "$SANDBOX_REMOTE_SETTINGS_TMP"
|
||||
mv "$SANDBOX_REMOTE_SETTINGS_TMP" "$SANDBOX_SETTINGS_FILE"
|
||||
# Registered per session, into the override layer from above and nowhere
|
||||
# else: putting it in the operator's real settings.json would run it for
|
||||
# every sandbox and for the host's own agent, which share that file, when
|
||||
# it is only meaningful for a session that has a marker directory.
|
||||
# $SANDBOX_SETTINGS_FILE is already a throwaway mktemp file, so this
|
||||
# rewrite cannot reach the operator's real settings; it is mounted
|
||||
# read-only, so the session cannot unregister itself either.
|
||||
#
|
||||
# How a harness is told to run something on those two events is the
|
||||
# harness's own business - Claude Code merges hook entries across
|
||||
# settings layers, another harness may take a config key or an
|
||||
# environment variable - so the writing is the plugin's. Whether it
|
||||
# happens at all is core's, and is gated on the harness having declared
|
||||
# the notify guard.
|
||||
if harness_has_guard notify; then
|
||||
harness_hook harness_register_notify "$SANDBOX_SETTINGS_FILE"
|
||||
fi
|
||||
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
|
||||
|
||||
# Named here, once, at the moment the operator is choosing to proceed. Silent
|
||||
# when the harness hosts every guard, so this is not a line anyone learns to
|
||||
# scroll past - it appears only when a protection they have elsewhere is
|
||||
# genuinely not in force here.
|
||||
harness_report_guards "$DEJIMA_HARNESS_NAME"
|
||||
|
||||
# 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 +1464,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 +1491,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%:*}"
|
||||
|
||||
+366
-184
@@ -12,6 +12,10 @@ 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"
|
||||
# shellcheck source=dejima-harness-lib.sh
|
||||
source "$(dirname "$(readlink -f "$0")")/dejima-harness-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
|
||||
@@ -19,17 +23,36 @@ config_validate || exit 1
|
||||
|
||||
cmd="${1:-}"; ws_arg="${2:-}"; state="${3:-}"
|
||||
|
||||
PROJECTS_DIR="$(config_get harness.claude-code projects_dir)"
|
||||
PROJECTS_DIR="$(harness_config_get projects_dir)"
|
||||
|
||||
# The one top-level ~/.claude entry docker-compose.yml's read-only overlays
|
||||
# don't cover (settings.json/CLAUDE.md/plugins/hooks/skills/commands/agents/
|
||||
# output-styles all are) - settings.json's statusLine.command runs it on the
|
||||
# HOST, unsandboxed, and bin/dejima's own read-only mount onto it
|
||||
# doesn't reliably protect it when it's a symlink (the common case - runc
|
||||
# resolves the mount destination through the symlink; confirmed against a
|
||||
# live container, see test/runtime.bats). Detection here is the compensating
|
||||
# control for that gap. Overridable for tests.
|
||||
STATUSLINE_PATH="$(config_get harness.claude-code statusline_path)"
|
||||
# The paths the selected harness causes to run ON THE HOST, unsandboxed,
|
||||
# from configuration a session can reach - Claude Code's statusline is the
|
||||
# instance that exists. Each is watched on its own rather than folded into
|
||||
# hash_paths, because bin/dejima's read-only mount does not reliably protect
|
||||
# one that is a symlink (the common case - runc resolves the mount
|
||||
# destination through the link; confirmed against a live container, see
|
||||
# test/runtime.bats), so detection here is the compensating control.
|
||||
#
|
||||
# Declared by the harness's plugin, not by its manifest: the labels below
|
||||
# are the fixed prefixes `ack` strips to recover an id, which makes them
|
||||
# part of a parser's grammar rather than data. See bin/dejima-harness-lib.sh.
|
||||
harness_plugin_load "$(config_get core harness)"
|
||||
host_exec_load
|
||||
|
||||
# With no harness selected there is no statusline path to watch, and the
|
||||
# compensating control above quietly covers nothing. bin/dejima refuses to
|
||||
# start without [core] harness, so a snapshot in this state means the audit
|
||||
# was driven by hand against a config dejima itself would reject - say so
|
||||
# rather than reporting a clean run over a surface that was never examined.
|
||||
# A silently narrower audit is worse than a noisy one.
|
||||
if [ "${#DEJIMA_HOST_EXEC[@]}" -eq 0 ] && [ -z "$(config_get core harness)" ]; then
|
||||
case "$cmd" in
|
||||
snapshot|compare)
|
||||
echo "dejima audit: no [core] harness is set, so harness-specific surfaces" >&2
|
||||
echo " (whatever this harness runs on the host) are NOT checked." >&2
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# This script's own repo root - the sandbox's trusted computing base (see
|
||||
# tcb_snapshot below) is only monitored when the workspace being audited IS
|
||||
@@ -53,7 +76,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.
|
||||
@@ -69,6 +94,24 @@ ws_slug() { printf '%s' "$1" | sed 's/[^a-zA-Z0-9]/-/g'; }
|
||||
# startup warning may well reach for `show` first, and showing them a
|
||||
# different report than the one being warned about sends them looking for a
|
||||
# finding that is not in it.
|
||||
# How many reports are waiting, and how many of those are CRITICAL. `ack`
|
||||
# walks one report at a time by design - each is a separate session's worth of
|
||||
# findings - but saying nothing about the rest is what made clearing a backlog
|
||||
# feel like the tool was stuck on one.
|
||||
# A report's stable identity: its basename without the -CRITICAL marker, which
|
||||
# `ack` removes once every CRITICAL in it has been dealt with.
|
||||
report_key() {
|
||||
local b; b="$(basename "$1")"
|
||||
printf '%s' "${b%-CRITICAL.txt}.txt" | sed 's/\.txt\.txt$/.txt/'
|
||||
}
|
||||
|
||||
count_reports() {
|
||||
local dir="$1"
|
||||
printf '%s %s\n' \
|
||||
"$(find "$dir" -maxdepth 1 -name '*.txt' 2>/dev/null | wc -l)" \
|
||||
"$(find "$dir" -maxdepth 1 -name '*-CRITICAL.txt' 2>/dev/null | wc -l)"
|
||||
}
|
||||
|
||||
select_report() {
|
||||
local dir="$1" r
|
||||
r="$(find "$dir" -maxdepth 1 -name '*-CRITICAL.txt' 2>/dev/null | sort | head -1)"
|
||||
@@ -83,19 +126,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
|
||||
@@ -350,11 +390,14 @@ withhold() { printf '%s\n' "$2" > "$1" 2>/dev/null || true; }
|
||||
# compare against.
|
||||
existed_before() {
|
||||
local rid="$1" range enc
|
||||
if host_exec_is_id "$rid"; then
|
||||
range='/^### hostexec/,$p'; enc="$(enc_id "${rid#*:}")"
|
||||
else
|
||||
case "$rid" in
|
||||
statusline:*) range='/^### statusline/,$p'; enc="$(enc_id "${rid#statusline:}")" ;;
|
||||
tcb:*) range='/^### tcb/,/^### statusline/p'; enc="$(enc_id "${rid#tcb:}")" ;;
|
||||
tcb:*) range='/^### tcb/,/^### hostexec/p'; enc="$(enc_id "${rid#tcb:}")" ;;
|
||||
*) range='/^### paths/,/^### projects/p'; enc="$(enc_id "$rid")" ;;
|
||||
esac
|
||||
fi
|
||||
sed -n "$range" "$state" | grep -v '^### ' | cut -f1 | grep -qxF -- "$enc"
|
||||
}
|
||||
|
||||
@@ -499,11 +542,15 @@ promote_finding() {
|
||||
# Mirrors current_content_for_id's dispatch; gitconfig ids never reach
|
||||
# here (promote_gitconfig returned above), and stat does not follow a
|
||||
# symlink, whose rendering is its target string rather than its content.
|
||||
# A host-execution surface's path comes from the harness plugin that
|
||||
# declared it, not from the workspace.
|
||||
local _hp
|
||||
if _hp="$(host_exec_path_for_id "$rid")"; then src="$_hp"; else
|
||||
case "$rid" in
|
||||
statusline:*) src="$STATUSLINE_PATH" ;;
|
||||
tcb:*) src="$ws/${rid#tcb:}" ;;
|
||||
*) src="$ws/$rid" ;;
|
||||
esac
|
||||
fi
|
||||
csz="$(stat -c %s "$src" 2>/dev/null)" || csz=0
|
||||
if [ "$csz" -gt "$STAGE_FILE_CAP" ]; then
|
||||
withhold "$sc/$key.withheld" "change too large to retain - compare by hand"
|
||||
@@ -568,6 +615,11 @@ baseline_suppresses() {
|
||||
}
|
||||
|
||||
# Whether `ack` has already walked this report to its end. Recorded in the
|
||||
# Recorded against the report's name with any -CRITICAL suffix stripped,
|
||||
# because a report that sheds that suffix later in the same run would
|
||||
# otherwise carry a marker naming a file that no longer exists - and re-prompt
|
||||
# forever, which is the failure the marker exists to prevent.
|
||||
#
|
||||
# baseline file, alongside the approvals, as `reviewed <report-basename>`:
|
||||
# the file is already the per-workspace record of what the operator has
|
||||
# decided, already parsed line-by-line, and already tolerant of verbs it does
|
||||
@@ -637,7 +689,7 @@ id_severity() {
|
||||
gitconfig:*) k="${1#gitconfig:}"; k="${k#worktree:}"
|
||||
is_exec_key "$k" && { echo CRITICAL; return; }
|
||||
case "$k" in alias.*) echo CRITICAL ;; *) echo WARNING ;; esac ;;
|
||||
statusline:*|tcb:*) echo CRITICAL ;;
|
||||
tcb:*) echo CRITICAL ;;
|
||||
*) path_severity "$1" ;;
|
||||
esac
|
||||
}
|
||||
@@ -654,37 +706,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"
|
||||
@@ -787,6 +828,22 @@ hash_paths() {
|
||||
} | sort
|
||||
}
|
||||
|
||||
# Whether `git status` would show a change to $2 inside repo $1. One call per
|
||||
# candidate path rather than one listing compared as a set: the candidate list
|
||||
# is what changed this session, which is small, and --porcelain on a path is
|
||||
# cheap. A path git does not know about at all (never added, and ignored)
|
||||
# prints nothing, which is exactly the invisible case.
|
||||
#
|
||||
# A repo git refuses to read (not a repository, or a dubious-ownership
|
||||
# refusal) makes this return false, so every change is reported rather than
|
||||
# silently downgraded - failing towards the noisier answer, not the quieter
|
||||
# one.
|
||||
git_shows_change() {
|
||||
local repo="$1" path="$2" out
|
||||
out="$(git -C "$repo" status --porcelain --untracked-files=all -- "$path" 2>/dev/null)" || return 1
|
||||
[ -n "$out" ]
|
||||
}
|
||||
|
||||
tcb_snapshot() {
|
||||
# encoded-path<TAB>sha for the sandbox's own trusted computing base, but
|
||||
# only when $ws IS the sandbox's repo (self-mount) - see AUDIT_REPO_ROOT.
|
||||
@@ -868,13 +925,14 @@ gitconfig_sha_for_key() {
|
||||
# that namespace, so the id/sha pair ack records is exactly what
|
||||
# baseline_suppresses will later compare against.
|
||||
current_sha_for_id() {
|
||||
local id="$1" f
|
||||
local id="$1" f _hp
|
||||
if _hp="$(host_exec_path_for_id "$id")"; then f="$_hp"; else
|
||||
case "$id" in
|
||||
gitconfig:*) gitconfig_sha_for_key "$ws" "${id#gitconfig:}"; return ;;
|
||||
statusline:*) f="$STATUSLINE_PATH" ;;
|
||||
tcb:*) f="$ws/${id#tcb:}" ;;
|
||||
*) f="$ws/$id" ;;
|
||||
esac
|
||||
fi
|
||||
# -e alone follows a symlink and misses one whose target is gone - but a
|
||||
# dangling symlink is exactly what hash_entry() below can still hash (the
|
||||
# link's target string, never the target's content), so excluding it here
|
||||
@@ -895,13 +953,14 @@ DISPLAY_LINE_CAP=40
|
||||
# as their target, because that is what hash_entry() hashes for them - the
|
||||
# link's own content is not what changed.
|
||||
current_content_for_id() {
|
||||
local id="$1" f
|
||||
local id="$1" f _hp
|
||||
if _hp="$(host_exec_path_for_id "$id")"; then f="$_hp"; else
|
||||
case "$id" in
|
||||
gitconfig:*) gitconfig_value_for_key "$ws" "${id#gitconfig:}" ;;
|
||||
statusline:*) f="$STATUSLINE_PATH" ;;
|
||||
tcb:*) f="$ws/${id#tcb:}" ;;
|
||||
*) f="$ws/$id" ;;
|
||||
esac
|
||||
fi
|
||||
case "$id" in gitconfig:*) return ;; esac
|
||||
[ -e "$f" ] || [ -L "$f" ] || return 1
|
||||
if [ -L "$f" ]; then
|
||||
@@ -970,10 +1029,13 @@ show_capped() {
|
||||
# Never sanitized through a path that would make it unfindable: it is the
|
||||
# audit's OWN text about a location, not workspace-controlled content.
|
||||
source_hint() {
|
||||
local id="$1"
|
||||
local id="$1" _hp
|
||||
if _hp="$(host_exec_path_for_id "$id")"; then
|
||||
printf '%s\n' " (full text: $(sanitize "$_hp"))"
|
||||
return
|
||||
fi
|
||||
case "$id" in
|
||||
gitconfig:*) printf '%s\n' " (full value: git -C $ws config --get $(sanitize "${id#gitconfig:}"))" ;;
|
||||
statusline:*) printf '%s\n' " (full text: $STATUSLINE_PATH)" ;;
|
||||
tcb:*) printf '%s\n' " (full text: $ws/$(sanitize "${id#tcb:}"))" ;;
|
||||
*) printf '%s\n' " (full text: $ws/$(sanitize "$id"))" ;;
|
||||
esac
|
||||
@@ -1021,34 +1083,33 @@ render_finding() {
|
||||
return 0
|
||||
}
|
||||
|
||||
projects_snapshot() {
|
||||
# slug<TAB>maxmtime for each immediate subdir of PROJECTS_DIR, sorted by
|
||||
# slug; maxmtime is 0 if the slug dir has no files.
|
||||
local dir="$1" d slug mtime
|
||||
[ -d "$dir" ] || return 0
|
||||
for d in "$dir"/*/; do
|
||||
[ -d "$d" ] || continue
|
||||
slug="$(basename "$d")"
|
||||
mtime="$(find "$d" -type f -printf '%T@\n' 2>/dev/null | sort -n | tail -1)"
|
||||
mtime="${mtime%%.*}"
|
||||
printf '%s\t%s\n' "$slug" "${mtime:-0}"
|
||||
done | sort
|
||||
}
|
||||
|
||||
statusline_snapshot() {
|
||||
# path<TAB>sha for $HOME/.claude/statusline.sh, or nothing if this host
|
||||
# has none - see STATUSLINE_PATH above for why this one path is
|
||||
# monitored on its own rather than as part of hash_paths.
|
||||
local f="$1"
|
||||
[ -e "$f" ] || return 0
|
||||
stage_entry "statusline:statusline.sh" "$f"
|
||||
printf 'statusline.sh\t%s\n' "$(hash_entry "$f")"
|
||||
host_exec_snapshot() {
|
||||
# leaf<TAB>sha for every host-execution surface the harness declared, or
|
||||
# nothing where the host has none. One line per surface, keyed by the
|
||||
# leaf so the id a baseline stores ("<namespace>:<leaf>") is unchanged
|
||||
# for a harness already in use.
|
||||
local rec ns leaf path label
|
||||
for rec in ${DEJIMA_HOST_EXEC[@]+"${DEJIMA_HOST_EXEC[@]}"}; do
|
||||
IFS=$'\t' read -r ns leaf path label <<< "$rec"
|
||||
[ -n "$path" ] && [ -e "$path" ] || continue
|
||||
stage_entry "$ns:$leaf" "$path"
|
||||
printf '%s\t%s\n' "$leaf" "$(hash_entry "$path")"
|
||||
done
|
||||
}
|
||||
|
||||
case "$cmd" in
|
||||
--print-paths)
|
||||
printf 'projects_dir=%s\nstatusline_path=%s\naudit_dir=%s\nbaselines_dir=%s\nkeep=%s\n' \
|
||||
"$PROJECTS_DIR" "$STATUSLINE_PATH" "$AUDIT_DIR" "$BASELINE_DIR" "$AUDIT_KEEP"
|
||||
# statusline_path is still named, because it is the surface this
|
||||
# harness declares and existing callers read it by that name. It
|
||||
# comes from the declaration now rather than from a global of its
|
||||
# own, so a harness declaring none prints an empty value and one
|
||||
# declaring several prints a line each.
|
||||
printf 'projects_dir=%s\naudit_dir=%s\nbaselines_dir=%s\nkeep=%s\n' \
|
||||
"$PROJECTS_DIR" "$AUDIT_DIR" "$BASELINE_DIR" "$AUDIT_KEEP"
|
||||
for _rec in ${DEJIMA_HOST_EXEC[@]+"${DEJIMA_HOST_EXEC[@]}"}; do
|
||||
IFS=$'\t' read -r _ns _leaf _path _label <<< "$_rec"
|
||||
printf '%s_path=%s\n' "$_ns" "$_path"
|
||||
done
|
||||
exit 0
|
||||
;;
|
||||
snapshot)
|
||||
@@ -1060,9 +1121,13 @@ case "$cmd" in
|
||||
{ echo "### staging $STAGE_DIR";
|
||||
echo "### gitconfig"; git_config_snapshot "$ws";
|
||||
echo "### paths"; hash_paths "$ws";
|
||||
echo "### projects"; projects_snapshot "$PROJECTS_DIR";
|
||||
# Empty since the cross-project finding was retired, and kept
|
||||
# rather than dropped: it is a range boundary for the paths section
|
||||
# above, and removing it would make a state file written by one
|
||||
# version unparseable by the other across an upgrade mid-session.
|
||||
echo "### projects";
|
||||
echo "### tcb"; tcb_snapshot "$ws";
|
||||
echo "### statusline"; statusline_snapshot "$STATUSLINE_PATH"; } > "$state" 2>/dev/null || true
|
||||
echo "### hostexec"; host_exec_snapshot; } > "$state" 2>/dev/null || true
|
||||
exit 0
|
||||
;;
|
||||
compare)
|
||||
@@ -1099,9 +1164,9 @@ case "$cmd" in
|
||||
|
||||
new="$(mktemp)"; { echo "### gitconfig"; git_config_snapshot "$ws";
|
||||
echo "### paths"; hash_paths "$ws";
|
||||
echo "### projects"; projects_snapshot "$PROJECTS_DIR";
|
||||
echo "### projects";
|
||||
echo "### tcb"; tcb_snapshot "$ws";
|
||||
echo "### statusline"; statusline_snapshot "$STATUSLINE_PATH"; } > "$new" 2>/dev/null || {
|
||||
echo "### hostexec"; host_exec_snapshot; } > "$new" 2>/dev/null || {
|
||||
echo "dejima audit: could not complete, skipping." >&2; rm -f "$new"; exit 0; }
|
||||
|
||||
# One raw namespaced id per finding whose surface has retainable
|
||||
@@ -1190,47 +1255,82 @@ case "$cmd" in
|
||||
<(sed -n '/^### paths/,/^### projects/p' "$new" | grep -v '^### ') \
|
||||
| sed 's/^\t//' | cut -f1 | sort -u )
|
||||
|
||||
# projects diffs: a write to another project's ~/.claude/projects
|
||||
# slug during this session (transcript/memory-poisoning surface).
|
||||
# "self" = current workspace's own slug or a worktree child of it.
|
||||
current_slug="$(ws_slug "$ws")"
|
||||
while IFS= read -r line; do
|
||||
slug="${line%%$'\t'*}"
|
||||
case "$slug" in
|
||||
"$current_slug"|"$current_slug"-*) continue ;;
|
||||
esac
|
||||
warn+=("CROSS-PROJECT history/memory write: $(sanitize "$slug")")
|
||||
done < <(comm -13 <(sed -n '/^### projects/,/^### tcb/p' "$state" | grep -v '^### ') \
|
||||
<(sed -n '/^### projects/,/^### tcb/p' "$new" | grep -v '^### ') )
|
||||
# There is deliberately no cross-project finding here any more. It
|
||||
# reported a write to another project's ~/.claude/projects slug, and
|
||||
# a session could once make one: the whole tree was mounted. Since
|
||||
# #104 it gets its own slugs and nothing else, so the only writes this
|
||||
# could still see are other host or dejima sessions doing their own
|
||||
# legitimate work - and with several sessions running it reported all
|
||||
# of them, on every report, drowning the findings that mattered.
|
||||
#
|
||||
# Detection that cannot observe the thing it was built for, and fires
|
||||
# constantly on the thing it was not, is worse than no detection: it
|
||||
# trains the operator to skim. The mount is the control now, and
|
||||
# static.bats pins it.
|
||||
|
||||
# tcb diffs: any change under the sandbox's own trusted computing
|
||||
# base, only populated when this session's workspace IS the
|
||||
# sandbox's own repo (see tcb_snapshot) - empty/no-op otherwise.
|
||||
# Bounded at ### statusline (not $p, the last section now) so this
|
||||
# Bounded at ### hostexec (not $p, the last section now) so this
|
||||
# doesn't also swallow that section's lines.
|
||||
# Split on whether git can see the change, because that decides
|
||||
# whether this finding is telling the operator anything.
|
||||
#
|
||||
# tcb_snapshot only populates when the workspace IS dejima's own repo,
|
||||
# so every change here is also a change to a git working tree the
|
||||
# operator is about to read in `git status` and in the pull request.
|
||||
# Reporting each one as a CRITICAL to acknowledge duplicated a
|
||||
# stronger control and made ordinary development unusable.
|
||||
#
|
||||
# What git cannot show is the case worth keeping: a file listed in
|
||||
# .gitignore or .git/info/exclude, or marked assume-unchanged or
|
||||
# skip-worktree, is live code that runs on the next launch and appears
|
||||
# nowhere in `git status`. Nothing but this hash sees it, so it stays
|
||||
# a CRITICAL finding.
|
||||
tcb_visible=0
|
||||
while IFS= read -r line; do
|
||||
# Encoded on arrival, same as the path loop above.
|
||||
rline="$(dec_id "$line")"
|
||||
tid="tcb:$rline"; tsha=""; [ -e "$ws/$rline" ] && tsha="$(hash_entry "$ws/$rline")"
|
||||
baseline_suppresses "$tid" "$tsha" && continue
|
||||
crit+=("TCB (sandbox self) modified: $line")
|
||||
if git_shows_change "$ws" "$rline"; then
|
||||
tcb_visible=$((tcb_visible + 1))
|
||||
continue
|
||||
fi
|
||||
crit+=("TCB (sandbox self) modified, INVISIBLE to git: $line")
|
||||
finding_ids+=("$tid")
|
||||
done < <(comm -3 <(sed -n '/^### tcb/,/^### statusline/p' "$state" | grep -v '^### ') \
|
||||
<(sed -n '/^### tcb/,/^### statusline/p' "$new" | grep -v '^### ') \
|
||||
done < <(comm -3 <(sed -n '/^### tcb/,/^### hostexec/p' "$state" | grep -v '^### ') \
|
||||
<(sed -n '/^### tcb/,/^### hostexec/p' "$new" | grep -v '^### ') \
|
||||
| sed 's/^\t//' | cut -f1 | sort -u )
|
||||
if [ "$tcb_visible" -gt 0 ]; then
|
||||
info+=("$tcb_visible change(s) under the sandbox's own bin/, hooks/, docker/, test/ - this workspace IS dejima; review them with git")
|
||||
fi
|
||||
|
||||
# statusline diff: ~/.claude/statusline.sh added/changed/removed/
|
||||
# repointed - see STATUSLINE_PATH above for why this gets its own
|
||||
# section instead of folding into hash_paths. Worded at least as
|
||||
# strongly as COMMAND-EXECUTION/TCB above: this is unsandboxed host
|
||||
# code execution, not just a config change.
|
||||
# host-execution diff: a path the harness runs on the HOST added,
|
||||
# changed, removed or repointed. Its own section rather than folded
|
||||
# into hash_paths for the reason given where the surfaces are loaded,
|
||||
# and worded at least as strongly as COMMAND-EXECUTION/TCB above:
|
||||
# this is unsandboxed host code execution, not just a config change.
|
||||
#
|
||||
# The label is the plugin's, because it is the fixed prefix `ack`
|
||||
# strips to recover the id and so belongs in trusted code rather than
|
||||
# in manifest data. A leaf with no declaring surface cannot happen
|
||||
# from a snapshot this run wrote, but it can from a state file
|
||||
# written before the harness changed - such a line is skipped rather
|
||||
# than reported under a label nothing owns.
|
||||
while IFS= read -r line; do
|
||||
sid="statusline:$line"; ssha=""; [ -e "$STATUSLINE_PATH" ] && ssha="$(hash_entry "$STATUSLINE_PATH")"
|
||||
_hl=""; _hns=""; _hpath=""
|
||||
for _rec in ${DEJIMA_HOST_EXEC[@]+"${DEJIMA_HOST_EXEC[@]}"}; do
|
||||
IFS=$'\t' read -r _ns _leaf _path _label <<< "$_rec"
|
||||
[ "$_leaf" = "$line" ] || continue
|
||||
_hns="$_ns"; _hpath="$_path"; _hl="$_label"; break
|
||||
done
|
||||
[ -n "$_hl" ] || continue
|
||||
sid="$_hns:$line"; ssha=""; [ -e "$_hpath" ] && ssha="$(hash_entry "$_hpath")"
|
||||
baseline_suppresses "$sid" "$ssha" && continue
|
||||
crit+=("HOST-CODE-EXECUTION statusline.sh changed (runs UNSANDBOXED on the host via settings.json statusLine.command): $(enc_id "$line")")
|
||||
crit+=("$_hl$(enc_id "$line")")
|
||||
finding_ids+=("$sid")
|
||||
done < <(comm -3 <(sed -n '/^### statusline/,$p' "$state" | grep -v '^### ') \
|
||||
<(sed -n '/^### statusline/,$p' "$new" | grep -v '^### ') \
|
||||
done < <(comm -3 <(sed -n '/^### hostexec/,$p' "$state" | grep -v '^### ') \
|
||||
<(sed -n '/^### hostexec/,$p' "$new" | grep -v '^### ') \
|
||||
| sed 's/^\t//' | cut -f1 | sort -u )
|
||||
|
||||
rm -f "$new"
|
||||
@@ -1368,15 +1468,41 @@ case "$cmd" in
|
||||
exit 0
|
||||
;;
|
||||
show)
|
||||
color_init 1
|
||||
report_dir="$AUDIT_DIR/$(ws_slug "$ws")"
|
||||
report="$(select_report "$report_dir")" \
|
||||
|| { echo "dejima audit: no reports for $ws" >&2; exit 0; }
|
||||
# Which report, on stderr so stdout stays the report content alone:
|
||||
# `show` no longer necessarily prints the newest one, and an operator
|
||||
# cross-referencing the run timestamps in the startup warning needs to
|
||||
# know which of them they are looking at.
|
||||
echo "dejima audit: $report" >&2
|
||||
cat "$report"
|
||||
# Default is an index, not a dump. `cat` of one report was a wall of
|
||||
# text that answered neither question an operator actually has - how
|
||||
# much is waiting, and which of it still needs a decision - and it
|
||||
# printed findings already acknowledged, so the wall did not even
|
||||
# shrink as they worked through it.
|
||||
if [ "${2:-}" = --full ] || [ "${3:-}" = --full ] || [ "${4:-}" = --full ]; then
|
||||
echo "dejima audit: $report" >&2
|
||||
cat "$report"
|
||||
exit 0
|
||||
fi
|
||||
read -r n_all n_crit <<< "$(count_reports "$report_dir")"
|
||||
printf '%s report(s) for %s' "$n_all" "$ws"
|
||||
[ "$n_crit" -gt 0 ] && printf ', %s%s with unacknowledged CRITICAL%s' \
|
||||
"$C_CRIT" "$n_crit" "$C_RESET"
|
||||
printf '\n\n'
|
||||
# Newest last, so the one `ack` will offer first is the one nearest
|
||||
# the prompt - and mark it, since select_report prefers CRITICAL over
|
||||
# recency and "the newest" is the wrong guess.
|
||||
r=""; line=""; pending=""
|
||||
while IFS= read -r r; do
|
||||
pending=$(grep -cE '^(CRITICAL|WARNING|INFO)' "$r" 2>/dev/null || true)
|
||||
line="$(printf ' %-44s %s finding(s)' "$(basename "$r")" "${pending:-0}")"
|
||||
case "$r" in
|
||||
*-CRITICAL.txt) printf '%s%s%s' "$C_CRIT" "$line" "$C_RESET" ;;
|
||||
*) printf '%s' "$line" ;;
|
||||
esac
|
||||
[ "$r" = "$report" ] && printf ' %s<- ack starts here%s' "$C_KEY" "$C_RESET"
|
||||
printf '\n'
|
||||
done < <(find "$report_dir" -maxdepth 1 -name '*.txt' 2>/dev/null | sort)
|
||||
printf '\n%sdejima-audit ack%s walks them; %sdejima-audit show --full%s prints one.\n' \
|
||||
"$C_KEY" "$C_RESET" "$C_KEY" "$C_RESET"
|
||||
exit 0
|
||||
;;
|
||||
ack)
|
||||
@@ -1385,6 +1511,17 @@ case "$cmd" in
|
||||
bf="$BASELINE_DIR/$(ws_slug "$ws")"
|
||||
report="$(select_report "$report_dir")" \
|
||||
|| { echo "dejima audit: no reports for $ws" >&2; exit 0; }
|
||||
read -r n_all n_crit <<< "$(count_reports "$report_dir")"
|
||||
if [ "$n_all" -gt 1 ]; then
|
||||
printf '%s reports waiting' "$n_all"
|
||||
[ "$n_crit" -gt 0 ] && printf ', %s%s with unacknowledged CRITICAL%s' \
|
||||
"$C_CRIT" "$n_crit" "$C_RESET"
|
||||
printf '.\n\n'
|
||||
fi
|
||||
# One report per pass, every report in one invocation. Walking one and
|
||||
# exiting meant clearing a backlog took as many runs as there were
|
||||
# reports, with nothing on screen to say another was behind it.
|
||||
while :; do
|
||||
sc="$(sidecar_dir "$report")"
|
||||
# Removed only at the end, and only for findings actually
|
||||
# acknowledged - an abort must leave the operator exactly where they
|
||||
@@ -1403,6 +1540,10 @@ case "$cmd" in
|
||||
# (or a non-path CRITICAL, which can never be "approved" - see
|
||||
# ackable below) must keep its suffix and stay eviction-protected.
|
||||
crit_seen=0; crit_acked=0
|
||||
# Set when a finding was cleared because its surface no longer exists.
|
||||
# Like unackable_shown, it means the report has been dealt with as far
|
||||
# as it can be, so it earns a `reviewed` marker.
|
||||
removed_cleared=0
|
||||
# Findings passed over because the baseline already covers them, or
|
||||
# because this report has been walked before - counted so a run that
|
||||
# displays nothing can say why instead of looking broken.
|
||||
@@ -1412,7 +1553,7 @@ case "$cmd" in
|
||||
# end. Together they decide if a `reviewed` line gets written below.
|
||||
unackable_shown=0
|
||||
report_reviewed=0
|
||||
if baseline_reviewed "$(basename "$report")"; then report_reviewed=1; fi
|
||||
if baseline_reviewed "$(report_key "$report")"; then report_reviewed=1; fi
|
||||
# The loop below reads report lines on its own stdin (`< "$report"`),
|
||||
# so operator keystrokes are read from fd 3 - a copy of the script's
|
||||
# original stdin taken before that redirection - rather than
|
||||
@@ -1440,48 +1581,50 @@ 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=""
|
||||
# Host-execution surfaces first, because their labels come from
|
||||
# the harness plugin rather than from a literal here. Matched by
|
||||
# an exact prefix strip - "$body" with the label removed differs
|
||||
# from "$body" only when the label really is at position 0 -
|
||||
# rather than by a `case` pattern, which would treat a label's
|
||||
# own characters as globs. Anchoring is the whole point (#45):
|
||||
# an unanchored match lets chosen text derive an id in the wrong
|
||||
# namespace, and approving it baselines an id that never matches
|
||||
# while the real finding counts as acknowledged.
|
||||
for _rec in ${DEJIMA_HOST_EXEC[@]+"${DEJIMA_HOST_EXEC[@]}"}; do
|
||||
IFS=$'\t' read -r _ns _leaf _path _label <<< "$_rec"
|
||||
_rest="${body#"$_label"}"
|
||||
[ "$_rest" != "$body" ] || continue
|
||||
ackable=1; lbl="$_label"; surface="$_rest"
|
||||
id="$_ns:$_rest"; rid="$_ns:$(dec_id "$_rest")"
|
||||
break
|
||||
done
|
||||
# Only if none of them claimed it: a host-exec label that
|
||||
# matched has already derived the id, and the arms below must
|
||||
# not get a second go at the same text.
|
||||
if [ -z "$id" ]; then
|
||||
case "$body" in
|
||||
"HOST-CODE-EXECUTION workspace file: "*)
|
||||
ackable=1; lbl="HOST-CODE-EXECUTION workspace file: "
|
||||
@@ -1509,18 +1652,14 @@ case "$cmd" in
|
||||
cfgline="${body#changed git config: }"
|
||||
ekey="${cfgline%%=*}"; surface="$ekey"
|
||||
id="gitconfig:$ekey"; rid="gitconfig:$(dec_id "$ekey")" ;;
|
||||
"HOST-CODE-EXECUTION statusline.sh changed (runs UNSANDBOXED on the host via settings.json statusLine.command): "*)
|
||||
ackable=1; lbl="HOST-CODE-EXECUTION statusline.sh changed (runs UNSANDBOXED on the host via settings.json statusLine.command): "
|
||||
esl="${body#HOST-CODE-EXECUTION statusline.sh changed (runs UNSANDBOXED on the host via settings.json statusLine.command): }"
|
||||
surface="$esl"
|
||||
id="statusline:$esl"; rid="statusline:$(dec_id "$esl")" ;;
|
||||
"TCB (sandbox self) modified: "*)
|
||||
ackable=1; lbl="TCB (sandbox self) modified: "
|
||||
etcb="${body#TCB (sandbox self) modified: }"
|
||||
"TCB (sandbox self) modified, INVISIBLE to git: "*)
|
||||
ackable=1; lbl="TCB (sandbox self) modified, INVISIBLE to git: "
|
||||
etcb="${body#TCB (sandbox self) modified, INVISIBLE to git: }"
|
||||
surface="$etcb"
|
||||
id="tcb:$etcb"; rid="tcb:$(dec_id "$etcb")" ;;
|
||||
*) ackable=0 ;;
|
||||
esac
|
||||
fi
|
||||
# Defence in depth: even an anchored arm above should never
|
||||
# leave id empty while claiming ackable, but don't trust that -
|
||||
# crit_acked (and thus whether a report can shed -CRITICAL.txt)
|
||||
@@ -1545,6 +1684,18 @@ case "$cmd" in
|
||||
if [ "$tier" = CRITICAL ]; then crit_acked=$((crit_acked + 1)); fi
|
||||
continue
|
||||
fi
|
||||
# A finding cleared on an earlier pass because its surface was
|
||||
# gone. Nothing was recorded for it - there is no content to pin -
|
||||
# so baseline_suppresses cannot cover it, and it is ackable, so
|
||||
# the un-ackable branch below does not either. Without this it
|
||||
# re-presents on every run: the same forever-loop the clear was
|
||||
# meant to end, one step further along.
|
||||
if [ "$ackable" -eq 1 ] && [ "$report_reviewed" -eq 1 ] \
|
||||
&& [ -z "$(current_sha_for_id "$rid")" ]; then
|
||||
already=$((already + 1))
|
||||
if [ "$tier" = CRITICAL ]; then crit_acked=$((crit_acked + 1)); fi
|
||||
continue
|
||||
fi
|
||||
# An un-ackable finding in a report already walked to the end.
|
||||
# There is nothing to record for one, so without this it would
|
||||
# re-prompt on every run forever, gating the exit from `ack` on a
|
||||
@@ -1611,18 +1762,25 @@ case "$cmd" in
|
||||
else
|
||||
sha="$(current_sha_for_id "$rid")"
|
||||
if [ -z "$sha" ]; then
|
||||
# Nothing left to pin the approval to: current_sha_for_id
|
||||
# returning empty means $id no longer exists (deleted
|
||||
# between compare and ack, or - for gitconfig - the key
|
||||
# was unset). Writing `safe <id> ` anyway would record a
|
||||
# baseline entry baseline_suppresses() can never match
|
||||
# (its own empty-sha guard rejects it as malformed), so
|
||||
# the surface would stay permanently unsuppressible -
|
||||
# while still advancing crit_acked and letting a CRITICAL
|
||||
# report shed its -CRITICAL.txt protection. Refuse
|
||||
# instead: record nothing, and leave the report's
|
||||
# protection exactly as it was.
|
||||
echo " cannot approve: $id no longer exists"
|
||||
# The surface is gone - deleted since compare, or a
|
||||
# gitconfig key unset. There is nothing to pin an
|
||||
# approval to, and `safe <id> ` with an empty sha would
|
||||
# record a baseline line baseline_suppresses() can never
|
||||
# match, leaving the surface permanently unsuppressible.
|
||||
#
|
||||
# So nothing is recorded - but the finding is CLEARED,
|
||||
# because a surface that does not exist does not run.
|
||||
# Refusing outright is what made one deleted file pin
|
||||
# the audit forever: crit_acked never reached crit_seen,
|
||||
# the report kept -CRITICAL.txt, and select_report picks
|
||||
# CRITICAL first, so every other report stayed hidden
|
||||
# behind a finding that could not be resolved by any
|
||||
# means. If the surface comes back, it comes back as a
|
||||
# new finding, which is the honest outcome.
|
||||
echo " ${C_OK}cleared${C_RESET}: $(sanitize "$id") no longer exists"
|
||||
removed_cleared=1
|
||||
[ "$tier" = CRITICAL ] && crit_acked=$((crit_acked + 1))
|
||||
sc_done+=("$rid")
|
||||
else
|
||||
pending+=("safe $id $sha")
|
||||
sc_done+=("$rid")
|
||||
@@ -1643,8 +1801,9 @@ case "$cmd" in
|
||||
# Written once the report has been walked to its end, and only when
|
||||
# it actually showed something that cannot be acknowledged - there is
|
||||
# nothing else to stop those from re-prompting forever.
|
||||
if [ "$unackable_shown" -eq 1 ] && [ "$report_reviewed" -eq 0 ]; then
|
||||
pending+=("reviewed $(basename "$report")")
|
||||
if { [ "$unackable_shown" -eq 1 ] || [ "$removed_cleared" -eq 1 ]; } \
|
||||
&& [ "$report_reviewed" -eq 0 ]; then
|
||||
pending+=("reviewed $(report_key "$report")")
|
||||
fi
|
||||
if [ "${#pending[@]}" -gt 0 ]; then
|
||||
mkdir -p "$BASELINE_DIR"
|
||||
@@ -1682,10 +1841,33 @@ case "$cmd" in
|
||||
else
|
||||
echo "dejima audit: nothing recorded."
|
||||
fi
|
||||
|
||||
# Next report, if there is one. Asked rather than assumed: an operator
|
||||
# who has decided enough for now should not have to abort out of a
|
||||
# report they have not read to stop.
|
||||
next_report="$(select_report "$report_dir")" || next_report=""
|
||||
if [ -n "$next_report" ] && [ "$next_report" != "$report" ]; then
|
||||
read -r n_all n_crit <<< "$(count_reports "$report_dir")"
|
||||
printf '\n%s report(s) still waiting' "$n_all"
|
||||
[ "$n_crit" -gt 0 ] && printf ', %s%s with unacknowledged CRITICAL%s' \
|
||||
"$C_CRIT" "$n_crit" "$C_RESET"
|
||||
printf '. Continue? [%sy%s/%sn%s] ' "$C_KEY" "$C_RESET" "$C_KEY" "$C_RESET"
|
||||
# fd 3, the same duplicate of the original stdin the finding
|
||||
# prompt reads: the report is this loop's stdin, and /dev/tty is
|
||||
# not the caller's input when `ack` is driven by a pipe.
|
||||
{ read -r answer <&3; } 2>/dev/null || answer=n
|
||||
printf '\n'
|
||||
case "$answer" in
|
||||
y|Y|"") report="$next_report"; sc="$(sidecar_dir "$report")"; continue ;;
|
||||
esac
|
||||
fi
|
||||
break
|
||||
done
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "usage: dejima-audit {snapshot|compare|show|ack} [workspace] [state-file]" >&2
|
||||
echo " show [--full] index of waiting reports; --full prints one" >&2
|
||||
echo " workspace defaults to the enclosing git repository, else the current directory" >&2
|
||||
exit 2
|
||||
;;
|
||||
|
||||
+17
-20
@@ -22,33 +22,27 @@
|
||||
# Schema
|
||||
#
|
||||
# Three TAB-separated columns: key, type, default. One source of truth - both
|
||||
# _config_spec and _config_keys read this, so a key cannot exist for lookup
|
||||
# but be missing from enumeration (which is what --print-config walks).
|
||||
# _config_spec and _config_keys read it, so a key cannot be lookup-able but
|
||||
# missing from --print-config's enumeration.
|
||||
#
|
||||
# A "?" prefix on the type means the empty string is a legal value. Without
|
||||
# it, empty is rejected: the distinction is between a setting that is
|
||||
# meaningfully absent (ssh_key, loki_url) and one that must always have a
|
||||
# A "?" type prefix means empty is legal, distinguishing a setting that is
|
||||
# meaningfully absent (ssh_key, loki_url) from one that must always have a
|
||||
# value (firewall, share_dir).
|
||||
#
|
||||
# The three axis keys are `provider:<axis>` - validated by looking the name
|
||||
# up under the provider root (see _config_extra_type_valid below and
|
||||
# dejima-provider-lib.sh) rather than a fixed enum, so adding a provider is
|
||||
# adding a directory, not editing this schema.
|
||||
#
|
||||
# [forge.<name>] and [harness.<name>] keys are absent from this table for the
|
||||
# same reason: enumerating a forge's own keys here would mean a new forge
|
||||
# needs a core edit, which is exactly what the provider axes are for.
|
||||
# Those two sections are resolved instead through the _config_extra_*
|
||||
# functions below, stubbed here and overridden by dejima-provider-lib.sh once
|
||||
# it is sourced, against the type+default each provider's own `keys` manifest
|
||||
# entry declares (see _provider_keyspec_parse there).
|
||||
# The axis keys are `provider:<axis>`, validated against the provider root
|
||||
# rather than a fixed enum, so adding a provider is adding a directory.
|
||||
# [forge.<name>] and [harness.<name>] keys are absent here for the same
|
||||
# reason - enumerating them would make a new forge need a core edit. They
|
||||
# resolve through the _config_extra_* functions below, stubbed here and
|
||||
# overridden by dejima-provider-lib.sh, against each provider's own `keys`
|
||||
# manifest entry.
|
||||
# ---------------------------------------------------------------------------
|
||||
_config_table() {
|
||||
cat <<'EOF'
|
||||
core.harness provider:harness claude-code
|
||||
core.harness provider:harness
|
||||
core.forge provider:forge none
|
||||
core.workflow provider:workflow local-only
|
||||
core.command identifier claude
|
||||
core.command ?identifier
|
||||
core.firewall bool on
|
||||
core.share_dir path ~/.local/share/dejima/shared
|
||||
core.ssh_key ?path
|
||||
@@ -61,6 +55,9 @@ credentials.proxy bool on
|
||||
credentials.transport enum:tls|loopback tls
|
||||
logging.loki_url ?url
|
||||
logging.conversation bool off
|
||||
epitaph.enabled bool on
|
||||
epitaph.dir path ~/.local/share/dejima/epitaphs
|
||||
epitaph.loki bool off
|
||||
audit.dir path ~/.local/share/dejima/audit
|
||||
audit.baselines_dir path ~/.local/share/dejima/baselines
|
||||
audit.keep int:1:1000 10
|
||||
@@ -313,7 +310,7 @@ _config_extra_section_known() { return 1; }
|
||||
|
||||
_config_section_known() {
|
||||
case "$1" in
|
||||
core|github|mounts|credentials|logging|audit|remote-access) return 0 ;;
|
||||
core|github|mounts|credentials|logging|audit|remote-access|epitaph) return 0 ;;
|
||||
*) _config_extra_section_known "$1" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
Executable
+171
@@ -0,0 +1,171 @@
|
||||
#!/bin/bash
|
||||
# Epitaph: what a session leaves behind when its context dies.
|
||||
#
|
||||
# Claude Code writes a summary each time a session is compacted. It is the
|
||||
# only durable account of what a long session worked out, and it goes with
|
||||
# the container. This keeps it.
|
||||
#
|
||||
# Runs entirely on the host, from bin/dejima's exit trap. Nothing in the
|
||||
# container writes here and the store is never mounted, so a session cannot
|
||||
# read what earlier sessions concluded or edit what this one is about to
|
||||
# record - the same arrangement as the audit store, and for the same reason
|
||||
# (#104: anything a session writes and a later one reads is a channel into
|
||||
# that later session's premises).
|
||||
#
|
||||
# The transcript IS session-writable, so a summary is only as honest as the
|
||||
# file it came from. This preserves what was recorded, it does not attest to
|
||||
# it.
|
||||
#
|
||||
# Usage: dejima-epitaph save <workspace> [transcripts-dir]
|
||||
set -uo pipefail
|
||||
|
||||
# shellcheck source=dejima-provider-lib.sh
|
||||
source "$(dirname "$(readlink -f "$0")")/dejima-provider-lib.sh"
|
||||
# shellcheck source=dejima-slug-lib.sh
|
||||
source "$(dirname "$(readlink -f "$0")")/dejima-slug-lib.sh"
|
||||
config_validate || exit 1
|
||||
|
||||
cmd="${1:-}"; ws_arg="${2:-}"; transcripts="${3:-}"
|
||||
|
||||
[ "$(config_get epitaph enabled)" = "on" ] || exit 0
|
||||
STORE="$(config_get epitaph dir)"
|
||||
[ -n "$transcripts" ] || transcripts="$(harness_config_get projects_dir)"
|
||||
|
||||
ws="$ws_arg"
|
||||
[ -n "$ws" ] || ws="$(pwd)"
|
||||
ws="$(cd "$ws" 2>/dev/null && pwd)" || exit 0
|
||||
|
||||
case "$cmd" in
|
||||
save) ;;
|
||||
*) echo "usage: dejima-epitaph save <workspace> [transcripts-dir]" >&2; exit 2 ;;
|
||||
esac
|
||||
|
||||
# One file per summary, named for the moment it was written, so a directory
|
||||
# listing is a timeline. Keyed on the summary record's own uuid: `save` runs
|
||||
# on every exit, and a session compacted three times has three epitaphs, two
|
||||
# of which an earlier run already wrote.
|
||||
mkdir -p "$STORE" 2>/dev/null || exit 0
|
||||
chmod 700 "$STORE" 2>/dev/null || true
|
||||
|
||||
saved=0
|
||||
while IFS= read -r slug; do
|
||||
[ -n "$slug" ] || continue
|
||||
dir="$transcripts/$slug"
|
||||
[ -d "$dir" ] || continue
|
||||
out="$STORE/$slug"
|
||||
for f in "$dir"/*.jsonl; do
|
||||
[ -f "$f" ] || continue
|
||||
# python3 is already a hard dependency (install.sh refuses without
|
||||
# it), and this is line-delimited JSON with embedded newlines in the
|
||||
# payload - not something to take apart with sed.
|
||||
n="$(python3 - "$f" "$out" <<'PY'
|
||||
import json, os, sys
|
||||
|
||||
path, out = sys.argv[1], sys.argv[2]
|
||||
pending = None # metadata from the compact_boundary that precedes a summary
|
||||
written = 0
|
||||
|
||||
for line in open(path, errors="replace"):
|
||||
line = line.strip()
|
||||
if not line or not line.startswith("{"):
|
||||
continue
|
||||
try:
|
||||
rec = json.loads(line)
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
if rec.get("subtype") == "compact_boundary":
|
||||
pending = rec.get("compactMetadata") or {}
|
||||
continue
|
||||
|
||||
if not rec.get("isCompactSummary"):
|
||||
continue
|
||||
|
||||
body = rec.get("message", {}).get("content")
|
||||
if isinstance(body, list):
|
||||
body = "\n".join(p.get("text", "") for p in body if isinstance(p, dict))
|
||||
if not isinstance(body, str) or not body.strip():
|
||||
pending = None
|
||||
continue
|
||||
|
||||
uid = rec.get("uuid") or ""
|
||||
stamp = (rec.get("timestamp") or "").replace(":", "").replace("-", "")[:15]
|
||||
name = "%s-%s.md" % (stamp or "unknown", uid[:8] or "nouuid")
|
||||
dest = os.path.join(out, name)
|
||||
if os.path.exists(dest):
|
||||
pending = None
|
||||
continue
|
||||
|
||||
meta = pending or {}
|
||||
head = [
|
||||
"# Epitaph",
|
||||
"",
|
||||
"- session: %s" % (rec.get("sessionId") or "unknown"),
|
||||
"- at: %s" % (rec.get("timestamp") or "unknown"),
|
||||
"- trigger: %s" % (meta.get("trigger") or "unknown"),
|
||||
]
|
||||
if meta.get("preTokens") is not None:
|
||||
head.append("- tokens: %s before, %s after"
|
||||
% (meta.get("preTokens"), meta.get("postTokens")))
|
||||
head += [
|
||||
"",
|
||||
"Written by the session and preserved verbatim. The transcript it came",
|
||||
"from is writable from inside the container, so this records what was",
|
||||
"there rather than vouching for it.",
|
||||
"", "---", "",
|
||||
]
|
||||
tmp = dest + ".part"
|
||||
os.makedirs(out, exist_ok=True)
|
||||
with open(tmp, "w") as fh:
|
||||
fh.write("\n".join(head) + body.rstrip() + "\n")
|
||||
os.chmod(tmp, 0o600)
|
||||
os.rename(tmp, dest)
|
||||
written += 1
|
||||
pending = None
|
||||
|
||||
print(written)
|
||||
PY
|
||||
)" || n=0
|
||||
saved=$((saved + ${n:-0}))
|
||||
done
|
||||
# The directory is made by the first write into it, not by looking at the
|
||||
# slug: most worktree slugs have transcripts and no compaction summary in
|
||||
# any of them, so creating one per slug up front filled the store with
|
||||
# empty directories and made it look like it held something for each. The
|
||||
# rmdir clears the ones earlier runs already left; it cannot touch a slug
|
||||
# that kept anything.
|
||||
rmdir "$out" 2>/dev/null || true
|
||||
done < <(workspace_slugs_sorted "$ws")
|
||||
|
||||
[ "$saved" -gt 0 ] || exit 0
|
||||
echo "dejima: wrote $saved epitaph(s) to $STORE" >&2
|
||||
|
||||
# Optional, and off by default: the store is the durable copy, this is for
|
||||
# operators who already centralise session logs. Host-side rather than
|
||||
# through the container's log-shipper, so the same "never in the session's
|
||||
# reach" property holds for the shipping path too.
|
||||
[ "$(config_get epitaph loki)" = "on" ] || exit 0
|
||||
loki="$(config_get logging loki_url)"
|
||||
[ -n "$loki" ] || exit 0
|
||||
while IFS= read -r slug; do
|
||||
[ -n "$slug" ] || continue
|
||||
for f in "$STORE/$slug"/*.md; do
|
||||
[ -f "$f" ] && [ ! -f "$f.shipped" ] || continue
|
||||
if python3 - "$loki" "$slug" "$f" <<'PY'
|
||||
import json, sys, time, urllib.request
|
||||
|
||||
loki, slug, path = sys.argv[1], sys.argv[2], sys.argv[3]
|
||||
body = json.dumps({"streams": [{
|
||||
"stream": {"job": "dejima-epitaph", "project": slug},
|
||||
"values": [[str(time.time_ns()), open(path, errors="replace").read()]],
|
||||
}]}).encode()
|
||||
req = urllib.request.Request(loki.rstrip("/") + "/loki/api/v1/push", data=body,
|
||||
headers={"Content-Type": "application/json"})
|
||||
urllib.request.urlopen(req, timeout=10).read()
|
||||
PY
|
||||
then
|
||||
: > "$f.shipped"
|
||||
fi
|
||||
done
|
||||
done < <(workspace_slugs_sorted "$ws")
|
||||
exit 0
|
||||
@@ -0,0 +1,241 @@
|
||||
# Shared library: loading a harness plugin, and the interface it declares
|
||||
# through. Sourced by bin/dejima. Not on PATH and not executable.
|
||||
#
|
||||
# Three tiers, and which one a thing belongs in is the whole design:
|
||||
#
|
||||
# core - the mechanism, and every enforcement point.
|
||||
# manifest - data. harnesses/<name>/harness.conf. Never sourced, never
|
||||
# executed. Selects among named core mechanisms, supplies paths.
|
||||
# plugin - bin/harness/<name>.sh. The harness-specific remainder, as
|
||||
# code, in this repository, supported one harness at a time.
|
||||
#
|
||||
# A feature belongs in the manifest if it can be stated as a value. It moves
|
||||
# here only when it cannot: opencode resolving its state directory through a
|
||||
# database, Claude Code pairing file-history to transcripts by session id.
|
||||
# "Add a manifest key" is the answer until it demonstrably is not.
|
||||
#
|
||||
# Plugins live under bin/ rather than beside their manifest because
|
||||
# dejima-audit's tcb_snapshot covers install.sh, bin/, hooks/, docker/ and
|
||||
# test/ - and not harnesses/. Host-side plugin code under harnesses/ would be
|
||||
# code that runs on the host and that a session could edit in dejima's own
|
||||
# checkout without raising a CRITICAL, which is the exact class the audit
|
||||
# exists to catch. Under bin/ it is covered for free, and "manifests are
|
||||
# data, never executed" (docs/security.md) stays true word for word.
|
||||
#
|
||||
# Plugins are SOURCED, not invoked. They are part of the trusted computing
|
||||
# base, so the risk worth designing against is not a careless plugin - it is
|
||||
# three plugins implementing the security model three different ways.
|
||||
# Sourcing lets a plugin use core's own primitives (_refuse_guarded_mount,
|
||||
# sanitize, ws_slug, harness_config_get) rather than re-deriving them, which
|
||||
# is what keeps one security model instead of one per harness. An invoked
|
||||
# protocol would also put a new text parser on the launch path, and every
|
||||
# parsing bug this repository has had is in that family.
|
||||
#
|
||||
# The rule that replaces isolation: A PLUGIN COMPUTES, CORE ENFORCES.
|
||||
# A plugin declares through the emit_* functions below, which validate at the
|
||||
# point of declaration. It never touches extra_mounts itself - a static test
|
||||
# holds that, because a plugin that appended directly would skip the guarded-
|
||||
# store refusal, and would skip it differently in each harness.
|
||||
|
||||
# The hooks core will call. A plugin defines the ones its harness needs and
|
||||
# omits the rest; anything else it defines is a name core does not call, and
|
||||
# the static test refuses it rather than letting it look wired up.
|
||||
DEJIMA_HARNESS_HOOKS="harness_state_mounts harness_register_notify harness_register_guards harness_host_exec_surfaces"
|
||||
|
||||
# What an operator loses when a harness cannot host a given guard. The set
|
||||
# itself is DEJIMA_KNOWN_GUARDS in dejima-provider-lib.sh, next to the
|
||||
# manifest schema and the validation that rejects a name outside it: a
|
||||
# constant the validation depends on must live where the validation does, or
|
||||
# a caller that loads only provider-lib validates against an empty set and
|
||||
# rejects every guard there is.
|
||||
_guard_description() {
|
||||
case "$1" in
|
||||
bash-deny) printf 'obviously destructive shell commands are not refused' ;;
|
||||
push-guard) printf 'pushes to hosts and branches outside the allowed set are not refused' ;;
|
||||
merge-guard) printf 'the agent can merge its own pull requests' ;;
|
||||
notify) printf 'remote access gets no waiting/done notifications' ;;
|
||||
*) printf 'unknown guard' ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Whether the selected harness declared a given guard. Core gates the work
|
||||
# on this rather than on whether the plugin happens to define a hook: a
|
||||
# harness that forgot the declaration but implemented the hook would get a
|
||||
# guard the launch message has just told the operator it does not have.
|
||||
harness_has_guard() {
|
||||
case " $(manifest_get harness "$DEJIMA_HARNESS_NAME" guards) " in
|
||||
*" $1 "*) return 0 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Says which guards the selected harness cannot host. Silent when it hosts
|
||||
# them all, which is the common case and must not become noise an operator
|
||||
# learns to scroll past - the point is that the line only appears when
|
||||
# something really is missing.
|
||||
harness_report_guards() {
|
||||
local harness="$1" declared guard missing=()
|
||||
declared="$(manifest_get harness "$harness" guards)"
|
||||
for guard in $DEJIMA_KNOWN_GUARDS; do
|
||||
case " $declared " in
|
||||
*" $guard "*) ;;
|
||||
*) missing+=("$guard") ;;
|
||||
esac
|
||||
done
|
||||
[ "${#missing[@]}" -gt 0 ] || return 0
|
||||
printf ' %sharness %s does not support %s of dejima'"'"'s guards:%s\n' \
|
||||
"$C_WARN" "$harness" "${#missing[@]}" "$C_RESET" >&2
|
||||
for guard in "${missing[@]}"; do
|
||||
printf ' %s%s%s - %s\n' "$C_KEY" "$guard" "$C_RESET" "$(_guard_description "$guard")" >&2
|
||||
done
|
||||
printf ' The container still bounds this session; these are the fast-fail\n' >&2
|
||||
printf ' guards it does not get. See docs/development.md.\n' >&2
|
||||
}
|
||||
|
||||
# Sources the selected harness's plugin. Hard error when it is missing:
|
||||
# support is per harness and deliberate (see "Adding a new harness" in
|
||||
# docs/development.md), so a harness with a manifest and no plugin is a
|
||||
# half-finished integration rather than a lightweight one.
|
||||
harness_plugin_load() {
|
||||
local name="$1" plugin
|
||||
[ -n "$name" ] || return 0
|
||||
# Resolved from this file's own location, not from a caller's SCRIPT_DIR:
|
||||
# install.sh sets that to the repo root while bin/dejima sets it to bin/,
|
||||
# and a library that only works for one of its callers is a trap for the
|
||||
# next one.
|
||||
plugin="$(cd "${BASH_SOURCE[0]%/*}" && pwd)/harness/$name.sh"
|
||||
if [ ! -f "$plugin" ]; then
|
||||
echo "No harness plugin for '$name' at $plugin." >&2
|
||||
echo "A supported harness has both a manifest and a plugin; see" >&2
|
||||
echo "\"Adding a new harness\" in docs/development.md." >&2
|
||||
exit 1
|
||||
fi
|
||||
# shellcheck disable=SC1090
|
||||
source "$plugin"
|
||||
}
|
||||
|
||||
# Calls one hook of the loaded plugin, if it defines it. Absence is normal:
|
||||
# not every harness needs every hook, and the manifest is where a harness
|
||||
# says what it cannot do at all.
|
||||
harness_hook() {
|
||||
local hook="$1"; shift
|
||||
declare -F "$hook" >/dev/null 2>&1 || return 0
|
||||
"$hook" "$@"
|
||||
}
|
||||
|
||||
# --- what a plugin declares through --------------------------------------
|
||||
#
|
||||
# Each of these validates and then appends to core's own state. A plugin
|
||||
# calls them; it does not reach past them.
|
||||
|
||||
# A host path mounted into the container at the same path. $2 is the mode
|
||||
# ("ro", or empty for read-write).
|
||||
#
|
||||
# The guarded-store refusal runs here, at the point of declaration, so it
|
||||
# cannot be skipped by a plugin that forgot it: a harness state directory
|
||||
# that overlapped the approval or audit or epitaph store would otherwise
|
||||
# hand a session exactly what those stores exist to withhold, and a harness
|
||||
# is precisely the kind of thing whose paths are computed rather than
|
||||
# written down.
|
||||
emit_mount() {
|
||||
local path="$1" mode="${2:-}"
|
||||
[ -n "$path" ] || return 0
|
||||
case "$path" in
|
||||
*:*)
|
||||
echo "Harness plugin emitted a mount path containing a colon: $path" >&2
|
||||
exit 1 ;;
|
||||
esac
|
||||
_guarded_stores
|
||||
_refuse_guarded_mount "harness state mount" "$path" \
|
||||
${GUARDED_STORES[@]+"${GUARDED_STORES[@]}"}
|
||||
if [ -n "$mode" ]; then
|
||||
extra_mounts+=(-v "$path:$path:$mode")
|
||||
else
|
||||
extra_mounts+=(-v "$path:$path")
|
||||
fi
|
||||
}
|
||||
|
||||
# An environment variable for the container. Name-checked because it becomes
|
||||
# a `-e NAME=value` argument, and a name carrying a space or an `=` would
|
||||
# split into something else entirely.
|
||||
emit_env() {
|
||||
local name="$1" value="${2:-}"
|
||||
case "$name" in
|
||||
''|*[!A-Za-z0-9_]*)
|
||||
echo "Harness plugin emitted an invalid environment variable name: $name" >&2
|
||||
exit 1 ;;
|
||||
esac
|
||||
extra_mounts+=(-e "$name=$value")
|
||||
}
|
||||
|
||||
# --- host-execution surfaces ---------------------------------------------
|
||||
#
|
||||
# Paths a harness causes to run ON THE HOST, unsandboxed, from configuration
|
||||
# a session can reach. Claude Code's statusline is the instance that exists:
|
||||
# settings.json's statusLine.command names a script the host runs, so a
|
||||
# session that can write that file has a way onto the host that no container
|
||||
# boundary sees.
|
||||
#
|
||||
# Declared by the plugin rather than by the manifest, and the reason is a
|
||||
# trust boundary rather than convenience. dejima-audit's `ack` derives a
|
||||
# finding's id by stripping a FIXED label from the front of the finding text
|
||||
# - anchored deliberately, because an unanchored match lets chosen text
|
||||
# derive an id in the wrong namespace and baseline an approval that never
|
||||
# matches while the real finding is counted as acknowledged (#45). Those
|
||||
# labels are therefore part of a parser's grammar. Manifests are data, and
|
||||
# provider directories are NOT in tcb_snapshot's list, so in the self-mount
|
||||
# case a session can edit one without raising a CRITICAL. A plugin is under
|
||||
# bin/, which the audit does watch. The label stays a literal in trusted
|
||||
# code; it just lives with the harness that owns it.
|
||||
#
|
||||
# Each surface carries its own namespace, so ids stay stable per harness and
|
||||
# no rename is forced on anyone's baseline.
|
||||
DEJIMA_HOST_EXEC=()
|
||||
|
||||
# namespace, leaf, path, label. The id is "<namespace>:<leaf>", which is what
|
||||
# a baseline stores, so neither may change for a harness already in use.
|
||||
emit_host_exec() {
|
||||
local ns="$1" leaf="$2" path="$3" label="$4"
|
||||
[ -n "$path" ] || return 0
|
||||
case "$ns" in
|
||||
''|*[!a-z0-9-]*)
|
||||
echo "Harness plugin emitted an invalid host-exec namespace: $ns" >&2
|
||||
exit 1 ;;
|
||||
esac
|
||||
# The leaf lands in an id, which is compared against baseline entries and
|
||||
# written into a report; a tab or newline in either would split a record.
|
||||
case "$leaf$label" in
|
||||
*$'\t'*|*$'\n'*)
|
||||
echo "Harness plugin emitted a host-exec leaf or label containing a tab or newline" >&2
|
||||
exit 1 ;;
|
||||
esac
|
||||
[ -n "$leaf" ] && [ -n "$label" ] || {
|
||||
echo "Harness plugin emitted a host-exec surface with no leaf or label" >&2
|
||||
exit 1
|
||||
}
|
||||
DEJIMA_HOST_EXEC+=("$ns"$'\t'"$leaf"$'\t'"$path"$'\t'"$label")
|
||||
}
|
||||
|
||||
# Asks the loaded plugin for its surfaces. Empty when no harness is selected
|
||||
# or the harness has none, which is a normal state rather than an error.
|
||||
host_exec_load() {
|
||||
DEJIMA_HOST_EXEC=()
|
||||
harness_hook harness_host_exec_surfaces
|
||||
}
|
||||
|
||||
# The host path behind an id, or nothing if that id is not a host-exec one.
|
||||
host_exec_path_for_id() {
|
||||
local id="$1" rec ns leaf path label
|
||||
for rec in ${DEJIMA_HOST_EXEC[@]+"${DEJIMA_HOST_EXEC[@]}"}; do
|
||||
IFS=$'\t' read -r ns leaf path label <<< "$rec"
|
||||
[ "$id" = "$ns:$leaf" ] || continue
|
||||
printf '%s' "$path"
|
||||
return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# Whether an id names a host-execution surface. Used where severity is
|
||||
# decided: every one of these is CRITICAL, because the whole category is
|
||||
# "runs on the host, outside everything".
|
||||
host_exec_is_id() { host_exec_path_for_id "$1" >/dev/null; }
|
||||
+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
|
||||
|
||||
|
||||
+107
-21
@@ -141,6 +141,10 @@ EOF
|
||||
;;
|
||||
harness) cat <<'EOF'
|
||||
command identifier claude
|
||||
command_path ?path
|
||||
guards ?list:identifier
|
||||
no_confirm_args ?template
|
||||
settings_args ?template
|
||||
keys ?list:keyspec
|
||||
resume_command ?template
|
||||
briefing ?relpath
|
||||
@@ -329,28 +333,17 @@ manifest_get() {
|
||||
# directory whose manifest declares its own keys, not editing core.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# config_validate re-parses the WHOLE config file on every config_get call
|
||||
# (see its own comment - "on the order of thirty reads" per launch), and each
|
||||
# pass through a [forge.<name>]/[harness.<name>] line calls back into here.
|
||||
# Going through manifest_get/manifest_validate here would mean thirty-odd
|
||||
# full-manifest validations (every field, not just `keys`) for every key in
|
||||
# those sections - so this reads and validates only the `keys` field itself,
|
||||
# a plain line-scan plus the one type check _provider_valid_value already
|
||||
# does for it, skipping fields (pr_command, capabilities, env_export, ...)
|
||||
# that this lookup never uses. The manifest's OTHER fields still get their
|
||||
# full manifest_validate elsewhere before anything consequential reads them
|
||||
# (provider_validate_selection, forge_hosts, forge_env_exports,
|
||||
# briefing_compose) - this function only ever feeds a config schema type
|
||||
# check, never a forge action.
|
||||
# config_validate re-parses the whole config on every config_get (~thirty per
|
||||
# launch), and each [forge.<name>] line calls back here. Going through
|
||||
# manifest_validate would mean that many full-manifest validations, so this
|
||||
# line-scans the `keys` field alone. Every other field still gets a full
|
||||
# manifest_validate before anything consequential reads it - this feeds a
|
||||
# config schema type check, never a forge action.
|
||||
#
|
||||
# Cached per axis+name for the rest of THIS command substitution: a
|
||||
# manifest cannot change mid-launch, so re-deriving it for every key in the
|
||||
# same section within one config_validate pass is pure waste. The cache
|
||||
# itself does not survive past this subshell (bash forks a new one per
|
||||
# config_get call), so it only dedupes the N keys of one section against
|
||||
# each other, not across a launch's ~thirty reads - a cheap, safe partial
|
||||
# win rather than a cross-process cache this file has no story for
|
||||
# invalidating.
|
||||
# Cached per axis+name for this command substitution only. bash forks a new
|
||||
# subshell per config_get, so it dedupes the keys of one section against each
|
||||
# other rather than across a launch: a cheap partial win, not a cross-process
|
||||
# cache this file has no way to invalidate.
|
||||
declare -gA _PROVIDER_KEYS_CACHE
|
||||
_provider_keys_cached() {
|
||||
local axis="$1" name="$2" cache_key="$axis:$name"
|
||||
@@ -371,6 +364,10 @@ _provider_keys_cached() {
|
||||
# here is a per-line fork thirty times over.
|
||||
_provider_keys_field() {
|
||||
local axis="$1" name="$2" dir file line key value="" seen=0 type n=0
|
||||
# An unselected axis contributes no keys - a different question from
|
||||
# whether a selection is valid. Without this, --print-config prints
|
||||
# "invalid harness name:" and fails at the thing it is for.
|
||||
[ -n "$name" ] || return 1
|
||||
dir="$(provider_dir "$axis" "$name")" || return 1
|
||||
file="$dir/$(_provider_manifest_name "$axis")"
|
||||
[ -f "$file" ] || return 1
|
||||
@@ -433,6 +430,37 @@ _config_extra_spec() {
|
||||
return 1
|
||||
}
|
||||
|
||||
# One key of the SELECTED harness, rather than of a harness named literally
|
||||
# at the call site.
|
||||
#
|
||||
# `keys` is declared per harness, so reading one through a fixed section name
|
||||
# makes it a declaration only one harness can ever be read through: a second
|
||||
# harness gets selected, gets validated, and is then handed the first one's
|
||||
# paths with nothing erroring anywhere. That is a silent wrong answer rather
|
||||
# than a failure, which is why it is worth a helper rather than a longer
|
||||
# expression at each site.
|
||||
#
|
||||
# Empty when no harness is selected. bin/dejima refuses to start in that
|
||||
# state (there is no default, deliberately - see [core] harness), so it is
|
||||
# reachable only from the standalone tools, where a harness-specific path
|
||||
# that resolves to nothing is the honest answer for "no harness chosen".
|
||||
harness_config_get() {
|
||||
local name
|
||||
name="$(config_get core harness)"
|
||||
[ -n "$name" ] || return 0
|
||||
config_get "harness.$name" "$1"
|
||||
}
|
||||
|
||||
# Every guard rail dejima knows how to apply. A harness declares which of
|
||||
# them it can host, in its manifest's `guards`; the ones it cannot are named
|
||||
# at launch (see harness_report_guards) rather than quietly absent.
|
||||
#
|
||||
# Here rather than in dejima-harness-lib.sh because provider_validate_selection
|
||||
# rejects a name outside this set, and a constant the validation depends on
|
||||
# has to live where the validation does - providers.bats sources only this
|
||||
# file, and against an empty set every guard is unknown.
|
||||
DEJIMA_KNOWN_GUARDS="bash-deny push-guard merge-guard notify"
|
||||
|
||||
# Enumerates the keys of the SELECTED forge/harness (core.forge/core.harness,
|
||||
# falling back to their schema default when no config file exists yet to
|
||||
# read a selection from - e.g. an isolated call against a fresh checkout)
|
||||
@@ -472,6 +500,25 @@ provider_validate_selection() {
|
||||
manifest_validate workflow "$workflow" || return 1
|
||||
manifest_validate harness "$harness" || return 1
|
||||
|
||||
# Every guard rail dejima knows how to apply. A harness declares which of
|
||||
# them it can host; the ones it cannot are named at launch rather than
|
||||
# quietly absent, because an operator who has them everywhere else would
|
||||
# otherwise have to read a manifest to find out they are gone here.
|
||||
#
|
||||
# A name outside this set is a hard error rather than an unknown guard
|
||||
# treated as absent: a typo would otherwise silently turn a guard off,
|
||||
# which is the failure this whole declaration exists to prevent.
|
||||
local guard
|
||||
for guard in $(manifest_get harness "$harness" guards); do
|
||||
case " $DEJIMA_KNOWN_GUARDS " in
|
||||
*" $guard "*) ;;
|
||||
*)
|
||||
echo "harness '$harness' declares an unknown guard: $guard" >&2
|
||||
echo "Known guards: $DEJIMA_KNOWN_GUARDS" >&2
|
||||
return 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
caps="$(manifest_get forge "$forge" capabilities)"
|
||||
requires="$(manifest_get workflow "$workflow" requires)"
|
||||
for need in $requires; do
|
||||
@@ -726,6 +773,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 +805,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 +1095,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-epitaph (to decide whose transcripts to keep). Not on PATH and
|
||||
# not executable.
|
||||
#
|
||||
# One definition, because the callers must not disagree. A slug the mount
|
||||
# exposes but a caller treats as foreign is a session whose work is silently
|
||||
# left behind; a slug a caller 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)"
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
# Harness plugin: Claude Code.
|
||||
#
|
||||
# Sourced by bin/dejima through harness_plugin_load when [core] harness names
|
||||
# this harness. Part of the trusted computing base - see the interface notes
|
||||
# in bin/dejima-harness-lib.sh for why plugins live here and why they are
|
||||
# sourced rather than invoked.
|
||||
#
|
||||
# What is here is what could not be stated as a manifest value. The paths
|
||||
# themselves come from the manifest ([harness.claude-code] projects_dir); the
|
||||
# reason this is code is the pairing in the second block, which resolves one
|
||||
# directory tree through the filenames of another.
|
||||
|
||||
# Registers the waiting/done notification handlers into dejima's settings
|
||||
# layer, in the shape Claude Code reads them.
|
||||
#
|
||||
# Claude Code merges hook entries across settings layers rather than letting
|
||||
# the higher one replace the lower, so these are added to whatever the
|
||||
# operator already has rather than displacing it. $HOME is left unexpanded
|
||||
# for Claude Code to resolve at hook-run time - inside the container, not
|
||||
# here - matching the convention install.sh already uses.
|
||||
#
|
||||
# Written through a temp file and renamed: the destination is the settings
|
||||
# layer bin/dejima is about to mount, and a jq that failed partway would
|
||||
# otherwise leave the session with a truncated one.
|
||||
harness_register_notify() {
|
||||
local settings="$1" tmp
|
||||
tmp="$(mktemp "${TMPDIR:-/tmp}/dejima-settings-remote.XXXXXX.json")"
|
||||
if jq '.hooks.Notification = ((.hooks.Notification // []) + [{"matcher": "", "hooks": [{"type": "command", "command": "$HOME/.claude/hooks/dejima-notify.sh waiting"}]}])
|
||||
| .hooks.Stop = ((.hooks.Stop // []) + [{"matcher": "", "hooks": [{"type": "command", "command": "$HOME/.claude/hooks/dejima-notify.sh done"}]}])' \
|
||||
"$settings" > "$tmp"; then
|
||||
mv "$tmp" "$settings"
|
||||
else
|
||||
rm -f "$tmp"
|
||||
echo "warning: could not register the notification hooks; remote access" >&2
|
||||
echo "will not be told when this session wants attention." >&2
|
||||
fi
|
||||
}
|
||||
|
||||
# The per-project and per-session state this harness keeps on the host, and
|
||||
# which parts of it this workspace is entitled to see.
|
||||
harness_state_mounts() {
|
||||
local ws="$1" projects slug sid
|
||||
projects="$(harness_config_get projects_dir)"
|
||||
[ -n "$projects" ] || return 0
|
||||
|
||||
# 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 "$projects/$(ws_slug "$ws")"
|
||||
while IFS= read -r slug; do
|
||||
[ -n "$slug" ] || continue
|
||||
[ -d "$projects/$slug" ] || continue
|
||||
emit_mount "$projects/$slug"
|
||||
done < <(workspace_slugs_sorted "$ws")
|
||||
|
||||
# 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.
|
||||
#
|
||||
# The pairing is why the plugin is code, and the split is the design in
|
||||
# miniature: both directories are statable, so both are manifest keys.
|
||||
# What cannot be stated is the rule that a name in one selects an entry
|
||||
# in the other, so that is here.
|
||||
local history
|
||||
history="$(harness_config_get file_history_dir)"
|
||||
[ -n "$history" ] || return 0
|
||||
mkdir -p "$history"
|
||||
while IFS= read -r sid; do
|
||||
[ -n "$sid" ] || continue
|
||||
[ -d "$history/$sid" ] || continue
|
||||
emit_mount "$history/$sid"
|
||||
done < <(workspace_session_ids "$projects" "$ws")
|
||||
}
|
||||
|
||||
# Puts dejima's guard hooks where Claude Code will find them: symlinked into
|
||||
# ~/.claude/hooks, and registered in ~/.claude/settings.json. Run by
|
||||
# install.sh, not at launch - this writes the operator's own config, and the
|
||||
# operator is who decides to install.
|
||||
#
|
||||
# $1 is the repo root, $2 the directory to move a displaced file into. Every
|
||||
# change is printed as it happens, because it is someone's real config.
|
||||
#
|
||||
# Each guard is gated on the harness having declared it: a harness declares
|
||||
# what it can host (see `guards` in its manifest), and registering something
|
||||
# it did not declare would contradict the launch message that says so.
|
||||
harness_register_guards() {
|
||||
local repo="$1" backup="$2" settings="$HOME/.claude/settings.json"
|
||||
|
||||
mkdir -p "$HOME/.claude/hooks"
|
||||
|
||||
_link_hook() {
|
||||
local name="$1" src="$repo/hooks/$1" dest="$HOME/.claude/hooks/$1"
|
||||
if [ -L "$dest" ] && [ "$(readlink -f "$dest")" = "$(readlink -f "$src")" ]; then
|
||||
echo "Skipping: $name already linked to this repo's copy"
|
||||
return
|
||||
fi
|
||||
if [ -e "$dest" ] || [ -L "$dest" ]; then
|
||||
mkdir -p "$backup"
|
||||
echo "Moving: existing ~/.claude/hooks/$name to $backup/$name"
|
||||
mv "$dest" "$backup/$name"
|
||||
fi
|
||||
echo "Linking: $name ($src -> $dest)"
|
||||
ln -s "$src" "$dest"
|
||||
}
|
||||
|
||||
# Not a guard: this is R3, the briefing's delivery mechanism, and a
|
||||
# session without it does not know it is in a sandbox.
|
||||
_link_hook sandbox-context.sh
|
||||
harness_has_guard push-guard && _link_hook git-push-guard.sh
|
||||
harness_has_guard merge-guard && _link_hook merge-guard.sh
|
||||
|
||||
# Symlink from before the gitea-merge-guard.sh -> merge-guard.sh rename
|
||||
# points at a hooks/ path that no longer exists. Only remove it if it is
|
||||
# actually dangling, so an operator's own file at this name is left alone.
|
||||
local old_merge_guard="$HOME/.claude/hooks/gitea-merge-guard.sh"
|
||||
if [ -L "$old_merge_guard" ] && [ ! -e "$old_merge_guard" ]; then
|
||||
echo "Removing: dangling pre-rename symlink $old_merge_guard"
|
||||
rm -f "$old_merge_guard"
|
||||
fi
|
||||
|
||||
# Linked so it exists at a stable path for the read-only ~/.claude/hooks
|
||||
# mount, but deliberately NOT registered below: bin/dejima registers it
|
||||
# per session, into a throwaway settings copy, and only when remote
|
||||
# access is configured. Registering it here would fire it for every
|
||||
# unsandboxed host session too, where it has no marker directory to
|
||||
# write to.
|
||||
harness_has_guard notify && _link_hook dejima-notify.sh
|
||||
|
||||
# deny-dangerous-bash.sh is the one hook that is NOT sandbox-gated: it
|
||||
# has no DEJIMA check, so it applies to the operator's own host sessions
|
||||
# as well. That is deliberate, and it is why it may already be owned by
|
||||
# another install (dotfiles, say) - only claim it if nothing else
|
||||
# provides it, so this repo works standalone without clobbering an
|
||||
# existing setup.
|
||||
if harness_has_guard bash-deny; then
|
||||
local deny_dest="$HOME/.claude/hooks/deny-dangerous-bash.sh"
|
||||
if [ -e "$deny_dest" ] || [ -L "$deny_dest" ]; then
|
||||
echo "Skipping: deny-dangerous-bash.sh already present at $deny_dest (leaving it as-is)"
|
||||
else
|
||||
echo "Linking: deny-dangerous-bash.sh ($repo/hooks/deny-dangerous-bash.sh -> $deny_dest)"
|
||||
ln -s "$repo/hooks/deny-dangerous-bash.sh" "$deny_dest"
|
||||
fi
|
||||
fi
|
||||
echo
|
||||
|
||||
if [ ! -f "$settings" ]; then
|
||||
echo "Creating $settings"
|
||||
echo '{}' > "$settings"
|
||||
fi
|
||||
|
||||
# The registration that went with the symlink removed above. Two things
|
||||
# depend on it going: Claude Code reports a missing hook command on every
|
||||
# session start while it points at a path that no longer exists, and -
|
||||
# until the dedupe below stopped matching it - it also stood in for the
|
||||
# registration it was renamed from, so `_register merge-guard.sh` skipped
|
||||
# and the merge guard was registered nowhere at all. Dropped only when
|
||||
# the path it names is gone, so an operator's own file at that name keeps
|
||||
# its registration, matching the symlink check above.
|
||||
if [ ! -e "$old_merge_guard" ] && jq -e --arg s "/gitea-merge-guard.sh" \
|
||||
'[.hooks.PreToolUse[]?.hooks[]?.command // empty] | any(endswith($s))' \
|
||||
"$settings" >/dev/null 2>&1; then
|
||||
echo "Removing: stale pre-rename gitea-merge-guard.sh registration from $settings"
|
||||
local stale_tmp; stale_tmp=$(mktemp)
|
||||
jq --arg s "/gitea-merge-guard.sh" \
|
||||
'.hooks.PreToolUse = ((.hooks.PreToolUse // [])
|
||||
| map(.hooks = ((.hooks // []) | map(select((.command // "") | endswith($s) | not))))
|
||||
| map(select((.hooks | length) > 0)))' \
|
||||
"$settings" > "$stale_tmp" && mv "$stale_tmp" "$settings"
|
||||
fi
|
||||
|
||||
_register() {
|
||||
local event="$1" matcher="$2" script_name="$3" description="$4"
|
||||
# endswith on the leading slash, not test(): test() is an unanchored
|
||||
# regex, so a hook whose name merely ends in another's - which is what
|
||||
# a rename like gitea-merge-guard.sh -> merge-guard.sh produces - read
|
||||
# as already registered, and the new hook was silently never added.
|
||||
if jq -e --arg s "/$script_name" \
|
||||
"[.hooks.${event}[]?.hooks[]?.command // empty] | any(endswith(\$s))" \
|
||||
"$settings" >/dev/null 2>&1; then
|
||||
echo "Skipping: $script_name already registered as a $event hook"
|
||||
return
|
||||
fi
|
||||
echo "Registering $script_name as a $event hook in $settings ($description)"
|
||||
local tmp
|
||||
tmp=$(mktemp)
|
||||
jq --arg matcher "$matcher" --arg cmd "\$HOME/.claude/hooks/$script_name" \
|
||||
".hooks.${event} = ((.hooks.${event} // []) + [{\"matcher\": \$matcher, \"hooks\": [{\"type\": \"command\", \"command\": \$cmd}]}])" \
|
||||
"$settings" > "$tmp" && mv "$tmp" "$settings"
|
||||
}
|
||||
|
||||
_register SessionStart "startup|resume|clear|compact" sandbox-context.sh \
|
||||
"injects sandbox context on session start"
|
||||
harness_has_guard push-guard && _register PreToolUse Bash git-push-guard.sh \
|
||||
"blocks pushes outside the allowed host(s)/branches"
|
||||
harness_has_guard merge-guard && _register PreToolUse Bash merge-guard.sh \
|
||||
"blocks the configured forge's merge command inside the sandbox"
|
||||
harness_has_guard bash-deny && _register PreToolUse Bash deny-dangerous-bash.sh \
|
||||
"blocks obviously destructive Bash commands"
|
||||
echo
|
||||
return 0
|
||||
}
|
||||
|
||||
# What Claude Code causes to run on the HOST, unsandboxed.
|
||||
#
|
||||
# settings.json's statusLine.command names a script the host executes on
|
||||
# every session, so a session that can write that file has a path onto the
|
||||
# host that no container boundary sees. bin/dejima stages the file rather
|
||||
# than mounting over it, because a :ro mount landing on a symlink - the
|
||||
# common case - protects the target and leaves the link replaceable;
|
||||
# detecting a change here is the compensating control for that.
|
||||
#
|
||||
# The namespace and leaf together form the id a baseline stores
|
||||
# ("statusline:statusline.sh"), so neither may change while anyone has
|
||||
# approvals recorded. The label is the fixed prefix `ack` strips to recover
|
||||
# that id, which is why it lives here as a literal rather than in the
|
||||
# manifest - see the host-exec notes in bin/dejima-harness-lib.sh.
|
||||
harness_host_exec_surfaces() {
|
||||
local p
|
||||
p="$(harness_config_get statusline_path)"
|
||||
emit_host_exec statusline statusline.sh "$p" \
|
||||
"HOST-CODE-EXECUTION statusline.sh changed (runs UNSANDBOXED on the host via settings.json statusLine.command): "
|
||||
}
|
||||
+28
-29
@@ -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,24 +255,23 @@ 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
|
||||
|
||||
# Copy and set up firewall + DNS-proxy + entrypoint scripts, every forge's
|
||||
# CLI helpers, and the claude wrapper that pins
|
||||
# --dangerously-skip-permissions. The launched application itself
|
||||
# (SANDBOX_CMD, default `claude`) and any editor config for it are supplied
|
||||
# at runtime, not baked into the image - see docker-compose.yml/entrypoint.sh.
|
||||
# CLI helpers, and the harness wrapper that pins whichever no-confirm flags
|
||||
# the selected harness needs. The wrapper is installed under a neutral name:
|
||||
# the image is shared across harnesses and the selection is per launch, so
|
||||
# entrypoint.sh is what links it into place as the harness's own command.
|
||||
# The launched application itself (SANDBOX_CMD) and any editor config for it
|
||||
# are supplied at runtime, not baked into the image - see
|
||||
# docker-compose.yml/entrypoint.sh.
|
||||
COPY docker/fixed-domains.sh /usr/local/bin/
|
||||
COPY docker/dns-proxy.sh /usr/local/bin/
|
||||
COPY docker/init-firewall.sh /usr/local/bin/
|
||||
@@ -282,7 +281,7 @@ COPY docker/ulogd.conf /etc/ulogd-sandbox.conf
|
||||
COPY docker/sandbox-clipboard.lua /usr/local/share/nvim/runtime/plugin/sandbox-clipboard.lua
|
||||
COPY docker/entrypoint.sh /usr/local/bin/
|
||||
COPY docker/anthropic-proxy-forwarder.js /usr/local/bin/anthropic-proxy-forwarder.js
|
||||
COPY docker/claude-wrapper.sh /usr/local/bin/claude
|
||||
COPY docker/harness-wrapper.sh /usr/local/bin/dejima-harness-wrapper
|
||||
# Back to root before the first RUN that writes outside /home/node. The npm
|
||||
# installs above run as node deliberately; COPY writes as root regardless of
|
||||
# USER, so the copies above are unaffected, but the install(1) below is a RUN
|
||||
@@ -296,7 +295,7 @@ COPY forges/ /tmp/forges/
|
||||
RUN find /tmp/forges -type f -path '*/bin/*' -exec install -m 755 {} /usr/local/bin/ \; \
|
||||
&& rm -rf /tmp/forges
|
||||
|
||||
RUN chmod +x /usr/local/bin/dns-proxy.sh /usr/local/bin/init-firewall.sh /usr/local/bin/log-shipper.sh /usr/local/bin/entrypoint.sh /usr/local/bin/claude && \
|
||||
RUN chmod +x /usr/local/bin/dns-proxy.sh /usr/local/bin/init-firewall.sh /usr/local/bin/log-shipper.sh /usr/local/bin/entrypoint.sh /usr/local/bin/dejima-harness-wrapper && \
|
||||
echo 'export PATH="$PATH:/usr/local/share/npm-global/bin"' > /etc/profile.d/npm-global-path.sh
|
||||
|
||||
# Entrypoint runs as root (no USER node here) so it can set up the firewall
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Shadows the real `claude` binary (installed under npm-global/bin, later on
|
||||
# PATH) so every invocation - interactive shell, or claudecode.nvim's
|
||||
# terminal - runs with --dangerously-skip-permissions, without callers
|
||||
# needing to know that.
|
||||
#
|
||||
# The sandbox's settings overrides arrive the same way: as an extra
|
||||
# --settings layer rather than by mounting over ~/.claude/settings.json (see
|
||||
# docker-compose.yml and #47). That only holds if every entry point passes
|
||||
# the flag, and shadowing the binary is what makes it true for the
|
||||
# invocations the entrypoint never sees. The file is absent when a container
|
||||
# is started without bin/dejima, as test/runtime.bats does.
|
||||
settings=()
|
||||
[ -f /etc/dejima-settings.json ] && settings=(--settings /etc/dejima-settings.json)
|
||||
|
||||
exec /usr/local/share/npm-global/bin/claude --dangerously-skip-permissions "${settings[@]}" "$@"
|
||||
+40
-28
@@ -68,9 +68,18 @@ services:
|
||||
# just live at ~/.gitconfig directly.
|
||||
GIT_CONFIG_GLOBAL: ${HOME}/.gitconfig-sandbox/.gitconfig
|
||||
ALLOWED_DOMAINS: ${ALLOWED_DOMAINS:-}
|
||||
# What actually runs as the sandbox user - defaults to bare `claude`.
|
||||
# See entrypoint.sh and bin/dejima's SANDBOX_NVIM_CONFIG.
|
||||
# What actually runs as the sandbox user - the selected harness's own
|
||||
# command unless [core] command overrides it. See entrypoint.sh and
|
||||
# bin/dejima's SANDBOX_NVIM_CONFIG.
|
||||
SANDBOX_CMD: ${SANDBOX_CMD:-claude}
|
||||
# The selected harness's manifest, resolved host-side: which command
|
||||
# the wrapper shadows, the real binary behind it, the flags that make
|
||||
# the session unattended, and how this harness is told to read dejima's
|
||||
# settings layer. Nothing about a harness is baked into the image.
|
||||
SANDBOX_HARNESS_COMMAND: ${SANDBOX_HARNESS_COMMAND:-}
|
||||
SANDBOX_HARNESS_REAL: ${SANDBOX_HARNESS_REAL:-}
|
||||
SANDBOX_HARNESS_ARGS: ${SANDBOX_HARNESS_ARGS:-}
|
||||
SANDBOX_HARNESS_SETTINGS: ${SANDBOX_HARNESS_SETTINGS:-}
|
||||
# Opt-in escape hatch from the default hard GitHub-push block - see
|
||||
# init-firewall.sh and git-push-guard.sh. Off unless explicitly set.
|
||||
ALLOW_GITHUB_PUSH: ${ALLOW_GITHUB_PUSH:-0}
|
||||
@@ -111,33 +120,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
|
||||
@@ -170,7 +170,7 @@ services:
|
||||
- ${HOME}/.claude/CLAUDE.md:/etc/dejima-claude/CLAUDE.md:ro
|
||||
# The sandbox's own settings overrides (sandbox.enabled off, plus the
|
||||
# remote-access notify hooks when that's on), generated per run by
|
||||
# bin/dejima and reaching Claude Code as claude-wrapper.sh's
|
||||
# bin/dejima and reaching the harness as harness-wrapper.sh's
|
||||
# --settings argument. A container-local path, like the staged config
|
||||
# above and for the same reason (#47) - and separate from that staging
|
||||
# directory because this file is generated per run rather than copied
|
||||
@@ -233,6 +233,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}
|
||||
|
||||
+39
-26
@@ -103,6 +103,30 @@ if [ -s "$SANDBOX_SSH_KEY_SRC" ]; then
|
||||
export SSH_AUTH_SOCK
|
||||
fi
|
||||
|
||||
# Put the harness wrapper on PATH under the harness's own command name,
|
||||
# ahead of the real binary, so every invocation gets the no-confirm flags -
|
||||
# including the ones dejima never sees, an interactive shell or an editor
|
||||
# plugin's terminal. Linked here rather than COPYed at build time because
|
||||
# the image is shared across harnesses and the selection is per launch.
|
||||
#
|
||||
# Root-owned in a root-owned directory: the sandbox user must not be able to
|
||||
# replace the wrapper with something that drops the flags, or reinstate the
|
||||
# real binary's name ahead of it.
|
||||
#
|
||||
# Skipped when SANDBOX_HARNESS_COMMAND is unset, which is a container started
|
||||
# without bin/dejima (test/runtime.bats). Refused when it is set to anything
|
||||
# but a bare command name: it becomes a path under /usr/local/bin, and a
|
||||
# value with a slash in it would write somewhere else entirely.
|
||||
if [ -n "${SANDBOX_HARNESS_COMMAND:-}" ]; then
|
||||
case "$SANDBOX_HARNESS_COMMAND" in
|
||||
*/*|.*|'')
|
||||
echo "entrypoint: refusing harness command name: $SANDBOX_HARNESS_COMMAND" >&2
|
||||
exit 1 ;;
|
||||
esac
|
||||
ln -sf /usr/local/bin/dejima-harness-wrapper \
|
||||
"/usr/local/bin/$SANDBOX_HARNESS_COMMAND"
|
||||
fi
|
||||
|
||||
# ~/.claude config files arrive staged read-only under /etc/dejima-claude
|
||||
# (see the mount inventory in docker-compose.yml) and get copied into place
|
||||
# here rather than bind-mounted at their real paths. A copy can't be
|
||||
@@ -252,27 +276,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
|
||||
@@ -301,10 +316,8 @@ fi
|
||||
# sudo/setuid path back to root after this, so it can't re-run
|
||||
# init-firewall.sh itself to widen the allowlist mid-session.
|
||||
#
|
||||
# SANDBOX_CMD is what actually runs as that user - defaults to bare `claude`
|
||||
# (the wrapped binary on PATH, see claude-wrapper.sh) so a fresh clone of
|
||||
# this repo works with no editor setup at all. Override it (e.g. to `nvim`)
|
||||
# for a custom editor-driven workflow; pair with SANDBOX_NVIM_CONFIG (see
|
||||
# bin/dejima) to bring in a real config for it instead of running
|
||||
# unconfigured.
|
||||
# SANDBOX_CMD is what actually runs as that user - the selected harness's
|
||||
# own command unless [core] command overrides it (e.g. to `nvim`) for an
|
||||
# editor-driven workflow; pair that with SANDBOX_NVIM_CONFIG (see bin/dejima)
|
||||
# to bring in a real config for it instead of running unconfigured.
|
||||
exec gosu "$SANDBOX_USER" ${SANDBOX_CMD:-claude} "$@"
|
||||
|
||||
+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
|
||||
|
||||
Executable
+51
@@ -0,0 +1,51 @@
|
||||
#!/bin/bash
|
||||
# Shadows the selected harness's real binary on PATH, so every invocation -
|
||||
# the entrypoint's, an interactive shell's, an editor plugin's terminal -
|
||||
# runs with the flags dejima requires, without any caller needing to know
|
||||
# that.
|
||||
#
|
||||
# Installed by entrypoint.sh under the harness's own command name rather than
|
||||
# COPYed to a fixed one at build time: the image is shared across harnesses
|
||||
# and the selection is per launch, so which name to shadow is only known once
|
||||
# the container starts.
|
||||
#
|
||||
# Every value here comes from the selected harness's manifest, resolved on
|
||||
# the host (see bin/dejima) and passed in as environment. Nothing about any
|
||||
# one harness is written down in this file.
|
||||
#
|
||||
# SANDBOX_HARNESS_REAL the real binary this shadows
|
||||
# SANDBOX_HARNESS_ARGS the no-confirm flags, without which the agent
|
||||
# stops to ask and the arrangement becomes a
|
||||
# worse way of running it on the host
|
||||
# SANDBOX_HARNESS_SETTINGS how this harness is told to read dejima's own
|
||||
# settings layer, if it reads one at all
|
||||
#
|
||||
# The settings layer arrives as an extra layer rather than as a mount over
|
||||
# the harness's own config file (docker-compose.yml, #47). That only holds if
|
||||
# every entry point passes it, which is what shadowing the binary buys.
|
||||
# /etc/dejima-settings.json is absent when a container is started without
|
||||
# bin/dejima, as test/runtime.bats does, so its flag stays conditional on the
|
||||
# file rather than on the manifest key alone.
|
||||
#
|
||||
# Word-split deliberately: both carry flags, which have no spaces, and an
|
||||
# array is what exec needs. A harness whose flags need quoting has outgrown
|
||||
# this and should say so in its plugin.
|
||||
set -u
|
||||
|
||||
if [ -z "${SANDBOX_HARNESS_REAL:-}" ] || [ ! -x "${SANDBOX_HARNESS_REAL:-}" ]; then
|
||||
echo "dejima: no harness binary at '${SANDBOX_HARNESS_REAL:-<unset>}'." >&2
|
||||
echo "dejima: command_path in the harness manifest is what names it." >&2
|
||||
exit 127
|
||||
fi
|
||||
|
||||
args=()
|
||||
# shellcheck disable=SC2206
|
||||
[ -n "${SANDBOX_HARNESS_ARGS:-}" ] && args=(${SANDBOX_HARNESS_ARGS})
|
||||
|
||||
settings=()
|
||||
# shellcheck disable=SC2206
|
||||
[ -n "${SANDBOX_HARNESS_SETTINGS:-}" ] && [ -f /etc/dejima-settings.json ] \
|
||||
&& settings=(${SANDBOX_HARNESS_SETTINGS})
|
||||
|
||||
exec "$SANDBOX_HARNESS_REAL" \
|
||||
${args[@]+"${args[@]}"} ${settings[@]+"${settings[@]}"} "$@"
|
||||
+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.
|
||||
|
||||
|
||||
+74
-28
@@ -18,7 +18,7 @@ what a given launch will actually use.
|
||||
|
||||
| Key | Default | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `[core] harness` | `claude-code` | agent harness the container runs |
|
||||
| `[core] harness` | *(none - must be set)* | agent harness the container runs; refused if unset, so a later default cannot change your selection |
|
||||
| `[core] forge` | `none` | forge integration: `gitea` (`gitea-pr`/`gitea-issue`), `github` (`gh`, opens `ssh.github.com:22` for push), or `none` (no forge at all) |
|
||||
| `[core] workflow` | `local-only` | workflow `sandbox-context.sh` tells the agent to follow: `worktree-pr` (push + open a PR), `branch-push` (push, stop at the branch), or `local-only` (commit locally, push nothing) |
|
||||
| `[core] command` | `claude` | container's main process (e.g. `nvim`), also settable per launch with `--cmd` |
|
||||
@@ -27,6 +27,7 @@ what a given launch will actually use.
|
||||
| `[core] ssh_key` | unset | dedicated deploy key for headless/unattended git push |
|
||||
| `[core] known_hosts` | `~/.ssh/known_hosts` | known-hosts file used for git-over-ssh |
|
||||
| `[harness.claude-code] projects_dir` | `~/.claude/projects` | where Claude Code session transcripts live |
|
||||
| `[harness.claude-code] file_history_dir` | `~/.claude/file-history` | Claude Code's edit-undo record, mounted per session rather than whole |
|
||||
| `[harness.claude-code] statusline_path` | `~/.claude/statusline.sh` | statusline script path |
|
||||
| `[forge.gitea] ssh_host` | unset | Gitea/Forgejo SSH hostname: skip if you only push to GitHub |
|
||||
| `[forge.gitea] api` | unset | `https://<gitea host>/api/v1`: skip if you only push to GitHub |
|
||||
@@ -39,6 +40,9 @@ what a given launch will actually use.
|
||||
| `[credentials] proxy` | `on` | host-side credential proxy; `off` mounts the real OAuth credential into the container directly, as before this feature |
|
||||
| `[credentials] transport` | `tls` | set to `loopback` to fall back to a plaintext forwarder with an explicit `ANTHROPIC_BASE_URL`, keeping the credential out of the container but reinstating the base-URL fingerprint: for when certificate pinning breaks the default TLS path (see [security notes](docs/security.md)) |
|
||||
| `[logging] loki_url` | unset | ship DNS/firewall logs to a Loki endpoint |
|
||||
| `[epitaph] enabled` | `on` | keep the summary Claude Code writes when a session compacts |
|
||||
| `[epitaph] dir` | `~/.local/share/dejima/epitaphs` | where they are kept; never mounted into a container |
|
||||
| `[epitaph] loki` | `off` | also push each epitaph to `[logging] loki_url`, host-side |
|
||||
| `[logging] conversation` | `off` | opt-in: also ship session transcripts to Loki (needs `loki_url`); see "Conversation logging (opt-in)" in docs/security.md |
|
||||
| `[audit] dir` | `~/.local/share/dejima/audit` | where exit-time audit findings are stored |
|
||||
| `[audit] baselines_dir` | `~/.local/share/dejima/baselines` | where audit baselines are stored |
|
||||
@@ -93,32 +97,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 +127,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 +135,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 +327,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 +361,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 +379,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 +487,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
|
||||
|
||||
+227
-2
@@ -17,7 +17,7 @@ run them.
|
||||
| `docker/ulogd.conf` | `ulogd2` config: turns `init-firewall.sh`'s NFLOG-tagged packets into JSON |
|
||||
| `docker/log-shipper.sh` | Tails the dnsmasq/ulogd2 logs and ships them to the configured Loki endpoint (`[logging] loki_url`); also ships session transcripts when conversation logging (`[logging] conversation`) is on |
|
||||
| `docker/entrypoint.sh` | Runs as root: DNS proxy, firewall, logging, drops to the sandbox user via `gosu`, then execs the session's command |
|
||||
| `docker/claude-wrapper.sh` | Shadows `claude` on `PATH` to always add `--dangerously-skip-permissions` |
|
||||
| `docker/harness-wrapper.sh` | Shadows the selected harness's command on `PATH` to add its no-confirm flags and dejima's settings layer. Named by no harness; `entrypoint.sh` links it into place |
|
||||
| `docker/docker-compose.yml` | Wires up mounts/env; invoked via `bin/dejima`, not directly |
|
||||
| `hooks/sandbox-context.sh` | `SessionStart` hook: injects sandbox context/workflow reminders, and any `.dejima.md` the project ships |
|
||||
| `.dejima.md` | Project-level agent instructions, harness-agnostic: `CLAUDE.md`'s role under a neutral name. dejima loads a project's into the session context; this one is dejima's own |
|
||||
@@ -34,6 +34,10 @@ 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-epitaph` | Host-side: keeps the summary a session leaves when its context is compacted away, in a store nothing mounts. Run from `bin/dejima`'s exit trap |
|
||||
| `bin/dejima-harness-lib.sh` | Loads the selected harness's plugin and provides the `emit_*` functions it declares through; the guarded-store refusal runs in `emit_mount`. Not on PATH |
|
||||
| `bin/harness/<name>.sh` | One per supported harness: what dejima does for that harness that no manifest value could state. Trusted computing base, sourced by `bin/dejima` |
|
||||
| `bin/dejima-slug-lib.sh` | The `~/.claude/projects` slug scheme and which slugs belong to a workspace; sourced by `bin/dejima` (what to mount) and `bin/dejima-epitaph` (whose transcripts to keep), so the two cannot disagree. Not on PATH |
|
||||
| `bin/dejima-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 +99,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
|
||||
@@ -110,6 +114,22 @@ socket withheld from the confined job, granted to the privileged job, and
|
||||
the egress each is allowed) - see [docs/ci.md](ci.md). It skips outright
|
||||
unless `DEJIMA_CI_TIER` is set, so it has nothing to say when run locally.
|
||||
|
||||
### Branches
|
||||
|
||||
Pull requests target `development`, which merges to `main` periodically, so
|
||||
`main` stays releasable while work lands quickly. `gitea-pr` defaults its
|
||||
base to the repository's default branch, so pass `--base development`.
|
||||
|
||||
CI needs no change for this: `.gitea/workflows/tests.yml` triggers on
|
||||
`pull_request_target` with no branch filter, so it already runs whatever the
|
||||
base is.
|
||||
|
||||
Nothing *enforces* the target. `git-push-guard.sh` blocks direct pushes to
|
||||
the branches a workflow names, and that list is workflow-wide rather than
|
||||
per-project - adding `development` would impose it on every dejima user,
|
||||
most of whom have no such branch. So this is a convention with a check
|
||||
attached: read what `gitea-pr` printed.
|
||||
|
||||
### Running the tests
|
||||
|
||||
```sh
|
||||
@@ -130,3 +150,208 @@ push to one, `runtime.bats` included. That job needs two things from the
|
||||
runner that an ordinary one doesn't (the host's Docker socket and the host
|
||||
network namespace), and it checks for both up front rather than failing
|
||||
obscurely partway through; the file says why each is needed.
|
||||
|
||||
## Adding a new harness
|
||||
|
||||
`[core] harness` picks a directory under `harnesses/`, and a harness manifest
|
||||
declares four things. Everything else dejima does for Claude Code is written
|
||||
against Claude Code, and this is the inventory of it: what a second harness
|
||||
has to supply, and where dejima has to stop assuming. Taken from a pass over
|
||||
the tree, so each row names the file to change rather than describing a
|
||||
sketch.
|
||||
|
||||
### What a harness must be able to do
|
||||
|
||||
Support is per harness and deliberate. A supported harness gets the same
|
||||
depth of integration Claude Code has - the same mounts, the same guards, the
|
||||
same audit surfaces - so a harness is not a manifest someone drops in. That
|
||||
is only possible if the harness can host what those protections need.
|
||||
|
||||
These are the requirements. A harness that fails one of the first five
|
||||
cannot be supported, and that is a decision about the harness rather than a
|
||||
gap in dejima.
|
||||
|
||||
| | Requirement | Why it is load-bearing |
|
||||
| --- | --- | --- |
|
||||
| **R1** | **Unattended execution.** A documented mode that runs tool calls without confirming each one. | The premise the project rests on. A harness that stops to ask is one an operator sits in front of, and the argument for accepting that mode is that the container makes being unattended survivable. |
|
||||
| **R2** | **Tool-call interception.** A supported mechanism to inspect a command before it runs and refuse it, without patching the harness. | The push guard, the merge guard and the dangerous-command denylist all live here. They are guards against an eager agent rather than a boundary against a hostile one, but a harness without them ships three protections short and the operator cannot add them back. |
|
||||
| **R3** | **Host-controlled context at session start.** A way to place text into the session's context at start, from a file the operator controls and the session cannot rewrite for the run in progress. | How the briefing and the project's `.dejima.md` arrive. It is also how the agent learns it is sandboxed, which is what makes it ask for a domain instead of working around the firewall. |
|
||||
| **R4** | **State dejima can locate or discard.** Per-project state must be computable on the host before launch, so it can be scoped per project - or safely discardable, so it can stay container-local. | #104. A store that is neither computable nor discardable cannot be scoped, and mounting it whole reintroduces cross-project context poisoning. |
|
||||
| **R5** | **A withholdable credential.** The model credential must live somewhere dejima can decline to mount, and the harness must accept a substitute: a staged fake, or an environment variable pointed at the host-side proxy. | The credential never entering the container is a headline property, not a nicety. |
|
||||
|
||||
Three more are required, but may be declared absent in the manifest, in
|
||||
which case dejima says at launch what is not in force:
|
||||
|
||||
| | Requirement | What is lost without it |
|
||||
| --- | --- | --- |
|
||||
| **D1** | Session lifecycle signals - something that fires when the agent wants attention and when it stops. | Remote access notifications. The session still runs and is still attachable. |
|
||||
| **D2** | A parseable record of the session. | Epitaph, and conversation logging. |
|
||||
| **D3** | An enumerable config directory whose extension points can be mounted read-only. | Config parity, and the read-only mounts that stop a session installing a persistent hook for the next one. |
|
||||
|
||||
Declaring one absent is a real decision with a visible cost, not a
|
||||
formality. It is there so a harness can be supported honestly at a smaller
|
||||
guard set rather than shipped with three protections quietly missing.
|
||||
|
||||
**The guard rails are declared in the manifest and reported at launch.**
|
||||
`guards` lists which of `bash-deny`, `push-guard`, `merge-guard` and
|
||||
`notify` the harness can host. A name outside that set is refused, because a
|
||||
typo would otherwise turn a guard off in silence. The ones a harness does
|
||||
not declare are named once at launch, with what each costs - and nothing is
|
||||
printed at all when it hosts them all, so the message stays a signal rather
|
||||
than a line to scroll past.
|
||||
|
||||
Core gates the work on the declaration rather than on whether a plugin
|
||||
happens to define the hook: a harness that forgot to declare a guard but
|
||||
implemented it would otherwise get a protection the launch message has just
|
||||
told the operator it does not have.
|
||||
|
||||
### Worked example: why aider is not supported
|
||||
|
||||
aider clears R1 (`--yes-always`), R3 (`--read` places a file in context),
|
||||
R4 (its state is `.aider.*` inside the workspace, which is already mounted)
|
||||
and R5 (it takes an API key from the environment).
|
||||
|
||||
It fails **R2**. aider has no hook or event system; the only occurrence of
|
||||
"hook" in its options is `--no-verify`, which controls git's own pre-commit
|
||||
hooks. There is no supported point at which a command can be inspected and
|
||||
refused, so `git-push-guard.sh`, `merge-guard.sh` and
|
||||
`deny-dangerous-bash.sh` have nowhere to live and no equivalent to be
|
||||
rebuilt from. It also fails D1.
|
||||
|
||||
The container would still bound an aider session. But three guards an
|
||||
operator gets everywhere else would be missing with no way to restore them,
|
||||
and "supported" would mean something different for that one harness than
|
||||
for every other. So it is not supported, and this is the reason.
|
||||
|
||||
### Where a harness's support lives: three tiers
|
||||
|
||||
| Tier | What | Rule |
|
||||
| --- | --- | --- |
|
||||
| **Core** | the mechanism, and every enforcement point | harness-agnostic, always |
|
||||
| **Manifest** | `harnesses/<name>/harness.conf` | data. Never sourced, never executed. Selects among named core mechanisms and supplies paths |
|
||||
| **Plugin** | `bin/harness/<name>.sh` | the harness-specific remainder, as code, in this repository |
|
||||
|
||||
**A feature belongs in the manifest if it can be stated as a value.** It only
|
||||
moves to a plugin when it cannot. `projects_dir` and `file_history_dir` are
|
||||
both paths, so both are manifest keys; the rule that a *name* in the first
|
||||
selects an *entry* in the second is Claude Code's own and could not be
|
||||
written as a value, so that is the plugin. "Add a manifest key" is the
|
||||
answer until it demonstrably is not.
|
||||
|
||||
**Plugins live under `bin/`, not beside the manifest.** `dejima-audit`'s
|
||||
`tcb_snapshot` covers `install.sh`, `bin/`, `hooks/`, `docker/` and `test/`,
|
||||
and not the provider directories. Host-side plugin code under `harnesses/`
|
||||
would be code that runs on the host and that a session could edit in
|
||||
dejima's own checkout without raising a CRITICAL - the exact class the audit
|
||||
exists to catch. Under `bin/` it is covered for free, and "manifests are
|
||||
data, never executed" stays true word for word.
|
||||
|
||||
**Plugins are sourced, not invoked**, and are part of the trusted computing
|
||||
base. The risk worth designing against is not a careless plugin but three
|
||||
plugins implementing the security model three ways, so a plugin uses core's
|
||||
own primitives rather than re-deriving them. An invoked protocol would also
|
||||
put a new text parser on the launch path, and every parsing bug this
|
||||
repository has had is in that family.
|
||||
|
||||
**The manifest may carry values that are only used as paths or compared as
|
||||
data. It must not carry text that becomes part of a parser's grammar, or the
|
||||
identity of a security surface.** `dejima-audit`'s `ack` recovers a finding's
|
||||
id by stripping a fixed label from the front of the finding text, anchored
|
||||
deliberately (#45). Those labels are grammar. Provider directories are not in
|
||||
`tcb_snapshot`'s list, so in the self-mount case a session can edit a
|
||||
manifest without raising a CRITICAL - which is why host-execution surfaces
|
||||
are a plugin hook (`harness_host_exec_surfaces`) rather than a manifest key.
|
||||
The label stays a literal in code the audit watches; it just lives with the
|
||||
harness that owns it.
|
||||
|
||||
**A plugin computes; core enforces.** A plugin declares through `emit_mount`
|
||||
and `emit_env`, which validate at the point of declaration - `emit_mount`
|
||||
runs the guarded-store refusal there, so a harness state directory that
|
||||
overlapped the approval, audit or epitaph store is refused without each
|
||||
plugin having to remember. A plugin never touches `extra_mounts` itself, and
|
||||
a static test holds that. A plugin may *classify* an audit path; it may never
|
||||
suppress a finding.
|
||||
|
||||
Every shipped harness has both a manifest and a plugin - a static test
|
||||
refuses one without the other, because a manifest alone would present as a
|
||||
supported harness while none of the work had been done.
|
||||
|
||||
### What the manifest already covers
|
||||
|
||||
`harnesses/<name>/harness.conf`, and the full contract is in
|
||||
[providers.md](providers.md):
|
||||
|
||||
| Key | What it decides |
|
||||
| --- | --- |
|
||||
| `command` | the container's main process |
|
||||
| `keys` | this harness's own `[harness.<name>]` config schema |
|
||||
| `resume_command` | what the briefing tells a session to hand the operator |
|
||||
| `briefing` | this harness's fragment of the session briefing |
|
||||
|
||||
### Reading a harness's own config
|
||||
|
||||
`harness_config_get <key>` resolves whichever harness `[core] harness`
|
||||
names. Use it; do not spell a `[harness.<name>]` section out at a call site.
|
||||
|
||||
`keys` is declared per harness, so a literal section name makes it a
|
||||
declaration only one harness can ever be read through - a second harness
|
||||
gets selected, gets validated, and is then handed the first one's paths with
|
||||
nothing erroring anywhere. Three call sites did exactly that, in
|
||||
`bin/dejima-audit` (twice) and `bin/dejima-epitaph`. A static test now holds
|
||||
the rule.
|
||||
|
||||
It returns empty when no harness is selected. `bin/dejima` refuses to start
|
||||
in that state, so that is reachable only from the standalone tools, where a
|
||||
harness-specific path resolving to nothing is the honest answer.
|
||||
|
||||
### The inventory
|
||||
|
||||
| Area | What is Claude Code specific | Where |
|
||||
| --- | --- | --- |
|
||||
| Install | `npm install -g @anthropic-ai/claude-code`, and a pinned `CLAUDE_CODE_VERSION` build arg | `docker/Dockerfile` |
|
||||
| Permission bypass | the flag itself, now `no_confirm_args` in the manifest; the wrapper is generic | `harnesses/<name>/harness.conf` |
|
||||
| Settings layering | that overrides arrive as an extra layer rather than a mount over the operator's file (#47) is core; the flag that carries them is `settings_args` in the manifest | `harnesses/<name>/harness.conf` |
|
||||
| Telemetry | `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1` | `docker/Dockerfile` |
|
||||
| Egress allowlist | `platform.claude.com` always, `api.anthropic.com` only when no credential proxy is in front of it | `docker/fixed-domains.sh` |
|
||||
| Credentials | the whole proxy exists because Claude Code authenticates from `~/.claude/.credentials.json` rather than an env var; the entrypoint seeds a well-formed fake so it has something to read | `bin/dejima-anthropic-proxy`, `docker/anthropic-proxy-forwarder.js`, `docker/entrypoint.sh` |
|
||||
| Hook protocol | the event payload arrives on stdin; `SessionStart` returns `hookSpecificOutput.hookEventName`/`additionalContext` as JSON, while a `PreToolUse` hook blocks by exiting 2 with its reason on stderr | `hooks/*.sh` |
|
||||
| Hook registration | four hooks written into `~/.claude/settings.json` in Claude Code's schema, one `SessionStart` with a `startup\|resume\|clear\|compact` matcher and three `PreToolUse` matching `Bash`. Now in the plugin, gated per declared guard | `bin/harness/claude-code.sh` |
|
||||
| Briefing delivery | the briefing and the project's `.dejima.md` reach the session *through* the `SessionStart` hook. No hooks, no briefing | `hooks/sandbox-context.sh` |
|
||||
| Notifications | `Notification`/`Stop` hooks, registered per session into the settings layer, are what drop the markers remote access watches | `hooks/dejima-notify.sh`, `bin/dejima` |
|
||||
| Config parity | every `~/.claude` subdirectory is enumerated and mounted by name: `plugins`, `hooks`, `skills`, `commands`, `agents`, `output-styles` read-only; `backups`, `tasks`, `plans`, `sessions` read-write | `docker/docker-compose.yml` |
|
||||
| Staged config | `settings.json`, `CLAUDE.md` and `statusline.sh` are staged read-only under `/etc/dejima-claude` and installed into place | `docker/entrypoint.sh`, `bin/dejima` |
|
||||
| App state | `~/.claude.json` is copied rather than mounted, because it mixes live state with `mcpServers` config | `bin/dejima` |
|
||||
| State layout | the project key - cwd with every non-alphanumeric replaced by `-` - is Claude Code's scheme, and dejima reproduces it in four places | `bin/dejima-slug-lib.sh`, `bin/dejima-project-lib.sh`, `docker/log-shipper.sh` |
|
||||
| Per-project scoping | `projects/` is mounted one slug at a time and `file-history/` one session at a time, the second resolved by pairing `projects/<slug>/<id>.jsonl` with `file-history/<id>` (#104). Now in the plugin | `bin/harness/claude-code.sh` |
|
||||
| Transcript format | conversation logging transforms Claude Code's JSONL line by line | `docker/log-shipper.sh` |
|
||||
| Compaction format | epitaph keys on `isCompactSummary` records and the `compact_boundary` that precedes them | `bin/dejima-epitaph` |
|
||||
| Audit surfaces | `.claude/settings.json` is CRITICAL and the rest of `.claude/` is a WARNING, because that is where this harness keeps things that run | `bin/dejima-audit` |
|
||||
| Host code execution | `statusline.sh` is audited as its own CRITICAL surface because `settings.json`'s `statusLine.command` runs it **unsandboxed on the host**. Declared by the plugin, since a finding's label is a parser's grammar rather than data | `bin/harness/claude-code.sh` |
|
||||
|
||||
### Two things worth deciding early
|
||||
|
||||
**What runs on the host when the harness is configured.** `statusLine.command`
|
||||
is the one that exists today, and it is not a quirk: a harness that will
|
||||
execute an operator-configured command outside the container hands a session
|
||||
that writes that file a way onto the host. Find that surface in the new
|
||||
harness before supporting it, because it belongs in the audit from the first
|
||||
release rather than after someone finds it.
|
||||
|
||||
**What the harness keeps, and who else can read it.** The per-project
|
||||
scoping of `projects/` and `file-history/` exists because a session that can
|
||||
write another project's memory can plant text a later session there reads as
|
||||
established background (#104). A new harness's state directory needs the same
|
||||
question asked of it, one directory at a time. Anything mounted host-wide
|
||||
should be there because it is keyed by session rather than by project, and
|
||||
because nothing loads it into a session's context at startup - which is the
|
||||
reasoning that leaves `tasks`, `plans`, `sessions` and `backups` mounted
|
||||
whole today.
|
||||
|
||||
### Testing a new harness
|
||||
|
||||
`providers.bats` covers the manifest contract itself and is where a new
|
||||
`harness.conf` gets its parsing and validation tests. The suites that assert
|
||||
on Claude Code paths by name - `config.bats`, `audit.bats`, `static.bats`,
|
||||
`runtime.bats` - are the ones that will need a second set, and a green run
|
||||
against the existing harness proves nothing about a new one. Nothing in
|
||||
`test/` currently runs a second harness end to end.
|
||||
|
||||
+35
-32
@@ -211,7 +211,11 @@ A workflow's `briefing.md` is plain Markdown plus two marker forms that
|
||||
|
||||
| Key | Type | Default | Meaning |
|
||||
| --- | --- | --- | --- |
|
||||
| `command` | `identifier` | `claude` | The container's main process for this harness. |
|
||||
| `command` | `identifier` | `claude` | The container's main process for this harness, and the name the wrapper shadows on `PATH`. `[core] command` defaults to it. |
|
||||
| `command_path` | `?path` | (empty) | Where the real binary lives, behind the shadow. |
|
||||
| `guards` | `?list:identifier` | (empty) | Which of dejima's guard rails this harness can host: `bash-deny`, `push-guard`, `merge-guard`, `notify`. A name outside that set is refused rather than treated as an unknown guard, since a typo would otherwise turn a guard off silently. The ones a harness does not declare are named at launch - see "What a harness must be able to do" in [development.md](development.md). |
|
||||
| `no_confirm_args` | `?template` | (empty) | The flags that make the harness run without confirming each tool call. Required in practice: dejima refuses to launch a harness that names none, because unattended operation is the premise the container is built on (R1 in [development.md](development.md)). |
|
||||
| `settings_args` | `?template` | (empty) | How this harness is told to read dejima's own settings layer, mounted at `/etc/dejima-settings.json`. Empty for a harness that takes its overrides some other way. |
|
||||
| `keys` | `?list:keyspec` | (empty) | This harness's own `[harness.<name>]` config schema - same grammar as a forge's `keys`. |
|
||||
| `resume_command` | `?template` | (empty) | Prose template (may use `{{session_id}}`) shown at the end of the briefing for resuming this exact session on the host. |
|
||||
| `briefing` | `?relpath` | (empty) | This harness's briefing fragment file, relative to its own directory. |
|
||||
@@ -219,14 +223,22 @@ A workflow's `briefing.md` is plain Markdown plus two marker forms that
|
||||
Worked example, `harnesses/claude-code/harness.conf`:
|
||||
|
||||
```
|
||||
command = claude
|
||||
keys = projects_dir:path=~/.claude/projects statusline_path:path=~/.claude/statusline.sh
|
||||
resume_command = claude --resume {{session_id}}
|
||||
briefing = briefing.md
|
||||
command = claude
|
||||
command_path = /usr/local/share/npm-global/bin/claude
|
||||
guards = bash-deny push-guard merge-guard notify
|
||||
no_confirm_args = --dangerously-skip-permissions
|
||||
settings_args = --settings /etc/dejima-settings.json
|
||||
keys = projects_dir:path=~/.claude/projects file_history_dir:path=~/.claude/file-history statusline_path:path=~/.claude/statusline.sh
|
||||
resume_command = claude --resume {{session_id}}
|
||||
briefing = briefing.md
|
||||
```
|
||||
|
||||
Reading it: the container's main process is `claude`; `[harness.claude-code]`
|
||||
gets `projects_dir` and `statusline_path`, both `path`-typed with defaults;
|
||||
Reading it: the container's main process is `claude`, wrapped on `PATH` in
|
||||
front of the real binary at `command_path` so that every invocation - not
|
||||
only the one the entrypoint makes - gets `--dangerously-skip-permissions`
|
||||
and dejima's settings layer; `[harness.claude-code]`
|
||||
gets `projects_dir`, `file_history_dir` and `statusline_path`, all `path`-typed
|
||||
with defaults;
|
||||
resuming the session on the host is `claude --resume <session id>`, with
|
||||
`{{session_id}}` filled in at composition time; and its briefing fragment is
|
||||
`briefing.md`.
|
||||
@@ -318,30 +330,21 @@ every launch:
|
||||
that has config keys but none of them resolve to a host is refused with
|
||||
"configure it, or select `forge = github`, or `forge = none` with a
|
||||
workflow that does not push".
|
||||
- A forge whose `requires` keys are **partly** set - some configured, some
|
||||
not (a key written but left empty, as `install.sh`'s commented-out starter
|
||||
config makes easy, counts as not set: it resolves to nothing, and every
|
||||
call the forge makes with it would fail inside the container) - is refused
|
||||
as a likely typo, but only when the workflow actually
|
||||
pushes: a non-pushing workflow never invokes the forge, so e.g. gitea's
|
||||
`ssh_host` alone (set only so the firewall allows fetches) is a legitimate
|
||||
configuration, not a typo. A wholly unconfigured forge is never flagged by
|
||||
this rule - that's the case above.
|
||||
- A pushing workflow whose forge has **none** of its required settings
|
||||
configured is refused too, even when `fixed_hosts` means the "resolves to
|
||||
no host" rule above never fires. This is what catches `github`: its single
|
||||
`requires` key (`push_token_file`) means "partly configured" (the rule
|
||||
above) can never trigger for it either - there's nothing to be partial
|
||||
between - so without this rule a pushing workflow could select `github`
|
||||
with no `push_token_file` set and launch anyway, `fixed_hosts` masking the
|
||||
fact that nothing was actually configured.
|
||||
|
||||
Two qualifications worth stating explicitly for the "partly configured" rule:
|
||||
it only applies when the workflow's `push = on` (a non-pushing workflow never
|
||||
touches the forge, so an incomplete `[forge.<name>]` section is harmless),
|
||||
and a required key with a non-empty schema default - gitea's `token_file`,
|
||||
defaulting to `~/.config/gitea/token` - never counts as missing, since its
|
||||
resolved value is already correct even when the operator never wrote it.
|
||||
- A forge whose `requires` keys are **partly** set is refused as a likely
|
||||
typo, but only when the workflow pushes. A key written but left empty
|
||||
counts as not set - it resolves to nothing, and every call made with it
|
||||
would fail inside the container. A non-pushing workflow never invokes the
|
||||
forge, so gitea's `ssh_host` alone (set only so the firewall allows
|
||||
fetches) is legitimate. A required key with a non-empty schema default -
|
||||
gitea's `token_file` - never counts as missing, since its resolved value is
|
||||
correct even unwritten. A wholly unconfigured forge is the case above, not
|
||||
this one.
|
||||
- A pushing workflow whose forge has **none** of its required settings is
|
||||
refused too, even where `fixed_hosts` stops the "resolves to no host" rule
|
||||
firing. This catches `github`: with a single `requires` key there is
|
||||
nothing to be partial between, so without this rule a pushing workflow
|
||||
could select it with no `push_token_file` and launch anyway, `fixed_hosts`
|
||||
masking that nothing was configured.
|
||||
|
||||
## Shipped providers
|
||||
|
||||
@@ -353,7 +356,7 @@ resolved value is already correct even when the operator never wrote it.
|
||||
| workflow | `worktree-pr` | worktree → push → PR, stops at the PR |
|
||||
| workflow | `branch-push` | worktree → push, stops at the branch |
|
||||
| workflow | `local-only` | **default** - commits locally, pushes nothing |
|
||||
| harness | `claude-code` | **default**, and the only harness today |
|
||||
| harness | `claude-code` | the only harness today, and **not a default** - `[core] harness` must be set |
|
||||
|
||||
**The shipped defaults are deliberately the non-pushing pair**
|
||||
(`forge = none`, `workflow = local-only`). A fresh install has no forge host
|
||||
|
||||
+32
-38
@@ -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`
|
||||
@@ -799,10 +792,11 @@ the next one is worth checking.
|
||||
2. **Is the hook registered?** The `Notification` and `Stop` entries live in
|
||||
the throwaway override layer, not in your real `settings.json`:
|
||||
`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`;
|
||||
That file is passed to the harness by `harness-wrapper.sh`, as whatever
|
||||
its manifest's `settings_args` names (`--settings` for Claude Code);
|
||||
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.
|
||||
|
||||
+256
-183
@@ -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
|
||||
@@ -212,27 +218,34 @@ hands to whichever session starts against it. See
|
||||
appears in `.git/config` directly), `!`-shell aliases - plus content
|
||||
changes under the workspace's `.claude/`, `.mcp.json`, `.envrc`,
|
||||
`.vscode/`, and `.git/hooks/` (symlinks included - a repointed symlink is
|
||||
hashed by its target, not just its content), plus cross-project writes
|
||||
under `~/.claude/projects` (see below). When the mounted workspace *is*
|
||||
this repo's own sandbox itself (see the trusted-computing-base note
|
||||
further below), it also diffs content hashes of the sandbox's own trusted
|
||||
computing base - `bin/`, `hooks/`, `docker/`, `install.sh`,
|
||||
`test/` - under that workspace. It's **advisory only**: it fires *after*
|
||||
the session has already ended, so a report of a planted `core.hooksPath`
|
||||
or hook file is something to read and clean up *before* the next `git`
|
||||
or `claude` in that repo, not something that was blocked. It's silent when nothing
|
||||
changed, and never affects the session's own exit code even if it can't
|
||||
complete. It doesn't cover `[core] share_dir` (see
|
||||
[Shared scratch space](configuration.md#shared-scratch-space)) - that
|
||||
hand-off path stays unaudited - and its coverage is a
|
||||
fixed list of known Claude Code config surfaces, not a general integrity
|
||||
check, so a future config surface this list hasn't been taught about yet
|
||||
would go unreported. **Its diff is time-windowed, not session-attributed:**
|
||||
a finding means one of these paths changed while the session was running,
|
||||
not that the session made the change. A concurrent dejima or Claude Code
|
||||
session, another host process, or you editing the same files by hand
|
||||
during the run all land in the same diff and get reported identically -
|
||||
the tool has no way to tell them apart.
|
||||
hashed by its target, not just its content). When the mounted workspace is
|
||||
this repo itself, it also hashes the sandbox's own trusted computing base -
|
||||
`bin/`, `hooks/`, `docker/`, `install.sh`, `test/`.
|
||||
|
||||
Worth knowing about that first surface, because the sandbox's own workflow
|
||||
makes it easy to get wrong: **a git worktree isolates the working tree, not
|
||||
`.git/config`.** Config is shared with the parent checkout, so a bare
|
||||
`git config <key> <value>` inside a worktree writes the real repository's
|
||||
config, including any exec key above. The briefing now says so, and gives
|
||||
`git -C <path> config`. A guard against an unaware agent, not a boundary:
|
||||
`.git/config` is in the read-write project mount and cannot be read-only
|
||||
without breaking `git push -u`, which writes `branch.<name>.merge` on every
|
||||
push. Detection covers the rest, and did - this was found by the audit
|
||||
reporting a `.git/config` change.
|
||||
|
||||
**Advisory only.** It fires after the session ended, so a planted
|
||||
`core.hooksPath` is something to clean up before the next `git` or
|
||||
`claude` in that repo, not something that was blocked. Silent when nothing
|
||||
changed, and never affects the session's exit code. It does not cover
|
||||
`[core] share_dir` (see
|
||||
[Shared scratch space](configuration.md#shared-scratch-space)), and its
|
||||
coverage is a fixed list rather than a general integrity check, so a config
|
||||
surface it has not been taught about goes unreported.
|
||||
|
||||
**Its diff is time-windowed, not session-attributed:** a finding means a
|
||||
path changed while the session ran, not that the session changed it. A
|
||||
concurrent session, another host process, or your own editing all land in
|
||||
the same diff, reported identically.
|
||||
- **Approving and ignoring are different decisions, and the difference is
|
||||
what you are promising about next time.** Approving records the exact
|
||||
content: `safe <id> <sha>`, so this file with these bytes is fine and any
|
||||
@@ -246,18 +259,22 @@ hands to whichever session starts against it. See
|
||||
[a] approve this exact content [i] ignore this key from now on [s] skip (ask again next time) [q] quit
|
||||
```
|
||||
|
||||
Skipping is the third option on purpose: a finding you have not decided
|
||||
about is asked again next session, which is the right default for anything
|
||||
you are unsure of. A WARNING-tier git config change is curatable the same
|
||||
way, which matters for keys some tools rewrite on every run: without a
|
||||
decision recorded against one, it re-fires every session and pushes real
|
||||
findings out of the capped list.
|
||||
A finding whose file has since been deleted is **cleared** rather than
|
||||
approved: there is no content left to pin an approval to, and a surface
|
||||
that does not exist does not run. Nothing is recorded, so if it comes back
|
||||
it comes back as a new finding. Refusing instead - the old behaviour - left
|
||||
the report unresolvable, and because `ack` opens CRITICAL reports first it
|
||||
hid every other report behind that one.
|
||||
|
||||
Severity is not negotiable by either verb: an exec-capable git config key or
|
||||
a shell alias is classified CRITICAL whatever the baseline says, and an
|
||||
`ignore` entry is refused against a CRITICAL surface when the baseline is
|
||||
read, not just when it is written. So neither decision can quietly downgrade
|
||||
something that matters.
|
||||
Skipping asks again next session, the right default when you are unsure.
|
||||
It matters for keys some tools rewrite on every run: with no decision
|
||||
recorded, one re-fires every session and pushes real findings out of the
|
||||
capped list.
|
||||
|
||||
Severity is not negotiable by either verb. An exec-capable key or shell
|
||||
alias is CRITICAL whatever the baseline says, and an `ignore` entry is
|
||||
refused against a CRITICAL surface when the baseline is read, not only when
|
||||
written - so neither decision can quietly downgrade something that matters.
|
||||
|
||||
- `dejima-audit ack` shows what it is asking you to approve. For a surface
|
||||
that changed during the session it prints a unified diff of the
|
||||
@@ -271,21 +288,19 @@ hands to whichever session starts against it. See
|
||||
live on disk); a removed one stores nothing at all, because a deletion
|
||||
diff *is* the whole former file.
|
||||
|
||||
Both the staging directory and the retained diffs live under
|
||||
`$AUDIT_DIR` (`~/.local/share/dejima/audit` by default), never under
|
||||
`/tmp`. That is the boundary the whole scheme rests on:
|
||||
`docker-compose.yml` mounts nothing covering it, so the container cannot
|
||||
read the retained content of earlier runs - `test/static.bats` pins that
|
||||
as a regression guard. If the store is configured *inside* the mounted
|
||||
workspace it becomes readable and deletable from within the sandbox, so
|
||||
in that case no content is retained at all - only a note saying why. A
|
||||
session killed with `SIGKILL` (no EXIT trap runs, so no `compare`)
|
||||
leaves its staging directory behind; the next run against that
|
||||
workspace reaps it, identifying dead owners by pid and process start
|
||||
time recorded in an `.owner` file, so a concurrent session against the
|
||||
same workspace is never mistaken for dead and disturbed. A directory
|
||||
whose owner record is missing or unreadable falls back to a 24-hour age
|
||||
check instead of guessing liveness.
|
||||
Staging and retained diffs live under `$AUDIT_DIR`
|
||||
(`~/.local/share/dejima/audit`), never `/tmp`. That is the boundary the
|
||||
scheme rests on: nothing in `docker-compose.yml` covers it, so the
|
||||
container cannot read earlier runs' retained content, and `static.bats`
|
||||
pins that. A store configured *inside* the workspace is readable and
|
||||
deletable from the sandbox, so nothing is retained in that case - only a
|
||||
note saying why.
|
||||
|
||||
A `SIGKILL`ed session runs no EXIT trap and leaves its staging directory;
|
||||
the next run against that workspace reaps it, identifying dead owners by
|
||||
pid and process start time from an `.owner` file so a concurrent session is
|
||||
never mistaken for dead. A missing or unreadable owner record falls back to
|
||||
a 24-hour age check rather than guessing.
|
||||
|
||||
Content is withheld from the store in several further cases, and in
|
||||
every one `ack` prints the reason in place of the diff - so an absent
|
||||
@@ -300,101 +315,121 @@ 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.
|
||||
|
||||
Residual: the workspace's own transcripts and memory stay fully writable
|
||||
throughout, since Claude Code needs that for host-side `claude --resume`.
|
||||
|
||||
There is no longer a `CROSS-PROJECT` finding for this. It existed when the
|
||||
whole tree was mounted and a session really could write another project's
|
||||
slug. Now that it cannot, the only writes the audit could still see there
|
||||
are other sessions doing their own work - and on a host running several at
|
||||
once it reported all of them, on every report. Detection that cannot
|
||||
observe what it was built for, and fires constantly on what it was not,
|
||||
trains an operator to skim. The mount is the control, and `static.bats`
|
||||
pins it.
|
||||
- `~/.claude/file-history` (Claude Code's edit-undo record) is **not**
|
||||
mounted as a tree either, and the reason is sharper than for `projects/`.
|
||||
Each `<session>/<hash>@v1` is a **verbatim copy of an edited file**, so the
|
||||
tree is other projects' source rather than a record about it. Read access
|
||||
alone is the leak; it was read-write on top of that, and tampering with
|
||||
another project's undo record puts chosen content into a later restore.
|
||||
|
||||
It is keyed by session id, so unlike `projects/` there is no slug in its
|
||||
own paths to scope it by. The transcript names supply it: `projects/<slug>/
|
||||
<id>.jsonl` and `file-history/<id>` name the same session, so the slugs the
|
||||
workspace owns select the session ids it owns
|
||||
(`workspace_session_ids` in `bin/dejima-slug-lib.sh`). A directory whose
|
||||
session has no transcript belongs to no project dejima can identify, and is
|
||||
left out.
|
||||
|
||||
The session about to start has an id nothing can know in advance, so its
|
||||
own directory is created container-local and does not survive the run.
|
||||
That costs undo history for that session if it is later resumed on the
|
||||
host, and it is the reason this cannot be scoped as completely as
|
||||
`projects/`, where the workspace's own slug is known at launch.
|
||||
- Epitaphs - the summary a session leaves when its context is compacted away -
|
||||
are kept host-side in `[epitaph] dir`, and that directory is never mounted. A session can neither read what earlier
|
||||
sessions concluded nor edit what this one is about to record, which is the
|
||||
same arrangement as the audit store and for the same reason. The transcript
|
||||
they are extracted from *is* session-writable, so a summary records what was
|
||||
there rather than vouching for it - and nothing loads one back into a later
|
||||
session, deliberately: that would be the channel #104 closed, rebuilt.
|
||||
- Allowing egress to a domain at all creates a channel for exfiltrating data
|
||||
through *that service's own request surface* (query strings, lookups,
|
||||
etc.), not just to a literal attacker-controlled destination. Domain-level
|
||||
@@ -408,51 +443,45 @@ hands to whichever session starts against it. See
|
||||
similar. It shares the host kernel, so a container-escape vulnerability
|
||||
would defeat every other control described here at once.
|
||||
- Most of what this document asserts is re-checked by the `test/` suite on
|
||||
every change - over 300 bats tests across six files, and `runtime.bats`
|
||||
builds the image and asserts against a real running container rather than
|
||||
a mock: IPv6 blocked, a non-allowlisted domain refused by the DNS proxy
|
||||
and a non-allowlisted destination unreachable, `node` unable to re-run
|
||||
`init-firewall.sh` to widen the allowlist, no working `sudo`. Coverage is
|
||||
not the same as completeness, though. The reasoning in this document is
|
||||
judgement rather than assertions, nothing tests it, and a property nobody
|
||||
thought to write a test for can still regress quietly. A missing `ipset`
|
||||
dedup flag broke firewall startup entirely - twice, on two different
|
||||
ipsets - before anyone caught it; there is a test asserting every
|
||||
`ipset add` is duplicate-safe now, which is the pattern to expect. Each
|
||||
of these tests exists because something was got wrong first.
|
||||
every change, and `runtime.bats` asserts against a real running container
|
||||
rather than a mock: IPv6 blocked, a non-allowlisted domain refused and a
|
||||
non-allowlisted destination unreachable, `node` unable to re-run
|
||||
`init-firewall.sh`, no working `sudo`. Coverage is not completeness: the
|
||||
reasoning here is judgement, nothing tests it, and a property nobody wrote
|
||||
a test for can regress quietly. A missing `ipset` dedup flag broke firewall
|
||||
startup twice before anyone caught it; there is a test for that now, which
|
||||
is the pattern to expect - each of these exists because something was got
|
||||
wrong first.
|
||||
- The Anthropic OAuth credential (`~/.claude/.credentials.json`) never
|
||||
enters the container. It 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 +535,21 @@ hands to whichever session starts against it. See
|
||||
are enumerated in `docker-compose.yml`, in three classes, and anything not
|
||||
listed is container-local: writable during the session and gone with the
|
||||
container.
|
||||
- **Read-write, because it has to survive the session**: `projects/`,
|
||||
`file-history/`, `backups/`, `tasks/`, `plans/`, `sessions/`. All
|
||||
directories, which is what makes them safe (see the detachment note
|
||||
below).
|
||||
- **Read-write, because it has to survive the session**: `backups/`,
|
||||
`tasks/`, `plans/`, `sessions/`, plus the entries of `projects/` and
|
||||
`file-history/` that belong to this workspace. All directories, which is
|
||||
what makes them safe (see the detachment note below). The two trees are
|
||||
not mounted whole: see the `projects/` note further down, and the
|
||||
`file-history/` note below it.
|
||||
- **Read-only**: `plugins/`, `hooks/`, `skills/`, `commands/`, `agents/`,
|
||||
`output-styles/`, plus the symlink-target directories `dejima`
|
||||
discovers at launch. Hooks execute automatically with no separate
|
||||
discovers at launch. Those targets are discovered rather than
|
||||
configured, so they are checked against the approval store and the
|
||||
audit stores and refused if they overlap either - a hook symlinked into
|
||||
the approval store used to mount it, read-only and silently, and
|
||||
read-only is no protection when the content is every project's
|
||||
approvals. The same check now covers `[mounts] readonly`, which had
|
||||
none at all. Hooks execute automatically with no separate
|
||||
approval step, skills can ship executable scripts, and
|
||||
commands/agents/output-styles are prompt text loaded into a later host
|
||||
session, so without this a compromised session could plant a backdoored
|
||||
@@ -531,8 +568,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
|
||||
@@ -698,6 +734,18 @@ hands to whichever session starts against it. See
|
||||
changes takes effect the *next* time `dejima` itself runs,
|
||||
not the current one.
|
||||
|
||||
The report splits on whether git can see the change. tcb_snapshot only
|
||||
populates when the workspace IS dejima's own repo, so an ordinary change
|
||||
there is already in the working tree you are about to read in `git status`
|
||||
and in the pull request - reporting each one as a CRITICAL to acknowledge
|
||||
duplicated a stronger control and made developing dejima itself unusable.
|
||||
Those are counted in one INFO line instead.
|
||||
|
||||
A change git *cannot* see stays a CRITICAL finding, because nothing else
|
||||
would catch it: a file in `.gitignore` or `.git/info/exclude`, or one
|
||||
marked `assume-unchanged` or `skip-worktree`, is live code that runs on the
|
||||
next launch and appears nowhere in `git status`.
|
||||
|
||||
### Declaration files are session-writable
|
||||
|
||||
A project's `.dejima/` declaration files sit inside the project mount, which
|
||||
@@ -930,10 +978,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 +1009,32 @@ want GitHub push and PRs as a standing choice instead, set
|
||||
`ssh.github.com:22`, but persistently rather than per launch, and uses its
|
||||
own `[forge.github] push_token_file` rather than a loaded SSH key.
|
||||
|
||||
**Why the default is off.** The principle is narrower than CI and holds
|
||||
whether or not CI exists anywhere: *a session that is not being used to push
|
||||
to a forge should not be able to push to it.* Your configured forge is the
|
||||
one you chose to push to. GitHub is where everything else lives - every
|
||||
dependency a session reads, every repository it clones to understand an
|
||||
error - so that is where the rule bites by default.
|
||||
|
||||
The case is an eager agent, not a compromised one: it finds a real bug in an
|
||||
unrelated open-source project and pushes a fix branch to somebody else's
|
||||
repository. That this is not an attack does not make it harmless. Maintainers
|
||||
are already triaging a flood of unsolicited AI-generated pull requests, and
|
||||
one more arriving in your name, unreviewed, is a cost imposed on someone who
|
||||
did not ask for it. The block exists so that it cannot happen, not so that
|
||||
you get to approve it first.
|
||||
|
||||
[Don't point it at repositories you don't own](responsible-use.md#dont-point-it-at-repositories-you-dont-own)
|
||||
is the rule this enforces, and gives the fork-and-review workflow to use
|
||||
instead: work against your own fork, review the result, and open the upstream
|
||||
pull request by hand if you still want to.
|
||||
|
||||
Closing the CI chain follows from this - a push is code execution on whatever
|
||||
runners that repository can reach, per the section above - but it is not the
|
||||
reason for it. Reading it the other way round gets the order backwards, and
|
||||
makes the default look like a CI workaround to be turned off once CI is
|
||||
handled.
|
||||
|
||||
The sandbox can always read from GitHub (clone, fetch, `gh` API calls). By
|
||||
**default** it can never push there: enforced at the network layer, not
|
||||
just by `git-push-guard.sh`. GitHub's IP ranges are only allowlisted on port
|
||||
|
||||
@@ -1,23 +1,19 @@
|
||||
#!/bin/bash
|
||||
# Read Gitea Actions runs and job logs from a shell - the CI-side counterpart
|
||||
# to gitea-pr and gitea-issue, and deliberately their twin rather than their
|
||||
# sibling: all three are copied into the sandbox image as single
|
||||
# self-contained files, and gitea-pr also lives in a dotfiles checkout, so a
|
||||
# shared library would be a third thing to keep in step across three places.
|
||||
# The duplicated config/token/curl preamble is pinned in all three by
|
||||
# test/static.bats instead.
|
||||
# to gitea-pr and gitea-issue. Self-contained like both of them rather than
|
||||
# sharing a library: all three are copied into the image as single files, so
|
||||
# a library would be another thing to keep in step. test/static.bats pins the
|
||||
# duplicated config/token/curl preamble instead.
|
||||
#
|
||||
# Its reason to exist is a sandboxed agent's: CI is where the tests that
|
||||
# cannot run in the sandbox actually run, and without this the only way to
|
||||
# see why one failed is for the operator to paste the log in by hand.
|
||||
# It exists for the agent: CI is where the tests the sandbox cannot run
|
||||
# actually run, and without this the only way to see a failure is for the
|
||||
# operator to paste the log in.
|
||||
#
|
||||
# Nothing here touches /repos/{owner}/{repo}/actions/runs, or the job and
|
||||
# log endpoints reached through it, however natural they look. Gitea gates
|
||||
# that group behind reqOwner(): a token belonging to a dedicated non-owner
|
||||
# account - which is what a sandbox should be given - is refused with "user
|
||||
# should be the owner of the repo", and no scope grants ownership, so
|
||||
# widening the token cannot fix it. The three sources used instead need only
|
||||
# read access to the repo:
|
||||
# Nothing here touches /repos/{owner}/{repo}/actions/runs or the endpoints
|
||||
# reached through it, however natural they look. Gitea gates that group
|
||||
# behind reqOwner(), so a dedicated non-owner account - what a sandbox should
|
||||
# have - is refused, and no scope grants ownership. The three sources used
|
||||
# instead need only read access:
|
||||
#
|
||||
# actions/tasks one record per job, carrying its run number,
|
||||
# head_sha, event and result
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
command = claude
|
||||
keys = projects_dir:path=~/.claude/projects statusline_path:path=~/.claude/statusline.sh
|
||||
resume_command = claude --resume {{session_id}}
|
||||
briefing = briefing.md
|
||||
command = claude
|
||||
command_path = /usr/local/share/npm-global/bin/claude
|
||||
guards = bash-deny push-guard merge-guard notify
|
||||
no_confirm_args = --dangerously-skip-permissions
|
||||
settings_args = --settings /etc/dejima-settings.json
|
||||
keys = projects_dir:path=~/.claude/projects file_history_dir:path=~/.claude/file-history statusline_path:path=~/.claude/statusline.sh
|
||||
resume_command = claude --resume {{session_id}}
|
||||
briefing = briefing.md
|
||||
|
||||
+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
|
||||
|
||||
+33
-112
@@ -1,6 +1,6 @@
|
||||
#!/bin/bash
|
||||
# Idempotent installer for dejima. Symlinks this repo's hooks into
|
||||
# ~/.claude/hooks, registers them in ~/.claude/settings.json, and puts
|
||||
# Idempotent installer for dejima. Registers the selected harness's guard
|
||||
# hooks wherever that harness reads them from, and puts
|
||||
# bin/dejima, bin/dejima-allow, bin/dejima-dep, bin/dejima-project,
|
||||
# bin/dejima-audit and the opt-in remote-access scripts on PATH. Safe to
|
||||
# re-run - every step is a no-op if already done, and every change is
|
||||
@@ -35,113 +35,6 @@ fi
|
||||
echo "Found required tools: docker, docker compose v2, jq, python3"
|
||||
echo
|
||||
|
||||
mkdir -p "$HOME/.claude/hooks"
|
||||
|
||||
link_hook() {
|
||||
local name="$1" src="$SCRIPT_DIR/hooks/$1" dest="$HOME/.claude/hooks/$1"
|
||||
if [ -L "$dest" ] && [ "$(readlink -f "$dest")" = "$(readlink -f "$src")" ]; then
|
||||
echo "Skipping: $name already linked to this repo's copy"
|
||||
return
|
||||
fi
|
||||
if [ -e "$dest" ] || [ -L "$dest" ]; then
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
echo "Moving: existing ~/.claude/hooks/$name to $BACKUP_DIR/$name"
|
||||
mv "$dest" "$BACKUP_DIR/$name"
|
||||
fi
|
||||
echo "Linking: $name ($src -> $dest)"
|
||||
ln -s "$src" "$dest"
|
||||
}
|
||||
|
||||
link_hook sandbox-context.sh
|
||||
link_hook git-push-guard.sh
|
||||
link_hook merge-guard.sh
|
||||
|
||||
# Symlink from before the gitea-merge-guard.sh -> merge-guard.sh rename
|
||||
# points at a hooks/ path that no longer exists. Only remove it if it's
|
||||
# actually dangling - the same check the claude-sandbox-* cleanup below
|
||||
# uses for bin/, so an operator's own file at this name is left alone.
|
||||
old_merge_guard="$HOME/.claude/hooks/gitea-merge-guard.sh"
|
||||
if [ -L "$old_merge_guard" ] && [ ! -e "$old_merge_guard" ]; then
|
||||
echo "Removing: dangling pre-rename symlink $old_merge_guard"
|
||||
rm -f "$old_merge_guard"
|
||||
fi
|
||||
|
||||
# Linked so it exists at a stable path for the read-only ~/.claude/hooks
|
||||
# mount, but deliberately NOT passed to register_hook below: bin/dejima
|
||||
# registers it per session, into a throwaway settings copy, and only when
|
||||
# remote access is configured. Registering it here would fire it for every
|
||||
# unsandboxed host session too, where it has no marker directory to write to.
|
||||
link_hook dejima-notify.sh
|
||||
|
||||
# deny-dangerous-bash.sh is a general (non-sandbox-gated) hook that may
|
||||
# already be owned by another install (e.g. dotfiles) - only claim it if
|
||||
# nothing else already provides it, so this repo works fully standalone
|
||||
# without clobbering an existing setup.
|
||||
deny_dest="$HOME/.claude/hooks/deny-dangerous-bash.sh"
|
||||
if [ -e "$deny_dest" ] || [ -L "$deny_dest" ]; then
|
||||
echo "Skipping: deny-dangerous-bash.sh already present at $deny_dest (leaving it as-is)"
|
||||
else
|
||||
echo "Linking: deny-dangerous-bash.sh ($SCRIPT_DIR/hooks/deny-dangerous-bash.sh -> $deny_dest)"
|
||||
ln -s "$SCRIPT_DIR/hooks/deny-dangerous-bash.sh" "$deny_dest"
|
||||
fi
|
||||
echo
|
||||
|
||||
SETTINGS="$HOME/.claude/settings.json"
|
||||
if [ ! -f "$SETTINGS" ]; then
|
||||
echo "Creating $SETTINGS"
|
||||
echo '{}' > "$SETTINGS"
|
||||
fi
|
||||
|
||||
# The registration that went with the symlink removed above. Two things
|
||||
# depend on it going: Claude Code reports a missing hook command on every
|
||||
# session start while it points at a path that no longer exists, and - until
|
||||
# the dedupe below stopped matching it - it also stood in for the
|
||||
# registration it was renamed from, so `register_hook merge-guard.sh`
|
||||
# skipped and the merge guard was registered nowhere at all. Dropped only
|
||||
# when the path it names is gone, so an operator's own file at that name
|
||||
# keeps its registration, matching the symlink check above.
|
||||
if [ ! -e "$old_merge_guard" ] && jq -e --arg s "/gitea-merge-guard.sh" \
|
||||
'[.hooks.PreToolUse[]?.hooks[]?.command // empty] | any(endswith($s))' \
|
||||
"$SETTINGS" >/dev/null 2>&1; then
|
||||
echo "Removing: stale pre-rename gitea-merge-guard.sh registration from $SETTINGS"
|
||||
settings_tmp=$(mktemp)
|
||||
jq --arg s "/gitea-merge-guard.sh" \
|
||||
'.hooks.PreToolUse = ((.hooks.PreToolUse // [])
|
||||
| map(.hooks = ((.hooks // []) | map(select((.command // "") | endswith($s) | not))))
|
||||
| map(select((.hooks | length) > 0)))' \
|
||||
"$SETTINGS" > "$settings_tmp" && mv "$settings_tmp" "$SETTINGS"
|
||||
fi
|
||||
|
||||
register_hook() {
|
||||
local event="$1" matcher="$2" script_name="$3" description="$4"
|
||||
# endswith on the leading slash, not test(): test() is an unanchored
|
||||
# regex, so a hook whose name merely ends in another's - which is what a
|
||||
# rename like gitea-merge-guard.sh -> merge-guard.sh produces - read as
|
||||
# already registered, and the new hook was silently never added.
|
||||
if jq -e --arg s "/$script_name" \
|
||||
"[.hooks.${event}[]?.hooks[]?.command // empty] | any(endswith(\$s))" \
|
||||
"$SETTINGS" >/dev/null 2>&1; then
|
||||
echo "Skipping: $script_name already registered as a $event hook"
|
||||
return
|
||||
fi
|
||||
echo "Registering $script_name as a $event hook in $SETTINGS ($description)"
|
||||
local tmp
|
||||
tmp=$(mktemp)
|
||||
jq --arg matcher "$matcher" --arg cmd "\$HOME/.claude/hooks/$script_name" \
|
||||
".hooks.${event} = ((.hooks.${event} // []) + [{\"matcher\": \$matcher, \"hooks\": [{\"type\": \"command\", \"command\": \$cmd}]}])" \
|
||||
"$SETTINGS" > "$tmp" && mv "$tmp" "$SETTINGS"
|
||||
}
|
||||
|
||||
register_hook SessionStart "startup|resume|clear|compact" sandbox-context.sh \
|
||||
"injects sandbox context on session start"
|
||||
register_hook PreToolUse Bash git-push-guard.sh \
|
||||
"blocks pushes outside the allowed host(s)/branches"
|
||||
register_hook PreToolUse Bash merge-guard.sh \
|
||||
"blocks the configured forge's merge command inside the sandbox"
|
||||
register_hook PreToolUse Bash deny-dangerous-bash.sh \
|
||||
"blocks obviously destructive Bash commands"
|
||||
echo
|
||||
|
||||
BIN_DIR="$HOME/.local/bin"
|
||||
if [[ ":$PATH:" != *":$BIN_DIR:"* ]]; then
|
||||
echo "Warning: $BIN_DIR is not on PATH - add it to your shell profile to run 'dejima' by name instead of the full path." >&2
|
||||
@@ -182,6 +75,7 @@ link_bin dejima-project
|
||||
# the startup warning about unacknowledged CRITICAL findings tells the
|
||||
# operator to run `dejima-audit ack`, which is only true once it is on PATH.
|
||||
link_bin dejima-audit
|
||||
link_bin dejima-epitaph
|
||||
|
||||
# Remote access (opt-in, see docs/remote-access.md). All three are inert
|
||||
# without [remote-access] enabled = on - they refuse to run at all - so
|
||||
@@ -235,9 +129,9 @@ else
|
||||
printf '# dejima configuration. See README.md.\n'
|
||||
printf '# Every key below is shown at its default; uncomment to change one.\n'
|
||||
printf '#\n'
|
||||
printf '# [core] forge and [core] workflow are the exception: they are written\n'
|
||||
printf '# out uncommented, so this file states what it selects rather than\n'
|
||||
printf '# inheriting whatever a future release defaults to.\n'
|
||||
printf '# [core] harness, forge and workflow are written out uncommented, so\n'
|
||||
printf '# this file states what it selects rather than inheriting a future\n'
|
||||
printf '# default. harness has none at all - dejima refuses to start unset.\n'
|
||||
printf '#\n'
|
||||
printf '# Those two decide whether this sandbox pushes at all. The shipped\n'
|
||||
printf '# default is forge = none with workflow = local-only: the agent\n'
|
||||
@@ -261,6 +155,7 @@ else
|
||||
case "$_key" in
|
||||
forge.gitea.ssh_host) _seed="${GITEA_SSH_HOST:-}" ;;
|
||||
forge.gitea.api) _seed="${GITEA_API:-}" ;;
|
||||
core.harness) _seed="claude-code" ;;
|
||||
core.forge) _seed="$_starter_forge" ;;
|
||||
core.workflow) _seed="$_starter_workflow" ;;
|
||||
core.command) _seed="${SANDBOX_CMD:-}" ;;
|
||||
@@ -293,6 +188,32 @@ else
|
||||
chmod 600 "$CONFIG_FILE"
|
||||
fi
|
||||
|
||||
# Guard registration, after the starter config above rather than before it:
|
||||
# where dejima's guards go, and in what shape, is the selected harness's
|
||||
# business, and on a fresh install that selection is the one just seeded.
|
||||
# Registering before reading it would mean writing one harness's config for
|
||||
# an operator who has chosen another.
|
||||
#
|
||||
# Delegated to the harness's plugin, which is also what gates each guard on
|
||||
# the harness having declared it (see `guards` in its manifest).
|
||||
echo
|
||||
source "$SCRIPT_DIR/bin/dejima-harness-lib.sh"
|
||||
_harness="$(config_get core harness 2>/dev/null)" || _harness=""
|
||||
if [ -z "$_harness" ]; then
|
||||
echo "No [core] harness is set in $CONFIG_FILE, so no guard hooks were"
|
||||
echo "registered. Set one and re-run ./install.sh."
|
||||
else
|
||||
DEJIMA_HARNESS_NAME="$_harness"
|
||||
harness_plugin_load "$_harness"
|
||||
if declare -F harness_register_guards >/dev/null 2>&1; then
|
||||
echo "Registering $_harness's guard hooks"
|
||||
harness_register_guards "$SCRIPT_DIR" "$BACKUP_DIR"
|
||||
else
|
||||
echo "Harness '$_harness' installs no guard hooks."
|
||||
echo
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -f "$(config_root)/remote.conf" ]; then
|
||||
echo
|
||||
echo "Note: $(config_root)/remote.conf is no longer read - its keys moved to the"
|
||||
|
||||
+198
-81
@@ -23,6 +23,8 @@ setup() {
|
||||
CONFIG_DIR="$(mktemp -d)"
|
||||
export DEJIMA_CONFIG_DIR="$CONFIG_DIR"
|
||||
cat > "$CONFIG_DIR/config" <<EOF
|
||||
[core]
|
||||
harness = claude-code
|
||||
[harness.claude-code]
|
||||
projects_dir = $PROJDIR
|
||||
statusline_path = $DEJIMA_AUDIT_STATUSLINE
|
||||
@@ -143,16 +145,24 @@ cmp_() { "$AUDIT" compare "$WS" "$STATE" 2>&1; }
|
||||
[ "$count" -eq 1 ]
|
||||
}
|
||||
|
||||
@test "flags a cross-project write under ~/.claude/projects" {
|
||||
# The cross-project finding is gone, and these are its replacement: writes
|
||||
# under ~/.claude/projects must produce NOTHING now, whichever slug they land
|
||||
# in. It reported a poisoning route a session could once take - the whole
|
||||
# tree was mounted - and since #104 a session gets its own slugs only, so
|
||||
# every remaining write it could see belongs to some other session doing its
|
||||
# own work. With several sessions running it reported all of them on every
|
||||
# report.
|
||||
@test "a write to another project's slug is not reported" {
|
||||
mkdir -p "$PROJDIR/-some-other-project"
|
||||
echo x > "$PROJDIR/-some-other-project/file1"
|
||||
snap
|
||||
echo y > "$PROJDIR/-some-other-project/file2"; touch -d '+1 hour' "$PROJDIR/-some-other-project/file2"
|
||||
run cmp_
|
||||
[[ "$output" == *"CROSS-PROJECT history/memory write: -some-other-project"* ]]
|
||||
[[ "$output" != *"CROSS-PROJECT"* ]]
|
||||
[[ "$output" != *"-some-other-project"* ]]
|
||||
}
|
||||
|
||||
@test "does not flag the current project's own projects slug" {
|
||||
@test "a write to this project's own slug is not reported either" {
|
||||
mkdir -p "$PROJDIR/$CURRENT_SLUG"
|
||||
echo x > "$PROJDIR/$CURRENT_SLUG/file1"
|
||||
snap
|
||||
@@ -161,15 +171,6 @@ cmp_() { "$AUDIT" compare "$WS" "$STATE" 2>&1; }
|
||||
[[ "$output" != *"CROSS-PROJECT"* ]]
|
||||
}
|
||||
|
||||
@test "does not flag a worktree child's projects slug" {
|
||||
mkdir -p "$PROJDIR/${CURRENT_SLUG}--worktrees-featurex"
|
||||
echo x > "$PROJDIR/${CURRENT_SLUG}--worktrees-featurex/file1"
|
||||
snap
|
||||
echo y > "$PROJDIR/${CURRENT_SLUG}--worktrees-featurex/file2"; touch -d '+1 hour' "$PROJDIR/${CURRENT_SLUG}--worktrees-featurex/file2"
|
||||
run cmp_
|
||||
[[ "$output" != *"CROSS-PROJECT"* ]]
|
||||
}
|
||||
|
||||
@test "silent when no projects changed" {
|
||||
mkdir -p "$PROJDIR/-some-other-project"
|
||||
echo x > "$PROJDIR/-some-other-project/file1"
|
||||
@@ -211,7 +212,11 @@ cmp_() { "$AUDIT" compare "$WS" "$STATE" 2>&1; }
|
||||
[[ "$output" == *".claude/evil"* ]]
|
||||
}
|
||||
|
||||
@test "TCB: flags a change under bin/ when workspace is the audit's own repo root" {
|
||||
@test "TCB: a change git can see is reported as info, not as a CRITICAL to ack" {
|
||||
# tcb_snapshot only populates when the workspace IS dejima's own repo, so
|
||||
# every change here is also in the git working tree the operator is about
|
||||
# to read. One CRITICAL per file duplicated `git status` and made
|
||||
# developing dejima itself unusable.
|
||||
mkdir -p "$WS/bin"
|
||||
echo x > "$WS/bin/x"
|
||||
echo x > "$WS/install.sh"
|
||||
@@ -219,7 +224,27 @@ cmp_() { "$AUDIT" compare "$WS" "$STATE" 2>&1; }
|
||||
snap
|
||||
echo changed > "$WS/bin/x"
|
||||
run cmp_
|
||||
[[ "$output" == *"TCB (sandbox self) modified: bin/x"* ]]
|
||||
[[ "$output" != *"TCB (sandbox self) modified"* ]] # nothing to acknowledge
|
||||
# Recorded as INFO in the report rather than shouted on stderr: the
|
||||
# startup warning already said the TCB is writable this session.
|
||||
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
|
||||
run bash -c "cat '$AUDITDIR/$slug'/*.txt"
|
||||
[[ "$output" == *"this workspace IS dejima"* ]]
|
||||
}
|
||||
|
||||
@test "TCB: a change git CANNOT see is still a CRITICAL finding" {
|
||||
# The case that justifies keeping the check at all. A file listed in
|
||||
# .git/info/exclude is live code that runs on the next launch and appears
|
||||
# nowhere in `git status`, so nothing but this hash sees it.
|
||||
mkdir -p "$WS/bin" "$WS/.git/info"
|
||||
echo x > "$WS/bin/hidden"
|
||||
echo x > "$WS/install.sh"
|
||||
echo 'bin/hidden' > "$WS/.git/info/exclude"
|
||||
export DEJIMA_AUDIT_REPO_ROOT="$(readlink -f "$WS")"
|
||||
snap
|
||||
echo changed > "$WS/bin/hidden"
|
||||
run cmp_
|
||||
[[ "$output" == *"TCB (sandbox self) modified, INVISIBLE to git: bin/hidden"* ]]
|
||||
}
|
||||
|
||||
@test "TCB: does not flag changes when the workspace is not the audit's own repo root" {
|
||||
@@ -241,6 +266,24 @@ cmp_() { "$AUDIT" compare "$WS" "$STATE" 2>&1; }
|
||||
[ "$output" = "" ]
|
||||
}
|
||||
|
||||
@test "says so when no harness is selected, rather than auditing a narrower surface in silence" {
|
||||
# The statusline path comes from the selected harness, so with none
|
||||
# selected there is nothing to watch and the compensating control for
|
||||
# the symlink gap covers nothing. bin/dejima refuses to start without
|
||||
# [core] harness, so this state means the audit was driven by hand - and
|
||||
# a clean report over a surface that was never examined is worse than a
|
||||
# noisy one.
|
||||
printf '#!/bin/sh\necho original\n' > "$DEJIMA_AUDIT_STATUSLINE"
|
||||
cat > "$CONFIG_DIR/config" <<EOF
|
||||
[audit]
|
||||
dir = $AUDITDIR
|
||||
baselines_dir = $BASEDIR
|
||||
EOF
|
||||
run "$AUDIT" snapshot "$WS" "$STATE"
|
||||
[[ "$output" == *"no [core] harness is set"* ]] || { echo "$output"; return 1; }
|
||||
[[ "$output" == *"NOT checked"* ]] || { echo "$output"; return 1; }
|
||||
}
|
||||
|
||||
@test "flags a plain regular-file statusline.sh replaced with different content" {
|
||||
printf '#!/bin/sh\necho original\n' > "$DEJIMA_AUDIT_STATUSLINE"
|
||||
snap
|
||||
@@ -592,14 +635,56 @@ cmp_() { "$AUDIT" compare "$WS" "$STATE" 2>&1; }
|
||||
[ "$result" -ne 124 ]
|
||||
}
|
||||
|
||||
@test "show prints the only report there is, and names it" {
|
||||
# One `ack` run used to walk one report and exit, saying nothing about the
|
||||
# rest, so clearing a backlog took as many runs as there were reports and
|
||||
# looked like the tool was stuck on the first one.
|
||||
@test "ack walks every report in one run when told to continue" {
|
||||
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
|
||||
snap; echo 'export X=1' > "$WS/.envrc"; cmp_ >/dev/null 2>&1
|
||||
snap; mkdir -p "$WS/.claude"; echo '{}' > "$WS/.claude/settings.json"; cmp_ >/dev/null 2>&1
|
||||
[ "$(find "$AUDITDIR/$slug" -name '*.txt' | wc -l)" -eq 2 ]
|
||||
# approve, continue, approve
|
||||
run bash -c "printf 'a\ny\na\n' | '$AUDIT' ack '$WS'"
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *"2 reports waiting"* ]]
|
||||
[[ "$output" == *".envrc"* ]]
|
||||
[[ "$output" == *"settings.json"* ]] # the second report, same run
|
||||
}
|
||||
|
||||
@test "ack stops at one report when told not to continue" {
|
||||
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
|
||||
snap; echo 'export X=1' > "$WS/.envrc"; cmp_ >/dev/null 2>&1
|
||||
snap; mkdir -p "$WS/.claude"; echo '{}' > "$WS/.claude/settings.json"; cmp_ >/dev/null 2>&1
|
||||
run bash -c "printf 'a\nn\n' | '$AUDIT' ack '$WS'"
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *"still waiting"* ]] # said what is left behind
|
||||
[[ "$output" != *"settings.json"* ]] # and did not open it
|
||||
}
|
||||
|
||||
@test "show indexes the reports rather than dumping one" {
|
||||
# It used to `cat` a single report: a wall of text that answered neither
|
||||
# question an operator has - how much is waiting, and what still needs a
|
||||
# decision - and included findings already acknowledged, so it did not
|
||||
# even shrink as they worked through it.
|
||||
snap
|
||||
echo 'export X=1' > "$WS/.envrc"
|
||||
cmp_ >/dev/null 2>&1
|
||||
run bash -c "'$AUDIT' show '$WS' 2>&1"
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *"1 report(s)"* ]]
|
||||
[[ "$output" == *"-CRITICAL.txt"* ]] # names it
|
||||
[[ "$output" == *"ack starts here"* ]] # and which one ack will open
|
||||
[[ "$output" != *".envrc"* ]] # without the contents
|
||||
}
|
||||
|
||||
@test "show --full prints the report ack would open" {
|
||||
snap
|
||||
echo 'export X=1' > "$WS/.envrc"
|
||||
cmp_ >/dev/null 2>&1
|
||||
run bash -c "'$AUDIT' show '$WS' --full 2>&1"
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *".envrc"* ]]
|
||||
[[ "$output" == *"-CRITICAL.txt"* ]] # said which report it printed
|
||||
[[ "$output" == *"-CRITICAL.txt"* ]]
|
||||
}
|
||||
|
||||
@test "show and ack agree on which report they mean" {
|
||||
@@ -619,19 +704,20 @@ WARNING changed workspace file: .vscode/settings.json
|
||||
REPORT
|
||||
run "$AUDIT" show "$WS"
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *".envrc"* ]] # the stale CRITICAL report...
|
||||
[[ "$output" == *"-CRITICAL.txt"* ]] # the stale CRITICAL report...
|
||||
[[ "$output" != *"settings.json"* ]] # ...not the newer clean one
|
||||
}
|
||||
|
||||
@test "show falls back to the newest report when no CRITICAL one is pending" {
|
||||
@test "show marks the newest report as the one ack will open, absent a CRITICAL" {
|
||||
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
|
||||
report_dir="$AUDITDIR/$slug"; mkdir -p "$report_dir"
|
||||
echo 'older' > "$report_dir/2020-01-01-000000.txt"
|
||||
echo 'newer' > "$report_dir/2026-01-01-000001.txt"
|
||||
run "$AUDIT" show "$WS"
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *"newer"* ]]
|
||||
[[ "$output" != *"older"* ]]
|
||||
[[ "$output" == *"2 report(s)"* ]] # both listed, not just one
|
||||
[[ "$output" == *"2020-01-01-000000.txt"* ]]
|
||||
[[ "$output" == *"2026-01-01-000001.txt"*"ack starts here"* ]]
|
||||
}
|
||||
|
||||
@test "ack presents a stale unacknowledged CRITICAL report before a newer clean one" {
|
||||
@@ -772,39 +858,32 @@ REPORT
|
||||
[[ "$output" != *"-CRITICAL.txt"* ]]
|
||||
}
|
||||
|
||||
@test "a finding that cannot be acknowledged is presented once, not on every run" {
|
||||
# There is nothing to record for one, so without a marker it re-prompted
|
||||
# forever and `ack` could never reach a state of having nothing to ask.
|
||||
@test "a cleared finding is presented once, not on every run" {
|
||||
# There is nothing to record for a surface that no longer exists, so
|
||||
# without a `reviewed` marker the report re-prompted forever and `ack`
|
||||
# could never reach a state of having nothing to ask.
|
||||
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
|
||||
mkdir -p "$PROJDIR/-some-other-project"
|
||||
echo x > "$PROJDIR/-some-other-project/file1"
|
||||
snap
|
||||
echo y > "$PROJDIR/-some-other-project/file2"
|
||||
touch -d '+1 hour' "$PROJDIR/-some-other-project/file2"
|
||||
cmp_ >/dev/null 2>&1
|
||||
run bash -c "printf 's\n' | '$AUDIT' ack '$WS'"
|
||||
[[ "$output" == *"CROSS-PROJECT"* ]]
|
||||
snap; echo 'export X=1' > "$WS/.envrc"; cmp_ >/dev/null 2>&1
|
||||
rm -f "$WS/.envrc"
|
||||
run bash -c "printf 'a\n' | '$AUDIT' ack '$WS'"
|
||||
[[ "$output" == *".envrc"* ]]
|
||||
grep -q '^reviewed ' "$BASEDIR/$slug"
|
||||
|
||||
run bash -c "printf 's\n' | '$AUDIT' ack '$WS'"
|
||||
[[ "$output" != *"CROSS-PROJECT"* ]]
|
||||
run bash -c "printf 'a\n' | '$AUDIT' ack '$WS'"
|
||||
[[ "$output" != *".envrc"* ]]
|
||||
}
|
||||
|
||||
@test "quitting a report does not mark it reviewed" {
|
||||
# The marker means "walked to the end". An abort discards everything
|
||||
# else, and must discard this too or one q would silence the report.
|
||||
# The marker means "walked to the end". An abort discards everything else,
|
||||
# and must discard this too or one q would silence the report.
|
||||
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
|
||||
mkdir -p "$PROJDIR/-some-other-project"
|
||||
echo x > "$PROJDIR/-some-other-project/file1"
|
||||
snap
|
||||
echo y > "$PROJDIR/-some-other-project/file2"
|
||||
touch -d '+1 hour' "$PROJDIR/-some-other-project/file2"
|
||||
cmp_ >/dev/null 2>&1
|
||||
snap; echo 'export X=1' > "$WS/.envrc"; cmp_ >/dev/null 2>&1
|
||||
rm -f "$WS/.envrc"
|
||||
run bash -c "printf 'q\n' | '$AUDIT' ack '$WS'"
|
||||
! grep -q '^reviewed ' "$BASEDIR/$slug" 2>/dev/null
|
||||
|
||||
run bash -c "printf 's\n' | '$AUDIT' ack '$WS'"
|
||||
[[ "$output" == *"CROSS-PROJECT"* ]]
|
||||
run bash -c "printf 'a\n' | '$AUDIT' ack '$WS'"
|
||||
[[ "$output" == *".envrc"* ]]
|
||||
}
|
||||
|
||||
@test "a WARNING git config finding can be acknowledged, both ways (#45)" {
|
||||
@@ -854,24 +933,6 @@ REPORT
|
||||
[[ "$output" != *"-CRITICAL.txt"* ]]
|
||||
}
|
||||
|
||||
@test "ack does not write an unmatchable entry for a finding with no id" {
|
||||
# Was written against a changed git config, which #45 made ackable. The
|
||||
# invariant is about findings that have no id to record at all, so it now
|
||||
# uses the one class that still has none: a cross-project write, whose
|
||||
# subject is another project's slug rather than anything this audit can
|
||||
# hash and pin.
|
||||
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
|
||||
mkdir -p "$PROJDIR/-some-other-project"
|
||||
echo x > "$PROJDIR/-some-other-project/file1"
|
||||
snap
|
||||
echo y > "$PROJDIR/-some-other-project/file2"
|
||||
touch -d '+1 hour' "$PROJDIR/-some-other-project/file2"
|
||||
cmp_ >/dev/null 2>&1
|
||||
run bash -c "printf 'a\n' | '$AUDIT' ack '$WS'"
|
||||
[[ "$output" == *"cannot"* ]]
|
||||
! grep -qE '^(safe|spam) ' "$BASEDIR/$slug" 2>/dev/null
|
||||
}
|
||||
|
||||
@test "an ack-approved finding is suppressed on the next compare (id round-trips through the baseline)" {
|
||||
snap; echo 'export X=1' > "$WS/.envrc"; cmp_ >/dev/null 2>&1
|
||||
run bash -c "printf 'a\n' | '$AUDIT' ack '$WS'"
|
||||
@@ -900,24 +961,37 @@ REPORT
|
||||
# so the report must stay eviction-protected
|
||||
}
|
||||
|
||||
@test "ack refuses to approve a finding whose path no longer exists, and the CRITICAL suffix survives" {
|
||||
# A since-deleted path has no content left to pin a `safe` approval to -
|
||||
# writing one anyway (the old behaviour) produced a baseline entry
|
||||
# baseline_suppresses() can never match (its own empty-sha guard rejects
|
||||
# it as malformed), so the surface stayed permanently unsuppressible -
|
||||
# while `a` still advanced crit_acked and let the report shed its
|
||||
# -CRITICAL.txt protection for good. Refusing outright keeps both the
|
||||
# baseline and the report's protection honest.
|
||||
@test "a finding whose path no longer exists is cleared, not refused forever" {
|
||||
# Refusing outright kept the baseline honest - there is no content left to
|
||||
# pin a `safe` entry to - but it also meant crit_acked never reached
|
||||
# crit_seen, so the report kept -CRITICAL.txt. select_report prefers
|
||||
# CRITICAL, so one deleted file pinned `ack` to a report that no decision
|
||||
# could resolve and hid every other report behind it, permanently.
|
||||
#
|
||||
# Still nothing written to the baseline: an entry with an empty sha can
|
||||
# never match. The finding is simply cleared, because a surface that does
|
||||
# not exist does not run. If it comes back, it comes back as new.
|
||||
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
|
||||
snap; echo 'export X=1' > "$WS/.envrc"; cmp_ >/dev/null 2>&1
|
||||
rm -f "$WS/.envrc" # gone by the time ack runs
|
||||
run bash -c "printf 'a\n' | '$AUDIT' ack '$WS'"
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *"cannot approve: .envrc no longer exists"* ]]
|
||||
[[ "$output" == *"nothing recorded"* ]]
|
||||
[ ! -f "$BASEDIR/$slug" ] # nothing written to the baseline at all
|
||||
[[ "$output" == *"cleared"* ]]
|
||||
[[ "$output" == *".envrc"* ]]
|
||||
! grep -qE '^(safe|spam) ' "$BASEDIR/$slug" 2>/dev/null # nothing unmatchable recorded
|
||||
grep -q '^reviewed ' "$BASEDIR/$slug" # but the report is done with
|
||||
run bash -c "ls '$AUDITDIR/$slug'"
|
||||
[[ "$output" == *"-CRITICAL.txt"* ]] # unacknowledged CRITICAL keeps its protection
|
||||
[[ "$output" != *"-CRITICAL.txt"* ]] # and no longer blocks the queue
|
||||
}
|
||||
|
||||
# The other half: clearing is not a way to launder a finding whose surface is
|
||||
# still there. Only an absent one can be cleared.
|
||||
@test "a finding whose path still exists is approved against its content, not cleared" {
|
||||
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
|
||||
snap; echo 'export X=1' > "$WS/.envrc"; cmp_ >/dev/null 2>&1
|
||||
run bash -c "printf 'a\n' | '$AUDIT' ack '$WS'"
|
||||
[[ "$output" != *"cleared"* ]]
|
||||
grep -q '^safe .envrc ' "$BASEDIR/$slug"
|
||||
}
|
||||
|
||||
@test "a workspace path containing its own ': ' cannot hijack ack's id derivation" {
|
||||
@@ -1060,7 +1134,10 @@ REPORT
|
||||
|
||||
@test "ack records a safe tcb entry whose id round-trips and suppresses the next compare" {
|
||||
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
|
||||
mkdir -p "$WS/bin"; echo x > "$WS/bin/x"; echo x > "$WS/install.sh"
|
||||
# Hidden from git, which is the only shape that is still a finding.
|
||||
mkdir -p "$WS/bin" "$WS/.git/info"
|
||||
echo x > "$WS/bin/x"; echo x > "$WS/install.sh"
|
||||
echo 'bin/x' > "$WS/.git/info/exclude"
|
||||
export DEJIMA_AUDIT_REPO_ROOT="$(readlink -f "$WS")"
|
||||
snap
|
||||
echo changed > "$WS/bin/x"
|
||||
@@ -1118,7 +1195,7 @@ REPORT
|
||||
cmp_ >/dev/null
|
||||
run bash -c "cd '$WS' && '$AUDIT' show 2>/dev/null"
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *".envrc"* ]]
|
||||
[[ "$output" == *"report(s) for $WS"* ]]
|
||||
}
|
||||
|
||||
@test "show finds the workspace when run from a subdirectory" {
|
||||
@@ -1128,7 +1205,7 @@ REPORT
|
||||
cmp_ >/dev/null
|
||||
run bash -c "cd '$WS/src/deep' && '$AUDIT' show 2>/dev/null"
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *".envrc"* ]]
|
||||
[[ "$output" == *"report(s) for $WS"* ]]
|
||||
}
|
||||
|
||||
@test "show finds the parent repo when run from a linked worktree" {
|
||||
@@ -1143,7 +1220,7 @@ REPORT
|
||||
cmp_ >/dev/null
|
||||
run bash -c "cd '$WS/.worktrees/wt' && '$AUDIT' show 2>/dev/null"
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *".envrc"* ]]
|
||||
[[ "$output" == *"report(s) for $WS"* ]]
|
||||
}
|
||||
|
||||
@test "show finds the parent repo's report even after the worktree itself was snapshotted" {
|
||||
@@ -1158,7 +1235,7 @@ REPORT
|
||||
"$AUDIT" snapshot "$WS/.worktrees/wt" "$(mktemp)"
|
||||
run bash -c "cd '$WS/.worktrees/wt' && '$AUDIT' show 2>/dev/null"
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *".envrc"* ]]
|
||||
[[ "$output" == *"report(s) for $WS"* ]]
|
||||
}
|
||||
|
||||
@test "show names the path it resolved when that workspace has no reports" {
|
||||
@@ -1179,7 +1256,7 @@ REPORT
|
||||
other="$(mktemp -d)"; git -C "$other" init -q
|
||||
run bash -c "cd '$other' && '$AUDIT' show '$WS' 2>/dev/null"
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *".envrc"* ]]
|
||||
[[ "$output" == *"report(s) for $WS"* ]]
|
||||
rm -rf "$other"
|
||||
}
|
||||
|
||||
@@ -1324,9 +1401,9 @@ key_of() { printf '%s' "$1" | sha256sum | cut -d' ' -f1; }
|
||||
snap
|
||||
head -1 "$STATE" | grep -q '^### staging /'
|
||||
# The existing section ranges are anchored at ### gitconfig and
|
||||
# ### statusline, so putting staging first must leave them untouched.
|
||||
# ### hostexec, so putting staging first must leave them untouched.
|
||||
grep -q '^### gitconfig$' "$STATE"
|
||||
grep -q '^### statusline$' "$STATE"
|
||||
grep -q '^### hostexec$' "$STATE"
|
||||
}
|
||||
|
||||
@test "denylisted surfaces are never staged, not even transiently" {
|
||||
@@ -1853,3 +1930,43 @@ sidecar() { local r; r="$(find "$AUDITDIR" -name '*.txt' | sort | tail -1)"; r="
|
||||
run "$AUDIT" compare "$WS" "$STATE"
|
||||
[[ "$output" != *"writable"* ]]
|
||||
}
|
||||
|
||||
@test "a host-execution finding still records the id existing baselines hold" {
|
||||
# The id is what an approval is stored under. Generalising the mechanism
|
||||
# must not rename it: every baseline an operator already has says
|
||||
# "statusline:statusline.sh", and an id that no longer matches re-fires
|
||||
# a CRITICAL they approved. The namespace and leaf come from the plugin
|
||||
# for exactly this reason - they are the harness's to keep stable.
|
||||
printf '#!/bin/sh\necho original\n' > "$DEJIMA_AUDIT_STATUSLINE"
|
||||
snap
|
||||
printf '#!/bin/sh\necho changed\n' > "$DEJIMA_AUDIT_STATUSLINE"
|
||||
run cmp_
|
||||
[[ "$output" == *"HOST-CODE-EXECUTION"* ]] || { echo "$output"; return 1; }
|
||||
# Approve it, then confirm the baseline line is the historical id.
|
||||
printf 'a\n' | "$AUDIT" ack "$WS" >/dev/null 2>&1 || true
|
||||
run cat "$BASEDIR/$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
|
||||
[[ "$output" == *"safe statusline:statusline.sh "* ]] \
|
||||
|| { echo "baseline id changed: $output"; return 1; }
|
||||
}
|
||||
|
||||
@test "a harness declaring no host-execution surface is watched for none" {
|
||||
# Not every harness runs something on the host from its own config. The
|
||||
# absence must mean nothing is checked, not that the previous harness's
|
||||
# path is.
|
||||
printf '#!/bin/sh\necho original\n' > "$DEJIMA_AUDIT_STATUSLINE"
|
||||
snap
|
||||
cat > "$CONFIG_DIR/config" <<EOF
|
||||
[core]
|
||||
harness = claude-code
|
||||
[harness.claude-code]
|
||||
projects_dir = $PROJDIR
|
||||
statusline_path =
|
||||
[audit]
|
||||
dir = $AUDITDIR
|
||||
baselines_dir = $BASEDIR
|
||||
EOF
|
||||
printf '#!/bin/sh\necho changed\n' > "$DEJIMA_AUDIT_STATUSLINE"
|
||||
run cmp_
|
||||
[[ "$output" != *"HOST-CODE-EXECUTION"* ]] \
|
||||
|| { echo "watched a surface the harness declares no path for: $output"; return 1; }
|
||||
}
|
||||
|
||||
+530
-19
@@ -188,6 +188,16 @@ EOF
|
||||
|
||||
write_config() {
|
||||
printf '%s\n' "$@" > "$CONFIG_DIR/config"
|
||||
# A launch is refused without a harness, so every fixture needs one. Its
|
||||
# own [core] section, which is legal and resolves the same; a test that
|
||||
# means to omit it writes the file directly.
|
||||
# A key assignment, not the word anywhere in the arguments: a [mounts]
|
||||
# path containing "harness" would otherwise suppress the seeding.
|
||||
local _a _has=0
|
||||
for _a in "$@"; do
|
||||
[[ "$_a" =~ ^[[:space:]]*harness[[:space:]]*= ]] && _has=1
|
||||
done
|
||||
[ "$_has" -eq 1 ] || printf '[core]\nharness = claude-code\n' >> "$CONFIG_DIR/config"
|
||||
}
|
||||
|
||||
@test "config_get returns the schema default when the key is absent" {
|
||||
@@ -1045,24 +1055,16 @@ run_install() {
|
||||
|
||||
local name out hit=0
|
||||
|
||||
# docs/ and systemd/ are pure operator-facing prose - nothing there is
|
||||
# live shell plumbing, so any mention of a retired name is either the
|
||||
# deliberate "no longer read" notice (install.sh's remote.conf one lives
|
||||
# outside these two directories, but the exclusion is kept here too in
|
||||
# case a future doc adds one), a stale instruction, or one of the two
|
||||
# cases below that are neither:
|
||||
# docs/ and systemd/ are operator-facing prose, no live plumbing, so a
|
||||
# retired name there is either the deliberate "no longer read" notice, a
|
||||
# stale instruction, or one of two cases that are neither:
|
||||
#
|
||||
# - A manifest-field line (`env_export = GITEA_API=api ...`): this is
|
||||
# docs/providers.md quoting a forge manifest's `env_export` value
|
||||
# verbatim, which *declares* a container-side environment variable -
|
||||
# the exact thing env_export is for - not an instruction to an operator
|
||||
# to set one. `GITEA_API` and friends are retired only as host-side,
|
||||
# operator-set variables (what _warn_legacy_env's table is about); they
|
||||
# are not retired as the container-side names env_export produces, and
|
||||
# docs/providers.md documenting that mapping is not a regression.
|
||||
# - A backtick-quoted mention (`` `GITEA_API` ``): prose naming a
|
||||
# variable - "the container gets `GITEA_API`" - reads a value, it
|
||||
# doesn't tell anyone to set one.
|
||||
# - A manifest-field line (`env_export = GITEA_API=api ...`), which is
|
||||
# providers.md quoting a manifest verbatim. These names are retired as
|
||||
# host-side operator-set variables, not as the container-side names
|
||||
# env_export produces.
|
||||
# - A backtick-quoted mention - prose naming a variable reads a value, it
|
||||
# does not tell anyone to set one.
|
||||
while IFS= read -r name; do
|
||||
out="$(grep -rnE "\b${name}\b" "$REPO_ROOT/docs" "$REPO_ROOT/systemd" 2>/dev/null \
|
||||
| grep -v 'no longer read' \
|
||||
@@ -1134,11 +1136,273 @@ exit 0
|
||||
EOF
|
||||
chmod +x "$STUB_DIR/docker"
|
||||
docker_log="$(mktemp)"
|
||||
HOME="$RW_HOME" PATH="$STUB_DIR:$PATH" SSH_AUTH_SOCK= \
|
||||
HOME="$RW_HOME" PATH="$STUB_DIR:$PATH" SSH_AUTH_SOCK="${RW_AGENT_SOCK-}" \
|
||||
SANDBOX_TEST_DOCKER_LOG="$docker_log" \
|
||||
run "$REPO_ROOT/bin/dejima" "$1"
|
||||
}
|
||||
|
||||
# A real agent, carrying nothing. `ssh-add -l` against it exits nonzero,
|
||||
# which is the condition the "no identities loaded" warning keys on, and a
|
||||
# handmade socket file would not reproduce it.
|
||||
_empty_agent() {
|
||||
eval "$(ssh-agent -s)" >/dev/null
|
||||
RW_AGENT_SOCK="$SSH_AUTH_SOCK"
|
||||
RW_AGENT_PID="$SSH_AGENT_PID"
|
||||
}
|
||||
_kill_agent() { [ -n "${RW_AGENT_PID:-}" ] && kill "$RW_AGENT_PID" 2>/dev/null; return 0; }
|
||||
|
||||
# The forwarded agent is the fallback; [core] ssh_key is the arrangement the
|
||||
# docs recommend, because it is a dedicated identity and works unattended.
|
||||
# So an empty agent is only worth mentioning when nothing else can push -
|
||||
# otherwise the warning fires at exactly the operators who took the advice.
|
||||
# ~/.claude/file-history is keyed by session id, so nothing in its own paths
|
||||
# says which project a directory belongs to. The transcript names are what
|
||||
# resolve it, and this is the assertion that the resolution is right: a
|
||||
# session of this workspace is mounted, one belonging to another project is
|
||||
# not, and neither is one whose slug merely extends this workspace's - the
|
||||
# sibling case that #104 turned on.
|
||||
@test "file-history is mounted for this workspace's sessions and no others" {
|
||||
_rw_setup
|
||||
workspace="$(mktemp -d)"
|
||||
local slug sib own foreign sibling
|
||||
slug="$(printf '%s' "$workspace" | sed 's/[^a-zA-Z0-9]/-/g')"
|
||||
sib="$slug-extra"
|
||||
own=11111111-1111-1111-1111-111111111111
|
||||
foreign=22222222-2222-2222-2222-222222222222
|
||||
sibling=33333333-3333-3333-3333-333333333333
|
||||
mkdir -p "$RW_HOME/.claude/projects/$slug" \
|
||||
"$RW_HOME/.claude/projects/-some-other-project" \
|
||||
"$RW_HOME/.claude/projects/$sib"
|
||||
: > "$RW_HOME/.claude/projects/$slug/$own.jsonl"
|
||||
: > "$RW_HOME/.claude/projects/-some-other-project/$foreign.jsonl"
|
||||
: > "$RW_HOME/.claude/projects/$sib/$sibling.jsonl"
|
||||
mkdir -p "$RW_HOME/.claude/file-history/$own" \
|
||||
"$RW_HOME/.claude/file-history/$foreign" \
|
||||
"$RW_HOME/.claude/file-history/$sibling"
|
||||
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only'
|
||||
_rw_launch "$workspace"
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
grep -qF "file-history/$own" "$docker_log" \
|
||||
|| { echo "own session not mounted:"; grep file-history "$docker_log"; return 1; }
|
||||
run grep -F "file-history/$foreign" "$docker_log"
|
||||
[ "$status" -ne 0 ] || { echo "another project's file-history was mounted"; return 1; }
|
||||
run grep -F "file-history/$sibling" "$docker_log"
|
||||
[ "$status" -ne 0 ] || { echo "a sibling project's file-history was mounted"; return 1; }
|
||||
rm -rf "$workspace"
|
||||
}
|
||||
|
||||
# No default on purpose: a release changing it would move an operator who
|
||||
# never chose to a different harness on upgrade.
|
||||
@test "a config with no [core] harness is refused, and says what to add" {
|
||||
_rw_setup
|
||||
workspace="$(mktemp -d)"
|
||||
# Written directly, not through write_config, which seeds a harness for
|
||||
# every other fixture precisely because a launch needs one.
|
||||
printf '[core]\nssh_key = %s\nforge = none\nworkflow = local-only\n' \
|
||||
"$RW_KEY" > "$CONFIG_DIR/config"
|
||||
_rw_launch "$workspace"
|
||||
[ "$status" -ne 0 ] || { echo "launched with no harness selected:"; echo "$output"; return 1; }
|
||||
[[ "$output" == *"[core] harness is not set"* ]]
|
||||
[[ "$output" == *"harness = claude-code"* ]] # the line to add
|
||||
[[ "$output" == *"Available:"*"claude-code"* ]]
|
||||
rm -rf "$workspace"
|
||||
}
|
||||
|
||||
@test "the harness schema carries no default to fall back to" {
|
||||
# Directly again: write_config seeds one, which is the whole point of it.
|
||||
printf '[core]\nforge = none\nworkflow = local-only\n' > "$CONFIG_DIR/config"
|
||||
run lib config_get core harness
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
[ "$output" = "" ]
|
||||
}
|
||||
|
||||
# --print-config is what an operator reaches for to find out what is unset, so
|
||||
# it has to survive the thing being unset.
|
||||
@test "--print-config still works when no harness is selected" {
|
||||
printf '[core]\nforge = none\nworkflow = local-only\n' > "$CONFIG_DIR/config"
|
||||
run "$REPO_ROOT/bin/dejima" --print-config
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
[[ "$output" == *"core.harness="* ]]
|
||||
[[ "$output" != *"invalid harness name"* ]]
|
||||
}
|
||||
|
||||
@test "an empty ssh-agent is not warned about when [core] ssh_key is set" {
|
||||
_rw_setup
|
||||
workspace="$(mktemp -d)"
|
||||
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only'
|
||||
_empty_agent
|
||||
_rw_launch "$workspace"
|
||||
_kill_agent
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
[[ "$output" != *"no identities loaded"* ]] || { echo "$output"; return 1; }
|
||||
rm -rf "$workspace"
|
||||
}
|
||||
|
||||
# The positive control for the test above: with no key configured the
|
||||
# forwarded agent really is the only way to push, and an empty one is worth
|
||||
# saying so before it fails deep into a session.
|
||||
# --extra-readonly / --extra-read-write append to the config's own lists
|
||||
# rather than carrying their own mount code, so these assert the mount lands
|
||||
# and then that every refusal the configured form gets applies to the flag
|
||||
# too. If they ever stop sharing a path, the guard tests below pass while the
|
||||
# flags quietly stop being covered by them.
|
||||
_rw_launch_flags() {
|
||||
local ws="$1"; shift
|
||||
STUB_DIR="$(mktemp -d)"
|
||||
cat > "$STUB_DIR/docker" <<'EOF'
|
||||
#!/bin/sh
|
||||
for a in "$@"; do printf '%s\n' "$a" >> "$SANDBOX_TEST_DOCKER_LOG"; done
|
||||
exit 0
|
||||
EOF
|
||||
chmod +x "$STUB_DIR/docker"
|
||||
docker_log="$(mktemp)"
|
||||
HOME="$RW_HOME" PATH="$STUB_DIR:$PATH" SSH_AUTH_SOCK= \
|
||||
SANDBOX_TEST_DOCKER_LOG="$docker_log" \
|
||||
run "$REPO_ROOT/bin/dejima" "$@" "$ws"
|
||||
}
|
||||
|
||||
@test "--extra-readonly and --extra-read-write mount for one launch" {
|
||||
_rw_setup
|
||||
workspace="$(mktemp -d)"; ro="$(mktemp -d)"; rw="$(mktemp -d)"
|
||||
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only'
|
||||
_rw_launch_flags "$workspace" --extra-readonly "$ro" --extra-read-write "$rw"
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
grep -qxF "$ro:$ro:ro" "$docker_log" || { cat "$docker_log"; return 1; }
|
||||
grep -qxF "$rw:$rw" "$docker_log" || { cat "$docker_log"; return 1; }
|
||||
rm -rf "$workspace" "$ro" "$rw"
|
||||
}
|
||||
|
||||
@test "the short forms are the same flags, and both are repeatable" {
|
||||
_rw_setup
|
||||
workspace="$(mktemp -d)"; a="$(mktemp -d)"; b="$(mktemp -d)"
|
||||
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only'
|
||||
_rw_launch_flags "$workspace" --ro "$a" --ro "$b"
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
grep -qxF "$a:$a:ro" "$docker_log" || { cat "$docker_log"; return 1; }
|
||||
grep -qxF "$b:$b:ro" "$docker_log" || { cat "$docker_log"; return 1; }
|
||||
rm -rf "$workspace" "$a" "$b"
|
||||
}
|
||||
|
||||
# The flags add to the configured lists; they do not replace them. A launch
|
||||
# that dropped the config's own entries would still pass the two tests above.
|
||||
@test "a flag adds to [mounts] readonly rather than replacing it" {
|
||||
_rw_setup
|
||||
workspace="$(mktemp -d)"; configured="$(mktemp -d)"; extra="$(mktemp -d)"
|
||||
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only' \
|
||||
'[mounts]' "readonly = $configured"
|
||||
_rw_launch_flags "$workspace" --ro "$extra"
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
grep -qxF "$configured:$configured:ro" "$docker_log" || { cat "$docker_log"; return 1; }
|
||||
grep -qxF "$extra:$extra:ro" "$docker_log" || { cat "$docker_log"; return 1; }
|
||||
rm -rf "$workspace" "$configured" "$extra"
|
||||
}
|
||||
|
||||
# The point of appending to the same list: a flag is a way to say where, not
|
||||
# a way past the refusals. Both directions, since the two lists are guarded
|
||||
# by different code.
|
||||
@test "--extra-readonly is refused when it names the approval store" {
|
||||
_rw_setup
|
||||
workspace="$(mktemp -d)"
|
||||
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only'
|
||||
_rw_launch_flags "$workspace" --ro "$CONFIG_DIR"
|
||||
[ "$status" -ne 0 ] || { echo "launched anyway:"; echo "$output"; return 1; }
|
||||
[[ "$output" == *"Refusing --extra-readonly path"* ]] || { echo "$output"; return 1; }
|
||||
rm -rf "$workspace"
|
||||
}
|
||||
|
||||
@test "--extra-read-write is refused when it names the approval store" {
|
||||
_rw_setup
|
||||
workspace="$(mktemp -d)"
|
||||
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only'
|
||||
_rw_launch_flags "$workspace" --rw "$CONFIG_DIR"
|
||||
[ "$status" -ne 0 ] || { echo "launched anyway:"; echo "$output"; return 1; }
|
||||
[[ "$output" == *"Refusing --extra-read-write path"* ]] || { echo "$output"; return 1; }
|
||||
rm -rf "$workspace"
|
||||
}
|
||||
|
||||
# A refusal that named "[mounts] readonly" would send someone to a config
|
||||
# file that does not mention the path.
|
||||
@test "a rejected flag path is reported as a flag, not as a config entry" {
|
||||
_rw_setup
|
||||
workspace="$(mktemp -d)"
|
||||
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only'
|
||||
_rw_launch_flags "$workspace" --ro /nonexistent-flag-path
|
||||
[ "$status" -ne 0 ]
|
||||
[[ "$output" == *"--extra-readonly path does not exist"* ]] || { echo "$output"; return 1; }
|
||||
rm -rf "$workspace"
|
||||
}
|
||||
|
||||
# Both lists are colon-separated, so such a path cannot be represented in one
|
||||
# and would split into two mounts that each fail the existence check.
|
||||
@test "a flag path containing a colon is refused" {
|
||||
_rw_setup
|
||||
workspace="$(mktemp -d)"
|
||||
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only'
|
||||
_rw_launch_flags "$workspace" --ro /tmp/has:colon
|
||||
[ "$status" -ne 0 ]
|
||||
[[ "$output" == *"must not contain a colon"* ]] || { echo "$output"; return 1; }
|
||||
rm -rf "$workspace"
|
||||
}
|
||||
|
||||
# #15. Both of these reach `extra_mounts+=(-v ...)` with a path computed at
|
||||
# run time, so the static grep for a literal $PROJECT_STORE / config_root
|
||||
# cannot see either one - it passed throughout. [mounts] read_write has been
|
||||
# guarded since it was added; these two were not.
|
||||
@test "[mounts] readonly is refused when it names the approval store" {
|
||||
_rw_setup
|
||||
workspace="$(mktemp -d)"
|
||||
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only' \
|
||||
'[mounts]' "readonly = $CONFIG_DIR"
|
||||
_rw_launch "$workspace"
|
||||
[ "$status" -ne 0 ] || { echo "launched anyway:"; echo "$output"; return 1; }
|
||||
[[ "$output" == *"Refusing [mounts] readonly entry"* ]] || { echo "$output"; return 1; }
|
||||
rm -rf "$workspace"
|
||||
}
|
||||
|
||||
# The target is discovered from a symlink rather than configured, so nothing
|
||||
# had vetted it: a hook pointing into the approval store mounted the store.
|
||||
@test "a ~/.claude symlink is refused when its target sits in the approval store" {
|
||||
_rw_setup
|
||||
workspace="$(mktemp -d)"
|
||||
mkdir -p "$RW_HOME/.claude/hooks" "$CONFIG_DIR/sub"
|
||||
: > "$CONFIG_DIR/sub/hook.sh"
|
||||
ln -s "$CONFIG_DIR/sub/hook.sh" "$RW_HOME/.claude/hooks/hook.sh"
|
||||
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only'
|
||||
_rw_launch "$workspace"
|
||||
[ "$status" -ne 0 ] || { echo "launched anyway:"; echo "$output"; return 1; }
|
||||
[[ "$output" == *"Refusing ~/.claude symlink target"* ]] || { echo "$output"; return 1; }
|
||||
rm -rf "$workspace"
|
||||
}
|
||||
|
||||
# The ordinary case the symlink scan exists for: this repo's own hooks/ are
|
||||
# symlinked into ~/.claude and must still be mounted. Without this, refusing
|
||||
# everything would pass the two tests above.
|
||||
@test "a ~/.claude symlink to an ordinary directory is still mounted" {
|
||||
_rw_setup
|
||||
workspace="$(mktemp -d)"
|
||||
hookdir="$(mktemp -d)"
|
||||
: > "$hookdir/hook.sh"
|
||||
mkdir -p "$RW_HOME/.claude/hooks"
|
||||
ln -s "$hookdir/hook.sh" "$RW_HOME/.claude/hooks/hook.sh"
|
||||
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only'
|
||||
_rw_launch "$workspace"
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
grep -qxF "$hookdir:$hookdir:ro" "$docker_log" \
|
||||
|| { echo "hook target not mounted:"; cat "$docker_log"; return 1; }
|
||||
rm -rf "$workspace" "$hookdir"
|
||||
}
|
||||
|
||||
@test "an empty ssh-agent is warned about when [core] ssh_key is not set" {
|
||||
_rw_setup
|
||||
workspace="$(mktemp -d)"
|
||||
write_config '[core]' 'forge = none' 'workflow = local-only'
|
||||
_empty_agent
|
||||
_rw_launch "$workspace"
|
||||
_kill_agent
|
||||
[[ "$output" == *"no identities loaded"* ]] || { echo "$output"; return 1; }
|
||||
rm -rf "$workspace"
|
||||
}
|
||||
|
||||
@test "a read_write entry is mounted without :ro, unlike a readonly one" {
|
||||
_rw_setup
|
||||
rw="$(mktemp -d)"
|
||||
@@ -1338,7 +1602,7 @@ EOF
|
||||
# a gitea token reaching the container: the shipped defaults select no
|
||||
# forge at all, so leaving them implicit would mean nothing ever reads
|
||||
# token_file and the refusal under test could never fire.
|
||||
printf '[core]\nfirewall = on\nssh_key = %s\nforge = gitea\nworkflow = worktree-pr\n[forge.gitea]\nssh_host = git.example\napi = https://api.example\ntoken_file = %s/token\n' \
|
||||
printf '[core]\nharness = claude-code\nfirewall = on\nssh_key = %s\nforge = gitea\nworkflow = worktree-pr\n[forge.gitea]\nssh_host = git.example\napi = https://api.example\ntoken_file = %s/token\n' \
|
||||
"$RW_KEY" "$CONFIG_DIR" > "$CONFIG_DIR/config"
|
||||
printf 'real-secret\ngarbage\n' > "$CONFIG_DIR/token"
|
||||
STUB_DIR="$(mktemp -d)"
|
||||
@@ -1357,3 +1621,250 @@ EOF
|
||||
|| { echo "docker was invoked despite the refusal"; return 1; }
|
||||
rm -rf "$workspace" "$RW_HOME" "$STUB_DIR"
|
||||
}
|
||||
|
||||
# --- epitaph: what a session leaves when its context dies ---------------------------------------------------
|
||||
#
|
||||
# What a long session worked out is in the summary Claude Code writes when it
|
||||
# compacts, and that goes away with the container. The store is host-side and
|
||||
# never mounted, so a later session can neither read what an earlier one
|
||||
# concluded nor edit what this one is about to record (#104).
|
||||
|
||||
_epitaph_fixture() {
|
||||
EPI_STORE="$(mktemp -d)"
|
||||
EPI_TR="$(mktemp -d)"
|
||||
EPI_WS="$(mktemp -d)"; git -C "$EPI_WS" init -q
|
||||
EPI_SLUG="$(printf '%s' "$EPI_WS" | sed 's/[^a-zA-Z0-9]/-/g')"
|
||||
mkdir -p "$EPI_TR/$EPI_SLUG"
|
||||
write_config '[epitaph]' "dir = $EPI_STORE"
|
||||
}
|
||||
|
||||
# One compact_boundary carrying the metadata, then the summary itself.
|
||||
_epitaph_transcript() {
|
||||
local f="$EPI_TR/$EPI_SLUG/$1.jsonl" text="${2:-worked out X}" uid="${3:-aaaabbbb}"
|
||||
{
|
||||
printf '{"type":"system","subtype":"compact_boundary","compactMetadata":{"trigger":"manual","preTokens":900,"postTokens":90}}\n'
|
||||
printf '{"type":"user","isCompactSummary":true,"uuid":"%s","sessionId":"s-1","timestamp":"2026-01-02T03:04:05.000Z","message":{"content":"%s"}}\n' "$uid" "$text"
|
||||
} > "$f"
|
||||
}
|
||||
|
||||
@test "an epitaph is written out of the container's reach" {
|
||||
_epitaph_fixture
|
||||
_epitaph_transcript s1 "the thing this session worked out"
|
||||
run "$REPO_ROOT/bin/dejima-epitaph" save "$EPI_WS" "$EPI_TR"
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
local saved; saved="$(find "$EPI_STORE" -name '*.md')"
|
||||
[ -n "$saved" ]
|
||||
grep -q 'the thing this session worked out' "$saved"
|
||||
grep -q 'trigger: manual' "$saved"
|
||||
grep -q '900 before, 90 after' "$saved"
|
||||
rm -rf "$EPI_STORE" "$EPI_TR" "$EPI_WS"
|
||||
}
|
||||
|
||||
@test "saving twice does not duplicate an epitaph already kept" {
|
||||
# `save` runs on every exit, and a session compacted three times has two
|
||||
# epitaphs an earlier run already wrote.
|
||||
_epitaph_fixture
|
||||
_epitaph_transcript s1 "once only"
|
||||
"$REPO_ROOT/bin/dejima-epitaph" save "$EPI_WS" "$EPI_TR" >/dev/null 2>&1
|
||||
"$REPO_ROOT/bin/dejima-epitaph" save "$EPI_WS" "$EPI_TR" >/dev/null 2>&1
|
||||
[ "$(find "$EPI_STORE" -name '*.md' | wc -l)" -eq 1 ]
|
||||
rm -rf "$EPI_STORE" "$EPI_TR" "$EPI_WS"
|
||||
}
|
||||
|
||||
@test "another project's epitaphs are not collected" {
|
||||
_epitaph_fixture
|
||||
_epitaph_transcript s1 "mine"
|
||||
mkdir -p "$EPI_TR/-some-other-project"
|
||||
printf '{"type":"user","isCompactSummary":true,"uuid":"cccc","sessionId":"s-2","timestamp":"2026-01-02T03:04:05.000Z","message":{"content":"theirs"}}\n' \
|
||||
> "$EPI_TR/-some-other-project/s2.jsonl"
|
||||
"$REPO_ROOT/bin/dejima-epitaph" save "$EPI_WS" "$EPI_TR" >/dev/null 2>&1
|
||||
run grep -rl 'theirs' "$EPI_STORE"
|
||||
[ "$status" -ne 0 ] || { echo "collected another project's summary"; return 1; }
|
||||
grep -rq 'mine' "$EPI_STORE"
|
||||
rm -rf "$EPI_STORE" "$EPI_TR" "$EPI_WS"
|
||||
}
|
||||
|
||||
@test "no epitaph is written when [epitaph] enabled is off" {
|
||||
_epitaph_fixture
|
||||
write_config '[epitaph]' "dir = $EPI_STORE" 'enabled = off'
|
||||
_epitaph_transcript s1 "should not be kept"
|
||||
"$REPO_ROOT/bin/dejima-epitaph" save "$EPI_WS" "$EPI_TR" >/dev/null 2>&1
|
||||
[ -z "$(find "$EPI_STORE" -name '*.md' 2>/dev/null)" ]
|
||||
rm -rf "$EPI_STORE" "$EPI_TR" "$EPI_WS"
|
||||
}
|
||||
|
||||
@test "a slug that kept nothing gets no directory" {
|
||||
# A workspace's worktrees are slugs of its own, and they have transcripts;
|
||||
# what they mostly do not have is a compaction summary in any of them. A
|
||||
# directory per slug therefore made the store read as one entry per
|
||||
# worktree, all of them empty.
|
||||
_epitaph_fixture
|
||||
_epitaph_transcript s1 "kept"
|
||||
git -C "$EPI_WS" -c user.email=t@example.com -c user.name=t \
|
||||
commit -q --allow-empty -m init
|
||||
local wt="$EPI_WS/wt" wt_slug
|
||||
git -C "$EPI_WS" worktree add -q "$wt" -b side
|
||||
wt_slug="$(printf '%s' "$wt" | sed 's/[^a-zA-Z0-9]/-/g')"
|
||||
mkdir -p "$EPI_TR/$wt_slug"
|
||||
printf '{"type":"user","message":{"content":"no summary in here"}}\n' \
|
||||
> "$EPI_TR/$wt_slug/s2.jsonl"
|
||||
# A directory an earlier run already left behind goes too.
|
||||
mkdir -p "$EPI_STORE/$wt_slug"
|
||||
"$REPO_ROOT/bin/dejima-epitaph" save "$EPI_WS" "$EPI_TR" >/dev/null 2>&1
|
||||
[ ! -d "$EPI_STORE/$wt_slug" ] \
|
||||
|| { echo "empty directory left for $wt_slug"; return 1; }
|
||||
[ -n "$(find "$EPI_STORE/$EPI_SLUG" -name '*.md' 2>/dev/null)" ] \
|
||||
|| { echo "the workspace's own epitaph was not kept"; return 1; }
|
||||
git -C "$EPI_WS" worktree remove --force "$wt" 2>/dev/null || true
|
||||
rm -rf "$EPI_STORE" "$EPI_TR" "$EPI_WS"
|
||||
}
|
||||
|
||||
@test "the epitaph store is not mounted into the container" {
|
||||
# The whole point: a session must not be able to read what earlier
|
||||
# sessions concluded, or edit what this one is about to record.
|
||||
run grep -n 'epitaph' "$REPO_ROOT/docker/docker-compose.yml"
|
||||
[ "$status" -ne 0 ] || { echo "$output"; return 1; }
|
||||
run grep -nE 'extra_mounts\+=.*epitaph' "$REPO_ROOT/bin/dejima"
|
||||
[ "$status" -ne 0 ] || { echo "$output"; return 1; }
|
||||
}
|
||||
|
||||
# --- harness plugins ------------------------------------------------------
|
||||
|
||||
@test "a harness with a manifest but no plugin is refused" {
|
||||
# Support is per harness and deliberate. A manifest alone would present
|
||||
# as a supported harness while none of what dejima does for a harness
|
||||
# had been written, which is the "thin drop-in manifest" this design
|
||||
# exists to prevent.
|
||||
_rw_setup
|
||||
workspace="$(mktemp -d)"
|
||||
# A provider root of its own, via the DEJIMA_PROVIDER_ROOT test hook,
|
||||
# rather than a directory added to the checkout: a suite that writes into
|
||||
# the repository it is testing leaves litter when it aborts and collides
|
||||
# with a concurrent run.
|
||||
proot="$(mktemp -d)"
|
||||
cp -a "$REPO_ROOT/forges" "$REPO_ROOT/workflows" "$REPO_ROOT/harnesses" "$proot/"
|
||||
mkdir -p "$proot/harnesses/test-no-plugin"
|
||||
printf 'command = true\n' > "$proot/harnesses/test-no-plugin/harness.conf"
|
||||
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only' \
|
||||
'harness = test-no-plugin'
|
||||
DEJIMA_PROVIDER_ROOT="$proot" _rw_launch "$workspace"
|
||||
[ "$status" -ne 0 ] || { echo "launched anyway:"; echo "$output"; return 1; }
|
||||
[[ "$output" == *"No harness plugin for 'test-no-plugin'"* ]] || { echo "$output"; return 1; }
|
||||
rm -rf "$workspace" "$proot"
|
||||
}
|
||||
|
||||
@test "emit_mount refuses a harness state directory that overlaps a guarded store" {
|
||||
# The reason the refusal lives in emit_mount rather than in each plugin.
|
||||
# A harness's state paths are computed from config rather than written
|
||||
# down, so this is exactly the shape that reaches a mount without anyone
|
||||
# having looked at the value - the same blind spot as #15.
|
||||
_rw_setup
|
||||
workspace="$(mktemp -d)"
|
||||
audit_dir="$(mktemp -d)"
|
||||
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only' \
|
||||
'[audit]' "dir = $audit_dir" \
|
||||
'[harness.claude-code]' "projects_dir = $audit_dir/projects"
|
||||
_rw_launch "$workspace"
|
||||
[ "$status" -ne 0 ] || { echo "launched anyway:"; echo "$output"; return 1; }
|
||||
[[ "$output" == *"Refusing harness state mount"* ]] || { echo "$output"; return 1; }
|
||||
rm -rf "$workspace" "$audit_dir"
|
||||
}
|
||||
|
||||
@test "a harness that cannot host a guard says so at launch" {
|
||||
# The capability bar allows a harness to be supported at a smaller guard
|
||||
# set. What it does not allow is that being invisible: an operator who
|
||||
# has these everywhere else would otherwise have to read a manifest to
|
||||
# find out they are gone here.
|
||||
_rw_setup
|
||||
workspace="$(mktemp -d)"
|
||||
proot="$(mktemp -d)"
|
||||
cp -a "$REPO_ROOT/forges" "$REPO_ROOT/workflows" "$REPO_ROOT/harnesses" "$proot/"
|
||||
# Same harness, minus two guards, so the message is the only difference.
|
||||
sed -i 's/^guards .*/guards = bash-deny notify/' \
|
||||
"$proot/harnesses/claude-code/harness.conf"
|
||||
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only'
|
||||
DEJIMA_PROVIDER_ROOT="$proot" _rw_launch "$workspace"
|
||||
[[ "$output" == *"does not support 2 of dejima's guards"* ]] || { echo "$output"; return 1; }
|
||||
[[ "$output" == *"push-guard"* ]] || { echo "$output"; return 1; }
|
||||
[[ "$output" == *"merge the agent"* || "$output" == *"merge its own pull requests"* ]] \
|
||||
|| { echo "$output"; return 1; }
|
||||
rm -rf "$workspace" "$proot"
|
||||
}
|
||||
|
||||
@test "a harness hosting every guard says nothing about guards" {
|
||||
# The message must not become a line an operator learns to scroll past.
|
||||
_rw_setup
|
||||
workspace="$(mktemp -d)"
|
||||
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only'
|
||||
_rw_launch "$workspace"
|
||||
[[ "$output" != *"guards"* ]] || { echo "$output"; return 1; }
|
||||
rm -rf "$workspace"
|
||||
}
|
||||
|
||||
@test "a harness declaring a guard dejima does not know is refused" {
|
||||
# A typo would otherwise turn a guard off silently, which is the failure
|
||||
# the declaration exists to prevent.
|
||||
_rw_setup
|
||||
workspace="$(mktemp -d)"
|
||||
proot="$(mktemp -d)"
|
||||
cp -a "$REPO_ROOT/forges" "$REPO_ROOT/workflows" "$REPO_ROOT/harnesses" "$proot/"
|
||||
sed -i 's/^guards .*/guards = bash-deny push-gaurd/' \
|
||||
"$proot/harnesses/claude-code/harness.conf"
|
||||
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only'
|
||||
DEJIMA_PROVIDER_ROOT="$proot" _rw_launch "$workspace"
|
||||
[ "$status" -ne 0 ] || { echo "launched anyway:"; echo "$output"; return 1; }
|
||||
[[ "$output" == *"unknown guard: push-gaurd"* ]] || { echo "$output"; return 1; }
|
||||
rm -rf "$workspace" "$proot"
|
||||
}
|
||||
|
||||
@test "install.sh registers the selected harness's guards, gated on its declarations" {
|
||||
# Which guards exist and how they are installed is the harness's, so a
|
||||
# harness declaring fewer gets fewer - and dejima does not write a
|
||||
# config belonging to a harness the operator did not choose.
|
||||
local proot; proot="$(mktemp -d)"
|
||||
cp -a "$REPO_ROOT/forges" "$REPO_ROOT/workflows" "$REPO_ROOT/harnesses" "$proot/"
|
||||
sed -i 's/^guards .*/guards = bash-deny/' \
|
||||
"$proot/harnesses/claude-code/harness.conf"
|
||||
run_install DEJIMA_PROVIDER_ROOT="$proot"
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
run jq -r '[.hooks.PreToolUse[]?.hooks[]?.command] | join(" ")' \
|
||||
"$CONFIG_DIR/.claude/settings.json"
|
||||
[[ "$output" == *"deny-dangerous-bash.sh"* ]] || { echo "$output"; return 1; }
|
||||
[[ "$output" != *"git-push-guard.sh"* ]] \
|
||||
|| { echo "registered an undeclared guard: $output"; return 1; }
|
||||
[[ "$output" != *"merge-guard.sh"* ]] \
|
||||
|| { echo "registered an undeclared guard: $output"; return 1; }
|
||||
rm -rf "$proot"
|
||||
}
|
||||
|
||||
@test "install.sh registers every guard when the harness declares them all" {
|
||||
# The shipped harness, unmodified: the behaviour this replaced, kept.
|
||||
run_install
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
local cmds
|
||||
cmds="$(jq -r '[.hooks.PreToolUse[]?.hooks[]?.command,
|
||||
.hooks.SessionStart[]?.hooks[]?.command] | join(" ")' \
|
||||
"$CONFIG_DIR/.claude/settings.json")"
|
||||
local h
|
||||
for h in sandbox-context.sh git-push-guard.sh merge-guard.sh deny-dangerous-bash.sh; do
|
||||
[[ "$cmds" == *"$h"* ]] || { echo "$h not registered: $cmds"; return 1; }
|
||||
done
|
||||
# Linked for the read-only hooks mount, but registered per session by
|
||||
# bin/dejima rather than here, where it would fire for host sessions too.
|
||||
[ -L "$CONFIG_DIR/.claude/hooks/dejima-notify.sh" ]
|
||||
[[ "$cmds" != *"dejima-notify.sh"* ]] \
|
||||
|| { echo "notify registered host-wide: $cmds"; return 1; }
|
||||
}
|
||||
|
||||
@test "install.sh registers no guards when no harness is selected" {
|
||||
# Writing one harness's config for an operator who has chosen another,
|
||||
# or none, is the coupling this removed.
|
||||
# A config that already exists is never overwritten, so this is how an
|
||||
# operator upgrading from before [core] harness was required arrives.
|
||||
printf '[core]\nforge = none\nworkflow = local-only\n' > "$CONFIG_DIR/config"
|
||||
run_install
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
[[ "$output" == *"No [core] harness is set"* ]] || { echo "$output"; return 1; }
|
||||
[ ! -f "$CONFIG_DIR/.claude/settings.json" ] \
|
||||
|| { echo "wrote a harness config anyway"; return 1; }
|
||||
}
|
||||
|
||||
+39
-2
@@ -92,7 +92,8 @@ launch() {
|
||||
local conf
|
||||
conf="$(bash -c 'source "$1"; config_path' _ "$LIB")"
|
||||
mkdir -p "$(dirname "$conf")"
|
||||
printf '[core]\nssh_key = %s\n' "$LAUNCH_DIR/sandbox-key" >> "$conf"
|
||||
# No default, so a config that launches must select one.
|
||||
printf '[core]\nharness = claude-code\nssh_key = %s\n' "$LAUNCH_DIR/sandbox-key" >> "$conf"
|
||||
HOME="$LAUNCH_DIR/home" PATH="$LAUNCH_DIR:$PATH" \
|
||||
SANDBOX_TEST_RUN_ENV="$LAUNCH_ENV" \
|
||||
SANDBOX_TEST_DOCKER_LOG="$LAUNCH_DIR/docker-log" \
|
||||
@@ -817,7 +818,7 @@ ENTRYPOINT [\"/usr/local/bin/entrypoint.sh\"]" ]]
|
||||
# no domains at all, or by the env prefix below never reaching the child.
|
||||
printf 'good.example.com\n' > "$CONFIG_DIR/allowed-domains"
|
||||
launch_setup
|
||||
printf '[core]\nssh_key = %s\n' "$LAUNCH_DIR/sandbox-key" >> "$CONFIG_DIR/config"
|
||||
printf '[core]\nharness = claude-code\nssh_key = %s\n' "$LAUNCH_DIR/sandbox-key" >> "$CONFIG_DIR/config"
|
||||
HOME="$LAUNCH_DIR/home" PATH="$LAUNCH_DIR:$PATH" \
|
||||
SANDBOX_TEST_RUN_ENV="$LAUNCH_ENV" \
|
||||
SSH_AUTH_SOCK= ALLOWED_DOMAINS=evil.example \
|
||||
@@ -908,6 +909,42 @@ ENTRYPOINT [\"/usr/local/bin/entrypoint.sh\"]" ]]
|
||||
[ "$(launched_env SANDBOX_IMAGE)" = "dejima:$(id -un)-$slug" ]
|
||||
}
|
||||
|
||||
# nvim's data/state/cache are Docker volumes, not host paths, so nothing a
|
||||
# session installs reaches the host's own nvim. They do outlive the container
|
||||
# though, and under one fixed name each they were shared by every project on
|
||||
# the machine: a Lua file dropped under lazy/, or a binary replaced under
|
||||
# mason/bin/, is executed by whatever project's session next runs nvim in the
|
||||
# sandbox. Scoped per project, the same way the derived image tag is.
|
||||
@test "the nvim volumes are per project, not one set shared by every project" {
|
||||
launch_setup
|
||||
run launch "$PROJ"
|
||||
[ "$status" -eq 0 ]
|
||||
slug="$(bash -c 'source "$1"; project_slug "$2"' _ "$LIB" "$PROJ")"
|
||||
[ "$(launched_env SANDBOX_NVIM_DATA)" = "dejima_nvim-data$slug" ]
|
||||
[ "$(launched_env SANDBOX_NVIM_STATE)" = "dejima_nvim-state$slug" ]
|
||||
[ "$(launched_env SANDBOX_NVIM_CACHE)" = "dejima_nvim-cache$slug" ]
|
||||
|
||||
local other; other="$(mktemp -d)"
|
||||
run launch "$other"
|
||||
[ "$status" -eq 0 ]
|
||||
[ "$(launched_env SANDBOX_NVIM_DATA)" != "dejima_nvim-data$slug" ]
|
||||
rm -rf "$other"
|
||||
}
|
||||
|
||||
# project_slug turns a leading / into -, and a Docker volume name may not
|
||||
# start with one: without the prefix every launch would fail at `compose run`
|
||||
# rather than at anything that names the cause.
|
||||
@test "the derived nvim volume names are valid Docker volume names" {
|
||||
launch_setup
|
||||
run launch "$PROJ"
|
||||
[ "$status" -eq 0 ]
|
||||
local v
|
||||
for v in SANDBOX_NVIM_DATA SANDBOX_NVIM_STATE SANDBOX_NVIM_CACHE; do
|
||||
[[ "$(launched_env "$v")" =~ ^[a-zA-Z0-9][a-zA-Z0-9_.-]*$ ]] \
|
||||
|| { echo "$v=$(launched_env "$v") is not a valid volume name"; return 1; }
|
||||
done
|
||||
}
|
||||
|
||||
@test "the launcher builds and runs the global layer when only global deps are approved" {
|
||||
"$REPO_ROOT/bin/dejima-dep" add --global apt:libpq-dev >/dev/null
|
||||
launch_setup
|
||||
|
||||
+76
-1
@@ -236,7 +236,8 @@ assert_remedy_forges_exist() {
|
||||
local forge workflow harness
|
||||
forge="$(lib config_default core.forge)"
|
||||
workflow="$(lib config_default core.workflow)"
|
||||
harness="$(lib config_default core.harness)"
|
||||
# No default to read: the shipped pair under test is forge/workflow.
|
||||
harness=claude-code
|
||||
[ "$(lib manifest_get workflow "$workflow" push)" = "off" ] \
|
||||
|| { echo "default workflow '$workflow' pushes - a fresh install has no forge to push to"; return 1; }
|
||||
run lib provider_validate_selection "$forge" "$workflow" "$harness"
|
||||
@@ -591,6 +592,80 @@ assert_remedy_forges_exist() {
|
||||
[[ "$output" != *"create"*"PR"* ]] || { echo "branch-push briefing mentions opening a PR"; return 1; }
|
||||
}
|
||||
|
||||
# #32: an implementer ran a bare `git config user.name ...` inside a
|
||||
# worktree and wrote the parent checkout's real config, misattributing three
|
||||
# commits. A worktree isolates the working tree, not `.git/config`, and the
|
||||
# briefing told the agent to use worktrees while saying nothing about that -
|
||||
# which reads as more isolation than is on offer. The same unscoped command
|
||||
# reaches core.hooksPath and credential.helper, which execute on the host.
|
||||
#
|
||||
# In the core paragraph rather than a workflow's own prose, so a workflow can
|
||||
# opt into worktrees but cannot ship them without this.
|
||||
@test "every worktree briefing says a worktree does not isolate .git/config" {
|
||||
local wf
|
||||
for wf in worktree-pr branch-push; do
|
||||
[ "$(lib manifest_get workflow "$wf" requires_worktree)" = "on" ]
|
||||
run lib briefing_compose gitea "$wf" claude-code
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
[[ "$output" == *".git/config"* ]] \
|
||||
|| { echo "$wf briefing does not mention .git/config"; return 1; }
|
||||
[[ "$output" == *"git -C"* ]] \
|
||||
|| { echo "$wf briefing does not give the scoped form"; return 1; }
|
||||
done
|
||||
}
|
||||
|
||||
# The negative half: a workflow that does not use worktrees has no reason to
|
||||
# carry the paragraph, and its presence there would mean the splice is
|
||||
# unconditional rather than opt-in.
|
||||
@test "a workflow without worktrees does not carry the worktree paragraph" {
|
||||
[ "$(lib manifest_get workflow local-only requires_worktree)" = "off" ]
|
||||
run lib briefing_compose none local-only claude-code
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
[[ "$output" != *"does not isolate"* ]]
|
||||
[[ "$output" != *"worktree MUST live"* ]]
|
||||
}
|
||||
|
||||
# The default that blocks GitHub push exists to stop an eager agent pushing to
|
||||
# somebody else's project. When the operator lifts it, the agent gains the
|
||||
# ability without the reason, so the briefing supplies the reason.
|
||||
@test "the briefing warns against pushing elsewhere when GitHub push is on" {
|
||||
DEJIMA_GITHUB_PUSH=on run lib briefing_compose gitea worktree-pr claude-code
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
[[ "$output" == *"GitHub push is enabled for this session"* ]]
|
||||
[[ "$output" == *"pointed at and nothing else"* ]]
|
||||
[[ "$output" == *"not a contribution"* ]]
|
||||
# and it says what to do instead of nothing at all
|
||||
[[ "$output" == *"say so in your reply"* ]]
|
||||
}
|
||||
|
||||
# Two points from docs/responsible-use.md that an agent can act on, as
|
||||
# opposed to the majority which are the operator's to follow. Kept short on
|
||||
# purpose: this text is in every session's context, for every user.
|
||||
@test "the briefing names the two responsible-use points an agent can act on" {
|
||||
run lib briefing_compose gitea worktree-pr claude-code
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
# a domain that acts on the world rather than serving data
|
||||
[[ "$output" == *"action rather than a source"* ]]
|
||||
[[ "$output" == *"let the operator decide"* ]]
|
||||
# and things in the working tree that are not code
|
||||
[[ "$output" == *"not"*"code"* ]]
|
||||
[[ "$output" == *".env"* ]]
|
||||
}
|
||||
|
||||
@test "the briefing says nothing about GitHub push when it is off" {
|
||||
run lib briefing_compose gitea worktree-pr claude-code
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
[[ "$output" != *"GitHub push is enabled"* ]]
|
||||
}
|
||||
|
||||
@test "bin/dejima turns the GitHub push briefing on for both routes to it" {
|
||||
# --allow-github-push, and forge = github under a pushing workflow.
|
||||
grep -q 'DEJIMA_GITHUB_PUSH=on' "$REPO_ROOT/bin/dejima"
|
||||
grep -q 'ALLOW_GITHUB_PUSH_FLAG" = 1 \]; then' "$REPO_ROOT/bin/dejima"
|
||||
grep -q 'hosts_allowlisted_by_core" ) \?= "on"\|hosts_allowlisted_by_core)" = "on"' "$REPO_ROOT/bin/dejima"
|
||||
grep -q 'export DEJIMA_GITHUB_PUSH' "$REPO_ROOT/bin/dejima"
|
||||
}
|
||||
|
||||
@test "local-only pushes nothing and blocks no branches" {
|
||||
run lib manifest_validate workflow local-only
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
|
||||
+9
-14
@@ -409,21 +409,16 @@ make_cert() {
|
||||
# shell *function* persists in the calling shell rather than scoping to
|
||||
# the call, so it would leak into every later test in this file.
|
||||
#
|
||||
# The -u pair is what makes this test about the code rather than about
|
||||
# where it runs (#56). "No cert configured" was expressed by not setting
|
||||
# the variables, which only holds where nothing else set them -
|
||||
# entrypoint.sh:211 exports both into every tls-transport session, so a
|
||||
# suite run from inside a dejima container inherited real cert paths, the
|
||||
# guard below never fired, and execution fell through to the listen. What
|
||||
# failed then was an EADDRINUSE against the session's own forwarder on
|
||||
# :443, reported as this assertion failing - the code was fine and the
|
||||
# test was reading its own environment. Unsetting them states the
|
||||
# precondition instead of assuming it.
|
||||
# The -u pair makes this about the code rather than where it runs (#56).
|
||||
# "No cert configured" used to be expressed by not setting the variables,
|
||||
# which only holds where nothing else did - entrypoint.sh exports both
|
||||
# into every tls-transport session, so a suite run inside a dejima
|
||||
# container inherited real paths, the guard never fired, and the
|
||||
# fall-through hit EADDRINUSE on :443 and was reported as this assertion
|
||||
# failing. Unsetting states the precondition instead of assuming it.
|
||||
#
|
||||
# The port override is belt-and-braces: the cert check runs before the
|
||||
# listen today, so a passing test never binds anything, but if that order
|
||||
# ever changes the fall-through lands on an unused port rather than on
|
||||
# whatever holds :443.
|
||||
# The port override is belt-and-braces: the cert check precedes the listen
|
||||
# today, so a fall-through lands on an unused port if that ever changes.
|
||||
run env -u SANDBOX_ANTHROPIC_TLS_CERT -u SANDBOX_ANTHROPIC_TLS_KEY \
|
||||
SANDBOX_ANTHROPIC_PROXY_SOCKET="$SOCK" SANDBOX_ANTHROPIC_TRANSPORT=tls \
|
||||
SANDBOX_ANTHROPIC_PROXY_PORT=18398 \
|
||||
|
||||
+79
-28
@@ -5,6 +5,25 @@
|
||||
# compromised session writes into - so most of what's here is about what it
|
||||
# refuses to do with that input.
|
||||
|
||||
# No test in this file may modify the repository's own CLIs. Two of them need
|
||||
# an exit-time audit that fails, and they get it from a temp shadow of bin/
|
||||
# (see shadow_repo) rather than by overwriting the real one. This is what
|
||||
# proves it stayed that way even if a test aborts partway through, and it is
|
||||
# checked against a hash rather than against `git status` so that it holds
|
||||
# while someone has uncommitted edits to that file.
|
||||
setup_file() {
|
||||
REMOTE_AUDIT_SUM="$(sha256sum "$BATS_TEST_DIRNAME/../bin/dejima-audit" | cut -d' ' -f1)"
|
||||
export REMOTE_AUDIT_SUM
|
||||
}
|
||||
teardown_file() {
|
||||
local now
|
||||
now="$(sha256sum "$BATS_TEST_DIRNAME/../bin/dejima-audit" | cut -d' ' -f1)"
|
||||
[ "$now" = "$REMOTE_AUDIT_SUM" ] || {
|
||||
echo "test/remote.bats modified bin/dejima-audit in the repository" >&2
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
setup() {
|
||||
REPO_ROOT="$BATS_TEST_DIRNAME/.."
|
||||
LIB="$REPO_ROOT/bin/dejima-project-lib.sh"
|
||||
@@ -19,15 +38,6 @@ teardown() {
|
||||
if [ -n "${TMUX_DIR:-}" ]; then
|
||||
TMUX_TMPDIR="$TMUX_DIR" tmux kill-server 2>/dev/null || true
|
||||
fi
|
||||
# Safety net for tests that temporarily swap out the real bin/dejima-
|
||||
# audit: each such test restores it itself right after `run`, before any
|
||||
# assertion that could short-circuit the test body, but this catches the
|
||||
# case anyway if something in between still managed to abort first.
|
||||
if [ -n "${AUDIT_BACKUP:-}" ] && [ -f "$AUDIT_BACKUP" ]; then
|
||||
cp "$AUDIT_BACKUP" "$REPO_ROOT/bin/dejima-audit"
|
||||
chmod +x "$REPO_ROOT/bin/dejima-audit"
|
||||
rm -f "$AUDIT_BACKUP"
|
||||
fi
|
||||
rm -rf "$CONFIG_DIR" "$RUNTIME_DIR" ${STUB_DIR:+"$STUB_DIR"} ${LAUNCH_DIR:+"$LAUNCH_DIR"} \
|
||||
${TMUX_DIR:+"$TMUX_DIR"} ${FAKE_HOME:+"$FAKE_HOME"}
|
||||
}
|
||||
@@ -70,6 +80,54 @@ write_remote_config() {
|
||||
[ "$output" = "$RUNTIME_DIR/dejima" ]
|
||||
}
|
||||
|
||||
# A throwaway copy of the repository whose bin/dejima-audit a test may
|
||||
# replace or delete. Everything else is a symlink to the real thing, so this
|
||||
# costs one directory of links.
|
||||
#
|
||||
# bin/ is copied rather than linked, and both reasons matter. bin/dejima
|
||||
# resolves its own location with `readlink -f`, so a symlinked copy would
|
||||
# point SCRIPT_DIR back at the repository and the stub would never be the one
|
||||
# it calls. And a symlink is actively dangerous here: `cat > $SHADOW/bin/
|
||||
# dejima-audit` follows it and truncates the real script, which is precisely
|
||||
# what this helper exists to stop. Copying removes the whole class rather
|
||||
# than one instance of it.
|
||||
#
|
||||
# Two tests here need an audit binary that fails, and they used to get one by
|
||||
# overwriting `$REPO_ROOT/bin/dejima-audit` and restoring it afterwards. That
|
||||
# left the working tree holding a two-line stub for the length of the test,
|
||||
# which is a poor thing to do to a security tool: a run killed partway, or
|
||||
# two runs of this file at once, restores the stub permanently instead of the
|
||||
# script, and a suite that passes while silently disabling the exit-time
|
||||
# audit is the exact failure this file exists to prevent. It also, reasonably
|
||||
# enough, reads like an attack to anything watching the repository for
|
||||
# changes.
|
||||
shadow_repo() {
|
||||
local root entry
|
||||
root="$(cd "$REPO_ROOT" && pwd)"
|
||||
SHADOW="$BATS_TEST_TMPDIR/shadow"
|
||||
mkdir -p "$SHADOW"
|
||||
for entry in "$root"/*; do
|
||||
[ "$(basename "$entry")" = bin ] && continue
|
||||
ln -sfn "$entry" "$SHADOW/$(basename "$entry")"
|
||||
done
|
||||
cp -a "$root/bin" "$SHADOW/bin"
|
||||
DEJIMA_BIN="$SHADOW/bin/dejima"
|
||||
}
|
||||
|
||||
# Stands in for the real audit and does nothing but fail. Self-describing
|
||||
# because a file watcher can surface it mid-test, and a bare `exit 3` sitting
|
||||
# where a security tool belongs is alarming without this comment on it.
|
||||
shadow_failing_audit() {
|
||||
cat > "$SHADOW/bin/dejima-audit" <<'EOF'
|
||||
#!/bin/bash
|
||||
# Test fixture for test/remote.bats, living in that test's temp directory
|
||||
# and never in the repository. Fails on purpose: bin/dejima must still
|
||||
# report the container's exit code and not this one.
|
||||
exit 3
|
||||
EOF
|
||||
chmod +x "$SHADOW/bin/dejima-audit"
|
||||
}
|
||||
|
||||
# A fake `docker` that records its argv, so bin/dejima's launch path can be
|
||||
# exercised with no daemon. Same approach as test/project.bats.
|
||||
stub_launch() {
|
||||
@@ -119,12 +177,13 @@ launch() {
|
||||
local conf
|
||||
conf="$(bash -c 'source "$1"; config_path' _ "$LIB")"
|
||||
mkdir -p "$(dirname "$conf")"
|
||||
printf '[core]\nssh_key = %s\n' "$LAUNCH_DIR/sandbox-key" >> "$conf"
|
||||
# No default, so a config that launches must select one.
|
||||
printf '[core]\nharness = claude-code\nssh_key = %s\n' "$LAUNCH_DIR/sandbox-key" >> "$conf"
|
||||
HOME="$LAUNCH_DIR/home" PATH="$LAUNCH_DIR:$PATH" \
|
||||
SANDBOX_TEST_DOCKER_LOG="$LAUNCH_DIR/docker-log" \
|
||||
SANDBOX_TEST_SETTINGS_COPY="$LAUNCH_DIR/settings-copy.json" \
|
||||
SSH_AUTH_SOCK= ALLOWED_DOMAINS= \
|
||||
"$REPO_ROOT/bin/dejima" "$@"
|
||||
"${DEJIMA_BIN:-$REPO_ROOT/bin/dejima}" "$@"
|
||||
}
|
||||
|
||||
run_line() { grep -- ' run --rm ' "$LAUNCH_DIR/docker-log" | tail -1; }
|
||||
@@ -280,15 +339,9 @@ done
|
||||
exit 0
|
||||
EOF
|
||||
chmod +x "$LAUNCH_DIR/docker"
|
||||
AUDIT_REAL="$REPO_ROOT/bin/dejima-audit"
|
||||
AUDIT_BACKUP="$(mktemp)"
|
||||
cp "$AUDIT_REAL" "$AUDIT_BACKUP"
|
||||
printf '#!/bin/bash\nexit 3\n' > "$AUDIT_REAL"
|
||||
chmod +x "$AUDIT_REAL"
|
||||
shadow_repo
|
||||
shadow_failing_audit
|
||||
run launch "$PROJ"
|
||||
cp "$AUDIT_BACKUP" "$AUDIT_REAL"
|
||||
chmod +x "$AUDIT_REAL"
|
||||
rm -f "$AUDIT_BACKUP"
|
||||
[ "$status" -eq 5 ]
|
||||
rm -rf "$PROJ"
|
||||
}
|
||||
@@ -309,14 +362,9 @@ done
|
||||
exit 0
|
||||
EOF
|
||||
chmod +x "$LAUNCH_DIR/docker"
|
||||
AUDIT_REAL="$REPO_ROOT/bin/dejima-audit"
|
||||
AUDIT_BACKUP="$(mktemp)"
|
||||
cp "$AUDIT_REAL" "$AUDIT_BACKUP"
|
||||
rm -f "$AUDIT_REAL"
|
||||
shadow_repo
|
||||
rm -f "$SHADOW/bin/dejima-audit"
|
||||
run launch "$PROJ"
|
||||
cp "$AUDIT_BACKUP" "$AUDIT_REAL"
|
||||
chmod +x "$AUDIT_REAL"
|
||||
rm -f "$AUDIT_BACKUP"
|
||||
[ "$status" -eq 5 ]
|
||||
rm -rf "$PROJ"
|
||||
}
|
||||
@@ -2450,14 +2498,17 @@ nginx_blocks() {
|
||||
}
|
||||
|
||||
@test "install.sh links the remote scripts and the notify hook, registers neither, and enables nothing" {
|
||||
grep -q '^link_hook dejima-notify.sh$' "$REPO_ROOT/install.sh"
|
||||
# The hook is linked by the harness plugin now - which file it is, and
|
||||
# where the harness reads it from, is the harness's business - while the
|
||||
# CLIs stay install.sh's, being dejima's own.
|
||||
grep -q '_link_hook dejima-notify.sh' "$REPO_ROOT/bin/harness/claude-code.sh"
|
||||
grep -q '^link_bin dejima-attach$' "$REPO_ROOT/install.sh"
|
||||
grep -q '^link_bin dejima-ttyd$' "$REPO_ROOT/install.sh"
|
||||
grep -q '^link_bin dejima-notify-watch$' "$REPO_ROOT/install.sh"
|
||||
# The notify hook is registered per session by bin/dejima, into a
|
||||
# throwaway settings copy. Registered here it would fire for every
|
||||
# unsandboxed host session too.
|
||||
run grep -nE 'register_hook.*dejima-notify' "$REPO_ROOT/install.sh"
|
||||
run grep -nE '_register .*dejima-notify' "$REPO_ROOT/bin/harness/claude-code.sh"
|
||||
[ "$status" -ne 0 ]
|
||||
# Standing up a front door is an explicit operator decision. Nothing in
|
||||
# the installer may start, enable or even copy a unit. Code only - the
|
||||
|
||||
+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
|
||||
|
||||
+523
-50
@@ -449,16 +449,98 @@ 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 "the claude-code plugin mounts projects/ one slug at a time" {
|
||||
# Moved out of bin/dejima into the harness plugin: which directories a
|
||||
# harness keeps, and what decides an entry belongs to this workspace, is
|
||||
# a fact about the harness. The property is unchanged - the tree is
|
||||
# every project's transcripts and memory, and memory is read into
|
||||
# context at session start (#104).
|
||||
local p="$REPO_ROOT/bin/harness/claude-code.sh"
|
||||
grep -q 'workspace_slugs_sorted "$ws"' "$p"
|
||||
grep -q 'emit_mount "$projects/$slug"' "$p"
|
||||
}
|
||||
|
||||
# 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 "the claude-code plugin mounts file-history one session at a time" {
|
||||
local p="$REPO_ROOT/bin/harness/claude-code.sh"
|
||||
grep -q 'workspace_session_ids "$projects" "$ws"' "$p"
|
||||
grep -q 'emit_mount "$history/$sid"' "$p"
|
||||
}
|
||||
|
||||
@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
|
||||
@@ -617,8 +699,13 @@ EOF
|
||||
# Which only takes effect if the flag is actually passed. It goes in the
|
||||
# wrapper rather than the entrypoint so it also covers invocations the
|
||||
# entrypoint never sees (an interactive shell, claudecode.nvim).
|
||||
grep -qE -- '--settings /etc/dejima-settings\.json' "$SANDBOX_DIR/claude-wrapper.sh"
|
||||
grep -qE -- '"\$\{settings\[@\]\}"' "$SANDBOX_DIR/claude-wrapper.sh"
|
||||
# The flag itself is the harness's now, not the wrapper's: the wrapper
|
||||
# is shared and a --settings it hardcoded would be handed to a harness
|
||||
# that layers settings some other way.
|
||||
grep -qE -- 'settings_args\s*=\s*--settings /etc/dejima-settings\.json' \
|
||||
"$REPO_ROOT/harnesses/claude-code/harness.conf"
|
||||
grep -q 'SANDBOX_HARNESS_SETTINGS' "$SANDBOX_DIR/harness-wrapper.sh"
|
||||
grep -qE -- '\$\{settings\[@\]\+"\$\{settings\[@\]\}"\}' "$SANDBOX_DIR/harness-wrapper.sh"
|
||||
}
|
||||
|
||||
@test "bin/dejima pre-creates the ~/.claude config files so neither is mounted as a directory" {
|
||||
@@ -1089,9 +1176,13 @@ cli_payload() { cat "$BATS_TEST_TMPDIR/cli/payload"; }
|
||||
[ "$status" -eq 0 ]
|
||||
}
|
||||
|
||||
@test "install.sh links and registers the merge guard" {
|
||||
grep -qx 'link_hook merge-guard.sh' "$REPO_ROOT/install.sh"
|
||||
grep -q 'register_hook PreToolUse Bash merge-guard.sh' "$REPO_ROOT/install.sh"
|
||||
@test "the merge guard is linked and registered" {
|
||||
# In the harness plugin now: which file a guard is and where a harness
|
||||
# reads it from is the harness's, while whether it is installed at all
|
||||
# is gated on the manifest declaring the guard.
|
||||
local p="$REPO_ROOT/bin/harness/claude-code.sh"
|
||||
grep -q 'harness_has_guard merge-guard && _link_hook merge-guard.sh' "$p"
|
||||
grep -q '_register PreToolUse Bash merge-guard.sh' "$p"
|
||||
}
|
||||
|
||||
# Runs install.sh against a throwaway HOME, with a stub `docker` on PATH so
|
||||
@@ -1341,15 +1432,46 @@ registered_commands() {
|
||||
audit_line=$(grep -n 'dejima-audit" compare' "$REPO_ROOT/bin/dejima" | head -1 | cut -d: -f1)
|
||||
trap_line=$(grep -n '^trap cleanup EXIT' "$REPO_ROOT/bin/dejima" | head -1 | cut -d: -f1)
|
||||
marker_line=$(grep -n 'could not remove the marker directory' "$REPO_ROOT/bin/dejima" | head -1 | cut -d: -f1)
|
||||
epitaph_line=$(grep -n 'dejima-epitaph" save' "$REPO_ROOT/bin/dejima" | head -1 | cut -d: -f1)
|
||||
[ -n "$marker_line" ]
|
||||
[ -n "$epitaph_line" ]
|
||||
[ "$audit_line" -lt "$trap_line" ]
|
||||
# Not just "inside cleanup()" but after the one line in it a session can
|
||||
# deliberately provoke to print - the actual last-writer invariant.
|
||||
[ "$marker_line" -lt "$audit_line" ]
|
||||
# Same invariant, for the other task in the trap that prints: epitaph
|
||||
# announces every summary it keeps, so it goes before the verdict.
|
||||
[ "$epitaph_line" -lt "$audit_line" ]
|
||||
run grep -c 'could not remove the marker directory' "$REPO_ROOT/bin/dejima"
|
||||
[ "$output" -eq 1 ]
|
||||
}
|
||||
|
||||
@test "cleanup says what it is doing before each task that takes a while" {
|
||||
# A silent terminal between the container exiting and the prompt
|
||||
# returning reads as a hung one, and the operator's answer to that is
|
||||
# Ctrl-C - during the audit, which is the worst moment for it.
|
||||
local d="$REPO_ROOT/bin/dejima" start s1 epi s2 aud
|
||||
# Absolute line numbers, so each announcement is checked to sit with the
|
||||
# task it names rather than merely somewhere in the same function. The
|
||||
# definition of exit_step() itself is above cleanup() and so is skipped.
|
||||
start=$(grep -n '^cleanup() {' "$d" | cut -d: -f1)
|
||||
epi=$(grep -n 'dejima-epitaph" save' "$d" | head -1 | cut -d: -f1)
|
||||
aud=$(grep -n 'dejima-audit" compare' "$d" | head -1 | cut -d: -f1)
|
||||
s1=$(awk -v s="$start" 'NR>s && /exit_step /{print NR; exit}' "$d")
|
||||
s2=$(awk -v s="$epi" 'NR>s && /exit_step /{print NR; exit}' "$d")
|
||||
[ -n "$s1" ] && [ -n "$s2" ] || { echo "no announcement before a task"; return 1; }
|
||||
[ "$s1" -lt "$epi" ] || { echo "epitaph runs unannounced"; return 1; }
|
||||
[ "$s2" -lt "$aud" ] || { echo "the audit runs unannounced"; return 1; }
|
||||
# Gated on the snapshot, the same condition `compare` uses to decide it
|
||||
# has work: an exit before the session started is instant, and its last
|
||||
# line is the error that caused it - which announcements would bury.
|
||||
run sed -n '/^exit_step() {/,/^}/p' "$REPO_ROOT/bin/dejima"
|
||||
[ -n "$output" ] || { echo "exit_step is not defined"; return 1; }
|
||||
grep -q 'SANDBOX_AUDIT_STATE' <<< "$output"
|
||||
# A nonzero status from anything in an EXIT trap replaces the script's.
|
||||
grep -q 'return 0' <<< "$output"
|
||||
}
|
||||
|
||||
@test "credential proxy is on by default, gated only by credentials.proxy" {
|
||||
grep -q 'config_get_bool credentials proxy' "$REPO_ROOT/bin/dejima"
|
||||
grep -Pq 'credentials\.proxy\tbool\ton' "$REPO_ROOT/bin/dejima-config-lib.sh"
|
||||
@@ -1503,9 +1625,12 @@ registered_commands() {
|
||||
# coverage (snapshot/compare
|
||||
# against real fixtures, including the symlink-swap exploit) lives in
|
||||
# test/audit.bats; this just confirms the wiring exists.
|
||||
grep -q 'STATUSLINE_PATH=' "$REPO_ROOT/bin/dejima-audit"
|
||||
grep -q 'statusline_snapshot' "$REPO_ROOT/bin/dejima-audit"
|
||||
grep -q 'HOST-CODE-EXECUTION' "$REPO_ROOT/bin/dejima-audit"
|
||||
# The path and its label are the harness plugin's now; the mechanism
|
||||
# that snapshots and reports them is still the audit's.
|
||||
grep -q 'host_exec_snapshot' "$REPO_ROOT/bin/dejima-audit"
|
||||
grep -q 'host_exec_load' "$REPO_ROOT/bin/dejima-audit"
|
||||
grep -q 'statusline_path' "$REPO_ROOT/bin/harness/claude-code.sh"
|
||||
grep -q 'HOST-CODE-EXECUTION' "$REPO_ROOT/bin/harness/claude-code.sh"
|
||||
}
|
||||
|
||||
@test "Dockerfile installs the sandbox clipboard fallback into nvim's VIMRUNTIME plugin dir" {
|
||||
@@ -1674,6 +1799,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" {
|
||||
@@ -1905,12 +2037,15 @@ EOF
|
||||
grep -q 'not a verdict on who caused them' "$REPO_ROOT/README.md"
|
||||
}
|
||||
|
||||
@test "docs/security.md documents the ~/.claude/projects cross-session false-positive case" {
|
||||
# Presence-only grep, same caveat as above: confirms the specific
|
||||
# ~/.claude/projects wording landed, not that the surrounding
|
||||
# reasoning is sound.
|
||||
grep -q 'CROSS-PROJECT. finding' "$REPO_ROOT/docs/security.md"
|
||||
grep -q 'directory shared by' "$REPO_ROOT/docs/security.md"
|
||||
# Was: that the cross-session false-positive case is documented. The finding
|
||||
# itself is gone - a session can no longer write another project's slug, so
|
||||
# every one it could still see belonged to some other session - and what
|
||||
# needs documenting now is its absence, so that removing detection stays a
|
||||
# stated decision rather than something a reader has to infer from silence.
|
||||
@test "docs/security.md says why there is no cross-project finding" {
|
||||
grep -q 'no longer a .CROSS-PROJECT. finding' "$REPO_ROOT/docs/security.md"
|
||||
run grep -n 'CROSS-PROJECT history/memory write' "$REPO_ROOT/bin/dejima-audit"
|
||||
[ "$status" -ne 0 ] || { echo "the finding is back but the doc says it is gone" >&2; false; }
|
||||
}
|
||||
|
||||
# The CI disclosure is the one part of issue #72 that helps a reader whatever
|
||||
@@ -1924,6 +2059,46 @@ EOF
|
||||
done
|
||||
}
|
||||
|
||||
@test "core never reads a harness's config by a literal harness name" {
|
||||
# `keys` is declared per harness, so a fixed [harness.<name>] at a call
|
||||
# site makes it a declaration only one harness can ever be read through:
|
||||
# a second harness is selected, is validated, and is then handed the
|
||||
# first one's paths with nothing erroring anywhere. A silent wrong
|
||||
# answer, so it gets a test rather than a convention.
|
||||
run grep -rn 'config_get["'"'"' ]*harness\.[a-z]' "$REPO_ROOT/bin"
|
||||
[ "$status" -ne 0 ] || { echo "use harness_config_get instead:"; echo "$output"; return 1; }
|
||||
grep -q '^harness_config_get()' "$REPO_ROOT/bin/dejima-provider-lib.sh"
|
||||
}
|
||||
|
||||
@test "the harness guide states the bar a harness has to clear" {
|
||||
# Support is per harness and deliberate, at the depth Claude Code has.
|
||||
# That only holds if the requirements are written down: without them
|
||||
# each new harness is argued about rather than measured, and the first
|
||||
# one that cannot host a guard gets waved through.
|
||||
local sec r
|
||||
grep -q '^## Adding a new harness' "$REPO_ROOT/docs/development.md"
|
||||
sec="$(sed -n '/^### What a harness must be able to do/,/^### What the manifest/p' \
|
||||
"$REPO_ROOT/docs/development.md")"
|
||||
[ -n "$sec" ] || { echo "the capability bar is gone"; return 1; }
|
||||
for r in R1 R2 R3 R4 R5; do
|
||||
grep -q "\*\*$r\*\*" <<< "$sec" || { echo "no $r in the bar"; return 1; }
|
||||
done
|
||||
# Unattended execution is the premise every other control is justified
|
||||
# against, so it stays stated rather than assumed.
|
||||
grep -qi 'without confirming each one' <<< "$sec"
|
||||
}
|
||||
|
||||
@test "a harness dejima declines is declined against a stated requirement" {
|
||||
# The reason has to point at a rule. Otherwise the bar is decoration and
|
||||
# the next harness is decided on how much someone wants it.
|
||||
local sec
|
||||
sec="$(sed -n '/^### Worked example/,/^### What the manifest/p' \
|
||||
"$REPO_ROOT/docs/development.md")"
|
||||
[ -n "$sec" ] || { echo "no worked example of a refusal"; return 1; }
|
||||
grep -q 'R2' <<< "$sec" \
|
||||
|| { echo "the refusal names no requirement it failed"; return 1; }
|
||||
}
|
||||
|
||||
@test "docs/ci.md exists and is linked from the README and docs/security.md" {
|
||||
[ -f "$REPO_ROOT/docs/ci.md" ]
|
||||
grep -q 'docs/ci\.md' "$REPO_ROOT/README.md"
|
||||
@@ -1943,6 +2118,81 @@ 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"
|
||||
}
|
||||
|
||||
# The branch convention is only a convention - nothing refuses a pull request
|
||||
# aimed at main - so the three places an agent or a contributor looks have to
|
||||
# agree, or the one that is stale becomes the instruction someone follows.
|
||||
@test "the development-branch convention is stated where it is looked for" {
|
||||
local f
|
||||
for f in .dejima.md CONTRIBUTING.md docs/development.md; do
|
||||
grep -q 'development' "$REPO_ROOT/$f" || {
|
||||
echo "$f does not mention the development branch" >&2
|
||||
false
|
||||
}
|
||||
done
|
||||
# and the one thing that is easy to get wrong: gitea-pr's default base
|
||||
grep -q -- '--base development' "$REPO_ROOT/.dejima.md"
|
||||
grep -q -- '--base development' "$REPO_ROOT/docs/development.md"
|
||||
}
|
||||
|
||||
@test "docs/ci.md says a project with no CI is exposed too" {
|
||||
grep -q 'applies to projects with no CI at all' "$REPO_ROOT/docs/ci.md"
|
||||
grep -q 'no workflows is not therefore safe' "$REPO_ROOT/docs/ci.md"
|
||||
@@ -2009,6 +2259,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 +2400,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' \
|
||||
@@ -2198,6 +2473,7 @@ EOF
|
||||
# past validation.
|
||||
cat > "$DEJIMA_T/config/config" <<EOF
|
||||
[core]
|
||||
harness = claude-code
|
||||
ssh_key = $DEJIMA_T/sandbox-key
|
||||
forge = none
|
||||
workflow = local-only
|
||||
@@ -2468,39 +2744,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 +2897,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.
|
||||
@@ -2693,3 +2977,192 @@ q
|
||||
run grep -n 'scope-flag) form\|scope-flag form' "$REPO_ROOT/README.md"
|
||||
[ "$status" -ne 0 ]
|
||||
}
|
||||
|
||||
# --- harness plugins: a plugin computes, core enforces ---
|
||||
|
||||
@test "no harness plugin touches the mount list directly" {
|
||||
# A plugin that appended to extra_mounts itself would skip the
|
||||
# guarded-store refusal emit_mount runs at the point of declaration -
|
||||
# and would skip it differently in each harness, which is how one
|
||||
# security model becomes one per harness. The plugins are trusted code,
|
||||
# so this is held at review time rather than by isolating them.
|
||||
run grep -rn 'extra_mounts' "$REPO_ROOT/bin/harness"
|
||||
[ "$status" -ne 0 ] || { echo "a plugin bypasses emit_mount:"; echo "$output"; return 1; }
|
||||
}
|
||||
|
||||
@test "a harness plugin defines only hooks core actually calls" {
|
||||
# A function named something core never calls looks wired up and is
|
||||
# dead. Worse, a near-miss of a real hook name (harness_state_mount for
|
||||
# harness_state_mounts) reads as implemented and silently does nothing.
|
||||
local hooks f fn
|
||||
hooks="$(sed -n 's/^DEJIMA_HARNESS_HOOKS="\(.*\)"$/\1/p' \
|
||||
"$REPO_ROOT/bin/dejima-harness-lib.sh")"
|
||||
[ -n "$hooks" ] || { echo "the hook list is gone"; return 1; }
|
||||
for f in "$REPO_ROOT"/bin/harness/*.sh; do
|
||||
[ -f "$f" ] || continue
|
||||
while IFS= read -r fn; do
|
||||
[ -n "$fn" ] || continue
|
||||
case " $hooks " in
|
||||
*" $fn "*) ;;
|
||||
*) echo "$(basename "$f") defines $fn, which core never calls"; return 1 ;;
|
||||
esac
|
||||
done < <(grep -oE '^[a-zA-Z_][a-zA-Z0-9_]*\(\)' "$f" | sed 's/()$//')
|
||||
done
|
||||
}
|
||||
|
||||
@test "every shipped harness has both a manifest and a plugin" {
|
||||
# Support is per harness and deliberate. A manifest with no plugin is a
|
||||
# half-finished integration that would present as a supported harness.
|
||||
local d name
|
||||
for d in "$REPO_ROOT"/harnesses/*/; do
|
||||
[ -d "$d" ] || continue
|
||||
name="$(basename "$d")"
|
||||
[ -f "$d/harness.conf" ] || { echo "$name has no manifest"; return 1; }
|
||||
[ -f "$REPO_ROOT/bin/harness/$name.sh" ] \
|
||||
|| { echo "$name has a manifest but no plugin"; return 1; }
|
||||
done
|
||||
for d in "$REPO_ROOT"/bin/harness/*.sh; do
|
||||
[ -f "$d" ] || continue
|
||||
name="$(basename "$d" .sh)"
|
||||
[ -f "$REPO_ROOT/harnesses/$name/harness.conf" ] \
|
||||
|| { echo "$name has a plugin but no manifest"; return 1; }
|
||||
done
|
||||
}
|
||||
|
||||
@test "harness plugins are inside the trusted computing base the audit watches" {
|
||||
# They run on the host, in the launch path. Under harnesses/ they would
|
||||
# be host-executed code a session could edit in dejima's own checkout
|
||||
# without raising a CRITICAL, because tcb_snapshot covers install.sh,
|
||||
# bin/, hooks/, docker/ and test/ - and not the provider directories.
|
||||
[ -d "$REPO_ROOT/bin/harness" ] || { echo "plugins are not under bin/"; return 1; }
|
||||
run grep -rn 'harness_state_mounts\|harness_plugin_load' "$REPO_ROOT/harnesses"
|
||||
[ "$status" -ne 0 ] || { echo "plugin code under harnesses/:"; echo "$output"; return 1; }
|
||||
sed -n '/^tcb_snapshot()/,/^}/p' "$REPO_ROOT/bin/dejima-audit" | grep -q 'for dir in bin '
|
||||
}
|
||||
|
||||
# --- the wrapper is the harness's, not Claude Code's ---
|
||||
|
||||
@test "the wrapper names no harness, and the image installs it under a neutral name" {
|
||||
# It is shadowed into place under the harness's own command by
|
||||
# entrypoint.sh, because the image is shared across harnesses and the
|
||||
# selection is per launch. A COPY to a fixed name would bake one
|
||||
# harness's command into every image.
|
||||
local w="$SANDBOX_DIR/harness-wrapper.sh"
|
||||
[ -f "$w" ] || { echo "the generic wrapper is gone"; return 1; }
|
||||
run grep -niE 'claude|anthropic' "$w"
|
||||
[ "$status" -ne 0 ] || { echo "the shared wrapper names a harness:"; echo "$output"; return 1; }
|
||||
grep -q 'COPY docker/harness-wrapper.sh /usr/local/bin/dejima-harness-wrapper' \
|
||||
"$SANDBOX_DIR/Dockerfile"
|
||||
run grep -n 'COPY docker/claude-wrapper' "$SANDBOX_DIR/Dockerfile"
|
||||
[ "$status" -ne 0 ] || { echo "the old fixed-name copy is back"; return 1; }
|
||||
}
|
||||
|
||||
@test "the entrypoint refuses a harness command name that is not a bare name" {
|
||||
# It becomes a path under /usr/local/bin. A value with a slash would
|
||||
# write the symlink somewhere else entirely, and this one arrives from
|
||||
# the host as an environment variable.
|
||||
sed -n '/SANDBOX_HARNESS_COMMAND:-/,/^fi/p' "$SANDBOX_DIR/entrypoint.sh" \
|
||||
| grep -q '\*/\*'
|
||||
}
|
||||
|
||||
@test "the no-confirm flags come from the harness manifest" {
|
||||
# R1 of the capability bar. The flag was hardcoded in the wrapper, which
|
||||
# made unattended operation a property of the image rather than of the
|
||||
# selected harness.
|
||||
grep -qE -- 'no_confirm_args\s*=\s*--dangerously-skip-permissions' \
|
||||
"$REPO_ROOT/harnesses/claude-code/harness.conf"
|
||||
grep -q 'no_confirm_args' "$REPO_ROOT/bin/dejima"
|
||||
# And a harness that names none is refused rather than run asking for
|
||||
# confirmation on every tool call, which presents as a hang.
|
||||
grep -q 'declares no no_confirm_args' "$REPO_ROOT/bin/dejima"
|
||||
}
|
||||
|
||||
@test "core's schema holds no harness's command as a default" {
|
||||
# [core] command defaulted to the literal `claude`, so core carried one
|
||||
# harness's command name while the manifest's own `command` key was read
|
||||
# by nothing.
|
||||
run grep -nE '^core\.command\s+identifier\s+claude' "$REPO_ROOT/bin/dejima-config-lib.sh"
|
||||
[ "$status" -ne 0 ] || { echo "core still defaults to a harness command"; return 1; }
|
||||
grep -qE '^core\.command\s+\?identifier' "$REPO_ROOT/bin/dejima-config-lib.sh"
|
||||
grep -q 'SANDBOX_CMD="$SANDBOX_HARNESS_COMMAND"' "$REPO_ROOT/bin/dejima"
|
||||
}
|
||||
|
||||
# --- guards are declared, not assumed ---
|
||||
|
||||
@test "every shipped harness declares which guards it can host" {
|
||||
# An absent `guards` reads as "hosts none", which for a harness that
|
||||
# hosts all four would tell the operator at every launch that four
|
||||
# protections are missing. Silence is not a valid declaration here.
|
||||
local d name g known
|
||||
known="$(sed -n 's/^DEJIMA_KNOWN_GUARDS="\(.*\)"$/\1/p' "$REPO_ROOT/bin/dejima-provider-lib.sh")"
|
||||
[ -n "$known" ] || { echo "the known-guard set is gone"; return 1; }
|
||||
for d in "$REPO_ROOT"/harnesses/*/; do
|
||||
[ -f "$d/harness.conf" ] || continue
|
||||
name="$(basename "$d")"
|
||||
grep -qE '^\s*guards\s*=' "$d/harness.conf" \
|
||||
|| { echo "$name declares no guards"; return 1; }
|
||||
for g in $(sed -n 's/^\s*guards\s*=\s*//p' "$d/harness.conf"); do
|
||||
case " $known " in
|
||||
*" $g "*) ;;
|
||||
*) echo "$name declares unknown guard: $g"; return 1 ;;
|
||||
esac
|
||||
done
|
||||
done
|
||||
}
|
||||
|
||||
@test "the guard set core knows lives with the validation that rejects a name" {
|
||||
# It was in dejima-harness-lib.sh while provider_validate_selection read
|
||||
# it from dejima-provider-lib.sh, so a caller loading only the latter -
|
||||
# providers.bats does - validated against an empty set and rejected
|
||||
# every guard there is.
|
||||
grep -q '^DEJIMA_KNOWN_GUARDS=' "$REPO_ROOT/bin/dejima-provider-lib.sh"
|
||||
run grep -n '^DEJIMA_KNOWN_GUARDS=' "$REPO_ROOT/bin/dejima-harness-lib.sh"
|
||||
[ "$status" -ne 0 ] || { echo "the set is defined in two places"; return 1; }
|
||||
}
|
||||
|
||||
@test "core gates guard work on the declaration, not on the plugin defining a hook" {
|
||||
# A harness that forgot the declaration but implemented the hook would
|
||||
# get a guard the launch message has just said it does not have.
|
||||
grep -q 'harness_has_guard notify' "$REPO_ROOT/bin/dejima"
|
||||
}
|
||||
|
||||
@test "install.sh installs no harness's hooks itself" {
|
||||
# Where dejima's guards go, and in what shape, is the selected harness's
|
||||
# business. install.sh knowing ~/.claude/settings.json's schema is the
|
||||
# coupling; it delegates to the harness's plugin now.
|
||||
run grep -nE '\.claude/(settings\.json|hooks)' "$REPO_ROOT/install.sh"
|
||||
[ "$status" -ne 0 ] || { echo "install.sh still writes a harness's config:"; echo "$output"; return 1; }
|
||||
grep -q 'harness_register_guards' "$REPO_ROOT/install.sh"
|
||||
grep -q '^harness_register_guards()' "$REPO_ROOT/bin/harness/claude-code.sh"
|
||||
}
|
||||
|
||||
@test "the plugin loader does not depend on its caller's SCRIPT_DIR" {
|
||||
# bin/dejima sets it to bin/ and install.sh sets it to the repo root. A
|
||||
# library that resolves paths from one of those only works for one of
|
||||
# its callers.
|
||||
sed -n '/^harness_plugin_load()/,/^}/p' "$REPO_ROOT/bin/dejima-harness-lib.sh" \
|
||||
| grep -q 'BASH_SOURCE'
|
||||
run sed -n '/^harness_plugin_load()/,/^}/p' "$REPO_ROOT/bin/dejima-harness-lib.sh"
|
||||
[[ "$output" != *'$SCRIPT_DIR'* ]] || { echo "$output"; return 1; }
|
||||
}
|
||||
|
||||
@test "a finding's label lives in trusted code, never in a manifest" {
|
||||
# `ack` recovers an id by stripping a fixed label from the front of a
|
||||
# finding. Those labels are a parser's grammar, and provider directories
|
||||
# are not in tcb_snapshot's list - in the self-mount case a session can
|
||||
# edit a manifest without raising a CRITICAL. So the label stays a
|
||||
# literal in code the audit does watch, which is why host-exec surfaces
|
||||
# are a plugin hook rather than a manifest key.
|
||||
grep -q 'HOST-CODE-EXECUTION' "$REPO_ROOT/bin/harness/claude-code.sh"
|
||||
run grep -rn 'HOST-CODE-EXECUTION' "$REPO_ROOT/harnesses"
|
||||
[ "$status" -ne 0 ] || { echo "a finding label is manifest data:"; echo "$output"; return 1; }
|
||||
run grep -rniE 'host_exec_paths|host_exec_label' "$REPO_ROOT/harnesses" "$REPO_ROOT/bin/dejima-provider-lib.sh"
|
||||
[ "$status" -ne 0 ] || { echo "host-exec surfaces leaked into the manifest schema:"; echo "$output"; return 1; }
|
||||
}
|
||||
|
||||
@test "the audit resolves host-execution paths from the harness, not a hardcoded one" {
|
||||
run grep -n 'STATUSLINE_PATH' "$REPO_ROOT/bin/dejima-audit"
|
||||
[ "$status" -ne 0 ] || { echo "$output"; return 1; }
|
||||
grep -q 'host_exec_load' "$REPO_ROOT/bin/dejima-audit"
|
||||
grep -q '^harness_host_exec_surfaces()' "$REPO_ROOT/bin/harness/claude-code.sh"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user