Compare commits

...
Author SHA1 Message Date
branderson 77b2d02866 Say that the self-hosting caveat needs dejima's own checkout, and watch tools/
tests / confined (proxy) (pull_request_target) Successful in 1m17s
tests / confined (conversation) (pull_request_target) Successful in 1m29s
tests / runner-env (confined) (pull_request_target) Successful in 40s
tests / confined (shellcheck) (pull_request_target) Successful in 1m1s
tests / confined (providers) (pull_request_target) Successful in 3m6s
tests / privileged (pull_request_target) Successful in 4m16s
tests / confined (remote) (pull_request_target) Successful in 4m29s
tests / runner-env (privileged) (pull_request_target) Successful in 42s
tests / confined (static) (pull_request_target) Successful in 5m53s
tests / confined (project) (pull_request_target) Successful in 6m20s
tests / confined (audit) (pull_request_target) Successful in 10m6s
tests / confined (config) (pull_request_target) Successful in 11m39s
Review of #247 asked whether the scope was expressed, and it was not - the
precondition appeared once, as a subordinate clause in a different paragraph,
while the paragraphs drawing the consequences carried none of it. Read
straight through, "Provider trust" describes an exposure every user has.

Nothing mounts forges/, workflows/, harnesses/, tools/ or the rest of the
checkout into a container: the resolved values go in, the files do not. So a
session pointed at any other project cannot read those paths, let alone write
them. The whole section is about one configuration - dejima sandboxing the
repository it is installed from - and now says so before the consequences
rather than in the middle of them.

Checking that turned up the claim being unbacked. docs/security.md said the
provider directories are in the audit's TCB list "for that reason", and #247
appended tools/ to the same sentence - but the audit's list was `bin hooks
docker test forges workflows harnesses`, with no tools. The doc asserted a
watch that did not exist. tools/ is in the list now.

The launch warning had the mirror problem, listing bin/, hooks/, docker/ and
install.sh while the audit watched those plus test/ and every manifest
directory. A warning that under-names the writable surface is one an operator
reads as complete.

Three tests, all mutation-checked. The first derives from what is on disk -
a directory shipping manifests that the audit does not watch fails it - so
the next axis is covered the day it lands rather than the day someone
remembers.

audit 164, static 405, providers 95, config 186, project 145.
2026-08-27 21:07:59 -07:00
branderson 0c2ceba35b Default block_merge to on, so a workflow that forgets it fails safe
tests / confined (proxy) (pull_request_target) Successful in 1m36s
tests / confined (conversation) (pull_request_target) Successful in 1m48s
tests / confined (shellcheck) (pull_request_target) Successful in 58s
tests / runner-env (confined) (pull_request_target) Successful in 39s
tests / confined (providers) (pull_request_target) Successful in 3m18s
tests / confined (remote) (pull_request_target) Successful in 4m25s
tests / confined (project) (pull_request_target) Successful in 6m17s
tests / confined (static) (pull_request_target) Successful in 5m25s
tests / confined (audit) (pull_request_target) Successful in 9m20s
tests / privileged (pull_request_target) Successful in 9m26s
tests / runner-env (privileged) (pull_request_target) Successful in 38s
tests / confined (config) (pull_request_target) Successful in 10m35s
The schema default was `off`, so a workflow manifest omitting block_merge
silently permitted merging from inside a session. Every shipped workflow sets
it explicitly and an invariant test pins that, so nothing today depends on the
default - what it decides is what happens to the next workflow someone writes,
and a guard should fail in the refusing direction.

The invariant test covering shipped workflows passes either way, so this has
its own: a fixture workflow that names no block_merge must resolve to on.

Two documentation corrections found alongside it, both release-review items
filed rather than fixed at the time:

docs/security.md's self-hosting caveat named forges/*/forge.conf as
session-writable input to the next launch and not tools/*/tool.conf, which is
the same class - the host reads it, and the image build installs what it
points at, as root (#247).

And the negative-space list did not say that Ctrl-Z suspends bin/dejima on the
host rather than the session. The container keeps running with its grants
while the terminal looks stopped, which matters most to whoever suspends one
because they are uneasy about what it is doing (#246).

Three tests, all mutation-checked. providers 95, config 186, project 145,
static 402.

Fixes #250
Fixes #246
Fixes #247
2026-08-27 18:00:26 -07:00
branderson 4d1bb99180 Merge pull request 'Make [identity.default] the account a session gets, and deprecate [core] ssh_key' (#258) from ssh-key-identity into development
tests / confined (proxy) (pull_request_target) Successful in 1m14s
tests / confined (conversation) (pull_request_target) Successful in 1m25s
tests / runner-env (confined) (pull_request_target) Successful in 38s
tests / confined (shellcheck) (pull_request_target) Successful in 1m2s
tests / confined (providers) (pull_request_target) Successful in 3m0s
tests / privileged (pull_request_target) Successful in 3m49s
tests / runner-env (privileged) (pull_request_target) Successful in 37s
tests / confined (remote) (pull_request_target) Successful in 4m22s
tests / confined (static) (pull_request_target) Successful in 5m39s
tests / confined (project) (pull_request_target) Successful in 6m9s
tests / confined (audit) (pull_request_target) Successful in 9m44s
tests / confined (config) (pull_request_target) Successful in 11m21s
Reviewed-on: #258
Reviewed-by: branderson <brad@branderson.io>
2026-08-27 03:58:23 -07:00
branderson 4163ec12cd Point the headless-push setup at [identity.default]
tests / confined (proxy) (pull_request_target) Successful in 1m15s
tests / confined (conversation) (pull_request_target) Successful in 1m29s
tests / runner-env (confined) (pull_request_target) Successful in 38s
tests / confined (shellcheck) (pull_request_target) Successful in 1m1s
tests / confined (providers) (pull_request_target) Successful in 3m6s
tests / privileged (pull_request_target) Successful in 3m57s
tests / runner-env (privileged) (pull_request_target) Successful in 41s
tests / confined (remote) (pull_request_target) Successful in 4m30s
tests / confined (static) (pull_request_target) Successful in 5m47s
tests / confined (project) (pull_request_target) Successful in 6m22s
tests / confined (audit) (pull_request_target) Successful in 9m56s
tests / confined (config) (pull_request_target) Successful in 11m33s
The section walks an operator through creating a deploy key and then shows
them writing it to the deprecated key, which would warn on every launch from
the moment they finished following it.
2026-08-27 03:46:22 -07:00
branderson 028fd8fb7f Warn about [core] ssh_key on every launch that sets it
The note only fired when nothing else named an account, so a config with
[core] ssh_key and a named identity was told nothing - and that config is one
[identity.default] away from refusing to start. Being told on the launch that
breaks is worse than being told now.

It names the key's path, read from the file rather than through config_get:
in this process config_get is bin/dejima's wrapper, so with a named identity
in force it answers with THAT identity's key, and printing it as the thing to
move would name a key the operator is not being asked to touch.

Every launch rather than once, because there is nowhere to record "once" that
a session could not reach, and a deprecation nobody sees until removal is not
a deprecation. It stops the moment the config is migrated, which is the other
half of a warning worth having.
2026-08-27 03:14:55 -07:00
branderson 69713202ad Make [identity.default] the account a session gets, and deprecate [core] ssh_key
[core] ssh_key and an identity's ssh_key were the same thing under two names,
and nothing said which won. Resolution was: the identity's if it named one,
else [core]'s - so an identity that named no key silently pushed with the
default account's, which is the mixing identities exist to prevent. The
paragraph directly beneath that rule in the docs argued against it: pushing
under the default key when you asked for another one does not fail at all,
which is the direction worth being careful about.

Identities are new in this release and [core] ssh_key is not, so this is the
moment the two spellings either converge or calcify.

[identity.default] is now the identity in force when none is named. It is
where a host's ordinary account goes, and having a name is what lets it carry
a git name and a forge credential beside the key rather than those coming
from three places.

[core] ssh_key is deprecated, not removed. A config with no [identity.default]
reads it exactly as before and says once at launch what replaced it. A config
with both is refused rather than ranked: which key a session pushed with is
not a thing to work out from precedence rules.

Credentials no longer fall through either. An identity that names some and
leaves the rest to the section beneath is refused, naming each one - the same
failure as a missing section, reached through a partial one. Only the selected
forge's keys, since refusing over a credential nothing on this launch reads
would let an unrelated section block a launch.

One gap is documented rather than left to be found: a forge key with a schema
default still answers for an identity that names none, because nothing in the
file says so.

Both refusals and the missing-section one are now a single identity_validate,
which --print-config calls too. It had its own copy of the first check, and
would otherwise have printed this machine's credentials under an identity's
name - the confusion the refusal exists to prevent.

Performance: identity_get runs on every config_get through bin/dejima's
wrapper. Resolving the identity before checking whether the key could belong
to one turned a --print-config from 2.4s to 41s, walking every forge manifest
per key to discover the key was core.harness. The cheap guard runs first now,
the manifest lookup last, and a test pins that order rather than a timing.
2.4s -> 2.6s on the worst case, which reads every key.

config 184, providers 94, static 400, project 145, remote 111, audit 164,
proxy 32, conversation 12.
2026-08-27 03:14:10 -07:00
branderson decf60fa9e Merge pull request 'Put the instructions before the essays, and cut what was padding' (#257) from prose-economy into development
Reviewed-on: #257
Reviewed-by: branderson <brad@branderson.io>
2026-08-27 03:10:45 -07:00
branderson 5c0f4b80c3 Restore three passages the economy pass should not have cut
tests / confined (proxy) (pull_request_target) Successful in 1m14s
tests / confined (conversation) (pull_request_target) Successful in 1m27s
tests / runner-env (confined) (pull_request_target) Successful in 36s
tests / confined (shellcheck) (pull_request_target) Successful in 57s
tests / confined (providers) (pull_request_target) Successful in 2m51s
tests / confined (remote) (pull_request_target) Successful in 4m20s
tests / privileged (pull_request_target) Successful in 3m58s
tests / runner-env (privileged) (pull_request_target) Successful in 39s
tests / confined (static) (pull_request_target) Successful in 5m49s
tests / confined (project) (pull_request_target) Successful in 6m3s
tests / confined (audit) (pull_request_target) Successful in 9m53s
tests / confined (config) (pull_request_target) Successful in 9m59s
All three are the project's own position rather than padding, and the pass
treated them as padding because each was short.

The default-branch claim gets its caveat back: none of the three workflows
puts work on your default branch because of how they are arranged, not
because anything is enforced against a session that has stopped cooperating.
Without it that claim reads as a guarantee.

"Understand what you ship" returns as a README bullet. It was the only one of
the ten responsible-use rules with no bullet left; the other two that lost
theirs were folded into neighbours rather than dropped.

And the claim that carries it - a good model is not a substitute for
engineering judgement or human review - is back in both places it was made,
the README bullet and the section in docs/responsible-use.md that argues for
it. Spelled "judgement" there now, matching every other use in the tree.
2026-08-27 02:54:24 -07:00
branderson 1587a154fd Fix two identity references left behind when the examples were renamed
tests / confined (proxy) (pull_request_target) Successful in 1m17s
tests / confined (conversation) (pull_request_target) Successful in 1m27s
tests / runner-env (confined) (pull_request_target) Successful in 37s
tests / confined (shellcheck) (pull_request_target) Successful in 59s
tests / confined (providers) (pull_request_target) Successful in 2m49s
tests / privileged (pull_request_target) Successful in 3m55s
tests / confined (remote) (pull_request_target) Successful in 4m20s
tests / runner-env (privileged) (pull_request_target) Successful in 41s
tests / confined (static) (pull_request_target) Successful in 5m46s
tests / confined (project) (pull_request_target) Successful in 6m1s
tests / confined (audit) (pull_request_target) Successful in 9m56s
tests / confined (config) (pull_request_target) Successful in 9m57s
The example identities became agent-gitea/agent-github/agent-vendor, and
two references to the old person-shaped pair survived: "the `oss` identity
above", pointing at a section that no longer exists, and `wrok`, which read
as a typo of `work` when nothing called `work` was left to typo.

No identity in the block omits `token_file` on the gitea path, so the
fall-through paragraph states the rule without naming one. The refusal
example becomes a typo of a name that is actually there.

The neighbouring test passed throughout, because it matches the section
spelling (`identity.oss`) and the prose says "the oss identity". It now
also resolves every identity the prose names against the example block,
allowing an undefined name only on the sentence that introduces it as
having no section - which is the one place naming a missing section is the
point. Verified both ways: a reintroduced `oss` reference fails it, and the
deliberate no-section name still passes.
2026-08-27 02:33:15 -07:00
branderson 17602234de Address review: voice note and badge back to the top, fix a pointer's render
tests / confined (proxy) (pull_request_target) Successful in 1m15s
tests / confined (conversation) (pull_request_target) Successful in 1m27s
tests / runner-env (confined) (pull_request_target) Successful in 39s
tests / confined (shellcheck) (pull_request_target) Successful in 58s
tests / confined (providers) (pull_request_target) Successful in 2m46s
tests / privileged (pull_request_target) Successful in 3m47s
tests / confined (remote) (pull_request_target) Successful in 4m0s
tests / runner-env (privileged) (pull_request_target) Successful in 37s
tests / confined (static) (pull_request_target) Successful in 5m11s
tests / confined (project) (pull_request_target) Successful in 5m25s
tests / confined (config) (pull_request_target) Successful in 8m45s
tests / confined (audit) (pull_request_target) Successful in 8m51s
The developer's note is the one thing in the README that is not the agent
voice, so it contextualizes everything after it and belongs below the pitch
rather than tucked inside the agent-perspective section. The badge goes back
with it, at the top rather than under documentation.

The docs/security.md pointer under "GitHub push" had its dash starting the
following line, which markdown reads as a list item - so it rendered as a
bullet where every other pointer on the page renders as a paragraph. Dash
moved to the end of the link line, matching the rest.
2026-08-27 01:28:11 -07:00
branderson 7585c4e8b6 Put the instructions before the essays, and cut what was padding
tests / confined (proxy) (pull_request_target) Successful in 1m12s
tests / confined (conversation) (pull_request_target) Successful in 1m25s
tests / runner-env (confined) (pull_request_target) Successful in 36s
tests / confined (shellcheck) (pull_request_target) Successful in 57s
tests / confined (providers) (pull_request_target) Successful in 2m38s
tests / privileged (pull_request_target) Successful in 3m41s
tests / confined (remote) (pull_request_target) Successful in 4m4s
tests / runner-env (privileged) (pull_request_target) Successful in 39s
tests / confined (static) (pull_request_target) Successful in 5m32s
tests / confined (project) (pull_request_target) Successful in 5m47s
tests / confined (audit) (pull_request_target) Successful in 9m38s
tests / confined (config) (pull_request_target) Successful in 9m37s
A reader arriving at the README met most of two thousand words of pitch,
name history, agent perspective and a ten-point responsible-use essay
before the first instruction. Setup was the ninth section on the page.

README: reordered to pitch, bounds, setup, usage, sign-in, providers,
responsible use, the per-feature sections, then the agent perspective,
the name and the indexes. Setup is four numbered steps plus two
independent optional blocks, with the prerequisites folded in. "Using
this responsibly" was a near-duplicate of docs/responsible-use.md and is
now seven bullets with the reasoning left where it lives. The Ctrl-Z
explanation moves to docs/configuration.md. 6435 words to 5253.

remote-access.md: the bind address, the one step where a plausible
choice is a full sandbox escape, sat below seventy lines of config
validation prose. It, and picking and verifying the address, now come
first; the config reference follows, example and restart command ahead
of its validation rules; the diagrams move below the front doors.

configuration.md: the approval workflow was reached only after ~1100
words on the corpus rule and tarball digests. Those follow it now. Fixes
a docs/security.md link that needed to be security.md from inside docs/.

security.md: the exit-time audit was a single 1800-word bullet list.
Same content under five subheadings. Records that a walked report is
evicted by rotation rather than by ack, which the README carried and no
longer does.

responsible-use.md: economy pass, no rule removed, and its opening no
longer counts the README's rules.

threat-model.md: says what the document is before saying how much to
trust it.
2026-08-27 00:15:11 -07:00
branderson a5b35c3750 Merge pull request 'Keep the update-check cache with the rest of dejima's host state' (#256) from cache-home into development
Reviewed-on: #256
Reviewed-by: branderson <brad@branderson.io>
2026-08-26 23:45:35 -07:00
branderson 4bdf3a7f14 Merge pull request 'Correct three doc claims the code no longer supports' (#251) from doc-fixes into development
Reviewed-on: #251
Reviewed-by: branderson <brad@branderson.io>
2026-08-26 23:44:43 -07:00
branderson 6f9b93517e Merge pull request 'Document identities as the session's accounts, not the operator's' (#255) from identity-docs into development
Reviewed-on: #255
Reviewed-by: branderson <brad@branderson.io>
2026-08-26 23:44:21 -07:00
branderson 7b82328541 Document identities as the session's accounts, not the operator's
tests / confined (proxy) (pull_request_target) Successful in 1m17s
tests / confined (conversation) (pull_request_target) Successful in 1m30s
tests / runner-env (confined) (pull_request_target) Successful in 38s
tests / confined (shellcheck) (pull_request_target) Successful in 1m3s
tests / confined (providers) (pull_request_target) Successful in 2m50s
tests / privileged (pull_request_target) Successful in 3m49s
tests / confined (remote) (pull_request_target) Successful in 4m12s
tests / runner-env (privileged) (pull_request_target) Successful in 38s
tests / confined (static) (pull_request_target) Successful in 5m18s
tests / confined (project) (pull_request_target) Successful in 5m34s
tests / confined (config) (pull_request_target) Successful in 9m11s
tests / confined (audit) (pull_request_target) Successful in 9m13s
configuration.md said an [identity.<name>] groups what belongs to a person
rather than to a project, and gave "work" and "oss" as the example - one
human's two accounts. The README and docs/security.md both say the opposite:
give the session an account that is not you. The section describing the
mechanism for holding several of those described it as a way of switching
between your own.

Rewritten around what it is for. An identity is one agent account: the key,
the token, the name on the commits, so a launch pointed at a different account
is one word rather than four edits. The examples are a gitea agent, a GitHub
agent, and a second GitHub agent for a repository the first may not touch -
which is the arrangement it exists to make possible, one host running a
session against each at the same time.

The reason there is more than one is stated rather than assumed: three
accounts are three things to scope, revoke and read in a log independently, so
a session that turns out to have been prompt-injected reached what its own
account could reach.

Also corrected: the [core] identity row said the section supplies "this
project's" credentials, which is the same confusion in one line; providers.md
described identity_keys as a forge's person-owned keys; and the closing
paragraph implied a project can already ask for an identity. It cannot -
there is no .dejima/ kind for one - and what a project layer would mean is
not "this project's credentials", which cannot be committed, but "whichever
account you have called X".

One test, mutation-checked against both the old framing and the old example.

static 394, providers 94.
2026-08-26 23:13:30 -07:00
branderson 4c63db4891 Correct three doc claims the code no longer supports
tests / confined (audit) (pull_request_target) Successful in 9m17s
tests / confined (config) (pull_request_target) Successful in 9m16s
tests / confined (conversation) (pull_request_target) Successful in 1m25s
tests / confined (project) (pull_request_target) Successful in 5m41s
tests / confined (providers) (pull_request_target) Successful in 2m40s
tests / confined (proxy) (pull_request_target) Successful in 1m11s
tests / confined (remote) (pull_request_target) Successful in 4m15s
tests / confined (static) (pull_request_target) Successful in 5m38s
tests / confined (shellcheck) (pull_request_target) Successful in 1m2s
tests / runner-env (confined) (pull_request_target) Successful in 38s
tests / runner-env (privileged) (pull_request_target) Successful in 37s
tests / privileged (pull_request_target) Successful in 3m59s
Release-review findings 5 and 7, plus the minor items grouped with them.
Finding 6 - configuration.md and providers.md claiming a project-side identity
layer that does not exist - is not here: it is superseded by #255, which
rewrites that whole section rather than the two sentences, and two PRs editing
the same paragraph is how both of them end up conflicting.

The capability floor said "the seven it actually uses. Six are the
entrypoint's own"; compose grants eight. The counts are gone rather than
corrected - a number in prose that has to match a list in a file is one the
repo's own rule says not to write. The doc names the split and points at
docker-compose.yml.

All three shipped workflows set block_merge = on, and the README still said
the merge guard was "inert unless that workflow turns it on" - true of the
mechanism, misleading about what you get. It now says every shipped workflow
turns it on, and that the switch is there for a third-party workflow.

Minor, same branch: tools.md was missing from the README docs index, and
.dejima.md's suite table gained rows for bin/dejima-tty-suspend and tools/.

Three tests, all mutation-checked, and two of them derived rather than
grepped: the docs index is checked against docs/ itself (release.md excluded
by name, as the maintainer's process), and the merge claim is checked against
the workflow manifests, so a workflow shipped with block_merge off fails the
test instead of leaving the README wrong. The third refuses a capability count
coming back.
2026-08-26 23:07:18 -07:00
14 changed files with 1690 additions and 1291 deletions
+2
View File
@@ -39,6 +39,8 @@ set before opening a PR, not between edits.
| `bin/dejima-project-lib.sh`, `-dep`, `-project`, `-allow` | `test/project.bats` |
| `bin/dejima-update-check` | `test/config.bats` |
| `bin/dejima-tui-lib.sh` | `test/remote.bats` and `test/project.bats` (both tools use it) |
| `bin/dejima-tty-suspend` | `test/static.bats` |
| `tools/` | `test/providers.bats` |
| `bin/dejima-anthropic-proxy`, `docker/anthropic-proxy-forwarder.js` | `test/proxy.bats` |
| `docker/`, `hooks/`, `README.md`, `docs/` | `test/static.bats` |
| anything under `docker/` that has to actually boot | `test/runtime.bats` (needs Docker) |
+481 -604
View File
File diff suppressed because it is too large Load Diff
+6 -9
View File
@@ -446,13 +446,9 @@ if [ "$PRINT_CONFIG" = 1 ]; then
# nothing would show this machine's own credentials as though they were
# the identity's - the exact confusion the refusal exists to prevent, in
# the one command an operator runs to check what they configured.
_identity=""
config_exists && { _identity="$(config_get core identity)" || _identity=""; }
if [ -n "$_identity" ] && ! identity_exists "$_identity"; then
echo "[core] identity is '$_identity', and no [identity.$_identity] section sets" >&2
echo "anything in $(config_path)." >&2
exit 1
fi
# The forge is read here rather than taken from DEJIMA_FORGE_NAME, which
# is resolved further down, past this early exit.
identity_validate "$(config_get core forge)" || exit 1
while IFS= read -r _key; do
_section="${_key%.*}"
_leaf="${_key##*.}"
@@ -1909,8 +1905,9 @@ fi
# below will report any change to it.
if [ "$(readlink -f "$WORKSPACE_DIR")" = "$(readlink -f "$REPO_ROOT")" ]; then
echo "Warning: sandbox's own repo is the mounted workspace - its trusted" >&2
echo "computing base (bin/, hooks/, docker/, install.sh) is writable this" >&2
echo "session; the exit audit will report any changes to review." >&2
echo "computing base (bin/, hooks/, docker/, test/, install.sh, and the" >&2
echo "provider and tool manifests) is writable this session; the exit audit" >&2
echo "will report any changes to review." >&2
fi
# Compose builds the shared base image under its own tag, explicitly - with
+5 -1
View File
@@ -966,7 +966,11 @@ tcb_snapshot() {
# here. Both decide what the next launch permits, which is what the
# TCB list is for. A visible edit stays a normal reviewable diff - the
# split above only reports what git cannot see.
for dir in bin hooks docker test forges workflows harnesses; do
# tools/ for the same reason as the provider directories, and it was
# missed when that layer landed: a tool.conf names a tarball URL and
# the sha256 checked against it, and the host's image build installs
# what it points at as root.
for dir in bin hooks docker test forges workflows harnesses tools; do
if [ -d "$ws/$dir" ]; then
while IFS= read -r -d '' f; do
stage_entry "tcb:${f#"$ws"/}" "$f"
+185 -25
View File
@@ -183,7 +183,7 @@ EOF
requires ?list:identifier
push bool on
blocked_branches ?list:identifier
block_merge bool off
block_merge bool on
requires_worktree bool off
autonomy enum:commit|push-branch|open-pr commit
briefing ?relpath
@@ -751,23 +751,198 @@ identity_exists() {
return 1
}
# The credential keys the selected identity leaves to the section beneath it,
# as "<section> <key>" lines, empty when there are none.
#
# Only the selected forge's, because refusing over a credential belonging to a
# forge this launch does not use would be refusing over a value nothing reads.
#
# "Set" means written in the config file, not resolved from a schema default.
# A forge whose credential key carries a default still hands an identity that
# names none the machine's own path, and that is not caught here - see
# docs/configuration.md, which says so rather than leaving it to be found.
identity_borrowed_credentials() {
local name="$1" forge="$2" key
if ! config_is_set "identity.$name" ssh_key && config_is_set core ssh_key; then
printf 'core ssh_key
'
fi
for key in $(manifest_get forge "$forge" identity_keys); do
if ! config_is_set "identity.$name" "$key" \
&& config_is_set "forge.$forge" "$key"; then
printf 'forge.%s %s
' "$forge" "$key"
fi
done
}
# Whether the selected identity, if there is one, is one a session can act
# under without borrowing from anyone.
#
# Two refusals, one reason. A name with no section resolves to nothing and
# falls through to the machine's own credentials; a section that names only
# some of them falls through for the rest. Either way a session launched as
# this identity acts under another account, and nothing says so. Pushing under
# the wrong key fails loudly; pushing under the default key when you asked for
# another one does not fail at all, which is the direction worth being careful
# about.
#
# One function rather than a check at each caller: bin/dejima's --print-config
# needs the same answer, because every value it prints resolves THROUGH the
# identity and printing this machine's credentials under an identity's name is
# the confusion the refusal exists to prevent.
#
# Guarded on the config existing rather than swallowing config_get's stderr:
# with no file at all it says so, and a selection validated without one is a
# legitimate case this must stay silent for.
# The identity in force: [core] identity when it names one, and otherwise the
# reserved `default` section when the config has one.
#
# `default` is what [core] ssh_key was without a name. Giving it one is what
# lets a host state its ordinary account the same way it states any other,
# with a git name and a forge credential beside the key rather than those
# coming from three different places.
#
# Empty when neither applies, which is the pre-identity config and still a
# supported one.
# Assigns rather than prints, and every caller uses this form. identity_get
# runs on every config_get through bin/dejima's wrapper, and `$(...)` around
# an answer this cheap costs a fork per read - which is most of what a launch
# spends on config.
identity_selected_into() {
local __var="$1" __name=""
if config_exists; then
__name="$(config_get core identity)" || __name=""
if [ -z "$__name" ] && _identity_section_present default; then
__name=default
fi
fi
printf -v "$__var" '%s' "$__name"
}
identity_selected() {
local _n
identity_selected_into _n
printf '%s' "$_n"
}
# Whether a section has any key in the parsed config, read straight off the
# parse rather than through identity_exists.
#
# identity_exists walks every forge's manifest to decide which keys count, and
# bin/dejima's config_get wrapper reaches this on every single read - which
# turned a --print-config from three seconds into forty. It does not need the
# manifest: the parser refuses an unknown key outright, so anything present
# under identity.<name> in a config that validated is already one of the keys
# identity_exists would have accepted.
_identity_section_present() {
local name="$1" k
config_exists || return 1
config_validate || return 1
for k in "${!_CFG_SEEN[@]}"; do
case "$k" in "identity.$name".*) return 0 ;; esac
done
return 1
}
identity_validate() {
local forge="$1" name="" borrowed section key
name="$(identity_selected)"
# [core] ssh_key is the default identity under its old name, so a config
# holding both says the same thing twice and dejima would have to pick.
# Refused rather than ranked: which key a session pushed with is not a
# thing to work out from precedence rules.
if identity_exists default && config_is_set core ssh_key; then
echo "[core] ssh_key is set and so is [identity.default], which is the same" >&2
echo "thing under two names - the key a session gets when none is asked for." >&2
echo "Move it into [identity.default] and remove [core] ssh_key." >&2
echo "See docs/configuration.md, \"Identities\"." >&2
return 1
fi
# Deprecated, not removed: it is how every config written before
# identities existed says this, and those still work. Warned on every
# launch that has it set, not only the ones using it - a config with
# [core] ssh_key and a named identity is one migration away from the
# refusal above, and finding that out on the launch that breaks is worse
# than being told now.
#
# Every launch rather than once, because there is nowhere to record
# "once" that a session could not reach, and a deprecation nobody sees
# until removal is not a deprecation.
if config_is_set core ssh_key; then
printf ' %s[core] ssh_key is deprecated%s - it is [identity.default] ssh_key\n' \
"${C_WARN:-}" "${C_RESET:-}" >&2
printf ' under its old name.\n' >&2
if [ -n "$name" ]; then
printf ' This launch runs as %s%s%s, so it is not the key being used here.\n' \
"${C_KEY:-}" "$name" "${C_RESET:-}" >&2
else
printf ' Nothing else names an account, so it is the key this launch uses.\n' >&2
fi
printf ' Move it into an [identity.default] section, which can carry the git\n' >&2
printf ' name and the forge credential belonging to that same account:\n\n' >&2
# The value in the file, not through config_get: in this process
# config_get is bin/dejima's wrapper, so with a named identity in
# force it answers with THAT identity's key - and printing it as the
# thing to move would name a key the operator is not being asked to
# touch.
printf ' [identity.default]\n ssh_key = %s\n\n' \
"$(_config_get_from_file core ssh_key)" >&2
fi
[ -n "$name" ] || return 0
if ! identity_exists "$name"; then
echo "[core] identity is '$name', and no [identity.$name] section sets" >&2
echo "anything in $(config_path). An identity that resolves to nothing would" >&2
echo "quietly use this machine's own credentials instead - refusing rather than" >&2
echo "doing that. See docs/configuration.md, \"Identities\"." >&2
return 1
fi
borrowed="$(identity_borrowed_credentials "$name" "$forge")"
[ -n "$borrowed" ] || return 0
echo "[identity.$name] leaves a credential to the section beneath it, so a" >&2
echo "session launched as '$name' would act under whichever account owns:" >&2
while IFS=$'\t' read -r section key; do
[ -n "$key" ] || continue
echo " [$section] $key" >&2
done <<< "$borrowed"
echo "An identity is how one session's account is kept separate from another's;" >&2
echo "set each of those under [identity.$name], or unset it in the section named." >&2
echo "See docs/configuration.md, \"Identities\"." >&2
return 1
}
identity_get() {
local section="$1" key="$2" name value pname
[ "$section.$key" != "core.identity" ] || return 1
name="$(config_get core identity)" || return 1
[ -n "$name" ] || return 1
# Which keys an identity could own, before working out which identity is
# in force: this runs on every config_get, and all but a handful of keys
# are ones no identity can answer for. Resolving the identity first meant
# doing that work ~40 times a launch to discard it ~35 of those times.
case "$section" in
core) [ "$key" = ssh_key ] || return 1 ;;
forge.*) _forge_owns_identity_key "${section#forge.}" "$key" || return 1 ;;
identity.*) return 1 ;;
*) return 1 ;;
core) [ "$key" = ssh_key ] || return 1 ;;
forge.*) ;;
*) return 1 ;;
esac
identity_selected_into name
[ -n "$name" ] || return 1
# The manifest lookup last, for the same reason: it forks, and by here
# there is an identity that might actually answer.
case "$section" in
forge.*) _forge_owns_identity_key "${section#forge.}" "$key" || return 1 ;;
esac
value="$(config_get "identity.$name" "$key")" || return 1
[ -n "$value" ] || return 1
printf '%s' "$value"
}
# Whether one forge declared this key as a person's rather than a project's.
# Whether one forge declared this key as naming an account rather than the
# forge itself - a credential a session acts under, not a setting like an API
# URL that every session on this host shares.
_forge_owns_identity_key() {
local forge="$1" key="$2" k
for k in $(manifest_get forge "$forge" identity_keys); do
@@ -782,7 +957,7 @@ _forge_owns_identity_key() {
# globally would be a new way to get the same answer.
identity_git_field() {
local field="$1" name
name="$(config_get core identity)" || return 1
name="$(identity_selected)" || return 1
[ -n "$name" ] || return 1
config_get "identity.$name" "$field"
}
@@ -862,22 +1037,7 @@ provider_validate_selection() {
# quietly absent, because an operator who has them everywhere else would
# otherwise have to read a manifest to find out they are gone here.
#
# An identity that names nothing is refused rather than ignored. Falling
# through to the machine's own credentials would mean pushing under the
# wrong account with nothing saying so, which is the failure this feature
# exists to remove rather than one it may introduce.
# Guarded on the config existing rather than swallowing config_get's
# stderr: with no file at all it says so, and a selection validated
# without one is a legitimate case this must stay silent for.
local _identity=""
config_exists && { _identity="$(config_get core identity)" || _identity=""; }
if [ -n "$_identity" ] && ! identity_exists "$_identity"; then
echo "[core] identity is '$_identity', and no [identity.$_identity] section sets" >&2
echo "anything in $(config_path). An identity that resolves to nothing would" >&2
echo "quietly use this machine's own credentials instead - refusing rather than" >&2
echo "doing that. See docs/configuration.md, \"Identities\"." >&2
return 1
fi
identity_validate "$forge" || return 1
# 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,
+208 -161
View File
@@ -2,10 +2,10 @@
[← 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.
Day-to-day configuration once dejima is installed and running. The reference
table below is every key there is; the sections after it cover what a project
declares versus what you approve, what carries over from your host, mounts and
scratch space, and what the sandbox knows about itself.
## Configuration reference
@@ -26,12 +26,12 @@ config file behind them.
| `[core] forge` | `none` | forge integration: `gitea` (`gitea-pr`/`gitea-issue`), `github` (`gh`/`github-pr`, 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` | *(the selected harness's own `command`)* | container's main process (e.g. `nvim`), also settable per launch with `--cmd`. Overriding it changes what dejima launches, not which harness is wrapped |
| `[core] identity` | unset | which `[identity.<name>]` section supplies this project's credentials and git name; also settable per launch with `--identity`. A name with no section is refused rather than falling back |
| `[core] identity` | unset | which `[identity.<name>]` section supplies the credentials and git name this session acts under; also settable per launch with `--identity`, which is how one host runs a session per account. Unset selects `[identity.default]` where the config has one. A name with no section is refused rather than falling back |
| `[core] tools` | unset | optional tools to put in the image, colon-separated; also `.dejima/tools` in a project (approved first) and `--include` per launch. None by default. See [docs/tools.md](tools.md) |
| `[core] update_check` | `on` | at startup, say when this checkout is behind the branch it tracks. One `ls-remote` to your own remote, at most hourly, never fetching; `off` makes dejima contact nothing of its own accord. The answer is cached in `~/.local/share/dejima/update-check`, which is safe to delete |
| `[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] ssh_key` | unset | **deprecated** - the pre-identity spelling of `[identity.default] ssh_key`. Still read when the config has no `[identity.default]`; refused when it has one, since the two say the same thing |
| `[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 |
@@ -49,7 +49,7 @@ config file behind them.
| `[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 |
| `[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)) |
| `[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](security.md)) |
| `[logging] loki_url` | unset | ship DNS/firewall logs to a Loki endpoint; `--no-log` clears it for one launch, which silences everything downstream of it |
| `[epitaph] enabled` | `on` | keep the summary a harness writes when a session compacts; `--no-epitaph` skips it for one launch |
| `[epitaph] dir` | `~/.local/share/dejima/epitaphs` | where they are kept; never mounted into a container |
@@ -78,12 +78,11 @@ 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.
layers **add** to each other, so a narrower layer can only widen what a broader
one already permits: the set a session gets is the union of every layer that
applies to it, never an override of one by another. Nothing at the project
layer can withdraw a built-in domain, and nothing at the container layer can
withdraw a project one.
| Config | Layers, least specific first |
| --- | --- |
@@ -102,61 +101,79 @@ A project can declare what it needs in two committed files:
# tarball:<name>@<sha256>:<url> release archive
```
### Declaring that the history is evidence
**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:
A third file, approved the same way as the two above:
```
.dejima/corpus # one rule per line; today, commit-per-step
```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
```
An approved `commit-per-step` adds a paragraph to the session briefing
requiring **one commit per plan step that writes a file**, rather than one
commit per task, and explaining why. Absent or unapproved, nothing is added
and nothing changes, which is the default for every project that has not asked
for it.
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.
It goes through approval despite granting no capability, and that is
deliberate. `.dejima/` lives in the workspace, which is session-writable, so a
session able to turn this on by writing a file would be writing the *next*
session's system prompt. `bin/dejima` reads the approval store and never a
project's declarations - a static test holds that it does not so much as name
that directory.
The declaration those two write still grants nothing, 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 still running while you type the command - see
[Declaration files are session-writable](security.md#declaration-files-are-session-writable).
The same write reads the file back to skip an already-declared entry, and that
read is capped, since a session can make its own `.dejima/<kind>` arbitrarily
large for free.
An unknown rule is reported but never offered for approval. One that nothing
implements would approve cleanly, sit in the store and do nothing, which reads
as being in force when it is not.
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.
**Check the forge's merge policy before relying on this.** The briefing tells
the agent how to commit, but the setting that can undo all of it is yours: a
squash-merge is survivable, because a clone fetches remote branches and the
step commits stay reachable through the branch ref, but deleting the branch
afterwards removes the last thing pointing at them. Several forges offer
"delete branch on merge" and some default it on. Measured both ways on a
throwaway repository: squash-merge with the branch retained left every step
commit reachable in a fresh clone; the same merge with the branch deleted first
left none.
`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.
So a project can adopt the rule, write every plan correctly, and still yield
nothing, with no error anywhere - a squash-merged `main` looks perfectly
healthy. dejima's own history is intact because it merges with merge commits.
Approved domains join the allowlist at the next launch. `dejima-allow` can also
patch a running session immediately, but only when run bare or with
`--container` - approving here (`--project`/`--global`) never touches one; see
[Adding a domain to a running session](security.md#adding-a-domain-to-a-running-session).
A domain that resolves to a private address has no live patch at all. 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 take effect on the **next** launch. Because of that rebuild, the
usual moment to approve is *after* leaving the sandbox, and persistence never
requires a running container.
The reason is recoverability rather than tidiness. A step is only legible
afterwards if the file's state immediately before it and immediately after it
can both be read back from history: before from the commit's parent, after
from the commit. One commit carrying several steps that touch the same file
destroys that, because no step in it can be isolated any more, and
reconstructing one would mean building a file state the repository never held.
Nothing recovers that later, so it has to be recorded while the work happens.
`dejima-project approve` never attempts to patch a running session: it only
ever calls `dejima-allow` without `--container`. 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, 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.
It lives in the project rather than in `~/.config/dejima/config` because it is
a fact about the repository. Anyone who clones it and runs dejima gets the same
commit discipline, which is what makes the history uniformly usable: a rule
half the contributors follow produces a history nothing can be taken from.
What dejima does not do is say what the history is *for*. Collecting it, and
deciding whether to, is the operator's business; the briefing states the rule
the project already adopted and stops there.
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.
### Tarball dependencies
@@ -205,88 +222,53 @@ 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:
### Declaring that the history is evidence
```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
A third file, approved the same way as the two above:
```
.dejima/corpus # one rule per line; today, commit-per-step
```
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.
An approved `commit-per-step` adds a paragraph to the session briefing
requiring **one commit per plan step that writes a file**, rather than one
commit per task, and explaining why. Absent or unapproved, nothing is added
and nothing changes, which is the default for every project that has not asked
for it.
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.
It goes through approval despite granting no capability, and that is
deliberate. `.dejima/` lives in the workspace, which is session-writable, so a
session able to turn this on by writing a file would be writing the *next*
session's system prompt. `bin/dejima` reads the approval store and never a
project's declarations - a static test holds that it does not so much as name
that directory.
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.
An unknown rule is reported but never offered for approval. One that nothing
implements would approve cleanly, sit in the store and do nothing, which reads
as being in force when it is not.
`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.
The reason is recoverability rather than tidiness. A step is legible afterwards
only if the file's state immediately before and immediately after it can both
be read back: before from the commit's parent, after from the commit. One
commit carrying several steps that touch the same file destroys that, and
nothing recovers it later, so it has to be recorded while the work happens.
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).
A domain that resolves to a private address has no live patch at all - an
internally-routed host can only be approved and picked up at the next launch.
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.
**Check the forge's merge policy before relying on this.** The setting that can
undo all of it is yours. A squash-merge is survivable, because the step commits
stay reachable through the branch ref, but "delete branch on merge" - which
several forges offer and some default on - removes the last thing pointing at
them. Measured both ways on a throwaway repository: squash-merge with the
branch retained left every step commit reachable in a fresh clone; the same
merge with the branch deleted first left none. So a project can adopt the rule,
write every plan correctly, and still yield nothing, with no error anywhere.
dejima's own history is intact because it merges with merge commits.
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.
It lives in the project rather than in `~/.config/dejima/config` because it is
a fact about the repository: anyone who clones it gets the same commit
discipline, and a rule half the contributors follow produces a history nothing
can be taken from. What dejima does not do is say what the history is *for*.
Collecting it is the operator's business; the briefing states the rule the
project adopted and stops there.
## Config parity with your host agent harness setup
@@ -427,39 +409,78 @@ Dockerfile directly puts it outside that record.
## Identities
One machine, several accounts. A `[identity.<name>]` section groups the things
that belong to a **person** rather than to a project - the SSH key, the forge
token, the name on the commits - so that selecting an account is one word
rather than four edits.
**Several accounts on one machine, and none of them yours.** An
`[identity.<name>]` section groups what one session acts as - the SSH key, the
forge token, the name on the commits - so that pointing a launch at a
different account is one word rather than four edits.
The rest of these documents ask you to give a session an account of its own -
the README under [Using this responsibly](../README.md#using-this-responsibly),
and security.md's [Give the sandbox its own
identity](security.md#give-the-sandbox-its-own-identity). This is how you
have more than one of them, which you will: an account is scoped to what it
may reach, and one account scoped to everything any session might ever need
is the thing separate accounts existed to avoid.
So the sections are per **agent account**, not per person:
```ini
[identity.work]
ssh_key = ~/.ssh/id_work
user_name = A Name
user_email = name@work.example
token_file = ~/.config/gitea/work-token
# Pushes to the gitea forge, and can reach nothing on GitHub.
[identity.agent-gitea]
ssh_key = ~/.ssh/id_agent_gitea
user_name = dejima agent (gitea)
user_email = agent-gitea@example.invalid
token_file = ~/.config/gitea/agent-token
[identity.oss]
ssh_key = ~/.ssh/id_oss
user_email = name@personal.example
push_token_file = ~/.config/gh/oss-push
# A GitHub account, with push rights to the repositories this host works on.
[identity.agent-github]
ssh_key = ~/.ssh/id_agent_github
user_name = dejima agent (github)
user_email = agent-github@example.invalid
push_token_file = ~/.config/gh/agent-push
# A second GitHub account, for a repository the one above may not touch.
[identity.agent-vendor]
ssh_key = ~/.ssh/id_agent_vendor
user_name = dejima agent (vendor)
user_email = agent-vendor@example.invalid
push_token_file = ~/.config/gh/vendor-push
```
Select one with `[core] identity = work`, or per launch with
`--identity oss`.
Select one with `[core] identity = agent-gitea`, or per launch with
`--identity agent-github` - which is how one host runs a session against each
of these at the same time, each pushing as itself.
**The separation is the point.** Three accounts means three things you can
scope, revoke and read in a log independently: a session that turns out to
have been prompt-injected reached what its own account could reach, and
answering "what did it touch" does not start with "everything I can touch".
**What an identity may set** is deliberately narrow: `ssh_key`, `user_name`,
`user_email`, and whichever keys the forges declare as a person's. A forge
`user_email`, and whichever keys the forges declare as an account's. A forge
says which of its own keys those are (`identity_keys` in its manifest), so
`token_file` belongs to an identity on the gitea path and `push_token_file`
does on the GitHub one, and neither is a list core keeps its own copy of.
**A key an identity does not set falls through to the file.** The `oss`
identity above names no `token_file`, so `[forge.gitea] token_file` answers
for it. An identity is an override for what it names, not a replacement for
the section.
**`default` is the account a session gets when none is named.** With `[core]
identity` unset, an `[identity.default]` section is what a launch acts under -
the ordinary account, given a name so it can carry the git name and the forge
credential too.
**A name with no section is refused.** `[core] identity = wrok` does not
**`[core] ssh_key` is that key under its old name, and is deprecated.** A
config with no `[identity.default]` reads it as before; a config with both is
refused rather than ranked, because which key a session pushed with should not
depend on precedence rules. Every launch that sets it says so and prints the
section to move it into - including launches running as some other identity,
which are one `[identity.default]` away from that refusal.
**A credential does not fall through.** An identity that names some and leaves
the rest to the section beneath it is refused, naming each one: an identity is
how one session's account stays separate from another's. "Set" means written
in the file, so a forge key carrying a schema default - `[forge.gitea]
token_file` - still answers for an identity that names none.
**A name with no section is refused.** `[core] identity = agent-gihub` does not
quietly use this machine's own credentials - it stops the launch and says so.
Pushing under the wrong key fails loudly; pushing under your default key when
you asked for another one does not fail at all, which is the direction worth
@@ -469,10 +490,16 @@ being careful about.
the copy dejima already makes of your `~/.gitconfig`. Your real one is not
touched, which was already true and is what the copy exists for.
This is the half of per-project configuration that a project cannot carry: a
project's `.dejima/` is committed and shared with everyone working on it, and
a key path is yours and your machine's. A project can say which identity it
wants; only you can say where that identity's key lives.
**Selection is the host's, not the project's.** There is no `.dejima/` kind
for an identity and a project cannot name one: a project's `.dejima/` is
committed and shared with everyone working on it, and both the credential and
which account this host holds are that host's own. `[core] identity` and
`--identity` are the whole of it today.
A project-side layer would be a different thing than it sounds - not "this
project's credentials", which cannot be shared, but "this project wants
whichever account you have called X". That does not exist yet, and this
document will say so until it does.
## Headless git push without agent forwarding
@@ -502,15 +529,15 @@ the public half as a Gitea deploy key scoped to just the repos that need
push access, then set it in the config:
```
[core]
[identity.default]
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.
[security notes](security.md) for the full mount rationale). Setting it means
a live forwarded agent is no longer required at all.
## Using a different editor/application
@@ -612,6 +639,26 @@ 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.
## Suspending a session
Ctrl-Z backgrounds all of dejima - the container, the credential proxy, the
conversation shipper - and `fg` brings back a working session, with the terminal
put back the way the harness had it.
That takes doing, because a container run holds the terminal in raw mode, where
Ctrl-Z is not a signal at all: it is a byte, delivered to whatever is running
inside. A harness that implements Ctrl-Z itself then stops itself, and nothing
in a container can continue it - there is no job-control shell in there. So
dejima takes that one character back for the host while a session runs
(`bin/dejima-tty-suspend`). Ctrl-C and Ctrl-\ are deliberately left alone and
still reach the session, which is how a harness expects to be interrupted.
Suspending is not detaching. A suspended session is stopped, so it makes no
progress until you resume it, and it belongs to the terminal you started it
from. For a session that keeps working while you are away, or that you want to
pick up from somewhere else, run dejima inside tmux and use
[`dejima-attach`](remote-access.md).
## Shared scratch space
`~/shared` is a read-write bind mount of `[core] share_dir` (default
+2 -2
View File
@@ -124,7 +124,7 @@ operator can set, resolved the same way as any other config key
| `issue_command` | `?command` | (empty) | The issue-management command name, same idea. |
| `merge_block` | `?command` | (empty) | The command prefix refused inside the sandbox - only when the workflow's `block_merge = on`. Enforced by `docker/forge-guard.sh`, which shadows the named CLI on PATH and compares argv, so it holds for a harness that cannot intercept a tool call; `hooks/merge-guard.sh` applies the same rule to a command string, for the harnesses that can. Matched as a run of consecutive non-flag arguments, so a flag before the subcommand does not slip past it. |
| `repo_flags` | `?list:flag` | (empty) | The option names this forge's CLI accepts for naming a repository other than the one the session was launched against - `--repo -R` for `gh`. `docker/forge-guard.sh` refuses a value that is not that repository, in every spelling (`--repo x`, `--repo=x`, and a one-dash flag with its value attached). Fast-fail, not a boundary, for the same reason `merge_block` is not one: the session holds the token. Leave it empty for a CLI that resolves the repo itself and refuses another host, as the gitea ones do - a forge is not required to be aimable. |
| `identity_keys` | `?list:identifier` | (empty) | Which of this forge's own `keys` belong to a **person** rather than to the project - the credential paths. One declaration, two rules: a key named here is one an `[identity.<name>]` section may set, and one a project may not declare in its own `.dejima/`. Core keeps no list of its own, so a forge whose credential is spelled differently needs no change here. The identity inherits the type from this forge's `keys` entry, not a second copy of it. |
| `identity_keys` | `?list:identifier` | (empty) | Which of this forge's own `keys` name an **account** rather than the forge itself - the credential paths, which differ per session, where an API URL does not. One declaration, two rules: a key named here is one an `[identity.<name>]` section may set, and one a project may not declare in its own `.dejima/`. Core keeps no list of its own, so a forge whose credential is spelled differently needs no change here. The identity inherits the type from this forge's `keys` entry, not a second copy of it. |
| `repo_env` | `?identifier` | (empty) | The environment variable this forge's CLI reads for the same thing - `GH_REPO`. Declared separately because it takes no argument and would otherwise be the way around every flag above. |
| `disclose` | `?disclosures` | (empty) | Which of this forge's commands send a body the session authored, as comma-separated `kind:command prefix` entries - `pr:gh pr create, issue:gh issue create, ...`. `docker/forge-guard.sh` prepends a marker to the body of a matching command, so a PR, issue or comment an agent wrote says so. The three kinds are `pr`, `issue` and `comment`; the wording of each marker is core's, not the manifest's, so that dejima says one thing about agent authorship whichever forge is selected. Unlike `merge_block` this does not depend on the workflow: what a workflow permits is a policy, who wrote the text is a fact. |
| `keys` | `?list:keyspec` | (empty) | This forge's own `[forge.<name>]` config schema - see above. |
@@ -172,7 +172,7 @@ as environment variables.
| `requires` | `?list:identifier` | (empty) | Forge capabilities this workflow needs (checked against the selected forge's `capabilities` at launch). |
| `push` | `bool` | `on` | Whether this workflow pushes at all. When `off`, `DEJIMA_PUSH_HOSTS` is empty regardless of the forge, so `git-push-guard.sh` refuses every push. |
| `blocked_branches` | `?list:identifier` | (empty) | Branch names `git-push-guard.sh` refuses a direct push to, even on an allowed host. |
| `block_merge` | `bool` | `off` | When `on`, resolves the selected forge's `merge_block` command into `DEJIMA_MERGE_BLOCK` for `hooks/merge-guard.sh`. |
| `block_merge` | `bool` | `on` | When `on`, resolves the selected forge's `merge_block` command into `DEJIMA_MERGE_BLOCK` for `hooks/merge-guard.sh`. Defaults on so a workflow that omits it refuses a merge rather than permitting one; every shipped workflow sets it anyway. |
| `requires_worktree` | `bool` | `off` | When `on`, this workflow's briefing fragment must contain a `<!-- core: worktree -->` marker, which the core worktree-containment paragraph is spliced into - see below. |
| `autonomy` | `enum:commit\|push-branch\|open-pr` | `commit` | Gates `<!-- requires: autonomy=VALUE -->` spans in this workflow's own briefing fragment (see below); also read by the forge/harness resolvers where autonomy-conditioned prose applies. |
| `briefing` | `?relpath` | (empty) | This workflow's briefing fragment file, relative to its own directory. |
+176 -195
View File
@@ -50,194 +50,8 @@ front door opening *inward*, and that is a full sandbox escape rather than a
degradation. [Verifying it](#verifying-it) is two commands, and worth
running.
## `[remote-access]`
A section of the single config file, at `~/.config/dejima/config`
(`$DEJIMA_CONFIG_DIR` relocates the whole config tree, same as it does for
approvals):
| Key | Default | Purpose |
| --- | --- | --- |
| `enabled` | `off` | the switch - nothing below runs until this is `on` |
| `ntfy_url` | unset | ntfy topic to push notifications to. `dejima-notify-watch` refuses to start without it; nothing else reads it |
| `base_url` | unset | public base URL of the web front door, used to build the notification's deep link. Absent, pushes still go out - just with no link on them |
| `ttyd_port` | `7681` | loopback port `ttyd` listens on. The reverse proxy has to `proxy_pass` to the same one |
| `ttyd_font_size` | unset | terminal font size in px, 8-72. Applies to *every* client, phone and desktop alike - see "Reading it on a phone". Unset passes no option at all and leaves `ttyd` on its own default |
| `ttyd_button_colors` | unset | whether the toolbar is colour-coded by what each button does, in the same gruvbox palette the session picker uses. Unset means yes; `off` restores the grey every key had before. See "Reading it on a phone" |
| `ttyd_focus_on_tap` | unset | whether tapping the terminal raises the soft keyboard. Unset means no on a touch device and yes on a desktop, which is almost always what you want; `on` or `off` forces it. Overridable per client with `?focus=1` / `?focus=0` |
| `ttyd_tmux_prefix` | unset | the tmux prefix the terminal page's `PREFIX`, `◀W`, `W▶` and `ZOOM` buttons send, written as a control chord: `C-a`, `C-b`, `C-Space`, `C-@`, `C-_`, `C-?`. Unset leaves the page on stock tmux's `C-b`. See "Reading it on a phone" |
**If this host sits behind a firewall it does not control**, the push has to
be permitted outbound before any of this works. `dejima-notify-watch` runs on
the host, not in the container, so the container's own allowlist has nothing
to do with whether it can reach your ntfy server - and a self-hosted one on a
private address is exactly the case a host-level egress filter drops.
That failure is the quiet kind. Nothing errors, the config is correct,
`systemctl --user status dejima-notify-watch` looks healthy, and the phone
simply never buzzes. If you administer that filter, the rule is yours to add;
if someone else does, this is the thing to ask them about before re-reading
your config a third time.
`ntfy_url` and `base_url` must carry an explicit `http://` or `https://`
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
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
validated on every read, not just the key being asked for: an unknown key, a
value outside that character set, or a line that doesn't parse makes
`dejima-ttyd` and `dejima-notify-watch` refuse to start and say which line
was wrong. A `ttyd_port` that isn't a number in 1-65535 is refused the same
way, as is a `ttyd_font_size` outside 8-72 - a size big enough to make one
glyph wider than the viewport renders a terminal that shows nothing, which
reads as a broken front door rather than as a typo three sections away. A
`ttyd_tmux_prefix` that is not a control chord is refused here too, rather
than on the phone, hours later, by whoever presses `PREFIX` and gets nothing.
`dejima-ttyd` and `dejima-notify-watch` are the two scripts that read a
*value* out of the config, so a typo anywhere in the file refuses their
start. `dejima` reads values too (`audit dir` among them) and refuses the
same way. `dejima-attach` alone only asks `remote_enabled` - deliberately
bypassing the whole-file validation, so a typo elsewhere in the config can't
lock you out of the terminal you'd fix it from.
```
# ~/.config/dejima/config - substitute your own topic and hostname
[remote-access]
enabled = on
ntfy_url = https://ntfy.example.invalid/your-topic-here
base_url = https://dejima.example.invalid
ttyd_port = 7681
# ttyd_font_size = 20 # optional, and applies to the desktop too
# ttyd_tmux_prefix = C-a # optional; the page assumes stock C-b without it
```
**Editing this changes nothing until the services restart.** Every value
is read once, at process start - `dejima-ttyd` reads `ttyd_port`,
`ttyd_font_size` and `ttyd_tmux_prefix` and then `exec`s `ttyd` with them
baked into its argv. So:
```sh
systemctl --user restart dejima-ttyd.service
systemctl --user restart dejima-notify-watch.service # if ntfy_url changed
pgrep -a ttyd # confirm the argv is what you just configured
```
Skipping this is quiet rather than loud: nothing warns, and the front door
keeps serving with the old settings, which reads as "the setting had no
effect" rather than as "the process is older than the file".
## How it works
Almost none of this is in the sandbox. The container writes one empty file
and knows nothing else about the feature; everything that turns that into a
notification, and everything that puts a terminal in front of you
afterwards, runs on the host as your own user.
### The session asks you something
```
┌─ the sandbox container ────────────────────────────────────────────┐
│ claude code, at a Stop hook │
│ │ │
│ │ hooks/dejima-notify.sh writes an empty file named │
│ ▼ `done` - no content, no stdin, no network │
└───────┼────────────────────────────────────────────────────────────┘
════════╪═══ container boundary - one bind-mounted directory ═════════
┌───────▼─── your machine, as your user ─────────────────────────────┐
│ ${XDG_RUNTIME_DIR}/dejima/notify/<run-id>/done │
│ │ │
│ │ inotify, or a ~6s poll where inotify-tools is absent │
│ ▼ │
│ dejima-notify-watch (systemd --user) │
│ │ consumes the marker by NAME; never reads its content │
│ │ every byte of the push is composed from host-side data │
│ ▼ │
└───────┼────────────────────────────────────────────────────────────┘
════════╪═══════════════ off your machine ════════════════════════════
│ curl
▼
your ntfy topic ──── push ────► your phone
"dejima: <project> finished"
link: <base_url>/?arg=<run-id>
```
The marker directory is the sandbox's entire surface here, and it is
one-way: a name, out. Nothing the session writes reaches the push, which is
why the notification says a project has stopped and never what it is asking.
[Notifications](#notifications) has the detail, including where that
guarantee actually lives.
### You come back
```
browser, on the link from the push any plain SSH client
│ │
│ https://<base_url>/?arg=<run-id> │ ssh dejima-box
│ HTTP Basic over TLS │ key with a
▼ ▼ forced command
════════╪══════════════════ your LAN ══════════════════╪══════════════
┌─ your machine, as your user ───────────────────────────────────────┐
│ nginx, listening on ONE LAN address sshd │
│ TLS + auth_basic, plus a default_server authorized_keys with │
│ that refuses every other name a forced command │
│ │ │ │
│ │ proxy_pass http://127.0.0.1:7681 │ │
│ ▼ │ │
│ ttyd --interface 127.0.0.1 --check-origin --url-arg │ │
│ │ │ │
│ └──────────────────────┬───────────────────────┘ │
│ ▼ │
│ dejima-attach [<run-id>] │
│ │ a grouped tmux session │
│ ▼ │
│ YOUR TMUX SERVER │
│ the sandbox's window - and every other │
│ window on it, one prefix key away │
└────────────────────────────────────────────────────────────────────┘
═════════════════════ container boundary ═════════════════════════════
┌─ the sandbox container ────────────────────────────────────────────┐
│ routes to the Docker bridge gateway - your host - and to nothing │
│ else of yours. Neither front door is on that address, which is │
│ the whole reason `ttyd` is on loopback and nginx is on one │
│ named LAN interface. A proxy on `0.0.0.0` or on a bridge puts a │
│ host shell in here. │
└────────────────────────────────────────────────────────────────────┘
```
The web door is the one the notification links into: the browser hits your
reverse proxy, which authenticates and forwards to a `ttyd` that only this
host can open a socket to, and `ttyd` runs `dejima-attach` with the run id
from `?arg=`. The SSH door skips all of that. It is a key in your own
`authorized_keys` pinned to a forced command, so a plain SSH app on the
phone runs `dejima-attach` and nothing else no matter what it asks to run,
and lands on a numbered picker of live sessions. It needs no proxy, no
`ttyd`, and no notification to have been sent.
Both converge on `dejima-attach`, and both end at *your* tmux server. That
convergence is the point: the controls worth spending effort on are the two
front doors, because behind them there is no further boundary to cross - a
grouped tmux session shares every window on the server, and the prefix key
opens new ones regardless.
### What `off` means
With `[remote-access] enabled` off (the default), `dejima` builds a
`docker compose` invocation with nothing remote-access in it: no
session state is written, no marker directory is created or mounted, and no
notification hook is registered. `dejima-attach`, `dejima-ttyd` and
`dejima-notify-watch` each refuse to run at all. `install.sh` symlinks those
three onto `PATH` and stops there - it runs no `systemctl`, copies no unit,
and starts nothing.
## The bind address is the one thing to get right
This is the step that carries the whole feature's security, and the only one
where a plausible-looking choice is a full sandbox escape.
`docker/init-firewall.sh` deliberately ACCEPTs container OUTPUT to the
container's default-route address - the Docker bridge gateway, which is your
host. That is the one host address a sandbox can reach at all. Combine it
@@ -320,6 +134,72 @@ 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 reaching nothing), and it passes on a real Docker host.
## `[remote-access]`
A section of the single config file, at `~/.config/dejima/config`
(`$DEJIMA_CONFIG_DIR` relocates the whole config tree, same as it does for
approvals):
| Key | Default | Purpose |
| --- | --- | --- |
| `enabled` | `off` | the switch - nothing below runs until this is `on` |
| `ntfy_url` | unset | ntfy topic to push notifications to. `dejima-notify-watch` refuses to start without it; nothing else reads it |
| `base_url` | unset | public base URL of the web front door, used to build the notification's deep link. Absent, pushes still go out - just with no link on them |
| `ttyd_port` | `7681` | loopback port `ttyd` listens on. The reverse proxy has to `proxy_pass` to the same one |
| `ttyd_font_size` | unset | terminal font size in px, 8-72. Applies to *every* client, phone and desktop alike - see "Reading it on a phone". Unset passes no option at all and leaves `ttyd` on its own default |
| `ttyd_button_colors` | unset | whether the toolbar is colour-coded by what each button does, in the same gruvbox palette the session picker uses. Unset means yes; `off` restores the grey every key had before. See "Reading it on a phone" |
| `ttyd_focus_on_tap` | unset | whether tapping the terminal raises the soft keyboard. Unset means no on a touch device and yes on a desktop, which is almost always what you want; `on` or `off` forces it. Overridable per client with `?focus=1` / `?focus=0` |
| `ttyd_tmux_prefix` | unset | the tmux prefix the terminal page's `PREFIX`, `◀W`, `W▶` and `ZOOM` buttons send, written as a control chord: `C-a`, `C-b`, `C-Space`, `C-@`, `C-_`, `C-?`. Unset leaves the page on stock tmux's `C-b`. See "Reading it on a phone" |
```
# ~/.config/dejima/config - substitute your own topic and hostname
[remote-access]
enabled = on
ntfy_url = https://ntfy.example.invalid/your-topic-here
base_url = https://dejima.example.invalid
ttyd_port = 7681
# ttyd_font_size = 20 # optional, and applies to the desktop too
# ttyd_tmux_prefix = C-a # optional; the page assumes stock C-b without it
```
**Editing this changes nothing until the services restart.** Every value is
read once, at process start, and baked into `ttyd`'s argv. Skipping the restart
is quiet rather than loud: the front door keeps serving with the old settings,
which reads as "the setting had no effect".
```sh
systemctl --user restart dejima-ttyd.service
systemctl --user restart dejima-notify-watch.service # if ntfy_url changed
pgrep -a ttyd # confirm the argv is what you just configured
```
**If this host sits behind a firewall it does not control**, the push has to be
permitted outbound before any of this works. `dejima-notify-watch` runs on the
host, not in the container, so the container's own allowlist has nothing to do
with whether it can reach your ntfy server - and a self-hosted one on a private
address is exactly the case a host-level egress filter drops. That failure is
the quiet kind: nothing errors, `systemctl --user status dejima-notify-watch`
looks healthy, and the phone simply never buzzes.
**A malformed config is a hard failure, not a warning.** The whole file is
validated on every read, not just the key being asked for, so an unknown key or
an unparseable line makes `dejima-ttyd` and `dejima-notify-watch` refuse to
start and say which line was wrong. So does an out-of-range `ttyd_port`
(1-65535) or `ttyd_font_size` (8-72), or a `ttyd_tmux_prefix` that is not a
control chord - refused here rather than on the phone, hours later, by whoever
presses `PREFIX` and gets nothing.
`ntfy_url` and `base_url` must carry an explicit `http://` or `https://` scheme
and are restricted to `A-Za-z0-9:/._~?=%@+-`. A value without a scheme 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; the character set
is because these end up in an HTTP header and in a URL handed to a phone.
`dejima` itself refuses the same way, since it reads values too.
`dejima-attach` alone reads only `remote_enabled`, deliberately bypassing the
whole-file validation, so a typo elsewhere in the config can't lock you out of
the terminal you'd fix it from.
## SSH front door
Generate a key that exists only for this, and give it a forced command:
@@ -465,6 +345,109 @@ and forgotten. What matters in it:
- **A long `proxy_read_timeout`.** An attached terminal sits idle for hours;
nginx's default is 60 seconds.
## How it works
Almost none of this is in the sandbox. The container writes one empty file
and knows nothing else about the feature; everything that turns that into a
notification, and everything that puts a terminal in front of you
afterwards, runs on the host as your own user.
### The session asks you something
```
┌─ the sandbox container ────────────────────────────────────────────┐
│ claude code, at a Stop hook │
│ │ │
│ │ hooks/dejima-notify.sh writes an empty file named │
│ ▼ `done` - no content, no stdin, no network │
└───────┼────────────────────────────────────────────────────────────┘
════════╪═══ container boundary - one bind-mounted directory ═════════
┌───────▼─── your machine, as your user ─────────────────────────────┐
│ ${XDG_RUNTIME_DIR}/dejima/notify/<run-id>/done │
│ │ │
│ │ inotify, or a ~6s poll where inotify-tools is absent │
│ ▼ │
│ dejima-notify-watch (systemd --user) │
│ │ consumes the marker by NAME; never reads its content │
│ │ every byte of the push is composed from host-side data │
│ ▼ │
└───────┼────────────────────────────────────────────────────────────┘
════════╪═══════════════ off your machine ════════════════════════════
│ curl
▼
your ntfy topic ──── push ────► your phone
"dejima: <project> finished"
link: <base_url>/?arg=<run-id>
```
The marker directory is the sandbox's entire surface here, and it is
one-way: a name, out. Nothing the session writes reaches the push, which is
why the notification says a project has stopped and never what it is asking.
[Notifications](#notifications) has the detail, including where that
guarantee actually lives.
### You come back
```
browser, on the link from the push any plain SSH client
│ │
│ https://<base_url>/?arg=<run-id> │ ssh dejima-box
│ HTTP Basic over TLS │ key with a
▼ ▼ forced command
════════╪══════════════════ your LAN ══════════════════╪══════════════
┌─ your machine, as your user ───────────────────────────────────────┐
│ nginx, listening on ONE LAN address sshd │
│ TLS + auth_basic, plus a default_server authorized_keys with │
│ that refuses every other name a forced command │
│ │ │ │
│ │ proxy_pass http://127.0.0.1:7681 │ │
│ ▼ │ │
│ ttyd --interface 127.0.0.1 --check-origin --url-arg │ │
│ │ │ │
│ └──────────────────────┬───────────────────────┘ │
│ ▼ │
│ dejima-attach [<run-id>] │
│ │ a grouped tmux session │
│ ▼ │
│ YOUR TMUX SERVER │
│ the sandbox's window - and every other │
│ window on it, one prefix key away │
└────────────────────────────────────────────────────────────────────┘
═════════════════════ container boundary ═════════════════════════════
┌─ the sandbox container ────────────────────────────────────────────┐
│ routes to the Docker bridge gateway - your host - and to nothing │
│ else of yours. Neither front door is on that address, which is │
│ the whole reason `ttyd` is on loopback and nginx is on one │
│ named LAN interface. A proxy on `0.0.0.0` or on a bridge puts a │
│ host shell in here. │
└────────────────────────────────────────────────────────────────────┘
```
The web door is the one the notification links into: the browser hits your
reverse proxy, which authenticates and forwards to a `ttyd` that only this
host can open a socket to, and `ttyd` runs `dejima-attach` with the run id
from `?arg=`. The SSH door skips all of that. It is a key in your own
`authorized_keys` pinned to a forced command, so a plain SSH app on the
phone runs `dejima-attach` and nothing else no matter what it asks to run,
and lands on a numbered picker of live sessions. It needs no proxy, no
`ttyd`, and no notification to have been sent.
Both converge on `dejima-attach`, and both end at *your* tmux server. That
convergence is the point: the controls worth spending effort on are the two
front doors, because behind them there is no further boundary to cross - a
grouped tmux session shares every window on the server, and the prefix key
opens new ones regardless.
### What `off` means
With `[remote-access] enabled` off (the default), `dejima` builds a
`docker compose` invocation with nothing remote-access in it: no
session state is written, no marker directory is created or mounted, and no
notification hook is registered. `dejima-attach`, `dejima-ttyd` and
`dejima-notify-watch` each refuse to run at all. `install.sh` symlinks those
three onto `PATH` and stops there - it runs no `systemctl`, copies no unit,
and starts nothing.
## Reading it on a phone
`dejima-ttyd` launches `ttyd` with `--index web/index.html`, which replaces
@@ -474,15 +457,13 @@ there is nothing to turn on.
`ttyd`'s own page is close to unusable on a phone for anything beyond typing,
and not because of a setting anyone missed. xterm.js turns a tap into a mouse
click, but a touch *drag* is consumed by the browser as viewport scrolling and
never becomes a mouse drag - so nothing downstream ever sees one. tmux binds
`MouseDrag1Border` to `resize-pane -M` by default and nvim resizes splits from
separator drags whenever `mouse` is on; both have always worked, and simply
never received a drag event from a touch client. The same missing drag is why
selecting text is impossible, and scrolling an application's output with it.
Then there is no Ctrl key, the tmux status bar's window names are a few pixels
wide, and `ttyd` serves no viewport meta tag at all - so a phone lays the page
out at a virtual desktop width and scales the result down, which no font size
can rescue.
never becomes a mouse drag, so nothing downstream ever sees one: tmux's
`resize-pane -M` and nvim's split-separator drags have always worked and simply
never received the event. The same missing drag is why selecting text and
scrolling an application's output are impossible. Then there is no Ctrl key,
the tmux status bar's window names are a few pixels wide, and `ttyd` serves no
viewport meta tag at all, so a phone lays the page out at a virtual desktop
width and scales the result down, which no font size can rescue.
### Gestures
+83 -117
View File
@@ -2,9 +2,8 @@
[← 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.
The README states each of these rules in a sentence or two. This is the part
that didn't fit: why each one matters, and what goes wrong without it.
## Check whether they want it first.
@@ -33,45 +32,37 @@ The concrete workflow, step by step:
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
An auto-push workflow aimed straight at someone else's repository puts a
session's raw output 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.
flood of low-effort AI-generated pull requests; a Godot maintainer's account of
what that costs is worth reading in full:
<https://bsky.app/profile/akien.bsky.social/post/3meyerixvhs2p>. Forking 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.
dejima narrows the paths code has to leave through; it doesn't close them.
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.
That is not a judgement about how good the containment is, and it does not get
better with a version bump. No containment fully prevents exfiltration: a
determined attacker with code execution and any outbound path will find a way.
The question is not whether you trust dejima enough, but whether you can accept
the risk of the contents leaving.
## 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.
The bind mount doesn't know which files you meant to hand over. Everything a
working tree accumulates sits there with the same reachability as the source
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, an old key on a branch that got checked out. All of it is 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
@@ -82,25 +73,22 @@ 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.
For every allowlist entry, 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.
session that fetches the wrong package version, or scrapes a misleading page,
produces bad input to more code that still has to pass your own review. 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.
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 gone, the charge already happened. Nothing about it passes through
your repository, so no diff downstream catches it. Allowlisting one gives an
unattended process standing authority to act on the world.
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
@@ -114,21 +102,14 @@ 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.
your default branch different from any other is that other things read it and
believe it. Concretely: 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, and it is only justified because the one
way content lands on `main` is through a pull request you merged. Keeping
insecure or broken code off it is your job; nothing about running the agent in
a sandbox does it for you.
## Give it its own identity, then own what you merge.
@@ -152,32 +133,27 @@ 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.
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 only reads the changes 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 judgement 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.
This is a statement about what kind of failure to plan for. dejima's
containment is Linux namespaces and a small capability set around the session,
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): 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 stays intact. A
container-escape vulnerability doesn't erode that protection gradually, it
removes every layer 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.
What "everything reachable from this host account" amounts to is not fixed,
and it is the one part of this that can be narrowed from outside. Bounding
@@ -190,34 +166,24 @@ would have to be bounded.
## 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.
threat model is about the session: what a process running inside the sandbox
can reach, exfiltrate, or execute while it runs. It 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.
Those are properties of the code, decided when it is written.
**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.
**It is not something a future agent can retrofit either.** 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 a pass that goes looking once does
not find them.
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.
So security has to be considered at every point in the work rather than
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. Review, linting, dependency scanning and tests aimed at
the failure modes you care about all still have to happen; dejima stands in for
none of them.
+192 -167
View File
@@ -44,6 +44,14 @@ than the thing inferred from silence.
session that loops until someone notices is not prevented, and this is a
deliberate omission rather than an oversight - see
[Why there are no timeouts](#why-there-are-no-timeouts).
- **A suspended job. Ctrl-Z stops the observer, not the session.** It
suspends `bin/dejima` on the host; the container keeps running, with its
mounts, its firewall grants and its forge token, and keeps making requests
while the terminal looks stopped. That is deliberate - SIGSTOP mid-tool-call
leaves a session wherever it was, and the feature exists to get the terminal
back rather than to pause the work - but a stopped job reads as a paused
agent, and the difference matters most to whoever suspends one because they
are uneasy about what it is doing.
- **The first layer's controls against an agent that has stopped
cooperating.** The briefing and `.dejima.md` are advice. They shape what a
cooperating agent does and stop nothing that has decided otherwise. Only the
@@ -250,13 +258,27 @@ checkout itself and needs no symlink to edit, so a session with write access
to this repo's own checkout can change what the next launch composes and
hands to whichever session starts against it.
The provider directories are in the exit-time audit's TCB list for that
reason, alongside `bin/`, `hooks/`, `docker/` and `test/`. They hold no code
and nothing executes them, but what they hold decides what the next launch
permits: a forge's `fixed_hosts` reaches `DEJIMA_FORGE_HOSTS`, which
`entrypoint.sh` joins into the domains `init-firewall.sh` allowlists, so a
line added to a manifest widens the egress boundary on the next run. An
ordinary edit is a diff you are about to read and is not reported; one git
**Which requires dejima's own checkout to be the workspace, and almost never
is.** Nothing mounts `forges/`, `workflows/`, `harnesses/`, `tools/` or the
rest of the checkout into a container - the resolved values go in, the files
do not - so for every launch pointed at some other project, a session cannot
read these paths, let alone write them. The rest of this section is about the
one configuration where it can: dejima sandboxing the repository it is itself
installed from, which is how dejima is developed. `bin/dejima` says so at
launch when it notices.
In that configuration the provider directories are in the exit-time audit's
TCB list, alongside `bin/`, `hooks/`, `docker/`, `test/` and `tools/`. They
hold no code and nothing executes them, but what they hold decides what the
next launch permits: a forge's `fixed_hosts` reaches `DEJIMA_FORGE_HOSTS`,
which `entrypoint.sh` joins into the domains `init-firewall.sh` allowlists, so
a line added to a manifest widens the egress boundary on the next run.
`tools/*/tool.conf` is the same class, and is read by the host rather than the
container: it names a tarball URL, the sha256 checked against it and the
layout it is unpacked with, all consumed by the image build - so a session
that edits one chooses what the next launch downloads and installs as root.
An ordinary edit is a diff you are about to read and is not reported; one git
cannot see - hidden by `.gitignore`, `.git/info/exclude`, `assume-unchanged`
or `skip-worktree` - is a CRITICAL finding. It is still a change a session
can make; it is one the audit reports. See
@@ -297,178 +319,180 @@ can make; it is one the audit reports. See
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` -
`core.hooksPath`, `core.fsmonitor`, `core.sshCommand`,
`credential.helper`, `core.pager`, `pager.*`, `core.editor`,
`sequence.editor`, `gpg.program`, `diff.external`, `merge.*.driver`,
`diff.*.command`, `filter.*.smudge`/`clean`/`process`, `diff.*.textconv`,
`include.path`/`includeif.*.path` (config indirection - these can point at
a planted file that itself sets an exec key, so the exec key never
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). When the mounted workspace is
this repo itself, it also hashes the sandbox's own trusted computing base -
`bin/`, `hooks/`, `docker/`, `install.sh`, `test/`.
`bin/dejima-audit` snapshots host-persistence surfaces before each run and
diffs them after the container exits. It is **advisory**: 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. It is
silent when nothing changed and never affects the session's exit code.
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.
**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.
**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.
### What it watches
**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:
`.git/config` (and worktree `config.worktree`) keys that run a command on the
next `git` - `core.hooksPath`, `core.fsmonitor`, `core.sshCommand`,
`credential.helper`, `core.pager`, `pager.*`, `core.editor`, `sequence.editor`,
`gpg.program`, `diff.external`, `merge.*.driver`, `diff.*.command`,
`filter.*.smudge`/`clean`/`process`, `diff.*.textconv`,
`include.path`/`includeif.*.path` (config indirection - these can point at a
planted file that itself sets an exec key, so the exec key never 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). When the mounted workspace is this repo itself, it also hashes the
sandbox's own trusted computing base: `bin/`, `hooks/`, `docker/`,
`install.sh`, `test/`.
```
[a] approve this exact content [i] ignore this key from now on [s] skip (ask again next time) [q] quit
```
Coverage is a fixed list rather than a general integrity check, so a config
surface it has not been taught about goes unreported. It does not cover
`[core] share_dir` (see
[Shared scratch space](configuration.md#shared-scratch-space)).
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 would leave the report
unresolvable: `ack` opens CRITICAL reports first, so every other report
would hide behind one no decision could clear.
**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 says so, and gives `git -C <path> config`. That is 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.
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.
### Approving and ignoring are different decisions
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.
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:
- `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
the session it prints the whole current content. To make that possible,
`snapshot` stages a copy of each monitored surface's bytes for the
duration of the session, and `compare` turns those into **bounded
diffs** stored in a `<report>.d/` directory beside the report. What
persists after the session is only the diff of a *modified* surface -
never a whole file. A created surface stores a marker (its content is
live on disk); a removed one stores nothing at all, because a deletion
diff *is* the whole former file.
```
[a] approve this exact content [i] ignore this key from now on [s] skip (ask again next time) [q] quit
```
What it prints is chosen by what changed, which is not always what you
need in order to judge it - three changed lines in a hook script are only
judgeable by reading the script. `[o]` at the prompt opens the file in
`$VISUAL`/`$EDITOR`, read-only where the editor understands how, and
returns to the same decision. It opens the file **as it is now**, not the
text the report captured, for the same reason an approval pins the content
that is on disk at the moment it is recorded: approving something nobody
read would be the one outcome worse than not asking.
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.
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 report walked to the end stops being counted as waiting, but stays on disk as
the record of what that session did. `[audit] keep` decides how many are
retained; rotation evicts one, `ack` never does.
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.
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 would leave the report unresolvable, since `ack` opens
CRITICAL reports first.
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
diff is never mistaken for an absent change - and still shows the live
current content on your terminal, because withholding is about the
persistent store, not about what you are shown while deciding. Surfaces
that hold credentials by convention are never staged in the first
place, so their bytes are never even copied transiently: `.envrc`,
`.mcp.json` and `.claude/settings.local.json` by exact path, and
`.env`/`.env.*` by *basename*, at any depth - so a nested `.claude/.env`
is caught too, not just one at the workspace root. A surface that
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 - 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, 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.
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.
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.
### What `ack` shows you
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.
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 the
session it prints the whole current content. To make that possible, `snapshot`
stages a copy of each monitored surface's bytes for the duration of the session,
and `compare` turns those into **bounded diffs** in a `<report>.d/` directory
beside the report. What persists afterwards is only the diff of a *modified*
surface, never a whole file. A created surface stores a marker (its content is
live on disk); a removed one stores nothing, because a deletion diff *is* the
whole former file.
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.
What it prints is chosen by what changed, which is not always what you need in
order to judge it: three changed lines in a hook script are only judgeable by
reading the script. `[o]` at the prompt opens the file in `$VISUAL`/`$EDITOR`,
read-only where the editor understands how, and returns to the same decision. It
opens the file **as it is now**, for the same reason an approval pins the content
on disk at the moment it is recorded: approving something nobody read would be
the one outcome worse than not asking.
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.
### When content is withheld from the store
In every case below `ack` prints the reason in place of the diff - so an absent
diff is never mistaken for an absent change - and still shows the live current
content on your terminal, because withholding is about the persistent store, not
about what you are shown while deciding.
Surfaces that hold credentials by convention are never staged in the first
place, so their bytes are never even copied transiently: `.envrc`, `.mcp.json`
and `.claude/settings.local.json` by exact path, and `.env`/`.env.*` by
*basename*, at any depth - so a nested `.claude/.env` is caught too.
A surface that 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 cap, and 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 (bounded
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
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 has real
false negatives; the denylist is the reliable half.
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.
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, since 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, so 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 reach your terminal as untrusted text
They name a path or config key the session may have chosen. 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.
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.
## One project's session cannot reach another's
- `~/.claude/projects` (session transcripts and per-project memory) is
@@ -773,10 +797,11 @@ session, where the credential deliberately never goes.
reach the daemon - that is daemon access rather than any process at all,
and it is not something the launcher can change.
- The container runs with `no-new-privileges` and an explicit capability
floor - `cap_drop: ALL` plus the seven it actually uses. Six are the
entrypoint's own; the seventh is `KILL`, which nothing in the entrypoint
needs and `dejima-allow --container` does, because adding a domain to a
running session signals dnsmasq and dnsmasq runs as its own user. The first means a setuid binary or a file capability cannot raise
floor - `cap_drop: ALL` plus the ones it actually uses: what the
entrypoint's root phase needs, and `KILL`. `KILL` is there for
`dejima-allow --container`, not for the entrypoint - adding a domain to a
running session signals dnsmasq, and dnsmasq runs as its own user. The list
itself is in `docker/docker-compose.yml`, which is where it stays correct. The first means a setuid binary or a file capability cannot raise
privileges on exec, which is the shape any escalation from the session user
would have to take; the second means the capabilities nothing here uses were
never in the bounding set to be regained. Both are asserted against a running
+4 -4
View File
@@ -2,16 +2,16 @@
[← dejima README](../README.md)
What dejima defends, what it does not, and where its boundary passes out of
its hands. [security.md](security.md) is the control-by-control account; this
is the shape of the thing those controls are arranged against.
**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).
What dejima defends, what it does not, and where its boundary passes out of
its hands. [security.md](security.md) is the control-by-control account; this
is the shape of the thing those controls are arranged against.
## What this tool assumes
**The agent is hostile, or will be.** Not badly behaved: captured, mid-session,
+142 -6
View File
@@ -502,7 +502,8 @@ _identity_fixture() {
"token_file = $ID_DIR/default-token" \
'[identity.work]' "ssh_key = $ID_DIR/work-key" 'user_name = A Name' \
'user_email = name@work.example' "token_file = $ID_DIR/work-token" \
'[identity.oss]' "ssh_key = $ID_DIR/oss-key"
'[identity.oss]' "ssh_key = $ID_DIR/oss-key" \
'[identity.keyless]' "token_file = $ID_DIR/work-token"
}
@test "an identity supplies the ssh key and the forge credential" {
@@ -515,15 +516,59 @@ _identity_fixture() {
rm -rf "$ID_DIR"
}
@test "a key the identity does not name falls through to the file" {
# An identity overrides what it names. Treating it as a replacement for
# the whole section would make a half-filled one silently unset the rest.
@test "an identity that leaves a credential to the section beneath it is refused" {
# This used to fall through: oss names no token_file, so [forge.gitea]
# answered for it, and a session launched as oss talked to the forge as
# whichever account owns that token. An identity is how one session's
# account is kept separate from another's, so a credential crossing that
# line is the feature not working rather than a convenience it offers.
_identity_fixture
run "$REPO_ROOT/bin/dejima" --identity oss --print-config
[ "$status" -ne 0 ] || { echo "the borrowed token was accepted: $output"; return 1; }
# Which credential, by name - "something is wrong with your identity" is
# not something an operator can act on.
[[ "$output" == *"[forge.gitea] token_file"* ]] \
|| { echo "the refusal does not name the credential: $output"; return 1; }
# And nothing resolved is printed, for the same reason the missing-section
# refusal prints nothing: it would show this machine's token under oss.
[[ "$output" != *"forge.gitea.token_file=$ID_DIR/default-token"* ]] \
|| { echo "printed the borrowed credential anyway: $output"; return 1; }
rm -rf "$ID_DIR"
}
@test "an identity that leaves the ssh key to [core] is refused" {
# The other half, and the one the ambiguity was about: [core] ssh_key is
# the key a session gets when no identity is named, so an identity that
# names none is asking to push as itself with the default account's key.
_identity_fixture
run "$REPO_ROOT/bin/dejima" --identity keyless --print-config
[ "$status" -ne 0 ] || { echo "the borrowed key was accepted: $output"; return 1; }
[[ "$output" == *"[core] ssh_key"* ]] \
|| { echo "the refusal does not name the key: $output"; return 1; }
rm -rf "$ID_DIR"
}
@test "a credential belonging to a forge this launch does not use is not borrowed" {
# Only the selected forge's keys. Refusing over a value nothing on this
# launch reads would make an unrelated section able to block a launch.
_identity_fixture
printf '[forge.github]\npush_token_file = %s\n' "$ID_DIR/default-token" \
>> "$CONFIG_DIR/config"
run "$REPO_ROOT/bin/dejima" --identity work --print-config
[ "$status" -eq 0 ] \
|| { echo "an unused forge's credential blocked the launch: $output"; return 1; }
rm -rf "$ID_DIR"
}
@test "with no identity named, the config's own credentials are the ones used" {
# The unnamed default, which is what [core] ssh_key means. Nothing about
# the refusals above touches a host that never names an identity.
_identity_fixture
run "$REPO_ROOT/bin/dejima" --print-config
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
grep -qx "core.ssh_key=$ID_DIR/oss-key" <<< "$output" || { echo "$output"; return 1; }
grep -qx "core.ssh_key=$ID_DIR/default-key" <<< "$output" || { echo "$output"; return 1; }
grep -qx "forge.gitea.token_file=$ID_DIR/default-token" <<< "$output" \
|| { echo "oss has no token_file and did not fall through: $output"; return 1; }
|| { echo "$output"; return 1; }
rm -rf "$ID_DIR"
}
@@ -2896,3 +2941,94 @@ EOF
[ "$output" -eq 0 ] || { echo "the launch called out:"; grep ls-remote "$git_log"; return 1; }
rm -rf "$workspace" "$GIT_WRAP"
}
@test "[identity.default] is what a launch acts under when none is named" {
# The unnamed default gets a name, which is what lets it carry a git name
# and a forge credential beside the key rather than those coming from
# three different places.
ID_DIR="$(mktemp -d)"; : > "$ID_DIR/default-key"; : > "$ID_DIR/tok"
write_config '[core]' 'forge = gitea' \
'[forge.gitea]' 'ssh_host = git.example' 'api = https://git.example/api/v1' \
'[identity.default]' "ssh_key = $ID_DIR/default-key" \
"token_file = $ID_DIR/tok" 'user_name = dejima agent'
run "$REPO_ROOT/bin/dejima" --print-config
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
grep -qx "core.ssh_key=$ID_DIR/default-key" <<< "$output" \
|| { echo "the default identity did not supply the key: $output"; return 1; }
rm -rf "$ID_DIR"
}
@test "[core] ssh_key alongside [identity.default] is refused, not ranked" {
# Two spellings of one thing. Which key a session pushed with is not
# something to work out from precedence rules.
ID_DIR="$(mktemp -d)"; : > "$ID_DIR/old-key"; : > "$ID_DIR/new-key"
write_config '[core]' 'forge = none' "ssh_key = $ID_DIR/old-key" \
'[identity.default]' "ssh_key = $ID_DIR/new-key"
run "$REPO_ROOT/bin/dejima" --print-config
[ "$status" -ne 0 ] || { echo "both spellings were accepted: $output"; return 1; }
[[ "$output" == *"[core] ssh_key"* ]] && [[ "$output" == *"[identity.default]"* ]] \
|| { echo "the refusal does not name both: $output"; return 1; }
rm -rf "$ID_DIR"
}
@test "[core] ssh_key on its own still works, and says what replaced it" {
# Deprecated, not removed: it is how every config written before
# identities existed says this.
ID_DIR="$(mktemp -d)"; : > "$ID_DIR/old-key"
write_config '[core]' 'forge = none' "ssh_key = $ID_DIR/old-key"
run "$REPO_ROOT/bin/dejima" --print-config
[ "$status" -eq 0 ] || { echo "a pre-identity config stopped working: $output"; return 1; }
grep -qx "core.ssh_key=$ID_DIR/old-key" <<< "$output" || { echo "$output"; return 1; }
[[ "$output" == *"[identity.default]"* ]] \
|| { echo "nothing said what replaced it: $output"; return 1; }
rm -rf "$ID_DIR"
}
@test "a named identity still beats [identity.default]" {
ID_DIR="$(mktemp -d)"; : > "$ID_DIR/default-key"; : > "$ID_DIR/bot-key"
write_config '[core]' 'forge = none' \
'[identity.default]' "ssh_key = $ID_DIR/default-key" \
'[identity.bot]' "ssh_key = $ID_DIR/bot-key"
run "$REPO_ROOT/bin/dejima" --identity bot --print-config
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
grep -qx "core.ssh_key=$ID_DIR/bot-key" <<< "$output" || { echo "$output"; return 1; }
rm -rf "$ID_DIR"
}
@test "the [core] ssh_key deprecation is warned even when a launch does not use it" {
# A config with [core] ssh_key and a named identity is one migration away
# from the refusal - adding [identity.default] would stop it starting.
# Finding that out on the launch that breaks is worse than being told now,
# so the warning is about the config rather than about this launch.
ID_DIR="$(mktemp -d)"; : > "$ID_DIR/old-key"; : > "$ID_DIR/bot-key"
write_config '[core]' 'forge = none' "ssh_key = $ID_DIR/old-key" \
'identity = bot' '[identity.bot]' "ssh_key = $ID_DIR/bot-key"
run "$REPO_ROOT/bin/dejima" --print-config
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[[ "$output" == *"[core] ssh_key is deprecated"* ]] \
|| { echo "no migration warning: $output"; return 1; }
# And says it is not the key in play, so the reader is not left thinking
# this launch pushed with it.
[[ "$output" == *"runs as bot"* ]] \
|| { echo "the warning does not say which account is in force: $output"; return 1; }
# The path, because "move it" is not actionable without knowing what.
[[ "$output" == *"$ID_DIR/old-key"* ]] \
|| { echo "the warning does not name the key: $output"; return 1; }
rm -rf "$ID_DIR"
}
@test "a migrated config is warned about nothing" {
# The other half of a deprecation warning: it has to stop. One that fires
# after the migration is one an operator learns to ignore, which is how
# the next warning gets missed too.
ID_DIR="$(mktemp -d)"; : > "$ID_DIR/key"
write_config '[core]' 'forge = none' \
'[identity.default]' "ssh_key = $ID_DIR/key" 'user_name = dejima agent'
run "$REPO_ROOT/bin/dejima" --print-config
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[[ "$output" != *"deprecated"* ]] \
|| { echo "still warning after the migration: $output"; return 1; }
grep -qx "core.ssh_key=$ID_DIR/key" <<< "$output" \
|| { echo "the migrated key is not the one in force: $output"; return 1; }
rm -rf "$ID_DIR"
}
+18
View File
@@ -1263,3 +1263,21 @@ assert_remedy_forges_exist() {
[ -n "$spec" ] || { echo "$name declares no install and is not in the base image"; return 1; }
done
}
@test "a workflow that omits block_merge blocks merges anyway" {
# The default decides what happens to a workflow that forgets the key -
# a third-party one, or a fourth shipped one added in a hurry. `off` means
# a missing key silently permits merging from inside a session, which is
# the direction a guard should never fail in. Every shipped workflow sets
# it explicitly, so the invariant test covering those would pass either
# way; this is about the ones nobody has written yet.
FAKE_ROOT="$(mktemp -d)"
mkdir -p "$FAKE_ROOT/workflows/forgetful"
printf 'requires =\npush = off\n' > "$FAKE_ROOT/workflows/forgetful/workflow.conf"
run env DEJIMA_PROVIDER_ROOT="$FAKE_ROOT" DEJIMA_CONFIG_DIR="$CONFIG_DIR" \
bash -c 'source "$1"; manifest_get workflow forgetful block_merge' _ "$LIB"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[ "$output" = "on" ] \
|| { echo "a workflow that names no block_merge got '$output'"; return 1; }
rm -rf "$FAKE_ROOT"
}
+186
View File
@@ -4667,6 +4667,47 @@ EOF
done
}
@test "identities are documented as the session's accounts, not the operator's" {
# configuration.md described an [identity.<name>] as grouping what belongs
# to a person rather than a project, and gave "work" and "oss" as the
# example - one human's two accounts. Both README and security.md tell you
# the opposite: give the session an account that is not you. An identity
# is how a host holds several of those, one per session.
local flat
flat="$(tr '\n' ' ' < "$REPO_ROOT/docs/configuration.md" | tr -d '*`' | tr -s ' ')"
grep -qi 'several accounts on one machine, and none of them yours' <<< "$flat" \
|| { echo "identities read as an operator's own accounts again"; return 1; }
# The reason there is more than one, which is the whole feature.
grep -qi 'scope, revoke and read in a log independently' <<< "$flat" \
|| { echo "the separation is unexplained"; return 1; }
# And the claim that sent someone looking for a project-side setting that
# does not exist.
grep -qi 'there is no .dejima/ kind for an identity' <<< "$flat" \
|| { echo "the docs no longer say a project cannot select one"; return 1; }
run grep -c 'identity.work\|identity.oss' <<< "$flat"
[ "$output" -eq 0 ] \
|| { echo "the person-shaped example is back"; return 1; }
# Every identity the prose names has to be one the example block defines.
# Renaming the examples left `oss` and `wrok` behind as references to
# sections that no longer existed, and the greps above passed throughout:
# they match the section-header spelling, and the prose says "the oss
# identity". The one deliberate exception is the name introduced as having
# no section, which is the point of that sentence - so it is allowed only
# on the sentence that makes it.
local doc="$REPO_ROOT/docs/configuration.md" defined name
defined="$(grep -oE '^\[identity\.[a-z0-9-]+\]' "$doc" | sed 's/^\[identity\.//; s/\]$//')"
[ -n "$defined" ] || { echo "the identity example block is gone"; return 1; }
for name in $(grep -oE '(--identity|\[core\] identity =) *`?[a-z][a-z0-9-]*' "$doc" \
| grep -oE '[a-z][a-z0-9-]*$' | sort -u); do
[ "$name" = "identity" ] && continue
grep -qx "$name" <<< "$defined" && continue
grep -qE "no section is refused.{0,80}$name" \
<<< "$(tr '\n' ' ' < "$doc" | tr -s ' ')" \
|| { echo "the prose names identity '$name', which no section defines"; return 1; }
done
}
@test "core never reads a harness's config by a literal harness name" {
# `keys` is declared per harness, so a fixed [harness.<name>] at a call
# site makes it a declaration only one harness can ever be read through:
@@ -6661,3 +6702,148 @@ sys.stdout.write(clean(sys.argv[1]))" "$nasty")"
grep -qi 'gone idle without having stopped' <<< "$flat" \
|| { echo "the docs do not say what is given up"; return 1; }
}
@test "the README's docs index names every doc a reader is meant to find" {
# tools.md shipped and the index did not gain a row, so the only new
# reader-facing document of that release was reachable only by knowing it
# existed. Derived from the directory rather than from a list here, which
# is what makes it catch the next one.
local f name missing=""
for f in "$REPO_ROOT"/docs/*.md; do
name="$(basename "$f")"
# release.md is the maintainer's process, deliberately not offered to
# a reader arriving at the README.
[ "$name" = "release.md" ] && continue
grep -q "(docs/$name)" "$REPO_ROOT/README.md" \
|| missing="$missing $name"
done
[ -z "$missing" ] || { echo "shipped but not in the README index:$missing"; return 1; }
}
@test "the docs do not count the capabilities the container is granted" {
# The count said seven and compose granted eight. A number in prose that
# has to match a list in a file is a number that goes wrong quietly, and
# the repo's own rule is not to write one - so the doc names the split and
# points at the file that holds the list.
local flat
flat="$(tr '\n' ' ' < "$REPO_ROOT/docs/security.md" | tr -d '*`' | tr -s ' ')"
grep -qiE 'cap_drop: ALL plus the (two|three|four|five|six|seven|eight|nine|ten) ' <<< "$flat" \
&& { echo "the capability floor is described by counting again"; return 1; }
grep -q 'docker/docker-compose.yml' <<< "$flat" \
|| { echo "the doc does not say where the real list is"; return 1; }
return 0
}
@test "the README says every shipped workflow blocks merges, because they all do" {
# Derived from the manifests: the claim and the thing it describes move
# together, so a workflow shipped with block_merge off fails this rather
# than leaving the README quietly wrong.
local d name off=""
for d in "$REPO_ROOT"/workflows/*/; do
[ -d "$d" ] || continue
name="$(basename "$d")"
[ "$(lib manifest_get workflow "$name" block_merge)" = "on" ] || off="$off $name"
done
local flat
flat="$(tr '\n' ' ' < "$REPO_ROOT/README.md" | tr -d '*`' | tr -s ' ')"
if [ -z "$off" ]; then
grep -qi 'every workflow dejima ships turns it on' <<< "$flat" \
|| { echo "all of them block merges and the README does not say so"; return 1; }
else
grep -qi 'every workflow dejima ships turns it on' <<< "$flat" \
&& { echo "the README claims all of them, but these do not:$off"; return 1; }
fi
return 0
}
@test "identity_get decides a key is not an identity's before resolving one" {
# bin/dejima's config_get wrapper calls identity_get on every read, and
# all but a handful of keys are ones no identity can answer for. With the
# resolution first, a --print-config went from 2.4s to 41s: it walked
# every forge manifest per key to find out the key was core.harness.
#
# Asserted by order rather than by timing, which would be a flaky test
# about the machine it runs on.
local body guard resolve
body="$(sed -n '/^identity_get() {/,/^}/p' "$REPO_ROOT/bin/dejima-provider-lib.sh")"
[ -n "$body" ] || { echo "identity_get is not where this looks"; return 1; }
guard="$(grep -n 'core)' <<< "$body" | head -1 | cut -d: -f1)"
resolve="$(grep -n 'identity_selected_into' <<< "$body" | head -1 | cut -d: -f1)"
[ -n "$guard" ] && [ -n "$resolve" ] \
|| { echo "the guard or the resolution moved out of identity_get"; return 1; }
[ "$guard" -lt "$resolve" ] \
|| { echo "the identity is resolved before the key is known to need one"; return 1; }
}
@test "the docs say a suspended job is not a paused session" {
# Ctrl-Z stops bin/dejima on the host; the container keeps running with
# its grants. A stopped job reads as a paused agent, and the person most
# likely to be misled is the one who suspended it because they were uneasy
# about what it was doing.
local flat
flat="$(tr '\n' ' ' < "$REPO_ROOT/docs/security.md" | tr -d '*`' | tr -s ' ')"
grep -qi 'Ctrl-Z stops the observer, not the session' <<< "$flat" \
|| { echo "the suspend caveat is not in the negative-space list"; return 1; }
grep -qi 'keeps making requests' <<< "$flat" \
|| { echo "it does not say the session keeps running"; return 1; }
}
@test "the self-hosting caveat names every manifest a session can aim at the next launch" {
# forge.conf was named and tool.conf was not, though the host reads both
# and the image build installs what tool.conf points at, as root.
local flat
flat="$(tr '\n' ' ' < "$REPO_ROOT/docs/security.md" | tr -d '*`' | tr -s ' ')"
# tool.conf, not tools/*/tool.conf: the normalisation strips the emphasis
# markers and takes the glob's asterisk with them.
grep -q 'tool.conf' <<< "$flat" \
|| { echo "tool.conf is not named as session-writable input"; return 1; }
grep -qi 'downloads and installs as root' <<< "$flat" \
|| { echo "what a tool.conf edit buys is unstated"; return 1; }
}
@test "the audit watches every directory whose contents decide the next launch" {
# docs/security.md says the provider directories are in the TCB list
# "for that reason", and tools/ was added to that claim without being
# added to the list - a tool.conf names a tarball and the sha256 checked
# against it, and the host's image build installs what it points at as
# root. Derived from what is on disk rather than from a list here, so the
# next axis is covered the day it lands.
local line dir missing=""
line="$(grep -n 'for dir in bin hooks' "$REPO_ROOT/bin/dejima-audit" | head -1)"
[ -n "$line" ] || { echo "the audit's TCB directory list is not where this looks"; return 1; }
for dir in "$REPO_ROOT"/*/; do
dir="$(basename "$dir")"
# The directories that hold provider or tool manifests, which is what
# this claim is about. Others are covered by their own entries.
case "$dir" in forges|workflows|harnesses|tools) ;; *) continue ;; esac
[ -n "$(find "$REPO_ROOT/$dir" -name '*.conf' -print -quit 2>/dev/null)" ] || continue
grep -qE "(^| )$dir( |;)" <<< "$line" || missing="$missing $dir"
done
[ -z "$missing" ] \
|| { echo "ships manifests the exit audit does not watch:$missing"; return 1; }
}
@test "the self-hosting caveat says it needs dejima's own checkout to be the workspace" {
# Nothing mounts the provider directories into a container, so for every
# launch pointed at another project a session cannot reach these paths at
# all. Without that stated, the section reads as an exposure every user
# has, when it belongs to the one configuration almost nobody runs.
local flat
flat="$(tr '\n' ' ' < "$REPO_ROOT/docs/security.md" | tr -d '*`' | tr -s ' ')"
grep -qi "requires dejima's own checkout to be the workspace" <<< "$flat" \
|| { echo "the precondition is not stated where the consequences are"; return 1; }
grep -qi 'the resolved values go in, the files do not' <<< "$flat" \
|| { echo "it does not say why another project's session cannot reach them"; return 1; }
}
@test "the self-hosting warning names what the audit actually watches" {
# The warning listed bin/, hooks/, docker/ and install.sh; the audit
# watches those plus test/ and every manifest directory. A warning that
# under-names the writable surface is one an operator reads as complete.
local warn
warn="$(sed -n '/own repo is the mounted workspace/,/^fi$/p' "$REPO_ROOT/bin/dejima")"
[ -n "$warn" ] || { echo "the self-hosting warning has moved"; return 1; }
grep -q 'test/' <<< "$warn" || { echo "the warning omits test/"; return 1; }
grep -qi 'manifest' <<< "$warn" \
|| { echo "the warning omits the provider and tool manifests"; return 1; }
}