Compare commits

...
Author SHA1 Message Date
branderson dd9fc844df Link the security notes' laptop caveat to what still holds there
Refs #387
2026-10-10 20:21:03 -07:00
branderson b41097c253 Point the README's stack table at the standalone answer
Refs #387
2026-10-10 20:07:14 -07:00
branderson f9f7a3f60f Say what dejima on its own gives you, and what to do about the rest
Refs #387
2026-10-10 20:07:09 -07:00
branderson af6913ebbe Merge pull request 'Wait for what teardown killed before removing its directories' (#385) from remote-teardown into development
Reviewed-on: #385
Reviewed-by: branderson <brad@branderson.io>
2026-10-09 23:13:49 -07:00
branderson e2a7dcd4dd Merge pull request 'Stage only the launching project's entry in ~/.claude.json' (#381) from claude-json-scope into development
Reviewed-on: #381
Reviewed-by: branderson <brad@branderson.io>
2026-10-09 23:13:10 -07:00
branderson a6585dd050 Merge pull request 'Take several domains at once in dejima-allow, capped and confirmed' (#383) from allow-multi into development
Reviewed-on: #383
Reviewed-by: branderson <brad@branderson.io>
2026-10-09 23:08:25 -07:00
branderson b142c31b22 Merge pull request 'The self-check retry that missed its own failure, and deadlines that scale with the load' (#379) from reliability into development
Reviewed-on: #379
Reviewed-by: branderson <brad@branderson.io>
2026-10-09 22:49:53 -07:00
branderson c5b3c5d222 Wait for what teardown killed before removing its directories
remote.bats failed intermittently on `rm: cannot remove ...: Directory not
empty` in its own teardown - a red check on a test that had passed. Seen in CI
on #374 and again locally on a branch touching neither this file nor
dejima-attach, which is what said it was the teardown rather than the test.

`kill` sends a signal; it does not wait. The attach client these tests
background - and the script(1) and tmux processes under it - takes a moment to
go, and `tmux kill-server` likewise returns once the server has been told. In
that window `rm -rf` lists a directory, deletes what it saw, and something
still exiting creates another entry before the rmdir.

So teardown waits for each thing to be gone rather than sleeping or hoping: the
client until its pid stops answering, the server until it stops answering
has-session. The removal then retries as a backstop for anything else that
writes on its way out, and reports what is left rather than failing - a leaked
temp directory costs disk, where a red check on a passing test costs a re-run
and some trust in the suite.

Three consecutive full-gate runs clean, with no occurrence of the message. That
is evidence rather than proof for a race, and the suite's own history says so:
#295 called three clean runs "rarer, not impossible".
2026-10-09 21:51:45 -07:00
branderson 3d2e3d2699 Stage only the launching project's entry in ~/.claude.json
That file keys per-project state by absolute path, and dejima copied the whole
map in. So a session was told the name of every project on the host, a sample
of each one's filenames (exampleFiles), what each had cost and when each last
ran - from inside a sandbox whose whole claim is that it cannot learn what you
do elsewhere (#380).

Narrowed for the reason #104 narrowed the projects MOUNT: the whole map is
every project's. The project's own path and anything under it are kept, because
a session's cwd is routinely a worktree inside the project and Claude Code keys
that separately. Compared as strings, which is meaningful here rather than a
guess about layout: both sides of this mount agree on the path by construction.

A `projects` key holding something other than an object is left alone rather
than narrowed. Narrowing it would error, and the staging treats a jq failure as
fatal - so an odd but parseable file would have become a launch that will not
start, over a key with nothing in it to narrow.

The other three directories #380 names are untouched: scoping backups/, tasks/,
plans/ and sessions/ is a behaviour change for the host side, and this one is
not.
2026-10-09 21:51:45 -07:00
branderson 503addfc8b Scale a bounded wait by the concurrency the run asked for
The suites wait on things that take as long as the machine makes them take - a
pty reaching raw mode, a shipper polling, a prompt being answered - and each
wait has a deadline. One that is right on an idle box is wrong under the gate,
which runs every suite at once, and raising it until both pass means picking a
number that is wrong in one of them. #296 reduced that by budgeting the
concurrency; it did not remove it, and the same mistake arrived in a new
fixture three days later, where an idle bound went from sixty seconds to two
hundred and forty.

So each runner says how much concurrency it asked the machine for -
run-confined.sh the budget it divided among the suites, the CI job what it
passes bats - and the fixtures multiply their bounds by how oversubscribed that
makes it. Neither number is a guess about load: it is what that runner asked
for.

Nothing gets shorter than it is today. The multiplier floors at 1, so an idle
run and a run with the variable unset behave exactly as before, and it is
capped because a wrong value here turns a test that should fail in seconds into
one that hangs for the length of the job.
2026-10-09 21:51:45 -07:00
branderson a279460318 Make two tests look at what they are named for
"The permitted set cannot be amended from the session container" checked only
the exit status, so an amendment that landed and then failed on something later
would have passed it - non-zero, with the destination permitted. It now re-reads
what a permit for a name actually writes: the entries file and the resolver
configuration. Not the address sets, which only ever hold addresses, so
grepping those for a hostname is a check that cannot fail.

The negative-control loop in the self-check test iterated the output of a grep,
so renaming those helpers would have left it asserting that none of zero lines
is retried. It counts them.
2026-10-09 21:51:45 -07:00
branderson 76a5c5202e Retry the lookup failure the retry was written for
resolve_one returned 0 when dig itself failed, so retry_positive saw an answer
on the first attempt, printed no retry line, and the caller died on an empty
result. A resolver a moment behind the container is exactly that case - dig
fails rather than answering empty - so the transient the retry exists to absorb
was the one it did not cover (#345).

It returns non-zero for both ways of having no address now. Negative checks
read it the same way either way: they want non-zero, and a lookup that did not
come back is not a name that resolved.

The test guarding this was satisfied by the `return 1` already in that function
for the empty-answer case, so it passed while the property was false. It drives
resolve_one against a stubbed dig instead - failing, empty, and answering - and
the third case matters as much as the first two: a lookup that reported failure
on a good answer would spin the retry on every name and fail the boot on all of
them.
2026-10-09 21:51:45 -07:00
14 changed files with 455 additions and 18 deletions
+5 -1
View File
@@ -171,7 +171,11 @@ jobs:
# otherwise refuses --jobs outright when GNU parallel is absent.
- run: |
chown -R node:node .
su node -c "TERM=xterm-256color bats --jobs $(nproc) --no-parallelize-across-files test/${{ matrix.suite }}.bats"
# DEJIMA_TEST_CONCURRENCY is what this job asks the machine for, which
# the fixtures that wait on a bounded deadline multiply their bounds
# by - see test/fixtures/loadscale.py. One suite per job here, so it
# is the --jobs value and nothing more.
su node -c "TERM=xterm-256color DEJIMA_TEST_CONCURRENCY=$(nproc) bats --jobs $(nproc) --no-parallelize-across-files test/${{ matrix.suite }}.bats"
shellcheck:
# Its own job rather than a matrix entry: it installs a different package,
+3
View File
@@ -151,6 +151,9 @@ what each one buys, what goes wrong without it, and where each one's boundary
ends. None of them is required to run dejima; what they change is what the
layer below the container costs, and whether anyone finds out.
**→ [Using dejima on its own](docs/threat-model.md#using-dejima-on-its-own)** -
what holds without them, what does not, and what to do about it.
**→ [Why a container, and not a microVM?](docs/threat-model.md#why-a-container-and-not-a-microvm)** -
the most common question about this design, and why the effort went into the
layers below the container rather than into a harder one.
+23 -1
View File
@@ -1332,9 +1332,31 @@ harness_hook harness_prepare_config "$SANDBOX_SETTINGS_FILE" || exit 1
# A file jq cannot parse is a refusal, not a fallback to cp. Falling back
# would restore the exact leak this exists to close, at the moment least
# likely to be noticed.
#
# The `projects` map is narrowed to the launching project at the same time, for
# the reason #104 narrowed the projects MOUNT: the whole map is every project's.
# It keys per-project state by absolute path, so a copy of all of it tells a
# session the names of every project on the host, a sample of each one's
# filenames (`exampleFiles`), and what each has cost (#380).
#
# The project's own path and anything under it, because a session's cwd is
# routinely a worktree inside the project and Claude Code keys that separately.
# Paths are compared as strings: both sides of this mount agree on them by
# construction - same uid, same home, same project path - which is what makes
# the comparison meaningful rather than a guess about layout.
SANDBOX_CLAUDE_JSON_FILE="$(mktemp "${TMPDIR:-/tmp}/dejima-claude-json.XXXXXX.json")"
if [ -f "$HOME/.claude.json" ]; then
if ! jq 'del(.primaryApiKey)' "$HOME/.claude.json" > "$SANDBOX_CLAUDE_JSON_FILE"; then
# The type is checked rather than the key's presence: `projects` holding
# something other than an object is not a shape Claude Code writes, and
# narrowing it would error - turning a parseable file into a refused launch
# over a key with nothing in it to narrow.
if ! jq --arg proj "$WORKSPACE_DIR" '
del(.primaryApiKey)
| if (.projects | type) == "object" then
.projects |= with_entries(
select(.key == $proj or (.key | startswith($proj + "/"))))
else . end
' "$HOME/.claude.json" > "$SANDBOX_CLAUDE_JSON_FILE"; then
echo "$HOME/.claude.json is not readable as JSON, so dejima cannot strip the" >&2
echo "credential it may hold before copying it into the container." >&2
exit 1
+6 -1
View File
@@ -1418,7 +1418,12 @@ resolve_one() {
local answers line
# A only: the resolver is configured to answer no AAAA at all, so asking for
# one spends a query to be told nothing.
answers=$(dig +short +time=3 +tries=2 @127.0.0.1 "$1" A 2>/dev/null) || return 0
# Non-zero when dig itself failed, not zero: that is a lookup which did not
# come back - a resolver a moment behind the container is exactly it - and so
# the one case the positive checks' retry exists to absorb. Returning 0 there
# reported success with no address, which retry_positive takes as an answer
# and the caller then dies on (#345).
answers=$(dig +short +time=3 +tries=2 @127.0.0.1 "$1" A 2>/dev/null) || return 1
while IFS= read -r line; do
[[ "$line" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]] || continue
printf '%s\n' "$line"
+3 -1
View File
@@ -34,7 +34,9 @@ than the thing inferred from silence.
to [dejima-devbox-hardener](https://gitbox.branderson.io/dejima/dejima-devbox-hardener).
Run on a laptop with no VM layer, the row below the container is the
operator's own machine, and dejima has nothing to say about it. See
[Which deployment this describes](threat-model.md#which-deployment-this-describes).
[Which deployment this describes](threat-model.md#which-deployment-this-describes),
and [Using dejima on its own](threat-model.md#using-dejima-on-its-own) for
what still holds there and what to do about the rest.
- **Anything finer than a session.** The unit of isolation is one container for
one session. An MCP server, a hook, or a tool the session runs inherits the
session's whole reach - dejima reports that a session *added* an MCP server
+76
View File
@@ -244,6 +244,8 @@ and no answer at all where it does not.
So `dejima-devbox-hardener` is load-bearing rather than optional-and-nice.
On a laptop, dejima is a hardened container and the honest description of what
is under it is "your home directory, and dejima has nothing to say about it".
[Using dejima on its own](#using-dejima-on-its-own) is what that leaves you
with, and what to do about it.
**Load-bearing for what, exactly**, because the field's tiering invites a
reading that project does not accept and has never claimed. A per-workload VM
@@ -263,6 +265,80 @@ repository's behalf.
**What this repository can do about that is say so**, which is the whole of
this section. Nothing in dejima can bound its own host.
## Using dejima on its own
Most installs are this: dejima on a laptop or a workstation, and none of the
sister projects. Everything above about the rows below the container is true,
and it is about one threat, the least likely one on the list.
### What holds
The container row, in full. Nothing in it depends on another project.
- **Disk:** the workspace, the mounts you declare, and this project's own slice
of harness state. Not your home directory, not `~/.ssh`, not your other
projects ([security.md](security.md#one-projects-session-cannot-reach-anothers)).
- **Network:** deny-by-default egress. No port on the host is open, and a
permitted name that answers with a private address is refused unless it is
a destination you configured, so the LAN is out of reach unless you put it
there ([security.md](security.md#network-egress-firewall)).
- **The model credential** stays on the host behind the credential proxy
([security.md](security.md#the-model-api-credential-never-enters-the-container)).
- **The forge:** whatever the token and key you hand the session can reach.
- **Afterwards:** the exit-time audit reports changes to the host-side files a
session could use to run code later, before your next `git` in that repo
([security.md](security.md#the-exit-time-audit)).
Against [the ranked list](#ranked-by-what-is-actually-likely): items 1, 2 and 4
are the container row's, and are handled the same with or without the stack.
Item 3 depends on where your CI runs, not on your machine. Item 5 is a person
deciding, with or without the stack. Item 6, escape, is the one the
devbox-side projects exist for.
### What does not
- **An escape lands on your machine, as you.** Your home directory, your
network position, your credentials. Nothing here bounds that.
- **Pushing is code execution wherever your CI runs**, if the project has CI
([security.md](security.md#ci-push-access-is-code-execution-elsewhere)).
- **Nothing watches the machine between sessions.** The audit covers the
surfaces it lists, for the window the session ran.
### Measured caution
None of these needs another project.
1. **Keep the kernel and the container runtime patched.** An escape needs an
unpatched host or a working zero-day, and the first is the one you control.
2. **Point repos at dejima that you would run yourself.** That is
[an assumption](#what-this-tool-assumes) the rest rests on.
3. **Give the sandbox its own identity**, with tokens scoped to the
repositories it works on, so the forge's reach is what you granted rather
than all of yours ([security.md](security.md#give-the-sandbox-its-own-identity)).
4. **Decide about CI before granting push.** Hosted CI, a runner you are
willing to lose, or no push at all (`[core] forge = none` with
`[core] workflow = local-only`). Not a self-hosted runner with your LAN
behind it ([ci.md](ci.md)).
5. **Treat what a session hands you as its writing:** a command it asks you to
run, a domain or dependency it asks you to approve, and every file in
`share_dir` or a read-write mount. See
[what mounting it costs you](remote-access.md#what-mounting-it-costs-you)
before syncing those anywhere a desktop will open them.
6. **Read the audit before your next `git` in that repo.** `[audit] strict`
refuses a headless launch while CRITICAL findings are unacknowledged.
7. **Leave the credential proxy on.** `proxy = off` is the one arrangement that
puts the real model credential in the container.
### When the rest is worth adding
- **dejima-devbox-hardener**, when your machine sits on a network with things
you would mind an escape reaching. It enforces from outside a VM, so the
first step is moving dejima into one.
- **dejima-ci-hardener, or hosted CI**, as soon as sessions push to a project
whose CI you run yourself.
- **dejima-guard**, when sessions run unattended long enough that "would I
notice?" matters, and you have a second machine to run it from.
## Why a container, and not a microVM
The most frequent question about this project.
+90
View File
@@ -3895,6 +3895,96 @@ EOF
rm -rf "$workspace"
}
@test "the staged ~/.claude.json carries the launching project's entry and no other" {
# The projects map keys per-project state by absolute path, so a verbatim
# copy tells a session the name of every project on the host, a sample of
# each one's filenames (exampleFiles) and what each has cost. Narrowed for
# the reason #104 narrowed the projects MOUNT: the whole map is every
# project's (#380).
_rw_setup
workspace="$(mktemp -d)"
# A worktree under the project, because a session's cwd routinely is one and
# Claude Code keys that path separately - and a sibling whose path merely
# begins with the project's, which is the comparison a bare prefix test
# gets wrong.
#
# The other project's path is deliberately dull. Calling it anything
# credential-shaped - "secret" was the first attempt - makes the staged
# copy trip dejima's own credential scan, so the launch refuses and this
# test passes on a refusal rather than on the narrowing it is named for.
mkdir -p "$workspace/.worktrees/feature"
cat > "$RW_HOME/.claude.json" <<JSON
{"primaryApiKey":"sk-ant-api03-REAL-KEY-FROM-THE-HOST",
"projects":{
"$workspace":{"exampleFiles":["mine.py"],"lastCost":1},
"$workspace/.worktrees/feature":{"exampleFiles":["branch.py"]},
"${workspace}-sibling":{"exampleFiles":["sibling.py"]},
"/home/someone/else/their-project":{"exampleFiles":["theirs.py"],"lastCost":99}},
"numStartups":7}
JSON
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only' \
'harness = claude-code' '[harness.claude-code]' 'backend = anthropic'
local stub log staged
stub="$(mktemp -d)"; log="$(mktemp)"; staged="$(mktemp)"
cat > "$stub/docker" <<EOF
#!/bin/sh
[ -n "\$SANDBOX_CLAUDE_JSON_FILE" ] && cp "\$SANDBOX_CLAUDE_JSON_FILE" "$staged" 2>/dev/null
exit 0
EOF
chmod +x "$stub/docker"
HOME="$RW_HOME" PATH="$stub:$PATH" SSH_AUTH_SOCK= \
run "$REPO_ROOT/bin/dejima" "$workspace"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[ -s "$staged" ] || { echo "no staged ~/.claude.json was mounted"; return 1; }
# The project and anything under it.
[ "$(jq -r --arg p "$workspace" '.projects[$p].lastCost' "$staged")" = 1 ] \
|| { echo "the launching project's own entry is gone"; return 1; }
[ "$(jq -r --arg p "$workspace/.worktrees/feature" '.projects[$p].exampleFiles[0]' "$staged")" = branch.py ] \
|| { echo "a worktree inside the project was dropped"; return 1; }
# And nothing else, including the sibling a bare prefix test would keep.
[ "$(jq -r --arg p "${workspace}-sibling" '.projects | has($p)' "$staged")" = false ] \
|| { echo "a sibling path sharing the project's prefix was kept"; return 1; }
[ "$(jq -r '.projects | has("/home/someone/else/their-project")' "$staged")" = false ] \
|| { echo "another project reached the container"; return 1; }
[ "$(jq -r '.projects | length' "$staged")" = 2 ] \
|| { echo "wrong number of entries: $(jq -c '.projects | keys' "$staged")"; return 1; }
grep -q 'theirs.py' "$staged" \
&& { echo "another project's filenames reached the container"; return 1; }
# The rest of the file is why the copy exists, and the credential still goes.
[ "$(jq -r '.numStartups' "$staged")" = 7 ] || { echo "lost app state"; return 1; }
[ "$(jq -r '.primaryApiKey // "absent"' "$staged")" = absent ] \
|| { echo "the console API key reached the container"; return 1; }
rm -rf "$workspace" "$stub" "$log" "$staged"
}
@test "a projects key that is not a map does not refuse the launch" {
# Narrowing a `projects` that holds a string would error, and the staging
# treats a jq failure as fatal - so an odd but parseable file would become
# a launch that will not start, over a key with nothing in it to narrow.
_rw_setup
workspace="$(mktemp -d)"
printf '{"projects":"not-a-map","numStartups":3}\n' > "$RW_HOME/.claude.json"
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only' \
'harness = claude-code' '[harness.claude-code]' 'backend = anthropic'
local stub staged
stub="$(mktemp -d)"; staged="$(mktemp)"
cat > "$stub/docker" <<EOF
#!/bin/sh
[ -n "\$SANDBOX_CLAUDE_JSON_FILE" ] && cp "\$SANDBOX_CLAUDE_JSON_FILE" "$staged" 2>/dev/null
exit 0
EOF
chmod +x "$stub/docker"
HOME="$RW_HOME" PATH="$stub:$PATH" SSH_AUTH_SOCK= \
run "$REPO_ROOT/bin/dejima" "$workspace"
[ "$status" -eq 0 ] || { echo "an odd projects key refused the launch: $output"; return 1; }
[ "$(jq -r '.numStartups' "$staged")" = 3 ] || { echo "the copy lost its state"; return 1; }
rm -rf "$workspace" "$stub" "$staged"
}
@test "a console API key in ~/.claude.json does not reach the container" {
# Found on a real desktop launch. A Claude Code signed in with a console
# API token keeps the key as a top-level primaryApiKey in ~/.claude.json,
+9 -3
View File
@@ -21,6 +21,9 @@ import select
import sys
import time
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import loadscale # noqa: E402
# The default is the credential scan's prompt. --marker takes another, so a
# test for a different prompt does not need a second copy of this.
MARKER = "[s/a/o/r]"
@@ -33,9 +36,12 @@ AMBIENT = "Press Enter to continue"
# An IDLE timeout: how long to wait with no output before giving up. Generous
# because the thing it guards against is a command that never finishes, and
# any finite bound catches that - while a tight one just times the machine.
# Sixty seconds was enough on an idle box and not enough under the full gate,
# where every suite runs at once (#295).
TIMEOUT = 240.0
#
# Multiplied by how oversubscribed the run asked the machine to be, rather than
# raised again the next time it is not enough: this one went from sixty seconds
# to this because sixty was ample on an idle box and expired under the full
# gate, which is the shape of guess loadscale exists to stop (#295).
TIMEOUT = loadscale.deadline(240.0)
def main():
+43
View File
@@ -0,0 +1,43 @@
"""How much longer a bounded wait should be given on a loaded machine.
The suites here wait on things that take as long as the machine makes them take
- a pty reaching raw mode, a shipper polling, a prompt being answered - and
each wait has a deadline. A deadline that is right on an idle box is wrong
under the gate, which runs every suite at once, and lengthening it until both
work means guessing a number that is generous in one place and useless in the
other (#295).
So the runner says how much concurrency it asked for, and the deadline is
multiplied by how oversubscribed that makes the machine. Nothing gets shorter
than it is today: the multiplier floors at 1, so an idle run and a run with no
variable set behave exactly as before.
Capped, because this is a multiplier on a timeout: a variable set wrongly, or a
machine reporting one core, would otherwise turn a test that should fail in
seconds into one that hangs for the length of the CI job.
DEJIMA_TEST_CONCURRENCY is how many tests the runner intends to have in flight
at once. test/run-confined.sh sets it to the budget it divided among the
suites; the CI workflow sets it to what it passes bats. Neither is a guess
about load - it is what that runner asked the machine for.
"""
import os
MAX_SCALE = 4.0
def scale():
"""The multiplier, >= 1.0 and <= MAX_SCALE."""
try:
want = int(os.environ.get("DEJIMA_TEST_CONCURRENCY", "0"))
except ValueError:
return 1.0
if want <= 0:
return 1.0
cores = os.cpu_count() or 1
return max(1.0, min(MAX_SCALE, want / float(cores)))
def deadline(seconds):
"""A bounded wait, lengthened for the concurrency this run asked for."""
return seconds * scale()
+11 -3
View File
@@ -25,6 +25,9 @@ import sys
import termios
import time
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from loadscale import deadline # noqa: E402
HELPER = os.path.abspath(sys.argv[1])
SHELL = sys.argv[2] if len(sys.argv) > 2 else "bash"
# Required, not defaulted. The fallback was "/tmp", and the three paths below
@@ -200,7 +203,7 @@ def pump(seconds):
def expect(text, timeout=8.0):
"""Wait for text to appear in output since the last call to forget()."""
end = time.time() + timeout
end = time.time() + deadline(timeout)
while time.time() < end:
if text in "".join(seen):
return True
@@ -238,7 +241,7 @@ def reclaimed():
def await_reclaim(timeout=10.0):
end = time.time() + timeout
end = time.time() + deadline(timeout)
while time.time() < end:
if reclaimed():
return True
@@ -259,8 +262,13 @@ def await_raw(timeout=15.0):
scheduling later. Sampling the flags right after fg was timing the machine,
and on a loaded one it read the shell's own canonical mode and reported it
as a failure to restore - the same shape as the reclaim wait above.
Every bound in this file is multiplied by how oversubscribed the run asked
the machine to be (loadscale), because lengthening them until both an idle
box and the full gate pass means picking a number that is wrong in one of
the two. They are unchanged when nothing says otherwise.
"""
end = time.time() + timeout
end = time.time() + deadline(timeout)
while time.time() < end:
if raw_restored():
return True
+45 -1
View File
@@ -33,15 +33,59 @@ setup() {
export XDG_RUNTIME_DIR="$RUNTIME_DIR"
}
teardown() {
# An attach client this test backgrounded. `kill` only sends the signal, and
# the client - plus the script(1) and tmux processes under it - takes a
# moment to go. Removing its directories in that window is what produced
# "rm: cannot remove ...: Directory not empty": rm lists a directory,
# deletes what it saw, and the still-dying client creates something else
# before the rmdir. So the wait is for the process to be gone, not a sleep.
if [ -n "${attach_pid:-}" ]; then
kill "$attach_pid" 2>/dev/null || true
_await_gone kill -0 "$attach_pid"
fi
# Only ever the private server start_tmux created (its own TMUX_TMPDIR),
# never the operator's.
if [ -n "${TMUX_DIR:-}" ]; then
TMUX_TMPDIR="$TMUX_DIR" tmux kill-server 2>/dev/null || true
# kill-server returns once the server has been told, and the server
# unlinks its socket on its own schedule.
_await_gone env TMUX_TMPDIR="$TMUX_DIR" tmux has-session
fi
rm -rf "$CONFIG_DIR" "$RUNTIME_DIR" ${STUB_DIR:+"$STUB_DIR"} ${LAUNCH_DIR:+"$LAUNCH_DIR"} \
_rm_settled "$CONFIG_DIR" "$RUNTIME_DIR" ${STUB_DIR:+"$STUB_DIR"} ${LAUNCH_DIR:+"$LAUNCH_DIR"} \
${TMUX_DIR:+"$TMUX_DIR"} ${FAKE_HOME:+"$FAKE_HOME"}
}
# Waits until the given command FAILS, which is how each of the things above
# reports that it is gone. Bounded, and silent on expiry: teardown's job is to
# clean up, and a cleanup that fails a passing test is the bug being fixed.
_await_gone() {
local i
for i in $(seq 1 50); do
"$@" >/dev/null 2>&1 || return 0
sleep 0.1
done
return 1
}
# rm -rf, retried. The waits above remove the cause; this is the backstop for
# anything else that writes into a temp directory as it exits, and it reports
# what is left rather than failing the test - a leaked temp directory costs
# disk, and a red check on a passing test costs a re-run and some trust in the
# suite.
_rm_settled() {
local d i
for d in "$@"; do
[ -n "$d" ] || continue
for i in 1 2 3 4 5; do
rm -rf "$d" 2>/dev/null
[ -e "$d" ] || break
sleep 0.2
done
[ ! -e "$d" ] \
|| echo "teardown: $d survived removal, holding: $(ls -A "$d" 2>/dev/null | tr '\n' ' ')" >&3
done
}
lib() { bash -c 'source "$1"; shift; "$@"' _ "$LIB" "$@"; }
# Writes a [remote-access] config with enabled = on and base_url set (so
+9
View File
@@ -247,6 +247,15 @@ fi
bats_flags=()
[ "$jobs" -gt 1 ] && bats_flags=(--jobs "$jobs" --no-parallelize-across-files)
# What this run asks the machine for, for the fixtures that wait on a bounded
# deadline. They multiply their bounds by how oversubscribed that makes it
# rather than carrying a number picked to survive the gate, which is a number
# that is then far too generous anywhere else (#295, test/fixtures/loadscale.py).
#
# Exported rather than passed, because it has to reach a python fixture several
# processes below bats.
export DEJIMA_TEST_CONCURRENCY=$(( ${#selected[@]} * jobs ))
{
if [ "$lane" = changed ]; then
printf 'lane: CHANGED - %d of %d confined suites, from %d changed path(s)\n' \
+26
View File
@@ -1479,11 +1479,37 @@ session_pid() { # [container], defaulting to the file-wide one
# running session, and it is the operator's to run from the host. From
# inside the session it must do nothing - it is the same binary in the same
# image, and only the container it runs in differs.
# What the policy would have CHANGED is re-read after each refusal, not
# just the exit status checked: an amendment that landed and then failed on
# something later would exit non-zero with the destination permitted, which
# is the outcome this test is named for and the one a status cannot see.
#
# The entries file and the resolver configuration, because a permit for a
# NAME writes both - the address sets only ever hold addresses, so grepping
# those for a hostname is a check that cannot fail.
local before_entries before_conf after_entries after_conf
before_entries="$(dexec_filter cat /run/egress-policy/entries)"
before_conf="$(dexec_filter cat /run/egress-policy/resolver.conf)"
[ -n "$before_entries" ] && [ -n "$before_conf" ] \
|| { echo "could not read the policy's own record of what is permitted"; return 1; }
run dexec_node /usr/local/sbin/egress-policy permit evil.example.com
[ "$status" -ne 0 ]
run docker exec -u root "$(container_name)" /usr/local/sbin/egress-policy permit evil.example.com
[ "$status" -ne 0 ] \
|| { echo "root in the session container amended the permitted set"; return 1; }
after_entries="$(dexec_filter cat /run/egress-policy/entries)"
after_conf="$(dexec_filter cat /run/egress-policy/resolver.conf)"
[ "$after_entries" = "$before_entries" ] \
|| { echo "the entries file changed despite both refusals"; return 1; }
[ "$after_conf" = "$before_conf" ] \
|| { echo "the resolver configuration changed despite both refusals"; return 1; }
# Named directly as well, so this does not rest on two snapshots matching:
# an entry already present before the test would make them match anyway.
grep -q evil.example <<< "$after_entries$after_conf" \
&& { echo "evil.example.com reached the policy's record of what is permitted"; return 1; }
return 0
}
@test "node has no working sudo" {
+106 -7
View File
@@ -2236,6 +2236,70 @@ _reference_has_key() { # <doc> <section> <leaf>
done
}
@test "a bounded wait is lengthened by what the run asked the machine for" {
# The suites wait on things that take as long as the machine makes them
# take, and each wait has a deadline. One that is right on an idle box is
# wrong under the gate, and raising it until both pass means picking a
# number that is wrong in one of them - which is how a fixture's idle bound
# went from sixty seconds to two hundred and forty (#295).
local ls="$REPO_ROOT/test/fixtures/loadscale.py"
[ -f "$ls" ] || { echo "the shared scale is gone"; return 1; }
# Both runners say what they asked for, or the multiplier is always 1 and
# this does nothing. Derived on each side from the concurrency it chose,
# not a constant either of them picked.
grep -qF 'export DEJIMA_TEST_CONCURRENCY=$(( ${#selected[@]} * jobs ))' \
"$REPO_ROOT/test/run-confined.sh" \
|| { echo "the gate does not say how much concurrency it asked for"; return 1; }
grep -qF 'DEJIMA_TEST_CONCURRENCY=$(nproc) bats --jobs' \
"$REPO_ROOT/.gitea/workflows/tests.yml" \
|| { echo "the CI job does not say how much concurrency it asked for"; return 1; }
# And every fixture that waits on one uses it, rather than carrying a bound
# chosen to survive the gate.
# The import, not the word: both files mention loadscale in a comment
# explaining why their bounds are what they are, so a grep for the name
# passes on a file that stopped using it.
local f
for f in tty-suspend-harness.py answer-prompt.py; do
grep -qE '^(import loadscale|from loadscale import)' "$REPO_ROOT/test/fixtures/$f" \
|| { echo "$f has bounded waits that do not scale with the run"; return 1; }
done
# Driven, because the shape is easy to get right and the arithmetic is what
# matters. Never below 1, so an idle run and one with nothing set behave
# exactly as they do today; capped, because a wrong value here turns a test
# that should fail in seconds into one that hangs for the whole job.
local cores; cores="$(nproc)"
run env -u DEJIMA_TEST_CONCURRENCY python3 -c "
import sys; sys.path.insert(0, '$REPO_ROOT/test/fixtures')
import loadscale; print(loadscale.deadline(10.0))"
[ "$status" -eq 0 ] && [ "$output" = "10.0" ] \
|| { echo "an unset variable changed a deadline: $output"; return 1; }
run env DEJIMA_TEST_CONCURRENCY=$(( cores * 2 )) python3 -c "
import sys; sys.path.insert(0, '$REPO_ROOT/test/fixtures')
import loadscale; print(loadscale.deadline(10.0))"
[ "$output" = "20.0" ] \
|| { echo "twice the cores did not double a deadline: $output"; return 1; }
run env DEJIMA_TEST_CONCURRENCY=$(( cores / 2 )) python3 -c "
import sys; sys.path.insert(0, '$REPO_ROOT/test/fixtures')
import loadscale; print(loadscale.deadline(10.0))"
[ "$output" = "10.0" ] \
|| { echo "an under-subscribed run shortened a deadline: $output"; return 1; }
local junk
for junk in 99999999 -5 "" notanumber; do
run env DEJIMA_TEST_CONCURRENCY="$junk" python3 -c "
import sys; sys.path.insert(0, '$REPO_ROOT/test/fixtures')
import loadscale; print(loadscale.scale())"
[ "$status" -eq 0 ] || { echo "'$junk' made the scale raise: $output"; return 1; }
awk -v s="$output" 'BEGIN { exit !(s >= 1.0 && s <= 4.0) }' \
|| { echo "'$junk' produced a scale of $output"; return 1; }
done
}
@test "a changed path selects every suite that reads it, not just the one that owns it" {
# .dejima.md says nothing can tell that a rule is too narrow. A human
# cannot; this can. For each mapped path, every suite whose text names its
@@ -5481,19 +5545,54 @@ registered_commands() {
|| { echo "the reachability check is not retried"; return 1; }
# The negatives: not.
local line
local line seen=0
while IFS= read -r line; do
seen=$((seen + 1))
case "$line" in
*retry_positive*) echo "a negative control is retried: $line"; return 1 ;;
esac
done < <(grep -E 'verify_refused|verify_port_refused|verify_ipv6_refused|PROBE_NAME\)' <<< "$v")
# Counted, because a loop over nothing passes: renaming those helpers would
# leave this asserting that none of zero lines retries.
[ "$seen" -ge 3 ] \
|| { echo "found $seen negative controls in verify(), so this checked almost nothing"; return 1; }
# And the lookup has to report emptiness, or retrying it is a no-op: it
# returns 0 whether or not it found an address otherwise.
local r
r=$(sed -n '/^resolve_one()/,/^}/p' "$p")
[ "$(grep -c 'return 1' <<< "$r")" -ge 1 ] \
|| { echo "resolve_one cannot signal 'no address', so retrying it does nothing"; return 1; }
# And the lookup has to report BOTH ways of having no address, or retrying
# it is a no-op for one of them. Driven rather than grepped: `return 1`
# appears in that function for the empty-answer case, so its presence was
# satisfied while a failing dig still returned 0 (#345).
local case_name
for case_name in failed empty; do
run bash -c '
set -u
d="$2/digstub"; mkdir -p "$d"
if [ "$3" = failed ]; then
printf "#!/bin/bash\nexit 9\n" > "$d/dig"
else
printf "#!/bin/bash\nexit 0\n" > "$d/dig"
fi
chmod +x "$d/dig"
PATH="$d:$PATH"
eval "$(sed -n "/^resolve_one()/,/^}/p" "$1")"
resolve_one some.example
' _ "$p" "$BATS_TEST_TMPDIR" "$case_name"
[ "$status" -ne 0 ] \
|| { echo "resolve_one reported success for a $case_name lookup, so retrying it does nothing"; return 1; }
done
# And it still succeeds when there IS an address, or the retry would spin
# on every name and the boot would fail on all of them.
run bash -c '
set -u
d="$2/digok"; mkdir -p "$d"
printf "#!/bin/bash\necho 192.0.2.7\n" > "$d/dig"
chmod +x "$d/dig"
PATH="$d:$PATH"
eval "$(sed -n "/^resolve_one()/,/^}/p" "$1")"
resolve_one some.example
' _ "$p" "$BATS_TEST_TMPDIR"
[ "$status" -eq 0 ] || { echo "resolve_one failed on a good answer: $output"; return 1; }
[ "$output" = 192.0.2.7 ] || { echo "resolve_one did not print the address: $output"; return 1; }
}
@test "a retried check reports the status the command actually returned" {