Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd9fc844df | ||
|
|
b41097c253 | ||
|
|
f9f7a3f60f | ||
|
|
af6913ebbe | ||
|
|
e2a7dcd4dd | ||
|
|
a6585dd050 | ||
|
|
b142c31b22 | ||
|
|
c5b3c5d222 | ||
|
|
3d2e3d2699 | ||
|
|
503addfc8b | ||
|
|
a279460318 | ||
|
|
76a5c5202e |
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
Vendored
+9
-3
@@ -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():
|
||||
|
||||
Vendored
+43
@@ -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()
|
||||
Vendored
+11
-3
@@ -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
@@ -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
|
||||
|
||||
@@ -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' \
|
||||
|
||||
@@ -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
@@ -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" {
|
||||
|
||||
Reference in New Issue
Block a user