Compare commits
58
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c637d1dee6 | ||
|
|
6a0ec12d41 | ||
|
|
44bcee193f | ||
|
|
c88240b4b6 | ||
|
|
c6a0a3ff77 | ||
|
|
e953bfa5fa | ||
|
|
5e0a390839 | ||
|
|
88d54cab78 | ||
|
|
d6f7f8c9d7 | ||
|
|
881e6343e5 | ||
|
|
668c3feccf | ||
|
|
8fb4a4d39b | ||
|
|
3b6c5c016f | ||
|
|
0a6b55b5b9 | ||
|
|
e250c705ed | ||
|
|
2cbf1a414c | ||
|
|
3100a5f454 | ||
|
|
b32c8921c2 | ||
|
|
e151faf4ec | ||
|
|
251beaab45 | ||
|
|
af6f0e4aa7 | ||
|
|
3a6c233a0f | ||
|
|
924dd4ed51 | ||
|
|
271d7843fe | ||
|
|
9b6ad238f3 | ||
|
|
19fb87c1f8 | ||
|
|
75eb427e62 | ||
|
|
aa3a198e26 | ||
|
|
1de83f8a16 | ||
|
|
bd96919504 | ||
|
|
a721e3d1fb | ||
|
|
d7e4c6be0a | ||
|
|
2c31d83a49 | ||
|
|
077cd241a7 | ||
|
|
4e47c30457 | ||
|
|
5b7250f8f0 | ||
|
|
f1f2fe4b02 | ||
|
|
a4f4d1428f | ||
|
|
a301bae1c4 | ||
|
|
849ec326a2 | ||
|
|
a0b45bdacc | ||
|
|
77e65513d9 | ||
|
|
a41aebb09f | ||
|
|
b872fabdf6 | ||
|
|
1e788548a0 | ||
|
|
3734a614bd | ||
|
|
55c33a8cc5 | ||
|
|
95b90761dd | ||
|
|
a438bf368b | ||
|
|
2a20ae62cc | ||
|
|
2efb93c644 | ||
|
|
cdad2fb1e9 | ||
|
|
b1d7975aee | ||
|
|
f2f40caa6b | ||
|
|
6f4e176400 | ||
|
|
bc5e5dc05b | ||
|
|
3a26bd2618 | ||
|
|
483e44978d |
+17
-7
@@ -100,13 +100,23 @@ locally is not a reason to stop, to hedge, or to leave a change unfinished. Run
|
||||
the confined suites, open the PR, and say plainly in the PR body that the
|
||||
privileged half was not run locally.
|
||||
|
||||
**But do not read "CI covers it" as "it passed."** That job is pinned to
|
||||
`runs-on: dejima-privileged`, a label only the one configured self-hosted runner
|
||||
carries — it needs the host network namespace, the Docker socket, and a shared
|
||||
volume, none of which a workflow can request. If that runner is offline the job
|
||||
**queues instead of failing**, and the PR sits with the runtime half neither
|
||||
green nor red. Check the PR's actual checks before treating it as verified, and
|
||||
if the job is still queued, say so rather than implying it ran.
|
||||
**Green means it ran.** The forge does not show a check green until every job
|
||||
in it has finished and passed, and `runtime.bats` is one of those jobs — so a
|
||||
green PR is one where the privileged half executed. There is no state in which
|
||||
the checks are green and that suite was skipped.
|
||||
|
||||
What a queued run looks like is yellow or grey, not green. That job is pinned
|
||||
to `runs-on: dejima-privileged`, a label only the one configured self-hosted
|
||||
runner carries — it needs the host network namespace, the Docker socket and a
|
||||
shared volume, none of which a workflow can request — so if that runner is
|
||||
offline the job **queues instead of failing**, and the PR sits unfinished
|
||||
rather than passing.
|
||||
|
||||
So: read the colour, and say what it says. Green is verified end to end and
|
||||
should be reported that way. Yellow or grey is a run that has not happened yet,
|
||||
and reporting it as passing is the thing to avoid. Hedging a green one is the
|
||||
opposite error and is the more common one here — it reads as doubt about a
|
||||
result that is not in doubt.
|
||||
|
||||
If a change touches what gets mounted, whether the firewall runs, or which
|
||||
credentials cross the container boundary, that runtime run is the only thing
|
||||
|
||||
@@ -80,6 +80,7 @@ jobs:
|
||||
matrix:
|
||||
suite:
|
||||
- static
|
||||
- launch
|
||||
- project
|
||||
- remote
|
||||
- audit
|
||||
@@ -327,9 +328,30 @@ jobs:
|
||||
echo "resolves for this architecture." >&2
|
||||
exit 1
|
||||
}
|
||||
# compose, from the same place and for a reason of the same kind: it
|
||||
# is a plugin, so without it `docker compose` is not a command at
|
||||
# all. Every launch on a real host goes through one, and nothing in
|
||||
# CI did - so the compose file was the one input to a launch that no
|
||||
# job had ever parsed.
|
||||
#
|
||||
# Its assets are named for `uname -m`, like the CLI tarball above and
|
||||
# unlike buildx, which uses the Debian architecture name.
|
||||
curl -fsSL -o /usr/local/lib/docker/cli-plugins/docker-compose \
|
||||
"https://github.com/docker/compose/releases/download/v${COMPOSE_VERSION}/docker-compose-linux-$(uname -m)"
|
||||
chmod +x /usr/local/lib/docker/cli-plugins/docker-compose
|
||||
# Checked for the same reason buildx is: an error page chmods just as
|
||||
# well as a binary, and the symptom would be a test that skips rather
|
||||
# than one that fails.
|
||||
docker compose version || {
|
||||
echo "The compose plugin did not load, so the compose-file test will skip" >&2
|
||||
echo "instead of running. Check the download URL above still resolves for" >&2
|
||||
echo "this architecture." >&2
|
||||
exit 1
|
||||
}
|
||||
env:
|
||||
DOCKER_CLI_VERSION: 27.5.1
|
||||
BUILDX_VERSION: 0.36.0
|
||||
COMPOSE_VERSION: 5.5.1
|
||||
|
||||
# Fails here, with the fix in the message, rather than thirty tests deep
|
||||
# in a suite that cannot work. Both of these are runner configuration,
|
||||
|
||||
+417
-6
@@ -645,6 +645,10 @@ unset _tools_wanted _tool
|
||||
|
||||
AUDIT_DIR="$(config_get audit dir)"
|
||||
BASELINE_DIR="$(config_get audit baselines_dir)"
|
||||
# Read before either guard runs, because what this list declares becomes
|
||||
# something the other two lists are refused for naming - see PP_PROJECT_ROOTS.
|
||||
SANDBOX_RW_PP_MOUNTS="$(config_get mounts read_write_per_project)"
|
||||
|
||||
SANDBOX_EXTRA_MOUNTS="$(config_get mounts readonly)"
|
||||
SANDBOX_EXTRA_MOUNTS="$(_append_mounts "$SANDBOX_EXTRA_MOUNTS" \
|
||||
${EXTRA_RO_FLAGS[@]+"${EXTRA_RO_FLAGS[@]}"})"
|
||||
@@ -745,6 +749,30 @@ _harness_host_config
|
||||
# arrives at ~/shared without going through this list; naming the root that
|
||||
# holds every project's would put the pile back, which is the thing the split
|
||||
# removed.
|
||||
# Refuse $2 if it reaches a per-project root. Its own check rather than a row
|
||||
# in GUARDED_STORES, because the generic refusal there is about approval and
|
||||
# audit stores and says so - an operator who wrote the same path into two
|
||||
# mount lists would be told their specs directory lets a session approve its
|
||||
# own domains, which is both untrue and no help.
|
||||
_refuse_pp_root() {
|
||||
local label="$1" path="$2" real root root_real
|
||||
_pp_project_roots
|
||||
[ ${#PP_PROJECT_ROOTS[@]} -gt 0 ] || return 0
|
||||
real="$(readlink -m "$path")"
|
||||
for root in "${PP_PROJECT_ROOTS[@]}"; do
|
||||
root_real="$(readlink -m "$root")"
|
||||
_paths_overlap "$real" "$root_real" || continue
|
||||
echo "Refusing $label: $path" >&2
|
||||
echo "It reaches $root_real, which holds one directory per project for a" >&2
|
||||
echo "[mounts] read_write_per_project entry." >&2
|
||||
echo "" >&2
|
||||
echo "Handing that over is handing over every project's, which is the pile" >&2
|
||||
echo "slicing it removed. If this path is meant to be shared by every" >&2
|
||||
echo "project, it belongs in one list or the other and not in both." >&2
|
||||
exit 1
|
||||
done
|
||||
}
|
||||
|
||||
GUARDED_STORES=()
|
||||
_guarded_stores() {
|
||||
[ ${#GUARDED_STORES[@]} -eq 0 ] || return 0
|
||||
@@ -753,6 +781,28 @@ _guarded_stores() {
|
||||
"$(share_projects_root "$(config_get core share_dir)")")
|
||||
}
|
||||
|
||||
# The `projects` subdirectory of every read_write_per_project root: the
|
||||
# directory that holds one slice per project, exactly as share_dir's does.
|
||||
#
|
||||
# Guarded separately from the stores above because the two lists need
|
||||
# different answers. For `readonly` and `read_write`, naming one of these is
|
||||
# the mistake the share guard already names: handing over the root that holds
|
||||
# every project's directory puts back the pile the split removed. For the
|
||||
# per-project list itself it is not a mistake at all - it is where that list
|
||||
# puts things - so that loop consults GUARDED_STORES alone.
|
||||
PP_PROJECT_ROOTS=()
|
||||
_pp_project_roots() {
|
||||
[ ${#PP_PROJECT_ROOTS[@]} -eq 0 ] || return 0
|
||||
local path
|
||||
[ -n "$SANDBOX_RW_PP_MOUNTS" ] || return 0
|
||||
IFS=':' read -ra _pp_declared <<< "$SANDBOX_RW_PP_MOUNTS"
|
||||
for path in "${_pp_declared[@]}"; do
|
||||
[ -n "$path" ] || continue
|
||||
PP_PROJECT_ROOTS+=("$(projects_root "$path")")
|
||||
done
|
||||
unset _pp_declared
|
||||
}
|
||||
|
||||
# Refuse $2 if it overlaps any of the paths in $3.., in either direction: an
|
||||
# ancestor of a store exposes it just as surely as the store itself.
|
||||
_refuse_guarded_mount() {
|
||||
@@ -802,6 +852,7 @@ if [ -n "$SANDBOX_RW_MOUNTS" ]; then
|
||||
# epitaph store be mountable from here after it was added to one of
|
||||
# them. A store added to _guarded_stores is now guarded on both
|
||||
# paths without anyone remembering to do it twice.
|
||||
_refuse_pp_root "$(_mount_label "$path" "[mounts] read_write entry" "--extra-read-write path")" "$path"
|
||||
_guarded_stores
|
||||
for guarded in ${GUARDED_STORES[@]+"${GUARDED_STORES[@]}"} \
|
||||
${HARNESS_HOST_CONFIG[@]+"${HARNESS_HOST_CONFIG[@]}"} \
|
||||
@@ -837,6 +888,78 @@ if [ -n "$SANDBOX_RW_MOUNTS" ]; then
|
||||
done
|
||||
fi
|
||||
|
||||
# [mounts] read_write_per_project: the same idea as read_write, sliced the way
|
||||
# [core] share_dir is. An operator declares ~/specs; the container gets ~/specs,
|
||||
# backed by ~/specs/projects/<slug> on the host rather than by the whole thing.
|
||||
#
|
||||
# Which keeps the property that separates these lists from share_dir - a mount
|
||||
# arrives at the host path it names, so a project instruction saying "put specs
|
||||
# in ~/specs" goes on working - while removing the one share_dir was split to
|
||||
# remove: every session seeing every other project's, across projects. A spec
|
||||
# is written to be read as background, which makes that threat-model item 4
|
||||
# with nobody attacking anything.
|
||||
#
|
||||
# Its own key rather than a marker on read_write, and that is what makes it
|
||||
# small: a key that did not previously parse has nothing in it on the day it
|
||||
# lands. Nothing moves, nothing is stranded, and there is no migration. Some
|
||||
# writable mounts are meant to be shared - a cache, a bin directory - and
|
||||
# read_write stays right for those.
|
||||
RW_PP_MOUNT_SOURCES=()
|
||||
RW_PP_MOUNT_TARGETS=()
|
||||
DEJIMA_RW_PP_MOUNTS=""
|
||||
# Every host path this launch makes writable, which for a plain read_write
|
||||
# entry is the path itself and for a per-project one is the slice. The audit
|
||||
# reads this; the briefing reads the two target lists.
|
||||
DEJIMA_RW_SOURCES="$DEJIMA_RW_MOUNTS"
|
||||
if [ -n "$SANDBOX_RW_PP_MOUNTS" ]; then
|
||||
IFS=':' read -ra rw_pp_paths <<< "$SANDBOX_RW_PP_MOUNTS"
|
||||
for path in "${rw_pp_paths[@]}"; do
|
||||
[ -n "$path" ] || continue
|
||||
# The declared root must exist, like a read_write entry: dejima creates
|
||||
# the slice below it, never the root itself, so a typo stays a typo
|
||||
# rather than becoming a new empty directory nobody meant.
|
||||
if [ ! -d "$path" ]; then
|
||||
echo "[mounts] read_write_per_project entry is not an existing directory: $path" >&2
|
||||
echo "dejima creates the per-project directory inside it, but not the" >&2
|
||||
echo "directory itself - so a path that is not there is a typo, not a" >&2
|
||||
echo "layout to be built." >&2
|
||||
exit 1
|
||||
fi
|
||||
pp_real="$(readlink -m "$path")"
|
||||
# GUARDED_STORES alone, deliberately: PP_PROJECT_ROOTS holds this
|
||||
# list's own `projects` directories, which the other two lists are
|
||||
# refused for naming and this one exists to write into.
|
||||
_guarded_stores
|
||||
for guarded in ${GUARDED_STORES[@]+"${GUARDED_STORES[@]}"} \
|
||||
${HARNESS_HOST_CONFIG[@]+"${HARNESS_HOST_CONFIG[@]}"} \
|
||||
"$HOME/.local/bin" "$REPO_ROOT" "$WORKSPACE_DIR"; do
|
||||
guarded_real="$(readlink -m "$guarded")"
|
||||
_paths_overlap "$pp_real" "$guarded_real" || continue
|
||||
echo "Refusing [mounts] read_write_per_project entry: $path" >&2
|
||||
echo "It overlaps $guarded_real, which dejima needs out of a session's reach." >&2
|
||||
echo "Slicing it per project does not change what the session could reach" >&2
|
||||
echo "through it - only how much of it there is." >&2
|
||||
exit 1
|
||||
done
|
||||
# Resolved here and created later, on the launch path. This block runs
|
||||
# before --print-briefing so the briefing it composes names the same
|
||||
# mounts a launch would, and a diagnostic that prints something should
|
||||
# not leave a directory behind - which is why share_dir's own mkdir
|
||||
# sits after that exit too.
|
||||
pp_source="$(project_dir_for "$path" "$WORKSPACE_DIR")"
|
||||
RW_PP_MOUNT_TARGETS+=("$path")
|
||||
RW_PP_MOUNT_SOURCES+=("$pp_source")
|
||||
# Two lists, because the two readers want different answers and for a
|
||||
# per-project mount they differ. The briefing tells the SESSION what it
|
||||
# can write, which is the path inside the container. The exit audit
|
||||
# tells the OPERATOR what on their host this session could reach, and
|
||||
# naming the root there would claim a blast radius this feature exists
|
||||
# to have narrowed.
|
||||
DEJIMA_RW_PP_MOUNTS="${DEJIMA_RW_PP_MOUNTS:+$DEJIMA_RW_PP_MOUNTS }$path"
|
||||
DEJIMA_RW_SOURCES="${DEJIMA_RW_SOURCES:+$DEJIMA_RW_SOURCES }$pp_source"
|
||||
done
|
||||
fi
|
||||
|
||||
# --print-briefing composes and exits, the same way --print-config does -
|
||||
# before anything is built or mounted, and before a workspace argument is
|
||||
# required. It exists so the composed briefing can be inspected (and, per
|
||||
@@ -884,6 +1007,12 @@ if [ "$(manifest_get forge "$DEJIMA_FORGE_NAME" hosts_allowlisted_by_core)" = "o
|
||||
else
|
||||
DEJIMA_FORGE_HOSTS="$_forge_all_hosts"
|
||||
fi
|
||||
# Exported as well as passed to the sandbox with `-e NAME=value`. The filter
|
||||
# container is started with `docker run` and takes this the `-e NAME` way, so
|
||||
# it has to be in this process's environment - and that form is what keeps a
|
||||
# value out of a command line, which is the rule the credential beside it in
|
||||
# that list follows.
|
||||
export DEJIMA_FORGE_HOSTS
|
||||
if [ "$DEJIMA_WORKFLOW_PUSH" = "on" ]; then
|
||||
DEJIMA_PUSH_HOSTS="$_forge_all_hosts"
|
||||
else
|
||||
@@ -1222,6 +1351,30 @@ SANDBOX_AUDIT_STATE="$(mktemp "${TMPDIR:-/tmp}/dejima-audit.XXXXXX")"
|
||||
# and SANDBOX_DERIVED_CONTEXT is one of the `rm -rf` arguments.
|
||||
DEJIMA_NOTIFY_DIR=""
|
||||
DEJIMA_SESSION_FILE=""
|
||||
# The debounce removal in cleanup() joins this to a run id and removes the
|
||||
# result, so it belongs here for the same reason as the two above and was
|
||||
# missing: with remote access off nothing assigns it, and an operator with
|
||||
# DEJIMA_STATE_DIR exported would have had the trap remove a path of their
|
||||
# environment's choosing rather than one dejima made.
|
||||
DEJIMA_STATE_DIR=""
|
||||
# These three are assigned again further down, where each is read - the
|
||||
# briefing file when it is composed, the other two among the credential
|
||||
# proxy's own variables. Both of those sites are BELOW `trap cleanup EXIT`,
|
||||
# with ten and twelve ordinary `exit` paths between, so on any of those exits
|
||||
# the trap ran against whatever the environment held. SANDBOX_CREDENTIAL_PROXY_DIR
|
||||
# is an `rm -rf` argument.
|
||||
#
|
||||
# Bound here as well rather than moved, because the later sites are read as
|
||||
# "what this feature starts from" and are correct as that. This block is a
|
||||
# different statement: what the trap must never inherit.
|
||||
SANDBOX_BRIEFING_FILE=""
|
||||
SANDBOX_CREDENTIAL_PROXY_DIR=""
|
||||
SANDBOX_FAKE_CREDENTIALS_FILE=""
|
||||
# The same rule, for two names cleanup() hands to `docker rm` rather than to
|
||||
# `rm`. An inherited value there removes a container or a network belonging to
|
||||
# something else on this host, which is a worse thing to get wrong than a file.
|
||||
SANDBOX_FILTER_CONTAINER=""
|
||||
SANDBOX_RUN_NETWORK=""
|
||||
SANDBOX_NVIM_LOCKFILE=""
|
||||
SANDBOX_DERIVED_DOCKERFILE=""
|
||||
SANDBOX_DERIVED_CONTEXT=""
|
||||
@@ -1230,6 +1383,17 @@ SANDBOX_TTY_SUSPEND_PID=""
|
||||
SANDBOX_TTY_MODES=""
|
||||
SANDBOX_CLIENT_PID=""
|
||||
|
||||
# This launch's identity, generated for every launch rather than only for a
|
||||
# remote-enabled one, and here rather than further down for the same reason as
|
||||
# the block above: cleanup() reads it, so it must be dejima's own value by the
|
||||
# time the trap is installed and not whatever arrived in the environment.
|
||||
#
|
||||
# It names the registry entry and the marker directory when remote access is
|
||||
# on, and nothing when it is off - but it is the launch's identity either way,
|
||||
# and deriving it from whether one optional feature is configured made it
|
||||
# absent exactly where an inherited value could stand in for it.
|
||||
DEJIMA_RUN_ID="$(od -An -tx1 -N8 /dev/urandom | tr -d ' \n')"
|
||||
|
||||
# Names each host-side task in cleanup() as it starts. Between the container
|
||||
# exiting and the operator's shell prompt returning, dejima audits every
|
||||
# surface the session could have written to and reads back the transcripts it
|
||||
@@ -1266,10 +1430,15 @@ cleanup() {
|
||||
#
|
||||
# SANDBOX_GITCONFIG_DIR additionally carries a `:-` default because it is
|
||||
# the one name here first assigned BELOW `trap cleanup EXIT` rather than
|
||||
# above it. Nothing in that window can exit today (it holds only
|
||||
# comments, and a failed `VAR="$(mktemp -d)"` still binds VAR to the
|
||||
# empty string before set -e fires), so this is the invariant every other
|
||||
# name here already keeps, not a live fix - see the static test.
|
||||
# above it. What makes that safe is not the `:-` but the window: it holds
|
||||
# only comments, so there is no exit between the trap and the assignment
|
||||
# for the trap to fire on (and a failed `VAR="$(mktemp -d)"` still binds
|
||||
# VAR to the empty string before set -e fires).
|
||||
#
|
||||
# That is a property of this one name and of nothing else here, so the
|
||||
# static test asserts it by exception: every other name the trap removes
|
||||
# is bound above the trap, and adding a second name to the window fails
|
||||
# the test rather than resting on someone re-deriving the argument.
|
||||
# SIGTERM, which the shipper traps: it scans once more and flushes before
|
||||
# exiting, so the tail of the session - the part worth having - is shipped
|
||||
# rather than dying with the batch. Reaped for the same reason the proxy
|
||||
@@ -1364,6 +1533,21 @@ cleanup() {
|
||||
# dejima-notify-watch's marker consumption.
|
||||
rm -rf "${DEJIMA_NOTIFY_DIR:-}" \
|
||||
|| echo "warning: could not remove the marker directory ${DEJIMA_NOTIFY_DIR:-}" >&2
|
||||
# This launch's egress control, and the network under it. The sandbox has
|
||||
# already exited by the time the trap runs, so nothing is left holding the
|
||||
# namespace - Docker permits removing an owner from under a live joiner
|
||||
# anyway, which is why the order here is deliberate rather than incidental.
|
||||
#
|
||||
# `|| true` on both, like every other removal in this trap: a Docker daemon
|
||||
# that has gone away must not replace the session's exit status. What that
|
||||
# leaves behind is inert - a filter container filters a namespace nothing
|
||||
# is in - and a later launch reconciles it.
|
||||
if [ -n "${SANDBOX_FILTER_CONTAINER:-}" ]; then
|
||||
docker rm -f "$SANDBOX_FILTER_CONTAINER" >/dev/null 2>&1 || true
|
||||
fi
|
||||
if [ -n "${SANDBOX_RUN_NETWORK:-}" ]; then
|
||||
docker network rm "$SANDBOX_RUN_NETWORK" >/dev/null 2>&1 || true
|
||||
fi
|
||||
exit_step "session ended - keeping what it compacted away"
|
||||
# Keep whatever the session compacted away, into a store nothing mounts.
|
||||
# `|| true` for the same reason every other line in this trap has it: a
|
||||
@@ -1396,7 +1580,11 @@ cleanup() {
|
||||
# read it, so this function's own cleanup can't race the audit that
|
||||
# needs it - and its removal is guarded too, so it stays truly last.
|
||||
exit_step "checking what changed on the host while it ran"
|
||||
DEJIMA_RW_MOUNTS="$DEJIMA_RW_MOUNTS" "$SCRIPT_DIR/dejima-audit" compare "$WORKSPACE_DIR" "$SANDBOX_AUDIT_STATE" || true
|
||||
# DEJIMA_RW_SOURCES, not DEJIMA_RW_MOUNTS: this report names what on the
|
||||
# HOST the session could have written, and for a per-project mount that is
|
||||
# the slice rather than the root it sits in. Naming the root would claim a
|
||||
# blast radius the per-project split exists to have narrowed.
|
||||
DEJIMA_RW_MOUNTS="$DEJIMA_RW_SOURCES" "$SCRIPT_DIR/dejima-audit" compare "$WORKSPACE_DIR" "$SANDBOX_AUDIT_STATE" || true
|
||||
rm -f "$SANDBOX_AUDIT_STATE" || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
@@ -2223,6 +2411,7 @@ if [ -n "$SANDBOX_EXTRA_MOUNTS" ]; then
|
||||
# [mounts] read_write has been guarded since it was added; this one
|
||||
# never was, so `readonly = ~/.config/dejima` mounted the approval
|
||||
# store into the container and nothing said a word (#15).
|
||||
_refuse_pp_root "$(_mount_label "$path" "[mounts] readonly entry" "--extra-readonly path")" "$path"
|
||||
_guarded_stores
|
||||
_refuse_guarded_mount "$(_mount_label "$path" "[mounts] readonly entry" "--extra-readonly path")" \
|
||||
"$path" "${GUARDED_STORES[@]}"
|
||||
@@ -2245,6 +2434,17 @@ fi
|
||||
for path in ${RW_MOUNT_PATHS[@]+"${RW_MOUNT_PATHS[@]}"}; do
|
||||
extra_mounts+=(-v "$path:$path")
|
||||
done
|
||||
# And the per-project ones, which are the only mounts in dejima whose source
|
||||
# and target differ on purpose: the slice on the host, the declared path in
|
||||
# the container. Indices are paired, filled together in one loop above.
|
||||
for _i in ${RW_PP_MOUNT_TARGETS[@]+"${!RW_PP_MOUNT_TARGETS[@]}"}; do
|
||||
# dejima's own layout below a directory the operator made, so the slice is
|
||||
# created and the root never is. Here rather than where it was resolved,
|
||||
# because this is the launch path and that was also --print-briefing's.
|
||||
mkdir -p "${RW_PP_MOUNT_SOURCES[$_i]}"
|
||||
extra_mounts+=(-v "${RW_PP_MOUNT_SOURCES[$_i]}:${RW_PP_MOUNT_TARGETS[$_i]}")
|
||||
done
|
||||
unset _i
|
||||
|
||||
# The session briefing, composed host-side from the core text and the
|
||||
# selected workflow/harness fragments (see briefing_compose) and mounted
|
||||
@@ -2583,7 +2783,6 @@ export SANDBOX_CREDENTIAL_TRANSPORT
|
||||
# nothing below runs, no state is created, and nothing is added to the
|
||||
# compose invocation.
|
||||
if remote_enabled; then
|
||||
DEJIMA_RUN_ID="$(od -An -tx1 -N8 /dev/urandom | tr -d ' \n')"
|
||||
DEJIMA_STATE_DIR="$(remote_state_dir)"
|
||||
|
||||
# The state dir's parent (e.g. /tmp, when XDG_RUNTIME_DIR is unset - the
|
||||
@@ -3069,6 +3268,218 @@ if [ -n "$pending_crit" ] && [ ! -t 0 ]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
# The egress control runs in a container of its own, which owns this launch's
|
||||
# network namespace. The sandbox joins that namespace rather than getting one
|
||||
# of its own, so the filter bounding it is installed somewhere it has no
|
||||
# capability to reach - capabilities are per process, and the sandbox declares
|
||||
# none of the network ones.
|
||||
#
|
||||
# Ordering is the point, and it is why this is here rather than inside the
|
||||
# sandbox's own startup. The namespace has its rules before anything is created
|
||||
# in it, so the window where a session holds an interface and no filter does
|
||||
# not exist, rather than being bounded by an argument about how fast an
|
||||
# entrypoint runs.
|
||||
#
|
||||
# The base image, never the per-project derived one: this is the privileged
|
||||
# half of the pair and has no use for a project's declared dependencies.
|
||||
SANDBOX_RUN_NETWORK="dejima-run-$DEJIMA_RUN_ID"
|
||||
SANDBOX_FILTER_CONTAINER="dejima-filter-$DEJIMA_RUN_ID"
|
||||
|
||||
# A launch removes its own pair on the way out, which covers every exit the
|
||||
# trap reaches. It does not cover SIGKILL, a host reboot, or a docker daemon
|
||||
# restart, and each of those leaves a filter container holding a network open.
|
||||
#
|
||||
# The leak is inert - a filter container filters a namespace nothing is in - so
|
||||
# this is hygiene rather than a second control. What makes it worth doing is
|
||||
# the address pool: Docker carves a bounded number of networks out of its
|
||||
# default pool, and exhausting it fails a launch with an error about address
|
||||
# space that says nothing about the cause.
|
||||
#
|
||||
# Never fatal. A daemon that cannot answer any of this is one that is about to
|
||||
# fail the launch a few lines down for a reason worth reporting, and a tidy-up
|
||||
# pass should not be what reports it.
|
||||
|
||||
# How old a filter container or network has to be before an absent session
|
||||
# counts as abandoned rather than as not started yet.
|
||||
#
|
||||
# Between `docker run` for the filter container and `docker compose run` for
|
||||
# the session there is a window in which a healthy pair has no joiner, and it
|
||||
# lasts as long as the firewall takes to come up - seconds, or half a minute
|
||||
# when the GitHub ranges fetch retries. The same window exists for a network
|
||||
# between its creation and its first endpoint. Minutes against seconds, because
|
||||
# reaping a concurrent launch out from under itself costs that launch, while
|
||||
# waiting costs one more pass.
|
||||
DEJIMA_ORPHAN_GRACE=300
|
||||
|
||||
# Seconds since $1, a timestamp as Docker rendered it, or nothing at all if it
|
||||
# cannot be read as one.
|
||||
#
|
||||
# Two renderings arrive here and only one is RFC 3339, because the field is not
|
||||
# the same type on both of the structs this asks about: a container's `Created`
|
||||
# is a string, a network's is a `time.Time`. A Go template prints a `time.Time`
|
||||
# through String(), which appends a timezone NAME - "+0000 UTC" - and date(1)
|
||||
# refuses that outright.
|
||||
#
|
||||
# So the name is stripped rather than the format assumed. Assuming the format
|
||||
# is what broke this: the comment here used to say every caller read RFC 3339,
|
||||
# which was true of one of them, and the network half therefore reaped nothing
|
||||
# at all while looking exactly like the half that worked.
|
||||
#
|
||||
# An unparseable value still yields no age rather than a huge one, so the
|
||||
# failure stays "nothing was reaped" and never becomes "everything was".
|
||||
_epoch_age() {
|
||||
local made stamp="$1"
|
||||
# "2026-09-09 18:00:00.1 +0000 UTC" -> "2026-09-09 18:00:00.1 +0000", and
|
||||
# an RFC 3339 stamp, which has no spaces, through unchanged.
|
||||
case "$stamp" in
|
||||
*' '[A-Z]*) stamp="${stamp% *}" ;;
|
||||
esac
|
||||
made="$(date -d "$stamp" +%s 2>/dev/null)" || return 1
|
||||
[ -n "$made" ] || return 1
|
||||
printf '%s' "$(( $(date +%s) - made ))"
|
||||
}
|
||||
|
||||
reconcile_orphans() {
|
||||
local candidates joined id name created age
|
||||
|
||||
# The candidates first, so a host with no filter containers at all asks one
|
||||
# question and stops. The query below is the expensive one - it inspects
|
||||
# every running container on the host, dejima's or not - and there is no
|
||||
# point paying for it to answer about nothing.
|
||||
candidates="$(docker ps -aq --filter 'label=dejima.filter' 2>/dev/null \
|
||||
| xargs -r docker inspect -f '{{.Id}} {{.Name}} {{.Created}}' 2>/dev/null)"
|
||||
|
||||
if [ -n "$candidates" ]; then
|
||||
# Whose namespace something is still in. Read once: asking per
|
||||
# candidate would be a docker call per candidate, on every launch.
|
||||
#
|
||||
# NetworkMode is `container:<id>` for a joiner - the id, even where the
|
||||
# launcher passed a name, so what is compared below are ids.
|
||||
joined="$(docker ps -q 2>/dev/null | xargs -r docker inspect \
|
||||
-f '{{.HostConfig.NetworkMode}}' 2>/dev/null | sed -n 's/^container://p')"
|
||||
fi
|
||||
|
||||
while read -r id name created; do
|
||||
[ -n "$id" ] && [ -n "$created" ] || continue
|
||||
grep -qxF "$id" <<< "${joined:-}" && continue
|
||||
age="$(_epoch_age "$created")" || continue
|
||||
[ "$age" -gt "$DEJIMA_ORPHAN_GRACE" ] || continue
|
||||
echo "dejima: removing a filter container an earlier session left behind (${name#/})" >&2
|
||||
docker rm -f "$id" >/dev/null 2>&1 || true
|
||||
done <<< "$candidates"
|
||||
|
||||
# Networks defend themselves twice over: `docker network rm` refuses one
|
||||
# that still has an endpoint, and the grace covers the moment between a
|
||||
# network being created and its filter container attaching to it.
|
||||
while read -r name created; do
|
||||
[ -n "$name" ] && [ -n "$created" ] || continue
|
||||
age="$(_epoch_age "$created")" || continue
|
||||
[ "$age" -gt "$DEJIMA_ORPHAN_GRACE" ] || continue
|
||||
docker network rm "$name" >/dev/null 2>&1 || true
|
||||
done < <(docker network ls -q --filter 'label=dejima.run' 2>/dev/null \
|
||||
| xargs -r docker network inspect -f '{{.Name}} {{.Created}}' 2>/dev/null)
|
||||
}
|
||||
reconcile_orphans || true
|
||||
|
||||
# One network per launch, for two reasons, and the first is not optional:
|
||||
# Docker's embedded resolver at 127.0.0.11 exists only on a user-defined
|
||||
# network, and dns-proxy.sh forwards to it. The second is that concurrent
|
||||
# sessions are then separated at the link layer as well, rather than only by
|
||||
# each namespace's own egress default-deny.
|
||||
docker network create --label "dejima.run=$DEJIMA_RUN_ID" "$SANDBOX_RUN_NETWORK" >/dev/null
|
||||
|
||||
# Everything the firewall path reads, passed as `-e NAME` so a value is taken
|
||||
# from this process's environment rather than written into a command line every
|
||||
# process on the host can read - GH_TOKEN is in this list.
|
||||
#
|
||||
# Derived from nothing: this IS the declaration, because the filter container
|
||||
# is started with `docker run` and so gets none of the environment
|
||||
# docker-compose.yml declares for the sandbox. A static test reads the same
|
||||
# names back out of the firewall scripts, so a script that starts reading a new
|
||||
# one fails rather than silently allowlisting less than it was told to.
|
||||
filter_env=(
|
||||
-e ENABLE_FIREWALL # whether to install rules at all
|
||||
-e ALLOWED_DOMAINS # the operator's approved names
|
||||
-e DEJIMA_FORGE_HOSTS # the configured forge's, resolved host-side
|
||||
-e ALLOW_GITHUB_PUSH # whether port 22 to GitHub opens
|
||||
-e GH_TOKEN # authenticates the GitHub ranges fetch
|
||||
-e LOKI_URL # its host is allowlisted, and the shipper needs it
|
||||
-e SANDBOX_HARNESS_DOMAINS
|
||||
-e SANDBOX_BACKEND_PROXIED_DOMAIN
|
||||
-e SANDBOX_CREDENTIAL_PROXY_SOCKET
|
||||
)
|
||||
|
||||
# The capabilities the egress control needs, and the sysctl the namespace it
|
||||
# owns needs. Every one of these used to be in docker-compose.yml, and none of
|
||||
# them belongs to a container running a session:
|
||||
#
|
||||
# NET_ADMIN, NET_RAW the packet filter and the ipsets
|
||||
# KILL dnsmasq drops to its own user, and dejima-allow
|
||||
# signals it to add a domain mid-session
|
||||
# CHOWN, DAC_OVERRIDE, FOWNER, SETUID, SETGID
|
||||
# dns-proxy.sh's log file, and the two daemons that
|
||||
# drop privilege after opening what they need
|
||||
# SETPCAP spent taking the rest away from log-shipper.sh
|
||||
#
|
||||
# The sysctl lets the credential proxy's TLS terminator bind :443 as the
|
||||
# unprivileged sandbox user. It is a property of the network namespace rather
|
||||
# than of a container, so it is set here, by the container that owns one, and
|
||||
# it applies to the session container that joins it.
|
||||
docker run -d --init \
|
||||
--name "$SANDBOX_FILTER_CONTAINER" \
|
||||
--label "dejima.filter=$DEJIMA_RUN_ID" \
|
||||
--network "$SANDBOX_RUN_NETWORK" \
|
||||
--cap-drop ALL \
|
||||
--cap-add NET_ADMIN --cap-add NET_RAW \
|
||||
--cap-add KILL \
|
||||
--cap-add CHOWN --cap-add DAC_OVERRIDE --cap-add FOWNER \
|
||||
--cap-add SETUID --cap-add SETGID \
|
||||
--cap-add SETPCAP \
|
||||
--security-opt no-new-privileges:true \
|
||||
--sysctl net.ipv4.ip_unprivileged_port_start=0 \
|
||||
${filter_env[@]+"${filter_env[@]}"} \
|
||||
--entrypoint /usr/local/bin/filter-entrypoint.sh \
|
||||
"${SANDBOX_IMAGE_REPO}:${SANDBOX_USER}" >/dev/null
|
||||
|
||||
# Named, not the id `docker run -d` printed. Both work for `container:`, and a
|
||||
# name we chose is one this launcher already knows without reading anything
|
||||
# back - which is also what lets the teardown below name it after a failure
|
||||
# that happened before any output could be captured.
|
||||
export SANDBOX_NETNS_CONTAINER="container:$SANDBOX_FILTER_CONTAINER"
|
||||
|
||||
# Waited for, rather than assumed. The filter container resolves domains and
|
||||
# fetches GitHub's published ranges, which is seconds on a good day and retries
|
||||
# for half a minute on a bad one - so "it will be up by the time compose has
|
||||
# started the sandbox" is a guess, and the thing it guesses about is whether
|
||||
# the session gets a filter.
|
||||
#
|
||||
# Two ways out, and the second is why this is a loop rather than a sleep: the
|
||||
# marker appears, or the container is gone. A firewall that refused to come up
|
||||
# exits the container, and without that check this would wait out the whole
|
||||
# timeout before reporting a failure that had already been decided.
|
||||
echo "dejima: bringing up this session's egress control..." >&2
|
||||
_filter_ready=0
|
||||
for _ in $(seq 1 240); do
|
||||
if docker exec "$SANDBOX_FILTER_CONTAINER" \
|
||||
test -f /run/dejima-firewall-ready 2>/dev/null; then
|
||||
_filter_ready=1
|
||||
break
|
||||
fi
|
||||
if [ "$(docker inspect -f '{{.State.Running}}' "$SANDBOX_FILTER_CONTAINER" 2>/dev/null)" != "true" ]; then
|
||||
break
|
||||
fi
|
||||
sleep 0.5
|
||||
done
|
||||
if [ "$_filter_ready" -ne 1 ]; then
|
||||
echo "" >&2
|
||||
echo "The egress control for this session did not come up, so there is no" >&2
|
||||
echo "filter for the sandbox to run behind. Refusing to start it." >&2
|
||||
echo "" >&2
|
||||
echo "What the filter container said:" >&2
|
||||
docker logs "$SANDBOX_FILTER_CONTAINER" 2>&1 | sed 's/^/ /' >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
compose_status=0
|
||||
# Conversation shipping, host-side: it reads transcripts that are on the host
|
||||
# already, so it never needed to be in the container - and being there put it
|
||||
|
||||
+59
-5
@@ -268,6 +268,59 @@ if [ "$session_wanted" -eq 1 ]; then
|
||||
[ -n "$container" ] || container="${containers%%$'\t'*}"
|
||||
fi
|
||||
|
||||
# The dnsmasq and the ipsets are not in the session's container. They are
|
||||
# in the filter container that owns its network namespace, which is what
|
||||
# keeps a session from reaching either. So everything that CHANGES the
|
||||
# allowlist below runs there, and only the reachability check at the end
|
||||
# runs in the session's own container - because that is the container whose
|
||||
# reachability this command claims to have changed.
|
||||
#
|
||||
# Both are derived from whichever one the caller named, rather than
|
||||
# recorded anywhere: Docker already knows the pairing, because joining a
|
||||
# namespace is how the pair is built.
|
||||
#
|
||||
# Naming either half has to work, and not as a convenience. A session that
|
||||
# is asked for its own container id reads /etc/hostname, and Docker names a
|
||||
# container that JOINED another's network namespace after the container it
|
||||
# joined - so the id in a briefing, the one an agent hands its operator, is
|
||||
# the filter container's. Nothing about the pair is worth making an
|
||||
# operator hold in their head to type one command.
|
||||
mode="$(docker inspect -f '{{.HostConfig.NetworkMode}}' "$container" 2>/dev/null)" || mode=""
|
||||
case "$mode" in
|
||||
container:*)
|
||||
# A session container: it names its own filter.
|
||||
filter="${mode#container:}"
|
||||
;;
|
||||
*)
|
||||
# Then it is a filter container, or not part of a pair at all.
|
||||
# `docker exec` takes a name or an id; NetworkMode reports the id,
|
||||
# so the search below compares ids.
|
||||
filter_id="$(docker inspect -f '{{.Id}}' "$container" 2>/dev/null)" || filter_id=""
|
||||
session=""
|
||||
if [ -n "$filter_id" ]; then
|
||||
while IFS=' ' read -r _id _mode; do
|
||||
[ "$_mode" = "container:$filter_id" ] || continue
|
||||
session="$_id"
|
||||
break
|
||||
done < <(docker ps -q 2>/dev/null | xargs -r docker inspect \
|
||||
-f '{{.Id}} {{.HostConfig.NetworkMode}}' 2>/dev/null)
|
||||
fi
|
||||
if [ -z "$session" ]; then
|
||||
echo "$container is neither a running session nor the filter container of" >&2
|
||||
echo "one, so there is no allowlist to add this domain to." >&2
|
||||
echo "" >&2
|
||||
echo "A session's own id is in its startup context, or from 'cat /etc/hostname'" >&2
|
||||
echo "inside it - either half of the pair is accepted here. If the session has" >&2
|
||||
echo "already exited, record the domain for the next one instead:" >&2
|
||||
echo "" >&2
|
||||
echo " dejima-allow --project $domain" >&2
|
||||
exit 1
|
||||
fi
|
||||
filter="$container"
|
||||
container="$session"
|
||||
;;
|
||||
esac
|
||||
|
||||
# The DNS proxy (dns-proxy.sh) NXDOMAINs anything not in its allowlist, so
|
||||
# this has to happen *before* resolving the domain below - otherwise the dig
|
||||
# right after this would itself get blocked. Append a server= line to
|
||||
@@ -277,15 +330,16 @@ if [ "$session_wanted" -eq 1 ]; then
|
||||
# single-quoted heredoc) rather than interpolated into the script text - it's
|
||||
# validated above, but this keeps it that way even if the validation regex
|
||||
# above is ever loosened later.
|
||||
docker exec -u root "$container" bash -c '
|
||||
docker exec -u root "$filter" bash -c '
|
||||
domain="$1"
|
||||
entry="server=/${domain}/127.0.0.11"
|
||||
grep -qxF "$entry" /etc/dnsmasq-sandbox-servers.conf 2>/dev/null || echo "$entry" >> /etc/dnsmasq-sandbox-servers.conf
|
||||
kill -HUP "$(cat /var/run/dnsmasq-sandbox.pid)"
|
||||
' -- "$domain"
|
||||
|
||||
echo "Resolving $domain inside $container..."
|
||||
ips=$(docker exec "$container" dig +short A "$domain" | grep -E '^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$' || true)
|
||||
# Named for where the lookup happens, which is where the DNS allowlist is.
|
||||
echo "Resolving $domain in $filter, this session's egress control..."
|
||||
ips=$(docker exec "$filter" dig +short A "$domain" | grep -E '^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$' || true)
|
||||
if [ -z "$ips" ]; then
|
||||
# An internally-routed name can't be added to a *running* session at
|
||||
# all: dnsmasq's stop-dns-rebind drops a private answer for any domain
|
||||
@@ -294,7 +348,7 @@ if [ "$session_wanted" -eq 1 ]; then
|
||||
# script SIGHUPs carries server= lines and nothing else. Asking the
|
||||
# upstream resolver directly separates that dead end from a name that
|
||||
# simply doesn't resolve, so the message can name the fix that works.
|
||||
upstream=$(docker exec "$container" dig +short A "$domain" @127.0.0.11 2>/dev/null |
|
||||
upstream=$(docker exec "$filter" dig +short A "$domain" @127.0.0.11 2>/dev/null |
|
||||
grep -E '^(10\.|127\.|169\.254\.|192\.168\.|172\.(1[6-9]|2[0-9]|3[01])\.)' || true)
|
||||
if [ -n "$upstream" ]; then
|
||||
echo "$domain resolves to a private address (${upstream//$'\n'/ }), which the" >&2
|
||||
@@ -320,7 +374,7 @@ if [ "$session_wanted" -eq 1 ]; then
|
||||
target_set=$(ipset_for_domain "$domain")
|
||||
for ip in $ips; do
|
||||
echo "Allowing $ip ($domain -> $target_set)"
|
||||
docker exec -u root "$container" ipset add "$target_set" "$ip" -exist
|
||||
docker exec -u root "$filter" ipset add "$target_set" "$ip" -exist
|
||||
done
|
||||
|
||||
echo "$domain allowed for the running session ($container)."
|
||||
|
||||
@@ -53,6 +53,7 @@ core.known_hosts path ~/.ssh/known_hosts
|
||||
github.token_file path ~/.config/gh/sandbox-token
|
||||
mounts.readonly ?list:path
|
||||
mounts.read_write ?list:path
|
||||
mounts.read_write_per_project ?list:path
|
||||
mounts.nvim_config ?path
|
||||
credentials.proxy bool on
|
||||
credentials.transport enum:tls|loopback tls
|
||||
|
||||
@@ -2002,6 +2002,16 @@ _briefing_resolve_key() {
|
||||
if [ -n "${DEJIMA_RW_MOUNTS:-}" ]; then
|
||||
printf '\n These host directories are bind-mounted read-write at their own paths and\n outlive the container too: %s.\n ' "$DEJIMA_RW_MOUNTS"
|
||||
fi
|
||||
# A separate sentence, because the first one is not true of these.
|
||||
# A per-project mount arrives at the path it names, like the
|
||||
# others, but what is behind it is this project's own slice of
|
||||
# that directory rather than the directory - so "these host
|
||||
# directories ... at their own paths" would tell a session it can
|
||||
# see something it cannot, and imply the next project's session
|
||||
# will read what it leaves.
|
||||
if [ -n "${DEJIMA_RW_PP_MOUNTS:-}" ]; then
|
||||
printf '\n These are bind-mounted read-write too and also outlive the container, but\n each is THIS project'"'"'s own directory rather than one every project shares -\n the same arrangement as ~/shared: %s.\n ' "$DEJIMA_RW_PP_MOUNTS"
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
echo "internal error: no resolver for whitelisted briefing key: $key" >&2
|
||||
|
||||
+20
-8
@@ -17,16 +17,28 @@
|
||||
# runs inside the container, the other is a separate library).
|
||||
ws_slug() { printf '%s' "$1" | sed 's/[^a-zA-Z0-9]/-/g'; }
|
||||
|
||||
# Every per-project share directory lives under this one subdirectory of
|
||||
# `[core] share_dir` rather than directly in it, which is what makes "left
|
||||
# over from before the split" a decidable question: anything at share_dir's
|
||||
# top level other than this directory predates the layout, because nothing
|
||||
# writes there any more. It is also the directory to mount over sshfs - one
|
||||
# entry per project, with no loose files mixed in.
|
||||
share_projects_root() { printf '%s/projects' "${1%/}"; }
|
||||
# The per-project layout, and there is one of it because there is more than one
|
||||
# caller: `[core] share_dir` slices this way, and so does every
|
||||
# `[mounts] read_write_per_project` entry. A host directory sliced per project
|
||||
# holds one `projects` subdirectory, and one directory per slug inside it.
|
||||
#
|
||||
# Everything lives under that single subdirectory rather than directly in the
|
||||
# root, which is what makes "left over from before, or put there by hand" a
|
||||
# decidable question: anything at the root's top level other than this
|
||||
# directory was not written by dejima. It is also the directory to mount over
|
||||
# sshfs - one entry per project, with no loose files mixed in.
|
||||
projects_root() { printf '%s/projects' "${1%/}"; }
|
||||
|
||||
# The directory a launch against $2 gets from a root sliced this way.
|
||||
project_dir_for() { printf '%s/%s' "$(projects_root "$1")" "$(ws_slug "$2")"; }
|
||||
|
||||
# share_dir's own spelling of the two above. `[core] share_dir` is one
|
||||
# instance of the layout, and these names are what let bin/dejima-share go on
|
||||
# talking about a share rather than about "a per-project root".
|
||||
share_projects_root() { projects_root "$1"; }
|
||||
|
||||
# The host directory mounted at ~/shared for a launch against $2.
|
||||
share_dir_for() { printf '%s/%s' "$(share_projects_root "$1")" "$(ws_slug "$2")"; }
|
||||
share_dir_for() { project_dir_for "$1" "$2"; }
|
||||
|
||||
# The slugs belonging to $1: its own, plus one per live git worktree nested
|
||||
# underneath it. One per line, sorted, no duplicates.
|
||||
|
||||
+15
-6
@@ -33,7 +33,7 @@ dejima-update-check - say when this dejima checkout is behind its remote
|
||||
Usage: dejima-update-check
|
||||
|
||||
Compares the checkout bin/dejima runs from against the branch it tracks and
|
||||
prints one line when the remote has commits this checkout does not. Run at
|
||||
prints a notice when the remote has commits this checkout does not. Run at
|
||||
startup by bin/dejima; safe to run by hand.
|
||||
|
||||
Silent when the checkout is current or ahead, when [core] update_check is off,
|
||||
@@ -175,16 +175,25 @@ color_init
|
||||
# fire on the terminal of the operator this is trying to inform.
|
||||
_remote_shown="$(sanitize "$remote")"
|
||||
_branch_shown="$(sanitize "${upstream#refs/heads/}")"
|
||||
# A blank line either side, the indent the other startup warnings carry, and
|
||||
# C_WARN: a launch prints this between its startup lines and the image build,
|
||||
# which starts scrolling immediately after, and two plain lines there are
|
||||
# indistinguishable from either.
|
||||
printf '\n' >&2
|
||||
# The count only when it is known. It is not, whenever the remote's tip is a
|
||||
# commit this checkout has never fetched, which is the common case - and a
|
||||
# number invented for those would be the number of commits it can see, not the
|
||||
# number there are.
|
||||
if [ -n "$count" ] && [ "$count" != "0" ]; then
|
||||
if [ "$count" = "1" ]; then noun="commit"; else noun="commits"; fi
|
||||
printf '%sdejima%s: %s/%s has %s %s this checkout does not.\n' \
|
||||
"$C_KEY" "$C_RESET" "$_remote_shown" "$_branch_shown" "$C_INFO$count$C_RESET" "$noun" >&2
|
||||
printf ' %sdejima: %s/%s has %s %s this checkout does not.%s\n' \
|
||||
"$C_WARN" "$_remote_shown" "$_branch_shown" "$count" "$noun" "$C_RESET" >&2
|
||||
else
|
||||
printf '%sdejima%s: %s/%s has commits this checkout does not.\n' \
|
||||
"$C_KEY" "$C_RESET" "$_remote_shown" "$_branch_shown" >&2
|
||||
printf ' %sdejima: %s/%s has commits this checkout does not.%s\n' \
|
||||
"$C_WARN" "$_remote_shown" "$_branch_shown" "$C_RESET" >&2
|
||||
fi
|
||||
printf ' Update with %sgit -C %s pull%s\n' "$C_KEY" "$REPO_ROOT" "$C_RESET" >&2
|
||||
# install.sh is safe to re-run, and a release can change what it links on PATH,
|
||||
# so pulling is only half the update. Both paths absolute because the operator
|
||||
# reading this is not necessarily standing in this checkout.
|
||||
printf ' Update with %sgit -C %s pull && %s/install.sh%s\n\n' \
|
||||
"$C_KEY" "$REPO_ROOT" "$REPO_ROOT" "$C_RESET" >&2
|
||||
|
||||
+10
-1
@@ -28,6 +28,11 @@ ENV TZ="$TZ"
|
||||
# hardening). Whichever harness has one, its plugin's harness_prepare_config
|
||||
# turns it off in the override layer, so there is nothing here to satisfy.
|
||||
# Claude Code's is the "sandbox" block in settings.json.
|
||||
#
|
||||
# libcap2-bin is capsh, which entrypoint.sh uses to hand the session a
|
||||
# container it holds no capabilities in. It was already here as a dependency of
|
||||
# iproute2; a security control resting on another package's dependency graph is
|
||||
# one an unrelated package change removes.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
less \
|
||||
git \
|
||||
@@ -41,6 +46,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
iptables \
|
||||
ipset \
|
||||
iproute2 \
|
||||
libcap2-bin \
|
||||
dnsutils \
|
||||
aggregate \
|
||||
jq \
|
||||
@@ -266,8 +272,11 @@ RUN npm install -g playwright@${PLAYWRIGHT_VERSION} && \
|
||||
# are supplied at runtime, not baked into the image - see
|
||||
# docker-compose.yml/entrypoint.sh.
|
||||
COPY docker/fixed-domains.sh /usr/local/bin/
|
||||
COPY docker/caps-lib.sh /usr/local/bin/
|
||||
COPY docker/dns-proxy.sh /usr/local/bin/
|
||||
COPY docker/init-firewall.sh /usr/local/bin/
|
||||
COPY docker/firewall-up.sh /usr/local/bin/
|
||||
COPY docker/filter-entrypoint.sh /usr/local/bin/
|
||||
COPY docker/log-shipper.sh /usr/local/bin/
|
||||
COPY docker/ulogd.conf /etc/ulogd-sandbox.conf
|
||||
# Into $VIMRUNTIME/plugin, not /etc/xdg/nvim - see the file's own comment.
|
||||
@@ -323,7 +332,7 @@ RUN bash /tmp/install-forge-bins.sh /tmp/forges /usr/local/lib/dejima/forge /usr
|
||||
# and describes what a page should look like instead of going and seeing.
|
||||
RUN ln -s /usr/local/share/npm-global/lib/node_modules /node_modules
|
||||
|
||||
RUN chmod +x /usr/local/bin/dns-proxy.sh /usr/local/bin/init-firewall.sh /usr/local/bin/log-shipper.sh /usr/local/bin/entrypoint.sh /usr/local/bin/dejima-harness-wrapper /usr/local/lib/dejima/git-guard.sh /usr/local/lib/dejima/forge-guard.sh /usr/local/lib/dejima/dejima-briefing-render.sh /usr/local/lib/dejima/hooks/*.sh && \
|
||||
RUN chmod +x /usr/local/bin/dns-proxy.sh /usr/local/bin/init-firewall.sh /usr/local/bin/firewall-up.sh /usr/local/bin/filter-entrypoint.sh /usr/local/bin/log-shipper.sh /usr/local/bin/entrypoint.sh /usr/local/bin/dejima-harness-wrapper /usr/local/lib/dejima/git-guard.sh /usr/local/lib/dejima/forge-guard.sh /usr/local/lib/dejima/dejima-briefing-render.sh /usr/local/lib/dejima/hooks/*.sh && \
|
||||
echo 'export PATH="$PATH:/usr/local/share/npm-global/bin"' > /etc/profile.d/npm-global-path.sh
|
||||
|
||||
# Entrypoint runs as root (no USER node here) so it can set up the firewall
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
# Sourced by every entrypoint in this image. Nothing here runs on its own.
|
||||
#
|
||||
# The startup sequence needs real capabilities - it builds the packet filter,
|
||||
# binds an NFLOG socket, chowns files and drops privilege twice. Nothing after
|
||||
# it does, and a bounding set survives execve: a process that reaches uid 0
|
||||
# anywhere downstream recovers its permitted set from whatever bounding set it
|
||||
# inherited. So an unprivileged session whose effective set is empty still sits
|
||||
# inside a tree holding every capability the container declares.
|
||||
#
|
||||
# `drop_caps` is how anything that outlives a startup script is started, and it
|
||||
# is what makes a declared floor a description of the root phase rather than of
|
||||
# the session's whole life.
|
||||
#
|
||||
# capsh, not `setpriv --bounding-set`, which is the obvious tool and silently
|
||||
# the wrong one: without CAP_SETPCAP it exits 0 having changed nothing, so a
|
||||
# container that lost the capability would run every session with the full set
|
||||
# and report success at every step. capsh refuses out loud, which is the only
|
||||
# behaviour worth having here.
|
||||
#
|
||||
# `-- -c 'exec "$@"'` runs the command through capsh's shell and immediately
|
||||
# replaces it, so nothing is left between the caller and the process.
|
||||
drop_caps() {
|
||||
exec capsh --drop=all "$@"
|
||||
}
|
||||
|
||||
# Checked rather than assumed, because the failure it prevents is invisible:
|
||||
# capsh would abort each drop_caps call, and in an entrypoint the last of those
|
||||
# is the session, so the container would die at the point it looked ready.
|
||||
# Named here instead, against the file the floor actually lives in.
|
||||
#
|
||||
# Called once by each top-level entrypoint rather than from drop_caps: it is a
|
||||
# precondition of the whole script, and the point is to refuse before doing
|
||||
# work rather than partway through it.
|
||||
require_setpcap() {
|
||||
if capsh --decode="$(awk '/^CapEff:/ {print $2}' /proc/self/status)" \
|
||||
| grep -q cap_setpcap; then
|
||||
return 0
|
||||
fi
|
||||
echo "${0##*/}: this container does not hold CAP_SETPCAP, so the capabilities" >&2
|
||||
echo "the startup sequence needs cannot be taken away again before the session" >&2
|
||||
echo "starts - it would run inside a tree holding all of them." >&2
|
||||
echo "Refusing to start rather than running with a floor that is not the one" >&2
|
||||
echo "docker/docker-compose.yml declares. Add SETPCAP to its cap_add." >&2
|
||||
exit 1
|
||||
}
|
||||
+75
-30
@@ -48,43 +48,87 @@ services:
|
||||
# ten per run - becomes a permanent zombie, and a long session eventually
|
||||
# exhausts the cgroup pids limit and can no longer fork at all.
|
||||
init: true
|
||||
# An explicit floor rather than Docker's default set plus two. The default
|
||||
# carries about fourteen capabilities and this container uses seven: it
|
||||
# writes and chowns files, runs the firewall, drops privilege twice (gosu
|
||||
# for the session, `ulogd -u` for the log daemon), and signals dnsmasq.
|
||||
# FSETID, MKNOD, SETFCAP, SETPCAP, SYS_CHROOT, AUDIT_WRITE and
|
||||
# NET_BIND_SERVICE are used by nothing here - the last one because the
|
||||
# sysctl below is what lets the TLS terminator bind :443 unprivileged.
|
||||
# The session does not get a network namespace of its own. It joins the one
|
||||
# the filter container owns, where the egress rules are already installed -
|
||||
# so the filter bounding this session sits somewhere it declares no
|
||||
# capability to reach. bin/dejima starts that container and passes its name
|
||||
# here; see the block above its compose invocation, and docs/security.md.
|
||||
#
|
||||
# KILL is the one worth explaining, because it is not obvious from reading
|
||||
# entrypoint.sh and was missing from the first version of this list. It is
|
||||
# not for anything the entrypoint does. `dejima-allow --container` adds a
|
||||
# domain to a RUNNING session by appending a server= line and sending
|
||||
# dnsmasq a SIGHUP, from outside via docker exec - and dnsmasq drops to its
|
||||
# own user (`user=dnsmasq`), so root in this container cannot signal it
|
||||
# without CAP_KILL. Without this line, adding a domain mid-session fails
|
||||
# with "Operation not permitted", which is a documented feature every
|
||||
# session briefing tells the agent to ask for.
|
||||
# The default is for a `docker compose` invocation by hand, which this
|
||||
# file's first line says is not how to run it. It is `network_mode: none`
|
||||
# rather than any real network, because the value that makes a session safe
|
||||
# arrives from outside this file, and the question a default answers is
|
||||
# what happens when it does not arrive: no network at all is a sandbox that
|
||||
# visibly cannot work, where a real one is a sandbox that works and has
|
||||
# unfiltered egress.
|
||||
#
|
||||
# It does not affect `docker compose build`, which reads `build.network`.
|
||||
network_mode: ${SANDBOX_NETNS_CONTAINER:-none}
|
||||
# An explicit floor rather than Docker's default set. The default carries
|
||||
# about fourteen capabilities; this container writes and chowns files and
|
||||
# drops privilege once, to the session user. FSETID, MKNOD, SETFCAP,
|
||||
# SYS_CHROOT, AUDIT_WRITE and NET_BIND_SERVICE are used by nothing here -
|
||||
# the last one because a sysctl on this namespace, not a capability, is
|
||||
# what lets the TLS terminator bind :443 unprivileged.
|
||||
#
|
||||
# No network capability at all, which is the point of the arrangement
|
||||
# rather than an economy. This container is inside a network namespace the
|
||||
# filter container owns and has already installed the egress rules in, and
|
||||
# capabilities are per process: without NET_ADMIN, nothing here can alter
|
||||
# those rules even at uid 0. NET_RAW goes with it - a raw socket is the
|
||||
# other way to speak past a filter, and nothing here opens one.
|
||||
#
|
||||
# KILL is here for tini, and for nothing else in this container.
|
||||
#
|
||||
# `init: true` above makes PID 1 the init that reaps for the session, and
|
||||
# tini forwards every signal it receives to its child. That child is the
|
||||
# entrypoint, which changes uid in place before exec'ing the session - so
|
||||
# tini is uid 0 signalling a process whose real AND saved uids are the
|
||||
# sandbox user's. kill(2) permits that only with CAP_KILL or a uid match,
|
||||
# and there is no match, so without this every forwarded signal is EPERM.
|
||||
#
|
||||
# tini treats a failed forward as fatal, and PID 1 exiting ends the
|
||||
# container. The signal that arrives in practice is SIGWINCH: the kernel
|
||||
# sends it to the pty's foreground process group on any terminal resize,
|
||||
# which includes tini. So without this line, splitting a tmux pane kills
|
||||
# the session with `[FATAL tini (1)] Unexpected error when forwarding
|
||||
# signal: 'Operation not permitted'`.
|
||||
#
|
||||
# It used to be here for `dejima-allow --container`, which signals dnsmasq;
|
||||
# that reason moved to the filter container with dnsmasq, and this one was
|
||||
# not noticed until the capability went away. Nothing in the suite ran an
|
||||
# init at all - see runtime.bats.
|
||||
#
|
||||
# The general lesson, since the next capability will be the same shape:
|
||||
# what the container needs is not only what its entrypoint does. Things
|
||||
# reach into it later.
|
||||
# what a container needs is not only what its entrypoint does. Things reach
|
||||
# into it later.
|
||||
#
|
||||
# This narrows the ROOT phase. The session user's capabilities were already
|
||||
# empty: they are cleared on the gosu drop, which runtime.bats asserts by
|
||||
# reading CapEff of the running process. What this adds is that the set was
|
||||
# never there to be regained.
|
||||
# empty: they are cleared on the privilege drop, which runtime.bats asserts
|
||||
# by reading CapEff of the running process. What this adds is that the set
|
||||
# was never there to be regained.
|
||||
#
|
||||
# SETPCAP is the odd one out: nothing in the startup sequence uses it for
|
||||
# its own work. It is spent, once, to take every capability above away from
|
||||
# the session before it starts - see entrypoint.sh. A bounding set survives
|
||||
# execve and a uid-0 process recovers its permitted set from it, so without
|
||||
# this the whole list is held by everything the session can reach, effective
|
||||
# set or not.
|
||||
#
|
||||
# Adding a capability to remove capabilities is worth stating plainly. It
|
||||
# can only reduce: since Linux 4.3 SETPCAP grants nothing a process does
|
||||
# not already hold in its permitted set, so what it buys is the drop and
|
||||
# nothing else.
|
||||
cap_drop:
|
||||
- ALL
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
- NET_RAW
|
||||
- KILL
|
||||
- CHOWN
|
||||
- DAC_OVERRIDE
|
||||
- FOWNER
|
||||
- SETUID
|
||||
- SETGID
|
||||
- KILL
|
||||
- SETPCAP
|
||||
# No path back up. gosu drops privilege rather than gaining it, so this
|
||||
# does not interfere with it - what it forbids is a setuid binary or a file
|
||||
# capability raising privileges on exec, which is the shape any escalation
|
||||
@@ -92,12 +136,13 @@ services:
|
||||
# structural instead of only observed.
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
# The credential-proxy TLS terminator binds :443 as the unprivileged
|
||||
# sandbox user (see entrypoint.sh). Docker already defaults this to 0, but
|
||||
# pinning it makes the dependency explicit: a future default change then
|
||||
# fails the launch instead of silently breaking the credential path.
|
||||
sysctls:
|
||||
net.ipv4.ip_unprivileged_port_start: 0
|
||||
# No sysctls here. The one this container depends on -
|
||||
# net.ipv4.ip_unprivileged_port_start, which lets the credential proxy's
|
||||
# TLS terminator bind :443 as the unprivileged sandbox user - is a property
|
||||
# of a network namespace, and this container does not own the one it is in.
|
||||
# bin/dejima sets it on the filter container that does, so it is declared
|
||||
# once, by the container it belongs to. runtime.bats reads the value back
|
||||
# from in here, which is where it has to be true.
|
||||
environment:
|
||||
# The forge's own env vars (declared by its manifest's env_export) and
|
||||
# the DEJIMA_* resolved values (DEJIMA_FORGE_HOSTS, DEJIMA_PUSH_HOSTS,
|
||||
|
||||
+30
-50
@@ -8,56 +8,30 @@ set -Eeuo pipefail
|
||||
# refuse deliberately, and say so (#59).
|
||||
trap 'status=$?; echo "ERROR: entrypoint.sh aborted at line $LINENO (exit $status): $BASH_COMMAND" >&2' ERR
|
||||
|
||||
# DEJIMA_FORGE_HOSTS is resolved host-side by bin/dejima (forge_hosts, in
|
||||
# dejima-provider-lib.sh) from whichever forge is configured, so this file
|
||||
# does not derive or name any one forge's hostnames itself.
|
||||
# drop_caps and require_setpcap, shared with the filter container's entrypoint.
|
||||
# Sourced rather than defined here because the callers must not drift: what
|
||||
# `drop_caps` does is the difference between a session holding nothing and a
|
||||
# session holding the whole floor, and that is not a thing to have two of.
|
||||
# shellcheck source=caps-lib.sh
|
||||
source /usr/local/bin/caps-lib.sh
|
||||
require_setpcap
|
||||
|
||||
# The egress control for this namespace is already up: the filter container
|
||||
# that owns it installed the rules, and bin/dejima waited for that before
|
||||
# starting this container. Nothing here brings it up, and nothing here could -
|
||||
# this container declares none of the capabilities it would take.
|
||||
#
|
||||
# LOKI_URL (a full URL) still needs its own derivation here - Loki's host
|
||||
# has to be reachable for log-shipper.sh to actually deliver anything.
|
||||
loki_host=""
|
||||
if [ -n "${LOKI_URL:-}" ]; then
|
||||
loki_host=$(echo "$LOKI_URL" | sed -E 's#^[a-zA-Z]+://##; s#[:/].*##')
|
||||
fi
|
||||
|
||||
extra_domains="${DEJIMA_FORGE_HOSTS:-} ${loki_host} ${ALLOWED_DOMAINS:-}"
|
||||
|
||||
# What this container does need is to be pointed at the resolver enforcing the
|
||||
# name allowlist. /etc/resolv.conf is per container even when the namespace is
|
||||
# shared, so dns-proxy.sh repointing the filter container's file says nothing
|
||||
# about this one - which Docker generated pointing straight at its embedded
|
||||
# resolver. Left alone, every lookup the session makes would go around dnsmasq
|
||||
# and the DNS allowlist would apply to nothing.
|
||||
#
|
||||
# The same value dns-proxy.sh writes for itself, at the same loopback address,
|
||||
# because loopback is shared with the container the resolver runs in.
|
||||
if [ "${ENABLE_FIREWALL:-1}" = "1" ]; then
|
||||
# dns-proxy.sh must run first: it repoints /etc/resolv.conf at a local
|
||||
# dnsmasq that only resolves allowlisted names, so init-firewall.sh's own
|
||||
# dig calls (and everything afterward) go through the same restriction.
|
||||
EXTRA_ALLOWED_DOMAINS="${extra_domains}" /usr/local/bin/dns-proxy.sh
|
||||
EXTRA_ALLOWED_DOMAINS="${extra_domains}" /usr/local/bin/init-firewall.sh
|
||||
|
||||
# NFLOG groups only exist once init-firewall.sh has run, so ulogd2 (the
|
||||
# NFLOG consumer) and the Loki shipper start after it. Both are
|
||||
# best-effort visibility on top of the enforcement above, not required
|
||||
# for it - log-shipper.sh itself no-ops if LOKI_URL isn't set. Runs as
|
||||
# its own unprivileged `ulogd` user (opens the NFLOG socket and output
|
||||
# files first, then drops), not root - it's parsing headers from
|
||||
# attacker-influenceable packets (whatever a compromised session sends),
|
||||
# so a bug in it shouldn't be a path back to root in the container.
|
||||
#
|
||||
# `ulogd -d` daemonizes immediately and always exits 0 from this shell's
|
||||
# point of view, regardless of whether the daemon itself goes on to
|
||||
# start successfully - so `set -e` above won't catch a failure here (nor
|
||||
# should it: logging is best-effort, not something worth failing the
|
||||
# whole container over). Check separately so a failure is at least
|
||||
# visible instead of silently producing no logs.
|
||||
/usr/sbin/ulogd -d -u ulogd -c /etc/ulogd-sandbox.conf
|
||||
sleep 0.5
|
||||
if ! pgrep -x ulogd >/dev/null; then
|
||||
echo "WARNING: ulogd2 failed to start - connection logging will be unavailable (see /var/log/ulogd.log)" >&2
|
||||
fi
|
||||
# Unlike ulogd2 above, this one stays root - deliberately, not an
|
||||
# oversight. What it ships is an oversight log of the session running
|
||||
# beside it, and letting the sandbox user own the shipper would let a
|
||||
# compromised session tamper with or kill its own logging. Root buys
|
||||
# tamper-resistance that a security log needs and a best-effort netflow
|
||||
# counter does not.
|
||||
#
|
||||
# What it reads is dnsmasq's query log and ulogd2's output, both written
|
||||
# by root-owned daemons here and neither session-writable.
|
||||
/usr/local/bin/log-shipper.sh &
|
||||
echo "nameserver 127.0.0.1" > /etc/resolv.conf
|
||||
fi
|
||||
|
||||
# A normal login/ssh session always chowns the allocated pty to the logging
|
||||
@@ -538,13 +512,19 @@ fi
|
||||
# exercising the actual code path instead of a reimplementation of it that
|
||||
# could drift out of sync.
|
||||
if [ "${SANDBOX_TEST_MODE:-0}" = "1" ]; then
|
||||
exec gosu "$SANDBOX_USER" sleep infinity
|
||||
drop_caps --user="$SANDBOX_USER" -- -c 'exec "$@"' dejima sleep infinity
|
||||
fi
|
||||
|
||||
# Drop root -> the sandbox user for the actual session; that user has no
|
||||
# sudo/setuid path back to root after this, so it can't re-run
|
||||
# init-firewall.sh itself to widen the allowlist mid-session.
|
||||
#
|
||||
# And drop the bounding set with it, so "no path back to root" and "no
|
||||
# capability if there were one" are two separate things rather than one
|
||||
# resting on the other. capsh does both in one process because it has to: the
|
||||
# bounding set can only be reduced while CAP_SETPCAP is still held, and
|
||||
# changing uid first gives it away.
|
||||
#
|
||||
# SANDBOX_CMD is what actually runs as that user - the selected harness's
|
||||
# own command unless [core] command overrides it (e.g. to `nvim`) for an
|
||||
# editor-driven workflow; pair that with SANDBOX_NVIM_CONFIG (see bin/dejima)
|
||||
@@ -560,4 +540,4 @@ if [ -z "${SANDBOX_CMD:-}" ]; then
|
||||
echo "command: docker run --entrypoint sh ..." >&2
|
||||
exit 1
|
||||
fi
|
||||
exec gosu "$SANDBOX_USER" $SANDBOX_CMD "$@"
|
||||
drop_caps --user="$SANDBOX_USER" -- -c 'exec "$@"' dejima $SANDBOX_CMD "$@"
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
#!/bin/bash
|
||||
# PID 1 of the filter container: brings this launch's egress control up, says
|
||||
# so, and then holds the network namespace open for the sandbox to join.
|
||||
#
|
||||
# The sandbox is a separate container that joins this namespace and declares no
|
||||
# network capability of its own. Capabilities are per process, so it cannot
|
||||
# alter the filter installed here even at uid 0 - which is the whole reason the
|
||||
# two are separate. See docs/security.md.
|
||||
#
|
||||
# Nothing a session runs ever executes in here. This container holds the
|
||||
# privilege; the one beside it holds the session.
|
||||
set -Eeuo pipefail
|
||||
trap 'status=$?; echo "ERROR: filter-entrypoint.sh aborted at line $LINENO (exit $status): $BASH_COMMAND" >&2' ERR
|
||||
|
||||
# shellcheck source=caps-lib.sh
|
||||
source /usr/local/bin/caps-lib.sh
|
||||
require_setpcap
|
||||
|
||||
/usr/local/bin/firewall-up.sh
|
||||
|
||||
# Written last, and only on the success path above - `set -e` means a firewall
|
||||
# that failed to come up never reaches this line. bin/dejima waits for this
|
||||
# file before it starts the sandbox, so the ordering that makes the arrangement
|
||||
# fail-closed is a fact about the filesystem rather than about how long a
|
||||
# launcher guessed the firewall would take.
|
||||
#
|
||||
# Under /run, which is a tmpfs: a container that is restarted rather than
|
||||
# relaunched must not inherit a marker saying its filter is up before it is.
|
||||
: > /run/dejima-firewall-ready
|
||||
echo "filter: egress control is up; holding the namespace for the session"
|
||||
|
||||
# Held open by a process that does nothing else. The namespace lives as long as
|
||||
# a process is in it, and this container is started with an init that forwards
|
||||
# SIGTERM, so `docker stop` ends it promptly rather than waiting out a timeout.
|
||||
exec sleep infinity
|
||||
@@ -0,0 +1,81 @@
|
||||
#!/bin/bash
|
||||
# Brings up this network namespace's egress control: the DNS proxy, the packet
|
||||
# filter, and the two log producers that watch them.
|
||||
#
|
||||
# Its own script rather than a block inside entrypoint.sh because the namespace
|
||||
# it acts on and the session that runs in it need not be in the same container.
|
||||
# What this does is a property of a network namespace; everything else an
|
||||
# entrypoint does is a property of a container.
|
||||
#
|
||||
# Called with the caller's environment: DEJIMA_FORGE_HOSTS, LOKI_URL,
|
||||
# ALLOWED_DOMAINS, ENABLE_FIREWALL and the harness values fixed-domains.sh
|
||||
# reads. It resolves nothing host-side itself.
|
||||
set -Eeuo pipefail
|
||||
# Same reason as entrypoint.sh's: under `set -e` alone a failure here aborts
|
||||
# with no output, and the only symptom is a container that never appeared (#59).
|
||||
trap 'status=$?; echo "ERROR: firewall-up.sh aborted at line $LINENO (exit $status): $BASH_COMMAND" >&2' ERR
|
||||
|
||||
# shellcheck source=caps-lib.sh
|
||||
source /usr/local/bin/caps-lib.sh
|
||||
|
||||
# DEJIMA_FORGE_HOSTS is resolved host-side by bin/dejima (forge_hosts, in
|
||||
# dejima-provider-lib.sh) from whichever forge is configured, so this file
|
||||
# does not derive or name any one forge's hostnames itself.
|
||||
#
|
||||
# LOKI_URL (a full URL) still needs its own derivation here - Loki's host
|
||||
# has to be reachable for log-shipper.sh to actually deliver anything.
|
||||
loki_host=""
|
||||
if [ -n "${LOKI_URL:-}" ]; then
|
||||
loki_host=$(echo "$LOKI_URL" | sed -E 's#^[a-zA-Z]+://##; s#[:/].*##')
|
||||
fi
|
||||
|
||||
extra_domains="${DEJIMA_FORGE_HOSTS:-} ${loki_host} ${ALLOWED_DOMAINS:-}"
|
||||
|
||||
if [ "${ENABLE_FIREWALL:-1}" = "1" ]; then
|
||||
# dns-proxy.sh must run first: it repoints /etc/resolv.conf at a local
|
||||
# dnsmasq that only resolves allowlisted names, so init-firewall.sh's own
|
||||
# dig calls (and everything afterward) go through the same restriction.
|
||||
EXTRA_ALLOWED_DOMAINS="${extra_domains}" /usr/local/bin/dns-proxy.sh
|
||||
EXTRA_ALLOWED_DOMAINS="${extra_domains}" /usr/local/bin/init-firewall.sh
|
||||
|
||||
# NFLOG groups only exist once init-firewall.sh has run, so ulogd2 (the
|
||||
# NFLOG consumer) and the Loki shipper start after it. Both are
|
||||
# best-effort visibility on top of the enforcement above, not required
|
||||
# for it - log-shipper.sh itself no-ops if LOKI_URL isn't set. Runs as
|
||||
# its own unprivileged `ulogd` user (opens the NFLOG socket and output
|
||||
# files first, then drops), not root - it's parsing headers from
|
||||
# attacker-influenceable packets (whatever a compromised session sends),
|
||||
# so a bug in it shouldn't be a path back to root in the container.
|
||||
#
|
||||
# `ulogd -d` daemonizes immediately and always exits 0 from this shell's
|
||||
# point of view, regardless of whether the daemon itself goes on to
|
||||
# start successfully - so `set -e` above won't catch a failure here (nor
|
||||
# should it: logging is best-effort, not something worth failing the
|
||||
# whole container over). Check separately so a failure is at least
|
||||
# visible instead of silently producing no logs.
|
||||
/usr/sbin/ulogd -d -u ulogd -c /etc/ulogd-sandbox.conf
|
||||
sleep 0.5
|
||||
if ! pgrep -x ulogd >/dev/null; then
|
||||
echo "WARNING: ulogd2 failed to start - connection logging will be unavailable (see /var/log/ulogd.log)" >&2
|
||||
fi
|
||||
# Unlike ulogd2 above, this one stays root - deliberately, not an
|
||||
# oversight. What it ships is an oversight log of the session running
|
||||
# beside it, and letting the sandbox user own the shipper would let a
|
||||
# compromised session tamper with or kill its own logging. Root buys
|
||||
# tamper-resistance that a security log needs and a best-effort netflow
|
||||
# counter does not.
|
||||
#
|
||||
# What it reads is dnsmasq's query log and ulogd2's output, both written
|
||||
# by root-owned daemons here and neither session-writable.
|
||||
#
|
||||
# Root, and nothing else: it parses content the session's own traffic
|
||||
# decides, and it needs no capability to read two files its gid already
|
||||
# opens or to POST them. So it keeps the uid that makes it untamperable
|
||||
# and holds none of the rest.
|
||||
#
|
||||
# It outlives this script, and reparents to the container's init when this
|
||||
# one exits - which is where a daemon watching a session belongs. Started
|
||||
# from the entrypoint, it was a child of the very session process it exists
|
||||
# to keep an eye on.
|
||||
drop_caps -- -c 'exec "$@"' dejima /usr/local/bin/log-shipper.sh &
|
||||
fi
|
||||
@@ -48,6 +48,19 @@ ship_stream() {
|
||||
last_flush=$(date +%s)
|
||||
}
|
||||
|
||||
# tail's stderr is discarded below because -F is meant to wait for a file
|
||||
# that does not exist yet, and complains once per second while it does.
|
||||
# That also swallows a permission error, which is not transient and which
|
||||
# looks identical from the outside: a stream that ships nothing, which is
|
||||
# also what a quiet session looks like.
|
||||
#
|
||||
# This process holds no capabilities (see entrypoint.sh), so it reads these
|
||||
# two files on their ordinary permissions and nothing else. Say so once
|
||||
# when that is not enough, rather than shipping an empty stream.
|
||||
if [ -e "$file" ] && [ ! -r "$file" ]; then
|
||||
echo "WARNING: $file is not readable by $(id -un) - the $job stream will ship nothing." >&2
|
||||
fi
|
||||
|
||||
last_flush=$(date +%s)
|
||||
tail -F -n0 "$file" 2>/dev/null | while true; do
|
||||
if IFS= read -r -t "$BATCH_MAX_SECONDS" line; then
|
||||
|
||||
+37
-9
@@ -163,7 +163,8 @@ you selected, the second is push access when GitHub *is* your forge.
|
||||
| Key | Default | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `readonly` | unset | extra host paths (colon-separated) a personal config needs, mounted read-only at their own path |
|
||||
| `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 |
|
||||
| `read_write` | unset | host paths (colon-separated) mounted **read-write** at their own path: for directories meant to outlive the session and be shared by every project; refused if they overlap anything dejima needs out of a session's reach |
|
||||
| `read_write_per_project` | unset | the same, mounted at the path named but backed by `<path>/projects/<slug>` - one directory per project rather than one for the host. For anything a session writes to be read later as background, where another project reading it would be a channel rather than a convenience |
|
||||
| `nvim_config` | unset | nvim config dir, used when `[core] command = nvim` |
|
||||
|
||||
### `[audit]` - the exit-time audit
|
||||
@@ -842,15 +843,41 @@ session is meant to write to and whose contents should outlive the container:
|
||||
|
||||
```
|
||||
[mounts]
|
||||
read_write = ~/specs
|
||||
read_write = ~/.cache/shared-toolchain
|
||||
```
|
||||
|
||||
Both lists mount at the host path they name, so a convention that says
|
||||
`[mounts] read_write_per_project` mounts at the same path, but backs it with
|
||||
one directory per project instead of the whole thing:
|
||||
|
||||
```
|
||||
[mounts]
|
||||
read_write_per_project = ~/specs
|
||||
```
|
||||
|
||||
The container sees `~/specs` either way. Behind it, `read_write` puts the
|
||||
directory itself - every session's contents, including other projects' - and
|
||||
`read_write_per_project` puts `~/specs/projects/<slug>`, keyed the same way
|
||||
`[core] share_dir` and the audit store are.
|
||||
|
||||
**Which to reach for is decided by one question: should another project's
|
||||
session read what this one leaves there?** For a build cache or a directory of
|
||||
scripts, yes, and `read_write` is right. For design docs, scratch notes,
|
||||
anything an agent writes to be read later as background - no, and that is what
|
||||
`read_write_per_project` is for. A spec is written to be read as context, so a
|
||||
shared one is a channel from any project into every other.
|
||||
|
||||
All three lists mount at the host path they name, so a convention that says
|
||||
"specs live in `~/specs`" keeps working inside the container without being
|
||||
rewritten. That is the difference from `[core] share_dir`, which is
|
||||
mounted at `~/shared` regardless of where it lives on the host because its job
|
||||
is to be a well-known handoff point.
|
||||
|
||||
dejima creates the per-project directory; it never creates the one you named,
|
||||
so a path that is not there is refused as the typo it is. Naming a
|
||||
`read_write_per_project` root's `projects` directory in either of the other
|
||||
two lists is refused as well - handing that over is handing over every
|
||||
project's.
|
||||
|
||||
An entry is **refused, not warned about**, if it overlaps anything dejima
|
||||
relies on being out of a session's reach: the approval store, either audit
|
||||
store, the epitaph store, every project's share, `~/.local/bin`, dejima's own
|
||||
@@ -958,13 +985,14 @@ Treat it as a handoff point, not as general-purpose storage. If your own
|
||||
project instructions tell agents to write somewhere at a fixed host path -
|
||||
design docs, scratch notes, anything meant to outlive the session but stay out
|
||||
of the project checkout - mount that directory instead, with
|
||||
`[mounts] read_write`. It arrives at its own absolute path, so a convention
|
||||
that says "put specs in `~/specs`" keeps working inside the container without
|
||||
being rewritten to point at `~/shared`.
|
||||
`[mounts] read_write_per_project`. It arrives at its own absolute path, so a
|
||||
convention that says "put specs in `~/specs`" keeps working inside the
|
||||
container without being rewritten to point at `~/shared` - and, like `~/shared`
|
||||
itself, what a session finds there is this project's and not every project's.
|
||||
|
||||
`~/shared`, the project mount, and anything in `[mounts] read_write` are the
|
||||
only writable host paths in the container. Everything else a session writes
|
||||
goes away with it.
|
||||
`~/shared`, the project mount, and anything in `[mounts] read_write` or
|
||||
`[mounts] read_write_per_project` are the only writable host paths in the
|
||||
container. Everything else a session writes goes away with it.
|
||||
|
||||
**Clear it out periodically.** The per-project split bounds which sessions can
|
||||
read what you left, and bounds nothing about how long. A project's share
|
||||
|
||||
+5
-2
@@ -14,10 +14,13 @@ run them.
|
||||
| `docker/install-forge-bins.sh` | Installs each forge's CLIs into the image from its manifest's `install` list, and fails the build on a file that is shipped but not declared, or declared but not shipped |
|
||||
| `docker/fixed-domains.sh` | Shared domain list sourced by both `init-firewall.sh` and `dns-proxy.sh` |
|
||||
| `docker/dns-proxy.sh` | Starts the DNS-allowlisting `dnsmasq`, repoints `resolv.conf` at it |
|
||||
| `docker/init-firewall.sh` | IPv4/IPv6 egress allowlist firewall, run at container start |
|
||||
| `docker/init-firewall.sh` | IPv4/IPv6 egress allowlist firewall, run at filter-container start |
|
||||
| `docker/firewall-up.sh` | Brings a network namespace's egress control up: the DNS proxy, the packet filter, and the two log producers. What a namespace needs, rather than what a container does |
|
||||
| `docker/filter-entrypoint.sh` | PID 1 of the filter container: runs `firewall-up.sh`, signals readiness, then holds the namespace open for the sandbox to join |
|
||||
| `docker/caps-lib.sh` | `drop_caps` and the `CAP_SETPCAP` precondition, sourced by both entrypoints so the two cannot drift |
|
||||
| `docker/ulogd.conf` | `ulogd2` config: turns `init-firewall.sh`'s NFLOG-tagged packets into JSON |
|
||||
| `docker/log-shipper.sh` | Tails the dnsmasq/ulogd2 logs and ships them to the configured Loki endpoint (`[logging] loki_url`). Container-side, because those files only exist there |
|
||||
| `docker/entrypoint.sh` | Runs as root: DNS proxy, firewall, logging, drops to the sandbox user via `gosu`, then execs the session's command |
|
||||
| `docker/entrypoint.sh` | PID 1 of the session container: runs as root to prepare the session's own state, points `resolv.conf` at the namespace's resolver, then execs the session's command as the sandbox user with an empty capability bounding set. The egress control is the filter container's to bring up, not this one's |
|
||||
| `docker/harness-wrapper.sh` | Shadows the selected harness's command on `PATH` to add its no-confirm flags and dejima's settings layer. Named by no harness; `entrypoint.sh` links it into place |
|
||||
| `docker/docker-compose.yml` | Wires up mounts/env; invoked via `bin/dejima`, not directly |
|
||||
| `hooks/sandbox-context.sh` | `SessionStart` hook: injects sandbox context/workflow reminders, and any `.dejima.md` the project ships |
|
||||
|
||||
+7
-4
@@ -46,11 +46,14 @@ at all.
|
||||
`test/runtime.bats` needs Docker, which a sandbox does not have. It is the only
|
||||
thing that exercises the mounts, the firewall and the entrypoint end to end, so
|
||||
a release is exactly when not running it stops being acceptable. Either run it
|
||||
on the host, or **check that the privileged CI job ran** - it is pinned to one
|
||||
self-hosted runner and queues silently when that runner is down, so a pull
|
||||
request can sit with the runtime half neither green nor red.
|
||||
on the host, or **read the pull request's checks**: green means every job
|
||||
finished and passed, `runtime.bats` among them. Yellow or grey means the
|
||||
privileged job has not run - it is pinned to one self-hosted runner and queues
|
||||
silently when that runner is down.
|
||||
|
||||
"CI covers it" is not "it passed."
|
||||
So the answer is the colour. Green is verified end to end; say so. Anything
|
||||
else is a run that has not happened, and reporting it as passing is what this
|
||||
paragraph exists to prevent.
|
||||
|
||||
## 2. Security review
|
||||
|
||||
|
||||
@@ -52,12 +52,11 @@ running.
|
||||
|
||||
## The bind address is the one thing to get right
|
||||
|
||||
`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
|
||||
with the fact above (a tmux client is a host shell) and the consequence is
|
||||
direct: **a front door the sandbox can reach is a compromised session's own
|
||||
escape hatch.**
|
||||
`docker/init-firewall.sh` deliberately ACCEPTs OUTPUT to the session'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 with the fact above
|
||||
(a tmux client is a host shell) and the consequence is direct: **a front door
|
||||
the sandbox can reach is a compromised session's own escape hatch.**
|
||||
|
||||
So:
|
||||
|
||||
|
||||
+82
-17
@@ -271,7 +271,7 @@ 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
|
||||
which `firewall-up.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
|
||||
@@ -1017,15 +1017,52 @@ reasoning below is what both are built on.
|
||||
SSH-agent/token story documented above. Keep credentials out of your
|
||||
global gitconfig, or expect them to be reachable from inside the
|
||||
sandbox.
|
||||
- **The egress filter is not in the container the session runs in.** A launch
|
||||
starts two: a filter container that owns the network namespace and installs
|
||||
the packet filter, and the sandbox, which joins that namespace with
|
||||
`network_mode: container:` rather than getting one of its own. Capabilities
|
||||
are per process rather than per namespace, so the sandbox cannot alter the
|
||||
filter bounding it even at uid 0 - measured, not reasoned: `iptables` and
|
||||
`ipset` both return `Operation not permitted` to a joiner that lacks
|
||||
`NET_ADMIN`.
|
||||
|
||||
Two things follow. The namespace has its rules before anything is created in
|
||||
it, so there is no window in which a session holds an interface and no
|
||||
filter; `bin/dejima` waits for the filter container to say it is up and
|
||||
refuses the launch otherwise. And killing the filter container leaves the
|
||||
sandbox holding loopback alone - no network at all, rather than an unfiltered
|
||||
one.
|
||||
|
||||
Nothing a session runs ever executes in the filter container. It holds the
|
||||
privilege; the one beside it holds the session. Each launch gets a network of
|
||||
its own, so concurrent sessions are separated at the link layer as well.
|
||||
|
||||
A launch removes both on the way out, and reconciles at the start against
|
||||
whatever a `SIGKILL` or a restarted host left behind - a filter container
|
||||
with no session in its namespace, older than the window in which a pair is
|
||||
still being built. What that reclaims is address space rather than safety: a
|
||||
leaked filter container filters a namespace nothing is in, and Docker's
|
||||
address pool is finite enough that exhausting it fails a later launch with an
|
||||
error naming neither cause nor remedy.
|
||||
- The sandbox's own user (matching your host user; see "Config parity" in
|
||||
the README) has no `sudo`/setuid path back to root once the container is up:
|
||||
`entrypoint.sh` runs as root, sets up the firewall directly, then drops to
|
||||
that user via `gosu` before execing the configured command (`[core] command`,
|
||||
or the selected harness's own if that is unset). A compromised session can't
|
||||
`entrypoint.sh` runs as root, prepares the session's own state, then drops to
|
||||
that user before execing the configured command (`[core] command`, or the
|
||||
selected harness's own if that is unset). A compromised session can't
|
||||
re-run `init-firewall.sh` with a wider allowlist to open its own
|
||||
exfiltration path. Verified: capabilities (`NET_ADMIN`/`NET_RAW`) are
|
||||
cleared from the effective/permitted sets on the `gosu` drop, and that
|
||||
user gets a permission error running `iptables` directly.
|
||||
exfiltration path: that script runs in the container beside it, so there is
|
||||
no copy of the allowlist within reach to re-run against. Verified: that user
|
||||
gets a permission error running `iptables` directly.
|
||||
- **The session's capability bounding set is empty**, not merely its effective
|
||||
one. The distinction is the whole of it: an empty effective set is what
|
||||
changing uid gives you anyway, while the bounding set is what a process that
|
||||
reached uid 0 in that tree would recover its capabilities from. So the two
|
||||
claims above - no path back to root, and no capability if there were one -
|
||||
hold separately rather than one resting on the other. The drop and the uid
|
||||
change happen in one `capsh` process because they have to: the bounding set
|
||||
can only be reduced while `CAP_SETPCAP` is still held, and changing uid first
|
||||
gives it away. The same applies to `log-shipper.sh`, the one root process
|
||||
that outlives the entrypoint.
|
||||
- A forge credential reaches the container as `-e NAME`, never
|
||||
`-e NAME=value`. The value comes from the launcher's own environment, so it
|
||||
sits in `/proc/<pid>/environ` (mode 0400, owner only) rather than in
|
||||
@@ -1033,17 +1070,45 @@ reasoning below is what both are built on.
|
||||
anyone. `docker inspect` still shows the resolved value to whoever can
|
||||
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 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
|
||||
- **Each container runs with `no-new-privileges` and a capability floor of its
|
||||
own**, `cap_drop: ALL` plus what that container actually uses. The two lists
|
||||
are the arrangement stated as configuration:
|
||||
|
||||
The session container declares **no network capability at all** - not
|
||||
`NET_ADMIN`, not `NET_RAW`. It is inside a namespace the filter container
|
||||
owns and has already installed the rules in, and capabilities are per
|
||||
process, so uid 0 in there is refused by the kernel rather than by policy.
|
||||
What it keeps is what its root phase does before the session starts: write
|
||||
and chown files, and drop privilege once. `SETPCAP` is there to spend rather
|
||||
than to use - it is what lets the entrypoint take the rest of the list away
|
||||
again before execing the session, and since Linux 4.3 it grants nothing a
|
||||
process does not already hold.
|
||||
|
||||
The filter container holds the network capabilities. The session's list is in
|
||||
`docker/docker-compose.yml` and the filter's is in `bin/dejima`, each beside
|
||||
the container it describes.
|
||||
|
||||
`KILL` is on both lists, for unrelated reasons, and it is the one worth
|
||||
reading twice. The filter container needs it because adding a domain to a
|
||||
running session signals dnsmasq, which runs as its own user. The session
|
||||
container needs it because `init: true` makes PID 1 an init that forwards
|
||||
signals to the session - and the entrypoint changes uid in place before
|
||||
exec'ing it, so that is uid 0 signalling a process whose real and saved uids
|
||||
are the sandbox user's. Without the capability every forwarded signal is
|
||||
`EPERM`, tini treats a failed forward as fatal, and an ordinary terminal
|
||||
resize ends the session.
|
||||
|
||||
`no-new-privileges` 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
|
||||
container rather than against the compose file, and the capability list the
|
||||
test uses is read out of that file so the two cannot drift.
|
||||
would have to take. The floor means the capabilities nothing uses were never
|
||||
in the bounding set to be regained. Both are asserted against running
|
||||
containers rather than against the files, and both lists the tests use are
|
||||
read out of those files so neither can drift.
|
||||
|
||||
What a floor describes is a container's **root phase**. A launch runs with
|
||||
`init: true`, so PID 1 is the init that reaps for the session and it holds
|
||||
that container's floor for as long as the container lives; the session is its
|
||||
child and holds nothing.
|
||||
- The approval store (`${DEJIMA_CONFIG_DIR:-$HOME/.config/dejima}`,
|
||||
see
|
||||
[Global and per-project domains and dependencies](../README.md#global-and-per-project-domains-and-dependencies)
|
||||
|
||||
@@ -35,11 +35,16 @@ session_id="${1:-}"
|
||||
resume_id="${session_id:-<your session id>}"
|
||||
project_dir="${2:-$PWD}"
|
||||
|
||||
# /etc/hostname in a docker container is the container's own ID, which
|
||||
# docker exec accepts directly - substituting it into the briefing below
|
||||
# means the agent states its real container instead of guessing (a wrong
|
||||
# guess that matches *another* running sandbox makes dejima-allow
|
||||
# "succeed" against the wrong container).
|
||||
# The id of this launch, read from the only place a session can see one.
|
||||
#
|
||||
# Not this container's own id, and worth being exact about: Docker names a
|
||||
# container that joined another's network namespace after the container it
|
||||
# joined, so what a session reads here is its FILTER container's id. That is
|
||||
# still the right thing to put in the briefing - `dejima-allow` takes either
|
||||
# half of the pair and derives the other - and it is what the agent has, so
|
||||
# substituting it means stating a real id rather than guessing one. A wrong
|
||||
# guess that matched another running sandbox would make dejima-allow "succeed"
|
||||
# against somebody else's session.
|
||||
container_id=$(cat /etc/hostname 2>/dev/null)
|
||||
container_id="${container_id:-<container-id>}"
|
||||
|
||||
|
||||
+154
-1
@@ -2221,6 +2221,139 @@ EOF
|
||||
rm -rf "$workspace" "$RW_HOME"
|
||||
}
|
||||
|
||||
@test "a read_write_per_project entry mounts this project's slice at the path it names" {
|
||||
_rw_setup
|
||||
pp="$(mktemp -d)"
|
||||
workspace="$(mktemp -d)"
|
||||
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only' \
|
||||
'[mounts]' "read_write_per_project = $pp"
|
||||
_rw_launch "$workspace"
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
|
||||
# The container gets the path the operator named; the host gives it one
|
||||
# directory of that path, keyed the same way share_dir and the audit store
|
||||
# are. Both halves asserted, because either alone is satisfied by the
|
||||
# arrangement this replaces.
|
||||
slug="$(printf '%s' "$workspace" | sed 's/[^a-zA-Z0-9]/-/g')"
|
||||
grep -qxF "$pp/projects/$slug:$pp" "$docker_log" \
|
||||
|| { echo "not mounted as a per-project slice:"; grep "$pp" "$docker_log"; return 1; }
|
||||
run grep -qxF "$pp:$pp" "$docker_log"
|
||||
[ "$status" -ne 0 ] \
|
||||
|| { echo "the whole directory was handed over as well"; return 1; }
|
||||
[ -d "$pp/projects/$slug" ] || { echo "the slice was never created"; return 1; }
|
||||
rm -rf "$pp" "$workspace" "$RW_HOME" "$docker_log"
|
||||
}
|
||||
|
||||
@test "two projects get different slices of one read_write_per_project entry" {
|
||||
# The whole point, and not deducible from one launch: the same declared
|
||||
# directory has to give two workspaces two directories, or it is a shared
|
||||
# pile with extra steps.
|
||||
_rw_setup
|
||||
pp="$(mktemp -d)"
|
||||
ws_a="$(mktemp -d)"; ws_b="$(mktemp -d)"
|
||||
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only' \
|
||||
'[mounts]' "read_write_per_project = $pp"
|
||||
_rw_launch "$ws_a"; [ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
a_log="$docker_log"
|
||||
_rw_launch "$ws_b"; [ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
|
||||
a="$(grep -oE "$pp/projects/[^:]+" "$a_log" | head -1)"
|
||||
b="$(grep -oE "$pp/projects/[^:]+" "$docker_log" | head -1)"
|
||||
[ -n "$a" ] && [ -n "$b" ] || { echo "no slice mounted for one of them"; return 1; }
|
||||
[ "$a" != "$b" ] \
|
||||
|| { echo "both projects were given the same directory: $a"; return 1; }
|
||||
rm -rf "$pp" "$ws_a" "$ws_b" "$RW_HOME" "$a_log" "$docker_log"
|
||||
}
|
||||
|
||||
@test "read_write is refused when it names a read_write_per_project root" {
|
||||
# The share guard's reason, one directory over: handing over the root that
|
||||
# holds every project's slice puts back the pile the slicing removed.
|
||||
_rw_setup
|
||||
pp="$(mktemp -d)"
|
||||
workspace="$(mktemp -d)"
|
||||
mkdir -p "$pp/projects"
|
||||
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only' \
|
||||
'[mounts]' "read_write = $pp/projects" "read_write_per_project = $pp"
|
||||
_rw_launch "$workspace"
|
||||
[ "$status" -ne 0 ] || { echo "the pile was handed back: $output"; return 1; }
|
||||
[[ "$output" == *"holds one directory per project"* ]] || { echo "$output"; return 1; }
|
||||
rm -rf "$pp" "$workspace" "$RW_HOME" "$docker_log"
|
||||
}
|
||||
|
||||
@test "readonly is refused when it names a read_write_per_project root" {
|
||||
_rw_setup
|
||||
pp="$(mktemp -d)"
|
||||
workspace="$(mktemp -d)"
|
||||
mkdir -p "$pp/projects"
|
||||
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only' \
|
||||
'[mounts]' "readonly = $pp/projects" "read_write_per_project = $pp"
|
||||
_rw_launch "$workspace"
|
||||
[ "$status" -ne 0 ] || { echo "every project's slice was readable: $output"; return 1; }
|
||||
rm -rf "$pp" "$workspace" "$RW_HOME" "$docker_log"
|
||||
}
|
||||
|
||||
@test "a read_write_per_project entry overlapping a protected path is refused" {
|
||||
# Slicing does not make a guarded store safe to hand over - it only makes
|
||||
# less of it reachable, which is not the property the guard is about.
|
||||
_rw_setup
|
||||
workspace="$(mktemp -d)"
|
||||
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only' \
|
||||
'[mounts]' "read_write_per_project = $CONFIG_DIR"
|
||||
_rw_launch "$workspace"
|
||||
[ "$status" -ne 0 ] || { echo "the approval store was mounted: $output"; return 1; }
|
||||
[[ "$output" == *"only how much of it there is"* ]] || { echo "$output"; return 1; }
|
||||
rm -rf "$workspace" "$RW_HOME" "$docker_log"
|
||||
}
|
||||
|
||||
@test "the same path in read_write and read_write_per_project is refused" {
|
||||
# Not by a duplicate check: the two mean different things, so naming a path
|
||||
# in both makes it an ancestor of its own per-project root, and the refusal
|
||||
# that fires is the one about handing over every project's directory. That
|
||||
# is the right answer, and it says which list the path belongs in.
|
||||
_rw_setup
|
||||
pp="$(mktemp -d)"
|
||||
workspace="$(mktemp -d)"
|
||||
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only' \
|
||||
'[mounts]' "read_write = $pp" "read_write_per_project = $pp"
|
||||
_rw_launch "$workspace"
|
||||
[ "$status" -ne 0 ] || { echo "$output"; return 1; }
|
||||
[[ "$output" == *"holds one directory per project"* ]] || { echo "$output"; return 1; }
|
||||
[[ "$output" == *"one list or the other and not in both"* ]] || { echo "$output"; return 1; }
|
||||
rm -rf "$pp" "$workspace" "$RW_HOME" "$docker_log"
|
||||
}
|
||||
|
||||
@test "a read_write_per_project entry that is not an existing directory is refused" {
|
||||
# The slice is dejima's to create; the root is not. A path that is not
|
||||
# there is a typo, and creating it would turn the typo into a directory.
|
||||
_rw_setup
|
||||
workspace="$(mktemp -d)"
|
||||
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only' \
|
||||
'[mounts]' "read_write_per_project = /nonexistent/specs"
|
||||
_rw_launch "$workspace"
|
||||
[ "$status" -ne 0 ]
|
||||
[[ "$output" == *"not an existing directory"* ]] || { echo "$output"; return 1; }
|
||||
[ ! -d /nonexistent/specs ]
|
||||
rm -rf "$workspace" "$RW_HOME" "$docker_log"
|
||||
}
|
||||
|
||||
@test "--print-briefing names a per-project mount as this project's own" {
|
||||
# A separate sentence from the read_write one, because the read_write
|
||||
# sentence is not true of these: a session told "these host directories at
|
||||
# their own paths" would believe the whole directory is behind the mount.
|
||||
pp="$(mktemp -d)"
|
||||
rw="$(mktemp -d)"
|
||||
write_config '[core]' 'forge = none' 'workflow = local-only' \
|
||||
'[mounts]' "read_write = $rw" "read_write_per_project = $pp"
|
||||
run "$REPO_ROOT/bin/dejima" --print-briefing
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
[[ "$output" == *"at their own paths"*"$rw"* ]] || { echo "$output"; return 1; }
|
||||
[[ "$output" == *"THIS project's own directory"*"$pp"* ]] || { echo "$output"; return 1; }
|
||||
# And composing a briefing leaves nothing behind: the slice belongs to a
|
||||
# launch, not to a diagnostic that prints one.
|
||||
[ ! -d "$pp/projects" ] || { echo "--print-briefing created the slice"; return 1; }
|
||||
rm -rf "$pp" "$rw"
|
||||
}
|
||||
|
||||
@test "a path in both read_write and readonly is refused rather than resolved by mount order" {
|
||||
_rw_setup
|
||||
d="$(mktemp -d)"
|
||||
@@ -3027,10 +3160,30 @@ _upd_cache() { printf '%s/.local/share/dejima/update-check' "$HOME"; }
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *"origin/trunk"* ]] \
|
||||
|| { echo "does not name what it is behind: $output"; return 1; }
|
||||
# The path, because the checkout that is behind is not necessarily the
|
||||
# The paths, because the checkout that is behind is not necessarily the
|
||||
# directory the operator is standing in when they read this.
|
||||
[[ "$output" == *"git -C $UPD_CHECKOUT pull"* ]] \
|
||||
|| { echo "does not say how to update: $output"; return 1; }
|
||||
# install.sh too, because pulling does not re-link what a new release
|
||||
# adds on PATH.
|
||||
[[ "$output" == *"$UPD_CHECKOUT/install.sh"* ]] \
|
||||
|| { echo "does not say to re-run install.sh: $output"; return 1; }
|
||||
}
|
||||
|
||||
@test "the notice is set off by a blank line either side" {
|
||||
_upd_fixture
|
||||
_upd_commit "$UPD_ORIGIN" second
|
||||
# Read from the file the tool wrote: bats' run strips the blank lines at
|
||||
# either end of what it captures, and those are the thing being asserted -
|
||||
# they are what keeps the launch's startup lines and image build from
|
||||
# reading as part of the notice.
|
||||
local out="$UPD_DIR/notice"
|
||||
"$UPD_CHECKOUT/bin/dejima-update-check" >"$out" 2>&1
|
||||
grep -q "commits this checkout" "$out" || { cat "$out"; return 1; }
|
||||
[ -z "$(head -n 1 "$out")" ] \
|
||||
|| { echo "no blank line before it: $(cat "$out")"; return 1; }
|
||||
[ -z "$(tail -n 1 "$out")" ] \
|
||||
|| { echo "no blank line after it: $(cat "$out")"; return 1; }
|
||||
}
|
||||
|
||||
@test "the commit count is given only when the commits are actually here" {
|
||||
|
||||
@@ -0,0 +1,382 @@
|
||||
#!/usr/bin/env bats
|
||||
# How a launch is arranged: the containers it starts, in what order, on what
|
||||
# network, and what it takes down again.
|
||||
#
|
||||
# The subject is bin/dejima's side of the filter container - the extra
|
||||
# container per launch that owns the network namespace and the packet filter,
|
||||
# which the sandbox joins and has no capability to alter. Whether the filter
|
||||
# actually filters is runtime.bats's question and needs a real Docker; whether
|
||||
# the launcher builds the arrangement that puts one there is this file's, and
|
||||
# needs only a stub.
|
||||
#
|
||||
# Everything here runs against a `docker` that records its argv and exits 0, so
|
||||
# a launch completes without a daemon. That makes the ORDER of the calls, and
|
||||
# the flags on each, the thing being asserted.
|
||||
|
||||
setup() {
|
||||
REPO_ROOT="$BATS_TEST_DIRNAME/.."
|
||||
SANDBOX_DIR="$REPO_ROOT/docker"
|
||||
LIB="$REPO_ROOT/bin/dejima-project-lib.sh"
|
||||
CONFIG_DIR="$(mktemp -d)"
|
||||
RUNTIME_DIR="$(mktemp -d)"
|
||||
export DEJIMA_CONFIG_DIR="$CONFIG_DIR"
|
||||
export XDG_RUNTIME_DIR="$RUNTIME_DIR"
|
||||
}
|
||||
|
||||
teardown() {
|
||||
rm -rf "$CONFIG_DIR" "$RUNTIME_DIR" ${LAUNCH_DIR:+"$LAUNCH_DIR"} ${PROJ:+"$PROJ"}
|
||||
}
|
||||
|
||||
# A `docker` that logs one line per invocation and succeeds at everything,
|
||||
# including the readiness probe. Tests that need a failure override it.
|
||||
stub_launch() {
|
||||
LAUNCH_DIR="$(mktemp -d)"
|
||||
mkdir -p "$LAUNCH_DIR/home/.claude"
|
||||
echo '{}' > "$LAUNCH_DIR/home/.claude/settings.json"
|
||||
cat > "$LAUNCH_DIR/docker" <<'EOF'
|
||||
#!/bin/bash
|
||||
echo "$*" >> "${SANDBOX_TEST_DOCKER_LOG:-/dev/null}"
|
||||
exit 0
|
||||
EOF
|
||||
chmod +x "$LAUNCH_DIR/docker"
|
||||
: > "$LAUNCH_DIR/sandbox-key"
|
||||
chmod 600 "$LAUNCH_DIR/sandbox-key"
|
||||
PROJ="$(mktemp -d)"
|
||||
}
|
||||
|
||||
launch() {
|
||||
local conf
|
||||
conf="$(bash -c 'source "$1"; config_path' _ "$LIB")"
|
||||
mkdir -p "$(dirname "$conf")"
|
||||
printf '[core]\nharness = claude-code\nupdate_check = off\nssh_key = %s\n' \
|
||||
"$LAUNCH_DIR/sandbox-key" >> "$conf"
|
||||
HOME="$LAUNCH_DIR/home" PATH="$LAUNCH_DIR:$PATH" \
|
||||
SANDBOX_TEST_DOCKER_LOG="$LAUNCH_DIR/docker-log" \
|
||||
SSH_AUTH_SOCK= ALLOWED_DOMAINS= \
|
||||
"${DEJIMA_BIN:-$REPO_ROOT/bin/dejima}" "$@"
|
||||
}
|
||||
|
||||
log_line() { grep -- "$1" "$LAUNCH_DIR/docker-log" | tail -1; }
|
||||
# 1-indexed position of the last line matching a pattern, for ordering claims.
|
||||
log_pos() { grep -n -- "$1" "$LAUNCH_DIR/docker-log" | tail -1 | cut -d: -f1; }
|
||||
|
||||
@test "a launch creates a network of its own and a filter container on it" {
|
||||
stub_launch
|
||||
launch "$PROJ"
|
||||
|
||||
local net; net="$(log_line 'network create')"
|
||||
[ -n "$net" ] || { echo "no network was created"; return 1; }
|
||||
[[ "$net" =~ dejima-run-[0-9a-f]{16} ]] \
|
||||
|| { echo "the network is not named for this run: $net"; return 1; }
|
||||
[[ "$net" == *"--label dejima.run="* ]] \
|
||||
|| { echo "the network carries no run label, so nothing can reconcile it: $net"; return 1; }
|
||||
|
||||
local run; run="$(log_line 'run -d --init')"
|
||||
[ -n "$run" ] || { echo "no filter container was started"; return 1; }
|
||||
[[ "$run" =~ --name\ dejima-filter-[0-9a-f]{16} ]] \
|
||||
|| { echo "the filter container is not named for this run: $run"; return 1; }
|
||||
[[ "$run" == *"--entrypoint /usr/local/bin/filter-entrypoint.sh"* ]] \
|
||||
|| { echo "the filter container does not run the filter entrypoint: $run"; return 1; }
|
||||
|
||||
# One network per launch, and the filter container on that one - not on
|
||||
# whatever bridge Docker would have picked. dns-proxy.sh forwards to the
|
||||
# embedded resolver at 127.0.0.11, which only exists on a user-defined
|
||||
# network, so this is load-bearing rather than tidiness.
|
||||
local id; id="$(sed -E 's/.*dejima-run-([0-9a-f]{16}).*/\1/' <<< "$net")"
|
||||
[[ "$run" == *"--network dejima-run-$id"* ]] \
|
||||
|| { echo "the filter container is not on this launch's network: $run"; return 1; }
|
||||
}
|
||||
|
||||
@test "the sandbox joins the filter container's namespace rather than getting one" {
|
||||
stub_launch
|
||||
launch "$PROJ"
|
||||
|
||||
# The compose file is what carries it, from an environment variable the
|
||||
# launcher exports - so both halves are asserted. Either alone passes on an
|
||||
# arrangement where the session still has a network of its own.
|
||||
grep -qE '^\s*network_mode: \$\{SANDBOX_NETNS_CONTAINER' "$SANDBOX_DIR/docker-compose.yml" \
|
||||
|| { echo "the compose file does not join a namespace"; return 1; }
|
||||
|
||||
# And what it falls back to when nothing supplies one. Only a `docker
|
||||
# compose` invocation by hand reaches this, but a default naming a real
|
||||
# network is a sandbox that comes up working with unfiltered egress, and
|
||||
# nothing about it looks wrong. `none` fails the same case visibly.
|
||||
grep -qE '^\s*network_mode: \$\{SANDBOX_NETNS_CONTAINER:-none\}\s*$' \
|
||||
"$SANDBOX_DIR/docker-compose.yml" \
|
||||
|| { echo "the network_mode default is not fail-closed"; return 1; }
|
||||
|
||||
local run; run="$(log_line 'run -d --init')"
|
||||
local id; id="$(sed -E 's/.*--name dejima-filter-([0-9a-f]{16}).*/\1/' <<< "$run")"
|
||||
[ -n "$id" ]
|
||||
run grep -c "SANDBOX_NETNS_CONTAINER" "$REPO_ROOT/bin/dejima"
|
||||
[ "$status" -eq 0 ]
|
||||
grep -q 'export SANDBOX_NETNS_CONTAINER="container:\$SANDBOX_FILTER_CONTAINER"' \
|
||||
"$REPO_ROOT/bin/dejima" \
|
||||
|| { echo "the launcher does not point the sandbox at the filter container"; return 1; }
|
||||
}
|
||||
|
||||
@test "the filter container comes up before the session, not beside it" {
|
||||
# The ordering IS the control. A namespace that gets its rules after
|
||||
# something is already running in it has a window where a session holds an
|
||||
# interface and no filter, which is what this arrangement exists to remove
|
||||
# by construction rather than to bound by argument.
|
||||
stub_launch
|
||||
launch "$PROJ"
|
||||
|
||||
local net_at filter_at ready_at session_at
|
||||
net_at="$(log_pos 'network create')"
|
||||
filter_at="$(log_pos 'run -d --init')"
|
||||
ready_at="$(log_pos 'dejima-firewall-ready')"
|
||||
session_at="$(log_pos ' run --rm ')"
|
||||
[ -n "$net_at" ] && [ -n "$filter_at" ] && [ -n "$ready_at" ] && [ -n "$session_at" ] \
|
||||
|| { echo "one of the four steps never happened"; cat "$LAUNCH_DIR/docker-log"; return 1; }
|
||||
|
||||
[ "$net_at" -lt "$filter_at" ] \
|
||||
|| { echo "the filter container started before its network existed"; return 1; }
|
||||
[ "$filter_at" -lt "$ready_at" ] \
|
||||
|| { echo "readiness was probed before the filter container was started"; return 1; }
|
||||
[ "$ready_at" -lt "$session_at" ] \
|
||||
|| { echo "the session started before the filter was confirmed up"; return 1; }
|
||||
}
|
||||
|
||||
@test "a filter container that never comes up refuses the launch" {
|
||||
# The failure this is really about is the quiet one: a launcher that waited,
|
||||
# gave up, and started the session anyway would produce a working sandbox
|
||||
# with no egress control, and nothing downstream would notice.
|
||||
stub_launch
|
||||
cat > "$LAUNCH_DIR/docker" <<'EOF'
|
||||
#!/bin/bash
|
||||
echo "$*" >> "${SANDBOX_TEST_DOCKER_LOG:-/dev/null}"
|
||||
case "$*" in
|
||||
# The marker never appears, and the container is reported gone - which is
|
||||
# what a firewall that refused to start leaves behind.
|
||||
*dejima-firewall-ready*) exit 1 ;;
|
||||
*inspect*State.Running*) echo false; exit 0 ;;
|
||||
*logs*) echo "init-firewall.sh: DNS lookup for registry.npmjs.org failed"; exit 0 ;;
|
||||
esac
|
||||
exit 0
|
||||
EOF
|
||||
chmod +x "$LAUNCH_DIR/docker"
|
||||
|
||||
run launch "$PROJ"
|
||||
[ "$status" -ne 0 ] \
|
||||
|| { echo "the launch succeeded with no egress control"; return 1; }
|
||||
[[ "$output" == *"did not come up"* ]] \
|
||||
|| { echo "it refused without saying why: $output"; return 1; }
|
||||
# And it hands over what the filter container said, or the operator is left
|
||||
# with a refusal and nowhere to look.
|
||||
[[ "$output" == *"registry.npmjs.org"* ]] \
|
||||
|| { echo "the refusal does not carry the filter container's own output: $output"; return 1; }
|
||||
|
||||
run grep -c -- ' run --rm ' "$LAUNCH_DIR/docker-log"
|
||||
[ "$output" = "0" ] \
|
||||
|| { echo "the session was started anyway"; return 1; }
|
||||
}
|
||||
|
||||
@test "the launch takes its own container and network down again" {
|
||||
stub_launch
|
||||
launch "$PROJ"
|
||||
|
||||
local run id
|
||||
run="$(log_line 'run -d --init')"
|
||||
id="$(sed -E 's/.*--name dejima-filter-([0-9a-f]{16}).*/\1/' <<< "$run")"
|
||||
[ -n "$id" ]
|
||||
|
||||
grep -q -- "rm -f dejima-filter-$id" "$LAUNCH_DIR/docker-log" \
|
||||
|| { echo "the filter container was left running"; return 1; }
|
||||
grep -q -- "network rm dejima-run-$id" "$LAUNCH_DIR/docker-log" \
|
||||
|| { echo "the network was left behind"; return 1; }
|
||||
|
||||
# After the session, not before it. Docker permits removing a namespace
|
||||
# owner from under a live joiner, so this order is the launcher's to keep.
|
||||
local session_at rm_at
|
||||
session_at="$(log_pos ' run --rm ')"
|
||||
rm_at="$(log_pos "rm -f dejima-filter-$id")"
|
||||
[ "$session_at" -lt "$rm_at" ] \
|
||||
|| { echo "the filter container was removed before the session ran"; return 1; }
|
||||
}
|
||||
|
||||
@test "the filter container runs the base image, never a project's derived one" {
|
||||
# It is the privileged half of the pair. A project's declared dependencies
|
||||
# are installed into the derived image for the session to use, and there is
|
||||
# no reason for any of them to be present in the container holding
|
||||
# NET_ADMIN.
|
||||
stub_launch
|
||||
launch "$PROJ"
|
||||
|
||||
local run; run="$(log_line 'run -d --init')"
|
||||
[[ "$run" =~ localhost/dejima:[^[:space:]-]+$ ]] \
|
||||
|| { echo "the filter container's image is not the plain base tag: $run"; return 1; }
|
||||
}
|
||||
|
||||
@test "the filter container declares the network capabilities and drops the rest" {
|
||||
stub_launch
|
||||
launch "$PROJ"
|
||||
|
||||
local run; run="$(log_line 'run -d --init')"
|
||||
[[ "$run" == *"--cap-drop ALL"* ]] \
|
||||
|| { echo "the filter container keeps Docker's default set: $run"; return 1; }
|
||||
[[ "$run" == *"--security-opt no-new-privileges:true"* ]] \
|
||||
|| { echo "no-new-privileges is missing from the filter container: $run"; return 1; }
|
||||
local cap
|
||||
for cap in NET_ADMIN NET_RAW KILL SETPCAP; do
|
||||
[[ "$run" == *"--cap-add $cap"* ]] \
|
||||
|| { echo "the filter container cannot do its job without $cap: $run"; return 1; }
|
||||
done
|
||||
}
|
||||
|
||||
@test "every variable the firewall path reads is passed to the filter container" {
|
||||
# The drift guard this arrangement needs most. The sandbox gets its
|
||||
# environment from docker-compose.yml; the filter container is started with
|
||||
# `docker run` and gets nothing that is not named on that command line.
|
||||
#
|
||||
# So a firewall script that starts reading a new variable does not fail - it
|
||||
# reads an empty one, and allowlists less than it was told to, which
|
||||
# presents as a domain the operator approved being unreachable rather than
|
||||
# as anything about this. Derived from the scripts rather than listed here,
|
||||
# so the test cannot drift from them either.
|
||||
stub_launch
|
||||
launch "$PROJ"
|
||||
local run; run="$(log_line 'run -d --init')"
|
||||
[ -n "$run" ]
|
||||
|
||||
local v missing=""
|
||||
while read -r v; do
|
||||
[ -n "$v" ] || continue
|
||||
[[ "$run" == *"-e $v"* ]] || missing="$missing $v"
|
||||
done < <(cat "$SANDBOX_DIR/firewall-up.sh" "$SANDBOX_DIR/dns-proxy.sh" \
|
||||
"$SANDBOX_DIR/init-firewall.sh" "$SANDBOX_DIR/fixed-domains.sh" \
|
||||
"$SANDBOX_DIR/log-shipper.sh" \
|
||||
| grep -oE '\$\{?(ALLOW[A-Z_]*|DEJIMA_[A-Z_]*|ENABLE_[A-Z_]*|GH_TOKEN|LOKI_URL|SANDBOX_[A-Z_]*)' \
|
||||
| tr -d '${' | sort -u \
|
||||
| grep -vE '^(EXTRA_ALLOWED_DOMAINS|SANDBOX_TEST.*)$')
|
||||
|
||||
[ -z "$missing" ] \
|
||||
|| { echo "the firewall path reads these, and the filter container is not given them:$missing"; return 1; }
|
||||
|
||||
# And the other half of the same failure, which the flag alone cannot show.
|
||||
# `docker run -e NAME` takes the value from the launcher's environment, so
|
||||
# a name that is assigned but never exported passes an empty one - the
|
||||
# filter container then allowlists less than it was told to, and what an
|
||||
# operator sees is a domain they approved being unreachable, with nothing
|
||||
# pointing here.
|
||||
#
|
||||
# Not hypothetical: `export DEJIMA_FORGE_HOSTS` is a line that had to be
|
||||
# added by hand when the filter container was introduced, because until
|
||||
# then that variable had only ever needed to reach the sandbox as
|
||||
# `-e NAME=value`, which carries its own value.
|
||||
#
|
||||
# Continuations are flattened first, because the exports here run to
|
||||
# several lines; and `export` is matched anywhere on a line rather than at
|
||||
# its start, because two of these are exported after a `;` on the line that
|
||||
# assigns them.
|
||||
local exported unexported=""
|
||||
exported="$(sed -z 's/\\\n[[:space:]]*/ /g' "$REPO_ROOT/bin/dejima" \
|
||||
| grep -oE 'export [A-Za-z_][A-Za-z0-9_ ]*')"
|
||||
[ -n "$exported" ] || { echo "found no exports in bin/dejima at all"; return 1; }
|
||||
|
||||
while read -r v; do
|
||||
[ -n "$v" ] || continue
|
||||
grep -qE "(^|[[:space:]])$v([[:space:]]|\$)" <<< "$exported" \
|
||||
|| unexported="$unexported $v"
|
||||
done < <(grep -oE '^\s+-e [A-Z_]+' "$REPO_ROOT/bin/dejima" | awk '{print $2}' | sort -u)
|
||||
|
||||
[ -z "$unexported" ] \
|
||||
|| { echo "passed as -e NAME but never exported, so the value is empty:$unexported"; return 1; }
|
||||
}
|
||||
|
||||
# A `docker` that reports one filter container and one network, with whatever
|
||||
# age, joiner and identity a test needs, and records every removal.
|
||||
#
|
||||
# Each of these tests is about a branch that decides NOT to remove something,
|
||||
# and a reconciler that removed nothing at all would pass every one of them -
|
||||
# so the first test here is the one that proves the mechanism fires.
|
||||
stub_orphan() { # <age-seconds> <joined-by|"">
|
||||
local age="$1" joiner="$2"
|
||||
stub_launch
|
||||
# Each query answers in the format Docker really answers it in, and the two
|
||||
# differ: `Created` is a string field on a container and a time.Time on a
|
||||
# network, so a template prints the second through String() and appends a
|
||||
# timezone NAME. One format for both is what let the network half of the
|
||||
# reconciler pass a test while reaping nothing.
|
||||
cat > "$LAUNCH_DIR/docker" <<EOF
|
||||
#!/bin/bash
|
||||
echo "\$*" >> "\${SANDBOX_TEST_DOCKER_LOG:-/dev/null}"
|
||||
epoch=\$(( \$(date +%s) - $age ))
|
||||
made=\$(date -u -d "@\$epoch" +%Y-%m-%dT%H:%M:%S.000000000Z)
|
||||
made_go=\$(date -u -d "@\$epoch" '+%Y-%m-%d %H:%M:%S.000000000 +0000 UTC')
|
||||
case "\$*" in
|
||||
# The running containers, and what namespace each is in.
|
||||
"ps -q") [ -n "$joiner" ] && echo j0 ; exit 0 ;;
|
||||
*HostConfig.NetworkMode*) [ -n "$joiner" ] && echo "container:$joiner" ; exit 0 ;;
|
||||
# The candidates.
|
||||
*"ps -aq --filter label=dejima.filter"*) echo orphan0 ; exit 0 ;;
|
||||
*"{{.Id}} {{.Name}} {{.Created}}"*) echo "orphan0 /dejima-filter-old \$made" ; exit 0 ;;
|
||||
*"network ls -q --filter label=dejima.run"*) echo net0 ; exit 0 ;;
|
||||
*"{{.Name}} {{.Created}}"*) echo "dejima-run-old \$made_go" ; exit 0 ;;
|
||||
esac
|
||||
exit 0
|
||||
EOF
|
||||
chmod +x "$LAUNCH_DIR/docker"
|
||||
}
|
||||
|
||||
@test "a filter container nothing is joined to, left over from before, is removed" {
|
||||
stub_orphan 6000 ""
|
||||
launch "$PROJ"
|
||||
grep -q -- 'rm -f orphan0' "$LAUNCH_DIR/docker-log" \
|
||||
|| { echo "the orphaned filter container was left running"; return 1; }
|
||||
grep -q -- 'network rm dejima-run-old' "$LAUNCH_DIR/docker-log" \
|
||||
|| { echo "its network was left behind"; return 1; }
|
||||
}
|
||||
|
||||
@test "the reconcile says what it removed, so a leak is not silently routine" {
|
||||
stub_orphan 6000 ""
|
||||
run launch "$PROJ"
|
||||
[[ "$output" == *"left behind"* ]] \
|
||||
|| { echo "an orphan was removed with nothing said about it: $output"; return 1; }
|
||||
}
|
||||
|
||||
@test "a filter container with a session still in its namespace is left alone" {
|
||||
# The one that matters on a host running several sessions: reaping this is
|
||||
# taking the network out from under somebody's running agent.
|
||||
stub_orphan 6000 orphan0
|
||||
launch "$PROJ"
|
||||
run grep -c -- 'rm -f orphan0' "$LAUNCH_DIR/docker-log"
|
||||
[ "$output" = "0" ] \
|
||||
|| { echo "a filter container with a live session was removed"; return 1; }
|
||||
}
|
||||
|
||||
@test "a filter container younger than the grace is left alone" {
|
||||
# A pair being built right now by a concurrent launch: between `docker run`
|
||||
# for the filter and `docker compose run` for the session, a healthy filter
|
||||
# container has no joiner. Age is what tells that apart from abandonment.
|
||||
stub_orphan 5 ""
|
||||
launch "$PROJ"
|
||||
run grep -c -- 'rm -f orphan0' "$LAUNCH_DIR/docker-log"
|
||||
[ "$output" = "0" ] \
|
||||
|| { echo "a filter container still being paired was removed"; return 1; }
|
||||
run grep -c -- 'network rm dejima-run-old' "$LAUNCH_DIR/docker-log"
|
||||
[ "$output" = "0" ] \
|
||||
|| { echo "a network still being attached to was removed"; return 1; }
|
||||
}
|
||||
|
||||
@test "a docker that cannot answer the reconcile does not fail the launch" {
|
||||
# It runs before the launch's own network and container, so an error here
|
||||
# would refuse a session over tidying up. The launch that follows reports
|
||||
# its own failures.
|
||||
stub_launch
|
||||
cat > "$LAUNCH_DIR/docker" <<'EOF'
|
||||
#!/bin/bash
|
||||
echo "$*" >> "${SANDBOX_TEST_DOCKER_LOG:-/dev/null}"
|
||||
case "$*" in
|
||||
ps*|*inspect*|*network\ ls*) echo "Cannot connect to the Docker daemon" >&2; exit 1 ;;
|
||||
esac
|
||||
exit 0
|
||||
EOF
|
||||
chmod +x "$LAUNCH_DIR/docker"
|
||||
run launch "$PROJ"
|
||||
[ "$status" -eq 0 ] \
|
||||
|| { echo "a failing reconcile refused the launch: $output"; return 1; }
|
||||
grep -q -- ' run --rm ' "$LAUNCH_DIR/docker-log" \
|
||||
|| { echo "the session never started"; return 1; }
|
||||
}
|
||||
+75
-1
@@ -31,6 +31,11 @@ if [ "\$1" = ps ]; then
|
||||
exit 0
|
||||
fi
|
||||
case "\$*" in
|
||||
# A session container shares a filter container's network namespace, and
|
||||
# that is where the dnsmasq and the ipsets are. dejima-allow derives the
|
||||
# one from the other rather than being told, so a stub that answers this
|
||||
# with nothing is a session with no filter to add a domain to.
|
||||
*inspect*NetworkMode*) echo "container:dejima-test-filter"; exit 0 ;;
|
||||
*dig*) echo 10.0.0.1; exit 0 ;;
|
||||
*curl*) exit $2 ;;
|
||||
*) exit 0 ;;
|
||||
@@ -651,6 +656,66 @@ EOF
|
||||
grep -qx 'crates.io' "$(store_of "$PROJ")/allowed-domains"
|
||||
}
|
||||
|
||||
@test "dejima-allow takes the filter container's id, which is what a session reads" {
|
||||
# A session asked for its own container id runs `cat /etc/hostname`, and
|
||||
# Docker names a container that joined another's network namespace after
|
||||
# the container it joined - so the id an agent hands its operator is the
|
||||
# FILTER container's. That id has to work, or the one command every
|
||||
# briefing tells an agent to ask for fails on the id the briefing gave it.
|
||||
STUB_DIR="$(mktemp -d)"
|
||||
cat > "$STUB_DIR/docker" <<'EOF'
|
||||
#!/bin/bash
|
||||
echo "$*" >> "${DOCKER_CALL_LOG:-/dev/null}"
|
||||
case "$*" in
|
||||
# Named container is a filter: it owns a network rather than joining one.
|
||||
*inspect*NetworkMode*c1*) echo "dejima-run-abcd" ;;
|
||||
*inspect*'{{.Id}}'*c1*) echo "filterid" ;;
|
||||
# And one running container is in its namespace.
|
||||
"ps -q") echo sessionid ;;
|
||||
*'{{.Id}} {{.HostConfig.NetworkMode}}'*) echo "sessionid container:filterid" ;;
|
||||
*dig*) echo 10.0.0.1 ;;
|
||||
esac
|
||||
exit 0
|
||||
EOF
|
||||
chmod +x "$STUB_DIR/docker"
|
||||
PATH="$STUB_DIR:$PATH" DOCKER_CALL_LOG="$STUB_DIR/calls" \
|
||||
run "$REPO_ROOT/bin/dejima-allow" --container c1 crates.io
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
|
||||
# Asserted on the calls rather than the messages: which container each
|
||||
# half runs in is the whole point, and a message could name either while
|
||||
# the exec went to the wrong one.
|
||||
grep -q 'exec -u root c1 ipset add' "$STUB_DIR/calls" \
|
||||
|| { echo "the ipset add did not go to the filter container:"; cat "$STUB_DIR/calls"; return 1; }
|
||||
grep -q 'exec sessionid curl' "$STUB_DIR/calls" \
|
||||
|| { echo "the probe did not run in the session container:"; cat "$STUB_DIR/calls"; return 1; }
|
||||
rm -rf "$STUB_DIR"
|
||||
}
|
||||
|
||||
@test "dejima-allow refuses a container that is neither half of a pair" {
|
||||
# A launch predating the split has a namespace of its own and no filter
|
||||
# container in it. The useful answer is what to do instead, not a docker
|
||||
# exec that fails somewhere inside a heredoc.
|
||||
STUB_DIR="$(mktemp -d)"
|
||||
cat > "$STUB_DIR/docker" <<'EOF'
|
||||
#!/bin/bash
|
||||
case "$*" in
|
||||
*inspect*NetworkMode*) echo "bridge"; exit 0 ;;
|
||||
*inspect*'{{.Id}}'*) echo "loneid"; exit 0 ;;
|
||||
"ps -q") exit 0 ;;
|
||||
esac
|
||||
exit 0
|
||||
EOF
|
||||
chmod +x "$STUB_DIR/docker"
|
||||
PATH="$STUB_DIR:$PATH" run "$REPO_ROOT/bin/dejima-allow" --container c1 crates.io
|
||||
[ "$status" -ne 0 ]
|
||||
[[ "$output" == *"neither a running session nor the filter container"* ]] \
|
||||
|| { echo "$output"; return 1; }
|
||||
[[ "$output" == *"dejima-allow --project crates.io"* ]] \
|
||||
|| { echo "the refusal does not say what to do instead: $output"; return 1; }
|
||||
rm -rf "$STUB_DIR"
|
||||
}
|
||||
|
||||
@test "dejima-allow --global persists without needing a container" {
|
||||
run "$REPO_ROOT/bin/dejima-allow" --global example.com
|
||||
[ "$status" -eq 0 ]
|
||||
@@ -704,6 +769,7 @@ stub_docker_rebind() {
|
||||
cat > "$STUB_DIR/docker" <<'EOF'
|
||||
#!/bin/bash
|
||||
case "$*" in
|
||||
*inspect*NetworkMode*) echo "container:dejima-test-filter"; exit 0 ;;
|
||||
*dig*@127.0.0.11*) echo 10.0.0.1 ;;
|
||||
esac
|
||||
exit 0
|
||||
@@ -737,7 +803,15 @@ EOF
|
||||
# The rebind branch above must not swallow the ordinary failure: a typo'd
|
||||
# or dead name gets no answer from the upstream either.
|
||||
STUB_DIR="$(mktemp -d)"
|
||||
printf '#!/bin/bash\nexit 0\n' > "$STUB_DIR/docker"
|
||||
# Answers where the filter container is and nothing else, so every lookup
|
||||
# comes back empty - which is the case this is about.
|
||||
cat > "$STUB_DIR/docker" <<'EOF'
|
||||
#!/bin/bash
|
||||
case "$*" in
|
||||
*inspect*NetworkMode*) echo "container:dejima-test-filter" ;;
|
||||
esac
|
||||
exit 0
|
||||
EOF
|
||||
chmod +x "$STUB_DIR/docker"
|
||||
run env PATH="$STUB_DIR:$PATH" \
|
||||
"$REPO_ROOT/bin/dejima-allow" --container c1 nowhere.example.com
|
||||
|
||||
+39
-6
@@ -10,13 +10,43 @@
|
||||
set -uo pipefail
|
||||
|
||||
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
base="${1:-main}"
|
||||
# The remote-tracking ref by default, not the local branch. Everything this
|
||||
# prints is derived from base..head - the manual checks from which paths the
|
||||
# diff touches, the rollback list from which config keys it adds - so a base
|
||||
# behind the real one silently reports a different release. That is not
|
||||
# hypothetical: two passes ran against a local `main` 260 commits behind
|
||||
# origin/main and reported keys and a trust-anchor domain that had shipped
|
||||
# releases earlier. A gate answering confidently about the wrong diff is worse
|
||||
# than one that does not run.
|
||||
base="${1:-}"
|
||||
if [ -z "$base" ]; then
|
||||
if git rev-parse --verify --quiet origin/main >/dev/null 2>&1; then
|
||||
base=origin/main
|
||||
else
|
||||
base=main
|
||||
fi
|
||||
fi
|
||||
head="${2:-HEAD}"
|
||||
# Guarded, because everything below is relative to it: a cd that failed
|
||||
# silently would have this reporting on some other directory's suites and
|
||||
# diff, which for a release gate is the worst way to be wrong.
|
||||
cd "$root" || { echo "cannot enter $root" >&2; exit 1; }
|
||||
|
||||
# A named base that is behind its own upstream is refused rather than used.
|
||||
# Passing `main` explicitly is the ordinary thing to do and is exactly how the
|
||||
# stale-base runs happened, so this has to catch the argument as well as the
|
||||
# default.
|
||||
base_upstream="$(git rev-parse --abbrev-ref --symbolic-full-name "$base@{upstream}" 2>/dev/null || true)"
|
||||
if [ -n "$base_upstream" ]; then
|
||||
behind="$(git rev-list --count "$base..$base_upstream" 2>/dev/null || echo 0)"
|
||||
if [ "${behind:-0}" -gt 0 ]; then
|
||||
echo "release-pass: '$base' is $behind commit(s) behind $base_upstream." >&2
|
||||
echo "Everything below is derived from $base..$head, so this would report on" >&2
|
||||
echo "a release that is not the one being made. Fetch, or name $base_upstream." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
case "${1:-}" in
|
||||
-h|--help)
|
||||
echo "Usage: test/release-pass.sh [base] [head]"
|
||||
@@ -65,9 +95,11 @@ if docker info >/dev/null 2>&1; then
|
||||
else
|
||||
fail "no Docker here - test/runtime.bats did NOT run"
|
||||
echo " It is the only thing that exercises mounts, the firewall and"
|
||||
echo " the entrypoint end to end. Run it on the host, or check that"
|
||||
echo " the privileged CI job RAN rather than queued: it is pinned to"
|
||||
echo " one self-hosted runner and queues silently when that is down."
|
||||
echo " the entrypoint end to end. Run it on the host, or read the"
|
||||
echo " pull request's checks: GREEN means every job finished and"
|
||||
echo " passed, this one among them. Yellow or grey means the"
|
||||
echo " privileged job is still queued - it is pinned to one"
|
||||
echo " self-hosted runner and queues silently when that is down."
|
||||
fi
|
||||
|
||||
# --- what a person has to do, chosen by what the release touches -------------
|
||||
@@ -211,6 +243,7 @@ else
|
||||
fi
|
||||
|
||||
note "Then"
|
||||
echo " Say in the release PR what was run and what was not. A privileged job"
|
||||
echo " that queued is not a job that passed."
|
||||
echo " Say in the release PR what was run and what was not. Green checks mean"
|
||||
echo " the privileged job ran; a queued one is yellow or grey, and is not a"
|
||||
echo " job that passed."
|
||||
exit "$status"
|
||||
|
||||
+36
-2
@@ -215,6 +215,30 @@ run_line() { grep -- ' run --rm ' "$LAUNCH_DIR/docker-log" | tail -1; }
|
||||
rm -rf "$PROJ"
|
||||
}
|
||||
|
||||
@test "an inherited state dir and run id are not what the exit trap deletes" {
|
||||
# cleanup() removes "$DEJIMA_STATE_DIR/debounce/$DEJIMA_RUN_ID-{waiting,done}".
|
||||
# With remote access off nothing used to assign either name, so both came
|
||||
# from the caller's environment if it had them - and the trap then removed
|
||||
# a path of the environment's choosing. Both are bound by dejima before the
|
||||
# trap is installed now, so the join can only ever name a path it made.
|
||||
#
|
||||
# Two canaries, because either name alone being dejima's is enough to miss
|
||||
# the path: the removal needs both halves to come from the environment.
|
||||
stub_launch
|
||||
local canary="$LAUNCH_DIR/canary"
|
||||
mkdir -p "$canary/debounce"
|
||||
: > "$canary/debounce/deadbeefdeadbeef-waiting"
|
||||
: > "$canary/debounce/deadbeefdeadbeef-done"
|
||||
|
||||
DEJIMA_STATE_DIR="$canary" DEJIMA_RUN_ID=deadbeefdeadbeef launch "$PROJ"
|
||||
|
||||
[ -f "$canary/debounce/deadbeefdeadbeef-waiting" ] \
|
||||
|| { echo "the trap removed a file named by the environment"; return 1; }
|
||||
[ -f "$canary/debounce/deadbeefdeadbeef-done" ] \
|
||||
|| { echo "the trap removed a file named by the environment"; return 1; }
|
||||
rm -rf "$PROJ"
|
||||
}
|
||||
|
||||
@test "with remote access configured a registry entry is written and removed on exit" {
|
||||
stub_launch
|
||||
write_remote_config
|
||||
@@ -420,11 +444,21 @@ EOF
|
||||
write_remote_config
|
||||
# The EXIT trap removes the entry, so snapshot it from inside the run:
|
||||
# the stub stands in for the container, and runs while it still exists.
|
||||
#
|
||||
# Matched on the session's own compose run rather than on every docker
|
||||
# call. A launch makes several - a build, this launch's filter container,
|
||||
# and two removals from the exit trap - and snapshotting on each meant the
|
||||
# last one won. Those two removals run after the entry is gone, so what
|
||||
# this read was an empty directory.
|
||||
cat > "$LAUNCH_DIR/docker" <<EOF
|
||||
#!/bin/bash
|
||||
echo "\$*" >> "\${SANDBOX_TEST_DOCKER_LOG:-/dev/null}"
|
||||
rm -rf "$LAUNCH_DIR/sessions-snapshot"
|
||||
cp -r "$RUNTIME_DIR/dejima/sessions" "$LAUNCH_DIR/sessions-snapshot" 2>/dev/null || true
|
||||
case "\$*" in
|
||||
*" run --rm "*)
|
||||
rm -rf "$LAUNCH_DIR/sessions-snapshot"
|
||||
cp -r "$RUNTIME_DIR/dejima/sessions" "$LAUNCH_DIR/sessions-snapshot" 2>/dev/null || true
|
||||
;;
|
||||
esac
|
||||
exit 0
|
||||
EOF
|
||||
chmod +x "$LAUNCH_DIR/docker"
|
||||
|
||||
+513
-220
File diff suppressed because it is too large
Load Diff
+11
-6
@@ -27,12 +27,17 @@ list_scripts() {
|
||||
ls test/*.sh 2>/dev/null || true
|
||||
} | sort -u | while read -r f; do
|
||||
[ -f "$f" ] || continue
|
||||
# Harness plugins are sourced, not executed, so they carry no shebang
|
||||
# and the discovery below would drop them - silently, which is the
|
||||
# worst version of that. They are in the trusted computing base, so
|
||||
# they are included by name and the -s bash below tells shellcheck
|
||||
# what they are.
|
||||
case "$f" in bin/harness/*.sh) printf '%s\n' "$f"; continue ;; esac
|
||||
# Sourced fragments are not executed, so they carry no shebang and the
|
||||
# discovery below would drop them - silently, which is the worst
|
||||
# version of that. Harness plugins are one kind; docker/ has two more,
|
||||
# and they are not incidental: caps-lib.sh holds the drop that decides
|
||||
# what a session's capability set is, and fixed-domains.sh is the seam
|
||||
# keeping the DNS allowlist and the ipset allowlist from drifting.
|
||||
#
|
||||
# Matched by directory rather than by name, because a list is a thing
|
||||
# to forget to add to. Everything under docker/ is in the trusted
|
||||
# computing base; the -s bash below tells shellcheck what these are.
|
||||
case "$f" in bin/harness/*.sh|docker/*.sh) printf '%s\n' "$f"; continue ;; esac
|
||||
head -1 "$f" | grep -qE '^#!.*(bash|sh)' && printf '%s\n' "$f"
|
||||
done
|
||||
}
|
||||
|
||||
+351
-32
@@ -51,9 +51,40 @@ flat_prose() { tr '\n' ' ' < "$1" | tr -d '*`' | tr -s ' '; }
|
||||
}
|
||||
|
||||
@test "entrypoint.sh drops privilege via SANDBOX_USER, not a literal node" {
|
||||
run grep -E 'gosu node\b' "$SANDBOX_DIR/entrypoint.sh"
|
||||
run grep -E 'gosu "?node\b|--user="?node\b' "$SANDBOX_DIR/entrypoint.sh"
|
||||
[ "$status" -ne 0 ]
|
||||
# Both spellings, because the session's own drop is not the gosu one: it
|
||||
# goes through capsh, which reduces the bounding set in the same process
|
||||
# that changes uid. A check that only knew about gosu would keep passing
|
||||
# while the line it is really about named a literal user.
|
||||
grep -q 'gosu "$SANDBOX_USER"' "$SANDBOX_DIR/entrypoint.sh"
|
||||
grep -q -- '--user="$SANDBOX_USER"' "$SANDBOX_DIR/entrypoint.sh"
|
||||
}
|
||||
|
||||
@test "nothing that outlives the entrypoint is started without dropping capabilities" {
|
||||
# The session and log-shipper.sh are the two processes that survive the
|
||||
# root phase, and the point of drop_caps is that neither inherits the
|
||||
# bounding set that phase needed. A `gosu`/bare exec creeping back in for
|
||||
# either is the regression, and it is invisible at runtime: the container
|
||||
# comes up and works, holding capabilities nothing asked for.
|
||||
grep -q 'drop_caps --user="$SANDBOX_USER" -- -c .exec "\$@". dejima \$SANDBOX_CMD' \
|
||||
"$SANDBOX_DIR/entrypoint.sh" \
|
||||
|| { echo "the session is not exec'd through drop_caps"; return 1; }
|
||||
grep -q 'drop_caps .*log-shipper\.sh &' "$SANDBOX_DIR/firewall-up.sh" \
|
||||
|| { echo "log-shipper.sh is not started through drop_caps"; return 1; }
|
||||
run grep -nE '^\s*(exec )?gosu "\$SANDBOX_USER" (\$SANDBOX_CMD|sleep)' \
|
||||
"$SANDBOX_DIR/entrypoint.sh"
|
||||
[ "$status" -ne 0 ] \
|
||||
|| { echo "a session exec still goes through gosu: $output"; return 1; }
|
||||
}
|
||||
|
||||
@test "capsh is a declared package, not a dependency of something else" {
|
||||
# entrypoint.sh refuses to start without it, so an image built without it
|
||||
# is a container that never appears. It arrived here as a dependency of
|
||||
# iproute2, which is exactly the kind of thing an unrelated package change
|
||||
# takes away.
|
||||
grep -qE '^\s+libcap2-bin \\$' "$SANDBOX_DIR/Dockerfile" \
|
||||
|| { echo "libcap2-bin is not in the Dockerfile's package list"; return 1; }
|
||||
}
|
||||
|
||||
@test "Playwright is installed and require()-able globally" {
|
||||
@@ -830,6 +861,67 @@ _declared_config_dirs() {
|
||||
done
|
||||
}
|
||||
|
||||
@test "every path cleanup() removes is one dejima bound, never one the environment did" {
|
||||
# A different invariant from the test above, and the `${NAME:-}` form that
|
||||
# satisfies that one is no help here. Guarding against UNBOUND stops set -u
|
||||
# aborting the trap; it does nothing about a name that arrives from the
|
||||
# caller's environment already carrying a value, which the trap then
|
||||
# removes. bin/dejima's own comment states this - "every name cleanup()
|
||||
# removes but does not itself create belongs here" - and until the run-id
|
||||
# change nothing checked it, so three names had drifted out of it, one of
|
||||
# them an `rm -rf` argument.
|
||||
#
|
||||
# An exit between the trap and a name's first assignment is what makes it
|
||||
# reachable. SANDBOX_GITCONFIG_DIR is the one name assigned below the trap,
|
||||
# and its window holds no exit; anything else below the trap is a finding.
|
||||
local d="$REPO_ROOT/bin/dejima"
|
||||
local trap_line; trap_line="$(grep -n '^trap cleanup EXIT' "$d" | cut -d: -f1)"
|
||||
[ -n "$trap_line" ]
|
||||
|
||||
# Only the rm lines, and only their arguments - cleanup() reads plenty it
|
||||
# never deletes, and those are the other test's business.
|
||||
local removed
|
||||
removed="$(sed -n '/^cleanup() {/,/^}/p' "$d" \
|
||||
| grep -E '^[[:space:]]*rm -' \
|
||||
| grep -oE '\$\{?[A-Za-z_][A-Za-z0-9_]*' | tr -d '${' | sort -u)"
|
||||
[ -n "$removed" ] || { echo "found no rm arguments in cleanup() at all"; return 1; }
|
||||
|
||||
local v asg
|
||||
for v in $removed; do
|
||||
[ "$v" != "SANDBOX_GITCONFIG_DIR" ] || continue
|
||||
asg="$(grep -nE "^[[:space:]]*(export )?$v=" "$d" | head -1 | cut -d: -f1)"
|
||||
[ -n "$asg" ] || { echo "cleanup() removes \$$v, which is never assigned"; return 1; }
|
||||
[ "$asg" -lt "$trap_line" ] || {
|
||||
echo "cleanup() removes \$$v, first assigned at line $asg - below the trap at"
|
||||
echo "$trap_line, so an exit in between removes whatever the environment held."
|
||||
return 1
|
||||
}
|
||||
done
|
||||
}
|
||||
|
||||
@test "the one name assigned below the trap still has no exit to fire on" {
|
||||
# SANDBOX_GITCONFIG_DIR is exempted above on a claim about its window, not
|
||||
# on a claim about the name. The claim is what needs checking: an `exit`
|
||||
# appearing between the trap and that assignment turns the exemption into
|
||||
# the bug it is exempted from, and nothing about that edit would look like
|
||||
# it touched this.
|
||||
local d="$REPO_ROOT/bin/dejima"
|
||||
local trap_line asg
|
||||
trap_line="$(grep -n '^trap cleanup EXIT' "$d" | cut -d: -f1)"
|
||||
asg="$(grep -nE '^[[:space:]]*SANDBOX_GITCONFIG_DIR=' "$d" | head -1 | cut -d: -f1)"
|
||||
[ -n "$trap_line" ] && [ -n "$asg" ]
|
||||
[ "$asg" -gt "$trap_line" ] \
|
||||
|| skip "SANDBOX_GITCONFIG_DIR is now assigned above the trap; drop the exception"
|
||||
|
||||
run awk -v a="$trap_line" -v b="$asg" 'NR>a && NR<b && /^[[:space:]]*exit [0-9]/ {print NR": "$0}' "$d"
|
||||
[ -z "$output" ] || {
|
||||
echo "an exit now sits between the trap at $trap_line and the assignment at $asg:"
|
||||
echo "$output"
|
||||
echo "so bind SANDBOX_GITCONFIG_DIR above the trap like the rest."
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
@test "docker-compose.yml never hardcodes /home/node - the container home is host-parametrized" {
|
||||
# ${HOME:-/home/node} (the SANDBOX_HOME build arg's own default value,
|
||||
# mirroring the Dockerfile's default) is the one legitimate exception -
|
||||
@@ -1545,6 +1637,41 @@ _reference_has_key() { # <doc> <section> <leaf>
|
||||
done
|
||||
}
|
||||
|
||||
@test "CI runs every confined suite, not just the ones someone remembered" {
|
||||
# The matrix in tests.yml is enumerated by hand, and test/run-confined.sh
|
||||
# discovers suites from the directory. So a suite added to the tree runs
|
||||
# locally and never runs in CI - a green set of checks covering less than
|
||||
# it appears to, which is this repo's least acceptable failure and one
|
||||
# nothing was watching for.
|
||||
#
|
||||
# Which suites are confined is run-confined.sh's own declaration, read
|
||||
# rather than restated: runtime and ci-runner are privileged and have their
|
||||
# own jobs elsewhere in this workflow, and a list here would be a second
|
||||
# place for that to be true.
|
||||
local matrix privileged f n
|
||||
matrix="$(sed -n '/^ suite:/,/^ name:/p' "$REPO_ROOT/.gitea/workflows/tests.yml" \
|
||||
| grep -oE '^ - [a-z-]+' | sed 's/^ - //')"
|
||||
[ -n "$matrix" ] || { echo "found no suite matrix in tests.yml"; return 1; }
|
||||
privileged="$(grep -oE '^PRIVILEGED=" [a-z -]+"' "$REPO_ROOT/test/run-confined.sh" \
|
||||
| sed -E 's/^PRIVILEGED=" //; s/ "$//')"
|
||||
[ -n "$privileged" ] || { echo "could not read the privileged list"; return 1; }
|
||||
|
||||
for f in "$REPO_ROOT"/test/*.bats; do
|
||||
n="$(basename "$f" .bats)"
|
||||
[[ " $privileged " == *" $n "* ]] && continue
|
||||
grep -qx "$n" <<< "$matrix" \
|
||||
|| { echo "test/$n.bats is a confined suite and CI never runs it"; return 1; }
|
||||
done
|
||||
|
||||
# And the other direction, or the matrix names a job that fails on a
|
||||
# missing file rather than covering anything.
|
||||
while read -r n; do
|
||||
[ -n "$n" ] || continue
|
||||
[ -f "$REPO_ROOT/test/$n.bats" ] \
|
||||
|| { echo "CI runs a suite that does not exist: $n"; return 1; }
|
||||
done <<< "$matrix"
|
||||
}
|
||||
|
||||
@test "the changed lane's map names only suites that exist" {
|
||||
# The map is what --changed selects with, so a suite renamed out from under
|
||||
# it stops being selected silently: the run still says green, having
|
||||
@@ -1605,6 +1732,92 @@ _reference_has_key() { # <doc> <section> <leaf>
|
||||
done < <(lib provider_list harness)
|
||||
}
|
||||
|
||||
@test "shellcheck covers every docker/ script, sourced fragments included" {
|
||||
# Same failure as the plugins above and a worse place for it: caps-lib.sh
|
||||
# is sourced, so it has no shebang, and it holds the drop that decides what
|
||||
# capabilities a session runs with. fixed-domains.sh is the seam holding
|
||||
# the DNS allowlist and the ipset allowlist together.
|
||||
#
|
||||
# Enumerated from the tree rather than from a list here, so a fragment
|
||||
# added tomorrow is covered without anyone remembering this test.
|
||||
local f
|
||||
for f in "$SANDBOX_DIR"/*.sh; do
|
||||
[ -e "$f" ] || continue
|
||||
bash "$REPO_ROOT/test/shellcheck.sh" --list | grep -qx "docker/${f##*/}" \
|
||||
|| { echo "docker/${f##*/} is not shellchecked"; return 1; }
|
||||
done
|
||||
}
|
||||
|
||||
@test "the filter entrypoint brings the firewall up, and lets a failure stop it" {
|
||||
# Exactly one caller, and it is the one in the container that holds the
|
||||
# capabilities. A second caller in the session's container would try to
|
||||
# install rules in a namespace that already has them, and start a second
|
||||
# dnsmasq on an address the first one is already bound to.
|
||||
grep -qE '^/usr/local/bin/firewall-up\.sh$' "$SANDBOX_DIR/filter-entrypoint.sh" \
|
||||
|| { echo "filter-entrypoint.sh does not call firewall-up.sh, or guards the call"; return 1; }
|
||||
# Code, not prose: the entrypoint may well go on mentioning the script in a
|
||||
# comment, and a check that could not tell the two apart would be one
|
||||
# nobody could satisfy while explaining why the call is absent.
|
||||
run bash -c "grep -vE '^[[:space:]]*#' '$SANDBOX_DIR/entrypoint.sh' | grep -n 'firewall-up'"
|
||||
[ "$status" -ne 0 ] \
|
||||
|| { echo "the session's entrypoint still brings the firewall up: $output"; return 1; }
|
||||
|
||||
# Unguarded is the point: under `set -e` a firewall that failed to come up
|
||||
# takes the container with it, and the readiness marker below it is never
|
||||
# written, so bin/dejima refuses the launch. `|| true`, an `if`, or a
|
||||
# background `&` would each turn that into a session running unfiltered.
|
||||
run grep -nE 'firewall-up\.sh.*(\|\||&|;)|(if|while).*firewall-up\.sh' \
|
||||
"$SANDBOX_DIR/filter-entrypoint.sh"
|
||||
[ "$status" -ne 0 ] \
|
||||
|| { echo "the firewall-up call is guarded or backgrounded: $output"; return 1; }
|
||||
|
||||
# And the marker is written after it, never before: it is what the launcher
|
||||
# waits on, so writing it first would announce a filter that is not there.
|
||||
local up_at ready_at
|
||||
up_at="$(grep -n '^/usr/local/bin/firewall-up\.sh$' "$SANDBOX_DIR/filter-entrypoint.sh" | cut -d: -f1)"
|
||||
ready_at="$(grep -n 'dejima-firewall-ready' "$SANDBOX_DIR/filter-entrypoint.sh" | grep -v '^ *#' | tail -1 | cut -d: -f1)"
|
||||
[ -n "$up_at" ] && [ -n "$ready_at" ] && [ "$up_at" -lt "$ready_at" ] \
|
||||
|| { echo "the readiness marker is not written after the firewall comes up"; return 1; }
|
||||
}
|
||||
|
||||
@test "the session container points itself at the namespace's resolver" {
|
||||
# /etc/resolv.conf is per container even when the network namespace is
|
||||
# shared - measured, and the reason this line has to exist at all.
|
||||
# dns-proxy.sh repoints the filter container's copy; Docker generated this
|
||||
# container's pointing straight at its embedded resolver.
|
||||
#
|
||||
# Left alone, every name the session looks up goes around dnsmasq and the
|
||||
# DNS allowlist applies to nothing - while the packet filter still holds,
|
||||
# so nothing fails and nothing says so.
|
||||
grep -q 'echo "nameserver 127.0.0.1" > /etc/resolv.conf' "$SANDBOX_DIR/entrypoint.sh" \
|
||||
|| { echo "the session container never points itself at the allowlisting resolver"; return 1; }
|
||||
|
||||
# The same address dns-proxy.sh binds and writes for itself, or the two
|
||||
# halves of one arrangement disagree about where the resolver is.
|
||||
grep -q 'echo "nameserver 127.0.0.1" > /etc/resolv.conf' "$SANDBOX_DIR/dns-proxy.sh" \
|
||||
|| { echo "dns-proxy.sh no longer writes that address; the two have drifted"; return 1; }
|
||||
}
|
||||
|
||||
@test "dejima-allow changes the allowlist where it lives, and checks where it matters" {
|
||||
# The dnsmasq and the ipsets are in the filter container. Every mutation
|
||||
# has to go there; the reachability probe has to stay in the session's
|
||||
# container, because that is the container whose reachability the command
|
||||
# claims to have changed. A probe that ran in the filter container would
|
||||
# pass on an arrangement where the session still could not reach the host.
|
||||
local a="$REPO_ROOT/bin/dejima-allow"
|
||||
grep -q 'docker exec -u root "$filter" bash -c' "$a" \
|
||||
|| { echo "the dnsmasq reload does not target the filter container"; return 1; }
|
||||
grep -q 'docker exec -u root "$filter" ipset add' "$a" \
|
||||
|| { echo "the ipset add does not target the filter container"; return 1; }
|
||||
grep -q 'docker exec "$container" curl' "$a" \
|
||||
|| { echo "the reachability check no longer runs in the session's container"; return 1; }
|
||||
|
||||
# Derived from Docker rather than recorded, so nothing has to be kept in
|
||||
# step with it.
|
||||
grep -q "HostConfig.NetworkMode" "$a" \
|
||||
|| { echo "the filter container is not derived from the session's namespace"; return 1; }
|
||||
}
|
||||
|
||||
@test "an unparseable settings.json stops the launch instead of being ignored" {
|
||||
# Claude Code ignores a settings.json it cannot parse, wholesale and with
|
||||
# a startup warning only - so the session would run without the
|
||||
@@ -2447,6 +2660,27 @@ _install_forge_bins() {
|
||||
rm -rf "$fake" "$dest"
|
||||
}
|
||||
|
||||
@test "the release pass refuses a base that is behind its own upstream" {
|
||||
# Everything the pass prints is derived from base..head - which manual
|
||||
# checks apply, which config keys a rollback would refuse - so a base
|
||||
# behind the real one reports on a different release, confidently. Two
|
||||
# passes ran against a local `main` 260 commits behind origin/main and
|
||||
# named keys and a domain that had shipped releases earlier.
|
||||
grep -q 'behind its own upstream\|behind \$base_upstream\|commit(s) behind' \
|
||||
"$REPO_ROOT/test/release-pass.sh" \
|
||||
|| { echo "the staleness check is gone"; return 1; }
|
||||
# The default is the tracking ref, not the local branch, so the ordinary
|
||||
# invocation cannot pick up a stale one in the first place.
|
||||
grep -q 'base=origin/main' "$REPO_ROOT/test/release-pass.sh" \
|
||||
|| { echo "the default base is not the tracking ref"; return 1; }
|
||||
# And it exits rather than warning: a gate that says something is wrong
|
||||
# and then reports anyway is one whose output still gets pasted into a
|
||||
# release PR.
|
||||
sed -n '/is .* behind /,/^fi$/p' "$REPO_ROOT/test/release-pass.sh" \
|
||||
| grep -q 'exit 1' \
|
||||
|| { echo "a stale base is warned about rather than refused"; return 1; }
|
||||
}
|
||||
|
||||
@test "the release pass names every area whose check is manual" {
|
||||
# This list is the only record of what the test suites cannot see, and it
|
||||
# is the kind of list that rots by omission: an area gets a manual check
|
||||
@@ -2464,8 +2698,18 @@ _install_forge_bins() {
|
||||
# And it must say what it could not run, rather than reporting a pass.
|
||||
grep -q 'did NOT run' "$script" \
|
||||
|| { echo "a gate that could not run is reported as passing"; return 1; }
|
||||
grep -q 'queued is not a job that passed' "$script" \
|
||||
|| { echo "nothing warns that a queued CI job is not a green one"; return 1; }
|
||||
# Both halves of the rule, because reporting only one of them is how this
|
||||
# goes wrong in each direction: a queued job read as a pass, or a green one
|
||||
# hedged as though it might be queued.
|
||||
#
|
||||
# Matched against the script flattened to one line, so rewrapping a message
|
||||
# does not fail a test about what it says.
|
||||
local flat_script
|
||||
flat_script="$(tr '\n' ' ' < "$script" | tr -s ' ')"
|
||||
[[ "$flat_script" == *"queued one is yellow or grey"* ]] \
|
||||
|| { echo "nothing says a queued CI job is not a passing one"; return 1; }
|
||||
[[ "$flat_script" == *"GREEN means"* ]] \
|
||||
|| { echo "nothing says what green means, so a green run gets hedged"; return 1; }
|
||||
}
|
||||
|
||||
@test "docs/release.md exists and is pointed at from where a release starts" {
|
||||
@@ -4567,43 +4811,103 @@ STUB
|
||||
grep -qE '^\s*- ALL\s*$' "$c" || { echo "cap_drop does not drop ALL"; return 1; }
|
||||
grep -qE '^\s*- no-new-privileges:true\s*$' "$c" \
|
||||
|| { echo "no-new-privileges is gone"; return 1; }
|
||||
# The two the firewall needs are still declared, or the container cannot
|
||||
# bring its own egress control up at all.
|
||||
local cap
|
||||
for cap in NET_ADMIN NET_RAW; do
|
||||
grep -qE "^\s*- $cap\s*$" "$c" || { echo "$cap is no longer added"; return 1; }
|
||||
done
|
||||
# SETPCAP is declared, and it is the one whose use is not a line of the
|
||||
# startup sequence: it is spent taking the rest of this list away again
|
||||
# before the session starts. Declared without that, it is a capability
|
||||
# nothing uses - so the two are asserted together, in both directions.
|
||||
grep -qE '^\s*- SETPCAP\s*$' "$c" \
|
||||
|| { echo "SETPCAP is gone, so the bounding set cannot be emptied"; return 1; }
|
||||
grep -q 'exec capsh --drop=all' "$SANDBOX_DIR/caps-lib.sh" \
|
||||
|| { echo "SETPCAP is declared but nothing drops with it"; return 1; }
|
||||
|
||||
# And nothing the entrypoint does not use has been added back.
|
||||
for cap in SYS_ADMIN MKNOD SETPCAP SETFCAP SYS_CHROOT SYS_PTRACE; do
|
||||
local cap
|
||||
for cap in SYS_ADMIN MKNOD SETFCAP SYS_CHROOT SYS_PTRACE; do
|
||||
run grep -nE "^\s*- $cap\s*$" "$c"
|
||||
[ "$status" -ne 0 ] || { echo "$cap is declared and nothing uses it"; return 1; }
|
||||
done
|
||||
}
|
||||
|
||||
@test "the runtime suite's session container reads the capability floor rather than copying it" {
|
||||
# Only the container that stands in for a real session. The suite also
|
||||
# starts ad-hoc containers to exercise the firewall in isolation, and those
|
||||
# legitimately pass NET_ADMIN/NET_RAW by hand - they are testing a
|
||||
# component, not the posture a session runs under.
|
||||
@test "the session container declares no network capability, and the filter declares them" {
|
||||
# The arrangement's whole point, asserted where each list is written.
|
||||
# Capabilities are per process, so a session container that declares
|
||||
# NET_ADMIN can alter the filter bounding it however carefully everything
|
||||
# else is arranged - and would do so silently, since the launch keeps
|
||||
# working either way.
|
||||
#
|
||||
# The one in setup_file is different: a hand-written list there would drift
|
||||
# from the compose file, and the container under test would stop being the
|
||||
# container a session gets.
|
||||
# KILL is NOT in this list, and that is the point of the exception: the
|
||||
# filter container needs it for dnsmasq, and the session container needs it
|
||||
# for tini, which forwards signals to a child whose uid it does not match.
|
||||
# Removing it from here once already shipped a session that died on a
|
||||
# terminal resize.
|
||||
#
|
||||
# Both directions for each, or "the session declares none of them" is
|
||||
# satisfied just as well by a launch with no filter container to hold them.
|
||||
local c="$SANDBOX_DIR/docker-compose.yml" cap
|
||||
for cap in NET_ADMIN NET_RAW; do
|
||||
run grep -nE "^\s*- $cap\s*$" "$c"
|
||||
[ "$status" -ne 0 ] \
|
||||
|| { echo "the session container declares $cap, so it can reach the filter"; return 1; }
|
||||
grep -q -- "--cap-add $cap" "$REPO_ROOT/bin/dejima" \
|
||||
|| { echo "$cap left the session container without arriving at the filter"; return 1; }
|
||||
done
|
||||
|
||||
# KILL is the exception, declared on both: tini needs it here to forward a
|
||||
# signal to the session, dnsmasq needs it there to be signalled.
|
||||
grep -qE '^\s*- KILL\s*$' "$c" \
|
||||
|| { echo "the session container cannot signal its own session - see tini"; return 1; }
|
||||
grep -q -- '--cap-add KILL' "$REPO_ROOT/bin/dejima" \
|
||||
|| { echo "the filter container cannot signal dnsmasq"; return 1; }
|
||||
|
||||
# The same split for the one sysctl: a property of a namespace the session
|
||||
# container does not own, so it is set by the container that does.
|
||||
run grep -nE '^\s*sysctls:' "$c"
|
||||
[ "$status" -ne 0 ] \
|
||||
|| { echo "the session container declares a sysctl for a namespace it does not own"; return 1; }
|
||||
grep -q -- '--sysctl net.ipv4.ip_unprivileged_port_start=0' "$REPO_ROOT/bin/dejima" \
|
||||
|| { echo "nothing sets the unprivileged port floor the TLS terminator needs"; return 1; }
|
||||
}
|
||||
|
||||
@test "the runtime suite's containers read both capability floors rather than copying them" {
|
||||
# start_pair builds the pair a launch actually starts, so it is where both
|
||||
# floors have to come from the code that declares each: the session's from
|
||||
# docker-compose.yml, the filter's from bin/dejima's own `docker run`. A
|
||||
# hand-written list in either place would drift, and the containers under
|
||||
# test would stop being the containers a launch gets.
|
||||
#
|
||||
# The suite still starts two containers by hand - one that must refuse to
|
||||
# start, one that must abort at a check before the network matters. Both
|
||||
# say so at the call site; neither stands in for a session.
|
||||
local rt="$REPO_ROOT/test/runtime.bats"
|
||||
grep -q '^compose_cap_add()' "$rt" \
|
||||
|| { echo "runtime.bats no longer derives the capability list"; return 1; }
|
||||
|| { echo "runtime.bats no longer derives the session capability list"; return 1; }
|
||||
grep -q '^launcher_filter_cap_add()' "$rt" \
|
||||
|| { echo "runtime.bats no longer derives the filter capability list"; return 1; }
|
||||
|
||||
local pair
|
||||
pair="$(sed -n '/^start_pair()/,/^}/p' "$rt")"
|
||||
[ -n "$pair" ] || { echo "no start_pair in runtime.bats"; return 1; }
|
||||
grep -q 'compose_cap_add' <<< "$pair" \
|
||||
|| { echo "the session container no longer derives its capabilities"; return 1; }
|
||||
grep -q 'launcher_filter_cap_add' <<< "$pair" \
|
||||
|| { echo "the filter container no longer derives its capabilities"; return 1; }
|
||||
|
||||
# Both halves, twice each: an empty set to start from, and no way back up.
|
||||
[ "$(grep -c -- '--cap-drop=ALL' <<< "$pair")" = 2 ] \
|
||||
|| { echo "a container in the pair does not start from an empty set"; return 1; }
|
||||
[ "$(grep -c -- '--security-opt=no-new-privileges:true' <<< "$pair")" = 2 ] \
|
||||
|| { echo "a container in the pair runs without no-new-privileges"; return 1; }
|
||||
|
||||
run grep -nE -- '--cap-add=(NET_ADMIN|NET_RAW|CHOWN|SETUID)' <<< "$pair"
|
||||
[ "$status" -ne 0 ] \
|
||||
|| { echo "a capability is hardcoded in the pair:"; echo "$output"; return 1; }
|
||||
|
||||
# And the fixture that stands in for a session goes through it, or the
|
||||
# derivation above is true of a helper nothing calls.
|
||||
local setup_block
|
||||
setup_block="$(sed -n '/^setup_file()/,/^}/p' "$rt")"
|
||||
[ -n "$setup_block" ] || { echo "no setup_file in runtime.bats"; return 1; }
|
||||
grep -q 'compose_cap_add' <<< "$setup_block" \
|
||||
|| { echo "the session container no longer derives its capabilities"; return 1; }
|
||||
grep -q -- '--cap-drop=ALL' <<< "$setup_block" \
|
||||
|| { echo "the session container does not start from an empty set"; return 1; }
|
||||
grep -q -- '--security-opt=no-new-privileges:true' <<< "$setup_block" \
|
||||
|| { echo "the session container does not run with no-new-privileges"; return 1; }
|
||||
run grep -nE -- '--cap-add=(NET_ADMIN|NET_RAW|CHOWN|SETUID)' <<< "$setup_block"
|
||||
[ "$status" -ne 0 ] \
|
||||
|| { echo "a capability is hardcoded for the session container:"; echo "$output"; return 1; }
|
||||
grep -q 'start_pair ""' <<< "$setup_block" \
|
||||
|| { echo "setup_file no longer starts the pair a launch starts"; return 1; }
|
||||
}
|
||||
|
||||
@test "dejima writes host state under one root, not a new base directory per file" {
|
||||
@@ -6290,12 +6594,19 @@ q
|
||||
grep -q 'echo "\$openssl_err" >&2' "$SANDBOX_DIR/entrypoint.sh"
|
||||
}
|
||||
|
||||
@test "compose passes the transport through and lets an unprivileged process bind :443" {
|
||||
@test "the transport is passed through, and an unprivileged process can bind :443" {
|
||||
grep -qE '^\s*SANDBOX_CREDENTIAL_TRANSPORT:\s*\$\{SANDBOX_CREDENTIAL_TRANSPORT:-\}\s*$' "$SANDBOX_DIR/docker-compose.yml"
|
||||
# Docker already defaults this to 0, but the terminator runs as the
|
||||
# unprivileged sandbox user: pinning it means a future default change
|
||||
# fails at launch instead of silently breaking the credential path.
|
||||
grep -qE '^\s*net\.ipv4\.ip_unprivileged_port_start:\s*0\s*$' "$SANDBOX_DIR/docker-compose.yml"
|
||||
#
|
||||
# Pinned on the filter container, because it is a property of a network
|
||||
# namespace and that is the container that owns one. The terminator binds
|
||||
# in the session container, which joins that namespace - so this is
|
||||
# asserted here as a declaration and in runtime.bats by reading the value
|
||||
# back from the container that has to have it.
|
||||
grep -q -- '--sysctl net.ipv4.ip_unprivileged_port_start=0' "$REPO_ROOT/bin/dejima" \
|
||||
|| { echo "nothing pins the unprivileged port floor"; return 1; }
|
||||
}
|
||||
|
||||
@test "compose does not pin api.anthropic.com to loopback unconditionally" {
|
||||
@@ -6515,6 +6826,13 @@ _provider_proxied_domain() {
|
||||
# of which forge is configured, not a coupling to one. Same
|
||||
# feature the "GitHub read-only" prose exception below names.
|
||||
matches="$(grep -vE 'github\.com' <<< "$matches" || true)"
|
||||
# `network_mode: none` is Docker's own value for "no network",
|
||||
# which collides with a shipped forge's name and with nothing
|
||||
# else about it. Scoped to the key AND the value, not to either
|
||||
# alone: excluding every `network_mode` line would hide a real
|
||||
# forge name written on one, and excluding the bare word would
|
||||
# hide it anywhere in the file.
|
||||
matches="$(grep -vE 'network_mode: .*none' <<< "$matches" || true)"
|
||||
;;
|
||||
*/hooks/sandbox-context.sh)
|
||||
# A citation of Anthropic's own devcontainer, not a forge.
|
||||
@@ -6993,7 +7311,8 @@ _provider_proxied_domain() {
|
||||
# shipping went, which is worth keeping.
|
||||
run bash -c "grep -vE '^[[:space:]]*#' '$SANDBOX_DIR/log-shipper.sh' | grep -nE 'projects|[.]jsonl|transcript'"
|
||||
[ "$status" -ne 0 ] || { echo "$output"; return 1; }
|
||||
grep -q 'neither session-writable' "$SANDBOX_DIR/entrypoint.sh"
|
||||
# And the claim that makes root safe, where the shipper is now started.
|
||||
grep -q 'neither session-writable' "$SANDBOX_DIR/firewall-up.sh"
|
||||
}
|
||||
|
||||
@test ".dejima.md says to cut what the code replaced, not just how it was found" {
|
||||
|
||||
Reference in New Issue
Block a user