Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77b2d02866 | ||
|
|
0c2ceba35b | ||
|
|
4d1bb99180 | ||
|
|
4163ec12cd | ||
|
|
028fd8fb7f | ||
|
|
69713202ad | ||
|
|
decf60fa9e | ||
|
|
5c0f4b80c3 | ||
|
|
1587a154fd | ||
|
|
17602234de | ||
|
|
7585c4e8b6 | ||
|
|
a5b35c3750 | ||
|
|
4bdf3a7f14 | ||
|
|
6f9b93517e | ||
|
|
7b82328541 | ||
|
|
4c63db4891 |
@@ -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) |
|
||||
|
||||
+6
-9
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user