Compare commits
132
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 | ||
|
|
32589c0603 | ||
|
|
3a72114770 | ||
|
|
e69d35a8a4 | ||
|
|
07e21f86b1 | ||
|
|
57ad800c34 | ||
|
|
c441116c3b | ||
|
|
97bada30d3 | ||
|
|
81123c6d05 | ||
|
|
c2023ff402 | ||
|
|
9374eb5757 | ||
|
|
5c1717ebc4 | ||
|
|
4ba9a0499e | ||
|
|
3b03545c3a | ||
|
|
f19f89c13a | ||
|
|
fc9ab6768b | ||
|
|
dddeefcd21 | ||
|
|
40e6c7ba8b | ||
|
|
599fc6e3eb | ||
|
|
7ed9790b6c | ||
|
|
d50343869f | ||
|
|
70b2a1e392 | ||
|
|
cd6ad04145 | ||
|
|
38ee9ee246 | ||
|
|
e2ea1a8afb | ||
|
|
313250f7c4 | ||
|
|
fa106cd8ff | ||
|
|
37e8609db3 | ||
|
|
c108e58992 | ||
|
|
cdf0e78525 | ||
|
|
f72e4292d5 | ||
|
|
42088f9c07 | ||
|
|
6e969c636f | ||
|
|
73e1a84414 | ||
|
|
0aef363910 | ||
|
|
2222aef7d1 | ||
|
|
b71b0c1dda | ||
|
|
e7e6a57dc3 | ||
|
|
d648c7743a | ||
|
|
00555eadd7 | ||
|
|
da045fa77f | ||
|
|
e241ff9a31 | ||
|
|
5fa4be3cac | ||
|
|
8122267f2b | ||
|
|
0e29236dff | ||
|
|
aec22e32b7 | ||
|
|
5a2d460075 | ||
|
|
1e65852f2c | ||
|
|
5a80e6f76f | ||
|
|
415564f00f | ||
|
|
8cc9e29ab3 | ||
|
|
3fbd6ad75f | ||
|
|
5dc158f3a1 | ||
|
|
14bb297dbc | ||
|
|
1cb1be5ee8 | ||
|
|
799ffa0770 | ||
|
|
bb2c1f218f | ||
|
|
4e6261561e |
+101
-5
@@ -10,7 +10,7 @@ told) and `README.md`.
|
||||
`test/runtime.bats` builds the image and boots real containers, so it needs a
|
||||
working Docker. A dejima sandbox does not have one — the container has no Docker
|
||||
socket, by design. Everything else runs anywhere, which is what the CI job names
|
||||
mean: `sandbox` for the suites that need only a shell, `host-docker` for the one
|
||||
mean: `confined` for the suites that need only a shell, `privileged` for the one
|
||||
that does not.
|
||||
|
||||
## `bats` is a declared dependency here, not part of the image
|
||||
@@ -48,13 +48,13 @@ a change to a shipped doc or a `docker/` file usually needs `static.bats` *as
|
||||
well as* whatever else it touched — that suite asserts on files it does not own.
|
||||
|
||||
**CI runs `test/runtime.bats` on every pull request**, as a separate
|
||||
`host-docker` job in `.gitea/workflows/tests.yml`. So being unable to run it
|
||||
`privileged` job in `.gitea/workflows/tests.yml`. So being unable to run it
|
||||
locally is not a reason to stop, to hedge, or to leave a change unfinished. Run
|
||||
the sandbox suites, open the PR, and say plainly in the PR body that the
|
||||
host-docker half was not run locally.
|
||||
the confined suites, open the PR, and say plainly in the PR body that the
|
||||
privileged half was not run locally.
|
||||
|
||||
**But do not read "CI covers it" as "it passed."** That job is pinned to
|
||||
`runs-on: dejima-runtime`, a label only the one configured self-hosted runner
|
||||
`runs-on: dejima-privileged`, a label only the one configured self-hosted runner
|
||||
carries — it needs the host network namespace, the Docker socket, and a shared
|
||||
volume, none of which a workflow can request. If that runner is offline the job
|
||||
**queues instead of failing**, and the PR sits with the runtime half neither
|
||||
@@ -80,6 +80,34 @@ a change would have CI reach a new host, that belongs in
|
||||
allowlist is read from `main`, so an undeclared destination fails rather than
|
||||
connects. See [docs/ci.md](docs/ci.md).
|
||||
|
||||
## Batch your pushes, because runs queue rather than supersede
|
||||
|
||||
Every push to an open pull request starts a full workflow run, and Gitea does
|
||||
not implement `concurrency:` (see the comment at the top of
|
||||
`.gitea/workflows/tests.yml`, which records the test). A second push does not
|
||||
cancel the first: it queues behind it. So four pushes in ten minutes is four
|
||||
full runs, and the load keeps arriving long after you have stopped pushing,
|
||||
because the backlog is still draining.
|
||||
|
||||
That is not hypothetical here. A session pushing commit-by-commit while
|
||||
iterating on a pull request queued enough runs to saturate this project's
|
||||
forge: nginx stopped being able to open connections to Gitea at all, and the
|
||||
requests that timed out were the runners' own `UpdateLog` and `UpdateTask`
|
||||
calls plus the operator's browser. The forge went down for several minutes and
|
||||
tripped uptime alerts.
|
||||
|
||||
So: **finish a change, then push once.** Commit as often as you like locally;
|
||||
that costs nothing. What costs something is every `git push`. If you have three
|
||||
commits to make, make them and push at the end, rather than pushing after each.
|
||||
|
||||
The same applies to forge API reads. `gitea-pr comments` fetches one request
|
||||
per review on the pull request, so on a heavily reviewed one that is dozens of
|
||||
requests per invocation. Run it when you need the answer, not repeatedly while
|
||||
iterating, and never in a loop.
|
||||
|
||||
If a push does supersede a run you no longer care about, cancel the stale one
|
||||
by hand in the web UI. Nothing does it for you.
|
||||
|
||||
## A test that reads its environment will pass here and fail on your machine
|
||||
|
||||
Twice now a test has stated a precondition by leaving something unset, which
|
||||
@@ -95,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.
|
||||
|
||||
+16
-15
@@ -8,9 +8,10 @@
|
||||
# and boots real containers, so it needs a Docker daemon the sandbox
|
||||
# deliberately does not have. That constraint is what a contributor has to
|
||||
# reason about when deciding what they can run before pushing, so the jobs are
|
||||
# named for it.
|
||||
# named for the runner tier they need: `confined` for the ones a shell can
|
||||
# run anywhere, `privileged` for the one that needs a daemon.
|
||||
#
|
||||
# The sandbox half is a matrix over its files rather than one invocation. The
|
||||
# The confined half is a matrix over its files rather than one invocation. The
|
||||
# files are independent, so running them together only serialised them: wall
|
||||
# clock was their sum rather than the slowest one. It also means a failure
|
||||
# names the suite that failed in the job list, instead of one red check
|
||||
@@ -54,7 +55,7 @@ on:
|
||||
# supersede, and cancel the stale run by hand if it is in the way.
|
||||
|
||||
jobs:
|
||||
sandbox:
|
||||
confined:
|
||||
# A label carried only by a runner configured for this - see ci/ and
|
||||
# docs/ci.md. Not a generic one, even though these suites genuinely do run
|
||||
# anywhere a shell does, because "runs anywhere" is a statement about what
|
||||
@@ -69,7 +70,7 @@ jobs:
|
||||
# If no runner carries this label, these jobs queue. That is the intended
|
||||
# failure: it is a better one than a green run on a host that grants
|
||||
# everything.
|
||||
runs-on: dejima-ci
|
||||
runs-on: dejima-confined
|
||||
strategy:
|
||||
# One suite per job. fail-fast off because these cover unrelated areas -
|
||||
# cancelling the other six on the first failure would hide whether a
|
||||
@@ -85,7 +86,7 @@ jobs:
|
||||
- proxy
|
||||
- config
|
||||
- providers
|
||||
name: sandbox (${{ matrix.suite }})
|
||||
name: confined (${{ matrix.suite }})
|
||||
container:
|
||||
# Debian bookworm plus a node binary - node is what actions/checkout
|
||||
# needs to run at all, and apt-get is what everything below needs.
|
||||
@@ -165,7 +166,7 @@ jobs:
|
||||
chown -R node:node .
|
||||
su node -c "TERM=xterm-256color bats test/${{ matrix.suite }}.bats"
|
||||
|
||||
host-docker:
|
||||
privileged:
|
||||
# A different label from the job above, and the only one in this file that
|
||||
# asks for anything privileged. This job needs the host network namespace,
|
||||
# the Docker socket and the shared volume below, none of which a workflow
|
||||
@@ -180,7 +181,7 @@ jobs:
|
||||
# The runner behind this label can be taken by anything it runs (see
|
||||
# ci/act-runner-runtime.example.yaml), so it belongs on a host that exists
|
||||
# for that and holds nothing else.
|
||||
runs-on: dejima-runtime
|
||||
runs-on: dejima-privileged
|
||||
container:
|
||||
image: node:20-bookworm
|
||||
# One volume, and it is load-bearing - see TMPDIR on the bats step for
|
||||
@@ -447,9 +448,9 @@ jobs:
|
||||
# These fail on how the RUNNER is configured, not on anything in a pull
|
||||
# request. A red check here means the machine needs attention; it does not
|
||||
# mean the branch does.
|
||||
runner-env-sandbox:
|
||||
runs-on: dejima-ci
|
||||
name: runner-env (sandbox)
|
||||
runner-env-confined:
|
||||
runs-on: dejima-confined
|
||||
name: runner-env (confined)
|
||||
container:
|
||||
image: node:20-bookworm
|
||||
steps:
|
||||
@@ -516,11 +517,11 @@ jobs:
|
||||
env:
|
||||
# Declared, not detected: landing on the wrong runner should fail
|
||||
# rather than quietly select a different set of assertions.
|
||||
DEJIMA_CI_TIER: sandbox
|
||||
DEJIMA_CI_TIER: confined
|
||||
|
||||
runner-env-host-docker:
|
||||
runs-on: dejima-runtime
|
||||
name: runner-env (host-docker)
|
||||
runner-env-privileged:
|
||||
runs-on: dejima-privileged
|
||||
name: runner-env (privileged)
|
||||
container:
|
||||
image: node:20-bookworm
|
||||
steps:
|
||||
@@ -581,4 +582,4 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- run: bats test/ci-runner.bats
|
||||
env:
|
||||
DEJIMA_CI_TIER: host-docker
|
||||
DEJIMA_CI_TIER: privileged
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,39 @@
|
||||
# Contributing
|
||||
|
||||
[← dejima README](README.md)
|
||||
|
||||
dejima is under active, rapid development, and it is developed on a private
|
||||
forge. That forge does not allow account registration, and it does not accept
|
||||
anonymous issues or pull requests. So there is currently no way for you to
|
||||
file either, and that is a property of where this lives rather than a comment
|
||||
on the contribution.
|
||||
|
||||
That will change: this project will provide a way to submit issues and pull
|
||||
requests. Until it does, if you are using dejima and need it to do something
|
||||
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.
|
||||
|
||||
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
|
||||
|
||||
`docs/responsible-use.md` applies to dejima itself as much as to anything else
|
||||
you point it at, and one rule in it is load-bearing here: what reaches `main`
|
||||
becomes the baseline for everything after it. dejima-ci-hardener reads
|
||||
`.dejima/allowed-domains` from `origin/main` and treats what it finds there as
|
||||
reviewed, so a change to that file is a change to what a CI runner will let
|
||||
through.
|
||||
+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 |
+409
-136
@@ -21,6 +21,15 @@ COMPOSE_FILE="$REPO_ROOT/docker/docker-compose.yml"
|
||||
source "$SCRIPT_DIR/dejima-project-lib.sh"
|
||||
# shellcheck source=dejima-provider-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
|
||||
|
||||
# Flags come first, then the optional workspace path, then "--" and
|
||||
# everything after that reaches the container command untouched - including a
|
||||
@@ -54,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
|
||||
@@ -67,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
|
||||
@@ -157,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
|
||||
@@ -234,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
|
||||
@@ -261,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 -
|
||||
@@ -281,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
|
||||
@@ -289,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
|
||||
@@ -306,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
|
||||
@@ -471,7 +605,7 @@ if [ -n "$SANDBOX_SSH_KEY" ]; then
|
||||
echo "Recommended: chmod 600 $SANDBOX_SSH_KEY" >&2
|
||||
fi
|
||||
# SANDBOX_SSH_KEY is meant to be a dedicated, low-privilege deploy key
|
||||
# (see README "Headless git push"), not your real identity - it stays
|
||||
# (see docs/configuration.md "Headless git push"), not your real identity - it stays
|
||||
# loaded in an in-container agent for the whole session, unattended.
|
||||
# Nothing enforces that convention, so at least catch the common case
|
||||
# of pointing it at a default personal keyfile by mistake.
|
||||
@@ -480,7 +614,7 @@ if [ -n "$SANDBOX_SSH_KEY" ]; then
|
||||
echo "Warning: $SANDBOX_SSH_KEY looks like a default personal SSH key," \
|
||||
"not a dedicated one." >&2
|
||||
echo "It will be loaded into the sandbox for the whole unattended session -" \
|
||||
"see README 'Headless git push'." >&2
|
||||
"see docs/configuration.md." >&2
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
@@ -496,19 +630,27 @@ fi
|
||||
if [ "$DEJIMA_WORKFLOW_PUSH" = "on" ] && [ "$have_forwarded_agent" = 0 ] && [ -z "$SANDBOX_SSH_KEY" ]; then
|
||||
echo "SSH_AUTH_SOCK is not set or not a valid socket, and [core] ssh_key is not set." >&2
|
||||
echo "Either start ssh-agent and run ssh-add, or set [core] ssh_key in $(config_path) to a" >&2
|
||||
echo "dedicated key for headless use - see README 'Headless git push' section." >&2
|
||||
echo "dedicated key for headless use - see docs/configuration.md." >&2
|
||||
exit 1
|
||||
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
|
||||
@@ -565,46 +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 README "Throwaway ~/.claude.json" 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"
|
||||
@@ -631,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
|
||||
@@ -667,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
|
||||
@@ -679,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
|
||||
@@ -688,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
|
||||
}
|
||||
@@ -764,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
|
||||
@@ -833,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
|
||||
@@ -877,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
|
||||
@@ -921,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
|
||||
@@ -961,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
|
||||
@@ -1095,6 +1290,25 @@ if remote_enabled; then
|
||||
DEJIMA_NOTIFY_DIR="$DEJIMA_STATE_DIR/notify/$DEJIMA_RUN_ID"
|
||||
mkdir -p -m 700 "$DEJIMA_NOTIFY_DIR"
|
||||
|
||||
# Verify what was just created, because everything downstream assumes it.
|
||||
# A leaf left behind root-owned by an earlier container restart (see
|
||||
# entrypoint.sh) is the shape this catches: mkdir -p succeeds against an
|
||||
# existing directory regardless of who owns it, so without this the mount
|
||||
# goes ahead and the failure surfaces later as EACCES in a hook.
|
||||
#
|
||||
# A warning rather than a refusal. Remote access is opt-in, and the rest
|
||||
# of the session works without it - blocking a launch over undeliverable
|
||||
# notifications would cost more than it saves. The neighbouring state-dir
|
||||
# check does refuse, but that one is about a symlink handing a session an
|
||||
# attacker-chosen path; this one is only about the feature working.
|
||||
if [ ! -O "$DEJIMA_NOTIFY_DIR" ] || [ ! -w "$DEJIMA_NOTIFY_DIR" ]; then
|
||||
echo "warning: $DEJIMA_NOTIFY_DIR is not a directory this user owns and can write." >&2
|
||||
echo "Remote access is enabled, so notifications and dejima-attach will not work for" >&2
|
||||
echo "this session. A leftover root-owned directory here is usually Docker's doing," >&2
|
||||
echo "from a container that was restarted rather than relaunched; removing it and" >&2
|
||||
echo "starting again clears it." >&2
|
||||
fi
|
||||
|
||||
# $TMUX_PANE is set by tmux in the launching shell. Empty when the
|
||||
# sandbox wasn't started from tmux: the session is still registered, just
|
||||
# listed as not-attachable, rather than silently omitted.
|
||||
@@ -1161,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
|
||||
@@ -1234,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
|
||||
@@ -1245,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
|
||||
@@ -1254,16 +1500,43 @@ audit_reports="$AUDIT_DIR/$audit_slug"
|
||||
# every ordinary launch.
|
||||
pending_crit="$(find "$audit_reports" -maxdepth 1 -name '*-CRITICAL.txt' 2>/dev/null | sort)" || true
|
||||
if [ -n "$pending_crit" ]; then
|
||||
n="$(printf '%s\n' "$pending_crit" | wc -l)"
|
||||
echo "" >&2
|
||||
echo " dejima: $n unacknowledged CRITICAL finding(s) in this workspace" >&2
|
||||
printf '%s\n' "$pending_crit" | sed 's#.*/##; s#-CRITICAL\.txt$##; s#^# from run #' >&2
|
||||
echo " Review with: dejima-audit ack \"$WORKSPACE_DIR\"" >&2
|
||||
if [ -t 0 ]; then
|
||||
printf ' [c] continue [q] quit: ' >&2
|
||||
read -r reply || reply=q
|
||||
case "$reply" in q|Q) echo "aborted." >&2; exit 1 ;; esac
|
||||
elif [ "$(config_get_bool audit strict)" = "1" ]; then
|
||||
# Re-read after an inline `ack`, so approving everything here clears the
|
||||
# warning instead of asking again about reports that no longer carry a
|
||||
# CRITICAL. Looping rather than asking once: `ack` walks ONE report per
|
||||
# run (see select_report), so a workspace with several needs several
|
||||
# passes, and sending the operator back to their shell between each is
|
||||
# the friction this option exists to remove (#65).
|
||||
while [ -n "$pending_crit" ]; do
|
||||
n="$(printf '%s\n' "$pending_crit" | wc -l)"
|
||||
echo "" >&2
|
||||
printf ' dejima: %s%s unacknowledged %sCRITICAL%s finding(s) in this workspace\n' \
|
||||
"$C_CRIT" "$n" "$C_CRIT" "$C_RESET" >&2
|
||||
printf '%s\n' "$pending_crit" \
|
||||
| sed "s#.*/##; s#-CRITICAL\.txt\$##; s#^# from run ${C_INFO}#; s#\$#${C_RESET}#" >&2
|
||||
printf ' Review with: %sdejima-audit ack "%s"%s\n' \
|
||||
"$C_KEY" "$WORKSPACE_DIR" "$C_RESET" >&2
|
||||
if [ -t 0 ]; then
|
||||
printf ' %s[a]%s review now %s[c]%s continue %s[q]%s quit: ' \
|
||||
"$C_KEY" "$C_RESET" "$C_KEY" "$C_RESET" "$C_KEY" "$C_RESET" >&2
|
||||
read -r reply || reply=q
|
||||
case "$reply" in
|
||||
q|Q) echo "aborted." >&2; exit 1 ;;
|
||||
a|A)
|
||||
# Run it here rather than telling the operator to run it
|
||||
# in another terminal: this is the moment they are
|
||||
# looking at the finding, and the container has not
|
||||
# started, so nothing is waiting on the answer.
|
||||
"$SCRIPT_DIR/dejima-audit" ack "$WORKSPACE_DIR" || true
|
||||
pending_crit="$(find "$audit_reports" -maxdepth 1 -name '*-CRITICAL.txt' 2>/dev/null | sort)" || true
|
||||
continue
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
break
|
||||
done
|
||||
fi
|
||||
if [ -n "$pending_crit" ] && [ ! -t 0 ]; then
|
||||
if [ "$(config_get_bool audit strict)" = "1" ]; then
|
||||
echo " audit.strict is on and stdin is not a tty - refusing to start." >&2
|
||||
exit 1
|
||||
else
|
||||
|
||||
+12
-2
@@ -20,6 +20,12 @@ set -euo pipefail
|
||||
SCRIPT_DIR="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
|
||||
# shellcheck source=dejima-project-lib.sh
|
||||
source "$SCRIPT_DIR/dejima-project-lib.sh"
|
||||
# For ipset_for_domain: the live add below writes into a running container's
|
||||
# ipsets, and which set a name belongs in is the container's rule, not this
|
||||
# script's. Sourced rather than restated so the two cannot drift - the same
|
||||
# reason init-firewall.sh and dns-proxy.sh share this file.
|
||||
# shellcheck source=../docker/fixed-domains.sh
|
||||
source "$SCRIPT_DIR/../docker/fixed-domains.sh"
|
||||
|
||||
usage() {
|
||||
echo "Usage: dejima-allow [--global] [--project [path]] [--container <id>] <domain>" >&2
|
||||
@@ -133,9 +139,13 @@ if [ "$session_wanted" -eq 1 ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# allowed-domains carries no port restriction, so a GitHub address added
|
||||
# there is reachable on 22 as well - which is the read-only guarantee gone,
|
||||
# from a command whose whole output says it granted one domain.
|
||||
target_set=$(ipset_for_domain "$domain")
|
||||
for ip in $ips; do
|
||||
echo "Allowing $ip ($domain)"
|
||||
docker exec -u root "$container" ipset add allowed-domains "$ip" -exist
|
||||
echo "Allowing $ip ($domain -> $target_set)"
|
||||
docker exec -u root "$container" ipset add "$target_set" "$ip" -exist
|
||||
done
|
||||
|
||||
echo "$domain allowed for the running session ($container)."
|
||||
|
||||
+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%:*}"
|
||||
|
||||
+580
-212
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,47 @@
|
||||
#!/bin/bash
|
||||
# Terminal colour, shared by bin/dejima and bin/dejima-audit.
|
||||
#
|
||||
# Shared rather than duplicated because the two are one surface to the
|
||||
# operator: the launcher's "unacknowledged CRITICAL" warning and the audit
|
||||
# review it sends them to must agree about what red means, or the colour is
|
||||
# decoration instead of information.
|
||||
#
|
||||
# Nothing here decides WHAT to highlight - that belongs with the text. It
|
||||
# decides only whether colour is safe to emit at all, and provides the names.
|
||||
#
|
||||
# Off unless the stream being written to is a terminal, so a redirected
|
||||
# report or a piped test never receives escape sequences: the audit's output
|
||||
# is read back by tests and, in the reports it writes, by future runs.
|
||||
# NO_COLOR (any value, https://no-color.org) and TERM=dumb both turn it off
|
||||
# as well.
|
||||
#
|
||||
# The variables are always DEFINED, empty when colour is off, so callers
|
||||
# interpolate them unconditionally and no call site needs an if.
|
||||
color_init() {
|
||||
local fd="${1:-1}"
|
||||
C_RESET=""; C_CRIT=""; C_WARN=""; C_INFO=""; C_PATH=""
|
||||
C_KEY=""; C_DIM=""; C_ADD=""; C_DEL=""; C_OK=""
|
||||
[ -t "$fd" ] || return 0
|
||||
[ -z "${NO_COLOR+x}" ] || return 0
|
||||
case "${TERM:-}" in ''|dumb) return 0 ;; esac
|
||||
C_RESET=$'\033[0m'
|
||||
C_CRIT=$'\033[1;31m' # bold red - runs unsandboxed host code
|
||||
C_WARN=$'\033[33m' # yellow
|
||||
C_INFO=$'\033[36m' # cyan
|
||||
C_PATH=$'\033[1;36m' # bold cyan - the file or key a finding is about
|
||||
C_KEY=$'\033[1m' # bold - the keystroke an operator has to press
|
||||
C_DIM=$'\033[2m' # dim - the audit's own framing around content
|
||||
C_ADD=$'\033[32m' # green - a diff's added lines
|
||||
C_DEL=$'\033[31m' # red - a diff's removed lines
|
||||
C_OK=$'\033[32m' # green
|
||||
}
|
||||
|
||||
# The colour for a tier name, so the mapping lives in one place rather than
|
||||
# at each site that prints one.
|
||||
tier_color() {
|
||||
case "$1" in
|
||||
CRITICAL) printf '%s' "$C_CRIT" ;;
|
||||
WARNING) printf '%s' "$C_WARN" ;;
|
||||
*) printf '%s' "$C_INFO" ;;
|
||||
esac
|
||||
}
|
||||
+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[@]}" "$@"
|
||||
+41
-29
@@ -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}
|
||||
@@ -82,7 +91,7 @@ services:
|
||||
# Opt-in (default off): also ship session prompts, assistant
|
||||
# responses, and tool invocations to Loki as the
|
||||
# dejima-conversation job. Content only leaves the container
|
||||
# if LOKI_URL is also set. See README "Network request logging".
|
||||
# if LOKI_URL is also set. See docs/security.md "Network request logging".
|
||||
SANDBOX_LOG_CONVERSATION: ${SANDBOX_LOG_CONVERSATION:-0}
|
||||
SSH_AUTH_SOCK: /ssh-agent
|
||||
ENABLE_FIREWALL: ${ENABLE_FIREWALL:-1}
|
||||
@@ -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}
|
||||
|
||||
+54
-6
@@ -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,6 +276,32 @@ fi
|
||||
[ -n "${ANTHROPIC_BASE_URL:-}" ] || unset ANTHROPIC_BASE_URL
|
||||
[ -n "${ANTHROPIC_AUTH_TOKEN:-}" ] || unset ANTHROPIC_AUTH_TOKEN
|
||||
|
||||
# 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.
|
||||
#
|
||||
# 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
|
||||
echo "Notifications will not be delivered for this session." >&2
|
||||
elif ! gosu "$SANDBOX_USER" test -w "$DEJIMA_NOTIFY_DIR"; then
|
||||
echo "WARNING: remote access is enabled, but $DEJIMA_NOTIFY_DIR is not writable by" >&2
|
||||
echo "$SANDBOX_USER - so notifications will fail, and this session will not appear in" >&2
|
||||
echo "dejima-attach. This is what a container that was RESTARTED rather than" >&2
|
||||
echo "relaunched looks like: bin/dejima creates the marker directory per launch and" >&2
|
||||
echo "removes it on exit, and Docker recreated the missing bind source as root." >&2
|
||||
echo "Stop this container and start a new session with 'dejima' to restore both." >&2
|
||||
fi
|
||||
fi
|
||||
|
||||
# Test harness hook (test/): everything above this point is the real
|
||||
# startup sequence - DNS proxy, firewall, IPv6 block, logging,
|
||||
# privilege-drop prep. Skip execing $SANDBOX_CMD so the suite can `docker
|
||||
@@ -266,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} "$@"
|
||||
|
||||
@@ -46,3 +46,28 @@ GITHUB_DNS_SUFFIXES=(
|
||||
"githubusercontent.com"
|
||||
"githubassets.com"
|
||||
)
|
||||
|
||||
# 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: 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.
|
||||
#
|
||||
# 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
|
||||
if [ "$domain" = "$suffix" ] || [[ "$domain" == *".$suffix" ]]; then
|
||||
printf 'github-domains'
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
printf 'allowed-domains'
|
||||
}
|
||||
|
||||
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[@]}"} "$@"
|
||||
+75
-21
@@ -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)"
|
||||
@@ -76,10 +70,13 @@ else
|
||||
echo "No Docker DNS rules were present to restore"
|
||||
fi
|
||||
|
||||
# Allow localhost. DNS resolution goes entirely through Docker's embedded
|
||||
# resolver at 127.0.0.11 (see resolv.conf), which stays on loopback, so this
|
||||
# also covers DNS - no need for a separate "UDP/53 to anywhere" rule, which
|
||||
# would just be an unrestricted-destination exfil channel.
|
||||
# Allow localhost. Every DNS query this container makes starts here: to
|
||||
# dnsmasq on 127.0.0.1, and from there to Docker's embedded resolver on
|
||||
# 127.0.0.11 (see resolv.conf). What this rule does NOT cover is the hop
|
||||
# after that, where the embedded resolver forwards an external name to the
|
||||
# host's nameservers - see the owner-matched rule further down, which is
|
||||
# there because that hop can leave this namespace. Still no "UDP/53 to
|
||||
# anywhere" rule, which would be an unrestricted-destination exfil channel.
|
||||
iptables -A INPUT -i lo -j ACCEPT
|
||||
iptables -A OUTPUT -o lo -j ACCEPT
|
||||
|
||||
@@ -269,13 +266,17 @@ if [ -n "${EXTRA_ALLOWED_DOMAINS:-}" ]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
# Not always allowed-domains: that set carries no port restriction,
|
||||
# so a GitHub address landing in it is reachable on 22 as well and the
|
||||
# read-only guarantee is gone. See ipset_for_domain.
|
||||
target_set=$(ipset_for_domain "$domain")
|
||||
while read -r ip; do
|
||||
if [[ ! "$ip" =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]; then
|
||||
echo "WARNING: Invalid IP from DNS for $domain: $ip, skipping"
|
||||
continue
|
||||
fi
|
||||
echo "Adding $ip for $domain"
|
||||
ipset add allowed-domains "$ip" 2>/dev/null || true
|
||||
echo "Adding $ip for $domain (-> $target_set)"
|
||||
ipset add "$target_set" "$ip" 2>/dev/null || true
|
||||
done < <(echo "$ips")
|
||||
done
|
||||
fi
|
||||
@@ -305,6 +306,32 @@ 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 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.
|
||||
#
|
||||
# --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.
|
||||
#
|
||||
# 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
|
||||
else
|
||||
echo "WARNING: the iptables owner match is unavailable, so Docker's embedded" >&2
|
||||
echo "resolver cannot forward out of this namespace. If DNS works anyway this" >&2
|
||||
echo "host forwards elsewhere and nothing is wrong; the self-check below says which." >&2
|
||||
fi
|
||||
|
||||
# Then allow only specific outbound traffic to allowed domains. This isn't
|
||||
# port-restricted, so it also covers git-over-ssh (port 22) to the
|
||||
# configured forge's hosts, which are already in the ipset above - no
|
||||
@@ -340,6 +367,33 @@ iptables -A OUTPUT -j NFLOG --nflog-group 2 --nflog-prefix "reject"
|
||||
iptables -A OUTPUT -j REJECT --reject-with icmp-admin-prohibited
|
||||
|
||||
echo "Firewall rules applied. Self-checking..."
|
||||
|
||||
# DNS before either connectivity check, because both of those resolve a name
|
||||
# before they connect and neither can tell you that resolution is what failed.
|
||||
# The negative control is the trap: with DNS dead it fails for the wrong
|
||||
# reason and prints "example.com is unreachable, as it should be", so a
|
||||
# container with no working DNS at all reports a passing check and then dies
|
||||
# on the positive control naming a host rather than the cause. Reading that
|
||||
# output backwards took an afternoon; it should take one line.
|
||||
if ! dns_probe=$(dig +short +time=3 +tries=2 A api.github.com 2>&1) || [ -z "$dns_probe" ]; then
|
||||
echo "ERROR: self-check failed - DNS does not resolve inside the sandbox." >&2
|
||||
echo >&2
|
||||
echo "The path is: this container -> dnsmasq on 127.0.0.1 (dns-proxy.sh) ->" >&2
|
||||
echo "Docker's embedded resolver on 127.0.0.11 -> the host's nameservers." >&2
|
||||
echo "Only the first two hops are on loopback. If Docker forwards the last" >&2
|
||||
echo "one from inside this namespace, it traverses the OUTPUT chain, matches" >&2
|
||||
echo "nothing, and is rejected - which reads as SERVFAIL, not as a firewall." >&2
|
||||
echo >&2
|
||||
echo " dig said: ${dns_probe:-(empty answer)}" >&2
|
||||
echo " packets taken by the catch-all OUTPUT reject so far:" >&2
|
||||
iptables -L OUTPUT -vn 2>/dev/null \
|
||||
| awk '/icmp-admin-prohibited/ {print " " $1 " packets, " $2 " bytes"}' >&2
|
||||
echo " dnsmasq's last words:" >&2
|
||||
tail -5 /var/log/dnsmasq.log 2>/dev/null | sed 's/^/ /' >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "self-check ok - DNS resolves an allowlisted name"
|
||||
|
||||
# --max-time as well as --connect-timeout on both self-checks: a connect that
|
||||
# succeeds and then stalls is not covered by --connect-timeout, and while these
|
||||
# two sit inside `if` (so set -e cannot take the script down the way it could
|
||||
|
||||
+90
-82
@@ -6,20 +6,20 @@
|
||||
CI runs.** That is the whole of it, and everything below is either why it
|
||||
cannot be gated or what to do about it instead.
|
||||
|
||||
> **This document is the why.** For the how, [dejima-ci-hardener](https://gitbox.branderson.io/branderson/dejima-ci-hardener)
|
||||
> is a self-contained tool that does it — installed on the CI box, never on a
|
||||
> **This document is the why.** For the how, [dejima-ci-hardener](https://gitbox.branderson.io/dejima/dejima-ci-hardener)
|
||||
> is a self-contained tool that does it, installed on the CI box, never on a
|
||||
> dejima host, and it does not assume you have read this.
|
||||
|
||||
The chain needs no operator action at any step. A session writes code; the
|
||||
push guard permits a feature branch, which is what it exists to allow; the
|
||||
session opens a pull request, which its briefing tells it to do without asking
|
||||
first; `on: pull_request` fires and the runner checks out that commit and runs
|
||||
it. The code does not have to be a modified workflow file to be executed — a
|
||||
it. The code does not have to be a modified workflow file to be executed: a
|
||||
workflow that runs a test suite is already running every file in it, and a
|
||||
plausible-looking helper in a test file is a much quieter carrier than an
|
||||
edited workflow, which shows up in a diff.
|
||||
|
||||
The gate this sandbox otherwise relies on — you merge, the agent does not —
|
||||
The gate this sandbox otherwise relies on (you merge, the agent does not)
|
||||
sits strictly downstream of that. By the time anyone reads the diff, the code
|
||||
has run.
|
||||
|
||||
@@ -30,7 +30,7 @@ elsewhere.
|
||||
|
||||
**And it applies to projects with no CI at all.** A branch can add the first
|
||||
workflow, so what matters is not what a repository's workflows do today but
|
||||
which runners its branches can reach — which is a property of the forge, not
|
||||
which runners its branches can reach, which is a property of the forge, not
|
||||
of the project. If you read one section of this document before pointing a
|
||||
sandbox at something, make it
|
||||
[what a repository can reach](#closing-the-rest-what-a-repository-can-reach);
|
||||
@@ -43,11 +43,11 @@ The code CI executes is code the session already had. Relative to the sandbox,
|
||||
a run adds three things, and every control in this document maps onto one of
|
||||
them:
|
||||
|
||||
1. **Execution on a host you own** — outside the container, outside the
|
||||
1. **Execution on a host you own**: outside the container, outside the
|
||||
allowlist, with whatever that host can reach.
|
||||
2. **Egress from a network position you own** — no dejima firewall, no DNS
|
||||
2. **Egress from a network position you own**: no dejima firewall, no DNS
|
||||
proxy, ordinary networking.
|
||||
3. **Credentials the run is handed** — the secrets you configured, plus the
|
||||
3. **Credentials the run is handed**: the secrets you configured, plus the
|
||||
token the forge injects into every job whether the workflow asks for one or
|
||||
not.
|
||||
|
||||
@@ -61,7 +61,7 @@ The obvious answer is to require a human to approve a run. Each version of
|
||||
that turns out to be reachable from the branch, from a credential the session
|
||||
already holds, or to be no gate at all:
|
||||
|
||||
- **Changing the trigger** — `workflow_dispatch` only, or `push` to `main`
|
||||
- **Changing the trigger**: `workflow_dispatch` only, or `push` to `main`
|
||||
only. The workflow file lives in the tree the session controls, and on a
|
||||
`pull_request` run it is read from the head of the pull request.
|
||||
- **Triggering by hand.** A forge token that can open a pull request generally
|
||||
@@ -84,7 +84,7 @@ the run. Everything here bounds what the run can do.
|
||||
|
||||
## Before anything else: protect the branch
|
||||
|
||||
Every control below that is worth anything roots its trust in `main` — most
|
||||
Every control below that is worth anything roots its trust in `main`: most
|
||||
directly the egress allowlist, which is read from there. A job token with
|
||||
write access to the repository can push to an unprotected branch, so without
|
||||
this the untrusted party can edit the thing that is supposed to constrain it.
|
||||
@@ -98,6 +98,20 @@ this the untrusted party can edit the thing that is supposed to constrain it.
|
||||
|
||||
This costs nothing and it is the single highest-value item in this document.
|
||||
|
||||
**Branch protection needs a second identity to protect anything.** By default
|
||||
a session authenticates as you, so a merge whitelist, a push whitelist and an
|
||||
approval requirement all admit the sandbox exactly as they admit you, and
|
||||
required approvals are unusable outright, since a forge will not let a pull
|
||||
request's author approve it and the session opens them as you. Giving the
|
||||
sandbox its own forge account is what turns those settings from decoration
|
||||
into enforcement, and it is configuration rather than a missing feature:
|
||||
[docs/security.md](security.md#to-your-forge-the-sandbox-is-you).
|
||||
|
||||
Note that it does not help with the rest of this document. A write-capable
|
||||
account still pushes feature branches, which is what makes CI run code, and
|
||||
the token the forge injects into a job is a third principal subject to none of
|
||||
those settings.
|
||||
|
||||
## The runner host
|
||||
|
||||
Treat it as what it is: a machine that exists to run untrusted code, because
|
||||
@@ -116,13 +130,13 @@ that is what it does on every pull request.
|
||||
an unrelated repository cannot schedule work onto a runner you hardened for
|
||||
this one, *and* this repository cannot schedule work onto a runner nobody
|
||||
hardened. An instance-level runner is reachable from every repository on the
|
||||
instance — see "A label is not a boundary" below. Gitea's own FAQ describes
|
||||
instance; see "A label is not a boundary" below. Gitea's own FAQ describes
|
||||
gitea.com doing this for its public runners.
|
||||
|
||||
Two runners on one VM give you label tiering, not host separation: a job that
|
||||
cannot get the Docker socket still shares a kernel and a network with a runner
|
||||
that can. If you only have one machine, that is a real reduction and worth
|
||||
doing — just do not read it as isolation between the tiers.
|
||||
doing; just do not read it as isolation between the tiers.
|
||||
|
||||
## Job containers
|
||||
|
||||
@@ -135,7 +149,7 @@ configuration says so:
|
||||
> but the docker host won't be mounted to the job containers and service
|
||||
> containers.
|
||||
|
||||
Access to that socket is root-equivalent on the runner host — `docker run
|
||||
Access to that socket is root-equivalent on the runner host: `docker run
|
||||
-v /:/host` ends the argument. Nothing in a job's log mentions the socket
|
||||
being there, so this is not something a passing look at a green run will
|
||||
surface. Check it directly, per runner:
|
||||
@@ -162,7 +176,7 @@ dejima-ci-hardener ships both configs, with the reasoning inline.
|
||||
**Prefer a new runner over reconfiguring an existing one.** A general-purpose
|
||||
runner often exists precisely to build images, and taking its socket away
|
||||
breaks it. Register a second runner with a label of its own, and name that
|
||||
label in the workflow — and if no runner carries the label yet, the job queues
|
||||
label in the workflow, and if no runner carries the label yet, the job queues
|
||||
rather than running somewhere unprepared, which is a better failure than a
|
||||
green run on the wrong host.
|
||||
|
||||
@@ -174,10 +188,10 @@ matters enough to state on its own.
|
||||
**`runs-on:` is in the workflow file, and the workflow file is in the branch.**
|
||||
A branch can name any label it likes. So a dedicated label keeps *honest* jobs
|
||||
on the runner you prepared; it does nothing against a branch that would rather
|
||||
run somewhere else. A session that changes `runs-on: dejima-ci` to
|
||||
run somewhere else. A session that changes `runs-on: dejima-confined` to
|
||||
`runs-on: ubuntu-latest`, pushes, and opens a pull request has stepped outside
|
||||
every control on this page — no allowlist, no socket restriction, no firewall
|
||||
— provided some runner answering `ubuntu-latest` will take the job.
|
||||
every control on this page (no allowlist, no socket restriction, no firewall),
|
||||
provided some runner answering `ubuntu-latest` will take the job.
|
||||
|
||||
Whether one will is a property of **where your runners are registered**, which
|
||||
is the actual boundary:
|
||||
@@ -188,7 +202,7 @@ is the actual boundary:
|
||||
| Organization | that organization's repositories |
|
||||
| Repository | that repository |
|
||||
|
||||
Gitea has no per-repository runner ACL — its FAQ says so directly, and points
|
||||
Gitea has no per-repository runner ACL; its FAQ says so directly, and points
|
||||
at registration scope as the control instead: gitea.com registers its runners
|
||||
for one organization "so our runners will not execute jobs from other
|
||||
repositories."
|
||||
@@ -207,23 +221,23 @@ Under `on: pull_request` the workflow is read from the head of the pull
|
||||
request, so the branch decides what runs and where. Under
|
||||
`on: pull_request_target` Gitea reads it from the **base branch**, and
|
||||
explicitly discards any `pull_request_target` workflow found on the head
|
||||
commit — a branch can neither edit the base copy nor introduce its own. What
|
||||
commit: a branch can neither edit the base copy nor introduce its own. What
|
||||
runs, and on which runner, is whatever `main` says.
|
||||
|
||||
**Adopting it takes two merges, not one.** The trigger only takes effect once
|
||||
it is on the base branch, because that is where it is read from — and a
|
||||
it is on the base branch, because that is where it is read from, and a
|
||||
`pull_request_target` workflow on a pull request's head is explicitly ignored.
|
||||
So the change that introduces it cannot be tested by it: with only
|
||||
`pull_request_target` on the branch and only `pull_request` on `main`, the
|
||||
head's copy is skipped, the base has nothing to contribute, and **no checks
|
||||
run at all** — which looks like a broken workflow rather than a bootstrap
|
||||
run at all**, which looks like a broken workflow rather than a bootstrap
|
||||
problem.
|
||||
|
||||
Carry both triggers through the first merge, then remove `pull_request` in a
|
||||
follow-up. The first change is tested by its own copy from the head, as
|
||||
today; the follow-up is tested by the copy now on `main`. In between, pull
|
||||
requests get both an untrusted run from the head and a trusted one from the
|
||||
base — noisy, and for the untrusted half no worse than the status quo, so
|
||||
base, noisy, and for the untrusted half no worse than the status quo, so
|
||||
keep the window short.
|
||||
|
||||
Two more consequences to handle rather than discover:
|
||||
@@ -244,7 +258,7 @@ This does not make the repository safe on its own. A branch can still add a
|
||||
workflow of its own with `on: pull_request`, which runs from the head with
|
||||
whatever `runs-on` it likes. What it buys is that the *intended* CI is
|
||||
trustworthy, and that escaping it means adding a file rather than editing
|
||||
one — a much louder diff.
|
||||
one: a much louder diff.
|
||||
|
||||
### Closing the rest: what a repository can reach
|
||||
|
||||
@@ -253,50 +267,44 @@ 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.
|
||||
- Register the *permissive* runner — the one with the Docker socket, or with
|
||||
unrestricted egress — at **repository** scope, for only the repositories
|
||||
- Register the *permissive* runner (the one with the Docker socket, or with
|
||||
unrestricted egress) at **repository** scope, for only the repositories
|
||||
that genuinely need it.
|
||||
|
||||
Most repositories need a runner that can run tests, not one that can build
|
||||
@@ -307,18 +315,18 @@ handful of registrations, and nothing moves or is renamed.
|
||||
`[core] workflow = local-only`. The session commits locally and stops; you
|
||||
review the diff and push it yourself. No branch, no workflow, no runner, and
|
||||
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
|
||||
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
|
||||
|
||||
The goal is that a job cannot reach somewhere new — cannot fetch a next stage,
|
||||
cannot post a repository to a paste service, cannot probe an internal address
|
||||
— without a human having agreed to that destination first.
|
||||
The goal is that a job cannot reach somewhere new (cannot fetch a next stage,
|
||||
cannot post a repository to a paste service, cannot probe an internal address)
|
||||
without a human having agreed to that destination first.
|
||||
|
||||
### Four network positions, only one of which is the job
|
||||
|
||||
@@ -334,7 +342,7 @@ protects nothing:
|
||||
|
||||
**Filtering only `DOCKER-USER` leaves the DNS allowlist decorative.** Traffic
|
||||
to a resolver on the bridge gateway is addressed to the host, so it is an
|
||||
`INPUT` packet and never passes through `DOCKER-USER` at all — and a job that
|
||||
`INPUT` packet and never passes through `DOCKER-USER` at all, and a job that
|
||||
can reach any resolver it likes can resolve anything it likes. `INPUT` needs
|
||||
its own pair of rules: accept port 53 from the bridge, drop the rest.
|
||||
|
||||
@@ -354,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
|
||||
@@ -362,7 +370,7 @@ layer out: allowlist the *names* and let the answers populate the set of
|
||||
allowed *addresses*.
|
||||
|
||||
`dnsmasq` binds the bridge gateway. With `no-resolv` and one `server=` line
|
||||
per allowed name, every other name gets `REFUSED` — not resolved-then-blocked,
|
||||
per allowed name, every other name gets `REFUSED`, not resolved-then-blocked,
|
||||
refused, which closes off encoding data into query names to a domain you never
|
||||
allowed. `ipset=/#/ci-allow` adds every answer it serves to the firewall's
|
||||
set, so a destination that rotates its edge addresses stays reachable without
|
||||
@@ -376,19 +384,20 @@ own and no route to it. Then two chains filter: `DOCKER-USER` for everything
|
||||
the job forwards off the bridge, and `INPUT` for the resolver itself, because
|
||||
the gateway address belongs to the host.
|
||||
|
||||
Block IPv6 outright unless you have built the same allowlist for it — an
|
||||
Block IPv6 outright unless you have built the same allowlist for it: an
|
||||
address family with no allowlist is not a smaller hole than the one you just
|
||||
closed.
|
||||
|
||||
**The working configuration for all of this is in [`ci/`](../ci/), and
|
||||
[`ci/README.md`](../ci/README.md) is the step-by-step.** It is deliberately not
|
||||
reproduced here: two copies of a firewall rule drift, and the one in `ci/` is
|
||||
the one with the ordering constraints and the substitution notes attached.
|
||||
**The working configuration for all of this is in
|
||||
[dejima-ci-hardener](https://gitbox.branderson.io/dejima/dejima-ci-hardener),
|
||||
with the ordering constraints and substitution notes attached.** It is
|
||||
deliberately not reproduced here: two copies of a firewall rule drift, and
|
||||
dejima-ci-hardener is the one that stays runnable.
|
||||
|
||||
## The allowlist comes from `main`, not from the checkout
|
||||
|
||||
A dejima project already carries a version-controlled statement of where it is
|
||||
allowed to reach: `.dejima/allowed-domains`. Use it — read from
|
||||
allowed to reach: `.dejima/allowed-domains`. Use it: read from
|
||||
**`origin/main`**, over the forge's API, by the runner host.
|
||||
|
||||
Never from the checkout. The checkout is the branch's, and a branch that can
|
||||
@@ -408,7 +417,7 @@ has to get that destination past you in a pull request first.
|
||||
|
||||
Refresh on a timer, render the `server=` lines, reload `dnsmasq`. Fail closed:
|
||||
a fetch that errors should leave the previous allowlist in place, never fall
|
||||
back to allowing everything — an allowlist builder is exactly the kind of
|
||||
back to allowing everything: an allowlist builder is exactly the kind of
|
||||
script whose failure mode is easy to get backwards, because the broken version
|
||||
looks like a working one right up until it matters.
|
||||
dejima-ci-hardener's `dejima-ci-allowlist` is that, including the distinction
|
||||
@@ -439,7 +448,7 @@ later you can set the repository's default to restricted mode (Repository →
|
||||
Settings → Actions → General): read-only on `code`, `releases` and
|
||||
`packages`, nothing else. The property that makes it worth having is that
|
||||
workflow-level `permissions:` is *clamped* by that maximum, so a branch
|
||||
raising its own cannot exceed it — it is one of the very few controls here
|
||||
raising its own cannot exceed it: it is one of the very few controls here
|
||||
that the branch genuinely cannot touch.
|
||||
|
||||
Before 1.27, `permissions:` is ignored and the token is permissive. There is
|
||||
@@ -449,7 +458,7 @@ upgrade.
|
||||
|
||||
**Secrets.** A secret exposed to a pull-request-triggered run is a secret
|
||||
exposed to whatever that branch contains. Give a run only what it needs, and
|
||||
prefer credentials that cannot do anything else — a token that raises an
|
||||
prefer credentials that cannot do anything else: a token that raises an
|
||||
anonymous API rate limit should be a fine-grained token with no permissions
|
||||
and no repository access, not a general-purpose one.
|
||||
|
||||
@@ -460,7 +469,7 @@ earning its place.
|
||||
|
||||
## CI you do not administer
|
||||
|
||||
On hosted CI — GitHub Actions, a shared pool, someone else's infrastructure —
|
||||
On hosted CI (GitHub Actions, a shared pool, someone else's infrastructure),
|
||||
**there is no reliable pre-execution gate for a same-repository branch**, and
|
||||
the egress section above is unavailable to you: every candidate gate lives in
|
||||
a file the branch can edit, and the network the job runs on is not yours to
|
||||
@@ -471,8 +480,8 @@ What still applies:
|
||||
|
||||
- **A hosted runner gives you the runner-host section for free.** It is
|
||||
disposable and it is already off your network. The dangerous case is a
|
||||
*self-hosted shared pool*, where a job can land on a machine nobody hardened
|
||||
— and where the socket-mounting default above is nobody's decision, just the
|
||||
*self-hosted shared pool*, where a job can land on a machine nobody hardened,
|
||||
and where the socket-mounting default above is nobody's decision, just the
|
||||
default.
|
||||
- **The credentials section applies everywhere.** `permissions: contents:
|
||||
read` where it is honoured, no secrets on pull-request runs, and no tunnel
|
||||
@@ -484,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.
|
||||
|
||||
@@ -501,7 +509,7 @@ can still add a workflow of its own.
|
||||
not from what it currently runs. Disabling the Actions unit is what makes
|
||||
"this project has no CI" into a property rather than a coincidence.
|
||||
- **An allowed destination is still an exfiltration channel** through its own
|
||||
request surface — a query string, a lookup, an issue body. Domain
|
||||
request surface: a query string, a lookup, an issue body. Domain
|
||||
allowlisting closes off reaching somewhere *new*; it cannot inspect what is
|
||||
sent somewhere already trusted. That is a structural property of
|
||||
allowlist-only filtering, and it is the same caveat
|
||||
|
||||
@@ -0,0 +1,524 @@
|
||||
# Configuration
|
||||
|
||||
[← dejima README](../README.md)
|
||||
|
||||
Day-to-day configuration once dejima is installed and running: what a
|
||||
project declares versus what you approve, config parity with your host agent
|
||||
harness setup, a different editor, writable host directories, shared
|
||||
scratch space, frontend testing, and what the sandbox knows about itself.
|
||||
|
||||
## Configuration reference
|
||||
|
||||
Every key dejima reads, its default, and what it is for. All of it lives in
|
||||
one file, `~/.config/dejima/config`. `./install.sh` writes a starter copy with
|
||||
every key present and commented out at its default, so nothing here has to be
|
||||
set to get a working sandbox: uncomment what you need. `dejima --print-config`
|
||||
prints the resolved schema with real values, which is the fastest way to see
|
||||
what a given launch will actually use.
|
||||
|
||||
| Key | Default | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `[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` |
|
||||
| `[core] firewall` | `on` | egress firewall; `off` disables it (debugging only), also settable per launch with `--no-firewall` |
|
||||
| `[core] share_dir` | `~/.local/share/dejima/shared` | host-visible read/write scratch dir, mounted at `~/shared` |
|
||||
| `[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 |
|
||||
| `[forge.gitea] token_file` | `~/.config/gitea/token` | path to the Gitea/Forgejo API token (the file, not the token itself) |
|
||||
| `[forge.github] push_token_file` | unset | path to a **write-capable** PAT, used only when `[core] forge = github`: deliberately separate from `[github] token_file` below; see "GitHub push" in docs/security.md |
|
||||
| `[github] token_file` | `~/.config/gh/sandbox-token` | path to a read-only PAT for private GitHub repos / `gh` |
|
||||
| `[mounts] readonly` | unset | extra host paths (colon-separated) a personal config needs, mounted read-only at their own path |
|
||||
| `[mounts] read_write` | unset | host paths (colon-separated) mounted **read-write** at their own path: for directories meant to outlive the session; refused if they overlap anything dejima needs out of a session's reach |
|
||||
| `[mounts] nvim_config` | unset | nvim config dir, used when `[core] command = nvim` |
|
||||
| `[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 |
|
||||
| `[audit] keep` | `10` | how many past findings/baselines to retain |
|
||||
| `[audit] strict` | `off` | refuse a headless (non-tty) launch over unacknowledged CRITICAL findings |
|
||||
| `[remote-access] enabled` | `off` | the switch: nothing in "Remote access" below runs until this is `on` |
|
||||
| `[remote-access] ntfy_url` | unset | ntfy topic URL for session notifications |
|
||||
| `[remote-access] base_url` | unset | public URL of the web front door, for notification links |
|
||||
| `[remote-access] ttyd_port` | `7681` | port `ttyd` listens on |
|
||||
| `[remote-access] ttyd_font_size` | unset | `ttyd` terminal font size |
|
||||
| `[remote-access] debounce` | `60` | seconds between repeat notifications for the same session |
|
||||
|
||||
**No forge is the default**: `[core] forge = none` with
|
||||
`[core] workflow = local-only`, so the sandbox works locally and pushes
|
||||
nowhere. Every other workflow needs forge credentials, which is what you are
|
||||
opting into when you pick one.
|
||||
|
||||
## Global and per-project domains and dependencies
|
||||
|
||||
Allowed domains and dependencies each stack through their own layers. The
|
||||
layers **add** to each other, so a narrower layer can only widen what a
|
||||
broader one already permits: nothing at the project layer can withdraw a
|
||||
built-in domain, and nothing at the container layer can withdraw a project
|
||||
one. Ordering them least specific first is how they are listed below and how
|
||||
they are applied, and it means the set a session gets is the union of every
|
||||
layer that applies to it, never an override of one by another.
|
||||
|
||||
| Config | Layers, least specific first |
|
||||
| --- | --- |
|
||||
| Allowed domains | built-in → global → project → container (one session) |
|
||||
| Dependencies | base image → global → project |
|
||||
|
||||
Dependencies have no container-only scope: installing one always needs an
|
||||
image rebuild, so there's no `dejima-allow`-style live-session patch
|
||||
for them.
|
||||
|
||||
A project can declare what it needs in two committed files:
|
||||
|
||||
```
|
||||
.dejima/allowed-domains # one domain per line
|
||||
.dejima/dependencies # <apt|npm|pip|cargo>:<name> per line, or a
|
||||
# tarball:<name>@<sha256>:<url> release archive
|
||||
```
|
||||
|
||||
### Tarball dependencies
|
||||
|
||||
For a toolchain no package ecosystem ships in a version you can use - a
|
||||
current Go, say, where Debian's is several releases behind - a dependency can
|
||||
name a release archive instead:
|
||||
|
||||
```sh
|
||||
dejima-dep add --global tarball:go@<sha256>:https://go.dev/dl/go1.25.0.linux-amd64.tar.gz
|
||||
```
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
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` - 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 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
|
||||
one archive, built for one architecture. There is no per-architecture
|
||||
tarball spec today, so a project that declares one is a project that builds
|
||||
on machines of that architecture and no others. Track that at
|
||||
[#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. 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
|
||||
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 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
|
||||
dejima-project approve ~/myproject # review and approve the declared set
|
||||
dejima-allow --project ~/myproject crates.io
|
||||
dejima-allow --global crates.io # applies to every project
|
||||
dejima-dep add --project ~/myproject cargo:cargo-nextest
|
||||
dejima-dep add --global cargo:cargo-nextest
|
||||
```
|
||||
|
||||
Two of those tools also write the declaration, so an approval you make by
|
||||
hand does not stay invisible to everyone else on the project:
|
||||
`dejima-allow --project` appends to `.dejima/allowed-domains`, and
|
||||
`dejima-dep add --project` appends to `.dejima/dependencies`. Both write the
|
||||
approval first - that is the part that grants - and report separately on the
|
||||
declaration, so a project you can't write to still gets you the approval.
|
||||
`--global` writes no declaration at all: there is no project to declare in.
|
||||
|
||||
The declaration those two write still grants nothing; it is the same
|
||||
untrusted file the section above describes, and writing to it does not
|
||||
promote it. That it is session-writable is exactly why both tools refuse to
|
||||
write a `.dejima` directory or a `.dejima/<kind>` file that is a **symlink**.
|
||||
The refusal is the `open` itself rather than a check before it, so it holds
|
||||
even against a session that is still running while you type the command. See
|
||||
[Declaration files are session-writable](security.md#declaration-files-are-session-writable)
|
||||
for what that defends against and how. The same write
|
||||
reads the file back to skip an already-declared entry, and that read is
|
||||
capped - a session can make its own `.dejima/<kind>` arbitrarily large for
|
||||
free, and an oversized one is refused rather than loaded into your memory.
|
||||
|
||||
Neither writer has an inverse for the declaration. `dejima-dep remove
|
||||
--project` withdraws the approval and deliberately leaves the declaration
|
||||
alone (it says so, since `dejima-project approve` will otherwise offer the
|
||||
entry again); `dejima-allow --project` has no `remove` at all. Taking
|
||||
something back from the project itself means editing `.dejima/` and
|
||||
committing that.
|
||||
|
||||
`dejima-project status`/`approve` classify a declared entry against
|
||||
the union of global and project approvals, so something already approved
|
||||
globally shows as approved, not pending. Unlike `dejima-allow`,
|
||||
`dejima-dep`'s `--global` and `--project` are mutually exclusive -
|
||||
an entry belongs to exactly one scope. `dejima-dep` has no `--container`
|
||||
scope either, and refuses the flag rather than ignoring it: a dependency
|
||||
needs an image rebuild, so there is nothing to patch in a live session.
|
||||
|
||||
Approved domains join the allowlist at the next launch. `dejima-allow` can
|
||||
also patch a running session immediately, but only when it is run bare or
|
||||
with `--container` - approving here (`--project`/`--global`, with no
|
||||
`--container`) never touches one; see
|
||||
[Adding a domain to a running session](security.md#adding-a-domain-to-a-running-session).
|
||||
Approved dependencies are baked into a derived image built `FROM` the shared
|
||||
base - first `dejima:<username>-global`, if the global store has any
|
||||
dependency approved, then `dejima:<username>-<slug>` on top of that,
|
||||
if the project store does - so they need an image rebuild: they take effect
|
||||
on the **next** launch.
|
||||
|
||||
Because of that rebuild, the usual moment to approve is *after* leaving the
|
||||
sandbox - so persistence (`--global` or `--project`) never requires a running
|
||||
container.
|
||||
|
||||
`dejima-project approve` never attempts to patch a running session: it only
|
||||
ever calls `dejima-allow` without `--container`, so recording the approval
|
||||
is the only thing it does. If an individual approval can't be recorded - an
|
||||
unwritable store, a slug collision, a corrupt approval list - it reports
|
||||
that entry as not approved and keeps going, rather than stopping at the
|
||||
first failure; that holds for dependencies and domains alike, so one bad
|
||||
entry can't cost you the rest of the set. It then exits non-zero if anything
|
||||
failed, so `dejima-project approve ~/myproject && dejima` won't launch a
|
||||
session missing the approvals you just confirmed.
|
||||
|
||||
A session whose project declares something it doesn't have is told so in its
|
||||
startup context, with the exact command to hand you. Project approvals are
|
||||
inherited by subdirectories, so a git worktree under the project gets its
|
||||
parent's; global approvals apply everywhere regardless.
|
||||
|
||||
## Config parity with your host agent harness setup
|
||||
|
||||
What carries over from your host into the container, and what deliberately
|
||||
does not. The mechanism is the same whichever harness `[core] harness`
|
||||
selects; the specific paths below belong to the one harness shipped today.
|
||||
|
||||
### Claude Code
|
||||
|
||||
Global `CLAUDE.md`, settings, hooks, skills, and plugins all carry over
|
||||
automatically, and your existing login stays effective too - no separate
|
||||
first-run login, though the credential itself never enters the container
|
||||
(see [security notes](security.md) for the host-side proxy that makes
|
||||
that work).
|
||||
|
||||
`~/.claude` itself is *not* shared as a whole: its contents are enumerated
|
||||
in `docker-compose.yml`, config files are copied in rather than mounted,
|
||||
and anything not listed is container-local - writable during the session,
|
||||
gone with the container. Session transcripts, plans, and the edit-undo
|
||||
record are on the list, so `claude --resume` on the host still works; a
|
||||
credential, cache, or newly-added config file is not. The
|
||||
[security notes](security.md) explain the split and why single-file
|
||||
mounts couldn't be trusted here. (`~/.claude.json` is handled differently
|
||||
again - a throwaway per-session copy, not the real file.)
|
||||
|
||||
The project directory is mounted at the *same absolute path* inside the
|
||||
container as on the host, so Claude Code's session-history keying lines up
|
||||
between host and container: a session's transcript is visible on the host
|
||||
afterward, and `claude --resume <session-id>` should find it there. (Whether
|
||||
a bare `claude --resume` with no ID picks it as the "most recent" session
|
||||
depends on `~/.claude.json`'s own bookkeeping, which - per the throwaway-copy
|
||||
design above - doesn't carry over from a sandboxed session.)
|
||||
|
||||
The container's own user matches yours exactly - username, uid, gid, and
|
||||
home directory (see `Dockerfile`) - so `$HOME`-relative paths (dotfiles,
|
||||
Claude Code's plugin bookkeeping) resolve the same on both sides. Defaults to
|
||||
`node`/1000/1000/`/home/node` if you build the image directly instead of via
|
||||
`dejima`. Images are tagged per-user (`dejima:<username>`)
|
||||
so concurrent builds on a shared machine don't clobber each other.
|
||||
|
||||
Your global `~/.gitconfig` also carries over (as a throwaway copy, so commits
|
||||
made inside the sandbox get the right identity, but nothing written to it
|
||||
during a session reaches your real file). Falls back to an empty config if
|
||||
you don't have one.
|
||||
|
||||
Caveat: MCP servers or hooks that shell out to host-specific binaries (a
|
||||
browser, a language runtime not installed here) will not work until that
|
||||
tooling is installed in the image. Add it with `dejima-dep` rather than by
|
||||
editing the base `Dockerfile`: a dependency added that way is declared,
|
||||
approved and layered like any other, and it survives a rebuild. Editing the
|
||||
Dockerfile directly puts it outside that record.
|
||||
|
||||
## Headless git push without agent forwarding
|
||||
|
||||
**The reason to do this is identity, not uptime.** A forwarded agent
|
||||
authenticates the session **as you**: every commit and every pull request it
|
||||
produces is yours, and for as long as it runs it can reach whatever that key
|
||||
can reach. A key of its own, belonging to an account that is not you, gives
|
||||
the session an identity you can scope, revoke and read in a log separately
|
||||
from your own. See
|
||||
[To your forge, the sandbox is you](security.md#to-your-forge-the-sandbox-is-you)
|
||||
for why that matters more than it first appears.
|
||||
|
||||
Running unattended is the second reason and the lesser one. Agent forwarding
|
||||
ties SSH access to whichever client is currently connected, so it dies when
|
||||
that client disconnects and takes git-over-ssh with it for anything still
|
||||
running headless.
|
||||
|
||||
Either way, generate a dedicated, purpose-built keypair:
|
||||
|
||||
```sh
|
||||
ssh-keygen -t ed25519 -N '' -f ~/.ssh/dejima_ed25519
|
||||
```
|
||||
|
||||
Passphrase-less is deliberate - this is meant to be a low-privilege
|
||||
credential living only inside the container, not your personal identity. Add
|
||||
the public half as a Gitea deploy key scoped to just the repos that need
|
||||
push access, then set it in the config:
|
||||
|
||||
```
|
||||
[core]
|
||||
ssh_key = ~/.ssh/dejima_ed25519
|
||||
```
|
||||
|
||||
`entrypoint.sh` loads it into a container-local `ssh-agent` at startup and
|
||||
deletes the plaintext copy immediately after - the sandbox user itself never
|
||||
has a path to the raw key, only the agent socket (see
|
||||
[security notes](security.md) for the full mount rationale). Setting
|
||||
`[core] ssh_key` means a live forwarded agent is no longer required at all.
|
||||
|
||||
## Using a different editor/application
|
||||
|
||||
```sh
|
||||
dejima --cmd nvim ~/repos/some-project
|
||||
```
|
||||
|
||||
Runs unconfigured unless you also set `[mounts] nvim_config` in the config to
|
||||
a real config directory on the host (bind-mounted read-only at
|
||||
`~/.config/nvim` - lazy.nvim's lockfile is the one exception, layered
|
||||
read-write on top so plugin syncs don't fail on a read-only filesystem;
|
||||
nothing else in the config is writable):
|
||||
|
||||
```
|
||||
[core]
|
||||
command = nvim
|
||||
|
||||
[mounts]
|
||||
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):
|
||||
|
||||
```
|
||||
[mounts]
|
||||
readonly = ~/dotfiles/bin
|
||||
```
|
||||
|
||||
## Writable host directories
|
||||
|
||||
`[mounts] read_write` is the same thing without `:ro` - for a directory a
|
||||
session is meant to write to and whose contents should outlive the container:
|
||||
|
||||
```
|
||||
[mounts]
|
||||
read_write = ~/specs
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
An entry is **refused, not warned about**, if it overlaps anything dejima
|
||||
relies on being out of a session's reach: the approval store, either audit
|
||||
store, `~/.claude`, `~/.local/bin`, dejima's own checkout, or the project mount
|
||||
(already read-write, so listing it again is a mistake worth surfacing). A
|
||||
writable mount over any of those would let a session approve its own domains,
|
||||
delete the audit findings naming it, or plant code that runs unsandboxed on the
|
||||
host next time you start an agent. Overlap is checked in both directions after
|
||||
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
|
||||
`~/.local/share/dejima/shared`, created automatically) - separate
|
||||
from the project mount, for passing files (screenshots, temp scripts) in and
|
||||
out of a session without touching the actual checkout. Persists across
|
||||
sessions since it's a real directory, not a container volume.
|
||||
|
||||
Treat it as a handoff point, not as general-purpose storage. If your own
|
||||
project instructions tell agents to write somewhere at a fixed host path -
|
||||
design docs, scratch notes, anything meant to outlive the session but stay out
|
||||
of the project checkout - mount that directory instead, with
|
||||
`[mounts] read_write`. It arrives at its own absolute path, so a convention
|
||||
that says "put specs in `~/specs`" keeps working inside the container without
|
||||
being rewritten to point at `~/shared`.
|
||||
|
||||
`~/shared`, the project mount, and anything in `[mounts] read_write` are the
|
||||
only writable host paths in the container. Everything else a session writes
|
||||
goes away with it.
|
||||
|
||||
**Clear it out periodically.** It persists across sessions by design, which
|
||||
means anything you left in it is readable by every session after the one you
|
||||
left it for. A credential dropped in there to get one job done, and then
|
||||
forgotten, is a credential you have handed to every session since.
|
||||
|
||||
## Testing frontend changes
|
||||
|
||||
Playwright (Chromium) is pre-installed at image build time, browser
|
||||
included - `require("playwright")` works from any script, no network access
|
||||
or per-project `npm install playwright` needed.
|
||||
|
||||
`localhost`/loopback traffic never goes through the egress firewall, so a
|
||||
project's own dev server needs no firewall changes. A real external site
|
||||
needs to be on the allowlist like any other network use
|
||||
(`dejima-allow <domain>` for a running session, add
|
||||
`--global`/`--project <path>` to persist it) - the firewall applies to the
|
||||
browser's traffic exactly like `curl`/`git`.
|
||||
|
||||
If a project pins its own `playwright`/`@playwright/test` version, its
|
||||
`npm install` may try to download a different Chromium build from
|
||||
Playwright's CDN, which isn't allowlisted by default - allow it the same way
|
||||
as any other one-off domain need.
|
||||
|
||||
Chromium's own process sandbox isn't actually active in this image (its
|
||||
setuid helper isn't setuid here) - not a concern, since the container itself
|
||||
is already the boundary that matters, and a compromised session already has
|
||||
full shell access as the container's own user regardless.
|
||||
|
||||
## Sandbox self-awareness
|
||||
|
||||
### Project instructions: `.dejima.md`
|
||||
|
||||
`.dejima.md` at a project's root is that project's agent-instruction file -
|
||||
the same role `CLAUDE.md` plays for Claude Code - under a name that doesn't
|
||||
name a harness. dejima loads it into the session's opening context the way the
|
||||
harness would load its own, so a project's instructions apply whichever agent
|
||||
is running.
|
||||
|
||||
The reason for a second name: Claude Code finds a `CLAUDE.md` by itself, and
|
||||
nothing else does. As dejima grows the ability to run agents that aren't Claude
|
||||
Code, a project that wants to say "here is how to work on me" needs one file
|
||||
that reaches all of them - so dejima delivers it rather than leaving each
|
||||
harness to go looking for its own.
|
||||
|
||||
Loaded verbatim, capped at 16 KB with any truncation noted in the text. It
|
||||
carries no authority over the sandbox itself: the file sits on the read-write
|
||||
workspace mount, so nothing in it widens the firewall, relaxes a guard, or
|
||||
authorises a push, and the text delivered around it says so.
|
||||
|
||||
This repo ships one, describing which half of its own test suite a sandboxed
|
||||
session can run.
|
||||
|
||||
### The session briefing
|
||||
|
||||
A `SessionStart` hook (`~/.claude/hooks/sandbox-context.sh`) injects a
|
||||
reminder when `DEJIMA` is set: the session is running in a
|
||||
disposable, firewalled container and should stay within the task at hand -
|
||||
if something genuinely needs to break out (broader network, host changes,
|
||||
GUI/hardware), stop and ask rather than trying to work around the firewall,
|
||||
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
|
||||
against an overeager agent, not a hard security boundary - a session has full
|
||||
shell access and could in principle edit `.git/config` to route around it.
|
||||
GitHub push is blocked by default too (see
|
||||
[GitHub push](security.md#github-push-blocked-by-default-optional-opt-in) in
|
||||
docs/security.md) unless `--allow-github-push` is given. For a real guarantee
|
||||
against pushes to main/master, use branch protection on the remote itself.
|
||||
|
||||
`merge-guard.sh` (`PreToolUse`/`Bash`, `DEJIMA`-gated) blocks
|
||||
`gitea-pr merge` inside the sandbox. It's the API-side counterpart to the rule
|
||||
above: a merge puts a branch on `main` without ever performing a push, so
|
||||
`git-push-guard.sh` never sees it. A hook is handed a command rather than a
|
||||
conversation, so it can't tell an instructed merge from an unprompted one -
|
||||
which is why it's scoped to the sandbox rather than applied everywhere. On the
|
||||
host it does nothing, and you can still ask an agent to merge. The same
|
||||
fast-fail caveat applies with more force than usual here: the session has the
|
||||
Gitea token in its environment and could call the merge endpoint with `curl`
|
||||
directly. Branch protection on the remote is the real guarantee.
|
||||
|
||||
`sandbox-context.sh` also tells the agent the actual workflow to follow: do
|
||||
new work in a `git worktree` under the mounted project (e.g.
|
||||
`.worktrees/<branch>`, never `../<branch>` or elsewhere under `$HOME` - only
|
||||
the bind-mounted path is backed by real host storage, so a worktree created
|
||||
outside it silently vanishes with the container), then push the feature
|
||||
branch and run `gitea-pr create`.
|
||||
@@ -0,0 +1,357 @@
|
||||
# Development
|
||||
|
||||
[← dejima README](../README.md)
|
||||
|
||||
The file-by-file map of this repository, and the detail behind its test
|
||||
suites: what each one covers, whether it needs a Docker daemon, and how to
|
||||
run them.
|
||||
|
||||
## Files
|
||||
|
||||
| File | Purpose |
|
||||
| --- | --- |
|
||||
| `docker/Dockerfile` | Base image, dev tools, nvim, Claude Code + wrapper, `gitea-pr`/`gitea-issue` |
|
||||
| `docker/fixed-domains.sh` | Shared domain list sourced by both `init-firewall.sh` and `dns-proxy.sh` |
|
||||
| `docker/dns-proxy.sh` | Starts the DNS-allowlisting `dnsmasq`, repoints `resolv.conf` at it |
|
||||
| `docker/init-firewall.sh` | IPv4/IPv6 egress allowlist firewall, run at container start |
|
||||
| `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/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 |
|
||||
| `hooks/git-push-guard.sh` | `PreToolUse` hook: blocks pushes outside the allowed host(s)/branches |
|
||||
| `hooks/deny-dangerous-bash.sh` | `PreToolUse` hook: blocks obviously destructive Bash commands |
|
||||
| `hooks/merge-guard.sh` | `PreToolUse` hook: blocks the configured forge's merge command (`DEJIMA_MERGE_BLOCK`) in the sandbox: merging is a human decision |
|
||||
| `hooks/dejima-notify.sh` | `Notification`/`Stop` hook: drops an empty `waiting`/`done` marker for a remote-enabled session - see [docs/remote-access.md](remote-access.md) |
|
||||
| `bin/dejima-attach` | Lists live sessions and attaches to one; the shared core behind both remote front doors |
|
||||
| `bin/dejima-audit` | Advisory exit-time diff of host-persistence surfaces (git config, `.claude/`, etc.). `dejima-audit show` / `dejima-audit ack` default to the enclosing repo |
|
||||
| `bin/dejima-dep` | Review and approve project dependencies declared in `.dejima/dependencies` |
|
||||
| `bin/dejima-provider-lib.sh` | Parses forge/workflow/harness manifests and composes the session briefing; not on PATH: see [docs/providers.md](providers.md) |
|
||||
| `forges/`, `workflows/`, `harnesses/` | Provider directories for the three axes selected by `[core] forge`/`workflow`/`harness` (data, never sourced or executed); see [docs/providers.md](providers.md) |
|
||||
| `forges/gitea/bin/` | The gitea forge's own CLIs, baked into the image so the sandbox can act on them: `gitea-pr` (create a PR, read its comments, merge it), `gitea-issue` (list, view, create, comment on, close and reopen issues) and `gitea-actions` (read CI runs and job logs) |
|
||||
| `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 |
|
||||
| `docs/ci.md` | What push access grants a session on your CI host, and how to bound it - runner hardening, and an egress allowlist read from `main` |
|
||||
| `docs/nginx-dejima.conf.example` | Reverse-proxy template for the web front door; deliberately not loadable as written |
|
||||
|
||||
`bin/dejima` is the entry point: it resolves the project path,
|
||||
checks for a live `ssh-agent`, reads `GITEA_TOKEN`/`GH_TOKEN` from the token
|
||||
files named in the config, discovers which host directories need mounting for symlinks under
|
||||
`~/.claude` (`hooks`, `skills`, `commands`, `agents`, `output-styles`, and
|
||||
top-level entries like `statusline.sh`) to resolve, and runs
|
||||
`docker compose run`. `bin/dejima-allow`
|
||||
adds a domain to a running session's firewall from the host - see
|
||||
[Adding a domain to a running session](security.md#adding-a-domain-to-a-running-session).
|
||||
`install.sh` installs the hooks into
|
||||
`~/.claude/hooks` and registers them in `~/.claude/settings.json`.
|
||||
|
||||
## Tests
|
||||
|
||||
`test/` has a [bats](https://github.com/bats-core/bats-core) suite covering
|
||||
the security invariants the README states, not a substitute for reading it,
|
||||
but a way to actually catch a future change silently breaking one of them
|
||||
instead of finding out the hard way.
|
||||
|
||||
`static.bats` greps the repo's own files - no sudo in the `Dockerfile`, every
|
||||
`ipset add` is duplicate-safe, no `docker.sock`/host-credential mounts, the
|
||||
`~/.claude` mount inventory is exactly what it should be, `dejima-allow`
|
||||
rejects a malicious domain argument. `runtime.bats` builds the image, boots
|
||||
a real container running the actual `entrypoint.sh` (a `SANDBOX_TEST_MODE`
|
||||
env var makes it stop just short of execing the session's command, so the suite can attach
|
||||
to a fully-initialized container instead of reimplementing its own copy of
|
||||
the setup sequence), and asserts against it: IPv6 blocked, DNS/IP allowlist
|
||||
enforcement in both directions, GitHub read-only, no working `sudo`/
|
||||
`iptables`/`init-firewall.sh` as `node`, capabilities actually stripped
|
||||
(not just absent from a `docker exec` that doesn't have them to begin
|
||||
with), `ulogd2` unprivileged, and the dynamic-ipset CDN-rotation fix.
|
||||
|
||||
`project.bats`, `remote.bats` and `audit.bats` need no Docker either.
|
||||
`project.bats` covers the
|
||||
declaration/approval split - what `.dejima/` may ask for, what only the host
|
||||
can grant, and that the approval store is never mounted. `remote.bats`
|
||||
covers everything in [docs/remote-access.md](remote-access.md):
|
||||
the `[remote-access]` config section, the session registry, and the properties the
|
||||
feature's security rests on - that the
|
||||
notification path carries no sandbox-controlled byte, and that no marker
|
||||
name outside `waiting`/`done` is ever acted on. `audit.bats` covers
|
||||
`bin/dejima-audit`'s own diffing.
|
||||
|
||||
`config.bats`, `providers.bats` and `proxy.bats` round out the suites that
|
||||
need no Docker either: `config.bats` covers `~/.config/dejima/config`
|
||||
parsing and `install.sh`'s legacy-variable warnings (stubbing `docker` itself,
|
||||
since the fast CI job's container deliberately has no daemon), `providers.bats`
|
||||
covers the forge/workflow/harness manifest contract documented in
|
||||
[docs/providers.md](providers.md), and `proxy.bats` covers the credential
|
||||
proxy's own request handling.
|
||||
|
||||
One assertion is worth calling out because it can only run for real on a
|
||||
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
|
||||
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
|
||||
inside the test container's own network namespace, same as any other
|
||||
container - none of it touches the host. Container/network names are
|
||||
derived from bats' own per-run temp directory, not anything guessable or
|
||||
likely to collide with a real container you have running.
|
||||
|
||||
`ci-runner.bats` is different in kind from the others: it doesn't test
|
||||
this repository at all, it asserts on the CI runner executing it (the Docker
|
||||
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
|
||||
bats test/ # everything
|
||||
bats test/audit.bats # one suite - the suites are independent, see .dejima.md
|
||||
SKIP_BUILD=1 bats test/runtime.bats # the one suite needing Docker; reuse the current image
|
||||
```
|
||||
|
||||
`runtime.bats` is the only suite that needs a Docker daemon: it is the one
|
||||
that builds the image and boots real containers to assert against.
|
||||
Everything else, `audit.bats`, `ci-runner.bats`, `config.bats`,
|
||||
`project.bats`, `providers.bats`, `proxy.bats`, `remote.bats` and
|
||||
`static.bats`, runs anywhere a shell does, including inside a dejima sandbox
|
||||
itself, which has no Docker daemon by design.
|
||||
|
||||
`.gitea/workflows/tests.yml` runs all of it on every pull request and on every
|
||||
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
|
||||
|
||||
+34
-40
@@ -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
|
||||
@@ -69,7 +69,7 @@ approvals):
|
||||
scheme; a value without one is refused rather than guessed at, because
|
||||
`curl` reads a leading `-K/path` as an *option* that takes the request's URL
|
||||
from that file instead. Values are restricted to
|
||||
`A-Za-z0-9:/._~?=%@+-` — no whitespace and nothing shell-active, since these
|
||||
`A-Za-z0-9:/._~?=%@+-`: no whitespace and nothing shell-active, since these
|
||||
end up in an HTTP header and in a URL handed to a phone.
|
||||
|
||||
**A malformed config is a hard failure, not a warning.** The whole file is
|
||||
@@ -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
|
||||
|
||||
@@ -442,7 +442,7 @@ and forgotten. What matters in it:
|
||||
the one you have - just make sure it doesn't fall through to this vhost.
|
||||
- **TLS.** HTTP Basic sends the password on every request, and the terminal
|
||||
itself carries whatever the session is working on.
|
||||
- **`proxy_set_header Host $http_host;`** — `$http_host`, not `$host`.
|
||||
- **`proxy_set_header Host $http_host;`**: `$http_host`, not `$host`.
|
||||
`--check-origin` compares the client's `Origin` against the `Host` header,
|
||||
and nginx's `$host` has the port stripped while `ttyd` keeps the port in
|
||||
what it compares unless it is 80 or 443. On a vhost listening anywhere
|
||||
@@ -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"
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
# Using this responsibly
|
||||
|
||||
[← dejima README](../README.md)
|
||||
|
||||
The README states each of these ten rules in a sentence or two. This is the
|
||||
part that didn't fit: why each one matters, what goes wrong without it, and
|
||||
the detail behind the three the README could only gesture at.
|
||||
|
||||
## Check whether they want it first.
|
||||
|
||||
"They didn't ask for this" is a consent problem, not a quality problem. A
|
||||
project that has said it doesn't accept AI-assisted contributions has said so
|
||||
for reasons that have nothing to do with whether a given patch is good: review
|
||||
load, licensing questions, a maintainer who simply doesn't want to referee
|
||||
that argument on every pull request. A well-written, well-tested change
|
||||
doesn't override a stated no, any more than good manners override a locked
|
||||
door. Read CONTRIBUTING (or the equivalent policy file, or the pinned issue,
|
||||
wherever a project keeps it) before you start work, because finding out after
|
||||
means the work is already done and now has nowhere to go.
|
||||
|
||||
## Don't point it at repositories you don't own.
|
||||
|
||||
The concrete workflow, step by step:
|
||||
|
||||
1. Fork the project on its forge.
|
||||
2. Point `bin/dejima` at your fork: run it from a local clone whose `origin`
|
||||
is your fork, not the upstream repository.
|
||||
3. Let the session do its work there. With a pushing workflow configured, it
|
||||
opens branches, and pull requests, against your fork, not upstream.
|
||||
4. Review the agent's branch yourself, the same way you'd review anyone
|
||||
else's diff before merging it.
|
||||
5. Merge it to your fork's own `main`.
|
||||
6. Open the upstream pull request by hand from there, once you would have
|
||||
been glad to have written it yourself.
|
||||
|
||||
The reason for the extra hop is what happens without it. An auto-push
|
||||
workflow aimed straight at someone else's repository puts a session's raw
|
||||
output directly into that project's pull request queue, unreviewed by
|
||||
anyone, the moment it opens. Maintainers already spend real time triaging a
|
||||
flood of low-effort AI-generated pull requests; a Godot maintainer's account
|
||||
of what that actually costs is worth reading in full:
|
||||
<https://bsky.app/profile/akien.bsky.social/post/3meyerixvhs2p>. Forking and
|
||||
reviewing first doesn't change what the session is capable of writing. It
|
||||
changes who reads the output before anyone else has to: you, not a stranger
|
||||
who owns the project.
|
||||
|
||||
## Don't point it at proprietary code.
|
||||
|
||||
dejima narrows the paths code has to leave through; it doesn't close them,
|
||||
and "point it only at code you could survive losing" is the practical test
|
||||
for that gap. Ask it plainly before you start a session, not after: if this
|
||||
checkout's contents ended up somewhere they shouldn't, would that be a bad
|
||||
week or the end of the business? Code you could survive losing may be worth
|
||||
the convenience of unattended agentic work. Code you couldn't shouldn't go in
|
||||
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. 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.
|
||||
|
||||
The bind mount doesn't know which files you meant to hand over. It exposes
|
||||
the whole checkout, and everything a working tree accumulates over its life
|
||||
sits there with the same reachability as the source files you actually care
|
||||
about: a `.env` a teammate left uncommitted, a database dump pulled down for
|
||||
local debugging and never deleted, a credential baked into a fixture file,
|
||||
an old key still sitting in the history of a branch that got checked out.
|
||||
None of that is code in any sense that matters to the agent; all of it is
|
||||
just bytes in a directory the session can read and any tool call it makes
|
||||
can touch.
|
||||
|
||||
Auditing a working tree by eye is not a minute's work, and it is the kind of
|
||||
check that succeeds every time until the once it matters. **Point dejima at a
|
||||
fresh clone instead.** A clone contains what is in version control and
|
||||
nothing else, so the whole class goes away rather than being searched for. If
|
||||
a directory might have ignored files in it worth protecting, that is the
|
||||
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. 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
|
||||
misleading page, produces bad input to more code, code that still has to
|
||||
pass through your own review before any of it ships. The mistake is
|
||||
contained by the same diff you were always going to read.
|
||||
|
||||
A deploy webhook, a production database API, and a payment provider are a
|
||||
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 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
|
||||
someone?
|
||||
|
||||
## Safe and responsible are not the same thing.
|
||||
|
||||
"Safe" is a property of the sandbox: what a session inside it can reach.
|
||||
"Responsible" is a property of what you let out of it. A session can be
|
||||
contained perfectly and still produce something that should not be merged,
|
||||
and the sandbox has no opinion either way.
|
||||
|
||||
This point is about the merge, not about code quality in general. What makes
|
||||
`main` (or `master`, or whatever your default branch is called) different
|
||||
from any other branch is that other things read it and believe it. It is
|
||||
worth being precise about why, rather than saying "the released version" in
|
||||
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 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.
|
||||
|
||||
The reason to separate identities is that forge-level controls key on who
|
||||
authenticated, not on what changed. By default the sandbox authenticates as
|
||||
you, so every control your forge offers that answers "who did this" (a merge
|
||||
whitelist, a push whitelist, required approvals) admits the session exactly
|
||||
as it admits you; a required approval becomes unusable outright, since a
|
||||
forge won't let a pull request's own author approve it, and the session
|
||||
opens pull requests as you. Giving the sandbox a dedicated forge account
|
||||
(see [docs/security.md](security.md#to-your-forge-the-sandbox-is-you) for
|
||||
how) is what turns those settings from decoration back into something that
|
||||
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 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.
|
||||
|
||||
The risk here is habituation, not a single lapse. dejima makes iterating
|
||||
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 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.
|
||||
|
||||
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. 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.
|
||||
|
||||
These are different axes, not different degrees of the same thing. dejima's
|
||||
threat model is about the session: what a process running inside the
|
||||
sandbox can reach, exfiltrate, or execute while it runs. None of that has
|
||||
anything to say about the software that session writes once it's on disk.
|
||||
dejima can contain a session perfectly and that session can still write a
|
||||
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, containment was never the
|
||||
control that was supposed to catch it.
|
||||
|
||||
**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
|
||||
check. It cannot move a boundary you built a system on top of. And insecure
|
||||
implementation decisions sit in software for years without announcing
|
||||
themselves, which is exactly why they are not found by a pass that goes
|
||||
looking once.
|
||||
|
||||
So security has to be considered at every point in the work, not scheduled
|
||||
as a phase, and dejima will not help you do that. It makes writing a lot of
|
||||
code fast, which makes writing a lot of insecure code fast if nobody is
|
||||
holding that concern. That responsibility is yours at every step, and it is
|
||||
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 does not stand in for any of them.
|
||||
+676
-177
@@ -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
|
||||
@@ -19,7 +25,7 @@ whatever host that CI runs on.** No operator action is needed anywhere in the
|
||||
chain: the push guard permits a feature branch, the briefing tells the agent
|
||||
to open a pull request without asking, `on: pull_request` fires, and the
|
||||
runner checks out that commit and executes it. The you-merge-not-the-agent
|
||||
gate sits strictly downstream — by the time anyone reads the diff, it has
|
||||
gate sits strictly downstream: by the time anyone reads the diff, it has
|
||||
already run.
|
||||
|
||||
Nor does the code have to be a modified workflow file. A workflow that runs a
|
||||
@@ -34,19 +40,110 @@ and **whatever credentials the run is handed**, including the token the forge
|
||||
injects into every job whether the workflow asks for one or not.
|
||||
|
||||
There is deliberately no approval gate offered for this. Every version of one
|
||||
— changing the trigger, dispatching by hand, leaving the runner stopped, the
|
||||
forge's own fork-PR approvals, filtering egress from inside the workflow — is
|
||||
(changing the trigger, dispatching by hand, leaving the runner stopped, the
|
||||
forge's own fork-PR approvals, filtering egress from inside the workflow) is
|
||||
reachable from the branch, covered by a credential the session already holds,
|
||||
or not a gate at all. The mitigations are environmental instead: a runner host
|
||||
you are willing to lose, job containers with no Docker socket, deny-by-default
|
||||
egress whose allowlist is read from `main` rather than from the branch, and no
|
||||
secret a run does not need. And where none of that is available, declining the
|
||||
push grant declines the code execution with it — `[core] forge = none` with
|
||||
push grant declines the code execution with it: `[core] forge = none` with
|
||||
`[core] workflow = local-only`.
|
||||
|
||||
This applies to any project developed in a dejima sandbox, not only to this
|
||||
repository. **→ [docs/ci.md](ci.md)** for the reasoning and the setup.
|
||||
|
||||
## To your forge, the sandbox is you
|
||||
|
||||
By default a session authenticates with your credentials: your SSH key through
|
||||
the forwarded agent, and a token generated under your account. A push, a pull
|
||||
request, a comment or a merge made from inside the sandbox is therefore
|
||||
**indistinguishable at the forge from one you made yourself**, not similar to
|
||||
it, identical.
|
||||
|
||||
The consequence is worth stating flatly, because it is easy to configure
|
||||
around without noticing: **every control your forge offers that keys on
|
||||
identity is a guard rather than a boundary.** Branch protection's merge
|
||||
whitelist, its push whitelist, required approvals, and the audit log all
|
||||
answer the question "who did this", and there is only one answer available.
|
||||
Putting yourself on a merge whitelist puts the sandbox on it too.
|
||||
|
||||
Three specific consequences:
|
||||
|
||||
- **Required approvals are not merely weak here, they are unusable.** A forge
|
||||
will not let a pull request's author approve it, and the sandbox opens pull
|
||||
requests as you, so requiring one approval means nobody can give it.
|
||||
- **The forge's audit trail cannot attribute.** "You pushed this" and "a
|
||||
session pushed this" are the same recorded event. This is the same shape as
|
||||
the exit-time audit's time-window caveat above: a record of what happened,
|
||||
not of who caused it.
|
||||
- **It compounds the SSH agent's reach.** The agent already signs for any repo
|
||||
your key can reach (below); the identity is what makes the resulting action
|
||||
land as yours everywhere it reaches.
|
||||
|
||||
### Give the sandbox its own identity
|
||||
|
||||
This is configuration, not a missing feature. Create a dedicated forge account
|
||||
with **write** access to the repositories a session should work on, and point
|
||||
the sandbox at it instead of at you:
|
||||
|
||||
- `[forge.gitea] token_file`: that account's token, rather than one issued
|
||||
under yours.
|
||||
- `[core] ssh_key`: a deploy key belonging to it, rather than your forwarded
|
||||
agent. See "Headless git push without agent forwarding" in
|
||||
[docs/configuration.md](configuration.md).
|
||||
|
||||
With the two identities separated, the same settings stop being decorative:
|
||||
|
||||
| Setting | What it becomes |
|
||||
| --- | --- |
|
||||
| Merge whitelist: you | The session genuinely cannot merge |
|
||||
| Push whitelist on `main`: you, or nobody | The session pushes feature branches and nothing else |
|
||||
| Require approvals ≥ 1 | Usable: the session authors, you approve |
|
||||
|
||||
That last row is the one that turns "the operator merges, the agent does not"
|
||||
from a convention into something the forge enforces.
|
||||
|
||||
**Verify which credential a push actually uses.** Nothing here enforces one:
|
||||
`[core] ssh_key` is a second, independent credential path (below), so if your
|
||||
own agent is *also* forwarded, a push may still be signed by your personal
|
||||
key and the separate identity becomes decorative for exactly the operation it
|
||||
was meant to constrain. Make a test push and read what the forge attributes
|
||||
it to, rather than assuming.
|
||||
|
||||
**Write access, and specifically not ownership.** Add the account as a
|
||||
collaborator on the repositories it works on. Write is the ceiling here, not
|
||||
a floor: an account that owns a repository, or holds admin on it, can merge
|
||||
its own work and edit the settings in the table above, so every row of that
|
||||
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 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
|
||||
cannot work, since no scope grants ownership, and its only real remedy is
|
||||
handing the sandbox your own token, which would undo this whole section.
|
||||
|
||||
`gitea-actions` therefore reads CI through `actions/tasks`, the commit's
|
||||
statuses and the run view's log route, none of which are owner-gated. If some
|
||||
future need lands on an owner-only endpoint, leaving that capability
|
||||
unavailable is the better trade than promoting the account.
|
||||
|
||||
Two things a separate identity does **not** do:
|
||||
|
||||
- It does not reduce what a compromised session can do inside the container,
|
||||
or to the mounted project. It changes what that session can do *to your
|
||||
forge*, and who the forge thinks did it.
|
||||
- It does not touch CI. A write-capable account can still push a feature
|
||||
branch, and pushing a branch is what makes CI execute code; see the
|
||||
section above. It also has no bearing on the token the forge injects into a
|
||||
CI job, which is a third principal again, subject to none of the settings in
|
||||
that table. **→ [docs/ci.md](ci.md)**
|
||||
|
||||
## Provider trust
|
||||
|
||||
Forges, workflows and harnesses (`forges/`, `workflows/`, `harnesses/`) are
|
||||
@@ -107,6 +204,8 @@ hands to whichever session starts against it. See
|
||||
overlaid (they're config, not fixed paths a static mount can cover) and
|
||||
remain fully writable; the exit-time audit below reports changes to
|
||||
either, but only after the session ends - it doesn't stop them.
|
||||
### The exit-time audit
|
||||
|
||||
- `bin/dejima-audit` snapshots host-persistence surfaces before each
|
||||
run and diffs them after the container exits: `.git/config` (and worktree
|
||||
`config.worktree`) keys that run a command on the next `git` -
|
||||
@@ -119,26 +218,64 @@ 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") - 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
|
||||
other content re-fires. Ignoring records the surface: `spam <id>`, so this
|
||||
path or key is fine whatever it later contains. Approve a hook you have
|
||||
read; ignore a key some tool rewrites on every run and which you have
|
||||
decided you will never care about. The review offers both, and says which
|
||||
is which rather than leaving you to infer it:
|
||||
|
||||
```
|
||||
[a] approve this exact content [i] ignore this key from now on [s] skip (ask again next time) [q] quit
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
pre-session content against the current content; for one created during
|
||||
@@ -151,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
|
||||
@@ -180,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
|
||||
@@ -288,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
|
||||
@@ -386,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
|
||||
@@ -411,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
|
||||
@@ -501,8 +657,7 @@ hands to whichever session starts against it. See
|
||||
sandboxed session (e.g. a token refresh) doesn't carry back out. Worth
|
||||
re-checking if a future Claude Code version starts relying on that for
|
||||
normal operation.
|
||||
- DNS resolution itself is allowlisted (see "DNS is allowlisted too" in the
|
||||
README),
|
||||
- DNS resolution itself is allowlisted (see "DNS is allowlisted too" below),
|
||||
not just the IP connections made afterward, closing off exfiltration via
|
||||
encoding data into query names to an attacker-controlled domain - verified
|
||||
a non-allowlisted domain gets refused outright (`status: REFUSED`) rather
|
||||
@@ -518,9 +673,10 @@ hands to whichever session starts against it. See
|
||||
is given, or `[core] forge = github` is selected with a workflow that
|
||||
pushes (which opens the same grant), so the forwarded agent otherwise has
|
||||
no transport to use for it at all (see "GitHub push: blocked by default,
|
||||
optional opt-in" above).
|
||||
- `[core] ssh_key` (see "Headless git push without agent forwarding") is a
|
||||
second, independent credential-loading path with the same "signs for any
|
||||
optional opt-in" below).
|
||||
- `[core] ssh_key` (see [Headless git push without agent
|
||||
forwarding](configuration.md#headless-git-push-without-agent-forwarding))
|
||||
is a second, independent credential-loading path with the same "signs for any
|
||||
Gitea repo it has access to" shape as the forwarded agent above - the
|
||||
mitigation is that it's meant to be a dedicated deploy key scoped to only
|
||||
the repos that actually need push access, not your personal identity.
|
||||
@@ -542,7 +698,7 @@ hands to whichever session starts against it. See
|
||||
SSH-agent/token story documented above. Keep credentials out of your
|
||||
global gitconfig, or expect them to be reachable from inside the
|
||||
sandbox.
|
||||
- The sandbox's own user (matching your host user — see "Config parity" in
|
||||
- The sandbox's own user (matching your host user; see "Config parity" in
|
||||
the README) has no `sudo`/setuid path back to root once the container is up:
|
||||
`entrypoint.sh` runs as root, sets up the firewall directly, then drops to
|
||||
that user via `gosu` before execing the configured command (`[core] command`,
|
||||
@@ -552,7 +708,9 @@ hands to whichever session starts against it. See
|
||||
cleared from the effective/permitted sets on the `gosu` drop, and that
|
||||
user gets a permission error running `iptables` directly.
|
||||
- The approval store (`${DEJIMA_CONFIG_DIR:-$HOME/.config/dejima}`,
|
||||
see "Global and per-project domains and dependencies" above) lives outside
|
||||
see
|
||||
[Global and per-project domains and dependencies](../README.md#global-and-per-project-domains-and-dependencies)
|
||||
in the README) lives outside
|
||||
this repo, and outside every project mount, by default - unlike a
|
||||
project's own `.dejima/` declaration files, it's never
|
||||
bind-mounted into the container, so a compromised
|
||||
@@ -562,8 +720,8 @@ hands to whichever session starts against it. See
|
||||
`bin/dejima-project-lib.sh`), never sourced or executed, and are
|
||||
written only by the CLIs (`dejima-allow`, `dejima-dep`,
|
||||
`dejima-project`) a human runs on the host. `dejima`
|
||||
warns at launch whenever the resolved store path — default or overridden
|
||||
via `DEJIMA_CONFIG_DIR` — lands under the mounted workspace,
|
||||
warns at launch whenever the resolved store path (default or overridden
|
||||
via `DEJIMA_CONFIG_DIR`) lands under the mounted workspace,
|
||||
rather than silently trusting it.
|
||||
- When the mounted workspace *is* this repo itself, `dejima`
|
||||
warns at launch that the repo's own trusted computing base - `bin/`,
|
||||
@@ -575,3 +733,344 @@ hands to whichever session starts against it. See
|
||||
session can still edit those files during the run, and whatever it
|
||||
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
|
||||
is bind-mounted read-write. A compromised session can write them, and that is
|
||||
fine by design: declaring grants nothing, and the approval store the session
|
||||
cannot reach is what a declaration has to pass through.
|
||||
|
||||
What a session *could* try is to make the host-side write land somewhere else.
|
||||
Plant `.dejima`, or `.dejima/<kind>`, as a symlink pointing at your approval
|
||||
store or any other file you can write, then get you to run the
|
||||
`dejima-allow --project` or `dejima-dep add` that appends to it. The append
|
||||
happens on the host, as you, so it inherits your write access rather than the
|
||||
container's.
|
||||
|
||||
Both writers refuse a symlink at either of those two leaves. The refusal is
|
||||
the `open` itself and not a check before it: `O_NOFOLLOW` on both, with the
|
||||
file opened relative to the already-opened directory, so a session still
|
||||
running while you type the command cannot swap either one out from under the
|
||||
write. Those two leaves are what a session controls. A symlink further up a
|
||||
`--project` path you supplied yourself is still resolved through, because
|
||||
that part of the path is your claim about where the project is, not the
|
||||
session's.
|
||||
|
||||
Expect the session to be live when you run these: the agent briefing tells it
|
||||
to ask you to run them from a terminal outside the sandbox, which is exactly
|
||||
the window this closes.
|
||||
|
||||
## Network egress firewall
|
||||
|
||||
On start, the container runs an allowlist-only `iptables`/`ipset` firewall
|
||||
(`init-firewall.sh`), matching the reference dev container. Allowed by
|
||||
default:
|
||||
|
||||
- DNS resolution via a local proxy (see "DNS is allowlisted too")
|
||||
- the Docker host itself (not its whole subnet)
|
||||
- GitHub's published IP ranges, port 443 only (see "GitHub push" below)
|
||||
- `registry.npmjs.org` and `platform.claude.com` (the harness and its
|
||||
updates), `release-assets.githubusercontent.com` (GitHub release assets, a
|
||||
separate CDN from github.com's own IPs, and where a good deal of tooling
|
||||
fetches its binaries); `api.anthropic.com` when the credential proxy is off
|
||||
(kill switch or API-key auth)
|
||||
|
||||
That is the whole built-in list, and it is meant to stay that way: it covers
|
||||
what the sandbox itself cannot run without, not what a project might want.
|
||||
Language package hosts are approved rather than assumed: `pypi.org`,
|
||||
`proxy.golang.org`, `crates.io` and the rest are one `dejima-allow --global`
|
||||
or one reviewed `.dejima/allowed-domains` line away. The reason to keep the
|
||||
built-in list short is that everything on it is reachable from every sandbox
|
||||
on the host whether or not a project has any use for it, and a name granted
|
||||
by being built in is a name nobody ever reviewed
|
||||
- whatever the selected forge needs reachable (`[forge.gitea] ssh_host`/`api`
|
||||
by default): not port-restricted, so git-over-ssh to the forge works
|
||||
without a blanket "port 22 to anywhere" rule, and exempted from DNS-rebind
|
||||
protection because a self-hosted forge host legitimately resolves to an
|
||||
internal address. `[core] forge = github` is deliberately *not* added here:
|
||||
its three hostnames are public names already covered by the GitHub entry
|
||||
above, so they stay on that entry: 443 by default, plus port 22 when the
|
||||
selected workflow pushes, exactly as `--allow-github-push` opens it per
|
||||
launch, rather than picking up all-port egress and losing rebind
|
||||
protection on the way
|
||||
|
||||
Everything else is rejected. Run `dejima-allow --global <domain>`
|
||||
(or `--project <path>` for one project) to allow more permanently (e.g.
|
||||
`crates.io` for a Rust project), or `dejima --no-firewall` to disable the
|
||||
firewall entirely for one launch (debugging only).
|
||||
|
||||
IPv6 is blocked outright (`ip6tables` DROP on INPUT/OUTPUT/FORWARD, `dnsmasq`
|
||||
strips AAAA answers): there's no IPv6 equivalent of the allowlist above, so
|
||||
leaving it open would bypass the whole firewall on any host with IPv6
|
||||
egress.
|
||||
|
||||
If your Gitea host only resolves via a LAN-local DNS server, make sure the
|
||||
Docker host itself can resolve it: containers inherit the host's resolver.
|
||||
|
||||
### DNS is allowlisted too
|
||||
|
||||
Resolution itself is restricted, not just the IP connections made
|
||||
afterward: `dns-proxy.sh` runs a local `dnsmasq` with no fallback resolver,
|
||||
and `/etc/resolv.conf` is repointed at it. Only names on the same allowlist
|
||||
above (plus `github.com`/`githubusercontent.com`/`githubassets.com`, needed
|
||||
since GitHub itself is allowlisted by IP range; see `fixed-domains.sh`) get
|
||||
forwarded to Docker's real resolver; anything else gets no upstream
|
||||
configured at all and is refused outright. Without this, a compromised
|
||||
session could exfiltrate data by encoding it into query names to an
|
||||
attacker-controlled domain, even with no direct connection ever possible.
|
||||
|
||||
### Adding a domain to a running session
|
||||
|
||||
Approved domains only take effect at container start. For a domain a
|
||||
*running* session needs right now, run from the host (not inside the
|
||||
sandbox: it has no privilege to do this itself):
|
||||
|
||||
```sh
|
||||
dejima-allow crates.io # this running session only
|
||||
dejima-allow --container <id> crates.io # a specific session, if more than one is running
|
||||
```
|
||||
|
||||
It first adds the domain to the running dnsmasq's allowlist (appends a
|
||||
`server=` line to its servers-file and sends `SIGHUP` to reload just that
|
||||
file - no restart), then resolves it through the now-unblocked container
|
||||
resolver and adds the IP(s) to the running firewall's `ipset`, all via
|
||||
`docker exec -u root`. It finishes by checking the domain is actually
|
||||
reachable from inside that same container and prints a definitive verdict:
|
||||
exit code 2 if it isn't.
|
||||
|
||||
`--global` and `--project` are the opposite of this: they record an approval
|
||||
for *future* sessions and deliberately do not touch a running container, even
|
||||
together - see
|
||||
[Global and per-project domains and dependencies](configuration.md#global-and-per-project-domains-and-dependencies)
|
||||
in docs/configuration.md, since the domain being approved may legitimately
|
||||
not answer on 443 (an
|
||||
internal service, an SSH-only host) and the natural moment to add an approval
|
||||
is precisely when nothing is running to probe in the first place. Pass
|
||||
`--container <id>` alongside a scope flag to do both in one call:
|
||||
`dejima-allow --global --container <id> crates.io` records the approval and
|
||||
patches that session immediately, in that order, so the approval is never
|
||||
lost to a live-session failure. If more than one dejima container is
|
||||
running, the bare form above refuses and asks you to disambiguate with
|
||||
`--container <name>` - `docker ps` finds it, or use the
|
||||
container's own ID, which `sandbox-context.sh` already hands the agent in
|
||||
its startup context (and which is always available inside the sandbox as
|
||||
`cat /etc/hostname`), so there's no need for it to guess one and risk
|
||||
"successfully" updating someone else's session instead.
|
||||
|
||||
A domain allowed this way stays allowed for the rest of the session even
|
||||
if its CDN later rotates edge IPs: `dns-proxy.sh` tops up the firewall's
|
||||
`ipset` from every DNS answer it serves, not just the one resolved at
|
||||
allow-time, since only allowlisted names get an upstream to answer from in
|
||||
the first place. GitHub's own domains sit outside this - their existing
|
||||
port-443-only `ipset` entries still win by dnsmasq's longest-match rule -
|
||||
so that restriction is unaffected.
|
||||
|
||||
One gap remains: allowing an *internal* hostname (one that resolves to an
|
||||
RFC1918 address) mid-session doesn't work. dnsmasq's `stop-dns-rebind`
|
||||
rejects private answers for any domain not exempted at startup, and that
|
||||
exemption list can only be extended by restarting dnsmasq. Internal hosts
|
||||
named before the container starts (`[forge.gitea] ssh_host`, `api`,
|
||||
`[logging] loki_url`, and any globally or per-project approved domain) are
|
||||
already exempted and unaffected by this.
|
||||
|
||||
The sandbox-context.sh `SessionStart` hook tells the agent to ask for this
|
||||
by name when a task is blocked on a single missing domain, rather than
|
||||
stopping the whole session over it.
|
||||
|
||||
## Network request logging
|
||||
|
||||
If `[logging] loki_url` is set (see Prerequisites in the README), every DNS query and every new
|
||||
outbound connection attempt - allowed or rejected - is shipped to that Loki
|
||||
endpoint. Two sources, both tailed and pushed by `log-shipper.sh`:
|
||||
|
||||
- `dns-proxy.sh`'s dnsmasq logs every query and whether it was forwarded
|
||||
(allowed) or refused (not on the allowlist) - labeled `job=dejima-dns`.
|
||||
- `init-firewall.sh` tags its terminal ACCEPT/REJECT rules with `NFLOG`
|
||||
(group 1 for allowed, group 2 for rejected), which `ulogd2`
|
||||
(`ulogd.conf`) turns into JSON lines with source/dest IP, port, and an
|
||||
`action` field - labeled `job=dejima-net`. Only new connection
|
||||
attempts are logged, not every packet: the firewall's existing
|
||||
ESTABLISHED,RELATED rule short-circuits the rest of a connection before it
|
||||
ever reaches the tagged rules.
|
||||
|
||||
This is visibility on top of the firewall/DNS-proxy enforcement above, not a
|
||||
substitute for it - nothing about logging changes what's actually allowed to
|
||||
resolve or connect.
|
||||
|
||||
### Conversation logging (opt-in)
|
||||
|
||||
Set `[logging] conversation = on` (with `loki_url` configured) to also ship
|
||||
session content to Loki as the `dejima-conversation` job: your
|
||||
prompts, the agent's text responses, and tool invocations (tool name +
|
||||
input - never tool outputs). Lines are JSON with the session ID inside
|
||||
(`{session, role, text}` / `{session, role: "tool", tool, input}`).
|
||||
|
||||
Off by default - conversation content is a different sensitivity class
|
||||
than DNS names and firewall verdicts, so it never leaves the container
|
||||
unless you opt in. Transcripts of every session under the mounted
|
||||
workspace (including worktrees and subagent sessions) are shipped while
|
||||
the sandbox runs.
|
||||
|
||||
### Viewing logs in Grafana
|
||||
|
||||
Nothing sandbox-specific needed on the Grafana side beyond a Loki data
|
||||
source pointed at wherever `[logging] loki_url` resolves to (Connections → Data
|
||||
sources → Loki, if one isn't already configured against that instance) -
|
||||
`log-shipper.sh` pushes directly to Loki's HTTP API, there's no Prometheus
|
||||
scrape target or exporter involved.
|
||||
|
||||
In Grafana's Explore view (or a dashboard panel with a Loki query), each
|
||||
stream is selectable by its `job` label:
|
||||
|
||||
```logql
|
||||
{job="dejima-dns"} # every DNS query + verdict
|
||||
{job="dejima-net"} # every connection attempt, as JSON
|
||||
{job="dejima-conversation"} # the session itself, if opted in
|
||||
```
|
||||
|
||||
Useful filters on top of those:
|
||||
|
||||
```logql
|
||||
# DNS lookups that got refused (not on the allowlist)
|
||||
{job="dejima-dns"} |= "REFUSED"
|
||||
|
||||
# Rejected connections only (parses ulogd2's JSON, filters on its action field)
|
||||
{job="dejima-net"} | json | action="blocked"
|
||||
|
||||
# All three together, e.g. to watch a live session across sources
|
||||
{source="dejima"}
|
||||
```
|
||||
|
||||
The conversation stream, when `[logging] conversation` is on, is one JSON
|
||||
object per line carrying `session`, `role`, and either `text` or a `tool` name
|
||||
with its `input`:
|
||||
|
||||
```logql
|
||||
# Just what you asked for, without the replies
|
||||
{job="dejima-conversation"} | json | role="user"
|
||||
|
||||
# Every shell command a session ran, and with what arguments
|
||||
{job="dejima-conversation"} | json | tool="Bash"
|
||||
|
||||
# One session end to end, id taken from a line you already found
|
||||
{job="dejima-conversation"} | json | session="0198e2c1-..."
|
||||
|
||||
# Anything a session said about a subject, across every session
|
||||
{job="dejima-conversation"} |= "allowed-domains"
|
||||
```
|
||||
|
||||
`session` is deliberately a field inside the line rather than a Loki label:
|
||||
one label per session would multiply the stream cardinality by the number of
|
||||
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. **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.
|
||||
|
||||
`{job="dejima-net"}` lines are one full ulogd2 JSON object per
|
||||
connection attempt (`| json` in a query unpacks fields like `dest_ip`,
|
||||
`dest_port`, `action`) - Loki's Explore view shows these expandable/
|
||||
pretty-printed by default, no extra parsing setup needed.
|
||||
|
||||
|
||||
## GitHub push: blocked by default, optional opt-in
|
||||
|
||||
`dejima --allow-github-push` is a per-launch *addition* to whichever forge is
|
||||
selected, not a substitute for selecting one. A pushing workflow is still
|
||||
refused at launch unless its forge resolves to a host with its required keys
|
||||
filled in, so the flag alone will not get an unconfigured `[forge.gitea]` past
|
||||
validation.
|
||||
|
||||
This section is about `--allow-github-push`, a per-launch flag that adds
|
||||
GitHub as a push destination on top of whichever forge is selected, without
|
||||
changing that selection. It adds a destination; it does not stand in for a
|
||||
forge, so the launch-time rules for the selected forge/workflow pair still
|
||||
apply exactly as they would without it. If you
|
||||
want GitHub push and PRs as a standing choice instead, set
|
||||
`[core] forge = github` (see
|
||||
[Shipped providers](providers.md#shipped-providers)): it also opens
|
||||
`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
|
||||
443. Port 22 is unreachable, so SSH-based push has no transport at all,
|
||||
regardless of what any hook does or doesn't catch.
|
||||
|
||||
**Opt-in, not hardcoded**: pass `--allow-github-push` to open port 22 to
|
||||
GitHub and let `git-push-guard.sh` accept `github.com`/`ssh.github.com` as a
|
||||
push destination (the no-direct-to-main rule still applies):
|
||||
|
||||
```sh
|
||||
dejima --allow-github-push ~/repos/some-project
|
||||
```
|
||||
|
||||
**Read this before turning it on.** Whether a push actually succeeds depends
|
||||
entirely on what SSH key is loaded into the agent. A forwarded personal
|
||||
agent with your normal GitHub key would let a compromised session push to
|
||||
**any** repo that key can write to, not just the one you intended: agent
|
||||
forwarding doesn't distinguish push from pull. Use a dedicated,
|
||||
**repo-scoped GitHub deploy key** via `[core] ssh_key` instead whenever
|
||||
`--allow-github-push` is set: that's what actually limits the blast radius.
|
||||
`--allow-github-push` only changes the firewall/hook's *host* check; it adds
|
||||
no credential-loading path of its own. The flag itself has no config-file
|
||||
equivalent: an exported or saved value must not silently grant a push path,
|
||||
so *this* opt-in stays a per-launch decision only. Selecting `[core] forge =
|
||||
github` (see [Shipped providers](providers.md#shipped-providers)) is a
|
||||
config-file path that opens the same port, but not
|
||||
silently: `hosts_allowlisted_by_core` is what asks for it, explicitly, in a
|
||||
manifest, and only when the selected workflow actually pushes. What it opens
|
||||
is the same GitHub ranges the read-only path already allows on port 443, now
|
||||
also on port 22, logged like every other allowed connection and verified by
|
||||
the firewall's own startup self-check.
|
||||
|
||||
`[github] token_file` must point at a **read-only** fine-grained PAT
|
||||
regardless of this setting. `gh auth setup-git` (run at container start if
|
||||
that file exists) points git's github.com HTTPS credential helper at `gh`,
|
||||
which only ever holds whatever token that file provides: a write-scoped
|
||||
token there would make HTTPS push possible independent of
|
||||
`--allow-github-push`, so don't use one.
|
||||
|
||||
+115
-52
@@ -1,15 +1,29 @@
|
||||
#!/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 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
|
||||
# commits/<sha>/statuses the same jobs as commit statuses, and the only
|
||||
# place a job's index is visible
|
||||
# the web log route the run view's own endpoint, which takes the
|
||||
# same token header as the API
|
||||
#
|
||||
# The job index matters because it is what addresses a log, and it is not a
|
||||
# field anywhere - only the tail of a status's target_url.
|
||||
set -euo pipefail
|
||||
|
||||
GITEA_TOKEN_FILE="${GITEA_TOKEN_FILE:-$HOME/.config/gitea/token}"
|
||||
@@ -99,57 +113,81 @@ function gitea_owner_repo() {
|
||||
echo "$owner_repo"
|
||||
}
|
||||
|
||||
# The API addresses runs by an internal id; every URL a human has shows the
|
||||
# per-repo run number instead, and the two are unrelated. Everything below
|
||||
# takes the number and resolves it here, against one page of the run list.
|
||||
#
|
||||
# With no number, HEAD's commit: a run list is not what someone who just
|
||||
# pushed is asking for. Matched on head_sha rather than on branch because a
|
||||
# pull_request run reports no branch at all - its ref is refs/pull/N/head.
|
||||
# Sets `tasks_json`. The list pages by TASK - one record per job - so a run
|
||||
# costs as many records as it has jobs, and `limit` is ignored unless `page`
|
||||
# comes with it. Taken as one page, sized for a few dozen runs of a ten-job
|
||||
# workflow; a lookup that falls off the end says which it was.
|
||||
function fetch_tasks() {
|
||||
api_or_die "list runs" \
|
||||
"${GITEA_API}/repos/${owner}/${repo}/actions/tasks?page=1&limit=$TASK_SCAN"
|
||||
tasks_json="$json"
|
||||
}
|
||||
|
||||
# Sets `run_number` and `run_sha`. With no number, HEAD's commit: a run list
|
||||
# is not what someone who just pushed is asking for. Matched on head_sha
|
||||
# rather than on branch because a pull_request run reports no branch at all -
|
||||
# its ref is refs/pull/N/head.
|
||||
function resolve_run() {
|
||||
local want="${1:-}" sha
|
||||
api_or_die "list runs" "${GITEA_API}/repos/${owner}/${repo}/actions/runs?page=1&limit=$RUN_SCAN"
|
||||
fetch_tasks
|
||||
if [ -n "$want" ]; then
|
||||
run_id=$(echo "$json" | jq -r --argjson n "$want" \
|
||||
'first(.workflow_runs[] | select(.run_number == $n) | .id) // empty')
|
||||
if [ -z "$run_id" ]; then
|
||||
echo "No run numbered $want in the last $RUN_SCAN runs (raise --limit, or see 'gitea-actions runs')" >&2
|
||||
run_sha=$(echo "$tasks_json" | jq -r --argjson n "$want" \
|
||||
'first(.workflow_runs[] | select(.run_number == $n) | .head_sha) // empty')
|
||||
if [ -z "$run_sha" ]; then
|
||||
echo "No run numbered $want in the last $TASK_SCAN job records (see 'gitea-actions runs')" >&2
|
||||
exit 1
|
||||
fi
|
||||
run_number="$want"
|
||||
else
|
||||
sha=$(git rev-parse HEAD 2>/dev/null) || {
|
||||
echo "Not in a git repository, so there is no HEAD to look up - pass a run number" >&2
|
||||
exit 1
|
||||
}
|
||||
run_id=$(echo "$json" | jq -r --arg sha "$sha" \
|
||||
'first(.workflow_runs[] | select(.head_sha == $sha) | .id) // empty')
|
||||
if [ -z "$run_id" ]; then
|
||||
echo "No run for HEAD (${sha:0:8}) in the last $RUN_SCAN runs - unpushed, or still queued?" >&2
|
||||
run_number=$(echo "$tasks_json" | jq -r --arg sha "$sha" \
|
||||
'first(.workflow_runs[] | select(.head_sha == $sha) | .run_number) // empty')
|
||||
if [ -z "$run_number" ]; then
|
||||
echo "No run for HEAD (${sha:0:8}) in the last $TASK_SCAN job records - unpushed, or still queued?" >&2
|
||||
exit 1
|
||||
fi
|
||||
run_sha="$sha"
|
||||
fi
|
||||
run_number=$(echo "$json" | jq -r --argjson id "$run_id" \
|
||||
'first(.workflow_runs[] | select(.id == $id) | .run_number)')
|
||||
}
|
||||
|
||||
# Sets `jobs_json` for the resolved run. The job index a URL shows is the
|
||||
# tail of the job's html_url, not a field of its own, and the list does not
|
||||
# arrive in that order.
|
||||
# Sets `jobs_json` for the resolved run, from the commit's statuses - the one
|
||||
# place a job's index is visible to a token that does not own the repo.
|
||||
#
|
||||
# A status is written more than once per job (queued, then finished) and the
|
||||
# list arrives newest first, so the newest per context wins. `context` is
|
||||
# "<workflow> / <job> (<event>)"; the job name is pulled out of it for
|
||||
# display, and matching keeps the whole string so either form works. Filtered
|
||||
# by run number as well as by commit, since a re-run puts a second run's
|
||||
# statuses on the same commit.
|
||||
function fetch_jobs() {
|
||||
api_or_die "list jobs for run $run_number" \
|
||||
"${GITEA_API}/repos/${owner}/${repo}/actions/runs/${run_id}/jobs"
|
||||
jobs_json=$(echo "$json" | jq '[.jobs[] | {
|
||||
index: (.html_url | split("/") | last | tonumber),
|
||||
id, name,
|
||||
result: (if .status == "completed" then .conclusion else .status end)
|
||||
}] | sort_by(.index)')
|
||||
"${GITEA_API}/repos/${owner}/${repo}/commits/${run_sha}/statuses?page=1&limit=100"
|
||||
jobs_json=$(echo "$json" | jq --arg rn "$run_number" '
|
||||
[.[] | select(.target_url | test("/actions/runs/" + $rn + "/jobs/[0-9]+$"))]
|
||||
| group_by(.context) | map(first)
|
||||
| map({
|
||||
index: (.target_url | split("/") | last | tonumber),
|
||||
name: (.context | sub("^[^/]+/ *"; "") | sub(" *\\([^()]*\\)$"; "")),
|
||||
context,
|
||||
result: .status
|
||||
})
|
||||
| sort_by(.index)')
|
||||
if [ "$(echo "$jobs_json" | jq 'length')" -eq 0 ]; then
|
||||
echo "Run $run_number (${run_sha:0:8}) reports no jobs." >&2
|
||||
echo "Gitea publishes a run's jobs as statuses on its commit, and this" >&2
|
||||
echo "commit has none for that run - so there is nothing to address a log by." >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
function print_job_log() {
|
||||
local id="$1" name="$2" result="$3"
|
||||
local index="$1" name="$2" result="$3"
|
||||
echo "=== $name ($result) ==="
|
||||
api_or_die "fetch logs for job $name" \
|
||||
"${GITEA_API}/repos/${owner}/${repo}/actions/jobs/${id}/logs"
|
||||
"${GITEA_WEB}/${owner}/${repo}/actions/runs/${run_number}/jobs/${index}/logs"
|
||||
if [ "$KEEP_TIMESTAMPS" = 1 ]; then
|
||||
echo "$json"
|
||||
else
|
||||
@@ -161,7 +199,7 @@ function print_job_log() {
|
||||
echo
|
||||
}
|
||||
|
||||
RUN_SCAN=50
|
||||
TASK_SCAN=600
|
||||
KEEP_TIMESTAMPS=0
|
||||
|
||||
if [ "$#" -lt 1 ]; then
|
||||
@@ -179,6 +217,15 @@ case "$subcommand" in
|
||||
owner_repo=$(gitea_owner_repo)
|
||||
owner="${owner_repo%%/*}"
|
||||
repo="${owner_repo##*/}"
|
||||
# Logs come from the run view's own endpoint rather than the API, so
|
||||
# the web root is needed as well. Derived from GITEA_API because that
|
||||
# is the only address the caller configures.
|
||||
GITEA_WEB="${GITEA_API%/api/v1}"
|
||||
if [ "$GITEA_WEB" = "$GITEA_API" ]; then
|
||||
echo "GITEA_API ($GITEA_API) does not end in /api/v1, so the web root" >&2
|
||||
echo "cannot be derived from it - logs are read from a non-API route." >&2
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
usage
|
||||
@@ -195,14 +242,28 @@ case "$subcommand" in
|
||||
*) echo "Unknown option: $1" >&2; usage; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
api_or_die "list runs" "${GITEA_API}/repos/${owner}/${repo}/actions/runs?page=1&limit=$limit"
|
||||
echo "$json" | jq -r '.workflow_runs[] | [
|
||||
(.run_number | tostring),
|
||||
(if .status == "completed" then .conclusion else .status end),
|
||||
.event,
|
||||
(.head_sha[0:8]),
|
||||
.display_title
|
||||
] | @tsv' | column -t -s $'\t'
|
||||
fetch_tasks
|
||||
# One record per job, so a run appears once for each job it has.
|
||||
# Folded here into a row per run, whose result is the worst of them:
|
||||
# a run with one failed job is a failed run, and reporting it as
|
||||
# seven successes and a failure is not what the caller asked.
|
||||
echo "$tasks_json" | jq -r --argjson limit "$limit" '
|
||||
[.workflow_runs[]] | group_by(.run_number)
|
||||
| map({
|
||||
run: .[0].run_number,
|
||||
event: .[0].event,
|
||||
sha: (.[0].head_sha[0:8]),
|
||||
title: .[0].display_title,
|
||||
result: (if any(.[]; .status == "failure") then "failure"
|
||||
elif any(.[]; .status == "cancelled") then "cancelled"
|
||||
elif any(.[]; .status != "success") then "in_progress"
|
||||
else "success" end)
|
||||
})
|
||||
| sort_by(.run) | reverse | .[0:$limit] | .[]
|
||||
| [(.run | tostring), .result, .event, .sha, .title] | @tsv' |
|
||||
while IFS=$'\t' read -r run result event sha title; do
|
||||
printf '%-5s %-11s %-20s %-8s %s\n' "$run" "$result" "$event" "$sha" "$title"
|
||||
done
|
||||
;;
|
||||
jobs)
|
||||
run_arg=""
|
||||
@@ -212,7 +273,9 @@ case "$subcommand" in
|
||||
fetch_jobs
|
||||
echo "run $run_number:"
|
||||
echo "$jobs_json" | jq -r '.[] | [(.index | tostring), .result, .name] | @tsv' |
|
||||
column -t -s $'\t'
|
||||
while IFS=$'\t' read -r index result name; do
|
||||
printf '%-3s %-11s %s\n' "$index" "$result" "$name"
|
||||
done
|
||||
;;
|
||||
logs)
|
||||
run_arg=""
|
||||
@@ -237,7 +300,7 @@ case "$subcommand" in
|
||||
if [[ "$job_want" =~ ^[0-9]+$ ]]; then
|
||||
selected=$(echo "$jobs_json" | jq --argjson i "$job_want" '[.[] | select(.index == $i)]')
|
||||
else
|
||||
selected=$(echo "$jobs_json" | jq --arg n "$job_want" '[.[] | select(.name | contains($n))]')
|
||||
selected=$(echo "$jobs_json" | jq --arg n "$job_want" '[.[] | select(.context | contains($n))]')
|
||||
fi
|
||||
if [ "$(echo "$selected" | jq 'length')" -eq 0 ]; then
|
||||
echo "No job matching '$job_want' in run $run_number:" >&2
|
||||
@@ -254,8 +317,8 @@ case "$subcommand" in
|
||||
fi
|
||||
fi
|
||||
|
||||
while IFS=$'\t' read -r id name result; do
|
||||
print_job_log "$id" "$name" "$result"
|
||||
done < <(echo "$selected" | jq -r '.[] | [(.id | tostring), .name, .result] | @tsv')
|
||||
while IFS=$'\t' read -r index name result; do
|
||||
print_job_log "$index" "$name" "$result"
|
||||
done < <(echo "$selected" | jq -r '.[] | [(.index | tostring), .name, .result] | @tsv')
|
||||
;;
|
||||
esac
|
||||
|
||||
@@ -13,6 +13,7 @@ function usage() {
|
||||
echo "Usage: gitea-issue list [--state open|closed|all] [--limit N]"
|
||||
echo " gitea-issue view NUMBER"
|
||||
echo " gitea-issue create --title TITLE [--body BODY | --body-file FILE]"
|
||||
echo " gitea-issue edit NUMBER [--title TITLE] [--body BODY | --body-file FILE]"
|
||||
echo " gitea-issue comment NUMBER (--body BODY | --body-file FILE)"
|
||||
echo " gitea-issue close NUMBER"
|
||||
echo " gitea-issue reopen NUMBER"
|
||||
@@ -153,7 +154,7 @@ subcommand="$1"
|
||||
shift
|
||||
|
||||
case "$subcommand" in
|
||||
list|view|create|comment|close|reopen)
|
||||
list|view|create|edit|comment|close|reopen)
|
||||
require_config
|
||||
require_token
|
||||
owner_repo=$(gitea_owner_repo)
|
||||
@@ -223,6 +224,35 @@ ${body}"
|
||||
'{title: $title, body: $body}')"
|
||||
echo "$json" | jq -r '.html_url'
|
||||
;;
|
||||
edit)
|
||||
require_number "${1:-}"
|
||||
number="$1"
|
||||
shift
|
||||
parse_body_args "$@"
|
||||
if [ -z "$parsed_title" ] && [ -z "$body" ]; then
|
||||
echo "gitea-issue edit requires --title, --body or --body-file" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Only the fields actually given are sent: an omitted key leaves that
|
||||
# field alone, so editing a title cannot blank a body by accident.
|
||||
payload='{}'
|
||||
if [ -n "$parsed_title" ]; then
|
||||
payload=$(echo "$payload" | jq --arg t "$parsed_title" '. + {title: $t}')
|
||||
fi
|
||||
if [ -n "$body" ]; then
|
||||
# The same marker create writes. It describes where the body came
|
||||
# from rather than the event that wrote it, so an edited body
|
||||
# keeps the disclosure instead of quietly shedding it.
|
||||
payload=$(echo "$payload" | jq --arg b "**Filed by LLM agent**
|
||||
|
||||
$body" '. + {body: $b}')
|
||||
fi
|
||||
|
||||
api_or_die "edit issue #${number}" -X PATCH "${issues_api}/${number}" \
|
||||
-H "Content-Type: application/json" -d "$payload"
|
||||
echo "$json" | jq -r '.html_url'
|
||||
;;
|
||||
comment)
|
||||
require_number "${1:-}"
|
||||
number="$1"
|
||||
|
||||
+164
-78
@@ -5,12 +5,19 @@ GITEA_TOKEN_FILE="${GITEA_TOKEN_FILE:-$HOME/.config/gitea/token}"
|
||||
|
||||
function usage() {
|
||||
echo "Usage: gitea-pr create [--title TITLE] [--body BODY | --body-file FILE] [--base BRANCH]"
|
||||
echo " gitea-pr comments PR_NUMBER"
|
||||
echo " gitea-pr edit PR_NUMBER [--title TITLE] [--body BODY | --body-file FILE]"
|
||||
echo " gitea-pr comments PR_NUMBER [--all]"
|
||||
echo " gitea-pr merge PR_NUMBER"
|
||||
echo ""
|
||||
echo "'comments' shows unresolved review threads by default. --all includes"
|
||||
echo "the ones already resolved, marked as such."
|
||||
echo ""
|
||||
echo "--body-file reads the body from FILE, or from stdin when FILE is '-'."
|
||||
echo "Prefer it over --body for anything with quotes, backticks or newlines."
|
||||
echo ""
|
||||
echo "'edit' replaces the title and/or body of an existing PR. Whichever of"
|
||||
echo "the two is not given is left as it is."
|
||||
echo ""
|
||||
echo "Requires GITEA_SSH_HOST and GITEA_API set in the environment (e.g. in" \
|
||||
"your shell profile), and a token at $GITEA_TOKEN_FILE (or GITEA_TOKEN" \
|
||||
"set for this invocation only)."
|
||||
@@ -53,6 +60,23 @@ function gitea_api() {
|
||||
curl -sS -w '\n%{http_code}' -K - "$@"
|
||||
}
|
||||
|
||||
# Splits gitea_api's combined output into the globals `json` and `http_code`
|
||||
# and fails with the body on any non-2xx. Copied from gitea-issue, which grew
|
||||
# it first for the same reason: every call site here did this by hand, and a
|
||||
# fifth hand-rolled copy is where one of them quietly stops checking.
|
||||
function api_or_die() {
|
||||
local what="$1"; shift
|
||||
local response
|
||||
response=$(gitea_api "$@")
|
||||
http_code=$(echo "$response" | tail -1)
|
||||
json=$(echo "$response" | sed '$d')
|
||||
if [ "$http_code" -lt 200 ] || [ "$http_code" -ge 300 ]; then
|
||||
echo "Failed to $what (HTTP $http_code):" >&2
|
||||
echo "$json" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Reads a body from a file, or from stdin for '-'. The point is to keep prose
|
||||
# out of argv: a body composed on a command line has to survive shell quoting,
|
||||
# and the things that break it - an apostrophe, a backtick, a $ - are exactly
|
||||
@@ -86,6 +110,43 @@ function gitea_owner_repo() {
|
||||
echo "$owner_repo"
|
||||
}
|
||||
|
||||
function require_number() {
|
||||
if ! [[ "${1:-}" =~ ^[0-9]+$ ]]; then
|
||||
echo "Expected a PR number, got '${1:-}'" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Sets `body` from --body/--body-file, `parsed_title` from --title and
|
||||
# `parsed_base` from --base, rejecting --body and --body-file together.
|
||||
# Shared by create and edit so the two cannot drift into disagreeing about
|
||||
# which one wins - the same reason gitea-issue shares one between its own
|
||||
# two body-taking subcommands.
|
||||
function parse_body_args() {
|
||||
body=""
|
||||
local body_file=""
|
||||
parsed_title=""
|
||||
parsed_base=""
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--title) parsed_title="$2"; shift 2 ;;
|
||||
--body) body="$2"; shift 2 ;;
|
||||
--body-file) body_file="$2"; shift 2 ;;
|
||||
--base) parsed_base="$2"; shift 2 ;;
|
||||
*) echo "Unknown option: $1" >&2; usage; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
# Rejected rather than ranked: silently preferring one would make a caller
|
||||
# that passed both ship a body it didn't mean to.
|
||||
if [ -n "$body" ] && [ -n "$body_file" ]; then
|
||||
echo "Pass --body or --body-file, not both" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -n "$body_file" ]; then
|
||||
body="$(read_body_file "$body_file")"
|
||||
fi
|
||||
}
|
||||
|
||||
if [ "$#" -lt 1 ]; then
|
||||
usage
|
||||
exit 1
|
||||
@@ -95,7 +156,7 @@ subcommand="$1"
|
||||
shift
|
||||
|
||||
case "$subcommand" in
|
||||
create|comments|merge)
|
||||
create|edit|comments|merge)
|
||||
require_config
|
||||
require_token
|
||||
owner_repo=$(gitea_owner_repo)
|
||||
@@ -110,28 +171,9 @@ esac
|
||||
|
||||
case "$subcommand" in
|
||||
create)
|
||||
title=""
|
||||
body=""
|
||||
body_file=""
|
||||
base=""
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--title) title="$2"; shift 2 ;;
|
||||
--body) body="$2"; shift 2 ;;
|
||||
--body-file) body_file="$2"; shift 2 ;;
|
||||
--base) base="$2"; shift 2 ;;
|
||||
*) echo "Unknown option: $1" >&2; usage; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
# Rejected rather than ranked: silently preferring one would make a
|
||||
# caller that passed both ship a body it didn't mean to.
|
||||
if [ -n "$body" ] && [ -n "$body_file" ]; then
|
||||
echo "Pass --body or --body-file, not both" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -n "$body_file" ]; then
|
||||
body="$(read_body_file "$body_file")"
|
||||
fi
|
||||
parse_body_args "$@"
|
||||
title="$parsed_title"
|
||||
base="$parsed_base"
|
||||
body="**Summary generated by LLM agent**
|
||||
|
||||
${body}"
|
||||
@@ -147,14 +189,7 @@ ${body}"
|
||||
base="${base:-main}"
|
||||
fi
|
||||
|
||||
response=$(gitea_api "${GITEA_API}/repos/${owner}/${repo}/pulls?state=open")
|
||||
http_code=$(echo "$response" | tail -1)
|
||||
json=$(echo "$response" | sed '$d')
|
||||
if [ "$http_code" -lt 200 ] || [ "$http_code" -ge 300 ]; then
|
||||
echo "Failed to check for an existing PR (HTTP $http_code):" >&2
|
||||
echo "$json" >&2
|
||||
exit 1
|
||||
fi
|
||||
api_or_die "check for an existing PR" "${GITEA_API}/repos/${owner}/${repo}/pulls?state=open"
|
||||
existing_url=$(echo "$json" | jq -r --arg head "$current_branch" --arg base "$base" \
|
||||
'.[] | select(.head.ref == $head and .base.ref == $base) | .html_url' | head -1)
|
||||
if [ -n "$existing_url" ]; then
|
||||
@@ -165,63 +200,122 @@ ${body}"
|
||||
title="$current_branch"
|
||||
fi
|
||||
|
||||
response=$(gitea_api -X POST "${GITEA_API}/repos/${owner}/${repo}/pulls" \
|
||||
api_or_die "create PR" -X POST "${GITEA_API}/repos/${owner}/${repo}/pulls" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$(jq -n --arg head "$current_branch" --arg base "$base" --arg title "$title" --arg body "$body" \
|
||||
'{head: $head, base: $base, title: $title, body: $body}')")
|
||||
http_code=$(echo "$response" | tail -1)
|
||||
json=$(echo "$response" | sed '$d')
|
||||
|
||||
if [ "$http_code" -lt 200 ] || [ "$http_code" -ge 300 ]; then
|
||||
echo "Failed to create PR (HTTP $http_code):" >&2
|
||||
echo "$json" >&2
|
||||
'{head: $head, base: $base, title: $title, body: $body}')"
|
||||
echo "$json" | jq -r '.html_url'
|
||||
;;
|
||||
edit)
|
||||
require_number "${1:-}"
|
||||
pr_number="$1"
|
||||
shift
|
||||
parse_body_args "$@"
|
||||
if [ -n "$parsed_base" ]; then
|
||||
echo "gitea-pr edit cannot change the base branch" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "$parsed_title" ] && [ -z "$body" ]; then
|
||||
echo "gitea-pr edit requires --title, --body or --body-file" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Only the fields actually given are sent: an omitted key leaves that
|
||||
# field alone, so editing a title cannot blank a body by accident.
|
||||
payload='{}'
|
||||
if [ -n "$parsed_title" ]; then
|
||||
payload=$(echo "$payload" | jq --arg t "$parsed_title" '. + {title: $t}')
|
||||
fi
|
||||
if [ -n "$body" ]; then
|
||||
# The same marker create writes. It describes where the body came
|
||||
# from rather than the event that wrote it, so an edited body
|
||||
# keeps the disclosure instead of quietly shedding it.
|
||||
payload=$(echo "$payload" | jq --arg b "**Summary generated by LLM agent**
|
||||
|
||||
$body" '. + {body: $b}')
|
||||
fi
|
||||
|
||||
api_or_die "edit PR #${pr_number}" -X PATCH \
|
||||
"${GITEA_API}/repos/${owner}/${repo}/pulls/${pr_number}" \
|
||||
-H "Content-Type: application/json" -d "$payload"
|
||||
echo "$json" | jq -r '.html_url'
|
||||
;;
|
||||
comments)
|
||||
pr_number="${1:-}"
|
||||
shift || true
|
||||
show_all=0
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--all) show_all=1 ;;
|
||||
*) echo "Unknown argument: $1" >&2
|
||||
echo "Usage: gitea-pr comments PR_NUMBER [--all]" >&2
|
||||
exit 1 ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
if [ -z "$pr_number" ]; then
|
||||
echo "Usage: gitea-pr comments PR_NUMBER" >&2
|
||||
echo "Usage: gitea-pr comments PR_NUMBER [--all]" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "=== Issue comments ==="
|
||||
response=$(gitea_api "${GITEA_API}/repos/${owner}/${repo}/issues/${pr_number}/comments")
|
||||
http_code=$(echo "$response" | tail -1)
|
||||
json=$(echo "$response" | sed '$d')
|
||||
if [ "$http_code" -lt 200 ] || [ "$http_code" -ge 300 ]; then
|
||||
echo "Failed to fetch issue comments (HTTP $http_code):" >&2
|
||||
echo "$json" >&2
|
||||
exit 1
|
||||
fi
|
||||
api_or_die "fetch issue comments" \
|
||||
"${GITEA_API}/repos/${owner}/${repo}/issues/${pr_number}/comments"
|
||||
echo "$json" | jq -r '.[] | "[\(.user.login) @ \(.created_at)]\n\(.body)\n"'
|
||||
|
||||
echo "=== Reviews ==="
|
||||
response=$(gitea_api "${GITEA_API}/repos/${owner}/${repo}/pulls/${pr_number}/reviews")
|
||||
http_code=$(echo "$response" | tail -1)
|
||||
reviews_json=$(echo "$response" | sed '$d')
|
||||
if [ "$http_code" -lt 200 ] || [ "$http_code" -ge 300 ]; then
|
||||
echo "Failed to fetch reviews (HTTP $http_code):" >&2
|
||||
echo "$reviews_json" >&2
|
||||
exit 1
|
||||
fi
|
||||
api_or_die "fetch reviews" "${GITEA_API}/repos/${owner}/${repo}/pulls/${pr_number}/reviews"
|
||||
reviews_json="$json"
|
||||
echo "$reviews_json" | jq -r '.[] | select(.body != "") | "[\(.user.login) @ \(.submitted_at) — \(.state)]\n\(.body)\n"'
|
||||
|
||||
echo "=== Inline review comments ==="
|
||||
review_ids=$(echo "$reviews_json" | jq -r '.[] | select(.comments_count > 0) | .id')
|
||||
all_comments='[]'
|
||||
for review_id in $review_ids; do
|
||||
response=$(gitea_api \
|
||||
"${GITEA_API}/repos/${owner}/${repo}/pulls/${pr_number}/reviews/${review_id}/comments")
|
||||
http_code=$(echo "$response" | tail -1)
|
||||
json=$(echo "$response" | sed '$d')
|
||||
if [ "$http_code" -lt 200 ] || [ "$http_code" -ge 300 ]; then
|
||||
echo "Failed to fetch comments for review ${review_id} (HTTP $http_code):" >&2
|
||||
echo "$json" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "$json" | jq -r '.[] | "[\(.user.login) @ \(.path):\(.line // "?")]\n\(.body)\n"'
|
||||
api_or_die "fetch comments for review ${review_id}" \
|
||||
"${GITEA_API}/repos/${owner}/${repo}/pulls/${pr_number}/reviews/${review_id}/comments"
|
||||
# The hunk, not just the path. Gitea's review-comment objects carry
|
||||
# no `line` field at all - the old `.line // "?"` could only ever
|
||||
# print `?` - but they do carry `diff_hunk`, which is the exact
|
||||
# text the comment was left against. Without it a reviewer's
|
||||
# "this is confusing" arrives with nothing to attach it to, and
|
||||
# whoever acts on the review has to guess which passage was meant.
|
||||
# `position` is the index into the diff rather than a file line, so
|
||||
# it is printed as a diff offset and not as something to jump to.
|
||||
all_comments=$(printf '%s\n%s' "$all_comments" "$json" | jq -s 'add')
|
||||
done
|
||||
|
||||
# A thread is every comment sharing a path and a diff position, and it
|
||||
# is resolved if ANY of them carries a resolver. Gitea stamps only a
|
||||
# thread's root when you resolve it, so a reply inside a closed thread
|
||||
# comes back with `resolver: null` and reads as open if each comment is
|
||||
# judged alone. That is not hypothetical: it is how a closed-and-refiled
|
||||
# thread on #96 presented as still needing an answer.
|
||||
#
|
||||
# Note what is NOT used here. A review's `state` (COMMENT vs
|
||||
# REQUEST_CHANGES) and whether comments arrived grouped under one review
|
||||
# or singly both describe how the reviewer submitted, not whether
|
||||
# anything is outstanding. Reading either as a resolution signal works
|
||||
# until a reviewer changes how they file, and then it silently stops.
|
||||
threads=$(printf '%s' "$all_comments" | jq '
|
||||
group_by(.path + "@" + (.position | tostring))
|
||||
| map({resolved: any(.[]; .resolver != null), comments: .})')
|
||||
resolved_count=$(printf '%s' "$threads" | jq '[.[] | select(.resolved)] | length')
|
||||
|
||||
if [ "$show_all" -eq 1 ]; then
|
||||
visible="$threads"
|
||||
else
|
||||
visible=$(printf '%s' "$threads" | jq '[.[] | select(.resolved | not)]')
|
||||
fi
|
||||
|
||||
printf '%s' "$visible" | jq -r '.[]
|
||||
| .resolved as $r
|
||||
| .comments[]
|
||||
| "[\(.user.login) @ \(.path) @@\(.position // "?")\(if $r then " (resolved)" else "" end)]\n\(.diff_hunk // "(no hunk recorded)")\n--- comment ---\n\(.body)\n"'
|
||||
|
||||
if [ "$show_all" -ne 1 ] && [ "$resolved_count" -gt 0 ]; then
|
||||
echo "($resolved_count resolved thread(s) hidden. Re-run with --all to include them.)"
|
||||
fi
|
||||
;;
|
||||
merge)
|
||||
pr_number="${1:-}"
|
||||
@@ -230,17 +324,9 @@ ${body}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
response=$(gitea_api -X POST "${GITEA_API}/repos/${owner}/${repo}/pulls/${pr_number}/merge" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"Do": "merge"}')
|
||||
http_code=$(echo "$response" | tail -1)
|
||||
json=$(echo "$response" | sed '$d')
|
||||
|
||||
if [ "$http_code" -lt 200 ] || [ "$http_code" -ge 300 ]; then
|
||||
echo "Failed to merge PR #${pr_number} (HTTP $http_code):" >&2
|
||||
echo "$json" >&2
|
||||
exit 1
|
||||
fi
|
||||
api_or_die "merge PR #${pr_number}" -X POST \
|
||||
"${GITEA_API}/repos/${owner}/${repo}/pulls/${pr_number}/merge" \
|
||||
-H "Content-Type: application/json" -d '{"Do": "merge"}'
|
||||
echo "Merged PR #${pr_number}"
|
||||
;;
|
||||
esac
|
||||
|
||||
@@ -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"
|
||||
|
||||
+295
-55
@@ -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
|
||||
@@ -557,7 +600,10 @@ cmp_() { "$AUDIT" compare "$WS" "$STATE" 2>&1; }
|
||||
printf 'keep = 10\n' >> "$CONFIG_DIR/config"
|
||||
snap; git -C "$WS" config user.name changed
|
||||
cmp_ >/dev/null 2>&1
|
||||
run bash -c "ls '$AUDITDIR/$slug' | wc -l"
|
||||
# *.txt, not every entry: a finding with retained content also leaves a
|
||||
# <report>.d sidecar beside it, which rotation counts as part of the
|
||||
# report it belongs to rather than as another report.
|
||||
run bash -c "ls '$AUDITDIR/$slug'/*.txt | wc -l"
|
||||
[ "$output" -le 10 ]
|
||||
}
|
||||
|
||||
@@ -589,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" {
|
||||
@@ -616,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" {
|
||||
@@ -723,15 +812,118 @@ REPORT
|
||||
echo 'export X=1' > "$WS/.envrc"
|
||||
mkdir -p "$WS/.vscode"; echo '{}' > "$WS/.vscode/settings.json"
|
||||
cmp_ >/dev/null 2>&1
|
||||
# 's' on the CRITICAL .envrc finding, then 'a' on the WARNING one that
|
||||
# 'i' on the CRITICAL .envrc finding, then 'a' on the WARNING one that
|
||||
# follows it - so $BASEDIR/$slug is guaranteed to exist afterward and the
|
||||
# negative grep below can't pass vacuously against a missing file.
|
||||
run bash -c "printf 's\na\n' | '$AUDIT' ack '$WS'"
|
||||
[[ "$output" == *"cannot 'always ignore' a CRITICAL surface - approve the exact content or skip."* ]]
|
||||
run bash -c "printf 'i\na\n' | '$AUDIT' ack '$WS'"
|
||||
[[ "$output" == *"cannot ignore a CRITICAL surface from now on"* ]]
|
||||
[ -f "$BASEDIR/$slug" ]
|
||||
! grep -q '^spam \.envrc' "$BASEDIR/$slug"
|
||||
}
|
||||
|
||||
@test "a finding the baseline already covers is not presented again" {
|
||||
# #65's headline bug. The approval was always written correctly; `ack`
|
||||
# just never consulted the baseline when deciding what to SHOW, so every
|
||||
# later run re-presented the same CRITICAL and appended a duplicate
|
||||
# entry. Reported as "approvals are not being recorded", which is what it
|
||||
# looks like from the outside.
|
||||
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'"
|
||||
[ "$(grep -c '^safe .envrc' "$BASEDIR/$slug")" -eq 1 ]
|
||||
|
||||
run bash -c "printf 'a\n' | '$AUDIT' ack '$WS'"
|
||||
[[ "$output" != *".envrc"* ]]
|
||||
[[ "$output" == *"already dealt with"* ]]
|
||||
# And no second entry for the same decision.
|
||||
[ "$(grep -c '^safe .envrc' "$BASEDIR/$slug")" -eq 1 ]
|
||||
}
|
||||
|
||||
@test "a report whose CRITICALs were approved through an earlier report stops nagging" {
|
||||
# Two reports can list the same finding. Approving it in the first left
|
||||
# the second permanently -CRITICAL.txt, so bin/dejima warned about a
|
||||
# report whose every finding had been dealt with, and `ack` re-presented
|
||||
# it forever. The rename now runs on a pass that records nothing new.
|
||||
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
|
||||
snap; echo 'export X=1' > "$WS/.envrc"; cmp_ >/dev/null 2>&1
|
||||
# A second report listing the same, still-unapproved finding.
|
||||
cp "$AUDITDIR/$slug"/*-CRITICAL.txt "$AUDITDIR/$slug/2020-01-01-000000-CRITICAL.txt"
|
||||
run bash -c "printf 'a\n' | '$AUDIT' ack '$WS'"
|
||||
[[ "$output" == *"recorded 1 acknowledgement"* ]]
|
||||
# The remaining report is walked with nothing left to decide, and sheds
|
||||
# its suffix rather than staying unacknowledged forever.
|
||||
run bash -c "printf 'a\n' | '$AUDIT' ack '$WS'"
|
||||
[[ "$output" == *"already dealt with"* ]]
|
||||
run bash -c "ls '$AUDITDIR/$slug'"
|
||||
[[ "$output" != *"-CRITICAL.txt"* ]]
|
||||
}
|
||||
|
||||
@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')"
|
||||
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 '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.
|
||||
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"
|
||||
run bash -c "printf 'q\n' | '$AUDIT' ack '$WS'"
|
||||
! grep -q '^reviewed ' "$BASEDIR/$slug" 2>/dev/null
|
||||
|
||||
run bash -c "printf 'a\n' | '$AUDIT' ack '$WS'"
|
||||
[[ "$output" == *".envrc"* ]]
|
||||
}
|
||||
|
||||
@test "a WARNING git config finding can be acknowledged, both ways (#45)" {
|
||||
# Neither `ack` nor `compare` handled this tier: no arm derived an id for
|
||||
# it, and the two branches that emit it never consulted the baseline. It
|
||||
# was the one class of finding with no way to curate it at all.
|
||||
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
|
||||
snap; git -C "$WS" config user.name changed-in-session; cmp_ >/dev/null 2>&1
|
||||
run bash -c "printf 'i\n' | '$AUDIT' ack '$WS'"
|
||||
grep -q '^spam gitconfig:user.name' "$BASEDIR/$slug"
|
||||
# ...and the surface stays quiet whatever it changes to next, which is
|
||||
# the point for a key some tool rewrites on every run.
|
||||
snap; git -C "$WS" config user.name changed-again
|
||||
run cmp_
|
||||
[[ "$output" != *"user.name"* ]]
|
||||
}
|
||||
|
||||
@test "an approved WARNING git config value re-fires when the value changes" {
|
||||
# The other verb: `safe` pins the exact value, so a different one is a
|
||||
# new decision rather than covered by the old one.
|
||||
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
|
||||
snap; git -C "$WS" config user.name first; cmp_ >/dev/null 2>&1
|
||||
run bash -c "printf 'a\n' | '$AUDIT' ack '$WS'"
|
||||
grep -q '^safe gitconfig:user.name ' "$BASEDIR/$slug"
|
||||
snap; git -C "$WS" config user.name second
|
||||
run cmp_
|
||||
[[ "$output" == *"changed git config: user.name=second"* ]]
|
||||
}
|
||||
|
||||
@test "ack emits no colour when its output is not a terminal" {
|
||||
# Reports and piped output are read back by machines - the audit's own
|
||||
# next run, and every test here. An escape sequence in either is a bug,
|
||||
# so the palette is decided from the stream rather than from a flag.
|
||||
snap; echo 'export X=1' > "$WS/.envrc"; cmp_ >/dev/null 2>&1
|
||||
run bash -c "printf 'a\n' | '$AUDIT' ack '$WS'"
|
||||
[[ "$output" != *$'\033'* ]]
|
||||
run cmp_
|
||||
[[ "$output" != *$'\033'* ]]
|
||||
}
|
||||
|
||||
@test "ack records a safe entry and clears the CRITICAL report marker" {
|
||||
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
|
||||
snap; echo 'export X=1' > "$WS/.envrc"; cmp_ >/dev/null 2>&1
|
||||
@@ -741,14 +933,6 @@ REPORT
|
||||
[[ "$output" != *"-CRITICAL.txt"* ]]
|
||||
}
|
||||
|
||||
@test "ack does not write an unmatchable entry for a non-path finding" {
|
||||
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
|
||||
snap; git -C "$WS" config user.name changed; cmp_ >/dev/null 2>&1
|
||||
run bash -c "printf 'a\n' | '$AUDIT' ack '$WS'"
|
||||
[[ "$output" == *"cannot"* ]]
|
||||
[ ! -f "$BASEDIR/$slug" ]
|
||||
}
|
||||
|
||||
@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'"
|
||||
@@ -777,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" {
|
||||
@@ -937,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"
|
||||
@@ -978,11 +1178,11 @@ REPORT
|
||||
git -C "$WS" config core.hooksPath /tmp/evil
|
||||
mkdir -p "$WS/.vscode"; echo '{}' > "$WS/.vscode/settings.json"
|
||||
cmp_ >/dev/null 2>&1
|
||||
# 's' on the CRITICAL gitconfig finding, then 'a' on the WARNING one that
|
||||
# 'i' on the CRITICAL gitconfig finding, then 'a' on the WARNING one that
|
||||
# follows it - so $BASEDIR/$slug is guaranteed to exist afterward and the
|
||||
# negative grep below can't pass vacuously against a missing file.
|
||||
run bash -c "printf 's\na\n' | '$AUDIT' ack '$WS'"
|
||||
[[ "$output" == *"cannot 'always ignore' a CRITICAL surface - approve the exact content or skip."* ]]
|
||||
run bash -c "printf 'i\na\n' | '$AUDIT' ack '$WS'"
|
||||
[[ "$output" == *"cannot ignore a CRITICAL surface from now on"* ]]
|
||||
[ -f "$BASEDIR/$slug" ]
|
||||
! grep -q '^spam gitconfig:core.hooksPath' "$BASEDIR/$slug"
|
||||
}
|
||||
@@ -995,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" {
|
||||
@@ -1005,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" {
|
||||
@@ -1020,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" {
|
||||
@@ -1035,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" {
|
||||
@@ -1056,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"
|
||||
}
|
||||
|
||||
@@ -1201,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" {
|
||||
@@ -1730,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; }
|
||||
}
|
||||
|
||||
+17
-16
@@ -18,14 +18,15 @@
|
||||
# a CI runner and there is nothing here to assert.
|
||||
#
|
||||
# Its values are the names of the jobs whose environment they verify -
|
||||
# `sandbox` and `host-docker` - rather than a separate vocabulary. A reader
|
||||
# looking at a red `runner-env (host-docker)` should not have to work out
|
||||
# `confined` and `privileged` - rather than a separate vocabulary. Those are
|
||||
# the same words the runner labels and dejima-ci-hardener's roles use, so a
|
||||
# reader looking at a red `runner-env (privileged)` should not have to work out
|
||||
# which job that corresponds to.
|
||||
|
||||
setup() {
|
||||
[ -n "${DEJIMA_CI_TIER:-}" ] || skip "not on a CI runner (DEJIMA_CI_TIER unset)"
|
||||
case "$DEJIMA_CI_TIER" in
|
||||
sandbox|host-docker) ;;
|
||||
confined|privileged) ;;
|
||||
*) echo "unknown DEJIMA_CI_TIER: $DEJIMA_CI_TIER" >&2; return 1 ;;
|
||||
esac
|
||||
}
|
||||
@@ -53,8 +54,8 @@ PY
|
||||
|
||||
# --- the grant that decides everything --------------------------------------
|
||||
|
||||
@test "the sandbox jobs are not handed the Docker daemon" {
|
||||
[ "$DEJIMA_CI_TIER" = sandbox ] || skip "asserted for the sandbox runner"
|
||||
@test "the confined jobs are not handed the Docker daemon" {
|
||||
[ "$DEJIMA_CI_TIER" = confined ] || skip "asserted for the confined runner"
|
||||
# act_runner mounts /var/run/docker.sock into every job container unless
|
||||
# container.docker_host is "-". An unset value is the default, and the
|
||||
# default mounts - so this is the assertion that a runner was actually
|
||||
@@ -67,11 +68,11 @@ PY
|
||||
}
|
||||
}
|
||||
|
||||
@test "the host-docker job does have the daemon it is scheduled for" {
|
||||
[ "$DEJIMA_CI_TIER" = host-docker ] || skip "asserted for the host-docker runner"
|
||||
# The positive control for the label routing. Without it, a host-docker
|
||||
@test "the privileged job does have the daemon it is scheduled for" {
|
||||
[ "$DEJIMA_CI_TIER" = privileged ] || skip "asserted for the privileged runner"
|
||||
# The positive control for the label routing. Without it, a privileged
|
||||
# job that landed on a hardened runner would fail later and obscurely, and
|
||||
# a sandbox job that landed on this one would be caught only by the test
|
||||
# a confined job that landed on this one would be caught only by the test
|
||||
# above - one direction of a two-direction problem.
|
||||
[ -e /var/run/docker.sock ]
|
||||
}
|
||||
@@ -80,7 +81,7 @@ PY
|
||||
# Whatever else is true, exactly one of them gets the daemon. If both jobs
|
||||
# report the same answer here, the labels are not routing to two different
|
||||
# runners and the tiering exists only on paper.
|
||||
if [ "$DEJIMA_CI_TIER" = sandbox ]; then
|
||||
if [ "$DEJIMA_CI_TIER" = confined ]; then
|
||||
[ ! -e /var/run/docker.sock ]
|
||||
else
|
||||
[ -e /var/run/docker.sock ]
|
||||
@@ -89,7 +90,7 @@ PY
|
||||
|
||||
# --- egress, for the tier the rules actually apply to ------------------------
|
||||
#
|
||||
# Deliberately scoped to the sandbox runner. host-docker's job runs in the
|
||||
# Deliberately scoped to the confined runner. The privileged job runs in the
|
||||
# HOST network namespace - that is the grant they exist for - so the bridge
|
||||
# rules dejima-ci-hardener installs do not sit in front of them at all. Their
|
||||
# egress is the host's own, which this repository's examples do not define.
|
||||
@@ -97,7 +98,7 @@ PY
|
||||
# the failure mode this whole suite is about.
|
||||
|
||||
@test "an allowlisted destination is reachable" {
|
||||
[ "$DEJIMA_CI_TIER" = sandbox ] || skip "bridge rules do not apply in the host netns"
|
||||
[ "$DEJIMA_CI_TIER" = confined ] || skip "bridge rules do not apply in the host netns"
|
||||
# The positive control for everything below. Without it, a runner with no
|
||||
# network at all passes every negative assertion in this file.
|
||||
run getent hosts "$ALLOWED_HOST"
|
||||
@@ -113,7 +114,7 @@ PY
|
||||
}
|
||||
|
||||
@test "a name that is on no allowlist does not resolve" {
|
||||
[ "$DEJIMA_CI_TIER" = sandbox ] || skip "bridge rules do not apply in the host netns"
|
||||
[ "$DEJIMA_CI_TIER" = confined ] || skip "bridge rules do not apply in the host netns"
|
||||
# .invalid is reserved by RFC 2606 precisely so that it never resolves
|
||||
# anywhere - but here it has to be REFUSED by a resolver that answers for
|
||||
# allowlisted names, which the positive control above establishes.
|
||||
@@ -122,7 +123,7 @@ PY
|
||||
}
|
||||
|
||||
@test "a job cannot reach a resolver of its own choosing" {
|
||||
[ "$DEJIMA_CI_TIER" = sandbox ] || skip "bridge rules do not apply in the host netns"
|
||||
[ "$DEJIMA_CI_TIER" = confined ] || skip "bridge rules do not apply in the host netns"
|
||||
# The check most easily left out, and the one that decides whether the DNS
|
||||
# allowlist means anything: a job that can query any resolver it likes can
|
||||
# resolve anything it likes, whatever dnsmasq is configured with. Traffic
|
||||
@@ -139,7 +140,7 @@ PY
|
||||
}
|
||||
|
||||
@test "a destination that is on no allowlist is unreachable" {
|
||||
[ "$DEJIMA_CI_TIER" = sandbox ] || skip "bridge rules do not apply in the host netns"
|
||||
[ "$DEJIMA_CI_TIER" = confined ] || skip "bridge rules do not apply in the host netns"
|
||||
# An IP literal rather than a name, so this tests the firewall rather than
|
||||
# the resolver. Reaching it by name would fail at DNS first and prove
|
||||
# nothing about DOCKER-USER.
|
||||
@@ -160,7 +161,7 @@ PY
|
||||
}
|
||||
|
||||
@test "IPv6 egress is not an unfiltered way around all of the above" {
|
||||
[ "$DEJIMA_CI_TIER" = sandbox ] || skip "bridge rules do not apply in the host netns"
|
||||
[ "$DEJIMA_CI_TIER" = confined ] || skip "bridge rules do not apply in the host netns"
|
||||
# An address family with no allowlist is not a smaller hole than the one
|
||||
# the rules above close; on a runner with IPv6 egress it is the same hole.
|
||||
#
|
||||
|
||||
+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
|
||||
|
||||
+259
-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=""
|
||||
@@ -207,6 +196,7 @@ teardown() {
|
||||
docker rm -f "dejima-test-sshkey-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
|
||||
docker rm -f "dejima-test-ghpush-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
|
||||
docker rm -f "dejima-test-ghforge-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
|
||||
docker rm -f "dejima-test-ghextra-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
|
||||
docker rm -f "dejima-test-claudejson-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
|
||||
docker rm -f "dejima-test-rebind-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
|
||||
docker rm -f "dejima-test-overlays-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
|
||||
@@ -220,6 +210,8 @@ teardown() {
|
||||
docker rm -f "dejima-test-tls-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
|
||||
docker rm -f "dejima-test-nohosts-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
|
||||
docker rm -f "dejima-test-badtransport-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
|
||||
docker rm -f "dejima-test-notifydir-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
|
||||
docker rm -f "dejima-test-nonotify-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
dexec() {
|
||||
@@ -368,6 +360,26 @@ dexec_node() {
|
||||
[ -n "$output" ]
|
||||
}
|
||||
|
||||
@test "the session user cannot address a resolver directly, so the DNS allowlist is not bypassable" {
|
||||
# init-firewall.sh opens UDP/TCP 53 to any destination for root-owned
|
||||
# sockets, because that is what Docker's embedded resolver forwards
|
||||
# external names from and without it nothing in here resolves at all.
|
||||
# This is the assertion that the opening stops there. `node` is the
|
||||
# session user and there is no sudo in the image, so if it could reach a
|
||||
# resolver of its own choosing it could carry data out in the query name
|
||||
# itself - the exact thing dns-proxy.sh exists to prevent, and something
|
||||
# no allowlist of *names* can catch once the *resolver* is attacker-chosen.
|
||||
#
|
||||
# 1.1.1.1 rather than an unrouted address: it answers if it is reachable,
|
||||
# so a pass here means the packet was stopped and not that the probe was
|
||||
# aimed at nothing.
|
||||
run dexec_node dig +short registry.npmjs.org
|
||||
[ -n "$output" ]
|
||||
|
||||
run dexec_node dig +time=2 +tries=1 @1.1.1.1 definitely-not-allowed.example.org
|
||||
[ "$status" -ne 0 ]
|
||||
}
|
||||
|
||||
@test "api.anthropic.com resolves in proxy-off mode (kill switch / API-key auth)" {
|
||||
# api.anthropic.com is left off the allowlist when the credential proxy is
|
||||
# on, so a bypass of the /etc/hosts pin that resolves the name by DNS gets
|
||||
@@ -385,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)" {
|
||||
@@ -468,6 +502,54 @@ dexec_node() {
|
||||
docker rm -f "$test_container" >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
@test "a GitHub name declared as an extra domain stays port-restricted, so push stays unreachable" {
|
||||
# .dejima/allowed-domains is read with the project and is meant to widen
|
||||
# what a session can reach. It must not widen how. GitHub's addresses are
|
||||
# confined to 443 by init-firewall.sh, and that is the entire read-only
|
||||
# guarantee - with 22 shut there is no transport left for the forwarded
|
||||
# ssh-agent's key to push with, whatever a hook does or does not catch.
|
||||
#
|
||||
# allowed-domains carries no port restriction, so before ipset_for_domain
|
||||
# this path put GitHub's address there and opened 22. It surfaced as an
|
||||
# intermittent startup failure rather than as a silent hole only because
|
||||
# the port-22 self-check re-resolves the name: it catches the hole when
|
||||
# GitHub's second answer matches the first, and misses it when the name
|
||||
# has rotated in between. This test does not depend on that coin flip.
|
||||
local test_container="dejima-test-ghextra-$(basename "$BATS_FILE_TMPDIR")"
|
||||
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:-}" \
|
||||
-e DEJIMA_FORGE_HOSTS= \
|
||||
-e ALLOWED_DOMAINS="github.com api.github.com" \
|
||||
--entrypoint /usr/local/bin/entrypoint.sh \
|
||||
"$IMAGE" >/dev/null
|
||||
|
||||
# Coming up at all is part of the assertion: the port-22 self-check runs
|
||||
# during startup, and with the address in the wrong set it fails the boot.
|
||||
wait_ready "$test_container"
|
||||
|
||||
# Resolved inside the container, through the same dnsmasq that answered
|
||||
# during startup - so this asks about the address actually allowlisted,
|
||||
# not one a fresh lookup from elsewhere happened to return.
|
||||
local ip
|
||||
ip=$(docker exec "$test_container" dig +short A github.com | head -1)
|
||||
[ -n "$ip" ]
|
||||
|
||||
run docker exec "$test_container" ipset test github-domains "$ip"
|
||||
[ "$status" -eq 0 ]
|
||||
|
||||
run docker exec "$test_container" ipset test allowed-domains "$ip"
|
||||
[ "$status" -ne 0 ]
|
||||
|
||||
# And the property those two sets exist to produce.
|
||||
run docker exec "$test_container" timeout 5 bash -c "cat < /dev/null > /dev/tcp/github.com/22"
|
||||
[ "$status" -ne 0 ]
|
||||
|
||||
docker rm -f "$test_container" >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
@test "a configured forge host is exempted from stop-dns-rebind, other allowed domains are not" {
|
||||
# Passed as DEJIMA_FORGE_HOSTS, not GITEA_SSH_HOST: the container no longer
|
||||
# derives hostnames from any one forge's variables - bin/dejima resolves
|
||||
@@ -523,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
|
||||
@@ -1272,3 +1421,78 @@ EOF
|
||||
[[ "$logs" == *"'tls'"* ]] || dump "the refusal did not name the valid values"
|
||||
[[ "$logs" == *"'loopback'"* ]] || dump "the refusal did not name the valid values"
|
||||
}
|
||||
|
||||
# The three-symptom failure entrypoint.sh's marker-directory check exists to
|
||||
# name: notifications stop, the session vanishes from dejima-attach, and the
|
||||
# notify hook reports EACCES - with no obvious moment they started.
|
||||
#
|
||||
# It is Docker's answer to a missing bind source that produces it. bin/dejima
|
||||
# creates the marker directory per launch and removes it on exit, so a
|
||||
# container that is RESTARTED rather than relaunched has no source left, and
|
||||
# Docker creates one root-owned rather than refusing. The directory then
|
||||
# exists, so the hook's own `[ -d ]` guard passes and the write fails instead.
|
||||
@test "a marker directory the sandbox user cannot write is reported at startup" {
|
||||
# Its own container, not container_name() - that one is created in
|
||||
# setup_file and used by every dexec test in this file. Starting a second
|
||||
# container under that name conflicts, and removing it at the end of this
|
||||
# body would take the shared one with it.
|
||||
local container="dejima-test-notifydir-$(basename "$BATS_FILE_TMPDIR")"
|
||||
local host_dir; host_dir="$(mktemp -d)"
|
||||
# Unwritable by the sandbox user, which is the property the check keys on.
|
||||
# Built here rather than by removing a bind source, so the test does not
|
||||
# depend on Docker's fallback behaviour to set up a test OF the
|
||||
# consequences of that behaviour.
|
||||
#
|
||||
# chmod rather than mounting :ro. A read-only mount would make the
|
||||
# "not repaired" assertion below vacuous - root could not chown it even
|
||||
# if entrypoint.sh tried. Mode 555 leaves it repairable by root inside the
|
||||
# container and still unwritable by the session user, so that assertion
|
||||
# means something.
|
||||
chmod 555 "$host_dir"
|
||||
|
||||
docker run -d --name "$container" \
|
||||
--cap-add=NET_ADMIN --cap-add=NET_RAW \
|
||||
--network "$(network_name)" \
|
||||
-e SANDBOX_TEST_MODE=1 \
|
||||
-e GH_TOKEN="${GH_TOKEN:-}" \
|
||||
-e DEJIMA_NOTIFY_DIR=/run/dejima-notify \
|
||||
-v "$host_dir:/run/dejima-notify" \
|
||||
--entrypoint /usr/local/bin/entrypoint.sh \
|
||||
"$IMAGE" >/dev/null
|
||||
wait_ready "$container"
|
||||
|
||||
run settled_logs "$container"
|
||||
[[ "$output" == *"not writable by"* ]]
|
||||
[[ "$output" == *"RESTARTED rather than"* ]]
|
||||
# Reported, not repaired: chowning would restore notifications while the
|
||||
# session stayed absent from a registry this container never sees.
|
||||
#
|
||||
# Asserted as "still not writable by the session user" rather than "still
|
||||
# owned by root": ownership depends on who ran the suite, since mktemp -d
|
||||
# follows the caller, while writability is the property the check reads
|
||||
# and the one repairing it would change.
|
||||
run docker exec -u node "$container" test -w /run/dejima-notify
|
||||
[ "$status" -ne 0 ]
|
||||
|
||||
rm -rf "$host_dir"
|
||||
}
|
||||
|
||||
# Remote access is opt-in and off by default. The check above must say nothing
|
||||
# at all for the sessions that never asked for it, which is every session by
|
||||
# default - a warning about an unconfigured optional feature is noise on every
|
||||
# start, and noise is what stops warnings being read.
|
||||
@test "no marker-directory warning when remote access is not enabled" {
|
||||
local container="dejima-test-nonotify-$(basename "$BATS_FILE_TMPDIR")"
|
||||
docker run -d --name "$container" \
|
||||
--cap-add=NET_ADMIN --cap-add=NET_RAW \
|
||||
--network "$(network_name)" \
|
||||
-e SANDBOX_TEST_MODE=1 \
|
||||
-e GH_TOKEN="${GH_TOKEN:-}" \
|
||||
--entrypoint /usr/local/bin/entrypoint.sh \
|
||||
"$IMAGE" >/dev/null
|
||||
wait_ready "$container"
|
||||
|
||||
run settled_logs "$container"
|
||||
[[ "$output" != *"dejima-notify"* ]]
|
||||
[[ "$output" != *"remote access is enabled"* ]]
|
||||
}
|
||||
|
||||
+869
-52
File diff suppressed because it is too large
Load Diff
@@ -44,5 +44,19 @@ Git workflow - do this even though nothing stops you from doing otherwise:
|
||||
crash, a restart, or a destructive command it is warning about will get
|
||||
the call blocked, which looks like a broken tool rather than what it is.
|
||||
Writing the file first keeps the prose out of the command entirely.
|
||||
`{{issue_command}}` (list/view/create/comment/close/reopen) takes the same
|
||||
flag, for recording a problem you found but aren't fixing.
|
||||
`{{issue_command}}` (list/view/create/edit/comment/close/reopen) takes the
|
||||
same flag, for recording a problem you found but aren't fixing.
|
||||
- Do not hard-wrap prose in those bodies. Write each paragraph as one long
|
||||
line and separate paragraphs with a blank line. The forge renders a single
|
||||
newline as a line break, so text you wrapped at a column arrives with a
|
||||
break every ten words: it wraps early on a desktop, and on a phone the
|
||||
screen breaks the line first and your newline breaks it again a few words
|
||||
later, which is much harder to read than either alone. Keep the breaks
|
||||
that carry structure - list items, table rows, headings, and anything
|
||||
inside a code fence - and drop the ones that were only there to keep the
|
||||
source tidy. The tools send the body through exactly as written; nothing
|
||||
reflows it for you.
|
||||
- To revise a body after the fact, use `{{pr_command}} edit <N> --body-file`
|
||||
(or `{{issue_command}} edit <N>`). Rewriting the file you built the body
|
||||
from changes nothing on its own - the forge has its own copy from the
|
||||
moment you created it, so an edit has to be sent.
|
||||
|
||||
Reference in New Issue
Block a user