Compare commits
126
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f0513208a1 | ||
|
|
a6e1de5f30 | ||
|
|
60d6736e37 | ||
|
|
b36e7c935f | ||
|
|
ebf3b1dfe1 | ||
|
|
84ed923f85 | ||
|
|
770e8778be | ||
|
|
ba3615ec4b | ||
|
|
83f9fcfe7f | ||
|
|
a687d1793b | ||
|
|
537ab56b04 | ||
|
|
a72ea3dcda | ||
|
|
006a2319e7 | ||
|
|
c9064053ab | ||
|
|
57317e14ec | ||
|
|
af6913ebbe | ||
|
|
e2a7dcd4dd | ||
|
|
a6585dd050 | ||
|
|
b142c31b22 | ||
|
|
c5b3c5d222 | ||
|
|
3d2e3d2699 | ||
|
|
2d8c86ea44 | ||
|
|
503addfc8b | ||
|
|
a279460318 | ||
|
|
76a5c5202e | ||
|
|
60a319b3ba | ||
|
|
c0bc131fd4 | ||
|
|
00a2df3b0c | ||
|
|
e7769bb3d6 | ||
|
|
e346ad3ae2 | ||
|
|
3f5dae88b5 | ||
|
|
5569e8d2fe | ||
|
|
e900c51e20 | ||
|
|
e91b5771f6 | ||
|
|
3b17604178 | ||
|
|
6511a0537c | ||
|
|
023efb3171 | ||
|
|
23462a3c71 | ||
|
|
2ddcf964c3 | ||
|
|
faeafa7f2d | ||
|
|
2cc6057c88 | ||
|
|
1d8725f089 | ||
|
|
aa40e0c455 | ||
|
|
d180581293 | ||
|
|
f61795381d | ||
|
|
805d9a2c35 | ||
|
|
5884930592 | ||
|
|
452d49394f | ||
|
|
1cda465898 | ||
|
|
cacb74fb28 | ||
|
|
4a9e789ba1 | ||
|
|
9b96d5781b | ||
|
|
bac3d45814 | ||
|
|
fa29e2ac00 | ||
|
|
b3f8252c73 | ||
|
|
720b91a569 | ||
|
|
3086bb884e | ||
|
|
02f5dd8b19 | ||
|
|
990ab94a40 | ||
|
|
940f632447 | ||
|
|
c8fd2dc605 | ||
|
|
ba2158e0e8 | ||
|
|
51c0a8bc65 | ||
|
|
d418de83df | ||
|
|
225abccece | ||
|
|
43081852ea | ||
|
|
4adfe9b36c | ||
|
|
12a222cace | ||
|
|
af0fce3326 | ||
|
|
2217d6d3c3 | ||
|
|
300097dcae | ||
|
|
30a2a5c288 | ||
|
|
ab67534887 | ||
|
|
bb4f06657f | ||
|
|
6d77293880 | ||
|
|
c7521c7b6d | ||
|
|
3474c96f95 | ||
|
|
4c81605df6 | ||
|
|
07d863d6dc | ||
|
|
2d3a8ef32a | ||
|
|
f0cf3b98f7 | ||
|
|
513187cef3 | ||
|
|
4caf9d8d76 | ||
|
|
bad60b2cbe | ||
|
|
b906f33ab5 | ||
|
|
2f921a0d6a | ||
|
|
79e18b916a | ||
|
|
ba2fb5b434 | ||
|
|
03b96b2da8 | ||
|
|
f8726b2ea9 | ||
|
|
1d91d07d5c | ||
|
|
172988a67c | ||
|
|
a7d7ec9dda | ||
|
|
6d6e40d243 | ||
|
|
eb5b34f3be | ||
|
|
9329c722cd | ||
|
|
978480c73d | ||
|
|
4f0fbff39c | ||
|
|
34ae5c4044 | ||
|
|
c400ff3ad0 | ||
|
|
57c76aa573 | ||
|
|
d188eb20be | ||
|
|
88779bd656 | ||
|
|
7535f4795b | ||
|
|
bd01875c7f | ||
|
|
d8263120e3 | ||
|
|
c302aaae96 | ||
|
|
a899d60d59 | ||
|
|
4425d83d88 | ||
|
|
c73fb4702b | ||
|
|
896773bfc5 | ||
|
|
8b579fdd59 | ||
|
|
f44fe47657 | ||
|
|
378ad85d8e | ||
|
|
6522ad56e0 | ||
|
|
b91b8f5fb3 | ||
|
|
e9e1c2bc52 | ||
|
|
cb793edead | ||
|
|
832c4a4f6d | ||
|
|
0daae02833 | ||
|
|
a88393f769 | ||
|
|
b8c34bb98c | ||
|
|
df7a74481c | ||
|
|
337b2bce04 | ||
|
|
d8845206c4 | ||
|
|
c0935a52c3 |
+70
-38
@@ -95,14 +95,6 @@ jobs:
|
||||
# needs to run at all, and apt-get is what everything below needs.
|
||||
image: node:20-bookworm
|
||||
steps:
|
||||
# Before apt, because apt's failure for this says "Unable to locate
|
||||
# package bats" and nothing about why. On a runner behind the egress
|
||||
# allowlist a name that does not resolve is the resolver or the
|
||||
# allowlist, not the package - and ci-runner.bats, which would say so
|
||||
# precisely, cannot run yet because installing it is what just failed.
|
||||
#
|
||||
# Uses only what the image already has: getent is libc, and the
|
||||
# resolver's address is in resolv.conf.
|
||||
# Before apt, because apt's failure for this says "Unable to locate
|
||||
# package bats" and nothing about why. On a runner behind the egress
|
||||
# allowlist a name that does not resolve is the resolver or the
|
||||
@@ -114,7 +106,8 @@ jobs:
|
||||
# job on a single lookup would be stricter than the step it is
|
||||
# protecting. Six attempts over ~15s, then a diagnosis.
|
||||
#
|
||||
# Uses only what the image already has: getent is libc.
|
||||
# Uses only what the image already has: getent is libc, and the
|
||||
# resolver's address is in resolv.conf.
|
||||
- name: Check the job network can resolve before installing anything
|
||||
run: |
|
||||
for attempt in 1 2 3 4 5 6; do
|
||||
@@ -147,7 +140,11 @@ jobs:
|
||||
- name: Install test dependencies
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends bats jq python3 tmux ca-certificates
|
||||
# iproute2 for `ss`: the one test asserting dejima-ttyd refuses to
|
||||
# start when something already holds its port skips without it, and a
|
||||
# permanent skip on the check guarding a reverse-proxy port takeover
|
||||
# is the same as not having it.
|
||||
apt-get install -y --no-install-recommends bats jq python3 tmux ca-certificates iproute2
|
||||
# The code under test, named explicitly - see the header. Without `ref`
|
||||
# this checks out the base branch and the suite passes without ever
|
||||
# seeing the change.
|
||||
@@ -174,7 +171,11 @@ jobs:
|
||||
# otherwise refuses --jobs outright when GNU parallel is absent.
|
||||
- run: |
|
||||
chown -R node:node .
|
||||
su node -c "TERM=xterm-256color bats --jobs $(nproc) --no-parallelize-across-files test/${{ matrix.suite }}.bats"
|
||||
# DEJIMA_TEST_CONCURRENCY is what this job asks the machine for, which
|
||||
# the fixtures that wait on a bounded deadline multiply their bounds
|
||||
# by - see test/fixtures/loadscale.py. One suite per job here, so it
|
||||
# is the --jobs value and nothing more.
|
||||
su node -c "TERM=xterm-256color DEJIMA_TEST_CONCURRENCY=$(nproc) bats --jobs $(nproc) --no-parallelize-across-files test/${{ matrix.suite }}.bats"
|
||||
|
||||
shellcheck:
|
||||
# Its own job rather than a matrix entry: it installs a different package,
|
||||
@@ -251,14 +252,6 @@ jobs:
|
||||
# nothing to do with what the test asserts. That test is the containment
|
||||
# argument for the ttyd front door, so it is not one to skip in CI.
|
||||
steps:
|
||||
# Before apt, because apt's failure for this says "Unable to locate
|
||||
# package bats" and nothing about why. On a runner behind the egress
|
||||
# allowlist a name that does not resolve is the resolver or the
|
||||
# allowlist, not the package - and ci-runner.bats, which would say so
|
||||
# precisely, cannot run yet because installing it is what just failed.
|
||||
#
|
||||
# Uses only what the image already has: getent is libc, and the
|
||||
# resolver's address is in resolv.conf.
|
||||
# Before apt, because apt's failure for this says "Unable to locate
|
||||
# package bats" and nothing about why. On a runner behind the egress
|
||||
# allowlist a name that does not resolve is the resolver or the
|
||||
@@ -270,7 +263,8 @@ jobs:
|
||||
# job on a single lookup would be stricter than the step it is
|
||||
# protecting. Six attempts over ~15s, then a diagnosis.
|
||||
#
|
||||
# Uses only what the image already has: getent is libc.
|
||||
# Uses only what the image already has: getent is libc, and the
|
||||
# resolver's address is in resolv.conf.
|
||||
- name: Check the job network can resolve before installing anything
|
||||
run: |
|
||||
for attempt in 1 2 3 4 5 6; do
|
||||
@@ -442,6 +436,58 @@ jobs:
|
||||
fi
|
||||
echo "GitHub ranges lookup is authenticated - ceiling is $limit requests/hour."
|
||||
|
||||
# One fetch for the whole run, into the directory the job already shares
|
||||
# with the containers it starts. test/runtime.bats starts twenty-seven
|
||||
# container pairs and each filter fetched the ranges for itself, which is
|
||||
# roughly twenty-seven requests in five minutes - comfortably inside the
|
||||
# 5000/hour a token allows, and exactly the burst GitHub's SECONDARY limit
|
||||
# answers with a 403 (#357). One request cannot be a burst.
|
||||
#
|
||||
# Best-effort: a failure here leaves no file, and runtime.bats falls back
|
||||
# to each container fetching for itself. That is the behaviour this
|
||||
# replaces, so the worst case is the status quo rather than a red job -
|
||||
# and the step says which of the two the run is getting.
|
||||
- name: Fetch the GitHub ranges once for the whole run
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GH_META_TOKEN }}
|
||||
run: |
|
||||
# POSIX sh, like every other run: block here - the runner's default
|
||||
# shell is `sh -e`, where an array is a syntax error and the step dies
|
||||
# before any of this runs. Two curl branches rather than one with a
|
||||
# built-up argument list, for the same reason.
|
||||
mkdir -p /tmp/dejima-ci
|
||||
out=/tmp/dejima-ci/github-meta.json
|
||||
rm -f "$out" "$out.part"
|
||||
fetched=0
|
||||
if [ -n "${GH_TOKEN:-}" ]; then
|
||||
if curl -fsS --max-time 30 -H "Authorization: Bearer $GH_TOKEN" \
|
||||
https://api.github.com/meta -o "$out.part" 2>/dev/null; then
|
||||
fetched=1
|
||||
fi
|
||||
else
|
||||
if curl -fsS --max-time 30 \
|
||||
https://api.github.com/meta -o "$out.part" 2>/dev/null; then
|
||||
fetched=1
|
||||
fi
|
||||
fi
|
||||
# Written only once it parses AND carries ranges, because a rate-limit
|
||||
# body is a 200 with a message and no ranges - and a cached one would
|
||||
# then fail every container in the run instead of one.
|
||||
ranges=""
|
||||
if [ "$fetched" = 1 ]; then
|
||||
ranges="$(jq -r '[.git[]?, .api[]?, .web[]?] | unique | .[]' < "$out.part" 2>/dev/null || true)"
|
||||
fi
|
||||
if [ -n "$ranges" ]; then
|
||||
mv "$out.part" "$out"
|
||||
echo "Cached GitHub ranges for this run: $(printf '%s\n' "$ranges" | wc -l) CIDRs."
|
||||
else
|
||||
rm -f "$out.part"
|
||||
echo "Could not cache the ranges, so each container will fetch its own."
|
||||
echo "That is the pre-#357 behaviour, not a new failure - but it is what"
|
||||
echo "trips the secondary limit, so a 403 in a container boot below is"
|
||||
echo "explained by this line."
|
||||
fi
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
@@ -536,14 +582,6 @@ jobs:
|
||||
container:
|
||||
image: node:20-bookworm
|
||||
steps:
|
||||
# Before apt, because apt's failure for this says "Unable to locate
|
||||
# package bats" and nothing about why. On a runner behind the egress
|
||||
# allowlist a name that does not resolve is the resolver or the
|
||||
# allowlist, not the package - and ci-runner.bats, which would say so
|
||||
# precisely, cannot run yet because installing it is what just failed.
|
||||
#
|
||||
# Uses only what the image already has: getent is libc, and the
|
||||
# resolver's address is in resolv.conf.
|
||||
# Before apt, because apt's failure for this says "Unable to locate
|
||||
# package bats" and nothing about why. On a runner behind the egress
|
||||
# allowlist a name that does not resolve is the resolver or the
|
||||
@@ -555,7 +593,8 @@ jobs:
|
||||
# job on a single lookup would be stricter than the step it is
|
||||
# protecting. Six attempts over ~15s, then a diagnosis.
|
||||
#
|
||||
# Uses only what the image already has: getent is libc.
|
||||
# Uses only what the image already has: getent is libc, and the
|
||||
# resolver's address is in resolv.conf.
|
||||
- name: Check the job network can resolve before installing anything
|
||||
run: |
|
||||
for attempt in 1 2 3 4 5 6; do
|
||||
@@ -607,14 +646,6 @@ jobs:
|
||||
container:
|
||||
image: node:20-bookworm
|
||||
steps:
|
||||
# Before apt, because apt's failure for this says "Unable to locate
|
||||
# package bats" and nothing about why. On a runner behind the egress
|
||||
# allowlist a name that does not resolve is the resolver or the
|
||||
# allowlist, not the package - and ci-runner.bats, which would say so
|
||||
# precisely, cannot run yet because installing it is what just failed.
|
||||
#
|
||||
# Uses only what the image already has: getent is libc, and the
|
||||
# resolver's address is in resolv.conf.
|
||||
# Before apt, because apt's failure for this says "Unable to locate
|
||||
# package bats" and nothing about why. On a runner behind the egress
|
||||
# allowlist a name that does not resolve is the resolver or the
|
||||
@@ -626,7 +657,8 @@ jobs:
|
||||
# job on a single lookup would be stricter than the step it is
|
||||
# protecting. Six attempts over ~15s, then a diagnosis.
|
||||
#
|
||||
# Uses only what the image already has: getent is libc.
|
||||
# Uses only what the image already has: getent is libc, and the
|
||||
# resolver's address is in resolv.conf.
|
||||
- name: Check the job network can resolve before installing anything
|
||||
run: |
|
||||
for attempt in 1 2 3 4 5 6; do
|
||||
|
||||
@@ -160,8 +160,8 @@ layers below the container rather than into a harder one.
|
||||
This repo only holds the tooling; no project lives here. Point it at a project
|
||||
on disk and that project is bind-mounted into the container at runtime.
|
||||
|
||||
**Prerequisites:** Docker with the `docker compose` v2 plugin, and `python3`
|
||||
(stdlib only, no packages) for the host-side credential proxy.
|
||||
**Prerequisites:** Docker with the `docker compose` v2 plugin, `jq`, and
|
||||
`python3` (stdlib only, no packages) for the host-side credential proxy.
|
||||
|
||||
Only the first block below is required. The rest are independent, so take the
|
||||
ones you want.
|
||||
@@ -184,7 +184,9 @@ ones you want.
|
||||
config.
|
||||
3. **Put `~/.local/bin` on your `PATH`** if it isn't already.
|
||||
4. **Set `[core] harness`.** It has no default and a launch is refused without
|
||||
it, which is what stops a later release changing your selection for you.
|
||||
it, which is what stops a later release changing your selection for you. The
|
||||
starter config states `claude-code`, because a file with no default to fall
|
||||
back on has to name a choice; change that line for Cline.
|
||||
|
||||
That is enough to run `dejima`, and no push credential is needed to get this
|
||||
far. Everything else runs at its default, which means `[core] forge = none` and
|
||||
@@ -293,9 +295,10 @@ dejima --ro ~/other-project ~/myproject
|
||||
**→ [docs/configuration.md](docs/configuration.md#one-off-mounts-for-a-single-launch)** -
|
||||
both forms, and which host paths a mount is refused over and why.
|
||||
|
||||
**Ctrl-Z backgrounds all of dejima**, and `fg` brings back a working session.
|
||||
It makes no progress while suspended, though, so for one that should keep
|
||||
working while you are away, run dejima inside tmux and use
|
||||
**Ctrl-Z backgrounds dejima's host side**, and `fg` brings back a working
|
||||
session. It gets your terminal back rather than pausing the agent - the
|
||||
container keeps running - so for a session you want to leave working and pick
|
||||
up elsewhere, run dejima inside tmux and use
|
||||
[`dejima-attach`](docs/remote-access.md).
|
||||
→ [why that takes doing](docs/configuration.md#suspending-a-session)
|
||||
|
||||
@@ -503,7 +506,7 @@ at a fixed host path.
|
||||
|
||||
`.dejima.md` at a project's root is that project's agent-instruction file, the
|
||||
harness-agnostic equivalent of `CLAUDE.md`, loaded into the session's context
|
||||
with no authority over the sandbox itself. A `SessionStart` hook also briefs the
|
||||
with no authority over the sandbox itself. dejima also briefs the
|
||||
session on where it is running and on the instructions belonging to whichever
|
||||
workflow you selected, so a session under `local-only` is told something
|
||||
different from one under `worktree-pr`.
|
||||
@@ -562,9 +565,8 @@ After the container exits it diffs them and tells you what changed.
|
||||
|
||||
It is **advisory, not a block**, firing after the session has ended, so a
|
||||
finding is something to clean up before your next `git` command or session in
|
||||
that repo. Its diff is time-windowed: a finding says a path changed while the
|
||||
session was running, not that the session changed it. Findings are a time
|
||||
window, not a verdict on who caused them.
|
||||
that repo. Its findings are a time window, not a verdict on who caused them:
|
||||
one says a path changed while the session was running.
|
||||
|
||||
`dejima-audit show` indexes what is waiting and `dejima-audit ack` walks it, one
|
||||
finding at a time. `[audit] strict` refuses a headless launch while CRITICAL
|
||||
|
||||
+483
-50
@@ -50,12 +50,12 @@ Options (each overrides the config file for one launch only):
|
||||
--no-epitaph keep nothing if this session compacts
|
||||
--no-firewall disable the egress firewall entirely (debugging only)
|
||||
--no-pause do not wait for a keypress before the image build
|
||||
--isolated no project, no memory, no forge; keeps only ~/shared
|
||||
--print-config print every key as it resolves, and exit
|
||||
--print-briefing print the briefing the agent will get, and exit
|
||||
-h, --help this message
|
||||
|
||||
Every --no-* flag only ever narrows. Nothing here turns a setting back on: a
|
||||
config that says off is a decision, not a default to override in passing.
|
||||
Every --no-* flag only ever narrows; a config that says off is a decision.
|
||||
|
||||
Config lives in ~/.config/dejima/config; [core] harness must be set there.
|
||||
DEJIMA_HELP
|
||||
@@ -147,6 +147,19 @@ _set_override() {
|
||||
done
|
||||
FLAG_OVERRIDES=(${kept[@]+"${kept[@]}"} "$key=$value")
|
||||
}
|
||||
|
||||
# Whether a flag has already set $1, for the defaults --isolated applies after
|
||||
# the loop. Flag order on the command line is arbitrary, so this is asked
|
||||
# rather than inferred from when a flag ran: `--isolated --forge <name>` and
|
||||
# `--forge <name> --isolated` have to mean the same thing.
|
||||
_override_set() {
|
||||
local key="$1" entry
|
||||
for entry in ${FLAG_OVERRIDES[@]+"${FLAG_OVERRIDES[@]}"}; do
|
||||
[ "${entry%%=*}" = "$key" ] || continue
|
||||
return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
_need_value() {
|
||||
[ -n "${2:-}" ] && [ "${2:0:2}" != "--" ] || {
|
||||
echo "$1 requires a value" >&2
|
||||
@@ -236,9 +249,24 @@ NO_LOG=0
|
||||
# Launch-scoped, like NO_LOG: initialised here so an inherited environment
|
||||
# variable of the same name cannot decide it.
|
||||
NO_PAUSE=0
|
||||
# A session with nothing before it and nothing after it: no project mounted,
|
||||
# no memory, no transcript, and a ~/shared of its own that no other session
|
||||
# has written to or will read. What survives it is that directory and the
|
||||
# branches it pushed.
|
||||
#
|
||||
# A flag and no config key, deliberately. Every other narrowing here has a
|
||||
# [section] key behind it because a machine can sensibly prefer it always;
|
||||
# this one changes what the session IS rather than how much it may do, and a
|
||||
# file that silently decided it would make the next launch's answer to "where
|
||||
# is my work" depend on a line nobody reread.
|
||||
DEJIMA_ISOLATED=0
|
||||
# Whether the operator wrote `--`, so an isolated launch can tell a project
|
||||
# path it must refuse from harness arguments it must pass through. Both arrive
|
||||
# here as positionals once the loop breaks.
|
||||
DEJIMA_ARGS_ONLY=0
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--) shift; break ;;
|
||||
--) DEJIMA_ARGS_ONLY=1; shift; break ;;
|
||||
--print-config) PRINT_CONFIG=1; shift ;;
|
||||
--print-briefing) PRINT_BRIEFING=1; shift ;;
|
||||
--no-firewall) _set_override core.firewall off; shift ;;
|
||||
@@ -255,6 +283,7 @@ while [ $# -gt 0 ]; do
|
||||
# reach its prompt, and does not want that recorded as a preference
|
||||
# for the launch they run by hand tomorrow.
|
||||
--no-pause) NO_PAUSE=1; shift ;;
|
||||
--isolated) DEJIMA_ISOLATED=1; shift ;;
|
||||
--no-log) _set_override logging.loki_url ""
|
||||
_set_override logging.conversation off
|
||||
NO_LOG=1; shift ;;
|
||||
@@ -287,6 +316,60 @@ while [ $# -gt 0 ]; do
|
||||
esac
|
||||
done
|
||||
|
||||
# An isolated launch reaches no forge and is not told to push, unless this
|
||||
# command says otherwise.
|
||||
#
|
||||
# The flag narrows, like every other one here, and the forge credential is the
|
||||
# widest thing a session gets: a forge token is commonly account-wide rather
|
||||
# than per repository, so a session holding one can read and write everything
|
||||
# the operator can. Carrying that by default into the mode whose purpose is a
|
||||
# session you are not watching is the wrong way round - the host is narrowed
|
||||
# to nothing and the forge is left at full reach.
|
||||
#
|
||||
# Applied after the loop rather than in it, so `--isolated --forge <name>` and
|
||||
# `--forge <name> --isolated` agree: naming either on the same command line is
|
||||
# how an operator asks for a session that clones and opens a pull request,
|
||||
# which is a real use of this mode and not something to make impossible.
|
||||
#
|
||||
# A configured ssh key is untouched, because an isolated launch is not where
|
||||
# that requirement lives - so a session with no forge still holds a key that
|
||||
# can reach a host over ssh (#392). The forwarded agent is not in the same
|
||||
# position: the workflow this sets pushes nothing, and a workflow that never
|
||||
# pushes already gets no agent socket.
|
||||
#
|
||||
# Whether the operator named a forge, asked before the default below answers
|
||||
# for them: it decides the GitHub credential further down as well, and after
|
||||
# this block "is there an override" is true either way.
|
||||
DEJIMA_FORGE_NAMED=0
|
||||
if [ "$DEJIMA_ISOLATED" = 1 ]; then
|
||||
if _override_set core.forge; then
|
||||
DEJIMA_FORGE_NAMED=1
|
||||
else
|
||||
_set_override core.forge none
|
||||
fi
|
||||
_override_set core.workflow || _set_override core.workflow local-only
|
||||
fi
|
||||
|
||||
# Refused rather than dropped. A configured mount is a standing preference and
|
||||
# an isolated launch simply does not apply it, with a line saying so; --ro and
|
||||
# --rw are the operator typing a path in this command, and the only two honest
|
||||
# answers to "mount this, in a session that mounts nothing" are to refuse or
|
||||
# to contradict them.
|
||||
if [ "$DEJIMA_ISOLATED" = 1 ] && [ -n "$FLAG_MOUNTS" ]; then
|
||||
echo "--isolated mounts nothing of the host, so it cannot take --ro or --rw:" >&2
|
||||
# One per line, read rather than word-split: a path may contain a space,
|
||||
# and the list is colon-separated precisely because it may not contain one
|
||||
# of those.
|
||||
while IFS= read -r _m; do
|
||||
[ -n "$_m" ] || continue
|
||||
printf ' %s\n' "$_m" >&2
|
||||
done < <(printf '%s\n' "$FLAG_MOUNTS" | tr ':' '\n')
|
||||
echo "" >&2
|
||||
echo "Hand the session what it needs through ~/shared, which is the one" >&2
|
||||
echo "directory an isolated session shares with you." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Wraps config_get so a flag wins over the file for the rest of this run.
|
||||
# Defined here rather than in the library because an override is a property
|
||||
# of one invocation of this launcher, not of the config file.
|
||||
@@ -562,18 +645,35 @@ export DEJIMA_GITHUB_PUSH
|
||||
# AUDIT_DIR, BASELINE_DIR and SANDBOX_EXTRA_MOUNTS are pure config reads (or,
|
||||
# for WORKSPACE_DIR, a default-to-pwd with an existence check) - no side
|
||||
# effects a print-and-exit needs to avoid.
|
||||
WORKSPACE_DIR="${1:-$(pwd)}"
|
||||
if [ ! -d "$WORKSPACE_DIR" ]; then
|
||||
echo "Not a directory: $WORKSPACE_DIR" >&2
|
||||
exit 1
|
||||
fi
|
||||
shift || true
|
||||
if [ "$DEJIMA_ISOLATED" = 1 ]; then
|
||||
# Refused rather than ignored. A path here would be read as the project
|
||||
# this session is for, and nothing afterwards would contradict that - the
|
||||
# directory simply would not be there. The operator may be anywhere;
|
||||
# saying so is the point of the flag.
|
||||
if [ "$DEJIMA_ARGS_ONLY" = 0 ] && [ $# -gt 0 ]; then
|
||||
echo "--isolated takes no project directory, and would not mount $1." >&2
|
||||
echo "Run it from anywhere: the working directory is the container's own." >&2
|
||||
exit 1
|
||||
fi
|
||||
# Container-local, and outside $HOME on purpose - a handful of host-side
|
||||
# reads below take this path (an origin URL for the forge sentence, the
|
||||
# self-hosting check), and a path an operator might plausibly have would
|
||||
# have them read a real directory of theirs. Created in the image.
|
||||
WORKSPACE_DIR=/dejima/work
|
||||
else
|
||||
WORKSPACE_DIR="${1:-$(pwd)}"
|
||||
if [ ! -d "$WORKSPACE_DIR" ]; then
|
||||
echo "Not a directory: $WORKSPACE_DIR" >&2
|
||||
exit 1
|
||||
fi
|
||||
shift || true
|
||||
|
||||
if [ "${1:-}" = "--" ]; then
|
||||
shift
|
||||
fi
|
||||
if [ "${1:-}" = "--" ]; then
|
||||
shift
|
||||
fi
|
||||
|
||||
WORKSPACE_DIR="$(cd "$WORKSPACE_DIR" && pwd)"
|
||||
WORKSPACE_DIR="$(cd "$WORKSPACE_DIR" && pwd)"
|
||||
fi
|
||||
|
||||
# Resolved here, with the workspace, rather than beside the other approval
|
||||
# reads further down: --print-briefing composes and exits well before those,
|
||||
@@ -581,7 +681,29 @@ WORKSPACE_DIR="$(cd "$WORKSPACE_DIR" && pwd)"
|
||||
# they decide. That is not hypothetical - the same ordering already caught
|
||||
# DEJIMA_RW_MOUNTS, whose sentence went missing from --print-briefing while
|
||||
# real sessions were told correctly.
|
||||
PROJECT_STORE="$(resolve_project_store "$WORKSPACE_DIR")"
|
||||
#
|
||||
# Left empty for an isolated launch, and NOT resolved first and discarded:
|
||||
# resolve_project_store walks upwards from the path it is given, so a launch
|
||||
# from $HOME on a host whose dotfiles were once approved as a project would
|
||||
# find that project's store - and with it the domains it may reach and the
|
||||
# dependencies baked into its image. A directory the operator happens to be
|
||||
# standing in decides nothing here, which is most of what the flag means.
|
||||
#
|
||||
# Every project-scoped read below is already guarded on this being non-empty
|
||||
# - the corpus paragraph, the approved tools, the allowed domains, the
|
||||
# dependency layer - so one empty assignment covers all four.
|
||||
#
|
||||
# It is not the only thing covering them, and that is deliberate rather than
|
||||
# redundant. The working directory above is container-local, so even a
|
||||
# resolver call here would be asking about /dejima/work rather than about
|
||||
# wherever the operator was standing. Either of the two closes the leak on
|
||||
# its own, and they answer different questions: that one is about where the
|
||||
# session runs, this one about what it is entitled to. A test that reverses
|
||||
# both is in project.bats, because reversing either alone proves nothing.
|
||||
PROJECT_STORE=""
|
||||
if [ "$DEJIMA_ISOLATED" = 0 ]; then
|
||||
PROJECT_STORE="$(resolve_project_store "$WORKSPACE_DIR")"
|
||||
fi
|
||||
|
||||
# Whether this project has been approved to keep its history as evidence, and
|
||||
# so gets the commit-granularity paragraph in its briefing.
|
||||
@@ -645,11 +767,36 @@ unset _tools_wanted _tool
|
||||
|
||||
AUDIT_DIR="$(config_get audit dir)"
|
||||
BASELINE_DIR="$(config_get audit baselines_dir)"
|
||||
|
||||
# A [mounts] list as this launch should see it: every entry is a host path
|
||||
# that outlives the session, so an isolated launch gets none of them.
|
||||
#
|
||||
# Dropped rather than refused, unlike --ro and --rw. These are a standing
|
||||
# preference about ordinary work, written once and inherited by every launch;
|
||||
# refusing them would mean an operator with a [mounts] readonly line could
|
||||
# never use this flag at all. The notice below is what keeps that from being
|
||||
# silent, because a directory that is simply absent inside the container is
|
||||
# the least informative way to find out.
|
||||
_mounts_list() {
|
||||
[ "$DEJIMA_ISOLATED" = 0 ] || return 0
|
||||
config_get mounts "$1"
|
||||
}
|
||||
if [ "$DEJIMA_ISOLATED" = 1 ]; then
|
||||
_dropped=""
|
||||
for _key in readonly read_write read_write_per_project; do
|
||||
[ -n "$(config_get mounts "$_key")" ] || continue
|
||||
_dropped="${_dropped:+$_dropped, }[mounts] $_key"
|
||||
done
|
||||
[ -z "$_dropped" ] \
|
||||
|| echo "Isolated: $_dropped not applied - nothing of the host is mounted." >&2
|
||||
unset _dropped _key
|
||||
fi
|
||||
|
||||
# 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_RW_PP_MOUNTS="$(_mounts_list read_write_per_project)"
|
||||
|
||||
SANDBOX_EXTRA_MOUNTS="$(config_get mounts readonly)"
|
||||
SANDBOX_EXTRA_MOUNTS="$(_mounts_list readonly)"
|
||||
SANDBOX_EXTRA_MOUNTS="$(_append_mounts "$SANDBOX_EXTRA_MOUNTS" \
|
||||
${EXTRA_RO_FLAGS[@]+"${EXTRA_RO_FLAGS[@]}"})"
|
||||
|
||||
@@ -778,7 +925,8 @@ _guarded_stores() {
|
||||
[ ${#GUARDED_STORES[@]} -eq 0 ] || return 0
|
||||
GUARDED_STORES=("$(config_root)" "$AUDIT_DIR" "$BASELINE_DIR" \
|
||||
"$(config_get epitaph dir)" \
|
||||
"$(share_projects_root "$(config_get core share_dir)")")
|
||||
"$(share_projects_root "$(config_get core share_dir)")" \
|
||||
"$(isolated_share_root "$(config_get core share_dir)")")
|
||||
}
|
||||
|
||||
# The `projects` subdirectory of every read_write_per_project root: the
|
||||
@@ -819,12 +967,12 @@ _refuse_guarded_mount() {
|
||||
echo "Refusing $kind: $path" >&2
|
||||
echo "It overlaps $guarded_real, which dejima needs out of a session's reach." >&2
|
||||
echo "Even read-only, that hands the session every project's approvals, the" >&2
|
||||
echo "record of what earlier sessions were caught doing, or every project's" >&2
|
||||
echo "handoff files." >&2
|
||||
echo "record of what earlier sessions were caught doing, or the handoff files" >&2
|
||||
echo "of every project and every isolated session." >&2
|
||||
exit 1
|
||||
done
|
||||
}
|
||||
SANDBOX_RW_MOUNTS="$(config_get mounts read_write)"
|
||||
SANDBOX_RW_MOUNTS="$(_mounts_list read_write)"
|
||||
SANDBOX_RW_MOUNTS="$(_append_mounts "$SANDBOX_RW_MOUNTS" \
|
||||
${EXTRA_RW_FLAGS[@]+"${EXTRA_RW_FLAGS[@]}"})"
|
||||
RW_MOUNT_PATHS=()
|
||||
@@ -1156,6 +1304,14 @@ if [ -n "$SANDBOX_SSH_KEY" ] && [ ! -f "$SANDBOX_SSH_KEY" ]; then
|
||||
echo "[core] ssh_key is set but not a file: $SANDBOX_SSH_KEY" >&2
|
||||
exit 1
|
||||
fi
|
||||
# An empty file is not a key: it passes the check above and loads nothing in the
|
||||
# container. Treated as no key at all rather than refused, because the only
|
||||
# launch it costs anything is one that pushes - and that case is Rule 4's
|
||||
# below, which already says what to do about having neither.
|
||||
have_usable_key=1
|
||||
if [ -z "$SANDBOX_SSH_KEY" ] || [ ! -s "$SANDBOX_SSH_KEY" ]; then
|
||||
have_usable_key=0
|
||||
fi
|
||||
if [ -n "$SANDBOX_SSH_KEY" ]; then
|
||||
key_perms=$(stat -c '%a' "$SANDBOX_SSH_KEY")
|
||||
if [ "${key_perms: -2}" != "00" ]; then
|
||||
@@ -1185,7 +1341,10 @@ fi
|
||||
# Rule 4: a workflow that never pushes has no use for either - skip the
|
||||
# requirement entirely rather than force an operator doing local-only work
|
||||
# to set up an agent or a deploy key for nothing.
|
||||
if [ "$DEJIMA_WORKFLOW_PUSH" = "on" ] && [ "$have_forwarded_agent" = 0 ] && [ -z "$SANDBOX_SSH_KEY" ]; then
|
||||
if [ "$DEJIMA_WORKFLOW_PUSH" = "on" ] && [ "$have_forwarded_agent" = 0 ] && [ "$have_usable_key" = 0 ]; then
|
||||
if [ -n "$SANDBOX_SSH_KEY" ]; then
|
||||
echo "[core] ssh_key names an empty file, so there is no key to load: $SANDBOX_SSH_KEY" >&2
|
||||
fi
|
||||
echo "SSH_AUTH_SOCK is not set or not a valid socket, and [core] ssh_key is not set." >&2
|
||||
echo "Either start ssh-agent and run ssh-add, or set [core] ssh_key in $(config_path) to a" >&2
|
||||
echo "dedicated key for headless use - see docs/configuration.md." >&2
|
||||
@@ -1237,16 +1396,33 @@ fi
|
||||
# boundary ~/.claude is already sliced per project to hold. What the container
|
||||
# sees is still ~/shared, so nothing an agent has been told changes; what it
|
||||
# cannot see any more is another project's.
|
||||
#
|
||||
# An isolated launch gets one of its own instead, under a sibling root. Still
|
||||
# a real host directory, because handing a file back to the operator is the
|
||||
# one thing that mode keeps - but never the directory any other session had,
|
||||
# so it carries nothing in and nothing it is given reaches a later session.
|
||||
# The operator is told the path below, since there is no project name to find
|
||||
# it under.
|
||||
SANDBOX_SHARE_ROOT="$(config_get core share_dir)"
|
||||
SANDBOX_SHARE_DIR="$(share_dir_for "$SANDBOX_SHARE_ROOT" "$WORKSPACE_DIR")"
|
||||
if [ "$DEJIMA_ISOLATED" = 1 ]; then
|
||||
SANDBOX_SHARE_DIR="$(isolated_share_make "$SANDBOX_SHARE_ROOT")" || {
|
||||
echo "Could not make an isolated share directory under $SANDBOX_SHARE_ROOT" >&2
|
||||
exit 1
|
||||
}
|
||||
else
|
||||
SANDBOX_SHARE_DIR="$(share_dir_for "$SANDBOX_SHARE_ROOT" "$WORKSPACE_DIR")"
|
||||
fi
|
||||
mkdir -p "$SANDBOX_SHARE_DIR"
|
||||
|
||||
# Files an operator left in share_dir before the split are still there and are
|
||||
# no longer mounted anywhere. Said every launch while that is true, because
|
||||
# the alternative is a file that was handed to a session yesterday silently
|
||||
# not arriving today.
|
||||
# Both roots dejima writes are excluded, not just the per-project one: the
|
||||
# question this answers is "did something other than dejima put this here",
|
||||
# and an isolated session's share is dejima's.
|
||||
share_loose="$(find "$SANDBOX_SHARE_ROOT" -mindepth 1 -maxdepth 1 \
|
||||
! -name projects -printf '%f\n' 2>/dev/null | sort)" || true
|
||||
! -name projects ! -name isolated -printf '%f\n' 2>/dev/null | sort)" || true
|
||||
if [ -n "$share_loose" ]; then
|
||||
echo "Note: $SANDBOX_SHARE_ROOT holds entries no session can see any more," >&2
|
||||
echo " because ~/shared is now this project's own subdirectory of it:" >&2
|
||||
@@ -1275,8 +1451,14 @@ unset share_loose _entry
|
||||
# Which directories those are is the harness's (harness_config_dirs); that
|
||||
# they must exist before the mount is set up is dejima's. Already loaded, up
|
||||
# where the read_write refusal needed them.
|
||||
#
|
||||
# Only the ones that will be mounted, which for an isolated launch is the
|
||||
# read-only half - see the mount loop further down, which filters the same
|
||||
# way. Creating the others would have an isolated launch leave four empty
|
||||
# directories on a host it is not supposed to write to at all.
|
||||
for _rec in ${DEJIMA_CONFIG_DIRS[@]+"${DEJIMA_CONFIG_DIRS[@]}"}; do
|
||||
IFS=$'\t' read -r _cdir _cmode _cscan <<< "$_rec"
|
||||
[ "$DEJIMA_ISOLATED" = 0 ] || [ "$_cmode" = ro ] || continue
|
||||
mkdir -p "$_cdir"
|
||||
done
|
||||
|
||||
@@ -1321,9 +1503,31 @@ harness_hook harness_prepare_config "$SANDBOX_SETTINGS_FILE" || exit 1
|
||||
# A file jq cannot parse is a refusal, not a fallback to cp. Falling back
|
||||
# would restore the exact leak this exists to close, at the moment least
|
||||
# likely to be noticed.
|
||||
#
|
||||
# The `projects` map is narrowed to the launching project at the same time, for
|
||||
# the reason #104 narrowed the projects MOUNT: the whole map is every project's.
|
||||
# It keys per-project state by absolute path, so a copy of all of it tells a
|
||||
# session the names of every project on the host, a sample of each one's
|
||||
# filenames (`exampleFiles`), and what each has cost (#380).
|
||||
#
|
||||
# The project's own path and anything under it, because a session's cwd is
|
||||
# routinely a worktree inside the project and Claude Code keys that separately.
|
||||
# Paths are compared as strings: both sides of this mount agree on them by
|
||||
# construction - same uid, same home, same project path - which is what makes
|
||||
# the comparison meaningful rather than a guess about layout.
|
||||
SANDBOX_CLAUDE_JSON_FILE="$(mktemp "${TMPDIR:-/tmp}/dejima-claude-json.XXXXXX.json")"
|
||||
if [ -f "$HOME/.claude.json" ]; then
|
||||
if ! jq 'del(.primaryApiKey)' "$HOME/.claude.json" > "$SANDBOX_CLAUDE_JSON_FILE"; then
|
||||
# The type is checked rather than the key's presence: `projects` holding
|
||||
# something other than an object is not a shape Claude Code writes, and
|
||||
# narrowing it would error - turning a parseable file into a refused launch
|
||||
# over a key with nothing in it to narrow.
|
||||
if ! jq --arg proj "$WORKSPACE_DIR" '
|
||||
del(.primaryApiKey)
|
||||
| if (.projects | type) == "object" then
|
||||
.projects |= with_entries(
|
||||
select(.key == $proj or (.key | startswith($proj + "/"))))
|
||||
else . end
|
||||
' "$HOME/.claude.json" > "$SANDBOX_CLAUDE_JSON_FILE"; then
|
||||
echo "$HOME/.claude.json is not readable as JSON, so dejima cannot strip the" >&2
|
||||
echo "credential it may hold before copying it into the container." >&2
|
||||
exit 1
|
||||
@@ -1369,12 +1573,16 @@ DEJIMA_STATE_DIR=""
|
||||
# different statement: what the trap must never inherit.
|
||||
SANDBOX_BRIEFING_FILE=""
|
||||
SANDBOX_CREDENTIAL_PROXY_DIR=""
|
||||
SANDBOX_CREDENTIAL_PROXY_LOG=""
|
||||
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=""
|
||||
# Same rule again, and `docker volume rm` is the third thing in this trap that
|
||||
# destroys something named rather than something mktemp'd.
|
||||
SANDBOX_ISOLATED_VOLUMES=""
|
||||
SANDBOX_NVIM_LOCKFILE=""
|
||||
SANDBOX_DERIVED_DOCKERFILE=""
|
||||
SANDBOX_DERIVED_CONTEXT=""
|
||||
@@ -1487,28 +1695,76 @@ cleanup() {
|
||||
# retries turns one condition into several identical lines - three
|
||||
# 429s four seconds apart was the first real instance - and a list of
|
||||
# them says no more than the count does.
|
||||
printf '\n%sThe model API answered with an error this session:%s\n' \
|
||||
"${C_WARN:-}" "${C_RESET:-}" >&2
|
||||
#
|
||||
# match() on the whole line rather than a loop over fields. A loop
|
||||
# needs an index, and an awk index dereferenced inside single quotes
|
||||
# is indistinguishable to the test that every name cleanup() uses is
|
||||
# bound before the EXIT trap can fire - it reads one and reports an
|
||||
# unbound name. That test is right to be strict, so this stays out of
|
||||
# its way rather than asking it to parse quoting.
|
||||
awk '{ if (match($0, /answered [0-9]+/))
|
||||
n[substr($0, RSTART + 9, RLENGTH - 9)]++ }
|
||||
#
|
||||
# Anchored to the whole line the proxy writes - its prefix, then
|
||||
# `upstream answered N for` or `cannot load|refresh credentials:`,
|
||||
# the spellings in bin/dejima-credential-proxy - not to a phrase
|
||||
# appearing anywhere. The log also carries text chosen elsewhere: an
|
||||
# upstream error-body prefix, an exception echoing the session's
|
||||
# request path. Classified on a floating match, a token endpoint
|
||||
# whose error body said `answered 503` fabricated a status count
|
||||
# here, and a session whose only lines were connection failures was
|
||||
# reported as unable to authenticate. Each section prints on its own
|
||||
# evidence, and a session can have earned both.
|
||||
_audit_counts="$(awk '{ if (match($0, /^[0-9:]* dejima-credential-proxy: upstream answered [0-9]+ for /)) {
|
||||
match($0, / answered [0-9]+ for /)
|
||||
n[substr($0, RSTART + 10, RLENGTH - 15)]++ } }
|
||||
END { for (s in n) printf " %d x HTTP %s\n", n[s], s }' \
|
||||
"$SANDBOX_CREDENTIAL_PROXY_LOG" >&2
|
||||
printf ' most recent: %s\n' \
|
||||
"$(tail -n 1 "$SANDBOX_CREDENTIAL_PROXY_LOG" \
|
||||
| sed 's/^[0-9:]* dejima-credential-proxy: //')" >&2
|
||||
# Said because the count above cannot distinguish them: a harness that
|
||||
# retried a 429 or a 5xx may well have got its answer in the end, so
|
||||
# this is not on its own a session that failed.
|
||||
printf ' A retried status may still have succeeded; this is what the API said,\n' >&2
|
||||
printf ' not a verdict on the session.\n' >&2
|
||||
"$SANDBOX_CREDENTIAL_PROXY_LOG")"
|
||||
_audit_api_last="$(awk '{ if (match($0, /^[0-9:]* dejima-credential-proxy: upstream answered [0-9]+ for /))
|
||||
line = $0 }
|
||||
END { print line }' "$SANDBOX_CREDENTIAL_PROXY_LOG" \
|
||||
| sed 's/^[0-9:]* dejima-credential-proxy: //')"
|
||||
# The `cannot ...` line names what failed; the line the proxy writes
|
||||
# right after it is the remedy, and is the one an operator can act on.
|
||||
_audit_cred="$(awk '
|
||||
{ if (match($0, /^[0-9:]* dejima-credential-proxy: cannot (load|refresh) credentials: /)) {
|
||||
cred = $0; want = NR + 1; remedy = "" }
|
||||
else if (NR == want) remedy = $0 }
|
||||
END { if (cred != "") { print cred; if (remedy != "") print remedy } }' \
|
||||
"$SANDBOX_CREDENTIAL_PROXY_LOG" \
|
||||
| sed 's/^[0-9:]* dejima-credential-proxy: //; s/^/ /')"
|
||||
_audit_last="$(tail -n 1 "$SANDBOX_CREDENTIAL_PROXY_LOG" \
|
||||
| sed 's/^[0-9:]* dejima-credential-proxy: //')"
|
||||
if [ -n "$_audit_counts" ]; then
|
||||
printf '\n%sThe model API answered with an error this session:%s\n' \
|
||||
"${C_WARN:-}" "${C_RESET:-}" >&2
|
||||
printf '%s\n' "$_audit_counts" >&2
|
||||
printf ' most recent: %s\n' "$_audit_api_last" >&2
|
||||
# Said because the count above cannot distinguish them: a harness
|
||||
# that retried a 429 or a 5xx may well have got its answer in the
|
||||
# end, so this is not on its own a session that failed.
|
||||
printf ' A retried status may still have succeeded; this is what the API said,\n' >&2
|
||||
printf ' not a verdict on the session.\n' >&2
|
||||
fi
|
||||
if [ -n "$_audit_cred" ]; then
|
||||
# The proxy logged a failure the API never answered - it could not
|
||||
# load or refresh the credential, and no request was made. Saying
|
||||
# "the model API answered with an error" there sends an operator to
|
||||
# check the provider's availability, and the retry caveat above it
|
||||
# invites them to hope it succeeded anyway. Neither is true.
|
||||
printf '\n%sThe session could not authenticate to the model API:%s\n' \
|
||||
"${C_WARN:-}" "${C_RESET:-}" >&2
|
||||
printf '%s\n' "$_audit_cred" >&2
|
||||
fi
|
||||
if [ -z "$_audit_counts" ] && [ -z "$_audit_cred" ]; then
|
||||
# Something else: a request that never reached the upstream, a
|
||||
# body the proxy refused. Neither an API answer nor a credential
|
||||
# failure, so this claims neither and shows the line instead.
|
||||
printf '\n%sThe credential proxy reported a problem this session:%s\n' \
|
||||
"${C_WARN:-}" "${C_RESET:-}" >&2
|
||||
printf ' %s\n' "$_audit_last" >&2
|
||||
fi
|
||||
unset _audit_counts _audit_last _audit_cred _audit_api_last
|
||||
fi
|
||||
rm -f "$SANDBOX_SETTINGS_FILE" "$SANDBOX_CLAUDE_JSON_FILE" "${SANDBOX_NVIM_LOCKFILE:-}" "${SANDBOX_FAKE_CREDENTIALS_FILE:-}" "${SANDBOX_DERIVED_DOCKERFILE:-}" "${SANDBOX_BRIEFING_FILE:-}" || true
|
||||
rm -f "$SANDBOX_SETTINGS_FILE" "$SANDBOX_CLAUDE_JSON_FILE" "${SANDBOX_NVIM_LOCKFILE:-}" "${SANDBOX_FAKE_CREDENTIALS_FILE:-}" "${SANDBOX_DERIVED_DOCKERFILE:-}" "${SANDBOX_BRIEFING_FILE:-}" "${SANDBOX_CREDENTIAL_PROXY_LOG:-}" || true
|
||||
rm -rf "${SANDBOX_GITCONFIG_DIR:-}" "${SANDBOX_CREDENTIAL_PROXY_DIR:-}" "${SANDBOX_DERIVED_CONTEXT:-}" || true
|
||||
rm -f "${DEJIMA_SESSION_FILE:-}" || true
|
||||
# dejima-notify-watch keys its debounce stamps by run id. Removed here,
|
||||
@@ -1548,6 +1804,19 @@ cleanup() {
|
||||
if [ -n "${SANDBOX_RUN_NETWORK:-}" ]; then
|
||||
docker network rm "$SANDBOX_RUN_NETWORK" >/dev/null 2>&1 || true
|
||||
fi
|
||||
# The five volumes an isolated launch named for itself.
|
||||
#
|
||||
# What keeps them out of every other session is the name rather than this
|
||||
# line: it carries this run's id and nothing else computes it, so a daemon
|
||||
# that has gone away leaves volumes nobody can ask for. What this line is
|
||||
# for is that they do not accumulate - one set per launch, and the
|
||||
# working-directory volume among them holds everything the session cloned
|
||||
# or wrote, with nothing else on the host to prune it.
|
||||
if [ -n "${SANDBOX_ISOLATED_VOLUMES:-}" ]; then
|
||||
# Deliberately unquoted - one name per word, all of them dejima's own.
|
||||
# shellcheck disable=SC2086
|
||||
docker volume rm $SANDBOX_ISOLATED_VOLUMES >/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
|
||||
@@ -1566,7 +1835,13 @@ cleanup() {
|
||||
# only ever suppresses, so a stale export makes epitaph quieter and never
|
||||
# louder, which is what makes it safe to honour from the environment at
|
||||
# all.
|
||||
if [ "$(config_get_bool epitaph enabled)" = "1" ]; then
|
||||
#
|
||||
# Nothing to keep for an isolated launch: an epitaph is read out of the
|
||||
# transcript on the host, and that mode mounts none - so this would write
|
||||
# an empty record keyed to a working directory that will never exist
|
||||
# again. Said at launch rather than inferred from an empty store, and the
|
||||
# same goes for the conversation shipper further down.
|
||||
if [ "$DEJIMA_ISOLATED" = 0 ] && [ "$(config_get_bool epitaph enabled)" = "1" ]; then
|
||||
DEJIMA_EPITAPH_NO_SHIP="$NO_LOG" \
|
||||
"$SCRIPT_DIR/dejima-epitaph" save "$WORKSPACE_DIR" || true
|
||||
fi
|
||||
@@ -1584,7 +1859,14 @@ cleanup() {
|
||||
# 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
|
||||
#
|
||||
# Not run for an isolated launch, which has no host-side surface for the
|
||||
# audit to be about: its subject is the workspace's own files, and there
|
||||
# is no workspace. The one thing such a session can write is ~/shared,
|
||||
# and this never hashed that in any mode.
|
||||
if [ "$DEJIMA_ISOLATED" = 0 ]; then
|
||||
DEJIMA_RW_MOUNTS="$DEJIMA_RW_SOURCES" "$SCRIPT_DIR/dejima-audit" compare "$WORKSPACE_DIR" "$SANDBOX_AUDIT_STATE" || true
|
||||
fi
|
||||
rm -f "$SANDBOX_AUDIT_STATE" || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
@@ -1653,6 +1935,22 @@ done
|
||||
unset _git_field _git_value
|
||||
|
||||
export WORKSPACE_DIR
|
||||
# The whole mount spec for the working directory, because the compose file
|
||||
# cannot build it from a source and a target: an isolated launch mounts no
|
||||
# host path there at all, and that is a spec with no colon in it rather than
|
||||
# an empty one.
|
||||
#
|
||||
# An isolated launch names the compose file's isolated-work volume, which
|
||||
# gets this run's own name below and is removed with the other four. A bare
|
||||
# target would have asked for an anonymous volume instead, which is just as
|
||||
# unreachable by another session but leaves the reaping to whatever `compose
|
||||
# run --rm` does with one - and an unreaped workspace volume holds everything
|
||||
# the session cloned, once per launch, with nothing to prune it.
|
||||
if [ "$DEJIMA_ISOLATED" = 1 ]; then
|
||||
export SANDBOX_WORKSPACE_MOUNT="isolated-work:$WORKSPACE_DIR"
|
||||
else
|
||||
export SANDBOX_WORKSPACE_MOUNT="$WORKSPACE_DIR:$WORKSPACE_DIR"
|
||||
fi
|
||||
# The image's user is built to match this uid/gid/username exactly (see
|
||||
# Dockerfile) - this is what makes $HOME-based absolute paths (dotfiles,
|
||||
# ~/.claude's plugin bookkeeping) resolve identically inside the container.
|
||||
@@ -1804,10 +2102,38 @@ export SANDBOX_GITCONFIG_DIR
|
||||
# forwarded either - left empty here, docker-compose.yml's
|
||||
# ${SSH_AUTH_SOCK:-/dev/null} mounts nothing.
|
||||
[ "$DEJIMA_WORKFLOW_PUSH" = "on" ] || SSH_AUTH_SOCK=""
|
||||
# And neither does a launch that configured its own key. entrypoint.sh loads
|
||||
# that key into an agent of its own and points SSH_AUTH_SOCK at it, so the
|
||||
# forwarded one was already unused - but it stayed mounted at the path
|
||||
# docker-compose.yml names, where `SSH_AUTH_SOCK=/ssh-agent ssh ...` signs with
|
||||
# the operator's personal identities. Choosing a dedicated key has to mean the
|
||||
# personal agent is not in the container, not that it is merely not the default.
|
||||
[ "$have_usable_key" = 0 ] || SSH_AUTH_SOCK=""
|
||||
export SSH_AUTH_SOCK
|
||||
export SANDBOX_SSH_KEY_FILE="$SANDBOX_SSH_KEY"
|
||||
SSH_KNOWN_HOSTS="$(config_get core known_hosts)"; export SSH_KNOWN_HOSTS
|
||||
export GH_TOKEN="${GH_TOKEN:-}"
|
||||
# What the SESSION gets, which is not the same question as what the filter
|
||||
# container gets. GH_TOKEN above goes to the filter, where it authenticates
|
||||
# the GitHub ranges fetch - a request dejima makes about its own allowlist,
|
||||
# not something the session can reach - so that one crosses whatever the
|
||||
# session is trusted with.
|
||||
#
|
||||
# This is core's read-only GitHub credential, filled from [github] token_file
|
||||
# for every session whatever forge is selected. Read-only across an
|
||||
# operator's private repositories is still account-wide reach, so an isolated
|
||||
# launch does not get it: the mode's claim is that it carries no forge
|
||||
# credential, and that claim has to cover the one core fills in as well as
|
||||
# the one a forge declares.
|
||||
#
|
||||
# Unless the command named a forge, which is the operator saying this session
|
||||
# is meant to reach one. Then nothing here is narrowed and the selection
|
||||
# decides, exactly as for any other launch.
|
||||
SANDBOX_GH_TOKEN="$GH_TOKEN"
|
||||
if [ "$DEJIMA_ISOLATED" = 1 ] && [ "$DEJIMA_FORGE_NAMED" = 0 ]; then
|
||||
SANDBOX_GH_TOKEN=""
|
||||
fi
|
||||
export SANDBOX_GH_TOKEN
|
||||
export ALLOWED_DOMAINS="${ALLOWED_DOMAINS:-}"
|
||||
# The --allow-github-push flag and a selected forge whose hosts_allowlisted_by_core
|
||||
# says these are its own push hosts (see ALLOW_CORE_PUSH_GRANT above) are the
|
||||
@@ -2438,6 +2764,19 @@ extra_mounts+=(-e "DEJIMA_FORGE_REPO_ENV=$DEJIMA_FORGE_REPO_ENV")
|
||||
mounted_dirs=""
|
||||
for _rec in ${DEJIMA_CONFIG_DIRS[@]+"${DEJIMA_CONFIG_DIRS[@]}"}; do
|
||||
IFS=$'\t' read -r dir mounted_mode mounted_scan <<< "$_rec"
|
||||
# An isolated launch takes the read-only ones and none of the writable
|
||||
# ones. The split is already in the declaration, so this filters on what
|
||||
# the harness said rather than on a list of names kept here.
|
||||
#
|
||||
# The two halves answer differently. A read-only directory is the
|
||||
# OPERATOR's - their hooks, skills and commands - and a session cannot
|
||||
# write to it, so nothing it holds came from an earlier session and
|
||||
# nothing this one does will reach a later one. The writable ones are
|
||||
# shared state every project's sessions append to (#380), which is the
|
||||
# channel this mode exists to close.
|
||||
if [ "$DEJIMA_ISOLATED" = 1 ] && [ "$mounted_mode" != ro ]; then
|
||||
continue
|
||||
fi
|
||||
emit_mount "$dir" "$mounted_mode"
|
||||
[ "$mounted_scan" = scan ] || continue
|
||||
[ -d "$dir" ] || continue
|
||||
@@ -2580,7 +2919,15 @@ fi
|
||||
# harness plugin, which declares each mount through emit_mount. The guarded-
|
||||
# store refusal runs there, at the point of declaration, rather than being
|
||||
# something each plugin has to remember. See bin/dejima-harness-lib.sh.
|
||||
harness_hook harness_state_mounts "$WORKSPACE_DIR"
|
||||
#
|
||||
# Not called at all for an isolated launch, which is both halves of what that
|
||||
# mode needs here: this hook is what mounts the transcripts and the memory
|
||||
# under ~/.claude/projects, and it CREATES the directory for the workspace's
|
||||
# own slug before mounting it. Skipping the call is therefore also what stops
|
||||
# an isolated launch leaving a directory on the host.
|
||||
if [ "$DEJIMA_ISOLATED" = 0 ]; then
|
||||
harness_hook harness_state_mounts "$WORKSPACE_DIR"
|
||||
fi
|
||||
|
||||
# statusline.sh is executed on the host by Claude Code (settings.json's
|
||||
# statusLine.command), so it is staged for entrypoint.sh to copy rather than
|
||||
@@ -2755,10 +3102,15 @@ if [ -n "$DEJIMA_BACKEND_NAME" ] \
|
||||
&& _credential_usable "$DEJIMA_BACKEND_CREDENTIAL_KIND" "$DEJIMA_BACKEND_CREDENTIAL_FILE" "$DEJIMA_BACKEND_CREDENTIAL_FIELD"; then
|
||||
SANDBOX_CREDENTIAL_PROXY_DIR="$(mktemp -d "${TMPDIR:-/tmp}/dejima-credential-proxy.XXXXXX")"
|
||||
SANDBOX_CREDENTIAL_PROXY_SOCKET="$SANDBOX_CREDENTIAL_PROXY_DIR/proxy.sock"
|
||||
# Outside SANDBOX_CREDENTIAL_PROXY_DIR, which is bind-mounted into the
|
||||
# session below so the socket is reachable. The log holds what the proxy
|
||||
# learned from the provider - statuses, error-body prefixes - and
|
||||
# docs/security.md says that stays on the host, so it must not share the
|
||||
# one proxy path the session can read.
|
||||
SANDBOX_CREDENTIAL_PROXY_LOG="$(mktemp "${TMPDIR:-/tmp}/dejima-credential-proxy-log.XXXXXX")"
|
||||
# Every value comes from backends/<name>/backend.conf. Nothing about a
|
||||
# backend is written here, which is what makes adding one a directory
|
||||
# rather than an edit to this file.
|
||||
SANDBOX_CREDENTIAL_PROXY_LOG="$SANDBOX_CREDENTIAL_PROXY_DIR/proxy.log"
|
||||
_proxy_args=(
|
||||
--socket "$SANDBOX_CREDENTIAL_PROXY_SOCKET"
|
||||
--log "$SANDBOX_CREDENTIAL_PROXY_LOG"
|
||||
@@ -2998,7 +3350,17 @@ if remote_enabled; then
|
||||
# exists, so it covers the whole build, and it stops being true at exactly
|
||||
# the moment the EXIT trap below stops being able to run.
|
||||
{
|
||||
printf 'workspace=%s\n' "$WORKSPACE_DIR"
|
||||
# `dejima-attach --list` shows this column as a basename, which for an
|
||||
# isolated launch would be "work" - the name of dejima's own scratch
|
||||
# path rather than anything about the session. Same substitution the
|
||||
# startup banner makes, and for the same reason. What tells two
|
||||
# concurrent isolated sessions apart is the run id beside it, which
|
||||
# that listing already prints.
|
||||
if [ "$DEJIMA_ISOLATED" = 1 ]; then
|
||||
printf 'workspace=isolated\n'
|
||||
else
|
||||
printf 'workspace=%s\n' "$WORKSPACE_DIR"
|
||||
fi
|
||||
printf 'pane=%s\n' "${TMUX_PANE:-}"
|
||||
printf 'label=%s\n' "$dejima_tmux_label"
|
||||
printf 'started=%s\n' "$(date +%s)"
|
||||
@@ -3085,8 +3447,16 @@ startup_banner() {
|
||||
# C_BADGE, the same name the pickers set the mark in. Two surfaces
|
||||
# spelling the wordmark two different colours is the drift a shared
|
||||
# palette exists to stop.
|
||||
local name
|
||||
# The working directory's basename would be "work" here, which names
|
||||
# dejima's own scratch path rather than anything about the launch.
|
||||
if [ "$DEJIMA_ISOLATED" = 1 ]; then
|
||||
name="isolated"
|
||||
else
|
||||
name="$(basename -- "$WORKSPACE_DIR")"
|
||||
fi
|
||||
printf '\n %sdejima%s %s%s%s' \
|
||||
"$C_BADGE" "$C_RESET" "$C_KEY" "$(basename -- "$WORKSPACE_DIR")" "$C_RESET" >&2
|
||||
"$C_BADGE" "$C_RESET" "$C_KEY" "$name" "$C_RESET" >&2
|
||||
[ -z "$branch" ] || printf ' %s%s%s %s' "$C_DIM" "$GLYPH_SEP" "$C_RESET" "$branch" >&2
|
||||
printf '\n %sharness%s %s %sforge%s %s %sworkflow%s %s\n' \
|
||||
"$C_DIM" "$C_RESET" "$DEJIMA_HARNESS_NAME${model:+ ($model)}" \
|
||||
@@ -3099,6 +3469,21 @@ startup_banner() {
|
||||
}
|
||||
startup_banner
|
||||
|
||||
# Printed whatever [ui] banner says and whether or not stderr is a terminal,
|
||||
# unlike the banner above: the share path is the only route back to anything
|
||||
# this session hands over, and the two features it does without are ones an
|
||||
# operator would otherwise go looking for afterwards and find empty.
|
||||
if [ "$DEJIMA_ISOLATED" = 1 ]; then
|
||||
printf '\n %sisolated%s no project mounted, no memory, no transcript;\n' \
|
||||
"${C_KEY:-}" "${C_RESET:-}" >&2
|
||||
printf ' conversation log and epitaph off - both read one\n' >&2
|
||||
printf ' %sforge%s %s %sworkflow%s %s\n' \
|
||||
"${C_DIM:-}" "${C_RESET:-}" "$DEJIMA_FORGE_NAME" \
|
||||
"${C_DIM:-}" "${C_RESET:-}" "$DEJIMA_WORKFLOW_NAME" >&2
|
||||
printf ' %sshared%s %s\n' "${C_DIM:-}" "${C_RESET:-}" "$SANDBOX_SHARE_DIR" >&2
|
||||
printf ' the only thing that outlives this session\n' >&2
|
||||
fi
|
||||
|
||||
printf '\n%sdejima%s is starting, please wait...\n' "$C_KEY" "$C_RESET" >&2
|
||||
printf '%sA first run builds the container image, which takes several minutes.%s\n' \
|
||||
"$C_DIM" "$C_RESET" >&2
|
||||
@@ -3153,7 +3538,11 @@ fi
|
||||
|
||||
# Snapshot host-persistence surfaces so the post-run audit can report
|
||||
# anything that changed while this session ran (advisory - see bin/dejima-audit).
|
||||
"$SCRIPT_DIR/dejima-audit" snapshot "$WORKSPACE_DIR" "$SANDBOX_AUDIT_STATE" || true
|
||||
# Paired with the compare in cleanup(), which states why an isolated launch
|
||||
# does neither.
|
||||
if [ "$DEJIMA_ISOLATED" = 0 ]; then
|
||||
"$SCRIPT_DIR/dejima-audit" snapshot "$WORKSPACE_DIR" "$SANDBOX_AUDIT_STATE" || true
|
||||
fi
|
||||
|
||||
# When the sandbox is pointed at its own repo, its TCB (bin/, hooks/,
|
||||
# docker/, install.sh, test/) is mounted writable - warn, and the audit
|
||||
@@ -3305,10 +3694,34 @@ export SANDBOX_IMAGE
|
||||
#
|
||||
# The prefix is not decoration: project_slug maps a leading `/` to `-`, and
|
||||
# a Docker volume name may not start with one.
|
||||
_nvim_volume_slug="$(project_slug "$WORKSPACE_DIR")"
|
||||
export SANDBOX_NVIM_DATA="dejima_nvim-data${_nvim_volume_slug}"
|
||||
export SANDBOX_NVIM_STATE="dejima_nvim-state${_nvim_volume_slug}"
|
||||
export SANDBOX_NVIM_CACHE="dejima_nvim-cache${_nvim_volume_slug}"
|
||||
#
|
||||
# Per RUN rather than per project for an isolated launch, along with the shell
|
||||
# history - which is shared by every project even when these are not, so it is
|
||||
# otherwise the one surface where an isolated session reads what was typed in
|
||||
# any session before it. Every isolated launch has the same working
|
||||
# directory, so a slug would give them all the same four volumes and put back
|
||||
# exactly the thing described above.
|
||||
#
|
||||
# The run id is what makes them unreachable: no other launch computes that
|
||||
# name, so no other session can ask for these volumes whether or not the
|
||||
# removal in cleanup() gets to run.
|
||||
if [ "$DEJIMA_ISOLATED" = 1 ]; then
|
||||
export SANDBOX_NVIM_DATA="dejima_isolated-nvim-data-$DEJIMA_RUN_ID"
|
||||
export SANDBOX_NVIM_STATE="dejima_isolated-nvim-state-$DEJIMA_RUN_ID"
|
||||
export SANDBOX_NVIM_CACHE="dejima_isolated-nvim-cache-$DEJIMA_RUN_ID"
|
||||
export SANDBOX_BASH_HISTORY="dejima_isolated-bashhistory-$DEJIMA_RUN_ID"
|
||||
# The working directory, which is a volume rather than a host path here -
|
||||
# see SANDBOX_WORKSPACE_MOUNT above. It is the one of the five that holds
|
||||
# whatever the session actually did, so it is the one that most needs
|
||||
# removing rather than merely being unreachable.
|
||||
export SANDBOX_ISOLATED_WORK="dejima_isolated-work-$DEJIMA_RUN_ID"
|
||||
SANDBOX_ISOLATED_VOLUMES="$SANDBOX_NVIM_DATA $SANDBOX_NVIM_STATE $SANDBOX_NVIM_CACHE $SANDBOX_BASH_HISTORY $SANDBOX_ISOLATED_WORK"
|
||||
else
|
||||
_nvim_volume_slug="$(project_slug "$WORKSPACE_DIR")"
|
||||
export SANDBOX_NVIM_DATA="dejima_nvim-data${_nvim_volume_slug}"
|
||||
export SANDBOX_NVIM_STATE="dejima_nvim-state${_nvim_volume_slug}"
|
||||
export SANDBOX_NVIM_CACHE="dejima_nvim-cache${_nvim_volume_slug}"
|
||||
fi
|
||||
|
||||
# Not exec'd (the EXIT trap cleans up the generated temp files) and not a
|
||||
# bare command under set -e (a nonzero/interrupted container run - e.g.
|
||||
@@ -3504,6 +3917,12 @@ docker network create --label "dejima.run=$DEJIMA_RUN_ID" "$SANDBOX_RUN_NETWORK"
|
||||
# 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.
|
||||
# The only two names here this launcher does not derive: they come from the
|
||||
# operator's own environment and travel through untouched. Marked exported
|
||||
# anyway, so that `-e NAME` carries a value rather than an empty string on a
|
||||
# shell where they were set without being exported.
|
||||
export EGRESS_HOST_ADDR EGRESS_HOST_PORTS
|
||||
|
||||
filter_env=(
|
||||
-e ENABLE_FIREWALL # whether to install rules at all
|
||||
-e ALLOWED_DOMAINS # the operator's approved names
|
||||
@@ -3513,6 +3932,13 @@ filter_env=(
|
||||
-e LOKI_URL # its host is allowlisted, and the shipper needs it
|
||||
-e DEJIMA_INTERNAL_DOMAINS # approved names that really do resolve inside
|
||||
-e DEJIMA_BACKEND_HOST # a self-served model's host, from its base_url
|
||||
# The host entry: which address stands for "this container's host", and
|
||||
# which of its TCP ports open. Both default inside egress-policy and
|
||||
# neither has a config key, so passing them is what makes them reachable
|
||||
# at all - without this the clamp would have no escape hatch an operator
|
||||
# could use.
|
||||
-e EGRESS_HOST_ADDR
|
||||
-e EGRESS_HOST_PORTS
|
||||
-e SANDBOX_HARNESS_DOMAINS
|
||||
-e SANDBOX_BACKEND_PROXIED_DOMAIN
|
||||
-e SANDBOX_CREDENTIAL_PROXY_SOCKET
|
||||
@@ -3603,7 +4029,14 @@ compose_status=0
|
||||
# Gated here as well as inside the shipper, which checks the same two keys for
|
||||
# itself: --no-conversation and --no-log are overrides held in this process,
|
||||
# and the shipper re-reads the config rather than inheriting them.
|
||||
if [ "$(config_get_bool logging conversation)" = "1" ] &&
|
||||
#
|
||||
# And not at all for an isolated launch. The shipper tails a transcript that
|
||||
# is on the host because the harness's state directory is mounted, and that
|
||||
# mode mounts none - so what it would ship is nothing, from a file that is
|
||||
# never created. Said at launch instead, because a log that is quietly empty
|
||||
# is worse than one the operator knows they are not getting.
|
||||
if [ "$DEJIMA_ISOLATED" = 0 ] &&
|
||||
[ "$(config_get_bool logging conversation)" = "1" ] &&
|
||||
[ -n "$(config_get logging loki_url)" ]; then
|
||||
"$SCRIPT_DIR/dejima-conversation-ship" "$WORKSPACE_DIR" &
|
||||
SANDBOX_CONVERSATION_SHIP_PID=$!
|
||||
|
||||
+221
-103
@@ -6,11 +6,12 @@
|
||||
# actually needs, it should tell you the exact command to run here rather
|
||||
# than try to work around the firewall.
|
||||
#
|
||||
# Usage: dejima-allow [--global] [--project [path]] [--container <id>] <domain>
|
||||
# Usage: dejima-allow [--global] [--project [path]] [--container <id>] <domain>...
|
||||
# dejima-allow crates.io # this running session only
|
||||
# dejima-allow --global crates.io # persist for every project
|
||||
# dejima-allow --project crates.io # persist for the project in $PWD
|
||||
# dejima-allow --project ~/myproj crates.io # persist for one named project
|
||||
# dejima-allow dl.example.com files.example.org # several, confirmed first
|
||||
#
|
||||
# --project and --global are approvals for future sessions and deliberately do
|
||||
# not touch a running container: a scope flag means "record this", not "patch
|
||||
@@ -25,32 +26,32 @@ case "${1:-}" in
|
||||
cat <<'DEJIMA_ALLOW_HELP'
|
||||
dejima-allow - let a sandbox reach a domain the egress firewall blocks
|
||||
|
||||
Usage: dejima-allow [--global] [--project [path]] [--container <id>] <domain>
|
||||
Usage: dejima-allow [--global] [--project [path]] [--container <id>] <domain>...
|
||||
Up to twenty. More than one is confirmed first, defaulting to no, which
|
||||
needs a terminal - so a script does them one at a time.
|
||||
|
||||
Scope, which decides who the grant is for and how long it lasts:
|
||||
(none) the running container, right now, until it exits. Finds it
|
||||
if exactly one is running. With several, it offers them to
|
||||
pick from on a terminal, and lists them and stops anywhere
|
||||
else - a prompt is no use to a script.
|
||||
if exactly one is running. With several it offers them to
|
||||
pick from on a terminal, by project and by the number the
|
||||
dejima-attach picker gives each; anywhere else it lists
|
||||
them and stops, a prompt being no use to a script.
|
||||
--container <id> the same, for a named container. Use this when a session
|
||||
told you its own id rather than letting anything guess.
|
||||
With several running, they are listed by project and by
|
||||
the number the dejima-attach picker gives each one. On a
|
||||
terminal it offers them to pick from instead.
|
||||
--project [path] every future session in one project. Also writes the domain
|
||||
to that project's .dejima/allowed-domains, so it is shared
|
||||
with everyone working on it. Path defaults to $PWD.
|
||||
--global every future session in every project on this host.
|
||||
|
||||
They combine. --project and --global deliberately do NOT touch a session that
|
||||
is already running: they are approvals for future ones. Pass --container as
|
||||
well to do both at once.
|
||||
They combine, and --project/--global deliberately do NOT touch a running
|
||||
session: they are approvals for future ones, so pass --container to do both.
|
||||
Prefer --project to --global - a domain one project needs is that project's,
|
||||
and a global grant widens every other project's firewall too.
|
||||
|
||||
Prefer --project to --global. A domain one project needs is that project's, and
|
||||
a global grant widens every other project's firewall too.
|
||||
|
||||
With --container, the domain is verified reachable afterwards and a warning is
|
||||
With --container, each domain is verified reachable afterwards and a warning is
|
||||
printed if it is not, so a typo or a blocked port does not look like success.
|
||||
One that fails stops there naming what landed; a scope flag has them all by
|
||||
then regardless.
|
||||
|
||||
See docs/configuration.md; docs/security.md explains what the allowlist bounds.
|
||||
DEJIMA_ALLOW_HELP
|
||||
@@ -73,34 +74,138 @@ source "$SCRIPT_DIR/../docker/fixed-domains.sh"
|
||||
source "$SCRIPT_DIR/dejima-tui-lib.sh"
|
||||
|
||||
usage() {
|
||||
echo "Usage: dejima-allow [--global] [--project [path]] [--container <id>] <domain>" >&2
|
||||
echo "Usage: dejima-allow [--global] [--project [path]] [--container <id>] <domain>..." >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# 1: the domain is the required trailing positional, which is what tells
|
||||
# parse_scope_args whether a token after --project is a path or the domain.
|
||||
# The most domains one command will take. A cap rather than no limit, because
|
||||
# the confirmation below only works if the operator reads the list, and a list
|
||||
# long enough to scroll is one nobody reads to the end of - which is where
|
||||
# something would be hidden. Twenty is enough for the real case (a toolchain
|
||||
# that fetches from several hosts) and short enough to take in at once.
|
||||
readonly DEJIMA_ALLOW_MAX=20
|
||||
|
||||
# 1: at least one trailing positional, which is what tells parse_scope_args
|
||||
# whether a token after --project is a path or a domain. It is a minimum, not
|
||||
# an exact count - several domains may follow.
|
||||
parse_scope_args 1 "$@" || usage
|
||||
[ "${#dj_positional[@]}" -eq 1 ] || usage
|
||||
domain="${dj_positional[0]}"
|
||||
[ "${#dj_positional[@]}" -ge 1 ] || usage
|
||||
domains=("${dj_positional[@]}")
|
||||
global="$dj_global"
|
||||
project=""
|
||||
if [ "$dj_project_set" -eq 1 ]; then
|
||||
# parse_scope_args consumes a token after --project as the path whenever
|
||||
# enough positionals follow it, which with several domains is any of them.
|
||||
# A path that is not a directory and does read as a domain was the first
|
||||
# domain: `dejima-allow --project a.com b.com` means both of those, in the
|
||||
# project in $PWD. A directory wins, so a project whose name happens to
|
||||
# look like a hostname still works.
|
||||
if [ -n "$dj_project" ] && [ ! -d "$dj_project" ] \
|
||||
&& [[ "$dj_project" =~ ^[a-zA-Z0-9]([a-zA-Z0-9-]{0,62}\.)*[a-zA-Z0-9-]{1,63}$ ]]; then
|
||||
domains=("$dj_project" "${domains[@]}")
|
||||
dj_project=""
|
||||
fi
|
||||
project="$(cd "${dj_project:-$PWD}" && pwd)"
|
||||
disclose_project_symlink "$project"
|
||||
fi
|
||||
container="$dj_container"
|
||||
|
||||
# Validated up front, not just passed as a positional arg below: this also
|
||||
# has to be a well-formed hostname to be a valid dnsmasq server=/domain/...
|
||||
# line (e.g. no embedded "/"), on top of ruling out shell metacharacters. A
|
||||
# sandboxed agent can suggest this exact command to the user to copy-paste
|
||||
# (see sandbox-context.sh), so a prompt-injected agent handing back a
|
||||
# malicious "domain" is a real path here, not just a theoretical one.
|
||||
if [[ ! "$domain" =~ ^[a-zA-Z0-9]([a-zA-Z0-9-]{0,62}\.)*[a-zA-Z0-9-]{1,63}$ ]]; then
|
||||
echo "Not a valid domain: $domain" >&2
|
||||
# Repeats dropped, order kept. A list with the same name twice asks the
|
||||
# operator to approve it twice and makes the count they are reading wrong.
|
||||
_seen=""
|
||||
_unique=()
|
||||
for _d in "${domains[@]}"; do
|
||||
case "$_seen" in *"|${_d,,}|"*) continue ;; esac
|
||||
_seen="$_seen|${_d,,}|"
|
||||
_unique+=("$_d")
|
||||
done
|
||||
domains=("${_unique[@]}")
|
||||
|
||||
# What to call this request where it is described as a whole, rather than once
|
||||
# per name. Anything outside the per-domain loops below has no $domain to use,
|
||||
# and under set -u reaching for one is fatal rather than merely wrong - which is
|
||||
# how the container picker's own prompt took this script down.
|
||||
if [ "${#domains[@]}" -eq 1 ]; then
|
||||
domain_label="${domains[0]}"
|
||||
else
|
||||
domain_label="${#domains[@]} domains"
|
||||
fi
|
||||
|
||||
if [ "${#domains[@]}" -gt "$DEJIMA_ALLOW_MAX" ]; then
|
||||
echo "That is ${#domains[@]} domains, and this takes at most $DEJIMA_ALLOW_MAX at once." >&2
|
||||
echo "" >&2
|
||||
echo "The limit is there because the confirmation below is only worth anything if" >&2
|
||||
echo "you read the whole list. Split it up, and read each part." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Every domain validated BEFORE any of them is shown or recorded, and the whole
|
||||
# command refused if any fails. Two reasons beyond the obvious one.
|
||||
#
|
||||
# The list the operator confirms has to be the list that gets added: validating
|
||||
# as we went would ask them to approve names, then add a prefix of them and
|
||||
# fail. And the confirmation rests on the list being readable as printed - this
|
||||
# pattern allows letters, digits, hyphens and dots and nothing else, so no
|
||||
# entry can carry a carriage return or an escape sequence that rewrites the
|
||||
# lines above it on the way past.
|
||||
#
|
||||
# It also has to be a well-formed hostname to be a valid dnsmasq
|
||||
# server=/domain/... line (e.g. no embedded "/"), on top of ruling out shell
|
||||
# metacharacters. A sandboxed agent can suggest this exact command for the
|
||||
# operator to copy-paste (see sandbox-context.sh), so a prompt-injected agent
|
||||
# handing back a malicious "domain" is a real path here, not a theoretical one.
|
||||
#
|
||||
# All of them reported, not the first: an operator fixing a typo in a list of
|
||||
# ten should not have to run it ten times to find the second one.
|
||||
_bad=()
|
||||
for _d in "${domains[@]}"; do
|
||||
[[ "$_d" =~ ^[a-zA-Z0-9]([a-zA-Z0-9-]{0,62}\.)*[a-zA-Z0-9-]{1,63}$ ]] || _bad+=("$_d")
|
||||
done
|
||||
if [ "${#_bad[@]}" -gt 0 ]; then
|
||||
if [ "${#_bad[@]}" -eq 1 ]; then
|
||||
echo "Not a valid domain: ${_bad[0]}" >&2
|
||||
else
|
||||
echo "Not valid domains:" >&2
|
||||
printf ' %s\n' "${_bad[@]}" >&2
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# One domain goes through as it always has. More than one is confirmed first,
|
||||
# because a list is where something unwanted can ride along unread - and the
|
||||
# agent that suggested the command is exactly what might have put it there.
|
||||
#
|
||||
# Default no, and the list on stdout one per line so nothing is off the right
|
||||
# edge of a narrow terminal.
|
||||
#
|
||||
# No terminal means no confirmation is possible, so it refuses rather than
|
||||
# assuming either answer. A script that wants several does them one at a time,
|
||||
# which is the behaviour this had before and still has for one.
|
||||
if [ "${#domains[@]}" -gt 1 ]; then
|
||||
echo "About to allow ${#domains[@]} domains:"
|
||||
printf ' %s\n' "${domains[@]}"
|
||||
echo ""
|
||||
_where=()
|
||||
[ -n "$project" ] && _where+=("every future session in $project")
|
||||
[ "$global" -eq 1 ] && _where+=("every future session in every project")
|
||||
[ -n "$container" ] && _where+=("the running session $container")
|
||||
[ "${#_where[@]}" -gt 0 ] || _where=("the running session")
|
||||
printf 'This grants them to: %s\n' "$(printf '%s; ' "${_where[@]}" | sed 's/; $//')"
|
||||
echo ""
|
||||
if [ ! -t 0 ]; then
|
||||
echo "Several domains at once needs a terminal to confirm on, and this is not one." >&2
|
||||
echo "Run them one at a time, which needs no confirmation." >&2
|
||||
exit 1
|
||||
fi
|
||||
printf 'Add all %s? [y/N] ' "${#domains[@]}"
|
||||
read -r _reply || _reply=""
|
||||
case "$_reply" in
|
||||
y|Y|yes|YES|Yes) ;;
|
||||
*) echo "Nothing was added."; exit 1 ;;
|
||||
esac
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# Recorded first, and unconditionally: --project/--global are the user's
|
||||
# approval decision for future sessions, not a claim about this one. Gating
|
||||
# either on a live container would break the documented workflow outright -
|
||||
@@ -111,6 +216,7 @@ fi
|
||||
# given together, and each persists to its own store.
|
||||
if [ -n "$project" ]; then
|
||||
store="$(ensure_project_store "$project")"
|
||||
for domain in "${domains[@]}"; do
|
||||
approve_entry "$store" allowed-domains "$domain"
|
||||
echo "Added $domain to the approved domains for $project (applies to every future session in it)."
|
||||
# Printed only after the approval above, and guarded rather than left to
|
||||
@@ -124,11 +230,14 @@ if [ -n "$project" ]; then
|
||||
echo "The approval above IS recorded and applies to every future session in it;" >&2
|
||||
echo "only the project's own .dejima declaration could not be written." >&2
|
||||
fi
|
||||
done
|
||||
fi
|
||||
if [ "$global" -eq 1 ]; then
|
||||
store="$(ensure_global_store)"
|
||||
approve_entry "$store" allowed-domains "$domain"
|
||||
echo "Added $domain to the global approved domains (applies to every future session)."
|
||||
for domain in "${domains[@]}"; do
|
||||
approve_entry "$store" allowed-domains "$domain"
|
||||
echo "Added $domain to the global approved domains (applies to every future session)."
|
||||
done
|
||||
fi
|
||||
|
||||
# A scope flag means "record this approval", not "patch what is running now" -
|
||||
@@ -225,7 +334,7 @@ pick_container() {
|
||||
"$C_LINK" "$CT_W_NAME" "${CT_NAMES[$i]}" "$C_OFF" \
|
||||
"$C_MUTED" "${CT_ATTACH[$i]}" "$C_OFF"
|
||||
done
|
||||
tui_pick "${#CT_IDS[@]}" "allow $domain in"
|
||||
tui_pick "${#CT_IDS[@]}" "allow $domain_label in"
|
||||
container="${CT_NAMES[$((TUI_CHOICE - 1))]}"
|
||||
}
|
||||
|
||||
@@ -313,7 +422,7 @@ if [ "$session_wanted" -eq 1 ]; then
|
||||
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
|
||||
echo " dejima-allow --project ${domains[*]}" >&2
|
||||
exit 1
|
||||
fi
|
||||
filter="$container"
|
||||
@@ -348,83 +457,92 @@ if [ "$session_wanted" -eq 1 ]; then
|
||||
# on the same name - with the failed launch as the operator's first hint
|
||||
# that the two commands had different ideas.
|
||||
#
|
||||
# $domain reaches the container as an argument rather than inside the
|
||||
# command string: it is validated above, and this keeps it that way if that
|
||||
# validation is ever loosened.
|
||||
# Each domain reaches the container as an argument rather than inside the
|
||||
# command string: they are validated above, and this keeps it that way if
|
||||
# that validation is ever loosened.
|
||||
#
|
||||
# Guarded on the file existing: config_get says so on stderr when it does
|
||||
# not, which is right for a launch and noise for this, where a host with no
|
||||
# configuration at all is an ordinary case and the answer is simply "not
|
||||
# listed". A config that exists and is malformed still reports itself.
|
||||
internal=0
|
||||
if [ -f "$(config_path)" ]; then
|
||||
# `|| [ -n "$_d" ]`, because config_get prints its value with no
|
||||
# trailing newline - so a single-entry list is one line read never
|
||||
# returns, and the loop body would not run at all.
|
||||
# Case-folded, like the class decision below and dejima_mark_internal:
|
||||
# a live add that misses the exemption the operator configured leaves
|
||||
# the name without it until the next launch, which then refuses to
|
||||
# start on the same mismatch.
|
||||
while IFS= read -r _d || [ -n "$_d" ]; do
|
||||
[ "${_d,,}" = "${domain,,}" ] && internal=1
|
||||
done < <(config_get core internal_domains | tr ':' '\n')
|
||||
fi
|
||||
|
||||
permit_args=(permit "$domain")
|
||||
[ "$(dejima_class_for "$domain")" = forge ] && permit_args+=(--forge)
|
||||
[ "$internal" -eq 1 ] && permit_args+=(--internal-ok)
|
||||
|
||||
echo "Permitting $domain in $filter, this session's egress control..."
|
||||
if ! docker exec -u root "$filter" /usr/local/sbin/egress-policy "${permit_args[@]}"; then
|
||||
echo "" >&2
|
||||
echo "The permitted set was left as it was - the reason is above." >&2
|
||||
if [ "$internal" -eq 0 ]; then
|
||||
echo "" >&2
|
||||
echo "If $domain resolves inside your own network, say so in the config and" >&2
|
||||
echo "relaunch - an answer pointing inside is refused for every name that is" >&2
|
||||
echo "not listed there, whether or not it is approved:" >&2
|
||||
echo "" >&2
|
||||
echo " [core]" >&2
|
||||
echo " internal_domains = $domain" >&2
|
||||
echo "" >&2
|
||||
echo "It still needs an approval to be reachable at all:" >&2
|
||||
echo "" >&2
|
||||
echo " dejima-allow --global $domain" >&2
|
||||
# One domain at a time, because each is its own change to the running
|
||||
# policy: the class it belongs to, whether the operator declared it
|
||||
# internal, and whether it then answers. A failure stops the loop rather
|
||||
# than carrying on, so what did and did not land is the order printed
|
||||
# above - and the names after it are still recorded under any scope flag
|
||||
# given, which happened before this and is not undone by a live add
|
||||
# failing.
|
||||
for domain in "${domains[@]}"; do
|
||||
internal=0
|
||||
if [ -f "$(config_path)" ]; then
|
||||
# `|| [ -n "$_d" ]`, because config_get prints its value with no
|
||||
# trailing newline - so a single-entry list is one line read never
|
||||
# returns, and the loop body would not run at all.
|
||||
# Case-folded, like the class decision below and dejima_mark_internal:
|
||||
# a live add that misses the exemption the operator configured leaves
|
||||
# the name without it until the next launch, which then refuses to
|
||||
# start on the same mismatch.
|
||||
while IFS= read -r _d || [ -n "$_d" ]; do
|
||||
[ "${_d,,}" = "${domain,,}" ] && internal=1
|
||||
done < <(config_get core internal_domains | tr ':' '\n')
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "$domain allowed for the running session ($container)."
|
||||
permit_args=(permit "$domain")
|
||||
[ "$(dejima_class_for "$domain")" = forge ] && permit_args+=(--forge)
|
||||
[ "$internal" -eq 1 ] && permit_args+=(--internal-ok)
|
||||
|
||||
# TCP-level truth check from inside the very container that was just
|
||||
# updated - this script's job is the firewall, so "TCP connect succeeded"
|
||||
# is the claim to verify. curl exit 6 (resolve failed), 7 (connect
|
||||
# refused/blocked) and 28 (connect or overall timeout) mean still
|
||||
# unreachable; anything else (0, or a TLS/HTTP-layer code like 35/52) means
|
||||
# the connection itself went through the firewall fine. --max-time bounds
|
||||
# the whole request, not just the connect phase - without it, a server that
|
||||
# accepts the TCP connection and then stalls (never sends TLS/HTTP bytes)
|
||||
# would hang this script indefinitely; curl maps that case to the same
|
||||
# exit 28 the case below already handles, so no new branch is needed. This
|
||||
# is a report on this run, not a gate on either scope's approval: both
|
||||
# --project and --global are already recorded above, deliberately - a
|
||||
# declared host may legitimately not answer on 443 (an internal service,
|
||||
# an SSH-only host, an apex that only serves a subdomain), and the natural
|
||||
# moment to add a global approval is precisely when nothing is running to
|
||||
# probe in the first place. The exit 2 below still means what it says:
|
||||
# the live patch to this session did not verify.
|
||||
echo "Verifying $domain is reachable from $container..."
|
||||
rc=0
|
||||
docker exec "$container" curl -sI --connect-timeout 5 --max-time 10 "https://$domain/" >/dev/null 2>&1 || rc=$?
|
||||
case "$rc" in
|
||||
6|7|28)
|
||||
echo "WARNING: $domain is still NOT reachable from $container (curl exit $rc)." >&2
|
||||
echo "If the service doesn't listen on 443 this check can fail spuriously;" >&2
|
||||
echo "otherwise the allow did not take effect - re-check the container ID." >&2
|
||||
exit 2
|
||||
;;
|
||||
*)
|
||||
echo "Verified: $domain is reachable from $container."
|
||||
;;
|
||||
esac
|
||||
echo "Permitting $domain in $filter, this session's egress control..."
|
||||
if ! docker exec -u root "$filter" /usr/local/sbin/egress-policy "${permit_args[@]}"; then
|
||||
echo "" >&2
|
||||
echo "The permitted set was left as it was - the reason is above." >&2
|
||||
if [ "$internal" -eq 0 ]; then
|
||||
echo "" >&2
|
||||
echo "If $domain resolves inside your own network, say so in the config and" >&2
|
||||
echo "relaunch - an answer pointing inside is refused for every name that is" >&2
|
||||
echo "not listed there, whether or not it is approved:" >&2
|
||||
echo "" >&2
|
||||
echo " [core]" >&2
|
||||
echo " internal_domains = $domain" >&2
|
||||
echo "" >&2
|
||||
echo "It still needs an approval to be reachable at all:" >&2
|
||||
echo "" >&2
|
||||
echo " dejima-allow --global $domain" >&2
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "$domain allowed for the running session ($container)."
|
||||
|
||||
# TCP-level truth check from inside the very container that was just
|
||||
# updated - this script's job is the firewall, so "TCP connect succeeded"
|
||||
# is the claim to verify. curl exit 6 (resolve failed), 7 (connect
|
||||
# refused/blocked) and 28 (connect or overall timeout) mean still
|
||||
# unreachable; anything else (0, or a TLS/HTTP-layer code like 35/52) means
|
||||
# the connection itself went through the firewall fine. --max-time bounds
|
||||
# the whole request, not just the connect phase - without it, a server that
|
||||
# accepts the TCP connection and then stalls (never sends TLS/HTTP bytes)
|
||||
# would hang this script indefinitely; curl maps that case to the same
|
||||
# exit 28 the case below already handles, so no new branch is needed. This
|
||||
# is a report on this run, not a gate on either scope's approval: both
|
||||
# --project and --global are already recorded above, deliberately - a
|
||||
# declared host may legitimately not answer on 443 (an internal service,
|
||||
# an SSH-only host, an apex that only serves a subdomain), and the natural
|
||||
# moment to add a global approval is precisely when nothing is running to
|
||||
# probe in the first place. The exit 2 below still means what it says:
|
||||
# the live patch to this session did not verify.
|
||||
echo "Verifying $domain is reachable from $container..."
|
||||
rc=0
|
||||
docker exec "$container" curl -sI --connect-timeout 5 --max-time 10 "https://$domain/" >/dev/null 2>&1 || rc=$?
|
||||
case "$rc" in
|
||||
6|7|28)
|
||||
echo "WARNING: $domain is still NOT reachable from $container (curl exit $rc)." >&2
|
||||
echo "If the service doesn't listen on 443 this check can fail spuriously;" >&2
|
||||
echo "otherwise the allow did not take effect - re-check the container ID." >&2
|
||||
exit 2
|
||||
;;
|
||||
*)
|
||||
echo "Verified: $domain is reachable from $container."
|
||||
;;
|
||||
esac
|
||||
done
|
||||
fi
|
||||
|
||||
@@ -400,29 +400,54 @@ class Handler(BaseHTTPRequestHandler):
|
||||
# forwarded upstream is a worse outcome than a rejected one.
|
||||
MAX_BODY_BYTES = 32 * 1024 * 1024
|
||||
|
||||
def _length(self, raw, what):
|
||||
"""A byte count from the wire, or BodyTooLarge.
|
||||
|
||||
`int()` alone accepts a negative, and a negative passes a `> MAX` test
|
||||
and then means "read to EOF" to a buffered reader - so the cap above,
|
||||
whose whole reason is that this runs on the host as the operator, was
|
||||
bypassed by sending `Content-Length: -1`.
|
||||
|
||||
A value that is not a number still raises out of here, deliberately: the
|
||||
suppression around handle_error is meant to be specific to the client
|
||||
going away, and a malformed chunk length is the fault the test for that
|
||||
uses to prove it. Refusing it cleanly here would remove the canary and
|
||||
trade noise for blindness.
|
||||
"""
|
||||
n = int(raw, 16) if what == "chunk size" else int(raw)
|
||||
if n < 0 or n > self.MAX_BODY_BYTES:
|
||||
# The offending value, not the cap. The host log said "refused a
|
||||
# request body: 33554432 bytes" whatever arrived, so a hostile -1
|
||||
# and a genuinely oversized upload read identically - and the
|
||||
# comment on the 413 path promises the log says what came in.
|
||||
raise BodyTooLarge("%s of %d, cap %d"
|
||||
% (what, n, self.MAX_BODY_BYTES))
|
||||
return n
|
||||
|
||||
def _read_body(self):
|
||||
if self.headers.get("Transfer-Encoding", "").lower() == "chunked":
|
||||
chunks = []
|
||||
total = 0
|
||||
while True:
|
||||
size = int(self.rfile.readline().split(b";")[0], 16)
|
||||
size = self._length(self.rfile.readline().split(b";")[0], "chunk size")
|
||||
if size == 0:
|
||||
self.rfile.readline()
|
||||
return b"".join(chunks)
|
||||
total += size
|
||||
if total > self.MAX_BODY_BYTES:
|
||||
raise BodyTooLarge("%d bytes" % self.MAX_BODY_BYTES)
|
||||
raise BodyTooLarge("chunked body of %d or more, cap %d"
|
||||
% (total, self.MAX_BODY_BYTES))
|
||||
chunks.append(self.rfile.read(size))
|
||||
self.rfile.read(2)
|
||||
length = int(self.headers.get("Content-Length", 0))
|
||||
if length > self.MAX_BODY_BYTES:
|
||||
raise BodyTooLarge("%d bytes" % self.MAX_BODY_BYTES)
|
||||
length = self._length(self.headers.get("Content-Length", 0), "Content-Length")
|
||||
return self.rfile.read(length)
|
||||
|
||||
def _fail(self, status, message, log_message=None):
|
||||
# log_message (host stderr) may contain upstream response bytes or
|
||||
# exception detail; message (sent to the container in the response
|
||||
# body) never should - see docs/security.md.
|
||||
# exception detail; message (sent to the container in the response body)
|
||||
# never should. The one thing that does cross is text written in this
|
||||
# file naming something the operator must do - see CredentialNeedsOperator,
|
||||
# and docs/security.md for why that is not an exception to the rule.
|
||||
self.log_error("%s", log_message if log_message is not None else message)
|
||||
body = json.dumps(
|
||||
{"type": "error", "error": {"type": "proxy_error", "message": message}}
|
||||
@@ -519,6 +544,12 @@ class Handler(BaseHTTPRequestHandler):
|
||||
# 413 rather than a traceback and a 500: the session gets a status
|
||||
# that says what to do about it, and the host log says what came
|
||||
# in. Nothing is forwarded upstream.
|
||||
#
|
||||
# And the connection ends here. The body was refused before it was
|
||||
# read, so its bytes are still queued; left open, the next parse
|
||||
# takes them as a request line and answers a keep-alive client with
|
||||
# garbage 400s instead of one clean refusal.
|
||||
self.close_connection = True
|
||||
return self._fail(413, "credential proxy: request body too large",
|
||||
log_message="refused a request body: %s" % e)
|
||||
try:
|
||||
|
||||
+45
-21
@@ -160,17 +160,31 @@ def classify(path, value):
|
||||
return (None, None)
|
||||
|
||||
|
||||
def walk_json(doc, prefix=""):
|
||||
def walk_json(doc, prefix="", parts=()):
|
||||
"""Yields (display path, value, components).
|
||||
|
||||
The display path is what an operator reads and what finding_id hashes, so
|
||||
its spelling is fixed - it is recorded in the decisions file and a change
|
||||
would orphan every standing answer. It is also ambiguous: a dict key of
|
||||
`x[0]` and element 0 of a list `x` produce the same string, and so do a key
|
||||
containing a dot and the traversal a dot denotes.
|
||||
|
||||
The components are the same walk as a tuple, which is not ambiguous. Anything
|
||||
that has to FIND the value again uses those; re-parsing the string deleted
|
||||
the wrong element, and for a dotted key raised KeyError out of the scrub,
|
||||
leaving the file to be copied unscrubbed.
|
||||
"""
|
||||
if isinstance(doc, dict):
|
||||
for k, v in doc.items():
|
||||
yield from walk_json(v, "%s.%s" % (prefix, k) if prefix else str(k))
|
||||
yield from walk_json(v, "%s.%s" % (prefix, k) if prefix else str(k),
|
||||
parts + (("key", k),))
|
||||
elif isinstance(doc, list):
|
||||
# Indexed, so a decision recorded against one element does not silently
|
||||
# cover a different one later.
|
||||
for i, v in enumerate(doc):
|
||||
yield from walk_json(v, "%s[%d]" % (prefix, i))
|
||||
yield from walk_json(v, "%s[%d]" % (prefix, i), parts + (("idx", i),))
|
||||
else:
|
||||
yield (prefix, doc)
|
||||
yield (prefix, doc, parts)
|
||||
|
||||
|
||||
def ini_logical_lines(text):
|
||||
@@ -227,17 +241,28 @@ def walk_ini(text):
|
||||
yield (key, value)
|
||||
|
||||
|
||||
def delete_json_path(doc, path):
|
||||
# Mirrors walk_json's construction exactly, including list indices.
|
||||
parts = re.findall(r"[^.\[\]]+|\[\d+\]", path)
|
||||
cur = doc
|
||||
for part in parts[:-1]:
|
||||
cur = cur[int(part[1:-1])] if part.startswith("[") else cur[part]
|
||||
last = parts[-1]
|
||||
if last.startswith("["):
|
||||
del cur[int(last[1:-1])]
|
||||
else:
|
||||
del cur[last]
|
||||
def components_sort_key(parts):
|
||||
"""Orders paths so a list element is deleted after any later sibling.
|
||||
|
||||
`del cur[i]` shifts every later index down, so applying findings in document
|
||||
order deletes the wrong elements from the second onwards: two secrets in one
|
||||
array left the second one in the file and removed an innocent value instead,
|
||||
and the file is then mounted into the container - the operator answers "keep
|
||||
it out" and it goes in. Deepest and highest index first makes each deletion
|
||||
independent of the ones after it.
|
||||
"""
|
||||
return [(1, p[1]) if p[0] == "idx" else (0, p[1]) for p in parts]
|
||||
|
||||
|
||||
def delete_json_components(doc, drop):
|
||||
"""Every component path, in an order under which no deletion invalidates
|
||||
another: deepest and highest index first, so a list deletion cannot shift an
|
||||
element that is still to be removed."""
|
||||
for parts in sorted(drop, key=components_sort_key, reverse=True):
|
||||
cur = doc
|
||||
for kind, item in parts[:-1]:
|
||||
cur = cur[item]
|
||||
del cur[parts[-1][1]]
|
||||
|
||||
|
||||
def drop_ini_paths(text, paths):
|
||||
@@ -311,21 +336,20 @@ def main():
|
||||
print("%s is not readable as JSON" % args.label, file=sys.stderr)
|
||||
return 2
|
||||
else:
|
||||
pairs = list(walk_ini(text))
|
||||
pairs = [(p, v, ()) for p, v in walk_ini(text)]
|
||||
|
||||
if args.scrub:
|
||||
wanted = set(load_list(args.scrub))
|
||||
drop = [path for path, value in pairs
|
||||
drop = [(path, parts) for path, value, parts in pairs
|
||||
if finding_id(args.label, path) in wanted]
|
||||
if not drop:
|
||||
return 0
|
||||
if args.format == "json":
|
||||
doc = json.loads(text)
|
||||
for path in drop:
|
||||
delete_json_path(doc, path)
|
||||
delete_json_components(doc, [parts for _path, parts in drop])
|
||||
out = json.dumps(doc, indent=2) + "\n"
|
||||
else:
|
||||
out = drop_ini_paths(text, drop)
|
||||
out = drop_ini_paths(text, [p for p, _parts in drop])
|
||||
with open(args.file, "w") as f:
|
||||
f.write(out)
|
||||
return 0
|
||||
@@ -350,7 +374,7 @@ def main():
|
||||
return True
|
||||
return False
|
||||
|
||||
for path, value in pairs:
|
||||
for path, value, _parts in pairs:
|
||||
fid = finding_id(args.label, path)
|
||||
if fid in decided or judged_safe("%s\t%s" % (args.label, path)):
|
||||
continue
|
||||
|
||||
@@ -308,10 +308,10 @@ emit_host_exec() {
|
||||
echo "Harness plugin emitted a host-exec leaf or label containing a tab or newline" >&2
|
||||
exit 1 ;;
|
||||
esac
|
||||
[ -n "$leaf" ] && [ -n "$label" ] || {
|
||||
if [ -z "$leaf" ] || [ -z "$label" ]; then
|
||||
echo "Harness plugin emitted a host-exec surface with no leaf or label" >&2
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
DEJIMA_HOST_EXEC+=("$ns"$'\t'"$leaf"$'\t'"$path"$'\t'"$label")
|
||||
}
|
||||
|
||||
|
||||
+124
-20
@@ -1661,7 +1661,7 @@ BRIEFING_FRAGMENT_MAX=16384
|
||||
# is a hard refusal (_briefing_substitute below), not a literal passthrough -
|
||||
# an unrecognized {{key}} reaching an agent's context verbatim is exactly the
|
||||
# kind of silent drift this whole function exists to prevent.
|
||||
_BRIEFING_WHITELIST="pr_command issue_command forge_name forge_ssh_host blocked_branches container_id project_dir workspace_dir session_id resume_command writable_mounts network_allowlist no_confirm_args harness_state_dir"
|
||||
_BRIEFING_WHITELIST="pr_command issue_command forge_name forge_ssh_host blocked_branches container_id project_dir workspace_dir session_id resume_command writable_mounts network_allowlist no_confirm_args harness_state_dir workspace_posture project_posture"
|
||||
|
||||
_briefing_whitelisted() {
|
||||
_set_contains "$_BRIEFING_WHITELIST" "$1"
|
||||
@@ -1671,14 +1671,30 @@ _briefing_whitelisted() {
|
||||
# allowlist is load-bearing for the agent too. Lives here, not in any
|
||||
# provider directory - see the module-level comment. A quoted heredoc, so
|
||||
# backticks and $ signs are literal and need no escaping.
|
||||
_briefing_core_preamble() {
|
||||
cat <<'BRIEFING_EOF'
|
||||
You are running inside the dejima Docker container, not directly on
|
||||
the user's machine, with
|
||||
{{no_confirm_args}} active (no tool-use confirmations).
|
||||
|
||||
What this means:
|
||||
- The workspace directory is bind-mounted read-write and is the real
|
||||
# What the session is told about where it is: which of the host it can see,
|
||||
# what it may hand back, and what survives it.
|
||||
#
|
||||
# Two variants rather than one paragraph with caveats, because the ordinary
|
||||
# one states four things an isolated launch makes false - that the workspace
|
||||
# is the real project, that ~/shared is shared with every other session
|
||||
# against it, that the harness keeps state on the host, and that the work is
|
||||
# recoverable there afterwards. A session acts on these: one that believes
|
||||
# its transcript outlives the container leaves things for a resume that
|
||||
# cannot happen.
|
||||
#
|
||||
# Continuation lines carry the two spaces the preamble's bullet list uses,
|
||||
# since this is substituted into one.
|
||||
_briefing_workspace_posture() {
|
||||
if [ "${DEJIMA_ISOLATED:-0}" = 1 ]; then
|
||||
printf '%s' "Nothing of the user's machine is mounted here and this session has no
|
||||
past: no project checkout, no memory, and no transcript of an earlier
|
||||
run. Your working directory is the container's own and goes with it, as
|
||||
does anything you clone or write into it. ~/shared is the single
|
||||
exception - a real host directory, made fresh for this session, which no
|
||||
earlier session wrote to and no later one will read. Put there whatever
|
||||
the user should still have afterwards."
|
||||
else
|
||||
printf '%s' "The workspace directory is bind-mounted read-write and is the real
|
||||
project. ~/shared is also a real, persistent host directory (this
|
||||
project's own, and not part of its checkout) - use it to hand the user a
|
||||
file (a screenshot, a temp script) without touching the project checkout,
|
||||
@@ -1687,8 +1703,37 @@ What this means:
|
||||
as readable by all of them. Your harness keeps its own state on the host too,
|
||||
so this session's transcript outlives the container - the work is
|
||||
recoverable on the host with the command at the end of this
|
||||
briefing.{{writable_mounts}} Everything else is disposable: installed system
|
||||
packages, anything outside the mounted paths, and the container itself go
|
||||
briefing."
|
||||
fi
|
||||
}
|
||||
|
||||
# The same split for what the session should expect to FIND. The ordinary
|
||||
# text is about a mounted checkout holding things that are not code; an
|
||||
# isolated session has no checkout to be careful with, and the useful
|
||||
# instruction is the opposite one - ask rather than look.
|
||||
_briefing_project_posture() {
|
||||
if [ "${DEJIMA_ISOLATED:-0}" = 1 ]; then
|
||||
printf '%s' "There is no project directory, so nothing you find in this container
|
||||
came from the user. If a task needs a file of theirs, say what you need
|
||||
rather than going looking for it: ~/shared is the only way they can hand
|
||||
you one."
|
||||
else
|
||||
printf '%s' "The project directory is mounted whole, so it can hold things that are not
|
||||
code: a .env, a database dump, a key someone left in the tree. Say so if
|
||||
you find one, and do not open, copy or send it anywhere unless the task
|
||||
actually requires it."
|
||||
fi
|
||||
}
|
||||
|
||||
_briefing_core_preamble() {
|
||||
cat <<'BRIEFING_EOF'
|
||||
You are running inside the dejima Docker container, not directly on
|
||||
the user's machine, with
|
||||
{{no_confirm_args}} active (no tool-use confirmations).
|
||||
|
||||
What this means:
|
||||
- {{workspace_posture}}{{writable_mounts}} Everything else is disposable: installed system
|
||||
packages, anything the mounts do not cover, and the container itself go
|
||||
away when this run ends.
|
||||
- A headless browser is installed: playwright, with chromium, importable
|
||||
from any directory in either module system. If you change something with a
|
||||
@@ -1696,8 +1741,10 @@ What this means:
|
||||
and leave the screenshot in ~/shared.
|
||||
- Outbound network is restricted to an allowlist (Anthropic's API, the npm
|
||||
registry, {{network_allowlist}}, plus anything
|
||||
approved via dejima-allow). Requests to anything else will simply
|
||||
fail to connect. That restriction is not only a limit on your reach: it
|
||||
approved via dejima-allow). Anything else is refused at once rather than
|
||||
left to time out, so a connection that HANGS is not this allowlist - it is
|
||||
something past the sandbox, and from in here the timing is the only thing
|
||||
that tells the two apart. That restriction is not only a limit on your reach: it
|
||||
is also what bounds where a prompt injection or a compromised dependency
|
||||
in this session could send the user's code or fetch its next instruction
|
||||
from. You cannot audit yourself for that from the inside, so the
|
||||
@@ -1709,6 +1756,10 @@ What this means:
|
||||
dejima-allow --container {{container_id}} <domain> # just this session
|
||||
dejima-allow --project <path> <domain> # this project, always
|
||||
dejima-allow --global <domain> # every project
|
||||
Several domains can go on one line, up to twenty, and more than one is
|
||||
printed back for the operator to confirm before anything is added. So ask
|
||||
for the whole set a task needs in one command rather than one at a time -
|
||||
but ask only for what it needs, because the list is read.
|
||||
Pick deliberately rather than asking for all three: a domain this project
|
||||
needs is --project, not --global - a global grant widens every other
|
||||
project's firewall too, which is rarely what one project's dependency
|
||||
@@ -1720,13 +1771,10 @@ What this means:
|
||||
payment or production API acts on the world the moment it is called, with
|
||||
no diff for anyone to review afterwards. Do not ask for one of those to
|
||||
finish a task - say what you need and let the operator decide.
|
||||
- The project directory is mounted whole, so it can hold things that are not
|
||||
code: a .env, a database dump, a key someone left in the tree. Say so if
|
||||
you find one, and do not open, copy or send it anywhere unless the task
|
||||
actually requires it.
|
||||
- {{project_posture}}
|
||||
- Stay within the task you were given. Since there are no permission
|
||||
prompts here, be more conservative than usual about anything destructive,
|
||||
irreversible, or outside the project directory.
|
||||
irreversible, or outside your working directory.
|
||||
BRIEFING_EOF
|
||||
}
|
||||
|
||||
@@ -1736,8 +1784,36 @@ BRIEFING_EOF
|
||||
# workflow can only opt into it (requires_worktree = on), never edit or omit
|
||||
# it. _briefing_resolve_worktree splices this into the workflow fragment at
|
||||
# its `<!-- core: worktree -->` marker.
|
||||
_briefing_core_worktree() {
|
||||
# Where a branch's working tree must live, and why.
|
||||
#
|
||||
# The ordinary rule rests on one fact - that the bind-mounted project
|
||||
# directory is the only path backed by host storage - and an isolated launch
|
||||
# has no such path, so every clause of it would be advice about a checkout
|
||||
# that is not there. What stays true is the question the rule answers: where
|
||||
# work can be put such that it is not thrown away.
|
||||
#
|
||||
# Both worktree workflows stated the first bullet identically in their own
|
||||
# fragments, which left the same argument in three places and only two of
|
||||
# them substitutable. It is core's now, so a workflow declaring
|
||||
# requires_worktree says where the block goes and nothing about what it says.
|
||||
_briefing_core_worktree_isolated() {
|
||||
cat <<'BRIEFING_EOF'
|
||||
- A worktree here is just a directory, and nothing in this container is the
|
||||
user's checkout: clone what you need into your working directory and work
|
||||
there. The rule about keeping a worktree under the mounted project does
|
||||
not apply, because there is no mounted project, and a `git config` in
|
||||
here reaches no repository of theirs. What leaves this container is what
|
||||
you push and what you leave in ~/shared.
|
||||
BRIEFING_EOF
|
||||
}
|
||||
|
||||
_briefing_core_worktree() {
|
||||
[ "${DEJIMA_ISOLATED:-0}" = 0 ] || { _briefing_core_worktree_isolated; return 0; }
|
||||
cat <<'BRIEFING_EOF'
|
||||
- Do new work in a git worktree for the feature branch, not by switching
|
||||
branches in place in the mounted checkout - that's the user's actual
|
||||
working copy, and changing its branch out from under them is disruptive
|
||||
even if you switch it back.
|
||||
- The worktree MUST live under the mounted project directory itself, e.g.
|
||||
`git worktree add .worktrees/<branch> -b <branch>` run from the project
|
||||
root - never `../<branch>` or any other path outside it, including
|
||||
@@ -1980,6 +2056,10 @@ _briefing_resolve_key() {
|
||||
printf '%s' "GitHub read-only$hosts"
|
||||
fi
|
||||
;;
|
||||
# Both of these differ by whether this is an isolated launch, and the
|
||||
# prose for each case lives with the preamble it is substituted into.
|
||||
workspace_posture) _briefing_workspace_posture ;;
|
||||
project_posture) _briefing_project_posture ;;
|
||||
blocked_branches) manifest_get workflow "$workflow" blocked_branches ;;
|
||||
project_dir) printf '%s' "${WORKSPACE_DIR:-}" ;;
|
||||
workspace_dir) printf '%s' "${WORKSPACE_DIR:-}" ;;
|
||||
@@ -2155,6 +2235,18 @@ _briefing_core_tools() {
|
||||
printf '%s' "$out"
|
||||
}
|
||||
|
||||
# What closes an isolated briefing, in place of the harness's own sentence
|
||||
# about resuming on the host.
|
||||
_briefing_core_isolated_close() {
|
||||
cat <<'BRIEFING_EOF'
|
||||
This session cannot be resumed. Its transcript is inside the container and
|
||||
goes when the container does, so anything worth keeping has to leave before
|
||||
you finish: into ~/shared, and into a pushed branch where this session has a
|
||||
forge to push to at all. Say what you left there, rather than assuming the
|
||||
user can read it back out of the session afterwards.
|
||||
BRIEFING_EOF
|
||||
}
|
||||
|
||||
briefing_compose() {
|
||||
local forge="$1" workflow="$2" harness="$3"
|
||||
local caps autonomy workflow_frag harness_frag text
|
||||
@@ -2215,7 +2307,19 @@ briefing_compose() {
|
||||
# business resolving.
|
||||
text+="$(_briefing_core_closing_a)"
|
||||
text+=$'\n\n'
|
||||
text+="$harness_frag"
|
||||
# The harness fragment is about resuming this session on the host, which
|
||||
# for an isolated launch is the one thing it cannot do: the state that
|
||||
# resume reads is the state this mode does not mount. Replaced rather
|
||||
# than dropped, because a session told nothing would reasonably assume
|
||||
# the usual arrangement and leave work for a resume that never comes.
|
||||
#
|
||||
# The fragment is still composed and validated above, so a harness whose
|
||||
# own briefing is broken is refused in this mode too.
|
||||
if [ "${DEJIMA_ISOLATED:-0}" = 1 ]; then
|
||||
text+="$(_briefing_core_isolated_close)"
|
||||
else
|
||||
text+="$harness_frag"
|
||||
fi
|
||||
|
||||
_briefing_substitute "$text" "$forge" "$workflow" "$harness"
|
||||
}
|
||||
|
||||
@@ -40,6 +40,27 @@ share_projects_root() { projects_root "$1"; }
|
||||
# The host directory mounted at ~/shared for a launch against $2.
|
||||
share_dir_for() { project_dir_for "$1" "$2"; }
|
||||
|
||||
# Where an isolated launch's ~/shared lives: a sibling of the per-project
|
||||
# root, holding one directory per SESSION rather than one per project.
|
||||
#
|
||||
# A sibling rather than a slug inside it because an isolated session has no
|
||||
# project to be keyed by, and the one thing this layout must never do is hand
|
||||
# a second session the directory a first one used. A name is not enough to
|
||||
# promise that, which is why the directory below is made rather than
|
||||
# computed.
|
||||
isolated_share_root() { printf '%s/isolated' "${1%/}"; }
|
||||
|
||||
# Makes this session's share directory under that root and prints its path.
|
||||
# Creating is the point: `mktemp -d` is what makes "no other session has this
|
||||
# directory" true for two launches in the same second, where a name built from
|
||||
# a timestamp would silently hand both of them one directory.
|
||||
isolated_share_make() {
|
||||
local root
|
||||
root="$(isolated_share_root "$1")"
|
||||
mkdir -p "$root" || return 1
|
||||
mktemp -d "$root/$(date -u +%Y%m%d-%H%M%S)-XXXXXX"
|
||||
}
|
||||
|
||||
# The slugs belonging to $1: its own, plus one per live git worktree nested
|
||||
# underneath it. One per line, sorted, no duplicates.
|
||||
#
|
||||
|
||||
@@ -230,6 +230,14 @@ RUN mkdir -p $SANDBOX_HOME/.ssh && \
|
||||
chown $SANDBOX_USER:$SANDBOX_USER $SANDBOX_HOME/.ssh && \
|
||||
chmod 700 $SANDBOX_HOME/.ssh
|
||||
|
||||
# The working directory an isolated session gets instead of a project mount.
|
||||
# Outside $SANDBOX_HOME on purpose: bin/dejima still reads this path on the
|
||||
# HOST for a few things (an origin URL, a self-hosting check), so a path the
|
||||
# operator might plausibly have - ~/work - would have those read a real
|
||||
# directory of theirs. Nothing on a host is at /dejima.
|
||||
RUN mkdir -p /dejima/work && \
|
||||
chown -R $SANDBOX_USER:$SANDBOX_USER /dejima
|
||||
|
||||
# Pre-create the nvim data/state/cache dirs owned by the sandbox user,
|
||||
# since they're mounted as named volumes in docker-compose.yml: Docker
|
||||
# seeds a fresh named volume from whatever already exists at the mount
|
||||
@@ -324,6 +332,9 @@ COPY docker/harness-wrapper.sh /usr/local/bin/dejima-harness-wrapper
|
||||
# nothing until something puts them on PATH.
|
||||
COPY docker/git-guard.sh /usr/local/lib/dejima/git-guard.sh
|
||||
COPY docker/forge-guard.sh /usr/local/lib/dejima/forge-guard.sh
|
||||
# The policy both of those apply, and that the two hooks beside them apply to
|
||||
# the same command from the other side. One copy, so a bypass is fixed once.
|
||||
COPY docker/guard-lib.sh /usr/local/lib/dejima/guard-lib.sh
|
||||
# The briefing renderer, which every harness needs and no harness owns. Baked
|
||||
# in rather than reached through the ~/.claude/hooks mount, because that mount
|
||||
# is Claude Code's configuration: sandbox-context.sh calls this, and so does
|
||||
|
||||
@@ -157,7 +157,13 @@ services:
|
||||
# no write scopes). Used by `gh` and, via `gh auth setup-git`, by git
|
||||
# itself for github.com over HTTPS - see the firewall/Dockerfile for
|
||||
# why that's the only path GitHub is reachable through at all.
|
||||
GH_TOKEN: ${GH_TOKEN:-}
|
||||
#
|
||||
# bin/dejima's SANDBOX_ prefixed copy rather than GH_TOKEN itself,
|
||||
# because the filter container needs that one for the authenticated
|
||||
# ranges fetch whatever the session is trusted with, and an isolated
|
||||
# launch is trusted with less. Empty is an ordinary state here: it is
|
||||
# what a host that configured no such token has always had.
|
||||
GH_TOKEN: ${SANDBOX_GH_TOKEN-}
|
||||
# Points git at the gitconfig copy mounted below instead of the
|
||||
# default ~/.gitconfig - see that mount's comment for why it can't
|
||||
# just live at ~/.gitconfig directly.
|
||||
@@ -225,7 +231,16 @@ services:
|
||||
# fixed /workspace) so Claude Code's project/session-history keying
|
||||
# (based on cwd) lines up with the host, letting a session started
|
||||
# here be resumed on the host with `claude --resume`.
|
||||
- ${WORKSPACE_DIR:?set WORKSPACE_DIR to the project directory to mount}:${WORKSPACE_DIR}
|
||||
#
|
||||
# One variable holding the whole spec rather than a source and a target,
|
||||
# because `--isolated` has to mount no host path here at all and an
|
||||
# entry in this list cannot be left empty. There it names the
|
||||
# isolated-work volume below, which bin/dejima gives this run's own name
|
||||
# and removes afterwards - the same rule as the other four, rather than
|
||||
# an anonymous volume whose reaping depends on what `compose run --rm`
|
||||
# does with one. bin/dejima always sets it: ordinarily to the project
|
||||
# directory at its own path, for the keying reason above.
|
||||
- ${SANDBOX_WORKSPACE_MOUNT:?set by bin/dejima - the project directory, or a container-local path for --isolated}
|
||||
# ~/.claude is enumerated, never mounted whole. A single-file bind
|
||||
# mount is pinned to the dentry at its path, so a host-side rename -
|
||||
# a sync daemon, or Claude Code's own atomic writes - unlinks it, the
|
||||
@@ -289,7 +304,10 @@ services:
|
||||
- nvim-state:${HOME}/.local/state/nvim
|
||||
- nvim-cache:${HOME}/.cache/nvim
|
||||
# Required only if SANDBOX_SSH_KEY isn't set - bin/dejima
|
||||
# itself enforces that at least one of the two is present.
|
||||
# itself enforces that at least one of the two is present, and blanks
|
||||
# SSH_AUTH_SOCK when the key is the one chosen, so this mounts nothing
|
||||
# then. A forwarded agent left here signs with the operator's personal
|
||||
# identities for anything in the container that names this path.
|
||||
- ${SSH_AUTH_SOCK:-/dev/null}:/ssh-agent
|
||||
- ${SSH_KNOWN_HOSTS:-/dev/null}:${HOME}/.ssh/known_hosts:ro
|
||||
# Dedicated, purpose-built key for headless git push - see README
|
||||
@@ -316,7 +334,18 @@ services:
|
||||
volumes:
|
||||
# Shell history, shared by every harness - it is the container's shell that
|
||||
# writes it, not the agent.
|
||||
#
|
||||
# Named like the three below so bin/dejima can give an `--isolated` launch
|
||||
# one of its own: this volume is shared by every project, so the default is
|
||||
# a session reading what the last one typed anywhere on the host.
|
||||
#
|
||||
# The default repeats the project name, which is not a typo. An entry with
|
||||
# no `name:` gets `<compose project>_<key>`, and the project here is
|
||||
# `dejima` while the key is `dejima-bashhistory` - so anything shorter is a
|
||||
# DIFFERENT volume, and every ordinary launch would start from an empty
|
||||
# history with the real one orphaned.
|
||||
dejima-bashhistory:
|
||||
name: ${SANDBOX_BASH_HISTORY:-dejima_dejima-bashhistory}
|
||||
# Named explicitly so bin/dejima can give each project its own set. These
|
||||
# persist across containers by design (a plugin install should not have to
|
||||
# happen every launch), which without the split meant one project's session
|
||||
@@ -326,6 +355,11 @@ volumes:
|
||||
#
|
||||
# The defaults reproduce the previous names exactly, so a `docker compose`
|
||||
# invocation that bypasses bin/dejima behaves as it did.
|
||||
# The working directory of an `--isolated` launch, which mounts no host
|
||||
# path there. Referenced only when the workspace mount above names it, so
|
||||
# an ordinary launch neither uses nor creates it.
|
||||
isolated-work:
|
||||
name: ${SANDBOX_ISOLATED_WORK:-dejima_isolated-work}
|
||||
nvim-data:
|
||||
name: ${SANDBOX_NVIM_DATA:-dejima_nvim-data}
|
||||
nvim-state:
|
||||
|
||||
+250
-32
@@ -126,6 +126,11 @@ INTERNAL_OK=${EGRESS_INTERNAL_OK:-}
|
||||
INTERNAL_OK_HINT=${EGRESS_INTERNAL_OK_HINT:-EGRESS_INTERNAL_OK}
|
||||
FORGE_SSH=${EGRESS_FORGE_SSH:-0}
|
||||
HOST_ADDR=${EGRESS_HOST_ADDR:-}
|
||||
# Which TCP ports the host entry opens. Empty - the default - opens none, so
|
||||
# the address is a known destination that is refused rather than an implicit
|
||||
# grant over everything the host publishes. `any` restores the unclamped rule,
|
||||
# explicitly. See the clamp below for why none is the right default.
|
||||
HOST_PORTS=${EGRESS_HOST_PORTS:-}
|
||||
# Self-check probes. example.com is reserved by RFC 2606 for documentation and
|
||||
# resolves globally, which is what makes it a usable negative: if it does not
|
||||
# resolve here, the resolver is restricting names rather than simply broken.
|
||||
@@ -138,6 +143,9 @@ PROBE_AAAA_NAME=${EGRESS_PROBE_AAAA_NAME:-registry.npmjs.org}
|
||||
PROBE_ADDR4=${EGRESS_PROBE_ADDR4:-192.0.2.1}
|
||||
FETCH_ATTEMPTS=${EGRESS_FETCH_ATTEMPTS:-5}
|
||||
FETCH_TIMEOUT=${EGRESS_FETCH_TIMEOUT:-20}
|
||||
# The ceiling on one wait between attempts, so a Retry-After nobody expected
|
||||
# cannot turn a boot into an indefinite hang.
|
||||
FETCH_MAX_BACKOFF=${EGRESS_FETCH_MAX_BACKOFF:-60}
|
||||
|
||||
say() { printf '[egress-policy] %s\n' "$*" >&2; }
|
||||
die() { printf '[egress-policy] FAILED: %s\n' "$*" >&2; exit 1; }
|
||||
@@ -224,15 +232,23 @@ classify_entry() {
|
||||
}
|
||||
|
||||
record_entries() {
|
||||
local class=$1 raw=$2 value kind exemption
|
||||
local class=$1 raw=$2 value kind exemption ok_name
|
||||
for value in ${raw//,/ }; do
|
||||
value=${value%.}
|
||||
[ -n "$value" ] || continue
|
||||
kind=$(classify_entry "$value")
|
||||
exemption=strict
|
||||
case " ${INTERNAL_OK//,/ } " in
|
||||
*" $value "*) exemption=internal-ok ;;
|
||||
esac
|
||||
# Case-folded and dot-stripped on both sides, like build_entries below and
|
||||
# dejima_class_for/dejima_mark_internal on the launcher's side. Compared
|
||||
# verbatim, a pair the launcher had accepted was recorded strict here, and
|
||||
# the boot then refused the name's internal answer while telling the
|
||||
# operator to configure the exemption they had already configured.
|
||||
for ok_name in ${INTERNAL_OK//,/ }; do
|
||||
ok_name=${ok_name%.}
|
||||
if [ "${ok_name,,}" = "${value,,}" ]; then
|
||||
exemption=internal-ok
|
||||
fi
|
||||
done
|
||||
if [ "$exemption" = internal-ok ] && [ "$kind" = addr ]; then
|
||||
# Harmless, but say so: the check it exempts is a check on answers, and
|
||||
# an address the operator typed is not an answer.
|
||||
@@ -272,7 +288,13 @@ build_entries() {
|
||||
local value
|
||||
for value in ${INTERNAL_OK//,/ }; do
|
||||
[ -n "$value" ] || continue
|
||||
listed=$(awk -v v="${value%.}" '$4 == v' "$ENTRIES_FILE")
|
||||
# Case-folded on both sides, like dejima_class_for and dejima_mark_internal
|
||||
# on the launcher's side of this. DNS names are case-insensitive and the
|
||||
# resolver matches them so, and comparing verbatim here refused a launch
|
||||
# whose configuration was correct - the launcher's own check had already
|
||||
# passed on the same pair, so the refusal arrived from the wrong layer with
|
||||
# a message saying the name was permitted by nothing.
|
||||
listed=$(awk -v v="${value%.}" 'BEGIN { v = tolower(v) } tolower($4) == v' "$ENTRIES_FILE")
|
||||
[ -n "$listed" ] || missing+=("$value")
|
||||
done
|
||||
[ ${#missing[@]} -eq 0 ] \
|
||||
@@ -343,8 +365,20 @@ host_of_url() {
|
||||
# through the policy. The session container has not been started at this point -
|
||||
# the launcher waits for the readiness file - so the window is not one the agent
|
||||
# can be in.
|
||||
# The seconds the endpoint asked us to wait, if it asked. Header names are
|
||||
# case-insensitive, and the value is either a count of seconds or an HTTP date
|
||||
# - only the numeric form is honoured, because a date needs parsing to compare
|
||||
# against a clock and a wrong answer there is a sleep nobody chose.
|
||||
retry_after_from() {
|
||||
local v
|
||||
v=$(tr -d '\r' < "$1" 2>/dev/null \
|
||||
| awk -F': *' 'tolower($1) == "retry-after" { print $2; exit }') || return 1
|
||||
case "$v" in ''|*[!0-9]*) return 1 ;; esac
|
||||
printf '%s' "$v"
|
||||
}
|
||||
|
||||
fetch_forge_ranges() {
|
||||
local out=$1 attempt rc http err
|
||||
local out=$1 attempt rc http err retry wait limited
|
||||
# Authenticated when the launcher supplied a token. The endpoint this
|
||||
# defaults to is rate-limited per source address, unauthenticated, across
|
||||
# everything on the host - so a host that starts sandboxes in a loop exhausts
|
||||
@@ -353,15 +387,38 @@ fetch_forge_ranges() {
|
||||
# the launcher's to choose.
|
||||
local -a auth=()
|
||||
[ -n "$FORGE_META_AUTH" ] && auth=(-H "$FORGE_META_AUTH")
|
||||
|
||||
# A file:// source is a test fixture and nothing else. test/runtime.bats
|
||||
# starts twenty-seven container pairs in one run, each of which fetched the
|
||||
# ranges for itself - which is what trips the endpoint's secondary limit
|
||||
# (#357) - so the suite fetches once and points every pair at the result.
|
||||
#
|
||||
# Refused outside a test container, because the ranges decide which addresses
|
||||
# the forge class reaches: a launch that could name its own source could name
|
||||
# its own ranges. SANDBOX_TEST_MODE is set by that suite and by nothing in
|
||||
# bin/dejima, so there is no launch this is reachable from.
|
||||
case "$FORGE_META_URL" in
|
||||
file://*)
|
||||
[ "${SANDBOX_TEST_MODE:-0}" = 1 ] \
|
||||
|| die "EGRESS_FORGE_META_URL names a file:// source, which is a test fixture only. The forge's address ranges decide what the forge class reaches, so they are not read from the filesystem in a real launch."
|
||||
;;
|
||||
esac
|
||||
|
||||
for ((attempt = 1; attempt <= FETCH_ATTEMPTS; attempt++)); do
|
||||
rc=0
|
||||
: > "$out.hdr"
|
||||
http=$(curl --silent --show-error --location \
|
||||
--max-time "$FETCH_TIMEOUT" \
|
||||
--write-out '%{http_code}' \
|
||||
--dump-header "$out.hdr" \
|
||||
--output "$out.part" \
|
||||
${auth[@]+"${auth[@]}"} \
|
||||
"$FORGE_META_URL" 2>"$out.err") || rc=$?
|
||||
if [ "$rc" -eq 0 ] && [ "$http" = 200 ]; then
|
||||
# curl reports no status for a scheme that has none, so what counts as a
|
||||
# good transfer is the scheme's own answer rather than 200 always.
|
||||
local want_http=200
|
||||
case "$FORGE_META_URL" in file://*) want_http=000 ;; esac
|
||||
if [ "$rc" -eq 0 ] && [ "$http" = "$want_http" ]; then
|
||||
# Whether the body carries ranges is part of whether the fetch worked,
|
||||
# so it is decided here rather than after the loop. A rate limit is the
|
||||
# reason: it answers with a message and no ranges, sometimes over a 200,
|
||||
@@ -393,12 +450,39 @@ fetch_forge_ranges() {
|
||||
# reason at all - which is the diagnostic this whole path exists to give.
|
||||
err=$(jq -re '.message' < "$out.part" 2>/dev/null || true)
|
||||
[ -n "$err" ] || err="response carried no ranges"
|
||||
say "attempt $attempt/$FETCH_ATTEMPTS to fetch $FORGE_META_URL failed: $err"
|
||||
say "attempt $attempt/$FETCH_ATTEMPTS to fetch $FORGE_META_URL failed: http $http, $err"
|
||||
else
|
||||
err=$(tr '\n' ' ' < "$out.err")
|
||||
# The body first, because an HTTP failure from this endpoint carries one
|
||||
# and curl's stderr does not: "API rate limit exceeded" names the cause,
|
||||
# where "curl exit 0, http 403" names only the symptom. curl's own
|
||||
# message is what is left when there was no response to carry a body.
|
||||
err=$(jq -re '.message' < "$out.part" 2>/dev/null || true)
|
||||
[ -n "$err" ] || err=$(tr '\n' ' ' < "$out.err")
|
||||
say "attempt $attempt/$FETCH_ATTEMPTS to fetch $FORGE_META_URL failed: curl exit $rc, http ${http:-none}${err:+, }$err"
|
||||
fi
|
||||
[ "$attempt" -lt "$FETCH_ATTEMPTS" ] && sleep $((attempt * 2))
|
||||
|
||||
# A rate limit is the one failure here that clears by waiting, and the only
|
||||
# one worth waiting long for. GitHub reports a secondary limit as 403 with
|
||||
# a Retry-After, and a primary one as 403 or 429 - but it will also answer
|
||||
# 200 with a message and no ranges, so the body counts as evidence too.
|
||||
limited=0
|
||||
case "$http" in 403|429) limited=1 ;; esac
|
||||
case "${err,,}" in *"rate limit"*|*"abuse"*|*"too many requests"*) limited=1 ;; esac
|
||||
|
||||
# attempt*2 spends twenty seconds over five tries, and a secondary limit is
|
||||
# measured in tens of seconds - so the whole sequence could sit inside one
|
||||
# window and exhaust itself without ever reaching the other side (#357).
|
||||
wait=$((attempt * 2))
|
||||
[ "$limited" -eq 1 ] && wait=$((attempt * 15))
|
||||
# What the endpoint asked for beats what this guessed, which is the only
|
||||
# number here that is not an estimate.
|
||||
retry=$(retry_after_from "$out.hdr") && wait=$retry
|
||||
[ "$wait" -le "$FETCH_MAX_BACKOFF" ] || wait=$FETCH_MAX_BACKOFF
|
||||
if [ "$attempt" -lt "$FETCH_ATTEMPTS" ]; then
|
||||
say "waiting ${wait}s before attempt $((attempt + 1))${retry:+ (Retry-After: ${retry}s)}"
|
||||
sleep "$wait"
|
||||
fi
|
||||
retry=""
|
||||
done
|
||||
die "could not fetch the forge address ranges after $FETCH_ATTEMPTS attempts; the reason for each is above. The sandbox cannot start without them - the policy would refuse every forge range, so git, gh and the API would all fail once inside."
|
||||
}
|
||||
@@ -435,6 +519,56 @@ emit_forge_clamp() {
|
||||
printf ' %s daddr @%s goto refuse\n' "$family" "$set"
|
||||
}
|
||||
|
||||
# The host entry is the one destination nobody declared: it is the gateway of
|
||||
# the default route, derived rather than configured. Docker publishes container
|
||||
# ports on that address, so permitting it on every port reached the host's sshd,
|
||||
# anything bound to 0.0.0.0, and every published port of every other container
|
||||
# on the machine - including the remote-access front door dejima itself ships,
|
||||
# which is a host shell behind a tmux prefix key (#363).
|
||||
#
|
||||
# Nothing in a launch needs it. A host-resident forge, Loki or self-served model
|
||||
# is reached through its own entry, by name or as a literal address, and either
|
||||
# way lands in a set of its own. So the default here is no ports at all, and the
|
||||
# address stays a named destination that is refused rather than one that falls
|
||||
# through to the end of the chain - the counter says the host was reached for,
|
||||
# which an operator debugging a clamp needs to see.
|
||||
#
|
||||
# TCP only: nothing here has ever permitted UDP to the host, and DNS to it is
|
||||
# refused above with everyone else's.
|
||||
emit_host_clamp() {
|
||||
local set=$1 p list=""
|
||||
if [ "$HOST_PORTS" = any ]; then
|
||||
printf ' ip daddr @%s goto permit\n' "$set"
|
||||
return
|
||||
fi
|
||||
# Rebuilt element by element rather than by substituting the separators: the
|
||||
# value may be comma-separated, space-separated or both, and a substitution
|
||||
# turns ", " into an empty element that nft rejects.
|
||||
for p in ${HOST_PORTS//,/ }; do
|
||||
list="${list:+$list, }$p"
|
||||
done
|
||||
if [ -n "$list" ]; then
|
||||
printf ' ip daddr @%s tcp dport { %s } goto permit\n' "$set" "$list"
|
||||
fi
|
||||
printf ' ip daddr @%s goto refuse\n' "$set"
|
||||
}
|
||||
|
||||
# A typo stops the container rather than becoming a rule that permits the wrong
|
||||
# port or a ruleset nft refuses after the fact, which is the same reasoning as
|
||||
# classify_entry's: a destination that looks configured and is silently refused
|
||||
# is the worst of the three.
|
||||
validate_host_ports() {
|
||||
local p
|
||||
[ "$HOST_PORTS" = any ] && return 0
|
||||
for p in ${HOST_PORTS//,/ }; do
|
||||
[[ $p =~ ^[0-9]+(-[0-9]+)?$ ]] \
|
||||
|| die "EGRESS_HOST_PORTS entry '$p' is not a TCP port or a low-high range (or the whole value 'any')"
|
||||
local lo=${p%%-*} hi=${p##*-}
|
||||
{ [ "$lo" -ge 1 ] && [ "$hi" -le 65535 ] && [ "$lo" -le "$hi" ]; } \
|
||||
|| die "EGRESS_HOST_PORTS entry '$p' is not within 1-65535, low first"
|
||||
done
|
||||
}
|
||||
|
||||
# Rules are generated into one file and loaded with `nft -f`, which is a single
|
||||
# transaction: the namespace is never left half-filtered. The table is replaced
|
||||
# rather than the ruleset flushed - the runtime has its own rules in this
|
||||
@@ -642,8 +776,12 @@ EOF
|
||||
ip daddr @oname_v4 goto permit
|
||||
ip daddr @literal_v4 goto permit
|
||||
|
||||
# The container's own host, as a single address.
|
||||
ip daddr @host_v4 goto permit
|
||||
# The container's own host, as a single address, on the ports it was told
|
||||
# to open and no others.
|
||||
EOF
|
||||
emit_host_clamp host_v4
|
||||
|
||||
cat <<EOF
|
||||
|
||||
# Anything else: logged, then refused. The chain policy is drop as a
|
||||
# backstop, but nothing should ever reach it.
|
||||
@@ -757,9 +895,21 @@ EOF
|
||||
fi
|
||||
}
|
||||
|
||||
# The forge credential, removed from a daemon's environment at the point it
|
||||
# starts rather than somewhere upstream of it. All three of these parse content
|
||||
# the session's own traffic decides, and all three are reachable by more than
|
||||
# one call path: `dejima-allow` runs `egress-policy permit` through `docker
|
||||
# exec`, which gets the filter container's configured environment, and both
|
||||
# permit_name and rollback_entries restart the resolver. An unset earlier in
|
||||
# one path says nothing about the others; this says it where it has to hold.
|
||||
#
|
||||
# `env -u` rather than an unset, so the scrub is visible on the line that
|
||||
# starts the daemon and cannot be separated from it by an edit elsewhere.
|
||||
readonly NO_CREDENTIAL=(env -u GH_TOKEN -u EGRESS_FORGE_META_AUTH)
|
||||
|
||||
start_resolver() {
|
||||
stop_daemon "$RESOLVER_PID" dnsmasq
|
||||
dnsmasq -C "$RESOLVER_CONF" || die "the resolver did not start"
|
||||
"${NO_CREDENTIAL[@]}" dnsmasq -C "$RESOLVER_CONF" || die "the resolver did not start"
|
||||
wait_for_daemon dnsmasq
|
||||
|
||||
# Waiting for the process is not waiting for the resolver. dnsmasq
|
||||
@@ -848,7 +998,7 @@ start_connection_log() {
|
||||
stop_daemon "$ULOGD_PID" ulogd
|
||||
id -u "$LOGGER_USER" >/dev/null 2>&1 \
|
||||
|| die "the image did not create the $LOGGER_USER account, which the connection logger drops to"
|
||||
ulogd -d -u "$LOGGER_USER" -c "$ULOGD_CONF" -p "$ULOGD_PID" \
|
||||
"${NO_CREDENTIAL[@]}" ulogd -d -u "$LOGGER_USER" -c "$ULOGD_CONF" -p "$ULOGD_PID" \
|
||||
|| die "the connection logger did not start"
|
||||
wait_for_daemon ulogd
|
||||
}
|
||||
@@ -871,7 +1021,7 @@ start_lookup_log() {
|
||||
# resolver's log - and that is the last thing in this container that should
|
||||
# be running with privilege. It opens two files and then only reads and
|
||||
# writes them, so the drop costs it nothing.
|
||||
nohup gosu "$LOGGER_USER" "$LOOKUP_LOGGER" --source "$RESOLVER_LOG" --output "$LOOKUP_LOG" \
|
||||
nohup "${NO_CREDENTIAL[@]}" gosu "$LOGGER_USER" "$LOOKUP_LOGGER" --source "$RESOLVER_LOG" --output "$LOOKUP_LOG" \
|
||||
>/dev/null 2>>"$LOGDIR/lookup-log.err" &
|
||||
echo $! > "$LOOKUP_PID"
|
||||
sleep 0.3
|
||||
@@ -939,8 +1089,31 @@ retry_positive() {
|
||||
# Ordered so that a failure names its own cause: the positive checks come
|
||||
# first, because "a blocked destination was refused" means nothing until
|
||||
# something that should work has been shown to work.
|
||||
# Which permitted name the resolver checks are about.
|
||||
#
|
||||
# Normally the meta URL's host, which is a permitted forge-class name by
|
||||
# construction. A file:// source has no host at all - that is the suite's
|
||||
# cached-ranges fixture (#357) - and handing an empty name to check 3 asked the
|
||||
# resolver to look up "" and failed the boot on it.
|
||||
#
|
||||
# The fallback is a permitted forge-class name, because the property those
|
||||
# checks state is about the POLICY: that the resolver resolves a permitted name
|
||||
# and files its address in that name's own set. Where the ranges were read from
|
||||
# is a different question, and check 5's.
|
||||
#
|
||||
# sed rather than head: a producer whose pipe closes early takes SIGPIPE, and
|
||||
# pipefail makes that the pipeline's status - see the note on grep -q above.
|
||||
verify_subject() {
|
||||
local host=$1
|
||||
if [ -n "$host" ]; then
|
||||
printf '%s' "$host"
|
||||
return 0
|
||||
fi
|
||||
entries_of forge name | sed -n '1p'
|
||||
}
|
||||
|
||||
verify() {
|
||||
local forge_host=$1 rc answer set_name
|
||||
local forge_host=$1 rc answer set_name subject
|
||||
|
||||
pgrep -x dnsmasq >/dev/null || die "check 1: the resolver is not running"
|
||||
pgrep -x ulogd >/dev/null || die "check 1: the connection logger is not running"
|
||||
@@ -954,9 +1127,13 @@ verify() {
|
||||
[ "$running_as" = "$RESOLVER_USER" ] \
|
||||
|| die "check 2: the resolver is running as '${running_as:-unknown}', not $RESOLVER_USER, so the rule permitting it to reach an upstream resolver is keyed to the wrong account"
|
||||
|
||||
answer=$(retry_positive 3 resolve_one "$forge_host") || true
|
||||
subject=$(verify_subject "$forge_host")
|
||||
[ -n "$subject" ] \
|
||||
|| die "check 3: there is no permitted forge-class name to check the resolver against, and no host in $FORGE_META_URL to fall back on"
|
||||
|
||||
answer=$(retry_positive 3 resolve_one "$subject") || true
|
||||
[ -n "$answer" ] \
|
||||
|| die "check 3: the permitted name $forge_host did not resolve through the local resolver - name resolution is broken, not restricted.$(why_no_answer "$forge_host")"
|
||||
|| die "check 3: the permitted name $subject did not resolve through the local resolver - name resolution is broken, not restricted.$(why_no_answer "$subject")"
|
||||
|
||||
# The resolver is also what puts a permitted name's addresses into the
|
||||
# allow-list, and which set it uses is the name's class. Checking membership
|
||||
@@ -970,13 +1147,25 @@ verify() {
|
||||
# two cannot look in different sets.
|
||||
set_name=$(set_for_class forge)
|
||||
nft get element inet "$TABLE" "$set_name" "{ $answer }" >/dev/null 2>&1 \
|
||||
|| die "check 4: $forge_host resolved to $answer but the resolver did not add it to $set_name - permitted names would resolve and then be refused"
|
||||
|| die "check 4: $subject resolved to $answer but the resolver did not add it to $set_name - permitted names would resolve and then be refused"
|
||||
|
||||
rc=0
|
||||
retry_positive 3 curl --silent --show-error --output /dev/null \
|
||||
--connect-timeout 5 --max-time 20 "$FORGE_META_URL" || rc=$?
|
||||
[ "$rc" -eq 0 ] \
|
||||
|| die "check 5: the permitted destination $FORGE_META_URL is not reachable through the policy (curl exit $rc)"
|
||||
# Skipped for a file:// source, and said out loud. This check asserts that a
|
||||
# permitted destination is reachable THROUGH the policy, and a local file is
|
||||
# not one - reaching it would pass while proving nothing. That is a real
|
||||
# reduction on the runs using the cached ranges, which is why it is a line in
|
||||
# the log rather than a silent pass.
|
||||
case "$FORGE_META_URL" in
|
||||
file://*)
|
||||
say "check 5 skipped: the ranges came from a file, so reading it again says nothing about what the policy permits"
|
||||
;;
|
||||
*)
|
||||
rc=0
|
||||
retry_positive 3 curl --silent --show-error --output /dev/null \
|
||||
--connect-timeout 5 --max-time 20 "$FORGE_META_URL" || rc=$?
|
||||
[ "$rc" -eq 0 ] \
|
||||
|| die "check 5: the permitted destination $FORGE_META_URL is not reachable through the policy (curl exit $rc)"
|
||||
;;
|
||||
esac
|
||||
|
||||
# The same address, on the port the opt-in gates. With the opt-in off it must
|
||||
# be refused even though the address was reached by name, which is the whole
|
||||
@@ -1229,7 +1418,12 @@ resolve_one() {
|
||||
local answers line
|
||||
# A only: the resolver is configured to answer no AAAA at all, so asking for
|
||||
# one spends a query to be told nothing.
|
||||
answers=$(dig +short +time=3 +tries=2 @127.0.0.1 "$1" A 2>/dev/null) || return 0
|
||||
# Non-zero when dig itself failed, not zero: that is a lookup which did not
|
||||
# come back - a resolver a moment behind the container is exactly it - and so
|
||||
# the one case the positive checks' retry exists to absorb. Returning 0 there
|
||||
# reported success with no address, which retry_positive takes as an answer
|
||||
# and the caller then dies on (#345).
|
||||
answers=$(dig +short +time=3 +tries=2 @127.0.0.1 "$1" A 2>/dev/null) || return 1
|
||||
while IFS= read -r line; do
|
||||
[[ "$line" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]] || continue
|
||||
printf '%s\n' "$line"
|
||||
@@ -1292,6 +1486,7 @@ cmd_apply() {
|
||||
discover_upstreams
|
||||
|
||||
local host_addrs forge_host
|
||||
validate_host_ports
|
||||
host_addrs=$(discover_host_addr)
|
||||
forge_host=$(host_of_url "$FORGE_META_URL")
|
||||
|
||||
@@ -1306,6 +1501,11 @@ cmd_apply() {
|
||||
|
||||
local meta="$RUNDIR/forge-meta.json"
|
||||
fetch_forge_ranges "$meta"
|
||||
# The one request that needed the credential has been made, so it stops here
|
||||
# for the rest of this process too. Each daemon start scrubs its own
|
||||
# environment as well (NO_CREDENTIAL), because `permit` reaches them without
|
||||
# ever coming through here.
|
||||
unset FORGE_META_AUTH EGRESS_FORGE_META_AUTH
|
||||
|
||||
local ranges forge4
|
||||
ranges=$(jq -r "$FORGE_META_FILTER" < "$meta" | grep -v '^null$' | grep -v '^$') \
|
||||
@@ -1325,7 +1525,7 @@ cmd_apply() {
|
||||
resolver_uid=$(id -u "$RESOLVER_USER")
|
||||
|
||||
apply_ruleset "$forge4" "$host_addrs" "$resolver_uid"
|
||||
say "policy applied: $(printf '%s\n' "$ranges" | wc -l) forge ranges, host $(printf '%s ' $host_addrs), forge ssh=$FORGE_SSH"
|
||||
say "policy applied: $(printf '%s\n' "$ranges" | wc -l) forge ranges, host $(printf '%s ' $host_addrs) on ports ${HOST_PORTS:-none}, forge ssh=$FORGE_SSH"
|
||||
|
||||
write_log_config
|
||||
start_connection_log
|
||||
@@ -1412,13 +1612,26 @@ rollback_entries() {
|
||||
# An address needs no resolution and no restart: it goes straight into the sets
|
||||
# its class uses. Both sets are interval sets, so a prefix works as well as a
|
||||
# single address.
|
||||
# A forge-class address belongs in both sets, and the adds are not one
|
||||
# transaction: if the second fails, the first has to come back out. Otherwise
|
||||
# the address the forge class says is HTTPS-only is left in literal_v4, which
|
||||
# permits every port, while the entries file rolls back - so nothing reports a
|
||||
# grant that is live for the rest of the session.
|
||||
#
|
||||
# Deleting it is unconditional, which can take back an element that was in that
|
||||
# set from somewhere else. That direction is safe: the refusal already rolled
|
||||
# the record back, so a narrower live set agrees with what `entries` says.
|
||||
permit_address() {
|
||||
local class=$1 value=$2
|
||||
nft add element inet "$TABLE" literal_v4 "{ $value }" \
|
||||
|| { mv "$ENTRIES_FILE.prev" "$ENTRIES_FILE"; die "could not add $value to the allow-list"; }
|
||||
if [ "$class" = forge ]; then
|
||||
nft add element inet "$TABLE" forge_v4 "{ $value }" \
|
||||
|| { mv "$ENTRIES_FILE.prev" "$ENTRIES_FILE"; die "could not add $value to the forge class"; }
|
||||
nft add element inet "$TABLE" forge_v4 "{ $value }" || {
|
||||
nft delete element inet "$TABLE" literal_v4 "{ $value }" 2>/dev/null \
|
||||
|| say "WARNING: $value is still in the any-port allow-list and could not be removed; restart the session"
|
||||
mv "$ENTRIES_FILE.prev" "$ENTRIES_FILE"
|
||||
die "could not add $value to the forge class"
|
||||
}
|
||||
fi
|
||||
say "$value permitted ($class class, direct)"
|
||||
}
|
||||
@@ -1429,7 +1642,7 @@ permit_address() {
|
||||
# up in the set its class uses. A name that resolved but never reached the
|
||||
# allow-list would fail later as a mystery, so it is undone here instead.
|
||||
permit_name() {
|
||||
local class=$1 name=$2 set_prefix addr addrs ok='' mark
|
||||
local class=$1 name=$2 set_name addr addrs ok='' mark
|
||||
|
||||
write_resolver_config
|
||||
mark=$(wc -c < "$RESOLVER_LOG")
|
||||
@@ -1449,13 +1662,18 @@ permit_name() {
|
||||
die "$name returned no address - the name may not exist, or its upstream may be unreachable. Nothing was changed."
|
||||
fi
|
||||
|
||||
[ "$class" = forge ] && set_prefix=fname || set_prefix=oname
|
||||
# Through the same helper as the config that fills the set and the check that
|
||||
# reads it back at boot. This was the fourth place naming a set for a class,
|
||||
# and the one the helper's own comment claimed did not exist - a live add
|
||||
# looking in a different set from the one being filled would report a name as
|
||||
# unreachable while it was permitted, or the reverse.
|
||||
set_name=$(set_for_class "$class")
|
||||
for addr in $addrs; do
|
||||
nft get element inet "$TABLE" "${set_prefix}_v4" "{ $addr }" >/dev/null 2>&1 && ok=yes
|
||||
nft get element inet "$TABLE" "$set_name" "{ $addr }" >/dev/null 2>&1 && ok=yes
|
||||
done
|
||||
if [ -z "$ok" ]; then
|
||||
rollback_entries
|
||||
die "$name resolved to [$(printf '%s ' $addrs)] but none of those addresses reached the ${set_prefix} sets; nothing was changed"
|
||||
die "$name resolved to [$(printf '%s ' $addrs)] but none of those addresses reached $set_name; nothing was changed"
|
||||
fi
|
||||
say "$name permitted ($class class, $exemption): $(printf '%s ' $addrs)"
|
||||
}
|
||||
|
||||
+29
-25
@@ -44,7 +44,10 @@ fi
|
||||
# involved), leaving the tty root-owned. nvim's clipboard fallback needs to
|
||||
# write to it directly as the sandbox user, so fix the ownership up here,
|
||||
# before dropping privileges.
|
||||
if [ -t 1 ]; then
|
||||
# Both halves read stdin, because that is what `tty` reports on. Testing stdout
|
||||
# instead meant a container with one attached and the other not produced an
|
||||
# empty path and a chown that silently did nothing.
|
||||
if [ -t 0 ]; then
|
||||
chown "$SANDBOX_USER" "$(tty)" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
@@ -70,6 +73,9 @@ fi
|
||||
# Mounted at /root (not under $SANDBOX_HOME) so only this root-run block
|
||||
# can ever read it - copied out, loaded into the agent, and the copy
|
||||
# deleted, all before dropping to the sandbox user below.
|
||||
# The forwarded agent is not mounted when this key is configured (bin/dejima
|
||||
# blanks SSH_AUTH_SOCK), so this is the only agent in the container rather than
|
||||
# the preferred one of two.
|
||||
SANDBOX_SSH_KEY_SRC="/root/.ssh-sandbox-key-src"
|
||||
if [ -s "$SANDBOX_SSH_KEY_SRC" ]; then
|
||||
install -o "$SANDBOX_USER" -g "$SANDBOX_USER" -m 600 "$SANDBOX_SSH_KEY_SRC" "$SANDBOX_HOME/.ssh/sandbox-key"
|
||||
@@ -90,31 +96,25 @@ fi
|
||||
# Linked here rather than COPYed into place at build time because both depend
|
||||
# on values that are only known per launch: which hosts this forge may be
|
||||
# pushed to, and which subcommand this workflow blocks.
|
||||
if [ -n "${DEJIMA_PUSH_HOSTS:-}${DEJIMA_BLOCKED_BRANCHES:-}" ]; then
|
||||
# /usr/local/bin precedes /usr/bin, so this shadows git without moving it
|
||||
# and the guard execs /usr/bin/git directly.
|
||||
ln -sf /usr/local/lib/dejima/git-guard.sh /usr/local/bin/git
|
||||
fi
|
||||
|
||||
# Every CLI the shadow has something to do about: the one whose merge a
|
||||
# workflow blocks, and every one that writes a body the session authored. More
|
||||
# than one, because a forge can have a CLI per artifact rather than one with
|
||||
# subcommands, and because a forge with nothing blocked can still have
|
||||
# something to disclose.
|
||||
# The git shadow is installed unconditionally, because DEJIMA_PUSH_HOSTS fails
|
||||
# CLOSED: empty means no host may be pushed to, which is how a no-forge or
|
||||
# non-pushing workflow becomes enforced rather than described. Gating the
|
||||
# shadow on a non-empty value would leave it absent in exactly that case - and
|
||||
# local-only, the shipped default, is exactly that case.
|
||||
#
|
||||
# The first word of each record names the CLI. The real one is already behind
|
||||
# the symlink this replaces (see install-forge-bins.sh), so the guard has a
|
||||
# fixed path to exec.
|
||||
_shadow_clis() {
|
||||
[ -z "${DEJIMA_MERGE_BLOCK:-}" ] || printf '%s\n' "${DEJIMA_MERGE_BLOCK%% *}"
|
||||
# "<kind> <cli> <subcommand>...", so the CLI is the second word.
|
||||
[ -z "${DEJIMA_DISCLOSE:-}" ] || printf '%s\n' "$DEJIMA_DISCLOSE" |
|
||||
while read -r _ _cli _; do
|
||||
[ -z "$_cli" ] || printf '%s\n' "$_cli"
|
||||
done
|
||||
}
|
||||
# /usr/local/bin precedes /usr/bin, so this shadows git without moving it and
|
||||
# the guard execs /usr/bin/git directly.
|
||||
ln -sf /usr/local/lib/dejima/git-guard.sh /usr/local/bin/git
|
||||
|
||||
_shadow_cli_list="$(_shadow_clis | sort -u)"
|
||||
# Every CLI the shadow has something to do about. Derived by the same function
|
||||
# hooks/merge-guard.sh uses to decide whether a command is one of these, so the
|
||||
# set the shadow is linked over and the set the hook checks against cannot
|
||||
# disagree. The real CLI is already behind the symlink this replaces (see
|
||||
# install-forge-bins.sh), so the guard has a fixed path to exec.
|
||||
# shellcheck source=guard-lib.sh
|
||||
source "${DEJIMA_GUARD_LIB:-/usr/local/lib/dejima/guard-lib.sh}"
|
||||
|
||||
_shadow_cli_list="$(dejima_forge_commands | sort -u)"
|
||||
if [ -n "$_shadow_cli_list" ]; then
|
||||
while read -r _cli; do
|
||||
[ -n "$_cli" ] || continue
|
||||
@@ -315,7 +315,11 @@ if [ -n "${SANDBOX_HARNESS_BRIEFING_ARGS:-}" ] || [ -n "${SANDBOX_HARNESS_BRIEFI
|
||||
mkdir -p /etc/dejima
|
||||
# Written and renamed, so a harness starting while this is written reads
|
||||
# either the whole briefing or no file at all.
|
||||
if ! /usr/local/lib/dejima/dejima-briefing-render.sh "" "$PWD" \
|
||||
# As the sandbox user, not as root. It reads the project's own .dejima.md
|
||||
# and asks the package managers what is installed, with its cwd inside the
|
||||
# workspace the session can write - and the file it produces is written by
|
||||
# the redirect out here, which root still owns.
|
||||
if ! gosu "$SANDBOX_USER" /usr/local/lib/dejima/dejima-briefing-render.sh "" "$PWD" \
|
||||
> "$DEJIMA_BRIEFING_RENDERED.part"; then
|
||||
rm -f "$DEJIMA_BRIEFING_RENDERED.part"
|
||||
echo "entrypoint: could not render the session briefing. Refusing to" >&2
|
||||
|
||||
+35
-2
@@ -101,8 +101,28 @@ fi
|
||||
|
||||
_join() { local IFS=' '; echo "${*:-}"; }
|
||||
|
||||
EGRESS_FORGE_NAMES="$(_join ${DEJIMA_FORGE_CLASS[@]+"${DEJIMA_FORGE_CLASS[@]}"})"
|
||||
EGRESS_ALLOWED_NAMES="$(_join ${DEJIMA_OPEN_CLASS[@]+"${DEJIMA_OPEN_CLASS[@]}"})"
|
||||
# The forge class wins wherever the two overlap, applied here rather than
|
||||
# asserted in a comment. Every route into the open class is normalised, not just
|
||||
# the approved-domains loop: the built-in list carries
|
||||
# release-assets.githubusercontent.com, which is under a forge suffix, and a
|
||||
# harness or backend domain under one would arrive the same way. Left alone, such
|
||||
# a name gets a resolver line more specific than the suffix entry, feeding the
|
||||
# open-class set - and the port restriction then rests on the forge's published
|
||||
# ranges alone, which is one layer where the design has two.
|
||||
_forge_extra=""
|
||||
_open_kept=""
|
||||
for _domain in ${DEJIMA_OPEN_CLASS[@]+"${DEJIMA_OPEN_CLASS[@]}"}; do
|
||||
if [ "$(dejima_class_for "$_domain")" = forge ]; then
|
||||
_forge_extra="$_forge_extra $_domain"
|
||||
else
|
||||
_open_kept="$_open_kept $_domain"
|
||||
fi
|
||||
done
|
||||
|
||||
# shellcheck disable=SC2086
|
||||
EGRESS_FORGE_NAMES="$(_join ${DEJIMA_FORGE_CLASS[@]+"${DEJIMA_FORGE_CLASS[@]}"} $_forge_extra)"
|
||||
# shellcheck disable=SC2086
|
||||
EGRESS_ALLOWED_NAMES="$(_join $_open_kept)"
|
||||
EGRESS_INTERNAL_OK="$(_join ${DEJIMA_INTERNAL_CLASS[@]+"${DEJIMA_INTERNAL_CLASS[@]}"})"
|
||||
# What the policy should call that list when it tells an operator to add
|
||||
# something to it. It knows the variable it reads; only dejima knows the
|
||||
@@ -116,8 +136,17 @@ export EGRESS_FORGE_SSH="${ALLOW_GITHUB_PUSH:-0}"
|
||||
# was available. Same read-only token `gh` already gets (docker-compose.yml),
|
||||
# sent to the API it belongs to and nowhere else.
|
||||
[ -n "${GH_TOKEN:-}" ] && export EGRESS_FORGE_META_AUTH="Authorization: Bearer $GH_TOKEN"
|
||||
# The raw token has no other use here, and the header is the only thing the
|
||||
# policy needs it for. Both are a credential anything started below has no
|
||||
# reason to hold: they parse content the session's own traffic decides.
|
||||
unset GH_TOKEN
|
||||
|
||||
/usr/local/sbin/egress-policy apply
|
||||
# The header carries the token in another wrapper, so it goes the moment the
|
||||
# request that needed it is made. egress-policy scrubs its own daemons at the
|
||||
# line that starts each one; nothing started below this point can hold either
|
||||
# half, which is how the shipper is covered.
|
||||
unset EGRESS_FORGE_META_AUTH
|
||||
|
||||
# Best-effort visibility on top of the enforcement above, not part of it -
|
||||
# log-shipper.sh no-ops if LOKI_URL isn't set. It stays root, deliberately:
|
||||
@@ -134,4 +163,8 @@ export EGRESS_FORGE_SSH="${ALLOW_GITHUB_PUSH:-0}"
|
||||
# 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 watch.
|
||||
# No `env -u` prefix here, unlike the daemons egress-policy starts: drop_caps
|
||||
# is a shell function, and env can only exec a binary - so a prefix turns this
|
||||
# line into exit 127, which the `&` hides from set -e. The two unsets above
|
||||
# already ran, which is what this one relies on instead.
|
||||
drop_caps -- -c 'exec "$@"' dejima /usr/local/bin/log-shipper.sh &
|
||||
|
||||
@@ -58,11 +58,13 @@ DEJIMA_OPEN_CLASS=(
|
||||
|
||||
DEJIMA_INTERNAL_CLASS=()
|
||||
|
||||
# release-assets.githubusercontent.com is under a forge-class suffix, and the
|
||||
# forge class wins wherever the two overlap - by design, not by accident. It is
|
||||
# fetched over HTTPS like everything else under that suffix, so the narrower
|
||||
# class costs it nothing and the alternative would be a name that quietly
|
||||
# widened the forge.
|
||||
# release-assets.githubusercontent.com is under a forge-class suffix, so it ends
|
||||
# up in the forge class: firewall-up.sh routes every open-class entry through
|
||||
# dejima_class_for, this list included, rather than only the ones a project
|
||||
# declared. It is fetched over HTTPS like everything else under that suffix, so
|
||||
# the narrower class costs it nothing, and the alternative is a name that
|
||||
# quietly widens the forge - which is what it did until the normalisation
|
||||
# covered this list too.
|
||||
|
||||
# Split on spaces explicitly, not on whatever IFS the caller happens to have,
|
||||
# so every consumer gets the same list from the same value. A harness declaring
|
||||
|
||||
+25
-187
@@ -3,21 +3,22 @@
|
||||
# one subcommand a workflow blocks is refused, a command aimed at another
|
||||
# repository is refused, and a body the session authored says so.
|
||||
#
|
||||
# The first rule is the one hooks/merge-guard.sh applies - a merge is the
|
||||
# operator's decision, so the sandbox opens a PR and stops - moved off the
|
||||
# harness's hook protocol and onto the command. It works for a harness that
|
||||
# cannot intercept a tool call, and it compares argv rather than
|
||||
# regex-matching a command string, so quoting, spacing and pipelines stop
|
||||
# mattering.
|
||||
# The first two are docker/guard-lib.sh, which hooks/merge-guard.sh applies to
|
||||
# the same command from the other side. Here they sit on the command itself: it
|
||||
# works for a harness that cannot intercept a tool call, and it compares argv
|
||||
# rather than regex-matching a command string, so quoting, spacing and
|
||||
# pipelines stop mattering.
|
||||
#
|
||||
# The second is here for the same reason it is not in the forge CLIs: dejima
|
||||
# writes two of them and not the third, so a disclosure living in the tools is
|
||||
# one that depends on which forge an operator picked. This is the one place
|
||||
# every forge passes through.
|
||||
# The third is this layer's alone, because a hook is handed a command string
|
||||
# and cannot rewrite the body an argument points at. It is here rather than in
|
||||
# the forge CLIs for the same reason the repository rule is: dejima writes two
|
||||
# of them and not the third, so a disclosure living in the tools is one that
|
||||
# depends on which forge an operator picked. This is the one place every forge
|
||||
# passes through.
|
||||
#
|
||||
# Fast-fail, not a boundary, exactly as the hook was: what actually decides
|
||||
# whether a merge is possible is the scope of the token the forge issued. This
|
||||
# stops an eager agent doing it by accident.
|
||||
# Fast-fail, not a boundary: what actually decides whether a merge is possible
|
||||
# is the scope of the token the forge issued. This stops an eager agent doing
|
||||
# it by accident.
|
||||
#
|
||||
# DEJIMA_MERGE_BLOCK the full command prefix to refuse, e.g. "gitea-pr
|
||||
# merge". Its first word is the command this shadows;
|
||||
@@ -51,180 +52,12 @@ if [ -z "${DEJIMA:-}" ] ||
|
||||
exec "$real" "$@"
|
||||
fi
|
||||
|
||||
# The words of a command prefix, minus the CLI name, appear as a run of
|
||||
# consecutive non-flag arguments. Flags are dropped first, so `gitea-pr
|
||||
# --verbose merge 7` is still a merge.
|
||||
#
|
||||
# Anywhere in that run rather than at the start of it, because a flag's value
|
||||
# is indistinguishable from a subcommand here: in `gh --repo o/r pr merge 7`
|
||||
# the first non-flag argument is `o/r`, and requiring the match to begin there
|
||||
# let the most ordinary way to name a PR from outside its checkout walk
|
||||
# straight past the block. Knowing which flags take a value would mean knowing
|
||||
# each CLI, which is the coupling this guard exists to avoid.
|
||||
#
|
||||
# The cost is a command that happens to pass the words as consecutive values -
|
||||
# `--title pr --body merge` - matching. For the merge block that is a loud
|
||||
# refusal against a merge going through unnoticed; for a disclosure it is a
|
||||
# line of text on something an agent did write anyway. Both are the direction
|
||||
# to be wrong in.
|
||||
plain=()
|
||||
for arg in "$@"; do
|
||||
case "$arg" in -*) continue ;; esac
|
||||
plain+=("$arg")
|
||||
done
|
||||
# shellcheck source=guard-lib.sh
|
||||
source "${DEJIMA_GUARD_LIB:-/usr/local/lib/dejima/guard-lib.sh}"
|
||||
|
||||
# argv_has <word>... - true when those words appear consecutively in `plain`.
|
||||
argv_has() {
|
||||
local want=("$@") n="${#plain[@]}" m="$#" i=0 k
|
||||
[ "$m" -gt 0 ] || return 1
|
||||
while [ "$((i + m))" -le "$n" ]; do
|
||||
k=0
|
||||
while [ "$k" -lt "$m" ] && [ "${plain[$((i + k))]}" = "${want[$k]}" ]; do
|
||||
k=$((k + 1))
|
||||
done
|
||||
[ "$k" -lt "$m" ] || return 0
|
||||
i=$((i + 1))
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# --- the merge block --------------------------------------------------------
|
||||
|
||||
if [ -n "${DEJIMA_MERGE_BLOCK:-}" ]; then
|
||||
read -ra blocked <<< "$DEJIMA_MERGE_BLOCK"
|
||||
# The first word names the command; only the rest is a subcommand sequence
|
||||
# to compare against argv. A block naming a different command than the one
|
||||
# being run is not this invocation's business.
|
||||
if [ "${#blocked[@]}" -ge 1 ] && [ "${blocked[0]}" = "$self" ]; then
|
||||
if [ "${#blocked[@]}" -eq 1 ]; then
|
||||
# The whole command is blocked, not one of its subcommands.
|
||||
echo "Blocked by dejima's forge guard: '$self' is not available in the sandbox." >&2
|
||||
exit 1
|
||||
fi
|
||||
if argv_has "${blocked[@]:1}"; then
|
||||
echo "Blocked by dejima's forge guard: merging is a human decision in the sandbox - open a PR/MR, then the operator merges. Report its URL and stop." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- the repository the session was launched against ------------------------
|
||||
#
|
||||
# A forge whose CLI can be aimed elsewhere declares how (repo_flags,
|
||||
# repo_env). The gitea CLIs declare neither, because they resolve the repo
|
||||
# from origin and refuse another host; `gh` declares both, so on that path the
|
||||
# most ordinary way to name a PR from outside its checkout is also the way to
|
||||
# file an issue on a repository the session has nothing to do with (#187).
|
||||
#
|
||||
# Fast-fail, not a boundary, for the same reason the merge block is not one:
|
||||
# the session holds the token and could call the API directly. What bounds
|
||||
# this is the scope of push_token_file.
|
||||
#
|
||||
# Refused rather than skipped when the launch resolved no repository at all: a
|
||||
# workspace with no origin has nothing for --repo to legitimately name, and
|
||||
# "cannot confirm" is not a reason to let it through.
|
||||
if [ -n "${DEJIMA_FORGE_REPO_FLAGS:-}" ] || [ -n "${DEJIMA_FORGE_REPO_ENV:-}" ] ||
|
||||
[ -n "${DEJIMA_FORGE_REPO_PATHS:-}" ]; then
|
||||
refuse_repo() {
|
||||
echo "Blocked by dejima's forge guard: this session was launched against ${DEJIMA_FORGE_REPO:-no repository}, and $1 names '$2'. Act on the repository the sandbox mounts." >&2
|
||||
exit 1
|
||||
}
|
||||
# Case-insensitively: forges treat an owner and a repository name as
|
||||
# case-preserving but not case-sensitive, and a refusal over capitalisation
|
||||
# would be a false one.
|
||||
same_repo() {
|
||||
[ -n "${DEJIMA_FORGE_REPO:-}" ] && [ "${1,,}" = "${DEJIMA_FORGE_REPO,,}" ]
|
||||
}
|
||||
if [ -n "${DEJIMA_FORGE_REPO_ENV:-}" ]; then
|
||||
env_value="${!DEJIMA_FORGE_REPO_ENV:-}"
|
||||
if [ -n "$env_value" ] && ! same_repo "$env_value"; then
|
||||
refuse_repo "$DEJIMA_FORGE_REPO_ENV" "$env_value"
|
||||
fi
|
||||
fi
|
||||
if [ -n "${DEJIMA_FORGE_REPO_FLAGS:-}" ]; then
|
||||
read -ra repo_flags <<< "$DEJIMA_FORGE_REPO_FLAGS"
|
||||
args_left=("$@")
|
||||
while [ "${#args_left[@]}" -gt 0 ]; do
|
||||
arg="${args_left[0]}"
|
||||
args_left=("${args_left[@]:1}")
|
||||
for flag in "${repo_flags[@]}"; do
|
||||
value=""
|
||||
case "$arg" in
|
||||
"$flag")
|
||||
# The value is the next argument, and its absence is
|
||||
# the CLI's error to report rather than this one's.
|
||||
[ "${#args_left[@]}" -gt 0 ] || continue
|
||||
value="${args_left[0]}"
|
||||
;;
|
||||
"$flag"=*) value="${arg#*=}" ;;
|
||||
*)
|
||||
# One-dash flags only; a two-dash flag carries its
|
||||
# value with `=` or as the next argument, both above.
|
||||
case "$flag" in
|
||||
-?) ;;
|
||||
*) continue ;;
|
||||
esac
|
||||
# Short flags cluster, so the letter can sit anywhere
|
||||
# in one: `-Rowner/name`, `-wR owner/name` and
|
||||
# `-wRowner/name` all name a repository and none of
|
||||
# them is `-R` as its own argument (#243). Split at the
|
||||
# letter the way getopt does - what follows it in the
|
||||
# same argument is the value, and nothing following it
|
||||
# means the next argument is.
|
||||
letter="${flag#-}"
|
||||
# A cluster, not a value that happens to start with a
|
||||
# dash: one dash, then the letter somewhere in a run of
|
||||
# letters. Without the second check, `--search foo-R/x`
|
||||
# style arguments would be mined for flag letters.
|
||||
case "$arg" in -[!-]*) ;; *) continue ;; esac
|
||||
rest="${arg#-}"
|
||||
case "$rest" in *"$letter"*) ;; *) continue ;; esac
|
||||
case "${rest%%"$letter"*}" in *[!A-Za-z]*) continue ;; esac
|
||||
value="${rest#*"$letter"}"
|
||||
if [ -z "$value" ]; then
|
||||
[ "${#args_left[@]}" -gt 0 ] || continue
|
||||
value="${args_left[0]}"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
[ -n "$value" ] || continue
|
||||
same_repo "$value" || refuse_repo "$flag" "$value"
|
||||
done
|
||||
done
|
||||
fi
|
||||
|
||||
# A repository named in a path rather than in a flag - `gh api
|
||||
# repos/<owner>/<name>/issues` reaches any repository the token does,
|
||||
# without a --repo anywhere in it (#243).
|
||||
#
|
||||
# Only when the declared subcommand is argv's first word. The alternative
|
||||
# is inspecting every positional that contains two slashes, which would
|
||||
# refuse `gh issue create --title repos/a/b` - and a false refusal costs an
|
||||
# honest agent a working command, which is the wrong way for a fast-fail to
|
||||
# be wrong.
|
||||
if [ -n "${DEJIMA_FORGE_REPO_PATHS:-}" ] && [ "$#" -gt 0 ]; then
|
||||
read -ra repo_paths <<< "$DEJIMA_FORGE_REPO_PATHS"
|
||||
for spec in "${repo_paths[@]}"; do
|
||||
[ "$1" = "${spec%%:*}" ] || continue
|
||||
segment="${spec#*:}"
|
||||
for arg in "$@"; do
|
||||
case "$arg" in -*) continue ;; esac
|
||||
# gh substitutes {owner} and {repo} from the checkout, so a
|
||||
# path carrying them names this repository by construction and
|
||||
# comparing the literal braces would refuse it.
|
||||
case "$arg" in *'{'*) continue ;; esac
|
||||
path="${arg#/}"
|
||||
case "$path" in "$segment"/*/*) ;; *) continue ;; esac
|
||||
path="${path#"$segment"/}"
|
||||
owner="${path%%/*}"
|
||||
path="${path#*/}"
|
||||
name="${path%%/*}"
|
||||
[ -n "$owner" ] && [ -n "$name" ] || continue
|
||||
same_repo "$owner/$name" \
|
||||
|| refuse_repo "the path '$arg'" "$owner/$name"
|
||||
done
|
||||
done
|
||||
fi
|
||||
if ! dejima_forge_verdict "$self" "$@"; then
|
||||
echo "Blocked by dejima's forge guard: $DEJIMA_GUARD_REASON" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- the authorship disclosure ---------------------------------------------
|
||||
@@ -242,13 +75,18 @@ marker_for() {
|
||||
esac
|
||||
}
|
||||
|
||||
# The non-flag arguments the records below are compared against. Set here
|
||||
# rather than relied on from the verdict above, so this section says what it
|
||||
# depends on.
|
||||
dejima_forge_plain "$@"
|
||||
|
||||
kind=""
|
||||
while read -r record_kind record_rest; do
|
||||
[ -n "$record_kind" ] || continue
|
||||
read -ra words <<< "$record_rest"
|
||||
[ "${#words[@]}" -ge 1 ] || continue
|
||||
[ "${words[0]}" = "$self" ] || continue
|
||||
if argv_has "${words[@]:1}"; then
|
||||
if dejima_argv_has "${words[@]:1}"; then
|
||||
kind="$record_kind"
|
||||
break
|
||||
fi
|
||||
|
||||
+17
-129
@@ -1,20 +1,20 @@
|
||||
#!/bin/bash
|
||||
# Shadows git on PATH so `git push` is checked whatever ran it.
|
||||
#
|
||||
# The rules are the ones hooks/git-push-guard.sh applies - push only to the
|
||||
# forge's own hosts, never straight to a blocked branch - moved off the
|
||||
# harness's hook protocol and onto the command itself. Two things follow from
|
||||
# that. It works for a harness with no way to intercept a tool call, which is
|
||||
# most of them. And it reads the decision out of argv rather than out of a
|
||||
# regex over a reconstructed command string, so `git push`, `git -c k=v
|
||||
# push` and a push buried in a pipeline are all just argv here.
|
||||
# The rules are in docker/guard-lib.sh, which hooks/git-push-guard.sh applies
|
||||
# to the same push from the other side - push only to the forge's own hosts,
|
||||
# never straight to a blocked branch. Here they sit on the command itself,
|
||||
# which means two things. It works for a harness with no way to intercept a
|
||||
# tool call, which is most of them. And it reads the decision out of argv
|
||||
# rather than out of a regex over a reconstructed command string, so
|
||||
# `git push`, `git -c k=v push` and a push buried in a pipeline are all just
|
||||
# argv here.
|
||||
#
|
||||
# It is a fast-fail guard, not a boundary, and the distinction is the same one
|
||||
# docs/security.md draws about the hook it replaces: a session that means to
|
||||
# get round it can call /usr/bin/git directly, exactly as it could have used a
|
||||
# tool the hook did not cover. What stops a push reaching somewhere it should
|
||||
# not is the firewall, and what stops it being accepted is the forge. This
|
||||
# stops an eager agent doing the obvious thing by accident.
|
||||
# docs/security.md draws about the hook beside it: a session that means to get
|
||||
# round it can call /usr/bin/git directly, exactly as it could have used a tool
|
||||
# the hook did not cover. What stops a push reaching somewhere it should not is
|
||||
# the firewall, and what stops it being accepted is the forge.
|
||||
#
|
||||
# Everything it needs is already in the container's environment, resolved on
|
||||
# the host from the forge and workflow manifests (see bin/dejima):
|
||||
@@ -32,125 +32,13 @@ if [ -z "${DEJIMA:-}" ]; then
|
||||
exec "$REAL_GIT" "$@"
|
||||
fi
|
||||
|
||||
# Find the subcommand, and -C if it is there. git's own global options come
|
||||
# before the subcommand and several take a value, so the ones that do are
|
||||
# skipped in pairs rather than assumed to be attached.
|
||||
repo_dir="$PWD"
|
||||
subcommand=""
|
||||
argv=("$@")
|
||||
# Every `-c key=value` this push carries, kept rather than only skipped. The
|
||||
# question below is where the push will go, and git answers that from the
|
||||
# config the push runs under - so resolving it without these would answer for
|
||||
# a different configuration than the one about to be used. A one-command
|
||||
# `-c remote.origin.pushurl=...` is exactly that case.
|
||||
config_overrides=()
|
||||
i=0
|
||||
while [ "$i" -lt "${#argv[@]}" ]; do
|
||||
case "${argv[$i]}" in
|
||||
-C)
|
||||
i=$((i + 1))
|
||||
[ "$i" -lt "${#argv[@]}" ] && repo_dir="${argv[$i]}"
|
||||
;;
|
||||
-c)
|
||||
i=$((i + 1))
|
||||
[ "$i" -lt "${#argv[@]}" ] \
|
||||
&& config_overrides+=(-c "${argv[$i]}")
|
||||
;;
|
||||
--exec-path|--git-dir|--work-tree|--namespace|--super-prefix)
|
||||
i=$((i + 1))
|
||||
;;
|
||||
-*)
|
||||
;;
|
||||
*)
|
||||
subcommand="${argv[$i]}"
|
||||
break
|
||||
;;
|
||||
esac
|
||||
i=$((i + 1))
|
||||
done
|
||||
DEJIMA_GUARD_GIT="$REAL_GIT"
|
||||
# shellcheck source=guard-lib.sh
|
||||
source "${DEJIMA_GUARD_LIB:-/usr/local/lib/dejima/guard-lib.sh}"
|
||||
|
||||
[ "$subcommand" = "push" ] || exec "$REAL_GIT" "$@"
|
||||
|
||||
# What comes after `push`: the first two non-flag words are the remote and the
|
||||
# refspec. Flags that take a value are skipped in pairs for the same reason as
|
||||
# above - `git push --repo origin main` must not read "origin" as the remote.
|
||||
# --repo names the remote in git's own legacy form and wins over a positional,
|
||||
# so it is read rather than skipped: reading it as an opaque flag would leave
|
||||
# `git push --repo=origin main` looking like a push to a remote called "main",
|
||||
# which resolves to no URL - and passing that through unchecked is a push to
|
||||
# origin/main that nothing looked at.
|
||||
remote=""
|
||||
refspec=""
|
||||
i=$((i + 1))
|
||||
while [ "$i" -lt "${#argv[@]}" ]; do
|
||||
case "${argv[$i]}" in
|
||||
--repo=*)
|
||||
remote="${argv[$i]#--repo=}"
|
||||
;;
|
||||
--repo)
|
||||
i=$((i + 1))
|
||||
[ "$i" -lt "${#argv[@]}" ] && remote="${argv[$i]}"
|
||||
;;
|
||||
-o|--push-option|--receive-pack|--exec)
|
||||
i=$((i + 1))
|
||||
;;
|
||||
-*)
|
||||
;;
|
||||
*)
|
||||
if [ -z "$remote" ]; then
|
||||
remote="${argv[$i]}"
|
||||
elif [ -z "$refspec" ]; then
|
||||
refspec="${argv[$i]}"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
i=$((i + 1))
|
||||
done
|
||||
[ -n "$remote" ] || remote=origin
|
||||
|
||||
# --push, because that is the URL the push will use. Without it this reads
|
||||
# remote.<name>.url while git pushes to remote.<name>.pushurl when one is set,
|
||||
# so a pushurl naming any other host is checked against the fetch URL and
|
||||
# allowed. It needs no evasion to reach: `git -c remote.origin.pushurl=...
|
||||
# push origin branch` sets one for a single command, and .git/config is in the
|
||||
# read-write project mount, so a session can also just write one down.
|
||||
remote_url="$("$REAL_GIT" ${config_overrides[@]+"${config_overrides[@]}"} \
|
||||
-C "$repo_dir" remote get-url --push "$remote" 2>/dev/null)"
|
||||
# A remote this cannot resolve is refused rather than passed through. It is
|
||||
# usually a typo, which git would reject in a moment anyway - but it is also
|
||||
# what a disagreement between this parser and git's looks like, and the
|
||||
# failure of that disagreement has to be a refusal rather than a push nobody
|
||||
# checked.
|
||||
if [ -z "$remote_url" ]; then
|
||||
echo "Blocked by dejima's git guard: cannot resolve the remote '$remote' to a URL," >&2
|
||||
echo "so there is nothing to check this push against. Name a remote that exists." >&2
|
||||
if ! dejima_push_verdict "$PWD" "$@"; then
|
||||
echo "Blocked by dejima's git guard: $DEJIMA_GUARD_REASON" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
host="$(printf '%s' "$remote_url" \
|
||||
| sed -E 's#^[a-zA-Z]+://([^/@]+@)?([^/:]+).*#\2#; s#^[^@/]+@([^:/]+)[:/].*#\1#')"
|
||||
|
||||
host_allowed=0
|
||||
for allowed_host in ${DEJIMA_PUSH_HOSTS:-}; do
|
||||
[ "$host" = "$allowed_host" ] && { host_allowed=1; break; }
|
||||
done
|
||||
if [ "$host_allowed" != 1 ]; then
|
||||
echo "Blocked by dejima's git guard: this sandbox may only push to ${DEJIMA_FORGE_NAME:-the configured forge}'s allowed host(s) (${DEJIMA_PUSH_HOSTS:-none allowed}), not '$host'." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -n "$refspec" ]; then
|
||||
branch="${refspec##*:}"
|
||||
else
|
||||
branch="$("$REAL_GIT" -C "$repo_dir" branch --show-current 2>/dev/null)"
|
||||
fi
|
||||
branch="${branch#refs/heads/}"
|
||||
|
||||
for blocked_branch in ${DEJIMA_BLOCKED_BRANCHES:-}; do
|
||||
if [ "$branch" = "$blocked_branch" ]; then
|
||||
echo "Blocked by dejima's git guard: direct pushes to '$branch' aren't allowed in the sandbox. Push a feature branch and open a PR/MR instead." >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
exec "$REAL_GIT" "$@"
|
||||
|
||||
@@ -0,0 +1,563 @@
|
||||
#!/bin/bash
|
||||
# The push and merge policy, once, for the two layers that apply it.
|
||||
#
|
||||
# Each rule used to exist twice: as a PATH shadow over the command
|
||||
# (docker/git-guard.sh, docker/forge-guard.sh) and as a PreToolUse hook over
|
||||
# the command string a harness reports (hooks/git-push-guard.sh,
|
||||
# hooks/merge-guard.sh). The two overlap deliberately - a hook sees
|
||||
# `/usr/bin/git push`, which walks past the shadow, and a shadow sees a push
|
||||
# from an editor integration or a script, which the harness never reports - so
|
||||
# neither can be dropped. What they cannot do is disagree, and they had already
|
||||
# drifted: `git push -o x origin main` was fine to one and a hard refusal from
|
||||
# the other, for the same invocation in the same session.
|
||||
#
|
||||
# So the decision lives here and both call it. A bypass is then fixed once.
|
||||
#
|
||||
# Every function reads argv, never a command string. A hook holds a string, so
|
||||
# it splits it into segments and words first (dejima_guard_segments) and hands
|
||||
# the words over - which is also why the hooks inherit the shadows' indifference
|
||||
# to spacing, quoting and pipelines.
|
||||
#
|
||||
# None of this is a boundary, and the distinction is the one docs/security.md
|
||||
# draws: a session that means to get round these can call /usr/bin/git or the
|
||||
# forge API directly. What stops a push reaching somewhere it should not is the
|
||||
# firewall, and what stops it being accepted is the forge's token scope. These
|
||||
# stop an eager agent doing the obvious thing by accident.
|
||||
#
|
||||
# Sourced, so nothing here exits: a refusal is a return of 1 with the reason in
|
||||
# DEJIMA_GUARD_REASON. The caller supplies its own prefix, because an operator
|
||||
# reading a refusal needs to know which layer spoke.
|
||||
|
||||
# The real git, never the shadow. The shadow passes a non-push through, so
|
||||
# calling it would work - but it would also mean every question this asks git
|
||||
# goes through a script that is asking git questions.
|
||||
DEJIMA_GUARD_GIT="${DEJIMA_GUARD_GIT:-/usr/bin/git}"
|
||||
|
||||
DEJIMA_GUARD_REASON=""
|
||||
|
||||
# --- splitting a command string into git/forge invocations -------------------
|
||||
|
||||
# dejima_guard_segments <command string>
|
||||
#
|
||||
# One segment per line. A guarded command can be the second half of a compound
|
||||
# one, and only a segment whose first word is the command in question is that
|
||||
# command - `echo push` is not a push.
|
||||
#
|
||||
# Word-splitting rather than a full shell parse, so quoting edge cases are not
|
||||
# handled. That is the same pragmatic tradeoff as the sibling
|
||||
# deny-dangerous-bash.sh denylist, and the reason every rule below refuses
|
||||
# rather than guesses when it cannot read what it is looking at.
|
||||
# The trailing newline is load-bearing: without it `read` returns false on the
|
||||
# last segment even though it read one, so a command with no separators in it -
|
||||
# every ordinary push - was never looked at.
|
||||
dejima_guard_segments() {
|
||||
printf '%s\n' "$1" | sed 's/&&/\n/g; s/||/\n/g; s/;/\n/g; s/|/\n/g'
|
||||
}
|
||||
|
||||
# --- git push ---------------------------------------------------------------
|
||||
|
||||
# Where a push will go and which branches it will write, read out of argv the
|
||||
# way git reads it. Sets DEJIMA_GUARD_PUSH=1 when the argv is a push at all.
|
||||
#
|
||||
# git's own global options come before the subcommand and several take a
|
||||
# separate value, so those are skipped in pairs rather than assumed attached.
|
||||
#
|
||||
# `-c key=value` is kept rather than skipped: git resolves where a push goes
|
||||
# from the config the push runs under, so asking without these answers for a
|
||||
# different configuration than the one about to be used. A one-command
|
||||
# `-c remote.origin.pushurl=...` is exactly that case.
|
||||
#
|
||||
# --git-dir and --work-tree are REFUSED rather than skipped or honoured. Both
|
||||
# used to be consumed and ignored, so `git --git-dir /tmp/x/.git push origin
|
||||
# main` resolved `origin` in the mounted project, found it allowed, and then
|
||||
# ran against a repository whose origin points somewhere else. Honouring them
|
||||
# would mean reimplementing how they combine with -C and with each other;
|
||||
# refusing is the direction the unresolvable-remote case below already takes,
|
||||
# and a session with a second repository to push from can cd into it or use -C.
|
||||
dejima_push_verdict() { # <repo-dir> <git argv...>
|
||||
local repo_dir="$1"; shift
|
||||
local argv=("$@") i=0 subcommand="" branch
|
||||
local -a config_overrides=() refspecs=()
|
||||
local remote="" repo_flag="" push_all=0 push_mirror=0 repo_override=0
|
||||
DEJIMA_GUARD_REASON=""
|
||||
DEJIMA_GUARD_PUSH=0
|
||||
|
||||
while [ "$i" -lt "${#argv[@]}" ]; do
|
||||
case "${argv[$i]}" in
|
||||
-C)
|
||||
i=$((i + 1))
|
||||
[ "$i" -lt "${#argv[@]}" ] && repo_dir="${argv[$i]}"
|
||||
;;
|
||||
-c)
|
||||
i=$((i + 1))
|
||||
[ "$i" -lt "${#argv[@]}" ] \
|
||||
&& config_overrides+=(-c "${argv[$i]}")
|
||||
;;
|
||||
--git-dir|--work-tree)
|
||||
repo_override=1
|
||||
i=$((i + 1))
|
||||
;;
|
||||
--git-dir=*|--work-tree=*)
|
||||
repo_override=1
|
||||
;;
|
||||
--exec-path|--namespace|--super-prefix)
|
||||
i=$((i + 1))
|
||||
;;
|
||||
-*)
|
||||
;;
|
||||
*)
|
||||
subcommand="${argv[$i]}"
|
||||
break
|
||||
;;
|
||||
esac
|
||||
i=$((i + 1))
|
||||
done
|
||||
|
||||
[ "$subcommand" = push ] || return 0
|
||||
DEJIMA_GUARD_PUSH=1
|
||||
|
||||
# Noted while parsing and refused here, so that only a push is refused:
|
||||
# these say nothing about any other git command, and a guard that refused
|
||||
# `git --git-dir X status` would be refusing the flag rather than the push.
|
||||
if [ "$repo_override" = 1 ]; then
|
||||
DEJIMA_GUARD_REASON="a push carrying --git-dir or --work-tree cannot be checked, because the repository it writes from is not the one this would resolve the remote in. Run it from that repository, or name it with -C."
|
||||
return 1
|
||||
fi
|
||||
|
||||
# What comes after `push`. --repo names the remote in git's own legacy form
|
||||
# and is read rather than treated as an opaque flag, because skipping it
|
||||
# would leave `git push --repo=origin main` looking like a bare push and
|
||||
# resolve a remote the command never named.
|
||||
#
|
||||
# It LOSES to a positional, which is what git documents: "this option is
|
||||
# equivalent to the <repository> argument. If both are specified, the
|
||||
# command-line argument takes precedence." So in `git push --repo=origin
|
||||
# other-remote feature` the remote is other-remote, and checking origin
|
||||
# instead would approve a host this push never goes to.
|
||||
#
|
||||
# Every positional after the remote is a refspec, not just the first.
|
||||
# `git push origin feature main` checked `feature` alone and let `main`
|
||||
# through beside it.
|
||||
i=$((i + 1))
|
||||
while [ "$i" -lt "${#argv[@]}" ]; do
|
||||
case "${argv[$i]}" in
|
||||
--repo=*) repo_flag="${argv[$i]#--repo=}" ;;
|
||||
--repo)
|
||||
i=$((i + 1))
|
||||
[ "$i" -lt "${#argv[@]}" ] && repo_flag="${argv[$i]}"
|
||||
;;
|
||||
--all) push_all=1 ;;
|
||||
--mirror) push_mirror=1 ;;
|
||||
-o|--push-option|--receive-pack|--exec)
|
||||
i=$((i + 1))
|
||||
;;
|
||||
-*) ;;
|
||||
*)
|
||||
if [ -z "$remote" ]; then
|
||||
remote="${argv[$i]}"
|
||||
else
|
||||
refspecs+=("${argv[$i]}")
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
i=$((i + 1))
|
||||
done
|
||||
[ -n "$remote" ] || remote="$repo_flag"
|
||||
[ -n "$remote" ] || remote=origin
|
||||
|
||||
# --push, because that is the URL the push will use. Without it this reads
|
||||
# remote.<name>.url while git pushes to remote.<name>.pushurl when one is
|
||||
# set, so a pushurl naming any other host is checked against the fetch URL
|
||||
# and allowed. It needs no evasion to reach: `git -c
|
||||
# remote.origin.pushurl=... push origin branch` sets one for a single
|
||||
# command, and .git/config is in the read-write project mount, so a session
|
||||
# can also just write one down.
|
||||
local remote_url host
|
||||
remote_url="$("$DEJIMA_GUARD_GIT" ${config_overrides[@]+"${config_overrides[@]}"} \
|
||||
-C "$repo_dir" remote get-url --push "$remote" 2>/dev/null)"
|
||||
if [ -z "$remote_url" ]; then
|
||||
# A remote this cannot resolve is refused rather than passed through.
|
||||
# It is usually a typo, which git would reject in a moment anyway - but
|
||||
# it is also what a disagreement between this parser and git's looks
|
||||
# like, and the failure of that disagreement has to be a refusal rather
|
||||
# than a push nobody checked.
|
||||
DEJIMA_GUARD_REASON="cannot resolve the remote '$remote' to a URL, so there is nothing to check this push against. Name a remote that exists."
|
||||
return 1
|
||||
fi
|
||||
|
||||
host="$(printf '%s' "$remote_url" \
|
||||
| sed -E 's#^[a-zA-Z]+://([^/@]+@)?([^/:]+).*#\2#; s#^[^@/]+@([^:/]+)[:/].*#\1#')"
|
||||
|
||||
local allowed_host host_allowed=0
|
||||
for allowed_host in ${DEJIMA_PUSH_HOSTS:-}; do
|
||||
[ "$host" = "$allowed_host" ] && { host_allowed=1; break; }
|
||||
done
|
||||
if [ "$host_allowed" != 1 ]; then
|
||||
DEJIMA_GUARD_REASON="this sandbox may only push to ${DEJIMA_FORGE_NAME:-the configured forge}'s allowed host(s) (${DEJIMA_PUSH_HOSTS:-none allowed}), not '$host'."
|
||||
return 1
|
||||
fi
|
||||
|
||||
# --mirror puts every ref under refs/ on the remote AND deletes the ones
|
||||
# that are gone locally, so what it does to a protected branch cannot be
|
||||
# read off the command: a mirror from a checkout that has no `main` deletes
|
||||
# the remote's. Refused outright rather than resolved, because there is no
|
||||
# branch list that makes it safe.
|
||||
if [ "$push_mirror" = 1 ]; then
|
||||
DEJIMA_GUARD_REASON="--mirror writes every ref and deletes the remote refs this checkout does not have, so it cannot be checked against a branch policy. Push the branch you mean."
|
||||
return 1
|
||||
fi
|
||||
|
||||
# --all pushes every local branch, so every local branch is what the policy
|
||||
# has to be applied to. The refspec list is empty here - git refuses --all
|
||||
# with one - and reading only the current branch is what let `git push
|
||||
# --all origin` from a feature branch push `main` beside it.
|
||||
if [ "$push_all" = 1 ]; then
|
||||
local local_branch
|
||||
while IFS= read -r local_branch; do
|
||||
[ -n "$local_branch" ] || continue
|
||||
if _dejima_branch_blocked "$local_branch"; then
|
||||
DEJIMA_GUARD_REASON="--all pushes every local branch, and '$local_branch' is one of them. Direct pushes to '$local_branch' aren't allowed in the sandbox. Push a feature branch and open a PR/MR instead."
|
||||
return 1
|
||||
fi
|
||||
done < <("$DEJIMA_GUARD_GIT" -C "$repo_dir" for-each-ref \
|
||||
--format='%(refname:short)' refs/heads/ 2>/dev/null)
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Each refspec's destination. `src:dst` names it outright, a bare name is
|
||||
# both, and `HEAD` or no refspec at all means the current branch - `git
|
||||
# push origin` and `git push origin HEAD` both push it.
|
||||
if [ "${#refspecs[@]}" -eq 0 ]; then
|
||||
refspecs=(HEAD)
|
||||
fi
|
||||
local refspec
|
||||
for refspec in "${refspecs[@]}"; do
|
||||
# One leading `+` is the force marker and belongs to the refspec rather
|
||||
# than to the branch, so it comes off before the destination is read -
|
||||
# `git push origin +main` names main, and the force spelling is the
|
||||
# destructive one to be wrong about.
|
||||
refspec="${refspec#+}"
|
||||
case "$refspec" in
|
||||
*:*) branch="${refspec##*:}" ;;
|
||||
HEAD) branch="$("$DEJIMA_GUARD_GIT" -C "$repo_dir" branch --show-current 2>/dev/null)" ;;
|
||||
*) branch="$refspec" ;;
|
||||
esac
|
||||
# A destination that does not start with refs/ is matched against the
|
||||
# remote's refs by git's rev-parse rules, so `heads/main` and `main`
|
||||
# both name refs/heads/main. Stripping only the full prefix left
|
||||
# `feature:heads/main` naming a branch no policy entry matched.
|
||||
branch="${branch#refs/}"
|
||||
branch="${branch#heads/}"
|
||||
if _dejima_branch_blocked "$branch"; then
|
||||
DEJIMA_GUARD_REASON="direct pushes to '$branch' aren't allowed in the sandbox. Push a feature branch and open a PR/MR instead."
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# DEJIMA_BLOCKED_BRANCHES fails OPEN: empty means no branch is protected, which
|
||||
# is correct for a workflow that genuinely has no branch policy. Unlike
|
||||
# DEJIMA_PUSH_HOSTS above, which fails CLOSED - empty means no host is allowed,
|
||||
# so every push is refused, and that is how a no-forge or non-pushing workflow
|
||||
# becomes enforced rather than merely described.
|
||||
_dejima_branch_blocked() {
|
||||
local blocked
|
||||
[ -n "${1:-}" ] || return 1
|
||||
for blocked in ${DEJIMA_BLOCKED_BRANCHES:-}; do
|
||||
[ "$1" = "$blocked" ] && return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# --- the forge CLI ----------------------------------------------------------
|
||||
|
||||
# dejima_argv_has <word>... - true when those words appear consecutively among
|
||||
# the non-flag arguments of the command being checked. _dejima_plain must be
|
||||
# set first, by dejima_forge_verdict.
|
||||
#
|
||||
# Anywhere in that run rather than at the start of it, because a flag's value
|
||||
# is indistinguishable from a subcommand here: in `gh --repo o/r pr merge 7`
|
||||
# the first non-flag argument is `o/r`, and requiring the match to begin there
|
||||
# let the most ordinary way to name a PR from outside its checkout walk
|
||||
# straight past the block. Knowing which flags take a value would mean knowing
|
||||
# each CLI, which is the coupling this guard exists to avoid.
|
||||
#
|
||||
# The cost is a command that happens to pass the words as consecutive values -
|
||||
# `--title pr --body merge` - matching. For the merge block that is a loud
|
||||
# refusal against a merge going through unnoticed; for a disclosure it is a
|
||||
# line of text on something an agent did write anyway. Both are the direction
|
||||
# to be wrong in.
|
||||
dejima_argv_has() {
|
||||
local want=("$@") n="${#_dejima_plain[@]}" m="$#" i=0 k
|
||||
[ "$m" -gt 0 ] || return 1
|
||||
while [ "$((i + m))" -le "$n" ]; do
|
||||
k=0
|
||||
while [ "$k" -lt "$m" ] && [ "${_dejima_plain[$((i + k))]}" = "${want[$k]}" ]; do
|
||||
k=$((k + 1))
|
||||
done
|
||||
[ "$k" -lt "$m" ] || return 0
|
||||
i=$((i + 1))
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# dejima_forge_plain <argv...> - fills _dejima_plain with the non-flag
|
||||
# arguments, which is what every rule below compares against.
|
||||
dejima_forge_plain() {
|
||||
local arg
|
||||
_dejima_plain=()
|
||||
for arg in "$@"; do
|
||||
case "$arg" in -*) continue ;; esac
|
||||
_dejima_plain+=("$arg")
|
||||
done
|
||||
}
|
||||
|
||||
# dejima_forge_commands - every CLI this policy applies to, one per line: the
|
||||
# one whose merge a workflow blocks, and every one that writes a body the
|
||||
# session authored. More than one, because a forge can have a CLI per artifact
|
||||
# rather than one with subcommands, and because a forge with nothing blocked
|
||||
# can still have something to disclose.
|
||||
#
|
||||
# docker/entrypoint.sh links the shadow over exactly this set, and
|
||||
# hooks/merge-guard.sh checks a command against it before applying anything -
|
||||
# without that the hook would apply gh's --repo rule to any command carrying
|
||||
# that flag, which the shadow cannot do because it only exists for these.
|
||||
dejima_forge_commands() {
|
||||
[ -z "${DEJIMA_MERGE_BLOCK:-}" ] || printf '%s\n' "${DEJIMA_MERGE_BLOCK%% *}"
|
||||
# "<kind> <cli> <subcommand>...", so the CLI is the second word.
|
||||
[ -z "${DEJIMA_DISCLOSE:-}" ] || printf '%s\n' "$DEJIMA_DISCLOSE" |
|
||||
while read -r _ _cli _; do
|
||||
[ -z "$_cli" ] || printf '%s\n' "$_cli"
|
||||
done
|
||||
}
|
||||
|
||||
# dejima_forge_verdict <command name> <argv...>
|
||||
#
|
||||
# The command name is argv[0] as the caller saw it - the shadow's own basename,
|
||||
# or the first word of a hook's segment. Every policy here is keyed to it,
|
||||
# because a block naming a different command than the one being run is not this
|
||||
# invocation's business.
|
||||
dejima_forge_verdict() {
|
||||
local self="$1"; shift
|
||||
DEJIMA_GUARD_REASON=""
|
||||
dejima_forge_plain "$@"
|
||||
|
||||
if [ -n "${DEJIMA_MERGE_BLOCK:-}" ]; then
|
||||
local -a blocked
|
||||
read -ra blocked <<< "$DEJIMA_MERGE_BLOCK"
|
||||
if [ "${#blocked[@]}" -ge 1 ] && [ "${blocked[0]}" = "$self" ]; then
|
||||
if [ "${#blocked[@]}" -eq 1 ]; then
|
||||
# The whole command is blocked, not one of its subcommands.
|
||||
DEJIMA_GUARD_REASON="'$self' is not available in the sandbox."
|
||||
return 1
|
||||
fi
|
||||
if dejima_argv_has "${blocked[@]:1}"; then
|
||||
DEJIMA_GUARD_REASON="merging is a human decision in the sandbox - open a PR/MR, then the operator merges. Report its URL and stop."
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
_dejima_forge_repo_verdict "$@" || return 1
|
||||
return 0
|
||||
}
|
||||
|
||||
# The repository the session was launched against.
|
||||
#
|
||||
# A forge whose CLI can be aimed elsewhere declares how (repo_flags, repo_env,
|
||||
# repo_paths). The gitea CLIs declare none, because they resolve the repo from
|
||||
# origin and refuse another host - which leaves nothing here to compare, not
|
||||
# that they are confined to one repository: origin is session-writable. `gh`
|
||||
# declares all three, so on that path the
|
||||
# most ordinary way to name a PR from outside its checkout is also the way to
|
||||
# file an issue on a repository the session has nothing to do with (#187).
|
||||
#
|
||||
# Refused rather than skipped when the launch resolved no repository at all: a
|
||||
# workspace with no origin has nothing for --repo to legitimately name, and
|
||||
# "cannot confirm" is not a reason to let it through.
|
||||
_dejima_forge_repo_verdict() {
|
||||
[ -n "${DEJIMA_FORGE_REPO_FLAGS:-}" ] || [ -n "${DEJIMA_FORGE_REPO_ENV:-}" ] \
|
||||
|| [ -n "${DEJIMA_FORGE_REPO_PATHS:-}" ] || return 0
|
||||
|
||||
if [ -n "${DEJIMA_FORGE_REPO_ENV:-}" ]; then
|
||||
local env_value="${!DEJIMA_FORGE_REPO_ENV:-}"
|
||||
if [ -n "$env_value" ] && ! _dejima_same_repo "$env_value"; then
|
||||
_dejima_refuse_repo "$DEJIMA_FORGE_REPO_ENV" "$env_value"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -n "${DEJIMA_FORGE_REPO_FLAGS:-}" ]; then
|
||||
local -a repo_flags args_left
|
||||
read -ra repo_flags <<< "$DEJIMA_FORGE_REPO_FLAGS"
|
||||
args_left=("$@")
|
||||
local arg flag value letter rest
|
||||
while [ "${#args_left[@]}" -gt 0 ]; do
|
||||
arg="${args_left[0]}"
|
||||
args_left=("${args_left[@]:1}")
|
||||
for flag in "${repo_flags[@]}"; do
|
||||
value=""
|
||||
case "$arg" in
|
||||
"$flag")
|
||||
# The value is the next argument, and its absence is
|
||||
# the CLI's error to report rather than this one's.
|
||||
[ "${#args_left[@]}" -gt 0 ] || continue
|
||||
value="${args_left[0]}"
|
||||
;;
|
||||
"$flag"=*) value="${arg#*=}" ;;
|
||||
*)
|
||||
# One-dash flags only; a two-dash flag carries its
|
||||
# value with `=` or as the next argument, both above.
|
||||
case "$flag" in
|
||||
-?) ;;
|
||||
*) continue ;;
|
||||
esac
|
||||
# Short flags cluster, so the letter can sit anywhere
|
||||
# in one: `-Rowner/name`, `-wR owner/name` and
|
||||
# `-wRowner/name` all name a repository and none of
|
||||
# them is `-R` as its own argument (#243). Split at the
|
||||
# letter the way getopt does - what follows it in the
|
||||
# same argument is the value, and nothing following it
|
||||
# means the next argument is.
|
||||
letter="${flag#-}"
|
||||
# A cluster, not a value that happens to start with a
|
||||
# dash: one dash, then the letter somewhere in a run of
|
||||
# letters. Without the second check, `--search foo-R/x`
|
||||
# style arguments would be mined for flag letters.
|
||||
case "$arg" in -[!-]*) ;; *) continue ;; esac
|
||||
rest="${arg#-}"
|
||||
case "$rest" in *"$letter"*) ;; *) continue ;; esac
|
||||
case "${rest%%"$letter"*}" in *[!A-Za-z]*) continue ;; esac
|
||||
value="${rest#*"$letter"}"
|
||||
if [ -z "$value" ]; then
|
||||
[ "${#args_left[@]}" -gt 0 ] || continue
|
||||
value="${args_left[0]}"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
[ -n "$value" ] || continue
|
||||
if ! _dejima_same_repo "$value"; then
|
||||
_dejima_refuse_repo "$flag" "$value"
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
done
|
||||
fi
|
||||
|
||||
_dejima_forge_path_verdict "$@" || return 1
|
||||
return 0
|
||||
}
|
||||
|
||||
# A repository named in a path rather than in a flag - `gh api
|
||||
# repos/<owner>/<name>/issues` reaches any repository the token does, without a
|
||||
# --repo anywhere in it (#243).
|
||||
#
|
||||
# Only when the declared subcommand is argv's first word. The alternative is
|
||||
# inspecting every positional that contains two slashes, which would refuse
|
||||
# `gh issue create --title repos/a/b` - and a false refusal costs an honest
|
||||
# agent a working command, which is the wrong way for a fast-fail to be wrong.
|
||||
#
|
||||
# Under that subcommand the rule is inverted: rather than looking for a path
|
||||
# naming the wrong repository, it requires one naming the right one. `gh api`
|
||||
# reaches every endpoint the token does, and an endpoint that is not under the
|
||||
# declared segment - `user/repos`, `/orgs/<org>/repos`, `graphql` - names no
|
||||
# repository for this to compare, so there was nothing to refuse and nothing
|
||||
# was (#365). Telling an endpoint apart from a header value or a field value
|
||||
# would mean knowing which of the CLI's flags take a separate value, which is
|
||||
# the coupling this guard exists to avoid - so the question asked is whether a
|
||||
# target it CAN read is present, which needs no such knowledge.
|
||||
#
|
||||
# graphql counts as one, because dejima's own github-pr reads review threads
|
||||
# through it and the API has no other route to them. A mutation does not:
|
||||
# `gh api graphql -f query='mutation{mergePullRequest(...)}'` is what an agent
|
||||
# reaches for once `gh pr merge` refuses, and no repository scoping can be read
|
||||
# out of a GraphQL document. Queries through it can only read, which the
|
||||
# read-only token could do anyway.
|
||||
_dejima_forge_path_verdict() {
|
||||
[ -n "${DEJIMA_FORGE_REPO_PATHS:-}" ] && [ "$#" -gt 0 ] || return 0
|
||||
|
||||
local -a repo_paths
|
||||
read -ra repo_paths <<< "$DEJIMA_FORGE_REPO_PATHS"
|
||||
local spec segment arg path owner name scopable
|
||||
for spec in "${repo_paths[@]}"; do
|
||||
[ "$1" = "${spec%%:*}" ] || continue
|
||||
segment="${spec#*:}"
|
||||
scopable=0
|
||||
for arg in "$@"; do
|
||||
# A GraphQL document whose operation is a mutation. Matched
|
||||
# anywhere in the argument, because the document arrives as the
|
||||
# value of a field flag - `-f query=mutation{...}` - rather than as
|
||||
# an argument of its own. Matched before the brace test below,
|
||||
# which a document would otherwise satisfy.
|
||||
#
|
||||
# Over EVERY argument, including the dash-prefixed ones, because a
|
||||
# flag can carry its value attached: `--field=query=mutation{...}`
|
||||
# and `-Fquery=mutation{...}` put the document inside one word that
|
||||
# the scoping loop below skips. This check needs no flag table to
|
||||
# read it, which is what lets it look at all of them - unlike the
|
||||
# repository scoping, which does.
|
||||
#
|
||||
# Both boundaries are anything that cannot continue an
|
||||
# identifier, rather than whitespace and a brace. GraphQL treats
|
||||
# commas and comments as ignored tokens, so `mutation,{...}` and
|
||||
# `mutation#c` followed by the body are valid documents, and a
|
||||
# document carrying more than one operation puts `}` immediately
|
||||
# before the second one. Each of those sat just outside a narrower
|
||||
# boundary. The class excludes `_` on both sides, so a field called
|
||||
# foo_mutation is not a mutation.
|
||||
if [[ "$arg" =~ (^|[^A-Za-z0-9_])mutation([^A-Za-z0-9_]|$) ]]; then
|
||||
DEJIMA_GUARD_REASON="a GraphQL mutation reaches any repository and any write the token allows, and no repository scoping can be read out of it. Use the subcommands, and leave a merge to the operator."
|
||||
return 1
|
||||
fi
|
||||
# A body this cannot read is a body it cannot tell a query from a
|
||||
# mutation in. Two spellings put one out of reach: a field value
|
||||
# read from a file, and --input, which takes the whole request body
|
||||
# from a file or from stdin. Refused under this subcommand only;
|
||||
# the document is one argument, so passing it inline is the fix.
|
||||
case "$arg" in
|
||||
*=@*|--input|--input=*)
|
||||
DEJIMA_GUARD_REASON="'$1' takes its body from a file or from stdin, so what it sends is not in the command for this to read. Pass the document inline."
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
# Everything from here needs to know which argument is the
|
||||
# endpoint, so the flags go.
|
||||
case "$arg" in -*) continue ;; esac
|
||||
[ "$arg" = graphql ] && { scopable=1; continue; }
|
||||
# gh substitutes {owner} and {repo} from the checkout, so a path
|
||||
# carrying them names this repository by construction and comparing
|
||||
# the literal braces would refuse it.
|
||||
case "$arg" in *'{'*) scopable=1; continue ;; esac
|
||||
path="${arg#/}"
|
||||
case "$path" in "$segment"/*/*) ;; *) continue ;; esac
|
||||
path="${path#"$segment"/}"
|
||||
owner="${path%%/*}"
|
||||
path="${path#*/}"
|
||||
name="${path%%/*}"
|
||||
[ -n "$owner" ] && [ -n "$name" ] || continue
|
||||
if ! _dejima_same_repo "$owner/$name"; then
|
||||
_dejima_refuse_repo "the path '$arg'" "$owner/$name"
|
||||
return 1
|
||||
fi
|
||||
scopable=1
|
||||
done
|
||||
if [ "$scopable" = 0 ]; then
|
||||
DEJIMA_GUARD_REASON="'$1' names no endpoint under '$segment/', so there is nothing here saying which repository this reaches. Name the endpoint as '$segment/${DEJIMA_FORGE_REPO:-<owner>/<name>}/...', or use the subcommands."
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
return 0
|
||||
}
|
||||
|
||||
_dejima_refuse_repo() {
|
||||
DEJIMA_GUARD_REASON="this session was launched against ${DEJIMA_FORGE_REPO:-no repository}, and $1 names '$2'. Act on the repository the sandbox mounts."
|
||||
}
|
||||
|
||||
# Case-insensitively: forges treat an owner and a repository name as
|
||||
# case-preserving but not case-sensitive, and a refusal over capitalisation
|
||||
# would be a false one.
|
||||
_dejima_same_repo() {
|
||||
[ -n "${DEJIMA_FORGE_REPO:-}" ] && [ "${1,,}" = "${DEJIMA_FORGE_REPO,,}" ]
|
||||
}
|
||||
+18
-4
@@ -464,6 +464,16 @@ prefer credentials that cannot do anything else: a token that raises an
|
||||
anonymous API rate limit should be a fine-grained token with no permissions
|
||||
and no repository access, not a general-purpose one.
|
||||
|
||||
**A per-run fetch, not a per-container one.** The privileged job starts
|
||||
twenty-seven container pairs, and each filter container asks the forge for its
|
||||
published address ranges before it brings the policy up. Done once per
|
||||
container that is a burst, and GitHub answers a burst on `/meta` with a `403`
|
||||
from its secondary rate limit - which is not the hourly ceiling a token raises,
|
||||
so a valid credential does not prevent it (#357). The job fetches once into the
|
||||
directory it already shares with those containers, and the suite points every
|
||||
filter at the result; a container falls back to fetching for itself when the
|
||||
file is absent, which is what happens outside CI.
|
||||
|
||||
**The cache server.** `act_runner` enables it by default, and it binds on the
|
||||
runner host specifically so that job containers can reach it. That is an
|
||||
inbound service exposed to untrusted code. Turn it off where it is not
|
||||
@@ -523,7 +533,11 @@ can still add a workflow of its own.
|
||||
daemon, the answer is a host you are willing to lose, not a smaller grant.
|
||||
- **Before Gitea 1.27, the job token stays permissive.** Branch protection
|
||||
bounds where it can write; it does not take the token away.
|
||||
- **None of this is checked by anything.** The rest of dejima's security
|
||||
properties have tests behind them. A runner's configuration lives on a
|
||||
machine this repository has no access to, so it is on you to re-check it
|
||||
after upgrades and rebuilds.
|
||||
- **Only part of this is checked, and only from inside a job.**
|
||||
`test/ci-runner.bats` runs on both tiers and asserts what a job can see from
|
||||
where it stands: which tier it landed on, that the confined one has no docker
|
||||
socket and the privileged one does, and that the egress rules are in front of
|
||||
the confined job. Everything outside a job - the runner's own configuration
|
||||
file, its labels, what else shares the machine - lives somewhere this
|
||||
repository has no access to, so it is on you to re-check that after upgrades
|
||||
and rebuilds.
|
||||
|
||||
+151
-22
@@ -55,6 +55,14 @@ Every tool takes `--help`, which describes its own arguments and what they do.
|
||||
That is the fastest answer to "what does this flag mean"; this page is for the
|
||||
config file behind them.
|
||||
|
||||
**A launch flag overrides the file for that launch only, and every `--no-*`
|
||||
flag only ever narrows.** Nothing dejima accepts on the command line turns a
|
||||
setting back on. A config that says `off` is a decision somebody made in a
|
||||
file they can reread, and a flag that could undo one in passing would make the
|
||||
file's answer the weaker of the two - so the flags that exist are the ones that
|
||||
take something away. `--isolated` is the far end of that: see
|
||||
[Isolated sessions](#isolated-sessions).
|
||||
|
||||
### `[core]` - what this sandbox runs
|
||||
|
||||
The three selections come first because everything below depends on them: the
|
||||
@@ -157,7 +165,7 @@ you selected, the second is push access when GitHub *is* your forge.
|
||||
| `[forge.gitea] api` | **required** for `forge = gitea` | `https://<gitea host>/api/v1` |
|
||||
| `[forge.gitea] token_file` | `~/.config/gitea/token` | path to the Gitea/Forgejo API token (the file, not the token itself) |
|
||||
| `[forge.github] push_token_file` | unset | path to a **write-capable** PAT, used only when `[core] forge = github`: deliberately separate from `[github] token_file`; see "GitHub push" in [docs/security.md](security.md) |
|
||||
| `[github] token_file` | `~/.config/gh/sandbox-token` | path to a read-only PAT for private GitHub repos / `gh`. Read whichever forge is selected, and it should stay read-only |
|
||||
| `[github] token_file` | `~/.config/gh/sandbox-token` | path to a read-only PAT for private GitHub repos / `gh`, and it should stay read-only. Read for every session EXCEPT when `[core] forge = github` and `push_token_file` is set: both fill `GH_TOKEN`, the forge's wins, and the launch says so. Which token can push is the half that matters |
|
||||
|
||||
### `[mounts]` - extra host paths
|
||||
|
||||
@@ -254,8 +262,16 @@ name and its subdomains, with no exact-name form to use instead - so a bare
|
||||
label would grant its whole suffix, and `org` would ask for every `.org` name.
|
||||
The same floor applies to every key whose value becomes a permitted
|
||||
destination: `[core] internal_domains`, a forge's `ssh_host` and `api`, a
|
||||
backend's `base_url`, and `[logging] loki_url`. A single-label value is refused
|
||||
when the entry is declared and again at launch, naming the key it came from.
|
||||
backend's `base_url`, and `[logging] loki_url`. A value that is not a host name of at least two labels is
|
||||
refused when the entry is declared and again at launch, naming the key it came
|
||||
from - which includes a value that reached the launcher through a URL and lost
|
||||
its host on the way, such as a `loki_url` carrying userinfo.
|
||||
|
||||
An approval granted before this rule existed is still read rather than called
|
||||
corrupt, so an upgrade does not present as tampering; the launch stops instead,
|
||||
naming the entry. There is no `remove` verb, so clearing one means editing the
|
||||
store by hand - `~/.config/dejima/allowed-domains` for a global approval, or
|
||||
`~/.config/dejima/projects/<slug>/allowed-domains` for a project's.
|
||||
|
||||
**Declaring grants nothing.** The project directory is bind-mounted
|
||||
read-write, so a session could write these files; they are read only to
|
||||
@@ -269,6 +285,7 @@ dejima-project status ~/myproject # declared vs approved vs missing
|
||||
dejima-project approve ~/myproject # review and approve the declared set
|
||||
dejima-allow --project ~/myproject crates.io
|
||||
dejima-allow --global crates.io # applies to every project
|
||||
dejima-allow --container <id> dl.example.com files.example.org # several
|
||||
dejima-dep add --project ~/myproject cargo:cargo-nextest
|
||||
dejima-dep add --global cargo:cargo-nextest
|
||||
```
|
||||
@@ -276,7 +293,19 @@ dejima-dep add --global cargo:cargo-nextest
|
||||
Two of those tools also write the declaration, so an approval you make by
|
||||
hand does not stay invisible to everyone else on the project:
|
||||
`dejima-allow --project` appends to `.dejima/allowed-domains`, and
|
||||
`dejima-dep add --project` appends to `.dejima/dependencies`. Both write the
|
||||
`dejima-dep add --project` appends to `.dejima/dependencies`.
|
||||
|
||||
`dejima-allow` takes up to twenty domains at once, which is what a toolchain
|
||||
fetching from several hosts actually needs. One goes through as a single
|
||||
decision you already made by typing it; more than one is printed back to you
|
||||
one per line and confirmed before anything is added, defaulting to no. The cap
|
||||
and the confirmation are the same point: a list is where an unwanted name rides
|
||||
along unread, the agent that suggested the command is what might have put it
|
||||
there, and neither mechanism helps if the list is too long to finish reading.
|
||||
Every name is validated before any of them is shown, so what you are confirming
|
||||
is what will be added and no entry can carry an escape sequence that rewrites
|
||||
the lines above it. Confirming needs a terminal, so a script does them one at a
|
||||
time. Both write the
|
||||
approval first - that is the part that grants - and report separately on the
|
||||
declaration, so a project you can't write to still gets you the approval.
|
||||
`--global` writes no declaration at all: there is no project to declare in.
|
||||
@@ -796,8 +825,15 @@ ssh_key = ~/.ssh/dejima_ed25519
|
||||
`entrypoint.sh` loads it into a container-local `ssh-agent` at startup and
|
||||
deletes the plaintext copy immediately after - the sandbox user itself never
|
||||
has a path to the raw key, only the agent socket (see
|
||||
[security notes](security.md) for the full mount rationale). Setting it means
|
||||
a live forwarded agent is no longer required at all.
|
||||
[security notes](security.md) for the full mount rationale).
|
||||
|
||||
Setting it means a forwarded agent is not merely unnecessary but **not
|
||||
mounted**: your own `SSH_AUTH_SOCK` is not passed to the container at all, so
|
||||
nothing in a session can sign with your personal identities by naming the
|
||||
socket directly. That is the point of choosing a dedicated key, and it is worth
|
||||
knowing before you choose one - a session that also needed the forwarded agent
|
||||
for something else (a second remote, a private submodule over ssh) no longer
|
||||
has it. Leave this unset to keep the agent.
|
||||
|
||||
## Using a different editor/application
|
||||
|
||||
@@ -947,11 +983,18 @@ dejima takes that one character back for the host while a session runs
|
||||
(`bin/dejima-tty-suspend`). Ctrl-C and Ctrl-\ are deliberately left alone and
|
||||
still reach the session, which is how a harness expects to be interrupted.
|
||||
|
||||
Suspending is not detaching. A suspended session is stopped, so it makes no
|
||||
progress until you resume it, and it belongs to the terminal you started it
|
||||
from. For a session that keeps working while you are away, or that you want to
|
||||
pick up from somewhere else, run dejima inside tmux and use
|
||||
[`dejima-attach`](remote-access.md).
|
||||
Suspending is not pausing, and the difference is worth knowing before you reach
|
||||
for it. What stops is the host side: `dejima` itself, the container run, the
|
||||
credential proxy and the conversation shipper, which share one process group so
|
||||
the kernel stops and continues them together. The container is not in that
|
||||
group. It keeps running under the container runtime, with its mounts, its
|
||||
firewall grants and its forge token, and keeps making requests while the
|
||||
terminal looks stopped - see
|
||||
[what dejima does not sandbox](security.md#what-dejima-does-not-sandbox).
|
||||
|
||||
Suspending is not detaching either: the job belongs to the terminal you started
|
||||
it from. For a session you want to pick up from somewhere else, run dejima
|
||||
inside tmux and use [`dejima-attach`](remote-access.md).
|
||||
|
||||
## Shared scratch space
|
||||
|
||||
@@ -1019,6 +1062,81 @@ that is true, rather than moving an operator's files on its own.
|
||||
`dejima-share adopt <project>` moves them into one project's share after
|
||||
showing what it will move and asking, and it never overwrites.
|
||||
|
||||
## Isolated sessions
|
||||
|
||||
`dejima --isolated` runs a session with nothing before it and nothing after it.
|
||||
No project is mounted, no memory or transcript crosses either boundary, and the
|
||||
one thing that outlives it is a `~/shared` of its own.
|
||||
|
||||
```sh
|
||||
dejima --isolated # from anywhere - the directory you are in is not used
|
||||
```
|
||||
|
||||
| | ordinary launch | `--isolated` |
|
||||
|---|---|---|
|
||||
| working directory | the project, read-write at its own path | `/dejima/work`, container-local |
|
||||
| `~/shared` | this project's, shared by every session against it | made fresh, never reused |
|
||||
| memory and transcripts | `~/.claude/projects/<slug>`, mounted | none |
|
||||
| shell history, nvim state | volumes that outlive the container | this run's own, removed with it |
|
||||
| domains, dependencies, tools | the global set plus this project's | the global set only |
|
||||
| `[mounts]` lists | applied | dropped, and named at launch |
|
||||
| forge, workflow | as configured | `none` and `local-only`, unless named |
|
||||
| forge token, `[github] token_file` | both cross | neither, unless a forge is named |
|
||||
| forwarded ssh agent | mounted when the workflow pushes | not mounted |
|
||||
| `[identity] ssh_key` | mounted | mounted |
|
||||
| conversation log, epitaph | as configured | off |
|
||||
|
||||
Your own `~/.claude` hooks, skills, commands, agents and output styles are
|
||||
still mounted read-only, as is `[mounts] nvim_config`. Those are yours, no
|
||||
session can write them, and a session without them is a session without the
|
||||
tools you built for it.
|
||||
|
||||
**The directory you launch from decides nothing.** An approval store is found
|
||||
by walking up from the launch directory, so a home directory that was once
|
||||
approved as a project would otherwise hand an anonymous session that project's
|
||||
allowed domains and its dependency layer - which is the whole reason to be able
|
||||
to run this from a fresh terminal in `$HOME` without thinking about it.
|
||||
|
||||
**There is no config key, in any spelling.** A mode this strong is a decision
|
||||
to make in the command, where it is visible, rather than a line in a file that
|
||||
every later launch inherits. For the same reason `--ro` and `--rw` are refused
|
||||
rather than dropped: a path typed into this command cannot be quietly ignored,
|
||||
where a `[mounts]` list written months ago can be - with a line saying which.
|
||||
|
||||
**What it gives up.** The conversation log and the epitaph both read a
|
||||
transcript on the host, and this mode mounts none, so both are off and the
|
||||
launch says so rather than leaving you to find an empty record later. The
|
||||
exit-time audit is skipped for the same reason: its subject is the project's
|
||||
own files. If you want either back in this mode, that is
|
||||
[#388](https://gitbox.branderson.io/dejima/dejima/issues/388) - both are your
|
||||
record rather than the agent's, and neither ever reaches a future session.
|
||||
|
||||
**No forge, unless you name one on the same command.** `--isolated` sets
|
||||
`[core] forge` to `none` and `[core] workflow` to `local-only` for that launch,
|
||||
and withholds `[github] token_file` with them. Naming either is how you ask for
|
||||
a session that clones and opens a pull request, which is a real use of this
|
||||
mode:
|
||||
|
||||
```sh
|
||||
dejima --isolated # no forge, pushes nothing
|
||||
dejima --isolated --forge gitea --workflow worktree-pr # clones, pushes, opens a PR
|
||||
```
|
||||
|
||||
Order does not matter: `--forge gitea --isolated` means the same thing, and
|
||||
naming a forge brings the credentials back with it.
|
||||
|
||||
The table above is the whole of what crosses. Two rows surprise people:
|
||||
`[github] token_file` is withheld even though it is not a forge setting, and
|
||||
an `[identity] ssh_key` is mounted even though the forwarded agent is not -
|
||||
so a session with no forge still holds a key that can reach a host over ssh.
|
||||
Withholding that key too, for any launch whose workflow never pushes, is
|
||||
[#392](https://gitbox.branderson.io/dejima/dejima/issues/392).
|
||||
|
||||
**Why each of those is the way it is**, and why this is a narrower blast
|
||||
radius rather than a stronger sandbox, is in
|
||||
[docs/security.md](security.md#a-session-with-no-project-mounted). Worth
|
||||
reading before relying on this mode.
|
||||
|
||||
## Reading what a session left behind
|
||||
|
||||
When a session compacts, the summary it writes is the only durable account of
|
||||
@@ -1140,22 +1258,33 @@ without the reason it is normally off (see
|
||||
[docs/security.md](security.md#github-push-blocked-by-default-optional-opt-in)),
|
||||
so the reason is supplied where it will be read.
|
||||
|
||||
`git-push-guard.sh` (`PreToolUse`/`Bash`, also `DEJIMA`-gated) blocks
|
||||
any `git push` whose resolved remote isn't the Gitea host, and any push to
|
||||
`main`/`master` even there. Like the other hooks, this is a fast-fail guard
|
||||
against an overeager agent, not a hard security boundary - a session has full
|
||||
shell access and could in principle edit `.git/config` to route around it.
|
||||
`git-push-guard.sh` (`PreToolUse`/`Bash`, also `DEJIMA`-gated) blocks any `git
|
||||
push` whose resolved remote is not one of the configured forge's hosts, and any
|
||||
push straight to a branch the workflow protects. Neither list is named here:
|
||||
both are resolved host-side from the selected forge and workflow, so this is
|
||||
the same rule whichever forge is in use. Like the other hooks, this is a
|
||||
fast-fail guard against an overeager agent, not a hard security boundary - a
|
||||
session has full shell access and could in principle edit `.git/config` to
|
||||
route around it.
|
||||
GitHub push is blocked by default too (see
|
||||
[GitHub push](security.md#github-push-blocked-by-default-optional-opt-in) in
|
||||
docs/security.md) unless `--allow-github-push` is given. For a real guarantee
|
||||
against pushes to main/master, use branch protection on the remote itself.
|
||||
|
||||
`merge-guard.sh` (`PreToolUse`/`Bash`, `DEJIMA`-gated) blocks
|
||||
`gitea-pr merge` inside the sandbox. It's the API-side counterpart to the rule
|
||||
above: a merge puts a branch on `main` without ever performing a push, so
|
||||
`git-push-guard.sh` never sees it. A hook is handed a command rather than a
|
||||
conversation, so it can't tell an instructed merge from an unprompted one -
|
||||
which is why it's scoped to the sandbox rather than applied everywhere. On the
|
||||
Both this hook and the PATH shadow beside it read the decision out of
|
||||
`docker/guard-lib.sh`, which is the only copy of the rules. A push that the
|
||||
library cannot read is refused rather than passed through: an unresolvable
|
||||
remote, `--mirror`, and `--git-dir` or `--work-tree`, each of which names a
|
||||
repository other than the one the remote would be resolved in.
|
||||
|
||||
`merge-guard.sh` (`PreToolUse`/`Bash`, `DEJIMA`-gated) applies the configured
|
||||
forge's policy inside the sandbox: the blocked merge command, and a command
|
||||
aimed at a repository the session was not launched against. It's the API-side
|
||||
counterpart to the rule above: a merge puts a branch on `main` without ever
|
||||
performing a push, so `git-push-guard.sh` never sees it. A hook is handed a
|
||||
command rather than a conversation, so it can't tell an instructed merge from
|
||||
an unprompted one - which is why it's scoped to the sandbox rather than
|
||||
applied everywhere. On the
|
||||
host it does nothing, and you can still ask an agent to merge. The same
|
||||
fast-fail caveat applies with more force than usual here: the session has the
|
||||
Gitea token in its environment and could call the merge endpoint with `curl`
|
||||
|
||||
+42
-35
@@ -25,9 +25,10 @@ run them.
|
||||
| `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 |
|
||||
| `.dejima.md` | Project-level agent instructions, harness-agnostic: `CLAUDE.md`'s role under a neutral name. dejima loads a project's into the session context; this one is dejima's own |
|
||||
| `hooks/git-push-guard.sh` | `PreToolUse` hook: blocks pushes outside the allowed host(s)/branches |
|
||||
| `hooks/git-push-guard.sh` | `PreToolUse` hook: blocks pushes outside the allowed host(s)/branches. Turns the reported command into argv and asks `docker/guard-lib.sh` |
|
||||
| `hooks/deny-dangerous-bash.sh` | `PreToolUse` hook: blocks obviously destructive Bash commands |
|
||||
| `hooks/merge-guard.sh` | `PreToolUse` hook: blocks the configured forge's merge command (`DEJIMA_MERGE_BLOCK`) in the sandbox: merging is a human decision |
|
||||
| `hooks/merge-guard.sh` | `PreToolUse` hook: applies the forge policy - the blocked merge command (`DEJIMA_MERGE_BLOCK`), and a command aimed at another repository - to a reported command. Named for the merge block, which is the rule it was created for |
|
||||
| `docker/guard-lib.sh` | The push and merge policy itself, read out of argv. The two hooks above and the two PATH shadows all call it, so a bypass is fixed once rather than twice |
|
||||
| `hooks/dejima-notify.sh` | Drops an empty `waiting`/`done` marker for a remote-enabled session. Registered as a `Stop` hook only; `waiting` is there for a harness that can say it without having stopped - see [docs/remote-access.md](remote-access.md) |
|
||||
| `bin/dejima-attach` | Lists live sessions and attaches to one; the shared core behind both remote front doors |
|
||||
| `bin/dejima-audit` | Advisory exit-time diff of host-persistence surfaces (git config, `.claude/`, etc.). `dejima-audit show` / `dejima-audit ack` default to the enclosing repo |
|
||||
@@ -49,7 +50,6 @@ run them.
|
||||
| `bin/dejima-epitaph` | Host-side: keeps the summary a session leaves when its context is compacted away, in a store nothing mounts. `save` runs from `bin/dejima`'s exit trap; `list`/`search`/`show` are the operator's, and only ever read |
|
||||
| `bin/dejima-harness-lib.sh` | Loads the selected harness's plugin and provides the `emit_*` functions it declares through; the guarded-store refusal runs in `emit_mount`. Not on PATH |
|
||||
| `bin/harness/<name>.sh` | One per supported harness: what dejima does for that harness that no manifest value could state. Trusted computing base, sourced by `bin/dejima` |
|
||||
| `bin/dejima-color-lib.sh` | The colour and terminal-detection helpers the CLIs share. Not on PATH |
|
||||
| `bin/dejima-slug-lib.sh` | The `~/.claude/projects` slug scheme and which slugs belong to a workspace; sourced by `bin/dejima` (what to mount) and `bin/dejima-epitaph` (whose transcripts to keep), so the two cannot disagree. Not on PATH |
|
||||
| `bin/dejima-ttyd` | Launches `ttyd` on loopback in front of `dejima-attach` |
|
||||
| `systemd/*.service` | User units for `dejima-ttyd` and `dejima-notify-watch`; ship disabled, installed by hand |
|
||||
@@ -106,27 +106,29 @@ notification path carries no sandbox-controlled byte, and that no marker
|
||||
name outside `waiting`/`done` is ever acted on. `audit.bats` covers
|
||||
`bin/dejima-audit`'s own diffing.
|
||||
|
||||
`config.bats`, `providers.bats` and `proxy.bats` round out the suites that
|
||||
need no Docker either: `config.bats` covers `~/.config/dejima/config`
|
||||
parsing and `install.sh`'s legacy-variable warnings (stubbing `docker` itself,
|
||||
since the fast CI job's container deliberately has no daemon), `providers.bats`
|
||||
covers the forge/workflow/harness manifest contract documented in
|
||||
[docs/providers.md](providers.md), and `proxy.bats` covers the credential
|
||||
proxy's own request handling. `conversation.bats` runs
|
||||
`bin/dejima-conversation-ship` against a real transcript and a real HTTP
|
||||
endpoint standing in for Loki, and asserts on what arrives: that a session's
|
||||
lines ship, that a torn line waits until it is whole, that a sibling project
|
||||
whose directory name merely extends this one is not swept up with it, and
|
||||
that neither the in-memory batch nor the unread tail is lost to the kill that
|
||||
ends a session.
|
||||
`config.bats`, `providers.bats`, `proxy.bats`, `launch.bats` and `share.bats`
|
||||
round out the suites that need no Docker either: `config.bats` covers
|
||||
`~/.config/dejima/config` parsing and `install.sh`'s legacy-variable warnings
|
||||
(stubbing `docker` itself, since the fast CI job's container deliberately has
|
||||
no daemon), `providers.bats` covers the forge/workflow/harness manifest
|
||||
contract documented in [docs/providers.md](providers.md), `proxy.bats` covers
|
||||
the credential proxy's own request handling, `launch.bats` the command line a
|
||||
launch builds, and `share.bats` the shared scratch directory's own layout.
|
||||
`conversation.bats` runs `bin/dejima-conversation-ship` against a real
|
||||
transcript and a real HTTP endpoint standing in for Loki, and asserts on what
|
||||
arrives: that a session's lines ship, that a torn line waits until it is whole,
|
||||
that a sibling project whose directory name merely extends this one is not
|
||||
swept up with it, and that neither the in-memory batch nor the unread tail is
|
||||
lost to the kill that ends a session.
|
||||
|
||||
One assertion is worth calling out because it can only run for real on a
|
||||
Docker host: `runtime.bats` proves that a service bound to the host's
|
||||
loopback is unreachable from inside a container while one bound to the
|
||||
bridge gateway *is* reachable. The second half is a positive control - it is
|
||||
what stops the first from passing in an environment where the container
|
||||
reaches nothing at all. That pair is the ttyd front door's whole containment
|
||||
argument, so run it before trusting a front door you just set up.
|
||||
Docker host: `runtime.bats` proves that a service on the host is unreachable
|
||||
from inside a container - bound to loopback, and bound to the bridge gateway
|
||||
on a port the policy was not told to open. A second container, given that one
|
||||
port, reaches it; that is the positive control, and it is what stops the
|
||||
others from passing in an environment where the container reaches nothing at
|
||||
all. That set is the ttyd front door's whole containment argument, so run it
|
||||
before trusting a front door you just set up.
|
||||
|
||||
Every mutation in `runtime.bats` (firewall rules, address-set entries) happens
|
||||
inside the test container's own network namespace, same as any other
|
||||
@@ -178,10 +180,12 @@ subset has.
|
||||
|
||||
`runtime.bats` is the only suite that needs a Docker daemon: it is the one
|
||||
that builds the image and boots real containers to assert against.
|
||||
Everything else, `audit.bats`, `ci-runner.bats`, `config.bats`,
|
||||
`conversation.bats`, `project.bats`, `providers.bats`, `proxy.bats`,
|
||||
`remote.bats` and `static.bats`, runs anywhere a shell does, including inside
|
||||
a dejima sandbox itself, which has no Docker daemon by design.
|
||||
`ci-runner.bats` needs something rarer - a real CI runner of either tier - and
|
||||
skips itself anywhere else. Everything in between runs anywhere a shell does,
|
||||
including inside a dejima sandbox itself, which has no Docker daemon by design.
|
||||
`test/run-confined.sh` discovers that set rather than naming it, so a suite
|
||||
added under `test/` is in the lane the day it lands - which is also why no list
|
||||
of them is kept here.
|
||||
|
||||
`.gitea/workflows/tests.yml` also runs `shellcheck` over every shell script in
|
||||
the repo, as its own job. `test/shellcheck.sh` holds the file list, discovered
|
||||
@@ -199,9 +203,10 @@ obscurely partway through; the file says why each is needed.
|
||||
## Adding a new harness
|
||||
|
||||
`[core] harness` picks a directory under `harnesses/`, and that harness's
|
||||
manifest declares eleven keys (see [providers.md](providers.md)). The
|
||||
inventory below is everything dejima does that is shaped by *which* harness is
|
||||
selected: which parts are the manifest's, which are the plugin's, and
|
||||
manifest declares what dejima needs to know about it - every key is in
|
||||
[providers.md](providers.md). The inventory below is everything dejima does
|
||||
that is shaped by *which* harness is selected: which parts are the manifest's,
|
||||
which are the plugin's, and
|
||||
which are still written against Claude Code by name. Taken from a pass over
|
||||
the tree, so each row names the file rather than describing a sketch - and a
|
||||
row that says "in the plugin" is one a second harness supplies rather than one
|
||||
@@ -245,12 +250,14 @@ R2 was a hard gate until the guards stopped needing it. Two of the three it
|
||||
hosted - the push guard and the merge guard - are now PATH shadows over `git`
|
||||
and the forge CLI (`docker/git-guard.sh`, `docker/forge-guard.sh`), which hold
|
||||
whatever the harness can or cannot intercept, and read argv rather than
|
||||
regex-matching a reconstructed command string. That left one guard depending
|
||||
on interception, and it is the one this repository already describes as "a
|
||||
fast-fail guard against an overeager agent doing something obviously
|
||||
destructive, not a boundary against one that's actively trying to get around
|
||||
them" (docs/security.md). A gate that costs a harness its support should be
|
||||
guarding more than that.
|
||||
regex-matching a reconstructed command string. The hooks beside them read argv
|
||||
too: they split the reported command into words and ask the same library
|
||||
(`docker/guard-lib.sh`), so the overlap cannot become a disagreement. That
|
||||
left one guard depending on interception, and it is the one this repository
|
||||
already describes as "a fast-fail guard against an overeager agent doing
|
||||
something obviously destructive, not a boundary against one that's actively
|
||||
trying to get around them" (docs/security.md). A gate that costs a harness its
|
||||
support should be guarding more than that.
|
||||
|
||||
Declaring one absent is a real decision with a visible cost, not a
|
||||
formality. It is there so a harness can be supported honestly at a smaller
|
||||
|
||||
+22
-7
@@ -127,8 +127,8 @@ operator can set, resolved the same way as any other config key
|
||||
| `capabilities` | `?list:identifier` | (empty) | What this forge can do - `pr`, `issues`, etc. A workflow's own `requires` is checked against this set at launch; a workflow asking for a capability this forge lacks is refused. |
|
||||
| `pr_command` | `?command` | (empty) | The PR-management command name the forge's briefing prose and `env_export`/`merge_block` refer to. |
|
||||
| `issue_command` | `?command` | (empty) | The issue-management command name, same idea. |
|
||||
| `merge_block` | `?command` | (empty) | The command prefix refused inside the sandbox - only when the workflow's `block_merge = on`. Enforced by `docker/forge-guard.sh`, which shadows the named CLI on PATH and compares argv, so it holds for a harness that cannot intercept a tool call; `hooks/merge-guard.sh` applies the same rule to a command string, for the harnesses that can. Matched as a run of consecutive non-flag arguments, so a flag before the subcommand does not slip past it. |
|
||||
| `repo_flags` | `?list:flag` | (empty) | The option names this forge's CLI accepts for naming a repository other than the one the session was launched against - `--repo -R` for `gh`. `docker/forge-guard.sh` refuses a value that is not that repository, in every spelling of the flag itself: `--repo x`, `--repo=x`, `-R x`, `-Rx`, and the letter inside a short-flag cluster (`-wR x`, `-wRx`). Fast-fail, not a boundary, for the same reason `merge_block` is not one: the session holds the token. Leave it empty for a CLI that resolves the repo itself and refuses another host, as the gitea ones do - a forge is not required to be aimable. |
|
||||
| `merge_block` | `?command` | (empty) | The command prefix refused inside the sandbox - only when the workflow's `block_merge = on`. Enforced by `docker/forge-guard.sh`, which shadows the named CLI on PATH and compares argv, so it holds for a harness that cannot intercept a tool call; `hooks/merge-guard.sh` applies the same rule to a reported command for the harnesses that can, through the same library, so the two cannot disagree. Matched as a run of consecutive non-flag arguments, so a flag before the subcommand does not slip past it. |
|
||||
| `repo_flags` | `?list:flag` | (empty) | The option names this forge's CLI accepts for naming a repository other than the one the session was launched against - `--repo -R` for `gh`. `docker/forge-guard.sh` refuses a value that is not that repository, in every spelling of the flag itself: `--repo x`, `--repo=x`, `-R x`, `-Rx`, and the letter inside a short-flag cluster (`-wR x`, `-wRx`). Fast-fail, not a boundary, for the same reason `merge_block` is not one: the session holds the token. Leave it empty for a CLI that resolves the repo itself and refuses another host, as the gitea ones do - a forge is not required to be aimable. **Enforced only for a CLI that also appears in `merge_block` or `disclose`:** those two are what name the commands the shadow is linked over and the hook checks against, so a forge declaring this and neither of them would get the rule applied to nothing. |
|
||||
| `identity_keys` | `?list:identifier` | (empty) | Which of this forge's own `keys` name an **account** rather than the forge itself - the credential paths, which differ per session, where an API URL does not. One declaration, two rules: a key named here is one an `[identity.<name>]` section may set, and one a project may not declare in its own `.dejima/`. Core keeps no list of its own, so a forge whose credential is spelled differently needs no change here. The identity inherits the type from this forge's `keys` entry, not a second copy of it. |
|
||||
| `repo_env` | `?identifier` | (empty) | The environment variable this forge's CLI reads for the same thing - `GH_REPO`. Declared separately because it takes no argument and would otherwise be the way around every flag above. |
|
||||
| `repo_paths` | `?list:repopath` | (empty) | A repository this forge's CLI names in a path rather than in a flag, as `<subcommand>:<first segment>` - `api:repos` for `gh api repos/<owner>/<name>/...`. Scoped to a subcommand on purpose: comparing every positional argument that contains two slashes would refuse an issue title that happens to look like one, and a false refusal costs an honest agent a working command. Paths carrying `gh`'s own `{owner}`/`{repo}` placeholders are left alone, since those resolve to this repository by construction. |
|
||||
@@ -234,10 +234,11 @@ A workflow's `briefing.md` is plain Markdown plus two marker forms that
|
||||
| `api_domains` | `?list:hostname` | (empty) | Hostnames this harness's own machinery must reach - its update host, its telemetry endpoint. They join the built-in part of the permitted set rather than the operator's, and are not exempted from the check that refuses an answer pointing inside a private network - a harness's endpoints are public names, and exempting a public name is what turns one influenced answer into egress to an internal host. |
|
||||
| `api_styles` | `?list:identifier` | (empty) | Which backend `api_style`s this harness has a client for. `claude-code` speaks `anthropic`; `cline` speaks both, which is what makes it the backend-agnostic one. Empty means unchecked. |
|
||||
| `guards` | `?list:identifier` | (empty) | Which of dejima's guard rails this harness can host: `bash-deny`, `push-guard`, `merge-guard`, `notify`. A name outside that set is refused rather than treated as an unknown guard, since a typo would otherwise turn a guard off silently. The ones a harness does not declare are named at launch - see "What a harness must be able to do" in [development.md](development.md). |
|
||||
| `assumed_backend` | `?provider:backend` | (empty) | Which backend this harness talked to before `[harness.<name>] backend` existed. Read only when that key is unset, and it writes into the operator's config rather than being consulted at launch - the one key here that does. |
|
||||
| `briefing_args` | `?args` | (empty) | The flag that carries the briefing to a harness taking it on the command line - for Claude Code, `--append-system-prompt`. `dejima-harness-wrapper` reads the rendered briefing and passes it as a single value, so every invocation carries it and not only the entrypoint's. Set, the session-start hook stops carrying the briefing and reduces to the session id, which is the one thing that does not exist until the harness starts. |
|
||||
| `briefing_path` | `?homepath` | (empty) | Where this harness reads its briefing from, relative to `$HOME`, for a harness that has no session-start hook to be handed it through. Set, `entrypoint.sh` renders the briefing into that path root-owned and read-only before the session starts, and refuses to start it at all if that fails. Empty for a harness that takes the briefing on the command line (`briefing_args`, which is how Claude Code gets it) or that can be handed it through a session-start hook. |
|
||||
| `no_confirm_args` | `?template` | (empty) | The flags that make the harness run without confirming each tool call. Required in practice: dejima refuses to launch a harness that names none, because unattended operation is the premise the container is built on (R1 in [development.md](development.md)). |
|
||||
| `settings_args` | `?template` | (empty) | How this harness is told to read dejima's own settings layer, mounted at `/etc/dejima-settings.json`. Empty for a harness that takes its overrides some other way. |
|
||||
| `no_confirm_args` | `?args` | (empty) | The flags that make the harness run without confirming each tool call. Required in practice: dejima refuses to launch a harness that names none, because unattended operation is the premise the container is built on (R1 in [development.md](development.md)). |
|
||||
| `settings_args` | `?args` | (empty) | How this harness is told to read dejima's own settings layer, mounted at `/etc/dejima-settings.json`. Empty for a harness that takes its overrides some other way. |
|
||||
| `keys` | `?list:keyspec` | (empty) | This harness's own `[harness.<name>]` config schema - same grammar as a forge's `keys`. |
|
||||
| `resume_command` | `?template` | (empty) | Prose template (may use `{{session_id}}`) shown at the end of the briefing for resuming this exact session on the host. Substitution is not recursive and the `template` type has no `=`, so a resume command that needs a second placeholder or an environment variable goes in the harness's briefing fragment instead, where both resolve. |
|
||||
| `transcript_update` | `enum:append\|rewrite` | `append` | How this harness's transcript files change, which decides how they can be read incrementally. `append` means the file only grows, one record per line, so the conversation shipper reads forward from a byte offset and consumes only whole lines. `rewrite` means the file is replaced wholesale, where an offset would land in the middle of a document - it is re-read when it changes and the records already shipped are skipped by count. Declared rather than sniffed, because append semantics against a rewritten file ship garbage. |
|
||||
@@ -437,12 +438,21 @@ Every `{{placeholder}}` in the assembled text must be on a fixed whitelist
|
||||
(`pr_command`, `issue_command`, `forge_name`, `forge_ssh_host`,
|
||||
`blocked_branches`, `container_id`, `project_dir`, `workspace_dir`,
|
||||
`session_id`, `resume_command`, `writable_mounts`, `network_allowlist`,
|
||||
`no_confirm_args`, `harness_state_dir`) - an unrecognized one is a
|
||||
`no_confirm_args`, `harness_state_dir`, `workspace_posture`,
|
||||
`project_posture`) - an unrecognized one is a
|
||||
hard refusal, not a literal pass-through. `{{session_id}}` and
|
||||
`{{container_id}}` are deliberately left as `@@SESSION_ID@@`/
|
||||
`@@CONTAINER_ID@@` markers instead of being resolved here, since neither is
|
||||
known until the container is actually running.
|
||||
|
||||
`workspace_posture` and `project_posture` are what
|
||||
[`--isolated`](configuration.md#isolated-sessions) changes. The preamble's
|
||||
claims about the mount, about `~/shared` being shared with every session
|
||||
against this project, and about the transcript outliving the container are
|
||||
substituted rather than written inline, because an isolated launch makes all
|
||||
three false - and a session that believes them leaves work for a resume that
|
||||
cannot happen.
|
||||
|
||||
### How the briefing reaches the session
|
||||
|
||||
**A session-start hook is a size-limited channel, which is why it is not the
|
||||
@@ -675,8 +685,13 @@ knowing about because they are what a second forge costs:
|
||||
as such, and a count of what was hidden. Everything else `gitea-pr` does has
|
||||
a `gh` subcommand already.
|
||||
- **`gh` can be aimed past the sandboxed repository, and is stopped.** The
|
||||
gitea CLIs refuse an origin that is not on the configured host, so they can
|
||||
only act on the repo the sandbox was launched against. `gh` takes `--repo`
|
||||
gitea CLIs resolve the repository from `origin` and refuse an origin that is
|
||||
not on the configured host, so there is no flag or variable to compare and
|
||||
nothing for this guard to declare. That is a narrower confinement than it
|
||||
sounds: `origin` lives in `.git/config`, inside the read-write project mount,
|
||||
and the gitea token is account-wide - so a session that rewrites `origin` to
|
||||
another repository **on the same host** reaches it. What bounds that is the
|
||||
token's scope, as it is for `gh`. `gh` takes `--repo`
|
||||
on nearly every subcommand, reads `GH_REPO`, and names repositories in `gh
|
||||
api` paths, so this forge declares all three (`repo_flags`, `repo_env`,
|
||||
`repo_paths`) and `docker/forge-guard.sh` refuses a value that is not the
|
||||
|
||||
+16
-10
@@ -52,11 +52,16 @@ running.
|
||||
|
||||
## The bind address is the one thing to get right
|
||||
|
||||
`docker/egress-policy` deliberately permits traffic 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.**
|
||||
The session's default-route address - the Docker bridge gateway - is your
|
||||
host, and it is the one host address a sandbox can route to at all. Combine
|
||||
that 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/egress-policy` opens no port on that address unless told to, so a
|
||||
front door bound to it is refused by the packet filter as well. Do not spend
|
||||
that: the clamp is the second layer, and binding the proxy correctly is the
|
||||
first.
|
||||
|
||||
So:
|
||||
|
||||
@@ -900,10 +905,11 @@ invisibly, and on the same `XDG_RUNTIME_DIR` as the gotcha above.
|
||||
|
||||
**The marker directory is a write channel into your runtime tmpfs**, and
|
||||
nothing bounds it. `/run/user/<uid>` is RAM-backed and sized at a fraction of
|
||||
physical memory, so a session filling `/run/dejima-notify` degrades your
|
||||
whole login session - D-Bus, the systemd user manager - not just dejima. A
|
||||
session can already fill the workspace mount, so this is an increment rather
|
||||
than a new exposure, but a RAM-backed filesystem fails harder and sooner.
|
||||
physical memory, so a session filling `${XDG_RUNTIME_DIR}/dejima/notify`
|
||||
degrades your whole login session - D-Bus, the systemd user manager - not just
|
||||
dejima. A session can already fill the workspace mount, so this is an increment
|
||||
rather than a new exposure, but a RAM-backed filesystem fails harder and
|
||||
sooner.
|
||||
There is deliberately no quota here; `rm -rf` on that run's marker directory (or
|
||||
logging out, which discards the tmpfs) is the recovery.
|
||||
|
||||
@@ -1013,7 +1019,7 @@ name, the session's age, its state, and the container id.
|
||||
The picker is coloured (gruvbox) when stdout is a terminal that says it can
|
||||
show it; `NO_COLOR=1` or a `dumb` `$TERM` turns that off, along with the
|
||||
box-drawing glyphs. `--list` is never coloured, terminal or not - the picker
|
||||
parses those rows itself and the README calls them scriptable.
|
||||
parses those rows itself and `dejima-attach --help` calls them scriptable.
|
||||
|
||||
Three states, from a registry `dejima` writes on launch and removes on
|
||||
exit:
|
||||
|
||||
+105
-8
@@ -87,7 +87,7 @@ last is what remains once something is.
|
||||
|
||||
| Layer | Reached by | Radius without | Bounded by | Radius with |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Agent, not yet hostile | ordinary operation | the workspace, its history, every mounted path, and the forge, at full speed | the session briefing and `.dejima.md`; the `PreToolUse` hooks | obvious destruction and out-of-remote pushes fail fast, and merging stays a human decision |
|
||||
| Agent, not yet hostile | ordinary operation | the workspace, its history, every mounted path, and the forge, at full speed | the session briefing and `.dejima.md`; the command guards | obvious destruction and out-of-remote pushes fail fast, and merging stays a human decision |
|
||||
| Container | ordinary operation | the operator's home directory, every credential in it, and the open internet | dejima | the workspace, allowlisted domains, and the forge token's scope |
|
||||
| Devbox VM, unprivileged | container escape | everything the account can read, and unrestricted egress to the internet and the LAN | `dejima-devbox-hardener` | the same VM, with LAN reach cut to what is declared; internet egress stays open, deliberately |
|
||||
| Devbox VM, root | escape plus local escalation, or misconfiguration | the whole VM, the same egress, and the model API credential | `dejima-devbox-hardener`, enforced outside the guest | the whole VM and no further: root cannot reach the rule set that holds the LAN boundary |
|
||||
@@ -176,8 +176,8 @@ the sandbox at it instead of at you:
|
||||
- `[forge.gitea] token_file`: that account's token, rather than one issued
|
||||
under yours.
|
||||
- `[core] ssh_key`: a deploy key belonging to it, rather than your forwarded
|
||||
agent. See "Headless git push without agent forwarding" in
|
||||
[docs/configuration.md](configuration.md).
|
||||
agent - which is then not mounted at all. See "Headless git push without
|
||||
agent forwarding" in [docs/configuration.md](configuration.md).
|
||||
|
||||
With the two identities separated, the same settings stop being decorative:
|
||||
|
||||
@@ -202,8 +202,7 @@ collaborator on the repositories it works on. Write is the ceiling here, not
|
||||
a floor: an account that owns a repository, or holds admin on it, can merge
|
||||
its own work and edit the settings in the table above, so every row of that
|
||||
table goes back to being decorative. `write:repository` and `write:issue` are
|
||||
the scopes its token needs (README, "Gitea/Forgejo PR workflow"), and nothing
|
||||
about that changes for a collaborator.
|
||||
the scopes its token needs, and nothing about that changes for a collaborator.
|
||||
|
||||
One consequence presents as a misconfiguration, and the obvious fix is the
|
||||
wrong one. Parts of Gitea's API are gated on *ownership* rather than on
|
||||
@@ -322,6 +321,47 @@ can make; it is one the audit reports. See
|
||||
overlaid (they're config, not fixed paths a static mount can cover) and
|
||||
remain fully writable; the exit-time audit below reports changes to
|
||||
either, but only after the session ends - it doesn't stop them.
|
||||
|
||||
### A session with no project mounted
|
||||
|
||||
`dejima --isolated` removes this whole class rather than bounding it: there is
|
||||
no mounted project to be written, no `.git` to plant a hook in, and no
|
||||
harness state directory on the host for a session to leave anything in. Its
|
||||
working directory is container-local and its `~/shared` is made fresh for that
|
||||
session alone.
|
||||
|
||||
**It narrows the forge credential too**, which is the widest thing a session
|
||||
holds: a Gitea token is account-wide (see
|
||||
[To your forge, the sandbox is you](#to-your-forge-the-sandbox-is-you)), so an
|
||||
ordinary session can read and write every repository its operator can.
|
||||
`--isolated` sets `forge` to `none` and `workflow` to `local-only` for that
|
||||
launch, and blanks the session's copy of `[github] token_file` - core's
|
||||
read-only GitHub credential, which crosses for every ordinary session
|
||||
whatever forge is selected, and which is read-only across the operator's
|
||||
*private* repositories rather than scoped to one. So no forge credential of
|
||||
either kind reaches an isolated session, and nothing tells it to push.
|
||||
|
||||
Naming a forge on the same command line brings that back: it is the operator
|
||||
saying this session is meant to reach one, and nothing is narrowed from there.
|
||||
The filter container keeps the GitHub token either way, because that is what
|
||||
authenticates dejima's own fetch of GitHub's address ranges - a request about
|
||||
the allowlist, made outside the session's reach.
|
||||
|
||||
**The forwarded agent is not mounted; a configured key is.** A workflow that
|
||||
never pushes gets no agent socket, and `local-only` is one - so the personal
|
||||
identities in the operator's own agent stay outside the container. An
|
||||
`[identity] ssh_key` is mounted and loaded whatever the workflow, so an
|
||||
isolated session with no forge still holds a key that can reach a host over
|
||||
ssh. Withholding that for any launch whose workflow never pushes is
|
||||
[#392](https://gitbox.branderson.io/dejima/dejima/issues/392).
|
||||
|
||||
So: a narrower blast radius on the host and a narrower credential, not a
|
||||
stronger sandbox. The firewall, the credential boundary and the capability
|
||||
drops are unchanged, and a container escape is exactly as serious. The cost is
|
||||
that the conversation log, the epitaph and the exit-time audit are all off,
|
||||
because each reads host state this mode does not mount. See
|
||||
[Isolated sessions](configuration.md#isolated-sessions).
|
||||
|
||||
## The exit-time audit
|
||||
|
||||
`bin/dejima-audit` snapshots host-persistence surfaces before each run and
|
||||
@@ -782,7 +822,22 @@ reach the model - which reads as a dejima fault rather than a missing
|
||||
prerequisite. Sign in on the host first; see the README's
|
||||
[Sign in on the host first](../README.md#sign-in-on-the-host-first).
|
||||
Refreshing an expired credential is the same: on the host, not from inside a
|
||||
session, where the credential deliberately never goes.
|
||||
session, where the credential deliberately never goes. A session that outlives
|
||||
its credential - one left open overnight is the ordinary way - meets a 502 from
|
||||
the proxy naming that as the remedy, and the same line is the last thing the
|
||||
proxy logs, so the exit audit repeats it on the host terminal.
|
||||
|
||||
**What the proxy may tell a session about a credential failure.** Anything it
|
||||
learned from the provider stays on the host: a response body, a status line, an
|
||||
exception. What crosses into the container is text this repository wrote, and
|
||||
only when it names something the operator has to do - "log in again on the
|
||||
host" is useful to an agent that would otherwise report itself broken, and
|
||||
discloses nothing the session did not already know. The distinction is the
|
||||
credential proxy's `CredentialNeedsOperator`, which carries the two separately
|
||||
so neither can be sent by accident. The proxy's own log is bound by the same
|
||||
rule - it keeps provider statuses and error-body prefixes for the exit audit -
|
||||
so it lives outside the socket directory, which is the one proxy path the
|
||||
session mounts.
|
||||
|
||||
### The refreshing OAuth path, in detail
|
||||
|
||||
@@ -999,6 +1054,9 @@ reasoning below is what both are built on.
|
||||
Gitea repo it has access to" shape as the forwarded agent above - the
|
||||
mitigation is that it's meant to be a dedicated deploy key scoped to only
|
||||
the repos that actually need push access, not your personal identity.
|
||||
Setting it also withdraws the forwarded agent: `SSH_AUTH_SOCK` is not passed
|
||||
to the container, so the two paths are alternatives rather than both being
|
||||
open at once.
|
||||
That scoping is a convention, not something enforced here: nothing checks
|
||||
what `[core] ssh_key` actually points at, so pointing it at your real
|
||||
`~/.ssh/id_ed25519`/`id_rsa` instead of a dedicated key would load your
|
||||
@@ -1219,7 +1277,9 @@ On start, the filter container establishes an allowlist-only egress policy
|
||||
everything the session may reach expressed as named sets. Allowed by default:
|
||||
|
||||
- DNS resolution via a local proxy (see "DNS is allowlisted too")
|
||||
- the Docker host itself (not its whole subnet)
|
||||
- nothing on the Docker host. Its address is a named destination so that
|
||||
reaching for it is counted and refused rather than falling off the end of the
|
||||
chain, but no port on it is open (see "The host entry" below)
|
||||
- GitHub's published IP ranges, port 443 only (see "GitHub push" below)
|
||||
- `registry.npmjs.org`, plus whatever the selected harness declares as its own
|
||||
(`platform.claude.com` for Claude Code; Cline declares none, so a session
|
||||
@@ -1286,6 +1346,16 @@ of it loads. A tool that will not run and a family that is not there are
|
||||
different conditions, and reporting the second on evidence of the first is how
|
||||
a boundary comes to be described rather than held.
|
||||
|
||||
The startup self-check also resolves every permitted name and reads each answer
|
||||
back out of the address set its class names, refusing to start if one is
|
||||
missing. A name that resolves and whose address is not where the ports are
|
||||
keyed to would be a session that reaches a configured destination only by
|
||||
accident, or not at all - and the class an entry carries, the resolver line
|
||||
built from it, and the set the rules use are three separate statements that can
|
||||
each be right about a different one. A name that returns no address is reported
|
||||
rather than fatal: that is the world being unavailable, not the policy
|
||||
disagreeing with itself.
|
||||
|
||||
The startup self-check asserts the shape of the ruleset, not a connection that
|
||||
fails: a v6 connection fails identically on a host with no v6 route, so a probe
|
||||
alone would pass whether the family is refused or merely absent.
|
||||
@@ -1293,6 +1363,32 @@ alone would pass whether the family is refused or merely absent.
|
||||
If your Gitea host only resolves via a LAN-local DNS server, make sure the
|
||||
Docker host itself can resolve it: containers inherit the host's resolver.
|
||||
|
||||
### The host entry
|
||||
|
||||
Every other destination in the policy is there because something declared it.
|
||||
The host entry is not: it is the gateway of the container's default route,
|
||||
derived from the routing table. Docker publishes container ports on that
|
||||
address, so permitting it on every port would reach the host's sshd, anything
|
||||
bound to `0.0.0.0`, and every published port of every other container on the
|
||||
machine - including dejima's own remote-access front door, which is a host
|
||||
shell behind a tmux prefix key.
|
||||
|
||||
So it opens no ports. The address is still named in the ruleset, with its own
|
||||
refusal rather than a fall-through to the end of the chain, so that a session
|
||||
reaching for the host shows up as a counted refusal instead of an anonymous
|
||||
one.
|
||||
|
||||
Nothing in a launch needs it open. A host-resident forge, a host-resident Loki
|
||||
and a self-served model endpoint are each reached through their own entry - by
|
||||
name, or as a literal address - and each lands in a set of its own with its own
|
||||
rules. Configuring one of those is how a host service becomes reachable.
|
||||
|
||||
For anything else, `EGRESS_HOST_PORTS` on the filter container takes TCP ports
|
||||
and low-high ranges, comma or space separated; `EGRESS_HOST_PORTS=any` restores
|
||||
the unclamped behaviour explicitly, and `EGRESS_HOST_ADDR=none` removes the
|
||||
entry altogether. A value that is not a port stops the container rather than
|
||||
becoming a rule nobody meant.
|
||||
|
||||
### DNS is allowlisted too
|
||||
|
||||
Resolution itself is restricted, not just the IP connections made
|
||||
@@ -1494,7 +1590,8 @@ execution, and the audit reports the config change it took.
|
||||
|
||||
## Network request logging
|
||||
|
||||
If `[logging] loki_url` is set (see Prerequisites in the README), every DNS query and every new
|
||||
If `[logging] loki_url` is set (see [docs/configuration.md](configuration.md)),
|
||||
every DNS query and every new
|
||||
outbound connection attempt - allowed or rejected - is shipped to that Loki
|
||||
endpoint. Two sources, both tailed and pushed by `log-shipper.sh`:
|
||||
|
||||
|
||||
@@ -110,7 +110,7 @@ if nothing is arranged against it, the last is what remains once something is.
|
||||
|
||||
| Layer | Reached by | Radius without | Bounded by | Radius with |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Agent, not yet hostile | ordinary operation | the workspace, its history, every mounted path, and the forge, at full speed | the session briefing and `.dejima.md`; the `PreToolUse` hooks | obvious destruction and out-of-remote pushes fail fast, and merging stays a human decision |
|
||||
| Agent, not yet hostile | ordinary operation | the workspace, its history, every mounted path, and the forge, at full speed | the session briefing and `.dejima.md`; the command guards | obvious destruction and out-of-remote pushes fail fast, and merging stays a human decision |
|
||||
| Container | ordinary operation | the operator's home directory, every credential in it, and the open internet | dejima | the workspace, allowlisted domains, and the forge token's scope |
|
||||
| Devbox VM, unprivileged | container escape | everything the account can read, and unrestricted egress to the internet and the LAN | `dejima-devbox-hardener` | the same VM, with LAN reach cut to what is declared; internet egress stays open, deliberately |
|
||||
| Devbox VM, root | escape plus local escalation, or misconfiguration | the whole VM, the same egress, and the model API credential | `dejima-devbox-hardener`, enforced outside the guest | the whole VM and no further: root cannot reach the rule set that holds the LAN boundary |
|
||||
|
||||
@@ -35,9 +35,12 @@ api_domains =
|
||||
|
||||
# None. Cline registers no hook that can refuse a tool call, so bash-deny has
|
||||
# nowhere to live, and it has no session lifecycle signal dejima can turn into
|
||||
# a notification. push-guard and merge-guard are not listed because no harness
|
||||
# hosts them any more - they are PATH shadows and hold regardless. Launch
|
||||
# names what this costs; see harness_report_guards.
|
||||
# a notification.
|
||||
#
|
||||
# push-guard and merge-guard are not listed because this harness cannot host
|
||||
# them, not because nothing does - claude-code declares both, and the PATH
|
||||
# shadows cover a harness that does not. Declaring none here costs the hook
|
||||
# layer, not the guard. Launch names the cost; see harness_report_guards.
|
||||
guards =
|
||||
|
||||
# Both, which is what makes this harness the backend-agnostic one: it has a
|
||||
|
||||
@@ -221,7 +221,7 @@ if [ -f "$decl_dir/allowed-domains" ]; then
|
||||
Tell the user to run, outside the sandbox:
|
||||
dejima-project approve \"$project_dir\"
|
||||
(or, for one domain right now:
|
||||
dejima-allow --container $container_id <domain>)"
|
||||
dejima-allow --container $container_id <domain>...)"
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
+35
-155
@@ -3,19 +3,19 @@
|
||||
# DEJIMA_PUSH_HOSTS, and never directly to a branch listed in
|
||||
# DEJIMA_BLOCKED_BRANCHES - both resolved host-side by bin/dejima from the
|
||||
# configured forge and workflow (see dejima-provider-lib.sh), not hardcoded
|
||||
# to any one forge. This is a fast-fail guard against an overeager agent,
|
||||
# not a hard security boundary - a sandbox session has full shell access
|
||||
# and could in principle edit .git/config or re-export these variables to
|
||||
# route around it; that is true today and is not a regression, and the
|
||||
# firewall (see docker/egress-policy) is the actual boundary. No-op outside the
|
||||
# sandbox (DEJIMA unset), since this restriction doesn't apply to normal
|
||||
# host usage.
|
||||
# to any one forge.
|
||||
#
|
||||
# DEJIMA_PUSH_HOSTS fails CLOSED: empty means no host is allowed, so every
|
||||
# push is refused. That is deliberate - it is how a no-forge or
|
||||
# non-pushing workflow becomes enforced rather than merely described.
|
||||
# DEJIMA_BLOCKED_BRANCHES fails OPEN: empty means no branch is protected,
|
||||
# which is correct for a workflow that genuinely has no branch policy.
|
||||
# The rules themselves are docker/guard-lib.sh, which docker/git-guard.sh
|
||||
# applies to the same push as a PATH shadow. This file's whole job is to turn
|
||||
# the command string a harness reports into the argv that library reads, so
|
||||
# that the two cannot reach different verdicts - which they had already started
|
||||
# to do.
|
||||
#
|
||||
# A fast-fail guard against an overeager agent, not a hard security boundary: a
|
||||
# sandbox session has full shell access and could in principle edit .git/config
|
||||
# or re-export these variables to route around it, and the firewall (see
|
||||
# docker/egress-policy) is the actual boundary. No-op outside the sandbox
|
||||
# (DEJIMA unset), since this restriction doesn't apply to normal host usage.
|
||||
|
||||
if [ -z "${DEJIMA:-}" ]; then
|
||||
exit 0
|
||||
@@ -25,151 +25,31 @@ input=$(cat)
|
||||
command=$(echo "$input" | jq -r '.tool_input.command // empty')
|
||||
cwd=$(echo "$input" | jq -r '.cwd // empty')
|
||||
|
||||
# Only care about `git push` invocations - found by walking tokens rather than
|
||||
# by matching a shape.
|
||||
# shellcheck source=../docker/guard-lib.sh
|
||||
source "${DEJIMA_GUARD_LIB:-/usr/local/lib/dejima/guard-lib.sh}"
|
||||
|
||||
# A push can be the second half of a compound command, and only a segment whose
|
||||
# first word is git is a git invocation. Every segment that is one gets the same
|
||||
# verdict function the shadow calls, on the words after `git`, so git's own
|
||||
# options do not need anticipating here: they are argv either way.
|
||||
#
|
||||
# The pattern this replaced recognised `git push` and `git -C <dir> push` and
|
||||
# nothing else, so every other spelling of git's own options - `git -c k=v
|
||||
# push`, `git --git-dir=... push`, `git --no-pager push` - was not a push as
|
||||
# far as this hook was concerned, and it exited 0 before any check ran. The
|
||||
# PATH shadow in docker/git-guard.sh walks argv and caught them regardless,
|
||||
# which is why this was a quiet gap in the earlier warning rather than an open
|
||||
# one.
|
||||
#
|
||||
# Split on the shell separators first, because a push can be the second half
|
||||
# of a compound command, and only the segment that STARTS with git is a git
|
||||
# invocation - "echo push" is not.
|
||||
push_seen=0
|
||||
segment=""
|
||||
IFS=$'\n' read -rd '' -a _segments < <(
|
||||
printf '%s' "$command" | sed 's/&&/\n/g; s/||/\n/g; s/;/\n/g; s/|/\n/g'
|
||||
printf '\0')
|
||||
for seg in ${_segments[@]+"${_segments[@]}"}; do
|
||||
read -ra _t <<< "$seg"
|
||||
[ "${#_t[@]}" -gt 0 ] || continue
|
||||
[ "${_t[0]}" = git ] || continue
|
||||
_i=1
|
||||
while [ "$_i" -lt "${#_t[@]}" ]; do
|
||||
case "${_t[$_i]}" in
|
||||
-c|-C|--exec-path|--git-dir|--work-tree|--namespace|--super-prefix)
|
||||
_i=$((_i + 1)) ;;
|
||||
-*) ;;
|
||||
push) push_seen=1; segment="$seg"; break ;;
|
||||
*) break ;;
|
||||
esac
|
||||
_i=$((_i + 1))
|
||||
done
|
||||
[ "$push_seen" -eq 1 ] && break
|
||||
done
|
||||
[ "$push_seen" -eq 1 ] || exit 0
|
||||
# Everything below reads the git invocation, not the whole command line.
|
||||
command="$segment"
|
||||
|
||||
repo_dir="$cwd"
|
||||
if [[ "$command" =~ git[[:space:]]+-C[[:space:]]+([^[:space:]]+) ]]; then
|
||||
repo_dir="${BASH_REMATCH[1]}"
|
||||
fi
|
||||
|
||||
# One pass over the whole command, the way the shadow walks argv: skip git's
|
||||
# own options until the first token that IS the subcommand, then take the
|
||||
# first non-flag token as the remote and the second as the refspec.
|
||||
#
|
||||
# Splitting on the first occurrence of the substring "push" is what this
|
||||
# replaced, and it split inside `remote.origin.pushurl=...`, a branch named
|
||||
# push-fix, and anything else carrying the word. What came out was a nonsense
|
||||
# remote, which then took the unresolvable path below.
|
||||
#
|
||||
# Word-splitting rather than a full shell parse, so quoting edge cases are not
|
||||
# handled - the same pragmatic tradeoff as the sibling
|
||||
# deny-dangerous-bash.sh denylist, and the reason the unresolvable case
|
||||
# refuses rather than guesses.
|
||||
#
|
||||
# `-c key=value` is kept rather than dropped: git resolves where a push goes
|
||||
# from the config the push runs under, so asking without these answers for a
|
||||
# different configuration. `git -c remote.origin.pushurl=... push origin
|
||||
# branch` is that case.
|
||||
config_overrides=()
|
||||
positional=()
|
||||
read -ra tokens <<< "$command"
|
||||
t=0
|
||||
seen_push=0
|
||||
while [ "$t" -lt "${#tokens[@]}" ]; do
|
||||
tok="${tokens[$t]}"
|
||||
if [ "$seen_push" -eq 0 ]; then
|
||||
case "$tok" in
|
||||
-c) t=$((t + 1))
|
||||
[ "$t" -lt "${#tokens[@]}" ] && config_overrides+=(-c "${tokens[$t]}") ;;
|
||||
-C|--exec-path|--git-dir|--work-tree|--namespace|--super-prefix)
|
||||
t=$((t + 1)) ;;
|
||||
push) seen_push=1 ;;
|
||||
*) ;;
|
||||
esac
|
||||
else
|
||||
case "$tok" in
|
||||
-*) ;;
|
||||
*) positional+=("$tok") ;;
|
||||
esac
|
||||
fi
|
||||
t=$((t + 1))
|
||||
done
|
||||
|
||||
remote="origin"
|
||||
refspec=""
|
||||
if [ "${#positional[@]}" -ge 1 ]; then
|
||||
remote="${positional[0]}"
|
||||
fi
|
||||
if [ "${#positional[@]}" -ge 2 ]; then
|
||||
refspec="${positional[1]}"
|
||||
fi
|
||||
|
||||
# --push: the URL the push will actually use. remote.<name>.pushurl wins over
|
||||
# remote.<name>.url when it is set, and reading the latter checks a host the
|
||||
# push is not going to. See docker/git-guard.sh, which asks the same question.
|
||||
remote_url=$(git ${config_overrides[@]+"${config_overrides[@]}"} \
|
||||
-C "$repo_dir" remote get-url --push "$remote" 2>/dev/null)
|
||||
if [ -z "$remote_url" ]; then
|
||||
# Refused, not passed through. A remote that does not resolve is usually a
|
||||
# typo git would reject in a moment anyway - but it is also what a
|
||||
# disagreement between this parser and git's own looks like, and the
|
||||
# failure of that disagreement has to be a refusal rather than a push
|
||||
# nobody checked. docker/git-guard.sh takes the same direction; the two
|
||||
# answering differently was the bug.
|
||||
echo "Blocked by git-push-guard.sh: cannot resolve the remote '$remote' to a URL, so there is nothing to check this push against. Name a remote that exists." >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
host=$(echo "$remote_url" | sed -E 's#^[a-zA-Z]+://([^/@]+@)?([^/:]+).*#\2#; s#^[^@/]+@([^:/]+)[:/].*#\1#')
|
||||
|
||||
host_allowed=0
|
||||
for allowed_host in ${DEJIMA_PUSH_HOSTS:-}; do
|
||||
if [ "$host" = "$allowed_host" ]; then
|
||||
host_allowed=1
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$host_allowed" != 1 ]; then
|
||||
echo "Blocked by git-push-guard.sh: this sandbox may only push to ${DEJIMA_FORGE_NAME:-the configured forge}'s allowed host(s) (${DEJIMA_PUSH_HOSTS:-none allowed}), not '$host'." >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# Resolve the target branch: a src:dst refspec's dst, a bare branch name, or
|
||||
# (if no refspec was given at all, or it's HEAD) the current branch - `git
|
||||
# push origin` and `git push origin HEAD` both push the current branch.
|
||||
if [[ "$refspec" == *:* ]]; then
|
||||
branch="${refspec#*:}"
|
||||
elif [ -n "$refspec" ] && [ "$refspec" != "HEAD" ]; then
|
||||
branch="$refspec"
|
||||
else
|
||||
branch=$(git -C "$repo_dir" branch --show-current 2>/dev/null)
|
||||
fi
|
||||
branch="${branch#refs/heads/}"
|
||||
|
||||
for blocked_branch in ${DEJIMA_BLOCKED_BRANCHES:-}; do
|
||||
if [ "$branch" = "$blocked_branch" ]; then
|
||||
echo "Blocked by git-push-guard.sh: direct pushes to '$branch' aren't allowed in the sandbox. Push a feature branch and open a PR/MR instead." >&2
|
||||
# What a segment's FIRST word has to be is still git. A `FOO=bar git push`
|
||||
# prefix, or `env git push`, is a git invocation this does not see - the PATH
|
||||
# shadow does, which is the division of labour the pair exists for rather than
|
||||
# a gap in it.
|
||||
# Basenamed, like hooks/merge-guard.sh and like the shadow, which reads its own
|
||||
# $0. `/usr/bin/git push origin main` is the one spelling that reaches neither
|
||||
# layer otherwise - the absolute path steps over the PATH shadow, and comparing
|
||||
# the whole word meant this hook did not see a git invocation at all. It is
|
||||
# also the form the library's own header names as the reason both layers exist.
|
||||
while IFS= read -r segment; do
|
||||
read -ra tokens <<< "$segment"
|
||||
[ "${#tokens[@]}" -gt 0 ] || continue
|
||||
[ "$(basename -- "${tokens[0]}")" = git ] || continue
|
||||
if ! dejima_push_verdict "$cwd" "${tokens[@]:1}"; then
|
||||
echo "Blocked by git-push-guard.sh: $DEJIMA_GUARD_REASON" >&2
|
||||
exit 2
|
||||
fi
|
||||
done
|
||||
done < <(dejima_guard_segments "$command")
|
||||
|
||||
exit 0
|
||||
|
||||
+36
-22
@@ -1,10 +1,15 @@
|
||||
#!/usr/bin/env bash
|
||||
# PreToolUse hook: inside dejima, block a merge command matching
|
||||
# DEJIMA_MERGE_BLOCK - a literal command prefix resolved host-side by
|
||||
# bin/dejima from the configured forge and workflow, never hardcoded to one
|
||||
# forge. What lands on the default branch is an operator decision, as it
|
||||
# already is for `git push`; this is the API-side counterpart to
|
||||
# git-push-guard.sh, since a merge reaches the branch without a push.
|
||||
# PreToolUse hook: inside dejima, apply the forge policy to a forge CLI call -
|
||||
# the merge a workflow blocks, and a command aimed at a repository the session
|
||||
# was not launched against. What lands on the default branch is an operator
|
||||
# decision, as it already is for `git push`; this is the API-side counterpart
|
||||
# to git-push-guard.sh, since a merge reaches the branch without a push.
|
||||
#
|
||||
# The rules are docker/guard-lib.sh, which docker/forge-guard.sh applies to the
|
||||
# same command as a PATH shadow. This file's job is to turn the command string
|
||||
# a harness reports into the argv that library reads, so the two cannot reach
|
||||
# different verdicts. Named for the merge block because that is the rule it was
|
||||
# created for; the rest came with the shared policy.
|
||||
#
|
||||
# Not conditional on intent: a hook is handed a command, not a conversation,
|
||||
# so it cannot tell an instructed merge from an unprompted one. The line is
|
||||
@@ -16,32 +21,41 @@
|
||||
# shell access and the forge token, so it could call the merge endpoint with
|
||||
# curl. It stops the mistake, not the attack.
|
||||
#
|
||||
# Fails OPEN - empty means nothing is blocked, so a workflow with no
|
||||
# merge-block policy genuinely has none rather than the hook guessing.
|
||||
# Fails OPEN - a forge that declares no policy has none blocked, rather than
|
||||
# the hook guessing.
|
||||
|
||||
if [ -z "${DEJIMA:-}" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ -z "${DEJIMA_MERGE_BLOCK:-}" ]; then
|
||||
if [ -z "${DEJIMA_MERGE_BLOCK:-}" ] && [ -z "${DEJIMA_DISCLOSE:-}" ] &&
|
||||
[ -z "${DEJIMA_FORGE_REPO_FLAGS:-}" ] && [ -z "${DEJIMA_FORGE_REPO_ENV:-}" ] &&
|
||||
[ -z "${DEJIMA_FORGE_REPO_PATHS:-}" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
command=$(jq -r '.tool_input.command // empty')
|
||||
|
||||
# DEJIMA_MERGE_BLOCK is a literal command prefix from the forge manifest,
|
||||
# not a regex - escape its regex metacharacters before matching so a value
|
||||
# like ".*" matches only that literal text, never "everything".
|
||||
escaped_block=$(printf '%s' "$DEJIMA_MERGE_BLOCK" | sed -E 's/[][(){}.*+?^$\\|]/\\&/g')
|
||||
# shellcheck source=../docker/guard-lib.sh
|
||||
source "${DEJIMA_GUARD_LIB:-/usr/local/lib/dejima/guard-lib.sh}"
|
||||
|
||||
# The leading class includes '/' so a path-qualified call is caught too -
|
||||
# the Dockerfile installs each forge's CLI under /usr/local/bin, and that's
|
||||
# what an agent reaching past a shadowed PATH would type. Word-splitting
|
||||
# rather than a full shell parse, the same pragmatic tradeoff the sibling
|
||||
# hook makes.
|
||||
if [[ "$command" =~ (^|[[:space:]&\|\;/])$escaped_block([[:space:]]|$) ]]; then
|
||||
echo "Blocked by merge-guard.sh: merging is a human decision in the sandbox - open a PR/MR, then the operator merges. Report its URL and stop." >&2
|
||||
exit 2
|
||||
fi
|
||||
forge_commands="$(dejima_forge_commands | sort -u)"
|
||||
[ -n "$forge_commands" ] || exit 0
|
||||
|
||||
# Basenamed, so a path-qualified call is checked too - the Dockerfile installs
|
||||
# each forge's CLI under /usr/local/bin, and the real one sits behind the
|
||||
# shadow at a fixed path, which is what an agent reaching past a shadowed PATH
|
||||
# would type. It is also what the shadow itself compares, since that reads its
|
||||
# own basename.
|
||||
while IFS= read -r segment; do
|
||||
read -ra tokens <<< "$segment"
|
||||
[ "${#tokens[@]}" -gt 0 ] || continue
|
||||
self="$(basename -- "${tokens[0]}")"
|
||||
grep -qxF -- "$self" <<< "$forge_commands" || continue
|
||||
if ! dejima_forge_verdict "$self" "${tokens[@]:1}"; then
|
||||
echo "Blocked by merge-guard.sh: $DEJIMA_GUARD_REASON" >&2
|
||||
exit 2
|
||||
fi
|
||||
done < <(dejima_guard_segments "$command")
|
||||
|
||||
exit 0
|
||||
|
||||
+3
-1
@@ -140,7 +140,9 @@ else
|
||||
# only what the operator has actually decided.
|
||||
{
|
||||
printf '# dejima configuration. See README.md.\n'
|
||||
printf '# Every key below is shown at its default; uncomment to change one.\n'
|
||||
printf '# This file holds what you have decided, not every key there is.\n'
|
||||
printf '# defaults.conf beside it is the complete reference, rewritten by\n'
|
||||
printf '# every ./install.sh - do not edit that one.\n'
|
||||
printf '#\n'
|
||||
printf '# [core] harness, forge and workflow are written out uncommented, so\n'
|
||||
printf '# this file states what it selects rather than inheriting a future\n'
|
||||
|
||||
+4
-10
@@ -77,16 +77,10 @@ PY
|
||||
[ -e /var/run/docker.sock ]
|
||||
}
|
||||
|
||||
@test "the two runners are not the same runner" {
|
||||
# Whatever else is true, exactly one of them gets the daemon. If both jobs
|
||||
# report the same answer here, the labels are not routing to two different
|
||||
# runners and the tiering exists only on paper.
|
||||
if [ "$DEJIMA_CI_TIER" = confined ]; then
|
||||
[ ! -e /var/run/docker.sock ]
|
||||
else
|
||||
[ -e /var/run/docker.sock ]
|
||||
fi
|
||||
}
|
||||
# The two directions above are each one job's answer: the confined runner must
|
||||
# not have the docker socket and the privileged one must. That the two jobs land
|
||||
# on DIFFERENT runners does not follow from either, and a suite cannot see it -
|
||||
# it runs in one job. Comparing the two jobs' results is the workflow's to do.
|
||||
|
||||
# --- egress, for the tier the rules actually apply to ------------------------
|
||||
#
|
||||
|
||||
+281
-20
@@ -569,6 +569,23 @@ write_defaults() { printf '%s\n' "$@" > "$CONFIG_DIR/defaults.conf"; }
|
||||
[[ "$output" == *"nonesuch"* ]]
|
||||
}
|
||||
|
||||
@test "the isolated mode has no config key, in any plausible spelling" {
|
||||
# --isolated is a flag and nothing else, on purpose: it changes what the
|
||||
# session IS rather than how much it may do, and a file that decided it
|
||||
# would make the next launch's answer to "where is my work" depend on a
|
||||
# line nobody reread. The schema is what holds that - an unknown key is
|
||||
# fatal, so every spelling someone might reach for has to stay unknown.
|
||||
local key
|
||||
for key in isolated isolate ephemeral no_workspace; do
|
||||
write_config '[core]' "$key = on"
|
||||
run lib config_validate
|
||||
[ "$status" -ne 0 ] \
|
||||
|| { echo "[core] $key was accepted, so the mode is settable from a file"; return 1; }
|
||||
[[ "$output" == *"unknown key"* ]] || { echo "$output"; return 1; }
|
||||
done
|
||||
return 0
|
||||
}
|
||||
|
||||
@test "an invalid value is fatal and names the key" {
|
||||
write_config '[audit]' 'keep = 0'
|
||||
run lib config_validate
|
||||
@@ -791,7 +808,7 @@ write_defaults() { printf '%s\n' "$@" > "$CONFIG_DIR/defaults.conf"; }
|
||||
|
||||
_identity_fixture() {
|
||||
ID_DIR="$(mktemp -d)"
|
||||
: > "$ID_DIR/default-key"; : > "$ID_DIR/work-key"; : > "$ID_DIR/oss-key"
|
||||
printf "k\n" | tee "$ID_DIR/default-key" "$ID_DIR/work-key" > "$ID_DIR/oss-key"
|
||||
: > "$ID_DIR/default-token"; : > "$ID_DIR/work-token"
|
||||
write_config '[core]' 'forge = gitea' "ssh_key = $ID_DIR/default-key" \
|
||||
'[forge.gitea]' 'ssh_host = git.example' 'api = https://git.example/api/v1' \
|
||||
@@ -1009,7 +1026,7 @@ _identity_fixture() {
|
||||
mkdir -p "$FAKE_HOME/.claude"
|
||||
echo '{}' > "$FAKE_HOME/.claude/settings.json"
|
||||
key="$FAKE_HOME/sandbox-key"
|
||||
: > "$key"
|
||||
printf "not-a-real-key\n" > "$key"
|
||||
chmod 600 "$key"
|
||||
# forge = none, workflow = local-only: provider_validate_selection runs
|
||||
# ahead of this check, and the default gitea/worktree-pr pair is refused
|
||||
@@ -1285,7 +1302,7 @@ _identity_fixture() {
|
||||
mkdir -p "$FAKE_HOME/.claude"
|
||||
echo '{}' > "$FAKE_HOME/.claude/settings.json"
|
||||
key="$FAKE_HOME/sandbox-key"
|
||||
: > "$key"
|
||||
printf "not-a-real-key\n" > "$key"
|
||||
chmod 600 "$key"
|
||||
write_config '[core]' "ssh_key = $key" 'forge = none' 'workflow = local-only'
|
||||
workspace="$(mktemp -d)"
|
||||
@@ -1315,7 +1332,7 @@ EOF
|
||||
mkdir -p "$FAKE_HOME/.claude"
|
||||
echo '{}' > "$FAKE_HOME/.claude/settings.json"
|
||||
key="$FAKE_HOME/sandbox-key"
|
||||
: > "$key"
|
||||
printf "not-a-real-key\n" > "$key"
|
||||
chmod 600 "$key"
|
||||
write_config '[core]' "ssh_key = $key" \
|
||||
'[forge.gitea]' 'ssh_host = git.example' 'api = https://api.example/v1' \
|
||||
@@ -1385,12 +1402,14 @@ EOF
|
||||
|
||||
@test "the github forge with no push_token_file leaves the read-only GH_TOKEN alone at launch" {
|
||||
# forge_env_exports must not emit `GH_TOKEN=` (empty) when
|
||||
# push_token_file is unset: `-e GH_TOKEN=` on `docker
|
||||
# compose run` overrides docker-compose.yml's own `environment: GH_TOKEN:
|
||||
# ${GH_TOKEN:-}` entry - the one carrying the independent read-only
|
||||
# [github] token_file value every session is supposed to get regardless
|
||||
# of forge. Asserted on the actual argument list handed to `docker`, not
|
||||
# on a warning, so this catches the container-facing symptom directly.
|
||||
# push_token_file is unset: `-e GH_TOKEN=` on `docker compose run`
|
||||
# overrides docker-compose.yml's own `environment: GH_TOKEN:` entry - the
|
||||
# one carrying the independent read-only [github] token_file value every
|
||||
# session is supposed to get regardless of forge. (That entry reads
|
||||
# bin/dejima's SANDBOX_GH_TOKEN, which is the same value except for an
|
||||
# isolated launch; the override is what matters here either way.)
|
||||
# Asserted on the actual argument list handed to `docker`, not on a
|
||||
# warning, so this catches the container-facing symptom directly.
|
||||
FAKE_HOME="$(mktemp -d)"
|
||||
mkdir -p "$FAKE_HOME/.claude"
|
||||
echo '{}' > "$FAKE_HOME/.claude/settings.json"
|
||||
@@ -1449,7 +1468,7 @@ EOF
|
||||
# dedicated key file is the headless form of that, and keeps this test
|
||||
# independent of whether the host running it has an agent.
|
||||
local key="$FAKE_HOME/sandbox-key"
|
||||
: > "$key"
|
||||
printf "not-a-real-key\n" > "$key"
|
||||
chmod 600 "$key"
|
||||
write_config '[core]' 'forge = github' 'workflow = worktree-pr' \
|
||||
"ssh_key = $key" \
|
||||
@@ -1506,7 +1525,7 @@ EOF
|
||||
mkdir -p "$FAKE_HOME/.claude"
|
||||
echo '{}' > "$FAKE_HOME/.claude/settings.json"
|
||||
local key="$FAKE_HOME/sandbox-key"
|
||||
: > "$key"
|
||||
printf "not-a-real-key\n" > "$key"
|
||||
chmod 600 "$key"
|
||||
write_config '[core]' 'forge = gitea' 'workflow = branch-push' \
|
||||
"ssh_key = $key" \
|
||||
@@ -1530,7 +1549,7 @@ EOF
|
||||
mkdir -p "$FAKE_HOME/.claude"
|
||||
echo '{}' > "$FAKE_HOME/.claude/settings.json"
|
||||
key="$FAKE_HOME/sandbox-key"
|
||||
: > "$key"
|
||||
printf "not-a-real-key\n" > "$key"
|
||||
chmod 600 "$key"
|
||||
write_config '[core]' "ssh_key = $key" 'forge = none' 'workflow = local-only' \
|
||||
'[logging]' 'loki_url = https://loki.example.invalid'
|
||||
@@ -1707,7 +1726,7 @@ _rw_setup() {
|
||||
mkdir -p "$RW_HOME/.claude"
|
||||
echo '{}' > "$RW_HOME/.claude/settings.json"
|
||||
RW_KEY="$RW_HOME/sandbox-key"
|
||||
: > "$RW_KEY"
|
||||
printf "not-a-real-key\n" > "$RW_KEY"
|
||||
chmod 600 "$RW_KEY"
|
||||
}
|
||||
|
||||
@@ -1831,6 +1850,118 @@ _kill_agent() { [ -n "${RW_AGENT_PID:-}" ] && kill "$RW_AGENT_PID" 2>/dev/null;
|
||||
rm -rf "$workspace"
|
||||
}
|
||||
|
||||
# A launch that really reaches `docker compose`, with a pushing workflow, so the
|
||||
# rule that blanks the agent socket for a workflow that never pushes is not the
|
||||
# one doing the work. Its own fixture rather than _rw_setup's: this needs a
|
||||
# forge configured, and stdin closed because a full launch opens /dev/tty,
|
||||
# which under bats is not the terminal it expects.
|
||||
#
|
||||
# The stub records its own environment rather than its arguments, because the
|
||||
# volume line resolves ${SSH_AUTH_SOCK:-/dev/null} inside docker-compose - so
|
||||
# the environment handed to it IS whether the agent is mounted. Into a file,
|
||||
# because the launch prints plenty of its own and the assertion is about one
|
||||
# value.
|
||||
#
|
||||
# Leaves AGENT_LOG and AGENT_OUT for the caller to read on failure.
|
||||
_agent_launch() { # <ssh_key or "">
|
||||
local key="$1" h c s w
|
||||
# The suite exports DEJIMA_CONFIG_DIR, which is where a launch under it
|
||||
# reads its config from - a config written under $HOME would not be found.
|
||||
h="$(mktemp -d)"; c="$CONFIG_DIR"
|
||||
printf 'tok\n' > "$c/token"
|
||||
{
|
||||
printf '[core]\nharness = claude-code\nfirewall = on\nupdate_check = off\n'
|
||||
printf 'forge = gitea\nworkflow = worktree-pr\n'
|
||||
printf '[harness.claude-code]\nbackend = anthropic\n'
|
||||
printf '[forge.gitea]\nssh_host = git.example\napi = https://api.example\n'
|
||||
# The forge credential moves under the identity when there is one: an
|
||||
# identity that leaves one behind is a session acting under whichever
|
||||
# account owns it, which the launch refuses outright.
|
||||
if [ -n "$key" ]; then
|
||||
printf '[identity.default]\nssh_key = %s\ntoken_file = %s/token\n' "$key" "$c"
|
||||
else
|
||||
printf 'token_file = %s/token\n' "$c"
|
||||
fi
|
||||
} > "$c/config"
|
||||
AGENT_LOG="$(mktemp)"
|
||||
s="$(mktemp -d)"
|
||||
cat > "$s/docker" <<'STUB'
|
||||
#!/bin/sh
|
||||
printf 'SSH_AUTH_SOCK=[%s]\n' "${SSH_AUTH_SOCK-unset}" >> "$AGENT_SOCK_LOG"
|
||||
exit 0
|
||||
STUB
|
||||
chmod +x "$s/docker"
|
||||
w="$(mktemp -d)"; git -C "$w" init -q
|
||||
# `|| true` because a launch here ends however the stubbed session ended,
|
||||
# and this is not a test about its exit status.
|
||||
AGENT_OUT="$(HOME="$h" PATH="$s:$PATH" SSH_AUTH_SOCK="$AGENT_SOCK" \
|
||||
AGENT_SOCK_LOG="$AGENT_LOG" \
|
||||
bash "$REPO_ROOT/bin/dejima" "$w" </dev/null 2>&1 || true)"
|
||||
rm -rf "$h" "$s" "$w"
|
||||
}
|
||||
|
||||
# A real agent, because bin/dejima tests the socket with `[ -S ]` and refuses a
|
||||
# path that is not one.
|
||||
_real_agent() {
|
||||
eval "$(ssh-agent -s)" >/dev/null
|
||||
AGENT_SOCK="$SSH_AUTH_SOCK"
|
||||
AGENT_PID="$SSH_AGENT_PID"
|
||||
}
|
||||
|
||||
@test "a configured ssh_key means the operator's agent is not in the container at all" {
|
||||
# entrypoint.sh loads that key into an agent of its own and points
|
||||
# SSH_AUTH_SOCK at it, so the forwarded one was already unused - but it
|
||||
# stayed mounted at the path docker-compose.yml names, where
|
||||
# `SSH_AUTH_SOCK=/ssh-agent ssh ...` signs with the operator's personal
|
||||
# identities. Choosing a dedicated key has to mean the personal agent is
|
||||
# not in the container, not that it is merely not the default.
|
||||
local key
|
||||
_real_agent
|
||||
key="$(mktemp)"; printf 'not-a-real-key\n' > "$key"; chmod 600 "$key"
|
||||
|
||||
_agent_launch "$key"
|
||||
kill "$AGENT_PID" 2>/dev/null || true
|
||||
rm -f "$key"
|
||||
[ -s "$AGENT_LOG" ] \
|
||||
|| { echo "the launch never reached docker:"; echo "$AGENT_OUT" | tail -15; return 1; }
|
||||
[ "$(sort -u "$AGENT_LOG")" = "SSH_AUTH_SOCK=[]" ] \
|
||||
|| { echo "the forwarded agent still reached compose:"; sort -u "$AGENT_LOG"; return 1; }
|
||||
}
|
||||
|
||||
@test "without a configured ssh_key the forwarded agent still reaches the container" {
|
||||
# The positive control for the test above, and the reason it means
|
||||
# anything: without it, a launch that simply never set the variable would
|
||||
# satisfy that assertion too.
|
||||
_real_agent
|
||||
|
||||
_agent_launch ""
|
||||
local want="SSH_AUTH_SOCK=[$AGENT_SOCK]"
|
||||
kill "$AGENT_PID" 2>/dev/null || true
|
||||
[ -s "$AGENT_LOG" ] \
|
||||
|| { echo "the launch never reached docker:"; echo "$AGENT_OUT" | tail -15; return 1; }
|
||||
[ "$(sort -u "$AGENT_LOG")" = "$want" ] \
|
||||
|| { echo "the forwarded agent did not reach compose:"; sort -u "$AGENT_LOG"; return 1; }
|
||||
}
|
||||
|
||||
@test "an empty ssh_key file is not a key, and a pushing launch says so" {
|
||||
# It passes the is-a-file check and loads nothing in the container, so it
|
||||
# cannot be what stops the forwarded agent being mounted. Treated as no key
|
||||
# at all: harmless for a workflow that never pushes - which is what most of
|
||||
# this suite's fixtures are, with an empty file standing in for a path -
|
||||
# and named for one that does.
|
||||
# A pushing workflow with an empty key and no agent: the one case where it
|
||||
# costs anything, and the message names the file rather than only saying no
|
||||
# key is set. The with-an-agent direction is its own test above.
|
||||
AGENT_SOCK=""
|
||||
local key; key="$(mktemp)"; chmod 600 "$key"
|
||||
_agent_launch "$key"
|
||||
rm -f "$key"
|
||||
[[ "$AGENT_OUT" == *"names an empty file"* ]] \
|
||||
|| { echo "an empty key went unmentioned:"; echo "$AGENT_OUT" | tail -12; return 1; }
|
||||
[ ! -s "$AGENT_LOG" ] \
|
||||
|| { echo "the launch reached docker with no credential at all"; return 1; }
|
||||
}
|
||||
|
||||
# The positive control for the test above: with no key configured the
|
||||
# forwarded agent really is the only way to push, and an empty one is worth
|
||||
# saying so before it fails deep into a session.
|
||||
@@ -3469,7 +3600,7 @@ EOF
|
||||
# The unnamed default gets a name, which is what lets it carry a git name
|
||||
# and a forge credential beside the key rather than those coming from
|
||||
# three different places.
|
||||
ID_DIR="$(mktemp -d)"; : > "$ID_DIR/default-key"; : > "$ID_DIR/tok"
|
||||
ID_DIR="$(mktemp -d)"; printf "k\n" | tee "$ID_DIR/default-key" > "$ID_DIR/tok"
|
||||
write_config '[core]' 'forge = gitea' \
|
||||
'[forge.gitea]' 'ssh_host = git.example' 'api = https://git.example/api/v1' \
|
||||
'[identity.default]' "ssh_key = $ID_DIR/default-key" \
|
||||
@@ -3484,7 +3615,7 @@ EOF
|
||||
@test "[core] ssh_key alongside [identity.default] is refused, not ranked" {
|
||||
# Two spellings of one thing. Which key a session pushed with is not
|
||||
# something to work out from precedence rules.
|
||||
ID_DIR="$(mktemp -d)"; : > "$ID_DIR/old-key"; : > "$ID_DIR/new-key"
|
||||
ID_DIR="$(mktemp -d)"; printf "k\n" | tee "$ID_DIR/old-key" > "$ID_DIR/new-key"
|
||||
write_config '[core]' 'forge = none' "ssh_key = $ID_DIR/old-key" \
|
||||
'[identity.default]' "ssh_key = $ID_DIR/new-key"
|
||||
run "$REPO_ROOT/bin/dejima" --print-config
|
||||
@@ -3497,7 +3628,7 @@ EOF
|
||||
@test "[core] ssh_key on its own still works, and says what replaced it" {
|
||||
# Deprecated, not removed: it is how every config written before
|
||||
# identities existed says this.
|
||||
ID_DIR="$(mktemp -d)"; : > "$ID_DIR/old-key"
|
||||
ID_DIR="$(mktemp -d)"; printf "not-a-real-key\n" > "$ID_DIR/old-key"
|
||||
write_config '[core]' 'forge = none' "ssh_key = $ID_DIR/old-key"
|
||||
run "$REPO_ROOT/bin/dejima" --print-config
|
||||
[ "$status" -eq 0 ] || { echo "a pre-identity config stopped working: $output"; return 1; }
|
||||
@@ -3508,7 +3639,7 @@ EOF
|
||||
}
|
||||
|
||||
@test "a named identity still beats [identity.default]" {
|
||||
ID_DIR="$(mktemp -d)"; : > "$ID_DIR/default-key"; : > "$ID_DIR/bot-key"
|
||||
ID_DIR="$(mktemp -d)"; printf "not-a-real-key\n" > "$ID_DIR/default-key"; printf "not-a-real-key\n" > "$ID_DIR/bot-key"
|
||||
write_config '[core]' 'forge = none' \
|
||||
'[identity.default]' "ssh_key = $ID_DIR/default-key" \
|
||||
'[identity.bot]' "ssh_key = $ID_DIR/bot-key"
|
||||
@@ -3523,7 +3654,7 @@ EOF
|
||||
# from the refusal - adding [identity.default] would stop it starting.
|
||||
# Finding that out on the launch that breaks is worse than being told now,
|
||||
# so the warning is about the config rather than about this launch.
|
||||
ID_DIR="$(mktemp -d)"; : > "$ID_DIR/old-key"; : > "$ID_DIR/bot-key"
|
||||
ID_DIR="$(mktemp -d)"; printf "not-a-real-key\n" > "$ID_DIR/old-key"; printf "not-a-real-key\n" > "$ID_DIR/bot-key"
|
||||
write_config '[core]' 'forge = none' "ssh_key = $ID_DIR/old-key" \
|
||||
'identity = bot' '[identity.bot]' "ssh_key = $ID_DIR/bot-key"
|
||||
run "$REPO_ROOT/bin/dejima" --print-config
|
||||
@@ -3544,7 +3675,7 @@ EOF
|
||||
# The other half of a deprecation warning: it has to stop. One that fires
|
||||
# after the migration is one an operator learns to ignore, which is how
|
||||
# the next warning gets missed too.
|
||||
ID_DIR="$(mktemp -d)"; : > "$ID_DIR/key"
|
||||
ID_DIR="$(mktemp -d)"; printf "not-a-real-key\n" > "$ID_DIR/key"
|
||||
write_config '[core]' 'forge = none' \
|
||||
'[identity.default]' "ssh_key = $ID_DIR/key" 'user_name = dejima agent'
|
||||
run "$REPO_ROOT/bin/dejima" --print-config
|
||||
@@ -3783,6 +3914,96 @@ EOF
|
||||
rm -rf "$workspace"
|
||||
}
|
||||
|
||||
@test "the staged ~/.claude.json carries the launching project's entry and no other" {
|
||||
# The projects map keys per-project state by absolute path, so a verbatim
|
||||
# copy tells a session the name of every project on the host, a sample of
|
||||
# each one's filenames (exampleFiles) and what each has cost. Narrowed for
|
||||
# the reason #104 narrowed the projects MOUNT: the whole map is every
|
||||
# project's (#380).
|
||||
_rw_setup
|
||||
workspace="$(mktemp -d)"
|
||||
# A worktree under the project, because a session's cwd routinely is one and
|
||||
# Claude Code keys that path separately - and a sibling whose path merely
|
||||
# begins with the project's, which is the comparison a bare prefix test
|
||||
# gets wrong.
|
||||
#
|
||||
# The other project's path is deliberately dull. Calling it anything
|
||||
# credential-shaped - "secret" was the first attempt - makes the staged
|
||||
# copy trip dejima's own credential scan, so the launch refuses and this
|
||||
# test passes on a refusal rather than on the narrowing it is named for.
|
||||
mkdir -p "$workspace/.worktrees/feature"
|
||||
cat > "$RW_HOME/.claude.json" <<JSON
|
||||
{"primaryApiKey":"sk-ant-api03-REAL-KEY-FROM-THE-HOST",
|
||||
"projects":{
|
||||
"$workspace":{"exampleFiles":["mine.py"],"lastCost":1},
|
||||
"$workspace/.worktrees/feature":{"exampleFiles":["branch.py"]},
|
||||
"${workspace}-sibling":{"exampleFiles":["sibling.py"]},
|
||||
"/home/someone/else/their-project":{"exampleFiles":["theirs.py"],"lastCost":99}},
|
||||
"numStartups":7}
|
||||
JSON
|
||||
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only' \
|
||||
'harness = claude-code' '[harness.claude-code]' 'backend = anthropic'
|
||||
|
||||
local stub log staged
|
||||
stub="$(mktemp -d)"; log="$(mktemp)"; staged="$(mktemp)"
|
||||
cat > "$stub/docker" <<EOF
|
||||
#!/bin/sh
|
||||
[ -n "\$SANDBOX_CLAUDE_JSON_FILE" ] && cp "\$SANDBOX_CLAUDE_JSON_FILE" "$staged" 2>/dev/null
|
||||
exit 0
|
||||
EOF
|
||||
chmod +x "$stub/docker"
|
||||
HOME="$RW_HOME" PATH="$stub:$PATH" SSH_AUTH_SOCK= \
|
||||
run "$REPO_ROOT/bin/dejima" "$workspace"
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
[ -s "$staged" ] || { echo "no staged ~/.claude.json was mounted"; return 1; }
|
||||
|
||||
# The project and anything under it.
|
||||
[ "$(jq -r --arg p "$workspace" '.projects[$p].lastCost' "$staged")" = 1 ] \
|
||||
|| { echo "the launching project's own entry is gone"; return 1; }
|
||||
[ "$(jq -r --arg p "$workspace/.worktrees/feature" '.projects[$p].exampleFiles[0]' "$staged")" = branch.py ] \
|
||||
|| { echo "a worktree inside the project was dropped"; return 1; }
|
||||
|
||||
# And nothing else, including the sibling a bare prefix test would keep.
|
||||
[ "$(jq -r --arg p "${workspace}-sibling" '.projects | has($p)' "$staged")" = false ] \
|
||||
|| { echo "a sibling path sharing the project's prefix was kept"; return 1; }
|
||||
[ "$(jq -r '.projects | has("/home/someone/else/their-project")' "$staged")" = false ] \
|
||||
|| { echo "another project reached the container"; return 1; }
|
||||
[ "$(jq -r '.projects | length' "$staged")" = 2 ] \
|
||||
|| { echo "wrong number of entries: $(jq -c '.projects | keys' "$staged")"; return 1; }
|
||||
grep -q 'theirs.py' "$staged" \
|
||||
&& { echo "another project's filenames reached the container"; return 1; }
|
||||
|
||||
# The rest of the file is why the copy exists, and the credential still goes.
|
||||
[ "$(jq -r '.numStartups' "$staged")" = 7 ] || { echo "lost app state"; return 1; }
|
||||
[ "$(jq -r '.primaryApiKey // "absent"' "$staged")" = absent ] \
|
||||
|| { echo "the console API key reached the container"; return 1; }
|
||||
rm -rf "$workspace" "$stub" "$log" "$staged"
|
||||
}
|
||||
|
||||
@test "a projects key that is not a map does not refuse the launch" {
|
||||
# Narrowing a `projects` that holds a string would error, and the staging
|
||||
# treats a jq failure as fatal - so an odd but parseable file would become
|
||||
# a launch that will not start, over a key with nothing in it to narrow.
|
||||
_rw_setup
|
||||
workspace="$(mktemp -d)"
|
||||
printf '{"projects":"not-a-map","numStartups":3}\n' > "$RW_HOME/.claude.json"
|
||||
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only' \
|
||||
'harness = claude-code' '[harness.claude-code]' 'backend = anthropic'
|
||||
local stub staged
|
||||
stub="$(mktemp -d)"; staged="$(mktemp)"
|
||||
cat > "$stub/docker" <<EOF
|
||||
#!/bin/sh
|
||||
[ -n "\$SANDBOX_CLAUDE_JSON_FILE" ] && cp "\$SANDBOX_CLAUDE_JSON_FILE" "$staged" 2>/dev/null
|
||||
exit 0
|
||||
EOF
|
||||
chmod +x "$stub/docker"
|
||||
HOME="$RW_HOME" PATH="$stub:$PATH" SSH_AUTH_SOCK= \
|
||||
run "$REPO_ROOT/bin/dejima" "$workspace"
|
||||
[ "$status" -eq 0 ] || { echo "an odd projects key refused the launch: $output"; return 1; }
|
||||
[ "$(jq -r '.numStartups' "$staged")" = 3 ] || { echo "the copy lost its state"; return 1; }
|
||||
rm -rf "$workspace" "$stub" "$staged"
|
||||
}
|
||||
|
||||
@test "a console API key in ~/.claude.json does not reach the container" {
|
||||
# Found on a real desktop launch. A Claude Code signed in with a console
|
||||
# API token keeps the key as a top-level primaryApiKey in ~/.claude.json,
|
||||
@@ -4109,6 +4330,46 @@ _scan_staged_has_token() {
|
||||
rm -rf "$workspace" "$SCAN_STUB" "$SCAN_STAGED"
|
||||
}
|
||||
|
||||
@test "a scrub finds the value again whatever the key is spelled like" {
|
||||
# Deleting by document order made each later index stale: the second secret
|
||||
# survived and an innocent value went instead. The staged file is mounted
|
||||
# into the container, so the operator answers "keep it out" and it goes in.
|
||||
#
|
||||
# Driven against the program's own functions rather than a reimplementation
|
||||
# of the ordering, which is the thing under test.
|
||||
local scan="$REPO_ROOT/bin/dejima-credential-scan"
|
||||
run python3 - "$scan" <<'PYEOF'
|
||||
import json, sys
|
||||
mod = {}
|
||||
exec(compile(open(sys.argv[1]).read(), "scan", "exec"), mod)
|
||||
doc = {"mcpServers": {"x": {"args": ["sk-SECRET-ONE-aaaaaaaaaaaaaaaaaaaa",
|
||||
"sk-SECRET-TWO-bbbbbbbbbbbbbbbbbbbb",
|
||||
"--harmless-flag"]}}}
|
||||
def scrub(doc, want):
|
||||
pairs = list(mod["walk_json"](json.loads(json.dumps(doc))))
|
||||
drop = [parts for p, v, parts in pairs if p in want]
|
||||
out = json.loads(json.dumps(doc))
|
||||
mod["delete_json_components"](out, drop)
|
||||
return out
|
||||
|
||||
print(json.dumps(scrub(doc, {"mcpServers.x.args[0]",
|
||||
"mcpServers.x.args[1]"})["mcpServers"]["x"]["args"]))
|
||||
# A key that spells an index, and a key carrying a dot: both used to be
|
||||
# re-parsed as traversals, deleting a bystander or raising out of the scrub.
|
||||
print(json.dumps(scrub({"x[0]": "sk-SECRET-key", "x": ["innocent"]}, {"x[0]"})))
|
||||
print(json.dumps(scrub({"a.b": "sk-SECRET-dotted"}, {"a.b"})))
|
||||
PYEOF
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
[[ "$output" != *SECRET* ]] \
|
||||
|| { echo "a secret survived the scrub: $output"; return 1; }
|
||||
[[ "$output" == *harmless-flag* ]] \
|
||||
|| { echo "the scrub removed a value that was not a secret: $output"; return 1; }
|
||||
# No line raised: a KeyError here aborts the scrub and bin/dejima then
|
||||
# copies the file in unscrubbed.
|
||||
[ "${#lines[@]}" -eq 3 ] \
|
||||
|| { echo "the scrub did not complete for every shape: $output"; return 1; }
|
||||
}
|
||||
|
||||
@test "a standing scrub written by hand is honoured, and holds no value" {
|
||||
# Not offered at the prompt, but available to someone who has read what it
|
||||
# does - and it is the only way a scrubbed field survives an unattended
|
||||
|
||||
Vendored
+9
-3
@@ -21,6 +21,9 @@ import select
|
||||
import sys
|
||||
import time
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import loadscale # noqa: E402
|
||||
|
||||
# The default is the credential scan's prompt. --marker takes another, so a
|
||||
# test for a different prompt does not need a second copy of this.
|
||||
MARKER = "[s/a/o/r]"
|
||||
@@ -33,9 +36,12 @@ AMBIENT = "Press Enter to continue"
|
||||
# An IDLE timeout: how long to wait with no output before giving up. Generous
|
||||
# because the thing it guards against is a command that never finishes, and
|
||||
# any finite bound catches that - while a tight one just times the machine.
|
||||
# Sixty seconds was enough on an idle box and not enough under the full gate,
|
||||
# where every suite runs at once (#295).
|
||||
TIMEOUT = 240.0
|
||||
#
|
||||
# Multiplied by how oversubscribed the run asked the machine to be, rather than
|
||||
# raised again the next time it is not enough: this one went from sixty seconds
|
||||
# to this because sixty was ample on an idle box and expired under the full
|
||||
# gate, which is the shape of guess loadscale exists to stop (#295).
|
||||
TIMEOUT = loadscale.deadline(240.0)
|
||||
|
||||
|
||||
def main():
|
||||
|
||||
Vendored
+10
-4
@@ -16,8 +16,10 @@ import json
|
||||
import threading
|
||||
import time
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
STATE = {"oauth_hits": 0, "oauth_last": {}, "returned_401": False, "oauth_reject": False}
|
||||
STATE = {"oauth_hits": 0, "oauth_last": {}, "returned_401": False, "oauth_reject": False,
|
||||
"oauth_reject_say": None}
|
||||
LOCK = threading.Lock()
|
||||
|
||||
|
||||
@@ -50,12 +52,15 @@ class Handler(BaseHTTPRequestHandler):
|
||||
}).encode())
|
||||
|
||||
def _handle(self):
|
||||
if self.path == "/oauth-reject":
|
||||
if self.path.startswith("/oauth-reject"):
|
||||
# Make the next refresh fail the way a finished refresh token does.
|
||||
# Set rather than always-on, so the ordinary refresh tests keep the
|
||||
# same endpoint.
|
||||
# same endpoint. ?say= replaces the error_description, for tests
|
||||
# about upstream-chosen text landing in the proxy's log.
|
||||
with LOCK:
|
||||
STATE["oauth_reject"] = True
|
||||
STATE["oauth_reject_say"] = \
|
||||
parse_qs(urlsplit(self.path).query).get("say", [None])[0]
|
||||
self._send(200, b"ok", "text/plain")
|
||||
elif self.path == "/v1/oauth/token":
|
||||
req = json.loads(self.rfile.read(int(self.headers.get("Content-Length", 0))))
|
||||
@@ -63,11 +68,12 @@ class Handler(BaseHTTPRequestHandler):
|
||||
STATE["oauth_hits"] += 1
|
||||
STATE["oauth_last"] = req
|
||||
reject = STATE.get("oauth_reject")
|
||||
say = STATE.get("oauth_reject_say")
|
||||
if reject:
|
||||
# Verbatim from a real overnight expiry.
|
||||
self._send(400, json.dumps({
|
||||
"error": "invalid_grant",
|
||||
"error_description": "Refresh token expired",
|
||||
"error_description": say or "Refresh token expired",
|
||||
}).encode())
|
||||
return
|
||||
self._send(200, json.dumps({
|
||||
|
||||
Vendored
+43
@@ -0,0 +1,43 @@
|
||||
"""How much longer a bounded wait should be given on a loaded machine.
|
||||
|
||||
The suites here wait on things that take as long as the machine makes them take
|
||||
- a pty reaching raw mode, a shipper polling, a prompt being answered - and
|
||||
each wait has a deadline. A deadline that is right on an idle box is wrong
|
||||
under the gate, which runs every suite at once, and lengthening it until both
|
||||
work means guessing a number that is generous in one place and useless in the
|
||||
other (#295).
|
||||
|
||||
So the runner says how much concurrency it asked for, and the deadline is
|
||||
multiplied by how oversubscribed that makes the machine. Nothing gets shorter
|
||||
than it is today: the multiplier floors at 1, so an idle run and a run with no
|
||||
variable set behave exactly as before.
|
||||
|
||||
Capped, because this is a multiplier on a timeout: a variable set wrongly, or a
|
||||
machine reporting one core, would otherwise turn a test that should fail in
|
||||
seconds into one that hangs for the length of the CI job.
|
||||
|
||||
DEJIMA_TEST_CONCURRENCY is how many tests the runner intends to have in flight
|
||||
at once. test/run-confined.sh sets it to the budget it divided among the
|
||||
suites; the CI workflow sets it to what it passes bats. Neither is a guess
|
||||
about load - it is what that runner asked the machine for.
|
||||
"""
|
||||
import os
|
||||
|
||||
MAX_SCALE = 4.0
|
||||
|
||||
|
||||
def scale():
|
||||
"""The multiplier, >= 1.0 and <= MAX_SCALE."""
|
||||
try:
|
||||
want = int(os.environ.get("DEJIMA_TEST_CONCURRENCY", "0"))
|
||||
except ValueError:
|
||||
return 1.0
|
||||
if want <= 0:
|
||||
return 1.0
|
||||
cores = os.cpu_count() or 1
|
||||
return max(1.0, min(MAX_SCALE, want / float(cores)))
|
||||
|
||||
|
||||
def deadline(seconds):
|
||||
"""A bounded wait, lengthened for the concurrency this run asked for."""
|
||||
return seconds * scale()
|
||||
Vendored
+11
-3
@@ -25,6 +25,9 @@ import sys
|
||||
import termios
|
||||
import time
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from loadscale import deadline # noqa: E402
|
||||
|
||||
HELPER = os.path.abspath(sys.argv[1])
|
||||
SHELL = sys.argv[2] if len(sys.argv) > 2 else "bash"
|
||||
# Required, not defaulted. The fallback was "/tmp", and the three paths below
|
||||
@@ -200,7 +203,7 @@ def pump(seconds):
|
||||
|
||||
def expect(text, timeout=8.0):
|
||||
"""Wait for text to appear in output since the last call to forget()."""
|
||||
end = time.time() + timeout
|
||||
end = time.time() + deadline(timeout)
|
||||
while time.time() < end:
|
||||
if text in "".join(seen):
|
||||
return True
|
||||
@@ -238,7 +241,7 @@ def reclaimed():
|
||||
|
||||
|
||||
def await_reclaim(timeout=10.0):
|
||||
end = time.time() + timeout
|
||||
end = time.time() + deadline(timeout)
|
||||
while time.time() < end:
|
||||
if reclaimed():
|
||||
return True
|
||||
@@ -259,8 +262,13 @@ def await_raw(timeout=15.0):
|
||||
scheduling later. Sampling the flags right after fg was timing the machine,
|
||||
and on a loaded one it read the shell's own canonical mode and reported it
|
||||
as a failure to restore - the same shape as the reclaim wait above.
|
||||
|
||||
Every bound in this file is multiplied by how oversubscribed the run asked
|
||||
the machine to be (loadscale), because lengthening them until both an idle
|
||||
box and the full gate pass means picking a number that is wrong in one of
|
||||
the two. They are unchanged when nothing says otherwise.
|
||||
"""
|
||||
end = time.time() + timeout
|
||||
end = time.time() + deadline(timeout)
|
||||
while time.time() < end:
|
||||
if raw_restored():
|
||||
return True
|
||||
|
||||
+1
-1
@@ -51,7 +51,7 @@ esac
|
||||
exit 0
|
||||
EOF
|
||||
chmod +x "$LAUNCH_DIR/docker"
|
||||
: > "$LAUNCH_DIR/sandbox-key"
|
||||
printf "not-a-real-key\n" > "$LAUNCH_DIR/sandbox-key"
|
||||
chmod 600 "$LAUNCH_DIR/sandbox-key"
|
||||
PROJ="$(mktemp -d)"
|
||||
}
|
||||
|
||||
+393
-2
@@ -84,10 +84,15 @@ done
|
||||
exit 0
|
||||
EOF
|
||||
chmod +x "$LAUNCH_DIR/docker"
|
||||
: > "$LAUNCH_DIR/sandbox-key"
|
||||
printf "not-a-real-key\n" > "$LAUNCH_DIR/sandbox-key"
|
||||
chmod 600 "$LAUNCH_DIR/sandbox-key"
|
||||
}
|
||||
|
||||
# SSH_AUTH_SOCK is emptied rather than passed through, so a test never reads
|
||||
# the agent of whoever is running the suite - an inherited one would make the
|
||||
# forwarded-agent path depend on the machine. LAUNCH_SSH_AUTH_SOCK is how the
|
||||
# one test about that path states its own precondition instead.
|
||||
#
|
||||
# ssh_key is written into whatever config file config_root() currently
|
||||
# resolves to (DEJIMA_CONFIG_DIR, which a few tests point elsewhere) rather
|
||||
# than always $CONFIG_DIR/config, and appended rather than written, so it
|
||||
@@ -110,7 +115,7 @@ launch() {
|
||||
SANDBOX_TEST_RUN_ENV="$LAUNCH_ENV" \
|
||||
SANDBOX_TEST_DOCKER_LOG="$LAUNCH_DIR/docker-log" \
|
||||
SANDBOX_TEST_DOCKERFILE_LOG="$LAUNCH_DIR/dockerfile-log" \
|
||||
SSH_AUTH_SOCK= ALLOWED_DOMAINS= \
|
||||
SSH_AUTH_SOCK="${LAUNCH_SSH_AUTH_SOCK-}" ALLOWED_DOMAINS= \
|
||||
"$REPO_ROOT/bin/dejima" "$@"
|
||||
}
|
||||
|
||||
@@ -1310,6 +1315,392 @@ ENTRYPOINT [\"/usr/local/bin/entrypoint.sh\"]" ]]
|
||||
done
|
||||
}
|
||||
|
||||
# --isolated: a session with nothing before it and nothing after it. The
|
||||
# claim is one sentence - nothing of the host crosses in but the operator's
|
||||
# own read-only config, and nothing survives but ~/shared - and these are the
|
||||
# parts of it that a stubbed launch can decide.
|
||||
#
|
||||
# Driven through the real launcher rather than by grepping it, because every
|
||||
# one of these is a property of what `compose run` is handed, and a grep over
|
||||
# the source passes just as well on a line that no longer takes effect.
|
||||
@test "an isolated launch mounts no host path for its working directory" {
|
||||
launch_setup
|
||||
run launch --isolated
|
||||
[ "$status" -eq 0 ]
|
||||
|
||||
# The whole spec, because the compose file cannot build one: a source and
|
||||
# a target would have to be two variables and neither can be empty. The
|
||||
# source here names the compose file's own volume, so nothing of the host
|
||||
# is behind the working directory.
|
||||
local spec; spec="$(launched_env SANDBOX_WORKSPACE_MOUNT)"
|
||||
[ "$spec" = "isolated-work:/dejima/work" ] \
|
||||
|| { echo "the workspace mount is $spec, not the container-local volume"; return 1; }
|
||||
[ "$(launched_env WORKSPACE_DIR)" = /dejima/work ]
|
||||
|
||||
# Whatever the source is, it must not be a path: a source starting with /
|
||||
# is a bind mount, which is the one thing this mode cannot have here.
|
||||
case "${spec%%:*}" in
|
||||
/*) echo "the workspace mount names a host path: $spec"; return 1 ;;
|
||||
esac
|
||||
|
||||
# That the volume it names is this run's own, and is removed afterwards,
|
||||
# belongs to the test below with the other four.
|
||||
|
||||
# And the ordinary case still mounts the project at its own path, so the
|
||||
# assertion above cannot pass by the variable having stopped working.
|
||||
run launch "$PROJ"
|
||||
[ "$status" -eq 0 ]
|
||||
[ "$(launched_env SANDBOX_WORKSPACE_MOUNT)" = "$PROJ:$PROJ" ]
|
||||
}
|
||||
|
||||
@test "an isolated launch takes nothing from the project it was started in" {
|
||||
# The one in this file that is about an attack rather than about tidiness.
|
||||
# resolve_project_store walks UPWARDS from the directory it is given, so
|
||||
# an operator in $HOME on a machine whose dotfiles were once approved as a
|
||||
# project would otherwise have handed an anonymous session that project's
|
||||
# allowed domains and its dependency layer. The flag means the directory
|
||||
# it was run from decides nothing.
|
||||
#
|
||||
# Two independent things in bin/dejima hold that - the working directory
|
||||
# being container-local, and the approval store not being resolved - and
|
||||
# this fails only when BOTH are reversed. Worth knowing when reading a
|
||||
# mutation result: either one alone still passes, which is what defence
|
||||
# in depth looks like from here rather than a test that misses.
|
||||
"$REPO_ROOT/bin/dejima-allow" --project "$PROJ" example.invalid >/dev/null
|
||||
"$REPO_ROOT/bin/dejima-dep" add --project "$PROJ" apt:libpq-dev >/dev/null
|
||||
launch_setup
|
||||
|
||||
local slug; slug="$(bash -c 'source "$1"; project_slug "$2"' _ "$LIB" "$PROJ")"
|
||||
|
||||
# First that the fixture works at all: an ordinary launch from in there
|
||||
# DOES get both. Without this the assertions below pass on a project
|
||||
# whose approvals were never recorded.
|
||||
cd "$PROJ"
|
||||
run launch
|
||||
cd - >/dev/null
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$(launched_env ALLOWED_DOMAINS)" == *example.invalid* ]] \
|
||||
|| { echo "the fixture's project domains were never approved"; return 1; }
|
||||
[ "$(launched_env SANDBOX_IMAGE)" = "localhost/dejima:$(id -un)-$slug" ] \
|
||||
|| { echo "the fixture's project deps were never approved"; return 1; }
|
||||
|
||||
# And then that the isolated launch from the same directory gets neither.
|
||||
# The log accumulates across launches in one fixture, so the build line
|
||||
# asserted absent below would otherwise be found from the launch above.
|
||||
: > "$LAUNCH_DIR/docker-log"
|
||||
cd "$PROJ"
|
||||
run launch --isolated
|
||||
cd - >/dev/null
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$(launched_env ALLOWED_DOMAINS)" != *example.invalid* ]] \
|
||||
|| { echo "an isolated launch inherited the cwd's approved domains"; return 1; }
|
||||
[ "$(launched_env SANDBOX_IMAGE)" != "localhost/dejima:$(id -un)-$slug" ] \
|
||||
|| { echo "an isolated launch inherited the cwd's dependency layer"; return 1; }
|
||||
grep -q -- "-t localhost/dejima:$(id -un)-$slug -f" <(docker_log) \
|
||||
&& { echo "an isolated launch built the cwd's dependency layer"; return 1; }
|
||||
return 0
|
||||
}
|
||||
|
||||
@test "an isolated launch mounts the operator's read-only config and none of the writable state" {
|
||||
launch_setup
|
||||
run launch --isolated
|
||||
[ "$status" -eq 0 ]
|
||||
|
||||
local log; log="$(docker_log)"
|
||||
# The operator's own hooks and skills: theirs, read-only, and not
|
||||
# something an earlier session could have written.
|
||||
grep -q -- "-v $LAUNCH_DIR/home/.claude/skills:" <<< "$log" \
|
||||
|| { echo "the operator's skills are not mounted"; return 1; }
|
||||
# The shared read-write state every project's sessions append to (#380),
|
||||
# and the transcripts and memory under projects/.
|
||||
local d
|
||||
for d in backups tasks plans sessions projects file-history; do
|
||||
grep -q -- "-v $LAUNCH_DIR/home/.claude/$d" <<< "$log" \
|
||||
&& { echo "an isolated launch mounts ~/.claude/$d"; return 1; }
|
||||
done
|
||||
# Not mounted and also not CREATED: the pre-create loop exists so Docker
|
||||
# does not make a root-owned mount source, and a directory nothing mounts
|
||||
# needs no source.
|
||||
for d in backups tasks plans sessions; do
|
||||
[ ! -d "$LAUNCH_DIR/home/.claude/$d" ] \
|
||||
|| { echo "an isolated launch created ~/.claude/$d on the host"; return 1; }
|
||||
done
|
||||
return 0
|
||||
}
|
||||
|
||||
@test "an isolated launch gets a fresh share directory that no other session has had" {
|
||||
local share; share="$(mktemp -d)"
|
||||
launch_setup
|
||||
printf '[core]\nshare_dir = %s\n' "$share" \
|
||||
>> "$(bash -c 'source "$1"; config_path' _ "$LIB")"
|
||||
|
||||
run launch --isolated
|
||||
[ "$status" -eq 0 ]
|
||||
local first; first="$(launched_env SANDBOX_SHARE_DIR)"
|
||||
run launch --isolated
|
||||
[ "$status" -eq 0 ]
|
||||
local second; second="$(launched_env SANDBOX_SHARE_DIR)"
|
||||
|
||||
[ -n "$first" ] && [ -n "$second" ] || { echo "no share directory was set"; return 1; }
|
||||
[ "$first" != "$second" ] \
|
||||
|| { echo "two isolated launches were handed the same directory: $first"; return 1; }
|
||||
case "$first" in
|
||||
"$share"/isolated/*) ;;
|
||||
*) echo "the isolated share is not under the isolated root: $first"; return 1 ;;
|
||||
esac
|
||||
[ -d "$first" ] && [ -d "$second" ] || { echo "the share directories were not made"; return 1; }
|
||||
# Made, and nothing else: the one host directory an isolated session
|
||||
# writes starts empty, which is the whole of "nothing crosses in".
|
||||
[ -z "$(ls -A "$first")" ] || { echo "the share directory is not empty"; return 1; }
|
||||
rm -rf "$share"
|
||||
}
|
||||
|
||||
@test "an isolated launch names its volumes for the run and removes them" {
|
||||
launch_setup
|
||||
run launch --isolated
|
||||
[ "$status" -eq 0 ]
|
||||
|
||||
# Two are sharper than the rest. The shell history is shared by every
|
||||
# project even when the nvim ones are not, so under its usual name an
|
||||
# isolated session reads what was typed in any session before it; and the
|
||||
# working directory is where everything the session did actually lives.
|
||||
local v names=""
|
||||
for v in SANDBOX_NVIM_DATA SANDBOX_NVIM_STATE SANDBOX_NVIM_CACHE SANDBOX_BASH_HISTORY SANDBOX_ISOLATED_WORK; do
|
||||
names="$names $(launched_env "$v")"
|
||||
[[ "$(launched_env "$v")" == dejima_isolated-* ]] \
|
||||
|| { echo "$v is not this run's own: $(launched_env "$v")"; return 1; }
|
||||
[[ "$(launched_env "$v")" =~ ^[a-zA-Z0-9][a-zA-Z0-9_.-]*$ ]] \
|
||||
|| { echo "$v is not a valid Docker volume name"; return 1; }
|
||||
done
|
||||
grep -q "volume rm$names" <(docker_log) \
|
||||
|| { echo "the run's volumes are not removed:$names"; cat "$LAUNCH_DIR/docker-log"; return 1; }
|
||||
|
||||
local before="$names"
|
||||
run launch --isolated
|
||||
[ "$status" -eq 0 ]
|
||||
names=""
|
||||
for v in SANDBOX_NVIM_DATA SANDBOX_NVIM_STATE SANDBOX_NVIM_CACHE SANDBOX_BASH_HISTORY SANDBOX_ISOLATED_WORK; do
|
||||
names="$names $(launched_env "$v")"
|
||||
done
|
||||
[ "$before" != "$names" ] \
|
||||
|| { echo "two isolated launches asked for the same volumes:$names"; return 1; }
|
||||
|
||||
# And an ordinary launch still gets the shared history volume, which is
|
||||
# what makes a session started tomorrow see what was typed today.
|
||||
#
|
||||
# Asserted as "sets nothing", not as a name. What the volume is called
|
||||
# then comes from the compose default, and whether THAT is the name Docker
|
||||
# would have chosen on its own is a property of the compose file - checked
|
||||
# there, in static.bats, against the project name rather than against a
|
||||
# string repeated here. A name in this test would have been updated to
|
||||
# match a wrong default rather than catching one.
|
||||
run launch "$PROJ"
|
||||
[ "$status" -eq 0 ]
|
||||
[ -z "$(launched_env SANDBOX_BASH_HISTORY)" ] \
|
||||
|| { echo "an ordinary launch renamed the history volume to $(launched_env SANDBOX_BASH_HISTORY)"
|
||||
return 1; }
|
||||
}
|
||||
|
||||
@test "an isolated launch reaches no forge unless the same command names one" {
|
||||
# The forge credential is the widest thing a session holds - a Gitea token
|
||||
# is account-wide - so the mode whose purpose is a session nobody is
|
||||
# watching does not carry one by default. It narrows, like every other
|
||||
# flag here.
|
||||
#
|
||||
# Both orders, because the defaults are applied after the argument loop
|
||||
# rather than inside it: a version that set them in the loop would pass
|
||||
# one of these two and fail the other.
|
||||
launch_setup
|
||||
local conf
|
||||
conf="$(bash -c 'source "$1"; config_path' _ "$LIB")"
|
||||
# With the section header: launch() appends its own [core] block after
|
||||
# this, so a bare key here would sit outside any section and be refused.
|
||||
printf '[core]\nforge = gitea\nworkflow = worktree-pr\n' >> "$conf"
|
||||
|
||||
run launch --isolated --print-config
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
grep -qx 'core.forge=none' <<< "$output" \
|
||||
|| { echo "an isolated launch kept the configured forge"; return 1; }
|
||||
grep -qx 'core.workflow=local-only' <<< "$output" \
|
||||
|| { echo "an isolated launch kept a workflow that pushes"; return 1; }
|
||||
|
||||
local args
|
||||
for args in "--isolated --workflow worktree-pr" "--workflow worktree-pr --isolated"; do
|
||||
# shellcheck disable=SC2086
|
||||
run launch $args --print-config
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
grep -qx 'core.workflow=worktree-pr' <<< "$output" \
|
||||
|| { echo "'$args' did not keep the workflow the operator named"; return 1; }
|
||||
done
|
||||
|
||||
# And the config file is untouched for a launch without the flag, so the
|
||||
# assertions above are about --isolated rather than about the fixture.
|
||||
run launch --print-config
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
grep -qx 'core.forge=gitea' <<< "$output" \
|
||||
|| { echo "an ordinary launch lost the configured forge"; return 1; }
|
||||
return 0
|
||||
}
|
||||
|
||||
@test "an isolated launch mounts the configured ssh key and not the forwarded agent" {
|
||||
# Opposite answers for the two, which is the whole reason this is one
|
||||
# test rather than a line in another: a workflow that never pushes gets
|
||||
# no agent socket, and --isolated defaults to one of those, while a
|
||||
# configured key is exported whatever the workflow. Both docs state the
|
||||
# pair, and a test that checked only the key - as this one first did -
|
||||
# cannot catch the half that was written backwards.
|
||||
#
|
||||
# Withholding the key as well, for any launch that never pushes, is #392.
|
||||
launch_setup
|
||||
run launch --isolated
|
||||
[ "$status" -eq 0 ]
|
||||
[ "$(launched_env SANDBOX_SSH_KEY_FILE)" = "$LAUNCH_DIR/sandbox-key" ] \
|
||||
|| { echo "the configured key did not cross: $(launched_env SANDBOX_SSH_KEY_FILE)"
|
||||
echo "- if that is deliberate, the two docs saying it does need updating"
|
||||
return 1; }
|
||||
|
||||
# Now the agent, which needs its own fixture for two reasons. The helper
|
||||
# empties SSH_AUTH_SOCK so no test reads the suite runner's own agent, so
|
||||
# a launch that never sets it could not have forwarded one whatever the
|
||||
# code did - asserting its absence there proves nothing. And a configured
|
||||
# key withdraws the agent too, independently of the workflow, so the key
|
||||
# above has to go for this to be about the rule the docs name. An EMPTY
|
||||
# key file is "not a key" by design, which is how that is arranged.
|
||||
: > "$LAUNCH_DIR/sandbox-key"
|
||||
# A real socket, because the launcher tests for one with -S. Binding
|
||||
# creates the filesystem entry and leaves it there after the process
|
||||
# exits, which is all this needs.
|
||||
python3 -c 'import socket,sys
|
||||
s = socket.socket(socket.AF_UNIX)
|
||||
s.bind(sys.argv[1])' "$LAUNCH_DIR/agent.sock"
|
||||
[ -S "$LAUNCH_DIR/agent.sock" ] || { echo "the fixture made no socket"; return 1; }
|
||||
|
||||
LAUNCH_SSH_AUTH_SOCK="$LAUNCH_DIR/agent.sock" run launch --isolated
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
[ -z "$(launched_env SSH_AUTH_SOCK)" ] \
|
||||
|| { echo "a workflow that pushes nothing still forwarded the agent"; return 1; }
|
||||
|
||||
# And the positive control. A pushing workflow needs a forge with a host
|
||||
# to push to, so this names both - which is also the combination an
|
||||
# operator asking for an isolated session that pushes would type.
|
||||
printf 'ghp_not_a_real_push_token\n' > "$LAUNCH_DIR/gh-push-token"
|
||||
chmod 600 "$LAUNCH_DIR/gh-push-token"
|
||||
printf '[forge.github]\npush_token_file = %s\n' "$LAUNCH_DIR/gh-push-token" \
|
||||
>> "$(bash -c 'source "$1"; config_path' _ "$LIB")"
|
||||
LAUNCH_SSH_AUTH_SOCK="$LAUNCH_DIR/agent.sock" \
|
||||
run launch --isolated --forge github --workflow branch-push
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
[ "$(launched_env SSH_AUTH_SOCK)" = "$LAUNCH_DIR/agent.sock" ] \
|
||||
|| { echo "naming a pushing workflow did not forward the agent"; return 1; }
|
||||
}
|
||||
|
||||
@test "an isolated launch gets no GitHub token unless the command names a forge" {
|
||||
# [github] token_file is not a forge setting, which is what makes it easy
|
||||
# to miss: core fills it for every session whatever forge is selected. A
|
||||
# read-only PAT is still read-only across the operator's private
|
||||
# repositories, so the mode that carries no forge credential cannot carry
|
||||
# this one either.
|
||||
#
|
||||
# The filter container keeps it, because that is what authenticates
|
||||
# dejima's own fetch of GitHub's address ranges - a request about the
|
||||
# allowlist, made where no session can reach it. So the two are separate
|
||||
# variables now, and this asserts both halves: the session's is empty and
|
||||
# the launcher's, which is what `-e GH_TOKEN` hands the filter, is not.
|
||||
launch_setup
|
||||
printf 'ghp_not_a_real_token\n' > "$LAUNCH_DIR/gh-token"
|
||||
chmod 600 "$LAUNCH_DIR/gh-token"
|
||||
printf '[github]\ntoken_file = %s\n' "$LAUNCH_DIR/gh-token" \
|
||||
>> "$(bash -c 'source "$1"; config_path' _ "$LIB")"
|
||||
|
||||
run launch --isolated
|
||||
[ "$status" -eq 0 ]
|
||||
[ -z "$(launched_env SANDBOX_GH_TOKEN)" ] \
|
||||
|| { echo "an isolated session was handed core's GitHub token"; return 1; }
|
||||
[ -n "$(launched_env GH_TOKEN)" ] \
|
||||
|| { echo "the filter container lost the token that authenticates its ranges fetch"
|
||||
return 1; }
|
||||
|
||||
# Naming a forge is the operator saying this session is meant to reach
|
||||
# one, and nothing is narrowed from there.
|
||||
run launch --isolated --forge gitea
|
||||
[ "$status" -eq 0 ]
|
||||
[ -n "$(launched_env SANDBOX_GH_TOKEN)" ] \
|
||||
|| { echo "a named forge did not restore the GitHub token"; return 1; }
|
||||
|
||||
# And an ordinary launch is untouched, including one whose config selects
|
||||
# no forge - this narrows for the flag, not for every forge-less launch.
|
||||
run launch --forge none --workflow local-only "$PROJ"
|
||||
[ "$status" -eq 0 ]
|
||||
[ -n "$(launched_env SANDBOX_GH_TOKEN)" ] \
|
||||
|| { echo "an ordinary launch lost core's GitHub token"; return 1; }
|
||||
}
|
||||
|
||||
@test "an isolated launch refuses a project path and the mount flags" {
|
||||
launch_setup
|
||||
# Each refusal names what it refused. A bare nonzero status would pass on
|
||||
# a launch that failed for an unrelated reason.
|
||||
run launch --isolated "$PROJ"
|
||||
[ "$status" -ne 0 ] || { echo "a project path was accepted"; return 1; }
|
||||
[[ "$output" == *"takes no project directory"* ]] \
|
||||
|| { echo "the refusal does not say why: $output"; return 1; }
|
||||
|
||||
local flag
|
||||
for flag in --ro --rw; do
|
||||
run launch --isolated "$flag" /tmp
|
||||
[ "$status" -ne 0 ] || { echo "$flag was accepted"; return 1; }
|
||||
[[ "$output" == *"cannot take --ro or --rw"* ]] \
|
||||
|| { echo "the $flag refusal does not say why: $output"; return 1; }
|
||||
[[ "$output" == *"/tmp"* ]] \
|
||||
|| { echo "the $flag refusal does not name the path: $output"; return 1; }
|
||||
done
|
||||
|
||||
# Harness arguments after -- are not a project path, and refusing them
|
||||
# would make the flag unusable with every launch that passes any.
|
||||
run launch --isolated -- --version
|
||||
[ "$status" -eq 0 ] \
|
||||
|| { echo "harness arguments were read as a project path: $output"; return 1; }
|
||||
}
|
||||
|
||||
@test "an isolated launch says what it is and what it does without" {
|
||||
launch_setup
|
||||
run launch --isolated
|
||||
[ "$status" -eq 0 ]
|
||||
|
||||
# Printed whatever [ui] banner says and with stderr redirected, unlike
|
||||
# the banner: the share path is the only route back to what the session
|
||||
# hands over, and the two features it does without are ones an operator
|
||||
# would otherwise go looking for afterwards and find empty.
|
||||
[[ "$output" == *"no project mounted, no memory, no transcript"* ]] \
|
||||
|| { echo "the launch does not say what this mode is: $output"; return 1; }
|
||||
[[ "$output" == *"conversation log and epitaph off"* ]] \
|
||||
|| { echo "the launch does not say what is off: $output"; return 1; }
|
||||
[[ "$output" == *"$(launched_env SANDBOX_SHARE_DIR)"* ]] \
|
||||
|| { echo "the launch does not name the share directory: $output"; return 1; }
|
||||
}
|
||||
|
||||
@test "an isolated launch does not apply the configured mounts, and says so" {
|
||||
local extra; extra="$(mktemp -d)"
|
||||
launch_setup
|
||||
printf '[mounts]\nreadonly = %s\n' "$extra" \
|
||||
>> "$(bash -c 'source "$1"; config_path' _ "$LIB")"
|
||||
|
||||
run launch --isolated
|
||||
[ "$status" -eq 0 ]
|
||||
grep -q -- "-v $extra" <(docker_log) \
|
||||
&& { echo "an isolated launch applied a configured mount"; return 1; }
|
||||
[[ "$output" == *"[mounts] readonly not applied"* ]] \
|
||||
|| { echo "the launch does not say the mount was dropped: $output"; return 1; }
|
||||
|
||||
# Dropped for an isolated launch only - the same config still mounts it
|
||||
# for an ordinary one, so the assertion above is about the flag rather
|
||||
# than about the list having stopped working.
|
||||
run launch "$PROJ"
|
||||
[ "$status" -eq 0 ]
|
||||
grep -q -- "-v $extra" <(docker_log) \
|
||||
|| { echo "an ordinary launch stopped applying [mounts] readonly"; return 1; }
|
||||
rm -rf "$extra"
|
||||
}
|
||||
|
||||
@test "the launcher builds and runs the global layer when only global deps are approved" {
|
||||
"$REPO_ROOT/bin/dejima-dep" add --global apt:libpq-dev >/dev/null
|
||||
launch_setup
|
||||
|
||||
@@ -215,6 +215,33 @@ assert_remedy_forges_exist() {
|
||||
[ "$status" -ne 0 ]
|
||||
}
|
||||
|
||||
@test "the briefing tells an agent how a refusal differs from a hang" {
|
||||
# The policy rejects rather than drops, for the reason its own chain
|
||||
# comment gives: silently dropping makes every blocked destination look
|
||||
# like a hang. The briefing described both as failing to connect, which
|
||||
# throws that signal away - and two agents in one day reported a host
|
||||
# firewall on the far side as dejima blocking a domain, because from
|
||||
# inside a sandbox the two are indistinguishable except by timing.
|
||||
local lib="$REPO_ROOT/bin/dejima-provider-lib.sh"
|
||||
grep -q 'refused at once rather than' "$lib" \
|
||||
|| { echo "the briefing no longer says a refusal is immediate"; return 1; }
|
||||
grep -q 'HANGS is not this allowlist' "$lib" \
|
||||
|| { echo "the briefing no longer tells an agent what a hang means"; return 1; }
|
||||
|
||||
# And the property it describes is still true of the policy, so the advice
|
||||
# cannot outlive the behaviour it rests on.
|
||||
local chain
|
||||
chain=$(sed -n '/^ chain refuse {/,/^ }/p' "$REPO_ROOT/docker/egress-policy")
|
||||
[ -n "$chain" ] || { echo "no refuse chain in the policy"; return 1; }
|
||||
grep -q 'reject with tcp reset' <<< "$chain" \
|
||||
|| { echo "the policy no longer rejects TCP, so a block would hang"; return 1; }
|
||||
grep -q 'reject with icmpx' <<< "$chain" \
|
||||
|| { echo "the policy no longer rejects non-TCP"; return 1; }
|
||||
grep -qE '(^|[^a-z])drop([^a-z]|$)' <<< "$chain" \
|
||||
&& { echo "the refuse chain drops as well as rejects: $chain"; return 1; }
|
||||
true
|
||||
}
|
||||
|
||||
@test "gitea's CLIs moved out of bin/ and into its forge" {
|
||||
[ ! -e "$REPO_ROOT/bin/gitea-pr" ]
|
||||
[ ! -e "$REPO_ROOT/bin/gitea-issue" ]
|
||||
|
||||
+176
-11
@@ -292,17 +292,18 @@ teardown() {
|
||||
|
||||
@test "forwarder bridges TCP to the proxy's unix socket" {
|
||||
start_proxy
|
||||
SANDBOX_CREDENTIAL_PROXY_SOCKET="$SOCK" SANDBOX_CREDENTIAL_PROXY_PORT=18399 \
|
||||
local fport; fport="$(free_port)"
|
||||
SANDBOX_CREDENTIAL_PROXY_SOCKET="$SOCK" SANDBOX_CREDENTIAL_PROXY_PORT="$fport" \
|
||||
SANDBOX_CREDENTIAL_TRANSPORT=loopback \
|
||||
node "$REPO_ROOT/docker/credential-proxy-forwarder.js" &
|
||||
FWD_PID=$!
|
||||
ok=""
|
||||
for _ in $(seq 1 50); do
|
||||
if curl -s -o /dev/null http://127.0.0.1:18399/echo; then ok=1; break; fi
|
||||
if curl -s -o /dev/null http://127.0.0.1:$fport/echo; then ok=1; break; fi
|
||||
sleep 0.1
|
||||
done
|
||||
[ -n "$ok" ]
|
||||
run curl -s -H "Authorization: Bearer $PLACEHOLDER" -d '{}' http://127.0.0.1:18399/v1/messages
|
||||
run curl -s -H "Authorization: Bearer $PLACEHOLDER" -d '{}' http://127.0.0.1:$fport/v1/messages
|
||||
kill "$FWD_PID" 2>/dev/null || true
|
||||
[ "$(jq -r '.headers.authorization' <<<"$output")" = "Bearer real-access-token" ]
|
||||
}
|
||||
@@ -353,6 +354,20 @@ PYEOF
|
||||
[[ "$output" != *"501"* ]]
|
||||
}
|
||||
|
||||
# The forwarder is given a port rather than asking for one, so these tests
|
||||
# have to name a number. Asking the kernel for a free one and handing that over
|
||||
# leaves a window where something else could take it; naming a constant leaves
|
||||
# the test failing on any machine already using it, which is the precondition
|
||||
# these tests were reading from the environment instead of stating. The window
|
||||
# is the smaller of the two, and the suites run on a developer's machine.
|
||||
free_port() {
|
||||
python3 -c 'import socket
|
||||
s = socket.socket()
|
||||
s.bind(("127.0.0.1", 0))
|
||||
print(s.getsockname()[1])
|
||||
s.close()'
|
||||
}
|
||||
|
||||
# The forwarder is the container-side half of the chain. In tls mode it
|
||||
# terminates TLS for api.anthropic.com on loopback so Claude Code needs no
|
||||
# ANTHROPIC_BASE_URL at all, then pipes raw bytes into the same unix socket
|
||||
@@ -382,13 +397,14 @@ make_cert() {
|
||||
@test "forwarder tls mode: https to api.anthropic.com reaches the proxy, which injects the real token" {
|
||||
make_cert
|
||||
start_proxy
|
||||
start_forwarder tls 18443
|
||||
local fport; fport="$(free_port)"
|
||||
start_forwarder tls "$fport"
|
||||
# --resolve is what stands in for the container's /etc/hosts entry: the
|
||||
# name must really be api.anthropic.com for certificate verification to
|
||||
# be a meaningful check, not a hostname the test picked to be easy.
|
||||
run curl -s --cacert "$TD/cert.pem" \
|
||||
--resolve "api.anthropic.com:18443:127.0.0.1" \
|
||||
-X POST "https://api.anthropic.com:18443/v1/messages" -d '{}'
|
||||
--resolve "api.anthropic.com:$fport:127.0.0.1" \
|
||||
-X POST "https://api.anthropic.com:$fport/v1/messages" -d '{}'
|
||||
kill "$FWD_PID" 2>/dev/null || true
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *'"authorization": "Bearer real-access-token"'* ]]
|
||||
@@ -403,8 +419,9 @@ make_cert() {
|
||||
# no HTTP awareness to catch it.
|
||||
make_cert
|
||||
start_proxy
|
||||
start_forwarder tls 18444
|
||||
run openssl s_client -connect 127.0.0.1:18444 -alpn h2,http/1.1 \
|
||||
local fport; fport="$(free_port)"
|
||||
start_forwarder tls "$fport"
|
||||
run openssl s_client -connect "127.0.0.1:$fport" -alpn h2,http/1.1 \
|
||||
-servername api.anthropic.com -CAfile "$TD/cert.pem" </dev/null
|
||||
kill "$FWD_PID" 2>/dev/null || true
|
||||
[[ "$output" == *"ALPN protocol: http/1.1"* ]]
|
||||
@@ -412,8 +429,9 @@ make_cert() {
|
||||
|
||||
@test "forwarder loopback mode serves plaintext http on the port it is given" {
|
||||
start_proxy
|
||||
start_forwarder loopback 18445
|
||||
run curl -s -X POST "http://127.0.0.1:18445/v1/messages" -d '{}'
|
||||
local fport; fport="$(free_port)"
|
||||
start_forwarder loopback "$fport"
|
||||
run curl -s -X POST "http://127.0.0.1:$fport/v1/messages" -d '{}'
|
||||
kill "$FWD_PID" 2>/dev/null || true
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *'"authorization": "Bearer real-access-token"'* ]]
|
||||
@@ -437,7 +455,7 @@ make_cert() {
|
||||
# today, so a fall-through lands on an unused port if that ever changes.
|
||||
run env -u SANDBOX_CREDENTIAL_TLS_CERT -u SANDBOX_CREDENTIAL_TLS_KEY \
|
||||
SANDBOX_CREDENTIAL_PROXY_SOCKET="$SOCK" SANDBOX_CREDENTIAL_TRANSPORT=tls \
|
||||
SANDBOX_CREDENTIAL_PROXY_PORT=18398 \
|
||||
SANDBOX_CREDENTIAL_PROXY_PORT="$(free_port)" \
|
||||
node "$REPO_ROOT/docker/credential-proxy-forwarder.js"
|
||||
[ "$status" -ne 0 ]
|
||||
[[ "$output" == *"SANDBOX_CREDENTIAL_TLS_CERT"* ]]
|
||||
@@ -861,3 +879,150 @@ print(raw.decode().strip())
|
||||
[[ "$output" != *"HTTP 400"* ]] \
|
||||
|| { echo "the last host line still leads with the provider's answer: $output"; return 1; }
|
||||
}
|
||||
|
||||
# The exit audit in bin/dejima reads this log. Extracted and driven here rather
|
||||
# than restated, and fed logs this proxy actually wrote, because the two live in
|
||||
# different files and the classification only works if they agree about what a
|
||||
# line looks like.
|
||||
run_exit_audit() { # <proxy log>
|
||||
C_WARN='' C_RESET='' SANDBOX_CREDENTIAL_PROXY_LOG="$1" bash -c '
|
||||
C_WARN=""; C_RESET=""
|
||||
SANDBOX_CREDENTIAL_PROXY_LOG="$1"
|
||||
'"$(sed -n '/_audit_counts="\$(awk/,/unset _audit_counts _audit_last/p' \
|
||||
"$REPO_ROOT/bin/dejima")"'
|
||||
' _ "$1" 2>&1
|
||||
}
|
||||
|
||||
@test "the exit audit does not report a credential failure as the API answering" {
|
||||
# An overnight expiry logs no upstream status, because no request was made -
|
||||
# the credential could not be loaded. Announcing "the model API answered
|
||||
# with an error" there sends an operator to check the provider, and the
|
||||
# retry caveat invites them to hope it succeeded anyway. Neither is true,
|
||||
# and the line the proxy leaves last is an instruction rather than a status.
|
||||
write_creds -60
|
||||
start_proxy
|
||||
curl -s "http://127.0.0.1:$PORT/oauth-reject" >/dev/null
|
||||
pcurl -o /dev/null -X POST --data '{}' http://x/v1/messages || true
|
||||
|
||||
run run_exit_audit "$TD/stderr"
|
||||
[[ "$output" != *"model API answered with an error"* ]] \
|
||||
|| { echo "reported as the API answering: $output"; return 1; }
|
||||
[[ "$output" != *"may still have succeeded"* ]] \
|
||||
|| { echo "invited a retry that cannot succeed: $output"; return 1; }
|
||||
[[ "$output" == *"could not authenticate"* ]] \
|
||||
|| { echo "did not say what happened: $output"; return 1; }
|
||||
[[ "$output" == *"log in again"* ]] \
|
||||
|| { echo "did not carry the remedy: $output"; return 1; }
|
||||
}
|
||||
|
||||
@test "an upstream that answered with errors is still reported as one" {
|
||||
# The control: the audit's original purpose has to survive the split.
|
||||
start_proxy
|
||||
pcurl -o /dev/null "http://localhost/status-429"
|
||||
pcurl -o /dev/null "http://localhost/status-429"
|
||||
|
||||
run run_exit_audit "$TD/stderr"
|
||||
[[ "$output" == *"model API answered with an error"* ]] \
|
||||
|| { echo "an upstream error stopped being reported as one: $output"; return 1; }
|
||||
[[ "$output" == *"2 x HTTP 429"* ]] \
|
||||
|| { echo "the grouping by status is gone: $output"; return 1; }
|
||||
[[ "$output" == *"may still have succeeded"* ]] \
|
||||
|| { echo "the retry caveat is gone from the case it is about: $output"; return 1; }
|
||||
}
|
||||
|
||||
@test "a request that never reached the upstream is reported as neither case" {
|
||||
# `upstream request failed` carries no status - nothing answered - and no
|
||||
# credential failed either. Classified by "no status lines anywhere", it
|
||||
# read as an authentication failure, sending an operator to log in again
|
||||
# over a connection problem.
|
||||
start_proxy --upstream "http://127.0.0.1:1"
|
||||
pcurl -o /dev/null -X POST --data '{}' http://x/v1/messages || true
|
||||
|
||||
run run_exit_audit "$TD/stderr"
|
||||
[[ "$output" != *"could not authenticate"* ]] \
|
||||
|| { echo "a connection failure read as an authentication failure: $output"; return 1; }
|
||||
[[ "$output" != *"model API answered with an error"* ]] \
|
||||
|| { echo "reported as the API answering: $output"; return 1; }
|
||||
[[ "$output" == *"credential proxy reported a problem"* ]] \
|
||||
|| { echo "the failure went unreported: $output"; return 1; }
|
||||
[[ "$output" == *"upstream request failed"* ]] \
|
||||
|| { echo "the line itself is missing: $output"; return 1; }
|
||||
}
|
||||
|
||||
@test "upstream text saying 'answered 503' cannot fabricate a status count" {
|
||||
# The refresh failure's detail line carries up to 200 bytes of the token
|
||||
# endpoint's response body - text the upstream chose. Counted wherever
|
||||
# `answered N` appeared, that body minted "1 x HTTP 503" for a session
|
||||
# that made no request, and brought the retry caveat with it. The count
|
||||
# is anchored to the proxy's own `upstream answered N for` lines.
|
||||
write_creds -60
|
||||
start_proxy
|
||||
curl -s "http://127.0.0.1:$PORT/oauth-reject?say=the+upstream+answered+503+today" >/dev/null
|
||||
pcurl -o /dev/null -X POST --data '{}' http://x/v1/messages || true
|
||||
|
||||
run run_exit_audit "$TD/stderr"
|
||||
[[ "$output" != *"model API answered with an error"* ]] \
|
||||
|| { echo "an upstream string was counted as an API answer: $output"; return 1; }
|
||||
[[ "$output" != *"HTTP 503"* ]] \
|
||||
|| { echo "a status count was minted from body text: $output"; return 1; }
|
||||
[[ "$output" == *"could not authenticate"* ]] \
|
||||
|| { echo "the real failure went unreported: $output"; return 1; }
|
||||
[[ "$output" == *"answered 503 today"* ]] \
|
||||
|| { echo "the detail line is not shown: $output"; return 1; }
|
||||
}
|
||||
|
||||
@test "a negative Content-Length is refused rather than read to EOF" {
|
||||
# The cap exists because this runs on the host as the operator's own user.
|
||||
# int() accepts a negative, a negative passes a `> MAX` test, and a buffered
|
||||
# reader takes read(-1) as "to EOF" - so `Content-Length: -1` read an
|
||||
# unbounded body into host RAM past the cap. A non-numeric value raised out
|
||||
# past the types handle_error knows and put a traceback on the terminal.
|
||||
start_proxy
|
||||
|
||||
run curl -s -o /dev/null -w '%{http_code}' --unix-socket "$SOCK" \
|
||||
-H "Authorization: Bearer $PLACEHOLDER" -H 'Content-Length: -1' \
|
||||
-X POST --data 'x' http://x/v1/messages
|
||||
[ "$output" = 413 ] \
|
||||
|| { echo "a negative Content-Length was not refused: $output"; return 1; }
|
||||
|
||||
# A value that is not a number is left alone: it still raises, because the
|
||||
# test above for narrow error suppression uses exactly that fault to prove
|
||||
# the suppression is specific to a client going away. This fix is about the
|
||||
# value that passes the cap and then reads to EOF, not about input tidiness.
|
||||
|
||||
# The log says what arrived, which is what tells a hostile negative length
|
||||
# apart from a genuinely oversized upload.
|
||||
grep -q 'Content-Length of -1' "$TD/stderr" \
|
||||
|| { echo "the refusal log does not name the offending length:"; cat "$TD/stderr"; return 1; }
|
||||
|
||||
# And an ordinary request still works, so the check did not close the door.
|
||||
run pcurl -o /dev/null -w '%{http_code}' -X POST --data '{}' http://x/v1/messages
|
||||
[ "$output" = 200 ] || { echo "a normal request was refused: $output"; return 1; }
|
||||
}
|
||||
|
||||
@test "a negative chunk size is refused too, not only Content-Length" {
|
||||
# The two paths share one helper today. A refactor that inlines the chunked
|
||||
# branch - int(readline().split(b";")[0], 16) - puts the unbounded read back
|
||||
# with nothing failing, so the chunked shape is asserted in its own right.
|
||||
start_proxy
|
||||
python3 - "$SOCK" <<'PYEOF'
|
||||
import socket, sys
|
||||
s = socket.socket(socket.AF_UNIX)
|
||||
s.connect(sys.argv[1])
|
||||
s.sendall(b"POST /v1/messages HTTP/1.1\r\nHost: proxy\r\n"
|
||||
b"Authorization: Bearer placeholder\r\n"
|
||||
b"Transfer-Encoding: chunked\r\n\r\n-1\r\n")
|
||||
s.settimeout(5)
|
||||
try:
|
||||
got = s.recv(200)
|
||||
except Exception:
|
||||
got = b"<no answer>"
|
||||
print(got.split(b"\r\n")[0].decode("latin-1"))
|
||||
s.close()
|
||||
PYEOF
|
||||
run cat "$TD/stderr"
|
||||
[[ "$output" == *"chunk size of -1"* ]] \
|
||||
|| { echo "a negative chunk size was not refused as one: $output"; return 1; }
|
||||
[[ "$output" != *"Traceback"* ]] \
|
||||
|| { echo "it raised instead of being refused: $output"; return 1; }
|
||||
}
|
||||
|
||||
+25
-7
@@ -143,6 +143,22 @@ touches '^tools/' &&
|
||||
touches '^\.dejima/allowed-domains$' &&
|
||||
manual "A domain here is read from main by the CI hardener and widens every
|
||||
future CI job on that runner. Say in the PR what it is for."
|
||||
# The boundary itself had no pattern at all, so a release that rewrote the
|
||||
# egress policy, the capability floor or the credential path was reported as
|
||||
# touching nothing that needs a manual check - over exactly the things this
|
||||
# project exists to hold.
|
||||
touches '^(docker/egress-policy|docker/firewall-up|docker/fixed-domains|docker/dns-lookup-log|docker/cidr-collapse|bin/dejima-allow)' &&
|
||||
manual "Reach an allowed destination and a refused one from inside a real
|
||||
session, and check a refusal is immediate rather than a hang. The suites
|
||||
drive the policy; only a session drives it with a real resolver."
|
||||
touches '^(docker/caps-lib|docker/docker-compose)' &&
|
||||
manual "Check the capability set of a running session against the floor the
|
||||
compose file declares. A container that starts says nothing about what
|
||||
it kept."
|
||||
touches '^(bin/dejima-credential-proxy|docker/credential-proxy-forwarder|backends/|bin/dejima-credential-scan)' &&
|
||||
manual "Run a session against a real model through the proxy, and let a
|
||||
credential expire or be refused. The failure paths are what the suites
|
||||
reach least."
|
||||
[ "$printed" = 1 ] || echo " (none - this release touches nothing with a manual check)"
|
||||
|
||||
note "Required before this merges"
|
||||
@@ -158,9 +174,11 @@ echo " launch on the other configured harness to confirm it starts."
|
||||
# turns the obvious rollback into a second outage, so it is worth knowing
|
||||
# before rather than after.
|
||||
note "Config keys this release adds (a rollback refuses a config using them)"
|
||||
# Both sides through git, because this reports on $base...$head and the working
|
||||
# tree is neither of them once a head is named explicitly.
|
||||
added="$(comm -13 \
|
||||
<(git show "$base:bin/dejima-config-lib.sh" 2>/dev/null | grep -oE "^[a-z-]+\\.[a-z_]+" | sort -u) \
|
||||
<(grep -oE "^[a-z-]+\\.[a-z_]+" bin/dejima-config-lib.sh | sort -u))"
|
||||
<(git show "$head:bin/dejima-config-lib.sh" 2>/dev/null | grep -oE "^[a-z-]+\\.[a-z_]+" | sort -u))"
|
||||
# Providers too, and this is the sharper half: a [harness.<name>] section for
|
||||
# a harness the older dejima does not ship is an unknown SECTION, which fails
|
||||
# validation exactly as an unknown key does. So adopting a new provider is
|
||||
@@ -193,12 +211,12 @@ new_providers=""
|
||||
for pair in $DEJIMA_RELEASE_AXES; do
|
||||
axis="${pair%%:*}"
|
||||
one="${pair#*:}"
|
||||
[ -d "$axis" ] || continue
|
||||
git cat-file -e "$head:$axis" 2>/dev/null || continue
|
||||
while IFS= read -r name; do
|
||||
[ -n "$name" ] || continue
|
||||
git cat-file -e "$base:$axis/$name/$one.conf" 2>/dev/null ||
|
||||
new_providers="$new_providers $one.$name"
|
||||
done < <(find "$axis" -mindepth 1 -maxdepth 1 -type d -printf '%f\n' | sort)
|
||||
done < <(git ls-tree -d --name-only "$head:$axis" 2>/dev/null | sort)
|
||||
done
|
||||
|
||||
# And the keys a provider DECLARES, which are config keys an operator writes
|
||||
@@ -210,18 +228,18 @@ new_keys=""
|
||||
for pair in $DEJIMA_RELEASE_AXES; do
|
||||
axis="${pair%%:*}"
|
||||
one="${pair#*:}"
|
||||
[ -d "$axis" ] || continue
|
||||
git cat-file -e "$head:$axis" 2>/dev/null || continue
|
||||
while IFS= read -r name; do
|
||||
[ -n "$name" ] || continue
|
||||
now="$(sed -n 's/^keys *= *//p' "$axis/$name/$one.conf" 2>/dev/null \
|
||||
| tr ' ' '\n' | sed 's/:.*//' | sort -u)"
|
||||
now="$(git show "$head:$axis/$name/$one.conf" 2>/dev/null \
|
||||
| sed -n 's/^keys *= *//p' | tr ' ' '\n' | sed 's/:.*//' | sort -u)"
|
||||
was="$(git show "$base:$axis/$name/$one.conf" 2>/dev/null \
|
||||
| sed -n 's/^keys *= *//p' | tr ' ' '\n' | sed 's/:.*//' | sort -u)"
|
||||
while IFS= read -r k; do
|
||||
[ -n "$k" ] || continue
|
||||
new_keys="$new_keys [$one.$name]|$k"
|
||||
done < <(comm -13 <(printf '%s\n' "$was") <(printf '%s\n' "$now"))
|
||||
done < <(find "$axis" -mindepth 1 -maxdepth 1 -type d -printf '%f\n' | sort)
|
||||
done < <(git ls-tree -d --name-only "$head:$axis" 2>/dev/null | sort)
|
||||
done
|
||||
|
||||
if [ -n "$added" ] || [ -n "$new_providers" ] || [ -n "$new_keys" ]; then
|
||||
|
||||
+81
-7
@@ -33,15 +33,59 @@ setup() {
|
||||
export XDG_RUNTIME_DIR="$RUNTIME_DIR"
|
||||
}
|
||||
teardown() {
|
||||
# An attach client this test backgrounded. `kill` only sends the signal, and
|
||||
# the client - plus the script(1) and tmux processes under it - takes a
|
||||
# moment to go. Removing its directories in that window is what produced
|
||||
# "rm: cannot remove ...: Directory not empty": rm lists a directory,
|
||||
# deletes what it saw, and the still-dying client creates something else
|
||||
# before the rmdir. So the wait is for the process to be gone, not a sleep.
|
||||
if [ -n "${attach_pid:-}" ]; then
|
||||
kill "$attach_pid" 2>/dev/null || true
|
||||
_await_gone kill -0 "$attach_pid"
|
||||
fi
|
||||
# Only ever the private server start_tmux created (its own TMUX_TMPDIR),
|
||||
# never the operator's.
|
||||
if [ -n "${TMUX_DIR:-}" ]; then
|
||||
TMUX_TMPDIR="$TMUX_DIR" tmux kill-server 2>/dev/null || true
|
||||
# kill-server returns once the server has been told, and the server
|
||||
# unlinks its socket on its own schedule.
|
||||
_await_gone env TMUX_TMPDIR="$TMUX_DIR" tmux has-session
|
||||
fi
|
||||
rm -rf "$CONFIG_DIR" "$RUNTIME_DIR" ${STUB_DIR:+"$STUB_DIR"} ${LAUNCH_DIR:+"$LAUNCH_DIR"} \
|
||||
_rm_settled "$CONFIG_DIR" "$RUNTIME_DIR" ${STUB_DIR:+"$STUB_DIR"} ${LAUNCH_DIR:+"$LAUNCH_DIR"} \
|
||||
${TMUX_DIR:+"$TMUX_DIR"} ${FAKE_HOME:+"$FAKE_HOME"}
|
||||
}
|
||||
|
||||
# Waits until the given command FAILS, which is how each of the things above
|
||||
# reports that it is gone. Bounded, and silent on expiry: teardown's job is to
|
||||
# clean up, and a cleanup that fails a passing test is the bug being fixed.
|
||||
_await_gone() {
|
||||
local i
|
||||
for i in $(seq 1 50); do
|
||||
"$@" >/dev/null 2>&1 || return 0
|
||||
sleep 0.1
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# rm -rf, retried. The waits above remove the cause; this is the backstop for
|
||||
# anything else that writes into a temp directory as it exits, and it reports
|
||||
# what is left rather than failing the test - a leaked temp directory costs
|
||||
# disk, and a red check on a passing test costs a re-run and some trust in the
|
||||
# suite.
|
||||
_rm_settled() {
|
||||
local d i
|
||||
for d in "$@"; do
|
||||
[ -n "$d" ] || continue
|
||||
for i in 1 2 3 4 5; do
|
||||
rm -rf "$d" 2>/dev/null
|
||||
[ -e "$d" ] || break
|
||||
sleep 0.2
|
||||
done
|
||||
[ ! -e "$d" ] \
|
||||
|| echo "teardown: $d survived removal, holding: $(ls -A "$d" 2>/dev/null | tr '\n' ' ')" >&3
|
||||
done
|
||||
}
|
||||
|
||||
lib() { bash -c 'source "$1"; shift; "$@"' _ "$LIB" "$@"; }
|
||||
|
||||
# Writes a [remote-access] config with enabled = on and base_url set (so
|
||||
@@ -163,7 +207,7 @@ done
|
||||
exit 0
|
||||
EOF
|
||||
chmod +x "$LAUNCH_DIR/docker"
|
||||
: > "$LAUNCH_DIR/sandbox-key"
|
||||
printf "not-a-real-key\n" > "$LAUNCH_DIR/sandbox-key"
|
||||
chmod 600 "$LAUNCH_DIR/sandbox-key"
|
||||
PROJ="$(mktemp -d)"
|
||||
}
|
||||
@@ -2003,15 +2047,16 @@ EOF
|
||||
}
|
||||
ttyd_log() { cat "$STUB_DIR/ttyd-argv" 2>/dev/null; }
|
||||
|
||||
# Reports no listener, whatever the host is actually running. Opt-in, and
|
||||
# deliberately not part of stub_ttyd: the port-in-use refusal has its own test
|
||||
# below, which binds a real socket and needs the real ss to see it.
|
||||
# Reports no listener, whatever the host is actually running. Called beside
|
||||
# stub_ttyd by every ttyd test but one, and deliberately not folded into it:
|
||||
# the port-in-use refusal binds a real socket and needs the real ss to see it.
|
||||
#
|
||||
# For every OTHER ttyd test the port being free is a precondition, not the
|
||||
# subject - and one that was being inherited from the machine rather than
|
||||
# stated. The default-port case asserts against 7681, which is exactly the
|
||||
# port a real dejima-ttyd front door holds, so it failed on any host running
|
||||
# the feature: the machine a developer is most likely to run this suite on.
|
||||
# The rest name 12345, which is free on most machines and on none reliably.
|
||||
# Same shape as #56 - a test reading its environment instead of setting it.
|
||||
stub_ss_no_listeners() {
|
||||
STUB_DIR="${STUB_DIR:-$(mktemp -d)}"
|
||||
@@ -2087,6 +2132,8 @@ unit_exec_start() {
|
||||
@test "dejima-ttyd refuses to start when something else already holds its port" {
|
||||
command -v ss >/dev/null 2>&1 || skip "ss is not available"
|
||||
[ -x "$REPO_ROOT/bin/dejima-ttyd" ]
|
||||
# The one ttyd test with no stub_ss_no_listeners beside stub_ttyd: ss
|
||||
# seeing the socket bound below is the subject here, not a precondition.
|
||||
stub_ttyd
|
||||
|
||||
# The distro's ttyd package ships an enabled service that binds every
|
||||
@@ -2143,6 +2190,7 @@ PY
|
||||
@test "dejima-ttyd refuses to start when remote access is unconfigured, before ttyd is reached" {
|
||||
[ -x "$REPO_ROOT/bin/dejima-ttyd" ]
|
||||
stub_ttyd
|
||||
stub_ss_no_listeners
|
||||
run "$REPO_ROOT/bin/dejima-ttyd"
|
||||
[ "$status" -ne 0 ]
|
||||
[[ "$output" == *"not configured"* ]]
|
||||
@@ -2254,6 +2302,7 @@ PY
|
||||
[ -x "$REPO_ROOT/bin/dejima-ttyd" ]
|
||||
write_remote_config 'ttyd_port = 99999'
|
||||
stub_ttyd
|
||||
stub_ss_no_listeners
|
||||
run "$REPO_ROOT/bin/dejima-ttyd"
|
||||
[ "$status" -ne 0 ]
|
||||
# Named, so this cannot pass because ttyd was missing or the config was.
|
||||
@@ -2270,6 +2319,7 @@ PY
|
||||
# currently happens to agree with it.
|
||||
write_remote_config 'ttyd_port = 12345'
|
||||
stub_ttyd
|
||||
stub_ss_no_listeners
|
||||
run "$REPO_ROOT/bin/dejima-ttyd"
|
||||
[ "$status" -eq 0 ]
|
||||
assert_ttyd_argv 12345
|
||||
@@ -2299,6 +2349,7 @@ PY
|
||||
local bad
|
||||
for bad in 0 7 73 999 abc 1e2 -20; do
|
||||
stub_ttyd
|
||||
stub_ss_no_listeners
|
||||
: > "$STUB_DIR/ttyd-argv"
|
||||
write_remote_config 'ttyd_port = 12345' "ttyd_font_size = $bad"
|
||||
run "$REPO_ROOT/bin/dejima-ttyd"
|
||||
@@ -2319,6 +2370,7 @@ PY
|
||||
# moment either default moves.
|
||||
write_remote_config 'ttyd_port = 12345'
|
||||
stub_ttyd
|
||||
stub_ss_no_listeners
|
||||
run "$REPO_ROOT/bin/dejima-ttyd"
|
||||
[ "$status" -eq 0 ]
|
||||
assert_ttyd_argv 12345
|
||||
@@ -2347,6 +2399,7 @@ PY
|
||||
# a host-wide "off" written here would be a different thing from silence.
|
||||
write_remote_config 'ttyd_port = 12345'
|
||||
stub_ttyd
|
||||
stub_ss_no_listeners
|
||||
run "$REPO_ROOT/bin/dejima-ttyd"
|
||||
[ "$status" -eq 0 ]
|
||||
run grep -c 'buttonColors' "$STUB_DIR/ttyd-argv"
|
||||
@@ -2378,6 +2431,7 @@ PY
|
||||
# would be wrong for whichever half of the clients it did not match.
|
||||
write_remote_config 'ttyd_port = 12345'
|
||||
stub_ttyd
|
||||
stub_ss_no_listeners
|
||||
run "$REPO_ROOT/bin/dejima-ttyd"
|
||||
[ "$status" -eq 0 ]
|
||||
run grep -c 'focusOnTap' "$STUB_DIR/ttyd-argv"
|
||||
@@ -2413,6 +2467,7 @@ PY
|
||||
local bad
|
||||
for bad in b M-b C-ab C- ctrl-a 2 'C-\' ; do
|
||||
stub_ttyd
|
||||
stub_ss_no_listeners
|
||||
: > "$STUB_DIR/ttyd-argv"
|
||||
write_remote_config 'ttyd_port = 12345' "ttyd_tmux_prefix = $bad"
|
||||
run "$REPO_ROOT/bin/dejima-ttyd"
|
||||
@@ -2442,6 +2497,7 @@ PY
|
||||
"$REPO_ROOT/bin/dejima-provider-lib.sh" "$fake/bin/"
|
||||
write_remote_config 'ttyd_port = 12345'
|
||||
stub_ttyd
|
||||
stub_ss_no_listeners
|
||||
run "$fake/bin/dejima-ttyd"
|
||||
[ "$status" -ne 0 ]
|
||||
[[ "$output" == *"$fake/web/index.html"* ]]
|
||||
@@ -2459,7 +2515,7 @@ PY
|
||||
local web="$REPO_ROOT/web"
|
||||
[ -f "$web/index.html" ]
|
||||
[ -f "$web/sources.sha256" ]
|
||||
run bash -c "cd '$web' && sha256sum src/* package-lock.json | LC_ALL=C sort -k2"
|
||||
run bash -c "cd '$web' && sha256sum build.mjs src/* package-lock.json | LC_ALL=C sort -k2"
|
||||
[ "$status" -eq 0 ]
|
||||
[ "$output" = "$(cat "$web/sources.sha256")" ] || {
|
||||
echo "web/sources.sha256 is stale. Rebuild it:" >&2
|
||||
@@ -2497,10 +2553,26 @@ PY
|
||||
# restricts outbound traffic to an allowlist, so a CDN reference would not
|
||||
# resolve even from a browser outside it if the operator is on the same
|
||||
# restricted network.
|
||||
#
|
||||
# A `data:` URI is exempt and only that: it carries its bytes in the
|
||||
# attribute, so it fetches nothing and neither reason above applies to it.
|
||||
# The favicon is one. Matched per attribute rather than per line, so a data
|
||||
# URI on a line cannot carry a real reference past this beside it.
|
||||
local page="$REPO_ROOT/web/index.html"
|
||||
[ -f "$page" ]
|
||||
run grep -nEi '<(script|link|img|iframe)[^>]+(src|href)=' "$page"
|
||||
run bash -c "grep -oEi '<(script|link|img|iframe)[^>]+(src|href)=\"[^\"]*\"' '$page' \
|
||||
| grep -vEi '(src|href)=\"data:'"
|
||||
[ "$status" -ne 0 ] || { echo "the page references an external asset: $output" >&2; false; }
|
||||
|
||||
# And the exemption is not a hole: anything that is not a data URI still
|
||||
# fails, checked against a copy rather than trusted.
|
||||
local probe="$BATS_TEST_TMPDIR/probe.html"
|
||||
sed 's|<link rel="icon"|<link rel="stylesheet" href="https://cdn.example.com/x.css"><link rel="icon"|' \
|
||||
"$page" > "$probe"
|
||||
run bash -c "grep -oEi '<(script|link|img|iframe)[^>]+(src|href)=\"[^\"]*\"' '$probe' \
|
||||
| grep -vEi '(src|href)=\"data:'"
|
||||
[ "$status" -eq 0 ] \
|
||||
|| { echo "the check no longer catches a real external reference" >&2; false; }
|
||||
}
|
||||
|
||||
@test "the terminal page's own unit tests pass" {
|
||||
@@ -2525,6 +2597,7 @@ PY
|
||||
[ -x "$REPO_ROOT/bin/dejima-ttyd" ]
|
||||
write_remote_config 'ttyd_port = 12345'
|
||||
stub_ttyd
|
||||
stub_ss_no_listeners
|
||||
run "$REPO_ROOT/bin/dejima-ttyd"
|
||||
[ "$status" -eq 0 ]
|
||||
# Executed, not grepped: the flag has to reach ttyd's argv.
|
||||
@@ -2928,6 +3001,7 @@ nginx_blocks() {
|
||||
printf '[remote-access]\nenabled = on\nbase_url = https://d.example\nttyd_port = 12345\n' \
|
||||
> "$FAKE_HOME/.config/dejima/config"
|
||||
stub_ttyd
|
||||
stub_ss_no_listeners
|
||||
|
||||
local exec_start
|
||||
exec_start="$(unit_exec_start dejima-ttyd.service "$FAKE_HOME")"
|
||||
|
||||
+85
-26
@@ -37,43 +37,82 @@ PRIVILEGED=" runtime ci-runner "
|
||||
# is a path nobody has decided about, and guessing "none" there is how a change
|
||||
# ships having been checked by nothing.
|
||||
suites_for() {
|
||||
# Every suite that READS the path, not the one that owns it. static.bats
|
||||
# asserts invariants over the whole tree and config.bats sources most of
|
||||
# the libraries, so both appear far more often than a by-area reading
|
||||
# suggests - and leaving them out is what made the --changed lane report an
|
||||
# edit as green from a selection that never loaded the file.
|
||||
#
|
||||
# The map is checked against the suites mechanically; see the test named
|
||||
# for it in static.bats. Over-selecting costs a suite run, under-selecting
|
||||
# costs a missed regression, so a rule here errs wide.
|
||||
case "$1" in
|
||||
# Read by nearly every other suite, so a change to one is the whole set.
|
||||
bin/dejima|bin/dejima-config-lib.sh) echo ALL ;;
|
||||
|
||||
bin/dejima-audit) echo audit ;;
|
||||
bin/dejima-update-check) echo config ;;
|
||||
bin/dejima-epitaph) echo config ;;
|
||||
bin/dejima-audit) echo 'audit config proxy remote static' ;;
|
||||
bin/dejima-update-check) echo 'config project remote static' ;;
|
||||
bin/dejima-epitaph) echo 'config static' ;;
|
||||
bin/dejima-tty-suspend) echo static ;;
|
||||
bin/dejima-color-lib.sh) echo 'remote project' ;;
|
||||
bin/dejima-tui-lib.sh) echo 'remote project' ;;
|
||||
bin/dejima-conversation-ship) echo conversation ;;
|
||||
bin/dejima-share) echo share ;;
|
||||
bin/dejima-color-lib.sh) echo 'remote project config static' ;;
|
||||
bin/dejima-tui-lib.sh) echo 'remote project static' ;;
|
||||
bin/dejima-conversation-ship) echo 'conversation static' ;;
|
||||
bin/dejima-share) echo 'share static' ;;
|
||||
# The slug scheme keys the transcript mounts, the epitaph and audit
|
||||
# stores, and the share layout, so a change to it is not one area's.
|
||||
bin/dejima-slug-lib.sh) echo 'share conversation audit static' ;;
|
||||
bin/dejima-credential-proxy|docker/credential-proxy-forwarder.js) echo proxy ;;
|
||||
bin/dejima-project-lib.sh|bin/dejima-project|bin/dejima-dep|bin/dejima-allow) echo project ;;
|
||||
bin/dejima-attach|bin/dejima-ttyd|bin/dejima-notify-watch) echo remote ;;
|
||||
web/*|systemd/*) echo remote ;;
|
||||
bin/dejima-provider-lib.sh|bin/dejima-harness-lib.sh) echo providers ;;
|
||||
bin/harness/*) echo providers ;;
|
||||
forges/*|workflows/*|harnesses/*|tools/*) echo providers ;;
|
||||
install.sh) echo config ;;
|
||||
bin/dejima-slug-lib.sh) echo 'share conversation audit project static' ;;
|
||||
bin/dejima-credential-proxy|docker/credential-proxy-forwarder.js) echo 'proxy static' ;;
|
||||
bin/dejima-credential-scan*) echo 'config static' ;;
|
||||
bin/dejima-project-lib.sh|bin/dejima-project|bin/dejima-dep) \
|
||||
echo 'project config launch providers remote static' ;;
|
||||
bin/dejima-allow) echo 'project static config' ;;
|
||||
bin/dejima-attach) echo 'remote config project static' ;;
|
||||
bin/dejima-ttyd|bin/dejima-notify-watch) echo 'remote config static' ;;
|
||||
web/*|systemd/*) echo 'remote static' ;;
|
||||
bin/dejima-provider-lib.sh) echo 'providers config remote share static' ;;
|
||||
bin/dejima-harness-lib.sh) echo 'providers config remote static' ;;
|
||||
bin/harness/*) echo 'providers remote static' ;;
|
||||
# A manifest is read by more than the suite that owns the schema: the
|
||||
# audit reads a workflow's briefing, conversation.bats sed-injects a
|
||||
# harness.conf, and config.bats copies the whole set in.
|
||||
forges/*|workflows/*|harnesses/*|tools/*) \
|
||||
echo 'providers static audit config conversation' ;;
|
||||
install.sh) echo 'config audit providers remote static' ;;
|
||||
|
||||
# static.bats asserts on files it does not own, so a shipped doc or a
|
||||
# docker/ file needs it as well as whatever else the change touched -
|
||||
# which for docker/ is most things, since the entrypoint and compose
|
||||
# file are read by nearly every suite that builds a launch.
|
||||
docker/*) echo ALL ;;
|
||||
hooks/*) echo 'static config providers project' ;;
|
||||
docs/*|README.md|*.md) echo static ;;
|
||||
hooks/*) echo 'static config providers project remote' ;;
|
||||
.gitea/*|.shellcheckrc) echo static ;;
|
||||
.dejima/*) echo 'static config project providers' ;;
|
||||
|
||||
test/*.bats) local n="${1##*/}"; echo "${n%.bats}" ;;
|
||||
# A suite file is read by static.bats as well as run by bats: it
|
||||
# asserts invariants over the text of the others, so an edit to one
|
||||
# that only selected itself was reported green by a lane that never
|
||||
# loaded the assertions about it.
|
||||
test/*.bats) local n="${1##*/}"; echo "${n%.bats} static" ;;
|
||||
# Fixtures are executed by the suites that consume them, not merely
|
||||
# read: fake-anthropic.py is the upstream in proxy.bats (and in
|
||||
# runtime.bats, which no confined selection can carry), and
|
||||
# answer-prompt.py drives config.bats' prompts.
|
||||
test/fixtures/*) echo 'proxy static config' ;;
|
||||
test/*) echo static ;;
|
||||
|
||||
# Last of the specific rules, not before them: a .md under test/ or
|
||||
# .dejima/ belongs to whatever reads that directory, and this pattern
|
||||
# would otherwise answer for it. The docs themselves are asserted
|
||||
# against by more than static.bats - the README's axis lists, the
|
||||
# provider tables, the nginx example and the remote-access page each
|
||||
# have a suite checking the code agrees with them.
|
||||
#
|
||||
# So a doc-only edit runs more than static alone. That is the cost of
|
||||
# the lane answering for what reads a file, and it is the safe
|
||||
# direction: narrowing this back would report a doc change as green
|
||||
# from suites that assert against it.
|
||||
docs/*|README.md|*.md) echo 'static config providers remote' ;;
|
||||
|
||||
*) echo ALL ;;
|
||||
esac
|
||||
}
|
||||
@@ -208,6 +247,15 @@ fi
|
||||
bats_flags=()
|
||||
[ "$jobs" -gt 1 ] && bats_flags=(--jobs "$jobs" --no-parallelize-across-files)
|
||||
|
||||
# What this run asks the machine for, for the fixtures that wait on a bounded
|
||||
# deadline. They multiply their bounds by how oversubscribed that makes it
|
||||
# rather than carrying a number picked to survive the gate, which is a number
|
||||
# that is then far too generous anywhere else (#295, test/fixtures/loadscale.py).
|
||||
#
|
||||
# Exported rather than passed, because it has to reach a python fixture several
|
||||
# processes below bats.
|
||||
export DEJIMA_TEST_CONCURRENCY=$(( ${#selected[@]} * jobs ))
|
||||
|
||||
{
|
||||
if [ "$lane" = changed ]; then
|
||||
printf 'lane: CHANGED - %d of %d confined suites, from %d changed path(s)\n' \
|
||||
@@ -246,14 +294,21 @@ wait
|
||||
# suite that fails to run at all - a syntax error, or duplicate test names,
|
||||
# which bats refuses outright - exits nonzero having run nothing, and reporting
|
||||
# that as "0 failures" is how a green board comes to mean nothing.
|
||||
#
|
||||
# Skips are counted and shown separately because bats emits one as `ok N ...
|
||||
# # skip`, so a test that decided not to run is indistinguishable from one that
|
||||
# passed. Several here skip on host state - an empty /opt/dejima, a missing
|
||||
# tool - so the column is the only thing that says how much of the suite the
|
||||
# number above actually covers.
|
||||
failed=0
|
||||
printf '%-16s %6s %8s\n' suite ran failed
|
||||
printf '%-16s %6s %8s %8s\n' suite ran skipped failed
|
||||
for s in "${selected[@]}"; do
|
||||
ran="$(grep -cE '^(ok|not ok)' "$out/$s")"
|
||||
emitted="$(grep -cE '^(ok|not ok)' "$out/$s")"
|
||||
n_skipped="$(grep -cE '^ok [0-9]+ .* # skip' "$out/$s")"
|
||||
bad="$(grep -cE '^not ok' "$out/$s")"
|
||||
status="$(cat "$out/$s.status" 2>/dev/null || echo 1)"
|
||||
printf '%-16s %6s %8s' "$s" "$ran" "$bad"
|
||||
if [ "$ran" -eq 0 ]; then
|
||||
printf '%-16s %6s %8s %8s' "$s" "$(( emitted - n_skipped ))" "$n_skipped" "$bad"
|
||||
if [ "$emitted" -eq 0 ]; then
|
||||
printf ' <- ran nothing\n'
|
||||
failed=1
|
||||
elif [ "$bad" -gt 0 ] || [ "$status" -ne 0 ]; then
|
||||
@@ -264,14 +319,18 @@ for s in "${selected[@]}"; do
|
||||
fi
|
||||
done
|
||||
|
||||
for s in "${selected[@]}"; do
|
||||
grep -hE '^ok [0-9]+ .* # skip' "$out/$s" | sed -E "s/^ok [0-9]+ / $s: /"
|
||||
done | { grep . && echo " (skipped, so the counts above do not cover them)"; } || true
|
||||
|
||||
if [ "$failed" -ne 0 ]; then
|
||||
echo
|
||||
for s in "${selected[@]}"; do
|
||||
ran="$(grep -cE '^(ok|not ok)' "$out/$s")"
|
||||
emitted="$(grep -cE '^(ok|not ok)' "$out/$s")"
|
||||
bad="$(grep -cE '^not ok' "$out/$s")"
|
||||
{ [ "$bad" -gt 0 ] || [ "$ran" -eq 0 ]; } || continue
|
||||
{ [ "$bad" -gt 0 ] || [ "$emitted" -eq 0 ]; } || continue
|
||||
echo "--- $s"
|
||||
if [ "$ran" -eq 0 ]; then
|
||||
if [ "$emitted" -eq 0 ]; then
|
||||
head -20 "$out/$s"
|
||||
else
|
||||
grep -E '^not ok' -A 20 "$out/$s"
|
||||
|
||||
+206
-29
@@ -476,6 +476,11 @@ teardown() {
|
||||
--format '{{.Names}}' 2>/dev/null)
|
||||
docker images -q "localhost/dejima-test-*:$suffix" 2>/dev/null \
|
||||
| xargs -r docker rmi -f >/dev/null 2>&1 || true
|
||||
# Volumes too, for the same reason and in the same namespace: a test that
|
||||
# makes one removes it itself, and this is what covers the run that was
|
||||
# interrupted between the two.
|
||||
docker volume ls -q --filter "name=^dejima-test-.*$suffix\$" 2>/dev/null \
|
||||
| xargs -r docker volume rm >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
# The capability list bin/dejima gives the filter container, read out of the
|
||||
@@ -518,17 +523,39 @@ start_pair() { # <purpose|""> <image> [docker run args...]
|
||||
filter="$(test_container_name "$purpose-filter")"
|
||||
fi
|
||||
|
||||
# The run's cached GitHub ranges, when the job fetched them. Each filter
|
||||
# fetched its own, which is twenty-seven requests in about five minutes
|
||||
# across this suite - inside the hourly ceiling a token buys, and exactly
|
||||
# the burst GitHub's secondary limit answers with a 403 (#357).
|
||||
#
|
||||
# Read-only, and only ever this one file. egress-policy refuses a file://
|
||||
# source unless SANDBOX_TEST_MODE is set, which start_pair's callers supply
|
||||
# and no launch does.
|
||||
#
|
||||
# Absent when the job could not fetch, or when the suite is run by hand
|
||||
# outside CI: every container then fetches for itself, which is the
|
||||
# behaviour this replaces rather than a failure.
|
||||
local -a meta=()
|
||||
if [ -r /tmp/dejima-ci/github-meta.json ]; then
|
||||
meta=(-v /tmp/dejima-ci/github-meta.json:/run/forge-meta-cache.json:ro
|
||||
-e EGRESS_FORGE_META_URL=file:///run/forge-meta-cache.json
|
||||
-e SANDBOX_TEST_MODE=1)
|
||||
fi
|
||||
|
||||
local -a fcaps=() scaps=()
|
||||
while IFS= read -r cap; do fcaps+=(--cap-add="$cap"); done < <(launcher_filter_cap_add)
|
||||
while IFS= read -r cap; do scaps+=(--cap-add="$cap"); done < <(compose_cap_add)
|
||||
[ "${#fcaps[@]}" -gt 0 ] || { echo "no --cap-add found in bin/dejima's filter run" >&2; return 1; }
|
||||
[ "${#scaps[@]}" -gt 0 ] || { echo "no cap_add found in docker-compose.yml" >&2; return 1; }
|
||||
|
||||
# The cache goes to the FILTER only: it is the half that fetches the ranges,
|
||||
# and the session container has no business holding a path that names them.
|
||||
docker run -d --init --name "$filter" \
|
||||
--cap-drop=ALL "${fcaps[@]}" \
|
||||
--security-opt=no-new-privileges:true \
|
||||
--sysctl net.ipv4.ip_unprivileged_port_start=0 \
|
||||
--network "$(network_name)" \
|
||||
${meta[@]+"${meta[@]}"} \
|
||||
"$@" \
|
||||
--entrypoint /usr/local/bin/filter-entrypoint.sh \
|
||||
"$image" >/dev/null
|
||||
@@ -1457,11 +1484,37 @@ session_pid() { # [container], defaulting to the file-wide one
|
||||
# running session, and it is the operator's to run from the host. From
|
||||
# inside the session it must do nothing - it is the same binary in the same
|
||||
# image, and only the container it runs in differs.
|
||||
# What the policy would have CHANGED is re-read after each refusal, not
|
||||
# just the exit status checked: an amendment that landed and then failed on
|
||||
# something later would exit non-zero with the destination permitted, which
|
||||
# is the outcome this test is named for and the one a status cannot see.
|
||||
#
|
||||
# The entries file and the resolver configuration, because a permit for a
|
||||
# NAME writes both - the address sets only ever hold addresses, so grepping
|
||||
# those for a hostname is a check that cannot fail.
|
||||
local before_entries before_conf after_entries after_conf
|
||||
before_entries="$(dexec_filter cat /run/egress-policy/entries)"
|
||||
before_conf="$(dexec_filter cat /run/egress-policy/resolver.conf)"
|
||||
[ -n "$before_entries" ] && [ -n "$before_conf" ] \
|
||||
|| { echo "could not read the policy's own record of what is permitted"; return 1; }
|
||||
|
||||
run dexec_node /usr/local/sbin/egress-policy permit evil.example.com
|
||||
[ "$status" -ne 0 ]
|
||||
run docker exec -u root "$(container_name)" /usr/local/sbin/egress-policy permit evil.example.com
|
||||
[ "$status" -ne 0 ] \
|
||||
|| { echo "root in the session container amended the permitted set"; return 1; }
|
||||
|
||||
after_entries="$(dexec_filter cat /run/egress-policy/entries)"
|
||||
after_conf="$(dexec_filter cat /run/egress-policy/resolver.conf)"
|
||||
[ "$after_entries" = "$before_entries" ] \
|
||||
|| { echo "the entries file changed despite both refusals"; return 1; }
|
||||
[ "$after_conf" = "$before_conf" ] \
|
||||
|| { echo "the resolver configuration changed despite both refusals"; return 1; }
|
||||
# Named directly as well, so this does not rest on two snapshots matching:
|
||||
# an entry already present before the test would make them match anyway.
|
||||
grep -q evil.example <<< "$after_entries$after_conf" \
|
||||
&& { echo "evil.example.com reached the policy's record of what is permitted"; return 1; }
|
||||
return 0
|
||||
}
|
||||
|
||||
@test "node has no working sudo" {
|
||||
@@ -1504,6 +1557,29 @@ session_pid() { # [container], defaulting to the file-wide one
|
||||
# container.
|
||||
run dexec /usr/local/sbin/strip-privilege-bits --check
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
|
||||
# And once more outside those roots. --check walks the directories the
|
||||
# sweeper walks, which deliberately exclude /tmp, /run, /mnt and /media -
|
||||
# the sweeper has no reason to visit them, but an image that shipped
|
||||
# something there would go unnoticed by a check scoped to its own sweep.
|
||||
# Cheap because those directories are near-empty in an image.
|
||||
run dexec sh -c 'find /tmp /run /mnt /media -xdev -type f -perm /6000 2>/dev/null; getcap -r /tmp /run /mnt /media 2>/dev/null'
|
||||
[ -z "$output" ] \
|
||||
|| { echo "privileged bits outside the swept roots:"; echo "$output"; return 1; }
|
||||
|
||||
# File capabilities over the whole image filesystem, not a directory
|
||||
# union: a capability at a top-level path nobody enumerated - /data/bin
|
||||
# in some future layer - is asserted by nothing else, while setuid gets
|
||||
# the same whole-filesystem walk in the derived-image test below. -xdev
|
||||
# keeps this to the image: /proc, /sys and every bind mount are their own
|
||||
# filesystems, and a capability in the operator's mounted checkout is not
|
||||
# the image's to answer for. A missing getcap prints a marker instead of
|
||||
# nothing, because an empty result has to mean "none found", not "nothing
|
||||
# asked".
|
||||
run dexec sh -c 'command -v getcap >/dev/null || { echo getcap-missing; exit 1; }
|
||||
find / -xdev -type f -print0 2>/dev/null | xargs -0 -r getcap 2>/dev/null || true'
|
||||
[ -z "$output" ] \
|
||||
|| { echo "a file capability survived somewhere in the image (or getcap is gone):"; echo "$output"; return 1; }
|
||||
}
|
||||
|
||||
@test "the session shell is bash, configured by the image, with history appended" {
|
||||
@@ -1795,20 +1871,36 @@ session_pid() { # [container], defaulting to the file-wide one
|
||||
|
||||
@test "every connection attempt is recorded with its verdict" {
|
||||
# A refused destination and a permitted one both have to leave a record, or
|
||||
# the log a supervisor reads proves nothing about what the session did.
|
||||
# the log a supervisor reads proves nothing about what the session did. And
|
||||
# each record's verdict has to be the right one, not merely present: the
|
||||
# verdict rides on which NFLOG group the rule named, so the two groups
|
||||
# swapped would log every refusal as "allowed" - and a check that only
|
||||
# asked for the field would stay green while it did.
|
||||
# The permitted half needs a destination nothing has to answer on: the allow
|
||||
# record is written when the first packet is accepted, not when a service
|
||||
# replies, and a test that waited for a reply would be testing the network.
|
||||
# A documentation address permitted as a literal is exactly that - in
|
||||
# @literal_v4, on any port, routed nowhere. Added live rather than declared
|
||||
# at launch so the file-wide pair keeps the permitted set a real one has.
|
||||
local allowed=198.51.100.1
|
||||
run dexec_filter /usr/local/sbin/egress-policy permit "$allowed"
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
|
||||
dexec curl -s -o /dev/null --connect-timeout 5 --max-time 8 https://192.0.2.1/ 2>/dev/null || true
|
||||
dexec curl -s -o /dev/null --connect-timeout 3 --max-time 5 "http://$allowed:9/" 2>/dev/null || true
|
||||
local found="" i
|
||||
for i in 1 2 3 4 5 6; do
|
||||
if dexec_filter grep -q '192.0.2.1' /var/log/egress-policy/connections.ndjson 2>/dev/null; then
|
||||
if dexec_filter grep -q '192.0.2.1' /var/log/egress-policy/connections.ndjson 2>/dev/null \
|
||||
&& dexec_filter grep -F "$allowed" /var/log/egress-policy/connections.ndjson 2>/dev/null | grep -q '"allowed"'; then
|
||||
found=yes; break
|
||||
fi
|
||||
sleep 0.5
|
||||
done
|
||||
[ -n "$found" ] \
|
||||
|| { echo "a refused connection left no record"; dexec_filter tail -5 /var/log/egress-policy/connections.ndjson; return 1; }
|
||||
|| { echo "a connection left no record with its verdict"; dexec_filter tail -5 /var/log/egress-policy/connections.ndjson; return 1; }
|
||||
run dexec_filter sh -c "grep -F '192.0.2.1' /var/log/egress-policy/connections.ndjson | tail -1"
|
||||
[[ "$output" == *'"action"'* ]] \
|
||||
|| { echo "recorded without a verdict field: $output"; return 1; }
|
||||
[[ "$output" == *'"blocked"'* ]] \
|
||||
|| { echo "a refused connection was not recorded as blocked: $output"; return 1; }
|
||||
}
|
||||
|
||||
|
||||
@@ -2254,49 +2346,69 @@ s.serve_forever()' "$bind_addr" > "$port_file" 2>/dev/null &
|
||||
timeout 5 bash -c "cat < /dev/null > /dev/tcp/$bind_addr/$HOST_LISTENER_PORT"
|
||||
}
|
||||
|
||||
@test "a host service bound to loopback is unreachable from inside the container, one bound to the bridge gateway is reachable" {
|
||||
@test "the host is unreachable on every port by default, and on exactly the ports EGRESS_HOST_PORTS names" {
|
||||
# The regression test for the escape path bin/dejima-ttyd's
|
||||
# --interface 127.0.0.1 exists to close. The egress policy deliberately
|
||||
# ACCEPTs OUTPUT to the Docker bridge gateway, and a tmux client attached
|
||||
# through ttyd is a host shell - the prefix key opens a window outside the
|
||||
# container - so a ttyd the sandbox could reach would be a full escape.
|
||||
# Prove the containment property holds rather than trusting the flag.
|
||||
local container host_ip loopback_port gateway_port
|
||||
# --interface 127.0.0.1 exists to close, now one layer stronger. The host
|
||||
# entry used to be accepted on every port, so a ttyd bound to 0.0.0.0 - and
|
||||
# a tmux client attached through ttyd is a host shell, the prefix key opens
|
||||
# a window outside the container - was reachable from the session. It now
|
||||
# opens only the ports it was told to (#363).
|
||||
local container host_ip loopback_port named_port other_port
|
||||
local test_container="$(test_container_name hostports)"
|
||||
container="$(container_name)"
|
||||
|
||||
# Derived exactly as the egress policy derives the host address, so this is the one
|
||||
# host address the container's OUTPUT chain accepts at all.
|
||||
# Derived exactly as the egress policy derives the host address, so this is
|
||||
# the one host address the container's OUTPUT chain names at all.
|
||||
host_ip="$(docker exec "$container" sh -c "ip route | grep default | cut -d' ' -f3")"
|
||||
[ -n "$host_ip" ]
|
||||
|
||||
start_host_listener 127.0.0.1
|
||||
loopback_port="$HOST_LISTENER_PORT"
|
||||
start_host_listener "$host_ip"
|
||||
gateway_port="$HOST_LISTENER_PORT"
|
||||
named_port="$HOST_LISTENER_PORT"
|
||||
start_host_listener "$host_ip"
|
||||
other_port="$HOST_LISTENER_PORT"
|
||||
|
||||
# Setup check, from the host's own vantage point: the loopback listener's
|
||||
# port must answer on 127.0.0.1 only. bind(0) on two different addresses
|
||||
# can legally return the same port number, and if it had, the container
|
||||
# probe below would be hitting the gateway listener and failing for a
|
||||
# reason that has nothing to do with containment.
|
||||
# probes below would be hitting a gateway listener and failing for a reason
|
||||
# that has nothing to do with containment.
|
||||
run timeout 5 bash -c "cat < /dev/null > /dev/tcp/$host_ip/$loopback_port"
|
||||
[ "$status" -ne 0 ]
|
||||
|
||||
# Positive control, and the reason the negative one means anything: same
|
||||
# container, same docker exec, same timeout/bash /dev/tcp probe, same
|
||||
# gateway address - only the port differs. Without it a `status -ne 0` is
|
||||
# equally satisfied by docker exec failing (125), bash or timeout missing
|
||||
# from the image (127), or a container that can reach nothing at all, none
|
||||
# of which say anything about loopback.
|
||||
run docker exec "$container" timeout 5 bash -c "cat < /dev/null > /dev/tcp/$host_ip/$gateway_port"
|
||||
# The default, on the file-wide pair, which sets no EGRESS_HOST_PORTS: a
|
||||
# real listener on the gateway address, and the session cannot reach it.
|
||||
run docker exec "$container" timeout 5 bash -c "cat < /dev/null > /dev/tcp/$host_ip/$named_port"
|
||||
[ "$status" -ne 0 ]
|
||||
|
||||
# A pair told to open that one port, and nothing else about it changed.
|
||||
start_pair hostports "$SESSION_IMAGE" \
|
||||
-e SANDBOX_TEST_MODE=1 \
|
||||
-e EGRESS_HOST_PORTS="$named_port"
|
||||
|
||||
# Positive control, and the reason the negatives mean anything: without it
|
||||
# a `status -ne 0` is equally satisfied by docker exec failing (125), bash
|
||||
# or timeout missing from the image (127), or a container that can reach
|
||||
# nothing at all, none of which say anything about the clamp.
|
||||
run docker exec "$test_container" timeout 5 bash -c "cat < /dev/null > /dev/tcp/$host_ip/$named_port"
|
||||
[ "$status" -eq 0 ]
|
||||
|
||||
# Deliberately not asserting the exact status. 1 (the host RSTs, since
|
||||
# nothing is bound on the gateway address at this port) is what it should
|
||||
# be, but a host that DROPs on its own docker bridge instead yields 124
|
||||
# from timeout, and that is still containment.
|
||||
run docker exec "$container" timeout 5 bash -c "cat < /dev/null > /dev/tcp/$host_ip/$loopback_port"
|
||||
# The clamp itself: same container, same address, same kind of listener,
|
||||
# only the port differs from the one above.
|
||||
run docker exec "$test_container" timeout 5 bash -c "cat < /dev/null > /dev/tcp/$host_ip/$other_port"
|
||||
[ "$status" -ne 0 ]
|
||||
|
||||
# And the original containment property, which the clamp does not replace:
|
||||
# a host service bound to loopback is not on the gateway address at all.
|
||||
#
|
||||
# Deliberately not asserting the exact status on any of the negatives. 1
|
||||
# (the host RSTs) is what a refusal should look like, but a drop yields 124
|
||||
# from timeout, and that is still containment.
|
||||
run docker exec "$test_container" timeout 5 bash -c "cat < /dev/null > /dev/tcp/$host_ip/$loopback_port"
|
||||
[ "$status" -ne 0 ]
|
||||
|
||||
docker rm -f "$test_container" >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
|
||||
@@ -2588,3 +2700,68 @@ EOF
|
||||
[[ "$output" != *"dejima-notify"* ]]
|
||||
[[ "$output" != *"remote access is enabled"* ]]
|
||||
}
|
||||
|
||||
@test "an isolated launch's working directory is a volume the session user owns" {
|
||||
# The one thing about --isolated that a confined test cannot decide: that
|
||||
# the directory the launcher points a session at is usable. A volume at a
|
||||
# path the image created is seeded from it, ownership included, and if
|
||||
# that did not hold the session would start in a directory root owns and
|
||||
# fail on its first write.
|
||||
#
|
||||
# The volume is named for the run by bin/dejima and removed in its exit
|
||||
# trap, both of which the confined suite checks against the launcher. So
|
||||
# this does not re-check the reaping, and deliberately does not count the
|
||||
# host's volumes before and after: another job on a shared runner creating
|
||||
# one between the two reads would fail a test about something else
|
||||
# entirely.
|
||||
local vol="dejima-test-work-$(basename "$BATS_RUN_TMPDIR")"
|
||||
docker volume create "$vol" >/dev/null
|
||||
|
||||
run docker run --rm -v "$vol:/dejima/work" --entrypoint sh "$SESSION_IMAGE" -c \
|
||||
'cd /dejima/work && touch marker && stat -c "%U" /dejima/work'
|
||||
if [ "$status" -ne 0 ] || [ "$output" != node ]; then
|
||||
docker volume rm "$vol" >/dev/null 2>&1 || true
|
||||
echo "the working directory is not writable by the session user: $output"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# And nothing of the host is behind it, which is the claim the mode rests
|
||||
# on: a second volume at the same path starts empty rather than carrying
|
||||
# what the first container wrote.
|
||||
# Purpose before the suffix, like test_container_name: teardown's filter
|
||||
# is anchored on the suffix, so a name ending in anything else is one it
|
||||
# cannot sweep if this is interrupted between create and remove.
|
||||
local other="dejima-test-work2-$(basename "$BATS_RUN_TMPDIR")"
|
||||
docker volume create "$other" >/dev/null
|
||||
run docker run --rm -v "$other:/dejima/work" --entrypoint sh "$SESSION_IMAGE" -c \
|
||||
'ls -A /dejima/work'
|
||||
local rc="$status" out="$output"
|
||||
docker volume rm "$vol" "$other" >/dev/null 2>&1 || true
|
||||
[ "$rc" -eq 0 ] || { echo "$out"; return 1; }
|
||||
[ -z "$out" ] \
|
||||
|| { echo "a second session's working directory was not empty: $out"; return 1; }
|
||||
}
|
||||
|
||||
@test "the git shadow loads its policy in the image, and leaves other git commands alone" {
|
||||
# The decision lives in a file the Dockerfile copies in
|
||||
# (docker/guard-lib.sh), and every git command in a session goes through
|
||||
# the shadow that sources it - so a COPY line that stopped matching the
|
||||
# path would break git outright, in every session, and nothing in the
|
||||
# confined suites would notice: they point the guards at the checkout.
|
||||
#
|
||||
# The file-wide pair sets no DEJIMA_PUSH_HOSTS, which fails closed, so any
|
||||
# push is refused. Which refusal does not matter; that the guard is the one
|
||||
# speaking does.
|
||||
run dexec bash -c 'cd /tmp && git init -q -b feature r && cd r && git push origin feature'
|
||||
[ "$status" -ne 0 ]
|
||||
[[ "$output" == *"Blocked by dejima's git guard"* ]] \
|
||||
|| { echo "the shadow did not reach its policy: $output"; return 1; }
|
||||
[[ "$output" != *"No such file"* ]] \
|
||||
|| { echo "the shadow could not load the policy file: $output"; return 1; }
|
||||
|
||||
# And the pass-through path: everything that is not a push has to reach
|
||||
# real git, or the shadow is a broken git rather than a guarded one.
|
||||
run dexec git --version
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == git\ version* ]] || { echo "$output"; return 1; }
|
||||
}
|
||||
|
||||
@@ -150,6 +150,38 @@ mounted_share() { sort -u "$SHARE_LOG" | grep -v '^$' | head -1; }
|
||||
|| { echo "a clean share_dir warned about its own projects dir: $output"; return 1; }
|
||||
}
|
||||
|
||||
@test "an isolated launch's share is its own, and the root is not reported as loose" {
|
||||
write_config
|
||||
# launch() takes a workspace, which --isolated refuses - so this one
|
||||
# drives the launcher directly with the same stub.
|
||||
STUB_DIR="$(mktemp -d)"
|
||||
cat > "$STUB_DIR/docker" <<'EOF'
|
||||
#!/bin/sh
|
||||
printf '%s\n' "${SANDBOX_SHARE_DIR-}" >> "$SANDBOX_TEST_SHARE_LOG"
|
||||
exit 0
|
||||
EOF
|
||||
chmod +x "$STUB_DIR/docker"
|
||||
SHARE_LOG="$HOST/share-log"
|
||||
: > "$SHARE_LOG"
|
||||
HOME="$HOST/home" PATH="$STUB_DIR:$PATH" SSH_AUTH_SOCK= \
|
||||
SANDBOX_TEST_SHARE_LOG="$SHARE_LOG" \
|
||||
run "$REPO_ROOT/bin/dejima" --isolated
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
|
||||
case "$(mounted_share)" in
|
||||
"$SHARE"/isolated/*) ;;
|
||||
*) echo "mounted '$(mounted_share)', not a directory under the isolated root"
|
||||
return 1 ;;
|
||||
esac
|
||||
[ -d "$(mounted_share)" ] \
|
||||
|| { echo "the launch did not create the directory it mounts"; return 1; }
|
||||
# The root holding them is dejima's, like projects/ - so a launch must
|
||||
# not go on telling the operator about it every time as something that
|
||||
# was left behind by hand.
|
||||
[[ "$output" != *"no session can see"* ]] \
|
||||
|| { echo "the isolated root was reported as left behind: $output"; return 1; }
|
||||
}
|
||||
|
||||
@test "dejima-share list names each project and flags what is loose" {
|
||||
write_config
|
||||
share path "$WS" > /dev/null
|
||||
|
||||
+10
-4
@@ -40,10 +40,16 @@ list_scripts() {
|
||||
# keeping the permitted set's classes from drifting between the
|
||||
# container that enforces them and the host command that adds to them.
|
||||
#
|
||||
# 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
|
||||
# Matched by pattern rather than by name, because a list is a thing to
|
||||
# forget to add to - and this one was forgotten anyway: bin/*-lib.sh
|
||||
# went unchecked, which is dejima-harness-lib.sh, the code deciding
|
||||
# which host paths may be mounted where. 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|bin/*-lib.sh)
|
||||
printf '%s\n' "$f"; continue ;;
|
||||
esac
|
||||
head -1 "$f" | grep -qE '^#!.*(bash|sh)' && printf '%s\n' "$f"
|
||||
done
|
||||
}
|
||||
|
||||
+1871
-131
File diff suppressed because it is too large
Load Diff
+21
-2
@@ -10,7 +10,7 @@
|
||||
//
|
||||
// Usage: npm ci && node build.mjs
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import { readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
@@ -41,8 +41,15 @@ const CLIENT_SOURCES = [
|
||||
// needed, since the first alone passes on a commit that rebuilds the manifest
|
||||
// and leaves the page behind.
|
||||
const MANIFEST_SOURCES = [
|
||||
// The builder itself. Without it, editing this file - the escaping, the fill
|
||||
// order, which sources get inlined - produces a different page with a
|
||||
// byte-identical manifest and stamp, so the staleness check passes on a page
|
||||
// nobody rebuilt. web/README.md defends committing the artifact precisely so
|
||||
// nobody has to run the build, which is what makes that check load-bearing.
|
||||
'build.mjs',
|
||||
'package-lock.json',
|
||||
'src/config.js',
|
||||
'src/favicon.png',
|
||||
'src/gestures.js',
|
||||
'src/keyboard.js',
|
||||
'src/keys.js',
|
||||
@@ -58,7 +65,18 @@ const MANIFEST_SOURCES = [
|
||||
'src/viewport.js'
|
||||
];
|
||||
|
||||
// The order above is hand-written because it is load-bearing, so the list
|
||||
// cannot be a glob. A src/*.js omitted from it is still hashed into the
|
||||
// manifest and still fails no test - it is simply never inlined, and the page
|
||||
// is missing a module. Reading the directory catches that at build time.
|
||||
const uninlined = readdirSync(join(here, 'src'))
|
||||
.filter((f) => f.endsWith('.js') && !CLIENT_SOURCES.includes(`src/${f}`));
|
||||
if (uninlined.length) {
|
||||
throw new Error(`src/*.js not in CLIENT_SOURCES, so never inlined: ${uninlined.join(', ')}`);
|
||||
}
|
||||
|
||||
const read = (relative) => readFileSync(join(here, relative), 'utf8');
|
||||
const readBytes = (relative) => readFileSync(join(here, relative));
|
||||
const sha256 = (text) => createHash('sha256').update(text).digest('hex');
|
||||
|
||||
// A literal </script> inside an inlined script would end the element early.
|
||||
@@ -82,8 +100,9 @@ html = fill(html, 'PAGE_CSS', read('src/page.css'));
|
||||
html = fill(html, 'XTERM_JS', escapeForScript(read('node_modules/@xterm/xterm/lib/xterm.js')));
|
||||
html = fill(html, 'ADDON_FIT_JS', escapeForScript(read('node_modules/@xterm/addon-fit/lib/addon-fit.js')));
|
||||
html = fill(html, 'CLIENT_JS', escapeForScript(client));
|
||||
html = fill(html, 'FAVICON_B64', readBytes('src/favicon.png').toString('base64'));
|
||||
|
||||
const manifest = MANIFEST_SOURCES.map((file) => `${sha256(read(file))} ${file}\n`).join('');
|
||||
const manifest = MANIFEST_SOURCES.map((file) => `${sha256(readBytes(file))} ${file}\n`).join('');
|
||||
html = html.replace(
|
||||
'<!--BUILD_STAMP-->',
|
||||
() =>
|
||||
|
||||
+5
-1
@@ -10,7 +10,11 @@
|
||||
<meta name="color-scheme" content="dark">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<title>dejima</title>
|
||||
<!-- Generated by web/build.mjs - do not edit. Sources: f8a5205278ef77485e319cdefff6cbeeb027f201ab27266b02703a9eb570c608 -->
|
||||
<!-- Inlined like everything else on this page: ttyd serves one file and answers
|
||||
no path a /favicon.ico could be fetched from, so a link to one would 404 and
|
||||
the tab would fall back to the generic icon. -->
|
||||
<link rel="icon" type="image/png" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGUAAABlCAYAAABUfC3PAAAACXBIWXMAAAsSAAALEgHS3X78AAAK9UlEQVR4nO2db0wb5x3Hv6BAa2w0ggcjJlHsOHYkYgs8oIFIThMsUTlCJJsEFW/CgjJpaFsi7VWFtBfTVNZXUYJU5Q1K11ckIK0KQ0JDcrTWVU0FFCMcpJxLMQoYhOuEyf+K3XV74dxxZ5/xnX1nH+E+b+D85+6xPzzP7+653++hBAACxNRlyEgBd42xa7ckQEx9BuB6sVsjAwDYBaArhSxESlQBaCotditk0pGlSBBZigSRpUgQWYoEkaVIEFmKBJGlSBBZigSRpUgQWYoEkaVIkGPFbgBXvGt+hCMxuJdXAQBbO6+wvfOS9bUqpQIGnQYAcFangUqlgMWkL1hb80WSUrxrfriXV+Fd8+Pb7zbhXfPz3odz1pP2WF1tNQxnNDDoNGgy6yUrqiRATP2v2I0gJSx6Vlm/TDEx6DSwtpmkJOlK0aR41/yYdszBOfss4zBEp1KtRKW6AupTVShXlFHbbOzFEgi+2AUAbBEBxvZBqJQKWNtMsF44D4tZD5VSwe9DCUNhpXAVUV5RBo2xBuqTVcmfr0XkyxYRwPcbuwi++A+2iABCwciBr7fbWmG9cB7WNlPex+aB+FLCkRimHfMYn3QeKELbpMEJYw0loxCEglFsEQH4iQB8S5uIRxOsr1MpFbDbWtDbfQl1tcfFbpZ4UhY9q5h2zGPaMcf6fHlFGbSN9dA2aaAx1gjSE/JliwjAt+SHz+3P2IssZj3sHa2w21rEaobwUqYd85iY/CLjGZO2SQNtYz2M7aeFPKzgbBEBPHetZ+xBdbXVuGprQU+3VejYI5yUacc8Ho7NsA5RlWolTLazONeulUSP4AvhWgfh8sFPBNKeUykV6O22wm5rFWpoy1/KxKQTD8dmEI7E0p7TNmlg7jDghLEmn0NIhlAwioWplYy9x25rxe1b3fn2nNylZOoZZKxo7mrIeMp62InHElh2eEG41tNiD9lz8hjW+EtZ9Kxi+N5jVhnmDgPMNsOhHKJyhXCtY2FqhVXOQF8nerqtfHfJXcr2ziuMjD5Ju+I+qjJSySTHoNPgj7+9xme2gJuUTHHDbDOguavhSMtIZfmpFwtTK2kxp6fbioG+Ti5DWnYp0455DN9/xHhMY6zBu/2tb2zMyJd4LIGFqRUsO7yMx+22VgzdeT/b269knSVO7R2dgxehbdTwbeeRolxRhvaeRhjbT2PmgYsa0tjOUNnIepMrdd5H7h3cUZ+swl4sTm1bL3CbQ8sqpa72OEPMsuPbHJp3NCFc61RsIefPuMDpdrC9Y39nhMuHeIx94k6GCeHyUb/zmSvjJMXaZkJdbTW1/Zx2MBl2QsEoY1qmt/sS5/dyTpy4SjPtkYewrHie7p95Wcx6XvNinKXYba3U76FgBFssk3MySeKxBGM0sXe0ZnwtG5yl1NUeZ4h57lrndaCjhM/tzynAk/DK+6LvXA74mVmmDV0qpQLTjnle7+clxWLSywE/C8GNXUaSxvbOSwzffwR73585Z+rwzpDspc16ygE/nUzXceFIDCOjk5z2wVsKfQiTAz6TeCwB39Imtf3BL3/AjXN71Pb2zktOQxlvKcnAJQd8NugBvl75E24Y9/CB5Qdc1+1PtTwcm8m6n5wSvOWAzw49wF/X7X8nv6L9ziXxMCcpqQE/dYr6KJIa4Om947O1/ftNXBL7ci6FoAd8Qh7CGAHedjKBeuVPAIBQogSOzX0p9HnETOQsJTXg+5b4Z8a/KaQGePrQ5dgoQyheAiCZK8alp+RcCkEGfDIDknD5CnLzK7ixi3g0gVAwmjGLsbyiDD9/nfpaiPSm5y4f4/bvZqQUm5FSzO0cw8eet6jHr3K8ss+rPqWn20pJSaZ6RgW9CUbP9U0ds/lQqVZCfepnouUqp16vffTN2/jom7fTXkc/az2IvKQYdMkCHDJFlXD50NzVkM8uEdzYBeFaPzCfly+hYCQ5xLqT7aTnMefbu7lk7wPJP2CuM8V5V3L1dF+iEisI13pOUshZVcK1zrk3vFP7I1pr/8v63GYkGVzJsTzteNEECJcPhMuH8ooynGvXwtRhyKmX06/TLGY9rBdMVIVBMoac552tn7cUa9t5qEYVCEdiVMDn+teXLQ0USH5Qi0mPJrMet4ceUI//3rSH1tof2febcsZz+9Y1AMlEwsXlVUYCQzyazHZcdnh5p9nGYwnG3cWbfZ2wmPS5JOAxyFsKWf3EJ+BvEQEsP/VSw0kqyWoqU1I4LU/K2maiJvX+8GUF/vFemDr1JAklSjD0tYLqJSqlgvqSyJ/OWQ+cXz+Dc9bDEORzJ8sg1KeqYO4wZK0MoE/I1tVWC1aeJ0ghKteAv0UEsDC1wpq9TpYWHJS9frOvk5ISipfg1/9S4YYxjnde95jNSCk+9ryFzcj+mf5AX2fafqxtJljbTAjf6oZz9lla6UbwxS7+/ekcFqZW0NzVkFEOPcD35tk76AhWCjFw5y71wZq7Ghix5SAZfItw2JIDM8Ex+Q3AwUVOlWplmpwtIoB/3v18v11jfxWqTkXY+hTyyyqvKMNv7l7LKoMcg/ninPVg+P7jA5PbBvo6cZOll2Rje+cVHo7NHChH26TBV+NLVDzhI58DwkkJR2LouTVMfVHlFWUZazjstpa8x1+ylnLaMUf1UCFrE7d3XmF88gtMO+bT5Kd+tpHhQSHLvYUtrxu+/zhjjaPd1oqBvk7BCzkXPavUWZnFrMfIh4OC7j8ciWFi0onxSSdrz6yrrcbE6JCQh8yeS8wHesAnEUtGoVApFbj5us6ETY6QAZ5EUCkGnQY93Va4l1dx9kz9oZaRSqoc56wHdb+oFqVKWPC1WcgLtTcVUk4uJxFckZeWkiCyFAkiS5EgshQJIon1vrgQjsSodcHoq+J5v/MzTlEt5uRFHLk63lmdBoYz9YfpLLB4631xwbvmx5ezHjhnPTmtjkeHvLdhMekLvVQUX6QnJRyJsc7cCkmuN58KhHSkZJvOAJITgieMNahUV0Dz+kaUirZCXpy2At73G7sIB6MIvthlnRAlkeCMgzSkZFt0R9tYT8nIhXgskVxoze3PeJeTx+IDYlNcKd41P/5271HaMEUuLWJs1wpeIh6PJeBz+zOupzJ05/1ix5ziSZl2zGNk9AmjdxR6nRffkh+u8aU0OUXuNcWRwjbFX8x1XtjWUzHoNBgZHiyGmMJKCUdiuD30gDFclVeU4b3fXSz6Qm2hYBSffzrHOClQKRUYGR6kVgMvEIWTwiZEY6xB5+BFSa2C5JpYYlQRFEHMlYJMs7AJMbZr0fWndyUlBADaexpxuX8/vZSt7WJTECnD9x+nCbncL9oSsnljbD+dJmbow79zXoUoX0SX8snYDKMqVupCSFLFbO+8ZGRoiomoUhY9q4wav8MihCRVjHfNj5HRJ6IfVzQpZJcnUZ+qwsXeRrEOJxrG9tMwtmup7YlJJxY9q6IeUzQpI6OTjBywy/0tkgvqXLnc3wL1qf2aluF7j0U9nihSkimg+xeHzV0NBfunAmJBH3a3d17iEw6l17kiihR6gzXGGpg7DGIcpqCoT1Yx8qMPms3OF8FTjMgaEJJQMIopWiL0YWaPtl4AeatBjFQjwaVMTDoZ22Rp25vIuEhSBB++CjxPVFQMZ8T5rKLMfYl9yigVDDqNGLPIwiZ4k0jkv8AdWuS8LwkiS5EgshQJIkuRILIUCSJLkSCyFAkiS5EgshQJIkuRILIUCVIKwFfsRsgw2D0G4C8A+ovdEhkAgLvG2OX+Pzk3fgySn1OHAAAAAElFTkSuQmCC">
|
||||
<!-- Generated by web/build.mjs - do not edit. Sources: 0cc964e9a08f82488c268b4e95b19542a42c5ada3b927e7fac62f7acc46bde94 -->
|
||||
<style>
|
||||
/**
|
||||
* Copyright (c) 2014 The xterm.js authors. All rights reserved.
|
||||
|
||||
+3
-1
@@ -1,5 +1,7 @@
|
||||
63b33d784dd94f6df18f63043a5f3734a306e1ea01d7ddbc285d53c1b7435cf2 build.mjs
|
||||
f6f6611d0b3eacc39d10add9c4bbc56d4b3a70a47f022351a1c6eab68cd66e71 package-lock.json
|
||||
613d28096b1bed66f347590663f5f0136fb7f61a4578066fa2ed9e788ff9567a src/config.js
|
||||
18a303a9b2bb95504e25e954c82e542da96702cf39a3499e9c9fd50e5160f39f src/favicon.png
|
||||
f690fd9d35975bc977e3d323595a2a4aa1fca6ed50f213602f08adec3f5b2c92 src/gestures.js
|
||||
e75dc9d5e311a3d9bf9dc373b651ad506afbd7a7cc7ad54aac6fdad94276b0a4 src/keyboard.js
|
||||
2a070f63702b95b0c62963ec6b9ec4fa2b0b89b7553a8a889d68cdf3506f9ce3 src/keys.js
|
||||
@@ -10,6 +12,6 @@ cb5e33c0fa117176d9073bd9019f78b65bfe5535833bf1765ee542c700d67f1f src/osc52.js
|
||||
94229c981f6287162d6ef774f71ef41d941494595f76f16bb742811ca32a767f src/page.css
|
||||
e02823ce9d5d57615a0a8c0c7b91611932202758048b7eadabe4f1054aaf1a9f src/protocol.js
|
||||
aac82e88c6a65617818c6589091cbb8c3bab50e82e98aabf8b583cbb3dfda7e7 src/sgr.js
|
||||
dab361cf748e6d530099f79ccd3f407fcc493f81dd4afeda227376a8c9e9b81f src/template.html
|
||||
34e34a8d258b8a7d2afde03287681b99d839994936c398ba5c7165a3ed7aec93 src/template.html
|
||||
3c8e5520b694693126097d9fe17f89ff23ba15adb8532521c647907edba20215 src/toolbar.js
|
||||
864405fe11e5456bdb98ea8c841cb04346d5a38aabec897101defa9612d89443 src/viewport.js
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.8 KiB |
@@ -10,6 +10,10 @@
|
||||
<meta name="color-scheme" content="dark">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<title>dejima</title>
|
||||
<!-- Inlined like everything else on this page: ttyd serves one file and answers
|
||||
no path a /favicon.ico could be fetched from, so a link to one would 404 and
|
||||
the tab would fall back to the generic icon. -->
|
||||
<link rel="icon" type="image/png" href="data:image/png;base64,/*FAVICON_B64*/">
|
||||
<!--BUILD_STAMP-->
|
||||
<style>
|
||||
/*XTERM_CSS*/
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
Git workflow - do this even though nothing stops you from doing otherwise:
|
||||
- Do new work in a git worktree for the feature branch, not by switching
|
||||
branches in place in the mounted checkout - that's the user's actual
|
||||
working copy, and changing its branch out from under them is disruptive
|
||||
even if you switch it back.
|
||||
<!-- core: worktree -->
|
||||
- Push that branch to the remote when the work is complete and its tests
|
||||
pass. Do that WITHOUT asking first: this workflow's destination is known,
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
Git workflow - do this even though nothing stops you from doing otherwise:
|
||||
- Do new work in a git worktree for the feature branch, not by switching
|
||||
branches in place in the mounted checkout - that's the user's actual
|
||||
working copy, and changing its branch out from under them is disruptive
|
||||
even if you switch it back.
|
||||
<!-- core: worktree -->
|
||||
- Push that branch to the remote, then run `{{pr_command}} create` from
|
||||
the worktree. Never push directly to {{blocked_branches}}, on any remote,
|
||||
|
||||
Reference in New Issue
Block a user